1012 lines
37 KiB
Dart
1012 lines
37 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:forui/forui.dart';
|
||
import 'package:get/get.dart';
|
||
|
||
import 'package:pj_supermarket_cloud/common/models/manage/terminal_device_status_models.dart';
|
||
import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart';
|
||
import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart';
|
||
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
|
||
import 'package:pj_supermarket_cloud/common/utils/app/app_device_status_tool.dart';
|
||
import 'package:pj_supermarket_cloud/common/utils/app/app_runtime_tool.dart';
|
||
import 'package:pj_supermarket_cloud/common/utils/app/control/terminal_admin_session_control.dart';
|
||
import 'package:pj_supermarket_cloud/common/utils/app/control/terminal_device_status_control.dart';
|
||
import 'package:pj_supermarket_cloud/common/utils/sync/sync_idle_tool.dart';
|
||
import 'package:pj_supermarket_cloud/common/widgets/app/app_dialog.dart';
|
||
|
||
/// 终端设备三态;仅表达当前运行态,不属于接口、缓存或数据库编码。
|
||
enum TerminalDeviceRunState {
|
||
/// 最近一次探测未发现该设备,顶栏使用红色状态标记。
|
||
missing,
|
||
|
||
/// 已发现设备但当前没有业务任务,顶栏使用黄色状态标记。
|
||
idle,
|
||
|
||
/// 设备正在执行打印、称重、读卡、展示或识别任务,顶栏使用绿色状态标记。
|
||
working,
|
||
}
|
||
|
||
/// 顶栏与详情弹窗共用的单项设备展示数据。
|
||
class _TerminalDeviceStatusItem {
|
||
/// 创建单项设备展示数据;可用性优先于工作标记,避免出现无设备却显示工作中的矛盾状态。
|
||
const _TerminalDeviceStatusItem({
|
||
required this.type,
|
||
required this.icon,
|
||
required this.label,
|
||
required this.available,
|
||
required this.working,
|
||
required this.deviceDescription,
|
||
required this.missingDescription,
|
||
required this.idleDescription,
|
||
required this.workingDescription,
|
||
required this.updatedAt,
|
||
});
|
||
|
||
/// 对应的稳定设备类型,用于逐项读取和保存顶部显隐设置。
|
||
final TerminalDeviceType type;
|
||
|
||
/// 当前设备使用的 Forui SVG 图标。
|
||
final SvgAsset icon;
|
||
|
||
/// 设备中文名称,用于顶栏、弹窗和无障碍语义。
|
||
final String label;
|
||
|
||
/// 最近一次探测是否发现该设备。
|
||
final bool available;
|
||
|
||
/// 当前是否存在真实业务任务占用该设备。
|
||
final bool working;
|
||
|
||
/// 设备在收银终端中的职责说明。
|
||
final String deviceDescription;
|
||
|
||
/// 未探测到设备时提供的诊断说明。
|
||
final String missingDescription;
|
||
|
||
/// 设备存在但没有业务任务时的说明。
|
||
final String idleDescription;
|
||
|
||
/// 设备执行真实业务任务时的说明。
|
||
final String workingDescription;
|
||
|
||
/// 最近一次设备状态更新时间;尚未检查时为空。
|
||
final DateTime? updatedAt;
|
||
|
||
/// 按“是否存在 -> 是否工作”的优先级解析设备三态。
|
||
TerminalDeviceRunState get state {
|
||
if (!available) return TerminalDeviceRunState.missing;
|
||
return working
|
||
? TerminalDeviceRunState.working
|
||
: TerminalDeviceRunState.idle;
|
||
}
|
||
|
||
/// 返回与当前三态匹配的具体设备说明。
|
||
String get currentDescription => switch (state) {
|
||
TerminalDeviceRunState.missing => missingDescription,
|
||
TerminalDeviceRunState.idle => idleDescription,
|
||
TerminalDeviceRunState.working => workingDescription,
|
||
};
|
||
}
|
||
|
||
/// 设备三态对应的颜色、文字和辅助图标。
|
||
class _TerminalDeviceStateVisual {
|
||
/// 创建一份设备状态视觉配置。
|
||
const _TerminalDeviceStateVisual({
|
||
required this.label,
|
||
required this.color,
|
||
required this.indicatorIcon,
|
||
});
|
||
|
||
/// 面向用户的明确状态文字,避免只依赖颜色传达含义。
|
||
final String label;
|
||
|
||
/// 顶栏状态标记和弹窗状态块使用的语义色。
|
||
final Color color;
|
||
|
||
/// 顶栏状态标记使用的辅助图标,提供颜色之外的状态区分。
|
||
final SvgAsset indicatorIcon;
|
||
|
||
/// 将设备三态转换为项目统一的危险、警告和成功视觉。
|
||
static _TerminalDeviceStateVisual resolve(TerminalDeviceRunState state) {
|
||
return switch (state) {
|
||
TerminalDeviceRunState.missing => _TerminalDeviceStateVisual(
|
||
label: '未检测到',
|
||
color: XfjDesignTokens.danger,
|
||
indicatorIcon: FAssets.icons.circleX,
|
||
),
|
||
TerminalDeviceRunState.idle => _TerminalDeviceStateVisual(
|
||
label: '已连接 · 空闲',
|
||
color: XfjDesignTokens.warning,
|
||
indicatorIcon: FAssets.icons.circleMinus,
|
||
),
|
||
TerminalDeviceRunState.working => _TerminalDeviceStateVisual(
|
||
label: '工作中',
|
||
color: XfjDesignTokens.success,
|
||
indicatorIcon: FAssets.icons.circleCheck,
|
||
),
|
||
};
|
||
}
|
||
}
|
||
|
||
/// 将全局可调字号限制在运维弹框的清晰可读区间,兼顾实体终端阅读距离与小尺寸降级布局。
|
||
TextStyle _compactDeviceDialogTextStyle(
|
||
TextStyle style, {
|
||
required double minSize,
|
||
required double maxSize,
|
||
}) {
|
||
final fontSize = (style.fontSize ?? maxSize).clamp(minSize, maxSize);
|
||
return style.copyWith(fontSize: fontSize.toDouble(), letterSpacing: 0);
|
||
}
|
||
|
||
/// 读取五类终端设备的同一时刻状态,供顶栏图标组和聚合详情弹窗共同使用。
|
||
///
|
||
/// 调用方必须位于 `Obx` 中,方法内读取的设备存在性、瞬时工作态与摄像头会话状态
|
||
/// 才能在对应状态变化时重建最小 UI 子树。
|
||
List<_TerminalDeviceStatusItem> _readTerminalDeviceStatusItems() {
|
||
final status = AppDeviceStatusTool.status.value;
|
||
final faceService = Get.isRegistered<FaceRecognitionSessionService>()
|
||
? Get.find<FaceRecognitionSessionService>()
|
||
: null;
|
||
final cameraOpen = faceService?.cameraOpen.value ?? false;
|
||
final cameraStatus =
|
||
faceService?.cameraStatus.value ?? FaceRecognitionSessionStatus.cold;
|
||
final cameraVisual = _FaceCameraStatusVisual.resolve(
|
||
cameraAvailable: status.cameraAvailable,
|
||
cameraOpen: cameraOpen,
|
||
status: cameraStatus,
|
||
);
|
||
return <_TerminalDeviceStatusItem>[
|
||
_TerminalDeviceStatusItem(
|
||
type: TerminalDeviceType.printer,
|
||
icon: FAssets.icons.printer,
|
||
label: '打印机',
|
||
available: status.printerAvailable,
|
||
working: AppDeviceStatusTool.printerWorking.value,
|
||
deviceDescription: '小票、发票和补打任务,通过 USB 连接。',
|
||
missingDescription: '未检测到打印机,请检查 USB、设备供电和系统识别。',
|
||
idleDescription: '打印机已连接,当前没有待处理的打印任务。',
|
||
workingDescription: '正在发送小票数据,请勿断开打印机。',
|
||
updatedAt: status.updatedAt,
|
||
),
|
||
_TerminalDeviceStatusItem(
|
||
type: TerminalDeviceType.steelyard,
|
||
icon: FAssets.icons.scale,
|
||
label: '电子秤',
|
||
available: status.steelyardAvailable,
|
||
working: AppDeviceStatusTool.steelyardWorking.value,
|
||
deviceDescription: '采集商品重量并计价,通过串口上报重量。',
|
||
missingDescription: '未收到重量数据,请检查供电、串口和秤体显示。',
|
||
idleDescription: '电子秤已连接,当前没有商品处于称重流程。',
|
||
workingDescription: '正在接收并计算商品重量,请保持秤台稳定。',
|
||
updatedAt: status.updatedAt,
|
||
),
|
||
_TerminalDeviceStatusItem(
|
||
type: TerminalDeviceType.cardReader,
|
||
icon: FAssets.icons.idCard,
|
||
label: '读卡器',
|
||
available: status.cardReaderAvailable,
|
||
working: AppDeviceStatusTool.cardReaderWorking.value,
|
||
deviceDescription: '饭卡 UID、业务卡号及 M1 数据的串口读写。',
|
||
missingDescription: '未检测到读卡器,请检查供电、串口和端口配置。',
|
||
idleDescription: '读卡器已连接,当前没有读卡、写卡或诊断任务。',
|
||
workingDescription: '正在等待或处理饭卡数据,请保持卡片贴近感应区。',
|
||
updatedAt: status.updatedAt,
|
||
),
|
||
_TerminalDeviceStatusItem(
|
||
type: TerminalDeviceType.subScreen,
|
||
icon: FAssets.icons.monitor,
|
||
label: '副屏',
|
||
available: status.subScreenAvailable,
|
||
working: AppDeviceStatusTool.subScreenWorking.value,
|
||
deviceDescription: '向顾客展示商品、应付金额、支付引导和业务结果。',
|
||
missingDescription: '未检测到副屏,请检查显示连接、悬浮窗权限和多屏设置。',
|
||
idleDescription: '副屏已连接,但顾客展示窗口当前未运行。',
|
||
workingDescription: '顾客展示窗口正在运行,并随主屏业务同步内容。',
|
||
updatedAt: status.updatedAt,
|
||
),
|
||
_TerminalDeviceStatusItem(
|
||
type: TerminalDeviceType.camera,
|
||
icon:
|
||
cameraVisual.working ? FAssets.icons.camera : FAssets.icons.cameraOff,
|
||
label: '摄像头',
|
||
available: status.cameraAvailable,
|
||
working: cameraVisual.working,
|
||
deviceDescription: '人脸支付与预约查询的实时取景和人脸识别。',
|
||
missingDescription: '系统未检测到摄像头,请检查相机硬件和系统识别。',
|
||
idleDescription: cameraVisual.description,
|
||
workingDescription: cameraVisual.description,
|
||
updatedAt: status.updatedAt,
|
||
),
|
||
];
|
||
}
|
||
|
||
/// 顶栏统一订阅设备存在性、业务活动和管理解锁状态。
|
||
class TerminalDeviceStatus extends StatelessWidget {
|
||
/// 创建订阅应用级设备状态的顶栏图标组。
|
||
const TerminalDeviceStatus({super.key});
|
||
|
||
/// 在顶栏设备组的最小 Obx 子树中读取五类设备状态并数据驱动构建图标。
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final adminSession = Get.find<TerminalAdminSessionControl>();
|
||
final visibilityControl = Get.find<TerminalDeviceStatusControl>();
|
||
return Obx(() {
|
||
final detailsEnabled =
|
||
adminSession.remainingSeconds.value > 0 && adminSession.isUnlocked;
|
||
final items = _readTerminalDeviceStatusItems()
|
||
.where((item) => visibilityControl.isHeaderDeviceVisible(item.type))
|
||
.toList(growable: false);
|
||
if (items.isEmpty) return const SizedBox.shrink();
|
||
final iconRow = items
|
||
.map(
|
||
(item) => TerminalStatusIcon(
|
||
icon: item.icon,
|
||
label: item.label,
|
||
state: item.state,
|
||
description: item.currentDescription,
|
||
),
|
||
)
|
||
.toList(growable: false)
|
||
.toRowSpace(space: 8.w);
|
||
final content = detailsEnabled
|
||
? Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
key: const ValueKey('terminal-device-status-group'),
|
||
borderRadius: BorderRadius.circular(
|
||
XfjDesignTokens.controlRadius,
|
||
),
|
||
onTap: () async {
|
||
// 倒计时重建与点击可能发生在相邻帧,弹窗前再次校验绝对过期时间,
|
||
// 确保会话刚过期时整个状态组都没有触觉反馈或导航副作用。
|
||
if (!Get.find<TerminalAdminSessionControl>().isUnlocked) {
|
||
return;
|
||
}
|
||
HapticFeedback.selectionClick();
|
||
await _showTerminalDeviceStatusDialog(context);
|
||
},
|
||
child: iconRow,
|
||
),
|
||
)
|
||
: iconRow;
|
||
final statusSummary = items.map((item) {
|
||
final visual = _TerminalDeviceStateVisual.resolve(item.state);
|
||
return '${item.label}${visual.label}';
|
||
}).join(',');
|
||
return Semantics(
|
||
container: true,
|
||
button: detailsEnabled,
|
||
enabled: detailsEnabled,
|
||
label: '终端设备状态,$statusSummary',
|
||
hint: detailsEnabled ? '点击查看全部设备详情' : null,
|
||
child: Tooltip(
|
||
message: detailsEnabled ? '点击查看全部设备状态' : statusSummary,
|
||
child: content,
|
||
),
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 48dp 状态图标提供单项三态提示;点击能力由外层设备组统一管理。
|
||
class TerminalStatusIcon extends StatelessWidget {
|
||
/// 创建一个带三态标记和语义标签的固定尺寸图标。
|
||
const TerminalStatusIcon({
|
||
super.key,
|
||
required this.icon,
|
||
required this.label,
|
||
required this.state,
|
||
required this.description,
|
||
});
|
||
|
||
/// 当前设备能力使用的 Forui SVG 图标。
|
||
final SvgAsset icon;
|
||
|
||
/// 设备能力中文名称,同时用于无障碍语义。
|
||
final String label;
|
||
|
||
/// 当前设备三态,决定状态颜色、文字和辅助图标。
|
||
final TerminalDeviceRunState state;
|
||
|
||
/// 当前状态的具体说明,用于顶栏 Tooltip 与无障碍语义。
|
||
final String description;
|
||
|
||
/// 构建 48dp 图标和右下三态标记,不在单项内部创建点击回调。
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final visual = _TerminalDeviceStateVisual.resolve(state);
|
||
final content = SizedBox.square(
|
||
dimension: 48.w,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
FIcon(
|
||
icon,
|
||
size: 21.w,
|
||
color: state == TerminalDeviceRunState.missing
|
||
? XfjDesignTokens.muted
|
||
: XfjDesignTokens.ink,
|
||
),
|
||
Positioned(
|
||
right: 5.w,
|
||
bottom: 5.w,
|
||
child: Container(
|
||
key: ValueKey('terminal-status-$label-${state.name}'),
|
||
width: 16.w,
|
||
height: 16.w,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: XfjDesignTokens.surface,
|
||
border: Border.all(
|
||
color: XfjDesignTokens.surface,
|
||
width: 1.w,
|
||
),
|
||
),
|
||
child: FIcon(
|
||
visual.indicatorIcon,
|
||
size: 13.w,
|
||
color: visual.color,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
return Semantics(
|
||
container: true,
|
||
label: '$label,${visual.label},$description',
|
||
child: Tooltip(
|
||
message: '$label:${visual.label}。$description',
|
||
child: content,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 以接近全屏的横屏弹窗同时展示五类设备详情;调用方必须先确认管理会话仍处于解锁期。
|
||
Future<void> _showTerminalDeviceStatusDialog(BuildContext context) async {
|
||
if (!Get.find<TerminalAdminSessionControl>().isUnlocked) return;
|
||
final theme = FTheme.of(context);
|
||
final viewportSize = MediaQuery.sizeOf(context);
|
||
// 终端虽然分辨率较高,但实体屏幕较小,因此仅保留少量模态边缘;
|
||
// 大弹框用于保证信息可读与后续诊断扩展,设备表自身仍按内容自然高度排列。
|
||
final dialogWidth = viewportSize.width * 0.92;
|
||
final dialogHeight = viewportSize.height * 0.90;
|
||
await showAppDialog<void>(
|
||
context: context,
|
||
builder: (dialogContext) => AppDialog(
|
||
key: const ValueKey('terminal-device-status-dialog'),
|
||
title: _deviceStatusDialogHeader(theme, dialogContext),
|
||
contentPadding: EdgeInsets.zero,
|
||
showCloseButton: false,
|
||
semanticLabel: '终端设备状态',
|
||
width: dialogWidth,
|
||
maxWidth: dialogWidth,
|
||
height: dialogHeight,
|
||
content: <Widget>[
|
||
Obx(() {
|
||
final items = _readTerminalDeviceStatusItems();
|
||
return SingleChildScrollView(
|
||
physics: const ClampingScrollPhysics(),
|
||
child: <Widget>[
|
||
_deviceStateSummary(theme, items),
|
||
_deviceStatusList(theme, items),
|
||
].toColumnSpace(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
space: 12.h,
|
||
),
|
||
);
|
||
}).expanded(),
|
||
const Divider(height: 1, color: XfjDesignTokens.line),
|
||
_deviceStatusDialogFooter(theme, dialogContext),
|
||
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 构建聚合设备弹窗标题栏,并提供符合终端触控尺寸的显式关闭入口。
|
||
Widget _deviceStatusDialogHeader(
|
||
FThemeData theme,
|
||
BuildContext dialogContext,
|
||
) {
|
||
final closeTouchSize = (56.w).clamp(48.0, 56.0).toDouble();
|
||
final headerIconSize = (22.w).clamp(18.0, 20.0).toDouble();
|
||
final headerIconBoxSize = (44.w).clamp(36.0, 40.0).toDouble();
|
||
return <Widget>[
|
||
FIcon(
|
||
FAssets.icons.monitorCog,
|
||
size: headerIconSize,
|
||
color: XfjDesignTokens.primary,
|
||
).center().tightSize(headerIconBoxSize).decorated(
|
||
color: XfjDesignTokens.primarySoft,
|
||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||
),
|
||
<Widget>[
|
||
Text(
|
||
'终端设备状态',
|
||
style: _compactDeviceDialogTextStyle(
|
||
theme.typography.xl2,
|
||
minSize: 18,
|
||
maxSize: 20,
|
||
).copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w800,
|
||
height: 1.15,
|
||
),
|
||
),
|
||
Text(
|
||
'打印机、电子秤、读卡器、副屏与摄像头',
|
||
style: _compactDeviceDialogTextStyle(
|
||
theme.typography.sm,
|
||
minSize: 14,
|
||
maxSize: 15,
|
||
).copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
height: 1.2,
|
||
),
|
||
),
|
||
]
|
||
.toColumnSpace(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
space: 2.h,
|
||
)
|
||
.expanded(),
|
||
Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
key: const ValueKey('terminal-device-status-close-icon'),
|
||
onTap: () => Navigator.of(dialogContext).pop(),
|
||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||
child: FIcon(
|
||
FAssets.icons.x,
|
||
size: (18.w).clamp(15.0, 17.0).toDouble(),
|
||
color: XfjDesignTokens.muted,
|
||
).center().tightSize(closeTouchSize),
|
||
),
|
||
),
|
||
].toRowSpace(space: 12.w).padding(horizontal: 22.w, vertical: 12.h);
|
||
}
|
||
|
||
/// 汇总五类设备的红、黄、绿数量,让用户无需逐项查找即可先判断整体运行情况。
|
||
Widget _deviceStateSummary(
|
||
FThemeData theme,
|
||
List<_TerminalDeviceStatusItem> items,
|
||
) {
|
||
final checkedAt = items.isEmpty ? null : items.first.updatedAt;
|
||
final summaries = TerminalDeviceRunState.values.map((state) {
|
||
return (
|
||
visual: _TerminalDeviceStateVisual.resolve(state),
|
||
count: items.where((item) => item.state == state).length,
|
||
);
|
||
}).toList(growable: false);
|
||
return <Widget>[
|
||
<Widget>[
|
||
Text(
|
||
'设备总览',
|
||
style: _compactDeviceDialogTextStyle(
|
||
theme.typography.base,
|
||
minSize: 15,
|
||
maxSize: 16,
|
||
).copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w800,
|
||
height: 1.15,
|
||
),
|
||
),
|
||
Text(
|
||
'共 ${items.length} 个设备 · 检测 ${_formatDeviceCheckedAt(checkedAt)}',
|
||
style: _compactDeviceDialogTextStyle(
|
||
theme.typography.sm,
|
||
minSize: 13,
|
||
maxSize: 14,
|
||
).copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
height: 1.15,
|
||
),
|
||
),
|
||
]
|
||
.toColumnSpace(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
space: 2.h,
|
||
)
|
||
.expanded(),
|
||
for (var index = 0; index < summaries.length; index++) ...[
|
||
if (index > 0)
|
||
Container(width: 1.w, height: 28.h, color: XfjDesignTokens.line),
|
||
Semantics(
|
||
label: '${summaries[index].visual.label} ${summaries[index].count} 个设备',
|
||
child: <Widget>[
|
||
FIcon(
|
||
summaries[index].visual.indicatorIcon,
|
||
size: (18.w).clamp(16.0, 18.0).toDouble(),
|
||
color: summaries[index].visual.color,
|
||
),
|
||
Text(
|
||
summaries[index].visual.label,
|
||
style: _compactDeviceDialogTextStyle(
|
||
theme.typography.sm,
|
||
minSize: 14,
|
||
maxSize: 15,
|
||
).copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.2,
|
||
),
|
||
),
|
||
Text(
|
||
'${summaries[index].count}',
|
||
style: _compactDeviceDialogTextStyle(
|
||
theme.typography.base,
|
||
minSize: 15,
|
||
maxSize: 17,
|
||
).copyWith(
|
||
color: summaries[index].visual.color,
|
||
fontWeight: FontWeight.w800,
|
||
height: 1.2,
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
]
|
||
.toRowSpace(space: 7.w, mainAxisSize: MainAxisSize.min)
|
||
.padding(horizontal: 8.w, vertical: 8.h),
|
||
),
|
||
],
|
||
]
|
||
.toRowSpace(space: 10.w)
|
||
.padding(horizontal: 22.w, vertical: 12.h)
|
||
.decorated(color: XfjDesignTokens.canvas);
|
||
}
|
||
|
||
/// 将五个设备组织为带稳定列宽的状态表,并以适中行距区分连续设备信息。
|
||
Widget _deviceStatusList(
|
||
FThemeData theme,
|
||
List<_TerminalDeviceStatusItem> items,
|
||
) {
|
||
final rowGap = (8.h).clamp(6.0, 8.0).toDouble();
|
||
final Widget table = <Widget>[
|
||
_deviceStatusTableHeader(theme),
|
||
...items.map((item) => _deviceStatusRow(theme, item)),
|
||
].toColumnSpace(
|
||
key: const ValueKey('terminal-device-status-table'),
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
space: rowGap,
|
||
);
|
||
return table.padding(horizontal: 22.w, bottom: 16.h);
|
||
}
|
||
|
||
/// 构建设备表头,使五行状态和各自的顶部显隐开关共享稳定列宽。
|
||
Widget _deviceStatusTableHeader(FThemeData theme) {
|
||
final headerStyle = _compactDeviceDialogTextStyle(
|
||
theme.typography.sm,
|
||
minSize: 14,
|
||
maxSize: 15,
|
||
).copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.15,
|
||
);
|
||
return _deviceStatusGridRow(
|
||
children: [
|
||
Text('设备', style: headerStyle),
|
||
Text('运行状态', style: headerStyle),
|
||
Text('当前状态', style: headerStyle),
|
||
Text('设备说明', style: headerStyle),
|
||
Text('顶部显示', style: headerStyle, textAlign: TextAlign.center),
|
||
],
|
||
)
|
||
.padding(horizontal: 18.w)
|
||
.constrained(minHeight: (48.h).clamp(44.0, 48.0).toDouble())
|
||
.decorated(
|
||
color: XfjDesignTokens.canvas,
|
||
border: Border.all(color: XfjDesignTokens.line),
|
||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||
);
|
||
}
|
||
|
||
/// 构建单个设备的可读表格行,使用色标与图标文字表达三态,避免状态底色铺满整列。
|
||
Widget _deviceStatusRow(
|
||
FThemeData theme,
|
||
_TerminalDeviceStatusItem item,
|
||
) {
|
||
final visual = _TerminalDeviceStateVisual.resolve(item.state);
|
||
final rowIconSize = (22.w).clamp(19.0, 22.0).toDouble();
|
||
final rowIconBoxSize = (42.w).clamp(36.0, 42.0).toDouble();
|
||
final rowHeight = (76.h).clamp(68.0, 76.0).toDouble();
|
||
final bodyStyle = _compactDeviceDialogTextStyle(
|
||
theme.typography.base,
|
||
minSize: 15,
|
||
maxSize: 16,
|
||
).copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w500,
|
||
height: 1.35,
|
||
);
|
||
final visibilityControl = Get.find<TerminalDeviceStatusControl>();
|
||
return Container(
|
||
key: ValueKey('terminal-device-status-row-${item.label}'),
|
||
clipBehavior: Clip.antiAlias,
|
||
decoration: BoxDecoration(
|
||
color: XfjDesignTokens.surface,
|
||
border: Border.all(color: XfjDesignTokens.line),
|
||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||
),
|
||
child: <Widget>[
|
||
Container(width: 4.w, color: visual.color),
|
||
_deviceStatusGridRow(
|
||
children: [
|
||
<Widget>[
|
||
FIcon(item.icon, size: rowIconSize, color: visual.color)
|
||
.center()
|
||
.tightSize(rowIconBoxSize)
|
||
.decorated(
|
||
color: visual.color.withValues(alpha: 0.09),
|
||
borderRadius: BorderRadius.circular(8.w),
|
||
),
|
||
Text(
|
||
item.label,
|
||
style: _compactDeviceDialogTextStyle(
|
||
theme.typography.lg,
|
||
minSize: 16,
|
||
maxSize: 17,
|
||
).copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.15,
|
||
),
|
||
).expanded(),
|
||
].toRowSpace(space: 7.w),
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Semantics(
|
||
label: '${item.label}${visual.label}',
|
||
child: <Widget>[
|
||
FIcon(
|
||
visual.indicatorIcon,
|
||
size: (18.w).clamp(16.0, 18.0).toDouble(),
|
||
color: visual.color,
|
||
),
|
||
Text(
|
||
visual.label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: _compactDeviceDialogTextStyle(
|
||
theme.typography.base,
|
||
minSize: 15,
|
||
maxSize: 16,
|
||
).copyWith(
|
||
color: visual.color,
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.2,
|
||
),
|
||
),
|
||
].toRowSpace(
|
||
key: ValueKey('terminal-device-status-label-${item.label}'),
|
||
space: 7.w,
|
||
mainAxisSize: MainAxisSize.min,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
item.currentDescription,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: bodyStyle.copyWith(fontWeight: FontWeight.w600),
|
||
),
|
||
Text(
|
||
item.deviceDescription,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: bodyStyle.copyWith(color: XfjDesignTokens.muted),
|
||
),
|
||
Obx(() {
|
||
final visible = visibilityControl.isHeaderDeviceVisible(item.type);
|
||
return FSwitch(
|
||
key: ValueKey(
|
||
'terminal-device-status-visibility-${item.type.code}',
|
||
),
|
||
semanticLabel: '在页面顶部显示${item.label}状态',
|
||
value: visible,
|
||
onChange: (value) => unawaited(
|
||
visibilityControl.setHeaderDeviceVisible(item.type, value),
|
||
),
|
||
);
|
||
}).center(),
|
||
],
|
||
).padding(horizontal: 16.w, vertical: 8.h).expanded(),
|
||
].toRow(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
),
|
||
).height(rowHeight);
|
||
}
|
||
|
||
/// 按固定比例排列设备、运行状态、当前状态、设备说明和顶部开关,确保表头与正文严格对齐。
|
||
Widget _deviceStatusGridRow({required List<Widget> children}) {
|
||
const flexes = <int>[15, 16, 32, 27, 10];
|
||
assert(children.length == flexes.length, '设备状态表格必须提供五列内容');
|
||
return <Widget>[
|
||
for (var index = 0; index < children.length; index++)
|
||
children[index].padding(horizontal: 4.w).expanded(flex: flexes[index]),
|
||
].toRow(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
);
|
||
}
|
||
|
||
/// 构建弹窗底部的全宽关闭按钮;逐项顶部显隐由对应设备行直接配置。
|
||
Widget _deviceStatusDialogFooter(
|
||
FThemeData theme,
|
||
BuildContext dialogContext,
|
||
) {
|
||
final buttonHeight = (56.h).clamp(48.0, 56.0).toDouble();
|
||
return FButton(
|
||
key: const ValueKey('terminal-device-status-close-button'),
|
||
label: Text(
|
||
'关闭',
|
||
style: _compactDeviceDialogTextStyle(
|
||
theme.typography.lg,
|
||
minSize: 15,
|
||
maxSize: 16,
|
||
).copyWith(
|
||
color: Colors.white,
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.1,
|
||
),
|
||
),
|
||
onPress: () => Navigator.of(dialogContext).pop(),
|
||
).height(buttonHeight).padding(horizontal: 22.w, vertical: 12.h);
|
||
}
|
||
|
||
/// 将设备本地检测时间格式化为稳定的数字布局;没有记录时返回明确空状态。
|
||
String _formatDeviceCheckedAt(DateTime? value) {
|
||
if (value == null) return '尚未完成设备检测';
|
||
String twoDigits(int number) => number.toString().padLeft(2, '0');
|
||
return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)} '
|
||
'${twoDigits(value.hour)}:${twoDigits(value.minute)}:${twoDigits(value.second)}';
|
||
}
|
||
|
||
/// 将人脸会话状态转换为摄像头当前是否工作及对应说明。
|
||
class _FaceCameraStatusVisual {
|
||
/// 创建一份摄像头状态描述。
|
||
const _FaceCameraStatusVisual({
|
||
required this.description,
|
||
required this.working,
|
||
});
|
||
|
||
/// 悬停时展示的摄像头与识别引擎状态说明。
|
||
final String description;
|
||
|
||
/// 摄像头是否正在初始化、识别或处理本次结果。
|
||
final bool working;
|
||
|
||
/// 综合硬件存在性、摄像头开启状态和人脸会话状态生成对应说明。
|
||
static _FaceCameraStatusVisual resolve({
|
||
required bool cameraAvailable,
|
||
required bool cameraOpen,
|
||
required FaceRecognitionSessionStatus status,
|
||
}) {
|
||
if (!cameraAvailable) {
|
||
return const _FaceCameraStatusVisual(
|
||
description: '系统未检测到可用摄像头,请检查硬件和系统识别。',
|
||
working: false,
|
||
);
|
||
}
|
||
if (status == FaceRecognitionSessionStatus.initializing) {
|
||
return const _FaceCameraStatusVisual(
|
||
description: '正在打开摄像头并初始化识别引擎。',
|
||
working: true,
|
||
);
|
||
}
|
||
if (status == FaceRecognitionSessionStatus.detecting) {
|
||
return const _FaceCameraStatusVisual(
|
||
description: '摄像头已打开,正在实时识别人脸。',
|
||
working: true,
|
||
);
|
||
}
|
||
if (status == FaceRecognitionSessionStatus.success) {
|
||
return const _FaceCameraStatusVisual(
|
||
description: '识别已完成,正在处理本次结果。',
|
||
working: true,
|
||
);
|
||
}
|
||
if (status == FaceRecognitionSessionStatus.failed) {
|
||
return const _FaceCameraStatusVisual(
|
||
description: '摄像头可用,但本次识别异常,当前已空闲。',
|
||
working: false,
|
||
);
|
||
}
|
||
return _FaceCameraStatusVisual(
|
||
description:
|
||
cameraOpen ? '摄像头热保持空闲,20 分钟无任务后自动关闭。' : '摄像头已检测到,当前没有人脸识别任务。',
|
||
working: false,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 顶栏轻量展示进程 CPU、系统内存、存储和调度空闲状态。
|
||
class TerminalPerformanceStatus extends StatefulWidget {
|
||
/// 创建顶栏性能与空闲状态区域。
|
||
const TerminalPerformanceStatus({super.key});
|
||
|
||
/// 创建管理三秒采样周期和两次 CPU 快照差值的状态对象。
|
||
@override
|
||
State<TerminalPerformanceStatus> createState() =>
|
||
_TerminalPerformanceStatusState();
|
||
}
|
||
|
||
/// 顶栏性能指标的采样、CPU 差值计算和展示状态。
|
||
class _TerminalPerformanceStatusState extends State<TerminalPerformanceStatus> {
|
||
/// 每三秒触发一次轻量原生资源采样的计时器。
|
||
Timer? _timer;
|
||
|
||
/// 上一次原生累计资源快照,用于计算当前进程 CPU 使用率。
|
||
AppPerformanceSnapshot? _previous;
|
||
|
||
/// 上一次资源快照完成的本地时间,用于计算 CPU 墙钟时间差。
|
||
DateTime? _previousAt;
|
||
|
||
/// 当前用于顶栏展示的内存、存储和累计 CPU 快照。
|
||
AppPerformanceSnapshot _snapshot = const AppPerformanceSnapshot.empty();
|
||
|
||
/// 当前调度空闲判断快照,与性能数据在同一次局部 setState 中刷新。
|
||
SyncIdleSnapshot _idle = SyncIdleTool.instance.snapshot;
|
||
|
||
/// 根据连续两次累计 CPU 时间计算的进程 CPU 百分比,范围为 0 到 100。
|
||
double _cpuUsage = 0;
|
||
|
||
/// 是否已有资源采样请求进行中,防止慢平台通道造成并发采样。
|
||
bool _refreshing = false;
|
||
|
||
/// 首次立即采样,并启动固定三秒的后续刷新周期。
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
unawaited(_refresh());
|
||
_timer = Timer.periodic(
|
||
const Duration(seconds: 3),
|
||
(_) => unawaited(_refresh()),
|
||
);
|
||
}
|
||
|
||
/// 页面释放时取消性能采样计时器,避免顶栏销毁后继续访问平台通道。
|
||
@override
|
||
void dispose() {
|
||
_timer?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
/// 读取原生资源快照、计算进程 CPU 差值,并在组件仍挂载时刷新可见状态。
|
||
Future<void> _refresh() async {
|
||
if (_refreshing) return;
|
||
_refreshing = true;
|
||
try {
|
||
final current = await AppRuntimeTool.getPerformanceSnapshot();
|
||
final now = DateTime.now();
|
||
final previous = _previous;
|
||
final previousAt = _previousAt;
|
||
var cpuUsage = _cpuUsage;
|
||
if (previous != null && previousAt != null) {
|
||
final wallMillis = now.difference(previousAt).inMilliseconds;
|
||
final cpuMillis =
|
||
current.processCpuTimeMillis - previous.processCpuTimeMillis;
|
||
if (wallMillis > 0 && cpuMillis >= 0) {
|
||
cpuUsage =
|
||
(cpuMillis / wallMillis / current.logicalProcessorCount * 100)
|
||
.clamp(0, 100)
|
||
.toDouble();
|
||
}
|
||
}
|
||
_previous = current;
|
||
_previousAt = now;
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_snapshot = current;
|
||
_idle = SyncIdleTool.instance.snapshot;
|
||
_cpuUsage = cpuUsage;
|
||
});
|
||
} finally {
|
||
_refreshing = false;
|
||
}
|
||
}
|
||
|
||
/// 构建 CPU、内存、存储和空闲摘要,详细容量通过 Tooltip 展示。
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = FTheme.of(context);
|
||
final idleColor =
|
||
_idle.idle ? XfjDesignTokens.success : XfjDesignTokens.warning;
|
||
final memoryUsage = (_snapshot.memoryUsageRatio * 100).round();
|
||
final storageUsage = (_snapshot.storageUsageRatio * 100).round();
|
||
final cpuText =
|
||
_snapshot.processCpuTimeMillis == 0 ? '--' : '${_cpuUsage.round()}%';
|
||
final memoryText =
|
||
_snapshot.systemMemoryTotalBytes == 0 ? '--' : '$memoryUsage%';
|
||
final storageText =
|
||
_snapshot.storageTotalBytes == 0 ? '--' : '$storageUsage%';
|
||
final warning = _cpuUsage >= 85 || memoryUsage >= 90 || storageUsage >= 90;
|
||
return Tooltip(
|
||
message: _tooltipText(memoryUsage, storageUsage),
|
||
child: Semantics(
|
||
container: true,
|
||
label:
|
||
'设备性能,CPU $cpuText,内存 $memoryText,存储 $storageText,${_idle.label}',
|
||
child: <Widget>[
|
||
FIcon(
|
||
FAssets.icons.gauge,
|
||
size: 21.w,
|
||
color: warning ? XfjDesignTokens.danger : XfjDesignTokens.ink,
|
||
),
|
||
<Widget>[
|
||
Text(
|
||
'CPU $cpuText · 内存 $memoryText · 存储 $storageText',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.typography.xs.copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w700,
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
<Widget>[
|
||
Container(
|
||
width: 8.w,
|
||
height: 8.w,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: idleColor,
|
||
),
|
||
),
|
||
Text(
|
||
_idle.idle ? '设备空闲' : '设备忙碌 · ${_idle.reason}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.typography.xs.copyWith(
|
||
color: idleColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
).expanded(),
|
||
].toRowSpace(space: 6.w),
|
||
]
|
||
.toColumnSpace(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
space: 3.h,
|
||
)
|
||
.expanded(),
|
||
]
|
||
.toRowSpace(space: 10.w)
|
||
.padding(horizontal: 12.w)
|
||
.constrained(width: 300.w, height: 48.h),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 生成包含应用内存、系统总内存、存储容量和忙碌原因的悬停说明。
|
||
String _tooltipText(int memoryUsage, int storageUsage) {
|
||
final memory = _snapshot.systemMemoryTotalBytes == 0
|
||
? '内存数据不可用'
|
||
: '系统内存 $memoryUsage%,应用占用 ${_formatBytes(_snapshot.appMemoryBytes)}'
|
||
' / 总计 ${_formatBytes(_snapshot.systemMemoryTotalBytes)}';
|
||
final storage = _snapshot.storageTotalBytes == 0
|
||
? '存储数据不可用'
|
||
: '存储 $storageUsage%,可用 ${_formatBytes(_snapshot.storageAvailableBytes)}'
|
||
' / 总计 ${_formatBytes(_snapshot.storageTotalBytes)}';
|
||
final idle = _idle.idle ? '设备空闲' : '设备忙碌:${_idle.reason}';
|
||
return '进程 CPU ${_cpuUsage.toStringAsFixed(1)}%\n$memory\n$storage\n$idle';
|
||
}
|
||
|
||
/// 将字节数转换为适合顶栏悬停说明的 GB、MB 或 KB 文本。
|
||
String _formatBytes(int bytes) {
|
||
if (bytes >= 1024 * 1024 * 1024) {
|
||
return '${(bytes / 1024 / 1024 / 1024).toStringAsFixed(1)} GB';
|
||
}
|
||
if (bytes >= 1024 * 1024) {
|
||
return '${(bytes / 1024 / 1024).toStringAsFixed(0)} MB';
|
||
}
|
||
return '${(bytes / 1024).toStringAsFixed(0)} KB';
|
||
}
|
||
}
|