feat: add vehicle and hydrogen heatmaps
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Building2, ShieldCheck } from 'lucide-react';
|
||||
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
@@ -11,7 +11,12 @@ export interface ModuleConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
component: ElementType;
|
||||
component?: ElementType;
|
||||
children?: ModuleConfig[];
|
||||
}
|
||||
|
||||
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
|
||||
return modules.flatMap((module) => module.children ?? [module]);
|
||||
}
|
||||
|
||||
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||
@@ -31,16 +36,27 @@ function getHashModule(modules: ModuleConfig[]): string {
|
||||
}
|
||||
|
||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
|
||||
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(modules);
|
||||
const h = getHashModule(flatModules);
|
||||
if (h) setActiveModule(h);
|
||||
};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, [modules]);
|
||||
}, [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 记录,
|
||||
@@ -53,16 +69,20 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}, [activeModule]);
|
||||
|
||||
const switchModule = (id: string) => {
|
||||
if (getHashHead() === id) return;
|
||||
setOpenGroupId(null);
|
||||
if (getHashHead() === id) {
|
||||
setActiveModule(id);
|
||||
return;
|
||||
}
|
||||
const { pathname, search } = window.location;
|
||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||
setActiveModule(id);
|
||||
};
|
||||
|
||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
||||
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
|
||||
|
||||
const { user } = useAuth();
|
||||
const activeLabel = modules.find((m) => m.id === activeModule)?.label ?? '业务看板';
|
||||
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, '-');
|
||||
@@ -87,29 +107,43 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
<div className="flex w-full flex-1 flex-col items-center gap-2">
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
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 (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
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 }}
|
||||
/>
|
||||
<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}
|
||||
<Icon size={21} className="relative mb-1" />
|
||||
<span className="relative leading-tight">{m.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -139,26 +173,38 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = m.id === activeModule;
|
||||
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 (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => switchModule(m.id)}
|
||||
className={cn(
|
||||
'relative flex min-h-12 flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-black 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 }}
|
||||
/>
|
||||
<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-black 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-black transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
|
||||
<ChildIcon size={16} />{child.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<Icon size={20} className="relative" />
|
||||
<span className="relative">{m.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user