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 fetchMarquees({ String method = defaultMethod, String path = defaultPath, Map? queryParameters, Map? body, String? storeId, String? keyword, int page = 1, int limit = 100, }) async { final params = { '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 response; if (upperMethod == 'GET') { response = await _dio.get( path, queryParameters: params, options: options, ); } else { response = await _dio.request( 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 fetchMarqueeDetail({ String method = defaultMethod, required String path, Map? queryParameters, Map? 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 [], total: 0, ); } Map? envelope; dynamic payload; if (raw is Map) { 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 listRaw; int? total; if (payload is Map) { if (payload['list'] is List) { listRaw = payload['list'] as List; 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; 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 data = []; for (final item in listRaw) { if (item is! Map) { 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 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 [], error: error, errorCode: errorCode, businessCode: businessCode, ); } /// 解析后的字幕列表。 final List 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, }