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 fetchDeviceTotalCount() async { final response = await _dio.get( '/device/info/list', queryParameters: {'page': 1, 'limit': 1}, ); final data = response.data; if (data is Map) { 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) { 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 fetchDeviceCountByStatus({String? status}) async { final query = {'page': 1, 'limit': 1}; if (status != null) query['status'] = status; final response = await _dio.get( '/device/info/list', queryParameters: query, ); final data = response.data; if (data is Map) { 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) { final pageTotal = page['totalCount']; if (pageTotal is int) return pageTotal; if (pageTotal is String) return int.tryParse(pageTotal) ?? 0; } } return 0; } /// 获取设备列表。 Future> 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( '/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(Device.fromJson) .toList(); } /// 按多个门店(组织节点)批量拉取并合并设备列表。 /// /// 后端 `/device/info/list` 仅接受单个 `storeOrgId`,故对每个门店 orgId 并发 /// 请求 [fetchDevices],再把结果合并、按 [Device.id] 去重(同一设备不会因归属 /// 多个选中组织而重复出现)。 /// /// [storeOrgIds] 为空时直接走 [fetchDevices](等效于「全部门店」),保持原行为。 Future> fetchDevicesByOrgSet( Set 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 = {}; for (final d in lists.expand((x) => x)) { map[d.id] = d; } return map.values.toList(); } /// 按单个门店 orgId 拉取设备列表(构造查询参数 + 解析)。 /// /// 抽取自 [fetchDevices] 的单店请求逻辑,供 [fetchDevicesByOrgSet] 复用, /// 避免多门店合并时重复拼装 queryParameters。 Future> _fetchDevicesForStore( String storeOrgId, { String? status, String? mac, String? deviceSn, String? deviceUid, String? groupId, }) async { final response = await _dio.get( '/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(Device.fromJson) .toList(); } /// 按 ID 获取设备详情;不存在时抛出 [DeviceNotFoundException]。 /// /// 注意:详情接口 /device/info/info/{id} 返回的 `status` / `lastSyncTime` /// 可能滞后于列表接口。Web 端设备列表走 /device/info/list 并显示最新在线状态, /// 因此本方法在拿到详情后,再用 [Device.mac] 到列表接口取一次最新状态并合并, /// 使手机端详情页与 Web 端列表/心跳保持一致。 Future fetchDeviceById(String id) async { try { final response = await _dio.get('/device/info/info/$id'); final data = response.data; if (data is! Map) { 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 _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 updateDevice(Map data) async { await _dio.post('/device/info/update', data: data); } /// 新增设备。 /// /// POST /device/info/save,必填字段参考 Web 端:groupId、mac。 Future saveDeviceInfo(Map data) async { await _dio.post('/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 deleteDevice(String id) async { final idValue = int.tryParse(id) ?? id; await _dio.post('/device/info/delete', data: [idValue]); } /// 按 MAC 查询设备(最多返回 1 条,用于判断设备是否已存在)。 Future 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 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 = { '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 = { '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 sendCommandByMac({ required String mac, required String command, Map? data, }) async { await _dio.post( '/device/command/sendByMac', data: {'mac': mac, 'command': command, 'data': data ?? {}}, ); } /// 按组织下发设备指令(批量)。 Future sendCommandByOrg({ required String orgId, required String command, Map? data, }) async { await _dio.post( '/device/command/sendByOrg', data: { 'orgId': orgId, 'command': command, 'data': data ?? {}, }, ); } /// 获取告警列表。 /// /// 真实后端无 alerts 端点:real 模式下由设备列表推导—— /// 仅为异常设备生成告警(offline→critical、warning→warning); /// 在线设备的级别映射为 info(见 [alertLevelFor]),但不产生告警条目。 /// mock 模式下返回内置示例告警。 Future> fetchAlerts() async { if (AppConstants.useMock) { final response = await _dio.get('/alerts'); final data = response.data; if (data is List) { return data .whereType>() .map(Alert.fromJson) .toList(); } return []; } final devices = await fetchDevices(limit: AppConstants.deviceListPageSize); // 受限账号:仅保留其组织子树内门店所属设备的告警,避免泄露跨组织设备告警。 final scope = SessionOrgScope.instance; List scopedDevices = devices; if (scope.enabled) { final ids = await OrgService().scopedOrgIdSet(); if (ids.isNotEmpty) { scopedDevices = devices.where((d) => ids.contains(d.storeId)).toList(); } } final alerts = []; 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> getDeviceGroupList({ int page = 1, int limit = 20, required String storeId, }) async { final response = await _dio.get( '/device/group/list', queryParameters: { 'page': page, 'limit': limit, 'orgId': storeId, 'storeId': storeId, }, ); final list = _extractList(response.data); return list .whereType>() .map(OrgNode.fromJson) .toList(); } /// 获取设备分组详情。 /// /// GET /device/group/info/{id} Future getDeviceGroupInfo(String id) async { final response = await _dio.get('/device/group/info/$id'); final data = response.data; if (data is! Map) { throw DeviceNotFoundException('设备分组信息加载失败:$id'); } return OrgNode.fromJson(data); } /// 新增设备分组。 /// /// POST /device/group/save,body 为分组数据。 Future saveDeviceGroup(Map data) async { await _dio.post('/device/group/save', data: data); } /// 修改设备分组。 /// /// POST /device/group/update,body 为分组数据(含 id)。 Future updateDeviceGroup(Map data) async { await _dio.post('/device/group/update', data: data); } /// 批量删除设备分组。 /// /// POST /device/group/delete,body 为 `{ids: [...]}`。 Future deleteDeviceGroups(List ids) async { await _dio.post( '/device/group/delete', data: {'ids': ids}, ); } // ================ 激活码管理 ================ /// 生成门店激活码。 /// /// POST /device/activationCode/generate,body 为 `{orgId, maxActivateCount}`。 Future generateActivationCode( String orgId, int maxActivateCount, ) async { await _dio.post( '/device/activationCode/generate', data: { 'orgId': orgId, 'maxActivateCount': maxActivateCount, }, ); } /// 获取激活码信息。 /// /// GET /device/activationCode/info/{orgId} Future> getActivationCodeInfo(String orgId) async { final response = await _dio.get('/device/activationCode/info/$orgId'); final data = response.data; if (data is! Map) { throw DeviceNotFoundException('激活码信息加载失败:$orgId'); } return data; } /// 重置激活码。 /// /// POST /device/activationCode/reset/{orgId} Future resetActivationCode(String orgId) async { await _dio.post('/device/activationCode/reset/$orgId'); } // ================ 门店配置 ================ /// 获取门店配置。 /// /// GET /device/storeConfig/info/{orgId}(与 Web 后台门店配置弹窗同源, /// 见 screen-front src/api/device.ts `getStoreConfig`)。 Future> getStoreConfig(String orgId) async { final response = await _dio.get('/device/storeConfig/info/$orgId'); final data = response.data; if (data is! Map) { throw DeviceNotFoundException('门店配置加载失败:$orgId'); } return data; } /// 新增或修改门店配置。 /// /// POST /device/storeConfig/createOrUpdate(与 Web 后台 `createOrUpdateStoreConfig` /// 严格一致;早期误写 /saveOrUpdate 会导致后端路由不匹配、保存失效)。 Future createOrUpdateStoreConfig(Map data) async { await _dio.post( '/device/storeConfig/createOrUpdate', data: data, ); } /// 重置门店配置密码。 /// /// POST /device/storeConfig/resetPassword/{orgId} Future resetStoreConfigPassword(String orgId) async { await _dio.post( '/device/storeConfig/resetPassword/$orgId', ); } // ================ 扫码绑定(前端占位) ================ /// 扫码绑定设备到门店/分组。 /// /// 当前后端无扫码绑定接口,前端占位:本地记录绑定关系并写回设备归属, /// 不发起真实请求。 /// /// [scanPayload] 为扫码解析出的设备标识(mac/sn/二维码内容); /// [storeId]/[storeName] 为目标门店;[groupId] 为分组(可空)。 /// /// TODO(后端): POST /device/bind {scanPayload, storeId, groupId}, /// 由后台按 scanPayload 定位设备并写归属。 Future 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 _extractList(dynamic data) { if (data is List) { return data; } if (data is Map) { final list = data['list']; if (list is List) { return list; } final page = data['page']; if (page is Map && page['list'] is List) { return page['list'] as List; } } return const []; } } /// 设备不存在异常。 class DeviceNotFoundException implements Exception { const DeviceNotFoundException(this.message); final String message; @override String toString() => message; }