feat:设备日志模块
feat:定时任务 feat:设置页面用路由重构
This commit is contained in:
1 parent
1033f403b1
commit
2efa7a3c19
60 files changed
+6560
-643
No files matched your search
@@ -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为版本号(内部比较)
|
||||
```
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
@@ -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<String, Any> {
|
||||
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 {
|
||||
|
||||
+17
-3
@@ -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<MainApp> createState() => _MainAppState();
|
||||
}
|
||||
|
||||
/// 主屏应用生命周期与根视图状态。
|
||||
class _MainAppState extends State<MainApp> 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<FaceRecognitionSessionService>()) {
|
||||
@@ -46,6 +54,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建主题与 GetX 根应用,并在最外层 Listener 记录所有指针活动。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScreenUtilInit(
|
||||
@@ -140,9 +149,14 @@ class _MainAppState extends State<MainApp> 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!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<String, dynamic> json) =>
|
||||
_$ScheduledTaskConfigFromJson(json);
|
||||
|
||||
/// 仅供旧版配置兼容和代码生成使用,不作为 v8 之后的持久化入口。
|
||||
Map<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$ScheduledTaskRecordFromJson(json);
|
||||
|
||||
/// 仅供旧版执行快照兼容和代码生成使用。
|
||||
Map<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$TerminalLogUploadStateFromJson(json);
|
||||
|
||||
/// 仅供旧版文件状态兼容和代码生成使用。
|
||||
Map<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$TerminalLogUploadSummaryFromJson(json);
|
||||
|
||||
/// 仅供旧版上传汇总兼容和代码生成使用。
|
||||
Map<String, dynamic> toJson() => _$TerminalLogUploadSummaryToJson(this);
|
||||
|
||||
/// 生成设置页和任务日志共用的中文统计摘要。
|
||||
String get label => '扫描 $scanned,跳过 $skipped,成功 $succeeded,失败 $failed';
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'scheduled_task_models.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ScheduledTaskConfig _$ScheduledTaskConfigFromJson(Map<String, dynamic> json) =>
|
||||
ScheduledTaskConfig(
|
||||
type: $enumDecode(_$ScheduledTaskTypeEnumMap, json['type']),
|
||||
enabled: json['enabled'] as bool,
|
||||
cron: json['cron'] as String,
|
||||
idleOnly: json['idleOnly'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScheduledTaskConfigToJson(
|
||||
ScheduledTaskConfig instance) =>
|
||||
<String, dynamic>{
|
||||
'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<String, dynamic> 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<String, dynamic> _$ScheduledTaskRecordToJson(
|
||||
ScheduledTaskRecord instance) =>
|
||||
<String, dynamic>{
|
||||
'lastRunAt': instance.lastRunAt?.toIso8601String(),
|
||||
'lastSuccessAt': instance.lastSuccessAt?.toIso8601String(),
|
||||
'success': instance.success,
|
||||
'message': instance.message,
|
||||
};
|
||||
|
||||
TerminalLogUploadState _$TerminalLogUploadStateFromJson(
|
||||
Map<String, dynamic> 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<String, dynamic> _$TerminalLogUploadStateToJson(
|
||||
TerminalLogUploadState instance) =>
|
||||
<String, dynamic>{
|
||||
'relativePath': instance.relativePath,
|
||||
'sourceSize': instance.sourceSize,
|
||||
'sourceModifiedTime': instance.sourceModifiedTime.toIso8601String(),
|
||||
'sanitizedHash': instance.sanitizedHash,
|
||||
'uploadedAt': instance.uploadedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
TerminalLogUploadSummary _$TerminalLogUploadSummaryFromJson(
|
||||
Map<String, dynamic> 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<String, dynamic> _$TerminalLogUploadSummaryToJson(
|
||||
TerminalLogUploadSummary instance) =>
|
||||
<String, dynamic>{
|
||||
'scanned': instance.scanned,
|
||||
'skipped': instance.skipped,
|
||||
'succeeded': instance.succeeded,
|
||||
'failed': instance.failed,
|
||||
'lastSuccessAt': instance.lastSuccessAt?.toIso8601String(),
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String, dynamic> json) =>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<int> save(FileUploadRecordEntity entity);
|
||||
|
||||
/// 按主键更新已经存在的文件上传状态。
|
||||
@update
|
||||
Future<void> edit(FileUploadRecordEntity entity);
|
||||
|
||||
/// 查询指定设备与业务类型的全部文件状态,并按最近更新时间倒序返回。
|
||||
@Query('''
|
||||
SELECT * FROM file_upload_record
|
||||
WHERE hardId = :hardId AND bizType = :bizType
|
||||
ORDER BY updateTime DESC
|
||||
''')
|
||||
Future<List<FileUploadRecordEntity>> getAll(
|
||||
String hardId,
|
||||
String bizType,
|
||||
);
|
||||
|
||||
/// 按设备、业务类型和相对路径查询唯一状态记录;不存在时返回空。
|
||||
@Query('''
|
||||
SELECT * FROM file_upload_record
|
||||
WHERE hardId = :hardId
|
||||
AND bizType = :bizType
|
||||
AND relativePath = :relativePath
|
||||
LIMIT 1
|
||||
''')
|
||||
Future<FileUploadRecordEntity?> getByPath(
|
||||
String hardId,
|
||||
String bizType,
|
||||
String relativePath,
|
||||
);
|
||||
}
|
||||
@@ -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<void> save(ScheduledTaskEntity entity);
|
||||
|
||||
/// 批量补齐默认任务;每条记录均按 taskCode 使用替换冲突策略。
|
||||
@Insert(onConflict: OnConflictStrategy.replace)
|
||||
Future<void> batchSave(List<ScheduledTaskEntity> entities);
|
||||
|
||||
/// 按实体主键更新一条已经存在的任务记录。
|
||||
@update
|
||||
Future<void> edit(ScheduledTaskEntity entity);
|
||||
|
||||
/// 查询全部任务并按稳定 taskCode 升序返回,便于启动时构建确定性配置映射。
|
||||
@Query('SELECT * FROM scheduled_task ORDER BY taskCode')
|
||||
Future<List<ScheduledTaskEntity>> getAll();
|
||||
|
||||
/// 按稳定 taskCode 查询单条任务;不存在时返回空。
|
||||
@Query('SELECT * FROM scheduled_task WHERE taskCode = :taskCode LIMIT 1')
|
||||
Future<ScheduledTaskEntity?> getByCode(String taskCode);
|
||||
}
|
||||
@@ -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<int> save(ScheduledTaskLogEntity entity);
|
||||
|
||||
/// 按日志主键写回结束状态、耗时、错误和统计字段。
|
||||
@update
|
||||
Future<void> edit(ScheduledTaskLogEntity entity);
|
||||
|
||||
/// 按任务编码查询最近日志,可选依赖类型过滤,并按主键倒序限制返回数量。
|
||||
@Query('''
|
||||
SELECT * FROM scheduled_task_log
|
||||
WHERE taskCode = :taskCode
|
||||
AND (:sourceType = '' OR sourceType = :sourceType)
|
||||
ORDER BY id DESC
|
||||
LIMIT :limit
|
||||
''')
|
||||
Future<List<ScheduledTaskLogEntity>> 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<List<ScheduledTaskLogEntity>> 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<int?> countByTask(String taskCode, String sourceType);
|
||||
|
||||
/// 查询指定任务最近一条日志;从未执行时返回空。
|
||||
@Query('''
|
||||
SELECT * FROM scheduled_task_log
|
||||
WHERE taskCode = :taskCode
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
''')
|
||||
Future<ScheduledTaskLogEntity?> 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<void> trimHistory();
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:sqflite/sqflite.dart' as sqflite;
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
/// Floor 代码生成器使用的全部业务实体类型,新增实体时必须同步数据库版本和连续迁移。
|
||||
const entities = <Type>[
|
||||
BroadcastEntity,
|
||||
OrderEntity,
|
||||
@@ -23,8 +24,12 @@ const entities = <Type>[
|
||||
UserEntity,
|
||||
FaceEntity,
|
||||
SettingEntity,
|
||||
ScheduledTaskEntity,
|
||||
ScheduledTaskLogEntity,
|
||||
FileUploadRecordEntity,
|
||||
];
|
||||
|
||||
/// Floor 在实体字段与 SQLite 基础类型之间使用的全局转换器集合。
|
||||
const typeConverters = <Type>[
|
||||
DateTimeConverter,
|
||||
DateTimeEmptyConverter,
|
||||
@@ -41,23 +46,59 @@ const typeConverters = <Type>[
|
||||
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<AppDatabase> getDataBase() async {
|
||||
final dbPath = await AppFileTool.getDatabasePath();
|
||||
final name = "$dbPath/${Constants.databaseName}";
|
||||
|
||||
@@ -98,13 +98,19 @@ class _$AppDatabase extends AppDatabase {
|
||||
|
||||
SettingDao? _getSettingDaoInstance;
|
||||
|
||||
ScheduledTaskDao? _getScheduledTaskDaoInstance;
|
||||
|
||||
ScheduledTaskLogDao? _getScheduledTaskLogDaoInstance;
|
||||
|
||||
FileUploadRecordDao? _getFileUploadRecordDaoInstance;
|
||||
|
||||
Future<sqflite.Database> open(
|
||||
String path,
|
||||
List<Migration> 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) => <String, Object?>{
|
||||
'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) => <String, Object?>{
|
||||
'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<String> changeListener;
|
||||
|
||||
final QueryAdapter _queryAdapter;
|
||||
|
||||
final InsertionAdapter<ScheduledTaskEntity>
|
||||
_scheduledTaskEntityInsertionAdapter;
|
||||
|
||||
final UpdateAdapter<ScheduledTaskEntity> _scheduledTaskEntityUpdateAdapter;
|
||||
|
||||
@override
|
||||
Future<List<ScheduledTaskEntity>> getAll() async {
|
||||
return _queryAdapter.queryList(
|
||||
'SELECT * FROM scheduled_task ORDER BY taskCode',
|
||||
mapper: (Map<String, Object?> 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<ScheduledTaskEntity?> getByCode(String taskCode) async {
|
||||
return _queryAdapter.query(
|
||||
'SELECT * FROM scheduled_task WHERE taskCode = ?1 LIMIT 1',
|
||||
mapper: (Map<String, Object?> 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<void> save(ScheduledTaskEntity entity) async {
|
||||
await _scheduledTaskEntityInsertionAdapter.insert(
|
||||
entity, OnConflictStrategy.replace);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> batchSave(List<ScheduledTaskEntity> entities) async {
|
||||
await _scheduledTaskEntityInsertionAdapter.insertList(
|
||||
entities, OnConflictStrategy.replace);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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) => <String, Object?>{
|
||||
'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) => <String, Object?>{
|
||||
'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<String> changeListener;
|
||||
|
||||
final QueryAdapter _queryAdapter;
|
||||
|
||||
final InsertionAdapter<ScheduledTaskLogEntity>
|
||||
_scheduledTaskLogEntityInsertionAdapter;
|
||||
|
||||
final UpdateAdapter<ScheduledTaskLogEntity>
|
||||
_scheduledTaskLogEntityUpdateAdapter;
|
||||
|
||||
@override
|
||||
Future<List<ScheduledTaskLogEntity>> 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<String, Object?> 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<List<ScheduledTaskLogEntity>> 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<String, Object?> 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<int?> 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<String, Object?> row) => row.values.first as int,
|
||||
arguments: [taskCode, sourceType]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ScheduledTaskLogEntity?> getLatest(String taskCode) async {
|
||||
return _queryAdapter.query(
|
||||
'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 ORDER BY id DESC LIMIT 1',
|
||||
mapper: (Map<String, Object?> 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<void> 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<int> save(ScheduledTaskLogEntity entity) {
|
||||
return _scheduledTaskLogEntityInsertionAdapter.insertAndReturnId(
|
||||
entity, OnConflictStrategy.abort);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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) => <String, Object?>{
|
||||
'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) => <String, Object?>{
|
||||
'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<String> changeListener;
|
||||
|
||||
final QueryAdapter _queryAdapter;
|
||||
|
||||
final InsertionAdapter<FileUploadRecordEntity>
|
||||
_fileUploadRecordEntityInsertionAdapter;
|
||||
|
||||
final UpdateAdapter<FileUploadRecordEntity>
|
||||
_fileUploadRecordEntityUpdateAdapter;
|
||||
|
||||
@override
|
||||
Future<List<FileUploadRecordEntity>> 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<String, Object?> 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<FileUploadRecordEntity?> 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<String, Object?> 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<int> save(FileUploadRecordEntity entity) {
|
||||
return _fileUploadRecordEntityInsertionAdapter.insertAndReturnId(
|
||||
entity, OnConflictStrategy.replace);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> edit(FileUploadRecordEntity entity) async {
|
||||
await _fileUploadRecordEntityUpdateAdapter.update(
|
||||
entity, OnConflictStrategy.abort);
|
||||
}
|
||||
}
|
||||
|
||||
// ignore_for_file: unused_element
|
||||
final _dateTimeConverter = DateTimeConverter();
|
||||
final _dateTimeEmptyConverter = DateTimeEmptyConverter();
|
||||
|
||||
@@ -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<Migration> getDatabaseMigrations() {
|
||||
return [
|
||||
migration1To2,
|
||||
@@ -191,6 +270,7 @@ abstract class DatabaseMigration {
|
||||
migration4To5,
|
||||
migration5To6,
|
||||
migration6To7,
|
||||
migration7To8,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> json) =>
|
||||
_$FileUploadRecordEntityFromJson(json);
|
||||
|
||||
/// 将上传记录转换为 JSON,仅供调试序列化使用。
|
||||
Map<String, dynamic> toJson() => _$FileUploadRecordEntityToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'file_upload_record_entity.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
FileUploadRecordEntity _$FileUploadRecordEntityFromJson(
|
||||
Map<String, dynamic> 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<String, dynamic> _$FileUploadRecordEntityToJson(
|
||||
FileUploadRecordEntity instance) =>
|
||||
<String, dynamic>{
|
||||
'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(),
|
||||
};
|
||||
@@ -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<String, dynamic> json) =>
|
||||
_$ScheduledTaskEntityFromJson(json);
|
||||
|
||||
/// 将实体转换为 JSON,仅供兼容迁移和调试序列化使用。
|
||||
Map<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'scheduled_task_entity.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ScheduledTaskEntity _$ScheduledTaskEntityFromJson(Map<String, dynamic> 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<String, dynamic> _$ScheduledTaskEntityToJson(
|
||||
ScheduledTaskEntity instance) =>
|
||||
<String, dynamic>{
|
||||
'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(),
|
||||
};
|
||||
@@ -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<String, dynamic> json) =>
|
||||
_$ScheduledTaskLogEntityFromJson(json);
|
||||
|
||||
/// 将任务日志转换为 JSON,仅供调试序列化使用。
|
||||
Map<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'scheduled_task_log_entity.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ScheduledTaskLogEntity _$ScheduledTaskLogEntityFromJson(
|
||||
Map<String, dynamic> 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<String, dynamic> _$ScheduledTaskLogEntityToJson(
|
||||
ScheduledTaskLogEntity instance) =>
|
||||
<String, dynamic>{
|
||||
'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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void> 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<void> dispatchMqttMessage(MqBaseMsg msg, String orgStr) async {
|
||||
if (ObjectUtil.isEmpty(msg.action)) return;
|
||||
final app = Get.find<AppControl>();
|
||||
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<AppControl>();
|
||||
app.mqListen?.cancel();
|
||||
|
||||
@@ -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<dynamic, dynamic>? 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<AppPerformanceSnapshot> getPerformanceSnapshot() async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'getPerformanceSnapshot',
|
||||
);
|
||||
return AppPerformanceSnapshot.fromMap(data);
|
||||
} on PlatformException {
|
||||
return const AppPerformanceSnapshot.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询插件当前 Presentation 是否仍在展示,用于避免 showSubDisplay() 先关后开造成闪屏。
|
||||
static Future<bool> isSubScreenShowing() async {
|
||||
try {
|
||||
|
||||
@@ -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<void> _extensionWriteQueue = Future.value();
|
||||
|
||||
static Future<void> removeSettingCache() async {
|
||||
await _settingCache.emptyCache();
|
||||
}
|
||||
@@ -143,6 +158,100 @@ abstract class AppSettingTool {
|
||||
await _setSetting(sysJson: sysInfo);
|
||||
}
|
||||
|
||||
/// 读取完整 sysJson 为可修改映射,仅供旧结构化数据迁移方法使用。
|
||||
static Future<Map<String, dynamic>> _getSysExtensionJson() async {
|
||||
final setting = await _getSetting();
|
||||
return Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
}
|
||||
|
||||
/// 仅用于把 v8 之前短暂写入 sysJson 的任务配置导入结构化表,不再提供 JSON 写入口。
|
||||
static Future<List<ScheduledTaskConfig>> getScheduledTaskConfigs() async {
|
||||
final sysJson = await _getSysExtensionJson();
|
||||
final raw = sysJson[_scheduledTaskConfigsKey];
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((item) => ScheduledTaskConfig.fromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// 读取 v8 之前的任务执行快照并按当前枚举过滤未知任务,仅用于迁移。
|
||||
static Future<Map<ScheduledTaskType, ScheduledTaskRecord>>
|
||||
getScheduledTaskRecords() async {
|
||||
final sysJson = await _getSysExtensionJson();
|
||||
final raw = sysJson[_scheduledTaskRecordsKey];
|
||||
if (raw is! Map) return const {};
|
||||
final result = <ScheduledTaskType, ScheduledTaskRecord>{};
|
||||
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<String, dynamic>.from(entry.value as Map),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 读取 v8 之前按相对路径保存的成功上传状态,仅用于导入文件上传记录表。
|
||||
static Future<Map<String, TerminalLogUploadState>>
|
||||
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<String, dynamic>.from(value as Map),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 读取 v8 之前的最近上传汇总;仅保留兼容能力,不再作为运行时数据源。
|
||||
static Future<TerminalLogUploadSummary> getTerminalLogUploadSummary() async {
|
||||
final sysJson = await _getSysExtensionJson();
|
||||
final raw = sysJson[_terminalLogUploadSummaryKey];
|
||||
if (raw is! Map) return const TerminalLogUploadSummary();
|
||||
return TerminalLogUploadSummary.fromJson(Map<String, dynamic>.from(raw));
|
||||
}
|
||||
|
||||
/// v8 已将任务与上传状态迁入独立表,导入成功后移除旧 JSON,避免形成双写数据源。
|
||||
static Future<void> clearLegacyScheduledTaskState({
|
||||
required bool includeUploadState,
|
||||
}) async {
|
||||
final operation = _extensionWriteQueue.catchError((_) {}).then((_) async {
|
||||
final app = Get.find<AppControl>();
|
||||
final settingDao = app.dataBase.getSettingDao;
|
||||
final setting = await settingDao.get();
|
||||
if (setting == null) throw StateError('终端设置尚未初始化');
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
sysJson.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<void> setUserSetting(UserSetting userInfo) async {
|
||||
final globalUserInfo = await getUserSetting();
|
||||
if (globalUserInfo == userInfo) return;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -43,6 +43,53 @@ Future<AppLogCatalog> listAppLogFiles() async {
|
||||
return _consoleOutput.listLogFiles();
|
||||
}
|
||||
|
||||
/// 上传当前日志前刷新文件写入队列,保证快照边界包含调用前已经产生的日志。
|
||||
Future<void> flushCurrentAppLog() async {
|
||||
await logger.init;
|
||||
await _consoleOutput.flushCurrentLog();
|
||||
}
|
||||
|
||||
/// 逐行生成脱敏副本,上传工具不会把原始日志文件直接交给网络层。
|
||||
Future<void> 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<void> flushCurrentLog() => _enqueue(() async => _sink?.flush());
|
||||
|
||||
/// 读取当前日志文件尾部,并在返回 UI 前移除终端颜色码和常见敏感字段。
|
||||
Future<AppLogPreview> readPreview({
|
||||
String? path,
|
||||
|
||||
@@ -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|");
|
||||
}
|
||||
}
|
||||
@@ -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<AppControl>().dataBase.getFileUploadRecordDao;
|
||||
|
||||
/// 将 v8 之前保存在 sysJson 的成功状态一次性导入 file_upload_record。
|
||||
///
|
||||
/// 相同设备、业务类型和相对路径已有记录时保持结构化表数据,不用旧 JSON 覆盖。
|
||||
static Future<void> importLegacy(
|
||||
String hardId,
|
||||
Map<String, TerminalLogUploadState> 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<Map<String, TerminalLogUploadState>> getSuccessfulStates(
|
||||
String hardId,
|
||||
) async {
|
||||
final result = <String, TerminalLogUploadState>{};
|
||||
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<FileUploadRecordEntity> 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<void> 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<void> 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)}...';
|
||||
}
|
||||
}
|
||||
@@ -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<AppControl>().dataBase.getScheduledTaskDao;
|
||||
|
||||
/// 当前应用数据库中的任务执行日志 DAO。
|
||||
static ScheduledTaskLogDao get _logDao =>
|
||||
Get.find<AppControl>().dataBase.getScheduledTaskLogDao;
|
||||
|
||||
/// 首次升级时把旧 sysJson 导入表中;表已有数据时只补齐后续版本新增的默认任务。
|
||||
static Future<void> initialize({
|
||||
required List<ScheduledTaskConfig> defaults,
|
||||
required List<ScheduledTaskConfig> legacyConfigs,
|
||||
required Map<ScheduledTaskType, ScheduledTaskRecord> 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 = <ScheduledTaskEntity>[];
|
||||
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<ScheduledTaskType, ScheduledTaskConfig> configs,
|
||||
Map<ScheduledTaskType, ScheduledTaskRecord> records,
|
||||
})> loadState() async {
|
||||
final configs = <ScheduledTaskType, ScheduledTaskConfig>{};
|
||||
final records = <ScheduledTaskType, ScheduledTaskRecord>{};
|
||||
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<void> 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<void> 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<ScheduledTaskLogEntity> 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<void> finishLog(ScheduledTaskLogEntity log) async {
|
||||
await _logDao.edit(log);
|
||||
await _logDao.trimHistory();
|
||||
}
|
||||
|
||||
/// 查询指定任务最近 100 条日志,可按主要依赖类型进一步筛选。
|
||||
static Future<List<ScheduledTaskLogEntity>> getLogs(
|
||||
ScheduledTaskType type, {
|
||||
ScheduledTaskSourceType? sourceType,
|
||||
int limit = 100,
|
||||
}) =>
|
||||
_logDao.getByTask(type.name, sourceType?.name ?? '', limit);
|
||||
|
||||
/// 分页查询指定任务日志,同时返回总数和校正后的当前页。
|
||||
///
|
||||
/// 页容量限制在 1 到 100 条;当历史日志裁剪导致请求页超出范围时,自动回退到最后一页,
|
||||
/// 避免分页组件仍显示有效总数但列表为空。
|
||||
static Future<
|
||||
({
|
||||
List<ScheduledTaskLogEntity> 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 <ScheduledTaskLogEntity>[]
|
||||
: await _logDao.getPageByTask(
|
||||
type.name,
|
||||
sourceCode,
|
||||
normalizedPageSize,
|
||||
(normalizedCurrentPage - 1) * normalizedPageSize,
|
||||
);
|
||||
return (
|
||||
records: records,
|
||||
total: total,
|
||||
currentPage: normalizedCurrentPage,
|
||||
);
|
||||
}
|
||||
|
||||
/// 查询指定任务最近一条日志;尚未执行时返回空。
|
||||
static Future<ScheduledTaskLogEntity?> 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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<FaceRecognitionSessionService>() &&
|
||||
Get.find<FaceRecognitionSessionService>().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;
|
||||
}
|
||||
@@ -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<TerminalLogUploadSummary> 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<bool> _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<String> _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<Response<dynamic>> _retryRequest(
|
||||
Future<Response<dynamic>> 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<void>.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<int> 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;
|
||||
}
|
||||
}
|
||||
@@ -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 <Widget>[
|
||||
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<TerminalPerformanceStatus> createState() =>
|
||||
_TerminalPerformanceStatusState();
|
||||
}
|
||||
|
||||
/// 顶栏性能指标的采样、CPU 差值计算和展示状态。
|
||||
class _TerminalPerformanceStatusState extends State<TerminalPerformanceStatus> {
|
||||
/// 每三秒触发一次轻量原生资源采样的计时器。
|
||||
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<void> _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: <Widget>[
|
||||
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,
|
||||
<Widget>[
|
||||
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()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
<Widget>[
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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<UserSetting>(
|
||||
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<UserSetting>(
|
||||
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<TerminalNetworkStatus> {
|
||||
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<TerminalNetworkStatus> {
|
||||
),
|
||||
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});
|
||||
|
||||
@@ -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<AppControl>();
|
||||
|
||||
/// 终端一级菜单控制器,提供可配置页面的实时排序。
|
||||
final terminalMenuC = Get.find<TerminalMenuControl>();
|
||||
|
||||
/// 当前设备系统设置;初始化完成前为空,修改深层字段后需主动 refresh。
|
||||
final sysSetting = Rxn<SysSetting>();
|
||||
|
||||
/// 当前登录用户的本地设置;初始化完成前为空。
|
||||
final userSetting = Rxn<UserSetting>();
|
||||
|
||||
/// 是否使用人工输入代替扫码、读卡和人脸等真实硬件输入。
|
||||
final manualHardwareInput = false.obs;
|
||||
|
||||
/// Android Kiosk 当前能力与运行状态,应用恢复前台时刷新。
|
||||
final kioskStatus = AppRuntimeTool.kioskStatus.obs;
|
||||
|
||||
/// 收银支付方式的界面排序,初始顺序来自枚举并由本地持久化覆盖。
|
||||
final payTypeOrder = <OrderPayDisplayType>[
|
||||
...OrderPayDisplayType.values,
|
||||
].obs;
|
||||
|
||||
/// 页面设置当前编辑的终端一级页面,默认展示收银页面配置。
|
||||
final pageSettingType = TerminalMenuItemType.cashier.obs;
|
||||
|
||||
/// 收银页右下角快捷操作显隐独立观察,切换时不重建支付方式排序区域。
|
||||
final cashierShowHoldAction = true.obs;
|
||||
|
||||
/// 收银页右下角“更多”快捷操作是否展示。
|
||||
final cashierShowMoreAction = true.obs;
|
||||
|
||||
/// 预约查询识别方式的展示顺序,持久化到现有设置 JSON。
|
||||
final reservationQueryMethodOrder = <ReservationRecognitionMethod>[
|
||||
...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 = <String>{}.obs;
|
||||
|
||||
/// 最近一次本地数据库备份文件修改时间;尚无备份时为空。
|
||||
final sqlBackupTime = Rxn<DateTime>();
|
||||
|
||||
/// 最近一次本地日志备份文件修改时间;尚无备份时为空。
|
||||
final logBackupTime = Rxn<DateTime>();
|
||||
|
||||
/// 最近一次手动备份的结果与目标路径摘要。
|
||||
final lastBackupMessage = ''.obs;
|
||||
|
||||
/// 旧内联编辑器当前展开的任务类型;保留给兼容组件读取,为空表示全部收起。
|
||||
final expandedScheduledTask = Rxn<ScheduledTaskType>();
|
||||
|
||||
/// 各任务尚未保存的 Cron 输入草稿,以任务类型隔离编辑状态。
|
||||
final scheduledTaskCronDrafts = <ScheduledTaskType, String>{}.obs;
|
||||
|
||||
/// 各任务 Cron 草稿的本地校验错误,保存成功或继续输入后移除。
|
||||
final scheduledTaskCronErrors = <ScheduledTaskType, String>{}.obs;
|
||||
|
||||
/// 正在持久化配置的任务集合,用于禁止同任务重复提交并局部禁用控件。
|
||||
final savingScheduledTasks = <ScheduledTaskType>{}.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<void> _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<MqtActionEnum>().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<void> 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<void> _flushReservationQueryPreferences() async {
|
||||
_reservationQueryPersisting = true;
|
||||
while (_reservationQueryPendingPersist) {
|
||||
@@ -256,10 +333,12 @@ class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
_reservationQueryPersisting = false;
|
||||
}
|
||||
|
||||
/// 从 Android 原生能力重新读取当前 Kiosk 状态。
|
||||
Future<void> refreshKioskStatus() async {
|
||||
kioskStatus.value = await AppRuntimeTool.refreshKioskStatus();
|
||||
}
|
||||
|
||||
/// 保存全局字体倍率并立即应用到运行中主题。
|
||||
Future<void> 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<void> 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<void> 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<void> 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<void> _flushCashierPagePreferences() async {
|
||||
_cashierPagePersisting = true;
|
||||
while (_cashierPagePendingPersist) {
|
||||
@@ -419,6 +509,7 @@ class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
if (!_payPersisting) unawaited(_flushPayPreferences());
|
||||
}
|
||||
|
||||
/// 串行写入支付方式偏好,保存期间的新变更会继续下一轮写入。
|
||||
Future<void> _flushPayPreferences() async {
|
||||
_payPersisting = true;
|
||||
while (_payPendingPersist) {
|
||||
@@ -438,6 +529,7 @@ class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
_payPersisting = false;
|
||||
}
|
||||
|
||||
/// 通过旧同步入口立即拉取设备配置,并在成功后刷新系统设置。
|
||||
Future<void> syncConfig() async {
|
||||
await _runSync('config', '设备信息', () async {
|
||||
final setting = await SyncConfigTool.autoSyncSysInfo();
|
||||
@@ -445,6 +537,7 @@ class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
|
||||
/// 执行或取消设备状态检查,并维护旧同步按钮的局部结果状态。
|
||||
Future<void> syncDeviceStatus() async {
|
||||
if (AppDeviceStatusTool.isRefreshing) {
|
||||
AppDeviceStatusTool.cancelRefresh();
|
||||
@@ -469,6 +562,7 @@ class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过旧同步入口关闭并重新初始化 MQTT 长连接。
|
||||
Future<void> syncMq() async {
|
||||
await _runSync('mq', '长链接', () async {
|
||||
await MqManager.closeMqtt();
|
||||
@@ -476,18 +570,147 @@ class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
|
||||
/// 通过旧同步入口立即同步商品分类。
|
||||
Future<void> syncClassify() async {
|
||||
await _runSync('classify', '分类', SyncProductTool.syncClassify);
|
||||
}
|
||||
|
||||
/// 通过旧同步入口立即同步商品主数据。
|
||||
Future<void> syncProduct() async {
|
||||
await _runSync('product', '商品', SyncProductTool.syncProduct);
|
||||
}
|
||||
|
||||
/// 通过旧同步入口立即补传本地离线订单。
|
||||
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> _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<void> 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<void> _runSync(
|
||||
String key,
|
||||
String label,
|
||||
@@ -514,14 +737,17 @@ class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动备份本地 SQLite 数据库并刷新最近备份时间。
|
||||
Future<void> backupsSql() async {
|
||||
await _runBackup('SQL', AppFileTool.backupSqlFilePath);
|
||||
}
|
||||
|
||||
/// 手动备份本地日志文件并刷新最近备份时间。
|
||||
Future<void> backupsLog() async {
|
||||
await _runBackup('日志', AppFileTool.backupLogFile);
|
||||
}
|
||||
|
||||
/// 执行单类文件备份,统一处理路径校验、状态提示和异常日志。
|
||||
Future<void> _runBackup(
|
||||
String label,
|
||||
Future<String?> Function() action,
|
||||
@@ -541,6 +767,7 @@ class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描现有备份目录并更新数据库、日志最近修改时间。
|
||||
Future<void> refreshBackupTimes() async {
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null) return;
|
||||
@@ -555,6 +782,7 @@ class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换人工硬件输入模式,模拟器必须保持开启并在真机开启前二次确认。
|
||||
Future<void> setManualHardwareInput(bool enabled) async {
|
||||
if (AppRuntimeTool.isEmulator && !enabled) {
|
||||
Toast.error(msg: '虚拟机必须使用手动硬件输入模式');
|
||||
|
||||
@@ -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<SettingController>().doMenu(SettingMenuType.faceSetting);
|
||||
SettingNavigation.openMenu(SettingMenuType.faceSetting);
|
||||
}
|
||||
|
||||
void toggleParameterVisibility() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
library;
|
||||
|
||||
export 'navigation.dart';
|
||||
export 'route_factory.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();
|
||||
}
|
||||
@@ -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<SettingMenuType> 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<void> openTaskLogs(ScheduledTaskType type) async {
|
||||
await Get.toNamed<void>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<dynamic> 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<void>(
|
||||
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<String> _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<SettingController>();
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ScheduledTaskLogEntity> logs = <ScheduledTaskLogEntity>[].obs;
|
||||
|
||||
/// 当前页码,从 1 开始,由分页组件修改并在查询结果越界时自动校正。
|
||||
final RxInt currentPage = 1.obs;
|
||||
|
||||
/// 每页固定展示数量;日志行可能展开错误详情,因此保持与交易记录相同的十条密度。
|
||||
final int pageSize = _defaultPageSize;
|
||||
|
||||
/// 当前任务和来源筛选下的日志总数,直接来自 SQLite 计数查询。
|
||||
final RxInt total = 0.obs;
|
||||
|
||||
/// 当前依赖来源筛选;为空表示查询 MQTT、HTTP、插件、文件和本地全部来源。
|
||||
final Rxn<ScheduledTaskSourceType> sourceType =
|
||||
Rxn<ScheduledTaskSourceType>();
|
||||
|
||||
/// 是否正在读取 SQLite,用于展示加载状态并禁止重复刷新。
|
||||
final RxBool isLoading = false.obs;
|
||||
|
||||
/// 最近一次查询失败时面向操作人员的摘要;查询成功后清空。
|
||||
final Rxn<String> errorMessage = Rxn<String>();
|
||||
|
||||
/// 查询序号用于丢弃快速切换筛选时晚返回的旧结果,避免列表与筛选条件错位。
|
||||
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<void> 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<void> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ScheduledTaskLogsController> {
|
||||
/// 创建由路由 Binding 提供控制器的任务日志页面。
|
||||
const ScheduledTaskLogsPage({super.key});
|
||||
|
||||
/// 构建页面工具栏、来源筛选、日志表格和分页栏,各响应区域分别局部刷新。
|
||||
@override
|
||||
Widget build(BuildContext context) => <Widget>[
|
||||
_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 <Widget>[
|
||||
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),
|
||||
),
|
||||
),
|
||||
<Widget>[
|
||||
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(
|
||||
() => <Widget>[
|
||||
_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 <Widget>[
|
||||
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 <Widget>[
|
||||
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: <Widget>[
|
||||
<Widget>[
|
||||
<Widget>[
|
||||
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),
|
||||
<Widget>[
|
||||
_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),
|
||||
<Widget>[
|
||||
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(
|
||||
<String?>[
|
||||
widget.log.errorType,
|
||||
widget.log.errorMessage,
|
||||
widget.log.errorStack,
|
||||
].whereType<String>().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) => <Widget>[
|
||||
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}';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<SettingController> {
|
||||
/// 创建由终端工作区 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 <Widget>[
|
||||
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<String?> onChanged;
|
||||
|
||||
/// 路由推入后同步当前设置菜单。
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
onChanged(route.settings.name);
|
||||
}
|
||||
|
||||
/// 路由替换后同步当前设置菜单。
|
||||
@override
|
||||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||
onChanged(newRoute?.settings.name);
|
||||
}
|
||||
|
||||
/// 从详情页返回后按上一个路由恢复设置菜单高亮。
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
onChanged(previousRoute?.settings.name);
|
||||
}
|
||||
}
|
||||
@@ -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<String?> showScheduledCronRuleDialog(
|
||||
BuildContext context, {
|
||||
required String currentExpression,
|
||||
}) =>
|
||||
showAdaptiveDialog<String>(
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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,
|
||||
),
|
||||
),
|
||||
<Widget>[
|
||||
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: [
|
||||
<Widget>[
|
||||
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: <Widget>[
|
||||
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 => '每月',
|
||||
};
|
||||
@@ -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<SettingController>();
|
||||
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 <Widget>[
|
||||
SettingGroup(
|
||||
title: '自动任务',
|
||||
child: <Widget>[
|
||||
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<void> 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: <Widget>[
|
||||
_taskIcon(metadata.icon, running: running),
|
||||
<Widget>[
|
||||
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 = <Widget>[
|
||||
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 <Widget>[
|
||||
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<void> _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: <Widget>[
|
||||
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<bool> 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 <Widget>[
|
||||
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: <Widget>[
|
||||
_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)}';
|
||||
@@ -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<void> showScheduledTaskLogDialog(
|
||||
BuildContext context, {
|
||||
required ScheduledTaskType type,
|
||||
}) =>
|
||||
showAdaptiveDialog<void>(
|
||||
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<ScheduledTaskLogEntity> _logs = const [];
|
||||
|
||||
/// 当前依赖类型筛选;为空表示查询全部来源。
|
||||
ScheduledTaskSourceType? _sourceType;
|
||||
|
||||
/// 是否正在读取 SQLite,用于禁用刷新并展示加载状态。
|
||||
bool _loading = true;
|
||||
|
||||
/// 最近一次日志读取失败摘要;查询成功前为空。
|
||||
String? _error;
|
||||
|
||||
/// 弹窗创建后立即加载当前任务最近日志。
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
/// 按当前任务和依赖筛选读取日志,并仅在弹窗仍挂载时更新局部状态。
|
||||
Future<void> _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: <Widget>[
|
||||
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: <Widget>[
|
||||
<Widget>[
|
||||
_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: <Widget>[
|
||||
<Widget>[
|
||||
FIcon(visual.icon, color: visual.color, size: 22.sp),
|
||||
<Widget>[
|
||||
<Widget>[
|
||||
_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<String>().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) => <Widget>[
|
||||
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}';
|
||||
}
|
||||
+25
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# 文字转拼音
|
||||
|
||||
Reference in new issue
Block a user