feat:设备日志模块

feat:定时任务
feat:设置页面用路由重构
This commit is contained in:
dzc committed 2026-07-30 13:16:14 +08:00
1 parent 1033f403b1
commit 2efa7a3c19
60 files changed
+6560 -643

No files matched your search

+1 -1
View File
@@ -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为版本号(内部比较)
```
@@ -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
View File
@@ -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!,
),
),
),
);
+1
View File
@@ -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(),
};
+25
View File
@@ -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);
}
+1
View File
@@ -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) =>
+37
View File
@@ -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();
}
+42 -1
View File
@@ -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}";
+379 -1
View File
@@ -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,
};
+6
View File
@@ -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';
+18 -4
View File
@@ -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 {
+109
View File
@@ -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;
+4
View File
@@ -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';
+50
View File
@@ -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,
+7
View File
@@ -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
+117
View File
@@ -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;
}
}
+273 -134
View File
@@ -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';
}
}
+75 -50
View File
@@ -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});
+234 -6
View File
@@ -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);
}
+75 -50
View File
@@ -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
View File
@@ -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:
+3
View File
@@ -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
# 文字转拼音