From 9cc99deed7d7b15592e307022e045f82ce2803f5 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Mon, 31 Aug 2026 17:59:03 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E5=90=8C=E6=97=B6=E6=A0=B8=E9=94=80?=
=?UTF-8?q?=E5=87=A0=E7=AC=94=E8=AE=A2=E5=8D=95=20feat:=E4=BA=BA=E8=84=B8?=
=?UTF-8?q?=E8=B4=A8=E9=87=8F=E6=A3=80=E6=B5=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 7 +-
.../face/model/RecognizeConfiguration.java | 32 +
.../ui/viewmodel/FaceDiscernViewModel.java | 16 +
lib/common/models/api/hard_api_models.dart | 71 +-
lib/common/models/api/hard_api_models.g.dart | 40 +-
lib/common/style/xfj_design_tokens.dart | 4 +-
lib/common/utils/json/json_tool.dart | 7 +
lib/common/utils/sync/sync_face_tool.dart | 6 +-
.../app/app_radio_hour_type_dialog.dart | 16 +-
lib/common/widgets/app/terminal_shell.dart | 4 +-
lib/common/widgets/app/terminal_toolbar.dart | 10 +-
lib/common/widgets/index.dart | 1 +
lib/pages/terminal/menu_query/controller.dart | 63 +-
.../reservation_verification_gateway.dart | 95 ++-
lib/pages/terminal/menu_query/view.dart | 12 +-
.../reservation_verification_dialog.dart | 391 +++++++--
.../terminal/order_history/controller.dart | 62 ++
lib/pages/terminal/order_history/view.dart | 771 ++++++++++++------
.../setting/widgets/setting_face.dart | 446 +++++-----
.../verification_records/controller.dart | 141 ++++
.../terminal/verification_records/view.dart | 66 +-
.../widgets/verification_record_table.dart | 259 ++++--
.../app_radio_hour_type_dialog_test.dart | 31 +-
test/common/terminal_toolbar_test.dart | 34 +
...verification_order_detail_drawer_test.dart | 162 ++++
.../verification_record_models_test.dart | 41 +
.../terminal/menu_query_controller_test.dart | 113 ++-
.../reservation_verification_dialog_test.dart | 131 ++-
28 files changed, 2324 insertions(+), 708 deletions(-)
create mode 100644 test/common/verification_order_detail_drawer_test.dart
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