feat:收银页面也可以快速注册人脸

This commit is contained in:
dzc committed 2026-09-10 17:05:03 +08:00
1 parent 24a7867ab4
commit e15197edf4
2 files changed
+443 -10

No files matched your search

+25 -6
View File
@@ -55,9 +55,13 @@ abstract class SyncFaceTool {
///
/// 完整快照会一次性拉入内存并完成唯一性、游标和页面连续性校验,
/// 任何页面中断都会抛出异常且不会进入本地孤儿删除阶段。
///
/// [onProgress] 在清单分页和逐条处理时上报进度;清单仍在分页获取时总数
/// 为空,进入逐条处理后总数为本次需要处理的记录数。
static Future<FaceSyncSummary> sync({
required FaceSyncMode mode,
Set<String>? userIds,
void Function(int processed, int? total)? onProgress,
}) async {
if (!AppNetworkTool.isAutoNetwork()) {
throw const FaceSyncSnapshotException('网络不可用,无法获取人脸权威快照');
@@ -108,18 +112,23 @@ abstract class SyncFaceTool {
local: local,
storagePath: storagePath,
accumulator: accumulator,
onProgress: onProgress,
);
}
} else {
final remote = mode == FaceSyncMode.debugAllSnapshot
? await _loadDebugAllSnapshot()
: await _loadV3Snapshot(merchantDeptId: merchantDeptId);
? await _loadDebugAllSnapshot(onProgress: onProgress)
: await _loadV3Snapshot(
merchantDeptId: merchantDeptId,
onProgress: onProgress,
);
final local = await Get.find<AppControl>().dataBase.getFaceDao.getAll();
await _reconcile(
remote: remote,
local: local,
storagePath: storagePath,
accumulator: accumulator,
onProgress: onProgress,
);
}
await _reportPersistedFaceQualitySafely();
@@ -129,9 +138,12 @@ abstract class SyncFaceTool {
}
/// 拉取 V3 固定上界游标快照并校验页面连续性、记录唯一性和字段有效性。
///
/// [onProgress] 在每页合并后上报当前已获取条数;分页总数未知,回调总数为空。
static Future<List<ServiceFaceSyncRecord>> _loadV3Snapshot({
required String merchantDeptId,
String? userId,
void Function(int processed, int? total)? onProgress,
}) async {
var afterId = '0';
String? snapshotMaxId;
@@ -188,6 +200,7 @@ abstract class SyncFaceTool {
throw const FaceSyncSnapshotException('人脸快照存在空页但仍声明后续数据');
}
afterId = snapshot.nextAfterId;
onProgress?.call(records.length, null);
}
throw const FaceSyncSnapshotException('人脸快照分页超过安全上限');
}
@@ -195,7 +208,9 @@ abstract class SyncFaceTool {
/// 使用保留的 V2 接口拉取非生产调试全租户快照。
///
/// 生产入口不会调用本方法;V2 不提供源更新时间,因此仅以图片地址判断图片版本。
static Future<List<ServiceFaceSyncRecord>> _loadDebugAllSnapshot() async {
static Future<List<ServiceFaceSyncRecord>> _loadDebugAllSnapshot({
void Function(int processed, int? total)? onProgress,
}) async {
final records = <ServiceFaceSyncRecord>[];
final sampleIds = <String>{};
for (var page = 1; page <= _maxSnapshotPages; page++) {
@@ -228,6 +243,7 @@ abstract class SyncFaceTool {
));
}
if (data.length < _debugPageSize) return records;
onProgress?.call(records.length, null);
}
throw const FaceSyncSnapshotException('调试全租户快照分页超过安全上限');
}
@@ -238,6 +254,7 @@ abstract class SyncFaceTool {
required List<FaceEntity> local,
required String storagePath,
required _FaceSyncAccumulator accumulator,
void Function(int processed, int? total)? onProgress,
}) async {
final faceDao = Get.find<AppControl>().dataBase.getFaceDao;
final localBySampleId = <String, FaceEntity>{};
@@ -266,23 +283,25 @@ abstract class SyncFaceTool {
} else {
accumulator.fail(record.sampleId);
}
continue;
}
if (_metadataChanged(existing, record)) {
} else if (_metadataChanged(existing, record)) {
await faceDao.edit(_toEntity(record, existing: existing));
accumulator.updated++;
} else {
accumulator.skipped++;
}
onProgress?.call(accumulator.scanned, remote.length);
}
// 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。
// 孤儿数量已知且通常远小于远端集合,并入总数保持进度单调推进。
final orphanTotal = remote.length + localBySampleId.length;
for (final orphan in localBySampleId.values) {
if (await _deleteLocalFace(orphan, faceDao)) {
accumulator.deleted++;
} else {
accumulator.fail(orphan.sampleId);
}
onProgress?.call(remote.length + accumulator.deleted, orphanTotal);
}
}
@@ -13,6 +13,36 @@ import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 收银弹框内人脸库就地同步的阶段性状态。
enum _FaceSetupPhase {
/// 当前没有需要展示的同步引导。
idle,
/// 本机人脸库为空,等待收银员确认就地同步。
empty,
/// 就地同步执行中,展示下载与注册进度。
running,
/// 同步完成并已具备可识别人脸。
success,
/// 同步失败或服务端没有可用数据,可重试或改用其他支付方式。
failed,
}
/// 人脸库就地同步的一次进度快照;清单仍在分页获取时总数为空。
class _FaceSetupProgress {
/// 创建进度快照。
const _FaceSetupProgress({required this.processed, this.total});
/// 已完成处理的记录数。
final int processed;
/// 本次需要处理的总记录数;为空表示总数尚未确定。
final int? total;
}
/// 收银支付弹框控制器,统一组织支付方式、硬件识别、人员钱包和提交状态。
class SelectPayTypeDialogController extends GetxController {
/// 创建支付弹框控制器,具体业务回调由食堂或超市页面绑定。
@@ -90,6 +120,22 @@ class SelectPayTypeDialogController extends GetxController {
/// 人脸启动、识别或人员查询失败时在支付工作区展示的可恢复说明。
final faceWorkspaceMessage = ''.obs;
/// 人脸库就地同步当前阶段;仅人脸支付方式下出现,切换或关闭时复位。
final faceSetupPhase = Rx<_FaceSetupPhase>(_FaceSetupPhase.idle);
/// 人脸库就地同步的进度快照;运行前和复位后为空。
final faceSetupProgress = Rxn<_FaceSetupProgress>();
/// 人脸库就地同步的结果说明;失败原因或完成统计。
final faceSetupMessage = ''.obs;
/// 人脸库就地同步请求序号;关闭、切换支付方式或停止识别后递增,使迟到的
/// 完成回调不再自动续接识别或改写界面状态。
int _faceSetupDispatchId = 0;
/// 就地同步是否仍在执行;识别发起前据此避让,防止与后台增量同时占用引擎。
bool _faceSetupRunning = false;
/// 人脸结果已返回、正在查询人员和钱包时为 true。
final faceUserLoading = false.obs;
@@ -177,6 +223,11 @@ class SelectPayTypeDialogController extends GetxController {
}
orderPayDisplayType.value = payType;
faceWorkspaceMessage.value = '';
// 就地同步引导只属于人脸支付;切换后由下次人脸识别重新判定是否为空库。
_faceSetupDispatchId++;
faceSetupPhase.value = _FaceSetupPhase.idle;
faceSetupProgress.value = null;
faceSetupMessage.value = '';
}
/// 将界面支付方式映射为默认订单支付类型;二维码真实扫码结果仍可在识别后覆盖该默认值。
@@ -256,6 +307,7 @@ class SelectPayTypeDialogController extends GetxController {
void _closeStatus() {
_faceDispatchRequestId++;
_walletCardDispatchRequestId++;
_faceSetupDispatchId++;
payUser.value = null;
userWalletList.value = [];
currentWallet.value = null;
@@ -270,6 +322,9 @@ class SelectPayTypeDialogController extends GetxController {
faceUserLoading.value = false;
closeRecognitionConfirmationVisible.value = false;
pendingPayDisplayType.value = null;
faceSetupPhase.value = _FaceSetupPhase.idle;
faceSetupProgress.value = null;
faceSetupMessage.value = '';
// 设备
if (appSetting.sysType == SysType.CANTEEN) {
BarcodeScanManage.instance.dispose();
@@ -402,6 +457,7 @@ class SelectPayTypeDialogController extends GetxController {
}
if (activeRecognitionType == OrderPayDisplayType.face) {
_faceDispatchRequestId++;
_faceSetupDispatchId++;
await _cancelPendingEvidence();
await _closeFaceSession(cancel: true);
} else if (activeRecognitionType == OrderPayDisplayType.qrCode) {
@@ -668,6 +724,11 @@ class SelectPayTypeDialogController extends GetxController {
Toast.error(msg: "正在识别中,请勿重复点击");
return;
}
if (_faceSetupRunning) {
// 就地同步仍在后台写入人脸特征,此时开启摄像头会与增量注册争抢引擎。
faceWorkspaceMessage.value = '人脸数据正在同步,请稍候';
return;
}
final requestId = ++_faceDispatchRequestId;
isRecognition.value = true;
try {
@@ -677,9 +738,18 @@ class SelectPayTypeDialogController extends GetxController {
final databaseFaceCount = await faceDao.findCount("-1");
if (!_ownsFaceDispatch(requestId)) return;
if (databaseFaceCount == null || databaseFaceCount == 0) {
String msg = "人脸数据库为空,请前往'设置'->'人脸管理'点击同步人脸";
throw Exception(msg);
// 空库不再把收银员引去设置页:切换为就地同步引导,由其当场拉取人脸
// 后自动续接识别,顾客无需离开结算弹框。
isRecognition.value = false;
isReidentifying.value = false;
faceWorkspaceMessage.value = '';
if (faceSetupPhase.value != _FaceSetupPhase.running) {
faceSetupPhase.value = _FaceSetupPhase.empty;
faceSetupMessage.value = '';
}
return;
}
faceSetupPhase.value = _FaceSetupPhase.idle;
// 注册占用通常是暂态的:整库重建或识别失效触发的增量同步都会短暂持有
// 人脸互斥锁。直接拒绝会让收银员在连续两单之间撞上“正在注册”,
// 这里等待其结束后自动继续,只有超时才提示稍后再试。
@@ -687,7 +757,8 @@ class SelectPayTypeDialogController extends GetxController {
if (!_ownsFaceDispatch(requestId)) return;
var waitRound = 0;
while (status?.registrationInProgress == true && waitRound < 8) {
faceWorkspaceMessage.value = waitRound == 0 ? '人脸数据正在同步,请稍候' : '人脸数据正在同步,请稍候(${waitRound * 2}秒)';
faceWorkspaceMessage.value =
waitRound == 0 ? '人脸数据正在同步,请稍候' : '人脸数据正在同步,请稍候(${waitRound * 2}秒)';
await Future<void>.delayed(const Duration(seconds: 2));
if (!_ownsFaceDispatch(requestId)) return;
status = await AppFaceTool.getStatus();
@@ -735,6 +806,113 @@ class SelectPayTypeDialogController extends GetxController {
}
}
/// 检查异步人脸同步结果是否仍属于未被关闭或切换的本轮就地同步请求。
bool _ownsFaceSetup(int requestId) {
return requestId == _faceSetupDispatchId;
}
/// 在收银弹框内直接同步本部门人脸,完成后自动续接人脸识别。
///
/// 弹框打开期间调度器的安全窗口永远不满足,因此这里直接执行同步并手动
/// 持有重型任务互斥;识别锁复用 [isRecognition],使底部提交与支付方式
/// 切换沿既有取消确认流程工作。关闭弹框或切换支付方式后同步会在后台
/// 安全完成,但不再自动开启摄像头。
Future<void> startFaceLibrarySetup() async {
if (faceSetupPhase.value == _FaceSetupPhase.running || isSubmitting.value) {
return;
}
if (!AppNetworkTool.isAutoNetwork()) {
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value = '网络不可用,无法同步人脸,请恢复网络后重试';
await AppRuntimeTool.vibrate(durationMs: 60);
return;
}
final setting = await AppSettingTool.getSysSetting();
if (ObjectUtil.isEmpty(setting.merchantDeptId)) {
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value = '终端尚未绑定商户部门,请先在设置中绑定设备';
return;
}
// 弹框打开前已启动的后台人脸同步仍在占用引擎时,等待其结束后再重试。
if (SyncCheckTool.instance.runningTasks
.contains(ScheduledTaskType.departmentFaceSync)) {
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value = '人脸数据正在后台同步,请稍后点击重试';
return;
}
final requestId = ++_faceSetupDispatchId;
_faceSetupRunning = true;
faceSetupPhase.value = _FaceSetupPhase.running;
faceSetupProgress.value = null;
faceSetupMessage.value = '';
isRecognition.value = true;
isReidentifying.value = false;
TtsManage.speak('开始同步人脸');
SyncIdleTool.instance.beginHeavyTask();
try {
final summary = await SyncFaceTool.sync(
mode: FaceSyncMode.departmentSnapshot,
onProgress: (processed, total) {
if (!_ownsFaceSetup(requestId)) return;
faceSetupProgress.value = _FaceSetupProgress(
processed: processed,
total: total,
);
},
);
if (summary.hasFailures) {
logger.w('|$_tag|弹框内人脸同步部分失败|${summary.message}|');
}
if (!_ownsFaceSetup(requestId)) return;
final usableCount = summary.added + summary.updated;
if (summary.scanned == 0) {
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value = '服务端暂无可同步的人脸,请先在管理端录入人脸';
return;
}
if (usableCount == 0 && summary.failed > 0) {
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value =
summary.hasFailures ? summary.message : '本次没有同步到新的人脸,请稍后重试';
return;
}
faceSetupPhase.value = _FaceSetupPhase.success;
faceSetupMessage.value = summary.message;
if (summary.hasFailures) {
Toast.error(msg: '部分人脸同步失败 ${summary.failed} 条,可稍后在设置中重新同步');
} else {
TtsManage.speak('人脸同步完成');
}
// 特征写入已经结束,先解除同步占位与识别锁再续接,避免被防重入拦截。
_faceSetupRunning = false;
isRecognition.value = false;
if (_ownsFaceSetup(requestId) &&
orderPayDisplayType.value == OrderPayDisplayType.face &&
payUser.value == null) {
await _dispatchFace();
if (!_ownsFaceSetup(requestId)) return;
if (faceSessionHandle.value == null && payUser.value == null) {
// 续接失败的原因已由 _dispatchFace 写入 faceWorkspaceMessage。
faceSetupPhase.value = _FaceSetupPhase.failed;
}
}
} catch (error, stack) {
logger.e('|$_tag|弹框内人脸同步失败error:|$error|stack:|$stack|');
if (!_ownsFaceSetup(requestId)) return;
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value =
error is FaceSyncSnapshotException ? error.message : '人脸同步失败,请稍后重试';
await AppRuntimeTool.vibrate(durationMs: 60);
} finally {
_faceSetupRunning = false;
SyncIdleTool.instance.endHeavyTask();
if (_ownsFaceSetup(requestId) && faceSessionHandle.value == null) {
isRecognition.value = false;
isReidentifying.value = false;
}
}
}
/// 处理嵌入式面板返回的唯一人脸结果,并在原位查询人员与钱包。
Future<void> handleFaceRecognized(
FaceStream event,
@@ -2195,6 +2373,22 @@ class _PaymentWorkspace extends StatelessWidget {
),
);
}
// 人脸库为空或就地同步进行中时,用可操作的同步引导替代离开弹框的指引。
if (type == OrderPayDisplayType.face &&
controller.faceSetupPhase.value != _FaceSetupPhase.idle) {
return SingleChildScrollView(
child: <Widget>[
_FaceLibrarySetupPanel(controller: controller),
if (!paymentEnabled) ...[
SizedBox(height: 12.h),
_PaymentWorkspaceMessage(
message: disabledMessage ?? '请先添加商品后再继续支付',
warning: true,
),
],
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch),
);
}
return SingleChildScrollView(
child: <Widget>[
_PaymentRecognitionCard(controller: controller),
@@ -2215,6 +2409,226 @@ class _PaymentWorkspace extends StatelessWidget {
}
}
/// 人脸库为空时的就地同步引导卡,覆盖空库、同步进度、完成与失败状态。
class _FaceLibrarySetupPanel extends StatelessWidget {
/// 创建读取人脸库同步状态的引导卡。
const _FaceLibrarySetupPanel({required this.controller});
/// 支付业务控制器,提供同步阶段、进度和启动入口。
final SelectPayTypeDialogController controller;
/// 按阶段渲染引导、进度或失败重试内容;阶段间只更换卡片内容不改变布局骨架。
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Obx(() {
final phase = controller.faceSetupPhase.value;
final progress = controller.faceSetupProgress.value;
final setupMessage = controller.faceSetupMessage.value;
final dispatchMessage = controller.faceWorkspaceMessage.value;
final accent = switch (phase) {
_FaceSetupPhase.success => XfjDesignTokens.success,
_FaceSetupPhase.failed => XfjDesignTokens.danger,
_ => XfjDesignTokens.primary,
};
final background = switch (phase) {
_FaceSetupPhase.running => XfjDesignTokens.primarySoft,
_FaceSetupPhase.success => const Color(0xFFF7FCF9),
_FaceSetupPhase.failed => const Color(0xFFFFF3F2),
_ => XfjDesignTokens.surface,
};
final processed = progress?.processed ?? 0;
final total = progress?.total;
final determinate = total != null && total > 0;
final percent = determinate
? ((processed / total) * 100).round().clamp(0, 100)
: null;
return <Widget>[
_FaceSetupBadge(
icon: phase == _FaceSetupPhase.success
? FAssets.icons.check
: phase == _FaceSetupPhase.failed
? FAssets.icons.circleAlert
: FAssets.icons.scanFace,
color: accent,
loading: phase == _FaceSetupPhase.running,
),
SizedBox(height: 16.h),
Text(
switch (phase) {
_FaceSetupPhase.running => '正在同步人脸',
_FaceSetupPhase.success => '人脸同步完成',
_FaceSetupPhase.failed => '人脸同步失败',
_ => '人脸库为空',
},
textAlign: TextAlign.center,
style: theme.typography.lg.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 8.h),
Text(
switch (phase) {
_FaceSetupPhase.running => determinate
? '正在下载照片并注册人脸特征,请保持弹框开启;人数较多时可能需要几分钟。'
: '正在获取服务端人脸清单,已获取 $processed 条,请稍候。',
_FaceSetupPhase.success =>
setupMessage.isEmpty ? '本机人脸已就绪' : setupMessage,
_FaceSetupPhase.failed =>
setupMessage.isEmpty ? '同步未完成,请重试或改用其他支付方式' : setupMessage,
_ => '本机还没有可识别人脸,可直接在此同步本部门人脸,完成后自动开始识别支付。',
},
textAlign: TextAlign.center,
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
height: 1.5,
),
),
if (phase == _FaceSetupPhase.running) ...[
SizedBox(height: 18.h),
Semantics(
label: determinate
? '人脸同步进度,已处理 $processed 条,共 $total 条'
: '正在获取服务端人脸清单,已获取 $processed 条',
child: <Widget>[
ClipRRect(
borderRadius: BorderRadius.circular(8.w),
child: LinearProgressIndicator(
key: const ValueKey('face-setup-progress-bar'),
value:
determinate ? (processed / total).clamp(0.0, 1.0) : null,
minHeight: 10.h,
color: XfjDesignTokens.primary,
backgroundColor: XfjDesignTokens.primary.withValues(
alpha: 0.12,
),
),
),
SizedBox(height: 8.h),
<Widget>[
Text(
determinate ? '已处理 $processed / $total' : '已获取 $processed 条',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
if (percent != null)
Text(
'$percent%',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.primary,
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
].toRow(mainAxisAlignment: MainAxisAlignment.spaceBetween),
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch),
),
],
if (phase == _FaceSetupPhase.empty) ...[
SizedBox(height: 18.h),
_PaymentDialogButton(
key: const ValueKey('face-setup-sync-button'),
label: '同步人脸并继续识别',
onTap: controller.startFaceLibrarySetup,
),
SizedBox(height: 12.h),
Text(
'同步也可稍后在 设置 → 人脸管理 中进行',
textAlign: TextAlign.center,
style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted),
),
],
if (phase == _FaceSetupPhase.failed) ...[
if (dispatchMessage.isNotEmpty) ...[
SizedBox(height: 12.h),
_PaymentWorkspaceMessage(message: dispatchMessage),
],
SizedBox(height: 18.h),
_PaymentDialogButton(
key: const ValueKey('face-setup-retry-button'),
label: '重试同步',
onTap: controller.startFaceLibrarySetup,
),
SizedBox(height: 12.h),
Text(
'也可以直接选择上方的其他支付方式完成本单',
textAlign: TextAlign.center,
style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted),
),
],
if (phase == _FaceSetupPhase.success) ...[
SizedBox(height: 12.h),
Text(
dispatchMessage.isEmpty ? '正在自动开始人脸识别…' : dispatchMessage,
textAlign: TextAlign.center,
style: theme.typography.sm.copyWith(
color: dispatchMessage.isEmpty
? XfjDesignTokens.primary
: XfjDesignTokens.danger,
fontWeight: FontWeight.w600,
),
),
],
]
.toColumn(crossAxisAlignment: CrossAxisAlignment.stretch)
.padding(all: 22.w)
.decorated(
key: const ValueKey('face-setup-panel'),
color: background,
border: Border.all(color: accent.withValues(alpha: 0.34)),
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
);
});
}
}
/// 引导卡顶部的状态徽标,进行中切换为加载指示器且不改变占位尺寸。
class _FaceSetupBadge extends StatelessWidget {
/// 创建固定尺寸的状态徽标。
const _FaceSetupBadge({
required this.icon,
required this.color,
this.loading = false,
});
/// 非进行中状态展示的图标资源。
final SvgAsset icon;
/// 徽标底色,与当前阶段语义色一致;图标和加载指示器使用白色。
final Color color;
/// 是否以加载指示器替代图标。
final bool loading;
/// 构建 56dp 见方的圆角徽标,保证各状态占位一致。
@override
Widget build(BuildContext context) {
return SizedBox(
width: 56.w,
height: 56.w,
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
),
child: loading
? SizedBox(
width: 26.w,
height: 26.w,
child: const CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
).center()
: FIcon(icon, size: 28.w, color: Colors.white).center(),
),
);
}
}
/// 人脸或硬件识别成功后的人员和钱包工作区,敏感字段仅展示脱敏结果。
class _RecognizedUserWorkspace extends StatelessWidget {
/// 创建人员信息与钱包信息并列、窄屏自动纵向排列的工作区。
@@ -3345,6 +3759,7 @@ class _ReidentifyButton extends StatelessWidget {
class _PaymentDialogButton extends StatelessWidget {
const _PaymentDialogButton({
super.key,
required this.label,
required this.onTap,
this.soft = false,
@@ -3457,4 +3872,3 @@ String _paymentPromptDescription(OrderPayDisplayType? type) {
}
String _paymentMoney(int amount) => '¥ ${(amount / 100).toStringAsFixed(2)}';