From fb90b477b93608296ae188a085bd72caa56120be Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 4 Sep 2026 16:23:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=89=93=E5=8D=B0=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/api/hard_api.dart | 29 +- .../models/database/database_enum_models.dart | 6 - .../models/database/database_models.dart | 30 +- .../models/database/database_models.g.dart | 17 +- lib/common/models/index.dart | 1 + .../models/manage/printer_device_models.dart | 77 ++ .../manage/printer_device_models.g.dart | 15 + lib/common/models/mq/mq_models.dart | 9 +- .../models/page/setting_page_enum_models.dart | 33 + lib/common/routers/names.dart | 6 +- lib/common/routers/pages.dart | 4 - lib/common/utils/app/app_mq_tool.dart | 29 +- lib/common/utils/app/app_network_tool.dart | 11 +- lib/common/utils/app/app_setting_tool.dart | 52 +- lib/common/utils/app/control/app_control.dart | 111 +-- lib/common/utils/manage/print_manage.dart | 248 ++++++ lib/common/utils/sync/sync_check_tool.dart | 48 +- lib/common/utils/sync/sync_print_tool.dart | 15 +- lib/common/values/constants.dart | 4 +- lib/pages/index.dart | 1 - lib/pages/printer/view.dart | 438 ---------- lib/pages/root/device_setup/view.dart | 26 +- lib/pages/root/login/controller.dart | 24 +- .../terminal/setting/routes/navigation.dart | 2 + .../setting/routes/printer_info/view.dart | 13 + .../setting/routes/route_factory.dart | 5 + lib/pages/terminal/setting/view.dart | 2 + lib/pages/terminal/setting/widgets/index.dart | 1 + .../setting/widgets/setting_baseInfo.dart | 7 +- .../setting/widgets/setting_printer_info.dart | 805 ++++++++++++++++++ test/common/terminal_device_status_test.dart | 24 - 31 files changed, 1369 insertions(+), 724 deletions(-) create mode 100644 lib/common/models/manage/printer_device_models.dart create mode 100644 lib/common/models/manage/printer_device_models.g.dart delete mode 100644 lib/pages/printer/view.dart create mode 100644 lib/pages/terminal/setting/routes/printer_info/view.dart create mode 100644 lib/pages/terminal/setting/widgets/setting_printer_info.dart diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index 0ea0a12..b9302c0 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -313,20 +313,35 @@ abstract class HardApi { return true; } - /// 读取当前 EY_PRINT 的服务端绑定和 USB/ESC-POS 参数。 - static Future> getPrinterBootstrap() async { + /// 读取当前食堂或超市终端绑定的逻辑打印机及 USB/ESC-POS 参数。 + static Future?> getPrinterBinding() async { final setting = await AppSettingTool.getSysSetting(); final hardId = setting.serviceId; - if (hardId == null || hardId.isEmpty) throw StateError('打印终端尚未绑定设备 ID'); + if (hardId == null || hardId.isEmpty) throw StateError('当前业务终端尚未绑定设备 ID'); final res = await HttpManage().dio.get( '/hard/hard-api/ad/printer/bootstrap', options: dio.Options(headers: {'DEVICE-ID': hardId}), ); - if (res.data is! Map) throw const FormatException('打印终端启动配置格式错误'); + if (res.data == null) return null; + if (res.data is! Map) throw const FormatException('打印机绑定配置格式错误'); return Map.from(res.data as Map); } - /// 拉取当前打印终端未完成任务,补偿 MQTT 离线窗口。 + /// 由当前业务终端把实际枚举到的 USB 打印机型号和 VID/PID 上报到绑定的 EY_PRINT 配置。 + static Future> reportPrinterInfo({ + required String hardId, + required PrinterInfoReportRequest request, + }) async { + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/printer/info', + data: request.toJson(), + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + if (res.data is! Map) throw const FormatException('打印机信息上报响应格式错误'); + return Map.from(res.data as Map); + } + + /// 拉取当前业务终端所绑定打印机的未完成任务,补偿 MQTT 离线窗口。 static Future> getPrinterTasks() async { final setting = await AppSettingTool.getSysSetting(); final hardId = setting.serviceId; @@ -346,7 +361,7 @@ abstract class HardApi { }).toList(growable: false); } - /// 上报完整 USB 写入结果;请求失败由本地队列保留并在网络恢复后补报。 + /// 由当前业务终端上报完整 USB 写入结果;请求失败由本地队列保留并在网络恢复后补报。 static Future reportPrinterResult({ required String taskId, required String orderId, @@ -357,7 +372,7 @@ abstract class HardApi { }) async { final setting = await AppSettingTool.getSysSetting(); final hardId = setting.serviceId; - if (hardId == null || hardId.isEmpty) throw StateError('打印终端设备 ID 缺失'); + if (hardId == null || hardId.isEmpty) throw StateError('业务终端设备 ID 缺失'); final res = await HttpManage().dio.post( '/hard/hard-api/ad/printer/result', data: { diff --git a/lib/common/models/database/database_enum_models.dart b/lib/common/models/database/database_enum_models.dart index e14d232..4f9e1ef 100644 --- a/lib/common/models/database/database_enum_models.dart +++ b/lib/common/models/database/database_enum_models.dart @@ -17,12 +17,6 @@ enum SysType implements BaseEnum { const SysType(this.code, this.title, this.cnStr, this.usStr); } -/// 终端运行角色与业务类型分离,打印终端仍沿用绑定来源的 [SysType] 获取业务配置。 -enum TerminalRole { - cashier, - printer, -} - /// 订单类型 enum OrderType implements BaseEnum { BELOW("1", "线下购买"), diff --git a/lib/common/models/database/database_models.dart b/lib/common/models/database/database_models.dart index e1f6df6..575a38f 100644 --- a/lib/common/models/database/database_models.dart +++ b/lib/common/models/database/database_models.dart @@ -10,10 +10,6 @@ class SysSetting extends Equatable { /// 设备类型 SysType sysType; - /// 当前 APK 运行角色;旧配置默认保持收银终端行为。 - @JsonKey(defaultValue: TerminalRole.cashier) - TerminalRole terminalRole; - /// mac地址 String? androidId; @@ -26,20 +22,28 @@ class SysSetting extends Equatable { /// 商户id String? serviceMerchantId; - /// 打印终端所绑定的来源业务终端。 - String? printerSourceHardId; + /// 当前业务终端绑定的 EY_PRINT 逻辑打印机资产 ID;未绑定时为空。 + String? printerHardId; - /// 打印终端所绑定的来源业务终端名称。 - String? printerSourceHardName; + /// 当前业务终端绑定的 EY_PRINT 逻辑打印机资产名称;未绑定时为空。 + String? printerHardName; - /// 真机打印机型号与 USB 参数,由服务端启动配置下发。 + /// 当前业务终端代理的 USB 打印机型号,由服务端绑定配置下发。 String? printerModel; + + /// 当前绑定打印机的 USB Vendor ID 十进制值;尚未上报时为空。 int? printerUsbVendorId; + + /// 当前绑定打印机的 USB Product ID 十进制值;尚未上报时为空。 int? printerUsbProductId; - /// 当前纸宽、切刀和图片指令模式。 + /// 当前绑定打印机的纸宽,单位毫米;旧配置缺失时默认 80。 int printerPaperWidth; + + /// 当前绑定打印机是否在完整写入后发送自动切刀指令。 bool printerAutoCut; + + /// 当前绑定打印机的 ESC/POS 图片指令模式,默认使用 GS_V0。 String printerImageMode; /// 商户部门id @@ -151,7 +155,6 @@ class SysSetting extends Equatable { SysSetting({ required this.sysType, - this.terminalRole = TerminalRole.cashier, this.androidId, this.serviceId, this.mqSocketAddress, @@ -180,13 +183,12 @@ class SysSetting extends Equatable { @override List get props => [ sysType, - terminalRole, androidId, serviceId, serviceName, serviceMerchantId, - printerSourceHardId, - printerSourceHardName, + printerHardId, + printerHardName, printerModel, printerUsbVendorId, printerUsbProductId, diff --git a/lib/common/models/database/database_models.g.dart b/lib/common/models/database/database_models.g.dart index e9a8746..76cf86b 100644 --- a/lib/common/models/database/database_models.g.dart +++ b/lib/common/models/database/database_models.g.dart @@ -8,9 +8,6 @@ part of 'database_models.dart'; SysSetting _$SysSettingFromJson(Map json) => SysSetting( sysType: $enumDecode(_$SysTypeEnumMap, json['sysType']), - terminalRole: - $enumDecodeNullable(_$TerminalRoleEnumMap, json['terminalRole']) ?? - TerminalRole.cashier, androidId: json['androidId'] as String?, serviceId: json['serviceId'] as String?, mqSocketAddress: json['mqSocketAddress'] as String?, @@ -44,8 +41,8 @@ SysSetting _$SysSettingFromJson(Map json) => SysSetting( ) ..serviceName = json['serviceName'] as String? ..serviceMerchantId = json['serviceMerchantId'] as String? - ..printerSourceHardId = json['printerSourceHardId'] as String? - ..printerSourceHardName = json['printerSourceHardName'] as String? + ..printerHardId = json['printerHardId'] as String? + ..printerHardName = json['printerHardName'] as String? ..printerModel = json['printerModel'] as String? ..printerUsbVendorId = (json['printerUsbVendorId'] as num?)?.toInt() ..printerUsbProductId = (json['printerUsbProductId'] as num?)?.toInt() @@ -94,13 +91,12 @@ SysSetting _$SysSettingFromJson(Map json) => SysSetting( Map _$SysSettingToJson(SysSetting instance) => { 'sysType': _$SysTypeEnumMap[instance.sysType]!, - 'terminalRole': _$TerminalRoleEnumMap[instance.terminalRole]!, 'androidId': instance.androidId, 'serviceId': instance.serviceId, 'serviceName': instance.serviceName, 'serviceMerchantId': instance.serviceMerchantId, - 'printerSourceHardId': instance.printerSourceHardId, - 'printerSourceHardName': instance.printerSourceHardName, + 'printerHardId': instance.printerHardId, + 'printerHardName': instance.printerHardName, 'printerModel': instance.printerModel, 'printerUsbVendorId': instance.printerUsbVendorId, 'printerUsbProductId': instance.printerUsbProductId, @@ -155,11 +151,6 @@ const _$SysTypeEnumMap = { SysType.CANTEEN: 'CANTEEN', }; -const _$TerminalRoleEnumMap = { - TerminalRole.cashier: 'cashier', - TerminalRole.printer: 'printer', -}; - const _$RootAccessStatusEnumMap = { RootAccessStatus.unknown: 'unknown', RootAccessStatus.available: 'available', diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart index 27abbf0..2fd08f6 100644 --- a/lib/common/models/index.dart +++ b/lib/common/models/index.dart @@ -27,6 +27,7 @@ export './manage/usb_facility_manage.dart'; export './manage/terminal_device_status_models.dart'; export './manage/hardware_input_history_models.dart'; export './manage/order_evidence_models.dart'; +export './manage/printer_device_models.dart'; export './manage/scheduled_task_models.dart'; export './manage/face_sync_models.dart'; export './manage/root_access_models.dart'; diff --git a/lib/common/models/manage/printer_device_models.dart b/lib/common/models/manage/printer_device_models.dart new file mode 100644 index 0000000..9d5dda5 --- /dev/null +++ b/lib/common/models/manage/printer_device_models.dart @@ -0,0 +1,77 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'printer_device_models.g.dart'; + +/// Android USB Host 实际枚举到的一台标准 Printer Class 设备。 +class PrinterUsbDeviceInfo { + /// 创建一次只读的 USB 打印机发现快照。 + const PrinterUsbDeviceInfo({ + required this.manufacturerName, + required this.productName, + required this.deviceId, + required this.vendorId, + required this.productId, + }); + + /// USB 描述符中的厂商名称;读取不到时为空。 + final String? manufacturerName; + + /// USB 描述符中的产品名称;读取不到时为空。 + final String? productName; + + /// Android 在本次连接期间分配的 USB 设备编号;重新插拔后可能变化。 + final int? deviceId; + + /// USB Vendor ID 十进制值,范围为 0 到 65535。 + final int vendorId; + + /// USB Product ID 十进制值,范围为 0 到 65535。 + final int productId; + + /// 页面列表选择使用的进程内稳定键,不作为服务端或数据库主键。 + String get selectionKey => '$vendorId:$productId:${deviceId ?? -1}'; + + /// 优先组合厂商和产品描述,缺少描述符时回退为通用打印机名称。 + String get displayName { + final parts = [ + if (manufacturerName?.trim().isNotEmpty == true) manufacturerName!.trim(), + if (productName?.trim().isNotEmpty == true) productName!.trim(), + ]; + return parts.isEmpty ? 'USB Printer' : parts.toSet().join(' / '); + } + + /// 将 USB 编号格式化为四位大写十六进制,便于与设备文档交叉核对。 + String get hexadecimalIdentity => + '0x${vendorId.toRadixString(16).padLeft(4, '0').toUpperCase()} / ' + '0x${productId.toRadixString(16).padLeft(4, '0').toUpperCase()}'; + + /// 生成只包含服务端允许字段的打印机信息上报请求。 + PrinterInfoReportRequest toReportRequest() => PrinterInfoReportRequest( + printerModel: displayName, + usbVendorId: vendorId, + usbProductId: productId, + ); +} + +/// 终端向服务端上报实际连接打印机型号与 USB 标识的请求。 +@JsonSerializable(createFactory: false) +class PrinterInfoReportRequest { + /// 创建一份不包含设备路径、序列号等敏感信息的上报请求。 + const PrinterInfoReportRequest({ + required this.printerModel, + required this.usbVendorId, + required this.usbProductId, + }); + + /// 由 USB 厂商和产品描述组合的打印机型号,服务端最长保存 128 个字符。 + final String printerModel; + + /// USB Vendor ID 十进制值。 + final int usbVendorId; + + /// USB Product ID 十进制值。 + final int usbProductId; + + /// 转换为终端打印机信息上报接口使用的 JSON 对象。 + Map toJson() => _$PrinterInfoReportRequestToJson(this); +} diff --git a/lib/common/models/manage/printer_device_models.g.dart b/lib/common/models/manage/printer_device_models.g.dart new file mode 100644 index 0000000..da21c02 --- /dev/null +++ b/lib/common/models/manage/printer_device_models.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'printer_device_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Map _$PrinterInfoReportRequestToJson( + PrinterInfoReportRequest instance) => + { + 'printerModel': instance.printerModel, + 'usbVendorId': instance.usbVendorId, + 'usbProductId': instance.usbProductId, + }; diff --git a/lib/common/models/mq/mq_models.dart b/lib/common/models/mq/mq_models.dart index 88b8e1e..ce28e64 100644 --- a/lib/common/models/mq/mq_models.dart +++ b/lib/common/models/mq/mq_models.dart @@ -40,16 +40,21 @@ class MqttInvoiceMsg { @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) final String? androidOrderCode; - /// 来源业务终端与实际目标打印终端。 + /// 实际接收消息并代理 USB 打印的来源业务终端 ID。 @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) final String? sourceHardId; + /// 后台绑定的 EY_PRINT 逻辑打印机资产 ID,用于核对当前任务路由。 @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) final String? targetHardId; - /// 打印类型、同订单打印序号和人工重试版本。 + /// 打印内容类型,兼容发票与兑换券协议编码。 final String? printType; + + /// 同订单同打印类型的业务打印序号;传输重试不会增加该值。 final int? printNo; + + /// 同一服务端任务的人工重试版本,用于终端幂等去重。 final int? retryVersion; // 图片地址url diff --git a/lib/common/models/page/setting_page_enum_models.dart b/lib/common/models/page/setting_page_enum_models.dart index 31afadd..c94eb4b 100644 --- a/lib/common/models/page/setting_page_enum_models.dart +++ b/lib/common/models/page/setting_page_enum_models.dart @@ -2,27 +2,60 @@ import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 设置中心二级菜单的稳定类型,编码用于页面排序和路由映射,只能追加不能复用。 enum SettingMenuType implements BaseEnum { + /// 终端身份、绑定状态和基础显示配置。 baseInfo("10", "基本信息"), + + /// 后台数据、消息服务和硬件状态的同步任务配置。 syncSetting("20", "同步设置"), + + /// 本地早餐、中餐和晚餐的时间范围。 mealPeriodSetting("25", "餐段配置"), + + /// 已迁移到页面设置的历史兼容入口,不直接展示。 paySetting("30", "支付管理"), + + /// 本地数据库与文件日志的备份操作。 backup("40", "资源备份"), + + /// 本地人脸样本查询、同步与维护。 faceSetting("50", "人脸管理"), + + /// 人脸插件授权、引擎和注册状态诊断。 facePluginSetting("55", "人脸设置"), + + /// 终端一级页面排序和页面级偏好设置。 pageSetting("57", "页面设置"), + + /// 本地商品、分类和默认菜品维护。 productSetting("60", "商品管理"), + + /// 本机文件日志目录与脱敏内容预览。 logPreview("62", "日志预览"), + + /// USB 打印机发现、参数上报和测试输出。 + printerInfo("63", "打印机"), + + /// 仅开发或模拟器使用的顾客副屏场景检查。 subScreenRoutes("65", "副屏路由"), + + /// APK 版本查询、下载和更新状态。 versionSetting("70", "版本管理"), + + /// DK25-ST 读写卡策略与诊断操作。 cardReaderSetting("75", "读/写卡"), ; + /// 页面排序与跨版本兼容使用的持久化编码。 @override final String code; + + /// 设置菜单和页头展示的中文标题。 @override final String title; + /// 创建一项具有稳定编码和展示标题的设置菜单。 const SettingMenuType(this.code, this.title); } diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index 90a2395..dc4734d 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -9,9 +9,6 @@ class RouteNames { /// 登录后为尚未绑定的安装实例输入服务端设备 ID。 static const deviceSetup = '/device-setup'; - /// EY_PRINT 无人值守打印状态面板,不装载收银业务页面。 - static const printer = '/printer'; - /// 顾客屏全窗口预览,不装载终端侧栏和顶栏。 static const customer = '/customer'; @@ -75,6 +72,9 @@ class RouteNames { /// 设置模块的终端文件日志预览子路由。 static const settingLogPreview = '$setting/log-preview'; + /// 设置模块的 USB 打印机发现、上报与测试子路由。 + static const settingPrinterInfo = '$setting/printer-info'; + /// 设置模块的副屏路由调试子路由。 static const settingSubScreenRoutes = '$setting/sub-screen-routes'; diff --git a/lib/common/routers/pages.dart b/lib/common/routers/pages.dart index 8f30d60..bef4a56 100644 --- a/lib/common/routers/pages.dart +++ b/lib/common/routers/pages.dart @@ -24,10 +24,6 @@ class RoutePages { name: RouteNames.deviceSetup, page: () => const DeviceSetupPage(), ), - GetPage( - name: RouteNames.printer, - page: () => const PrinterStatusPage(), - ), GetPage( name: RouteNames.customer, page: () => CustomerDisplayPreviewPage(arguments: Get.arguments), diff --git a/lib/common/utils/app/app_mq_tool.dart b/lib/common/utils/app/app_mq_tool.dart index 3e13f7f..859ccf7 100644 --- a/lib/common/utils/app/app_mq_tool.dart +++ b/lib/common/utils/app/app_mq_tool.dart @@ -79,9 +79,7 @@ class AppMqTool { logger.e("|$_tag|mqtt监听消息错误error:|$e|"); } }); - if (sysInfo.terminalRole == TerminalRole.printer) { - unawaited(SyncPrintTool.pullPendingTasks()); - } + unawaited(SyncPrintTool.pullPendingTasks()); } /// 根据协议动作分发已完成设备 Topic 校验的 MQTT 消息。 @@ -91,20 +89,17 @@ class AppMqTool { if (ObjectUtil.isEmpty(msg.action)) return; final app = Get.find(); final mqUpdateEventBus = app.mqUpdateEventBus; - final setting = await AppSettingTool.getSysSetting(); - if (setting.terminalRole == TerminalRole.printer && - msg.action != MqtActionEnum.CONFIG && - msg.action != MqtActionEnum.PRINT && - msg.action != MqtActionEnum.PULL_TERMINAL_LOG) { - logger.w('|$_tag|打印角色忽略非打印消息action:${msg.action}|'); - return; - } + var setting = await AppSettingTool.getSysSetting(); switch (msg.action!) { case MqtActionEnum.CONFIG: await SyncConfigTool.autoSyncSysInfo(); - if (setting.terminalRole == TerminalRole.printer) { - final bootstrap = await HardApi.getPrinterBootstrap(); - await AppSettingTool.updatePrinterBootstrap(bootstrap); + try { + setting = await AppSettingTool.updatePrinterBinding( + await HardApi.getPrinterBinding(), + ); + } catch (error, stack) { + // 打印机绑定是业务终端的附加能力,读取失败不能阻断商品、人脸等基础配置刷新。 + logger.w('|$_tag|刷新打印机绑定失败|error:$error|stack:$stack|'); } mqUpdateEventBus.fire(MqtActionEnum.CONFIG); break; @@ -141,9 +136,11 @@ class AppMqTool { final msg = MqttInvoiceMsg.fromJson(json.decode(orgStr)); if (ObjectUtil.isNotEmpty(msg.historyId) && ObjectUtil.isNotEmpty(msg.orderId) && - setting.terminalRole == TerminalRole.printer && + (msg.sourceHardId == null || + msg.sourceHardId == setting.serviceId) && + // targetHardId 是逻辑打印机资产,不是 Android 设备;新协议消息必须匹配本机当前绑定。 (msg.targetHardId == null || - msg.targetHardId == setting.serviceId) && + msg.targetHardId == setting.printerHardId) && ObjectUtil.isNotEmpty(msg.pic)) { unawaited(SyncPrintTool.addTask( orderId: msg.orderId!, diff --git a/lib/common/utils/app/app_network_tool.dart b/lib/common/utils/app/app_network_tool.dart index 8d0378a..f69adc8 100644 --- a/lib/common/utils/app/app_network_tool.dart +++ b/lib/common/utils/app/app_network_tool.dart @@ -102,13 +102,10 @@ abstract class AppNetworkTool { } catch (e, stack) { logger.e('|$_tag|网络恢复后连接消息服务失败error:|$e|stack:|$stack|'); } - final setting = await AppSettingTool.getSysSetting(); - if (setting.terminalRole == TerminalRole.cashier) { - try { - await SyncOrderTool.syncOfflineOrder(); - } catch (e, stack) { - logger.e('|$_tag|网络恢复后同步离线订单失败error:|$e|stack:|$stack|'); - } + try { + await SyncOrderTool.syncOfflineOrder(); + } catch (e, stack) { + logger.e('|$_tag|网络恢复后同步离线订单失败error:|$e|stack:|$stack|'); } } diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 3c05170..18e3c6a 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -755,14 +755,9 @@ abstract class AppSettingTool { } final type = data.type.toString(); if (ObjectUtil.isNotEmpty(type)) { - if (type == '12') { - sysSetting.terminalRole = TerminalRole.printer; - } else { - final t = SysType.values.fromCode(type); - if (ObjectUtil.isNotEmpty(t)) { - sysSetting.sysType = t!; - sysSetting.terminalRole = TerminalRole.cashier; - } + final t = SysType.values.fromCode(type); + if (ObjectUtil.isNotEmpty(t)) { + sysSetting.sysType = t!; } } final merchantId = data.merchantId; @@ -821,23 +816,38 @@ abstract class AppSettingTool { await _setSetting(sysJson: sysSetting); } - /// 将服务端校验过的 EY_PRINT 一对一绑定和打印参数持久化到本机。 - static Future updatePrinterBootstrap( - Map bootstrap, + /// 将当前业务终端的打印机绑定和打印参数持久化到本机;空响应表示尚未绑定。 + static Future updatePrinterBinding( + Map? binding, ) async { final setting = await getSysSetting(); - final sourceType = bootstrap['sourceHardType']?.toString(); - final businessType = SysType.values.fromCode(sourceType ?? ''); - if (businessType == null) { - throw const FormatException('打印终端绑定来源不是超市或食堂设备'); + if (binding == null) { + setting.printerHardId = null; + setting.printerHardName = null; + setting.printerModel = null; + setting.printerUsbVendorId = null; + setting.printerUsbProductId = null; + setting.printerPaperWidth = 80; + setting.printerAutoCut = true; + setting.printerImageMode = 'GS_V0'; + await _setSetting(sysJson: setting); + return setting; } - final config = bootstrap['printerConfig'] is Map - ? Map.from(bootstrap['printerConfig'] as Map) + final sourceHardId = binding['sourceHardId']?.toString(); + if (sourceHardId != setting.serviceId) { + throw const FormatException('服务端打印机绑定与当前业务终端不匹配'); + } + final sourceType = SysType.values.fromCode( + binding['sourceHardType']?.toString() ?? '', + ); + if (sourceType != setting.sysType) { + throw const FormatException('服务端打印机绑定的业务类型与当前终端不匹配'); + } + final config = binding['printerConfig'] is Map + ? Map.from(binding['printerConfig'] as Map) : {}; - setting.terminalRole = TerminalRole.printer; - setting.sysType = businessType; - setting.printerSourceHardId = bootstrap['sourceHardId']?.toString(); - setting.printerSourceHardName = bootstrap['sourceHardName']?.toString(); + setting.printerHardId = binding['printerHardId']?.toString(); + setting.printerHardName = binding['printerHardName']?.toString(); setting.printerModel = config['printerModel']?.toString(); setting.printerUsbVendorId = int.tryParse('${config['usbVendorId'] ?? ''}'); setting.printerUsbProductId = diff --git a/lib/common/utils/app/control/app_control.dart b/lib/common/utils/app/control/app_control.dart index 85233cb..d3f8400 100644 --- a/lib/common/utils/app/control/app_control.dart +++ b/lib/common/utils/app/control/app_control.dart @@ -205,20 +205,13 @@ class AppControl extends GetxController { } final setting = await AppSettingTool.getSysSetting(); - final isPrinterTerminal = setting.terminalRole == TerminalRole.printer; - // 打印终端没有顾客副屏,不能在启动时创建第二 Flutter Engine。 - if (!isPrinterTerminal) { - // 插件会在 Android 注册阶段自动创建副屏;这里统一确认可用状态并记录常驻标记, - // 即使无副屏或权限不足也只影响设备状态,不阻塞后续启动流程。 - unawaited(AppSubScreenTool.show()); - } - var storageGranted = true; - if (!isPrinterTerminal) { - storageGranted = - await Permission.manageExternalStorage.request().isGranted; - if (!storageGranted) { - storageGranted = await Permission.storage.request().isGranted; - } + // 插件会在 Android 注册阶段自动创建副屏;这里统一确认可用状态并记录常驻标记, + // 即使无副屏或权限不足也只影响设备状态,不阻塞后续启动流程。 + unawaited(AppSubScreenTool.show()); + var storageGranted = + await Permission.manageExternalStorage.request().isGranted; + if (!storageGranted) { + storageGranted = await Permission.storage.request().isGranted; } AppRuntimeTool.applyManualHardwareInput(setting.manualHardwareInput); final environment = _resolveEnvironment(setting); @@ -245,36 +238,27 @@ class AppControl extends GetxController { ); if (!printManageInitialized) printManage = PrintManage(); await AppDeviceStatusTool.loadCachedStatus(); - if (isPrinterTerminal) { - // 打印角色只探测 USB 打印机,避免初始化读卡、电子秤、人脸和副屏插件。 - unawaited(printManage.checkAvailable().then( - (available) => AppDeviceStatusTool.updatePrinter(available), - )); - } else { - if (!serialCardManageInitialized) serialCardManage = SerialCardManage(); - if (!steelyardManageInitialized) { - srialSteelyardManage = SerialSteelyardManage(); - } - // 硬件检查失败只能影响状态展示,不能阻塞收银终端进入业务页面。 - unawaited( - AppDeviceStatusTool.refreshAll() - .catchError((Object error, StackTrace stack) { - logger.w('|$_tag|首次检查设备状态失败error:|$error|stack:|$stack|'); - return AppDeviceStatusTool.status.value; - }), - ); - await TtsManage.initialize(); + if (!serialCardManageInitialized) serialCardManage = SerialCardManage(); + if (!steelyardManageInitialized) { + srialSteelyardManage = SerialSteelyardManage(); } + // 硬件检查失败只能影响状态展示,不能阻塞收银终端进入业务页面。 + unawaited( + AppDeviceStatusTool.refreshAll() + .catchError((Object error, StackTrace stack) { + logger.w('|$_tag|首次检查设备状态失败error:|$error|stack:|$stack|'); + return AppDeviceStatusTool.status.value; + }), + ); + await TtsManage.initialize(); _setStartupStep( 2, status: environment.manualHardwareInput ? AppStartupStepStatus.warning : AppStartupStepStatus.success, - detail: isPrinterTerminal - ? 'USB 打印服务已就绪,未启动收银外设' - : environment.manualHardwareInput - ? '当前为手动硬件输入模式,实体设备将在业务使用时按需连接' - : '设备管理服务已就绪,实体设备将在业务使用时按需连接', + detail: environment.manualHardwareInput + ? '当前为手动硬件输入模式,实体设备将在业务使用时按需连接' + : '设备管理服务已就绪,实体设备将在业务使用时按需连接', ); _setStartupStep( @@ -282,28 +266,19 @@ class AppControl extends GetxController { status: AppStartupStepStatus.running, detail: '正在检查激活参数并装载本地人脸库', ); - if (isPrinterTerminal) { - _setStartupStep( - 3, - status: AppStartupStepStatus.success, - detail: '打印角色无需加载人脸识别能力', - ); - } else { - final facePreparation = await AppFaceTool.prepareForStartup(); - faceStartupPreparationResult.value = facePreparation; - _setStartupStep( - 3, - status: facePreparation.status == FaceStartupPreparationStatus.success - ? AppStartupStepStatus.success - : AppStartupStepStatus.warning, - detail: _facePreparationDetail(facePreparation), - ); - } + final facePreparation = await AppFaceTool.prepareForStartup(); + faceStartupPreparationResult.value = facePreparation; + _setStartupStep( + 3, + status: facePreparation.status == FaceStartupPreparationStatus.success + ? AppStartupStepStatus.success + : AppStartupStepStatus.warning, + detail: _facePreparationDetail(facePreparation), + ); - final backupSucceeded = !isPrinterTerminal && storageGranted - ? await AppFileTool.backupSqlFile() - : true; - if (!isPrinterTerminal && storageGranted && !backupSucceeded) { + final backupSucceeded = + storageGranted ? await AppFileTool.backupSqlFile() : false; + if (storageGranted && !backupSucceeded) { _setStartupStep( 1, status: AppStartupStepStatus.warning, @@ -319,9 +294,7 @@ class AppControl extends GetxController { _authenticatedServicesEnabled = ObjectUtil.isNotEmpty(userSetting.accessToken); if (_authenticatedServicesEnabled) { - await SyncCheckTool.instance.startAll( - terminalRole: setting.terminalRole, - ); + await SyncCheckTool.instance.startAll(); TerminalHeartbeatTool.start(); } await TerminalAutoUpdateTool.recoverPendingUpdate(); @@ -360,10 +333,10 @@ class AppControl extends GetxController { await initAsync(destination: destination, force: true); } - /// 登录或设备角色切换后恢复该角色允许的后台任务与在线心跳。 - Future resumeAuthenticatedServices(TerminalRole terminalRole) async { + /// 登录成功后恢复业务终端的后台任务与在线心跳。 + Future resumeAuthenticatedServices() async { _authenticatedServicesEnabled = true; - await SyncCheckTool.instance.startAll(terminalRole: terminalRole); + await SyncCheckTool.instance.startAll(); TerminalHeartbeatTool.start(); } @@ -430,14 +403,6 @@ class AppControl extends GetxController { String stepDetail, bool manualHardwareInput, }) _resolveEnvironment(SysSetting setting) { - if (setting.terminalRole == TerminalRole.printer) { - return ( - title: '打印终端模式', - detail: '仅启用网络、MQTT、本地队列和 USB 打印能力。', - stepDetail: '已读取尔印打印终端配置', - manualHardwareInput: false, - ); - } final manualHardwareInput = AppRuntimeTool.isEmulator || setting.manualHardwareInput; if (AppRuntimeTool.isEmulator) { diff --git a/lib/common/utils/manage/print_manage.dart b/lib/common/utils/manage/print_manage.dart index 72a4a78..48e3b05 100644 --- a/lib/common/utils/manage/print_manage.dart +++ b/lib/common/utils/manage/print_manage.dart @@ -1,5 +1,8 @@ +import 'dart:ui' as ui; + import 'package:common_utils/common_utils.dart'; import 'package:drago_pos_printer/drago_pos_printer.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:image/image.dart' as imglib; @@ -10,6 +13,15 @@ class PrintManage extends BaseFacilityManage { /// 打印机发现、连接和输出日志使用的稳定模块标签。 static const String _tag = "打印机管理"; + /// 80mm 测试小票使用的标准光栅宽度,确保与正式凭证的输出宽度一致。 + static const int _testPageWidth = 576; + + /// 测试小票画布高度,完整容纳设备信息、灰度块和底部校验文字。 + static const int _testPageHeight = 760; + + /// 测试小票左右安全边距,避免真机可打印区域略窄时裁掉文字。 + static const double _testPagePadding = 28; + /// 当前已选择并连接的 USB 打印管理器;未连接时为空。 USBPrinterManager? manager; @@ -49,6 +61,24 @@ class PrintManage extends BaseFacilityManage { } } + /// 枚举 Android 当前公开的标准 USB Printer Class 设备并转换为只读信息快照。 + Future> discoverPrinterDevices() async { + final printers = await USBPrinterManager.discover(); + return printers + .where( + (printer) => printer.vendorId != null && printer.productId != null) + .map( + (printer) => PrinterUsbDeviceInfo( + manufacturerName: printer.address, + productName: printer.name, + deviceId: printer.deviceId, + vendorId: printer.vendorId!, + productId: printer.productId!, + ), + ) + .toList(growable: false); + } + /// 优先按后台 VID/PID 精确匹配;未配置时才沿用已知厂商或唯一设备回退规则。 USBPrinter? _findTarget(List printers, SysSetting setting) { final configuredVendor = setting.printerUsbVendorId; @@ -148,6 +178,224 @@ class PrintManage extends BaseFacilityManage { } } + /// 将测试文字和灰度校准块渲染成 576px PNG,避免依赖打印机中文代码页。 + Future _buildTestPageImage( + PrinterUsbDeviceInfo device, + String printedAt, + String imageMode, + ) async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + final backgroundPaint = ui.Paint()..color = Colors.white; + canvas.drawRect( + const Rect.fromLTWH(0, 0, _testPageWidth * 1.0, _testPageHeight * 1.0), + backgroundPaint, + ); + + var top = 26.0; + top = _drawTestPageText( + canvas, + 'HQ 打印机测试', + top, + fontSize: 38, + fontWeight: FontWeight.w700, + textAlign: TextAlign.center, + ); + top = _drawTestPageText( + canvas, + '打印内容显示正常', + top + 2, + fontSize: 24, + textAlign: TextAlign.center, + ); + top = _drawTestPageDivider(canvas, top + 16); + top = _drawTestPageText(canvas, '打印时间:$printedAt', top + 15); + top = _drawTestPageText(canvas, '打印机:${device.displayName}', top + 4); + top = _drawTestPageText( + canvas, + 'VID:${device.vendorId} / ' + '0x${device.vendorId.toRadixString(16).padLeft(4, '0').toUpperCase()}', + top + 4, + ); + top = _drawTestPageText( + canvas, + 'PID:${device.productId} / ' + '0x${device.productId.toRadixString(16).padLeft(4, '0').toUpperCase()}', + top + 4, + ); + top = _drawTestPageText(canvas, '纸宽:80mm / 576px', top + 4); + top = _drawTestPageText(canvas, '图片模式:$imageMode', top + 4); + top = _drawTestPageDivider(canvas, top + 15); + top = _drawTestPageText( + canvas, + '黑度测试', + top + 15, + fontSize: 25, + fontWeight: FontWeight.w700, + ); + + const swatchGap = 12.0; + const swatchHeight = 62.0; + final swatchWidth = + (_testPageWidth - _testPagePadding * 2 - swatchGap * 3) / 4; + final swatchColors = [ + const Color(0xFFE5E5E5), + const Color(0xFFAAAAAA), + const Color(0xFF555555), + Colors.black, + ]; + for (var index = 0; index < swatchColors.length; index++) { + final left = _testPagePadding + index * (swatchWidth + swatchGap); + canvas.drawRect( + Rect.fromLTWH(left, top + 8, swatchWidth, swatchHeight), + ui.Paint()..color = swatchColors[index], + ); + } + top += swatchHeight + 24; + top = _drawTestPageText( + canvas, + '浅灰 中灰 深灰 纯黑', + top, + fontSize: 20, + textAlign: TextAlign.center, + ); + top = _drawTestPageDivider(canvas, top + 15); + top = _drawTestPageText( + canvas, + 'TEST PRINT COMPLETED', + top + 18, + fontSize: 24, + fontWeight: FontWeight.w700, + textAlign: TextAlign.center, + ); + _drawTestPageText( + canvas, + '如能看到本页文字和色块,图片打印链路正常', + top + 4, + fontSize: 21, + textAlign: TextAlign.center, + ); + + final picture = recorder.endRecording(); + ui.Image? renderedImage; + try { + renderedImage = await picture.toImage(_testPageWidth, _testPageHeight); + final pngData = + await renderedImage.toByteData(format: ui.ImageByteFormat.png); + if (pngData == null) throw StateError('无法编码测试打印图片'); + final decoded = imglib.decodePng(pngData.buffer.asUint8List()); + if (decoded == null) throw StateError('无法解码测试打印图片'); + return decoded; + } finally { + renderedImage?.dispose(); + picture.dispose(); + } + } + + /// 在测试小票画布上绘制一段可自动换行的黑色文字,并返回下一段起始纵坐标。 + double _drawTestPageText( + ui.Canvas canvas, + String text, + double top, { + double fontSize = 23, + FontWeight fontWeight = FontWeight.w400, + TextAlign textAlign = TextAlign.left, + }) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + color: Colors.black, + fontSize: fontSize, + fontWeight: fontWeight, + height: 1.25, + ), + ), + textAlign: textAlign, + textDirection: TextDirection.ltr, + )..layout(maxWidth: _testPageWidth - _testPagePadding * 2); + painter.paint(canvas, Offset(_testPagePadding, top)); + final nextTop = top + painter.height; + painter.dispose(); + return nextTop; + } + + /// 绘制测试小票的水平分隔线,并返回分隔线下方的纵坐标。 + double _drawTestPageDivider(ui.Canvas canvas, double top) { + canvas.drawLine( + Offset(_testPagePadding, top), + Offset(_testPageWidth - _testPagePadding, top), + ui.Paint() + ..color = Colors.black + ..strokeWidth = 2, + ); + return top + 2; + } + + /// 向指定 USB 打印机输出包含中英文、设备参数和灰度块的图片诊断小票。 + /// + /// 返回 true 只表示测试内容、走纸和可选切刀命令全部被 USB 接口接受, + /// 不代表已经从打印机硬件状态确认纸张实际输出。 + Future printTestPage(PrinterUsbDeviceInfo device) async { + USBPrinterManager? testManager; + try { + lastError = null; + final setting = await AppSettingTool.getSysSetting(); + final testProfile = await CapabilityProfile.load(); + final generator = EscGenerator( + paperSizeWidthMM, + maxPerLine, + testProfile, + ); + final printedAt = DateUtil.formatDate( + DateTime.now(), + format: DateFormats.full, + ); + final imageMode = + setting.printerImageMode == 'ESC_STAR' ? 'ESC_STAR' : 'GS_V0'; + final testPage = await _buildTestPageImage( + device, + printedAt, + imageMode, + ); + final bytes = [ + ...generator.feed(1), + ...(imageMode == 'ESC_STAR' + ? generator.image(testPage) + : generator.imageRaster(testPage)), + ...generator.feed(2), + if (setting.printerAutoCut) ...generator.cut(), + ]; + testManager = USBPrinterManager( + USBPrinter( + name: device.productName, + address: device.manufacturerName, + deviceId: device.deviceId, + vendorId: device.vendorId, + productId: device.productId, + ), + ); + AppDeviceStatusTool.updatePrinterWorking(true); + await testManager.writeBytes(bytes); + AppDeviceStatusTool.updatePrinter(true); + return true; + } catch (e) { + lastError = e.toString(); + AppDeviceStatusTool.updatePrinter(false); + logger.e('|$_tag|测试打印失败error|$e|'); + return false; + } finally { + AppDeviceStatusTool.updatePrinterWorking(false); + if (testManager != null) { + try { + await testManager.disconnect(); + } catch (_) { + // 写入流程已经给出准确结果,重复释放失败不覆盖本次测试打印结论。 + } + } + } + } + /// 关闭当前 USB 连接并清除“打印中”状态;重复调用不报错。 @override Future close() async { diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index 56abc1c..d087e78 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -19,16 +19,6 @@ class SyncCheckTool { /// 应用级任务调度单例,由 AppControl 初始化并由设置页读取响应式状态。 static final SyncCheckTool instance = SyncCheckTool._(); - /// 打印角色允许注册的后台任务;其余商品、人脸、订单和取餐任务属于收银角色。 - static const Set _printerTaskTypes = { - ScheduledTaskType.mqReconnect, - ScheduledTaskType.printCheck, - ScheduledTaskType.deviceStatus, - ScheduledTaskType.autoUpdate, - ScheduledTaskType.terminalLogUpload, - ScheduledTaskType.expiredLogCleanup, - }; - /// 当前已加载的任务配置,以稳定任务类型为键并向设置页提供响应式更新。 final configs = {}.obs; @@ -62,12 +52,6 @@ class SyncCheckTool { /// 是否已经完成本轮启动,防止 AppControl 重复注册相同 Cron 任务。 bool _started = false; - /// 当前调度任务对应的终端角色;设备首次绑定改变角色时据此重建任务集合。 - TerminalRole? _activeRole; - - /// 当前已经注册的终端角色,供状态诊断和测试确认角色切换结果。 - TerminalRole? get activeRole => _activeRole; - /// 当前注册到 Cron 的任务类型快照。 Set get scheduledTaskTypes => Set.unmodifiable(_scheduledTasks.keys); @@ -353,33 +337,19 @@ class SyncCheckTool { ), ]; - /// 初始化结构化配置并注册全部已启用任务;角色改变时先释放旧角色任务再重新注册。 - Future startAll( - {TerminalRole terminalRole = TerminalRole.cashier}) async { + /// 初始化结构化配置并注册全部已启用任务;同一生命周期内重复调用直接返回。 + Future startAll() async { await _disposeFuture; - if (_started && _activeRole == terminalRole) return; - if (_started) await dispose(); + if (_started) return; _started = true; - _activeRole = terminalRole; _cron ??= Cron(); await _loadState(); for (final config in configs.values) { - if (!supportsTask(terminalRole, config.type)) { - continue; - } await _rebuildTask(config); } - if (terminalRole == TerminalRole.cashier) { - queueMissedDepartmentFaceSync(); - } + queueMissedDepartmentFaceSync(); } - /// 判断指定角色是否允许执行某类后台任务,打印角色只保留通信、打印和运维任务。 - static bool supportsTask( - TerminalRole terminalRole, ScheduledTaskType taskType) => - terminalRole == TerminalRole.cashier || - _printerTaskTypes.contains(taskType); - /// 在每天 00:40 之后检查当天是否成功完成部门人脸同步,并在首次启动或恢复时补排一次。 /// /// 补排只加入同一人脸任务队列并要求完整空闲,不会阻塞启动或打断支付、识别和业务弹框。 @@ -839,14 +809,7 @@ class SyncCheckTool { ); return const _ScheduledTaskActionResult.success('取餐广播检查完成'); case ScheduledTaskType.deviceStatus: - final setting = await AppSettingTool.getSysSetting(); - if (setting.terminalRole == TerminalRole.printer) { - final available = - await Get.find().printManage.checkAvailable(); - AppDeviceStatusTool.updatePrinter(available); - } else { - await AppDeviceStatusTool.refreshAll(); - } + await AppDeviceStatusTool.refreshAll(); return const _ScheduledTaskActionResult.success('设备状态检查完成'); case ScheduledTaskType.autoUpdate: final update = await TerminalAutoUpdateTool.run(); @@ -1086,7 +1049,6 @@ class SyncCheckTool { /// 取消轮询和全部 Cron 句柄、清空待执行状态并关闭 Cron 实例。 Future _dispose() async { _started = false; - _activeRole = null; _idleTimer?.cancel(); _idleTimer = null; for (final task in _scheduledTasks.values) { diff --git a/lib/common/utils/sync/sync_print_tool.dart b/lib/common/utils/sync/sync_print_tool.dart index 95bdfb6..4614749 100644 --- a/lib/common/utils/sync/sync_print_tool.dart +++ b/lib/common/utils/sync/sync_print_tool.dart @@ -5,7 +5,7 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -/// EY_PRINT 本地打印队列,负责双通道入队、版本幂等、串行 USB 重试和结果补报。 +/// 业务终端的 EY_PRINT 代理队列,负责双通道入队、版本幂等、串行 USB 重试和结果补报。 abstract class SyncPrintTool { /// 打印队列诊断日志使用的稳定模块标签。 static const _tag = 'sync打印工具'; @@ -125,18 +125,19 @@ abstract class SyncPrintTool { existing.status == PrintStatus.ERROR && incomingVersion > existing.retryVersion; - /// 在登录、MQTT 重连、应用恢复和周期检查时补拉本机待办。 + /// 在登录、MQTT 重连、应用恢复和周期检查时补拉当前业务终端代理的待办。 static Future pullPendingTasks() async { if (_isPulling || !AppNetworkTool.isAutoNetwork()) { return; } - final setting = await AppSettingTool.getSysSetting(); - if (setting.terminalRole != TerminalRole.printer || - setting.serviceId == null) { - return; - } + var setting = await AppSettingTool.getSysSetting(); + if (setting.serviceId == null) return; _isPulling = true; try { + setting = await AppSettingTool.updatePrinterBinding( + await HardApi.getPrinterBinding(), + ); + if (setting.printerHardId == null) return; final tasks = await HardApi.getPrinterTasks(); for (final task in tasks) { if (task.historyId == null || diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index e22da94..72c24a3 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -15,8 +15,8 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; - static const apiUrl = 'https://www.zianxin.com/api'; + static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + // static const apiUrl = 'https://www.zianxin.com/api'; // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; diff --git a/lib/pages/index.dart b/lib/pages/index.dart index e37c13b..8fca6fb 100644 --- a/lib/pages/index.dart +++ b/lib/pages/index.dart @@ -5,5 +5,4 @@ export './root/load/view.dart'; export './root/login/controller.dart'; export './root/login/view.dart'; export './root/device_setup/view.dart'; -export './printer/view.dart'; export './terminal/view.dart'; diff --git a/lib/pages/printer/view.dart b/lib/pages/printer/view.dart deleted file mode 100644 index 8b36443..0000000 --- a/lib/pages/printer/view.dart +++ /dev/null @@ -1,438 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mqtt_client/mqtt_client.dart'; - -import 'package:pj_supermarket_cloud/common/index.dart'; - -/// EY_PRINT 专用状态面板,只承载绑定、连接、队列和故障恢复信息。 -class PrinterStatusPage extends StatefulWidget { - /// 创建打印终端常驻状态页。 - const PrinterStatusPage({super.key}); - - /// 创建负责生命周期恢复、轮询和本地队列统计的页面状态。 - @override - State createState() => _PrinterStatusPageState(); -} - -/// 打印终端状态页的进程内状态,页面销毁时必须取消轮询和生命周期观察。 -class _PrinterStatusPageState extends State - with WidgetsBindingObserver { - /// 每 30 秒刷新一次本地 USB 与队列状态的页面级计时器。 - Timer? _timer; - - /// 最近一次从本地设置读取的绑定和打印机配置快照。 - SysSetting? _setting; - - /// 本机尚未完成 USB 输出的任务数量。 - int _pending = 0; - - /// 本机等待后台人工重试的失败任务数量。 - int _failed = 0; - - /// 最近一次本地打印或连接错误;为空表示暂无已知错误。 - String? _lastError; - - /// 当前配置的 VID/PID 或自动发现的唯一打印机是否在线。 - bool _usbAvailable = false; - - /// 是否正在执行服务端配置刷新、MQTT 重连和待办补拉。 - bool _refreshing = false; - - /// 退出流程开始后为 true,用于阻止尚未结束的恢复任务重新连接消息服务。 - bool _sessionEnding = false; - - /// 注册应用恢复监听,并启动首次恢复和页面状态轮询。 - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - unawaited(_restoreConnections()); - _timer = Timer.periodic( - const Duration(seconds: 30), (_) => unawaited(_refreshStatus())); - } - - /// 取消所有页面级资源,避免退出后继续刷新已销毁的 State。 - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _timer?.cancel(); - super.dispose(); - } - - /// 应用回到前台时恢复 MQTT、HTTP 待办和待补报结果。 - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - unawaited(_restoreConnections()); - } - } - - /// 刷新服务端绑定配置、恢复 MQTT,并按顺序补拉、补报和启动本地打印。 - Future _restoreConnections() async { - if (_refreshing || _sessionEnding) { - return; - } - _refreshing = true; - if (mounted) setState(() {}); - try { - if (AppNetworkTool.isAutoNetwork()) { - final bootstrap = await HardApi.getPrinterBootstrap(); - if (_sessionEnding) return; - await AppSettingTool.updatePrinterBootstrap(bootstrap); - if (_sessionEnding) return; - await AppMqTool.mqRefresh(); - if (_sessionEnding) return; - await SyncPrintTool.pullPendingTasks(); - await SyncPrintTool.doCheckSync(); - } - if (_sessionEnding) return; - unawaited(SyncPrintTool.doCheckPrint()); - } catch (error, stack) { - logger.w('|打印终端恢复连接失败|error:$error|stack:$stack|'); - _lastError = error.toString(); - } finally { - _refreshing = false; - if (!_sessionEnding) await _refreshStatus(); - } - } - - /// 汇总本地设置、USB 可用性和队列数量,并以一次 setState 更新面板。 - Future _refreshStatus() async { - if (_sessionEnding) return; - final app = Get.find(); - final dao = app.dataBase.getPrintHistoryDao; - final setting = await AppSettingTool.getSysSetting(); - final latestError = await dao.findLatestError(); - final usb = await app.printManage.checkAvailable(); - final pending = await dao.countPending() ?? 0; - final failed = await dao.countFailed() ?? 0; - if (!mounted) { - return; - } - setState(() { - _setting = setting; - _usbAvailable = usb; - _lastError = - latestError?.lastError ?? app.printManage.lastError ?? _lastError; - _pending = pending; - _failed = failed; - }); - } - - /// 展示由 PC 管理端维护的只读打印机配置摘要。 - Future _showSettings() async { - final setting = _setting ?? await AppSettingTool.getSysSetting(); - if (!mounted) return; - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('打印机配置'), - content: Text( - '型号:${setting.printerModel ?? "未填写"}\n' - 'USB VID/PID:${setting.printerUsbVendorId ?? "未填写"} / ${setting.printerUsbProductId ?? "未填写"}\n' - '纸宽:${setting.printerPaperWidth}mm\n' - '图片模式:${setting.printerImageMode}\n' - '自动切刀:${setting.printerAutoCut ? "开启" : "关闭"}', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), child: const Text('关闭')) - ], - ), - ); - } - - /// 二次确认后关闭 MQTT、清理登录态并返回登录页。 - Future _logout() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('退出打印终端'), - content: const Text('退出后将停止接收新的打印任务。'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('取消')), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('退出')), - ], - ), - ); - if (confirmed != true) return; - _sessionEnding = true; - await AppSettingTool.setUserSetting(UserSetting()); - await Get.find().suspendAuthenticatedServices(); - Get.offAllNamed(RouteNames.login); - } - - /// 构建打印角色专用的绑定、连接、队列和故障恢复面板。 - @override - Widget build(BuildContext context) { - final setting = _setting; - final mqtt = MqManager.state == MqttConnectionState.connected; - return Scaffold( - backgroundColor: XfjDesignTokens.canvas, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.print, - size: 36, color: XfjDesignTokens.primary), - const SizedBox(width: 14), - const Expanded( - child: Text('尔印打印终端', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, - color: XfjDesignTokens.ink))), - Obx(() { - final network = Get.find().isNetwork.value; - return _StatusBadge( - label: network ? '网络正常' : '网络断开', - ok: network, - ); - }), - const SizedBox(width: 8), - _StatusBadge(label: mqtt ? 'MQTT 已连接' : 'MQTT 未连接', ok: mqtt), - const SizedBox(width: 8), - _StatusBadge( - label: _usbAvailable ? 'USB 已连接' : 'USB 未连接', - ok: _usbAvailable), - ], - ), - const SizedBox(height: 24), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 3, - child: _Panel( - title: '设备绑定', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InfoRow( - label: '本机设备', - value: setting?.serviceName ?? '--'), - _InfoRow( - label: '设备 ID', - value: setting?.serviceId ?? '--'), - _InfoRow( - label: '来源终端', - value: setting?.printerSourceHardName ?? '--'), - _InfoRow( - label: '业务类型', - value: setting?.sysType.title ?? '--'), - _InfoRow( - label: '打印机型号', - value: setting?.printerModel ?? '未填写'), - ], - ), - ), - ), - const SizedBox(width: 18), - Expanded( - flex: 2, - child: Column( - children: [ - Expanded( - child: _MetricPanel( - label: '待处理任务', - value: _pending, - color: XfjDesignTokens.primary)), - const SizedBox(height: 18), - Expanded( - child: _MetricPanel( - label: '失败任务', - value: _failed, - color: - _failed > 0 ? Colors.red : Colors.green)), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 18), - _Panel( - title: '最近错误', - child: Text(_lastError ?? '暂无错误', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: _lastError == null - ? XfjDesignTokens.muted - : Colors.red.shade700)), - ), - const SizedBox(height: 18), - Row( - children: [ - FilledButton.icon( - onPressed: _refreshing ? null : _restoreConnections, - icon: const Icon(Icons.refresh), - label: Text(_refreshing ? '连接中' : '重新连接')), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: _refreshing ? null : _restoreConnections, - icon: const Icon(Icons.replay), - label: const Text('同步并重试')), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: _showSettings, - icon: const Icon(Icons.settings), - label: const Text('设置')), - const Spacer(), - TextButton.icon( - onPressed: _logout, - icon: const Icon(Icons.logout), - label: const Text('退出')), - ], - ), - ], - ), - ), - ), - ); - } -} - -class _StatusBadge extends StatelessWidget { - /// 创建固定高度的连接状态标签。 - const _StatusBadge({required this.label, required this.ok}); - - /// 状态标签文案。 - final String label; - - /// 是否使用正常状态颜色。 - final bool ok; - - /// 构建不会因文案变化而改变外部工具栏高度的状态标签。 - @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: ok ? Colors.green.shade50 : Colors.red.shade50, - borderRadius: BorderRadius.circular(6)), - child: Text(label, - style: TextStyle( - color: ok ? Colors.green.shade800 : Colors.red.shade800, - fontWeight: FontWeight.w700)), - ); -} - -class _Panel extends StatelessWidget { - /// 创建与现有新 UI 主题一致的单层信息面板。 - const _Panel({required this.title, required this.child}); - - /// 面板标题。 - final String title; - - /// 面板正文内容。 - final Widget child; - - /// 构建带边框的单层内容容器。 - @override - Widget build(BuildContext context) => Container( - width: double.infinity, - padding: const EdgeInsets.all(22), - decoration: BoxDecoration( - color: XfjDesignTokens.surface, - border: Border.all(color: XfjDesignTokens.line), - borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(title, - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.w800)), - const SizedBox(height: 16), - child - ]), - ); -} - -class _MetricPanel extends StatelessWidget { - /// 创建填满父布局高度的队列数量面板。 - const _MetricPanel( - {required this.label, required this.value, required this.color}); - - /// 指标名称。 - final String label; - - /// 当前任务数量。 - final int value; - - /// 数值强调色。 - final Color color; - - /// 构建独立指标卡;不复用 `_Panel`,避免在受限高度内嵌套 Expanded。 - @override - Widget build(BuildContext context) => Container( - width: double.infinity, - padding: const EdgeInsets.all(22), - decoration: BoxDecoration( - color: XfjDesignTokens.surface, - border: Border.all(color: XfjDesignTokens.line), - borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), - ), - Expanded( - child: Align( - alignment: Alignment.bottomLeft, - child: Text( - '$value', - style: TextStyle( - fontSize: 54, - fontWeight: FontWeight.w800, - color: color, - ), - ), - ), - ), - ], - ), - ); -} - -/// 绑定信息面板中的固定标签与可截断值行。 -class _InfoRow extends StatelessWidget { - /// 创建一行绑定信息。 - const _InfoRow({required this.label, required this.value}); - - /// 左侧固定宽度标签。 - final String label; - - /// 右侧允许省略展示的服务端或本地配置值。 - final String value; - - /// 构建适合横屏状态面板密度的信息行。 - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row(children: [ - SizedBox( - width: 110, - child: Text(label, - style: const TextStyle(color: XfjDesignTokens.muted))), - Expanded( - child: Text(value, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w700))) - ]), - ); -} diff --git a/lib/pages/root/device_setup/view.dart b/lib/pages/root/device_setup/view.dart index 0fc4288..8783932 100644 --- a/lib/pages/root/device_setup/view.dart +++ b/lib/pages/root/device_setup/view.dart @@ -3,7 +3,7 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -/// 登录后的服务端设备绑定页,同时识别 cashier/printer 终端角色。 +/// 登录后的食堂或超市服务端设备绑定页。 class DeviceSetupPage extends StatefulWidget { /// 创建登录后首次绑定服务端设备的页面。 const DeviceSetupPage({super.key}); @@ -13,7 +13,7 @@ class DeviceSetupPage extends StatefulWidget { State createState() => _DeviceSetupPageState(); } -/// 首次设备绑定页面状态;绑定成功后按服务端类型路由到收银或打印角色。 +/// 首次设备绑定页面状态;绑定成功后进入统一业务终端工作区。 class _DeviceSetupPageState extends State { /// PC 设备管理中生成的服务端设备 ID 输入控制器。 final _controller = TextEditingController(); @@ -31,7 +31,7 @@ class _DeviceSetupPageState extends State { super.dispose(); } - /// 校验设备 ID、绑定设备并根据服务端返回的终端角色完成首次路由。 + /// 校验设备 ID、绑定食堂或超市设备并进入业务终端工作区。 Future _bind() async { final id = int.tryParse(_controller.text.trim()); if (id == null || id <= 0) { @@ -45,22 +45,10 @@ class _DeviceSetupPageState extends State { try { final bound = await AppSettingTool.bindSys(id); if (!bound) throw StateError('设备绑定失败,请确认设备 ID、单位以及后台绑定关系'); - var setting = await AppSettingTool.getSysSetting(); - if (setting.terminalRole == TerminalRole.printer) { - setting = await AppSettingTool.updatePrinterBootstrap( - await HardApi.getPrinterBootstrap(), - ); - } - await Get.find().resumeAuthenticatedServices( - setting.terminalRole, - ); - if (setting.terminalRole == TerminalRole.printer) { - await AppMqTool.mqRefresh(); - await SyncPrintTool.pullPendingTasks(); - Get.offAllNamed(RouteNames.printer); - } else { - Get.offAllNamed(RouteNames.terminal); - } + await Get.find().resumeAuthenticatedServices(); + await AppMqTool.mqRefresh(); + await SyncPrintTool.pullPendingTasks(); + Get.offAllNamed(RouteNames.terminal); } catch (error) { if (mounted) { setState( diff --git a/lib/pages/root/login/controller.dart b/lib/pages/root/login/controller.dart index 1f31b48..1a3abd4 100644 --- a/lib/pages/root/login/controller.dart +++ b/lib/pages/root/login/controller.dart @@ -179,7 +179,7 @@ class LoginController extends GetxController { if (visibleMsg) { Toast.success(msg: "登录成功"); } - // 启动页已经完成数据库与设备初始化,登录后按持久化终端角色进入独立工作区。 + // 启动页已经完成数据库与设备初始化,登录后进入食堂或超市业务工作区。 _clearForm(); await _openWorkspace(); } catch (error) { @@ -218,30 +218,14 @@ class LoginController extends GetxController { await doLogin(true, automatic: true); } - /// 未绑定实例先进入设备 ID 流程;EY_PRINT 永远进入打印面板而非收银终端。 + /// 未绑定实例先进入设备 ID 流程;已绑定实例统一进入业务终端工作区。 Future _openWorkspace() async { - var setting = await AppSettingTool.getSysSetting(); + final setting = await AppSettingTool.getSysSetting(); if (ObjectUtil.isEmpty(setting.serviceId)) { Get.offAllNamed(RouteNames.deviceSetup); return; } - if (setting.terminalRole == TerminalRole.printer) { - try { - setting = await AppSettingTool.updatePrinterBootstrap( - await HardApi.getPrinterBootstrap(), - ); - } catch (error, stack) { - logger.w('|$_tag|刷新打印终端绑定失败,使用本地配置进入状态页|error:$error|stack:$stack|'); - } - await Get.find().resumeAuthenticatedServices( - setting.terminalRole, - ); - Get.offAllNamed(RouteNames.printer); - return; - } - await Get.find().resumeAuthenticatedServices( - setting.terminalRole, - ); + await Get.find().resumeAuthenticatedServices(); Get.offAllNamed(RouteNames.terminal); } diff --git a/lib/pages/terminal/setting/routes/navigation.dart b/lib/pages/terminal/setting/routes/navigation.dart index 6692a85..8da64a0 100644 --- a/lib/pages/terminal/setting/routes/navigation.dart +++ b/lib/pages/terminal/setting/routes/navigation.dart @@ -114,6 +114,7 @@ abstract final class SettingNavigation { SettingMenuType.pageSetting => RouteNames.settingPage, SettingMenuType.productSetting => RouteNames.settingProduct, SettingMenuType.logPreview => RouteNames.settingLogPreview, + SettingMenuType.printerInfo => RouteNames.settingPrinterInfo, SettingMenuType.subScreenRoutes => RouteNames.settingSubScreenRoutes, SettingMenuType.versionSetting => RouteNames.settingVersion, SettingMenuType.cardReaderSetting => RouteNames.settingCardReader, @@ -133,6 +134,7 @@ abstract final class SettingNavigation { RouteNames.settingPage => SettingMenuType.pageSetting, RouteNames.settingProduct => SettingMenuType.productSetting, RouteNames.settingLogPreview => SettingMenuType.logPreview, + RouteNames.settingPrinterInfo => SettingMenuType.printerInfo, RouteNames.settingSubScreenRoutes => SettingMenuType.subScreenRoutes, RouteNames.settingVersion => SettingMenuType.versionSetting, RouteNames.settingCardReader => SettingMenuType.cardReaderSetting, diff --git a/lib/pages/terminal/setting/routes/printer_info/view.dart b/lib/pages/terminal/setting/routes/printer_info/view.dart new file mode 100644 index 0000000..b8db2a1 --- /dev/null +++ b/lib/pages/terminal/setting/routes/printer_info/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“打印机信息”路由页,提供独立的 USB 扫描、上报和测试生命周期。 +class SettingPrinterInfoRoutePage extends StatelessWidget { + /// 创建不持有路由外共享状态的打印机信息页。 + const SettingPrinterInfoRoutePage({super.key}); + + /// 构建打印机信息业务组件。 + @override + Widget build(BuildContext context) => const SettingPrinterInfo(); +} diff --git a/lib/pages/terminal/setting/routes/route_factory.dart b/lib/pages/terminal/setting/routes/route_factory.dart index 0c51415..39bacd3 100644 --- a/lib/pages/terminal/setting/routes/route_factory.dart +++ b/lib/pages/terminal/setting/routes/route_factory.dart @@ -16,6 +16,7 @@ import 'log_preview/view.dart'; import 'meal_periods/view.dart'; import 'navigation.dart'; import 'page_setting/view.dart'; +import 'printer_info/view.dart'; import 'product/view.dart'; import 'sub_screen_routes/view.dart'; import 'sync/view.dart'; @@ -77,6 +78,7 @@ abstract final class SettingRouteFactory { RouteNames.settingPage, RouteNames.settingProduct, RouteNames.settingLogPreview, + RouteNames.settingPrinterInfo, RouteNames.settingSubScreenRoutes, RouteNames.settingVersion, RouteNames.settingCardReader, @@ -96,6 +98,8 @@ abstract final class SettingRouteFactory { RouteNames.settingProduct => () => const SettingProductRoutePage(), RouteNames.settingLogPreview => () => const SettingLogPreviewRoutePage(), + RouteNames.settingPrinterInfo => () => + const SettingPrinterInfoRoutePage(), RouteNames.settingSubScreenRoutes => () => const SettingSubScreenRoutesRoutePage(), RouteNames.settingVersion => () => const SettingVersionRoutePage(), @@ -141,6 +145,7 @@ class _SettingAccessGuard extends StatelessWidget { SettingMenuType.pageSetting, SettingMenuType.backup, SettingMenuType.logPreview, + SettingMenuType.printerInfo, SettingMenuType.subScreenRoutes, SettingMenuType.cardReaderSetting, }.contains(type); diff --git a/lib/pages/terminal/setting/view.dart b/lib/pages/terminal/setting/view.dart index cd46493..626d359 100644 --- a/lib/pages/terminal/setting/view.dart +++ b/lib/pages/terminal/setting/view.dart @@ -308,6 +308,7 @@ SvgAsset _menuIcon(SettingMenuType type) { SettingMenuType.pageSetting => FAssets.icons.layoutDashboard, SettingMenuType.productSetting => FAssets.icons.package, SettingMenuType.logPreview => FAssets.icons.logs, + SettingMenuType.printerInfo => FAssets.icons.printer, SettingMenuType.subScreenRoutes => FAssets.icons.monitor, SettingMenuType.versionSetting => FAssets.icons.rocket, SettingMenuType.cardReaderSetting => FAssets.icons.nfc, @@ -327,6 +328,7 @@ String _menuDescription(SettingMenuType type) { SettingMenuType.pageSetting => '配置查询菜单、收银等终端页面的显示顺序和默认操作', SettingMenuType.productSetting => '查询本地商品、同步分类商品和维护默认菜品', SettingMenuType.logPreview => '按目录切换历史日志,查看脱敏后的最近内容', + SettingMenuType.printerInfo => '查看实际 USB 参数,手动上报并输出测试小票', SettingMenuType.subScreenRoutes => '选择顾客屏演示状态并在当前应用窗口中全屏检查', SettingMenuType.versionSetting => '查看可用终端版本、更新内容和下载状态', SettingMenuType.cardReaderSetting => '配置 DK25-ST 读取策略、M1 数据块和 KEY A/B 密钥', diff --git a/lib/pages/terminal/setting/widgets/index.dart b/lib/pages/terminal/setting/widgets/index.dart index 6d6ed4d..1aa3f3f 100644 --- a/lib/pages/terminal/setting/widgets/index.dart +++ b/lib/pages/terminal/setting/widgets/index.dart @@ -10,6 +10,7 @@ export './setting_log_preview.dart'; export './setting_meal_periods.dart'; export './setting_pay_type_editor.dart'; export './setting_page.dart'; +export './setting_printer_info.dart'; export './setting_product.dart'; export './setting_sub_screen_routes.dart'; export './setting_sync.dart'; diff --git a/lib/pages/terminal/setting/widgets/setting_baseInfo.dart b/lib/pages/terminal/setting/widgets/setting_baseInfo.dart index f3e171f..6f863b1 100644 --- a/lib/pages/terminal/setting/widgets/setting_baseInfo.dart +++ b/lib/pages/terminal/setting/widgets/setting_baseInfo.dart @@ -25,12 +25,11 @@ Future _refreshMqAfterBinding() async { Toast.error(msg: '绑定成功,消息服务暂未连接'); } +/// 打开服务端设备绑定弹窗;输入框只回显本机当前值,不为调试包注入固定业务终端 ID。 Future _bindServiceDialog(BuildContext context, SysSetting sys) async { final theme = FTheme.of(context); - var text = sys.serviceId ?? ""; - if (AppSysTool.isDev()) { - text = "2094277933165223938"; - } + // 调试包不得注入固定业务终端 ID,否则不同机器会共用同一套心跳、MQTT 和打印代理身份。 + final text = sys.serviceId ?? ""; final uniqueCodeController = TextEditingController(text: text); await showAppDialog( context: context, diff --git a/lib/pages/terminal/setting/widgets/setting_printer_info.dart b/lib/pages/terminal/setting/widgets/setting_printer_info.dart new file mode 100644 index 0000000..1a2b001 --- /dev/null +++ b/lib/pages/terminal/setting/widgets/setting_printer_info.dart @@ -0,0 +1,805 @@ +import 'dart:async'; + +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'setting_components.dart'; + +/// 打印机信息页,负责 USB 设备发现、后台参数对比、信息上报和本地测试输出。 +class SettingPrinterInfo extends StatefulWidget { + /// 创建一份拥有独立扫描和操作状态的打印机信息页。 + const SettingPrinterInfo({super.key}); + + /// 创建页面局部状态;USB 连接仍由应用级 [PrintManage] 统一管理。 + @override + State createState() => _SettingPrinterInfoState(); +} + +/// 打印机信息页状态,只持有本次页面生命周期内的发现快照和操作反馈。 +class _SettingPrinterInfoState extends State { + /// 应用级控制器,提供唯一的打印管理实例,避免页面重复创建硬件连接管理器。 + late final AppControl _app; + + /// 最近一次从本地持久化设置读取的业务终端和绑定打印机配置快照。 + SysSetting? _setting; + + /// 最近一次 Android USB Host 枚举出的标准 Printer Class 设备。 + List _devices = const []; + + /// 当前选中设备的进程内键;设备重新插拔后会在刷新时重新匹配。 + String? _selectedDeviceKey; + + /// 最近一次完成 USB 枚举的本机时间;尚未成功扫描时为空。 + DateTime? _lastScannedAt; + + /// 最近一次完成服务端上报的本机时间;当前页面未上报时为空。 + DateTime? _lastReportedAt; + + /// 扫描或硬件操作错误,成功刷新后清空。 + String? _errorMessage; + + /// 最近一次上报或测试打印的结果摘要,为空时不展示结果提示条。 + String? _operationMessage; + + /// USB 设备枚举进行中标记,用于反馈进度并拦截重复操作。 + bool _refreshing = false; + + /// 打印机信息上报进行中标记,接口返回前禁止切换设备或重复提交。 + bool _reporting = false; + + /// 测试打印进行中标记,完整 USB 写入返回前禁止发起其他硬件操作。 + bool _testing = false; + + /// 初始化应用级硬件管理引用,并在首帧后自动刷新一次设备列表。 + @override + void initState() { + super.initState(); + _app = Get.find(); + unawaited(_refreshDevices()); + } + + /// 当前是否有互斥的 USB、网络或测试打印操作正在执行。 + bool get _busy => _refreshing || _reporting || _testing; + + /// 按选择键解析当前 USB 设备;设备拔出或尚未选择时返回空。 + PrinterUsbDeviceInfo? get _selectedDevice { + final key = _selectedDeviceKey; + if (key == null) return null; + return _devices.firstWhereOrNull((device) => device.selectionKey == key); + } + + /// 当前业务终端是否已绑定服务端设备和 EY_PRINT 逻辑打印机。 + bool get _canReport => + _setting?.serviceId?.trim().isNotEmpty == true && + _setting?.printerHardId?.trim().isNotEmpty == true; + + /// 说明本机缺少业务终端身份或打印机绑定时的恢复动作。 + String get _reportRestrictionText { + final setting = _setting; + final hardId = setting?.serviceId?.trim(); + if (hardId == null || hardId.isEmpty) { + return '本机尚未绑定食堂或超市设备,可以刷新和测试 USB 打印机;完成业务终端绑定后才能读取打印机关系'; + } + final terminalName = setting?.serviceName?.trim(); + final displayName = + terminalName == null || terminalName.isEmpty ? '未命名设备' : terminalName; + final typeName = setting?.sysType.title ?? '业务'; + return '本机是$typeName终端“$displayName”(设备 ID:$hardId),但后台尚未给它绑定尔印打印机。' + '请先在设备管理中建立绑定;完成后回到本页刷新,即可由本机上报和管理 USB 打印机'; + } + + /// 枚举 USB 打印设备,并优先恢复原选择或匹配后台配置的设备。 + Future _refreshDevices() async { + if (_busy) return; + setState(() { + _refreshing = true; + _errorMessage = null; + _operationMessage = null; + }); + try { + var setting = await AppSettingTool.getSysSetting(); + if (setting.serviceId?.trim().isNotEmpty == true && + AppNetworkTool.isAutoNetwork()) { + try { + setting = await AppSettingTool.updatePrinterBinding( + await HardApi.getPrinterBinding(), + ); + } catch (error, stack) { + logger.w('|打印机信息|读取后台绑定失败,继续刷新本地 USB|error:$error|stack:$stack|'); + _operationMessage = '后台打印机绑定读取失败,当前显示上次同步配置'; + } + } + final devices = await _app.printManage.discoverPrinterDevices(); + final selection = _resolveSelection(devices, setting); + if (!mounted) return; + setState(() { + _setting = setting; + _devices = devices; + _selectedDeviceKey = selection; + _lastScannedAt = DateTime.now(); + }); + } catch (error, stack) { + logger.e('|打印机信息|刷新 USB 设备失败|error:$error|stack:$stack|'); + if (!mounted) return; + setState(() { + _errorMessage = '读取 USB 打印机失败,请检查连接和 USB 授权后重试'; + }); + } finally { + if (mounted) setState(() => _refreshing = false); + } + } + + /// 按“原选择、后台 VID/PID、唯一设备”的顺序确定刷新后的选中项。 + String? _resolveSelection( + List devices, + SysSetting setting, + ) { + final previous = _selectedDeviceKey; + if (previous != null && + devices.any((device) => device.selectionKey == previous)) { + return previous; + } + final configured = devices.firstWhereOrNull( + (device) => _matchesConfiguredDevice(device, setting), + ); + if (configured != null) return configured.selectionKey; + return devices.length == 1 ? devices.first.selectionKey : null; + } + + /// 判断 USB 设备的 VID/PID 是否与当前后台配置完全一致。 + bool _matchesConfiguredDevice( + PrinterUsbDeviceInfo device, [ + SysSetting? setting, + ]) { + final current = setting ?? _setting; + return current?.printerUsbVendorId == device.vendorId && + current?.printerUsbProductId == device.productId; + } + + /// 选择一台 USB 设备;操作进行中时忽略点击,避免显示项与实际操作目标不一致。 + void _selectDevice(PrinterUsbDeviceInfo device) { + if (_busy || _selectedDeviceKey == device.selectionKey) return; + setState(() { + _selectedDeviceKey = device.selectionKey; + _operationMessage = null; + }); + } + + /// 由当前业务终端把选中设备的型号、VID 和 PID 上报到绑定的 EY_PRINT 配置。 + Future _reportSelectedDevice() async { + final device = _selectedDevice; + final hardId = _setting?.serviceId?.trim(); + if (device == null) { + Toast.error(msg: '请先选择一台 USB 打印机'); + return; + } + if (!_canReport || hardId == null) { + Toast.error(msg: '请先在后台给当前业务终端绑定尔印打印机'); + return; + } + setState(() { + _reporting = true; + _errorMessage = null; + _operationMessage = null; + }); + try { + final saved = await HardApi.reportPrinterInfo( + hardId: hardId, + request: device.toReportRequest(), + ); + final setting = await AppSettingTool.getSysSetting(); + setting.printerModel = + saved['printerModel']?.toString() ?? device.displayName; + setting.printerUsbVendorId = + int.tryParse('${saved['usbVendorId'] ?? device.vendorId}'); + setting.printerUsbProductId = + int.tryParse('${saved['usbProductId'] ?? device.productId}'); + await AppSettingTool.setSysSetting(setting); + if (!mounted) return; + setState(() { + _setting = setting; + _lastReportedAt = DateTime.now(); + _operationMessage = '打印机型号和 USB 参数已上报,后台配置已同步更新'; + }); + Toast.success(msg: '打印机信息上报成功'); + } catch (error, stack) { + logger.e('|打印机信息|上报失败|hardId:$hardId|error:$error|stack:$stack|'); + if (!mounted) return; + setState(() { + _errorMessage = '打印机信息上报失败,请检查网络和当前业务终端的后台绑定关系'; + }); + Toast.error(msg: '打印机信息上报失败'); + } finally { + if (mounted) setState(() => _reporting = false); + } + } + + /// 向选中设备输出一张诊断小票,并准确描述 USB 写入层能够确认的结果边界。 + Future _printTestPage() async { + final device = _selectedDevice; + if (device == null) { + Toast.error(msg: '请先选择一台 USB 打印机'); + return; + } + setState(() { + _testing = true; + _errorMessage = null; + _operationMessage = null; + }); + try { + final accepted = await _app.printManage.printTestPage(device); + if (!mounted) return; + if (!accepted) { + setState(() { + _errorMessage = _app.printManage.lastError == null + ? '测试打印失败,请检查 USB 连接、授权和纸张状态' + : '测试打印失败:${_app.printManage.lastError}'; + }); + Toast.error(msg: '测试打印失败'); + return; + } + setState(() { + _operationMessage = '测试数据已被 USB 接口完整接受,请现场核对小票内容和切纸结果'; + }); + Toast.success(msg: '测试数据已提交到 USB 打印机'); + } catch (error, stack) { + logger.e('|打印机信息|测试打印异常|error:$error|stack:$stack|'); + if (!mounted) return; + setState(() => _errorMessage = '测试打印异常,请重新连接打印机后再试'); + Toast.error(msg: '测试打印异常'); + } finally { + if (mounted) setState(() => _testing = false); + } + } + + /// 构建紧凑型诊断页,状态、设备列表和操作区保持从上到下的稳定阅读顺序。 + @override + Widget build(BuildContext context) { + final setting = _setting; + final configured = setting?.printerUsbVendorId != null && + setting?.printerUsbProductId != null; + final hasConfiguredMatch = + _devices.any((device) => _matchesConfiguredDevice(device)); + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_busy) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: const LinearProgressIndicator(minHeight: 3), + ), + SizedBox(height: 8.h), + ], + SettingNotice( + text: _statusText(setting, configured, hasConfiguredMatch), + color: configured && !hasConfiguredMatch + ? XfjDesignTokens.warning + : XfjDesignTokens.muted, + background: configured && !hasConfiguredMatch + ? const Color(0xFFFFF9E8) + : XfjDesignTokens.primarySoft, + borderColor: configured && !hasConfiguredMatch + ? XfjDesignTokens.warning.withValues(alpha: 0.3) + : XfjDesignTokens.primary.withValues(alpha: 0.25), + icon: configured && !hasConfiguredMatch + ? FAssets.icons.circleAlert + : FAssets.icons.info, + dense: true, + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: '连接概览', + dense: true, + child: _buildSummary(setting, hasConfiguredMatch), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: '后台打印配置', + dense: true, + child: _buildConfiguredDetails(setting), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: 'USB 打印机', + dense: true, + child: _buildDeviceList(), + ), + if (_errorMessage != null || _operationMessage != null) ...[ + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingNotice( + text: _errorMessage ?? _operationMessage!, + color: _errorMessage == null + ? XfjDesignTokens.success + : XfjDesignTokens.danger, + background: _errorMessage == null + ? const Color(0xFFECFBF3) + : const Color(0xFFFFF3F2), + borderColor: (_errorMessage == null + ? XfjDesignTokens.success + : XfjDesignTokens.danger) + .withValues(alpha: 0.3), + icon: _errorMessage == null + ? FAssets.icons.circleCheck + : FAssets.icons.circleAlert, + dense: true, + ), + ], + if (!_canReport) ...[ + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingNotice( + text: _reportRestrictionText, + color: XfjDesignTokens.warning, + background: const Color(0xFFFFF9E8), + borderColor: XfjDesignTokens.warning.withValues(alpha: 0.3), + icon: FAssets.icons.circleAlert, + dense: true, + ), + ], + SizedBox(height: 12.h), + _buildActions(), + ], + ), + ); + } + + /// 根据后台配置和本次扫描结果生成不只依赖颜色表达的状态说明。 + String _statusText( + SysSetting? setting, + bool configured, + bool hasConfiguredMatch, + ) { + if (_refreshing) return '正在读取 Android USB Host 公开的打印设备信息'; + if (setting?.printerHardId == null) { + return '当前业务终端尚未绑定尔印打印机,本地测试可用,后台上报和自动任务暂不可用'; + } + if (_devices.isEmpty) return '未发现 USB 打印机,请连接设备后点击“刷新设备”'; + if (!configured) return '后台尚未配置 VID/PID,请选择实际打印机后上报'; + if (hasConfiguredMatch) return '已发现与后台 VID/PID 配置一致的 USB 打印机'; + return '当前 USB 打印机与后台配置不一致,请确认选中设备后重新上报'; + } + + /// 构建设备数量、后台配置和时间信息的紧凑三列概览。 + Widget _buildSummary(SysSetting? setting, bool hasConfiguredMatch) { + final configuredIdentity = setting?.printerUsbVendorId == null || + setting?.printerUsbProductId == null + ? '未配置' + : '${setting!.printerUsbVendorId} / ${setting.printerUsbProductId}'; + return LayoutBuilder( + builder: (context, constraints) { + final items = [ + _PrinterSummaryItem( + label: '发现设备', + value: '${_devices.length} 台', + state: _devices.isEmpty ? '等待连接' : '已完成扫描', + stateColor: _devices.isEmpty + ? XfjDesignTokens.warning + : XfjDesignTokens.success, + ), + _PrinterSummaryItem( + label: '后台 VID / PID', + value: configuredIdentity, + state: hasConfiguredMatch ? '配置匹配' : '尚未匹配', + stateColor: hasConfiguredMatch + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + ), + _PrinterSummaryItem( + label: '最近操作', + value: _formatTime(_lastScannedAt), + state: _lastReportedAt == null + ? '本页尚未上报' + : '已上报 ${_formatTime(_lastReportedAt)}', + stateColor: XfjDesignTokens.muted, + ), + ]; + if (constraints.maxWidth < 720) { + return Column(children: _withDividers(items)); + } + return IntrinsicHeight( + child: Row( + children: [ + for (var index = 0; index < items.length; index++) ...[ + Expanded(child: items[index]), + if (index != items.length - 1) + const VerticalDivider( + width: 1, + thickness: 1, + color: XfjDesignTokens.line, + ), + ], + ], + ), + ); + }, + ); + } + + /// 为窄屏概览插入水平分隔线,避免连续信息块失去层级。 + List _withDividers(List items) => [ + for (var index = 0; index < items.length; index++) ...[ + items[index], + if (index != items.length - 1) + const Divider(height: 1, color: XfjDesignTokens.line), + ], + ]; + + /// 构建服务端下发的型号、纸宽、图片指令与切刀配置,替代原状态页只读弹窗。 + Widget _buildConfiguredDetails(SysSetting? setting) => Column( + children: [ + SettingInfoRow( + icon: FAssets.icons.printer, + label: '绑定打印机', + value: setting?.printerHardId == null + ? '尚未绑定' + : '${setting?.printerHardName ?? "未命名打印机"}(${setting!.printerHardId})', + ), + SettingInfoRow( + icon: FAssets.icons.printer, + label: '打印机型号', + value: setting?.printerModel?.trim().isNotEmpty == true + ? setting!.printerModel + : '未填写', + ), + SettingInfoRow( + icon: FAssets.icons.hardDrive, + label: '纸张与图片指令', + value: + '${setting?.printerPaperWidth ?? 80}mm / ${setting?.printerImageMode ?? "GS_V0"}', + ), + SettingInfoRow( + icon: FAssets.icons.settings, + label: '自动切刀', + value: setting?.printerAutoCut == false ? '关闭' : '开启', + showDivider: false, + ), + ], + ); + + /// 构建 USB 设备空状态或可选择设备行列表。 + Widget _buildDeviceList() { + if (_refreshing && _devices.isEmpty) { + return const SizedBox( + height: 116, + child: Center(child: CircularProgressIndicator()), + ); + } + if (_devices.isEmpty) { + return const SizedBox( + height: 116, + child: SettingEmpty( + message: '没有发现标准 USB Printer Class 设备', + ), + ); + } + return Column( + children: [ + for (var index = 0; index < _devices.length; index++) + _PrinterDeviceRow( + device: _devices[index], + selected: _selectedDeviceKey == _devices[index].selectionKey, + configured: _matchesConfiguredDevice(_devices[index]), + enabled: !_busy, + showDivider: index != _devices.length - 1, + onTap: () => _selectDevice(_devices[index]), + ), + ], + ); + } + + /// 构建一个主要操作和两个次要操作;忙碌期间三者同时禁用。 + Widget _buildActions() => Wrap( + alignment: WrapAlignment.end, + spacing: 8.w, + runSpacing: 8.h, + children: [ + SettingActionButton( + label: _reporting ? '上报中...' : '上报当前设备', + icon: FAssets.icons.hardDrive, + enabled: !_busy && _selectedDevice != null && _canReport, + onTap: _reportSelectedDevice, + dense: true, + ), + SettingActionButton( + label: _testing ? '打印中...' : '测试打印', + icon: FAssets.icons.printer, + enabled: !_busy && _selectedDevice != null, + onTap: _printTestPage, + dense: true, + ), + SettingActionButton( + label: _refreshing ? '刷新中...' : '刷新设备', + icon: FAssets.icons.refreshCw, + primary: true, + enabled: !_busy, + onTap: _refreshDevices, + dense: true, + ), + ], + ); + + /// 格式化页面级扫描和上报时间;空值使用稳定占位文案。 + String _formatTime(DateTime? time) => time == null + ? '尚未执行' + : DateUtil.formatDate(time, format: DateFormats.full); +} + +/// 连接概览中的单个信息块,数值采用等宽数字以避免刷新时布局跳动。 +class _PrinterSummaryItem extends StatelessWidget { + /// 创建包含标签、主要值和文字状态的概览项。 + const _PrinterSummaryItem({ + required this.label, + required this.value, + required this.state, + required this.stateColor, + }); + + /// 信息项标签。 + final String label; + + /// 信息项主要数值或标识。 + final String value; + + /// 对主要值的状态说明,确保语义不只依赖颜色。 + final String state; + + /// 状态说明的语义色。 + final Color stateColor; + + /// 构建固定内边距的信息块。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Padding( + padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(height: 5.h), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + SizedBox(height: 4.h), + Text( + state, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith(color: stateColor), + ), + ], + ), + ); + } +} + +/// 一台 USB 打印机的可选择信息行,完整展示型号、十进制/十六进制标识和设备编号。 +class _PrinterDeviceRow extends StatelessWidget { + /// 创建一行稳定高度的打印机设备选择项。 + const _PrinterDeviceRow({ + required this.device, + required this.selected, + required this.configured, + required this.enabled, + required this.showDivider, + required this.onTap, + }); + + /// Android 本次发现的 USB 打印机快照。 + final PrinterUsbDeviceInfo device; + + /// 本行是否是当前操作目标。 + final bool selected; + + /// 本行 VID/PID 是否与后台配置一致。 + final bool configured; + + /// 本行是否允许切换选中状态。 + final bool enabled; + + /// 是否显示与下一台设备之间的分隔线。 + final bool showDivider; + + /// 点击后选择当前设备的回调。 + final VoidCallback onTap; + + /// 构建保留至少 80dp 点击高度且支持长型号换行的设备行。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Semantics( + selected: selected, + button: true, + label: + '${device.displayName},VID ${device.vendorId},PID ${device.productId}', + child: Opacity( + opacity: enabled ? 1 : 0.55, + child: InkWell( + onTap: enabled ? onTap : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + constraints: BoxConstraints(minHeight: 88.h), + padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 12.h), + decoration: BoxDecoration( + color: + selected ? XfjDesignTokens.primarySoft : Colors.transparent, + border: Border( + bottom: showDivider + ? const BorderSide(color: XfjDesignTokens.line) + : BorderSide.none, + left: BorderSide( + color: + selected ? XfjDesignTokens.primary : Colors.transparent, + width: 3, + ), + ), + ), + child: LayoutBuilder( + builder: (context, constraints) => constraints.maxWidth < 680 + ? _buildCompactContent(theme) + : _buildWideContent(theme), + ), + ), + ), + ), + ); + } + + /// 构建窄内容区的上下两行布局,防止设备名、USB 标识和状态相互遮挡。 + Widget _buildCompactContent(FThemeData theme) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildDeviceIcon(), + SizedBox(width: 12.w), + Expanded(child: _buildDeviceName(theme)), + SizedBox(width: 8.w), + _buildSelectionIcon(), + ], + ), + SizedBox(height: 10.h), + Wrap( + spacing: 16.w, + runSpacing: 4.h, + children: [ + Text( + 'VID ${device.vendorId} / PID ${device.productId}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + device.hexadecimalIdentity, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + _buildStateText(theme), + ], + ), + ], + ); + + /// 构建横屏终端的单行高密度布局,便于连续对比多台 USB 设备。 + Widget _buildWideContent(FThemeData theme) => Row( + children: [ + _buildDeviceIcon(), + SizedBox(width: 14.w), + Expanded(flex: 4, child: _buildDeviceName(theme)), + SizedBox(width: 16.w), + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'VID ${device.vendorId} / PID ${device.productId}', + textAlign: TextAlign.right, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + SizedBox(height: 4.h), + Text( + device.hexadecimalIdentity, + textAlign: TextAlign.right, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + SizedBox(width: 14.w), + SizedBox(width: 132.w, child: _buildStateText(theme)), + SizedBox(width: 8.w), + _buildSelectionIcon(), + ], + ); + + /// 构建打印机图标,选中态只改变颜色而不改变外部尺寸。 + Widget _buildDeviceIcon() => Container( + width: 42.w, + height: 42.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? XfjDesignTokens.primary.withValues(alpha: 0.12) + : XfjDesignTokens.control, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: FIcon( + FAssets.icons.printer, + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, + size: 21.sp, + ), + ); + + /// 构建设备型号和 Android 临时编号,长型号允许在两行内省略。 + Widget _buildDeviceName(FThemeData theme) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.displayName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 4.h), + Text( + 'Android 设备编号:${device.deviceId ?? "--"}', + style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), + ), + ], + ); + + /// 构建匹配或选择状态文字,并在宽布局中保持右对齐。 + Widget _buildStateText(FThemeData theme) => Text( + configured + ? '与后台配置一致' + : selected + ? '当前已选择' + : '点击选择', + textAlign: TextAlign.right, + style: theme.typography.xs.copyWith( + color: configured + ? XfjDesignTokens.success + : selected + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ); + + /// 构建固定尺寸的选择状态图标,避免选择时设备行宽度变化。 + Widget _buildSelectionIcon() => FIcon( + selected ? FAssets.icons.circleCheck : FAssets.icons.circle, + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, + size: 20.sp, + ); +} diff --git a/test/common/terminal_device_status_test.dart b/test/common/terminal_device_status_test.dart index 2f6a0a6..7f3bf63 100644 --- a/test/common/terminal_device_status_test.dart +++ b/test/common/terminal_device_status_test.dart @@ -221,28 +221,4 @@ void main() { await expectLater(repeated, throwsA(isA())); expect(AppDeviceStatusTool.progress.value.running, isFalse); }); - - test('打印角色只保留通信、打印和运维 Cron 任务', () { - const expected = { - ScheduledTaskType.mqReconnect, - ScheduledTaskType.printCheck, - ScheduledTaskType.deviceStatus, - ScheduledTaskType.autoUpdate, - ScheduledTaskType.terminalLogUpload, - ScheduledTaskType.expiredLogCleanup, - }; - - for (final type in ScheduledTaskType.values) { - expect( - SyncCheckTool.supportsTask(TerminalRole.printer, type), - expected.contains(type), - reason: '打印角色任务过滤错误: $type', - ); - expect( - SyncCheckTool.supportsTask(TerminalRole.cashier, type), - isTrue, - reason: '收银角色应保留完整任务: $type', - ); - } - }); }