1181 lines
41 KiB
Dart
1181 lines
41 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:common_utils/common_utils.dart';
|
||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||
import 'package:forui/forui.dart';
|
||
import 'package:get/get.dart';
|
||
|
||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||
|
||
import 'setting_components.dart';
|
||
|
||
/// 人脸管理内容区的两个一级视图。
|
||
enum FaceManageTab {
|
||
/// 全部本地人脸及既有同步维护能力。
|
||
all,
|
||
|
||
/// 已检测且分数低于当前在库阈值的人脸。
|
||
lowQuality,
|
||
}
|
||
|
||
/// 设置页人脸管理控制器,只负责列表交互和将人工同步请求提交到应用级队列。
|
||
class FaceManageController extends GetxController {
|
||
/// 应用级数据库和事件总线持有者,由根 Binding 统一注册。
|
||
final appC = Get.find<AppControl>();
|
||
|
||
/// 当前终端设置,初始化后用于确定系统类型和设备绑定状态。
|
||
late SysSetting sysSetting;
|
||
|
||
/// 当前终端业务类型,决定搜索框允许的输入方式。
|
||
Rx<SysType> sysType = SysType.CANTEEN.obs;
|
||
|
||
/// 当前分页展示的人脸本地记录。
|
||
RxList<FaceEntity> faceList = RxList();
|
||
|
||
/// 当前列表页码,从 1 开始。
|
||
RxInt currentPage = RxInt(1);
|
||
|
||
/// 当前搜索条件命中的记录总数,供分页组件计算页数。
|
||
RxInt totalPage = RxInt(0);
|
||
|
||
/// 单页展示数量,沿用现有人脸管理网格密度。
|
||
RxInt pageSize = RxInt(15);
|
||
|
||
/// 列表查询是否进行中,仅驱动列表区域加载状态。
|
||
RxBool isLoading = false.obs;
|
||
|
||
/// 最近一次列表查询失败的安全提示,不包含数据库或文件路径。
|
||
RxString errorMessage = ''.obs;
|
||
|
||
/// 质量检测或服务端删除的可恢复提示,不替代列表内容。
|
||
final qualityOperationMessage = ''.obs;
|
||
|
||
/// 当前选中的内容 Tab。
|
||
final selectedTab = FaceManageTab.all.obs;
|
||
|
||
/// Android 人脸设置中的在库质量阈值。
|
||
final libraryQualityThreshold = 0.60.obs;
|
||
|
||
/// 低质量、待检测和检测失败的实时统计。
|
||
final lowQualityCount = 0.obs;
|
||
final pendingQualityCount = 0.obs;
|
||
final failedQualityCount = 0.obs;
|
||
|
||
/// 存量质量检测的最新原生进度。
|
||
final qualityAuditProgress = Rxn<FaceQualityAuditProgress>();
|
||
|
||
/// 同步、检测和服务端删除状态,用于互斥及按钮禁用。
|
||
final isSyncing = false.obs;
|
||
final isAuditing = false.obs;
|
||
final isDeleting = false.obs;
|
||
|
||
/// 姓名搜索输入控制器,由本控制器负责释放。
|
||
final usernameC = TextEditingController();
|
||
|
||
/// FACE 增量任务成功后的列表刷新订阅,由 [onClose] 取消。
|
||
StreamSubscription<MqtActionEnum>? mqUpdateListen;
|
||
|
||
/// 原生质量检测进度订阅,由 [onClose] 释放。
|
||
StreamSubscription<FaceQualityAuditProgress>? qualityAuditListen;
|
||
|
||
/// 启动异步设置读取、首次列表查询和 FACE 完成事件监听。
|
||
@override
|
||
void onInit() {
|
||
super.onInit();
|
||
unawaited(_initialize());
|
||
}
|
||
|
||
/// 读取终端设置后加载列表;初始化失败交由列表状态统一展示。
|
||
Future<void> _initialize() async {
|
||
sysSetting = await AppSettingTool.getSysSetting();
|
||
sysType.value = sysSetting.sysType;
|
||
_listenMqCallback();
|
||
qualityAuditListen = FaceDiscernPlugs.qualityAuditStream.listen(
|
||
_handleQualityAuditProgress,
|
||
onError: (Object error, StackTrace stack) {
|
||
logger.w('|人脸管理|质量进度监听失败error:$error|stack:$stack|');
|
||
},
|
||
);
|
||
await refreshQualityThreshold();
|
||
await getFaceList();
|
||
}
|
||
|
||
/// 释放 MQTT 完成事件和输入控制器,防止退出设置页后继续刷新。
|
||
@override
|
||
void onClose() {
|
||
mqUpdateListen?.cancel();
|
||
qualityAuditListen?.cancel();
|
||
usernameC.dispose();
|
||
super.onClose();
|
||
}
|
||
|
||
/// 按当前姓名和页码读取本地人脸列表,可选显示旧页面使用的查询遮罩。
|
||
Future<void> getFaceList({bool isLoading = false}) async {
|
||
this.isLoading.value = true;
|
||
errorMessage.value = '';
|
||
if (isLoading) {
|
||
EasyLoading.show(
|
||
status: '查询中...',
|
||
maskType: EasyLoadingMaskType.black,
|
||
);
|
||
}
|
||
try {
|
||
final faceDao = appC.dataBase.getFaceDao;
|
||
String userName = usernameC.text;
|
||
if (ObjectUtil.isEmpty(userName)) userName = "-1";
|
||
final lowQuality = selectedTab.value == FaceManageTab.lowQuality;
|
||
final findCount = lowQuality
|
||
? await faceDao.findLowQualityCount(
|
||
userName,
|
||
libraryQualityThreshold.value,
|
||
)
|
||
: await faceDao.findCount(userName);
|
||
totalPage.value = findCount ?? 0;
|
||
final list = lowQuality
|
||
? await faceDao.findLowQuality(
|
||
userName,
|
||
libraryQualityThreshold.value,
|
||
pageSize.value,
|
||
(currentPage.value - 1) * pageSize.value,
|
||
)
|
||
: await faceDao.findAll(
|
||
userName,
|
||
pageSize.value,
|
||
(currentPage.value - 1) * pageSize.value,
|
||
);
|
||
faceList.value = list;
|
||
await _refreshQualityCounts(faceDao);
|
||
} catch (e, stack) {
|
||
errorMessage.value = '人脸数据加载失败,请稍后重试';
|
||
logger.e('|人脸管理|加载失败error:|$e|stack:|$stack|');
|
||
} finally {
|
||
this.isLoading.value = false;
|
||
if (isLoading) {
|
||
Future.delayed(const Duration(seconds: 1), () {
|
||
EasyLoading.dismiss();
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 确认后将本部门快照或非生产全租户调试请求提交到统一人脸任务队列。
|
||
///
|
||
/// 队列会先避开支付、人脸会话和业务弹框;只有任务已经实际开始时才显示遮罩,
|
||
/// 避免遮罩本身被空闲检测识别为业务占用而导致任务无法启动。
|
||
Future<void> asyncFace({bool isAll = false}) async {
|
||
if (operationInProgress) return;
|
||
final isNetwork = AppNetworkTool.isAutoNetworkMsg();
|
||
if (!isNetwork) return;
|
||
final setting = await AppSettingTool.getSysSetting();
|
||
if (ObjectUtil.isEmpty(setting.merchantDeptId)) {
|
||
Toast.error(msg: "请先绑定设备");
|
||
return;
|
||
}
|
||
if (isAll && AppSysTool.isProd()) {
|
||
Toast.error(msg: '生产环境仅允许同步本部门人脸');
|
||
return;
|
||
}
|
||
if (AppSysTool.isProd()) {
|
||
final isActive = await AppFaceTool.isActiveSdk();
|
||
if (!isActive) {
|
||
Toast.error(msg: "人脸sdk激活失败");
|
||
return;
|
||
}
|
||
}
|
||
final isSure = await appMessageDialog(
|
||
Get.context!,
|
||
isAll ? '是否同步全租户人脸?此能力仅用于非生产调试,过程可能较长' : '是否同步本部门及子部门人脸?过程可能较长',
|
||
);
|
||
if (!isSure) return;
|
||
final scheduler = SyncCheckTool.instance;
|
||
isSyncing.value = true;
|
||
final operation = scheduler.trigger(
|
||
ScheduledTaskType.departmentFaceSync,
|
||
debugAllFaces: isAll,
|
||
triggerType: ScheduledTaskTriggerType.manual,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
var overlayVisible = false;
|
||
if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync)) {
|
||
overlayVisible = true;
|
||
EasyLoading.show(
|
||
status: '同步中...',
|
||
maskType: EasyLoadingMaskType.black,
|
||
);
|
||
}
|
||
try {
|
||
await operation;
|
||
if (scheduler.pendingTasks
|
||
.contains(ScheduledTaskType.departmentFaceSync)) {
|
||
Toast.success(msg: '人脸同步已排队,将在业务空闲后执行');
|
||
return;
|
||
}
|
||
currentPage.value = 1;
|
||
await getFaceList();
|
||
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: '同步人脸失败,请稍后重试');
|
||
logger.e('|人脸管理|同步失败error:|$e|stack:|$stack|');
|
||
} finally {
|
||
currentPage.value = 1;
|
||
isSyncing.value = false;
|
||
if (overlayVisible) EasyLoading.dismiss();
|
||
}
|
||
}
|
||
|
||
/// 重新读取 Android 人脸配置并按新阈值立即重查低质量分类。
|
||
Future<void> refreshQualityThreshold() async {
|
||
try {
|
||
final configuration = await FaceDiscernPlugs.getRecognizeConfiguration();
|
||
libraryQualityThreshold.value =
|
||
configuration.libraryFaceQualityThreshold.clamp(0.0, 1.0).toDouble();
|
||
} catch (error, stack) {
|
||
logger.w('|人脸管理|读取在库质量阈值失败error:$error|stack:$stack|');
|
||
}
|
||
}
|
||
|
||
/// 切换全部或低质量视图,保留搜索词但从第一页按对应查询重新加载。
|
||
Future<void> switchTab(FaceManageTab tab) async {
|
||
if (selectedTab.value == tab) return;
|
||
selectedTab.value = tab;
|
||
currentPage.value = 1;
|
||
if (tab == FaceManageTab.lowQuality) {
|
||
await refreshQualityThreshold();
|
||
}
|
||
await getFaceList();
|
||
}
|
||
|
||
/// 手动检测待检测及失败记录,检测失败不会移除人脸或特征。
|
||
Future<void> auditLibraryFaces({bool failedOnly = false}) async {
|
||
if (operationInProgress) return;
|
||
if (failedOnly && failedQualityCount.value == 0) return;
|
||
if (!failedOnly &&
|
||
pendingQualityCount.value == 0 &&
|
||
failedQualityCount.value == 0) {
|
||
Toast.success(msg: '没有需要检测的人脸');
|
||
return;
|
||
}
|
||
isAuditing.value = true;
|
||
qualityOperationMessage.value = '';
|
||
try {
|
||
final result = await SyncFaceTool.auditLibraryFaceQuality(
|
||
failedOnly: failedOnly,
|
||
);
|
||
final success = result.results
|
||
.where((item) => item.status == FaceQualityStatus.detected)
|
||
.length;
|
||
final failed = result.results.length - success;
|
||
if (failed > 0) {
|
||
qualityOperationMessage.value =
|
||
'检测完成:成功 $success 条,失败 $failed 条,可点击失败重试';
|
||
Toast.error(msg: qualityOperationMessage.value);
|
||
} else {
|
||
Toast.success(msg: '质量检测完成,共 ${result.results.length} 条');
|
||
}
|
||
} catch (error, stack) {
|
||
qualityOperationMessage.value = '在库人脸质量检测失败,请稍后重试';
|
||
logger.e('|人脸管理|质量检测失败error:$error|stack:$stack|');
|
||
Toast.error(msg: qualityOperationMessage.value);
|
||
} finally {
|
||
isAuditing.value = false;
|
||
currentPage.value = 1;
|
||
await getFaceList();
|
||
}
|
||
}
|
||
|
||
/// 确认后删除一条低质量人脸,服务端成功或已不存在后才清理本地。
|
||
Future<void> deleteLowQualityFace(FaceEntity face) async {
|
||
if (operationInProgress) return;
|
||
final confirmed = await appMessageDialog(
|
||
Get.context!,
|
||
'是否删除“${face.userName}”的该低质量人脸?删除后将不再参与识别。',
|
||
);
|
||
if (!confirmed) return;
|
||
await _deleteServerBackedFaces([face]);
|
||
}
|
||
|
||
/// 删除当前阈值下全部明确低质量的人脸,不受搜索词和分页影响。
|
||
Future<void> deleteAllLowQualityFaces() async {
|
||
if (operationInProgress || lowQualityCount.value == 0) return;
|
||
final faces = await appC.dataBase.getFaceDao.findAllLowQuality(
|
||
libraryQualityThreshold.value,
|
||
);
|
||
if (faces.isEmpty) {
|
||
await getFaceList();
|
||
return;
|
||
}
|
||
final confirmed = await appMessageDialog(
|
||
Get.context!,
|
||
'是否删除全部 ${faces.length} 条低质量人脸?该操作会先删除服务端数据,且不可撤销。',
|
||
);
|
||
if (!confirmed) return;
|
||
await _deleteServerBackedFaces(faces);
|
||
}
|
||
|
||
/// 按服务端 500 条上限分批删除,并仅清理服务端已确认的本地样本。
|
||
Future<void> _deleteServerBackedFaces(List<FaceEntity> faces) async {
|
||
isDeleting.value = true;
|
||
qualityOperationMessage.value = '';
|
||
var localDeleted = 0;
|
||
var localFailed = 0;
|
||
try {
|
||
for (var start = 0; start < faces.length; start += 500) {
|
||
final end = (start + 500).clamp(0, faces.length).toInt();
|
||
final batch = faces.sublist(start, end);
|
||
final response = await UserApi.deleteTerminalFaces(
|
||
batch.map((face) => face.sampleId).toList(growable: false),
|
||
);
|
||
final confirmed = response.confirmedSampleIds;
|
||
for (final face in batch) {
|
||
if (!confirmed.contains(face.sampleId)) {
|
||
throw StateError('服务端未确认样本删除结果');
|
||
}
|
||
if (await SyncFaceTool.deleteLocalFace(face)) {
|
||
localDeleted++;
|
||
} else {
|
||
localFailed++;
|
||
}
|
||
}
|
||
}
|
||
if (localFailed > 0) {
|
||
qualityOperationMessage.value =
|
||
'服务端已删除 ${localDeleted + localFailed} 条,本地清理失败 $localFailed 条,可再次删除重试';
|
||
Toast.error(msg: qualityOperationMessage.value);
|
||
} else {
|
||
Toast.success(msg: '已删除 $localDeleted 条低质量人脸');
|
||
}
|
||
} catch (error, stack) {
|
||
qualityOperationMessage.value = localDeleted > 0
|
||
? '已完成 $localDeleted 条,后续批次删除失败;未确认样本已保留在本机'
|
||
: '服务端删除失败,本机人脸已保留';
|
||
logger.e('|人脸管理|服务端删除失败error:$error|stack:$stack|');
|
||
Toast.error(msg: qualityOperationMessage.value);
|
||
} finally {
|
||
isDeleting.value = false;
|
||
currentPage.value = 1;
|
||
await getFaceList();
|
||
}
|
||
}
|
||
|
||
/// 刷新不受当前搜索条件影响的质量摘要。
|
||
Future<void> _refreshQualityCounts(FaceDao faceDao) async {
|
||
lowQualityCount.value = await faceDao.findLowQualityCount(
|
||
'-1', libraryQualityThreshold.value) ??
|
||
0;
|
||
pendingQualityCount.value = await faceDao.findPendingQualityCount() ?? 0;
|
||
failedQualityCount.value = await faceDao.findFailedQualityCount() ?? 0;
|
||
}
|
||
|
||
/// 将原生进度同步到页面;完成事件不负责数据库刷新,最终结果返回后统一收敛。
|
||
void _handleQualityAuditProgress(FaceQualityAuditProgress value) {
|
||
qualityAuditProgress.value = value;
|
||
if (value.inProgress || value.processed > 0) {
|
||
isAuditing.value = value.inProgress;
|
||
}
|
||
}
|
||
|
||
/// 同步、检测和删除共同占用人脸维护区,防止并发操作造成状态交叉。
|
||
bool get operationInProgress =>
|
||
isSyncing.value ||
|
||
isAuditing.value ||
|
||
isDeleting.value ||
|
||
SyncCheckTool.instance.runningTasks.contains(
|
||
ScheduledTaskType.departmentFaceSync,
|
||
);
|
||
|
||
/// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。
|
||
Future<void> delAllFace() async {
|
||
final isSure = await appMessageDialog(
|
||
Get.context!,
|
||
"是否删除全部人脸",
|
||
);
|
||
if (!isSure) return;
|
||
EasyLoading.show(
|
||
status: '删除中...',
|
||
maskType: EasyLoadingMaskType.black,
|
||
);
|
||
try {
|
||
final faceDao = appC.dataBase.getFaceDao;
|
||
await faceDao.delAll();
|
||
await AppFaceTool.removeFace(sampleId: '-1');
|
||
await getFaceList();
|
||
Toast.success(msg: '删除成功');
|
||
} catch (e, stack) {
|
||
Toast.error(msg: '删除全部人脸失败');
|
||
logger.e('|人脸管理|删除全部失败error:|$e|stack:|$stack|');
|
||
} finally {
|
||
EasyLoading.dismiss();
|
||
}
|
||
}
|
||
|
||
/// 监听成功完成的 FACE 增量同步,并仅刷新当前设置页本地列表。
|
||
void _listenMqCallback() {
|
||
mqUpdateListen =
|
||
appC.mqUpdateEventBus.on<MqtActionEnum>().listen((event) async {
|
||
switch (event) {
|
||
case MqtActionEnum.FACE:
|
||
await getFaceList(isLoading: true);
|
||
break;
|
||
default:
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 按指定页码执行姓名搜索,并在查询前收起软键盘。
|
||
Future<void> doPage({int? page}) async {
|
||
if (page != null) currentPage.value = page;
|
||
// 收起键盘
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
await getFaceList(isLoading: true);
|
||
}
|
||
|
||
/// 清空姓名条件、回到第一页并重新加载列表。
|
||
Future<void> doReset() async {
|
||
currentPage.value = 1;
|
||
usernameC.clear();
|
||
// 收起键盘
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
await getFaceList(isLoading: true);
|
||
}
|
||
|
||
/// 删除一条本地人脸及其虹软内存特征,不修改服务端人脸真源。
|
||
Future<void> deleteFace(FaceEntity v) async {
|
||
final isSure = await appMessageDialog(Get.context!, "是否删除人脸");
|
||
if (!isSure) return;
|
||
EasyLoading.show(
|
||
status: '删除中...',
|
||
maskType: EasyLoadingMaskType.black,
|
||
);
|
||
try {
|
||
final deleted = await SyncFaceTool.deleteLocalFace(v);
|
||
if (!deleted) throw StateError('本地人脸删除失败并已尝试恢复');
|
||
await getFaceList();
|
||
Toast.success(msg: '删除成功');
|
||
} catch (e, stack) {
|
||
Toast.error(msg: '删除人脸失败');
|
||
logger.e('|人脸管理|删除失败error:|$e|stack:|$stack|');
|
||
} finally {
|
||
EasyLoading.dismiss();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 构建紧凑的人脸视图切换栏,并在低质量页同行展示质量摘要。
|
||
Widget _buildFaceHeader(BuildContext context) {
|
||
final controller = Get.find<FaceManageController>();
|
||
return Obx(
|
||
() {
|
||
final lowQuality =
|
||
controller.selectedTab.value == FaceManageTab.lowQuality;
|
||
return Row(
|
||
children: [
|
||
FaceManageTabBar(
|
||
selectedTab: controller.selectedTab.value,
|
||
onChanged: controller.switchTab,
|
||
),
|
||
if (lowQuality) ...[
|
||
SizedBox(width: 12.w),
|
||
SizedBox(
|
||
width: 640.w,
|
||
child: _buildQualitySummary(controller),
|
||
),
|
||
SizedBox(width: 12.w),
|
||
Expanded(child: _buildQualityDiagnosticHint(context)),
|
||
],
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 人脸管理内容区的可测试文字 Tab,仅负责选中展示和点击回调。
|
||
class FaceManageTabBar extends StatelessWidget {
|
||
/// 创建一组固定高度且满足大屏触控尺寸的内容 Tab。
|
||
const FaceManageTabBar({
|
||
super.key,
|
||
required this.selectedTab,
|
||
required this.onChanged,
|
||
});
|
||
|
||
/// 当前选中的业务视图。
|
||
final FaceManageTab selectedTab;
|
||
|
||
/// 用户点击不同 Tab 时的回调;异步列表刷新由控制器负责。
|
||
final ValueChanged<FaceManageTab> onChanged;
|
||
|
||
/// 按当前 ForUI 主题和终端设计令牌构建选中态、按压态及无障碍语义。
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = FTheme.of(context);
|
||
return SizedBox(
|
||
height: 48,
|
||
width: 320.w,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
Container(
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: XfjDesignTokens.control,
|
||
borderRadius:
|
||
BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||
),
|
||
),
|
||
Row(
|
||
children: FaceManageTab.values.map((tab) {
|
||
final selected = selectedTab == tab;
|
||
final label = tab == FaceManageTab.all ? '人脸管理' : '低质量人脸';
|
||
return Expanded(
|
||
child: Semantics(
|
||
button: true,
|
||
selected: selected,
|
||
excludeSemantics: true,
|
||
label: '$label Tab${selected ? ',已选中' : ''}',
|
||
child: InkWell(
|
||
onTap: () => onChanged(tab),
|
||
borderRadius: BorderRadius.circular(
|
||
XfjDesignTokens.controlRadius,
|
||
),
|
||
child: Center(
|
||
child: Container(
|
||
height: 36,
|
||
width: double.infinity,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: selected
|
||
? XfjDesignTokens.surface
|
||
: Colors.transparent,
|
||
borderRadius: BorderRadius.circular(
|
||
XfjDesignTokens.controlRadius,
|
||
),
|
||
boxShadow:
|
||
selected ? XfjDesignTokens.surfaceShadow : null,
|
||
),
|
||
child: Text(
|
||
label,
|
||
style: theme.typography.sm.copyWith(
|
||
color: selected
|
||
? XfjDesignTokens.primary
|
||
: XfjDesignTokens.muted,
|
||
fontWeight:
|
||
selected ? FontWeight.w700 : FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(growable: false),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 说明虹软质量分的能力边界,并给出可执行的照片排查方向。
|
||
Widget _buildQualityDiagnosticHint(BuildContext context) {
|
||
final theme = FTheme.of(context);
|
||
const message = '虹软仅返回综合分;请排查清晰度、光照、角度和人脸大小';
|
||
return Semantics(
|
||
container: true,
|
||
label: message,
|
||
child: Container(
|
||
height: 36,
|
||
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||
decoration: BoxDecoration(
|
||
color: XfjDesignTokens.primarySoft,
|
||
border: Border.all(
|
||
color: XfjDesignTokens.primary.withValues(alpha: 0.20),
|
||
),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
FIcon(FAssets.icons.info,
|
||
color: XfjDesignTokens.primary, size: 14.sp),
|
||
SizedBox(width: 5.w),
|
||
Expanded(
|
||
child: Text(
|
||
message,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.typography.xs.copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 构建当前系统类型适配的姓名搜索框。
|
||
Widget _buildFaceNameField(FaceManageController controller) => Obx(
|
||
() => TerminalToolbarTextField(
|
||
width: 300.w,
|
||
textInputAction: controller.sysType.value == SysType.SUPERMARKET
|
||
? TextInputAction.none
|
||
: TextInputAction.newline,
|
||
keyboardType: controller.sysType.value == SysType.SUPERMARKET
|
||
? TextInputType.none
|
||
: TextInputType.multiline,
|
||
controller: controller.usernameC,
|
||
hint: '请输入姓名',
|
||
onSubmit: (_) => controller.doPage(),
|
||
),
|
||
);
|
||
|
||
/// 构建人脸搜索与既有同步操作区;全租户同步仅在非生产调试环境出现。
|
||
Widget buildFaceSearch(BuildContext _) {
|
||
final controller = Get.find<FaceManageController>();
|
||
return Obx(() {
|
||
final busy = controller.operationInProgress;
|
||
return <Widget>[
|
||
_buildFaceNameField(controller),
|
||
TerminalToolbarButton(
|
||
label: '重置',
|
||
icon: FAssets.icons.ban,
|
||
style: TerminalToolbarButtonStyle.secondary,
|
||
onTap: busy ? null : controller.doReset,
|
||
),
|
||
TerminalToolbarButton(
|
||
label: '搜索',
|
||
icon: FAssets.icons.search,
|
||
onTap: busy ? null : controller.doPage,
|
||
),
|
||
TerminalToolbarButton(
|
||
label: controller.isSyncing.value ? '同步中...' : '同步本部门人脸',
|
||
icon: FAssets.icons.scanFace,
|
||
style: TerminalToolbarButtonStyle.secondary,
|
||
minWidth: 188.w,
|
||
onTap: busy ? null : () => controller.asyncFace(isAll: false),
|
||
),
|
||
if (!AppSysTool.isProd())
|
||
TerminalToolbarButton(
|
||
label: '同步全部人脸',
|
||
icon: FAssets.icons.scanFace,
|
||
style: TerminalToolbarButtonStyle.secondary,
|
||
minWidth: 176.w,
|
||
onTap: busy ? null : () => controller.asyncFace(isAll: true),
|
||
),
|
||
const Spacer(),
|
||
TerminalToolbarButton(
|
||
label: '删除全部人脸',
|
||
icon: FAssets.icons.trash2,
|
||
style: TerminalToolbarButtonStyle.destructive,
|
||
minWidth: 176.w,
|
||
onTap: busy ? null : controller.delAllFace,
|
||
),
|
||
].toRowSpace(space: 12.w);
|
||
});
|
||
}
|
||
|
||
/// 构建低质量页的单行只读质量摘要。
|
||
Widget _buildQualitySummary(
|
||
FaceManageController controller,
|
||
) {
|
||
return Container(
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: XfjDesignTokens.surface,
|
||
border: Border.all(color: XfjDesignTokens.line),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
_QualityMetric(
|
||
label: '当前阈值',
|
||
value: controller.libraryQualityThreshold.value.toStringAsFixed(2),
|
||
icon: FAssets.icons.slidersHorizontal,
|
||
color: XfjDesignTokens.primary,
|
||
flex: 3,
|
||
),
|
||
_QualityMetric(
|
||
label: '低质量',
|
||
value: '${controller.lowQualityCount.value}',
|
||
icon: FAssets.icons.triangleAlert,
|
||
color: XfjDesignTokens.danger,
|
||
),
|
||
_QualityMetric(
|
||
label: '待检测',
|
||
value: '${controller.pendingQualityCount.value}',
|
||
icon: FAssets.icons.image,
|
||
color: XfjDesignTokens.warning,
|
||
),
|
||
_QualityMetric(
|
||
label: '检测失败',
|
||
value: '${controller.failedQualityCount.value}',
|
||
icon: FAssets.icons.circleAlert,
|
||
color: XfjDesignTokens.danger,
|
||
showDivider: false,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 构建低质量页搜索和互斥操作区。
|
||
Widget _buildLowQualityOperations(BuildContext _) {
|
||
final controller = Get.find<FaceManageController>();
|
||
return Obx(() {
|
||
final busy = controller.operationInProgress;
|
||
final pending = controller.pendingQualityCount.value;
|
||
final failed = controller.failedQualityCount.value;
|
||
final low = controller.lowQualityCount.value;
|
||
final progress = controller.qualityAuditProgress.value;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
_buildFaceNameField(controller),
|
||
SizedBox(width: 12.w),
|
||
TerminalToolbarButton(
|
||
label: '重置',
|
||
icon: FAssets.icons.ban,
|
||
style: TerminalToolbarButtonStyle.secondary,
|
||
onTap: busy ? null : controller.doReset,
|
||
),
|
||
SizedBox(width: 12.w),
|
||
TerminalToolbarButton(
|
||
label: '搜索',
|
||
icon: FAssets.icons.search,
|
||
onTap: busy ? null : controller.doPage,
|
||
),
|
||
const Spacer(),
|
||
TerminalToolbarButton(
|
||
label: controller.isAuditing.value ? '检测中...' : '检测存量人脸',
|
||
icon: FAssets.icons.scanFace,
|
||
style: TerminalToolbarButtonStyle.secondary,
|
||
minWidth: 176.w,
|
||
onTap: busy || (pending + failed == 0)
|
||
? null
|
||
: controller.auditLibraryFaces,
|
||
),
|
||
SizedBox(width: 12.w),
|
||
TerminalToolbarButton(
|
||
label: '失败重试',
|
||
icon: FAssets.icons.refreshCw,
|
||
style: TerminalToolbarButtonStyle.secondary,
|
||
minWidth: 140.w,
|
||
onTap: busy || failed == 0
|
||
? null
|
||
: () => controller.auditLibraryFaces(failedOnly: true),
|
||
),
|
||
SizedBox(width: 12.w),
|
||
TerminalToolbarButton(
|
||
label: controller.isDeleting.value ? '删除中...' : '一键删除',
|
||
icon: FAssets.icons.trash2,
|
||
style: TerminalToolbarButtonStyle.destructive,
|
||
minWidth: 140.w,
|
||
onTap:
|
||
busy || low == 0 ? null : controller.deleteAllLowQualityFaces,
|
||
),
|
||
],
|
||
),
|
||
if (progress != null &&
|
||
(progress.inProgress || progress.total > 0)) ...[
|
||
SizedBox(height: 8.h),
|
||
_QualityAuditProgress(progress: progress),
|
||
],
|
||
if (controller.qualityOperationMessage.isNotEmpty) ...[
|
||
SizedBox(height: 8.h),
|
||
Semantics(
|
||
liveRegion: true,
|
||
child: SettingNotice(
|
||
text: controller.qualityOperationMessage.value,
|
||
dense: true,
|
||
icon: FAssets.icons.triangleAlert,
|
||
color: XfjDesignTokens.danger,
|
||
background: XfjDesignTokens.danger.withValues(alpha: 0.05),
|
||
borderColor: XfjDesignTokens.danger.withValues(alpha: 0.24),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
});
|
||
}
|
||
|
||
/// 紧凑质量摘要指标,使用文字、图标和颜色共同表达状态。
|
||
class _QualityMetric extends StatelessWidget {
|
||
const _QualityMetric({
|
||
required this.label,
|
||
required this.value,
|
||
required this.icon,
|
||
required this.color,
|
||
this.flex = 2,
|
||
this.showDivider = true,
|
||
});
|
||
|
||
final String label;
|
||
final String value;
|
||
final SvgAsset icon;
|
||
final Color color;
|
||
final int flex;
|
||
final bool showDivider;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = FTheme.of(context);
|
||
return Expanded(
|
||
flex: flex,
|
||
child: Semantics(
|
||
container: true,
|
||
label: '$label $value',
|
||
excludeSemantics: true,
|
||
child: Container(
|
||
height: 34,
|
||
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||
decoration: BoxDecoration(
|
||
border: showDivider
|
||
? const Border(
|
||
right: BorderSide(color: XfjDesignTokens.line),
|
||
)
|
||
: null,
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
FIcon(icon, color: color, size: 14.sp),
|
||
SizedBox(width: 4.w),
|
||
Flexible(
|
||
child: Text(
|
||
label,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.typography.xs.copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
),
|
||
),
|
||
),
|
||
SizedBox(width: 4.w),
|
||
Text(
|
||
value,
|
||
style: theme.typography.xs.copyWith(
|
||
color: color,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 显示存量质量检测百分比和成功、失败数量。
|
||
class _QualityAuditProgress extends StatelessWidget {
|
||
const _QualityAuditProgress({required this.progress});
|
||
|
||
final FaceQualityAuditProgress progress;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final percent = (progress.fraction * 100).round();
|
||
return Semantics(
|
||
liveRegion: !progress.inProgress,
|
||
label:
|
||
'${progress.message},$percent%,已处理${progress.processed}/${progress.total},成功${progress.successCount},失败${progress.failedCount}',
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
|
||
decoration: BoxDecoration(
|
||
color: XfjDesignTokens.primarySoft,
|
||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(99),
|
||
child: LinearProgressIndicator(
|
||
minHeight: 8.h,
|
||
value: progress.total == 0 && progress.inProgress
|
||
? null
|
||
: progress.fraction,
|
||
backgroundColor: XfjDesignTokens.control,
|
||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||
XfjDesignTokens.primary,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(width: 14.w),
|
||
Text('$percent% ${progress.processed}/${progress.total}'),
|
||
SizedBox(width: 14.w),
|
||
Text('成功 ${progress.successCount} 失败 ${progress.failedCount}'),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 低质量人脸卡片的紧凑删除按钮。
|
||
///
|
||
/// 可见按钮固定为 32dp 高,避免 ForUI 主题的全局按钮尺寸撑破卡片;外层仍保留
|
||
/// 48dp 的 Android 最小触控高度,并提供完整的删除语义和按压反馈。
|
||
class _CompactFaceDeleteButton extends StatelessWidget {
|
||
const _CompactFaceDeleteButton({
|
||
required this.face,
|
||
required this.onPressed,
|
||
});
|
||
|
||
final FaceEntity face;
|
||
final VoidCallback? onPressed;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = FTheme.of(context);
|
||
final enabled = onPressed != null;
|
||
final borderRadius = BorderRadius.circular(XfjDesignTokens.controlRadius);
|
||
return Semantics(
|
||
key: ValueKey('low-quality-delete-hit-${face.sampleId}'),
|
||
container: true,
|
||
button: true,
|
||
enabled: enabled,
|
||
label: '删除${face.userName}的低质量人脸',
|
||
onTap: onPressed,
|
||
child: ExcludeSemantics(
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
height: 48,
|
||
child: Center(
|
||
child: SizedBox(
|
||
key: ValueKey('low-quality-delete-visual-${face.sampleId}'),
|
||
width: 116,
|
||
height: 32,
|
||
child: Material(
|
||
color: enabled
|
||
? XfjDesignTokens.danger
|
||
: XfjDesignTokens.danger.withValues(alpha: 0.42),
|
||
borderRadius: borderRadius,
|
||
clipBehavior: Clip.antiAlias,
|
||
child: InkWell(
|
||
onTap: onPressed,
|
||
borderRadius: borderRadius,
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
FIcon(
|
||
FAssets.icons.trash2,
|
||
color: Colors.white,
|
||
size: 14.sp,
|
||
),
|
||
SizedBox(width: 4.w),
|
||
Text(
|
||
'删除',
|
||
style: theme.typography.xs.copyWith(
|
||
color: Colors.white,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 构建支持加载、失败、空数据和分页内容的人脸本地样本网格。
|
||
Widget buildFaceList(BuildContext context) {
|
||
final theme = FTheme.of(context);
|
||
final controller = Get.find<FaceManageController>();
|
||
return Obx(() {
|
||
if (controller.isLoading.value) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (controller.errorMessage.isNotEmpty) {
|
||
return SettingEmpty(message: controller.errorMessage.value);
|
||
}
|
||
final lowQuality = controller.selectedTab.value == FaceManageTab.lowQuality;
|
||
if (controller.faceList.isEmpty) {
|
||
return SettingEmpty(
|
||
message: lowQuality ? '当前阈值下暂无低质量人脸' : '暂无本地人脸样本',
|
||
);
|
||
}
|
||
return GridView.builder(
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 5,
|
||
mainAxisSpacing: XfjDesignTokens.cardGap.h,
|
||
crossAxisSpacing: XfjDesignTokens.cardGap.w,
|
||
childAspectRatio: 1.2,
|
||
mainAxisExtent: lowQuality ? 296.h : null,
|
||
),
|
||
itemCount: controller.faceList.length,
|
||
itemBuilder: (BuildContext context, int index) {
|
||
final face = controller.faceList[index];
|
||
final content = Container(
|
||
padding: lowQuality
|
||
? EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h)
|
||
: EdgeInsets.all(14.w),
|
||
decoration: BoxDecoration(
|
||
color: XfjDesignTokens.surface,
|
||
border: Border.all(
|
||
color: lowQuality
|
||
? XfjDesignTokens.danger.withValues(alpha: 0.35)
|
||
: XfjDesignTokens.line,
|
||
),
|
||
borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius),
|
||
boxShadow: XfjDesignTokens.surfaceShadow,
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Semantics(
|
||
image: true,
|
||
label: '${face.userName}的人脸照片',
|
||
child: ClipOval(
|
||
child: appImg(
|
||
face.path,
|
||
width: (lowQuality ? 52 : 72).w,
|
||
height: (lowQuality ? 52 : 72).w,
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: (lowQuality ? 4 : 8).h),
|
||
Text(
|
||
face.userName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style:
|
||
(lowQuality ? theme.typography.sm : theme.typography.base)
|
||
.copyWith(
|
||
color: XfjDesignTokens.ink,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
SizedBox(height: (lowQuality ? 2 : 4).h),
|
||
Text(
|
||
'用户编号:${_maskFaceId(face.userId)}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.typography.xs.copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
height: lowQuality ? 1.1 : null,
|
||
),
|
||
),
|
||
SizedBox(height: (lowQuality ? 1 : 2).h),
|
||
Text(
|
||
'样本编号:${_maskFaceId(face.sampleId)}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.typography.xs.copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
height: lowQuality ? 1.1 : null,
|
||
),
|
||
),
|
||
if (lowQuality) ...[
|
||
SizedBox(height: 4.h),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
FIcon(
|
||
FAssets.icons.triangleAlert,
|
||
color: XfjDesignTokens.danger,
|
||
size: 14.sp,
|
||
),
|
||
SizedBox(width: 4.w),
|
||
Text(
|
||
'低质量 ${face.qualityScore?.toStringAsFixed(2) ?? '-'}',
|
||
style: theme.typography.xs.copyWith(
|
||
color: XfjDesignTokens.danger,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 2.h),
|
||
Text(
|
||
'原因:评分低于 ${controller.libraryQualityThreshold.value.toStringAsFixed(2)}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.typography.xs.copyWith(
|
||
color: XfjDesignTokens.muted,
|
||
height: 1.1,
|
||
),
|
||
),
|
||
SizedBox(height: 4.h),
|
||
_CompactFaceDeleteButton(
|
||
face: face,
|
||
onPressed: controller.operationInProgress
|
||
? null
|
||
: () => controller.deleteLowQualityFace(face),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
if (lowQuality) return content;
|
||
return Semantics(
|
||
button: true,
|
||
label: '删除${face.userName}的本地人脸',
|
||
child: InkWell(
|
||
onTap: controller.operationInProgress
|
||
? null
|
||
: () => controller.deleteFace(face),
|
||
borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius),
|
||
child: content,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
/// 构建带两个内容 Tab 的人脸管理页面。
|
||
Widget settingFace(BuildContext context) {
|
||
final controller = Get.find<FaceManageController>();
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_buildFaceHeader(context),
|
||
Expanded(
|
||
child: Obx(() {
|
||
final lowQuality =
|
||
controller.selectedTab.value == FaceManageTab.lowQuality;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (lowQuality)
|
||
_buildLowQualityOperations(context)
|
||
else
|
||
buildFaceSearch(context),
|
||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||
Expanded(child: buildFaceList(context)),
|
||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||
appPage(
|
||
context,
|
||
pageNo: controller.currentPage.value,
|
||
pageSize: controller.pageSize.value,
|
||
total: controller.totalPage.value,
|
||
onPageChanged: (pageNo) => controller.doPage(page: pageNo),
|
||
),
|
||
],
|
||
);
|
||
}),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
String _maskFaceId(String value) {
|
||
if (value.length <= 4) return '***$value';
|
||
return '${value.substring(0, 1)}***${value.substring(value.length - 3)}';
|
||
}
|