1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 11:30:19 +08:00
parent 50759f3c1e
commit 8a7bd90458
85 changed files with 7290 additions and 1903 deletions

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
@@ -27,7 +27,13 @@ function renderAccountModal(overrides?: {
riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[];
initialSection?: 'appearance' | 'account' | 'security' | 'devices' | 'logs' | null;
initialSection?:
| 'appearance'
| 'account'
| 'security'
| 'devices'
| 'logs'
| null;
}) {
return render(
<AccountModal
@@ -69,6 +75,14 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
expect(screen.getByText('设置与账号安全')).toBeTruthy();
expect(screen.queryByText('138****8000')).toBeNull();
expect(screen.queryByText('选择要管理的内容')).toBeNull();
expect(
screen.queryByText('主题、账号与设备能力统一在独立面板中管理'),
).toBeNull();
expect(screen.queryByText(/^安全状态$/)).toBeNull();
expect(screen.queryByText(/^登录设备$/)).toBeNull();
expect(screen.queryByText(/^操作记录$/)).toBeNull();
expect(screen.queryByText('当前账号状态')).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
@@ -80,13 +94,154 @@ test('account actions open in independent panels instead of inline expansion', a
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(within(accountDialog).getByRole('button', { name: '返回' })).toBeTruthy();
expect(within(accountDialog).getByRole('button', { name: '更换手机号' })).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
).toBeTruthy();
expect(screen.queryByLabelText('新手机号')).toBeNull();
await user.click(within(accountDialog).getByRole('button', { name: '更换手机号' }));
await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', { name: '绑定新手机号' });
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
});
test('nested settings panels keep back navigation without an extra close action', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(accountDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
expect(
within(changePhoneDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
});
test('settings overlays move focus away from inert triggers and restore it on back', async () => {
const user = userEvent.setup();
renderAccountModal();
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
expect(document.activeElement).not.toBe(accountTrigger);
await user.click(accountTrigger);
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountBackButton = within(accountDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(accountBackButton);
});
const changePhoneTrigger = within(accountDialog).getByRole('button', {
name: '更换手机号',
});
await user.click(changePhoneTrigger);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
const changePhoneBackButton = within(changePhoneDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneBackButton);
});
await user.click(changePhoneBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneTrigger);
});
await user.click(accountBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(accountTrigger);
});
});
test('account panel includes merged security devices and audit sections', async () => {
const user = userEvent.setup();
renderAccountModal({
riskBlocks: [
{
scopeType: 'phone',
title: '手机号保护',
detail: '检测到异常验证行为,已开启保护。',
remainingSeconds: 600,
expiresAt: '2026-04-20T10:00:00.000Z',
},
],
sessions: [
{
sessionId: 'session-1',
clientLabel: 'iPhone 15 Pro',
isCurrent: true,
lastSeenAt: '2026-04-20T09:00:00.000Z',
expiresAt: '2026-04-27T09:00:00.000Z',
ipMasked: '10.0.*.*',
},
],
auditLogs: [
{
id: 'log-1',
title: '登录成功',
detail: '通过手机号验证码完成登录。',
createdAt: '2026-04-20T08:00:00.000Z',
ipMasked: '10.0.*.*',
},
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
});
test('legacy nested section requests now open the merged account panel', () => {
renderAccountModal({ initialSection: 'security' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
});

View File

@@ -1,8 +1,12 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type {
PlatformTheme,
} from '../../../packages/shared/src/contracts/runtime';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type {
AuthAuditLogEntry,
AuthCaptchaChallenge,
@@ -51,17 +55,38 @@ type AccountModalProps = {
};
const SETTINGS_SECTIONS: Array<{
id: PlatformSettingsSection;
id: 'appearance' | 'account';
label: string;
detail: string;
}> = [
{ id: 'appearance', label: '主题外观', detail: '亮暗主题' },
{ id: 'account', label: '账号信息', detail: '身份与换绑' },
{ id: 'security', label: '安全状态', detail: '保护与限制' },
{ id: 'devices', label: '登录设备', detail: '会话管理' },
{ id: 'logs', 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':
@@ -88,37 +113,6 @@ function formatSessionTime(value: string) {
});
}
function SectionHeader({
eyebrow,
title,
description,
action,
}: {
eyebrow: string;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.24em] 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>
{action}
</div>
);
}
function SettingsEntryCard({
label,
detail,
@@ -128,12 +122,12 @@ function SettingsEntryCard({
label: string;
detail: string;
summary: string;
onClick: () => void;
onClick: (trigger: HTMLButtonElement) => void;
}) {
return (
<button
type="button"
onClick={onClick}
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">
@@ -179,10 +173,11 @@ function OverlayPanel({
onClick={onBack ?? onClose}
>
<div
className="platform-auth-card flex max-h-full w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
className="platform-auth-card flex w-full 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">
@@ -191,6 +186,7 @@ function OverlayPanel({
{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}
>
@@ -212,17 +208,21 @@ function OverlayPanel({
</div>
<div className="flex items-center gap-2">
{action}
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
{onBack ? null : (
<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 pr-1">{children}</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div>
</div>
);
@@ -290,7 +290,9 @@ export function AccountModal({
onChangePhone,
}: AccountModalProps) {
const [activeSection, setActiveSection] =
useState<PlatformSettingsSection | null>(initialSection);
useState<PrimarySettingsSection | null>(
normalizeSettingsSection(initialSection),
);
const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
@@ -301,6 +303,21 @@ export function AccountModal({
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('');
@@ -316,12 +333,27 @@ export function AccountModal({
return;
}
setActiveSection(initialSection);
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;
@@ -337,15 +369,19 @@ export function AccountModal({
}, [cooldownSeconds]);
const closeSectionPanel = useCallback(() => {
const sectionTrigger = sectionTriggerRef.current;
setIsChangePhonePanelOpen(false);
setActiveSection(null);
resetChangePhoneDraft();
}, [resetChangePhoneDraft]);
focusAfterNextPaint(sectionTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
const closeChangePhonePanel = useCallback(() => {
const changePhoneTrigger = changePhoneTriggerRef.current;
setIsChangePhonePanelOpen(false);
resetChangePhoneDraft();
}, [resetChangePhoneDraft]);
focusAfterNextPaint(changePhoneTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
if (!isOpen) {
return null;
@@ -358,46 +394,25 @@ export function AccountModal({
: isPersistingSettings
? '正在同步平台设置...'
: '平台设置已同步';
const latestAuditLog = auditLogs[0];
const accountSummaryCards = [
['登录方式', resolveLoginMethodLabel(user.loginMethod)],
['手机号', user.phoneNumberMasked || '未绑定'],
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
[
'账号状态',
user.bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '已激活',
],
] as const;
const sectionSummaries: Record<PlatformSettingsSection, string> = {
const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance:
platformTheme === 'dark'
? '当前使用暗色主题。'
: '当前使用亮色主题。',
account: user.phoneNumberMasked
? '查看账号身份与换绑入口。'
: '查看账号身份与绑定状态。',
security: loadingRiskBlocks
? '正在读取安全状态。'
: riskBlocks.length > 0
? `当前有 ${riskBlocks.length} 项保护生效。`
: '当前没有生效中的安全限制。',
devices: loadingSessions
? '正在读取设备会话。'
: sessions.length > 0
? `当前共有 ${sessions.length} 台设备会话。`
: '暂无可展示的登录设备。',
logs: loadingAuditLogs
? '正在读取账号动态。'
: latestAuditLog
? `最近一条记录:${formatSessionTime(latestAuditLog.createdAt)}`
: '暂无账号操作记录。',
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-y-auto px-4 sm:items-center`}
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)',
@@ -405,23 +420,18 @@ export function AccountModal({
onClick={onClose}
>
<div
className="platform-auth-card relative flex max-h-full w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
className="platform-auth-card relative flex w-full max-w-5xl 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-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -432,81 +442,55 @@ export function AccountModal({
</button>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-hidden">
<div
className="min-h-0 h-full overflow-y-auto pr-1"
aria-hidden={activeSection !== null}
>
<div className="space-y-4">
<SectionHeader
eyebrow="设置首页"
title="选择要管理的内容"
description="每项内容都会进入独立面板,不在当前层级堆叠详情。"
/>
<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={() => {
setAccountNotice('');
setActiveSection(section.id);
}}
/>
))}
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div className="platform-subpanel rounded-2xl px-4 py-4">
<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>
<span className="platform-pill platform-pill--neutral mt-3 inline-flex px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{user.bindingStatus === 'pending_bind_phone'
? '待绑定手机号'
: '账号已激活'}
</div>
<div className="mt-3 text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
{resolveLoginMethodLabel(user.loginMethod)}
</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();
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="space-y-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);
}}
>
退
</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="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
<span className="platform-pill platform-pill--neutral mt-3 inline-flex px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</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>
</div>
@@ -515,7 +499,7 @@ export function AccountModal({
<OverlayPanel
eyebrow="平台偏好"
title="主题外观"
description="切换平台层的亮色暗色展示。"
description="切换平台亮色暗色主题。"
onBack={closeSectionPanel}
onClose={onClose}
>
@@ -560,7 +544,7 @@ export function AccountModal({
<OverlayPanel
eyebrow="身份信息"
title="账号信息"
description="查看当前登录身份,并通过独立面板处理手机号换绑。"
description="统一查看身份、安全状态、登录设备与最近操作。"
onBack={closeSectionPanel}
onClose={onClose}
>
@@ -600,7 +584,8 @@ export function AccountModal({
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
@@ -610,13 +595,201 @@ export function AccountModal({
</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="验证码与校验流程继续由后端决定,前端只负责收集输入与展示结果。"
description="输入新手机号并完成验证码验证。"
onBack={closeChangePhonePanel}
onClose={onClose}
>
@@ -644,18 +817,23 @@ export function AccountModal({
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
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,
});
const result = await onSendChangePhoneCode(
phone,
{
challengeId:
changePhoneCaptchaChallenge?.challengeId,
answer: captchaAnswer,
},
);
setCooldownSeconds(result.cooldownSeconds);
setChangePhoneHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
@@ -732,200 +910,6 @@ export function AccountModal({
) : null}
</OverlayPanel>
) : null}
{activeSection === 'security' ? (
<OverlayPanel
eyebrow="安全状态"
title="保护与限制"
description="查看当前生效中的账号保护状态。"
action={(
<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>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="grid gap-3">
{loadingRiskBlocks ? (
<div className="platform-subpanel rounded-2xl 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="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</OverlayPanel>
) : null}
{activeSection === 'devices' ? (
<OverlayPanel
eyebrow="会话管理"
title="登录设备"
description="查看当前账号的设备会话状态。"
action={(
<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>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="space-y-4">
<div className="grid gap-3">
{loadingSessions ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="platform-subpanel rounded-2xl 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="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</OverlayPanel>
) : null}
{activeSection === 'logs' ? (
<OverlayPanel
eyebrow="账号动态"
title="最近操作"
description="查看最近的账号登录与安全动作。"
action={(
<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>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="grid gap-3">
{loadingAuditLogs ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="platform-subpanel rounded-2xl 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="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</OverlayPanel>
) : null}
</div>
</div>
);