11
This commit is contained in:
1 parent
f6b37348c4
commit
917f03bc58
61 files changed
+4233
-367
No files matched your search
@@ -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<void> setSelectedProductHourType(ProductHourType hourType) =>
|
||||
_serializeSettingWrite(() async {
|
||||
final app = Get.find<AppControl>();
|
||||
final settingDao = app.dataBase.getSettingDao;
|
||||
final setting = await settingDao.get();
|
||||
if (setting == null) throw StateError('终端设置尚未初始化');
|
||||
final sysJson = Map<String, dynamic>.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<void> setRootAccessResult(RootAccessCheckResult result) =>
|
||||
_serializeSettingWrite(() async {
|
||||
@@ -476,6 +493,30 @@ abstract class AppSettingTool {
|
||||
sysJson[_cashierShowMoreActionKey] = showMoreAction;
|
||||
});
|
||||
|
||||
/// 读取商品餐段时间;旧终端无配置或持久化值损坏时返回产品默认时间。
|
||||
static Future<ProductMealPeriodSchedule>
|
||||
getProductMealPeriodSchedule() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
return ProductMealPeriodSchedule.fromJson(sysJson[_productMealPeriodsKey]);
|
||||
}
|
||||
|
||||
/// 保存经过统一校验的基础三餐时间,不增加 setting 表字段和数据库迁移。
|
||||
static Future<void> setProductMealPeriodSchedule(
|
||||
ProductMealPeriodSchedule schedule,
|
||||
) =>
|
||||
_updateSysJson((sysJson) {
|
||||
sysJson[_productMealPeriodsKey] = schedule.toJson();
|
||||
});
|
||||
|
||||
/// 按本机当前时间取得配置餐段;未覆盖时段返回空,调用方可保留人工选择。
|
||||
static Future<ProductHourType?> getCurrentProductHourType({
|
||||
DateTime? now,
|
||||
}) async {
|
||||
final schedule = await getProductMealPeriodSchedule();
|
||||
return schedule.resolve(now ?? DateTime.now());
|
||||
}
|
||||
|
||||
/// 读取顶部需要隐藏的设备类型;缺键、异常值和未知编码均忽略,因此升级后默认五项全部显示。
|
||||
static Future<Set<TerminalDeviceType>>
|
||||
getHiddenTerminalDeviceStatuses() async {
|
||||
|
||||
@@ -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<String>(value: initial.code) {
|
||||
selection.addValueListener(_handleSelectionChanged);
|
||||
}
|
||||
|
||||
/// 当前全局餐段,供需要强类型值的业务逻辑使用。
|
||||
final Rx<ProductHourType> selected;
|
||||
|
||||
/// 公共 UI 选择器使用的单选 Controller;页面不得自行创建餐段 Controller。
|
||||
final FRadioSelectGroupController<String> selection;
|
||||
|
||||
/// 首次缓存恢复任务;重复进入页面时复用同一个 Future,避免并发读取与重复迁移。
|
||||
Future<void>? _initializationFuture;
|
||||
|
||||
/// 内部同步强类型值与 Forui 选择器时暂时屏蔽监听器,防止重复持久化。
|
||||
bool _suppressPersistence = false;
|
||||
|
||||
/// 是否把选择写入终端设置;独立单测实例可关闭,应用级实例始终开启。
|
||||
final bool _persistSelection;
|
||||
|
||||
/// 当前餐段缓存恢复完成信号。
|
||||
Future<void> get initialization => _initializationFuture ?? Future.value();
|
||||
|
||||
/// 读取 SysSetting 中缓存的餐段;旧版本没有缓存时尝试迁移查询菜单的历史选择。
|
||||
Future<void> initAsync() {
|
||||
return _initializationFuture ??= _initialize();
|
||||
}
|
||||
|
||||
/// 执行一次缓存恢复,并兼容迁移旧版查询菜单保存的最近餐段。
|
||||
Future<void> _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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -23,12 +23,20 @@ abstract class FaceDiscernPlugs {
|
||||
Constants.facePlugsRegistrationEventChannel,
|
||||
);
|
||||
|
||||
/// 原生在库图片质量批量检测进度通道。
|
||||
static const EventChannel _qualityAuditEventChannel = EventChannel(
|
||||
Constants.facePlugsQualityAuditEventChannel,
|
||||
);
|
||||
|
||||
static MethodChannel get getChannel => _channel;
|
||||
|
||||
static Stream<FaceStream?>? _stream;
|
||||
static Stream<FaceRecognitionSessionEvent>? _sessionStream;
|
||||
static Stream<FaceRegistrationProgress>? _registrationStream;
|
||||
|
||||
/// 延迟创建并复用的在库图片质量检测进度流。
|
||||
static Stream<FaceQualityAuditProgress>? _qualityAuditStream;
|
||||
|
||||
static Future<bool> jumpFaceDiscernPage() async {
|
||||
try {
|
||||
await _channel.invokeMethod('jumpFaceDiscern');
|
||||
@@ -269,6 +277,23 @@ abstract class FaceDiscernPlugs {
|
||||
);
|
||||
}
|
||||
|
||||
/// 在原生人脸操作互斥边界内批量检测本地在库照片质量。
|
||||
///
|
||||
/// 文件路径仅传入本机 Android 插件;原生逐图解码并通过 [qualityAuditStream]
|
||||
/// 报告进度,返回结果不会自动修改 Floor 数据库。
|
||||
static Future<FaceQualityAuditResult> auditFaceQuality({
|
||||
required List<FaceQualityAuditItem> items,
|
||||
}) async {
|
||||
final map = await _channel.invokeMethod<dynamic>('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<FaceQualityAuditProgress> get qualityAuditStream {
|
||||
_qualityAuditStream ??= _qualityAuditEventChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) => event is Map)
|
||||
.map((event) => FaceQualityAuditProgress.fromMap(event as Map))
|
||||
.asBroadcastStream();
|
||||
return _qualityAuditStream!;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -322,12 +322,12 @@ abstract class SyncFaceTool {
|
||||
var databaseChanged = false;
|
||||
try {
|
||||
temporaryFile = await _downloadTemporaryFace(record, storagePath);
|
||||
final featureData = AppRuntimeTool.useManualHardwareInput
|
||||
? <int>[]
|
||||
: (await FaceDiscernPlugs.extractFaceFeature(
|
||||
final extraction = AppRuntimeTool.useManualHardwareInput
|
||||
? null
|
||||
: await FaceDiscernPlugs.extractFaceFeature(
|
||||
filePath: temporaryFile.path,
|
||||
))
|
||||
.featureData;
|
||||
);
|
||||
final featureData = extraction?.featureData ?? <int>[];
|
||||
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<bool> deleteLocalFace(FaceEntity face) =>
|
||||
_deleteLocalFace(face, Get.find<AppControl>().dataBase.getFaceDao);
|
||||
|
||||
/// 手动检测存量在库照片质量并将结果批量回写 SQLite。
|
||||
///
|
||||
/// [failedOnly] 为 true 时仅重试上次失败记录;不存在或不可读的本地文件会直接
|
||||
/// 标记为 failed,其他图片交给 Android 原生单线程质量任务处理。
|
||||
static Future<FaceQualityAuditResult> auditLibraryFaceQuality({
|
||||
bool failedOnly = false,
|
||||
}) async {
|
||||
final faceDao = Get.find<AppControl>().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 = <FaceImageQualityResult>[];
|
||||
final validFaces = <FaceEntity>[];
|
||||
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 = <FaceImageQualityResult>[
|
||||
...invalidResults,
|
||||
...nativeResult.results,
|
||||
];
|
||||
final bySampleId = {
|
||||
for (final result in results) result.sampleId: result,
|
||||
};
|
||||
final checkedAt = DateTime.now();
|
||||
final updates = <FaceEntity>[];
|
||||
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<void> _restoreNativeFace(FaceEntity face) async {
|
||||
if (AppRuntimeTool.useManualHardwareInput) return;
|
||||
@@ -494,6 +618,8 @@ abstract class SyncFaceTool {
|
||||
required FaceEntity? existing,
|
||||
String? path,
|
||||
List<int>? 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,
|
||||
);
|
||||
|
||||
/// 删除指定本地文件;失败只记录文件路径以外的异常类型,避免同步主流程被清理动作中断。
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user