From 609dd26f0835eda2234b4739cdeff8d19926ad82 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 12 Apr 2026 12:25:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=8C=E6=94=AF=E6=8C=81=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=89=8D=E5=80=92=E8=AE=A1=E6=97=B6=E5=92=8C=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 339 ++++++++++++++++++++++++++++++++++++++- manifest.json | 1 + sidepanel/sidepanel.css | 80 +++++++++ sidepanel/sidepanel.html | 36 +++++ sidepanel/sidepanel.js | 194 ++++++++++++++++++++-- 5 files changed, 632 insertions(+), 18 deletions(-) diff --git a/background.js b/background.js index 3bec528..56761fe 100644 --- a/background.js +++ b/background.js @@ -8,6 +8,9 @@ const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const HUMAN_STEP_DELAY_MIN = 700; const HUMAN_STEP_DELAY_MAX = 2200; 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(); @@ -20,6 +23,8 @@ const PERSISTED_SETTING_DEFAULTS = { vpsPassword: '', // VPS 面板登录密码,可手动填写。 customPassword: '', // 自定义账号密码;留空时由程序自动生成随机密码。 autoRunSkipFailures: false, // 自动运行遇到失败步骤后,是否继续执行后续流程。 + autoRunDelayEnabled: false, // 自动运行是否启用启动前倒计时。 + autoRunDelayMinutes: 30, // 自动运行倒计时分钟数。 mailProvider: '163', // 验证码邮箱来源,当前支持 163 / inbucket。 inbucketHost: '', // 仅当 mailProvider 为 inbucket 时填写 Inbucket 地址,其他情况保持为空。 inbucketMailbox: '', // 仅当 mailProvider 为 inbucket 时填写邮箱名,其他情况保持为空。 @@ -51,14 +56,49 @@ const DEFAULT_STATE = { autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。 autoRunTotalRuns: 1, // 自动运行计划总轮数。 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() { const stored = await chrome.storage.local.get(PERSISTED_SETTING_KEYS); return { ...PERSISTED_SETTING_DEFAULTS, ...stored, 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 = {}; for (const key of PERSISTED_SETTING_KEYS) { 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 totalRuns = payload.totalRuns ?? autoRunTotalRuns; 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 { autoRunning, @@ -1082,18 +1130,26 @@ function getAutoRunStatusPayload(phase, payload = {}) { autoRunCurrentRun: currentRun, autoRunTotalRuns: totalRuns, 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 = { phase, currentRun: payload.currentRun ?? autoRunCurrentRun, totalRuns: payload.totalRuns ?? autoRunTotalRuns, attemptRun: payload.attemptRun ?? autoRunAttemptRun, + scheduledAt: rawScheduledAt === null ? null : Number(rawScheduledAt), }; - await setState(getAutoRunStatusPayload(phase, statusPayload)); + await setState({ + ...extraState, + ...getAutoRunStatusPayload(phase, statusPayload), + }); chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: statusPayload, @@ -1108,6 +1164,202 @@ function isAutoRunPausedState(state) { 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) { const state = await getState(); @@ -1117,6 +1369,9 @@ async function ensureManualInteractionAllowed(actionLabel) { if (isAutoRunPausedState(state)) { throw new Error(`自动流程当前已暂停。请点击“继续”,或先确认接管自动流程后再${actionLabel}。`); } + if (isAutoRunScheduledState(state)) { + throw new Error(`自动流程已计划启动。请先取消计划,或立即开始后再${actionLabel}。`); + } return state; } @@ -1313,6 +1568,7 @@ async function handleMessage(message, sender) { case 'RESET': { clearStopRequest(); + await clearScheduledAutoRunAlarm(); await resetState(); await addLog('流程已重置', 'info'); return { ok: true }; @@ -1337,7 +1593,11 @@ async function handleMessage(message, sender) { case 'AUTO_RUN': { 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 mode = message.payload?.mode === 'continue' ? 'continue' : 'restart'; await setState({ autoRunSkipFailures }); @@ -1345,6 +1605,33 @@ async function handleMessage(message, sender) { 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': { clearStopRequest(); 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.customPassword !== undefined) updates.customPassword = message.payload.customPassword; 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.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost; if (message.payload.inbucketMailbox !== undefined) updates.inbucketMailbox = message.payload.inbucketMailbox; @@ -1516,6 +1805,17 @@ async function markRunningStepsStopped() { async function requestStop(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; stopRequested = true; @@ -3004,3 +3304,28 @@ async function executeStep9(state) { // ============================================================ 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); +}); diff --git a/manifest.json b/manifest.json index 9638b71..14473c3 100644 --- a/manifest.json +++ b/manifest.json @@ -5,6 +5,7 @@ "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", + "alarms", "tabs", "webNavigation", "debugger", diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index d1dea21..7de5e70 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -376,6 +376,35 @@ header { 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 { display: flex; @@ -422,6 +451,12 @@ header { } .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 { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } @@ -441,6 +476,51 @@ header { .auto-continue-bar svg { color: var(--orange); flex-shrink: 0; } .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 ============================================================ */ diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 98a4d9c..3c69dfe 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -96,6 +96,28 @@ 出现错误无法继续时,直接丢弃当前线程并新开一轮,直到补足目标运行次数 +
+ 延迟 +
+ +
+ + 分钟 +
+
+
OAuth 等待中... @@ -114,6 +136,20 @@ 先自动获取 Duck 邮箱,或手动粘贴邮箱后再继续
+
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 9e728f9..029fe98 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -27,6 +27,11 @@ const stepsProgress = document.getElementById('steps-progress'); const btnAutoRun = document.getElementById('btn-auto-run'); const btnAutoContinue = document.getElementById('btn-auto-continue'); 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 inputVpsUrl = document.getElementById('input-vps-url'); 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 inputRunCount = document.getElementById('input-run-count'); 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 autoStartTitle = autoStartModal?.querySelector('.modal-title'); const autoStartMessage = document.getElementById('auto-start-message'); @@ -56,6 +63,9 @@ const STEP_DEFAULT_STATUSES = { 9: 'pending', }; 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 currentAutoRun = { @@ -64,12 +74,14 @@ let currentAutoRun = { currentRun: 0, totalRuns: 1, attemptRun: 0, + scheduledAt: null, }; let settingsDirty = false; let settingsSaveInFlight = false; let settingsAutoSaveTimer = null; let modalChoiceResolver = null; let currentModalActions = []; +let scheduledCountdownTimer = null; const EYE_OPEN_ICON = ''; const EYE_CLOSED_ICON = ''; @@ -240,7 +252,7 @@ function syncAutoRunState(source = {}) { const autoRunning = source.autoRunning !== undefined ? Boolean(source.autoRunning) : (source.autoRunPhase !== undefined || source.phase !== undefined - ? ['running', 'waiting_email', 'retrying'].includes(phase) + ? ['scheduled', 'running', 'waiting_email', 'retrying'].includes(phase) : currentAutoRun.autoRunning); currentAutoRun = { @@ -249,6 +261,7 @@ function syncAutoRunState(source = {}) { currentRun: source.autoRunCurrentRun ?? source.currentRun ?? currentAutoRun.currentRun, totalRuns: source.autoRunTotalRuns ?? source.totalRuns ?? currentAutoRun.totalRuns, 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'; } +function isAutoRunScheduledPhase() { + return currentAutoRun.phase === 'scheduled'; +} + function getAutoRunLabel(payload = currentAutoRun) { + if ((payload.phase ?? currentAutoRun.phase) === 'scheduled') { + return (payload.totalRuns || 1) > 1 ? ` (${payload.totalRuns}轮)` : ''; + } const attemptLabel = payload.attemptRun ? ` · 尝试${payload.attemptRun}` : ''; if ((payload.totalRuns || 1) > 1) { return ` (${payload.currentRun}/${payload.totalRuns}${attemptLabel})`; @@ -268,6 +288,79 @@ function getAutoRunLabel(payload = currentAutoRun) { 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() { btnAutoRun.disabled = false; inputRunCount.disabled = false; @@ -283,6 +376,8 @@ function collectSettingsPayload() { inbucketHost: inputInbucketHost.value.trim(), inbucketMailbox: inputInbucketMailbox.value.trim(), autoRunSkipFailures: inputAutoSkipFailures.checked, + autoRunDelayEnabled: inputAutoDelayEnabled.checked, + autoRunDelayMinutes: normalizeAutoDelayMinutes(inputAutoDelayMinutes.value), }; } @@ -350,13 +445,23 @@ function applyAutoRunStatus(payload = currentAutoRun) { const runLabel = getAutoRunLabel(currentAutoRun); const locked = isAutoRunLockedPhase(); const paused = isAutoRunPausedPhase(); + const scheduled = isAutoRunScheduledPhase(); inputRunCount.disabled = currentAutoRun.autoRunning; btnAutoRun.disabled = currentAutoRun.autoRunning; btnFetchEmail.disabled = locked; inputEmail.disabled = locked; + inputAutoSkipFailures.disabled = scheduled; + + if (currentAutoRun.totalRuns > 0) { + inputRunCount.value = String(currentAutoRun.totalRuns); + } switch (currentAutoRun.phase) { + case 'scheduled': + autoContinueBar.style.display = 'none'; + btnAutoRun.innerHTML = `已计划${runLabel}`; + break; case 'waiting_email': autoContinueBar.style.display = 'flex'; btnAutoRun.innerHTML = `已暂停${runLabel}`; @@ -379,7 +484,9 @@ function applyAutoRunStatus(payload = currentAutoRun) { 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() { @@ -451,6 +558,11 @@ async function restoreState() { inputInbucketMailbox.value = state.inbucketMailbox; } 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) { for (const [step, status] of Object.entries(state.stepStatuses)) { @@ -466,6 +578,7 @@ async function restoreState() { applyAutoRunStatus(state); markSettingsDirty(false); + updateAutoDelayInputState(); updateStatusDisplay(latestState); updateProgressCounter(); updateMailProviderUI(); @@ -518,12 +631,13 @@ function updateButtonStates() { const statuses = getStepStatuses(); const anyRunning = Object.values(statuses).some(s => s === 'running'); const autoLocked = isAutoRunLockedPhase(); + const autoScheduled = isAutoRunScheduledPhase(); for (let step = 1; step <= 9; step++) { const btn = document.querySelector(`.step-btn[data-step="${step}"]`); if (!btn) continue; - if (anyRunning || autoLocked) { + if (anyRunning || autoLocked || autoScheduled) { btn.disabled = true; } else if (step === 1) { btn.disabled = false; @@ -539,7 +653,7 @@ function updateButtonStates() { const currentStatus = statuses[step]; 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.disabled = true; btn.title = '当前不可跳过'; @@ -558,7 +672,8 @@ function updateButtonStates() { btn.title = `跳过步骤 ${step}`; }); - updateStopButtonState(anyRunning || isAutoRunPausedPhase() || autoLocked); + btnReset.disabled = anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked; + updateStopButtonState(anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked); } function updateStopButtonState(active) { @@ -570,6 +685,17 @@ function updateStatusDisplay(state) { 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()) { displayStatus.textContent = `自动已暂停${getAutoRunLabel()},等待邮箱后继续`; statusBar.classList.add('paused'); @@ -818,7 +944,7 @@ btnSaveSettings.addEventListener('click', async () => { btnStop.addEventListener('click', async () => { btnStop.disabled = true; await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} }); - showToast('正在停止当前流程...', 'warn', 2000); + showToast(isAutoRunScheduledPhase() ? '正在取消倒计时计划...' : '正在停止当前流程...', 'warn', 2000); }); autoStartModal?.addEventListener('click', (event) => { @@ -831,7 +957,7 @@ btnAutoStartClose?.addEventListener('click', () => resolveModalChoice(null)); // Auto Run btnAutoRun.addEventListener('click', async () => { try { - const totalRuns = parseInt(inputRunCount.value) || 1; + const totalRuns = Math.min(50, Math.max(1, parseInt(inputRunCount.value, 10) || 1)); let mode = 'restart'; if (shouldOfferAutoModeChoice()) { @@ -845,12 +971,18 @@ btnAutoRun.addEventListener('click', async () => { btnAutoRun.disabled = true; inputRunCount.disabled = true; - btnAutoRun.innerHTML = ' 运行中...'; + const delayEnabled = inputAutoDelayEnabled.checked; + const delayMinutes = normalizeAutoDelayMinutes(inputAutoDelayMinutes.value); + inputAutoDelayMinutes.value = String(delayMinutes); + btnAutoRun.innerHTML = delayEnabled + ? ' 计划中...' + : ' 运行中...'; const response = await chrome.runtime.sendMessage({ - type: 'AUTO_RUN', + type: delayEnabled ? 'SCHEDULE_AUTO_RUN' : 'AUTO_RUN', source: 'sidepanel', payload: { totalRuns, + delayMinutes, autoRunSkipFailures: inputAutoSkipFailures.checked, mode, }, @@ -875,6 +1007,29 @@ btnAutoContinue.addEventListener('click', async () => { 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 btnReset.addEventListener('click', async () => { const confirmed = await openConfirmModal({ @@ -889,7 +1044,7 @@ btnReset.addEventListener('click', async () => { await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' }); 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.classList.remove('has-value'); displayLocalhostUrl.textContent = '等待中...'; @@ -973,6 +1128,21 @@ inputAutoSkipFailures.addEventListener('change', () => { 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 // ============================================================ @@ -1019,6 +1189,7 @@ chrome.runtime.onMessage.addListener((message) => { password: null, stepStatuses: STEP_DEFAULT_STATUSES, logs: [], + scheduledAutoRunAt: null, }); displayOauthUrl.textContent = '等待中...'; displayOauthUrl.classList.remove('has-value'); @@ -1057,11 +1228,12 @@ chrome.runtime.onMessage.addListener((message) => { case 'AUTO_RUN_STATUS': { syncLatestState({ - autoRunning: ['running', 'waiting_email', 'retrying'].includes(message.payload.phase), + autoRunning: ['scheduled', 'running', 'waiting_email', 'retrying'].includes(message.payload.phase), autoRunPhase: message.payload.phase, autoRunCurrentRun: message.payload.currentRun, autoRunTotalRuns: message.payload.totalRuns, autoRunAttemptRun: message.payload.attemptRun, + scheduledAutoRunAt: message.payload.scheduledAt ?? null, }); applyAutoRunStatus(message.payload); updateStatusDisplay(latestState);