diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java index 9a08e8f..ada3c2c 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java @@ -181,7 +181,7 @@ public class FaceServer { int searchId = faceEntity.getFaceId(); FaceEntity oldEntity = copyFaceEntity(faceRegisterInfoMap.get(searchId)); try { - FaceFeatureInfo oldFeature = getFaceFeature(searchId); + FaceFeatureInfo oldFeature = oldEntity == null ? getFaceFeatureQuiet(searchId) : null; if (oldEntity == null && oldFeature != null) { oldEntity = new FaceEntity(searchId, oldFeature.getFeatureData()); } @@ -268,6 +268,22 @@ public class FaceServer { return faceFeatureInfo; } + /** + * 探测内存库特征但不记录异常日志。 + * + *

标识不存在是增量路径的常见前置状态(新样本 upsert 探测旧特征、孤儿 + * 重复删除的存在性检查),IllegalArgumentException 属预期分支;批量同步 + * 数千张全新样本时会逐条抛错刷屏,这里静默返回空。

+ */ + private FaceFeatureInfo getFaceFeatureQuiet(int searchId) { + if (faceEngine == null) return null; + try { + return faceEngine.getFaceFeature(searchId); + } catch (IllegalArgumentException e) { + return null; + } + } + // 获取人脸数量 public synchronized int getTotalFaceCount() { if (faceEngine == null) return -99; @@ -278,7 +294,7 @@ public class FaceServer { public synchronized int removeFace(int searchId) { if (faceEngine == null) return -99; // MQ 和每日快照都可能重复删除同一样本;内存库已不存在时直接按幂等成功处理。 - if (searchId != -1 && getFaceFeature(searchId) == null) { + if (searchId != -1 && getFaceFeatureQuiet(searchId) == null) { faceRegisterInfoMap.remove(searchId); return ErrorInfo.MOK; } diff --git a/lib/common/models/api/user_api_models.dart b/lib/common/models/api/user_api_models.dart index 1960337..4596736 100644 --- a/lib/common/models/api/user_api_models.dart +++ b/lib/common/models/api/user_api_models.dart @@ -344,6 +344,7 @@ class ServiceFaceQualityReportResult { required this.updatedSampleIds, required this.ignoredStaleSampleIds, required this.alreadyAbsentSampleIds, + this.ignoredNotApprovedSampleIds = const [], }); /// 本次成功写入服务端的样本编号。 @@ -355,6 +356,10 @@ class ServiceFaceQualityReportResult { /// 请求时服务端已经不存在的样本编号。 final List alreadyAbsentSampleIds; + /// 未审核通过或服务端编号异常而逐条拒收的样本编号;不计入已确认, + /// 终端保留待下次重试,样本审核通过后即可正常写入。 + final List ignoredNotApprovedSampleIds; + /// 从统一网络层返回的 JSON 创建上报确认结果。 factory ServiceFaceQualityReportResult.fromJson(Map json) => _$ServiceFaceQualityReportResultFromJson(json); diff --git a/lib/common/models/api/user_api_models.g.dart b/lib/common/models/api/user_api_models.g.dart index c82fb92..192cf1d 100644 --- a/lib/common/models/api/user_api_models.g.dart +++ b/lib/common/models/api/user_api_models.g.dart @@ -189,6 +189,11 @@ ServiceFaceQualityReportResult _$ServiceFaceQualityReportResultFromJson( alreadyAbsentSampleIds: (json['alreadyAbsentSampleIds'] as List) .map((e) => e as String) .toList(), + ignoredNotApprovedSampleIds: + (json['ignoredNotApprovedSampleIds'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], ); Map _$ServiceFaceQualityReportResultToJson( @@ -197,6 +202,7 @@ Map _$ServiceFaceQualityReportResultToJson( 'updatedSampleIds': instance.updatedSampleIds, 'ignoredStaleSampleIds': instance.ignoredStaleSampleIds, 'alreadyAbsentSampleIds': instance.alreadyAbsentSampleIds, + 'ignoredNotApprovedSampleIds': instance.ignoredNotApprovedSampleIds, }; ServiceTenant _$ServiceTenantFromJson(Map json) => diff --git a/lib/common/models/manage/face_sync_models.dart b/lib/common/models/manage/face_sync_models.dart index 324ff89..cd57ab4 100644 --- a/lib/common/models/manage/face_sync_models.dart +++ b/lib/common/models/manage/face_sync_models.dart @@ -22,6 +22,7 @@ class FaceSyncSummary { required this.skipped, required this.failed, required this.maskedFailedSampleIds, + this.failureReasons = const {}, this.qualityReportNote, }); @@ -49,6 +50,9 @@ class FaceSyncSummary { /// 失败样本的脱敏编号,最多由同步服务保留有限数量用于现场定位。 final List maskedFailedSampleIds; + /// 失败原因到数量的归类统计,保持出现顺序;供结果弹框逐条展示。 + final Map failureReasons; + /// 同步末尾的质量上报未完成时的提示;上报成功或无需上报时为空。 final String? qualityReportNote; @@ -73,8 +77,9 @@ class FaceSyncSummary { /// 人脸同步过程上报的最新进度快照,供手动同步界面展示进度条。 class FaceSyncProgress { - /// 创建进度快照;[total] 在清单分页获取阶段为空,比对处理阶段为已知总数。 - const FaceSyncProgress({required this.processed, this.total}); + /// 创建进度快照;[total] 在清单分页获取阶段为空,比对处理阶段为已知总数; + /// [note] 非空表示进入收尾阶段(质量上报、目录清理),优先作为文案展示。 + const FaceSyncProgress({required this.processed, this.total, this.note}); /// 已获取的清单条数或已处理的记录数,含义随阶段变化但始终单调递增。 final int processed; @@ -82,6 +87,9 @@ class FaceSyncProgress { /// 本次需要处理的总记录数;清单仍在分页获取时尚不确定,为空。 final int? total; + /// 阶段说明文案;比对结束后仍有耗时收尾,避免进度条停满后看似卡死。 + final String? note; + /// 已处理记录占比;总数未知或非法时返回空,由调用方展示不定态样式。 double? get ratio { final value = total; diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index 3b28ac0..4246b43 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -37,6 +37,9 @@ class SyncCheckTool { /// 人脸同步任务执行中的最新进度快照,任务结束后清空;仅驱动界面展示。 final departmentFaceSyncProgress = Rxn(); + /// 最近一次人脸同步的结构化结果,供页面弹出统计弹框;失败重试前保持可读。 + final departmentFaceSyncSummary = Rxn(); + /// 当前进程内 Cron 调度器;任务全部停止后置空并关闭其内部计时资源。 Cron? _cron; @@ -722,8 +725,10 @@ class SyncCheckTool { ), ); } - if (error is ScheduledTaskDependencyException) { - logger.w('|$_tag|任务依赖暂不可用type:${type.name}|error:${error.message}|'); + if (error is ScheduledTaskDependencyException || + error is FaceSyncPartialFailureException) { + // 依赖暂不可用与部分样本失败都是面向终端人员的业务结果,不按系统错误打整段堆栈。 + logger.w('|$_tag|任务未完全成功type:${type.name}|error:${error.toString()}|'); } else { logger.e( '|$_tag|任务执行失败type:${type.name}|error:$error|stack:$stack|', @@ -833,6 +838,7 @@ class SyncCheckTool { } return const _ScheduledTaskActionResult.success('设备配置同步完成'); case ScheduledTaskType.departmentFaceSync: + departmentFaceSyncSummary.value = null; final mode = request.debugAllFaces ? FaceSyncMode.debugAllSnapshot : request.faceUserIds == null @@ -841,13 +847,16 @@ class SyncCheckTool { final summary = await SyncFaceTool.sync( mode: mode, userIds: request.faceUserIds, - onProgress: (processed, total) { + onProgress: (processed, total, note) { departmentFaceSyncProgress.value = FaceSyncProgress( processed: processed, total: total, + note: note, ); }, ); + // 同步主体完成即暴露结构化结果;部分失败抛异常前页面也能取到统计。 + departmentFaceSyncSummary.value = summary; if (summary.changed > 0 && request.triggerType == ScheduledTaskTriggerType.mqtt && Get.isRegistered()) { diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 8b48d40..80b5d8f 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -26,6 +26,9 @@ abstract class SyncFaceTool { /// 服务端单次允许接收的人脸质量结果上限。 static const int _qualityReportBatchSize = 500; + /// 整批上报被服务端拒绝后的降级小批大小,避免个别样本拖住整批。 + static const int _qualityRetryBatchSize = 25; + /// 同步图片预取的临时下载目录名;每次同步开始时整体清理中断遗留。 static const String _syncTempDirectoryName = '.face-sync-temp'; @@ -86,11 +89,12 @@ abstract class SyncFaceTool { /// 任何页面中断都会抛出异常且不会进入本地孤儿删除阶段。 /// /// [onProgress] 在清单分页和逐条处理时上报进度;清单仍在分页获取时总数 - /// 为空,进入逐条处理后总数为本次需要处理的记录数。 + /// 为空,进入逐条处理后总数为本次需要处理的记录数;比对结束后以 [note] + /// 说明收尾阶段(质量上报、目录清理),避免进度停满后看似卡死。 static Future sync({ required FaceSyncMode mode, Set? userIds, - void Function(int processed, int? total)? onProgress, + void Function(int processed, int? total, String? note)? onProgress, }) async { if (!AppNetworkTool.isAutoNetwork()) { throw const FaceSyncSnapshotException('网络不可用,无法获取人脸权威快照'); @@ -168,7 +172,10 @@ abstract class SyncFaceTool { onProgress: onProgress, ); } - final qualityReported = await _reportPersistedFaceQualitySafely(); + final qualityReported = await _reportPersistedFaceQualitySafely( + onProgress: onProgress, + ); + onProgress?.call(0, null, '正在清理目录文件...'); await _sweepFaceDirectory(storagePath); final summary = accumulator.toSummary( qualityReportNote: qualityReported ? null : '质量上报未完成,将在下次同步重试', @@ -200,7 +207,7 @@ abstract class SyncFaceTool { static Future> _loadV3Snapshot({ required String merchantDeptId, String? userId, - void Function(int processed, int? total)? onProgress, + void Function(int processed, int? total, String? note)? onProgress, }) async { var afterId = '0'; String? snapshotMaxId; @@ -257,7 +264,7 @@ abstract class SyncFaceTool { throw const FaceSyncSnapshotException('人脸快照存在空页但仍声明后续数据'); } afterId = snapshot.nextAfterId; - onProgress?.call(records.length, null); + onProgress?.call(records.length, null, null); } throw const FaceSyncSnapshotException('人脸快照分页超过安全上限'); } @@ -268,7 +275,7 @@ abstract class SyncFaceTool { /// 图片版本,保证调试同步写入的人脸同样可以参与质量上报和版本比对。 /// 服务端历史脏数据(空编号或重复编号)只跳过并记录计数,不阻断调试同步。 static Future> _loadDebugAllSnapshot({ - void Function(int processed, int? total)? onProgress, + void Function(int processed, int? total, String? note)? onProgress, }) async { final records = []; final sampleIds = {}; @@ -316,7 +323,7 @@ abstract class SyncFaceTool { } return records; } - onProgress?.call(records.length, null); + onProgress?.call(records.length, null, null); } throw const FaceSyncSnapshotException('调试全租户快照分页超过安全上限'); } @@ -332,7 +339,7 @@ abstract class SyncFaceTool { required String storagePath, required _FaceSyncAccumulator accumulator, Set? orphanScopeDeptIds, - void Function(int processed, int? total)? onProgress, + void Function(int processed, int? total, String? note)? onProgress, }) async { final faceDao = Get.find().dataBase.getFaceDao; final localBySampleId = {}; @@ -367,17 +374,17 @@ abstract class SyncFaceTool { final existing = localBySampleId.remove(record.sampleId); final imageChanged = _imageChanged(existing, record, existingFilePaths); if (existing == null || imageChanged) { - final success = await _upsertImageFace( + final failureReason = await _upsertImageFace( record: record, existing: existing, storagePath: storagePath, faceDao: faceDao, prefetcher: prefetcher, ); - if (success) { + if (failureReason == null) { existing == null ? accumulator.added++ : accumulator.updated++; } else { - accumulator.fail(record.sampleId); + accumulator.fail(record.sampleId, failureReason); } } else if (_metadataChanged(existing, record)) { // 元数据变化只改对应列,不回写特征大字段和质量检测结果。 @@ -397,7 +404,7 @@ abstract class SyncFaceTool { } else { accumulator.skipped++; } - onProgress?.call(accumulator.scanned, remote.length); + onProgress?.call(accumulator.scanned, remote.length, null); } // 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。 @@ -427,11 +434,12 @@ abstract class SyncFaceTool { : (processed, total) => onProgress( remote.length + processed, remote.length + scopedOrphans.length, + null, ), ); accumulator.deleted += bulkResult.deleted; for (final face in bulkResult.failedFaces) { - accumulator.fail(face.sampleId); + accumulator.fail(face.sampleId, '本地样本清理失败'); } } finally { await prefetcher?.dispose(); @@ -491,7 +499,8 @@ abstract class SyncFaceTool { /// 下载临时图片、提取特征并提交文件、SQLite 和虹软单样本增量。 /// /// 虹软替换失败时原生保留旧特征,本方法随后回滚 SQLite 和新文件;新样本失败则不留正式记录。 - static Future _upsertImageFace({ + /// 返回空表示成功;失败时返回可直接展示的原因摘要,供调用方统计归类。 + static Future _upsertImageFace({ required ServiceFaceSyncRecord record, required FaceEntity? existing, required String storagePath, @@ -552,7 +561,7 @@ abstract class SyncFaceTool { if (existingRow?.path != null && existingRow!.path != finalFile.path) { await delFaceByStorage(filePath: existingRow.path!); } - return true; + return null; } catch (error, stack) { logger.w( '|$_tag|样本处理失败sampleId:${_maskSampleId(record.sampleId)}|error:${error.runtimeType}|stack:$stack|', @@ -571,7 +580,8 @@ abstract class SyncFaceTool { } } if (finalFile != null) await delFaceByStorage(filePath: finalFile.path); - return false; + // 快照异常携带面向终端人员的安全摘要;其余异常只归类不透出细节。 + return error is FaceSyncSnapshotException ? error.message : '样本处理异常'; } finally { if (temporaryFile != null) { await delFaceByStorage(filePath: temporaryFile.path); @@ -957,8 +967,10 @@ abstract class SyncFaceTool { /// /// 上报范围按本地 [FaceEntity.qualityReportedAt] 增量判定:服务端确认过的样本 /// 在重新检测前不再重复上报;每批确认后立即落标记,标记失败只导致下次重复 - /// 补报,不回滚 SQLite、特征或虹软内存库。 - static Future _reportPersistedFaceQualitySafely() async { + /// 补报,不回滚 SQLite、特征或虹软内存库。[onProgress] 逐批上报收尾进度。 + static Future _reportPersistedFaceQualitySafely({ + void Function(int processed, int? total, String? note)? onProgress, + }) async { if (!AppNetworkTool.isAutoNetwork()) { logger.w('|$_tag|质量上报跳过|reason:网络不可用|'); return false; @@ -1020,12 +1032,12 @@ abstract class SyncFaceTool { var allSucceeded = invalidCount == 0; var confirmedCount = 0; var failedBatchCount = 0; - for (var offset = 0; - offset < items.length; - offset += _qualityReportBatchSize) { - final end = - (offset + _qualityReportBatchSize).clamp(0, items.length).toInt(); - final batch = items.sublist(offset, end); + onProgress?.call(0, items.length, '正在上报质量结果'); + + // 上报一小批并在成功时落确认标记;返回空表示本批被服务端拒绝。 + Future?> reportBatch( + List batch, + ) async { try { final result = await UserApi.reportTerminalFaceQuality(batch); final batchSampleIds = { @@ -1036,7 +1048,7 @@ abstract class SyncFaceTool { ...result.ignoredStaleSampleIds, ...result.alreadyAbsentSampleIds, }.where(batchSampleIds.contains).toList(growable: false); - confirmedCount += confirmedSampleIds.length; + if (confirmedSampleIds.length != batch.length) allSucceeded = false; try { // 逐批落确认标记,进程中断时已确认批次不会在下次重复上报。 if (confirmedSampleIds.isNotEmpty) { @@ -1050,15 +1062,45 @@ abstract class SyncFaceTool { '|$_tag|质量上报标记失败|error:${markError.runtimeType}|stack:$markStack|', ); } - if (confirmedSampleIds.length != batch.length) allSucceeded = false; + return confirmedSampleIds; } catch (error, stack) { - allSucceeded = false; - failedBatchCount++; logger.w( '|$_tag|质量上报批次失败|size:${batch.length}|error:${error.runtimeType}|stack:$stack|', ); + return null; } } + + for (var offset = 0; + offset < items.length; + offset += _qualityReportBatchSize) { + final end = + (offset + _qualityReportBatchSize).clamp(0, items.length).toInt(); + final batch = items.sublist(offset, end); + final confirmed = await reportBatch(batch); + if (confirmed != null) { + confirmedCount += confirmed.length; + } else if (batch.length > _qualityRetryBatchSize) { + // 服务端对整批做一票否决校验(未审核/重复编号会拒绝整批): + // 降级为小子批重试,个别问题样本只拖住自己的小批,其余照常确认。 + for (var sub = 0; sub < batch.length; sub += _qualityRetryBatchSize) { + final subEnd = + (sub + _qualityRetryBatchSize).clamp(0, batch.length).toInt(); + final subConfirmed = await reportBatch(batch.sublist(sub, subEnd)); + if (subConfirmed == null) { + failedBatchCount++; + allSucceeded = false; + } else { + confirmedCount += subConfirmed.length; + } + onProgress?.call(confirmedCount, items.length, '正在上报质量结果'); + } + } else { + failedBatchCount++; + allSucceeded = false; + } + onProgress?.call(confirmedCount, items.length, '正在上报质量结果'); + } logger.i( '|$_tag|质量上报完成|total:${items.length}|confirmed:$confirmedCount|reported:$alreadyReportedCount|invalid:$invalidCount|failedBatch:$failedBatchCount|', ); @@ -1344,12 +1386,17 @@ class _FaceSyncAccumulator { /// 有限保存的脱敏失败样本。 final List maskedFailedSampleIds = []; - /// 累加一个失败样本并按上限保存脱敏编号。 - void fail(String sampleId) { + /// 失败原因到数量的归类统计,按出现顺序保留。 + final Map failureReasons = {}; + + /// 累加一个失败样本并按上限保存脱敏编号,同时归类失败原因。 + void fail(String sampleId, [String? reason]) { failed++; if (maskedFailedSampleIds.length < SyncFaceTool._maxFailureSamples) { maskedFailedSampleIds.add(SyncFaceTool._maskSampleId(sampleId)); } + final key = reason == null || reason.isEmpty ? '未知原因' : reason; + failureReasons[key] = (failureReasons[key] ?? 0) + 1; } /// 生成可公开给任务日志和页面的不可变汇总。 @@ -1362,6 +1409,7 @@ class _FaceSyncAccumulator { skipped: skipped, failed: failed, maskedFailedSampleIds: List.unmodifiable(maskedFailedSampleIds), + failureReasons: Map.unmodifiable(failureReasons), qualityReportNote: qualityReportNote, ); } diff --git a/lib/pages/setting/face_manage_controller.dart b/lib/pages/setting/face_manage_controller.dart index 6b10a6a..8c72920 100644 --- a/lib/pages/setting/face_manage_controller.dart +++ b/lib/pages/setting/face_manage_controller.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'package:common_utils/common_utils.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/setting/widgets/face_sync_result_dialog.dart'; /// 人脸管理内容区的两个一级视图。 enum FaceManageTab { @@ -67,9 +69,15 @@ class FaceManageController extends GetxController { final isAuditing = false.obs; final isDeleting = false.obs; + /// 虹软内存库整库重注册进行中,占用维护区防止并发操作。 + final isRegistering = false.obs; + /// 清单分页遮罩是否已绘制;该阶段只画一次横条动画,避免分页回调高频重建闪屏。 bool _faceSyncListingPainted = false; + /// 当前遮罩文案;相同文案跳过重绘,避免节流窗口内高频刷新。 + String? _lastOverlayStatus; + /// 姓名搜索输入控制器,由本控制器负责释放。 final usernameC = TextEditingController(); @@ -203,19 +211,24 @@ class FaceManageController extends GetxController { overlayVisible = true; _showFaceSyncProgress(null); var lastPaintAt = DateTime.now(); + String? lastNote; progressWorker = ever( scheduler.departmentFaceSyncProgress, (progress) { if (progress == null) return; final finished = progress.total != null && progress.processed >= progress.total!; - // 跳过等记录处理极快,逐条重绘 loading 会掉帧,按 100 毫秒节流收敛。 + // 阶段文案切换必须立即上屏,否则收尾阶段的提示会被节流吞掉, + // 看起来像进度停在原地;同阶段内的数值更新仍按 100 毫秒节流收敛。 + final noteChanged = progress.note != lastNote; if (!finished && + !noteChanged && DateTime.now().difference(lastPaintAt) < const Duration(milliseconds: 100)) { return; } lastPaintAt = DateTime.now(); + lastNote = progress.note; _showFaceSyncProgress(progress); }, ); @@ -227,13 +240,27 @@ class FaceManageController extends GetxController { Toast.success(msg: '人脸同步已排队,将在业务空闲后执行'); return; } + // 同步主体已结束,提前释放互斥状态;否则弹框后的"重新注册人脸"会被 + // operationInProgress 的 isSyncing 分支静默拦截,看起来像点了没反应。 + isSyncing.value = false; currentPage.value = 1; await getFaceList(); - final record = scheduler.records[ScheduledTaskType.departmentFaceSync]; - if (record?.success == true) { - Toast.success(msg: record?.message ?? '人脸同步完成'); + // 结构化结果走统计弹框;任务异常导致无汇总时回退到任务记录文案。 + final summary = scheduler.departmentFaceSyncSummary.value; + final context = Get.context; + if (summary != null && context != null && context.mounted) { + // 先撤进度遮罩再弹结果:EasyLoading 的黑色蒙层盖在弹框之上时, + // 用户无法点按"知道了",await 永不完成、finally 的 dismiss 也到不了。 + if (overlayVisible) EasyLoading.dismiss(); + final rebuild = await showFaceSyncResultDialog(context, summary); + if (rebuild) await rebuildFaceLibraryAfterSync(); } else { - Toast.error(msg: record?.message ?? '人脸同步失败,请稍后重试'); + final record = scheduler.records[ScheduledTaskType.departmentFaceSync]; + if (record?.success == true) { + Toast.success(msg: record?.message ?? '人脸同步完成'); + } else { + Toast.error(msg: record?.message ?? '人脸同步失败,请稍后重试'); + } } } catch (e, stack) { Toast.error(msg: '同步人脸失败,请稍后重试'); @@ -246,23 +273,50 @@ class FaceManageController extends GetxController { } } - /// 按最新进度刷新同步遮罩:清单分页阶段用不定进度横条加静态文案,比对阶段切为百分比进度条。 + /// 按最新进度刷新同步遮罩,阶段优先级:收尾文案 > 清单不定态横条 > 百分比进度条。 /// - /// 清单阶段服务端游标分页不提供总数,累计条数会随每页到达高频跳变导致闪屏; - /// 因此进入该阶段只绘制一次横条动画并保持文案不变,直到拿到比对总数。 + /// 清单阶段服务端游标分页不提供总数,累计条数会随每页到达高频跳变导致闪屏, + /// 因此进入该阶段只绘制一次横条动画并保持文案不变,直到拿到比对总数;比对 + /// 结束后的质量上报与目录清理阶段以 [FaceSyncProgress.note] 文案驱动,避免 + /// 进度条停满后长时间无反馈被误认为卡死。 void _showFaceSyncProgress(FaceSyncProgress? progress) { if (progress == null) { _faceSyncListingPainted = false; + _lastOverlayStatus = '同步中...'; EasyLoading.show( status: '同步中...', maskType: EasyLoadingMaskType.black, ); return; } + final note = progress.note; + if (note != null) { + _faceSyncListingPainted = false; + final ratio = progress.ratio; + if (ratio != null) { + final status = '$note ${progress.processed}/${progress.total}'; + if (_lastOverlayStatus == status) return; + _lastOverlayStatus = status; + EasyLoading.showProgress( + ratio, + status: status, + maskType: EasyLoadingMaskType.black, + ); + } else { + if (_lastOverlayStatus == note) return; + _lastOverlayStatus = note; + EasyLoading.show( + status: note, + maskType: EasyLoadingMaskType.black, + ); + } + return; + } final ratio = progress.ratio; if (ratio == null) { if (!_faceSyncListingPainted) { _faceSyncListingPainted = true; + _lastOverlayStatus = '正在获取人脸清单...'; EasyLoading.show( status: '正在获取人脸清单...', indicator: const _FaceSyncMarqueeBar(), @@ -272,9 +326,12 @@ class FaceManageController extends GetxController { return; } _faceSyncListingPainted = false; + final status = '人脸同步中 ${progress.processed}/${progress.total}'; + if (_lastOverlayStatus == status) return; + _lastOverlayStatus = status; EasyLoading.showProgress( ratio, - status: '人脸同步中 ${progress.processed}/${progress.total}', + status: status, maskType: EasyLoadingMaskType.black, ); } @@ -508,15 +565,84 @@ class FaceManageController extends GetxController { } } - /// 同步、检测和删除共同占用人脸维护区,防止并发操作造成状态交叉。 + /// 同步、检测、删除和重注册共同占用人脸维护区,防止并发操作造成状态交叉。 bool get operationInProgress => isSyncing.value || isAuditing.value || isDeleting.value || + isRegistering.value || SyncCheckTool.instance.runningTasks.contains( ScheduledTaskType.departmentFaceSync, ); + /// 清空虹软内存库并从本地库整册重新装载,进度语义对齐启动 Load 页。 + /// + /// 同步或换图后特征可能已按样本增量写入;整库重注册以本地库为准重建内存 + /// 库,可消除任何增量路径遗留的不一致。注册期间原生互斥会拒绝识别会话。 + Future rebuildFaceLibraryAfterSync() async { + if (operationInProgress) return; + if (AppRuntimeTool.useManualHardwareInput) { + Toast.error(msg: '手动硬件输入模式无需注册人脸库'); + return; + } + isRegistering.value = true; + SyncIdleTool.instance.beginHeavyTask(); + StreamSubscription? progressListen; + var lastPaintAt = DateTime.now(); + try { + EasyLoading.show( + status: '正在准备人脸库...', + maskType: EasyLoadingMaskType.black, + ); + progressListen = FaceDiscernPlugs.registrationStream.listen( + (event) { + if (!event.inProgress) return; + // 逐样本事件极快,与同步进度同款 100 毫秒节流。 + if (DateTime.now().difference(lastPaintAt) < + const Duration(milliseconds: 100)) { + return; + } + lastPaintAt = DateTime.now(); + if (event.total > 0) { + EasyLoading.showProgress( + event.fraction, + status: '正在注册人脸库 ${event.processed}/${event.total}', + maskType: EasyLoadingMaskType.black, + ); + } else { + EasyLoading.show( + status: event.message, + maskType: EasyLoadingMaskType.black, + ); + } + }, + onError: (Object error, StackTrace stack) { + logger.w('|人脸管理|注册进度监听失败error:$error|stack:$stack|'); + }, + ); + final result = await FaceDiscernPlugs.rebuildFaceLibrary(); + if (result.failedCount > 0) { + Toast.error(msg: result.message); + } else { + Toast.success(msg: result.message); + } + } on PlatformException catch (error, stack) { + final message = error.code == 'REGISTRATION_IN_PROGRESS' + ? '人脸库正在注册,请稍候' + : '人脸库注册失败,请稍后重试'; + Toast.error(msg: message); + logger.e('|人脸管理|重建人脸库失败error:$error|stack:$stack|'); + } catch (e, stack) { + Toast.error(msg: '人脸库注册失败,请稍后重试'); + logger.e('|人脸管理|重建人脸库失败error:$e|stack:$stack|'); + } finally { + await progressListen?.cancel(); + SyncIdleTool.instance.endHeavyTask(); + isRegistering.value = false; + EasyLoading.dismiss(); + } + } + /// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。 Future delAllFace() async { final isSure = await appMessageDialog( diff --git a/lib/pages/setting/widgets/face_sync_result_dialog.dart b/lib/pages/setting/widgets/face_sync_result_dialog.dart new file mode 100644 index 0000000..5569c34 --- /dev/null +++ b/lib/pages/setting/widgets/face_sync_result_dialog.dart @@ -0,0 +1,287 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 弹出一次人脸同步的结构化结果统计。 +/// +/// 数量统计走指标网格,失败明细按原因归类并附脱敏样本编号;确认按钮提供 +/// "重新注册人脸"(清空虹软内存库并整库重装载,适合同步后立即刷新识别库) +/// 与"知道了"两个出口。返回 true 表示用户选择立即重新注册。 +Future showFaceSyncResultDialog( + BuildContext context, + FaceSyncSummary summary, +) { + final theme = FTheme.of(context); + final hasFailures = summary.hasFailures; + return showAdaptiveDialog( + context: context, + builder: (dialogContext) => FDialog( + direction: Axis.vertical, + title: [ + FIcon( + hasFailures ? FAssets.icons.circleAlert : FAssets.icons.circleCheck, + color: hasFailures ? XfjDesignTokens.danger : XfjDesignTokens.success, + size: theme.typography.xl2.fontSize, + ).paddingOnly(top: 5.h), + Text( + hasFailures ? '同步完成(部分失败)' : '同步完成', + style: theme.typography.xl, + ).paddingOnly(left: 10.w), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center), + body: _SyncResultBody(summary: summary) + .width(dialogContext.width * 0.55) + .padding(vertical: 30.w), + actions: [ + [ + FButton( + // primary(zinc-900) 深底上文字必须用浅色,沿用 appMessageDialog 确定按钮的 secondary 先例。 + label: Text( + '重新注册人脸', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => Navigator.of(dialogContext).pop(true), + ).flexible(flex: 1), + SizedBox(width: 10.w), + FButton( + style: FButtonStyle.outline, + label: Text('知道了', style: theme.typography.base), + onPress: () => Navigator.of(dialogContext).pop(false), + ).flexible(flex: 1), + ].toRow(), + ], + ), + ).then((value) => value ?? false); +} + +/// 结果主体:指标网格 + 失败明细 + 收尾提示。 +class _SyncResultBody extends StatelessWidget { + const _SyncResultBody({required this.summary}); + + final FaceSyncSummary summary; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildMetricGrid(theme), + if (summary.hasFailures) ...[ + SizedBox(height: 14.h), + _buildFailureDetail(theme), + ], + if (summary.qualityReportNote != null) ...[ + SizedBox(height: 14.h), + _buildQualityNote(theme), + ], + ], + ); + } + + /// 六项核心指标的 3×2 网格;每格标签在左、数值在右,失败数大于零时数值转危险色。 + /// + /// FDialog 会对内容做固有尺寸测量,GridView 一类 viewport 不支持该测量 + /// (实测抛 RenderShrinkWrappingViewport 异常导致只剩黑色遮罩), + /// 因此用普通 Row + Expanded 手工排布两行三列。 + Widget _buildMetricGrid(FThemeData theme) { + final metrics = <({String label, String value, Color color})>[ + ( + label: '扫描', + value: '${summary.scanned}', + color: XfjDesignTokens.primary, + ), + ( + label: '新增', + value: '${summary.added}', + color: XfjDesignTokens.primary, + ), + ( + label: '更新', + value: '${summary.updated}', + color: XfjDesignTokens.primary, + ), + ( + label: '删除', + value: '${summary.deleted}', + color: XfjDesignTokens.primary, + ), + ( + label: '无变化', + value: '${summary.skipped}', + color: XfjDesignTokens.primary, + ), + ( + label: '失败', + value: '${summary.failed}', + color: summary.failed > 0 + ? XfjDesignTokens.danger + : XfjDesignTokens.primary, + ), + ]; + Widget metricRow(int start, int end) => Row( + children: [ + for (var index = start; index < end; index++) ...[ + if (index > start) SizedBox(width: 10.w), + Expanded( + child: _SyncMetricTile( + label: metrics[index].label, + value: metrics[index].value, + color: metrics[index].color, + ), + ), + ], + ], + ); + return Column( + children: [ + metricRow(0, 3), + SizedBox(height: 10.w), + metricRow(3, 6), + ], + ); + } + + /// 失败原因归类与脱敏样本编号;编号仅用于现场排查,不含姓名与图片地址。 + Widget _buildFailureDetail(FThemeData theme) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), + decoration: BoxDecoration( + color: XfjDesignTokens.danger.withValues(alpha: 0.05), + border: Border.all(color: XfjDesignTokens.danger.withValues(alpha: 0.24)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + [ + FIcon( + FAssets.icons.triangleAlert, + color: XfjDesignTokens.danger, + size: theme.typography.base.fontSize, + ), + SizedBox(width: 8.w), + Text( + '失败明细(下次同步自动重试)', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.danger, + fontWeight: FontWeight.w600, + ), + ), + ].toRow(), + SizedBox(height: 8.h), + for (final entry in summary.failureReasons.entries) + Text( + '${entry.key} ×${entry.value}', + style: theme.typography.base, + ).paddingOnly(bottom: 4.h), + if (summary.maskedFailedSampleIds.isNotEmpty) ...[ + SizedBox(height: 4.h), + Wrap( + spacing: 6.w, + runSpacing: 6.h, + children: [ + for (final maskedId in summary.maskedFailedSampleIds) + Container( + padding: EdgeInsets.symmetric( + horizontal: 8.w, + vertical: 3.h, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + maskedId, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } + + /// 质量上报未完成的收尾提示条。 + Widget _buildQualityNote(FThemeData theme) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), + decoration: BoxDecoration( + color: XfjDesignTokens.warning.withValues(alpha: 0.05), + border: Border.all( + color: XfjDesignTokens.warning.withValues(alpha: 0.24), + ), + borderRadius: BorderRadius.circular(10), + ), + child: [ + FIcon( + FAssets.icons.fileWarning, + color: XfjDesignTokens.warning, + size: theme.typography.base.fontSize, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + summary.qualityReportNote!, + style: theme.typography.base, + ), + ), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center), + ); + } +} + +/// 单个统计指标格:标签在左、数值在右的水平行,沿用设置页摘要卡的表面、 +/// 描边与圆角;数值用语义色,失败数大于零时由调用方传入危险色。 +class _SyncMetricTile extends StatelessWidget { + const _SyncMetricTile({ + required this.label, + required this.value, + required this.color, + }); + + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + height: 64.h, + padding: EdgeInsets.symmetric(horizontal: 14.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Text( + label, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + const Spacer(), + Text( + value, + style: theme.typography.xl.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +}