Parcourir la source

Merge branch 'dev'

QLHazyCoder il y a 4 mois
Parent
commit
843b1fee8a
6 fichiers modifiés avec 822 ajouts et 125 suppressions
  1. 4 0
      README.md
  2. 259 53
      background.js
  3. 118 0
      sidepanel/sidepanel.css
  4. 28 8
      sidepanel/sidepanel.html
  5. 398 62
      sidepanel/sidepanel.js
  6. 15 2
      tests/step9-cpa-mode.test.js

+ 4 - 0
README.md

@@ -215,6 +215,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 - 手动输入:使用你自定义的密码
 - 手动输入:使用你自定义的密码
 - 可通过输入框右侧的眼睛图标切换显示
 - 可通过输入框右侧的眼睛图标切换显示
 - 配置会自动保存,也可以点击右侧 `保存` 按钮手动保存一次
 - 配置会自动保存,也可以点击右侧 `保存` 按钮手动保存一次
+- 右上角 `配置` 按钮支持导出当前配置到 JSON 文件,也支持从 JSON 文件覆盖导入配置
 
 
 扩展会把本轮实际使用的密码同步回侧边栏,便于查看和复制。
 扩展会把本轮实际使用的密码同步回侧边栏,便于查看和复制。
 
 
@@ -369,6 +370,9 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 
 
 - 步骤 9 会拒绝任何不是真实 `/auth/callback`,或缺少 `code` / `state` 的本地回调地址
 - 步骤 9 会拒绝任何不是真实 `/auth/callback`,或缺少 `code` / `state` 的本地回调地址
 - 成功后的清理只会针对 `/auth` 这一类真实回调标签页,不会再泛化清理任意 localhost 路径
 - 成功后的清理只会针对 `/auth` 这一类真实回调标签页,不会再泛化清理任意 localhost 路径
+- 侧边栏可切换“本地 CPA”策略,默认是 `全部回调`
+- 选择 `全部回调` 时,即使 CPA 部署在本地,也会执行步骤 9
+- 选择 `跳过第9步` 时,仅当本地 CPA 且步骤 8 已拿到回调地址时,才会直接跳过步骤 9
 
 
 回到 CPA 面板:
 回到 CPA 面板:
 
 

+ 259 - 53
background.js

@@ -39,6 +39,7 @@ const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback';
 const AUTO_RUN_ALARM_NAME = 'scheduled-auto-run';
 const AUTO_RUN_ALARM_NAME = 'scheduled-auto-run';
 const AUTO_RUN_DELAY_MIN_MINUTES = 1;
 const AUTO_RUN_DELAY_MIN_MINUTES = 1;
 const AUTO_RUN_DELAY_MAX_MINUTES = 1440;
 const AUTO_RUN_DELAY_MAX_MINUTES = 1440;
+const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
 
 
 initializeSessionStorageAccess();
 initializeSessionStorageAccess();
 
 
@@ -50,6 +51,7 @@ const PERSISTED_SETTING_DEFAULTS = {
   panelMode: 'cpa', // Step 1 / Step 9 的来源模式:cpa | sub2api。
   panelMode: 'cpa', // Step 1 / Step 9 的来源模式:cpa | sub2api。
   vpsUrl: '', // VPS 面板地址,可手动填写。
   vpsUrl: '', // VPS 面板地址,可手动填写。
   vpsPassword: '', // VPS 面板登录密码,可手动填写。
   vpsPassword: '', // VPS 面板登录密码,可手动填写。
+  localCpaStep9Mode: DEFAULT_LOCAL_CPA_STEP9_MODE, // 本地 CPA 的第 9 步策略:submit | bypass。
   sub2apiUrl: DEFAULT_SUB2API_URL, // SUB2API 管理后台地址。
   sub2apiUrl: DEFAULT_SUB2API_URL, // SUB2API 管理后台地址。
   sub2apiEmail: '', // SUB2API 登录邮箱。
   sub2apiEmail: '', // SUB2API 登录邮箱。
   sub2apiPassword: '', // SUB2API 登录密码。
   sub2apiPassword: '', // SUB2API 登录密码。
@@ -68,6 +70,8 @@ const PERSISTED_SETTING_DEFAULTS = {
 };
 };
 
 
 const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
 const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
+const SETTINGS_EXPORT_SCHEMA_VERSION = 1;
+const SETTINGS_EXPORT_FILENAME_PREFIX = 'multipage-settings';
 
 
 const DEFAULT_STATE = {
 const DEFAULT_STATE = {
   currentStep: 0, // 当前流程执行到的步骤编号。
   currentStep: 0, // 当前流程执行到的步骤编号。
@@ -139,6 +143,30 @@ function normalizeEmailGenerator(value = '') {
   return String(value || '').trim().toLowerCase() === 'cloudflare' ? 'cloudflare' : 'duck';
   return String(value || '').trim().toLowerCase() === 'cloudflare' ? 'cloudflare' : 'duck';
 }
 }
 
 
+function normalizePanelMode(value = '') {
+  return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa';
+}
+
+function normalizeMailProvider(value = '') {
+  const normalized = String(value || '').trim().toLowerCase();
+  switch (normalized) {
+    case HOTMAIL_PROVIDER:
+    case '163':
+    case '163-vip':
+    case 'qq':
+    case 'inbucket':
+      return normalized;
+    default:
+      return PERSISTED_SETTING_DEFAULTS.mailProvider;
+  }
+}
+
+function normalizeLocalCpaStep9Mode(value = '') {
+  return String(value || '').trim().toLowerCase() === 'bypass'
+    ? 'bypass'
+    : DEFAULT_LOCAL_CPA_STEP9_MODE;
+}
+
 function normalizeCloudflareDomain(rawValue = '') {
 function normalizeCloudflareDomain(rawValue = '') {
   let value = String(rawValue || '').trim().toLowerCase();
   let value = String(rawValue || '').trim().toLowerCase();
   if (!value) return '';
   if (!value) return '';
@@ -149,17 +177,99 @@ function normalizeCloudflareDomain(rawValue = '') {
   return value;
   return value;
 }
 }
 
 
+function normalizeCloudflareDomains(values) {
+  const normalizedDomains = [];
+  const seen = new Set();
+
+  for (const value of Array.isArray(values) ? values : []) {
+    const normalized = normalizeCloudflareDomain(value);
+    if (!normalized || seen.has(normalized)) continue;
+    seen.add(normalized);
+    normalizedDomains.push(normalized);
+  }
+
+  return normalizedDomains;
+}
+
+function normalizePersistentSettingValue(key, value) {
+  switch (key) {
+    case 'panelMode':
+      return normalizePanelMode(value);
+    case 'vpsUrl':
+      return String(value || '').trim();
+    case 'vpsPassword':
+      return String(value || '');
+    case 'localCpaStep9Mode':
+      return normalizeLocalCpaStep9Mode(value);
+    case 'sub2apiUrl':
+      return String(value || '').trim();
+    case 'sub2apiEmail':
+      return String(value || '').trim();
+    case 'sub2apiPassword':
+      return String(value || '');
+    case 'sub2apiGroupName':
+      return String(value || '').trim();
+    case 'customPassword':
+      return String(value || '');
+    case 'autoRunSkipFailures':
+    case 'autoRunDelayEnabled':
+      return Boolean(value);
+    case 'autoRunDelayMinutes':
+      return normalizeAutoRunDelayMinutes(value);
+    case 'mailProvider':
+      return normalizeMailProvider(value);
+    case 'emailGenerator':
+      return normalizeEmailGenerator(value);
+    case 'inbucketHost':
+      return String(value || '').trim();
+    case 'inbucketMailbox':
+      return String(value || '').trim();
+    case 'cloudflareDomain':
+      return normalizeCloudflareDomain(value);
+    case 'cloudflareDomains':
+      return normalizeCloudflareDomains(value);
+    case 'hotmailAccounts':
+      return normalizeHotmailAccounts(value);
+    default:
+      return value;
+  }
+}
+
+function buildPersistentSettingsPayload(input = {}, options = {}) {
+  const { fillDefaults = false, requireKnownKeys = false } = options;
+  if (!input || typeof input !== 'object' || Array.isArray(input)) {
+    throw new Error('\u914d\u7f6e\u5185\u5bb9\u683c\u5f0f\u65e0\u6548\u3002');
+  }
+
+  const payload = {};
+  let matchedKeyCount = 0;
+  for (const key of PERSISTED_SETTING_KEYS) {
+    if (input[key] !== undefined) {
+      payload[key] = normalizePersistentSettingValue(key, input[key]);
+      matchedKeyCount += 1;
+    } else if (fillDefaults) {
+      payload[key] = normalizePersistentSettingValue(key, PERSISTED_SETTING_DEFAULTS[key]);
+    }
+  }
+
+  if (requireKnownKeys && matchedKeyCount === 0) {
+    throw new Error('\u914d\u7f6e\u6587\u4ef6\u4e2d\u6ca1\u6709\u53ef\u8bc6\u522b\u7684\u914d\u7f6e\u5185\u5bb9\u3002');
+  }
+
+  if (payload.cloudflareDomains) {
+    const domains = normalizeCloudflareDomains(payload.cloudflareDomains);
+    if (payload.cloudflareDomain && !domains.includes(payload.cloudflareDomain)) {
+      domains.unshift(payload.cloudflareDomain);
+    }
+    payload.cloudflareDomains = domains;
+  }
+
+  return payload;
+}
+
 async function getPersistedSettings() {
 async function getPersistedSettings() {
   const stored = await chrome.storage.local.get(PERSISTED_SETTING_KEYS);
   const stored = await chrome.storage.local.get(PERSISTED_SETTING_KEYS);
-  return {
-    ...PERSISTED_SETTING_DEFAULTS,
-    ...stored,
-    autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures),
-    autoRunDelayEnabled: Boolean(stored.autoRunDelayEnabled ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayEnabled),
-    autoRunDelayMinutes: normalizeAutoRunDelayMinutes(stored.autoRunDelayMinutes ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayMinutes),
-    emailGenerator: normalizeEmailGenerator(stored.emailGenerator ?? PERSISTED_SETTING_DEFAULTS.emailGenerator),
-    hotmailAccounts: normalizeHotmailAccounts(stored.hotmailAccounts),
-  };
+  return buildPersistentSettingsPayload(stored, { fillDefaults: true });
 }
 }
 
 
 async function getState() {
 async function getState() {
@@ -191,26 +301,75 @@ async function setState(updates) {
 }
 }
 
 
 async function setPersistentSettings(updates) {
 async function setPersistentSettings(updates) {
-  const persistedUpdates = {};
-  for (const key of PERSISTED_SETTING_KEYS) {
-    if (updates[key] !== undefined) {
-      if (key === 'autoRunSkipFailures' || key === 'autoRunDelayEnabled') {
-        persistedUpdates[key] = Boolean(updates[key]);
-      } else if (key === 'autoRunDelayMinutes') {
-        persistedUpdates[key] = normalizeAutoRunDelayMinutes(updates[key]);
-      } else if (key === 'hotmailAccounts') {
-        persistedUpdates[key] = normalizeHotmailAccounts(updates[key]);
-      } else {
-        persistedUpdates[key] = updates[key];
-      }
-    }
-  }
+  const persistedUpdates = buildPersistentSettingsPayload(updates);
 
 
   if (Object.keys(persistedUpdates).length > 0) {
   if (Object.keys(persistedUpdates).length > 0) {
     await chrome.storage.local.set(persistedUpdates);
     await chrome.storage.local.set(persistedUpdates);
   }
   }
 }
 }
 
 
+function buildSettingsExportFilename(date = new Date()) {
+  const pad = (value) => String(value).padStart(2, '0');
+  return `${SETTINGS_EXPORT_FILENAME_PREFIX}-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.json`;
+}
+
+async function exportSettingsBundle() {
+  const settings = await getPersistedSettings();
+  const bundle = {
+    schemaVersion: SETTINGS_EXPORT_SCHEMA_VERSION,
+    exportedAt: new Date().toISOString(),
+    extensionVersion: chrome.runtime.getManifest().version,
+    settings,
+  };
+
+  return {
+    fileName: buildSettingsExportFilename(),
+    fileContent: JSON.stringify(bundle, null, 2),
+  };
+}
+
+async function importSettingsBundle(configBundle) {
+  const state = await ensureManualInteractionAllowed('\u5bfc\u5165\u914d\u7f6e');
+  if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) {
+    throw new Error('\u5f53\u524d\u6709\u6b65\u9aa4\u6b63\u5728\u6267\u884c\uff0c\u65e0\u6cd5\u5bfc\u5165\u914d\u7f6e\u3002');
+  }
+  if (!configBundle || typeof configBundle !== 'object' || Array.isArray(configBundle)) {
+    throw new Error('\u914d\u7f6e\u6587\u4ef6\u5185\u5bb9\u65e0\u6548\u3002');
+  }
+
+  const schemaVersion = Number(configBundle.schemaVersion);
+  if (schemaVersion !== SETTINGS_EXPORT_SCHEMA_VERSION) {
+    throw new Error(`\u4ec5\u652f\u6301\u5bfc\u5165 schemaVersion=${SETTINGS_EXPORT_SCHEMA_VERSION} \u7684\u914d\u7f6e\u6587\u4ef6\u3002`);
+  }
+  if (!configBundle.settings || typeof configBundle.settings !== 'object' || Array.isArray(configBundle.settings)) {
+    throw new Error('\u914d\u7f6e\u6587\u4ef6\u7f3a\u5c11 settings \u914d\u7f6e\u6bb5\u3002');
+  }
+
+  const importedSettings = buildPersistentSettingsPayload(configBundle.settings, {
+    fillDefaults: true,
+    requireKnownKeys: true,
+  });
+
+  await setPersistentSettings(importedSettings);
+
+  const sessionUpdates = {
+    ...importedSettings,
+    currentHotmailAccountId: null,
+  };
+  if (importedSettings.mailProvider === HOTMAIL_PROVIDER) {
+    sessionUpdates.email = null;
+  }
+
+  await setState(sessionUpdates);
+  broadcastDataUpdate({
+    ...importedSettings,
+    currentHotmailAccountId: null,
+    ...(sessionUpdates.email !== undefined ? { email: sessionUpdates.email } : {}),
+  });
+
+  return getState();
+}
+
 function broadcastDataUpdate(payload) {
 function broadcastDataUpdate(payload) {
   chrome.runtime.sendMessage({
   chrome.runtime.sendMessage({
     type: 'DATA_UPDATED',
     type: 'DATA_UPDATED',
@@ -218,9 +377,13 @@ function broadcastDataUpdate(payload) {
   }).catch(() => { });
   }).catch(() => { });
 }
 }
 
 
-async function setEmailState(email) {
+async function setEmailStateSilently(email) {
   await setState({ email });
   await setState({ email });
   broadcastDataUpdate({ email });
   broadcastDataUpdate({ email });
+}
+
+async function setEmailState(email) {
+  await setEmailStateSilently(email);
   if (email) {
   if (email) {
     await resumeAutoRunIfWaitingForEmail();
     await resumeAutoRunIfWaitingForEmail();
   }
   }
@@ -983,7 +1146,9 @@ function isLocalCpaUrl(rawUrl) {
 }
 }
 
 
 function shouldBypassStep9ForLocalCpa(state) {
 function shouldBypassStep9ForLocalCpa(state) {
-  return Boolean(state?.localhostUrl) && isLocalCpaUrl(state?.vpsUrl);
+  return normalizeLocalCpaStep9Mode(state?.localCpaStep9Mode) === 'bypass'
+    && Boolean(state?.localhostUrl)
+    && isLocalCpaUrl(state?.vpsUrl);
 }
 }
 
 
 function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) {
 function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) {
@@ -1881,6 +2046,13 @@ function clearStopRequest() {
   stopRequested = false;
   stopRequested = false;
 }
 }
 
 
+function getRunningSteps(statuses = {}) {
+  return Object.entries({ ...DEFAULT_STATE.stepStatuses, ...statuses })
+    .filter(([, status]) => status === 'running')
+    .map(([step]) => Number(step))
+    .sort((a, b) => a - b);
+}
+
 function getAutoRunStatusPayload(phase, payload = {}) {
 function getAutoRunStatusPayload(phase, payload = {}) {
   const currentRun = payload.currentRun ?? autoRunCurrentRun;
   const currentRun = payload.currentRun ?? autoRunCurrentRun;
   const totalRuns = payload.totalRuns ?? autoRunTotalRuns;
   const totalRuns = payload.totalRuns ?? autoRunTotalRuns;
@@ -1889,7 +2061,7 @@ function getAutoRunStatusPayload(phase, payload = {}) {
     ? (payload.scheduledAt ?? payload.scheduledAutoRunAt ?? null)
     ? (payload.scheduledAt ?? payload.scheduledAutoRunAt ?? null)
     : null;
     : null;
   const scheduledAt = rawScheduledAt === null ? null : Number(rawScheduledAt);
   const scheduledAt = rawScheduledAt === null ? null : Number(rawScheduledAt);
-  const autoRunning = phase === 'scheduled' || phase === 'running' || phase === 'waiting_email' || phase === 'retrying';
+  const autoRunning = phase === 'scheduled' || phase === 'running' || phase === 'waiting_step' || phase === 'waiting_email' || phase === 'retrying';
 
 
   return {
   return {
     autoRunning,
     autoRunning,
@@ -1924,7 +2096,7 @@ async function broadcastAutoRunStatus(phase, payload = {}, extraState = {}) {
 }
 }
 
 
 function isAutoRunLockedState(state) {
 function isAutoRunLockedState(state) {
-  return Boolean(state.autoRunning) && (state.autoRunPhase === 'running' || state.autoRunPhase === 'retrying');
+  return Boolean(state.autoRunning) && (state.autoRunPhase === 'running' || state.autoRunPhase === 'waiting_step' || state.autoRunPhase === 'retrying');
 }
 }
 
 
 function isAutoRunPausedState(state) {
 function isAutoRunPausedState(state) {
@@ -2211,6 +2383,7 @@ async function humanStepDelay(min = HUMAN_STEP_DELAY_MIN, max = HUMAN_STEP_DELAY
 }
 }
 
 
 async function clickWithDebugger(tabId, rect) {
 async function clickWithDebugger(tabId, rect) {
+  throwIfStopped();
   if (!tabId) {
   if (!tabId) {
     throw new Error('未找到用于调试点击的认证页面标签页。');
     throw new Error('未找到用于调试点击的认证页面标签页。');
   }
   }
@@ -2229,10 +2402,12 @@ async function clickWithDebugger(tabId, rect) {
   }
   }
 
 
   try {
   try {
+    throwIfStopped();
     const x = Math.round(rect.centerX);
     const x = Math.round(rect.centerX);
     const y = Math.round(rect.centerY);
     const y = Math.round(rect.centerY);
 
 
     await chrome.debugger.sendCommand(target, 'Page.bringToFront');
     await chrome.debugger.sendCommand(target, 'Page.bringToFront');
+    throwIfStopped();
     await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {
     await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {
       type: 'mouseMoved',
       type: 'mouseMoved',
       x,
       x,
@@ -2241,6 +2416,7 @@ async function clickWithDebugger(tabId, rect) {
       buttons: 0,
       buttons: 0,
       clickCount: 0,
       clickCount: 0,
     });
     });
+    throwIfStopped();
     await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {
     await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {
       type: 'mousePressed',
       type: 'mousePressed',
       x,
       x,
@@ -2249,6 +2425,7 @@ async function clickWithDebugger(tabId, rect) {
       buttons: 1,
       buttons: 1,
       clickCount: 1,
       clickCount: 1,
     });
     });
+    throwIfStopped();
     await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {
     await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {
       type: 'mouseReleased',
       type: 'mouseReleased',
       x,
       x,
@@ -2430,31 +2607,21 @@ async function handleMessage(message, sender) {
     }
     }
 
 
     case 'SAVE_SETTING': {
     case 'SAVE_SETTING': {
-      const updates = {};
-      if (message.payload.panelMode !== undefined) updates.panelMode = message.payload.panelMode;
-      if (message.payload.vpsUrl !== undefined) updates.vpsUrl = message.payload.vpsUrl;
-      if (message.payload.vpsPassword !== undefined) updates.vpsPassword = message.payload.vpsPassword;
-      if (message.payload.sub2apiUrl !== undefined) updates.sub2apiUrl = message.payload.sub2apiUrl;
-      if (message.payload.sub2apiEmail !== undefined) updates.sub2apiEmail = message.payload.sub2apiEmail;
-      if (message.payload.sub2apiPassword !== undefined) updates.sub2apiPassword = message.payload.sub2apiPassword;
-      if (message.payload.sub2apiGroupName !== undefined) updates.sub2apiGroupName = message.payload.sub2apiGroupName;
-      if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword;
-      if (message.payload.autoRunSkipFailures !== undefined) updates.autoRunSkipFailures = Boolean(message.payload.autoRunSkipFailures);
-      if (message.payload.autoRunDelayEnabled !== undefined) updates.autoRunDelayEnabled = Boolean(message.payload.autoRunDelayEnabled);
-      if (message.payload.autoRunDelayMinutes !== undefined) updates.autoRunDelayMinutes = normalizeAutoRunDelayMinutes(message.payload.autoRunDelayMinutes);
-      if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider;
-      if (message.payload.emailGenerator !== undefined) updates.emailGenerator = normalizeEmailGenerator(message.payload.emailGenerator);
-      if (message.payload.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost;
-      if (message.payload.inbucketMailbox !== undefined) updates.inbucketMailbox = message.payload.inbucketMailbox;
-      if (message.payload.cloudflareDomain !== undefined) updates.cloudflareDomain = normalizeCloudflareDomain(message.payload.cloudflareDomain);
-      if (message.payload.cloudflareDomains !== undefined) updates.cloudflareDomains = Array.isArray(message.payload.cloudflareDomains)
-        ? message.payload.cloudflareDomains.map(domain => normalizeCloudflareDomain(domain)).filter(Boolean)
-        : [];
+      const updates = buildPersistentSettingsPayload(message.payload || {});
       await setPersistentSettings(updates);
       await setPersistentSettings(updates);
       await setState(updates);
       await setState(updates);
       return { ok: true };
       return { ok: true };
     }
     }
 
 
+    case 'EXPORT_SETTINGS': {
+      return { ok: true, ...(await exportSettingsBundle()) };
+    }
+
+    case 'IMPORT_SETTINGS': {
+      const state = await importSettingsBundle(message.payload?.config || null);
+      return { ok: true, state };
+    }
+
     case 'UPSERT_HOTMAIL_ACCOUNT': {
     case 'UPSERT_HOTMAIL_ACCOUNT': {
       const account = await upsertHotmailAccount(message.payload || {});
       const account = await upsertHotmailAccount(message.payload || {});
       return { ok: true, account };
       return { ok: true, account };
@@ -2513,6 +2680,16 @@ async function handleMessage(message, sender) {
     }
     }
 
 
     // Side panel data updates
     // Side panel data updates
+    case 'SET_EMAIL_STATE': {
+      const state = await getState();
+      if (isAutoRunLockedState(state)) {
+        throw new Error('自动流程运行中,当前不能手动修改邮箱。');
+      }
+      const email = String(message.payload?.email || '').trim() || null;
+      await setEmailStateSilently(email);
+      return { ok: true, email };
+    }
+
     case 'SAVE_EMAIL': {
     case 'SAVE_EMAIL': {
       const state = await getState();
       const state = await getState();
       if (isAutoRunLockedState(state)) {
       if (isAutoRunLockedState(state)) {
@@ -2688,11 +2865,29 @@ async function completeStepFromBackground(step, payload = {}) {
   notifyStepComplete(step, payload);
   notifyStepComplete(step, payload);
 }
 }
 
 
+async function waitForRunningStepsToFinish(payload = {}) {
+  let currentState = await getState();
+  let runningSteps = getRunningSteps(currentState.stepStatuses);
+  if (!runningSteps.length) {
+    return currentState;
+  }
+
+  await addLog(`自动继续:检测到步骤 ${runningSteps.join(', ')} 正在运行,等待完成后再继续自动流程...`, 'info');
+  await broadcastAutoRunStatus('waiting_step', payload);
+
+  while (runningSteps.length) {
+    await sleepWithStop(250);
+    currentState = await getState();
+    runningSteps = getRunningSteps(currentState.stepStatuses);
+  }
+
+  await addLog('自动继续:当前运行步骤已结束,准备按最新进度继续自动流程...', 'info');
+  return currentState;
+}
+
 async function markRunningStepsStopped() {
 async function markRunningStepsStopped() {
   const state = await getState();
   const state = await getState();
-  const runningSteps = Object.entries(state.stepStatuses || {})
-    .filter(([, status]) => status === 'running')
-    .map(([step]) => Number(step));
+  const runningSteps = getRunningSteps(state.stepStatuses);
 
 
   for (const step of runningSteps) {
   for (const step of runningSteps) {
     await setStepStatus(step, 'stopped');
     await setStepStatus(step, 'stopped');
@@ -3083,10 +3278,14 @@ async function autoRunLoop(totalRuns, options = {}) {
   let attemptRuns = Math.max(0, resumeAttemptRunsProcessed);
   let attemptRuns = Math.max(0, resumeAttemptRunsProcessed);
   let forceFreshTabsNextRun = false;
   let forceFreshTabsNextRun = false;
   let continueCurrentOnFirstAttempt = initialMode === 'continue';
   let continueCurrentOnFirstAttempt = initialMode === 'continue';
+  const initialState = await getState();
+  const initialPhase = continueCurrentOnFirstAttempt && getRunningSteps(initialState.stepStatuses).length
+    ? 'waiting_step'
+    : 'running';
 
 
   await setState({
   await setState({
     autoRunSkipFailures,
     autoRunSkipFailures,
-    ...getAutoRunStatusPayload('running', {
+    ...getAutoRunStatusPayload(initialPhase, {
       currentRun: resumeCurrentRun,
       currentRun: resumeCurrentRun,
       totalRuns,
       totalRuns,
       attemptRun: resumeAttemptRunsProcessed,
       attemptRun: resumeAttemptRunsProcessed,
@@ -3102,7 +3301,14 @@ async function autoRunLoop(totalRuns, options = {}) {
     let useExistingProgress = false;
     let useExistingProgress = false;
 
 
     if (continueCurrentOnFirstAttempt) {
     if (continueCurrentOnFirstAttempt) {
-      const currentState = await getState();
+      let currentState = await getState();
+      if (getRunningSteps(currentState.stepStatuses).length) {
+        currentState = await waitForRunningStepsToFinish({
+          currentRun: targetRun,
+          totalRuns,
+          attemptRun: attemptRuns,
+        });
+      }
       const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses);
       const resumeStep = getFirstUnfinishedStep(currentState.stepStatuses);
       if (resumeStep && hasSavedProgress(currentState.stepStatuses)) {
       if (resumeStep && hasSavedProgress(currentState.stepStatuses)) {
         startStep = resumeStep;
         startStep = resumeStep;
@@ -4415,7 +4621,7 @@ async function executeCpaStep9(state) {
   }
   }
 
 
   if (shouldBypassStep9ForLocalCpa(state)) {
   if (shouldBypassStep9ForLocalCpa(state)) {
-    await addLog('步骤 9:检测到本地 CPA,步骤 8 完成后已自动添加,无需重复提交回调地址。', 'info');
+    await addLog('步骤 9:检测到本地 CPA,且当前策略为“跳过第9步”,本轮不再重复提交回调地址。', 'info');
     await completeStepFromBackground(9, {
     await completeStepFromBackground(9, {
       localhostUrl: state.localhostUrl,
       localhostUrl: state.localhostUrl,
       verifiedStatus: 'local-auto',
       verifiedStatus: 'local-auto',

+ 118 - 0
sidepanel/sidepanel.css

@@ -142,6 +142,67 @@ header {
 [data-theme="dark"] .theme-toggle .icon-moon { display: none; }
 [data-theme="dark"] .theme-toggle .icon-moon { display: none; }
 [data-theme="dark"] .theme-toggle .icon-sun { display: block; }
 [data-theme="dark"] .theme-toggle .icon-sun { display: block; }
 
 
+.header-menu {
+  position: relative;
+  display: flex;
+  align-items: center;
+}
+
+.header-config-btn {
+  min-width: 0;
+  padding-inline: 10px;
+}
+
+.header-config-btn[aria-expanded="true"] {
+  background: var(--bg-hover);
+  color: var(--text-primary);
+}
+
+.header-dropdown {
+  position: absolute;
+  top: calc(100% + 6px);
+  right: 0;
+  min-width: 120px;
+  padding: 6px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  background: var(--bg-base);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  box-shadow: var(--shadow-md);
+  z-index: 1300;
+}
+
+.header-dropdown[hidden] {
+  display: none !important;
+}
+
+.header-dropdown-item {
+  width: 100%;
+  padding: 8px 10px;
+  border: none;
+  border-radius: var(--radius-sm);
+  background: transparent;
+  color: var(--text-secondary);
+  font: inherit;
+  font-size: 12px;
+  font-weight: 600;
+  text-align: left;
+  cursor: pointer;
+  transition: background var(--transition), color var(--transition);
+}
+
+.header-dropdown-item:hover:not(:disabled) {
+  background: var(--bg-hover);
+  color: var(--text-primary);
+}
+
+.header-dropdown-item:disabled {
+  color: var(--text-muted);
+  cursor: not-allowed;
+}
+
 /* ============================================================
 /* ============================================================
    Buttons
    Buttons
    ============================================================ */
    ============================================================ */
@@ -238,6 +299,20 @@ header {
   box-shadow: var(--shadow-sm);
   box-shadow: var(--shadow-sm);
 }
 }
 
 
+.data-card.is-locked {
+  position: relative;
+  overflow: hidden;
+}
+
+.data-card.is-locked::after {
+  content: '';
+  position: absolute;
+  inset: 0;
+  border-radius: inherit;
+  background: color-mix(in srgb, var(--bg-base) 18%, transparent);
+  pointer-events: none;
+}
+
 .section-mini-header {
 .section-mini-header {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -283,6 +358,10 @@ header {
   min-width: 0;
   min-width: 0;
 }
 }
 
 
+.data-value-actions {
+  justify-content: space-between;
+}
+
 .input-with-icon {
 .input-with-icon {
   position: relative;
   position: relative;
   flex: 1;
   flex: 1;
@@ -307,6 +386,10 @@ header {
   min-width: 0;
   min-width: 0;
 }
 }
 
 
+.data-value-fill {
+  flex: 1;
+}
+
 .data-value.has-value { color: var(--text-primary); }
 .data-value.has-value { color: var(--text-primary); }
 
 
 .mono {
 .mono {
@@ -378,6 +461,41 @@ header {
   background: var(--bg-hover);
   background: var(--bg-hover);
 }
 }
 
 
+.choice-group {
+  flex: 1;
+  min-width: 0;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.choice-btn {
+  flex: 1;
+  min-width: 0;
+  padding: 7px 10px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--bg-base);
+  color: var(--text-secondary);
+  font-size: 12px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: border-color var(--transition), background var(--transition), color var(--transition), box-shadow var(--transition);
+}
+
+.choice-btn:hover {
+  border-color: var(--blue);
+  color: var(--blue);
+  background: var(--blue-soft);
+}
+
+.choice-btn.is-active {
+  border-color: var(--blue);
+  color: var(--blue);
+  background: var(--blue-soft);
+  box-shadow: 0 0 0 1px var(--blue-glow);
+}
+
 #btn-fetch-email,
 #btn-fetch-email,
 #btn-save-settings {
 #btn-save-settings {
   padding-inline: 10px;
   padding-inline: 10px;

+ 28 - 8
sidepanel/sidepanel.html

@@ -58,11 +58,19 @@
           <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
           <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
         </svg>
         </svg>
       </button>
       </button>
+      <div id="config-menu-shell" class="header-menu">
+        <button id="btn-config-menu" class="btn btn-ghost btn-sm header-config-btn" type="button" aria-haspopup="menu"
+          aria-expanded="false">配置</button>
+        <div id="config-menu" class="header-dropdown" role="menu" aria-labelledby="btn-config-menu" hidden>
+          <button id="btn-export-settings" class="header-dropdown-item" type="button" role="menuitem">导出配置</button>
+          <button id="btn-import-settings" class="header-dropdown-item" type="button" role="menuitem">导入配置</button>
+        </div>
+      </div>
     </div>
     </div>
   </header>
   </header>
 
 
   <section id="data-section">
   <section id="data-section">
-    <div class="data-card">
+    <div id="settings-card" class="data-card">
       <div class="data-row">
       <div class="data-row">
         <span class="data-label">来源</span>
         <span class="data-label">来源</span>
         <select id="select-panel-mode" class="data-select">
         <select id="select-panel-mode" class="data-select">
@@ -81,7 +89,18 @@
       </div>
       </div>
       <div class="data-row" id="row-vps-password">
       <div class="data-row" id="row-vps-password">
         <span class="data-label">管理密钥</span>
         <span class="data-label">管理密钥</span>
-        <input type="password" id="input-vps-password" class="data-input" placeholder="请输入 CPA 管理密钥" />
+        <div class="input-with-icon">
+          <input type="password" id="input-vps-password" class="data-input data-input-with-icon" placeholder="请输入 CPA 管理密钥" />
+          <button id="btn-toggle-vps-password" class="input-icon-btn" type="button" aria-label="显示管理密钥"
+            title="显示管理密钥"></button>
+        </div>
+      </div>
+      <div class="data-row" id="row-local-cpa-step9-mode">
+        <span class="data-label">本地 CPA</span>
+        <div id="local-cpa-step9-mode-group" class="choice-group" role="group" aria-label="本地 CPA 第 9 步策略">
+          <button type="button" class="choice-btn" data-local-cpa-step9-mode="submit">全部回调</button>
+          <button type="button" class="choice-btn" data-local-cpa-step9-mode="bypass">跳过第9步</button>
+        </div>
       </div>
       </div>
       <div class="data-row" id="row-sub2api-url" style="display:none;">
       <div class="data-row" id="row-sub2api-url" style="display:none;">
         <span class="data-label">SUB2API</span>
         <span class="data-label">SUB2API</span>
@@ -101,14 +120,11 @@
       </div>
       </div>
       <div class="data-row">
       <div class="data-row">
         <span class="data-label">codex密码</span>
         <span class="data-label">codex密码</span>
-        <div class="data-inline">
-          <div class="input-with-icon">
-            <input type="password" id="input-password" class="data-input data-input-with-icon"
+        <div class="input-with-icon">
+          <input type="password" id="input-password" class="data-input data-input-with-icon"
               placeholder="codex密码,留空则自动生成" />
               placeholder="codex密码,留空则自动生成" />
             <button id="btn-toggle-password" class="input-icon-btn" type="button" aria-label="显示密码"
             <button id="btn-toggle-password" class="input-icon-btn" type="button" aria-label="显示密码"
               title="显示密码"></button>
               title="显示密码"></button>
-          </div>
-          <button id="btn-save-settings" class="btn btn-outline btn-sm" type="button">保存</button>
         </div>
         </div>
       </div>
       </div>
       <div class="data-row">
       <div class="data-row">
@@ -179,7 +195,10 @@
       </div>
       </div>
       <div class="data-row">
       <div class="data-row">
         <span class="data-label">回调</span>
         <span class="data-label">回调</span>
-        <span id="display-localhost-url" class="data-value mono truncate">等待中...</span>
+        <div class="data-inline data-value-actions">
+          <span id="display-localhost-url" class="data-value data-value-fill mono truncate">等待中...</span>
+          <button id="btn-save-settings" class="btn btn-outline btn-sm" type="button">保存</button>
+        </div>
       </div>
       </div>
     </div>
     </div>
     <div id="hotmail-section" class="data-card hotmail-card" style="display:none;">
     <div id="hotmail-section" class="data-card hotmail-card" style="display:none;">
@@ -332,6 +351,7 @@
   </div>
   </div>
 
 
   <div id="toast-container"></div>
   <div id="toast-container"></div>
+  <input id="input-import-settings-file" type="file" accept=".json,application/json" hidden />
   <script src="../hotmail-utils.js"></script>
   <script src="../hotmail-utils.js"></script>
   <script src="sidepanel.js"></script>
   <script src="sidepanel.js"></script>
 </body>
 </body>

+ 398 - 62
sidepanel/sidepanel.js

@@ -11,6 +11,7 @@ const STATUS_ICONS = {
 };
 };
 
 
 const logArea = document.getElementById('log-area');
 const logArea = document.getElementById('log-area');
+const settingsCard = document.getElementById('settings-card');
 const displayOauthUrl = document.getElementById('display-oauth-url');
 const displayOauthUrl = document.getElementById('display-oauth-url');
 const displayLocalhostUrl = document.getElementById('display-localhost-url');
 const displayLocalhostUrl = document.getElementById('display-localhost-url');
 const displayStatus = document.getElementById('display-status');
 const displayStatus = document.getElementById('display-status');
@@ -18,6 +19,7 @@ const statusBar = document.getElementById('status-bar');
 const inputEmail = document.getElementById('input-email');
 const inputEmail = document.getElementById('input-email');
 const inputPassword = document.getElementById('input-password');
 const inputPassword = document.getElementById('input-password');
 const btnToggleVpsUrl = document.getElementById('btn-toggle-vps-url');
 const btnToggleVpsUrl = document.getElementById('btn-toggle-vps-url');
+const btnToggleVpsPassword = document.getElementById('btn-toggle-vps-password');
 const btnFetchEmail = document.getElementById('btn-fetch-email');
 const btnFetchEmail = document.getElementById('btn-fetch-email');
 const btnTogglePassword = document.getElementById('btn-toggle-password');
 const btnTogglePassword = document.getElementById('btn-toggle-password');
 const btnSaveSettings = document.getElementById('btn-save-settings');
 const btnSaveSettings = document.getElementById('btn-save-settings');
@@ -33,11 +35,19 @@ const autoScheduleMeta = document.getElementById('auto-schedule-meta');
 const btnAutoRunNow = document.getElementById('btn-auto-run-now');
 const btnAutoRunNow = document.getElementById('btn-auto-run-now');
 const btnAutoCancelSchedule = document.getElementById('btn-auto-cancel-schedule');
 const btnAutoCancelSchedule = document.getElementById('btn-auto-cancel-schedule');
 const btnClearLog = document.getElementById('btn-clear-log');
 const btnClearLog = document.getElementById('btn-clear-log');
+const configMenuShell = document.getElementById('config-menu-shell');
+const btnConfigMenu = document.getElementById('btn-config-menu');
+const configMenu = document.getElementById('config-menu');
+const btnExportSettings = document.getElementById('btn-export-settings');
+const btnImportSettings = document.getElementById('btn-import-settings');
+const inputImportSettingsFile = document.getElementById('input-import-settings-file');
 const selectPanelMode = document.getElementById('select-panel-mode');
 const selectPanelMode = document.getElementById('select-panel-mode');
 const rowVpsUrl = document.getElementById('row-vps-url');
 const rowVpsUrl = document.getElementById('row-vps-url');
 const inputVpsUrl = document.getElementById('input-vps-url');
 const inputVpsUrl = document.getElementById('input-vps-url');
 const rowVpsPassword = document.getElementById('row-vps-password');
 const rowVpsPassword = document.getElementById('row-vps-password');
 const inputVpsPassword = document.getElementById('input-vps-password');
 const inputVpsPassword = document.getElementById('input-vps-password');
+const rowLocalCpaStep9Mode = document.getElementById('row-local-cpa-step9-mode');
+const localCpaStep9ModeButtons = Array.from(document.querySelectorAll('[data-local-cpa-step9-mode]'));
 const rowSub2ApiUrl = document.getElementById('row-sub2api-url');
 const rowSub2ApiUrl = document.getElementById('row-sub2api-url');
 const inputSub2ApiUrl = document.getElementById('input-sub2api-url');
 const inputSub2ApiUrl = document.getElementById('input-sub2api-url');
 const rowSub2ApiEmail = document.getElementById('row-sub2api-email');
 const rowSub2ApiEmail = document.getElementById('row-sub2api-email');
@@ -96,6 +106,7 @@ const SKIPPABLE_STEPS = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9]);
 const AUTO_DELAY_MIN_MINUTES = 1;
 const AUTO_DELAY_MIN_MINUTES = 1;
 const AUTO_DELAY_MAX_MINUTES = 1440;
 const AUTO_DELAY_MAX_MINUTES = 1440;
 const AUTO_DELAY_DEFAULT_MINUTES = 30;
 const AUTO_DELAY_DEFAULT_MINUTES = 30;
+const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
 
 
 let latestState = null;
 let latestState = null;
 let currentAutoRun = {
 let currentAutoRun = {
@@ -115,6 +126,8 @@ let currentModalActions = [];
 let scheduledCountdownTimer = null;
 let scheduledCountdownTimer = null;
 let hotmailActionInFlight = false;
 let hotmailActionInFlight = false;
 let hotmailListExpanded = false;
 let hotmailListExpanded = false;
+let configMenuOpen = false;
+let configActionInFlight = false;
 
 
 const EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
 const EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
 const EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
 const EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
@@ -225,10 +238,14 @@ function openActionModal({ title, message, actions }) {
   });
   });
 }
 }
 
 
-function openAutoStartChoiceDialog(startStep) {
+function openAutoStartChoiceDialog(startStep, options = {}) {
+  const runningStep = Number.isInteger(options.runningStep) ? options.runningStep : null;
+  const continueMessage = runningStep
+    ? `继续当前会先等待步骤 ${runningStep} 完成,再按最新进度自动执行。`
+    : `继续当前会从步骤 ${startStep} 开始自动执行。`;
   return openActionModal({
   return openActionModal({
     title: '启动自动',
     title: '启动自动',
-    message: `检测到当前已有流程进度。继续当前会从步骤 ${startStep} 开始自动执行,重新开始会清空当前流程进度并从步骤 1 新开一轮。`,
+    message: `检测到当前已有流程进度。${continueMessage}重新开始会清空当前流程进度并从步骤 1 新开一轮。`,
     actions: [
     actions: [
       { id: null, label: '取消', variant: 'btn-ghost' },
       { id: null, label: '取消', variant: 'btn-ghost' },
       { id: 'restart', label: '重新开始', variant: 'btn-outline' },
       { id: 'restart', label: '重新开始', variant: 'btn-outline' },
@@ -249,6 +266,71 @@ async function openConfirmModal({ title, message, confirmLabel = '确认', confi
   return choice === 'confirm';
   return choice === 'confirm';
 }
 }
 
 
+function updateConfigMenuControls() {
+  const disabled = configActionInFlight || settingsSaveInFlight;
+  const importLocked = disabled
+    || currentAutoRun.autoRunning
+    || Object.values(getStepStatuses()).some((status) => status === 'running');
+  if (btnConfigMenu) {
+    btnConfigMenu.disabled = disabled;
+    btnConfigMenu.setAttribute('aria-expanded', String(configMenuOpen));
+  }
+  if (configMenu) {
+    configMenu.hidden = !configMenuOpen;
+  }
+  if (btnExportSettings) {
+    btnExportSettings.disabled = disabled;
+  }
+  if (btnImportSettings) {
+    btnImportSettings.disabled = importLocked;
+  }
+}
+
+function closeConfigMenu() {
+  configMenuOpen = false;
+  updateConfigMenuControls();
+}
+
+function openConfigMenu() {
+  configMenuOpen = true;
+  updateConfigMenuControls();
+}
+
+function toggleConfigMenu() {
+  configMenuOpen ? closeConfigMenu() : openConfigMenu();
+}
+
+async function waitForSettingsSaveIdle() {
+  while (settingsSaveInFlight) {
+    await new Promise((resolve) => setTimeout(resolve, 50));
+  }
+}
+
+async function flushPendingSettingsBeforeExport() {
+  clearTimeout(settingsAutoSaveTimer);
+  await waitForSettingsSaveIdle();
+  if (settingsDirty) {
+    await saveSettings({ silent: true });
+  }
+}
+
+async function settlePendingSettingsBeforeImport() {
+  clearTimeout(settingsAutoSaveTimer);
+  await waitForSettingsSaveIdle();
+}
+
+function downloadTextFile(content, fileName, mimeType = 'application/json;charset=utf-8') {
+  const blob = new Blob([content], { type: mimeType });
+  const objectUrl = URL.createObjectURL(blob);
+  const anchor = document.createElement('a');
+  anchor.href = objectUrl;
+  anchor.download = fileName;
+  document.body.appendChild(anchor);
+  anchor.click();
+  anchor.remove();
+  setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
+}
+
 function isDoneStatus(status) {
 function isDoneStatus(status) {
   return status === 'completed' || status === 'manual_completed' || status === 'skipped';
   return status === 'completed' || status === 'manual_completed' || status === 'skipped';
 }
 }
@@ -267,6 +349,14 @@ function getFirstUnfinishedStep(state = latestState) {
   return null;
   return null;
 }
 }
 
 
+function getRunningSteps(state = latestState) {
+  const statuses = getStepStatuses(state);
+  return Object.entries(statuses)
+    .filter(([, status]) => status === 'running')
+    .map(([step]) => Number(step))
+    .sort((a, b) => a - b);
+}
+
 function hasSavedProgress(state = latestState) {
 function hasSavedProgress(state = latestState) {
   const statuses = getStepStatuses(state);
   const statuses = getStepStatuses(state);
   return Object.values(statuses).some((status) => status !== 'pending');
   return Object.values(statuses).some((status) => status !== 'pending');
@@ -293,7 +383,7 @@ function syncAutoRunState(source = {}) {
   const autoRunning = source.autoRunning !== undefined
   const autoRunning = source.autoRunning !== undefined
     ? Boolean(source.autoRunning)
     ? Boolean(source.autoRunning)
     : (source.autoRunPhase !== undefined || source.phase !== undefined
     : (source.autoRunPhase !== undefined || source.phase !== undefined
-      ? ['scheduled', 'running', 'waiting_email', 'retrying'].includes(phase)
+      ? ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying'].includes(phase)
       : currentAutoRun.autoRunning);
       : currentAutoRun.autoRunning);
 
 
   currentAutoRun = {
   currentAutoRun = {
@@ -307,13 +397,17 @@ function syncAutoRunState(source = {}) {
 }
 }
 
 
 function isAutoRunLockedPhase() {
 function isAutoRunLockedPhase() {
-  return currentAutoRun.phase === 'running' || currentAutoRun.phase === 'retrying';
+  return currentAutoRun.phase === 'running' || currentAutoRun.phase === 'waiting_step' || currentAutoRun.phase === 'retrying';
 }
 }
 
 
 function isAutoRunPausedPhase() {
 function isAutoRunPausedPhase() {
   return currentAutoRun.phase === 'waiting_email';
   return currentAutoRun.phase === 'waiting_email';
 }
 }
 
 
+function isAutoRunWaitingStepPhase() {
+  return currentAutoRun.phase === 'waiting_step';
+}
+
 function isAutoRunScheduledPhase() {
 function isAutoRunScheduledPhase() {
   return currentAutoRun.phase === 'scheduled';
   return currentAutoRun.phase === 'scheduled';
 }
 }
@@ -492,6 +586,7 @@ function collectSettingsPayload() {
     panelMode: selectPanelMode.value,
     panelMode: selectPanelMode.value,
     vpsUrl: inputVpsUrl.value.trim(),
     vpsUrl: inputVpsUrl.value.trim(),
     vpsPassword: inputVpsPassword.value,
     vpsPassword: inputVpsPassword.value,
+    localCpaStep9Mode: getSelectedLocalCpaStep9Mode(),
     sub2apiUrl: inputSub2ApiUrl.value.trim(),
     sub2apiUrl: inputSub2ApiUrl.value.trim(),
     sub2apiEmail: inputSub2ApiEmail.value.trim(),
     sub2apiEmail: inputSub2ApiEmail.value.trim(),
     sub2apiPassword: inputSub2ApiPassword.value,
     sub2apiPassword: inputSub2ApiPassword.value,
@@ -509,6 +604,68 @@ function collectSettingsPayload() {
   };
   };
 }
 }
 
 
+function normalizeLocalCpaStep9Mode(value = '') {
+  return String(value || '').trim().toLowerCase() === 'bypass'
+    ? 'bypass'
+    : DEFAULT_LOCAL_CPA_STEP9_MODE;
+}
+
+function getSelectedLocalCpaStep9Mode() {
+  const activeButton = localCpaStep9ModeButtons.find((button) => button.classList.contains('is-active'));
+  return normalizeLocalCpaStep9Mode(activeButton?.dataset.localCpaStep9Mode);
+}
+
+function setLocalCpaStep9Mode(mode) {
+  const resolvedMode = normalizeLocalCpaStep9Mode(mode);
+  localCpaStep9ModeButtons.forEach((button) => {
+    const active = button.dataset.localCpaStep9Mode === resolvedMode;
+    button.classList.toggle('is-active', active);
+    button.setAttribute('aria-pressed', String(active));
+  });
+}
+
+function setSettingsCardLocked(locked) {
+  if (!settingsCard) {
+    return;
+  }
+  settingsCard.classList.toggle('is-locked', locked);
+  settingsCard.toggleAttribute('inert', locked);
+}
+
+async function setRuntimeEmailState(email) {
+  const normalizedEmail = String(email || '').trim() || null;
+  const response = await chrome.runtime.sendMessage({
+    type: 'SET_EMAIL_STATE',
+    source: 'sidepanel',
+    payload: { email: normalizedEmail },
+  });
+
+  if (response?.error) {
+    throw new Error(response.error);
+  }
+
+  return normalizedEmail;
+}
+
+async function clearRegistrationEmail(options = {}) {
+  const { silent = false } = options;
+  if (!inputEmail.value.trim() && !latestState?.email) {
+    return;
+  }
+
+  inputEmail.value = '';
+  syncLatestState({ email: null });
+
+  try {
+    await setRuntimeEmailState(null);
+  } catch (err) {
+    if (!silent) {
+      showToast(`清空邮箱失败:${err.message}`, 'error');
+    }
+    throw err;
+  }
+}
+
 function markSettingsDirty(isDirty = true) {
 function markSettingsDirty(isDirty = true) {
   settingsDirty = isDirty;
   settingsDirty = isDirty;
   updateSaveButtonState();
   updateSaveButtonState();
@@ -516,6 +673,7 @@ function markSettingsDirty(isDirty = true) {
 
 
 function updateSaveButtonState() {
 function updateSaveButtonState() {
   btnSaveSettings.disabled = settingsSaveInFlight || !settingsDirty;
   btnSaveSettings.disabled = settingsSaveInFlight || !settingsDirty;
+  updateConfigMenuControls();
   btnSaveSettings.textContent = settingsSaveInFlight ? '保存中' : '保存';
   btnSaveSettings.textContent = settingsSaveInFlight ? '保存中' : '保存';
 }
 }
 
 
@@ -575,6 +733,9 @@ function applyAutoRunStatus(payload = currentAutoRun) {
   const locked = isAutoRunLockedPhase();
   const locked = isAutoRunLockedPhase();
   const paused = isAutoRunPausedPhase();
   const paused = isAutoRunPausedPhase();
   const scheduled = isAutoRunScheduledPhase();
   const scheduled = isAutoRunScheduledPhase();
+  const settingsCardLocked = scheduled || locked;
+
+  setSettingsCardLocked(settingsCardLocked);
 
 
   inputRunCount.disabled = currentAutoRun.autoRunning;
   inputRunCount.disabled = currentAutoRun.autoRunning;
   btnAutoRun.disabled = currentAutoRun.autoRunning;
   btnAutoRun.disabled = currentAutoRun.autoRunning;
@@ -591,6 +752,10 @@ function applyAutoRunStatus(payload = currentAutoRun) {
       autoContinueBar.style.display = 'none';
       autoContinueBar.style.display = 'none';
       btnAutoRun.innerHTML = `已计划${runLabel}`;
       btnAutoRun.innerHTML = `已计划${runLabel}`;
       break;
       break;
+    case 'waiting_step':
+      autoContinueBar.style.display = 'none';
+      btnAutoRun.innerHTML = `等待中${runLabel}`;
+      break;
     case 'waiting_email':
     case 'waiting_email':
       autoContinueBar.style.display = 'flex';
       autoContinueBar.style.display = 'flex';
       btnAutoRun.innerHTML = `已暂停${runLabel}`;
       btnAutoRun.innerHTML = `已暂停${runLabel}`;
@@ -616,6 +781,7 @@ function applyAutoRunStatus(payload = currentAutoRun) {
   updateAutoDelayInputState();
   updateAutoDelayInputState();
   syncScheduledCountdownTicker();
   syncScheduledCountdownTicker();
   updateStopButtonState(scheduled || paused || locked || Object.values(getStepStatuses()).some(status => status === 'running'));
   updateStopButtonState(scheduled || paused || locked || Object.values(getStepStatuses()).some(status => status === 'running'));
+  updateConfigMenuControls();
 }
 }
 
 
 function initializeManualStepActions() {
 function initializeManualStepActions() {
@@ -653,11 +819,45 @@ function initializeManualStepActions() {
 // State Restore on load
 // State Restore on load
 // ============================================================
 // ============================================================
 
 
+function applySettingsState(state) {
+  syncLatestState(state);
+  syncAutoRunState(state);
+
+  inputEmail.value = state?.email || '';
+  syncPasswordField(state || {});
+  inputVpsUrl.value = state?.vpsUrl || '';
+  inputVpsPassword.value = state?.vpsPassword || '';
+  setLocalCpaStep9Mode(state?.localCpaStep9Mode);
+  selectPanelMode.value = state?.panelMode || 'cpa';
+  inputSub2ApiUrl.value = state?.sub2apiUrl || '';
+  inputSub2ApiEmail.value = state?.sub2apiEmail || '';
+  inputSub2ApiPassword.value = state?.sub2apiPassword || '';
+  inputSub2ApiGroup.value = state?.sub2apiGroupName || '';
+  selectMailProvider.value = state?.mailProvider || '163';
+  selectEmailGenerator.value = state?.emailGenerator || 'duck';
+  inputInbucketHost.value = state?.inbucketHost || '';
+  inputInbucketMailbox.value = state?.inbucketMailbox || '';
+  renderCloudflareDomainOptions(state?.cloudflareDomain || '');
+  setCloudflareDomainEditMode(false, { clearInput: true });
+  inputAutoSkipFailures.checked = Boolean(state?.autoRunSkipFailures);
+  inputAutoDelayEnabled.checked = Boolean(state?.autoRunDelayEnabled);
+  inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state?.autoRunDelayMinutes));
+  if (state?.autoRunTotalRuns) {
+    inputRunCount.value = String(state.autoRunTotalRuns);
+  }
+
+  applyAutoRunStatus(state);
+  markSettingsDirty(false);
+  updateAutoDelayInputState();
+  updatePanelModeUI();
+  updateMailProviderUI();
+  updateButtonStates();
+}
+
 async function restoreState() {
 async function restoreState() {
   try {
   try {
     const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
     const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
-    syncLatestState(state);
-    syncAutoRunState(state);
+    applySettingsState(state);
 
 
     if (state.oauthUrl) {
     if (state.oauthUrl) {
       displayOauthUrl.textContent = state.oauthUrl;
       displayOauthUrl.textContent = state.oauthUrl;
@@ -667,52 +867,6 @@ async function restoreState() {
       displayLocalhostUrl.textContent = state.localhostUrl;
       displayLocalhostUrl.textContent = state.localhostUrl;
       displayLocalhostUrl.classList.add('has-value');
       displayLocalhostUrl.classList.add('has-value');
     }
     }
-    if (state.email) {
-      inputEmail.value = state.email;
-    }
-    syncPasswordField(state);
-    if (state.vpsUrl) {
-      inputVpsUrl.value = state.vpsUrl;
-    }
-    if (state.vpsPassword) {
-      inputVpsPassword.value = state.vpsPassword;
-    }
-    if (state.panelMode) {
-      selectPanelMode.value = state.panelMode;
-    }
-    if (state.sub2apiUrl) {
-      inputSub2ApiUrl.value = state.sub2apiUrl;
-    }
-    if (state.sub2apiEmail) {
-      inputSub2ApiEmail.value = state.sub2apiEmail;
-    }
-    if (state.sub2apiPassword) {
-      inputSub2ApiPassword.value = state.sub2apiPassword;
-    }
-    if (state.sub2apiGroupName) {
-      inputSub2ApiGroup.value = state.sub2apiGroupName;
-    }
-    if (state.mailProvider) {
-      selectMailProvider.value = state.mailProvider;
-    }
-    if (state.emailGenerator) {
-      selectEmailGenerator.value = state.emailGenerator;
-    }
-    if (state.inbucketHost) {
-      inputInbucketHost.value = state.inbucketHost;
-    }
-    if (state.inbucketMailbox) {
-      inputInbucketMailbox.value = state.inbucketMailbox;
-    }
-    renderCloudflareDomainOptions(state.cloudflareDomain || '');
-    setCloudflareDomainEditMode(false, { clearInput: true });
-    inputAutoSkipFailures.checked = Boolean(state.autoRunSkipFailures);
-    inputAutoDelayEnabled.checked = Boolean(state.autoRunDelayEnabled);
-    inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state.autoRunDelayMinutes));
-    if (state.autoRunTotalRuns) {
-      inputRunCount.value = String(state.autoRunTotalRuns);
-    }
-
     if (state.stepStatuses) {
     if (state.stepStatuses) {
       for (const [step, status] of Object.entries(state.stepStatuses)) {
       for (const [step, status] of Object.entries(state.stepStatuses)) {
         updateStepUI(Number(step), status);
         updateStepUI(Number(step), status);
@@ -725,14 +879,8 @@ async function restoreState() {
       }
       }
     }
     }
 
 
-    applyAutoRunStatus(state);
-    markSettingsDirty(false);
-    updateAutoDelayInputState();
     updateStatusDisplay(latestState);
     updateStatusDisplay(latestState);
     updateProgressCounter();
     updateProgressCounter();
-    updatePanelModeUI();
-    updateMailProviderUI();
-    updateButtonStates();
   } catch (err) {
   } catch (err) {
     console.error('Failed to restore state:', err);
     console.error('Failed to restore state:', err);
   }
   }
@@ -1051,6 +1199,7 @@ function updatePanelModeUI() {
   const useSub2Api = selectPanelMode.value === 'sub2api';
   const useSub2Api = selectPanelMode.value === 'sub2api';
   rowVpsUrl.style.display = useSub2Api ? 'none' : '';
   rowVpsUrl.style.display = useSub2Api ? 'none' : '';
   rowVpsPassword.style.display = useSub2Api ? 'none' : '';
   rowVpsPassword.style.display = useSub2Api ? 'none' : '';
+  rowLocalCpaStep9Mode.style.display = useSub2Api ? 'none' : '';
   rowSub2ApiUrl.style.display = useSub2Api ? '' : 'none';
   rowSub2ApiUrl.style.display = useSub2Api ? '' : 'none';
   rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none';
   rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none';
   rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none';
   rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none';
@@ -1084,6 +1233,7 @@ function updateStepUI(step, status) {
 
 
   updateButtonStates();
   updateButtonStates();
   updateProgressCounter();
   updateProgressCounter();
+  updateConfigMenuControls();
 }
 }
 
 
 function updateProgressCounter() {
 function updateProgressCounter() {
@@ -1166,6 +1316,15 @@ function updateStatusDisplay(state) {
     return;
     return;
   }
   }
 
 
+  if (isAutoRunWaitingStepPhase()) {
+    const runningSteps = getRunningSteps(state);
+    displayStatus.textContent = runningSteps.length
+      ? `自动等待步骤 ${runningSteps.join(', ')} 完成后继续${getAutoRunLabel()}`
+      : `自动正在按最新进度准备继续${getAutoRunLabel()}`;
+    statusBar.classList.add('running');
+    return;
+  }
+
   const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
   const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
   if (running) {
   if (running) {
     displayStatus.textContent = `步骤 ${running[0]} 运行中...`;
     displayStatus.textContent = `步骤 ${running[0]} 运行中...`;
@@ -1295,6 +1454,93 @@ async function copyTextToClipboard(text) {
   await navigator.clipboard.writeText(value);
   await navigator.clipboard.writeText(value);
 }
 }
 
 
+async function exportSettingsFile() {
+  closeConfigMenu();
+  configActionInFlight = true;
+  updateConfigMenuControls();
+
+  try {
+    await flushPendingSettingsBeforeExport();
+    const response = await chrome.runtime.sendMessage({
+      type: 'EXPORT_SETTINGS',
+      source: 'sidepanel',
+      payload: {},
+    });
+
+    if (response?.error) {
+      throw new Error(response.error);
+    }
+    if (!response?.fileContent || !response?.fileName) {
+      throw new Error('\u672a\u751f\u6210\u53ef\u4e0b\u8f7d\u7684\u914d\u7f6e\u6587\u4ef6\u3002');
+    }
+
+    downloadTextFile(response.fileContent, response.fileName);
+    showToast('\u914d\u7f6e\u5df2\u5bfc\u51fa\uff1a' + response.fileName, 'success', 2200);
+  } catch (err) {
+    showToast('\u5bfc\u51fa\u914d\u7f6e\u5931\u8d25\uff1a' + err.message, 'error');
+  } finally {
+    configActionInFlight = false;
+    updateConfigMenuControls();
+  }
+}
+
+async function importSettingsFromFile(file) {
+  if (!file) return;
+
+  configActionInFlight = true;
+  closeConfigMenu();
+  updateConfigMenuControls();
+
+  try {
+    await settlePendingSettingsBeforeImport();
+    const rawText = await file.text();
+
+    let parsedConfig = null;
+    try {
+      parsedConfig = JSON.parse(rawText);
+    } catch {
+      throw new Error('\u914d\u7f6e\u6587\u4ef6\u4e0d\u662f\u6709\u6548\u7684 JSON\u3002');
+    }
+
+    const confirmed = await openConfirmModal({
+      title: '\u5bfc\u5165\u914d\u7f6e',
+      message: '\u786e\u8ba4\u5bfc\u5165\u914d\u7f6e\u6587\u4ef6 "' + file.name + '" \u5417\uff1f\u5bfc\u5165\u540e\u4f1a\u8986\u76d6\u5f53\u524d\u914d\u7f6e\u3002',
+      confirmLabel: '\u786e\u8ba4\u8986\u76d6\u5bfc\u5165',
+      confirmVariant: 'btn-danger',
+    });
+    if (!confirmed) {
+      return;
+    }
+
+    const response = await chrome.runtime.sendMessage({
+      type: 'IMPORT_SETTINGS',
+      source: 'sidepanel',
+      payload: {
+        config: parsedConfig,
+      },
+    });
+
+    if (response?.error) {
+      throw new Error(response.error);
+    }
+    if (!response?.state) {
+      throw new Error('\u5bfc\u5165\u540e\u672a\u8fd4\u56de\u6700\u65b0\u914d\u7f6e\u72b6\u6001\u3002');
+    }
+
+    applySettingsState(response.state);
+    updateStatusDisplay(latestState);
+    showToast('\u914d\u7f6e\u5df2\u5bfc\u5165\uff0c\u5f53\u524d\u914d\u7f6e\u5df2\u8986\u76d6\u3002', 'success', 2200);
+  } catch (err) {
+    showToast('\u5bfc\u5165\u914d\u7f6e\u5931\u8d25\uff1a' + err.message, 'error');
+  } finally {
+    configActionInFlight = false;
+    updateConfigMenuControls();
+    if (inputImportSettingsFile) {
+      inputImportSettingsFile.value = '';
+    }
+  }
+}
+
 async function deleteHotmailAccountsByMode(mode) {
 async function deleteHotmailAccountsByMode(mode) {
   const isUsedMode = mode === 'used';
   const isUsedMode = mode === 'used';
   const targetAccounts = getHotmailAccountsByUsage(isUsedMode ? 'used' : 'all');
   const targetAccounts = getHotmailAccountsByUsage(isUsedMode ? 'used' : 'all');
@@ -1362,6 +1608,13 @@ function syncVpsUrlToggleLabel() {
   });
   });
 }
 }
 
 
+function syncVpsPasswordToggleLabel() {
+  syncToggleButtonLabel(btnToggleVpsPassword, inputVpsPassword, {
+    show: '显示管理密钥',
+    hide: '隐藏管理密钥',
+  });
+}
+
 async function maybeTakeoverAutoRun(actionLabel) {
 async function maybeTakeoverAutoRun(actionLabel) {
   if (!isAutoRunPausedPhase()) {
   if (!isAutoRunPausedPhase()) {
     return true;
     return true;
@@ -1698,6 +1951,23 @@ btnToggleVpsUrl.addEventListener('click', () => {
   syncVpsUrlToggleLabel();
   syncVpsUrlToggleLabel();
 });
 });
 
 
+btnToggleVpsPassword.addEventListener('click', () => {
+  inputVpsPassword.type = inputVpsPassword.type === 'password' ? 'text' : 'password';
+  syncVpsPasswordToggleLabel();
+});
+
+localCpaStep9ModeButtons.forEach((button) => {
+  button.addEventListener('click', () => {
+    const nextMode = button.dataset.localCpaStep9Mode;
+    if (getSelectedLocalCpaStep9Mode() === normalizeLocalCpaStep9Mode(nextMode)) {
+      return;
+    }
+    setLocalCpaStep9Mode(nextMode);
+    markSettingsDirty(true);
+    saveSettings({ silent: true }).catch(() => { });
+  });
+});
+
 btnSaveSettings.addEventListener('click', async () => {
 btnSaveSettings.addEventListener('click', async () => {
   if (!settingsDirty) {
   if (!settingsDirty) {
     showToast('配置已是最新', 'info', 1400);
     showToast('配置已是最新', 'info', 1400);
@@ -1712,6 +1982,38 @@ btnStop.addEventListener('click', async () => {
   showToast(isAutoRunScheduledPhase() ? '正在取消倒计时计划...' : '正在停止当前流程...', 'warn', 2000);
   showToast(isAutoRunScheduledPhase() ? '正在取消倒计时计划...' : '正在停止当前流程...', 'warn', 2000);
 });
 });
 
 
+btnConfigMenu?.addEventListener('click', (event) => {
+  event.stopPropagation();
+  toggleConfigMenu();
+});
+
+configMenu?.addEventListener('click', (event) => {
+  event.stopPropagation();
+});
+
+btnExportSettings?.addEventListener('click', async () => {
+  if (configActionInFlight || settingsSaveInFlight) {
+    return;
+  }
+  await exportSettingsFile();
+});
+
+btnImportSettings?.addEventListener('click', async () => {
+  if (configActionInFlight || settingsSaveInFlight) {
+    return;
+  }
+  closeConfigMenu();
+  if (inputImportSettingsFile) {
+    inputImportSettingsFile.value = '';
+    inputImportSettingsFile.click();
+  }
+});
+
+inputImportSettingsFile?.addEventListener('change', async () => {
+  const file = inputImportSettingsFile.files?.[0] || null;
+  await importSettingsFromFile(file);
+});
+
 autoStartModal?.addEventListener('click', (event) => {
 autoStartModal?.addEventListener('click', (event) => {
   if (event.target === autoStartModal) {
   if (event.target === autoStartModal) {
     resolveModalChoice(null);
     resolveModalChoice(null);
@@ -1727,7 +2029,8 @@ btnAutoRun.addEventListener('click', async () => {
 
 
     if (shouldOfferAutoModeChoice()) {
     if (shouldOfferAutoModeChoice()) {
       const startStep = getFirstUnfinishedStep();
       const startStep = getFirstUnfinishedStep();
-      const choice = await openAutoStartChoiceDialog(startStep);
+      const runningStep = getRunningSteps()[0] ?? null;
+      const choice = await openAutoStartChoiceDialog(startStep, { runningStep });
       if (!choice) {
       if (!choice) {
         return;
         return;
       }
       }
@@ -1847,8 +2150,18 @@ inputEmail.addEventListener('change', async () => {
     return;
     return;
   }
   }
   const email = inputEmail.value.trim();
   const email = inputEmail.value.trim();
-  if (email) {
-    await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
+  inputEmail.value = email;
+  try {
+    if (email) {
+      const response = await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
+      if (response?.error) {
+        throw new Error(response.error);
+      }
+    } else {
+      await setRuntimeEmailState(null);
+    }
+  } catch (err) {
+    showToast(err.message, 'error');
   }
   }
 });
 });
 inputEmail.addEventListener('input', updateButtonStates);
 inputEmail.addEventListener('input', updateButtonStates);
@@ -1885,6 +2198,7 @@ selectMailProvider.addEventListener('change', () => {
 
 
 selectEmailGenerator.addEventListener('change', () => {
 selectEmailGenerator.addEventListener('change', () => {
   updateMailProviderUI();
   updateMailProviderUI();
+  clearRegistrationEmail({ silent: true }).catch(() => { });
   markSettingsDirty(true);
   markSettingsDirty(true);
   saveSettings({ silent: true }).catch(() => { });
   saveSettings({ silent: true }).catch(() => { });
 });
 });
@@ -2072,6 +2386,9 @@ chrome.runtime.onMessage.addListener((message) => {
       if (message.payload.password !== undefined) {
       if (message.payload.password !== undefined) {
         inputPassword.value = message.payload.password || '';
         inputPassword.value = message.payload.password || '';
       }
       }
+      if (message.payload.localCpaStep9Mode !== undefined) {
+        setLocalCpaStep9Mode(message.payload.localCpaStep9Mode);
+      }
       if (message.payload.oauthUrl !== undefined) {
       if (message.payload.oauthUrl !== undefined) {
         displayOauthUrl.textContent = message.payload.oauthUrl || '等待中...';
         displayOauthUrl.textContent = message.payload.oauthUrl || '等待中...';
         displayOauthUrl.classList.toggle('has-value', Boolean(message.payload.oauthUrl));
         displayOauthUrl.classList.toggle('has-value', Boolean(message.payload.oauthUrl));
@@ -2099,7 +2416,7 @@ chrome.runtime.onMessage.addListener((message) => {
 
 
     case 'AUTO_RUN_STATUS': {
     case 'AUTO_RUN_STATUS': {
       syncLatestState({
       syncLatestState({
-        autoRunning: ['scheduled', 'running', 'waiting_email', 'retrying'].includes(message.payload.phase),
+        autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying'].includes(message.payload.phase),
         autoRunPhase: message.payload.phase,
         autoRunPhase: message.payload.phase,
         autoRunCurrentRun: message.payload.currentRun,
         autoRunCurrentRun: message.payload.currentRun,
         autoRunTotalRuns: message.payload.totalRuns,
         autoRunTotalRuns: message.payload.totalRuns,
@@ -2139,6 +2456,22 @@ btnTheme.addEventListener('click', () => {
   setTheme(current === 'dark' ? 'light' : 'dark');
   setTheme(current === 'dark' ? 'light' : 'dark');
 });
 });
 
 
+document.addEventListener('click', (event) => {
+  if (!configMenuOpen) {
+    return;
+  }
+  if (configMenuShell?.contains(event.target)) {
+    return;
+  }
+  closeConfigMenu();
+});
+
+document.addEventListener('keydown', (event) => {
+  if (event.key === 'Escape' && configMenuOpen) {
+    closeConfigMenu();
+  }
+});
+
 // ============================================================
 // ============================================================
 // Init
 // Init
 // ============================================================
 // ============================================================
@@ -2147,9 +2480,12 @@ initializeManualStepActions();
 initTheme();
 initTheme();
 initHotmailListExpandedState();
 initHotmailListExpandedState();
 updateSaveButtonState();
 updateSaveButtonState();
+updateConfigMenuControls();
+setLocalCpaStep9Mode(DEFAULT_LOCAL_CPA_STEP9_MODE);
 restoreState().then(() => {
 restoreState().then(() => {
   syncPasswordToggleLabel();
   syncPasswordToggleLabel();
   syncVpsUrlToggleLabel();
   syncVpsUrlToggleLabel();
+  syncVpsPasswordToggleLabel();
   updatePanelModeUI();
   updatePanelModeUI();
   updateButtonStates();
   updateButtonStates();
   updateStatusDisplay(latestState);
   updateStatusDisplay(latestState);

+ 15 - 2
tests/step9-cpa-mode.test.js

@@ -28,29 +28,42 @@ function extractFunction(name) {
 }
 }
 
 
 const bundle = [
 const bundle = [
+  'const DEFAULT_LOCAL_CPA_STEP9_MODE = "submit";',
   extractFunction('parseUrlSafely'),
   extractFunction('parseUrlSafely'),
   extractFunction('isLocalCpaUrl'),
   extractFunction('isLocalCpaUrl'),
+  extractFunction('normalizeLocalCpaStep9Mode'),
   extractFunction('shouldBypassStep9ForLocalCpa'),
   extractFunction('shouldBypassStep9ForLocalCpa'),
 ].join('\n');
 ].join('\n');
 
 
-const api = new Function(`${bundle}; return { isLocalCpaUrl, shouldBypassStep9ForLocalCpa };`)();
+const api = new Function(`${bundle}; return { isLocalCpaUrl, normalizeLocalCpaStep9Mode, shouldBypassStep9ForLocalCpa };`)();
 
 
 assert.strictEqual(api.isLocalCpaUrl('http://127.0.0.1:8317/management.html#/oauth'), true, '127.0.0.1 应视为本地 CPA');
 assert.strictEqual(api.isLocalCpaUrl('http://127.0.0.1:8317/management.html#/oauth'), true, '127.0.0.1 应视为本地 CPA');
 assert.strictEqual(api.isLocalCpaUrl('http://localhost:1455/management.html#/oauth'), true, 'localhost 应视为本地 CPA');
 assert.strictEqual(api.isLocalCpaUrl('http://localhost:1455/management.html#/oauth'), true, 'localhost 应视为本地 CPA');
 assert.strictEqual(api.isLocalCpaUrl('https://example.com/management.html#/oauth'), false, '远程域名不应视为本地 CPA');
 assert.strictEqual(api.isLocalCpaUrl('https://example.com/management.html#/oauth'), false, '远程域名不应视为本地 CPA');
 assert.strictEqual(api.isLocalCpaUrl('notaurl'), false, '非法 URL 不应视为本地 CPA');
 assert.strictEqual(api.isLocalCpaUrl('notaurl'), false, '非法 URL 不应视为本地 CPA');
+assert.strictEqual(api.normalizeLocalCpaStep9Mode('submit'), 'submit', 'submit 应保持为 submit');
+assert.strictEqual(api.normalizeLocalCpaStep9Mode('bypass'), 'bypass', 'bypass 应保持为 bypass');
+assert.strictEqual(api.normalizeLocalCpaStep9Mode('other'), 'submit', '未知模式应回退为 submit');
 
 
 assert.strictEqual(api.shouldBypassStep9ForLocalCpa({
 assert.strictEqual(api.shouldBypassStep9ForLocalCpa({
   vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
   vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
   localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz',
   localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz',
-}), true, '本地 CPA 且已有 callback 时应跳过远程提交流程');
+}), false, '默认模式下,本地 CPA 也应执行步骤 9');
 
 
 assert.strictEqual(api.shouldBypassStep9ForLocalCpa({
 assert.strictEqual(api.shouldBypassStep9ForLocalCpa({
+  localCpaStep9Mode: 'bypass',
+  vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
+  localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz',
+}), true, '切换为 bypass 后,本地 CPA 且已有 callback 时应跳过步骤 9');
+
+assert.strictEqual(api.shouldBypassStep9ForLocalCpa({
+  localCpaStep9Mode: 'bypass',
   vpsUrl: 'https://example.com/management.html#/oauth',
   vpsUrl: 'https://example.com/management.html#/oauth',
   localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz',
   localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz',
 }), false, '远程 CPA 不应跳过步骤 9');
 }), false, '远程 CPA 不应跳过步骤 9');
 
 
 assert.strictEqual(api.shouldBypassStep9ForLocalCpa({
 assert.strictEqual(api.shouldBypassStep9ForLocalCpa({
+  localCpaStep9Mode: 'bypass',
   vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
   vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
   localhostUrl: '',
   localhostUrl: '',
 }), false, '没有 callback 时不应跳过步骤 9');
 }), false, '没有 callback 时不应跳过步骤 9');