ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
667
lib/services/api_client.dart
Normal file
667
lib/services/api_client.dart
Normal file
@@ -0,0 +1,667 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../utils/constants.dart';
|
||||
import '../utils/credential_storage.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 内存态令牌存储。
|
||||
///
|
||||
/// token / refreshToken 仅保存在内存中(不引入 shared_preferences,
|
||||
/// 避免鸿蒙端插件兼容问题),应用重启后需重新登录。
|
||||
class TokenStore {
|
||||
TokenStore._();
|
||||
|
||||
/// 全局单例。
|
||||
static final TokenStore instance = TokenStore._();
|
||||
|
||||
/// 访问令牌。
|
||||
String? token;
|
||||
|
||||
/// 刷新令牌。
|
||||
String? refreshToken;
|
||||
|
||||
/// 保存登录 / 刷新后的令牌对。
|
||||
void save({required String token, String? refreshToken}) {
|
||||
this.token = token;
|
||||
if (refreshToken != null && refreshToken.isNotEmpty) {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空令牌(登出或会话失效时调用)。
|
||||
void clear() {
|
||||
token = null;
|
||||
refreshToken = null;
|
||||
}
|
||||
|
||||
/// 是否持有访问令牌。
|
||||
bool get hasToken => token != null && token!.isNotEmpty;
|
||||
}
|
||||
|
||||
/// 业务异常:后端统一响应 `{code, msg, data}` 中 code 非成功值时抛出。
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.code, this.message);
|
||||
|
||||
/// 业务错误码。
|
||||
final int code;
|
||||
|
||||
/// 错误信息。
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// 把 DioException / 业务异常转成用户可读的简短文本。
|
||||
///
|
||||
/// 优先展示后端返回的 msg,其次网络状态,最后兜底提示。
|
||||
String formatApiError(Object? error, {String fallback = '请求失败,请稍后重试'}) {
|
||||
if (error is ApiException && error.message.isNotEmpty) {
|
||||
return error.message;
|
||||
}
|
||||
if (error is DioException) {
|
||||
final inner = error.error;
|
||||
if (inner is ApiException && inner.message.isNotEmpty) {
|
||||
return inner.message;
|
||||
}
|
||||
final response = error.response;
|
||||
if (response != null && response.statusCode != null) {
|
||||
return '服务器响应异常:HTTP ${response.statusCode}';
|
||||
}
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
return '连接超时,请检查网络';
|
||||
case DioExceptionType.connectionError:
|
||||
return '网络连接失败,请检查网络';
|
||||
case DioExceptionType.cancel:
|
||||
return '请求已取消';
|
||||
case DioExceptionType.badCertificate:
|
||||
return '服务器证书校验失败';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
final text = error?.toString();
|
||||
if (text != null && text.isNotEmpty && text != 'null') {
|
||||
return text;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// 判断异常是否表示登录态失效(token 过期 / 401)。
|
||||
///
|
||||
/// 后端常以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」而非 HTTP 401,
|
||||
/// 拦截器已统一把这类场景包装为 [ApiException](401, '登录已过期,请重新登录')。
|
||||
///
|
||||
/// UI 层捕获到此类异常时,应跳过普通错误提示、改由路由重定向引导重新登录,
|
||||
/// 避免把「登录已过期」之类的底层文案直接暴露给用户(如「查询失败:登录已过期」)。
|
||||
bool isUnauthorizedError(Object? error) {
|
||||
ApiException? apiEx;
|
||||
if (error is ApiException) {
|
||||
apiEx = error;
|
||||
} else if (error is DioException && error.error is ApiException) {
|
||||
apiEx = error.error as ApiException;
|
||||
}
|
||||
if (apiEx == null) return false;
|
||||
return apiEx.code == 401 || apiEx.message.contains('登录已过期');
|
||||
}
|
||||
|
||||
/// HTTP 客户端(Dio 单例)。
|
||||
///
|
||||
/// - [AppConstants.useMock] = false:挂载 [ApiInterceptor],对接真实「爱视后台」,
|
||||
/// 自动附加 Bearer 令牌与防缓存时间戳、解包 `{code,msg,data}`、
|
||||
/// 401 时用 refreshToken 刷新并重试一次;
|
||||
/// - [AppConstants.useMock] = true:挂载 [MockInterceptor] 本地返回示例数据。
|
||||
class ApiClient {
|
||||
ApiClient._() {
|
||||
_dio = _buildDio();
|
||||
}
|
||||
|
||||
/// 构建 Dio 实例(拦截器配置集中于此,[reconfigure] 复用)。
|
||||
///
|
||||
/// baseUrl 取自 [AppConfig],支持运行时切换环境后端地址。
|
||||
Dio _buildDio() {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.apiFullBaseUrl,
|
||||
connectTimeout: AppConstants.apiTimeout,
|
||||
receiveTimeout: AppConstants.apiTimeout,
|
||||
headers: {'Content-Type': 'application/json; charset=utf-8'},
|
||||
),
|
||||
);
|
||||
if (AppConstants.useMock) {
|
||||
// Mock 在前:直接本地 resolve,后续 ApiInterceptor 仍负责解包信封。
|
||||
dio.interceptors.add(MockInterceptor());
|
||||
}
|
||||
dio.interceptors.add(ApiInterceptor(dio));
|
||||
return dio;
|
||||
}
|
||||
|
||||
/// 运行时切换后端地址后重建 Dio 单例(保留拦截器配置)。
|
||||
///
|
||||
/// 允许重复调用,用于环境切换、语言切换后刷新网络层等场景。
|
||||
/// 返回重建结果与可能的错误信息,避免直接抛异常导致 UI 崩溃。
|
||||
({bool success, String? error}) reconfigure() {
|
||||
try {
|
||||
_dio = _buildDio();
|
||||
return (success: true, error: null);
|
||||
} catch (e) {
|
||||
return (success: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局单例。
|
||||
static final ApiClient instance = ApiClient._();
|
||||
|
||||
/// Dio 实例。使用 [late](非 [late final])以支持 [reconfigure] 重复重建。
|
||||
late Dio _dio;
|
||||
|
||||
/// 会话失效回调(401 刷新失败后触发),由认证层注册以清空登录态。
|
||||
void Function()? onUnauthorized;
|
||||
|
||||
/// 获取 Dio 实例。
|
||||
Dio get dio => _dio;
|
||||
}
|
||||
|
||||
/// 真实接口拦截器:鉴权头、防缓存时间戳、信封解包、401 刷新重试。
|
||||
class ApiInterceptor extends Interceptor {
|
||||
ApiInterceptor(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// 刷新接口路径(避免 401 处理时对自身重试)。
|
||||
static const String _refreshPath = '/sys/refresh';
|
||||
|
||||
/// 标记已重试过的请求,防止循环刷新。
|
||||
static const String _retriedFlag = '__retried_after_refresh';
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
// 鉴权:Bearer token(不使用 Cookie)。
|
||||
final token = TokenStore.instance.token;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
// 防缓存:仅 GET 在 query 上附加毫秒时间戳 t。
|
||||
// 注意:POST/PUT 写入请求不缓存,且绝不能把 t 注入业务 body——
|
||||
// 后端(如 CodeIgniter)按全字段 insert 时会因未知列 `t` 报 500,
|
||||
// 导致「列表能读、保存失败」。故写入请求保持 body 纯净。
|
||||
if (options.method == 'GET') {
|
||||
options.queryParameters['t'] =
|
||||
DateTime.now().millisecondsSinceEpoch.toString();
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onResponse(
|
||||
Response response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) async {
|
||||
final body = response.data;
|
||||
// 非信封格式(如二进制流)直接放行。
|
||||
if (body is! Map<String, dynamic> || !body.containsKey('code')) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
final code = _asInt(body['code']);
|
||||
final msg = body['msg']?.toString() ?? '请求失败';
|
||||
|
||||
// 成功码:0 / 200,解包 data。
|
||||
if (code == 0 || code == 200) {
|
||||
response.data = body['data'];
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 业务 401 或后端以普通业务码返回 token 失效:尝试刷新令牌并重试一次。
|
||||
final isTokenExpired = _isSessionExpiredMessage(msg);
|
||||
if ((code == 401 || isTokenExpired) &&
|
||||
response.requestOptions.path != _refreshPath &&
|
||||
response.requestOptions.extra[_retriedFlag] != true) {
|
||||
final retried = await _refreshAndRetry(response.requestOptions, handler);
|
||||
if (retried) {
|
||||
return;
|
||||
}
|
||||
// 刷新失败:已触发 _notifyUnauthorized() 清空登录态并跳转登录页,
|
||||
// 将错误信息统一为更友好的文案,避免把原始 DioException 抛给 UI。
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
error: ApiException(401, '登录已过期,请重新登录'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
error: ApiException(code, msg),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
// HTTP 401:同样尝试刷新令牌并重试一次。
|
||||
if (err.response?.statusCode == 401 &&
|
||||
err.requestOptions.path != _refreshPath &&
|
||||
err.requestOptions.extra[_retriedFlag] != true) {
|
||||
final retried = await _refreshAndRetry(err.requestOptions, handler);
|
||||
if (retried) {
|
||||
return;
|
||||
}
|
||||
// _refreshAndRetry 失败路径内部已调用 _notifyUnauthorized,此处不重复调用。
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
// 已解包出的业务异常直接透传,其余包装网络错误信息。
|
||||
if (err.error is ApiException) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
/// 刷新令牌并重试原请求;成功返回 true。
|
||||
Future<bool> _refreshAndRetry(
|
||||
RequestOptions options,
|
||||
dynamic handler,
|
||||
) async {
|
||||
final refreshed = await _tryRefreshToken();
|
||||
if (!refreshed) {
|
||||
_notifyUnauthorized();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
options.extra[_retriedFlag] = true;
|
||||
// 重新附加新 token。
|
||||
final token = TokenStore.instance.token;
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final retryResponse = await _dio.fetch<dynamic>(options);
|
||||
if (handler is ResponseInterceptorHandler) {
|
||||
handler.resolve(retryResponse);
|
||||
} else if (handler is ErrorInterceptorHandler) {
|
||||
handler.resolve(retryResponse);
|
||||
}
|
||||
return true;
|
||||
} catch (_) {
|
||||
_notifyUnauthorized();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 进行中的刷新请求:并发 401 只发起一次 /sys/refresh,其余共享结果。
|
||||
Future<bool>? _refreshing;
|
||||
|
||||
/// 用 refreshToken 换新 token(带并发锁)。
|
||||
Future<bool> _tryRefreshToken() {
|
||||
return _refreshing ??= _doRefreshToken().whenComplete(() {
|
||||
_refreshing = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// 实际刷新逻辑。使用独立 Dio 实例,避免进入拦截器循环。
|
||||
Future<bool> _doRefreshToken() async {
|
||||
final refreshToken = TokenStore.instance.refreshToken;
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final rawDio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.apiFullBaseUrl,
|
||||
connectTimeout: AppConstants.apiTimeout,
|
||||
receiveTimeout: AppConstants.apiTimeout,
|
||||
headers: {'Content-Type': 'application/json; charset=utf-8'},
|
||||
),
|
||||
);
|
||||
final response = await rawDio.post<Map<String, dynamic>>(
|
||||
_refreshPath,
|
||||
data: {
|
||||
'refreshToken': refreshToken,
|
||||
't': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
},
|
||||
);
|
||||
final body = response.data;
|
||||
if (body == null) {
|
||||
return false;
|
||||
}
|
||||
final code = _asInt(body['code']);
|
||||
if (code != 0 && code != 200) {
|
||||
return false;
|
||||
}
|
||||
final data = body['data'];
|
||||
if (data is Map<String, dynamic> && data['token'] != null) {
|
||||
final newToken = data['token'].toString();
|
||||
final newRefreshToken = data['refreshToken']?.toString();
|
||||
TokenStore.instance.save(
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
);
|
||||
// 刷新后的新令牌必须同步持久化,否则切换语言/进程重启后
|
||||
// 会恢复到旧令牌,导致「token 失效」报错。
|
||||
await CredentialStorage.instance.saveSession(
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话失效:清空令牌并通知认证层(跳转登录页)。
|
||||
void _notifyUnauthorized() {
|
||||
TokenStore.instance.clear();
|
||||
ApiClient.instance.onUnauthorized?.call();
|
||||
}
|
||||
|
||||
/// 判断后端返回的 msg 是否为 token/会话失效类提示。
|
||||
///
|
||||
/// 爱视后台有时以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」,
|
||||
/// 而非 HTTP 401;此处统一识别并走刷新/登出流程。
|
||||
static bool _isSessionExpiredMessage(String msg) {
|
||||
final lower = msg.toLowerCase();
|
||||
if (!lower.contains('token') && !lower.contains('登录')) return false;
|
||||
return lower.contains('失效') ||
|
||||
lower.contains('过期') ||
|
||||
lower.contains('无效') ||
|
||||
lower.contains('请重新登录') ||
|
||||
lower.contains('未登录');
|
||||
}
|
||||
|
||||
static int _asInt(dynamic value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
return int.tryParse(value?.toString() ?? '') ?? -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock 拦截器(仅 [AppConstants.useMock] = true 时挂载)。
|
||||
///
|
||||
/// 返回与真实后端一致的 `{code, msg, data}` 信封,由 [ApiInterceptor] 统一解包。
|
||||
class MockInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) {
|
||||
// 模拟网络延迟。
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
handler.resolve(
|
||||
Response(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: _mockResponse(options),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _ok(Object? data) {
|
||||
return {'code': 0, 'msg': 'success', 'data': data};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mockResponse(RequestOptions options) {
|
||||
final path = options.path;
|
||||
|
||||
// 登录:POST /sys/login
|
||||
if (path == '/sys/login') {
|
||||
final body = options.data;
|
||||
if (body is Map &&
|
||||
body['username'] == AppConstants.mockAccount &&
|
||||
body['password'] == AppConstants.mockPassword) {
|
||||
return _ok({
|
||||
'token': 'mock-token-20240520',
|
||||
'expire': 7200,
|
||||
'refreshToken': 'mock-refresh-token',
|
||||
'refreshExpire': 604800,
|
||||
'orgId': 1,
|
||||
'orgType': 1,
|
||||
'superAdmin': true,
|
||||
'roleNames': ['运维管理员'],
|
||||
});
|
||||
}
|
||||
return {'code': 500, 'msg': '账号或密码错误', 'data': null};
|
||||
}
|
||||
|
||||
// 登出:POST /sys/logout
|
||||
if (path == '/sys/logout') {
|
||||
return _ok(null);
|
||||
}
|
||||
|
||||
// 验证码图片:GET /captcha.jpg?uuid=xxx
|
||||
// mock 模式下返回一个 1x1 透明 PNG 占位图。
|
||||
if (path == '/captcha.jpg') {
|
||||
return {'code': 0, 'msg': 'success', 'data': _transparentPng};
|
||||
}
|
||||
|
||||
// 当前用户:GET /sys/user/info
|
||||
if (path == '/sys/user/info') {
|
||||
return _ok({
|
||||
'username': AppConstants.mockAccount,
|
||||
'realName': '运维管理员',
|
||||
'roleNames': ['运维管理员'],
|
||||
});
|
||||
}
|
||||
|
||||
// 设备列表:GET /device/info/list
|
||||
if (path == '/device/info/list') {
|
||||
return _ok({'list': _mockDevices, 'totalCount': _mockDevices.length});
|
||||
}
|
||||
|
||||
// 设备详情:GET /device/info/info/{id}
|
||||
final deviceDetailMatch =
|
||||
RegExp(r'^/device/info/info/([A-Za-z0-9\-]+)$').firstMatch(path);
|
||||
if (deviceDetailMatch != null) {
|
||||
final id = deviceDetailMatch.group(1);
|
||||
for (final device in _mockDevices) {
|
||||
if (device['id'] == id) {
|
||||
return _ok(device);
|
||||
}
|
||||
}
|
||||
return {'code': 404, 'msg': '未找到设备:$id', 'data': null};
|
||||
}
|
||||
|
||||
// 设备指令:POST /device/command/sendByMac | sendByOrg
|
||||
if (path == '/device/command/sendByMac' ||
|
||||
path == '/device/command/sendByOrg') {
|
||||
return _ok(null);
|
||||
}
|
||||
|
||||
// 告警列表(仅 mock 模式使用;真实模式由设备列表推导):GET /alerts
|
||||
if (path == '/alerts') {
|
||||
return _ok(_mockAlerts);
|
||||
}
|
||||
|
||||
return {'code': 404, 'msg': '接口不存在:$path', 'data': null};
|
||||
}
|
||||
|
||||
/// mock 验证码占位图:1x1 透明 PNG(Base64)。
|
||||
static const String _transparentPng =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
|
||||
|
||||
/// 示例设备数据(真实 DeviceInfo 结构 + 演示用扩展字段:
|
||||
/// ip / location / os / uptimeSeconds / metrics)。
|
||||
static final List<Map<String, dynamic>> _mockDevices = [
|
||||
{
|
||||
'id': 'dev-001',
|
||||
'mac': 'AA:BB:CC:00:00:11',
|
||||
'name': '核心交换机-SW01',
|
||||
'deviceSn': 'SN-SW01-2024',
|
||||
'deviceUid': 'UID-0001',
|
||||
'status': 'online',
|
||||
'groupId': 'g-core',
|
||||
'lastSyncTime': '2024-05-20 09:30:00',
|
||||
'createTime': '2024-01-15 10:00:00',
|
||||
'ip': '10.0.1.11',
|
||||
'location': '华南机房-A区',
|
||||
'os': 'Huawei VRP V8',
|
||||
'uptimeSeconds': 1861272,
|
||||
'metrics': {
|
||||
'cpuUsage': 0.32,
|
||||
'memoryUsage': 0.41,
|
||||
'diskUsage': 0.55,
|
||||
'networkRxKbps': 1280.5,
|
||||
'networkTxKbps': 986.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'dev-002',
|
||||
'mac': 'AA:BB:CC:00:00:21',
|
||||
'name': '应用服务器-APP01',
|
||||
'deviceSn': 'SN-APP01-2024',
|
||||
'deviceUid': 'UID-0002',
|
||||
'status': 'warning',
|
||||
'groupId': 'g-app',
|
||||
'lastSyncTime': '2024-05-20 09:28:40',
|
||||
'createTime': '2024-01-16 14:20:00',
|
||||
'ip': '10.0.2.21',
|
||||
'location': '华南机房-B区',
|
||||
'os': 'CentOS Stream 9',
|
||||
'uptimeSeconds': 952133,
|
||||
'metrics': {
|
||||
'cpuUsage': 0.87,
|
||||
'memoryUsage': 0.92,
|
||||
'diskUsage': 0.63,
|
||||
'networkRxKbps': 4520.8,
|
||||
'networkTxKbps': 3890.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'dev-003',
|
||||
'mac': 'AA:BB:CC:00:00:31',
|
||||
'name': '数据库服务器-DB01',
|
||||
'deviceSn': 'SN-DB01-2024',
|
||||
'deviceUid': 'UID-0003',
|
||||
'status': 'online',
|
||||
'groupId': 'g-db',
|
||||
'lastSyncTime': '2024-05-20 09:29:55',
|
||||
'createTime': '2024-01-18 08:00:00',
|
||||
'ip': '10.0.2.31',
|
||||
'location': '华南机房-B区',
|
||||
'os': 'openEuler 22.03 LTS',
|
||||
'uptimeSeconds': 3654820,
|
||||
'metrics': {
|
||||
'cpuUsage': 0.45,
|
||||
'memoryUsage': 0.68,
|
||||
'diskUsage': 0.78,
|
||||
'networkRxKbps': 2310.4,
|
||||
'networkTxKbps': 1980.6,
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'dev-004',
|
||||
'mac': 'AA:BB:CC:00:00:42',
|
||||
'name': '存储节点-NAS02',
|
||||
'deviceSn': 'SN-NAS02-2024',
|
||||
'deviceUid': 'UID-0004',
|
||||
'status': 'offline',
|
||||
'groupId': 'g-store',
|
||||
'lastSyncTime': '2024-05-20 07:58:00',
|
||||
'createTime': '2024-02-01 09:00:00',
|
||||
'ip': '10.0.3.42',
|
||||
'location': '华北机房-C区',
|
||||
'os': 'TrueNAS SCALE 23.10',
|
||||
'uptimeSeconds': 0,
|
||||
'metrics': {
|
||||
'cpuUsage': 0.0,
|
||||
'memoryUsage': 0.0,
|
||||
'diskUsage': 0.0,
|
||||
'networkRxKbps': 0.0,
|
||||
'networkTxKbps': 0.0,
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'dev-005',
|
||||
'mac': 'AA:BB:CC:00:00:107',
|
||||
'name': '边缘网关-EDGE07',
|
||||
'deviceSn': 'SN-EDGE07-2024',
|
||||
'deviceUid': 'UID-0005',
|
||||
'status': 'online',
|
||||
'groupId': 'g-edge',
|
||||
'lastSyncTime': '2024-05-20 09:31:10',
|
||||
'createTime': '2024-03-05 16:40:00',
|
||||
'ip': '172.16.8.107',
|
||||
'location': '华东边缘节点',
|
||||
'os': 'OpenHarmony 4.1',
|
||||
'uptimeSeconds': 612345,
|
||||
'metrics': {
|
||||
'cpuUsage': 0.21,
|
||||
'memoryUsage': 0.35,
|
||||
'diskUsage': 0.48,
|
||||
'networkRxKbps': 320.7,
|
||||
'networkTxKbps': 288.3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/// 示例告警数据(仅 mock 模式使用)。
|
||||
static final List<Map<String, dynamic>> _mockAlerts = [
|
||||
{
|
||||
'id': 'alert-001',
|
||||
'deviceId': 'dev-002',
|
||||
'deviceName': '应用服务器-APP01',
|
||||
'title': '内存使用率过高',
|
||||
'message': '内存使用率达到 92%,超过阈值 85%,请尽快扩容或排查内存泄漏。',
|
||||
'level': 'critical',
|
||||
'occurredAt': '2024-05-20T09:12:30.000',
|
||||
'acknowledged': false,
|
||||
},
|
||||
{
|
||||
'id': 'alert-002',
|
||||
'deviceId': 'dev-002',
|
||||
'deviceName': '应用服务器-APP01',
|
||||
'title': 'CPU 使用率过高',
|
||||
'message': 'CPU 使用率达到 87%,超过阈值 80%。',
|
||||
'level': 'warning',
|
||||
'occurredAt': '2024-05-20T08:45:10.000',
|
||||
'acknowledged': true,
|
||||
},
|
||||
{
|
||||
'id': 'alert-003',
|
||||
'deviceId': 'dev-004',
|
||||
'deviceName': '存储节点-NAS02',
|
||||
'title': '设备离线',
|
||||
'message': '存储节点连续 10 分钟无心跳,请检查电源与网络连接。',
|
||||
'level': 'critical',
|
||||
'occurredAt': '2024-05-20T07:58:00.000',
|
||||
'acknowledged': false,
|
||||
},
|
||||
{
|
||||
'id': 'alert-004',
|
||||
'deviceId': 'dev-003',
|
||||
'deviceName': '数据库服务器-DB01',
|
||||
'title': '磁盘空间不足',
|
||||
'message': '磁盘使用率达到 78%,接近阈值 80%,建议清理归档日志。',
|
||||
'level': 'warning',
|
||||
'occurredAt': '2024-05-19T22:30:45.000',
|
||||
'acknowledged': true,
|
||||
},
|
||||
{
|
||||
'id': 'alert-005',
|
||||
'deviceId': 'dev-001',
|
||||
'deviceName': '核心交换机-SW01',
|
||||
'title': '端口流量波动',
|
||||
'message': '上联端口 GE0/0/1 出现短时流量尖峰,已自动恢复。',
|
||||
'level': 'info',
|
||||
'occurredAt': '2024-05-19T16:05:22.000',
|
||||
'acknowledged': true,
|
||||
},
|
||||
];
|
||||
}
|
||||
212
lib/services/app_service.dart
Normal file
212
lib/services/app_service.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/app_version.dart';
|
||||
import '../models/tv_app.dart';
|
||||
import 'api_client.dart';
|
||||
import '../utils/session_org_scope.dart';
|
||||
|
||||
/// 应用管理服务:APP 版本管理与 TV 应用管理。
|
||||
///
|
||||
/// 对接真实「爱视后台」:
|
||||
/// - APP 版本:/api/device/orgGroupAppVersion/*
|
||||
/// - TV 应用:/api/theme/tvapp/*
|
||||
class AppService {
|
||||
AppService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
// ================ APP 版本管理 ================
|
||||
|
||||
/// 获取 APP 版本列表(分页,可按组织/分组筛选)。
|
||||
///
|
||||
/// GET /device/orgGroupAppVersion/list?page=&limit=&orgId=&groupId=
|
||||
Future<List<AppVersion>> getAppVersionList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? orgId,
|
||||
String? groupId,
|
||||
}) async {
|
||||
final effectiveOrgId = orgId ??
|
||||
(SessionOrgScope.instance.enabled
|
||||
? SessionOrgScope.instance.currentOrgId
|
||||
: null);
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/device/orgGroupAppVersion/list',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
if (effectiveOrgId != null) 'orgId': effectiveOrgId,
|
||||
if (groupId != null) 'groupId': groupId,
|
||||
},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(AppVersion.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 按 orgId 查询该组织直接配置的版本(单体)。
|
||||
///
|
||||
/// 对齐 Web 端 `searchAppVersion`:每个 scope=ORG 的组织至多一条版本配置,
|
||||
/// 有则返回当前版本(进入编辑流程)、无则返回 null(走新增流程)。
|
||||
///
|
||||
/// GET /device/orgGroupAppVersion/search?orgId=
|
||||
///
|
||||
/// 注意:拦截器已解包 `{code,msg,data}` 信封,`response.data` 即内层 data;
|
||||
/// 兼容 data 为 `{appVersion: {...}}`、版本对象直接返回、或 null(无配置)三种形态。
|
||||
Future<Map<String, dynamic>?> searchAppVersion({
|
||||
required String orgId,
|
||||
String? groupId,
|
||||
}) async {
|
||||
if (orgId.isEmpty && groupId == null) {
|
||||
throw const ApiException(400, 'orgId 与 groupId 需二选一');
|
||||
}
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/device/orgGroupAppVersion/search',
|
||||
queryParameters: {
|
||||
if (orgId.isNotEmpty) 'orgId': orgId,
|
||||
if (groupId != null) 'groupId': groupId,
|
||||
},
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) return null;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final appVersion = data['appVersion'];
|
||||
if (appVersion is Map) {
|
||||
return Map<String, dynamic>.from(appVersion);
|
||||
}
|
||||
// 直接是版本对象(含 id 或 versionName)。
|
||||
if (data.containsKey('id') || data.containsKey('versionName')) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 获取 APP 版本详情。
|
||||
///
|
||||
/// GET /device/orgGroupAppVersion/info/{id}
|
||||
Future<AppVersion> getAppVersionInfo(String id) async {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/device/orgGroupAppVersion/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, 'APP 版本信息加载失败:$id');
|
||||
}
|
||||
return AppVersion.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增 APP 版本。
|
||||
///
|
||||
/// POST /device/orgGroupAppVersion/save,body 为版本数据。
|
||||
Future<void> saveAppVersion(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/device/orgGroupAppVersion/save',
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
|
||||
/// 修改 APP 版本。
|
||||
///
|
||||
/// POST /device/orgGroupAppVersion/update,body 为版本数据(含 id)。
|
||||
Future<void> updateAppVersion(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/device/orgGroupAppVersion/update',
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
|
||||
/// 批量删除 APP 版本。
|
||||
///
|
||||
/// POST /device/orgGroupAppVersion/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteAppVersion(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/device/orgGroupAppVersion/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ TV 应用管理 ================
|
||||
|
||||
/// 获取 TV 应用列表(分页,可按名称/组织/分组筛选)。
|
||||
///
|
||||
/// GET /theme/tvapp/list?page=&limit=&appName=&orgId=&groupId=
|
||||
Future<List<TvApp>> getTvAppList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? appName,
|
||||
String? orgId,
|
||||
String? groupId,
|
||||
}) async {
|
||||
final effectiveOrgId = orgId ??
|
||||
(SessionOrgScope.instance.enabled
|
||||
? SessionOrgScope.instance.currentOrgId
|
||||
: null);
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/theme/tvapp/list',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
if (appName != null) 'appName': appName,
|
||||
if (effectiveOrgId != null) 'orgId': effectiveOrgId,
|
||||
if (groupId != null) 'groupId': groupId,
|
||||
},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(TvApp.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取 TV 应用详情。
|
||||
///
|
||||
/// GET /theme/tvapp/info/{id}
|
||||
Future<TvApp> getTvAppInfo(String id) async {
|
||||
final response = await _dio.get<dynamic>('/theme/tvapp/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, 'TV 应用信息加载失败:$id');
|
||||
}
|
||||
return TvApp.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改 TV 应用。
|
||||
///
|
||||
/// POST /theme/tvapp/save,body 为应用数据。
|
||||
Future<void> saveTvApp(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/theme/tvapp/save', data: data);
|
||||
}
|
||||
|
||||
/// 批量删除 TV 应用。
|
||||
///
|
||||
/// POST /theme/tvapp/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteTvApp(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/theme/tvapp/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 工具方法 ================
|
||||
|
||||
/// 兼容列表响应的三种形态:
|
||||
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
|
||||
static List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List) {
|
||||
return data;
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
final list = data['list'];
|
||||
if (list is List) {
|
||||
return list;
|
||||
}
|
||||
final page = data['page'];
|
||||
if (page is Map<String, dynamic> && page['list'] is List) {
|
||||
return page['list'] as List;
|
||||
}
|
||||
}
|
||||
return const <dynamic>[];
|
||||
}
|
||||
}
|
||||
264
lib/services/auth_service.dart
Normal file
264
lib/services/auth_service.dart
Normal file
@@ -0,0 +1,264 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/user.dart';
|
||||
import '../utils/constants.dart';
|
||||
import '../utils/uuid.dart';
|
||||
import '../config/app_config.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 认证服务:对接真实「爱视后台」/api/sys/* 接口。
|
||||
///
|
||||
/// mock 模式下([MockInterceptor] 挂载)请求路径与真实一致,
|
||||
/// 由拦截器本地返回示例数据,service 层代码两种模式完全统一。
|
||||
class AuthService {
|
||||
AuthService({Dio? dio, Dio? rawDio})
|
||||
: _dio = dio ?? ApiClient.instance.dio,
|
||||
_rawDio = rawDio ?? _createRawDio();
|
||||
|
||||
final Dio _dio;
|
||||
final Dio _rawDio;
|
||||
|
||||
/// 用于拉取验证码图片的独立 Dio 实例(不经过 ApiInterceptor 信封解包)。
|
||||
static Dio _createRawDio() {
|
||||
return Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.apiFullBaseUrl,
|
||||
connectTimeout: AppConstants.apiTimeout,
|
||||
receiveTimeout: AppConstants.apiTimeout,
|
||||
responseType: ResponseType.bytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取验证码图片字节与对应 UUID。
|
||||
///
|
||||
/// 接口:GET /captcha.jpg?uuid=xxx,返回图片二进制。
|
||||
/// web 端生成 UUID 规则:[generateUuid]。
|
||||
Future<Captcha> fetchCaptcha() async {
|
||||
final uuid = generateUuid();
|
||||
|
||||
// mock 模式直接返回占位图,不发起真实请求。
|
||||
if (AppConstants.useMock) {
|
||||
return Captcha(uuid: uuid, imageBytes: _mockCaptchaBytes);
|
||||
}
|
||||
|
||||
final response = await _rawDio.get<List<int>>(
|
||||
'/captcha.jpg',
|
||||
queryParameters: {'uuid': uuid},
|
||||
);
|
||||
final bytes = response.data;
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
throw const AuthException('验证码加载失败');
|
||||
}
|
||||
return Captcha(uuid: uuid, imageBytes: bytes);
|
||||
}
|
||||
|
||||
/// 登录。
|
||||
///
|
||||
/// 请求:POST /sys/login。
|
||||
/// 当 [AppConfig.enableCaptcha] 为真时,body 携带 `{username, password,
|
||||
/// captcha, uuid}`;否则仅发送 `{username, password}`,由后端会话策略决定
|
||||
/// 是否仍需校验验证码(前端已不再展示/收集验证码)。
|
||||
/// 成功后令牌写入 [TokenStore](内存态),失败抛出 [AuthException]。
|
||||
Future<User> login({
|
||||
required String account,
|
||||
required String password,
|
||||
String captcha = '',
|
||||
String uuid = '',
|
||||
}) async {
|
||||
try {
|
||||
// 仅当全局启用验证码时才把 captcha/uuid 填入请求体,
|
||||
// 关闭后登录界面不展示验证码,请求体也不携带这两个字段。
|
||||
final body = <String, dynamic>{
|
||||
'username': account,
|
||||
'password': password,
|
||||
};
|
||||
if (AppConfig.enableCaptcha) {
|
||||
body['captcha'] = captcha;
|
||||
body['uuid'] = uuid;
|
||||
}
|
||||
// 响应已被 ApiInterceptor 解包,data 即业务数据。
|
||||
final response = await _dio.post<dynamic>(
|
||||
'/sys/login',
|
||||
data: body,
|
||||
);
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic> || data['token'] == null) {
|
||||
throw const AuthException('登录响应格式异常');
|
||||
}
|
||||
|
||||
final token = data['token'].toString();
|
||||
TokenStore.instance.save(
|
||||
token: token,
|
||||
refreshToken: data['refreshToken']?.toString(),
|
||||
);
|
||||
|
||||
// 尝试获取当前用户信息以补充显示名称、组织与角色 ID;失败不影响登录。
|
||||
final profile = await _tryFetchUserInfo(fallbackName: account);
|
||||
|
||||
// 登录响应中直接取 orgId/roleId;若不存在则使用 /sys/user/info 结果。
|
||||
final loginOrgId = data['orgId']?.toString();
|
||||
final loginRoleId = data['roleId']?.toString() ??
|
||||
(data['roles'] is List && (data['roles'] as List).isNotEmpty
|
||||
? (data['roles'] as List).first['roleId']?.toString()
|
||||
: null);
|
||||
final loginOrgType = data['orgType'];
|
||||
final loginSuperAdmin = data['superAdmin'];
|
||||
|
||||
return User(
|
||||
id: loginOrgId ?? account,
|
||||
account: account,
|
||||
displayName: profile.displayName,
|
||||
role: _joinRoleNames(data['roleNames']) ?? profile.role ?? '',
|
||||
token: token,
|
||||
orgId: loginOrgId ?? profile.orgId ?? '',
|
||||
roleId: loginRoleId ?? profile.roleId ?? '',
|
||||
orgType: loginOrgType is int
|
||||
? loginOrgType
|
||||
: int.tryParse(loginOrgType?.toString() ?? '') ??
|
||||
profile.orgType,
|
||||
superAdmin: loginSuperAdmin is bool
|
||||
? loginSuperAdmin
|
||||
: profile.superAdmin,
|
||||
);
|
||||
} on AuthException {
|
||||
rethrow;
|
||||
} on DioException catch (e) {
|
||||
throw AuthException(_errorMessage(e, fallback: '账号或密码错误'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出:通知后端(失败忽略),并清空内存令牌。
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _dio.post<dynamic>('/sys/logout');
|
||||
} catch (_) {
|
||||
// 登出接口失败不阻塞本地登出。
|
||||
} finally {
|
||||
TokenStore.instance.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取用户资料(GET /sys/user/info);失败时返回兜底值。
|
||||
Future<_UserProfile> _tryFetchUserInfo({required String fallbackName}) async {
|
||||
try {
|
||||
final response = await _dio.get<dynamic>('/sys/user/info');
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final roleId = data['roleId']?.toString() ??
|
||||
_extractFirstRoleId(data['roles'] ?? data['role']);
|
||||
final orgId = data['orgId']?.toString() ??
|
||||
_extractNestedId(data['org'] ?? data['dept']);
|
||||
final rawOrgType = data['orgType'];
|
||||
final rawSuperAdmin = data['superAdmin'];
|
||||
return _UserProfile(
|
||||
displayName: (data['realName'] ??
|
||||
data['nickname'] ??
|
||||
data['username'] ??
|
||||
fallbackName)
|
||||
.toString(),
|
||||
role: _joinRoleNames(data['roleNames']),
|
||||
orgId: orgId,
|
||||
roleId: roleId,
|
||||
orgType: rawOrgType is int
|
||||
? rawOrgType
|
||||
: int.tryParse(rawOrgType?.toString() ?? '') ?? 0,
|
||||
superAdmin: rawSuperAdmin is bool ? rawSuperAdmin : false,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// 用户信息接口异常时使用兜底资料。
|
||||
}
|
||||
return _UserProfile(displayName: fallbackName, role: null);
|
||||
}
|
||||
|
||||
/// 从角色对象或列表中提取第一个 roleId。
|
||||
static String? _extractFirstRoleId(dynamic value) {
|
||||
if (value is List && value.isNotEmpty) {
|
||||
return _extractFirstRoleId(value.first);
|
||||
}
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value['roleId']?.toString() ?? value['id']?.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 从组织/部门对象中提取 ID。
|
||||
static String? _extractNestedId(dynamic value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value['orgId']?.toString() ??
|
||||
value['id']?.toString() ??
|
||||
value['deptId']?.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// roleNames 列表拼接为展示字符串。
|
||||
static String? _joinRoleNames(dynamic roleNames) {
|
||||
if (roleNames is List && roleNames.isNotEmpty) {
|
||||
return roleNames.map((e) => e.toString()).join('/');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 从 Dio 异常中提取业务错误信息。
|
||||
static String _errorMessage(DioException e, {required String fallback}) {
|
||||
final error = e.error;
|
||||
if (error is ApiException && error.message.isNotEmpty) {
|
||||
return error.message;
|
||||
}
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout ||
|
||||
e.type == DioExceptionType.connectionError) {
|
||||
return '网络异常,请检查网络后重试';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// mock 模式下的占位验证码图片:1x1 透明 PNG。
|
||||
static final List<int> _mockCaptchaBytes = base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户资料中间结构(service 内部使用)。
|
||||
class _UserProfile {
|
||||
const _UserProfile({
|
||||
required this.displayName,
|
||||
this.role,
|
||||
this.orgId,
|
||||
this.roleId,
|
||||
this.orgType = 0,
|
||||
this.superAdmin = false,
|
||||
});
|
||||
|
||||
final String displayName;
|
||||
final String? role;
|
||||
final String? orgId;
|
||||
final String? roleId;
|
||||
final int orgType;
|
||||
final bool superAdmin;
|
||||
}
|
||||
|
||||
/// 验证码数据。
|
||||
class Captcha {
|
||||
const Captcha({required this.uuid, required this.imageBytes});
|
||||
|
||||
/// 与该验证码图片绑定的 UUID,登录时必须原样回传。
|
||||
final String uuid;
|
||||
|
||||
/// 验证码 PNG/JPEG 图片字节。
|
||||
final List<int> imageBytes;
|
||||
}
|
||||
|
||||
/// 认证异常。
|
||||
class AuthException implements Exception {
|
||||
const AuthException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
355
lib/services/cos/cos_client.dart
Normal file
355
lib/services/cos/cos_client.dart
Normal file
@@ -0,0 +1,355 @@
|
||||
// 腾讯云 COS 直传客户端(纯 Dart 实现,对齐 Web 端 cos-js-sdk-v5 的 STS 临时密钥签名)。
|
||||
//
|
||||
// 来源:flutter_cos_client (MIT) 的本地化副本。
|
||||
// 之所以 vendoring 而非直接依赖 pub 包:
|
||||
// - 原 pub 包 (0.0.1) 在生成签名时硬编码 `expires = now + 3600`,
|
||||
// 当后端下发的 STS 临时密钥有效期短于 1 小时时,COS 会以 403 拒绝
|
||||
// (q-sign-time 末尾超出凭证 ExpiredTime)。此处改为尊重凭证自身的 ExpiredTime。
|
||||
// - 移除对未维护 0.0.1 包的依赖,便于长期维护。
|
||||
// - 去掉 mime 依赖,用内置扩展名→Content-Type 映射。
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 临时云 API 凭据。
|
||||
class CosCredential {
|
||||
/// 临时密钥 Id。
|
||||
final String tmpSecretId;
|
||||
|
||||
/// 临时密钥 Key。
|
||||
final String tmpSecretKey;
|
||||
|
||||
/// 请求时需要用的 token 字符串。
|
||||
final String sessionToken;
|
||||
|
||||
CosCredential({
|
||||
required this.tmpSecretId,
|
||||
required this.tmpSecretKey,
|
||||
required this.sessionToken,
|
||||
});
|
||||
}
|
||||
|
||||
/// COS 临时凭据数据。
|
||||
class CosCredentialData {
|
||||
/// 密钥的起始时间,UNIX 时间戳(秒)。
|
||||
final int startTime;
|
||||
|
||||
/// 密钥的失效时间,UNIX 时间戳(秒)。
|
||||
final int expiredTime;
|
||||
|
||||
/// 临时云 API 凭据。
|
||||
final CosCredential credentials;
|
||||
|
||||
/// 请求 ID。
|
||||
final String requestId;
|
||||
|
||||
CosCredentialData({
|
||||
required this.startTime,
|
||||
required this.expiredTime,
|
||||
required this.credentials,
|
||||
required this.requestId,
|
||||
});
|
||||
|
||||
/// 检查凭据是否已过期(提前 5 分钟判断为过期)。
|
||||
bool get isExpired {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return now >= (expiredTime - 300);
|
||||
}
|
||||
}
|
||||
|
||||
/// 临时秘钥提供者。
|
||||
class TemporaryCredentialProvider {
|
||||
/// 获取临时凭据的异步函数。
|
||||
final Future<CosCredentialData> Function() fetchCredentials;
|
||||
|
||||
/// 缓存的临时凭据。
|
||||
CosCredentialData? _cachedCredentials;
|
||||
|
||||
TemporaryCredentialProvider({required this.fetchCredentials});
|
||||
|
||||
/// 获取有效的临时凭据(缓存过期则重新获取)。
|
||||
Future<CosCredentialData> getCredentials() async {
|
||||
if (_cachedCredentials == null || _cachedCredentials!.isExpired) {
|
||||
_cachedCredentials = await fetchCredentials();
|
||||
}
|
||||
return _cachedCredentials!;
|
||||
}
|
||||
|
||||
/// 清除缓存的凭据。
|
||||
void clearCache() {
|
||||
_cachedCredentials = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 腾讯云 COS 客户端。
|
||||
class CosClient {
|
||||
/// 地域,例如:ap-beijing。
|
||||
final String region;
|
||||
|
||||
/// 存储桶名称,格式:bucketname-appid。
|
||||
final String bucket;
|
||||
|
||||
/// 临时秘钥提供者。
|
||||
final TemporaryCredentialProvider? temporaryCredentialProvider;
|
||||
|
||||
late final Dio _dio;
|
||||
|
||||
/// 构造函数(使用临时密钥提供者)。
|
||||
CosClient.withTemporaryCredential({
|
||||
required this.temporaryCredentialProvider,
|
||||
required this.region,
|
||||
required this.bucket,
|
||||
BaseOptions? dioOptions,
|
||||
}) {
|
||||
_dio = Dio(dioOptions ?? BaseOptions());
|
||||
}
|
||||
|
||||
/// 获取存储桶的 Host。
|
||||
String get _host => '$bucket.cos.$region.myqcloud.com';
|
||||
|
||||
/// 上传对象(字节数据)。
|
||||
Future<CosUploadResult> putObject({
|
||||
required String objectKey,
|
||||
required List<int> fileBytes,
|
||||
String? fileName,
|
||||
String? contentType,
|
||||
void Function(int count, int total)? onProgress,
|
||||
}) async {
|
||||
// 确保 objectKey 以 / 开头。
|
||||
if (!objectKey.startsWith('/')) {
|
||||
objectKey = '/$objectKey';
|
||||
}
|
||||
|
||||
final fileLength = fileBytes.length;
|
||||
|
||||
// 计算 Content-MD5。
|
||||
final md5Digest = md5.convert(fileBytes);
|
||||
final contentMd5 = base64.encode(md5Digest.bytes);
|
||||
|
||||
// 确定 Content-Type。
|
||||
contentType ??= _getContentType(fileName ?? '');
|
||||
|
||||
// 获取密钥(临时)。
|
||||
String currentSecretId;
|
||||
String currentSecretKey;
|
||||
String? sessionToken;
|
||||
int signExpires;
|
||||
|
||||
if (temporaryCredentialProvider != null) {
|
||||
final credentials = await temporaryCredentialProvider!.getCredentials();
|
||||
currentSecretId = credentials.credentials.tmpSecretId;
|
||||
currentSecretKey = credentials.credentials.tmpSecretKey;
|
||||
sessionToken = credentials.credentials.sessionToken;
|
||||
// 关键修复:签名有效期不得超过 STS 凭证自身的 ExpiredTime,
|
||||
// 否则 COS 会以 403 拒绝(q-sign-time 末尾超出凭证有效期)。
|
||||
signExpires = credentials.expiredTime;
|
||||
} else {
|
||||
throw ArgumentError('CosClient 仅支持临时密钥(STS)方式');
|
||||
}
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// 构建请求头(临时密钥需包含 x-cos-security-token)。
|
||||
final headers = <String, String>{
|
||||
'content-length': fileLength.toString(),
|
||||
'content-md5': contentMd5,
|
||||
'content-type': contentType,
|
||||
'host': _host,
|
||||
};
|
||||
if (sessionToken != null) {
|
||||
headers['x-cos-security-token'] = sessionToken;
|
||||
}
|
||||
|
||||
// 生成签名。
|
||||
final authorization = _generateAuthorization(
|
||||
method: 'put',
|
||||
uri: objectKey,
|
||||
headers: headers,
|
||||
queryParams: {},
|
||||
now: now,
|
||||
expires: signExpires,
|
||||
secretId: currentSecretId,
|
||||
secretKey: currentSecretKey,
|
||||
);
|
||||
|
||||
// 构建请求 URL。
|
||||
final url = 'https://$_host$objectKey';
|
||||
|
||||
// 构建实际请求头。
|
||||
final requestHeaders = <String, String>{
|
||||
'Authorization': authorization,
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': fileLength.toString(),
|
||||
'Content-MD5': contentMd5,
|
||||
'Host': _host,
|
||||
};
|
||||
if (sessionToken != null) {
|
||||
requestHeaders['x-cos-security-token'] = sessionToken;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
url,
|
||||
data: Stream.fromIterable(fileBytes.map((e) => [e])),
|
||||
options: Options(
|
||||
headers: requestHeaders,
|
||||
contentType: contentType,
|
||||
responseType: ResponseType.plain,
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
),
|
||||
onSendProgress: onProgress,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return CosUploadResult(
|
||||
success: true,
|
||||
statusCode: response.statusCode!,
|
||||
etag: response.headers.value('etag'),
|
||||
message: 'Upload successful',
|
||||
url: url,
|
||||
);
|
||||
}
|
||||
return CosUploadResult(
|
||||
success: false,
|
||||
statusCode: response.statusCode!,
|
||||
message: 'Upload failed: ${response.statusMessage}, ${response.data}',
|
||||
);
|
||||
} catch (e) {
|
||||
return CosUploadResult(
|
||||
success: false,
|
||||
statusCode: -1,
|
||||
message: 'Upload error: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 从文件上传对象(便捷方法)。
|
||||
Future<CosUploadResult> putObjectFromFile({
|
||||
required String objectKey,
|
||||
required File file,
|
||||
String? contentType,
|
||||
void Function(int count, int total)? onProgress,
|
||||
}) async {
|
||||
final fileBytes = await file.readAsBytes();
|
||||
return putObject(
|
||||
objectKey: objectKey,
|
||||
fileBytes: fileBytes,
|
||||
fileName: file.path,
|
||||
contentType: contentType,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/// 生成 Authorization 签名(腾讯云 q-sign-algorithm=sha1)。
|
||||
String _generateAuthorization({
|
||||
required String method,
|
||||
required String uri,
|
||||
required Map<String, String> headers,
|
||||
required Map<String, String> queryParams,
|
||||
required int now,
|
||||
required int expires,
|
||||
required String secretId,
|
||||
required String secretKey,
|
||||
}) {
|
||||
final keyTime = '$now;$expires';
|
||||
final signKey = _hmacSha1(secretKey, keyTime);
|
||||
final urlParamList = _generateParamList(queryParams);
|
||||
final httpParameters = _generateHttpParameters(queryParams);
|
||||
final headerList = _generateParamList(headers);
|
||||
final httpHeaders = _generateHttpParameters(headers);
|
||||
|
||||
final httpString =
|
||||
'${method.toLowerCase()}\n$uri\n$httpParameters\n$httpHeaders\n';
|
||||
final httpStringSha1 = sha1.convert(utf8.encode(httpString)).toString();
|
||||
final stringToSign = 'sha1\n$keyTime\n$httpStringSha1\n';
|
||||
final signature = _hmacSha1(signKey, stringToSign);
|
||||
|
||||
return 'q-sign-algorithm=sha1'
|
||||
'&q-ak=$secretId'
|
||||
'&q-sign-time=$keyTime'
|
||||
'&q-key-time=$keyTime'
|
||||
'&q-header-list=$headerList'
|
||||
'&q-url-param-list=$urlParamList'
|
||||
'&q-signature=$signature';
|
||||
}
|
||||
|
||||
/// HMAC-SHA1 加密。
|
||||
String _hmacSha1(String key, String data) {
|
||||
final hmac = Hmac(sha1, utf8.encode(key));
|
||||
final digest = hmac.convert(utf8.encode(data));
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
/// 生成参数列表(key 排序后用 ; 分隔)。
|
||||
String _generateParamList(Map<String, String> params) {
|
||||
if (params.isEmpty) return '';
|
||||
final keys = params.keys.toList()..sort();
|
||||
return keys.map((k) => k.toLowerCase()).join(';');
|
||||
}
|
||||
|
||||
/// 生成 HTTP 参数字符串(key=value 格式,用 & 分隔,需 URL 编码)。
|
||||
String _generateHttpParameters(Map<String, String> params) {
|
||||
if (params.isEmpty) return '';
|
||||
final keys = params.keys.toList()..sort();
|
||||
return keys
|
||||
.map((k) =>
|
||||
'${Uri.encodeComponent(k.toLowerCase())}=${Uri.encodeComponent(params[k]!)}')
|
||||
.join('&');
|
||||
}
|
||||
|
||||
/// 根据文件扩展名获取 Content-Type(替代 mime 依赖)。
|
||||
String _getContentType(String filePath) {
|
||||
final lower = filePath.toLowerCase();
|
||||
final dot = lower.lastIndexOf('.');
|
||||
final ext = dot >= 0 ? lower.substring(dot + 1) : '';
|
||||
const map = <String, String>{
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
'mp4': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'mov': 'video/quicktime',
|
||||
'm4v': 'video/x-m4v',
|
||||
'pdf': 'application/pdf',
|
||||
};
|
||||
return map[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
/// COS 上传结果。
|
||||
class CosUploadResult {
|
||||
/// 是否成功。
|
||||
final bool success;
|
||||
|
||||
/// HTTP 状态码。
|
||||
final int statusCode;
|
||||
|
||||
/// ETag(文件的唯一标识)。
|
||||
final String? etag;
|
||||
|
||||
/// 消息。
|
||||
final String? message;
|
||||
|
||||
/// 对象的 COS 访问地址。
|
||||
final String? url;
|
||||
|
||||
CosUploadResult({
|
||||
required this.success,
|
||||
required this.statusCode,
|
||||
this.etag,
|
||||
this.message,
|
||||
this.url,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CosUploadResult{success: $success, statusCode: $statusCode, '
|
||||
'etag: $etag, message: $message, url: $url}';
|
||||
}
|
||||
}
|
||||
612
lib/services/device_service.dart
Normal file
612
lib/services/device_service.dart
Normal file
@@ -0,0 +1,612 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/alert.dart';
|
||||
import '../models/device.dart';
|
||||
import '../models/org_node.dart';
|
||||
import '../utils/constants.dart';
|
||||
import '../utils/session_org_scope.dart';
|
||||
import '../utils/store_local_cache.dart';
|
||||
import 'org_service.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 设备与告警数据服务:对接真实「爱视后台」/api/device/* 接口。
|
||||
///
|
||||
/// - 设备列表:GET /device/info/list(响应三种形态均兼容,见 [_extractList]);
|
||||
/// - 设备详情:GET /device/info/info/{id};
|
||||
/// - 设备指令:POST /device/command/sendByMac | sendByOrg(service 已实现,
|
||||
/// 下发 UI 属下一轮迭代);
|
||||
/// - 告警:真实后端无 alerts 端点,real 模式下由设备列表推导
|
||||
/// (offline→critical、warning→warning);mock 模式使用示例告警。
|
||||
/// - 设备分组:GET/POST /device/group/*;
|
||||
/// - 激活码:POST/GET /device/activationCode/*;
|
||||
/// - 门店配置:GET/POST /device/storeConfig/*。
|
||||
class DeviceService {
|
||||
DeviceService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// 获取设备总数量(后端分页响应中的 totalCount)。
|
||||
///
|
||||
/// GET /device/info/list?page=1&limit=1
|
||||
Future<int> fetchDeviceTotalCount() async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/device/info/list',
|
||||
queryParameters: {'page': 1, 'limit': 1},
|
||||
);
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final total = data['totalCount'];
|
||||
if (total is int) return total;
|
||||
if (total is String) return int.tryParse(total) ?? 0;
|
||||
// 部分后端把 total 放到 page.totalCount。
|
||||
final page = data['page'];
|
||||
if (page is Map<String, dynamic>) {
|
||||
final pageTotal = page['totalCount'];
|
||||
if (pageTotal is int) return pageTotal;
|
||||
if (pageTotal is String) return int.tryParse(pageTotal) ?? 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 按状态统计设备数量(O(1):limit=1 仅取 totalCount,不下拉全量)。
|
||||
///
|
||||
/// [status] 取值:'online' / 'offline' / 'warning';传 null 表示全部。
|
||||
/// GET /device/info/list?page=1&limit=1&status=xxx
|
||||
Future<int> fetchDeviceCountByStatus({String? status}) async {
|
||||
final query = <String, dynamic>{'page': 1, 'limit': 1};
|
||||
if (status != null) query['status'] = status;
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/device/info/list',
|
||||
queryParameters: query,
|
||||
);
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final total = data['totalCount'];
|
||||
if (total is int) return total;
|
||||
if (total is String) return int.tryParse(total) ?? 0;
|
||||
final page = data['page'];
|
||||
if (page is Map<String, dynamic>) {
|
||||
final pageTotal = page['totalCount'];
|
||||
if (pageTotal is int) return pageTotal;
|
||||
if (pageTotal is String) return int.tryParse(pageTotal) ?? 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 获取设备列表。
|
||||
Future<List<Device>> fetchDevices({
|
||||
int page = 1,
|
||||
int limit = AppConstants.deviceListPageSize,
|
||||
String? status,
|
||||
String? mac,
|
||||
String? deviceSn,
|
||||
String? deviceUid,
|
||||
String? groupId,
|
||||
String? storeOrgId,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/device/info/list',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
if (status != null) 'status': status,
|
||||
if (mac != null) 'mac': mac,
|
||||
if (deviceSn != null) 'deviceSn': deviceSn,
|
||||
if (deviceUid != null) 'deviceUid': deviceUid,
|
||||
if (groupId != null) 'groupId': groupId,
|
||||
if (storeOrgId != null) 'storeOrgId': storeOrgId,
|
||||
},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Device.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 按多个门店(组织节点)批量拉取并合并设备列表。
|
||||
///
|
||||
/// 后端 `/device/info/list` 仅接受单个 `storeOrgId`,故对每个门店 orgId 并发
|
||||
/// 请求 [fetchDevices],再把结果合并、按 [Device.id] 去重(同一设备不会因归属
|
||||
/// 多个选中组织而重复出现)。
|
||||
///
|
||||
/// [storeOrgIds] 为空时直接走 [fetchDevices](等效于「全部门店」),保持原行为。
|
||||
Future<List<Device>> fetchDevicesByOrgSet(
|
||||
Set<String> storeOrgIds, {
|
||||
String? status,
|
||||
String? mac,
|
||||
String? deviceSn,
|
||||
String? deviceUid,
|
||||
String? groupId,
|
||||
}) async {
|
||||
if (storeOrgIds.isEmpty) {
|
||||
return fetchDevices(
|
||||
status: status,
|
||||
mac: mac,
|
||||
deviceSn: deviceSn,
|
||||
deviceUid: deviceUid,
|
||||
groupId: groupId,
|
||||
);
|
||||
}
|
||||
final lists = await Future.wait(
|
||||
storeOrgIds.map(
|
||||
(id) => _fetchDevicesForStore(
|
||||
id,
|
||||
status: status,
|
||||
mac: mac,
|
||||
deviceSn: deviceSn,
|
||||
deviceUid: deviceUid,
|
||||
groupId: groupId,
|
||||
),
|
||||
),
|
||||
);
|
||||
final map = <String, Device>{};
|
||||
for (final d in lists.expand((x) => x)) {
|
||||
map[d.id] = d;
|
||||
}
|
||||
return map.values.toList();
|
||||
}
|
||||
|
||||
/// 按单个门店 orgId 拉取设备列表(构造查询参数 + 解析)。
|
||||
///
|
||||
/// 抽取自 [fetchDevices] 的单店请求逻辑,供 [fetchDevicesByOrgSet] 复用,
|
||||
/// 避免多门店合并时重复拼装 queryParameters。
|
||||
Future<List<Device>> _fetchDevicesForStore(
|
||||
String storeOrgId, {
|
||||
String? status,
|
||||
String? mac,
|
||||
String? deviceSn,
|
||||
String? deviceUid,
|
||||
String? groupId,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/device/info/list',
|
||||
queryParameters: {
|
||||
'page': 1,
|
||||
'limit': AppConstants.deviceListPageSize,
|
||||
if (status != null) 'status': status,
|
||||
if (mac != null) 'mac': mac,
|
||||
if (deviceSn != null) 'deviceSn': deviceSn,
|
||||
if (deviceUid != null) 'deviceUid': deviceUid,
|
||||
if (groupId != null) 'groupId': groupId,
|
||||
'storeOrgId': storeOrgId,
|
||||
},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Device.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 按 ID 获取设备详情;不存在时抛出 [DeviceNotFoundException]。
|
||||
///
|
||||
/// 注意:详情接口 /device/info/info/{id} 返回的 `status` / `lastSyncTime`
|
||||
/// 可能滞后于列表接口。Web 端设备列表走 /device/info/list 并显示最新在线状态,
|
||||
/// 因此本方法在拿到详情后,再用 [Device.mac] 到列表接口取一次最新状态并合并,
|
||||
/// 使手机端详情页与 Web 端列表/心跳保持一致。
|
||||
Future<Device> fetchDeviceById(String id) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/device/info/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw DeviceNotFoundException('未找到设备:$id');
|
||||
}
|
||||
final detail = Device.fromJson(data);
|
||||
return _mergeWithFreshListStatus(detail);
|
||||
} on DioException catch (e) {
|
||||
final error = e.error;
|
||||
if (error is ApiException) {
|
||||
throw DeviceNotFoundException('${error.message}($id)');
|
||||
}
|
||||
throw DeviceNotFoundException('设备详情加载失败:$id');
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 [detail.mac] 从列表接口拉取最新状态并合并。
|
||||
///
|
||||
/// 列表接口 /device/info/list 与 Web 端同源,状态/心跳刷新更及时;
|
||||
/// 合并时以列表的 [status] 与 [lastSyncTime] 覆盖详情,其余字段保留详情。
|
||||
Future<Device> _mergeWithFreshListStatus(Device detail) async {
|
||||
if (detail.mac.isEmpty) return detail;
|
||||
try {
|
||||
final freshList = await fetchDevices(mac: detail.mac, limit: 1);
|
||||
if (freshList.isEmpty) return detail;
|
||||
final fresh = freshList.first;
|
||||
return detail.copyWith(
|
||||
status: fresh.status,
|
||||
lastSyncTime: fresh.lastSyncTime ?? detail.lastSyncTime,
|
||||
);
|
||||
} on Object {
|
||||
// 列表接口失败时不阻断详情展示,保持原样。
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新设备信息(修改分组、房号等)。
|
||||
///
|
||||
/// POST /device/info/update,body 至少含 `id` 与需修改字段。
|
||||
Future<void> updateDevice(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/device/info/update', data: data);
|
||||
}
|
||||
|
||||
/// 新增设备。
|
||||
///
|
||||
/// POST /device/info/save,必填字段参考 Web 端:groupId、mac。
|
||||
Future<void> saveDeviceInfo(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/device/info/save', data: data);
|
||||
}
|
||||
|
||||
/// 删除单个设备。
|
||||
///
|
||||
/// 后端 `POST /device/info/delete` 接收的是**裸 JSON 数组** `[id]`,与 Web 端
|
||||
/// 设备列表批量删除保持一致(Web 调用 `Ao(t)` → `$.adornData(t,!1)` →
|
||||
/// `JSON.stringify([id,...])`)。
|
||||
/// 旧实现误传 `{'ids': [id]}`(对象),后端无法解析请求体 → 返回「未知异常」。
|
||||
/// id 在可转为数字时按数字发送(复刻 Web 端 `Yo()` 的 `Number(id)` 行为),
|
||||
/// 否则保留原字符串,保证数字/字符串两种主键都能正确命中。
|
||||
Future<void> deleteDevice(String id) async {
|
||||
final idValue = int.tryParse(id) ?? id;
|
||||
await _dio.post<dynamic>('/device/info/delete', data: [idValue]);
|
||||
}
|
||||
|
||||
/// 按 MAC 查询设备(最多返回 1 条,用于判断设备是否已存在)。
|
||||
Future<Device?> fetchDeviceByMac(String mac) async {
|
||||
if (mac.isEmpty) return null;
|
||||
final devices = await fetchDevices(mac: mac, limit: 1);
|
||||
return devices.isNotEmpty ? devices.first : null;
|
||||
}
|
||||
|
||||
/// 扫码激活/绑定设备:把设备真正写入后端。
|
||||
///
|
||||
/// 逻辑:
|
||||
/// 1. 先按 MAC 查询设备是否已存在;
|
||||
/// 2. 存在则走 `/device/info/update` 更新归属(门店/分组/房号);
|
||||
/// 3. 不存在则走 `/device/info/save` 新增设备。
|
||||
///
|
||||
/// 这样手机端扫码激活后,Web 端设备列表能立即看到该设备。
|
||||
Future<void> activateDeviceByScan({
|
||||
required String mac,
|
||||
required String storeId,
|
||||
String? groupId,
|
||||
String? roomNo,
|
||||
}) async {
|
||||
final existing = await fetchDeviceByMac(mac);
|
||||
if (existing != null && existing.id.isNotEmpty) {
|
||||
final data = <String, dynamic>{
|
||||
'id': existing.id,
|
||||
'mac': mac,
|
||||
};
|
||||
if (groupId != null && groupId.isNotEmpty) data['groupId'] = groupId;
|
||||
if (roomNo != null && roomNo.isNotEmpty) data['roomId'] = roomNo;
|
||||
await updateDevice(data);
|
||||
return;
|
||||
}
|
||||
|
||||
final data = <String, dynamic>{
|
||||
'mac': mac,
|
||||
// 门店在后端设备表中通常映射为 orgId / storeOrgId;同时携带两个别名,
|
||||
// 兼容不同后端版本。
|
||||
'orgId': storeId,
|
||||
'storeOrgId': storeId,
|
||||
};
|
||||
if (groupId != null && groupId.isNotEmpty) data['groupId'] = groupId;
|
||||
if (roomNo != null && roomNo.isNotEmpty) data['roomId'] = roomNo;
|
||||
await saveDeviceInfo(data);
|
||||
}
|
||||
|
||||
/// 按 MAC 下发设备指令。
|
||||
///
|
||||
/// [command] 取值见 [DeviceCommand];[data] 为指令参数(可为空)。
|
||||
Future<void> sendCommandByMac({
|
||||
required String mac,
|
||||
required String command,
|
||||
Map<String, dynamic>? data,
|
||||
}) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/device/command/sendByMac',
|
||||
data: {'mac': mac, 'command': command, 'data': data ?? <String, dynamic>{}},
|
||||
);
|
||||
}
|
||||
|
||||
/// 按组织下发设备指令(批量)。
|
||||
Future<void> sendCommandByOrg({
|
||||
required String orgId,
|
||||
required String command,
|
||||
Map<String, dynamic>? data,
|
||||
}) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/device/command/sendByOrg',
|
||||
data: {
|
||||
'orgId': orgId,
|
||||
'command': command,
|
||||
'data': data ?? <String, dynamic>{},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取告警列表。
|
||||
///
|
||||
/// 真实后端无 alerts 端点:real 模式下由设备列表推导——
|
||||
/// 仅为异常设备生成告警(offline→critical、warning→warning);
|
||||
/// 在线设备的级别映射为 info(见 [alertLevelFor]),但不产生告警条目。
|
||||
/// mock 模式下返回内置示例告警。
|
||||
Future<List<Alert>> fetchAlerts() async {
|
||||
if (AppConstants.useMock) {
|
||||
final response = await _dio.get<dynamic>('/alerts');
|
||||
final data = response.data;
|
||||
if (data is List) {
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Alert.fromJson)
|
||||
.toList();
|
||||
}
|
||||
return <Alert>[];
|
||||
}
|
||||
|
||||
final devices = await fetchDevices(limit: AppConstants.deviceListPageSize);
|
||||
// 受限账号:仅保留其组织子树内门店所属设备的告警,避免泄露跨组织设备告警。
|
||||
final scope = SessionOrgScope.instance;
|
||||
List<Device> scopedDevices = devices;
|
||||
if (scope.enabled) {
|
||||
final ids = await OrgService().scopedOrgIdSet();
|
||||
if (ids.isNotEmpty) {
|
||||
scopedDevices = devices.where((d) => ids.contains(d.storeId)).toList();
|
||||
}
|
||||
}
|
||||
final alerts = <Alert>[];
|
||||
for (final device in scopedDevices) {
|
||||
if (device.status == DeviceStatus.online) {
|
||||
continue;
|
||||
}
|
||||
alerts.add(_deriveAlert(device));
|
||||
}
|
||||
// 按级别严重程度排序:critical 在前。
|
||||
alerts.sort((a, b) => a.level.index.compareTo(b.level.index));
|
||||
return alerts;
|
||||
}
|
||||
|
||||
/// 设备状态 → 告警级别映射(offline→critical、warning→warning、online→info)。
|
||||
static AlertLevel alertLevelFor(DeviceStatus status) {
|
||||
switch (status) {
|
||||
case DeviceStatus.offline:
|
||||
return AlertLevel.critical;
|
||||
case DeviceStatus.warning:
|
||||
return AlertLevel.warning;
|
||||
case DeviceStatus.online:
|
||||
return AlertLevel.info;
|
||||
}
|
||||
}
|
||||
|
||||
/// 由异常设备推导告警条目。
|
||||
static Alert _deriveAlert(Device device) {
|
||||
final offline = device.status == DeviceStatus.offline;
|
||||
return Alert(
|
||||
id: 'derived-${device.id}',
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
title: offline ? '设备离线' : '设备指标异常',
|
||||
message: offline
|
||||
? '设备 ${device.mac} 最近无心跳上报,请检查电源与网络连接。'
|
||||
: '设备 ${device.mac} 上报状态为「告警」,请登录后台查看详情。',
|
||||
level: alertLevelFor(device.status),
|
||||
occurredAt: device.lastSyncTime ?? DateTime.now(),
|
||||
acknowledged: false,
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 设备分组管理 ================
|
||||
|
||||
/// 获取设备分组列表(分页,按门店筛选)。
|
||||
///
|
||||
/// GET /device/group/list?page=&limit=&orgId=&storeId=
|
||||
///
|
||||
/// 注意:Web 端该接口使用 `orgId` 作为门店过滤参数,移动端同时携带
|
||||
/// `orgId` 与 `storeId`,避免后端因参数名差异而忽略门店过滤,导致
|
||||
/// 返回全部门店分组。
|
||||
Future<List<OrgNode>> getDeviceGroupList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
required String storeId,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/device/group/list',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'orgId': storeId,
|
||||
'storeId': storeId,
|
||||
},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(OrgNode.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取设备分组详情。
|
||||
///
|
||||
/// GET /device/group/info/{id}
|
||||
Future<OrgNode> getDeviceGroupInfo(String id) async {
|
||||
final response = await _dio.get<dynamic>('/device/group/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw DeviceNotFoundException('设备分组信息加载失败:$id');
|
||||
}
|
||||
return OrgNode.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增设备分组。
|
||||
///
|
||||
/// POST /device/group/save,body 为分组数据。
|
||||
Future<void> saveDeviceGroup(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/device/group/save', data: data);
|
||||
}
|
||||
|
||||
/// 修改设备分组。
|
||||
///
|
||||
/// POST /device/group/update,body 为分组数据(含 id)。
|
||||
Future<void> updateDeviceGroup(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/device/group/update', data: data);
|
||||
}
|
||||
|
||||
/// 批量删除设备分组。
|
||||
///
|
||||
/// POST /device/group/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteDeviceGroups(List<String> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/device/group/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 激活码管理 ================
|
||||
|
||||
/// 生成门店激活码。
|
||||
///
|
||||
/// POST /device/activationCode/generate,body 为 `{orgId, maxActivateCount}`。
|
||||
Future<void> generateActivationCode(
|
||||
String orgId,
|
||||
int maxActivateCount,
|
||||
) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/device/activationCode/generate',
|
||||
data: {
|
||||
'orgId': orgId,
|
||||
'maxActivateCount': maxActivateCount,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取激活码信息。
|
||||
///
|
||||
/// GET /device/activationCode/info/{orgId}
|
||||
Future<Map<String, dynamic>> getActivationCodeInfo(String orgId) async {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/device/activationCode/info/$orgId');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw DeviceNotFoundException('激活码信息加载失败:$orgId');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// 重置激活码。
|
||||
///
|
||||
/// POST /device/activationCode/reset/{orgId}
|
||||
Future<void> resetActivationCode(String orgId) async {
|
||||
await _dio.post<dynamic>('/device/activationCode/reset/$orgId');
|
||||
}
|
||||
|
||||
// ================ 门店配置 ================
|
||||
|
||||
/// 获取门店配置。
|
||||
///
|
||||
/// GET /device/storeConfig/info/{orgId}(与 Web 后台门店配置弹窗同源,
|
||||
/// 见 screen-front src/api/device.ts `getStoreConfig`)。
|
||||
Future<Map<String, dynamic>> getStoreConfig(String orgId) async {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/device/storeConfig/info/$orgId');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw DeviceNotFoundException('门店配置加载失败:$orgId');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// 新增或修改门店配置。
|
||||
///
|
||||
/// POST /device/storeConfig/createOrUpdate(与 Web 后台 `createOrUpdateStoreConfig`
|
||||
/// 严格一致;早期误写 /saveOrUpdate 会导致后端路由不匹配、保存失效)。
|
||||
Future<void> createOrUpdateStoreConfig(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/device/storeConfig/createOrUpdate',
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
|
||||
/// 重置门店配置密码。
|
||||
///
|
||||
/// POST /device/storeConfig/resetPassword/{orgId}
|
||||
Future<void> resetStoreConfigPassword(String orgId) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/device/storeConfig/resetPassword/$orgId',
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 扫码绑定(前端占位) ================
|
||||
|
||||
/// 扫码绑定设备到门店/分组。
|
||||
///
|
||||
/// 当前后端无扫码绑定接口,前端占位:本地记录绑定关系并写回设备归属,
|
||||
/// 不发起真实请求。
|
||||
///
|
||||
/// [scanPayload] 为扫码解析出的设备标识(mac/sn/二维码内容);
|
||||
/// [storeId]/[storeName] 为目标门店;[groupId] 为分组(可空)。
|
||||
///
|
||||
/// TODO(后端): POST /device/bind {scanPayload, storeId, groupId},
|
||||
/// 由后台按 scanPayload 定位设备并写归属。
|
||||
Future<void> bindDeviceByScan(
|
||||
String scanPayload,
|
||||
String storeId,
|
||||
String storeName,
|
||||
String? groupId,
|
||||
) async {
|
||||
// 占位:把绑定关系写入本地缓存(key = deviceId,此处以 scanPayload 作为标识)。
|
||||
await StoreLocalCache.instance.saveDeviceBinding(
|
||||
scanPayload,
|
||||
storeId,
|
||||
storeName,
|
||||
groupId,
|
||||
);
|
||||
// 占位:本地 updateDevice 写 groupId/storeName,便于详情页回显。
|
||||
try {
|
||||
await updateDevice({
|
||||
'id': storeId,
|
||||
'groupId': groupId ?? '',
|
||||
'storeName': storeName,
|
||||
});
|
||||
} on Object {
|
||||
// 真实后台可能没有该设备/接口,占位阶段忽略失败。
|
||||
}
|
||||
// TODO(后端): POST /device/bind {scanPayload, storeId, groupId}。
|
||||
debugPrint('[DeviceService.bindDeviceByScan] 占位未上报: '
|
||||
'payload=$scanPayload storeId=$storeId');
|
||||
}
|
||||
|
||||
// ================ 工具方法 ================
|
||||
|
||||
/// 兼容设备列表响应的三种形态:
|
||||
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
|
||||
static List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List) {
|
||||
return data;
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
final list = data['list'];
|
||||
if (list is List) {
|
||||
return list;
|
||||
}
|
||||
final page = data['page'];
|
||||
if (page is Map<String, dynamic> && page['list'] is List) {
|
||||
return page['list'] as List;
|
||||
}
|
||||
}
|
||||
return const <dynamic>[];
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备不存在异常。
|
||||
class DeviceNotFoundException implements Exception {
|
||||
const DeviceNotFoundException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
259
lib/services/intro_service.dart
Normal file
259
lib/services/intro_service.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/intro_category.dart';
|
||||
import 'api_client.dart';
|
||||
import '../utils/session_org_scope.dart';
|
||||
|
||||
/// 介绍页管理服务:分类、子分类、详情。
|
||||
///
|
||||
/// 对接真实「爱视后台」/api/company/* 接口。
|
||||
/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。
|
||||
class IntroService {
|
||||
IntroService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
// ================ 分类管理 ================
|
||||
|
||||
/// 获取介绍页分类列表(分页)。
|
||||
///
|
||||
/// GET /company/introCategory/list?page=&limit=&orgId=
|
||||
Future<List<IntroCategory>> getIntroCategoryList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
dynamic orgId,
|
||||
}) async {
|
||||
// 受限账号(SessionOrgScope 启用)未显式传入 orgId 时,自动带上当前组织,
|
||||
// 避免列表接口忽略组织过滤而返回全量跨组织分类。
|
||||
final effectiveOrgId = orgId ??
|
||||
(SessionOrgScope.instance.enabled
|
||||
? SessionOrgScope.instance.currentOrgId
|
||||
: null);
|
||||
final params = <String, dynamic>{'page': page, 'limit': limit};
|
||||
if (effectiveOrgId != null && effectiveOrgId.toString().isNotEmpty) {
|
||||
params['orgId'] = effectiveOrgId;
|
||||
}
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/company/introCategory/list',
|
||||
queryParameters: params,
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(IntroCategory.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取全部分类(不分页,用于下拉选择)。
|
||||
///
|
||||
/// GET /company/introCategory/all
|
||||
Future<List<IntroCategory>> getIntroCategoryAll() async {
|
||||
final response = await _dio.get<dynamic>('/company/introCategory/all');
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(IntroCategory.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取分类详情。
|
||||
///
|
||||
/// GET /company/introCategory/info/{id}
|
||||
Future<IntroCategory> getIntroCategoryInfo(String id) async {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/company/introCategory/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '分类信息加载失败:$id');
|
||||
}
|
||||
return IntroCategory.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改分类。
|
||||
///
|
||||
/// 有 id 则 POST /company/introCategory/update,否则 POST /company/introCategory/save。
|
||||
Future<void> saveIntroCategory(Map<String, dynamic> data) async {
|
||||
final hasId = data['id'] != null &&
|
||||
data['id'].toString().isNotEmpty &&
|
||||
data['id'].toString() != '0';
|
||||
final url = hasId
|
||||
? '/company/introCategory/update'
|
||||
: '/company/introCategory/save';
|
||||
await _dio.post<dynamic>(url, data: data);
|
||||
}
|
||||
|
||||
/// 批量删除分类。
|
||||
///
|
||||
/// POST /company/introCategory/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteIntroCategory(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/company/introCategory/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 子分类管理 ================
|
||||
|
||||
/// 获取子分类列表(分页)。
|
||||
///
|
||||
/// GET /company/introSubcategory/list?page=&limit=
|
||||
Future<List<IntroSubcategory>> getIntroSubcategoryList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/company/introSubcategory/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(IntroSubcategory.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 按分类 ID 获取子分类列表。
|
||||
///
|
||||
/// GET /company/introSubcategory/listByCategory/{categoryId}
|
||||
Future<List<IntroSubcategory>> getIntroSubcategoryListByCategory(
|
||||
String categoryId,
|
||||
) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/company/introSubcategory/listByCategory/$categoryId',
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(IntroSubcategory.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取子分类详情。
|
||||
///
|
||||
/// GET /company/introSubcategory/info/{id}
|
||||
Future<IntroSubcategory> getIntroSubcategoryInfo(String id) async {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/company/introSubcategory/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '子分类信息加载失败:$id');
|
||||
}
|
||||
return IntroSubcategory.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改子分类。
|
||||
///
|
||||
/// 有 id 则 POST /company/introSubcategory/update,否则 POST /company/introSubcategory/save。
|
||||
Future<void> saveIntroSubcategory(Map<String, dynamic> data) async {
|
||||
final hasId = data['id'] != null &&
|
||||
data['id'].toString().isNotEmpty &&
|
||||
data['id'].toString() != '0';
|
||||
final url = hasId
|
||||
? '/company/introSubcategory/update'
|
||||
: '/company/introSubcategory/save';
|
||||
await _dio.post<dynamic>(url, data: data);
|
||||
}
|
||||
|
||||
/// 批量删除子分类。
|
||||
///
|
||||
/// POST /company/introSubcategory/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteIntroSubcategory(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/company/introSubcategory/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 详情管理 ================
|
||||
|
||||
/// 获取介绍页详情列表(分页)。
|
||||
///
|
||||
/// GET /company/introDetail/list?page=&limit=
|
||||
Future<List<IntroDetail>> getIntroDetailList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/company/introDetail/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(IntroDetail.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 按子分类 ID 获取详情列表。
|
||||
///
|
||||
/// GET /company/introDetail/listBySubcategory/{subcategoryId}
|
||||
Future<List<IntroDetail>> getIntroDetailListBySubcategory(
|
||||
String subcategoryId,
|
||||
) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/company/introDetail/listBySubcategory/$subcategoryId',
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(IntroDetail.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取详情信息。
|
||||
///
|
||||
/// GET /company/introDetail/info/{id}
|
||||
Future<IntroDetail> getIntroDetailInfo(String id) async {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/company/introDetail/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '详情信息加载失败:$id');
|
||||
}
|
||||
return IntroDetail.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改详情。
|
||||
///
|
||||
/// 有 id 则 POST /company/introDetail/update,否则 POST /company/introDetail/save。
|
||||
Future<void> saveIntroDetail(Map<String, dynamic> data) async {
|
||||
final hasId = data['id'] != null &&
|
||||
data['id'].toString().isNotEmpty &&
|
||||
data['id'].toString() != '0';
|
||||
final url = hasId
|
||||
? '/company/introDetail/update'
|
||||
: '/company/introDetail/save';
|
||||
await _dio.post<dynamic>(url, data: data);
|
||||
}
|
||||
|
||||
/// 批量删除详情。
|
||||
///
|
||||
/// POST /company/introDetail/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteIntroDetail(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/company/introDetail/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 工具方法 ================
|
||||
|
||||
/// 兼容列表响应的三种形态:
|
||||
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
|
||||
static List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List) {
|
||||
return data;
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
final list = data['list'];
|
||||
if (list is List) {
|
||||
return list;
|
||||
}
|
||||
final page = data['page'];
|
||||
if (page is Map<String, dynamic> && page['list'] is List) {
|
||||
return page['list'] as List;
|
||||
}
|
||||
}
|
||||
return const <dynamic>[];
|
||||
}
|
||||
}
|
||||
278
lib/services/marquee_service.dart
Normal file
278
lib/services/marquee_service.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/marquee_message.dart';
|
||||
import '../models/store_info.dart';
|
||||
import 'api_client.dart';
|
||||
import 'marquee_web_source.dart';
|
||||
import 'org_service.dart';
|
||||
|
||||
/// 滚动字幕管理服务。
|
||||
///
|
||||
/// 列表/新增/编辑/删除均优先对接 Web 后台 `/theme/scrollingText/*` 接口,
|
||||
/// 同时保留本地 `SharedPreferences` 缓存作为离线兜底。
|
||||
class MarqueeService {
|
||||
MarqueeService({
|
||||
Dio? dio,
|
||||
OrgService? orgService,
|
||||
MarqueeWebSource? webSource,
|
||||
}) : _dio = dio ?? ApiClient.instance.dio,
|
||||
_orgService = orgService ?? OrgService(),
|
||||
_webSource = webSource ?? MarqueeWebSource(dio: ApiClient.instance.dio);
|
||||
|
||||
final Dio _dio;
|
||||
final OrgService _orgService;
|
||||
final MarqueeWebSource _webSource;
|
||||
|
||||
static const _storageKey = 'cloudhome_ops_marquee_messages';
|
||||
|
||||
Future<SharedPreferences> get _prefs => SharedPreferences.getInstance();
|
||||
|
||||
/// 获取滚动字幕列表。
|
||||
///
|
||||
/// 优先从 Web 端 `/theme/scrollingText/list` 获取;若失败则回退本地缓存。
|
||||
Future<List<MarqueeMessage>> getMarqueeList({
|
||||
String? storeId,
|
||||
String? keyword,
|
||||
String method = 'GET',
|
||||
String path = MarqueeWebSource.defaultPath,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Map<String, dynamic>? body,
|
||||
}) async {
|
||||
final webResult = await _webSource.fetchMarquees(
|
||||
method: method,
|
||||
path: path,
|
||||
queryParameters: queryParameters,
|
||||
body: body,
|
||||
storeId: storeId,
|
||||
keyword: keyword,
|
||||
);
|
||||
|
||||
if (webResult.isSuccess) {
|
||||
await _saveAll(webResult.data);
|
||||
return _applyFiltersAndSort(webResult.data, storeId: storeId, keyword: keyword);
|
||||
}
|
||||
|
||||
final localList = await _loadAll();
|
||||
return _applyFiltersAndSort(localList, storeId: storeId, keyword: keyword);
|
||||
}
|
||||
|
||||
List<MarqueeMessage> _applyFiltersAndSort(
|
||||
List<MarqueeMessage> list, {
|
||||
String? storeId,
|
||||
String? keyword,
|
||||
}) {
|
||||
var result = list;
|
||||
if (storeId != null && storeId.isNotEmpty) {
|
||||
result = result.where((m) => m.storeId == storeId).toList();
|
||||
}
|
||||
if (keyword != null && keyword.isNotEmpty) {
|
||||
final lower = keyword.toLowerCase();
|
||||
result = result.where((m) {
|
||||
return m.content.toLowerCase().contains(lower) ||
|
||||
m.storeName.toLowerCase().contains(lower);
|
||||
}).toList();
|
||||
}
|
||||
result.sort((a, b) {
|
||||
// 启用的排在前面,其次按更新时间倒序。
|
||||
final statusCompare = b.status.compareTo(a.status);
|
||||
if (statusCompare != 0) return statusCompare;
|
||||
final bTime = b.updateTime;
|
||||
final aTime = a.updateTime;
|
||||
if (bTime == null || aTime == null) return 0;
|
||||
return bTime.compareTo(aTime);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 新增或修改滚动字幕。
|
||||
///
|
||||
/// 编辑(data 含 id)调用 `/theme/scrollingText/update`,
|
||||
/// 新增调用 `/theme/scrollingText/save`。
|
||||
/// 成功后刷新本地缓存。
|
||||
Future<void> saveMarquee(Map<String, dynamic> data) async {
|
||||
final id = data['id']?.toString();
|
||||
final payload = _buildWebPayload(data);
|
||||
|
||||
try {
|
||||
if (id != null && id.isNotEmpty) {
|
||||
await _dio.post<dynamic>('/theme/scrollingText/update', data: payload);
|
||||
} else {
|
||||
await _dio.post<dynamic>('/theme/scrollingText/save', data: payload);
|
||||
}
|
||||
} on DioException {
|
||||
// 写操作失败时仅更新本地缓存,保证移动端仍可展示。
|
||||
}
|
||||
|
||||
final list = await _loadAll();
|
||||
final now = DateTime.now();
|
||||
final message = MarqueeMessage(
|
||||
id: id ?? _generateId(),
|
||||
storeId: data['storeId']?.toString() ?? '',
|
||||
storeName: data['storeName']?.toString() ?? '',
|
||||
content: data['content']?.toString() ?? '',
|
||||
status: data['status'] is int ? data['status'] as int : 0,
|
||||
speed: int.tryParse(data['speed']?.toString() ?? '50') ?? 50,
|
||||
fontColor: data['fontColor']?.toString(),
|
||||
backgroundColor: data['backgroundColor']?.toString(),
|
||||
fontSize: int.tryParse(data['fontSize']?.toString() ?? ''),
|
||||
level: int.tryParse(data['level']?.toString() ?? ''),
|
||||
startTime: _tryParseDateTime(data['startTime']),
|
||||
endTime: _tryParseDateTime(data['endTime']),
|
||||
createTime: now,
|
||||
updateTime: now,
|
||||
);
|
||||
|
||||
final index = list.indexWhere((m) => m.id == message.id);
|
||||
if (index >= 0) {
|
||||
list[index] = message;
|
||||
} else {
|
||||
list.add(message);
|
||||
}
|
||||
await _saveAll(list);
|
||||
}
|
||||
|
||||
/// 删除滚动字幕。
|
||||
///
|
||||
/// 优先调用 Web 端 `/theme/scrollingText/delete`,失败也移除本地缓存。
|
||||
Future<void> deleteMarquee(String id) async {
|
||||
final numericId = int.tryParse(id);
|
||||
if (numericId != null) {
|
||||
try {
|
||||
await _dio.post<dynamic>(
|
||||
'/theme/scrollingText/delete',
|
||||
data: {'ids': [numericId]},
|
||||
);
|
||||
} on DioException {
|
||||
// 网络失败时继续移除本地记录。
|
||||
}
|
||||
}
|
||||
|
||||
final list = await _loadAll();
|
||||
list.removeWhere((m) => m.id == id);
|
||||
await _saveAll(list);
|
||||
}
|
||||
|
||||
/// 启用/禁用单条滚动字幕。
|
||||
///
|
||||
/// 直接调用 Web 端 `/theme/scrollingText/update` 修改 status。
|
||||
Future<void> setMarqueeStatus(String id, {required int status}) async {
|
||||
final list = await _loadAll();
|
||||
final index = list.indexWhere((m) => m.id == id);
|
||||
if (index < 0) return;
|
||||
|
||||
final item = list[index];
|
||||
final numericId = int.tryParse(id);
|
||||
if (numericId != null) {
|
||||
try {
|
||||
await _dio.post<dynamic>(
|
||||
'/theme/scrollingText/update',
|
||||
data: item.copyWith(status: status).toWebJson(),
|
||||
);
|
||||
} on DioException {
|
||||
// 接口失败时仅更新本地状态。
|
||||
}
|
||||
}
|
||||
|
||||
list[index] = item.copyWith(
|
||||
status: status,
|
||||
updateTime: DateTime.now(),
|
||||
);
|
||||
await _saveAll(list);
|
||||
}
|
||||
|
||||
/// 获取当前用户可见的门店列表(用于新增/编辑时选择目标门店)。
|
||||
Future<List<StoreInfo>> getStores() async {
|
||||
return _orgService.getMyStores();
|
||||
}
|
||||
|
||||
/// 构造 Web 后台可接收的请求体。
|
||||
Map<String, dynamic> _buildWebPayload(Map<String, dynamic> data) {
|
||||
final storeId = data['storeId']?.toString() ?? '';
|
||||
final storeName = data['storeName']?.toString() ?? '';
|
||||
final content = data['content']?.toString() ?? '';
|
||||
final speed = int.tryParse(data['speed']?.toString() ?? '50') ?? 50;
|
||||
final fontSize = int.tryParse(data['fontSize']?.toString() ?? '');
|
||||
final level = int.tryParse(data['level']?.toString() ?? '');
|
||||
final status = data['status'] is int ? data['status'] as int : 0;
|
||||
final fontColor = data['fontColor']?.toString();
|
||||
final backgroundColor = data['backgroundColor']?.toString();
|
||||
final startTime = _tryParseDateTime(data['startTime']);
|
||||
final endTime = _tryParseDateTime(data['endTime']);
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'orgId': int.tryParse(storeId) ?? storeId,
|
||||
'orgName': storeName,
|
||||
'content': content,
|
||||
'scrollSpeed': speed,
|
||||
'status': status,
|
||||
if (fontSize != null) 'fontSize': fontSize,
|
||||
if (level != null) 'level': level,
|
||||
if (fontColor != null && fontColor.isNotEmpty) 'fontColor': fontColor,
|
||||
if (backgroundColor != null && backgroundColor.isNotEmpty)
|
||||
'bgColor': backgroundColor,
|
||||
if (startTime != null)
|
||||
'startTime': _formatTime(startTime),
|
||||
if (endTime != null)
|
||||
'endTime': _formatTime(endTime),
|
||||
};
|
||||
|
||||
final id = data['id']?.toString();
|
||||
if (id != null && id.isNotEmpty) {
|
||||
payload['id'] = int.tryParse(id) ?? id;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
Future<List<MarqueeMessage>> _loadAll() async {
|
||||
final prefs = await _prefs;
|
||||
final raw = prefs.getString(_storageKey);
|
||||
if (raw == null || raw.isEmpty) return <MarqueeMessage>[];
|
||||
try {
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MarqueeMessage.fromJson)
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return <MarqueeMessage>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAll(List<MarqueeMessage> list) async {
|
||||
final prefs = await _prefs;
|
||||
final raw = jsonEncode(list.map((m) => m.toJson()).toList());
|
||||
await prefs.setString(_storageKey, raw);
|
||||
}
|
||||
|
||||
String _generateId() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final rand = Random().nextInt(10000).toString().padLeft(4, '0');
|
||||
return '${now}_$rand';
|
||||
}
|
||||
|
||||
DateTime? _tryParseDateTime(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is DateTime) return value;
|
||||
final text = value.toString().trim();
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
// 兼容 Web 端纯时间字符串 `HH:mm:ss`。
|
||||
if (RegExp(r'^\d{2}:\d{2}:\d{2}$').hasMatch(text)) {
|
||||
return DateTime.tryParse('2000-01-01 $text');
|
||||
}
|
||||
|
||||
return DateTime.tryParse(text) ??
|
||||
DateTime.tryParse(text.replaceFirst(' ', 'T'));
|
||||
}
|
||||
|
||||
String _formatTime(DateTime value) {
|
||||
return '${value.hour.toString().padLeft(2, '0')}:'
|
||||
'${value.minute.toString().padLeft(2, '0')}:'
|
||||
'${value.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
343
lib/services/marquee_web_source.dart
Normal file
343
lib/services/marquee_web_source.dart
Normal file
@@ -0,0 +1,343 @@
|
||||
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,
|
||||
}
|
||||
146
lib/services/media_service.dart
Normal file
146
lib/services/media_service.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/media_asset.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 媒体资源管理服务:媒体资产与排行榜。
|
||||
///
|
||||
/// 对接真实「爱视后台」:
|
||||
/// - 媒体资产:/api/sys/mediaAsset/*
|
||||
/// - 媒体排行榜:/api/sys/mediaAssetRank/*
|
||||
class MediaService {
|
||||
MediaService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
// ================ 媒体资产管理 ================
|
||||
|
||||
/// 获取媒体资产列表(分页)。
|
||||
///
|
||||
/// GET /sys/mediaAsset/list?page=&limit=
|
||||
Future<List<MediaAsset>> getMediaAssetList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/mediaAsset/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MediaAsset.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取媒体资产详情。
|
||||
///
|
||||
/// GET /sys/mediaAsset/info/{id}
|
||||
Future<MediaAsset> getMediaAssetInfo(String id) async {
|
||||
final response = await _dio.get<dynamic>('/sys/mediaAsset/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '媒体资产信息加载失败:$id');
|
||||
}
|
||||
return MediaAsset.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改媒体资产。
|
||||
///
|
||||
/// POST /sys/mediaAsset/save,body 为媒体数据。
|
||||
Future<void> saveMediaAsset(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/sys/mediaAsset/save', data: data);
|
||||
}
|
||||
|
||||
/// 批量删除媒体资产。
|
||||
///
|
||||
/// POST /sys/mediaAsset/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteMediaAsset(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/mediaAsset/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 媒体排行榜 ================
|
||||
|
||||
/// 获取媒体排行榜 Top 10。
|
||||
///
|
||||
/// GET /sys/mediaAssetRank/top10
|
||||
Future<List<MediaRankItem>> getMediaAssetRankTop10() async {
|
||||
final response = await _dio.get<dynamic>('/sys/mediaAssetRank/top10');
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MediaRankItem.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 设置排行榜(批量设置,覆盖原有列表)。
|
||||
///
|
||||
/// POST /sys/mediaAssetRank/set,body 为 `{codeList: [...]}`。
|
||||
Future<void> setMediaAssetRank(List<String> codeList) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/mediaAssetRank/set',
|
||||
data: {'codeList': codeList},
|
||||
);
|
||||
}
|
||||
|
||||
/// 从排行榜移除指定媒体。
|
||||
///
|
||||
/// POST /sys/mediaAssetRank/remove,body 为 `{code: ...}`。
|
||||
Future<void> removeMediaAssetRank(String code) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/mediaAssetRank/remove',
|
||||
data: {'code': code},
|
||||
);
|
||||
}
|
||||
|
||||
/// 将指定媒体上移一位。
|
||||
///
|
||||
/// POST /sys/mediaAssetRank/moveUp,body 为 `{code: ...}`。
|
||||
Future<void> moveUpMediaAssetRank(String code) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/mediaAssetRank/moveUp',
|
||||
data: {'code': code},
|
||||
);
|
||||
}
|
||||
|
||||
/// 将指定媒体下移一位。
|
||||
///
|
||||
/// POST /sys/mediaAssetRank/moveDown,body 为 `{code: ...}`。
|
||||
Future<void> moveDownMediaAssetRank(String code) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/mediaAssetRank/moveDown',
|
||||
data: {'code': code},
|
||||
);
|
||||
}
|
||||
|
||||
/// 清空排行榜。
|
||||
///
|
||||
/// POST /sys/mediaAssetRank/clear
|
||||
Future<void> clearMediaAssetRank() async {
|
||||
await _dio.post<dynamic>('/sys/mediaAssetRank/clear');
|
||||
}
|
||||
|
||||
// ================ 工具方法 ================
|
||||
|
||||
/// 兼容列表响应的三种形态:
|
||||
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
|
||||
static List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List) {
|
||||
return data;
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
final list = data['list'];
|
||||
if (list is List) {
|
||||
return list;
|
||||
}
|
||||
final page = data['page'];
|
||||
if (page is Map<String, dynamic> && page['list'] is List) {
|
||||
return page['list'] as List;
|
||||
}
|
||||
}
|
||||
return const <dynamic>[];
|
||||
}
|
||||
}
|
||||
747
lib/services/org_service.dart
Normal file
747
lib/services/org_service.dart
Normal file
@@ -0,0 +1,747 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../models/org_node.dart';
|
||||
import '../models/store_config.dart';
|
||||
import '../models/store_info.dart';
|
||||
import '../utils/session_org_scope.dart';
|
||||
import '../utils/store_local_cache.dart';
|
||||
import 'api_client.dart';
|
||||
import 'upload_service.dart';
|
||||
|
||||
/// 组织与门店管理服务:组织树、门店、设备分组查询。
|
||||
///
|
||||
/// 对接真实「爱视后台」/api/sys/org/* 与 /api/sys/store/* 接口。
|
||||
/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。
|
||||
class OrgService {
|
||||
OrgService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
// ================ 组织管理 ================
|
||||
|
||||
/// 获取组织树(全量树形结构,当前用户可见范围)。
|
||||
///
|
||||
/// GET /sys/org/tree
|
||||
///
|
||||
/// 注:后端该接口在部分部署下会返回**全量**组织树,不做数据权限收敛,
|
||||
/// 因此这里在客户端按 [SessionOrgScope] 把树裁剪到「当前登录用户所属组织
|
||||
/// 子树」(含自身与全部后代)。[SessionOrgScope] 未启用(管理员 / orgId 为空)
|
||||
/// 时原样返回,行为与后端保持一致。
|
||||
Future<List<OrgNode>> getOrgTree() async {
|
||||
final response = await _dio.get<dynamic>('/sys/org/tree');
|
||||
final list = _extractList(response.data);
|
||||
final tree = list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(OrgNode.fromJson)
|
||||
.toList();
|
||||
return _scopeTree(tree);
|
||||
}
|
||||
|
||||
/// 获取组织列表(分页)。
|
||||
///
|
||||
/// GET /sys/org/list?page=&limit=
|
||||
Future<List<OrgNode>> getOrgList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/org/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(OrgNode.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取组织扁平列表(不分页,用于下拉选择)。
|
||||
///
|
||||
/// GET /sys/org/flatList
|
||||
///
|
||||
/// 命中 [SessionOrgScope] 时,仅保留当前用户组织子树内的节点,
|
||||
/// 避免下拉选项出现其他组织的越权数据。
|
||||
Future<List<OrgNode>> getOrgFlatList() async {
|
||||
final response = await _dio.get<dynamic>('/sys/org/flatList');
|
||||
final list = _extractList(response.data);
|
||||
final all = list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(OrgNode.fromJson)
|
||||
.toList();
|
||||
final scope = SessionOrgScope.instance;
|
||||
if (!scope.enabled) return all;
|
||||
final ids = await scopedOrgIdSet();
|
||||
if (ids.isEmpty) return all;
|
||||
return all.where((n) => ids.contains(n.id)).toList();
|
||||
}
|
||||
|
||||
/// 获取组织下拉选项(同扁平列表,语义区分)。
|
||||
///
|
||||
/// GET /sys/org/select
|
||||
///
|
||||
/// 同样受 [SessionOrgScope] 收敛,仅返回当前用户组织子树内的节点。
|
||||
Future<List<OrgNode>> getOrgSelect() async {
|
||||
final response = await _dio.get<dynamic>('/sys/org/select');
|
||||
final list = _extractList(response.data);
|
||||
final all = list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(OrgNode.fromJson)
|
||||
.toList();
|
||||
final scope = SessionOrgScope.instance;
|
||||
if (!scope.enabled) return all;
|
||||
final ids = await scopedOrgIdSet();
|
||||
if (ids.isEmpty) return all;
|
||||
return all.where((n) => ids.contains(n.id)).toList();
|
||||
}
|
||||
|
||||
/// 获取组织详情。
|
||||
///
|
||||
/// GET /sys/org/info/{id}
|
||||
Future<OrgNode> getOrgInfo(String id) async {
|
||||
final response = await _dio.get<dynamic>('/sys/org/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '组织信息加载失败:$id');
|
||||
}
|
||||
return OrgNode.fromJson(data);
|
||||
}
|
||||
|
||||
/// 获取渠道列表(type=1 的组织节点)。
|
||||
///
|
||||
/// GET /sys/org/channels
|
||||
Future<List<OrgNode>> getOrgChannels() async {
|
||||
final response = await _dio.get<dynamic>('/sys/org/channels');
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(OrgNode.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取渠道下的代理商树(渠道 → 代理商 → 门店)。
|
||||
///
|
||||
/// GET /sys/org/channelAgentTree?channelId={channelId}
|
||||
///
|
||||
/// 注:与 Web 后台 [org.ts] 的 [getChannelAgentTree] 严格一致——channelId 走
|
||||
/// **查询参数**而非路径段(早期误写成 /channelAgentTree/{id} 会导致后端取不到
|
||||
/// 渠道、代理树加载为空)。
|
||||
Future<List<OrgNode>> getChannelAgentTree(String channelId) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/org/channelAgentTree',
|
||||
queryParameters: {'channelId': channelId},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(OrgNode.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取「渠道商管理」统一树(渠道 → 代理/子代理)。
|
||||
///
|
||||
/// 后端无单一渠道商全量树接口,此处组合两个既有接口:
|
||||
/// 1. GET /sys/org/channels 拉顶层渠道列表;
|
||||
/// 2. 对每个渠道 GET /sys/org/channelAgentTree?channelId=xxx 拉其下代理/子代理树。
|
||||
///
|
||||
/// **降级策略**:若 /sys/org/channels 失败(部分部署下该端点可能返回服务端异常
|
||||
/// 或权限错误),自动回退到 GET /sys/org/tree(全量组织树,已在门店列表中验证可用),
|
||||
/// 从中提取 type=1(渠道)与 type=2(代理)节点及其子树,保证页面不白屏。
|
||||
///
|
||||
/// 单渠道 agentTree 请求失败不影响整体(该渠道降级为无子节点),其余渠道正常展示。
|
||||
/// 结果按渠道 orderNo 升序、渠道内子节点递归按 orderNo 升序,便于稳定展示。
|
||||
Future<List<OrgNode>> getChannelTree() async {
|
||||
// 主路径:/sys/org/channels + /sys/org/channelAgentTree
|
||||
try {
|
||||
final channels = await getOrgChannels();
|
||||
final tree = <OrgNode>[];
|
||||
for (final channel in channels) {
|
||||
List<OrgNode> agents = const <OrgNode>[];
|
||||
try {
|
||||
agents = await getChannelAgentTree(channel.id);
|
||||
} catch (_) {
|
||||
// 单渠道拉取失败:降级为无子节点,继续后续渠道。
|
||||
}
|
||||
tree.add(channel.copyWith(children: _sortTree(agents)));
|
||||
}
|
||||
tree.sort(_byOrderNo);
|
||||
if (tree.isNotEmpty) return tree;
|
||||
} catch (e) {
|
||||
debugPrint('[OrgService.getChannelTree] /sys/org/channels 主路径失败: $e');
|
||||
}
|
||||
|
||||
// 降级路径:从全量组织树递归提取渠道/代理节点。
|
||||
try {
|
||||
final orgTree = await getOrgTree();
|
||||
final fallback = _collectChannelNodes(orgTree);
|
||||
if (fallback.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[OrgService.getChannelTree] 已从 /sys/org/tree 降级加载 '
|
||||
'${fallback.length} 个渠道/代理节点',
|
||||
);
|
||||
return _sortTree(fallback);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[OrgService.getChannelTree] 降级路径也失败: $e');
|
||||
}
|
||||
|
||||
// 双路径均失败:抛出原始异常让 UI 展示错误。
|
||||
throw ApiException(500, '无法加载渠道数据,请检查网络或联系管理员');
|
||||
}
|
||||
|
||||
/// 从组织树中**递归**收集渠道(type=1)/代理(type=2) 节点,保留它们之间的层级关系。
|
||||
///
|
||||
/// 平台(type=0)/门店(type=3)/分组(type=4) 等非渠道节点作为「过路节点」被跳过
|
||||
/// (不单独展示),但其下方若含有渠道/代理则继续下钻——确保即使组织树的
|
||||
/// 顶层是平台节点(type=0),其下的渠道与代理仍能被正确提取。
|
||||
///
|
||||
/// 例:平台 → 渠道A → 代理B → 门店C → 代理D
|
||||
/// 提取结果:[渠道A(子=[代理B(子=[代理D])])]
|
||||
static List<OrgNode> _collectChannelNodes(List<OrgNode> nodes) {
|
||||
final result = <OrgNode>[];
|
||||
for (final node in nodes) {
|
||||
if (node.type == 1 || node.type == 2) {
|
||||
// 渠道/代理:保留自身,递归过滤子孙仅保留渠道/代理支系。
|
||||
result.add(node.copyWith(children: _collectChannelNodes(node.children)));
|
||||
} else {
|
||||
// 非渠道节点:跳过自身,但透传其下方可能含渠道/代理的分支。
|
||||
if (node.children.isNotEmpty) {
|
||||
result.addAll(_collectChannelNodes(node.children));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 递归按 orderNo 升序排序树(节点及各级子节点)。
|
||||
static List<OrgNode> _sortTree(List<OrgNode> nodes) {
|
||||
final sorted = [...nodes]..sort(_byOrderNo);
|
||||
return sorted
|
||||
.map((n) => n.copyWith(children: _sortTree(n.children)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 按 orderNo 升序的比较器(orderNo 相同再按 id 兜底,保证稳定)。
|
||||
static int _byOrderNo(OrgNode a, OrgNode b) {
|
||||
if (a.orderNo != b.orderNo) return a.orderNo.compareTo(b.orderNo);
|
||||
return a.id.compareTo(b.id);
|
||||
}
|
||||
|
||||
/// 新增门店 / 组织。
|
||||
///
|
||||
/// POST /sys/org/save,body **不带 id/orgId**(带 id 会走 insert 分支,
|
||||
/// 报「数据库中已存在该记录」;只带 orgId 会被忽略而误建新记录)。
|
||||
/// 门店为 type='STORE',且**必填 province**,否则报「门店省份不能为空」。
|
||||
/// 返回后端 data 中的 orgId(新增门店时可用于后续创建激活码等)。
|
||||
Future<String?> createOrg(Map<String, dynamic> data) async {
|
||||
final payload = Map<String, dynamic>.from(data);
|
||||
// 新增必须剥离主键,防止后端误判为更新或重复插入。
|
||||
payload.remove('id');
|
||||
payload.remove('orgId');
|
||||
final response = await _dio.post<dynamic>('/sys/org/save', data: payload);
|
||||
final result = response.data;
|
||||
if (result is Map<String, dynamic>) {
|
||||
return result['orgId']?.toString() ?? result['id']?.toString();
|
||||
}
|
||||
if (result is String) return result;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 修改门店 / 组织。
|
||||
///
|
||||
/// POST /sys/org/update,后端**只认 id**(传 orgId 会报「组织不存在」),
|
||||
/// 因此强制以 id 为主键并剥离 orgId,避免编辑被误判。
|
||||
/// 返回后端 data 中的 orgId。
|
||||
Future<String?> updateOrg(Map<String, dynamic> data) async {
|
||||
final payload = Map<String, dynamic>.from(data);
|
||||
// 后端 update 仅使用 id;orgId 会被忽略甚至触发「组织不存在」,必须剥离。
|
||||
final id = (data['id'] ?? data['orgId'])?.toString() ?? '';
|
||||
payload.remove('orgId');
|
||||
payload['id'] = id;
|
||||
final response = await _dio.post<dynamic>('/sys/org/update', data: payload);
|
||||
final result = response.data;
|
||||
if (result is Map<String, dynamic>) {
|
||||
return result['orgId']?.toString() ?? result['id']?.toString();
|
||||
}
|
||||
if (result is String) return result;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 新增或修改组织(兼容旧调用方,如组织树页面)。
|
||||
///
|
||||
/// 注意:后端**未注册** `/sys/org/saveOrUpdate` 路由,调用必返回
|
||||
/// 「未知异常,请联系管理员」。故按 body 是否携带 id/orgId 自动分流到
|
||||
/// [createOrg](新增)或 [updateOrg](编辑)。
|
||||
Future<String?> saveOrUpdateOrg(Map<String, dynamic> data) async {
|
||||
final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) ||
|
||||
(data['orgId'] != null && data['orgId'].toString().isNotEmpty);
|
||||
return isEdit ? updateOrg(data) : createOrg(data);
|
||||
}
|
||||
|
||||
/// 删除组织(含子节点校验)。
|
||||
///
|
||||
/// POST /sys/org/delete/{id}
|
||||
Future<void> deleteOrg(String id) async {
|
||||
await _dio.post<dynamic>('/sys/org/delete/$id');
|
||||
}
|
||||
|
||||
// ================ 门店管理 ================
|
||||
|
||||
/// 获取当前登录用户可见的门店列表。
|
||||
///
|
||||
/// 排序 / 展示需与 Web 后台「门店管理」保持一致:Web 后台门店管理读取的是
|
||||
/// 组织树(门店专用接口 /sys/store/* 在该账号下多返回 500,不可用),故
|
||||
/// 第 1 步直接读组织树并递归收集 type=3 门店,同时透传节点的 [OrgNode.createdAt]
|
||||
/// 作为 [StoreInfo.createTime] 供「建店时间倒序」使用;门店专用接口仅作兜底。
|
||||
///
|
||||
/// 回退链(任一成功且非空即用):
|
||||
/// 1. 组织树 GET /sys/org/tree,递归收集 type=3 门店(透传 createdAt);
|
||||
/// 2. 门店专用 GET /sys/store/myStores;
|
||||
/// 3. 登录组织门店 GET /sys/store/listByLoginOrg。
|
||||
Future<List<StoreInfo>> getMyStores() async {
|
||||
// 第 1 步:组织树(与 Web 后台门店管理同源,且透传 createdAt)。
|
||||
try {
|
||||
final tree = await getOrgTree();
|
||||
final stores = _collectStores(tree);
|
||||
if (stores.isNotEmpty) return stores;
|
||||
} catch (_) {
|
||||
// 继续回退。
|
||||
}
|
||||
// 第 2 步:myStores。
|
||||
try {
|
||||
final response = await _dio.get<dynamic>('/sys/store/myStores');
|
||||
final list = _extractList(response.data);
|
||||
final stores = list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(StoreInfo.fromJson)
|
||||
.toList();
|
||||
if (stores.isNotEmpty) return stores;
|
||||
} catch (_) {
|
||||
// 继续回退。
|
||||
}
|
||||
// 第 3 步:按登录组织查门店。
|
||||
try {
|
||||
final stores = await getStoreListByLoginOrg();
|
||||
if (stores.isNotEmpty) return stores;
|
||||
} catch (_) {
|
||||
// 全部失败,返回空。
|
||||
}
|
||||
return const <StoreInfo>[];
|
||||
}
|
||||
|
||||
/// 统计门店数量(type=3)。
|
||||
///
|
||||
/// 复用 [getMyStores] 的回退链,确保门店总数与门店列表一致,
|
||||
/// 避免组织树异常时直接返回 0 导致「门店总数=0」的问题。
|
||||
Future<int> fetchStoreCount() async {
|
||||
try {
|
||||
return (await getMyStores()).length;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归把组织树扁平化,并过滤出门店(type=3 或 type='STORE')。
|
||||
///
|
||||
/// 新接口 /sys/org/tree 返回的 type 为字符串(CHANNEL/SERVICE_PROVIDER/STORE/GROUP),
|
||||
/// 经 [OrgNode.fromJson] 已映射为 int,但此处兼容判断字符串更保险。
|
||||
///
|
||||
/// 组织树节点通常不携带 [parentId],而是通过 [children] 嵌套表达层级;
|
||||
/// 因此递归时把当前节点 [id] 作为子节点的 [parentId] 传入,确保门店的
|
||||
/// [StoreInfo.parentId] 能正确指向其在树中的直接父组织(渠道/代理商)。
|
||||
static List<StoreInfo> _collectStores(List<OrgNode> nodes, {String parentId = ''}) {
|
||||
final stores = <StoreInfo>[];
|
||||
for (final node in nodes) {
|
||||
final isStore = node.type == 3 || node.type.toString().toUpperCase() == 'STORE';
|
||||
if (isStore) {
|
||||
final province = node.province.trim();
|
||||
final city = node.city.trim();
|
||||
final address = node.address.trim();
|
||||
final fullAddress = <String>[];
|
||||
if (province.isNotEmpty) fullAddress.add(province);
|
||||
if (city.isNotEmpty) fullAddress.add(city);
|
||||
if (address.isNotEmpty) fullAddress.add(address);
|
||||
// 优先使用节点自身携带的 parentId;树接口未返回时,使用递归上下文中的父节点 ID。
|
||||
final effectiveParentId = node.parentId.isNotEmpty ? node.parentId : parentId;
|
||||
stores.add(
|
||||
StoreInfo(
|
||||
orgId: node.id,
|
||||
parentId: effectiveParentId.isEmpty ? null : effectiveParentId,
|
||||
name: node.name,
|
||||
address: fullAddress.join(''),
|
||||
contactPhone: node.contactPhone,
|
||||
activationCode: node.activationCode,
|
||||
status: 0,
|
||||
// 透传组织树节点的到期时间(部分部署的 /sys/org/tree 会返回
|
||||
// expireTime/expire_time;缺失则为 null,列表项将据此显示「无到期时间」)。
|
||||
expireTime: node.expireTime,
|
||||
createTime: node.createdAt,
|
||||
deviceLimit: node.groupLimit,
|
||||
// 新接口 /sys/org/tree 返回的 themeId(如 35/36/37),
|
||||
// 转为字符串供前端 StoreThemePresets 匹配或回显。
|
||||
themeId: node.themeId?.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (node.children.isNotEmpty) {
|
||||
stores.addAll(_collectStores(node.children, parentId: node.id));
|
||||
}
|
||||
}
|
||||
return stores;
|
||||
}
|
||||
|
||||
/// 按组织 ID 获取门店列表。
|
||||
///
|
||||
/// GET /sys/store/byOrgId/{orgId}
|
||||
Future<List<StoreInfo>> getStoresByOrgId(String orgId) async {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/sys/store/byOrgId/$orgId');
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(StoreInfo.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 按门店 ID 获取设备分组列表。
|
||||
///
|
||||
/// GET /sys/store/deviceGroups/{storeId}
|
||||
Future<List<OrgNode>> getDeviceGroupsByStoreId(String storeId) async {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/sys/store/deviceGroups/$storeId');
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(OrgNode.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 门店搜索(分页,支持多条件筛选)。
|
||||
///
|
||||
/// GET /sys/store/search?page=&limit=&orgId=&name=&status=
|
||||
///
|
||||
/// 未显式传入 [orgId] 时,自动带上 [SessionOrgScope] 的当前用户组织,
|
||||
/// 防止未指定组织时越权查询到其他组织的门店([SessionOrgScope] 未启用时
|
||||
/// 不附加,保留管理员全量查询能力)。
|
||||
Future<List<StoreInfo>> searchStore({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? orgId,
|
||||
String? name,
|
||||
int? status,
|
||||
}) async {
|
||||
final effectiveOrgId = orgId ??
|
||||
(SessionOrgScope.instance.enabled
|
||||
? SessionOrgScope.instance.currentOrgId
|
||||
: null);
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/store/search',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
if (effectiveOrgId != null) 'orgId': effectiveOrgId,
|
||||
if (name != null) 'name': name,
|
||||
if (status != null) 'status': status,
|
||||
},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(StoreInfo.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取当前登录组织下的门店列表(可按名称模糊查询)。
|
||||
///
|
||||
/// GET /sys/store/listByLoginOrg?name=
|
||||
Future<List<StoreInfo>> getStoreListByLoginOrg({String? name}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/store/listByLoginOrg',
|
||||
queryParameters: {
|
||||
if (name != null) 'name': name,
|
||||
},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(StoreInfo.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 查询门店配置(到期时间 / 禁用状态等)。
|
||||
///
|
||||
/// GET /device/storeConfig/info/{orgId}
|
||||
///
|
||||
/// 与 Web 后台「门店配置」弹窗同源(screen-front `getStoreConfig`)。
|
||||
/// 组织树与通用门店列表接口通常不返回 `expireTime`,门店的到期时间必须由此
|
||||
/// 端点补全——列表/详情均用它做权威兜底。接口异常或无数据返回 [StoreConfig.empty]。
|
||||
Future<StoreConfig> getStoreConfig(String orgId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.get<dynamic>('/device/storeConfig/info/$orgId');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) return StoreConfig.empty();
|
||||
return StoreConfig.fromJson(data);
|
||||
} catch (_) {
|
||||
return StoreConfig.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// ================ 门店扩展字段(前端占位) ================
|
||||
|
||||
/// 新增或更新门店(含主题/品牌/省市区等扩展字段)。
|
||||
///
|
||||
/// 门店本质上是 type='STORE' 的组织节点。按是否携带 id/orgId 分流:
|
||||
/// - 新增 → [createOrg](POST /sys/org/save,不带主键,必填 province);
|
||||
/// - 编辑 → [updateOrg](POST /sys/org/update,只认 id)。
|
||||
/// 返回后端生成的 orgId(新增门店时可用于创建激活码)。
|
||||
Future<String?> saveStore(Map<String, dynamic> data) async {
|
||||
// 真实上报后端(新增/编辑分流到正确路由)。
|
||||
final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) ||
|
||||
(data['orgId'] != null && data['orgId'].toString().isNotEmpty);
|
||||
final result = isEdit ? updateOrg(data) : createOrg(data);
|
||||
// 扩展字段写本地缓存:用真实 orgId 回填,避免新建时因 orgId 为空而丢失
|
||||
// 主题/品牌/省市区等建店配置(Web 端同源,移动端本地兜底回显)。
|
||||
final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? '';
|
||||
if (realOrgId.isNotEmpty) {
|
||||
final ext = Map<String, dynamic>.from(data)..['orgId'] = realOrgId;
|
||||
await _persistStoreExtension(ext);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 更新门店(含扩展字段)。
|
||||
///
|
||||
/// 固定走 [updateOrg](POST /sys/org/update,只认 id)。
|
||||
Future<String?> updateStore(Map<String, dynamic> data) async {
|
||||
final result = updateOrg(data);
|
||||
final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? '';
|
||||
if (realOrgId.isNotEmpty) {
|
||||
final ext = Map<String, dynamic>.from(data)..['orgId'] = realOrgId;
|
||||
await _persistStoreExtension(ext);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 设置门店主题皮肤。
|
||||
///
|
||||
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setTheme`。
|
||||
/// 上报失败时异常向外传播,由调用方决定提示策略(门店列表页会静默忽略,
|
||||
/// 不影响门店本身已保存的结果)。
|
||||
Future<void> setStoreTheme(String orgId, String themeId) async {
|
||||
// 本地缓存兜底:即使上报失败,本机回显仍可用。
|
||||
await StoreLocalCache.instance.saveTheme(orgId, themeId);
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/store/setTheme',
|
||||
data: {'orgId': orgId, 'themeId': themeId},
|
||||
);
|
||||
}
|
||||
|
||||
/// 设置门店启动图/Logo。
|
||||
///
|
||||
/// [splashUrl]/[logoUrl] 可以是:
|
||||
/// - 远程 URL(http/https 开头)→ 直接上报;
|
||||
/// - 本地文件路径 → 先经 [UploadService.uploadImage] 上传拿 CDN URL 再上报。
|
||||
///
|
||||
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setBranding`。
|
||||
Future<void> setStoreBranding(
|
||||
String orgId,
|
||||
String splashUrl,
|
||||
String logoUrl,
|
||||
) async {
|
||||
// 本地缓存兜底。
|
||||
await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl);
|
||||
|
||||
final upload = UploadService();
|
||||
Future<String> resolve(String raw) async {
|
||||
if (raw.isEmpty) return raw;
|
||||
// 已是远程 URL 直接复用;否则当作本地路径上传拿 CDN URL。
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
|
||||
return upload.uploadImage(XFile(raw), mediaType: 'image');
|
||||
}
|
||||
|
||||
final splash = await resolve(splashUrl);
|
||||
final logo = await resolve(logoUrl);
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/store/setBranding',
|
||||
data: {'orgId': orgId, 'splashUrl': splash, 'logoUrl': logo},
|
||||
);
|
||||
}
|
||||
|
||||
/// 设置门店省市区归属与详细地址。
|
||||
///
|
||||
/// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setRegion`。
|
||||
Future<void> setRegion(
|
||||
String orgId,
|
||||
String provinceCode,
|
||||
String cityCode,
|
||||
String districtCode,
|
||||
String addressDetail,
|
||||
) async {
|
||||
await StoreLocalCache.instance.saveRegion(
|
||||
orgId,
|
||||
provinceCode,
|
||||
cityCode,
|
||||
districtCode,
|
||||
addressDetail,
|
||||
);
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/store/setRegion',
|
||||
data: {
|
||||
'orgId': orgId,
|
||||
'provinceCode': provinceCode,
|
||||
'cityCode': cityCode,
|
||||
'districtCode': districtCode,
|
||||
'addressDetail': addressDetail,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 把门店扩展字段写入本地缓存([data] 缺省字段忽略)。
|
||||
Future<void> _persistStoreExtension(Map<String, dynamic> data) async {
|
||||
final orgId = (data['orgId'] ?? data['id'])?.toString() ?? '';
|
||||
if (orgId.isEmpty) return;
|
||||
final themeId = data['themeId']?.toString();
|
||||
if (themeId != null && themeId.isNotEmpty) {
|
||||
await StoreLocalCache.instance.saveTheme(orgId, themeId);
|
||||
}
|
||||
final splashUrl = data['splashUrl']?.toString() ?? '';
|
||||
final logoUrl = data['logoUrl']?.toString() ?? '';
|
||||
if (splashUrl.isNotEmpty || logoUrl.isNotEmpty) {
|
||||
await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl);
|
||||
}
|
||||
final provinceCode = data['provinceCode']?.toString() ?? '';
|
||||
final cityCode = data['cityCode']?.toString() ?? '';
|
||||
final districtCode = data['districtCode']?.toString() ?? '';
|
||||
final addressDetail = data['addressDetail']?.toString() ?? '';
|
||||
if (provinceCode.isNotEmpty ||
|
||||
cityCode.isNotEmpty ||
|
||||
districtCode.isNotEmpty ||
|
||||
addressDetail.isNotEmpty) {
|
||||
await StoreLocalCache.instance.saveRegion(
|
||||
orgId,
|
||||
provinceCode,
|
||||
cityCode,
|
||||
districtCode,
|
||||
addressDetail,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================ 工具方法 ================
|
||||
|
||||
/// 在组织树中按节点 ID 深度优先查找节点。
|
||||
static OrgNode? _findNode(List<OrgNode> nodes, String target) {
|
||||
for (final n in nodes) {
|
||||
if (n.id == target) return n;
|
||||
final found = _findNode(n.children, target);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 收集某节点(含自身)的全部后代 ID(深度优先,含 root 本身)。
|
||||
///
|
||||
/// 用于组织树筛选:选中某个渠道 / 代理商 / 分组后,命中其下所有层级的门店。
|
||||
/// 找不到 [rootId] 对应节点时返回空集合。
|
||||
///
|
||||
/// [nodes] 为已按 [SessionOrgScope] 收敛的组织树([getOrgTree] 返回值)。
|
||||
static Set<String> collectDescendantIds(List<OrgNode> nodes, String rootId) {
|
||||
final root = _findNode(nodes, rootId);
|
||||
final ids = <String>{};
|
||||
void walk(List<OrgNode> ns) {
|
||||
for (final n in ns) {
|
||||
ids.add(n.id);
|
||||
walk(n.children);
|
||||
}
|
||||
}
|
||||
|
||||
if (root != null) walk([root]);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// 收集某节点(含自身)的全部后代中「门店(type=3)」的 ID。
|
||||
///
|
||||
/// 与 [collectDescendantIds] 的区别:仅收集门店节点,过滤掉渠道/代理/分组等
|
||||
/// 非门店节点,便于直接作为 `/device/info/list` 的 `storeOrgId` 入参集合使用。
|
||||
/// 找不到 [rootId] 对应节点时返回空集合。
|
||||
static Set<String> collectDescendantStoreIds(
|
||||
List<OrgNode> nodes,
|
||||
String rootId,
|
||||
) {
|
||||
final root = _findNode(nodes, rootId);
|
||||
final ids = <String>{};
|
||||
void walk(List<OrgNode> ns) {
|
||||
for (final n in ns) {
|
||||
if (n.type == 3) ids.add(n.id);
|
||||
walk(n.children);
|
||||
}
|
||||
}
|
||||
|
||||
if (root != null) walk([root]);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// 将组织树收敛到「当前登录用户所属组织子树」(含自身与全部后代)。
|
||||
///
|
||||
/// - 未启用 [SessionOrgScope](管理员 / orgId 为空):原样返回。
|
||||
/// - 已启用但树中找不到对应节点:兜底原样返回,避免误清空。
|
||||
///
|
||||
/// 这样即便后端 `/sys/org/tree` 返回全量树,非管理员用户也只能看到
|
||||
/// 自己组织及下属门店的数据。
|
||||
static List<OrgNode> _scopeTree(List<OrgNode> tree) {
|
||||
final scope = SessionOrgScope.instance;
|
||||
if (!scope.enabled) return tree;
|
||||
final root = _findNode(tree, scope.currentOrgId);
|
||||
// 兜底:找不到当前组织节点时不过滤(避免误清空导致白屏),
|
||||
// 但打印告警以便排查「用户 orgId 与组织树节点不匹配」的数据异常。
|
||||
if (root == null) {
|
||||
debugPrint(
|
||||
'[OrgService._scopeTree] 未在组织树中找到当前组织节点 '
|
||||
'(orgId=${scope.currentOrgId}),放弃客户端收敛',
|
||||
);
|
||||
return tree;
|
||||
}
|
||||
return [root];
|
||||
}
|
||||
|
||||
/// 收集「当前已收敛组织树」包含的全部节点 ID(用于裁剪扁平/下拉列表与用户列表)。
|
||||
///
|
||||
/// 仅在 [SessionOrgScope] 启用时返回非空集合([getOrgTree] 已按范围收敛);
|
||||
/// 未启用时返回空集合,调用方据此跳过过滤、保持原样。
|
||||
Future<Set<String>> scopedOrgIdSet() async {
|
||||
final scope = SessionOrgScope.instance;
|
||||
if (!scope.enabled) return const {};
|
||||
final tree = await getOrgTree();
|
||||
final ids = <String>{};
|
||||
void collect(List<OrgNode> nodes) {
|
||||
for (final n in nodes) {
|
||||
ids.add(n.id);
|
||||
collect(n.children);
|
||||
}
|
||||
}
|
||||
|
||||
collect(tree);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// 兼容列表响应的三种形态:
|
||||
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
|
||||
static List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List) {
|
||||
return data;
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
final list = data['list'];
|
||||
if (list is List) {
|
||||
return list;
|
||||
}
|
||||
final page = data['page'];
|
||||
if (page is Map<String, dynamic> && page['list'] is List) {
|
||||
return page['list'] as List;
|
||||
}
|
||||
}
|
||||
return const <dynamic>[];
|
||||
}
|
||||
}
|
||||
444
lib/services/sys_service.dart
Normal file
444
lib/services/sys_service.dart
Normal file
@@ -0,0 +1,444 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/schedule.dart';
|
||||
import '../models/sys_config.dart';
|
||||
import '../models/sys_log.dart';
|
||||
import '../models/sys_menu.dart';
|
||||
import '../models/sys_role.dart';
|
||||
import '../models/sys_user.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 系统管理服务:用户、角色、菜单、参数配置、日志、定时任务、密码修改。
|
||||
///
|
||||
/// 对接真实「爱视后台」/api/sys/* 接口。
|
||||
/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。
|
||||
class SystemService {
|
||||
SystemService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
// ================ 用户管理 ================
|
||||
|
||||
/// 获取用户列表。
|
||||
///
|
||||
/// GET /sys/user/list?page=&limit=&username=
|
||||
Future<List<SysUser>> getUserList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? username,
|
||||
String? orgId,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/user/list',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
if (username != null) 'username': username,
|
||||
if (orgId != null) 'orgId': orgId,
|
||||
},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysUser.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取用户详情。
|
||||
///
|
||||
/// GET /sys/user/info/{id}
|
||||
Future<SysUser> getUserInfo(String id) async {
|
||||
final response = await _dio.get<dynamic>('/sys/user/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '用户信息加载失败:$id');
|
||||
}
|
||||
return SysUser.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改用户。
|
||||
///
|
||||
/// 与 Web 端一致:携带 id 走 /sys/user/update(修改,密码留空表示不修改),
|
||||
/// 否则走 /sys/user/save(新增,密码必填)。
|
||||
Future<void> saveUser(Map<String, dynamic> data) async {
|
||||
final hasId = data['id'] != null && data['id'].toString().isNotEmpty;
|
||||
final url = hasId ? '/sys/user/update' : '/sys/user/save';
|
||||
await _dio.post<dynamic>(url, data: data);
|
||||
}
|
||||
|
||||
/// 批量删除用户。
|
||||
///
|
||||
/// POST /sys/user/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteUser(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/user/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 角色管理 ================
|
||||
|
||||
/// 获取角色列表(分页)。
|
||||
///
|
||||
/// GET /sys/role/list?page=&limit=
|
||||
Future<List<SysRole>> getRoleList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/role/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysRole.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取角色下拉选项(不分页,用于用户表单选择角色)。
|
||||
///
|
||||
/// GET /sys/role/select
|
||||
Future<List<SysRole>> getRoleSelect() async {
|
||||
final response = await _dio.get<dynamic>('/sys/role/select');
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysRole.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取角色详情。
|
||||
///
|
||||
/// GET /sys/role/info/{id}
|
||||
Future<SysRole> getRoleInfo(String id) async {
|
||||
final response = await _dio.get<dynamic>('/sys/role/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '角色信息加载失败:$id');
|
||||
}
|
||||
return SysRole.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改角色。
|
||||
///
|
||||
/// POST /sys/role/save,body 为角色数据(可含 menuIdList)。
|
||||
Future<void> saveRole(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/sys/role/save', data: data);
|
||||
}
|
||||
|
||||
/// 批量删除角色。
|
||||
///
|
||||
/// POST /sys/role/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteRole(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/role/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 菜单管理 ================
|
||||
|
||||
/// 获取当前用户可见的导航菜单树。
|
||||
///
|
||||
/// GET /sys/menu/nav
|
||||
Future<List<SysMenu>> getMenuNav() async {
|
||||
final response = await _dio.get<dynamic>('/sys/menu/nav');
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysMenu.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取菜单列表(分页或全量,后端可能返回平铺列表)。
|
||||
///
|
||||
/// GET /sys/menu/list
|
||||
Future<List<SysMenu>> getMenuList() async {
|
||||
final response = await _dio.get<dynamic>('/sys/menu/list');
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysMenu.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取菜单下拉选项(用于角色权限树选择)。
|
||||
///
|
||||
/// GET /sys/menu/select
|
||||
Future<List<SysMenu>> getMenuSelect() async {
|
||||
final response = await _dio.get<dynamic>('/sys/menu/select');
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysMenu.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取菜单详情。
|
||||
///
|
||||
/// GET /sys/menu/info/{id}
|
||||
Future<SysMenu> getMenuInfo(String id) async {
|
||||
final response = await _dio.get<dynamic>('/sys/menu/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '菜单信息加载失败:$id');
|
||||
}
|
||||
return SysMenu.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改菜单。
|
||||
///
|
||||
/// POST /sys/menu/save,body 为菜单数据。
|
||||
Future<void> saveMenu(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/sys/menu/save', data: data);
|
||||
}
|
||||
|
||||
/// 删除菜单(单个,含子菜单校验)。
|
||||
///
|
||||
/// POST /sys/menu/delete/{id}
|
||||
Future<void> deleteMenu(String id) async {
|
||||
await _dio.post<dynamic>('/sys/menu/delete/$id');
|
||||
}
|
||||
|
||||
// ================ 参数配置 ================
|
||||
|
||||
/// 获取参数配置列表(分页)。
|
||||
///
|
||||
/// GET /sys/config/list?page=&limit=
|
||||
Future<List<SysConfig>> getConfigList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/config/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysConfig.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取参数配置详情。
|
||||
///
|
||||
/// GET /sys/config/info/{id}
|
||||
Future<SysConfig> getConfigInfo(String id) async {
|
||||
final response = await _dio.get<dynamic>('/sys/config/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '参数配置加载失败:$id');
|
||||
}
|
||||
return SysConfig.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改参数配置。
|
||||
///
|
||||
/// POST /sys/config/save,body 为配置数据。
|
||||
Future<void> saveConfig(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/sys/config/save', data: data);
|
||||
}
|
||||
|
||||
/// 批量删除参数配置。
|
||||
///
|
||||
/// POST /sys/config/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteConfig(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/config/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 日志管理 ================
|
||||
|
||||
/// 获取操作日志列表(分页)。
|
||||
///
|
||||
/// GET /sys/log/list?page=&limit=
|
||||
Future<List<SysLog>> getLogList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/log/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysLog.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取审计日志列表(分页)。
|
||||
///
|
||||
/// GET /sys/auditLog/list?page=&limit=
|
||||
Future<List<SysLog>> getAuditLogList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/auditLog/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysLog.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取 API 调用日志列表(分页)。
|
||||
///
|
||||
/// GET /sys/scheduleLog/list?page=&limit=
|
||||
/// 注意:接口路径为 scheduleLog,用于定时任务调用日志查询。
|
||||
Future<List<SysLog>> getApiJstCallLogList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/scheduleLog/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysLog.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ================ 定时任务 ================
|
||||
|
||||
/// 获取定时任务列表(分页)。
|
||||
///
|
||||
/// GET /sys/schedule/list?page=&limit=
|
||||
Future<List<Schedule>> getScheduleList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/schedule/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Schedule.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取定时任务详情。
|
||||
///
|
||||
/// GET /sys/schedule/info/{id}
|
||||
Future<Schedule> getScheduleInfo(String id) async {
|
||||
final response = await _dio.get<dynamic>('/sys/schedule/info/$id');
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ApiException(500, '定时任务信息加载失败:$id');
|
||||
}
|
||||
return Schedule.fromJson(data);
|
||||
}
|
||||
|
||||
/// 新增或修改定时任务。
|
||||
///
|
||||
/// POST /sys/schedule/save,body 为任务数据。
|
||||
Future<void> saveSchedule(Map<String, dynamic> data) async {
|
||||
await _dio.post<dynamic>('/sys/schedule/save', data: data);
|
||||
}
|
||||
|
||||
/// 批量删除定时任务。
|
||||
///
|
||||
/// POST /sys/schedule/delete,body 为 `{ids: [...]}`。
|
||||
Future<void> deleteSchedule(List<int> ids) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/schedule/delete',
|
||||
data: {'ids': ids},
|
||||
);
|
||||
}
|
||||
|
||||
/// 暂停定时任务。
|
||||
///
|
||||
/// POST /sys/schedule/pause,body 为 `{jobId: ...}`。
|
||||
Future<void> pauseSchedule(String jobId) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/schedule/pause',
|
||||
data: {'jobId': jobId},
|
||||
);
|
||||
}
|
||||
|
||||
/// 恢复定时任务。
|
||||
///
|
||||
/// POST /sys/schedule/resume,body 为 `{jobId: ...}`。
|
||||
Future<void> resumeSchedule(String jobId) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/schedule/resume',
|
||||
data: {'jobId': jobId},
|
||||
);
|
||||
}
|
||||
|
||||
/// 立即执行一次定时任务。
|
||||
///
|
||||
/// POST /sys/schedule/run,body 为 `{jobId: ...}`。
|
||||
Future<void> runSchedule(String jobId) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/schedule/run',
|
||||
data: {'jobId': jobId},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取定时任务日志列表(分页)。
|
||||
///
|
||||
/// GET /sys/scheduleLog/list?page=&limit=
|
||||
///
|
||||
/// 与 [getApiJstCallLogList] 相同路径,保留独立方法以区分语义。
|
||||
Future<List<SysLog>> getScheduleLogList({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/sys/scheduleLog/list',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SysLog.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ================ 密码修改 ================
|
||||
|
||||
/// 修改当前用户密码。
|
||||
///
|
||||
/// POST /sys/user/password,body 为 `{password, newPassword}`。
|
||||
Future<void> updatePassword(
|
||||
String oldPassword,
|
||||
String newPassword,
|
||||
) async {
|
||||
await _dio.post<dynamic>(
|
||||
'/sys/user/password',
|
||||
data: {'password': oldPassword, 'newPassword': newPassword},
|
||||
);
|
||||
}
|
||||
|
||||
// ================ 工具方法 ================
|
||||
|
||||
/// 兼容列表响应的三种形态:
|
||||
/// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。
|
||||
static List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List) {
|
||||
return data;
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
final list = data['list'];
|
||||
if (list is List) {
|
||||
return list;
|
||||
}
|
||||
final page = data['page'];
|
||||
if (page is Map<String, dynamic> && page['list'] is List) {
|
||||
return page['list'] as List;
|
||||
}
|
||||
}
|
||||
return const <dynamic>[];
|
||||
}
|
||||
}
|
||||
72
lib/services/theme_service.dart
Normal file
72
lib/services/theme_service.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/theme_group.dart';
|
||||
import '../models/theme_template.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 主题模板服务:对接 web 后台 /theme/themetemplate/* 与 /theme/page/* 接口。
|
||||
class ThemeService {
|
||||
ThemeService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// 获取全部启用的主题模板。
|
||||
///
|
||||
/// GET /theme/themetemplate/all?status=ENABLED
|
||||
Future<List<ThemeTemplate>> getThemeTemplateAll() async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/theme/themetemplate/all',
|
||||
queryParameters: const {'status': 'ENABLED'},
|
||||
);
|
||||
final list = _extractList(response.data);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ThemeTemplate.fromJson)
|
||||
.where((t) => t.id.isNotEmpty && t.isEnabled)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 查询门店下各设备分组的主题预览列表(界面配置)。
|
||||
///
|
||||
/// GET /theme/page/org/{orgId}/photos
|
||||
Future<List<ThemeGroup>> getStoreThemeGroups(String orgId) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/theme/page/org/$orgId/photos',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data is List) {
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ThemeGroup.fromJson)
|
||||
.where((g) => g.groupId > 0)
|
||||
.toList();
|
||||
}
|
||||
if (data is Map<String, dynamic>) {
|
||||
final code = data['code'];
|
||||
final isSuccess = code == null || code == 200 || code == 0;
|
||||
if (!isSuccess) return [];
|
||||
final list = data['data'] ?? data['list'];
|
||||
if (list is List) {
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ThemeGroup.fromJson)
|
||||
.where((g) => g.groupId > 0)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// 兼容列表响应的三种形态:
|
||||
/// `{list: [...]}` / `{data: [...]}` / 直接数组。
|
||||
static List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final list = data['list'];
|
||||
if (list is List) return list;
|
||||
final innerData = data['data'];
|
||||
if (innerData is List) return innerData;
|
||||
}
|
||||
return const <dynamic>[];
|
||||
}
|
||||
}
|
||||
127
lib/services/update_service.dart
Normal file
127
lib/services/update_service.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../models/app_version.dart';
|
||||
import '../utils/session_org_scope.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 版本更新检测结果。
|
||||
///
|
||||
/// [hasUpdate] 是否检测到更高版本;[latest] 服务端最新可用版本(无更新时为 null)。
|
||||
class UpdateCheckResult {
|
||||
const UpdateCheckResult({required this.hasUpdate, this.latest});
|
||||
|
||||
final bool hasUpdate;
|
||||
final AppVersion? latest;
|
||||
}
|
||||
|
||||
/// 版本更新服务:检测当前 App 是否有新版本可更新。
|
||||
///
|
||||
/// 对齐 Web 端 `searchAppVersion`:按当前登录用户所属组织查询该组织配置的
|
||||
/// APP 版本(`GET /device/orgGroupAppVersion/search?orgId=...`),再与本地
|
||||
/// versionCode 比较。
|
||||
///
|
||||
/// 注意:这里不再调用 `/device/orgGroupAppVersion/list`。`/list` 是管理后台
|
||||
/// 全量枚举接口,普通用户常因权限不足收到「未知异常,请联系管理员」;
|
||||
/// `/search` 才是「查询某组织当前应使用版本」的语义入口。
|
||||
class UpdateService {
|
||||
UpdateService({required this.packageName, ApiClient? client})
|
||||
: _client = client ?? ApiClient.instance;
|
||||
|
||||
/// 当前 App 包名(运行时由 package_info_plus 取得,如 com.tongshi.cloudhome)。
|
||||
final String packageName;
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 查询当前用户所属组织配置的版本,并判定是否有更新。
|
||||
///
|
||||
/// - 优先使用传入的 [orgId],未传入时回退到 [SessionOrgScope.instance.currentOrgId];
|
||||
/// - 若最终没有可用 orgId(管理员/未识别),直接返回「无更新」,避免向后端
|
||||
/// 发起缺少必要参数的请求;
|
||||
/// - 调用 `/device/orgGroupAppVersion/search?orgId=xxx` 获取该组织配置的版本;
|
||||
/// - 兼容返回体为 `null` / `{appVersion: {...}}` / 直接版本对象三种形态;
|
||||
/// - 仅当返回版本 `status==0` 且 versionCode 大于当前版本时才认为有更新;
|
||||
/// - 若后端返回了 packageName 且与当前 App 包名不一致,也认为无更新,防止
|
||||
/// 不同包被错误提示。
|
||||
///
|
||||
/// 任一异常均向上抛出,由调用方决定是否静默忽略(启动时检测失败不应阻断使用)。
|
||||
Future<UpdateCheckResult> checkForUpdate(
|
||||
int currentVersionCode, {
|
||||
String? orgId,
|
||||
}) async {
|
||||
final effectiveOrgId = (orgId != null && orgId.isNotEmpty)
|
||||
? orgId
|
||||
: (SessionOrgScope.instance.enabled
|
||||
? SessionOrgScope.instance.currentOrgId
|
||||
: null);
|
||||
|
||||
if (effectiveOrgId == null || effectiveOrgId.isEmpty) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[UpdateService] 缺少 orgId,跳过更新检查');
|
||||
}
|
||||
return const UpdateCheckResult(hasUpdate: false);
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[UpdateService] orgId=$effectiveOrgId currentCode=$currentVersionCode',
|
||||
);
|
||||
}
|
||||
|
||||
final response = await _client.dio.get<dynamic>(
|
||||
'/device/orgGroupAppVersion/search',
|
||||
queryParameters: {'orgId': effectiveOrgId},
|
||||
);
|
||||
|
||||
final latest = _parseVersion(response.data);
|
||||
if (latest == null) {
|
||||
if (kDebugMode) debugPrint('[UpdateService] 未查询到组织版本配置');
|
||||
return const UpdateCheckResult(hasUpdate: false);
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[UpdateService] server versionCode=${latest.versionCode} '
|
||||
'versionName=${latest.versionName} status=${latest.status} '
|
||||
'packageName=${latest.packageName}',
|
||||
);
|
||||
}
|
||||
|
||||
if (latest.status != 0) {
|
||||
return const UpdateCheckResult(hasUpdate: false);
|
||||
}
|
||||
|
||||
// 后端配置了 packageName 但与当前 App 不匹配时,不提示更新
|
||||
//(常见于同一组织下存在多个不同包的场景)。
|
||||
if (latest.packageName.isNotEmpty && latest.packageName != packageName) {
|
||||
return const UpdateCheckResult(hasUpdate: false);
|
||||
}
|
||||
|
||||
return UpdateCheckResult(
|
||||
hasUpdate: latest.versionCode > currentVersionCode,
|
||||
latest: latest,
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析 `/search` 返回的三种形态:
|
||||
/// - `null`:该组织没有配置版本;
|
||||
/// - `{appVersion: {...}}`:Web 端标准形态;
|
||||
/// - 直接返回版本对象。
|
||||
AppVersion? _parseVersion(dynamic data) {
|
||||
if (data == null) return null;
|
||||
if (data is! Map<String, dynamic>) return null;
|
||||
final appVersion = data['appVersion'];
|
||||
if (appVersion is Map<String, dynamic>) {
|
||||
return AppVersion.fromJson(appVersion);
|
||||
}
|
||||
if (data.containsKey('id') || data.containsKey('versionName')) {
|
||||
return AppVersion.fromJson(data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取当前 App 的 [PackageInfo](包名 / versionCode / versionName)。
|
||||
///
|
||||
/// 集中封装以便多处复用,并在 [PackageInfo] 未初始化时提供兜底。
|
||||
Future<PackageInfo> getCurrentPackageInfo() => PackageInfo.fromPlatform();
|
||||
383
lib/services/upload_service.dart
Normal file
383
lib/services/upload_service.dart
Normal file
@@ -0,0 +1,383 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'api_client.dart';
|
||||
import 'cos/cos_client.dart';
|
||||
|
||||
/// 公司介绍(小类/详情)图片与视频上传服务。
|
||||
///
|
||||
/// 对齐 Web 端 `company-intro-subcategory-form.vue` 使用的上传通道:
|
||||
/// 1. 计算文件 MD5;
|
||||
/// 2. `GET /theme/resource/check-md5` 判断是否已存在(秒传,跳过 COS);
|
||||
/// 3. `GET /theme/upload/credential` 获取腾讯云 STS 临时凭证(含 bucket/region/key);
|
||||
/// 4. 用 STS 临时密钥直传 COS(putObject,原生签名 q-sign-algorithm=sha1);
|
||||
/// 5. `POST /theme/resource/register` 注册 tv_resource;
|
||||
/// 6. 解析访问 URL(`theme/` 走 ipscdn CDN)返回。
|
||||
///
|
||||
/// 复用 [ApiClient.instance.dio],自动携带 Bearer 令牌、解包 `{code,msg,data}` 信封。
|
||||
class UploadService {
|
||||
UploadService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// 主题上传资源 CDN 根地址(与 Web 端 themeCdnBase 一致)。
|
||||
static const String _themeCdnBase = 'https://ipscdn.ainetv.cn';
|
||||
|
||||
/// 上传单张图片/视频,返回可访问的 CDN URL。
|
||||
///
|
||||
/// [mediaType] 资源类型:image | video | file,用于后端凭证与落库。
|
||||
///
|
||||
/// 失败统一抛出 [ApiException],message 为可展示的中文错误。
|
||||
Future<String> uploadImage(XFile file, {String mediaType = 'image'}) async {
|
||||
final ext = _suffixOf(file.path);
|
||||
final bytes = await file.readAsBytes();
|
||||
final fileMd5 = md5.convert(bytes).toString(); // 32 位小写十六进制
|
||||
|
||||
// 1. 秒传判断:已存在则直接返回 URL,跳过 COS 直传。
|
||||
final instant = await _tryInstantUpload(fileMd5);
|
||||
if (instant != null) return instant;
|
||||
|
||||
// 2. 获取 STS 临时凭证(含 bucket / region / key)。
|
||||
final cred = await _fetchStsCredential(fileMd5, ext, mediaType);
|
||||
if (cred.key.isEmpty || cred.bucket.isEmpty || cred.region.isEmpty) {
|
||||
throw const ApiException(500, '上传凭证数据不完整(缺少 key/bucket/region)');
|
||||
}
|
||||
|
||||
// 3. 用 STS 临时密钥直传 COS。
|
||||
final provider = TemporaryCredentialProvider(
|
||||
fetchCredentials: () async => CosCredentialData(
|
||||
startTime: cred.startTime,
|
||||
expiredTime: cred.expiredTime,
|
||||
credentials: CosCredential(
|
||||
tmpSecretId: cred.tmpSecretId,
|
||||
tmpSecretKey: cred.tmpSecretKey,
|
||||
sessionToken: cred.securityToken,
|
||||
),
|
||||
requestId: '',
|
||||
),
|
||||
);
|
||||
final cos = CosClient.withTemporaryCredential(
|
||||
temporaryCredentialProvider: provider,
|
||||
region: cred.region,
|
||||
bucket: cred.bucket,
|
||||
);
|
||||
final result = await cos.putObjectFromFile(
|
||||
objectKey: cred.key,
|
||||
file: File(file.path),
|
||||
);
|
||||
if (!result.success) {
|
||||
throw ApiException(500, '云存储上传失败:${result.message ?? "未知错误"}');
|
||||
}
|
||||
|
||||
// 4. 解析可访问 URL(theme/ 走 ipscdn CDN)。
|
||||
final url = _resolveUrl(cred.key, cred.url);
|
||||
|
||||
// 5. 注册 tv_resource(落库,供后续秒传与关联)。
|
||||
await _registerResource(
|
||||
fileName: file.name,
|
||||
fileKey: cred.key,
|
||||
fileUrl: url,
|
||||
fileType: mediaType,
|
||||
fileSize: bytes.length,
|
||||
suffix: ext,
|
||||
fileMd5: fileMd5,
|
||||
);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// 按原文件名上传 APK 文件(对齐 Web 端 `uploadThemeFileByOriginalName`)。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. `GET /theme/upload/credential?fileName=<原始文件名>&suffix=apk&type=file` 获取 STS 凭证;
|
||||
/// 2. 用 STS 临时密钥直传 COS(putObject);
|
||||
/// 3. 返回可访问 URL(不注册 tv_resource,APK 不走 MD5 秒传)。
|
||||
///
|
||||
/// [onProgress] 上传进度回调(0~100)。
|
||||
///
|
||||
/// 失败抛出 [ApiException]。
|
||||
Future<String> uploadApkByOriginalName(
|
||||
PlatformFile file, {
|
||||
void Function(int percent)? onProgress,
|
||||
}) async {
|
||||
final fileName = file.name.trim();
|
||||
if (fileName.isEmpty) {
|
||||
throw const ApiException(400, '文件名不能为空');
|
||||
}
|
||||
final ext = _suffixOf(fileName);
|
||||
final safeExt = (ext == 'apk') ? 'apk' : (ext.isNotEmpty ? ext : 'apk');
|
||||
|
||||
// 1. 获取 STS 凭证(fileName = 原始文件名,非 MD5)。
|
||||
final cred = await _fetchStsCredentialByName(
|
||||
fileName: fileName,
|
||||
suffix: safeExt,
|
||||
type: 'file',
|
||||
);
|
||||
if (cred.key.isEmpty || cred.bucket.isEmpty || cred.region.isEmpty) {
|
||||
throw const ApiException(500, '上传凭证数据不完整(缺少 key/bucket/region)');
|
||||
}
|
||||
|
||||
// 2. 直传 COS。
|
||||
final provider = TemporaryCredentialProvider(
|
||||
fetchCredentials: () async => CosCredentialData(
|
||||
startTime: cred.startTime,
|
||||
expiredTime: cred.expiredTime,
|
||||
credentials: CosCredential(
|
||||
tmpSecretId: cred.tmpSecretId,
|
||||
tmpSecretKey: cred.tmpSecretKey,
|
||||
sessionToken: cred.securityToken,
|
||||
),
|
||||
requestId: '',
|
||||
),
|
||||
);
|
||||
final cos = CosClient.withTemporaryCredential(
|
||||
temporaryCredentialProvider: provider,
|
||||
region: cred.region,
|
||||
bucket: cred.bucket,
|
||||
);
|
||||
|
||||
// file_picker 的 PlatformFile 可能有 path(Android 通常有)。
|
||||
final filePath = file.path;
|
||||
if (filePath == null || filePath.isEmpty) {
|
||||
throw const ApiException(400, '无法读取选中文件的路径');
|
||||
}
|
||||
final localFile = File(filePath);
|
||||
if (!localFile.existsSync()) {
|
||||
throw ApiException(400, '文件不存在:$filePath');
|
||||
}
|
||||
|
||||
final result = await cos.putObjectFromFile(
|
||||
objectKey: cred.key,
|
||||
file: localFile,
|
||||
contentType: 'application/vnd.android.package-archive',
|
||||
onProgress: (count, total) {
|
||||
if (total > 0 && onProgress != null) {
|
||||
onProgress(((count / total) * 100).round());
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw ApiException(500, '云存储上传失败:${result.message ?? "未知错误"}');
|
||||
}
|
||||
|
||||
// 3. 解析访问 URL。
|
||||
final url = _resolveUrl(cred.key, cred.url);
|
||||
if (url.isEmpty) {
|
||||
throw const ApiException(500, '未返回访问地址');
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// 打开系统文件选择器,仅允许 .apk 文件。
|
||||
///
|
||||
/// 返回选中的 [PlatformFile],用户取消返回 null。
|
||||
static Future<PlatformFile?> pickApkFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['apk'],
|
||||
);
|
||||
final files = result?.files;
|
||||
if (files == null || files.isEmpty) return null;
|
||||
return files.first;
|
||||
}
|
||||
|
||||
/// 取文件扩展名(不含点,小写)。
|
||||
String _suffixOf(String path) {
|
||||
final name = path.split(Platform.pathSeparator).last;
|
||||
final idx = name.lastIndexOf('.');
|
||||
return idx > 0 ? name.substring(idx + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/// 秒传判断:已存在同 MD5 资源则直接返回其访问 URL,否则返回 null。
|
||||
///
|
||||
/// 任何异常都不阻塞主流程(当作未命中秒传)。
|
||||
Future<String?> _tryInstantUpload(String fileMd5) async {
|
||||
try {
|
||||
final resp = await _dio.get<Map<String, dynamic>>(
|
||||
'/theme/resource/check-md5',
|
||||
queryParameters: {'fileMd5': fileMd5},
|
||||
);
|
||||
final data = resp.data;
|
||||
if (data == null) return null;
|
||||
|
||||
bool uploaded = data['uploaded'] == true;
|
||||
dynamic resourceRaw = data['resource'];
|
||||
if (data['data'] is Map) {
|
||||
final inner = data['data'] as Map;
|
||||
if (inner['uploaded'] == true) {
|
||||
uploaded = true;
|
||||
resourceRaw ??= inner['resource'] ?? inner;
|
||||
}
|
||||
}
|
||||
if (!uploaded) return null;
|
||||
|
||||
final r = resourceRaw is Map
|
||||
? Map<String, dynamic>.from(resourceRaw as Map)
|
||||
: null;
|
||||
if (r == null) return null;
|
||||
final id = r['id'] ?? r['resourceId'] ?? r['resource_id'];
|
||||
if (id == null || id == 0) return null;
|
||||
|
||||
final key = (r['key'] ?? r['fileKey'] ?? '').toString();
|
||||
final rawUrl = (r['url'] ?? r['fileUrl'] ?? '').toString();
|
||||
final url = _resolveUrl(key, rawUrl);
|
||||
return url.isNotEmpty ? url : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 STS 临时凭证(以文件 MD5 作为 fileName,用于主题资源秒传)。
|
||||
Future<_StsCred> _fetchStsCredential(
|
||||
String fileMd5,
|
||||
String ext,
|
||||
String mediaType,
|
||||
) async {
|
||||
final resp = await _dio.get<Map<String, dynamic>>(
|
||||
'/theme/upload/credential',
|
||||
queryParameters: <String, dynamic>{
|
||||
'fileName': fileMd5,
|
||||
if (ext.isNotEmpty) 'suffix': ext,
|
||||
'type': mediaType,
|
||||
},
|
||||
);
|
||||
final data = resp.data;
|
||||
if (data == null) {
|
||||
throw const ApiException(500, '获取上传凭证失败:响应为空');
|
||||
}
|
||||
return _StsCred.fromJson(Map<String, dynamic>.from(data as Map));
|
||||
}
|
||||
|
||||
/// 获取 STS 临时凭证(以原始文件名作为 fileName,用于 APK 等需保留原名的场景)。
|
||||
///
|
||||
/// 对齐 Web 端 `fetchThemeCredentialByOriginalName`:
|
||||
/// `GET /theme/upload/credential?fileName=<原始名>&suffix=<扩展名>&type=file`
|
||||
Future<_StsCred> _fetchStsCredentialByName({
|
||||
required String fileName,
|
||||
required String suffix,
|
||||
required String type,
|
||||
}) async {
|
||||
final resp = await _dio.get<Map<String, dynamic>>(
|
||||
'/theme/upload/credential',
|
||||
queryParameters: <String, dynamic>{
|
||||
'fileName': fileName,
|
||||
'suffix': suffix,
|
||||
'type': type,
|
||||
},
|
||||
);
|
||||
final data = resp.data;
|
||||
if (data == null) {
|
||||
throw const ApiException(500, '获取上传凭证失败:响应为空');
|
||||
}
|
||||
return _StsCred.fromJson(Map<String, dynamic>.from(data as Map));
|
||||
}
|
||||
|
||||
/// 注册 tv_resource。
|
||||
Future<void> _registerResource({
|
||||
required String fileName,
|
||||
required String fileKey,
|
||||
required String fileUrl,
|
||||
required String fileType,
|
||||
required int fileSize,
|
||||
required String suffix,
|
||||
required String fileMd5,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.post<Map<String, dynamic>>(
|
||||
'/theme/resource/register',
|
||||
data: <String, dynamic>{
|
||||
'fileName': fileName,
|
||||
'fileKey': fileKey,
|
||||
'fileUrl': fileUrl,
|
||||
'fileType': fileType,
|
||||
'fileSize': fileSize,
|
||||
'suffix': suffix,
|
||||
'fileMd5': fileMd5,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// 直传已成功,但资源记录未落库:影响后续秒传与关联,明确提示。
|
||||
throw ApiException(500, '资源注册失败:${formatApiError(e)}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析访问 URL:theme/ 路径走 ipscdn CDN,其余沿用后端 url 或历史 CDN。
|
||||
static String _resolveUrl(String key, String apiUrl) {
|
||||
final k = key.replaceFirst(RegExp(r'^/+'), '');
|
||||
if (k.startsWith('theme/')) {
|
||||
return '$_themeCdnBase/$k';
|
||||
}
|
||||
if (apiUrl.startsWith('http')) return apiUrl;
|
||||
if (k.startsWith('CloudMedia')) return 'https://metarialcdn.ainetv.cn/$k';
|
||||
return apiUrl.isNotEmpty ? apiUrl : '$_themeCdnBase/$k';
|
||||
}
|
||||
}
|
||||
|
||||
/// 归一化后的 STS 临时凭证(兼容后端大小写字段名)。
|
||||
class _StsCred {
|
||||
_StsCred({
|
||||
required this.tmpSecretId,
|
||||
required this.tmpSecretKey,
|
||||
required this.securityToken,
|
||||
required this.startTime,
|
||||
required this.expiredTime,
|
||||
required this.bucket,
|
||||
required this.region,
|
||||
required this.key,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
final String tmpSecretId;
|
||||
final String tmpSecretKey;
|
||||
final String securityToken;
|
||||
final int startTime;
|
||||
final int expiredTime;
|
||||
final String bucket;
|
||||
final String region;
|
||||
final String key;
|
||||
final String url;
|
||||
|
||||
static String _pick(Map m, List<String> keys) {
|
||||
for (final k in keys) {
|
||||
final v = m[k];
|
||||
if (v != null && v.toString().trim().isNotEmpty) return v.toString().trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static int _pickInt(Map m, List<String> keys) {
|
||||
for (final k in keys) {
|
||||
final v = m[k];
|
||||
if (v != null) {
|
||||
final n = v is int ? v : int.tryParse(v.toString());
|
||||
if (n != null && n > 0) return n;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory _StsCred.fromJson(Map<String, dynamic> m) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
var start = _pickInt(m, ['StartTime', 'startTime']);
|
||||
if (start == 0) start = now - 60;
|
||||
var exp = _pickInt(m, ['ExpiredTime', 'expiredTime']);
|
||||
if (exp == 0) exp = now + 1800;
|
||||
return _StsCred(
|
||||
tmpSecretId: _pick(m, ['TmpSecretId', 'tmpSecretId']),
|
||||
tmpSecretKey: _pick(m, ['TmpSecretKey', 'tmpSecretKey']),
|
||||
securityToken: _pick(m, ['SecurityToken', 'securityToken', 'sessionToken']),
|
||||
startTime: start,
|
||||
expiredTime: exp,
|
||||
bucket: _pick(m, ['bucket', 'Bucket']),
|
||||
region: _pick(m, ['region', 'Region']),
|
||||
key: _pick(m, ['key', 'Key']),
|
||||
url: _pick(m, ['url']),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user