Files
biz_xfj_cloud/lib/pages/terminal/setting/widgets/setting_sync.dart
T
dzc 541923150e feat:订单列表增加预约时间
feat:优化食堂菜品预约统计导出
feat:增加订单识别证据图片功能
2026-08-23 18:49:55 +08:00

526 lines
18 KiB
Dart

import 'dart:async';
import 'package:common_utils/common_utils.dart';
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
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 configs = [
for (final type in ScheduledTaskType.values)
if (scheduler.configs[type] != null) scheduler.configs[type]!,
];
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,
),
].toColumn(),
),
_terminalLogSummary(context, scheduler.terminalLogSummary.value),
const SettingNotice(
text:
'定时任务仅在应用进程存活时执行。自动空闲任务要求前台连续 60 秒无操作;手动执行和后台拉取只等待支付、人脸、遮罩和业务弹框结束。',
),
]
.toColumnSpace(
crossAxisAlignment: CrossAxisAlignment.stretch,
space: XfjDesignTokens.cardGap.h,
)
.scrollable();
});
}
/// 单个定时任务的紧凑配置行,任务标题区域同时作为执行日志入口。
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,
});
/// 当前任务的自动开关、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 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: () => 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),
),
);
}
}
/// 任务行中复用的带短标签二元开关。
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.autoUpdate => (
title: 'APK 自动更新',
icon: FAssets.icons.packageCheck
),
ScheduledTaskType.configSync => (
title: '设备配置同步',
icon: FAssets.icons.settings
),
ScheduledTaskType.departmentFaceSync => (
title: '本部门人脸同步',
icon: FAssets.icons.scanFace
),
ScheduledTaskType.classifySync => (
title: '分类同步',
icon: FAssets.icons.packageOpen
),
ScheduledTaskType.productSync => (
title: '商品同步',
icon: FAssets.icons.package
),
ScheduledTaskType.offlineOrderSync => (
title: '离线订单同步',
icon: FAssets.icons.cloudDownload
),
ScheduledTaskType.orderEvidenceUpload => (
title: '订单证据补传',
icon: FAssets.icons.image
),
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)}';