Browse Source

feat: 添加本地 CPA 第 9 步策略切换功能

QLHazyCoder 4 tháng trước cách đây
mục cha
commit
c9736f6c5d
6 tập tin đã thay đổi với 115 bổ sung4 xóa
  1. 3 0
      README.md
  2. 16 2
      background.js
  3. 35 0
      sidepanel/sidepanel.css
  4. 7 0
      sidepanel/sidepanel.html
  5. 39 0
      sidepanel/sidepanel.js
  6. 15 2
      tests/step9-cpa-mode.test.js

+ 3 - 0
README.md

@@ -369,6 +369,9 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 
 - 步骤 9 会拒绝任何不是真实 `/auth/callback`,或缺少 `code` / `state` 的本地回调地址
 - 成功后的清理只会针对 `/auth` 这一类真实回调标签页,不会再泛化清理任意 localhost 路径
+- 侧边栏可切换“本地 CPA”策略,默认是 `全部回调`
+- 选择 `全部回调` 时,即使 CPA 部署在本地,也会执行步骤 9
+- 选择 `跳过第9步` 时,仅当本地 CPA 且步骤 8 已拿到回调地址时,才会直接跳过步骤 9
 
 回到 CPA 面板:
 

+ 16 - 2
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_DELAY_MIN_MINUTES = 1;
 const AUTO_RUN_DELAY_MAX_MINUTES = 1440;
+const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
 
 initializeSessionStorageAccess();
 
@@ -50,6 +51,7 @@ const PERSISTED_SETTING_DEFAULTS = {
   panelMode: 'cpa', // Step 1 / Step 9 的来源模式:cpa | sub2api。
   vpsUrl: '', // VPS 面板地址,可手动填写。
   vpsPassword: '', // VPS 面板登录密码,可手动填写。
+  localCpaStep9Mode: DEFAULT_LOCAL_CPA_STEP9_MODE, // 本地 CPA 的第 9 步策略:submit | bypass。
   sub2apiUrl: DEFAULT_SUB2API_URL, // SUB2API 管理后台地址。
   sub2apiEmail: '', // SUB2API 登录邮箱。
   sub2apiPassword: '', // SUB2API 登录密码。
@@ -139,6 +141,12 @@ function normalizeEmailGenerator(value = '') {
   return String(value || '').trim().toLowerCase() === 'cloudflare' ? 'cloudflare' : 'duck';
 }
 
+function normalizeLocalCpaStep9Mode(value = '') {
+  return String(value || '').trim().toLowerCase() === 'bypass'
+    ? 'bypass'
+    : DEFAULT_LOCAL_CPA_STEP9_MODE;
+}
+
 function normalizeCloudflareDomain(rawValue = '') {
   let value = String(rawValue || '').trim().toLowerCase();
   if (!value) return '';
@@ -158,6 +166,7 @@ async function getPersistedSettings() {
     autoRunDelayEnabled: Boolean(stored.autoRunDelayEnabled ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayEnabled),
     autoRunDelayMinutes: normalizeAutoRunDelayMinutes(stored.autoRunDelayMinutes ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayMinutes),
     emailGenerator: normalizeEmailGenerator(stored.emailGenerator ?? PERSISTED_SETTING_DEFAULTS.emailGenerator),
+    localCpaStep9Mode: normalizeLocalCpaStep9Mode(stored.localCpaStep9Mode ?? PERSISTED_SETTING_DEFAULTS.localCpaStep9Mode),
     hotmailAccounts: normalizeHotmailAccounts(stored.hotmailAccounts),
   };
 }
@@ -198,6 +207,8 @@ async function setPersistentSettings(updates) {
         persistedUpdates[key] = Boolean(updates[key]);
       } else if (key === 'autoRunDelayMinutes') {
         persistedUpdates[key] = normalizeAutoRunDelayMinutes(updates[key]);
+      } else if (key === 'localCpaStep9Mode') {
+        persistedUpdates[key] = normalizeLocalCpaStep9Mode(updates[key]);
       } else if (key === 'hotmailAccounts') {
         persistedUpdates[key] = normalizeHotmailAccounts(updates[key]);
       } else {
@@ -983,7 +994,9 @@ function isLocalCpaUrl(rawUrl) {
 }
 
 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) {
@@ -2439,6 +2452,7 @@ async function handleMessage(message, sender) {
       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.localCpaStep9Mode !== undefined) updates.localCpaStep9Mode = normalizeLocalCpaStep9Mode(message.payload.localCpaStep9Mode);
       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;
@@ -4420,7 +4434,7 @@ async function executeCpaStep9(state) {
   }
 
   if (shouldBypassStep9ForLocalCpa(state)) {
-    await addLog('步骤 9:检测到本地 CPA,步骤 8 完成后已自动添加,无需重复提交回调地址。', 'info');
+    await addLog('步骤 9:检测到本地 CPA,且当前策略为“跳过第9步”,本轮不再重复提交回调地址。', 'info');
     await completeStepFromBackground(9, {
       localhostUrl: state.localhostUrl,
       verifiedStatus: 'local-auto',

+ 35 - 0
sidepanel/sidepanel.css

@@ -378,6 +378,41 @@ header {
   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-save-settings {
   padding-inline: 10px;

+ 7 - 0
sidepanel/sidepanel.html

@@ -83,6 +83,13 @@
         <span class="data-label">管理密钥</span>
         <input type="password" id="input-vps-password" class="data-input" placeholder="请输入 CPA 管理密钥" />
       </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 class="data-row" id="row-sub2api-url" style="display:none;">
         <span class="data-label">SUB2API</span>
         <input type="text" id="input-sub2api-url" class="data-input" placeholder="https://sub2api.hisence.fun/admin/accounts" />

+ 39 - 0
sidepanel/sidepanel.js

@@ -38,6 +38,8 @@ const rowVpsUrl = document.getElementById('row-vps-url');
 const inputVpsUrl = document.getElementById('input-vps-url');
 const rowVpsPassword = document.getElementById('row-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 inputSub2ApiUrl = document.getElementById('input-sub2api-url');
 const rowSub2ApiEmail = document.getElementById('row-sub2api-email');
@@ -96,6 +98,7 @@ const SKIPPABLE_STEPS = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9]);
 const AUTO_DELAY_MIN_MINUTES = 1;
 const AUTO_DELAY_MAX_MINUTES = 1440;
 const AUTO_DELAY_DEFAULT_MINUTES = 30;
+const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
 
 let latestState = null;
 let currentAutoRun = {
@@ -492,6 +495,7 @@ function collectSettingsPayload() {
     panelMode: selectPanelMode.value,
     vpsUrl: inputVpsUrl.value.trim(),
     vpsPassword: inputVpsPassword.value,
+    localCpaStep9Mode: getSelectedLocalCpaStep9Mode(),
     sub2apiUrl: inputSub2ApiUrl.value.trim(),
     sub2apiEmail: inputSub2ApiEmail.value.trim(),
     sub2apiPassword: inputSub2ApiPassword.value,
@@ -509,6 +513,26 @@ 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 markSettingsDirty(isDirty = true) {
   settingsDirty = isDirty;
   updateSaveButtonState();
@@ -677,6 +701,7 @@ async function restoreState() {
     if (state.vpsPassword) {
       inputVpsPassword.value = state.vpsPassword;
     }
+    setLocalCpaStep9Mode(state.localCpaStep9Mode);
     if (state.panelMode) {
       selectPanelMode.value = state.panelMode;
     }
@@ -1051,6 +1076,7 @@ function updatePanelModeUI() {
   const useSub2Api = selectPanelMode.value === 'sub2api';
   rowVpsUrl.style.display = useSub2Api ? 'none' : '';
   rowVpsPassword.style.display = useSub2Api ? 'none' : '';
+  rowLocalCpaStep9Mode.style.display = useSub2Api ? 'none' : '';
   rowSub2ApiUrl.style.display = useSub2Api ? '' : 'none';
   rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none';
   rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none';
@@ -1698,6 +1724,18 @@ btnToggleVpsUrl.addEventListener('click', () => {
   syncVpsUrlToggleLabel();
 });
 
+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 () => {
   if (!settingsDirty) {
     showToast('配置已是最新', 'info', 1400);
@@ -2147,6 +2185,7 @@ initializeManualStepActions();
 initTheme();
 initHotmailListExpandedState();
 updateSaveButtonState();
+setLocalCpaStep9Mode(DEFAULT_LOCAL_CPA_STEP9_MODE);
 restoreState().then(() => {
   syncPasswordToggleLabel();
   syncVpsUrlToggleLabel();

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

@@ -28,29 +28,42 @@ function extractFunction(name) {
 }
 
 const bundle = [
+  'const DEFAULT_LOCAL_CPA_STEP9_MODE = "submit";',
   extractFunction('parseUrlSafely'),
   extractFunction('isLocalCpaUrl'),
+  extractFunction('normalizeLocalCpaStep9Mode'),
   extractFunction('shouldBypassStep9ForLocalCpa'),
 ].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://localhost:1455/management.html#/oauth'), true, 'localhost 应视为本地 CPA');
 assert.strictEqual(api.isLocalCpaUrl('https://example.com/management.html#/oauth'), false, '远程域名不应视为本地 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({
   vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
   localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz',
-}), true, '本地 CPA 且已有 callback 时应跳过远程提交流程');
+}), false, '默认模式下,本地 CPA 也应执行步骤 9');
 
 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',
   localhostUrl: 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz',
 }), false, '远程 CPA 不应跳过步骤 9');
 
 assert.strictEqual(api.shouldBypassStep9ForLocalCpa({
+  localCpaStep9Mode: 'bypass',
   vpsUrl: 'http://127.0.0.1:8317/management.html#/oauth',
   localhostUrl: '',
 }), false, '没有 callback 时不应跳过步骤 9');