@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user