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