feat:同时核销几笔订单
feat:人脸质量检测
This commit is contained in:
1 parent
917f03bc58
commit
9cc99deed7
28 files changed
+2324
-708
No files matched your search
@@ -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 基线、数据库、人脸插件、双屏协议、支付编码或其他跨项目契约时,必须同步评估两个终端项目,禁止单边分叉。
|
||||
+32
@@ -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;
|
||||
}
|
||||
|
||||
+16
@@ -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();
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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|');
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// 删除当前身份尚未绑定订单的现场图,避免仅查询菜单时保留无业务用途的人脸图片。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user