Files
Genarrative/src/components/auth/AccountModal.tsx
T
kdletters c5200c7d45
Project CI / Backend tests (push) Successful in 8m31s
Project CI / Repository checks (push) Successful in 14m14s
Project CI / Frontend tests (push) Successful in 13m2s
Project CI / Native shell tests (push) Successful in 21m30s
补齐共享 UI 组件库与展示页交互 (#202)
## 变更内容

- 新增基于 shadcn open-code 模式的共享 UI canonical 组件、样式与导出。
- 新增 `/components` 共享组件展示页,并覆盖平台组件、Token、状态和移动端布局。
- 补齐平台展示页的筛选、排序、上传预览、标签、开关和异步状态交互。
- 修复排序导致预览主题变化、筛选弹窗误关闭和入口状态不更新的问题。
- 同步网站与客户端构建别名、依赖、路由测试和项目文档。

## 验证

- `npm run test -- src/components/shared-components/SharedComponentsShowcasePage.test.tsx`
- `npm run typecheck`
- `npm run check:encoding`
- `npm run build:raw`
- `git diff --check`
- pre-push 门禁通过

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/202
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-08-31 14:10:44 +08:00

1237 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 {
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<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 !overflow-visible !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 !p-0 ${
isDirectAccountMode ? '!overflow-visible' : '!overflow-hidden'
}`}
panelStyle={{
maxHeight: ACCOUNT_MODAL_MAX_HEIGHT,
...(isDirectAccountMode
? {
background: 'none',
borderWidth: 0,
}
: {}),
}}
>
<div
className={
isDirectAccountMode
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-visible'
: '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>
);
}