This commit is contained in:
dzc committed 2026-09-10 14:50:55 +08:00
1 parent 154122a847
commit 2e929c199b
21 files changed
+5517 -85

No files matched your search

@@ -0,0 +1,263 @@
import 'package:flutter/foundation.dart';
/// 顾客屏可展示的稳定业务状态,同时用于真实副屏和主屏调试预览。
enum CustomerDisplayScenario {
idle,
review,
paying,
success,
cancelled,
timeout,
}
CustomerDisplayScenario customerDisplayScenarioFromName(String? name) {
return CustomerDisplayScenario.values.firstWhere(
(scenario) => scenario.name == name,
orElse: () => CustomerDisplayScenario.review,
);
}
extension CustomerDisplayScenarioX on CustomerDisplayScenario {
String get title => switch (this) {
CustomerDisplayScenario.idle => '待机欢迎',
CustomerDisplayScenario.review => '商品核对',
CustomerDisplayScenario.paying => '扫码支付',
CustomerDisplayScenario.success => '支付成功',
CustomerDisplayScenario.cancelled => '支付取消',
CustomerDisplayScenario.timeout => '支付超时',
};
String get description => switch (this) {
CustomerDisplayScenario.idle => '展示欢迎信息,等待收银员添加商品',
CustomerDisplayScenario.review => '展示商品明细、额外支付和实际扣款',
CustomerDisplayScenario.paying => '展示支付二维码、倒计时和防重复扫码提示',
CustomerDisplayScenario.success => '提示本次支付已完成',
CustomerDisplayScenario.cancelled => '提示支付已取消并等待重新发起',
CustomerDisplayScenario.timeout => '提示二维码已失效并等待重新发起',
};
}
/// 副屏套餐中的单个组成菜品,只承载名称和每套份数,不参与结算金额。
@immutable
class CustomerDisplayComboItem {
/// 创建一条已经过副屏消息校验的套餐组成菜品。
const CustomerDisplayComboItem({
required this.name,
required this.quantity,
});
/// 套餐组成菜品名称,来源于主屏本地每日菜谱快照。
final String name;
/// 每套包含的菜品份数,始终为正整数。
final int quantity;
/// 按全部展示字段判断两条套餐组成是否一致。
@override
bool operator ==(Object other) {
return other is CustomerDisplayComboItem &&
other.name == name &&
other.quantity == quantity;
}
/// 为副屏状态去重提供与相等判断一致的哈希值。
@override
int get hashCode => Object.hash(name, quantity);
}
/// 副屏购物车中的一个主商品,包含可选的食堂套餐组成。
@immutable
class CustomerDisplayProduct {
/// 创建一条副屏商品展示记录;旧协议商品默认按单品处理。
const CustomerDisplayProduct({
required this.name,
required this.quantityLabel,
required this.unitPrice,
required this.totalPrice,
this.imageSource,
this.isCombo = false,
this.comboItems = const [],
});
/// 主商品名称。
final String name;
/// 按件数量或称重重量的本地化展示文本。
final String quantityLabel;
/// 主商品单价,单位为分。
final int unitPrice;
/// 当前购物车条目的结算小计,单位为分。
final int totalPrice;
/// 商品图片来源,兼容完整网络地址和服务端返回的 OSS 相对地址;为空时展示名称首字占位。
final String? imageSource;
/// 是否为食堂套餐;旧版副屏消息缺失该字段时为 false。
final bool isCombo;
/// 每套包含的组成菜品;单品或本地明细缺失时为空列表。
final List<CustomerDisplayComboItem> comboItems;
/// 按主商品及套餐组成的全部展示字段判断两条记录是否一致。
@override
bool operator ==(Object other) {
return other is CustomerDisplayProduct &&
other.name == name &&
other.quantityLabel == quantityLabel &&
other.unitPrice == unitPrice &&
other.totalPrice == totalPrice &&
other.imageSource == imageSource &&
other.isCombo == isCombo &&
listEquals(other.comboItems, comboItems);
}
/// 为副屏状态去重提供与相等判断一致的哈希值。
@override
int get hashCode => Object.hash(
name,
quantityLabel,
unitPrice,
totalPrice,
imageSource,
isCombo,
Object.hashAll(comboItems),
);
}
@immutable
class CustomerDisplayState {
const CustomerDisplayState({
required this.scenario,
required this.businessTitle,
required this.products,
required this.totalPrice,
required this.amountMealPrice,
this.customerName,
this.qrCodeData,
this.countdownSeconds = 0,
this.connected = true,
});
factory CustomerDisplayState.idle() => const CustomerDisplayState(
scenario: CustomerDisplayScenario.idle,
businessTitle: '智慧收银',
products: [],
totalPrice: 0,
amountMealPrice: 0,
);
/// 演示数据只用于主屏预览,避免调试页面读取真实顾客、订单或支付信息。
factory CustomerDisplayState.demo(CustomerDisplayScenario scenario) {
if (scenario == CustomerDisplayScenario.idle) {
return CustomerDisplayState.idle();
}
return CustomerDisplayState(
scenario: scenario,
businessTitle: '智慧超市',
customerName: '张小明',
products: const [
CustomerDisplayProduct(
name: '农夫山泉 550ml',
quantityLabel: '1 件',
unitPrice: 200,
totalPrice: 200,
),
CustomerDisplayProduct(
name: '纯牛奶 250ml',
quantityLabel: '1 件',
unitPrice: 480,
totalPrice: 480,
),
CustomerDisplayProduct(
name: '乐事薯片 原味 70g',
quantityLabel: '1 件',
unitPrice: 390,
totalPrice: 390,
),
CustomerDisplayProduct(
name: '香蕉(称重)',
quantityLabel: '0.36 kg',
unitPrice: 10000,
totalPrice: 3600,
),
],
totalPrice: 4670,
amountMealPrice: 300,
qrCodeData: scenario == CustomerDisplayScenario.paying
? 'biz-xfj-customer-preview'
: null,
countdownSeconds: switch (scenario) {
CustomerDisplayScenario.paying => 298,
CustomerDisplayScenario.success => 10,
_ => 0,
},
);
}
final CustomerDisplayScenario scenario;
final String businessTitle;
final String? customerName;
final List<CustomerDisplayProduct> products;
final int totalPrice;
final int amountMealPrice;
final String? qrCodeData;
final int countdownSeconds;
final bool connected;
int get payablePrice => totalPrice + amountMealPrice;
String? get maskedCustomerName {
final value = customerName?.trim();
if (value == null || value.isEmpty) return null;
if (value.length == 1) return '*';
if (value.length == 2) return '${value[0]}*';
return '${value[0]}*${value[value.length - 1]}';
}
CustomerDisplayState withScenario(
CustomerDisplayScenario next, {
String? qrCodeData,
int countdownSeconds = 0,
}) {
return CustomerDisplayState(
scenario: next,
businessTitle: businessTitle,
customerName: customerName,
products: products,
totalPrice: totalPrice,
amountMealPrice: amountMealPrice,
qrCodeData: qrCodeData,
countdownSeconds: countdownSeconds,
connected: connected,
);
}
@override
bool operator ==(Object other) {
return other is CustomerDisplayState &&
other.scenario == scenario &&
other.businessTitle == businessTitle &&
other.customerName == customerName &&
listEquals(other.products, products) &&
other.totalPrice == totalPrice &&
other.amountMealPrice == amountMealPrice &&
other.qrCodeData == qrCodeData &&
other.countdownSeconds == countdownSeconds &&
other.connected == connected;
}
@override
int get hashCode => Object.hash(
scenario,
businessTitle,
customerName,
Object.hashAll(products),
totalPrice,
amountMealPrice,
qrCodeData,
countdownSeconds,
connected,
);
}
@@ -0,0 +1,51 @@
import 'package:flutter/foundation.dart';
enum FaceRecognitionDisplayScene { cashier, reservation }
enum FaceRecognitionDisplayStatus {
initializing,
detecting,
success,
failed,
cancelled,
}
@immutable
class FaceRecognitionDisplayState {
const FaceRecognitionDisplayState({
required this.sessionId,
required this.revision,
required this.scene,
required this.status,
required this.message,
this.payableAmount,
this.totalAmount,
this.discountAmount,
});
final String sessionId;
final int revision;
final FaceRecognitionDisplayScene scene;
final FaceRecognitionDisplayStatus status;
final String message;
final int? payableAmount;
final int? totalAmount;
final int? discountAmount;
FaceRecognitionDisplayState copyWith({
required int revision,
required FaceRecognitionDisplayStatus status,
required String message,
}) {
return FaceRecognitionDisplayState(
sessionId: sessionId,
revision: revision,
scene: scene,
status: status,
message: message,
payableAmount: payableAmount,
totalAmount: totalAmount,
discountAmount: discountAmount,
);
}
}
@@ -0,0 +1,282 @@
import 'package:flutter/foundation.dart';
import 'package:pj_supermarket_cloud/common/models/page/reservation_menu_page_models.dart';
/// 预约查询副屏的稳定展示状态,真实副屏和设置预览共用同一份模型。
enum ReservationQueryDisplayScenario {
/// 普通饭卡或单轮人脸模式尚未开始识别。
idle,
/// 持续人脸模式正在等待并识别下一位用户。
recognizing,
/// 身份已识别,正在请求当前用户的预约菜单。
loading,
/// 已获取并展示当前用户的预约菜单。
success,
/// 身份有效,但当前用户没有可展示的预约。
empty,
/// 识别设备或预约查询出现需要人工恢复的异常。
error,
/// 普通模式的身份结果已按隐私时限清除。
expired,
}
/// 将跨 Engine 场景名称转换为副屏枚举,未知预览参数兼容为成功态。
ReservationQueryDisplayScenario reservationQueryDisplayScenarioFromName(
String? name,
) {
return ReservationQueryDisplayScenario.values.firstWhere(
(scenario) => scenario.name == name,
orElse: () => ReservationQueryDisplayScenario.success,
);
}
extension ReservationQueryDisplayScenarioX on ReservationQueryDisplayScenario {
/// 当前场景用于状态标签的短标题。
String get title => switch (this) {
ReservationQueryDisplayScenario.idle => '等待识别',
ReservationQueryDisplayScenario.recognizing => '持续识别中',
ReservationQueryDisplayScenario.loading => '正在查询',
ReservationQueryDisplayScenario.success => '查询成功',
ReservationQueryDisplayScenario.empty => '暂无预约',
ReservationQueryDisplayScenario.error => '查询失败',
ReservationQueryDisplayScenario.expired => '隐私清屏',
};
/// 当前场景用于预览和辅助说明的完整描述。
String get description => switch (this) {
ReservationQueryDisplayScenario.idle => '等待用户刷饭卡或进行人脸识别',
ReservationQueryDisplayScenario.recognizing => '摄像头已开启,正在识别下一位用户',
ReservationQueryDisplayScenario.loading => '正在识别本人身份并查询预约菜单',
ReservationQueryDisplayScenario.success => '展示当前页预约日期、餐段和菜品',
ReservationQueryDisplayScenario.empty => '身份识别成功,但当前没有有效预约',
ReservationQueryDisplayScenario.error => '识别或网络异常,等待主屏重新发起',
ReservationQueryDisplayScenario.expired => '身份与预约内容已按隐私规则清除',
};
}
@immutable
class ReservationQueryDisplayState {
/// 创建已通过 Coordinator 校验的不可变副屏展示快照。
const ReservationQueryDisplayState({
required this.sessionId,
required this.revision,
required this.scenario,
required this.totalDayCount,
required this.totalMealCount,
required this.currentPage,
required this.totalPages,
required this.days,
this.maskedName,
this.deptName,
this.merchantName,
this.expiresAt,
this.remainingSeconds = 0,
this.continuousFaceRecognition = false,
});
factory ReservationQueryDisplayState.idle({
String sessionId = '',
int revision = 0,
}) {
return ReservationQueryDisplayState(
sessionId: sessionId,
revision: revision,
scenario: ReservationQueryDisplayScenario.idle,
totalDayCount: 0,
totalMealCount: 0,
currentPage: 1,
totalPages: 1,
days: const [],
);
}
/// 调试数据均为虚构内容,不读取真实用户身份或终端预约。
factory ReservationQueryDisplayState.demo(
ReservationQueryDisplayScenario scenario, {
DateTime? now,
}) {
final current = now ?? DateTime.now();
if (scenario == ReservationQueryDisplayScenario.idle ||
scenario == ReservationQueryDisplayScenario.recognizing ||
scenario == ReservationQueryDisplayScenario.expired) {
return ReservationQueryDisplayState(
sessionId: 'preview',
revision: 1,
scenario: scenario,
totalDayCount: 0,
totalMealCount: 0,
currentPage: 1,
totalPages: 1,
days: const [],
continuousFaceRecognition:
scenario == ReservationQueryDisplayScenario.recognizing,
);
}
if (scenario == ReservationQueryDisplayScenario.loading ||
scenario == ReservationQueryDisplayScenario.error) {
return ReservationQueryDisplayState(
sessionId: 'preview',
revision: 1,
scenario: scenario,
totalDayCount: 0,
totalMealCount: 0,
currentPage: 1,
totalPages: 1,
days: const [],
continuousFaceRecognition: false,
);
}
final days = scenario == ReservationQueryDisplayScenario.success
? List<ReservationMenuDayData>.generate(10, (index) {
final date = DateTime(current.year, current.month, current.day)
.add(Duration(days: index));
final dateText =
'${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
return ReservationMenuDayData(
date: dateText,
meals: const [
ReservationMenuMealData(
hourType: 'BREAKFAST',
title: '早餐',
items: ['鲜肉包', '水煮蛋', '纯牛奶'],
),
ReservationMenuMealData(
hourType: 'LUNCH',
title: '午餐',
items: ['香菇滑鸡', '清炒时蔬', '玉米排骨汤'],
),
],
);
})
: const <ReservationMenuDayData>[];
return ReservationQueryDisplayState(
sessionId: 'preview',
revision: 1,
scenario: scenario,
maskedName: '张*明',
deptName: '三年级二班',
merchantName: '第一食堂',
totalDayCount:
scenario == ReservationQueryDisplayScenario.success ? 12 : 0,
totalMealCount:
scenario == ReservationQueryDisplayScenario.success ? 24 : 0,
currentPage: 1,
totalPages: scenario == ReservationQueryDisplayScenario.success ? 2 : 1,
days: List.unmodifiable(days),
expiresAt: current.add(const Duration(seconds: 60)),
remainingSeconds: 60,
continuousFaceRecognition: false,
);
}
/// 主屏查询页面会话编号,用于拒绝旧页面迟到消息。
final String sessionId;
/// 当前会话内单调递增的消息版本。
final int revision;
/// 副屏当前展示的查询阶段。
final ReservationQueryDisplayScenario scenario;
/// 服务端返回的脱敏姓名;等待和异常场景为空。
final String? maskedName;
/// 用餐人所属班级或部门;服务端未提供时为空。
final String? deptName;
/// 当前终端绑定的食堂名称;未取得身份结果时为空。
final String? merchantName;
/// 当前身份全部预约日期数量,不等同于当前页卡片数。
final int totalDayCount;
/// 当前身份全部已预约餐次数量。
final int totalMealCount;
/// 当前结果页码,从一开始。
final int currentPage;
/// 当前结果总页数,至少为一。
final int totalPages;
/// 当前页最多十个预约日期,不包含核销订单标识。
final List<ReservationMenuDayData> days;
/// 当前身份结果的绝对过期时间;非结果场景为空。
final DateTime? expiresAt;
/// 距离结果清理的剩余秒数,使用等宽数字展示。
final int remainingSeconds;
/// 是否在副屏使用摄像头与预约内容并列的持续人脸布局。
final bool continuousFaceRecognition;
/// 复制计时器需要变化的场景或剩余秒数,其余快照内容保持不变。
ReservationQueryDisplayState copyWith({
ReservationQueryDisplayScenario? scenario,
int? remainingSeconds,
bool? continuousFaceRecognition,
}) {
return ReservationQueryDisplayState(
sessionId: sessionId,
revision: revision,
scenario: scenario ?? this.scenario,
maskedName: maskedName,
deptName: deptName,
merchantName: merchantName,
totalDayCount: totalDayCount,
totalMealCount: totalMealCount,
currentPage: currentPage,
totalPages: totalPages,
days: days,
expiresAt: expiresAt,
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
continuousFaceRecognition:
continuousFaceRecognition ?? this.continuousFaceRecognition,
);
}
@override
bool operator ==(Object other) {
return other is ReservationQueryDisplayState &&
other.sessionId == sessionId &&
other.revision == revision &&
other.scenario == scenario &&
other.maskedName == maskedName &&
other.deptName == deptName &&
other.merchantName == merchantName &&
other.totalDayCount == totalDayCount &&
other.totalMealCount == totalMealCount &&
other.currentPage == currentPage &&
other.totalPages == totalPages &&
listEquals(other.days, days) &&
other.expiresAt == expiresAt &&
other.remainingSeconds == remainingSeconds &&
other.continuousFaceRecognition == continuousFaceRecognition;
}
@override
int get hashCode => Object.hash(
sessionId,
revision,
scenario,
maskedName,
deptName,
merchantName,
totalDayCount,
totalMealCount,
currentPage,
totalPages,
Object.hashAll(days),
expiresAt,
remainingSeconds,
continuousFaceRecognition,
);
}