Files
biz_xfj_cloud/lib/common/widgets/app/terminal_device_status.dart
T
2026-08-06 20:55:09 +08:00

1012 lines
37 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
}
}