From 16baad20a328b31fdcbaf6a93e01c839c222b6f7 Mon Sep 17 00:00:00 2001 From: QLHazyCoder Date: Tue, 5 May 2026 01:48:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E5=8F=B7=E8=BE=93=E5=85=A5=E6=A1=86=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E6=89=8B=E5=8A=A8=E8=BE=93=E5=85=A5=E5=80=BC?= =?UTF-8?q?=E5=9C=A8=E6=89=A7=E8=A1=8C=E6=AD=A5=E9=AA=A4=E5=89=8D=E8=A2=AB?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A2=AB=E6=97=A7?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/steps/oauth-login.js | 2 +- sidepanel/sidepanel.js | 138 +++++++++++++++--- tests/background-step6-retry-limit.test.js | 60 ++++++++ ...sidepanel-auto-run-content-refresh.test.js | 7 +- ...epanel-phone-verification-settings.test.js | 49 ++++++- 项目完整链路说明.md | 2 +- 6 files changed, 236 insertions(+), 22 deletions(-) diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 2c08462..07eb041 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -63,7 +63,7 @@ || '' ).trim(); if (!email && !phoneNumber) { - throw new Error('缺少登录账号,请先完成步骤 2 和步骤 3。'); + throw new Error('缺少登录账号:请先完成步骤 2,或在侧栏“注册邮箱/注册手机号”中手动填写账号后再执行当前步骤。'); } let attempt = 0; diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 161aa15..524570f 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -1076,6 +1076,9 @@ let settingsDirty = false; let settingsSaveInFlight = false; let settingsAutoSaveTimer = null; let settingsSaveRevision = 0; +let signupPhoneInputDirty = false; +let signupPhoneInputFocused = false; +let signupPhoneInputPersistPromise = null; let cloudflareDomainEditMode = false; let cloudflareTempEmailDomainEditMode = false; let modalChoiceResolver = null; @@ -7012,10 +7015,29 @@ function getRuntimeSignupPhoneValue(state = latestState) { ).trim(); } +function getSignupPhoneInputValue() { + return typeof inputSignupPhone !== 'undefined' && inputSignupPhone + ? String(inputSignupPhone.value || '').trim() + : ''; +} + +function shouldPreserveSignupPhoneInputValue(stateSignupPhone = '') { + if (typeof inputSignupPhone === 'undefined' || !inputSignupPhone || !signupPhoneInputDirty) { + return false; + } + if (getSignupPhoneInputValue() === String(stateSignupPhone || '').trim()) { + signupPhoneInputDirty = false; + return false; + } + return signupPhoneInputFocused || (typeof document !== 'undefined' && document.activeElement === inputSignupPhone); +} + function syncSignupPhoneInputFromState(state = latestState) { const signupPhone = getRuntimeSignupPhoneValue(state); if (typeof inputSignupPhone !== 'undefined' && inputSignupPhone) { - inputSignupPhone.value = signupPhone; + if (!shouldPreserveSignupPhoneInputValue(signupPhone)) { + inputSignupPhone.value = signupPhone; + } } if (typeof rowSignupPhone !== 'undefined' && rowSignupPhone) { const phoneVerificationEnabled = typeof inputPhoneVerificationEnabled !== 'undefined' && inputPhoneVerificationEnabled @@ -7029,7 +7051,10 @@ function syncSignupPhoneInputFromState(state = latestState) { const selectedMethod = typeof normalizeSignupMethod === 'function' ? normalizeSignupMethod(rawSignupMethod) : (String(rawSignupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'); - rowSignupPhone.style.display = phoneVerificationEnabled && (selectedMethod === 'phone' || Boolean(signupPhone)) ? '' : 'none'; + rowSignupPhone.style.display = phoneVerificationEnabled + && (selectedMethod === 'phone' || Boolean(signupPhone) || Boolean(getSignupPhoneInputValue()) || signupPhoneInputDirty) + ? '' + : 'none'; } } @@ -7048,6 +7073,74 @@ async function setRuntimeSignupPhoneState(phoneNumber) { return normalizedPhone; } +async function persistSignupPhoneInputValue(options = {}) { + const { final = true, silent = true } = options; + if (typeof inputSignupPhone === 'undefined' || !inputSignupPhone) { + return getRuntimeSignupPhoneValue(latestState); + } + if (signupPhoneInputPersistPromise) { + return signupPhoneInputPersistPromise; + } + + const phoneNumber = getSignupPhoneInputValue(); + inputSignupPhone.value = phoneNumber; + const currentPhone = getRuntimeSignupPhoneValue(latestState); + if (!signupPhoneInputDirty && phoneNumber === currentPhone) { + return phoneNumber; + } + + signupPhoneInputPersistPromise = (async () => { + const response = await chrome.runtime.sendMessage({ + type: final ? 'SAVE_SIGNUP_PHONE' : 'SET_SIGNUP_PHONE_STATE', + source: 'sidepanel', + payload: { phoneNumber }, + }); + if (response?.error) { + throw new Error(response.error); + } + + const normalizedPhone = String(response?.phoneNumber || phoneNumber || '').trim(); + signupPhoneInputDirty = getSignupPhoneInputValue() !== normalizedPhone; + syncLatestState({ + signupPhoneNumber: normalizedPhone, + ...(normalizedPhone + ? { + accountIdentifierType: 'phone', + accountIdentifier: normalizedPhone, + } + : (String(latestState?.accountIdentifierType || '').trim().toLowerCase() === 'phone' + ? { + accountIdentifierType: null, + accountIdentifier: '', + } + : {})), + }); + syncSignupPhoneInputFromState(latestState); + if (!silent) { + showToast(normalizedPhone ? '注册手机号已保存。' : '注册手机号已清空。', 'success', 1600); + } + return normalizedPhone; + })(); + + try { + return await signupPhoneInputPersistPromise; + } finally { + signupPhoneInputPersistPromise = null; + } +} + +async function persistSignupPhoneInputForAction() { + if (typeof inputSignupPhone === 'undefined' || !inputSignupPhone) { + return; + } + const phoneNumber = getSignupPhoneInputValue(); + const currentPhone = getRuntimeSignupPhoneValue(latestState); + if (!signupPhoneInputDirty && phoneNumber === currentPhone) { + return; + } + await persistSignupPhoneInputValue({ final: true, silent: true }); +} + async function openPlusManualConfirmationDialog(options = {}) { const method = String(options.method || '').trim().toLowerCase(); const gopayValue = typeof PLUS_PAYMENT_METHOD_GOPAY !== 'undefined' ? PLUS_PAYMENT_METHOD_GOPAY : 'gopay'; @@ -7207,6 +7300,7 @@ async function clearRegistrationSignupPhone(options = {}) { if (typeof inputSignupPhone !== 'undefined' && inputSignupPhone) { inputSignupPhone.value = ''; } + signupPhoneInputDirty = false; syncSignupPhoneInputFromState(latestState); return; } @@ -7214,6 +7308,7 @@ async function clearRegistrationSignupPhone(options = {}) { if (typeof inputSignupPhone !== 'undefined' && inputSignupPhone) { inputSignupPhone.value = ''; } + signupPhoneInputDirty = false; syncLatestState({ signupPhoneNumber: '', ...(String(latestState?.accountIdentifierType || '').trim().toLowerCase() === 'phone' @@ -7309,6 +7404,7 @@ async function saveSettings(options = {}) { async function persistCurrentSettingsForAction() { clearTimeout(settingsAutoSaveTimer); await waitForSettingsSaveIdle(); + await persistSignupPhoneInputForAction(); await saveSettings({ silent: true, force: true }); } @@ -10203,6 +10299,8 @@ async function handleSkipStep(step) { } } + await persistCurrentSettingsForAction(); + const response = await chrome.runtime.sendMessage({ type: 'SKIP_STEP', source: 'sidepanel', @@ -10728,27 +10826,31 @@ inputEmail.addEventListener('change', async () => { }); inputEmail.addEventListener('input', updateButtonStates); if (typeof inputSignupPhone !== 'undefined' && inputSignupPhone) { - inputSignupPhone.addEventListener('change', async () => { - const phoneNumber = inputSignupPhone.value.trim(); - inputSignupPhone.value = phoneNumber; + inputSignupPhone.addEventListener('focus', () => { + signupPhoneInputFocused = true; + }); + inputSignupPhone.addEventListener('blur', async () => { + signupPhoneInputFocused = false; + if (!signupPhoneInputDirty) { + return; + } try { - if (phoneNumber) { - const response = await chrome.runtime.sendMessage({ - type: 'SAVE_SIGNUP_PHONE', - source: 'sidepanel', - payload: { phoneNumber }, - }); - if (response?.error) { - throw new Error(response.error); - } - } else { - await clearRegistrationSignupPhone(); - } + await persistSignupPhoneInputValue({ final: true, silent: true }); } catch (err) { showToast(err.message, 'error'); } }); - inputSignupPhone.addEventListener('input', updateButtonStates); + inputSignupPhone.addEventListener('change', async () => { + try { + await persistSignupPhoneInputValue({ final: true, silent: true }); + } catch (err) { + showToast(err.message, 'error'); + } + }); + inputSignupPhone.addEventListener('input', () => { + signupPhoneInputDirty = true; + updateButtonStates(); + }); } inputVpsUrl.addEventListener('input', () => { markSettingsDirty(true); diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index e0c27e4..7541c19 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -296,6 +296,66 @@ test('step 7 forwards phone login identity payload when account identifier is ph ]); }); +test('step 7 can start from a manually filled signup phone without completed step 2 or step 3 state', async () => { + const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + payloads: [], + completions: [], + }; + + const executor = api.createStep7Executor({ + addLog: async () => {}, + completeStepFromBackground: async (step, payload) => { + events.completions.push({ step, payload }); + }, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getState: async () => ({ + accountIdentifierType: 'phone', + accountIdentifier: '+447780579093', + signupPhoneNumber: '+447780579093', + stepStatuses: { 2: 'pending', 3: 'pending' }, + }), + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest', + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async (_sourceName, message) => { + events.payloads.push(message.payload); + return { + step6Outcome: 'success', + state: 'phone_verification_page', + loginVerificationRequestedAt: 987654, + }; + }, + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await executor.executeStep7({ + accountIdentifierType: 'phone', + accountIdentifier: '+447780579093', + signupPhoneNumber: '+447780579093', + stepStatuses: { 2: 'pending', 3: 'pending' }, + }); + + assert.equal(events.payloads[0].loginIdentifierType, 'phone'); + assert.equal(events.payloads[0].phoneNumber, '+447780579093'); + assert.equal(events.payloads[0].email, ''); + assert.equal(events.payloads[0].password, ''); + assert.deepStrictEqual(events.completions, [ + { + step: 7, + payload: { + loginVerificationRequestedAt: 987654, + }, + }, + ]); +}); + test('step 7 stops immediately when management secret is missing', async () => { const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); const globalScope = {}; diff --git a/tests/sidepanel-auto-run-content-refresh.test.js b/tests/sidepanel-auto-run-content-refresh.test.js index 59ca371..115be14 100644 --- a/tests/sidepanel-auto-run-content-refresh.test.js +++ b/tests/sidepanel-auto-run-content-refresh.test.js @@ -282,10 +282,14 @@ let clearedTimer = null; let settingsSaveInFlight = false; let settingsDirty = false; let settingsSaveRevision = 0; +let phonePersistCalls = 0; const saveCalls = []; function clearTimeout(value) { clearedTimer = value; } +async function persistSignupPhoneInputForAction() { + phonePersistCalls += 1; +} function updateSaveButtonState() {} function collectSettingsPayload() { return { luckmailApiKey: 'autofilled-key' }; @@ -308,7 +312,7 @@ ${bundle} return { persistCurrentSettingsForAction, getSnapshot() { - return { clearedTimer, saveCalls }; + return { clearedTimer, phonePersistCalls, saveCalls }; }, }; `)(); @@ -317,5 +321,6 @@ return { const snapshot = api.getSnapshot(); assert.equal(snapshot.clearedTimer, 123); + assert.equal(snapshot.phonePersistCalls, 1); assert.deepStrictEqual(snapshot.saveCalls, [{ luckmailApiKey: 'autofilled-key' }]); }); diff --git a/tests/sidepanel-phone-verification-settings.test.js b/tests/sidepanel-phone-verification-settings.test.js index 9586a9f..00f66c1 100644 --- a/tests/sidepanel-phone-verification-settings.test.js +++ b/tests/sidepanel-phone-verification-settings.test.js @@ -136,10 +136,57 @@ test('sidepanel html exposes phone verification toggle and multi-provider SMS ro test('sidepanel source wires runtime signup phone field to background sync messages', () => { assert.match(sidepanelSource, /function getRuntimeSignupPhoneValue\(state = latestState\)/); + assert.match(sidepanelSource, /function shouldPreserveSignupPhoneInputValue\(stateSignupPhone = ''\)/); assert.match(sidepanelSource, /function syncSignupPhoneInputFromState\(state = latestState\)/); + assert.match(sidepanelSource, /async function persistSignupPhoneInputForAction\(\)/); assert.match(sidepanelSource, /type:\s*'SET_SIGNUP_PHONE_STATE'/); - assert.match(sidepanelSource, /type:\s*'SAVE_SIGNUP_PHONE'/); + assert.match(sidepanelSource, /final \? 'SAVE_SIGNUP_PHONE' : 'SET_SIGNUP_PHONE_STATE'/); assert.match(sidepanelSource, /message\.payload\.signupPhoneNumber !== undefined/); + assert.match(sidepanelSource, /await persistSignupPhoneInputForAction\(\);\s*await saveSettings/); + assert.match(sidepanelSource, /async function handleSkipStep\(step\)[\s\S]*await persistCurrentSettingsForAction\(\);/); + assert.match(sidepanelSource, /inputSignupPhone\.addEventListener\('input'[\s\S]*signupPhoneInputDirty = true/); +}); + +test('runtime signup phone sync preserves active manual input until it is saved', () => { + const api = new Function(` +let latestState = { signupMethod: 'phone', phoneVerificationEnabled: true, signupPhoneNumber: '+441111111111' }; +let signupPhoneInputDirty = true; +let signupPhoneInputFocused = true; +const inputSignupPhone = { value: '+442222222222' }; +const rowSignupPhone = { style: { display: 'none' } }; +const inputPhoneVerificationEnabled = { checked: true }; +const document = { activeElement: inputSignupPhone }; +function getSelectedSignupMethod() { return 'phone'; } +${extractFunction('normalizeSignupMethod')} +${extractFunction('getRuntimeSignupPhoneValue')} +${extractFunction('getSignupPhoneInputValue')} +${extractFunction('shouldPreserveSignupPhoneInputValue')} +${extractFunction('syncSignupPhoneInputFromState')} +return { + inputSignupPhone, + rowSignupPhone, + syncSignupPhoneInputFromState, + getDirty: () => signupPhoneInputDirty, + setFocused: (value) => { signupPhoneInputFocused = Boolean(value); document.activeElement = value ? inputSignupPhone : null; }, +}; +`)(); + + api.syncSignupPhoneInputFromState({ + signupMethod: 'phone', + phoneVerificationEnabled: true, + signupPhoneNumber: '+441111111111', + }); + assert.equal(api.inputSignupPhone.value, '+442222222222'); + assert.equal(api.rowSignupPhone.style.display, ''); + assert.equal(api.getDirty(), true); + + api.setFocused(false); + api.syncSignupPhoneInputFromState({ + signupMethod: 'phone', + phoneVerificationEnabled: true, + signupPhoneNumber: '+441111111111', + }); + assert.equal(api.inputSignupPhone.value, '+441111111111'); }); test('hero sms country helpers keep empty summary state and expose removable order handling', () => { diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 31ab652..7c07d02 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -155,7 +155,7 @@ - 本轮联动自检重点: 1. Step 2 一旦自动取到手机号,必须立刻广播并回填侧栏运行态手机号。 2. sidepanel 重新加载后,必须从当前 state 恢复“注册手机号”文本框,而不是只恢复邮箱。 - 3. 用户手动修改“注册手机号”时,只能改运行态身份槽位,不能误写入持久配置或配置导入导出。 + 3. 用户手动修改“注册手机号”时,只能改运行态身份槽位,不能误写入持久配置或配置导入导出;编辑中的值不能被后台旧 state 覆盖,执行步骤或跳过步骤前必须先保存当前输入框值。 4. 第 4 步继续依赖当前手机号注册 activation 获取验证码;只有手机号字符串、没有 activation 时,只能作为页面填写来源,不能伪造成自动接码订单。 5. 显示与身份同步统一以侧栏运行态手机号为准,避免 UI 空白和后台状态脱节。 - 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail`