This commit is contained in:
dzc committed 2026-09-09 17:45:45 +08:00
1 parent 60d9380a65
commit fe6d988fa3
46 files changed
+5738 -1013

No files matched your search

+6 -2
View File
@@ -82,8 +82,12 @@ class AppMqTool {
mqUpdateEventBus.fire(MqtActionEnum.CONFIG);
break;
case MqtActionEnum.CANTEEN_PRODUCT:
await SyncProductTool.syncProduct();
mqUpdateEventBus.fire(MqtActionEnum.CANTEEN_PRODUCT);
// 食堂变更需要让当前点餐页及时刷新,但只查询收到消息时的当天,
// 避免 MQTT 拉取未来一周菜谱。
final success = await SyncProductTool.syncTodayCanteenProduct();
if (success) {
mqUpdateEventBus.fire(MqtActionEnum.CANTEEN_PRODUCT);
}
break;
case MqtActionEnum.SUPERMARKET_PRODUCT:
await SyncProductTool.syncProduct();
@@ -0,0 +1,70 @@
import 'dart:async';
import 'package:get/get.dart';
/// 管理设置与退出共用的临时解锁会话。
///
/// 会话只保存在当前应用进程中,并使用绝对过期时间校验,避免切页、卡顿或应用进入后台
/// 导致简单减秒计时失真。退出登录时会主动锁定,不把管理权限带到下一次登录。
class TerminalAdminSessionControl extends GetxController {
static const Duration sessionDuration = Duration(minutes: 5);
final remainingSeconds = 0.obs;
DateTime? _expiresAt;
Timer? _timer;
bool get isUnlocked {
final expiresAt = _expiresAt;
return expiresAt != null && DateTime.now().isBefore(expiresAt);
}
String get remainingLabel {
final seconds = remainingSeconds.value.clamp(
0,
sessionDuration.inSeconds,
);
final minutesPart = (seconds ~/ 60).toString().padLeft(2, '0');
final secondsPart = (seconds % 60).toString().padLeft(2, '0');
return '$minutesPart:$secondsPart';
}
void unlock() {
_expiresAt = DateTime.now().add(sessionDuration);
_syncRemainingTime();
_timer?.cancel();
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => _syncRemainingTime(),
);
}
void lock() {
_expiresAt = null;
_timer?.cancel();
_timer = null;
if (remainingSeconds.value != 0) remainingSeconds.value = 0;
}
void _syncRemainingTime() {
final expiresAt = _expiresAt;
if (expiresAt == null) {
lock();
return;
}
final milliseconds = expiresAt.difference(DateTime.now()).inMilliseconds;
if (milliseconds <= 0) {
lock();
return;
}
// 向上取整可让刚完成验证的第一帧稳定显示 05:00。
final seconds = (milliseconds / 1000).ceil();
if (remainingSeconds.value != seconds) remainingSeconds.value = seconds;
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
}
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 读取终端顶部隐藏设备集合的方法签名,生产环境从本机 `sysJson` 恢复。
typedef HiddenTerminalDeviceStatusesLoader = Future<Set<TerminalDeviceType>>
Function();
/// 保存终端顶部隐藏设备集合的方法签名,每次接收一份不可变含义的完整快照。
typedef HiddenTerminalDeviceStatusesSaver = Future<void> Function(
Set<TerminalDeviceType> hiddenDevices,
);
/// 管理终端顶部设备状态的实时显隐,并串行持久化本机界面偏好。
class TerminalDeviceStatusControl extends GetxController {
/// 创建应用级逐项显隐控制器;可注入读写方法以隔离 Widget 测试与真实数据库。
TerminalDeviceStatusControl({
HiddenTerminalDeviceStatusesLoader? loadHiddenDevices,
HiddenTerminalDeviceStatusesSaver? saveHiddenDevices,
}) : _loadHiddenDevices =
loadHiddenDevices ?? AppSettingTool.getHiddenTerminalDeviceStatuses,
_saveHiddenDevices =
saveHiddenDevices ?? AppSettingTool.setHiddenTerminalDeviceStatuses;
/// 顶部需要隐藏的设备集合;默认空集合保证首次启动和旧总开关升级后五项全部显示。
final hiddenHeaderDevices = <TerminalDeviceType>{}.obs;
/// 从本机设置读取初始隐藏集合,测试可注入内存实现。
final HiddenTerminalDeviceStatusesLoader _loadHiddenDevices;
/// 将最新隐藏集合写入本机设置,测试可注入内存实现。
final HiddenTerminalDeviceStatusesSaver _saveHiddenDevices;
/// 保存期间是否又发生显隐切换,用于在当前写入后继续保存最终状态。
bool _pendingPersist = false;
/// 当前串行保存任务;为空表示没有设置写入正在执行。
Future<void>? _persistFuture;
/// 是否至少有一个设备需要显示;调用方应在 `Obx` 内读取以响应逐项开关变化。
bool get hasVisibleHeaderDevices =>
hiddenHeaderDevices.length < TerminalDeviceType.values.length;
/// 判断指定设备是否显示在顶部;不在隐藏集合中的新增设备默认显示。
bool isHeaderDeviceVisible(TerminalDeviceType type) =>
!hiddenHeaderDevices.contains(type);
/// 启动时恢复本机隐藏集合;读取失败时清空集合,避免升级后丢失设备告警入口。
Future<void> initAsync() async {
try {
hiddenHeaderDevices.assignAll(await _loadHiddenDevices());
} catch (error, stack) {
hiddenHeaderDevices.clear();
logger.e('|终端设备状态|恢复顶部显隐失败error:|$error|stack:|$stack|');
}
}
/// 立即切换指定设备并串行保存完整集合;连续操作时返回覆盖全部待写入状态的同一任务。
Future<void> setHeaderDeviceVisible(
TerminalDeviceType type,
bool visible,
) {
if (isHeaderDeviceVisible(type) != visible) {
if (visible) {
hiddenHeaderDevices.remove(type);
} else {
hiddenHeaderDevices.add(type);
}
_pendingPersist = true;
}
final running = _persistFuture;
if (running != null) return running;
if (!_pendingPersist) return Future<void>.value();
late final Future<void> future;
future = _flushPersist().whenComplete(() {
if (identical(_persistFuture, future)) _persistFuture = null;
});
_persistFuture = future;
return future;
}
/// 逐次写入保存期间产生的最新快照,保证较慢的旧写入不会覆盖最终选择。
Future<void> _flushPersist() async {
while (_pendingPersist) {
_pendingPersist = false;
final hiddenDevices = Set<TerminalDeviceType>.from(hiddenHeaderDevices);
try {
await _saveHiddenDevices(hiddenDevices);
} catch (error, stack) {
logger.e('|终端设备状态|保存顶部显隐失败error:|$error|stack:|$stack|');
Toast.error(msg: '设备状态显示设置保存失败,请重试');
}
}
}
}
@@ -0,0 +1,43 @@
import 'package:get/get.dart';
/// 执行一次终端唤醒身份校验,返回是否允许解除睡眠遮罩。
typedef TerminalWakeVerifier = Future<bool> Function();
/// 管理主屏睡眠遮罩及唤醒验证的应用级状态。
///
/// 睡眠仅阻止主屏查看和触摸,不切换路由、不销毁页面 Controller,也不暂停串口、
/// 人脸、网络请求或副屏通信。状态仅保存在当前进程,设备重新启动后不自动进入睡眠。
class TerminalSleepControl extends GetxController {
/// 创建一份默认处于唤醒状态的主屏睡眠控制器。
TerminalSleepControl();
/// 主屏是否正在展示全屏睡眠遮罩。
final isSleeping = false.obs;
/// 是否已有唤醒密码校验正在进行,防止快速点击叠加多个密码弹窗。
bool _wakeVerificationInProgress = false;
/// 立即进入睡眠状态;重复调用保持幂等且不影响当前业务页面生命周期。
void enterSleep() {
if (!isSleeping.value) isSleeping.value = true;
}
/// 通过调用方提供的密码校验请求唤醒主屏。
///
/// 校验取消、失败或重复提交时继续保持睡眠;校验过程抛出的异常会向上返回,
/// 由界面层统一记录并提示,同时保证下一次唤醒仍可重新发起。
Future<bool> requestWake(TerminalWakeVerifier verifier) async {
if (!isSleeping.value) return true;
if (_wakeVerificationInProgress) return false;
_wakeVerificationInProgress = true;
try {
final verified = await verifier();
if (!verified) return false;
isSleeping.value = false;
return true;
} finally {
_wakeVerificationInProgress = false;
}
}
}
@@ -0,0 +1,162 @@
import 'package:common_utils/common_utils.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 终端工作区的统一导航守卫。
///
/// 统一终端导航仍需保留设备绑定、联网限制和设置密码校验,
/// 否则视觉重构会意外放开原有业务入口。
abstract final class TerminalNavigationTool {
/// 当前仍负责接收菜单跳转的终端页面实例标识。
static Object? _navigatorOwner;
/// 当前终端页面实例持有的导航 Key;页面重叠退场时不同实例必须使用不同 Key。
static GlobalKey<NavigatorState>? _navigatorKey;
/// 当前活跃终端实例实际显示的工作区路由,用于拦截重复返回同一菜单。
static String? _currentWorkspaceRoute;
/// 管理密码弹层是否正在验证,用于阻止重复创建弹层。
static bool _verificationInProgress = false;
/// 为新挂载的终端页面创建并登记实例级导航 Key。
///
/// GetX 的 `nestedKey(id)` 会永久复用同一 GlobalKey;根路由替换期间新旧终端页
/// 可能短暂共存,因此必须由各页面实例持有独立 Key,再只把菜单操作发给最新实例。
static GlobalKey<NavigatorState> attachNavigator(Object owner) {
final key = GlobalKey<NavigatorState>(
debugLabel: 'Terminal workspace navigator',
);
_navigatorOwner = owner;
_navigatorKey = key;
_currentWorkspaceRoute = null;
return key;
}
/// 终端页面销毁时解除登记;旧页面延迟销毁不得清除后来页面的导航 Key。
static void detachNavigator(Object owner) {
if (!identical(_navigatorOwner, owner)) return;
_navigatorOwner = null;
_navigatorKey = null;
_currentWorkspaceRoute = null;
}
/// 接收活跃终端 Navigator 的真实路由,旧实例的延迟回调会被所有权校验丢弃。
static void handleRouteChanged(Object owner, String? route) {
if (!identical(_navigatorOwner, owner) || route == null) return;
_currentWorkspaceRoute = route;
}
/// 校验入口权限后切换到指定终端工作区路由。
static Future<void> open(String route) async {
if (_requiresAdminUnlock(route)) {
if (!await _verifyAdminSession(actionLabel: '进入设置')) return;
_replaceWorkspace(route);
return;
}
if (!await _checkIsBind()) return;
if (_requiresNetwork(route) && !AppNetworkTool.isAutoNetwork()) {
Toast.error(msg: '请先连接网络');
return;
}
_replaceWorkspace(route);
}
/// 仅替换终端二级页面,保留一级外壳及应用级资源。
static void replaceWorkspace(String route) => _replaceWorkspace(route);
/// 在终端工作区历史栈之上压入目标二级页面(如交易详情),不清空现有栈。
static void pushWorkspace(String route, {Object? arguments}) {
final navigator = _navigatorKey?.currentState;
if (navigator == null || !navigator.mounted) return;
navigator.pushNamed<void>(route, arguments: arguments);
}
/// 返回 panel 主菜单页;终端之上还叠有根路由页面时先整页回到主菜单。
static void backToPanel() {
if (Get.currentRoute == RouteNames.terminal) {
Get.back();
return;
}
Get.offAndToNamed(RouteNames.panel);
}
/// 清空当前终端内部历史并装载目标页面,不影响根 Navigator 的终端外壳。
static void _replaceWorkspace(String route) {
final navigator = _navigatorKey?.currentState;
if (navigator == null ||
!navigator.mounted ||
_currentWorkspaceRoute == route) {
return;
}
navigator.pushNamedAndRemoveUntil<void>(route, (_) => false);
}
/// 退出前统一执行管理密码校验,避免顶栏与主菜单出口出现不同的安全规则。
static Future<void> logout() async {
if (!await _verifyAdminSession(actionLabel: '退出登录')) return;
Get.find<TerminalAdminSessionControl>().lock();
if (AppNetworkTool.isAutoNetwork()) {
await AppSettingTool.setUserSetting(UserSetting());
}
Get.offAllNamed(RouteNames.login);
}
/// 顶栏锁状态可主动发起验证,不需要先进入设置或退出流程。
static Future<void> unlockAdminSession() async {
await _verifyAdminSession(actionLabel: '解锁管理操作');
}
/// 复用已解锁会话或弹出管理密码校验,返回本次管理操作是否获准。
static Future<bool> _verifyAdminSession({
required String actionLabel,
}) async {
final session = Get.find<TerminalAdminSessionControl>();
if (session.isUnlocked) return true;
session.lock();
// 快速连续点击设置与退出时只允许存在一个密码弹层,避免重复 Overlay 和 GlobalKey。
if (_verificationInProgress) return false;
final context = Get.context;
if (context == null) return false;
_verificationInProgress = true;
try {
final verified = await terminalAdminPasswordDialog(
context,
actionLabel: actionLabel,
);
// 弹层取消与验证成功都要等退场结束,避免快速再次点击叠加两个 Overlay。
await Future<void>.delayed(const Duration(milliseconds: 200));
if (!verified) return false;
session.unlock();
return true;
} finally {
_verificationInProgress = false;
}
}
/// 设置页承载管理与配置能力,必须先通过管理密码校验。
static bool _requiresAdminUnlock(String route) {
return route == RouteNames.setting;
}
/// 判断目标业务是否必须在服务网络可用时进入。
static bool _requiresNetwork(String route) {
return route == RouteNames.canteenReserve ||
route == RouteNames.takeFood ||
route == RouteNames.canteenOrderTotal;
}
/// 检查终端是否已经绑定服务商户,未绑定时统一提示并拒绝进入业务页。
static Future<bool> _checkIsBind() async {
final setting = await AppSettingTool.getSysSetting();
if (ObjectUtil.isEmpty(setting.serviceMerchantId)) {
Toast.error(msg: '请前往设置绑定设备');
return false;
}
return true;
}
}
+4
View File
@@ -3,6 +3,9 @@ library;
export './app/control/app_control.dart';
export './app/control/product_hour_type_control.dart';
export './app/control/app_theme_control.dart';
export './app/control/terminal_admin_session_control.dart';
export './app/control/terminal_device_status_control.dart';
export './app/control/terminal_sleep_control.dart';
export './app/app_face_tool.dart';
export './app/app_device_status_tool.dart';
@@ -15,6 +18,7 @@ export './app/app_price_tool.dart';
export './app/app_setting_tool.dart';
export './app/app_sys_tool.dart';
export './app/app_user_tool.dart';
export './app/terminal_navigation_tool.dart';
export './discounts/discounts_tool.dart';
+133 -24
View File
@@ -195,35 +195,22 @@ abstract class SyncProductTool {
}
}
/// 同步商品
/// 按终端业态执行常规商品同步。
///
/// 食堂常规同步保留今天起连续七天的缓存范围,一次范围请求完成。
/// MQTT 食堂变更和进入点餐页只刷新当天,使用 [syncTodayCanteenProduct]。
static Future<void> syncProduct() async {
final sysSetting = await AppSettingTool.getSysSetting();
final sysType = sysSetting.sysType;
try {
if (sysType == SysType.CANTEEN) {
// 获取从今天开始的所有日期时间
final DateTime now = DateTime.now();
for (int i = 0; i < 7; i++) {
final DateTime date = now.add(Duration(days: i));
final String day = DateUtil.formatDate(
date,
format: DateFormats.y_mo_d,
);
final dayDatetime = DateUtil.getDateTime(day);
final pa = {
"day": day,
"hourType": null,
"categoryId": null,
"merchantId": sysSetting.serviceMerchantId,
};
final canteenList = await ProductApi.canteenProduct(pa);
if (canteenList == null) return;
for (var v in canteenList) {
v.saleDate = dayDatetime;
}
// 更新数据
await _batchUpdateCaneenProduct(list: canteenList, dayStr: day);
}
final startDate = DateTime.now();
final success = await _syncCanteenProductForDateRange(
startDate: startDate,
endDate: startDate.add(const Duration(days: 6)),
merchantId: sysSetting.serviceMerchantId,
);
if (!success) return;
} else if (sysType == SysType.SUPERMARKET) {
// 超市
final app = Get.find<AppControl>();
@@ -273,6 +260,128 @@ abstract class SyncProductTool {
// 同步食堂商品 spu start
// **************************************************************************
/// 仅同步终端本地时区的当天食堂菜品,并在成功后记录食堂商品同步时间。
///
/// MQTT 菜品变更和进入点餐页使用此入口,避免刷新当天时额外拉取未来六天。
static Future<bool> syncTodayCanteenProduct() async {
final today = DateTime.now();
final sysSetting = await AppSettingTool.getSysSetting();
if (sysSetting.sysType != SysType.CANTEEN) return false;
try {
final success = await _syncCanteenProductForDay(
date: today,
merchantId: sysSetting.serviceMerchantId,
);
if (!success) return false;
sysSetting.canteenProductSyncTime = DateTime.now();
await AppSettingTool.setSysSetting(sysSetting);
return true;
} catch (e, stack) {
logger.e("|$_tag|同步当天食堂商品信息意外的错误error:|$e|,stack:|$stack|");
return false;
}
}
/// 请求并替换指定自然日的食堂菜品快照。
///
/// 返回 `false` 表示接口没有有效响应,本地当天旧快照会保留;
/// 空数组属于有效响应,会清空当天快照。
static Future<bool> _syncCanteenProductForDay({
required DateTime date,
required String? merchantId,
}) async {
final day = DateUtil.formatDate(date, format: DateFormats.y_mo_d);
final dayDatetime = DateUtil.getDateTime(day);
final canteenList = await ProductApi.canteenProduct({
"day": day,
"hourType": null,
"categoryId": null,
"merchantId": merchantId,
});
if (canteenList == null) return false;
if (ObjectUtil.isEmptyList(canteenList)) {
// 空数组只代表指定日期没有可售菜品,清空当天快照,不影响其他日期。
await _batchUpdateCaneenProduct(list: const [], dayStr: day);
return true;
}
for (final product in canteenList) {
// 接口按 day 查询,统一覆盖供应日期可防止异常响应把其他日期写进当天同步结果。
product.saleDate = dayDatetime;
}
await _batchUpdateCaneenProduct(list: canteenList, dayStr: day);
return true;
}
/// 一次请求指定闭区间的食堂菜谱,并按接口返回的供应日期逐日原子替换本地快照。
///
/// 返回 `false` 表示接口无有效响应或任一记录缺少合法供应日期,
/// 此时不会修改任何日期的本地数据。
static Future<bool> _syncCanteenProductForDateRange({
required DateTime startDate,
required DateTime endDate,
required String? merchantId,
}) async {
final startDay = DateUtil.formatDate(
startDate,
format: DateFormats.y_mo_d,
);
final endDay = DateUtil.formatDate(
endDate,
format: DateFormats.y_mo_d,
);
final requestMerchantId = int.tryParse(merchantId ?? '');
if (requestMerchantId == null) {
logger.w('|$_tag|范围菜谱同步缺少有效商户 ID,保留本地旧快照');
return false;
}
final canteenList = await ProductApi.canteenProductsByDateRange({
"startDate": startDay,
"endDate": endDay,
"hourType": null,
"categoryId": null,
"merchantId": requestMerchantId,
});
if (canteenList == null) return false;
final productsByDay = <String, List<ServiceProduct>>{};
var currentDate = DateTime.parse(startDay);
final lastDate = DateTime.parse(endDay);
while (!currentDate.isAfter(lastDate)) {
productsByDay[DateUtil.formatDate(
currentDate,
format: DateFormats.y_mo_d,
)] = <ServiceProduct>[];
currentDate = currentDate.add(const Duration(days: 1));
}
for (final product in canteenList) {
final saleDate = product.saleDate;
if (saleDate == null) {
logger.w('|$_tag|范围菜谱响应缺少供应日期,保留本地旧快照:${product.toJson()}');
return false;
}
final saleDay = DateUtil.formatDate(
saleDate,
format: DateFormats.y_mo_d,
);
final dayProducts = productsByDay[saleDay];
if (dayProducts == null) {
logger
.w('|$_tag|范围菜谱响应日期超出请求区间,保留本地旧快照:请求$startDay至$endDay,响应$saleDay');
return false;
}
dayProducts.add(product);
}
for (final entry in productsByDay.entries) {
await _batchUpdateCaneenProduct(
list: entry.value,
dayStr: entry.key,
);
}
return true;
}
static Future<void> _batchUpdateCaneenProduct({
required List<ServiceProduct> list,
required String dayStr,