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

344 lines
10 KiB
Dart
Raw Permalink Normal View History

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,
}