From 2efa7a3c196ee47f0c48b4167d6d18e480de3efe Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 30 Jul 2026 13:16:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=AE=BE=E5=A4=87=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=20feat:=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=20feat:=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2=E7=94=A8=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../facediscern/database/DatabaseHelper.java | 3 +- .../pj_supermarket_cloud/MainActivity.kt | 25 + lib/app.dart | 20 +- lib/common/models/index.dart | 1 + .../models/manage/scheduled_task_models.dart | 282 +++++ .../manage/scheduled_task_models.g.dart | 100 ++ lib/common/models/mq/mq_enum_models.dart | 25 + lib/common/models/mq/mq_models.g.dart | 1 + lib/common/routers/names.dart | 37 + .../database/dao/file_upload_record_dao.dart | 40 + .../database/dao/scheduled_task_dao.dart | 27 + .../database/dao/scheduled_task_log_dao.dart | 70 ++ lib/common/services/database/database.dart | 43 +- lib/common/services/database/database.g.dart | 380 +++++- .../services/database/database_migration.dart | 80 ++ .../entity/file_upload_record_entity.dart | 97 ++ .../entity/file_upload_record_entity.g.dart | 55 + .../entity/scheduled_task_entity.dart | 87 ++ .../entity/scheduled_task_entity.g.dart | 40 + .../entity/scheduled_task_log_entity.dart | 135 ++ .../entity/scheduled_task_log_entity.g.dart | 55 + lib/common/services/index.dart | 6 + lib/common/utils/app/app_mq_tool.dart | 22 +- lib/common/utils/app/app_runtime_tool.dart | 93 ++ lib/common/utils/app/app_setting_tool.dart | 109 ++ lib/common/utils/index.dart | 4 + lib/common/utils/logger/logger_tool.dart | 50 + lib/common/utils/manage/mq_manager.dart | 7 + .../utils/sync/file_upload_record_tool.dart | 191 +++ .../sync/scheduled_task_storage_tool.dart | 222 ++++ lib/common/utils/sync/sync_check_tool.dart | 1119 ++++++++++++++--- lib/common/utils/sync/sync_idle_tool.dart | 117 ++ .../utils/sync/terminal_log_upload_tool.dart | 309 +++++ .../widgets/app/terminal_device_status.dart | 407 ++++-- lib/common/widgets/app/terminal_shell.dart | 125 +- lib/pages/terminal/setting/controller.dart | 240 +++- .../setting/face_setting_controller.dart | 4 +- .../terminal/setting/routes/backup/view.dart | 13 + .../setting/routes/base_info/view.dart | 13 + .../setting/routes/card_reader/view.dart | 13 + .../terminal/setting/routes/face/view.dart | 13 + .../setting/routes/face_plugin/view.dart | 13 + lib/pages/terminal/setting/routes/index.dart | 4 + .../setting/routes/log_preview/view.dart | 13 + .../terminal/setting/routes/navigation.dart | 99 ++ .../setting/routes/page_setting/view.dart | 13 + .../terminal/setting/routes/product/view.dart | 13 + .../setting/routes/route_factory.dart | 133 ++ .../routes/sub_screen_routes/view.dart | 13 + .../terminal/setting/routes/sync/view.dart | 13 + .../setting/routes/sync_logs/controller.dart | 98 ++ .../setting/routes/sync_logs/view.dart | 498 ++++++++ .../terminal/setting/routes/version/view.dart | 13 + lib/pages/terminal/setting/view.dart | 125 +- .../widgets/setting_cron_rule_dialog.dart | 312 +++++ .../setting/widgets/setting_sync.dart | 700 +++++++---- .../widgets/setting_sync_log_dialog.dart | 432 +++++++ pubspec.lock | 26 +- pubspec.yaml | 3 + 60 files changed, 6560 insertions(+), 643 deletions(-) create mode 100644 lib/common/models/manage/scheduled_task_models.dart create mode 100644 lib/common/models/manage/scheduled_task_models.g.dart create mode 100644 lib/common/services/database/dao/file_upload_record_dao.dart create mode 100644 lib/common/services/database/dao/scheduled_task_dao.dart create mode 100644 lib/common/services/database/dao/scheduled_task_log_dao.dart create mode 100644 lib/common/services/database/entity/file_upload_record_entity.dart create mode 100644 lib/common/services/database/entity/file_upload_record_entity.g.dart create mode 100644 lib/common/services/database/entity/scheduled_task_entity.dart create mode 100644 lib/common/services/database/entity/scheduled_task_entity.g.dart create mode 100644 lib/common/services/database/entity/scheduled_task_log_entity.dart create mode 100644 lib/common/services/database/entity/scheduled_task_log_entity.g.dart create mode 100644 lib/common/utils/sync/file_upload_record_tool.dart create mode 100644 lib/common/utils/sync/scheduled_task_storage_tool.dart create mode 100644 lib/common/utils/sync/sync_idle_tool.dart create mode 100644 lib/common/utils/sync/terminal_log_upload_tool.dart create mode 100644 lib/pages/terminal/setting/routes/backup/view.dart create mode 100644 lib/pages/terminal/setting/routes/base_info/view.dart create mode 100644 lib/pages/terminal/setting/routes/card_reader/view.dart create mode 100644 lib/pages/terminal/setting/routes/face/view.dart create mode 100644 lib/pages/terminal/setting/routes/face_plugin/view.dart create mode 100644 lib/pages/terminal/setting/routes/index.dart create mode 100644 lib/pages/terminal/setting/routes/log_preview/view.dart create mode 100644 lib/pages/terminal/setting/routes/navigation.dart create mode 100644 lib/pages/terminal/setting/routes/page_setting/view.dart create mode 100644 lib/pages/terminal/setting/routes/product/view.dart create mode 100644 lib/pages/terminal/setting/routes/route_factory.dart create mode 100644 lib/pages/terminal/setting/routes/sub_screen_routes/view.dart create mode 100644 lib/pages/terminal/setting/routes/sync/view.dart create mode 100644 lib/pages/terminal/setting/routes/sync_logs/controller.dart create mode 100644 lib/pages/terminal/setting/routes/sync_logs/view.dart create mode 100644 lib/pages/terminal/setting/routes/version/view.dart create mode 100644 lib/pages/terminal/setting/widgets/setting_cron_rule_dialog.dart create mode 100644 lib/pages/terminal/setting/widgets/setting_sync_log_dialog.dart diff --git a/README.md b/README.md index 784044a..9473dca 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~ ## flutter打包 ``` -flutter build apk --build-name 1.3.0 --build-number 130 +flutter build apk --build-name 1.3.1 --build-number 131 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java index d27878d..057135e 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java @@ -15,7 +15,8 @@ public class DatabaseHelper extends SQLiteOpenHelper { private static final String DATABASE_PATH = "pj_cashier/database"; private static final String DATABASE_NAME = "cash_register.db"; - private static final int DATABASE_VERSION = 7; + /** 与 Flutter Floor 的 @Database 版本保持一致,避免原生层提前推进 user_version。 */ + private static final int DATABASE_VERSION = 8; public DatabaseHelper(Context context) { super(context, getCustomDatabasePath(context), null, DATABASE_VERSION); diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt index 8cbb2c5..79b7e13 100644 --- a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt @@ -1,11 +1,15 @@ package com.example.pj_supermarket_cloud import android.content.Context +import android.app.ActivityManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.TrafficStats import android.hardware.display.DisplayManager import android.os.Build +import android.os.Debug +import android.os.Process +import android.os.StatFs import android.telephony.TelephonyManager import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -49,6 +53,7 @@ class MainActivity : FlutterActivity() { ) } "getNetworkSnapshot" -> result.success(getNetworkSnapshot()) + "getPerformanceSnapshot" -> result.success(getPerformanceSnapshot()) "isSubScreenShowing" -> result.success( FlutterSubScreenProvider.instance.presentation?.isShowing == true ) @@ -174,6 +179,26 @@ class MainActivity : FlutterActivity() { ) } + /** + * 返回轻量的进程与设备资源快照。CPU 使用率由 Flutter 比较两次累计进程时间计算, + * 原生层不创建额外线程或定时器,避免运行状态展示反过来增加终端负载。 + */ + private fun getPerformanceSnapshot(): Map { + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memoryInfo) + val storage = StatFs(filesDir.absolutePath) + return mapOf( + "processCpuTimeMs" to Process.getElapsedCpuTime(), + "logicalProcessorCount" to Runtime.getRuntime().availableProcessors().coerceAtLeast(1), + "memoryTotalBytes" to memoryInfo.totalMem, + "memoryAvailableBytes" to memoryInfo.availMem, + "appMemoryBytes" to Debug.getPss().toLong() * 1024L, + "storageTotalBytes" to storage.totalBytes, + "storageAvailableBytes" to storage.availableBytes + ) + } + @Suppress("DEPRECATION") private fun mobileNetworkType(): String { return try { diff --git a/lib/app.dart b/lib/app.dart index 147ee43..d56c215 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,28 +6,36 @@ import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 主屏 Flutter 应用根组件,统一承载主题、路由、生命周期和用户活动监听。 class MainApp extends StatefulWidget { + /// 创建主屏应用根组件。 const MainApp({super.key}); + /// 创建负责生命周期观察和根级空闲活动采集的应用状态。 @override State createState() => _MainAppState(); } +/// 主屏应用生命周期与根视图状态。 class _MainAppState extends State with WidgetsBindingObserver { + /// 注册应用生命周期观察者,供副屏、人脸和同步空闲状态共用。 @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } + /// 注销生命周期观察者,避免应用根组件释放后继续接收回调。 @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } + /// 同步前后台空闲状态,并在挂起时释放人脸会话、恢复时校验副屏窗口。 @override void didChangeAppLifecycleState(AppLifecycleState state) async { + SyncIdleTool.instance.updateLifecycle(state); if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { if (Get.isRegistered()) { @@ -46,6 +54,7 @@ class _MainAppState extends State with WidgetsBindingObserver { } } + /// 构建主题与 GetX 根应用,并在最外层 Listener 记录所有指针活动。 @override Widget build(BuildContext context) { return ScreenUtilInit( @@ -140,9 +149,14 @@ class _MainAppState extends State with WidgetsBindingObserver { GlobalRouteObserver(), ], builder: EasyLoading.init( - builder: (_, childSecond) => FTheme( - data: themeData, - child: childSecond!, + builder: (_, childSecond) => Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => + SyncIdleTool.instance.recordUserActivity(), + child: FTheme( + data: themeData, + child: childSecond!, + ), ), ), ); diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart index 36a03ee..62a5fb6 100644 --- a/lib/common/models/index.dart +++ b/lib/common/models/index.dart @@ -24,6 +24,7 @@ export './manage/usb_facility_enum_manage.dart'; export './manage/usb_facility_manage.dart'; export './manage/terminal_device_status_models.dart'; export './manage/hardware_input_history_models.dart'; +export './manage/scheduled_task_models.dart'; export './encryption/encryption_tool.dart'; diff --git a/lib/common/models/manage/scheduled_task_models.dart b/lib/common/models/manage/scheduled_task_models.dart new file mode 100644 index 0000000..2315920 --- /dev/null +++ b/lib/common/models/manage/scheduled_task_models.dart @@ -0,0 +1,282 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'scheduled_task_models.g.dart'; + +/// 应用内统一调度的任务标识,枚举名作为 scheduled_task.taskCode,不能随 UI 文案变化。 +enum ScheduledTaskType { + /// MQTT 长连接断开后的重连检查。 + mqReconnect, + + /// 待打印任务与打印结果同步检查。 + printCheck, + + /// 取餐广播本地队列检查。 + broadcastCheck, + + /// 打印机、电子秤、读卡器等终端硬件状态检查。 + deviceStatus, + + /// 服务端设备配置同步。 + configSync, + + /// 商品分类数据同步。 + classifySync, + + /// 商品主数据同步。 + productSync, + + /// 本地离线订单补传。 + offlineOrderSync, + + /// 本机脱敏日志文件上传。 + terminalLogUpload, + + /// 已成功上传的过期日志清理。 + expiredLogCleanup, +} + +/// 任务最近一次执行状态,枚举名直接持久化到任务表和任务日志表。 +enum ScheduledTaskRunStatus { + /// 尚未产生过执行记录。 + notRun, + + /// 任务已经开始但尚未结束。 + running, + + /// 任务已完成且业务结果成功。 + success, + + /// 任务执行失败并记录了错误信息。 + failed, + + /// 因网络不可用或数据未变化而无需执行后续动作。 + skipped, +} + +/// 任务触发来源,枚举名写入 scheduled_task_log.triggerType。 +enum ScheduledTaskTriggerType { + /// Cron 到点自动触发。 + cron, + + /// 设置页“立即执行”人工触发。 + manual, + + /// 后台通过 MQTT 指令触发。 + mqtt, + + /// 应用启动或其他内部流程触发。 + system, +} + +/// 任务依赖的主要资源类型,用于执行日志筛选和故障归类。 +enum ScheduledTaskSourceType { + /// MQTT 消息服务。 + mqtt, + + /// HTTP 业务接口。 + http, + + /// 打印、串口或其他终端插件。 + plugin, + + /// 本地日志等文件系统资源。 + file, + + /// 不依赖远程服务的本地队列或本地逻辑。 + local, +} + +/// 本机文件上传记录状态,枚举名写入 file_upload_record.status。 +enum FileUploadStatus { + /// 已创建上传尝试,文件尚未确认成功。 + uploading, + + /// 服务端确认无需上传或已经上传成功。 + success, + + /// 本次预检、脱敏或上传处理失败。 + failed, +} + +/// Cron 常用规则在选择弹窗中的分组。 +enum ScheduledCronPresetGroup { + /// 按秒、分钟或小时周期执行。 + interval, + + /// 每天固定时间执行。 + daily, + + /// 每周固定星期和时间执行。 + weekly, + + /// 每月固定日期和时间执行。 + monthly, +} + +/// Cron 弹窗使用的数据模型,表达式仍以调度器实际支持的六段格式保存。 +class ScheduledCronPreset { + /// 创建一条可直接应用到任务配置的 Cron 预设。 + const ScheduledCronPreset({ + required this.group, + required this.label, + required this.expression, + }); + + /// 预设在选择弹窗中的分类。 + final ScheduledCronPresetGroup group; + + /// 面向终端操作人员展示的规则名称。 + final String label; + + /// cron 0.6.2 支持的六段表达式,顺序为秒、分、时、日、月、周。 + final String expression; +} + +/// 定时任务可编辑配置,旧 JSON 只在 SQLite v8 首次迁移时读取。 +@JsonSerializable() +class ScheduledTaskConfig { + /// 稳定任务类型,对应 scheduled_task.taskCode。 + final ScheduledTaskType type; + + /// 是否注册自动 Cron 调度;关闭后仍允许手动执行。 + final bool enabled; + + /// 六段 Cron 表达式。 + final String cron; + + /// 自动触发后是否必须等待连续 60 秒无人操作的完整空闲状态。 + final bool idleOnly; + + /// 创建一份任务调度配置。 + const ScheduledTaskConfig({ + required this.type, + required this.enabled, + required this.cron, + required this.idleOnly, + }); + + /// 从旧版 sysJson 恢复迁移用任务配置。 + factory ScheduledTaskConfig.fromJson(Map json) => + _$ScheduledTaskConfigFromJson(json); + + /// 仅供旧版配置兼容和代码生成使用,不作为 v8 之后的持久化入口。 + Map toJson() => _$ScheduledTaskConfigToJson(this); + + /// 保留任务类型并生成包含指定变更的新配置。 + ScheduledTaskConfig copyWith({ + bool? enabled, + String? cron, + bool? idleOnly, + }) { + return ScheduledTaskConfig( + type: type, + enabled: enabled ?? this.enabled, + cron: cron ?? this.cron, + idleOnly: idleOnly ?? this.idleOnly, + ); + } +} + +/// 设置页展示的任务最近执行快照。 +@JsonSerializable() +class ScheduledTaskRecord { + /// 最近一次开始执行的本地时间;从未执行时为空。 + final DateTime? lastRunAt; + + /// 最近一次成功完成的本地时间;尚无成功记录时为空。 + final DateTime? lastSuccessAt; + + /// 最近结果是否可视为成功,跳过状态也按可继续运行处理。 + final bool success; + + /// 最近结果的简短中文摘要。 + final String message; + + /// 创建任务最近执行快照。 + const ScheduledTaskRecord({ + this.lastRunAt, + this.lastSuccessAt, + this.success = true, + this.message = '尚未执行', + }); + + /// 从旧版 sysJson 恢复迁移用执行快照。 + factory ScheduledTaskRecord.fromJson(Map json) => + _$ScheduledTaskRecordFromJson(json); + + /// 仅供旧版执行快照兼容和代码生成使用。 + Map toJson() => _$ScheduledTaskRecordToJson(this); +} + +/// 日志文件最近一次成功上传状态,用于本地未变化文件快速跳过。 +@JsonSerializable() +class TerminalLogUploadState { + /// 日志根目录下使用正斜杠的相对路径。 + final String relativePath; + + /// 生成脱敏副本时读取的原日志快照大小,单位为字节。 + final int sourceSize; + + /// 生成脱敏副本时原日志文件的最后修改时间。 + final DateTime sourceModifiedTime; + + /// 脱敏后文件内容的 MD5 哈希。 + final String sanitizedHash; + + /// 服务端确认成功或无需重复上传的本地时间。 + final DateTime uploadedAt; + + /// 创建一条成功上传状态。 + const TerminalLogUploadState({ + required this.relativePath, + required this.sourceSize, + required this.sourceModifiedTime, + required this.sanitizedHash, + required this.uploadedAt, + }); + + /// 从旧版 sysJson 恢复待迁移的文件状态。 + factory TerminalLogUploadState.fromJson(Map json) => + _$TerminalLogUploadStateFromJson(json); + + /// 仅供旧版文件状态兼容和代码生成使用。 + Map toJson() => _$TerminalLogUploadStateToJson(this); +} + +/// 单次终端日志上传任务的汇总统计。 +@JsonSerializable() +class TerminalLogUploadSummary { + /// 本次进入处理范围的日志文件数量。 + final int scanned; + + /// 因本地快照未变化或服务端已有相同内容而跳过的数量。 + final int skipped; + + /// 本次实际上传成功的文件数量。 + final int succeeded; + + /// 本次脱敏、预检或上传失败的文件数量。 + final int failed; + + /// 本次任务内最近一次成功确认时间;没有成功文件时为空。 + final DateTime? lastSuccessAt; + + /// 创建一次日志上传汇总,未传入的计数按零处理。 + const TerminalLogUploadSummary({ + this.scanned = 0, + this.skipped = 0, + this.succeeded = 0, + this.failed = 0, + this.lastSuccessAt, + }); + + /// 从旧版 sysJson 恢复迁移用上传汇总。 + factory TerminalLogUploadSummary.fromJson(Map json) => + _$TerminalLogUploadSummaryFromJson(json); + + /// 仅供旧版上传汇总兼容和代码生成使用。 + Map toJson() => _$TerminalLogUploadSummaryToJson(this); + + /// 生成设置页和任务日志共用的中文统计摘要。 + String get label => '扫描 $scanned,跳过 $skipped,成功 $succeeded,失败 $failed'; +} diff --git a/lib/common/models/manage/scheduled_task_models.g.dart b/lib/common/models/manage/scheduled_task_models.g.dart new file mode 100644 index 0000000..a090826 --- /dev/null +++ b/lib/common/models/manage/scheduled_task_models.g.dart @@ -0,0 +1,100 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'scheduled_task_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ScheduledTaskConfig _$ScheduledTaskConfigFromJson(Map json) => + ScheduledTaskConfig( + type: $enumDecode(_$ScheduledTaskTypeEnumMap, json['type']), + enabled: json['enabled'] as bool, + cron: json['cron'] as String, + idleOnly: json['idleOnly'] as bool, + ); + +Map _$ScheduledTaskConfigToJson( + ScheduledTaskConfig instance) => + { + 'type': _$ScheduledTaskTypeEnumMap[instance.type]!, + 'enabled': instance.enabled, + 'cron': instance.cron, + 'idleOnly': instance.idleOnly, + }; + +const _$ScheduledTaskTypeEnumMap = { + ScheduledTaskType.mqReconnect: 'mqReconnect', + ScheduledTaskType.printCheck: 'printCheck', + ScheduledTaskType.broadcastCheck: 'broadcastCheck', + ScheduledTaskType.deviceStatus: 'deviceStatus', + ScheduledTaskType.configSync: 'configSync', + ScheduledTaskType.classifySync: 'classifySync', + ScheduledTaskType.productSync: 'productSync', + ScheduledTaskType.offlineOrderSync: 'offlineOrderSync', + ScheduledTaskType.terminalLogUpload: 'terminalLogUpload', + ScheduledTaskType.expiredLogCleanup: 'expiredLogCleanup', +}; + +ScheduledTaskRecord _$ScheduledTaskRecordFromJson(Map json) => + ScheduledTaskRecord( + lastRunAt: json['lastRunAt'] == null + ? null + : DateTime.parse(json['lastRunAt'] as String), + lastSuccessAt: json['lastSuccessAt'] == null + ? null + : DateTime.parse(json['lastSuccessAt'] as String), + success: json['success'] as bool? ?? true, + message: json['message'] as String? ?? '尚未执行', + ); + +Map _$ScheduledTaskRecordToJson( + ScheduledTaskRecord instance) => + { + 'lastRunAt': instance.lastRunAt?.toIso8601String(), + 'lastSuccessAt': instance.lastSuccessAt?.toIso8601String(), + 'success': instance.success, + 'message': instance.message, + }; + +TerminalLogUploadState _$TerminalLogUploadStateFromJson( + Map json) => + TerminalLogUploadState( + relativePath: json['relativePath'] as String, + sourceSize: (json['sourceSize'] as num).toInt(), + sourceModifiedTime: DateTime.parse(json['sourceModifiedTime'] as String), + sanitizedHash: json['sanitizedHash'] as String, + uploadedAt: DateTime.parse(json['uploadedAt'] as String), + ); + +Map _$TerminalLogUploadStateToJson( + TerminalLogUploadState instance) => + { + 'relativePath': instance.relativePath, + 'sourceSize': instance.sourceSize, + 'sourceModifiedTime': instance.sourceModifiedTime.toIso8601String(), + 'sanitizedHash': instance.sanitizedHash, + 'uploadedAt': instance.uploadedAt.toIso8601String(), + }; + +TerminalLogUploadSummary _$TerminalLogUploadSummaryFromJson( + Map json) => + TerminalLogUploadSummary( + scanned: (json['scanned'] as num?)?.toInt() ?? 0, + skipped: (json['skipped'] as num?)?.toInt() ?? 0, + succeeded: (json['succeeded'] as num?)?.toInt() ?? 0, + failed: (json['failed'] as num?)?.toInt() ?? 0, + lastSuccessAt: json['lastSuccessAt'] == null + ? null + : DateTime.parse(json['lastSuccessAt'] as String), + ); + +Map _$TerminalLogUploadSummaryToJson( + TerminalLogUploadSummary instance) => + { + 'scanned': instance.scanned, + 'skipped': instance.skipped, + 'succeeded': instance.succeeded, + 'failed': instance.failed, + 'lastSuccessAt': instance.lastSuccessAt?.toIso8601String(), + }; diff --git a/lib/common/models/mq/mq_enum_models.dart b/lib/common/models/mq/mq_enum_models.dart index 4399989..564062c 100644 --- a/lib/common/models/mq/mq_enum_models.dart +++ b/lib/common/models/mq/mq_enum_models.dart @@ -1,21 +1,46 @@ import 'package:pj_supermarket_cloud/common/index.dart'; +/// 服务端与终端约定的 MQTT 动作枚举,编码属于跨端协议,不能随展示文案修改。 enum MqtActionEnum implements BaseEnum { + /// 通知终端重新同步设备设置。 CONFIG("10", "设置变化"), + + /// 通知终端重新同步食堂商品。 CANTEEN_PRODUCT("20", "食堂商品变化"), + + /// 通知终端重新同步食堂分类。 CANTEEN_CLASSIFY("30", "食堂分类变化"), + + /// 通知终端重新同步超市商品。 SUPERMARKET_PRODUCT("40", "超市商品变化"), + + /// 通知终端重新同步超市分类。 SUPERMARKET_CLASSIFY("50", "超市分类变化"), + + /// 通知终端同步超市业务数据的历史兼容动作。 SUPERMARKET("51", "超市分类变化"), + + /// 通知终端处理或同步打印任务。 PRINT("60", "打印变化"), + + /// 通知终端刷新人脸数据的保留动作。 FACE("70", "人脸变化"), + + /// 通知终端处理取餐广播。 TAKE_FOOD_BROADCAST("80", "定餐广播"), + + /// 指定终端在业务安全窗口上传当前日志文件。 + PULL_TERMINAL_LOG("90", "拉取终端当前日志"), ; + /// 服务端消息中持久化和传输的稳定动作编码。 @override final String code; + + /// 日志和界面使用的中文动作名称。 @override final String title; + /// 创建一项 MQTT 协议动作,编码与标题均由枚举常量固定。 const MqtActionEnum(this.code, this.title); } diff --git a/lib/common/models/mq/mq_models.g.dart b/lib/common/models/mq/mq_models.g.dart index 5ae0b68..7c14fc9 100644 --- a/lib/common/models/mq/mq_models.g.dart +++ b/lib/common/models/mq/mq_models.g.dart @@ -24,6 +24,7 @@ const _$MqtActionEnumEnumMap = { MqtActionEnum.PRINT: 'PRINT', MqtActionEnum.FACE: 'FACE', MqtActionEnum.TAKE_FOOD_BROADCAST: 'TAKE_FOOD_BROADCAST', + MqtActionEnum.PULL_TERMINAL_LOG: 'PULL_TERMINAL_LOG', }; MqttInvoiceMsg _$MqttInvoiceMsgFromJson(Map json) => diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index ed17a95..0ff8e67 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -1,3 +1,4 @@ +/// 应用全部命名路由的稳定名称集合,字符串会被导航守卫和嵌套 Navigator 共同使用。 class RouteNames { /// 加载页面 static const load = '/load'; @@ -41,6 +42,42 @@ class RouteNames { /// 设置 static const setting = '$terminal/setting'; + /// 设置模块默认的基本信息子路由。 + static const settingBaseInfo = '$setting/base-info'; + + /// 设置模块的同步任务子路由。 + static const settingSync = '$setting/sync'; + + /// 设置模块的资源备份子路由。 + static const settingBackup = '$setting/backup'; + + /// 设置模块的人脸管理子路由。 + static const settingFace = '$setting/face'; + + /// 设置模块的人脸插件配置子路由。 + static const settingFacePlugin = '$setting/face-plugin'; + + /// 设置模块的终端页面配置子路由。 + static const settingPage = '$setting/page'; + + /// 设置模块的本地商品管理子路由。 + static const settingProduct = '$setting/product'; + + /// 设置模块的终端文件日志预览子路由。 + static const settingLogPreview = '$setting/log-preview'; + + /// 设置模块的副屏路由调试子路由。 + static const settingSubScreenRoutes = '$setting/sub-screen-routes'; + + /// 设置模块的终端版本管理子路由。 + static const settingVersion = '$setting/version'; + + /// 设置模块的读写卡配置子路由。 + static const settingCardReader = '$setting/card-reader'; + + /// 设置模块的结构化定时任务日志详情路由,任务类型通过路由参数传递。 + static const settingSyncLogs = '$setting/sync/logs'; + /// 饭卡 static const cardActive = '$terminal/card_active'; } diff --git a/lib/common/services/database/dao/file_upload_record_dao.dart b/lib/common/services/database/dao/file_upload_record_dao.dart new file mode 100644 index 0000000..509646b --- /dev/null +++ b/lib/common/services/database/dao/file_upload_record_dao.dart @@ -0,0 +1,40 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// `file_upload_record` 本机文件上传状态的数据访问接口。 +@dao +abstract class FileUploadRecordDao { + /// 保存文件状态并返回主键;相同主键时替换原记录以保留同一路径的最新状态。 + @Insert(onConflict: OnConflictStrategy.replace) + Future save(FileUploadRecordEntity entity); + + /// 按主键更新已经存在的文件上传状态。 + @update + Future edit(FileUploadRecordEntity entity); + + /// 查询指定设备与业务类型的全部文件状态,并按最近更新时间倒序返回。 + @Query(''' + SELECT * FROM file_upload_record + WHERE hardId = :hardId AND bizType = :bizType + ORDER BY updateTime DESC + ''') + Future> getAll( + String hardId, + String bizType, + ); + + /// 按设备、业务类型和相对路径查询唯一状态记录;不存在时返回空。 + @Query(''' + SELECT * FROM file_upload_record + WHERE hardId = :hardId + AND bizType = :bizType + AND relativePath = :relativePath + LIMIT 1 + ''') + Future getByPath( + String hardId, + String bizType, + String relativePath, + ); +} diff --git a/lib/common/services/database/dao/scheduled_task_dao.dart b/lib/common/services/database/dao/scheduled_task_dao.dart new file mode 100644 index 0000000..5a7d8e3 --- /dev/null +++ b/lib/common/services/database/dao/scheduled_task_dao.dart @@ -0,0 +1,27 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// `scheduled_task` 配置和最近执行快照的数据访问接口。 +@dao +abstract class ScheduledTaskDao { + /// 按 taskCode 保存单条任务;主键冲突时以新配置和执行快照整体替换旧记录。 + @Insert(onConflict: OnConflictStrategy.replace) + Future save(ScheduledTaskEntity entity); + + /// 批量补齐默认任务;每条记录均按 taskCode 使用替换冲突策略。 + @Insert(onConflict: OnConflictStrategy.replace) + Future batchSave(List entities); + + /// 按实体主键更新一条已经存在的任务记录。 + @update + Future edit(ScheduledTaskEntity entity); + + /// 查询全部任务并按稳定 taskCode 升序返回,便于启动时构建确定性配置映射。 + @Query('SELECT * FROM scheduled_task ORDER BY taskCode') + Future> getAll(); + + /// 按稳定 taskCode 查询单条任务;不存在时返回空。 + @Query('SELECT * FROM scheduled_task WHERE taskCode = :taskCode LIMIT 1') + Future getByCode(String taskCode); +} diff --git a/lib/common/services/database/dao/scheduled_task_log_dao.dart b/lib/common/services/database/dao/scheduled_task_log_dao.dart new file mode 100644 index 0000000..06635e2 --- /dev/null +++ b/lib/common/services/database/dao/scheduled_task_log_dao.dart @@ -0,0 +1,70 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// `scheduled_task_log` 结构化执行日志的数据访问接口。 +@dao +abstract class ScheduledTaskLogDao { + /// 新增任务开始日志并返回 SQLite 自增主键。 + @insert + Future save(ScheduledTaskLogEntity entity); + + /// 按日志主键写回结束状态、耗时、错误和统计字段。 + @update + Future edit(ScheduledTaskLogEntity entity); + + /// 按任务编码查询最近日志,可选依赖类型过滤,并按主键倒序限制返回数量。 + @Query(''' + SELECT * FROM scheduled_task_log + WHERE taskCode = :taskCode + AND (:sourceType = '' OR sourceType = :sourceType) + ORDER BY id DESC + LIMIT :limit + ''') + Future> getByTask( + String taskCode, + String sourceType, + int limit, + ); + + /// 按任务编码和可选来源分页查询日志,使用主键倒序保证翻页期间最新记录始终位于前页。 + @Query(''' + SELECT * FROM scheduled_task_log + WHERE taskCode = :taskCode + AND (:sourceType = '' OR sourceType = :sourceType) + ORDER BY id DESC + LIMIT :pageSize OFFSET :offset + ''') + Future> getPageByTask( + String taskCode, + String sourceType, + int pageSize, + int offset, + ); + + /// 统计指定任务和可选来源下的日志总数,为分页组件提供准确的总记录数。 + @Query(''' + SELECT COUNT(0) FROM scheduled_task_log + WHERE taskCode = :taskCode + AND (:sourceType = '' OR sourceType = :sourceType) + ''') + Future countByTask(String taskCode, String sourceType); + + /// 查询指定任务最近一条日志;从未执行时返回空。 + @Query(''' + SELECT * FROM scheduled_task_log + WHERE taskCode = :taskCode + ORDER BY id DESC + LIMIT 1 + ''') + Future getLatest(String taskCode); + + /// 终端长期常驻,执行日志只保留最近 2000 条,避免数据库无限增长。 + @Query(''' + DELETE FROM scheduled_task_log + WHERE id NOT IN ( + SELECT id FROM scheduled_task_log ORDER BY id DESC LIMIT 2000 + ) + ''') + Future trimHistory(); +} diff --git a/lib/common/services/database/database.dart b/lib/common/services/database/database.dart index 87c5f8b..d8374e8 100644 --- a/lib/common/services/database/database.dart +++ b/lib/common/services/database/database.dart @@ -9,6 +9,7 @@ import 'package:sqflite/sqflite.dart' as sqflite; part 'database.g.dart'; +/// Floor 代码生成器使用的全部业务实体类型,新增实体时必须同步数据库版本和连续迁移。 const entities = [ BroadcastEntity, OrderEntity, @@ -23,8 +24,12 @@ const entities = [ UserEntity, FaceEntity, SettingEntity, + ScheduledTaskEntity, + ScheduledTaskLogEntity, + FileUploadRecordEntity, ]; +/// Floor 在实体字段与 SQLite 基础类型之间使用的全局转换器集合。 const typeConverters = [ DateTimeConverter, DateTimeEmptyConverter, @@ -41,23 +46,59 @@ const typeConverters = [ BoolConverter, ]; -@Database(version: 7, entities: entities) +/// 收银终端共享 SQLite 数据库入口,统一暴露各业务 DAO 和带迁移的建库方法。 +@Database(version: 8, entities: entities) @TypeConverters(typeConverters) abstract class AppDatabase extends FloorDatabase { + /// 取餐广播队列 DAO。 BroadcastDao get getBroadcastDao; + + /// 本地订单及离线订单 DAO。 OrderDao get getOrderDao; + + /// 挂单数据 DAO。 OrderHoldBillDao get getOrderHoldBillDao; + + /// 食堂商品 DAO。 CanteenSpuDao get getCanteenSpuDao; + + /// 超市 SKU DAO。 SupermarketSkuDao get getSupermarketSkuDao; + + /// 超市 SPU DAO。 SupermarketSpuDao get getSupermarketSpuDao; + + /// 超市商品条码 DAO。 SupermarketCodeDao get getSupermarketCodeDao; + + /// 店铺卡配置 DAO。 ShopCardDao get getShopCardDao; + + /// 商品分类 DAO。 ClassifyDao get getClassifyDao; + + /// 打印历史记录 DAO。 PrintHistoryDao get getPrintHistoryDao; + + /// 本地用户数据 DAO。 UserDao get getUserDao; + + /// 本地人脸数据 DAO。 FaceDao get getFaceDao; + + /// 终端设置 DAO。 SettingDao get getSettingDao; + /// 定时任务配置及最近执行快照 DAO。 + ScheduledTaskDao get getScheduledTaskDao; + + /// 定时任务结构化执行日志 DAO。 + ScheduledTaskLogDao get getScheduledTaskLogDao; + + /// 文件上传状态与幂等快照 DAO。 + FileUploadRecordDao get getFileUploadRecordDao; + + /// 使用统一外部存储路径打开数据库并按版本顺序执行全部 Floor Migration。 static Future getDataBase() async { final dbPath = await AppFileTool.getDatabasePath(); final name = "$dbPath/${Constants.databaseName}"; diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 0fff027..c965784 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -98,13 +98,19 @@ class _$AppDatabase extends AppDatabase { SettingDao? _getSettingDaoInstance; + ScheduledTaskDao? _getScheduledTaskDaoInstance; + + ScheduledTaskLogDao? _getScheduledTaskLogDaoInstance; + + FileUploadRecordDao? _getFileUploadRecordDaoInstance; + Future open( String path, List migrations, [ Callback? callback, ]) async { final databaseOptions = sqflite.OpenDatabaseOptions( - version: 7, + version: 8, onConfigure: (database) async { await database.execute('PRAGMA foreign_keys = ON'); await callback?.onConfigure?.call(database); @@ -145,6 +151,20 @@ class _$AppDatabase extends AppDatabase { 'CREATE TABLE IF NOT EXISTS `face` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `sampleId` TEXT NOT NULL, `userName` TEXT NOT NULL, `deptId` TEXT, `deptName` TEXT, `imagePath` TEXT NOT NULL, `path` TEXT, `faceModel` TEXT, `createTime` TEXT, `updateTime` TEXT)'); await database.execute( 'CREATE TABLE IF NOT EXISTS `setting` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userJson` TEXT NOT NULL, `sysJson` TEXT NOT NULL, `canteenExtraPriceConfigJson` TEXT)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `scheduled_task` (`taskCode` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `cronExpression` TEXT NOT NULL, `idleOnly` INTEGER NOT NULL, `lastRunAt` TEXT, `lastSuccessAt` TEXT, `lastStatus` TEXT NOT NULL, `lastMessage` TEXT NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, PRIMARY KEY (`taskCode`))'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `scheduled_task_log` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `taskCode` TEXT NOT NULL, `taskName` TEXT NOT NULL, `triggerType` TEXT NOT NULL, `sourceType` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `status` TEXT NOT NULL, `message` TEXT NOT NULL, `errorType` TEXT, `errorMessage` TEXT, `errorStack` TEXT, `startedAt` TEXT NOT NULL, `finishedAt` TEXT, `durationMs` INTEGER NOT NULL, `scannedCount` INTEGER, `skippedCount` INTEGER, `successCount` INTEGER, `failureCount` INTEGER)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `file_upload_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `hardId` TEXT NOT NULL, `bizType` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `originalName` TEXT NOT NULL, `sourceSize` INTEGER NOT NULL, `sourceModifiedTime` TEXT NOT NULL, `sanitizedHash` TEXT NOT NULL, `sanitizedSize` INTEGER NOT NULL, `status` TEXT NOT NULL, `serverFileId` TEXT, `attemptCount` INTEGER NOT NULL, `lastAttemptAt` TEXT, `uploadedAt` TEXT, `lastError` TEXT, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL)'); + await database.execute( + 'CREATE INDEX `index_scheduled_task_log_taskCode_startedAt` ON `scheduled_task_log` (`taskCode`, `startedAt`)'); + await database.execute( + 'CREATE INDEX `index_scheduled_task_log_sourceType_status_startedAt` ON `scheduled_task_log` (`sourceType`, `status`, `startedAt`)'); + await database.execute( + 'CREATE UNIQUE INDEX `index_file_upload_record_hardId_bizType_relativePath` ON `file_upload_record` (`hardId`, `bizType`, `relativePath`)'); + await database.execute( + 'CREATE INDEX `index_file_upload_record_bizType_status_updateTime` ON `file_upload_record` (`bizType`, `status`, `updateTime`)'); await callback?.onCreate?.call(database, version); }, @@ -223,6 +243,24 @@ class _$AppDatabase extends AppDatabase { SettingDao get getSettingDao { return _getSettingDaoInstance ??= _$SettingDao(database, changeListener); } + + @override + ScheduledTaskDao get getScheduledTaskDao { + return _getScheduledTaskDaoInstance ??= + _$ScheduledTaskDao(database, changeListener); + } + + @override + ScheduledTaskLogDao get getScheduledTaskLogDao { + return _getScheduledTaskLogDaoInstance ??= + _$ScheduledTaskLogDao(database, changeListener); + } + + @override + FileUploadRecordDao get getFileUploadRecordDao { + return _getFileUploadRecordDaoInstance ??= + _$FileUploadRecordDao(database, changeListener); + } } class _$BroadcastDao extends BroadcastDao { @@ -1981,6 +2019,346 @@ class _$SettingDao extends SettingDao { } } +class _$ScheduledTaskDao extends ScheduledTaskDao { + _$ScheduledTaskDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _scheduledTaskEntityInsertionAdapter = InsertionAdapter( + database, + 'scheduled_task', + (ScheduledTaskEntity item) => { + 'taskCode': item.taskCode, + 'enabled': _boolConverter.encode(item.enabled), + 'cronExpression': item.cronExpression, + 'idleOnly': _boolConverter.encode(item.idleOnly), + 'lastRunAt': _dateTimeEmptyConverter.encode(item.lastRunAt), + 'lastSuccessAt': + _dateTimeEmptyConverter.encode(item.lastSuccessAt), + 'lastStatus': item.lastStatus, + 'lastMessage': item.lastMessage, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }), + _scheduledTaskEntityUpdateAdapter = UpdateAdapter( + database, + 'scheduled_task', + ['taskCode'], + (ScheduledTaskEntity item) => { + 'taskCode': item.taskCode, + 'enabled': _boolConverter.encode(item.enabled), + 'cronExpression': item.cronExpression, + 'idleOnly': _boolConverter.encode(item.idleOnly), + 'lastRunAt': _dateTimeEmptyConverter.encode(item.lastRunAt), + 'lastSuccessAt': + _dateTimeEmptyConverter.encode(item.lastSuccessAt), + 'lastStatus': item.lastStatus, + 'lastMessage': item.lastMessage, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _scheduledTaskEntityInsertionAdapter; + + final UpdateAdapter _scheduledTaskEntityUpdateAdapter; + + @override + Future> getAll() async { + return _queryAdapter.queryList( + 'SELECT * FROM scheduled_task ORDER BY taskCode', + mapper: (Map row) => ScheduledTaskEntity( + taskCode: row['taskCode'] as String, + enabled: _boolConverter.decode(row['enabled'] as int), + cronExpression: row['cronExpression'] as String, + idleOnly: _boolConverter.decode(row['idleOnly'] as int), + lastRunAt: + _dateTimeEmptyConverter.decode(row['lastRunAt'] as String), + lastSuccessAt: + _dateTimeEmptyConverter.decode(row['lastSuccessAt'] as String), + lastStatus: row['lastStatus'] as String, + lastMessage: row['lastMessage'] as String, + createTime: _dateTimeConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeConverter.decode(row['updateTime'] as String))); + } + + @override + Future getByCode(String taskCode) async { + return _queryAdapter.query( + 'SELECT * FROM scheduled_task WHERE taskCode = ?1 LIMIT 1', + mapper: (Map row) => ScheduledTaskEntity( + taskCode: row['taskCode'] as String, + enabled: _boolConverter.decode(row['enabled'] as int), + cronExpression: row['cronExpression'] as String, + idleOnly: _boolConverter.decode(row['idleOnly'] as int), + lastRunAt: + _dateTimeEmptyConverter.decode(row['lastRunAt'] as String), + lastSuccessAt: + _dateTimeEmptyConverter.decode(row['lastSuccessAt'] as String), + lastStatus: row['lastStatus'] as String, + lastMessage: row['lastMessage'] as String, + createTime: _dateTimeConverter.decode(row['createTime'] as String), + updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [taskCode]); + } + + @override + Future save(ScheduledTaskEntity entity) async { + await _scheduledTaskEntityInsertionAdapter.insert( + entity, OnConflictStrategy.replace); + } + + @override + Future batchSave(List entities) async { + await _scheduledTaskEntityInsertionAdapter.insertList( + entities, OnConflictStrategy.replace); + } + + @override + Future edit(ScheduledTaskEntity entity) async { + await _scheduledTaskEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } +} + +class _$ScheduledTaskLogDao extends ScheduledTaskLogDao { + _$ScheduledTaskLogDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _scheduledTaskLogEntityInsertionAdapter = InsertionAdapter( + database, + 'scheduled_task_log', + (ScheduledTaskLogEntity item) => { + 'id': item.id, + 'taskCode': item.taskCode, + 'taskName': item.taskName, + 'triggerType': item.triggerType, + 'sourceType': item.sourceType, + 'sourceName': item.sourceName, + 'status': item.status, + 'message': item.message, + 'errorType': item.errorType, + 'errorMessage': item.errorMessage, + 'errorStack': item.errorStack, + 'startedAt': _dateTimeConverter.encode(item.startedAt), + 'finishedAt': _dateTimeEmptyConverter.encode(item.finishedAt), + 'durationMs': item.durationMs, + 'scannedCount': item.scannedCount, + 'skippedCount': item.skippedCount, + 'successCount': item.successCount, + 'failureCount': item.failureCount + }), + _scheduledTaskLogEntityUpdateAdapter = UpdateAdapter( + database, + 'scheduled_task_log', + ['id'], + (ScheduledTaskLogEntity item) => { + 'id': item.id, + 'taskCode': item.taskCode, + 'taskName': item.taskName, + 'triggerType': item.triggerType, + 'sourceType': item.sourceType, + 'sourceName': item.sourceName, + 'status': item.status, + 'message': item.message, + 'errorType': item.errorType, + 'errorMessage': item.errorMessage, + 'errorStack': item.errorStack, + 'startedAt': _dateTimeConverter.encode(item.startedAt), + 'finishedAt': _dateTimeEmptyConverter.encode(item.finishedAt), + 'durationMs': item.durationMs, + 'scannedCount': item.scannedCount, + 'skippedCount': item.skippedCount, + 'successCount': item.successCount, + 'failureCount': item.failureCount + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _scheduledTaskLogEntityInsertionAdapter; + + final UpdateAdapter + _scheduledTaskLogEntityUpdateAdapter; + + @override + Future> getByTask( + String taskCode, + String sourceType, + int limit, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 AND (?2 = \'\' OR sourceType = ?2) ORDER BY id DESC LIMIT ?3', + mapper: (Map row) => ScheduledTaskLogEntity(id: row['id'] as int?, taskCode: row['taskCode'] as String, taskName: row['taskName'] as String, triggerType: row['triggerType'] as String, sourceType: row['sourceType'] as String, sourceName: row['sourceName'] as String, status: row['status'] as String, message: row['message'] as String, errorType: row['errorType'] as String?, errorMessage: row['errorMessage'] as String?, errorStack: row['errorStack'] as String?, startedAt: _dateTimeConverter.decode(row['startedAt'] as String), finishedAt: _dateTimeEmptyConverter.decode(row['finishedAt'] as String), durationMs: row['durationMs'] as int, scannedCount: row['scannedCount'] as int?, skippedCount: row['skippedCount'] as int?, successCount: row['successCount'] as int?, failureCount: row['failureCount'] as int?), + arguments: [taskCode, sourceType, limit]); + } + + @override + Future> getPageByTask( + String taskCode, + String sourceType, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 AND (?2 = \'\' OR sourceType = ?2) ORDER BY id DESC LIMIT ?3 OFFSET ?4', + mapper: (Map row) => ScheduledTaskLogEntity(id: row['id'] as int?, taskCode: row['taskCode'] as String, taskName: row['taskName'] as String, triggerType: row['triggerType'] as String, sourceType: row['sourceType'] as String, sourceName: row['sourceName'] as String, status: row['status'] as String, message: row['message'] as String, errorType: row['errorType'] as String?, errorMessage: row['errorMessage'] as String?, errorStack: row['errorStack'] as String?, startedAt: _dateTimeConverter.decode(row['startedAt'] as String), finishedAt: _dateTimeEmptyConverter.decode(row['finishedAt'] as String), durationMs: row['durationMs'] as int, scannedCount: row['scannedCount'] as int?, skippedCount: row['skippedCount'] as int?, successCount: row['successCount'] as int?, failureCount: row['failureCount'] as int?), + arguments: [taskCode, sourceType, pageSize, offset]); + } + + @override + Future countByTask( + String taskCode, + String sourceType, + ) async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM scheduled_task_log WHERE taskCode = ?1 AND (?2 = \'\' OR sourceType = ?2)', + mapper: (Map row) => row.values.first as int, + arguments: [taskCode, sourceType]); + } + + @override + Future getLatest(String taskCode) async { + return _queryAdapter.query( + 'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 ORDER BY id DESC LIMIT 1', + mapper: (Map row) => ScheduledTaskLogEntity(id: row['id'] as int?, taskCode: row['taskCode'] as String, taskName: row['taskName'] as String, triggerType: row['triggerType'] as String, sourceType: row['sourceType'] as String, sourceName: row['sourceName'] as String, status: row['status'] as String, message: row['message'] as String, errorType: row['errorType'] as String?, errorMessage: row['errorMessage'] as String?, errorStack: row['errorStack'] as String?, startedAt: _dateTimeConverter.decode(row['startedAt'] as String), finishedAt: _dateTimeEmptyConverter.decode(row['finishedAt'] as String), durationMs: row['durationMs'] as int, scannedCount: row['scannedCount'] as int?, skippedCount: row['skippedCount'] as int?, successCount: row['successCount'] as int?, failureCount: row['failureCount'] as int?), + arguments: [taskCode]); + } + + @override + Future trimHistory() async { + await _queryAdapter.queryNoReturn( + 'DELETE FROM scheduled_task_log WHERE id NOT IN ( SELECT id FROM scheduled_task_log ORDER BY id DESC LIMIT 2000 )'); + } + + @override + Future save(ScheduledTaskLogEntity entity) { + return _scheduledTaskLogEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.abort); + } + + @override + Future edit(ScheduledTaskLogEntity entity) async { + await _scheduledTaskLogEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } +} + +class _$FileUploadRecordDao extends FileUploadRecordDao { + _$FileUploadRecordDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _fileUploadRecordEntityInsertionAdapter = InsertionAdapter( + database, + 'file_upload_record', + (FileUploadRecordEntity item) => { + 'id': item.id, + 'hardId': item.hardId, + 'bizType': item.bizType, + 'relativePath': item.relativePath, + 'originalName': item.originalName, + 'sourceSize': item.sourceSize, + 'sourceModifiedTime': + _dateTimeConverter.encode(item.sourceModifiedTime), + 'sanitizedHash': item.sanitizedHash, + 'sanitizedSize': item.sanitizedSize, + 'status': item.status, + 'serverFileId': item.serverFileId, + 'attemptCount': item.attemptCount, + 'lastAttemptAt': + _dateTimeEmptyConverter.encode(item.lastAttemptAt), + 'uploadedAt': _dateTimeEmptyConverter.encode(item.uploadedAt), + 'lastError': item.lastError, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }), + _fileUploadRecordEntityUpdateAdapter = UpdateAdapter( + database, + 'file_upload_record', + ['id'], + (FileUploadRecordEntity item) => { + 'id': item.id, + 'hardId': item.hardId, + 'bizType': item.bizType, + 'relativePath': item.relativePath, + 'originalName': item.originalName, + 'sourceSize': item.sourceSize, + 'sourceModifiedTime': + _dateTimeConverter.encode(item.sourceModifiedTime), + 'sanitizedHash': item.sanitizedHash, + 'sanitizedSize': item.sanitizedSize, + 'status': item.status, + 'serverFileId': item.serverFileId, + 'attemptCount': item.attemptCount, + 'lastAttemptAt': + _dateTimeEmptyConverter.encode(item.lastAttemptAt), + 'uploadedAt': _dateTimeEmptyConverter.encode(item.uploadedAt), + 'lastError': item.lastError, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _fileUploadRecordEntityInsertionAdapter; + + final UpdateAdapter + _fileUploadRecordEntityUpdateAdapter; + + @override + Future> getAll( + String hardId, + String bizType, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM file_upload_record WHERE hardId = ?1 AND bizType = ?2 ORDER BY updateTime DESC', + mapper: (Map row) => FileUploadRecordEntity(id: row['id'] as int?, hardId: row['hardId'] as String, bizType: row['bizType'] as String, relativePath: row['relativePath'] as String, originalName: row['originalName'] as String, sourceSize: row['sourceSize'] as int, sourceModifiedTime: _dateTimeConverter.decode(row['sourceModifiedTime'] as String), sanitizedHash: row['sanitizedHash'] as String, sanitizedSize: row['sanitizedSize'] as int, status: row['status'] as String, serverFileId: row['serverFileId'] as String?, attemptCount: row['attemptCount'] as int, lastAttemptAt: _dateTimeEmptyConverter.decode(row['lastAttemptAt'] as String), uploadedAt: _dateTimeEmptyConverter.decode(row['uploadedAt'] as String), lastError: row['lastError'] as String?, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [hardId, bizType]); + } + + @override + Future getByPath( + String hardId, + String bizType, + String relativePath, + ) async { + return _queryAdapter.query( + 'SELECT * FROM file_upload_record WHERE hardId = ?1 AND bizType = ?2 AND relativePath = ?3 LIMIT 1', + mapper: (Map row) => FileUploadRecordEntity(id: row['id'] as int?, hardId: row['hardId'] as String, bizType: row['bizType'] as String, relativePath: row['relativePath'] as String, originalName: row['originalName'] as String, sourceSize: row['sourceSize'] as int, sourceModifiedTime: _dateTimeConverter.decode(row['sourceModifiedTime'] as String), sanitizedHash: row['sanitizedHash'] as String, sanitizedSize: row['sanitizedSize'] as int, status: row['status'] as String, serverFileId: row['serverFileId'] as String?, attemptCount: row['attemptCount'] as int, lastAttemptAt: _dateTimeEmptyConverter.decode(row['lastAttemptAt'] as String), uploadedAt: _dateTimeEmptyConverter.decode(row['uploadedAt'] as String), lastError: row['lastError'] as String?, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [hardId, bizType, relativePath]); + } + + @override + Future save(FileUploadRecordEntity entity) { + return _fileUploadRecordEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.replace); + } + + @override + Future edit(FileUploadRecordEntity entity) async { + await _fileUploadRecordEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } +} + // ignore_for_file: unused_element final _dateTimeConverter = DateTimeConverter(); final _dateTimeEmptyConverter = DateTimeEmptyConverter(); diff --git a/lib/common/services/database/database_migration.dart b/lib/common/services/database/database_migration.dart index c68bd69..eec4b71 100644 --- a/lib/common/services/database/database_migration.dart +++ b/lib/common/services/database/database_migration.dart @@ -1,5 +1,6 @@ import 'package:floor/floor.dart'; +/// 收银终端 SQLite 连续版本迁移清单,禁止跳过中间版本或在原生层重复执行结构 SQL。 abstract class DatabaseMigration { /// 从版本1到版本2的迁移:添加productNamePy字段 static Migration migration1To2 = Migration(1, 2, (database) async { @@ -183,6 +184,84 @@ abstract class DatabaseMigration { } }); + /// 版本8将调度配置、执行日志和文件上传状态从 sysJson 收敛为可查询的结构化表。 + static Migration migration7To8 = Migration(7, 8, (database) async { + await database.execute(''' + CREATE TABLE IF NOT EXISTS scheduled_task ( + taskCode TEXT NOT NULL PRIMARY KEY, + enabled INTEGER NOT NULL, + cronExpression TEXT NOT NULL, + idleOnly INTEGER NOT NULL, + lastRunAt TEXT, + lastSuccessAt TEXT, + lastStatus TEXT NOT NULL, + lastMessage TEXT NOT NULL, + createTime TEXT NOT NULL, + updateTime TEXT NOT NULL + ) + '''); + await database.execute(''' + CREATE TABLE IF NOT EXISTS scheduled_task_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + taskCode TEXT NOT NULL, + taskName TEXT NOT NULL, + triggerType TEXT NOT NULL, + sourceType TEXT NOT NULL, + sourceName TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT NOT NULL, + errorType TEXT, + errorMessage TEXT, + errorStack TEXT, + startedAt TEXT NOT NULL, + finishedAt TEXT, + durationMs INTEGER NOT NULL, + scannedCount INTEGER, + skippedCount INTEGER, + successCount INTEGER, + failureCount INTEGER + ) + '''); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_scheduled_task_log_taskCode_startedAt ' + 'ON scheduled_task_log(taskCode, startedAt)', + ); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_scheduled_task_log_sourceType_status_startedAt ' + 'ON scheduled_task_log(sourceType, status, startedAt)', + ); + await database.execute(''' + CREATE TABLE IF NOT EXISTS file_upload_record ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hardId TEXT NOT NULL, + bizType TEXT NOT NULL, + relativePath TEXT NOT NULL, + originalName TEXT NOT NULL, + sourceSize INTEGER NOT NULL, + sourceModifiedTime TEXT NOT NULL, + sanitizedHash TEXT NOT NULL, + sanitizedSize INTEGER NOT NULL, + status TEXT NOT NULL, + serverFileId TEXT, + attemptCount INTEGER NOT NULL, + lastAttemptAt TEXT, + uploadedAt TEXT, + lastError TEXT, + createTime TEXT NOT NULL, + updateTime TEXT NOT NULL + ) + '''); + await database.execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS index_file_upload_record_hardId_bizType_relativePath ' + 'ON file_upload_record(hardId, bizType, relativePath)', + ); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_file_upload_record_bizType_status_updateTime ' + 'ON file_upload_record(bizType, status, updateTime)', + ); + }); + + /// 按起始版本升序返回全部迁移,Floor 打开旧数据库时据此连续升级到当前版本。 static List getDatabaseMigrations() { return [ migration1To2, @@ -191,6 +270,7 @@ abstract class DatabaseMigration { migration4To5, migration5To6, migration6To7, + migration7To8, ]; } } diff --git a/lib/common/services/database/entity/file_upload_record_entity.dart b/lib/common/services/database/entity/file_upload_record_entity.dart new file mode 100644 index 0000000..2c8fe3f --- /dev/null +++ b/lib/common/services/database/entity/file_upload_record_entity.dart @@ -0,0 +1,97 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'file_upload_record_entity.g.dart'; + +/// 本机文件上传记录。 +/// +/// 目前 [bizType] 使用 `terminal_log`,保留业务类型后可复用到数据库备份等文件任务。 +@JsonSerializable() +@Entity( + tableName: 'file_upload_record', + indices: [ + Index(value: ['hardId', 'bizType', 'relativePath'], unique: true), + Index(value: ['bizType', 'status', 'updateTime']), + ], +) +class FileUploadRecordEntity { + /// SQLite 自增主键;首次保存前为空。 + @PrimaryKey(autoGenerate: true) + final int? id; + + /// 终端绑定的设备 ID,用于隔离设备重新绑定前后的文件状态。 + final String hardId; + + /// 文件业务类型;终端日志固定使用 `terminal_log`。 + final String bizType; + + /// 文件在业务根目录下使用正斜杠的相对路径。 + final String relativePath; + + /// 原始文件名,不包含目录。 + final String originalName; + + /// 生成脱敏副本时读取的原文件快照大小,单位为字节。 + final int sourceSize; + + /// 生成脱敏副本时原文件的最后修改时间。 + final DateTime sourceModifiedTime; + + /// 脱敏后文件内容的 MD5 哈希;首次尝试尚未完成脱敏时可为空字符串。 + final String sanitizedHash; + + /// 脱敏后文件大小,单位为字节;尚未完成脱敏时为零。 + final int sanitizedSize; + + /// 最近一次上传尝试状态,保存 [FileUploadStatus.name]。 + final String status; + + /// 后端 hard_terminal_log_file 记录 ID;预检判定无需上传且本机无旧记录时可为空。 + final String? serverFileId; + + /// 对该路径发起过的上传尝试次数,预检前即递增。 + final int attemptCount; + + /// 最近一次开始上传尝试的本地时间;从未尝试时为空。 + final DateTime? lastAttemptAt; + + /// 最近一次被服务端确认成功或无需上传的本地时间。 + final DateTime? uploadedAt; + + /// 最近一次失败的截断错误摘要;成功记录为空。 + final String? lastError; + + /// 当前设备与业务类型下首次创建该路径记录的本地时间。 + final DateTime createTime; + + /// 状态、快照或服务端 ID 最近一次更新的本地时间。 + final DateTime updateTime; + + /// 创建一条文件上传状态记录。 + const FileUploadRecordEntity({ + this.id, + required this.hardId, + required this.bizType, + required this.relativePath, + required this.originalName, + required this.sourceSize, + required this.sourceModifiedTime, + required this.sanitizedHash, + required this.sanitizedSize, + required this.status, + this.serverFileId, + this.attemptCount = 0, + this.lastAttemptAt, + this.uploadedAt, + this.lastError, + required this.createTime, + required this.updateTime, + }); + + /// 从 JSON 数据恢复上传记录,仅供调试序列化使用。 + factory FileUploadRecordEntity.fromJson(Map json) => + _$FileUploadRecordEntityFromJson(json); + + /// 将上传记录转换为 JSON,仅供调试序列化使用。 + Map toJson() => _$FileUploadRecordEntityToJson(this); +} diff --git a/lib/common/services/database/entity/file_upload_record_entity.g.dart b/lib/common/services/database/entity/file_upload_record_entity.g.dart new file mode 100644 index 0000000..a3cd8af --- /dev/null +++ b/lib/common/services/database/entity/file_upload_record_entity.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'file_upload_record_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FileUploadRecordEntity _$FileUploadRecordEntityFromJson( + Map json) => + FileUploadRecordEntity( + id: (json['id'] as num?)?.toInt(), + hardId: json['hardId'] as String, + bizType: json['bizType'] as String, + relativePath: json['relativePath'] as String, + originalName: json['originalName'] as String, + sourceSize: (json['sourceSize'] as num).toInt(), + sourceModifiedTime: DateTime.parse(json['sourceModifiedTime'] as String), + sanitizedHash: json['sanitizedHash'] as String, + sanitizedSize: (json['sanitizedSize'] as num).toInt(), + status: json['status'] as String, + serverFileId: json['serverFileId'] as String?, + attemptCount: (json['attemptCount'] as num?)?.toInt() ?? 0, + lastAttemptAt: json['lastAttemptAt'] == null + ? null + : DateTime.parse(json['lastAttemptAt'] as String), + uploadedAt: json['uploadedAt'] == null + ? null + : DateTime.parse(json['uploadedAt'] as String), + lastError: json['lastError'] as String?, + createTime: DateTime.parse(json['createTime'] as String), + updateTime: DateTime.parse(json['updateTime'] as String), + ); + +Map _$FileUploadRecordEntityToJson( + FileUploadRecordEntity instance) => + { + 'id': instance.id, + 'hardId': instance.hardId, + 'bizType': instance.bizType, + 'relativePath': instance.relativePath, + 'originalName': instance.originalName, + 'sourceSize': instance.sourceSize, + 'sourceModifiedTime': instance.sourceModifiedTime.toIso8601String(), + 'sanitizedHash': instance.sanitizedHash, + 'sanitizedSize': instance.sanitizedSize, + 'status': instance.status, + 'serverFileId': instance.serverFileId, + 'attemptCount': instance.attemptCount, + 'lastAttemptAt': instance.lastAttemptAt?.toIso8601String(), + 'uploadedAt': instance.uploadedAt?.toIso8601String(), + 'lastError': instance.lastError, + 'createTime': instance.createTime.toIso8601String(), + 'updateTime': instance.updateTime.toIso8601String(), + }; diff --git a/lib/common/services/database/entity/scheduled_task_entity.dart b/lib/common/services/database/entity/scheduled_task_entity.dart new file mode 100644 index 0000000..56b205b --- /dev/null +++ b/lib/common/services/database/entity/scheduled_task_entity.dart @@ -0,0 +1,87 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'scheduled_task_entity.g.dart'; + +/// 终端定时任务配置及最近一次执行快照。 +/// +/// [taskCode] 使用代码枚举名作为稳定主键,界面文案调整时不会产生重复任务。 +@JsonSerializable() +@Entity(tableName: 'scheduled_task') +class ScheduledTaskEntity { + /// 稳定任务编码,取 [ScheduledTaskType.name],作为表主键且不能随展示文案变化。 + @PrimaryKey() + final String taskCode; + + /// 是否启用 Cron 自动调度;关闭时仍可通过设置页手动触发。 + final bool enabled; + + /// cron 0.6.2 使用的六段表达式,字段顺序为秒、分、时、日、月、周。 + final String cronExpression; + + /// 自动任务是否必须等待连续 60 秒无人操作的完整空闲窗口。 + final bool idleOnly; + + /// 最近一次任务开始执行的本地时间;从未执行时为空。 + final DateTime? lastRunAt; + + /// 最近一次成功完成的本地时间;尚无成功记录时为空。 + final DateTime? lastSuccessAt; + + /// 最近一次运行状态,保存 [ScheduledTaskRunStatus.name]。 + final String lastStatus; + + /// 最近一次运行结果的简短中文摘要。 + final String lastMessage; + + /// 任务配置首次写入 SQLite 的本地时间。 + final DateTime createTime; + + /// 配置或执行快照最近一次更新的本地时间。 + final DateTime updateTime; + + /// 创建一条定时任务配置与最近执行快照。 + const ScheduledTaskEntity({ + required this.taskCode, + required this.enabled, + required this.cronExpression, + required this.idleOnly, + this.lastRunAt, + this.lastSuccessAt, + required this.lastStatus, + required this.lastMessage, + required this.createTime, + required this.updateTime, + }); + + /// 从 JSON 数据恢复实体,仅供兼容迁移和调试序列化使用。 + factory ScheduledTaskEntity.fromJson(Map json) => + _$ScheduledTaskEntityFromJson(json); + + /// 将实体转换为 JSON,仅供兼容迁移和调试序列化使用。 + Map toJson() => _$ScheduledTaskEntityToJson(this); + + /// 保留主键与创建时间,生成包含配置或最近执行结果变更的新实体。 + ScheduledTaskEntity copyWith({ + bool? enabled, + String? cronExpression, + bool? idleOnly, + DateTime? lastRunAt, + DateTime? lastSuccessAt, + String? lastStatus, + String? lastMessage, + DateTime? updateTime, + }) => + ScheduledTaskEntity( + taskCode: taskCode, + enabled: enabled ?? this.enabled, + cronExpression: cronExpression ?? this.cronExpression, + idleOnly: idleOnly ?? this.idleOnly, + lastRunAt: lastRunAt ?? this.lastRunAt, + lastSuccessAt: lastSuccessAt ?? this.lastSuccessAt, + lastStatus: lastStatus ?? this.lastStatus, + lastMessage: lastMessage ?? this.lastMessage, + createTime: createTime, + updateTime: updateTime ?? this.updateTime, + ); +} diff --git a/lib/common/services/database/entity/scheduled_task_entity.g.dart b/lib/common/services/database/entity/scheduled_task_entity.g.dart new file mode 100644 index 0000000..e3733b0 --- /dev/null +++ b/lib/common/services/database/entity/scheduled_task_entity.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'scheduled_task_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ScheduledTaskEntity _$ScheduledTaskEntityFromJson(Map json) => + ScheduledTaskEntity( + taskCode: json['taskCode'] as String, + enabled: json['enabled'] as bool, + cronExpression: json['cronExpression'] as String, + idleOnly: json['idleOnly'] as bool, + lastRunAt: json['lastRunAt'] == null + ? null + : DateTime.parse(json['lastRunAt'] as String), + lastSuccessAt: json['lastSuccessAt'] == null + ? null + : DateTime.parse(json['lastSuccessAt'] as String), + lastStatus: json['lastStatus'] as String, + lastMessage: json['lastMessage'] as String, + createTime: DateTime.parse(json['createTime'] as String), + updateTime: DateTime.parse(json['updateTime'] as String), + ); + +Map _$ScheduledTaskEntityToJson( + ScheduledTaskEntity instance) => + { + 'taskCode': instance.taskCode, + 'enabled': instance.enabled, + 'cronExpression': instance.cronExpression, + 'idleOnly': instance.idleOnly, + 'lastRunAt': instance.lastRunAt?.toIso8601String(), + 'lastSuccessAt': instance.lastSuccessAt?.toIso8601String(), + 'lastStatus': instance.lastStatus, + 'lastMessage': instance.lastMessage, + 'createTime': instance.createTime.toIso8601String(), + 'updateTime': instance.updateTime.toIso8601String(), + }; diff --git a/lib/common/services/database/entity/scheduled_task_log_entity.dart b/lib/common/services/database/entity/scheduled_task_log_entity.dart new file mode 100644 index 0000000..33ce01f --- /dev/null +++ b/lib/common/services/database/entity/scheduled_task_log_entity.dart @@ -0,0 +1,135 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'scheduled_task_log_entity.g.dart'; + +/// 定时任务的结构化执行日志。 +/// +/// 来源、触发方式、状态和统计值使用独立列,日志页面无需解析拼接文本即可筛选。 +@JsonSerializable() +@Entity( + tableName: 'scheduled_task_log', + indices: [ + Index(value: ['taskCode', 'startedAt']), + Index(value: ['sourceType', 'status', 'startedAt']), + ], +) +class ScheduledTaskLogEntity { + /// SQLite 自增主键;日志尚未插入数据库时为空。 + @PrimaryKey(autoGenerate: true) + final int? id; + + /// 任务稳定编码,关联 scheduled_task.taskCode,但不建立外键以兼容任务版本演进。 + final String taskCode; + + /// 执行发生时的任务展示名称快照。 + final String taskName; + + /// 触发来源,保存 [ScheduledTaskTriggerType.name]。 + final String triggerType; + + /// 主要依赖类型,保存 [ScheduledTaskSourceType.name]。 + final String sourceType; + + /// 执行发生时的依赖名称快照,例如 MQTT 消息服务或 HTTP 商品接口。 + final String sourceName; + + /// 本次执行状态,保存 [ScheduledTaskRunStatus.name]。 + final String status; + + /// 本次执行结果的简短中文摘要。 + final String message; + + /// 异常运行时的 Dart 异常类型;成功或跳过时为空。 + final String? errorType; + + /// 截断并脱敏后的异常摘要;成功或跳过时为空。 + final String? errorMessage; + + /// 截断后的异常堆栈,最多保存调度器允许的诊断长度。 + final String? errorStack; + + /// 任务开始执行的本地时间。 + final DateTime startedAt; + + /// 任务结束的本地时间;执行中日志为空。 + final DateTime? finishedAt; + + /// 从开始到结束的耗时,单位为毫秒;执行中默认为零。 + final int durationMs; + + /// 文件类任务扫描到的文件数量;不适用的任务为空。 + final int? scannedCount; + + /// 文件类任务因未变化或服务端已存在而跳过的数量;不适用时为空。 + final int? skippedCount; + + /// 文件类任务实际上传成功的数量;不适用时为空。 + final int? successCount; + + /// 文件类任务处理失败的数量;不适用时为空。 + final int? failureCount; + + /// 创建一条任务执行日志;开始记录尚未结束时不填写结束信息。 + const ScheduledTaskLogEntity({ + this.id, + required this.taskCode, + required this.taskName, + required this.triggerType, + required this.sourceType, + required this.sourceName, + required this.status, + required this.message, + this.errorType, + this.errorMessage, + this.errorStack, + required this.startedAt, + this.finishedAt, + this.durationMs = 0, + this.scannedCount, + this.skippedCount, + this.successCount, + this.failureCount, + }); + + /// 从 JSON 数据恢复任务日志,仅供调试序列化使用。 + factory ScheduledTaskLogEntity.fromJson(Map json) => + _$ScheduledTaskLogEntityFromJson(json); + + /// 将任务日志转换为 JSON,仅供调试序列化使用。 + Map toJson() => _$ScheduledTaskLogEntityToJson(this); + + /// 使用最终状态、错误和统计值生成同一条日志的完成快照。 + ScheduledTaskLogEntity finish({ + required String nextStatus, + required String nextMessage, + required DateTime at, + String? nextErrorType, + String? nextErrorMessage, + String? nextErrorStack, + int? scanned, + int? skipped, + int? succeeded, + int? failed, + }) => + ScheduledTaskLogEntity( + id: id, + taskCode: taskCode, + taskName: taskName, + triggerType: triggerType, + sourceType: sourceType, + sourceName: sourceName, + status: nextStatus, + message: nextMessage, + errorType: nextErrorType, + errorMessage: nextErrorMessage, + errorStack: nextErrorStack, + startedAt: startedAt, + finishedAt: at, + durationMs: at.difference(startedAt).inMilliseconds, + scannedCount: scanned, + skippedCount: skipped, + successCount: succeeded, + failureCount: failed, + ); +} diff --git a/lib/common/services/database/entity/scheduled_task_log_entity.g.dart b/lib/common/services/database/entity/scheduled_task_log_entity.g.dart new file mode 100644 index 0000000..641ffa2 --- /dev/null +++ b/lib/common/services/database/entity/scheduled_task_log_entity.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'scheduled_task_log_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ScheduledTaskLogEntity _$ScheduledTaskLogEntityFromJson( + Map json) => + ScheduledTaskLogEntity( + id: (json['id'] as num?)?.toInt(), + taskCode: json['taskCode'] as String, + taskName: json['taskName'] as String, + triggerType: json['triggerType'] as String, + sourceType: json['sourceType'] as String, + sourceName: json['sourceName'] as String, + status: json['status'] as String, + message: json['message'] as String, + errorType: json['errorType'] as String?, + errorMessage: json['errorMessage'] as String?, + errorStack: json['errorStack'] as String?, + startedAt: DateTime.parse(json['startedAt'] as String), + finishedAt: json['finishedAt'] == null + ? null + : DateTime.parse(json['finishedAt'] as String), + durationMs: (json['durationMs'] as num?)?.toInt() ?? 0, + scannedCount: (json['scannedCount'] as num?)?.toInt(), + skippedCount: (json['skippedCount'] as num?)?.toInt(), + successCount: (json['successCount'] as num?)?.toInt(), + failureCount: (json['failureCount'] as num?)?.toInt(), + ); + +Map _$ScheduledTaskLogEntityToJson( + ScheduledTaskLogEntity instance) => + { + 'id': instance.id, + 'taskCode': instance.taskCode, + 'taskName': instance.taskName, + 'triggerType': instance.triggerType, + 'sourceType': instance.sourceType, + 'sourceName': instance.sourceName, + 'status': instance.status, + 'message': instance.message, + 'errorType': instance.errorType, + 'errorMessage': instance.errorMessage, + 'errorStack': instance.errorStack, + 'startedAt': instance.startedAt.toIso8601String(), + 'finishedAt': instance.finishedAt?.toIso8601String(), + 'durationMs': instance.durationMs, + 'scannedCount': instance.scannedCount, + 'skippedCount': instance.skippedCount, + 'successCount': instance.successCount, + 'failureCount': instance.failureCount, + }; diff --git a/lib/common/services/index.dart b/lib/common/services/index.dart index d68522a..3ff0cd9 100644 --- a/lib/common/services/index.dart +++ b/lib/common/services/index.dart @@ -7,12 +7,15 @@ export './database/type_converter/type_converter.dart'; export './database/dao/broadcast_dao.dart'; export './database/dao/classify_dao.dart'; export './database/dao/face_dao.dart'; +export './database/dao/file_upload_record_dao.dart'; export './face_recognition_session_service.dart'; export './database/dao/order_hold_bill_dao.dart'; export './database/dao/order_dao.dart'; export './database/dao/print_history_dao.dart'; export './database/dao/canteen_spu_dao.dart'; export './database/dao/setting_dao.dart'; +export './database/dao/scheduled_task_dao.dart'; +export './database/dao/scheduled_task_log_dao.dart'; export './database/dao/shop_card_dao.dart'; export './database/dao/supermarket_code_dao.dart'; export './database/dao/supermarket_sku_dao.dart'; @@ -22,11 +25,14 @@ export './database/dao/user_dao.dart'; export './database/entity/broadcast_entity.dart'; export './database/entity/classify_entity.dart'; export './database/entity/face_entity.dart'; +export './database/entity/file_upload_record_entity.dart'; export './database/entity/order_entity.dart'; export './database/entity/order_hold_bill_entity.dart'; export './database/entity/print_history_entity.dart'; export './database/entity/canteen_spu_entity.dart'; export './database/entity/setting_entity.dart'; +export './database/entity/scheduled_task_entity.dart'; +export './database/entity/scheduled_task_log_entity.dart'; export './database/entity/shop_card_entity.dart'; export './database/entity/supermarket_code_entity.dart'; export './database/entity/supermarket_sku_entity.dart'; diff --git a/lib/common/utils/app/app_mq_tool.dart b/lib/common/utils/app/app_mq_tool.dart index a0fe6a9..e98781f 100644 --- a/lib/common/utils/app/app_mq_tool.dart +++ b/lib/common/utils/app/app_mq_tool.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:async'; import 'package:common_utils/common_utils.dart'; import 'package:get/get.dart'; @@ -6,9 +7,12 @@ import 'package:mqtt_client/mqtt_client.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 应用级 MQTT 连接初始化、精确 Topic 过滤和业务动作分发工具。 class AppMqTool { + /// MQTT 连接与消息分发诊断日志使用的稳定模块标签。 static const String _tag = "app mq 工具"; + /// 校验网络和设备绑定后重建 MQTT 连接,并注册当前设备的消息监听。 static Future mqRefresh() async { final isNetwork = AppNetworkTool.isAutoNetwork(); if (!isNetwork) { @@ -74,8 +78,10 @@ class AppMqTool { }); } - // 根据 msg action 来处理 mq 消息 - static void dispatchMqttMessage(MqBaseMsg msg, String orgStr) async { + /// 根据协议动作分发已完成设备 Topic 校验的 MQTT 消息。 + /// + /// [orgStr] 仅用于需要专用消息模型的兼容动作;日志拉取采用非阻塞触发,避免占用 MQTT 回调。 + static Future dispatchMqttMessage(MqBaseMsg msg, String orgStr) async { if (ObjectUtil.isEmpty(msg.action)) return; final app = Get.find(); final mqUpdateEventBus = app.mqUpdateEventBus; @@ -128,6 +134,15 @@ class AppMqTool { logger.e("|$_tag|定餐广播执行错误orgStr|$orgStr|error|$e|stack|$stack|"); } return; + case MqtActionEnum.PULL_TERMINAL_LOG: + mqUpdateEventBus.fire(MqtActionEnum.PULL_TERMINAL_LOG); + // MQTT 回调不能等待文件扫描和上传,任务会在支付/识别结束后的安全窗口异步执行。 + unawaited(SyncCheckTool.instance.trigger( + ScheduledTaskType.terminalLogUpload, + currentLogOnly: true, + triggerType: ScheduledTaskTriggerType.mqtt, + )); + return; case MqtActionEnum.FACE: return; // final msg = MqttInvoiceMsg.fromJson(json.decode(orgStr)); @@ -151,11 +166,10 @@ class AppMqTool { // ); // mqUpdateEventBus.fire(MqtActionEnum.FACE); // break; - default: - break; } } + /// 取消当前 MQTT 消息订阅并清空 AppControl 中的监听句柄。 static void mqListenClose() { final app = Get.find(); app.mqListen?.cancel(); diff --git a/lib/common/utils/app/app_runtime_tool.dart b/lib/common/utils/app/app_runtime_tool.dart index 74e35d6..e722f6b 100644 --- a/lib/common/utils/app/app_runtime_tool.dart +++ b/lib/common/utils/app/app_runtime_tool.dart @@ -110,6 +110,87 @@ class AppNetworkSnapshot { } } +/// Android 进程与设备资源快照。 +/// +/// CPU 使用率需要比较两次 [processCpuTimeMillis],原生层只返回累计值,避免在平台通道内 +/// 持有采样定时器;内存和存储同时保留总量,便于顶栏给出百分比并在 Tooltip 展示明细。 +class AppPerformanceSnapshot { + /// Android 进程从启动至采样时累计消耗的 CPU 时间,单位为毫秒。 + final int processCpuTimeMillis; + + /// Android Runtime 报告的逻辑处理器数量,最小按 1 处理。 + final int logicalProcessorCount; + + /// 系统物理内存总量,单位为字节;平台数据不可用时为 0。 + final int systemMemoryTotalBytes; + + /// 系统当前可用物理内存,单位为字节;平台数据不可用时为 0。 + final int systemMemoryAvailableBytes; + + /// 当前应用进程的 PSS 内存占用,单位为字节。 + final int appMemoryBytes; + + /// 应用文件目录所在存储卷的总容量,单位为字节;平台数据不可用时为 0。 + final int storageTotalBytes; + + /// 应用文件目录所在存储卷的可用容量,单位为字节;平台数据不可用时为 0。 + final int storageAvailableBytes; + + /// 创建一份原生平台已经完成单位换算的资源快照。 + const AppPerformanceSnapshot({ + required this.processCpuTimeMillis, + required this.logicalProcessorCount, + required this.systemMemoryTotalBytes, + required this.systemMemoryAvailableBytes, + required this.appMemoryBytes, + required this.storageTotalBytes, + required this.storageAvailableBytes, + }); + + /// 创建平台通道不可用时的空快照,百分比 Getter 将稳定返回 0。 + const AppPerformanceSnapshot.empty() + : processCpuTimeMillis = 0, + logicalProcessorCount = 1, + systemMemoryTotalBytes = 0, + systemMemoryAvailableBytes = 0, + appMemoryBytes = 0, + storageTotalBytes = 0, + storageAvailableBytes = 0; + + /// 从 Android MethodChannel 返回值解析资源快照,缺失或类型异常字段按 0 处理。 + factory AppPerformanceSnapshot.fromMap(Map? data) { + int value(String key) => (data?[key] as num?)?.toInt() ?? 0; + return AppPerformanceSnapshot( + processCpuTimeMillis: value('processCpuTimeMs'), + logicalProcessorCount: + value('logicalProcessorCount').clamp(1, 256).toInt(), + systemMemoryTotalBytes: value('memoryTotalBytes'), + systemMemoryAvailableBytes: value('memoryAvailableBytes'), + appMemoryBytes: value('appMemoryBytes'), + storageTotalBytes: value('storageTotalBytes'), + storageAvailableBytes: value('storageAvailableBytes'), + ); + } + + /// 系统内存已使用比例,范围为 0 到 1;总量未知时返回 0。 + double get memoryUsageRatio => _usedRatio( + total: systemMemoryTotalBytes, + available: systemMemoryAvailableBytes, + ); + + /// 应用所在存储卷已使用比例,范围为 0 到 1;总量未知时返回 0。 + double get storageUsageRatio => _usedRatio( + total: storageTotalBytes, + available: storageAvailableBytes, + ); + + /// 根据总量和可用量计算受限于 0 到 1 的占用比例,容忍原生瞬时采样误差。 + static double _usedRatio({required int total, required int available}) { + if (total <= 0) return 0; + return ((total - available) / total).clamp(0, 1).toDouble(); + } +} + /// 统一管理虚拟机识别和硬件手动输入模式,避免各业务页面自行猜测设备能力。 abstract class AppRuntimeTool { static const MethodChannel _channel = MethodChannel( @@ -208,6 +289,18 @@ abstract class AppRuntimeTool { } } + /// 读取一次 Android 进程、内存和存储快照;通道失败时返回可安全展示的空快照。 + static Future getPerformanceSnapshot() async { + try { + final data = await _channel.invokeMapMethod( + 'getPerformanceSnapshot', + ); + return AppPerformanceSnapshot.fromMap(data); + } on PlatformException { + return const AppPerformanceSnapshot.empty(); + } + } + /// 查询插件当前 Presentation 是否仍在展示,用于避免 showSubDisplay() 先关后开造成闪屏。 static Future isSubScreenShowing() async { try { diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 4a6aaf5..d0cc91f 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -28,6 +28,18 @@ abstract class AppSettingTool { "reservationQueryVerificationDateScope"; static const String _serialCardConfigKey = "serialCardConfig"; + /// v8 之前短暂保存定时任务配置的旧 sysJson 键,仅供一次性迁移读取。 + static const String _scheduledTaskConfigsKey = "scheduledTaskConfigs"; + + /// v8 之前短暂保存任务最近执行结果的旧 sysJson 键,仅供一次性迁移读取。 + static const String _scheduledTaskRecordsKey = "scheduledTaskRecords"; + + /// v8 之前保存日志文件成功上传状态的旧 sysJson 键,仅供一次性迁移读取。 + static const String _terminalLogUploadStatesKey = "terminalLogUploadStates"; + + /// v8 之前保存最近日志上传汇总的旧 sysJson 键,仅供一次性迁移清理。 + static const String _terminalLogUploadSummaryKey = "terminalLogUploadSummary"; + /// 人工硬件输入历史在现有 sysJson 扩展配置中的存储键。 static const String _hardwareInputHistoryKey = "hardwareInputHistory"; @@ -40,6 +52,9 @@ abstract class AppSettingTool { ), ); + /// sysJson 扩展数据的进程内写入队列,避免迁移清理与设置保存相互覆盖。 + static Future _extensionWriteQueue = Future.value(); + static Future removeSettingCache() async { await _settingCache.emptyCache(); } @@ -143,6 +158,100 @@ abstract class AppSettingTool { await _setSetting(sysJson: sysInfo); } + /// 读取完整 sysJson 为可修改映射,仅供旧结构化数据迁移方法使用。 + static Future> _getSysExtensionJson() async { + final setting = await _getSetting(); + return Map.from(json.decode(setting.sysJson)); + } + + /// 仅用于把 v8 之前短暂写入 sysJson 的任务配置导入结构化表,不再提供 JSON 写入口。 + static Future> getScheduledTaskConfigs() async { + final sysJson = await _getSysExtensionJson(); + final raw = sysJson[_scheduledTaskConfigsKey]; + if (raw is! List) return const []; + return raw + .whereType() + .map((item) => ScheduledTaskConfig.fromJson( + Map.from(item), + )) + .toList(growable: false); + } + + /// 读取 v8 之前的任务执行快照并按当前枚举过滤未知任务,仅用于迁移。 + static Future> + getScheduledTaskRecords() async { + final sysJson = await _getSysExtensionJson(); + final raw = sysJson[_scheduledTaskRecordsKey]; + if (raw is! Map) return const {}; + final result = {}; + for (final entry in raw.entries) { + ScheduledTaskType? type; + for (final item in ScheduledTaskType.values) { + if (item.name == entry.key.toString()) { + type = item; + break; + } + } + if (type == null || entry.value is! Map) continue; + result[type] = ScheduledTaskRecord.fromJson( + Map.from(entry.value as Map), + ); + } + return result; + } + + /// 读取 v8 之前按相对路径保存的成功上传状态,仅用于导入文件上传记录表。 + static Future> + getTerminalLogUploadStates() async { + final sysJson = await _getSysExtensionJson(); + final raw = sysJson[_terminalLogUploadStatesKey]; + if (raw is! Map) return const {}; + return raw.map( + (key, value) => MapEntry( + key.toString(), + TerminalLogUploadState.fromJson( + Map.from(value as Map), + ), + ), + ); + } + + /// 读取 v8 之前的最近上传汇总;仅保留兼容能力,不再作为运行时数据源。 + static Future getTerminalLogUploadSummary() async { + final sysJson = await _getSysExtensionJson(); + final raw = sysJson[_terminalLogUploadSummaryKey]; + if (raw is! Map) return const TerminalLogUploadSummary(); + return TerminalLogUploadSummary.fromJson(Map.from(raw)); + } + + /// v8 已将任务与上传状态迁入独立表,导入成功后移除旧 JSON,避免形成双写数据源。 + static Future clearLegacyScheduledTaskState({ + required bool includeUploadState, + }) async { + final operation = _extensionWriteQueue.catchError((_) {}).then((_) async { + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + final setting = await settingDao.get(); + if (setting == null) throw StateError('终端设置尚未初始化'); + final sysJson = Map.from(json.decode(setting.sysJson)); + sysJson.remove(_scheduledTaskConfigsKey); + sysJson.remove(_scheduledTaskRecordsKey); + if (includeUploadState) { + sysJson.remove(_terminalLogUploadStatesKey); + sysJson.remove(_terminalLogUploadSummaryKey); + } + setting.sysJson = json.encode(sysJson); + await settingDao.edit(setting); + await _settingCache.putFile( + _settingKey, + Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), + fileExtension: 'json', + ); + }); + _extensionWriteQueue = operation; + await operation; + } + static Future setUserSetting(UserSetting userInfo) async { final globalUserInfo = await getUserSetting(); if (globalUserInfo == userInfo) return; diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart index 61f74aa..97a2f84 100644 --- a/lib/common/utils/index.dart +++ b/lib/common/utils/index.dart @@ -44,6 +44,10 @@ export './plugs/face_discern_plugs.dart'; export './router/router_observer_tool.dart'; export './sync/sync_check_tool.dart'; +export './sync/sync_idle_tool.dart'; +export './sync/scheduled_task_storage_tool.dart'; +export './sync/file_upload_record_tool.dart'; +export './sync/terminal_log_upload_tool.dart'; export './sync/sync_config_tool.dart'; export './sync/sync_face_tool.dart'; export './sync/sync_order_tool.dart'; diff --git a/lib/common/utils/logger/logger_tool.dart b/lib/common/utils/logger/logger_tool.dart index 497a438..963b8b8 100644 --- a/lib/common/utils/logger/logger_tool.dart +++ b/lib/common/utils/logger/logger_tool.dart @@ -43,6 +43,53 @@ Future listAppLogFiles() async { return _consoleOutput.listLogFiles(); } +/// 上传当前日志前刷新文件写入队列,保证快照边界包含调用前已经产生的日志。 +Future flushCurrentAppLog() async { + await logger.init; + await _consoleOutput.flushCurrentLog(); +} + +/// 逐行生成脱敏副本,上传工具不会把原始日志文件直接交给网络层。 +Future sanitizeAppLogFile({ + required File source, + required File target, + int? maxSourceBytes, +}) async { + final sourceLength = await source.length(); + final limit = maxSourceBytes == null + ? sourceLength + : maxSourceBytes.clamp(0, sourceLength); + final input = await source.open(); + IOSink? output; + try { + output = target.openWrite(mode: FileMode.writeOnly); + var position = 0; + var pending = ''; + const chunkSize = 64 * 1024; + while (position < limit) { + final length = (limit - position).clamp(0, chunkSize); + final bytes = await input.read(length); + if (bytes.isEmpty) break; + position += bytes.length; + pending += utf8.decode(bytes, allowMalformed: true); + final lines = pending.split('\n'); + pending = lines.removeLast(); + for (final line in lines) { + output.writeln(sanitizeAppLogContent(line)); + } + } + if (pending.isNotEmpty) output.write(sanitizeAppLogContent(pending)); + await output.flush(); + } finally { + await input.close(); + await output?.close(); + } +} + +/// 日志预览和上传共用同一套脱敏规则,避免两个入口出现隐私边界漂移。 +String sanitizeAppLogContent(String content) => + _sanitizePreviewContent(content); + class AppLogCatalog { const AppLogCatalog({ required this.rootPath, @@ -113,6 +160,9 @@ class ConsoleOutput extends LogOutput { return _queue; } + /// 排在既有写入队列末尾刷新当前文件,完成后调用方可取得一致的文件大小快照。 + Future flushCurrentLog() => _enqueue(() async => _sink?.flush()); + /// 读取当前日志文件尾部,并在返回 UI 前移除终端颜色码和常见敏感字段。 Future readPreview({ String? path, diff --git a/lib/common/utils/manage/mq_manager.dart b/lib/common/utils/manage/mq_manager.dart index fe1c4e3..5facb5d 100644 --- a/lib/common/utils/manage/mq_manager.dart +++ b/lib/common/utils/manage/mq_manager.dart @@ -229,6 +229,7 @@ class MqManager { await refresh(); } + /// 将底层连接异常归一化为任务层可展示原因,并抑制可预期网络故障的重复长堆栈。 static void _recordConnectionFailure(Object error, StackTrace stack) { state = MqttConnectionState.disconnected; final message = error.toString().toLowerCase(); @@ -239,6 +240,12 @@ class MqManager { : error is SocketException ? '消息服务地址不可达' : '消息服务连接失败'; + if (error is TimeoutException || error is SocketException) { + // 连接超时和地址不可达属于可预期的外部依赖故障,任务日志会保留结构化结果, + // 此处不再每分钟重复输出整段堆栈污染待上传日志。 + logger.w("|$_tag|mqtt连接失败:${connectionFailureReason ?? error}|"); + return; + } logger.e("|$_tag|mqtt连接失败error:|$error|stack:|$stack|"); } } diff --git a/lib/common/utils/sync/file_upload_record_tool.dart b/lib/common/utils/sync/file_upload_record_tool.dart new file mode 100644 index 0000000..8743fb5 --- /dev/null +++ b/lib/common/utils/sync/file_upload_record_tool.dart @@ -0,0 +1,191 @@ +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 文件上传记录的结构化存储入口。 +abstract final class FileUploadRecordTool { + /// 终端日志在通用文件上传记录表中的稳定业务类型。 + static const String terminalLogBizType = 'terminal_log'; + + /// 当前应用数据库中的文件上传记录 DAO。 + static FileUploadRecordDao get _dao => + Get.find().dataBase.getFileUploadRecordDao; + + /// 将 v8 之前保存在 sysJson 的成功状态一次性导入 file_upload_record。 + /// + /// 相同设备、业务类型和相对路径已有记录时保持结构化表数据,不用旧 JSON 覆盖。 + static Future importLegacy( + String hardId, + Map states, + ) async { + final now = DateTime.now(); + for (final state in states.values) { + if (await _dao.getByPath( + hardId, + terminalLogBizType, + state.relativePath, + ) != + null) { + continue; + } + await _dao.save( + FileUploadRecordEntity( + hardId: hardId, + bizType: terminalLogBizType, + relativePath: state.relativePath, + originalName: _fileName(state.relativePath), + sourceSize: state.sourceSize, + sourceModifiedTime: state.sourceModifiedTime, + sanitizedHash: state.sanitizedHash, + sanitizedSize: 0, + status: FileUploadStatus.success.name, + uploadedAt: state.uploadedAt, + createTime: state.uploadedAt, + updateTime: now, + ), + ); + } + } + + /// 查询指定设备所有成功的终端日志状态,并以相对路径构建快速匹配映射。 + static Future> getSuccessfulStates( + String hardId, + ) async { + final result = {}; + for (final record in await _dao.getAll(hardId, terminalLogBizType)) { + if (record.status != FileUploadStatus.success.name || + record.uploadedAt == null) { + continue; + } + result[record.relativePath] = TerminalLogUploadState( + relativePath: record.relativePath, + sourceSize: record.sourceSize, + sourceModifiedTime: record.sourceModifiedTime, + sanitizedHash: record.sanitizedHash, + uploadedAt: record.uploadedAt!, + ); + } + return result; + } + + /// 创建或覆盖同一路径的 uploading 状态,并将尝试次数递增后返回带主键实体。 + static Future beginAttempt({ + required String hardId, + required String relativePath, + required int sourceSize, + required DateTime sourceModifiedTime, + }) async { + final previous = await _dao.getByPath( + hardId, + terminalLogBizType, + relativePath, + ); + final now = DateTime.now(); + final next = FileUploadRecordEntity( + id: previous?.id, + hardId: hardId, + bizType: terminalLogBizType, + relativePath: relativePath, + originalName: _fileName(relativePath), + sourceSize: sourceSize, + sourceModifiedTime: sourceModifiedTime, + sanitizedHash: previous?.sanitizedHash ?? '', + sanitizedSize: previous?.sanitizedSize ?? 0, + status: FileUploadStatus.uploading.name, + serverFileId: previous?.serverFileId, + attemptCount: (previous?.attemptCount ?? 0) + 1, + lastAttemptAt: now, + uploadedAt: previous?.uploadedAt, + createTime: previous?.createTime ?? now, + updateTime: now, + ); + final id = await _dao.save(next); + return FileUploadRecordEntity( + id: id, + hardId: next.hardId, + bizType: next.bizType, + relativePath: next.relativePath, + originalName: next.originalName, + sourceSize: next.sourceSize, + sourceModifiedTime: next.sourceModifiedTime, + sanitizedHash: next.sanitizedHash, + sanitizedSize: next.sanitizedSize, + status: next.status, + serverFileId: next.serverFileId, + attemptCount: next.attemptCount, + lastAttemptAt: next.lastAttemptAt, + uploadedAt: next.uploadedAt, + createTime: next.createTime, + updateTime: next.updateTime, + ); + } + + /// 将一次上传尝试标记为成功,并保存脱敏文件哈希、大小和后端记录 ID。 + static Future markSuccess({ + required FileUploadRecordEntity attempt, + required String sanitizedHash, + required int sanitizedSize, + String? serverFileId, + }) async { + final now = DateTime.now(); + await _dao.save( + FileUploadRecordEntity( + id: attempt.id, + hardId: attempt.hardId, + bizType: attempt.bizType, + relativePath: attempt.relativePath, + originalName: attempt.originalName, + sourceSize: attempt.sourceSize, + sourceModifiedTime: attempt.sourceModifiedTime, + sanitizedHash: sanitizedHash, + sanitizedSize: sanitizedSize, + status: FileUploadStatus.success.name, + serverFileId: serverFileId ?? attempt.serverFileId, + attemptCount: attempt.attemptCount, + lastAttemptAt: attempt.lastAttemptAt, + uploadedAt: now, + createTime: attempt.createTime, + updateTime: now, + ), + ); + } + + /// 将一次上传尝试标记为失败,保留上次成功时间和服务端 ID 供后续重试使用。 + static Future markFailure({ + required FileUploadRecordEntity attempt, + required Object error, + }) async { + final now = DateTime.now(); + await _dao.save( + FileUploadRecordEntity( + id: attempt.id, + hardId: attempt.hardId, + bizType: attempt.bizType, + relativePath: attempt.relativePath, + originalName: attempt.originalName, + sourceSize: attempt.sourceSize, + sourceModifiedTime: attempt.sourceModifiedTime, + sanitizedHash: attempt.sanitizedHash, + sanitizedSize: attempt.sanitizedSize, + status: FileUploadStatus.failed.name, + serverFileId: attempt.serverFileId, + attemptCount: attempt.attemptCount, + lastAttemptAt: attempt.lastAttemptAt, + uploadedAt: attempt.uploadedAt, + lastError: _safeError(error), + createTime: attempt.createTime, + updateTime: now, + ), + ); + } + + /// 从跨平台相对路径中提取不含目录的原始文件名。 + static String _fileName(String path) => + path.replaceAll('\\', '/').split('/').last; + + /// 将上传异常摘要限制在 500 个字符内,避免单条状态记录无限增长。 + static String _safeError(Object error) { + final value = error.toString(); + return value.length <= 500 ? value : '${value.substring(0, 500)}...'; + } +} diff --git a/lib/common/utils/sync/scheduled_task_storage_tool.dart b/lib/common/utils/sync/scheduled_task_storage_tool.dart new file mode 100644 index 0000000..d2ca802 --- /dev/null +++ b/lib/common/utils/sync/scheduled_task_storage_tool.dart @@ -0,0 +1,222 @@ +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 定时任务结构化存储入口,隔离调度器与 Floor DAO 的实体转换细节。 +abstract final class ScheduledTaskStorageTool { + /// 当前应用数据库中的任务配置 DAO;数据库由 AppControl 在调度器启动前完成初始化。 + static ScheduledTaskDao get _taskDao => + Get.find().dataBase.getScheduledTaskDao; + + /// 当前应用数据库中的任务执行日志 DAO。 + static ScheduledTaskLogDao get _logDao => + Get.find().dataBase.getScheduledTaskLogDao; + + /// 首次升级时把旧 sysJson 导入表中;表已有数据时只补齐后续版本新增的默认任务。 + static Future initialize({ + required List defaults, + required List legacyConfigs, + required Map legacyRecords, + }) async { + final existing = { + for (final entity in await _taskDao.getAll()) entity.taskCode: entity, + }; + final legacy = {for (final config in legacyConfigs) config.type: config}; + final now = DateTime.now(); + final additions = []; + for (final defaultConfig in defaults) { + if (existing.containsKey(defaultConfig.type.name)) continue; + final config = legacy[defaultConfig.type] ?? defaultConfig; + final record = legacyRecords[defaultConfig.type]; + additions.add( + ScheduledTaskEntity( + taskCode: config.type.name, + enabled: config.enabled, + cronExpression: config.cron, + idleOnly: config.idleOnly, + lastRunAt: record?.lastRunAt, + lastSuccessAt: record?.lastSuccessAt, + lastStatus: record == null + ? ScheduledTaskRunStatus.notRun.name + : record.success + ? ScheduledTaskRunStatus.success.name + : ScheduledTaskRunStatus.failed.name, + lastMessage: record?.message ?? '尚未执行', + createTime: now, + updateTime: now, + ), + ); + } + if (additions.isNotEmpty) await _taskDao.batchSave(additions); + } + + /// 从 scheduled_task 读取有效任务,并转换为调度器使用的配置和最近执行快照。 + /// + /// 数据库中未知的 taskCode 会被忽略,保证新旧版本任务集合不一致时仍可启动。 + static Future< + ({ + Map configs, + Map records, + })> loadState() async { + final configs = {}; + final records = {}; + for (final entity in await _taskDao.getAll()) { + final type = _taskType(entity.taskCode); + if (type == null) continue; + configs[type] = ScheduledTaskConfig( + type: type, + enabled: entity.enabled, + cron: entity.cronExpression, + idleOnly: entity.idleOnly, + ); + records[type] = ScheduledTaskRecord( + lastRunAt: entity.lastRunAt, + lastSuccessAt: entity.lastSuccessAt, + success: entity.lastStatus == ScheduledTaskRunStatus.success.name || + entity.lastStatus == ScheduledTaskRunStatus.skipped.name, + message: entity.lastMessage, + ); + } + return (configs: configs, records: records); + } + + /// 保存任务开关、Cron 和空闲策略;已有记录会保留历史执行时间和创建时间。 + static Future saveConfig(ScheduledTaskConfig config) async { + final current = await _taskDao.getByCode(config.type.name); + final now = DateTime.now(); + await _taskDao.save( + current == null + ? ScheduledTaskEntity( + taskCode: config.type.name, + enabled: config.enabled, + cronExpression: config.cron, + idleOnly: config.idleOnly, + lastStatus: ScheduledTaskRunStatus.notRun.name, + lastMessage: '尚未执行', + createTime: now, + updateTime: now, + ) + : current.copyWith( + enabled: config.enabled, + cronExpression: config.cron, + idleOnly: config.idleOnly, + updateTime: now, + ), + ); + } + + /// 将一次任务结果写回 scheduled_task 的最近执行快照。 + static Future saveRecord( + ScheduledTaskType type, + ScheduledTaskRecord record, + ScheduledTaskRunStatus status, + ) async { + final current = await _taskDao.getByCode(type.name); + if (current == null) return; + await _taskDao.save( + current.copyWith( + lastRunAt: record.lastRunAt, + lastSuccessAt: record.lastSuccessAt, + lastStatus: status.name, + lastMessage: record.message, + updateTime: DateTime.now(), + ), + ); + } + + /// 插入状态为 running 的开始日志,并返回带自增主键的实体供结束时更新。 + static Future startLog({ + required ScheduledTaskType type, + required ScheduledTaskTriggerType triggerType, + required ScheduledTaskSourceType sourceType, + required String taskName, + required String sourceName, + required DateTime startedAt, + }) async { + final log = ScheduledTaskLogEntity( + taskCode: type.name, + taskName: taskName, + triggerType: triggerType.name, + sourceType: sourceType.name, + sourceName: sourceName, + status: ScheduledTaskRunStatus.running.name, + message: '任务开始执行', + startedAt: startedAt, + ); + final id = await _logDao.save(log); + return ScheduledTaskLogEntity( + id: id, + taskCode: log.taskCode, + taskName: log.taskName, + triggerType: log.triggerType, + sourceType: log.sourceType, + sourceName: log.sourceName, + status: log.status, + message: log.message, + startedAt: log.startedAt, + ); + } + + /// 更新任务日志的最终状态,并将全局历史裁剪到最近 2000 条。 + static Future finishLog(ScheduledTaskLogEntity log) async { + await _logDao.edit(log); + await _logDao.trimHistory(); + } + + /// 查询指定任务最近 100 条日志,可按主要依赖类型进一步筛选。 + static Future> getLogs( + ScheduledTaskType type, { + ScheduledTaskSourceType? sourceType, + int limit = 100, + }) => + _logDao.getByTask(type.name, sourceType?.name ?? '', limit); + + /// 分页查询指定任务日志,同时返回总数和校正后的当前页。 + /// + /// 页容量限制在 1 到 100 条;当历史日志裁剪导致请求页超出范围时,自动回退到最后一页, + /// 避免分页组件仍显示有效总数但列表为空。 + static Future< + ({ + List records, + int total, + int currentPage, + })> getLogPage( + ScheduledTaskType type, { + ScheduledTaskSourceType? sourceType, + required int currentPage, + required int pageSize, + }) async { + final sourceCode = sourceType?.name ?? ''; + final normalizedPageSize = pageSize.clamp(1, 100).toInt(); + final total = await _logDao.countByTask(type.name, sourceCode) ?? 0; + final totalPages = total == 0 ? 1 : (total / normalizedPageSize).ceil(); + final normalizedCurrentPage = currentPage.clamp(1, totalPages).toInt(); + final records = total == 0 + ? const [] + : await _logDao.getPageByTask( + type.name, + sourceCode, + normalizedPageSize, + (normalizedCurrentPage - 1) * normalizedPageSize, + ); + return ( + records: records, + total: total, + currentPage: normalizedCurrentPage, + ); + } + + /// 查询指定任务最近一条日志;尚未执行时返回空。 + static Future getLatestLog( + ScheduledTaskType type, + ) => + _logDao.getLatest(type.name); + + /// 将数据库中的稳定任务编码转换为当前版本枚举,未知编码返回空。 + static ScheduledTaskType? _taskType(String code) { + for (final type in ScheduledTaskType.values) { + if (type.name == code) return type; + } + return null; + } +} diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index 5df4051..6de6e9a 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -1,211 +1,994 @@ import 'dart:async'; -import 'dart:io'; +import 'package:cron/cron.dart'; +import 'package:get/get.dart'; import 'package:mqtt_client/mqtt_client.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 应用进程内任务调度入口。 +/// +/// 收银终端常驻时 Cron 可提供秒级规则;Android 杀死进程后不会继续执行,启动后由本类重新注册。 class SyncCheckTool { - static const _tag = "自动检查任务"; + /// 调度器写入本地诊断日志时使用的稳定模块标签。 + static const _tag = '终端任务调度'; - SyncCheckTool._privateConstructor(); - static final SyncCheckTool _instance = SyncCheckTool._privateConstructor(); - static SyncCheckTool get instance => _instance; + /// 禁止外部创建第二套调度状态,确保 Cron、运行集合和待执行队列全局唯一。 + SyncCheckTool._(); - bool _isRunning = false; + /// 应用级任务调度单例,由 AppControl 初始化并由设置页读取响应式状态。 + static final SyncCheckTool instance = SyncCheckTool._(); -// ************************************************************************** -// 定时器配置 start -// ************************************************************************** + /// 当前已加载的任务配置,以稳定任务类型为键并向设置页提供响应式更新。 + final configs = {}.obs; - Duration mqInterval = const Duration(seconds: 60); + /// 每个任务最近一次执行结果快照,以稳定任务类型为键。 + final records = {}.obs; - Duration printInterval = const Duration(seconds: 60 * 10); + /// 正在实际执行业务动作的任务集合,用于禁止同任务并发并驱动运行状态 UI。 + final runningTasks = {}.obs; - Duration broadcastInterval = const Duration(seconds: 60 * 2); + /// 正在等待安全窗口或等待前一次同类任务结束的任务集合。 + final pendingTasks = {}.obs; - Duration deviceStatusInterval = const Duration(seconds: 60); + /// 最近一次终端日志上传统计,供设置页展示扫描、跳过、成功和失败数量。 + final terminalLogSummary = const TerminalLogUploadSummary().obs; - Timer? mqTimer; + /// 当前进程内 Cron 调度器;任务全部停止后置空并关闭其内部计时资源。 + Cron? _cron; - Timer? printTimer; + /// 已注册到 Cron 的任务句柄,以任务类型为键支持单任务重建和取消。 + final Map _scheduledTasks = {}; - Timer? broadcastTimer; + /// 同任务合并后的待执行请求,记录触发来源和需要满足的空闲级别。 + final Map _pendingRequests = {}; - Timer? deviceStatusTimer; + /// 每 5 秒检查待执行任务安全窗口的轮询计时器;队列清空后自动释放。 + Timer? _idleTimer; -// ************************************************************************** -// 定时器配置 end -// ************************************************************************** + /// 正在进行的释放操作,避免生命周期重复回调并发关闭同一个 Cron 实例。 + Future? _disposeFuture; - void startAll() { - if (_isRunning) return; - _isRunning = true; - checkMq(); - checkPrint(); - checkBroadcast(); - checkDeviceStatus(); - checkExpiredLog(); + /// 是否已经完成本轮启动,防止 AppControl 重复注册相同 Cron 任务。 + bool _started = false; + + /// 常用规则均使用 cron 0.6.2 可解析的标准字段、范围和步长,不包含 Quartz 专属的 L/W/#。 + static const List cronPresets = [ + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 30 秒', + expression: '*/30 * * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 1 分钟', + expression: '0 * * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 2 分钟', + expression: '0 */2 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 3 分钟', + expression: '0 */3 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 5 分钟', + expression: '0 */5 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 10 分钟', + expression: '0 */10 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 15 分钟', + expression: '0 */15 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 20 分钟', + expression: '0 */20 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 30 分钟', + expression: '0 */30 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 45 分钟', + expression: '0 */45 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每小时', + expression: '0 0 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 2 小时', + expression: '0 0 */2 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 3 小时', + expression: '0 0 */3 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 4 小时', + expression: '0 0 */4 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 6 小时', + expression: '0 0 */6 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 8 小时', + expression: '0 0 */8 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 12 小时', + expression: '0 0 */12 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 00:00', + expression: '0 0 0 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 00:10', + expression: '0 10 0 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 00:30', + expression: '0 30 0 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 01:00', + expression: '0 0 1 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 02:00', + expression: '0 0 2 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 03:00', + expression: '0 0 3 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 04:00', + expression: '0 0 4 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 06:00', + expression: '0 0 6 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 08:00', + expression: '0 0 8 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 09:00', + expression: '0 0 9 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 12:00', + expression: '0 0 12 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 18:00', + expression: '0 0 18 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 21:00', + expression: '0 0 21 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 22:00', + expression: '0 0 22 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 23:00', + expression: '0 0 23 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '工作日 08:00', + expression: '0 0 8 * * 1-5'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '工作日 18:00', + expression: '0 0 18 * * 1-5'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周一 00:00', + expression: '0 0 0 * * 1'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周一 08:00', + expression: '0 0 8 * * 1'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周三 00:00', + expression: '0 0 0 * * 3'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周五 18:00', + expression: '0 0 18 * * 5'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周六 00:00', + expression: '0 0 0 * * 6'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周日 00:00', + expression: '0 0 0 * * 7'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 1 日 00:00', + expression: '0 0 0 1 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 1 日 03:00', + expression: '0 0 3 1 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 5 日 00:00', + expression: '0 0 0 5 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 10 日 00:00', + expression: '0 0 0 10 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 15 日 00:00', + expression: '0 0 0 15 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 20 日 00:00', + expression: '0 0 0 20 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 25 日 00:00', + expression: '0 0 0 25 * *'), + ]; + + /// 首次建表或后续新增任务时写入的默认配置,不覆盖设备已经调整过的规则。 + static const List defaultConfigs = [ + ScheduledTaskConfig( + type: ScheduledTaskType.mqReconnect, + enabled: true, + cron: '0 * * * * *', + idleOnly: false, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.printCheck, + enabled: true, + cron: '0 */10 * * * *', + idleOnly: false, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.broadcastCheck, + enabled: true, + cron: '0 */2 * * * *', + idleOnly: false, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.deviceStatus, + enabled: true, + cron: '0 * * * * *', + idleOnly: false, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.configSync, + enabled: false, + cron: '0 5 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.classifySync, + enabled: false, + cron: '0 15 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.productSync, + enabled: false, + cron: '0 25 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.offlineOrderSync, + enabled: false, + cron: '0 35 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.terminalLogUpload, + enabled: true, + cron: '0 0 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.expiredLogCleanup, + enabled: true, + cron: '0 10 0 * * *', + idleOnly: true, + ), + ]; + + /// 初始化结构化配置并注册全部已启用任务;同一生命周期内重复调用直接返回。 + Future startAll() async { + if (_started) return; + await _disposeFuture; + _started = true; + _cron ??= Cron(); + await _loadState(); + for (final config in configs.values) { + await _rebuildTask(config); + } } - void dispose() { - _isRunning = false; - mqTimer?.cancel(); - mqTimer = null; - printTimer?.cancel(); - printTimer = null; - broadcastTimer?.cancel(); - broadcastTimer = null; - deviceStatusTimer?.cancel(); - deviceStatusTimer = null; + /// 完成旧 sysJson 一次性迁移,再从 SQLite 加载任务配置、最近结果和上传统计。 + Future _loadState() async { + final legacyConfigs = (await AppSettingTool.getScheduledTaskConfigs()) + .where((item) => isValidCron(item.cron)) + .toList(growable: false); + final legacyRecords = await AppSettingTool.getScheduledTaskRecords(); + final legacyUploadStates = + await AppSettingTool.getTerminalLogUploadStates(); + await ScheduledTaskStorageTool.initialize( + defaults: defaultConfigs, + legacyConfigs: legacyConfigs, + legacyRecords: legacyRecords, + ); + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + final canImportUploadState = hardId != null && hardId.isNotEmpty; + if (canImportUploadState && legacyUploadStates.isNotEmpty) { + await FileUploadRecordTool.importLegacy(hardId, legacyUploadStates); + } + await AppSettingTool.clearLegacyScheduledTaskState( + includeUploadState: canImportUploadState, + ); + final state = await ScheduledTaskStorageTool.loadState(); + configs.assignAll(state.configs); + records.assignAll(state.records); + final latestUploadLog = await ScheduledTaskStorageTool.getLatestLog( + ScheduledTaskType.terminalLogUpload, + ); + if (latestUploadLog != null) { + terminalLogSummary.value = TerminalLogUploadSummary( + scanned: latestUploadLog.scannedCount ?? 0, + skipped: latestUploadLog.skippedCount ?? 0, + succeeded: latestUploadLog.successCount ?? 0, + failed: latestUploadLog.failureCount ?? 0, + lastSuccessAt: + latestUploadLog.status == ScheduledTaskRunStatus.success.name + ? latestUploadLog.finishedAt + : records[ScheduledTaskType.terminalLogUpload]?.lastSuccessAt, + ); + } } - // 检查mq是否可以连接,是否需要重新连接 - checkMq() { - String logMsg = "检查mq是否可以连接,是否需要重新连接"; - final inSeconds = mqInterval.inSeconds; - if (mqTimer != null && mqTimer!.isActive) { - mqTimer?.cancel(); - mqTimer = null; + /// 校验 cron 0.6.2 使用的六段表达式及每段数值范围,返回是否可以安全注册。 + static bool isValidCron(String expression) { + final fields = expression.trim().split(RegExp(r'\s+')); + if (fields.length != 6) return false; + final ranges = const [ + (min: 0, max: 59), + (min: 0, max: 59), + (min: 0, max: 23), + (min: 1, max: 31), + (min: 1, max: 12), + (min: 0, max: 7), + ]; + for (var index = 0; index < fields.length; index++) { + if (!_isValidCronField( + fields[index], + min: ranges[index].min, + max: ranges[index].max, + )) { + return false; + } } - if (mqTimer != null) { - mqTimer?.cancel(); - mqTimer = null; + try { + Schedule.parse(expression); + return true; + } catch (_) { + return false; } - mqTimer = Timer.periodic(mqInterval, (Timer timer) async { - final isNetwork = AppNetworkTool.isAutoNetwork(); - if (!isNetwork) return; - logger.i("|$_tag|$logMsg,$inSeconds秒一次,开始执行"); - if (MqManager.state == MqttConnectionState.connected) return; + } + + /// cron 0.6.2 会静默过滤越界数字,因此保存前额外校验每一段,避免 `99 * * * * *` + /// 这类永远不会触发的表达式被误判为有效。 + static bool _isValidCronField( + String field, { + required int min, + required int max, + }) { + if (field.isEmpty) return false; + for (final segment in field.split(',')) { + final intervalParts = segment.split('/'); + if (intervalParts.length > 2) return false; + final base = intervalParts.first; + if (intervalParts.length == 2) { + final interval = int.tryParse(intervalParts.last); + if (interval == null || interval <= 0) return false; + if (base != '*' && !base.contains('-')) return false; + } + if (base == '*') continue; + if (base.contains('-')) { + final range = base.split('-'); + if (range.length != 2) return false; + final lower = int.tryParse(range.first); + final upper = int.tryParse(range.last); + if (lower == null || + upper == null || + lower < min || + upper > max || + lower > upper) { + return false; + } + continue; + } + final value = int.tryParse(base); + if (value == null || value < min || value > max) return false; + } + return true; + } + + /// 保存单个任务配置并仅重建该任务;任一步失败时恢复旧配置和旧调度句柄。 + Future updateConfig(ScheduledTaskConfig config) async { + if (!isValidCron(config.cron)) { + throw const FormatException('请输入有效的 6 段 Cron 表达式'); + } + final previous = configs[config.type]; + configs[config.type] = config; + try { + await _rebuildTask(config); + await ScheduledTaskStorageTool.saveConfig(config); + } catch (error, stack) { + if (previous == null) { + configs.remove(config.type); + } else { + configs[config.type] = previous; + } + // 调度注册和本地配置必须一起回滚,避免界面显示旧值但后台仍按新规则运行。 try { - AppMqTool.mqRefresh(); - } catch (e) { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行失败,error:$e"); - } finally { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行完成"); + if (previous == null) { + await _scheduledTasks.remove(config.type)?.cancel(); + } else { + await _rebuildTask(previous); + } + if (previous != null) { + await ScheduledTaskStorageTool.saveConfig(previous); + } + } catch (rollbackError, rollbackStack) { + logger.e( + '|$_tag|任务配置回滚失败type:${config.type.name}|error:$rollbackError|stack:$rollbackStack|', + ); + } + logger.e( + '|$_tag|任务配置保存失败type:${config.type.name}|error:$error|stack:$stack|', + ); + rethrow; + } + } + + /// 取消任务旧句柄,并按当前开关和 Cron 表达式重新注册对应任务。 + Future _rebuildTask(ScheduledTaskConfig config) async { + await _scheduledTasks.remove(config.type)?.cancel(); + if (!_started || !config.enabled) return; + _scheduledTasks[config.type] = _cron!.schedule( + Schedule.parse(config.cron), + () => trigger(config.type, scheduled: true), + ); + } + + /// 手动和 MQTT 触发不检查时间规则;同任务重复触发只保留一个合并后的待执行请求。 + Future trigger( + ScheduledTaskType type, { + bool currentLogOnly = false, + bool scheduled = false, + ScheduledTaskTriggerType triggerType = ScheduledTaskTriggerType.manual, + }) async { + final config = + configs[type] ?? defaultConfigs.firstWhere((item) => item.type == type); + final request = _PendingTaskRequest( + currentLogOnly: currentLogOnly, + requireSafeWindow: !scheduled || config.idleOnly, + requireFullIdle: scheduled && config.idleOnly, + triggerType: scheduled ? ScheduledTaskTriggerType.cron : triggerType, + ); + if (runningTasks.contains(type)) { + _enqueue(type, request); + return; + } + if (!_isReady(request)) { + _enqueue(type, request); + _ensureIdleTimer(); + return; + } + await _execute(type, request: request); + } + + /// 将任务加入等待集合;同任务已有请求时按更完整的处理范围合并。 + void _enqueue(ScheduledTaskType type, _PendingTaskRequest request) { + final existing = _pendingRequests[type]; + _pendingRequests[type] = existing?.merge(request) ?? request; + pendingTasks.add(type); + } + + /// 判断请求当前是否满足无需等待、业务安全窗口或完整空闲窗口中的对应条件。 + bool _isReady(_PendingTaskRequest request) { + if (!request.requireSafeWindow) return true; + return request.requireFullIdle + ? SyncIdleTool.instance.isIdle + : SyncIdleTool.instance.isSafeWindow; + } + + /// 从可观察集合和内部请求映射同时取出一个待执行任务,避免 UI 与队列状态分离。 + _PendingTaskRequest? _takePending(ScheduledTaskType type) { + pendingTasks.remove(type); + return _pendingRequests.remove(type); + } + + /// 在存在待执行任务时启动唯一轮询器,安全窗口满足后按队列快照依次执行。 + void _ensureIdleTimer() { + _idleTimer ??= Timer.periodic(const Duration(seconds: 5), (_) async { + if (pendingTasks.isEmpty) { + _idleTimer?.cancel(); + _idleTimer = null; + return; + } + final tasks = pendingTasks.toList(growable: false); + for (final type in tasks) { + if (runningTasks.contains(type)) continue; + final request = _pendingRequests[type]; + if (request == null || !_isReady(request)) continue; + _takePending(type); + await _execute(type, request: request); } }); } - // 定时检查需不要打印 - checkPrint() { - String logMsg = "定时检查需不要打印"; - final inSeconds = printInterval.inSeconds; - if (printTimer != null && printTimer!.isActive) { - printTimer?.cancel(); - printTimer = null; + /// 执行一次任务并同步运行集合、最近结果及结构化日志,结束后处理合并请求。 + Future _execute( + ScheduledTaskType type, { + required _PendingTaskRequest request, + }) async { + runningTasks.add(type); + final startedAt = DateTime.now(); + final descriptor = _taskDescriptor(type); + ScheduledTaskLogEntity? executionLog; + try { + executionLog = await ScheduledTaskStorageTool.startLog( + type: type, + triggerType: request.triggerType, + sourceType: descriptor.sourceType, + taskName: descriptor.title, + sourceName: descriptor.sourceName, + startedAt: startedAt, + ); + } catch (error, stack) { + // 诊断日志写入失败不能阻断收银终端本身的同步任务。 + logger.w('|$_tag|创建任务日志失败type:${type.name}|error:$error|stack:$stack|'); } - if (printTimer != null) { - printTimer?.cancel(); - printTimer = null; + final heavy = _isHeavy(type); + if (heavy) SyncIdleTool.instance.beginHeavyTask(); + try { + final result = await _runAction( + type, + currentLogOnly: request.currentLogOnly, + ); + records[type] = ScheduledTaskRecord( + lastRunAt: startedAt, + lastSuccessAt: result.status == ScheduledTaskRunStatus.success + ? DateTime.now() + : records[type]?.lastSuccessAt, + success: true, + message: result.message, + ); + await _safeSaveRecord( + type, + records[type]!, + result.status, + ); + if (executionLog != null) { + await _safeFinishLog( + executionLog.finish( + nextStatus: result.status.name, + nextMessage: result.message, + at: DateTime.now(), + scanned: result.summary?.scanned, + skipped: result.summary?.skipped, + succeeded: result.summary?.succeeded, + failed: result.summary?.failed, + ), + ); + } + } catch (error, stack) { + if (error is TerminalLogUploadException) { + terminalLogSummary.value = error.summary; + } + final message = error is TerminalLogUploadException + ? error.summary.label + : error.toString(); + records[type] = ScheduledTaskRecord( + lastRunAt: startedAt, + lastSuccessAt: records[type]?.lastSuccessAt, + success: false, + message: message, + ); + await _safeSaveRecord( + type, + records[type]!, + ScheduledTaskRunStatus.failed, + ); + if (executionLog != null) { + await _safeFinishLog( + executionLog.finish( + nextStatus: ScheduledTaskRunStatus.failed.name, + nextMessage: message, + at: DateTime.now(), + nextErrorType: error.runtimeType.toString(), + nextErrorMessage: _safeError(error), + nextErrorStack: _safeStack(stack), + scanned: error is TerminalLogUploadException + ? error.summary.scanned + : null, + skipped: error is TerminalLogUploadException + ? error.summary.skipped + : null, + succeeded: error is TerminalLogUploadException + ? error.summary.succeeded + : null, + failed: error is TerminalLogUploadException + ? error.summary.failed + : null, + ), + ); + } + if (error is ScheduledTaskDependencyException) { + logger.w('|$_tag|任务依赖暂不可用type:${type.name}|error:${error.message}|'); + } else { + logger.e( + '|$_tag|任务执行失败type:${type.name}|error:$error|stack:$stack|', + ); + } + } finally { + if (heavy) SyncIdleTool.instance.endHeavyTask(); + runningTasks.remove(type); + final nextRequest = _pendingRequests[type]; + if (nextRequest != null) { + if (_isReady(nextRequest)) { + _takePending(type); + unawaited(_execute(type, request: nextRequest)); + } else { + _ensureIdleTimer(); + } + } } - printTimer = Timer.periodic(printInterval, (Timer timer) async { - final isNetwork = AppNetworkTool.isAutoNetwork(); - if (!isNetwork) return; - logger.i("|$_tag|$logMsg,$inSeconds秒一次,开始执行"); - try { + } + + /// 结构化诊断数据属于旁路能力,SQLite 暂时不可写时不能把已经成功的业务任务改判为失败。 + Future _safeSaveRecord( + ScheduledTaskType type, + ScheduledTaskRecord record, + ScheduledTaskRunStatus status, + ) async { + try { + await ScheduledTaskStorageTool.saveRecord(type, record, status); + } catch (error, stack) { + logger.w( + '|$_tag|保存任务状态失败type:${type.name}|error:$error|stack:$stack|', + ); + } + } + + /// 完成日志和历史裁剪失败只写本地告警,原始 HTTP、MQTT 或插件结果保持不变。 + Future _safeFinishLog(ScheduledTaskLogEntity log) async { + try { + await ScheduledTaskStorageTool.finishLog(log); + } catch (error, stack) { + logger.w( + '|$_tag|完成任务日志失败type:${log.taskCode}|error:$error|stack:$stack|', + ); + } + } + + /// 将稳定任务类型分派到既有 MQTT、HTTP、插件或文件业务入口并返回统一结果。 + Future<_ScheduledTaskActionResult> _runAction( + ScheduledTaskType type, { + required bool currentLogOnly, + }) async { + switch (type) { + case ScheduledTaskType.mqReconnect: + if (!AppNetworkTool.isAutoNetwork()) { + return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); + } + if (MqManager.state == MqttConnectionState.connected) { + return const _ScheduledTaskActionResult.success('消息服务连接正常'); + } + await AppMqTool.mqRefresh(); + if (MqManager.state != MqttConnectionState.connected) { + throw ScheduledTaskDependencyException( + MqManager.connectionFailureReason ?? '消息服务暂未连接', + ); + } + return const _ScheduledTaskActionResult.success('消息服务重连成功'); + case ScheduledTaskType.printCheck: + if (!AppNetworkTool.isAutoNetwork()) { + return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); + } await SyncPrintTool.doCheckPrint(); await SyncPrintTool.doCheckSync(); - } catch (e) { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行失败,error:$e"); - } finally { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行完成"); - } - }); - } - - // 定时检查需不要广播 - checkBroadcast() { - String logMsg = "定时检查需不要广播"; - final inSeconds = broadcastInterval.inSeconds; - if (broadcastTimer != null && broadcastTimer!.isActive) { - broadcastTimer?.cancel(); - broadcastTimer = null; - } - if (broadcastTimer != null) { - broadcastTimer?.cancel(); - broadcastTimer = null; - } - broadcastTimer = Timer.periodic(broadcastInterval, (Timer timer) async { - final isNetwork = AppNetworkTool.isAutoNetwork(); - if (!isNetwork) return; - logger.i("|$_tag|$logMsg,$inSeconds秒一次,开始执行"); - try { + return const _ScheduledTaskActionResult.success('打印任务检查完成'); + case ScheduledTaskType.broadcastCheck: + if (!AppNetworkTool.isAutoNetwork()) { + return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); + } final setting = await AppSettingTool.getSysSetting(); await SyncTakeFoodBroadcastTool.doCheckBroadcast( setting.hourType ?? ProductHourType.TOTAL, ); - } catch (e) { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行失败,error:$e"); - } finally { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行完成"); - } - }); - } - - /// 设备状态检查不依赖网络;电子秤只临时只读监听主动上报,不发送指令,其他设备沿用无侵入探测。 - void checkDeviceStatus() { - final inSeconds = deviceStatusInterval.inSeconds; - deviceStatusTimer?.cancel(); - deviceStatusTimer = Timer.periodic(deviceStatusInterval, (timer) async { - logger.i("|$_tag|检查终端设备状态,$inSeconds秒一次,开始执行"); - try { + return const _ScheduledTaskActionResult.success('取餐广播检查完成'); + case ScheduledTaskType.deviceStatus: await AppDeviceStatusTool.refreshAll(); - } catch (e) { - logger.i("|$_tag|检查终端设备状态执行失败,error:$e"); - } finally { - logger.i("|$_tag|检查终端设备状态执行完成"); - } - }); - } - - // 检查过期日志(超过3个月的日志文件都要删除) - checkExpiredLog() async { - const logMsg = "检查过期日志"; - try { - final backupPath = await AppFileTool.getLoggerBackupPath(); - if (backupPath == null || backupPath.isEmpty) { - logger.w("|$_tag|$logMsg,日志备份路径为空"); - return; - } - final loggerBackupDir = Directory(backupPath).parent.parent; - if (!await loggerBackupDir.exists()) { - logger.i("|$_tag|$logMsg,日志目录不存在 path:${loggerBackupDir.path}"); - return; - } - final expireBefore = DateTime( - DateTime.now().year, - DateTime.now().month - 3, - DateTime.now().day, - ); - final entities = loggerBackupDir.listSync(recursive: true); - int deleteCount = 0; - for (final entity in entities) { - if (entity is! File) continue; - if (!entity.path.endsWith(".log")) continue; - final stat = await entity.stat(); - if (stat.modified.isBefore(expireBefore)) { - await entity.delete(); - deleteCount++; - logger.i("|$_tag|$logMsg,删除过期日志 path:${entity.path}"); - } - } - final monthDirs = loggerBackupDir - .listSync() - .whereType() - .toList() - ..sort((a, b) => b.path.compareTo(a.path)); - for (final dir in monthDirs) { - if (!await dir.exists()) continue; - final isEmpty = dir.listSync().isEmpty; - if (isEmpty) { - await dir.delete(); - logger.i("|$_tag|$logMsg,删除空目录 path:${dir.path}"); - } - } - logger.i("|$_tag|$logMsg,执行完成,删除日志数量:$deleteCount"); - } catch (e, stack) { - logger.e("|$_tag|$logMsg,执行失败,error:$e,stack:$stack"); + return const _ScheduledTaskActionResult.success('设备状态检查完成'); + case ScheduledTaskType.configSync: + await SyncConfigTool.autoSyncSysInfo(); + return const _ScheduledTaskActionResult.success('设备配置同步完成'); + case ScheduledTaskType.classifySync: + await SyncProductTool.syncClassify(); + return const _ScheduledTaskActionResult.success('分类同步完成'); + case ScheduledTaskType.productSync: + await SyncProductTool.syncProduct(); + return const _ScheduledTaskActionResult.success('商品同步完成'); + case ScheduledTaskType.offlineOrderSync: + await SyncOrderTool.syncOfflineOrder(); + return const _ScheduledTaskActionResult.success('离线订单同步完成'); + case ScheduledTaskType.terminalLogUpload: + final summary = await TerminalLogUploadTool.upload( + currentOnly: currentLogOnly, + ); + terminalLogSummary.value = summary; + return _ScheduledTaskActionResult.success( + summary.label, + summary: summary, + ); + case ScheduledTaskType.expiredLogCleanup: + final uploadSummary = await TerminalLogUploadTool.upload(); + terminalLogSummary.value = uploadSummary; + final deleted = await TerminalLogUploadTool.cleanupExpiredLogs(); + return _ScheduledTaskActionResult.success( + '上传检查完成,清理 $deleted 个过期日志', + summary: uploadSummary, + ); } } + + /// 返回任务是否会占用较多网络、磁盘或数据处理资源,需要进入重型任务互斥区。 + bool _isHeavy(ScheduledTaskType type) { + return const { + ScheduledTaskType.configSync, + ScheduledTaskType.classifySync, + ScheduledTaskType.productSync, + ScheduledTaskType.offlineOrderSync, + ScheduledTaskType.terminalLogUpload, + ScheduledTaskType.expiredLogCleanup, + }.contains(type); + } + + /// 返回任务设置页和执行日志共用的中文名称。 + static String taskTitle(ScheduledTaskType type) => + _taskDescriptor(type).title; + + /// 返回任务执行日志使用的主要依赖类型。 + static ScheduledTaskSourceType taskSourceType(ScheduledTaskType type) => + _taskDescriptor(type).sourceType; + + /// 返回任务执行日志使用的具体依赖名称。 + static String taskSourceName(ScheduledTaskType type) => + _taskDescriptor(type).sourceName; + + /// 从 SQLite 查询指定任务最近日志,并可按依赖类型过滤。 + Future> loadLogs( + ScheduledTaskType type, { + ScheduledTaskSourceType? sourceType, + }) => + ScheduledTaskStorageTool.getLogs(type, sourceType: sourceType); + + /// 从 SQLite 分页查询指定任务日志,并返回筛选后的总数与校正页码。 + Future< + ({ + List records, + int total, + int currentPage, + })> loadLogPage( + ScheduledTaskType type, { + ScheduledTaskSourceType? sourceType, + required int currentPage, + required int pageSize, + }) => + ScheduledTaskStorageTool.getLogPage( + type, + sourceType: sourceType, + currentPage: currentPage, + pageSize: pageSize, + ); + + /// 将任务类型映射为稳定展示名称和依赖分类,不把 UI 文案写入持久化主键。 + static _ScheduledTaskDescriptor _taskDescriptor(ScheduledTaskType type) => + switch (type) { + ScheduledTaskType.mqReconnect => const _ScheduledTaskDescriptor( + title: 'MQTT 重连', + sourceType: ScheduledTaskSourceType.mqtt, + sourceName: 'MQTT 消息服务', + ), + ScheduledTaskType.printCheck => const _ScheduledTaskDescriptor( + title: '打印任务检查', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 接口与打印插件', + ), + ScheduledTaskType.broadcastCheck => const _ScheduledTaskDescriptor( + title: '取餐广播检查', + sourceType: ScheduledTaskSourceType.local, + sourceName: '本地广播队列', + ), + ScheduledTaskType.deviceStatus => const _ScheduledTaskDescriptor( + title: '设备状态检查', + sourceType: ScheduledTaskSourceType.plugin, + sourceName: '终端硬件插件', + ), + ScheduledTaskType.configSync => const _ScheduledTaskDescriptor( + title: '设备配置同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 配置接口', + ), + ScheduledTaskType.classifySync => const _ScheduledTaskDescriptor( + title: '分类同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 分类接口', + ), + ScheduledTaskType.productSync => const _ScheduledTaskDescriptor( + title: '商品同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 商品接口', + ), + ScheduledTaskType.offlineOrderSync => const _ScheduledTaskDescriptor( + title: '离线订单同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 订单接口', + ), + ScheduledTaskType.terminalLogUpload => const _ScheduledTaskDescriptor( + title: '终端日志上传', + sourceType: ScheduledTaskSourceType.file, + sourceName: '本地文件与 HTTP 上传接口', + ), + ScheduledTaskType.expiredLogCleanup => const _ScheduledTaskDescriptor( + title: '过期日志清理', + sourceType: ScheduledTaskSourceType.file, + sourceName: '本地日志文件', + ), + }; + + /// 将异常摘要截断到 1000 个字符,避免单条任务日志异常膨胀。 + static String _safeError(Object error) { + final text = error.toString(); + return text.length <= 1000 ? text : '${text.substring(0, 1000)}...'; + } + + /// 将异常堆栈截断到 4000 个字符,在保留诊断价值的同时限制 SQLite 记录大小。 + static String _safeStack(StackTrace stack) { + final text = stack.toString(); + return text.length <= 4000 ? text : '${text.substring(0, 4000)}...'; + } + + /// 幂等释放调度资源;并发调用会等待同一个正在执行的释放操作。 + Future dispose() async { + final disposing = _disposeFuture; + if (disposing != null) return disposing; + final operation = _dispose(); + _disposeFuture = operation; + try { + await operation; + } finally { + if (identical(_disposeFuture, operation)) _disposeFuture = null; + } + } + + /// 取消轮询和全部 Cron 句柄、清空待执行状态并关闭 Cron 实例。 + Future _dispose() async { + _started = false; + _idleTimer?.cancel(); + _idleTimer = null; + for (final task in _scheduledTasks.values) { + await task.cancel(); + } + _scheduledTasks.clear(); + _pendingRequests.clear(); + pendingTasks.clear(); + final cron = _cron; + _cron = null; + await cron?.close(); + } +} + +/// 尚未执行或同任务运行中再次触发时保存的合并请求。 +class _PendingTaskRequest { + /// 创建一次待执行请求,明确日志范围、空闲要求和触发来源。 + const _PendingTaskRequest({ + required this.currentLogOnly, + required this.requireSafeWindow, + required this.requireFullIdle, + required this.triggerType, + }); + + /// 是否只上传 Logger 当前写入的日志文件;为假时扫描全部日志。 + final bool currentLogOnly; + + /// 是否必须等待支付、人脸、遮罩、弹窗和其他重型任务全部结束。 + final bool requireSafeWindow; + + /// 是否还需在安全窗口基础上满足连续 60 秒无人操作。 + final bool requireFullIdle; + + /// 最终写入任务执行日志的触发来源。 + final ScheduledTaskTriggerType triggerType; + + /// 全量日志请求优先于“仅当前文件”;任一手动请求到达后只需等待安全窗口。 + _PendingTaskRequest merge(_PendingTaskRequest other) => _PendingTaskRequest( + currentLogOnly: currentLogOnly && other.currentLogOnly, + requireSafeWindow: requireSafeWindow || other.requireSafeWindow, + requireFullIdle: requireFullIdle && other.requireFullIdle, + triggerType: triggerType == ScheduledTaskTriggerType.cron + ? other.triggerType + : triggerType, + ); +} + +/// MQTT 等外部依赖暂不可用时使用的可诊断任务异常。 +class ScheduledTaskDependencyException implements Exception { + /// 使用面向终端人员的安全摘要创建依赖异常。 + const ScheduledTaskDependencyException(this.message); + + /// 不包含地址、凭证或堆栈的依赖失败摘要。 + final String message; + + /// 返回可直接写入任务最近结果的依赖失败摘要。 + @override + String toString() => message; +} + +/// 单个业务动作完成后返回给统一调度收尾流程的结果。 +class _ScheduledTaskActionResult { + /// 创建成功结果,可附带文件任务的上传统计。 + const _ScheduledTaskActionResult.success(this.message, {this.summary}) + : status = ScheduledTaskRunStatus.success; + + /// 创建无需继续处理的跳过结果,不携带文件上传统计。 + const _ScheduledTaskActionResult.skipped(this.message) + : status = ScheduledTaskRunStatus.skipped, + summary = null; + + /// 设置页和任务日志使用的简短中文结果摘要。 + final String message; + + /// 本次动作的最终运行状态,仅包含成功或跳过。 + final ScheduledTaskRunStatus status; + + /// 日志上传或清理任务的文件统计,其他任务为空。 + final TerminalLogUploadSummary? summary; +} + +/// 任务展示名称及主要依赖来源的稳定描述。 +class _ScheduledTaskDescriptor { + /// 创建一项任务元数据,仅用于展示和结构化日志分类。 + const _ScheduledTaskDescriptor({ + required this.title, + required this.sourceType, + required this.sourceName, + }); + + /// 面向终端人员的中文任务名称。 + final String title; + + /// MQTT、HTTP、插件、文件或本地逻辑中的主要依赖分类。 + final ScheduledTaskSourceType sourceType; + + /// 执行日志展示的具体依赖名称快照。 + final String sourceName; } diff --git a/lib/common/utils/sync/sync_idle_tool.dart b/lib/common/utils/sync/sync_idle_tool.dart new file mode 100644 index 0000000..6ff5638 --- /dev/null +++ b/lib/common/utils/sync/sync_idle_tool.dart @@ -0,0 +1,117 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart'; + +/// 同步调度与顶栏共用的空闲状态快照,避免界面自行拼装支付、弹窗和重型任务规则。 +class SyncIdleSnapshot { + /// 创建一次不可变的终端空闲判断快照。 + const SyncIdleSnapshot({ + required this.foreground, + required this.safeWindow, + required this.idle, + required this.inactiveFor, + required this.reason, + }); + + /// 应用当前是否处于 resumed 前台状态。 + final bool foreground; + + /// 当前是否避开了支付遮罩、人脸会话、业务弹窗和其他重型任务。 + final bool safeWindow; + + /// 当前是否同时满足安全窗口和连续 60 秒无人操作。 + final bool idle; + + /// 距离最近一次指针操作或应用恢复前台经过的时长。 + final Duration inactiveFor; + + /// 当前未达到空闲状态的首要中文原因;空闲时为空字符串。 + final String reason; + + /// 返回顶栏使用的简短空闲状态文案。 + String get label => idle ? '空闲' : '忙碌'; +} + +/// 统一记录终端用户活动和重型后台任务,用于选择不会干扰支付、人脸和弹框的安全执行窗口。 +class SyncIdleTool { + /// 隐藏构造器,空闲判断必须通过全局单例维护同一份活动时间和任务计数。 + SyncIdleTool._(); + + /// 应用级空闲检测单例。 + static final SyncIdleTool instance = SyncIdleTool._(); + + /// 自动空闲任务要求的最短连续无人操作时间。 + static const Duration idleDuration = Duration(seconds: 60); + + /// 最近一次用户指针操作或恢复前台的本地时间。 + DateTime _lastActivityAt = DateTime.now(); + + /// 应用是否处于可安全执行终端任务的前台生命周期。 + bool _foreground = true; + + /// 当前并行执行的重型同步任务数量,用计数支持嵌套开始和结束。 + int _heavyTaskCount = 0; + + /// 记录一次终端触控活动并重新开始 60 秒空闲计时。 + void recordUserActivity() { + _lastActivityAt = DateTime.now(); + } + + /// 根据应用生命周期更新前台状态,恢复前台时视为一次用户活动。 + void updateLifecycle(AppLifecycleState state) { + _foreground = state == AppLifecycleState.resumed; + if (_foreground) recordUserActivity(); + } + + /// 标记一个重型任务开始,阻止其他需要安全窗口的任务并发执行。 + void beginHeavyTask() => _heavyTaskCount++; + + /// 标记一个重型任务结束;计数最低保持为零以容忍重复清理。 + void endHeavyTask() { + if (_heavyTaskCount > 0) _heavyTaskCount--; + } + + /// 手动任务和后台拉取只等待业务安全窗口,不额外要求 60 秒无人操作。 + SyncIdleSnapshot get snapshot { + final faceActive = Get.isRegistered() && + Get.find().isActive; + final route = Get.context == null ? null : ModalRoute.of(Get.context!); + final dialogOpen = Get.isDialogOpen == true || + Get.isBottomSheetOpen == true || + route?.isCurrent == false; + final inactiveFor = DateTime.now().difference(_lastActivityAt); + final reason = !_foreground + ? '应用不在前台' + : EasyLoading.isShow + ? '前台操作进行中' + : faceActive + ? '人脸识别进行中' + : dialogOpen + ? '业务弹窗处理中' + : _heavyTaskCount > 0 + ? '后台同步进行中' + : inactiveFor < idleDuration + ? '最近有终端操作' + : ''; + final safeWindow = _foreground && + !EasyLoading.isShow && + !faceActive && + !dialogOpen && + _heavyTaskCount == 0; + return SyncIdleSnapshot( + foreground: _foreground, + safeWindow: safeWindow, + idle: safeWindow && inactiveFor >= idleDuration, + inactiveFor: inactiveFor, + reason: reason, + ); + } + + /// 当前是否满足手动执行和 MQTT 拉取所需的业务安全窗口。 + bool get isSafeWindow => snapshot.safeWindow; + + /// 当前是否满足自动空闲任务的完整空闲条件。 + bool get isIdle => snapshot.idle; +} diff --git a/lib/common/utils/sync/terminal_log_upload_tool.dart b/lib/common/utils/sync/terminal_log_upload_tool.dart new file mode 100644 index 0000000..6ee9a22 --- /dev/null +++ b/lib/common/utils/sync/terminal_log_upload_tool.dart @@ -0,0 +1,309 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 单次日志上传存在部分文件失败时抛出的汇总异常。 +/// +/// 已成功或跳过的文件不会回滚,[summary] 供调度日志和设置页保留本次完整统计。 +class TerminalLogUploadException implements Exception { + /// 使用本次上传统计创建异常,调用方应读取 [summary] 而不是重新扫描文件。 + const TerminalLogUploadException(this.summary); + + /// 包含扫描、跳过、成功、失败数量及最近成功时间的本次任务统计。 + final TerminalLogUploadSummary summary; + + /// 返回不包含日志正文和文件路径的安全错误摘要。 + @override + String toString() => '终端日志上传存在 ${summary.failed} 个失败文件'; +} + +/// 脱敏日志上传器,所有文件串行处理,避免占满终端磁盘、CPU 和上行带宽。 +class TerminalLogUploadTool { + /// 禁止实例化;上传流程只通过静态入口串行处理本次扫描结果。 + TerminalLogUploadTool._(); + + /// 当前日志脱敏规则版本,随 multipart 元数据提交给服务端留存。 + static const String sanitizeVersion = 'v1'; + + /// 单次预检或文件上传请求的发送与响应超时时间。 + static const Duration requestTimeout = Duration(minutes: 2); + + /// 扫描并上传终端日志,返回本次处理统计。 + /// + /// [currentOnly] 为真时只处理 Logger 当前写入文件,供 MQTT 实时拉取使用;每个文件都先生成 + /// 脱敏副本并通过预检确认是否需要上传,任一文件失败不会阻断后续文件处理。 + static Future upload({ + bool currentOnly = false, + }) async { + if (!AppNetworkTool.isAutoNetwork()) { + throw StateError('当前网络不可用'); + } + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) { + throw StateError('终端尚未绑定,无法上传日志'); + } + + await flushCurrentAppLog(); + final catalog = await listAppLogFiles(); + final files = currentOnly + ? catalog.files.where((item) => item.isCurrent).toList() + : catalog.files.where((item) => item.exists).toList(); + final states = await FileUploadRecordTool.getSuccessfulStates(hardId); + final appVersion = (await PackageInfo.fromPlatform()).version; + var skipped = 0; + var succeeded = 0; + var failed = 0; + DateTime? lastSuccessAt; + + for (final item in files) { + final source = File(item.path); + if (!await source.exists()) continue; + final stat = await source.stat(); + final sourceSize = item.isCurrent ? stat.size : stat.size; + final previous = states[item.relativePath]; + if (previous != null && + previous.sourceSize == sourceSize && + previous.sourceModifiedTime == stat.modified) { + skipped++; + lastSuccessAt = _latest(lastSuccessAt, previous.uploadedAt); + continue; + } + + File? temporaryFile; + FileUploadRecordEntity? uploadAttempt; + try { + uploadAttempt = await FileUploadRecordTool.beginAttempt( + hardId: hardId, + relativePath: item.relativePath, + sourceSize: sourceSize, + sourceModifiedTime: stat.modified, + ); + temporaryFile = File( + '${Directory.systemTemp.path}/terminal-log-${DateTime.now().microsecondsSinceEpoch}.log', + ); + await sanitizeAppLogFile( + source: source, + target: temporaryFile, + maxSourceBytes: sourceSize, + ); + final sanitizedHash = + (await md5.bind(temporaryFile.openRead()).first).toString(); + final sanitizedSize = await temporaryFile.length(); + final needUpload = await _preflight( + hardId: hardId, + relativePath: item.relativePath, + fileSize: sanitizedSize, + fileHash: sanitizedHash, + ); + String? serverFileId; + if (needUpload) { + serverFileId = await _uploadFile( + hardId: hardId, + relativePath: item.relativePath, + modifiedAt: stat.modified, + appVersion: appVersion, + file: temporaryFile, + ); + succeeded++; + } else { + skipped++; + } + final uploadedAt = DateTime.now(); + lastSuccessAt = _latest(lastSuccessAt, uploadedAt); + await FileUploadRecordTool.markSuccess( + attempt: uploadAttempt, + sanitizedHash: sanitizedHash, + sanitizedSize: sanitizedSize, + serverFileId: serverFileId, + ); + } catch (error, stack) { + failed++; + if (uploadAttempt != null) { + try { + await FileUploadRecordTool.markFailure( + attempt: uploadAttempt, + error: error, + ); + } catch (recordError, recordStack) { + // 上传异常是主错误,记录表写入失败只能补充告警,不能覆盖真实失败原因。 + logger.w( + '|终端日志上传|保存失败记录异常path:${item.relativePath}|' + 'error:$recordError|stack:$recordStack|', + ); + } + } + logger.e( + '|终端日志上传|文件处理失败path:${item.relativePath}|error:$error|stack:$stack|', + ); + } finally { + try { + if (temporaryFile != null && await temporaryFile.exists()) { + await temporaryFile.delete(); + } + } catch (error, stack) { + // 临时脱敏副本清理失败不能中断后续文件,但必须留下可诊断告警。 + logger.w( + '|终端日志上传|临时文件清理失败path:${temporaryFile?.path}|' + 'error:$error|stack:$stack|', + ); + } + } + } + + final summary = TerminalLogUploadSummary( + scanned: files.length, + skipped: skipped, + succeeded: succeeded, + failed: failed, + lastSuccessAt: lastSuccessAt, + ); + if (failed > 0) throw TerminalLogUploadException(summary); + return summary; + } + + /// 在可空的当前时间与候选时间中返回较晚值,用于汇总最近成功时间。 + static DateTime _latest(DateTime? current, DateTime candidate) => + current == null || candidate.isAfter(current) ? candidate : current; + + /// 向后端提交路径、脱敏文件大小和 MD5,返回是否仍需上传文件内容。 + static Future _preflight({ + required String hardId, + required String relativePath, + required int fileSize, + required String fileHash, + }) async { + final response = await _retryRequest( + () => HttpManage().dio.post( + '/hard/hardTerminalLogFile/ad/preflight', + data: { + 'relativePath': relativePath, + 'fileSize': fileSize, + 'fileHash': fileHash, + }, + options: Options( + headers: {'DEVICE-ID': hardId}, + sendTimeout: requestTimeout, + receiveTimeout: requestTimeout, + ), + ), + ); + final data = response.data; + if (data is! Map || data['needUpload'] is! bool) { + throw StateError('日志上传预检响应无效'); + } + return data['needUpload'] as bool; + } + + /// 以 multipart 上传单个脱敏日志,并返回后端 `hard_terminal_log_file.id`。 + static Future _uploadFile({ + required String hardId, + required String relativePath, + required DateTime modifiedAt, + required String appVersion, + required File file, + }) async { + final fileName = relativePath.replaceAll('\\', '/').split('/').last; + final response = await _retryRequest( + () => HttpManage().dio.post( + '/hard/hardTerminalLogFile/ad/upload', + data: FormData.fromMap({ + 'relativePath': relativePath, + 'logDate': _logDate(relativePath, modifiedAt), + 'fileModifiedTime': modifiedAt.toIso8601String(), + 'appVersion': appVersion, + 'sanitizeVersion': sanitizeVersion, + 'file': MultipartFile.fromFileSync(file.path, filename: fileName), + }), + options: Options( + headers: {'DEVICE-ID': hardId}, + contentType: 'multipart/form-data', + sendTimeout: requestTimeout, + receiveTimeout: requestTimeout, + ), + ), + ); + final data = response.data; + if (data is! Map || data['id'] == null) { + // 统一请求层会展示业务错误但不抛异常,上传器必须自行确认服务端确实返回了成功结果。 + throw StateError('日志上传响应无效'); + } + return data['id'].toString(); + } + + /// 对连接、收发超时、网络错误和 5xx 最多重试三次,业务错误直接向上抛出。 + static Future> _retryRequest( + Future> Function() action, + ) async { + Object? lastError; + for (var attempt = 1; attempt <= 3; attempt++) { + try { + return await action(); + } on DioException catch (error) { + lastError = error; + final transient = error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.connectionError || + (error.response?.statusCode ?? 0) >= 500; + if (!transient || attempt == 3) rethrow; + await Future.delayed(Duration(seconds: attempt)); + } + } + throw StateError('日志上传失败:$lastError'); + } + + /// 从标准日志相对路径推导 `yyyy-MM-dd`,旧路径不匹配时回退到文件修改日期。 + static String _logDate(String relativePath, DateTime fallback) { + final match = RegExp(r'(\d{4})-(\d{2})/(\d{2})\.log$') + .firstMatch(relativePath.replaceAll('\\', '/')); + if (match == null) { + return '${fallback.year.toString().padLeft(4, '0')}-${fallback.month.toString().padLeft(2, '0')}-${fallback.day.toString().padLeft(2, '0')}'; + } + return '${match.group(1)}-${match.group(2)}-${match.group(3)}'; + } + + /// 清理前必须确认历史日志已经成功上传,未同步文件只告警、不删除。 + static Future cleanupExpiredLogs() async { + final catalog = await listAppLogFiles(); + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) return 0; + final states = await FileUploadRecordTool.getSuccessfulStates(hardId); + final expireBefore = DateTime( + DateTime.now().year, + DateTime.now().month - 3, + DateTime.now().day, + ); + var deleted = 0; + for (final item in catalog.files) { + if (item.isCurrent || + item.modifiedAt == null || + !item.modifiedAt!.isBefore(expireBefore)) { + continue; + } + final state = states[item.relativePath]; + final file = File(item.path); + if (state == null) { + logger.w('|终端日志清理|过期日志尚未上传,暂不删除path:${item.relativePath}|'); + continue; + } + if (await file.exists()) { + final stat = await file.stat(); + // 上传成功后文件可能被继续写入,只有本地快照仍匹配时才允许清理。 + if (state.sourceSize != stat.size || + state.sourceModifiedTime != stat.modified) { + logger.w('|终端日志清理|过期日志上传后已有变化,暂不删除path:${item.relativePath}|'); + continue; + } + await file.delete(); + deleted++; + } + } + return deleted; + } +} diff --git a/lib/common/widgets/app/terminal_device_status.dart b/lib/common/widgets/app/terminal_device_status.dart index e14d34a..752b1e4 100644 --- a/lib/common/widgets/app/terminal_device_status.dart +++ b/lib/common/widgets/app/terminal_device_status.dart @@ -1,17 +1,23 @@ +import 'dart:async'; + 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/style/xfj_design_tokens.dart'; import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart'; import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart'; +import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; import 'package:pj_supermarket_cloud/common/utils/app/app_device_status_tool.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/app_runtime_tool.dart'; +import 'package:pj_supermarket_cloud/common/utils/sync/sync_idle_tool.dart'; -/// 顶栏统一订阅设备状态快照,避免各业务页面分别维护硬件在线标记。 +/// 顶栏统一订阅设备状态快照;名称放入 Tooltip,常驻区只保留一致的图标和状态点。 class TerminalDeviceStatus extends StatelessWidget { + /// 创建订阅应用级设备状态的顶栏图标组。 const TerminalDeviceStatus({super.key}); + /// 在最小 Obx 子树中读取硬件与人脸会话状态并构建设备图标。 @override Widget build(BuildContext context) { return Obx(() { @@ -22,99 +28,108 @@ class TerminalDeviceStatus extends StatelessWidget { final cameraOpen = faceService?.cameraOpen.value ?? false; final cameraStatus = faceService?.cameraStatus.value ?? FaceRecognitionSessionStatus.cold; - return Row( - children: [ - TerminalStatusPill( - icon: FAssets.icons.printer, - label: '打印机', - active: status.printerAvailable, - showBorder: false, - ), - TerminalStatusPill( - icon: FAssets.icons.scale, - label: '电子秤', - active: status.steelyardAvailable, - showBorder: false, - ), - TerminalStatusPill( - icon: FAssets.icons.idCard, - label: '读卡器', - active: status.cardReaderAvailable, - showBorder: false, - ), - TerminalStatusPill( - icon: FAssets.icons.monitor, - label: '副屏', - active: status.subScreenAvailable, - showBorder: false, - ), - FaceCameraStatusCard( - cameraOpen: cameraOpen, - status: cameraStatus, - ), - ], + final cameraVisual = _FaceCameraStatusVisual.resolve( + cameraOpen: cameraOpen, + status: cameraStatus, ); + return [ + TerminalStatusIcon( + icon: FAssets.icons.printer, + label: '打印机', + active: status.printerAvailable, + ), + TerminalStatusIcon( + icon: FAssets.icons.scale, + label: '电子秤', + active: status.steelyardAvailable, + ), + TerminalStatusIcon( + icon: FAssets.icons.idCard, + label: '读卡器', + active: status.cardReaderAvailable, + ), + TerminalStatusIcon( + icon: FAssets.icons.monitor, + label: '副屏', + active: status.subScreenAvailable, + ), + TerminalStatusIcon( + icon: cameraOpen ? FAssets.icons.camera : FAssets.icons.cameraOff, + label: '摄像头', + active: cameraOpen, + accent: cameraVisual.accent, + description: cameraVisual.description, + ), + ].toRowSpace(space: 2.w); }); } } -/// 摄像头既有识别中状态,也有连续客流所需的热保持状态,因此在同一行保留具体状态文字。 -class FaceCameraStatusCard extends StatelessWidget { - const FaceCameraStatusCard({ +/// 48dp 状态图标在不牺牲触控/悬停说明的前提下压缩顶栏宽度。 +class TerminalStatusIcon extends StatelessWidget { + /// 创建一个带可用状态点、语义标签和悬停说明的固定尺寸图标。 + const TerminalStatusIcon({ super.key, - required this.cameraOpen, - required this.status, + required this.icon, + required this.label, + required this.active, + this.accent, + this.description, }); - final bool cameraOpen; - final FaceRecognitionSessionStatus status; + /// 当前设备能力使用的 Forui SVG 图标。 + final SvgAsset icon; + /// 设备能力中文名称,同时用于无障碍语义。 + final String label; + + /// 当前能力是否可用,决定图标透明度和默认状态点颜色。 + final bool active; + + /// 覆盖默认可用状态颜色的业务强调色,例如摄像头初始化或异常状态。 + final Color? accent; + + /// 覆盖默认“可用/不可用”文案的详细悬停说明。 + final String? description; + + /// 构建 48dp 图标、右下状态点及 Tooltip,不展示常驻文字以节省顶栏宽度。 @override Widget build(BuildContext context) { - final theme = FTheme.of(context); - final sizeScale = XfjDesignTokens.buttonSizeScale; - final itemPadding = 12.w * sizeScale; - final visual = _FaceCameraStatusVisual.resolve( - cameraOpen: cameraOpen, - status: status, - ); - return Tooltip( - message: visual.description, - child: SizedBox( - key: ValueKey('terminal-camera-${visual.label}'), - height: 36.h, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: itemPadding), - child: Row( - mainAxisSize: MainAxisSize.min, + final statusColor = + accent ?? (active ? XfjDesignTokens.success : XfjDesignTokens.warning); + final statusText = active ? '可用' : '不可用'; + return Semantics( + container: true, + label: '$label$statusText', + child: Tooltip( + message: description ?? '$label$statusText', + child: SizedBox.square( + dimension: 48.w, + child: Stack( + alignment: Alignment.center, children: [ FIcon( - cameraOpen ? FAssets.icons.camera : FAssets.icons.cameraOff, - size: 19.w, - color: XfjDesignTokens.ink, + icon, + size: 21.w, + color: active ? XfjDesignTokens.ink : XfjDesignTokens.muted, ), - SizedBox(width: 8.w), - Text( - '摄像头', - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.ink, - ), - ), - SizedBox(width: 8.w), - Container( - width: 8.w, - height: 8.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: visual.accent, - ), - ), - SizedBox(width: 8.w), - Text( - visual.label, - style: theme.typography.xs.copyWith( - color: visual.accent, - fontWeight: FontWeight.w600, + Positioned( + right: 7.w, + bottom: 7.w, + child: Container( + key: ValueKey( + 'terminal-status-$label-${active ? 'online' : 'offline'}', + ), + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + border: Border.all( + color: XfjDesignTokens.surface, + width: 1.5.w, + ), + ), ), ), ], @@ -125,17 +140,21 @@ class FaceCameraStatusCard extends StatelessWidget { } } +/// 将人脸会话状态转换为摄像头图标使用的说明和强调色。 class _FaceCameraStatusVisual { + /// 创建一份摄像头状态视觉描述。 const _FaceCameraStatusVisual({ - required this.label, required this.description, required this.accent, }); - final String label; + /// 悬停时展示的摄像头与识别引擎状态说明。 final String description; + + /// 摄像头状态点颜色。 final Color accent; + /// 综合摄像头开启状态和人脸会话状态生成对应视觉描述。 static _FaceCameraStatusVisual resolve({ required bool cameraOpen, required FaceRecognitionSessionStatus status, @@ -143,100 +162,220 @@ class _FaceCameraStatusVisual { if (!cameraOpen) { final failed = status == FaceRecognitionSessionStatus.failed; return _FaceCameraStatusVisual( - label: failed ? '异常关闭' : '已关闭', description: failed ? '摄像头或识别引擎异常,当前已释放资源' : '摄像头当前未开启', accent: failed ? XfjDesignTokens.danger : XfjDesignTokens.muted, ); } if (status == FaceRecognitionSessionStatus.initializing) { return const _FaceCameraStatusVisual( - label: '启动中', description: '摄像头已打开,正在初始化虹软识别引擎', accent: XfjDesignTokens.primary, ); } if (status == FaceRecognitionSessionStatus.detecting) { return const _FaceCameraStatusVisual( - label: '识别中', description: '摄像头已打开,正在实时识别人脸', accent: XfjDesignTokens.success, ); } if (status == FaceRecognitionSessionStatus.failed) { return const _FaceCameraStatusVisual( - label: '等待重试', description: '摄像头仍保持开启,可直接重试当前人脸会话', accent: XfjDesignTokens.warning, ); } if (status == FaceRecognitionSessionStatus.success) { return const _FaceCameraStatusVisual( - label: '识别成功', description: '摄像头已打开,正在处理本次识别结果', accent: XfjDesignTokens.success, ); } return const _FaceCameraStatusVisual( - label: '热保持', description: '摄像头已打开,连续 20 分钟无新的人脸会话后自动关闭', accent: Color(0xFF0F766E), ); } } -class TerminalStatusPill extends StatelessWidget { - const TerminalStatusPill({ - super.key, - required this.label, - this.icon, - this.active = true, - this.showBorder = true, - }); +/// 顶栏轻量展示进程 CPU、系统内存、存储和调度空闲状态。 +class TerminalPerformanceStatus extends StatefulWidget { + /// 创建顶栏性能与空闲状态区域。 + const TerminalPerformanceStatus({super.key}); - final String label; - final SvgAsset? icon; - final bool active; - final bool showBorder; + /// 创建管理三秒采样周期和两次 CPU 快照差值的状态对象。 + @override + State createState() => + _TerminalPerformanceStatusState(); +} +/// 顶栏性能指标的采样、CPU 差值计算和展示状态。 +class _TerminalPerformanceStatusState extends State { + /// 每三秒触发一次轻量原生资源采样的计时器。 + Timer? _timer; + + /// 上一次原生累计资源快照,用于计算当前进程 CPU 使用率。 + AppPerformanceSnapshot? _previous; + + /// 上一次资源快照完成的本地时间,用于计算 CPU 墙钟时间差。 + DateTime? _previousAt; + + /// 当前用于顶栏展示的内存、存储和累计 CPU 快照。 + AppPerformanceSnapshot _snapshot = const AppPerformanceSnapshot.empty(); + + /// 当前调度空闲判断快照,与性能数据在同一次局部 setState 中刷新。 + SyncIdleSnapshot _idle = SyncIdleTool.instance.snapshot; + + /// 根据连续两次累计 CPU 时间计算的进程 CPU 百分比,范围为 0 到 100。 + double _cpuUsage = 0; + + /// 是否已有资源采样请求进行中,防止慢平台通道造成并发采样。 + bool _refreshing = false; + + /// 首次立即采样,并启动固定三秒的后续刷新周期。 + @override + void initState() { + super.initState(); + unawaited(_refresh()); + _timer = Timer.periodic( + const Duration(seconds: 3), + (_) => unawaited(_refresh()), + ); + } + + /// 页面释放时取消性能采样计时器,避免顶栏销毁后继续访问平台通道。 + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + /// 读取原生资源快照、计算进程 CPU 差值,并在组件仍挂载时刷新可见状态。 + Future _refresh() async { + if (_refreshing) return; + _refreshing = true; + try { + final current = await AppRuntimeTool.getPerformanceSnapshot(); + final now = DateTime.now(); + final previous = _previous; + final previousAt = _previousAt; + var cpuUsage = _cpuUsage; + if (previous != null && previousAt != null) { + final wallMillis = now.difference(previousAt).inMilliseconds; + final cpuMillis = + current.processCpuTimeMillis - previous.processCpuTimeMillis; + if (wallMillis > 0 && cpuMillis >= 0) { + cpuUsage = + (cpuMillis / wallMillis / current.logicalProcessorCount * 100) + .clamp(0, 100) + .toDouble(); + } + } + _previous = current; + _previousAt = now; + if (!mounted) return; + setState(() { + _snapshot = current; + _idle = SyncIdleTool.instance.snapshot; + _cpuUsage = cpuUsage; + }); + } finally { + _refreshing = false; + } + } + + /// 构建 CPU、内存、存储和空闲摘要,详细容量通过 Tooltip 展示。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); - final sizeScale = XfjDesignTokens.buttonSizeScale; - return Container( - height: 36.h, - padding: EdgeInsets.symmetric(horizontal: 12.w * sizeScale), - decoration: BoxDecoration( - color: XfjDesignTokens.surface, - border: showBorder ? Border.all(color: XfjDesignTokens.line) : null, - borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - FIcon(icon!, size: 19.w, color: XfjDesignTokens.ink), - SizedBox(width: 8.w), - ], - Container( - key: ValueKey( - 'terminal-status-$label-${active ? 'online' : 'offline'}', - ), - width: 8.w, - height: 8.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: active ? XfjDesignTokens.success : XfjDesignTokens.warning, - ), + final idleColor = + _idle.idle ? XfjDesignTokens.success : XfjDesignTokens.warning; + final memoryUsage = (_snapshot.memoryUsageRatio * 100).round(); + final storageUsage = (_snapshot.storageUsageRatio * 100).round(); + final cpuText = + _snapshot.processCpuTimeMillis == 0 ? '--' : '${_cpuUsage.round()}%'; + final memoryText = + _snapshot.systemMemoryTotalBytes == 0 ? '--' : '$memoryUsage%'; + final storageText = + _snapshot.storageTotalBytes == 0 ? '--' : '$storageUsage%'; + final warning = _cpuUsage >= 85 || memoryUsage >= 90 || storageUsage >= 90; + return Tooltip( + message: _tooltipText(memoryUsage, storageUsage), + child: Semantics( + container: true, + label: + '设备性能,CPU $cpuText,内存 $memoryText,存储 $storageText,${_idle.label}', + child: [ + FIcon( + FAssets.icons.gauge, + size: 21.w, + color: warning ? XfjDesignTokens.danger : XfjDesignTokens.ink, ), - SizedBox(width: 8.w), - Text( - label, - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.ink, + [ + Text( + 'CPU $cpuText · 内存 $memoryText · 存储 $storageText', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), - ), - ], + [ + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: idleColor, + ), + ), + Text( + _idle.idle ? '设备空闲' : '设备忙碌 · ${_idle.reason}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: idleColor, + fontWeight: FontWeight.w600, + ), + ).expanded(), + ].toRowSpace(space: 6.w), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.start, + space: 3.h, + ) + .expanded(), + ] + .toRowSpace(space: 10.w) + .padding(horizontal: 12.w) + .constrained(width: 300.w, height: 48.h), ), ); } + + /// 生成包含应用内存、系统总内存、存储容量和忙碌原因的悬停说明。 + String _tooltipText(int memoryUsage, int storageUsage) { + final memory = _snapshot.systemMemoryTotalBytes == 0 + ? '内存数据不可用' + : '系统内存 $memoryUsage%,应用占用 ${_formatBytes(_snapshot.appMemoryBytes)}' + ' / 总计 ${_formatBytes(_snapshot.systemMemoryTotalBytes)}'; + final storage = _snapshot.storageTotalBytes == 0 + ? '存储数据不可用' + : '存储 $storageUsage%,可用 ${_formatBytes(_snapshot.storageAvailableBytes)}' + ' / 总计 ${_formatBytes(_snapshot.storageTotalBytes)}'; + final idle = _idle.idle ? '设备空闲' : '设备忙碌:${_idle.reason}'; + return '进程 CPU ${_cpuUsage.toStringAsFixed(1)}%\n$memory\n$storage\n$idle'; + } + + /// 将字节数转换为适合顶栏悬停说明的 GB、MB 或 KB 文本。 + String _formatBytes(int bytes) { + if (bytes >= 1024 * 1024 * 1024) { + return '${(bytes / 1024 / 1024 / 1024).toStringAsFixed(1)} GB'; + } + if (bytes >= 1024 * 1024) { + return '${(bytes / 1024 / 1024).toStringAsFixed(0)} MB'; + } + return '${(bytes / 1024).toStringAsFixed(0)} KB'; + } } diff --git a/lib/common/widgets/app/terminal_shell.dart b/lib/common/widgets/app/terminal_shell.dart index 74e61b8..79373bd 100644 --- a/lib/common/widgets/app/terminal_shell.dart +++ b/lib/common/widgets/app/terminal_shell.dart @@ -151,19 +151,29 @@ class TerminalHeader extends StatelessWidget { child: Row( children: [ const TerminalNetworkStatus(), + const _TerminalHeaderDivider(), const TerminalDeviceStatus(), + const _TerminalHeaderDivider(), + const TerminalPerformanceStatus(), + const _TerminalHeaderDivider(), const _TerminalAdminLockStatus(), ], ), ), SizedBox(width: 24.w), - FutureBuilder( - future: AppSettingTool.getUserSetting(), - builder: (context, snapshot) => Text( - snapshot.data?.username ?? '收银员', - style: theme.typography.base.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w600, + SizedBox( + width: 100.w, + child: FutureBuilder( + future: AppSettingTool.getUserSetting(), + builder: (context, snapshot) => Text( + snapshot.data?.username ?? '收银员', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), ), ), ), @@ -178,15 +188,15 @@ class TerminalHeader extends StatelessWidget { SizedBox(width: 8.w), const _TerminalClock(), SizedBox(width: 16.w), - TerminalSoftButton( - label: '设置', + TerminalHeaderIconButton( + tooltip: '设置', icon: FAssets.icons.settings, onTap: () => TerminalNavigationTool.open(RouteNames.setting), ), SizedBox(width: 8.w), - TerminalSoftButton( - label: '退出', + TerminalHeaderIconButton( + tooltip: '退出登录', icon: FAssets.icons.logOut, danger: true, onTap: TerminalNavigationTool.logout, @@ -329,9 +339,9 @@ class _TerminalNetworkStatusState extends State { Widget build(BuildContext context) { final theme = FTheme.of(context); return Container( - width: 270.w, + width: 220.w, constraints: BoxConstraints(minHeight: 48.h), - padding: EdgeInsets.symmetric(horizontal: 12.w), + padding: EdgeInsets.symmetric(horizontal: 10.w), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -353,7 +363,7 @@ class _TerminalNetworkStatusState extends State { ), SizedBox(width: 7.w), SizedBox( - width: 100.w, + width: 72.w, child: Text( _connected ? _networkType : '网络离线', maxLines: 1, @@ -645,61 +655,76 @@ class _TerminalAdminLockStatus extends StatelessWidget { } } -class TerminalSoftButton extends StatelessWidget { - const TerminalSoftButton({ +/// 顶栏设置与退出使用的固定尺寸图标按钮,完整命令名称通过 Tooltip 和语义暴露。 +class TerminalHeaderIconButton extends StatelessWidget { + /// 创建一个固定 48dp 的顶栏命令按钮。 + const TerminalHeaderIconButton({ super.key, - required this.label, + required this.tooltip, required this.onTap, - this.icon, + required this.icon, this.danger = false, }); - final String label; - final SvgAsset? icon; + /// 悬停和无障碍读屏使用的完整命令名称。 + final String tooltip; + + /// 命令使用的 Forui SVG 图标。 + final SvgAsset icon; + + /// 点击后执行的设置跳转或退出登录命令。 final VoidCallback onTap; + + /// 是否使用危险操作颜色,退出登录时为真。 final bool danger; + /// 构建带 Tooltip、语义和危险态颜色的固定尺寸图标按钮。 @override Widget build(BuildContext context) { - final theme = FTheme.of(context); - final sizeScale = XfjDesignTokens.buttonSizeScale; final foreground = danger ? XfjDesignTokens.danger : XfjDesignTokens.ink; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), - child: Container( - constraints: BoxConstraints(minHeight: 48.h), - padding: EdgeInsets.symmetric(horizontal: 16.w * sizeScale), - decoration: BoxDecoration( - color: danger ? const Color(0xFFFFF3F2) : const Color(0xFFF7F8FA), - border: Border.all( - color: danger - ? XfjDesignTokens.danger.withValues(alpha: 0.35) - : XfjDesignTokens.line, - ), + return Semantics( + button: true, + label: tooltip, + child: Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - FIcon(icon!, size: 21.w, color: foreground), - SizedBox(width: 8.w), - ], - Text( - label, - style: theme.typography.base.copyWith( - color: foreground, - fontWeight: FontWeight.w600, - ), + child: SizedBox.square( + dimension: 48.w, + child: FIcon(icon, size: 21.w, color: foreground).center(), + ).decorated( + color: danger ? const Color(0xFFFFF3F2) : const Color(0xFFF7F8FA), + border: Border.all( + color: danger + ? XfjDesignTokens.danger.withValues(alpha: 0.35) + : XfjDesignTokens.line, ), - ], + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), ), ), ); } } +/// 顶栏网络、硬件、性能和管理员状态之间的轻量垂直分隔线。 +class _TerminalHeaderDivider extends StatelessWidget { + /// 创建不参与交互的固定高度分隔线。 + const _TerminalHeaderDivider(); + + /// 构建使用全局边框色的单像素分隔线。 + @override + Widget build(BuildContext context) => Container( + width: 1, + height: 28.h, + margin: EdgeInsets.symmetric(horizontal: 4.w), + color: XfjDesignTokens.line, + ); +} + /// 收银页顶部只展示当前设备模式,不在页面内动态切换两套硬件 Controller。 class TerminalModeBadge extends StatelessWidget { const TerminalModeBadge({super.key, required this.label}); diff --git a/lib/pages/terminal/setting/controller.dart b/lib/pages/terminal/setting/controller.dart index 320edbd..fd33858 100644 --- a/lib/pages/terminal/setting/controller.dart +++ b/lib/pages/terminal/setting/controller.dart @@ -7,49 +7,113 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 设置模块共享控制器,集中管理终端配置、同步任务和本地备份业务状态。 class SettingController extends GetxController with WidgetsBindingObserver { + /// 设置模块统一日志标签,用于过滤本控制器产生的诊断日志。 static const String _tag = '设置 Controller'; + /// 应用级控制器,提供 MQTT 配置变更事件总线。 final appC = Get.find(); + + /// 终端一级菜单控制器,提供可配置页面的实时排序。 final terminalMenuC = Get.find(); + + /// 当前设备系统设置;初始化完成前为空,修改深层字段后需主动 refresh。 final sysSetting = Rxn(); + + /// 当前登录用户的本地设置;初始化完成前为空。 final userSetting = Rxn(); + + /// 是否使用人工输入代替扫码、读卡和人脸等真实硬件输入。 final manualHardwareInput = false.obs; + + /// Android Kiosk 当前能力与运行状态,应用恢复前台时刷新。 final kioskStatus = AppRuntimeTool.kioskStatus.obs; + + /// 收银支付方式的界面排序,初始顺序来自枚举并由本地持久化覆盖。 final payTypeOrder = [ ...OrderPayDisplayType.values, ].obs; + + /// 页面设置当前编辑的终端一级页面,默认展示收银页面配置。 final pageSettingType = TerminalMenuItemType.cashier.obs; /// 收银页右下角快捷操作显隐独立观察,切换时不重建支付方式排序区域。 final cashierShowHoldAction = true.obs; + + /// 收银页右下角“更多”快捷操作是否展示。 final cashierShowMoreAction = true.obs; + + /// 预约查询识别方式的展示顺序,持久化到现有设置 JSON。 final reservationQueryMethodOrder = [ ...ReservationRecognitionMethod.values, ].obs; + + /// 预约查询默认识别方式,必须始终包含在当前识别方式顺序中。 final reservationQueryDefaultMethod = ReservationRecognitionMethod.card.obs; /// 查询菜单是否要求服务端脱敏用户姓名。 final reservationQueryNameMaskEnabled = true.obs; + /// 查询菜单是否启用服务端二次核销校验。 final reservationQueryVerificationEnabled = false.obs; + + /// 查询菜单核销允许的日期范围,默认仅限当天。 final reservationQueryVerificationDateScope = ReservationVerificationDateScope.today.obs; - final cuurentMenuType = SettingMenuType.baseInfo.obs; + + /// 设置模块是否仍在初始化系统设置、备份时间和调度器。 final isLoading = true.obs; + + /// 当前终端是否已经绑定服务端商户设备。 final isBound = false.obs; + + /// 旧同步操作当前执行键;空字符串表示没有旧同步操作运行。 final syncingKey = ''.obs; + + /// 最近执行失败的旧同步操作键,用于对应按钮展示失败状态。 final syncFailedKeys = {}.obs; + + /// 最近一次本地数据库备份文件修改时间;尚无备份时为空。 final sqlBackupTime = Rxn(); + + /// 最近一次本地日志备份文件修改时间;尚无备份时为空。 final logBackupTime = Rxn(); + + /// 最近一次手动备份的结果与目标路径摘要。 final lastBackupMessage = ''.obs; + /// 旧内联编辑器当前展开的任务类型;保留给兼容组件读取,为空表示全部收起。 + final expandedScheduledTask = Rxn(); + + /// 各任务尚未保存的 Cron 输入草稿,以任务类型隔离编辑状态。 + final scheduledTaskCronDrafts = {}.obs; + + /// 各任务 Cron 草稿的本地校验错误,保存成功或继续输入后移除。 + final scheduledTaskCronErrors = {}.obs; + + /// 正在持久化配置的任务集合,用于禁止同任务重复提交并局部禁用控件。 + final savingScheduledTasks = {}.obs; + + /// MQTT 配置更新事件订阅,在控制器关闭时释放。 StreamSubscription? _mqUpdateListen; + + /// 是否正在串行写入支付方式偏好,防止拖动操作并发覆盖。 bool _payPersisting = false; + + /// 支付方式保存期间是否又产生了新状态,需要在当前写入后继续保存。 bool _payPendingPersist = false; + + /// 是否正在串行写入收银页面快捷操作偏好。 bool _cashierPagePersisting = false; + + /// 收银页面偏好保存期间是否又产生了待写入状态。 bool _cashierPagePendingPersist = false; + + /// 是否正在串行写入预约查询页面偏好。 bool _reservationQueryPersisting = false; + + /// 预约查询偏好保存期间是否又产生了待写入状态。 bool _reservationQueryPendingPersist = false; /// 页面设置跟随主屏左侧菜单的实时顺序,但不继承隐藏状态;设置页自身不递归配置。 @@ -71,6 +135,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { ) .toList(growable: false); + /// 注册应用生命周期观察并异步加载设置模块全部基础数据。 @override void onInit() { super.onInit(); @@ -78,10 +143,16 @@ class SettingController extends GetxController with WidgetsBindingObserver { _initialize(); } + /// 读取本地设置、备份时间和任务配置,并订阅服务端配置更新事件。 Future _initialize() async { try { await getSetting(); await refreshBackupTimes(); + await SyncCheckTool.instance.startAll(); + scheduledTaskCronDrafts.assignAll({ + for (final entry in SyncCheckTool.instance.configs.entries) + entry.key: entry.value.cron, + }); _mqUpdateListen = appC.mqUpdateEventBus.on().listen( (event) async { if (event == MqtActionEnum.CONFIG) await getSetting(); @@ -95,6 +166,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { } } + /// 释放应用生命周期观察和 MQTT 配置事件订阅。 @override void onClose() { WidgetsBinding.instance.removeObserver(this); @@ -102,16 +174,13 @@ class SettingController extends GetxController with WidgetsBindingObserver { super.onClose(); } + /// 应用恢复前台时重新读取 Kiosk 状态,反映外部系统设置变化。 @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) refreshKioskStatus(); } - void doMenu(SettingMenuType type) { - if (!visibleMenuTypes.contains(type)) return; - cuurentMenuType.value = type; - } - + /// 从现有持久化服务恢复系统、用户、支付和页面偏好,并更新设备绑定状态。 Future getSetting() async { await refreshKioskStatus(); final setting = await AppSettingTool.getSysSetting(); @@ -165,11 +234,13 @@ class SettingController extends GetxController with WidgetsBindingObserver { AppRuntimeTool.applyManualHardwareInput(setting.manualHardwareInput); } + /// 切换页面设置当前编辑对象;不可配置的一级菜单请求会被忽略。 void selectPageSettingType(TerminalMenuItemType type) { if (!configurablePageTypes.contains(type)) return; pageSettingType.value = type; } + /// 调整预约查询识别方式顺序,并合并保存到最新页面偏好。 void reorderReservationQueryMethod(int oldIndex, int newIndex) { if (oldIndex == newIndex || oldIndex < 0 || @@ -185,6 +256,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { _scheduleReservationQueryPreferencesPersist(); } + /// 设置预约查询默认识别方式,仅接受当前列表内的方式。 void setReservationQueryDefaultMethod( ReservationRecognitionMethod method, ) { @@ -196,18 +268,21 @@ class SettingController extends GetxController with WidgetsBindingObserver { _scheduleReservationQueryPreferencesPersist(); } + /// 切换查询菜单服务端二次核销校验并安排持久化。 void setReservationQueryVerificationEnabled(bool enabled) { if (reservationQueryVerificationEnabled.value == enabled) return; reservationQueryVerificationEnabled.value = enabled; _scheduleReservationQueryPreferencesPersist(); } + /// 切换查询结果姓名脱敏并安排持久化。 void setReservationQueryNameMaskEnabled(bool enabled) { if (reservationQueryNameMaskEnabled.value == enabled) return; reservationQueryNameMaskEnabled.value = enabled; _scheduleReservationQueryPreferencesPersist(); } + /// 更新预约核销日期范围并安排持久化。 void setReservationQueryVerificationDateScope( ReservationVerificationDateScope scope, ) { @@ -216,6 +291,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { _scheduleReservationQueryPreferencesPersist(); } + /// 将预约查询页面偏好恢复为产品默认值并安排持久化。 void resetReservationQueryPreferences() { reservationQueryMethodOrder.assignAll( ReservationRecognitionMethod.values, @@ -236,6 +312,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { } } + /// 串行写入预约查询页面偏好,保存期间的新变更会继续下一轮写入。 Future _flushReservationQueryPreferences() async { _reservationQueryPersisting = true; while (_reservationQueryPendingPersist) { @@ -256,10 +333,12 @@ class SettingController extends GetxController with WidgetsBindingObserver { _reservationQueryPersisting = false; } + /// 从 Android 原生能力重新读取当前 Kiosk 状态。 Future refreshKioskStatus() async { kioskStatus.value = await AppRuntimeTool.refreshKioskStatus(); } + /// 保存全局字体倍率并立即应用到运行中主题。 Future setGlobalFontSize(SettingGlobalFontSize fontSize) async { final setting = sysSetting.value; if (setting == null || setting.globalFontSize == fontSize) return; @@ -269,6 +348,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { sysSetting.refresh(); } + /// 保存按钮圆角并立即应用到运行中主题。 Future setButtonRadius(SettingButtonRadius value) async { final setting = sysSetting.value; if (setting == null || setting.buttonRadius == value) return; @@ -278,6 +358,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { sysSetting.refresh(); } + /// 保存卡片圆角并立即应用到运行中主题。 Future setCardRadius(SettingCardRadius value) async { final setting = sysSetting.value; if (setting == null || setting.cardRadius == value) return; @@ -287,6 +368,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { sysSetting.refresh(); } + /// 保存按钮尺寸档位并立即应用到运行中主题。 Future setButtonSize(SettingButtonSize value) async { final setting = sysSetting.value; if (setting == null || setting.buttonSize == value) return; @@ -296,6 +378,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { sysSetting.refresh(); } + /// 启用或停用支付方式,始终至少保留一种并修正失效的默认方式。 void togglePayType(OrderPayDisplayType type) { final setting = sysSetting.value; if (setting == null) return; @@ -322,6 +405,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { _schedulePayPreferencesPersist(); } + /// 调整支付方式展示顺序,同时保持启用集合与完整排序一致。 void reorderPayType(int oldIndex, int newIndex) { if (oldIndex == newIndex || oldIndex < 0 || @@ -344,6 +428,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { _schedulePayPreferencesPersist(); } + /// 恢复全部支付方式及默认枚举顺序,并安排持久化。 void resetPayTypes() { payTypeOrder.assignAll(OrderPayDisplayType.values); final setting = sysSetting.value; @@ -357,6 +442,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { _schedulePayPreferencesPersist(); } + /// 设置默认支付方式;未启用的支付方式不能成为默认值。 void setDefaultPayType(OrderPayDisplayType type) { final setting = sysSetting.value; if (setting == null) return; @@ -370,18 +456,21 @@ class SettingController extends GetxController with WidgetsBindingObserver { _schedulePayPreferencesPersist(); } + /// 切换收银页“挂单”快捷操作显隐并安排持久化。 void setCashierShowHoldAction(bool visible) { if (cashierShowHoldAction.value == visible) return; cashierShowHoldAction.value = visible; _scheduleCashierPagePreferencesPersist(); } + /// 切换收银页“更多”快捷操作显隐并安排持久化。 void setCashierShowMoreAction(bool visible) { if (cashierShowMoreAction.value == visible) return; cashierShowMoreAction.value = visible; _scheduleCashierPagePreferencesPersist(); } + /// 将收银页面快捷操作恢复为默认显示状态。 void resetCashierPagePreferences() { cashierShowHoldAction.value = true; cashierShowMoreAction.value = true; @@ -396,6 +485,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { } } + /// 串行写入收银页面偏好,保存期间的新变更会继续下一轮写入。 Future _flushCashierPagePreferences() async { _cashierPagePersisting = true; while (_cashierPagePendingPersist) { @@ -419,6 +509,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { if (!_payPersisting) unawaited(_flushPayPreferences()); } + /// 串行写入支付方式偏好,保存期间的新变更会继续下一轮写入。 Future _flushPayPreferences() async { _payPersisting = true; while (_payPendingPersist) { @@ -438,6 +529,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { _payPersisting = false; } + /// 通过旧同步入口立即拉取设备配置,并在成功后刷新系统设置。 Future syncConfig() async { await _runSync('config', '设备信息', () async { final setting = await SyncConfigTool.autoSyncSysInfo(); @@ -445,6 +537,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { }); } + /// 执行或取消设备状态检查,并维护旧同步按钮的局部结果状态。 Future syncDeviceStatus() async { if (AppDeviceStatusTool.isRefreshing) { AppDeviceStatusTool.cancelRefresh(); @@ -469,6 +562,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { } } + /// 通过旧同步入口关闭并重新初始化 MQTT 长连接。 Future syncMq() async { await _runSync('mq', '长链接', () async { await MqManager.closeMqtt(); @@ -476,18 +570,147 @@ class SettingController extends GetxController with WidgetsBindingObserver { }); } + /// 通过旧同步入口立即同步商品分类。 Future syncClassify() async { await _runSync('classify', '分类', SyncProductTool.syncClassify); } + /// 通过旧同步入口立即同步商品主数据。 Future syncProduct() async { await _runSync('product', '商品', SyncProductTool.syncProduct); } + /// 通过旧同步入口立即补传本地离线订单。 Future syncOfflineOrder() async { await _runSync('offlineOrder', '离线订单', SyncOrderTool.syncOfflineOrder); } + /// 展开或收起旧内联 Cron 编辑器,并用当前已保存规则重置草稿和错误。 + void toggleScheduledTaskEditor(ScheduledTaskType type) { + expandedScheduledTask.value = + expandedScheduledTask.value == type ? null : type; + scheduledTaskCronDrafts[type] = + SyncCheckTool.instance.configs[type]?.cron ?? ''; + scheduledTaskCronErrors.remove(type); + } + + /// 更新指定任务的 Cron 草稿,输入变化后清除该任务旧校验错误。 + void setScheduledTaskCronDraft(ScheduledTaskType type, String value) { + scheduledTaskCronDrafts[type] = value; + if (scheduledTaskCronErrors.containsKey(type)) { + scheduledTaskCronErrors.remove(type); + } + } + + /// 切换任务自动调度开关;关闭运行保障任务前要求操作人员确认。 + Future setScheduledTaskEnabled( + ScheduledTaskType type, + bool enabled, + ) async { + final config = SyncCheckTool.instance.configs[type]; + if (config == null || config.enabled == enabled) return; + if (!enabled && _isRuntimeGuardTask(type)) { + final confirmed = await appMessageDialog( + Get.context!, + '关闭后终端将不再自动执行该运行保障任务,是否继续?', + ); + if (!confirmed) return; + } + await _saveScheduledTaskConfig(config.copyWith(enabled: enabled)); + } + + /// 切换任务是否仅在完整空闲窗口自动执行,并持久化到结构化任务表。 + Future setScheduledTaskIdleOnly( + ScheduledTaskType type, + bool idleOnly, + ) async { + final config = SyncCheckTool.instance.configs[type]; + if (config == null || config.idleOnly == idleOnly) return; + await _saveScheduledTaskConfig(config.copyWith(idleOnly: idleOnly)); + } + + /// Cron 弹窗返回经过本地解析校验的表达式,Controller 仍在持久化边界再次校验, + /// 避免其他调用入口绕过校验写入无法注册的任务规则。 + Future setScheduledTaskCron( + ScheduledTaskType type, + String expression, + ) async { + final config = SyncCheckTool.instance.configs[type]; + if (config == null) return; + final cron = expression.trim(); + if (!SyncCheckTool.isValidCron(cron)) { + Toast.error(msg: '请输入有效的 6 段 Cron 表达式'); + return; + } + if (cron == config.cron) return; + await _saveScheduledTaskConfig(config.copyWith(cron: cron)); + } + + /// 校验并保存旧内联编辑器中的 Cron 草稿,非法值只更新对应错误状态。 + Future saveScheduledTaskCron(ScheduledTaskType type) async { + final config = SyncCheckTool.instance.configs[type]; + if (config == null) return; + final cron = (scheduledTaskCronDrafts[type] ?? '').trim(); + if (!SyncCheckTool.isValidCron(cron)) { + scheduledTaskCronErrors[type] = '请输入有效的 6 段 Cron 表达式'; + return; + } + await _saveScheduledTaskConfig(config.copyWith(cron: cron)); + } + + /// 串联调度重建和 SQLite 保存,负责重复提交保护、草稿回退及用户反馈。 + Future _saveScheduledTaskConfig(ScheduledTaskConfig config) async { + if (savingScheduledTasks.contains(config.type)) return; + final previous = SyncCheckTool.instance.configs[config.type]; + savingScheduledTasks.add(config.type); + try { + await SyncCheckTool.instance.updateConfig(config); + scheduledTaskCronDrafts[config.type] = config.cron; + scheduledTaskCronErrors.remove(config.type); + Toast.success(msg: '任务配置已保存'); + } catch (e, stack) { + if (previous != null) { + scheduledTaskCronDrafts[config.type] = previous.cron; + } + logger + .e('|$_tag|保存定时任务失败type:${config.type.name}|error:$e|stack:$stack|'); + Toast.error(msg: '任务配置保存失败,请重试'); + } finally { + savingScheduledTasks.remove(config.type); + } + } + + /// 手动触发指定任务,并根据立即执行、排队或合并结果向操作人员反馈。 + Future runScheduledTaskNow(ScheduledTaskType type) async { + final scheduler = SyncCheckTool.instance; + if (scheduler.runningTasks.contains(type) || + scheduler.pendingTasks.contains(type)) { + await scheduler.trigger(type); + Toast.success(msg: '已合并到待执行任务'); + return; + } + await scheduler.trigger(type); + if (scheduler.pendingTasks.contains(type)) { + Toast.success(msg: '任务已排队,将在安全窗口执行'); + return; + } + final record = scheduler.records[type]; + if (record?.success == true) { + Toast.success(msg: record?.message ?? '任务执行完成'); + } else { + Toast.error(msg: record?.message ?? '任务执行失败'); + } + } + + /// 返回关闭自动调度前需要二次确认的 MQTT、打印、广播和设备状态保障任务。 + bool _isRuntimeGuardTask(ScheduledTaskType type) => const { + ScheduledTaskType.mqReconnect, + ScheduledTaskType.printCheck, + ScheduledTaskType.broadcastCheck, + ScheduledTaskType.deviceStatus, + }.contains(type); + + /// 执行旧同步动作的统一网络校验、遮罩、成功反馈和异常记录流程。 Future _runSync( String key, String label, @@ -514,14 +737,17 @@ class SettingController extends GetxController with WidgetsBindingObserver { } } + /// 手动备份本地 SQLite 数据库并刷新最近备份时间。 Future backupsSql() async { await _runBackup('SQL', AppFileTool.backupSqlFilePath); } + /// 手动备份本地日志文件并刷新最近备份时间。 Future backupsLog() async { await _runBackup('日志', AppFileTool.backupLogFile); } + /// 执行单类文件备份,统一处理路径校验、状态提示和异常日志。 Future _runBackup( String label, Future Function() action, @@ -541,6 +767,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { } } + /// 扫描现有备份目录并更新数据库、日志最近修改时间。 Future refreshBackupTimes() async { final setting = sysSetting.value; if (setting == null) return; @@ -555,6 +782,7 @@ class SettingController extends GetxController with WidgetsBindingObserver { ); } + /// 切换人工硬件输入模式,模拟器必须保持开启并在真机开启前二次确认。 Future setManualHardwareInput(bool enabled) async { if (AppRuntimeTool.isEmulator && !enabled) { Toast.error(msg: '虚拟机必须使用手动硬件输入模式'); diff --git a/lib/pages/terminal/setting/face_setting_controller.dart b/lib/pages/terminal/setting/face_setting_controller.dart index 86665ff..62977c7 100644 --- a/lib/pages/terminal/setting/face_setting_controller.dart +++ b/lib/pages/terminal/setting/face_setting_controller.dart @@ -6,6 +6,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/pages/terminal/setting/controller.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/setting/routes/navigation.dart'; /// 管理人脸插件状态、激活和内存库重建,避免硬件状态继续堆叠到设置主 Controller。 class FaceSettingController extends GetxController { @@ -250,8 +251,9 @@ class FaceSettingController extends GetxController { await openAppSettings(); } + /// 从人脸插件诊断页切换到独立的人脸管理路由。 void goFaceManage() { - Get.find().doMenu(SettingMenuType.faceSetting); + SettingNavigation.openMenu(SettingMenuType.faceSetting); } void toggleParameterVisibility() { diff --git a/lib/pages/terminal/setting/routes/backup/view.dart b/lib/pages/terminal/setting/routes/backup/view.dart new file mode 100644 index 0000000..6d4c501 --- /dev/null +++ b/lib/pages/terminal/setting/routes/backup/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“资源备份”路由页,复用既有数据库与日志导出组件。 +class SettingBackupRoutePage extends StatelessWidget { + /// 创建不持有局部状态的资源备份路由页。 + const SettingBackupRoutePage({super.key}); + + /// 构建既有资源备份内容。 + @override + Widget build(BuildContext context) => settingBackup(context); +} diff --git a/lib/pages/terminal/setting/routes/base_info/view.dart b/lib/pages/terminal/setting/routes/base_info/view.dart new file mode 100644 index 0000000..4093cfb --- /dev/null +++ b/lib/pages/terminal/setting/routes/base_info/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“基本信息”路由页,复用既有设备绑定和显示设置组件。 +class SettingBaseInfoRoutePage extends StatelessWidget { + /// 创建不持有局部状态的基本信息路由页。 + const SettingBaseInfoRoutePage({super.key}); + + /// 构建既有基本信息内容,业务状态继续由 SettingController 管理。 + @override + Widget build(BuildContext context) => settingBaseInfo(context); +} diff --git a/lib/pages/terminal/setting/routes/card_reader/view.dart b/lib/pages/terminal/setting/routes/card_reader/view.dart new file mode 100644 index 0000000..be3c9dd --- /dev/null +++ b/lib/pages/terminal/setting/routes/card_reader/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“读/写卡”路由页,复用 DK25-ST 设备配置组件。 +class SettingCardReaderRoutePage extends StatelessWidget { + /// 创建不持有局部状态的读写卡配置路由页。 + const SettingCardReaderRoutePage({super.key}); + + /// 构建既有读写卡配置内容。 + @override + Widget build(BuildContext context) => const SettingCardReader(); +} diff --git a/lib/pages/terminal/setting/routes/face/view.dart b/lib/pages/terminal/setting/routes/face/view.dart new file mode 100644 index 0000000..02bffb2 --- /dev/null +++ b/lib/pages/terminal/setting/routes/face/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“人脸管理”路由页,复用既有人脸样本维护组件。 +class SettingFaceRoutePage extends StatelessWidget { + /// 创建不持有局部状态的人脸管理路由页。 + const SettingFaceRoutePage({super.key}); + + /// 构建既有人脸管理内容。 + @override + Widget build(BuildContext context) => settingFace(context); +} diff --git a/lib/pages/terminal/setting/routes/face_plugin/view.dart b/lib/pages/terminal/setting/routes/face_plugin/view.dart new file mode 100644 index 0000000..a58d2ed --- /dev/null +++ b/lib/pages/terminal/setting/routes/face_plugin/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“人脸设置”路由页,复用既有人脸插件诊断与配置组件。 +class SettingFacePluginRoutePage extends StatelessWidget { + /// 创建不持有局部状态的人脸插件设置路由页。 + const SettingFacePluginRoutePage({super.key}); + + /// 构建既有人脸插件设置内容。 + @override + Widget build(BuildContext context) => settingFacePlugin(context); +} diff --git a/lib/pages/terminal/setting/routes/index.dart b/lib/pages/terminal/setting/routes/index.dart new file mode 100644 index 0000000..95d5808 --- /dev/null +++ b/lib/pages/terminal/setting/routes/index.dart @@ -0,0 +1,4 @@ +library; + +export 'navigation.dart'; +export 'route_factory.dart'; diff --git a/lib/pages/terminal/setting/routes/log_preview/view.dart b/lib/pages/terminal/setting/routes/log_preview/view.dart new file mode 100644 index 0000000..e783e90 --- /dev/null +++ b/lib/pages/terminal/setting/routes/log_preview/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“日志预览”路由页,用于查看本机文件日志内容。 +class SettingLogPreviewRoutePage extends StatelessWidget { + /// 创建不持有局部状态的日志文件预览路由页。 + const SettingLogPreviewRoutePage({super.key}); + + /// 构建既有日志文件树与预览组件。 + @override + Widget build(BuildContext context) => const SettingLogPreview(); +} diff --git a/lib/pages/terminal/setting/routes/navigation.dart b/lib/pages/terminal/setting/routes/navigation.dart new file mode 100644 index 0000000..908ec54 --- /dev/null +++ b/lib/pages/terminal/setting/routes/navigation.dart @@ -0,0 +1,99 @@ +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 设置模块的二级路由入口。 +/// +/// 菜单只负责发起路由跳转,当前选中项由实际路由回调更新,避免再次退化为 +/// `cuurentMenuType` 驱动的内容显隐。 +abstract final class SettingNavigation { + /// 设置模块独立 Navigator 的 GetX 编号,不能与终端工作区的编号 `1` 重复。 + static const int navigatorId = 2; + + /// 当前路由对应的设置菜单,仅用于菜单高亮和页头文案,不负责构建正文。 + static final Rx currentMenuType = + SettingMenuType.baseInfo.obs; + + /// Navigator 同一帧内连续变化时最后一个待同步的路由名称。 + static String? _pendingRouteName; + + /// 是否已经安排帧后路由同步,避免 Navigator 构建期间直接触发菜单 Obx 重建。 + static bool _routeSyncScheduled = false; + + /// 切换到指定设置菜单,并清空设置模块内部的详情页历史。 + static void openMenu(SettingMenuType type) { + Get.offAllNamed(routeForMenu(type), id: navigatorId); + } + + /// 打开指定定时任务的结构化日志页面,保留同步设置页作为返回目标。 + static Future openTaskLogs(ScheduledTaskType type) async { + await Get.toNamed( + RouteNames.settingSyncLogs, + arguments: type, + id: navigatorId, + ); + } + + /// 从日志详情返回同步设置;路由栈异常为空时直接恢复同步设置页。 + static void backToSync() { + final navigator = Get.nestedKey(navigatorId)?.currentState; + if (navigator?.canPop() == true) { + navigator!.pop(); + return; + } + openMenu(SettingMenuType.syncSetting); + } + + /// 接收 Navigator 的真实路由变化并同步菜单高亮,日志详情仍归属同步设置菜单。 + static void handleRouteChanged(String? routeName) { + if (routeName == null) return; + _pendingRouteName = routeName; + if (_routeSyncScheduled) return; + _routeSyncScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _routeSyncScheduled = false; + final pendingRouteName = _pendingRouteName; + _pendingRouteName = null; + final menuType = menuForRoute(pendingRouteName); + if (menuType != null && currentMenuType.value != menuType) { + currentMenuType.value = menuType; + } + }); + } + + /// 将稳定设置菜单映射到对应的二级路由名称。 + static String routeForMenu(SettingMenuType type) => switch (type) { + SettingMenuType.baseInfo => RouteNames.settingBaseInfo, + SettingMenuType.syncSetting => RouteNames.settingSync, + SettingMenuType.paySetting => RouteNames.settingPage, + SettingMenuType.backup => RouteNames.settingBackup, + SettingMenuType.faceSetting => RouteNames.settingFace, + SettingMenuType.facePluginSetting => RouteNames.settingFacePlugin, + SettingMenuType.pageSetting => RouteNames.settingPage, + SettingMenuType.productSetting => RouteNames.settingProduct, + SettingMenuType.logPreview => RouteNames.settingLogPreview, + SettingMenuType.subScreenRoutes => RouteNames.settingSubScreenRoutes, + SettingMenuType.versionSetting => RouteNames.settingVersion, + SettingMenuType.cardReaderSetting => RouteNames.settingCardReader, + }; + + /// 将设置二级路由映射回菜单类型;未知路由返回空,避免错误高亮。 + static SettingMenuType? menuForRoute(String? routeName) => + switch (routeName) { + RouteNames.settingBaseInfo => SettingMenuType.baseInfo, + RouteNames.settingSync || + RouteNames.settingSyncLogs => + SettingMenuType.syncSetting, + RouteNames.settingBackup => SettingMenuType.backup, + RouteNames.settingFace => SettingMenuType.faceSetting, + RouteNames.settingFacePlugin => SettingMenuType.facePluginSetting, + RouteNames.settingPage => SettingMenuType.pageSetting, + RouteNames.settingProduct => SettingMenuType.productSetting, + RouteNames.settingLogPreview => SettingMenuType.logPreview, + RouteNames.settingSubScreenRoutes => SettingMenuType.subScreenRoutes, + RouteNames.settingVersion => SettingMenuType.versionSetting, + RouteNames.settingCardReader => SettingMenuType.cardReaderSetting, + _ => null, + }; +} diff --git a/lib/pages/terminal/setting/routes/page_setting/view.dart b/lib/pages/terminal/setting/routes/page_setting/view.dart new file mode 100644 index 0000000..852629c --- /dev/null +++ b/lib/pages/terminal/setting/routes/page_setting/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“页面设置”路由页,复用菜单与收银页面配置组件。 +class SettingPageConfigRoutePage extends StatelessWidget { + /// 创建不持有局部状态的页面设置路由页。 + const SettingPageConfigRoutePage({super.key}); + + /// 构建既有页面设置内容。 + @override + Widget build(BuildContext context) => settingPage(context); +} diff --git a/lib/pages/terminal/setting/routes/product/view.dart b/lib/pages/terminal/setting/routes/product/view.dart new file mode 100644 index 0000000..313adda --- /dev/null +++ b/lib/pages/terminal/setting/routes/product/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“商品管理”路由页,复用本地商品查询与同步组件。 +class SettingProductRoutePage extends StatelessWidget { + /// 创建不持有局部状态的商品管理路由页。 + const SettingProductRoutePage({super.key}); + + /// 构建既有商品管理内容。 + @override + Widget build(BuildContext context) => settingProduct(context); +} diff --git a/lib/pages/terminal/setting/routes/route_factory.dart b/lib/pages/terminal/setting/routes/route_factory.dart new file mode 100644 index 0000000..0f569b9 --- /dev/null +++ b/lib/pages/terminal/setting/routes/route_factory.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import '../controller.dart'; +import '../widgets/setting_components.dart'; +import 'backup/view.dart'; +import 'base_info/view.dart'; +import 'card_reader/view.dart'; +import 'face/view.dart'; +import 'face_plugin/view.dart'; +import 'log_preview/view.dart'; +import 'navigation.dart'; +import 'page_setting/view.dart'; +import 'product/view.dart'; +import 'sub_screen_routes/view.dart'; +import 'sync/view.dart'; +import 'sync_logs/controller.dart'; +import 'sync_logs/view.dart'; +import 'version/view.dart'; + +/// 设置模块二级 Navigator 的路由工厂。 +/// +/// 每个设置菜单对应同名目录下的路由页,原业务组件继续复用,路由只负责页面生命周期、 +/// 设备绑定守卫和日志详情 Controller 的创建。 +abstract final class SettingRouteFactory { + /// 根据二级路由配置创建无动画 GetPageRoute,未知路由安全回退到基本信息。 + static Route generate(RouteSettings settings) { + final requestedName = settings.name; + final routeName = _knownRoutes.contains(requestedName) + ? requestedName! + : RouteNames.settingBaseInfo; + final menuType = + SettingNavigation.menuForRoute(routeName) ?? SettingMenuType.baseInfo; + final taskType = settings.arguments is ScheduledTaskType + ? settings.arguments! as ScheduledTaskType + : ScheduledTaskType.mqReconnect; + final page = _pageForRoute(routeName); + return GetPageRoute( + routeName: routeName, + settings: RouteSettings(name: routeName, arguments: settings.arguments), + page: () => _SettingAccessGuard( + menuType: menuType, + child: page(), + ), + binding: routeName == RouteNames.settingSyncLogs + ? BindingsBuilder( + () => Get.lazyPut( + () => ScheduledTaskLogsController(taskType), + ), + ) + : null, + transition: Transition.noTransition, + transitionDuration: Duration.zero, + ); + } + + /// 设置模块允许生成的全部二级路由,避免拼写错误意外进入错误内容页。 + static const Set _knownRoutes = { + RouteNames.settingBaseInfo, + RouteNames.settingSync, + RouteNames.settingBackup, + RouteNames.settingFace, + RouteNames.settingFacePlugin, + RouteNames.settingPage, + RouteNames.settingProduct, + RouteNames.settingLogPreview, + RouteNames.settingSubScreenRoutes, + RouteNames.settingVersion, + RouteNames.settingCardReader, + RouteNames.settingSyncLogs, + }; + + /// 将已校验路由名称映射到对应目录的页面构造器。 + static GetPageBuilder _pageForRoute(String routeName) => switch (routeName) { + RouteNames.settingSync => () => const SettingSyncRoutePage(), + RouteNames.settingBackup => () => const SettingBackupRoutePage(), + RouteNames.settingFace => () => const SettingFaceRoutePage(), + RouteNames.settingFacePlugin => () => + const SettingFacePluginRoutePage(), + RouteNames.settingPage => () => const SettingPageConfigRoutePage(), + RouteNames.settingProduct => () => const SettingProductRoutePage(), + RouteNames.settingLogPreview => () => + const SettingLogPreviewRoutePage(), + RouteNames.settingSubScreenRoutes => () => + const SettingSubScreenRoutesRoutePage(), + RouteNames.settingVersion => () => const SettingVersionRoutePage(), + RouteNames.settingCardReader => () => + const SettingCardReaderRoutePage(), + RouteNames.settingSyncLogs => () => const ScheduledTaskLogsPage(), + _ => () => const SettingBaseInfoRoutePage(), + }; +} + +/// 设置子路由共用的加载与设备绑定守卫。 +class _SettingAccessGuard extends StatelessWidget { + /// 创建只负责访问状态的路由包装层,不改变内部业务组件契约。 + const _SettingAccessGuard({required this.menuType, required this.child}); + + /// 当前路由归属的菜单类型,用于判断未绑定设备时是否允许访问。 + final SettingMenuType menuType; + + /// 通过访问校验后展示的实际设置页面。 + final Widget child; + + /// 根据 SettingController 的最小响应状态展示加载、未绑定说明或正文。 + @override + Widget build(BuildContext context) { + final controller = Get.find(); + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (!controller.isBound.value && !_allowsUnboundAccess(menuType)) { + return const SettingEmpty( + message: '设备尚未绑定,请先在“基本信息”中完成绑定', + ); + } + return child; + }); + } + + /// 返回未绑定设备仍可访问的本地设置菜单,规则与旧设置页保持一致。 + static bool _allowsUnboundAccess(SettingMenuType type) => const { + SettingMenuType.baseInfo, + SettingMenuType.pageSetting, + SettingMenuType.backup, + SettingMenuType.logPreview, + SettingMenuType.subScreenRoutes, + SettingMenuType.cardReaderSetting, + }.contains(type); +} diff --git a/lib/pages/terminal/setting/routes/sub_screen_routes/view.dart b/lib/pages/terminal/setting/routes/sub_screen_routes/view.dart new file mode 100644 index 0000000..25f8d7c --- /dev/null +++ b/lib/pages/terminal/setting/routes/sub_screen_routes/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“副屏路由”调试页,仅在现有开发环境规则允许时展示入口。 +class SettingSubScreenRoutesRoutePage extends StatelessWidget { + /// 创建不持有局部状态的副屏路由调试页。 + const SettingSubScreenRoutesRoutePage({super.key}); + + /// 构建既有副屏演示与检查组件。 + @override + Widget build(BuildContext context) => const SettingSubScreenRoutes(); +} diff --git a/lib/pages/terminal/setting/routes/sync/view.dart b/lib/pages/terminal/setting/routes/sync/view.dart new file mode 100644 index 0000000..3e22ba8 --- /dev/null +++ b/lib/pages/terminal/setting/routes/sync/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“同步设置”路由页,复用统一调度器配置列表。 +class SettingSyncRoutePage extends StatelessWidget { + /// 创建不持有局部状态的同步设置路由页。 + const SettingSyncRoutePage({super.key}); + + /// 构建自动任务、日志上传概况和调度说明。 + @override + Widget build(BuildContext context) => settingSync(context); +} diff --git a/lib/pages/terminal/setting/routes/sync_logs/controller.dart b/lib/pages/terminal/setting/routes/sync_logs/controller.dart new file mode 100644 index 0000000..728c20b --- /dev/null +++ b/lib/pages/terminal/setting/routes/sync_logs/controller.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 定时任务日志页面控制器,负责 SQLite 查询、来源筛选和刷新状态。 +class ScheduledTaskLogsController extends GetxController { + /// 日志表格默认每页数量,与 1080p 设置页可用高度和现有交易记录密度保持一致。 + static const int _defaultPageSize = 10; + + /// 使用路由参数中的稳定任务类型创建日志控制器。 + ScheduledTaskLogsController(this.taskType); + + /// 当前页面固定查看的任务类型,生命周期内不会变化。 + final ScheduledTaskType taskType; + + /// 当前筛选条件和页码对应的结构化执行日志,按数据库主键倒序排列。 + final RxList logs = [].obs; + + /// 当前页码,从 1 开始,由分页组件修改并在查询结果越界时自动校正。 + final RxInt currentPage = 1.obs; + + /// 每页固定展示数量;日志行可能展开错误详情,因此保持与交易记录相同的十条密度。 + final int pageSize = _defaultPageSize; + + /// 当前任务和来源筛选下的日志总数,直接来自 SQLite 计数查询。 + final RxInt total = 0.obs; + + /// 当前依赖来源筛选;为空表示查询 MQTT、HTTP、插件、文件和本地全部来源。 + final Rxn sourceType = + Rxn(); + + /// 是否正在读取 SQLite,用于展示加载状态并禁止重复刷新。 + final RxBool isLoading = false.obs; + + /// 最近一次查询失败时面向操作人员的摘要;查询成功后清空。 + final Rxn errorMessage = Rxn(); + + /// 查询序号用于丢弃快速切换筛选时晚返回的旧结果,避免列表与筛选条件错位。 + int _requestSerial = 0; + + /// 控制器创建后立即加载当前任务的全部来源日志。 + @override + void onInit() { + super.onInit(); + unawaited(loadLogs()); + } + + /// 选择新的依赖来源并重新查询;重复点击当前筛选不会制造额外数据库请求。 + void selectSource(ScheduledTaskSourceType? value) { + if (sourceType.value == value) return; + sourceType.value = value; + currentPage.value = 1; + unawaited(loadLogs()); + } + + /// 按当前任务、来源和页码读取日志,只应用最后一次请求的结果。 + Future loadLogs({bool resetPage = false}) async { + if (resetPage) currentPage.value = 1; + final requestSerial = ++_requestSerial; + isLoading.value = true; + errorMessage.value = null; + try { + final result = await SyncCheckTool.instance.loadLogPage( + taskType, + sourceType: sourceType.value, + currentPage: currentPage.value, + pageSize: pageSize, + ); + if (isClosed || requestSerial != _requestSerial) return; + currentPage.value = result.currentPage; + total.value = result.total; + logs.assignAll(result.records); + } catch (error, stack) { + logger.e( + '|定时任务日志|读取失败task:|${taskType.name}|error:|$error|stack:|$stack|', + ); + if (isClosed || requestSerial != _requestSerial) return; + currentPage.value = 1; + total.value = 0; + logs.clear(); + errorMessage.value = '日志读取失败,请稍后重试'; + } finally { + if (!isClosed && requestSerial == _requestSerial) { + isLoading.value = false; + } + } + } + + /// 切换到有效页码并读取对应日志;重复点击当前页不会发起额外 SQLite 查询。 + Future changePage(int page) async { + final totalPages = total.value == 0 ? 1 : (total.value / pageSize).ceil(); + if (page < 1 || page > totalPages || page == currentPage.value) return; + currentPage.value = page; + await loadLogs(); + } +} diff --git a/lib/pages/terminal/setting/routes/sync_logs/view.dart b/lib/pages/terminal/setting/routes/sync_logs/view.dart new file mode 100644 index 0000000..de0a612 --- /dev/null +++ b/lib/pages/terminal/setting/routes/sync_logs/view.dart @@ -0,0 +1,498 @@ +import 'package:common_utils/common_utils.dart'; +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 '../navigation.dart'; +import 'controller.dart'; + +/// 紧凑型定时任务结构化日志页面。 +/// +/// 页面替代原弹窗,使用稳定表格列展示状态、依赖、触发方式、时间、耗时和结果, +/// 错误堆栈仅在用户主动展开时显示,保证常规巡检时的信息密度。 +class ScheduledTaskLogsPage extends GetView { + /// 创建由路由 Binding 提供控制器的任务日志页面。 + const ScheduledTaskLogsPage({super.key}); + + /// 构建页面工具栏、来源筛选、日志表格和分页栏,各响应区域分别局部刷新。 + @override + Widget build(BuildContext context) => [ + _buildToolbar(context), + SizedBox(height: 10.h), + _buildSourceFilters(context), + SizedBox(height: 10.h), + Obx(() => _buildLogBody(context)).expanded(), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx( + () => appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize, + total: controller.total.value, + itemLabel: '条任务日志', + onPageChanged: controller.changePage, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + + /// 构建返回入口、任务与依赖名称、当前条数以及手动刷新按钮。 + Widget _buildToolbar(BuildContext context) { + final theme = FTheme.of(context); + return [ + Tooltip( + message: '返回同步设置', + child: InkWell( + onTap: SettingNavigation.backToSync, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: FIcon( + FAssets.icons.arrowLeft, + size: 22.sp, + color: XfjDesignTokens.ink, + ).center().constrained(width: 48.w, height: 48.h), + ), + ), + [ + Text( + '${SyncCheckTool.taskTitle(controller.taskType)}日志', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 2.h), + Text( + SyncCheckTool.taskSourceName(controller.taskType), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + Obx( + () => Text( + '${controller.total.value} 条', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + SizedBox(width: 12.w), + Obx( + () => FButton( + style: FButtonStyle.outline, + onPress: controller.isLoading.value ? null : controller.loadLogs, + prefix: controller.isLoading.value + ? SizedBox.square( + dimension: 17.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : FIcon(FAssets.icons.refreshCw, size: 17.sp), + label: Text('刷新', style: theme.typography.sm), + ).constrained(minWidth: 104.w, minHeight: 44.h), + ), + ] + .toRowSpace(space: 12.w) + .padding(horizontal: 4.w) + .constrained(minHeight: 52.h); + } + + /// 构建全部来源与五类依赖的紧凑筛选条,窄宽度时允许自然换行。 + Widget _buildSourceFilters(BuildContext context) => Obx( + () => [ + _SourceFilter( + label: '全部', + selected: controller.sourceType.value == null, + onTap: () => controller.selectSource(null), + ), + for (final source in ScheduledTaskSourceType.values) + _SourceFilter( + label: _sourceLabel(source), + selected: controller.sourceType.value == source, + onTap: () => controller.selectSource(source), + ), + ] + .toWrap(spacing: 8.w, runSpacing: 8.h) + .padding(horizontal: 4.w, vertical: 2.h), + ); + + /// 根据查询状态构建加载、失败、空数据或带固定表头的日志列表。 + Widget _buildLogBody(BuildContext context) { + if (controller.isLoading.value && controller.logs.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + final error = controller.errorMessage.value; + if (error != null) { + return _LogState( + icon: FAssets.icons.circleAlert, + message: error, + actionLabel: '重新加载', + onAction: controller.loadLogs, + ); + } + if (controller.logs.isEmpty) { + return _LogState( + icon: FAssets.icons.scrollText, + message: '暂无符合当前来源条件的任务执行日志', + ); + } + return [ + const _LogTableHeader(), + ListView.separated( + padding: EdgeInsets.zero, + itemCount: controller.logs.length, + separatorBuilder: (_, __) => const Divider( + height: 1, + thickness: 1, + color: XfjDesignTokens.line, + ), + itemBuilder: (_, index) => _TaskLogRow(log: controller.logs[index]), + ).expanded(), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch).decorated( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} + +/// 日志表格固定列头,与每条日志行使用同一套宽度约束。 +class _LogTableHeader extends StatelessWidget { + /// 创建无交互的日志表头。 + const _LogTableHeader(); + + /// 构建状态、依赖、开始时间、耗时和执行结果列名。 + @override + Widget build(BuildContext context) { + final style = FTheme.of(context).typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ); + return [ + Text('状态', style: style).width(94.w), + Text('依赖 / 触发', style: style).width(188.w), + Text('开始时间', style: style).width(190.w), + Text('耗时', style: style).width(88.w), + Text('执行结果', style: style).expanded(), + SizedBox(width: 44.w), + ].toRow().padding(horizontal: 14.w, vertical: 9.h).decorated( + color: XfjDesignTokens.control, + border: const Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ); + } +} + +/// 单条结构化任务日志,错误内容按需展开而不占用其他行空间。 +class _TaskLogRow extends StatefulWidget { + /// 使用数据库返回的不可变日志快照创建列表行。 + const _TaskLogRow({required this.log}); + + /// 当前行展示的结构化任务日志。 + final ScheduledTaskLogEntity log; + + /// 创建仅管理本行错误详情展开状态的 State。 + @override + State<_TaskLogRow> createState() => _TaskLogRowState(); +} + +/// 单条任务日志的局部展开状态。 +class _TaskLogRowState extends State<_TaskLogRow> { + /// 是否展示异常类型、异常摘要和堆栈,默认关闭以保持列表紧凑。 + bool _expanded = false; + + /// 构建固定列数据以及可选的错误详情区域。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final visual = _statusVisual(widget.log.status); + final hasDetail = widget.log.errorMessage?.isNotEmpty == true || + widget.log.errorStack?.isNotEmpty == true; + final rowStyle = theme.typography.sm; + return InkWell( + onTap: hasDetail ? _toggleExpanded : null, + child: [ + [ + [ + FIcon(visual.icon, color: visual.color, size: 19.sp), + Text( + visual.label, + maxLines: 1, + style: rowStyle.copyWith( + color: visual.color, + fontWeight: FontWeight.w700, + ), + ), + ].toRowSpace(space: 7.w).width(94.w), + [ + _Badge(label: _sourceLabelName(widget.log.sourceType)), + _Badge(label: _triggerLabel(widget.log.triggerType)), + ].toWrap(spacing: 5.w, runSpacing: 4.h).width(188.w), + Text( + DateUtil.formatDate( + widget.log.startedAt, + format: DateFormats.full, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: rowStyle.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).width(190.w), + Text( + '${widget.log.durationMs} ms', + maxLines: 1, + style: rowStyle.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).width(88.w), + [ + Text( + widget.log.message, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: rowStyle.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + if (_logMetrics(widget.log) case final metrics?) + Text( + metrics, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + FIcon( + hasDetail + ? (_expanded + ? FAssets.icons.chevronUp + : FAssets.icons.chevronDown) + : FAssets.icons.minus, + color: hasDetail ? XfjDesignTokens.muted : XfjDesignTokens.line, + size: 18.sp, + ).center().width(44.w), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 14.w, vertical: 10.h) + .constrained(minHeight: 58.h), + if (_expanded && hasDetail) _buildErrorDetail(context), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + ); + } + + /// 切换本行错误详情,不触发整页日志列表重建。 + void _toggleExpanded() { + setState(() => _expanded = !_expanded); + } + + /// 构建可选择复制的异常详情,内容来自已截断并脱敏的结构化日志字段。 + Widget _buildErrorDetail(BuildContext context) => SelectableText( + [ + widget.log.errorType, + widget.log.errorMessage, + widget.log.errorStack, + ].whereType().where((value) => value.isNotEmpty).join('\n'), + style: FTheme.of(context).typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFamily: 'monospace', + height: 1.45, + ), + ) + .padding(all: 10.w) + .decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ) + .padding(left: 108.w, right: 58.w, bottom: 10.h); +} + +/// MQTT、HTTP、插件、文件和本地来源的紧凑筛选按钮。 +class _SourceFilter extends StatelessWidget { + /// 创建由日志控制器管理选中态的来源筛选项。 + const _SourceFilter({ + required this.label, + required this.selected, + required this.onTap, + }); + + /// 筛选按钮展示的短名称。 + final String label; + + /// 当前来源是否处于选中状态。 + final bool selected; + + /// 点击后切换来源并重新查询的回调。 + final VoidCallback onTap; + + /// 构建 44dp 触控热区内的紧凑文字筛选项。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Text( + label, + maxLines: 1, + softWrap: false, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ) + .alignment(Alignment.center) + .constrained(width: 78.w, height: 40.h) + .decorated( + color: selected + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ) + .padding(vertical: 2.h), + ); + } +} + +/// 日志来源和触发方式使用的轻量元数据标签。 +class _Badge extends StatelessWidget { + /// 使用指定短文本创建无交互标签。 + const _Badge({required this.label}); + + /// 标签展示的来源或触发方式名称。 + final String label; + + /// 构建不改变表格行高的紧凑标签。 + @override + Widget build(BuildContext context) => Text( + label, + maxLines: 1, + style: FTheme.of(context).typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ).padding(horizontal: 7.w, vertical: 2.h).decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ); +} + +/// 日志读取失败或无数据时共用的页面状态视图。 +class _LogState extends StatelessWidget { + /// 使用状态图标、说明和可选重试动作创建状态页。 + const _LogState({ + required this.icon, + required this.message, + this.actionLabel, + this.onAction, + }); + + /// 状态语义对应的 Forui 图标。 + final SvgAsset icon; + + /// 面向终端操作人员的状态说明。 + final String message; + + /// 可选操作按钮名称;为空时不展示按钮。 + final String? actionLabel; + + /// 可选操作回调,通常用于失败后的重新加载。 + final VoidCallback? onAction; + + /// 构建居中的状态内容和可选操作按钮。 + @override + Widget build(BuildContext context) => [ + FIcon(icon, size: 40.sp, color: XfjDesignTokens.muted), + SizedBox(height: 10.h), + Text( + message, + textAlign: TextAlign.center, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 12.h), + FButton( + style: FButtonStyle.outline, + onPress: onAction, + label: Text(actionLabel!), + ).constrained(minWidth: 120.w, minHeight: 44.h), + ], + ].toColumn(mainAxisSize: MainAxisSize.min).center(); +} + +/// 将持久化状态转换为日志列表使用的中文名称、语义颜色和图标。 +({String label, Color color, SvgAsset icon}) _statusVisual(String status) => + switch (status) { + 'success' => ( + label: '成功', + color: XfjDesignTokens.success, + icon: FAssets.icons.circleCheck, + ), + 'failed' => ( + label: '失败', + color: XfjDesignTokens.danger, + icon: FAssets.icons.circleAlert, + ), + 'skipped' => ( + label: '跳过', + color: XfjDesignTokens.warning, + icon: FAssets.icons.circleMinus, + ), + _ => ( + label: '执行中', + color: XfjDesignTokens.primary, + icon: FAssets.icons.refreshCw, + ), + }; + +/// 将任务依赖枚举转换为筛选按钮使用的短名称。 +String _sourceLabel(ScheduledTaskSourceType type) => switch (type) { + ScheduledTaskSourceType.mqtt => 'MQTT', + ScheduledTaskSourceType.http => 'HTTP', + ScheduledTaskSourceType.plugin => '插件', + ScheduledTaskSourceType.file => '文件', + ScheduledTaskSourceType.local => '本地', + }; + +/// 将数据库依赖编码转换为短名称,未知编码保持原样便于诊断兼容问题。 +String _sourceLabelName(String source) { + for (final type in ScheduledTaskSourceType.values) { + if (type.name == source) return _sourceLabel(type); + } + return source; +} + +/// 将数据库中的任务触发来源转换为日志表格中文标签。 +String _triggerLabel(String trigger) => switch (trigger) { + 'cron' => '定时', + 'mqtt' => 'MQTT 拉取', + 'system' => '系统', + _ => '手动', + }; + +/// 生成文件类任务的扫描统计摘要;非文件任务未记录扫描数时返回空。 +String? _logMetrics(ScheduledTaskLogEntity log) { + if (log.scannedCount == null) return null; + return '扫描 ${log.scannedCount ?? 0} · 跳过 ${log.skippedCount ?? 0} · ' + '成功 ${log.successCount ?? 0} · 失败 ${log.failureCount ?? 0}'; +} diff --git a/lib/pages/terminal/setting/routes/version/view.dart b/lib/pages/terminal/setting/routes/version/view.dart new file mode 100644 index 0000000..046e48a --- /dev/null +++ b/lib/pages/terminal/setting/routes/version/view.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +import '../../widgets/index.dart'; + +/// 设置模块“版本管理”路由页,复用终端版本检查和下载组件。 +class SettingVersionRoutePage extends StatelessWidget { + /// 创建不持有局部状态的版本管理路由页。 + const SettingVersionRoutePage({super.key}); + + /// 构建既有版本管理内容。 + @override + Widget build(BuildContext context) => settingVersionSetting(context); +} diff --git a/lib/pages/terminal/setting/view.dart b/lib/pages/terminal/setting/view.dart index 3dfda51..6e17eff 100644 --- a/lib/pages/terminal/setting/view.dart +++ b/lib/pages/terminal/setting/view.dart @@ -4,31 +4,36 @@ import 'package:forui/forui.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/setting/routes/index.dart'; import 'package:pj_supermarket_cloud/pages/terminal/setting/widgets/index.dart'; import 'controller.dart'; +/// 设置模块外壳,左侧菜单和设备绑定状态保持常驻,右侧内容由独立 Navigator 管理。 class SettingPage extends GetView { + /// 创建由终端工作区 Binding 提供 SettingController 的设置页。 const SettingPage({super.key}); + /// 构建固定设置菜单与可路由切换的内容区。 @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(width: 240.w, child: _SettingMenu(controller: controller)), - SizedBox(width: XfjDesignTokens.cardGap.w), - Expanded(child: _SettingContent(controller: controller)), - ], - ); + return [ + SizedBox(width: 240.w, child: _SettingMenu(controller: controller)), + SizedBox(width: XfjDesignTokens.cardGap.w), + const _SettingContent().expanded(), + ].toRow(crossAxisAlignment: CrossAxisAlignment.stretch); } } +/// 设置页左侧常驻菜单和设备绑定状态区。 class _SettingMenu extends StatelessWidget { + /// 创建读取同一设置控制器的菜单区域。 const _SettingMenu({required this.controller}); + /// 终端工作区 Binding 注册的设置控制器,用于菜单可见性和设备状态。 final SettingController controller; + /// 构建路由菜单、设备绑定提示和可选的 Kiosk 退出入口。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); @@ -55,8 +60,9 @@ class _SettingMenu extends StatelessWidget { .map( (type) => _SettingMenuButton( type: type, - selected: controller.cuurentMenuType.value == type, - onTap: () => controller.doMenu(type), + selected: + SettingNavigation.currentMenuType.value == type, + onTap: () => SettingNavigation.openMenu(type), ).marginOnly(bottom: 8.h), ) .toList(), @@ -127,17 +133,25 @@ class _SettingMenu extends StatelessWidget { } } +/// 设置菜单中的单个路由入口。 class _SettingMenuButton extends StatelessWidget { + /// 创建带选中态和点击回调的菜单按钮。 const _SettingMenuButton({ required this.type, required this.selected, required this.onTap, }); + /// 菜单稳定类型,用于展示标题、图标和映射路由。 final SettingMenuType type; + + /// 当前实际子路由是否归属于本菜单。 final bool selected; + + /// 点击后切换设置二级路由的回调。 final VoidCallback onTap; + /// 根据路由选中态构建图标、标题和确认标识。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); @@ -177,11 +191,12 @@ class _SettingMenuButton extends StatelessWidget { } } +/// 设置页右侧常驻内容外壳,页头跟随路由变化,正文由二级 Navigator 生成。 class _SettingContent extends StatelessWidget { - const _SettingContent({required this.controller}); - - final SettingController controller; + /// 创建不持有具体菜单正文的路由内容区。 + const _SettingContent(); + /// 构建路由驱动的页头与设置模块独立导航栈。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); @@ -196,7 +211,7 @@ class _SettingContent extends StatelessWidget { border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), ), child: Obx(() { - final type = controller.cuurentMenuType.value; + final type = SettingNavigation.currentMenuType.value; return Row( children: [ Expanded( @@ -233,49 +248,31 @@ class _SettingContent extends StatelessWidget { Expanded( child: Padding( padding: EdgeInsets.all(20.w), - child: Obx(() { - if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } - final type = controller.cuurentMenuType.value; - if (!controller.isBound.value && - type != SettingMenuType.baseInfo && - type != SettingMenuType.pageSetting && - type != SettingMenuType.backup && - type != SettingMenuType.logPreview && - type != SettingMenuType.subScreenRoutes && - type != SettingMenuType.cardReaderSetting) { - return const SettingEmpty( - message: '设备尚未绑定,请先在“基本信息”中完成绑定', - ); - } - return _buildDetail(context, type); - }), + child: Navigator( + key: Get.nestedKey(SettingNavigation.navigatorId), + initialRoute: RouteNames.settingBaseInfo, + // 直接创建目标页,避免完整路径自动生成无意义的中间路由。 + onGenerateInitialRoutes: (_, initialRoute) => [ + SettingRouteFactory.generate( + RouteSettings(name: initialRoute), + ), + ], + observers: [ + _SettingRouteObserver( + SettingNavigation.handleRouteChanged, + ), + ], + onGenerateRoute: SettingRouteFactory.generate, + ), ), ), ], ), ); } - - Widget _buildDetail(BuildContext context, SettingMenuType type) { - return switch (type) { - SettingMenuType.baseInfo => settingBaseInfo(context), - SettingMenuType.backup => settingBackup(context), - SettingMenuType.syncSetting => settingSync(context), - SettingMenuType.paySetting => settingPage(context), - SettingMenuType.faceSetting => settingFace(context), - SettingMenuType.facePluginSetting => settingFacePlugin(context), - SettingMenuType.pageSetting => settingPage(context), - SettingMenuType.productSetting => settingProduct(context), - SettingMenuType.logPreview => const SettingLogPreview(), - SettingMenuType.subScreenRoutes => const SettingSubScreenRoutes(), - SettingMenuType.versionSetting => settingVersionSetting(context), - SettingMenuType.cardReaderSetting => const SettingCardReader(), - }; - } } +/// 将设置菜单类型映射为当前 Forui 图标资产。 SvgAsset _menuIcon(SettingMenuType type) { return switch (type) { SettingMenuType.baseInfo => FAssets.icons.settings, @@ -293,11 +290,12 @@ SvgAsset _menuIcon(SettingMenuType type) { }; } +/// 将设置菜单类型转换为常驻页头的功能摘要。 String _menuDescription(SettingMenuType type) { return switch (type) { SettingMenuType.baseInfo => '查看终端身份、绑定状态和字体显示设置', SettingMenuType.backup => '查看备份路径并导出本地数据库和日志', - SettingMenuType.syncSetting => '刷新硬件状态、设备配置、长连接、商品和离线订单', + SettingMenuType.syncSetting => '配置自动规则、空闲执行,并立即运行终端同步和日志任务', SettingMenuType.paySetting => '支付配置已迁入页面设置的收银页面', SettingMenuType.faceSetting => '查询、同步和维护本地人脸样本', SettingMenuType.facePluginSetting => '查看插件授权、引擎状态和内存人脸库注册进度', @@ -309,3 +307,30 @@ String _menuDescription(SettingMenuType type) { SettingMenuType.cardReaderSetting => '配置 DK25-ST 读取策略、M1 数据块和 KEY A/B 密钥', }; } + +/// 监听设置模块二级 Navigator 的真实路由变化。 +class _SettingRouteObserver extends NavigatorObserver { + /// 创建向菜单高亮状态回传路由名称的观察器。 + _SettingRouteObserver(this.onChanged); + + /// 路由推入、替换或返回后接收当前路由名称的回调。 + final ValueChanged onChanged; + + /// 路由推入后同步当前设置菜单。 + @override + void didPush(Route route, Route? previousRoute) { + onChanged(route.settings.name); + } + + /// 路由替换后同步当前设置菜单。 + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + onChanged(newRoute?.settings.name); + } + + /// 从详情页返回后按上一个路由恢复设置菜单高亮。 + @override + void didPop(Route route, Route? previousRoute) { + onChanged(previousRoute?.settings.name); + } +} diff --git a/lib/pages/terminal/setting/widgets/setting_cron_rule_dialog.dart b/lib/pages/terminal/setting/widgets/setting_cron_rule_dialog.dart new file mode 100644 index 0000000..6c0bb0e --- /dev/null +++ b/lib/pages/terminal/setting/widgets/setting_cron_rule_dialog.dart @@ -0,0 +1,312 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 打开 Cron 规则选择弹窗,确认时返回校验后的六段表达式,取消时返回空。 +Future showScheduledCronRuleDialog( + BuildContext context, { + required String currentExpression, +}) => + showAdaptiveDialog( + context: context, + barrierDismissible: false, + builder: (_) => _ScheduledCronRuleDialog( + currentExpression: currentExpression, + ), + ); + +/// 支持预设筛选和自定义输入的 Cron 规则弹窗。 +class _ScheduledCronRuleDialog extends StatefulWidget { + /// 使用任务当前表达式创建弹窗,便于定位已选预设并支持原值编辑。 + const _ScheduledCronRuleDialog({required this.currentExpression}); + + /// 当前任务已经保存的六段 Cron 表达式。 + final String currentExpression; + + /// 创建持有输入控制器、预设分组和校验错误的弹窗状态。 + @override + State<_ScheduledCronRuleDialog> createState() => + _ScheduledCronRuleDialogState(); +} + +/// Cron 规则弹窗的本地编辑状态,不直接写数据库或修改调度器。 +class _ScheduledCronRuleDialogState extends State<_ScheduledCronRuleDialog> { + /// 自定义表达式输入控制器,初始值来自任务当前配置。 + late final TextEditingController _controller; + + /// 当前展示的预设分组,默认显示按间隔规则。 + ScheduledCronPresetGroup _group = ScheduledCronPresetGroup.interval; + + /// 最近一次本地 Cron 校验错误;输入变化后立即清空。 + String? _error; + + /// 初始化输入值,并在当前表达式命中预设时自动切换到对应分组。 + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.currentExpression); + for (final preset in SyncCheckTool.cronPresets) { + if (preset.expression == widget.currentExpression) { + _group = preset.group; + break; + } + } + } + + /// 释放仅属于本弹窗的文本控制器。 + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// 构建分组预设、自定义输入及确认操作,所有状态只在弹窗局部刷新。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final dialogWidth = MediaQuery.sizeOf(context).width * 0.6; + final presets = SyncCheckTool.cronPresets + .where((item) => item.group == _group) + .toList(growable: false); + return FDialog( + style: theme.dialogStyle.copyWith( + insetPadding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h), + minWidth: dialogWidth, + maxWidth: dialogWidth, + ), + direction: Axis.vertical, + title: [ + FIcon(FAssets.icons.clock, color: XfjDesignTokens.primary, size: 26.sp), + Text( + '选择定时规则', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + Text( + '共 ${SyncCheckTool.cronPresets.length} 条常用预设', + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ).expanded(), + ].toRowSpace(space: 12.w), + body: [ + Text( + 'Cron 表达式', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + FTextField( + controller: _controller, + hint: '秒 分 时 日 月 周,例如 */30 * * * * *', + maxLines: 1, + textAlign: TextAlign.center, + textAlignVertical: TextAlignVertical.center, + onChange: (_) { + if (_error != null) setState(() => _error = null); + }, + onSubmit: (_) => _save(), + ).constrained(minHeight: 58.h), + if (_error != null) + Text( + _error!, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.danger, + fontWeight: FontWeight.w600, + ), + ), + [ + for (final group in ScheduledCronPresetGroup.values) + _GroupButton( + label: _groupLabel(group), + selected: _group == group, + onTap: () => setState(() => _group = group), + ).expanded(), + ].toRowSpace(space: 8.w), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 1000.w ? 5 : 4; + final gap = 10.w; + final width = + (constraints.maxWidth - gap * (columns - 1)) / columns; + return Wrap( + spacing: gap, + runSpacing: 10.h, + children: [ + for (final preset in presets) + _PresetButton( + preset: preset, + selected: _controller.text.trim() == preset.expression, + onTap: () { + _controller.text = preset.expression; + setState(() => _error = null); + }, + ).width(width), + ], + ); + }, + ).scrollable().expanded(), + const Text('使用本地时区;不支持 Quartz 专属的 L、W、# 语法。') + .padding(all: 10.w) + .decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.stretch, + space: 12.h, + ) + .constrained(height: 620.h), + actions: [ + [ + FButton( + style: FButtonStyle.outline, + onPress: () => Navigator.of(context).pop(), + label: const Text('取消'), + ).expanded(), + FButton( + onPress: _save, + prefix: FIcon(FAssets.icons.save, size: 18.sp), + label: const Text('应用规则'), + ).expanded(), + ].toRowSpace(space: 12.w), + ], + ); + } + + /// 再次校验输入并关闭弹窗;非法表达式保留弹窗并展示错误。 + void _save() { + final expression = _controller.text.trim(); + if (!SyncCheckTool.isValidCron(expression)) { + setState(() => _error = '请输入有效的 6 段 Cron 表达式'); + return; + } + Navigator.of(context).pop(expression); + } +} + +/// Cron 预设分组的单选按钮。 +class _GroupButton extends StatelessWidget { + /// 创建一个由父状态控制选中态的预设分组按钮。 + const _GroupButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + /// 分组的中文展示名称。 + final String label; + + /// 当前分组是否被选中。 + final bool selected; + + /// 点击后由父弹窗切换分组的回调。 + final VoidCallback onTap; + + /// 根据选中态构建固定高度按钮,避免切换分组时布局抖动。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Text( + label, + textAlign: TextAlign.center, + style: theme.typography.base.copyWith( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ).center().constrained(minHeight: 48.h).decorated( + color: selected + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ); + } +} + +/// 单条常用 Cron 预设按钮,同时展示中文含义和实际表达式。 +class _PresetButton extends StatelessWidget { + /// 创建一个由父弹窗控制选中态和应用行为的预设按钮。 + const _PresetButton({ + required this.preset, + required this.selected, + required this.onTap, + }); + + /// 当前按钮代表的预设分组、名称和六段表达式。 + final ScheduledCronPreset preset; + + /// 输入框内容是否与该预设表达式完全一致。 + final bool selected; + + /// 点击后把该预设写入父弹窗输入框的回调。 + final VoidCallback onTap; + + /// 构建固定高度预设项,长名称和表达式均以单行省略保护布局。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + Text( + preset.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 3.h), + Text( + preset.expression, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.center) + .center() + .padding(horizontal: 10.w, vertical: 8.h) + .constrained(minHeight: 70.h) + .decorated( + color: selected + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ); + } +} + +/// 将预设分组枚举转换为弹窗分段按钮使用的中文名称。 +String _groupLabel(ScheduledCronPresetGroup group) => switch (group) { + ScheduledCronPresetGroup.interval => '按间隔', + ScheduledCronPresetGroup.daily => '每天', + ScheduledCronPresetGroup.weekly => '每周', + ScheduledCronPresetGroup.monthly => '每月', + }; diff --git a/lib/pages/terminal/setting/widgets/setting_sync.dart b/lib/pages/terminal/setting/widgets/setting_sync.dart index c6ff802..028a18f 100644 --- a/lib/pages/terminal/setting/widgets/setting_sync.dart +++ b/lib/pages/terminal/setting/widgets/setting_sync.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:common_utils/common_utils.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; @@ -7,251 +9,505 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import '../controller.dart'; +import '../routes/navigation.dart'; import 'setting_components.dart'; +import 'setting_cron_rule_dialog.dart'; +/// 同步设置只读取统一调度器状态;开关、规则和立即执行均由 SettingController 持久化或触发。 Widget settingSync(BuildContext context) { final controller = Get.find(); + final scheduler = SyncCheckTool.instance; return Obx(() { - final sys = controller.sysSetting.value; - if (sys == null) return const SettingEmpty(message: '暂无同步信息'); - final deviceStatus = AppDeviceStatusTool.status.value; - final deviceProgress = AppDeviceStatusTool.progress.value; - final availableDeviceCount = [ - deviceStatus.printerAvailable, - deviceStatus.steelyardAvailable, - deviceStatus.cardReaderAvailable, - deviceStatus.subScreenAvailable, - ].where((available) => available).length; - final items = <_SyncItem>[ - _SyncItem( - 'deviceStatus', - '同步设备状态', - deviceStatus.updatedAt, - controller.syncDeviceStatus, - icon: FAssets.icons.hardDrive, - statusText: deviceProgress.running - ? deviceProgress.message - : '已连接 $availableDeviceCount/4 个设备', - warning: availableDeviceCount < 4, - forceSyncing: deviceProgress.running, - ), - _SyncItem('config', '同步设备信息', sys.syncTime, controller.syncConfig), - _SyncItem('mq', '同步长链接', sys.mqSyncTime, controller.syncMq), - _SyncItem( - 'classify', - '同步分类', - sys.sysType == SysType.CANTEEN - ? sys.canteenClassifySyncTime - : sys.supermarketClassifySyncTime, - controller.syncClassify, - ), - _SyncItem( - 'product', - '同步商品', - sys.sysType == SysType.CANTEEN - ? sys.canteenProductSyncTime - : sys.supermarketProductSyncTime, - controller.syncProduct, - ), - _SyncItem( - 'offlineOrder', - '同步离线订单', - sys.offlineOrderSyncTime, - controller.syncOfflineOrder, - icon: FAssets.icons.cloudDownload, - ), + final configs = [ + for (final type in ScheduledTaskType.values) + if (scheduler.configs[type] != null) scheduler.configs[type]!, ]; - return Column( - children: [ - Expanded( - child: Container( - key: const ValueKey('setting-sync-list'), - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: XfjDesignTokens.surface, - border: Border.all(color: XfjDesignTokens.line), - borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + if (configs.isEmpty) return const SettingEmpty(message: '正在加载同步任务'); + return [ + SettingGroup( + title: '自动任务', + child: [ + for (var index = 0; index < configs.length; index++) + _ScheduledTaskRow( + config: configs[index], + record: scheduler.records[configs[index].type], + running: scheduler.runningTasks.contains(configs[index].type), + pending: scheduler.pendingTasks.contains(configs[index].type), + saving: + controller.savingScheduledTasks.contains(configs[index].type), + showDivider: index != configs.length - 1, + controller: controller, ), - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final itemSyncing = controller.syncingKey.value == item.key || - item.forceSyncing; - return _SyncRow( - item: item, - syncing: itemSyncing, - failed: controller.syncFailedKeys.contains(item.key), - enabled: controller.syncingKey.isEmpty || - (item.key == 'deviceStatus' && - (controller.syncingKey.value == 'deviceStatus' || - item.forceSyncing)), - showDivider: index != items.length - 1, - ); - }, - ), - ), - ), - SizedBox(height: XfjDesignTokens.cardGap.h), - const SettingNotice( - text: '网络接口可用不等于业务服务可用。同步失败时会保留本地数据,可在网络恢复后重新执行。', - ), - ], - ); + ].toColumn(), + ), + _terminalLogSummary(context, scheduler.terminalLogSummary.value), + const SettingNotice( + text: + '定时任务仅在应用进程存活时执行。自动空闲任务要求前台连续 60 秒无操作;手动执行和后台拉取只等待支付、人脸、遮罩和业务弹框结束。', + ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.stretch, + space: XfjDesignTokens.cardGap.h, + ) + .scrollable(); }); } -class _SyncItem { - const _SyncItem( - this.key, - this.title, - this.time, - this.action, { - this.icon, - this.statusText, - this.warning = false, - this.forceSyncing = false, - }); - - final String key; - final String title; - final DateTime? time; - final Future Function() action; - final SvgAsset? icon; - final String? statusText; - final bool warning; - final bool forceSyncing; -} - -class _SyncRow extends StatelessWidget { - const _SyncRow({ - required this.item, - required this.syncing, - required this.failed, - required this.enabled, +/// 单个定时任务的紧凑配置行,任务标题区域同时作为执行日志入口。 +class _ScheduledTaskRow extends StatelessWidget { + /// 创建由调度器快照和设置控制器共同驱动的任务行。 + const _ScheduledTaskRow({ + required this.config, + required this.record, + required this.running, + required this.pending, + required this.saving, required this.showDivider, + required this.controller, }); - final _SyncItem item; - final bool syncing; - final bool failed; - final bool enabled; + /// 当前任务的自动开关、Cron 和空闲策略配置。 + final ScheduledTaskConfig config; + + /// 当前任务最近一次执行结果;从未执行或尚未加载时为空。 + final ScheduledTaskRecord? record; + + /// 当前任务是否正在实际执行业务动作。 + final bool running; + + /// 当前任务是否正在等待安全窗口或等待同类任务结束。 + final bool pending; + + /// 当前任务配置是否正在写入 SQLite,用于暂时禁用编辑控件。 + final bool saving; + + /// 是否在本行底部展示与下一任务之间的分隔线。 final bool showDivider; + /// 当前设置页 Binding 注册的控制器,负责保存配置和立即执行。 + final SettingController controller; + + /// 构建日志入口、Cron 规则、空闲开关、自动开关和立即执行操作。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); - final statusColor = failed - ? XfjDesignTokens.danger - : syncing - ? XfjDesignTokens.primary - : item.warning - ? XfjDesignTokens.warning - : XfjDesignTokens.success; - return Opacity( - opacity: enabled || syncing ? 1 : 0.55, + final metadata = _taskMetadata(config.type); + final status = _taskStatus(record, running: running, pending: pending); + final rowTextStyle = theme.typography.sm; + // 任务详情、规则、开关和立即执行使用独立热区,避免点击日志时误切换任务配置。 + final taskSummary = Tooltip( + message: '查看${metadata.title}执行日志', child: InkWell( - onTap: enabled ? item.action : null, - child: Container( - constraints: BoxConstraints(minHeight: 76.h), - padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 10.h), - decoration: BoxDecoration( - border: showDivider - ? const Border( - bottom: BorderSide(color: XfjDesignTokens.line), - ) - : null, - ), - child: Row( - children: [ - Container( - width: 44.w, - height: 44.w, - alignment: Alignment.center, - decoration: BoxDecoration( - color: XfjDesignTokens.primarySoft, - borderRadius: BorderRadius.circular( - XfjDesignTokens.controlRadius, - ), - ), - child: syncing - ? SizedBox( - width: 22.w, - height: 22.w, - child: const CircularProgressIndicator(strokeWidth: 2), - ) - : FIcon( - item.icon ?? FAssets.icons.refreshCw, - color: XfjDesignTokens.primary, - size: 24.sp, - ), - ), - SizedBox(width: 14.w), - Expanded( - child: Row( - children: [ - Expanded( - child: Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.typography.base.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w700, - ), - ), - ), - SizedBox(width: 20.w), - SizedBox( - width: 360.w, - child: Text( - '最后同步:${_formatTime(item.time)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: theme.typography.sm.copyWith( - color: XfjDesignTokens.muted, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ), - SizedBox(width: 20.w), - SizedBox( - width: 320.w, - child: Text( - failed - ? '同步失败,点击重试' - : syncing - ? item.statusText ?? '同步中...' - : item.statusText ?? '状态正常', - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: theme.typography.sm.copyWith( - color: statusColor, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - SizedBox(width: 14.w), - FIcon( - FAssets.icons.chevronRight, - color: XfjDesignTokens.muted, - size: 22.sp, - ), - ], - ), + onTap: () => unawaited( + SettingNavigation.openTaskLogs(config.type), ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + _taskIcon(metadata.icon, running: running), + [ + Text( + metadata.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: rowTextStyle.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4.h), + Text( + '${status.text} · ${_formatRecordTime(record?.lastRunAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: rowTextStyle.copyWith(color: status.color), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + ].toRowSpace(space: 10.w).padding(vertical: 4.h), + ), + ); + final taskActions = [ + SizedBox(width: 8.w), + _TaskLogButton( + taskTitle: metadata.title, + onTap: () => unawaited(SettingNavigation.openTaskLogs(config.type)), + ), + SizedBox(width: 10.w), + _CronRuleButton( + expression: config.cron, + enabled: !saving, + onTap: () => _selectCron(context), + ), + SizedBox(width: 10.w), + _TaskSwitch( + label: config.idleOnly ? '仅空闲' : '直接执行', + semanticLabel: '${metadata.title}仅空闲执行', + cellWidth: 144.w, + value: config.idleOnly, + enabled: !saving, + onChange: (value) => + controller.setScheduledTaskIdleOnly(config.type, value), + ), + SizedBox(width: 10.w), + _TaskSwitch( + label: '自动', + semanticLabel: '${metadata.title}自动执行', + cellWidth: 112.w, + value: config.enabled, + enabled: !saving, + onChange: (value) => + controller.setScheduledTaskEnabled(config.type, value), + ), + const Spacer(), + SizedBox(width: 10.w), + FButton( + style: FButtonStyle.outline, + onPress: saving + ? null + : () => unawaited( + controller.runScheduledTaskNow(config.type), + ), + prefix: running + ? SizedBox.square( + dimension: 18.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : FIcon(FAssets.icons.refreshCw, size: 18.sp), + label: Text( + running + ? '执行中' + : pending + ? '再执行一次' + : '立即执行', + style: rowTextStyle, + ), + ).constrained(minWidth: 138.w, minHeight: 48.h), + ].toRow(); + return [ + taskSummary.expanded(flex: 5), + taskActions.expanded(flex: 5), + ] + .toRow() + .padding(horizontal: 18.w, vertical: 10.h) + .constrained(minHeight: 72.h) + .decorated( + border: showDivider + ? const Border( + bottom: BorderSide(color: XfjDesignTokens.line), + ) + : null, + ); + } + + /// 打开规则弹窗,并在用户确认了不同表达式时交由 Controller 二次校验和保存。 + Future _selectCron(BuildContext context) async { + final expression = await showScheduledCronRuleDialog( + context, + currentExpression: config.cron, + ); + if (expression == null || expression == config.cron) return; + await controller.setScheduledTaskCron(config.type, expression); + } +} + +/// 任务行中独立的结构化日志入口,图标热区不受任务摘要宽度影响。 +class _TaskLogButton extends StatelessWidget { + /// 创建固定 48dp 热区的日志页面入口。 + const _TaskLogButton({required this.taskTitle, required this.onTap}); + + /// 当前任务中文名称,用于 Tooltip 和语义说明。 + final String taskTitle; + + /// 点击后打开对应任务日志路由的回调。 + final VoidCallback onTap; + + /// 使用卷轴文本图标构建更明确的日志操作入口。 + @override + Widget build(BuildContext context) => Tooltip( + message: '查看$taskTitle执行日志', + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: FIcon( + FAssets.icons.scrollText, + color: XfjDesignTokens.primary, + size: 24.sp, + ).center().constrained(width: 48.w, height: 48.h).decorated( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ), + ); +} + +/// 在任务行中垂直、水平居中展示当前规则摘要的 Cron 编辑按钮。 +class _CronRuleButton extends StatelessWidget { + /// 创建一个固定尺寸的 Cron 规则按钮。 + const _CronRuleButton({ + required this.expression, + required this.enabled, + required this.onTap, + }); + + /// 当前任务已保存的六段 Cron 表达式。 + final String expression; + + /// 是否允许打开规则弹窗;配置保存期间为假。 + final bool enabled; + + /// 打开 Cron 规则弹窗的回调。 + final VoidCallback onTap; + + /// 构建 48dp 点击热区内的紧凑 Cron 标签,保持任务行列稳定。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Tooltip( + message: '设置 Cron 定时规则', + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + FIcon( + FAssets.icons.clock, + color: enabled ? XfjDesignTokens.primary : XfjDesignTokens.muted, + size: 16.sp, + ), + Text( + _cronLabel(expression), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: enabled ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).expanded(), + ] + .toRowSpace(space: 6.w) + .padding(horizontal: 9.w) + .constrained(width: 158.w, height: 36.h) + .decorated( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: enabled ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ) + .center() + .constrained(width: 164.w, height: 48.h), ), ); } } -String _formatTime(DateTime? time) { - if (time == null) return '未同步'; - return DateUtil.formatDate(time, format: DateFormats.full); +/// 任务行中复用的带短标签二元开关。 +class _TaskSwitch extends StatelessWidget { + /// 创建一个由父任务行管理值与保存行为的开关。 + const _TaskSwitch({ + required this.label, + required this.semanticLabel, + required this.cellWidth, + required this.value, + required this.enabled, + required this.onChange, + }); + + /// 开关左侧的短展示名称。 + final String label; + + /// 读屏使用的完整任务操作说明。 + final String semanticLabel; + + /// 本列固定宽度,分别为执行策略和自动开关保留不换行空间。 + final double cellWidth; + + /// 当前开关值。 + final bool value; + + /// 配置保存期间是否允许切换。 + final bool enabled; + + /// 值变化后交由设置控制器持久化的回调。 + final ValueChanged onChange; + + /// 构建固定尺寸标签与 Forui 开关,保证各任务行列对齐。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + // 当前开关没有使用 Forui 的内置标签,移除其默认水平留白,避免固定表格列产生溢出; + // 保留垂直留白,使开关本身仍具有 48dp 的稳定触控高度。 + final switchStyle = theme.switchStyle.copyWith( + labelLayoutStyle: theme.switchStyle.labelLayoutStyle.copyWith( + childPadding: EdgeInsets.symmetric(vertical: 4.5.h), + ), + ); + return [ + Text( + label, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: value ? XfjDesignTokens.ink : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ).expanded(), + SizedBox(width: 6.w), + FSwitch( + style: switchStyle, + semanticLabel: semanticLabel, + value: value, + enabled: enabled, + onChange: onChange, + ), + ].toRow().constrained(width: cellWidth, minHeight: 48.h); + } } + +/// 构建任务行固定尺寸图标;运行中用加载指示器替代业务图标。 +Widget _taskIcon(SvgAsset icon, {required bool running}) => Container( + width: 44.w, + height: 44.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: running + ? SizedBox.square( + dimension: 22.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : FIcon(icon, color: XfjDesignTokens.primary, size: 23.sp), + ); + +/// 构建最近一次日志上传的文件统计和成功时间概况。 +Widget _terminalLogSummary( + BuildContext context, + TerminalLogUploadSummary summary, +) { + final theme = FTheme.of(context); + final lastSuccess = summary.lastSuccessAt == null + ? '暂无成功记录' + : DateUtil.formatDate( + summary.lastSuccessAt!, + format: DateFormats.full, + ); + return SettingGroup( + title: '日志上传概况', + child: [ + _summaryValue(theme, '扫描', summary.scanned, XfjDesignTokens.ink), + _summaryValue(theme, '跳过', summary.skipped, XfjDesignTokens.muted), + _summaryValue(theme, '成功', summary.succeeded, XfjDesignTokens.success), + _summaryValue(theme, '失败', summary.failed, XfjDesignTokens.danger), + Text( + '最近成功:$lastSuccess', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).expanded(), + ] + .toRowSpace(space: 22.w) + .padding(horizontal: 20.w, vertical: 14.h) + .constrained(minHeight: 64.h), + ); +} + +/// 构建单个日志上传统计值,颜色由成功、失败等业务语义传入。 +Widget _summaryValue( + FThemeData theme, + String label, + int value, + Color color, +) => + Text( + '$label $value', + style: theme.typography.base.copyWith( + color: color, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + +/// 将任务类型转换为设置页使用的中文名称和图标。 +({String title, SvgAsset icon}) _taskMetadata(ScheduledTaskType type) => + switch (type) { + ScheduledTaskType.mqReconnect => ( + title: 'MQTT 重连', + icon: FAssets.icons.wifi + ), + ScheduledTaskType.printCheck => ( + title: '打印任务检查', + icon: FAssets.icons.printer + ), + ScheduledTaskType.broadcastCheck => ( + title: '取餐广播检查', + icon: FAssets.icons.receiptText + ), + ScheduledTaskType.deviceStatus => ( + title: '设备状态检查', + icon: FAssets.icons.hardDrive + ), + ScheduledTaskType.configSync => ( + title: '设备配置同步', + icon: FAssets.icons.settings + ), + ScheduledTaskType.classifySync => ( + title: '分类同步', + icon: FAssets.icons.packageOpen + ), + ScheduledTaskType.productSync => ( + title: '商品同步', + icon: FAssets.icons.package + ), + ScheduledTaskType.offlineOrderSync => ( + title: '离线订单同步', + icon: FAssets.icons.cloudDownload + ), + ScheduledTaskType.terminalLogUpload => ( + title: '终端日志上传', + icon: FAssets.icons.logs + ), + ScheduledTaskType.expiredLogCleanup => ( + title: '过期日志清理', + icon: FAssets.icons.trash2 + ), + }; + +/// 根据运行、等待和最近结果生成任务行状态文案与语义颜色。 +({String text, Color color}) _taskStatus( + ScheduledTaskRecord? record, { + required bool running, + required bool pending, +}) { + if (running) return (text: '正在执行', color: XfjDesignTokens.primary); + if (pending) return (text: '等待安全窗口', color: XfjDesignTokens.warning); + if (record == null) return (text: '尚未执行', color: XfjDesignTokens.muted); + return record.success + ? (text: record.message, color: XfjDesignTokens.success) + : (text: record.message, color: XfjDesignTokens.danger); +} + +/// 优先返回常用预设名称,自定义表达式无法匹配预设时展示原值。 +String _cronLabel(String cron) { + for (final preset in SyncCheckTool.cronPresets) { + if (preset.expression == cron) return preset.label; + } + return cron; +} + +/// 将最近执行时间格式化为任务行摘要,从未执行时返回明确空状态。 +String _formatRecordTime(DateTime? time) => time == null + ? '最近执行:暂无' + : '最近执行:${DateUtil.formatDate(time, format: DateFormats.full)}'; diff --git a/lib/pages/terminal/setting/widgets/setting_sync_log_dialog.dart b/lib/pages/terminal/setting/widgets/setting_sync_log_dialog.dart new file mode 100644 index 0000000..173753c --- /dev/null +++ b/lib/pages/terminal/setting/widgets/setting_sync_log_dialog.dart @@ -0,0 +1,432 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 打开指定任务的结构化执行日志弹窗,关闭时不返回业务数据。 +Future showScheduledTaskLogDialog( + BuildContext context, { + required ScheduledTaskType type, +}) => + showAdaptiveDialog( + context: context, + builder: (_) => _ScheduledTaskLogDialog(type: type), + ); + +/// 按任务类型展示并筛选最近执行日志的弹窗。 +class _ScheduledTaskLogDialog extends StatefulWidget { + /// 使用稳定任务类型创建日志弹窗。 + const _ScheduledTaskLogDialog({required this.type}); + + /// 本弹窗固定查询的任务类型。 + final ScheduledTaskType type; + + /// 创建管理日志列表、依赖筛选和加载结果的弹窗状态。 + @override + State<_ScheduledTaskLogDialog> createState() => + _ScheduledTaskLogDialogState(); +} + +/// 任务日志弹窗的查询与筛选状态。 +class _ScheduledTaskLogDialogState extends State<_ScheduledTaskLogDialog> { + /// 当前筛选条件下最近的结构化执行日志,默认尚无结果。 + List _logs = const []; + + /// 当前依赖类型筛选;为空表示查询全部来源。 + ScheduledTaskSourceType? _sourceType; + + /// 是否正在读取 SQLite,用于禁用刷新并展示加载状态。 + bool _loading = true; + + /// 最近一次日志读取失败摘要;查询成功前为空。 + String? _error; + + /// 弹窗创建后立即加载当前任务最近日志。 + @override + void initState() { + super.initState(); + _load(); + } + + /// 按当前任务和依赖筛选读取日志,并仅在弹窗仍挂载时更新局部状态。 + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final logs = await SyncCheckTool.instance.loadLogs( + widget.type, + sourceType: _sourceType, + ); + if (mounted) setState(() => _logs = logs); + } catch (error) { + if (mounted) setState(() => _error = error.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// 构建依赖筛选、刷新入口以及日志加载、空数据、失败和列表状态。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return FDialog( + direction: Axis.vertical, + title: [ + FIcon(FAssets.icons.logs, color: XfjDesignTokens.primary, size: 26.sp), + Text( + '${SyncCheckTool.taskTitle(widget.type)}日志', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + Text( + SyncCheckTool.taskSourceName(widget.type), + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ).expanded(), + ].toRowSpace(space: 12.w), + body: [ + [ + _SourceFilter( + label: '全部', + selected: _sourceType == null, + onTap: () { + _sourceType = null; + _load(); + }, + ), + for (final type in ScheduledTaskSourceType.values) + _SourceFilter( + label: _sourceLabel(type), + selected: _sourceType == type, + onTap: () { + _sourceType = type; + _load(); + }, + ), + const Spacer(), + FButton( + style: FButtonStyle.outline, + onPress: _loading ? null : _load, + prefix: _loading + ? SizedBox.square( + dimension: 18.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : FIcon(FAssets.icons.refreshCw, size: 18.sp), + label: const Text('刷新'), + ).constrained(minWidth: 110.w, minHeight: 44.h), + ].toRowSpace(space: 8.w), + if (_error != null) + _LogEmpty( + icon: FAssets.icons.circleAlert, + message: '日志读取失败:$_error', + ).expanded() + else if (!_loading && _logs.isEmpty) + _LogEmpty( + icon: FAssets.icons.logs, + message: '暂无符合条件的任务执行日志', + ).expanded() + else + ListView.separated( + itemCount: _logs.length, + separatorBuilder: (_, __) => SizedBox(height: 8.h), + itemBuilder: (_, index) => _TaskLogRow(log: _logs[index]), + ).expanded(), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.stretch, + space: 12.h, + ) + .constrained(width: 1120.w, height: 650.h), + actions: [ + FButton( + onPress: () => Navigator.of(context).pop(), + label: const Text('关闭'), + ).width(180.w), + ], + ); + } +} + +/// 单条任务日志行,存在错误详情时允许展开诊断内容。 +class _TaskLogRow extends StatefulWidget { + /// 使用一条不可变结构化日志创建列表行。 + const _TaskLogRow({required this.log}); + + /// 当前行展示的任务执行日志。 + final ScheduledTaskLogEntity log; + + /// 创建仅管理错误详情展开状态的列表行状态。 + @override + State<_TaskLogRow> createState() => _TaskLogRowState(); +} + +/// 单条任务日志的错误详情展开状态。 +class _TaskLogRowState extends State<_TaskLogRow> { + /// 是否展开异常类型、摘要和堆栈;默认保持紧凑列表。 + bool _expanded = false; + + /// 构建状态、来源、触发方式、耗时、统计及可选错误详情。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final visual = _statusVisual(widget.log.status); + final hasDetail = widget.log.errorMessage?.isNotEmpty == true || + widget.log.errorStack?.isNotEmpty == true; + return InkWell( + onTap: hasDetail ? () => setState(() => _expanded = !_expanded) : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + [ + FIcon(visual.icon, color: visual.color, size: 22.sp), + [ + [ + _Badge(label: _sourceLabelName(widget.log.sourceType)), + _Badge(label: _triggerLabel(widget.log.triggerType)), + Text( + DateUtil.formatDate( + widget.log.startedAt, + format: DateFormats.full, + ), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + '${widget.log.durationMs} ms', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ].toRowSpace(space: 8.w), + SizedBox(height: 6.h), + Text( + widget.log.message, + maxLines: _expanded ? null : 2, + overflow: _expanded ? null : TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + if (_logMetrics(widget.log) != null) ...[ + SizedBox(height: 5.h), + Text( + _logMetrics(widget.log)!, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + Text( + visual.label, + style: theme.typography.sm.copyWith( + color: visual.color, + fontWeight: FontWeight.w800, + ), + ), + if (hasDetail) + FIcon( + _expanded ? FAssets.icons.chevronUp : FAssets.icons.chevronDown, + color: XfjDesignTokens.muted, + size: 18.sp, + ), + ].toRowSpace(crossAxisAlignment: CrossAxisAlignment.start, space: 12.w), + if (_expanded && hasDetail) ...[ + SizedBox(height: 10.h), + SelectableText( + [ + if (widget.log.errorType != null) widget.log.errorType, + if (widget.log.errorMessage != null) widget.log.errorMessage, + if (widget.log.errorStack != null) widget.log.errorStack, + ].whereType().join('\n'), + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFamily: 'monospace', + height: 1.45, + ), + ).padding(all: 10.w).decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ], + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + .padding(horizontal: 14.w, vertical: 12.h) + .decorated( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ); + } +} + +/// MQTT、HTTP、插件、文件和本地来源的单选筛选按钮。 +class _SourceFilter extends StatelessWidget { + /// 创建一个由父弹窗控制选中态的来源筛选项。 + const _SourceFilter({ + required this.label, + required this.selected, + required this.onTap, + }); + + /// 筛选项的中文展示名称。 + final String label; + + /// 当前筛选项是否生效。 + final bool selected; + + /// 点击后更新父弹窗筛选并重新查询的回调。 + final VoidCallback onTap; + + /// 根据选中态构建紧凑的来源筛选按钮。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Text( + label, + style: theme.typography.sm.copyWith( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ) + .center() + .padding(horizontal: 12.w) + .constrained(minHeight: 42.h) + .decorated( + color: selected + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ); + } +} + +/// 日志行内展示来源或触发方式的紧凑标签。 +class _Badge extends StatelessWidget { + /// 使用指定短文本创建标签。 + const _Badge({required this.label}); + + /// 标签展示文本。 + final String label; + + /// 构建不参与交互的日志元数据标签。 + @override + Widget build(BuildContext context) => Text( + label, + style: FTheme.of(context).typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ).padding(horizontal: 8.w, vertical: 3.h).decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ); +} + +/// 日志读取失败或无数据时共用的居中状态视图。 +class _LogEmpty extends StatelessWidget { + /// 使用状态图标和说明文案创建空状态。 + const _LogEmpty({required this.icon, required this.message}); + + /// 空状态使用的 Forui SVG 图标。 + final SvgAsset icon; + + /// 面向终端人员的状态说明。 + final String message; + + /// 构建固定语义层级的图标与说明文案。 + @override + Widget build(BuildContext context) => [ + FIcon(icon, size: 44.sp, color: XfjDesignTokens.muted), + SizedBox(height: 12.h), + Text( + message, + textAlign: TextAlign.center, + style: FTheme.of(context).typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toColumn(mainAxisSize: MainAxisSize.min).center(); +} + +/// 将任务持久化状态转换为日志行使用的中文名称、颜色和图标。 +({String label, Color color, SvgAsset icon}) _statusVisual(String status) => + switch (status) { + 'success' => ( + label: '成功', + color: XfjDesignTokens.success, + icon: FAssets.icons.circleCheck + ), + 'failed' => ( + label: '失败', + color: XfjDesignTokens.danger, + icon: FAssets.icons.circleAlert + ), + 'skipped' => ( + label: '跳过', + color: XfjDesignTokens.warning, + icon: FAssets.icons.circleMinus + ), + _ => ( + label: '执行中', + color: XfjDesignTokens.primary, + icon: FAssets.icons.refreshCw + ), + }; + +/// 将任务依赖类型转换为筛选按钮使用的短名称。 +String _sourceLabel(ScheduledTaskSourceType type) => switch (type) { + ScheduledTaskSourceType.mqtt => 'MQTT', + ScheduledTaskSourceType.http => 'HTTP', + ScheduledTaskSourceType.plugin => '插件', + ScheduledTaskSourceType.file => '文件', + ScheduledTaskSourceType.local => '本地', + }; + +/// 将数据库中的依赖类型枚举名转换为短名称,未知值保持原样以便诊断兼容问题。 +String _sourceLabelName(String source) { + for (final type in ScheduledTaskSourceType.values) { + if (type.name == source) return _sourceLabel(type); + } + return source; +} + +/// 将数据库中的任务触发来源转换为日志行中文标签。 +String _triggerLabel(String trigger) => switch (trigger) { + 'cron' => '定时', + 'mqtt' => 'MQTT 拉取', + 'system' => '系统', + _ => '手动', + }; + +/// 生成文件任务的扫描统计摘要;非文件任务未记录扫描数时返回空。 +String? _logMetrics(ScheduledTaskLogEntity log) { + if (log.scannedCount == null) return null; + return '扫描 ${log.scannedCount ?? 0} · 跳过 ${log.skippedCount ?? 0} · ' + '成功 ${log.successCount ?? 0} · 失败 ${log.failureCount ?? 0}'; +} diff --git a/pubspec.lock b/pubspec.lock index e4f24b9..0a0c0de 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -254,8 +254,16 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.1.2" + cron: + dependency: "direct main" + description: + name: cron + sha256: "2af3de2cabcc81346c00ff9db06296cdaf37ad17bd6f97fb63777a9ddefcacf3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" @@ -858,6 +866,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.1" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "67eae327b1b0faf761964a1d2e5d323c797f3799db0e85aa232db8d9e922bc35" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.1" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 84bee4c..afa2b00 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,9 @@ dependencies: # http请求 dio: 5.7.0 http: ^1.3.0 + cron: 0.6.2 + crypto: ^3.0.6 + package_info_plus: 8.2.1 # mq mqtt_client: ^10.0.2 # 文字转拼音