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

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+14
View File
@@ -29,6 +29,20 @@ abstract class ProductApi {
.toList();
}
/// 按闭区间查询多日食堂菜谱商品,响应结构与单日接口保持一致。
static Future<List<ServiceProduct>?> canteenProductsByDateRange(
Map<String, dynamic> requestBody,
) async {
final res = await HttpManage().dio.post(
"/canteen/canteenDailyMenu/ad/getCanteenListByDateRange",
data: requestBody,
);
if (res.data == null) return null;
return res.data
?.map<ServiceProduct>((e) => ServiceProduct.fromJson(e))
.toList();
}
/// 超市分类
static Future<List<ServiceClassify>?> supermarketClassify(
dynamic pa,
@@ -9,8 +9,10 @@ enum SettingMenuType implements BaseEnum {
backup("40", "资源备份"),
faceSetting("50", "人脸管理"),
productSetting("60", "商品管理"),
logPreview("62", "日志预览"),
versionSetting("70", "版本管理"),
facePluginSetting("80", "人脸设置"),
mealPeriodSetting("90", "餐段时间配置"),
;
@override
+23 -23
View File
@@ -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';
}
+10 -75
View File
@@ -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());
}),
),
];
}
+6 -2
View File
@@ -82,8 +82,12 @@ class AppMqTool {
mqUpdateEventBus.fire(MqtActionEnum.CONFIG);
break;
case MqtActionEnum.CANTEEN_PRODUCT:
await SyncProductTool.syncProduct();
mqUpdateEventBus.fire(MqtActionEnum.CANTEEN_PRODUCT);
// 食堂变更需要让当前点餐页及时刷新,但只查询收到消息时的当天,
// 避免 MQTT 拉取未来一周菜谱。
final success = await SyncProductTool.syncTodayCanteenProduct();
if (success) {
mqUpdateEventBus.fire(MqtActionEnum.CANTEEN_PRODUCT);
}
break;
case MqtActionEnum.SUPERMARKET_PRODUCT:
await SyncProductTool.syncProduct();
@@ -0,0 +1,70 @@
import 'dart:async';
import 'package:get/get.dart';
/// 管理设置与退出共用的临时解锁会话。
///
/// 会话只保存在当前应用进程中,并使用绝对过期时间校验,避免切页、卡顿或应用进入后台
/// 导致简单减秒计时失真。退出登录时会主动锁定,不把管理权限带到下一次登录。
class TerminalAdminSessionControl extends GetxController {
static const Duration sessionDuration = Duration(minutes: 5);
final remainingSeconds = 0.obs;
DateTime? _expiresAt;
Timer? _timer;
bool get isUnlocked {
final expiresAt = _expiresAt;
return expiresAt != null && DateTime.now().isBefore(expiresAt);
}
String get remainingLabel {
final seconds = remainingSeconds.value.clamp(
0,
sessionDuration.inSeconds,
);
final minutesPart = (seconds ~/ 60).toString().padLeft(2, '0');
final secondsPart = (seconds % 60).toString().padLeft(2, '0');
return '$minutesPart:$secondsPart';
}
void unlock() {
_expiresAt = DateTime.now().add(sessionDuration);
_syncRemainingTime();
_timer?.cancel();
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => _syncRemainingTime(),
);
}
void lock() {
_expiresAt = null;
_timer?.cancel();
_timer = null;
if (remainingSeconds.value != 0) remainingSeconds.value = 0;
}
void _syncRemainingTime() {
final expiresAt = _expiresAt;
if (expiresAt == null) {
lock();
return;
}
final milliseconds = expiresAt.difference(DateTime.now()).inMilliseconds;
if (milliseconds <= 0) {
lock();
return;
}
// 向上取整可让刚完成验证的第一帧稳定显示 05:00。
final seconds = (milliseconds / 1000).ceil();
if (remainingSeconds.value != seconds) remainingSeconds.value = seconds;
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
}
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 读取终端顶部隐藏设备集合的方法签名,生产环境从本机 `sysJson` 恢复。
typedef HiddenTerminalDeviceStatusesLoader = Future<Set<TerminalDeviceType>>
Function();
/// 保存终端顶部隐藏设备集合的方法签名,每次接收一份不可变含义的完整快照。
typedef HiddenTerminalDeviceStatusesSaver = Future<void> Function(
Set<TerminalDeviceType> hiddenDevices,
);
/// 管理终端顶部设备状态的实时显隐,并串行持久化本机界面偏好。
class TerminalDeviceStatusControl extends GetxController {
/// 创建应用级逐项显隐控制器;可注入读写方法以隔离 Widget 测试与真实数据库。
TerminalDeviceStatusControl({
HiddenTerminalDeviceStatusesLoader? loadHiddenDevices,
HiddenTerminalDeviceStatusesSaver? saveHiddenDevices,
}) : _loadHiddenDevices =
loadHiddenDevices ?? AppSettingTool.getHiddenTerminalDeviceStatuses,
_saveHiddenDevices =
saveHiddenDevices ?? AppSettingTool.setHiddenTerminalDeviceStatuses;
/// 顶部需要隐藏的设备集合;默认空集合保证首次启动和旧总开关升级后五项全部显示。
final hiddenHeaderDevices = <TerminalDeviceType>{}.obs;
/// 从本机设置读取初始隐藏集合,测试可注入内存实现。
final HiddenTerminalDeviceStatusesLoader _loadHiddenDevices;
/// 将最新隐藏集合写入本机设置,测试可注入内存实现。
final HiddenTerminalDeviceStatusesSaver _saveHiddenDevices;
/// 保存期间是否又发生显隐切换,用于在当前写入后继续保存最终状态。
bool _pendingPersist = false;
/// 当前串行保存任务;为空表示没有设置写入正在执行。
Future<void>? _persistFuture;
/// 是否至少有一个设备需要显示;调用方应在 `Obx` 内读取以响应逐项开关变化。
bool get hasVisibleHeaderDevices =>
hiddenHeaderDevices.length < TerminalDeviceType.values.length;
/// 判断指定设备是否显示在顶部;不在隐藏集合中的新增设备默认显示。
bool isHeaderDeviceVisible(TerminalDeviceType type) =>
!hiddenHeaderDevices.contains(type);
/// 启动时恢复本机隐藏集合;读取失败时清空集合,避免升级后丢失设备告警入口。
Future<void> initAsync() async {
try {
hiddenHeaderDevices.assignAll(await _loadHiddenDevices());
} catch (error, stack) {
hiddenHeaderDevices.clear();
logger.e('|终端设备状态|恢复顶部显隐失败error:|$error|stack:|$stack|');
}
}
/// 立即切换指定设备并串行保存完整集合;连续操作时返回覆盖全部待写入状态的同一任务。
Future<void> setHeaderDeviceVisible(
TerminalDeviceType type,
bool visible,
) {
if (isHeaderDeviceVisible(type) != visible) {
if (visible) {
hiddenHeaderDevices.remove(type);
} else {
hiddenHeaderDevices.add(type);
}
_pendingPersist = true;
}
final running = _persistFuture;
if (running != null) return running;
if (!_pendingPersist) return Future<void>.value();
late final Future<void> future;
future = _flushPersist().whenComplete(() {
if (identical(_persistFuture, future)) _persistFuture = null;
});
_persistFuture = future;
return future;
}
/// 逐次写入保存期间产生的最新快照,保证较慢的旧写入不会覆盖最终选择。
Future<void> _flushPersist() async {
while (_pendingPersist) {
_pendingPersist = false;
final hiddenDevices = Set<TerminalDeviceType>.from(hiddenHeaderDevices);
try {
await _saveHiddenDevices(hiddenDevices);
} catch (error, stack) {
logger.e('|终端设备状态|保存顶部显隐失败error:|$error|stack:|$stack|');
Toast.error(msg: '设备状态显示设置保存失败,请重试');
}
}
}
}
@@ -0,0 +1,43 @@
import 'package:get/get.dart';
/// 执行一次终端唤醒身份校验,返回是否允许解除睡眠遮罩。
typedef TerminalWakeVerifier = Future<bool> Function();
/// 管理主屏睡眠遮罩及唤醒验证的应用级状态。
///
/// 睡眠仅阻止主屏查看和触摸,不切换路由、不销毁页面 Controller,也不暂停串口、
/// 人脸、网络请求或副屏通信。状态仅保存在当前进程,设备重新启动后不自动进入睡眠。
class TerminalSleepControl extends GetxController {
/// 创建一份默认处于唤醒状态的主屏睡眠控制器。
TerminalSleepControl();
/// 主屏是否正在展示全屏睡眠遮罩。
final isSleeping = false.obs;
/// 是否已有唤醒密码校验正在进行,防止快速点击叠加多个密码弹窗。
bool _wakeVerificationInProgress = false;
/// 立即进入睡眠状态;重复调用保持幂等且不影响当前业务页面生命周期。
void enterSleep() {
if (!isSleeping.value) isSleeping.value = true;
}
/// 通过调用方提供的密码校验请求唤醒主屏。
///
/// 校验取消、失败或重复提交时继续保持睡眠;校验过程抛出的异常会向上返回,
/// 由界面层统一记录并提示,同时保证下一次唤醒仍可重新发起。
Future<bool> requestWake(TerminalWakeVerifier verifier) async {
if (!isSleeping.value) return true;
if (_wakeVerificationInProgress) return false;
_wakeVerificationInProgress = true;
try {
final verified = await verifier();
if (!verified) return false;
isSleeping.value = false;
return true;
} finally {
_wakeVerificationInProgress = false;
}
}
}
@@ -0,0 +1,162 @@
import 'package:common_utils/common_utils.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 终端工作区的统一导航守卫。
///
/// 统一终端导航仍需保留设备绑定、联网限制和设置密码校验,
/// 否则视觉重构会意外放开原有业务入口。
abstract final class TerminalNavigationTool {
/// 当前仍负责接收菜单跳转的终端页面实例标识。
static Object? _navigatorOwner;
/// 当前终端页面实例持有的导航 Key;页面重叠退场时不同实例必须使用不同 Key。
static GlobalKey<NavigatorState>? _navigatorKey;
/// 当前活跃终端实例实际显示的工作区路由,用于拦截重复返回同一菜单。
static String? _currentWorkspaceRoute;
/// 管理密码弹层是否正在验证,用于阻止重复创建弹层。
static bool _verificationInProgress = false;
/// 为新挂载的终端页面创建并登记实例级导航 Key。
///
/// GetX 的 `nestedKey(id)` 会永久复用同一 GlobalKey;根路由替换期间新旧终端页
/// 可能短暂共存,因此必须由各页面实例持有独立 Key,再只把菜单操作发给最新实例。
static GlobalKey<NavigatorState> attachNavigator(Object owner) {
final key = GlobalKey<NavigatorState>(
debugLabel: 'Terminal workspace navigator',
);
_navigatorOwner = owner;
_navigatorKey = key;
_currentWorkspaceRoute = null;
return key;
}
/// 终端页面销毁时解除登记;旧页面延迟销毁不得清除后来页面的导航 Key。
static void detachNavigator(Object owner) {
if (!identical(_navigatorOwner, owner)) return;
_navigatorOwner = null;
_navigatorKey = null;
_currentWorkspaceRoute = null;
}
/// 接收活跃终端 Navigator 的真实路由,旧实例的延迟回调会被所有权校验丢弃。
static void handleRouteChanged(Object owner, String? route) {
if (!identical(_navigatorOwner, owner) || route == null) return;
_currentWorkspaceRoute = route;
}
/// 校验入口权限后切换到指定终端工作区路由。
static Future<void> open(String route) async {
if (_requiresAdminUnlock(route)) {
if (!await _verifyAdminSession(actionLabel: '进入设置')) return;
_replaceWorkspace(route);
return;
}
if (!await _checkIsBind()) return;
if (_requiresNetwork(route) && !AppNetworkTool.isAutoNetwork()) {
Toast.error(msg: '请先连接网络');
return;
}
_replaceWorkspace(route);
}
/// 仅替换终端二级页面,保留一级外壳及应用级资源。
static void replaceWorkspace(String route) => _replaceWorkspace(route);
/// 在终端工作区历史栈之上压入目标二级页面(如交易详情),不清空现有栈。
static void pushWorkspace(String route, {Object? arguments}) {
final navigator = _navigatorKey?.currentState;
if (navigator == null || !navigator.mounted) return;
navigator.pushNamed<void>(route, arguments: arguments);
}
/// 返回 panel 主菜单页;终端之上还叠有根路由页面时先整页回到主菜单。
static void backToPanel() {
if (Get.currentRoute == RouteNames.terminal) {
Get.back();
return;
}
Get.offAndToNamed(RouteNames.panel);
}
/// 清空当前终端内部历史并装载目标页面,不影响根 Navigator 的终端外壳。
static void _replaceWorkspace(String route) {
final navigator = _navigatorKey?.currentState;
if (navigator == null ||
!navigator.mounted ||
_currentWorkspaceRoute == route) {
return;
}
navigator.pushNamedAndRemoveUntil<void>(route, (_) => false);
}
/// 退出前统一执行管理密码校验,避免顶栏与主菜单出口出现不同的安全规则。
static Future<void> logout() async {
if (!await _verifyAdminSession(actionLabel: '退出登录')) return;
Get.find<TerminalAdminSessionControl>().lock();
if (AppNetworkTool.isAutoNetwork()) {
await AppSettingTool.setUserSetting(UserSetting());
}
Get.offAllNamed(RouteNames.login);
}
/// 顶栏锁状态可主动发起验证,不需要先进入设置或退出流程。
static Future<void> unlockAdminSession() async {
await _verifyAdminSession(actionLabel: '解锁管理操作');
}
/// 复用已解锁会话或弹出管理密码校验,返回本次管理操作是否获准。
static Future<bool> _verifyAdminSession({
required String actionLabel,
}) async {
final session = Get.find<TerminalAdminSessionControl>();
if (session.isUnlocked) return true;
session.lock();
// 快速连续点击设置与退出时只允许存在一个密码弹层,避免重复 Overlay 和 GlobalKey。
if (_verificationInProgress) return false;
final context = Get.context;
if (context == null) return false;
_verificationInProgress = true;
try {
final verified = await terminalAdminPasswordDialog(
context,
actionLabel: actionLabel,
);
// 弹层取消与验证成功都要等退场结束,避免快速再次点击叠加两个 Overlay。
await Future<void>.delayed(const Duration(milliseconds: 200));
if (!verified) return false;
session.unlock();
return true;
} finally {
_verificationInProgress = false;
}
}
/// 设置页承载管理与配置能力,必须先通过管理密码校验。
static bool _requiresAdminUnlock(String route) {
return route == RouteNames.setting;
}
/// 判断目标业务是否必须在服务网络可用时进入。
static bool _requiresNetwork(String route) {
return route == RouteNames.canteenReserve ||
route == RouteNames.takeFood ||
route == RouteNames.canteenOrderTotal;
}
/// 检查终端是否已经绑定服务商户,未绑定时统一提示并拒绝进入业务页。
static Future<bool> _checkIsBind() async {
final setting = await AppSettingTool.getSysSetting();
if (ObjectUtil.isEmpty(setting.serviceMerchantId)) {
Toast.error(msg: '请前往设置绑定设备');
return false;
}
return true;
}
}
+4
View File
@@ -3,6 +3,9 @@ library;
export './app/control/app_control.dart';
export './app/control/product_hour_type_control.dart';
export './app/control/app_theme_control.dart';
export './app/control/terminal_admin_session_control.dart';
export './app/control/terminal_device_status_control.dart';
export './app/control/terminal_sleep_control.dart';
export './app/app_face_tool.dart';
export './app/app_device_status_tool.dart';
@@ -15,6 +18,7 @@ export './app/app_price_tool.dart';
export './app/app_setting_tool.dart';
export './app/app_sys_tool.dart';
export './app/app_user_tool.dart';
export './app/terminal_navigation_tool.dart';
export './discounts/discounts_tool.dart';
+133 -24
View File
@@ -195,35 +195,22 @@ abstract class SyncProductTool {
}
}
/// 同步商品
/// 按终端业态执行常规商品同步。
///
/// 食堂常规同步保留今天起连续七天的缓存范围,一次范围请求完成。
/// MQTT 食堂变更和进入点餐页只刷新当天,使用 [syncTodayCanteenProduct]。
static Future<void> syncProduct() async {
final sysSetting = await AppSettingTool.getSysSetting();
final sysType = sysSetting.sysType;
try {
if (sysType == SysType.CANTEEN) {
// 获取从今天开始的所有日期时间
final DateTime now = DateTime.now();
for (int i = 0; i < 7; i++) {
final DateTime date = now.add(Duration(days: i));
final String day = DateUtil.formatDate(
date,
format: DateFormats.y_mo_d,
);
final dayDatetime = DateUtil.getDateTime(day);
final pa = {
"day": day,
"hourType": null,
"categoryId": null,
"merchantId": sysSetting.serviceMerchantId,
};
final canteenList = await ProductApi.canteenProduct(pa);
if (canteenList == null) return;
for (var v in canteenList) {
v.saleDate = dayDatetime;
}
// 更新数据
await _batchUpdateCaneenProduct(list: canteenList, dayStr: day);
}
final startDate = DateTime.now();
final success = await _syncCanteenProductForDateRange(
startDate: startDate,
endDate: startDate.add(const Duration(days: 6)),
merchantId: sysSetting.serviceMerchantId,
);
if (!success) return;
} else if (sysType == SysType.SUPERMARKET) {
// 超市
final app = Get.find<AppControl>();
@@ -273,6 +260,128 @@ abstract class SyncProductTool {
// 同步食堂商品 spu start
// **************************************************************************
/// 仅同步终端本地时区的当天食堂菜品,并在成功后记录食堂商品同步时间。
///
/// MQTT 菜品变更和进入点餐页使用此入口,避免刷新当天时额外拉取未来六天。
static Future<bool> syncTodayCanteenProduct() async {
final today = DateTime.now();
final sysSetting = await AppSettingTool.getSysSetting();
if (sysSetting.sysType != SysType.CANTEEN) return false;
try {
final success = await _syncCanteenProductForDay(
date: today,
merchantId: sysSetting.serviceMerchantId,
);
if (!success) return false;
sysSetting.canteenProductSyncTime = DateTime.now();
await AppSettingTool.setSysSetting(sysSetting);
return true;
} catch (e, stack) {
logger.e("|$_tag|同步当天食堂商品信息意外的错误error:|$e|,stack:|$stack|");
return false;
}
}
/// 请求并替换指定自然日的食堂菜品快照。
///
/// 返回 `false` 表示接口没有有效响应,本地当天旧快照会保留;
/// 空数组属于有效响应,会清空当天快照。
static Future<bool> _syncCanteenProductForDay({
required DateTime date,
required String? merchantId,
}) async {
final day = DateUtil.formatDate(date, format: DateFormats.y_mo_d);
final dayDatetime = DateUtil.getDateTime(day);
final canteenList = await ProductApi.canteenProduct({
"day": day,
"hourType": null,
"categoryId": null,
"merchantId": merchantId,
});
if (canteenList == null) return false;
if (ObjectUtil.isEmptyList(canteenList)) {
// 空数组只代表指定日期没有可售菜品,清空当天快照,不影响其他日期。
await _batchUpdateCaneenProduct(list: const [], dayStr: day);
return true;
}
for (final product in canteenList) {
// 接口按 day 查询,统一覆盖供应日期可防止异常响应把其他日期写进当天同步结果。
product.saleDate = dayDatetime;
}
await _batchUpdateCaneenProduct(list: canteenList, dayStr: day);
return true;
}
/// 一次请求指定闭区间的食堂菜谱,并按接口返回的供应日期逐日原子替换本地快照。
///
/// 返回 `false` 表示接口无有效响应或任一记录缺少合法供应日期,
/// 此时不会修改任何日期的本地数据。
static Future<bool> _syncCanteenProductForDateRange({
required DateTime startDate,
required DateTime endDate,
required String? merchantId,
}) async {
final startDay = DateUtil.formatDate(
startDate,
format: DateFormats.y_mo_d,
);
final endDay = DateUtil.formatDate(
endDate,
format: DateFormats.y_mo_d,
);
final requestMerchantId = int.tryParse(merchantId ?? '');
if (requestMerchantId == null) {
logger.w('|$_tag|范围菜谱同步缺少有效商户 ID,保留本地旧快照');
return false;
}
final canteenList = await ProductApi.canteenProductsByDateRange({
"startDate": startDay,
"endDate": endDay,
"hourType": null,
"categoryId": null,
"merchantId": requestMerchantId,
});
if (canteenList == null) return false;
final productsByDay = <String, List<ServiceProduct>>{};
var currentDate = DateTime.parse(startDay);
final lastDate = DateTime.parse(endDay);
while (!currentDate.isAfter(lastDate)) {
productsByDay[DateUtil.formatDate(
currentDate,
format: DateFormats.y_mo_d,
)] = <ServiceProduct>[];
currentDate = currentDate.add(const Duration(days: 1));
}
for (final product in canteenList) {
final saleDate = product.saleDate;
if (saleDate == null) {
logger.w('|$_tag|范围菜谱响应缺少供应日期,保留本地旧快照:${product.toJson()}');
return false;
}
final saleDay = DateUtil.formatDate(
saleDate,
format: DateFormats.y_mo_d,
);
final dayProducts = productsByDay[saleDay];
if (dayProducts == null) {
logger
.w('|$_tag|范围菜谱响应日期超出请求区间,保留本地旧快照:请求$startDay至$endDay,响应$saleDay');
return false;
}
dayProducts.add(product);
}
for (final entry in productsByDay.entries) {
await _batchUpdateCaneenProduct(
list: entry.value,
dayStr: entry.key,
);
}
return true;
}
static Future<void> _batchUpdateCaneenProduct({
required List<ServiceProduct> list,
required String dayStr,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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';
@@ -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<bool> settingPasswordDialog(BuildContext context) async {
);
return flag ?? false;
}
/// 终端顶栏管理操作(进入设置、退出登录、唤醒终端)共用的二次验证。
///
/// 校验来源与主菜单的设置入口一致:当前登录账号的登录密码。
Future<bool> terminalAdminPasswordDialog(
BuildContext context, {
required String actionLabel,
}) async {
final appUserSetting = await AppSettingTool.getUserSetting();
final result = await showAppDialog<bool>(
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,
),
],
);
}
}
+10
View File
@@ -26,6 +26,15 @@ Future<void> 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<void> checkSubScreen() async {
await app.initAsync();
await appTheme.initAsync();
await productHourType.initAsync();
await terminalDeviceStatus.initAsync();
});
break;
case "subMain":
+2 -2
View File
@@ -154,11 +154,11 @@ class CanteenOrderTotalPage extends GetView<CanteenOrderTotalController> {
@override
Widget build(BuildContext context) {
return GetBuilder<CanteenOrderTotalController>(
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,
);
+7 -4
View File
@@ -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();
+9 -13
View File
@@ -13,15 +13,12 @@ class CanteenProductPage extends GetView<CanteenProductController> {
const CanteenProductPage({super.key});
// 头部
Widget _buildHeader(double headerHeight) {
Widget _buildHeader() {
final context = Get.context!;
final theme = FTheme.of(context);
// 标题与返回入口由终端外壳顶栏统一提供,这里只保留分类与餐段工具条,
// 紧贴外壳顶栏排布,不再为系统状态栏让位预留 padding。
return <Widget>[
appBack(
context,
height: headerHeight,
onTap: () => Get.offAndToNamed(RouteNames.panel),
),
Obx(
() => orderRowClassify(
context,
@@ -32,7 +29,11 @@ class CanteenProductPage extends GetView<CanteenProductController> {
).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<CanteenProductController> {
@override
Widget build(BuildContext context) {
final height = 90;
return GetBuilder<CanteenProductController>(
init: Get.find<CanteenProductController>(),
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,
+2 -2
View File
@@ -224,11 +224,11 @@ class CanteenReservePage extends GetView<CanteenReserveController> {
@override
Widget build(BuildContext context) {
return GetBuilder<CanteenReserveController>(
init: CanteenReserveController(),
id: "canteen_reserve",
builder: (_) {
return FScaffold(
header: appDefaultNav(context, title: "预定"),
// 标题与返回入口由终端外壳顶栏统一提供。
header: FHeader(title: SizedBox.shrink()),
content: _buildView(context),
resizeToAvoidBottomInset: false,
);
+8 -11
View File
@@ -12,15 +12,12 @@ class CardActivePage extends GetView<CardActiveController> {
const CardActivePage({super.key});
// 条件
Widget _buildHeader(double height) {
Widget _buildHeader() {
final context = Get.context!;
final theme = FTheme.of(context);
// 标题与返回入口由终端外壳顶栏统一提供,这里只保留卡号搜索工具条,
// 紧贴外壳顶栏排布,不再为系统状态栏让位预留 padding。
return <Widget>[
appBack(
context,
height: height,
onTap: () => Get.offAndToNamed(RouteNames.panel),
),
const Spacer(),
FTextField(
controller: controller.carNumberC,
@@ -219,15 +216,15 @@ class CardActivePage extends GetView<CardActiveController> {
@override
Widget build(BuildContext context) {
final height = 90;
return GetBuilder<CardActiveController>(
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,
);
+1
View File
@@ -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';
-1
View File
@@ -86,7 +86,6 @@ class LoginPage extends GetView<LoginController> {
@override
Widget build(BuildContext context) {
return GetBuilder<LoginController>(
init: LoginController(),
id: "login",
builder: (_) {
return FScaffold(
+2 -2
View File
@@ -259,9 +259,9 @@ class OrderHistoryController extends GetxController {
);
}
/// 跳转到订单详情
/// 跳转到订单详情(压入终端内层工作区栈,返回时回到交易查询)
void doDetail(OrderEntity v) {
Get.toNamed(
TerminalNavigationTool.pushWorkspace(
RouteNames.orderHistoryDetail,
arguments: {'id': v.id},
);
-1
View File
@@ -258,7 +258,6 @@ class OrderHistoryPage extends GetView<OrderHistoryController> {
@override
Widget build(BuildContext context) {
return GetBuilder<OrderHistoryController>(
init: OrderHistoryController(),
id: "order_history",
builder: (_) {
return FScaffold(
+12 -4
View File
@@ -16,13 +16,21 @@ class OrderHistoryDetailController extends GetxController {
void onTap() {}
bool _argumentsLoaded = false;
/// 详情页挂在终端内层 Navigator 上,路由参数只能从 RouteSettings 读取;
/// 由页面首次构建时注入,不能依赖 GetX 根路由的 Get.arguments。
void loadArguments(Map<String, dynamic> 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<String, dynamic>;
detailId = arguments['id'] as int;
await getOrderDetail();
await getShopOrderList();
}
@override
+9 -2
View File
@@ -217,15 +217,22 @@ class OrderHistoryDetailPage extends GetView<OrderHistoryDetailController> {
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<String, dynamic>) {
controller.loadArguments(routeArguments);
}
return GetBuilder<OrderHistoryDetailController>(
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,
+12 -8
View File
@@ -87,14 +87,18 @@ class PanelController extends GetxController {
Future<void> 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();
-1
View File
@@ -50,7 +50,6 @@ class PanelPage extends GetView<PanelController> {
@override
Widget build(BuildContext context) {
return GetBuilder<PanelController>(
init: PanelController(),
id: "panel",
builder: (_) {
return FScaffold(
+45 -3
View File
@@ -29,6 +29,12 @@ class SettingController extends GetxController {
// 默认付款方式
var defaultGlobalPayTableC = FRadioSelectGroupController<String>();
/// 商品餐段时间配置;餐段校验弹框和自动切换按该时间范围判定。
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<void> 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<bool> saveProductMealPeriods(List<ProductMealPeriod> 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;
}
}
}
@@ -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<AppControl>();
/// 当前终端设置,初始化后用于确定系统类型和设备绑定状态。
late SysSetting sysSetting;
/// 当前终端业务类型,决定搜索框允许的输入方式。
Rx<SysType> sysType = SysType.CANTEEN.obs;
/// 当前分页展示的人脸本地记录。
RxList<FaceEntity> faceList = RxList();
/// 当前列表页码,从 1 开始。
RxInt currentPage = RxInt(1);
/// 当前搜索条件命中的记录总数,供分页组件计算页数。
RxInt totalPage = RxInt(0);
/// 单页展示数量,沿用现有人脸管理网格密度。
RxInt pageSize = RxInt(15);
/// 列表查询是否进行中,仅驱动列表区域加载状态。
RxBool isLoading = false.obs;
/// 最近一次列表查询失败的安全提示,不包含数据库或文件路径。
RxString errorMessage = ''.obs;
/// 质量检测或服务端删除的可恢复提示,不替代列表内容。
final qualityOperationMessage = ''.obs;
/// 当前选中的内容 Tab。
final selectedTab = FaceManageTab.all.obs;
/// Android 人脸设置中的在库质量阈值。
final libraryQualityThreshold = 0.60.obs;
/// 低质量、待检测和检测失败的实时统计。
final lowQualityCount = 0.obs;
final pendingQualityCount = 0.obs;
final failedQualityCount = 0.obs;
/// 存量质量检测的最新原生进度。
final qualityAuditProgress = Rxn<FaceQualityAuditProgress>();
/// 同步、检测和服务端删除状态,用于互斥及按钮禁用。
final isSyncing = false.obs;
final isAuditing = false.obs;
final isDeleting = false.obs;
/// 姓名搜索输入控制器,由本控制器负责释放。
final usernameC = TextEditingController();
/// FACE 增量任务成功后的列表刷新订阅,由 [onClose] 取消。
StreamSubscription<MqtActionEnum>? mqUpdateListen;
/// 原生质量检测进度订阅,由 [onClose] 释放。
StreamSubscription<FaceQualityAuditProgress>? qualityAuditListen;
/// 启动异步设置读取、首次列表查询和 FACE 完成事件监听。
@override
void onInit() {
super.onInit();
unawaited(_initialize());
}
/// 读取终端设置后加载列表;初始化失败交由列表状态统一展示。
Future<void> _initialize() async {
sysSetting = await AppSettingTool.getSysSetting();
sysType.value = sysSetting.sysType;
_listenMqCallback();
qualityAuditListen = FaceDiscernPlugs.qualityAuditStream.listen(
_handleQualityAuditProgress,
onError: (Object error, StackTrace stack) {
logger.w('|人脸管理|质量进度监听失败error:$error|stack:$stack|');
},
);
await refreshQualityThreshold();
await getFaceList();
}
/// 释放 MQTT 完成事件和输入控制器,防止退出设置页后继续刷新。
@override
void onClose() {
mqUpdateListen?.cancel();
qualityAuditListen?.cancel();
usernameC.dispose();
super.onClose();
}
/// 按当前姓名和页码读取本地人脸列表,可选显示旧页面使用的查询遮罩。
Future<void> getFaceList({bool isLoading = false}) async {
this.isLoading.value = true;
errorMessage.value = '';
if (isLoading) {
EasyLoading.show(
status: '查询中...',
maskType: EasyLoadingMaskType.black,
);
}
try {
final faceDao = appC.dataBase.getFaceDao;
String userName = usernameC.text;
if (ObjectUtil.isEmpty(userName)) userName = "-1";
final lowQuality = selectedTab.value == FaceManageTab.lowQuality;
final findCount = lowQuality
? await faceDao.findLowQualityCount(
userName,
libraryQualityThreshold.value,
)
: await faceDao.findCount(userName);
totalPage.value = findCount ?? 0;
final list = lowQuality
? await faceDao.findLowQuality(
userName,
libraryQualityThreshold.value,
pageSize.value,
(currentPage.value - 1) * pageSize.value,
)
: await faceDao.findAll(
userName,
pageSize.value,
(currentPage.value - 1) * pageSize.value,
);
faceList.value = list;
await _refreshQualityCounts(faceDao);
} catch (e, stack) {
errorMessage.value = '人脸数据加载失败,请稍后重试';
logger.e('|人脸管理|加载失败error:$e|stack:$stack|');
} finally {
this.isLoading.value = false;
if (isLoading) {
Future.delayed(const Duration(seconds: 1), () {
EasyLoading.dismiss();
});
}
}
}
/// 确认后将本部门快照或非生产全租户调试请求提交到统一人脸任务队列。
///
/// 队列会先避开支付、人脸会话和业务弹框;只有任务已经实际开始时才显示遮罩,
/// 避免遮罩本身被空闲检测识别为业务占用而导致任务无法启动。
Future<void> asyncFace({bool isAll = false}) async {
if (operationInProgress) return;
final isNetwork = AppNetworkTool.isAutoNetworkMsg();
if (!isNetwork) return;
final setting = await AppSettingTool.getSysSetting();
if (ObjectUtil.isEmpty(setting.merchantDeptId)) {
Toast.error(msg: "请先绑定设备");
return;
}
if (isAll && AppSysTool.isProd()) {
Toast.error(msg: '生产环境仅允许同步本部门人脸');
return;
}
if (AppSysTool.isProd()) {
final isActive = await AppFaceTool.isActiveSdk();
if (!isActive) {
Toast.error(msg: "人脸sdk激活失败");
return;
}
}
final isSure = await appMessageDialog(
Get.context!,
isAll ? '是否同步全租户人脸?此能力仅用于非生产调试,过程可能较长' : '是否同步本部门及子部门人脸?过程可能较长',
);
if (!isSure) return;
final scheduler = SyncCheckTool.instance;
isSyncing.value = true;
final operation = scheduler.trigger(
ScheduledTaskType.departmentFaceSync,
debugAllFaces: isAll,
triggerType: ScheduledTaskTriggerType.manual,
);
await Future<void>.delayed(Duration.zero);
var overlayVisible = false;
if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync)) {
overlayVisible = true;
EasyLoading.show(
status: '同步中...',
maskType: EasyLoadingMaskType.black,
);
}
try {
await operation;
if (scheduler.pendingTasks
.contains(ScheduledTaskType.departmentFaceSync)) {
Toast.success(msg: '人脸同步已排队,将在业务空闲后执行');
return;
}
currentPage.value = 1;
await getFaceList();
final record = scheduler.records[ScheduledTaskType.departmentFaceSync];
if (record?.success == true) {
Toast.success(msg: record?.message ?? '人脸同步完成');
} else {
Toast.error(msg: record?.message ?? '人脸同步失败,请稍后重试');
}
} catch (e, stack) {
Toast.error(msg: '同步人脸失败,请稍后重试');
logger.e('|人脸管理|同步失败error:$e|stack:$stack|');
} finally {
currentPage.value = 1;
isSyncing.value = false;
if (overlayVisible) EasyLoading.dismiss();
}
}
/// 重新读取 Android 人脸配置并按新阈值立即重查低质量分类。
Future<void> refreshQualityThreshold() async {
try {
final configuration = await FaceDiscernPlugs.getRecognizeConfiguration();
libraryQualityThreshold.value =
configuration.libraryFaceQualityThreshold.clamp(0.0, 1.0).toDouble();
} catch (error, stack) {
logger.w('|人脸管理|读取在库质量阈值失败error:$error|stack:$stack|');
}
}
/// 切换全部或低质量视图,保留搜索词但从第一页按对应查询重新加载。
Future<void> switchTab(FaceManageTab tab) async {
if (selectedTab.value == tab) return;
selectedTab.value = tab;
currentPage.value = 1;
if (tab == FaceManageTab.lowQuality) {
await refreshQualityThreshold();
}
await getFaceList();
}
/// 手动检测待检测及失败记录,检测失败不会移除人脸或特征。
Future<void> auditLibraryFaces({bool failedOnly = false}) async {
if (operationInProgress) return;
if (failedOnly && failedQualityCount.value == 0) return;
if (!failedOnly &&
pendingQualityCount.value == 0 &&
failedQualityCount.value == 0) {
Toast.success(msg: '没有需要检测的人脸');
return;
}
isAuditing.value = true;
qualityOperationMessage.value = '';
try {
final result = await SyncFaceTool.auditLibraryFaceQuality(
failedOnly: failedOnly,
);
final success = result.results
.where((item) => item.status == FaceQualityStatus.detected)
.length;
final failed = result.results.length - success;
if (failed > 0) {
qualityOperationMessage.value =
'检测完成:成功 $success 条,失败 $failed 条,可点击失败重试';
Toast.error(msg: qualityOperationMessage.value);
} else {
Toast.success(msg: '质量检测完成,共 ${result.results.length} 条');
}
} catch (error, stack) {
qualityOperationMessage.value = '在库人脸质量检测失败,请稍后重试';
logger.e('|人脸管理|质量检测失败error:$error|stack:$stack|');
Toast.error(msg: qualityOperationMessage.value);
} finally {
isAuditing.value = false;
currentPage.value = 1;
await getFaceList();
}
}
/// 确认后删除一条低质量人脸,服务端成功或已不存在后才清理本地。
Future<void> deleteLowQualityFace(FaceEntity face) async {
if (operationInProgress) return;
final confirmed = await appMessageDialog(
Get.context!,
'是否删除“${face.userName}”的该低质量人脸?删除后将不再参与识别。',
);
if (!confirmed) return;
await _deleteServerBackedFaces([face]);
}
/// 删除当前阈值下全部明确低质量的人脸,不受搜索词和分页影响。
Future<void> deleteAllLowQualityFaces() async {
if (operationInProgress || lowQualityCount.value == 0) return;
final faces = await appC.dataBase.getFaceDao.findAllLowQuality(
libraryQualityThreshold.value,
);
if (faces.isEmpty) {
await getFaceList();
return;
}
final confirmed = await appMessageDialog(
Get.context!,
'是否删除全部 ${faces.length} 条低质量人脸?该操作会先删除服务端数据,且不可撤销。',
);
if (!confirmed) return;
await _deleteServerBackedFaces(faces);
}
/// 按服务端 500 条上限分批删除,并仅清理服务端已确认的本地样本。
Future<void> _deleteServerBackedFaces(List<FaceEntity> faces) async {
isDeleting.value = true;
qualityOperationMessage.value = '';
var localDeleted = 0;
var localFailed = 0;
try {
for (var start = 0; start < faces.length; start += 500) {
final end = (start + 500).clamp(0, faces.length).toInt();
final batch = faces.sublist(start, end);
final response = await UserApi.deleteTerminalFaces(
batch.map((face) => face.sampleId).toList(growable: false),
);
final confirmed = response.confirmedSampleIds;
for (final face in batch) {
if (!confirmed.contains(face.sampleId)) {
throw StateError('服务端未确认样本删除结果');
}
if (await SyncFaceTool.deleteLocalFace(face)) {
localDeleted++;
} else {
localFailed++;
}
}
}
if (localFailed > 0) {
qualityOperationMessage.value =
'服务端已删除 ${localDeleted + localFailed} 条,本地清理失败 $localFailed 条,可再次删除重试';
Toast.error(msg: qualityOperationMessage.value);
} else {
Toast.success(msg: '已删除 $localDeleted 条低质量人脸');
}
} catch (error, stack) {
qualityOperationMessage.value = localDeleted > 0
? '已完成 $localDeleted 条,后续批次删除失败;未确认样本已保留在本机'
: '服务端删除失败,本机人脸已保留';
logger.e('|人脸管理|服务端删除失败error:$error|stack:$stack|');
Toast.error(msg: qualityOperationMessage.value);
} finally {
isDeleting.value = false;
currentPage.value = 1;
await getFaceList();
}
}
/// 刷新不受当前搜索条件影响的质量摘要。
Future<void> _refreshQualityCounts(FaceDao faceDao) async {
lowQualityCount.value = await faceDao.findLowQualityCount(
'-1', libraryQualityThreshold.value) ??
0;
pendingQualityCount.value = await faceDao.findPendingQualityCount() ?? 0;
failedQualityCount.value = await faceDao.findFailedQualityCount() ?? 0;
}
/// 将原生进度同步到页面;完成事件不负责数据库刷新,最终结果返回后统一收敛。
void _handleQualityAuditProgress(FaceQualityAuditProgress value) {
qualityAuditProgress.value = value;
if (value.inProgress || value.processed > 0) {
isAuditing.value = value.inProgress;
}
}
/// 同步、检测和删除共同占用人脸维护区,防止并发操作造成状态交叉。
bool get operationInProgress =>
isSyncing.value ||
isAuditing.value ||
isDeleting.value ||
SyncCheckTool.instance.runningTasks.contains(
ScheduledTaskType.departmentFaceSync,
);
/// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。
Future<void> delAllFace() async {
final isSure = await appMessageDialog(
Get.context!,
"是否删除全部人脸",
);
if (!isSure) return;
EasyLoading.show(
status: '删除中...',
maskType: EasyLoadingMaskType.black,
);
try {
final faceDao = appC.dataBase.getFaceDao;
await faceDao.delAll();
await AppFaceTool.removeFace(sampleId: '-1');
await getFaceList();
Toast.success(msg: "删除成功");
} catch (e, stack) {
Toast.error(msg: "删除全部人脸失败");
logger.e('|人脸管理|删除全部失败error:$e|stack:$stack|');
} finally {
EasyLoading.dismiss();
}
}
/// 监听成功完成的 FACE 增量同步,并仅刷新当前设置页本地列表。
void _listenMqCallback() {
mqUpdateListen =
appC.mqUpdateEventBus.on<MqtActionEnum>().listen((event) async {
switch (event) {
case MqtActionEnum.FACE:
await getFaceList(isLoading: true);
break;
default:
}
});
}
/// 按指定页码执行姓名搜索,并在查询前收起软键盘。
Future<void> doPage({int? page}) async {
if (page != null) currentPage.value = page;
// 收起键盘
FocusManager.instance.primaryFocus?.unfocus();
await getFaceList(isLoading: true);
}
/// 清空姓名条件、回到第一页并重新加载列表。
Future<void> doReset() async {
currentPage.value = 1;
usernameC.clear();
// 收起键盘
FocusManager.instance.primaryFocus?.unfocus();
await getFaceList(isLoading: true);
}
/// 删除一条本地人脸及其虹软内存特征,不修改服务端人脸真源。
Future<void> deleteFace(FaceEntity v) async {
final isSure = await appMessageDialog(Get.context!, "是否删除人脸");
if (!isSure) return;
EasyLoading.show(
status: '删除中...',
maskType: EasyLoadingMaskType.black,
);
try {
final deleted = await SyncFaceTool.deleteLocalFace(v);
if (!deleted) throw StateError('本地人脸删除失败并已尝试恢复');
await getFaceList();
Toast.success(msg: "删除成功");
} catch (e, stack) {
Toast.error(msg: "删除人脸失败");
logger.e('|人脸管理|删除失败error:$e|stack:$stack|');
} finally {
EasyLoading.dismiss();
}
}
}
+1
View File
@@ -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';
+16 -5
View File
@@ -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<ScheduledTaskLogEntity> logs = <ScheduledTaskLogEntity>[].obs;
@@ -40,11 +52,10 @@ class ScheduledTaskLogsController extends GetxController {
/// 查询序号用于丢弃快速切换筛选时晚返回的旧结果,避免列表与筛选条件错位。
int _requestSerial = 0;
/// 控制器创建后立即加载当前任务的全部来源日志。
/// 首次查询由 loadArguments 注入任务类型后触发,控制器自身不再主动加载。
@override
void onInit() {
super.onInit();
unawaited(loadLogs());
}
/// 选择新的依赖来源并重新查询;重复点击当前筛选不会制造额外数据库请求。
+10 -2
View File
@@ -19,7 +19,13 @@ class ScheduledTaskLogsPage extends GetView<ScheduledTaskLogsController> {
/// 构建页面工具栏、来源筛选、日志表格和分页栏,各响应区域分别局部刷新。
@override
Widget build(BuildContext context) => <Widget>[
Widget build(BuildContext context) {
// 内层路由的参数挂在 RouteSettings 上,首次构建时交给 Controller 首查。
final routeArguments = ModalRoute.of(context)?.settings.arguments;
if (routeArguments is ScheduledTaskType) {
controller.loadArguments(routeArguments);
}
return <Widget>[
_buildToolbar(context),
SizedBox(height: 10.h),
_buildSourceFilters(context),
@@ -37,6 +43,7 @@ class ScheduledTaskLogsPage extends GetView<ScheduledTaskLogsController> {
),
),
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch);
}
/// 构建返回入口、任务与依赖名称、当前条数以及手动刷新按钮。
Widget _buildToolbar(BuildContext context) {
@@ -45,7 +52,8 @@ class ScheduledTaskLogsPage extends GetView<ScheduledTaskLogsController> {
Tooltip(
message: '返回同步设置',
child: InkWell(
onTap: SettingNavigation.backToSync,
// 日志页压在终端内层 Navigator 之上,返回必须弹出内层栈而非根路由。
onTap: () => Navigator.of(context).maybePop(),
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: FIcon(
FAssets.icons.arrowLeft,
+7 -3
View File
@@ -28,7 +28,7 @@ class SettingPage extends GetView<SettingController> {
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<SettingController> {
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<SettingController> {
@override
Widget build(BuildContext context) {
return GetBuilder<SettingController>(
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,
+2
View File
@@ -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';
@@ -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<AppControl>();
/// 当前服务端分页页码,从 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 = <ServiceAppVersion>[].obs;
RxList<ServiceAppVersion> 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<void> _initialize() async {
final package = await PackageInfo.fromPlatform();
currentVersionCode.value = int.tryParse(package.buildNumber) ?? 0;
await getVersionList();
}
@override
void onClose() {}
/// 搜索
/// 切换页码或刷新当前页。
Future<void> doPage({int? page}) async {
if (page != null) currentPage.value = page;
await getVersionList(isLoading: true);
await getVersionList(showLoading: true);
}
/// 获取版本列表
Future<void> getVersionList({bool isLoading = false}) async {
if (isLoading) {
EasyLoading.show(
status: '查询中...',
maskType: EasyLoadingMaskType.black,
);
/// 根据本机 SysType 传递精确平台编码,只读取服务端返回的已发布版本。
Future<void> 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<void> openDetails(
BuildContext context,
ServiceAppVersion item,
) async {
await _showVersionDrawer(
context,
item: item,
currentVersionCode: currentVersionCode.value,
onDownload: () => doDownload(item.downloadUrl ?? ''),
);
}
/// 人工下载继续保存到系统下载目录,不参与 Root 自动安装流程。
Future<void> 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<void> 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>(
VersionManageController(),
);
final controller = Get.isRegistered<VersionManageController>()
? Get.find<VersionManageController>()
: Get.put(VersionManageController());
return <Widget>[
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<VersionManageController>();
final theme = FTheme.of(context);
return <Widget>[
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<VersionManageController>();
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: <Widget>[
<Widget>[
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),
<Widget>[
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<VersionManageController>();
/// 构建同时带文字与语义色的版本状态标签,避免只依靠颜色表达状态。
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<void> _showVersionDrawer(
BuildContext context, {
required ServiceAppVersion item,
required int currentVersionCode,
required VoidCallback onDownload,
}) async {
await showGeneralDialog<void>(
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<Offset>(
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 <Widget>[
<Widget>[
<Widget>[
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)),
),
<Widget>[
_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)
? '<p>暂无更新内容</p>'
: 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 <Widget>[
<Widget>[
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),
<Widget>[
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);
}
+1 -460
View File
@@ -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<AppControl>();
/// 当前终端设置,初始化后用于确定系统类型和设备绑定状态。
late SysSetting sysSetting;
/// 当前终端业务类型,决定搜索框允许的输入方式。
Rx<SysType> sysType = SysType.CANTEEN.obs;
/// 当前分页展示的人脸本地记录。
RxList<FaceEntity> faceList = RxList();
/// 当前列表页码,从 1 开始。
RxInt currentPage = RxInt(1);
/// 当前搜索条件命中的记录总数,供分页组件计算页数。
RxInt totalPage = RxInt(0);
/// 单页展示数量,沿用现有人脸管理网格密度。
RxInt pageSize = RxInt(15);
/// 列表查询是否进行中,仅驱动列表区域加载状态。
RxBool isLoading = false.obs;
/// 最近一次列表查询失败的安全提示,不包含数据库或文件路径。
RxString errorMessage = ''.obs;
/// 质量检测或服务端删除的可恢复提示,不替代列表内容。
final qualityOperationMessage = ''.obs;
/// 当前选中的内容 Tab。
final selectedTab = FaceManageTab.all.obs;
/// Android 人脸设置中的在库质量阈值。
final libraryQualityThreshold = 0.60.obs;
/// 低质量、待检测和检测失败的实时统计。
final lowQualityCount = 0.obs;
final pendingQualityCount = 0.obs;
final failedQualityCount = 0.obs;
/// 存量质量检测的最新原生进度。
final qualityAuditProgress = Rxn<FaceQualityAuditProgress>();
/// 同步、检测和服务端删除状态,用于互斥及按钮禁用。
final isSyncing = false.obs;
final isAuditing = false.obs;
final isDeleting = false.obs;
/// 姓名搜索输入控制器,由本控制器负责释放。
final usernameC = TextEditingController();
/// FACE 增量任务成功后的列表刷新订阅,由 [onClose] 取消。
StreamSubscription<MqtActionEnum>? mqUpdateListen;
/// 原生质量检测进度订阅,由 [onClose] 释放。
StreamSubscription<FaceQualityAuditProgress>? qualityAuditListen;
/// 启动异步设置读取、首次列表查询和 FACE 完成事件监听。
@override
void onInit() {
super.onInit();
unawaited(_initialize());
}
/// 读取终端设置后加载列表;初始化失败交由列表状态统一展示。
Future<void> _initialize() async {
sysSetting = await AppSettingTool.getSysSetting();
sysType.value = sysSetting.sysType;
_listenMqCallback();
qualityAuditListen = FaceDiscernPlugs.qualityAuditStream.listen(
_handleQualityAuditProgress,
onError: (Object error, StackTrace stack) {
logger.w('|人脸管理|质量进度监听失败error:$error|stack:$stack|');
},
);
await refreshQualityThreshold();
await getFaceList();
}
/// 释放 MQTT 完成事件和输入控制器,防止退出设置页后继续刷新。
@override
void onClose() {
mqUpdateListen?.cancel();
qualityAuditListen?.cancel();
usernameC.dispose();
super.onClose();
}
/// 按当前姓名和页码读取本地人脸列表,可选显示旧页面使用的查询遮罩。
Future<void> getFaceList({bool isLoading = false}) async {
this.isLoading.value = true;
errorMessage.value = '';
if (isLoading) {
EasyLoading.show(
status: '查询中...',
maskType: EasyLoadingMaskType.black,
);
}
try {
final faceDao = appC.dataBase.getFaceDao;
String userName = usernameC.text;
if (ObjectUtil.isEmpty(userName)) userName = "-1";
final lowQuality = selectedTab.value == FaceManageTab.lowQuality;
final findCount = lowQuality
? await faceDao.findLowQualityCount(
userName,
libraryQualityThreshold.value,
)
: await faceDao.findCount(userName);
totalPage.value = findCount ?? 0;
final list = lowQuality
? await faceDao.findLowQuality(
userName,
libraryQualityThreshold.value,
pageSize.value,
(currentPage.value - 1) * pageSize.value,
)
: await faceDao.findAll(
userName,
pageSize.value,
(currentPage.value - 1) * pageSize.value,
);
faceList.value = list;
await _refreshQualityCounts(faceDao);
} catch (e, stack) {
errorMessage.value = '人脸数据加载失败,请稍后重试';
logger.e('|人脸管理|加载失败error:|$e|stack:|$stack|');
} finally {
this.isLoading.value = false;
if (isLoading) {
Future.delayed(const Duration(seconds: 1), () {
EasyLoading.dismiss();
});
}
}
}
/// 确认后将本部门快照或非生产全租户调试请求提交到统一人脸任务队列。
///
/// 队列会先避开支付、人脸会话和业务弹框;只有任务已经实际开始时才显示遮罩,
/// 避免遮罩本身被空闲检测识别为业务占用而导致任务无法启动。
Future<void> asyncFace({bool isAll = false}) async {
if (operationInProgress) return;
final isNetwork = AppNetworkTool.isAutoNetworkMsg();
if (!isNetwork) return;
final setting = await AppSettingTool.getSysSetting();
if (ObjectUtil.isEmpty(setting.merchantDeptId)) {
Toast.error(msg: "请先绑定设备");
return;
}
if (isAll && AppSysTool.isProd()) {
Toast.error(msg: '生产环境仅允许同步本部门人脸');
return;
}
if (AppSysTool.isProd()) {
final isActive = await AppFaceTool.isActiveSdk();
if (!isActive) {
Toast.error(msg: "人脸sdk激活失败");
return;
}
}
final isSure = await appMessageDialog(
Get.context!,
isAll ? '是否同步全租户人脸?此能力仅用于非生产调试,过程可能较长' : '是否同步本部门及子部门人脸?过程可能较长',
);
if (!isSure) return;
final scheduler = SyncCheckTool.instance;
isSyncing.value = true;
final operation = scheduler.trigger(
ScheduledTaskType.departmentFaceSync,
debugAllFaces: isAll,
triggerType: ScheduledTaskTriggerType.manual,
);
await Future<void>.delayed(Duration.zero);
var overlayVisible = false;
if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync)) {
overlayVisible = true;
EasyLoading.show(
status: '同步中...',
maskType: EasyLoadingMaskType.black,
);
}
try {
await operation;
if (scheduler.pendingTasks
.contains(ScheduledTaskType.departmentFaceSync)) {
Toast.success(msg: '人脸同步已排队,将在业务空闲后执行');
return;
}
currentPage.value = 1;
await getFaceList();
final record = scheduler.records[ScheduledTaskType.departmentFaceSync];
if (record?.success == true) {
Toast.success(msg: record?.message ?? '人脸同步完成');
} else {
Toast.error(msg: record?.message ?? '人脸同步失败,请稍后重试');
}
} catch (e, stack) {
Toast.error(msg: '同步人脸失败,请稍后重试');
logger.e('|人脸管理|同步失败error:|$e|stack:|$stack|');
} finally {
currentPage.value = 1;
isSyncing.value = false;
if (overlayVisible) EasyLoading.dismiss();
}
}
/// 重新读取 Android 人脸配置并按新阈值立即重查低质量分类。
Future<void> refreshQualityThreshold() async {
try {
final configuration = await FaceDiscernPlugs.getRecognizeConfiguration();
libraryQualityThreshold.value =
configuration.libraryFaceQualityThreshold.clamp(0.0, 1.0).toDouble();
} catch (error, stack) {
logger.w('|人脸管理|读取在库质量阈值失败error:$error|stack:$stack|');
}
}
/// 切换全部或低质量视图,保留搜索词但从第一页按对应查询重新加载。
Future<void> switchTab(FaceManageTab tab) async {
if (selectedTab.value == tab) return;
selectedTab.value = tab;
currentPage.value = 1;
if (tab == FaceManageTab.lowQuality) {
await refreshQualityThreshold();
}
await getFaceList();
}
/// 手动检测待检测及失败记录,检测失败不会移除人脸或特征。
Future<void> auditLibraryFaces({bool failedOnly = false}) async {
if (operationInProgress) return;
if (failedOnly && failedQualityCount.value == 0) return;
if (!failedOnly &&
pendingQualityCount.value == 0 &&
failedQualityCount.value == 0) {
Toast.success(msg: '没有需要检测的人脸');
return;
}
isAuditing.value = true;
qualityOperationMessage.value = '';
try {
final result = await SyncFaceTool.auditLibraryFaceQuality(
failedOnly: failedOnly,
);
final success = result.results
.where((item) => item.status == FaceQualityStatus.detected)
.length;
final failed = result.results.length - success;
if (failed > 0) {
qualityOperationMessage.value =
'检测完成:成功 $success 条,失败 $failed 条,可点击失败重试';
Toast.error(msg: qualityOperationMessage.value);
} else {
Toast.success(msg: '质量检测完成,共 ${result.results.length} 条');
}
} catch (error, stack) {
qualityOperationMessage.value = '在库人脸质量检测失败,请稍后重试';
logger.e('|人脸管理|质量检测失败error:$error|stack:$stack|');
Toast.error(msg: qualityOperationMessage.value);
} finally {
isAuditing.value = false;
currentPage.value = 1;
await getFaceList();
}
}
/// 确认后删除一条低质量人脸,服务端成功或已不存在后才清理本地。
Future<void> deleteLowQualityFace(FaceEntity face) async {
if (operationInProgress) return;
final confirmed = await appMessageDialog(
Get.context!,
'是否删除“${face.userName}”的该低质量人脸?删除后将不再参与识别。',
);
if (!confirmed) return;
await _deleteServerBackedFaces([face]);
}
/// 删除当前阈值下全部明确低质量的人脸,不受搜索词和分页影响。
Future<void> deleteAllLowQualityFaces() async {
if (operationInProgress || lowQualityCount.value == 0) return;
final faces = await appC.dataBase.getFaceDao.findAllLowQuality(
libraryQualityThreshold.value,
);
if (faces.isEmpty) {
await getFaceList();
return;
}
final confirmed = await appMessageDialog(
Get.context!,
'是否删除全部 ${faces.length} 条低质量人脸?该操作会先删除服务端数据,且不可撤销。',
);
if (!confirmed) return;
await _deleteServerBackedFaces(faces);
}
/// 按服务端 500 条上限分批删除,并仅清理服务端已确认的本地样本。
Future<void> _deleteServerBackedFaces(List<FaceEntity> faces) async {
isDeleting.value = true;
qualityOperationMessage.value = '';
var localDeleted = 0;
var localFailed = 0;
try {
for (var start = 0; start < faces.length; start += 500) {
final end = (start + 500).clamp(0, faces.length).toInt();
final batch = faces.sublist(start, end);
final response = await UserApi.deleteTerminalFaces(
batch.map((face) => face.sampleId).toList(growable: false),
);
final confirmed = response.confirmedSampleIds;
for (final face in batch) {
if (!confirmed.contains(face.sampleId)) {
throw StateError('服务端未确认样本删除结果');
}
if (await SyncFaceTool.deleteLocalFace(face)) {
localDeleted++;
} else {
localFailed++;
}
}
}
if (localFailed > 0) {
qualityOperationMessage.value =
'服务端已删除 ${localDeleted + localFailed} 条,本地清理失败 $localFailed 条,可再次删除重试';
Toast.error(msg: qualityOperationMessage.value);
} else {
Toast.success(msg: '已删除 $localDeleted 条低质量人脸');
}
} catch (error, stack) {
qualityOperationMessage.value = localDeleted > 0
? '已完成 $localDeleted 条,后续批次删除失败;未确认样本已保留在本机'
: '服务端删除失败,本机人脸已保留';
logger.e('|人脸管理|服务端删除失败error:$error|stack:$stack|');
Toast.error(msg: qualityOperationMessage.value);
} finally {
isDeleting.value = false;
currentPage.value = 1;
await getFaceList();
}
}
/// 刷新不受当前搜索条件影响的质量摘要。
Future<void> _refreshQualityCounts(FaceDao faceDao) async {
lowQualityCount.value = await faceDao.findLowQualityCount(
'-1', libraryQualityThreshold.value) ??
0;
pendingQualityCount.value = await faceDao.findPendingQualityCount() ?? 0;
failedQualityCount.value = await faceDao.findFailedQualityCount() ?? 0;
}
/// 将原生进度同步到页面;完成事件不负责数据库刷新,最终结果返回后统一收敛。
void _handleQualityAuditProgress(FaceQualityAuditProgress value) {
qualityAuditProgress.value = value;
if (value.inProgress || value.processed > 0) {
isAuditing.value = value.inProgress;
}
}
/// 同步、检测和删除共同占用人脸维护区,防止并发操作造成状态交叉。
bool get operationInProgress =>
isSyncing.value ||
isAuditing.value ||
isDeleting.value ||
SyncCheckTool.instance.runningTasks.contains(
ScheduledTaskType.departmentFaceSync,
);
/// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。
Future<void> delAllFace() async {
final isSure = await appMessageDialog(
Get.context!,
"是否删除全部人脸",
);
if (!isSure) return;
EasyLoading.show(
status: '删除中...',
maskType: EasyLoadingMaskType.black,
);
try {
final faceDao = appC.dataBase.getFaceDao;
await faceDao.delAll();
await AppFaceTool.removeFace(sampleId: '-1');
await getFaceList();
Toast.success(msg: '删除成功');
} catch (e, stack) {
Toast.error(msg: '删除全部人脸失败');
logger.e('|人脸管理|删除全部失败error:|$e|stack:|$stack|');
} finally {
EasyLoading.dismiss();
}
}
/// 监听成功完成的 FACE 增量同步,并仅刷新当前设置页本地列表。
void _listenMqCallback() {
mqUpdateListen =
appC.mqUpdateEventBus.on<MqtActionEnum>().listen((event) async {
switch (event) {
case MqtActionEnum.FACE:
await getFaceList(isLoading: true);
break;
default:
}
});
}
/// 按指定页码执行姓名搜索,并在查询前收起软键盘。
Future<void> doPage({int? page}) async {
if (page != null) currentPage.value = page;
// 收起键盘
FocusManager.instance.primaryFocus?.unfocus();
await getFaceList(isLoading: true);
}
/// 清空姓名条件、回到第一页并重新加载列表。
Future<void> doReset() async {
currentPage.value = 1;
usernameC.clear();
// 收起键盘
FocusManager.instance.primaryFocus?.unfocus();
await getFaceList(isLoading: true);
}
/// 删除一条本地人脸及其虹软内存特征,不修改服务端人脸真源。
Future<void> deleteFace(FaceEntity v) async {
final isSure = await appMessageDialog(Get.context!, "是否删除人脸");
if (!isSure) return;
EasyLoading.show(
status: '删除中...',
maskType: EasyLoadingMaskType.black,
);
try {
final deleted = await SyncFaceTool.deleteLocalFace(v);
if (!deleted) throw StateError('本地人脸删除失败并已尝试恢复');
await getFaceList();
Toast.success(msg: '删除成功');
} catch (e, stack) {
Toast.error(msg: '删除人脸失败');
logger.e('|人脸管理|删除失败error:|$e|stack:|$stack|');
} finally {
EasyLoading.dismiss();
}
}
}
/// 构建紧凑的人脸视图切换栏,并在低质量页同行展示质量摘要。
Widget _buildFaceHeader(BuildContext context) {
final controller = Get.find<FaceManageController>();
File diff suppressed because it is too large. Load diff
@@ -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<SettingMealPeriods> createState() => _SettingMealPeriodsState();
}
/// 餐段配置页状态;草稿变更只影响当前页面,保存成功后才更新基准快照。
class _SettingMealPeriodsState extends State<SettingMealPeriods> {
/// 设置模块 Binding 注册的控制器,负责持久化和保存中状态。
late final SettingController _controller;
/// 最近一次加载或保存成功的餐段快照,用于判断页面是否存在未保存变更。
late List<ProductMealPeriod> _savedPeriods;
/// 当前页面可编辑草稿,时间字段变化时通过 setState 局部刷新。
late List<ProductMealPeriod> _draftPeriods;
/// 从控制器读取已持久化配置并创建互不共享对象的页面草稿。
@override
void initState() {
super.initState();
_controller = Get.find<SettingController>();
_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<void> _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<ProductMealPeriod> 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 <Widget>[
_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) => <Widget>[
_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 <Widget>[
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<int> options;
/// 是否允许展开下拉框并选择时间。
final bool enabled;
/// 用户选中有效分钟值后的回调。
final ValueChanged<int> onChanged;
/// 构建标签居左、时间值居右的单行下拉字段。
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Semantics(
label: label,
value: formatMealPeriodTime(value),
enabled: enabled,
child: <Widget>[
Text(
label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted),
),
const Spacer(),
DropdownButtonHideUnderline(
child: DropdownButton<int>(
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<int>(
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<int>.generate(48, (index) => index * 30);
/// 结束时间半小时选项,范围为 00:30 至 24:00。
final _endTimeOptions = List<int>.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<ProductMealPeriod> periods) {
final ranges = periods
.map(
(period) =>
'${period.hourType.title} ${formatMealPeriodTime(period.startMinutes)}–${formatMealPeriodTime(period.endMinutes)}',
)
.join(',');
return '当前配置有效:$ranges。';
}
/// 深拷贝餐段列表,避免页面草稿直接修改控制器持有的配置对象。
List<ProductMealPeriod> _copyPeriods(Iterable<ProductMealPeriod> source) =>
source.map((period) => period.copyWith()).toList(growable: false);
/// 按顺序比较两组餐段,用于控制保存按钮是否可用。
bool _samePeriods(List<ProductMealPeriod> left, List<ProductMealPeriod> 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;
}
+9 -10
View File
@@ -17,15 +17,12 @@ class SupermarketOrderPage extends GetView<SupermarketOrderController> {
// **************************************************************************
// 头部
Widget _buildHeader(double headerHeight) {
Widget _buildHeader() {
final context = Get.context!;
final theme = FTheme.of(context);
// 标题与返回入口由终端外壳顶栏统一提供,这里只保留分类、搜索与工具条,
// 紧贴外壳顶栏排布,不再为系统状态栏让位预留 padding。
return <Widget>[
appBack(
context,
height: headerHeight,
onTap: () => Get.offAndToNamed(RouteNames.panel),
),
Obx(
() => orderRowClassify(
context,
@@ -71,7 +68,11 @@ class SupermarketOrderPage extends GetView<SupermarketOrderController> {
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<SupermarketOrderController> {
@override
Widget build(BuildContext context) {
final height = 90;
return GetBuilder<SupermarketOrderController>(
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,
+2 -2
View File
@@ -140,11 +140,11 @@ class TakeFoodPage extends GetView<TakeFoodController> {
@override
Widget build(BuildContext context) {
return GetBuilder<TakeFoodController>(
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,
);
+332
View File
@@ -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<TerminalPage> createState() => _TerminalPageState();
}
/// 终端外壳的路由状态,确保新旧终端页重叠退场时各自持有独立 Navigator Key。
class _TerminalPageState extends State<TerminalPage> {
/// 当前页面实例的导航所有权标识,用于避免旧实例销毁时误解绑新实例。
final Object _navigatorOwner = Object();
/// 当前页面实例独占的终端工作区 Navigator Key。
late final GlobalKey<NavigatorState> _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<void> _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<dynamic> _generateRoute(RouteSettings settings) {
final config = _terminalRoutes[settings.name] ??
_terminalRoutes[RouteNames.supermarketProduct]!;
return GetPageRoute<void>(
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<String?> onChanged;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
onChanged(route.settings.name);
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
onChanged(newRoute?.settings.name);
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? 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<String, _TerminalRouteConfig> _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);
}),
),
};
+19 -3
View File
@@ -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:
+6
View File
@@ -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/