Files
CloudHome-Ops/lib/services/marquee_web_source.dart

344 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:dio/dio.dart';
import '../models/marquee_message.dart';
import '../utils/constants.dart';
import '../config/app_config.dart';
import 'api_client.dart';
/// Web 端滚动字幕数据源。
///
/// 专门负责从 Web 后台 `/theme/scrollingText/*` 接口请求、接收并解析滚动字幕数据。
/// 支持通过参数配置请求地址(URL / path)与请求方式(GET / POST),
/// 内置网络异常、超时、HTTP 错误、解析失败等错误处理,
/// 将原始响应封装为结构化的 [MarqueeFetchResult]。
class MarqueeWebSource {
/// [baseUrl] 可为空;为空时使用 [AppConfig] 当前环境的默认地址。
/// [dio] 可注入,便于测试或复用已挂载拦截器的实例。
MarqueeWebSource({
Dio? dio,
String? baseUrl,
this.timeout = AppConstants.apiTimeout,
}) : _dio = dio ?? _createDio(baseUrl: baseUrl, timeout: timeout);
final Dio _dio;
/// 请求超时时间。
final Duration timeout;
/// 默认列表接口路径。与 Web 后台滚动字幕管理页保持一致。
static const String defaultPath = '/theme/scrollingText/list';
/// 默认请求方式。
static const String defaultMethod = 'GET';
static Dio _createDio({String? baseUrl, required Duration timeout}) {
return Dio(
BaseOptions(
baseUrl: baseUrl ?? AppConfig.apiFullBaseUrl,
connectTimeout: timeout,
receiveTimeout: timeout,
sendTimeout: timeout,
headers: {'Content-Type': 'application/json; charset=utf-8'},
responseType: ResponseType.json,
),
);
}
/// 从 Web 端获取滚动字幕列表。
///
/// [method] 支持 'GET' / 'POST' / 'PUT' 等;默认 'GET'。
/// [path] 支持绝对 URL(以 http:// 或 https:// 开头)或相对路径。
/// [queryParameters] 用于 GET / POST 的查询参数(如 page、limit、orgId)。
/// [body] 用于 POST / PUT 的请求体。
/// [storeId] 与 [keyword] 为业务常用过滤条件,会自动合并到 queryParameters。
/// [page] / [limit] 默认 1 / 100。
///
/// 返回 [MarqueeFetchResult],内部包含解析后的 [MarqueeMessage] 列表、总数、错误码等。
Future<MarqueeFetchResult> fetchMarquees({
String method = defaultMethod,
String path = defaultPath,
Map<String, dynamic>? queryParameters,
Map<String, dynamic>? body,
String? storeId,
String? keyword,
int page = 1,
int limit = 100,
}) async {
final params = <String, dynamic>{
'page': page,
'limit': limit,
if (storeId != null && storeId.isNotEmpty) 'orgId': storeId,
if (keyword != null && keyword.isNotEmpty) 'content': keyword,
...?queryParameters,
};
final options = Options(method: method.toUpperCase());
try {
final upperMethod = method.toUpperCase();
final Response<dynamic> response;
if (upperMethod == 'GET') {
response = await _dio.get<dynamic>(
path,
queryParameters: params,
options: options,
);
} else {
response = await _dio.request<dynamic>(
path,
queryParameters: params,
data: body,
options: options,
);
}
return _parseResponse(response.data);
} on DioException catch (e) {
return MarqueeFetchResult.failure(
error: _formatDioError(e),
errorCode: _errorCodeFromDio(e),
);
} on FormatException catch (e) {
return MarqueeFetchResult.failure(
error: '数据解析失败:${e.message}',
errorCode: MarqueeErrorCode.parseFailed,
);
} on Exception catch (e) {
return MarqueeFetchResult.failure(
error: '请求异常:$e',
errorCode: MarqueeErrorCode.unknown,
);
}
}
/// 通用单条数据请求封装(用于新增/编辑/删除后回显或详情查询)。
Future<MarqueeFetchResult> fetchMarqueeDetail({
String method = defaultMethod,
required String path,
Map<String, dynamic>? queryParameters,
Map<String, dynamic>? body,
}) async {
return fetchMarquees(
method: method,
path: path,
queryParameters: queryParameters,
body: body,
page: 1,
limit: 1,
);
}
/// 解析 Web 端响应为结构化结果。
///
/// 兼容三种常见响应形态:
/// 1. 后端信封 `{code:0, msg:'ok', data:{list:[...], totalCount:5}}`
/// 2. 后端信封 `{code:0, msg:'ok', data:[...]}`
/// 3. 原生数组 `[...]`
/// 4. 解包后的分页对象 `{list:[...], totalCount:5}`
MarqueeFetchResult _parseResponse(dynamic raw) {
if (raw == null) {
return MarqueeFetchResult.success(
data: const <MarqueeMessage>[],
total: 0,
);
}
Map<String, dynamic>? envelope;
dynamic payload;
if (raw is Map<String, dynamic>) {
envelope = raw;
payload = raw['data'] ?? raw;
} else {
payload = raw;
}
// 处理业务错误码(信封中存在 code 且非成功)。
if (envelope != null && envelope.containsKey('code')) {
final code = envelope['code'];
final success = code == 0 || code == 200 || code == '0' || code == '200';
if (!success) {
final msg = envelope['msg']?.toString() ??
envelope['message']?.toString() ??
'请求失败(业务码:$code)';
return MarqueeFetchResult.failure(
error: msg,
errorCode: MarqueeErrorCode.businessError,
businessCode: code?.toString(),
);
}
}
// 解析列表与总数。
List<dynamic> listRaw;
int? total;
if (payload is Map<String, dynamic>) {
if (payload['list'] is List) {
listRaw = payload['list'] as List<dynamic>;
final t = payload['totalCount'] ?? payload['total'];
if (t is int) total = t;
if (t is String) total = int.tryParse(t);
} else if (payload['rows'] is List) {
listRaw = payload['rows'] as List<dynamic>;
final t = payload['totalCount'] ?? payload['total'];
if (t is int) total = t;
if (t is String) total = int.tryParse(t);
} else {
// 兜底:整个 data 就是单条对象。
listRaw = [payload];
}
} else if (payload is List) {
listRaw = payload;
} else {
return MarqueeFetchResult.failure(
error: '返回数据格式不正确',
errorCode: MarqueeErrorCode.parseFailed,
);
}
final List<MarqueeMessage> data = [];
for (final item in listRaw) {
if (item is! Map<String, dynamic>) {
continue;
}
try {
data.add(MarqueeMessage.fromJson(item));
} on FormatException catch (e) {
return MarqueeFetchResult.failure(
error: '单条字幕解析失败:${e.message}',
errorCode: MarqueeErrorCode.parseFailed,
);
} on Exception catch (e) {
return MarqueeFetchResult.failure(
error: '单条字幕解析异常:$e',
errorCode: MarqueeErrorCode.parseFailed,
);
}
}
return MarqueeFetchResult.success(
data: data,
total: total ?? data.length,
);
}
String _formatDioError(DioException e) {
final inner = e.error;
if (inner is ApiException && inner.message.isNotEmpty) {
return inner.message;
}
final response = e.response;
if (response != null && response.statusCode != null) {
return '服务器响应异常:HTTP ${response.statusCode}';
}
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
return '连接超时,请检查网络';
case DioExceptionType.connectionError:
return '网络连接失败,请检查网络';
case DioExceptionType.cancel:
return '请求已取消';
case DioExceptionType.badCertificate:
return '服务器证书校验失败';
case DioExceptionType.badResponse:
return '服务器返回异常响应';
case DioExceptionType.unknown:
default:
final text = e.message;
if (text != null && text.isNotEmpty) return text;
return '网络请求失败';
}
}
MarqueeErrorCode _errorCodeFromDio(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
return MarqueeErrorCode.timeout;
case DioExceptionType.connectionError:
return MarqueeErrorCode.networkError;
case DioExceptionType.badCertificate:
return MarqueeErrorCode.certificateError;
case DioExceptionType.badResponse:
return MarqueeErrorCode.httpError;
case DioExceptionType.cancel:
return MarqueeErrorCode.cancelled;
default:
return MarqueeErrorCode.unknown;
}
}
}
/// Web 端滚动字幕请求结果。
class MarqueeFetchResult {
const MarqueeFetchResult({
required this.data,
this.total = 0,
this.hasMore = false,
this.error,
this.errorCode,
this.businessCode,
});
factory MarqueeFetchResult.success({
required List<MarqueeMessage> data,
int total = 0,
bool hasMore = false,
}) {
return MarqueeFetchResult(
data: data,
total: total,
hasMore: hasMore,
);
}
factory MarqueeFetchResult.failure({
required String error,
MarqueeErrorCode errorCode = MarqueeErrorCode.unknown,
String? businessCode,
}) {
return MarqueeFetchResult(
data: const <MarqueeMessage>[],
error: error,
errorCode: errorCode,
businessCode: businessCode,
);
}
/// 解析后的字幕列表。
final List<MarqueeMessage> data;
/// 服务端总数(用于分页)。
final int total;
/// 是否还有下一页。
final bool hasMore;
/// 错误描述(成功时为 null)。
final String? error;
/// 错误码(分类)。
final MarqueeErrorCode? errorCode;
/// 后端业务错误码(非 HTTP 状态码)。
final String? businessCode;
bool get isSuccess => error == null && errorCode == null;
}
/// Web 端滚动字幕错误分类。
enum MarqueeErrorCode {
networkError,
timeout,
httpError,
businessError,
parseFailed,
certificateError,
cancelled,
unknown,
}