From 79d3b17660303a0a7ca7cca56a81e37aa2a4de9d Mon Sep 17 00:00:00 2001 From: zmdyy0318 Date: Fri, 26 Jun 2026 21:13:44 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=88=86?= =?UTF-8?q?=E9=92=9F=E7=BA=A7=E5=88=AB=E5=AE=9A=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SchedulePanel.tsx | 116 ++++++++++++++++++------------- src/i18n/locales/en-US.ts | 8 +-- src/i18n/locales/ja-JP.ts | 8 +-- src/i18n/locales/ko-KR.ts | 8 +-- src/i18n/locales/zh-CN.ts | 8 +-- src/i18n/locales/zh-TW.ts | 8 +-- src/services/scheduleService.ts | 61 +++++++++------- src/stores/appStore.ts | 24 ++++++- src/types/config.ts | 4 +- src/types/interface.ts | 4 +- 10 files changed, 146 insertions(+), 103 deletions(-) diff --git a/src/components/SchedulePanel.tsx b/src/components/SchedulePanel.tsx index 56173b4a..acd2bf53 100644 --- a/src/components/SchedulePanel.tsx +++ b/src/components/SchedulePanel.tsx @@ -18,8 +18,8 @@ import { ConfirmDialog } from './ConfirmDialog'; // 生成唯一 ID const generateId = () => Math.random().toString(36).substring(2, 9); -// 小时选项 (0-23) -const HOURS = Array.from({ length: 24 }, (_, i) => i); +// 校验 "HH:mm" 格式 +const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; interface SchedulePanelProps { instanceId: string; @@ -43,6 +43,7 @@ function PolicyCard({ const { t } = useTranslation(); const { confirmBeforeDelete } = useAppStore(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [timeDraft, setTimeDraft] = useState('08:00'); const weekdayLabels = t('schedule.weekdays', { returnObjects: true }) as string[]; @@ -53,11 +54,15 @@ function PolicyCard({ onUpdate({ weekdays: newWeekdays }); }; - const handleToggleHour = (hour: number) => { - const newHours = policy.hours.includes(hour) - ? policy.hours.filter((h) => h !== hour) - : [...policy.hours, hour].sort((a, b) => a - b); - onUpdate({ hours: newHours }); + const handleAddTime = () => { + if (!TIME_PATTERN.test(timeDraft)) return; + if (policy.times.includes(timeDraft)) return; + const newTimes = [...policy.times, timeDraft].sort((a, b) => a.localeCompare(b)); + onUpdate({ times: newTimes }); + }; + + const handleRemoveTime = (time: string) => { + onUpdate({ times: policy.times.filter((t) => t !== time) }); }; const handleSelectAllWeekdays = () => { @@ -69,15 +74,6 @@ function PolicyCard({ } }; - const handleSelectAllHours = () => { - // 已全选时取消全选,否则全选 - if (policy.hours.length === 24) { - onUpdate({ hours: [] }); - } else { - onUpdate({ hours: HOURS }); - } - }; - // 格式化显示已选周几 const formatWeekdays = () => { if (policy.weekdays.length === 0) return t('schedule.noWeekdays'); @@ -86,13 +82,12 @@ function PolicyCard({ }; // 格式化显示已选时间 - const formatHours = () => { - if (policy.hours.length === 0) return t('schedule.noHours'); - if (policy.hours.length === 24) return t('schedule.everyHour'); - if (policy.hours.length <= 3) { - return policy.hours.map((h) => `${h.toString().padStart(2, '0')}:00`).join(', '); + const formatTimes = () => { + if (policy.times.length === 0) return t('schedule.noTimes'); + if (policy.times.length <= 3) { + return policy.times.join(', '); } - return `${policy.hours.length} ${t('schedule.hoursSelected')}`; + return `${policy.times.length} ${t('schedule.timesSelected')}`; }; return ( @@ -217,34 +212,61 @@ function PolicyCard({ ({t('schedule.multiSelect')}) - {/* 时间网格 */} -
+ {/* 时间点添加 */} +
+ setTimeDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTime(); + } + }} + className={clsx( + 'flex-1 px-2 py-1.5 text-sm rounded border', + 'bg-bg-primary text-text-primary border-border', + 'focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20', + )} + /> - {HOURS.map((hour) => ( - - ))}
+ {/* 已选时间点 */} + {policy.times.length > 0 ? ( +
+ {policy.times.map((time) => ( + + {time} + + + ))} +
+ ) : ( +

{t('schedule.noTimes')}

+ )}

{t('schedule.timeZoneHint')} ( {(() => { @@ -258,7 +280,7 @@ function PolicyCard({ {/* 摘要显示 */}

- {formatWeekdays()} · {formatHours()} + {formatWeekdays()} · {formatTimes()}

@@ -268,7 +290,7 @@ function PolicyCard({ {!isExpanded && (

- {formatWeekdays()} · {formatHours()} + {formatWeekdays()} · {formatTimes()}

)} @@ -318,7 +340,7 @@ export function SchedulePanel({ instanceId, onClose }: SchedulePanelProps) { name: `${t('schedule.defaultPolicyName')} ${policies.length + 1}`, enabled: true, weekdays: [1, 2, 3, 4, 5], // 默认工作日 - hours: [8], // 默认早上8点 + times: ['08:00'], // 默认早上 8 点 }; updateInstance(instanceId, { schedulePolicies: [...policies, newPolicy], diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index f792556b..8dd5f511 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -712,13 +712,11 @@ export default { repeatDays: 'Repeat Days', startTime: 'Start Time', selectDays: 'Select days...', - selectHours: 'Select hours...', + addTime: 'Add time', noWeekdays: 'No days selected', - noHours: 'No hours selected', + noTimes: 'No times selected', everyday: 'Every day', - everyHour: 'Every hour', - all: 'All', - hoursSelected: 'hours selected', + timesSelected: 'times selected', timeZoneHint: 'Using local timezone', multiSelect: 'multi-select', enable: 'Enable schedule', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 2d90031e..b2281454 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -711,13 +711,11 @@ export default { repeatDays: '繰り返し日', startTime: '開始時刻', selectDays: '日を選択...', - selectHours: '時刻を選択...', + addTime: '時刻を追加', noWeekdays: '日が選択されていません', - noHours: '時刻が選択されていません', + noTimes: '時刻が選択されていません', everyday: '毎日', - everyHour: '毎時', - all: 'すべて', - hoursSelected: '件の時刻', + timesSelected: '件の時刻', timeZoneHint: 'ローカルタイムゾーンを使用', multiSelect: '複数選択可', enable: 'スケジュールを有効化', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index bad8eb42..5b1bd84b 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -704,13 +704,11 @@ export default { repeatDays: '반복 요일', startTime: '시작 시간', selectDays: '요일 선택...', - selectHours: '시간 선택...', + addTime: '시간 추가', noWeekdays: '요일이 선택되지 않았습니다', - noHours: '시간이 선택되지 않았습니다', + noTimes: '시간이 선택되지 않았습니다', everyday: '매일', - everyHour: '매시', - all: '전체', - hoursSelected: '개의 시간', + timesSelected: '개의 시간', timeZoneHint: '로컬 시간대 사용', multiSelect: '다중 선택', enable: '예약 활성화', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index fd1b9c30..00a54894 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -706,13 +706,11 @@ export default { repeatDays: '重复日期', startTime: '开始时间', selectDays: '选择日期...', - selectHours: '选择时间...', + addTime: '添加时间', noWeekdays: '未选择日期', - noHours: '未选择时间', + noTimes: '未选择时间', everyday: '每天', - everyHour: '每小时', - all: '全部', - hoursSelected: '个时间点', + timesSelected: '个时间点', timeZoneHint: '使用本地时区', multiSelect: '可多选', enable: '启用策略', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 27d45a84..de599420 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -691,13 +691,11 @@ export default { repeatDays: '重複日期', startTime: '開始時間', selectDays: '選擇日期...', - selectHours: '選擇時間...', + addTime: '新增時間', noWeekdays: '未選擇日期', - noHours: '未選擇時間', + noTimes: '未選擇時間', everyday: '每天', - everyHour: '每小時', - all: '全部', - hoursSelected: '個時間点', + timesSelected: '個時間點', timeZoneHint: '使用本地時區', multiSelect: '可多選', enable: '啟用策略', diff --git a/src/services/scheduleService.ts b/src/services/scheduleService.ts index 315c32d1..7974e76d 100644 --- a/src/services/scheduleService.ts +++ b/src/services/scheduleService.ts @@ -7,11 +7,11 @@ const log = loggers.task; const STORAGE_KEY_LAST_CHECK = 'mxu_schedule_lastCheckAt'; const STORAGE_KEY_TRIGGERED = 'mxu_schedule_triggeredSlots'; -const CHECK_INTERVAL_MS = 60_000; // 每 60 秒轮询一次 +const CHECK_INTERVAL_MS = 30_000; // 每 30 秒轮询一次(分钟精度下降低到点延迟) const SLOT_TTL_MS = 48 * 60 * 60 * 1000; // 触发记录保留 48 小时 const MAX_COMPENSATE_MS = 3 * 60 * 60 * 1000; // 最多补偿 3 小时内的遗漏 const DEBOUNCE_MS = 2_000; // 事件触发后 2 秒内去重 -const CURRENT_SLOT_COMPENSATION_GRACE_MS = 5 * 60 * 1000; // 当前小时超过 5 分钟后补触发也记为补偿 +const CURRENT_SLOT_COMPENSATION_GRACE_MS = 30 * 1000; // 当前分钟超过 30 秒后补触发也记为补偿 export type ScheduleTriggerCallback = ( instance: Instance, @@ -25,11 +25,18 @@ function formatSlotKey(date: Date): string { const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); const h = String(date.getHours()).padStart(2, '0'); - return `${y}-${m}-${d}-${h}`; + const mi = String(date.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${d}-${h}-${mi}`; } -function hourStart(date: Date): Date { - return new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours()); +function minuteStart(date: Date): Date { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + ); } function buildTriggeredSlotKey(instanceId: string, slotStr: string): string { @@ -43,7 +50,7 @@ function normalizeTriggeredSlotKey(key: string): string | null { } const slotStr = key.substring(lastColon + 1); - if (!/^\d{4}-\d{2}-\d{2}-\d{2}$/.test(slotStr)) { + if (!/^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}$/.test(slotStr)) { return null; } @@ -56,19 +63,19 @@ function normalizeTriggeredSlotKey(key: string): string | null { function shouldMarkSlotAsCompensation( slotDate: Date, - currentHour: Date, + currentSlot: Date, nowTs: number, lastCheckAt: number, hadPreviousCheck: boolean, ): boolean { const slotTs = slotDate.getTime(); - const currentHourTs = currentHour.getTime(); + const currentSlotTs = currentSlot.getTime(); - if (slotTs < currentHourTs) { + if (slotTs < currentSlotTs) { return true; } - if (slotTs !== currentHourTs) { + if (slotTs !== currentSlotTs) { return false; } @@ -153,12 +160,11 @@ class ScheduleService { const cleaned = new Set(); for (const key of slots) { - // 当前格式: instanceId:YYYY-MM-DD-HH - // 兼容旧格式: instanceId:policyId:YYYY-MM-DD-HH + // 当前格式: instanceId:YYYY-MM-DD-HH-mm const lastColon = key.lastIndexOf(':'); const slotStr = key.substring(lastColon + 1); - const [y, mo, d, h] = slotStr.split('-').map(Number); - const slotTs = new Date(y, mo - 1, d, h).getTime(); + const [y, mo, d, h, mi] = slotStr.split('-').map(Number); + const slotTs = new Date(y, mo - 1, d, h, mi).getTime(); if (slotTs >= cutoff) { cleaned.add(key); } @@ -234,9 +240,9 @@ class ScheduleService { const hadPreviousCheck = storedLastCheckAt > 0; let lastCheckAt = storedLastCheckAt; - // 首次运行:以当前整点为起点 + // 首次运行:以当前整分为起点 if (lastCheckAt === 0) { - lastCheckAt = hourStart(now).getTime(); + lastCheckAt = minuteStart(now).getTime(); this.setLastCheckAt(lastCheckAt); } @@ -250,15 +256,15 @@ class ScheduleService { lastCheckAt = minCheckTs; } - // 枚举 lastCheckAt 到当前时间之间的所有整点时间槽 - const startHour = hourStart(new Date(lastCheckAt)); - const currentHour = hourStart(now); + // 枚举 lastCheckAt 到当前时间之间的所有整分时间槽 + const startSlot = minuteStart(new Date(lastCheckAt)); + const currentSlot = minuteStart(now); const slotsToCheck: Date[] = []; - const cursor = new Date(startHour); - while (cursor <= currentHour) { + const cursor = new Date(startSlot); + while (cursor <= currentSlot) { slotsToCheck.push(new Date(cursor)); - cursor.setTime(cursor.getTime() + 60 * 60 * 1000); + cursor.setTime(cursor.getTime() + 60 * 1000); } if (slotsToCheck.length > 1) { @@ -274,11 +280,13 @@ class ScheduleService { for (const slotDate of slotsToCheck) { const weekday = slotDate.getDay(); - const hour = slotDate.getHours(); + const timeStr = `${String(slotDate.getHours()).padStart(2, '0')}:${String( + slotDate.getMinutes(), + ).padStart(2, '0')}`; const slotStr = formatSlotKey(slotDate); const isCompensation = shouldMarkSlotAsCompensation( slotDate, - currentHour, + currentSlot, nowTs, lastCheckAt, hadPreviousCheck, @@ -292,7 +300,7 @@ class ScheduleService { for (const policy of policies) { if (!policy.enabled) continue; if (!policy.weekdays.includes(weekday)) continue; - if (!policy.hours.includes(hour)) continue; + if (!policy.times?.includes(timeStr)) continue; const slotKey = buildTriggeredSlotKey(inst.id, slotStr); if (triggeredSlots.has(slotKey)) break; @@ -319,8 +327,7 @@ class ScheduleService { slotsModified = true; try { - const timeLabel = `${String(hour).padStart(2, '0')}:00`; - await this.triggerFn(freshInst, policy.name, timeLabel, isCompensation); + await this.triggerFn(freshInst, policy.name, timeStr, isCompensation); } catch (err) { log.error(`[调度器] 触发失败:`, err); } diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index eb3520cd..61a49624 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -26,6 +26,7 @@ import type { OptionDefinition, OptionValue, ProjectInterface, + SchedulePolicy, SelectedTask, } from '@/types/interface'; import type { ConnectionStatus, TaskStatus } from '@/types/maa'; @@ -60,6 +61,25 @@ import { persistRuntimeLogs } from '@/utils/runtimeLogPersistence'; // 从独立模块导入类型和辅助函数 import type { AppState, LogEntry, TaskRunStatus } from './types'; +/** 向后兼容:将旧版整点小时 hours 迁移为 times ("HH:mm") */ +function migrateSchedulePolicies(inst: { + schedulePolicies?: SchedulePolicy[]; +}): SchedulePolicy[] | undefined { + if (!inst.schedulePolicies) return undefined; + return inst.schedulePolicies.map((policy) => { + if (policy.times) { + // 已是新格式,丢弃可能残留的 hours 字段 + const { hours: _legacyHours, ...rest } = policy; + return rest; + } + const times = (policy.hours ?? []) + .map((h) => `${String(h).padStart(2, '0')}:00`) + .sort((a, b) => a.localeCompare(b)); + const { hours: _legacyHours, ...rest } = policy; + return { ...rest, times }; + }); +} + /** 向后兼容:将旧版单个 preAction 迁移为 preActions 数组 */ function migratePreActions(inst: { preActions?: ActionConfig[]; @@ -1074,7 +1094,7 @@ export const useAppStore = create()( savedDevice: inst.savedDevice, selectedTasks: savedTasks, isRunning: prevRunningByInstance.get(inst.id) ?? false, - schedulePolicies: inst.schedulePolicies, + schedulePolicies: migrateSchedulePolicies(inst), preActions: migratePreActions(inst), }; }); @@ -1786,7 +1806,7 @@ export const useAppStore = create()( expanded: false, })), isRunning: false, - schedulePolicies: closedInstance.schedulePolicies, + schedulePolicies: migrateSchedulePolicies(closedInstance), preActions: migratePreActions(closedInstance), }; diff --git a/src/types/config.ts b/src/types/config.ts index a44df316..dd752192 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -11,7 +11,9 @@ export interface SchedulePolicy { name: string; // 策略名称 enabled: boolean; // 是否启用 weekdays: number[]; // 重复日期 (0-6, 0=周日) - hours: number[]; // 开始时间 (0-23) + times: string[]; // 开始时间点 ("HH:mm",已排序去重) + /** @deprecated 旧版整点小时字段 (0-23),仅用于读取旧配置迁移为 times */ + hours?: number[]; } // 保存的任务配置 diff --git a/src/types/interface.ts b/src/types/interface.ts index f4d67a6a..9405abb0 100644 --- a/src/types/interface.ts +++ b/src/types/interface.ts @@ -252,7 +252,9 @@ export interface SchedulePolicy { name: string; // 策略名称 enabled: boolean; // 是否启用 weekdays: number[]; // 重复日期 (0-6, 0=周日) - hours: number[]; // 开始时间 (0-23) + times: string[]; // 开始时间点 ("HH:mm",已排序去重) + /** @deprecated 旧版整点小时字段 (0-23),仅用于读取旧配置迁移为 times */ + hours?: number[]; } // pre-action config From 85825d4ac4fee85b55afac94a0e9f219ece4d993 Mon Sep 17 00:00:00 2001 From: zmdyy0318 Date: Fri, 26 Jun 2026 21:25:33 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stores/appStore.ts | 30 ++++++++++++++---------------- src/types/config.ts | 2 -- src/types/interface.ts | 2 -- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 61a49624..14f66e02 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -61,23 +61,21 @@ import { persistRuntimeLogs } from '@/utils/runtimeLogPersistence'; // 从独立模块导入类型和辅助函数 import type { AppState, LogEntry, TaskRunStatus } from './types'; -/** 向后兼容:将旧版整点小时 hours 迁移为 times ("HH:mm") */ -function migrateSchedulePolicies(inst: { +/** + * 规范化定时策略:仅保留 times(分钟精度)字段,丢弃旧版整点 hours 字段。 + * 不做新旧数据迁移——旧配置中基于 hours 的时间点不会被转换为 times,加载后时间点为空,需用户重新配置。 + */ +function normalizeSchedulePolicies(inst: { schedulePolicies?: SchedulePolicy[]; }): SchedulePolicy[] | undefined { if (!inst.schedulePolicies) return undefined; - return inst.schedulePolicies.map((policy) => { - if (policy.times) { - // 已是新格式,丢弃可能残留的 hours 字段 - const { hours: _legacyHours, ...rest } = policy; - return rest; - } - const times = (policy.hours ?? []) - .map((h) => `${String(h).padStart(2, '0')}:00`) - .sort((a, b) => a.localeCompare(b)); - const { hours: _legacyHours, ...rest } = policy; - return { ...rest, times }; - }); + return inst.schedulePolicies.map((policy) => ({ + id: policy.id, + name: policy.name, + enabled: policy.enabled, + weekdays: policy.weekdays, + times: Array.isArray(policy.times) ? policy.times : [], + })); } /** 向后兼容:将旧版单个 preAction 迁移为 preActions 数组 */ @@ -1094,7 +1092,7 @@ export const useAppStore = create()( savedDevice: inst.savedDevice, selectedTasks: savedTasks, isRunning: prevRunningByInstance.get(inst.id) ?? false, - schedulePolicies: migrateSchedulePolicies(inst), + schedulePolicies: normalizeSchedulePolicies(inst), preActions: migratePreActions(inst), }; }); @@ -1806,7 +1804,7 @@ export const useAppStore = create()( expanded: false, })), isRunning: false, - schedulePolicies: migrateSchedulePolicies(closedInstance), + schedulePolicies: normalizeSchedulePolicies(closedInstance), preActions: migratePreActions(closedInstance), }; diff --git a/src/types/config.ts b/src/types/config.ts index dd752192..7c9e9e00 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -12,8 +12,6 @@ export interface SchedulePolicy { enabled: boolean; // 是否启用 weekdays: number[]; // 重复日期 (0-6, 0=周日) times: string[]; // 开始时间点 ("HH:mm",已排序去重) - /** @deprecated 旧版整点小时字段 (0-23),仅用于读取旧配置迁移为 times */ - hours?: number[]; } // 保存的任务配置 diff --git a/src/types/interface.ts b/src/types/interface.ts index 9405abb0..45ff1a1d 100644 --- a/src/types/interface.ts +++ b/src/types/interface.ts @@ -253,8 +253,6 @@ export interface SchedulePolicy { enabled: boolean; // 是否启用 weekdays: number[]; // 重复日期 (0-6, 0=周日) times: string[]; // 开始时间点 ("HH:mm",已排序去重) - /** @deprecated 旧版整点小时字段 (0-23),仅用于读取旧配置迁移为 times */ - hours?: number[]; } // pre-action config