ops-console v1.0.2 (versionCode 8) 源码入库

This commit is contained in:
txh
2026-09-03 18:01:05 +08:00
commit 685fff27c8
170 changed files with 53049 additions and 0 deletions

View 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,
},
];
}

View 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>[];
}
}

View 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;
}

View 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}';
}
}

View 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;
}

View 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>[];
}
}

View 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')}';
}
}

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

View 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>[];
}
}

View 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>[];
}
}

View 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>[];
}
}

View 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>[];
}
}

View 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();

View 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']),
);
}
}