902 lines
33 KiB
TypeScript
902 lines
33 KiB
TypeScript
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';
|
||
|
||
type AccountModalProps = {
|
||
user: AuthUser;
|
||
isOpen: boolean;
|
||
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: (sessionId: string) => Promise<void>;
|
||
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
|
||
onSendChangePhoneCode: (
|
||
phone: string,
|
||
captcha?: {
|
||
challengeId?: string;
|
||
answer?: string;
|
||
},
|
||
) => Promise<{
|
||
cooldownSeconds: number;
|
||
expiresInSeconds: number;
|
||
}>;
|
||
onChangePhone: (phone: string, code: 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 resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
|
||
switch (loginMethod) {
|
||
case 'wechat':
|
||
return '微信登录';
|
||
case 'phone':
|
||
return '手机号登录';
|
||
default:
|
||
return '账号登录';
|
||
}
|
||
}
|
||
|
||
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 SettingsEntryCard({
|
||
label,
|
||
detail,
|
||
summary,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
detail: string;
|
||
summary: string;
|
||
onClick: (trigger: HTMLButtonElement) => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
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">
|
||
<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>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function OverlayPanel({
|
||
eyebrow,
|
||
title,
|
||
description,
|
||
action,
|
||
onBack,
|
||
onClose,
|
||
children,
|
||
}: {
|
||
eyebrow: string;
|
||
title: string;
|
||
description?: string;
|
||
action?: ReactNode;
|
||
onBack?: () => void;
|
||
onClose: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
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}
|
||
>
|
||
<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"
|
||
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">
|
||
<div className="min-w-0">
|
||
<div className="text-xs uppercase tracking-[0.28em] 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>
|
||
<div className="flex items-center gap-2">
|
||
{action}
|
||
{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}
|
||
>
|
||
返回
|
||
</button>
|
||
) : (
|
||
<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 overscroll-y-contain pr-1">
|
||
{children}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ThemeOptionCard({
|
||
active,
|
||
title,
|
||
detail,
|
||
previewClassName,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
title: string;
|
||
detail: string;
|
||
previewClassName: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`platform-subpanel w-full rounded-[1.5rem] p-4 text-left transition ${
|
||
active
|
||
? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(255,91,132,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>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function AccountModal({
|
||
user,
|
||
isOpen,
|
||
initialSection = null,
|
||
platformTheme,
|
||
riskBlocks,
|
||
sessions,
|
||
auditLogs,
|
||
loadingRiskBlocks,
|
||
loadingSessions,
|
||
loadingAuditLogs,
|
||
isHydratingSettings,
|
||
isPersistingSettings,
|
||
settingsError,
|
||
onClose,
|
||
onPlatformThemeChange,
|
||
onLogout,
|
||
onRefreshRiskBlocks,
|
||
onLiftRiskBlock,
|
||
onRefreshSessions,
|
||
onLogoutAll,
|
||
onRefreshAuditLogs,
|
||
onRevokeSession,
|
||
changePhoneCaptchaChallenge,
|
||
onSendChangePhoneCode,
|
||
onChangePhone,
|
||
}: AccountModalProps) {
|
||
const [activeSection, setActiveSection] =
|
||
useState<PrimarySettingsSection | null>(
|
||
normalizeSettingsSection(initialSection),
|
||
);
|
||
const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false);
|
||
const [phone, setPhone] = useState('');
|
||
const [code, setCode] = useState('');
|
||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||
const [changePhoneError, setChangePhoneError] = useState('');
|
||
const [changePhoneHint, setChangePhoneHint] = useState('');
|
||
const [accountNotice, setAccountNotice] = useState('');
|
||
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('');
|
||
setCode('');
|
||
setCaptchaAnswer('');
|
||
setChangePhoneError('');
|
||
setChangePhoneHint('');
|
||
setCooldownSeconds(0);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
return;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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);
|
||
setActiveSection(null);
|
||
resetChangePhoneDraft();
|
||
focusAfterNextPaint(sectionTrigger);
|
||
}, [focusAfterNextPaint, resetChangePhoneDraft]);
|
||
|
||
const closeChangePhonePanel = useCallback(() => {
|
||
const changePhoneTrigger = changePhoneTriggerRef.current;
|
||
setIsChangePhonePanelOpen(false);
|
||
resetChangePhoneDraft();
|
||
focusAfterNextPaint(changePhoneTrigger);
|
||
}, [focusAfterNextPaint, resetChangePhoneDraft]);
|
||
|
||
if (!isOpen) {
|
||
return null;
|
||
}
|
||
|
||
const themeStatusText = settingsError
|
||
? settingsError
|
||
: isHydratingSettings
|
||
? '正在读取平台设置...'
|
||
: isPersistingSettings
|
||
? '正在同步平台设置...'
|
||
: '平台设置已同步';
|
||
|
||
const accountSummaryCards = [
|
||
['登录方式', resolveLoginMethodLabel(user.loginMethod)],
|
||
['手机号', user.phoneNumberMasked || '未绑定'],
|
||
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
|
||
] as const;
|
||
|
||
const sectionSummaries: Record<PrimarySettingsSection, string> = {
|
||
appearance:
|
||
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-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)',
|
||
}}
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
className="platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 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-2xl font-semibold text-[var(--platform-text-strong)]">
|
||
设置与账号安全
|
||
</div>
|
||
</div>
|
||
<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 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>
|
||
|
||
{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.28),transparent_30%),linear-gradient(135deg,#fff8fb_0%,#ffe9ee_52%,#ffd8cb_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>
|
||
|
||
<div className="platform-subpanel rounded-2xl 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>
|
||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]">
|
||
{themeStatusText}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</OverlayPanel>
|
||
) : null}
|
||
|
||
{activeSection === 'account' ? (
|
||
<OverlayPanel
|
||
eyebrow="身份信息"
|
||
title="账号信息"
|
||
description="统一查看身份、安全状态、登录设备与最近操作。"
|
||
onBack={closeSectionPanel}
|
||
onClose={onClose}
|
||
>
|
||
<div className="flex min-h-0 flex-col gap-4">
|
||
{accountNotice ? (
|
||
<div className="platform-banner platform-banner--success text-sm">
|
||
{accountNotice}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{accountSummaryCards.map(([label, value]) => (
|
||
<div
|
||
key={label}
|
||
className="platform-subpanel rounded-2xl px-4 py-3"
|
||
>
|
||
<div className="text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
{label}
|
||
</div>
|
||
<div className="mt-2 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{value}
|
||
</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();
|
||
}}
|
||
>
|
||
退出登录
|
||
</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="flex items-center 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={(event) => {
|
||
changePhoneTriggerRef.current = event.currentTarget;
|
||
setAccountNotice('');
|
||
resetChangePhoneDraft();
|
||
setIsChangePhonePanelOpen(true);
|
||
}}
|
||
>
|
||
更换手机号
|
||
</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="输入新手机号并完成验证码验证。"
|
||
onBack={closeChangePhonePanel}
|
||
onClose={onClose}
|
||
>
|
||
<div className="grid gap-3">
|
||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||
<span>新手机号</span>
|
||
<input
|
||
className="platform-input h-11"
|
||
value={phone}
|
||
inputMode="numeric"
|
||
placeholder="13800000000"
|
||
onChange={(event) => setPhone(event.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||
<span>验证码</span>
|
||
<div className="flex gap-3">
|
||
<input
|
||
className="platform-input h-11 min-w-0 flex-1"
|
||
value={code}
|
||
inputMode="numeric"
|
||
placeholder="输入验证码"
|
||
onChange={(event) => setCode(event.target.value)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
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,
|
||
},
|
||
);
|
||
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`
|
||
: '获取验证码'}
|
||
</button>
|
||
</div>
|
||
</label>
|
||
|
||
{changePhoneHint ? (
|
||
<div className="platform-banner platform-banner--success text-sm">
|
||
{changePhoneHint}
|
||
</div>
|
||
) : null}
|
||
|
||
<CaptchaChallengeField
|
||
challenge={changePhoneCaptchaChallenge}
|
||
answer={captchaAnswer}
|
||
onAnswerChange={setCaptchaAnswer}
|
||
/>
|
||
|
||
{changePhoneError ? (
|
||
<div className="platform-banner platform-banner--danger text-sm">
|
||
{changePhoneError}
|
||
</div>
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
disabled={changingPhone || !phone.trim() || !code.trim()}
|
||
className="platform-button platform-button--primary h-11 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||
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 ? '提交中...' : '确认更换手机号'}
|
||
</button>
|
||
</div>
|
||
</OverlayPanel>
|
||
) : null}
|
||
</OverlayPanel>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|