diff --git a/assets/images/customer_welcome/abstract-care.webp b/assets/images/customer_welcome/abstract-care.webp new file mode 100644 index 0000000..dc2b3fa Binary files /dev/null and b/assets/images/customer_welcome/abstract-care.webp differ diff --git a/lib/common/api/product_api.dart b/lib/common/api/product_api.dart index 8e4d927..4155fe1 100644 --- a/lib/common/api/product_api.dart +++ b/lib/common/api/product_api.dart @@ -29,6 +29,20 @@ abstract class ProductApi { .toList(); } + /// 按闭区间查询多日食堂菜谱商品,响应结构与单日接口保持一致。 + static Future?> canteenProductsByDateRange( + Map requestBody, + ) async { + final res = await HttpManage().dio.post( + "/canteen/canteenDailyMenu/ad/getCanteenListByDateRange", + data: requestBody, + ); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceProduct.fromJson(e)) + .toList(); + } + /// 超市分类 static Future?> supermarketClassify( dynamic pa, diff --git a/lib/common/models/page/setting_page_enum_models.dart b/lib/common/models/page/setting_page_enum_models.dart index dd01f49..4ec5ab8 100644 --- a/lib/common/models/page/setting_page_enum_models.dart +++ b/lib/common/models/page/setting_page_enum_models.dart @@ -9,8 +9,10 @@ enum SettingMenuType implements BaseEnum { backup("40", "资源备份"), faceSetting("50", "人脸管理"), productSetting("60", "商品管理"), + logPreview("62", "日志预览"), versionSetting("70", "版本管理"), facePluginSetting("80", "人脸设置"), + mealPeriodSetting("90", "餐段时间配置"), ; @override diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index 586f1bd..7e889cb 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -5,39 +5,39 @@ class RouteNames { /// 登录 static const login = '/login'; - /// 面板 + /// 主菜单面板 static const panel = '/panel'; - /// 超市购买 - static const supermarketProduct = '/supermarket_product'; + /// 终端工作台一级路由,顶部状态栏在该路由内常驻,业务页面走内层二级路由。 + static const terminal = '/terminal'; - /// 点餐 - static const canteenProduct = '/canteen_product'; + /// 超市购买(终端二级路由) + static const supermarketProduct = '$terminal/supermarket_product'; - /// 取餐 - static const takeFood = '/take_food'; + /// 点餐(终端二级路由) + static const canteenProduct = '$terminal/canteen_product'; - /// 食堂预定 - static const canteenReserve = '/canteen_reserve'; + /// 取餐(终端二级路由) + static const takeFood = '$terminal/take_food'; - /// 食堂交易统计 - static const canteenOrderTotal = '/canteen_order_total'; + /// 食堂预定(终端二级路由) + static const canteenReserve = '$terminal/canteen_reserve'; - /// 交易查询 - static const orderHistory = '/order_history'; + /// 食堂交易统计(终端二级路由) + static const canteenOrderTotal = '$terminal/canteen_order_total'; - // 交易记录详情 - static const orderHistoryDetail = '/order_history_detail'; + /// 交易查询(终端二级路由) + static const orderHistory = '$terminal/order_history'; - /// 设置 - static const setting = '/setting'; + /// 交易记录详情(终端二级路由) + static const orderHistoryDetail = '$terminal/order_history_detail'; - /// 定时任务日志 + /// 设置(终端二级路由) + static const setting = '$terminal/setting'; + + /// 定时任务日志(根路由,全屏覆盖终端外壳) static const settingSyncLogs = '/setting/sync-logs'; - /// 饭卡 - static const cardActive = '/card_active'; - - /// 人脸 - static const faceDiscern = '/face_discern'; + /// 饭卡(终端二级路由) + static const cardActive = '$terminal/card_active'; } diff --git a/lib/common/routers/pages.dart b/lib/common/routers/pages.dart index 95db03a..c4dc079 100644 --- a/lib/common/routers/pages.dart +++ b/lib/common/routers/pages.dart @@ -13,82 +13,25 @@ class RoutePages { GetPage( name: RouteNames.login, page: () => const LoginPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => LoginController(), fenix: true); + }), ), GetPage( name: RouteNames.panel, page: () => const PanelPage(), binding: BindingsBuilder(() { - Get.lazyPut(() => PanelController()); - Get.lazyPut(() => SettingController()); + Get.lazyPut(() => PanelController(), fenix: true); + Get.lazyPut(() => SettingController(), fenix: true); }), ), + // 终端工作台一级路由:顶部状态栏常驻外壳,业务页面在 TerminalPage + // 的内层 Navigator 中以二级路由装载,各自的 Binding 见 _terminalRoutes。 GetPage( - name: RouteNames.canteenProduct, - page: () => const CanteenProductPage(), - binding: BindingsBuilder(() { - Get.lazyPut(() => SelectPayTypeDialogController()); - Get.lazyPut(() => CanteenProductController()); - Get.lazyPut(() => PanelController()); - }), - ), - GetPage( - name: RouteNames.supermarketProduct, - page: () => const SupermarketOrderPage(), - binding: BindingsBuilder(() { - Get.lazyPut(() => SelectPayTypeDialogController()); - Get.lazyPut(() => PanelController()); - Get.lazyPut(() => SteelyardController()); - Get.lazyPut(() => SupermarketOrderController()); - }), - ), - GetPage( - name: RouteNames.canteenReserve, - page: () => const CanteenReservePage(), - binding: BindingsBuilder(() { - Get.lazyPut(() => PanelController()); - Get.lazyPut(() => CanteenReserveController()); - }), - ), - GetPage( - name: RouteNames.takeFood, - page: () => const TakeFoodPage(), - binding: BindingsBuilder(() { - Get.lazyPut(() => PanelController()); - Get.lazyPut(() => TakeFoodController()); - }), - ), - GetPage( - name: RouteNames.canteenOrderTotal, - page: () => const CanteenOrderTotalPage(), - binding: BindingsBuilder(() { - Get.lazyPut(() => PanelController()); - Get.lazyPut(() => CanteenOrderTotalController()); - }), - ), - GetPage( - name: RouteNames.orderHistory, - page: () => const OrderHistoryPage(), - binding: BindingsBuilder(() { - Get.lazyPut(() => PanelController()); - Get.lazyPut(() => OrderHistoryController()); - }), - ), - GetPage( - name: RouteNames.orderHistoryDetail, - page: () => const OrderHistoryDetailPage(), - binding: BindingsBuilder(() { - Get.lazyPut(() => OrderHistoryController()); - Get.lazyPut(() => OrderHistoryDetailController()); - }), - ), - GetPage( - name: RouteNames.setting, - page: () => const SettingPage(), - binding: BindingsBuilder(() { - Get.lazyPut(() => PanelController()); - Get.lazyPut(() => SettingController()); - }), + name: RouteNames.terminal, + page: () => const TerminalPage(), ), + // 定时任务日志从设置页全屏打开,保留根路由与 Get.arguments 传参方式。 GetPage( name: RouteNames.settingSyncLogs, page: () => const ScheduledTaskLogsPage(), @@ -99,13 +42,5 @@ class RoutePages { ); }), ), - GetPage( - name: RouteNames.cardActive, - page: () => const CardActivePage(), - binding: BindingsBuilder(() { - Get.lazyPut(() => SettingController()); - Get.lazyPut(() => CardActiveController()); - }), - ), ]; } diff --git a/lib/common/utils/app/app_mq_tool.dart b/lib/common/utils/app/app_mq_tool.dart index ed52b3f..aa94f62 100644 --- a/lib/common/utils/app/app_mq_tool.dart +++ b/lib/common/utils/app/app_mq_tool.dart @@ -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(); diff --git a/lib/common/utils/app/control/terminal_admin_session_control.dart b/lib/common/utils/app/control/terminal_admin_session_control.dart new file mode 100644 index 0000000..0208077 --- /dev/null +++ b/lib/common/utils/app/control/terminal_admin_session_control.dart @@ -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(); + } +} diff --git a/lib/common/utils/app/control/terminal_device_status_control.dart b/lib/common/utils/app/control/terminal_device_status_control.dart new file mode 100644 index 0000000..98452c7 --- /dev/null +++ b/lib/common/utils/app/control/terminal_device_status_control.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 读取终端顶部隐藏设备集合的方法签名,生产环境从本机 `sysJson` 恢复。 +typedef HiddenTerminalDeviceStatusesLoader = Future> + Function(); + +/// 保存终端顶部隐藏设备集合的方法签名,每次接收一份不可变含义的完整快照。 +typedef HiddenTerminalDeviceStatusesSaver = Future Function( + Set hiddenDevices, +); + +/// 管理终端顶部设备状态的实时显隐,并串行持久化本机界面偏好。 +class TerminalDeviceStatusControl extends GetxController { + /// 创建应用级逐项显隐控制器;可注入读写方法以隔离 Widget 测试与真实数据库。 + TerminalDeviceStatusControl({ + HiddenTerminalDeviceStatusesLoader? loadHiddenDevices, + HiddenTerminalDeviceStatusesSaver? saveHiddenDevices, + }) : _loadHiddenDevices = + loadHiddenDevices ?? AppSettingTool.getHiddenTerminalDeviceStatuses, + _saveHiddenDevices = + saveHiddenDevices ?? AppSettingTool.setHiddenTerminalDeviceStatuses; + + /// 顶部需要隐藏的设备集合;默认空集合保证首次启动和旧总开关升级后五项全部显示。 + final hiddenHeaderDevices = {}.obs; + + /// 从本机设置读取初始隐藏集合,测试可注入内存实现。 + final HiddenTerminalDeviceStatusesLoader _loadHiddenDevices; + + /// 将最新隐藏集合写入本机设置,测试可注入内存实现。 + final HiddenTerminalDeviceStatusesSaver _saveHiddenDevices; + + /// 保存期间是否又发生显隐切换,用于在当前写入后继续保存最终状态。 + bool _pendingPersist = false; + + /// 当前串行保存任务;为空表示没有设置写入正在执行。 + Future? _persistFuture; + + /// 是否至少有一个设备需要显示;调用方应在 `Obx` 内读取以响应逐项开关变化。 + bool get hasVisibleHeaderDevices => + hiddenHeaderDevices.length < TerminalDeviceType.values.length; + + /// 判断指定设备是否显示在顶部;不在隐藏集合中的新增设备默认显示。 + bool isHeaderDeviceVisible(TerminalDeviceType type) => + !hiddenHeaderDevices.contains(type); + + /// 启动时恢复本机隐藏集合;读取失败时清空集合,避免升级后丢失设备告警入口。 + Future initAsync() async { + try { + hiddenHeaderDevices.assignAll(await _loadHiddenDevices()); + } catch (error, stack) { + hiddenHeaderDevices.clear(); + logger.e('|终端设备状态|恢复顶部显隐失败error:|$error|stack:|$stack|'); + } + } + + /// 立即切换指定设备并串行保存完整集合;连续操作时返回覆盖全部待写入状态的同一任务。 + Future 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.value(); + + late final Future future; + future = _flushPersist().whenComplete(() { + if (identical(_persistFuture, future)) _persistFuture = null; + }); + _persistFuture = future; + return future; + } + + /// 逐次写入保存期间产生的最新快照,保证较慢的旧写入不会覆盖最终选择。 + Future _flushPersist() async { + while (_pendingPersist) { + _pendingPersist = false; + final hiddenDevices = Set.from(hiddenHeaderDevices); + try { + await _saveHiddenDevices(hiddenDevices); + } catch (error, stack) { + logger.e('|终端设备状态|保存顶部显隐失败error:|$error|stack:|$stack|'); + Toast.error(msg: '设备状态显示设置保存失败,请重试'); + } + } + } +} diff --git a/lib/common/utils/app/control/terminal_sleep_control.dart b/lib/common/utils/app/control/terminal_sleep_control.dart new file mode 100644 index 0000000..e83047c --- /dev/null +++ b/lib/common/utils/app/control/terminal_sleep_control.dart @@ -0,0 +1,43 @@ +import 'package:get/get.dart'; + +/// 执行一次终端唤醒身份校验,返回是否允许解除睡眠遮罩。 +typedef TerminalWakeVerifier = Future Function(); + +/// 管理主屏睡眠遮罩及唤醒验证的应用级状态。 +/// +/// 睡眠仅阻止主屏查看和触摸,不切换路由、不销毁页面 Controller,也不暂停串口、 +/// 人脸、网络请求或副屏通信。状态仅保存在当前进程,设备重新启动后不自动进入睡眠。 +class TerminalSleepControl extends GetxController { + /// 创建一份默认处于唤醒状态的主屏睡眠控制器。 + TerminalSleepControl(); + + /// 主屏是否正在展示全屏睡眠遮罩。 + final isSleeping = false.obs; + + /// 是否已有唤醒密码校验正在进行,防止快速点击叠加多个密码弹窗。 + bool _wakeVerificationInProgress = false; + + /// 立即进入睡眠状态;重复调用保持幂等且不影响当前业务页面生命周期。 + void enterSleep() { + if (!isSleeping.value) isSleeping.value = true; + } + + /// 通过调用方提供的密码校验请求唤醒主屏。 + /// + /// 校验取消、失败或重复提交时继续保持睡眠;校验过程抛出的异常会向上返回, + /// 由界面层统一记录并提示,同时保证下一次唤醒仍可重新发起。 + Future 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; + } + } +} diff --git a/lib/common/utils/app/terminal_navigation_tool.dart b/lib/common/utils/app/terminal_navigation_tool.dart new file mode 100644 index 0000000..531a36d --- /dev/null +++ b/lib/common/utils/app/terminal_navigation_tool.dart @@ -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? _navigatorKey; + + /// 当前活跃终端实例实际显示的工作区路由,用于拦截重复返回同一菜单。 + static String? _currentWorkspaceRoute; + + /// 管理密码弹层是否正在验证,用于阻止重复创建弹层。 + static bool _verificationInProgress = false; + + /// 为新挂载的终端页面创建并登记实例级导航 Key。 + /// + /// GetX 的 `nestedKey(id)` 会永久复用同一 GlobalKey;根路由替换期间新旧终端页 + /// 可能短暂共存,因此必须由各页面实例持有独立 Key,再只把菜单操作发给最新实例。 + static GlobalKey attachNavigator(Object owner) { + final key = GlobalKey( + 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 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(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(route, (_) => false); + } + + /// 退出前统一执行管理密码校验,避免顶栏与主菜单出口出现不同的安全规则。 + static Future logout() async { + if (!await _verifyAdminSession(actionLabel: '退出登录')) return; + Get.find().lock(); + if (AppNetworkTool.isAutoNetwork()) { + await AppSettingTool.setUserSetting(UserSetting()); + } + Get.offAllNamed(RouteNames.login); + } + + /// 顶栏锁状态可主动发起验证,不需要先进入设置或退出流程。 + static Future unlockAdminSession() async { + await _verifyAdminSession(actionLabel: '解锁管理操作'); + } + + /// 复用已解锁会话或弹出管理密码校验,返回本次管理操作是否获准。 + static Future _verifyAdminSession({ + required String actionLabel, + }) async { + final session = Get.find(); + 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.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 _checkIsBind() async { + final setting = await AppSettingTool.getSysSetting(); + if (ObjectUtil.isEmpty(setting.serviceMerchantId)) { + Toast.error(msg: '请前往设置绑定设备'); + return false; + } + return true; + } +} diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart index 95aa63a..a7578d1 100644 --- a/lib/common/utils/index.dart +++ b/lib/common/utils/index.dart @@ -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'; diff --git a/lib/common/utils/sync/sync_product_tool.dart b/lib/common/utils/sync/sync_product_tool.dart index d613172..95c254f 100644 --- a/lib/common/utils/sync/sync_product_tool.dart +++ b/lib/common/utils/sync/sync_product_tool.dart @@ -195,35 +195,22 @@ abstract class SyncProductTool { } } - /// 同步商品 + /// 按终端业态执行常规商品同步。 + /// + /// 食堂常规同步保留今天起连续七天的缓存范围,一次范围请求完成。 + /// MQTT 食堂变更和进入点餐页只刷新当天,使用 [syncTodayCanteenProduct]。 static Future 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(); @@ -273,6 +260,128 @@ abstract class SyncProductTool { // 同步食堂商品 spu start // ************************************************************************** + /// 仅同步终端本地时区的当天食堂菜品,并在成功后记录食堂商品同步时间。 + /// + /// MQTT 菜品变更和进入点餐页使用此入口,避免刷新当天时额外拉取未来六天。 + static Future 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 _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 _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 = >{}; + var currentDate = DateTime.parse(startDay); + final lastDate = DateTime.parse(endDay); + while (!currentDate.isAfter(lastDate)) { + productsByDay[DateUtil.formatDate( + currentDate, + format: DateFormats.y_mo_d, + )] = []; + 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 _batchUpdateCaneenProduct({ required List list, required String dayStr, diff --git a/lib/common/widgets/app/terminal_device_status.dart b/lib/common/widgets/app/terminal_device_status.dart new file mode 100644 index 0000000..9dfc88c --- /dev/null +++ b/lib/common/widgets/app/terminal_device_status.dart @@ -0,0 +1,1011 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/models/manage/terminal_device_status_models.dart'; +import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart'; +import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart'; +import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/app_device_status_tool.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/app_runtime_tool.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/control/terminal_admin_session_control.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/control/terminal_device_status_control.dart'; +import 'package:pj_supermarket_cloud/common/utils/sync/sync_idle_tool.dart'; +import 'package:pj_supermarket_cloud/common/widgets/app/app_dialog.dart'; + +/// 终端设备三态;仅表达当前运行态,不属于接口、缓存或数据库编码。 +enum TerminalDeviceRunState { + /// 最近一次探测未发现该设备,顶栏使用红色状态标记。 + missing, + + /// 已发现设备但当前没有业务任务,顶栏使用黄色状态标记。 + idle, + + /// 设备正在执行打印、称重、读卡、展示或识别任务,顶栏使用绿色状态标记。 + working, +} + +/// 顶栏与详情弹窗共用的单项设备展示数据。 +class _TerminalDeviceStatusItem { + /// 创建单项设备展示数据;可用性优先于工作标记,避免出现无设备却显示工作中的矛盾状态。 + const _TerminalDeviceStatusItem({ + required this.type, + required this.icon, + required this.label, + required this.available, + required this.working, + required this.deviceDescription, + required this.missingDescription, + required this.idleDescription, + required this.workingDescription, + required this.updatedAt, + }); + + /// 对应的稳定设备类型,用于逐项读取和保存顶部显隐设置。 + final TerminalDeviceType type; + + /// 当前设备使用的 Forui SVG 图标。 + final SvgAsset icon; + + /// 设备中文名称,用于顶栏、弹窗和无障碍语义。 + final String label; + + /// 最近一次探测是否发现该设备。 + final bool available; + + /// 当前是否存在真实业务任务占用该设备。 + final bool working; + + /// 设备在收银终端中的职责说明。 + final String deviceDescription; + + /// 未探测到设备时提供的诊断说明。 + final String missingDescription; + + /// 设备存在但没有业务任务时的说明。 + final String idleDescription; + + /// 设备执行真实业务任务时的说明。 + final String workingDescription; + + /// 最近一次设备状态更新时间;尚未检查时为空。 + final DateTime? updatedAt; + + /// 按“是否存在 -> 是否工作”的优先级解析设备三态。 + TerminalDeviceRunState get state { + if (!available) return TerminalDeviceRunState.missing; + return working + ? TerminalDeviceRunState.working + : TerminalDeviceRunState.idle; + } + + /// 返回与当前三态匹配的具体设备说明。 + String get currentDescription => switch (state) { + TerminalDeviceRunState.missing => missingDescription, + TerminalDeviceRunState.idle => idleDescription, + TerminalDeviceRunState.working => workingDescription, + }; +} + +/// 设备三态对应的颜色、文字和辅助图标。 +class _TerminalDeviceStateVisual { + /// 创建一份设备状态视觉配置。 + const _TerminalDeviceStateVisual({ + required this.label, + required this.color, + required this.indicatorIcon, + }); + + /// 面向用户的明确状态文字,避免只依赖颜色传达含义。 + final String label; + + /// 顶栏状态标记和弹窗状态块使用的语义色。 + final Color color; + + /// 顶栏状态标记使用的辅助图标,提供颜色之外的状态区分。 + final SvgAsset indicatorIcon; + + /// 将设备三态转换为项目统一的危险、警告和成功视觉。 + static _TerminalDeviceStateVisual resolve(TerminalDeviceRunState state) { + return switch (state) { + TerminalDeviceRunState.missing => _TerminalDeviceStateVisual( + label: '未检测到', + color: XfjDesignTokens.danger, + indicatorIcon: FAssets.icons.circleX, + ), + TerminalDeviceRunState.idle => _TerminalDeviceStateVisual( + label: '已连接 · 空闲', + color: XfjDesignTokens.warning, + indicatorIcon: FAssets.icons.circleMinus, + ), + TerminalDeviceRunState.working => _TerminalDeviceStateVisual( + label: '工作中', + color: XfjDesignTokens.success, + indicatorIcon: FAssets.icons.circleCheck, + ), + }; + } +} + +/// 将全局可调字号限制在运维弹框的清晰可读区间,兼顾实体终端阅读距离与小尺寸降级布局。 +TextStyle _compactDeviceDialogTextStyle( + TextStyle style, { + required double minSize, + required double maxSize, +}) { + final fontSize = (style.fontSize ?? maxSize).clamp(minSize, maxSize); + return style.copyWith(fontSize: fontSize.toDouble(), letterSpacing: 0); +} + +/// 读取五类终端设备的同一时刻状态,供顶栏图标组和聚合详情弹窗共同使用。 +/// +/// 调用方必须位于 `Obx` 中,方法内读取的设备存在性、瞬时工作态与摄像头会话状态 +/// 才能在对应状态变化时重建最小 UI 子树。 +List<_TerminalDeviceStatusItem> _readTerminalDeviceStatusItems() { + final status = AppDeviceStatusTool.status.value; + final faceService = Get.isRegistered() + ? Get.find() + : null; + final cameraOpen = faceService?.cameraOpen.value ?? false; + final cameraStatus = + faceService?.cameraStatus.value ?? FaceRecognitionSessionStatus.cold; + final cameraVisual = _FaceCameraStatusVisual.resolve( + cameraAvailable: status.cameraAvailable, + cameraOpen: cameraOpen, + status: cameraStatus, + ); + return <_TerminalDeviceStatusItem>[ + _TerminalDeviceStatusItem( + type: TerminalDeviceType.printer, + icon: FAssets.icons.printer, + label: '打印机', + available: status.printerAvailable, + working: AppDeviceStatusTool.printerWorking.value, + deviceDescription: '小票、发票和补打任务,通过 USB 连接。', + missingDescription: '未检测到打印机,请检查 USB、设备供电和系统识别。', + idleDescription: '打印机已连接,当前没有待处理的打印任务。', + workingDescription: '正在发送小票数据,请勿断开打印机。', + updatedAt: status.updatedAt, + ), + _TerminalDeviceStatusItem( + type: TerminalDeviceType.steelyard, + icon: FAssets.icons.scale, + label: '电子秤', + available: status.steelyardAvailable, + working: AppDeviceStatusTool.steelyardWorking.value, + deviceDescription: '采集商品重量并计价,通过串口上报重量。', + missingDescription: '未收到重量数据,请检查供电、串口和秤体显示。', + idleDescription: '电子秤已连接,当前没有商品处于称重流程。', + workingDescription: '正在接收并计算商品重量,请保持秤台稳定。', + updatedAt: status.updatedAt, + ), + _TerminalDeviceStatusItem( + type: TerminalDeviceType.cardReader, + icon: FAssets.icons.idCard, + label: '读卡器', + available: status.cardReaderAvailable, + working: AppDeviceStatusTool.cardReaderWorking.value, + deviceDescription: '饭卡 UID、业务卡号及 M1 数据的串口读写。', + missingDescription: '未检测到读卡器,请检查供电、串口和端口配置。', + idleDescription: '读卡器已连接,当前没有读卡、写卡或诊断任务。', + workingDescription: '正在等待或处理饭卡数据,请保持卡片贴近感应区。', + updatedAt: status.updatedAt, + ), + _TerminalDeviceStatusItem( + type: TerminalDeviceType.subScreen, + icon: FAssets.icons.monitor, + label: '副屏', + available: status.subScreenAvailable, + working: AppDeviceStatusTool.subScreenWorking.value, + deviceDescription: '向顾客展示商品、应付金额、支付引导和业务结果。', + missingDescription: '未检测到副屏,请检查显示连接、悬浮窗权限和多屏设置。', + idleDescription: '副屏已连接,但顾客展示窗口当前未运行。', + workingDescription: '顾客展示窗口正在运行,并随主屏业务同步内容。', + updatedAt: status.updatedAt, + ), + _TerminalDeviceStatusItem( + type: TerminalDeviceType.camera, + icon: + cameraVisual.working ? FAssets.icons.camera : FAssets.icons.cameraOff, + label: '摄像头', + available: status.cameraAvailable, + working: cameraVisual.working, + deviceDescription: '人脸支付与预约查询的实时取景和人脸识别。', + missingDescription: '系统未检测到摄像头,请检查相机硬件和系统识别。', + idleDescription: cameraVisual.description, + workingDescription: cameraVisual.description, + updatedAt: status.updatedAt, + ), + ]; +} + +/// 顶栏统一订阅设备存在性、业务活动和管理解锁状态。 +class TerminalDeviceStatus extends StatelessWidget { + /// 创建订阅应用级设备状态的顶栏图标组。 + const TerminalDeviceStatus({super.key}); + + /// 在顶栏设备组的最小 Obx 子树中读取五类设备状态并数据驱动构建图标。 + @override + Widget build(BuildContext context) { + final adminSession = Get.find(); + final visibilityControl = Get.find(); + return Obx(() { + final detailsEnabled = + adminSession.remainingSeconds.value > 0 && adminSession.isUnlocked; + final items = _readTerminalDeviceStatusItems() + .where((item) => visibilityControl.isHeaderDeviceVisible(item.type)) + .toList(growable: false); + if (items.isEmpty) return const SizedBox.shrink(); + final iconRow = items + .map( + (item) => TerminalStatusIcon( + icon: item.icon, + label: item.label, + state: item.state, + description: item.currentDescription, + ), + ) + .toList(growable: false) + .toRowSpace(space: 8.w); + final content = detailsEnabled + ? Material( + color: Colors.transparent, + child: InkWell( + key: const ValueKey('terminal-device-status-group'), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + onTap: () async { + // 倒计时重建与点击可能发生在相邻帧,弹窗前再次校验绝对过期时间, + // 确保会话刚过期时整个状态组都没有触觉反馈或导航副作用。 + if (!Get.find().isUnlocked) { + return; + } + HapticFeedback.selectionClick(); + await _showTerminalDeviceStatusDialog(context); + }, + child: iconRow, + ), + ) + : iconRow; + final statusSummary = items.map((item) { + final visual = _TerminalDeviceStateVisual.resolve(item.state); + return '${item.label}${visual.label}'; + }).join(','); + return Semantics( + container: true, + button: detailsEnabled, + enabled: detailsEnabled, + label: '终端设备状态,$statusSummary', + hint: detailsEnabled ? '点击查看全部设备详情' : null, + child: Tooltip( + message: detailsEnabled ? '点击查看全部设备状态' : statusSummary, + child: content, + ), + ); + }); + } +} + +/// 48dp 状态图标提供单项三态提示;点击能力由外层设备组统一管理。 +class TerminalStatusIcon extends StatelessWidget { + /// 创建一个带三态标记和语义标签的固定尺寸图标。 + const TerminalStatusIcon({ + super.key, + required this.icon, + required this.label, + required this.state, + required this.description, + }); + + /// 当前设备能力使用的 Forui SVG 图标。 + final SvgAsset icon; + + /// 设备能力中文名称,同时用于无障碍语义。 + final String label; + + /// 当前设备三态,决定状态颜色、文字和辅助图标。 + final TerminalDeviceRunState state; + + /// 当前状态的具体说明,用于顶栏 Tooltip 与无障碍语义。 + final String description; + + /// 构建 48dp 图标和右下三态标记,不在单项内部创建点击回调。 + @override + Widget build(BuildContext context) { + final visual = _TerminalDeviceStateVisual.resolve(state); + final content = SizedBox.square( + dimension: 48.w, + child: Stack( + alignment: Alignment.center, + children: [ + FIcon( + icon, + size: 21.w, + color: state == TerminalDeviceRunState.missing + ? XfjDesignTokens.muted + : XfjDesignTokens.ink, + ), + Positioned( + right: 5.w, + bottom: 5.w, + child: Container( + key: ValueKey('terminal-status-$label-${state.name}'), + width: 16.w, + height: 16.w, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: XfjDesignTokens.surface, + border: Border.all( + color: XfjDesignTokens.surface, + width: 1.w, + ), + ), + child: FIcon( + visual.indicatorIcon, + size: 13.w, + color: visual.color, + ), + ), + ), + ], + ), + ); + return Semantics( + container: true, + label: '$label,${visual.label},$description', + child: Tooltip( + message: '$label:${visual.label}。$description', + child: content, + ), + ); + } +} + +/// 以接近全屏的横屏弹窗同时展示五类设备详情;调用方必须先确认管理会话仍处于解锁期。 +Future _showTerminalDeviceStatusDialog(BuildContext context) async { + if (!Get.find().isUnlocked) return; + final theme = FTheme.of(context); + final viewportSize = MediaQuery.sizeOf(context); + // 终端虽然分辨率较高,但实体屏幕较小,因此仅保留少量模态边缘; + // 大弹框用于保证信息可读与后续诊断扩展,设备表自身仍按内容自然高度排列。 + final dialogWidth = viewportSize.width * 0.92; + final dialogHeight = viewportSize.height * 0.90; + await showAppDialog( + context: context, + builder: (dialogContext) => AppDialog( + key: const ValueKey('terminal-device-status-dialog'), + title: _deviceStatusDialogHeader(theme, dialogContext), + contentPadding: EdgeInsets.zero, + showCloseButton: false, + semanticLabel: '终端设备状态', + width: dialogWidth, + maxWidth: dialogWidth, + height: dialogHeight, + content: [ + Obx(() { + final items = _readTerminalDeviceStatusItems(); + return SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: [ + _deviceStateSummary(theme, items), + _deviceStatusList(theme, items), + ].toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.stretch, + space: 12.h, + ), + ); + }).expanded(), + const Divider(height: 1, color: XfjDesignTokens.line), + _deviceStatusDialogFooter(theme, dialogContext), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + ), + ); +} + +/// 构建聚合设备弹窗标题栏,并提供符合终端触控尺寸的显式关闭入口。 +Widget _deviceStatusDialogHeader( + FThemeData theme, + BuildContext dialogContext, +) { + final closeTouchSize = (56.w).clamp(48.0, 56.0).toDouble(); + final headerIconSize = (22.w).clamp(18.0, 20.0).toDouble(); + final headerIconBoxSize = (44.w).clamp(36.0, 40.0).toDouble(); + return [ + FIcon( + FAssets.icons.monitorCog, + size: headerIconSize, + color: XfjDesignTokens.primary, + ).center().tightSize(headerIconBoxSize).decorated( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + [ + Text( + '终端设备状态', + style: _compactDeviceDialogTextStyle( + theme.typography.xl2, + minSize: 18, + maxSize: 20, + ).copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + height: 1.15, + ), + ), + Text( + '打印机、电子秤、读卡器、副屏与摄像头', + style: _compactDeviceDialogTextStyle( + theme.typography.sm, + minSize: 14, + maxSize: 15, + ).copyWith( + color: XfjDesignTokens.muted, + height: 1.2, + ), + ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + space: 2.h, + ) + .expanded(), + Material( + color: Colors.transparent, + child: InkWell( + key: const ValueKey('terminal-device-status-close-icon'), + onTap: () => Navigator.of(dialogContext).pop(), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: FIcon( + FAssets.icons.x, + size: (18.w).clamp(15.0, 17.0).toDouble(), + color: XfjDesignTokens.muted, + ).center().tightSize(closeTouchSize), + ), + ), + ].toRowSpace(space: 12.w).padding(horizontal: 22.w, vertical: 12.h); +} + +/// 汇总五类设备的红、黄、绿数量,让用户无需逐项查找即可先判断整体运行情况。 +Widget _deviceStateSummary( + FThemeData theme, + List<_TerminalDeviceStatusItem> items, +) { + final checkedAt = items.isEmpty ? null : items.first.updatedAt; + final summaries = TerminalDeviceRunState.values.map((state) { + return ( + visual: _TerminalDeviceStateVisual.resolve(state), + count: items.where((item) => item.state == state).length, + ); + }).toList(growable: false); + return [ + [ + Text( + '设备总览', + style: _compactDeviceDialogTextStyle( + theme.typography.base, + minSize: 15, + maxSize: 16, + ).copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + height: 1.15, + ), + ), + Text( + '共 ${items.length} 个设备 · 检测 ${_formatDeviceCheckedAt(checkedAt)}', + style: _compactDeviceDialogTextStyle( + theme.typography.sm, + minSize: 13, + maxSize: 14, + ).copyWith( + color: XfjDesignTokens.muted, + height: 1.15, + ), + ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + space: 2.h, + ) + .expanded(), + for (var index = 0; index < summaries.length; index++) ...[ + if (index > 0) + Container(width: 1.w, height: 28.h, color: XfjDesignTokens.line), + Semantics( + label: '${summaries[index].visual.label} ${summaries[index].count} 个设备', + child: [ + FIcon( + summaries[index].visual.indicatorIcon, + size: (18.w).clamp(16.0, 18.0).toDouble(), + color: summaries[index].visual.color, + ), + Text( + summaries[index].visual.label, + style: _compactDeviceDialogTextStyle( + theme.typography.sm, + minSize: 14, + maxSize: 15, + ).copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + Text( + '${summaries[index].count}', + style: _compactDeviceDialogTextStyle( + theme.typography.base, + minSize: 15, + maxSize: 17, + ).copyWith( + color: summaries[index].visual.color, + fontWeight: FontWeight.w800, + height: 1.2, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ] + .toRowSpace(space: 7.w, mainAxisSize: MainAxisSize.min) + .padding(horizontal: 8.w, vertical: 8.h), + ), + ], + ] + .toRowSpace(space: 10.w) + .padding(horizontal: 22.w, vertical: 12.h) + .decorated(color: XfjDesignTokens.canvas); +} + +/// 将五个设备组织为带稳定列宽的状态表,并以适中行距区分连续设备信息。 +Widget _deviceStatusList( + FThemeData theme, + List<_TerminalDeviceStatusItem> items, +) { + final rowGap = (8.h).clamp(6.0, 8.0).toDouble(); + final Widget table = [ + _deviceStatusTableHeader(theme), + ...items.map((item) => _deviceStatusRow(theme, item)), + ].toColumnSpace( + key: const ValueKey('terminal-device-status-table'), + crossAxisAlignment: CrossAxisAlignment.stretch, + space: rowGap, + ); + return table.padding(horizontal: 22.w, bottom: 16.h); +} + +/// 构建设备表头,使五行状态和各自的顶部显隐开关共享稳定列宽。 +Widget _deviceStatusTableHeader(FThemeData theme) { + final headerStyle = _compactDeviceDialogTextStyle( + theme.typography.sm, + minSize: 14, + maxSize: 15, + ).copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + height: 1.15, + ); + return _deviceStatusGridRow( + children: [ + Text('设备', style: headerStyle), + Text('运行状态', style: headerStyle), + Text('当前状态', style: headerStyle), + Text('设备说明', style: headerStyle), + Text('顶部显示', style: headerStyle, textAlign: TextAlign.center), + ], + ) + .padding(horizontal: 18.w) + .constrained(minHeight: (48.h).clamp(44.0, 48.0).toDouble()) + .decorated( + color: XfjDesignTokens.canvas, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); +} + +/// 构建单个设备的可读表格行,使用色标与图标文字表达三态,避免状态底色铺满整列。 +Widget _deviceStatusRow( + FThemeData theme, + _TerminalDeviceStatusItem item, +) { + final visual = _TerminalDeviceStateVisual.resolve(item.state); + final rowIconSize = (22.w).clamp(19.0, 22.0).toDouble(); + final rowIconBoxSize = (42.w).clamp(36.0, 42.0).toDouble(); + final rowHeight = (76.h).clamp(68.0, 76.0).toDouble(); + final bodyStyle = _compactDeviceDialogTextStyle( + theme.typography.base, + minSize: 15, + maxSize: 16, + ).copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w500, + height: 1.35, + ); + final visibilityControl = Get.find(); + return Container( + key: ValueKey('terminal-device-status-row-${item.label}'), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: [ + Container(width: 4.w, color: visual.color), + _deviceStatusGridRow( + children: [ + [ + FIcon(item.icon, size: rowIconSize, color: visual.color) + .center() + .tightSize(rowIconBoxSize) + .decorated( + color: visual.color.withValues(alpha: 0.09), + borderRadius: BorderRadius.circular(8.w), + ), + Text( + item.label, + style: _compactDeviceDialogTextStyle( + theme.typography.lg, + minSize: 16, + maxSize: 17, + ).copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + height: 1.15, + ), + ).expanded(), + ].toRowSpace(space: 7.w), + Align( + alignment: Alignment.centerLeft, + child: Semantics( + label: '${item.label}${visual.label}', + child: [ + FIcon( + visual.indicatorIcon, + size: (18.w).clamp(16.0, 18.0).toDouble(), + color: visual.color, + ), + Text( + visual.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _compactDeviceDialogTextStyle( + theme.typography.base, + minSize: 15, + maxSize: 16, + ).copyWith( + color: visual.color, + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + ].toRowSpace( + key: ValueKey('terminal-device-status-label-${item.label}'), + space: 7.w, + mainAxisSize: MainAxisSize.min, + ), + ), + ), + Text( + item.currentDescription, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: bodyStyle.copyWith(fontWeight: FontWeight.w600), + ), + Text( + item.deviceDescription, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: bodyStyle.copyWith(color: XfjDesignTokens.muted), + ), + Obx(() { + final visible = visibilityControl.isHeaderDeviceVisible(item.type); + return FSwitch( + key: ValueKey( + 'terminal-device-status-visibility-${item.type.code}', + ), + semanticLabel: '在页面顶部显示${item.label}状态', + value: visible, + onChange: (value) => unawaited( + visibilityControl.setHeaderDeviceVisible(item.type, value), + ), + ); + }).center(), + ], + ).padding(horizontal: 16.w, vertical: 8.h).expanded(), + ].toRow( + crossAxisAlignment: CrossAxisAlignment.stretch, + ), + ).height(rowHeight); +} + +/// 按固定比例排列设备、运行状态、当前状态、设备说明和顶部开关,确保表头与正文严格对齐。 +Widget _deviceStatusGridRow({required List children}) { + const flexes = [15, 16, 32, 27, 10]; + assert(children.length == flexes.length, '设备状态表格必须提供五列内容'); + return [ + for (var index = 0; index < children.length; index++) + children[index].padding(horizontal: 4.w).expanded(flex: flexes[index]), + ].toRow( + crossAxisAlignment: CrossAxisAlignment.center, + ); +} + +/// 构建弹窗底部的全宽关闭按钮;逐项顶部显隐由对应设备行直接配置。 +Widget _deviceStatusDialogFooter( + FThemeData theme, + BuildContext dialogContext, +) { + final buttonHeight = (56.h).clamp(48.0, 56.0).toDouble(); + return FButton( + key: const ValueKey('terminal-device-status-close-button'), + label: Text( + '关闭', + style: _compactDeviceDialogTextStyle( + theme.typography.lg, + minSize: 15, + maxSize: 16, + ).copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + onPress: () => Navigator.of(dialogContext).pop(), + ).height(buttonHeight).padding(horizontal: 22.w, vertical: 12.h); +} + +/// 将设备本地检测时间格式化为稳定的数字布局;没有记录时返回明确空状态。 +String _formatDeviceCheckedAt(DateTime? value) { + if (value == null) return '尚未完成设备检测'; + String twoDigits(int number) => number.toString().padLeft(2, '0'); + return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)} ' + '${twoDigits(value.hour)}:${twoDigits(value.minute)}:${twoDigits(value.second)}'; +} + +/// 将人脸会话状态转换为摄像头当前是否工作及对应说明。 +class _FaceCameraStatusVisual { + /// 创建一份摄像头状态描述。 + const _FaceCameraStatusVisual({ + required this.description, + required this.working, + }); + + /// 悬停时展示的摄像头与识别引擎状态说明。 + final String description; + + /// 摄像头是否正在初始化、识别或处理本次结果。 + final bool working; + + /// 综合硬件存在性、摄像头开启状态和人脸会话状态生成对应说明。 + static _FaceCameraStatusVisual resolve({ + required bool cameraAvailable, + required bool cameraOpen, + required FaceRecognitionSessionStatus status, + }) { + if (!cameraAvailable) { + return const _FaceCameraStatusVisual( + description: '系统未检测到可用摄像头,请检查硬件和系统识别。', + working: false, + ); + } + if (status == FaceRecognitionSessionStatus.initializing) { + return const _FaceCameraStatusVisual( + description: '正在打开摄像头并初始化识别引擎。', + working: true, + ); + } + if (status == FaceRecognitionSessionStatus.detecting) { + return const _FaceCameraStatusVisual( + description: '摄像头已打开,正在实时识别人脸。', + working: true, + ); + } + if (status == FaceRecognitionSessionStatus.success) { + return const _FaceCameraStatusVisual( + description: '识别已完成,正在处理本次结果。', + working: true, + ); + } + if (status == FaceRecognitionSessionStatus.failed) { + return const _FaceCameraStatusVisual( + description: '摄像头可用,但本次识别异常,当前已空闲。', + working: false, + ); + } + return _FaceCameraStatusVisual( + description: + cameraOpen ? '摄像头热保持空闲,20 分钟无任务后自动关闭。' : '摄像头已检测到,当前没有人脸识别任务。', + working: false, + ); + } +} + +/// 顶栏轻量展示进程 CPU、系统内存、存储和调度空闲状态。 +class TerminalPerformanceStatus extends StatefulWidget { + /// 创建顶栏性能与空闲状态区域。 + const TerminalPerformanceStatus({super.key}); + + /// 创建管理三秒采样周期和两次 CPU 快照差值的状态对象。 + @override + State createState() => + _TerminalPerformanceStatusState(); +} + +/// 顶栏性能指标的采样、CPU 差值计算和展示状态。 +class _TerminalPerformanceStatusState extends State { + /// 每三秒触发一次轻量原生资源采样的计时器。 + Timer? _timer; + + /// 上一次原生累计资源快照,用于计算当前进程 CPU 使用率。 + AppPerformanceSnapshot? _previous; + + /// 上一次资源快照完成的本地时间,用于计算 CPU 墙钟时间差。 + DateTime? _previousAt; + + /// 当前用于顶栏展示的内存、存储和累计 CPU 快照。 + AppPerformanceSnapshot _snapshot = const AppPerformanceSnapshot.empty(); + + /// 当前调度空闲判断快照,与性能数据在同一次局部 setState 中刷新。 + SyncIdleSnapshot _idle = SyncIdleTool.instance.snapshot; + + /// 根据连续两次累计 CPU 时间计算的进程 CPU 百分比,范围为 0 到 100。 + double _cpuUsage = 0; + + /// 是否已有资源采样请求进行中,防止慢平台通道造成并发采样。 + bool _refreshing = false; + + /// 首次立即采样,并启动固定三秒的后续刷新周期。 + @override + void initState() { + super.initState(); + unawaited(_refresh()); + _timer = Timer.periodic( + const Duration(seconds: 3), + (_) => unawaited(_refresh()), + ); + } + + /// 页面释放时取消性能采样计时器,避免顶栏销毁后继续访问平台通道。 + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + /// 读取原生资源快照、计算进程 CPU 差值,并在组件仍挂载时刷新可见状态。 + Future _refresh() async { + if (_refreshing) return; + _refreshing = true; + try { + final current = await AppRuntimeTool.getPerformanceSnapshot(); + final now = DateTime.now(); + final previous = _previous; + final previousAt = _previousAt; + var cpuUsage = _cpuUsage; + if (previous != null && previousAt != null) { + final wallMillis = now.difference(previousAt).inMilliseconds; + final cpuMillis = + current.processCpuTimeMillis - previous.processCpuTimeMillis; + if (wallMillis > 0 && cpuMillis >= 0) { + cpuUsage = + (cpuMillis / wallMillis / current.logicalProcessorCount * 100) + .clamp(0, 100) + .toDouble(); + } + } + _previous = current; + _previousAt = now; + if (!mounted) return; + setState(() { + _snapshot = current; + _idle = SyncIdleTool.instance.snapshot; + _cpuUsage = cpuUsage; + }); + } finally { + _refreshing = false; + } + } + + /// 构建 CPU、内存、存储和空闲摘要,详细容量通过 Tooltip 展示。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final idleColor = + _idle.idle ? XfjDesignTokens.success : XfjDesignTokens.warning; + final memoryUsage = (_snapshot.memoryUsageRatio * 100).round(); + final storageUsage = (_snapshot.storageUsageRatio * 100).round(); + final cpuText = + _snapshot.processCpuTimeMillis == 0 ? '--' : '${_cpuUsage.round()}%'; + final memoryText = + _snapshot.systemMemoryTotalBytes == 0 ? '--' : '$memoryUsage%'; + final storageText = + _snapshot.storageTotalBytes == 0 ? '--' : '$storageUsage%'; + final warning = _cpuUsage >= 85 || memoryUsage >= 90 || storageUsage >= 90; + return Tooltip( + message: _tooltipText(memoryUsage, storageUsage), + child: Semantics( + container: true, + label: + '设备性能,CPU $cpuText,内存 $memoryText,存储 $storageText,${_idle.label}', + child: [ + FIcon( + FAssets.icons.gauge, + size: 21.w, + color: warning ? XfjDesignTokens.danger : XfjDesignTokens.ink, + ), + [ + Text( + 'CPU $cpuText · 内存 $memoryText · 存储 $storageText', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + [ + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: idleColor, + ), + ), + Text( + _idle.idle ? '设备空闲' : '设备忙碌 · ${_idle.reason}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: idleColor, + fontWeight: FontWeight.w600, + ), + ).expanded(), + ].toRowSpace(space: 6.w), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.start, + space: 3.h, + ) + .expanded(), + ] + .toRowSpace(space: 10.w) + .padding(horizontal: 12.w) + .constrained(width: 300.w, height: 48.h), + ), + ); + } + + /// 生成包含应用内存、系统总内存、存储容量和忙碌原因的悬停说明。 + String _tooltipText(int memoryUsage, int storageUsage) { + final memory = _snapshot.systemMemoryTotalBytes == 0 + ? '内存数据不可用' + : '系统内存 $memoryUsage%,应用占用 ${_formatBytes(_snapshot.appMemoryBytes)}' + ' / 总计 ${_formatBytes(_snapshot.systemMemoryTotalBytes)}'; + final storage = _snapshot.storageTotalBytes == 0 + ? '存储数据不可用' + : '存储 $storageUsage%,可用 ${_formatBytes(_snapshot.storageAvailableBytes)}' + ' / 总计 ${_formatBytes(_snapshot.storageTotalBytes)}'; + final idle = _idle.idle ? '设备空闲' : '设备忙碌:${_idle.reason}'; + return '进程 CPU ${_cpuUsage.toStringAsFixed(1)}%\n$memory\n$storage\n$idle'; + } + + /// 将字节数转换为适合顶栏悬停说明的 GB、MB 或 KB 文本。 + String _formatBytes(int bytes) { + if (bytes >= 1024 * 1024 * 1024) { + return '${(bytes / 1024 / 1024 / 1024).toStringAsFixed(1)} GB'; + } + if (bytes >= 1024 * 1024) { + return '${(bytes / 1024 / 1024).toStringAsFixed(0)} MB'; + } + return '${(bytes / 1024).toStringAsFixed(0)} KB'; + } +} diff --git a/lib/common/widgets/app/terminal_shell.dart b/lib/common/widgets/app/terminal_shell.dart new file mode 100644 index 0000000..ea5d337 --- /dev/null +++ b/lib/common/widgets/app/terminal_shell.dart @@ -0,0 +1,1056 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +export 'terminal_device_status.dart'; + +/// 主屏各业务页面共用的 Flutter 终端框架。 +/// +/// 顶部系统操作必须常驻,设备状态按本机偏好显示;本项目的业务入口统一放在 +/// panel 主菜单页,终端内不再提供左侧菜单,业务页面只负责顶栏下方的工作区。 +class TerminalShell extends StatelessWidget { + /// 创建一个保留终端顶栏和业务页面生命周期的主屏外壳。 + const TerminalShell({ + super.key, + required this.title, + required this.child, + this.headerLeading, + }); + + /// 当前内层业务路由显示在顶栏的标题。 + final String title; + + /// 持续保留在睡眠遮罩下方的业务页面。 + final Widget child; + + /// 标题右侧按业务页面需要插入的附加控件。 + final Widget? headerLeading; + + /// 构建终端常驻框架,并仅在最上层响应式切换睡眠遮罩。 + @override + Widget build(BuildContext context) { + final sleepControl = Get.find(); + return ColoredBox( + color: XfjDesignTokens.canvas, + child: [ + SafeArea( + child: Column( + children: [ + TerminalHeader(title: title, leading: headerLeading), + child.expanded(), + ], + ), + ), + Obx( + () { + if (!sleepControl.isSleeping.value) { + return const SizedBox.shrink(); + } + // 只叠加不透明交互层,底下的页面、Controller 和硬件监听继续保持原生命周期。 + return const TerminalSleepOverlay(); + }, + ), + ].toStack(fit: StackFit.expand), + ); + } +} + +class TerminalHeader extends StatelessWidget { + const TerminalHeader({super.key, required this.title, this.leading}); + + final String title; + final Widget? leading; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + height: 80.h, + padding: EdgeInsets.symmetric(horizontal: 20.w), + decoration: const BoxDecoration( + color: XfjDesignTokens.surface, + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + // 宽屏时保留原有的左右对齐,窄屏时则让从 PJ 到退出的整段顶栏可横向滚动。 + child: IntrinsicWidth( + child: Row( + children: [ + TerminalBrand(onTap: TerminalNavigationTool.backToPanel), + SizedBox(width: 20.w), + Container( + width: 1, + height: 36.h, + color: XfjDesignTokens.line, + ), + SizedBox(width: 20.w), + Text( + title, + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + if (leading != null) ...[SizedBox(width: 36.w), leading!], + const Spacer(), + Container( + padding: EdgeInsets.all(4.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + const TerminalNetworkStatus(), + _terminalDeviceStatusHeaderSlot(), + const _TerminalHeaderDivider(), + const TerminalPerformanceStatus(), + const _TerminalHeaderDivider(), + const _TerminalAdminLockStatus(), + const _TerminalHeaderDivider(), + const _TerminalSleepStatus(), + ], + ), + ), + SizedBox(width: 24.w), + SizedBox( + width: 100.w, + child: FutureBuilder( + future: AppSettingTool.getUserSetting(), + builder: (context, snapshot) => Text( + snapshot.data?.username ?? '收银员', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + SizedBox(width: 20.w), + Container( + width: 1, + height: 32.h, + color: XfjDesignTokens.line, + ), + SizedBox(width: 20.w), + FIcon(FAssets.icons.clock, color: XfjDesignTokens.ink), + SizedBox(width: 8.w), + const _TerminalClock(), + SizedBox(width: 16.w), + TerminalHeaderIconButton( + tooltip: '返回主菜单', + icon: FAssets.icons.layoutGrid, + onTap: TerminalNavigationTool.backToPanel, + ), + SizedBox(width: 8.w), + TerminalHeaderIconButton( + tooltip: '设置', + icon: FAssets.icons.settings, + onTap: () => + TerminalNavigationTool.open(RouteNames.setting), + ), + SizedBox(width: 8.w), + TerminalHeaderIconButton( + tooltip: '退出登录', + icon: FAssets.icons.logOut, + danger: true, + onTap: TerminalNavigationTool.logout, + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// 仅响应顶部逐项显隐;五项全部隐藏时连同左侧分隔线移除,避免状态区出现重复分隔线。 +Widget _terminalDeviceStatusHeaderSlot() { + final control = Get.find(); + return Obx(() { + if (!control.hasVisibleHeaderDevices) return const SizedBox.shrink(); + return [ + const _TerminalHeaderDivider(), + const TerminalDeviceStatus(), + ].toRow( + key: const ValueKey('terminal-device-status-header-slot'), + mainAxisSize: MainAxisSize.min, + ); + }); +} + +/// 顶栏品牌字标需要保持固定比例,不能跟随全局字号缩放无限放大。 +/// +/// 这里使用受约束的 `FittedBox` 保留既有品牌字标的斜体、紧字距效果,同时避免 +/// 大字号设置把品牌挤出 80 高的终端顶栏。 +class TerminalBrand extends StatelessWidget { + const TerminalBrand({super.key, this.onTap}); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final brand = SizedBox( + width: 100.w, + height: 55.h, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: Text( + 'PJ', + textAlign: TextAlign.center, + style: theme.typography.xl6.copyWith( + color: const Color(0xFF1268E8), + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + ), + ), + ), + ); + if (onTap == null) return brand; + + // 本项目业务入口统一在 panel 主菜单;品牌位作为返回主菜单的固定兜底入口。 + return Semantics( + button: true, + label: '返回主菜单', + child: Tooltip( + message: '返回主菜单', + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: brand, + ), + ), + ); + } +} + +/// 网络无有效流量或原生累计计数器暂不可用时使用的稳定展示值。 +const String _zeroNetworkSpeedLabel = '0 KB/s'; + +/// 将每秒字节数格式化为终端顶栏使用的自适应速率文本。 +/// +/// 零值、负增量、计数器异常产生的非有限值都按无流量处理,避免只剩单位或显示无效数字。 +String formatTerminalNetworkSpeed(double bytesPerSecond) { + if (!bytesPerSecond.isFinite || bytesPerSecond <= 0) { + return _zeroNetworkSpeedLabel; + } + if (bytesPerSecond >= 1024 * 1024) { + return '${(bytesPerSecond / 1024 / 1024).toStringAsFixed(1)} MB/s'; + } + if (bytesPerSecond >= 1024) { + return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; + } + return '${bytesPerSecond.toStringAsFixed(0)} B/s'; +} + +/// 终端顶栏网络类型、连接状态及实时上下行速率入口。 +class TerminalNetworkStatus extends StatefulWidget { + /// 创建一个使用 Android 累计流量快照进行轻量采样的网络状态组件。 + const TerminalNetworkStatus({super.key}); + + /// 创建并持有网络采样定时器与当前展示状态。 + @override + State createState() => _TerminalNetworkStatusState(); +} + +/// 网络状态组件的采样与展示状态,每三秒比较一次累计字节差值。 +class _TerminalNetworkStatusState extends State { + /// 最近一次跨页面共享的原生累计流量快照。 + static AppNetworkSnapshot? _lastSnapshot; + + /// 最近一次快照的本机采样时间,用于将累计字节差换算为每秒速率。 + static DateTime? _lastSampleTime; + + /// 页面重建时复用的最近下行速率文案。 + static String _cachedDownloadSpeed = _zeroNetworkSpeedLabel; + + /// 页面重建时复用的最近上行速率文案。 + static String _cachedUploadSpeed = _zeroNetworkSpeedLabel; + + /// 最近一次平滑后的下行速率,单位为字节每秒。 + static double _smoothedDownload = 0; + + /// 最近一次平滑后的上行速率,单位为字节每秒。 + static double _smoothedUpload = 0; + + /// 周期采样定时器,组件销毁时必须取消。 + Timer? _timer; + + /// 当前网络类型的中文展示名称。 + String _networkType = '网络'; + + /// 当前下行速率文案,无流量时为 `0 KB/s`。 + String _downloadSpeed = _zeroNetworkSpeedLabel; + + /// 当前上行速率文案,无流量时为 `0 KB/s`。 + String _uploadSpeed = _zeroNetworkSpeedLabel; + + /// 原生网络或 Flutter 连接探测任一确认在线时为 true。 + bool _connected = false; + + /// 进入顶栏后立即采样,并启动三秒一次的轻量刷新任务。 + @override + void initState() { + super.initState(); + _refresh(); + _timer = Timer.periodic(const Duration(seconds: 3), (_) => _refresh()); + } + + /// 取消周期采样,避免路由切换后旧顶栏继续访问平台通道。 + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + /// 读取原生累计流量并计算相邻快照差值,同时使用 Flutter 网络状态兜底连接类型。 + Future _refresh() async { + final current = await AppRuntimeTool.getNetworkSnapshot(); + final flutterConnected = await AppNetworkTool.isConnectedNow(); + final flutterType = await AppNetworkTool.getNetworkTypeNow(); + final now = DateTime.now(); + final previous = _lastSnapshot; + final previousTime = _lastSampleTime; + if (!mounted) return; + setState(() { + // 原生 activeNetwork 在部分模拟器中可能为空,在线状态以 Flutter 监听结果兜底。 + _connected = current.connected || flutterConnected; + _networkType = _networkLabel( + current.type == 'offline' && flutterConnected + ? flutterType + : current.type, + ); + if (previous != null && previousTime != null && _connected) { + final seconds = now.difference(previousTime).inMilliseconds / 1000; + if (seconds > 0) { + final download = + (current.receivedBytes - previous.receivedBytes) / seconds; + final upload = + (current.transmittedBytes - previous.transmittedBytes) / seconds; + // 正增量做平滑以降低峰值跳动;零、负数或异常采样立即归零,不能沿用陈旧上行值。 + _smoothedDownload = _smoothSpeed(_smoothedDownload, download); + _smoothedUpload = _smoothSpeed(_smoothedUpload, upload); + _cachedDownloadSpeed = formatTerminalNetworkSpeed( + _smoothedDownload, + ); + _cachedUploadSpeed = formatTerminalNetworkSpeed(_smoothedUpload); + } + _downloadSpeed = _cachedDownloadSpeed; + _uploadSpeed = _cachedUploadSpeed; + } else { + // 页面切换重建顶栏时继续显示最近一次测速,避免第一帧错误归零。 + _downloadSpeed = _cachedDownloadSpeed; + _uploadSpeed = _cachedUploadSpeed; + } + _lastSnapshot = current; + _lastSampleTime = now; + }); + } + + /// 构建固定双行的上下行速率,禁止长下行文案自动折行占用上行所在行。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + width: 230.w, + constraints: BoxConstraints(minHeight: 48.h), + padding: EdgeInsets.symmetric(horizontal: 10.w), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + _networkIcon(_networkType), + size: 19.w, + color: _connected ? XfjDesignTokens.ink : XfjDesignTokens.warning, + ), + SizedBox(width: 7.w), + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _connected + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + ), + ), + SizedBox(width: 7.w), + SizedBox( + width: 72.w, + child: Text( + _connected ? _networkType : '网络离线', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith(color: XfjDesignTokens.ink), + ), + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + '↓$_downloadSpeed\n↑$_uploadSpeed', + key: const ValueKey('terminal-network-speed'), + maxLines: 2, + softWrap: false, + overflow: TextOverflow.clip, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontSize: (theme.typography.xs.fontSize ?? 12) * 0.82, + height: 1.05, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + } + + /// 将原生网络类型编码转换为顶栏短文案,未知类型降级为“网络”。 + String _networkLabel(String type) => switch (type) { + 'ethernet' => '网线', + 'wifi' => 'WiFi', + '2g' => '2G', + '3g' => '3G', + '4g' => '4G', + '5g' => '5G', + 'mobile' => '移动网络', + _ => '网络', + }; + + /// 根据网络类型选择有线、WiFi 或移动信号图标。 + SvgAsset _networkIcon(String label) { + if (label == 'WiFi') return FAssets.icons.wifi; + if (label == '网线') return FAssets.icons.network; + return FAssets.icons.signal; + } + + /// 仅平滑有效正增量;无流量、计数器回退和非有限值立即归零,防止显示陈旧速率。 + double _smoothSpeed(double previous, double current) { + if (!current.isFinite || current <= 0) return 0; + if (!previous.isFinite || previous <= 0) return current; + return previous * 0.65 + current * 0.35; + } +} + +class TerminalSurface extends StatelessWidget { + const TerminalSurface({ + super.key, + required this.child, + this.padding, + this.width, + this.height, + this.color = XfjDesignTokens.surface, + this.clipBehavior = Clip.none, + }); + + final Widget child; + final EdgeInsetsGeometry? padding; + final double? width; + final double? height; + final Color color; + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + padding: padding, + clipBehavior: clipBehavior, + decoration: BoxDecoration( + color: color, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: child, + ); + } +} + +/// 顶栏仅监听管理会话秒数,避免倒计时导致整个终端页面每秒重建。 +class _TerminalAdminLockStatus extends StatelessWidget { + const _TerminalAdminLockStatus(); + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final session = Get.find(); + return Obx(() { + // 读取响应式秒数触发局部刷新,真实有效性仍以绝对过期时间为准。 + final seconds = session.remainingSeconds.value; + final unlocked = session.isUnlocked && seconds > 0; + final stateColor = + unlocked ? XfjDesignTokens.success : XfjDesignTokens.muted; + final stateIcon = unlocked ? FAssets.icons.lockOpen : FAssets.icons.lock; + final actionIcon = unlocked ? FAssets.icons.lock : FAssets.icons.lockOpen; + final label = unlocked ? session.remainingLabel : '已锁定'; + return Semantics( + container: true, + label: unlocked ? '管理操作已解锁,剩余$label' : '管理操作已锁定', + child: Container( + width: 164.w, + height: 40.h, + padding: EdgeInsets.only(left: 10.w, right: 4.w), + decoration: BoxDecoration( + color: unlocked ? const Color(0xFFF0FAF6) : XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + FIcon(stateIcon, size: 19.w, color: stateColor), + SizedBox(width: 8.w), + Expanded( + child: Text( + label, + maxLines: 1, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: unlocked ? XfjDesignTokens.ink : stateColor, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + SizedBox(width: 4.w), + Semantics( + button: true, + label: unlocked ? '立即锁定管理操作' : '解锁管理操作', + child: Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: InkWell( + onTap: () { + if (unlocked) { + session.lock(); + HapticFeedback.selectionClick(); + return; + } + TerminalNavigationTool.unlockAdminSession(); + }, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: SizedBox( + width: 40.w, + height: 40.h, + child: FIcon( + actionIcon, + size: 19.w, + color: unlocked + ? XfjDesignTokens.danger + : XfjDesignTokens.primary, + ).center(), + ), + ), + ), + ), + ], + ), + ), + ); + }); + } +} + +/// 顶栏睡眠入口,仅切换主屏遮罩状态,不触碰当前页面和硬件资源。 +class _TerminalSleepStatus extends StatelessWidget { + /// 创建固定尺寸的主屏睡眠入口。 + const _TerminalSleepStatus(); + + /// 构建带提示和触觉反馈的睡眠按钮。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sleepControl = Get.find(); + return Semantics( + button: true, + label: '进入终端睡眠', + child: Tooltip( + message: '进入睡眠', + child: InkWell( + onTap: () { + sleepControl.enterSleep(); + HapticFeedback.selectionClick(); + }, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + FIcon( + FAssets.icons.moonStar, + size: 19.w, + color: XfjDesignTokens.primary, + ), + Text( + '睡眠', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ] + .toRowSpace( + space: 8.w, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .padding(horizontal: 12.w) + .tight(height: 40.h), + ), + ), + ); + } +} + +/// 覆盖主屏交互的睡眠层,业务页面仍保留在同一棵 Widget 树中持续运行。 +class TerminalSleepOverlay extends StatelessWidget { + /// 创建不可通过系统返回键绕过的全屏睡眠层。 + const TerminalSleepOverlay({super.key}); + + /// 与顾客欢迎副屏共用的安心守护插图,保证主副屏品牌视觉一致。 + static const _illustrationAsset = + 'assets/images/customer_welcome/abstract-care.webp'; + + /// 请求管理密码校验,只有验证成功才移除睡眠层。 + Future _requestWake( + BuildContext context, + TerminalSleepControl sleepControl, + ) async { + try { + final awakened = await sleepControl.requestWake( + () => terminalAdminPasswordDialog( + context, + actionLabel: '唤醒终端', + ), + ); + if (awakened) await HapticFeedback.selectionClick(); + } catch (error, stack) { + logger.e('|终端睡眠|唤醒验证失败error:|$error|stack:|$stack|'); + Toast.error(msg: '暂时无法唤醒终端,请重试'); + } + } + + /// 构建与顾客欢迎副屏一致的品牌栏,并明确后台识别服务不会随主屏锁定暂停。 + Widget _buildBrandHeader(FThemeData theme) { + return [ + Text( + 'PJ', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + ), + ), + ColoredBox(color: XfjDesignTokens.line).tight(width: 1.w, height: 32.h), + Text( + '智慧收银', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + DecoratedBox( + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: XfjDesignTokens.primary.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12.w), + ), + child: Text( + '安全锁屏', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ).padding(horizontal: 14.w, vertical: 8.h), + ), + const Spacer(), + FIcon( + FAssets.icons.circleCheck, + size: 19.sp, + color: XfjDesignTokens.success, + ), + Text( + '人脸与饭卡服务保持运行', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w700, + ), + ), + ] + .toRowSpace(space: 18.w, crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 32.w) + .tight(height: 80.h); + } + + /// 构建睡眠状态、时间与唯一唤醒操作,信息层级按大屏远距离阅读优化。 + Widget _buildSleepSummary( + BuildContext context, + FThemeData theme, + TerminalSleepControl sleepControl, + ) { + return [ + DecoratedBox( + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(24.w), + ), + child: [ + FIcon( + FAssets.icons.moonStar, + size: 20.sp, + color: XfjDesignTokens.primary, + ), + Text( + '主屏已锁定', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w800, + ), + ), + ] + .toRowSpace(space: 10.w, mainAxisSize: MainAxisSize.min) + .padding(horizontal: 18.w, vertical: 11.h), + ), + const _TerminalClock(prominent: true), + Text( + '终端正在休息', + style: theme.typography.xl3.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w900, + ), + ), + Text( + '屏幕已安全锁定,查询、识别与设备服务仍在后台持续运行。', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.muted, + height: 1.55, + ), + ).constrained(maxWidth: 620.w), + FButton.raw( + key: const ValueKey('terminal-sleep-wake-button'), + child: [ + FIcon( + FAssets.icons.lockKeyholeOpen, + size: 22.sp, + color: Colors.white, + ), + Text( + '唤醒终端', + style: theme.typography.lg.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + ] + .toRowSpace( + space: 12.w, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .center(), + onPress: () => unawaited(_requestWake(context, sleepControl)), + ).tight(width: 300.w, height: 72.h), + [ + FIcon( + FAssets.icons.shieldCheck, + size: 18.sp, + color: XfjDesignTokens.muted, + ), + Text( + '唤醒需要验证管理员密码', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toRowSpace(space: 8.w, mainAxisSize: MainAxisSize.min), + ].toColumnSpace( + space: 22.h, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + ); + } + + /// 构建欢迎副屏同源的守护插图,资源异常时保留稳定占位而不暴露底层页面。 + Widget _buildIllustration() { + final radius = BorderRadius.circular(24.w); + return DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: Border.all(color: XfjDesignTokens.line), + borderRadius: radius, + boxShadow: [ + BoxShadow( + color: XfjDesignTokens.ink.withValues(alpha: 0.08), + blurRadius: 44, + offset: Offset(0, 16), + ), + ], + ), + child: ClipRRect( + borderRadius: radius, + child: Image.asset( + _illustrationAsset, + key: const ValueKey('terminal-sleep-illustration'), + fit: BoxFit.cover, + semanticLabel: '安心守护终端的蓝绿色卡通插图', + errorBuilder: (context, error, stackTrace) => ColoredBox( + color: XfjDesignTokens.primarySoft, + child: FIcon( + FAssets.icons.shieldCheck, + size: 72.sp, + color: XfjDesignTokens.primary, + ).center(), + ), + ), + ), + ); + } + + /// 构建不透明锁屏与唯一的密码唤醒入口,拦截对底层页面的所有触摸。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sleepControl = Get.find(); + return PopScope( + canPop: false, + child: Semantics( + container: true, + scopesRoute: true, + explicitChildNodes: true, + label: '终端睡眠中', + child: ColoredBox( + color: XfjDesignTokens.canvas, + child: SafeArea( + child: [ + _buildBrandHeader(theme), + [ + _buildSleepSummary(context, theme, sleepControl) + .alignLeft() + .expanded(flex: 9), + _buildIllustration() + .aspectRatio(aspectRatio: 4 / 3) + .center() + .expanded(flex: 11), + ] + .toRowSpace( + space: 64.w, + crossAxisAlignment: CrossAxisAlignment.stretch, + ) + .padding(horizontal: 64.w, vertical: 32.h) + .expanded(), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + ), + ), + ), + ); + } +} + +/// 顶栏设置与退出使用的固定尺寸图标按钮,完整命令名称通过 Tooltip 和语义暴露。 +class TerminalHeaderIconButton extends StatelessWidget { + /// 创建一个固定 48dp 的顶栏命令按钮。 + const TerminalHeaderIconButton({ + super.key, + required this.tooltip, + required this.onTap, + required this.icon, + this.danger = false, + }); + + /// 悬停和无障碍读屏使用的完整命令名称。 + final String tooltip; + + /// 命令使用的 Forui SVG 图标。 + final SvgAsset icon; + + /// 点击后执行的设置跳转或退出登录命令。 + final VoidCallback onTap; + + /// 是否使用危险操作颜色,退出登录时为真。 + final bool danger; + + /// 构建带 Tooltip、语义和危险态颜色的固定尺寸图标按钮。 + @override + Widget build(BuildContext context) { + final foreground = danger ? XfjDesignTokens.danger : XfjDesignTokens.ink; + return Semantics( + button: true, + label: tooltip, + child: Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: SizedBox.square( + dimension: 48.w, + child: FIcon(icon, size: 21.w, color: foreground).center(), + ).decorated( + color: danger ? const Color(0xFFFFF3F2) : const Color(0xFFF7F8FA), + border: Border.all( + color: danger + ? XfjDesignTokens.danger.withValues(alpha: 0.35) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ), + ), + ); + } +} + +/// 顶栏网络、硬件、性能和管理员状态之间的轻量垂直分隔线。 +class _TerminalHeaderDivider extends StatelessWidget { + /// 创建不参与交互的固定高度分隔线。 + const _TerminalHeaderDivider(); + + /// 构建使用全局边框色的单像素分隔线。 + @override + Widget build(BuildContext context) => Container( + width: 1, + height: 28.h, + margin: EdgeInsets.symmetric(horizontal: 4.w), + color: XfjDesignTokens.line, + ); +} + +/// 收银页顶部只展示当前设备模式,不在页面内动态切换两套硬件 Controller。 +class TerminalModeBadge extends StatelessWidget { + const TerminalModeBadge({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sizeScale = XfjDesignTokens.buttonSizeScale; + return Container( + height: 44.h, + padding: EdgeInsets.symmetric(horizontal: 18.w * sizeScale), + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all(color: XfjDesignTokens.primary), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Text( + label, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +/// 顶栏与睡眠层共用的本地时间组件。 +class _TerminalClock extends StatefulWidget { + /// 创建顶栏或睡眠页时钟;醒目模式会增加日期,但不改变刷新频率。 + const _TerminalClock({this.prominent = false}); + + /// 是否使用睡眠页的大号时间与日期组合样式。 + final bool prominent; + + /// 创建负责按分钟边界附近刷新时间的状态对象。 + @override + State<_TerminalClock> createState() => _TerminalClockState(); +} + +/// 终端时钟的当前时间与周期刷新资源。 +class _TerminalClockState extends State<_TerminalClock> { + /// 最近一次用于渲染的本地时间。 + late DateTime _now; + + /// 每 30 秒校准一次显示的计时器,页面销毁时必须取消。 + Timer? _timer; + + /// 初始化当前时间并启动低频刷新。 + @override + void initState() { + super.initState(); + _now = DateTime.now(); + _timer = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() => _now = DateTime.now()); + }); + } + + /// 释放时钟计时器,避免终端页面销毁后继续触发刷新。 + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + /// 将 Dart 的周序号转换为终端中文日期文案,不依赖额外国际化包。 + String _weekdayLabel() => switch (_now.weekday) { + DateTime.monday => '星期一', + DateTime.tuesday => '星期二', + DateTime.wednesday => '星期三', + DateTime.thursday => '星期四', + DateTime.friday => '星期五', + DateTime.saturday => '星期六', + DateTime.sunday => '星期日', + _ => '', + }; + + /// 按使用场景构建紧凑顶栏时间或醒目睡眠时间。 + @override + Widget build(BuildContext context) { + final hour = _now.hour.toString().padLeft(2, '0'); + final minute = _now.minute.toString().padLeft(2, '0'); + final theme = FTheme.of(context); + final time = Text( + '$hour:$minute', + key: widget.prominent ? const ValueKey('terminal-sleep-clock') : null, + style: (widget.prominent ? theme.typography.xl6 : theme.typography.lg) + .copyWith( + color: XfjDesignTokens.ink, + fontWeight: widget.prominent ? FontWeight.w700 : null, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + if (!widget.prominent) return time; + return [ + time, + Text( + '${_now.month}月${_now.day}日 ${_weekdayLabel()}', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + ].toColumnSpace( + space: 8.h, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + ); + } +} diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart index 223f913..d94e593 100644 --- a/lib/common/widgets/index.dart +++ b/lib/common/widgets/index.dart @@ -20,6 +20,7 @@ export './app/app_title.dart'; export './app/app_wallet_selector.dart'; export './app/app_dialog.dart'; export './app/terminal_toolbar.dart'; +export './app/terminal_shell.dart'; export './card/card_common.dart'; diff --git a/lib/common/widgets/setting/setting_password_dialog.dart b/lib/common/widgets/setting/setting_password_dialog.dart index 6b615c6..4d91859 100644 --- a/lib/common/widgets/setting/setting_password_dialog.dart +++ b/lib/common/widgets/setting/setting_password_dialog.dart @@ -1,6 +1,7 @@ import 'package:common_utils/common_utils.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; @@ -55,3 +56,197 @@ Future settingPasswordDialog(BuildContext context) async { ); return flag ?? false; } + +/// 终端顶栏管理操作(进入设置、退出登录、唤醒终端)共用的二次验证。 +/// +/// 校验来源与主菜单的设置入口一致:当前登录账号的登录密码。 +Future terminalAdminPasswordDialog( + BuildContext context, { + required String actionLabel, +}) async { + final appUserSetting = await AppSettingTool.getUserSetting(); + final result = await showAppDialog( + context: context, + barrierDismissible: false, + canPop: false, + builder: (_) => _TerminalAdminPasswordDialog( + actionLabel: actionLabel, + expectedPassword: appUserSetting.password, + ), + ); + return result ?? false; +} + +class _TerminalAdminPasswordDialog extends StatefulWidget { + const _TerminalAdminPasswordDialog({ + required this.actionLabel, + required this.expectedPassword, + }); + + final String actionLabel; + + final String? expectedPassword; + + @override + State<_TerminalAdminPasswordDialog> createState() => + _TerminalAdminPasswordDialogState(); +} + +class _TerminalAdminPasswordDialogState + extends State<_TerminalAdminPasswordDialog> { + late final TextEditingController _controller; + bool _obscureText = true; + bool _closing = false; + String? _errorText; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + if (_closing) return; + final password = _controller.text.trim(); + if (password.isEmpty) { + setState(() => _errorText = '请输入管理密码'); + return; + } + if (password != widget.expectedPassword) { + setState(() => _errorText = '管理密码不正确,请重新输入'); + HapticFeedback.mediumImpact(); + return; + } + _close(true); + } + + void _close(bool result) { + if (_closing) return; + _closing = true; + // 先释放输入法焦点,再由路由守卫等待弹窗退场后切换页面,避免 Overlay 与根路由同时拆除。 + FocusManager.instance.primaryFocus?.unfocus(); + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return AppDialog( + title: Text( + '终端操作验证', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + height: 1, + ), + ), + leading: Container( + width: 48.w, + height: 48.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: FIcon( + FAssets.icons.shieldCheck, + color: XfjDesignTokens.primary, + size: 24.sp, + ), + ), + width: 680.w, + maxWidth: 680.w, + contentPadding: EdgeInsets.symmetric(horizontal: 28.w, vertical: 36.h), + onClose: () => _close(false), + semanticLabel: '${widget.actionLabel}管理密码验证弹框', + content: SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.actionLabel}前需要验证管理密码。', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 16.h), + FTextField.password( + controller: _controller, + autofocus: true, + label: const Text('管理密码'), + hint: '请输入管理密码', + maxLines: 1, + obscureText: _obscureText, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + onChange: (_) { + if (_errorText != null) setState(() => _errorText = null); + }, + onSubmit: (_) => _submit(), + suffixBuilder: (_, __, ___) => InkWell( + onTap: () => setState(() => _obscureText = !_obscureText), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: SizedBox( + width: 48.w, + height: 48.h, + child: FIcon( + _obscureText ? FAssets.icons.eye : FAssets.icons.eyeOff, + color: XfjDesignTokens.muted, + size: 22.sp, + ).center(), + ), + ), + ), + if (_errorText != null) ...[ + SizedBox(height: 8.h), + Row( + children: [ + FIcon( + FAssets.icons.circleAlert, + color: XfjDesignTokens.danger, + size: 18.sp, + ), + SizedBox(width: 8.w), + Text( + _errorText!, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.danger, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ], + ), + ), + actions: [ + FButton( + style: FButtonStyle.outline, + label: Text('取消', style: theme.typography.base), + onPress: () => _close(false), + ), + FButton( + label: Text( + '验证并继续', + style: theme.typography.base.copyWith(color: Colors.white), + ), + onPress: _submit, + ), + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 20f88e0..5f405ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,15 @@ Future checkSubScreen() async { ProductHourTypeControl(), permanent: true, ); + // 管理操作临时解锁会话,供终端顶栏安全锁倒计时读取。 + Get.put(TerminalAdminSessionControl(), permanent: true); + // 睡眠状态需要跨终端工作区路由常驻,但不会进入副屏 Engine。 + Get.put(TerminalSleepControl(), permanent: true); + // 终端顶栏五设备逐项显隐偏好,常驻供外壳读取。 + final terminalDeviceStatus = Get.put( + TerminalDeviceStatusControl(), + permanent: true, + ); // 人脸识别会话服务常驻,供收银识别流程和空闲判定读取。 Get.put(FaceRecognitionSessionService(), permanent: true); runApp(MainApp()); @@ -34,6 +43,7 @@ Future checkSubScreen() async { await app.initAsync(); await appTheme.initAsync(); await productHourType.initAsync(); + await terminalDeviceStatus.initAsync(); }); break; case "subMain": diff --git a/lib/pages/canteen_order_total/view.dart b/lib/pages/canteen_order_total/view.dart index 582a1be..a151d37 100644 --- a/lib/pages/canteen_order_total/view.dart +++ b/lib/pages/canteen_order_total/view.dart @@ -154,11 +154,11 @@ class CanteenOrderTotalPage extends GetView { @override Widget build(BuildContext context) { return GetBuilder( - init: CanteenOrderTotalController(), id: "canteen_order_total", builder: (_) { return FScaffold( - header: appDefaultNav(context, title: "交易统计"), + // 标题与返回入口由终端外壳顶栏统一提供。 + header: FHeader(title: SizedBox.shrink()), content: _buildView(context).marginOnly(top: 20.h), resizeToAvoidBottomInset: false, ); diff --git a/lib/pages/canteen_product/controller.dart b/lib/pages/canteen_product/controller.dart index 1a7fa4e..e3a5def 100644 --- a/lib/pages/canteen_product/controller.dart +++ b/lib/pages/canteen_product/controller.dart @@ -67,15 +67,18 @@ class CanteenProductController extends GetxController with OrderMixin { hourTypeC.value = {hourType.value.code}; sysType = SysType.CANTEEN; _listenPayTypeSelectCallback(); - // 更新分类和商品 + // 进入点餐页时在线只刷新当天菜谱;接口失败或离线仍读取本地快照,不能阻断点餐。 + // 分类和未来菜谱由定时任务及 MQTT 消息维护。 + if (AppNetworkTool.isAutoNetwork()) { + await SyncProductTool.syncTodayCanteenProduct(); + } try { - await SyncProductTool.updateDatabase(); await _getMchRule(); } catch (e, stack) { - logger.e("|$_tag|初始化资源失败:error|$e|stack|$stack"); + logger.e("|$_tag|初始化资源失败:error:$e|stack:$stack|"); Toast.error(msg: "初始化资源失败,请检查网络情况"); EasyLoading.dismiss(); - Get.offAndToNamed(RouteNames.panel); + TerminalNavigationTool.backToPanel(); } // 获取分类和商品 await getClassify(); diff --git a/lib/pages/canteen_product/view.dart b/lib/pages/canteen_product/view.dart index f6d346e..926c459 100644 --- a/lib/pages/canteen_product/view.dart +++ b/lib/pages/canteen_product/view.dart @@ -13,15 +13,12 @@ class CanteenProductPage extends GetView { const CanteenProductPage({super.key}); // 头部 - Widget _buildHeader(double headerHeight) { + Widget _buildHeader() { final context = Get.context!; final theme = FTheme.of(context); + // 标题与返回入口由终端外壳顶栏统一提供,这里只保留分类与餐段工具条, + // 紧贴外壳顶栏排布,不再为系统状态栏让位预留 padding。 return [ - appBack( - context, - height: headerHeight, - onTap: () => Get.offAndToNamed(RouteNames.panel), - ), Obx( () => orderRowClassify( context, @@ -32,7 +29,11 @@ class CanteenProductPage extends GetView { ).scrollable(scrollDirection: Axis.horizontal).expanded(), appRadioHourTypeDialog(context, controller.hourTypeC).width(300.w), SizedBox(width: 15.w), - ].toRow().decorated( + ] + .toRow() + .paddingVertical(8.h) + .paddingHorizontal(15.w) + .decorated( color: theme.colorScheme.background, boxShadow: [ BoxShadow( @@ -275,16 +276,11 @@ class CanteenProductPage extends GetView { @override Widget build(BuildContext context) { - final height = 90; return GetBuilder( - init: Get.find(), id: "canteen_product", builder: (_) { return FScaffold( - header: _buildHeader(height.h) - .height(height.h) - .paddingTop(30.h) - .paddingBottom(15.h), + header: _buildHeader(), content: _buildView(), contentPad: true, resizeToAvoidBottomInset: false, diff --git a/lib/pages/canteen_reserve/view.dart b/lib/pages/canteen_reserve/view.dart index a8bf6d0..8caf58e 100644 --- a/lib/pages/canteen_reserve/view.dart +++ b/lib/pages/canteen_reserve/view.dart @@ -224,11 +224,11 @@ class CanteenReservePage extends GetView { @override Widget build(BuildContext context) { return GetBuilder( - init: CanteenReserveController(), id: "canteen_reserve", builder: (_) { return FScaffold( - header: appDefaultNav(context, title: "预定"), + // 标题与返回入口由终端外壳顶栏统一提供。 + header: FHeader(title: SizedBox.shrink()), content: _buildView(context), resizeToAvoidBottomInset: false, ); diff --git a/lib/pages/card_active/view.dart b/lib/pages/card_active/view.dart index af0ab42..c24c80e 100644 --- a/lib/pages/card_active/view.dart +++ b/lib/pages/card_active/view.dart @@ -12,15 +12,12 @@ class CardActivePage extends GetView { const CardActivePage({super.key}); // 条件 - Widget _buildHeader(double height) { + Widget _buildHeader() { final context = Get.context!; final theme = FTheme.of(context); + // 标题与返回入口由终端外壳顶栏统一提供,这里只保留卡号搜索工具条, + // 紧贴外壳顶栏排布,不再为系统状态栏让位预留 padding。 return [ - appBack( - context, - height: height, - onTap: () => Get.offAndToNamed(RouteNames.panel), - ), const Spacer(), FTextField( controller: controller.carNumberC, @@ -219,15 +216,15 @@ class CardActivePage extends GetView { @override Widget build(BuildContext context) { - final height = 90; return GetBuilder( - init: CardActiveController(), id: "card_active", builder: (_) { return FScaffold( - header: _buildHeader(height.h) - .height(height.h) - .padding(vertical: 30.h, horizontal: 10.w), + header: _buildHeader().paddingOnly( + left: 10.w, + right: 10.w, + top: 8.h, + ), content: _buildView(), resizeToAvoidBottomInset: false, ); diff --git a/lib/pages/index.dart b/lib/pages/index.dart index aeac3b9..e574688 100644 --- a/lib/pages/index.dart +++ b/lib/pages/index.dart @@ -12,3 +12,4 @@ export './panel/index.dart'; export './setting/index.dart'; export './supermarket_order/index.dart'; export './take_food/index.dart'; +export './terminal/view.dart'; diff --git a/lib/pages/login/view.dart b/lib/pages/login/view.dart index 9a65e73..8f5755f 100644 --- a/lib/pages/login/view.dart +++ b/lib/pages/login/view.dart @@ -86,7 +86,6 @@ class LoginPage extends GetView { @override Widget build(BuildContext context) { return GetBuilder( - init: LoginController(), id: "login", builder: (_) { return FScaffold( diff --git a/lib/pages/order_history/controller.dart b/lib/pages/order_history/controller.dart index f6a0dd7..d6d466b 100644 --- a/lib/pages/order_history/controller.dart +++ b/lib/pages/order_history/controller.dart @@ -259,9 +259,9 @@ class OrderHistoryController extends GetxController { ); } - /// 跳转到订单详情 + /// 跳转到订单详情(压入终端内层工作区栈,返回时回到交易查询) void doDetail(OrderEntity v) { - Get.toNamed( + TerminalNavigationTool.pushWorkspace( RouteNames.orderHistoryDetail, arguments: {'id': v.id}, ); diff --git a/lib/pages/order_history/view.dart b/lib/pages/order_history/view.dart index 7a5a505..1f1c1d2 100644 --- a/lib/pages/order_history/view.dart +++ b/lib/pages/order_history/view.dart @@ -258,7 +258,6 @@ class OrderHistoryPage extends GetView { @override Widget build(BuildContext context) { return GetBuilder( - init: OrderHistoryController(), id: "order_history", builder: (_) { return FScaffold( diff --git a/lib/pages/order_history_detail/controller.dart b/lib/pages/order_history_detail/controller.dart index 943434a..a8f3a4d 100644 --- a/lib/pages/order_history_detail/controller.dart +++ b/lib/pages/order_history_detail/controller.dart @@ -16,13 +16,21 @@ class OrderHistoryDetailController extends GetxController { void onTap() {} + bool _argumentsLoaded = false; + + /// 详情页挂在终端内层 Navigator 上,路由参数只能从 RouteSettings 读取; + /// 由页面首次构建时注入,不能依赖 GetX 根路由的 Get.arguments。 + void loadArguments(Map arguments) { + if (_argumentsLoaded) return; + _argumentsLoaded = true; + detailId = arguments['id'] as int; + getOrderDetail(); + getShopOrderList(); + } + @override void onInit() async { super.onInit(); - final arguments = Get.arguments as Map; - detailId = arguments['id'] as int; - await getOrderDetail(); - await getShopOrderList(); } @override diff --git a/lib/pages/order_history_detail/view.dart b/lib/pages/order_history_detail/view.dart index a117008..644a8b5 100644 --- a/lib/pages/order_history_detail/view.dart +++ b/lib/pages/order_history_detail/view.dart @@ -217,15 +217,22 @@ class OrderHistoryDetailPage extends GetView { Widget build(BuildContext context) { final theme = FTheme.of(context); final height = context.height; + // 内层路由的参数挂在 RouteSettings 上,首次构建时交给 Controller 拉取详情。 + final routeArguments = ModalRoute.of(context)?.settings.arguments; + if (routeArguments is Map) { + controller.loadArguments(routeArguments); + } return GetBuilder( - init: OrderHistoryDetailController(), id: "order_history_detail", builder: (_) { return FScaffold( header: FHeader.nested( title: Text("订单详情"), prefixActions: [ - FHeaderAction.back(onPress: Get.back), + // 详情压在终端内层 Navigator 之上,返回必须弹出内层栈而非根路由。 + FHeaderAction.back( + onPress: () => Navigator.of(context).maybePop(), + ), ], style: theme.headerStyle.nestedStyle.copyWith( titleTextStyle: theme.typography.lg, diff --git a/lib/pages/panel/controller.dart b/lib/pages/panel/controller.dart index d6d5530..5cb1468 100644 --- a/lib/pages/panel/controller.dart +++ b/lib/pages/panel/controller.dart @@ -87,14 +87,18 @@ class PanelController extends GetxController { Future doBtn(PanelType type) async { final isNetwork = AppNetworkTool.isAutoNetwork(); + // 业务页面统一装载在 /terminal 一级路由的内层 Navigator 中, + // 通过 arguments 告知终端外壳首次应显示的二级工作区。 + void openTerminal(String route) => + Get.toNamed(RouteNames.terminal, arguments: route); switch (type) { case PanelType.supermarketOrder: if (!await _checkIsBind()) return; - Get.toNamed(RouteNames.supermarketProduct); + openTerminal(RouteNames.supermarketProduct); break; case PanelType.canteenOrder: if (!await _checkIsBind()) return; - Get.toNamed(RouteNames.canteenProduct); + openTerminal(RouteNames.canteenProduct); break; case PanelType.canteenReserve: if (!await _checkIsBind()) return; @@ -102,7 +106,7 @@ class PanelController extends GetxController { Toast.error(msg: "请先连接网络"); return; } - Get.toNamed(RouteNames.canteenReserve); + openTerminal(RouteNames.canteenReserve); break; case PanelType.takeFood: if (!await _checkIsBind()) return; @@ -110,11 +114,11 @@ class PanelController extends GetxController { Toast.error(msg: "请先连接网络"); return; } - Get.toNamed(RouteNames.takeFood); + openTerminal(RouteNames.takeFood); break; case PanelType.orderHistory: if (!await _checkIsBind()) return; - Get.toNamed(RouteNames.orderHistory); + openTerminal(RouteNames.orderHistory); break; case PanelType.canteenOrderTotal: if (!await _checkIsBind()) return; @@ -122,18 +126,18 @@ class PanelController extends GetxController { Toast.error(msg: "请先连接网络"); return; } - Get.toNamed(RouteNames.canteenOrderTotal); + openTerminal(RouteNames.canteenOrderTotal); break; case PanelType.setting: if (AppSysTool.isProd()) { final flag = await settingPasswordDialog(Get.context!); if (!flag) return; } - Get.toNamed(RouteNames.setting); + openTerminal(RouteNames.setting); break; case PanelType.cardActive: if (!await _checkIsBind()) return; - Get.toNamed(RouteNames.cardActive); + openTerminal(RouteNames.cardActive); break; case PanelType.logout: final isNetwork = AppNetworkTool.isAutoNetwork(); diff --git a/lib/pages/panel/view.dart b/lib/pages/panel/view.dart index baff0ae..f24f144 100644 --- a/lib/pages/panel/view.dart +++ b/lib/pages/panel/view.dart @@ -50,7 +50,6 @@ class PanelPage extends GetView { @override Widget build(BuildContext context) { return GetBuilder( - init: PanelController(), id: "panel", builder: (_) { return FScaffold( diff --git a/lib/pages/setting/controller.dart b/lib/pages/setting/controller.dart index ae9c439..e31966e 100644 --- a/lib/pages/setting/controller.dart +++ b/lib/pages/setting/controller.dart @@ -29,6 +29,12 @@ class SettingController extends GetxController { // 默认付款方式 var defaultGlobalPayTableC = FRadioSelectGroupController(); + /// 商品餐段时间配置;餐段校验弹框和自动切换按该时间范围判定。 + final productMealPeriodSchedule = ProductMealPeriodSchedule.defaults.obs; + + /// 餐段配置正在保存时禁用重复提交并展示按钮状态。 + final productMealPeriodSaving = false.obs; + _initData() { update(["setting"]); } @@ -54,20 +60,30 @@ class SettingController extends GetxController { title: "支付设置", type: SettingMenuType.paySetting, ), + SettingMenu( + prefixIcon: FAssets.icons.utensils, + title: "餐段时间配置", + type: SettingMenuType.mealPeriodSetting, + ), SettingMenu( prefixIcon: FAssets.icons.scanFace, title: "人脸管理", type: SettingMenuType.faceSetting, ), + SettingMenu( + prefixIcon: FAssets.icons.settings2, + title: "人脸设置", + type: SettingMenuType.facePluginSetting, + ), SettingMenu( prefixIcon: FAssets.icons.shoppingCart, title: "商品管理", type: SettingMenuType.productSetting, ), SettingMenu( - prefixIcon: FAssets.icons.settings2, - title: "人脸设置", - type: SettingMenuType.facePluginSetting, + prefixIcon: FAssets.icons.logs, + title: "日志预览", + type: SettingMenuType.logPreview, ), SettingMenu( prefixIcon: FAssets.icons.rocket, @@ -346,6 +362,8 @@ class SettingController extends GetxController { Future getSetting() async { sysSetting.value = await AppSettingTool.getSysSetting(); + productMealPeriodSchedule.value = + await AppSettingTool.getProductMealPeriodSchedule(); if (sysSetting.value!.globalFontSize != null) { globalFontSizeC.value = {sysSetting.value!.globalFontSize!.code}; } @@ -368,4 +386,28 @@ class SettingController extends GetxController { } return true; } + + /// 校验并保存餐段时间;空档允许保留,相邻或交叉范围由强类型模型统一处理。 + 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; + } + } } diff --git a/lib/pages/setting/face_manage_controller.dart b/lib/pages/setting/face_manage_controller.dart new file mode 100644 index 0000000..aca391e --- /dev/null +++ b/lib/pages/setting/face_manage_controller.dart @@ -0,0 +1,464 @@ +import 'dart:async'; + +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 人脸管理内容区的两个一级视图。 +enum FaceManageTab { + /// 全部本地人脸及既有同步维护能力。 + all, + + /// 已检测且分数低于当前在库阈值的人脸。 + lowQuality, +} + +/// 设置页人脸管理控制器,只负责列表交互和将人工同步请求提交到应用级队列。 +class FaceManageController extends GetxController { + /// 应用级数据库和事件总线持有者,由根 Binding 统一注册。 + final appC = Get.find(); + + /// 当前终端设置,初始化后用于确定系统类型和设备绑定状态。 + late SysSetting sysSetting; + + /// 当前终端业务类型,决定搜索框允许的输入方式。 + Rx sysType = SysType.CANTEEN.obs; + + /// 当前分页展示的人脸本地记录。 + RxList faceList = RxList(); + + /// 当前列表页码,从 1 开始。 + RxInt currentPage = RxInt(1); + + /// 当前搜索条件命中的记录总数,供分页组件计算页数。 + RxInt totalPage = RxInt(0); + + /// 单页展示数量,沿用现有人脸管理网格密度。 + RxInt pageSize = RxInt(15); + + /// 列表查询是否进行中,仅驱动列表区域加载状态。 + RxBool isLoading = false.obs; + + /// 最近一次列表查询失败的安全提示,不包含数据库或文件路径。 + RxString errorMessage = ''.obs; + + /// 质量检测或服务端删除的可恢复提示,不替代列表内容。 + final qualityOperationMessage = ''.obs; + + /// 当前选中的内容 Tab。 + final selectedTab = FaceManageTab.all.obs; + + /// Android 人脸设置中的在库质量阈值。 + final libraryQualityThreshold = 0.60.obs; + + /// 低质量、待检测和检测失败的实时统计。 + final lowQualityCount = 0.obs; + final pendingQualityCount = 0.obs; + final failedQualityCount = 0.obs; + + /// 存量质量检测的最新原生进度。 + final qualityAuditProgress = Rxn(); + + /// 同步、检测和服务端删除状态,用于互斥及按钮禁用。 + 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() { + super.onInit(); + unawaited(_initialize()); + } + + /// 读取终端设置后加载列表;初始化失败交由列表状态统一展示。 + Future _initialize() async { + sysSetting = await AppSettingTool.getSysSetting(); + sysType.value = sysSetting.sysType; + _listenMqCallback(); + qualityAuditListen = FaceDiscernPlugs.qualityAuditStream.listen( + _handleQualityAuditProgress, + onError: (Object error, StackTrace stack) { + logger.w('|人脸管理|质量进度监听失败error:$error|stack:$stack|'); + }, + ); + await refreshQualityThreshold(); + await getFaceList(); + } + + /// 释放 MQTT 完成事件和输入控制器,防止退出设置页后继续刷新。 + @override + void onClose() { + mqUpdateListen?.cancel(); + qualityAuditListen?.cancel(); + usernameC.dispose(); + super.onClose(); + } + + /// 按当前姓名和页码读取本地人脸列表,可选显示旧页面使用的查询遮罩。 + Future getFaceList({bool isLoading = false}) async { + this.isLoading.value = true; + errorMessage.value = ''; + if (isLoading) { + EasyLoading.show( + status: '查询中...', + maskType: EasyLoadingMaskType.black, + ); + } + try { + final faceDao = appC.dataBase.getFaceDao; + String userName = usernameC.text; + if (ObjectUtil.isEmpty(userName)) userName = "-1"; + final lowQuality = selectedTab.value == FaceManageTab.lowQuality; + final findCount = lowQuality + ? await faceDao.findLowQualityCount( + userName, + libraryQualityThreshold.value, + ) + : await faceDao.findCount(userName); + totalPage.value = findCount ?? 0; + final list = lowQuality + ? await faceDao.findLowQuality( + userName, + libraryQualityThreshold.value, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ) + : await faceDao.findAll( + userName, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); + faceList.value = list; + await _refreshQualityCounts(faceDao); + } catch (e, stack) { + errorMessage.value = '人脸数据加载失败,请稍后重试'; + logger.e('|人脸管理|加载失败error:$e|stack:$stack|'); + } finally { + this.isLoading.value = false; + if (isLoading) { + Future.delayed(const Duration(seconds: 1), () { + EasyLoading.dismiss(); + }); + } + } + } + + /// 确认后将本部门快照或非生产全租户调试请求提交到统一人脸任务队列。 + /// + /// 队列会先避开支付、人脸会话和业务弹框;只有任务已经实际开始时才显示遮罩, + /// 避免遮罩本身被空闲检测识别为业务占用而导致任务无法启动。 + Future asyncFace({bool isAll = false}) async { + if (operationInProgress) return; + final isNetwork = AppNetworkTool.isAutoNetworkMsg(); + if (!isNetwork) return; + final setting = await AppSettingTool.getSysSetting(); + if (ObjectUtil.isEmpty(setting.merchantDeptId)) { + Toast.error(msg: "请先绑定设备"); + return; + } + if (isAll && AppSysTool.isProd()) { + Toast.error(msg: '生产环境仅允许同步本部门人脸'); + return; + } + if (AppSysTool.isProd()) { + final isActive = await AppFaceTool.isActiveSdk(); + if (!isActive) { + Toast.error(msg: "人脸sdk激活失败"); + return; + } + } + final isSure = await appMessageDialog( + Get.context!, + isAll ? '是否同步全租户人脸?此能力仅用于非生产调试,过程可能较长' : '是否同步本部门及子部门人脸?过程可能较长', + ); + if (!isSure) return; + final scheduler = SyncCheckTool.instance; + isSyncing.value = true; + final operation = scheduler.trigger( + ScheduledTaskType.departmentFaceSync, + debugAllFaces: isAll, + triggerType: ScheduledTaskTriggerType.manual, + ); + await Future.delayed(Duration.zero); + var overlayVisible = false; + if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync)) { + overlayVisible = true; + EasyLoading.show( + status: '同步中...', + maskType: EasyLoadingMaskType.black, + ); + } + try { + await operation; + if (scheduler.pendingTasks + .contains(ScheduledTaskType.departmentFaceSync)) { + Toast.success(msg: '人脸同步已排队,将在业务空闲后执行'); + return; + } + currentPage.value = 1; + await getFaceList(); + final record = scheduler.records[ScheduledTaskType.departmentFaceSync]; + if (record?.success == true) { + Toast.success(msg: record?.message ?? '人脸同步完成'); + } else { + Toast.error(msg: record?.message ?? '人脸同步失败,请稍后重试'); + } + } catch (e, stack) { + Toast.error(msg: '同步人脸失败,请稍后重试'); + logger.e('|人脸管理|同步失败error:$e|stack:$stack|'); + } finally { + currentPage.value = 1; + isSyncing.value = false; + if (overlayVisible) EasyLoading.dismiss(); + } + } + + /// 重新读取 Android 人脸配置并按新阈值立即重查低质量分类。 + Future 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 || + SyncCheckTool.instance.runningTasks.contains( + ScheduledTaskType.departmentFaceSync, + ); + + /// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。 + Future delAllFace() async { + final isSure = await appMessageDialog( + Get.context!, + "是否删除全部人脸", + ); + if (!isSure) return; + EasyLoading.show( + status: '删除中...', + maskType: EasyLoadingMaskType.black, + ); + try { + final faceDao = appC.dataBase.getFaceDao; + await faceDao.delAll(); + await AppFaceTool.removeFace(sampleId: '-1'); + await getFaceList(); + Toast.success(msg: "删除成功"); + } catch (e, stack) { + Toast.error(msg: "删除全部人脸失败"); + logger.e('|人脸管理|删除全部失败error:$e|stack:$stack|'); + } finally { + EasyLoading.dismiss(); + } + } + + /// 监听成功完成的 FACE 增量同步,并仅刷新当前设置页本地列表。 + void _listenMqCallback() { + mqUpdateListen = + appC.mqUpdateEventBus.on().listen((event) async { + switch (event) { + case MqtActionEnum.FACE: + await getFaceList(isLoading: true); + break; + default: + } + }); + } + + /// 按指定页码执行姓名搜索,并在查询前收起软键盘。 + Future doPage({int? page}) async { + if (page != null) currentPage.value = page; + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + await getFaceList(isLoading: true); + } + + /// 清空姓名条件、回到第一页并重新加载列表。 + Future doReset() async { + currentPage.value = 1; + usernameC.clear(); + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + await getFaceList(isLoading: true); + } + + /// 删除一条本地人脸及其虹软内存特征,不修改服务端人脸真源。 + Future deleteFace(FaceEntity v) async { + final isSure = await appMessageDialog(Get.context!, "是否删除人脸"); + if (!isSure) return; + EasyLoading.show( + status: '删除中...', + maskType: EasyLoadingMaskType.black, + ); + try { + final deleted = await SyncFaceTool.deleteLocalFace(v); + if (!deleted) throw StateError('本地人脸删除失败并已尝试恢复'); + await getFaceList(); + Toast.success(msg: "删除成功"); + } catch (e, stack) { + Toast.error(msg: "删除人脸失败"); + logger.e('|人脸管理|删除失败error:$e|stack:$stack|'); + } finally { + EasyLoading.dismiss(); + } + } +} diff --git a/lib/pages/setting/index.dart b/lib/pages/setting/index.dart index 1a6f668..ea81a18 100644 --- a/lib/pages/setting/index.dart +++ b/lib/pages/setting/index.dart @@ -1,6 +1,7 @@ library; export './controller.dart'; +export './face_manage_controller.dart'; export './face_setting_controller.dart'; export './view.dart'; export './sync_logs/controller.dart'; diff --git a/lib/pages/setting/sync_logs/controller.dart b/lib/pages/setting/sync_logs/controller.dart index 728c20b..35ec423 100644 --- a/lib/pages/setting/sync_logs/controller.dart +++ b/lib/pages/setting/sync_logs/controller.dart @@ -9,11 +9,23 @@ class ScheduledTaskLogsController extends GetxController { /// 日志表格默认每页数量,与 1080p 设置页可用高度和现有交易记录密度保持一致。 static const int _defaultPageSize = 10; - /// 使用路由参数中的稳定任务类型创建日志控制器。 - ScheduledTaskLogsController(this.taskType); + /// 日志页挂在终端内层 Navigator 上,任务类型由页面首次构建时 + /// 从 RouteSettings 注入,不能依赖 GetX 根路由的 Get.arguments。 + ScheduledTaskLogsController(); /// 当前页面固定查看的任务类型,生命周期内不会变化。 - final ScheduledTaskType taskType; + late final ScheduledTaskType taskType; + + /// 是否已注入路由参数,防止重复构建触发重复查询。 + bool _argumentsLoaded = false; + + /// 注入路由参数并执行首次查询;重复调用会被忽略。 + void loadArguments(ScheduledTaskType type) { + if (_argumentsLoaded) return; + _argumentsLoaded = true; + taskType = type; + unawaited(loadLogs()); + } /// 当前筛选条件和页码对应的结构化执行日志,按数据库主键倒序排列。 final RxList logs = [].obs; @@ -40,11 +52,10 @@ class ScheduledTaskLogsController extends GetxController { /// 查询序号用于丢弃快速切换筛选时晚返回的旧结果,避免列表与筛选条件错位。 int _requestSerial = 0; - /// 控制器创建后立即加载当前任务的全部来源日志。 + /// 首次查询由 loadArguments 注入任务类型后触发,控制器自身不再主动加载。 @override void onInit() { super.onInit(); - unawaited(loadLogs()); } /// 选择新的依赖来源并重新查询;重复点击当前筛选不会制造额外数据库请求。 diff --git a/lib/pages/setting/sync_logs/view.dart b/lib/pages/setting/sync_logs/view.dart index 230481d..77722cb 100644 --- a/lib/pages/setting/sync_logs/view.dart +++ b/lib/pages/setting/sync_logs/view.dart @@ -19,7 +19,13 @@ class ScheduledTaskLogsPage extends GetView { /// 构建页面工具栏、来源筛选、日志表格和分页栏,各响应区域分别局部刷新。 @override - Widget build(BuildContext context) => [ + Widget build(BuildContext context) { + // 内层路由的参数挂在 RouteSettings 上,首次构建时交给 Controller 首查。 + final routeArguments = ModalRoute.of(context)?.settings.arguments; + if (routeArguments is ScheduledTaskType) { + controller.loadArguments(routeArguments); + } + return [ _buildToolbar(context), SizedBox(height: 10.h), _buildSourceFilters(context), @@ -37,6 +43,7 @@ class ScheduledTaskLogsPage extends GetView { ), ), ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + } /// 构建返回入口、任务与依赖名称、当前条数以及手动刷新按钮。 Widget _buildToolbar(BuildContext context) { @@ -45,7 +52,8 @@ class ScheduledTaskLogsPage extends GetView { Tooltip( message: '返回同步设置', child: InkWell( - onTap: SettingNavigation.backToSync, + // 日志页压在终端内层 Navigator 之上,返回必须弹出内层栈而非根路由。 + onTap: () => Navigator.of(context).maybePop(), borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), child: FIcon( FAssets.icons.arrowLeft, diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart index 941219e..ce8fd96 100644 --- a/lib/pages/setting/view.dart +++ b/lib/pages/setting/view.dart @@ -28,7 +28,7 @@ class SettingPage extends GetView { Obx(() => _buildDetail(context, controller.cuurentMenuType.value)) .marginSymmetric(horizontal: 10.w) .expanded(), - ].toRow().height(double.infinity); + ].toRow(crossAxisAlignment: CrossAxisAlignment.stretch).height(double.infinity); } Widget _buildMenu() { @@ -113,11 +113,15 @@ class SettingPage extends GetView { return const SizedBox.shrink(); } return settingFace(context); + case SettingMenuType.mealPeriodSetting: + return const SettingMealPeriods(); case SettingMenuType.productSetting: if (!isBand) { return const SizedBox.shrink(); } return settingProduct(context); + case SettingMenuType.logPreview: + return const SettingLogPreview(); case SettingMenuType.facePluginSetting: if (!isBand) { return const SizedBox.shrink(); @@ -136,11 +140,11 @@ class SettingPage extends GetView { @override Widget build(BuildContext context) { return GetBuilder( - init: SettingController(), id: "setting", builder: (_) { return FScaffold( - header: appDefaultNav(context, title: "设置"), + // 标题与返回入口由终端外壳顶栏统一提供;设置模块自身的分区菜单保留在内容区。 + header: FHeader(title: SizedBox.shrink()), content: _buildView().marginOnly(top: 20.h), contentPad: true, resizeToAvoidBottomInset: false, diff --git a/lib/pages/setting/widgets/index.dart b/lib/pages/setting/widgets/index.dart index 3b8686a..3627140 100644 --- a/lib/pages/setting/widgets/index.dart +++ b/lib/pages/setting/widgets/index.dart @@ -4,6 +4,8 @@ export './setting_backup.dart'; export './setting_baseInfo.dart'; export './setting_face.dart'; export './setting_face_plugin.dart'; +export './setting_log_preview.dart'; +export './setting_meal_periods.dart'; export './setting_pay.dart'; export './setting_product.dart'; export './setting_sync.dart'; diff --git a/lib/pages/setting/widgets/setting-version-setting.dart b/lib/pages/setting/widgets/setting-version-setting.dart index dec02ff..c5f7f4e 100644 --- a/lib/pages/setting/widgets/setting-version-setting.dart +++ b/lib/pages/setting/widgets/setting-version-setting.dart @@ -1,407 +1,538 @@ -import 'dart:convert'; +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; import 'package:background_downloader/background_downloader.dart'; import 'package:common_utils/common_utils.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; -import 'dart:io'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:external_path/external_path.dart'; -import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; - import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_html/flutter_html.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; + import 'package:pj_supermarket_cloud/common/index.dart'; +import 'setting_components.dart'; + +/// 版本管理页面控制器,负责终端平台精确查询、当前版本识别和人工下载。 class VersionManageController extends GetxController { - static const String _tag = "版本管理 Controller"; + /// 版本页诊断日志使用的稳定模块标签。 + static const String _tag = '版本管理 Controller'; - final appC = Get.find(); + /// 当前服务端分页页码,从 1 开始。 + final currentPage = 1.obs; - RxInt currentPage = RxInt(1); + /// 服务端返回的版本总条数。 + final total = 0.obs; - RxInt totalPage = RxInt(0); + /// 紧凑列表固定每页展示 8 条。 + final pageSize = 8.obs; - RxInt pageSize = RxInt(3); + /// 当前页中与本机 App 编码、终端平台精确匹配的已发布版本。 + final versionList = [].obs; - RxList versionList = RxList(); + /// 当前是否正在读取版本列表。 + final isLoading = false.obs; + /// 列表加载失败时展示的可恢复错误摘要。 + final errorMessage = ''.obs; + + /// 当前安装 APK 的内部版本码,用于标记“当前版本”并禁止误判升级。 + final currentVersionCode = 0.obs; + + /// 初始化当前安装版本后加载第一页数据。 @override - void onInit() async { + void onInit() { super.onInit(); + unawaited(_initialize()); + } + + /// 先读取本机 Build,再请求终端平台版本列表,避免首帧标识跳变。 + Future _initialize() async { + final package = await PackageInfo.fromPlatform(); + currentVersionCode.value = int.tryParse(package.buildNumber) ?? 0; await getVersionList(); } - @override - void onClose() {} - - /// 搜索 + /// 切换页码或刷新当前页。 Future doPage({int? page}) async { if (page != null) currentPage.value = page; - await getVersionList(isLoading: true); + await getVersionList(showLoading: true); } - /// 获取版本列表 - Future getVersionList({bool isLoading = false}) async { - if (isLoading) { - EasyLoading.show( - status: '查询中...', - maskType: EasyLoadingMaskType.black, - ); + /// 根据本机 SysType 传递精确平台编码,只读取服务端返回的已发布版本。 + Future getVersionList({bool showLoading = false}) async { + isLoading.value = true; + errorMessage.value = ''; + if (showLoading) { + EasyLoading.show(status: '查询中...', maskType: EasyLoadingMaskType.black); } try { - final params = { - "appUniqueCode": Constants.deviceUniqueCode, - }; - final data = await AppApi.openCardList(params); - logger.d("|$_tag|获取版本列表成功:${json.encode(data)}"); - if (data == null || ObjectUtil.isEmptyList(data.records)) { + final setting = await AppSettingTool.getSysSetting(); + final data = await AppApi.openCardList({ + 'appUniqueCode': Constants.deviceUniqueCode, + 'platform': AppPlatformEnum.terminalCode(setting.sysType), + 'current': currentPage.value, + 'size': pageSize.value, + }); + versionList.assignAll(data?.records ?? const []); + total.value = data?.total ?? 0; + } catch (error, stack) { + versionList.clear(); + errorMessage.value = '版本列表加载失败,请检查网络后重试'; + logger.e('|$_tag|获取版本列表失败error:|$error|stack:|$stack|'); + } finally { + isLoading.value = false; + if (showLoading) EasyLoading.dismiss(); + } + } + + /// 打开右侧版本详情抽屉,列表本身不承载更新说明和下载操作。 + Future openDetails( + BuildContext context, + ServiceAppVersion item, + ) async { + await _showVersionDrawer( + context, + item: item, + currentVersionCode: currentVersionCode.value, + onDownload: () => doDownload(item.downloadUrl ?? ''), + ); + } + + /// 人工下载继续保存到系统下载目录,不参与 Root 自动安装流程。 + Future doDownload(String url) async { + if (url.isEmpty) { + Toast.error(msg: '当前版本没有可用下载地址'); + return; + } + var downUrl = url; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + downUrl = '${Constants.ossUrl}/api/admin/sys-file/oss/file?fileName=$url'; + } + if (!Platform.isAndroid) { + EasyLoading.showInfo('当前平台不支持系统下载'); + return; + } + EasyLoading.show(status: '下载中...', maskType: EasyLoadingMaskType.black); + try { + var granted = await Permission.storage.request().isGranted; + if (!granted && await Permission.manageExternalStorage.isDenied) { + granted = await Permission.manageExternalStorage.request().isGranted; + } + if (!granted) { + EasyLoading.showError('存储权限未授权,无法下载'); return; } - versionList.value = data.records!; - totalPage.value = data.total; - } finally { - if (isLoading) { - Future.delayed(const Duration(seconds: 1), () { - EasyLoading.dismiss(); - }); - } - } - } - - /// 远程下载apk - Future doDownload(String url) async { - if (url.isEmpty) return; - String downUrl = url; - if (!url.startsWith("http") && !url.startsWith("https")) { - downUrl = "${Constants.ossUrl}/api/admin/sys-file/oss/file?fileName=$url"; - } - logger.d("|$_tag|远程下载apk,url:$downUrl"); - try { - EasyLoading.show( - status: '下载中...', - maskType: EasyLoadingMaskType.black, + final ext = AppFileTool.getFileExtension(downUrl); + final useExt = ObjectUtil.isEmpty(ext) ? '.apk' : ext; + final fileName = 'pj_xfj_${DateTime.now().millisecondsSinceEpoch}$useExt'; + final task = DownloadTask( + url: downUrl, + filename: fileName, + updates: Updates.statusAndProgress, + baseDirectory: BaseDirectory.applicationDocuments, ); - if (Platform.isAndroid) { - var hasStorage = await Permission.storage.request().isGranted; - if (!hasStorage) { - EasyLoading.showError("存储权限未授权,无法下载"); - return; - } - if (await Permission.manageExternalStorage.isDenied) { - final g = await Permission.manageExternalStorage.request(); - hasStorage = hasStorage || g.isGranted; - } - final ext = AppFileTool.getFileExtension(downUrl); - final useExt = ObjectUtil.isEmpty(ext) ? ".apk" : ext; - final now = DateTime.now(); - final dateStr = DateUtil.formatDate( - now, - format: DateFormats.full, - ); - final fileName = - "pj_xfj_${dateStr}_${now.millisecondsSinceEpoch}$useExt"; - final task = DownloadTask( - url: downUrl, - filename: fileName, - updates: Updates.statusAndProgress, - baseDirectory: BaseDirectory.applicationDocuments, - ); - final result = await FileDownloader().download( - task, - onProgress: (progress) { - logger.d("|$_tag|下载进度:$progress"); - EasyLoading.showProgress( - progress, - status: "(${(progress * 100).toStringAsFixed(2)}%)下载中...", - ); - }, - onStatus: (status) => {logger.d("|$_tag|下载状态:$status")}, - ); - if (result.status == TaskStatus.complete) { - final movedPath = await FileDownloader().moveToSharedStorage( - task, - SharedStorage.downloads, - mimeType: useExt == ".apk" - ? "application/vnd.android.package-archive" - : "application/octet-stream", - ); - if (ObjectUtil.isNotEmpty(movedPath)) { - logger.i("|$_tag|下载完成:$movedPath"); - EasyLoading.showSuccess("下载完成"); - } else { - EasyLoading.showError("移动到下载目录失败"); - } - } else { - EasyLoading.showError("下载失败"); - } - } else { - EasyLoading.showInfo("当前平台不支持系统下载"); + final result = await FileDownloader().download( + task, + onProgress: (progress) => EasyLoading.showProgress( + progress, + status: '(${(progress * 100).toStringAsFixed(0)}%) 下载中...', + ), + onStatus: (status) => logger.d('|$_tag|下载状态:$status'), + ); + if (result.status != TaskStatus.complete) { + EasyLoading.showError('下载失败'); + return; } - } catch (e) { - logger.e("|$_tag|远程下载apk,下载失败:$e"); - EasyLoading.showError("下载失败"); + final movedPath = await FileDownloader().moveToSharedStorage( + task, + SharedStorage.downloads, + mimeType: useExt == '.apk' + ? 'application/vnd.android.package-archive' + : 'application/octet-stream', + ); + if (ObjectUtil.isEmpty(movedPath)) { + EasyLoading.showError('移动到下载目录失败'); + return; + } + logger.i('|$_tag|人工下载完成:$movedPath'); + EasyLoading.showSuccess('下载完成'); + } catch (error, stack) { + logger.e('|$_tag|下载失败error:|$error|stack:|$stack|'); + EasyLoading.showError('下载失败'); } finally { EasyLoading.dismiss(); } } } +/// 构建每页 8 条的紧凑版本列表,点击行后从右侧展开完整详情。 Widget settingVersionSetting(BuildContext context) { - final versionC = Get.put( - VersionManageController(), - ); + final controller = Get.isRegistered() + ? Get.find() + : Get.put(VersionManageController()); return [ - buildSettingVersionSettingSearch(context), - buildSettingVersionSettingList(context).expanded(), + SettingActionButton( + label: '刷新版本列表', + icon: FAssets.icons.refreshCw, + primary: true, + onTap: controller.doPage, + ).alignRight(), + Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.errorMessage.isNotEmpty) { + return SettingEmpty(message: controller.errorMessage.value); + } + if (controller.versionList.isEmpty) { + return const SettingEmpty(message: '暂无已发布版本'); + } + return SettingGroup( + title: '已发布版本', + child: List.generate( + controller.versionList.length, + (index) { + final item = controller.versionList[index]; + return _VersionRow( + item: item, + latest: controller.currentPage.value == 1 && index == 0, + current: item.versionCode == controller.currentVersionCode.value, + onTap: () => controller.openDetails(context, item), + ); + }, + growable: false, + ).toColumn( + separator: const Divider( + height: 1, + color: XfjDesignTokens.line, + ), + ), + ); + }).expanded(), Obx( () => appPage( context, - pageNo: versionC.currentPage.value, - pageSize: versionC.pageSize.value, - total: versionC.totalPage.value, - onPageChanged: (pageNo) => versionC.doPage(page: pageNo), + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.total.value, + onPageChanged: (pageNo) => controller.doPage(page: pageNo), ), - ) - ].toColumn(); -} - -// 版本更新搜索区域 -Widget buildSettingVersionSettingSearch(BuildContext context) { - final versionC = Get.find(); - final theme = FTheme.of(context); - return [ - const Spacer(), - FButton( - prefix: FIcon( - FAssets.icons.search, - size: theme.typography.base.fontSize, - ), - label: Text( - '搜索', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: () => versionC.doPage(), ), - ].toRow(); + ].toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.stretch, + space: XfjDesignTokens.cardGap.h, + ); } -// 版本更新列表区域 -Widget buildSettingVersionSettingList(BuildContext context) { - final versionC = Get.find(); - return Obx(() { - if (versionC.versionList.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.info_outline, size: 48, color: Colors.grey), - const SizedBox(height: 10), - Text("暂无版本信息", style: TextStyle(color: Colors.grey[600])), - ], - ), - ); - } - return GridView.builder( - padding: const EdgeInsets.all(10), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 1, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 6, - ), - itemCount: versionC.versionList.length, - itemBuilder: (context, index) { - final item = versionC.versionList[index]; - return buildSettingVersionSettingCardCopy(context, item); - }, - ); +/// 单条版本只展示版本名、Build、平台、发布状态和当前/最新标识。 +class _VersionRow extends StatelessWidget { + /// 创建一个具有稳定高度和完整点击热区的版本行。 + const _VersionRow({ + required this.item, + required this.latest, + required this.current, + required this.onTap, }); + + /// 服务端版本记录。 + final ServiceAppVersion item; + + /// 是否为第一页版本码最高记录。 + final bool latest; + + /// 是否与当前安装 Build 相同。 + final bool current; + + /// 打开右侧详情抽屉的回调。 + final VoidCallback onTap; + + /// 构建固定列宽的紧凑扫描行。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final platform = + AppPlatformEnum.values.fromCode(item.platform ?? '')?.title ?? '未知平台'; + final status = + AppVersionStatusEnum.values.fromCode(item.status ?? '')?.title ?? + '未知状态'; + return InkWell( + onTap: onTap, + child: [ + [ + Text( + item.versionName ?? '--', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + Text( + 'Build ${item.versionCode ?? '--'}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.start, + space: 4.h, + ) + .expanded(flex: 3), + Text(platform, style: theme.typography.sm).expanded(flex: 2), + _versionBadge(status, XfjDesignTokens.success), + [ + if (current) _versionBadge('当前', XfjDesignTokens.primary), + if (latest) _versionBadge('最新', XfjDesignTokens.warning), + if (!current && !latest) + Text('--', + style: + theme.typography.sm.copyWith(color: XfjDesignTokens.muted)), + ] + .toRowSpace(mainAxisSize: MainAxisSize.min, space: 8.w) + .expanded(flex: 2), + FIcon( + FAssets.icons.chevronRight, + color: XfjDesignTokens.muted, + size: 20.sp, + ), + ] + .toRowSpace(space: 18.w) + .padding(horizontal: 20.w, vertical: 10.h) + .constrained(minHeight: 68.h), + ); + } } -Widget buildSettingVersionSettingCardCopy( - BuildContext context, - ServiceAppVersion item, -) { - final versionC = Get.find(); +/// 构建同时带文字与语义色的版本状态标签,避免只依靠颜色表达状态。 +Widget _versionBadge(String label, Color color) => Text( + label, + maxLines: 1, + style: TextStyle( + color: color, + fontSize: 13.sp, + fontWeight: FontWeight.w700, + ), + ).padding(horizontal: 10.w, vertical: 5.h).decorated( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + +/// 从右侧展示完整版本信息与静态 HTML 更新内容,遮罩点击和关闭图标均可退出。 +Future _showVersionDrawer( + BuildContext context, { + required ServiceAppVersion item, + required int currentVersionCode, + required VoidCallback onDownload, +}) async { + await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '关闭版本详情', + barrierColor: Colors.black.withValues(alpha: 0.45), + transitionDuration: const Duration(milliseconds: 220), + pageBuilder: (dialogContext, _, __) { + final width = math.min(MediaQuery.sizeOf(context).width * 0.52, 960.w); + return Align( + alignment: Alignment.centerRight, + child: Material( + color: XfjDesignTokens.surface, + child: SafeArea( + child: _VersionDetail( + item: item, + currentVersionCode: currentVersionCode, + onClose: () => Navigator.of(dialogContext).pop(), + onDownload: onDownload, + ).width(width), + ), + ), + ); + }, + transitionBuilder: (_, animation, __, child) => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), + child: child, + ), + ); +} + +/// 右侧抽屉内容,详情与 HTML 使用单一滚动区域,底部下载操作保持可见。 +class _VersionDetail extends StatelessWidget { + /// 创建版本详情内容。 + const _VersionDetail({ + required this.item, + required this.currentVersionCode, + required this.onClose, + required this.onDownload, + }); + + /// 服务端版本记录。 + final ServiceAppVersion item; + + /// 当前安装版本码,用于详情状态说明。 + final int currentVersionCode; + + /// 关闭抽屉回调。 + final VoidCallback onClose; + + /// 人工下载 APK 回调。 + final VoidCallback onDownload; + + /// 构建标题、元数据、静态 HTML 与底部操作。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final platform = + AppPlatformEnum.values.fromCode(item.platform ?? '')?.title ?? '未知平台'; + final status = + AppVersionStatusEnum.values.fromCode(item.status ?? '')?.title ?? + '未知状态'; + return [ + [ + [ + Text( + '版本 ${item.versionName ?? '--'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + Text( + 'Build ${item.versionCode ?? '--'}', + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.start, + space: 4.h, + ) + .expanded(), + IconButton( + tooltip: '关闭版本详情', + onPressed: onClose, + icon: FIcon(FAssets.icons.x, size: 22.sp), + ), + ] + .toRowSpace(space: 12.w) + .padding(horizontal: 24.w, vertical: 18.h) + .decorated( + border: + const Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + [ + _detailLine(context, '平台', platform), + _detailLine(context, '状态', status), + _detailLine(context, 'App 编码', item.appUniqueCode ?? '--'), + _detailLine(context, '强制更新', item.isForce == true ? '是' : '否'), + _detailLine(context, '静默更新', item.isSilent == true ? '是' : '否'), + _detailLine( + context, + '本机状态', + item.versionCode == currentVersionCode ? '当前版本' : '非当前版本', + ), + Text( + '更新内容', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ).paddingOnly(top: 16.h, bottom: 6.h), + Html( + data: ObjectUtil.isEmpty(item.updateContent) + ? '

暂无更新内容

' + : item.updateContent!, + shrinkWrap: true, + // 只允许静态文本排版标签;a 标签保留文字但回调不执行任何外链跳转。 + onlyRenderTheseTags: const { + 'html', + 'body', + 'div', + 'span', + 'p', + 'br', + 'ul', + 'ol', + 'li', + 'strong', + 'b', + 'em', + 'i', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'a', + }, + doNotRenderTheseTags: const { + 'script', + 'iframe', + 'object', + 'embed', + 'form', + 'input', + 'button', + }, + onLinkTap: (_, __, ___) {}, + style: { + 'body': Style( + color: XfjDesignTokens.ink, + fontSize: FontSize(15.sp), + lineHeight: const LineHeight(1.6), + margin: Margins.zero, + ), + 'p': Style(margin: Margins.only(bottom: 10.h)), + 'li': Style(margin: Margins.only(bottom: 6.h)), + }, + ), + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + .padding(horizontal: 24.w, vertical: 18.h) + .scrollable() + .expanded(), + SettingActionButton( + label: '下载 APK', + icon: FAssets.icons.arrowDownToLine, + primary: true, + enabled: ObjectUtil.isNotEmpty(item.downloadUrl), + onTap: onDownload, + ).alignRight().padding(horizontal: 24.w, vertical: 16.h).decorated( + border: const Border(top: BorderSide(color: XfjDesignTokens.line)), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + } +} + +/// 构建抽屉元数据键值行,长值允许换行且不会挤压标签。 +Widget _detailLine(BuildContext context, String label, String value) { final theme = FTheme.of(context); return [ - [ - Text( - "${AppPlatformEnum.values.fromCode(item.platform ?? '')?.title ?? '未知平台'} - ${item.versionName ?? '未知版本'}", - style: theme.typography.lg.copyWith(fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - ), - SizedBox(width: 20.w), - FBadge( - label: Text( - AppVersionStatusEnum.values.fromCode(item.status ?? '')?.title ?? - '未知状态', - style: theme.typography.sm.copyWith(height: 1, color: Colors.white), - ).padding(), - ), - ].toRow(), - SizedBox(height: 10.w), Text( - "更新内容", - style: theme.typography.base.copyWith(fontWeight: FontWeight.bold), - ), - SizedBox(height: 10.w), + label, + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ).width(130.w), Text( - item.updateContent ?? '暂无更新内容', - style: theme.typography.base.copyWith(color: Colors.grey), - ), - SizedBox(height: 10.w), - [ - FIcon( - FAssets.icons.sendHorizontal, - size: theme.typography.base.fontSize, + value, + textAlign: TextAlign.right, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, ), - SizedBox(width: 10.w), - Text( - item.downloadUrl ?? '未知下载地址', - style: theme.typography.sm.copyWith(color: Colors.grey), - ), - const Spacer(), - FIcon( - FAssets.icons.arrowDownToLine, - size: theme.typography.base.fontSize, - ), - ] - .toRow() - .padding(horizontal: 20.w, vertical: 15.w) - .decorated( - color: theme.colorScheme.secondary, - border: Border.all( - color: theme.colorScheme.primary, - width: 1, - ), - borderRadius: theme.style.borderRadius, - ) - .onTap(() => versionC.doDownload(item.downloadUrl ?? '')), + ).expanded(), ] - .toColumn(crossAxisAlignment: CrossAxisAlignment.start) - .padding(horizontal: 20.w, vertical: 15.w) - .decorated( - color: theme.colorScheme.muted, - border: Border.all( - color: theme.colorScheme.primary, - width: 1, - ), - borderRadius: theme.style.borderRadius, - ); -} - -// 版本管理卡片 -Widget buildSettingVersionSettingCard( - BuildContext context, - ServiceAppVersion item, -) { - final theme = FTheme.of(context); - return Container( - padding: const EdgeInsets.all(15), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - "${item.platform ?? '未知平台'} - ${item.versionName ?? '未知版本'}", - style: - theme.typography.lg.copyWith(fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: item.status == '已发布' - ? Colors.green.withOpacity(0.1) - : Colors.grey.withOpacity(0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - item.status ?? '未知状态', - style: theme.typography.sm.copyWith( - color: item.status == '已发布' ? Colors.green : Colors.grey, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Text("Build: ${item.versionCode ?? '-'}", - style: theme.typography.base.copyWith(color: Colors.grey)), - const SizedBox(width: 10), - if (item.isForce == true) _buildTag(context, "强制更新", Colors.red), - if (item.isSilent == true) - _buildTag(context, "静默更新", Colors.orange), - ], - ), - const Divider(height: 20), - Text("更新内容:", - style: theme.typography.base.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Text( - item.updateContent ?? '暂无更新内容', - style: theme.typography.base.copyWith(color: Colors.grey[800]), - ), - if (item.downloadUrl != null && item.downloadUrl!.isNotEmpty) ...[ - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(4), - ), - child: Row( - children: [ - const Icon(Icons.link, size: 16, color: Colors.blue), - const SizedBox(width: 5), - Expanded( - child: Text( - item.downloadUrl!, - style: theme.typography.sm.copyWith(color: Colors.blue), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ], - ], - ), - ); -} - -Widget _buildTag(BuildContext context, String text, Color color) { - return Container( - margin: const EdgeInsets.only(left: 8), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - border: Border.all(color: color), - borderRadius: BorderRadius.circular(4), - ), - child: Text(text, style: TextStyle(color: color, fontSize: 10)), - ); + .toRowSpace(crossAxisAlignment: CrossAxisAlignment.start, space: 16.w) + .padding(vertical: 8.h); } diff --git a/lib/pages/setting/widgets/setting_face.dart b/lib/pages/setting/widgets/setting_face.dart index 42abd06..920229d 100644 --- a/lib/pages/setting/widgets/setting_face.dart +++ b/lib/pages/setting/widgets/setting_face.dart @@ -1,472 +1,13 @@ -import 'dart:async'; - -import 'package:common_utils/common_utils.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/setting/face_manage_controller.dart'; import 'setting_components.dart'; -/// 人脸管理内容区的两个一级视图。 -enum FaceManageTab { - /// 全部本地人脸及既有同步维护能力。 - all, - - /// 已检测且分数低于当前在库阈值的人脸。 - lowQuality, -} - -/// 设置页人脸管理控制器,只负责列表交互和将人工同步请求提交到应用级队列。 -class FaceManageController extends GetxController { - /// 应用级数据库和事件总线持有者,由根 Binding 统一注册。 - final appC = Get.find(); - - /// 当前终端设置,初始化后用于确定系统类型和设备绑定状态。 - late SysSetting sysSetting; - - /// 当前终端业务类型,决定搜索框允许的输入方式。 - Rx sysType = SysType.CANTEEN.obs; - - /// 当前分页展示的人脸本地记录。 - RxList faceList = RxList(); - - /// 当前列表页码,从 1 开始。 - RxInt currentPage = RxInt(1); - - /// 当前搜索条件命中的记录总数,供分页组件计算页数。 - RxInt totalPage = RxInt(0); - - /// 单页展示数量,沿用现有人脸管理网格密度。 - RxInt pageSize = RxInt(15); - - /// 列表查询是否进行中,仅驱动列表区域加载状态。 - RxBool isLoading = false.obs; - - /// 最近一次列表查询失败的安全提示,不包含数据库或文件路径。 - RxString errorMessage = ''.obs; - - /// 质量检测或服务端删除的可恢复提示,不替代列表内容。 - final qualityOperationMessage = ''.obs; - - /// 当前选中的内容 Tab。 - final selectedTab = FaceManageTab.all.obs; - - /// Android 人脸设置中的在库质量阈值。 - final libraryQualityThreshold = 0.60.obs; - - /// 低质量、待检测和检测失败的实时统计。 - final lowQualityCount = 0.obs; - final pendingQualityCount = 0.obs; - final failedQualityCount = 0.obs; - - /// 存量质量检测的最新原生进度。 - final qualityAuditProgress = Rxn(); - - /// 同步、检测和服务端删除状态,用于互斥及按钮禁用。 - 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() { - super.onInit(); - unawaited(_initialize()); - } - - /// 读取终端设置后加载列表;初始化失败交由列表状态统一展示。 - Future _initialize() async { - sysSetting = await AppSettingTool.getSysSetting(); - sysType.value = sysSetting.sysType; - _listenMqCallback(); - qualityAuditListen = FaceDiscernPlugs.qualityAuditStream.listen( - _handleQualityAuditProgress, - onError: (Object error, StackTrace stack) { - logger.w('|人脸管理|质量进度监听失败error:$error|stack:$stack|'); - }, - ); - await refreshQualityThreshold(); - await getFaceList(); - } - - /// 释放 MQTT 完成事件和输入控制器,防止退出设置页后继续刷新。 - @override - void onClose() { - mqUpdateListen?.cancel(); - qualityAuditListen?.cancel(); - usernameC.dispose(); - super.onClose(); - } - - /// 按当前姓名和页码读取本地人脸列表,可选显示旧页面使用的查询遮罩。 - Future getFaceList({bool isLoading = false}) async { - this.isLoading.value = true; - errorMessage.value = ''; - if (isLoading) { - EasyLoading.show( - status: '查询中...', - maskType: EasyLoadingMaskType.black, - ); - } - try { - final faceDao = appC.dataBase.getFaceDao; - String userName = usernameC.text; - if (ObjectUtil.isEmpty(userName)) userName = "-1"; - final lowQuality = selectedTab.value == FaceManageTab.lowQuality; - final findCount = lowQuality - ? await faceDao.findLowQualityCount( - userName, - libraryQualityThreshold.value, - ) - : await faceDao.findCount(userName); - totalPage.value = findCount ?? 0; - final list = lowQuality - ? await faceDao.findLowQuality( - userName, - libraryQualityThreshold.value, - pageSize.value, - (currentPage.value - 1) * pageSize.value, - ) - : await faceDao.findAll( - userName, - pageSize.value, - (currentPage.value - 1) * pageSize.value, - ); - faceList.value = list; - await _refreshQualityCounts(faceDao); - } catch (e, stack) { - errorMessage.value = '人脸数据加载失败,请稍后重试'; - logger.e('|人脸管理|加载失败error:|$e|stack:|$stack|'); - } finally { - this.isLoading.value = false; - if (isLoading) { - Future.delayed(const Duration(seconds: 1), () { - EasyLoading.dismiss(); - }); - } - } - } - - /// 确认后将本部门快照或非生产全租户调试请求提交到统一人脸任务队列。 - /// - /// 队列会先避开支付、人脸会话和业务弹框;只有任务已经实际开始时才显示遮罩, - /// 避免遮罩本身被空闲检测识别为业务占用而导致任务无法启动。 - Future asyncFace({bool isAll = false}) async { - if (operationInProgress) return; - final isNetwork = AppNetworkTool.isAutoNetworkMsg(); - if (!isNetwork) return; - final setting = await AppSettingTool.getSysSetting(); - if (ObjectUtil.isEmpty(setting.merchantDeptId)) { - Toast.error(msg: "请先绑定设备"); - return; - } - if (isAll && AppSysTool.isProd()) { - Toast.error(msg: '生产环境仅允许同步本部门人脸'); - return; - } - if (AppSysTool.isProd()) { - final isActive = await AppFaceTool.isActiveSdk(); - if (!isActive) { - Toast.error(msg: "人脸sdk激活失败"); - return; - } - } - final isSure = await appMessageDialog( - Get.context!, - isAll ? '是否同步全租户人脸?此能力仅用于非生产调试,过程可能较长' : '是否同步本部门及子部门人脸?过程可能较长', - ); - if (!isSure) return; - final scheduler = SyncCheckTool.instance; - isSyncing.value = true; - final operation = scheduler.trigger( - ScheduledTaskType.departmentFaceSync, - debugAllFaces: isAll, - triggerType: ScheduledTaskTriggerType.manual, - ); - await Future.delayed(Duration.zero); - var overlayVisible = false; - if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync)) { - overlayVisible = true; - EasyLoading.show( - status: '同步中...', - maskType: EasyLoadingMaskType.black, - ); - } - try { - await operation; - if (scheduler.pendingTasks - .contains(ScheduledTaskType.departmentFaceSync)) { - Toast.success(msg: '人脸同步已排队,将在业务空闲后执行'); - return; - } - currentPage.value = 1; - await getFaceList(); - final record = scheduler.records[ScheduledTaskType.departmentFaceSync]; - if (record?.success == true) { - Toast.success(msg: record?.message ?? '人脸同步完成'); - } else { - Toast.error(msg: record?.message ?? '人脸同步失败,请稍后重试'); - } - } catch (e, stack) { - Toast.error(msg: '同步人脸失败,请稍后重试'); - logger.e('|人脸管理|同步失败error:|$e|stack:|$stack|'); - } finally { - currentPage.value = 1; - isSyncing.value = false; - if (overlayVisible) EasyLoading.dismiss(); - } - } - - /// 重新读取 Android 人脸配置并按新阈值立即重查低质量分类。 - Future 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 || - SyncCheckTool.instance.runningTasks.contains( - ScheduledTaskType.departmentFaceSync, - ); - - /// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。 - Future delAllFace() async { - final isSure = await appMessageDialog( - Get.context!, - "是否删除全部人脸", - ); - if (!isSure) return; - EasyLoading.show( - status: '删除中...', - maskType: EasyLoadingMaskType.black, - ); - try { - final faceDao = appC.dataBase.getFaceDao; - await faceDao.delAll(); - await AppFaceTool.removeFace(sampleId: '-1'); - await getFaceList(); - Toast.success(msg: '删除成功'); - } catch (e, stack) { - Toast.error(msg: '删除全部人脸失败'); - logger.e('|人脸管理|删除全部失败error:|$e|stack:|$stack|'); - } finally { - EasyLoading.dismiss(); - } - } - - /// 监听成功完成的 FACE 增量同步,并仅刷新当前设置页本地列表。 - void _listenMqCallback() { - mqUpdateListen = - appC.mqUpdateEventBus.on().listen((event) async { - switch (event) { - case MqtActionEnum.FACE: - await getFaceList(isLoading: true); - break; - default: - } - }); - } - - /// 按指定页码执行姓名搜索,并在查询前收起软键盘。 - Future doPage({int? page}) async { - if (page != null) currentPage.value = page; - // 收起键盘 - FocusManager.instance.primaryFocus?.unfocus(); - await getFaceList(isLoading: true); - } - - /// 清空姓名条件、回到第一页并重新加载列表。 - Future doReset() async { - currentPage.value = 1; - usernameC.clear(); - // 收起键盘 - FocusManager.instance.primaryFocus?.unfocus(); - await getFaceList(isLoading: true); - } - - /// 删除一条本地人脸及其虹软内存特征,不修改服务端人脸真源。 - Future deleteFace(FaceEntity v) async { - final isSure = await appMessageDialog(Get.context!, "是否删除人脸"); - if (!isSure) return; - EasyLoading.show( - status: '删除中...', - maskType: EasyLoadingMaskType.black, - ); - try { - final deleted = await SyncFaceTool.deleteLocalFace(v); - if (!deleted) throw StateError('本地人脸删除失败并已尝试恢复'); - await getFaceList(); - Toast.success(msg: '删除成功'); - } catch (e, stack) { - Toast.error(msg: '删除人脸失败'); - logger.e('|人脸管理|删除失败error:|$e|stack:|$stack|'); - } finally { - EasyLoading.dismiss(); - } - } -} - /// 构建紧凑的人脸视图切换栏,并在低质量页同行展示质量摘要。 Widget _buildFaceHeader(BuildContext context) { final controller = Get.find(); diff --git a/lib/pages/setting/widgets/setting_log_preview.dart b/lib/pages/setting/widgets/setting_log_preview.dart new file mode 100644 index 0000000..1ea17bc --- /dev/null +++ b/lib/pages/setting/widgets/setting_log_preview.dart @@ -0,0 +1,1038 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'setting_components.dart'; + +/// 日志预览采用独立生命周期,离开设置分区后立即停止轮询和输入监听。 +class SettingLogPreview extends StatefulWidget { + const SettingLogPreview({super.key}); + + @override + State createState() => _SettingLogPreviewState(); +} + +class _SettingLogPreviewState extends State { + final TextEditingController _keywordController = TextEditingController(); + final ScrollController _catalogScrollController = ScrollController(); + final ScrollController _previewScrollController = ScrollController(); + Timer? _autoRefreshTimer; + AppLogCatalog? _catalog; + AppLogPreview? _preview; + String? _selectedPath; + bool _loadingCatalog = true; + bool _loadingPreview = true; + bool _refreshing = false; + bool _autoRefresh = false; + _LogDisplayOrder _displayOrder = _LogDisplayOrder.latestFirst; + String _keyword = ''; + String _errorMessage = ''; + int _previewRequestId = 0; + + @override + void initState() { + super.initState(); + _keywordController.addListener(_onKeywordChanged); + unawaited(_reload()); + } + + @override + void dispose() { + _autoRefreshTimer?.cancel(); + _keywordController + ..removeListener(_onKeywordChanged) + ..dispose(); + _catalogScrollController.dispose(); + _previewScrollController.dispose(); + super.dispose(); + } + + void _onKeywordChanged() { + final value = _keywordController.text.trim(); + if (value == _keyword || !mounted) return; + setState(() => _keyword = value); + } + + Future _reload({bool quiet = false}) async { + if (_refreshing) return; + _refreshing = true; + if (mounted) { + setState(() { + if (!quiet) _loadingCatalog = true; + }); + } + try { + final catalog = await listAppLogFiles(); + final selectedPath = catalog.files.any( + (file) => file.path == _selectedPath, + ) + ? _selectedPath! + : catalog.currentPath; + if (!mounted) return; + setState(() { + _catalog = catalog; + _selectedPath = selectedPath; + _loadingCatalog = false; + _errorMessage = ''; + }); + await _loadPreview(selectedPath, quiet: quiet); + } catch (e, stack) { + logger.e('|日志预览|读取失败error:|$e|stack:|$stack|'); + if (!mounted) return; + setState(() { + _loadingCatalog = false; + _loadingPreview = false; + _errorMessage = '日志目录读取失败,请检查存储权限后重试'; + }); + } finally { + if (mounted) { + setState(() => _refreshing = false); + } else { + _refreshing = false; + } + } + } + + Future _loadPreview(String path, {bool quiet = false}) async { + final requestId = ++_previewRequestId; + if (mounted) { + setState(() { + _selectedPath = path; + if (!quiet) { + _loadingPreview = true; + _preview = null; + } + _errorMessage = ''; + }); + } + try { + final preview = await readAppLogPreview(path: path); + if (!mounted || requestId != _previewRequestId) return; + setState(() { + _preview = preview; + _loadingPreview = false; + }); + _scrollPreviewToLatest(); + } catch (e, stack) { + logger.e('|日志预览|读取文件失败error:|$e|stack:|$stack|'); + if (!mounted || requestId != _previewRequestId) return; + setState(() { + _loadingPreview = false; + _errorMessage = '日志文件读取失败,文件可能已被移动或清理'; + }); + } + } + + /// 手动刷新、自动刷新和顺序切换都追踪最新日志:倒序定位顶部,正序定位底部。 + void _scrollPreviewToLatest() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_previewScrollController.hasClients) return; + final position = _previewScrollController.position; + final offset = _displayOrder == _LogDisplayOrder.latestFirst + ? position.minScrollExtent + : position.maxScrollExtent; + _previewScrollController.jumpTo(offset); + }); + } + + void _selectFile(AppLogFile file) { + if (_selectedPath == file.path) return; + if (_autoRefresh && !file.isCurrent) _toggleAutoRefresh(false); + unawaited(_loadPreview(file.path)); + } + + void _toggleAutoRefresh(bool value) { + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + setState(() => _autoRefresh = value); + if (value) { + _autoRefreshTimer = Timer.periodic( + const Duration(seconds: 3), + (_) { + final path = _selectedPath; + if (path != null) unawaited(_loadPreview(path, quiet: true)); + }, + ); + final path = _selectedPath; + if (path != null) unawaited(_loadPreview(path, quiet: true)); + } + } + + void _toggleDisplayOrder() { + setState(() { + _displayOrder = _displayOrder == _LogDisplayOrder.latestFirst + ? _LogDisplayOrder.oldestFirst + : _LogDisplayOrder.latestFirst; + }); + _scrollPreviewToLatest(); + } + + List get _visibleLines { + final content = _preview?.content ?? ''; + final lines = _orderedLogLines(content, order: _displayOrder); + if (_keyword.isEmpty) return lines; + final keyword = _keyword.toLowerCase(); + return lines + .where((line) => line.toLowerCase().contains(keyword)) + .toList(growable: false); + } + + @override + Widget build(BuildContext context) { + final lines = _visibleLines; + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: Row( + children: [ + SizedBox(width: 278.w, child: _buildLogTree(context)), + const VerticalDivider( + width: 1, + thickness: 1, + color: XfjDesignTokens.line, + ), + Expanded( + child: Column( + children: [ + _buildToolbar(context, lines), + Expanded(child: _buildPreview(context, lines)), + ], + ), + ), + ], + ), + ); + } + + Widget _buildLogTree(BuildContext context) { + final theme = FTheme.of(context); + final catalog = _catalog; + final groupedFiles = _groupedFiles; + return ColoredBox( + color: const Color(0xFFF8F9FB), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(14.w, 12.h, 10.w, 10.h), + child: Row( + children: [ + FIcon( + FAssets.icons.folderTree, + color: XfjDesignTokens.primary, + size: 20.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + '日志目录', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + _CompactIconButton( + label: '刷新日志目录', + icon: FAssets.icons.refreshCw, + onTap: _refreshing ? null : () => unawaited(_reload()), + ), + ], + ), + ), + if (catalog != null) + Padding( + padding: EdgeInsets.fromLTRB(14.w, 0, 14.w, 10.h), + child: Text( + '${catalog.files.length} 个日志文件', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + const Divider(height: 1, thickness: 1), + Expanded( + child: _loadingCatalog && catalog == null + ? const Center(child: CircularProgressIndicator()) + : groupedFiles.isEmpty + ? SettingEmpty( + message: '暂无日志文件', + icon: FAssets.icons.logs, + ) + : Scrollbar( + controller: _catalogScrollController, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + child: ListView( + controller: _catalogScrollController, + padding: EdgeInsets.fromLTRB(0, 8.h, 10.w, 8.h), + children: groupedFiles.entries + .map( + (entry) => _LogTreeGroup( + label: entry.key, + files: entry.value, + selectedPath: _selectedPath, + onSelected: _selectFile, + ), + ) + .toList(growable: false), + ), + ), + ), + if (catalog != null) + Semantics( + button: true, + label: '复制日志目录路径', + child: InkWell( + onTap: () => KeywordTool.copy(catalog.rootPath), + child: Container( + constraints: BoxConstraints(minHeight: 48.h), + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Row( + children: [ + FIcon( + FAssets.icons.folderOpen, + color: XfjDesignTokens.muted, + size: 18.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + catalog.rootPath, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + SizedBox(width: 8.w), + FIcon( + FAssets.icons.copy, + color: XfjDesignTokens.primary, + size: 17.sp, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Map> get _groupedFiles { + final groups = >{}; + for (final file in _catalog?.files ?? const []) { + final separator = file.relativePath.indexOf('/'); + final label = + separator < 0 ? '日志文件' : file.relativePath.substring(0, separator); + groups.putIfAbsent(label, () => []).add(file); + } + return groups; + } + + Widget _buildToolbar(BuildContext context, List lines) { + final theme = FTheme.of(context); + final preview = _preview; + final selectedFile = _selectedFile; + return Container( + padding: EdgeInsets.all(8.w), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + SizedBox( + width: 300.w, + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + selectedFile?.name ?? '正在获取日志文件', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + if (selectedFile?.isCurrent ?? false) ...[ + SizedBox(width: 8.w), + _CurrentLogBadge(), + ], + ], + ), + SizedBox(height: 2.h), + Text( + preview == null + ? '正在读取文件信息' + : '${_formatBytes(preview.fileSize)} · ${preview.lineCount} 行 · ${_formatTime(preview.modifiedAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + _CompactIconButton( + label: '复制当前日志路径', + icon: FAssets.icons.copy, + onTap: preview == null + ? null + : () => KeywordTool.copy(preview.path), + ), + ], + ), + ), + SizedBox(width: 8.w), + Container(width: 1, height: 36.h, color: XfjDesignTokens.line), + SizedBox(width: 10.w), + Expanded( + child: Semantics( + textField: true, + label: '筛选日志关键词', + // Forui 会按运行时字号和主题内边距计算实际高度,只设最小触控高度, + // 避免固定 48.h 在字号缩放后裁掉输入框底部并触发 RenderFlex 溢出。 + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: 48.h), + child: FTextField( + controller: _keywordController, + hint: '筛选关键词,如 error、支付、MQTT', + maxLines: 1, + textInputAction: TextInputAction.search, + prefixBuilder: (_, __, ___) => Padding( + padding: EdgeInsets.only(left: 12.w, right: 8.w), + child: FIcon( + FAssets.icons.search, + color: XfjDesignTokens.muted, + size: 19.sp, + ), + ), + ), + ), + ), + ), + SizedBox(width: 8.w), + _CompactActionButton( + label: '刷新', + icon: FAssets.icons.refreshCw, + onTap: _refreshing ? null : () => unawaited(_reload()), + ), + SizedBox(width: 8.w), + _CompactActionButton( + label: _displayOrder.label, + icon: FAssets.icons.arrowDownUp, + onTap: _toggleDisplayOrder, + ), + SizedBox(width: 8.w), + _CompactActionButton( + label: '复制结果', + icon: FAssets.icons.copy, + onTap: lines.isEmpty + ? null + : () async { + await Clipboard.setData( + ClipboardData(text: lines.join('\n')), + ); + Toast.success(msg: '已复制当前筛选结果'); + }, + ), + SizedBox(width: 8.w), + Container( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: XfjDesignTokens.control, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + Text( + '自动刷新', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(width: 8.w), + FSwitch( + semanticLabel: '每 3 秒自动刷新当前日志', + value: _autoRefresh, + enabled: selectedFile?.isCurrent ?? false, + onChange: _toggleAutoRefresh, + ), + ], + ), + ), + ], + ), + ); + } + + AppLogFile? get _selectedFile { + for (final file in _catalog?.files ?? const []) { + if (file.path == _selectedPath) return file; + } + return null; + } + + Widget _buildPreview(BuildContext context, List lines) { + if (_loadingPreview && _preview == null) { + return const Center(child: CircularProgressIndicator()); + } + if (_errorMessage.isNotEmpty) { + return _LogPreviewState( + icon: FAssets.icons.circleAlert, + message: _errorMessage, + actionLabel: '重新读取', + onTap: _selectedPath == null + ? () => unawaited(_reload()) + : () => unawaited(_loadPreview(_selectedPath!)), + ); + } + if ((_preview?.content ?? '').isEmpty) { + return _LogPreviewState( + icon: FAssets.icons.logs, + message: '当前还没有文件日志,发生运行事件后可手动刷新查看', + ); + } + if (lines.isEmpty) { + return _LogPreviewState( + icon: FAssets.icons.search, + message: '没有匹配当前关键词的日志,请调整筛选条件', + ); + } + + final theme = FTheme.of(context); + final preview = _preview!; + return Container( + color: const Color(0xFFF8F9FB), + child: Column( + children: [ + if (preview.truncated) + Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + color: const Color(0xFFFFF9E8), + child: Text( + '文件较大,仅显示最近 ${preview.lineCount} 行;完整文件可在左侧切换或通过“资源备份”导出。', + style: theme.typography.sm.copyWith( + color: const Color(0xFF9A6700), + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Scrollbar( + controller: _previewScrollController, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + child: SelectionArea( + child: ListView.builder( + controller: _previewScrollController, + padding: EdgeInsets.fromLTRB(0, 8.h, 10.w, 8.h), + itemCount: lines.length, + itemBuilder: (context, index) { + final line = lines[index]; + return Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 3.h, + ), + color: _lineBackground(line), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 58.w, + child: Text( + '${index + 1}', + textAlign: TextAlign.right, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Text( + line.isEmpty ? ' ' : line, + style: theme.typography.sm.copyWith( + color: _lineColor(line), + height: 1.55, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ], + ), + ); + } +} + +enum _LogDisplayOrder { + latestFirst('倒序'), + oldestFirst('正序'); + + const _LogDisplayOrder(this.label); + + final String label; +} + +/// 当前日志由 PrettyPrinter 连续写入,事件之间没有空行,因此以固定的 `#0` 堆栈行识别事件起点。 +/// 倒序只反转事件,不反转事件内部的堆栈、时间和正文,避免日志内容被拆乱。 +List _orderedLogLines( + String content, { + required _LogDisplayOrder order, +}) { + if (content.isEmpty) return const []; + final lines = const LineSplitter().convert(content); + if (order == _LogDisplayOrder.oldestFirst) return lines; + + final entries = >[]; + var currentEntry = []; + for (var index = 0; index < lines.length; index++) { + final line = lines[index]; + final nextLine = index + 1 < lines.length ? lines[index + 1] : ''; + if (_isLogEntryStart(line, nextLine) && currentEntry.isNotEmpty) { + entries.add(currentEntry); + currentEntry = []; + } + currentEntry.add(line); + } + if (currentEntry.isNotEmpty) entries.add(currentEntry); + return entries.reversed.expand((entry) => entry).toList(growable: false); +} + +bool _isLogEntryStart(String line, String nextLine) { + return line.startsWith('#0') && + RegExp(r'^\d{2}:\d{2}:\d{2}\.\d{3} \(\+.+\)$').hasMatch(nextLine); +} + +class _LogTreeGroup extends StatelessWidget { + const _LogTreeGroup({ + required this.label, + required this.files, + required this.selectedPath, + required this.onSelected, + }); + + final String label; + final List files; + final String? selectedPath; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Padding( + padding: EdgeInsets.only(bottom: 6.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + child: Row( + children: [ + FIcon( + label == 'manual' + ? FAssets.icons.folderArchive + : FAssets.icons.folderOpen, + color: XfjDesignTokens.muted, + size: 18.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + label == 'manual' ? '手动快照' : label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '${files.length}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ...files.map( + (file) => _LogTreeFileTile( + file: file, + selected: selectedPath == file.path, + onTap: () => onSelected(file), + ), + ), + ], + ), + ); + } +} + +class _LogTreeFileTile extends StatelessWidget { + const _LogTreeFileTile({ + required this.file, + required this.selected, + required this.onTap, + }); + + final AppLogFile file; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Semantics( + button: true, + selected: selected, + label: '${file.isCurrent ? '当前日志,' : ''}${file.name}', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h), + child: Material( + color: selected ? XfjDesignTokens.primarySoft : Colors.transparent, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: Container( + constraints: BoxConstraints(minHeight: 56.h), + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 7.h), + child: Row( + children: [ + FIcon( + FAssets.icons.fileText, + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + size: 19.sp, + ), + SizedBox(width: 9.w), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + if (file.isCurrent) ...[ + SizedBox(width: 6.w), + Container( + padding: EdgeInsets.symmetric( + horizontal: 5.w, + vertical: 1.h, + ), + decoration: const BoxDecoration( + color: Color(0xFFECFBF3), + borderRadius: BorderRadius.all( + Radius.circular(999), + ), + ), + child: Text( + '当前', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ], + ), + SizedBox(height: 2.h), + Text( + file.exists + ? '${_formatBytes(file.fileSize)} · ${_formatTreeTime(file.modifiedAt)}' + : '等待生成', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _CompactIconButton extends StatelessWidget { + const _CompactIconButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + final String label; + final SvgAsset icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return Semantics( + button: true, + enabled: enabled, + label: label, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: SizedBox( + width: 48.w, + height: 48.h, + child: Center( + child: FIcon( + icon, + color: XfjDesignTokens.primary, + size: 19.sp, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _CompactActionButton extends StatelessWidget { + const _CompactActionButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + final String label; + final SvgAsset icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final enabled = onTap != null; + return Semantics( + button: true, + enabled: enabled, + label: label, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: Container( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 13.w), + decoration: BoxDecoration( + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon(icon, color: XfjDesignTokens.ink, size: 18.sp), + SizedBox(width: 7.w), + Text( + label, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _CurrentLogBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h), + decoration: BoxDecoration( + color: const Color(0xFFECFBF3), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '当前', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _LogPreviewState extends StatelessWidget { + const _LogPreviewState({ + required this.icon, + required this.message, + this.actionLabel, + this.onTap, + }); + + final SvgAsset icon; + final String message; + final String? actionLabel; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon(icon, color: XfjDesignTokens.muted, size: 44.sp), + SizedBox(height: 12.h), + Text( + message, + textAlign: TextAlign.center, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + if (actionLabel != null && onTap != null) ...[ + SizedBox(height: 16.h), + SettingActionButton( + label: actionLabel!, + icon: FAssets.icons.refreshCw, + onTap: onTap!, + ), + ], + ], + ), + ); + } +} + +String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; +} + +String _formatTime(DateTime? time) { + if (time == null) return '尚未生成'; + return DateUtil.formatDate(time, format: DateFormats.full); +} + +String _formatTreeTime(DateTime? time) { + if (time == null) return '未知时间'; + return DateUtil.formatDate(time, format: DateFormats.mo_d_h_m); +} + +Color _lineColor(String line) { + final value = line.toLowerCase(); + if (value.contains('error') || value.contains('失败')) { + return XfjDesignTokens.danger; + } + if (value.contains('warning') || value.contains('警告')) { + return const Color(0xFF9A6700); + } + return XfjDesignTokens.ink; +} + +Color _lineBackground(String line) { + final value = line.toLowerCase(); + if (value.contains('error') || value.contains('失败')) { + return const Color(0xFFFFF5F4); + } + if (value.contains('warning') || value.contains('警告')) { + return const Color(0xFFFFFBEB); + } + return Colors.transparent; +} diff --git a/lib/pages/setting/widgets/setting_meal_periods.dart b/lib/pages/setting/widgets/setting_meal_periods.dart new file mode 100644 index 0000000..5cee4c4 --- /dev/null +++ b/lib/pages/setting/widgets/setting_meal_periods.dart @@ -0,0 +1,403 @@ +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, + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: '每日餐段', + 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, + ) + : 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, + ), + ), + SizedBox(height: 20.h), + Wrap( + alignment: WrapAlignment.end, + spacing: 12.w, + runSpacing: 12.h, + children: [ + SettingActionButton( + label: '恢复默认', + icon: FAssets.icons.rotateCcw, + enabled: !saving, + onTap: _restoreDefaults, + ), + SettingActionButton( + label: saving ? '保存中...' : '保存配置', + icon: FAssets.icons.save, + primary: true, + enabled: !saving && dirty && error == null, + onTap: _save, + ), + ], + ), + ], + ), + ); + }); + } + + /// 将草稿恢复为产品默认三餐时间,不立即写入本机设置。 + 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 + .width(double.infinity) + .padding(horizontal: 24.w, vertical: 20.h) + .constrained(minHeight: 124.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(280.w), + SizedBox(width: 24.w), + _startField().expanded(), + SizedBox(width: 16.w), + Text( + '至', + maxLines: 1, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(width: 16.w), + _endField().expanded(), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center); + } + + /// 构建较窄内容区使用的纵向布局,字段占满整行并保持可触控点击高度。 + Widget _verticalContent(BuildContext context) => [ + _mealIdentity(context), + SizedBox(height: 12.h), + _startField(), + SizedBox(height: 12.h), + _endField(), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + + /// 构建只包含图标与餐段名称的标识,餐段编码不再出现在界面中。 + Widget _mealIdentity(BuildContext context) { + final theme = FTheme.of(context); + return [ + FIcon( + period.hourType.icon, + size: 28.sp, + color: XfjDesignTokens.primary, + ).center().width(56.w).height(56.w).decorated( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + SizedBox(width: 16.w), + Text( + period.hourType.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl.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, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + const Spacer(), + DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + // 不用 isExpanded/isDense:按钮按最宽选项自适应宽高, + // 避免 xl 字号在固定高度或压缩宽度里被裁切。 + menuMaxHeight: 560.h, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + icon: FIcon( + FAssets.icons.chevronDown, + size: 24.sp, + color: XfjDesignTokens.muted, + ), + iconSize: 24.sp, + style: theme.typography.xl.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, + ), + ), + ) + .toList(growable: false), + onChanged: enabled + ? (next) => next == null ? null : onChanged(next) + : null, + ), + ), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 20.w) + .constrained(minHeight: 84.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/supermarket_order/view.dart b/lib/pages/supermarket_order/view.dart index cb47d8e..7a637a7 100644 --- a/lib/pages/supermarket_order/view.dart +++ b/lib/pages/supermarket_order/view.dart @@ -17,15 +17,12 @@ class SupermarketOrderPage extends GetView { // ************************************************************************** // 头部 - Widget _buildHeader(double headerHeight) { + Widget _buildHeader() { final context = Get.context!; final theme = FTheme.of(context); + // 标题与返回入口由终端外壳顶栏统一提供,这里只保留分类、搜索与工具条, + // 紧贴外壳顶栏排布,不再为系统状态栏让位预留 padding。 return [ - appBack( - context, - height: headerHeight, - onTap: () => Get.offAndToNamed(RouteNames.panel), - ), Obx( () => orderRowClassify( context, @@ -71,7 +68,11 @@ class SupermarketOrderPage extends GetView { onPress: () => controller.doPage(), ), ].toRowSpace(space: 10.w).padding(horizontal: 10.w), - ].toRow(crossAxisAlignment: CrossAxisAlignment.center).decorated( + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .paddingVertical(8.h) + .paddingLeft(15.w) + .decorated( color: theme.colorScheme.background, boxShadow: [ BoxShadow( @@ -337,13 +338,11 @@ class SupermarketOrderPage extends GetView { @override Widget build(BuildContext context) { - final height = 90; return GetBuilder( - init: SupermarketOrderController(), id: "supermarket_order", builder: (_) { return FScaffold( - header: _buildHeader(height.h).height(height.h).paddingVertical(30.h), + header: _buildHeader(), content: _buildView(context), contentPad: true, resizeToAvoidBottomInset: false, diff --git a/lib/pages/take_food/view.dart b/lib/pages/take_food/view.dart index d548eea..cef1bda 100644 --- a/lib/pages/take_food/view.dart +++ b/lib/pages/take_food/view.dart @@ -140,11 +140,11 @@ class TakeFoodPage extends GetView { @override Widget build(BuildContext context) { return GetBuilder( - init: TakeFoodController(), id: "take_food", builder: (_) { return FScaffold( - header: appDefaultNav(context, title: "核销"), + // 标题与返回入口由终端外壳顶栏统一提供。 + header: FHeader(title: SizedBox.shrink()), content: _buildView(context).marginOnly(top: 20.h), resizeToAvoidBottomInset: false, ); diff --git a/lib/pages/terminal/view.dart b/lib/pages/terminal/view.dart new file mode 100644 index 0000000..11c9484 --- /dev/null +++ b/lib/pages/terminal/view.dart @@ -0,0 +1,332 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/canteen_order_total/controller.dart'; +import 'package:pj_supermarket_cloud/pages/canteen_order_total/view.dart'; +import 'package:pj_supermarket_cloud/pages/canteen_product/controller.dart'; +import 'package:pj_supermarket_cloud/pages/canteen_product/view.dart'; +import 'package:pj_supermarket_cloud/pages/canteen_reserve/controller.dart'; +import 'package:pj_supermarket_cloud/pages/canteen_reserve/view.dart'; +import 'package:pj_supermarket_cloud/pages/card_active/controller.dart'; +import 'package:pj_supermarket_cloud/pages/card_active/view.dart'; +import 'package:pj_supermarket_cloud/pages/order_history/controller.dart'; +import 'package:pj_supermarket_cloud/pages/order_history/view.dart'; +import 'package:pj_supermarket_cloud/pages/order_history_detail/controller.dart'; +import 'package:pj_supermarket_cloud/pages/order_history_detail/view.dart'; +import 'package:pj_supermarket_cloud/pages/setting/controller.dart'; +import 'package:pj_supermarket_cloud/pages/setting/face_manage_controller.dart'; +import 'package:pj_supermarket_cloud/pages/setting/face_setting_controller.dart'; +import 'package:pj_supermarket_cloud/pages/setting/view.dart'; +import 'package:pj_supermarket_cloud/pages/supermarket_order/controller.dart'; +import 'package:pj_supermarket_cloud/pages/supermarket_order/view.dart'; +import 'package:pj_supermarket_cloud/pages/take_food/controller.dart'; +import 'package:pj_supermarket_cloud/pages/take_food/view.dart'; + +/// 终端一级路由,仅创建一次公共外壳,业务页面通过内层 Navigator 切换。 +/// +/// 本项目业务入口统一放在 panel 主菜单页,外壳只保留顶部状态栏; +/// 内层路由继续使用独立 Binding,保证 Controller、扫码监听、硬件资源和副屏回调 +/// 仍跟随各业务页面创建与释放,布局复用不能改变原有生命周期边界。 +class TerminalPage extends StatefulWidget { + /// 创建承载终端顶栏与独立业务导航栈的页面。 + const TerminalPage({super.key}); + + /// 创建持有实例级 Navigator Key 与路由同步状态的页面状态。 + @override + State createState() => _TerminalPageState(); +} + +/// 终端外壳的路由状态,确保新旧终端页重叠退场时各自持有独立 Navigator Key。 +class _TerminalPageState extends State { + /// 当前页面实例的导航所有权标识,用于避免旧实例销毁时误解绑新实例。 + final Object _navigatorOwner = Object(); + + /// 当前页面实例独占的终端工作区 Navigator Key。 + late final GlobalKey _navigatorKey; + + /// 终端首次创建时 panel 通过 Get.arguments 传入的二级路由。 + late final String? _requestedRoute; + + /// 当前终端页面首次创建时应装载的二级路由。 + late final String _initialRoute; + + /// 当前实际显示的终端二级路由配置。 + late _TerminalRouteConfig _currentConfig; + + /// 是否已完成初始路由解析;解析前仅展示轻量加载占位。 + bool _ready = false; + + /// 控制路由切换遮罩最短展示时长的计时器。 + Timer? _routeLoadingTimer; + + /// 是否显示阻止重复操作的短时路由切换遮罩。 + bool _routeLoading = false; + + /// 是否已经安排 Navigator 路由状态的帧后同步。 + bool _routeStateSyncScheduled = false; + + /// 同一帧内最后一次收到的终端二级路由名称。 + String? _pendingRouteName; + + /// 登记实例级导航 Key,并解析 panel 请求或按设备类型选择的初始工作区。 + @override + void initState() { + super.initState(); + _navigatorKey = TerminalNavigationTool.attachNavigator(_navigatorOwner); + // GetX 的路由参数只在路由装载初期可靠,必须在任何 await 之前同步取出。 + _requestedRoute = Get.arguments is String ? Get.arguments as String : null; + unawaited(_resolveInitialRoute()); + } + + /// 解析初始二级路由:panel 指定优先,未指定时按系统类型装载对应收银页。 + Future _resolveInitialRoute() async { + var initial = _requestedRoute ?? ''; + if (!_terminalRoutes.containsKey(initial)) { + final sysSetting = await AppSettingTool.getSysSetting(); + initial = sysSetting.sysType == SysType.SUPERMARKET + ? RouteNames.supermarketProduct + : RouteNames.canteenProduct; + } + if (!mounted) return; + setState(() { + _initialRoute = initial; + _currentConfig = _terminalRoutes[initial]!; + _ready = true; + }); + } + + /// 释放计时器和当前实例的导航登记。 + @override + void dispose() { + _routeLoadingTimer?.cancel(); + TerminalNavigationTool.detachNavigator(_navigatorOwner); + super.dispose(); + } + + /// 构建常驻终端外壳和由实例级 Key 管理的业务 Navigator。 + @override + Widget build(BuildContext context) { + if (!_ready) { + return const ColoredBox( + color: XfjDesignTokens.canvas, + child: Center( + child: SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator( + strokeWidth: 3, + color: XfjDesignTokens.primary, + ), + ), + ), + ); + } + return TerminalShell( + title: _currentConfig.title, + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: XfjDesignTokens.canvas, + child: Navigator( + key: _navigatorKey, + initialRoute: _initialRoute, + // 内层路由名称包含 /terminal 前缀,默认生成逻辑会额外创建 / 和 + // /terminal 中间路由;这里只创建目标页,避免误装载收银页及其 Binding。 + onGenerateInitialRoutes: (_, initialRoute) => [ + _generateRoute(RouteSettings(name: initialRoute)), + ], + observers: [_TerminalRouteObserver(_handleRouteChanged)], + onGenerateRoute: _generateRoute, + ), + ), + if (_routeLoading) const _TerminalRouteLoading(), + ], + ), + ); + } + + /// 根据终端二级路由配置创建带独立 Binding 的无动画页面路由。 + Route _generateRoute(RouteSettings settings) { + final config = _terminalRoutes[settings.name] ?? + _terminalRoutes[RouteNames.supermarketProduct]!; + return GetPageRoute( + routeName: config.name, + settings: RouteSettings(name: config.name, arguments: settings.arguments), + page: config.page, + binding: config.binding, + transition: Transition.noTransition, + transitionDuration: Duration.zero, + ); + } + + /// 接收 Navigator 的真实路由变化,并在帧后刷新标题与切换遮罩。 + void _handleRouteChanged(String? routeName) { + TerminalNavigationTool.handleRouteChanged(_navigatorOwner, routeName); + if (routeName == null || !_terminalRoutes.containsKey(routeName)) return; + _pendingRouteName = routeName; + if (_routeStateSyncScheduled) return; + _routeStateSyncScheduled = true; + // Navigator 会在自身构建和历史栈刷新期间通知 Observer,父页面状态必须延后更新。 + WidgetsBinding.instance.addPostFrameCallback((_) { + _routeStateSyncScheduled = false; + if (!mounted) return; + final pendingRouteName = _pendingRouteName; + _pendingRouteName = null; + final config = _terminalRoutes[pendingRouteName]; + if (config == null || config == _currentConfig) return; + _routeLoadingTimer?.cancel(); + setState(() { + _currentConfig = config; + _routeLoading = true; + }); + // Loading 只遮住二级路由重建的短暂帧,页面自己的接口加载状态仍由 Controller 负责。 + _routeLoadingTimer = Timer(const Duration(milliseconds: 180), () { + if (mounted) setState(() => _routeLoading = false); + }); + }); + } +} + +class _TerminalRouteLoading extends StatelessWidget { + const _TerminalRouteLoading(); + + @override + Widget build(BuildContext context) { + return const AbsorbPointer( + child: ColoredBox( + color: XfjDesignTokens.canvas, + child: Center( + child: SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator( + strokeWidth: 3, + color: XfjDesignTokens.primary, + ), + ), + ), + ), + ); + } +} + +class _TerminalRouteObserver extends NavigatorObserver { + _TerminalRouteObserver(this.onChanged); + + final ValueChanged onChanged; + + @override + void didPush(Route route, Route? previousRoute) { + onChanged(route.settings.name); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + onChanged(newRoute?.settings.name); + } + + @override + void didPop(Route route, Route? previousRoute) { + onChanged(previousRoute?.settings.name); + } +} + +class _TerminalRouteConfig { + const _TerminalRouteConfig({ + required this.name, + required this.title, + required this.page, + this.binding, + }); + + final String name; + final String title; + final GetPageBuilder page; + final Bindings? binding; +} + +final Map _terminalRoutes = { + RouteNames.supermarketProduct: _TerminalRouteConfig( + name: RouteNames.supermarketProduct, + title: '超市收银', + page: () => const SupermarketOrderPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => SelectPayTypeDialogController(), fenix: true); + Get.lazyPut(() => SteelyardController(), fenix: true); + Get.lazyPut(() => SupermarketOrderController(), fenix: true); + }), + ), + RouteNames.canteenProduct: _TerminalRouteConfig( + name: RouteNames.canteenProduct, + title: '点餐收银', + page: () => const CanteenProductPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => SelectPayTypeDialogController(), fenix: true); + Get.lazyPut(() => CanteenProductController(), fenix: true); + }), + ), + RouteNames.canteenReserve: _TerminalRouteConfig( + name: RouteNames.canteenReserve, + title: '预订管理', + page: () => const CanteenReservePage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => CanteenReserveController(), fenix: true); + }), + ), + RouteNames.takeFood: _TerminalRouteConfig( + name: RouteNames.takeFood, + title: '核销', + page: () => const TakeFoodPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => TakeFoodController(), fenix: true); + }), + ), + RouteNames.canteenOrderTotal: _TerminalRouteConfig( + name: RouteNames.canteenOrderTotal, + title: '交易统计', + page: () => const CanteenOrderTotalPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => CanteenOrderTotalController(), fenix: true); + }), + ), + RouteNames.orderHistory: _TerminalRouteConfig( + name: RouteNames.orderHistory, + title: '交易查询', + page: () => const OrderHistoryPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => OrderHistoryController(), fenix: true); + }), + ), + RouteNames.orderHistoryDetail: _TerminalRouteConfig( + name: RouteNames.orderHistoryDetail, + title: '交易详情', + page: () => const OrderHistoryDetailPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => OrderHistoryController(), fenix: true); + Get.lazyPut(() => OrderHistoryDetailController(), fenix: true); + }), + ), + RouteNames.cardActive: _TerminalRouteConfig( + name: RouteNames.cardActive, + title: '开卡管理', + page: () => const CardActivePage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => SettingController(), fenix: true); + Get.lazyPut(() => CardActiveController(), fenix: true); + }), + ), + RouteNames.setting: _TerminalRouteConfig( + name: RouteNames.setting, + title: '设置', + page: () => const SettingPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => SettingController(), fenix: true); + Get.lazyPut(() => FaceManageController(), fenix: true); + Get.lazyPut(() => FaceSettingController(), fenix: true); + }), + ), +}; diff --git a/pubspec.lock b/pubspec.lock index cf1fea1..076a952 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -466,6 +466,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.5" + flutter_html: + dependency: "direct main" + description: + name: flutter_html + sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" + url: "https://pub.dev" + source: hosted + version: "3.0.0" flutter_libserialport: dependency: "direct main" description: @@ -610,13 +618,13 @@ packages: source: hosted version: "0.2.0" html: - dependency: transitive + dependency: "direct overridden" description: name: html - sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" url: "https://pub.dev" source: hosted - version: "0.15.7" + version: "0.15.5" http: dependency: "direct main" description: @@ -737,6 +745,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + list_counter: + dependency: transitive + description: + name: list_counter + sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 + url: "https://pub.dev" + source: hosted + version: "1.0.2" logger: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5bb0076..cc0e41c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,8 @@ dependencies: crypto: ^3.0.6 # 应用包信息(自动更新/日志上传) package_info_plus: 8.2.1 + # 版本管理更新内容 HTML 渲染 + flutter_html: 3.0.0 dev_dependencies: flutter_test: sdk: flutter @@ -99,10 +101,14 @@ dev_dependencies: dependency_overrides: drago_usb_printer: path: packages/drago_usb_printer + # flutter_html 3.0.0 以实现内部导入方式调用 html 包顶层 matches(), + # 该函数在 html 0.15.7 中被移除导致编译失败;钉回基线项目同款 0.15.5。 + html: 0.15.5 flutter: uses-material-design: true assets: - assets/images/ + - assets/images/customer_welcome/ - assets/crt/