From ce78a099d6bae254aff04082e79de0670b7b9be5 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Wed, 8 Apr 2026 17:41:00 +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=E9=80=89=E6=8B=A9=E5=AF=B9=E8=AF=9D=E6=A1=86?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E7=BB=A7=E7=BB=AD=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E6=88=96=E9=87=8D=E6=96=B0=E5=BC=80=E5=A7=8B?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++ background.js | 204 ++++++++++++++++++++++++++------------- sidepanel/sidepanel.css | 73 ++++++++++++++ sidepanel/sidepanel.html | 15 +++ sidepanel/sidepanel.js | 108 ++++++++++++++++++--- 5 files changed, 329 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index c831739..84b9467 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,11 @@ Step 3 使用的注册邮箱。 支持多轮运行,运行次数由右上角数字框决定。 +如果当前面板里已经存在未完成进度,点击 `Auto` 时会弹出选择: + +- `重新开始`:重置当前流程进度,从 Step 1 开始新一轮 +- `继续当前`:把 `已完成 / 已跳过` 视为已处理,从第一个未处理步骤继续往后执行 + ## 工作流 ### 单步模式 @@ -161,6 +166,7 @@ Step 3 使用的注册邮箱。 - 如果不能自动获取,Auto 会在邮箱阶段暂停 - Auto 的暂停状态会保存在会话状态中,重新打开侧边栏后仍可继续 - 如果你在 Auto 暂停时改为手动点步骤或跳过步骤,面板会先确认并停止 Auto,再切回手动控制 +- 选择 `继续当前` 时,后台不会先做大而全的前置校验,而是从当前步骤状态直接继续;缺什么条件,就在运行到那一步时再报错或暂停 ## 详细步骤说明 diff --git a/background.js b/background.js index 5d8fc7f..781871a 100644 --- a/background.js +++ b/background.js @@ -573,6 +573,19 @@ function isStepDoneStatus(status) { return status === 'completed' || status === 'manual_completed' || status === 'skipped'; } +function getFirstUnfinishedStep(statuses = {}) { + for (let step = 1; step <= 9; step++) { + if (!isStepDoneStatus(statuses[step] || 'pending')) { + return step; + } + } + return null; +} + +function hasSavedProgress(statuses = {}) { + return Object.values({ ...DEFAULT_STATE.stepStatuses, ...statuses }).some((status) => status !== 'pending'); +} + function clearStopRequest() { stopRequested = false; } @@ -833,8 +846,9 @@ async function handleMessage(message, sender) { clearStopRequest(); const totalRuns = message.payload?.totalRuns || 1; const autoRunSkipFailures = Boolean(message.payload?.autoRunSkipFailures); + const mode = message.payload?.mode === 'continue' ? 'continue' : 'restart'; await setState({ autoRunSkipFailures }); - autoRunLoop(totalRuns, { autoRunSkipFailures }); // fire-and-forget + autoRunLoop(totalRuns, { autoRunSkipFailures, mode }); // fire-and-forget return { ok: true }; } @@ -1120,6 +1134,17 @@ let autoRunActive = false; let autoRunCurrentRun = 0; let autoRunTotalRuns = 1; let autoRunAttemptRun = 0; +const AUTO_STEP_DELAYS = { + 1: 2000, + 2: 2000, + 3: 3000, + 4: 2000, + 5: 3000, + 6: 3000, + 7: 2000, + 8: 2000, + 9: 1000, +}; async function resumeAutoRunIfWaitingForEmail(options = {}) { const { silent = false } = options; @@ -1140,6 +1165,75 @@ async function resumeAutoRunIfWaitingForEmail(options = {}) { return false; } +async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { + const currentState = await getState(); + if (currentState.email) { + return currentState.email; + } + + try { + const duckEmail = await fetchDuckEmail({ generateNew: true }); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:Duck 邮箱已就绪:${duckEmail}(第 ${attemptRuns} 次尝试)===`, 'ok'); + return duckEmail; + } catch (err) { + await addLog(`Duck 邮箱自动获取失败:${err.message}`, 'warn'); + } + + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮已暂停:请先获取 Duck 邮箱或手动粘贴邮箱,然后继续 ===`, 'warn'); + await broadcastAutoRunStatus('waiting_email', { + currentRun: targetRun, + totalRuns, + attemptRun: attemptRuns, + }); + + await waitForResume(); + + const resumedState = await getState(); + if (!resumedState.email) { + throw new Error('无法继续:当前没有邮箱地址。'); + } + return resumedState.email; +} + +async function runAutoSequenceFromStep(startStep, context = {}) { + const { targetRun, totalRuns, attemptRuns, continued = false } = context; + + if (continued) { + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:继续当前进度,从步骤 ${startStep} 开始(第 ${attemptRuns} 次尝试)===`, 'info'); + } else { + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:第 ${attemptRuns} 次尝试,阶段 1,获取 OAuth 链接并打开注册页 ===`, 'info'); + } + + if (startStep <= 2) { + for (const step of [1, 2]) { + if (step < startStep) continue; + await executeStepAndWait(step, AUTO_STEP_DELAYS[step]); + } + } + + if (startStep <= 3) { + await ensureAutoEmailReady(targetRun, totalRuns, attemptRuns); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:阶段 2,注册、验证、登录并完成授权(第 ${attemptRuns} 次尝试)===`, 'info'); + await broadcastAutoRunStatus('running', { + currentRun: targetRun, + totalRuns, + attemptRun: attemptRuns, + }); + await executeStepAndWait(3, AUTO_STEP_DELAYS[3]); + } else { + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:继续执行剩余流程(第 ${attemptRuns} 次尝试)===`, 'info'); + } + + const signupTabId = await getTabId('signup-page'); + if (signupTabId) { + await chrome.tabs.update(signupTabId, { active: true }); + } + + for (let step = Math.max(startStep, 4); step <= 9; step++) { + await executeStepAndWait(step, AUTO_STEP_DELAYS[step]); + } +} + // Outer loop: keep retrying until the target number of successful runs is reached. async function autoRunLoop(totalRuns, options = {}) { if (autoRunActive) { @@ -1153,10 +1247,12 @@ async function autoRunLoop(totalRuns, options = {}) { autoRunCurrentRun = 0; autoRunAttemptRun = 0; const autoRunSkipFailures = Boolean(options.autoRunSkipFailures); + const initialMode = options.mode === 'continue' ? 'continue' : 'restart'; const maxAttempts = autoRunSkipFailures ? Math.max(totalRuns * 10, totalRuns + 20) : totalRuns; let successfulRuns = 0; let attemptRuns = 0; let forceFreshTabsNextRun = false; + let continueCurrentOnFirstAttempt = initialMode === 'continue'; await setState({ autoRunSkipFailures, @@ -1168,32 +1264,51 @@ async function autoRunLoop(totalRuns, options = {}) { const targetRun = successfulRuns + 1; autoRunCurrentRun = targetRun; autoRunAttemptRun = attemptRuns; + let startStep = 1; + let useExistingProgress = false; - // Reset everything at the start of each attempt (keep user settings). - const prevState = await getState(); - const keepSettings = { - vpsUrl: prevState.vpsUrl, - vpsPassword: prevState.vpsPassword, - customPassword: prevState.customPassword, - autoRunSkipFailures: prevState.autoRunSkipFailures, - mailProvider: prevState.mailProvider, - inbucketHost: prevState.inbucketHost, - inbucketMailbox: prevState.inbucketMailbox, - ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun: attemptRuns }), - ...(forceFreshTabsNextRun ? { tabRegistry: {} } : {}), - }; - await resetState(); - await setState(keepSettings); - chrome.runtime.sendMessage({ type: 'AUTO_RUN_RESET' }).catch(() => {}); - await sleepWithStop(500); + if (continueCurrentOnFirstAttempt) { + const currentState = await getState(); + const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses); + if (resumeStep && hasSavedProgress(currentState.stepStatuses)) { + startStep = resumeStep; + useExistingProgress = true; + } else if (hasSavedProgress(currentState.stepStatuses)) { + await addLog('当前流程已全部处理,将按“重新开始”新开一轮自动运行。', 'info'); + } + continueCurrentOnFirstAttempt = false; + } + + if (!useExistingProgress) { + // Reset everything at the start of each fresh attempt (keep user settings). + const prevState = await getState(); + const keepSettings = { + vpsUrl: prevState.vpsUrl, + vpsPassword: prevState.vpsPassword, + customPassword: prevState.customPassword, + autoRunSkipFailures: prevState.autoRunSkipFailures, + mailProvider: prevState.mailProvider, + inbucketHost: prevState.inbucketHost, + inbucketMailbox: prevState.inbucketMailbox, + ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun: attemptRuns }), + ...(forceFreshTabsNextRun ? { tabRegistry: {} } : {}), + }; + await resetState(); + await setState(keepSettings); + chrome.runtime.sendMessage({ type: 'AUTO_RUN_RESET' }).catch(() => {}); + await sleepWithStop(500); + } else { + await setState({ + autoRunSkipFailures, + ...getAutoRunStatusPayload('running', { currentRun: targetRun, totalRuns, attemptRun: attemptRuns }), + }); + } if (forceFreshTabsNextRun) { await addLog(`兜底模式:上一轮已放弃,当前开始第 ${attemptRuns} 次尝试,将使用新线程继续补足第 ${targetRun}/${totalRuns} 轮。`, 'warn'); forceFreshTabsNextRun = false; } - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:第 ${attemptRuns} 次尝试,阶段 1,获取 OAuth 链接并打开注册页 ===`, 'info'); - try { throwIfStopped(); await broadcastAutoRunStatus('running', { @@ -1202,54 +1317,13 @@ async function autoRunLoop(totalRuns, options = {}) { attemptRun: attemptRuns, }); - await executeStepAndWait(1, 2000); - await executeStepAndWait(2, 2000); - - let emailReady = false; - try { - const duckEmail = await fetchDuckEmail({ generateNew: true }); - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:Duck 邮箱已就绪:${duckEmail}(第 ${attemptRuns} 次尝试)===`, 'ok'); - emailReady = true; - } catch (err) { - await addLog(`Duck 邮箱自动获取失败:${err.message}`, 'warn'); - } - - if (!emailReady) { - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮已暂停:请先获取 Duck 邮箱或手动粘贴邮箱,然后继续 ===`, 'warn'); - await broadcastAutoRunStatus('waiting_email', { - currentRun: targetRun, - totalRuns, - attemptRun: attemptRuns, - }); - - await waitForResume(); - - const resumedState = await getState(); - if (!resumedState.email) { - throw new Error('无法继续:当前没有邮箱地址。'); - } - } - - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:阶段 2,注册、验证、登录并完成授权(第 ${attemptRuns} 次尝试)===`, 'info'); - await broadcastAutoRunStatus('running', { - currentRun: targetRun, + await runAutoSequenceFromStep(startStep, { + targetRun, totalRuns, - attemptRun: attemptRuns, + attemptRuns, + continued: useExistingProgress, }); - const signupTabId = await getTabId('signup-page'); - if (signupTabId) { - await chrome.tabs.update(signupTabId, { active: true }); - } - - await executeStepAndWait(3, 3000); - await executeStepAndWait(4, 2000); - await executeStepAndWait(5, 3000); - await executeStepAndWait(6, 3000); - await executeStepAndWait(7, 2000); - await executeStepAndWait(8, 2000); - await executeStepAndWait(9, 1000); - successfulRuns += 1; autoRunCurrentRun = successfulRuns; await addLog(`=== 目标 ${successfulRuns}/${totalRuns} 轮已完成(第 ${attemptRuns} 次尝试成功)===`, 'ok'); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 41907b0..3fb033f 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -677,6 +677,79 @@ header { .log-line { animation: fadeIn 120ms ease-out; } +/* ============================================================ + Modal + ============================================================ */ + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(15, 17, 23, 0.32); + backdrop-filter: blur(2px); +} + +.modal-overlay[hidden] { + display: none !important; +} + +.modal-card { + width: min(100%, 320px); + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow-md), 0 18px 36px rgba(0, 0, 0, 0.18); + padding: 14px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.modal-title { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} + +.modal-close { + width: 26px; + height: 26px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 18px; + line-height: 1; + transition: all var(--transition); +} +.modal-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.modal-message { + font-size: 13px; + line-height: 1.55; + color: var(--text-secondary); + margin-bottom: 14px; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + /* ============================================================ Toast Notifications ============================================================ */ diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 7a5d1ba..49e6b27 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -174,6 +174,21 @@
+ +
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index e9fa40a..827276e 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -36,6 +36,12 @@ 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 autoStartModal = document.getElementById('auto-start-modal'); +const autoStartMessage = document.getElementById('auto-start-message'); +const btnAutoStartClose = document.getElementById('btn-auto-start-close'); +const btnAutoStartCancel = document.getElementById('btn-auto-start-cancel'); +const btnAutoStartRestart = document.getElementById('btn-auto-start-restart'); +const btnAutoStartContinue = document.getElementById('btn-auto-start-continue'); const STEP_DEFAULT_STATUSES = { 1: 'pending', 2: 'pending', @@ -60,6 +66,7 @@ let currentAutoRun = { let settingsDirty = false; let settingsSaveInFlight = false; let settingsAutoSaveTimer = null; +let autoStartChoiceResolver = null; const EYE_OPEN_ICON = ''; const EYE_CLOSED_ICON = ''; @@ -103,6 +110,33 @@ function dismissToast(toast) { toast.addEventListener('animationend', () => toast.remove()); } +function resolveAutoStartChoice(choice) { + if (autoStartChoiceResolver) { + autoStartChoiceResolver(choice); + autoStartChoiceResolver = null; + } + if (autoStartModal) { + autoStartModal.hidden = true; + } +} + +function openAutoStartChoiceDialog(startStep) { + if (!autoStartModal) { + return Promise.resolve('restart'); + } + + if (autoStartChoiceResolver) { + resolveAutoStartChoice(null); + } + + autoStartMessage.textContent = `检测到当前已有流程进度。继续当前会从步骤 ${startStep} 开始自动执行,重新开始会清空当前流程进度并从步骤 1 新开一轮。`; + autoStartModal.hidden = false; + + return new Promise((resolve) => { + autoStartChoiceResolver = resolve; + }); +} + function isDoneStatus(status) { return status === 'completed' || status === 'manual_completed' || status === 'skipped'; } @@ -111,6 +145,25 @@ function getStepStatuses(state = latestState) { return { ...STEP_DEFAULT_STATUSES, ...(state?.stepStatuses || {}) }; } +function getFirstUnfinishedStep(state = latestState) { + const statuses = getStepStatuses(state); + for (let step = 1; step <= 9; step++) { + if (!isDoneStatus(statuses[step])) { + return step; + } + } + return null; +} + +function hasSavedProgress(state = latestState) { + const statuses = getStepStatuses(state); + return Object.values(statuses).some((status) => status !== 'pending'); +} + +function shouldOfferAutoModeChoice(state = latestState) { + return hasSavedProgress(state) && getFirstUnfinishedStep(state) !== null; +} + function syncLatestState(nextState) { const mergedStepStatuses = nextState?.stepStatuses ? { ...STEP_DEFAULT_STATUSES, ...(latestState?.stepStatuses || {}), ...nextState.stepStatuses } @@ -681,20 +734,51 @@ btnStop.addEventListener('click', async () => { showToast('正在停止当前流程...', 'warn', 2000); }); +autoStartModal?.addEventListener('click', (event) => { + if (event.target === autoStartModal) { + resolveAutoStartChoice(null); + } +}); +btnAutoStartClose?.addEventListener('click', () => resolveAutoStartChoice(null)); +btnAutoStartCancel?.addEventListener('click', () => resolveAutoStartChoice(null)); +btnAutoStartRestart?.addEventListener('click', () => resolveAutoStartChoice('restart')); +btnAutoStartContinue?.addEventListener('click', () => resolveAutoStartChoice('continue')); + // Auto Run btnAutoRun.addEventListener('click', async () => { - const totalRuns = parseInt(inputRunCount.value) || 1; - btnAutoRun.disabled = true; - inputRunCount.disabled = true; - btnAutoRun.innerHTML = ' 运行中...'; - await chrome.runtime.sendMessage({ - type: 'AUTO_RUN', - source: 'sidepanel', - payload: { - totalRuns, - autoRunSkipFailures: inputAutoSkipFailures.checked, - }, - }); + try { + const totalRuns = parseInt(inputRunCount.value) || 1; + let mode = 'restart'; + + if (shouldOfferAutoModeChoice()) { + const startStep = getFirstUnfinishedStep(); + const choice = await openAutoStartChoiceDialog(startStep); + if (!choice) { + return; + } + mode = choice; + } + + btnAutoRun.disabled = true; + inputRunCount.disabled = true; + btnAutoRun.innerHTML = ' 运行中...'; + const response = await chrome.runtime.sendMessage({ + type: 'AUTO_RUN', + source: 'sidepanel', + payload: { + totalRuns, + autoRunSkipFailures: inputAutoSkipFailures.checked, + mode, + }, + }); + if (response?.error) { + throw new Error(response.error); + } + } catch (err) { + setDefaultAutoRunButton(); + inputRunCount.disabled = false; + showToast(err.message, 'error'); + } }); btnAutoContinue.addEventListener('click', async () => {