import type { ComponentType } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Bell, 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; }; type AppShellProps = { title: string; subtitle: string; workspaceName: string; loginPath: string; portal: Portal; userName: string; userRole: string; navSections: ShellNavSection[]; auditNotifications?: AuditNotificationItem[]; onSessionLockedChange?: (locked: boolean) => void; }; export function AppShell({ title, workspaceName, loginPath, portal, userName, userRole, navSections, auditNotifications = [], onSessionLockedChange, }: AppShellProps) { const [collapsed, setCollapsed] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [closedSections, setClosedSections] = useState>({}); const [userMenuOpen, setUserMenuOpen] = useState(false); const [noticeOpen, setNoticeOpen] = 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(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], ); 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((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 (
{mobileNavOpen ?
{`${title}
{noticeOpen ? (
待审核任务 {auditTotal} 条
{auditNotifications.length ? auditNotifications.map((item) => ( setNoticeOpen(false)} role="menuitem" to={item.to}> {item.label} {item.count} )) :

暂无待审核任务

}
) : null}
{userMenuOpen ? (
) : null}
{routesSuspended ? null : }
} onClose={() => setPasswordModalOpen(false)} open={passwordModalOpen} title="修改密码" >
setCurrentPassword(event.target.value)} required type="password" value={currentPassword} /> setNewPassword(event.target.value)} required type="password" value={newPassword} /> setConfirmPassword(event.target.value)} required type="password" value={confirmPassword} /> {passwordError ?

{passwordError}

: null}
} onClose={() => undefined} open={locked} title="会话已安全锁定" >

由于长时间未操作,请输入当前密码继续使用。解锁后将返回当前页面;锁定超过 4 小时后需要完整登录。

setUnlockPassword(event.target.value)} type="password" value={unlockPassword} /> {unlockError ?

{unlockError}

: null}
} onClose={() => setIdleWarningSeconds(null)} open={!locked && idleWarningSeconds !== null} title="会话即将锁定" >

长时间未操作,系统将在 {formatCountdown(idleWarningSeconds ?? 0)} 后锁定。点击“继续使用”可保持当前会话。

} onClose={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }} open={reauthenticationOpen} title="敏感操作身份验证" >

该操作影响账号、通道或资金安全,请输入当前登录用户密码。验证通过后 30 分钟内无需重复输入。

setReauthenticationPassword(event.target.value)} type="password" value={reauthenticationPassword} /> {reauthenticationError ?

{reauthenticationError}

: null}
); } 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')}`; }