feat:人脸质量上报、人脸支付对比凭证

This commit is contained in:
dzc committed 2026-09-10 17:49:07 +08:00
1 parent e15197edf4
commit dc5ef2bc9e
9 files changed
+341 -130

No files matched your search

@@ -15,6 +15,9 @@ mixin OrderMixin {
/// 下单参数日志保留的最大字符数,与 HttpManage 请求日志的截断口径一致。
static const _maxOrderParamLogLength = 2000;
/// 当前收银弹框成功的人脸证据结果,只有人脸支付下单时才会消费并绑定订单号。
Rxn<FaceStream> pendingFaceRecognition = Rxn<FaceStream>();
final appC = Get.find<AppControl>();
/// **************** start 需要重制 start ****************
@@ -325,6 +328,21 @@ mixin OrderMixin {
}).toList();
}
final isNetwork = AppNetworkTool.isAutoNetwork();
// 人脸支付订单在发起请求前把识别会话绑定到终端订单号;离线单同样绑定,
// 联网同步并由服务端确认支付后,后台补传任务按订单号对账上传证据。
final isFacePayment = orderPayType.value == OrderPayType.FACE;
final faceSessionId = pendingFaceRecognition.value?.sessionId?.trim();
var evidenceMetadataList = const <Map<String, dynamic>>[];
String? evidenceAppVersion;
if (isFacePayment && faceSessionId != null && faceSessionId.isNotEmpty) {
evidenceMetadataList = await OrderEvidenceUploadTool.bindSessionToOrder(
recognitionSessionId: faceSessionId,
androidOrderCode: sqlOrderDetail.uniqueCode,
);
evidenceAppVersion =
await OrderEvidenceUploadTool.getSessionAppVersion(faceSessionId);
pendingFaceRecognition.value = null;
}
var pa = {
"androidOrderId": sqlOrderId,
"androidOrderCode": sqlOrderDetail.uniqueCode,
@@ -338,6 +356,9 @@ mixin OrderMixin {
"orderPayType": orderPayType.value?.name,
"list": shopList,
"extraPrice": amountMealPrice.value,
if (evidenceMetadataList.isNotEmpty)
"evidenceMetadataList": evidenceMetadataList,
if (evidenceAppVersion != null) "appVersion": evidenceAppVersion,
};
if (orderPayType.value != null &&
appSetting.sysType == SysType.CANTEEN &&
@@ -349,7 +370,10 @@ mixin OrderMixin {
pa["walletId"] = payUser.value?.walletId;
}
}
final orderParamsJson = json.encode(pa);
// 证据元数据不进入日志,避免哈希和样本编号长期留存在终端日志文件中。
final safeLogParameters = Map<String, dynamic>.from(pa)
..remove('evidenceMetadataList');
final orderParamsJson = json.encode(safeLogParameters);
logger.i(
"|$_tag|创建api订单,下单参数:|${orderParamsJson.length > _maxOrderParamLogLength ? "${orderParamsJson.substring(0, _maxOrderParamLogLength)}...(已截断,原长度:${orderParamsJson.length})" : orderParamsJson}|;",
);
@@ -381,6 +405,36 @@ mixin OrderMixin {
sqlOrderDetail.payPrice = payPrice;
sqlOrderDetail.payTime = DateTime.now();
await orderDao.edit(sqlOrderDetail);
if (isFacePayment &&
faceSessionId != null &&
faceSessionId.isNotEmpty &&
result is String &&
result.isNotEmpty) {
try {
await OrderEvidenceUploadTool.markOrderReady(
recognitionSessionId: faceSessionId,
serverOrderId: result,
);
// 调度器仍会检查支付、人脸和业务弹框安全窗口,当前收银收尾不会被补传占用。
unawaited(
SyncCheckTool.instance
.trigger(
ScheduledTaskType.orderEvidenceUpload,
triggerType: ScheduledTaskTriggerType.system,
)
.catchError((error) {
logger.w(
'|$_tag|订单已成功但证据补传触发失败|errorType:${error.runtimeType}|',
);
}),
);
} catch (error) {
// 任务已经绑定 androidOrderCode,后续可重新对账;本地证据状态失败不能反向否定已成功订单。
logger.w(
'|$_tag|订单已成功但证据任务状态更新失败|errorType:${error.runtimeType}|',
);
}
}
if (orderPayType.value != OrderPayType.WALLET_QR_CODE_SCAN) {
// 支付结果已落库,副屏通知只能尽力发送,不能等待其回执阻塞主屏订单收尾。
unawaited(sendSubScreenPaySuccess());
@@ -392,6 +446,15 @@ mixin OrderMixin {
}
return result;
} catch (e, stack) {
if (isFacePayment &&
faceSessionId != null &&
faceSessionId.isNotEmpty &&
!OrderEvidenceUploadTool.shouldReconcileOrderFailure(e)) {
await OrderEvidenceUploadTool.cancelSession(
faceSessionId,
reason: '服务端明确拒绝订单,证据任务已取消',
);
}
Toast.error(msg: "结算订单失败");
logger.e("|$_tag|创建api订单,下单失败error:|$e|stack:|$stack|");
}
@@ -6,6 +6,7 @@ import 'package:common_utils/common_utils.dart';
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart';
import 'package:forui/forui.dart';
@@ -13,33 +14,33 @@ 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;
}
@@ -120,20 +121,20 @@ 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。
@@ -725,8 +726,8 @@ class SelectPayTypeDialogController extends GetxController {
return;
}
if (_faceSetupRunning) {
// 就地同步仍在后台写入人脸特征,此时开启摄像头会与增量注册争抢引擎。
faceWorkspaceMessage.value = '人脸数据正在同步,请稍候';
// 就地注册仍在装载人脸特征,此时开启摄像头会与原生注册争抢引擎。
faceWorkspaceMessage.value = '人脸库正在注册,请稍候';
return;
}
final requestId = ++_faceDispatchRequestId;
@@ -738,8 +739,8 @@ class SelectPayTypeDialogController extends GetxController {
final databaseFaceCount = await faceDao.findCount("-1");
if (!_ownsFaceDispatch(requestId)) return;
if (databaseFaceCount == null || databaseFaceCount == 0) {
// 空库不再把收银员引去设置页:切换为就地同步引导,由其当场拉取人脸
// 后自动续接识别,顾客无需离开结算弹框。
// 空库不再把收银员引去设置页:切换为就地注册引导,把本机人脸装载进
// 识别引擎后自动续接识别,顾客无需离开结算弹框。
isRecognition.value = false;
isReidentifying.value = false;
faceWorkspaceMessage.value = '';
@@ -758,7 +759,7 @@ class SelectPayTypeDialogController extends GetxController {
var waitRound = 0;
while (status?.registrationInProgress == true && waitRound < 8) {
faceWorkspaceMessage.value =
waitRound == 0 ? '人脸数据正在同步,请稍候' : '人脸数据正在同步,请稍候(${waitRound * 2}秒)';
waitRound == 0 ? '人脸库正在注册,请稍候' : '人脸库正在注册,请稍候(${waitRound * 2}秒)';
await Future<void>.delayed(const Duration(seconds: 2));
if (!_ownsFaceDispatch(requestId)) return;
status = await AppFaceTool.getStatus();
@@ -774,8 +775,16 @@ class SelectPayTypeDialogController extends GetxController {
throw Exception(msg);
}
if (status!.memoryCount != databaseFaceCount || !status.engineReady) {
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;
}
final handle = await service.start(
scene: FaceRecognitionScene.cashier,
@@ -806,106 +815,101 @@ class SelectPayTypeDialogController extends GetxController {
}
}
/// 检查异步人脸同步结果是否仍属于未被关闭或切换的本轮就地同步请求。
/// 检查异步人脸注册结果是否仍属于未被关闭或切换的本轮就地注册请求。
bool _ownsFaceSetup(int requestId) {
return requestId == _faceSetupDispatchId;
}
/// 在收银弹框内直接同步本部门人脸,完成后自动续接人脸识别。
/// 在收银弹框内直接把本机人脸数据注册进识别引擎,完成后自动续接人脸识别。
///
/// 弹框打开期间调度器的安全窗口永远不满足,因此这里直接执行同步并手动
/// 持有重型任务互斥;识别锁复用 [isRecognition],使底部提交与支付方式
/// 切换沿既有取消确认流程工作。关闭弹框或切换支付方式后同步会在后台
/// 安全完成,但不再自动开启摄像头。
Future<void> startFaceLibrarySetup() async {
/// 收银弹框只负责注册:人脸数据本身的拉取仍由设置页、启动装载和每日任务
/// 完成,这里不发起任何网络请求,注册只读取本地库并批量装载虹软内存特征。
/// 识别锁复用 [isRecognition],使底部提交与支付方式切换沿既有取消确认流程
/// 工作。关闭弹框或切换支付方式后注册会在后台安全完成,但不再自动开启
/// 摄像头。
Future<void> startFaceLibraryRegistration() 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;
final status = await AppFaceTool.getStatus();
if (!_ownsFaceSetup(requestId)) return;
if (status?.activated != true) {
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value = "人脸插件未激活,请前往'设置'->'人脸设置'处理";
return;
}
_faceSetupRunning = true;
faceSetupPhase.value = _FaceSetupPhase.running;
faceSetupProgress.value = null;
faceSetupMessage.value = '';
isRecognition.value = true;
isReidentifying.value = false;
TtsManage.speak('开始同步人脸');
TtsManage.speak('开始注册人脸');
SyncIdleTool.instance.beginHeavyTask();
StreamSubscription<FaceRegistrationProgress>? progressListen;
try {
final summary = await SyncFaceTool.sync(
mode: FaceSyncMode.departmentSnapshot,
onProgress: (processed, total) {
progressListen = FaceDiscernPlugs.registrationStream.listen(
(event) {
if (!_ownsFaceSetup(requestId)) return;
faceSetupProgress.value = _FaceSetupProgress(
processed: processed,
total: total,
processed: event.processed,
total: event.total > 0 ? event.total : null,
);
},
onError: (Object error, StackTrace stack) {
logger.w('|$_tag|弹框内注册进度监听失败error:$error|stack:$stack|');
},
);
if (summary.hasFailures) {
logger.w('|$_tag|弹框内人脸同步部分失败|${summary.message}|');
FaceRegistrationResult? result;
try {
result = await FaceDiscernPlugs.rebuildFaceLibrary();
} on PlatformException catch (error) {
if (error.code != 'REGISTRATION_IN_PROGRESS') rethrow;
// 弹框打开前已有注册在装载(多为启动步骤):跟随原生进度等待结束。
logger.i('|$_tag|弹框内注册避让进行中的原生注册|code:${error.code}|');
final finished = await _awaitRegistrationIdle(requestId);
if (!_ownsFaceSetup(requestId)) return;
if (!finished) {
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value = '人脸库正在注册,请稍后点击重试';
return;
}
}
if (!_ownsFaceSetup(requestId)) return;
final usableCount = summary.added + summary.updated;
if (summary.scanned == 0) {
if (result != null && result.total == 0) {
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value = '服务端暂无可同步的人脸,请先在管理端录入人脸';
faceSetupMessage.value = "本机暂无人脸数据,请先前往'设置'->'人脸管理'同步人脸";
return;
}
if (usableCount == 0 && summary.failed > 0) {
if (result != null &&
result.successCount == 0 &&
result.failedCount > 0) {
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value =
summary.hasFailures ? summary.message : '本次没有同步到新的人脸,请稍后重试';
faceSetupMessage.value = result.message;
return;
}
faceSetupPhase.value = _FaceSetupPhase.success;
faceSetupMessage.value = summary.message;
if (summary.hasFailures) {
Toast.error(msg: '部分人脸同步失败 ${summary.failed} 条,可稍后在设置中重新同步');
faceSetupMessage.value = result?.message ?? '本机人脸库已装载完成';
if (result != null && result.failedCount > 0) {
Toast.error(msg: '部分人脸注册失败 ${result.failedCount} 条,可稍后在设置中重新注册');
} 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;
}
TtsManage.speak('人脸注册完成');
}
await _continueRecognitionAfterSetup(requestId);
} catch (error, stack) {
logger.e('|$_tag|弹框内人脸同步失败error:|$error|stack:|$stack|');
logger.e('|$_tag|弹框内人脸注册失败error:|$error|stack:|$stack|');
if (!_ownsFaceSetup(requestId)) return;
faceSetupPhase.value = _FaceSetupPhase.failed;
faceSetupMessage.value =
error is FaceSyncSnapshotException ? error.message : '人脸同步失败,请稍后重试';
error is PlatformException && error.message != null
? error.message!
: '人脸注册失败,请稍后重试';
await AppRuntimeTool.vibrate(durationMs: 60);
} finally {
_faceSetupRunning = false;
SyncIdleTool.instance.endHeavyTask();
await progressListen?.cancel();
if (_ownsFaceSetup(requestId) && faceSessionHandle.value == null) {
isRecognition.value = false;
isReidentifying.value = false;
@@ -913,6 +917,56 @@ class SelectPayTypeDialogController extends GetxController {
}
}
/// 跟随原生注册进度直到结束;超时或流异常返回 false 交由调用方提示重试。
Future<bool> _awaitRegistrationIdle(
int requestId, {
Duration timeout = const Duration(minutes: 3),
}) async {
final completer = Completer<bool>();
StreamSubscription<FaceRegistrationProgress>? listen;
listen = FaceDiscernPlugs.registrationStream.listen(
(event) {
if (_ownsFaceSetup(requestId)) {
faceSetupProgress.value = _FaceSetupProgress(
processed: event.processed,
total: event.total > 0 ? event.total : null,
);
}
if (!event.inProgress && !completer.isCompleted) {
completer.complete(true);
}
},
onError: (Object error) {
if (!completer.isCompleted) completer.complete(false);
},
);
try {
return await completer.future.timeout(timeout, onTimeout: () => false);
} finally {
await listen.cancel();
}
}
/// 注册成功后自动续接人脸识别;续接失败的原因已写入 [faceWorkspaceMessage]。
Future<void> _continueRecognitionAfterSetup(int requestId) async {
// 特征装载已经结束,先解除注册占位与识别锁再续接,避免被防重入拦截。
_faceSetupRunning = false;
isRecognition.value = false;
if (!_ownsFaceSetup(requestId) ||
orderPayDisplayType.value != OrderPayDisplayType.face ||
payUser.value != null) {
return;
}
await _dispatchFace();
if (!_ownsFaceSetup(requestId)) return;
if (faceSessionHandle.value == null && payUser.value == null) {
faceSetupPhase.value = _FaceSetupPhase.failed;
if (faceWorkspaceMessage.value.isEmpty) {
faceSetupMessage.value = '人脸库装载后仍未就绪,请重试或前往设置检查';
}
}
}
/// 处理嵌入式面板返回的唯一人脸结果,并在原位查询人员与钱包。
Future<void> handleFaceRecognized(
FaceStream event,
@@ -2373,21 +2427,30 @@ 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 <Widget>[
LayoutBuilder(
builder: (context, constraints) {
// minHeight 让注册卡撑满工作区剩余高度;内容超高时仍可滚动。
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: _FaceLibrarySetupPanel(controller: controller),
),
);
},
).expanded(),
if (!paymentEnabled) ...[
SizedBox(height: 12.h),
_PaymentWorkspaceMessage(
message: disabledMessage ?? '请先添加商品后再继续支付',
warning: true,
),
],
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch);
}
return SingleChildScrollView(
child: <Widget>[
@@ -2456,10 +2519,10 @@ class _FaceLibrarySetupPanel extends StatelessWidget {
SizedBox(height: 16.h),
Text(
switch (phase) {
_FaceSetupPhase.running => '正在同步人脸',
_FaceSetupPhase.success => '人脸同步完成',
_FaceSetupPhase.failed => '人脸同步失败',
_ => '人脸库为空',
_FaceSetupPhase.running => '正在注册人脸',
_FaceSetupPhase.success => '人脸注册完成',
_FaceSetupPhase.failed => '人脸注册失败',
_ => '人脸库未就绪',
},
textAlign: TextAlign.center,
style: theme.typography.lg.copyWith(
@@ -2470,14 +2533,13 @@ class _FaceLibrarySetupPanel extends StatelessWidget {
SizedBox(height: 8.h),
Text(
switch (phase) {
_FaceSetupPhase.running => determinate
? '正在下载照片并注册人脸特征,请保持弹框开启;人数较多时可能需要几分钟。'
: '正在获取服务端人脸清单,已获取 $processed 条,请稍候。',
_FaceSetupPhase.running =>
determinate ? '正在把本机人脸特征装载进识别引擎,请保持弹框开启。' : '正在准备本机人脸特征,请稍候。',
_FaceSetupPhase.success =>
setupMessage.isEmpty ? '本机人脸已就绪' : setupMessage,
_FaceSetupPhase.failed =>
setupMessage.isEmpty ? '同步未完成,请重试或改用其他支付方式' : setupMessage,
_ => '本机还没有可识别人脸,可直接在此同步本部门人脸,完成后自动开始识别支付。',
setupMessage.isEmpty ? '注册未完成,请重试或改用其他支付方式' : setupMessage,
_ => '点击注册将把本机已同步的人脸装载进识别引擎,完成后自动开始人脸识别。',
},
textAlign: TextAlign.center,
style: theme.typography.sm.copyWith(
@@ -2489,8 +2551,8 @@ class _FaceLibrarySetupPanel extends StatelessWidget {
SizedBox(height: 18.h),
Semantics(
label: determinate
? '人脸同步进度,已处理 $processed 条,共 $total 条'
: '正在获取服务端人脸清单,已获取 $processed 条',
? '人脸注册进度,已注册 $processed 条,共 $total 条'
: '正在准备本机人脸特征,已处理 $processed 条',
child: <Widget>[
ClipRRect(
borderRadius: BorderRadius.circular(8.w),
@@ -2508,7 +2570,7 @@ class _FaceLibrarySetupPanel extends StatelessWidget {
SizedBox(height: 8.h),
<Widget>[
Text(
determinate ? '已处理 $processed / $total' : '已获取 $processed 条',
determinate ? '已注册 $processed / $total' : '已处理 $processed 条',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontFeatures: const [FontFeature.tabularFigures()],
@@ -2530,13 +2592,13 @@ class _FaceLibrarySetupPanel extends StatelessWidget {
if (phase == _FaceSetupPhase.empty) ...[
SizedBox(height: 18.h),
_PaymentDialogButton(
key: const ValueKey('face-setup-sync-button'),
label: '同步人脸并继续识别',
onTap: controller.startFaceLibrarySetup,
key: const ValueKey('face-setup-register-button'),
label: '注册人脸',
onTap: controller.startFaceLibraryRegistration,
),
SizedBox(height: 12.h),
Text(
'同步也可稍后在 设置 → 人脸管理 中进行',
'本机人脸数据的同步由 设置 → 人脸管理 或每日任务完成',
textAlign: TextAlign.center,
style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted),
),
@@ -2549,8 +2611,8 @@ class _FaceLibrarySetupPanel extends StatelessWidget {
SizedBox(height: 18.h),
_PaymentDialogButton(
key: const ValueKey('face-setup-retry-button'),
label: '重试同步',
onTap: controller.startFaceLibrarySetup,
label: '重试注册',
onTap: controller.startFaceLibraryRegistration,
),
SizedBox(height: 12.h),
Text(
@@ -2573,7 +2635,11 @@ class _FaceLibrarySetupPanel extends StatelessWidget {
),
],
]
.toColumn(crossAxisAlignment: CrossAxisAlignment.stretch)
.toColumn(
// 卡片由外层约束撑满工作区剩余高度,内容在整卡内垂直居中。
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
)
.padding(all: 22.w)
.decorated(
key: const ValueKey('face-setup-panel'),