Răsfoiți Sursa

feat: 添加自动运行功能及相关界面,支持启动前倒计时和计划管理

QLHazyCoder 4 luni în urmă
părinte
comite
609dd26f08
5 a modificat fișierele cu 632 adăugiri și 18 ștergeri
  1. 332 7
      background.js
  2. 1 0
      manifest.json
  3. 80 0
      sidepanel/sidepanel.css
  4. 36 0
      sidepanel/sidepanel.html
  5. 183 11
      sidepanel/sidepanel.js

+ 332 - 7
background.js

@@ -8,6 +8,9 @@ const STOP_ERROR_MESSAGE = '流程已被用户停止。';
 const HUMAN_STEP_DELAY_MIN = 700;
 const HUMAN_STEP_DELAY_MIN = 700;
 const HUMAN_STEP_DELAY_MAX = 2200;
 const HUMAN_STEP_DELAY_MAX = 2200;
 const STEP7_RESTART_MAX_ROUNDS = 8;
 const STEP7_RESTART_MAX_ROUNDS = 8;
+const AUTO_RUN_ALARM_NAME = 'scheduled-auto-run';
+const AUTO_RUN_DELAY_MIN_MINUTES = 1;
+const AUTO_RUN_DELAY_MAX_MINUTES = 1440;
 
 
 initializeSessionStorageAccess();
 initializeSessionStorageAccess();
 
 
@@ -20,6 +23,8 @@ const PERSISTED_SETTING_DEFAULTS = {
   vpsPassword: '', // VPS 面板登录密码,可手动填写。
   vpsPassword: '', // VPS 面板登录密码,可手动填写。
   customPassword: '', // 自定义账号密码;留空时由程序自动生成随机密码。
   customPassword: '', // 自定义账号密码;留空时由程序自动生成随机密码。
   autoRunSkipFailures: false, // 自动运行遇到失败步骤后,是否继续执行后续流程。
   autoRunSkipFailures: false, // 自动运行遇到失败步骤后,是否继续执行后续流程。
+  autoRunDelayEnabled: false, // 自动运行是否启用启动前倒计时。
+  autoRunDelayMinutes: 30, // 自动运行倒计时分钟数。
   mailProvider: '163', // 验证码邮箱来源,当前支持 163 / inbucket。
   mailProvider: '163', // 验证码邮箱来源,当前支持 163 / inbucket。
   inbucketHost: '', // 仅当 mailProvider 为 inbucket 时填写 Inbucket 地址,其他情况保持为空。
   inbucketHost: '', // 仅当 mailProvider 为 inbucket 时填写 Inbucket 地址,其他情况保持为空。
   inbucketMailbox: '', // 仅当 mailProvider 为 inbucket 时填写邮箱名,其他情况保持为空。
   inbucketMailbox: '', // 仅当 mailProvider 为 inbucket 时填写邮箱名,其他情况保持为空。
@@ -51,14 +56,49 @@ const DEFAULT_STATE = {
   autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。
   autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。
   autoRunTotalRuns: 1, // 自动运行计划总轮数。
   autoRunTotalRuns: 1, // 自动运行计划总轮数。
   autoRunAttemptRun: 0, // 当前轮次的重试序号。
   autoRunAttemptRun: 0, // 当前轮次的重试序号。
+  scheduledAutoRunAt: null, // 自动运行计划启动时间戳。
+  scheduledAutoRunPlan: null, // 自动运行计划参数快照。
 };
 };
 
 
+function normalizeAutoRunDelayMinutes(value) {
+  const numeric = Number(value);
+  if (!Number.isFinite(numeric)) {
+    return PERSISTED_SETTING_DEFAULTS.autoRunDelayMinutes;
+  }
+  return Math.min(
+    AUTO_RUN_DELAY_MAX_MINUTES,
+    Math.max(AUTO_RUN_DELAY_MIN_MINUTES, Math.floor(numeric))
+  );
+}
+
+function normalizeRunCount(value) {
+  const numeric = Number(value);
+  if (!Number.isFinite(numeric)) {
+    return 1;
+  }
+  return Math.min(50, Math.max(1, Math.floor(numeric)));
+}
+
+function normalizeScheduledAutoRunPlan(plan) {
+  if (!plan || typeof plan !== 'object') {
+    return null;
+  }
+
+  return {
+    totalRuns: normalizeRunCount(plan.totalRuns),
+    autoRunSkipFailures: Boolean(plan.autoRunSkipFailures),
+    mode: plan.mode === 'continue' ? 'continue' : 'restart',
+  };
+}
+
 async function getPersistedSettings() {
 async function getPersistedSettings() {
   const stored = await chrome.storage.local.get(PERSISTED_SETTING_KEYS);
   const stored = await chrome.storage.local.get(PERSISTED_SETTING_KEYS);
   return {
   return {
     ...PERSISTED_SETTING_DEFAULTS,
     ...PERSISTED_SETTING_DEFAULTS,
     ...stored,
     ...stored,
     autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures),
     autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures),
+    autoRunDelayEnabled: Boolean(stored.autoRunDelayEnabled ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayEnabled),
+    autoRunDelayMinutes: normalizeAutoRunDelayMinutes(stored.autoRunDelayMinutes ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayMinutes),
   };
   };
 }
 }
 
 
@@ -92,9 +132,13 @@ async function setPersistentSettings(updates) {
   const persistedUpdates = {};
   const persistedUpdates = {};
   for (const key of PERSISTED_SETTING_KEYS) {
   for (const key of PERSISTED_SETTING_KEYS) {
     if (updates[key] !== undefined) {
     if (updates[key] !== undefined) {
-      persistedUpdates[key] = key === 'autoRunSkipFailures'
-        ? Boolean(updates[key])
-        : updates[key];
+      if (key === 'autoRunSkipFailures' || key === 'autoRunDelayEnabled') {
+        persistedUpdates[key] = Boolean(updates[key]);
+      } else if (key === 'autoRunDelayMinutes') {
+        persistedUpdates[key] = normalizeAutoRunDelayMinutes(updates[key]);
+      } else {
+        persistedUpdates[key] = updates[key];
+      }
     }
     }
   }
   }
 
 
@@ -1074,7 +1118,11 @@ function getAutoRunStatusPayload(phase, payload = {}) {
   const currentRun = payload.currentRun ?? autoRunCurrentRun;
   const currentRun = payload.currentRun ?? autoRunCurrentRun;
   const totalRuns = payload.totalRuns ?? autoRunTotalRuns;
   const totalRuns = payload.totalRuns ?? autoRunTotalRuns;
   const attemptRun = payload.attemptRun ?? autoRunAttemptRun;
   const attemptRun = payload.attemptRun ?? autoRunAttemptRun;
-  const autoRunning = phase === 'running' || phase === 'waiting_email' || phase === 'retrying';
+  const rawScheduledAt = phase === 'scheduled'
+    ? (payload.scheduledAt ?? payload.scheduledAutoRunAt ?? null)
+    : null;
+  const scheduledAt = rawScheduledAt === null ? null : Number(rawScheduledAt);
+  const autoRunning = phase === 'scheduled' || phase === 'running' || phase === 'waiting_email' || phase === 'retrying';
 
 
   return {
   return {
     autoRunning,
     autoRunning,
@@ -1082,18 +1130,26 @@ function getAutoRunStatusPayload(phase, payload = {}) {
     autoRunCurrentRun: currentRun,
     autoRunCurrentRun: currentRun,
     autoRunTotalRuns: totalRuns,
     autoRunTotalRuns: totalRuns,
     autoRunAttemptRun: attemptRun,
     autoRunAttemptRun: attemptRun,
+    scheduledAutoRunAt: Number.isFinite(scheduledAt) ? scheduledAt : null,
   };
   };
 }
 }
 
 
-async function broadcastAutoRunStatus(phase, payload = {}) {
+async function broadcastAutoRunStatus(phase, payload = {}, extraState = {}) {
+  const rawScheduledAt = phase === 'scheduled'
+    ? (payload.scheduledAt ?? payload.scheduledAutoRunAt ?? null)
+    : null;
   const statusPayload = {
   const statusPayload = {
     phase,
     phase,
     currentRun: payload.currentRun ?? autoRunCurrentRun,
     currentRun: payload.currentRun ?? autoRunCurrentRun,
     totalRuns: payload.totalRuns ?? autoRunTotalRuns,
     totalRuns: payload.totalRuns ?? autoRunTotalRuns,
     attemptRun: payload.attemptRun ?? autoRunAttemptRun,
     attemptRun: payload.attemptRun ?? autoRunAttemptRun,
+    scheduledAt: rawScheduledAt === null ? null : Number(rawScheduledAt),
   };
   };
 
 
-  await setState(getAutoRunStatusPayload(phase, statusPayload));
+  await setState({
+    ...extraState,
+    ...getAutoRunStatusPayload(phase, statusPayload),
+  });
   chrome.runtime.sendMessage({
   chrome.runtime.sendMessage({
     type: 'AUTO_RUN_STATUS',
     type: 'AUTO_RUN_STATUS',
     payload: statusPayload,
     payload: statusPayload,
@@ -1108,6 +1164,202 @@ function isAutoRunPausedState(state) {
   return Boolean(state.autoRunning) && state.autoRunPhase === 'waiting_email';
   return Boolean(state.autoRunning) && state.autoRunPhase === 'waiting_email';
 }
 }
 
 
+function isAutoRunScheduledState(state) {
+  const scheduledAt = state.scheduledAutoRunAt === null ? null : Number(state.scheduledAutoRunAt);
+  return Boolean(state.autoRunning)
+    && state.autoRunPhase === 'scheduled'
+    && Number.isFinite(scheduledAt)
+    && Boolean(normalizeScheduledAutoRunPlan(state.scheduledAutoRunPlan));
+}
+
+function formatAutoRunScheduleTime(timestamp) {
+  return new Date(timestamp).toLocaleString('zh-CN', {
+    hour12: false,
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+  });
+}
+
+async function ensureScheduledAutoRunAlarm(scheduledAt) {
+  if (!Number.isFinite(scheduledAt) || scheduledAt <= Date.now()) {
+    return false;
+  }
+
+  const existingAlarm = await chrome.alarms.get(AUTO_RUN_ALARM_NAME);
+  if (!existingAlarm || Math.abs((existingAlarm.scheduledTime || 0) - scheduledAt) > 1000) {
+    await chrome.alarms.clear(AUTO_RUN_ALARM_NAME);
+    await chrome.alarms.create(AUTO_RUN_ALARM_NAME, { when: scheduledAt });
+  }
+
+  return true;
+}
+
+async function clearScheduledAutoRunAlarm() {
+  await chrome.alarms.clear(AUTO_RUN_ALARM_NAME);
+}
+
+async function scheduleAutoRun(totalRuns, options = {}) {
+  const state = await getState();
+  if (isAutoRunLockedState(state) || isAutoRunPausedState(state) || autoRunActive) {
+    throw new Error('自动运行已在进行中,请先停止后再重新计划。');
+  }
+  if (isAutoRunScheduledState(state)) {
+    throw new Error('已有自动运行倒计时计划,请先取消或立即开始。');
+  }
+
+  const delayMinutes = normalizeAutoRunDelayMinutes(options.delayMinutes);
+  const plan = normalizeScheduledAutoRunPlan({
+    totalRuns,
+    autoRunSkipFailures: options.autoRunSkipFailures,
+    mode: options.mode,
+  });
+  const scheduledAt = Date.now() + delayMinutes * 60 * 1000;
+
+  autoRunCurrentRun = 0;
+  autoRunTotalRuns = plan.totalRuns;
+  autoRunAttemptRun = 0;
+
+  await ensureScheduledAutoRunAlarm(scheduledAt);
+  await broadcastAutoRunStatus(
+    'scheduled',
+    {
+      currentRun: 0,
+      totalRuns: plan.totalRuns,
+      attemptRun: 0,
+      scheduledAt,
+    },
+    {
+      autoRunSkipFailures: plan.autoRunSkipFailures,
+      scheduledAutoRunPlan: plan,
+    }
+  );
+  await addLog(
+    `自动运行已计划:${delayMinutes} 分钟后启动(${formatAutoRunScheduleTime(scheduledAt)}),目标 ${plan.totalRuns} 轮。`,
+    'info'
+  );
+  return { ok: true, scheduledAt };
+}
+
+let scheduledAutoRunLaunching = false;
+
+async function launchScheduledAutoRun(trigger = 'alarm') {
+  if (scheduledAutoRunLaunching) {
+    return false;
+  }
+
+  scheduledAutoRunLaunching = true;
+  try {
+    const state = await getState();
+    if (!isAutoRunScheduledState(state)) {
+      return false;
+    }
+    if (autoRunActive) {
+      return false;
+    }
+
+    const plan = normalizeScheduledAutoRunPlan(state.scheduledAutoRunPlan);
+    if (!plan) {
+      await clearScheduledAutoRunAlarm();
+      await broadcastAutoRunStatus('idle', {
+        currentRun: 0,
+        totalRuns: 1,
+        attemptRun: 0,
+      }, {
+        scheduledAutoRunPlan: null,
+      });
+      return false;
+    }
+
+    await clearScheduledAutoRunAlarm();
+    await broadcastAutoRunStatus(
+      'running',
+      {
+        currentRun: 0,
+        totalRuns: plan.totalRuns,
+        attemptRun: 0,
+      },
+      {
+        autoRunSkipFailures: plan.autoRunSkipFailures,
+        scheduledAutoRunPlan: null,
+      }
+    );
+
+    clearStopRequest();
+    await addLog(
+      trigger === 'manual'
+        ? '已手动跳过倒计时,自动运行立即开始。'
+        : '倒计时结束,自动运行开始执行。',
+      'info'
+    );
+    autoRunLoop(plan.totalRuns, {
+      autoRunSkipFailures: plan.autoRunSkipFailures,
+      mode: plan.mode,
+    });
+    return true;
+  } finally {
+    scheduledAutoRunLaunching = false;
+  }
+}
+
+async function cancelScheduledAutoRun(options = {}) {
+  const state = await getState();
+  if (!isAutoRunScheduledState(state)) {
+    return false;
+  }
+  const plan = normalizeScheduledAutoRunPlan(state.scheduledAutoRunPlan);
+
+  await clearScheduledAutoRunAlarm();
+  autoRunCurrentRun = 0;
+  autoRunTotalRuns = plan?.totalRuns || 1;
+  autoRunAttemptRun = 0;
+  await broadcastAutoRunStatus(
+    'idle',
+    {
+      currentRun: 0,
+      totalRuns: plan?.totalRuns || 1,
+      attemptRun: 0,
+    },
+    {
+      scheduledAutoRunPlan: null,
+    }
+  );
+  if (options.logMessage !== false) {
+    await addLog(options.logMessage || '已取消自动运行倒计时计划。', 'warn');
+  }
+  return true;
+}
+
+async function restoreScheduledAutoRunIfNeeded() {
+  const state = await getState();
+  if (state.autoRunPhase !== 'scheduled') {
+    return;
+  }
+
+  const plan = normalizeScheduledAutoRunPlan(state.scheduledAutoRunPlan);
+  const scheduledAt = state.scheduledAutoRunAt === null ? null : Number(state.scheduledAutoRunAt);
+  if (!plan || !Number.isFinite(scheduledAt)) {
+    await clearScheduledAutoRunAlarm();
+    await broadcastAutoRunStatus('idle', {
+      currentRun: 0,
+      totalRuns: 1,
+      attemptRun: 0,
+    }, {
+      scheduledAutoRunPlan: null,
+    });
+    return;
+  }
+
+  if (scheduledAt <= Date.now()) {
+    await launchScheduledAutoRun('restore');
+    return;
+  }
+
+  await ensureScheduledAutoRunAlarm(scheduledAt);
+}
+
 async function ensureManualInteractionAllowed(actionLabel) {
 async function ensureManualInteractionAllowed(actionLabel) {
   const state = await getState();
   const state = await getState();
 
 
@@ -1117,6 +1369,9 @@ async function ensureManualInteractionAllowed(actionLabel) {
   if (isAutoRunPausedState(state)) {
   if (isAutoRunPausedState(state)) {
     throw new Error(`自动流程当前已暂停。请点击“继续”,或先确认接管自动流程后再${actionLabel}。`);
     throw new Error(`自动流程当前已暂停。请点击“继续”,或先确认接管自动流程后再${actionLabel}。`);
   }
   }
+  if (isAutoRunScheduledState(state)) {
+    throw new Error(`自动流程已计划启动。请先取消计划,或立即开始后再${actionLabel}。`);
+  }
 
 
   return state;
   return state;
 }
 }
@@ -1313,6 +1568,7 @@ async function handleMessage(message, sender) {
 
 
     case 'RESET': {
     case 'RESET': {
       clearStopRequest();
       clearStopRequest();
+      await clearScheduledAutoRunAlarm();
       await resetState();
       await resetState();
       await addLog('流程已重置', 'info');
       await addLog('流程已重置', 'info');
       return { ok: true };
       return { ok: true };
@@ -1337,7 +1593,11 @@ async function handleMessage(message, sender) {
 
 
     case 'AUTO_RUN': {
     case 'AUTO_RUN': {
       clearStopRequest();
       clearStopRequest();
-      const totalRuns = message.payload?.totalRuns || 1;
+      const state = await getState();
+      if (isAutoRunScheduledState(state)) {
+        throw new Error('已有自动运行倒计时计划,请先取消或立即开始。');
+      }
+      const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1);
       const autoRunSkipFailures = Boolean(message.payload?.autoRunSkipFailures);
       const autoRunSkipFailures = Boolean(message.payload?.autoRunSkipFailures);
       const mode = message.payload?.mode === 'continue' ? 'continue' : 'restart';
       const mode = message.payload?.mode === 'continue' ? 'continue' : 'restart';
       await setState({ autoRunSkipFailures });
       await setState({ autoRunSkipFailures });
@@ -1345,6 +1605,33 @@ async function handleMessage(message, sender) {
       return { ok: true };
       return { ok: true };
     }
     }
 
 
+    case 'SCHEDULE_AUTO_RUN': {
+      clearStopRequest();
+      const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1);
+      return await scheduleAutoRun(totalRuns, {
+        delayMinutes: message.payload?.delayMinutes,
+        autoRunSkipFailures: Boolean(message.payload?.autoRunSkipFailures),
+        mode: message.payload?.mode,
+      });
+    }
+
+    case 'START_SCHEDULED_AUTO_RUN_NOW': {
+      clearStopRequest();
+      const started = await launchScheduledAutoRun('manual');
+      if (!started) {
+        throw new Error('当前没有可立即开始的倒计时计划。');
+      }
+      return { ok: true };
+    }
+
+    case 'CANCEL_SCHEDULED_AUTO_RUN': {
+      const cancelled = await cancelScheduledAutoRun();
+      if (!cancelled) {
+        throw new Error('当前没有可取消的倒计时计划。');
+      }
+      return { ok: true };
+    }
+
     case 'RESUME_AUTO_RUN': {
     case 'RESUME_AUTO_RUN': {
       clearStopRequest();
       clearStopRequest();
       if (message.payload.email) {
       if (message.payload.email) {
@@ -1371,6 +1658,8 @@ async function handleMessage(message, sender) {
       if (message.payload.vpsPassword !== undefined) updates.vpsPassword = message.payload.vpsPassword;
       if (message.payload.vpsPassword !== undefined) updates.vpsPassword = message.payload.vpsPassword;
       if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword;
       if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword;
       if (message.payload.autoRunSkipFailures !== undefined) updates.autoRunSkipFailures = Boolean(message.payload.autoRunSkipFailures);
       if (message.payload.autoRunSkipFailures !== undefined) updates.autoRunSkipFailures = Boolean(message.payload.autoRunSkipFailures);
+      if (message.payload.autoRunDelayEnabled !== undefined) updates.autoRunDelayEnabled = Boolean(message.payload.autoRunDelayEnabled);
+      if (message.payload.autoRunDelayMinutes !== undefined) updates.autoRunDelayMinutes = normalizeAutoRunDelayMinutes(message.payload.autoRunDelayMinutes);
       if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider;
       if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider;
       if (message.payload.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost;
       if (message.payload.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost;
       if (message.payload.inbucketMailbox !== undefined) updates.inbucketMailbox = message.payload.inbucketMailbox;
       if (message.payload.inbucketMailbox !== undefined) updates.inbucketMailbox = message.payload.inbucketMailbox;
@@ -1516,6 +1805,17 @@ async function markRunningStepsStopped() {
 
 
 async function requestStop(options = {}) {
 async function requestStop(options = {}) {
   const { logMessage = '已收到停止请求,正在取消当前操作...' } = options;
   const { logMessage = '已收到停止请求,正在取消当前操作...' } = options;
+  const state = await getState();
+
+  if (isAutoRunScheduledState(state) && !autoRunActive) {
+    await cancelScheduledAutoRun({
+      logMessage: options.logMessage === false
+        ? false
+        : (options.logMessage || '已取消自动运行倒计时计划。'),
+    });
+    return;
+  }
+
   if (stopRequested) return;
   if (stopRequested) return;
 
 
   stopRequested = true;
   stopRequested = true;
@@ -3004,3 +3304,28 @@ async function executeStep9(state) {
 // ============================================================
 // ============================================================
 
 
 chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
 chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
+
+chrome.alarms.onAlarm.addListener((alarm) => {
+  if (alarm.name !== AUTO_RUN_ALARM_NAME) {
+    return;
+  }
+  launchScheduledAutoRun('alarm').catch((err) => {
+    console.error(LOG_PREFIX, 'Failed to launch scheduled auto run from alarm:', err);
+  });
+});
+
+chrome.runtime.onStartup.addListener(() => {
+  restoreScheduledAutoRunIfNeeded().catch((err) => {
+    console.error(LOG_PREFIX, 'Failed to restore scheduled auto run on startup:', err);
+  });
+});
+
+chrome.runtime.onInstalled.addListener(() => {
+  restoreScheduledAutoRunIfNeeded().catch((err) => {
+    console.error(LOG_PREFIX, 'Failed to restore scheduled auto run on install/update:', err);
+  });
+});
+
+restoreScheduledAutoRunIfNeeded().catch((err) => {
+  console.error(LOG_PREFIX, 'Failed to restore scheduled auto run:', err);
+});

+ 1 - 0
manifest.json

@@ -5,6 +5,7 @@
   "description": "用于自动执行多步骤 OAuth 注册流程",
   "description": "用于自动执行多步骤 OAuth 注册流程",
   "permissions": [
   "permissions": [
     "sidePanel",
     "sidePanel",
+    "alarms",
     "tabs",
     "tabs",
     "webNavigation",
     "webNavigation",
     "debugger",
     "debugger",

+ 80 - 0
sidepanel/sidepanel.css

@@ -376,6 +376,35 @@ header {
   flex-shrink: 0;
   flex-shrink: 0;
 }
 }
 
 
+.auto-delay-inline {
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.auto-delay-check {
+  flex: 0 1 auto;
+}
+
+.auto-delay-controls {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+}
+
+.auto-delay-input {
+  width: 72px;
+  flex: 0 0 auto;
+  text-align: center;
+}
+
+.data-unit {
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--text-muted);
+  flex-shrink: 0;
+}
+
 /* Status Bar */
 /* Status Bar */
 .status-bar {
 .status-bar {
   display: flex;
   display: flex;
@@ -422,6 +451,12 @@ header {
 }
 }
 .status-bar.paused { color: var(--orange); }
 .status-bar.paused { color: var(--orange); }
 
 
+.status-bar.scheduled .status-dot {
+  background: var(--blue);
+  animation: pulse 1.5s ease-in-out infinite;
+}
+.status-bar.scheduled { color: var(--blue); }
+
 @keyframes pulse {
 @keyframes pulse {
   0%, 100% { opacity: 1; transform: scale(1); }
   0%, 100% { opacity: 1; transform: scale(1); }
   50% { opacity: 0.4; transform: scale(0.85); }
   50% { opacity: 0.4; transform: scale(0.85); }
@@ -441,6 +476,51 @@ header {
 .auto-continue-bar svg { color: var(--orange); flex-shrink: 0; }
 .auto-continue-bar svg { color: var(--orange); flex-shrink: 0; }
 .auto-hint { font-size: 13px; color: var(--orange); flex: 1; font-weight: 500; }
 .auto-hint { font-size: 13px; color: var(--orange); flex: 1; font-weight: 500; }
 
 
+.auto-schedule-bar {
+  margin-top: 8px;
+  padding: 10px;
+  background: var(--blue-soft);
+  border: 1px solid rgba(37, 99, 235, 0.2);
+  border-radius: var(--radius-sm);
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.auto-schedule-bar svg {
+  color: var(--blue);
+  flex-shrink: 0;
+}
+
+.auto-schedule-copy {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  flex: 1;
+  min-width: 0;
+}
+
+.auto-schedule-title {
+  font-size: 13px;
+  font-weight: 700;
+  color: var(--blue);
+}
+
+.auto-schedule-meta {
+  font-size: 12px;
+  line-height: 1.5;
+  color: var(--text-secondary);
+}
+
+.auto-schedule-actions {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  flex-shrink: 0;
+}
+
 /* ============================================================
 /* ============================================================
    Steps Section
    Steps Section
    ============================================================ */
    ============================================================ */

+ 36 - 0
sidepanel/sidepanel.html

@@ -96,6 +96,28 @@
           <span>出现错误无法继续时,直接丢弃当前线程并新开一轮,直到补足目标运行次数</span>
           <span>出现错误无法继续时,直接丢弃当前线程并新开一轮,直到补足目标运行次数</span>
         </label>
         </label>
       </div>
       </div>
+      <div class="data-row">
+        <span class="data-label">延迟</span>
+        <div class="data-inline auto-delay-inline">
+          <label class="data-check auto-delay-check" for="input-auto-delay-enabled">
+            <input type="checkbox" id="input-auto-delay-enabled" />
+            <span>启动前倒计时</span>
+          </label>
+          <div class="auto-delay-controls">
+            <input
+              type="number"
+              id="input-auto-delay-minutes"
+              class="data-input auto-delay-input"
+              value="30"
+              min="1"
+              max="1440"
+              step="1"
+              title="启动前倒计时分钟数"
+            />
+            <span class="data-unit">分钟</span>
+          </div>
+        </div>
+      </div>
       <div class="data-row">
       <div class="data-row">
         <span class="data-label">OAuth</span>
         <span class="data-label">OAuth</span>
         <span id="display-oauth-url" class="data-value mono truncate">等待中...</span>
         <span id="display-oauth-url" class="data-value mono truncate">等待中...</span>
@@ -114,6 +136,20 @@
       <span class="auto-hint">先自动获取 Duck 邮箱,或手动粘贴邮箱后再继续</span>
       <span class="auto-hint">先自动获取 Duck 邮箱,或手动粘贴邮箱后再继续</span>
       <button id="btn-auto-continue" class="btn btn-primary btn-sm">继续</button>
       <button id="btn-auto-continue" class="btn btn-primary btn-sm">继续</button>
     </div>
     </div>
+    <div id="auto-schedule-bar" class="auto-schedule-bar" style="display:none;">
+      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <circle cx="12" cy="12" r="10"/>
+        <polyline points="12 6 12 12 16 14"/>
+      </svg>
+      <div class="auto-schedule-copy">
+        <span id="auto-schedule-title" class="auto-schedule-title">已计划自动运行</span>
+        <span id="auto-schedule-meta" class="auto-schedule-meta">等待倒计时开始...</span>
+      </div>
+      <div class="auto-schedule-actions">
+        <button id="btn-auto-run-now" class="btn btn-primary btn-sm" type="button">立即开始</button>
+        <button id="btn-auto-cancel-schedule" class="btn btn-outline btn-sm" type="button">取消</button>
+      </div>
+    </div>
   </section>
   </section>
 
 
   <section id="steps-section">
   <section id="steps-section">

+ 183 - 11
sidepanel/sidepanel.js

@@ -27,6 +27,11 @@ const stepsProgress = document.getElementById('steps-progress');
 const btnAutoRun = document.getElementById('btn-auto-run');
 const btnAutoRun = document.getElementById('btn-auto-run');
 const btnAutoContinue = document.getElementById('btn-auto-continue');
 const btnAutoContinue = document.getElementById('btn-auto-continue');
 const autoContinueBar = document.getElementById('auto-continue-bar');
 const autoContinueBar = document.getElementById('auto-continue-bar');
+const autoScheduleBar = document.getElementById('auto-schedule-bar');
+const autoScheduleTitle = document.getElementById('auto-schedule-title');
+const autoScheduleMeta = document.getElementById('auto-schedule-meta');
+const btnAutoRunNow = document.getElementById('btn-auto-run-now');
+const btnAutoCancelSchedule = document.getElementById('btn-auto-cancel-schedule');
 const btnClearLog = document.getElementById('btn-clear-log');
 const btnClearLog = document.getElementById('btn-clear-log');
 const inputVpsUrl = document.getElementById('input-vps-url');
 const inputVpsUrl = document.getElementById('input-vps-url');
 const inputVpsPassword = document.getElementById('input-vps-password');
 const inputVpsPassword = document.getElementById('input-vps-password');
@@ -37,6 +42,8 @@ const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
 const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
 const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
 const inputRunCount = document.getElementById('input-run-count');
 const inputRunCount = document.getElementById('input-run-count');
 const inputAutoSkipFailures = document.getElementById('input-auto-skip-failures');
 const inputAutoSkipFailures = document.getElementById('input-auto-skip-failures');
+const inputAutoDelayEnabled = document.getElementById('input-auto-delay-enabled');
+const inputAutoDelayMinutes = document.getElementById('input-auto-delay-minutes');
 const autoStartModal = document.getElementById('auto-start-modal');
 const autoStartModal = document.getElementById('auto-start-modal');
 const autoStartTitle = autoStartModal?.querySelector('.modal-title');
 const autoStartTitle = autoStartModal?.querySelector('.modal-title');
 const autoStartMessage = document.getElementById('auto-start-message');
 const autoStartMessage = document.getElementById('auto-start-message');
@@ -56,6 +63,9 @@ const STEP_DEFAULT_STATUSES = {
   9: 'pending',
   9: 'pending',
 };
 };
 const SKIPPABLE_STEPS = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9]);
 const SKIPPABLE_STEPS = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9]);
+const AUTO_DELAY_MIN_MINUTES = 1;
+const AUTO_DELAY_MAX_MINUTES = 1440;
+const AUTO_DELAY_DEFAULT_MINUTES = 30;
 
 
 let latestState = null;
 let latestState = null;
 let currentAutoRun = {
 let currentAutoRun = {
@@ -64,12 +74,14 @@ let currentAutoRun = {
   currentRun: 0,
   currentRun: 0,
   totalRuns: 1,
   totalRuns: 1,
   attemptRun: 0,
   attemptRun: 0,
+  scheduledAt: null,
 };
 };
 let settingsDirty = false;
 let settingsDirty = false;
 let settingsSaveInFlight = false;
 let settingsSaveInFlight = false;
 let settingsAutoSaveTimer = null;
 let settingsAutoSaveTimer = null;
 let modalChoiceResolver = null;
 let modalChoiceResolver = null;
 let currentModalActions = [];
 let currentModalActions = [];
+let scheduledCountdownTimer = null;
 
 
 const EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
 const EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
 const EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
 const EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
@@ -240,7 +252,7 @@ function syncAutoRunState(source = {}) {
   const autoRunning = source.autoRunning !== undefined
   const autoRunning = source.autoRunning !== undefined
     ? Boolean(source.autoRunning)
     ? Boolean(source.autoRunning)
     : (source.autoRunPhase !== undefined || source.phase !== undefined
     : (source.autoRunPhase !== undefined || source.phase !== undefined
-      ? ['running', 'waiting_email', 'retrying'].includes(phase)
+      ? ['scheduled', 'running', 'waiting_email', 'retrying'].includes(phase)
       : currentAutoRun.autoRunning);
       : currentAutoRun.autoRunning);
 
 
   currentAutoRun = {
   currentAutoRun = {
@@ -249,6 +261,7 @@ function syncAutoRunState(source = {}) {
     currentRun: source.autoRunCurrentRun ?? source.currentRun ?? currentAutoRun.currentRun,
     currentRun: source.autoRunCurrentRun ?? source.currentRun ?? currentAutoRun.currentRun,
     totalRuns: source.autoRunTotalRuns ?? source.totalRuns ?? currentAutoRun.totalRuns,
     totalRuns: source.autoRunTotalRuns ?? source.totalRuns ?? currentAutoRun.totalRuns,
     attemptRun: source.autoRunAttemptRun ?? source.attemptRun ?? currentAutoRun.attemptRun,
     attemptRun: source.autoRunAttemptRun ?? source.attemptRun ?? currentAutoRun.attemptRun,
+    scheduledAt: source.scheduledAutoRunAt ?? source.scheduledAt ?? currentAutoRun.scheduledAt,
   };
   };
 }
 }
 
 
@@ -260,7 +273,14 @@ function isAutoRunPausedPhase() {
   return currentAutoRun.phase === 'waiting_email';
   return currentAutoRun.phase === 'waiting_email';
 }
 }
 
 
+function isAutoRunScheduledPhase() {
+  return currentAutoRun.phase === 'scheduled';
+}
+
 function getAutoRunLabel(payload = currentAutoRun) {
 function getAutoRunLabel(payload = currentAutoRun) {
+  if ((payload.phase ?? currentAutoRun.phase) === 'scheduled') {
+    return (payload.totalRuns || 1) > 1 ? ` (${payload.totalRuns}轮)` : '';
+  }
   const attemptLabel = payload.attemptRun ? ` · 尝试${payload.attemptRun}` : '';
   const attemptLabel = payload.attemptRun ? ` · 尝试${payload.attemptRun}` : '';
   if ((payload.totalRuns || 1) > 1) {
   if ((payload.totalRuns || 1) > 1) {
     return ` (${payload.currentRun}/${payload.totalRuns}${attemptLabel})`;
     return ` (${payload.currentRun}/${payload.totalRuns}${attemptLabel})`;
@@ -268,6 +288,79 @@ function getAutoRunLabel(payload = currentAutoRun) {
   return attemptLabel ? ` (${attemptLabel.slice(3)})` : '';
   return attemptLabel ? ` (${attemptLabel.slice(3)})` : '';
 }
 }
 
 
+function normalizeAutoDelayMinutes(value) {
+  const numeric = Number(value);
+  if (!Number.isFinite(numeric)) {
+    return AUTO_DELAY_DEFAULT_MINUTES;
+  }
+  return Math.min(AUTO_DELAY_MAX_MINUTES, Math.max(AUTO_DELAY_MIN_MINUTES, Math.floor(numeric)));
+}
+
+function updateAutoDelayInputState() {
+  const scheduled = isAutoRunScheduledPhase();
+  inputAutoDelayEnabled.disabled = scheduled;
+  inputAutoDelayMinutes.disabled = scheduled || !inputAutoDelayEnabled.checked;
+}
+
+function formatCountdown(remainingMs) {
+  const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
+  const hours = Math.floor(totalSeconds / 3600);
+  const minutes = Math.floor((totalSeconds % 3600) / 60);
+  const seconds = totalSeconds % 60;
+  return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+}
+
+function formatScheduleTime(timestamp) {
+  return new Date(timestamp).toLocaleString('zh-CN', {
+    hour12: false,
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+  });
+}
+
+function stopScheduledCountdownTicker() {
+  clearInterval(scheduledCountdownTimer);
+  scheduledCountdownTimer = null;
+}
+
+function renderScheduledAutoRunInfo() {
+  if (!autoScheduleBar) {
+    return;
+  }
+
+  if (!isAutoRunScheduledPhase() || !Number.isFinite(currentAutoRun.scheduledAt)) {
+    autoScheduleBar.style.display = 'none';
+    return;
+  }
+
+  const remainingMs = currentAutoRun.scheduledAt - Date.now();
+  autoScheduleBar.style.display = 'flex';
+  autoScheduleTitle.textContent = '已计划自动运行';
+  autoScheduleMeta.textContent = remainingMs > 0
+    ? `计划于 ${formatScheduleTime(currentAutoRun.scheduledAt)} 开始,剩余 ${formatCountdown(remainingMs)}`
+    : '倒计时即将结束,正在准备启动...';
+}
+
+function syncScheduledCountdownTicker() {
+  renderScheduledAutoRunInfo();
+  if (!isAutoRunScheduledPhase() || !Number.isFinite(currentAutoRun.scheduledAt)) {
+    stopScheduledCountdownTicker();
+    return;
+  }
+
+  if (scheduledCountdownTimer) {
+    return;
+  }
+
+  scheduledCountdownTimer = setInterval(() => {
+    renderScheduledAutoRunInfo();
+    updateStatusDisplay(latestState);
+  }, 1000);
+}
+
 function setDefaultAutoRunButton() {
 function setDefaultAutoRunButton() {
   btnAutoRun.disabled = false;
   btnAutoRun.disabled = false;
   inputRunCount.disabled = false;
   inputRunCount.disabled = false;
@@ -283,6 +376,8 @@ function collectSettingsPayload() {
     inbucketHost: inputInbucketHost.value.trim(),
     inbucketHost: inputInbucketHost.value.trim(),
     inbucketMailbox: inputInbucketMailbox.value.trim(),
     inbucketMailbox: inputInbucketMailbox.value.trim(),
     autoRunSkipFailures: inputAutoSkipFailures.checked,
     autoRunSkipFailures: inputAutoSkipFailures.checked,
+    autoRunDelayEnabled: inputAutoDelayEnabled.checked,
+    autoRunDelayMinutes: normalizeAutoDelayMinutes(inputAutoDelayMinutes.value),
   };
   };
 }
 }
 
 
@@ -350,13 +445,23 @@ function applyAutoRunStatus(payload = currentAutoRun) {
   const runLabel = getAutoRunLabel(currentAutoRun);
   const runLabel = getAutoRunLabel(currentAutoRun);
   const locked = isAutoRunLockedPhase();
   const locked = isAutoRunLockedPhase();
   const paused = isAutoRunPausedPhase();
   const paused = isAutoRunPausedPhase();
+  const scheduled = isAutoRunScheduledPhase();
 
 
   inputRunCount.disabled = currentAutoRun.autoRunning;
   inputRunCount.disabled = currentAutoRun.autoRunning;
   btnAutoRun.disabled = currentAutoRun.autoRunning;
   btnAutoRun.disabled = currentAutoRun.autoRunning;
   btnFetchEmail.disabled = locked;
   btnFetchEmail.disabled = locked;
   inputEmail.disabled = locked;
   inputEmail.disabled = locked;
+  inputAutoSkipFailures.disabled = scheduled;
+
+  if (currentAutoRun.totalRuns > 0) {
+    inputRunCount.value = String(currentAutoRun.totalRuns);
+  }
 
 
   switch (currentAutoRun.phase) {
   switch (currentAutoRun.phase) {
+    case 'scheduled':
+      autoContinueBar.style.display = 'none';
+      btnAutoRun.innerHTML = `已计划${runLabel}`;
+      break;
     case 'waiting_email':
     case 'waiting_email':
       autoContinueBar.style.display = 'flex';
       autoContinueBar.style.display = 'flex';
       btnAutoRun.innerHTML = `已暂停${runLabel}`;
       btnAutoRun.innerHTML = `已暂停${runLabel}`;
@@ -379,7 +484,9 @@ function applyAutoRunStatus(payload = currentAutoRun) {
       break;
       break;
   }
   }
 
 
-  updateStopButtonState(paused || locked || Object.values(getStepStatuses()).some(status => status === 'running'));
+  updateAutoDelayInputState();
+  syncScheduledCountdownTicker();
+  updateStopButtonState(scheduled || paused || locked || Object.values(getStepStatuses()).some(status => status === 'running'));
 }
 }
 
 
 function initializeManualStepActions() {
 function initializeManualStepActions() {
@@ -451,6 +558,11 @@ async function restoreState() {
       inputInbucketMailbox.value = state.inbucketMailbox;
       inputInbucketMailbox.value = state.inbucketMailbox;
     }
     }
     inputAutoSkipFailures.checked = Boolean(state.autoRunSkipFailures);
     inputAutoSkipFailures.checked = Boolean(state.autoRunSkipFailures);
+    inputAutoDelayEnabled.checked = Boolean(state.autoRunDelayEnabled);
+    inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state.autoRunDelayMinutes));
+    if (state.autoRunTotalRuns) {
+      inputRunCount.value = String(state.autoRunTotalRuns);
+    }
 
 
     if (state.stepStatuses) {
     if (state.stepStatuses) {
       for (const [step, status] of Object.entries(state.stepStatuses)) {
       for (const [step, status] of Object.entries(state.stepStatuses)) {
@@ -466,6 +578,7 @@ async function restoreState() {
 
 
     applyAutoRunStatus(state);
     applyAutoRunStatus(state);
     markSettingsDirty(false);
     markSettingsDirty(false);
+    updateAutoDelayInputState();
     updateStatusDisplay(latestState);
     updateStatusDisplay(latestState);
     updateProgressCounter();
     updateProgressCounter();
     updateMailProviderUI();
     updateMailProviderUI();
@@ -518,12 +631,13 @@ function updateButtonStates() {
   const statuses = getStepStatuses();
   const statuses = getStepStatuses();
   const anyRunning = Object.values(statuses).some(s => s === 'running');
   const anyRunning = Object.values(statuses).some(s => s === 'running');
   const autoLocked = isAutoRunLockedPhase();
   const autoLocked = isAutoRunLockedPhase();
+  const autoScheduled = isAutoRunScheduledPhase();
 
 
   for (let step = 1; step <= 9; step++) {
   for (let step = 1; step <= 9; step++) {
     const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
     const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
     if (!btn) continue;
     if (!btn) continue;
 
 
-    if (anyRunning || autoLocked) {
+    if (anyRunning || autoLocked || autoScheduled) {
       btn.disabled = true;
       btn.disabled = true;
     } else if (step === 1) {
     } else if (step === 1) {
       btn.disabled = false;
       btn.disabled = false;
@@ -539,7 +653,7 @@ function updateButtonStates() {
     const currentStatus = statuses[step];
     const currentStatus = statuses[step];
     const prevStatus = statuses[step - 1];
     const prevStatus = statuses[step - 1];
 
 
-    if (!SKIPPABLE_STEPS.has(step) || anyRunning || autoLocked || currentStatus === 'running' || isDoneStatus(currentStatus)) {
+    if (!SKIPPABLE_STEPS.has(step) || anyRunning || autoLocked || autoScheduled || currentStatus === 'running' || isDoneStatus(currentStatus)) {
       btn.style.display = 'none';
       btn.style.display = 'none';
       btn.disabled = true;
       btn.disabled = true;
       btn.title = '当前不可跳过';
       btn.title = '当前不可跳过';
@@ -558,7 +672,8 @@ function updateButtonStates() {
     btn.title = `跳过步骤 ${step}`;
     btn.title = `跳过步骤 ${step}`;
   });
   });
 
 
-  updateStopButtonState(anyRunning || isAutoRunPausedPhase() || autoLocked);
+  btnReset.disabled = anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked;
+  updateStopButtonState(anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked);
 }
 }
 
 
 function updateStopButtonState(active) {
 function updateStopButtonState(active) {
@@ -570,6 +685,17 @@ function updateStatusDisplay(state) {
 
 
   statusBar.className = 'status-bar';
   statusBar.className = 'status-bar';
 
 
+  if (isAutoRunScheduledPhase()) {
+    const remainingMs = Number.isFinite(currentAutoRun.scheduledAt)
+      ? currentAutoRun.scheduledAt - Date.now()
+      : 0;
+    displayStatus.textContent = remainingMs > 0
+      ? `自动计划中,剩余 ${formatCountdown(remainingMs)}`
+      : '倒计时即将结束,正在准备启动...';
+    statusBar.classList.add('scheduled');
+    return;
+  }
+
   if (isAutoRunPausedPhase()) {
   if (isAutoRunPausedPhase()) {
     displayStatus.textContent = `自动已暂停${getAutoRunLabel()},等待邮箱后继续`;
     displayStatus.textContent = `自动已暂停${getAutoRunLabel()},等待邮箱后继续`;
     statusBar.classList.add('paused');
     statusBar.classList.add('paused');
@@ -818,7 +944,7 @@ btnSaveSettings.addEventListener('click', async () => {
 btnStop.addEventListener('click', async () => {
 btnStop.addEventListener('click', async () => {
   btnStop.disabled = true;
   btnStop.disabled = true;
   await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} });
   await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} });
-  showToast('正在停止当前流程...', 'warn', 2000);
+  showToast(isAutoRunScheduledPhase() ? '正在取消倒计时计划...' : '正在停止当前流程...', 'warn', 2000);
 });
 });
 
 
 autoStartModal?.addEventListener('click', (event) => {
 autoStartModal?.addEventListener('click', (event) => {
@@ -831,7 +957,7 @@ btnAutoStartClose?.addEventListener('click', () => resolveModalChoice(null));
 // Auto Run
 // Auto Run
 btnAutoRun.addEventListener('click', async () => {
 btnAutoRun.addEventListener('click', async () => {
   try {
   try {
-    const totalRuns = parseInt(inputRunCount.value) || 1;
+    const totalRuns = Math.min(50, Math.max(1, parseInt(inputRunCount.value, 10) || 1));
     let mode = 'restart';
     let mode = 'restart';
 
 
     if (shouldOfferAutoModeChoice()) {
     if (shouldOfferAutoModeChoice()) {
@@ -845,12 +971,18 @@ btnAutoRun.addEventListener('click', async () => {
 
 
     btnAutoRun.disabled = true;
     btnAutoRun.disabled = true;
     inputRunCount.disabled = true;
     inputRunCount.disabled = true;
-    btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 运行中...';
+    const delayEnabled = inputAutoDelayEnabled.checked;
+    const delayMinutes = normalizeAutoDelayMinutes(inputAutoDelayMinutes.value);
+    inputAutoDelayMinutes.value = String(delayMinutes);
+    btnAutoRun.innerHTML = delayEnabled
+      ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 计划中...'
+      : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 运行中...';
     const response = await chrome.runtime.sendMessage({
     const response = await chrome.runtime.sendMessage({
-      type: 'AUTO_RUN',
+      type: delayEnabled ? 'SCHEDULE_AUTO_RUN' : 'AUTO_RUN',
       source: 'sidepanel',
       source: 'sidepanel',
       payload: {
       payload: {
         totalRuns,
         totalRuns,
+        delayMinutes,
         autoRunSkipFailures: inputAutoSkipFailures.checked,
         autoRunSkipFailures: inputAutoSkipFailures.checked,
         mode,
         mode,
       },
       },
@@ -875,6 +1007,29 @@ btnAutoContinue.addEventListener('click', async () => {
   await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } });
   await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } });
 });
 });
 
 
+btnAutoRunNow?.addEventListener('click', async () => {
+  try {
+    btnAutoRunNow.disabled = true;
+    await chrome.runtime.sendMessage({ type: 'START_SCHEDULED_AUTO_RUN_NOW', source: 'sidepanel', payload: {} });
+  } catch (err) {
+    showToast(err.message, 'error');
+  } finally {
+    btnAutoRunNow.disabled = false;
+  }
+});
+
+btnAutoCancelSchedule?.addEventListener('click', async () => {
+  try {
+    btnAutoCancelSchedule.disabled = true;
+    await chrome.runtime.sendMessage({ type: 'CANCEL_SCHEDULED_AUTO_RUN', source: 'sidepanel', payload: {} });
+    showToast('已取消倒计时计划。', 'info', 1800);
+  } catch (err) {
+    showToast(err.message, 'error');
+  } finally {
+    btnAutoCancelSchedule.disabled = false;
+  }
+});
+
 // Reset
 // Reset
 btnReset.addEventListener('click', async () => {
 btnReset.addEventListener('click', async () => {
   const confirmed = await openConfirmModal({
   const confirmed = await openConfirmModal({
@@ -889,7 +1044,7 @@ btnReset.addEventListener('click', async () => {
 
 
   await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
   await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
   syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES });
   syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES });
-  syncAutoRunState({ autoRunning: false, autoRunPhase: 'idle', autoRunCurrentRun: 0, autoRunTotalRuns: 1, autoRunAttemptRun: 0 });
+  syncAutoRunState({ autoRunning: false, autoRunPhase: 'idle', autoRunCurrentRun: 0, autoRunTotalRuns: 1, autoRunAttemptRun: 0, scheduledAutoRunAt: null });
   displayOauthUrl.textContent = '等待中...';
   displayOauthUrl.textContent = '等待中...';
   displayOauthUrl.classList.remove('has-value');
   displayOauthUrl.classList.remove('has-value');
   displayLocalhostUrl.textContent = '等待中...';
   displayLocalhostUrl.textContent = '等待中...';
@@ -973,6 +1128,21 @@ inputAutoSkipFailures.addEventListener('change', () => {
   saveSettings({ silent: true }).catch(() => { });
   saveSettings({ silent: true }).catch(() => { });
 });
 });
 
 
+inputAutoDelayEnabled.addEventListener('change', () => {
+  updateAutoDelayInputState();
+  markSettingsDirty(true);
+  saveSettings({ silent: true }).catch(() => { });
+});
+
+inputAutoDelayMinutes.addEventListener('input', () => {
+  markSettingsDirty(true);
+  scheduleSettingsAutoSave();
+});
+inputAutoDelayMinutes.addEventListener('blur', () => {
+  inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(inputAutoDelayMinutes.value));
+  saveSettings({ silent: true }).catch(() => { });
+});
+
 // ============================================================
 // ============================================================
 // Listen for Background broadcasts
 // Listen for Background broadcasts
 // ============================================================
 // ============================================================
@@ -1019,6 +1189,7 @@ chrome.runtime.onMessage.addListener((message) => {
         password: null,
         password: null,
         stepStatuses: STEP_DEFAULT_STATUSES,
         stepStatuses: STEP_DEFAULT_STATUSES,
         logs: [],
         logs: [],
+        scheduledAutoRunAt: null,
       });
       });
       displayOauthUrl.textContent = '等待中...';
       displayOauthUrl.textContent = '等待中...';
       displayOauthUrl.classList.remove('has-value');
       displayOauthUrl.classList.remove('has-value');
@@ -1057,11 +1228,12 @@ chrome.runtime.onMessage.addListener((message) => {
 
 
     case 'AUTO_RUN_STATUS': {
     case 'AUTO_RUN_STATUS': {
       syncLatestState({
       syncLatestState({
-        autoRunning: ['running', 'waiting_email', 'retrying'].includes(message.payload.phase),
+        autoRunning: ['scheduled', 'running', 'waiting_email', 'retrying'].includes(message.payload.phase),
         autoRunPhase: message.payload.phase,
         autoRunPhase: message.payload.phase,
         autoRunCurrentRun: message.payload.currentRun,
         autoRunCurrentRun: message.payload.currentRun,
         autoRunTotalRuns: message.payload.totalRuns,
         autoRunTotalRuns: message.payload.totalRuns,
         autoRunAttemptRun: message.payload.attemptRun,
         autoRunAttemptRun: message.payload.attemptRun,
+        scheduledAutoRunAt: message.payload.scheduledAt ?? null,
       });
       });
       applyAutoRunStatus(message.payload);
       applyAutoRunStatus(message.payload);
       updateStatusDisplay(latestState);
       updateStatusDisplay(latestState);