c5200c7d45
## 变更内容 - 新增基于 shadcn open-code 模式的共享 UI canonical 组件、样式与导出。 - 新增 `/components` 共享组件展示页,并覆盖平台组件、Token、状态和移动端布局。 - 补齐平台展示页的筛选、排序、上传预览、标签、开关和异步状态交互。 - 修复排序导致预览主题变化、筛选弹窗误关闭和入口状态不更新的问题。 - 同步网站与客户端构建别名、依赖、路由测试和项目文档。 ## 验证 - `npm run test -- src/components/shared-components/SharedComponentsShowcasePage.test.tsx` - `npm run typecheck` - `npm run check:encoding` - `npm run build:raw` - `git diff --check` - pre-push 门禁通过 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/202 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
1237 lines
43 KiB
TypeScript
1237 lines
43 KiB
TypeScript
import {
|
||
PlatformActionButton,
|
||
PlatformAsyncStatePanel,
|
||
PlatformEmptyState,
|
||
PlatformFieldLabel,
|
||
PlatformPillBadge,
|
||
PlatformStatusMessage,
|
||
PlatformSubpanel,
|
||
PlatformTextField,
|
||
} from '@genarrative/shared/components';
|
||
import { ArrowLeft } from 'lucide-react';
|
||
import {
|
||
type ReactNode,
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||
import type {
|
||
AuthAuditLogEntry,
|
||
AuthCaptchaChallenge,
|
||
AuthRiskBlockSummary,
|
||
AuthSessionSummary,
|
||
AuthUser,
|
||
} from '../../services/authService';
|
||
import type { PlatformSettingsSection } from './AuthUiContext';
|
||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
|
||
|
||
type AccountModalProps = {
|
||
user: AuthUser;
|
||
isOpen: boolean;
|
||
entryMode?: 'settings' | 'account';
|
||
initialSection?: PlatformSettingsSection | null;
|
||
platformTheme: PlatformTheme;
|
||
riskBlocks: AuthRiskBlockSummary[];
|
||
sessions: AuthSessionSummary[];
|
||
auditLogs: AuthAuditLogEntry[];
|
||
loadingRiskBlocks: boolean;
|
||
loadingSessions: boolean;
|
||
loadingAuditLogs: boolean;
|
||
isHydratingSettings: boolean;
|
||
isPersistingSettings: boolean;
|
||
settingsError: string | null;
|
||
onClose: () => void;
|
||
onPlatformThemeChange: (theme: PlatformTheme) => void;
|
||
onLogout: () => Promise<void>;
|
||
onRefreshRiskBlocks: () => Promise<void>;
|
||
onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise<void>;
|
||
onRefreshSessions: () => Promise<void>;
|
||
onLogoutAll: () => Promise<void>;
|
||
onRefreshAuditLogs: () => Promise<void>;
|
||
onRevokeSession: (session: AuthSessionSummary) => Promise<void>;
|
||
revokingSessionIds: string[];
|
||
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
|
||
onSendChangePhoneCode: (
|
||
phone: string,
|
||
captcha?: {
|
||
challengeId?: string;
|
||
answer?: string;
|
||
},
|
||
) => Promise<{
|
||
cooldownSeconds: number;
|
||
expiresInSeconds: number;
|
||
}>;
|
||
onChangePhone: (phone: string, code: string) => Promise<void>;
|
||
onChangePassword: (
|
||
currentPassword: string,
|
||
newPassword: string,
|
||
) => Promise<void>;
|
||
};
|
||
|
||
const SETTINGS_SECTIONS: Array<{
|
||
id: 'appearance' | 'account';
|
||
label: string;
|
||
detail: string;
|
||
}> = [
|
||
{ id: 'appearance', label: '主题设置', detail: '亮暗主题' },
|
||
{ id: 'account', label: '账号与安全', detail: '身份与设备' },
|
||
];
|
||
|
||
const ACCOUNT_MODAL_MAX_HEIGHT =
|
||
'calc(100vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 2rem)';
|
||
|
||
type PrimarySettingsSection = (typeof SETTINGS_SECTIONS)[number]['id'];
|
||
|
||
function normalizeSettingsSection(
|
||
section: PlatformSettingsSection | null | undefined,
|
||
): PrimarySettingsSection | null {
|
||
if (section === 'appearance') {
|
||
return 'appearance';
|
||
}
|
||
|
||
if (
|
||
section === 'account' ||
|
||
section === 'security' ||
|
||
section === 'devices' ||
|
||
section === 'logs'
|
||
) {
|
||
return 'account';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function formatSessionTime(value: string) {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return value;
|
||
}
|
||
|
||
return date.toLocaleString('zh-CN', {
|
||
hour12: false,
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function formatBoundWechatAccount(value: string | null | undefined) {
|
||
const normalized = value?.trim();
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
|
||
return `微信账号尾号 ${normalized.slice(-6)}`;
|
||
}
|
||
|
||
function SettingsEntryCard({
|
||
label,
|
||
detail,
|
||
summary,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
detail: string;
|
||
summary: string;
|
||
onClick: (trigger: HTMLButtonElement) => void;
|
||
}) {
|
||
return (
|
||
<PlatformSubpanel
|
||
as="button"
|
||
interactive
|
||
radius="xl"
|
||
padding="md"
|
||
onClick={(event) => onClick(event.currentTarget)}
|
||
className="w-full hover:border-[var(--platform-surface-hover-border)]"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{label}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-[var(--platform-text-soft)]">
|
||
{detail}
|
||
</div>
|
||
</div>
|
||
<span className="text-lg leading-none text-[var(--platform-text-soft)]">
|
||
›
|
||
</span>
|
||
</div>
|
||
<div className="mt-3 text-sm text-[var(--platform-text-base)]">
|
||
{summary}
|
||
</div>
|
||
</PlatformSubpanel>
|
||
);
|
||
}
|
||
|
||
// 中文注释:账号安全子面板里的空态与轻量加载态共用同一层白底外壳,避免重复拼 flat subpanel 样式。
|
||
function AccountSubpanelState({ children }: { children: ReactNode }) {
|
||
return (
|
||
<PlatformEmptyState
|
||
surface="subpanel"
|
||
size="compact"
|
||
className="py-3 text-center"
|
||
>
|
||
{children}
|
||
</PlatformEmptyState>
|
||
);
|
||
}
|
||
|
||
function OverlayPanel({
|
||
eyebrow,
|
||
title,
|
||
description,
|
||
action,
|
||
standalone = false,
|
||
dialog = true,
|
||
onBack,
|
||
onClose,
|
||
children,
|
||
}: {
|
||
eyebrow?: string;
|
||
title: string;
|
||
description?: string;
|
||
action?: ReactNode;
|
||
standalone?: boolean;
|
||
dialog?: boolean;
|
||
onBack?: () => void;
|
||
onClose: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
const panel = (
|
||
<div
|
||
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
|
||
role={dialog ? 'dialog' : undefined}
|
||
aria-modal={dialog ? true : undefined}
|
||
aria-label={dialog ? title : undefined}
|
||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
{eyebrow ? (
|
||
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
|
||
{eyebrow}
|
||
</div>
|
||
) : null}
|
||
<div
|
||
className={`${eyebrow ? 'mt-2 text-2xl' : 'text-xl sm:text-2xl'} font-semibold text-[var(--platform-text-strong)]`}
|
||
>
|
||
{title}
|
||
</div>
|
||
{description ? (
|
||
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
|
||
{description}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{action}
|
||
{onBack ? (
|
||
<PlatformActionButton
|
||
autoFocus
|
||
tone="ghost"
|
||
size="xs"
|
||
shape="pill"
|
||
className="min-h-0 gap-1.5 px-3 py-1.5"
|
||
onClick={onBack}
|
||
>
|
||
<ArrowLeft className="h-3.5 w-3.5" />
|
||
返回
|
||
</PlatformActionButton>
|
||
) : (
|
||
<PlatformActionButton
|
||
tone="ghost"
|
||
size="xs"
|
||
shape="pill"
|
||
className="min-h-0 px-3 py-1.5"
|
||
onClick={onClose}
|
||
>
|
||
关闭
|
||
</PlatformActionButton>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
if (standalone) {
|
||
return panel;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="absolute inset-0 z-10 flex items-end bg-black/20 backdrop-blur-[2px] sm:items-center sm:justify-center sm:p-4"
|
||
onClick={onBack ?? onClose}
|
||
>
|
||
{panel}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ThemeOptionCard({
|
||
active,
|
||
title,
|
||
detail,
|
||
previewClassName,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
title: string;
|
||
detail: string;
|
||
previewClassName: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<PlatformSubpanel
|
||
as="button"
|
||
interactive
|
||
radius="xl"
|
||
padding="md"
|
||
onClick={onClick}
|
||
className={`w-full ${
|
||
active
|
||
? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(112,57,30,0.14)]'
|
||
: 'hover:border-[var(--platform-surface-hover-border)]'
|
||
}`}
|
||
>
|
||
<div className={`h-28 rounded-[1.15rem] ${previewClassName}`} />
|
||
<div className="mt-4 text-base font-semibold text-[var(--platform-text-strong)]">
|
||
{title}
|
||
</div>
|
||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||
{detail}
|
||
</div>
|
||
</PlatformSubpanel>
|
||
);
|
||
}
|
||
|
||
export function AccountModal({
|
||
user,
|
||
isOpen,
|
||
entryMode = 'settings',
|
||
initialSection = null,
|
||
platformTheme,
|
||
riskBlocks,
|
||
sessions,
|
||
auditLogs,
|
||
loadingRiskBlocks,
|
||
loadingSessions,
|
||
loadingAuditLogs,
|
||
isHydratingSettings,
|
||
isPersistingSettings,
|
||
settingsError,
|
||
onClose,
|
||
onPlatformThemeChange,
|
||
onLogout,
|
||
onRefreshRiskBlocks,
|
||
onLiftRiskBlock,
|
||
onRefreshSessions,
|
||
onLogoutAll,
|
||
onRefreshAuditLogs,
|
||
onRevokeSession,
|
||
revokingSessionIds,
|
||
changePhoneCaptchaChallenge,
|
||
onSendChangePhoneCode,
|
||
onChangePhone,
|
||
onChangePassword,
|
||
}: AccountModalProps) {
|
||
const [activeSection, setActiveSection] =
|
||
useState<PrimarySettingsSection | null>(
|
||
normalizeSettingsSection(initialSection),
|
||
);
|
||
const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false);
|
||
const [isPasswordPanelOpen, setIsPasswordPanelOpen] = useState(false);
|
||
const [phone, setPhone] = useState('');
|
||
const [code, setCode] = useState('');
|
||
const [currentPassword, setCurrentPassword] = useState('');
|
||
const [newPassword, setNewPassword] = useState('');
|
||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||
const [changePhoneError, setChangePhoneError] = useState('');
|
||
const [passwordError, setPasswordError] = useState('');
|
||
const [changePhoneHint, setChangePhoneHint] = useState('');
|
||
const [accountNotice, setAccountNotice] = useState('');
|
||
const [sendingCode, setSendingCode] = useState(false);
|
||
const [changingPhone, setChangingPhone] = useState(false);
|
||
const [changingPassword, setChangingPassword] = useState(false);
|
||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||
const settingsHomeRef = useRef<HTMLDivElement | null>(null);
|
||
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||
const passwordTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||
const isDirectAccountMode = entryMode === 'account';
|
||
|
||
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
|
||
if (!element) {
|
||
return;
|
||
}
|
||
|
||
window.requestAnimationFrame(() => {
|
||
if (element.isConnected) {
|
||
element.focus();
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
const resetChangePhoneDraft = useCallback(() => {
|
||
setPhone('');
|
||
setCode('');
|
||
setCaptchaAnswer('');
|
||
setChangePhoneError('');
|
||
setChangePhoneHint('');
|
||
setCooldownSeconds(0);
|
||
}, []);
|
||
|
||
const resetPasswordDraft = useCallback(() => {
|
||
setCurrentPassword('');
|
||
setNewPassword('');
|
||
setPasswordError('');
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
return;
|
||
}
|
||
|
||
setActiveSection(
|
||
isDirectAccountMode
|
||
? 'account'
|
||
: normalizeSettingsSection(initialSection),
|
||
);
|
||
setIsChangePhonePanelOpen(false);
|
||
setIsPasswordPanelOpen(false);
|
||
setAccountNotice('');
|
||
sectionTriggerRef.current = null;
|
||
changePhoneTriggerRef.current = null;
|
||
passwordTriggerRef.current = null;
|
||
resetChangePhoneDraft();
|
||
resetPasswordDraft();
|
||
}, [
|
||
initialSection,
|
||
isDirectAccountMode,
|
||
isOpen,
|
||
resetChangePhoneDraft,
|
||
resetPasswordDraft,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
const settingsHome = settingsHomeRef.current;
|
||
if (!settingsHome) {
|
||
return;
|
||
}
|
||
|
||
settingsHome.toggleAttribute('inert', activeSection !== null);
|
||
|
||
return () => {
|
||
settingsHome.removeAttribute('inert');
|
||
};
|
||
}, [activeSection]);
|
||
|
||
useEffect(() => {
|
||
if (cooldownSeconds <= 0) {
|
||
return;
|
||
}
|
||
|
||
const timeoutId = window.setTimeout(() => {
|
||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||
}, 1000);
|
||
|
||
return () => {
|
||
window.clearTimeout(timeoutId);
|
||
};
|
||
}, [cooldownSeconds]);
|
||
|
||
const closeSectionPanel = useCallback(() => {
|
||
const sectionTrigger = sectionTriggerRef.current;
|
||
setIsChangePhonePanelOpen(false);
|
||
setIsPasswordPanelOpen(false);
|
||
setActiveSection(null);
|
||
resetChangePhoneDraft();
|
||
resetPasswordDraft();
|
||
focusAfterNextPaint(sectionTrigger);
|
||
}, [focusAfterNextPaint, resetChangePhoneDraft, resetPasswordDraft]);
|
||
|
||
const closeChangePhonePanel = useCallback(() => {
|
||
const changePhoneTrigger = changePhoneTriggerRef.current;
|
||
setIsChangePhonePanelOpen(false);
|
||
resetChangePhoneDraft();
|
||
focusAfterNextPaint(changePhoneTrigger);
|
||
}, [focusAfterNextPaint, resetChangePhoneDraft]);
|
||
|
||
const closePasswordPanel = useCallback(() => {
|
||
const passwordTrigger = passwordTriggerRef.current;
|
||
setIsPasswordPanelOpen(false);
|
||
resetPasswordDraft();
|
||
focusAfterNextPaint(passwordTrigger);
|
||
}, [focusAfterNextPaint, resetPasswordDraft]);
|
||
|
||
if (!isOpen) {
|
||
return null;
|
||
}
|
||
|
||
const themeStatusText = settingsError
|
||
? settingsError
|
||
: isHydratingSettings
|
||
? '正在读取平台设置...'
|
||
: isPersistingSettings
|
||
? '正在同步平台设置...'
|
||
: '平台设置已同步';
|
||
|
||
const boundPhoneNumber =
|
||
user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定';
|
||
const boundWechatDisplayName =
|
||
user.wechatDisplayName?.trim() ||
|
||
formatBoundWechatAccount(user.wechatAccount) ||
|
||
(user.wechatBound ? '微信账号已绑定' : '未绑定');
|
||
|
||
const sectionSummaries: Record<PrimarySettingsSection, string> = {
|
||
appearance:
|
||
platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
|
||
account:
|
||
user.phoneNumber || user.phoneNumberMasked || user.wechatBound
|
||
? '查看身份、安全状态、登录设备与操作记录。'
|
||
: '查看账号绑定状态与安全记录。',
|
||
};
|
||
|
||
return (
|
||
<PlatformAuthModalShell
|
||
title={isDirectAccountMode ? '账号信息' : '设置与账号安全'}
|
||
platformTheme={platformTheme}
|
||
onClose={onClose}
|
||
closeLabel="关闭账号弹窗"
|
||
size="xl"
|
||
showHeader={false}
|
||
overlaySpacing="none"
|
||
zIndexClassName="z-[70]"
|
||
overlayClassName="!items-end !justify-center overflow-hidden !px-4 !py-0 sm:!items-center"
|
||
overlayStyle={{
|
||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
|
||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
|
||
}}
|
||
authCardClassName={isDirectAccountMode ? '' : undefined}
|
||
panelClassName={
|
||
isDirectAccountMode
|
||
? 'relative !max-w-3xl !overflow-visible !rounded-none !bg-transparent !shadow-none'
|
||
: 'relative !h-[min(100%,calc(100vh-2rem))] !max-w-5xl !rounded-[28px] !p-5 sm:!p-6'
|
||
}
|
||
bodyClassName={`!flex !min-h-0 !flex-1 !p-0 ${
|
||
isDirectAccountMode ? '!overflow-visible' : '!overflow-hidden'
|
||
}`}
|
||
panelStyle={{
|
||
maxHeight: ACCOUNT_MODAL_MAX_HEIGHT,
|
||
...(isDirectAccountMode
|
||
? {
|
||
background: 'none',
|
||
borderWidth: 0,
|
||
}
|
||
: {}),
|
||
}}
|
||
>
|
||
<div
|
||
className={
|
||
isDirectAccountMode
|
||
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-visible'
|
||
: 'relative flex h-full w-full min-h-0 flex-col overflow-hidden'
|
||
}
|
||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
{!isDirectAccountMode ? (
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
|
||
设置与账号安全
|
||
</div>
|
||
</div>
|
||
<PlatformActionButton
|
||
tone="ghost"
|
||
size="xs"
|
||
shape="pill"
|
||
className="min-h-0 px-3 py-1.5"
|
||
onClick={onClose}
|
||
>
|
||
关闭
|
||
</PlatformActionButton>
|
||
</div>
|
||
) : null}
|
||
|
||
{!isDirectAccountMode ? (
|
||
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
|
||
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{SETTINGS_SECTIONS.map((section) => (
|
||
<SettingsEntryCard
|
||
key={section.id}
|
||
label={section.label}
|
||
detail={section.detail}
|
||
summary={sectionSummaries[section.id]}
|
||
onClick={(trigger) => {
|
||
sectionTriggerRef.current = trigger;
|
||
setAccountNotice('');
|
||
setActiveSection(section.id);
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeSection === 'appearance' ? (
|
||
<OverlayPanel
|
||
eyebrow="平台偏好"
|
||
title="主题设置"
|
||
description="切换平台亮色或暗色主题。"
|
||
onBack={closeSectionPanel}
|
||
onClose={onClose}
|
||
>
|
||
<div className="flex min-h-0 flex-col gap-4">
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<ThemeOptionCard
|
||
active={platformTheme === 'light'}
|
||
title="亮色主题"
|
||
detail="暖白底面板,陶土橙强调。"
|
||
previewClassName="bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.32),transparent_30%),linear-gradient(135deg,#fffdf9_0%,#f4e5d7_52%,#eaccb3_100%)] border border-white/70"
|
||
onClick={() => onPlatformThemeChange('light')}
|
||
/>
|
||
<ThemeOptionCard
|
||
active={platformTheme === 'dark'}
|
||
title="暗色主题"
|
||
detail="保留原有紫蓝深色方案。"
|
||
previewClassName="bg-[radial-gradient(circle_at_top_left,rgba(129,140,248,0.28),transparent_34%),linear-gradient(180deg,#17192b_0%,#0b0d15_100%)] border border-white/10"
|
||
onClick={() => onPlatformThemeChange('dark')}
|
||
/>
|
||
</div>
|
||
|
||
<PlatformSubpanel
|
||
as="div"
|
||
radius="sm"
|
||
padding="none"
|
||
className="px-4 py-4"
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
当前主题
|
||
</div>
|
||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
|
||
</div>
|
||
</div>
|
||
<PlatformPillBadge
|
||
tone="neutral"
|
||
size="xs"
|
||
className="px-3 py-1"
|
||
>
|
||
{themeStatusText}
|
||
</PlatformPillBadge>
|
||
</div>
|
||
</PlatformSubpanel>
|
||
</div>
|
||
</OverlayPanel>
|
||
) : null}
|
||
|
||
{activeSection === 'account' ? (
|
||
<OverlayPanel
|
||
title="账号信息"
|
||
standalone={isDirectAccountMode}
|
||
dialog={!isDirectAccountMode}
|
||
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
|
||
onClose={onClose}
|
||
>
|
||
<div data-account-content className="flex min-h-0 flex-col gap-3">
|
||
{accountNotice ? (
|
||
<PlatformStatusMessage tone="success" surface="profile">
|
||
{accountNotice}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
|
||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||
<PlatformSubpanel
|
||
as="div"
|
||
data-account-binding-card
|
||
radius="sm"
|
||
padding="none"
|
||
className="px-3.5 py-3"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
绑定手机号
|
||
</div>
|
||
<PlatformActionButton
|
||
tone="ghost"
|
||
size="xs"
|
||
shape="pill"
|
||
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
|
||
onClick={(event) => {
|
||
changePhoneTriggerRef.current = event.currentTarget;
|
||
setAccountNotice('');
|
||
resetChangePhoneDraft();
|
||
setIsChangePhonePanelOpen(true);
|
||
}}
|
||
>
|
||
更换手机号
|
||
</PlatformActionButton>
|
||
</div>
|
||
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{boundPhoneNumber}
|
||
</div>
|
||
</PlatformSubpanel>
|
||
|
||
<PlatformSubpanel
|
||
as="div"
|
||
data-account-binding-card
|
||
radius="sm"
|
||
padding="none"
|
||
className="px-3.5 py-3"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
绑定微信
|
||
</div>
|
||
<PlatformActionButton
|
||
tone="ghost"
|
||
size="xs"
|
||
shape="pill"
|
||
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
|
||
onClick={() => {
|
||
setAccountNotice('更换微信号功能暂未接入。');
|
||
}}
|
||
>
|
||
更换微信号
|
||
</PlatformActionButton>
|
||
</div>
|
||
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{boundWechatDisplayName}
|
||
</div>
|
||
</PlatformSubpanel>
|
||
</div>
|
||
|
||
<PlatformSubpanel
|
||
as="div"
|
||
radius="sm"
|
||
padding="none"
|
||
className="px-3.5 py-3"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
登录密码
|
||
</div>
|
||
</div>
|
||
<PlatformActionButton
|
||
tone="ghost"
|
||
size="xs"
|
||
shape="pill"
|
||
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
|
||
onClick={(event) => {
|
||
passwordTriggerRef.current = event.currentTarget;
|
||
setAccountNotice('');
|
||
resetPasswordDraft();
|
||
setIsPasswordPanelOpen(true);
|
||
}}
|
||
>
|
||
修改密码
|
||
</PlatformActionButton>
|
||
</div>
|
||
</PlatformSubpanel>
|
||
|
||
<PlatformSubpanel
|
||
as="div"
|
||
radius="sm"
|
||
padding="none"
|
||
className="px-3.5 py-3"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
安全状态
|
||
</div>
|
||
</div>
|
||
<PlatformActionButton
|
||
tone="ghost"
|
||
size="xs"
|
||
shape="pill"
|
||
className="min-h-0 px-3 py-1.5 text-[11px]"
|
||
onClick={() => {
|
||
void onRefreshRiskBlocks();
|
||
}}
|
||
>
|
||
刷新
|
||
</PlatformActionButton>
|
||
</div>
|
||
|
||
<div className="mt-3 grid gap-2.5">
|
||
<PlatformAsyncStatePanel
|
||
isLoading={loadingRiskBlocks}
|
||
loadingState={
|
||
<AccountSubpanelState>
|
||
正在读取安全状态...
|
||
</AccountSubpanelState>
|
||
}
|
||
isEmpty={riskBlocks.length === 0}
|
||
emptyState={
|
||
<AccountSubpanelState>
|
||
当前没有生效中的安全限制。
|
||
</AccountSubpanelState>
|
||
}
|
||
>
|
||
{riskBlocks.map((block) => (
|
||
<PlatformStatusMessage
|
||
key={`${block.scopeType}:${block.expiresAt}`}
|
||
tone="warning"
|
||
surface="profile"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span>{block.title}</span>
|
||
<span className="text-xs">
|
||
剩余约{' '}
|
||
{Math.max(
|
||
1,
|
||
Math.ceil(block.remainingSeconds / 60),
|
||
)}{' '}
|
||
分钟
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 text-xs leading-5">
|
||
{block.detail}
|
||
</div>
|
||
<PlatformActionButton
|
||
tone="secondary"
|
||
size="xs"
|
||
className="mt-3 h-9 min-h-0 px-3"
|
||
onClick={() => {
|
||
void onLiftRiskBlock(block.scopeType);
|
||
}}
|
||
>
|
||
解除保护
|
||
</PlatformActionButton>
|
||
</PlatformStatusMessage>
|
||
))}
|
||
</PlatformAsyncStatePanel>
|
||
</div>
|
||
</PlatformSubpanel>
|
||
|
||
<PlatformSubpanel
|
||
as="div"
|
||
radius="sm"
|
||
padding="none"
|
||
className="px-3.5 py-3"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
登录设备
|
||
</div>
|
||
</div>
|
||
<PlatformActionButton
|
||
tone="ghost"
|
||
size="xs"
|
||
shape="pill"
|
||
className="min-h-0 px-3 py-1.5 text-[11px]"
|
||
onClick={() => {
|
||
void onRefreshSessions();
|
||
}}
|
||
>
|
||
刷新
|
||
</PlatformActionButton>
|
||
</div>
|
||
|
||
<div className="mt-3 grid gap-2.5">
|
||
<PlatformAsyncStatePanel
|
||
isLoading={loadingSessions}
|
||
loadingState={
|
||
<AccountSubpanelState>
|
||
正在读取当前登录设备...
|
||
</AccountSubpanelState>
|
||
}
|
||
isEmpty={sessions.length === 0}
|
||
emptyState={
|
||
<AccountSubpanelState>
|
||
暂无可展示的登录设备。
|
||
</AccountSubpanelState>
|
||
}
|
||
>
|
||
{sessions.map((session) => {
|
||
const isRevoking = revokingSessionIds.includes(
|
||
session.sessionId,
|
||
);
|
||
|
||
return (
|
||
<PlatformSubpanel
|
||
as="div"
|
||
key={session.sessionId}
|
||
surface="flat"
|
||
radius="sm"
|
||
padding="none"
|
||
className="px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span>{session.clientLabel}</span>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{session.sessionCount > 1 ? (
|
||
<PlatformPillBadge
|
||
tone="neutral"
|
||
size="xs"
|
||
className="px-2.5 py-1 text-[10px]"
|
||
>
|
||
{session.sessionCount} 个会话
|
||
</PlatformPillBadge>
|
||
) : null}
|
||
<PlatformPillBadge
|
||
tone="success"
|
||
size="xs"
|
||
className="px-2.5 py-1 text-[10px]"
|
||
>
|
||
{session.isCurrent ? '当前设备' : '已登录'}
|
||
</PlatformPillBadge>
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
|
||
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
||
</div>
|
||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||
到期时间:{formatSessionTime(session.expiresAt)}
|
||
</div>
|
||
{session.ipMasked ? (
|
||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||
IP:{session.ipMasked}
|
||
</div>
|
||
) : null}
|
||
{!session.isCurrent ? (
|
||
<PlatformActionButton
|
||
tone="danger"
|
||
size="xs"
|
||
className="mt-3 h-9 min-h-0 px-3"
|
||
disabled={isRevoking}
|
||
onClick={() => {
|
||
void onRevokeSession(session);
|
||
}}
|
||
>
|
||
{isRevoking ? '处理中...' : '踢下线'}
|
||
</PlatformActionButton>
|
||
) : null}
|
||
</PlatformSubpanel>
|
||
);
|
||
})}
|
||
</PlatformAsyncStatePanel>
|
||
</div>
|
||
</PlatformSubpanel>
|
||
|
||
<PlatformSubpanel
|
||
as="div"
|
||
radius="sm"
|
||
padding="none"
|
||
className="px-3.5 py-3"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
操作记录
|
||
</div>
|
||
</div>
|
||
<PlatformActionButton
|
||
tone="ghost"
|
||
size="xs"
|
||
shape="pill"
|
||
className="min-h-0 px-3 py-1.5 text-[11px]"
|
||
onClick={() => {
|
||
void onRefreshAuditLogs();
|
||
}}
|
||
>
|
||
刷新
|
||
</PlatformActionButton>
|
||
</div>
|
||
|
||
<div className="mt-3 grid gap-2.5">
|
||
<PlatformAsyncStatePanel
|
||
isLoading={loadingAuditLogs}
|
||
loadingState={
|
||
<AccountSubpanelState>
|
||
正在读取账号操作记录...
|
||
</AccountSubpanelState>
|
||
}
|
||
isEmpty={auditLogs.length === 0}
|
||
emptyState={
|
||
<AccountSubpanelState>
|
||
暂无账号操作记录。
|
||
</AccountSubpanelState>
|
||
}
|
||
>
|
||
{auditLogs.map((log) => (
|
||
<PlatformSubpanel
|
||
as="div"
|
||
key={log.id}
|
||
surface="flat"
|
||
radius="sm"
|
||
padding="none"
|
||
className="px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span>{log.title}</span>
|
||
<span className="text-xs text-[var(--platform-text-soft)]">
|
||
{formatSessionTime(log.createdAt)}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
|
||
{log.detail}
|
||
</div>
|
||
{log.ipMasked ? (
|
||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||
IP:{log.ipMasked}
|
||
</div>
|
||
) : null}
|
||
</PlatformSubpanel>
|
||
))}
|
||
</PlatformAsyncStatePanel>
|
||
</div>
|
||
</PlatformSubpanel>
|
||
|
||
<div
|
||
data-account-actions
|
||
className="grid gap-2.5 pt-1 sm:grid-cols-2"
|
||
>
|
||
<PlatformActionButton
|
||
tone="ghost"
|
||
size="sm"
|
||
fullWidth
|
||
className="h-10"
|
||
onClick={() => {
|
||
void onLogout();
|
||
}}
|
||
>
|
||
退出登录
|
||
</PlatformActionButton>
|
||
<PlatformActionButton
|
||
tone="danger"
|
||
size="sm"
|
||
fullWidth
|
||
className="h-10"
|
||
onClick={() => {
|
||
void onLogoutAll();
|
||
}}
|
||
>
|
||
退出全部设备
|
||
</PlatformActionButton>
|
||
</div>
|
||
</div>
|
||
|
||
{isChangePhonePanelOpen ? (
|
||
<OverlayPanel
|
||
eyebrow="手机号换绑"
|
||
title="绑定新手机号"
|
||
description="输入新手机号并完成验证码验证。"
|
||
onBack={closeChangePhonePanel}
|
||
onClose={onClose}
|
||
>
|
||
<div className="grid gap-3">
|
||
<label className="grid gap-2">
|
||
<PlatformFieldLabel variant="form" className="mb-0">
|
||
新手机号
|
||
</PlatformFieldLabel>
|
||
<PlatformTextField
|
||
className="h-11"
|
||
value={phone}
|
||
inputMode="numeric"
|
||
placeholder="13800000000"
|
||
onChange={(event) => setPhone(event.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<label className="grid gap-2">
|
||
<PlatformFieldLabel variant="form" className="mb-0">
|
||
验证码
|
||
</PlatformFieldLabel>
|
||
<div className="flex gap-3">
|
||
<PlatformTextField
|
||
className="h-11 min-w-0 flex-1"
|
||
value={code}
|
||
inputMode="numeric"
|
||
placeholder="输入验证码"
|
||
onChange={(event) => setCode(event.target.value)}
|
||
/>
|
||
<PlatformActionButton
|
||
disabled={
|
||
sendingCode || cooldownSeconds > 0 || !phone.trim()
|
||
}
|
||
tone="secondary"
|
||
size="md"
|
||
className="h-11 shrink-0"
|
||
onClick={() => {
|
||
void (async () => {
|
||
setSendingCode(true);
|
||
setChangePhoneError('');
|
||
try {
|
||
const result = await onSendChangePhoneCode(
|
||
phone,
|
||
{
|
||
challengeId:
|
||
changePhoneCaptchaChallenge?.challengeId,
|
||
answer: captchaAnswer,
|
||
},
|
||
);
|
||
setCooldownSeconds(result.cooldownSeconds);
|
||
setChangePhoneHint(
|
||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||
);
|
||
setCaptchaAnswer('');
|
||
} catch (error) {
|
||
setChangePhoneError(
|
||
error instanceof Error
|
||
? error.message
|
||
: '发送验证码失败,请稍后再试。',
|
||
);
|
||
setChangePhoneHint('');
|
||
} finally {
|
||
setSendingCode(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{sendingCode
|
||
? '发送中...'
|
||
: cooldownSeconds > 0
|
||
? `${cooldownSeconds}s`
|
||
: '获取验证码'}
|
||
</PlatformActionButton>
|
||
</div>
|
||
</label>
|
||
|
||
{changePhoneHint ? (
|
||
<PlatformStatusMessage tone="success" surface="profile">
|
||
{changePhoneHint}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
|
||
<CaptchaChallengeField
|
||
challenge={changePhoneCaptchaChallenge}
|
||
answer={captchaAnswer}
|
||
onAnswerChange={setCaptchaAnswer}
|
||
/>
|
||
|
||
{changePhoneError ? (
|
||
<PlatformStatusMessage tone="error" surface="profile">
|
||
{changePhoneError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
|
||
<PlatformActionButton
|
||
disabled={changingPhone || !phone.trim() || !code.trim()}
|
||
size="md"
|
||
className="h-11"
|
||
onClick={() => {
|
||
void (async () => {
|
||
setChangingPhone(true);
|
||
setChangePhoneError('');
|
||
try {
|
||
await onChangePhone(phone, code);
|
||
setAccountNotice('手机号已更新。');
|
||
closeChangePhonePanel();
|
||
} catch (error) {
|
||
setChangePhoneError(
|
||
error instanceof Error
|
||
? error.message
|
||
: '更换手机号失败,请稍后再试。',
|
||
);
|
||
} finally {
|
||
setChangingPhone(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{changingPhone ? '提交中...' : '确认更换手机号'}
|
||
</PlatformActionButton>
|
||
</div>
|
||
</OverlayPanel>
|
||
) : null}
|
||
|
||
{isPasswordPanelOpen ? (
|
||
<OverlayPanel
|
||
eyebrow="账号安全"
|
||
title="修改登录密码"
|
||
description="输入当前密码与新密码。首次设置密码时当前密码可留空。"
|
||
onBack={closePasswordPanel}
|
||
onClose={onClose}
|
||
>
|
||
<div className="grid gap-3">
|
||
<label className="grid gap-2">
|
||
<PlatformFieldLabel variant="form" className="mb-0">
|
||
当前密码
|
||
</PlatformFieldLabel>
|
||
<PlatformTextField
|
||
className="h-11"
|
||
value={currentPassword}
|
||
type="password"
|
||
autoComplete="current-password"
|
||
placeholder="首次设置可留空"
|
||
onChange={(event) =>
|
||
setCurrentPassword(event.target.value)
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="grid gap-2">
|
||
<PlatformFieldLabel variant="form" className="mb-0">
|
||
新密码
|
||
</PlatformFieldLabel>
|
||
<PlatformTextField
|
||
className="h-11"
|
||
value={newPassword}
|
||
type="password"
|
||
autoComplete="new-password"
|
||
placeholder="设置新密码"
|
||
onChange={(event) => setNewPassword(event.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
{passwordError ? (
|
||
<PlatformStatusMessage tone="error" surface="profile">
|
||
{passwordError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
|
||
<PlatformActionButton
|
||
disabled={changingPassword || !newPassword.trim()}
|
||
size="md"
|
||
fullWidth
|
||
className="h-11"
|
||
onClick={() => {
|
||
void (async () => {
|
||
setChangingPassword(true);
|
||
setPasswordError('');
|
||
try {
|
||
await onChangePassword(currentPassword, newPassword);
|
||
setAccountNotice('密码已更新。');
|
||
closePasswordPanel();
|
||
} catch (error) {
|
||
setPasswordError(
|
||
error instanceof Error
|
||
? error.message
|
||
: '修改密码失败,请稍后再试。',
|
||
);
|
||
} finally {
|
||
setChangingPassword(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{changingPassword ? '提交中...' : '确认修改密码'}
|
||
</PlatformActionButton>
|
||
</div>
|
||
</OverlayPanel>
|
||
) : null}
|
||
</OverlayPanel>
|
||
) : null}
|
||
</div>
|
||
</PlatformAuthModalShell>
|
||
);
|
||
}
|