Files
Genarrative/src/components/auth/AccountModal.tsx
T
kdletters ed2c386603 继续收口账号与运行态弹窗壳层
账号设置弹窗复用认证壳层并保留 direct mode 唯一 dialog 语义

拼图运行态新增本地弹窗壳层收口确认设置退出失败和通关结算

抓大鹅与跳一跳结算弹窗提取本地结算结构并补测试

拼图 onboarding 登录保存覆盖层迁入 UnifiedModal

更新 PlatformUiKit 收口文档和 Hermes 决策记录
2026-06-11 21:53:10 +08:00

1227 lines
43 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 { 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 { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
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<void>;
onRefreshRiskBlocks: () => Promise<void>;
onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise<void>;
onRefreshSessions: () => Promise<void>;
onLogoutAll: () => Promise<void>;
onRefreshAuditLogs: () => Promise<void>;
onRevokeSession: (session: AuthSessionSummary) => Promise<void>;
revokingSessionIds: string[];
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
onSendChangePhoneCode: (
phone: string,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onChangePhone: (phone: string, code: string) => Promise<void>;
onChangePassword: (
currentPassword: string,
newPassword: 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 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 (
<PlatformSubpanel
as="button"
interactive
radius="xl"
padding="md"
onClick={(event) => onClick(event.currentTarget)}
className="w-full 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>
</PlatformSubpanel>
);
}
// 中文注释:账号安全子面板里的空态与轻量加载态共用同一层白底外壳,避免重复拼 flat subpanel 样式。
function AccountSubpanelState({ children }: { children: ReactNode }) {
return (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="py-3 text-center"
>
{children}
</PlatformEmptyState>
);
}
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 = (
<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 ? 'dialog' : undefined}
aria-modal={dialog ? true : undefined}
aria-label={dialog ? title : undefined}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
{eyebrow ? (
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
) : null}
<div
className={`${eyebrow ? 'mt-2 text-2xl' : 'text-xl sm: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 ? (
<PlatformActionButton
autoFocus
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 gap-1.5 px-3 py-1.5"
onClick={onBack}
>
<ArrowLeft className="h-3.5 w-3.5" />
返回
</PlatformActionButton>
) : (
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5"
onClick={onClose}
>
关闭
</PlatformActionButton>
)}
</div>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div>
);
if (standalone) {
return panel;
}
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}
>
{panel}
</div>
);
}
function ThemeOptionCard({
active,
title,
detail,
previewClassName,
onClick,
}: {
active: boolean;
title: string;
detail: string;
previewClassName: string;
onClick: () => void;
}) {
return (
<PlatformSubpanel
as="button"
interactive
radius="xl"
padding="md"
onClick={onClick}
className={`w-full ${
active
? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(112,57,30,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>
</PlatformSubpanel>
);
}
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<PrimarySettingsSection | null>(
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<HTMLDivElement | null>(null);
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
const passwordTriggerRef = useRef<HTMLButtonElement | null>(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<PrimarySettingsSection, string> = {
appearance:
platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
account:
user.phoneNumber || user.phoneNumberMasked || user.wechatBound
? '查看身份、安全状态、登录设备与操作记录。'
: '查看账号绑定状态与安全记录。',
};
return (
<PlatformAuthModalShell
title={isDirectAccountMode ? '账号信息' : '设置与账号安全'}
platformTheme={platformTheme}
onClose={onClose}
closeLabel="关闭账号弹窗"
size="xl"
showHeader={false}
overlaySpacing="none"
zIndexClassName="z-[70]"
overlayClassName="!items-end !justify-center overflow-hidden !px-4 !py-0 sm:!items-center"
overlayStyle={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
}}
authCardClassName={isDirectAccountMode ? '' : undefined}
panelClassName={
isDirectAccountMode
? 'relative !max-w-3xl !rounded-none !bg-transparent !shadow-none'
: 'relative !h-[min(100%,calc(100vh-2rem))] !max-w-5xl !rounded-[28px] !p-5 sm:!p-6'
}
bodyClassName="!flex !min-h-0 !flex-1 !overflow-hidden !p-0"
panelStyle={{
maxHeight: ACCOUNT_MODAL_MAX_HEIGHT,
}}
>
<div
className={
isDirectAccountMode
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
: 'relative flex h-full w-full min-h-0 flex-col overflow-hidden'
}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
{!isDirectAccountMode ? (
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
设置与账号安全
</div>
</div>
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5"
onClick={onClose}
>
关闭
</PlatformActionButton>
</div>
) : null}
{!isDirectAccountMode ? (
<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>
) : null}
{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.32),transparent_30%),linear-gradient(135deg,#fffdf9_0%,#f4e5d7_52%,#eaccb3_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>
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="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>
<PlatformPillBadge
tone="neutral"
size="xs"
className="px-3 py-1"
>
{themeStatusText}
</PlatformPillBadge>
</div>
</PlatformSubpanel>
</div>
</OverlayPanel>
) : null}
{activeSection === 'account' ? (
<OverlayPanel
title="账号信息"
standalone={isDirectAccountMode}
dialog={!isDirectAccountMode}
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
onClose={onClose}
>
<div data-account-content className="flex min-h-0 flex-col gap-3">
{accountNotice ? (
<PlatformStatusMessage tone="success" surface="profile">
{accountNotice}
</PlatformStatusMessage>
) : null}
<div className="grid gap-2.5 sm:grid-cols-2">
<PlatformSubpanel
as="div"
data-account-binding-card
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
绑定手机号
</div>
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
}}
>
更换手机号
</PlatformActionButton>
</div>
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundPhoneNumber}
</div>
</PlatformSubpanel>
<PlatformSubpanel
as="div"
data-account-binding-card
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
绑定微信
</div>
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
onClick={() => {
setAccountNotice('更换微信号功能暂未接入。');
}}
>
更换微信号
</PlatformActionButton>
</div>
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundWechatDisplayName}
</div>
</PlatformSubpanel>
</div>
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
登录密码
</div>
</div>
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
onClick={(event) => {
passwordTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetPasswordDraft();
setIsPasswordPanelOpen(true);
}}
>
修改密码
</PlatformActionButton>
</div>
</PlatformSubpanel>
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
安全状态
</div>
</div>
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
刷新
</PlatformActionButton>
</div>
<div className="mt-3 grid gap-2.5">
<PlatformAsyncStatePanel
isLoading={loadingRiskBlocks}
loadingState={
<AccountSubpanelState>
正在读取安全状态...
</AccountSubpanelState>
}
isEmpty={riskBlocks.length === 0}
emptyState={
<AccountSubpanelState>
当前没有生效中的安全限制。
</AccountSubpanelState>
}
>
{riskBlocks.map((block) => (
<PlatformStatusMessage
key={`${block.scopeType}:${block.expiresAt}`}
tone="warning"
surface="profile"
>
<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>
<PlatformActionButton
tone="secondary"
size="xs"
className="mt-3 h-9 min-h-0 px-3"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
解除保护
</PlatformActionButton>
</PlatformStatusMessage>
))}
</PlatformAsyncStatePanel>
</div>
</PlatformSubpanel>
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
登录设备
</div>
</div>
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshSessions();
}}
>
刷新
</PlatformActionButton>
</div>
<div className="mt-3 grid gap-2.5">
<PlatformAsyncStatePanel
isLoading={loadingSessions}
loadingState={
<AccountSubpanelState>
正在读取当前登录设备...
</AccountSubpanelState>
}
isEmpty={sessions.length === 0}
emptyState={
<AccountSubpanelState>
暂无可展示的登录设备。
</AccountSubpanelState>
}
>
{sessions.map((session) => {
const isRevoking = revokingSessionIds.includes(
session.sessionId,
);
return (
<PlatformSubpanel
as="div"
key={session.sessionId}
surface="flat"
radius="sm"
padding="none"
className="px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<div className="flex shrink-0 items-center gap-2">
{session.sessionCount > 1 ? (
<PlatformPillBadge
tone="neutral"
size="xs"
className="px-2.5 py-1 text-[10px]"
>
{session.sessionCount} 个会话
</PlatformPillBadge>
) : null}
<PlatformPillBadge
tone="success"
size="xs"
className="px-2.5 py-1 text-[10px]"
>
{session.isCurrent ? '当前设备' : '已登录'}
</PlatformPillBadge>
</div>
</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 ? (
<PlatformActionButton
tone="danger"
size="xs"
className="mt-3 h-9 min-h-0 px-3"
disabled={isRevoking}
onClick={() => {
void onRevokeSession(session);
}}
>
{isRevoking ? '处理中...' : '踢下线'}
</PlatformActionButton>
) : null}
</PlatformSubpanel>
);
})}
</PlatformAsyncStatePanel>
</div>
</PlatformSubpanel>
<PlatformSubpanel
as="div"
radius="sm"
padding="none"
className="px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
操作记录
</div>
</div>
<PlatformActionButton
tone="ghost"
size="xs"
shape="pill"
className="min-h-0 px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshAuditLogs();
}}
>
刷新
</PlatformActionButton>
</div>
<div className="mt-3 grid gap-2.5">
<PlatformAsyncStatePanel
isLoading={loadingAuditLogs}
loadingState={
<AccountSubpanelState>
正在读取账号操作记录...
</AccountSubpanelState>
}
isEmpty={auditLogs.length === 0}
emptyState={
<AccountSubpanelState>
暂无账号操作记录。
</AccountSubpanelState>
}
>
{auditLogs.map((log) => (
<PlatformSubpanel
as="div"
key={log.id}
surface="flat"
radius="sm"
padding="none"
className="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}
</PlatformSubpanel>
))}
</PlatformAsyncStatePanel>
</div>
</PlatformSubpanel>
<div
data-account-actions
className="grid gap-2.5 pt-1 sm:grid-cols-2"
>
<PlatformActionButton
tone="ghost"
size="sm"
fullWidth
className="h-10"
onClick={() => {
void onLogout();
}}
>
退出登录
</PlatformActionButton>
<PlatformActionButton
tone="danger"
size="sm"
fullWidth
className="h-10"
onClick={() => {
void onLogoutAll();
}}
>
退出全部设备
</PlatformActionButton>
</div>
</div>
{isChangePhonePanelOpen ? (
<OverlayPanel
eyebrow="手机号换绑"
title="绑定新手机号"
description="输入新手机号并完成验证码验证。"
onBack={closeChangePhonePanel}
onClose={onClose}
>
<div className="grid gap-3">
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
新手机号
</PlatformFieldLabel>
<PlatformTextField
className="h-11"
value={phone}
inputMode="numeric"
placeholder="13800000000"
onChange={(event) => setPhone(event.target.value)}
/>
</label>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
验证码
</PlatformFieldLabel>
<div className="flex gap-3">
<PlatformTextField
className="h-11 min-w-0 flex-1"
value={code}
inputMode="numeric"
placeholder="输入验证码"
onChange={(event) => setCode(event.target.value)}
/>
<PlatformActionButton
disabled={
sendingCode || cooldownSeconds > 0 || !phone.trim()
}
tone="secondary"
size="md"
className="h-11 shrink-0"
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`
: '获取验证码'}
</PlatformActionButton>
</div>
</label>
{changePhoneHint ? (
<PlatformStatusMessage tone="success" surface="profile">
{changePhoneHint}
</PlatformStatusMessage>
) : null}
<CaptchaChallengeField
challenge={changePhoneCaptchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
{changePhoneError ? (
<PlatformStatusMessage tone="error" surface="profile">
{changePhoneError}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
disabled={changingPhone || !phone.trim() || !code.trim()}
size="md"
className="h-11"
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 ? '提交中...' : '确认更换手机号'}
</PlatformActionButton>
</div>
</OverlayPanel>
) : null}
{isPasswordPanelOpen ? (
<OverlayPanel
eyebrow="账号安全"
title="修改登录密码"
description="输入当前密码与新密码。首次设置密码时当前密码可留空。"
onBack={closePasswordPanel}
onClose={onClose}
>
<div className="grid gap-3">
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
当前密码
</PlatformFieldLabel>
<PlatformTextField
className="h-11"
value={currentPassword}
type="password"
autoComplete="current-password"
placeholder="首次设置可留空"
onChange={(event) =>
setCurrentPassword(event.target.value)
}
/>
</label>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
新密码
</PlatformFieldLabel>
<PlatformTextField
className="h-11"
value={newPassword}
type="password"
autoComplete="new-password"
placeholder="设置新密码"
onChange={(event) => setNewPassword(event.target.value)}
/>
</label>
{passwordError ? (
<PlatformStatusMessage tone="error" surface="profile">
{passwordError}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
disabled={changingPassword || !newPassword.trim()}
size="md"
fullWidth
className="h-11"
onClick={() => {
void (async () => {
setChangingPassword(true);
setPasswordError('');
try {
await onChangePassword(currentPassword, newPassword);
setAccountNotice('密码已更新。');
closePasswordPanel();
} catch (error) {
setPasswordError(
error instanceof Error
? error.message
: '修改密码失败,请稍后再试。',
);
} finally {
setChangingPassword(false);
}
})();
}}
>
{changingPassword ? '提交中...' : '确认修改密码'}
</PlatformActionButton>
</div>
</OverlayPanel>
) : null}
</OverlayPanel>
) : null}
</div>
</PlatformAuthModalShell>
);
}