Ver código fonte

feat: 添加自动运行选择对话框,支持继续当前流程或重新开始,优化用户体验

QLHazyCoder 4 meses atrás
pai
commit
ce78a099d6
5 arquivos alterados com 329 adições e 77 exclusões
  1. 6 0
      README.md
  2. 139 65
      background.js
  3. 73 0
      sidepanel/sidepanel.css
  4. 15 0
      sidepanel/sidepanel.html
  5. 96 12
      sidepanel/sidepanel.js

+ 6 - 0
README.md

@@ -127,6 +127,11 @@ Step 3 使用的注册邮箱。
 
 支持多轮运行,运行次数由右上角数字框决定。
 
+如果当前面板里已经存在未完成进度,点击 `Auto` 时会弹出选择:
+
+- `重新开始`:重置当前流程进度,从 Step 1 开始新一轮
+- `继续当前`:把 `已完成 / 已跳过` 视为已处理,从第一个未处理步骤继续往后执行
+
 ## 工作流
 
 ### 单步模式
@@ -161,6 +166,7 @@ Step 3 使用的注册邮箱。
 - 如果不能自动获取,Auto 会在邮箱阶段暂停
 - Auto 的暂停状态会保存在会话状态中,重新打开侧边栏后仍可继续
 - 如果你在 Auto 暂停时改为手动点步骤或跳过步骤,面板会先确认并停止 Auto,再切回手动控制
+- 选择 `继续当前` 时,后台不会先做大而全的前置校验,而是从当前步骤状态直接继续;缺什么条件,就在运行到那一步时再报错或暂停
 
 ## 详细步骤说明
 

+ 139 - 65
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;
+
+    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;
+    }
 
-    // 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 (!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');

+ 73 - 0
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
    ============================================================ */

+ 15 - 0
sidepanel/sidepanel.html

@@ -174,6 +174,21 @@
     <div id="log-area"></div>
   </section>
 
+  <div id="auto-start-modal" class="modal-overlay" hidden>
+    <div class="modal-card">
+      <div class="modal-header">
+        <span class="modal-title">启动自动</span>
+        <button id="btn-auto-start-close" class="modal-close" type="button" aria-label="关闭">×</button>
+      </div>
+      <p id="auto-start-message" class="modal-message">检测到已有流程进度,选择继续当前还是重新开始。</p>
+      <div class="modal-actions">
+        <button id="btn-auto-start-cancel" class="btn btn-ghost btn-sm" type="button">取消</button>
+        <button id="btn-auto-start-restart" class="btn btn-outline btn-sm" type="button">重新开始</button>
+        <button id="btn-auto-start-continue" class="btn btn-primary btn-sm" type="button">继续当前</button>
+      </div>
+    </div>
+  </div>
+
   <div id="toast-container"></div>
   <script src="sidepanel.js"></script>
 </body>

+ 96 - 12
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 = '<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>';
@@ -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 = '<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> 运行中...';
-  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 = '<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({
+      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 () => {