diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index e017711..090af7d 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -128,6 +128,34 @@ abstract class HardApi { return ServiceHardCreateHardQrCodeScanOrder.fromJson(res.data); } + /// 创建与 Hardware 业务订单绑定的支付宝、微信聚合订单码。 + static Future createAggregateOrderCodePay( + dynamic formData) async { + final res = await HttpManage().dio.post( + "/hard/hard-api/ad/create-aggregate-order-code-pay", + data: formData, + ); + if (res.data == null) return null; + return ServiceHardAggregateOrderCodePay.fromJson(res.data); + } + + /// 查询聚合支付状态;成功响应表示 Hardware 业务订单已同步完成结算。 + static Future + getAggregateOrderCodePayStatus({ + required String orderId, + required String aggregateNo, + }) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/aggregate-order-code-pay/status", + queryParameters: { + "orderId": orderId, + "aggregateNo": aggregateNo, + }, + ); + if (res.data == null) return null; + return ServiceHardAggregateOrderCodePayStatus.fromJson(res.data); + } + /// 根据订单id,获取订单详情 static Future getOrder(dynamic formData) async { final res = await HttpManage().dio.get( diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart index 2d09a57..58bcf1e 100644 --- a/lib/common/models/api/hard_api_models.dart +++ b/lib/common/models/api/hard_api_models.dart @@ -593,6 +593,102 @@ class ServiceHardCreateHardQrCodeScanOrder { _$ServiceHardCreateHardQrCodeScanOrderToJson(this); } +/// 收银终端支付宝、微信聚合订单码创建结果。 +/// +/// 该模型只接收 `AGGREGATE_ORDER_PAY` HTTPS 收银地址,不承载自有钱包码或平台原生码。 +@JsonSerializable() +class ServiceHardAggregateOrderCodePay { + /// Hardware 业务订单 ID,状态查询必须与 [aggregateNo] 一起提交。 + final String orderId; + + /// 当前终端绑定的收款商户 ID。 + final String mchId; + + /// Thirdparty 服务生成并持久化绑定到 Hardware 订单的聚合会话编号。 + final String aggregateNo; + + /// 支付宝和微信均可扫码访问的 HTTPS 聚合收银地址。 + final String qrCode; + + /// 二维码协议类型,服务端固定返回 `AGGREGATE_ORDER_PAY`。 + final String qrCodeType; + + /// 服务端确定的聚合二维码失效时间。 + final DateTime expireTime; + + /// 创建不可变的聚合订单码结果。 + const ServiceHardAggregateOrderCodePay({ + required this.orderId, + required this.mchId, + required this.aggregateNo, + required this.qrCode, + required this.qrCodeType, + required this.expireTime, + }); + + /// 将 Hardware 接口 JSON 转换为聚合订单码结果。 + factory ServiceHardAggregateOrderCodePay.fromJson( + Map json, + ) => + _$ServiceHardAggregateOrderCodePayFromJson(json); + + /// 将聚合订单码结果转换为 JSON,供测试和跨 Engine 消息核对使用。 + Map toJson() => + _$ServiceHardAggregateOrderCodePayToJson(this); +} + +/// Hardware 聚合订单码状态查询结果。 +/// +/// [paySuccess] 为 `true` 时,服务端已在订单锁内完成 Hardware 订单和库存更新。 +@JsonSerializable() +class ServiceHardAggregateOrderCodePayStatus { + /// Hardware 业务订单 ID。 + final String orderId; + + /// 与 Hardware 订单持久化绑定的聚合会话编号。 + final String aggregateNo; + + /// 聚合会话状态编码:10 等待、15 创建中、20 支付中、30 成功、40 关闭、50 过期、60 创建失败。 + final String status; + + /// 首个有效扫码选定的平台,尚未扫码时为空。 + final String? selectedPlatform; + + /// 支付宝或微信实际子订单号,尚未创建时为空。 + final String? payNo; + + /// 是否已经由支付平台确认成功并同步 Hardware 业务订单。 + final bool paySuccess; + + /// 聚合二维码服务端失效时间;已由本地成功态短路返回时允许为空。 + final DateTime? expireTime; + + /// Hardware 业务订单当前状态编码。 + final String orderStatus; + + /// 创建不可变的聚合支付状态。 + const ServiceHardAggregateOrderCodePayStatus({ + required this.orderId, + required this.aggregateNo, + required this.status, + this.selectedPlatform, + this.payNo, + required this.paySuccess, + this.expireTime, + required this.orderStatus, + }); + + /// 将 Hardware 接口 JSON 转换为聚合支付状态。 + factory ServiceHardAggregateOrderCodePayStatus.fromJson( + Map json, + ) => + _$ServiceHardAggregateOrderCodePayStatusFromJson(json); + + /// 将聚合支付状态转换为 JSON,供日志脱敏筛选和测试使用。 + Map toJson() => + _$ServiceHardAggregateOrderCodePayStatusToJson(this); +} + /// 终端本人预约菜单查询参数。 @JsonSerializable(includeIfNull: false) class ReservationMenuQueryRequest { diff --git a/lib/common/models/api/hard_api_models.g.dart b/lib/common/models/api/hard_api_models.g.dart index cb757a0..e86ff7e 100644 --- a/lib/common/models/api/hard_api_models.g.dart +++ b/lib/common/models/api/hard_api_models.g.dart @@ -417,6 +417,57 @@ Map _$ServiceHardCreateHardQrCodeScanOrderToJson( 'qrCodeData': instance.qrCodeData, }; +ServiceHardAggregateOrderCodePay _$ServiceHardAggregateOrderCodePayFromJson( + Map json) => + ServiceHardAggregateOrderCodePay( + orderId: json['orderId'] as String, + mchId: json['mchId'] as String, + aggregateNo: json['aggregateNo'] as String, + qrCode: json['qrCode'] as String, + qrCodeType: json['qrCodeType'] as String, + expireTime: DateTime.parse(json['expireTime'] as String), + ); + +Map _$ServiceHardAggregateOrderCodePayToJson( + ServiceHardAggregateOrderCodePay instance) => + { + 'orderId': instance.orderId, + 'mchId': instance.mchId, + 'aggregateNo': instance.aggregateNo, + 'qrCode': instance.qrCode, + 'qrCodeType': instance.qrCodeType, + 'expireTime': instance.expireTime.toIso8601String(), + }; + +ServiceHardAggregateOrderCodePayStatus + _$ServiceHardAggregateOrderCodePayStatusFromJson( + Map json) => + ServiceHardAggregateOrderCodePayStatus( + orderId: json['orderId'] as String, + aggregateNo: json['aggregateNo'] as String, + status: json['status'] as String, + selectedPlatform: json['selectedPlatform'] as String?, + payNo: json['payNo'] as String?, + paySuccess: json['paySuccess'] as bool, + expireTime: json['expireTime'] == null + ? null + : DateTime.parse(json['expireTime'] as String), + orderStatus: json['orderStatus'] as String, + ); + +Map _$ServiceHardAggregateOrderCodePayStatusToJson( + ServiceHardAggregateOrderCodePayStatus instance) => + { + 'orderId': instance.orderId, + 'aggregateNo': instance.aggregateNo, + 'status': instance.status, + 'selectedPlatform': instance.selectedPlatform, + 'payNo': instance.payNo, + 'paySuccess': instance.paySuccess, + 'expireTime': instance.expireTime?.toIso8601String(), + 'orderStatus': instance.orderStatus, + }; + ReservationMenuQueryRequest _$ReservationMenuQueryRequestFromJson( Map json) => ReservationMenuQueryRequest( diff --git a/lib/common/models/database/database_enum_models.dart b/lib/common/models/database/database_enum_models.dart index a10f161..4f9e1ef 100644 --- a/lib/common/models/database/database_enum_models.dart +++ b/lib/common/models/database/database_enum_models.dart @@ -36,31 +36,68 @@ enum OrderType implements BaseEnum { /// 付款方式 enum OrderPayType implements BaseEnum { + /// 默认钱包余额支付。 WALLET_DEFAULT("1", "普通钱包支付"), + + /// 用户向终端出示自有钱包二维码支付。 WALLET_QR_CODE("2", "钱包二维码支付"), + + /// 旧版终端展示自有钱包订单码,由移动端扫码支付。 WALLET_QR_CODE_SCAN("22", "钱包扫一扫支付"), + + /// 终端展示支付宝、微信共用的 HTTPS 聚合收银二维码。 + AGGREGATE_ORDER_PAY("23", "支付宝微信聚合码支付"), + + /// 实体饭卡钱包支付。 WALLET_CARD("3", "钱包饭卡支付"), + + /// 人脸识别钱包支付。 FACE("4", "人脸识别支付"), + + /// 跨单位业务二维码支付。 STRIDE_DEPT_QR_CODE("5", "跨单位二维码支付"), + + /// 外来人员票券二维码支付。 FOREIGN_TICKET_QR_CODE("6", "外来人员二维码支付"), + + /// 线上预约订单二维码核销支付。 RESERVE_QR_CODE("7", "线上订餐二维码支付"), + + /// 终端线下现金支付。 CASH("8", "现金支付"), + + /// 微信用户付款码支付。 WECHANT_SELF_QR_CODE_PAY("10", "微信付款码支付"), + + /// 支付宝用户付款码支付。 ALI_SELF_QR_CODE_PAY("11", "支付宝付款码支付"), + + /// 中医院渠道支付。 ZYY("12", "中医院"), + + /// 人民医院渠道支付。 RMYY("13", "人民医院"), + + /// 超市消费卡渠道支付。 CSXFK("13", "超市消费卡"), + + /// 活动订单核销。 ACT_CANCEL("50", "活动核销"), ; @override + + /// 数据库和服务端接口使用的稳定支付类型编码。 final String code; @override + + /// 支付方式的中文展示名称。 final String title; + /// 创建一个使用稳定协议编码的支付方式枚举值。 const OrderPayType(this.code, this.title); - // 是否是外部人员支付 + /// 判断是否为跨单位或外来人员二维码支付。 static bool isStrideOrForeign(OrderPayType payType) { return [ OrderPayType.STRIDE_DEPT_QR_CODE, @@ -68,7 +105,7 @@ enum OrderPayType implements BaseEnum { ].contains(payType); } - // 是否是自付款(微信、支付宝) + /// 判断是否为顾客直接使用微信或支付宝付款码支付。 static bool isExternalPay(OrderPayType payType) { return [ OrderPayType.WECHANT_SELF_QR_CODE_PAY, diff --git a/lib/common/models/plugs/sub_screen_enum_models.dart b/lib/common/models/plugs/sub_screen_enum_models.dart index d359789..547f3cc 100644 --- a/lib/common/models/plugs/sub_screen_enum_models.dart +++ b/lib/common/models/plugs/sub_screen_enum_models.dart @@ -2,25 +2,64 @@ import 'package:pj_supermarket_cloud/common/index.dart'; enum SubScreenAction implements BaseEnum { + /// 同步当前购物车和金额快照。 updateCard("updateCard", "订单更新"), + + /// 跳转到结算副屏页面。 jumpSubPage("jumpSubPage", "跳转到订单选择页面"), + + /// 返回副屏上一页面。 jumpSubBack("jumpSubBack", "跳转到上一个页面"), + + /// 展示旧版自有钱包订单二维码。 qrCodeScan("qrCodeScan", "二维码扫一扫"), + + /// 旧版自有钱包订单二维码支付成功。 qrCodeScanSuccess("qrCodeScanSuccess", "二维码支付成功"), + + /// 旧版自有钱包订单二维码支付取消。 qrCodeScanCancel("qrCodeScanCancel", "二维码支付取消"), + + /// 展示支付宝、微信共用的聚合订单二维码。 + aggregateOrderPay("aggregateOrderPay", "聚合订单码支付"), + + /// 聚合订单码支付成功。 + aggregateOrderPaySuccess("aggregateOrderPaySuccess", "聚合订单码支付成功"), + + /// 聚合订单码支付取消、关闭或过期。 + aggregateOrderPayCancel("aggregateOrderPayCancel", "聚合订单码支付取消"), + + /// 更新预约查询副屏状态。 reservationQueryUpdate("reservationQueryUpdate", "预约查询状态更新"), + + /// 清空预约查询副屏状态。 reservationQueryClear("reservationQueryClear", "预约查询清屏"), + + /// 开始人脸识别副屏会话。 faceRecognitionStart("faceRecognitionStart", "人脸识别开始"), + + /// 展示人脸检测中状态。 faceRecognitionDetecting("faceRecognitionDetecting", "人脸识别中"), + + /// 展示人脸识别成功状态。 faceRecognitionSuccess("faceRecognitionSuccess", "人脸识别成功"), + + /// 展示人脸识别失败状态。 faceRecognitionFailed("faceRecognitionFailed", "人脸识别失败"), + + /// 取消人脸识别副屏会话。 faceRecognitionCancel("faceRecognitionCancel", "人脸识别取消"), ; @override + + /// 主副屏协议使用的稳定动作编码。 final String code; @override + + /// 动作的中文业务说明。 final String title; + /// 创建一个主副屏协议动作枚举值。 const SubScreenAction(this.code, this.title); } diff --git a/lib/common/models/plugs/sub_screen_models.dart b/lib/common/models/plugs/sub_screen_models.dart index 2990064..e50fd7a 100644 --- a/lib/common/models/plugs/sub_screen_models.dart +++ b/lib/common/models/plugs/sub_screen_models.dart @@ -39,21 +39,39 @@ class SubScreenProductEvent { @JsonSerializable() class SubScreenQrCodeScanEvent { + /// 支付消息业务动作,聚合码固定为 `aggregateOrderPay`。 String action; + /// Hardware 业务订单 ID,仅用于副屏消息去重和诊断。 String orderId; + /// 副屏需要渲染的二维码内容,不得写入业务日志。 String qrCodeData; + /// 聚合会话编号;旧自有钱包二维码消息允许为空。 + String? aggregateNo; + + /// 二维码协议类型;聚合码固定为 `AGGREGATE_ORDER_PAY`。 + String? qrCodeType; + + /// 服务端二维码过期时间,用于副屏按真实有效期倒计时。 + DateTime? expireTime; + + /// 创建主屏向副屏发送的二维码支付消息。 SubScreenQrCodeScanEvent({ required this.action, required this.orderId, required this.qrCodeData, + this.aggregateNo, + this.qrCodeType, + this.expireTime, }); + /// 从跨 Engine JSON 消息恢复二维码支付参数。 factory SubScreenQrCodeScanEvent.fromJson(Map json) => _$SubScreenQrCodeScanEventFromJson(json); + /// 转换为插件可跨 Engine 发送的 JSON 数据。 Map toJson() => _$SubScreenQrCodeScanEventToJson(this); } diff --git a/lib/common/models/plugs/sub_screen_models.g.dart b/lib/common/models/plugs/sub_screen_models.g.dart index 14fff9e..15bca14 100644 --- a/lib/common/models/plugs/sub_screen_models.g.dart +++ b/lib/common/models/plugs/sub_screen_models.g.dart @@ -39,6 +39,11 @@ SubScreenQrCodeScanEvent _$SubScreenQrCodeScanEventFromJson( action: json['action'] as String, orderId: json['orderId'] as String, qrCodeData: json['qrCodeData'] as String, + aggregateNo: json['aggregateNo'] as String?, + qrCodeType: json['qrCodeType'] as String?, + expireTime: json['expireTime'] == null + ? null + : DateTime.parse(json['expireTime'] as String), ); Map _$SubScreenQrCodeScanEventToJson( @@ -47,6 +52,9 @@ Map _$SubScreenQrCodeScanEventToJson( 'action': instance.action, 'orderId': instance.orderId, 'qrCodeData': instance.qrCodeData, + 'aggregateNo': instance.aggregateNo, + 'qrCodeType': instance.qrCodeType, + 'expireTime': instance.expireTime?.toIso8601String(), }; SubScreenReservationQueryEvent _$SubScreenReservationQueryEventFromJson( diff --git a/lib/common/services/database/entity/order_entity.g.dart b/lib/common/services/database/entity/order_entity.g.dart index 0b7af90..1bc26c8 100644 --- a/lib/common/services/database/entity/order_entity.g.dart +++ b/lib/common/services/database/entity/order_entity.g.dart @@ -82,6 +82,7 @@ const _$OrderPayTypeEnumMap = { OrderPayType.WALLET_DEFAULT: 'WALLET_DEFAULT', OrderPayType.WALLET_QR_CODE: 'WALLET_QR_CODE', OrderPayType.WALLET_QR_CODE_SCAN: 'WALLET_QR_CODE_SCAN', + OrderPayType.AGGREGATE_ORDER_PAY: 'AGGREGATE_ORDER_PAY', OrderPayType.WALLET_CARD: 'WALLET_CARD', OrderPayType.FACE: 'FACE', OrderPayType.STRIDE_DEPT_QR_CODE: 'STRIDE_DEPT_QR_CODE', diff --git a/lib/common/widgets/app/app_radio_hour_type_dialog.dart b/lib/common/widgets/app/app_radio_hour_type_dialog.dart index 4bbb116..10f0d4b 100644 --- a/lib/common/widgets/app/app_radio_hour_type_dialog.dart +++ b/lib/common/widgets/app/app_radio_hour_type_dialog.dart @@ -2,31 +2,59 @@ import 'package:flutter/material.dart'; import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 构建全终端共用的餐段选择组件,选中值由调用方持有的 Forui 单选 Controller 管理。 +/// +/// [title] 和 [enabled] 允许业务页面调整触发器文案及交互状态;[key] 用于定位具体业务入口。 +/// [compact] 仅降低触发器的视觉权重,不缩小餐段选项的触控区域;查询菜单使用该模式, +/// 收银、预订和设置页面继续使用原有的图标与强调色样式。 Widget appRadioHourTypeDialog( BuildContext context, - FRadioSelectGroupController controller, -) { + FRadioSelectGroupController controller, { + Key? key, + String title = "用餐时间", + bool enabled = true, + bool compact = false, +}) { final theme = FTheme.of(context); final list = ProductHourType.values.where((v) => v.code != "-1"); + final compactTriggerStyle = _withMinimumTouchPadding( + theme.selectMenuTileStyle.tileStyle, + ); + final compactOptionStyle = _withMinimumTouchPadding( + theme.selectMenuTileStyle.menuStyle.tileGroupStyle.tileStyle, + ); // Forui 0.9.1 的 Tile 会根据字体和内边距计算高度,不能套固定高度约束。 // 否则字号缩放或路由切换时 RenderTile 的实际高度与父约束不一致,会连续触发布局异常。 return FSelectMenuTile( + key: key, + style: compact + ? theme.selectMenuTileStyle.copyWith(tileStyle: compactTriggerStyle) + : null, title: Text( - "用餐时间", - style: theme.typography.base, + title, + style: (compact ? theme.typography.sm : theme.typography.base).copyWith( + color: compact ? XfjDesignTokens.muted : null, + fontWeight: compact ? FontWeight.w600 : null, + ), ), groupController: controller, + enabled: enabled, validator: (value) => value == null ? '请选择' : null, autoHide: true, - prefixIcon: FIcon( - FAssets.icons.sun, - size: theme.typography.xl.fontSize, - ), + semanticLabel: '$title选择', + prefixIcon: compact + ? null + : FIcon( + FAssets.icons.sun, + size: theme.typography.xl.fontSize, + ), menu: list.map((v) { return FSelectTile( + key: ValueKey('meal-slot-option-${v.code}'), + style: compact ? compactOptionStyle : null, title: Text( v.title, - style: theme.typography.xl, + style: compact ? theme.typography.base : theme.typography.xl, ), value: v.code, ); @@ -40,10 +68,29 @@ Widget appRadioHourTypeDialog( return Text( hourType.title, style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, + color: !enabled + ? XfjDesignTokens.muted + : compact + ? XfjDesignTokens.ink + : theme.colorScheme.primary, + fontWeight: compact ? FontWeight.w700 : null, ), ); }, ), ); } + +/// 在不固定 Tile 高度的前提下补足紧凑模式内边距,使其触控区域至少达到 48dp。 +FTileStyle _withMinimumTouchPadding(FTileStyle style) { + final contentStyle = style.contentStyle; + final padding = contentStyle.padding; + return style.copyWith( + contentStyle: contentStyle.copyWith( + padding: padding.copyWith( + top: padding.top + 2, + bottom: padding.bottom + 2, + ), + ), + ); +} diff --git a/lib/common/widgets/app/terminal_device_status.dart b/lib/common/widgets/app/terminal_device_status.dart index 2d8c26e..48a3c31 100644 --- a/lib/common/widgets/app/terminal_device_status.dart +++ b/lib/common/widgets/app/terminal_device_status.dart @@ -124,6 +124,16 @@ class _TerminalDeviceStateVisual { } } +/// 将全局可调字号限制在运维弹框的清晰可读区间,兼顾实体终端阅读距离与小尺寸降级布局。 +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` 中,方法内读取的设备存在性、瞬时工作态与摄像头会话状态 @@ -147,10 +157,10 @@ List<_TerminalDeviceStatusItem> _readTerminalDeviceStatusItems() { label: '打印机', available: status.printerAvailable, working: AppDeviceStatusTool.printerWorking.value, - deviceDescription: '负责订单小票、发票和补打任务,通过 USB 与收银终端连接。', - missingDescription: '未发现可用打印机,请检查 USB 连接、设备供电与系统识别状态。', + deviceDescription: '小票、发票和补打任务,通过 USB 连接。', + missingDescription: '未检测到打印机,请检查 USB、设备供电和系统识别。', idleDescription: '打印机已连接,当前没有待处理的打印任务。', - workingDescription: '正在下载并发送小票数据,请勿断开打印机。', + workingDescription: '正在发送小票数据,请勿断开打印机。', updatedAt: status.updatedAt, ), _TerminalDeviceStatusItem( @@ -158,8 +168,8 @@ List<_TerminalDeviceStatusItem> _readTerminalDeviceStatusItems() { label: '电子秤', available: status.steelyardAvailable, working: AppDeviceStatusTool.steelyardWorking.value, - deviceDescription: '负责称重商品的重量采集和价格计算,通过串口持续上报重量。', - missingDescription: '未收到有效重量数据,请检查电子秤供电、串口连接和秤体显示。', + deviceDescription: '采集商品重量并计价,通过串口上报重量。', + missingDescription: '未收到重量数据,请检查供电、串口和秤体显示。', idleDescription: '电子秤已连接,当前没有商品处于称重流程。', workingDescription: '正在接收并计算商品重量,请保持秤台稳定。', updatedAt: status.updatedAt, @@ -169,8 +179,8 @@ List<_TerminalDeviceStatusItem> _readTerminalDeviceStatusItems() { label: '读卡器', available: status.cardReaderAvailable, working: AppDeviceStatusTool.cardReaderWorking.value, - deviceDescription: '负责饭卡 UID、业务卡号及 M1 数据块的串口读写。', - missingDescription: '未发现可用读卡器,请检查串口连接、设备供电与端口配置。', + deviceDescription: '饭卡 UID、业务卡号及 M1 数据的串口读写。', + missingDescription: '未检测到读卡器,请检查供电、串口和端口配置。', idleDescription: '读卡器已连接,当前没有读卡、写卡或诊断任务。', workingDescription: '正在等待或处理饭卡数据,请保持卡片贴近感应区。', updatedAt: status.updatedAt, @@ -180,8 +190,8 @@ List<_TerminalDeviceStatusItem> _readTerminalDeviceStatusItems() { label: '副屏', available: status.subScreenAvailable, working: AppDeviceStatusTool.subScreenWorking.value, - deviceDescription: '用于向顾客展示商品、应付金额、支付引导和业务结果。', - missingDescription: '未发现可用副屏,请检查显示连接、Overlay 权限和系统多屏状态。', + deviceDescription: '向顾客展示商品、应付金额、支付引导和业务结果。', + missingDescription: '未检测到副屏,请检查显示连接、悬浮窗权限和多屏设置。', idleDescription: '副屏已连接,但顾客展示窗口当前未运行。', workingDescription: '顾客展示窗口正在运行,并随主屏业务同步内容。', updatedAt: status.updatedAt, @@ -192,8 +202,8 @@ List<_TerminalDeviceStatusItem> _readTerminalDeviceStatusItems() { label: '摄像头', available: status.cameraAvailable, working: cameraVisual.working, - deviceDescription: '用于人脸支付与预约查询的实时取景和虹软人脸识别。', - missingDescription: 'Android 系统未枚举到摄像头,请检查相机硬件和系统设备状态。', + deviceDescription: '人脸支付与预约查询的实时取景和人脸识别。', + missingDescription: '系统未检测到摄像头,请检查相机硬件和系统识别。', idleDescription: cameraVisual.description, workingDescription: cameraVisual.description, updatedAt: status.updatedAt, @@ -347,8 +357,8 @@ Future _showTerminalDeviceStatusDialog(BuildContext context) async { final theme = FTheme.of(context); final baseStyle = theme.dialogStyle; final viewportSize = MediaQuery.sizeOf(context); - // 终端虽然分辨率较高,但实体屏幕较小,因此仅保留少量模态边缘, - // 将主要空间交给五个同时可见的设备状态面板和大触控关闭按钮。 + // 终端虽然分辨率较高,但实体屏幕较小,因此仅保留少量模态边缘; + // 大弹框用于保证信息可读与后续诊断扩展,设备表自身仍按内容自然高度排列。 final dialogWidth = viewportSize.width * 0.92; final dialogHeight = viewportSize.height * 0.90; final dialogStyle = baseStyle.copyWith( @@ -378,12 +388,15 @@ Future _showTerminalDeviceStatusDialog(BuildContext context) async { const Divider(height: 1, color: XfjDesignTokens.line), Obx(() { final items = _readTerminalDeviceStatusItems(); - return [ - _deviceStateSummary(theme, items), - _deviceStatusList(theme, items).expanded(), - ].toColumnSpace( - crossAxisAlignment: CrossAxisAlignment.stretch, - space: 14.h, + return SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: [ + _deviceStateSummary(theme, items), + _deviceStatusList(theme, items), + ].toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.stretch, + space: 12.h, + ), ); }).expanded(), const Divider(height: 1, color: XfjDesignTokens.line), @@ -399,32 +412,47 @@ 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 [ FIcon( FAssets.icons.monitorCog, - size: 28.w, + size: headerIconSize, color: XfjDesignTokens.primary, - ).center().tightSize(56.w).decorated( + ).center().tightSize(headerIconBoxSize).decorated( color: XfjDesignTokens.primarySoft, borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), ), [ Text( '终端设备状态', - style: theme.typography.xl3.copyWith( + style: _compactDeviceDialogTextStyle( + theme.typography.xl2, + minSize: 18, + maxSize: 20, + ).copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w800, + height: 1.15, ), ), Text( '打印机、电子秤、读卡器、副屏与摄像头', - style: theme.typography.base.copyWith(color: XfjDesignTokens.muted), + style: _compactDeviceDialogTextStyle( + theme.typography.sm, + minSize: 14, + maxSize: 15, + ).copyWith( + color: XfjDesignTokens.muted, + height: 1.2, + ), ), ] .toColumnSpace( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - space: 4.h, + space: 2.h, ) .expanded(), Material( @@ -435,12 +463,12 @@ Widget _deviceStatusDialogHeader( borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), child: FIcon( FAssets.icons.x, - size: 24.w, + size: (18.w).clamp(15.0, 17.0).toDouble(), color: XfjDesignTokens.muted, - ).center().tightSize(56.w), + ).center().tightSize(closeTouchSize), ), ), - ].toRowSpace(space: 16.w).padding(horizontal: 28.w, vertical: 20.h); + ].toRowSpace(space: 12.w).padding(horizontal: 22.w, vertical: 12.h); } /// 汇总五类设备的红、黄、绿数量,让用户无需逐项查找即可先判断整体运行情况。 @@ -448,6 +476,7 @@ 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), @@ -458,14 +487,26 @@ Widget _deviceStateSummary( [ Text( '设备总览', - style: theme.typography.lg.copyWith( + style: _compactDeviceDialogTextStyle( + theme.typography.base, + minSize: 15, + maxSize: 16, + ).copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w800, + height: 1.15, ), ), Text( - '共 ${items.length} 个设备', - style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + '共 ${items.length} 个设备 · 检测 ${_formatDeviceCheckedAt(checkedAt)}', + style: _compactDeviceDialogTextStyle( + theme.typography.sm, + minSize: 13, + maxSize: 14, + ).copyWith( + color: XfjDesignTokens.muted, + height: 1.15, + ), ), ] .toColumnSpace( @@ -474,201 +515,234 @@ Widget _deviceStateSummary( space: 2.h, ) .expanded(), - ...summaries.map( - (summary) => [ - FIcon( - summary.visual.indicatorIcon, - size: 22.w, - color: summary.visual.color, - ), - Text( - summary.visual.label, - style: theme.typography.base.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w700, + 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: [ + FIcon( + summaries[index].visual.indicatorIcon, + size: (18.w).clamp(16.0, 18.0).toDouble(), + color: summaries[index].visual.color, ), - ), - Text( - '${summary.count}', - style: theme.typography.xl.copyWith( - color: summary.visual.color, - fontWeight: FontWeight.w800, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ] - .toRowSpace(space: 8.w, mainAxisSize: MainAxisSize.min) - .padding(horizontal: 16.w, vertical: 10.h) - .decorated( - color: summary.visual.color.withValues(alpha: 0.08), - border: Border.all( - color: summary.visual.color.withValues(alpha: 0.24), - ), - borderRadius: BorderRadius.circular( - XfjDesignTokens.controlRadius, + 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: 12.w) - .padding(horizontal: 28.w, vertical: 14.h) + .toRowSpace(space: 10.w) + .padding(horizontal: 22.w, vertical: 12.h) .decorated(color: XfjDesignTokens.canvas); } -/// 将五个设备按横屏纵向列表等分可用高度,保证无需逐项点击或滚动即可同时看全。 +/// 将五个设备组织为带稳定列宽的状态表,并以适中行距区分连续设备信息。 Widget _deviceStatusList( FThemeData theme, List<_TerminalDeviceStatusItem> items, ) { - return items - .map((item) => _deviceStatusRow(theme, item).expanded()) - .toList(growable: false) - .toColumnSpace( - space: 10.h, - crossAxisAlignment: CrossAxisAlignment.stretch, - ) - .padding(horizontal: 28.w, bottom: 18.h); + final rowGap = (8.h).clamp(6.0, 8.0).toDouble(); + final Widget table = [ + _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), + ], + ) + .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, + ); return Container( key: ValueKey('terminal-device-status-row-${item.label}'), - padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 10.h), + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: XfjDesignTokens.surface, - border: Border.all( - color: visual.color.withValues(alpha: 0.34), - width: 1.2.w, - ), + border: Border.all(color: XfjDesignTokens.line), borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), - boxShadow: XfjDesignTokens.surfaceShadow, ), child: [ - [ - FIcon(item.icon, size: 25.w, color: visual.color) - .center() - .tightSize(48.w) - .decorated( - color: visual.color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular( - XfjDesignTokens.controlRadius, + Container(width: 4.w, color: visual.color), + _deviceStatusGridRow( + children: [ + [ + 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: [ + 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.label, - style: theme.typography.xl.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w800, ), - ).expanded(), - ].toRowSpace(space: 12.w).width(230.w), - Container(width: 1.w, color: XfjDesignTokens.line), - [ - FIcon( - visual.indicatorIcon, - size: 18.w, - color: visual.color, - ), - Text( - visual.label, - style: theme.typography.sm.copyWith( - color: visual.color, - fontWeight: FontWeight.w800, + Text( + item.currentDescription, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: bodyStyle.copyWith(fontWeight: FontWeight.w600), ), - ), - ] - .toRowSpace(space: 6.w, mainAxisSize: MainAxisSize.min) - .center() - .padding(horizontal: 12.w, vertical: 8.h) - .decorated( - color: visual.color.withValues(alpha: 0.09), - borderRadius: BorderRadius.circular( - XfjDesignTokens.controlRadius, - ), - ) - .width(190.w), - Container(width: 1.w, color: XfjDesignTokens.line), - _deviceStatusTextSection( - theme: theme, - label: '当前状态', - value: item.currentDescription, - emphasized: true, - ).expanded(flex: 5), - Container(width: 1.w, color: XfjDesignTokens.line), - _deviceStatusTextSection( - theme: theme, - label: '设备说明', - value: item.deviceDescription, - ).expanded(flex: 4), - Container(width: 1.w, color: XfjDesignTokens.line), - _deviceStatusTextSection( - theme: theme, - label: '状态快照', - value: _formatDeviceCheckedAt(item.updatedAt), - tabularFigures: true, - ).width(250.w), - ].toRowSpace( + Text( + item.deviceDescription, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: bodyStyle.copyWith(color: XfjDesignTokens.muted), + ), + ], + ).padding(horizontal: 16.w, vertical: 8.h).expanded(), + ].toRow( crossAxisAlignment: CrossAxisAlignment.stretch, - space: 16.w, ), - ); + ).height(rowHeight); } -/// 构建设备状态行中的标签和值区域,文本自然换行并在等高行内垂直居中。 -Widget _deviceStatusTextSection({ - required FThemeData theme, - required String label, - required String value, - bool emphasized = false, - bool tabularFigures = false, -}) { +/// 按固定比例排列设备、运行状态、当前状态和设备说明,确保表头与正文严格对齐。 +Widget _deviceStatusGridRow({required List children}) { + const flexes = [16, 18, 36, 30]; + assert(children.length == flexes.length, '设备状态表格必须提供四列内容'); return [ - Text( - label, - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.muted, - fontWeight: FontWeight.w700, - ), - ), - Text( - value, - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.ink, - fontWeight: emphasized ? FontWeight.w700 : FontWeight.w500, - height: 1.35, - fontFeatures: - tabularFigures ? const [FontFeature.tabularFigures()] : null, - ), - ), - ].toColumnSpace( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - space: 3.h, + 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: theme.typography.lg.copyWith( + 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(64.h).padding(horizontal: 28.w, vertical: 16.h); + ).height(buttonHeight).padding(horizontal: 22.w, vertical: 12.h); } /// 将设备本地检测时间格式化为稳定的数字布局;没有记录时返回明确空状态。 @@ -701,13 +775,13 @@ class _FaceCameraStatusVisual { }) { if (!cameraAvailable) { return const _FaceCameraStatusVisual( - description: 'Android 系统未检测到可用摄像头。', + description: '系统未检测到可用摄像头,请检查硬件和系统识别。', working: false, ); } if (status == FaceRecognitionSessionStatus.initializing) { return const _FaceCameraStatusVisual( - description: '正在打开摄像头并初始化虹软识别引擎。', + description: '正在打开摄像头并初始化识别引擎。', working: true, ); } @@ -719,19 +793,19 @@ class _FaceCameraStatusVisual { } if (status == FaceRecognitionSessionStatus.success) { return const _FaceCameraStatusVisual( - description: '摄像头已完成识别,正在处理本次识别结果。', + description: '识别已完成,正在处理本次结果。', working: true, ); } if (status == FaceRecognitionSessionStatus.failed) { return const _FaceCameraStatusVisual( - description: '摄像头存在,但本次识别异常,当前没有执行识别任务。', + description: '摄像头可用,但本次识别异常,当前已空闲。', working: false, ); } return _FaceCameraStatusVisual( description: - cameraOpen ? '摄像头处于热保持空闲状态,连续 20 分钟无任务后自动关闭。' : '摄像头已检测到,当前没有人脸识别任务。', + cameraOpen ? '摄像头热保持空闲,20 分钟无任务后自动关闭。' : '摄像头已检测到,当前没有人脸识别任务。', working: false, ); } diff --git a/lib/common/widgets/app/terminal_shell.dart b/lib/common/widgets/app/terminal_shell.dart index 6246a2e..349e212 100644 --- a/lib/common/widgets/app/terminal_shell.dart +++ b/lib/common/widgets/app/terminal_shell.dart @@ -283,27 +283,71 @@ class TerminalBrand extends StatelessWidget { } } +/// 网络无有效流量或原生累计计数器暂不可用时使用的稳定展示值。 +const String _zeroNetworkSpeedLabel = '0 KB/s'; + +/// 将每秒字节数格式化为终端顶栏使用的自适应速率文本。 +/// +/// 零值、负增量、计数器异常产生的非有限值都按无流量处理,避免只剩单位或显示无效数字。 +String formatTerminalNetworkSpeed(double bytesPerSecond) { + if (!bytesPerSecond.isFinite || bytesPerSecond <= 0) { + return _zeroNetworkSpeedLabel; + } + if (bytesPerSecond >= 1024 * 1024) { + return '${(bytesPerSecond / 1024 / 1024).toStringAsFixed(1)} MB/s'; + } + if (bytesPerSecond >= 1024) { + return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; + } + return '${bytesPerSecond.toStringAsFixed(0)} B/s'; +} + +/// 终端顶栏网络类型、连接状态及实时上下行速率入口。 class TerminalNetworkStatus extends StatefulWidget { + /// 创建一个使用 Android 累计流量快照进行轻量采样的网络状态组件。 const TerminalNetworkStatus({super.key}); + /// 创建并持有网络采样定时器与当前展示状态。 @override State createState() => _TerminalNetworkStatusState(); } +/// 网络状态组件的采样与展示状态,每三秒比较一次累计字节差值。 class _TerminalNetworkStatusState extends State { + /// 最近一次跨页面共享的原生累计流量快照。 static AppNetworkSnapshot? _lastSnapshot; + + /// 最近一次快照的本机采样时间,用于将累计字节差换算为每秒速率。 static DateTime? _lastSampleTime; - static String _cachedDownloadSpeed = '0 B/s'; - static String _cachedUploadSpeed = '0 B/s'; + + /// 页面重建时复用的最近下行速率文案。 + static String _cachedDownloadSpeed = _zeroNetworkSpeedLabel; + + /// 页面重建时复用的最近上行速率文案。 + static String _cachedUploadSpeed = _zeroNetworkSpeedLabel; + + /// 最近一次平滑后的下行速率,单位为字节每秒。 static double _smoothedDownload = 0; + + /// 最近一次平滑后的上行速率,单位为字节每秒。 static double _smoothedUpload = 0; + /// 周期采样定时器,组件销毁时必须取消。 Timer? _timer; + + /// 当前网络类型的中文展示名称。 String _networkType = '网络'; - String _downloadSpeed = '0 B/s'; - String _uploadSpeed = '0 B/s'; + + /// 当前下行速率文案,无流量时为 `0 KB/s`。 + String _downloadSpeed = _zeroNetworkSpeedLabel; + + /// 当前上行速率文案,无流量时为 `0 KB/s`。 + String _uploadSpeed = _zeroNetworkSpeedLabel; + + /// 原生网络或 Flutter 连接探测任一确认在线时为 true。 bool _connected = false; + /// 进入顶栏后立即采样,并启动三秒一次的轻量刷新任务。 @override void initState() { super.initState(); @@ -311,12 +355,14 @@ class _TerminalNetworkStatusState extends State { _timer = Timer.periodic(const Duration(seconds: 3), (_) => _refresh()); } + /// 取消周期采样,避免路由切换后旧顶栏继续访问平台通道。 @override void dispose() { _timer?.cancel(); super.dispose(); } + /// 读取原生累计流量并计算相邻快照差值,同时使用 Flutter 网络状态兜底连接类型。 Future _refresh() async { final current = await AppRuntimeTool.getNetworkSnapshot(); final flutterConnected = await AppNetworkTool.isConnectedNow(); @@ -340,11 +386,13 @@ class _TerminalNetworkStatusState extends State { (current.receivedBytes - previous.receivedBytes) / seconds; final upload = (current.transmittedBytes - previous.transmittedBytes) / seconds; - // 短周期流量天然会在 0 和峰值间跳变,平滑后更适合终端状态栏阅读。 + // 正增量做平滑以降低峰值跳动;零、负数或异常采样立即归零,不能沿用陈旧上行值。 _smoothedDownload = _smoothSpeed(_smoothedDownload, download); _smoothedUpload = _smoothSpeed(_smoothedUpload, upload); - _cachedDownloadSpeed = _formatSpeed(_smoothedDownload); - _cachedUploadSpeed = _formatSpeed(_smoothedUpload); + _cachedDownloadSpeed = formatTerminalNetworkSpeed( + _smoothedDownload, + ); + _cachedUploadSpeed = formatTerminalNetworkSpeed(_smoothedUpload); } _downloadSpeed = _cachedDownloadSpeed; _uploadSpeed = _cachedUploadSpeed; @@ -358,11 +406,12 @@ class _TerminalNetworkStatusState extends State { }); } + /// 构建固定双行的上下行速率,禁止长下行文案自动折行占用上行所在行。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); return Container( - width: 220.w, + width: 230.w, constraints: BoxConstraints(minHeight: 48.h), padding: EdgeInsets.symmetric(horizontal: 10.w), child: Row( @@ -398,7 +447,9 @@ class _TerminalNetworkStatusState extends State { Expanded( child: Text( '↓$_downloadSpeed\n↑$_uploadSpeed', + key: const ValueKey('terminal-network-speed'), maxLines: 2, + softWrap: false, overflow: TextOverflow.clip, style: theme.typography.xs.copyWith( color: XfjDesignTokens.muted, @@ -413,6 +464,7 @@ class _TerminalNetworkStatusState extends State { ); } + /// 将原生网络类型编码转换为顶栏短文案,未知类型降级为“网络”。 String _networkLabel(String type) => switch (type) { 'ethernet' => '网线', 'wifi' => 'WiFi', @@ -424,25 +476,18 @@ class _TerminalNetworkStatusState extends State { _ => '网络', }; + /// 根据网络类型选择有线、WiFi 或移动信号图标。 SvgAsset _networkIcon(String label) { if (label == 'WiFi') return FAssets.icons.wifi; if (label == '网线') return FAssets.icons.network; return FAssets.icons.signal; } - String _formatSpeed(double bytesPerSecond) { - final speed = bytesPerSecond < 0 ? 0 : bytesPerSecond; - if (speed >= 1024 * 1024) { - return '${(speed / 1024 / 1024).toStringAsFixed(1)} MB/s'; - } - if (speed >= 1024) return '${(speed / 1024).toStringAsFixed(1)} KB/s'; - return '${speed.toStringAsFixed(0)} B/s'; - } - + /// 仅平滑有效正增量;无流量、计数器回退和非有限值立即归零,防止显示陈旧速率。 double _smoothSpeed(double previous, double current) { - final normalized = current < 0 ? 0.0 : current; - if (previous == 0) return normalized; - return previous * 0.65 + normalized * 0.35; + if (!current.isFinite || current <= 0) return 0; + if (!previous.isFinite || previous <= 0) return current; + return previous * 0.65 + current * 0.35; } } diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index e178a41..019844f 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -257,6 +257,53 @@ mixin OrderMixin { return false; } + /// 在服务端确认聚合支付成功后完成本地订单,禁止在二维码创建阶段提前调用。 + /// + /// 聚合支付没有本平台钱包用户,因此本地用户固定记录为匿名 `-1`;重复成功通知遇到 + /// 已支付订单时直接返回,避免重复插入本地购物车明细。 + Future completeAggregateSqlOrder() async { + if (sqlOrderId == null) { + logger.e("|$_tag|完成聚合支付本地订单失败,本地订单id不存在"); + return false; + } + final orderDao = appC.dataBase.getOrderDao; + final order = await orderDao.findById(sqlOrderId!); + if (order == null) { + logger.e("|$_tag|完成聚合支付本地订单失败,本地订单不存在,orderId:|$sqlOrderId|"); + return false; + } + if (order.status == OrderStatus.PAY && + order.payType == OrderPayType.AGGREGATE_ORDER_PAY) { + return true; + } + try { + order + ..username = "匿名顾客" + ..userCode = "-1" + ..walletId = null + ..productDesc = getProductDesc() + ..count = getProductCount() + ..weight = getProductWeight() + ..price = getTotalPrice() + ..extraPrice = amountMealPrice.value + ..payPrice = getTotalPrice() + amountMealPrice.value + ..hourType = hourType.value.code + ..type = orderType + ..payType = OrderPayType.AGGREGATE_ORDER_PAY + ..status = OrderStatus.PAY + ..payTime = DateTime.now(); + await orderDao.edit(order); + await createSqlChildCar(); + logger.i("|$_tag|完成聚合支付本地订单成功,orderId:|$sqlOrderId|"); + return true; + } catch (error, stack) { + logger.e( + "|$_tag|完成聚合支付本地订单失败,orderId:|$sqlOrderId|error:|$error|stack:|$stack|", + ); + return false; + } + } + Future createApiOrderValidate() async { logger.i("|$_tag|开始创建api订单payUserId:|${payUser.value?.userId}|"); final appSetting = await AppSettingTool.getSysSetting(); @@ -350,7 +397,17 @@ mixin OrderMixin { T? result; try { if (isNetwork) { - if (orderPayType.value == OrderPayType.WALLET_QR_CODE_SCAN) { + if (orderPayType.value == OrderPayType.AGGREGATE_ORDER_PAY) { + final data = await HardApi.createAggregateOrderCodePay(pa); + if (data == null) throw StateError("聚合支付创建结果为空"); + logger.i( + "|$_tag||有网络|创建聚合支付订单成功,orderId:|${data.orderId}|aggregateNo:|${data.aggregateNo}|", + ); + sqlOrderDetail.status = OrderStatus.CREATE; + sqlOrderDetail.serviceUniqueCode = data.orderId; + sqlOrderDetail.payType = OrderPayType.AGGREGATE_ORDER_PAY; + result = data as T; + } else if (orderPayType.value == OrderPayType.WALLET_QR_CODE_SCAN) { final data = await HardApi.createHardQrCodeScanOrder(pa); if (data == null) throw Error(); if (ObjectUtil.isEmpty(data)) throw Error(); @@ -368,14 +425,26 @@ mixin OrderMixin { result = data as T; } } else { + if (orderPayType.value == OrderPayType.AGGREGATE_ORDER_PAY) { + Toast.error(msg: "聚合码支付需要连接网络"); + return null; + } sqlOrderDetail.status = OrderStatus.OFFLINE_PAY; } - // 更新本地订单 - final payPrice = sqlOrderDetail.price + sqlOrderDetail.extraPrice; - sqlOrderDetail.payPrice = payPrice; - sqlOrderDetail.payTime = DateTime.now(); + // 聚合码和旧钱包订单码在创建阶段都只是待支付,必须等待服务端确认后再写支付时间和金额。 + final isPendingOrderCode = + orderPayType.value == OrderPayType.AGGREGATE_ORDER_PAY || + orderPayType.value == OrderPayType.WALLET_QR_CODE_SCAN; + if (isPendingOrderCode) { + sqlOrderDetail.payPrice = 0; + sqlOrderDetail.payTime = null; + } else { + sqlOrderDetail.payPrice = + sqlOrderDetail.price + sqlOrderDetail.extraPrice; + sqlOrderDetail.payTime = DateTime.now(); + } await orderDao.edit(sqlOrderDetail); - if (orderPayType.value != OrderPayType.WALLET_QR_CODE_SCAN) { + if (!isPendingOrderCode) { if (sqlOrderDetail.payPrice != 0) { final price = AppPriceTool.fenToYuan(sqlOrderDetail.payPrice); TtsManage.speak("购买成功总金额$price元"); diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index 00bf511..c922e8b 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -30,15 +30,15 @@ class SelectPayTypeDialogController extends GetxController { ServiceQRCode?, )? onSubmit; - // |"二维码扫一扫支付"点击"开始识别"| + /// 聚合订单码支付开始回调,由当前收银业务先创建 Hardware 订单和聚合会话。 Future Function( Future Function( - ServiceHardCreateHardQrCodeScanOrder orderResult, + ServiceHardAggregateOrderCodePay orderResult, ) onOrderCreate, )? onQrCodeScanRecognition; - // |"二维码扫一扫支付" "支付成功"| - Future Function(ServiceUser? payUser)? onQrCodeScanPaySuccess; + /// 聚合订单码服务端确认成功后的回调,不携带本平台钱包用户。 + Future Function()? onQrCodeScanPaySuccess; // |取消|的回调 VoidCallback? onCancel; @@ -50,6 +50,18 @@ class SelectPayTypeDialogController extends GetxController { static const String _tag = "支付识别 Controller"; + /// 聚合会话支付成功状态编码。 + static const String _aggregatePaySuccessStatus = "30"; + + /// 聚合会话被支付平台关闭的状态编码。 + static const String _aggregatePayClosedStatus = "40"; + + /// 聚合会话超过服务端有效期的状态编码。 + static const String _aggregatePayExpiredStatus = "50"; + + /// 聚合会话创建第三方子订单失败的状态编码。 + static const String _aggregatePayCreateFailedStatus = "60"; + final app = Get.find(); late SysSetting appSetting; @@ -124,7 +136,7 @@ class SelectPayTypeDialogController extends GetxController { orderPayType.value = OrderPayType.CSXFK; } } else if (payType == OrderPayDisplayType.qrCodeScan) { - orderPayType.value = OrderPayType.WALLET_QR_CODE_SCAN; + orderPayType.value = OrderPayType.AGGREGATE_ORDER_PAY; } else if (payUser.value != null && AppUserTool.isGenerateExternalUser(payUser.value!)) { payUser.value = null; @@ -238,7 +250,7 @@ class SelectPayTypeDialogController extends GetxController { Navigator.of(Get.context!).pop(); if (orderPayDisplayType.value == OrderPayDisplayType.qrCodeScan) { SubScreenPlugin.sendMsgToViceScreen( - SubScreenAction.qrCodeScanCancel.name, + SubScreenAction.aggregateOrderPayCancel.name, ); } return; @@ -532,40 +544,50 @@ class SelectPayTypeDialogController extends GetxController { // ************************************************************************** // 二维码扫一扫 start // ************************************************************************** + /// 创建并展示聚合订单码,轮询到服务端终态后再通知收银业务完成本地订单。 Future _dispatchQrCodeScan() async { + var aggregateQrCodeDisplayed = false; + var paymentConfirmed = false; try { - // 先创建订单 + // 收银业务先创建 Hardware 订单,Thirdparty 聚合会话必须与该业务订单持久化绑定。 await onQrCodeScanRecognition?.call( (orderRes) async { logger.i( - "|$_tag|二维码扫一扫,创建本地id成功的回调,sqlOrderId|${orderRes.toJson()}|", + "|$_tag|聚合订单码创建成功,orderId:|${orderRes.orderId}|aggregateNo:|${orderRes.aggregateNo}|", ); - // 发送消息给副屏展示二维码弹框 - SubScreenQrCodeScanEvent qrCodeScanEvent = SubScreenQrCodeScanEvent( - action: "hardQrCodeScan", + if (orderRes.qrCodeType != "AGGREGATE_ORDER_PAY" || + !_isAggregateCashierUrl(orderRes.qrCode)) { + throw StateError("聚合支付二维码类型或地址不正确"); + } + final qrCodeScanEvent = SubScreenQrCodeScanEvent( + action: "aggregateOrderPay", orderId: orderRes.orderId, - qrCodeData: orderRes.qrCodeData, + qrCodeData: orderRes.qrCode, + aggregateNo: orderRes.aggregateNo, + qrCodeType: orderRes.qrCodeType, + expireTime: orderRes.expireTime, ); SubScreenPlugin.sendMsgToViceScreen( - SubScreenAction.qrCodeScan.name, + SubScreenAction.aggregateOrderPay.name, params: qrCodeScanEvent.toJson(), ); + aggregateQrCodeDisplayed = true; isRecognition.value = true; - // 循环查询订单状态 - final order = await _queryQrCodeScanOrderStatus(orderRes.orderId); + final status = await _queryAggregateOrderPayStatus(orderRes); if (!isRecognition.value) return; - if (order == null) { + if (status == null || !status.paySuccess) { isRecognition.value = false; - Toast.error(msg: "未支付,请检查用户是否扫码支付"); + SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.aggregateOrderPayCancel.name, + ); + Toast.error(msg: _aggregatePayFailureMessage(status?.status)); return; } - // 设置支付用户 - payUser.value?.walletId = order.walletId; + paymentConfirmed = true; SubScreenPlugin.sendMsgToViceScreen( - SubScreenAction.qrCodeScanSuccess.name, + SubScreenAction.aggregateOrderPaySuccess.name, ); - // 支付成功 - await onQrCodeScanPaySuccess?.call(payUser.value); + await onQrCodeScanPaySuccess?.call(); isRecognition.value = false; _closeStatus(); Navigator.of(Get.context!).pop(); @@ -574,41 +596,88 @@ class SelectPayTypeDialogController extends GetxController { } catch (error, stack) { logger.e("|$_tag|二维码扫一扫,意外的错误error:|$error|task:|$stack|"); isRecognition.value = false; - Toast.error(msg: "二维码扫一扫错误,请重试"); + if (paymentConfirmed) { + // 支付平台和 Hardware 已确认成功时绝不能提示重新付款,本地异常需由订单同步或人工核对处理。 + Toast.error(msg: "支付已成功,但本地订单保存失败,请勿重复收款"); + } else { + if (aggregateQrCodeDisplayed) { + SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.aggregateOrderPayCancel.name, + ); + } + Toast.error(msg: "二维码扫一扫错误,请重试"); + } } } - /// 轮训查询二维码订单状态 - Future _queryQrCodeScanOrderStatus( - String serviceOrderId, + /// 校验聚合码只能承载独立 HTTPS 收银路径,禁止混入钱包码或平台原生码内容。 + bool _isAggregateCashierUrl(String value) { + final uri = Uri.tryParse(value.trim()); + return uri != null && + uri.scheme == 'https' && + uri.host.isNotEmpty && + uri.path.contains('/aggregate-order-pay/cashier/'); + } + + /// 在服务端有效期内轮询聚合支付状态,短暂网络失败不会改变支付结论。 + Future _queryAggregateOrderPayStatus( + ServiceHardAggregateOrderCodePay order, ) async { - int errorCount = 0; - int maxErrorCount = 5; - while (true) { + final deadline = order.expireTime.add(const Duration(seconds: 10)); + var consecutiveErrors = 0; + while (!DateTime.now().isAfter(deadline)) { if (!isRecognition.value) { - logger.e("|$_tag|轮训查询二维码订单状态,已取消订单,停止轮训,sqlOrderId|$serviceOrderId|"); + logger.i( + "|$_tag|聚合支付查询已由收银员取消,orderId:|${order.orderId}|", + ); return null; } - if (errorCount > maxErrorCount) { - logger.e("|$_tag|轮训查询二维码订单状态,订单不存在,sqlOrderId|$serviceOrderId|"); - Toast.error(msg: "订单不存在,请根据系统检查订单"); - return null; - } - await Future.delayed(const Duration(seconds: 3)); - final order = await HardApi.getOrder({"id": serviceOrderId}); - if (order == null) { - logger.e("|$_tag|轮训查询二维码订单状态,订单不存在,sqlOrderId|$serviceOrderId|"); - errorCount++; + await Future.delayed(const Duration(seconds: 2)); + ServiceHardAggregateOrderCodePayStatus? status; + try { + status = await HardApi.getAggregateOrderCodePayStatus( + orderId: order.orderId, + aggregateNo: order.aggregateNo, + ); + } catch (error) { + consecutiveErrors++; + logger.w( + "|$_tag|聚合支付状态查询暂时失败,orderId:|${order.orderId}|consecutiveErrors:|$consecutiveErrors|error:|$error|", + ); continue; } - if (order.orderStatus == OrderStatus.PAY.code) { - logger.i( - "|$_tag|轮训查询二维码订单状态,订单支付成功,sqlOrderId|$serviceOrderId|orderJson|${order.toJson()}|", - ); - return order; + if (status == null) { + consecutiveErrors++; + continue; + } + consecutiveErrors = 0; + if (status.paySuccess && + status.status == _aggregatePaySuccessStatus && + status.orderStatus == OrderStatus.PAY.code) { + logger.i( + "|$_tag|聚合支付成功,orderId:|${order.orderId}|platform:|${status.selectedPlatform}|", + ); + return status; + } + if ({ + _aggregatePayClosedStatus, + _aggregatePayExpiredStatus, + _aggregatePayCreateFailedStatus, + }.contains(status.status)) { + return status; } - errorCount++; } + return null; + } + + /// 将聚合支付失败终态转换为收银员可理解的简短提示。 + String _aggregatePayFailureMessage(String? status) { + return switch (status) { + _aggregatePayClosedStatus => "交易已关闭,请重新发起支付", + _aggregatePayExpiredStatus => "支付二维码已过期,请重新发起支付", + _aggregatePayCreateFailedStatus => "支付平台下单失败,请重新发起支付", + _ => "未确认支付成功,请检查网络或重新支付", + }; } // ************************************************************************** @@ -1430,7 +1499,7 @@ String _paymentPromptTitle(OrderPayDisplayType? type) { return switch (type) { OrderPayDisplayType.walletCard => '请刷饭卡', OrderPayDisplayType.qrCode => '请出示付款码', - OrderPayDisplayType.qrCodeScan => '请扫描支付二维码', + OrderPayDisplayType.qrCodeScan => '请扫描支付宝微信聚合码', OrderPayDisplayType.face => '请进行人脸识别', OrderPayDisplayType.cash => '请收取现金', _ => '请按提示完成支付', @@ -1441,7 +1510,7 @@ String _paymentPromptDescription(OrderPayDisplayType? type) { return switch (type) { OrderPayDisplayType.walletCard => '请在读卡器上刷卡', OrderPayDisplayType.qrCode => '请使用扫码枪读取用户付款码', - OrderPayDisplayType.qrCodeScan => '请让用户扫描副屏二维码', + OrderPayDisplayType.qrCodeScan => '请让用户使用支付宝或微信扫描副屏二维码', OrderPayDisplayType.face => '请让用户正视摄像头', OrderPayDisplayType.cash => '确认收款后提交支付', _ => '选择支付方式后继续', diff --git a/lib/pages/terminal/canteen_product/controller.dart b/lib/pages/terminal/canteen_product/controller.dart index 038d1e6..a5f6703 100644 --- a/lib/pages/terminal/canteen_product/controller.dart +++ b/lib/pages/terminal/canteen_product/controller.dart @@ -269,6 +269,37 @@ class CanteenProductController extends GetxController with OrderMixin { }; // |支付方式|点击|取消| payTypeSelect.onCancel = () {}; + + // 食堂和超市共用聚合码契约,但金额在食堂场景需在创建前重新计算额外餐费。 + payTypeSelect.onQrCodeScanRecognition = (onOrderCreate) async { + orderType = OrderType.BELOW; + orderPayType.value = OrderPayType.AGGREGATE_ORDER_PAY; + orderPayDisplayType.value = OrderPayDisplayType.qrCodeScan; + final validShopCar = _validateShopCar(); + if (!validShopCar) return; + await _computedAmountMealPrice(); + final orderRes = await createApiOrder(); + if (orderRes == null) { + throw StateError("创建聚合支付订单失败"); + } + TtsManage.speak("请使用支付宝或微信扫描屏幕二维码"); + await onOrderCreate.call(orderRes); + }; + + // 服务端明确确认聚合支付成功后,才更新本地订单并清空当前购物车。 + payTypeSelect.onQrCodeScanPaySuccess = () async { + final price = AppPriceTool.fenToYuan( + totalPrice.value + amountMealPrice.value, + ); + final completed = await completeAggregateSqlOrder(); + if (!completed) { + throw StateError("聚合支付成功,但本地订单更新失败"); + } + putOffSendSubScreen(); + await _clearShopCard(); + TtsManage.speak("购买成功总金额$price元"); + Toast.success(msg: "购买成功"); + }; } bool _validatePayUser() { diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index 3b96263..802578f 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; +import 'package:forui/forui.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; @@ -121,6 +122,11 @@ class ReservationMenuQueryController extends GetxController { /// 当前操作员在识别前选择的餐段,页面会在多位用户间保留选择以支持连续核销。 final selectedMealSlot = ReservationMealSlot.morning.obs; + /// 查询菜单与收银页面共用餐段组件的单选 Controller,值使用后端稳定餐段编码。 + final mealSlotC = FRadioSelectGroupController( + value: ReservationMealSlot.morning.code, + ); + /// 识别查询成功后是否直接请求页面打开今天所选餐段的核销弹框。 final quickVerificationEnabled = false.obs; @@ -160,9 +166,11 @@ class ReservationMenuQueryController extends GetxController { int get totalPages => result.value?.totalPages ?? 1; + /// 注册公共餐段组件的编码监听,并异步恢复查询偏好与初始化识别会话。 @override void onInit() { super.onInit(); + mealSlotC.addValueListener(_handleMealSlotSelection); unawaited(_initialize()); } @@ -193,6 +201,9 @@ class ReservationMenuQueryController extends GetxController { ReservationMealSlot.querySelectableValues.contains(restoredMealSlot) ? restoredMealSlot : ReservationMealSlot.morning; + if (!mealSlotC.contains(selectedMealSlot.value.code)) { + mealSlotC.value = {selectedMealSlot.value.code}; + } verificationDateScope.value = preferences.verificationDateScope; } catch (error, stack) { // 配置损坏或读取失败时继续使用安全默认值,不能让查询入口不可用。 @@ -209,9 +220,22 @@ class ReservationMenuQueryController extends GetxController { return; } selectedMealSlot.value = next; + if (!mealSlotC.contains(next.code)) mealSlotC.value = {next.code}; unawaited(_persistSelectedMealSlot(next)); } + /// 接收公共餐段组件的编码选择,并映射回查询菜单的强类型餐段状态。 + void _handleMealSlotSelection(Set selectedCodes) { + final selectedCode = selectedCodes.firstOrNull; + if (selectedCode == null) return; + for (final mealSlot in ReservationMealSlot.querySelectableValues) { + if (mealSlot.code == selectedCode) { + selectMealSlot(mealSlot); + return; + } + } + } + /// 异步缓存最近餐段;失败时保留内存中的即时选择,避免本机设置故障阻断识别查询。 Future _persistSelectedMealSlot(ReservationMealSlot mealSlot) async { try { @@ -727,6 +751,7 @@ class ReservationMenuQueryController extends GetxController { } } + /// 退出页面时终止识别与副屏任务,并释放公共餐段组件持有的选择 Controller。 @override void onClose() { _closed = true; @@ -736,6 +761,7 @@ class ReservationMenuQueryController extends GetxController { unawaited(_recognitionGateway.stopAll()); unawaited(_clearSubScreen()); unawaited(_quickVerificationRequestController.close()); + mealSlotC.dispose(); super.onClose(); } } diff --git a/lib/pages/terminal/menu_query/view.dart b/lib/pages/terminal/menu_query/view.dart index fd96319..f3ea759 100644 --- a/lib/pages/terminal/menu_query/view.dart +++ b/lib/pages/terminal/menu_query/view.dart @@ -67,13 +67,11 @@ class ReservationMenuQueryPage extends GetView { methods: controller.methodOrder.toList(), selected: controller.method.value, status: controller.status.value, - mealSlots: ReservationMealSlot.querySelectableValues, - selectedMealSlot: controller.selectedMealSlot.value, + mealSlotController: controller.mealSlotC, autoRecognitionEnabled: controller.autoRecognitionEnabled.value, continuousRecognitionEnabled: controller.continuousRecognitionEnabled.value, onSelect: controller.selectMethod, - onMealSlotSelected: controller.selectMealSlot, onStart: controller.startRecognition, ), SizedBox(height: 12.h), diff --git a/lib/pages/terminal/menu_query/widgets/query_method_panel.dart b/lib/pages/terminal/menu_query/widgets/query_method_panel.dart index 84cdba3..6fbe086 100644 --- a/lib/pages/terminal/menu_query/widgets/query_method_panel.dart +++ b/lib/pages/terminal/menu_query/widgets/query_method_panel.dart @@ -5,7 +5,6 @@ import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import '../controller.dart'; -import 'query_meal_slot_selector.dart'; /// 查询方式侧栏,在自动模式下用只读运行状态替代手动“开始识别”按钮。 class QueryMethodPanel extends StatelessWidget { @@ -15,12 +14,10 @@ class QueryMethodPanel extends StatelessWidget { required this.methods, required this.selected, required this.status, - required this.mealSlots, - required this.selectedMealSlot, + required this.mealSlotController, required this.autoRecognitionEnabled, required this.continuousRecognitionEnabled, required this.onSelect, - required this.onMealSlotSelected, required this.onStart, }); @@ -33,11 +30,8 @@ class QueryMethodPanel extends StatelessWidget { /// 当前识别与预约查询状态,用于禁用按钮或展示自动运行反馈。 final ReservationMenuQueryStatus status; - /// 查询前允许操作员选择的餐段列表,顺序由稳定业务配置提供。 - final List mealSlots; - - /// 当前查询轮次预选的餐段,快速核销只处理该餐段的当天预约。 - final ReservationMealSlot selectedMealSlot; + /// 与收银页面共用餐段组件的单选 Controller,值使用后端稳定餐段编码。 + final FRadioSelectGroupController mealSlotController; /// 是否以自动识别状态区替换手动启动按钮。 final bool autoRecognitionEnabled; @@ -48,9 +42,6 @@ class QueryMethodPanel extends StatelessWidget { /// 用户切换识别方式时调用的 Controller 回调。 final ValueChanged onSelect; - /// 用户切换预查询餐段时调用的 Controller 回调。 - final ValueChanged onMealSlotSelected; - /// 手动模式下启动识别时调用的 Controller 回调。 final VoidCallback onStart; @@ -68,11 +59,13 @@ class QueryMethodPanel extends StatelessWidget { onTap: () => onSelect(method), ), ), - QueryMealSlotSelector( - mealSlots: mealSlots, - selected: selectedMealSlot, + appRadioHourTypeDialog( + context, + mealSlotController, + key: const ValueKey('reservation-meal-slot-trigger'), + title: '餐段', enabled: mealSelectionEnabled, - onSelected: onMealSlotSelected, + compact: true, ), if (autoRecognitionEnabled) _automaticRecognitionStatus() diff --git a/lib/pages/terminal/supermarket_order/controller.dart b/lib/pages/terminal/supermarket_order/controller.dart index e11d235..60a2481 100644 --- a/lib/pages/terminal/supermarket_order/controller.dart +++ b/lib/pages/terminal/supermarket_order/controller.dart @@ -359,29 +359,29 @@ class SupermarketOrderController extends GetxController with OrderMixin { // |支付方式|点击|取消| payTypeSelect.onCancel = () {}; - // |"二维码扫一扫支付"点击"开始识别"| + // 聚合码支付先创建 Hardware 业务订单,再由副屏展示支付宝、微信共用收银地址。 payTypeSelect.onQrCodeScanRecognition = (onOrderCreate) async { orderType = OrderType.BELOW; - orderPayType.value = OrderPayType.WALLET_QR_CODE_SCAN; + orderPayType.value = OrderPayType.AGGREGATE_ORDER_PAY; orderPayDisplayType.value = OrderPayDisplayType.qrCodeScan; final validShopCar = _validateShopCar(); if (!validShopCar) return; - final orderRes = - await createApiOrder(); - TtsManage.speak("请扫描屏幕二维码"); - await onOrderCreate.call(orderRes!); + final orderRes = await createApiOrder(); + if (orderRes == null) { + throw StateError("创建聚合支付订单失败"); + } + TtsManage.speak("请使用支付宝或微信扫描屏幕二维码"); + await onOrderCreate.call(orderRes); }; - // |"二维码扫一扫支付" "支付成功"| - payTypeSelect.onQrCodeScanPaySuccess = (payUser) async { - if (payUser != null) this.payUser.value = payUser; - final validShopCar = _validateShopCar(); - if (!validShopCar) return; - // 总金额 + // 服务端已完成 Hardware 订单后,本地订单才允许进入已支付状态。 + payTypeSelect.onQrCodeScanPaySuccess = () async { final price = AppPriceTool.fenToYuan(totalPrice.value); - await createSqlOrder(); - await updateSqlOrder(); - _clearShopCard(); + final completed = await completeAggregateSqlOrder(); + if (!completed) { + throw StateError("聚合支付成功,但本地订单更新失败"); + } + await _clearShopCard(); putOffSendSubScreen(); TtsManage.speak("购买成功总金额$price元"); Toast.success(msg: "购买成功"); diff --git a/lib/sub_screen/common/utils/customer_display_coordinator.dart b/lib/sub_screen/common/utils/customer_display_coordinator.dart index 6c864c4..434932c 100644 --- a/lib/sub_screen/common/utils/customer_display_coordinator.dart +++ b/lib/sub_screen/common/utils/customer_display_coordinator.dart @@ -13,6 +13,9 @@ abstract final class _SubScreenProtocol { static const qrCodeScan = 'qrCodeScan'; static const qrCodeScanSuccess = 'qrCodeScanSuccess'; static const qrCodeScanCancel = 'qrCodeScanCancel'; + static const aggregateOrderPay = 'aggregateOrderPay'; + static const aggregateOrderPaySuccess = 'aggregateOrderPaySuccess'; + static const aggregateOrderPayCancel = 'aggregateOrderPayCancel'; } /// 将插件消息转换为纯展示状态,副屏页面不直接依赖 GetX、主屏 Controller 或路由栈。 @@ -64,12 +67,21 @@ class CustomerDisplayCoordinator extends ChangeNotifier { _stopCountdown(); return _replace(next); } - if (action == _SubScreenProtocol.qrCodeScan) { + if (action == _SubScreenProtocol.qrCodeScan || + action == _SubScreenProtocol.aggregateOrderPay) { // 清屏后的迟到支付消息没有订单上下文,必须继续展示欢迎页, // 不能重新构造一个无商品、无金额的支付框架。 if (_state.products.isEmpty) return false; - final qrCodeData = _parseQrCode(arguments); + final isAggregate = action == _SubScreenProtocol.aggregateOrderPay; + final qrCodeData = _parseQrCode( + arguments, + requireAggregateProtocol: isAggregate, + ); if (qrCodeData == null) return false; + final countdownSeconds = isAggregate + ? _parseAggregateCountdown(arguments) + : qrCountdownSeconds; + if (countdownSeconds == null) return false; // 插件可能重复投递同一支付消息,不能因此重置顾客正在观察的倒计时。 if (_state.scenario == CustomerDisplayScenario.paying && _state.qrCodeData == qrCodeData && @@ -80,18 +92,20 @@ class CustomerDisplayCoordinator extends ChangeNotifier { _state.withScenario( CustomerDisplayScenario.paying, qrCodeData: qrCodeData, - countdownSeconds: qrCountdownSeconds, + countdownSeconds: countdownSeconds, ), ); if (changed) _startCountdown(); return changed; } - if (action == _SubScreenProtocol.qrCodeScanSuccess) { + if (action == _SubScreenProtocol.qrCodeScanSuccess || + action == _SubScreenProtocol.aggregateOrderPaySuccess) { if (_state.scenario != CustomerDisplayScenario.paying) return false; _stopCountdown(); return _replace(_state.withScenario(CustomerDisplayScenario.success)); } - if (action == _SubScreenProtocol.qrCodeScanCancel) { + if (action == _SubScreenProtocol.qrCodeScanCancel || + action == _SubScreenProtocol.aggregateOrderPayCancel) { if (_state.scenario != CustomerDisplayScenario.paying) return false; _stopCountdown(); return _replace(_state.withScenario(CustomerDisplayScenario.cancelled)); @@ -182,13 +196,49 @@ class CustomerDisplayCoordinator extends ChangeNotifier { ); } - String? _parseQrCode(Object? arguments) { + /// 解析二维码内容;聚合码校验独立协议,旧动作同时拒绝聚合协议内容反向混入。 + String? _parseQrCode( + Object? arguments, { + required bool requireAggregateProtocol, + }) { final data = _normalizeMap(arguments); final value = data?['qrCodeData']; if (value is! String || value.trim().isEmpty) return null; + if (requireAggregateProtocol) { + if (data?['qrCodeType'] != 'AGGREGATE_ORDER_PAY' || + data?['aggregateNo'] is! String || + (data?['aggregateNo'] as String).trim().isEmpty) { + return null; + } + final uri = Uri.tryParse(value.trim()); + if (uri == null || + uri.scheme != 'https' || + !uri.path.contains('/aggregate-order-pay/cashier/')) { + return null; + } + } else { + final uri = Uri.tryParse(value.trim()); + if (data?['qrCodeType'] == 'AGGREGATE_ORDER_PAY' || + (uri?.path.contains('/aggregate-order-pay/cashier/') ?? false)) { + return null; + } + } return value; } + /// 按服务端过期时间计算聚合码剩余秒数,拒绝过期或异常时间。 + int? _parseAggregateCountdown(Object? arguments) { + final data = _normalizeMap(arguments); + final rawExpireTime = data?['expireTime']; + if (rawExpireTime is! String) return null; + final expireTime = DateTime.tryParse(rawExpireTime); + if (expireTime == null) return null; + final milliseconds = expireTime.difference(DateTime.now()).inMilliseconds; + if (milliseconds <= 0) return null; + final seconds = (milliseconds / Duration.millisecondsPerSecond).ceil(); + return seconds.clamp(1, qrCountdownSeconds).toInt(); + } + Map? _normalizeMap(Object? value) { if (value == null) return null; if (value is Map) return value; diff --git a/lib/sub_screen/common/utils/sub_display_coordinator.dart b/lib/sub_screen/common/utils/sub_display_coordinator.dart index 095fc00..e427d05 100644 --- a/lib/sub_screen/common/utils/sub_display_coordinator.dart +++ b/lib/sub_screen/common/utils/sub_display_coordinator.dart @@ -38,6 +38,9 @@ class SubDisplayCoordinator extends ChangeNotifier { 'qrCodeScan', 'qrCodeScanSuccess', 'qrCodeScanCancel', + 'aggregateOrderPay', + 'aggregateOrderPaySuccess', + 'aggregateOrderPayCancel', }; final CustomerDisplayCoordinator customerCoordinator; diff --git a/test/common/terminal_device_status_widget_test.dart b/test/common/terminal_device_status_widget_test.dart new file mode 100644 index 0000000..a8d1213 --- /dev/null +++ b/test/common/terminal_device_status_widget_test.dart @@ -0,0 +1,145 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 注册终端设备弹框在多档横屏尺寸和放大主题下的布局回归测试。 +void main() { + setUp(AppDeviceStatusTool.resetForTest); + + for (final viewportSize in const [ + Size(800, 450), + Size(800, 600), + Size(1280, 720), + ]) { + testWidgets( + '${viewportSize.width.toInt()}x${viewportSize.height.toInt()}大字号下设备弹框无布局溢出', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = viewportSize; + addTearDown(tester.view.reset); + final adminSession = Get.put(TerminalAdminSessionControl()); + adminSession.unlock(); + + await tester.pumpWidget(_terminalDeviceStatusTestApp()); + await tester.pump(); + expect( + MediaQuery.sizeOf(tester.element(find.byType(Scaffold))), + viewportSize, + ); + await tester.tap( + find.byKey(const ValueKey('terminal-device-status-group')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('terminal-device-status-dialog')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('terminal-device-status-table')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('terminal-device-status-row-打印机')), + findsOneWidget, + ); + for (final deviceName in const ['打印机', '电子秤', '读卡器', '副屏', '摄像头']) { + expect( + find.byKey(ValueKey('terminal-device-status-row-$deviceName')), + findsOneWidget, + ); + } + final printerBottom = tester + .getBottomLeft( + find.byKey(const ValueKey('terminal-device-status-row-打印机')), + ) + .dy; + final scaleTop = tester + .getTopLeft( + find.byKey(const ValueKey('terminal-device-status-row-电子秤')), + ) + .dy; + expect( + scaleTop - printerBottom, + inInclusiveRange(5, 9), + reason: '设备行之间应保留适中的 6-8dp 间距,既可分组又不恢复成松散大卡片', + ); + final printerRow = + find.byKey(const ValueKey('terminal-device-status-row-打印机')); + final printerRowSize = tester.getSize(printerRow); + expect( + printerRowSize.height, + greaterThanOrEqualTo(68), + reason: '设备行需要提供足够的阅读高度,不能再次压缩成过薄表格', + ); + final printerLabel = tester.widget( + find.descendant(of: printerRow, matching: find.text('打印机')).first, + ); + expect( + printerLabel.style?.fontSize, + greaterThanOrEqualTo(16), + reason: '设备名称必须保持实体终端可连续识别的字号', + ); + final printerStatusWidth = tester + .getSize( + find.byKey( + const ValueKey('terminal-device-status-label-打印机'), + ), + ) + .width; + expect( + printerStatusWidth, + lessThan(printerRowSize.width * 0.25), + reason: '运行状态应按图标和文案自然宽度展示,不能铺满整列形成色条', + ); + final tableHeight = tester + .getSize( + find.byKey(const ValueKey('terminal-device-status-table')), + ) + .height; + expect( + tableHeight, + greaterThanOrEqualTo(400), + reason: '设备表需要恢复可读行高和行距,不能集中挤在弹框顶部', + ); + final layoutException = tester.takeException(); + await tester.tap( + find.byKey(const ValueKey('terminal-device-status-close-icon')), + ); + await tester.pumpAndSettle(); + adminSession.lock(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + Get.reset(); + expect(layoutException, isNull); + }); + } +} + +/// 构建与真机问题一致的横屏、大字号主题环境,用于捕获弹框运行时布局溢出。 +Widget _terminalDeviceStatusTestApp() { + final baseTheme = FThemes.zinc.light; + final terminalTheme = FThemeData.inherit( + colorScheme: baseTheme.colorScheme, + typography: baseTheme.typography.scale(sizeScalar: 1.4), + style: baseTheme.style, + ); + return ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + splitScreenMode: true, + builder: (_, __) => MaterialApp( + builder: (_, child) => FTheme(data: terminalTheme, child: child!), + home: const Scaffold( + body: Align( + alignment: Alignment.topRight, + child: TerminalDeviceStatus(), + ), + ), + ), + ); +} diff --git a/test/common/terminal_network_status_test.dart b/test/common/terminal_network_status_test.dart new file mode 100644 index 0000000..c84caa6 --- /dev/null +++ b/test/common/terminal_network_status_test.dart @@ -0,0 +1,97 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 验证终端网络速率在无上行、异常采样和长下行文案下仍能稳定展示。 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const runtimeChannel = MethodChannel('biz_xfj/runtime_environment'); + const connectivityChannel = MethodChannel( + 'dev.fluttercommunity.plus/connectivity', + ); + var networkSampleCount = 0; + + setUp(() { + networkSampleCount = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(runtimeChannel, (call) async { + if (call.method != 'getNetworkSnapshot') return null; + networkSampleCount++; + return { + 'connected': true, + 'type': 'wifi', + 'rxBytes': + networkSampleCount == 1 ? 1024 : 3 * 1024 * 1024 * 1024 + 1024, + // 连续两次累计发送字节不变,模拟只有下行、没有上行的合法采样。 + 'txBytes': 2048, + }; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + connectivityChannel, + (call) async => call.method == 'check' ? ['wifi'] : null, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(runtimeChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectivityChannel, null); + }); + + test('零速率和异常采样统一显示为0 KB/s', () { + expect(formatTerminalNetworkSpeed(0), '0 KB/s'); + expect(formatTerminalNetworkSpeed(-1), '0 KB/s'); + expect(formatTerminalNetworkSpeed(double.nan), '0 KB/s'); + expect(formatTerminalNetworkSpeed(double.infinity), '0 KB/s'); + expect(formatTerminalNetworkSpeed(512), '512 B/s'); + expect(formatTerminalNetworkSpeed(1024), '1.0 KB/s'); + }); + + testWidgets('只有下行时保留独立上行行并明确显示0 KB/s', (tester) async { + await tester.binding.setSurfaceSize(const Size(1920, 1080)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final baseTheme = FThemes.zinc.light; + final largeTheme = FThemeData.inherit( + colorScheme: baseTheme.colorScheme, + typography: baseTheme.typography.scale(sizeScalar: 1.4), + style: baseTheme.style, + ); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + builder: (_, __) => MaterialApp( + home: FTheme( + data: largeTheme, + child: const Scaffold( + body: Align( + alignment: Alignment.topRight, + child: TerminalNetworkStatus(), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); + + final speedText = tester.widget( + find.byKey(const ValueKey('terminal-network-speed')), + ); + expect(networkSampleCount, greaterThanOrEqualTo(2)); + expect(speedText.data, contains('MB/s\n↑0 KB/s')); + expect(speedText.data, isNot(contains('\n↑/s'))); + expect(speedText.softWrap, isFalse); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/test/pages/terminal/menu_query_controller_test.dart b/test/pages/terminal/menu_query_controller_test.dart index 07803ad..e8ef46a 100644 --- a/test/pages/terminal/menu_query_controller_test.dart +++ b/test/pages/terminal/menu_query_controller_test.dart @@ -81,10 +81,12 @@ void main() { controller.onInit(); await tester.pump(); expect(controller.selectedMealSlot.value, ReservationMealSlot.noon); + expect(controller.mealSlotC.value, {'2'}); controller.selectMealSlot(ReservationMealSlot.night); await tester.pump(); expect(controller.selectedMealSlot.value, ReservationMealSlot.night); + expect(controller.mealSlotC.value, {'3'}); expect(savedMealSlots, [ReservationMealSlot.night]); controller.status.value = ReservationMenuQueryStatus.querying; diff --git a/test/pages/terminal/query_method_panel_test.dart b/test/pages/terminal/query_method_panel_test.dart index 784e309..56acb59 100644 --- a/test/pages/terminal/query_method_panel_test.dart +++ b/test/pages/terminal/query_method_panel_test.dart @@ -6,7 +6,6 @@ import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/controller.dart'; import 'package:pj_supermarket_cloud/pages/terminal/menu_query/widgets/query_method_panel.dart'; -import 'package:pj_supermarket_cloud/pages/terminal/menu_query/widgets/query_meal_slot_selector.dart'; void main() { testWidgets('手动模式保留开始按钮,自动模式改为饭卡等待状态且仍可切换方式', (tester) async { @@ -14,6 +13,8 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); var startCalls = 0; ReservationRecognitionMethod? selectedMethod; + final mealSlotController = FRadioSelectGroupController(value: '1'); + addTearDown(mealSlotController.dispose); await tester.pumpWidget( _testApp( @@ -21,12 +22,10 @@ void main() { methods: ReservationRecognitionMethod.values, selected: ReservationRecognitionMethod.card, status: ReservationMenuQueryStatus.idle, - mealSlots: ReservationMealSlot.querySelectableValues, - selectedMealSlot: ReservationMealSlot.morning, + mealSlotController: mealSlotController, autoRecognitionEnabled: false, continuousRecognitionEnabled: false, onSelect: (method) => selectedMethod = method, - onMealSlotSelected: (_) {}, onStart: () => startCalls++, ), ), @@ -43,12 +42,10 @@ void main() { methods: ReservationRecognitionMethod.values, selected: ReservationRecognitionMethod.card, status: ReservationMenuQueryStatus.recognizing, - mealSlots: ReservationMealSlot.querySelectableValues, - selectedMealSlot: ReservationMealSlot.morning, + mealSlotController: mealSlotController, autoRecognitionEnabled: true, continuousRecognitionEnabled: true, onSelect: (method) => selectedMethod = method, - onMealSlotSelected: (_) {}, onStart: () => startCalls++, ), ), @@ -68,43 +65,45 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); var selectedMealSlot = ReservationMealSlot.morning; var status = ReservationMenuQueryStatus.idle; + final mealSlotController = FRadioSelectGroupController(value: '1'); + mealSlotController.addValueListener((selectedCodes) { + final selectedCode = selectedCodes.firstOrNull; + selectedMealSlot = ReservationMealSlot.querySelectableValues.firstWhere( + (mealSlot) => mealSlot.code == selectedCode, + ); + }); + addTearDown(mealSlotController.dispose); await tester.pumpWidget( _testApp( - StatefulBuilder( - builder: (context, setState) => QueryMethodPanel( - methods: ReservationRecognitionMethod.values, - selected: ReservationRecognitionMethod.card, - status: status, - mealSlots: ReservationMealSlot.querySelectableValues, - selectedMealSlot: selectedMealSlot, - autoRecognitionEnabled: false, - continuousRecognitionEnabled: true, - onSelect: (_) {}, - onMealSlotSelected: (slot) => setState(() { - selectedMealSlot = slot; - }), - onStart: () {}, - ), + QueryMethodPanel( + methods: ReservationRecognitionMethod.values, + selected: ReservationRecognitionMethod.card, + status: status, + mealSlotController: mealSlotController, + autoRecognitionEnabled: false, + continuousRecognitionEnabled: true, + onSelect: (_) {}, + onStart: () {}, ), ), ); - expect(find.byType(QueryMealSlotSelector), findsOneWidget); + expect(find.byType(FSelectMenuTile), findsOneWidget); expect(find.text('餐段'), findsOneWidget); expect(find.text('早餐'), findsOneWidget); - expect(find.text('午餐'), findsNothing); + expect(find.text('中餐'), findsNothing); expect(find.text('晚餐'), findsNothing); final trigger = find.byKey( const ValueKey('reservation-meal-slot-trigger'), ); expect(tester.getSize(trigger).height, greaterThanOrEqualTo(48)); - expect(tester.widget(trigger).properties.enabled, isTrue); + expect(tester.widget>(trigger).enabled, isTrue); await tester.tap(trigger); await tester.pumpAndSettle(); final noonOption = find.byKey( - const ValueKey('reservation-meal-slot-option-2'), + const ValueKey('meal-slot-option-2'), ); expect(tester.getSize(noonOption).height, greaterThanOrEqualTo(48)); @@ -112,7 +111,7 @@ void main() { await tester.pumpAndSettle(); expect(selectedMealSlot, ReservationMealSlot.noon); expect(find.text('早餐'), findsNothing); - expect(find.text('午餐'), findsOneWidget); + expect(find.text('中餐'), findsOneWidget); status = ReservationMenuQueryStatus.recognizing; await tester.pumpWidget( @@ -121,12 +120,10 @@ void main() { methods: ReservationRecognitionMethod.values, selected: ReservationRecognitionMethod.card, status: status, - mealSlots: ReservationMealSlot.querySelectableValues, - selectedMealSlot: selectedMealSlot, + mealSlotController: mealSlotController, autoRecognitionEnabled: false, continuousRecognitionEnabled: true, onSelect: (_) {}, - onMealSlotSelected: (slot) => selectedMealSlot = slot, onStart: () {}, ), ), @@ -134,7 +131,7 @@ void main() { await tester.tap(trigger); await tester.pumpAndSettle(); final nightOption = find.byKey( - const ValueKey('reservation-meal-slot-option-3'), + const ValueKey('meal-slot-option-3'), ); await tester.tap(nightOption); await tester.pumpAndSettle(); @@ -147,12 +144,10 @@ void main() { methods: ReservationRecognitionMethod.values, selected: ReservationRecognitionMethod.card, status: status, - mealSlots: ReservationMealSlot.querySelectableValues, - selectedMealSlot: selectedMealSlot, + mealSlotController: mealSlotController, autoRecognitionEnabled: false, continuousRecognitionEnabled: true, onSelect: (_) {}, - onMealSlotSelected: (slot) => selectedMealSlot = slot, onStart: () {}, ), ), @@ -161,14 +156,14 @@ void main() { const ValueKey('reservation-meal-slot-trigger'), ); expect( - tester.widget(disabledTrigger).properties.enabled, + tester.widget>(disabledTrigger).enabled, isFalse, ); await tester.tap(disabledTrigger); await tester.pump(); expect(selectedMealSlot, ReservationMealSlot.night); expect( - find.byKey(const ValueKey('reservation-meal-slot-option-1')), + find.byKey(const ValueKey('meal-slot-option-1')), findsNothing, ); expect(tester.takeException(), isNull); diff --git a/test/sub_screen/customer_display_coordinator_test.dart b/test/sub_screen/customer_display_coordinator_test.dart index 36d323a..2fdfe7a 100644 --- a/test/sub_screen/customer_display_coordinator_test.dart +++ b/test/sub_screen/customer_display_coordinator_test.dart @@ -67,6 +67,85 @@ void main() { coordinator.dispose(); }); + test('聚合码只接受独立 HTTPS 收银协议并按服务端时间倒计时', () { + final coordinator = CustomerDisplayCoordinator(); + coordinator.handleAction('updateCard', _productEvent()); + final expireTime = DateTime.now().add(const Duration(seconds: 90)); + final aggregateEvent = { + 'orderId': '1001', + 'aggregateNo': 'AGG-1001', + 'qrCodeType': 'AGGREGATE_ORDER_PAY', + 'qrCodeData': + 'https://pay.example.com/aggregate-order-pay/cashier/tenant/token', + 'expireTime': expireTime.toIso8601String(), + }; + + expect( + coordinator.handleAction('aggregateOrderPay', aggregateEvent), + isTrue, + ); + expect(coordinator.state.scenario, CustomerDisplayScenario.paying); + expect(coordinator.state.countdownSeconds, inInclusiveRange(89, 90)); + expect( + coordinator.handleAction('aggregateOrderPaySuccess'), + isTrue, + ); + expect(coordinator.state.scenario, CustomerDisplayScenario.success); + + coordinator.dispose(); + }); + + test('聚合码拒绝自有钱包内容、非 HTTPS 地址和错误协议类型', () { + final coordinator = CustomerDisplayCoordinator(); + coordinator.handleAction('updateCard', _productEvent()); + final expireTime = DateTime.now().add(const Duration(seconds: 90)); + + for (final event in [ + { + 'aggregateNo': 'AGG-1001', + 'qrCodeType': 'WALLET_ORDER_PAY', + 'qrCodeData': + 'https://pay.example.com/aggregate-order-pay/cashier/tenant/token', + 'expireTime': expireTime.toIso8601String(), + }, + { + 'aggregateNo': 'AGG-1001', + 'qrCodeType': 'AGGREGATE_ORDER_PAY', + 'qrCodeData': + 'http://pay.example.com/aggregate-order-pay/cashier/tenant/token', + 'expireTime': expireTime.toIso8601String(), + }, + { + 'aggregateNo': 'AGG-1001', + 'qrCodeType': 'AGGREGATE_ORDER_PAY', + 'qrCodeData': 'action=harQrCodeScanPay&orderId=1001', + 'expireTime': expireTime.toIso8601String(), + }, + ]) { + expect(coordinator.handleAction('aggregateOrderPay', event), isFalse); + } + expect(coordinator.state.scenario, CustomerDisplayScenario.review); + + coordinator.dispose(); + }); + + test('旧钱包二维码动作拒绝聚合协议内容反向混入', () { + final coordinator = CustomerDisplayCoordinator(); + coordinator.handleAction('updateCard', _productEvent()); + + expect( + coordinator.handleAction('qrCodeScan', const { + 'qrCodeType': 'AGGREGATE_ORDER_PAY', + 'qrCodeData': + 'https://pay.example.com/aggregate-order-pay/cashier/tenant/token', + }), + isFalse, + ); + expect(coordinator.state.scenario, CustomerDisplayScenario.review); + + coordinator.dispose(); + }); + testWidgets('重复二维码不重置倒计时,结束后进入超时状态', (tester) async { final coordinator = CustomerDisplayCoordinator(qrCountdownSeconds: 3); const qrEvent = {'qrCodeData': 'preview-payment'}; diff --git a/test/sub_screen/sub_display_coordinator_test.dart b/test/sub_screen/sub_display_coordinator_test.dart index 864ab4a..958dd5d 100644 --- a/test/sub_screen/sub_display_coordinator_test.dart +++ b/test/sub_screen/sub_display_coordinator_test.dart @@ -53,6 +53,30 @@ void main() { expect(coordinator.customerCoordinator.state.products, isEmpty); }); + test('有效聚合支付消息进入结算模式且不会混入预约或人脸页面', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + coordinator.handleAction('updateCard', _productEvent()); + + expect( + coordinator.handleAction('aggregateOrderPay', { + 'orderId': '1001', + 'aggregateNo': 'AGG-1001', + 'qrCodeType': 'AGGREGATE_ORDER_PAY', + 'qrCodeData': + 'https://pay.example.com/aggregate-order-pay/cashier/tenant/token', + 'expireTime': + DateTime.now().add(const Duration(seconds: 90)).toIso8601String(), + }), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.settlement); + expect( + coordinator.customerCoordinator.state.scenario.name, + 'paying', + ); + }); + testWidgets('收银人脸结束后恢复进入人脸前的页面', (tester) async { final coordinator = SubDisplayCoordinator(); addTearDown(coordinator.dispose);