581 lines
24 KiB
TypeScript
581 lines
24 KiB
TypeScript
import type { ComponentType } from 'react';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Bell,
|
|
ClipboardList,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
CircleHelp,
|
|
KeyRound,
|
|
LogOut,
|
|
Menu,
|
|
PanelLeftClose,
|
|
PanelLeftOpen,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
import { portalSessionApi } from '@/api/adminApi';
|
|
import {
|
|
clearSession,
|
|
clearSessionRecovery,
|
|
dispatchSessionEvent,
|
|
getLastUserActivityAt,
|
|
markUserActivity,
|
|
readSession,
|
|
saveSessionRecovery,
|
|
sessionChannel,
|
|
sessionEvent,
|
|
setReauthenticationHandler,
|
|
updateSessionTiming,
|
|
type Portal,
|
|
} from '@/api/session';
|
|
import { Button, Input, Modal } from '@/components/ui';
|
|
|
|
export type ShellNavItem = {
|
|
label: string;
|
|
to: string;
|
|
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
|
|
pending?: boolean;
|
|
};
|
|
|
|
export type ShellNavSection = {
|
|
title: string;
|
|
icon?: ComponentType<{ size?: number; strokeWidth?: number }>;
|
|
items: ShellNavItem[];
|
|
};
|
|
|
|
export type AuditNotificationItem = {
|
|
label: string;
|
|
count: number;
|
|
to: string;
|
|
};
|
|
|
|
export type AlertNotificationItem = AuditNotificationItem & {
|
|
description: string;
|
|
};
|
|
|
|
type AppShellProps = {
|
|
title: string;
|
|
subtitle: string;
|
|
workspaceName: string;
|
|
loginPath: string;
|
|
portal: Portal;
|
|
userName: string;
|
|
userRole: string;
|
|
navSections: ShellNavSection[];
|
|
auditNotifications?: AuditNotificationItem[];
|
|
alertNotifications?: AlertNotificationItem[];
|
|
onSessionLockedChange?: (locked: boolean) => void;
|
|
};
|
|
|
|
export function AppShell({
|
|
title,
|
|
workspaceName,
|
|
loginPath,
|
|
portal,
|
|
userName,
|
|
userRole,
|
|
navSections,
|
|
auditNotifications = [],
|
|
alertNotifications = [],
|
|
onSessionLockedChange,
|
|
}: AppShellProps) {
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
|
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
|
const [noticeOpen, setNoticeOpen] = useState(false);
|
|
const [alertNoticeOpen, setAlertNoticeOpen] = useState(false);
|
|
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [passwordError, setPasswordError] = useState('');
|
|
const [passwordSaving, setPasswordSaving] = useState(false);
|
|
const [idleWarningSeconds, setIdleWarningSeconds] = useState<number | null>(null);
|
|
const [locked, setLocked] = useState(() => Boolean(readSession(portal)?.locked));
|
|
const [routesSuspended, setRoutesSuspended] = useState(() => Boolean(readSession(portal)?.locked));
|
|
const [unlockPassword, setUnlockPassword] = useState('');
|
|
const [unlockError, setUnlockError] = useState('');
|
|
const [unlocking, setUnlocking] = useState(false);
|
|
const [reauthenticationOpen, setReauthenticationOpen] = useState(false);
|
|
const [reauthenticationPassword, setReauthenticationPassword] = useState('');
|
|
const [reauthenticationError, setReauthenticationError] = useState('');
|
|
const [reauthenticating, setReauthenticating] = useState(false);
|
|
const reauthenticationResolve = useRef<(() => void) | null>(null);
|
|
const reauthenticationReject = useRef<((error: Error) => void) | null>(null);
|
|
const lockRequested = useRef(false);
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
|
const auditTotal = useMemo(
|
|
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
|
|
[auditNotifications],
|
|
);
|
|
const alertTotal = useMemo(
|
|
() => alertNotifications.reduce((sum, item) => sum + item.count, 0),
|
|
[alertNotifications],
|
|
);
|
|
|
|
async function changeOwnPassword() {
|
|
if (!currentPassword || newPassword.length < 6) {
|
|
setPasswordError('请输入当前密码,新密码至少 6 位');
|
|
return;
|
|
}
|
|
if (newPassword !== confirmPassword) {
|
|
setPasswordError('两次输入的新密码不一致');
|
|
return;
|
|
}
|
|
setPasswordSaving(true);
|
|
setPasswordError('');
|
|
try {
|
|
await portalSessionApi.changeOwnPassword(portal, { currentPassword, password: newPassword });
|
|
clearSession(portal);
|
|
clearSessionRecovery(portal);
|
|
dispatchSessionEvent(portal, 'logout', { message: '密码修改成功,请重新登录' });
|
|
navigate(loginPath, { replace: true });
|
|
} catch (error) {
|
|
setPasswordError(error instanceof Error ? error.message : '修改密码失败');
|
|
} finally {
|
|
setPasswordSaving(false);
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await portalSessionApi.logout(portal);
|
|
} finally {
|
|
clearSession(portal);
|
|
clearSessionRecovery(portal);
|
|
dispatchSessionEvent(portal, 'logout');
|
|
navigate(loginPath, { replace: true });
|
|
}
|
|
}
|
|
|
|
async function continueSession() {
|
|
try {
|
|
const timing = await portalSessionApi.touch(portal);
|
|
updateSessionTiming(portal, { ...timing, locked: false });
|
|
markUserActivity();
|
|
setIdleWarningSeconds(null);
|
|
} catch {
|
|
setLocked(true);
|
|
}
|
|
}
|
|
|
|
async function unlockSession() {
|
|
if (!unlockPassword) {
|
|
setUnlockError('请输入当前密码');
|
|
return;
|
|
}
|
|
setUnlocking(true);
|
|
setUnlockError('');
|
|
try {
|
|
const timing = await portalSessionApi.unlock(portal, unlockPassword);
|
|
updateSessionTiming(portal, { ...timing, locked: false });
|
|
markUserActivity();
|
|
setLocked(false);
|
|
setRoutesSuspended(false);
|
|
lockRequested.current = false;
|
|
setUnlockPassword('');
|
|
setIdleWarningSeconds(null);
|
|
dispatchSessionEvent(portal, 'unlocked');
|
|
} catch (error) {
|
|
setUnlockError(error instanceof Error ? error.message : '解锁失败');
|
|
} finally {
|
|
setUnlocking(false);
|
|
}
|
|
}
|
|
|
|
async function confirmReauthentication() {
|
|
if (!reauthenticationPassword) {
|
|
setReauthenticationError('请输入当前密码');
|
|
return;
|
|
}
|
|
setReauthenticating(true);
|
|
setReauthenticationError('');
|
|
try {
|
|
const timing = await portalSessionApi.reauthenticate(portal, reauthenticationPassword);
|
|
updateSessionTiming(portal, timing);
|
|
reauthenticationResolve.current?.();
|
|
reauthenticationResolve.current = null;
|
|
reauthenticationReject.current = null;
|
|
setReauthenticationOpen(false);
|
|
setReauthenticationPassword('');
|
|
} catch (error) {
|
|
setReauthenticationError(error instanceof Error ? error.message : '身份验证失败');
|
|
} finally {
|
|
setReauthenticating(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
setMobileNavOpen(false);
|
|
}, [location.pathname]);
|
|
|
|
useEffect(() => {
|
|
onSessionLockedChange?.(locked);
|
|
}, [locked, onSessionLockedChange]);
|
|
|
|
useEffect(() => {
|
|
if (!mobileNavOpen) return;
|
|
const closeOnEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setMobileNavOpen(false);
|
|
};
|
|
window.addEventListener('keydown', closeOnEscape);
|
|
return () => window.removeEventListener('keydown', closeOnEscape);
|
|
}, [mobileNavOpen]);
|
|
|
|
useEffect(() => {
|
|
const activityEvents = ['pointerdown', 'keydown', 'touchstart', 'scroll'] as const;
|
|
const onActivity = () => markUserActivity();
|
|
activityEvents.forEach((eventName) => window.addEventListener(eventName, onActivity, { passive: true }));
|
|
|
|
const onLocked = () => {
|
|
updateSessionTiming(portal, { locked: true });
|
|
setLocked(true);
|
|
// Business routes must be unmounted while the server session is locked.
|
|
// This prevents background list and badge requests from repeatedly
|
|
// receiving 401 responses and guarantees a fresh read after unlocking.
|
|
setRoutesSuspended(true);
|
|
};
|
|
const onUnlocked = () => {
|
|
updateSessionTiming(portal, { locked: false });
|
|
setLocked(false);
|
|
setRoutesSuspended(false);
|
|
lockRequested.current = false;
|
|
markUserActivity();
|
|
};
|
|
const onLogout = () => { clearSession(portal); navigate(loginPath, { replace: true }); };
|
|
const lockedEvent = sessionEvent(portal, 'locked');
|
|
const unlockedEvent = sessionEvent(portal, 'unlocked');
|
|
const logoutEvent = sessionEvent(portal, 'logout');
|
|
window.addEventListener(lockedEvent, onLocked);
|
|
window.addEventListener(unlockedEvent, onUnlocked);
|
|
window.addEventListener(logoutEvent, onLogout);
|
|
|
|
let channel: BroadcastChannel | undefined;
|
|
try {
|
|
channel = new BroadcastChannel(sessionChannel(portal));
|
|
channel.onmessage = (event: MessageEvent<{ type?: string }>) => {
|
|
if (event.data?.type === 'locked') onLocked();
|
|
if (event.data?.type === 'unlocked') onUnlocked();
|
|
if (event.data?.type === 'logout') onLogout();
|
|
};
|
|
} catch {
|
|
channel = undefined;
|
|
}
|
|
|
|
setReauthenticationHandler(() => new Promise<void>((resolve, reject) => {
|
|
reauthenticationResolve.current = resolve;
|
|
reauthenticationReject.current = reject;
|
|
setReauthenticationPassword('');
|
|
setReauthenticationError('');
|
|
setReauthenticationOpen(true);
|
|
}));
|
|
|
|
const timer = window.setInterval(() => {
|
|
const session = readSession(portal);
|
|
if (!session) return;
|
|
const now = Date.now();
|
|
if (now >= Date.parse(session.absoluteExpiresAt)) {
|
|
saveSessionRecovery(portal, {
|
|
returnUrl: `${location.pathname}${location.search}${location.hash}`,
|
|
code: 'SESSION_ABSOLUTE_TIMEOUT',
|
|
message: '登录已达到最长有效期,请重新登录后继续。',
|
|
});
|
|
clearSession(portal);
|
|
dispatchSessionEvent(portal, 'logout', { code: 'SESSION_ABSOLUTE_TIMEOUT' });
|
|
navigate(loginPath, { replace: true });
|
|
return;
|
|
}
|
|
const remaining = session.idleTimeoutSeconds * 1000 - (now - getLastUserActivityAt());
|
|
if (remaining <= 0 && !lockRequested.current) {
|
|
lockRequested.current = true;
|
|
setIdleWarningSeconds(null);
|
|
dispatchSessionEvent(portal, 'locked', { reason: 'client_idle_timer' });
|
|
void portalSessionApi.lock(portal).catch(() => undefined);
|
|
} else if (remaining <= 5 * 60 * 1000) {
|
|
setIdleWarningSeconds(Math.ceil(remaining / 1000));
|
|
} else {
|
|
setIdleWarningSeconds(null);
|
|
}
|
|
}, 1000);
|
|
|
|
return () => {
|
|
activityEvents.forEach((eventName) => window.removeEventListener(eventName, onActivity));
|
|
window.removeEventListener(lockedEvent, onLocked);
|
|
window.removeEventListener(unlockedEvent, onUnlocked);
|
|
window.removeEventListener(logoutEvent, onLogout);
|
|
channel?.close();
|
|
window.clearInterval(timer);
|
|
setReauthenticationHandler(undefined);
|
|
reauthenticationReject.current?.(new Error('身份验证已取消'));
|
|
};
|
|
}, [location.hash, location.pathname, location.search, loginPath, navigate, portal]);
|
|
|
|
useEffect(() => {
|
|
if (auditTotal <= 0 || typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
const alertedKey = `cmpp-audit-alert-${title}`;
|
|
if (window.sessionStorage.getItem(alertedKey)) {
|
|
return;
|
|
}
|
|
window.sessionStorage.setItem(alertedKey, '1');
|
|
const timer = window.setTimeout(() => {
|
|
window.alert(`有新的审核任务进入待办,共 ${auditTotal} 条。`);
|
|
}, 1200);
|
|
return () => window.clearTimeout(timer);
|
|
}, [auditTotal, title]);
|
|
|
|
return (
|
|
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : '', mobileNavOpen ? 'app-shell--mobile-nav-open' : ''].filter(Boolean).join(' ')}>
|
|
<aside aria-label="应用导航" className={['sidebar', mobileNavOpen ? 'sidebar--mobile-open' : ''].filter(Boolean).join(' ')}>
|
|
<div className="sidebar-brand-row">
|
|
<div className="brand-block">
|
|
<img alt={`${title} logo`} className="brand-logo brand-logo--full" src="/logo/logo1.png" />
|
|
<img alt={`${title} logo`} className="brand-logo brand-logo--compact" src="/logo/logo2.png" />
|
|
</div>
|
|
<button aria-label="关闭导航" className="icon-button mobile-nav-close" onClick={() => setMobileNavOpen(false)} type="button">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<nav className="side-nav" aria-label="主导航">
|
|
{navSections.map((section) => (
|
|
<section className="side-nav-section" key={section.title}>
|
|
{section.icon ? (
|
|
<button
|
|
aria-expanded={!closedSections[section.title]}
|
|
className="side-nav-group-toggle"
|
|
onClick={() => setClosedSections((current) => ({ ...current, [section.title]: !current[section.title] }))}
|
|
type="button"
|
|
>
|
|
<section.icon size={18} strokeWidth={2.1} />
|
|
<span className="side-nav-group-label">{section.title}</span>
|
|
{closedSections[section.title] ? (
|
|
<ChevronRight className="side-nav-group-chevron" size={16} />
|
|
) : (
|
|
<ChevronDown className="side-nav-group-chevron" size={16} />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<p>{section.title}</p>
|
|
)}
|
|
<div className={['side-nav-list', closedSections[section.title] ? 'side-nav-list--closed' : ''].join(' ')}>
|
|
{section.items.map((item) => {
|
|
const Icon = item.icon;
|
|
|
|
return (
|
|
<Link
|
|
aria-current={isShellNavItemActive(location.pathname, item.to) ? 'page' : undefined}
|
|
className={isShellNavItemActive(location.pathname, item.to) ? 'active' : undefined}
|
|
key={item.to}
|
|
onClick={() => setMobileNavOpen(false)}
|
|
to={item.to}
|
|
title={item.pending ? `${item.label}(待开发)` : item.label}
|
|
>
|
|
<Icon size={17} strokeWidth={2.1} />
|
|
<span className="side-nav-label">
|
|
<span className="side-nav-label-text">{item.label}</span>
|
|
{item.pending ? <span className="dev-status-badge side-nav-pending-badge">待开发</span> : null}
|
|
</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="sidebar-footer">
|
|
<span>当前空间</span>
|
|
<strong>{workspaceName}</strong>
|
|
</div>
|
|
</aside>
|
|
|
|
{mobileNavOpen ? <button aria-label="关闭导航遮罩" className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} type="button" /> : null}
|
|
|
|
<main className="main-area">
|
|
<header className="topbar">
|
|
<div className="topbar-left">
|
|
<button
|
|
className="icon-button desktop-nav-toggle"
|
|
onClick={() => setCollapsed((value) => !value)}
|
|
type="button"
|
|
aria-label={collapsed ? '展开导航' : '收起导航'}
|
|
>
|
|
<ToggleIcon size={18} />
|
|
</button>
|
|
<button
|
|
aria-expanded={mobileNavOpen}
|
|
aria-label={mobileNavOpen ? '关闭导航' : '打开导航'}
|
|
className="icon-button mobile-nav-toggle"
|
|
onClick={() => setMobileNavOpen((open) => !open)}
|
|
type="button"
|
|
>
|
|
<Menu size={20} />
|
|
</button>
|
|
<div className="mobile-topbar-brand">
|
|
<img alt={`${title} logo`} src="/logo/logo1.png" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="topbar-actions">
|
|
<button className="icon-button topbar-help" type="button" aria-label="帮助中心">
|
|
<CircleHelp size={18} />
|
|
</button>
|
|
{alertNotifications.length ? (
|
|
<div className="notice-menu-wrap">
|
|
<button
|
|
aria-expanded={alertNoticeOpen}
|
|
aria-haspopup="menu"
|
|
aria-label="预警通知"
|
|
className={['icon-button', alertTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
|
|
onClick={() => { setAlertNoticeOpen((open) => !open); setNoticeOpen(false); }}
|
|
type="button"
|
|
>
|
|
<Bell size={18} />
|
|
{alertTotal > 0 ? <span className="notice-count">{alertTotal}</span> : null}
|
|
</button>
|
|
{alertNoticeOpen ? (
|
|
<div className="notice-popover notice-popover--alerts" role="menu">
|
|
<div className="notice-popover__header">
|
|
<strong>预警中心</strong>
|
|
<span className={alertTotal === 0 ? 'is-zero' : ''}>{alertTotal} 条</span>
|
|
</div>
|
|
{alertNotifications.map((item) => (
|
|
<NavLink key={item.to} onClick={() => setAlertNoticeOpen(false)} role="menuitem" to={item.to}>
|
|
<span className="notice-popover__copy"><b>{item.label}</b><small>{item.description}</small></span>
|
|
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{auditNotifications.length ? <div className="notice-menu-wrap">
|
|
<button
|
|
aria-expanded={noticeOpen}
|
|
aria-haspopup="menu"
|
|
className={['icon-button', auditTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
|
|
onClick={() => { setNoticeOpen((open) => !open); setAlertNoticeOpen(false); }}
|
|
type="button"
|
|
aria-label="通知"
|
|
>
|
|
<ClipboardList size={18} />
|
|
{auditTotal > 0 ? <span className="notice-count">{auditTotal}</span> : null}
|
|
</button>
|
|
{noticeOpen ? (
|
|
<div className="notice-popover" role="menu">
|
|
<div className="notice-popover__header">
|
|
<strong>待审核任务</strong>
|
|
<span className={auditTotal === 0 ? 'is-zero' : ''}>{auditTotal} 条</span>
|
|
</div>
|
|
{auditNotifications.length ? auditNotifications.map((item) => (
|
|
<NavLink key={item.to} onClick={() => setNoticeOpen(false)} role="menuitem" to={item.to}>
|
|
<span>{item.label}</span>
|
|
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
|
|
</NavLink>
|
|
)) : <p>暂无待审核任务</p>}
|
|
</div>
|
|
) : null}
|
|
</div> : null}
|
|
<div className="user-menu-wrap">
|
|
<button
|
|
aria-expanded={userMenuOpen}
|
|
aria-haspopup="menu"
|
|
className="user-menu"
|
|
onClick={() => setUserMenuOpen((open) => !open)}
|
|
type="button"
|
|
>
|
|
<span className="user-avatar">{userName.slice(0, 1)}</span>
|
|
<span>
|
|
<strong>{userName}</strong>
|
|
<small>{userRole}</small>
|
|
</span>
|
|
<ChevronDown size={16} />
|
|
</button>
|
|
{userMenuOpen ? (
|
|
<div className="user-menu-popover" role="menu">
|
|
<button onClick={() => { setUserMenuOpen(false); setPasswordModalOpen(true); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setPasswordError(''); }} role="menuitem" type="button">
|
|
<KeyRound size={16} />
|
|
修改密码
|
|
</button>
|
|
<button onClick={() => {
|
|
setUserMenuOpen(false);
|
|
void logout();
|
|
}} role="menuitem" type="button">
|
|
<LogOut size={16} />
|
|
退出登录
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="page-content">
|
|
{routesSuspended ? null : <Outlet />}
|
|
</div>
|
|
</main>
|
|
<Modal
|
|
footer={<><Button disabled={passwordSaving} onClick={() => setPasswordModalOpen(false)} variant="ghost">取消</Button><Button disabled={passwordSaving} icon={<KeyRound size={16} />} onClick={() => void changeOwnPassword()}>{passwordSaving ? '保存中...' : '确认修改'}</Button></>}
|
|
onClose={() => setPasswordModalOpen(false)}
|
|
open={passwordModalOpen}
|
|
title="修改密码"
|
|
>
|
|
<div className="admin-system-modal-form">
|
|
<Input label="当前密码" onChange={(event) => setCurrentPassword(event.target.value)} required type="password" value={currentPassword} />
|
|
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} required type="password" value={newPassword} />
|
|
<Input label="确认新密码" onChange={(event) => setConfirmPassword(event.target.value)} required type="password" value={confirmPassword} />
|
|
{passwordError ? <p className="form-error">{passwordError}</p> : null}
|
|
</div>
|
|
</Modal>
|
|
<Modal
|
|
footer={<><Button onClick={() => void logout()} variant="ghost">退出登录</Button><Button disabled={unlocking} onClick={() => void unlockSession()}>{unlocking ? '解锁中...' : '解锁'}</Button></>}
|
|
onClose={() => undefined}
|
|
open={locked}
|
|
title="会话已安全锁定"
|
|
>
|
|
<p>由于长时间未操作,请输入当前密码继续使用。解锁后将返回当前页面;锁定超过 4 小时后需要完整登录。</p>
|
|
<Input label="当前密码" onChange={(event) => setUnlockPassword(event.target.value)} type="password" value={unlockPassword} />
|
|
{unlockError ? <p className="login-error">{unlockError}</p> : null}
|
|
</Modal>
|
|
<Modal
|
|
footer={<><Button onClick={() => setIdleWarningSeconds(null)} variant="ghost">稍后处理</Button><Button onClick={() => void continueSession()}>继续使用</Button></>}
|
|
onClose={() => setIdleWarningSeconds(null)}
|
|
open={!locked && idleWarningSeconds !== null}
|
|
title="会话即将锁定"
|
|
>
|
|
<p>长时间未操作,系统将在 {formatCountdown(idleWarningSeconds ?? 0)} 后锁定。点击“继续使用”可保持当前会话。</p>
|
|
</Modal>
|
|
<Modal
|
|
footer={<><Button onClick={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }} variant="ghost">取消</Button><Button disabled={reauthenticating} onClick={() => void confirmReauthentication()}>{reauthenticating ? '验证中...' : '确认身份'}</Button></>}
|
|
onClose={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }}
|
|
open={reauthenticationOpen}
|
|
title="敏感操作身份验证"
|
|
>
|
|
<p>该操作影响账号、通道或资金安全,请输入当前登录用户密码。验证通过后 30 分钟内无需重复输入。</p>
|
|
<Input label="当前密码" onChange={(event) => setReauthenticationPassword(event.target.value)} type="password" value={reauthenticationPassword} />
|
|
{reauthenticationError ? <p className="login-error">{reauthenticationError}</p> : null}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function isShellNavItemActive(pathname: string, itemPath: string) {
|
|
if (pathname === itemPath) return true;
|
|
if (itemPath === '/admin' || itemPath === '/client') return false;
|
|
if (pathname.startsWith(`${itemPath}/`)) return true;
|
|
if (itemPath === '/admin/enterprise-applications' && /^\/admin\/customers\/[^/]+\/sms-apps\//.test(pathname)) return true;
|
|
if (itemPath === '/admin/customer-enterprises' && /^\/admin\/customers\/[^/]+\/edit$/.test(pathname)) return true;
|
|
return false;
|
|
}
|
|
|
|
function formatCountdown(seconds: number) {
|
|
const minutes = Math.floor(seconds / 60);
|
|
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
|
}
|