344 lines
10 KiB
Dart
344 lines
10 KiB
Dart
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,
|
||
}
|