feat:同时核销几笔订单

feat:人脸质量检测
This commit is contained in:
dzc committed 2026-08-31 17:59:03 +08:00
1 parent 917f03bc58
commit 9cc99deed7
28 files changed
+2324 -708

No files matched your search

+3 -4
View File
@@ -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 基线、数据库、人脸插件、双屏协议、支付编码或其他跨项目契约时,必须同步评估两个终端项目,禁止单边分叉。
@@ -263,6 +263,38 @@ public class RecognizeConfiguration {
return enableImageQuality;
}
/**
* 创建仅切换实时图像质量能力的运行时配置,其他现场参数保持不变。
*
* <p>该副本不会写回 SharedPreferences,专供虹软质量增值模块初始化失败时
* 降级本次识别会话,避免授权差异阻断原有特征提取和比对。</p>
*
* @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;
}
@@ -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();
+68 -3
View File
@@ -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<String, dynamic> json) =>
+34 -6
View File
@@ -186,13 +186,15 @@ Map<String, dynamic> _$ServiceHardwareToJson(ServiceHardware instance) =>
};
ServiceCar _$ServiceCarFromJson(Map<String, dynamic> 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<String, dynamic> _$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<String, dynamic> _$ServiceQRCodeToJson(ServiceQRCode instance) =>
ServiceHardOrder _$ServiceHardOrderFromJson(Map<String, dynamic> 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<String, dynamic> _$ServiceHardOrderToJson(ServiceHardOrder instance) =>
<String, dynamic>{
'id': instance.id,
'orderNo': instance.orderNo,
'userId': instance.userId,
'username': instance.username,
'userMobile': instance.userMobile,
@@ -268,6 +287,15 @@ Map<String, dynamic> _$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(
+2 -2
View File
@@ -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);
+7
View File
@@ -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();
+3 -3
View File
@@ -393,7 +393,7 @@ abstract class SyncFaceTool {
}
}
/// 删除本地孤儿人脸;虹软失败或 SQLite 失败时恢复文件和旧特征。
/// 删除本地孤儿人脸;文件、虹软或 SQLite 失败时恢复文件和旧特征。
static Future<bool> _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) {
@@ -332,7 +332,7 @@ class _MealPeriodSelectionGuardState extends State<_MealPeriodSelectionGuard> {
});
}
/// 校验人工候选或当前缓存餐段;确认切换配置餐段,取消时保留人工选择。
/// 校验人工候选或当前缓存餐段;人工确认后提交候选,初始化确认后提交配置餐段。
Future<void> _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|');
+2 -2
View File
@@ -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});
+9 -1
View File
@@ -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,
+1
View File
@@ -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';
+49 -14
View File
@@ -973,11 +973,11 @@ class ReservationMenuQueryController extends GetxController {
}
}
/// 只核销用户在当前餐段弹框中选中的订单 ID,成功后重新查询并替换页面结果。
/// 核销用户在当前餐段弹框中勾选的订单,至少一笔成功后重新查询并替换页面结果。
Future<bool> confirmVerification(
ReservationMenuDayData day,
ReservationMenuMealData meal,
int selectedOrderId,
List<int> 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<void> _announceVerificationSuccess() async {
/// 播报本次逐笔核销结果;单笔全部成功继续使用操作员熟悉的既有文案。
Future<void> _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|');
@@ -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<int> succeededOrderIds;
/// 服务端未确认成功或尚未提交的订单 ID。
final List<int> failedOrderIds;
/// 是否至少有一笔订单已经核销成功。
bool get anySucceeded => succeededOrderIds.isNotEmpty;
/// 是否全部所选订单均已核销成功。
bool get allSucceeded => anySucceeded && failedOrderIds.isEmpty;
}
/// 隔离查询页面与服务端预约核销、现场证据补传实现的业务网关。
abstract interface class ReservationVerificationGateway {
/// 核销当前查询结果中的预约订单,并在真实人脸识别时移交对应证据会话。
Future<bool> cancel({
required int orderId,
/// 逐笔核销当前查询结果中勾选的预约订单,并返回成功和失败范围。
Future<ReservationVerificationResult> cancel({
required List<int> orderIds,
String? faceEvidenceSessionId,
});
@@ -16,36 +38,75 @@ abstract interface class ReservationVerificationGateway {
});
}
/// 核销接口只接收订单 ID,日期、餐段、设备和识别信息均不得参与服务端订单选择。
/// 服务端核销接口只接收单个订单 ID,终端按操作员勾选顺序逐笔提交并汇总结果。
class TerminalReservationVerificationGateway
implements ReservationVerificationGateway {
/// 对所选订单执行单笔幂等核销;发生网络异常前已成功的订单会作为部分成功返回。
@override
Future<bool> cancel({
required int orderId,
Future<ReservationVerificationResult> cancel({
required List<int> orderIds,
String? faceEvidenceSessionId,
}) async {
if (faceEvidenceSessionId != null) {
// 先落服务端订单 ID,确保核销响应丢失或进程退出后仍能恢复这次现场图片。
await OrderEvidenceUploadTool.prepareSessionForExistingOrder(
recognitionSessionId: faceEvidenceSessionId,
serverOrderId: '$orderId',
);
final normalizedOrderIds = orderIds.toSet().toList(growable: false);
final succeededOrderIds = <int>[];
final failedOrderIds = <int>[];
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<void> _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;
}
/// 删除当前身份尚未绑定订单的现场图,避免仅查询菜单时保留无业务用途的人脸图片。
+6 -6
View File
@@ -20,7 +20,7 @@ Future<bool> _showResultDialogWithRecognitionPaused(
required ReservationMenuDayData day,
required ReservationMenuMealData meal,
required bool verificationEnabled,
Future<bool> Function(int orderId)? onConfirm,
Future<bool> Function(List<int> orderIds)? onConfirm,
}) async {
final acquired = await controller.beginResultDialogPresentation();
if (!acquired) return false;
@@ -257,8 +257,8 @@ class ReservationMenuQueryPage extends GetView<ReservationMenuQueryController> {
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<void> _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 {
@@ -12,7 +12,7 @@ Future<bool> reservationOrderDialog(
required ReservationMenuDayData day,
required ReservationMenuMealData meal,
required bool verificationEnabled,
Future<bool> Function(int orderId)? onConfirm,
Future<bool> Function(List<int> orderIds)? onConfirm,
}) async {
return await showAppDialog<bool>(
context: context,
@@ -35,7 +35,7 @@ Future<bool> reservationVerificationDialog(
required ReservationMenuDisplayData result,
required ReservationMenuDayData day,
required ReservationMenuMealData meal,
required Future<bool> Function(int orderId) onConfirm,
required Future<bool> Function(List<int> orderIds) onConfirm,
}) {
return reservationOrderDialog(
context,
@@ -47,7 +47,9 @@ Future<bool> 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<bool> Function(int orderId)? onConfirm;
/// 可核销模式下按选中的订单 ID 列表提交,并返回是否成功。
final Future<bool> Function(List<int> orderIds)? onConfirm;
/// 创建管理查看范围、勾选集合和提交状态的弹框状态对象。
@override
State<_ReservationOrderDialog> createState() =>
_ReservationOrderDialogState();
}
/// 预约订单弹框的本地交互状态,关闭后不持久化任何勾选或折叠结果。
class _ReservationOrderDialogState extends State<_ReservationOrderDialog> {
/// 已被操作员收起的套餐行标识;默认集合为空,因此套餐明细首次进入时全部展开。
final Set<String> _collapsedComboKeys = <String>{};
@@ -86,25 +90,54 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> {
/// 是否已经触发弹框关闭,避免重复 pop。
bool _closing = false;
/// 当前用户查看或准备核销的预约订单 ID。
late int _selectedOrderId;
/// 当前准备核销的预约订单 ID;默认包含当前餐段的全部订单。
late final Set<int> _selectedOrderIds;
/// 只读详情模式下当前查看的预约订单 ID;为空表示查看全部订单商品。
int? _displayedOrderId;
/// 首次进入时默认勾选全部预约;核销模式的右侧商品始终跟随该勾选集合。
@override
void initState() {
super.initState();
_selectedOrderId = widget.meal.orderIds.first;
_selectedOrderIds = widget.meal.orderIds.toSet();
}
/// 按服务端订单顺序返回当前已勾选的订单,避免集合顺序影响批量核销结果。
List<int> 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<int> get _displayedOrderIds => widget.verificationEnabled
? _orderedSelectedOrderIds
: _displayedOrderId == null
? widget.meal.orderIds
: <int>[_displayedOrderId!];
/// 提交当前勾选订单并根据 Controller 结果决定关闭弹框或恢复操作。
Future<void> _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 <Widget>[
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 <Widget>[
@@ -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 <Widget>[
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),
<Widget>[
_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 : <int>[orderId];
final items =
orderIds.expand(widget.meal.itemsForOrder).toList(growable: false);
final totalCount = items.fold<int>(
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: <Widget>[
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),
),
<Widget>[
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<int>(
0,
(sum, item) => sum + (item.productCount ?? 0),
(sum, displayed) => sum + (displayed.item.productCount ?? 0),
);
return <Widget>[
<Widget>[
<Widget>[
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<ReservationMenuOrderItemData> 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 <Widget>[
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(),
<Widget>[
@@ -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 <Widget>[
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 <Widget>[
<Widget>[
@@ -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: <Widget>[
_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;
}
@@ -43,6 +43,13 @@ class OrderHistoryController extends GetxController {
RxList<OrderEntity> orderList = RxList([]);
/// 列表只展开一笔订单,避免多个大详情块同时挤压横屏操作区。
final expandedOrderId = RxnInt();
final expandedDetailStatus = OrderRecordDetailLoadStatus.success.obs;
final expandedShopCardList = <ShopCardEntity>[].obs;
final expandedDetailError = ''.obs;
int _expandedDetailRequestId = 0;
/// 交易详情在当前页面以右侧抽屉展示,不再占用独立路由。
Rx<OrderEntity?> 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<void> 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<void> 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<void> retryExpandedOrder() async {
final id = expandedOrderId.value;
if (id == null) return;
await _loadExpandedOrder(id);
}
Future<void> _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<void> doTab(OrderStatus i) async {
if (i.code == currentStatus.value.code) return;
currentStatus.value = i;
+528 -243
View File
@@ -141,6 +141,7 @@ class OrderHistoryPage extends GetView<OrderHistoryController> {
color: const Color(0xFFF7F8FA),
child: _OrderGridRow(
children: const [
'明细',
'下单时间',
'订餐类型',
'支付类型',
@@ -171,7 +172,12 @@ class OrderHistoryPage extends GetView<OrderHistoryController> {
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<OrderHistoryController> {
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 <Widget>[
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: <Widget>[
_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,
),
),
),
],
),
@@ -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<void> delAllFace() async {
@@ -462,14 +467,29 @@ class FaceManageController extends GetxController {
}
}
/// 构建两个稳定尺寸、带选中语义的人脸管理文字 Tab。
Widget _buildFaceTabs(BuildContext context) {
/// 构建紧凑的人脸视图切换栏,并在低质量页同行展示质量摘要。
Widget _buildFaceHeader(BuildContext context) {
final controller = Get.find<FaceManageController>();
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<FaceManageController>();
final theme = FTheme.of(context);
return Obx(() {
final busy = controller.operationInProgress;
return <Widget>[
_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<FaceManageController>();
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 =
@@ -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<ServiceHardOrder>();
final expandedCarList = <ServiceCar>[].obs;
int _expandedDetailRequestId = 0;
/// 当前打开完整详情的核销记录;非空时由页面展示右侧订单详情抽屉。
final selectedRecord = Rxn<ServiceVerificationRecord>();
/// 完整详情抽屉中的服务端订单数据,加载失败时保持为空并展示错误态。
final selectedOrder = Rxn<ServiceHardOrder>();
/// 完整详情抽屉中的服务端购物车快照。
final selectedCarList = <ServiceCar>[].obs;
/// 完整详情抽屉订单和购物车数据的加载状态。
final selectedDetailStatus = OrderRecordDetailLoadStatus.success.obs;
/// 完整详情抽屉的可恢复错误信息。
final selectedDetailError = ''.obs;
/// 详情请求序号,用于丢弃关闭抽屉或快速切换后的旧响应。
int _selectedDetailRequestId = 0;
List<ServiceVerificationRecord> 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<void> 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<void> toggleExpandedRecord(ServiceVerificationRecord record) async {
if (expandedOrderId.value == record.orderId) {
_collapseExpandedRecord();
return;
}
expandedOrderId.value = record.orderId;
await _loadExpandedRecord(record.orderId);
}
Future<void> retryExpandedRecord() async {
final orderId = expandedOrderId.value;
if (orderId == null) return;
await _loadExpandedRecord(orderId);
}
/// 打开核销订单完整详情,并复用展开区相同的订单与购物车接口。
Future<void> 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<Object?>([
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<ServiceCar>?) ?? const <ServiceCar>[],
);
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<void> 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<void> _loadExpandedRecord(int orderId) async {
final requestId = ++_expandedDetailRequestId;
expandedDetailStatus.value = OrderRecordDetailLoadStatus.loading;
expandedDetailError.value = '';
expandedOrder.value = null;
expandedCarList.clear();
try {
final results = await Future.wait<Object?>([
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<ServiceCar>?) ?? const <ServiceCar>[],
);
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;
@@ -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<VerificationRecordsController> {
const VerificationRecordsPage({super.key});
/// 构建核销列表、分页和覆盖在页面右侧的完整详情抽屉。
@override
Widget build(BuildContext context) {
return <Widget>[
_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: [
<Widget>[
_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,
);
}),
],
);
}
/// 工具栏沿用交易记录的信息层级,并移除固定为预约核销后的无效类型筛选。
@@ -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<ServiceVerificationRecord> records;
final String errorMessage;
final VoidCallback onRetry;
final int? expandedOrderId;
final OrderRecordDetailLoadStatus expandedDetailStatus;
final ServiceHardOrder? expandedOrder;
final List<ServiceCar> expandedCarList;
final String expandedDetailError;
final ValueChanged<ServiceVerificationRecord> onToggleExpanded;
final VoidCallback onRetryExpanded;
/// 请求页面打开指定核销订单的完整详情抽屉。
final ValueChanged<ServiceVerificationRecord> 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: [
<Widget>[
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),
),
<Widget>[
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),
<Widget>[
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),
<Widget>[
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<OrderRecordDetailItem> _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 <Widget>[
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(),
),
),
),
);
}
}
@@ -129,12 +129,12 @@ void main() {
expect(controller.value, {'1'});
});
testWidgets('用户手动选择不匹配餐段时确认前不提交且确认后切到配置餐段', (tester) async {
final controller = FRadioSelectGroupController<String>(value: '1');
testWidgets('用户手动选择不匹配餐段时确认前不提交且确认后保留强制选择', (tester) async {
final controller = FRadioSelectGroupController<String>(value: '3');
addTearDown(controller.dispose);
final committedValues = <Set<String>>[];
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<String>));
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<String>(value: '1');
testWidgets('用户取消强制选择时在弹框关闭后恢复原全局餐段', (tester) async {
final controller = FRadioSelectGroupController<String>(value: '3');
addTearDown(controller.dispose);
final committedValues = <Set<String>>[];
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<String>));
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 {
+34
View File
@@ -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) {
@@ -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<ServiceCar> _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);
});
}
@@ -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');
});
}
@@ -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 = <String>[];
final successes = <String>[];
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 = <String>[];
/// 测试指定的逐笔结果;为空时根据传入订单构造全部成功。
final ReservationVerificationResult? result;
/// 记录订单列表与人脸会话,并模拟所选订单全部核销成功。
@override
Future<bool> cancel({
required int orderId,
Future<ReservationVerificationResult> cancel({
required List<int> 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<void> 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<int> 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 {
@@ -89,7 +89,7 @@ void main() {
testWidgets('核销弹框展示移动端确认页的关键信息并支持取消', (tester) async {
_useTerminalViewport(tester);
int? selectedOrderId;
List<int>? 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<Checkbox>(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<int>? 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<int>? 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<Checkbox>(allCheckbox).value, isNull);
expect(tester.widget<Checkbox>(firstCheckbox).value, isTrue);
expect(tester.widget<Checkbox>(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<Checkbox>(allCheckbox).value, isTrue);
expect(tester.widget<Checkbox>(secondCheckbox).value, isTrue);
expect(find.text('全部订单商品'), findsOneWidget);
expect(find.text('黄瓜炒鸡蛋'), findsOneWidget);
await tester.tap(allCheckbox);
await tester.pump();
expect(tester.widget<Checkbox>(allCheckbox).value, isFalse);
final disabledConfirm = tester.widget<FButton>(
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<Checkbox>(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 {