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..14f66e02 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,23 @@ import { persistRuntimeLogs } from '@/utils/runtimeLogPersistence';
// 从独立模块导入类型和辅助函数
import type { AppState, LogEntry, TaskRunStatus } from './types';
+/**
+ * 规范化定时策略:仅保留 times(分钟精度)字段,丢弃旧版整点 hours 字段。
+ * 不做新旧数据迁移——旧配置中基于 hours 的时间点不会被转换为 times,加载后时间点为空,需用户重新配置。
+ */
+function normalizeSchedulePolicies(inst: {
+ schedulePolicies?: SchedulePolicy[];
+}): SchedulePolicy[] | undefined {
+ if (!inst.schedulePolicies) return undefined;
+ 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 数组 */
function migratePreActions(inst: {
preActions?: ActionConfig[];
@@ -1074,7 +1092,7 @@ export const useAppStore = create()(
savedDevice: inst.savedDevice,
selectedTasks: savedTasks,
isRunning: prevRunningByInstance.get(inst.id) ?? false,
- schedulePolicies: inst.schedulePolicies,
+ schedulePolicies: normalizeSchedulePolicies(inst),
preActions: migratePreActions(inst),
};
});
@@ -1786,7 +1804,7 @@ export const useAppStore = create()(
expanded: false,
})),
isRunning: false,
- schedulePolicies: closedInstance.schedulePolicies,
+ schedulePolicies: normalizeSchedulePolicies(closedInstance),
preActions: migratePreActions(closedInstance),
};
diff --git a/src/types/config.ts b/src/types/config.ts
index a44df316..7c9e9e00 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -11,7 +11,7 @@ export interface SchedulePolicy {
name: string; // 策略名称
enabled: boolean; // 是否启用
weekdays: number[]; // 重复日期 (0-6, 0=周日)
- hours: number[]; // 开始时间 (0-23)
+ times: string[]; // 开始时间点 ("HH:mm",已排序去重)
}
// 保存的任务配置
diff --git a/src/types/interface.ts b/src/types/interface.ts
index f4d67a6a..45ff1a1d 100644
--- a/src/types/interface.ts
+++ b/src/types/interface.ts
@@ -252,7 +252,7 @@ export interface SchedulePolicy {
name: string; // 策略名称
enabled: boolean; // 是否启用
weekdays: number[]; // 重复日期 (0-6, 0=周日)
- hours: number[]; // 开始时间 (0-23)
+ times: string[]; // 开始时间点 ("HH:mm",已排序去重)
}
// pre-action config