import { PlatformActionButton, PlatformAsyncStatePanel, PlatformEmptyState, PlatformFieldLabel, PlatformPillBadge, PlatformStatusMessage, PlatformSubpanel, PlatformTextField, } from '@genarrative/shared/components'; import { ArrowLeft } from 'lucide-react'; 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'; import { PlatformAuthModalShell } from './PlatformAuthModalShell'; type AccountModalProps = { user: AuthUser; isOpen: boolean; entryMode?: 'settings' | 'account'; 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: (session: AuthSessionSummary) => Promise; revokingSessionIds: string[]; changePhoneCaptchaChallenge: AuthCaptchaChallenge | null; onSendChangePhoneCode: ( phone: string, captcha?: { challengeId?: string; answer?: string; }, ) => Promise<{ cooldownSeconds: number; expiresInSeconds: number; }>; onChangePhone: (phone: string, code: string) => Promise; onChangePassword: ( currentPassword: string, newPassword: 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 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 formatBoundWechatAccount(value: string | null | undefined) { const normalized = value?.trim(); if (!normalized) { return null; } return `微信账号尾号 ${normalized.slice(-6)}`; } function SettingsEntryCard({ label, detail, summary, onClick, }: { label: string; detail: string; summary: string; onClick: (trigger: HTMLButtonElement) => void; }) { return ( onClick(event.currentTarget)} className="w-full hover:border-[var(--platform-surface-hover-border)]" >
{label}
{detail}
›
{summary}
); } // 中文注释:账号安全子面板里的空态与轻量加载态共用同一层白底外壳,避免重复拼 flat subpanel 样式。 function AccountSubpanelState({ children }: { children: ReactNode }) { return ( {children} ); } function OverlayPanel({ eyebrow, title, description, action, standalone = false, dialog = true, onBack, onClose, children, }: { eyebrow?: string; title: string; description?: string; action?: ReactNode; standalone?: boolean; dialog?: boolean; onBack?: () => void; onClose: () => void; children: ReactNode; }) { const panel = (
event.stopPropagation()} >
{eyebrow ? (
{eyebrow}
) : null}
{title}
{description ? (
{description}
) : null}
{action} {onBack ? ( 返回 ) : ( 关闭 )}
{children}
); if (standalone) { return panel; } return (
{panel}
); } function ThemeOptionCard({ active, title, detail, previewClassName, onClick, }: { active: boolean; title: string; detail: string; previewClassName: string; onClick: () => void; }) { return (
{title}
{detail}
); } export function AccountModal({ user, isOpen, entryMode = 'settings', initialSection = null, platformTheme, riskBlocks, sessions, auditLogs, loadingRiskBlocks, loadingSessions, loadingAuditLogs, isHydratingSettings, isPersistingSettings, settingsError, onClose, onPlatformThemeChange, onLogout, onRefreshRiskBlocks, onLiftRiskBlock, onRefreshSessions, onLogoutAll, onRefreshAuditLogs, onRevokeSession, revokingSessionIds, changePhoneCaptchaChallenge, onSendChangePhoneCode, onChangePhone, onChangePassword, }: AccountModalProps) { const [activeSection, setActiveSection] = useState( normalizeSettingsSection(initialSection), ); const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false); const [isPasswordPanelOpen, setIsPasswordPanelOpen] = useState(false); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [captchaAnswer, setCaptchaAnswer] = useState(''); const [changePhoneError, setChangePhoneError] = useState(''); const [passwordError, setPasswordError] = useState(''); const [changePhoneHint, setChangePhoneHint] = useState(''); const [accountNotice, setAccountNotice] = useState(''); const [sendingCode, setSendingCode] = useState(false); const [changingPhone, setChangingPhone] = useState(false); const [changingPassword, setChangingPassword] = useState(false); const [cooldownSeconds, setCooldownSeconds] = useState(0); const settingsHomeRef = useRef(null); const sectionTriggerRef = useRef(null); const changePhoneTriggerRef = useRef(null); const passwordTriggerRef = useRef(null); const isDirectAccountMode = entryMode === 'account'; 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); }, []); const resetPasswordDraft = useCallback(() => { setCurrentPassword(''); setNewPassword(''); setPasswordError(''); }, []); useEffect(() => { if (!isOpen) { return; } setActiveSection( isDirectAccountMode ? 'account' : normalizeSettingsSection(initialSection), ); setIsChangePhonePanelOpen(false); setIsPasswordPanelOpen(false); setAccountNotice(''); sectionTriggerRef.current = null; changePhoneTriggerRef.current = null; passwordTriggerRef.current = null; resetChangePhoneDraft(); resetPasswordDraft(); }, [ initialSection, isDirectAccountMode, isOpen, resetChangePhoneDraft, resetPasswordDraft, ]); 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); setIsPasswordPanelOpen(false); setActiveSection(null); resetChangePhoneDraft(); resetPasswordDraft(); focusAfterNextPaint(sectionTrigger); }, [focusAfterNextPaint, resetChangePhoneDraft, resetPasswordDraft]); const closeChangePhonePanel = useCallback(() => { const changePhoneTrigger = changePhoneTriggerRef.current; setIsChangePhonePanelOpen(false); resetChangePhoneDraft(); focusAfterNextPaint(changePhoneTrigger); }, [focusAfterNextPaint, resetChangePhoneDraft]); const closePasswordPanel = useCallback(() => { const passwordTrigger = passwordTriggerRef.current; setIsPasswordPanelOpen(false); resetPasswordDraft(); focusAfterNextPaint(passwordTrigger); }, [focusAfterNextPaint, resetPasswordDraft]); if (!isOpen) { return null; } const themeStatusText = settingsError ? settingsError : isHydratingSettings ? '正在读取平台设置...' : isPersistingSettings ? '正在同步平台设置...' : '平台设置已同步'; const boundPhoneNumber = user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定'; const boundWechatDisplayName = user.wechatDisplayName?.trim() || formatBoundWechatAccount(user.wechatAccount) || (user.wechatBound ? '微信账号已绑定' : '未绑定'); const sectionSummaries: Record = { appearance: platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。', account: user.phoneNumber || user.phoneNumberMasked || user.wechatBound ? '查看身份、安全状态、登录设备与操作记录。' : '查看账号绑定状态与安全记录。', }; return (
event.stopPropagation()} > {!isDirectAccountMode ? (
设置与账号安全
关闭
) : null} {!isDirectAccountMode ? (
{SETTINGS_SECTIONS.map((section) => ( { sectionTriggerRef.current = trigger; setAccountNotice(''); setActiveSection(section.id); }} /> ))}
) : null} {activeSection === 'appearance' ? (
onPlatformThemeChange('light')} /> onPlatformThemeChange('dark')} />
当前主题
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
{themeStatusText}
) : null} {activeSection === 'account' ? (
{accountNotice ? ( {accountNotice} ) : null}
绑定手机号
{ changePhoneTriggerRef.current = event.currentTarget; setAccountNotice(''); resetChangePhoneDraft(); setIsChangePhonePanelOpen(true); }} > 更换手机号
{boundPhoneNumber}
绑定微信
{ setAccountNotice('更换微信号功能暂未接入。'); }} > 更换微信号
{boundWechatDisplayName}
登录密码
{ passwordTriggerRef.current = event.currentTarget; setAccountNotice(''); resetPasswordDraft(); setIsPasswordPanelOpen(true); }} > 修改密码
安全状态
{ void onRefreshRiskBlocks(); }} > 刷新
正在读取安全状态... } isEmpty={riskBlocks.length === 0} emptyState={ 当前没有生效中的安全限制。 } > {riskBlocks.map((block) => (
{block.title} 剩余约{' '} {Math.max( 1, Math.ceil(block.remainingSeconds / 60), )}{' '} 分钟
{block.detail}
{ void onLiftRiskBlock(block.scopeType); }} > 解除保护
))}
登录设备
{ void onRefreshSessions(); }} > 刷新
正在读取当前登录设备... } isEmpty={sessions.length === 0} emptyState={ 暂无可展示的登录设备。 } > {sessions.map((session) => { const isRevoking = revokingSessionIds.includes( session.sessionId, ); return (
{session.clientLabel}
{session.sessionCount > 1 ? ( {session.sessionCount} 个会话 ) : null} {session.isCurrent ? '当前设备' : '已登录'}
最近活跃:{formatSessionTime(session.lastSeenAt)}
到期时间:{formatSessionTime(session.expiresAt)}
{session.ipMasked ? (
IP:{session.ipMasked}
) : null} {!session.isCurrent ? ( { void onRevokeSession(session); }} > {isRevoking ? '处理中...' : '踢下线'} ) : null}
); })}
操作记录
{ void onRefreshAuditLogs(); }} > 刷新
正在读取账号操作记录... } isEmpty={auditLogs.length === 0} emptyState={ 暂无账号操作记录。 } > {auditLogs.map((log) => (
{log.title} {formatSessionTime(log.createdAt)}
{log.detail}
{log.ipMasked ? (
IP:{log.ipMasked}
) : null}
))}
{ void onLogout(); }} > 退出登录 { void onLogoutAll(); }} > 退出全部设备
{isChangePhonePanelOpen ? (
{changePhoneHint ? ( {changePhoneHint} ) : null} {changePhoneError ? ( {changePhoneError} ) : null} { void (async () => { setChangingPhone(true); setChangePhoneError(''); try { await onChangePhone(phone, code); setAccountNotice('手机号已更新。'); closeChangePhonePanel(); } catch (error) { setChangePhoneError( error instanceof Error ? error.message : '更换手机号失败,请稍后再试。', ); } finally { setChangingPhone(false); } })(); }} > {changingPhone ? '提交中...' : '确认更换手机号'}
) : null} {isPasswordPanelOpen ? (
{passwordError ? ( {passwordError} ) : null} { void (async () => { setChangingPassword(true); setPasswordError(''); try { await onChangePassword(currentPassword, newPassword); setAccountNotice('密码已更新。'); closePasswordPanel(); } catch (error) { setPasswordError( error instanceof Error ? error.message : '修改密码失败,请稍后再试。', ); } finally { setChangingPassword(false); } })(); }} > {changingPassword ? '提交中...' : '确认修改密码'}
) : null}
) : null}
); }