diff --git a/AGENTS.md b/AGENTS.md index ff1906b..d426c0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,13 +5,12 @@ ## 必读顺序 1. [HQ Platform 通用规则](../agents/common.md)。 -2. [Flutter UI 共享规则](../agents/flutter-ui.md)。 -3. [双屏收银终端共享规则](../agents/flutter-terminal.md)。 -4. 涉及界面设计时阅读 [UI 设计规范](../ui/UI_DESIGN.md)。 -5. 检查目标目录内更具体的 `AGENTS.md`、`README.md`、开发文档和现有同类实现。 +2. [双屏收银终端共享规则](../agents/flutter-terminal.md)。 +3. 检查目标目录内更具体的 `AGENTS.md`、`README.md`、开发文档和现有同类实现。 ## 本项目定位 - 修改业务功能前,必须阅读 `../biz_supermarket_cloud` 中对应页面、Controller、Binding、公共组件、API、模型、设备管理类和 Android 原生实现。 - 可以形成独立的新 UI 视觉,但不得删减、弱化或改变基线项目的业务能力、异常处理、硬件不可用处理和主副屏联动语义。 +- UI 设计和开发直接以当前 Flutter 工程的页面、主题、公共组件及 `biz_supermarket_cloud` 对应实现为依据。 - 涉及依赖、Android 基线、数据库、人脸插件、双屏协议、支付编码或其他跨项目契约时,必须同步评估两个终端项目,禁止单边分叉。 diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java index 98e7beb..96e41a4 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java @@ -263,6 +263,38 @@ public class RecognizeConfiguration { return enableImageQuality; } + /** + * 创建仅切换实时图像质量能力的运行时配置,其他现场参数保持不变。 + * + *

该副本不会写回 SharedPreferences,专供虹软质量增值模块初始化失败时 + * 降级本次识别会话,避免授权差异阻断原有特征提取和比对。

+ * + * @param enabled 本次识别会话是否执行实时图像质量检测 + * @return 保留其余识别参数的新配置 + */ + public RecognizeConfiguration withImageQualityEnabled(boolean enabled) { + return new Builder() + .extractRetryCount(extractRetryCount) + .livenessRetryCount(livenessRetryCount) + .maxDetectFaces(maxDetectFaces) + .recognizeFailedRetryInterval(recognizeFailedRetryInterval) + .livenessFailedRetryInterval(livenessFailedRetryInterval) + .similarThreshold(similarThreshold) + .imageQualityNoMaskRecognizeThreshold(imageQualityNoMaskRecognizeThreshold) + .imageQualityMaskRecognizeThreshold(imageQualityMaskRecognizeThreshold) + .libraryFaceQualityThreshold(libraryFaceQualityThreshold) + .enableLiveness(enableLiveness) + .enableImageQuality(enabled) + .enableFaceAreaLimit(enableFaceAreaLimit) + .keepMaxFace(keepMaxFace) + .livenessParam(livenessParam) + .enableFaceSizeLimit(enableFaceSizeLimit) + .enableFaceMoveLimit(enableFaceMoveLimit) + .faceSizeLimit(faceSizeLimit) + .faceMoveLimit(faceMoveLimit) + .build(); + } + public boolean isEnableFaceAreaLimit() { return enableFaceAreaLimit; } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java index 252fcf6..6817f69 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java @@ -185,6 +185,22 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback frEngineMask |= FaceEngine.ASF_IMAGEQUALITY; } frInitCode = frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, maxDetectFaces, frEngineMask); + if (enableFaceQualityDetect && frInitCode != ErrorInfo.MOK) { + int qualityInitCode = frInitCode; + Log.w(TAG, "实时图像质量模块不可用,回退基础识别引擎code=" + qualityInitCode); + frEngine.unInit(); + frEngine = new FaceEngine(); + frInitCode = frEngine.init( + context, + DetectMode.ASF_DETECT_MODE_IMAGE, + DetectFaceOrientPriority.ASF_OP_0_ONLY, + maxDetectFaces, + FaceEngine.ASF_FACE_RECOGNITION); + if (frInitCode == ErrorInfo.MOK) { + // 只关闭本次会话的质量判断,保留管理员配置,授权恢复后下次会重新尝试。 + configuration = configuration.withImageQualityEnabled(false); + } + } //启用活体检测时,才初始化活体引擎 if (enableLive) { flEngine = new FaceEngine(); diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart index 5325a33..5c0e554 100644 --- a/lib/common/models/api/hard_api_models.dart +++ b/lib/common/models/api/hard_api_models.dart @@ -306,9 +306,11 @@ class ServiceHardware extends ServiceBase { @JsonSerializable() class ServiceCar { /// 编号 + @JsonKey(fromJson: JsonTool.anyToString) final String? id; /// 父购物车id + @JsonKey(fromJson: JsonTool.anyToString) final String? parentCarId; /// 商品名 @@ -318,13 +320,24 @@ class ServiceCar { final int? productPrice; /// 商品id + @JsonKey(fromJson: JsonTool.anyToString) final String? productId; /// 数量 final int? productCount; - /// 总价(分) - @JsonKey(fromJson: JsonTool.stringToInt, toJson: JsonTool.intToString) + /// 商品性质,1 为按件商品,2 为称重商品。 + final String? productNature; + + /// 称重商品重量,单位沿用服务端购物车快照。 + @JsonKey(fromJson: JsonTool.anyToNullableDouble) + final double? productWeight; + + /// 总价(分),兼容服务端 Long 返回数字或字符串。 + @JsonKey( + fromJson: JsonTool.anyToNullableInt, + toJson: JsonTool.intToString, + ) final int? totalPrice; /// 商品图片 @@ -337,6 +350,8 @@ class ServiceCar { this.productPrice, this.productId, this.productCount, + this.productNature, + this.productWeight, this.totalPrice, this.productPic, }); @@ -423,7 +438,15 @@ class ServiceQRCode { @JsonSerializable() class ServiceHardOrder { + /// 服务端订单主键。 + @JsonKey(fromJson: JsonTool.anyToString) + final String? id; + + /// 业务订单号。 + final String? orderNo; + /// 用户id + @JsonKey(fromJson: JsonTool.anyToString) final String? userId; /// 用户名 @@ -444,10 +467,43 @@ class ServiceHardOrder { /// 支付方式 final String? payType; - /// 支付方式 + /// 钱包id + @JsonKey(fromJson: JsonTool.anyToString) final String? walletId; + /// 用餐时段。 + final String? hourType; + + /// 预约用餐时段,旧预约订单可能只返回该字段。 + final String? apptHourType; + + /// 商品摘要。 + final String? productDesc; + + /// 用户所属部门。 + final String? userDeptName; + + /// 商品总价(分)。 + @JsonKey(fromJson: JsonTool.anyToNullableInt) + final int? totalPrice; + + /// 额外支付(分)。 + @JsonKey(fromJson: JsonTool.anyToNullableInt) + final int? extraPrice; + + /// 实际支付(分)。 + @JsonKey(fromJson: JsonTool.anyToNullableInt) + final int? payPrice; + + /// 终端订单号。 + final String? androidOrderCode; + + /// 下单时间。 + final DateTime? createTime; + ServiceHardOrder({ + this.id, + this.orderNo, this.userId, this.username, this.userMobile, @@ -456,6 +512,15 @@ class ServiceHardOrder { this.orderStatus, this.payType, this.walletId, + this.hourType, + this.apptHourType, + this.productDesc, + this.userDeptName, + this.totalPrice, + this.extraPrice, + this.payPrice, + this.androidOrderCode, + this.createTime, }); factory ServiceHardOrder.fromJson(Map json) => diff --git a/lib/common/models/api/hard_api_models.g.dart b/lib/common/models/api/hard_api_models.g.dart index fe5436c..8de6482 100644 --- a/lib/common/models/api/hard_api_models.g.dart +++ b/lib/common/models/api/hard_api_models.g.dart @@ -186,13 +186,15 @@ Map _$ServiceHardwareToJson(ServiceHardware instance) => }; ServiceCar _$ServiceCarFromJson(Map json) => ServiceCar( - id: json['id'] as String?, - parentCarId: json['parentCarId'] as String?, + id: JsonTool.anyToString(json['id']), + parentCarId: JsonTool.anyToString(json['parentCarId']), productName: json['productName'] as String?, productPrice: (json['productPrice'] as num?)?.toInt(), - productId: json['productId'] as String?, + productId: JsonTool.anyToString(json['productId']), productCount: (json['productCount'] as num?)?.toInt(), - totalPrice: JsonTool.stringToInt(json['totalPrice'] as String?), + productNature: json['productNature'] as String?, + productWeight: JsonTool.anyToNullableDouble(json['productWeight']), + totalPrice: JsonTool.anyToNullableInt(json['totalPrice']), productPic: json['productPic'] as String?, ); @@ -204,6 +206,8 @@ Map _$ServiceCarToJson(ServiceCar instance) => 'productPrice': instance.productPrice, 'productId': instance.productId, 'productCount': instance.productCount, + 'productNature': instance.productNature, + 'productWeight': instance.productWeight, 'totalPrice': JsonTool.intToString(instance.totalPrice), 'productPic': instance.productPic, }; @@ -248,18 +252,33 @@ Map _$ServiceQRCodeToJson(ServiceQRCode instance) => ServiceHardOrder _$ServiceHardOrderFromJson(Map json) => ServiceHardOrder( - userId: json['userId'] as String?, + id: JsonTool.anyToString(json['id']), + orderNo: json['orderNo'] as String?, + userId: JsonTool.anyToString(json['userId']), username: json['username'] as String?, userMobile: json['userMobile'] as String?, userAvatar: json['userAvatar'] as String?, orderType: json['orderType'] as String?, orderStatus: json['orderStatus'] as String?, payType: json['payType'] as String?, - walletId: json['walletId'] as String?, + walletId: JsonTool.anyToString(json['walletId']), + hourType: json['hourType'] as String?, + apptHourType: json['apptHourType'] as String?, + productDesc: json['productDesc'] as String?, + userDeptName: json['userDeptName'] as String?, + totalPrice: JsonTool.anyToNullableInt(json['totalPrice']), + extraPrice: JsonTool.anyToNullableInt(json['extraPrice']), + payPrice: JsonTool.anyToNullableInt(json['payPrice']), + androidOrderCode: json['androidOrderCode'] as String?, + createTime: json['createTime'] == null + ? null + : DateTime.parse(json['createTime'] as String), ); Map _$ServiceHardOrderToJson(ServiceHardOrder instance) => { + 'id': instance.id, + 'orderNo': instance.orderNo, 'userId': instance.userId, 'username': instance.username, 'userMobile': instance.userMobile, @@ -268,6 +287,15 @@ Map _$ServiceHardOrderToJson(ServiceHardOrder instance) => 'orderStatus': instance.orderStatus, 'payType': instance.payType, 'walletId': instance.walletId, + 'hourType': instance.hourType, + 'apptHourType': instance.apptHourType, + 'productDesc': instance.productDesc, + 'userDeptName': instance.userDeptName, + 'totalPrice': instance.totalPrice, + 'extraPrice': instance.extraPrice, + 'payPrice': instance.payPrice, + 'androidOrderCode': instance.androidOrderCode, + 'createTime': instance.createTime?.toIso8601String(), }; VerificationRecordQueryRequest _$VerificationRecordQueryRequestFromJson( diff --git a/lib/common/style/xfj_design_tokens.dart b/lib/common/style/xfj_design_tokens.dart index cf80561..0eab929 100644 --- a/lib/common/style/xfj_design_tokens.dart +++ b/lib/common/style/xfj_design_tokens.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -/// 与 `ui/UI_DESIGN.md`、React 原型共用的终端视觉令牌。 +/// `biz_xfj_cloud` 终端页面统一使用的语义视觉令牌。 /// -/// Flutter 页面只能通过语义令牌表达颜色,避免页面实现与 5188 原型再次偏离。 +/// Flutter 页面只能通过语义令牌表达颜色,避免页面实现与既有视觉体系偏离。 abstract final class XfjDesignTokens { static const Color primary = Color(0xFF0094FF); static const Color primarySoft = Color(0xFFEAF6FF); diff --git a/lib/common/utils/json/json_tool.dart b/lib/common/utils/json/json_tool.dart index 9f7ac38..9ea66dd 100644 --- a/lib/common/utils/json/json_tool.dart +++ b/lib/common/utils/json/json_tool.dart @@ -31,6 +31,13 @@ abstract class JsonTool { return anyToInt(value); } + /// 兼容后端 BigDecimal 以字符串或数字返回的可选小数。 + static double? anyToNullableDouble(Object? value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()); + } + static String? intToString(int? value) { if (value == null) return null; return value.toString(); diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 6e97b8b..65775a3 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -393,7 +393,7 @@ abstract class SyncFaceTool { } } - /// 删除本地孤儿人脸;虹软失败或 SQLite 失败时恢复文件和旧特征。 + /// 删除本地孤儿人脸;文件、虹软或 SQLite 失败时恢复文件和旧特征。 static Future _deleteLocalFace( FaceEntity face, FaceDao faceDao, @@ -417,8 +417,8 @@ abstract class SyncFaceTool { if (!removed.success) throw FaceSyncSnapshotException(removed.message); } await faceDao.delBySampleId(face.sampleId); - if (pendingFile != null) { - await delFaceByStorage(filePath: pendingFile.path); + if (pendingFile != null && await pendingFile.exists()) { + await pendingFile.delete(); } return true; } catch (error, stack) { 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 4f30609..219c21f 100644 --- a/lib/common/widgets/app/app_radio_hour_type_dialog.dart +++ b/lib/common/widgets/app/app_radio_hour_type_dialog.dart @@ -332,7 +332,7 @@ class _MealPeriodSelectionGuardState extends State<_MealPeriodSelectionGuard> { }); } - /// 校验人工候选或当前缓存餐段;确认切换配置餐段,取消时保留人工选择。 + /// 校验人工候选或当前缓存餐段;人工确认后提交候选,初始化确认后提交配置餐段。 Future _checkSelection() async { if (_checking || !mounted) return; _checking = true; @@ -356,15 +356,15 @@ class _MealPeriodSelectionGuardState extends State<_MealPeriodSelectionGuard> { return; } if (configured == null || configured == selected) return; - final shouldSwitch = await appMessageDialog( - context, - '当前选择的餐段可能有问题,按餐段配置当前时间应为“${configured.title}”,是否切换?', - ); + final message = candidate == null + ? '当前选择的餐段可能有问题,按餐段配置当前时间应为“${configured.title}”,是否切换?' + : '当前选择的餐段可能有问题,按餐段配置当前时间应为“${configured.title}”,是否仍使用“${candidate.title}”?'; + final confirmed = await appMessageDialog(context, message); if (!mounted || revision != _selectionRevision) return; - if (shouldSwitch) { + if (candidate != null) { + if (confirmed) _commitSelection(candidate); + } else if (confirmed) { _commitSelection(configured); - } else if (candidate != null) { - _commitSelection(candidate); } } catch (error, stack) { logger.w('|餐段配置|校验当前餐段失败error:|$error|stack:|$stack|'); diff --git a/lib/common/widgets/app/terminal_shell.dart b/lib/common/widgets/app/terminal_shell.dart index 2461522..25fa473 100644 --- a/lib/common/widgets/app/terminal_shell.dart +++ b/lib/common/widgets/app/terminal_shell.dart @@ -37,7 +37,7 @@ extension on TerminalNavType { }; } -/// 对应 React 原型 `TerminalLayout` 的 Flutter 公共终端框架。 +/// 主屏各业务页面共用的 Flutter 终端框架。 /// /// 顶部系统操作和左侧一级导航必须常驻,设备状态按本机偏好显示;业务页面只负责 /// 右侧工作区,避免每个页面自己创建返回标题栏后破坏收银终端的连续操作体验。 @@ -250,7 +250,7 @@ Widget _terminalDeviceStatusHeaderSlot() { /// 顶栏品牌字标需要保持固定比例,不能跟随全局字号缩放无限放大。 /// -/// 这里使用受约束的 `FittedBox` 保留 React 原型中的斜体、紧字距效果,同时避免 +/// 这里使用受约束的 `FittedBox` 保留既有品牌字标的斜体、紧字距效果,同时避免 /// 大字号设置把品牌挤出 80 高的终端顶栏。 class TerminalBrand extends StatelessWidget { const TerminalBrand({super.key, this.targetMenu, this.onTap}); diff --git a/lib/common/widgets/app/terminal_toolbar.dart b/lib/common/widgets/app/terminal_toolbar.dart index 0b97787..35c77e7 100644 --- a/lib/common/widgets/app/terminal_toolbar.dart +++ b/lib/common/widgets/app/terminal_toolbar.dart @@ -4,7 +4,12 @@ import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -enum TerminalToolbarButtonStyle { primary, secondary, danger } +enum TerminalToolbarButtonStyle { + primary, + secondary, + danger, + destructive, +} /// 统一搜索、重置、删除、扫码等工具操作的高度,避免主题尺寸变化后相邻控件错位。 class TerminalToolbarButton extends StatelessWidget { @@ -31,17 +36,20 @@ class TerminalToolbarButton extends StatelessWidget { TerminalToolbarButtonStyle.primary => XfjDesignTokens.primary, TerminalToolbarButtonStyle.secondary => XfjDesignTokens.surface, TerminalToolbarButtonStyle.danger => const Color(0xFFFFF3F2), + TerminalToolbarButtonStyle.destructive => XfjDesignTokens.danger, }; final foreground = switch (style) { TerminalToolbarButtonStyle.primary => Colors.white, TerminalToolbarButtonStyle.secondary => XfjDesignTokens.ink, TerminalToolbarButtonStyle.danger => XfjDesignTokens.danger, + TerminalToolbarButtonStyle.destructive => Colors.white, }; final border = switch (style) { TerminalToolbarButtonStyle.primary => XfjDesignTokens.primary, TerminalToolbarButtonStyle.secondary => XfjDesignTokens.line, TerminalToolbarButtonStyle.danger => XfjDesignTokens.danger.withValues(alpha: 0.35), + TerminalToolbarButtonStyle.destructive => XfjDesignTokens.danger, }; return Opacity( opacity: enabled ? 1 : 0.45, diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart index e6226be..743a179 100644 --- a/lib/common/widgets/index.dart +++ b/lib/common/widgets/index.dart @@ -37,6 +37,7 @@ export './order/order_common.dart'; export './order/order_supermarket_common.dart'; export './order/select_hold_bill_dialog.dart'; export './order/order_more_dialog.dart'; +export './order/order_record_collapse.dart'; export './setting/setting_password_dialog.dart'; export './setting/terminal_setting_tile.dart'; diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index f66274c..85d5ec8 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -973,11 +973,11 @@ class ReservationMenuQueryController extends GetxController { } } - /// 只核销用户在当前餐段弹框中选中的订单 ID,成功后重新查询并替换页面结果。 + /// 核销用户在当前餐段弹框中勾选的订单,至少一笔成功后重新查询并替换页面结果。 Future confirmVerification( ReservationMenuDayData day, ReservationMenuMealData meal, - int selectedOrderId, + List selectedOrderIds, ) async { if (_closed || _verificationSubmitting) return false; final currentMeal = _findCurrentMeal(day, meal); @@ -989,10 +989,20 @@ class ReservationMenuQueryController extends GetxController { _errorNotifier('未获取到可核销的预约订单,请重新查询'); return false; } - if (!currentMeal.orderIds.contains(selectedOrderId)) { - _errorNotifier('所选预约订单已失效,请重新查询'); + final selectedOrderIdSet = selectedOrderIds.toSet(); + if (selectedOrderIdSet.isEmpty) { + _errorNotifier('请至少选择一笔预约订单'); return false; } + if (selectedOrderIdSet.length != selectedOrderIds.length || + !selectedOrderIdSet.every(currentMeal.orderIds.contains)) { + _errorNotifier('部分所选预约订单已失效,请重新查询'); + return false; + } + // 始终按当前查询结果中的服务端顺序提交,弹框回传列表只能决定选择范围,不能改变核销顺序。 + final orderedSelectedOrderIds = currentMeal.orderIds + .where(selectedOrderIdSet.contains) + .toList(growable: false); if (!_networkAvailable()) { _errorNotifier('核销需要联网,请检查网络后重试'); return false; @@ -1008,24 +1018,30 @@ class ReservationMenuQueryController extends GetxController { _verificationSubmitting = true; _stopPrivacyTimer(); try { - // selectedOrderId 只能来自当前点击餐段的订单选择器,避免误取其他餐段的 ID。 - final canceled = await _verificationGateway.cancel( - orderId: selectedOrderId, + // 所有订单 ID 均已重新通过当前餐段校验,禁止把日期、餐段或身份信息交给服务端参与订单选择。 + final verificationResult = await _verificationGateway.cancel( + orderIds: orderedSelectedOrderIds, faceEvidenceSessionId: faceEvidenceSessionId, ); - if (!canceled) throw StateError('订单 $selectedOrderId 核销接口未返回成功'); - if (_recognizedFaceEvidenceSessionId == faceEvidenceSessionId) { + if (!verificationResult.anySucceeded) { + throw StateError('所选预约订单核销接口均未返回成功'); + } + if (faceEvidenceSessionId != null && + _recognizedFaceEvidenceSessionId == faceEvidenceSessionId) { // 核销网关已把会话移交给持久化补传队列,后续页面清屏不得再取消这些任务。 _recognizedFaceEvidenceSessionId = null; } - unawaited(_announceVerificationSuccess()); + unawaited( + _announceVerificationSuccess(verificationResult), + ); + final successMessage = _verificationSuccessMessage(verificationResult); try { await _refreshAfterVerification( identityValue, method: recognitionMethod, ); - _successNotifier('核销成功,页面已刷新'); + _successNotifier('$successMessage,页面已刷新'); _startPrivacyTimer(); final scenario = status.value == ReservationMenuQueryStatus.empty ? 'empty' @@ -1035,7 +1051,7 @@ class ReservationMenuQueryController extends GetxController { // 核销已由服务端确认成功,后续刷新失败不能反向提示“核销失败”。 // 清掉旧结果可避免用户对同一笔订单再次点击,重新识别即可获取最新数据。 logger.w('|预约查询|核销成功但刷新失败error:|$error|stack:|$stack|'); - _successNotifier('核销成功,请重新识别刷新结果'); + _successNotifier('$successMessage,请重新识别刷新结果'); _clearStaleResultAfterVerification(); } return true; @@ -1054,6 +1070,15 @@ class ReservationMenuQueryController extends GetxController { } } + /// 根据逐笔结果生成面向操作员的成功摘要,单笔核销继续保留既有简短文案。 + String _verificationSuccessMessage(ReservationVerificationResult result) { + final successCount = result.succeededOrderIds.length; + final failedCount = result.failedOrderIds.length; + if (successCount == 1 && failedCount == 0) return '核销成功'; + if (failedCount == 0) return '已核销 $successCount 笔预约'; + return '已核销 $successCount 笔预约,$failedCount 笔未成功'; + } + /// 不信任弹框回传的餐段对象,重新从当前查询结果中定位餐段及其订单 ID。 /// 这样即使页面数据已经刷新或调用方传入了其他餐段的 ID,也不会发起错误核销。 ReservationMenuMealData? _findCurrentMeal( @@ -1082,9 +1107,19 @@ class ReservationMenuQueryController extends GetxController { return null; } - Future _announceVerificationSuccess() async { + /// 播报本次逐笔核销结果;单笔全部成功继续使用操作员熟悉的既有文案。 + Future _announceVerificationSuccess( + ReservationVerificationResult result, + ) async { try { - await _speaker('核销成功'); + final successCount = result.succeededOrderIds.length; + final failedCount = result.failedOrderIds.length; + final message = failedCount > 0 + ? '$successCount笔预约核销成功,$failedCount笔未成功' + : successCount == 1 + ? '核销成功' + : '$successCount笔预约核销成功'; + await _speaker(message); } catch (error, stack) { // 语音属于成功后的辅助反馈,TTS 异常不能改变核销结果。 logger.w('|预约查询|核销成功语音播报失败error:|$error|stack:|$stack|'); diff --git a/lib/pages/terminal/menu_query/reservation_verification_gateway.dart b/lib/pages/terminal/menu_query/reservation_verification_gateway.dart index 8d08408..c405f9b 100644 --- a/lib/pages/terminal/menu_query/reservation_verification_gateway.dart +++ b/lib/pages/terminal/menu_query/reservation_verification_gateway.dart @@ -2,10 +2,32 @@ import 'dart:async'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 一次操作员提交所选预约订单后的逐笔核销结果。 +class ReservationVerificationResult { + /// 创建保持订单原始提交顺序的批量核销结果。 + const ReservationVerificationResult({ + required this.succeededOrderIds, + required this.failedOrderIds, + }); + + /// 已由服务端确认核销成功的订单 ID。 + final List succeededOrderIds; + + /// 服务端未确认成功或尚未提交的订单 ID。 + final List failedOrderIds; + + /// 是否至少有一笔订单已经核销成功。 + bool get anySucceeded => succeededOrderIds.isNotEmpty; + + /// 是否全部所选订单均已核销成功。 + bool get allSucceeded => anySucceeded && failedOrderIds.isEmpty; +} + +/// 隔离查询页面与服务端预约核销、现场证据补传实现的业务网关。 abstract interface class ReservationVerificationGateway { - /// 核销当前查询结果中的预约订单,并在真实人脸识别时移交对应证据会话。 - Future cancel({ - required int orderId, + /// 逐笔核销当前查询结果中勾选的预约订单,并返回成功和失败范围。 + Future cancel({ + required List orderIds, String? faceEvidenceSessionId, }); @@ -16,36 +38,75 @@ abstract interface class ReservationVerificationGateway { }); } -/// 核销接口只接收订单 ID,日期、餐段、设备和识别信息均不得参与服务端订单选择。 +/// 服务端核销接口只接收单个订单 ID,终端按操作员勾选顺序逐笔提交并汇总结果。 class TerminalReservationVerificationGateway implements ReservationVerificationGateway { + /// 对所选订单执行单笔幂等核销;发生网络异常前已成功的订单会作为部分成功返回。 @override - Future cancel({ - required int orderId, + Future cancel({ + required List orderIds, String? faceEvidenceSessionId, }) async { - if (faceEvidenceSessionId != null) { - // 先落服务端订单 ID,确保核销响应丢失或进程退出后仍能恢复这次现场图片。 - await OrderEvidenceUploadTool.prepareSessionForExistingOrder( - recognitionSessionId: faceEvidenceSessionId, - serverOrderId: '$orderId', - ); + final normalizedOrderIds = orderIds.toSet().toList(growable: false); + final succeededOrderIds = []; + final failedOrderIds = []; + var pendingEvidenceSessionId = faceEvidenceSessionId; + for (var index = 0; index < normalizedOrderIds.length; index++) { + final orderId = normalizedOrderIds[index]; + try { + final evidenceSessionId = pendingEvidenceSessionId; + if (evidenceSessionId != null) { + // 本地证据模型按订单保存,同一次多选核销将现场证据绑定到首笔成功订单,其余订单沿用同一次操作确认。 + await OrderEvidenceUploadTool.prepareSessionForExistingOrder( + recognitionSessionId: evidenceSessionId, + serverOrderId: '$orderId', + ); + } + final canceled = await HardApi.cancelReservationOrder(orderId: orderId); + if (!canceled) { + failedOrderIds.add(orderId); + continue; + } + succeededOrderIds.add(orderId); + if (evidenceSessionId != null) { + pendingEvidenceSessionId = null; + await _markEvidenceReady( + recognitionSessionId: evidenceSessionId, + orderId: orderId, + ); + } + } catch (error, stack) { + if (succeededOrderIds.isEmpty) rethrow; + // 网络或服务异常后继续提交会扩大结果不确定性,保留当前及剩余订单供刷新后再次核销。 + failedOrderIds.addAll(normalizedOrderIds.skip(index)); + logger.w( + '|预约查询|批量核销部分成功|成功:${succeededOrderIds.length}|未成功:${failedOrderIds.length}|error:$error|stack:$stack|', + ); + break; + } } - final canceled = await HardApi.cancelReservationOrder(orderId: orderId); - if (!canceled || faceEvidenceSessionId == null) return canceled; + return ReservationVerificationResult( + succeededOrderIds: List.unmodifiable(succeededOrderIds), + failedOrderIds: List.unmodifiable(failedOrderIds), + ); + } + + /// 把首笔成功订单的人脸证据切换为可补传状态,本地失败不能反向覆盖服务端核销结果。 + Future _markEvidenceReady({ + required String recognitionSessionId, + required int orderId, + }) async { try { await OrderEvidenceUploadTool.markOrderReady( - recognitionSessionId: faceEvidenceSessionId, + recognitionSessionId: recognitionSessionId, serverOrderId: '$orderId', ); } catch (error) { - // 核销已经由服务端确认,不能因本地队列状态更新失败向操作员误报核销失败。 logger.w( '|预约查询|核销成功后更新人脸证据任务失败|errorType:${error.runtimeType}|', ); } unawaited(_uploadPendingEvidence()); - return true; } /// 删除当前身份尚未绑定订单的现场图,避免仅查询菜单时保留无业务用途的人脸图片。 diff --git a/lib/pages/terminal/menu_query/view.dart b/lib/pages/terminal/menu_query/view.dart index f122495..d71087d 100644 --- a/lib/pages/terminal/menu_query/view.dart +++ b/lib/pages/terminal/menu_query/view.dart @@ -20,7 +20,7 @@ Future _showResultDialogWithRecognitionPaused( required ReservationMenuDayData day, required ReservationMenuMealData meal, required bool verificationEnabled, - Future Function(int orderId)? onConfirm, + Future Function(List orderIds)? onConfirm, }) async { final acquired = await controller.beginResultDialogPresentation(); if (!acquired) return false; @@ -257,8 +257,8 @@ class ReservationMenuQueryPage extends GetView { meal: meal, verificationEnabled: verificationEnabled, onConfirm: verificationEnabled - ? (selectedOrderId) => - controller.confirmVerification(day, meal, selectedOrderId) + ? (selectedOrderIds) => + controller.confirmVerification(day, meal, selectedOrderIds) : null, ); } @@ -294,7 +294,7 @@ class _QuickVerificationEffectState extends State<_QuickVerificationEffect> { widget.controller.quickVerificationRequests.listen(_present); } - /// 使用当前页面上下文展示已有逐单核销弹框,提交仍复用 Controller 的防重复与失效校验。 + /// 使用当前页面上下文展示多选核销弹框,提交仍复用 Controller 的防重复与失效校验。 Future _present(ReservationQuickVerificationRequest request) async { if (!mounted || _presenting) return; _presenting = true; @@ -306,10 +306,10 @@ class _QuickVerificationEffectState extends State<_QuickVerificationEffect> { day: request.day, meal: request.meal, verificationEnabled: true, - onConfirm: (selectedOrderId) => widget.controller.confirmVerification( + onConfirm: (selectedOrderIds) => widget.controller.confirmVerification( request.day, request.meal, - selectedOrderId, + selectedOrderIds, ), ); } finally { diff --git a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart index 20d0953..3f53c94 100644 --- a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart +++ b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart @@ -12,7 +12,7 @@ Future reservationOrderDialog( required ReservationMenuDayData day, required ReservationMenuMealData meal, required bool verificationEnabled, - Future Function(int orderId)? onConfirm, + Future Function(List orderIds)? onConfirm, }) async { return await showAppDialog( context: context, @@ -35,7 +35,7 @@ Future reservationVerificationDialog( required ReservationMenuDisplayData result, required ReservationMenuDayData day, required ReservationMenuMealData meal, - required Future Function(int orderId) onConfirm, + required Future Function(List orderIds) onConfirm, }) { return reservationOrderDialog( context, @@ -47,7 +47,9 @@ Future reservationVerificationDialog( ); } +/// 承载预约商品核对、查看范围切换和订单勾选的模态工作区。 class _ReservationOrderDialog extends StatefulWidget { + /// 创建使用当前查询快照的预约订单弹框,提交回调仅在可核销模式使用。 const _ReservationOrderDialog({ required this.result, required this.day, @@ -68,14 +70,16 @@ class _ReservationOrderDialog extends StatefulWidget { /// 是否允许在弹框内提交选中的预约订单核销。 final bool verificationEnabled; - /// 可核销模式下按选中订单 ID 提交,并返回是否成功。 - final Future Function(int orderId)? onConfirm; + /// 可核销模式下按选中的订单 ID 列表提交,并返回是否成功。 + final Future Function(List orderIds)? onConfirm; + /// 创建管理查看范围、勾选集合和提交状态的弹框状态对象。 @override State<_ReservationOrderDialog> createState() => _ReservationOrderDialogState(); } +/// 预约订单弹框的本地交互状态,关闭后不持久化任何勾选或折叠结果。 class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { /// 已被操作员收起的套餐行标识;默认集合为空,因此套餐明细首次进入时全部展开。 final Set _collapsedComboKeys = {}; @@ -86,25 +90,54 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { /// 是否已经触发弹框关闭,避免重复 pop。 bool _closing = false; - /// 当前用户查看或准备核销的预约订单 ID。 - late int _selectedOrderId; + /// 当前准备核销的预约订单 ID;默认包含当前餐段的全部订单。 + late final Set _selectedOrderIds; + /// 只读详情模式下当前查看的预约订单 ID;为空表示查看全部订单商品。 + int? _displayedOrderId; + + /// 首次进入时默认勾选全部预约;核销模式的右侧商品始终跟随该勾选集合。 @override void initState() { super.initState(); - _selectedOrderId = widget.meal.orderIds.first; + _selectedOrderIds = widget.meal.orderIds.toSet(); } + /// 按服务端订单顺序返回当前已勾选的订单,避免集合顺序影响批量核销结果。 + List get _orderedSelectedOrderIds => widget.meal.orderIds + .where(_selectedOrderIds.contains) + .toList(growable: false); + + /// 当前是否勾选了餐段内的全部预约订单。 + bool get _allOrdersSelected => + _selectedOrderIds.length == widget.meal.orderIds.length; + + /// “全部”复选框状态;部分勾选时返回空值以展示不确定态。 + bool? get _allOrdersCheckboxValue => _selectedOrderIds.isEmpty + ? false + : _allOrdersSelected + ? true + : null; + + /// 核销模式展示勾选订单,只读模式展示当前浏览范围,并始终保持服务端订单顺序。 + List get _displayedOrderIds => widget.verificationEnabled + ? _orderedSelectedOrderIds + : _displayedOrderId == null + ? widget.meal.orderIds + : [_displayedOrderId!]; + + /// 提交当前勾选订单并根据 Controller 结果决定关闭弹框或恢复操作。 Future _confirm() async { final onConfirm = widget.onConfirm; if (!widget.verificationEnabled || onConfirm == null || + _selectedOrderIds.isEmpty || _submitting || _closing) { return; } setState(() => _submitting = true); - final success = await onConfirm(_selectedOrderId); + final success = await onConfirm(_orderedSelectedOrderIds); if (!mounted) return; if (success) { _close(true); @@ -113,9 +146,48 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { setState(() => _submitting = false); } - void _selectOrder(int orderId) { - if (_submitting || orderId == _selectedOrderId) return; - setState(() => _selectedOrderId = orderId); + /// 切换右侧商品明细的查看范围;空订单 ID 表示展示当前餐段全部订单。 + void _displayOrder(int? orderId) { + if (_submitting || orderId == _displayedOrderId) return; + setState(() => _displayedOrderId = orderId); + } + + /// 切换“全部”勾选状态;部分勾选时再次点击会恢复全选。 + void _toggleAllOrders(bool selected) { + if (_submitting) return; + setState(() { + if (selected == true) { + _selectedOrderIds.addAll(widget.meal.orderIds); + } else { + _selectedOrderIds.clear(); + } + }); + } + + /// 切换单笔预约订单的核销勾选状态,右侧商品范围随之同步。 + void _toggleOrder(int orderId, bool? selected) { + if (_submitting) return; + setState(() { + if (selected == true) { + _selectedOrderIds.add(orderId); + } else { + _selectedOrderIds.remove(orderId); + } + }); + } + + /// 核销模式下整行与复选框执行同一选择动作,只读模式下整行用于切换浏览范围。 + void _handleOrderNavigationTap(int? orderId) { + if (_submitting) return; + if (!widget.verificationEnabled) { + _displayOrder(orderId); + return; + } + if (orderId == null) { + _toggleAllOrders(!_allOrdersSelected); + return; + } + _toggleOrder(orderId, !_selectedOrderIds.contains(orderId)); } /// 切换指定套餐的明细显隐;空明细不提供无反馈的折叠操作。 @@ -128,6 +200,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { }); } + /// 按指定结果关闭弹框;提交期间只允许成功路径主动关闭。 void _close(bool result) { if (_closing || _submitting && !result) return; _closing = true; @@ -176,6 +249,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ); } + /// 构建标题、模式说明和关闭入口,提交期间禁用关闭以避免结果不确定。 Widget _header(FThemeData theme) { return [ FIcon( @@ -199,7 +273,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ), Text( widget.verificationEnabled - ? '先核对订单商品与数量,再确认本次核销。' + ? '勾选需要核销的预约,并核对所选订单商品与数量。' : '查看每笔预约的商品图片、名称与购买数量。', style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), ), @@ -221,6 +295,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ].toRowSpace(space: 14.w).padding(horizontal: 24.w, vertical: 18.h); } + /// 使用图标和文字共同标记当前是核销模式还是只读详情模式。 Widget _modeBadge(FThemeData theme) { final enabled = widget.verificationEnabled; return [ @@ -250,6 +325,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ); } + /// 构建用餐人、食堂、日期和餐段四项查询摘要。 Widget _summary(FThemeData theme) { final deptName = widget.result.deptName?.trim(); final information = <({SvgAsset icon, String label, String value})>[ @@ -285,6 +361,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { .decorated(color: XfjDesignTokens.canvas); } + /// 构建单项摘要并对过长内容执行省略,避免挤压相邻信息。 Widget _summaryItem( FThemeData theme, ({SvgAsset icon, String label, String value}) item, @@ -319,6 +396,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { .expanded(); } + /// 构建包含“全部”和逐笔预约的左侧导航,核销模式额外提供复选框。 Widget _orderNavigation(FThemeData theme) { return [ Text( @@ -329,20 +407,20 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ), ), Text( - widget.verificationEnabled ? '请选择本次需要核销的订单' : '点击订单查看对应商品明细', + widget.verificationEnabled ? '右侧同步展示已勾选预约商品' : '点击订单查看对应商品明细', style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), ), SizedBox(height: 12.h), - widget.meal.orderIds.indexed - .map( - (entry) => _orderNavigationItem( - theme, - index: entry.$1, - orderId: entry.$2, - ), - ) - .toList() - .toColumnSpace(space: 8.h), + [ + _orderNavigationItem(theme), + ...widget.meal.orderIds.indexed.map( + (entry) => _orderNavigationItem( + theme, + index: entry.$1, + orderId: entry.$2, + ), + ), + ].toColumnSpace(space: 8.h), ] .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) .padding(all: 14.w) @@ -353,51 +431,90 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ); } - /// 订单选择项使用图标、边框与文字共同表达选中态,避免只依赖蓝色区分。 + /// 核销模式用复选框和边框共同表达右侧商品范围,只读模式用边框表达浏览范围。 Widget _orderNavigationItem( FThemeData theme, { - required int index, - required int orderId, + int? index, + int? orderId, }) { - final selected = orderId == _selectedOrderId; - final items = widget.meal.itemsForOrder(orderId); + final isAll = orderId == null; + final checked = + isAll ? _allOrdersCheckboxValue : _selectedOrderIds.contains(orderId); + final displayed = widget.verificationEnabled + ? isAll + ? _allOrdersSelected + : !_allOrdersSelected && checked == true + : orderId == _displayedOrderId; + final orderIds = isAll ? widget.meal.orderIds : [orderId]; + final items = + orderIds.expand(widget.meal.itemsForOrder).toList(growable: false); final totalCount = items.fold( 0, (sum, item) => sum + (item.productCount ?? 0), ); + final title = isAll ? '全部' : '第 ${index! + 1} 笔预约'; + final detail = isAll + ? '${widget.meal.orderIds.length} 笔预约 · ${items.length} 种商品${totalCount > 0 ? ' · $totalCount 件' : ''}' + : '尾号 ${_orderIdSuffix(orderId)} · ${items.length} 种商品${totalCount > 0 ? ' · $totalCount 件' : ''}'; final radius = BorderRadius.circular(XfjDesignTokens.controlRadius); return Semantics( button: true, - selected: selected, - label: - '第 ${index + 1} 笔${widget.meal.title}预约,订单尾号 ${_orderIdSuffix(orderId)}', + selected: displayed, + label: isAll + ? '全部${widget.meal.title}预约,共 ${widget.meal.orderIds.length} 笔' + : '第 ${index! + 1} 笔${widget.meal.title}预约,订单尾号 ${_orderIdSuffix(orderId)}', child: Material( color: Colors.transparent, child: InkWell( - key: ValueKey('reservation-verification-order-$orderId'), - onTap: _submitting ? null : () => _selectOrder(orderId), + key: ValueKey( + isAll + ? 'reservation-verification-order-all' + : 'reservation-verification-order-$orderId', + ), + onTap: _submitting ? null : () => _handleOrderNavigationTap(orderId), borderRadius: radius, child: [ - FIcon( - selected ? FAssets.icons.circleCheck : FAssets.icons.receiptText, - color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, - size: 20.sp, - ).center().tightSize(38.w).decorated( - color: selected - ? XfjDesignTokens.primarySoft - : XfjDesignTokens.canvas, - borderRadius: BorderRadius.circular(10.w), + if (widget.verificationEnabled) + Checkbox( + key: ValueKey( + isAll + ? 'reservation-verification-checkbox-all' + : 'reservation-verification-checkbox-$orderId', ), + value: checked, + tristate: isAll, + onChanged: _submitting + ? null + : isAll + ? (_) => _toggleAllOrders(!_allOrdersSelected) + : (selected) => _toggleOrder(orderId, selected), + activeColor: XfjDesignTokens.primary, + checkColor: Colors.white, + side: const BorderSide(color: XfjDesignTokens.muted), + visualDensity: VisualDensity.compact, + ).tightSize(44.w) + else + FIcon( + isAll ? FAssets.icons.layers : FAssets.icons.receiptText, + color: + displayed ? XfjDesignTokens.primary : XfjDesignTokens.muted, + size: 20.sp, + ).center().tightSize(44.w).decorated( + color: displayed + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.canvas, + borderRadius: BorderRadius.circular(10.w), + ), [ Text( - '第 ${index + 1} 笔预约', + title, style: theme.typography.sm.copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w800, ), ), Text( - '尾号 ${_orderIdSuffix(orderId)} · ${items.length} 种商品${totalCount > 0 ? ' · $totalCount 件' : ''}', + detail, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.typography.xs.copyWith( @@ -405,49 +522,94 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ), ), ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), - FIcon( - FAssets.icons.chevronRight, - color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, - size: 16.sp, - ), + if (!widget.verificationEnabled) + FIcon( + FAssets.icons.chevronRight, + color: + displayed ? XfjDesignTokens.primary : XfjDesignTokens.muted, + size: 16.sp, + ), ].toRowSpace(space: 10.w).padding(horizontal: 12.w, vertical: 10.h), ), ).decorated( - color: selected ? XfjDesignTokens.surface : Colors.transparent, + color: displayed ? XfjDesignTokens.surface : Colors.transparent, border: Border.all( - color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, - width: selected ? 1.5 : 1, + color: displayed ? XfjDesignTokens.primary : XfjDesignTokens.line, + width: displayed ? 1.5 : 1, ), borderRadius: radius, - boxShadow: selected ? XfjDesignTokens.surfaceShadow : null, + boxShadow: displayed ? XfjDesignTokens.surfaceShadow : null, ), ); } + /// 构建当前查看范围的订单商品;“全部”模式保留订单顺序和每笔订单内的商品顺序。 + List<_DisplayedReservationOrderItem> _displayedItems() { + final displayed = <_DisplayedReservationOrderItem>[]; + for (final orderId in _displayedOrderIds) { + final orderIndex = widget.meal.orderIds.indexOf(orderId); + final items = widget.meal.itemsForOrder(orderId); + for (final entry in items.indexed) { + displayed.add( + _DisplayedReservationOrderItem( + orderId: orderId, + orderIndex: orderIndex, + itemIndex: entry.$1, + item: entry.$2, + ), + ); + } + } + return displayed; + } + + /// 构建当前查看范围的商品标题、数量摘要和可滚动菜谱列表。 Widget _cartPanel(FThemeData theme) { - final items = widget.meal.itemsForOrder(_selectedOrderId); - final hasDetails = widget.meal.hasItemsForOrder(_selectedOrderId); + final displayedOrderIds = _displayedOrderIds; + final hasSelection = displayedOrderIds.isNotEmpty; + final isAll = displayedOrderIds.length == widget.meal.orderIds.length; + final isSingle = displayedOrderIds.length == 1; + final singleOrderId = isSingle ? displayedOrderIds.single : null; + final singleOrderIndex = singleOrderId == null + ? -1 + : widget.meal.orderIds.indexOf(singleOrderId); + final title = !hasSelection + ? '未选择预约商品' + : isAll + ? '全部订单商品' + : isSingle + ? '第 ${singleOrderIndex + 1} 笔预约商品' + : '已选预约商品'; + final detail = !hasSelection + ? '当前未勾选预约订单' + : isAll + ? '当前餐段共 ${widget.meal.orderIds.length} 笔预约' + : isSingle + ? '订单尾号 ${_orderIdSuffix(singleOrderId!)}' + : '当前已选 ${displayedOrderIds.length} 笔预约'; + final items = _displayedItems(); + final hasDetails = displayedOrderIds.every(widget.meal.hasItemsForOrder); final totalCount = items.fold( 0, - (sum, item) => sum + (item.productCount ?? 0), + (sum, displayed) => sum + (displayed.item.productCount ?? 0), ); return [ [ [ Text( - '订单商品', + title, style: theme.typography.base.copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w800, ), ), Text( - '订单尾号 ${_orderIdSuffix(_selectedOrderId)}', + detail, style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), ), ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), Text( - hasDetails ? '${items.length} 种商品 · $totalCount 件' : '明细缺失', + hasDetails ? '${items.length} 种商品 · $totalCount 件' : '部分明细缺失', style: theme.typography.sm.copyWith( color: hasDetails ? XfjDesignTokens.primary : XfjDesignTokens.warning, @@ -456,7 +618,14 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ), ].toRowSpace(space: 12.w), const Divider(height: 24, color: XfjDesignTokens.line), - _cartContent(theme, items, hasDetails).expanded(), + _cartContent( + theme, + items, + hasDetails, + isAll, + hasSelection, + displayedOrderIds.length > 1, + ).expanded(), ] .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) .padding(all: 16.w) @@ -467,17 +636,32 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ); } + /// 根据明细完整性展示商品列表、缺失提示或空状态。 Widget _cartContent( FThemeData theme, - List items, + List<_DisplayedReservationOrderItem> items, bool hasDetails, + bool isAll, + bool hasSelection, + bool showOrderSource, ) { - if (!hasDetails) { + if (!hasSelection) { + return _emptyCartState( + theme, + icon: FAssets.icons.squareCheckBig, + title: '尚未勾选预约订单', + detail: '勾选左侧预约后,这里会同步显示对应商品。', + color: XfjDesignTokens.muted, + ); + } + if (!hasDetails && items.isEmpty) { return _emptyCartState( theme, icon: FAssets.icons.circleAlert, - title: '当前接口未返回订单商品明细', - detail: '暂时无法确认该订单的商品图片和数量,请按订单尾号核对。', + title: isAll ? '当前接口未返回全部订单商品明细' : '当前接口未返回订单商品明细', + detail: isAll + ? '暂时无法确认全部预约的商品图片和数量,请按订单尾号逐笔核对。' + : '暂时无法确认该订单的商品图片和数量,请按订单尾号核对。', color: XfjDesignTokens.warning, ); } @@ -493,15 +677,46 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { return ListView.separated( key: const ValueKey('reservation-order-product-list'), padding: EdgeInsets.zero, - itemCount: items.length, + itemCount: items.length + (hasDetails ? 0 : 1), separatorBuilder: (_, __) => const Divider( height: 1, color: XfjDesignTokens.line, ), - itemBuilder: (_, index) => _cartItem(theme, items[index], index), + itemBuilder: (_, index) { + if (!hasDetails && index == 0) { + return _missingDetailsNotice(theme); + } + final itemIndex = index - (hasDetails ? 0 : 1); + return _cartItem(theme, items[itemIndex], showOrderSource); + }, ); } + /// 在部分订单明细缺失时保留已有菜谱,并提示操作员继续按订单尾号核对。 + Widget _missingDetailsNotice(FThemeData theme) { + return [ + FIcon( + FAssets.icons.circleAlert, + color: XfjDesignTokens.warning, + size: 19.sp, + ), + Text( + '部分预约未返回商品明细,以下仅展示当前可读取的菜谱。', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.warning, + fontWeight: FontWeight.w700, + ), + ).expanded(), + ] + .toRowSpace(space: 8.w) + .padding(horizontal: 12.w, vertical: 10.h) + .decorated( + color: XfjDesignTokens.warning.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } + + /// 构建没有可展示商品时的图标、标题和恢复提示。 Widget _emptyCartState( FThemeData theme, { required SvgAsset icon, @@ -534,11 +749,12 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { /// 构建订单商品行;套餐主行本身承担折叠交互,组成菜品与普通商品保持同层列表布局。 Widget _cartItem( FThemeData theme, - ReservationMenuOrderItemData item, - int index, + _DisplayedReservationOrderItem displayed, + bool showOrderSource, ) { + final item = displayed.item; final comboKey = - '$_selectedOrderId-${item.productId ?? '${item.productName}-$index'}'; + '${displayed.orderId}-${item.productId ?? '${item.productName}-${displayed.itemIndex}'}'; final hasComboItems = item.comboItems.isNotEmpty; final comboExpanded = item.isCombo && !_collapsedComboKeys.contains(comboKey); @@ -566,6 +782,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { theme, item: item, expanded: comboExpanded, + orderIndex: showOrderSource ? displayed.orderIndex : null, ), ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), [ @@ -622,13 +839,17 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { FThemeData theme, { required ReservationMenuOrderItemData item, required bool expanded, + int? orderIndex, }) { final hasComboItems = item.comboItems.isNotEmpty; - final description = !item.isCombo + final productDescription = !item.isCombo ? '预约商品' : hasComboItems ? '套餐搭配 · ${item.comboItems.length} 样' : '套餐 · 暂无明细'; + final description = orderIndex == null + ? productDescription + : '第 ${orderIndex + 1} 笔预约 · $productDescription'; return [ Text( description, @@ -817,7 +1038,9 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { /// 构建核销提示与大尺寸操作按钮,满足横屏收银设备连续触控的命中要求。 Widget _footer(FThemeData theme) { final helper = widget.verificationEnabled - ? '只核销当前选中的 1 笔预约,核销后不可重复使用。' + ? _selectedOrderIds.isEmpty + ? '请至少勾选 1 笔预约后再确认核销。' + : '已选择 ${_selectedOrderIds.length} 笔预约,确认后将按所选订单核销且不可重复使用。' : '当前为详情查看模式,不会修改或核销任何预约订单。'; return [ [ @@ -858,7 +1081,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { if (widget.verificationEnabled) FButton.raw( key: const ValueKey('reservation-verification-confirm'), - onPress: _submitting ? null : _confirm, + onPress: _submitting || _selectedOrderIds.isEmpty ? null : _confirm, child: [ _submitting ? const FButtonSpinner() @@ -889,8 +1112,32 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ].toRowSpace(space: 12.w).padding(horizontal: 24.w, vertical: 16.h); } + /// 返回用于界面核对的订单尾号,短订单 ID 保持原值。 String _orderIdSuffix(int orderId) { final text = orderId.toString(); return text.length <= 6 ? text : text.substring(text.length - 6); } } + +/// 弹框右侧单条商品的订单来源,保证“全部”模式仍可标明每道菜属于哪笔预约。 +class _DisplayedReservationOrderItem { + /// 创建一条保留订单与原商品位置的只读展示记录。 + const _DisplayedReservationOrderItem({ + required this.orderId, + required this.orderIndex, + required this.itemIndex, + required this.item, + }); + + /// 当前商品所属的预约订单 ID。 + final int orderId; + + /// 当前订单在餐段订单列表中的从零开始位置。 + final int orderIndex; + + /// 当前商品在所属订单中的从零开始位置。 + final int itemIndex; + + /// 服务端返回并已规范化的预约商品数据。 + final ReservationMenuOrderItemData item; +} diff --git a/lib/pages/terminal/order_history/controller.dart b/lib/pages/terminal/order_history/controller.dart index 60d282f..24af8e9 100644 --- a/lib/pages/terminal/order_history/controller.dart +++ b/lib/pages/terminal/order_history/controller.dart @@ -43,6 +43,13 @@ class OrderHistoryController extends GetxController { RxList orderList = RxList([]); + /// 列表只展开一笔订单,避免多个大详情块同时挤压横屏操作区。 + final expandedOrderId = RxnInt(); + final expandedDetailStatus = OrderRecordDetailLoadStatus.success.obs; + final expandedShopCardList = [].obs; + final expandedDetailError = ''.obs; + int _expandedDetailRequestId = 0; + /// 交易详情在当前页面以右侧抽屉展示,不再占用独立路由。 Rx selectedOrder = Rx(null); @@ -77,6 +84,7 @@ class OrderHistoryController extends GetxController { @override void onClose() { + _collapseExpandedOrder(); super.onClose(); keywordC.dispose(); hourTypeC.dispose(); @@ -91,6 +99,7 @@ class OrderHistoryController extends GetxController { // 订单列表 Future getOrder() async { + _collapseExpandedOrder(); final appSetting = await AppSettingTool.getSysSetting(); final sysType = appSetting.sysType.name; final orderDao = appC.dataBase.getOrderDao; @@ -129,6 +138,59 @@ class OrderHistoryController extends GetxController { orderList.value = list; } + /// 展开列表订单并按需查询本地购物车;再次点击同一笔订单时收起。 + Future toggleExpandedOrder(OrderEntity order) async { + final id = order.id; + if (id == null) { + Toast.error(msg: '订单尚未写入本地数据库,无法查询购物车明细'); + return; + } + if (expandedOrderId.value == id) { + _collapseExpandedOrder(); + return; + } + expandedOrderId.value = id; + await _loadExpandedOrder(id); + } + + Future retryExpandedOrder() async { + final id = expandedOrderId.value; + if (id == null) return; + await _loadExpandedOrder(id); + } + + Future _loadExpandedOrder(int id) async { + final requestId = ++_expandedDetailRequestId; + expandedDetailStatus.value = OrderRecordDetailLoadStatus.loading; + expandedDetailError.value = ''; + expandedShopCardList.clear(); + try { + final items = await appC.dataBase.getShopCardDao.findByOrderId(id); + if (requestId != _expandedDetailRequestId || + expandedOrderId.value != id) { + return; + } + expandedShopCardList.assignAll(items); + expandedDetailStatus.value = OrderRecordDetailLoadStatus.success; + } catch (error, stack) { + if (requestId != _expandedDetailRequestId || + expandedOrderId.value != id) { + return; + } + expandedDetailError.value = '本地购物车读取失败,请重试'; + expandedDetailStatus.value = OrderRecordDetailLoadStatus.failure; + logger.e('|交易记录|展开购物车失败|orderId:$id|error:$error|stack:$stack|'); + } + } + + void _collapseExpandedOrder() { + _expandedDetailRequestId++; + expandedOrderId.value = null; + expandedShopCardList.clear(); + expandedDetailError.value = ''; + expandedDetailStatus.value = OrderRecordDetailLoadStatus.success; + } + Future doTab(OrderStatus i) async { if (i.code == currentStatus.value.code) return; currentStatus.value = i; diff --git a/lib/pages/terminal/order_history/view.dart b/lib/pages/terminal/order_history/view.dart index 1541c8e..b88e1a0 100644 --- a/lib/pages/terminal/order_history/view.dart +++ b/lib/pages/terminal/order_history/view.dart @@ -141,6 +141,7 @@ class OrderHistoryPage extends GetView { color: const Color(0xFFF7F8FA), child: _OrderGridRow( children: const [ + '明细', '下单时间', '订餐类型', '支付类型', @@ -171,7 +172,12 @@ class OrderHistoryPage extends GetView { padding: EdgeInsets.zero, itemCount: controller.orderList.length, itemBuilder: (context, index) { - return _buildOrderRow(context, controller.orderList[index]); + return Obx( + () => _buildOrderRow( + context, + controller.orderList[index], + ), + ); }, ).expanded(); }), @@ -183,102 +189,155 @@ class OrderHistoryPage extends GetView { Widget _buildOrderRow(BuildContext context, OrderEntity order) { final theme = FTheme.of(context); final hourType = ProductHourType.values.fromCode(order.hourType ?? '-1'); - return Container( - constraints: BoxConstraints(minHeight: 88.h), - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), - decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), - ), - child: _OrderGridRow( - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, + final expanded = + order.id != null && controller.expandedOrderId.value == order.id; + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220); + return Column( + children: [ + Container( + constraints: BoxConstraints(minHeight: 88.h), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + color: expanded + ? XfjDesignTokens.primarySoft.withValues(alpha: 0.28) + : Colors.white, + child: _OrderGridRow( children: [ - Text( - DateUtil.formatDate( - order.createTime, - format: DateFormats.y_mo_d, - ), - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w600, - ), + OrderRecordExpandButton( + expanded: expanded, + semanticLabel: '${order.serviceUniqueCode}订单明细', + onTap: () => controller.toggleExpandedOrder(order), ), - SizedBox(height: 4.h), - Text( - DateUtil.formatDate(order.createTime, - format: DateFormats.h_m_s), - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.muted, - ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateUtil.formatDate( + order.createTime, + format: DateFormats.y_mo_d, + ), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 4.h), + Text( + DateUtil.formatDate( + order.createTime, + format: DateFormats.h_m_s, + ), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], ), - ], - ), - Text(order.type.title), - Text(order.payType.title, - maxLines: 1, overflow: TextOverflow.ellipsis), - Text(order.username ?? '未知用户'), - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ Text( - order.productDesc ?? '未知商品', + order.type.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w600, - ), ), - SizedBox(height: 6.h), - Row( + Text(order.payType.title, + maxLines: 1, overflow: TextOverflow.ellipsis), + Text( + order.username ?? '未知用户', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _OrderStatusBadge(status: order.status), - if (hourType != null) ...[ - SizedBox(width: 8.w), - Text( - hourType.title, - style: theme.typography.xs.copyWith( - color: XfjDesignTokens.muted, - ), + Text( + order.productDesc ?? '未知商品', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, ), - ], + ), + SizedBox(height: 6.h), + Row( + children: [ + _OrderStatusBadge(status: order.status), + if (hourType != null) ...[ + SizedBox(width: 8.w), + Text( + hourType.title, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ], + ), + ], + ), + appPrice(context, order.price), + appPrice(context, order.extraPrice), + appPrice(context, order.payPrice), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _TableActionButton( + label: '退款', + danger: true, + enabled: OrderStatus.successPayCodes().contains( + order.status.code, + ) && + DateTime.now().difference(order.createTime).inHours <= + 4 && + controller.sysType.value != SysType.SUPERMARKET, + onTap: () => controller.doRefund(order), + ).expanded(flex: 2), + SizedBox(width: 4.w), + _TableActionButton( + label: '打印发票', + onTap: () => controller.doPrint(order), + ).expanded(flex: 3), + SizedBox(width: 4.w), + _TableActionButton( + label: '完整详情', + onTap: () => controller.doDetail(order), + ).expanded(flex: 3), ], ), ], ), - appPrice(context, order.price), - appPrice(context, order.extraPrice), - appPrice(context, order.payPrice), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _TableActionButton( - label: '退款', - danger: true, - enabled: OrderStatus.successPayCodes().contains( - order.status.code, - ) && - DateTime.now().difference(order.createTime).inHours <= 4 && - controller.sysType.value != SysType.SUPERMARKET, - onTap: () => controller.doRefund(order), - ), - SizedBox(width: 8.w), - _TableActionButton( - label: '打印发票', - onTap: () => controller.doPrint(order), - ), - SizedBox(width: 8.w), - _TableActionButton( - label: '详情', - onTap: () => controller.doDetail(order), - ), - ], - ), - ], - ), + ), + AnimatedSize( + duration: duration, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: expanded + ? OrderRecordCollapsePanel( + status: controller.expandedDetailStatus.value, + items: controller.expandedShopCardList + .map( + (item) => OrderRecordDetailItem( + name: item.productName, + quantity: item.nature == ProductNature.DEFAULT + ? '${item.productNumber}' + : '${item.productWidget}/kg', + unitPrice: item.productPrice, + totalPrice: item.totalPrice, + ), + ) + .toList(growable: false), + totalPrice: order.price, + extraPrice: order.extraPrice, + payPrice: order.payPrice, + errorMessage: controller.expandedDetailError.value, + onRetry: controller.retryExpandedOrder, + ) + : const SizedBox.shrink(), + ), + const Divider(height: 1, color: XfjDesignTokens.line), + ], ); } @@ -408,7 +467,7 @@ class _OrderGridRow extends StatelessWidget { @override Widget build(BuildContext context) { - const flexes = [17, 11, 15, 9, 23, 10, 10, 11, 24]; + const flexes = [7, 16, 10, 14, 8, 21, 9, 9, 10, 23]; return Row( children: List.generate( children.length, @@ -542,8 +601,8 @@ class _TableActionButton extends StatelessWidget { onTap: enabled ? onTap : null, borderRadius: BorderRadius.circular(10), child: Container( - constraints: BoxConstraints(minHeight: 40.h), - padding: EdgeInsets.symmetric(horizontal: 12.w), + constraints: BoxConstraints(minHeight: 48.h), + padding: EdgeInsets.symmetric(horizontal: 6.w), decoration: BoxDecoration( color: Colors.white, border: Border.all( @@ -553,12 +612,16 @@ class _TableActionButton extends StatelessWidget { ), borderRadius: BorderRadius.circular(10), ), - child: Text( - label, - style: FTheme.of(context).typography.sm.copyWith( - color: color, - fontWeight: FontWeight.w600, - ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + label, + maxLines: 1, + style: FTheme.of(context).typography.sm.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), ).center(), ), ); @@ -632,163 +695,378 @@ class _OrderDetailDrawer extends StatelessWidget { color: Colors.black.withValues(alpha: 0.35), child: Align( alignment: Alignment.centerRight, - child: Container( - width: 1040.w, - color: XfjDesignTokens.canvas, - child: Column( - children: [ - Container( - height: 80.h, - padding: EdgeInsets.symmetric(horizontal: 24.w), - decoration: const BoxDecoration( - color: Colors.white, - border: Border( - bottom: BorderSide(color: XfjDesignTokens.line), - ), - ), - child: Row( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 1240.w), + child: SizedBox( + width: double.infinity, + child: SafeArea( + child: ColoredBox( + color: XfjDesignTokens.canvas, + child: Column( children: [ - Container( - width: 48.w, - height: 48.w, - decoration: BoxDecoration( - color: XfjDesignTokens.primarySoft, - borderRadius: BorderRadius.circular(12), - ), - child: FIcon( - FAssets.icons.fileText, - color: XfjDesignTokens.primary, - ).center(), - ), - SizedBox(width: 16.w), - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '订单详情', - style: theme.typography.xl2.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w800, - ), - ), - Text( - '订单号:${order.serviceUniqueCode}', - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.muted, - ), - ), - ], - ), - const Spacer(), - InkWell( - onTap: controller.closeDetail, - child: FIcon( - FAssets.icons.x, - color: XfjDesignTokens.muted, - size: 26.w, - ).center().width(48.w).height(48.w), - ), - ], - ), - ), - Padding( - padding: EdgeInsets.all(16.w), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TerminalSurface( - width: 350.w, + _buildDetailHeader(context, order, theme), + Padding( padding: EdgeInsets.all(16.w), - child: ListView( - children: [ - _DetailLine( - label: '机器订单 ID', value: '${order.id ?? ''}'), - _DetailLine( - label: '服务端唯一标识', - value: order.serviceUniqueCode), - _DetailLine(label: '用户唯一标识', value: order.userCode), - _DetailLine( - label: '用户名', value: order.username ?? '未知用户'), - _DetailLine(label: '订单类型', value: order.type.title), - _DetailLine( - label: '订单状态', value: order.status.title), - _DetailLine( - label: '支付方式', value: order.payType.title), - _DetailLine( - label: '商品总数量', value: '${order.count}'), - _DetailLine( - label: '创建时间', - value: DateUtil.formatDate(order.createTime), - ), - ], - ), - ), - SizedBox(width: XfjDesignTokens.cardGap.w), - TerminalSurface( - padding: EdgeInsets.all(16.w), - child: Column( - children: [ - Text( - '商品明细', - style: theme.typography.xl.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w800, - ), - ).alignLeft(), - SizedBox(height: 12.h), - Obx( - () => ListView.separated( - itemCount: - controller.selectedShopCardList.length, - separatorBuilder: (context, index) => - const Divider( - color: XfjDesignTokens.line, - height: 1, - ), - itemBuilder: (context, index) { - final item = - controller.selectedShopCardList[index]; - return SizedBox( - height: 70.h, - child: Row( - children: [ - Text(item.productName).expanded(), - Text( - item.nature == ProductNature.DEFAULT - ? '${item.productNumber}' - : '${item.productWidget}/kg', - ).width(100.w), - appPrice(context, item.productPrice) - .width(120.w), - appPrice(context, item.totalPrice) - .width(120.w), - ], - ), - ); - }, - ), - ).expanded(), - orderAllPrice( + child: LayoutBuilder( + builder: (context, constraints) { + final sideBySide = constraints.maxWidth >= 720.w; + final orderInfo = _buildDetailOrderInfo(order); + final productInfo = _buildDetailProducts( context, - totalPrice: order.price, - amountMealPrice: order.extraPrice, - payPrice: order.payPrice, - productPrice: '商品总价', - ), - ], + order, + theme, + ); + if (!sideBySide) { + return [ + orderInfo.expanded(flex: 5), + SizedBox(height: 12.h), + productInfo.expanded(flex: 7), + ].toColumn( + crossAxisAlignment: CrossAxisAlignment.stretch, + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + orderInfo.expanded(flex: 4), + SizedBox(width: 12.w), + productInfo.expanded(flex: 6), + ], + ); + }, ), ).expanded(), ], ), - ).expanded(), - ], + ), + ), ), ), ), ), ); } + + /// 构建可容纳长订单号的详情头部,并保留标准触控尺寸的关闭入口。 + Widget _buildDetailHeader( + BuildContext context, + OrderEntity order, + FThemeData theme, + ) { + return Container( + constraints: BoxConstraints(minHeight: 64.h), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h), + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide(color: XfjDesignTokens.line), + ), + ), + child: Row( + children: [ + Container( + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(12), + ), + child: FIcon( + FAssets.icons.fileText, + color: XfjDesignTokens.primary, + ).center(), + ), + SizedBox(width: 12.w), + Expanded( + child: Tooltip( + message: '订单号:${order.serviceUniqueCode}', + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '订单详情', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + Text( + '订单号:${order.serviceUniqueCode}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + ), + SizedBox(width: 8.w), + Semantics( + button: true, + label: '关闭订单详情', + child: InkWell( + onTap: controller.closeDetail, + borderRadius: BorderRadius.circular(10), + child: FIcon( + FAssets.icons.x, + color: XfjDesignTokens.muted, + size: 24.w, + ).center().constrained( + minWidth: 48.w, + minHeight: 48.h, + ), + ), + ), + ], + ), + ); + } + + /// 构建独立滚动的订单基础信息区,避免字段较多时挤压商品明细。 + Widget _buildDetailOrderInfo(OrderEntity order) { + return TerminalSurface( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + child: ListView( + padding: EdgeInsets.zero, + children: [ + _DetailLine(label: '机器订单 ID', value: '${order.id ?? ''}'), + _DetailLine(label: '服务端唯一标识', value: order.serviceUniqueCode), + _DetailLine(label: '用户唯一标识', value: order.userCode), + _DetailLine(label: '用户名', value: order.username ?? '未知用户'), + _DetailLine(label: '订单类型', value: order.type.title), + _DetailLine(label: '订单状态', value: order.status.title), + _DetailLine(label: '支付方式', value: order.payType.title), + _DetailLine( + label: '用餐时段', + value: ProductHourType.values + .fromCode(order.hourType ?? '-1') + ?.title ?? + '不区分', + ), + _DetailLine(label: '商品总数量', value: '${order.count}'), + _DetailLine( + label: '创建时间', + value: DateUtil.formatDate(order.createTime), + ), + ], + ), + ); + } + + /// 构建商品明细和固定在区域底部的金额汇总。 + Widget _buildDetailProducts( + BuildContext context, + OrderEntity order, + FThemeData theme, + ) { + return TerminalSurface( + padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 12.h), + child: Column( + children: [ + Text( + '商品明细', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ).alignLeft(), + SizedBox(height: 10.h), + _detailProductRow( + context, + theme, + name: '商品', + quantity: '数量/重量', + unitPrice: '单价', + totalPrice: '小计', + header: true, + ), + SizedBox(height: 4.h), + Obx( + () => ListView.separated( + padding: EdgeInsets.zero, + itemCount: controller.selectedShopCardList.length, + separatorBuilder: (context, index) => const Divider( + color: XfjDesignTokens.line, + height: 1, + ), + itemBuilder: (context, index) { + final item = controller.selectedShopCardList[index]; + return _detailProductRow( + context, + theme, + name: item.productName, + quantity: item.nature == ProductNature.DEFAULT + ? '${item.productNumber}' + : '${item.productWidget}/kg', + unitPrice: item.productPrice, + totalPrice: item.totalPrice, + ); + }, + ), + ).expanded(), + _detailPriceSummary(context, theme, order), + ], + ), + ); + } + + /// 构建单行商品数据,商品名弹性占位,数量和金额保持稳定列宽。 + Widget _detailProductRow( + BuildContext context, + FThemeData theme, { + required String name, + required String quantity, + required dynamic unitPrice, + required dynamic totalPrice, + bool header = false, + }) { + final textStyle = theme.typography.sm.copyWith( + color: header ? XfjDesignTokens.muted : XfjDesignTokens.ink, + fontWeight: header ? FontWeight.w700 : FontWeight.w500, + fontFeatures: const [FontFeature.tabularFigures()], + ); + return SizedBox( + height: header ? 34.h : 58.h, + child: Row( + children: [ + Expanded( + child: Tooltip( + message: name, + child: Text( + name, + maxLines: header ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), + ), + ), + SizedBox(width: 8.w), + SizedBox( + width: 82.w, + child: Text( + quantity, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: textStyle, + ), + ), + SizedBox(width: 8.w), + SizedBox( + width: 92.w, + child: header + ? Text( + unitPrice as String, + maxLines: 1, + textAlign: TextAlign.right, + style: textStyle, + ) + : _detailPriceValue(context, unitPrice, textStyle), + ), + SizedBox(width: 8.w), + SizedBox( + width: 92.w, + child: header + ? Text( + totalPrice as String, + maxLines: 1, + textAlign: TextAlign.right, + style: textStyle, + ) + : _detailPriceValue(context, totalPrice, textStyle), + ), + ], + ), + ); + } + + /// 构建不会超出金额列宽的价格文本。 + Widget _detailPriceValue( + BuildContext context, + dynamic price, + TextStyle textStyle, + ) { + return Align( + alignment: Alignment.centerRight, + child: FittedBox( + fit: BoxFit.scaleDown, + child: appPrice(context, price, textStyle: textStyle), + ), + ); + } + + /// 构建紧凑纵向金额汇总,替代旧组件的无约束横向排列。 + Widget _detailPriceSummary( + BuildContext context, + FThemeData theme, + OrderEntity order, + ) { + return Container( + margin: EdgeInsets.only(top: 10.h), + padding: EdgeInsets.only(top: 10.h), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: XfjDesignTokens.primary)), + ), + child: [ + _detailSummaryLine(context, theme, '商品总价', order.price), + _detailSummaryLine(context, theme, '额外支付', order.extraPrice), + _detailSummaryLine( + context, + theme, + '实际扣款', + order.payPrice, + emphasized: true, + ), + ].toColumn(), + ); + } + + /// 构建一项金额汇总,并突出实际扣款。 + Widget _detailSummaryLine( + BuildContext context, + FThemeData theme, + String label, + int price, { + bool emphasized = false, + }) { + final textStyle = + (emphasized ? theme.typography.lg : theme.typography.base).copyWith( + color: emphasized ? XfjDesignTokens.ink : XfjDesignTokens.muted, + fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600, + ); + return Container( + constraints: BoxConstraints(minHeight: 38.h), + padding: EdgeInsets.symmetric(vertical: 4.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle.copyWith(height: 1.15), + ), + SizedBox(width: 8.w), + Flexible( + child: Align( + alignment: Alignment.centerRight, + child: FittedBox( + fit: BoxFit.scaleDown, + child: appPrice(context, price, textStyle: textStyle), + ), + ), + ), + ], + ), + ); + } } class _DetailLine extends StatelessWidget { @@ -806,17 +1084,24 @@ class _DetailLine extends StatelessWidget { children: [ Text( label, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: FTheme.of(context).typography.sm.copyWith( color: XfjDesignTokens.muted, ), ), SizedBox(height: 6.h), - Text( - value, - style: FTheme.of(context).typography.base.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w600, - ), + Tooltip( + message: value, + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: FTheme.of(context).typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), ), ], ), diff --git a/lib/pages/terminal/setting/widgets/setting_face.dart b/lib/pages/terminal/setting/widgets/setting_face.dart index f18c1ad..9e98781 100644 --- a/lib/pages/terminal/setting/widgets/setting_face.dart +++ b/lib/pages/terminal/setting/widgets/setting_face.dart @@ -383,7 +383,12 @@ class FaceManageController extends GetxController { /// 同步、检测和删除共同占用人脸维护区,防止并发操作造成状态交叉。 bool get operationInProgress => - isSyncing.value || isAuditing.value || isDeleting.value; + isSyncing.value || + isAuditing.value || + isDeleting.value || + SyncCheckTool.instance.runningTasks.contains( + ScheduledTaskType.departmentFaceSync, + ); /// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。 Future delAllFace() async { @@ -462,14 +467,29 @@ class FaceManageController extends GetxController { } } -/// 构建两个稳定尺寸、带选中语义的人脸管理文字 Tab。 -Widget _buildFaceTabs(BuildContext context) { +/// 构建紧凑的人脸视图切换栏,并在低质量页同行展示质量摘要。 +Widget _buildFaceHeader(BuildContext context) { final controller = Get.find(); return Obx( - () => FaceManageTabBar( - selectedTab: controller.selectedTab.value, - onChanged: controller.switchTab, - ), + () { + final lowQuality = + controller.selectedTab.value == FaceManageTab.lowQuality; + return Row( + children: [ + FaceManageTabBar( + selectedTab: controller.selectedTab.value, + onChanged: controller.switchTab, + ), + if (lowQuality) ...[ + SizedBox(width: 12.w), + SizedBox( + width: 640.w, + child: _buildQualitySummary(controller), + ), + ], + ], + ); + }, ); } @@ -492,53 +512,68 @@ class FaceManageTabBar extends StatelessWidget { @override Widget build(BuildContext context) { final theme = FTheme.of(context); - return Container( - constraints: const BoxConstraints(minHeight: 56), - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: XfjDesignTokens.control, - borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), - ), - child: Row( - children: FaceManageTab.values.map((tab) { - final selected = selectedTab == tab; - final label = tab == FaceManageTab.all ? '人脸管理' : '低质量人脸'; - return Expanded( - child: Semantics( - button: true, - selected: selected, - excludeSemantics: true, - label: '$label Tab${selected ? ',已选中' : ''}', - child: InkWell( - onTap: () => onChanged(tab), - borderRadius: BorderRadius.circular( - XfjDesignTokens.controlRadius, - ), - child: Container( - constraints: const BoxConstraints(minHeight: 48), - alignment: Alignment.center, - decoration: BoxDecoration( - color: - selected ? XfjDesignTokens.surface : Colors.transparent, + return SizedBox( + height: 48, + width: 320.w, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + height: 36, + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: + BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + ), + Row( + children: FaceManageTab.values.map((tab) { + final selected = selectedTab == tab; + final label = tab == FaceManageTab.all ? '人脸管理' : '低质量人脸'; + return Expanded( + child: Semantics( + button: true, + selected: selected, + excludeSemantics: true, + label: '$label Tab${selected ? ',已选中' : ''}', + child: InkWell( + onTap: () => onChanged(tab), borderRadius: BorderRadius.circular( XfjDesignTokens.controlRadius, ), - boxShadow: selected ? XfjDesignTokens.surfaceShadow : null, - ), - child: Text( - label, - style: theme.typography.base.copyWith( - color: selected - ? XfjDesignTokens.primary - : XfjDesignTokens.muted, - fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + child: Center( + child: Container( + height: 36, + width: double.infinity, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? XfjDesignTokens.surface + : Colors.transparent, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + boxShadow: + selected ? XfjDesignTokens.surfaceShadow : null, + ), + child: Text( + label, + style: theme.typography.sm.copyWith( + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w600, + ), + ), + ), ), ), ), - ), - ), - ); - }).toList(growable: false), + ); + }).toList(growable: false), + ), + ], ), ); } @@ -546,7 +581,8 @@ class FaceManageTabBar extends StatelessWidget { /// 构建当前系统类型适配的姓名搜索框。 Widget _buildFaceNameField(FaceManageController controller) => Obx( - () => FTextField( + () => TerminalToolbarTextField( + width: 300.w, textInputAction: controller.sysType.value == SysType.SUPERMARKET ? TextInputAction.none : TextInputAction.newline, @@ -555,68 +591,102 @@ Widget _buildFaceNameField(FaceManageController controller) => Obx( : TextInputType.multiline, controller: controller.usernameC, hint: '请输入姓名', - maxLines: 1, onSubmit: (_) => controller.doPage(), ), - ).width(300.w); + ); /// 构建人脸搜索与既有同步操作区;全租户同步仅在非生产调试环境出现。 -Widget buildFaceSearch(BuildContext context) { +Widget buildFaceSearch(BuildContext _) { final controller = Get.find(); - final theme = FTheme.of(context); return Obx(() { final busy = controller.operationInProgress; return [ _buildFaceNameField(controller), - FButton( - prefix: FIcon(FAssets.icons.ban, size: theme.typography.base.fontSize), - label: const Text('重置'), - onPress: busy ? null : controller.doReset, + TerminalToolbarButton( + label: '重置', + icon: FAssets.icons.ban, + style: TerminalToolbarButtonStyle.secondary, + onTap: busy ? null : controller.doReset, ), - FButton( - prefix: - FIcon(FAssets.icons.search, size: theme.typography.base.fontSize), - label: const Text('搜索'), - onPress: busy ? null : controller.doPage, + TerminalToolbarButton( + label: '搜索', + icon: FAssets.icons.search, + onTap: busy ? null : controller.doPage, ), - FButton( - prefix: FIcon( - FAssets.icons.scanFace, - size: theme.typography.base.fontSize, - ), - label: Text(controller.isSyncing.value ? '同步中...' : '同步本部门人脸'), - style: FButtonStyle.outline, - onPress: busy ? null : () => controller.asyncFace(isAll: false), + TerminalToolbarButton( + label: controller.isSyncing.value ? '同步中...' : '同步本部门人脸', + icon: FAssets.icons.scanFace, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 188.w, + onTap: busy ? null : () => controller.asyncFace(isAll: false), ), if (!AppSysTool.isProd()) - FButton( - prefix: FIcon( - FAssets.icons.scanFace, - size: theme.typography.base.fontSize, - ), - label: const Text('同步全部人脸'), - style: FButtonStyle.outline, - onPress: busy ? null : () => controller.asyncFace(isAll: true), + TerminalToolbarButton( + label: '同步全部人脸', + icon: FAssets.icons.scanFace, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 176.w, + onTap: busy ? null : () => controller.asyncFace(isAll: true), ), const Spacer(), - FButton( - prefix: FIcon( - FAssets.icons.trash2, - color: Colors.white, - size: theme.typography.base.fontSize, - ), - label: const Text('删除全部人脸'), - style: FButtonStyle.destructive, - onPress: busy ? null : controller.delAllFace, + TerminalToolbarButton( + label: '删除全部人脸', + icon: FAssets.icons.trash2, + style: TerminalToolbarButtonStyle.destructive, + minWidth: 176.w, + onTap: busy ? null : controller.delAllFace, ), ].toRowSpace(space: 12.w); }); } -/// 构建低质量页摘要、搜索和互斥操作区。 -Widget _buildLowQualityOperations(BuildContext context) { +/// 构建低质量页的单行只读质量摘要。 +Widget _buildQualitySummary( + FaceManageController controller, +) { + return Container( + height: 36, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + _QualityMetric( + label: '当前阈值', + value: controller.libraryQualityThreshold.value.toStringAsFixed(2), + icon: FAssets.icons.slidersHorizontal, + color: XfjDesignTokens.primary, + flex: 2, + ), + _QualityMetric( + label: '低质量', + value: '${controller.lowQualityCount.value}', + icon: FAssets.icons.triangleAlert, + color: XfjDesignTokens.danger, + ), + _QualityMetric( + label: '待检测', + value: '${controller.pendingQualityCount.value}', + icon: FAssets.icons.image, + color: XfjDesignTokens.warning, + ), + _QualityMetric( + label: '检测失败', + value: '${controller.failedQualityCount.value}', + icon: FAssets.icons.circleAlert, + color: XfjDesignTokens.danger, + showDivider: false, + ), + ], + ), + ); +} + +/// 构建低质量页搜索和互斥操作区。 +Widget _buildLowQualityOperations(BuildContext _) { final controller = Get.find(); - final theme = FTheme.of(context); return Obx(() { final busy = controller.operationInProgress; final pending = controller.pendingQualityCount.value; @@ -626,95 +696,49 @@ Widget _buildLowQualityOperations(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Wrap( - spacing: 8.w, - runSpacing: 8.h, - children: [ - _QualityMetric( - label: '当前阈值', - value: - controller.libraryQualityThreshold.value.toStringAsFixed(2), - icon: FAssets.icons.slidersHorizontal, - color: XfjDesignTokens.primary, - ), - _QualityMetric( - label: '低质量', - value: '$low', - icon: FAssets.icons.triangleAlert, - color: XfjDesignTokens.danger, - ), - _QualityMetric( - label: '待检测', - value: '$pending', - icon: FAssets.icons.image, - color: XfjDesignTokens.warning, - ), - _QualityMetric( - label: '检测失败', - value: '$failed', - icon: FAssets.icons.circleAlert, - color: XfjDesignTokens.danger, - ), - ], - ), - SizedBox(height: 8.h), Row( children: [ _buildFaceNameField(controller), SizedBox(width: 12.w), - FButton( - prefix: FIcon( - FAssets.icons.ban, - size: theme.typography.base.fontSize, - ), - label: const Text('重置'), - onPress: busy ? null : controller.doReset, + TerminalToolbarButton( + label: '重置', + icon: FAssets.icons.ban, + style: TerminalToolbarButtonStyle.secondary, + onTap: busy ? null : controller.doReset, ), SizedBox(width: 12.w), - FButton( - prefix: FIcon( - FAssets.icons.search, - size: theme.typography.base.fontSize, - ), - label: const Text('搜索'), - onPress: busy ? null : controller.doPage, + TerminalToolbarButton( + label: '搜索', + icon: FAssets.icons.search, + onTap: busy ? null : controller.doPage, ), const Spacer(), - FButton( - prefix: FIcon( - FAssets.icons.scanFace, - size: theme.typography.base.fontSize, - ), - label: Text( - controller.isAuditing.value ? '检测中...' : '检测存量人脸', - ), - style: FButtonStyle.outline, - onPress: busy || (pending + failed == 0) + TerminalToolbarButton( + label: controller.isAuditing.value ? '检测中...' : '检测存量人脸', + icon: FAssets.icons.scanFace, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 176.w, + onTap: busy || (pending + failed == 0) ? null : controller.auditLibraryFaces, ), SizedBox(width: 12.w), - FButton( - prefix: FIcon( - FAssets.icons.refreshCw, - size: theme.typography.base.fontSize, - ), - label: const Text('失败重试'), - style: FButtonStyle.outline, - onPress: busy || failed == 0 + TerminalToolbarButton( + label: '失败重试', + icon: FAssets.icons.refreshCw, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 140.w, + onTap: busy || failed == 0 ? null : () => controller.auditLibraryFaces(failedOnly: true), ), SizedBox(width: 12.w), - FButton( - prefix: FIcon( - FAssets.icons.trash2, - color: Colors.white, - size: theme.typography.base.fontSize, - ), - label: Text(controller.isDeleting.value ? '删除中...' : '一键删除'), - style: FButtonStyle.destructive, - onPress: + TerminalToolbarButton( + label: controller.isDeleting.value ? '删除中...' : '一键删除', + icon: FAssets.icons.trash2, + style: TerminalToolbarButtonStyle.destructive, + minWidth: 140.w, + onTap: busy || low == 0 ? null : controller.deleteAllLowQualityFaces, ), ], @@ -750,44 +774,61 @@ class _QualityMetric extends StatelessWidget { required this.value, required this.icon, required this.color, + this.flex = 1, + this.showDivider = true, }); final String label; final String value; final SvgAsset icon; final Color color; + final int flex; + final bool showDivider; @override Widget build(BuildContext context) { final theme = FTheme.of(context); - return Container( - constraints: BoxConstraints(minWidth: 160.w, minHeight: 56.h), - padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h), - decoration: BoxDecoration( - color: XfjDesignTokens.surface, - border: Border.all(color: XfjDesignTokens.line), - borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FIcon(icon, color: color, size: 20.sp), - SizedBox(width: 8.w), - Text( - label, - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.muted, - ), + return Expanded( + flex: flex, + child: Semantics( + container: true, + label: '$label $value', + excludeSemantics: true, + child: Container( + height: 34, + padding: EdgeInsets.symmetric(horizontal: 8.w), + decoration: BoxDecoration( + border: showDivider + ? const Border( + right: BorderSide(color: XfjDesignTokens.line), + ) + : null, ), - SizedBox(width: 12.w), - Text( - value, - style: theme.typography.lg.copyWith( - color: color, - fontWeight: FontWeight.w800, - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FIcon(icon, color: color, size: 14.sp), + SizedBox(width: 4.w), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + SizedBox(width: 4.w), + Text( + value, + style: theme.typography.xs.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], ), - ], + ), ), ); } @@ -944,18 +985,20 @@ Widget buildFaceList(BuildContext context) { SizedBox(height: 8.h), SizedBox( width: double.infinity, - height: 48.h, - child: FButton( - prefix: FIcon( - FAssets.icons.trash2, - color: Colors.white, - size: 18.sp, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: FButton( + prefix: FIcon( + FAssets.icons.trash2, + color: Colors.white, + size: 18.sp, + ), + label: const Text('删除'), + style: FButtonStyle.destructive, + onPress: controller.operationInProgress + ? null + : () => controller.deleteLowQualityFace(face), ), - label: const Text('删除'), - style: FButtonStyle.destructive, - onPress: controller.operationInProgress - ? null - : () => controller.deleteLowQualityFace(face), ), ), ], @@ -985,8 +1028,7 @@ Widget settingFace(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildFaceTabs(context), - SizedBox(height: XfjDesignTokens.cardGap.h), + _buildFaceHeader(context), Expanded( child: Obx(() { final lowQuality = diff --git a/lib/pages/terminal/verification_records/controller.dart b/lib/pages/terminal/verification_records/controller.dart index 3cecd35..b943344 100644 --- a/lib/pages/terminal/verification_records/controller.dart +++ b/lib/pages/terminal/verification_records/controller.dart @@ -20,6 +20,32 @@ class VerificationRecordsController extends GetxController { final total = 0.obs; final errorMessage = ''.obs; + /// 核销记录来自服务端,展开时按订单 ID 并行查询订单和可履约购物车快照。 + final expandedOrderId = RxnInt(); + final expandedDetailStatus = OrderRecordDetailLoadStatus.success.obs; + final expandedDetailError = ''.obs; + final expandedOrder = Rxn(); + final expandedCarList = [].obs; + int _expandedDetailRequestId = 0; + + /// 当前打开完整详情的核销记录;非空时由页面展示右侧订单详情抽屉。 + final selectedRecord = Rxn(); + + /// 完整详情抽屉中的服务端订单数据,加载失败时保持为空并展示错误态。 + final selectedOrder = Rxn(); + + /// 完整详情抽屉中的服务端购物车快照。 + final selectedCarList = [].obs; + + /// 完整详情抽屉订单和购物车数据的加载状态。 + final selectedDetailStatus = OrderRecordDetailLoadStatus.success.obs; + + /// 完整详情抽屉的可恢复错误信息。 + final selectedDetailError = ''.obs; + + /// 详情请求序号,用于丢弃关闭抽屉或快速切换后的旧响应。 + int _selectedDetailRequestId = 0; + List get visibleRecords { final keyword = keywordController.text.trim().toLowerCase(); if (keyword.isEmpty) return records; @@ -39,11 +65,14 @@ class VerificationRecordsController extends GetxController { @override void onClose() { + _collapseExpandedRecord(); + closeOrderDetail(); keywordController.dispose(); super.onClose(); } Future loadRecords({bool resetPage = false}) async { + _collapseExpandedRecord(); if (resetPage) currentPage.value = 1; final requestId = ++_loadRequestId; status.value = VerificationRecordLoadStatus.loading; @@ -100,9 +129,121 @@ class VerificationRecordsController extends GetxController { void applyKeyword() { FocusManager.instance.primaryFocus?.unfocus(); + _collapseExpandedRecord(); records.refresh(); } + /// 列表每次只允许展开一笔核销订单;快速切换时旧请求不能覆盖新记录。 + Future toggleExpandedRecord(ServiceVerificationRecord record) async { + if (expandedOrderId.value == record.orderId) { + _collapseExpandedRecord(); + return; + } + expandedOrderId.value = record.orderId; + await _loadExpandedRecord(record.orderId); + } + + Future retryExpandedRecord() async { + final orderId = expandedOrderId.value; + if (orderId == null) return; + await _loadExpandedRecord(orderId); + } + + /// 打开核销订单完整详情,并复用展开区相同的订单与购物车接口。 + Future openOrderDetail(ServiceVerificationRecord record) async { + final requestId = ++_selectedDetailRequestId; + selectedRecord.value = record; + selectedOrder.value = null; + selectedCarList.clear(); + selectedDetailError.value = ''; + selectedDetailStatus.value = OrderRecordDetailLoadStatus.loading; + try { + final results = await Future.wait([ + HardApi.getOrder({'id': record.orderId}), + HardApi.getCarByOrderId({'orderId': record.orderId}), + ]); + if (requestId != _selectedDetailRequestId || + selectedRecord.value?.orderId != record.orderId) { + return; + } + final order = results[0] as ServiceHardOrder?; + if (order == null) throw StateError('服务端未返回订单详情'); + selectedOrder.value = order; + selectedCarList.assignAll( + (results[1] as List?) ?? const [], + ); + selectedDetailStatus.value = OrderRecordDetailLoadStatus.success; + } catch (error, stack) { + if (requestId != _selectedDetailRequestId || + selectedRecord.value?.orderId != record.orderId) { + return; + } + selectedDetailError.value = '服务端订单或购物车数据读取失败,请检查网络后重试'; + selectedDetailStatus.value = OrderRecordDetailLoadStatus.failure; + logger.e( + '|核销记录|打开完整详情失败|orderId:${record.orderId}|error:$error|stack:$stack|'); + } + } + + /// 重试当前完整详情抽屉的数据请求。 + Future retryOrderDetail() async { + final record = selectedRecord.value; + if (record == null) return; + await openOrderDetail(record); + } + + /// 关闭完整详情抽屉并取消其未完成请求的 UI 更新。 + void closeOrderDetail() { + _selectedDetailRequestId++; + selectedRecord.value = null; + selectedOrder.value = null; + selectedCarList.clear(); + selectedDetailError.value = ''; + selectedDetailStatus.value = OrderRecordDetailLoadStatus.success; + } + + Future _loadExpandedRecord(int orderId) async { + final requestId = ++_expandedDetailRequestId; + expandedDetailStatus.value = OrderRecordDetailLoadStatus.loading; + expandedDetailError.value = ''; + expandedOrder.value = null; + expandedCarList.clear(); + try { + final results = await Future.wait([ + HardApi.getOrder({'id': orderId}), + HardApi.getCarByOrderId({'orderId': orderId}), + ]); + if (requestId != _expandedDetailRequestId || + expandedOrderId.value != orderId) { + return; + } + final order = results[0] as ServiceHardOrder?; + if (order == null) throw StateError('服务端未返回订单详情'); + expandedOrder.value = order; + expandedCarList.assignAll( + (results[1] as List?) ?? const [], + ); + expandedDetailStatus.value = OrderRecordDetailLoadStatus.success; + } catch (error, stack) { + if (requestId != _expandedDetailRequestId || + expandedOrderId.value != orderId) { + return; + } + expandedDetailError.value = '服务端订单或购物车数据读取失败,请检查网络后重试'; + expandedDetailStatus.value = OrderRecordDetailLoadStatus.failure; + logger.e('|核销记录|展开订单失败|orderId:$orderId|error:$error|stack:$stack|'); + } + } + + void _collapseExpandedRecord() { + _expandedDetailRequestId++; + expandedOrderId.value = null; + expandedDetailStatus.value = OrderRecordDetailLoadStatus.success; + expandedDetailError.value = ''; + expandedOrder.value = null; + expandedCarList.clear(); + } + String _readableError(Object error) { final message = error.toString().replaceFirst('Bad state: ', ''); return message.isEmpty ? '核销记录加载失败,请检查网络后重试' : message; diff --git a/lib/pages/terminal/verification_records/view.dart b/lib/pages/terminal/verification_records/view.dart index 0c1338b..d574982 100644 --- a/lib/pages/terminal/verification_records/view.dart +++ b/lib/pages/terminal/verification_records/view.dart @@ -8,32 +8,60 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'controller.dart'; +import 'widgets/verification_order_detail_drawer.dart'; import 'widgets/verification_record_table.dart'; class VerificationRecordsPage extends GetView { const VerificationRecordsPage({super.key}); + /// 构建核销列表、分页和覆盖在页面右侧的完整详情抽屉。 @override Widget build(BuildContext context) { - return [ - _buildToolbar(context), - SizedBox(height: XfjDesignTokens.cardGap.h), - Obx(() => VerificationRecordTable( - status: controller.status.value, - records: controller.visibleRecords, - errorMessage: controller.errorMessage.value, - onRetry: () => controller.loadRecords(), - )).expanded(), - SizedBox(height: XfjDesignTokens.cardGap.h), - Obx(() => appPage( - context, - pageNo: controller.currentPage.value, - pageSize: controller.pageSize.value, - total: controller.total.value, - itemLabel: '条核销记录', - onPageChanged: controller.changePage, - )), - ].toColumn().padding(all: 8.w); + return Stack( + children: [ + [ + _buildToolbar(context), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx(() => VerificationRecordTable( + status: controller.status.value, + records: controller.visibleRecords, + errorMessage: controller.errorMessage.value, + onRetry: () => controller.loadRecords(), + expandedOrderId: controller.expandedOrderId.value, + expandedDetailStatus: controller.expandedDetailStatus.value, + expandedOrder: controller.expandedOrder.value, + expandedCarList: + controller.expandedCarList.toList(growable: false), + expandedDetailError: controller.expandedDetailError.value, + onToggleExpanded: controller.toggleExpandedRecord, + onRetryExpanded: controller.retryExpandedRecord, + onOpenDetail: controller.openOrderDetail, + )).expanded(), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx(() => appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.total.value, + itemLabel: '条核销记录', + onPageChanged: controller.changePage, + )), + ].toColumn().padding(all: 8.w).expanded(), + Obx(() { + final record = controller.selectedRecord.value; + if (record == null) return const SizedBox.shrink(); + return VerificationOrderDetailDrawer( + record: record, + status: controller.selectedDetailStatus.value, + order: controller.selectedOrder.value, + items: controller.selectedCarList.toList(growable: false), + errorMessage: controller.selectedDetailError.value, + onRetry: controller.retryOrderDetail, + onClose: controller.closeOrderDetail, + ); + }), + ], + ); } /// 工具栏沿用交易记录的信息层级,并移除固定为预约核销后的无效类型筛选。 diff --git a/lib/pages/terminal/verification_records/widgets/verification_record_table.dart b/lib/pages/terminal/verification_records/widgets/verification_record_table.dart index 3d39a12..619cacd 100644 --- a/lib/pages/terminal/verification_records/widgets/verification_record_table.dart +++ b/lib/pages/terminal/verification_records/widgets/verification_record_table.dart @@ -13,12 +13,30 @@ class VerificationRecordTable extends StatelessWidget { required this.records, required this.errorMessage, required this.onRetry, + required this.expandedOrderId, + required this.expandedDetailStatus, + required this.expandedOrder, + required this.expandedCarList, + required this.expandedDetailError, + required this.onToggleExpanded, + required this.onRetryExpanded, + required this.onOpenDetail, }); final VerificationRecordLoadStatus status; final List records; final String errorMessage; final VoidCallback onRetry; + final int? expandedOrderId; + final OrderRecordDetailLoadStatus expandedDetailStatus; + final ServiceHardOrder? expandedOrder; + final List expandedCarList; + final String expandedDetailError; + final ValueChanged onToggleExpanded; + final VoidCallback onRetryExpanded; + + /// 请求页面打开指定核销订单的完整详情抽屉。 + final ValueChanged onOpenDetail; @override Widget build(BuildContext context) { @@ -68,6 +86,7 @@ class VerificationRecordTable extends StatelessWidget { color: const Color(0xFFF7F8FA), child: _VerificationGridRow( children: [ + Text('明细', style: style), Text('核销时间', style: style), Text('订单号', style: style), Text('类型', style: style), @@ -76,6 +95,7 @@ class VerificationRecordTable extends StatelessWidget { Text('核销方式', style: style), Text('订单金额', style: style), Text('状态', style: style), + Text('操作', style: style), ], ).padding(horizontal: 16.w).constrained(minHeight: 46.h), ); @@ -83,6 +103,7 @@ class VerificationRecordTable extends StatelessWidget { Widget _row(BuildContext context, ServiceVerificationRecord record) { final theme = FTheme.of(context); + final expanded = expandedOrderId == record.orderId; // 表格正文统一使用同一字号和字重,避免未显式指定样式的 Text 回退到 Material 默认字体。 final cellStyle = theme.typography.sm.copyWith( color: XfjDesignTokens.ink, @@ -93,69 +114,147 @@ class VerificationRecordTable extends StatelessWidget { color: XfjDesignTokens.muted, fontWeight: FontWeight.w400, ); - return _VerificationGridRow( + return Column( children: [ - [ - Text( - DateUtil.formatDate(record.verificationTime, - format: DateFormats.y_mo_d), - style: cellStyle.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - Text( - DateUtil.formatDate(record.verificationTime, - format: DateFormats.h_m_s), - style: secondaryStyle.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), - Text( - record.orderNo, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: cellStyle.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - ), + Container( + color: expanded + ? XfjDesignTokens.primarySoft.withValues(alpha: 0.28) + : Colors.white, + child: _VerificationGridRow( + children: [ + OrderRecordExpandButton( + expanded: expanded, + semanticLabel: '${record.orderNo}订单明细', + onTap: () => onToggleExpanded(record), + ), + [ + Text( + DateUtil.formatDate(record.verificationTime, + format: DateFormats.y_mo_d), + style: cellStyle.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + DateUtil.formatDate(record.verificationTime, + format: DateFormats.h_m_s), + style: secondaryStyle.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), + Text( + record.orderNo, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: cellStyle.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + _typeBadge(context, record), + [ + Text( + record.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: cellStyle, + ), + if ((record.userDeptName ?? '').isNotEmpty) + Text( + record.userDeptName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: secondaryStyle, + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), + Text( + record.productDesc, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: cellStyle, + ), + Text( + record.payTypeName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: cellStyle, + ), + Text( + '¥${AppPriceTool.fenToYuan(record.totalPrice)}', + style: cellStyle.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + _successBadge(context), + _VerificationDetailButton( + semanticLabel: '查看${record.orderNo}完整详情', + onTap: () => onOpenDetail(record), + ), + ], + ) + .padding(horizontal: 16.w, vertical: 8.h) + .constrained(minHeight: 66.h), ), - _typeBadge(context, record), - [ - Text(record.username, style: cellStyle), - if ((record.userDeptName ?? '').isNotEmpty) - Text( - record.userDeptName!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: secondaryStyle, - ), - ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), - Text( - record.productDesc, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: cellStyle, + AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: expanded + ? OrderRecordCollapsePanel( + status: expandedDetailStatus, + items: _detailItems(), + totalPrice: _detailTotalPrice(record), + extraPrice: _detailExtraPrice, + payPrice: _detailPayPrice(record), + errorMessage: expandedDetailError, + onRetry: onRetryExpanded, + ) + : const SizedBox.shrink(), ), - Text( - record.payTypeName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: cellStyle, - ), - Text( - '¥${AppPriceTool.fenToYuan(record.totalPrice)}', - style: cellStyle.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - _successBadge(context), ], - ).padding(horizontal: 16.w, vertical: 8.h).constrained(minHeight: 66.h); + ); + } + + List _detailItems() { + return expandedCarList + .map( + (item) => OrderRecordDetailItem( + name: item.productName ?? '未命名商品', + quantity: _quantityLabel(item), + unitPrice: item.productPrice, + totalPrice: item.totalPrice, + ), + ) + .toList(growable: false); + } + + int get _detailExtraPrice => expandedOrder?.extraPrice ?? 0; + + int _detailTotalPrice(ServiceVerificationRecord record) { + return expandedOrder?.totalPrice ?? record.totalPrice; + } + + int _detailPayPrice(ServiceVerificationRecord record) { + return expandedOrder?.payPrice ?? record.totalPrice; + } + + String _quantityLabel(ServiceCar item) { + final nature = item.productNature; + if (nature == ProductNature.WEIGHT.code || + nature == ProductNature.WEIGHT.name) { + final weight = item.productWeight; + return weight == null ? '称重商品' : '${weight.toStringAsFixed(2)} kg'; + } + return item.productCount?.toString() ?? '-'; } Widget _typeBadge(BuildContext context, ServiceVerificationRecord record) { final badge = Text( record.verificationTypeName, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: FTheme.of(context).typography.xs.copyWith( color: XfjDesignTokens.primary, fontWeight: FontWeight.w600, @@ -230,10 +329,60 @@ class _VerificationGridRow extends StatelessWidget { @override Widget build(BuildContext context) { - const flexes = [14, 16, 11, 14, 25, 14, 11, 10]; + const flexes = [7, 12, 15, 10, 13, 20, 12, 10, 9, 14]; return [ for (var index = 0; index < children.length; index++) children[index].padding(horizontal: 4.w).expanded(flex: flexes[index]), ].toRow(crossAxisAlignment: CrossAxisAlignment.center); } } + +/// 核销列表中的完整详情入口,保留大屏终端所需的触控尺寸。 +class _VerificationDetailButton extends StatelessWidget { + const _VerificationDetailButton({ + required this.semanticLabel, + required this.onTap, + }); + + /// 无障碍读屏使用的按钮语义。 + final String semanticLabel; + + /// 点击后打开订单详情抽屉。 + final VoidCallback onTap; + + /// 构建带文字标签和无障碍语义的次要操作按钮。 + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: semanticLabel, + child: Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + constraints: BoxConstraints(minWidth: 96.w, minHeight: 48.h), + padding: EdgeInsets.symmetric(horizontal: 6.w), + decoration: BoxDecoration( + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(10), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '完整详情', + maxLines: 1, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w600, + ), + ), + ).center(), + ), + ), + ), + ); + } +} diff --git a/test/common/app_radio_hour_type_dialog_test.dart b/test/common/app_radio_hour_type_dialog_test.dart index d96ca51..8fad518 100644 --- a/test/common/app_radio_hour_type_dialog_test.dart +++ b/test/common/app_radio_hour_type_dialog_test.dart @@ -129,12 +129,12 @@ void main() { expect(controller.value, {'1'}); }); - testWidgets('用户手动选择不匹配餐段时确认前不提交且确认后切到配置餐段', (tester) async { - final controller = FRadioSelectGroupController(value: '1'); + testWidgets('用户手动选择不匹配餐段时确认前不提交且确认后保留强制选择', (tester) async { + final controller = FRadioSelectGroupController(value: '3'); addTearDown(controller.dispose); final committedValues = >[]; controller.addValueListener(committedValues.add); - var configured = ProductHourType.MORNING; + const configured = ProductHourType.NIGHT; await tester.pumpWidget( _testApp( @@ -152,31 +152,31 @@ void main() { await tester.pumpAndSettle(); expect(find.textContaining('当前选择的餐段可能有问题'), findsNothing); - configured = ProductHourType.NOON; await tester.tap(find.byType(FSelectMenuTile)); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('meal-slot-option-3'))); + await tester.tap(find.byKey(const ValueKey('meal-slot-option-1'))); await tester.pumpAndSettle(); expect(find.textContaining('当前选择的餐段可能有问题'), findsOneWidget); - expect(controller.value, {'1'}); + expect(find.textContaining('是否仍使用“早餐”'), findsOneWidget); + expect(controller.value, {'3'}); expect(committedValues, isEmpty); await tester.tap(find.text('确定')); await tester.pumpAndSettle(); - expect(controller.value, {'2'}); + expect(controller.value, {'1'}); expect(committedValues, [ - {'2'} + {'1'} ]); }); - testWidgets('用户拒绝切换时在弹框关闭后才提交人工候选餐段', (tester) async { - final controller = FRadioSelectGroupController(value: '1'); + testWidgets('用户取消强制选择时在弹框关闭后恢复原全局餐段', (tester) async { + final controller = FRadioSelectGroupController(value: '3'); addTearDown(controller.dispose); final committedValues = >[]; controller.addValueListener(committedValues.add); - var configured = ProductHourType.MORNING; + const configured = ProductHourType.NIGHT; await tester.pumpWidget( _testApp( @@ -193,21 +193,18 @@ void main() { ); await tester.pumpAndSettle(); - configured = ProductHourType.NOON; await tester.tap(find.byType(FSelectMenuTile)); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('meal-slot-option-3'))); + await tester.tap(find.byKey(const ValueKey('meal-slot-option-1'))); await tester.pumpAndSettle(); - expect(controller.value, {'1'}); + expect(controller.value, {'3'}); expect(committedValues, isEmpty); await tester.tap(find.text('取消')); await tester.pumpAndSettle(); expect(controller.value, {'3'}); - expect(committedValues, [ - {'3'} - ]); + expect(committedValues, isEmpty); }); testWidgets('用户选择符合当前配置的餐段时直接提交且不弹框', (tester) async { diff --git a/test/common/terminal_toolbar_test.dart b/test/common/terminal_toolbar_test.dart index b5e01c3..4f79f08 100644 --- a/test/common/terminal_toolbar_test.dart +++ b/test/common/terminal_toolbar_test.dart @@ -95,6 +95,40 @@ void main() { expect(tester.takeException(), isNull); } }); + + testWidgets('工具栏按钮的全部视觉层级保持统一高度', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + _terminalTestApp( + (_) => Row( + mainAxisSize: MainAxisSize.min, + children: TerminalToolbarButtonStyle.values + .map( + (style) => TerminalToolbarButton( + label: style.name, + icon: FAssets.icons.search, + style: style, + onTap: () {}, + ), + ) + .toList(growable: false), + ), + ), + ); + + final buttons = find.byType(TerminalToolbarButton); + expect(buttons, findsNWidgets(TerminalToolbarButtonStyle.values.length)); + final heights = tester.getSize(buttons.at(0)).height; + for (var index = 1; + index < TerminalToolbarButtonStyle.values.length; + index++) { + expect(tester.getSize(buttons.at(index)).height, closeTo(heights, 0.01)); + } + expect(find.text('destructive'), findsOneWidget); + expect(tester.takeException(), isNull); + }); } Widget _terminalTestApp(WidgetBuilder childBuilder) { diff --git a/test/common/verification_order_detail_drawer_test.dart b/test/common/verification_order_detail_drawer_test.dart new file mode 100644 index 0000000..8ee7d30 --- /dev/null +++ b/test/common/verification_order_detail_drawer_test.dart @@ -0,0 +1,162 @@ +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:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/verification_records/widgets/verification_order_detail_drawer.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/verification_records/widgets/verification_record_table.dart'; + +/// 以收银终端设计分辨率包裹待测组件,确保列宽和抽屉尺寸使用真实适配基准。 +Widget _terminalApp(Widget child) { + return ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + splitScreenMode: true, + builder: (_, __) => FTheme( + data: FThemes.zinc.light, + child: MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(body: child), + ), + ), + ); +} + +/// 构造一笔包含长文本的核销记录,用于覆盖表格省略和详情入口布局。 +ServiceVerificationRecord _record() { + return ServiceVerificationRecord( + orderId: 2094302933091917826, + orderNo: '2094302933091917826', + verificationType: verificationRecordReservationType, + verificationTypeName: '预约核销', + username: '邓测试被监护人长用户名', + userDeptName: '青鸟同文学校长部门名称', + productDesc: '酱香饼x2;鲜肉烧麦x2;', + payTypeName: '钱包支付', + totalPrice: 1280, + verificationTime: DateTime(2026, 8, 31, 16, 41, 9), + ); +} + +/// 构造完整远程订单,覆盖抽屉内的订单字段和金额汇总。 +ServiceHardOrder _order() { + return ServiceHardOrder( + id: '2094302933091917826', + orderNo: '2094302933091917826', + userId: '2034824566572941314', + username: '邓测试被监护人长用户名', + userMobile: '13800000000', + orderType: OrderType.RESERVE.code, + orderStatus: OrderStatus.CANCEL.code, + payType: OrderPayType.WALLET_DEFAULT.code, + walletId: '208000000000001474', + hourType: ProductHourType.NIGHT.code, + productDesc: '酱香饼x2;鲜肉烧麦x2;', + userDeptName: '青鸟同文学校长部门名称', + totalPrice: 1280, + extraPrice: 0, + payPrice: 1280, + androidOrderCode: '391874', + createTime: DateTime(2026, 8, 31, 13, 55, 3), + ); +} + +/// 构造详情抽屉使用的购物车快照。 +List _items() { + return [ + ServiceCar( + productName: '酱香饼', + productPrice: 320, + productNature: '1', + productCount: 2, + totalPrice: 640, + ), + ServiceCar( + productName: '鲜肉烧麦', + productPrice: 320, + productNature: '1', + productCount: 2, + totalPrice: 640, + ), + ]; +} + +/// 核对核销列表入口和完整详情抽屉在终端分辨率下均可正常布局。 +void main() { + testWidgets('核销记录提供完整详情入口', (tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + ServiceVerificationRecord? openedRecord; + + await tester.pumpWidget( + _terminalApp( + SizedBox.expand( + child: VerificationRecordTable( + status: VerificationRecordLoadStatus.success, + records: [_record()], + errorMessage: '', + onRetry: () {}, + expandedOrderId: null, + expandedDetailStatus: OrderRecordDetailLoadStatus.success, + expandedOrder: null, + expandedCarList: const [], + expandedDetailError: '', + onToggleExpanded: (_) {}, + onRetryExpanded: () {}, + onOpenDetail: (record) => openedRecord = record, + ), + ), + ), + ); + + expect(find.text('操作'), findsOneWidget); + expect(find.text('完整详情'), findsOneWidget); + await tester.tap(find.text('完整详情')); + await tester.pump(); + + expect(openedRecord?.orderId, _record().orderId); + expect(tester.takeException(), isNull); + }); + + testWidgets('核销订单完整详情展示订单字段、商品和金额', (tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + _terminalApp( + Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: XfjDesignTokens.canvas), + VerificationOrderDetailDrawer( + record: _record(), + status: OrderRecordDetailLoadStatus.success, + order: _order(), + items: _items(), + errorMessage: '', + onRetry: () {}, + onClose: () {}, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('订单详情'), findsOneWidget); + expect(tester.takeException(), isNull); + expect(find.text('服务端订单 ID'), findsOneWidget); + expect(find.text('用户部门'), findsOneWidget); + expect(find.text('用餐时段'), findsOneWidget); + expect(find.text('核销方式'), findsOneWidget); + expect(find.text('商品明细'), findsOneWidget); + expect(find.text('酱香饼'), findsOneWidget); + expect(find.text('鲜肉烧麦'), findsOneWidget); + expect(find.text('实际扣款'), findsOneWidget); + }); +} diff --git a/test/common/verification_record_models_test.dart b/test/common/verification_record_models_test.dart index 51742f4..5a566f3 100644 --- a/test/common/verification_record_models_test.dart +++ b/test/common/verification_record_models_test.dart @@ -42,4 +42,45 @@ void main() { expect(page.records!.last.orderId, 42); expect(page.records!.last.totalPrice, 800); }); + + test('核销订单详情兼容 Long、金额和称重商品的多种序列化类型', () { + final order = ServiceHardOrder.fromJson({ + 'id': 2080112524665057281, + 'orderNo': 'HX-001', + 'userId': 2080112524665057001, + 'walletId': '2080112524665057002', + 'orderType': '2', + 'orderStatus': '20', + 'payType': '7', + 'hourType': '2', + 'totalPrice': '2550', + 'extraPrice': 50, + 'payPrice': '2600', + 'createTime': '2026-08-30T18:42:09', + }); + final car = ServiceCar.fromJson({ + 'id': 2080112524665057101, + 'parentCarId': '2080112524665057100', + 'productId': 2080112524665057201, + 'productName': '称重商品', + 'productPrice': 990, + 'productNature': '2', + 'productWeight': '1.25', + 'totalPrice': '1238', + }); + + expect(order.id, '2080112524665057281'); + expect(order.userId, '2080112524665057001'); + expect(order.walletId, '2080112524665057002'); + expect(order.totalPrice, 2550); + expect(order.extraPrice, 50); + expect(order.payPrice, 2600); + expect(order.createTime, DateTime(2026, 8, 30, 18, 42, 9)); + expect(car.id, '2080112524665057101'); + expect(car.parentCarId, '2080112524665057100'); + expect(car.productId, '2080112524665057201'); + expect(car.productWeight, 1.25); + expect(car.totalPrice, 1238); + expect(car.toJson()['totalPrice'], '1238'); + }); } diff --git a/test/pages/terminal/menu_query_controller_test.dart b/test/pages/terminal/menu_query_controller_test.dart index 250f796..894c18c 100644 --- a/test/pages/terminal/menu_query_controller_test.dart +++ b/test/pages/terminal/menu_query_controller_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:forui/forui.dart'; @@ -986,7 +987,7 @@ void main() { await controller.clearForPrivacy(); }); - testWidgets('饭卡核销只提交点击餐段中选中的订单 ID,成功后刷新并播报', (tester) async { + testWidgets('饭卡核销提交点击餐段中勾选的多个订单,成功后刷新并播报', (tester) async { final repository = _SequenceRepository([ _resultWithOrderIds('张*明', const [1001, 1002]), _result('张*明', 0), @@ -1017,12 +1018,15 @@ void main() { final day = controller.result.value!.days.single; final meal = day.meals.single; - expect(await controller.confirmVerification(day, meal, 1002), isTrue); + expect( + await controller.confirmVerification(day, meal, const [1001, 1002]), + isTrue, + ); expect( verification.calls, const [ _VerificationCall( - orderId: 1002, + orderIds: [1001, 1002], ), ], ); @@ -1031,10 +1035,55 @@ void main() { const _QueryCall(identityValue: 'CARD-001', maskName: true), ]); expect(controller.status.value, ReservationMenuQueryStatus.empty); - expect(successes, ['核销成功,页面已刷新']); + expect(successes, ['已核销 2 笔预约,页面已刷新']); expect(errors, isEmpty); await tester.pump(); - expect(spoken, ['核销成功']); + expect(spoken, ['2笔预约核销成功']); + await controller.clearForPrivacy(); + }); + + testWidgets('多笔预约部分核销成功时刷新结果并明确提示成功与失败数量', (tester) async { + final repository = _SequenceRepository([ + _resultWithOrderIds('张*明', const [1001, 1002]), + _resultWithOrderIds('张*明', const [1002]), + ]); + final recognition = _FakeRecognitionGateway(); + final verification = _FakeVerificationGateway( + result: const ReservationVerificationResult( + succeededOrderIds: [1001], + failedOrderIds: [1002], + ), + ); + final spoken = []; + final successes = []; + final controller = ReservationMenuQueryController( + repository: repository, + recognitionGateway: recognition, + subScreenGateway: _FakeSubScreenGateway(), + verificationGateway: verification, + networkAvailable: () => true, + preferencesLoader: _verificationPreferences, + speaker: (message) async => spoken.add(message), + successNotifier: successes.add, + now: () => DateTime(2026, 7, 24, 8), + ); + addTearDown(controller.onClose); + + controller.onInit(); + await tester.pump(); + await controller.startRecognition(); + await recognition.emitCardAt(0, 'CARD-001'); + final day = controller.result.value!.days.single; + final meal = day.meals.single; + + expect( + await controller.confirmVerification(day, meal, const [1001, 1002]), + isTrue, + ); + expect(controller.result.value!.days.single.meals.single.orderIds, [1002]); + expect(successes, ['已核销 1 笔预约,1 笔未成功,页面已刷新']); + await tester.pump(); + expect(spoken, ['1笔预约核销成功,1笔未成功']); await controller.clearForPrivacy(); }); @@ -1075,12 +1124,13 @@ void main() { final day = controller.result.value!.days.single; final meal = day.meals.single; - expect(await controller.confirmVerification(day, meal, 1002), isTrue); + expect( + await controller.confirmVerification(day, meal, const [1002]), isTrue); expect( verification.calls, const [ _VerificationCall( - orderId: 1002, + orderIds: [1002], faceEvidenceSessionId: 'face-session-1', ), ], @@ -1144,9 +1194,12 @@ void main() { ReservationMealSlot.noon.matches, ); - expect(await controller.confirmVerification(day, noonMeal, 2001), isFalse); + expect( + await controller.confirmVerification(day, noonMeal, const [2001]), + isFalse, + ); expect(verification.calls, isEmpty); - expect(errors, ['所选预约订单已失效,请重新查询']); + expect(errors, ['部分所选预约订单已失效,请重新查询']); await controller.clearForPrivacy(); }); @@ -1182,7 +1235,7 @@ void main() { final meal = day.meals.single; expect( - await controller.confirmVerification(day, meal, meal.orderIds.single), + await controller.confirmVerification(day, meal, meal.orderIds), isTrue, ); expect(successes, ['核销成功,请重新识别刷新结果']); @@ -1457,24 +1510,40 @@ class _QueryCall { '_QueryCall(identityValue: $identityValue, maskName: $maskName)'; } +/// 记录 Controller 批量核销调用并默认把全部传入订单标记为成功的测试网关。 class _FakeVerificationGateway implements ReservationVerificationGateway { + /// 创建默认全成功或返回指定逐笔结果的测试网关。 + _FakeVerificationGateway({this.result}); + + /// 已收到的逐次批量核销调用。 final calls = <_VerificationCall>[]; + + /// Controller 要求清理的未使用人脸证据会话。 final discardedSessions = []; + /// 测试指定的逐笔结果;为空时根据传入订单构造全部成功。 + final ReservationVerificationResult? result; + + /// 记录订单列表与人脸会话,并模拟所选订单全部核销成功。 @override - Future cancel({ - required int orderId, + Future cancel({ + required List orderIds, String? faceEvidenceSessionId, }) async { calls.add( _VerificationCall( - orderId: orderId, + orderIds: orderIds, faceEvidenceSessionId: faceEvidenceSessionId, ), ); - return true; + return result ?? + ReservationVerificationResult( + succeededOrderIds: List.unmodifiable(orderIds), + failedOrderIds: const [], + ); } + /// 记录 Controller 放弃的未绑定人脸证据会话。 @override Future discardFaceEvidence({ required String recognitionSessionId, @@ -1484,23 +1553,31 @@ class _FakeVerificationGateway implements ReservationVerificationGateway { } } +/// 一次批量核销调用的不可变断言快照。 class _VerificationCall { + /// 创建包含订单顺序和可选人脸证据会话的调用快照。 const _VerificationCall({ - required this.orderId, + required this.orderIds, this.faceEvidenceSessionId, }); - final int orderId; + /// Controller 按当前餐段顺序提交的订单 ID。 + final List orderIds; + + /// 人脸识别产生的证据会话;饭卡核销时为空。 final String? faceEvidenceSessionId; + /// 按订单顺序和证据会话比较两次核销调用。 @override bool operator ==(Object other) => other is _VerificationCall && - other.orderId == orderId && + listEquals(other.orderIds, orderIds) && other.faceEvidenceSessionId == faceEvidenceSessionId; + /// 生成与相等判断字段一致的哈希值。 @override - int get hashCode => Object.hash(orderId, faceEvidenceSessionId); + int get hashCode => + Object.hash(Object.hashAll(orderIds), faceEvidenceSessionId); } class _FakeRecognitionGateway implements ReservationRecognitionGateway { diff --git a/test/pages/terminal/reservation_verification_dialog_test.dart b/test/pages/terminal/reservation_verification_dialog_test.dart index e42a410..8bb6b4f 100644 --- a/test/pages/terminal/reservation_verification_dialog_test.dart +++ b/test/pages/terminal/reservation_verification_dialog_test.dart @@ -89,7 +89,7 @@ void main() { testWidgets('核销弹框展示移动端确认页的关键信息并支持取消', (tester) async { _useTerminalViewport(tester); - int? selectedOrderId; + List? selectedOrderIds; await tester.pumpWidget( ScreenUtilInit( designSize: const Size(1920, 1080), @@ -105,8 +105,8 @@ void main() { result: result, day: day, meal: meal, - onConfirm: (orderId) async { - selectedOrderId = orderId; + onConfirm: (orderIds) async { + selectedOrderIds = orderIds; return true; }, ), @@ -187,17 +187,23 @@ void main() { ); expect(find.text('确认核销'), findsNWidgets(2)); expect(find.text('可核销'), findsOneWidget); - expect(find.text('单品'), findsOneWidget); + expect(find.text('单品'), findsNWidgets(2)); expect(find.text('张*明 · 三年级二班'), findsOneWidget); expect(find.text('第一食堂'), findsOneWidget); expect(find.text('2026-07-27 周一'), findsOneWidget); expect(find.text('小青菜'), findsOneWidget); expect(find.text('1'), findsOneWidget); - expect(find.text('黄瓜炒鸡蛋'), findsNothing); + expect(find.text('黄瓜炒鸡蛋'), findsOneWidget); + expect(find.text('全部订单商品'), findsOneWidget); expect(find.text('预约订单'), findsOneWidget); - expect(find.text('请选择本次需要核销的订单'), findsOneWidget); + expect(find.text('右侧同步展示已勾选预约商品'), findsOneWidget); + expect(find.text('全部'), findsOneWidget); expect(find.text('第 1 笔预约'), findsOneWidget); expect(find.text('第 2 笔预约'), findsOneWidget); + expect(find.byType(Checkbox), findsNWidgets(3)); + for (final checkbox in tester.widgetList(find.byType(Checkbox))) { + expect(checkbox.value, isTrue); + } final firstOrder = find.byKey( const ValueKey('reservation-verification-order-1001'), ); @@ -210,10 +216,16 @@ void main() { find.byKey(const ValueKey('reservation-order-product-image-小青菜')), findsOneWidget, ); - expect(find.textContaining('只核销当前选中的 1 笔预约'), findsOneWidget); + expect(find.textContaining('已选择 2 笔预约'), findsOneWidget); - await tester.tap(find.text('第 2 笔预约')); + await tester.tap( + find.byKey( + const ValueKey('reservation-verification-checkbox-1001'), + ), + ); await tester.pump(); + expect(find.text('第 2 笔预约商品'), findsOneWidget); + expect(find.text('全部订单商品'), findsNothing); expect(find.text('小青菜'), findsNothing); expect(find.text('黄瓜炒鸡蛋'), findsOneWidget); expect(find.text('2'), findsOneWidget); @@ -224,7 +236,7 @@ void main() { await tester .tap(find.byKey(const ValueKey('reservation-verification-confirm'))); await tester.pumpAndSettle(); - expect(selectedOrderId, 1002); + expect(selectedOrderIds, [1002]); }); testWidgets('套餐显示类型标签且点击商品行可折叠同层明细', (tester) async { @@ -324,8 +336,8 @@ void main() { expect(find.text('小青菜'), findsNothing); expect(find.text('黄瓜炒鸡蛋'), findsNothing); - expect(find.text('当前接口未返回订单商品明细'), findsOneWidget); - expect(find.textContaining('暂时无法确认该订单的商品图片和数量'), findsOneWidget); + expect(find.text('当前接口未返回全部订单商品明细'), findsOneWidget); + expect(find.textContaining('暂时无法确认全部预约的商品图片和数量'), findsOneWidget); }); testWidgets('关闭核销按钮时同一弹框只读展示且没有提交入口', (tester) async { @@ -365,6 +377,8 @@ void main() { expect(find.text('预约详情'), findsOneWidget); expect(find.text('仅查看'), findsOneWidget); expect(find.text('确认核销'), findsNothing); + expect(find.text('全部'), findsOneWidget); + expect(find.byType(Checkbox), findsNothing); expect( find.byKey(const ValueKey('reservation-verification-confirm')), findsNothing, @@ -379,9 +393,9 @@ void main() { expect(confirmCalls, 0); }); - testWidgets('多笔预约不选择时默认核销第一笔', (tester) async { + testWidgets('多笔预约不调整勾选时默认核销全部订单', (tester) async { _useTerminalViewport(tester); - int? selectedOrderId; + List? selectedOrderIds; await tester.pumpWidget( ScreenUtilInit( designSize: const Size(1920, 1080), @@ -397,8 +411,8 @@ void main() { result: result, day: day, meal: meal, - onConfirm: (orderId) async { - selectedOrderId = orderId; + onConfirm: (orderIds) async { + selectedOrderIds = orderIds; return true; }, ), @@ -415,7 +429,92 @@ void main() { await tester .tap(find.byKey(const ValueKey('reservation-verification-confirm'))); await tester.pumpAndSettle(); - expect(selectedOrderId, 1001); + expect(selectedOrderIds, [1001, 1002]); + }); + + testWidgets('全部与单笔复选框联动且只提交最终勾选的订单', (tester) async { + _useTerminalViewport(tester); + List? selectedOrderIds; + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + splitScreenMode: true, + builder: (_, __) => MaterialApp( + home: FTheme( + data: _terminalTheme(), + child: Builder( + builder: (context) => TextButton( + onPressed: () => reservationVerificationDialog( + context, + result: result, + day: day, + meal: meal, + onConfirm: (orderIds) async { + selectedOrderIds = orderIds; + return true; + }, + ), + child: const Text('打开'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + final allCheckbox = find.byKey( + const ValueKey('reservation-verification-checkbox-all'), + ); + final firstCheckbox = find.byKey( + const ValueKey('reservation-verification-checkbox-1001'), + ); + final secondCheckbox = find.byKey( + const ValueKey('reservation-verification-checkbox-1002'), + ); + + await tester.tap(secondCheckbox); + await tester.pump(); + expect(tester.widget(allCheckbox).value, isNull); + expect(tester.widget(firstCheckbox).value, isTrue); + expect(tester.widget(secondCheckbox).value, isFalse); + expect(find.textContaining('已选择 1 笔预约'), findsOneWidget); + expect(find.text('第 1 笔预约商品'), findsOneWidget); + expect(find.text('全部订单商品'), findsNothing); + expect(find.text('小青菜'), findsOneWidget); + expect(find.text('黄瓜炒鸡蛋'), findsNothing); + + await tester.tap(allCheckbox); + await tester.pump(); + expect(tester.widget(allCheckbox).value, isTrue); + expect(tester.widget(secondCheckbox).value, isTrue); + expect(find.text('全部订单商品'), findsOneWidget); + expect(find.text('黄瓜炒鸡蛋'), findsOneWidget); + + await tester.tap(allCheckbox); + await tester.pump(); + expect(tester.widget(allCheckbox).value, isFalse); + final disabledConfirm = tester.widget( + find.byKey(const ValueKey('reservation-verification-confirm')), + ); + expect(disabledConfirm.onPress, isNull); + expect(find.textContaining('请至少勾选 1 笔预约'), findsOneWidget); + expect(find.text('未选择预约商品'), findsOneWidget); + expect(find.text('小青菜'), findsNothing); + expect(find.text('黄瓜炒鸡蛋'), findsNothing); + + await tester.tap( + find.byKey(const ValueKey('reservation-verification-order-1001')), + ); + await tester.pump(); + expect(tester.widget(firstCheckbox).value, isTrue); + expect(find.text('第 1 笔预约商品'), findsOneWidget); + await tester + .tap(find.byKey(const ValueKey('reservation-verification-confirm'))); + await tester.pumpAndSettle(); + expect(selectedOrderIds, [1001]); }); testWidgets('核销弹框支持取消', (tester) async {