diff --git a/background.js b/background.js
index 12df754..d665f84 100644
--- a/background.js
+++ b/background.js
@@ -245,6 +245,10 @@ const CONTRIBUTION_SOURCE_CPA = 'cpa';
const CONTRIBUTION_SOURCE_SUB2API = 'sub2api';
const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池';
const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus';
+const DEFAULT_SUB2API_GROUP_NAMES = [
+ DEFAULT_SUB2API_GROUP_NAME,
+ CONTRIBUTION_SUB2API_PLUS_GROUP_NAME,
+];
const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback';
const DEFAULT_IP_PROXY_SERVICE = '711proxy';
const IP_PROXY_SERVICE_VALUES = ['711proxy', 'lumiproxy', 'iproyal', 'omegaproxy'];
@@ -568,6 +572,7 @@ const PERSISTED_SETTING_DEFAULTS = {
sub2apiEmail: '',
sub2apiPassword: '',
sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME,
+ sub2apiGroupNames: DEFAULT_SUB2API_GROUP_NAMES,
sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME,
ipProxyEnabled: false,
ipProxyService: DEFAULT_IP_PROXY_SERVICE,
@@ -2058,6 +2063,24 @@ function resolveCloudflareTempEmailPollTargetEmail(state = {}, pollPayload = {},
return normalizeCloudflareTempEmailReceiveMailbox(state.email);
}
+function normalizeSub2ApiGroupNames(value = '') {
+ const source = Array.isArray(value)
+ ? value
+ : String(value || '').split(/[\r\n,,、]+/);
+ const names = [];
+ const seen = new Set();
+ for (const item of source) {
+ const name = String(item || '').trim();
+ const key = name.toLowerCase();
+ if (!key || seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ names.push(name);
+ }
+ return names;
+}
+
function normalizePersistentSettingValue(key, value) {
switch (key) {
case 'panelMode':
@@ -2076,6 +2099,8 @@ function normalizePersistentSettingValue(key, value) {
return String(value || '');
case 'sub2apiGroupName':
return String(value || '').trim();
+ case 'sub2apiGroupNames':
+ return normalizeSub2ApiGroupNames(value);
case 'sub2apiDefaultProxyName':
return String(value || '').trim();
case 'ipProxyEnabled':
@@ -2413,6 +2438,18 @@ function buildPersistentSettingsPayload(input = {}, options = {}) {
}
payload.cloudflareTempEmailDomains = domains;
}
+ if (
+ Object.prototype.hasOwnProperty.call(payload, 'sub2apiGroupName')
+ || Object.prototype.hasOwnProperty.call(payload, 'sub2apiGroupNames')
+ ) {
+ const groupNames = normalizeSub2ApiGroupNames([
+ ...(Array.isArray(payload.sub2apiGroupNames) ? payload.sub2apiGroupNames : []),
+ payload.sub2apiGroupName,
+ ]);
+ payload.sub2apiGroupNames = groupNames.length
+ ? groupNames
+ : [...DEFAULT_SUB2API_GROUP_NAMES];
+ }
const nextSignupConstraintState = {
...PERSISTED_SETTING_DEFAULTS,
...payload,
@@ -10137,6 +10174,7 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe
ensureHotmailAccountForFlow,
ensureMail2925AccountForFlow,
ensureLuckmailPurchaseForFlow,
+ fetchGeneratedEmail,
getTabId,
isGeneratedAliasProvider,
isReusableGeneratedAliasEmail,
@@ -10157,6 +10195,7 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe
reuseOrCreateTab,
sendToContentScriptResilient,
setEmailState,
+ setState,
SIGNUP_ENTRY_URL,
SIGNUP_PAGE_INJECT_FILES,
waitForTabStableComplete,
@@ -10256,6 +10295,7 @@ const step3Executor = self.MultiPageBackgroundStep3?.createStep3Executor({
generatePassword,
getTabId,
isTabAlive,
+ resolveSignupMethod,
sendToContentScript,
setPasswordState,
setState,
diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js
index 36671c2..b85736e 100644
--- a/background/signup-flow-helpers.js
+++ b/background/signup-flow-helpers.js
@@ -10,6 +10,7 @@
ensureHotmailAccountForFlow,
ensureMail2925AccountForFlow,
ensureLuckmailPurchaseForFlow,
+ fetchGeneratedEmail,
isGeneratedAliasProvider,
isReusableGeneratedAliasEmail,
isHotmailProvider,
@@ -22,6 +23,7 @@
reuseOrCreateTab,
sendToContentScriptResilient,
setEmailState,
+ setState,
SIGNUP_ENTRY_URL,
SIGNUP_PAGE_INJECT_FILES,
waitForTabStableComplete = null,
@@ -256,8 +258,52 @@
return result || {};
}
- async function resolveSignupEmailForFlow(state) {
+ function getPreservedPhoneIdentityForEmailResolution(state = {}, options = {}) {
+ if (!Boolean(options?.preserveAccountIdentity)) {
+ return null;
+ }
+ const accountIdentifierType = String(state?.accountIdentifierType || '').trim().toLowerCase();
+ const signupPhoneNumber = String(
+ state?.signupPhoneNumber
+ || (accountIdentifierType === 'phone' ? state?.accountIdentifier : '')
+ || state?.signupPhoneCompletedActivation?.phoneNumber
+ || state?.signupPhoneActivation?.phoneNumber
+ || ''
+ ).trim();
+ if (accountIdentifierType !== 'phone' && !signupPhoneNumber) {
+ return null;
+ }
+ return {
+ accountIdentifierType: 'phone',
+ accountIdentifier: signupPhoneNumber || String(state?.accountIdentifier || '').trim(),
+ signupPhoneNumber,
+ signupPhoneActivation: state?.signupPhoneActivation || null,
+ signupPhoneCompletedActivation: state?.signupPhoneCompletedActivation || null,
+ signupPhoneVerificationRequestedAt: state?.signupPhoneVerificationRequestedAt ?? null,
+ signupPhoneVerificationPurpose: state?.signupPhoneVerificationPurpose || '',
+ };
+ }
+
+ async function persistResolvedSignupEmail(resolvedEmail, state = {}, options = {}) {
+ if (resolvedEmail === state.email && !options?.preserveAccountIdentity) {
+ return;
+ }
+ const preservedPhoneIdentity = getPreservedPhoneIdentityForEmailResolution(state, options);
+ if (preservedPhoneIdentity && typeof setState === 'function') {
+ await setState({
+ email: resolvedEmail,
+ ...preservedPhoneIdentity,
+ });
+ return;
+ }
+ if (resolvedEmail !== state.email) {
+ await setEmailState(resolvedEmail);
+ }
+ }
+
+ async function resolveSignupEmailForFlow(state, options = {}) {
let resolvedEmail = state.email;
+ let generatedEmailAlreadyPersisted = false;
if (isHotmailProvider(state)) {
const account = await ensureHotmailAccountForFlow({
allowAllocate: true,
@@ -281,14 +327,17 @@
if (!isReusableGeneratedAliasEmail?.(state, resolvedEmail)) {
resolvedEmail = buildGeneratedAliasEmail(state);
}
+ } else if (!resolvedEmail && typeof fetchGeneratedEmail === 'function') {
+ resolvedEmail = await fetchGeneratedEmail(state, options);
+ generatedEmailAlreadyPersisted = true;
}
if (!resolvedEmail) {
throw new Error('缺少邮箱地址,请先在侧边栏粘贴邮箱。');
}
- if (resolvedEmail !== state.email) {
- await setEmailState(resolvedEmail);
+ if (!generatedEmailAlreadyPersisted || options?.preserveAccountIdentity) {
+ await persistResolvedSignupEmail(resolvedEmail, state, options);
}
return resolvedEmail;
diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js
index 41851d2..b0c7fcc 100644
--- a/background/steps/fetch-login-code.js
+++ b/background/steps/fetch-login-code.js
@@ -145,7 +145,9 @@
}
const latestState = typeof getState === 'function' ? await getState() : state;
- const resolvedEmail = await resolveSignupEmailForFlow(latestState);
+ const resolvedEmail = await resolveSignupEmailForFlow(latestState, {
+ preserveAccountIdentity: true,
+ });
await addLog(`步骤 ${visibleStep}:检测到添加邮箱页,正在添加邮箱 ${resolvedEmail} 并进入邮箱验证码页...`);
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
diff --git a/background/steps/fill-password.js b/background/steps/fill-password.js
index 1f1dc55..9d92d24 100644
--- a/background/steps/fill-password.js
+++ b/background/steps/fill-password.js
@@ -9,22 +9,47 @@
generatePassword,
getTabId,
isTabAlive,
+ resolveSignupMethod,
sendToContentScript,
setPasswordState,
setState,
SIGNUP_PAGE_INJECT_FILES,
} = deps;
+ function normalizeSignupMethod(value = '') {
+ return String(value || '').trim().toLowerCase() === 'phone'
+ ? 'phone'
+ : 'email';
+ }
+
+ function getResolvedSignupMethodForStep3(state = {}) {
+ if (typeof resolveSignupMethod === 'function') {
+ return normalizeSignupMethod(resolveSignupMethod(state));
+ }
+ const frozenMethod = String(state?.resolvedSignupMethod || '').trim().toLowerCase();
+ if (frozenMethod === 'phone' || frozenMethod === 'email') {
+ return normalizeSignupMethod(frozenMethod);
+ }
+ return normalizeSignupMethod(state?.signupMethod);
+ }
+
function resolveStep3AccountIdentity(state = {}) {
const resolvedEmail = String(state?.email || '').trim();
+ const rawAccountIdentifierType = String(state?.accountIdentifierType || '').trim().toLowerCase();
const signupPhoneNumber = String(
state?.signupPhoneNumber
- || (state?.accountIdentifierType === 'phone' ? state?.accountIdentifier : '')
+ || (rawAccountIdentifierType === 'phone' ? state?.accountIdentifier : '')
|| ''
).trim();
- const accountIdentifierType = signupPhoneNumber && state?.accountIdentifierType === 'phone'
+ const explicitEmailIdentity = rawAccountIdentifierType === 'email' && resolvedEmail;
+ const shouldUsePhoneIdentity = !explicitEmailIdentity && (
+ rawAccountIdentifierType === 'phone'
+ || Boolean(signupPhoneNumber)
+ || getResolvedSignupMethodForStep3(state) === 'phone'
+ );
+ const accountIdentifierType = shouldUsePhoneIdentity
? 'phone'
- : (resolvedEmail ? 'email' : (signupPhoneNumber ? 'phone' : 'email'));
+ : (resolvedEmail ? 'email' : 'email');
const accountIdentifier = accountIdentifierType === 'phone'
? signupPhoneNumber
: resolvedEmail;
@@ -40,6 +65,9 @@
async function executeStep3(state) {
const identity = resolveStep3AccountIdentity(state);
if (!identity.accountIdentifier) {
+ if (identity.accountIdentifierType === 'phone') {
+ throw new Error('缺少注册手机号,请先完成步骤 2 或在侧栏填写注册手机号后再执行步骤 3。');
+ }
throw new Error('缺少注册账号,请先完成步骤 2。');
}
diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css
index 8f23474..45cb3c8 100644
--- a/sidepanel/sidepanel.css
+++ b/sidepanel/sidepanel.css
@@ -1971,6 +1971,102 @@ header {
.data-select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
[data-theme="dark"] .data-select { color-scheme: dark; }
+.sub2api-group-picker {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+}
+
+.sub2api-group-trigger {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ font-family: 'JetBrains Mono', monospace;
+ text-align: left;
+}
+
+.sub2api-group-caret {
+ width: 8px;
+ height: 8px;
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: translateY(-2px) rotate(45deg);
+ flex: 0 0 auto;
+ opacity: 0.7;
+}
+
+.sub2api-group-trigger.is-open .sub2api-group-caret {
+ transform: translateY(2px) rotate(225deg);
+}
+
+.sub2api-group-menu {
+ position: absolute;
+ z-index: 70;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ max-height: 220px;
+ overflow: auto;
+ padding: 4px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-base);
+ box-shadow: var(--shadow-md);
+}
+
+.sub2api-group-option-row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-height: 30px;
+}
+
+.sub2api-group-option,
+.sub2api-group-delete {
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: background var(--transition), color var(--transition);
+}
+
+.sub2api-group-option {
+ flex: 1;
+ min-width: 0;
+ padding: 6px 8px;
+ text-align: left;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sub2api-group-option[aria-selected="true"] {
+ color: var(--blue);
+ background: var(--blue-soft);
+}
+
+.sub2api-group-option:hover,
+.sub2api-group-delete:hover:not(:disabled) {
+ color: var(--text-primary);
+ background: var(--bg-hover);
+}
+
+.sub2api-group-delete {
+ flex: 0 0 auto;
+ padding: 5px 7px;
+ font-size: 11px;
+}
+
+.sub2api-group-delete:disabled {
+ cursor: not-allowed;
+ opacity: 0.42;
+}
+
.data-check {
display: flex;
align-items: flex-start;
diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html
index e1f387f..b509b79 100644
--- a/sidepanel/sidepanel.html
+++ b/sidepanel/sidepanel.html
@@ -197,7 +197,18 @@
默认代理
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js
index 524570f..548c223 100644
--- a/sidepanel/sidepanel.js
+++ b/sidepanel/sidepanel.js
@@ -95,6 +95,11 @@ const rowSub2ApiPassword = document.getElementById('row-sub2api-password');
const inputSub2ApiPassword = document.getElementById('input-sub2api-password');
const rowSub2ApiGroup = document.getElementById('row-sub2api-group');
const inputSub2ApiGroup = document.getElementById('input-sub2api-group');
+const sub2ApiGroupPicker = document.getElementById('sub2api-group-picker');
+const btnSub2ApiGroupMenu = document.getElementById('btn-sub2api-group-menu');
+const sub2ApiGroupCurrent = document.getElementById('sub2api-group-current');
+const sub2ApiGroupMenu = document.getElementById('sub2api-group-menu');
+const btnAddSub2ApiGroup = document.getElementById('btn-add-sub2api-group');
const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-proxy');
const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy');
const rowIpProxyEnabled = document.getElementById('row-ip-proxy-enabled');
@@ -1092,6 +1097,138 @@ let configActionInFlight = false;
let currentReleaseSnapshot = null;
let currentContributionContentSnapshot = null;
let contributionContentSnapshotRequestInFlight = null;
+
+const DEFAULT_SUB2API_GROUP_OPTIONS = ['codex', 'openai-plus'];
+let sub2ApiGroupMenuOpen = false;
+
+function splitSub2ApiGroupNames(value = '') {
+ return String(value || '')
+ .split(/[\r\n,,、]+/)
+ .map((name) => name.trim())
+ .filter(Boolean);
+}
+
+function normalizeSub2ApiGroupOptions(...sources) {
+ const groups = [];
+ const seen = new Set();
+
+ const append = (value) => {
+ if (Array.isArray(value)) {
+ value.forEach(append);
+ return;
+ }
+ for (const name of splitSub2ApiGroupNames(value)) {
+ const key = name.toLowerCase();
+ if (!key || seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ groups.push(name);
+ }
+ };
+
+ sources.forEach(append);
+ return groups;
+}
+
+function getSelectedSub2ApiGroupName() {
+ return String(inputSub2ApiGroup?.value || '').trim()
+ || DEFAULT_SUB2API_GROUP_OPTIONS[0];
+}
+
+function getSub2ApiGroupOptionsState(state = latestState) {
+ const options = normalizeSub2ApiGroupOptions(
+ state?.sub2apiGroupNames,
+ state?.sub2apiGroupName
+ );
+ return options.length ? options : [...DEFAULT_SUB2API_GROUP_OPTIONS];
+}
+
+function setSub2ApiGroupMenuOpen(open) {
+ sub2ApiGroupMenuOpen = Boolean(open);
+ if (sub2ApiGroupMenu) {
+ sub2ApiGroupMenu.hidden = !sub2ApiGroupMenuOpen;
+ }
+ if (btnSub2ApiGroupMenu) {
+ btnSub2ApiGroupMenu.setAttribute('aria-expanded', sub2ApiGroupMenuOpen ? 'true' : 'false');
+ btnSub2ApiGroupMenu.classList?.toggle('is-open', sub2ApiGroupMenuOpen);
+ }
+}
+
+function setSub2ApiGroupSelection(groupName, options = {}) {
+ const selected = String(groupName || '').trim() || DEFAULT_SUB2API_GROUP_OPTIONS[0];
+ if (inputSub2ApiGroup) {
+ inputSub2ApiGroup.value = selected;
+ if (options.emit && typeof inputSub2ApiGroup.dispatchEvent === 'function') {
+ inputSub2ApiGroup.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ }
+ if (sub2ApiGroupCurrent) {
+ sub2ApiGroupCurrent.textContent = selected;
+ }
+}
+
+function renderSub2ApiGroupOptions(state = latestState, selectedValue = '') {
+ if (!inputSub2ApiGroup) {
+ return;
+ }
+
+ const selected = String(selectedValue || state?.sub2apiGroupName || '').trim();
+ const options = getSub2ApiGroupOptionsState({
+ ...(state || {}),
+ sub2apiGroupName: selected || state?.sub2apiGroupName,
+ });
+ if (selected && !options.some((name) => name.toLowerCase() === selected.toLowerCase())) {
+ options.unshift(selected);
+ }
+
+ if (
+ !sub2ApiGroupMenu
+ || typeof sub2ApiGroupMenu.appendChild !== 'function'
+ || typeof document === 'undefined'
+ || typeof document.createElement !== 'function'
+ ) {
+ setSub2ApiGroupSelection(selected || options[0] || DEFAULT_SUB2API_GROUP_OPTIONS[0]);
+ return;
+ }
+
+ sub2ApiGroupMenu.innerHTML = '';
+ options.forEach((name) => {
+ const row = document.createElement('div');
+ row.className = 'sub2api-group-option-row';
+
+ const option = document.createElement('button');
+ option.type = 'button';
+ option.className = 'sub2api-group-option';
+ option.setAttribute('role', 'option');
+ option.setAttribute('aria-selected', name === selected ? 'true' : 'false');
+ option.textContent = name;
+ option.addEventListener('click', () => {
+ setSub2ApiGroupSelection(name, { emit: true });
+ setSub2ApiGroupMenuOpen(false);
+ });
+
+ const deleteButton = document.createElement('button');
+ deleteButton.type = 'button';
+ deleteButton.className = 'sub2api-group-delete';
+ deleteButton.textContent = '删除';
+ deleteButton.title = `删除分组 ${name}`;
+ deleteButton.setAttribute('aria-label', `删除分组 ${name}`);
+ deleteButton.disabled = options.length <= 1;
+ deleteButton.addEventListener('click', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ handleDeleteSub2ApiGroup(name).catch((error) => {
+ showToast(error?.message || '删除 SUB2API 分组失败。', 'error');
+ });
+ });
+
+ row.appendChild(option);
+ row.appendChild(deleteButton);
+ sub2ApiGroupMenu.appendChild(row);
+ });
+ setSub2ApiGroupSelection(selected || options[0] || DEFAULT_SUB2API_GROUP_OPTIONS[0]);
+}
let customEmailPoolEntriesState = [];
const EYE_OPEN_ICON = '
';
@@ -3118,6 +3255,35 @@ function collectSettingsPayload() {
: (String(latestState?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'))
);
const plusPaymentMethod = getSelectedPlusPaymentMethod();
+ const selectedSub2ApiGroupName = String(inputSub2ApiGroup.value || '').trim();
+ const sub2apiGroupNames = [];
+ const seenSub2ApiGroupNames = new Set();
+ const appendSub2ApiGroupNames = (value) => {
+ if (Array.isArray(value)) {
+ value.forEach(appendSub2ApiGroupNames);
+ return;
+ }
+ String(value || '')
+ .split(/[\r\n,,、]+/)
+ .map((name) => name.trim())
+ .filter(Boolean)
+ .forEach((name) => {
+ const key = name.toLowerCase();
+ if (!key || seenSub2ApiGroupNames.has(key)) {
+ return;
+ }
+ seenSub2ApiGroupNames.add(key);
+ sub2apiGroupNames.push(name);
+ });
+ };
+ [
+ latestState?.sub2apiGroupNames,
+ latestState?.sub2apiGroupName,
+ selectedSub2ApiGroupName,
+ ].forEach(appendSub2ApiGroupNames);
+ if (sub2apiGroupNames.length === 0) {
+ appendSub2ApiGroupNames(['codex', 'openai-plus']);
+ }
return {
...(contributionModeEnabled ? {} : {
panelMode: selectPanelMode.value,
@@ -3128,7 +3294,8 @@ function collectSettingsPayload() {
sub2apiUrl: inputSub2ApiUrl.value.trim(),
sub2apiEmail: inputSub2ApiEmail.value.trim(),
sub2apiPassword: inputSub2ApiPassword.value,
- sub2apiGroupName: inputSub2ApiGroup.value.trim(),
+ sub2apiGroupName: selectedSub2ApiGroupName,
+ sub2apiGroupNames,
sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim(),
ipProxyEnabled: getSelectedIpProxyEnabledSafe(),
ipProxyService: selectedIpProxyService,
@@ -7015,6 +7182,18 @@ function getRuntimeSignupPhoneValue(state = latestState) {
).trim();
}
+function shouldExecuteStep3WithSignupPhoneIdentity(state = latestState) {
+ const identifierType = String(state?.accountIdentifierType || '').trim().toLowerCase();
+ const resolvedMethod = normalizeSignupMethod(
+ state?.resolvedSignupMethod
+ || state?.signupMethod
+ || (typeof getSelectedSignupMethod === 'function' ? getSelectedSignupMethod() : DEFAULT_SIGNUP_METHOD)
+ );
+ return identifierType === 'phone'
+ || Boolean(getRuntimeSignupPhoneValue(state))
+ || resolvedMethod === SIGNUP_METHOD_PHONE;
+}
+
function getSignupPhoneInputValue() {
return typeof inputSignupPhone !== 'undefined' && inputSignupPhone
? String(inputSignupPhone.value || '').trim()
@@ -7715,7 +7894,7 @@ function applySettingsState(state) {
inputSub2ApiUrl.value = state?.sub2apiUrl || '';
inputSub2ApiEmail.value = state?.sub2apiEmail || '';
inputSub2ApiPassword.value = state?.sub2apiPassword || '';
- inputSub2ApiGroup.value = state?.sub2apiGroupName || '';
+ renderSub2ApiGroupOptions(state, state?.sub2apiGroupName || '');
inputSub2ApiDefaultProxy.value = state?.sub2apiDefaultProxyName || '';
const normalizedIpProxyService = resolveIpProxyService(state?.ipProxyService);
const normalizedIpProxyServiceProfiles = typeof normalizeIpProxyServiceProfiles === 'function'
@@ -9336,6 +9515,90 @@ async function saveCloudflareTempEmailDomainSettings(domains, activeDomain, opti
}
}
+async function handleAddSub2ApiGroup() {
+ if (!sharedFormDialog?.open) {
+ showToast('表单弹窗未加载,请刷新扩展后重试。', 'error');
+ return;
+ }
+
+ const result = await sharedFormDialog.open({
+ title: '添加 SUB2API 分组',
+ confirmLabel: '添加',
+ confirmVariant: 'btn-primary',
+ fields: [
+ {
+ key: 'groupName',
+ label: '分组',
+ type: 'text',
+ placeholder: '例如 openai-plus',
+ autocomplete: 'off',
+ required: true,
+ requiredMessage: '请先填写 SUB2API 分组名称。',
+ validate: (value) => {
+ const names = normalizeSub2ApiGroupOptions(value);
+ return names.length ? '' : '请先填写 SUB2API 分组名称。';
+ },
+ },
+ ],
+ });
+ if (!result) {
+ return;
+ }
+
+ const newGroups = normalizeSub2ApiGroupOptions(result.groupName);
+ if (!newGroups.length) {
+ return;
+ }
+
+ const selectedGroup = newGroups[0];
+ const nextGroups = normalizeSub2ApiGroupOptions(
+ getSub2ApiGroupOptionsState(latestState),
+ newGroups
+ );
+ syncLatestState({
+ sub2apiGroupName: selectedGroup,
+ sub2apiGroupNames: nextGroups,
+ });
+ renderSub2ApiGroupOptions(latestState, selectedGroup);
+ markSettingsDirty(true);
+ await saveSettings({ silent: true }).catch(() => { });
+ showToast(`已添加并切换到 SUB2API 分组:${selectedGroup}`, 'success', 1800);
+}
+
+async function handleDeleteSub2ApiGroup(groupName) {
+ const targetName = String(groupName || '').trim();
+ if (!targetName) {
+ return;
+ }
+
+ const currentGroups = getSub2ApiGroupOptionsState(latestState);
+ if (currentGroups.length <= 1) {
+ showToast('至少保留一个 SUB2API 分组。', 'warn', 1800);
+ return;
+ }
+
+ const targetKey = targetName.toLowerCase();
+ const nextGroups = currentGroups.filter((name) => name.toLowerCase() !== targetKey);
+ if (nextGroups.length === currentGroups.length) {
+ return;
+ }
+
+ const currentGroup = getSelectedSub2ApiGroupName();
+ const nextSelectedGroup = currentGroup.toLowerCase() === targetKey
+ ? nextGroups[0]
+ : (nextGroups.find((name) => name.toLowerCase() === currentGroup.toLowerCase()) || nextGroups[0]);
+
+ syncLatestState({
+ sub2apiGroupName: nextSelectedGroup,
+ sub2apiGroupNames: nextGroups,
+ });
+ renderSub2ApiGroupOptions(latestState, nextSelectedGroup);
+ setSub2ApiGroupMenuOpen(true);
+ markSettingsDirty(true);
+ await saveSettings({ silent: true }).catch(() => { });
+ showToast(`已删除 SUB2API 分组:${targetName}`, 'success', 1600);
+}
+
function updatePanelModeUI() {
const useSub2Api = selectPanelMode.value === 'sub2api';
const useCodex2Api = selectPanelMode.value === 'codex2api';
@@ -10338,8 +10601,12 @@ stepsList?.addEventListener('click', async (event) => {
});
syncLatestState({ customPassword: inputPassword.value });
}
- let email = inputEmail.value.trim();
- if (selectMailProvider.value === 'hotmail-api' || isLuckmailProvider()) {
+ if (shouldExecuteStep3WithSignupPhoneIdentity(latestState)) {
+ const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
+ if (response?.error) {
+ throw new Error(response.error);
+ }
+ } else if (selectMailProvider.value === 'hotmail-api' || isLuckmailProvider()) {
const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
if (response?.error) {
throw new Error(response.error);
@@ -11412,13 +11679,34 @@ inputSub2ApiPassword.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
-inputSub2ApiGroup.addEventListener('input', () => {
+inputSub2ApiGroup.addEventListener('change', () => {
+ syncLatestState({
+ sub2apiGroupName: getSelectedSub2ApiGroupName(),
+ sub2apiGroupNames: normalizeSub2ApiGroupOptions(
+ getSub2ApiGroupOptionsState(latestState),
+ getSelectedSub2ApiGroupName()
+ ),
+ });
markSettingsDirty(true);
- scheduleSettingsAutoSave();
-});
-inputSub2ApiGroup.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
+btnSub2ApiGroupMenu?.addEventListener('click', (event) => {
+ event.stopPropagation();
+ setSub2ApiGroupMenuOpen(!sub2ApiGroupMenuOpen);
+});
+btnSub2ApiGroupMenu?.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ setSub2ApiGroupMenuOpen(false);
+ }
+});
+sub2ApiGroupMenu?.addEventListener('click', (event) => {
+ event.stopPropagation();
+});
+btnAddSub2ApiGroup?.addEventListener('click', () => {
+ handleAddSub2ApiGroup().catch((error) => {
+ showToast(error?.message || '添加 SUB2API 分组失败。', 'error');
+ });
+});
inputSub2ApiDefaultProxy.addEventListener('input', () => {
markSettingsDirty(true);
@@ -12570,6 +12858,12 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
selectPanelMode.value = message.payload.panelMode || 'cpa';
updatePanelModeUI();
}
+ if (
+ message.payload.sub2apiGroupName !== undefined
+ || message.payload.sub2apiGroupNames !== undefined
+ ) {
+ renderSub2ApiGroupOptions(latestState, latestState?.sub2apiGroupName || '');
+ }
if (
message.payload.ipProxyEnabled !== undefined
|| message.payload.ipProxyService !== undefined
@@ -13171,6 +13465,7 @@ document.addEventListener('click', (event) => {
const clickedInsideFiveSimCountryMenu = Boolean(fiveSimCountryMenuShell?.contains(event.target));
const clickedInsideNexSmsCountryMenu = Boolean(nexSmsCountryMenuShell?.contains(event.target));
const clickedInsideProviderOrderMenu = Boolean(phoneSmsProviderOrderMenuShell?.contains(event.target));
+ const clickedInsideSub2ApiGroupPicker = Boolean(sub2ApiGroupPicker?.contains(event.target));
if (configMenuOpen && !clickedInsideConfigMenu) {
closeConfigMenu();
@@ -13192,6 +13487,9 @@ document.addEventListener('click', (event) => {
if (providerOrderMenuOpen && !clickedInsideProviderOrderMenu) {
setPhoneSmsProviderOrderMenuOpen(false);
}
+ if (sub2ApiGroupMenuOpen && !clickedInsideSub2ApiGroupPicker) {
+ setSub2ApiGroupMenuOpen(false);
+ }
});
document.addEventListener('keydown', (event) => {
@@ -13213,6 +13511,9 @@ document.addEventListener('keydown', (event) => {
if (btnPhoneSmsProviderOrderMenu?.getAttribute('aria-expanded') === 'true') {
setPhoneSmsProviderOrderMenuOpen(false);
}
+ if (sub2ApiGroupMenuOpen) {
+ setSub2ApiGroupMenuOpen(false);
+ }
});
window.addEventListener('resize', () => {
diff --git a/tests/background-account-history-settings.test.js b/tests/background-account-history-settings.test.js
index 726c903..0b4ca5d 100644
--- a/tests/background-account-history-settings.test.js
+++ b/tests/background-account-history-settings.test.js
@@ -77,6 +77,7 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizeFiveSimOperator'),
extractFunction('normalizeFiveSimMaxPrice'),
extractFunction('normalizeFiveSimCountryFallback'),
+ extractFunction('normalizeSub2ApiGroupNames'),
extractFunction('normalizePersistentSettingValue'),
].join('\n');
@@ -247,6 +248,10 @@ return {
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '),
'proxy-a'
);
+ assert.deepStrictEqual(
+ api.normalizePersistentSettingValue('sub2apiGroupNames', [' codex ', 'openai-plus', 'CODEX']),
+ ['codex', 'openai-plus']
+ );
assert.equal(
api.normalizePersistentSettingValue('codex2apiUrl', 'localhost:8080/admin'),
'http://localhost:8080/admin/accounts'
diff --git a/tests/background-signup-step2-branching.test.js b/tests/background-signup-step2-branching.test.js
index f289b72..3a02ee2 100644
--- a/tests/background-signup-step2-branching.test.js
+++ b/tests/background-signup-step2-branching.test.js
@@ -512,6 +512,73 @@ test('signup flow helper reuses existing managed alias email when it is still co
assert.equal(setEmailCalls, 0);
});
+test('signup flow helper can generate an email on demand when add-email starts from phone signup', async () => {
+ const fetchedStates = [];
+ const setStateCalls = [];
+
+ const helpers = signupFlowApi.createSignupFlowHelpers({
+ buildGeneratedAliasEmail: () => '',
+ chrome: { tabs: { get: async () => ({ id: 21, url: 'https://auth.openai.com/create-account/password' }) } },
+ ensureContentScriptReadyOnTab: async () => {},
+ ensureHotmailAccountForFlow: async () => ({}),
+ ensureLuckmailPurchaseForFlow: async () => ({}),
+ fetchGeneratedEmail: async (state, options) => {
+ fetchedStates.push({ state, options });
+ return 'duck.generated@example.com';
+ },
+ isGeneratedAliasProvider: () => false,
+ isReusableGeneratedAliasEmail: () => false,
+ isHotmailProvider: () => false,
+ isLuckmailProvider: () => false,
+ isSignupEmailVerificationPageUrl: () => false,
+ isSignupPasswordPageUrl: () => true,
+ reuseOrCreateTab: async () => 21,
+ sendToContentScriptResilient: async () => ({}),
+ setEmailState: async () => {
+ throw new Error('fetchGeneratedEmail already persists the generated email');
+ },
+ setState: async (updates) => {
+ setStateCalls.push(updates);
+ },
+ SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
+ SIGNUP_PAGE_INJECT_FILES: [],
+ waitForTabUrlMatch: async () => null,
+ });
+
+ const email = await helpers.resolveSignupEmailForFlow({
+ email: '',
+ emailGenerator: 'duck',
+ accountIdentifierType: 'phone',
+ accountIdentifier: '+447780579093',
+ signupPhoneNumber: '+447780579093',
+ signupPhoneCompletedActivation: {
+ activationId: 'signup-completed',
+ phoneNumber: '+447780579093',
+ },
+ }, {
+ preserveAccountIdentity: true,
+ });
+
+ assert.equal(email, 'duck.generated@example.com');
+ assert.equal(fetchedStates.length, 1);
+ assert.equal(fetchedStates[0].options.preserveAccountIdentity, true);
+ assert.deepStrictEqual(setStateCalls, [
+ {
+ email: 'duck.generated@example.com',
+ accountIdentifierType: 'phone',
+ accountIdentifier: '+447780579093',
+ signupPhoneNumber: '+447780579093',
+ signupPhoneActivation: null,
+ signupPhoneCompletedActivation: {
+ activationId: 'signup-completed',
+ phoneNumber: '+447780579093',
+ },
+ signupPhoneVerificationRequestedAt: null,
+ signupPhoneVerificationPurpose: '',
+ },
+ ]);
+});
+
test('signup flow helper finalizes step 3 submit by reusing signup verification preparation', async () => {
let ensureCalls = 0;
const messages = [];
diff --git a/tests/background-step3-password-reuse.test.js b/tests/background-step3-password-reuse.test.js
index 0a3db35..3ce6d6e 100644
--- a/tests/background-step3-password-reuse.test.js
+++ b/tests/background-step3-password-reuse.test.js
@@ -122,3 +122,81 @@ test('step 3 supports phone-only signup identity when password page is present',
},
]);
});
+
+test('step 3 phone signup intent does not fall back to a stale email identity', async () => {
+ const events = {
+ passwordStates: [],
+ messages: [],
+ stateUpdates: [],
+ };
+
+ const executor = api.createStep3Executor({
+ addLog: async () => {},
+ chrome: { tabs: { update: async () => {} } },
+ ensureContentScriptReadyOnTab: async () => {},
+ generatePassword: () => 'Generated123!',
+ getTabId: async () => 88,
+ isTabAlive: async () => true,
+ resolveSignupMethod: () => 'phone',
+ sendToContentScript: async (_source, message) => {
+ events.messages.push(message);
+ },
+ setPasswordState: async (password) => {
+ events.passwordStates.push(password);
+ },
+ setState: async (updates) => {
+ events.stateUpdates.push(updates);
+ },
+ SIGNUP_PAGE_INJECT_FILES: [],
+ });
+
+ await assert.rejects(
+ () => executor.executeStep3({
+ email: 'stale@example.com',
+ signupMethod: 'phone',
+ resolvedSignupMethod: 'phone',
+ accountIdentifierType: null,
+ accountIdentifier: '',
+ customPassword: 'PhoneSecret123!',
+ accounts: [],
+ }),
+ /缺少注册手机号,请先完成步骤 2 或在侧栏填写注册手机号后再执行步骤 3。/
+ );
+
+ assert.deepStrictEqual(events.passwordStates, []);
+ assert.deepStrictEqual(events.stateUpdates, []);
+ assert.deepStrictEqual(events.messages, []);
+});
+
+test('step 3 respects resolved email fallback when phone signup is unavailable', async () => {
+ const events = {
+ messages: [],
+ };
+
+ const executor = api.createStep3Executor({
+ addLog: async () => {},
+ chrome: { tabs: { update: async () => {} } },
+ ensureContentScriptReadyOnTab: async () => {},
+ generatePassword: () => 'Generated123!',
+ getTabId: async () => 88,
+ isTabAlive: async () => true,
+ resolveSignupMethod: () => 'email',
+ sendToContentScript: async (_source, message) => {
+ events.messages.push(message);
+ },
+ setPasswordState: async () => {},
+ setState: async () => {},
+ SIGNUP_PAGE_INJECT_FILES: [],
+ });
+
+ await executor.executeStep3({
+ email: 'fallback@example.com',
+ signupMethod: 'phone',
+ resolvedSignupMethod: 'email',
+ customPassword: 'EmailSecret123!',
+ accounts: [],
+ });
+
+ assert.equal(events.messages[0].payload.accountIdentifierType, 'email');
+ assert.equal(events.messages[0].payload.accountIdentifier, 'fallback@example.com');
+});
diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js
index 3248750..0ce9f21 100644
--- a/tests/background-step7-recovery.test.js
+++ b/tests/background-step7-recovery.test.js
@@ -300,8 +300,9 @@ test('step 8 submits add-email before polling the email verification code', asyn
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
- resolveSignupEmailForFlow: async (state) => {
+ resolveSignupEmailForFlow: async (state, options = {}) => {
calls.resolvedStates.push(state);
+ calls.resolveOptions = options;
return 'new.user@example.com';
},
resolveVerificationStep: async (_step, state, _mail, options) => {
@@ -336,6 +337,7 @@ test('step 8 submits add-email before polling the email verification code', asyn
assert.equal(calls.contentMessages.length, 1);
assert.equal(calls.resolvedStates.length, 1);
+ assert.equal(calls.resolveOptions.preserveAccountIdentity, true);
assert.equal(calls.mailStates[0].email, 'new.user@example.com');
assert.equal(calls.resolvedVerification.state.email, 'new.user@example.com');
assert.equal(calls.resolvedVerification.options.targetEmail, 'new.user@example.com');
diff --git a/tests/sidepanel-account-records-manager.test.js b/tests/sidepanel-account-records-manager.test.js
index 5420d05..bae623c 100644
--- a/tests/sidepanel-account-records-manager.test.js
+++ b/tests/sidepanel-account-records-manager.test.js
@@ -141,6 +141,9 @@ test('sidepanel html contains account records overlay and manager script', () =>
assert.match(html, /id="btn-toggle-account-records-selection"/);
assert.match(html, /id="btn-delete-selected-account-records"/);
assert.match(html, /id="input-sub2api-default-proxy"/);
+ assert.match(html, /id="sub2api-group-picker"/);
+ assert.match(html, /id="input-sub2api-group" value="codex"/);
+ assert.match(html, /id="btn-add-sub2api-group"/);
assert.notEqual(managerIndex, -1);
assert.notEqual(sidepanelIndex, -1);
assert.ok(managerIndex < sidepanelIndex);
diff --git a/tests/sidepanel-icloud-provider.test.js b/tests/sidepanel-icloud-provider.test.js
index a19617d..115bd8e 100644
--- a/tests/sidepanel-icloud-provider.test.js
+++ b/tests/sidepanel-icloud-provider.test.js
@@ -512,6 +512,7 @@ function updateAccountRunHistorySettingsUI() {}
function updatePhoneVerificationSettingsUI() {}
function updatePanelModeUI() {}
function updateMailProviderUI() { calls.push({ target: selectIcloudTargetMailboxType.value, provider: selectIcloudForwardMailProvider.value }); }
+function renderSub2ApiGroupOptions() {}
function isLuckmailProvider() { return false; }
function updateButtonStates() {}
${bundle}
diff --git a/tests/sidepanel-phone-verification-settings.test.js b/tests/sidepanel-phone-verification-settings.test.js
index 00f66c1..cb41861 100644
--- a/tests/sidepanel-phone-verification-settings.test.js
+++ b/tests/sidepanel-phone-verification-settings.test.js
@@ -136,6 +136,7 @@ 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 shouldExecuteStep3WithSignupPhoneIdentity\(state = latestState\)/);
assert.match(sidepanelSource, /function shouldPreserveSignupPhoneInputValue\(stateSignupPhone = ''\)/);
assert.match(sidepanelSource, /function syncSignupPhoneInputFromState\(state = latestState\)/);
assert.match(sidepanelSource, /async function persistSignupPhoneInputForAction\(\)/);
@@ -143,10 +144,40 @@ test('sidepanel source wires runtime signup phone field to background sync messa
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, /if \(shouldExecuteStep3WithSignupPhoneIdentity\(latestState\)\)[\s\S]*payload: \{ step \}/);
assert.match(sidepanelSource, /async function handleSkipStep\(step\)[\s\S]*await persistCurrentSettingsForAction\(\);/);
assert.match(sidepanelSource, /inputSignupPhone\.addEventListener\('input'[\s\S]*signupPhoneInputDirty = true/);
});
+test('manual step 3 uses phone identity without requiring registration email', () => {
+ const api = new Function(`
+let latestState = { signupMethod: 'phone', phoneVerificationEnabled: true, signupPhoneNumber: '+441111111111', accountIdentifierType: 'phone', accountIdentifier: '+441111111111' };
+const DEFAULT_SIGNUP_METHOD = 'email';
+const SIGNUP_METHOD_PHONE = 'phone';
+function getSelectedSignupMethod() { return 'phone'; }
+${extractFunction('normalizeSignupMethod')}
+${extractFunction('getRuntimeSignupPhoneValue')}
+${extractFunction('shouldExecuteStep3WithSignupPhoneIdentity')}
+return { shouldExecuteStep3WithSignupPhoneIdentity };
+`)();
+
+ assert.equal(api.shouldExecuteStep3WithSignupPhoneIdentity({
+ signupMethod: 'phone',
+ phoneVerificationEnabled: true,
+ accountIdentifierType: 'phone',
+ accountIdentifier: '+441111111111',
+ signupPhoneNumber: '+441111111111',
+ email: '',
+ }), true);
+ assert.equal(api.shouldExecuteStep3WithSignupPhoneIdentity({
+ signupMethod: 'email',
+ accountIdentifierType: 'email',
+ accountIdentifier: 'user@example.com',
+ signupPhoneNumber: '',
+ email: 'user@example.com',
+ }), false);
+});
+
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' };
diff --git a/项目完整链路说明.md b/项目完整链路说明.md
index 7c07d02..b929098 100644
--- a/项目完整链路说明.md
+++ b/项目完整链路说明.md
@@ -355,6 +355,8 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功
补充:
- 手机号注册如果在 Step 2 后直接落到短信验证码页、资料页或已登录页,Step 3 会按页面状态自动跳过,不再因为缺少邮箱而失败。
+- 手动点击 Step 3 时,侧栏会先判断当前统一账号身份:如果本轮是手机号注册,或运行态已经写入 `accountIdentifierType = phone / signupPhoneNumber`,则直接执行 Step 3,不再尝试生成或校验注册邮箱,避免把手机号注册链路误切回邮箱注册链路。
+- 后台 Step 3 会再次按 `resolveSignupMethod` 与统一账号身份兜底校验;手机号注册意图下如果缺少手机号,会明确提示先完成 Step 2 或填写注册手机号,不会回退使用旧邮箱。
### Step 4 / Step 8
@@ -459,7 +461,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功
1. 先确认当前认证页真实状态,而不是只看 `accountIdentifierType`。
2. 当前是 `add-email`:
- - 调用 `resolveSignupEmailForFlow` 获取或生成本轮绑定邮箱
+ - 调用 `resolveSignupEmailForFlow` 按需获取或生成本轮绑定邮箱;手机号注册不会在 Step 2 提前生成邮箱,只有真实进入 `add-email` 时才占用邮箱资源
- 内容脚本发送 `SUBMIT_ADD_EMAIL` 填写邮箱并提交
- 页面进入邮箱验证码页后,用绑定邮箱作为 `step8VerificationTargetEmail`
3. 当前是邮箱验证码页:
@@ -479,7 +481,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功
- 对 `2925 receive` 而言,Step 8 会把当前目标注册邮箱一并传给 2925 内容脚本;只有当邮件里显式写出了其他邮箱时才会跳过,从而在不破坏历史兼容性的前提下,尽量降低误收验证码的概率。
- 对 `custom` provider 而言,Step 8 仍使用手动验证码确认弹窗;弹窗当前额外提供“出现手机号验证”按钮,点击后会直接抛出与真实 `add-phone` 页面一致的 fatal 错误,供 Auto 按既有 add-phone 分支继续下一邮箱。
- 当登录验证码提交后进入 `phone-verification` 页时,内容脚本会显式把该页面识别为“手机验证码页”,避免与邮箱验证码页混淆。
-- 手机号注册身份本身不会让 Step 8 自动走短信;只有真实页面状态是 `phone_verification_page` 时才调用短信 helper。手机号注册登录后出现 `add-email` 时,Step 8 走“绑定邮箱 -> 邮箱验证码”链路。
+- 手机号注册身份本身不会让 Step 8 自动走短信;只有真实页面状态是 `phone_verification_page` 时才调用短信 helper。手机号注册登录后出现 `add-email` 时,Step 8 走“按需生成邮箱 -> 绑定邮箱 -> 邮箱验证码”链路。
- `email_in_use` 会在 Step 8 当前步骤内清理当前邮箱并重新打开 `add-email` 获取新邮箱;`max_check_attempts` 不继续点击认证页 `重试`,只恢复当前 Step 8。
### Step 9