216 lines
11 KiB
TypeScript
216 lines
11 KiB
TypeScript
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||
import { motion } from 'motion/react';
|
||
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
|
||
import { useAuth } from '../auth/useAuth';
|
||
import { DemoModeProvider } from './Blur';
|
||
import FeedbackFab from './FeedbackFab';
|
||
import { cn } from '../lib/cn';
|
||
import { LoadingState } from './ui/surface';
|
||
|
||
export interface ModuleConfig {
|
||
id: string;
|
||
label: string;
|
||
icon: ComponentType<{ size?: number; className?: string }>;
|
||
component?: ElementType;
|
||
children?: ModuleConfig[];
|
||
}
|
||
|
||
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
|
||
return modules.flatMap((module) => module.children ?? [module]);
|
||
}
|
||
|
||
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||
function getHashHead(): string {
|
||
return window.location.hash.slice(1).split('/')[0];
|
||
}
|
||
|
||
function getInitialModule(modules: ModuleConfig[]): string {
|
||
const head = getHashHead();
|
||
if (modules.some((m) => m.id === head)) return head;
|
||
return modules[0]?.id ?? '';
|
||
}
|
||
|
||
function getHashModule(modules: ModuleConfig[]): string {
|
||
const head = getHashHead();
|
||
return modules.some((m) => m.id === head) ? head : '';
|
||
}
|
||
|
||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||
const flatModules = useMemo(() => flattenModules(modules), [modules]);
|
||
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
|
||
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
const onHashChange = () => {
|
||
const h = getHashModule(flatModules);
|
||
if (h) setActiveModule(h);
|
||
};
|
||
window.addEventListener('hashchange', onHashChange);
|
||
return () => window.removeEventListener('hashchange', onHashChange);
|
||
}, [flatModules]);
|
||
|
||
useEffect(() => {
|
||
if (!openGroupId) return undefined;
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') setOpenGroupId(null);
|
||
};
|
||
document.addEventListener('keydown', handleKeyDown);
|
||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||
}, [openGroupId]);
|
||
|
||
useEffect(() => {
|
||
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
|
||
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
|
||
if (getHashHead() !== activeModule) {
|
||
const { pathname, search } = window.location;
|
||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||
}
|
||
}, [activeModule]);
|
||
|
||
const switchModule = (id: string) => {
|
||
setOpenGroupId(null);
|
||
if (getHashHead() === id) {
|
||
setActiveModule(id);
|
||
return;
|
||
}
|
||
const { pathname, search } = window.location;
|
||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||
setActiveModule(id);
|
||
};
|
||
|
||
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
|
||
|
||
const { user } = useAuth();
|
||
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
|
||
const watermarkText = useMemo(() => {
|
||
const name = user?.userName || '未登录';
|
||
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
||
return `${name}-${time}`;
|
||
}, [user]);
|
||
|
||
return (
|
||
<DemoModeProvider enabled={false}>
|
||
<div className="enterprise-grid-bg flex min-h-screen">
|
||
{/* 全局水印 */}
|
||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||
<div className="absolute inset-0" style={{
|
||
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'><text x='50%' y='50%' text-anchor='middle' dominant-baseline='middle' font-size='14' font-family='sans-serif' fill='%23000' transform='rotate(-25 160 100)'>${watermarkText}</text></svg>`)}")`,
|
||
backgroundRepeat: 'repeat',
|
||
}} />
|
||
</div>
|
||
{/* Web 侧边栏 (md 及以上) */}
|
||
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
|
||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
|
||
<Building2 size={22} />
|
||
</div>
|
||
<div className="flex w-full flex-1 flex-col items-center gap-2">
|
||
{modules.map((m) => {
|
||
const Icon = m.icon;
|
||
const isGroup = Boolean(m.children?.length);
|
||
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
|
||
const isOpen = openGroupId === m.id;
|
||
return (
|
||
<div key={m.id} className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||
aria-expanded={isGroup ? isOpen : undefined}
|
||
aria-haspopup={isGroup ? 'menu' : undefined}
|
||
className={cn(
|
||
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
|
||
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
|
||
)}
|
||
title={m.label}
|
||
>
|
||
{isActive ? (
|
||
<motion.span layoutId="desktop-shell-active" className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||
) : null}
|
||
<Icon size={21} className="relative mb-1" />
|
||
<span className="relative leading-tight">{m.label}</span>
|
||
{isGroup ? <ChevronRight size={11} className={cn('absolute right-1 top-1/2 -translate-y-1/2 transition-transform', isOpen && 'rotate-180')} /> : null}
|
||
</button>
|
||
{isGroup && isOpen ? (
|
||
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute left-[calc(100%+12px)] top-1/2 w-36 -translate-y-1/2 rounded-2xl border border-slate-700 bg-slate-900 p-2 shadow-2xl shadow-slate-950/30">
|
||
{m.children!.map((child) => {
|
||
const ChildIcon = child.icon;
|
||
const childActive = child.id === activeModule;
|
||
return (
|
||
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-white/8 hover:text-white')}>
|
||
<ChildIcon size={16} />{child.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
|
||
<ShieldCheck size={18} />
|
||
</div>
|
||
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
|
||
</div>
|
||
</nav>
|
||
|
||
{/* 内容区 */}
|
||
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
|
||
<span className="text-slate-600">羚牛氢能 BI</span>
|
||
<span className="mx-2 text-slate-300">/</span>
|
||
<span>{activeLabel}</span>
|
||
</div>
|
||
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
|
||
{ActiveComponent && <ActiveComponent />}
|
||
</Suspense>
|
||
<FeedbackFab module={activeModule} />
|
||
</main>
|
||
|
||
{/* 移动端底部导航 (md 以下) */}
|
||
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
|
||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
|
||
{modules.map((m) => {
|
||
const Icon = m.icon;
|
||
const isGroup = Boolean(m.children?.length);
|
||
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
|
||
const isOpen = openGroupId === m.id;
|
||
return (
|
||
<div key={m.id} className="relative flex justify-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||
aria-expanded={isGroup ? isOpen : undefined}
|
||
aria-haspopup={isGroup ? 'menu' : undefined}
|
||
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-medium transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
|
||
>
|
||
{isActive ? (
|
||
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||
) : null}
|
||
<Icon size={20} className="relative" />
|
||
<span className="relative">{m.label}</span>
|
||
</button>
|
||
{isGroup && isOpen ? (
|
||
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
|
||
{m.children!.map((child) => {
|
||
const ChildIcon = child.icon;
|
||
const childActive = child.id === activeModule;
|
||
return (
|
||
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-medium transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
|
||
<ChildIcon size={16} />{child.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</nav>
|
||
</div>
|
||
</DemoModeProvider>
|
||
);
|
||
}
|