231 lines
10 KiB
TypeScript
231 lines
10 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
import {
|
|
LayoutDashboard, Database, Server, Zap, Leaf,
|
|
Code2, FileEdit, Users, ShieldCheck, FolderLock, Shield, GitBranch, Bell,
|
|
LogOut, Settings, Timer, ScrollText, Megaphone, Sun, Moon, BookOpen,
|
|
ChevronRight, Monitor
|
|
} from 'lucide-react';
|
|
import { menuSections, menuItems } from '@/constants/menu';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
|
import request from '@/api/request';
|
|
|
|
const iconMap: Record<string, React.ElementType> = {
|
|
LayoutDashboard, Database, Server, Zap, Leaf,
|
|
Code2, FileEdit, Users, ShieldCheck, FolderLock, Shield, GitBranch, Bell,
|
|
Settings, Timer, ScrollText, Megaphone, BookOpen,
|
|
};
|
|
|
|
export default function Layout() {
|
|
const { user, logout, visibleMenuItems } = useAuth();
|
|
const { theme, toggleTheme } = useTheme();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const currentPath = location.pathname.split('/')[1] || 'dashboard';
|
|
const [pendingCount, setPendingCount] = useState(0);
|
|
|
|
useEffect(() => {
|
|
request.get('/dashboard/stats')
|
|
.then((data: any) => setPendingCount(data?.ticket_pending ?? 0))
|
|
.catch(() => {});
|
|
}, [location.pathname]);
|
|
|
|
// Build breadcrumb info from current path
|
|
const breadcrumbInfo = useMemo(() => {
|
|
const currentItem = menuItems.find(m => m.id === currentPath);
|
|
if (!currentItem) {
|
|
return { sectionLabel: '', pageLabel: '仪表盘' };
|
|
}
|
|
const section = menuSections.find(s => s.id === currentItem.section);
|
|
return {
|
|
sectionLabel: section?.label || '',
|
|
pageLabel: currentItem.label,
|
|
};
|
|
}, [currentPath]);
|
|
|
|
const themeOption = useMemo(() => THEME_OPTIONS.find(o => o.id === theme), [theme]);
|
|
const themeIcon = useMemo(() => {
|
|
if (!themeOption) return <Monitor className="w-4 h-4" />;
|
|
if (themeOption.isDark === 'system') return <Monitor className="w-4 h-4" />;
|
|
if (themeOption.isDark) return <Moon className="w-4 h-4" />;
|
|
return <Sun className="w-4 h-4" />;
|
|
}, [themeOption]);
|
|
|
|
return (
|
|
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
|
{/* 侧边栏 — z-[200] 永远在弹框之上 */}
|
|
<aside className="relative z-[200] hidden md:flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
|
|
{/* Logo */}
|
|
<div className="h-14 flex items-center px-4">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="w-7 h-7 rounded-lg dg-gradient-br flex items-center justify-center flex-shrink-0">
|
|
<Shield className="w-4 h-4 text-white" />
|
|
</div>
|
|
<span className="text-base font-bold dg-gradient-text whitespace-nowrap">
|
|
OpsxSQL
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 菜单 */}
|
|
<nav className="flex-1 overflow-y-auto scrollbar-thin py-1">
|
|
{menuSections.map(section => {
|
|
const items = visibleMenuItems.filter(item => item.section === section.id);
|
|
if (items.length === 0) return null;
|
|
return (
|
|
<div key={section.id} className="mb-1">
|
|
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--dg-text-muted)]">
|
|
{section.label}
|
|
</div>
|
|
{items.map(item => {
|
|
const Icon = iconMap[item.icon] || Database;
|
|
const isActive = currentPath === item.id;
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => navigate('/' + item.id)}
|
|
className={
|
|
'group flex items-center mx-2 px-3 py-2 rounded-lg cursor-pointer '
|
|
+ 'transition-all duration-200 relative text-[13px] '
|
|
+ (isActive
|
|
? 'bg-[var(--dg-blue)]/10 text-[var(--dg-blue)] font-medium'
|
|
: 'text-[var(--dg-text-secondary)] '
|
|
+ 'hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] '
|
|
+ 'hover:ml-0.5')
|
|
}
|
|
>
|
|
{/* Active indicator bar */}
|
|
{isActive && (
|
|
<div
|
|
className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r animate-[slideInLeft_200ms_ease-out]"
|
|
style={{
|
|
background: 'var(--dg-blue)',
|
|
boxShadow: '0 0 0 1px color-mix(in srgb, var(--dg-blue) 30%, transparent)',
|
|
}}
|
|
/>
|
|
)}
|
|
<Icon
|
|
className={
|
|
'w-4 h-4 flex-shrink-0 transition-colors duration-200 '
|
|
+ (isActive
|
|
? 'text-[var(--dg-blue)]'
|
|
: 'group-hover:text-[var(--dg-blue)]')
|
|
}
|
|
/>
|
|
<span className="ml-2.5">{item.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
|
|
{/* 主内容区 */}
|
|
<main id="app-main" className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
{/* 顶部栏 — z-[200] 永远在弹框之上 */}
|
|
<header className="relative z-[200] h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-3 md:px-5 flex-shrink-0">
|
|
{/* Breadcrumb area */}
|
|
<div className="flex items-center gap-1.5 text-sm min-w-0">
|
|
<span className="text-[var(--dg-text-muted)] font-medium">OpsxSQL</span>
|
|
{breadcrumbInfo.sectionLabel && (
|
|
<span className="hidden sm:contents">
|
|
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
|
<span className="text-[var(--dg-text-muted)] opacity-70">{breadcrumbInfo.sectionLabel}</span>
|
|
</span>
|
|
)}
|
|
<ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)] opacity-50" />
|
|
<span className="text-[var(--dg-text-primary)] font-medium truncate">{breadcrumbInfo.pageLabel}</span>
|
|
</div>
|
|
|
|
{/* Right actions */}
|
|
<div className="flex items-center gap-2">
|
|
{/* Theme toggle with tooltip */}
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-all duration-200"
|
|
title={`当前主题: ${themeOption?.name || theme}\n点击切换下一个主题`}
|
|
>
|
|
{themeIcon}
|
|
</button>
|
|
|
|
{/* Notification bell */}
|
|
<button onClick={() => navigate('/tickets')}
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-all duration-200 relative">
|
|
<Bell className="w-4 h-4" />
|
|
{pendingCount > 0 && (
|
|
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 bg-red-500 rounded-full text-[10px] text-white font-bold flex items-center justify-center animate-pulse">
|
|
{pendingCount > 99 ? '99+' : pendingCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
<div className="w-px h-5 bg-[var(--dg-border)] mx-1" />
|
|
|
|
{/* User area */}
|
|
<div className="flex items-center gap-2 pl-1">
|
|
<div className="w-7 h-7 rounded-full dg-gradient-br flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
|
{user.avatar}
|
|
</div>
|
|
<span className="hidden sm:inline text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
|
|
<button
|
|
onClick={() => { logout(); navigate('/login'); }}
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/10 hover:text-red-400 transition-colors duration-200"
|
|
title="退出登录"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* 页面内容 */}
|
|
<div className="flex-1 overflow-y-auto scrollbar-thin p-3 md:p-5 pb-20 md:pb-5">
|
|
<Outlet />
|
|
</div>
|
|
</main>
|
|
|
|
<nav className="fixed bottom-0 left-0 right-0 z-[210] md:hidden border-t border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]/95 backdrop-blur">
|
|
<div className="flex overflow-x-auto px-2 py-1.5 gap-1 scrollbar-thin">
|
|
{visibleMenuItems.map(item => {
|
|
const Icon = iconMap[item.icon] || Database;
|
|
const isActive = currentPath === item.id;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => navigate('/' + item.id)}
|
|
className={`min-w-[70px] flex flex-col items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-[11px] transition-colors ${
|
|
isActive
|
|
? 'bg-[var(--dg-blue)]/12 text-[var(--dg-blue)]'
|
|
: 'text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]'
|
|
}`}
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
<span className="max-w-[64px] truncate">{item.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Keyframe animation for active indicator */}
|
|
<style>{`
|
|
@keyframes slideInLeft {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-50%) scaleX(0);
|
|
transform-origin: left;
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(-50%) scaleX(1);
|
|
transform-origin: left;
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|