/// 设备状态枚举。 enum DeviceStatus { /// 在线。 online, /// 离线。 offline, /// 告警(指标异常)。 warning; /// 状态中文名称。 String get label { switch (this) { case DeviceStatus.online: return '在线'; case DeviceStatus.offline: return '离线'; case DeviceStatus.warning: return '告警'; } } /// 从后端状态字符串解析。 /// /// 规则(与「爱视后台」约定一致):`offline` → 离线、`warning` → 告警、 /// 其余(含 `online` 与未知值)→ 在线。 static DeviceStatus fromString(String? value) { final status = (value ?? '').toLowerCase(); if (status == 'offline') { return DeviceStatus.offline; } if (status == 'warning') { return DeviceStatus.warning; } return DeviceStatus.online; } } /// 设备指令常量(对应 /device/command/sendByMac|sendByOrg 的 command 字段)。 class DeviceCommand { DeviceCommand._(); static const String powerOff = 'powerOff'; static const String reboot = 'reboot'; static const String screenshot = 'screenshot'; static const String themeUpdate = 'themeUpdate'; static const String versionUpdate = 'versionUpdate'; static const String marqueeUpdate = 'marqueeUpdate'; static const String weatherUpdate = 'weatherUpdate'; static const String mediaZoneVol = 'mediaZoneVol'; static const String defaultVol = 'defaultVol'; static const String themeClear = 'themeClear'; static const String cacheClear = 'cacheClear'; static const String storeExpire = 'storeExpire'; static const String storeDisable = 'storeDisable'; static const String passwordUpdate = 'passwordUpdate'; static const String worldAd = 'worldAd'; } /// 设备运行指标(CPU / 内存 / 磁盘 / 网络)。 /// /// 注意:真实后端不提供该类指标,仅 mock 模式有值; /// 由 [Device.hasMetrics] 标识当前数据是否包含指标。 class DeviceMetrics { const DeviceMetrics({ required this.cpuUsage, required this.memoryUsage, required this.diskUsage, required this.networkRxKbps, required this.networkTxKbps, }); /// 全零指标(真实模式下的占位值,配合 hasMetrics=false 使用)。 const DeviceMetrics.zero() : cpuUsage = 0, memoryUsage = 0, diskUsage = 0, networkRxKbps = 0, networkTxKbps = 0; /// CPU 使用率(0.0 ~ 1.0)。 final double cpuUsage; /// 内存使用率(0.0 ~ 1.0)。 final double memoryUsage; /// 磁盘使用率(0.0 ~ 1.0)。 final double diskUsage; /// 网络下行速率(KB/s)。 final double networkRxKbps; /// 网络上行速率(KB/s)。 final double networkTxKbps; /// 从后端 JSON 解析运行指标。 /// /// 兼容嵌套 `metrics` 对象与顶层扁平字段两种形态,并对百分比做 0~100→0~1 /// 归一化;任意字段缺失时安全回退为 0,保证 UI 不崩溃。 factory DeviceMetrics.fromJson(Map json) { return DeviceMetrics( cpuUsage: _normalizePercent( json['cpuUsage'] ?? json['cpu_usage'] ?? json['cpu'], ), memoryUsage: _normalizePercent( json['memoryUsage'] ?? json['memory_usage'] ?? json['mem'] ?? json['memUsage'] ?? json['memory'], ), diskUsage: _normalizePercent( json['diskUsage'] ?? json['disk_usage'] ?? json['disk'], ), networkRxKbps: _toDouble( json['networkRxKbps'] ?? json['rxKbps'] ?? json['networkRx'], ), networkTxKbps: _toDouble( json['networkTxKbps'] ?? json['txKbps'] ?? json['networkTx'], ), ); } Map toJson() { return { 'cpuUsage': cpuUsage, 'memoryUsage': memoryUsage, 'diskUsage': diskUsage, 'networkRxKbps': networkRxKbps, 'networkTxKbps': networkTxKbps, }; } } /// 将后端返回的百分数字段归一化为 0.0~1.0。 /// /// 兼容后端可能返回 0~100(如 87)或 0~1(如 0.87)两种形态,避免 UI 进度条 /// 与百分比文本越界。 double _normalizePercent(dynamic value) { if (value == null) return 0; final n = value is num ? value.toDouble() : double.tryParse(value.toString()); if (n == null) return 0; return n > 1 ? n / 100 : n; } /// 将动态值安全转换为 double,无法解析时返回 0。 double _toDouble(dynamic value) { if (value == null) return 0; return (value is num ? value.toDouble() : double.tryParse(value.toString())) ?? 0; } /// 设备模型。 /// /// 字段对齐真实后端 DeviceInfo(id / mac / name / deviceSn / deviceUid / /// status / groupId / lastSyncTime / createTime),并保留 UI 展示兼容字段 /// (ip / location / os / uptimeSeconds / metrics / storeId),真实模式下取默认值。 class Device { const Device({ required this.id, required this.mac, required this.name, required this.model, required this.deviceSn, required this.deviceUid, required this.status, required this.groupId, this.storeId = '', required this.lastSyncTime, required this.createTime, this.ip = '', this.location = '', this.storeName = '', this.os = '', this.roomNo = '', this.uptimeSeconds = 0, this.hasMetrics = false, this.metrics = const DeviceMetrics.zero(), }); /// 设备唯一标识。 final String id; /// 设备 MAC 地址(指令下发的目标标识)。 final String mac; /// 设备名称(后端可能返回 MAC、SN 或自定义别名)。 final String name; /// 设备型号(如 rk3566_r、p321)。 /// /// 优先从后端 model/modelName/deviceModel/productModel 等字段解析; /// 缺失时降级为 [name](若 name 是 MAC 地址则跳过),再降级为 [deviceSn], /// 最终兜底 [mac],确保不会把 MAC 误当成型号展示。 final String model; /// 设备序列号。 final String deviceSn; /// 设备 UID。 final String deviceUid; /// 当前状态。 final DeviceStatus status; /// 分组 ID。 final String groupId; /// 所属门店 ID(用于拉取设备分组列表;后端命名多样,按常见别名解析)。 final String storeId; /// 房号(房间号,如「802」「A栋301」)。 final String roomNo; /// 最近一次同步时间。 final DateTime? lastSyncTime; /// 创建时间。 final DateTime? createTime; /// IP 地址(真实后端无此字段,默认回退为 MAC)。 final String ip; /// 机房 / 位置(真实后端无此字段,默认为空)。 final String location; /// 所属门店名称(真实后端可能返回 storeName / orgName)。 final String storeName; /// 操作系统描述(真实后端无此字段,默认为空)。 final String os; /// 已连续运行时长(秒)(真实后端无此字段,默认为 0)。 final int uptimeSeconds; /// 是否包含运行指标(仅 mock 模式为 true)。 final bool hasMetrics; /// 运行指标。 final DeviceMetrics metrics; /// 创建副本并覆盖指定字段。 Device copyWith({ String? id, String? mac, String? name, String? model, String? deviceSn, String? deviceUid, DeviceStatus? status, String? groupId, String? storeId, String? roomNo, DateTime? lastSyncTime, DateTime? createTime, String? ip, String? location, String? storeName, String? os, int? uptimeSeconds, bool? hasMetrics, DeviceMetrics? metrics, }) { return Device( id: id ?? this.id, mac: mac ?? this.mac, name: name ?? this.name, model: model ?? this.model, deviceSn: deviceSn ?? this.deviceSn, deviceUid: deviceUid ?? this.deviceUid, status: status ?? this.status, groupId: groupId ?? this.groupId, storeId: storeId ?? this.storeId, roomNo: roomNo ?? this.roomNo, lastSyncTime: lastSyncTime ?? this.lastSyncTime, createTime: createTime ?? this.createTime, ip: ip ?? this.ip, location: location ?? this.location, storeName: storeName ?? this.storeName, os: os ?? this.os, uptimeSeconds: uptimeSeconds ?? this.uptimeSeconds, hasMetrics: hasMetrics ?? this.hasMetrics, metrics: metrics ?? this.metrics, ); } /// 设备标识:统一为「MAC / 房号」组合。 /// 优先 MAC + 房号;任一缺失则只用存在的那项;两者皆空时逐级降级到 /// name → model → mac,确保永不展示空白。 String get macRoomLabel { final m = mac.trim(); final r = roomNo.trim(); if (m.isNotEmpty && r.isNotEmpty) return '$m / $r'; if (m.isNotEmpty) return m; if (r.isNotEmpty) return r; if (name.isNotEmpty) return name; if (model.isNotEmpty) return model; return m; } /// 仅展示 MAC 地址(房号改为独立「房号:XXX」列示,不再用「/」把两者粘在一起)。 /// MAC 为空时逐级降级到 name → model → mac,确保标题不空白。 String get macLabel { final m = mac.trim(); if (m.isNotEmpty) return m; if (name.isNotEmpty) return name; if (model.isNotEmpty) return model; return m; } factory Device.fromJson(Map json) { // 兼容详情接口可能再包一层 {device|info|data:{...}} 的形态,先兜底解包。 final Map data = _unwrapDeviceJson(json); final mac = data['mac']?.toString() ?? ''; final metricsJson = data['metrics']; // 合并嵌套 metrics 与顶层扁平遥测字段(详情接口可能直接返回 cpuUsage / // cpu_usage / cpu 等)。任一使用率字段存在即视为有指标数据,便于详情页展示。 final Map mergedMetrics = {}; if (metricsJson is Map) { mergedMetrics.addAll(metricsJson); } final flatCpu = _firstNum(data, [ 'cpuUsage', 'cpu_usage', 'cpu', 'cpuPercent', 'cpu_percent', 'cpuRate', 'cpu_rate', ]); if (flatCpu != null) { mergedMetrics.putIfAbsent('cpuUsage', () => flatCpu); } final flatMem = _firstNum(data, [ 'memoryUsage', 'memory_usage', 'mem', 'memUsage', 'memory', 'memoryPercent', 'mem_percent', ]); if (flatMem != null) { mergedMetrics.putIfAbsent('memoryUsage', () => flatMem); } final flatDisk = _firstNum(data, [ 'diskUsage', 'disk_usage', 'disk', 'diskPercent', 'disk_percent', ]); if (flatDisk != null) { mergedMetrics.putIfAbsent('diskUsage', () => flatDisk); } // 爱视后台列表字段名为 deviceName / device_name,name 字段常缺失或存 MAC/IP。 final name = data['deviceName']?.toString() ?? data['device_name']?.toString() ?? data['name']?.toString() ?? ''; // IP 可能落在 ip / ipAddress / ip_address / deviceIp / device_ip 等多个字段。 final ip = data['ip']?.toString() ?? data['ipAddress']?.toString() ?? data['ip_address']?.toString() ?? data['deviceIp']?.toString() ?? data['device_ip']?.toString() ?? ''; final deviceSn = data['deviceSn']?.toString() ?? ''; // 型号字段后端命名多样,按常见别名逐级解析;解析不到时按规则降级。 final rawModel = _firstString(data, [ 'model', 'modelName', 'model_name', 'deviceModel', 'device_model', 'productModel', 'product_model', 'productName', 'product_name', 'board', 'hardware', 'brand', 'deviceType', 'device_type', 'type', ]) ?? ''; final model = _resolveModel(rawModel, name, deviceSn, mac); return Device( id: data['id']?.toString() ?? '', mac: mac, name: name, model: model, deviceSn: deviceSn, deviceUid: data['deviceUid']?.toString() ?? '', status: DeviceStatus.fromString(data['status']?.toString()), groupId: data['groupId']?.toString() ?? '', storeId: _resolveStoreId(data), // Web 端设备列表把最近心跳时间存为 heartbeatTime / heartbeat_time, // 详情接口多为 lastSyncTime;兼容两种命名,避免最后活跃时间解析为空。 lastSyncTime: _parseDateTime( data['lastSyncTime'] ?? data['last_sync_time'] ?? data['heartbeatTime'] ?? data['heartbeat_time'] ?? data['lastHeartbeat'] ?? data['last_heartbeat'], ), createTime: _parseDateTime(data['createTime']), // UI 兼容字段:真实数据缺失时回退。 ip: ip, location: _firstString(data, [ 'location', 'locationDesc', 'loc', 'position', 'place', 'area', 'region', ]) ?? '', storeName: data['storeName']?.toString() ?? data['orgName']?.toString() ?? data['store']?.toString() ?? '', os: _firstString(data, [ 'os', 'osName', 'system', 'systemVersion', 'osVersion', 'platform', 'osInfo', 'sysVersion', ]) ?? '', // 房号/房间号字段后端命名极不统一,按常见别名逐级回退。 roomNo: _firstString(data, [ 'roomNo', 'room', 'roomNoStr', 'roomNumber', 'room_number', 'roomNum', 'roomNumStr', 'roomId', 'room_id', 'roomName', 'houseNumber', 'house_number', 'houseNo', 'houseNoStr', 'roomCode', 'room_code', ]) ?? '', uptimeSeconds: _firstNum(data, [ 'uptimeSeconds', 'uptime', 'uptimeSec', 'uptimeSecs', 'runTime', 'runningTime', 'onlineTime', 'bootTime', 'upTime', 'up_time', ])?.toInt() ?? 0, hasMetrics: mergedMetrics.isNotEmpty, metrics: mergedMetrics.isNotEmpty ? DeviceMetrics.fromJson(mergedMetrics) : const DeviceMetrics.zero(), ); } /// 兜底解包设备 JSON。 /// /// 后端 /device/info/info/{id} 详情接口可能并未把设备对象直接作为响应体, /// 而是再包一层 `{device|info|data: {...}}`;若顶层没有 `id` 但某嵌套字段 /// 是含 `id` 的 Map,则返回该嵌套 Map,否则原样返回。 /// 这样可避免详情页所有遥测字段因解析不到而显示为空。 static Map _unwrapDeviceJson(Map json) { if (json.containsKey('id')) return json; for (final key in const ['device', 'info', 'data', 'result', 'record']) { final nested = json[key]; if (nested is Map && (nested.containsKey('id') || nested.containsKey('mac'))) { return nested; } } return json; } /// 从 [json] 中按 [keys] 顺序取第一个非空数值。 /// /// 兼容后端返回字符串或数字两种形态;任意匹配键取到有效数值即返回, /// 否则返回 null(交由调用处按默认 0 处理,避免 UI 崩溃)。 static num? _firstNum( Map json, List keys, ) { for (final key in keys) { final value = json[key]; if (value == null) continue; if (value is num) return value; final n = double.tryParse(value.toString()); if (n != null) return n; } return null; } /// 从 [json] 中按 [keys] 顺序取第一个非空字符串值。 static String? _firstString( Map json, List keys, ) { for (final key in keys) { final value = json[key]; if (value == null) continue; final text = value.toString().trim(); if (text.isNotEmpty) return text; } return null; } /// 解析设备所属门店 ID。 /// /// 后端字段命名极不统一:可能直接在顶层返回 `storeId` / `orgId`,也可能嵌套在 /// `store` / `storeInfo` / `org` 对象中。本方法先尝试顶层字段,再尝试嵌套对象 /// 中的 `id` / `orgId` / `storeId`,尽力避免分组下拉因取不到门店 ID 而无法加载。 static String _resolveStoreId(Map json) { final topLevel = _firstString(json, [ 'storeId', 'storeOrgId', 'orgId', 'store_id', 'org_id', 'storeIdStr', ]); if (topLevel != null && topLevel.isNotEmpty) return topLevel; for (final nestedKey in const ['store', 'storeInfo', 'org', 'organization']) { final nested = json[nestedKey]; if (nested is Map) { final id = _firstString(nested, [ 'id', 'orgId', 'storeId', 'store_id', 'org_id', ]); if (id != null && id.isNotEmpty) return id; } } return ''; } /// 解析最终展示的型号文本。 /// /// 优先级: /// 1. 后端明确返回的 [rawModel] /// 2. [name](仅当非空且不是 MAC 地址时) /// 3. [deviceSn] /// 4. [mac](最终兜底,避免空标题) static String _resolveModel( String rawModel, String name, String deviceSn, String mac, ) { if (rawModel.isNotEmpty) return rawModel; if (name.isNotEmpty && !_isMacAddress(name)) return name; if (deviceSn.isNotEmpty) return deviceSn; return mac; } /// 判断 [value] 是否为 MAC 地址格式(xx:xx:xx:xx:xx:xx 或 xx-xx-xx-xx-xx-xx)。 static bool _isMacAddress(String value) { return RegExp(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$') .hasMatch(value.trim()); } /// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。 static DateTime? _parseDateTime(dynamic value) { if (value == null) { return null; } final text = value.toString().trim(); if (text.isEmpty) { return null; } return DateTime.tryParse(text) ?? DateTime.tryParse(text.replaceFirst(' ', 'T')); } Map toJson() { return { 'id': id, 'mac': mac, 'name': name, 'model': model, 'deviceSn': deviceSn, 'deviceUid': deviceUid, 'status': status.name, 'groupId': groupId, 'storeId': storeId, 'lastSyncTime': lastSyncTime?.toIso8601String(), 'createTime': createTime?.toIso8601String(), 'ip': ip, 'location': location, 'storeName': storeName, 'os': os, 'roomId': roomNo, 'uptimeSeconds': uptimeSeconds, 'metrics': hasMetrics ? metrics.toJson() : null, }; } }