Files
lislgosms/src/layouts/AppShell.tsx
T

540 lines
22 KiB
TypeScript

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<Record<string, boolean>>({});
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<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],
);
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>
<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)}
type="button"
aria-label="通知"
>
<Bell 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>
<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')}`;
}