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

622
lib/models/device.dart Normal file
View File

@@ -0,0 +1,622 @@
/// 设备状态枚举。
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
// 兼容详情接口可能再包一层 {device|info|data:{...}} 的形态,先兜底解包。
final Map<String, dynamic> data = _unwrapDeviceJson(json);
final mac = data['mac']?.toString() ?? '';
final metricsJson = data['metrics'];
// 合并嵌套 metrics 与顶层扁平遥测字段(详情接口可能直接返回 cpuUsage /
// cpu_usage / cpu 等)。任一使用率字段存在即视为有指标数据,便于详情页展示。
final Map<String, dynamic> mergedMetrics = <String, dynamic>{};
if (metricsJson is Map<String, dynamic>) {
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<String, dynamic> _unwrapDeviceJson(Map<String, dynamic> 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<String, dynamic> &&
(nested.containsKey('id') || nested.containsKey('mac'))) {
return nested;
}
}
return json;
}
/// 从 [json] 中按 [keys] 顺序取第一个非空数值。
///
/// 兼容后端返回字符串或数字两种形态;任意匹配键取到有效数值即返回,
/// 否则返回 null(交由调用处按默认 0 处理,避免 UI 崩溃)。
static num? _firstNum(
Map<String, dynamic> json,
List<String> 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<String, dynamic> json,
List<String> 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<String, dynamic> 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<String, dynamic>) {
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<String, dynamic> 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,
};
}
}