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; onRefreshRiskBlocks: () => Promise; onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise; onRefreshSessions: () => Promise; onLogoutAll: () => Promise; onRefreshAuditLogs: () => Promise; onRevokeSession: (sessionId: string) => Promise; changePhoneCaptchaChallenge: AuthCaptchaChallenge | null; onSendChangePhoneCode: ( phone: string, captcha?: { challengeId?: string; answer?: string; }, ) => Promise<{ cooldownSeconds: number; expiresInSeconds: number; }>; onChangePhone: (phone: string, code: string) => Promise; }; 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 ( ); } function OverlayPanel({ eyebrow, title, description, action, onBack, onClose, children, }: { eyebrow: string; title: string; description?: string; action?: ReactNode; onBack?: () => void; onClose: () => void; children: ReactNode; }) { return (
event.stopPropagation()} >
{eyebrow}
{title}
{description ? (
{description}
) : null}
{action} {onBack ? ( ) : ( )}
{children}
); } function ThemeOptionCard({ active, title, detail, previewClassName, onClick, }: { active: boolean; title: string; detail: string; previewClassName: string; onClick: () => void; }) { return ( ); } 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( 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(null); const sectionTriggerRef = useRef(null); const changePhoneTriggerRef = useRef(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 = { appearance: platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。', account: user.phoneNumberMasked || user.wechatBound ? '查看身份、安全状态、登录设备与操作记录。' : '查看账号绑定状态与安全记录。', }; return (
event.stopPropagation()} >
设置与账号安全
{SETTINGS_SECTIONS.map((section) => ( { sectionTriggerRef.current = trigger; setAccountNotice(''); setActiveSection(section.id); }} /> ))}
{activeSection === 'appearance' ? (
onPlatformThemeChange('light')} /> onPlatformThemeChange('dark')} />
当前主题
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
{themeStatusText}
) : null} {activeSection === 'account' ? (
{accountNotice ? (
{accountNotice}
) : null}
{accountSummaryCards.map(([label, value]) => (
{label}
{value}
))}
更换手机号
在独立面板中输入新的手机号与验证码。
安全状态
查看当前生效中的账号保护与限制。
{loadingRiskBlocks ? (
正在读取安全状态...
) : riskBlocks.length > 0 ? ( riskBlocks.map((block) => (
{block.title} 剩余约{' '} {Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '} 分钟
{block.detail}
)) ) : (
当前没有生效中的安全限制。
)}
登录设备
查看当前账号的设备会话与登录状态。
{loadingSessions ? (
正在读取当前登录设备...
) : sessions.length > 0 ? ( sessions.map((session) => (
{session.clientLabel} {session.isCurrent ? '当前设备' : '已登录'}
最近活跃:{formatSessionTime(session.lastSeenAt)}
到期时间:{formatSessionTime(session.expiresAt)}
{session.ipMasked ? (
IP:{session.ipMasked}
) : null} {!session.isCurrent ? ( ) : null}
)) ) : (
暂无可展示的登录设备。
)}
操作记录
查看最近的账号登录与安全动作。
{loadingAuditLogs ? (
正在读取账号操作记录...
) : auditLogs.length > 0 ? ( auditLogs.map((log) => (
{log.title} {formatSessionTime(log.createdAt)}
{log.detail}
{log.ipMasked ? (
IP:{log.ipMasked}
) : null}
)) ) : (
暂无账号操作记录。
)}
{isChangePhonePanelOpen ? (
{changePhoneHint ? (
{changePhoneHint}
) : null} {changePhoneError ? (
{changePhoneError}
) : null}
) : null}
) : null}
); }