Files
Genarrative/src/components/auth/AccountModal.tsx
高物 1c72066bab
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 15:45:14 +08:00

902 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type {
AuthAuditLogEntry,
AuthCaptchaChallenge,
AuthRiskBlockSummary,
AuthSessionSummary,
AuthUser,
} from '../../services/authService';
import type { PlatformSettingsSection } from './AuthUiContext';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type AccountModalProps = {
user: AuthUser;
isOpen: boolean;
initialSection?: PlatformSettingsSection | null;
platformTheme: PlatformTheme;
riskBlocks: AuthRiskBlockSummary[];
sessions: AuthSessionSummary[];
auditLogs: AuthAuditLogEntry[];
loadingRiskBlocks: boolean;
loadingSessions: boolean;
loadingAuditLogs: boolean;
isHydratingSettings: boolean;
isPersistingSettings: boolean;
settingsError: string | null;
onClose: () => void;
onPlatformThemeChange: (theme: PlatformTheme) => void;
onLogout: () => Promise<void>;
onRefreshRiskBlocks: () => Promise<void>;
onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise<void>;
onRefreshSessions: () => Promise<void>;
onLogoutAll: () => Promise<void>;
onRefreshAuditLogs: () => Promise<void>;
onRevokeSession: (sessionId: string) => Promise<void>;
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
onSendChangePhoneCode: (
phone: string,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onChangePhone: (phone: string, code: string) => Promise<void>;
};
const SETTINGS_SECTIONS: Array<{
id: 'appearance' | 'account';
label: string;
detail: string;
}> = [
{ id: 'appearance', label: '主题外观', detail: '亮暗主题' },
{ id: 'account', label: '账号信息', detail: '身份与安全' },
];
const ACCOUNT_MODAL_MAX_HEIGHT =
'calc(100vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 2rem)';
type PrimarySettingsSection = (typeof SETTINGS_SECTIONS)[number]['id'];
function normalizeSettingsSection(
section: PlatformSettingsSection | null | undefined,
): PrimarySettingsSection | null {
if (section === 'appearance') {
return 'appearance';
}
if (
section === 'account' ||
section === 'security' ||
section === 'devices' ||
section === 'logs'
) {
return 'account';
}
return null;
}
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) {
case 'wechat':
return '微信登录';
case 'phone':
return '手机号登录';
default:
return '账号登录';
}
}
function formatSessionTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function SettingsEntryCard({
label,
detail,
summary,
onClick,
}: {
label: string;
detail: string;
summary: string;
onClick: (trigger: HTMLButtonElement) => void;
}) {
return (
<button
type="button"
onClick={(event) => onClick(event.currentTarget)}
className="platform-subpanel w-full rounded-[1.5rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)]"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
<div className="mt-1 text-[11px] text-[var(--platform-text-soft)]">
{detail}
</div>
</div>
<span className="text-lg leading-none text-[var(--platform-text-soft)]">
</span>
</div>
<div className="mt-3 text-sm text-[var(--platform-text-base)]">
{summary}
</div>
</button>
);
}
function OverlayPanel({
eyebrow,
title,
description,
action,
onBack,
onClose,
children,
}: {
eyebrow: string;
title: string;
description?: string;
action?: ReactNode;
onBack?: () => void;
onClose: () => void;
children: ReactNode;
}) {
return (
<div
className="absolute inset-0 z-10 flex items-end bg-black/20 backdrop-blur-[2px] sm:items-center sm:justify-center sm:p-4"
onClick={onBack ?? onClose}
>
<div
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
{description}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
{action}
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
</button>
) : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
)}
</div>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div>
</div>
);
}
function ThemeOptionCard({
active,
title,
detail,
previewClassName,
onClick,
}: {
active: boolean;
title: string;
detail: string;
previewClassName: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`platform-subpanel w-full rounded-[1.5rem] p-4 text-left transition ${
active
? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(255,91,132,0.14)]'
: 'hover:border-[var(--platform-surface-hover-border)]'
}`}
>
<div className={`h-28 rounded-[1.15rem] ${previewClassName}`} />
<div className="mt-4 text-base font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{detail}
</div>
</button>
);
}
export function AccountModal({
user,
isOpen,
initialSection = null,
platformTheme,
riskBlocks,
sessions,
auditLogs,
loadingRiskBlocks,
loadingSessions,
loadingAuditLogs,
isHydratingSettings,
isPersistingSettings,
settingsError,
onClose,
onPlatformThemeChange,
onLogout,
onRefreshRiskBlocks,
onLiftRiskBlock,
onRefreshSessions,
onLogoutAll,
onRefreshAuditLogs,
onRevokeSession,
changePhoneCaptchaChallenge,
onSendChangePhoneCode,
onChangePhone,
}: AccountModalProps) {
const [activeSection, setActiveSection] =
useState<PrimarySettingsSection | null>(
normalizeSettingsSection(initialSection),
);
const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [changePhoneError, setChangePhoneError] = useState('');
const [changePhoneHint, setChangePhoneHint] = useState('');
const [accountNotice, setAccountNotice] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [changingPhone, setChangingPhone] = useState(false);
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const settingsHomeRef = useRef<HTMLDivElement | null>(null);
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
if (!element) {
return;
}
window.requestAnimationFrame(() => {
if (element.isConnected) {
element.focus();
}
});
}, []);
const resetChangePhoneDraft = useCallback(() => {
setPhone('');
setCode('');
setCaptchaAnswer('');
setChangePhoneError('');
setChangePhoneHint('');
setCooldownSeconds(0);
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
setActiveSection(normalizeSettingsSection(initialSection));
setIsChangePhonePanelOpen(false);
setAccountNotice('');
sectionTriggerRef.current = null;
changePhoneTriggerRef.current = null;
resetChangePhoneDraft();
}, [initialSection, isOpen, resetChangePhoneDraft]);
useEffect(() => {
const settingsHome = settingsHomeRef.current;
if (!settingsHome) {
return;
}
settingsHome.toggleAttribute('inert', activeSection !== null);
return () => {
settingsHome.removeAttribute('inert');
};
}, [activeSection]);
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [cooldownSeconds]);
const closeSectionPanel = useCallback(() => {
const sectionTrigger = sectionTriggerRef.current;
setIsChangePhonePanelOpen(false);
setActiveSection(null);
resetChangePhoneDraft();
focusAfterNextPaint(sectionTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
const closeChangePhonePanel = useCallback(() => {
const changePhoneTrigger = changePhoneTriggerRef.current;
setIsChangePhonePanelOpen(false);
resetChangePhoneDraft();
focusAfterNextPaint(changePhoneTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
if (!isOpen) {
return null;
}
const themeStatusText = settingsError
? settingsError
: isHydratingSettings
? '正在读取平台设置...'
: isPersistingSettings
? '正在同步平台设置...'
: '平台设置已同步';
const accountSummaryCards = [
['登录方式', resolveLoginMethodLabel(user.loginMethod)],
['手机号', user.phoneNumberMasked || '未绑定'],
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
] as const;
const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance:
platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
account:
user.phoneNumberMasked || user.wechatBound
? '查看身份、安全状态、登录设备与操作记录。'
: '查看账号绑定状态与安全记录。',
};
return (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-hidden px-4 sm:items-center`}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
}}
onClick={onClose}
>
<div
className="platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
role="dialog"
aria-modal="true"
aria-label="设置与账号安全"
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={(trigger) => {
sectionTriggerRef.current = trigger;
setAccountNotice('');
setActiveSection(section.id);
}}
/>
))}
</div>
</div>
</div>
{activeSection === 'appearance' ? (
<OverlayPanel
eyebrow="平台偏好"
title="主题外观"
description="切换平台亮色或暗色主题。"
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 md:grid-cols-2">
<ThemeOptionCard
active={platformTheme === 'light'}
title="亮色主题"
detail="暖白底面板,粉橘强调。"
previewClassName="bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,#fff8fb_0%,#ffe9ee_52%,#ffd8cb_100%)] border border-white/70"
onClick={() => onPlatformThemeChange('light')}
/>
<ThemeOptionCard
active={platformTheme === 'dark'}
title="暗色主题"
detail="保留原有紫蓝深色方案。"
previewClassName="bg-[radial-gradient(circle_at_top_left,rgba(129,140,248,0.28),transparent_34%),linear-gradient(180deg,#17192b_0%,#0b0d15_100%)] border border-white/10"
onClick={() => onPlatformThemeChange('dark')}
/>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</div>
</div>
</div>
</OverlayPanel>
) : null}
{activeSection === 'account' ? (
<OverlayPanel
eyebrow="身份信息"
title="账号信息"
description="统一查看身份、安全状态、登录设备与最近操作。"
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="flex min-h-0 flex-col gap-4">
{accountNotice ? (
<div className="platform-banner platform-banner--success text-sm">
{accountNotice}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{accountSummaryCards.map(([label, value]) => (
<div
key={label}
className="platform-subpanel rounded-2xl px-4 py-3"
>
<div className="text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
{label}
</div>
<div className="mt-2 text-sm font-semibold text-[var(--platform-text-strong)]">
{value}
</div>
</div>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
}}
>
</button>
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
key={`${block.scopeType}:${block.expiresAt}`}
className="platform-banner platform-banner--warning text-sm"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
<span className="text-xs">
{' '}
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
</span>
</div>
<div className="mt-2 text-xs leading-5">
{block.detail}
</div>
<button
type="button"
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingSessions ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
<span className="text-xs text-[var(--platform-text-soft)]">
{formatSessionTime(log.createdAt)}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{log.detail}
</div>
{log.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{log.ipMasked}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
</div>
{isChangePhonePanelOpen ? (
<OverlayPanel
eyebrow="手机号换绑"
title="绑定新手机号"
description="输入新手机号并完成验证码验证。"
onBack={closeChangePhonePanel}
onClose={onClose}
>
<div className="grid gap-3">
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input h-11"
value={phone}
inputMode="numeric"
placeholder="13800000000"
onChange={(event) => setPhone(event.target.value)}
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<div className="flex gap-3">
<input
className="platform-input h-11 min-w-0 flex-1"
value={code}
inputMode="numeric"
placeholder="输入验证码"
onChange={(event) => setCode(event.target.value)}
/>
<button
type="button"
disabled={
sendingCode || cooldownSeconds > 0 || !phone.trim()
}
className="platform-button platform-button--secondary h-11 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
setSendingCode(true);
setChangePhoneError('');
try {
const result = await onSendChangePhoneCode(
phone,
{
challengeId:
changePhoneCaptchaChallenge?.challengeId,
answer: captchaAnswer,
},
);
setCooldownSeconds(result.cooldownSeconds);
setChangePhoneHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch (error) {
setChangePhoneError(
error instanceof Error
? error.message
: '发送验证码失败,请稍后再试。',
);
setChangePhoneHint('');
} finally {
setSendingCode(false);
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{changePhoneHint ? (
<div className="platform-banner platform-banner--success text-sm">
{changePhoneHint}
</div>
) : null}
<CaptchaChallengeField
challenge={changePhoneCaptchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
{changePhoneError ? (
<div className="platform-banner platform-banner--danger text-sm">
{changePhoneError}
</div>
) : null}
<button
type="button"
disabled={changingPhone || !phone.trim() || !code.trim()}
className="platform-button platform-button--primary h-11 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void (async () => {
setChangingPhone(true);
setChangePhoneError('');
try {
await onChangePhone(phone, code);
setAccountNotice('手机号已更新。');
closeChangePhonePanel();
} catch (error) {
setChangePhoneError(
error instanceof Error
? error.message
: '更换手机号失败,请稍后再试。',
);
} finally {
setChangingPhone(false);
}
})();
}}
>
{changingPhone ? '提交中...' : '确认更换手机号'}
</button>
</div>
</OverlayPanel>
) : null}
</OverlayPanel>
) : null}
</div>
</div>
);
}