diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index 3268606..005a496 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -1,3 +1,5 @@ +import 'package:dio/dio.dart' as dio; + import 'package:pj_supermarket_cloud/common/index.dart'; abstract class HardApi { @@ -47,11 +49,11 @@ abstract class HardApi { /// 仅按当前终端和识别身份查询本人预约,调用方不得补充商户或其他用户参数。 static Future queryReservationMenu( - Map formData, + ReservationMenuQueryRequest request, ) async { final res = await HttpManage().dio.post( "/hard/hard-api/ad/reservation-menu/query", - data: formData, + data: request.toJson(), ); if (res.data == null) return null; return ServiceReservationMenuQuery.fromJson(res.data); @@ -61,13 +63,35 @@ abstract class HardApi { static Future cancelReservationOrder({ required int orderId, }) async { + final setting = await AppSettingTool.getSysSetting(); final res = await HttpManage().dio.post( - '/hard/hard-api/ad/reservation-menu/cancel', - data: {'orderId': orderId}, - ); + '/hard/hard-api/ad/reservation-menu/cancel', + data: {'orderId': orderId}, + options: dio.Options( + headers: { + if (setting.serviceId != null) 'DEVICE-ID': setting.serviceId, + }, + ), + ); return res.data == true; } + /// 分页查询当前设备的全部核销记录,设备范围由服务端再次校验。 + static Future verificationRecordPage({ + required int current, + required int size, + required VerificationRecordQueryRequest request, + }) async { + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/verification-record/page', + queryParameters: {'current': current, 'size': size}, + data: request.toJson(), + options: dio.Options(headers: {'DEVICE-ID': request.hardId}), + ); + if (res.data == null) return null; + return ServiceVerificationRecordPage.fromJson(res.data); + } + /// 创建硬件订单 static Future createHardwareOrder(dynamic formData) async { final res = await HttpManage().dio.post( diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart index 66787bc..fe537e8 100644 --- a/lib/common/models/api/hard_api_models.dart +++ b/lib/common/models/api/hard_api_models.dart @@ -253,6 +253,78 @@ class ServiceHardOrder { Map toJson() => _$ServiceHardOrderToJson(this); } +/// 当前终端核销记录的服务端查询参数。 +@JsonSerializable(includeIfNull: false) +class VerificationRecordQueryRequest { + const VerificationRecordQueryRequest({ + required this.hardId, + required this.startDate, + required this.endDate, + this.verificationType, + }); + + final int hardId; + final String startDate; + final String endDate; + final String? verificationType; + + factory VerificationRecordQueryRequest.fromJson(Map json) => + _$VerificationRecordQueryRequestFromJson(json); + + Map toJson() => _$VerificationRecordQueryRequestToJson(this); +} + +/// 核销记录只承载列表展示字段,不复用本地订单实体,避免混淆远端与本地状态。 +@JsonSerializable() +class ServiceVerificationRecord { + const ServiceVerificationRecord({ + required this.orderId, + required this.orderNo, + required this.verificationType, + required this.verificationTypeName, + required this.username, + required this.productDesc, + required this.payTypeName, + required this.totalPrice, + required this.verificationTime, + this.userDeptName, + }); + + /// 后端 Long 为避免跨端精度丢失可能序列化为字符串,终端同时兼容数字返回。 + @JsonKey(fromJson: JsonTool.anyToInt) + final int orderId; + final String orderNo; + final String verificationType; + final String verificationTypeName; + final String username; + final String? userDeptName; + final String productDesc; + final String payTypeName; + + /// 金额仍按整数分使用,同时兼容部分环境将数值序列化为字符串。 + @JsonKey(fromJson: JsonTool.anyToInt) + final int totalPrice; + final DateTime verificationTime; + + factory ServiceVerificationRecord.fromJson(Map json) => + _$ServiceVerificationRecordFromJson(json); + + Map toJson() => _$ServiceVerificationRecordToJson(this); +} + +@JsonSerializable() +class ServiceVerificationRecordPage extends ServicePageBase { + ServiceVerificationRecordPage({this.records}); + + final List? records; + + factory ServiceVerificationRecordPage.fromJson(Map json) => + _$ServiceVerificationRecordPageFromJson(json); + + @override + Map toJson() => _$ServiceVerificationRecordPageToJson(this); +} + @JsonSerializable() class ServiceOpenCard { /// 用户名 @@ -521,13 +593,45 @@ class ServiceHardCreateHardQrCodeScanOrder { _$ServiceHardCreateHardQrCodeScanOrderToJson(this); } -/// 终端识别本人后返回的预约菜单,身份字段已经由服务端脱敏。 +/// 终端本人预约菜单查询参数。 +@JsonSerializable(includeIfNull: false) +class ReservationMenuQueryRequest { + const ReservationMenuQueryRequest({ + required this.hardId, + required this.recognitionType, + required this.maskName, + this.cardNumber, + this.faceUserId, + }); + + /// 当前终端设备 ID。 + final int hardId; + + /// 本人识别方式,沿用后端 CARD、FACE 契约。 + final String recognitionType; + + /// 是否由服务端脱敏用户姓名。 + final bool maskName; + + /// 饭卡卡号,仅 CARD 识别时传递。 + final String? cardNumber; + + /// 人脸插件识别出的用户 ID,仅 FACE 识别时传递。 + final int? faceUserId; + + factory ReservationMenuQueryRequest.fromJson(Map json) => + _$ReservationMenuQueryRequestFromJson(json); + + Map toJson() => _$ReservationMenuQueryRequestToJson(this); +} + +/// 终端识别本人后返回的预约菜单,姓名由服务端按终端配置决定是否脱敏。 @JsonSerializable(explicitToJson: true) class ServiceReservationMenuQuery { - /// 服务端脱敏后的用餐人姓名。 + /// 服务端按终端配置返回的用户姓名;字段名保留用于兼容既有协议。 final String maskedName; - /// 用餐人所属部门或班级名称。 + /// 用户所属部门名称。 final String? deptName; /// 当前终端绑定的食堂名称。 diff --git a/lib/common/models/api/hard_api_models.g.dart b/lib/common/models/api/hard_api_models.g.dart index 9fc2bea..cb757a0 100644 --- a/lib/common/models/api/hard_api_models.g.dart +++ b/lib/common/models/api/hard_api_models.g.dart @@ -143,6 +143,86 @@ Map _$ServiceHardOrderToJson(ServiceHardOrder instance) => 'walletId': instance.walletId, }; +VerificationRecordQueryRequest _$VerificationRecordQueryRequestFromJson( + Map json) => + VerificationRecordQueryRequest( + hardId: (json['hardId'] as num).toInt(), + startDate: json['startDate'] as String, + endDate: json['endDate'] as String, + verificationType: json['verificationType'] as String?, + ); + +Map _$VerificationRecordQueryRequestToJson( + VerificationRecordQueryRequest instance) { + final val = { + 'hardId': instance.hardId, + 'startDate': instance.startDate, + 'endDate': instance.endDate, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('verificationType', instance.verificationType); + return val; +} + +ServiceVerificationRecord _$ServiceVerificationRecordFromJson( + Map json) => + ServiceVerificationRecord( + orderId: JsonTool.anyToInt(json['orderId']), + orderNo: json['orderNo'] as String, + verificationType: json['verificationType'] as String, + verificationTypeName: json['verificationTypeName'] as String, + username: json['username'] as String, + productDesc: json['productDesc'] as String, + payTypeName: json['payTypeName'] as String, + totalPrice: JsonTool.anyToInt(json['totalPrice']), + verificationTime: DateTime.parse(json['verificationTime'] as String), + userDeptName: json['userDeptName'] as String?, + ); + +Map _$ServiceVerificationRecordToJson( + ServiceVerificationRecord instance) => + { + 'orderId': instance.orderId, + 'orderNo': instance.orderNo, + 'verificationType': instance.verificationType, + 'verificationTypeName': instance.verificationTypeName, + 'username': instance.username, + 'userDeptName': instance.userDeptName, + 'productDesc': instance.productDesc, + 'payTypeName': instance.payTypeName, + 'totalPrice': instance.totalPrice, + 'verificationTime': instance.verificationTime.toIso8601String(), + }; + +ServiceVerificationRecordPage _$ServiceVerificationRecordPageFromJson( + Map json) => + ServiceVerificationRecordPage( + records: (json['records'] as List?) + ?.map((e) => + ServiceVerificationRecord.fromJson(e as Map)) + .toList(), + ) + ..current = (json['current'] as num).toInt() + ..pages = (json['pages'] as num).toInt() + ..size = (json['size'] as num).toInt() + ..total = (json['total'] as num).toInt(); + +Map _$ServiceVerificationRecordPageToJson( + ServiceVerificationRecordPage instance) => + { + 'current': instance.current, + 'pages': instance.pages, + 'size': instance.size, + 'total': instance.total, + 'records': instance.records, + }; + ServiceOpenCard _$ServiceOpenCardFromJson(Map json) => ServiceOpenCard( username: json['username'] as String?, @@ -337,6 +417,35 @@ Map _$ServiceHardCreateHardQrCodeScanOrderToJson( 'qrCodeData': instance.qrCodeData, }; +ReservationMenuQueryRequest _$ReservationMenuQueryRequestFromJson( + Map json) => + ReservationMenuQueryRequest( + hardId: (json['hardId'] as num).toInt(), + recognitionType: json['recognitionType'] as String, + maskName: json['maskName'] as bool, + cardNumber: json['cardNumber'] as String?, + faceUserId: (json['faceUserId'] as num?)?.toInt(), + ); + +Map _$ReservationMenuQueryRequestToJson( + ReservationMenuQueryRequest instance) { + final val = { + 'hardId': instance.hardId, + 'recognitionType': instance.recognitionType, + 'maskName': instance.maskName, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('cardNumber', instance.cardNumber); + writeNotNull('faceUserId', instance.faceUserId); + return val; +} + ServiceReservationMenuQuery _$ServiceReservationMenuQueryFromJson( Map json) => ServiceReservationMenuQuery( diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart index 2ee8bc2..36a03ee 100644 --- a/lib/common/models/index.dart +++ b/lib/common/models/index.dart @@ -14,6 +14,7 @@ export './page/order_page_enum_models.dart'; export './page/order_page_models.dart'; export './page/reservation_menu_page_models.dart'; export './page/setting_page_enum_models.dart'; +export './page/verification_record_page_models.dart'; export './page/wallet_page_enum_models.dart'; export './mq/mq_enum_models.dart'; diff --git a/lib/common/models/page/reservation_menu_page_models.dart b/lib/common/models/page/reservation_menu_page_models.dart index faa4055..cb3a938 100644 --- a/lib/common/models/page/reservation_menu_page_models.dart +++ b/lib/common/models/page/reservation_menu_page_models.dart @@ -81,6 +81,7 @@ class ReservationQueryPagePreferences { const ReservationQueryPagePreferences({ required this.methodOrder, required this.defaultMethod, + this.nameMaskEnabled = true, this.verificationEnabled = false, this.verificationDateScope = ReservationVerificationDateScope.today, }); @@ -89,6 +90,7 @@ class ReservationQueryPagePreferences { static const defaults = ReservationQueryPagePreferences( methodOrder: ReservationRecognitionMethod.values, defaultMethod: ReservationRecognitionMethod.card, + nameMaskEnabled: true, verificationEnabled: false, verificationDateScope: ReservationVerificationDateScope.today, ); @@ -99,6 +101,9 @@ class ReservationQueryPagePreferences { /// 页面首次进入时默认选中的查询方式。 final ReservationRecognitionMethod defaultMethod; + /// 查询结果中的用户姓名是否按服务端规则脱敏,默认开启以兼容旧终端。 + final bool nameMaskEnabled; + /// 查询结果是否显示预约订单核销入口。 final bool verificationEnabled; @@ -395,10 +400,10 @@ class ReservationMenuDisplayData { ); } - /// 服务端脱敏后的用餐人姓名。 + /// 服务端按终端配置返回的用户姓名;字段名保留 maskedName 以兼容既有协议。 final String maskedName; - /// 用餐人所属部门或班级名称。 + /// 用户所属部门名称。 final String? deptName; /// 当前终端绑定的食堂名称。 diff --git a/lib/common/models/page/setting_page_enum_models.dart b/lib/common/models/page/setting_page_enum_models.dart index 524d065..5c04794 100644 --- a/lib/common/models/page/setting_page_enum_models.dart +++ b/lib/common/models/page/setting_page_enum_models.dart @@ -32,7 +32,8 @@ enum TerminalMenuItemType implements BaseEnum { menuQuery("menu_query", "查询菜单"), reservation("reservation", "预定"), verification("verification", "核销"), - transactions("transactions", "交易"), + transactions("transactions", "交易记录"), + verificationRecords("verification_records", "核销记录"), statistics("statistics", "统计"), card("card", "开卡"), settings("settings", "设置"), @@ -53,6 +54,7 @@ enum TerminalMenuItemType implements BaseEnum { reservation, verification, transactions, + verificationRecords, statistics, card, settings, @@ -64,6 +66,7 @@ enum TerminalMenuItemType implements BaseEnum { TerminalMenuItemType.reservation => FAssets.icons.calendar, TerminalMenuItemType.verification => FAssets.icons.scan, TerminalMenuItemType.transactions => FAssets.icons.coins, + TerminalMenuItemType.verificationRecords => FAssets.icons.receiptText, TerminalMenuItemType.statistics => FAssets.icons.chartPie, TerminalMenuItemType.card => FAssets.icons.creditCard, TerminalMenuItemType.settings => FAssets.icons.settings, diff --git a/lib/common/models/page/verification_record_page_models.dart b/lib/common/models/page/verification_record_page_models.dart new file mode 100644 index 0000000..45dfe40 --- /dev/null +++ b/lib/common/models/page/verification_record_page_models.dart @@ -0,0 +1,18 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 核销类型筛选与后端稳定编码一一对应,全部类型不传编码。 +enum VerificationRecordType implements BaseEnum { + all('', '全部核销'), + reservation('RESERVATION', '预约核销'), + activity('ACTIVITY', '活动核销'), + other('OTHER', '二维码核销'); + + @override + final String code; + @override + final String title; + + const VerificationRecordType(this.code, this.title); +} + +enum VerificationRecordLoadStatus { loading, success, failure } diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index b548242..ed17a95 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -35,6 +35,9 @@ class RouteNames { /// 交易查询 static const orderHistory = '$terminal/order_history'; + /// 当前设备的远端核销记录。 + static const verificationRecords = '$terminal/verification_records'; + /// 设置 static const setting = '$terminal/setting'; diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index d8cef42..34170d1 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -18,6 +18,8 @@ abstract class AppSettingTool { "reservationQueryMethodOrder"; static const String _reservationQueryDefaultMethodKey = "reservationQueryDefaultMethod"; + static const String _reservationQueryNameMaskEnabledKey = + "reservationQueryNameMaskEnabled"; static const String _reservationQueryVerificationEnabledKey = "reservationQueryVerificationEnabled"; static const String _reservationQueryVerificationDateScopeKey = @@ -272,6 +274,8 @@ abstract class AppSettingTool { return ReservationQueryPagePreferences( methodOrder: List.unmodifiable(restored), defaultMethod: defaultMethod, + // 旧设置没有该键时继续脱敏,避免升级后意外展示完整姓名。 + nameMaskEnabled: sysJson[_reservationQueryNameMaskEnabledKey] != false, verificationEnabled: sysJson[_reservationQueryVerificationEnabledKey] == true, verificationDateScope: verificationDateScope, @@ -282,6 +286,7 @@ abstract class AppSettingTool { static Future setReservationQueryPagePreferences({ required List order, required ReservationRecognitionMethod defaultMethod, + required bool nameMaskEnabled, required bool verificationEnabled, required ReservationVerificationDateScope verificationDateScope, }) async { @@ -304,6 +309,7 @@ abstract class AppSettingTool { sysJson[_reservationQueryMethodOrderKey] = normalized.map((method) => method.code).toList(); sysJson[_reservationQueryDefaultMethodKey] = normalizedDefault.code; + sysJson[_reservationQueryNameMaskEnabledKey] = nameMaskEnabled; sysJson[_reservationQueryVerificationEnabledKey] = verificationEnabled; sysJson[_reservationQueryVerificationDateScopeKey] = verificationDateScope.code; diff --git a/lib/common/widgets/app/terminal_shell.dart b/lib/common/widgets/app/terminal_shell.dart index 156c4d0..74e61b8 100644 --- a/lib/common/widgets/app/terminal_shell.dart +++ b/lib/common/widgets/app/terminal_shell.dart @@ -16,6 +16,7 @@ enum TerminalNavType { reservation, verification, transactions, + verificationRecords, statistics, card, settings, @@ -28,6 +29,8 @@ extension on TerminalNavType { TerminalNavType.reservation => TerminalMenuItemType.reservation, TerminalNavType.verification => TerminalMenuItemType.verification, TerminalNavType.transactions => TerminalMenuItemType.transactions, + TerminalNavType.verificationRecords => + TerminalMenuItemType.verificationRecords, TerminalNavType.statistics => TerminalMenuItemType.statistics, TerminalNavType.card => TerminalMenuItemType.card, TerminalNavType.settings => TerminalMenuItemType.settings, @@ -485,6 +488,12 @@ _TerminalNavItem _terminalNavItem(TerminalMenuItemType type) => switch (type) { icon: type.icon, route: RouteNames.orderHistory, ), + TerminalMenuItemType.verificationRecords => _TerminalNavItem( + type: type, + label: type.title, + icon: type.icon, + route: RouteNames.verificationRecords, + ), TerminalMenuItemType.statistics => _TerminalNavItem( type: type, label: type.title, diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index df0ea56..e4be2d8 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -77,6 +77,10 @@ class ReservationMenuQueryController extends GetxController { final methodOrder = [ ...ReservationRecognitionMethod.values, ].obs; + + /// 当前页面查询请求是否要求服务端脱敏用户姓名。 + final nameMaskEnabled = true.obs; + final verificationEnabled = false.obs; final verificationDateScope = ReservationVerificationDateScope.today.obs; final status = ReservationMenuQueryStatus.idle.obs; @@ -120,6 +124,7 @@ class ReservationMenuQueryController extends GetxController { if (_closed) return; methodOrder.assignAll(preferences.methodOrder); method.value = preferences.defaultMethod; + nameMaskEnabled.value = preferences.nameMaskEnabled; verificationEnabled.value = preferences.verificationEnabled; verificationDateScope.value = preferences.verificationDateScope; } catch (error, stack) { @@ -229,6 +234,7 @@ class ReservationMenuQueryController extends GetxController { final response = await _repository.query( method: method.value, identityValue: identityValue, + maskName: nameMaskEnabled.value, ); // 新的刷卡或人脸事件已经开始时,旧请求即使成功也不能覆盖新身份。 if (_closed || generation != _requestGeneration) return; @@ -413,6 +419,7 @@ class ReservationMenuQueryController extends GetxController { final response = await _repository.query( method: method, identityValue: identityValue, + maskName: nameMaskEnabled.value, ); if (response == null) throw StateError('核销后未获取到最新预约结果'); result.value = ReservationMenuDisplayData.fromService(response); diff --git a/lib/pages/terminal/menu_query/reservation_menu_repository.dart b/lib/pages/terminal/menu_query/reservation_menu_repository.dart index c17c94c..2b366bc 100644 --- a/lib/pages/terminal/menu_query/reservation_menu_repository.dart +++ b/lib/pages/terminal/menu_query/reservation_menu_repository.dart @@ -4,6 +4,7 @@ abstract interface class ReservationMenuRepository { Future query({ required ReservationRecognitionMethod method, required String identityValue, + required bool maskName, }); } @@ -12,6 +13,7 @@ class HardReservationMenuRepository implements ReservationMenuRepository { Future query({ required ReservationRecognitionMethod method, required String identityValue, + required bool maskName, }) async { final setting = await AppSettingTool.getSysSetting(); final hardId = int.tryParse(setting.serviceId ?? ''); @@ -20,20 +22,24 @@ class HardReservationMenuRepository implements ReservationMenuRepository { '设备尚未绑定有效终端,请先前往设置完成绑定', ); } - final data = { - 'hardId': hardId, - 'recognitionType': method.serviceCode, - }; + String? cardNumber; + int? faceUserId; if (method == ReservationRecognitionMethod.card) { - data['cardNumber'] = identityValue; + cardNumber = identityValue; } else { - final faceUserId = int.tryParse(identityValue); + faceUserId = int.tryParse(identityValue); if (faceUserId == null) { throw const ReservationMenuIdentityException('人脸用户编号无效,请重新识别'); } - data['faceUserId'] = faceUserId; } - return HardApi.queryReservationMenu(data); + final request = ReservationMenuQueryRequest( + hardId: hardId, + recognitionType: method.serviceCode, + maskName: maskName, + cardNumber: cardNumber, + faceUserId: faceUserId, + ); + return HardApi.queryReservationMenu(request); } } diff --git a/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart b/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart index 06a3a63..60adece 100644 --- a/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart +++ b/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart @@ -61,7 +61,7 @@ class TerminalReservationRecognitionGateway context, scene: HardwareInputScene.readCard, featureKey: 'reservation_query_card', - description: '调试模式仅输入当前学生自己的饭卡卡号。', + description: '调试模式仅输入当前用户本人的饭卡卡号。', ); if (cardNumber == null) { onFailure(const ReservationRecognitionFailure( @@ -131,7 +131,7 @@ class TerminalReservationRecognitionGateway context, scene: HardwareInputScene.faceRecognition, featureKey: 'reservation_query_face_user_id', - description: '调试模式仅输入虹软插件实际识别出的当前学生用户编号。', + description: '调试模式仅输入虹软插件实际识别出的当前用户编号。', ); if (userId == null) { onFailure(const ReservationRecognitionFailure( diff --git a/lib/pages/terminal/menu_query/view.dart b/lib/pages/terminal/menu_query/view.dart index 1274669..a064cfb 100644 --- a/lib/pages/terminal/menu_query/view.dart +++ b/lib/pages/terminal/menu_query/view.dart @@ -83,7 +83,7 @@ class ReservationMenuQueryPage extends GetView { size: 19.w, ), Text( - '饭卡或人脸只用于识别当前学生本人,系统不会提供其他学生的选择入口。', + '饭卡或人脸只用于识别当前用户本人,系统不会提供其他用户的选择入口。', style: TextStyle( color: XfjDesignTokens.muted, fontSize: 13.sp, diff --git a/lib/pages/terminal/menu_query/widgets/identity_summary.dart b/lib/pages/terminal/menu_query/widgets/identity_summary.dart index 56588ba..c306f87 100644 --- a/lib/pages/terminal/menu_query/widgets/identity_summary.dart +++ b/lib/pages/terminal/menu_query/widgets/identity_summary.dart @@ -64,7 +64,7 @@ class IdentitySummary extends StatelessWidget { children: [ Flexible( child: Text( - '${result!.maskedName} 小朋友', + result!.maskedName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -97,7 +97,7 @@ class IdentitySummary extends StatelessWidget { ), SizedBox(height: 5.h), Text( - '${result!.deptName ?? '班级信息未配置'} · 身份已自动匹配', + '${result!.deptName ?? '部门信息未配置'} · 身份已自动匹配', maxLines: 2, style: TextStyle( color: XfjDesignTokens.muted, diff --git a/lib/pages/terminal/menu_query/widgets/reservation_result_panel.dart b/lib/pages/terminal/menu_query/widgets/reservation_result_panel.dart index 62907e8..a31161c 100644 --- a/lib/pages/terminal/menu_query/widgets/reservation_result_panel.dart +++ b/lib/pages/terminal/menu_query/widgets/reservation_result_panel.dart @@ -87,7 +87,7 @@ class ReservationResultPanel extends StatelessWidget { icon: FAssets.icons.calendarCheck, color: XfjDesignTokens.muted, title: '暂未查询到预约菜单', - detail: '当前食堂没有该学生今天及未来的有效预约', + detail: '当前用户在本食堂今天及未来暂无有效预约', ), ), ); @@ -340,7 +340,7 @@ class _ResultFooter extends StatelessWidget { Expanded( child: Text( status == ReservationMenuQueryStatus.success - ? '已展示当前页全部预约日期,内容以学校最终确认结果为准' + ? '已展示当前页全部预约日期,内容以最终预约记录为准' : '查询结果仅用于本人查看,离开页面或倒计时结束后会自动清除', maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/pages/terminal/setting/controller.dart b/lib/pages/terminal/setting/controller.dart index 39abfad..7bc8432 100644 --- a/lib/pages/terminal/setting/controller.dart +++ b/lib/pages/terminal/setting/controller.dart @@ -24,6 +24,10 @@ class SettingController extends GetxController with WidgetsBindingObserver { ...ReservationRecognitionMethod.values, ].obs; final reservationQueryDefaultMethod = ReservationRecognitionMethod.card.obs; + + /// 查询菜单是否要求服务端脱敏用户姓名。 + final reservationQueryNameMaskEnabled = true.obs; + final reservationQueryVerificationEnabled = false.obs; final reservationQueryVerificationDateScope = ReservationVerificationDateScope.today.obs; @@ -131,6 +135,8 @@ class SettingController extends GetxController with WidgetsBindingObserver { ); reservationQueryDefaultMethod.value = reservationQueryPreferences.defaultMethod; + reservationQueryNameMaskEnabled.value = + reservationQueryPreferences.nameMaskEnabled; reservationQueryVerificationEnabled.value = reservationQueryPreferences.verificationEnabled; reservationQueryVerificationDateScope.value = @@ -185,6 +191,12 @@ class SettingController extends GetxController with WidgetsBindingObserver { _scheduleReservationQueryPreferencesPersist(); } + void setReservationQueryNameMaskEnabled(bool enabled) { + if (reservationQueryNameMaskEnabled.value == enabled) return; + reservationQueryNameMaskEnabled.value = enabled; + _scheduleReservationQueryPreferencesPersist(); + } + void setReservationQueryVerificationDateScope( ReservationVerificationDateScope scope, ) { @@ -198,6 +210,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { ReservationRecognitionMethod.values, ); reservationQueryDefaultMethod.value = ReservationRecognitionMethod.card; + reservationQueryNameMaskEnabled.value = true; reservationQueryVerificationEnabled.value = false; reservationQueryVerificationDateScope.value = ReservationVerificationDateScope.today; @@ -220,6 +233,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { await AppSettingTool.setReservationQueryPagePreferences( order: reservationQueryMethodOrder.toList(), defaultMethod: reservationQueryDefaultMethod.value, + nameMaskEnabled: reservationQueryNameMaskEnabled.value, verificationEnabled: reservationQueryVerificationEnabled.value, verificationDateScope: reservationQueryVerificationDateScope.value, ); diff --git a/lib/pages/terminal/setting/widgets/setting_page.dart b/lib/pages/terminal/setting/widgets/setting_page.dart index 6df45d7..e5c579a 100644 --- a/lib/pages/terminal/setting/widgets/setting_page.dart +++ b/lib/pages/terminal/setting/widgets/setting_page.dart @@ -178,10 +178,10 @@ class _ReservationQuerySettings extends StatelessWidget { @override Widget build(BuildContext context) => [ _queryMethodSettings(controller), + _nameMaskSettings(controller), _verificationSettings(controller), SettingNotice( - text: - '查询顺序和默认项会在下次进入“查询菜单”时生效;进入页面和切换方式都不会自动启动饭卡或人脸识别。核销按钮配置同样在下次进入查询页面后读取。', + text: '查询顺序、默认项、姓名脱敏和核销配置会在下次进入“查询菜单”时读取;进入页面和切换方式都不会自动启动饭卡或人脸识别。', icon: FAssets.icons.circleAlert, ), ].toColumnSpace(space: XfjDesignTokens.cardGap.h).scrollable(); @@ -227,6 +227,27 @@ Widget _queryMethodSettings(SettingController controller) => Obx(() { ].toColumnSpace(space: XfjDesignTokens.cardGap.h); }); +/// 姓名展示配置独立观察,切换时不会重建查询方式排序区域。 +Widget _nameMaskSettings(SettingController controller) => Obx(() { + final enabled = controller.reservationQueryNameMaskEnabled.value; + return SettingGroup( + title: '隐私设置', + child: SettingInfoRow( + icon: enabled ? FAssets.icons.shieldCheck : FAssets.icons.user, + label: '姓名信息脱敏', + subtitle: + enabled ? '查询结果仅展示脱敏后的用户姓名。' : '查询结果展示完整用户姓名,请确认当前终端使用环境安全。', + value: enabled ? '已开启' : '已关闭', + showDivider: false, + trailing: FSwitch( + semanticLabel: '查询菜单姓名信息脱敏', + value: enabled, + onChange: controller.setReservationQueryNameMaskEnabled, + ), + ), + ); + }); + /// 核销配置独立观察自己的 Rx,开关变化不会重建上方排序列表。 Widget _verificationSettings(SettingController controller) => Obx(() { final enabled = controller.reservationQueryVerificationEnabled.value; @@ -695,6 +716,7 @@ class _MoveButton extends StatelessWidget { String _pageTitle(TerminalMenuItemType type) => switch (type) { TerminalMenuItemType.transactions => '交易查询', + TerminalMenuItemType.verificationRecords => '核销记录', TerminalMenuItemType.statistics => '交易统计', TerminalMenuItemType.card => '开卡管理', _ => type.title, diff --git a/lib/pages/terminal/verification_records/controller.dart b/lib/pages/terminal/verification_records/controller.dart new file mode 100644 index 0000000..19ce506 --- /dev/null +++ b/lib/pages/terminal/verification_records/controller.dart @@ -0,0 +1,119 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class VerificationRecordsController extends GetxController { + final keywordController = TextEditingController(); + + int _loadRequestId = 0; + + final status = VerificationRecordLoadStatus.loading.obs; + final records = [].obs; + final selectedType = VerificationRecordType.all.obs; + final selectDateTime = [ + DateTime.now(), + DateTime.now(), + ].obs; + final currentPage = 1.obs; + final pageSize = 12.obs; + final total = 0.obs; + final errorMessage = ''.obs; + + List get visibleRecords { + final keyword = keywordController.text.trim().toLowerCase(); + if (keyword.isEmpty) return records; + return records.where((record) { + return record.orderNo.toLowerCase().contains(keyword) || + record.username.toLowerCase().contains(keyword) || + (record.userDeptName ?? '').toLowerCase().contains(keyword) || + record.productDesc.toLowerCase().contains(keyword); + }).toList(growable: false); + } + + @override + void onInit() { + super.onInit(); + loadRecords(); + } + + @override + void onClose() { + keywordController.dispose(); + super.onClose(); + } + + Future loadRecords({bool resetPage = false}) async { + if (resetPage) currentPage.value = 1; + final requestId = ++_loadRequestId; + status.value = VerificationRecordLoadStatus.loading; + errorMessage.value = ''; + try { + final setting = await AppSettingTool.getSysSetting(); + final hardId = int.tryParse(setting.serviceId ?? ''); + if (hardId == null) { + throw StateError('设备尚未绑定,请先前往设置完成绑定'); + } + final response = await HardApi.verificationRecordPage( + current: currentPage.value, + size: pageSize.value, + request: VerificationRecordQueryRequest( + hardId: hardId, + startDate: DateUtil.formatDate( + selectDateTime[0], + format: DateFormats.y_mo_d, + ), + endDate: DateUtil.formatDate( + selectDateTime[1], + format: DateFormats.y_mo_d, + ), + verificationType: selectedType.value == VerificationRecordType.all + ? null + : selectedType.value.code, + ), + ); + if (response == null) throw StateError('服务端未返回核销记录'); + // 快速切换类型、日期或页码时,只允许最后一次请求刷新列表。 + if (requestId != _loadRequestId) return; + records.assignAll(response.records ?? const []); + total.value = response.total; + status.value = VerificationRecordLoadStatus.success; + } catch (error, stack) { + if (requestId != _loadRequestId) return; + records.clear(); + total.value = 0; + errorMessage.value = _readableError(error); + status.value = VerificationRecordLoadStatus.failure; + logger.e('|核销记录|加载失败error:|$error|stack:|$stack|'); + } + } + + Future changeType(VerificationRecordType type) async { + if (selectedType.value == type) return; + selectedType.value = type; + await loadRecords(resetPage: true); + } + + Future changeDateRange(List? dates) async { + if (dates == null || dates.length < 2) return; + selectDateTime.assignAll(dates.take(2)); + await loadRecords(resetPage: true); + } + + Future changePage(int page) async { + if (page == currentPage.value) return; + currentPage.value = page; + await loadRecords(); + } + + void applyKeyword() { + FocusManager.instance.primaryFocus?.unfocus(); + records.refresh(); + } + + String _readableError(Object error) { + final message = error.toString().replaceFirst('Bad state: ', ''); + return message.isEmpty ? '核销记录加载失败,请检查网络后重试' : message; + } +} diff --git a/lib/pages/terminal/verification_records/view.dart b/lib/pages/terminal/verification_records/view.dart new file mode 100644 index 0000000..3571346 --- /dev/null +++ b/lib/pages/terminal/verification_records/view.dart @@ -0,0 +1,200 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; +import 'widgets/verification_record_table.dart'; + +class VerificationRecordsPage extends GetView { + const VerificationRecordsPage({super.key}); + + @override + Widget build(BuildContext context) { + return [ + _buildToolbar(context), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx(() => VerificationRecordTable( + status: controller.status.value, + records: controller.visibleRecords, + errorMessage: controller.errorMessage.value, + onRetry: () => controller.loadRecords(), + )).expanded(), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx(() => appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.total.value, + itemLabel: '条核销记录', + onPageChanged: controller.changePage, + )), + ].toColumn().padding(all: 8.w); + } + + /// 筛选区沿用交易记录的布局语义,但压缩为一行标题、一行控件,给列表更多纵向空间。 + Widget _buildToolbar(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), + child: [ + [ + [ + Text( + '核销记录', + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(width: 10.w), + Text( + '仅显示当前设备已完成的核销订单', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center), + const Spacer(), + Obx(() => Text( + '共 ${controller.total.value} 条', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + )), + ].toRow(), + SizedBox(height: 10.h), + [ + Obx(() => [ + for (final type in VerificationRecordType.values) + _VerificationTypeButton( + label: type.title, + selected: controller.selectedType.value == type, + onTap: () => controller.changeType(type), + ).marginOnly(right: 8.w), + ].toRow()), + const Spacer(), + TerminalToolbarControl( + width: 300.w, + child: FTextField( + controller: controller.keywordController, + hint: '当前页:订单号、用户或商品', + maxLines: 1, + textInputAction: TextInputAction.search, + onSubmit: (_) => controller.applyKeyword(), + prefixBuilder: (context, style, child) => Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: FIcon( + FAssets.icons.search, + size: 19.sp, + color: XfjDesignTokens.muted, + ), + ), + ), + ), + SizedBox(width: 8.w), + Obx(() => _VerificationDateRangeControl( + dates: [ + controller.selectDateTime[0], + controller.selectDateTime[1], + ], + onChanged: controller.changeDateRange, + )), + SizedBox(width: 8.w), + TerminalToolbarButton( + label: '刷新', + icon: FAssets.icons.refreshCw, + style: TerminalToolbarButtonStyle.secondary, + onTap: () => controller.loadRecords(resetPage: true), + ), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + ); + } +} + +class _VerificationTypeButton extends StatelessWidget { + const _VerificationTypeButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: selected, + label: '$label筛选', + child: Material( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.control, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Text( + label, + style: FTheme.of(context).typography.sm.copyWith( + color: selected ? Colors.white : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ).center().width(110.w).height(48.h), + ), + ), + ); + } +} + +class _VerificationDateRangeControl extends StatelessWidget { + const _VerificationDateRangeControl({ + required this.dates, + required this.onChanged, + }); + + final List dates; + final ValueChanged?> onChanged; + + @override + Widget build(BuildContext context) { + final start = DateUtil.formatDate(dates[0], format: DateFormats.y_mo_d); + final end = DateUtil.formatDate(dates[1], format: DateFormats.y_mo_d); + return Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: () async { + FocusManager.instance.primaryFocus?.unfocus(); + await SystemChannels.textInput.invokeMethod('TextInput.hide'); + if (!context.mounted) return; + onChanged(await appShowDatePicker(context, datetimeRange: dates)); + }, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + FIcon(FAssets.icons.calendar, size: 19.sp), + SizedBox(width: 8.w), + Text( + '$start 至 $end', + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 14.w) + .width(326.w) + .height(XfjDesignTokens.controlHeight.h), + ), + ); + } +} diff --git a/lib/pages/terminal/verification_records/widgets/verification_record_table.dart b/lib/pages/terminal/verification_records/widgets/verification_record_table.dart new file mode 100644 index 0000000..4fc8f51 --- /dev/null +++ b/lib/pages/terminal/verification_records/widgets/verification_record_table.dart @@ -0,0 +1,225 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 核销记录表格独立封装加载、失败和空状态,页面只负责筛选条件与分页。 +class VerificationRecordTable extends StatelessWidget { + const VerificationRecordTable({ + super.key, + required this.status, + required this.records, + required this.errorMessage, + required this.onRetry, + }); + + final VerificationRecordLoadStatus status; + final List records; + final String errorMessage; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return TerminalSurface( + clipBehavior: Clip.hardEdge, + child: [ + _header(context), + switch (status) { + VerificationRecordLoadStatus.loading => const Center( + child: CircularProgressIndicator(color: XfjDesignTokens.primary), + ).expanded(), + VerificationRecordLoadStatus.failure => _messageState( + context, + icon: FAssets.icons.circleAlert, + title: '核销记录加载失败', + detail: errorMessage, + actionLabel: '重新加载', + onAction: onRetry, + ).expanded(), + VerificationRecordLoadStatus.success when records.isEmpty => + _messageState( + context, + icon: FAssets.icons.receiptText, + title: '暂无核销记录', + detail: '当前筛选范围内没有本机完成的核销订单', + ).expanded(), + VerificationRecordLoadStatus.success => ListView.separated( + padding: EdgeInsets.zero, + itemCount: records.length, + separatorBuilder: (_, __) => const Divider( + height: 1, + color: XfjDesignTokens.line, + ), + itemBuilder: (context, index) => _row(context, records[index]), + ).expanded(), + }, + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + ); + } + + Widget _header(BuildContext context) { + final style = FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ); + return ColoredBox( + color: const Color(0xFFF7F8FA), + child: _VerificationGridRow( + children: [ + Text('核销时间', style: style), + Text('订单号', style: style), + Text('类型', style: style), + Text('核销用户', style: style), + Text('商品/内容', style: style), + Text('核销方式', style: style), + Text('订单金额', style: style), + Text('状态', style: style), + ], + ).padding(horizontal: 16.w).constrained(minHeight: 46.h), + ); + } + + Widget _row(BuildContext context, ServiceVerificationRecord record) { + final theme = FTheme.of(context); + final primary = theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ); + final secondary = theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ); + return _VerificationGridRow( + children: [ + [ + Text( + DateUtil.formatDate(record.verificationTime, + format: DateFormats.y_mo_d), + style: primary, + ), + Text( + DateUtil.formatDate(record.verificationTime, + format: DateFormats.h_m_s), + style: secondary, + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), + Text(record.orderNo, maxLines: 1, overflow: TextOverflow.ellipsis), + _typeBadge(context, record), + [ + Text(record.username, style: primary), + if ((record.userDeptName ?? '').isNotEmpty) + Text( + record.userDeptName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: secondary, + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), + Text( + record.productDesc, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: primary, + ), + Text( + record.payTypeName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '¥${AppPriceTool.fenToYuan(record.totalPrice)}', + style: primary.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + _successBadge(context), + ], + ).padding(horizontal: 16.w, vertical: 8.h).constrained(minHeight: 66.h); + } + + Widget _typeBadge(BuildContext context, ServiceVerificationRecord record) { + final isActivity = record.verificationType == 'ACTIVITY'; + final color = + isActivity ? const Color(0xFF8C6A00) : XfjDesignTokens.primary; + final background = + isActivity ? const Color(0xFFFFF9E8) : XfjDesignTokens.primarySoft; + return Container( + padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 4.h), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + record.verificationTypeName, + style: FTheme.of(context).typography.xs.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + Widget _successBadge(BuildContext context) { + return [ + FIcon(FAssets.icons.circleCheck, + size: 15.sp, color: XfjDesignTokens.success), + SizedBox(width: 5.w), + Text( + '已核销', + style: FTheme.of(context).typography.xs.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w700, + ), + ), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center); + } + + Widget _messageState( + BuildContext context, { + required SvgAsset icon, + required String title, + required String detail, + String? actionLabel, + VoidCallback? onAction, + }) { + return [ + FIcon(icon, size: 40.sp, color: XfjDesignTokens.muted), + SizedBox(height: 12.h), + Text( + title, + style: FTheme.of(context).typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 6.h), + Text( + detail, + textAlign: TextAlign.center, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 16.h), + TerminalToolbarButton(label: actionLabel, onTap: onAction), + ], + ].toColumn(mainAxisAlignment: MainAxisAlignment.center).center(); + } +} + +class _VerificationGridRow extends StatelessWidget { + const _VerificationGridRow({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + const flexes = [14, 16, 11, 14, 25, 14, 11, 10]; + return [ + for (var index = 0; index < children.length; index++) + children[index].padding(horizontal: 4.w).expanded(flex: flexes[index]), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center); + } +} diff --git a/lib/pages/terminal/view.dart b/lib/pages/terminal/view.dart index e215750..5b4edba 100644 --- a/lib/pages/terminal/view.dart +++ b/lib/pages/terminal/view.dart @@ -19,6 +19,8 @@ import 'package:pj_supermarket_cloud/pages/terminal/setting/controller.dart'; import 'package:pj_supermarket_cloud/pages/terminal/setting/view.dart'; import 'package:pj_supermarket_cloud/pages/terminal/take_food/controller.dart'; import 'package:pj_supermarket_cloud/pages/terminal/take_food/view.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/verification_records/controller.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/verification_records/view.dart'; /// 终端一级路由,仅创建一次公共外壳,业务页面通过内层 Navigator 切换。 /// @@ -162,6 +164,8 @@ String _routeForMenu(TerminalMenuItemType type) => switch (type) { TerminalMenuItemType.reservation => RouteNames.canteenReserve, TerminalMenuItemType.verification => RouteNames.takeFood, TerminalMenuItemType.transactions => RouteNames.orderHistory, + TerminalMenuItemType.verificationRecords => + RouteNames.verificationRecords, TerminalMenuItemType.statistics => RouteNames.canteenOrderTotal, TerminalMenuItemType.card => RouteNames.cardActive, TerminalMenuItemType.settings => RouteNames.setting, @@ -284,6 +288,15 @@ final Map _terminalRoutes = { Get.lazyPut(() => OrderHistoryController()); }), ), + RouteNames.verificationRecords: _TerminalRouteConfig( + name: RouteNames.verificationRecords, + title: '核销记录', + activeNav: TerminalNavType.verificationRecords, + page: () => const VerificationRecordsPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => VerificationRecordsController()); + }), + ), RouteNames.cardActive: _TerminalRouteConfig( name: RouteNames.cardActive, title: '开卡管理', diff --git a/lib/sub_screen/common/models/reservation_query_display_models.dart b/lib/sub_screen/common/models/reservation_query_display_models.dart index 3264ee0..5b825f1 100644 --- a/lib/sub_screen/common/models/reservation_query_display_models.dart +++ b/lib/sub_screen/common/models/reservation_query_display_models.dart @@ -32,7 +32,7 @@ extension ReservationQueryDisplayScenarioX on ReservationQueryDisplayScenario { }; String get description => switch (this) { - ReservationQueryDisplayScenario.idle => '等待学生刷饭卡或进行人脸识别', + ReservationQueryDisplayScenario.idle => '等待用户刷饭卡或进行人脸识别', ReservationQueryDisplayScenario.loading => '正在识别本人身份并查询预约菜单', ReservationQueryDisplayScenario.success => '展示当前页预约日期、餐段和菜品', ReservationQueryDisplayScenario.empty => '身份识别成功,但当前没有有效预约', @@ -75,7 +75,7 @@ class ReservationQueryDisplayState { ); } - /// 调试数据均为虚构内容,不读取真实学生身份或终端预约。 + /// 调试数据均为虚构内容,不读取真实用户身份或终端预约。 factory ReservationQueryDisplayState.demo( ReservationQueryDisplayScenario scenario, { DateTime? now, diff --git a/lib/sub_screen/page/reservation_query_display.dart b/lib/sub_screen/page/reservation_query_display.dart index 6d92c0f..32453ae 100644 --- a/lib/sub_screen/page/reservation_query_display.dart +++ b/lib/sub_screen/page/reservation_query_display.dart @@ -401,11 +401,11 @@ class _ReservationStatePanel extends StatelessWidget { ReservationQueryDisplayScenario.success => '预约查询成功', }; final description = switch (state.scenario) { - ReservationQueryDisplayScenario.idle => '识别成功后,这里将展示当前学生本人的预约菜单', + ReservationQueryDisplayScenario.idle => '识别成功后,这里将展示当前用户本人的预约菜单', ReservationQueryDisplayScenario.loading => '请稍候,不要重复刷卡或离开识别区域', ReservationQueryDisplayScenario.empty => '当前食堂今天及未来日期暂无可展示的已支付预约', ReservationQueryDisplayScenario.error => '请在主屏检查网络或识别设备后重新尝试', - ReservationQueryDisplayScenario.expired => '为保护学生隐私,请重新刷卡或进行人脸识别', + ReservationQueryDisplayScenario.expired => '为保护用户隐私,请重新刷卡或进行人脸识别', ReservationQueryDisplayScenario.success => '', }; return Container( @@ -488,7 +488,7 @@ class _ReservationFooter extends StatelessWidget { SizedBox(width: 8 * scale), Expanded( child: Text( - '仅展示当前学生本人的有效预约,页面不会显示卡号、手机号或订单号', + '仅展示当前用户本人的有效预约,页面不会显示卡号、手机号或订单号', style: TextStyle( color: XfjDesignTokens.muted, fontSize: 14 * scale, diff --git a/test/pages/terminal/menu_query_controller_test.dart b/test/pages/terminal/menu_query_controller_test.dart index 5aecbdd..7c4dbf5 100644 --- a/test/pages/terminal/menu_query_controller_test.dart +++ b/test/pages/terminal/menu_query_controller_test.dart @@ -12,8 +12,9 @@ import 'package:pj_supermarket_cloud/pages/terminal/menu_query/reservation_verif void main() { testWidgets('页面配置决定查询方式顺序和默认项,进入页面不自动识别', (tester) async { final recognition = _FakeRecognitionGateway(); + final repository = _ImmediateRepository(_result('张*明', 1)); final controller = ReservationMenuQueryController( - repository: _ImmediateRepository(_result('张*明', 1)), + repository: repository, recognitionGateway: recognition, subScreenGateway: _FakeSubScreenGateway(), networkAvailable: () => true, @@ -23,6 +24,7 @@ void main() { ReservationRecognitionMethod.card, ], defaultMethod: ReservationRecognitionMethod.face, + nameMaskEnabled: false, verificationEnabled: true, verificationDateScope: ReservationVerificationDateScope.all, ), @@ -37,6 +39,7 @@ void main() { ReservationRecognitionMethod.card, ]); expect(controller.method.value, ReservationRecognitionMethod.face); + expect(controller.nameMaskEnabled.value, isFalse); expect(controller.status.value, ReservationMenuQueryStatus.idle); expect(controller.verificationEnabled.value, isTrue); expect( @@ -48,6 +51,9 @@ void main() { await controller.startRecognition(); expect(recognition.faceCallbacks, hasLength(1)); + await recognition.emitFaceAt(0, '1001'); + expect(repository.calls.single.maskName, isFalse); + await controller.clearForPrivacy(); }); testWidgets('重复识别只查询一次,旧响应不能覆盖新身份', (tester) async { @@ -207,7 +213,9 @@ void main() { ); await recognition.emitFaceAt(0, '1001'); - expect(repository.calls, ['1001']); + expect(repository.calls, [ + const _QueryCall(identityValue: '1001', maskName: true), + ]); expect( subScreen.updates.map((event) => event.scenario), ['loading', 'success'], @@ -234,7 +242,9 @@ void main() { final recognitionFuture = recognition.emitCardAt(0, 'card-1'); await tester.pump(); - expect(repository.calls, ['card-1']); + expect(repository.calls, [ + const _QueryCall(identityValue: 'card-1', maskName: true), + ]); subScreen.releaseUpdates(); await recognitionFuture; expect(controller.status.value, ReservationMenuQueryStatus.success); @@ -281,7 +291,10 @@ void main() { ), ], ); - expect(repository.calls, ['CARD-001', 'CARD-001']); + expect(repository.calls, [ + const _QueryCall(identityValue: 'CARD-001', maskName: true), + const _QueryCall(identityValue: 'CARD-001', maskName: true), + ]); expect(controller.status.value, ReservationMenuQueryStatus.empty); expect(successes, ['核销成功,页面已刷新']); expect(errors, isEmpty); @@ -415,6 +428,33 @@ void main() { expect(day.toJson().toString(), isNot(contains('orders'))); }); + test('预约查询请求使用生成式 JSON 序列化并省略无关身份字段', () { + const cardRequest = ReservationMenuQueryRequest( + hardId: 100, + recognitionType: 'CARD', + maskName: true, + cardNumber: 'CARD-001', + ); + expect(cardRequest.toJson(), { + 'hardId': 100, + 'recognitionType': 'CARD', + 'maskName': true, + 'cardNumber': 'CARD-001', + }); + + final restored = ReservationMenuQueryRequest.fromJson({ + 'hardId': 100, + 'recognitionType': 'FACE', + 'maskName': false, + 'faceUserId': 300, + }); + expect(restored.hardId, 100); + expect(restored.recognitionType, 'FACE'); + expect(restored.maskName, isFalse); + expect(restored.cardNumber, isNull); + expect(restored.faceUserId, 300); + }); + test('后端只返回午餐时仅补齐基础三餐,不生成夜间餐占位', () { final day = ReservationMenuDisplayData.fromService( _result('张*明', 1), @@ -469,15 +509,16 @@ Future _verificationPreferences() async => ); class _CompleterRepository implements ReservationMenuRepository { - final calls = []; + final calls = <_QueryCall>[]; final completers = >[]; @override Future query({ required ReservationRecognitionMethod method, required String identityValue, + required bool maskName, }) { - calls.add(identityValue); + calls.add(_QueryCall(identityValue: identityValue, maskName: maskName)); final completer = Completer(); completers.add(completer); return completer.future; @@ -488,14 +529,15 @@ class _ImmediateRepository implements ReservationMenuRepository { _ImmediateRepository(this.value); final ServiceReservationMenuQuery value; - final calls = []; + final calls = <_QueryCall>[]; @override Future query({ required ReservationRecognitionMethod method, required String identityValue, + required bool maskName, }) async { - calls.add(identityValue); + calls.add(_QueryCall(identityValue: identityValue, maskName: maskName)); return value; } } @@ -504,19 +546,40 @@ class _SequenceRepository implements ReservationMenuRepository { _SequenceRepository(this.responses); final List responses; - final calls = []; + final calls = <_QueryCall>[]; int _index = 0; @override Future query({ required ReservationRecognitionMethod method, required String identityValue, + required bool maskName, }) async { - calls.add(identityValue); + calls.add(_QueryCall(identityValue: identityValue, maskName: maskName)); return responses[_index++]; } } +class _QueryCall { + const _QueryCall({required this.identityValue, required this.maskName}); + + final String identityValue; + final bool maskName; + + @override + bool operator ==(Object other) => + other is _QueryCall && + other.identityValue == identityValue && + other.maskName == maskName; + + @override + int get hashCode => Object.hash(identityValue, maskName); + + @override + String toString() => + '_QueryCall(identityValue: $identityValue, maskName: $maskName)'; +} + class _FakeVerificationGateway implements ReservationVerificationGateway { final calls = <_VerificationCall>[];