Quellcode durchsuchen

feat: add Auto Run mode for full automated flow

- Background: add step completion waiting (promise-based), autoRun/resumeAutoRun
  chains steps 1→2 (pause for email) → 3→4→5→6→7→8→9 automatically
- Side Panel: add "Auto Run" button and "Continue Auto" bar for email pause point
- signup-page.js: add step 8 handler to click "继续" on OAuth consent page
- Fix all steps to reportComplete before form submit (prevents connection loss on navigation)
- qq-mail.js: fallback after 3 attempts to first matching email
- vps-panel.js: step 9 waits for "认证成功!" status badge
unknown vor 4 Monaten
Ursprung
Commit
1c1e589bdc
7 geänderte Dateien mit 736 neuen und 424 gelöschten Zeilen
  1. 197 30
      background.js
  2. 102 188
      content/qq-mail.js
  3. 188 72
      content/signup-page.js
  4. 93 130
      content/vps-panel.js
  5. 78 2
      sidepanel/sidepanel.css
  6. 8 1
      sidepanel/sidepanel.html
  7. 70 1
      sidepanel/sidepanel.js

+ 197 - 30
background.js

@@ -200,14 +200,15 @@ async function handleMessage(message, sender) {
     case 'STEP_COMPLETE': {
       await setStepStatus(message.step, 'completed');
       await addLog(`Step ${message.step} completed`, 'ok');
-      // Store step-specific data
       await handleStepData(message.step, message.payload);
+      notifyStepComplete(message.step, message.payload);
       return { ok: true };
     }
 
     case 'STEP_ERROR': {
       await setStepStatus(message.step, 'failed');
       await addLog(`Step ${message.step} failed: ${message.error}`, 'error');
+      notifyStepError(message.step, message.error);
       return { ok: true };
     }
 
@@ -231,6 +232,19 @@ async function handleMessage(message, sender) {
       return { ok: true };
     }
 
+    case 'AUTO_RUN': {
+      autoRun();  // fire-and-forget, runs in background
+      return { ok: true };
+    }
+
+    case 'RESUME_AUTO_RUN': {
+      if (message.payload.email) {
+        await setState({ email: message.payload.email });
+      }
+      resumeAutoRun();  // fire-and-forget
+      return { ok: true };
+    }
+
     // Side panel data updates
     case 'SAVE_EMAIL': {
       await setState({ email: message.payload.email });
@@ -277,6 +291,37 @@ async function handleStepData(step, payload) {
   }
 }
 
+// ============================================================
+// Step Completion Waiting
+// ============================================================
+
+// Map of step -> { resolve, reject } for waiting on step completion
+const stepWaiters = new Map();
+
+function waitForStepComplete(step, timeoutMs = 120000) {
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => {
+      stepWaiters.delete(step);
+      reject(new Error(`Step ${step} timed out after ${timeoutMs / 1000}s`));
+    }, timeoutMs);
+
+    stepWaiters.set(step, {
+      resolve: (data) => { clearTimeout(timer); stepWaiters.delete(step); resolve(data); },
+      reject: (err) => { clearTimeout(timer); stepWaiters.delete(step); reject(err); },
+    });
+  });
+}
+
+function notifyStepComplete(step, payload) {
+  const waiter = stepWaiters.get(step);
+  if (waiter) waiter.resolve(payload);
+}
+
+function notifyStepError(step, error) {
+  const waiter = stepWaiters.get(step);
+  if (waiter) waiter.reject(new Error(error));
+}
+
 // ============================================================
 // Step Execution
 // ============================================================
@@ -313,11 +358,104 @@ async function executeStep(step) {
   }
 }
 
+/**
+ * Execute a step and wait for it to complete before returning.
+ * @param {number} step
+ * @param {number} delayAfter - ms to wait after completion (for page transitions)
+ */
+async function executeStepAndWait(step, delayAfter = 2000) {
+  const promise = waitForStepComplete(step, 120000);
+  await executeStep(step);
+  await promise;
+  // Extra delay for page transitions / DOM updates
+  if (delayAfter > 0) {
+    await new Promise(r => setTimeout(r, delayAfter));
+  }
+}
+
+// ============================================================
+// Auto Run Flow
+// ============================================================
+
+let autoRunActive = false;
+
+async function autoRun() {
+  if (autoRunActive) {
+    await addLog('Auto run already in progress', 'warn');
+    return;
+  }
+
+  autoRunActive = true;
+  await setState({ autoRunning: true });
+  chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'running' } }).catch(() => {});
+
+  try {
+    // Phase 1: Steps 1-2 (get OAuth link, open signup)
+    await addLog('=== Auto Run Phase 1: Get OAuth link & open signup ===', 'info');
+    await executeStepAndWait(1, 2000);
+    await executeStepAndWait(2, 2000);
+
+    // Pause: ask user to generate DuckDuckGo email
+    await addLog('=== Auto Run PAUSED: Please paste DuckDuckGo email and click "Continue Auto" ===', 'warn');
+    chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'waiting_email' } }).catch(() => {});
+
+    // Wait here — resumed by RESUME_AUTO_RUN message from side panel
+
+  } catch (err) {
+    await addLog(`Auto run failed at Phase 1: ${err.message}`, 'error');
+    autoRunActive = false;
+    await setState({ autoRunning: false });
+    chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'stopped' } }).catch(() => {});
+  }
+}
+
+async function resumeAutoRun() {
+  try {
+    const state = await getState();
+    if (!state.email) {
+      await addLog('Cannot resume: no email address. Paste email in Side Panel first.', 'error');
+      return;
+    }
+
+    // Phase 2: Steps 3-9 (fill form, get codes, login, OAuth, verify)
+    await addLog('=== Auto Run Phase 2: Register, verify, login, complete OAuth ===', 'info');
+
+    await executeStepAndWait(3, 3000);  // Fill email/password → page navigates to code input
+    await executeStepAndWait(4, 2000);  // Get signup code from QQ Mail → fill in
+    await executeStepAndWait(5, 3000);  // Fill name/birthday → page navigates to add-phone
+    await executeStepAndWait(6, 3000);  // Login via OAuth URL → fill email/password
+    await executeStepAndWait(7, 2000);  // Get login code from QQ Mail → fill in
+    await executeStepAndWait(8, 2000);  // Click "继续" → localhost redirect captured
+    await executeStepAndWait(9, 1000);  // VPS verify → wait for "认证成功!"
+
+    await addLog('=== Auto Run COMPLETE! All 9 steps finished successfully ===', 'ok');
+    chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'complete' } }).catch(() => {});
+
+  } catch (err) {
+    await addLog(`Auto run failed: ${err.message}`, 'error');
+    chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'stopped' } }).catch(() => {});
+  } finally {
+    autoRunActive = false;
+    await setState({ autoRunning: false });
+  }
+}
+
 // ============================================================
 // Step 1: Get OAuth Link (via vps-panel.js)
 // ============================================================
 
 async function executeStep1(state) {
+  // Ensure VPS panel tab is open
+  const alive = await isTabAlive('vps-panel');
+  if (!alive) {
+    await addLog('Step 1: Opening VPS panel...');
+    await chrome.tabs.create({ url: 'http://154.26.182.181:8317/management.html#/oauth', active: true });
+  } else {
+    const tabId = await getTabId('vps-panel');
+    if (tabId) await chrome.tabs.update(tabId, { active: true });
+  }
+
+  // Send command — will queue if content script not ready yet, flush on READY signal
   await sendToContentScript('vps-panel', {
     type: 'EXECUTE_STEP',
     step: 1,
@@ -439,20 +577,29 @@ async function executeStep5(state) {
 // ============================================================
 
 async function executeStep6(state) {
-  const alive = await isTabAlive('chatgpt');
-  if (!alive) {
-    await addLog('Step 6: Opening ChatGPT...');
-    await chrome.tabs.create({ url: 'https://chatgpt.com/', active: true });
-  } else {
-    const tabId = await getTabId('chatgpt');
-    if (tabId) await chrome.tabs.update(tabId, { active: true });
+  if (!state.oauthUrl) {
+    throw new Error('No OAuth URL. Complete step 1 first.');
+  }
+  if (!state.email) {
+    throw new Error('No email. Complete step 3 first.');
+  }
+
+  // Open the OAuth URL again in a new tab to start the login flow
+  // Close the old signup tab first (it's on add-phone page, not needed)
+  const oldSignupTabId = await getTabId('signup-page');
+  if (oldSignupTabId) {
+    try { await chrome.tabs.remove(oldSignupTabId); } catch {}
   }
 
-  await sendToContentScript('chatgpt', {
+  await addLog(`Step 6: Opening OAuth URL for login: ${state.oauthUrl.slice(0, 60)}...`);
+  await chrome.tabs.create({ url: state.oauthUrl, active: true });
+
+  // signup-page.js will inject (same auth.openai.com domain) and handle login
+  await sendToContentScript('signup-page', {
     type: 'EXECUTE_STEP',
     step: 6,
     source: 'background',
-    payload: {},
+    payload: { email: state.email, password: state.password || 'mimashisha0.0' },
   });
 }
 
@@ -490,18 +637,18 @@ async function executeStep7(state) {
   if (result && result.code) {
     await addLog(`Step 7: Got login verification code: ${result.code}`);
 
-    // Switch to ChatGPT tab and fill code
-    const chatgptTabId = await getTabId('chatgpt');
-    if (chatgptTabId) {
-      await chrome.tabs.update(chatgptTabId, { active: true });
-      await sendToContentScript('chatgpt', {
+    // Switch to signup/auth tab and fill code
+    const signupTabId = await getTabId('signup-page');
+    if (signupTabId) {
+      await chrome.tabs.update(signupTabId, { active: true });
+      await sendToContentScript('signup-page', {
         type: 'FILL_CODE',
         step: 7,
         source: 'background',
         payload: { code: result.code },
       });
     } else {
-      throw new Error('ChatGPT tab was closed. Cannot fill verification code.');
+      throw new Error('Auth page tab was closed. Cannot fill verification code.');
     }
   }
 }
@@ -541,7 +688,7 @@ async function executeStep8(state) {
         setState({ localhostUrl: details.url }).then(() => {
           addLog(`Step 8: Captured localhost URL: ${details.url}`, 'ok');
           setStepStatus(8, 'completed');
-          // Broadcast to side panel
+          notifyStepComplete(8, { localhostUrl: details.url });
           chrome.runtime.sendMessage({
             type: 'DATA_UPDATED',
             payload: { localhostUrl: details.url },
@@ -553,20 +700,40 @@ async function executeStep8(state) {
 
     chrome.webNavigation.onBeforeNavigate.addListener(webNavListener);
 
-    // Tell chatgpt.js to navigate to OAuth URL
-    sendToContentScript('chatgpt', {
-      type: 'EXECUTE_STEP',
-      step: 8,
-      source: 'background',
-      payload: {},
-    }).catch(err => {
-      clearTimeout(timeout);
-      if (webNavListener) {
-        chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener);
-        webNavListener = null;
+    // After step 7, the auth page shows a consent screen ("使用 ChatGPT 登录到 Codex")
+    // with a "继续" button. We need to click it, which triggers the localhost redirect.
+    (async () => {
+      try {
+        const signupTabId = await getTabId('signup-page');
+        if (signupTabId) {
+          await chrome.tabs.update(signupTabId, { active: true });
+          await addLog('Step 8: Switching to auth page, clicking "继续" to complete OAuth...');
+          await sendToContentScript('signup-page', {
+            type: 'EXECUTE_STEP',
+            step: 8,
+            source: 'background',
+            payload: {},
+          });
+        } else {
+          // Auth tab was closed, reopen OAuth URL
+          await chrome.tabs.create({ url: state.oauthUrl, active: true });
+          await addLog('Step 8: Auth tab closed, reopening OAuth URL...');
+          await sendToContentScript('signup-page', {
+            type: 'EXECUTE_STEP',
+            step: 8,
+            source: 'background',
+            payload: {},
+          });
+        }
+      } catch (err) {
+        clearTimeout(timeout);
+        if (webNavListener) {
+          chrome.webNavigation.onBeforeNavigate.removeListener(webNavListener);
+          webNavListener = null;
+        }
+        reject(err);
       }
-      reject(err);
-    });
+    })();
   });
 }
 

+ 102 - 188
content/qq-mail.js

@@ -1,64 +1,27 @@
 // content/qq-mail.js — Content script for QQ Mail (steps 4, 7)
 // Injected on: mail.qq.com, wx.mail.qq.com
-// NOTE: all_frames: true — this script runs in every frame on QQ Mail
+// NOTE: all_frames: true
+//
+// Strategy for avoiding stale codes:
+// 1. On poll start, snapshot all existing mail IDs as "old"
+// 2. On each poll cycle, refresh inbox and look for NEW items (not in snapshot)
+// 3. Only extract codes from NEW items that match sender/subject filters
 
 const QQ_MAIL_PREFIX = '[MultiPage:qq-mail]';
-const isNewVersion = location.hostname === 'wx.mail.qq.com';
 const isTopFrame = window === window.top;
 
 console.log(QQ_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
 
-// For old QQ Mail with iframes, only report ready from the top frame
-// to avoid duplicate registrations. The inbox frame will handle email ops.
-if (!isTopFrame && isNewVersion) {
-  console.log(QQ_MAIL_PREFIX, 'Skipping non-top frame on new QQ Mail');
-  // Don't do anything in child frames of new version
-}
-
-// ============================================================
-// Frame detection
-// ============================================================
-
-function isInboxFrame() {
-  if (isNewVersion) return isTopFrame;
-  // Old version: check if this frame has email list elements
-  return !!document.querySelector(
-    '#mailList, .mail-list, [id*="mailList"], #frm_main, .toarea, .mailList'
-  );
-}
-
-// ============================================================
-// Login State Check
-// ============================================================
-
-function checkLoginState() {
-  if (isNewVersion) {
-    // wx.mail.qq.com: look for compose button or folder list
-    return !!(
-      document.querySelector('[class*="folder"], [class*="compose"], [class*="sidebar"]') ||
-      document.querySelector('nav, [role="navigation"]') ||
-      document.querySelector('[class*="mail-list"], [class*="mailList"]')
-    );
-  } else {
-    // mail.qq.com: look for known logged-in elements
-    return !!(
-      document.querySelector('#folder_1, .folder_inbox, #composebtn, #mainFrameContainer')
-    );
-  }
-}
-
 // ============================================================
 // Message Handler
 // ============================================================
 
 chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
   if (message.type === 'POLL_EMAIL') {
-    // For old QQ Mail, only handle in the inbox frame
-    if (!isNewVersion && !isInboxFrame()) {
+    if (!isTopFrame) {
       sendResponse({ ok: false, reason: 'wrong-frame' });
       return;
     }
-
     handlePollEmail(message.step, message.payload).then(result => {
       sendResponse(result);
     }).catch(err => {
@@ -67,60 +30,93 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
     });
     return true; // async response
   }
-
-  if (message.type === 'CHECK_LOGIN') {
-    if (!isTopFrame) {
-      sendResponse({ loggedIn: false });
-      return;
-    }
-    sendResponse({ loggedIn: checkLoginState() });
-    return;
-  }
 });
 
+// ============================================================
+// Get all current mail IDs from the list
+// ============================================================
+
+function getCurrentMailIds() {
+  const ids = new Set();
+  document.querySelectorAll('.mail-list-page-item[data-mailid]').forEach(item => {
+    ids.add(item.getAttribute('data-mailid'));
+  });
+  return ids;
+}
+
 // ============================================================
 // Email Polling
 // ============================================================
 
 async function handlePollEmail(step, payload) {
-  const { filterAfterTimestamp, senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
+  const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
 
-  // Check login state first
-  if (isTopFrame && !checkLoginState()) {
-    throw new Error('QQ Mail not logged in. Please log in to QQ Mail and retry.');
+  log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
+
+  // Wait for mail list to load
+  try {
+    await waitForElement('.mail-list-page-item', 10000);
+    log(`Step ${step}: Mail list loaded`);
+  } catch {
+    throw new Error('Mail list did not load. Make sure QQ Mail inbox is open.');
   }
 
-  log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
+  // Step 1: Snapshot existing mail IDs BEFORE we start waiting for new email
+  const existingMailIds = getCurrentMailIds();
+  log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails as "old"`);
+
+  // Fallback after just 3 attempts (~10s). In practice, the email is usually
+  // already in the list but has the same mailid (page was already open).
+  const FALLBACK_AFTER = 3;
 
   for (let attempt = 1; attempt <= maxAttempts; attempt++) {
     log(`Polling QQ Mail... attempt ${attempt}/${maxAttempts}`);
 
-    // Try to refresh inbox
-    await refreshInbox();
-    await sleep(800); // Wait for refresh to take effect
-
-    // Search for matching email
-    const result = await findMatchingEmail(senderFilters, subjectFilters);
-
-    if (result) {
-      log(`Step ${step}: Found matching email! Extracting code...`);
-      const code = extractVerificationCode(result.content);
-      if (code) {
-        log(`Step ${step}: Verification code found: ${code}`, 'ok');
-        return { ok: true, code, emailTimestamp: Date.now() };
-      } else {
-        log(`Step ${step}: Email found but no 6-digit code in content. Preview: ${result.content.slice(0, 100)}`, 'warn');
+    // Refresh inbox (skip on first attempt, list is fresh)
+    if (attempt > 1) {
+      await refreshInbox();
+      await sleep(800);
+    }
+
+    const allItems = document.querySelectorAll('.mail-list-page-item[data-mailid]');
+    const useFallback = attempt > FALLBACK_AFTER;
+
+    // Phase 1 (attempt 1~3): only look at NEW emails (not in snapshot)
+    // Phase 2 (attempt 4+): fallback to first matching email in list
+    for (const item of allItems) {
+      const mailId = item.getAttribute('data-mailid');
+
+      if (!useFallback && existingMailIds.has(mailId)) continue;
+
+      const sender = (item.querySelector('.cmp-account-nick')?.textContent || '').toLowerCase();
+      const subject = (item.querySelector('.mail-subject')?.textContent || '').toLowerCase();
+      const digest = item.querySelector('.mail-digest')?.textContent || '';
+
+      const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()));
+      const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()));
+
+      if (senderMatch || subjectMatch) {
+        const code = extractVerificationCode(subject + ' ' + digest);
+        if (code) {
+          const source = useFallback && existingMailIds.has(mailId) ? 'fallback-first-match' : 'new';
+          log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
+          return { ok: true, code, emailTimestamp: Date.now(), mailId };
+        }
       }
     }
 
+    if (attempt === FALLBACK_AFTER + 1) {
+      log(`Step ${step}: No new emails after ${FALLBACK_AFTER} attempts, falling back to first matching email`, 'warn');
+    }
+
     if (attempt < maxAttempts) {
       await sleep(intervalMs);
     }
   }
 
   throw new Error(
-    `No matching email found after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
-    'Check QQ Mail manually. Email may be in spam folder.'
+    `No new matching email found after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
+    'Check QQ Mail manually. Email may be delayed or in spam folder.'
   );
 }
 
@@ -129,114 +125,33 @@ async function handlePollEmail(step, payload) {
 // ============================================================
 
 async function refreshInbox() {
-  if (isNewVersion) {
-    // wx.mail.qq.com: try multiple refresh selectors
-    const refreshBtn = document.querySelector(
-      '[class*="refresh"], [title*="刷新"], button[aria-label*="refresh"], ' +
-      '[data-action="refresh"], .toolbar-refresh'
-    );
-    if (refreshBtn) {
-      simulateClick(refreshBtn);
-      console.log(QQ_MAIL_PREFIX, 'Clicked refresh button (new version)');
-    } else {
-      // Fallback: click on "Inbox" folder to force reload
-      const inboxLink = document.querySelector(
-        '[class*="inbox"], [title*="收件箱"], a[href*="inbox"]'
-      );
-      if (inboxLink) {
-        simulateClick(inboxLink);
-        console.log(QQ_MAIL_PREFIX, 'Clicked inbox link to refresh');
-      }
-    }
-  } else {
-    // mail.qq.com: old version
-    const refreshBtn = document.querySelector(
-      '#refresh, .refresh_btn, [id*="refresh"], a[title*="刷新"]'
-    );
-    if (refreshBtn) {
-      simulateClick(refreshBtn);
-      console.log(QQ_MAIL_PREFIX, 'Clicked refresh button (old version)');
-    }
-  }
-}
-
-// ============================================================
-// Find Matching Email
-// ============================================================
-
-async function findMatchingEmail(senderFilters, subjectFilters) {
-  let emailItems;
-
-  if (isNewVersion) {
-    // wx.mail.qq.com: email list items
-    emailItems = document.querySelectorAll(
-      '[class*="mail-item"], [class*="list-item"], [class*="mail_item"], ' +
-      'tr[class*="mail"], div[class*="letter"], [class*="thread"]'
-    );
-  } else {
-    // mail.qq.com: email list inside table
-    emailItems = document.querySelectorAll(
-      '.toarea tr, #mailList tr, .mail_list tr, [id*="mail_"] tr'
-    );
+  // Try multiple strategies to refresh the mail list
+
+  // Strategy 1: Click any visible refresh button
+  const refreshBtn = document.querySelector('[class*="refresh"], [title*="刷新"]');
+  if (refreshBtn) {
+    simulateClick(refreshBtn);
+    console.log(QQ_MAIL_PREFIX, 'Clicked refresh button');
+    await sleep(500);
+    return;
   }
 
-  console.log(QQ_MAIL_PREFIX, `Found ${emailItems.length} email items to scan`);
-
-  for (const item of emailItems) {
-    const text = (item.textContent || '').toLowerCase();
-
-    const senderMatch = senderFilters.some(f => text.includes(f.toLowerCase()));
-    const subjectMatch = subjectFilters.some(f => text.includes(f.toLowerCase()));
-
-    if (senderMatch || subjectMatch) {
-      console.log(QQ_MAIL_PREFIX, 'Found matching email item:', text.slice(0, 100));
-
-      // Try to get content from the visible text first
-      let content = item.textContent || '';
-
-      // Check if we can already extract a code from the preview
-      if (extractVerificationCode(content)) {
-        return { content };
-      }
-
-      // Need to click into the email for full body
-      log('Clicking email to read full content...');
-      simulateClick(item);
-      await sleep(1500);
-
-      // Read email body
-      const bodyEl = document.querySelector(
-        '[class*="mail-body"], [class*="mail_body"], [class*="letter-body"], ' +
-        '.body_content, #contentDiv, [class*="read-content"], [class*="mail-detail"]'
-      );
-
-      if (bodyEl) {
-        content = bodyEl.textContent || bodyEl.innerText || '';
-        console.log(QQ_MAIL_PREFIX, 'Email body content:', content.slice(0, 200));
-      }
-
-      // Go back to inbox after reading
-      await goBackToInbox();
-
-      return { content };
-    }
+  // Strategy 2: Click inbox in sidebar to reload list
+  const sidebarInbox = document.querySelector('a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title="收件箱"]');
+  if (sidebarInbox) {
+    simulateClick(sidebarInbox);
+    console.log(QQ_MAIL_PREFIX, 'Clicked sidebar inbox');
+    await sleep(500);
+    return;
   }
 
-  return null;
-}
-
-async function goBackToInbox() {
-  if (isNewVersion) {
-    // Click back or inbox link
-    const backBtn = document.querySelector(
-      '[class*="back"], [aria-label*="back"], [title*="返回"], [class*="return"]'
-    );
-    if (backBtn) {
-      simulateClick(backBtn);
-      await sleep(500);
-    }
+  // Strategy 3: Click the folder name in toolbar
+  const folderName = document.querySelector('.toolbar-folder-name');
+  if (folderName) {
+    simulateClick(folderName);
+    console.log(QQ_MAIL_PREFIX, 'Clicked toolbar folder name');
+    await sleep(500);
   }
-  // Old version typically uses frames, no need to go back
 }
 
 // ============================================================
@@ -244,18 +159,17 @@ async function goBackToInbox() {
 // ============================================================
 
 function extractVerificationCode(text) {
-  // Try various patterns for 6-digit verification codes
-  // Pattern 1: standalone 6 digits
-  const match6 = text.match(/\b(\d{6})\b/);
-  if (match6) return match6[1];
+  // Pattern 1: Chinese format "代码为 370794" or "验证码...370794"
+  const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
+  if (matchCn) return matchCn[1];
 
-  // Pattern 2: code/verification followed by digits
-  const matchLabeled = text.match(/(?:code|验证码|verification|verify)[:\s]*(\d{4,8})/i);
-  if (matchLabeled) return matchLabeled[1];
+  // Pattern 2: English format "code is 370794" or "code: 370794"
+  const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
+  if (matchEn) return matchEn[1] || matchEn[2];
 
-  // Pattern 3: digits followed by "code" label (Chinese: 是您的验证码)
-  const matchReverse = text.match(/(\d{4,8})\s*(?:是|为|is)?\s*(?:您的)?(?:验证码|code)/i);
-  if (matchReverse) return matchReverse[1];
+  // Pattern 3: standalone 6-digit number (first occurrence)
+  const match6 = text.match(/\b(\d{6})\b/);
+  if (match6) return match6[1];
 
   return null;
 }

+ 188 - 72
content/signup-page.js

@@ -23,10 +23,13 @@ async function handleCommand(message) {
         case 2: return await step2_clickRegister();
         case 3: return await step3_fillEmailPassword(message.payload);
         case 5: return await step5_fillNameBirthday(message.payload);
+        case 6: return await step6_login(message.payload);
+        case 8: return await step8_clickContinue();
         default: throw new Error(`signup-page.js does not handle step ${message.step}`);
       }
     case 'FILL_CODE':
-      return await step4_fillVerificationCode(message.payload);
+      // Step 4 = signup code, Step 7 = login code (same handler)
+      return await fillVerificationCode(message.step, message.payload);
   }
 }
 
@@ -57,12 +60,9 @@ async function step2_clickRegister() {
     }
   }
 
+  reportComplete(2);
   simulateClick(registerBtn);
   log('Step 2: Clicked Register button');
-
-  // Wait for page transition
-  await sleep(2000);
-  reportComplete(2);
 }
 
 // ============================================================
@@ -114,7 +114,11 @@ async function step3_fillEmailPassword(payload) {
   fillInput(passwordInput, 'mimashisha0.0');
   log('Step 3: Password filled');
 
-  // Submit the form
+  // Report complete BEFORE submit, because submit causes page navigation
+  // which kills the content script connection
+  reportComplete(3, { email });
+
+  // Submit the form (page will navigate away after this)
   await sleep(500);
   const submitBtn = document.querySelector('button[type="submit"]')
     || await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
@@ -123,20 +127,17 @@ async function step3_fillEmailPassword(payload) {
     simulateClick(submitBtn);
     log('Step 3: Form submitted');
   }
-
-  await sleep(2000);
-  reportComplete(3, { email });
 }
 
 // ============================================================
-// Step 4 (receiving end): Fill Verification Code
+// Fill Verification Code (used by step 4 and step 7)
 // ============================================================
 
-async function step4_fillVerificationCode(payload) {
+async function fillVerificationCode(step, payload) {
   const { code } = payload;
   if (!code) throw new Error('No verification code provided.');
 
-  log(`Step 4: Filling verification code: ${code}`);
+  log(`Step ${step}: Filling verification code: ${code}`);
 
   // Find code input — could be a single input or multiple separate inputs
   let codeInput = null;
@@ -149,20 +150,23 @@ async function step4_fillVerificationCode(payload) {
     // Check for multiple single-digit inputs (common pattern)
     const singleInputs = document.querySelectorAll('input[maxlength="1"]');
     if (singleInputs.length >= 6) {
-      log('Step 4: Found single-digit code inputs, filling individually...');
+      log(`Step ${step}: Found single-digit code inputs, filling individually...`);
       for (let i = 0; i < 6 && i < singleInputs.length; i++) {
         fillInput(singleInputs[i], code[i]);
         await sleep(100);
       }
       await sleep(1000);
-      reportComplete(4);
+      reportComplete(step);
       return;
     }
     throw new Error('Could not find verification code input. URL: ' + location.href);
   }
 
   fillInput(codeInput, code);
-  log('Step 4: Code filled');
+  log(`Step ${step}: Code filled`);
+
+  // Report complete BEFORE submit (page may navigate away)
+  reportComplete(step);
 
   // Submit
   await sleep(500);
@@ -171,12 +175,100 @@ async function step4_fillVerificationCode(payload) {
 
   if (submitBtn) {
     simulateClick(submitBtn);
-    log('Step 4: Verification submitted');
+    log(`Step ${step}: Verification submitted`);
+  }
+}
+
+// ============================================================
+// Step 6: Login with registered account (on OAuth auth page)
+// ============================================================
+
+async function step6_login(payload) {
+  const { email, password } = payload;
+  if (!email) throw new Error('No email provided for login.');
+
+  log(`Step 6: Logging in with ${email}...`);
+
+  // Wait for email input on the auth page
+  let emailInput = null;
+  try {
+    emailInput = await waitForElement(
+      'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]',
+      15000
+    );
+  } catch {
+    throw new Error('Could not find email input on login page. URL: ' + location.href);
+  }
+
+  fillInput(emailInput, email);
+  log('Step 6: Email filled');
+
+  // Submit email
+  await sleep(500);
+  const submitBtn1 = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
+  if (submitBtn1) {
+    simulateClick(submitBtn1);
+    log('Step 6: Submitted email');
   }
 
-  // Wait for page transition
   await sleep(2000);
-  reportComplete(4);
+
+  // Check for password field
+  const passwordInput = document.querySelector('input[type="password"]');
+  if (passwordInput) {
+    log('Step 6: Password field found, filling password...');
+    fillInput(passwordInput, password);
+
+    await sleep(500);
+    const submitBtn2 = document.querySelector('button[type="submit"]')
+      || await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null);
+    // Report complete BEFORE submit in case page navigates
+    reportComplete(6, { needsOTP: true });
+
+    if (submitBtn2) {
+      simulateClick(submitBtn2);
+      log('Step 6: Submitted password, may need verification code (step 7)');
+    }
+    return;
+  }
+
+  // No password field — OTP flow
+  log('Step 6: No password field. OTP flow or auto-redirect.');
+  reportComplete(6, { needsOTP: true });
+}
+
+// ============================================================
+// Step 8: Click "继续" on OAuth consent page
+// ============================================================
+// After login + verification, page shows:
+// "使用 ChatGPT 登录到 Codex" with a "继续" submit button.
+// Clicking it triggers redirect to localhost URL.
+
+async function step8_clickContinue() {
+  log('Step 8: Looking for OAuth consent "继续" button...');
+
+  // Wait for the consent page to be ready
+  // Look for the submit button with text "继续" or data-dd-action-name="Continue"
+  let continueBtn = null;
+  try {
+    continueBtn = await waitForElement(
+      'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107',
+      10000
+    );
+  } catch {
+    try {
+      continueBtn = await waitForElementByText('button', /继续|Continue/, 5000);
+    } catch {
+      throw new Error('Could not find "继续" button on OAuth consent page. URL: ' + location.href);
+    }
+  }
+
+  log('Step 8: Found "继续" button, clicking...');
+  simulateClick(continueBtn);
+  log('Step 8: Clicked "继续", redirecting to localhost... (background will capture URL)');
+
+  // Don't reportComplete — background handles it via webNavigation listener
 }
 
 // ============================================================
@@ -187,80 +279,104 @@ async function step5_fillNameBirthday(payload) {
   const { firstName, lastName, year, month, day } = payload;
   if (!firstName || !lastName) throw new Error('No name data provided.');
 
-  log(`Step 5: Filling name: ${firstName} ${lastName}, Birthday: ${year}-${month}-${day}`);
+  const fullName = `${firstName} ${lastName}`;
+  log(`Step 5: Filling name: ${fullName}, Birthday: ${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}`);
+
+  // Actual DOM structure:
+  // - Full name: <input name="name" placeholder="全名" type="text">
+  // - Birthday: React Aria DateField with 3 spinbutton divs (year/month/day)
+  //   + <input type="hidden" name="birthday" value="2026-04-05">
 
-  // --- First name ---
-  let firstNameInput = null;
+  // --- Full Name (single field, not first+last) ---
+  let nameInput = null;
   try {
-    firstNameInput = await waitForElement(
-      'input[name="firstName"], input[name="first_name"], input[name="fname"], input[placeholder*="first" i], input[placeholder*="First"], input[id*="first" i]',
+    nameInput = await waitForElement(
+      'input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]',
       10000
     );
   } catch {
-    throw new Error('Could not find first name input. URL: ' + location.href);
+    throw new Error('Could not find name input. URL: ' + location.href);
   }
-  fillInput(firstNameInput, firstName);
-  log(`Step 5: First name filled: ${firstName}`);
+  fillInput(nameInput, fullName);
+  log(`Step 5: Name filled: ${fullName}`);
+
+  // --- Birthday (React Aria DateField with spinbutton segments) ---
+  // The date field has three contenteditable divs with role="spinbutton"
+  // and data-type="year", data-type="month", data-type="day"
+  // There's also a hidden input[name="birthday"] that stores the actual value
+
+  const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
+  const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
+  const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
+
+  if (yearSpinner && monthSpinner && daySpinner) {
+    log('Step 5: Found React Aria DateField spinbuttons');
+
+    // Helper to set a spinbutton value via focus + keyboard input
+    async function setSpinButton(el, value) {
+      el.focus();
+      await sleep(100);
+
+      // Select all existing text
+      document.execCommand('selectAll', false, null);
+      await sleep(50);
+
+      // Type the new value digit by digit
+      const valueStr = String(value);
+      for (const char of valueStr) {
+        el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true }));
+        el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true }));
+        // Also use InputEvent for React Aria
+        el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true }));
+        el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true }));
+        await sleep(50);
+      }
 
-  // --- Last name ---
-  let lastNameInput = null;
-  try {
-    lastNameInput = await waitForElement(
-      'input[name="lastName"], input[name="last_name"], input[name="lname"], input[placeholder*="last" i], input[placeholder*="Last"], input[id*="last" i]',
-      5000
-    );
-  } catch {
-    throw new Error('Could not find last name input. URL: ' + location.href);
-  }
-  fillInput(lastNameInput, lastName);
-  log(`Step 5: Last name filled: ${lastName}`);
+      el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
+      el.blur();
+      await sleep(100);
+    }
 
-  // --- Birthday ---
-  const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+    await setSpinButton(yearSpinner, year);
+    log(`Step 5: Year set: ${year}`);
 
-  // Try single date input first
-  const dateInput = document.querySelector('input[type="date"], input[name="birthday"], input[name="dob"], input[name="birthdate"]');
-  if (dateInput) {
-    fillInput(dateInput, dateStr);
-    log(`Step 5: Birthday filled (single input): ${dateStr}`);
-  } else {
-    // Try separate fields (month/day/year selects or inputs)
-    log('Step 5: Looking for separate birthday fields...');
+    await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
+    log(`Step 5: Month set: ${month}`);
 
-    const monthEl = document.querySelector('select[name*="month" i], input[name*="month" i], input[placeholder*="month" i], select[id*="month" i]');
-    const dayEl = document.querySelector('select[name*="day" i], input[name*="day" i], input[placeholder*="day" i], select[id*="day" i]');
-    const yearEl = document.querySelector('select[name*="year" i], input[name*="year" i], input[placeholder*="year" i], select[id*="year" i]');
+    await setSpinButton(daySpinner, String(day).padStart(2, '0'));
+    log(`Step 5: Day set: ${day}`);
 
-    if (monthEl) {
-      if (monthEl.tagName === 'SELECT') fillSelect(monthEl, String(month));
-      else fillInput(monthEl, String(month).padStart(2, '0'));
-    }
-    if (dayEl) {
-      if (dayEl.tagName === 'SELECT') fillSelect(dayEl, String(day));
-      else fillInput(dayEl, String(day).padStart(2, '0'));
+    // Also update the hidden input directly as a safety measure
+    const hiddenBirthday = document.querySelector('input[type="hidden"][name="birthday"]');
+    if (hiddenBirthday) {
+      const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+      hiddenBirthday.value = dateStr;
+      hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
+      log(`Step 5: Hidden birthday input set: ${dateStr}`);
     }
-    if (yearEl) {
-      if (yearEl.tagName === 'SELECT') fillSelect(yearEl, String(year));
-      else fillInput(yearEl, String(year));
-    }
-
-    if (!monthEl && !dayEl && !yearEl) {
-      log('Step 5: WARNING - Could not find any birthday fields. May need to adjust selectors.', 'warn');
+  } else {
+    // Fallback: try setting hidden input directly
+    const hiddenBirthday = document.querySelector('input[name="birthday"]');
+    if (hiddenBirthday) {
+      const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+      hiddenBirthday.value = dateStr;
+      hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
+      log(`Step 5: Birthday set via hidden input: ${dateStr}`);
     } else {
-      log(`Step 5: Birthday filled (separate fields): ${year}-${month}-${day}`);
+      log('Step 5: WARNING - Could not find birthday fields. May need to adjust selectors.', 'warn');
     }
   }
 
-  // Submit / Complete
+  // Click "完成帐户创建" button
   await sleep(500);
   const completeBtn = document.querySelector('button[type="submit"]')
-    || await waitForElementByText('button', /complete|continue|finish|done|create|agree|完成|创建|同意/i, 5000).catch(() => null);
+    || await waitForElementByText('button', /完成|create|continue|finish|done|agree/i, 5000).catch(() => null);
+
+  // Report complete BEFORE submit (page navigates to add-phone after this)
+  reportComplete(5);
 
   if (completeBtn) {
     simulateClick(completeBtn);
-    log('Step 5: Profile form submitted');
+    log('Step 5: Clicked "完成帐户创建"');
   }
-
-  await sleep(2000);
-  reportComplete(5);
 }

+ 93 - 130
content/vps-panel.js

@@ -1,5 +1,27 @@
 // content/vps-panel.js — Content script for VPS panel (steps 1, 9)
 // Injected on: http://154.26.182.181:8317/*
+//
+// Actual DOM structure (after login click):
+// <div class="card">
+//   <div class="card-header">
+//     <span class="OAuthPage-module__cardTitle___yFaP0">Codex OAuth</span>
+//     <button class="btn btn-primary"><span>登录</span></button>
+//   </div>
+//   <div class="OAuthPage-module__cardContent___1sXLA">
+//     <div class="OAuthPage-module__authUrlBox___Iu1d4">
+//       <div class="OAuthPage-module__authUrlLabel___mYFJB">授权链接:</div>
+//       <div class="OAuthPage-module__authUrlValue___axvUJ">https://auth.openai.com/...</div>
+//       <div class="OAuthPage-module__authUrlActions___venPj">
+//         <button class="btn btn-secondary btn-sm"><span>复制链接</span></button>
+//         <button class="btn btn-secondary btn-sm"><span>打开链接</span></button>
+//       </div>
+//     </div>
+//     <div class="OAuthPage-module__callbackSection___8kA31">
+//       <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
+//       <button class="btn btn-secondary btn-sm"><span>提交回调 URL</span></button>
+//     </div>
+//   </div>
+// </div>
 
 console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
 
@@ -30,178 +52,119 @@ async function handleStep(step, payload) {
 // ============================================================
 
 async function step1_getOAuthLink() {
-  log('Step 1: Checking VPS panel state...');
+  log('Step 1: Waiting for VPS panel to load (auto-login may take a moment)...');
 
-  // --- Selector strategy ---
-  // The VPS panel is at management.html#/oauth
-  // We need to: 1) find OAuth login section, 2) click Codex login, 3) read auth URL
-  // TODO: These selectors MUST be adjusted after inspecting the actual VPS panel DOM
-  // The selectors below are best-guess placeholders.
-
-  // Try to find OAuth login button by text content
-  log('Step 1: Looking for OAuth login button...');
-  let oauthBtn = null;
+  // The page may start at #/login and auto-redirect to #/oauth.
+  // Wait for the Codex OAuth card to appear (up to 30s for auto-login + redirect).
+  let loginBtn = null;
   try {
-    oauthBtn = await waitForElementByText(
-      'button, a, [role="button"], .el-button, div[class*="btn"]',
-      /oauth|OAuth/i,
-      10000
-    );
+    // Wait for any card-header containing "Codex" to appear
+    const header = await waitForElementByText('.card-header', /codex/i, 30000);
+    loginBtn = header.querySelector('button.btn.btn-primary, button.btn');
+    log('Step 1: Found Codex OAuth card');
   } catch {
-    // Fallback: try common UI framework selectors
-    try {
-      oauthBtn = await waitForElement('.oauth-login-btn, [data-action="oauth-login"]', 5000);
-    } catch {
-      throw new Error(
-        'Could not find OAuth login button. ' +
-        'Please inspect the VPS panel page in DevTools and update the selector in vps-panel.js. ' +
-        'URL: ' + location.href
-      );
-    }
-  }
-
-  simulateClick(oauthBtn);
-  log('Step 1: Clicked OAuth login, waiting for Codex login option...');
-  await sleep(1500);
-
-  // Wait for Codex login option to appear
-  let codexBtn = null;
-  try {
-    codexBtn = await waitForElementByText(
-      'button, a, [role="button"], .el-button, div[class*="btn"], span',
-      /codex/i,
-      10000
+    throw new Error(
+      'Codex OAuth card did not appear after 30s. Page may still be loading or not logged in. ' +
+      'Current URL: ' + location.href
     );
-  } catch {
-    try {
-      codexBtn = await waitForElement('[data-action="codex-login"], .codex-login-btn', 5000);
-    } catch {
-      throw new Error(
-        'Could not find Codex login button after clicking OAuth. ' +
-        'Check the VPS panel DOM in DevTools. URL: ' + location.href
-      );
-    }
-  }
-
-  simulateClick(codexBtn);
-  log('Step 1: Clicked Codex login, waiting for auth URL...');
-  await sleep(2000);
-
-  // Extract the auth URL — could be in various elements
-  let oauthUrl = null;
-
-  // Strategy 1: Look for an input/textarea with a URL value
-  const inputs = document.querySelectorAll('input[readonly], input[type="text"], textarea, code, pre');
-  for (const el of inputs) {
-    const val = (el.value || el.textContent || '').trim();
-    if (val.startsWith('http') && val.length > 30) {
-      oauthUrl = val;
-      log(`Step 1: Found URL in <${el.tagName}>: ${val.slice(0, 80)}...`);
-      break;
-    }
   }
 
-  // Strategy 2: Look for any element containing a long URL
-  if (!oauthUrl) {
-    const allElements = document.querySelectorAll('span, p, div, a, code, pre');
-    for (const el of allElements) {
-      const text = (el.textContent || '').trim();
-      // Match a URL that looks like an OAuth authorization URL
-      const urlMatch = text.match(/(https?:\/\/[^\s<>"']{30,})/);
-      if (urlMatch) {
-        oauthUrl = urlMatch[1];
-        log(`Step 1: Found URL in text: ${oauthUrl.slice(0, 80)}...`);
-        break;
-      }
-    }
+  if (!loginBtn) {
+    throw new Error('Found Codex OAuth card but no login button inside it. URL: ' + location.href);
   }
 
-  // Strategy 3: Check clipboard (if the page auto-copies)
-  if (!oauthUrl) {
-    try {
-      oauthUrl = await navigator.clipboard.readText();
-      if (oauthUrl && oauthUrl.startsWith('http') && oauthUrl.length > 30) {
-        log(`Step 1: Found URL in clipboard: ${oauthUrl.slice(0, 80)}...`);
-      } else {
-        oauthUrl = null;
-      }
-    } catch {
-      // Clipboard access may be denied
-    }
+  // Check if button is disabled (already clicked / loading)
+  if (loginBtn.disabled) {
+    log('Step 1: Login button is disabled (already loading), waiting for auth URL...');
+  } else {
+    simulateClick(loginBtn);
+    log('Step 1: Clicked login button, waiting for auth URL...');
   }
 
-  if (!oauthUrl) {
+  // Wait for the auth URL to appear in the specific div
+  let authUrlEl = null;
+  try {
+    authUrlEl = await waitForElement('[class*="authUrlValue"]', 15000);
+  } catch {
     throw new Error(
-      'Could not find auth URL. The URL may be displayed in a format we cannot detect. ' +
-      'Please check the VPS panel page and copy the URL manually, or update the extraction logic in vps-panel.js. ' +
-      'URL: ' + location.href
+      'Auth URL did not appear after clicking login. ' +
+      'Check if VPS panel is logged in and Codex service is running. URL: ' + location.href
     );
   }
 
+  const oauthUrl = (authUrlEl.textContent || '').trim();
+  if (!oauthUrl || !oauthUrl.startsWith('http')) {
+    throw new Error(`Invalid OAuth URL found: "${oauthUrl.slice(0, 50)}". Expected URL starting with http.`);
+  }
+
   log(`Step 1: OAuth URL obtained: ${oauthUrl.slice(0, 80)}...`, 'ok');
-  reportComplete(1, { oauthUrl: oauthUrl.trim() });
+  reportComplete(1, { oauthUrl });
 }
 
 // ============================================================
-// Step 9: VPS Verify
+// Step 9: VPS Verify — paste localhost URL and submit
 // ============================================================
 
 async function step9_vpsVerify() {
   log('Step 9: Getting localhost URL from storage...');
 
-  // Get localhostUrl from storage (via Background)
   const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
   const localhostUrl = state.localhostUrl;
   if (!localhostUrl) {
     throw new Error('No localhost URL found. Complete step 8 first.');
   }
 
-  log(`Step 9: Looking for URL input field on VPS panel...`);
+  log('Step 9: Looking for callback URL input...');
 
-  // Try to find URL input field
+  // Find the callback URL input
+  // Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
   let urlInput = null;
   try {
-    urlInput = await waitForElement(
-      'input[placeholder*="localhost"], input[placeholder*="callback"], input[placeholder*="URL"], input[placeholder*="url"], input[name*="callback"], input[name*="url"]',
-      10000
-    );
+    urlInput = await waitForElement('[class*="callbackSection"] input.input', 10000);
   } catch {
-    // Fallback: find any text input that's visible and empty
-    const inputs = document.querySelectorAll('input[type="text"], input:not([type])');
-    for (const input of inputs) {
-      if (input.offsetParent !== null && !input.value) {
-        urlInput = input;
-        log('Step 9: Using fallback empty input field');
-        break;
-      }
-    }
-    if (!urlInput) {
-      throw new Error(
-        'Could not find URL input field on VPS panel. ' +
-        'Check DOM structure in DevTools. URL: ' + location.href
-      );
+    try {
+      urlInput = await waitForElement('input[placeholder*="localhost"]', 5000);
+    } catch {
+      throw new Error('Could not find callback URL input on VPS panel. URL: ' + location.href);
     }
   }
 
   fillInput(urlInput, localhostUrl);
-  log(`Step 9: Filled URL input with: ${localhostUrl}`);
+  log(`Step 9: Filled callback URL: ${localhostUrl.slice(0, 80)}...`);
 
-  // Find and click verify button
-  let verifyBtn = null;
+  // Find and click "提交回调 URL" button
+  let submitBtn = null;
   try {
-    verifyBtn = await waitForElementByText(
-      'button, [role="button"], .el-button, a',
-      /verif|确认|验证|submit|提交/i,
-      10000
+    submitBtn = await waitForElementByText(
+      '[class*="callbackActions"] button, [class*="callbackSection"] button',
+      /提交/,
+      5000
     );
   } catch {
-    throw new Error(
-      'Could not find verify/submit button. ' +
-      'Check VPS panel DOM in DevTools. URL: ' + location.href
-    );
+    try {
+      submitBtn = await waitForElementByText('button.btn', /提交回调/, 5000);
+    } catch {
+      throw new Error('Could not find "提交回调 URL" button. URL: ' + location.href);
+    }
+  }
+
+  simulateClick(submitBtn);
+  log('Step 9: Clicked "提交回调 URL", waiting for authentication result...');
+
+  // Wait for "认证成功!" status badge to appear
+  try {
+    await waitForElementByText('.status-badge, [class*="status"]', /认证成功/, 30000);
+    log('Step 9: Authentication successful!', 'ok');
+  } catch {
+    // Check if there's an error message instead
+    const statusEl = document.querySelector('.status-badge, [class*="status"]');
+    const statusText = statusEl ? statusEl.textContent : 'unknown';
+    if (/成功|success/i.test(statusText)) {
+      log('Step 9: Authentication successful!', 'ok');
+    } else {
+      log(`Step 9: Status after submit: "${statusText}". May still be processing.`, 'warn');
+    }
   }
 
-  simulateClick(verifyBtn);
-  log('Step 9: Clicked verify button', 'ok');
   reportComplete(9);
 }

+ 78 - 2
sidepanel/sidepanel.css

@@ -23,6 +23,11 @@ header h1 {
   font-weight: 600;
 }
 
+.header-btns {
+  display: flex;
+  gap: 6px;
+}
+
 #btn-reset {
   padding: 4px 10px;
   font-size: 12px;
@@ -34,6 +39,49 @@ header h1 {
 }
 #btn-reset:hover { background: #c82333; }
 
+.btn-auto {
+  padding: 4px 10px;
+  font-size: 12px;
+  background: #28a745;
+  color: #fff;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  font-weight: 600;
+}
+.btn-auto:hover { background: #218838; }
+.btn-auto:disabled { background: #6c757d; cursor: not-allowed; }
+
+.auto-continue-bar {
+  margin-top: 8px;
+  padding: 8px;
+  background: #fff3cd;
+  border: 1px solid #ffc107;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.auto-hint {
+  font-size: 11px;
+  color: #856404;
+  flex: 1;
+}
+
+.btn-continue {
+  padding: 4px 12px;
+  font-size: 12px;
+  background: #007bff;
+  color: #fff;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  font-weight: 600;
+  white-space: nowrap;
+}
+.btn-continue:hover { background: #0069d9; }
+
 /* Data Section */
 #data-section {
   background: #fff;
@@ -141,6 +189,34 @@ header h1 {
 }
 
 .log-info { color: #d4d4d4; }
-.log-ok { color: #4ec9b0; }
+.log-ok { color: #4ec9b0; font-weight: bold; }
 .log-warn { color: #dcdcaa; }
-.log-error { color: #f44747; }
+.log-error { color: #f44747; font-weight: bold; }
+
+.log-step-tag {
+  display: inline-block;
+  background: #3a3d41;
+  color: #569cd6;
+  border-radius: 3px;
+  padding: 0 4px;
+  margin-right: 4px;
+  font-weight: bold;
+  font-size: 10px;
+}
+
+.log-step-tag.step-1 { color: #4fc1ff; }
+.log-step-tag.step-2 { color: #c586c0; }
+.log-step-tag.step-3 { color: #dcdcaa; }
+.log-step-tag.step-4 { color: #ce9178; }
+.log-step-tag.step-5 { color: #b5cea8; }
+.log-step-tag.step-6 { color: #4fc1ff; }
+.log-step-tag.step-7 { color: #ce9178; }
+.log-step-tag.step-8 { color: #c586c0; }
+.log-step-tag.step-9 { color: #b5cea8; }
+
+.log-time { color: #6a9955; }
+.log-level { font-weight: bold; }
+.log-level-info { color: #569cd6; }
+.log-level-ok { color: #4ec9b0; }
+.log-level-warn { color: #dcdcaa; }
+.log-level-error { color: #f44747; }

+ 8 - 1
sidepanel/sidepanel.html

@@ -9,7 +9,10 @@
 <body>
   <header>
     <h1>Multi-Page Automation</h1>
-    <button id="btn-reset" title="Reset all steps">Reset</button>
+    <div class="header-btns">
+      <button id="btn-auto-run" class="btn-auto" title="Run all steps automatically">Auto Run</button>
+      <button id="btn-reset" title="Reset all steps">Reset</button>
+    </div>
   </header>
 
   <section id="data-section">
@@ -29,6 +32,10 @@
       <label>Status:</label>
       <span id="display-status" class="data-value">Waiting</span>
     </div>
+    <div id="auto-continue-bar" class="auto-continue-bar" style="display:none;">
+      <span class="auto-hint">Paste DuckDuckGo email above, then:</span>
+      <button id="btn-auto-continue" class="btn-continue">Continue Auto</button>
+    </div>
   </section>
 
   <section id="steps-section">

+ 70 - 1
sidepanel/sidepanel.js

@@ -128,11 +128,30 @@ function appendLog(entry) {
   const levelLabel = entry.level.toUpperCase().padEnd(5);
   const line = document.createElement('div');
   line.className = `log-${entry.level}`;
-  line.textContent = `${time} [${levelLabel}] ${entry.message}`;
+
+  // Extract step number from message (e.g., "Step 4: ..." or "[vps-panel] Step 1: ...")
+  const stepMatch = entry.message.match(/Step (\d)/);
+  const stepNum = stepMatch ? stepMatch[1] : null;
+
+  // Build rich HTML
+  let html = `<span class="log-time">${time}</span> `;
+  html += `<span class="log-level log-level-${entry.level}">[${levelLabel}]</span> `;
+  if (stepNum) {
+    html += `<span class="log-step-tag step-${stepNum}">S${stepNum}</span>`;
+  }
+  html += `<span>${escapeHtml(entry.message)}</span>`;
+
+  line.innerHTML = html;
   logArea.appendChild(line);
   logArea.scrollTop = logArea.scrollHeight;
 }
 
+function escapeHtml(text) {
+  const div = document.createElement('div');
+  div.textContent = text;
+  return div.innerHTML;
+}
+
 // ============================================================
 // Button Handlers
 // ============================================================
@@ -163,6 +182,32 @@ document.querySelectorAll('.step-btn').forEach(btn => {
   });
 });
 
+// Auto Run button
+const btnAutoRun = document.getElementById('btn-auto-run');
+const btnAutoContinue = document.getElementById('btn-auto-continue');
+const autoContinueBar = document.getElementById('auto-continue-bar');
+
+btnAutoRun.addEventListener('click', async () => {
+  btnAutoRun.disabled = true;
+  btnAutoRun.textContent = 'Running...';
+  await chrome.runtime.sendMessage({ type: 'AUTO_RUN', source: 'sidepanel' });
+});
+
+btnAutoContinue.addEventListener('click', async () => {
+  const email = inputEmail.value.trim();
+  if (!email) {
+    appendLog({ message: 'Please paste DuckDuckGo email first!', level: 'error', timestamp: Date.now() });
+    return;
+  }
+  autoContinueBar.style.display = 'none';
+  btnAutoRun.textContent = 'Running...';
+  await chrome.runtime.sendMessage({
+    type: 'RESUME_AUTO_RUN',
+    source: 'sidepanel',
+    payload: { email },
+  });
+});
+
 // Reset button
 btnReset.addEventListener('click', async () => {
   if (confirm('Reset all steps and data?')) {
@@ -177,6 +222,9 @@ btnReset.addEventListener('click', async () => {
     displayStatus.classList.remove('has-value');
     logArea.innerHTML = '';
     document.querySelectorAll('.step-status').forEach(el => el.textContent = '\u2B1A');
+    btnAutoRun.disabled = false;
+    btnAutoRun.textContent = 'Auto Run';
+    autoContinueBar.style.display = 'none';
     updateButtonStates();
   }
 });
@@ -222,6 +270,27 @@ chrome.runtime.onMessage.addListener((message) => {
       }
       break;
     }
+
+    case 'AUTO_RUN_STATUS': {
+      const { phase } = message.payload;
+      switch (phase) {
+        case 'waiting_email':
+          autoContinueBar.style.display = 'flex';
+          btnAutoRun.textContent = 'Waiting...';
+          break;
+        case 'complete':
+          btnAutoRun.disabled = false;
+          btnAutoRun.textContent = 'Auto Run';
+          autoContinueBar.style.display = 'none';
+          break;
+        case 'stopped':
+          btnAutoRun.disabled = false;
+          btnAutoRun.textContent = 'Auto Run';
+          autoContinueBar.style.display = 'none';
+          break;
+      }
+      break;
+    }
   }
 });