From 917f03bc58ad5cfee24a89dc53e06fb2a44de156 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 31 Aug 2026 15:09:38 +0800 Subject: [PATCH] 11 --- AGENTS.md | 17 + README.md | 2 +- .../facediscern/database/DatabaseHelper.java | 43 +- .../flutter/FlutterFaceDiscernPlugin.java | 63 ++ .../facediscern/flutter/FlutterTool.java | 220 ++++- .../face/model/RecognizeConfiguration.java | 30 + .../model/RecognizeConfigurationStore.java | 4 + .../tool/face/servcie/FaceServer.java | 161 +++- lib/common/api/user_api.dart | 26 + lib/common/models/api/user_api_models.dart | 30 + lib/common/models/api/user_api_models.g.dart | 18 + lib/common/models/index.dart | 1 + .../page/product_meal_period_models.dart | 163 ++++ .../models/page/setting_page_enum_models.dart | 1 + .../models/plugs/face_discern_models.dart | 203 +++- lib/common/routers/names.dart | 3 + .../services/database/dao/face_dao.dart | 53 + lib/common/services/database/database.dart | 3 +- lib/common/services/database/database.g.dart | 133 ++- .../services/database/database_migration.dart | 36 + .../services/database/entity/face_entity.dart | 16 + .../database/entity/face_entity.g.dart | 10 + lib/common/utils/app/app_setting_tool.dart | 41 + .../control/product_hour_type_control.dart | 108 +++ lib/common/utils/index.dart | 1 + .../utils/plugs/face_discern_plugs.dart | 35 + lib/common/utils/sync/sync_check_tool.dart | 6 +- lib/common/utils/sync/sync_face_tool.dart | 159 ++- .../sync/sync_take_food_broadcast_tool.dart | 4 +- lib/common/values/constants.dart | 4 + .../app/app_radio_hour_type_dialog.dart | 305 +++++- .../widgets/cashier/cashier_workspace.dart | 29 +- lib/main.dart | 5 + lib/pages/root/load/view.dart | 23 +- .../terminal/canteen_product/controller.dart | 92 +- lib/pages/terminal/canteen_product/view.dart | 4 + .../terminal/canteen_reserve/controller.dart | 84 +- lib/pages/terminal/canteen_reserve/view.dart | 7 +- lib/pages/terminal/menu_query/controller.dart | 88 +- lib/pages/terminal/menu_query/view.dart | 1 + .../widgets/query_method_panel.dart | 6 + lib/pages/terminal/setting/controller.dart | 35 + .../setting/routes/meal_periods/view.dart | 11 + .../terminal/setting/routes/navigation.dart | 2 + .../setting/routes/route_factory.dart | 28 +- lib/pages/terminal/setting/view.dart | 2 + lib/pages/terminal/setting/widgets/index.dart | 1 + .../setting/widgets/setting_components.dart | 81 +- .../setting/widgets/setting_face.dart | 911 ++++++++++++++---- .../setting/widgets/setting_face_plugin.dart | 20 +- .../setting/widgets/setting_meal_periods.dart | 407 ++++++++ .../setting/widgets/setting_product.dart | 77 +- pubspec.yaml | 2 +- .../app_radio_hour_type_dialog_test.dart | 255 +++++ test/common/cashier_checkout_dialog_test.dart | 12 +- test/common/face_quality_database_test.dart | 122 +++ test/common/face_quality_models_test.dart | 120 +++ .../product_meal_period_models_test.dart | 109 +++ test/pages/root/load_view_test.dart | 53 + .../terminal/setting_face_quality_test.dart | 45 + .../terminal/setting_meal_periods_test.dart | 69 ++ 61 files changed, 4233 insertions(+), 367 deletions(-) create mode 100644 AGENTS.md create mode 100644 lib/common/models/page/product_meal_period_models.dart create mode 100644 lib/common/utils/app/control/product_hour_type_control.dart create mode 100644 lib/pages/terminal/setting/routes/meal_periods/view.dart create mode 100644 lib/pages/terminal/setting/widgets/setting_meal_periods.dart create mode 100644 test/common/app_radio_hour_type_dialog_test.dart create mode 100644 test/common/face_quality_database_test.dart create mode 100644 test/common/face_quality_models_test.dart create mode 100644 test/common/product_meal_period_models_test.dart create mode 100644 test/pages/root/load_view_test.dart create mode 100644 test/pages/terminal/setting_face_quality_test.dart create mode 100644 test/pages/terminal/setting_meal_periods_test.dart diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ff1906b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# `biz_xfj_cloud` 项目规则 + +> 本项目是以 `biz_supermarket_cloud` 为功能与工程基线的新 UI 版本。 + +## 必读顺序 + +1. [HQ Platform 通用规则](../agents/common.md)。 +2. [Flutter UI 共享规则](../agents/flutter-ui.md)。 +3. [双屏收银终端共享规则](../agents/flutter-terminal.md)。 +4. 涉及界面设计时阅读 [UI 设计规范](../ui/UI_DESIGN.md)。 +5. 检查目标目录内更具体的 `AGENTS.md`、`README.md`、开发文档和现有同类实现。 + +## 本项目定位 + +- 修改业务功能前,必须阅读 `../biz_supermarket_cloud` 中对应页面、Controller、Binding、公共组件、API、模型、设备管理类和 Android 原生实现。 +- 可以形成独立的新 UI 视觉,但不得删减、弱化或改变基线项目的业务能力、异常处理、硬件不可用处理和主副屏联动语义。 +- 涉及依赖、Android 基线、数据库、人脸插件、双屏协议、支付编码或其他跨项目契约时,必须同步评估两个终端项目,禁止单边分叉。 diff --git a/README.md b/README.md index 8b942fd..23016df 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~ ## flutter打包 ``` -flutter build apk --build-name 1.4.0 --build-number 140 +flutter build apk --build-name 1.4.2 --build-number 142 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java index 448379a..6abc702 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java @@ -16,7 +16,7 @@ public class DatabaseHelper extends SQLiteOpenHelper { private static final String DATABASE_PATH = "pj_cashier/database"; private static final String DATABASE_NAME = "cash_register.db"; /** 与 Flutter Floor 的 @Database 版本保持一致,避免原生层提前推进 user_version。 */ - private static final int DATABASE_VERSION = 11; + private static final int DATABASE_VERSION = 12; public DatabaseHelper(Context context) { super(context, getCustomDatabasePath(context), null, DATABASE_VERSION); @@ -41,6 +41,45 @@ public class DatabaseHelper extends SQLiteOpenHelper { @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - onCreate(db); + if (oldVersion < 12) { + migrateFaceQuality(db); + } + } + + /** + * 从版本11无损补齐本地人脸质量字段;重复执行安全,避免原生打开数据库时覆盖 Floor 迁移。 + */ + private void migrateFaceQuality(SQLiteDatabase db) { + if (!hasColumn(db, "face", "qualityScore")) { + db.execSQL("ALTER TABLE face ADD COLUMN qualityScore REAL"); + } + if (!hasColumn(db, "face", "qualityStatus")) { + db.execSQL("ALTER TABLE face ADD COLUMN qualityStatus TEXT NOT NULL DEFAULT 'pending'"); + } + if (!hasColumn(db, "face", "qualityCheckedAt")) { + db.execSQL("ALTER TABLE face ADD COLUMN qualityCheckedAt TEXT"); + } + if (!hasColumn(db, "face", "qualityErrorCode")) { + db.execSQL("ALTER TABLE face ADD COLUMN qualityErrorCode INTEGER"); + } + db.execSQL("UPDATE face SET qualityStatus = 'pending' WHERE qualityStatus IS NULL OR qualityStatus NOT IN ('pending', 'detected', 'failed')"); + // Floor 的可空时间转换器使用空字符串承载业务空值,原生兜底升级必须保持同一存储语义。 + db.execSQL("UPDATE face SET qualityCheckedAt = '' WHERE qualityCheckedAt IS NULL"); + db.execSQL("CREATE INDEX IF NOT EXISTS index_face_qualityStatus_qualityScore ON face(qualityStatus, qualityScore)"); + } + + /** + * 检查表中是否已经存在目标字段。 + */ + private boolean hasColumn(SQLiteDatabase db, String table, String column) { + try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) { + int nameIndex = cursor.getColumnIndex("name"); + while (cursor.moveToNext()) { + if (nameIndex >= 0 && column.equals(cursor.getString(nameIndex))) { + return true; + } + } + } + return false; } } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java index 152e28a..6fcbaf3 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -40,6 +40,8 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me private static final String EVENT_TAG = "local/face_discern_event"; private static final String SESSION_EVENT_TAG = "local/face_discern_session_event"; private static final String REGISTRATION_EVENT_TAG = "local/face_discern_registration_event"; + /** 在库照片质量检测进度事件通道。 */ + private static final String QUALITY_AUDIT_EVENT_TAG = "local/face_discern_quality_audit_event"; private static final ExecutorService FACE_EXECUTOR = Executors.newSingleThreadExecutor(); private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper()); @@ -51,9 +53,13 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me private EventChannel eventChannel; private EventChannel sessionEventChannel; private EventChannel registrationEventChannel; + /** 当前Flutter Engine对应的质量检测进度通道。 */ + private EventChannel qualityAuditEventChannel; private volatile EventChannel.EventSink faceEventSink; private volatile EventChannel.EventSink sessionEventSink; private volatile EventChannel.EventSink registrationEventSink; + /** 当前质量检测进度订阅;页面离开时置空但不终止原生任务。 */ + private volatile EventChannel.EventSink qualityAuditEventSink; private MethodChannel.Result pendingResult; @@ -115,6 +121,21 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me registrationEventSink = null; } }); + qualityAuditEventChannel = new EventChannel(binding.getBinaryMessenger(), QUALITY_AUDIT_EVENT_TAG); + qualityAuditEventChannel.setStreamHandler(new EventChannel.StreamHandler() { + /** 订阅后立即返回最新进度,支持页面重建。 */ + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + qualityAuditEventSink = events; + events.success(FlutterTool.getLatestQualityAuditProgress()); + } + + /** 取消页面订阅,不取消仍在后台执行的批量检测。 */ + @Override + public void onCancel(Object arguments) { + qualityAuditEventSink = null; + } + }); } @Override @@ -125,9 +146,11 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me eventChannel.setStreamHandler(null); sessionEventChannel.setStreamHandler(null); registrationEventChannel.setStreamHandler(null); + if (qualityAuditEventChannel != null) qualityAuditEventChannel.setStreamHandler(null); faceEventSink = null; sessionEventSink = null; registrationEventSink = null; + qualityAuditEventSink = null; unregisterLegacyReceiver(); if (sessionManager != null) { for (Long outputId : previewOutputs.values().toArray(new Long[0])) { @@ -295,6 +318,9 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me case "extractFaceFeature": startFaceFeatureExtraction(stringArgument(call, "filePath"), result); break; + case "auditFaceQuality": + startFaceQualityAudit(call.argument("items"), result); + break; case "applyFaceDelta": startFaceDelta(call, result); break; @@ -366,6 +392,35 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me }); } + /** + * 在统一人脸操作互斥锁内批量检测存量图片,并通过独立事件通道报告进度。 + * + * @param rawItems Flutter提交的样本编号与本地路径列表 + * @param result 批量检测完成结果 + */ + private void startFaceQualityAudit(Object rawItems, MethodChannel.Result result) { + if (!(rawItems instanceof List)) { + result.error("INVALID_QUALITY_AUDIT_ITEMS", "质量检测参数无效", null); + return; + } + if (!FlutterTool.tryStartRegistration()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); + return; + } + FACE_EXECUTOR.execute(() -> { + try { + Map audit = FlutterTool.auditFaceQuality( + context, (List) rawItems, this::emitQualityAuditProgress); + MAIN_HANDLER.post(() -> result.success(audit)); + } catch (Exception exception) { + Log.e(TAG, "在库人脸质量检测失败:" + exception.getMessage(), exception); + MAIN_HANDLER.post(() -> result.error("FACE_QUALITY_AUDIT_FAILED", "在库人脸质量检测失败", null)); + } finally { + FlutterTool.finishRegistration(); + } + }); + } + /** * 在统一人脸注册占用标记和后台执行器中应用单样本增删。 * @@ -427,6 +482,14 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me }); } + /** 将质量检测进度安全切回主线程发送给Flutter。 */ + private void emitQualityAuditProgress(Map progress) { + MAIN_HANDLER.post(() -> { + EventChannel.EventSink sink = qualityAuditEventSink; + if (sink != null) sink.success(progress); + }); + } + private String stringArgument(MethodCall call, String key) { Object value = call.argument(key); return value == null ? "" : value.toString(); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java index 22978c2..7646083 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java @@ -47,11 +47,18 @@ public class FlutterTool { private static final AtomicBoolean REGISTRATION_IN_PROGRESS = new AtomicBoolean(false); private static volatile Map latestRegistrationProgress = idleRegistrationProgress(); private static volatile Map latestRegistrationResult; + private static volatile Map latestQualityAuditProgress = idleQualityAuditProgress(); public interface RegistrationProgressListener { void onProgress(Map progress); } + /** 接收原生在库质量检测进度并转发给Flutter事件通道。 */ + public interface QualityAuditProgressListener { + /** 发布一份不包含图片路径和人员信息的进度快照。 */ + void onProgress(Map progress); + } + static void updateConfig(String appId, String activeKey, String sdkKey) { Constants.APP_ID = appId == null ? "" : appId.trim(); Constants.ACTIVE_KEY = activeKey == null ? "" : activeKey.trim(); @@ -152,6 +159,11 @@ public class FlutterTool { return new HashMap<>(latestRegistrationProgress); } + /** 返回最新在库图片质量检测进度,供新订阅者立即恢复页面状态。 */ + static Map getLatestQualityAuditProgress() { + return new HashMap<>(latestQualityAuditProgress); + } + static Integer parseId(String id) { try { @@ -239,29 +251,38 @@ public class FlutterTool { static public Boolean registerFace(Context context, byte[] bytes, FaceEntity entity) { FaceServer faceServer = FaceServer.getInstance(); faceServer.init(context); - // 类型转换 Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (bitmap == null) return false; Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); - byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); - int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); - if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) { - Log.e(TAG, "注册人脸,bitmapToImageData错误code:" + transformCode); + if (alignedBitmap == null) { + bitmap.recycle(); return false; } - byte[] faceBytes = faceServer.registerBgr24( - context, - bgr24Data, - alignedBitmap.getWidth(), - alignedBitmap.getHeight(), - String.valueOf(System.currentTimeMillis()) - ); - if (faceBytes == null) { - Log.e(TAG, "注册人脸,registerBgr24错误"); - return false; + try { + byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); + int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); + if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) { + Log.e(TAG, "注册人脸,bitmapToImageData错误code:" + transformCode); + return false; + } + byte[] faceBytes = faceServer.registerBgr24( + context, + bgr24Data, + alignedBitmap.getWidth(), + alignedBitmap.getHeight(), + String.valueOf(System.currentTimeMillis()) + ); + if (faceBytes == null) { + Log.e(TAG, "注册人脸,registerBgr24错误"); + return false; + } + entity.setFaceModel(faceBytes); + int code = faceServer.registerFaceFeatureInfoFromDb(entity); + return code == ErrorInfo.MOK; + } finally { + if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle(); + if (!bitmap.isRecycled()) bitmap.recycle(); } - entity.setFaceModel(faceBytes); - int code = faceServer.registerFaceFeatureInfoFromDb(entity); - return code == ErrorInfo.MOK; } @@ -303,21 +324,72 @@ public class FlutterTool { static public Map extractFaceFeature(Context context, String filePath) { Map result = new HashMap<>(); try { - byte[] featureData = extractFaceModelFromFile(context, filePath); + FaceServer.QualityFeatureResult nativeResult = extractFaceResultFromFile(context, filePath); + byte[] featureData = nativeResult.featureData; boolean success = featureData != null && featureData.length > 0; result.put("success", success); result.put("message", success ? "人脸特征提取成功" : "图片中未提取到可注册人脸"); result.put("featureData", success ? featureData : new byte[0]); + result.put("qualityStatus", nativeResult.qualityStatus); + result.put("qualityScore", nativeResult.qualityScore); + result.put("qualityErrorCode", nativeResult.qualityErrorCode); return result; } catch (Exception exception) { Log.e(TAG, "提取人脸特征异常error:" + exception.getMessage(), exception); result.put("success", false); result.put("message", "人脸特征提取失败"); result.put("featureData", new byte[0]); + result.put("qualityStatus", "failed"); + result.put("qualityErrorCode", -99); return result; } } + /** + * 批量检测Flutter提交的本地人脸图片质量,逐图释放Bitmap并返回一一对应的结果。 + */ + static public Map auditFaceQuality(Context context, List items, QualityAuditProgressListener listener) { + List> results = new ArrayList<>(); + int total = items == null ? 0 : items.size(); + int success = 0; + int failed = 0; + publishQualityAuditProgress(listener, 0, total, success, failed, null, true, "准备检测在库人脸"); + if (items != null) { + for (Object rawItem : items) { + Map itemResult = new HashMap<>(); + String sampleId = ""; + try { + if (!(rawItem instanceof Map)) throw new IllegalArgumentException("检测项格式错误"); + Map item = (Map) rawItem; + sampleId = item.get("sampleId") == null ? "" : item.get("sampleId").toString(); + String filePath = item.get("filePath") == null ? "" : item.get("filePath").toString(); + FaceServer.QualityFeatureResult quality = detectFaceQualityFromFile(context, filePath); + itemResult.put("sampleId", sampleId); + itemResult.put("status", quality.qualityStatus); + itemResult.put("score", quality.qualityScore); + itemResult.put("errorCode", quality.qualityErrorCode); + boolean detected = "detected".equals(quality.qualityStatus) && quality.qualityScore != null; + itemResult.put("message", detected ? "图片质量检测成功" : "图片质量检测失败"); + if (detected) success++; else failed++; + } catch (Exception exception) { + Log.w(TAG, "在库图片质量检测失败sampleId:" + maskSampleIdText(sampleId) + ";error:" + exception.getClass().getSimpleName()); + itemResult.put("sampleId", sampleId); + itemResult.put("status", "failed"); + itemResult.put("errorCode", -99); + itemResult.put("message", "图片质量检测失败"); + failed++; + } + results.add(itemResult); + publishQualityAuditProgress(listener, results.size(), total, success, failed, sampleId, true, "正在检测在库人脸"); + } + } + publishQualityAuditProgress(listener, total, total, success, failed, null, false, "在库人脸质量检测完成"); + Map auditResult = new HashMap<>(); + auditResult.put("results", results); + auditResult.put("message", "在库人脸质量检测完成"); + return auditResult; + } + /** * 在虹软内存库中应用单样本增删;替换失败由 FaceServer 恢复旧特征。 * @@ -478,21 +550,107 @@ public class FlutterTool { * @return 虹软特征字节,图片无效或未检测到合格人脸时返回空 */ private static byte[] extractFaceModelFromFile(Context context, String filePath) { - if (filePath == null || filePath.isEmpty()) return null; + return extractFaceResultFromFile(context, filePath).featureData; + } + + /** 将本地文件一次转换为BGR24并同时返回特征与质量结果。 */ + private static FaceServer.QualityFeatureResult extractFaceResultFromFile(Context context, String filePath) { + FaceServer.QualityFeatureResult failed = new FaceServer.QualityFeatureResult(); + failed.qualityStatus = "failed"; + failed.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + if (filePath == null || filePath.isEmpty()) return failed; byte[] bytes = FileUtil.fileToData(new File(filePath)); - if (bytes == null || bytes.length == 0) return null; + if (bytes == null || bytes.length == 0) return failed; Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - if (bitmap == null) return null; + if (bitmap == null) return failed; Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); - if (alignedBitmap == null) return null; - byte[] bgr24Data = ArcSoftImageUtil.createImageData( - alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); - int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); - if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) return null; - FaceServer faceServer = FaceServer.getInstance(); - faceServer.init(context); - return faceServer.registerBgr24(context, bgr24Data, alignedBitmap.getWidth(), - alignedBitmap.getHeight(), String.valueOf(System.currentTimeMillis())); + if (alignedBitmap == null) { + bitmap.recycle(); + return failed; + } + try { + byte[] bgr24Data = ArcSoftImageUtil.createImageData( + alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); + int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); + if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) { + failed.qualityErrorCode = transformCode; + return failed; + } + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + return faceServer.registerBgr24WithQuality(context, bgr24Data, alignedBitmap.getWidth(), + alignedBitmap.getHeight(), String.valueOf(System.currentTimeMillis())); + } finally { + if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle(); + if (!bitmap.isRecycled()) bitmap.recycle(); + } + } + + /** 将本地文件转换为BGR24后仅执行质量检测。 */ + private static FaceServer.QualityFeatureResult detectFaceQualityFromFile(Context context, String filePath) { + FaceServer.QualityFeatureResult failed = new FaceServer.QualityFeatureResult(); + failed.qualityStatus = "failed"; + failed.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + if (filePath == null || filePath.isEmpty()) return failed; + byte[] bytes = FileUtil.fileToData(new File(filePath)); + if (bytes == null || bytes.length == 0) return failed; + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (bitmap == null) return failed; + Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); + if (alignedBitmap == null) { + bitmap.recycle(); + return failed; + } + try { + byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); + int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); + if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) { + failed.qualityErrorCode = transformCode; + return failed; + } + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + return faceServer.detectBgr24Quality(bgr24Data, alignedBitmap.getWidth(), alignedBitmap.getHeight()); + } finally { + if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle(); + if (!bitmap.isRecycled()) bitmap.recycle(); + } + } + + /** 发布并缓存质量检测进度,供页面中途进入时恢复。 */ + private static void publishQualityAuditProgress(QualityAuditProgressListener listener, int processed, int total, + int success, int failed, String currentSampleId, + boolean inProgress, String message) { + Map progress = new HashMap<>(); + progress.put("processed", processed); + progress.put("total", total); + progress.put("successCount", success); + progress.put("failedCount", failed); + progress.put("currentSampleId", currentSampleId); + progress.put("inProgress", inProgress); + progress.put("message", message); + latestQualityAuditProgress = progress; + if (listener != null) listener.onProgress(new HashMap<>(progress)); + } + + /** 创建尚未执行质量检测时的空进度快照。 */ + private static Map idleQualityAuditProgress() { + Map progress = new HashMap<>(); + progress.put("processed", 0); + progress.put("total", 0); + progress.put("successCount", 0); + progress.put("failedCount", 0); + progress.put("currentSampleId", null); + progress.put("inProgress", false); + progress.put("message", "尚未执行在库人脸质量检测"); + return progress; + } + + /** 对质量检测日志中的样本编号做最小脱敏。 */ + private static String maskSampleIdText(String sampleId) { + if (sampleId == null || sampleId.isEmpty()) return "-"; + if (sampleId.length() <= 4) return "***" + sampleId; + return sampleId.substring(0, 1) + "***" + sampleId.substring(sampleId.length() - 3); } private static boolean hasFaceModel(FaceEntity face) { diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java index 76f19af..98e7beb 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java @@ -14,6 +14,8 @@ public class RecognizeConfiguration { public static final float DEFAULT_SIMILAR_THRESHOLD = 0.8F; public static final float DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD = 0.49F; public static final float DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD = 0.29F; + /** 在库照片质量诊断默认阈值,只用于终端运维分类。 */ + public static final float DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD = 0.60F; public static final float DEFAULT_RGB_LIVENESS_THRESHOLD = 0.5F; public static final boolean DEFAULT_ENABLE_LIVENESS = false; public static final boolean DEFAULT_ENABLE_IMAGE_QUALITY = true; @@ -43,6 +45,10 @@ public class RecognizeConfiguration { * 图像质量检测阈值:适用于戴口罩且人脸识别场景 */ private float imageQualityMaskRecognizeThreshold; + /** + * 在库照片质量诊断阈值,不参与实时识别和特征注册。 + */ + private float libraryFaceQualityThreshold; /** * 识别失败重试间隔 */ @@ -99,6 +105,7 @@ public class RecognizeConfiguration { this.similarThreshold = builder.similarThreshold; this.imageQualityNoMaskRecognizeThreshold = builder.imageQualityNoMaskRecognizeThreshold; this.imageQualityMaskRecognizeThreshold = builder.imageQualityMaskRecognizeThreshold; + this.libraryFaceQualityThreshold = builder.libraryFaceQualityThreshold; this.enableLiveness = builder.enableLiveness; this.enableImageQuality = builder.enableImageQuality; this.enableFaceAreaLimit = builder.enableFaceAreaLimit; @@ -121,6 +128,8 @@ public class RecognizeConfiguration { private float similarThreshold = DEFAULT_SIMILAR_THRESHOLD; private float imageQualityNoMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD; private float imageQualityMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD; + /** 在库照片质量诊断阈值,允许范围为0到1。 */ + private float libraryFaceQualityThreshold = DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD; private boolean enableLiveness = DEFAULT_ENABLE_LIVENESS; private boolean enableFaceAreaLimit = false; private boolean enableImageQuality = DEFAULT_ENABLE_IMAGE_QUALITY; @@ -171,6 +180,17 @@ public class RecognizeConfiguration { return this; } + /** + * 设置在库照片质量诊断阈值。 + * + * @param val 0到1之间的质量分界值 + * @return 当前构建器 + */ + public Builder libraryFaceQualityThreshold(float val) { + this.libraryFaceQualityThreshold = val; + return this; + } + public Builder enableLiveness(boolean val) { this.enableLiveness = val; return this; @@ -230,6 +250,15 @@ public class RecognizeConfiguration { return imageQualityMaskRecognizeThreshold; } + /** + * 读取在库照片质量诊断阈值。 + * + * @return 0到1之间的质量分界值 + */ + public float getLibraryFaceQualityThreshold() { + return libraryFaceQualityThreshold; + } + public boolean isEnableImageQuality() { return enableImageQuality; } @@ -304,6 +333,7 @@ public class RecognizeConfiguration { "enableImageQuality: " + enableImageQuality + "\r\n" + "imageQualityNoMaskRecognizeThreshold: " + imageQualityNoMaskRecognizeThreshold + "\r\n" + "imageQualityMaskRecognizeThreshold: " + imageQualityMaskRecognizeThreshold + "\r\n" + + "libraryFaceQualityThreshold: " + libraryFaceQualityThreshold + "\r\n" + "enableLiveness: " + enableLiveness + "\r\n" + "livenessRetryCount: " + livenessRetryCount + "\r\n" + diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java index ac3d6bf..685cd93 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java @@ -28,6 +28,7 @@ public final class RecognizeConfigurationStore { .enableImageQuality(preferences.getBoolean("enableImageQuality", RecognizeConfiguration.DEFAULT_ENABLE_IMAGE_QUALITY)) .imageQualityNoMaskRecognizeThreshold(preferences.getFloat("imageQualityNoMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD)) .imageQualityMaskRecognizeThreshold(preferences.getFloat("imageQualityMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD)) + .libraryFaceQualityThreshold(preferences.getFloat("libraryFaceQualityThreshold", RecognizeConfiguration.DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD)) .keepMaxFace(preferences.getBoolean("keepMaxFace", RecognizeConfiguration.DEFAULT_KEEP_MAX_FACE)) .maxDetectFaces(preferences.getInt("maxDetectFaces", RecognizeConfiguration.DEFAULT_MAX_DETECT_FACES)) .extractRetryCount(preferences.getInt("extractRetryCount", RecognizeConfiguration.DEFAULT_EXTRACT_RETRY_COUNT)) @@ -48,6 +49,7 @@ public final class RecognizeConfigurationStore { boolean enableImageQuality = booleanValue(values, "enableImageQuality", current.isEnableImageQuality()); float noMaskThreshold = floatValue(values, "imageQualityNoMaskRecognizeThreshold", current.getImageQualityNoMaskRecognizeThreshold(), 0F, 1F); float maskThreshold = floatValue(values, "imageQualityMaskRecognizeThreshold", current.getImageQualityMaskRecognizeThreshold(), 0F, 1F); + float libraryFaceQualityThreshold = floatValue(values, "libraryFaceQualityThreshold", current.getLibraryFaceQualityThreshold(), 0F, 1F); boolean keepMaxFace = booleanValue(values, "keepMaxFace", current.isKeepMaxFace()); int maxDetectFaces = intValue(values, "maxDetectFaces", current.getMaxDetectFaces(), 1, 3); int extractRetryCount = intValue(values, "extractRetryCount", current.getExtractRetryCount(), 0, 10); @@ -62,6 +64,7 @@ public final class RecognizeConfigurationStore { .putBoolean("enableImageQuality", enableImageQuality) .putFloat("imageQualityNoMaskRecognizeThreshold", noMaskThreshold) .putFloat("imageQualityMaskRecognizeThreshold", maskThreshold) + .putFloat("libraryFaceQualityThreshold", libraryFaceQualityThreshold) .putBoolean("keepMaxFace", keepMaxFace) .putInt("maxDetectFaces", maxDetectFaces) .putInt("extractRetryCount", extractRetryCount) @@ -88,6 +91,7 @@ public final class RecognizeConfigurationStore { result.put("enableImageQuality", configuration.isEnableImageQuality()); result.put("imageQualityNoMaskRecognizeThreshold", configuration.getImageQualityNoMaskRecognizeThreshold()); result.put("imageQualityMaskRecognizeThreshold", configuration.getImageQualityMaskRecognizeThreshold()); + result.put("libraryFaceQualityThreshold", configuration.getLibraryFaceQualityThreshold()); result.put("keepMaxFace", configuration.isKeepMaxFace()); result.put("maxDetectFaces", configuration.getMaxDetectFaces()); result.put("extractRetryCount", configuration.getExtractRetryCount()); 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 3bbdefc..12f6dd9 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 @@ -11,6 +11,7 @@ import com.arcsoft.face.FaceEngine; import com.arcsoft.face.FaceFeature; import com.arcsoft.face.FaceFeatureInfo; import com.arcsoft.face.FaceInfo; +import com.arcsoft.face.ImageQualitySimilar; import com.arcsoft.face.MaskInfo; import com.arcsoft.face.SearchResult; import com.arcsoft.face.enums.DetectFaceOrientPriority; @@ -45,6 +46,8 @@ public class FaceServer { private static volatile FaceServer faceServer = null; private final Map faceRegisterInfoMap = new HashMap<>(); private int engineInitCode = -99; + private int imageQualityInitCode = -99; + private boolean imageQualityReady; private String imageRootPath; /** * 最大注册人脸数 @@ -54,17 +57,35 @@ public class FaceServer { public synchronized void init(Context context) { if (faceEngine == null && context != null) { faceEngine = new FaceEngine(); - engineInitCode = faceEngine.init( + int requestedCode = faceEngine.init( context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, - FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT + FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT | FaceEngine.ASF_IMAGEQUALITY ); - if (engineInitCode != ErrorInfo.MOK) { - Log.e(TAG, "人脸引擎初始化失败code" + engineInitCode); + imageQualityInitCode = requestedCode; + if (requestedCode == ErrorInfo.MOK) { + engineInitCode = requestedCode; + imageQualityReady = true; + } else { + Log.w(TAG, "图像质量模块不可用,回退基础人脸引擎code" + requestedCode); faceEngine.unInit(); faceEngine = null; + faceEngine = new FaceEngine(); + engineInitCode = faceEngine.init( + context, + DetectMode.ASF_DETECT_MODE_IMAGE, + DetectFaceOrientPriority.ASF_OP_ALL_OUT, + 1, + FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT + ); + imageQualityReady = false; + if (engineInitCode != ErrorInfo.MOK) { + Log.e(TAG, "人脸引擎初始化失败code" + engineInitCode); + faceEngine.unInit(); + faceEngine = null; + } } } } @@ -77,6 +98,16 @@ public class FaceServer { return faceEngine != null && engineInitCode == ErrorInfo.MOK; } + /** 返回当前授权是否包含虹软图像质量模块。 */ + public synchronized boolean isImageQualityReady() { + return imageQualityReady; + } + + /** 返回图像质量模块初始化返回码,用于终端诊断授权状态。 */ + public synchronized int getImageQualityInitCode() { + return imageQualityInitCode; + } + public static FaceServer getInstance() { if (faceServer == null) { synchronized (FaceServer.class) { @@ -100,6 +131,8 @@ public class FaceServer { faceEngine = null; } engineInitCode = -99; + imageQualityInitCode = -99; + imageQualityReady = false; faceServer = null; } @@ -302,10 +335,23 @@ public class FaceServer { } + /** 保持旧调用方返回特征字节的兼容入口。 */ public synchronized byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) { + return registerBgr24WithQuality(context, bgr24, width, height, name).featureData; + } + + /** + * 在统一解码结果上同时执行图像质量检测和虹软特征提取。 + * + *

质量模块不可用或检测失败时仅记录failed状态,仍继续原有特征提取,兼容免费版授权。

+ */ + public synchronized QualityFeatureResult registerBgr24WithQuality(Context context, byte[] bgr24, int width, int height, String name) { + QualityFeatureResult qualityResult = new QualityFeatureResult(); if (faceEngine == null || context == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) { Log.e(TAG, "registerBgr24: invalid params"); - return null; + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + return qualityResult; } //人脸检测 List faceInfoList = new ArrayList<>(); @@ -314,6 +360,25 @@ public class FaceServer { code = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList); } if (code == ErrorInfo.MOK && !faceInfoList.isEmpty()) { + if (imageQualityReady) { + ImageQualitySimilar qualitySimilar = new ImageQualitySimilar(); + int qualityCode; + synchronized (faceEngine) { + qualityCode = faceEngine.imageQualityDetect( + bgr24, width, height, FaceEngine.CP_PAF_BGR24, + faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar); + } + if (qualityCode == ErrorInfo.MOK) { + qualityResult.qualityStatus = "detected"; + qualityResult.qualityScore = (double) qualitySimilar.getScore(); + } else { + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = qualityCode; + } + } else { + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = imageQualityInitCode; + } code = faceEngine.process(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList, FaceEngine.ASF_MASK_DETECT); if (code == ErrorInfo.MOK) { @@ -326,16 +391,18 @@ public class FaceServer { * 注册照要求不戴口罩 */ Log.e(TAG, "registerBgr24: maskInfo is worn"); - return null; + return qualityResult; } if (isMask == MaskInfo.UNKNOWN) { Log.e(TAG, "registerBgr24: maskInfo is unknown"); - return null; + return qualityResult; } } } else { Log.e(TAG, "registerBgr24 mask process fail res=" + code); - return null; + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = code; + return qualityResult; } FaceFeature faceFeature = new FaceFeature(); /* @@ -352,7 +419,9 @@ public class FaceServer { Rect cropRect = getBestRect(width, height, faceInfoList.get(0).getRect()); if (cropRect == null) { Log.e(TAG, "registerBgr24: cropRect is null"); - return null; + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + return qualityResult; } cropRect.left &= ~3; cropRect.top &= ~3; @@ -361,25 +430,83 @@ public class FaceServer { String imgPath = getImagePath(userName, context); // 创建一个头像的Bitmap,存放旋转结果图 Bitmap headBmp = getHeadImage(bgr24, width, height, faceInfoList.get(0).getOrient(), cropRect, ArcSoftImageFormat.BGR24); - try { - FileOutputStream fos = new FileOutputStream(imgPath); + try (FileOutputStream fos = new FileOutputStream(imgPath)) { headBmp.compress(Bitmap.CompressFormat.JPEG, 100, fos); - fos.close(); } catch (IOException e) { - e.printStackTrace(); - return null; + Log.e(TAG, "registerBgr24: save head image failed", e); + return qualityResult; + } finally { + if (!headBmp.isRecycled()) headBmp.recycle(); } - return faceFeature.getFeatureData(); + qualityResult.featureData = faceFeature.getFeatureData(); + return qualityResult; } else { Log.e(TAG, "registerBgr24: extract face feature failed, code is " + code); - return null; + return qualityResult; } } else { Log.e(TAG, "registerBgr24: no face detected, code is " + code); - return null; + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = code == ErrorInfo.MOK + ? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE + : code; + return qualityResult; } } + /** + * 仅检测BGR24在库照片的质量,不提取或写入人脸特征。 + * + * @param bgr24 BGR24图片数据 + * @param width 图片宽度 + * @param height 图片高度 + * @return detected或failed质量结果 + */ + public synchronized QualityFeatureResult detectBgr24Quality(byte[] bgr24, int width, int height) { + QualityFeatureResult result = new QualityFeatureResult(); + if (faceEngine == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) { + result.qualityStatus = "failed"; + result.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + return result; + } + List faceInfoList = new ArrayList<>(); + int detectCode = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList); + if (detectCode != ErrorInfo.MOK || faceInfoList.isEmpty()) { + result.qualityStatus = "failed"; + result.qualityErrorCode = detectCode == ErrorInfo.MOK ? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE : detectCode; + return result; + } + if (!imageQualityReady) { + result.qualityStatus = "failed"; + result.qualityErrorCode = imageQualityInitCode; + return result; + } + ImageQualitySimilar qualitySimilar = new ImageQualitySimilar(); + int qualityCode = faceEngine.imageQualityDetect( + bgr24, width, height, FaceEngine.CP_PAF_BGR24, + faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar); + if (qualityCode == ErrorInfo.MOK) { + result.qualityStatus = "detected"; + result.qualityScore = (double) qualitySimilar.getScore(); + } else { + result.qualityStatus = "failed"; + result.qualityErrorCode = qualityCode; + } + return result; + } + + /** 原生图片特征与质量诊断的组合结果。 */ + public static class QualityFeatureResult { + /** 注册特征字节,提取失败时为空。 */ + public byte[] featureData; + /** 质量状态,稳定值为detected或failed。 */ + public String qualityStatus = "pending"; + /** 检测成功时的质量分。 */ + public Double qualityScore; + /** 检测失败时的错误码。 */ + public Integer qualityErrorCode; + } + private String getImagePath(String name, Context context) { if (imageRootPath == null) { imageRootPath = getImageDir(context); diff --git a/lib/common/api/user_api.dart b/lib/common/api/user_api.dart index 81a1da3..c790b34 100644 --- a/lib/common/api/user_api.dart +++ b/lib/common/api/user_api.dart @@ -91,6 +91,32 @@ abstract class UserApi { ); } + /// 由当前设备请求删除服务端部门范围内的人脸,单批最多 500 条。 + /// + /// 服务端会以 DEVICE-ID 解析真实绑定部门;业务错误必须抛给调用方,确保终端 + /// 在未得到 deleted 或 alreadyAbsent 确认前不清理本地数据。 + static Future deleteTerminalFaces( + List sampleIds, + ) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null) throw StateError('终端尚未绑定设备'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/face/delete', + data: {'sampleIds': sampleIds}, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.rejectBusinessErrorKey: true}, + ), + ); + if (res.data is! Map) { + throw const FormatException('服务端未返回有效的人脸删除结果'); + } + return ServiceTerminalFaceDeleteResult.fromJson( + Map.from(res.data as Map), + ); + } + /// 获取租户列表 static Future?> getTenantList() async { final res = await HttpManage().dio.get("/admin/tenant/list"); diff --git a/lib/common/models/api/user_api_models.dart b/lib/common/models/api/user_api_models.dart index e1f98d6..f02ee35 100644 --- a/lib/common/models/api/user_api_models.dart +++ b/lib/common/models/api/user_api_models.dart @@ -238,6 +238,36 @@ class ServiceFaceSyncSnapshot { Map toJson() => _$ServiceFaceSyncSnapshotToJson(this); } +/// 终端批量删除服务端人脸后的幂等确认结果。 +@JsonSerializable() +class ServiceTerminalFaceDeleteResult { + /// 本次实际逻辑删除的人脸样本编号。 + final List deletedSampleIds; + + /// 请求前已经不存在的样本编号,本机可继续执行本地清理。 + final List alreadyAbsentSampleIds; + + /// 创建一份服务端删除确认结果。 + const ServiceTerminalFaceDeleteResult({ + required this.deletedSampleIds, + required this.alreadyAbsentSampleIds, + }); + + /// 从统一网络层返回的 JSON 创建删除结果。 + factory ServiceTerminalFaceDeleteResult.fromJson(Map json) => + _$ServiceTerminalFaceDeleteResultFromJson(json); + + /// 将删除结果转换为 JSON,主要供协议测试使用。 + Map toJson() => + _$ServiceTerminalFaceDeleteResultToJson(this); + + /// 服务端允许本机清理的全部样本编号。 + Set get confirmedSampleIds => { + ...deletedSampleIds, + ...alreadyAbsentSampleIds, + }; +} + @JsonSerializable() class ServiceTenant { /// id diff --git a/lib/common/models/api/user_api_models.g.dart b/lib/common/models/api/user_api_models.g.dart index 7f31f75..6e41a8d 100644 --- a/lib/common/models/api/user_api_models.g.dart +++ b/lib/common/models/api/user_api_models.g.dart @@ -125,6 +125,24 @@ Map _$ServiceFaceSyncSnapshotToJson( 'snapshotMaxId': JsonTool.stringToInt(instance.snapshotMaxId), }; +ServiceTerminalFaceDeleteResult _$ServiceTerminalFaceDeleteResultFromJson( + Map json) => + ServiceTerminalFaceDeleteResult( + deletedSampleIds: (json['deletedSampleIds'] as List) + .map((e) => e as String) + .toList(), + alreadyAbsentSampleIds: (json['alreadyAbsentSampleIds'] as List) + .map((e) => e as String) + .toList(), + ); + +Map _$ServiceTerminalFaceDeleteResultToJson( + ServiceTerminalFaceDeleteResult instance) => + { + 'deletedSampleIds': instance.deletedSampleIds, + 'alreadyAbsentSampleIds': instance.alreadyAbsentSampleIds, + }; + ServiceTenant _$ServiceTenantFromJson(Map json) => ServiceTenant( id: json['id'] as String?, diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart index 3efc60b..27abbf0 100644 --- a/lib/common/models/index.dart +++ b/lib/common/models/index.dart @@ -13,6 +13,7 @@ export './page/app_version_enum_models.dart'; export './page/cashier_checkout_snapshot.dart'; export './page/order_page_enum_models.dart'; export './page/order_page_models.dart'; +export './page/product_meal_period_models.dart'; export './page/reservation_menu_page_models.dart'; export './page/setting_page_enum_models.dart'; export './page/verification_record_page_models.dart'; diff --git a/lib/common/models/page/product_meal_period_models.dart b/lib/common/models/page/product_meal_period_models.dart new file mode 100644 index 0000000..cc22ec1 --- /dev/null +++ b/lib/common/models/page/product_meal_period_models.dart @@ -0,0 +1,163 @@ +import '../database/database_enum_models.dart'; + +/// 单个商品餐段在一天内的时间范围,采用 `[开始, 结束)` 规则避免相邻边界重叠。 +class ProductMealPeriod { + const ProductMealPeriod({ + required this.hourType, + required this.startMinutes, + required this.endMinutes, + }); + + /// 复用商品、订单和接口已有的早餐/中餐/晚餐枚举。 + final ProductHourType hourType; + + /// 从 00:00 起算的开始分钟数,允许 0 至 1439。 + final int startMinutes; + + /// 从 00:00 起算的结束分钟数,允许 1 至 1440;1440 表示 24:00。 + final int endMinutes; + + ProductMealPeriod copyWith({int? startMinutes, int? endMinutes}) { + return ProductMealPeriod( + hourType: hourType, + startMinutes: startMinutes ?? this.startMinutes, + endMinutes: endMinutes ?? this.endMinutes, + ); + } + + bool containsMinutes(int minutes) => + minutes >= startMinutes && minutes < endMinutes; + + Map toJson() => { + 'hourType': hourType.code, + 'startMinutes': startMinutes, + 'endMinutes': endMinutes, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductMealPeriod && + hourType == other.hourType && + startMinutes == other.startMinutes && + endMinutes == other.endMinutes; + + @override + int get hashCode => Object.hash(hourType, startMinutes, endMinutes); +} + +/// 每日商品餐段配置,负责默认值、持久化恢复、校验和当前餐段解析。 +class ProductMealPeriodSchedule { + ProductMealPeriodSchedule._(List periods) + : periods = List.unmodifiable(periods); + + /// 产品默认餐段;10:00 至 11:00 按需求保留为空档。 + static final ProductMealPeriodSchedule defaults = + ProductMealPeriodSchedule._(const [ + ProductMealPeriod( + hourType: ProductHourType.MORNING, + startMinutes: 5 * 60, + endMinutes: 10 * 60, + ), + ProductMealPeriod( + hourType: ProductHourType.NOON, + startMinutes: 11 * 60, + endMinutes: 16 * 60, + ), + ProductMealPeriod( + hourType: ProductHourType.NIGHT, + startMinutes: 16 * 60, + endMinutes: 24 * 60, + ), + ]); + + /// 按早餐、中餐、晚餐固定顺序保存;“查询全部”不属于可配置餐段。 + final List periods; + + /// 从 sysJson 扩展值恢复配置;旧版本、残缺或非法配置统一安全回退默认值。 + factory ProductMealPeriodSchedule.fromJson(Object? source) { + if (source is! List) return defaults; + final byCode = {for (final type in configurableHourTypes) type.code: type}; + final restored = {}; + for (final item in source) { + if (item is! Map) return defaults; + final map = Map.from(item); + final type = byCode[map['hourType']?.toString()]; + final start = _asInt(map['startMinutes']); + final end = _asInt(map['endMinutes']); + if (type == null || start == null || end == null) return defaults; + restored[type] = ProductMealPeriod( + hourType: type, + startMinutes: start, + endMinutes: end, + ); + } + if (restored.length != configurableHourTypes.length) return defaults; + final periods = configurableHourTypes + .map((type) => restored[type]!) + .toList(growable: false); + return validate(periods) == null + ? ProductMealPeriodSchedule._(periods) + : defaults; + } + + /// 仅基础三餐参与时间配置,并保持与现有枚举一致的展示顺序。 + static const configurableHourTypes = [ + ProductHourType.MORNING, + ProductHourType.NOON, + ProductHourType.NIGHT, + ]; + + /// 创建经过校验的配置;页面保存前也使用同一条规则。 + factory ProductMealPeriodSchedule.checked(List periods) { + final error = validate(periods); + if (error != null) throw ArgumentError.value(periods, 'periods', error); + return ProductMealPeriodSchedule._(periods); + } + + /// 返回首条用户可读错误;允许餐段之间留空档,但不允许乱序、交叉或越过当天边界。 + static String? validate(List periods) { + if (periods.length != configurableHourTypes.length) { + return '请完整配置早餐、中餐和晚餐'; + } + for (var index = 0; index < configurableHourTypes.length; index++) { + final period = periods[index]; + final expectedType = configurableHourTypes[index]; + if (period.hourType != expectedType) return '餐段顺序必须为早餐、中餐、晚餐'; + if (period.startMinutes < 0 || period.startMinutes >= 24 * 60) { + return '${period.hourType.title}开始时间必须在 00:00 至 23:59 之间'; + } + if (period.endMinutes <= 0 || period.endMinutes > 24 * 60) { + return '${period.hourType.title}结束时间必须在 00:01 至 24:00 之间'; + } + if (period.startMinutes % 30 != 0 || period.endMinutes % 30 != 0) { + return '${period.hourType.title}时间必须按 30 分钟递增'; + } + if (period.startMinutes >= period.endMinutes) { + return '${period.hourType.title}结束时间必须晚于开始时间'; + } + if (index > 0 && periods[index - 1].endMinutes > period.startMinutes) { + return '${periods[index - 1].hourType.title}与${period.hourType.title}时间范围不能重叠'; + } + } + return null; + } + + /// 按本地当前时间解析基础餐段;处于未配置空档时返回空,而不是“查询全部”。 + ProductHourType? resolve(DateTime time) { + final minutes = time.hour * 60 + time.minute; + for (final period in periods) { + if (period.containsMinutes(minutes)) return period.hourType; + } + return null; + } + + List> toJson() => + periods.map((period) => period.toJson()).toList(growable: false); + + static int? _asInt(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); + } +} diff --git a/lib/common/models/page/setting_page_enum_models.dart b/lib/common/models/page/setting_page_enum_models.dart index cf2cb68..31afadd 100644 --- a/lib/common/models/page/setting_page_enum_models.dart +++ b/lib/common/models/page/setting_page_enum_models.dart @@ -5,6 +5,7 @@ import 'package:pj_supermarket_cloud/common/index.dart'; enum SettingMenuType implements BaseEnum { baseInfo("10", "基本信息"), syncSetting("20", "同步设置"), + mealPeriodSetting("25", "餐段配置"), paySetting("30", "支付管理"), backup("40", "资源备份"), faceSetting("50", "人脸管理"), diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart index fa2a007..a2d8cac 100644 --- a/lib/common/models/plugs/face_discern_models.dart +++ b/lib/common/models/plugs/face_discern_models.dart @@ -106,6 +106,7 @@ class FaceRecognitionConfiguration { required this.enableImageQuality, required this.imageQualityNoMaskRecognizeThreshold, required this.imageQualityMaskRecognizeThreshold, + required this.libraryFaceQualityThreshold, required this.keepMaxFace, required this.maxDetectFaces, required this.extractRetryCount, @@ -122,6 +123,7 @@ class FaceRecognitionConfiguration { enableImageQuality = true, imageQualityNoMaskRecognizeThreshold = 0.49, imageQualityMaskRecognizeThreshold = 0.29, + libraryFaceQualityThreshold = 0.60, keepMaxFace = true, maxDetectFaces = 1, extractRetryCount = 3, @@ -136,6 +138,9 @@ class FaceRecognitionConfiguration { final bool enableImageQuality; final double imageQualityNoMaskRecognizeThreshold; final double imageQualityMaskRecognizeThreshold; + + /// 在库照片诊断阈值;只决定低质量列表分类,不阻止人脸同步或参与识别。 + final double libraryFaceQualityThreshold; final bool keepMaxFace; final int maxDetectFaces; final int extractRetryCount; @@ -164,6 +169,10 @@ class FaceRecognitionConfiguration { map['imageQualityMaskRecognizeThreshold'], defaults.imageQualityMaskRecognizeThreshold, ), + libraryFaceQualityThreshold: _asDouble( + map['libraryFaceQualityThreshold'], + defaults.libraryFaceQualityThreshold, + ).clamp(0.0, 1.0).toDouble(), keepMaxFace: _asBoolOr(map['keepMaxFace'], defaults.keepMaxFace), maxDetectFaces: _asIntOr(map['maxDetectFaces'], defaults.maxDetectFaces), extractRetryCount: @@ -191,12 +200,15 @@ class FaceRecognitionConfiguration { imageQualityNoMaskRecognizeThreshold, 'imageQualityMaskRecognizeThreshold': imageQualityMaskRecognizeThreshold, + 'libraryFaceQualityThreshold': + libraryFaceQualityThreshold.clamp(0.0, 1.0).toDouble(), 'keepMaxFace': keepMaxFace, 'maxDetectFaces': maxDetectFaces, 'extractRetryCount': extractRetryCount, 'livenessRetryCount': livenessRetryCount, 'recognizeFailedRetryInterval': recognizeFailedRetryInterval, 'livenessFailedRetryInterval': livenessFailedRetryInterval, + 'livenessType': livenessType, }; bool get usesDefaults => @@ -209,6 +221,7 @@ class FaceRecognitionConfiguration { bool? enableImageQuality, double? imageQualityNoMaskRecognizeThreshold, double? imageQualityMaskRecognizeThreshold, + double? libraryFaceQualityThreshold, bool? keepMaxFace, int? maxDetectFaces, int? extractRetryCount, @@ -226,6 +239,10 @@ class FaceRecognitionConfiguration { this.imageQualityNoMaskRecognizeThreshold, imageQualityMaskRecognizeThreshold: imageQualityMaskRecognizeThreshold ?? this.imageQualityMaskRecognizeThreshold, + libraryFaceQualityThreshold: + (libraryFaceQualityThreshold ?? this.libraryFaceQualityThreshold) + .clamp(0.0, 1.0) + .toDouble(), keepMaxFace: keepMaxFace ?? this.keepMaxFace, maxDetectFaces: maxDetectFaces ?? this.maxDetectFaces, extractRetryCount: extractRetryCount ?? this.extractRetryCount, @@ -250,6 +267,7 @@ class FaceRecognitionConfiguration { other.imageQualityNoMaskRecognizeThreshold && imageQualityMaskRecognizeThreshold == other.imageQualityMaskRecognizeThreshold && + libraryFaceQualityThreshold == other.libraryFaceQualityThreshold && keepMaxFace == other.keepMaxFace && maxDetectFaces == other.maxDetectFaces && extractRetryCount == other.extractRetryCount && @@ -266,6 +284,7 @@ class FaceRecognitionConfiguration { enableImageQuality, imageQualityNoMaskRecognizeThreshold, imageQualityMaskRecognizeThreshold, + libraryFaceQualityThreshold, keepMaxFace, maxDetectFaces, extractRetryCount, @@ -330,6 +349,9 @@ class FaceFeatureExtractionResult { required this.success, required this.message, required this.featureData, + this.qualityStatus = FaceQualityStatus.pending, + this.qualityScore, + this.qualityErrorCode, }); /// 从 MethodChannel 返回映射创建特征提取结果。 @@ -341,6 +363,11 @@ class FaceFeatureExtractionResult { featureData: rawFeature is List ? rawFeature.map((item) => (item as num).toInt()).toList() : const [], + qualityStatus: FaceQualityStatus.fromCode( + map['qualityStatus']?.toString(), + ), + qualityScore: (map['qualityScore'] as num?)?.toDouble(), + qualityErrorCode: (map['qualityErrorCode'] as num?)?.toInt(), ); } @@ -352,6 +379,179 @@ class FaceFeatureExtractionResult { /// 虹软注册特征字节;仅在进程内传递给增量应用通道。 final List featureData; + + /// 静态图片质量检测状态;失败不改变 [success] 的特征提取语义。 + final FaceQualityStatus qualityStatus; + + /// 虹软图片质量分,范围为 0 到 1;未检测或检测失败时为空。 + final double? qualityScore; + + /// 虹软质量检测错误码;插件内部错误使用负值,成功时为空。 + final int? qualityErrorCode; +} + +/// 在库人脸质量状态的跨端持久化协议。 +enum FaceQualityStatus { + /// 历史数据尚未执行质量检测。 + pending('pending'), + + /// 虹软已返回有效的 0 到 1 质量分。 + detected('detected'), + + /// 图片、授权或 SDK 异常导致本次未取得有效质量分。 + failed('failed'); + + /// 创建一个数据库和 MethodChannel 共用的稳定状态值。 + const FaceQualityStatus(this.code); + + /// 写入 SQLite 和原生通道的稳定编码,不依赖 Dart 枚举名称。 + final String code; + + /// 将未知或空状态兼容为待检测,避免旧版本数据被误判为低质量。 + static FaceQualityStatus fromCode(String? code) { + return FaceQualityStatus.values.firstWhere( + (value) => value.code == code, + orElse: () => FaceQualityStatus.pending, + ); + } +} + +/// 提交给 Android 原生批量检测的一条本地图片引用。 +class FaceQualityAuditItem { + /// 创建一条仅在进程内传递的样本与本地文件映射。 + const FaceQualityAuditItem({ + required this.sampleId, + required this.filePath, + }); + + /// 服务端同步到终端的虹软样本编号。 + final String sampleId; + + /// 终端私有存储中的图片绝对路径;只传给本机原生层,不写日志或上报。 + final String filePath; + + /// 转换为 StandardMessageCodec 支持的原生参数。 + Map toMap() => { + 'sampleId': sampleId, + 'filePath': filePath, + }; +} + +/// 原生批量质量检测中的单条结果。 +class FaceImageQualityResult { + /// 创建一条只包含样本编号和诊断信息的质量结果。 + const FaceImageQualityResult({ + required this.sampleId, + required this.status, + required this.message, + this.score, + this.errorCode, + }); + + /// 从 Android StandardMessageCodec 映射解析单条结果。 + factory FaceImageQualityResult.fromMap(Map map) => + FaceImageQualityResult( + sampleId: map['sampleId']?.toString() ?? '', + status: FaceQualityStatus.fromCode(map['status']?.toString()), + message: map['message']?.toString() ?? '图片质量检测失败', + score: (map['score'] as num?)?.toDouble(), + errorCode: (map['errorCode'] as num?)?.toInt(), + ); + + /// 服务端同步到终端的虹软样本编号。 + final String sampleId; + + /// 本次图片质量检测状态。 + final FaceQualityStatus status; + + /// 不包含文件路径和人员信息的安全结果摘要。 + final String message; + + /// 虹软质量分,检测成功时范围为 0 到 1。 + final double? score; + + /// 虹软或插件内部错误码,检测成功时为空。 + final int? errorCode; +} + +/// 原生批量质量检测的实时进度。 +class FaceQualityAuditProgress { + /// 创建一份可供设置页展示的批量检测进度快照。 + const FaceQualityAuditProgress({ + required this.processed, + required this.total, + required this.successCount, + required this.failedCount, + required this.inProgress, + required this.message, + this.currentSampleId, + }); + + /// 从质量检测事件通道解析进度快照。 + factory FaceQualityAuditProgress.fromMap(Map map) => + FaceQualityAuditProgress( + processed: (map['processed'] as num?)?.toInt() ?? 0, + total: (map['total'] as num?)?.toInt() ?? 0, + successCount: (map['successCount'] as num?)?.toInt() ?? 0, + failedCount: (map['failedCount'] as num?)?.toInt() ?? 0, + inProgress: map['inProgress'] == true, + message: map['message']?.toString() ?? '等待检测', + currentSampleId: map['currentSampleId']?.toString(), + ); + + /// 已完成检测的样本数量。 + final int processed; + + /// 本轮提交给原生层的样本总数。 + final int total; + + /// 已取得有效质量分的样本数量。 + final int successCount; + + /// 未取得有效质量分的样本数量。 + final int failedCount; + + /// 原生批量任务是否仍持有人脸操作互斥锁。 + final bool inProgress; + + /// 当前阶段的安全展示文案。 + final String message; + + /// 当前处理样本编号;空值表示尚未开始或已经结束。 + final String? currentSampleId; + + /// 供进度条使用的 0 到 1 比例,空任务视为 0。 + double get fraction => + total <= 0 ? 0.0 : (processed / total).clamp(0.0, 1.0).toDouble(); +} + +/// 原生批量质量检测完成结果。 +class FaceQualityAuditResult { + /// 创建一轮不可变的质量检测结果集合。 + const FaceQualityAuditResult({ + required this.results, + required this.message, + }); + + /// 从 MethodChannel 返回映射解析全部单条结果。 + factory FaceQualityAuditResult.fromMap(Map map) { + final rawResults = map['results']; + return FaceQualityAuditResult( + results: rawResults is List + ? rawResults + .whereType() + .map(FaceImageQualityResult.fromMap) + .toList(growable: false) + : const [], + message: map['message']?.toString() ?? '在库人脸质量检测完成', + ); + } + + /// 与请求样本一一对应的检测结果。 + final List results; + + /// 本轮检测完成后的安全摘要。 + final String message; } /// 原生单样本人脸增删结果。 @@ -490,7 +690,8 @@ class FaceRegistrationProgress { final bool inProgress; final int? currentSampleId; - double get fraction => total <= 0 ? 0 : (processed / total).clamp(0, 1); + double get fraction => + total <= 0 ? 0.0 : (processed / total).clamp(0.0, 1.0).toDouble(); factory FaceRegistrationProgress.fromMap(Map map) { return FaceRegistrationProgress( diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index 0ff8e67..b035ab1 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -48,6 +48,9 @@ class RouteNames { /// 设置模块的同步任务子路由。 static const settingSync = '$setting/sync'; + /// 设置模块的早餐、中餐和晚餐时间配置子路由。 + static const settingMealPeriods = '$setting/meal-periods'; + /// 设置模块的资源备份子路由。 static const settingBackup = '$setting/backup'; diff --git a/lib/common/services/database/dao/face_dao.dart b/lib/common/services/database/dao/face_dao.dart index 6e5644c..dfc16d1 100644 --- a/lib/common/services/database/dao/face_dao.dart +++ b/lib/common/services/database/dao/face_dao.dart @@ -49,6 +49,59 @@ abstract class FaceDao { """) Future findCount(String userName); + /// 统计已完成质量检测且低于指定阈值的人脸数量,阈值不写入样本以支持即时重分类。 + @Query(''' + SELECT COUNT(0) FROM face + WHERE qualityStatus = 'detected' + AND qualityScore IS NOT NULL + AND qualityScore < :threshold + AND (:userName = '-1' OR userName LIKE '%' || :userName || '%') + ''') + Future findLowQualityCount(String userName, double threshold); + + /// 分页查询已检测且低于阈值的人脸,结果按本地自增编号稳定排序。 + @Query(''' + SELECT * FROM face + WHERE qualityStatus = 'detected' + AND qualityScore IS NOT NULL + AND qualityScore < :threshold + AND (:userName = '-1' OR userName LIKE '%' || :userName || '%') + ORDER BY id ASC + LIMIT :pageSize OFFSET :offset + ''') + Future> findLowQuality( + String userName, + double threshold, + int pageSize, + int offset, + ); + + /// 查询全部已检测且低于阈值的人脸,用于一键删除前获取完整服务端删除集合。 + @Query(''' + SELECT * FROM face + WHERE qualityStatus = 'detected' + AND qualityScore IS NOT NULL + AND qualityScore < :threshold + ORDER BY id ASC + ''') + Future> findAllLowQuality(double threshold); + + /// 统计尚未完成质量检测的历史样本数量。 + @Query("SELECT COUNT(0) FROM face WHERE qualityStatus = 'pending'") + Future findPendingQualityCount(); + + /// 统计最近一次质量检测失败的样本数量。 + @Query("SELECT COUNT(0) FROM face WHERE qualityStatus = 'failed'") + Future findFailedQualityCount(); + + /// 查询待检测或失败后重试的本地样本,按编号稳定排序。 + @Query(""" + SELECT * FROM face + WHERE qualityStatus IN ('pending', 'failed') + ORDER BY id ASC + """) + Future> findQualityAuditCandidates(); + @Query(''' SELECT * FROM face WHERE id = :id ''') diff --git a/lib/common/services/database/database.dart b/lib/common/services/database/database.dart index 8dd6d2f..4f6a47d 100644 --- a/lib/common/services/database/database.dart +++ b/lib/common/services/database/database.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:floor/floor.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; @@ -49,7 +48,7 @@ const typeConverters = [ ]; /// 收银终端共享 SQLite 数据库入口,统一暴露各业务 DAO 和带迁移的建库方法。 -@Database(version: 11, entities: entities) +@Database(version: 12, entities: entities) @TypeConverters(typeConverters) abstract class AppDatabase extends FloorDatabase { /// 取餐广播队列 DAO。 diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 5dc07de..84f6377 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -112,7 +112,7 @@ class _$AppDatabase extends AppDatabase { Callback? callback, ]) async { final databaseOptions = sqflite.OpenDatabaseOptions( - version: 11, + version: 12, onConfigure: (database) async { await database.execute('PRAGMA foreign_keys = ON'); await callback?.onConfigure?.call(database); @@ -152,7 +152,7 @@ class _$AppDatabase extends AppDatabase { await database.execute( 'CREATE TABLE IF NOT EXISTS `user_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `nickname` TEXT NOT NULL, `mobile` TEXT, `avatar` TEXT, `walletCardNumber` TEXT, `walletId` TEXT, `walletTypeId` TEXT, `walletBalance` INTEGER, `canteenId` TEXT, `discountsList` TEXT, `discountsUpdateTime` TEXT, `createTime` TEXT, `updateTime` TEXT)'); await database.execute( - 'CREATE TABLE IF NOT EXISTS `face` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `sampleId` TEXT NOT NULL, `userName` TEXT NOT NULL, `deptId` TEXT, `deptName` TEXT, `imagePath` TEXT NOT NULL, `path` TEXT, `faceModel` TEXT, `createTime` TEXT, `updateTime` TEXT)'); + 'CREATE TABLE IF NOT EXISTS `face` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `sampleId` TEXT NOT NULL, `userName` TEXT NOT NULL, `deptId` TEXT, `deptName` TEXT, `imagePath` TEXT NOT NULL, `path` TEXT, `faceModel` TEXT, `createTime` TEXT, `updateTime` TEXT, `qualityScore` REAL, `qualityStatus` TEXT NOT NULL, `qualityCheckedAt` TEXT, `qualityErrorCode` INTEGER)'); await database.execute( 'CREATE TABLE IF NOT EXISTS `setting` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userJson` TEXT NOT NULL, `sysJson` TEXT NOT NULL, `canteenExtraPriceConfigJson` TEXT)'); await database.execute( @@ -1864,7 +1864,12 @@ class _$FaceDao extends FaceDao { 'path': item.path, 'faceModel': _listIntConverter.encode(item.faceModel), 'createTime': _dateTimeEmptyConverter.encode(item.createTime), - 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime), + 'qualityScore': item.qualityScore, + 'qualityStatus': item.qualityStatus, + 'qualityCheckedAt': + _dateTimeEmptyConverter.encode(item.qualityCheckedAt), + 'qualityErrorCode': item.qualityErrorCode }), _faceEntityUpdateAdapter = UpdateAdapter( database, @@ -1881,7 +1886,12 @@ class _$FaceDao extends FaceDao { 'path': item.path, 'faceModel': _listIntConverter.encode(item.faceModel), 'createTime': _dateTimeEmptyConverter.encode(item.createTime), - 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime), + 'qualityScore': item.qualityScore, + 'qualityStatus': item.qualityStatus, + 'qualityCheckedAt': + _dateTimeEmptyConverter.encode(item.qualityCheckedAt), + 'qualityErrorCode': item.qualityErrorCode }), _faceEntityDeletionAdapter = DeletionAdapter( database, @@ -1898,7 +1908,12 @@ class _$FaceDao extends FaceDao { 'path': item.path, 'faceModel': _listIntConverter.encode(item.faceModel), 'createTime': _dateTimeEmptyConverter.encode(item.createTime), - 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime), + 'qualityScore': item.qualityScore, + 'qualityStatus': item.qualityStatus, + 'qualityCheckedAt': + _dateTimeEmptyConverter.encode(item.qualityCheckedAt), + 'qualityErrorCode': item.qualityErrorCode }); final sqflite.DatabaseExecutor database; @@ -1944,6 +1959,77 @@ class _$FaceDao extends FaceDao { arguments: [userName]); } + @override + Future findLowQualityCount( + String userName, + double threshold, + ) async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?2 AND (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\')', + mapper: (Map row) => row.values.first as int, + arguments: [userName, threshold]); + } + + @override + Future> findLowQuality( + String userName, + double threshold, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?2 AND (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\') ORDER BY id ASC LIMIT ?3 OFFSET ?4', + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String), qualityErrorCode: row['qualityErrorCode'] as int?), + arguments: [userName, threshold, pageSize, offset]); + } + + @override + Future> findAllLowQuality(double threshold) async { + return _queryAdapter.queryList( + 'SELECT * FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?1 ORDER BY id ASC', + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String), qualityErrorCode: row['qualityErrorCode'] as int?), + arguments: [threshold]); + } + + @override + Future findPendingQualityCount() async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM face WHERE qualityStatus = \'pending\'', + mapper: (Map row) => row.values.first as int); + } + + @override + Future findFailedQualityCount() async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM face WHERE qualityStatus = \'failed\'', + mapper: (Map row) => row.values.first as int); + } + + @override + Future> findQualityAuditCandidates() async { + return _queryAdapter.queryList( + 'SELECT * FROM face WHERE qualityStatus IN (\'pending\', \'failed\') ORDER BY id ASC', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String), + qualityErrorCode: row['qualityErrorCode'] as int?)); + } + @override Future getById(int id) async { return _queryAdapter.query('SELECT * FROM face WHERE id = ?1', @@ -1960,7 +2046,12 @@ class _$FaceDao extends FaceDao { createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String)), + _dateTimeEmptyConverter.decode(row['updateTime'] as String), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String), + qualityErrorCode: row['qualityErrorCode'] as int?), arguments: [id]); } @@ -1980,7 +2071,12 @@ class _$FaceDao extends FaceDao { createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String)), + _dateTimeEmptyConverter.decode(row['updateTime'] as String), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String), + qualityErrorCode: row['qualityErrorCode'] as int?), arguments: [sampleId]); } @@ -2000,7 +2096,12 @@ class _$FaceDao extends FaceDao { createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String)), + _dateTimeEmptyConverter.decode(row['updateTime'] as String), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String), + qualityErrorCode: row['qualityErrorCode'] as int?), arguments: [userId]); } @@ -2012,7 +2113,7 @@ class _$FaceDao extends FaceDao { ) async { return _queryAdapter.queryList( 'SELECT * FROM face WHERE (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\') LIMIT ?2 OFFSET ?3', - mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String)), + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String), qualityErrorCode: row['qualityErrorCode'] as int?), arguments: [userName, pageSize, offset]); } @@ -2032,7 +2133,12 @@ class _$FaceDao extends FaceDao { createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String))); + _dateTimeEmptyConverter.decode(row['updateTime'] as String), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String), + qualityErrorCode: row['qualityErrorCode'] as int?)); } @override @@ -2059,7 +2165,12 @@ class _$FaceDao extends FaceDao { createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String))); + _dateTimeEmptyConverter.decode(row['updateTime'] as String), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String), + qualityErrorCode: row['qualityErrorCode'] as int?)); } @override diff --git a/lib/common/services/database/database_migration.dart b/lib/common/services/database/database_migration.dart index 80a613e..11b153f 100644 --- a/lib/common/services/database/database_migration.dart +++ b/lib/common/services/database/database_migration.dart @@ -351,6 +351,41 @@ abstract class DatabaseMigration { } }); + /// 版本12为本地人脸增加质量诊断结果;全部字段可空或带默认值,不影响历史识别数据。 + static Migration migration11To12 = Migration(11, 12, (database) async { + final columns = await database.rawQuery('PRAGMA table_info(face)'); + final names = columns.map((column) => column['name']).toSet(); + if (!names.contains('qualityScore')) { + await database.execute('ALTER TABLE face ADD COLUMN qualityScore REAL'); + } + if (!names.contains('qualityStatus')) { + await database.execute( + "ALTER TABLE face ADD COLUMN qualityStatus TEXT NOT NULL DEFAULT 'pending'", + ); + } + if (!names.contains('qualityCheckedAt')) { + await database.execute( + 'ALTER TABLE face ADD COLUMN qualityCheckedAt TEXT', + ); + } + if (!names.contains('qualityErrorCode')) { + await database.execute( + 'ALTER TABLE face ADD COLUMN qualityErrorCode INTEGER', + ); + } + await database.execute( + "UPDATE face SET qualityStatus = 'pending' WHERE qualityStatus IS NULL OR qualityStatus NOT IN ('pending', 'detected', 'failed')", + ); + // 项目现有 DateTimeEmptyConverter 以空字符串表示业务空值;迁移旧记录时同步规范化, + // 避免 Floor 生成代码把 SQLite NULL 强转为 String 后读取失败。 + await database.execute( + "UPDATE face SET qualityCheckedAt = '' WHERE qualityCheckedAt IS NULL", + ); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_face_qualityStatus_qualityScore ON face(qualityStatus, qualityScore)', + ); + }); + /// 按起始版本升序返回全部迁移,Floor 打开旧数据库时据此连续升级到当前版本。 static List getDatabaseMigrations() { return [ @@ -364,6 +399,7 @@ abstract class DatabaseMigration { migration8To9, migration9To10, migration10To11, + migration11To12, ]; } } diff --git a/lib/common/services/database/entity/face_entity.dart b/lib/common/services/database/entity/face_entity.dart index 0cdfaa0..15404fc 100644 --- a/lib/common/services/database/entity/face_entity.dart +++ b/lib/common/services/database/entity/face_entity.dart @@ -41,6 +41,18 @@ class FaceEntity extends Equatable { /// 同步时间 DateTime? updateTime; + /// 最近一次在库图片质量分,范围为0到1;未检测或检测失败时为空。 + double? qualityScore; + + /// 质量检测状态,持久化值为pending、detected或failed。 + String qualityStatus; + + /// 最近一次质量检测完成时间;从未检测时为空。 + DateTime? qualityCheckedAt; + + /// 最近一次质量检测的虹软或插件错误码;成功时为空。 + int? qualityErrorCode; + FaceEntity({ this.id, required this.userId, @@ -53,6 +65,10 @@ class FaceEntity extends Equatable { this.faceModel, this.createTime, this.updateTime, + this.qualityScore, + this.qualityStatus = 'pending', + this.qualityCheckedAt, + this.qualityErrorCode, }); factory FaceEntity.fromJson(Map json) => diff --git a/lib/common/services/database/entity/face_entity.g.dart b/lib/common/services/database/entity/face_entity.g.dart index b9816fa..1900109 100644 --- a/lib/common/services/database/entity/face_entity.g.dart +++ b/lib/common/services/database/entity/face_entity.g.dart @@ -22,6 +22,12 @@ FaceEntity _$FaceEntityFromJson(Map json) => FaceEntity( updateTime: json['updateTime'] == null ? null : DateTime.parse(json['updateTime'] as String), + qualityScore: (json['qualityScore'] as num?)?.toDouble(), + qualityStatus: json['qualityStatus'] as String? ?? 'pending', + qualityCheckedAt: json['qualityCheckedAt'] == null + ? null + : DateTime.parse(json['qualityCheckedAt'] as String), + qualityErrorCode: (json['qualityErrorCode'] as num?)?.toInt(), ); Map _$FaceEntityToJson(FaceEntity instance) => @@ -37,4 +43,8 @@ Map _$FaceEntityToJson(FaceEntity instance) => 'faceModel': instance.faceModel, 'createTime': instance.createTime?.toIso8601String(), 'updateTime': instance.updateTime?.toIso8601String(), + 'qualityScore': instance.qualityScore, + 'qualityStatus': instance.qualityStatus, + 'qualityCheckedAt': instance.qualityCheckedAt?.toIso8601String(), + 'qualityErrorCode': instance.qualityErrorCode, }; diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 9986a56..6915bdc 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -68,6 +68,9 @@ abstract class AppSettingTool { "reservationQuerySelectedMealSlot"; static const String _reservationQueryVerificationDateScopeKey = "reservationQueryVerificationDateScope"; + + /// 早餐、中餐和晚餐的本地时间范围;值使用现有 ProductHourType 后端编码。 + static const String _productMealPeriodsKey = "productMealPeriods"; static const String _serialCardConfigKey = "serialCardConfig"; /// v8 之前短暂保存定时任务配置的旧 sysJson 键,仅供一次性迁移读取。 @@ -256,6 +259,20 @@ abstract class AppSettingTool { await _setSetting(sysJson: sysInfo); } + /// 保存全局餐段选择到 SysSetting;不新增字段,兼容所有已有终端缓存。 + static Future setSelectedProductHourType(ProductHourType hourType) => + _serializeSettingWrite(() async { + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + final setting = await settingDao.get(); + if (setting == null) throw StateError('终端设置尚未初始化'); + final sysJson = Map.from(json.decode(setting.sysJson)); + sysJson['hourType'] = hourType.name; + setting.sysJson = json.encode(sysJson); + await settingDao.edit(setting); + await _cacheSetting(setting); + }); + /// 串行更新 Root 专属键,始终在实际写入前重新读取 sysJson,避免后台检查覆盖设置页并发修改。 static Future setRootAccessResult(RootAccessCheckResult result) => _serializeSettingWrite(() async { @@ -476,6 +493,30 @@ abstract class AppSettingTool { sysJson[_cashierShowMoreActionKey] = showMoreAction; }); + /// 读取商品餐段时间;旧终端无配置或持久化值损坏时返回产品默认时间。 + static Future + getProductMealPeriodSchedule() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + return ProductMealPeriodSchedule.fromJson(sysJson[_productMealPeriodsKey]); + } + + /// 保存经过统一校验的基础三餐时间,不增加 setting 表字段和数据库迁移。 + static Future setProductMealPeriodSchedule( + ProductMealPeriodSchedule schedule, + ) => + _updateSysJson((sysJson) { + sysJson[_productMealPeriodsKey] = schedule.toJson(); + }); + + /// 按本机当前时间取得配置餐段;未覆盖时段返回空,调用方可保留人工选择。 + static Future getCurrentProductHourType({ + DateTime? now, + }) async { + final schedule = await getProductMealPeriodSchedule(); + return schedule.resolve(now ?? DateTime.now()); + } + /// 读取顶部需要隐藏的设备类型;缺键、异常值和未知编码均忽略,因此升级后默认五项全部显示。 static Future> getHiddenTerminalDeviceStatuses() async { diff --git a/lib/common/utils/app/control/product_hour_type_control.dart b/lib/common/utils/app/control/product_hour_type_control.dart new file mode 100644 index 0000000..d7b1dfc --- /dev/null +++ b/lib/common/utils/app/control/product_hour_type_control.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 应用级餐段状态;收银、查询菜单和其他餐段入口都复用同一个选择器与缓存值。 +class ProductHourTypeControl extends GetxController { + /// 创建默认选中早餐的全局餐段控制器;真正进入业务前由 [initAsync] 恢复本机缓存。 + ProductHourTypeControl({ + ProductHourType initial = ProductHourType.MORNING, + bool persistSelection = true, + }) : _persistSelection = persistSelection, + selected = initial.obs, + selection = FRadioSelectGroupController(value: initial.code) { + selection.addValueListener(_handleSelectionChanged); + } + + /// 当前全局餐段,供需要强类型值的业务逻辑使用。 + final Rx selected; + + /// 公共 UI 选择器使用的单选 Controller;页面不得自行创建餐段 Controller。 + final FRadioSelectGroupController selection; + + /// 首次缓存恢复任务;重复进入页面时复用同一个 Future,避免并发读取与重复迁移。 + Future? _initializationFuture; + + /// 内部同步强类型值与 Forui 选择器时暂时屏蔽监听器,防止重复持久化。 + bool _suppressPersistence = false; + + /// 是否把选择写入终端设置;独立单测实例可关闭,应用级实例始终开启。 + final bool _persistSelection; + + /// 当前餐段缓存恢复完成信号。 + Future get initialization => _initializationFuture ?? Future.value(); + + /// 读取 SysSetting 中缓存的餐段;旧版本没有缓存时尝试迁移查询菜单的历史选择。 + Future initAsync() { + return _initializationFuture ??= _initialize(); + } + + /// 执行一次缓存恢复,并兼容迁移旧版查询菜单保存的最近餐段。 + Future _initialize() async { + try { + final setting = await AppSettingTool.getSysSetting(); + var restored = setting.hourType; + if (restored == null) { + try { + final preferences = + await AppSettingTool.getReservationQueryPagePreferences(); + restored = ProductHourType.values.fromCode( + preferences.selectedMealSlot.code, + ); + } catch (_) { + // 旧配置没有查询菜单缓存时继续使用早餐默认值。 + } + } + if (restored != null && restored != ProductHourType.TOTAL) { + _setSelection(restored, persist: setting.hourType == null); + } + } catch (error, stack) { + logger.w('|餐段|恢复全局餐段缓存失败error:|$error|stack:|$stack|'); + } + } + + /// 由页面或公共组件请求切换餐段;可在初始化迁移时关闭持久化。 + void setSelected(ProductHourType next, {bool persist = true}) { + if (next == ProductHourType.TOTAL || selected.value == next) return; + _setSelection(next, persist: persist); + } + + /// 接收公共 Forui 选择器变化,立即更新全局值并异步写入本机缓存。 + void _handleSelectionChanged(Set values) { + if (_suppressPersistence) return; + final next = ProductHourType.values.fromCode(values.firstOrNull); + if (next == null || next == ProductHourType.TOTAL) return; + if (selected.value == next) return; + selected.value = next; + if (_persistSelection) { + unawaited(AppSettingTool.setSelectedProductHourType(next)); + } + } + + /// 原子同步全局强类型值和 UI 选择值,并按调用场景决定是否写入缓存。 + void _setSelection(ProductHourType next, {required bool persist}) { + _suppressPersistence = true; + try { + selected.value = next; + if (selection.value.firstOrNull != next.code) { + selection.value = {next.code}; + } + } finally { + _suppressPersistence = false; + } + if (persist && _persistSelection) { + unawaited(AppSettingTool.setSelectedProductHourType(next)); + } + } + + /// 应用级控制器被显式销毁时释放选择器监听与 Forui 资源。 + @override + void onClose() { + selection.removeValueListener(_handleSelectionChanged); + selection.dispose(); + super.onClose(); + } +} diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart index b2f7ab8..09ca6cc 100644 --- a/lib/common/utils/index.dart +++ b/lib/common/utils/index.dart @@ -2,6 +2,7 @@ library; export './app/control/app_control.dart'; export './app/control/app_theme_control.dart'; +export './app/control/product_hour_type_control.dart'; export './app/control/terminal_admin_session_control.dart'; export './app/control/terminal_device_status_control.dart'; export './app/control/terminal_menu_control.dart'; diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart index ce2b65c..83cec4b 100644 --- a/lib/common/utils/plugs/face_discern_plugs.dart +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -23,12 +23,20 @@ abstract class FaceDiscernPlugs { Constants.facePlugsRegistrationEventChannel, ); + /// 原生在库图片质量批量检测进度通道。 + static const EventChannel _qualityAuditEventChannel = EventChannel( + Constants.facePlugsQualityAuditEventChannel, + ); + static MethodChannel get getChannel => _channel; static Stream? _stream; static Stream? _sessionStream; static Stream? _registrationStream; + /// 延迟创建并复用的在库图片质量检测进度流。 + static Stream? _qualityAuditStream; + static Future jumpFaceDiscernPage() async { try { await _channel.invokeMethod('jumpFaceDiscern'); @@ -269,6 +277,23 @@ abstract class FaceDiscernPlugs { ); } + /// 在原生人脸操作互斥边界内批量检测本地在库照片质量。 + /// + /// 文件路径仅传入本机 Android 插件;原生逐图解码并通过 [qualityAuditStream] + /// 报告进度,返回结果不会自动修改 Floor 数据库。 + static Future auditFaceQuality({ + required List items, + }) async { + final map = await _channel.invokeMethod('auditFaceQuality', { + 'items': items.map((item) => item.toMap()).toList(growable: false), + }); + if (map is Map) return FaceQualityAuditResult.fromMap(map); + return const FaceQualityAuditResult( + results: [], + message: '原生未返回有效的质量检测结果', + ); + } + /// 在虹软内存库中原子应用单样本新增、替换或删除。 /// /// 替换失败时原生层恢复旧特征;[operation] 仅允许 upsert 或 delete。 @@ -367,4 +392,14 @@ abstract class FaceDiscernPlugs { .map((event) => FaceRegistrationProgress.fromMap(event as Map)); return _registrationStream!; } + + /// 在库人脸质量检测进度广播流;订阅时会先收到原生最新状态。 + static Stream get qualityAuditStream { + _qualityAuditStream ??= _qualityAuditEventChannel + .receiveBroadcastStream() + .where((event) => event is Map) + .map((event) => FaceQualityAuditProgress.fromMap(event as Map)) + .asBroadcastStream(); + return _qualityAuditStream!; + } } diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index ee70baf..0aeff10 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -795,8 +795,12 @@ class SyncCheckTool { return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); } final setting = await AppSettingTool.getSysSetting(); + final configuredCurrentHourType = + await AppSettingTool.getCurrentProductHourType(); await SyncTakeFoodBroadcastTool.doCheckBroadcast( - setting.hourType ?? ProductHourType.TOTAL, + configuredCurrentHourType ?? + setting.hourType ?? + ProductHourType.TOTAL, ); return const _ScheduledTaskActionResult.success('取餐广播检查完成'); case ScheduledTaskType.deviceStatus: diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 5bd22aa..6e97b8b 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -322,12 +322,12 @@ abstract class SyncFaceTool { var databaseChanged = false; try { temporaryFile = await _downloadTemporaryFace(record, storagePath); - final featureData = AppRuntimeTool.useManualHardwareInput - ? [] - : (await FaceDiscernPlugs.extractFaceFeature( + final extraction = AppRuntimeTool.useManualHardwareInput + ? null + : await FaceDiscernPlugs.extractFaceFeature( filePath: temporaryFile.path, - )) - .featureData; + ); + final featureData = extraction?.featureData ?? []; if (!AppRuntimeTool.useManualHardwareInput && featureData.isEmpty) { throw const FaceSyncSnapshotException('图片未提取到可注册人脸'); } @@ -341,6 +341,8 @@ abstract class SyncFaceTool { existing: existing, path: finalFile.path, faceModel: featureData, + extraction: extraction, + imageReplaced: true, ); if (existing == null) { final id = await faceDao.save(entity); @@ -439,6 +441,128 @@ abstract class SyncFaceTool { } } + /// 通过与同步孤儿删除相同的可回滚流程清理一条已获服务端确认的人脸。 + /// + /// 调用方必须先完成服务端删除或取得“服务端已不存在”确认;失败时本方法恢复 + /// 图片、SQLite 和虹软内存特征,并返回 false 供页面保留重试入口。 + static Future deleteLocalFace(FaceEntity face) => + _deleteLocalFace(face, Get.find().dataBase.getFaceDao); + + /// 手动检测存量在库照片质量并将结果批量回写 SQLite。 + /// + /// [failedOnly] 为 true 时仅重试上次失败记录;不存在或不可读的本地文件会直接 + /// 标记为 failed,其他图片交给 Android 原生单线程质量任务处理。 + static Future auditLibraryFaceQuality({ + bool failedOnly = false, + }) async { + final faceDao = Get.find().dataBase.getFaceDao; + final allCandidates = await faceDao.findQualityAuditCandidates(); + final candidates = failedOnly + ? allCandidates + .where( + (face) => face.qualityStatus == FaceQualityStatus.failed.code, + ) + .toList(growable: false) + : allCandidates; + if (candidates.isEmpty) { + return const FaceQualityAuditResult( + results: [], + message: '没有需要检测的人脸', + ); + } + + final invalidResults = []; + final validFaces = []; + for (final face in candidates) { + final path = face.path; + if (path == null || path.isEmpty || !await File(path).exists()) { + invalidResults.add(FaceImageQualityResult( + sampleId: face.sampleId, + status: FaceQualityStatus.failed, + message: '本地人脸图片不存在', + errorCode: -2, + )); + } else { + validFaces.add(face); + } + } + + FaceQualityAuditResult nativeResult = const FaceQualityAuditResult( + results: [], + message: '在库人脸质量检测完成', + ); + if (validFaces.isNotEmpty) { + try { + nativeResult = await FaceDiscernPlugs.auditFaceQuality( + items: validFaces + .map( + (face) => FaceQualityAuditItem( + sampleId: face.sampleId, + filePath: face.path!, + ), + ) + .toList(growable: false), + ); + } catch (error, stack) { + logger.w('|$_tag|原生质量检测通道失败,将样本记录为检测失败|error:$error|stack:$stack|'); + nativeResult = FaceQualityAuditResult( + results: [ + for (final face in validFaces) + FaceImageQualityResult( + sampleId: face.sampleId, + status: FaceQualityStatus.failed, + message: '原生质量检测不可用', + errorCode: -99, + ), + ], + message: '原生质量检测不可用,样本已标记为检测失败', + ); + } + } + + final results = [ + ...invalidResults, + ...nativeResult.results, + ]; + final bySampleId = { + for (final result in results) result.sampleId: result, + }; + final checkedAt = DateTime.now(); + final updates = []; + for (final face in candidates) { + final result = bySampleId[face.sampleId] ?? + FaceImageQualityResult( + sampleId: face.sampleId, + status: FaceQualityStatus.failed, + message: '原生未返回该样本的质量结果', + errorCode: -99, + ); + face + ..qualityStatus = result.status.code + ..qualityScore = + result.status == FaceQualityStatus.detected ? result.score : null + ..qualityCheckedAt = checkedAt + ..qualityErrorCode = result.status == FaceQualityStatus.detected + ? null + : result.errorCode; + updates.add(face); + } + if (updates.isNotEmpty) await faceDao.batchEdit(updates); + return FaceQualityAuditResult( + results: [ + for (final face in candidates) + bySampleId[face.sampleId] ?? + FaceImageQualityResult( + sampleId: face.sampleId, + status: FaceQualityStatus.failed, + message: '原生未返回该样本的质量结果', + errorCode: -99, + ), + ], + message: nativeResult.message, + ); + } + /// 删除回滚时恢复旧内存特征;历史记录无特征时按用户从仍存在的 SQLite 重新装载。 static Future _restoreNativeFace(FaceEntity face) async { if (AppRuntimeTool.useManualHardwareInput) return; @@ -494,6 +618,8 @@ abstract class SyncFaceTool { required FaceEntity? existing, String? path, List? faceModel, + FaceFeatureExtractionResult? extraction, + bool imageReplaced = false, }) => FaceEntity( id: existing?.id, @@ -507,6 +633,29 @@ abstract class SyncFaceTool { faceModel: faceModel ?? existing?.faceModel, createTime: existing?.createTime ?? DateTime.now(), updateTime: record.sourceUpdatedAt ?? existing?.updateTime, + qualityScore: extraction == null + ? imageReplaced + ? null + : existing?.qualityScore + : extraction.qualityStatus == FaceQualityStatus.detected + ? extraction.qualityScore + : null, + qualityStatus: extraction?.qualityStatus.code ?? + (imageReplaced ? FaceQualityStatus.pending.code : null) ?? + existing?.qualityStatus ?? + FaceQualityStatus.pending.code, + qualityCheckedAt: extraction == null + ? imageReplaced + ? null + : existing?.qualityCheckedAt + : DateTime.now(), + qualityErrorCode: extraction == null + ? imageReplaced + ? null + : existing?.qualityErrorCode + : extraction.qualityStatus == FaceQualityStatus.detected + ? null + : extraction.qualityErrorCode, ); /// 删除指定本地文件;失败只记录文件路径以外的异常类型,避免同步主流程被清理动作中断。 diff --git a/lib/common/utils/sync/sync_take_food_broadcast_tool.dart b/lib/common/utils/sync/sync_take_food_broadcast_tool.dart index 03be004..fd5a121 100644 --- a/lib/common/utils/sync/sync_take_food_broadcast_tool.dart +++ b/lib/common/utils/sync/sync_take_food_broadcast_tool.dart @@ -37,8 +37,10 @@ abstract class SyncTakeFoodBroadcastTool { await broadcastDao.save(saveObj); logger.i("|$_tag|添加到广播记录到本地数据库,json:|${saveObj.toJson()}|"); final setting = await AppSettingTool.getSysSetting(); + final configuredCurrentHourType = + await AppSettingTool.getCurrentProductHourType(); await SyncTakeFoodBroadcastTool.doCheckBroadcast( - setting.hourType ?? ProductHourType.TOTAL, + configuredCurrentHourType ?? setting.hourType ?? ProductHourType.TOTAL, ); } diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index f7d213b..87aa10a 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -31,6 +31,10 @@ class Constants { static const facePlugsRegistrationEventChannel = 'local/face_discern_registration_event'; + /// 在库照片批量质量检测进度事件通道。 + static const facePlugsQualityAuditEventChannel = + 'local/face_discern_quality_audit_event'; + /// 数据库名字 static const databaseName = 'cash_register.db'; diff --git a/lib/common/widgets/app/app_radio_hour_type_dialog.dart b/lib/common/widgets/app/app_radio_hour_type_dialog.dart index 10f0d4b..4f30609 100644 --- a/lib/common/widgets/app/app_radio_hour_type_dialog.dart +++ b/lib/common/widgets/app/app_radio_hour_type_dialog.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; @@ -7,6 +11,8 @@ import 'package:pj_supermarket_cloud/common/index.dart'; /// [title] 和 [enabled] 允许业务页面调整触发器文案及交互状态;[key] 用于定位具体业务入口。 /// [compact] 仅降低触发器的视觉权重,不缩小餐段选项的触控区域;查询菜单使用该模式, /// 收银、预订和设置页面继续使用原有的图标与强调色样式。 +/// [checkConfiguredTime] 开启后会在页面初始化完成时校验当前餐段;[ready] 用于等待调用方恢复自身状态。 +/// [currentHourTypeLoader] 允许调用方提供当前配置餐段读取器,未提供时读取本机餐段配置。 Widget appRadioHourTypeDialog( BuildContext context, FRadioSelectGroupController controller, { @@ -14,10 +20,48 @@ Widget appRadioHourTypeDialog( String title = "用餐时间", bool enabled = true, bool compact = false, + bool checkConfiguredTime = false, + Future? ready, + Future Function()? currentHourTypeLoader, +}) { + if (checkConfiguredTime) { + return _MealPeriodSelectionGuard( + controller: controller, + ready: ready, + currentHourTypeLoader: currentHourTypeLoader, + selectorBuilder: (context, candidateController) => _buildHourTypeSelector( + context, + candidateController, + key: key, + title: title, + enabled: enabled, + compact: compact, + ), + ); + } + return _buildHourTypeSelector( + context, + controller, + key: key, + title: title, + enabled: enabled, + compact: compact, + ); +} + +/// 使用指定单选 Controller 构建餐段菜单;时间校验模式会传入尚未提交的候选 Controller。 +Widget _buildHourTypeSelector( + BuildContext context, + FRadioSelectGroupController controller, { + Key? key, + required String title, + required bool enabled, + required bool compact, }) { final theme = FTheme.of(context); final list = ProductHourType.values.where((v) => v.code != "-1"); - final compactTriggerStyle = _withMinimumTouchPadding( + final compactTriggerStyle = _withToolbarHeight( + context, theme.selectMenuTileStyle.tileStyle, ); final compactOptionStyle = _withMinimumTouchPadding( @@ -94,3 +138,262 @@ FTileStyle _withMinimumTouchPadding(FTileStyle style) { ), ); } + +/// 让紧凑餐段入口与搜索、重置等工具栏控件共享主题按钮高度。 +FTileStyle _withToolbarHeight(BuildContext context, FTileStyle style) { + final contentStyle = style.contentStyle; + final textPainter = TextPainter( + text: TextSpan( + text: '中餐', + style: contentStyle.enabledStyle.detailsTextStyle, + ), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), + )..layout(); + final iconHeight = contentStyle.enabledStyle.suffixIconStyle.size; + final contentHeight = math.max(textPainter.height, iconHeight).toDouble(); + final targetHeight = math.max( + XfjDesignTokens.controlHeight.h, + 48.0, + ); + final verticalPadding = math + .max( + 0, + (targetHeight - contentHeight) / 2, + ) + .toDouble(); + textPainter.dispose(); + return style.copyWith( + contentStyle: contentStyle.copyWith( + padding: contentStyle.padding.copyWith( + top: verticalPadding, + bottom: verticalPadding, + ), + ), + ); +} + +/// 根据内部候选 Controller 构建餐段菜单,避免用户确认前改动应用级餐段。 +typedef _MealPeriodSelectorBuilder = Widget Function( + BuildContext context, + FRadioSelectGroupController candidateController, +); + +/// 先校验候选餐段再提交到应用级 Controller,页面间共用同一套确认规则。 +class _MealPeriodSelectionGuard extends StatefulWidget { + /// 创建餐段时间校验包装器;[ready] 完成前不会读取当前时间配置。 + const _MealPeriodSelectionGuard({ + required this.controller, + required this.selectorBuilder, + this.ready, + this.currentHourTypeLoader, + }); + + /// 已提交的应用级餐段 Controller;只有校验通过或用户确认后才会被修改。 + final FRadioSelectGroupController controller; + + /// 页面业务初始化信号;为空表示无需等待额外初始化。 + final Future? ready; + + /// 当前时间命中餐段的读取器;测试可注入固定结果。 + final Future Function()? currentHourTypeLoader; + + /// 使用内部候选 Controller 构建餐段菜单的回调。 + final _MealPeriodSelectorBuilder selectorBuilder; + + /// 创建负责异步校验与监听清理的状态对象。 + @override + State<_MealPeriodSelectionGuard> createState() => + _MealPeriodSelectionGuardState(); +} + +/// 管理候选餐段、配置校验、弹框确认和最终全局提交。 +class _MealPeriodSelectionGuardState extends State<_MealPeriodSelectionGuard> { + /// 菜单内部使用的候选选择器;用户点击不会直接触发页面刷新或缓存写入。 + late FRadioSelectGroupController _candidateController; + + /// 当前是否已有校验在执行,避免一次选择触发多个重叠弹框。 + bool _checking = false; + + /// 是否已经登记帧后校验任务,合并同一帧内的多次选择通知。 + bool _checkScheduled = false; + + /// 选择修订号;异步读取或弹框返回时用它丢弃过期结果。 + int _selectionRevision = 0; + + /// 最近一次尚未校验的人工候选餐段;新选择会覆盖尚未开始处理的旧选择。 + ProductHourType? _pendingCandidate; + + /// 内部回退候选显示时屏蔽候选监听,防止产生新的人工选择请求。 + bool _syncingCandidate = false; + + /// 校验通过后写入全局 Controller 时屏蔽外部变化复检,避免重复弹框。 + bool _applyingApprovedSelection = false; + + /// 创建候选 Controller、注册全局监听,并在首帧后校验缓存恢复出的初始餐段。 + @override + void initState() { + super.initState(); + _createCandidateController(); + widget.controller.addValueListener(_handleCommittedSelectionChanged); + _scheduleCheck(); + } + + /// Controller 或初始化 Future 变化时重建监听并重新校验当前选择。 + @override + void didUpdateWidget(covariant _MealPeriodSelectionGuard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller + .removeValueListener(_handleCommittedSelectionChanged); + _disposeCandidateController(); + _createCandidateController(); + widget.controller.addValueListener(_handleCommittedSelectionChanged); + _pendingCandidate = null; + _selectionRevision++; + _scheduleCheck(); + } else if (oldWidget.ready != widget.ready || + oldWidget.currentHourTypeLoader != widget.currentHourTypeLoader) { + _scheduleCheck(); + } + } + + /// 页面卸载时释放候选选择器并移除全局监听,防止隐藏页面继续弹出提示。 + @override + void dispose() { + widget.controller.removeValueListener(_handleCommittedSelectionChanged); + _disposeCandidateController(); + super.dispose(); + } + + /// 用当前已提交餐段创建内部候选 Controller,并注册人工选择监听。 + void _createCandidateController() { + _candidateController = FRadioSelectGroupController( + value: widget.controller.value.firstOrNull, + )..addValueListener(_handleCandidateSelectionChanged); + } + + /// 移除候选监听并释放 Forui Controller 资源。 + void _disposeCandidateController() { + _candidateController.removeValueListener(_handleCandidateSelectionChanged); + _candidateController.dispose(); + } + + /// 接收人工候选餐段,立即恢复已提交值的显示,再异步校验是否允许提交。 + void _handleCandidateSelectionChanged(Set values) { + if (_syncingCandidate) return; + final candidate = ProductHourType.values.fromCode(values.firstOrNull); + final committedCode = widget.controller.value.firstOrNull; + if (candidate == null || + candidate == ProductHourType.TOTAL || + candidate.code == committedCode) { + _syncCandidateFromCommitted(); + return; + } + _pendingCandidate = candidate; + _selectionRevision++; + _syncCandidateFromCommitted(); + _scheduleCheck(); + } + + /// 接收其他页面或业务代码提交的全局餐段,更新候选显示并重新检查时间配置。 + void _handleCommittedSelectionChanged(Set _) { + _syncCandidateFromCommitted(); + if (_applyingApprovedSelection) return; + _pendingCandidate = null; + _selectionRevision++; + _scheduleCheck(); + } + + /// 把候选显示恢复为当前全局值,确保弹框确认前页面仍展示并使用原餐段。 + void _syncCandidateFromCommitted() { + final committedCode = widget.controller.value.firstOrNull; + if (_candidateController.value.firstOrNull == committedCode) return; + _syncingCandidate = true; + try { + _candidateController.value = committedCode == null ? {} : {committedCode}; + } finally { + _syncingCandidate = false; + } + } + + /// 合并重复请求后登记一次帧后校验,避免在组件构建阶段打开弹框。 + void _scheduleCheck() { + if (_checkScheduled || !mounted) return; + _checkScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkScheduled = false; + unawaited(_checkSelection()); + }); + } + + /// 校验人工候选或当前缓存餐段;确认切换配置餐段,取消时保留人工选择。 + Future _checkSelection() async { + if (_checking || !mounted) return; + _checking = true; + final revision = _selectionRevision; + final candidate = _pendingCandidate; + _pendingCandidate = null; + try { + await widget.ready; + if (!mounted) return; + final selected = candidate ?? + ProductHourType.values.fromCode( + widget.controller.value.firstOrNull, + ); + if (selected == null || selected == ProductHourType.TOTAL) return; + final configured = await (widget.currentHourTypeLoader?.call() ?? + AppSettingTool.getCurrentProductHourType()); + if (!mounted || revision != _selectionRevision) return; + if (candidate != null && + (configured == null || configured == candidate)) { + _commitSelection(candidate); + return; + } + if (configured == null || configured == selected) return; + final shouldSwitch = await appMessageDialog( + context, + '当前选择的餐段可能有问题,按餐段配置当前时间应为“${configured.title}”,是否切换?', + ); + if (!mounted || revision != _selectionRevision) return; + if (shouldSwitch) { + _commitSelection(configured); + } else if (candidate != null) { + _commitSelection(candidate); + } + } catch (error, stack) { + logger.w('|餐段配置|校验当前餐段失败error:|$error|stack:|$stack|'); + } finally { + _checking = false; + if (mounted && + (_pendingCandidate != null || revision != _selectionRevision)) { + _scheduleCheck(); + } + } + } + + /// 把已通过校验的餐段提交到全局 Controller,此时页面刷新和缓存写入才会发生。 + void _commitSelection(ProductHourType hourType) { + if (widget.controller.value.firstOrNull == hourType.code) { + _syncCandidateFromCommitted(); + return; + } + _applyingApprovedSelection = true; + try { + widget.controller.value = {hourType.code}; + } finally { + _applyingApprovedSelection = false; + } + _syncCandidateFromCommitted(); + } + + /// 使用候选 Controller 构建菜单,使人工选择在确认前与全局业务状态隔离。 + @override + Widget build(BuildContext context) => + widget.selectorBuilder(context, _candidateController); +} diff --git a/lib/common/widgets/cashier/cashier_workspace.dart b/lib/common/widgets/cashier/cashier_workspace.dart index 2bf6f17..7ce2cfb 100644 --- a/lib/common/widgets/cashier/cashier_workspace.dart +++ b/lib/common/widgets/cashier/cashier_workspace.dart @@ -1724,15 +1724,9 @@ class _CashierButton extends StatelessWidget { borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - FIcon(icon!, size: 19.w, color: foreground), - SizedBox(width: 7.w), - ], - Text( + child: LayoutBuilder( + builder: (context, constraints) { + final text = Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -1741,8 +1735,21 @@ class _CashierButton extends StatelessWidget { color: foreground, fontWeight: FontWeight.w600, ), - ), - ], + ); + final hasBoundedWidth = constraints.hasBoundedWidth; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: + hasBoundedWidth ? MainAxisSize.max : MainAxisSize.min, + children: [ + if (icon != null) ...[ + FIcon(icon!, size: 19.w, color: foreground), + SizedBox(width: 7.w), + ], + if (hasBoundedWidth) Flexible(child: text) else text, + ], + ); + }, ), ), ), diff --git a/lib/main.dart b/lib/main.dart index a715298..c360cca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,10 @@ Future checkSubScreen() async { DartPluginRegistrant.ensureInitialized(); await AppRuntimeTool.initializePlatform(); final appTheme = Get.put(AppThemeControl()); + final productHourType = Get.put( + ProductHourTypeControl(), + permanent: true, + ); Get.put(TerminalAdminSessionControl(), permanent: true); // 睡眠状态需要跨终端工作区路由常驻,但不会进入副屏 Engine。 Get.put(TerminalSleepControl(), permanent: true); @@ -38,6 +42,7 @@ Future checkSubScreen() async { await Future.microtask(() => app.initAsync()); if (app.startupFinished.value) { await appTheme.initAsync(); + await productHourType.initAsync(); await terminalDeviceStatus.initAsync(); await terminalMenu.initAsync(); } diff --git a/lib/pages/root/load/view.dart b/lib/pages/root/load/view.dart index 8c3f1e4..9480b8b 100644 --- a/lib/pages/root/load/view.dart +++ b/lib/pages/root/load/view.dart @@ -227,20 +227,19 @@ class _StartupProgressPanel extends StatelessWidget { ), ), SizedBox(height: 18.h), - Column( - children: List.generate(controller.startupSteps.length, (index) { + ListView.separated( + primary: false, + padding: EdgeInsets.zero, + physics: const ClampingScrollPhysics(), + itemCount: controller.startupSteps.length, + separatorBuilder: (_, __) => SizedBox(height: 8.h), + itemBuilder: (_, index) { final item = controller.startupSteps[index]; - return Padding( - padding: EdgeInsets.only( - bottom: - index == controller.startupSteps.length - 1 ? 0 : 8.h, - ), - child: StartupStepCard( - icon: _stepIconAt(index), - state: item, - ), + return StartupStepCard( + icon: _stepIconAt(index), + state: item, ); - }), + }, ).expanded(), SizedBox(height: 12.h), _StartupNotice( diff --git a/lib/pages/terminal/canteen_product/controller.dart b/lib/pages/terminal/canteen_product/controller.dart index 8889cf6..f984e8d 100644 --- a/lib/pages/terminal/canteen_product/controller.dart +++ b/lib/pages/terminal/canteen_product/controller.dart @@ -20,6 +20,21 @@ class CanteenProductController extends GetxController with OrderMixin { StreamSubscription? mqUpdateListen; + /// 页面初始化任务;供餐段组件等待缓存、商品和购物车恢复完成。 + Future? _initializationFuture; + + /// 全局餐段选择监听句柄,页面关闭时仅移除监听,不销毁共享 Controller。 + late final ValueChanged> _hourTypeListener; + + /// 分类、商品和购物车完成首次加载后为 true,此后切换餐段才触发重载。 + bool _mealResourcesReady = false; + + /// 本机系统设置已读取后为 true,用于同步页面内仍依赖的设置快照。 + bool _appSettingReady = false; + + /// 页面完成餐段、商品和购物车初始化后再执行依赖当前选择的提示。 + Future get initialization => _initializationFuture ?? Future.value(); + final searchC = TextEditingController(); /// 食堂接口沿用原有整批商品查询,搜索只在当前餐段和分类结果内过滤, @@ -41,9 +56,17 @@ class CanteenProductController extends GetxController with OrderMixin { /// 套餐查询递增序号;关闭或重开抽屉都会推进,用于屏蔽过期异步结果。 int _comboDetailRequestId = 0; - final hourTypeC = FRadioSelectGroupController( - value: ProductHourType.MORNING.code, - ); + /// 获取应用级餐段控制器;非标准入口未预注册时补充永久注册以保持单一状态源。 + ProductHourTypeControl get _hourTypeControl { + if (!Get.isRegistered()) { + Get.put(ProductHourTypeControl(), permanent: true); + } + return Get.find(); + } + + /// 收银页复用的全局 Forui 餐段选择器。 + FRadioSelectGroupController get hourTypeC => + _hourTypeControl.selection; late SysSetting appSetting; @@ -51,9 +74,18 @@ class CanteenProductController extends GetxController with OrderMixin { update(["canteen_product"]); } + /// 注册全局餐段监听并开始恢复页面资源。 @override - void onInit() async { + void onInit() { super.onInit(); + _hourTypeListener = _handleHourTypeSelection; + hourTypeC.addValueListener(_hourTypeListener); + _initializationFuture = _initialize(); + } + + /// 等待全局餐段缓存后初始化收银资源与 MQTT 监听。 + Future _initialize() async { + await _hourTypeControl.initAsync(); await _initState(); _listrenMq(); } @@ -64,6 +96,7 @@ class CanteenProductController extends GetxController with OrderMixin { _initData(); } + /// 退出收银页时释放页面资源,但保留应用级餐段选择器及其缓存状态。 @override void onClose() { super.onClose(); @@ -71,7 +104,7 @@ class CanteenProductController extends GetxController with OrderMixin { // 读卡器 appC.serialCardManage.readerClose(); appC.serialCardManage.close(); - hourTypeC.dispose(); + hourTypeC.removeValueListener(_hourTypeListener); searchC.dispose(); // mq mqUpdateListen?.cancel(); @@ -136,12 +169,16 @@ class CanteenProductController extends GetxController with OrderMixin { maskType: EasyLoadingMaskType.black, ); appSetting = await AppSettingTool.getSysSetting(); + _appSettingReady = true; // 打开副屏 await AppSubScreenTool.show(); SubScreenPlugin.sendMsgToViceScreen(SubScreenAction.jumpSubPage.name); // 组件初始化 - hourType.value = appSetting.hourType ?? ProductHourType.MORNING; - hourTypeC.value = {hourType.value.code}; + hourType.value = _hourTypeControl.selected.value; + appSetting.hourType = hourType.value; + if (hourTypeC.value.firstOrNull != hourType.value.code) { + hourTypeC.value = {hourType.value.code}; + } sysType = SysType.CANTEEN; _listenPayTypeSelectCallback(); // 收银页只读取本地商品快照;定时和 MQTT 商品更新统一由 productSync 在安全窗口执行。 @@ -158,27 +195,36 @@ class CanteenProductController extends GetxController with OrderMixin { await getProduct(); // 清空购物车 await _clearShopCard(); + _mealResourcesReady = true; EasyLoading.dismiss(); - // 监听时间段选择 - hourTypeC.addValueListener((v) async { - final currentHourType = ProductHourType.values.fromCode( - v.first, - ); - if (currentHourType == null) { - return; - } - EasyLoading.show( - status: '切换中...', - maskType: EasyLoadingMaskType.black, - ); - hourType.value = currentHourType; - appSetting.hourType = currentHourType; + } + + /// 接收全局餐段变化;首次初始化只同步值,初始化后重新加载对应商品与购物车。 + void _handleHourTypeSelection(Set values) { + final currentHourType = ProductHourType.values.fromCode(values.firstOrNull); + if (currentHourType == null || currentHourType == ProductHourType.TOTAL) { + return; + } + if (hourType.value == currentHourType && _mealResourcesReady) return; + hourType.value = currentHourType; + if (_appSettingReady) appSetting.hourType = currentHourType; + if (!_mealResourcesReady) return; + unawaited(_reloadForHourType()); + } + + /// 按最新全局餐段重载分类、商品并清空旧餐段购物车。 + Future _reloadForHourType() async { + EasyLoading.show( + status: '切换中...', + maskType: EasyLoadingMaskType.black, + ); + try { await getClassify(); await getProduct(); await _clearShopCard(); - await AppSettingTool.setSysSetting(appSetting); + } finally { EasyLoading.dismiss(); - }); + } } _listrenMq() { diff --git a/lib/pages/terminal/canteen_product/view.dart b/lib/pages/terminal/canteen_product/view.dart index c2b02c3..6315a88 100644 --- a/lib/pages/terminal/canteen_product/view.dart +++ b/lib/pages/terminal/canteen_product/view.dart @@ -87,6 +87,10 @@ class CanteenProductPage extends GetView { headerTrailing: appRadioHourTypeDialog( context, controller.hourTypeC, + title: '餐段', + compact: true, + checkConfiguredTime: true, + ready: controller.initialization, ).width(280.w), cartTitle: '已点菜品', ); diff --git a/lib/pages/terminal/canteen_reserve/controller.dart b/lib/pages/terminal/canteen_reserve/controller.dart index 020f0dc..d2ac9b9 100644 --- a/lib/pages/terminal/canteen_reserve/controller.dart +++ b/lib/pages/terminal/canteen_reserve/controller.dart @@ -1,25 +1,48 @@ import 'dart:async'; +import 'package:flutter/foundation.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'; +/// 预订播报页控制器,按应用级餐段筛选并管理待取餐播报记录。 class CanteenReserveController extends GetxController { + /// 创建预订播报页控制器,页面初始化时接入全局餐段。 CanteenReserveController(); final appC = Get.find(); StreamSubscription? mqUpdateListen; + /// 全局餐段选择监听句柄,页面关闭时用于解除订阅。 + late final ValueChanged> _hourTypeListener; + + /// 首次播报数据完成加载后为 true,此后餐段变化才触发翻页重载。 + bool _mealResourcesReady = false; + + /// 页面首次初始化任务,供餐段时间校验等待缓存和播报数据恢复。 + Future? _initializationFuture; + + /// 页面餐段和播报数据初始化完成信号。 + Future get initialization => _initializationFuture ?? Future.value(); + RxList reserveList = [].obs; Rx hourType = Rx(ProductHourType.MORNING); - var hourTypeC = FRadioSelectGroupController( - value: ProductHourType.MORNING.code, - ); + /// 获取应用级餐段控制器,兼容未经过标准主入口的页面启动方式。 + ProductHourTypeControl get _hourTypeControl { + if (!Get.isRegistered()) { + Get.put(ProductHourTypeControl(), permanent: true); + } + return Get.find(); + } + + /// 预订播报页复用的全局 Forui 餐段选择器。 + FRadioSelectGroupController get hourTypeC => + _hourTypeControl.selection; // table RxInt currentPage = 1.obs; @@ -32,25 +55,22 @@ class CanteenReserveController extends GetxController { update(["canteen_reserve"]); } + /// 注册全局餐段监听并启动页面数据初始化。 @override - void onInit() async { + void onInit() { super.onInit(); + _hourTypeListener = _handleHourTypeSelection; + hourTypeC.addValueListener(_hourTypeListener); + _initializationFuture = _initialize(); + } + + /// 等待全局餐段缓存后同步播报记录并建立 MQTT 监听。 + Future _initialize() async { + await _hourTypeControl.initAsync(); + hourType.value = _hourTypeControl.selected.value; await syncAllBroadcast(isMessageDialog: false); + _mealResourcesReady = true; _listrenMq(); - hourTypeC.addValueListener((v) async { - final currentHourType = ProductHourType.values.fromCode( - v.first, - ); - if (currentHourType == null) return; - EasyLoading.show( - status: '切换中...', - maskType: EasyLoadingMaskType.black, - ); - hourType.value = currentHourType; - currentPage.value = 1; - await _getReservePage(); - EasyLoading.dismiss(); - }); } @override @@ -59,14 +79,40 @@ class CanteenReserveController extends GetxController { _initData(); } + /// 退出页面时移除餐段与 MQTT 监听,但不销毁应用级选择器。 @override void onClose() { super.onClose(); - hourTypeC.dispose(); + hourTypeC.removeValueListener(_hourTypeListener); // mq mqUpdateListen?.cancel(); } + /// 接收全局餐段变化,重置页码并在首次加载完成后刷新播报列表。 + void _handleHourTypeSelection(Set values) { + final currentHourType = ProductHourType.values.fromCode(values.firstOrNull); + if (currentHourType == null || currentHourType == ProductHourType.TOTAL) { + return; + } + hourType.value = currentHourType; + if (!_mealResourcesReady) return; + EasyLoading.show( + status: '切换中...', + maskType: EasyLoadingMaskType.black, + ); + currentPage.value = 1; + unawaited(_reloadForHourType()); + } + + /// 按最新餐段重新读取播报分页,并确保结束切换加载状态。 + Future _reloadForHourType() async { + try { + await _getReservePage(); + } finally { + EasyLoading.dismiss(); + } + } + Future doPage({int? page}) async { if (page != null) { currentPage.value = page; diff --git a/lib/pages/terminal/canteen_reserve/view.dart b/lib/pages/terminal/canteen_reserve/view.dart index ab14d44..8c0ac36 100644 --- a/lib/pages/terminal/canteen_reserve/view.dart +++ b/lib/pages/terminal/canteen_reserve/view.dart @@ -72,7 +72,12 @@ class CanteenReservePage extends GetView { Widget _buildCondition(BuildContext context, double headerHeight) { final theme = FTheme.of(context); return [ - appRadioHourTypeDialog(context, controller.hourTypeC).width(300.w), + appRadioHourTypeDialog( + context, + controller.hourTypeC, + checkConfiguredTime: true, + ready: controller.initialization, + ).width(300.w), const Spacer(), FButton( style: FButtonStyle.outline, diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index cda6a2d..f66274c 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; @@ -59,6 +60,7 @@ class ReservationMenuQueryController extends GetxController { bool Function()? networkAvailable, Future Function()? preferencesLoader, Future Function(ReservationMealSlot)? mealSlotSaver, + ProductHourTypeControl? hourTypeControl, Future Function(String)? speaker, void Function(String)? successNotifier, void Function(String)? errorNotifier, @@ -85,7 +87,13 @@ class ReservationMenuQueryController extends GetxController { successNotifier ?? ((message) => Toast.success(msg: message)), _errorNotifier = errorNotifier ?? ((message) => Toast.error(msg: message)), - _now = now ?? DateTime.now; + _now = now ?? DateTime.now, + _hourTypeControl = hourTypeControl ?? + (Get.isRegistered() + ? Get.find() + : ProductHourTypeControl(persistSelection: false)), + _ownsHourTypeControl = hourTypeControl == null && + !Get.isRegistered(); final ReservationMenuRepository _repository; final ReservationRecognitionGateway _recognitionGateway; @@ -100,6 +108,12 @@ class ReservationMenuQueryController extends GetxController { final void Function(String) _successNotifier; final void Function(String) _errorNotifier; + /// 查询菜单复用的餐段控制器;生产环境指向应用级实例,测试可注入独立实例。 + final ProductHourTypeControl _hourTypeControl; + + /// 是否由当前页面创建独立餐段控制器;仅测试回退场景需要自行释放。 + final bool _ownsHourTypeControl; + /// 普通查询结果的隐私展示时长,当前生产默认六十秒。 final Duration privacyDuration; @@ -135,9 +149,11 @@ class ReservationMenuQueryController extends GetxController { final selectedMealSlot = ReservationMealSlot.morning.obs; /// 查询菜单与收银页面共用餐段组件的单选 Controller,值使用后端稳定餐段编码。 - final mealSlotC = FRadioSelectGroupController( - value: ReservationMealSlot.morning.code, - ); + FRadioSelectGroupController get mealSlotC => + _hourTypeControl.selection; + + /// 全局餐段选择监听句柄,页面关闭时用于解除订阅。 + late final ValueChanged> _mealSlotListener; /// 识别查询成功后是否直接请求页面打开今天所选餐段的核销弹框。 final quickVerificationEnabled = false.obs; @@ -163,6 +179,9 @@ class ReservationMenuQueryController extends GetxController { bool _recognitionLocked = false; bool _verificationSubmitting = false; + /// 恢复旧版页面偏好时临时禁止重复写入最近餐段缓存。 + bool _suppressMealSlotPersistence = false; + /// 菜单详情或核销弹框是否正在展示;展示期间禁止自动续轮和任何新的硬件识别。 bool _resultDialogPresented = false; @@ -176,6 +195,12 @@ class ReservationMenuQueryController extends GetxController { _quickVerificationRequestController = StreamController.broadcast(); + /// 页面偏好、副屏和识别会话的首次初始化任务。 + Future? _initializationFuture; + + /// 页面偏好与识别会话初始化完成后再执行依赖当前餐段的提示。 + Future get initialization => _initializationFuture ?? Future.value(); + /// 页面层使用的只读快速核销请求流,Controller 不直接依赖弹框或导航上下文。 Stream get quickVerificationRequests => _quickVerificationRequestController.stream; @@ -201,11 +226,15 @@ class ReservationMenuQueryController extends GetxController { @override void onInit() { super.onInit(); - mealSlotC.addValueListener(_handleMealSlotSelection); - unawaited(_initialize()); + _mealSlotListener = _handleMealSlotSelection; + mealSlotC.addValueListener(_mealSlotListener); + _initializationFuture = _initialize(); } + /// 恢复全局餐段与页面偏好,再建立副屏和识别会话。 Future _initialize() async { + // 页面单测可注入独立餐段控制器;生产环境使用应用级控制器并等待其缓存恢复。 + if (!_ownsHourTypeControl) await _hourTypeControl.initAsync(); await Future.wait([ _loadPagePreferences(), _subScreenGateway.show(), @@ -218,7 +247,6 @@ class ReservationMenuQueryController extends GetxController { try { final preferences = await _preferencesLoader(); if (_closed) return; - final restoredMealSlot = preferences.selectedMealSlot; methodOrder.assignAll(preferences.methodOrder); method.value = preferences.defaultMethod; autoRecognitionEnabled.value = preferences.autoRecognitionEnabled; @@ -228,13 +256,24 @@ class ReservationMenuQueryController extends GetxController { verificationEnabled.value = preferences.verificationEnabled; quickVerificationEnabled.value = preferences.verificationEnabled && preferences.quickVerificationEnabled; - selectedMealSlot.value = - ReservationMealSlot.querySelectableValues.contains(restoredMealSlot) - ? restoredMealSlot - : ReservationMealSlot.morning; - if (!mealSlotC.contains(selectedMealSlot.value.code)) { - mealSlotC.value = {selectedMealSlot.value.code}; + if (_ownsHourTypeControl && + ReservationMealSlot.querySelectableValues + .contains(preferences.selectedMealSlot)) { + final restoredType = ProductHourType.values.fromCode( + preferences.selectedMealSlot.code, + ); + if (restoredType != null) { + _suppressMealSlotPersistence = true; + try { + _hourTypeControl.setSelected(restoredType, persist: false); + } finally { + _suppressMealSlotPersistence = false; + } + } } + selectedMealSlot.value = _mealSlotForProductHourType( + _hourTypeControl.selected.value, + ); verificationDateScope.value = preferences.verificationDateScope; } catch (error, stack) { // 配置损坏或读取失败时继续使用安全默认值,不能让查询入口不可用。 @@ -243,16 +282,18 @@ class ReservationMenuQueryController extends GetxController { } /// 更新预选餐段;等待硬件识别时仍允许调整,身份已获取并查询服务端后锁定本轮目标。 - void selectMealSlot(ReservationMealSlot next) { + void selectMealSlot(ReservationMealSlot next, {bool fromGlobal = false}) { if (_closed || !ReservationMealSlot.querySelectableValues.contains(next) || - status.value == ReservationMenuQueryStatus.querying || + (!fromGlobal && status.value == ReservationMenuQueryStatus.querying) || selectedMealSlot.value == next) { return; } selectedMealSlot.value = next; if (!mealSlotC.contains(next.code)) mealSlotC.value = {next.code}; - unawaited(_persistSelectedMealSlot(next)); + if (!_suppressMealSlotPersistence) { + unawaited(_persistSelectedMealSlot(next)); + } } /// 接收公共餐段组件的编码选择,并映射回查询菜单的强类型餐段状态。 @@ -261,12 +302,20 @@ class ReservationMenuQueryController extends GetxController { if (selectedCode == null) return; for (final mealSlot in ReservationMealSlot.querySelectableValues) { if (mealSlot.code == selectedCode) { - selectMealSlot(mealSlot); + selectMealSlot(mealSlot, fromGlobal: true); return; } } } + /// 把全局商品餐段映射为查询菜单餐段;异常编码回退早餐以保持页面可查询。 + ReservationMealSlot _mealSlotForProductHourType(ProductHourType hourType) { + return ReservationMealSlot.querySelectableValues.firstWhere( + (slot) => slot.code == hourType.code, + orElse: () => ReservationMealSlot.morning, + ); + } + /// 异步缓存最近餐段;失败时保留内存中的即时选择,避免本机设置故障阻断识别查询。 Future _persistSelectedMealSlot(ReservationMealSlot mealSlot) async { try { @@ -1196,7 +1245,7 @@ class ReservationMenuQueryController extends GetxController { } } - /// 退出页面时终止识别与副屏任务,并释放公共餐段组件持有的选择 Controller。 + /// 退出页面时终止识别与副屏任务;生产环境仅解除全局餐段监听。 @override void onClose() { _closed = true; @@ -1208,7 +1257,8 @@ class ReservationMenuQueryController extends GetxController { unawaited(_discardCurrentFaceEvidence('退出预约查询页面')); unawaited(_clearSubScreen()); unawaited(_quickVerificationRequestController.close()); - mealSlotC.dispose(); + mealSlotC.removeValueListener(_mealSlotListener); + if (_ownsHourTypeControl) _hourTypeControl.onClose(); super.onClose(); } } diff --git a/lib/pages/terminal/menu_query/view.dart b/lib/pages/terminal/menu_query/view.dart index 8378571..f122495 100644 --- a/lib/pages/terminal/menu_query/view.dart +++ b/lib/pages/terminal/menu_query/view.dart @@ -102,6 +102,7 @@ class ReservationMenuQueryPage extends GetView { continuousRecognitionEnabled: controller.continuousRecognitionEnabled.value, quickVerificationEnabled: controller.quickVerificationEnabled.value, + mealPeriodReady: controller.initialization, onSelect: controller.selectMethod, onStart: controller.startRecognition, ), diff --git a/lib/pages/terminal/menu_query/widgets/query_method_panel.dart b/lib/pages/terminal/menu_query/widgets/query_method_panel.dart index 57dbede..8d208a2 100644 --- a/lib/pages/terminal/menu_query/widgets/query_method_panel.dart +++ b/lib/pages/terminal/menu_query/widgets/query_method_panel.dart @@ -20,6 +20,7 @@ class QueryMethodPanel extends StatelessWidget { required this.quickVerificationEnabled, required this.onSelect, required this.onStart, + this.mealPeriodReady, }); /// 按设置页排序后的可用识别方式。 @@ -43,6 +44,9 @@ class QueryMethodPanel extends StatelessWidget { /// 是否开启查询后的快速核销;仅开启时展示核销目标餐段的预选入口。 final bool quickVerificationEnabled; + /// 查询页面偏好初始化完成信号,用于在恢复最近餐段后校验当前时间配置。 + final Future? mealPeriodReady; + /// 用户切换识别方式时调用的 Controller 回调。 final ValueChanged onSelect; @@ -72,6 +76,8 @@ class QueryMethodPanel extends StatelessWidget { title: '餐段', enabled: mealSelectionEnabled, compact: true, + checkConfiguredTime: mealPeriodReady != null, + ready: mealPeriodReady, ), if (autoRecognitionEnabled) _automaticRecognitionStatus() diff --git a/lib/pages/terminal/setting/controller.dart b/lib/pages/terminal/setting/controller.dart index 7cf65f9..08b621e 100644 --- a/lib/pages/terminal/setting/controller.dart +++ b/lib/pages/terminal/setting/controller.dart @@ -87,6 +87,12 @@ class SettingController extends GetxController with WidgetsBindingObserver { final reservationQueryVerificationDateScope = ReservationVerificationDateScope.today.obs; + /// 早餐、中餐和晚餐的本地时间范围,设置加载后由 sysJson 中的持久化值覆盖。 + final productMealPeriodSchedule = ProductMealPeriodSchedule.defaults.obs; + + /// 餐段配置正在保存时禁用重复提交并展示按钮状态。 + final productMealPeriodSaving = false.obs; + /// 设置模块是否仍在初始化系统设置、备份时间和调度器。 final isLoading = true.obs; @@ -216,10 +222,14 @@ class SettingController extends GetxController with WidgetsBindingObserver { AppSettingTool.getCashierPagePreferences(); final reservationQueryPreferencesFuture = AppSettingTool.getReservationQueryPagePreferences(); + final productMealPeriodScheduleFuture = + AppSettingTool.getProductMealPeriodSchedule(); final storedPayTypeOrder = await storedPayTypeOrderFuture; final reidentifiablePayTypes = await reidentifiablePayTypesFuture; final cashierPagePreferences = await cashierPagePreferencesFuture; final reservationQueryPreferences = await reservationQueryPreferencesFuture; + final restoredProductMealPeriodSchedule = + await productMealPeriodScheduleFuture; final byCode = { for (final item in OrderPayDisplayType.values) item.code: item, }; @@ -256,6 +266,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { reservationQueryPreferences.quickVerificationEnabled; reservationQueryVerificationDateScope.value = reservationQueryPreferences.verificationDateScope; + productMealPeriodSchedule.value = restoredProductMealPeriodSchedule; if (setting.usePayDisplayType != null) { setting.usePayDisplayType = restoredPayTypeOrder .where(setting.usePayDisplayType!.contains) @@ -275,6 +286,30 @@ class SettingController extends GetxController with WidgetsBindingObserver { pageSettingType.value = type; } + /// 校验并保存餐段时间;空档允许保留,相邻或交叉范围由强类型模型统一处理。 + Future saveProductMealPeriods(List periods) async { + if (productMealPeriodSaving.value) return false; + final error = ProductMealPeriodSchedule.validate(periods); + if (error != null) { + Toast.error(msg: error); + return false; + } + productMealPeriodSaving.value = true; + try { + final schedule = ProductMealPeriodSchedule.checked(periods); + await AppSettingTool.setProductMealPeriodSchedule(schedule); + productMealPeriodSchedule.value = schedule; + Toast.success(msg: '餐段配置已保存'); + return true; + } catch (e, stack) { + logger.e('|$_tag|保存餐段配置失败error:|$e|stack:|$stack|'); + Toast.error(msg: '餐段配置保存失败,请重试'); + return false; + } finally { + productMealPeriodSaving.value = false; + } + } + /// 调整预约查询识别方式顺序,并合并保存到最新页面偏好。 void reorderReservationQueryMethod(int oldIndex, int newIndex) { if (oldIndex == newIndex || diff --git a/lib/pages/terminal/setting/routes/meal_periods/view.dart b/lib/pages/terminal/setting/routes/meal_periods/view.dart new file mode 100644 index 0000000..277816e --- /dev/null +++ b/lib/pages/terminal/setting/routes/meal_periods/view.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“餐段配置”路由页,编辑早餐、中餐和晚餐的本地时间范围。 +class SettingMealPeriodsRoutePage extends StatelessWidget { + const SettingMealPeriodsRoutePage({super.key}); + + @override + Widget build(BuildContext context) => const SettingMealPeriods(); +} diff --git a/lib/pages/terminal/setting/routes/navigation.dart b/lib/pages/terminal/setting/routes/navigation.dart index ce0cf9e..6692a85 100644 --- a/lib/pages/terminal/setting/routes/navigation.dart +++ b/lib/pages/terminal/setting/routes/navigation.dart @@ -106,6 +106,7 @@ abstract final class SettingNavigation { static String routeForMenu(SettingMenuType type) => switch (type) { SettingMenuType.baseInfo => RouteNames.settingBaseInfo, SettingMenuType.syncSetting => RouteNames.settingSync, + SettingMenuType.mealPeriodSetting => RouteNames.settingMealPeriods, SettingMenuType.paySetting => RouteNames.settingPage, SettingMenuType.backup => RouteNames.settingBackup, SettingMenuType.faceSetting => RouteNames.settingFace, @@ -125,6 +126,7 @@ abstract final class SettingNavigation { RouteNames.settingSync || RouteNames.settingSyncLogs => SettingMenuType.syncSetting, + RouteNames.settingMealPeriods => SettingMenuType.mealPeriodSetting, RouteNames.settingBackup => SettingMenuType.backup, RouteNames.settingFace => SettingMenuType.faceSetting, RouteNames.settingFacePlugin => SettingMenuType.facePluginSetting, diff --git a/lib/pages/terminal/setting/routes/route_factory.dart b/lib/pages/terminal/setting/routes/route_factory.dart index 0f569b9..0c51415 100644 --- a/lib/pages/terminal/setting/routes/route_factory.dart +++ b/lib/pages/terminal/setting/routes/route_factory.dart @@ -5,12 +5,15 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import '../controller.dart'; import '../widgets/setting_components.dart'; +import '../face_setting_controller.dart'; +import '../widgets/setting_face.dart'; import 'backup/view.dart'; import 'base_info/view.dart'; import 'card_reader/view.dart'; import 'face/view.dart'; import 'face_plugin/view.dart'; import 'log_preview/view.dart'; +import 'meal_periods/view.dart'; import 'navigation.dart'; import 'page_setting/view.dart'; import 'product/view.dart'; @@ -44,13 +47,20 @@ abstract final class SettingRouteFactory { menuType: menuType, child: page(), ), - binding: routeName == RouteNames.settingSyncLogs - ? BindingsBuilder( - () => Get.lazyPut( - () => ScheduledTaskLogsController(taskType), - ), - ) - : null, + binding: switch (routeName) { + RouteNames.settingSyncLogs => BindingsBuilder( + () => Get.lazyPut( + () => ScheduledTaskLogsController(taskType), + ), + ), + RouteNames.settingFace => BindingsBuilder( + () => Get.lazyPut(() => FaceManageController()), + ), + RouteNames.settingFacePlugin => BindingsBuilder( + () => Get.lazyPut(() => FaceSettingController()), + ), + _ => null, + }, transition: Transition.noTransition, transitionDuration: Duration.zero, ); @@ -60,6 +70,7 @@ abstract final class SettingRouteFactory { static const Set _knownRoutes = { RouteNames.settingBaseInfo, RouteNames.settingSync, + RouteNames.settingMealPeriods, RouteNames.settingBackup, RouteNames.settingFace, RouteNames.settingFacePlugin, @@ -75,6 +86,8 @@ abstract final class SettingRouteFactory { /// 将已校验路由名称映射到对应目录的页面构造器。 static GetPageBuilder _pageForRoute(String routeName) => switch (routeName) { RouteNames.settingSync => () => const SettingSyncRoutePage(), + RouteNames.settingMealPeriods => () => + const SettingMealPeriodsRoutePage(), RouteNames.settingBackup => () => const SettingBackupRoutePage(), RouteNames.settingFace => () => const SettingFaceRoutePage(), RouteNames.settingFacePlugin => () => @@ -124,6 +137,7 @@ class _SettingAccessGuard extends StatelessWidget { /// 返回未绑定设备仍可访问的本地设置菜单,规则与旧设置页保持一致。 static bool _allowsUnboundAccess(SettingMenuType type) => const { SettingMenuType.baseInfo, + SettingMenuType.mealPeriodSetting, SettingMenuType.pageSetting, SettingMenuType.backup, SettingMenuType.logPreview, diff --git a/lib/pages/terminal/setting/view.dart b/lib/pages/terminal/setting/view.dart index b7ab493..cd46493 100644 --- a/lib/pages/terminal/setting/view.dart +++ b/lib/pages/terminal/setting/view.dart @@ -301,6 +301,7 @@ SvgAsset _menuIcon(SettingMenuType type) { SettingMenuType.baseInfo => FAssets.icons.settings, SettingMenuType.backup => FAssets.icons.databaseBackup, SettingMenuType.syncSetting => FAssets.icons.refreshCw, + SettingMenuType.mealPeriodSetting => FAssets.icons.utensils, SettingMenuType.paySetting => FAssets.icons.creditCard, SettingMenuType.faceSetting => FAssets.icons.scanFace, SettingMenuType.facePluginSetting => FAssets.icons.shieldCheck, @@ -319,6 +320,7 @@ String _menuDescription(SettingMenuType type) { SettingMenuType.baseInfo => '查看终端身份、绑定状态和字体显示设置', SettingMenuType.backup => '查看备份路径并导出本地数据库和日志', SettingMenuType.syncSetting => '配置自动规则、空闲执行,并立即运行终端同步和日志任务', + SettingMenuType.mealPeriodSetting => '设置早餐、中餐和晚餐在一天内对应的时间范围', SettingMenuType.paySetting => '支付配置已迁入页面设置的收银页面', SettingMenuType.faceSetting => '查询、同步和维护本地人脸样本', SettingMenuType.facePluginSetting => '查看插件授权、引擎状态和内存人脸库注册进度', diff --git a/lib/pages/terminal/setting/widgets/index.dart b/lib/pages/terminal/setting/widgets/index.dart index 2762679..6d6ed4d 100644 --- a/lib/pages/terminal/setting/widgets/index.dart +++ b/lib/pages/terminal/setting/widgets/index.dart @@ -7,6 +7,7 @@ export './setting_face.dart'; export './setting_face_plugin.dart'; export './setting_kiosk_exit_button.dart'; export './setting_log_preview.dart'; +export './setting_meal_periods.dart'; export './setting_pay_type_editor.dart'; export './setting_page.dart'; export './setting_product.dart'; diff --git a/lib/pages/terminal/setting/widgets/setting_components.dart b/lib/pages/terminal/setting/widgets/setting_components.dart index 257a357..fbc727f 100644 --- a/lib/pages/terminal/setting/widgets/setting_components.dart +++ b/lib/pages/terminal/setting/widgets/setting_components.dart @@ -4,12 +4,26 @@ import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 设置页通用分组,统一标题层级、边框和圆角。 class SettingGroup extends StatelessWidget { - const SettingGroup({super.key, required this.title, required this.child}); + /// 创建设置分组;[dense] 仅用于需要提高信息密度的页面。 + const SettingGroup({ + super.key, + required this.title, + required this.child, + this.dense = false, + }); + /// 分组标题。 final String title; + + /// 分组卡片中的实际设置内容。 final Widget child; + /// 是否使用紧凑标题层级;只压缩排版,不改变分组内容的触控尺寸。 + final bool dense; + + /// 构建标题和白色分组卡片。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); @@ -18,12 +32,12 @@ class SettingGroup extends StatelessWidget { children: [ Text( title, - style: theme.typography.lg.copyWith( + style: (dense ? theme.typography.base : theme.typography.lg).copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w800, ), ), - SizedBox(height: 12.h), + SizedBox(height: (dense ? 8 : 12).h), Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( @@ -304,7 +318,9 @@ class SettingInfoRow extends StatelessWidget { } } +/// 设置页通用提示条,用图标、文字和语义颜色反馈说明或状态。 class SettingNotice extends StatelessWidget { + /// 创建提示条;[dense] 适用于紧凑表单,但不会删减提示内容。 const SettingNotice({ super.key, required this.text, @@ -312,20 +328,37 @@ class SettingNotice extends StatelessWidget { this.background = XfjDesignTokens.control, this.borderColor, this.icon, + this.dense = false, }); + /// 提示正文。 final String text; + + /// 提示正文和图标的前景色。 final Color color; + + /// 提示条背景色。 final Color background; + + /// 可选边框颜色,为空时不绘制边框。 final Color? borderColor; + + /// 可选语义图标。 final SvgAsset? icon; + /// 是否使用紧凑提示样式;保留完整文案和语义,仅降低字号与内边距。 + final bool dense; + + /// 构建可随宽度换行的提示内容。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); return Container( width: double.infinity, - padding: EdgeInsets.all(16.w), + padding: EdgeInsets.symmetric( + horizontal: (dense ? 12 : 16).w, + vertical: (dense ? 10 : 16).h, + ), decoration: BoxDecoration( color: background, border: borderColor == null ? null : Border.all(color: borderColor!), @@ -334,13 +367,14 @@ class SettingNotice extends StatelessWidget { child: Row( children: [ if (icon != null) ...[ - FIcon(icon!, color: color, size: 20.sp), - SizedBox(width: 10.w), + FIcon(icon!, color: color, size: (dense ? 18 : 20).sp), + SizedBox(width: (dense ? 8 : 10).w), ], Expanded( child: Text( text, - style: theme.typography.sm.copyWith(color: color), + style: (dense ? theme.typography.xs : theme.typography.sm) + .copyWith(color: color), ), ), ], @@ -380,7 +414,9 @@ class SettingEmpty extends StatelessWidget { } } +/// 设置页通用操作按钮,提供主要、普通和危险三种层级。 class SettingActionButton extends StatelessWidget { + /// 创建具有真实禁用态和最小触控高度的操作按钮。 const SettingActionButton({ super.key, required this.label, @@ -389,15 +425,31 @@ class SettingActionButton extends StatelessWidget { this.primary = false, this.danger = false, this.enabled = true, + this.dense = false, }); + /// 按钮文字。 final String label; + + /// 按钮处于可用状态时的点击回调。 final VoidCallback onTap; + + /// 可选前置图标。 final SvgAsset? icon; + + /// 是否按页面唯一主要操作展示。 final bool primary; + + /// 是否按危险操作展示。 final bool danger; + + /// 是否允许点击;禁用时同时移除回调并降低视觉强调。 final bool enabled; + /// 是否使用紧凑视觉样式;点击高度仍固定不小于 48dp。 + final bool dense; + + /// 构建保持至少 48dp 点击高度的操作按钮。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); @@ -420,8 +472,8 @@ class SettingActionButton extends StatelessWidget { child: Container( constraints: BoxConstraints(minHeight: 48.h), padding: EdgeInsets.symmetric( - horizontal: 18.w * sizeScale, - vertical: 10.h * sizeScale, + horizontal: (dense ? 14 : 18).w * sizeScale, + vertical: (dense ? 8 : 10).h * sizeScale, ), decoration: BoxDecoration( color: background, @@ -439,12 +491,17 @@ class SettingActionButton extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ if (icon != null) ...[ - FIcon(icon!, color: foreground, size: 20.sp), - SizedBox(width: 8.w), + FIcon( + icon!, + color: foreground, + size: (dense ? 18 : 20).sp, + ), + SizedBox(width: (dense ? 6 : 8).w), ], Text( label, - style: theme.typography.base.copyWith( + style: (dense ? theme.typography.sm : theme.typography.base) + .copyWith( color: foreground, fontWeight: FontWeight.w600, ), diff --git a/lib/pages/terminal/setting/widgets/setting_face.dart b/lib/pages/terminal/setting/widgets/setting_face.dart index acf86e1..f18c1ad 100644 --- a/lib/pages/terminal/setting/widgets/setting_face.dart +++ b/lib/pages/terminal/setting/widgets/setting_face.dart @@ -11,6 +11,15 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import 'setting_components.dart'; +/// 人脸管理内容区的两个一级视图。 +enum FaceManageTab { + /// 全部本地人脸及既有同步维护能力。 + all, + + /// 已检测且分数低于当前在库阈值的人脸。 + lowQuality, +} + /// 设置页人脸管理控制器,只负责列表交互和将人工同步请求提交到应用级队列。 class FaceManageController extends GetxController { /// 应用级数据库和事件总线持有者,由根 Binding 统一注册。 @@ -40,12 +49,37 @@ class FaceManageController extends GetxController { /// 最近一次列表查询失败的安全提示,不包含数据库或文件路径。 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(); + + /// 同步、检测和服务端删除状态,用于互斥及按钮禁用。 + final isSyncing = false.obs; + final isAuditing = false.obs; + final isDeleting = false.obs; + /// 姓名搜索输入控制器,由本控制器负责释放。 final usernameC = TextEditingController(); /// FACE 增量任务成功后的列表刷新订阅,由 [onClose] 取消。 StreamSubscription? mqUpdateListen; + /// 原生质量检测进度订阅,由 [onClose] 释放。 + StreamSubscription? qualityAuditListen; + /// 启动异步设置读取、首次列表查询和 FACE 完成事件监听。 @override void onInit() { @@ -58,6 +92,13 @@ class FaceManageController extends GetxController { 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(); } @@ -65,6 +106,7 @@ class FaceManageController extends GetxController { @override void onClose() { mqUpdateListen?.cancel(); + qualityAuditListen?.cancel(); usernameC.dispose(); super.onClose(); } @@ -83,14 +125,28 @@ class FaceManageController extends GetxController { final faceDao = appC.dataBase.getFaceDao; String userName = usernameC.text; if (ObjectUtil.isEmpty(userName)) userName = "-1"; - final findCount = await faceDao.findCount(userName); + 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 = await faceDao.findAll( - userName, - pageSize.value, - (currentPage.value - 1) * pageSize.value, - ); + 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|'); @@ -109,6 +165,7 @@ class FaceManageController extends GetxController { /// 队列会先避开支付、人脸会话和业务弹框;只有任务已经实际开始时才显示遮罩, /// 避免遮罩本身被空闲检测识别为业务占用而导致任务无法启动。 Future asyncFace({bool isAll = false}) async { + if (operationInProgress) return; final isNetwork = AppNetworkTool.isAutoNetworkMsg(); if (!isNetwork) return; final setting = await AppSettingTool.getSysSetting(); @@ -133,6 +190,7 @@ class FaceManageController extends GetxController { ); if (!isSure) return; final scheduler = SyncCheckTool.instance; + isSyncing.value = true; final operation = scheduler.trigger( ScheduledTaskType.departmentFaceSync, debugAllFaces: isAll, @@ -167,10 +225,166 @@ class FaceManageController extends GetxController { logger.e('|人脸管理|同步失败error:|$e|stack:|$stack|'); } finally { currentPage.value = 1; + isSyncing.value = false; if (overlayVisible) EasyLoading.dismiss(); } } + /// 重新读取 Android 人脸配置并按新阈值立即重查低质量分类。 + Future 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 switchTab(FaceManageTab tab) async { + if (selectedTab.value == tab) return; + selectedTab.value = tab; + currentPage.value = 1; + if (tab == FaceManageTab.lowQuality) { + await refreshQualityThreshold(); + } + await getFaceList(); + } + + /// 手动检测待检测及失败记录,检测失败不会移除人脸或特征。 + Future 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 deleteLowQualityFace(FaceEntity face) async { + if (operationInProgress) return; + final confirmed = await appMessageDialog( + Get.context!, + '是否删除“${face.userName}”的该低质量人脸?删除后将不再参与识别。', + ); + if (!confirmed) return; + await _deleteServerBackedFaces([face]); + } + + /// 删除当前阈值下全部明确低质量的人脸,不受搜索词和分页影响。 + Future 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 _deleteServerBackedFaces(List 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 _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; + /// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。 Future delAllFace() async { final isSure = await appMessageDialog( @@ -235,12 +449,8 @@ class FaceManageController extends GetxController { maskType: EasyLoadingMaskType.black, ); try { - final faceDao = appC.dataBase.getFaceDao; - if (ObjectUtil.isNotEmpty(v.path)) { - await SyncFaceTool.delFaceByStorage(filePath: v.path!); - } - await faceDao.delById(v.id!); - await AppFaceTool.removeFace(sampleId: v.sampleId); + final deleted = await SyncFaceTool.deleteLocalFace(v); + if (!deleted) throw StateError('本地人脸删除失败并已尝试恢复'); await getFaceList(); Toast.success(msg: '删除成功'); } catch (e, stack) { @@ -252,185 +462,558 @@ class FaceManageController extends GetxController { } } -/// 构建人脸搜索与管理操作区;全租户同步按钮仅在非生产调试环境出现。 -Widget buildFaceSearch(BuildContext context) { - final faceC = Get.find(); - final theme = FTheme.of(context); - return [ - Obx( +/// 构建两个稳定尺寸、带选中语义的人脸管理文字 Tab。 +Widget _buildFaceTabs(BuildContext context) { + final controller = Get.find(); + return Obx( + () => FaceManageTabBar( + selectedTab: controller.selectedTab.value, + onChanged: controller.switchTab, + ), + ); +} + +/// 人脸管理内容区的可测试文字 Tab,仅负责选中展示和点击回调。 +class FaceManageTabBar extends StatelessWidget { + /// 创建一组固定高度且满足大屏触控尺寸的内容 Tab。 + const FaceManageTabBar({ + super.key, + required this.selectedTab, + required this.onChanged, + }); + + /// 当前选中的业务视图。 + final FaceManageTab selectedTab; + + /// 用户点击不同 Tab 时的回调;异步列表刷新由控制器负责。 + final ValueChanged onChanged; + + /// 按当前 ForUI 主题和终端设计令牌构建选中态、按压态及无障碍语义。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + constraints: const BoxConstraints(minHeight: 56), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: 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: Container( + constraints: const BoxConstraints(minHeight: 48), + 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.base.copyWith( + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + ), + ), + ), + ), + ), + ); + }).toList(growable: false), + ), + ); + } +} + +/// 构建当前系统类型适配的姓名搜索框。 +Widget _buildFaceNameField(FaceManageController controller) => Obx( () => FTextField( - textInputAction: faceC.sysType.value == SysType.SUPERMARKET + textInputAction: controller.sysType.value == SysType.SUPERMARKET ? TextInputAction.none : TextInputAction.newline, - keyboardType: faceC.sysType.value == SysType.SUPERMARKET + keyboardType: controller.sysType.value == SysType.SUPERMARKET ? TextInputType.none : TextInputType.multiline, - controller: faceC.usernameC, + controller: controller.usernameC, hint: '请输入姓名', maxLines: 1, - onSubmit: (_) => faceC.doPage(), + onSubmit: (_) => controller.doPage(), ), - ).width(300.w), - FButton( - prefix: FIcon( - FAssets.icons.ban, - size: theme.typography.base.fontSize, + ).width(300.w); + +/// 构建人脸搜索与既有同步操作区;全租户同步仅在非生产调试环境出现。 +Widget buildFaceSearch(BuildContext context) { + final controller = Get.find(); + final theme = FTheme.of(context); + return Obx(() { + final busy = controller.operationInProgress; + return [ + _buildFaceNameField(controller), + FButton( + prefix: FIcon(FAssets.icons.ban, size: theme.typography.base.fontSize), + label: const Text('重置'), + onPress: busy ? null : controller.doReset, ), - label: Text( - '重置', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), + FButton( + prefix: + FIcon(FAssets.icons.search, size: theme.typography.base.fontSize), + label: const Text('搜索'), + onPress: busy ? null : controller.doPage, ), - onPress: () => faceC.doReset(), - ), - FButton( - prefix: FIcon( - FAssets.icons.search, - size: theme.typography.base.fontSize, - ), - label: Text( - '搜索', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: () => faceC.doPage(), - ), - FButton( - prefix: FIcon( - FAssets.icons.scanFace, - size: theme.typography.base.fontSize, - ), - label: Text( - '同步本部门人脸', - style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, - ), - ), - style: FButtonStyle.outline, - onPress: () => faceC.asyncFace(isAll: false), - ), - if (!AppSysTool.isProd()) FButton( prefix: FIcon( FAssets.icons.scanFace, size: theme.typography.base.fontSize, ), - label: Text( - '同步全部人脸', - style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, - ), - ), + label: Text(controller.isSyncing.value ? '同步中...' : '同步本部门人脸'), style: FButtonStyle.outline, - onPress: () => faceC.asyncFace(isAll: true), + onPress: busy ? null : () => controller.asyncFace(isAll: false), ), - const Spacer(), - FButton( - label: Text( - '删除全部人脸', - style: theme.typography.base.copyWith(color: Colors.white), + if (!AppSysTool.isProd()) + FButton( + prefix: FIcon( + FAssets.icons.scanFace, + size: theme.typography.base.fontSize, + ), + label: const Text('同步全部人脸'), + style: FButtonStyle.outline, + onPress: busy ? null : () => controller.asyncFace(isAll: true), + ), + const Spacer(), + FButton( + prefix: FIcon( + FAssets.icons.trash2, + color: Colors.white, + size: theme.typography.base.fontSize, + ), + label: const Text('删除全部人脸'), + style: FButtonStyle.destructive, + onPress: busy ? null : controller.delAllFace, ), - style: FButtonStyle.destructive, - onPress: () => faceC.delAllFace(), - ), - ].toRowSpace(space: 20.w); + ].toRowSpace(space: 12.w); + }); +} + +/// 构建低质量页摘要、搜索和互斥操作区。 +Widget _buildLowQualityOperations(BuildContext context) { + final controller = Get.find(); + final theme = FTheme.of(context); + 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: [ + Wrap( + spacing: 8.w, + runSpacing: 8.h, + children: [ + _QualityMetric( + label: '当前阈值', + value: + controller.libraryQualityThreshold.value.toStringAsFixed(2), + icon: FAssets.icons.slidersHorizontal, + color: XfjDesignTokens.primary, + ), + _QualityMetric( + label: '低质量', + value: '$low', + icon: FAssets.icons.triangleAlert, + color: XfjDesignTokens.danger, + ), + _QualityMetric( + label: '待检测', + value: '$pending', + icon: FAssets.icons.image, + color: XfjDesignTokens.warning, + ), + _QualityMetric( + label: '检测失败', + value: '$failed', + icon: FAssets.icons.circleAlert, + color: XfjDesignTokens.danger, + ), + ], + ), + SizedBox(height: 8.h), + Row( + children: [ + _buildFaceNameField(controller), + SizedBox(width: 12.w), + FButton( + prefix: FIcon( + FAssets.icons.ban, + size: theme.typography.base.fontSize, + ), + label: const Text('重置'), + onPress: busy ? null : controller.doReset, + ), + SizedBox(width: 12.w), + FButton( + prefix: FIcon( + FAssets.icons.search, + size: theme.typography.base.fontSize, + ), + label: const Text('搜索'), + onPress: busy ? null : controller.doPage, + ), + const Spacer(), + FButton( + prefix: FIcon( + FAssets.icons.scanFace, + size: theme.typography.base.fontSize, + ), + label: Text( + controller.isAuditing.value ? '检测中...' : '检测存量人脸', + ), + style: FButtonStyle.outline, + onPress: busy || (pending + failed == 0) + ? null + : controller.auditLibraryFaces, + ), + SizedBox(width: 12.w), + FButton( + prefix: FIcon( + FAssets.icons.refreshCw, + size: theme.typography.base.fontSize, + ), + label: const Text('失败重试'), + style: FButtonStyle.outline, + onPress: busy || failed == 0 + ? null + : () => controller.auditLibraryFaces(failedOnly: true), + ), + SizedBox(width: 12.w), + FButton( + prefix: FIcon( + FAssets.icons.trash2, + color: Colors.white, + size: theme.typography.base.fontSize, + ), + label: Text(controller.isDeleting.value ? '删除中...' : '一键删除'), + style: FButtonStyle.destructive, + onPress: + 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, + }); + + final String label; + final String value; + final SvgAsset icon; + final Color color; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + constraints: BoxConstraints(minWidth: 160.w, minHeight: 56.h), + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon(icon, color: color, size: 20.sp), + SizedBox(width: 8.w), + Text( + label, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(width: 12.w), + Text( + value, + style: theme.typography.lg.copyWith( + color: color, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ); + } +} + +/// 显示存量质量检测百分比和成功、失败数量。 +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( + XfjDesignTokens.primary, + ), + ), + ), + ), + SizedBox(width: 14.w), + Text('$percent% ${progress.processed}/${progress.total}'), + SizedBox(width: 14.w), + Text('成功 ${progress.successCount} 失败 ${progress.failedCount}'), + ], + ), + ), + ); + } } /// 构建支持加载、失败、空数据和分页内容的人脸本地样本网格。 -Widget buildFaceList(context) { +Widget buildFaceList(BuildContext context) { final theme = FTheme.of(context); - final productC = Get.find(); - return Obx( - () { - if (productC.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } - if (productC.errorMessage.isNotEmpty) { - return SettingEmpty(message: productC.errorMessage.value); - } - if (productC.faceList.isEmpty) { - return const SettingEmpty(message: '暂无本地人脸样本'); - } - return GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 5, - mainAxisSpacing: XfjDesignTokens.cardGap.h, - crossAxisSpacing: XfjDesignTokens.cardGap.w, - childAspectRatio: 1.2, - ), - itemCount: productC.faceList.length, - itemBuilder: (BuildContext context, int index) { - final v = productC.faceList[index]; - return InkWell( - onTap: () => productC.deleteFace(v), - borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), - child: Container( - padding: EdgeInsets.all(14.w), - decoration: BoxDecoration( - color: XfjDesignTokens.surface, - border: Border.all(color: XfjDesignTokens.line), - borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), - boxShadow: XfjDesignTokens.surfaceShadow, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ClipOval( - child: appImg(v.path, width: 72.w, height: 72.w), - ), - SizedBox(height: 10.h), - Text( - v.userName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.typography.base.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 5.h), - Text( - '用户编号:${_maskFaceId(v.userId.toString())}', - style: theme.typography.xs.copyWith( - color: XfjDesignTokens.muted, - ), - ), - SizedBox(height: 3.h), - Text( - '样本编号:${_maskFaceId(v.sampleId)}', - style: theme.typography.xs.copyWith( - color: XfjDesignTokens.muted, - ), - ), - ], - ), - ), - ); - }, + final controller = Get.find(); + 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: lowQuality ? 0.93 : 1.2, + ), + itemCount: controller.faceList.length, + itemBuilder: (BuildContext context, int index) { + final face = controller.faceList[index]; + final content = Container( + padding: 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 ? 64 : 72).w, + height: (lowQuality ? 64 : 72).w, + ), + ), + ), + SizedBox(height: 8.h), + Text( + face.userName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4.h), + Text( + '用户编号:${_maskFaceId(face.userId)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 2.h), + Text( + '样本编号:${_maskFaceId(face.sampleId)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + if (lowQuality) ...[ + SizedBox(height: 8.h), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FIcon( + FAssets.icons.triangleAlert, + color: XfjDesignTokens.danger, + size: 16.sp, + ), + SizedBox(width: 5.w), + Text( + '低质量 ${face.qualityScore?.toStringAsFixed(2) ?? '-'}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.danger, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + SizedBox(height: 8.h), + SizedBox( + width: double.infinity, + height: 48.h, + child: FButton( + prefix: FIcon( + FAssets.icons.trash2, + color: Colors.white, + size: 18.sp, + ), + label: const Text('删除'), + style: FButtonStyle.destructive, + onPress: 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 faceC = Get.isRegistered() - ? Get.find() - : Get.put(FaceManageController()); - return [ - buildFaceSearch(context), - buildFaceList(context).expanded(), - Obx( - () => appPage( - context, - pageNo: faceC.currentPage.value, - pageSize: faceC.pageSize.value, - total: faceC.totalPage.value, - onPageChanged: (pageNo) => faceC.doPage(page: pageNo), + final controller = Get.find(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildFaceTabs(context), + SizedBox(height: XfjDesignTokens.cardGap.h), + 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), + ), + ], + ); + }), ), - ), - ].toColumnSpace(space: XfjDesignTokens.cardGap.h); + ], + ); } String _maskFaceId(String value) { diff --git a/lib/pages/terminal/setting/widgets/setting_face_plugin.dart b/lib/pages/terminal/setting/widgets/setting_face_plugin.dart index 326d5b8..0f95ece 100644 --- a/lib/pages/terminal/setting/widgets/setting_face_plugin.dart +++ b/lib/pages/terminal/setting/widgets/setting_face_plugin.dart @@ -9,9 +9,7 @@ import 'package:pj_supermarket_cloud/pages/terminal/setting/face_setting_control import 'setting_components.dart'; Widget settingFacePlugin(BuildContext context) { - final controller = Get.isRegistered() - ? Get.find() - : Get.put(FaceSettingController()); + final controller = Get.find(); return Obx(() { if (controller.isLoading.value && controller.status.value == null) { return const Center(child: CircularProgressIndicator()); @@ -138,6 +136,22 @@ class _RecognitionConfigurationGroup extends StatelessWidget { ), ), ), + _RecognitionValueRow( + icon: FAssets.icons.image, + label: '在库人脸质量阈值', + subtitle: '仅用于低质量人脸诊断;低于该分数会出现在低质量 Tab,仍继续参与识别', + value: configuration.libraryFaceQualityThreshold, + min: 0, + max: 1, + step: 0.01, + formatter: _decimal, + enabled: !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith( + libraryFaceQualityThreshold: value, + ), + ), + ), SettingInfoRow( icon: configuration.keepMaxFace ? FAssets.icons.userRoundCheck diff --git a/lib/pages/terminal/setting/widgets/setting_meal_periods.dart b/lib/pages/terminal/setting/widgets/setting_meal_periods.dart new file mode 100644 index 0000000..84cc899 --- /dev/null +++ b/lib/pages/terminal/setting/widgets/setting_meal_periods.dart @@ -0,0 +1,407 @@ +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'; + +import '../controller.dart'; +import 'setting_components.dart'; + +/// 早餐、中餐和晚餐时间范围编辑器;草稿仅在点击保存后写入本机设置。 +class SettingMealPeriods extends StatefulWidget { + /// 创建使用设置模块同一 Controller 的餐段配置页。 + const SettingMealPeriods({super.key}); + + /// 创建持有已保存配置和页面草稿的状态。 + @override + State createState() => _SettingMealPeriodsState(); +} + +/// 餐段配置页状态;草稿变更只影响当前页面,保存成功后才更新基准快照。 +class _SettingMealPeriodsState extends State { + /// 设置模块 Binding 注册的控制器,负责持久化和保存中状态。 + late final SettingController _controller; + + /// 最近一次加载或保存成功的餐段快照,用于判断页面是否存在未保存变更。 + late List _savedPeriods; + + /// 当前页面可编辑草稿,时间字段变化时通过 setState 局部刷新。 + late List _draftPeriods; + + /// 从控制器读取已持久化配置并创建互不共享对象的页面草稿。 + @override + void initState() { + super.initState(); + _controller = Get.find(); + _savedPeriods = _copyPeriods( + _controller.productMealPeriodSchedule.value.periods, + ); + _draftPeriods = _copyPeriods(_savedPeriods); + } + + /// 构建规则说明、三餐编辑区、就地校验反馈和保存操作。 + @override + Widget build(BuildContext context) { + final error = ProductMealPeriodSchedule.validate(_draftPeriods); + final dirty = !_samePeriods(_savedPeriods, _draftPeriods); + return Obx(() { + final saving = _controller.productMealPeriodSaving.value; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SettingNotice( + text: + '时间范围按“开始时间(含)—结束时间(不含)”生效。餐段之间可以留空档,但不能交叉重叠;未覆盖时间不会触发自动切换,业务页面将保留当前人工选择。', + background: XfjDesignTokens.primarySoft, + borderColor: XfjDesignTokens.primary.withValues(alpha: 0.25), + icon: FAssets.icons.info, + dense: true, + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: '每日餐段', + dense: true, + child: Column( + children: List.generate(_draftPeriods.length, (index) { + final period = _draftPeriods[index]; + return _MealPeriodRow( + key: ValueKey('meal-period-${period.hourType.code}'), + period: period, + showDivider: index != _draftPeriods.length - 1, + enabled: !saving, + onChanged: (next) => setState(() { + _draftPeriods[index] = next; + }), + ); + }), + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: error == null + ? SettingNotice( + key: const ValueKey('meal-period-valid'), + text: _coverageSummary(_draftPeriods), + color: XfjDesignTokens.success, + background: const Color(0xFFECFBF3), + borderColor: XfjDesignTokens.success.withValues( + alpha: 0.3, + ), + icon: FAssets.icons.circleCheck, + dense: true, + ) + : SettingNotice( + key: const ValueKey('meal-period-error'), + text: error, + color: XfjDesignTokens.danger, + background: const Color(0xFFFFF3F2), + borderColor: XfjDesignTokens.danger.withValues( + alpha: 0.3, + ), + icon: FAssets.icons.circleAlert, + dense: true, + ), + ), + SizedBox(height: 12.h), + Wrap( + alignment: WrapAlignment.end, + spacing: 8.w, + runSpacing: 8.h, + children: [ + SettingActionButton( + label: '恢复默认', + icon: FAssets.icons.rotateCcw, + enabled: !saving, + onTap: _restoreDefaults, + dense: true, + ), + SettingActionButton( + label: saving ? '保存中...' : '保存配置', + icon: FAssets.icons.save, + primary: true, + enabled: !saving && dirty && error == null, + onTap: _save, + dense: true, + ), + ], + ), + ], + ), + ); + }); + } + + /// 将草稿恢复为产品默认三餐时间,不立即写入本机设置。 + void _restoreDefaults() { + setState(() { + _draftPeriods = _copyPeriods(ProductMealPeriodSchedule.defaults.periods); + }); + } + + /// 保存当前草稿;只有控制器确认成功后才更新页面的已保存快照。 + Future _save() async { + final snapshot = _copyPeriods(_draftPeriods); + final saved = await _controller.saveProductMealPeriods(snapshot); + if (!mounted || !saved) return; + setState(() => _savedPeriods = _copyPeriods(snapshot)); + } +} + +/// 单个餐段编辑行,在常规终端宽度和窄内容区之间自适应排列。 +class _MealPeriodRow extends StatelessWidget { + /// 创建餐段行,并由父页面接收不可变配置副本的变化。 + const _MealPeriodRow({ + super.key, + required this.period, + required this.showDivider, + required this.enabled, + required this.onChanged, + }); + + /// 当前餐段草稿,包含业务餐段类型与分钟边界。 + final ProductMealPeriod period; + + /// 是否在本行底部绘制与下一餐段之间的分隔线。 + final bool showDivider; + + /// 时间字段是否允许操作;保存期间为 false,避免重复提交时继续改草稿。 + final bool enabled; + + /// 时间变化后的不可变餐段副本回调。 + final ValueChanged onChanged; + + /// 根据实际可用宽度选择单行或纵向时间表单布局。 + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // 设置内容区变窄时改为纵向字段,避免时间控件被压缩后换行或溢出。 + final useVerticalLayout = constraints.maxWidth < 760.w; + final content = useVerticalLayout + ? _verticalContent(context) + : _horizontalContent(context); + return content + .padding(horizontal: 16.w, vertical: 10.h) + .constrained(minHeight: 76.h) + .decorated( + border: showDivider + ? const Border( + bottom: BorderSide(color: XfjDesignTokens.line), + ) + : null, + ); + }, + ); + } + + /// 构建常规横屏终端使用的高密度单行布局,将剩余宽度等分给两个时间字段。 + Widget _horizontalContent(BuildContext context) { + final theme = FTheme.of(context); + return [ + _mealIdentity(context).width(156.w), + SizedBox(width: 20.w), + _startField().expanded(), + SizedBox(width: 10.w), + Text( + '至', + maxLines: 1, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(width: 10.w), + _endField().expanded(), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center); + } + + /// 构建较窄内容区使用的纵向布局,字段占满整行并保持 48dp 以上点击高度。 + Widget _verticalContent(BuildContext context) => [ + _mealIdentity(context), + SizedBox(height: 8.h), + _startField(), + SizedBox(height: 8.h), + _endField(), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + + /// 构建只包含图标与餐段名称的紧凑标识,餐段编码不再出现在界面中。 + Widget _mealIdentity(BuildContext context) { + final theme = FTheme.of(context); + return [ + FIcon( + period.hourType.icon, + size: 18.sp, + color: XfjDesignTokens.primary, + ).center().width(36.w).height(36.w).decorated( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + SizedBox(width: 10.w), + Text( + period.hourType.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ).expanded(), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center); + } + + /// 构建当前餐段的开始时间字段,选项沿用半小时粒度。 + Widget _startField() => _MealTimeField( + key: ValueKey('${period.hourType.code}-start'), + label: '开始时间', + value: period.startMinutes, + options: _startTimeOptions, + enabled: enabled, + onChanged: (value) => onChanged(period.copyWith(startMinutes: value)), + ); + + /// 构建当前餐段的结束时间字段,允许选择一天结束边界 24:00。 + Widget _endField() => _MealTimeField( + key: ValueKey('${period.hourType.code}-end'), + label: '结束时间', + value: period.endMinutes, + options: _endTimeOptions, + enabled: enabled, + onChanged: (value) => onChanged(period.copyWith(endMinutes: value)), + ); +} + +/// 餐段时间下拉字段,使用半小时选项和等宽数字稳定显示时间。 +class _MealTimeField extends StatelessWidget { + /// 创建带可见标签、无障碍语义和真实禁用态的时间字段。 + const _MealTimeField({ + super.key, + required this.label, + required this.value, + required this.options, + required this.enabled, + required this.onChanged, + }); + + /// 字段可见标签及无障碍名称。 + final String label; + + /// 当前选择的当日分钟数,结束字段允许使用 1440 表示 24:00。 + final int value; + + /// 可选当日分钟数列表,按半小时递增。 + final List options; + + /// 是否允许展开下拉框并选择时间。 + final bool enabled; + + /// 用户选中有效分钟值后的回调。 + final ValueChanged onChanged; + + /// 构建占满父级可用宽度且时间值固定单行的下拉字段。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Semantics( + label: label, + value: formatMealPeriodTime(value), + enabled: enabled, + child: [ + Text( + label, + maxLines: 1, + softWrap: false, + style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(width: 8.w), + DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + isDense: true, + menuMaxHeight: 420.h, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + icon: FIcon( + FAssets.icons.chevronDown, + size: 16.sp, + color: XfjDesignTokens.muted, + ), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + items: options + .map( + (minutes) => DropdownMenuItem( + value: minutes, + child: Text( + formatMealPeriodTime(minutes), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + ), + ), + ) + .toList(growable: false), + onChanged: enabled + ? (next) => next == null ? null : onChanged(next) + : null, + ), + ).expanded(), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 12.w) + .constrained(minHeight: 52.h, minWidth: 0) + .decorated( + color: enabled ? XfjDesignTokens.surface : XfjDesignTokens.control, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + ); + } +} + +/// 开始时间半小时选项,范围为 00:00 至 23:30。 +final _startTimeOptions = List.generate(48, (index) => index * 30); + +/// 结束时间半小时选项,范围为 00:30 至 24:00。 +final _endTimeOptions = List.generate(48, (index) => (index + 1) * 30); + +/// 将当日分钟数格式化为固定两位小时和分钟的终端展示值。 +String formatMealPeriodTime(int minutes) { + if (minutes == 24 * 60) return '24:00'; + final hour = minutes ~/ 60; + final minute = minutes % 60; + return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; +} + +/// 生成当前三餐配置的可读摘要,供有效状态提示条展示。 +String _coverageSummary(List periods) { + final ranges = periods + .map( + (period) => + '${period.hourType.title} ${formatMealPeriodTime(period.startMinutes)}–${formatMealPeriodTime(period.endMinutes)}', + ) + .join(','); + return '当前配置有效:$ranges。'; +} + +/// 深拷贝餐段列表,避免页面草稿直接修改控制器持有的配置对象。 +List _copyPeriods(Iterable source) => + source.map((period) => period.copyWith()).toList(growable: false); + +/// 按顺序比较两组餐段,用于控制保存按钮是否可用。 +bool _samePeriods(List left, List right) { + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index++) { + if (left[index] != right[index]) return false; + } + return true; +} diff --git a/lib/pages/terminal/setting/widgets/setting_product.dart b/lib/pages/terminal/setting/widgets/setting_product.dart index b5770a9..67c0cc1 100644 --- a/lib/pages/terminal/setting/widgets/setting_product.dart +++ b/lib/pages/terminal/setting/widgets/setting_product.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:forui/forui.dart'; @@ -9,6 +11,7 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import 'setting_components.dart'; +/// 商品管理页控制器,食堂模式下按应用级餐段读取商品快照。 class ProductManageController extends GetxController { final appC = Get.find(); @@ -35,33 +38,67 @@ class ProductManageController extends GetxController { RxString errorMessage = ''.obs; - final hourTypeC = FRadioSelectGroupController( - value: ProductHourType.MORNING.code, - ); - - @override - void onInit() async { - super.onInit(); - sysSetting = await AppSettingTool.getSysSetting(); - sysType.value = sysSetting.sysType; - await getProductList(); - hourTypeC.addValueListener((v) async { - final currentHourType = ProductHourType.values.fromCode( - v.first, - ); - if (currentHourType == null) return; - hourType = currentHourType; - await doPage(page: 1); - }); + /// 获取应用级餐段控制器,兼容独立打开设置页的非标准启动方式。 + ProductHourTypeControl get _hourTypeControl { + if (!Get.isRegistered()) { + Get.put(ProductHourTypeControl(), permanent: true); + } + return Get.find(); } + /// 商品管理页复用的全局 Forui 餐段选择器。 + FRadioSelectGroupController get hourTypeC => + _hourTypeControl.selection; + + /// 全局餐段选择监听句柄,页面销毁时用于解除订阅。 + late final ValueChanged> _hourTypeListener; + + /// 商品管理页首次初始化任务,供餐段时间校验等待缓存恢复。 + Future? _initializationFuture; + + /// 商品列表完成首次加载后为 true,此后餐段变化才触发重新分页。 + bool _mealResourcesReady = false; + + /// 商品管理页餐段初始化完成信号。 + Future get initialization => _initializationFuture ?? Future.value(); + + /// 注册全局餐段监听并开始读取设置与商品列表。 + @override + void onInit() { + super.onInit(); + _hourTypeListener = _handleHourTypeSelection; + hourTypeC.addValueListener(_hourTypeListener); + _initializationFuture = _initialize(); + } + + /// 等待全局餐段缓存后加载终端模式与对应商品列表。 + Future _initialize() async { + await _hourTypeControl.initAsync(); + sysSetting = await AppSettingTool.getSysSetting(); + sysType.value = sysSetting.sysType; + hourType = _hourTypeControl.selected.value; + await getProductList(); + _mealResourcesReady = true; + } + + /// 页面销毁时释放页面资源,并保留应用级餐段选择器。 @override void onClose() { super.onClose(); - hourTypeC.dispose(); + hourTypeC.removeValueListener(_hourTypeListener); selfCodeC.dispose(); } + /// 接收全局餐段变化,并在列表初始化完成后回到第一页重新查询。 + void _handleHourTypeSelection(Set values) { + final currentHourType = ProductHourType.values.fromCode(values.firstOrNull); + if (currentHourType == null || currentHourType == ProductHourType.TOTAL) { + return; + } + hourType = currentHourType; + if (_mealResourcesReady) unawaited(doPage(page: 1)); + } + // 更新商品和分类 Future updateProductAndClassify() async { EasyLoading.show( @@ -239,6 +276,8 @@ Widget _buildSearch(BuildContext context) { () => appRadioHourTypeDialog( context, productC.hourTypeC, + checkConfiguredTime: true, + ready: productC.initialization, ).offstage( offstage: productC.sysType.value != SysType.CANTEEN, ), diff --git a/pubspec.yaml b/pubspec.yaml index 5349bb4..e63687f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "资安信智慧后勤-测试" publish_to: "none" -version: 1.4.1+141 +version: 1.4.2+142 environment: sdk: ^3.6.0 diff --git a/test/common/app_radio_hour_type_dialog_test.dart b/test/common/app_radio_hour_type_dialog_test.dart new file mode 100644 index 0000000..d96ca51 --- /dev/null +++ b/test/common/app_radio_hour_type_dialog_test.dart @@ -0,0 +1,255 @@ +import 'dart:math' as math; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('紧凑餐段选择器在窄宽度下完整显示当前餐段', (tester) async { + final controller = FRadioSelectGroupController(value: '1'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _testApp( + Builder( + builder: (context) => SizedBox( + width: 140, + child: appRadioHourTypeDialog( + context, + controller, + title: '餐段', + compact: true, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('餐段'), findsOneWidget); + expect(find.text('早餐'), findsOneWidget); + expect(tester.getSize(find.text('早餐')).width, greaterThan(0)); + expect(tester.takeException(), isNull); + }); + + testWidgets('餐段选择器高度跟随工具栏按钮大小', (tester) async { + final controller = FRadioSelectGroupController(value: '2'); + addTearDown(controller.dispose); + addTearDown(() => XfjDesignTokens.applyThemeMetrics( + cardRadius: SettingCardRadius.standard.radius, + controlRadius: SettingButtonRadius.standard.radius, + buttonSizeScale: SettingButtonSize.standard.scale, + )); + + for (final size in SettingButtonSize.values) { + XfjDesignTokens.applyThemeMetrics( + cardRadius: SettingCardRadius.standard.radius, + controlRadius: SettingButtonRadius.standard.radius, + buttonSizeScale: size.scale, + ); + await tester.pumpWidget( + _testApp( + Builder( + builder: (context) => SizedBox( + width: 280, + child: appRadioHourTypeDialog( + context, + controller, + title: '餐段', + compact: true, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final selectorHeight = + tester.getSize(find.byType(FSelectMenuTile)).height; + expect( + selectorHeight, + closeTo(math.max(XfjDesignTokens.controlHeight.h, 48), 0.01), + ); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('当前餐段与时间配置不一致时确认可切换', (tester) async { + final controller = FRadioSelectGroupController(value: '1'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _testApp( + Builder( + builder: (context) => appRadioHourTypeDialog( + context, + controller, + checkConfiguredTime: true, + ready: Future.value(), + currentHourTypeLoader: () async => ProductHourType.NOON, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('当前选择的餐段可能有问题'), findsOneWidget); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + + expect(controller.value, {'2'}); + }); + + testWidgets('当前餐段与时间配置不一致时拒绝保持原选择', (tester) async { + final controller = FRadioSelectGroupController(value: '1'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _testApp( + Builder( + builder: (context) => appRadioHourTypeDialog( + context, + controller, + checkConfiguredTime: true, + ready: Future.value(), + currentHourTypeLoader: () async => ProductHourType.NOON, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + + expect(controller.value, {'1'}); + }); + + testWidgets('用户手动选择不匹配餐段时确认前不提交且确认后切到配置餐段', (tester) async { + final controller = FRadioSelectGroupController(value: '1'); + addTearDown(controller.dispose); + final committedValues = >[]; + controller.addValueListener(committedValues.add); + var configured = ProductHourType.MORNING; + + await tester.pumpWidget( + _testApp( + Builder( + builder: (context) => appRadioHourTypeDialog( + context, + controller, + checkConfiguredTime: true, + ready: Future.value(), + currentHourTypeLoader: () async => configured, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('当前选择的餐段可能有问题'), findsNothing); + + configured = ProductHourType.NOON; + await tester.tap(find.byType(FSelectMenuTile)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('meal-slot-option-3'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('当前选择的餐段可能有问题'), findsOneWidget); + expect(controller.value, {'1'}); + expect(committedValues, isEmpty); + + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + + expect(controller.value, {'2'}); + expect(committedValues, [ + {'2'} + ]); + }); + + testWidgets('用户拒绝切换时在弹框关闭后才提交人工候选餐段', (tester) async { + final controller = FRadioSelectGroupController(value: '1'); + addTearDown(controller.dispose); + final committedValues = >[]; + controller.addValueListener(committedValues.add); + var configured = ProductHourType.MORNING; + + await tester.pumpWidget( + _testApp( + Builder( + builder: (context) => appRadioHourTypeDialog( + context, + controller, + checkConfiguredTime: true, + ready: Future.value(), + currentHourTypeLoader: () async => configured, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + configured = ProductHourType.NOON; + await tester.tap(find.byType(FSelectMenuTile)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('meal-slot-option-3'))); + await tester.pumpAndSettle(); + + expect(controller.value, {'1'}); + expect(committedValues, isEmpty); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + + expect(controller.value, {'3'}); + expect(committedValues, [ + {'3'} + ]); + }); + + testWidgets('用户选择符合当前配置的餐段时直接提交且不弹框', (tester) async { + final controller = FRadioSelectGroupController(value: '1'); + addTearDown(controller.dispose); + var configured = ProductHourType.MORNING; + + await tester.pumpWidget( + _testApp( + Builder( + builder: (context) => appRadioHourTypeDialog( + context, + controller, + checkConfiguredTime: true, + ready: Future.value(), + currentHourTypeLoader: () async => configured, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + configured = ProductHourType.NOON; + await tester.tap(find.byType(FSelectMenuTile)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('meal-slot-option-2'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('当前选择的餐段可能有问题'), findsNothing); + expect(controller.value, {'2'}); + }); +} + +Widget _testApp(Widget child) { + return ScreenUtilInit( + designSize: const Size(1920, 1080), + builder: (_, __) => MaterialApp( + home: FTheme( + data: FThemes.zinc.light, + child: + Scaffold(body: Center(child: SizedBox(width: 360, child: child))), + ), + ), + ); +} diff --git a/test/common/cashier_checkout_dialog_test.dart b/test/common/cashier_checkout_dialog_test.dart index 8f56bbf..cc4bddb 100644 --- a/test/common/cashier_checkout_dialog_test.dart +++ b/test/common/cashier_checkout_dialog_test.dart @@ -33,7 +33,10 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('空购物车主支付按钮使用真实禁用回调', (tester) async { + testWidgets('空购物车主支付按钮禁用且窄宽度不溢出', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 900); + addTearDown(tester.view.reset); addTearDown(Get.reset); Get.put(AppControl()).isNetwork.value = true; var payCount = 0; @@ -52,8 +55,10 @@ void main() { onPay: () => payCount++, onCancel: () {}, ), + width: 360, ), ); + await tester.pumpAndSettle(); final payLabel = find.textContaining('立即支付'); final payInkWell = find.ancestor( @@ -64,6 +69,7 @@ void main() { expect(tester.widget(payInkWell).onTap, isNull); await tester.tap(payLabel); expect(payCount, 0); + expect(tester.takeException(), isNull); }); testWidgets('食堂结算弹框展示库存并通过业务回调刷新数量和金额', (tester) async { @@ -497,7 +503,7 @@ void main() { } /// 构建不依赖 GetX 业务控制器的收银组件测试环境。 -Widget _cashierWidgetTestApp(Widget child) { +Widget _cashierWidgetTestApp(Widget child, {double width = 500}) { return ScreenUtilInit( designSize: const Size(1920, 1080), minTextAdapt: true, @@ -505,7 +511,7 @@ Widget _cashierWidgetTestApp(Widget child) { builder: (_, __) => MaterialApp( home: FTheme( data: FThemes.zinc.light, - child: Scaffold(body: SizedBox(width: 500, child: child)), + child: Scaffold(body: SizedBox(width: width, child: child)), ), ), ); diff --git a/test/common/face_quality_database_test.dart b/test/common/face_quality_database_test.dart new file mode 100644 index 0000000..60a6150 --- /dev/null +++ b/test/common/face_quality_database_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +// ignore_for_file: depend_on_referenced_packages + +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/common/services/database/database_migration.dart'; + +/// 验证本地人脸质量迁移、阈值比较和查询统计协议。 +void main() { + sqfliteFfiInit(); + + setUpAll(() { + databaseFactory = databaseFactoryFfi; + }); + + test('11到12迁移保留旧记录并补齐待检测默认值', () async { + final database = + await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + addTearDown(database.close); + await database.execute(''' + CREATE TABLE face ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + userId TEXT NOT NULL, + sampleId TEXT NOT NULL, + userName TEXT NOT NULL, + deptId TEXT, + deptName TEXT, + imagePath TEXT NOT NULL, + path TEXT, + faceModel TEXT, + createTime TEXT, + updateTime TEXT + ) + '''); + await database.insert('face', { + 'userId': 'u1', + 'sampleId': '1001', + 'userName': '历史用户', + 'imagePath': '/remote/face.jpg', + 'faceModel': '[1,2,3]', + }); + + await DatabaseMigration.migration11To12.migrate(database); + + final columns = await database.rawQuery('PRAGMA table_info(face)'); + final columnNames = columns.map((column) => column['name']).toSet(); + final rows = await database.query('face'); + final indexes = await database.rawQuery('PRAGMA index_list(face)'); + expect( + columnNames, + containsAll(const { + 'qualityScore', + 'qualityStatus', + 'qualityCheckedAt', + 'qualityErrorCode', + })); + expect(rows, hasLength(1)); + expect(rows.single['sampleId'], '1001'); + expect(rows.single['qualityStatus'], FaceQualityStatus.pending.code); + expect(rows.single['qualityCheckedAt'], ''); + expect( + indexes.map((index) => index['name']), + contains('index_face_qualityStatus_qualityScore'), + ); + }); + + test('低质量查询严格使用小于阈值且排除待检测和失败', () async { + final database = + await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + addTearDown(database.close); + await database.execute(''' + CREATE TABLE face ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + userName TEXT NOT NULL, + qualityScore REAL, + qualityStatus TEXT NOT NULL + ) + '''); + await database.rawInsert( + 'INSERT INTO face(userName, qualityScore, qualityStatus) VALUES (?, ?, ?)', + ['低于阈值', 0.59, FaceQualityStatus.detected.code], + ); + await database.rawInsert( + 'INSERT INTO face(userName, qualityScore, qualityStatus) VALUES (?, ?, ?)', + ['等于阈值', 0.60, FaceQualityStatus.detected.code], + ); + await database.rawInsert( + 'INSERT INTO face(userName, qualityScore, qualityStatus) VALUES (?, ?, ?)', + ['待检测', null, FaceQualityStatus.pending.code], + ); + await database.rawInsert( + 'INSERT INTO face(userName, qualityScore, qualityStatus) VALUES (?, ?, ?)', + ['检测失败', null, FaceQualityStatus.failed.code], + ); + + final lowAtSixty = await database.rawQuery(''' + SELECT userName FROM face + WHERE qualityStatus = 'detected' + AND qualityScore IS NOT NULL + AND qualityScore < ? + ORDER BY id ASC + ''', [0.60]); + final lowAtFifty = await database.rawQuery(''' + SELECT userName FROM face + WHERE qualityStatus = 'detected' + AND qualityScore IS NOT NULL + AND qualityScore < ? + ''', [0.50]); + final summary = await database.rawQuery(''' + SELECT + SUM(CASE WHEN qualityStatus = 'pending' THEN 1 ELSE 0 END) AS pendingCount, + SUM(CASE WHEN qualityStatus = 'failed' THEN 1 ELSE 0 END) AS failedCount + FROM face + '''); + + expect(lowAtSixty.map((row) => row['userName']), ['低于阈值']); + expect(lowAtFifty, isEmpty); + expect(summary.single['pendingCount'], 1); + expect(summary.single['failedCount'], 1); + }); +} diff --git a/test/common/face_quality_models_test.dart b/test/common/face_quality_models_test.dart new file mode 100644 index 0000000..5047495 --- /dev/null +++ b/test/common/face_quality_models_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 验证在库人脸质量配置、跨端状态协议和服务端幂等删除结果。 +void main() { + test('旧版识别配置缺少在库阈值时使用0.60默认值', () { + final configuration = FaceRecognitionConfiguration.fromMap(const {}); + + expect(configuration.libraryFaceQualityThreshold, 0.60); + expect( + const FaceRecognitionConfiguration.defaults().libraryFaceQualityThreshold, + 0.60, + ); + }); + + test('在库阈值允许0和1边界并限制越界输入', () { + final zero = FaceRecognitionConfiguration.fromMap(const { + 'libraryFaceQualityThreshold': 0, + }); + final one = FaceRecognitionConfiguration.fromMap(const { + 'libraryFaceQualityThreshold': 1, + }); + final below = FaceRecognitionConfiguration.fromMap(const { + 'libraryFaceQualityThreshold': -0.1, + }); + final above = FaceRecognitionConfiguration.fromMap(const { + 'libraryFaceQualityThreshold': 1.1, + }); + + expect(zero.libraryFaceQualityThreshold, 0.0); + expect(one.libraryFaceQualityThreshold, 1.0); + expect(below.libraryFaceQualityThreshold, 0.0); + expect(above.libraryFaceQualityThreshold, 1.0); + expect( + const FaceRecognitionConfiguration.defaults() + .copyWith(libraryFaceQualityThreshold: 1.5) + .libraryFaceQualityThreshold, + 1.0, + ); + }); + + test('质量状态解析兼容未知值且检测结果保留分数和错误码', () { + final detected = FaceFeatureExtractionResult.fromMap(const { + 'success': true, + 'message': '成功', + 'featureData': [1, 2, 255], + 'qualityStatus': 'detected', + 'qualityScore': 0.59, + }); + final failed = FaceImageQualityResult.fromMap(const { + 'sampleId': 1001, + 'status': 'failed', + 'message': '未授权', + 'errorCode': 90114, + }); + + expect(detected.success, isTrue); + expect(detected.featureData, [1, 2, 255]); + expect(detected.qualityStatus, FaceQualityStatus.detected); + expect(detected.qualityScore, 0.59); + expect(failed.sampleId, '1001'); + expect(failed.status, FaceQualityStatus.failed); + expect(failed.errorCode, 90114); + expect( + FaceQualityStatus.fromCode('future_status'), FaceQualityStatus.pending); + }); + + test('质量检测进度限制在0到1并保留成功失败数量', () { + final progress = FaceQualityAuditProgress.fromMap(const { + 'processed': 8, + 'total': 5, + 'successCount': 3, + 'failedCount': 2, + 'inProgress': false, + 'message': '检测完成', + }); + + expect(progress.fraction, 1.0); + expect(progress.successCount, 3); + expect(progress.failedCount, 2); + }); + + test('FaceEntity旧JSON默认待检测且新质量字段可往返', () { + final legacy = FaceEntity.fromJson(const { + 'userId': 'u1', + 'sampleId': '1001', + 'userName': '测试用户', + 'imagePath': '/remote/face.jpg', + }); + final checkedAt = DateTime(2026, 8, 31, 12, 30); + final entity = FaceEntity( + userId: 'u2', + sampleId: '1002', + userName: '低质量用户', + imagePath: '/remote/low.jpg', + qualityScore: 0.42, + qualityStatus: FaceQualityStatus.detected.code, + qualityCheckedAt: checkedAt, + ); + final decoded = FaceEntity.fromJson(entity.toJson()); + + expect(legacy.qualityStatus, FaceQualityStatus.pending.code); + expect(legacy.qualityScore, isNull); + expect(decoded.qualityStatus, FaceQualityStatus.detected.code); + expect(decoded.qualityScore, 0.42); + expect(decoded.qualityCheckedAt, checkedAt); + }); + + test('服务端删除响应合并已删除和已不存在编号', () { + final result = ServiceTerminalFaceDeleteResult.fromJson(const { + 'deletedSampleIds': ['1001', '1002'], + 'alreadyAbsentSampleIds': ['1002', '1003'], + }); + + expect(result.deletedSampleIds, ['1001', '1002']); + expect(result.alreadyAbsentSampleIds, ['1002', '1003']); + expect(result.confirmedSampleIds, {'1001', '1002', '1003'}); + }); +} diff --git a/test/common/product_meal_period_models_test.dart b/test/common/product_meal_period_models_test.dart new file mode 100644 index 0000000..8f58bd1 --- /dev/null +++ b/test/common/product_meal_period_models_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/setting/routes/navigation.dart'; + +void main() { + test('默认餐段沿用 ProductHourType 编码并按半开区间解析', () { + final schedule = ProductMealPeriodSchedule.defaults; + + expect( + schedule.periods.map((period) => period.hourType), + [ + ProductHourType.MORNING, + ProductHourType.NOON, + ProductHourType.NIGHT, + ], + ); + expect(schedule.resolve(DateTime(2026, 8, 31, 4, 59)), isNull); + expect( + schedule.resolve(DateTime(2026, 8, 31, 5)), + ProductHourType.MORNING, + ); + expect(schedule.resolve(DateTime(2026, 8, 31, 10, 30)), isNull); + expect( + schedule.resolve(DateTime(2026, 8, 31, 11)), + ProductHourType.NOON, + ); + expect( + schedule.resolve(DateTime(2026, 8, 31, 15, 59)), + ProductHourType.NOON, + ); + expect( + schedule.resolve(DateTime(2026, 8, 31, 16)), + ProductHourType.NIGHT, + ); + expect( + schedule.resolve(DateTime(2026, 8, 31, 23, 59)), + ProductHourType.NIGHT, + ); + }); + + test('持久化使用现有餐段 code,非法旧值安全回退默认配置', () { + final source = ProductMealPeriodSchedule.defaults.toJson(); + expect(source.map((item) => item['hourType']), ['1', '2', '3']); + expect(ProductMealPeriodSchedule.fromJson(source).periods, isNotEmpty); + + final invalid = source + .map((item) => Map.from(item)) + .toList(growable: false); + invalid[1]['startMinutes'] = 9 * 60; + expect( + ProductMealPeriodSchedule.fromJson(invalid).periods, + ProductMealPeriodSchedule.defaults.periods, + ); + + invalid[1]['startMinutes'] = 11 * 60 + 1; + expect( + ProductMealPeriodSchedule.fromJson(invalid).periods, + ProductMealPeriodSchedule.defaults.periods, + ); + }); + + test('允许空档和相邻边界,但拒绝餐段重叠及结束早于开始', () { + expect( + ProductMealPeriodSchedule.validate( + ProductMealPeriodSchedule.defaults.periods, + ), + isNull, + ); + expect( + ProductMealPeriodSchedule.validate([ + ProductMealPeriodSchedule.defaults.periods[0], + ProductMealPeriodSchedule.defaults.periods[1].copyWith( + startMinutes: 9 * 60, + ), + ProductMealPeriodSchedule.defaults.periods[2], + ]), + contains('不能重叠'), + ); + expect( + ProductMealPeriodSchedule.validate([ + ProductMealPeriodSchedule.defaults.periods[0].copyWith( + startMinutes: 10 * 60, + ), + ProductMealPeriodSchedule.defaults.periods[1], + ProductMealPeriodSchedule.defaults.periods[2], + ]), + contains('晚于开始时间'), + ); + }); + + test('餐段配置菜单默认紧跟同步设置并具有独立路由', () { + final syncIndex = SettingMenuType.values.indexOf( + SettingMenuType.syncSetting, + ); + expect( + SettingMenuType.values[syncIndex + 1], + SettingMenuType.mealPeriodSetting, + ); + expect( + SettingNavigation.routeForMenu(SettingMenuType.mealPeriodSetting), + RouteNames.settingMealPeriods, + ); + expect( + SettingNavigation.menuForRoute(RouteNames.settingMealPeriods), + SettingMenuType.mealPeriodSetting, + ); + }); +} diff --git a/test/pages/root/load_view_test.dart b/test/pages/root/load_view_test.dart new file mode 100644 index 0000000..efe6d87 --- /dev/null +++ b/test/pages/root/load_view_test.dart @@ -0,0 +1,53 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/root/load/controller.dart'; +import 'package:pj_supermarket_cloud/pages/root/load/view.dart'; + +void main() { + testWidgets('启动步骤在紧凑窗口中可滚动且不会垂直溢出', (tester) async { + // 与问题日志中的 1280×720 运行窗口一致,此时步骤区只有约 264px 高。 + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + Get.reset(); + }); + + Get.put(_TestLoadController(AppControl())); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + splitScreenMode: true, + builder: (_, __) => MaterialApp( + home: FTheme( + data: FThemes.zinc.light, + child: const LoadPage(), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(ListView), findsOneWidget); + expect(find.text('加载终端配置'), findsOneWidget); + final scrollable = tester.state(find.byType(Scrollable)); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); + expect(tester.takeException(), isNull); + }); +} + +class _TestLoadController extends LoadController { + _TestLoadController(AppControl appControl) : super(appControl: appControl); + + @override + void onReady() {} + + @override + Future retry() async {} +} diff --git a/test/pages/terminal/setting_face_quality_test.dart b/test/pages/terminal/setting_face_quality_test.dart new file mode 100644 index 0000000..371a05c --- /dev/null +++ b/test/pages/terminal/setting_face_quality_test.dart @@ -0,0 +1,45 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/pages/terminal/setting/widgets/setting_face.dart'; + +/// 验证人脸管理两个文字 Tab 的点击、选中语义和最小触控尺寸。 +void main() { + testWidgets('点击低质量人脸Tab后更新选中状态和语义', (tester) async { + await tester.binding.setSurfaceSize(const Size(1920, 1080)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var selected = FaceManageTab.all; + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + builder: (_, __) => MaterialApp( + home: FTheme( + data: FThemes.zinc.light, + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => FaceManageTabBar( + selectedTab: selected, + onChanged: (value) => setState(() => selected = value), + ), + ), + ), + ), + ), + ), + ); + + expect(find.bySemanticsLabel('人脸管理 Tab,已选中'), findsOneWidget); + expect(find.bySemanticsLabel('低质量人脸 Tab'), findsOneWidget); + final lowQualityTab = find.bySemanticsLabel('低质量人脸 Tab'); + expect(tester.getSize(lowQualityTab).height, greaterThanOrEqualTo(48)); + + await tester.tap(find.text('低质量人脸')); + await tester.pump(); + + expect(find.bySemanticsLabel('低质量人脸 Tab,已选中'), findsOneWidget); + expect(find.bySemanticsLabel('人脸管理 Tab'), findsOneWidget); + }); +} diff --git a/test/pages/terminal/setting_meal_periods_test.dart b/test/pages/terminal/setting_meal_periods_test.dart new file mode 100644 index 0000000..f0749cc --- /dev/null +++ b/test/pages/terminal/setting_meal_periods_test.dart @@ -0,0 +1,69 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/setting/controller.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/setting/widgets/setting_meal_periods.dart'; + +void main() { + testWidgets('餐段配置页展示默认基础三餐且无布局异常', (tester) async { + await tester.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + Get.reset(); + }); + Get.put(AppControl()); + Get.put(TerminalMenuControl()); + Get.put(_TestSettingController()); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + builder: (_, __) => MaterialApp( + home: FTheme( + data: FThemes.zinc.light, + child: const Scaffold(body: SettingMealPeriods()), + ), + ), + ), + ); + await tester.pump(); + + for (final title in const ['早餐', '中餐', '晚餐']) { + expect(find.text(title), findsOneWidget); + } + expect(find.textContaining('餐段编码'), findsNothing); + for (final label in const ['开始时间', '结束时间']) { + final labels = tester.widgetList(find.text(label)); + expect(labels, hasLength(3)); + for (final text in labels) { + expect(text.softWrap, isFalse); + expect(text.overflow, isNull); + } + } + for (final time in const ['05:00', '10:00', '11:00', '16:00', '24:00']) { + expect(find.text(time), findsWidgets); + } + expect( + tester.getSize(find.byKey(const ValueKey('1-start'))).width, + greaterThan(214), + ); + expect(find.text('保存配置'), findsOneWidget); + expect(find.textContaining('当前配置有效'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +class _TestSettingController extends SettingController { + // 测试只验证本地页面草稿,不启动数据库、调度器和 MQTT 生命周期。 + @override + // ignore: must_call_super + void onInit() {} + + @override + // ignore: must_call_super + void onClose() {} +}