366 lines
19 KiB
TypeScript
366 lines
19 KiB
TypeScript
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import CommandPalette from '@/components/CommandPalette'
|
|
import {
|
|
LayoutDashboard, Users, Shield, MessageSquare, FolderTree, Leaf,
|
|
LogOut, ChevronDown, Menu, FileText, Settings, Book, Home, Monitor,
|
|
Hash, Folder, ChevronRight, Search, Bell, ChevronLeft, Bot, Radio,
|
|
Image, Music, ScrollText, Sun, Moon, Trophy, Award, Star, Gift, List,
|
|
} from 'lucide-react'
|
|
import { useState, useMemo, useEffect } from 'react'
|
|
import { useAuthStore } from '@/store/auth'
|
|
import { useAppStore } from '@/store/app'
|
|
import type { SystemMenu } from '@/api/system'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import TabBar from '@/components/TabBar'
|
|
import {
|
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
|
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
// Icon mapping
|
|
const iconMap: Record<string, React.ReactNode> = {
|
|
dashboard: <LayoutDashboard className="h-4 w-4" />,
|
|
home: <Home className="h-4 w-4" />,
|
|
user: <Users className="h-4 w-4" />,
|
|
users: <Users className="h-4 w-4" />,
|
|
role: <Shield className="h-4 w-4" />,
|
|
shield: <Shield className="h-4 w-4" />,
|
|
system: <Settings className="h-4 w-4" />,
|
|
settings: <Settings className="h-4 w-4" />,
|
|
topic: <MessageSquare className="h-4 w-4" />,
|
|
message: <MessageSquare className="h-4 w-4" />,
|
|
post: <FileText className="h-4 w-4" />,
|
|
category: <FolderTree className="h-4 w-4" />,
|
|
tree: <FolderTree className="h-4 w-4" />,
|
|
plant: <Leaf className="h-4 w-4" />,
|
|
leaf: <Leaf className="h-4 w-4" />,
|
|
wiki: <Book className="h-4 w-4" />,
|
|
book: <Book className="h-4 w-4" />,
|
|
menu: <Menu className="h-4 w-4" />,
|
|
monitor: <Monitor className="h-4 w-4" />,
|
|
client: <Monitor className="h-4 w-4" />,
|
|
hash: <Hash className="h-4 w-4" />,
|
|
'file-text': <FileText className="h-4 w-4" />,
|
|
folder: <Folder className="h-4 w-4" />,
|
|
bot: <Bot className="h-4 w-4" />,
|
|
ai: <Bot className="h-4 w-4" />,
|
|
radio: <Radio className="h-4 w-4" />,
|
|
image: <Image className="h-4 w-4" />,
|
|
music: <Music className="h-4 w-4" />,
|
|
scroll: <ScrollText className="h-4 w-4" />,
|
|
log: <ScrollText className="h-4 w-4" />,
|
|
trophy: <Trophy className="h-4 w-4" />,
|
|
award: <Award className="h-4 w-4" />,
|
|
star: <Star className="h-4 w-4" />,
|
|
gift: <Gift className="h-4 w-4" />,
|
|
list: <List className="h-4 w-4" />,
|
|
}
|
|
|
|
function getIcon(iconName?: string): React.ReactNode {
|
|
if (!iconName) return <FileText className="h-4 w-4" />
|
|
const lower = iconName.toLowerCase()
|
|
for (const [key, icon] of Object.entries(iconMap)) {
|
|
if (lower.includes(key)) return icon
|
|
}
|
|
return <FileText className="h-4 w-4" />
|
|
}
|
|
|
|
interface NavItem {
|
|
title: string; href: string; icon: React.ReactNode
|
|
permission?: string; children?: NavItem[]
|
|
}
|
|
|
|
function convertMenuToNavItem(menu: SystemMenu): NavItem {
|
|
return {
|
|
title: menu.title || menu.name,
|
|
href: menu.path || menu.code || `/${menu.name.toLowerCase()}`,
|
|
icon: getIcon(menu.icon),
|
|
permission: menu.permission,
|
|
children: menu.children?.filter(c => c.category !== 2).length ? menu.children.filter(c => c.category !== 2).map(convertMenuToNavItem) : undefined,
|
|
}
|
|
}
|
|
|
|
// ==================== Nav Item ====================
|
|
|
|
function NavItemComponent({ item, collapsed, level = 0 }: { item: NavItem; collapsed: boolean; level?: number }) {
|
|
const [open, setOpen] = useState(false)
|
|
const hasPermission = useAuthStore(s => s.hasPermission)
|
|
const location = useLocation()
|
|
const hasActiveChild = item.children?.some(c => location.pathname.startsWith(c.href))
|
|
|
|
useEffect(() => {
|
|
if (hasActiveChild && !collapsed) setOpen(true)
|
|
}, [hasActiveChild, collapsed])
|
|
|
|
if (item.permission && !hasPermission(item.permission)) return null
|
|
|
|
if (item.children?.length) {
|
|
const visible = item.children.filter(c => !c.permission || hasPermission(c.permission))
|
|
if (!visible.length) return null
|
|
|
|
return (
|
|
<div className="mb-1">
|
|
<button onClick={() => setOpen(!open)}
|
|
className={cn(
|
|
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sidebar-foreground transition-all duration-300 hover:bg-sidebar-accent/60 group relative overflow-hidden',
|
|
(open || hasActiveChild) && 'text-primary font-medium bg-sidebar-accent/30',
|
|
level > 0 && 'text-[13px] py-2'
|
|
)}>
|
|
<span className={cn("transition-colors", (open || hasActiveChild) ? "text-primary" : "text-muted-foreground group-hover:text-primary")}>{item.icon}</span>
|
|
{!collapsed && (
|
|
<>
|
|
<span className="flex-1 text-left line-clamp-1 text-sm">{item.title}</span>
|
|
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform text-muted-foreground/70', open && 'rotate-180 text-foreground')} />
|
|
</>
|
|
)}
|
|
</button>
|
|
{open && !collapsed && (
|
|
<div className={cn('mt-1 space-y-0.5 relative', level === 0 ? 'ml-4 pl-3 border-l border-sidebar-border/50' : 'ml-3 pl-3 border-l border-sidebar-border/50')}>
|
|
{visible.map(child => <NavItemComponent key={child.href} item={child} collapsed={collapsed} level={level + 1} />)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<NavLink to={item.href} end
|
|
className={({ isActive }) => cn(
|
|
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sidebar-foreground transition-all duration-300 hover:bg-sidebar-accent/60 mb-1 group relative overflow-hidden',
|
|
isActive && 'bg-primary/10 text-primary font-semibold',
|
|
level > 0 && 'text-[13px] py-2'
|
|
)}>
|
|
<span className={cn("absolute left-0 top-1/2 -translate-y-1/2 w-1 h-0 bg-primary rounded-r-full transition-all duration-300", location.pathname === item.href && "h-3/5")} />
|
|
{level === 0 && <span className="text-muted-foreground group-hover:text-primary transition-colors">{item.icon}</span>}
|
|
{level > 0 && <span className={cn("w-1.5 h-1.5 rounded-full transition-all bg-current opacity-30 group-hover:opacity-70", location.pathname === item.href && "opacity-100 scale-110 bg-primary")} />}
|
|
{!collapsed && <span className="text-sm line-clamp-1">{item.title}</span>}
|
|
</NavLink>
|
|
)
|
|
}
|
|
|
|
// ==================== Breadcrumb ====================
|
|
|
|
function Breadcrumb() {
|
|
const location = useLocation()
|
|
const menus = useAuthStore(s => s.menus)
|
|
|
|
const breadcrumbs = useMemo(() => {
|
|
const segments = location.pathname.split('/').filter(Boolean)
|
|
const crumbs: { title: string; href: string }[] = []
|
|
let currentPath = ''
|
|
const findMenuItem = (items: SystemMenu[], target: string): SystemMenu | undefined => {
|
|
for (const item of items) {
|
|
if (item.path === target || item.code === target) return item
|
|
if (item.children) { const found = findMenuItem(item.children, target); if (found) return found }
|
|
}
|
|
}
|
|
segments.forEach(seg => {
|
|
currentPath += `/${seg}`
|
|
const menuItem = menus ? findMenuItem(menus, currentPath) : null
|
|
crumbs.push({ title: menuItem ? (menuItem.title || menuItem.name) : seg.charAt(0).toUpperCase() + seg.slice(1), href: currentPath })
|
|
})
|
|
return crumbs
|
|
}, [location.pathname, menus])
|
|
|
|
if (!breadcrumbs.length) return null
|
|
return (
|
|
<div className="flex items-center text-sm">
|
|
<NavLink to="/dashboard" className="flex items-center text-muted-foreground hover:text-foreground transition-colors"><Home className="h-4 w-4" /></NavLink>
|
|
{breadcrumbs.length > 0 && <ChevronRight className="h-4 w-4 mx-2 text-muted-foreground/40" />}
|
|
{breadcrumbs.map((crumb, i) => (
|
|
<div key={crumb.href} className="flex items-center">
|
|
{i > 0 && <ChevronRight className="h-4 w-4 mx-2 text-muted-foreground/40" />}
|
|
<span className={cn("transition-colors", i === breadcrumbs.length - 1 ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground")}>
|
|
{i === breadcrumbs.length - 1 ? crumb.title : <NavLink to={crumb.href}>{crumb.title}</NavLink>}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ==================== Layout ====================
|
|
|
|
export default function AdminLayout() {
|
|
const { sidebarOpen, mobileMenuOpen, toggleSidebar, setMobileMenu } = useAppStore()
|
|
const { user, logout, menus } = useAuthStore()
|
|
const navigate = useNavigate()
|
|
|
|
const navItems = useMemo(() => {
|
|
if (menus?.length) return menus.filter(m => m.category !== 2).map(convertMenuToNavItem)
|
|
return []
|
|
}, [menus])
|
|
|
|
const handleLogout = async () => { await logout(); navigate('/login') }
|
|
|
|
return <LayoutShell sidebarOpen={sidebarOpen} mobileMenuOpen={mobileMenuOpen} toggleSidebar={toggleSidebar} setMobileMenu={setMobileMenu} navItems={navItems} user={user} onLogout={handleLogout} />
|
|
}
|
|
|
|
// ==================== Shell Render ====================
|
|
|
|
import { useRef } from 'react'
|
|
|
|
function LayoutShell({ sidebarOpen, mobileMenuOpen, toggleSidebar, setMobileMenu, navItems, user, onLogout }: {
|
|
sidebarOpen: boolean; mobileMenuOpen: boolean; toggleSidebar: () => void; setMobileMenu: (v: boolean) => void
|
|
navItems: NavItem[]; user: import('@/api/system').SystemUser | null; onLogout: () => void
|
|
}) {
|
|
const { setCmdKOpen } = useAppStore()
|
|
const location = useLocation()
|
|
const spotlightRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (spotlightRef.current) {
|
|
spotlightRef.current.style.background = `radial-gradient(600px circle at ${e.clientX}px ${e.clientY}px, rgba(6,182,212,0.25), transparent 40%)`
|
|
}
|
|
}
|
|
window.addEventListener('mousemove', handleMouseMove)
|
|
return () => window.removeEventListener('mousemove', handleMouseMove)
|
|
}, [])
|
|
|
|
return (
|
|
<div className="flex min-h-screen bg-gradient-to-br from-slate-100 via-white to-cyan-50 dark:from-[#080d17] dark:via-[#080d17] dark:to-[#080d17] p-2 lg:p-3 gap-2 lg:gap-3 relative overflow-hidden">
|
|
|
|
{/* Interactive Cursor Spotlight (Illuminates the glass as you move the mouse) */}
|
|
<div
|
|
ref={spotlightRef}
|
|
className="pointer-events-none fixed inset-0 z-0 transition-opacity duration-300 opacity-100 dark:opacity-60"
|
|
style={{
|
|
background: `radial-gradient(600px circle at -1000px -1000px, rgba(6,182,212,0.25), transparent 40%)`
|
|
}}
|
|
/>
|
|
|
|
{/* Global Ambient Background */}
|
|
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
|
|
{/* Cyan Glow Top Left */}
|
|
<div className="absolute top-[-20%] left-[-10%] w-[50vw] h-[50vw] max-w-[800px] max-h-[800px] bg-cyan-200/40 dark:bg-cyan-600/20 rounded-full blur-[100px] dark:blur-[120px] mix-blend-multiply dark:mix-blend-screen" />
|
|
{/* Purple Glow Bottom Right */}
|
|
<div className="absolute bottom-[-20%] right-[-10%] w-[50vw] h-[50vw] max-w-[800px] max-h-[800px] bg-fuchsia-200/40 dark:bg-fuchsia-600/20 rounded-full blur-[100px] dark:blur-[120px] mix-blend-multiply dark:mix-blend-screen" />
|
|
{/* Subtle Tech Grid */}
|
|
<div className="absolute inset-0 opacity-[0.04] dark:opacity-[0.05]" style={{ backgroundImage: 'linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)', backgroundSize: '60px 60px' }} />
|
|
</div>
|
|
|
|
<CommandPalette />
|
|
{/* Sidebar */}
|
|
<aside className={cn(
|
|
'fixed inset-y-2 lg:inset-y-3 left-2 lg:left-3 z-40 flex flex-col rounded-2xl border border-white/60 dark:border-white/10 bg-white/60 dark:bg-black/40 backdrop-blur-2xl shadow-lg transition-all duration-300 overflow-hidden',
|
|
sidebarOpen ? 'w-[260px]' : 'w-20',
|
|
mobileMenuOpen ? 'translate-x-0' : '-translate-x-[120%] lg:translate-x-0'
|
|
)}>
|
|
{/* Logo */}
|
|
<div className="flex h-16 items-center justify-between px-5 shrink-0">
|
|
<div className={cn("flex items-center gap-3 overflow-hidden transition-all", !sidebarOpen && "justify-center w-full")}>
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white shadow-sm overflow-hidden border border-slate-200/60 dark:border-white/10">
|
|
<img src="/logo.png" alt="Logo" className="w-full h-full object-cover" />
|
|
</div>
|
|
{sidebarOpen && <span className="font-bold text-lg tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-emerald-600 to-teal-600 truncate">Sundynix Cloud</span>}
|
|
</div>
|
|
</div>
|
|
{/* Nav */}
|
|
<ScrollArea className="flex-1 px-3 py-4">
|
|
<nav className="space-y-0.5">
|
|
{navItems.map(item => <NavItemComponent key={item.href} item={item} collapsed={!sidebarOpen} />)}
|
|
</nav>
|
|
</ScrollArea>
|
|
{/* User */}
|
|
<div className="p-3 shrink-0 bg-sidebar-background/50 border-t border-white/10 dark:border-white/5">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className={cn("flex w-full items-center gap-3 rounded-xl p-2 transition-all duration-200 hover:bg-white/50 dark:hover:bg-white/5 outline-none", !sidebarOpen && "justify-center")}>
|
|
<Avatar className="h-9 w-9 ring-2 ring-white/80 dark:ring-white/10 shadow-sm">
|
|
<AvatarImage src={user?.avatar?.url} alt={user?.name || user?.account} />
|
|
<AvatarFallback className="bg-emerald-100 text-emerald-700 text-xs font-bold">{(user?.name || user?.account)?.charAt(0).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
{sidebarOpen && (
|
|
<div className="flex-1 text-left overflow-hidden">
|
|
<p className="text-sm font-semibold text-sidebar-foreground truncate leading-none mb-1.5">{user?.name || user?.account}</p>
|
|
<p className="text-[10px] text-muted-foreground truncate leading-none">管理员</p>
|
|
</div>
|
|
)}
|
|
{sidebarOpen && <ChevronDown className="h-4 w-4 text-muted-foreground/50" />}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-56" side="right" sideOffset={12}>
|
|
<DropdownMenuLabel className="font-normal">
|
|
<p className="text-sm font-medium leading-none">{user?.name}</p>
|
|
<p className="text-xs leading-none text-muted-foreground mt-1">{user?.account}</p>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={onLogout} className="text-destructive cursor-pointer hover:bg-destructive/10 focus:bg-destructive/10">
|
|
<LogOut className="mr-2 h-4 w-4" /> 退出登录
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Mobile overlay */}
|
|
{mobileMenuOpen && <div className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden animate-in fade-in duration-200" onClick={() => setMobileMenu(false)} />}
|
|
|
|
{/* Main */}
|
|
<div className={cn(
|
|
'relative z-10 flex-1 flex flex-col min-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1.5rem)] transition-all duration-300 bg-white/60 dark:bg-black/30 rounded-2xl border border-white/60 dark:border-white/10 shadow-xl overflow-hidden backdrop-blur-2xl',
|
|
sidebarOpen ? 'lg:ml-[268px]' : 'lg:ml-[88px]'
|
|
)}>
|
|
{/* Header */}
|
|
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-white/30 dark:border-white/5 bg-white/30 dark:bg-black/10 backdrop-blur-md px-4 lg:px-6">
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" size="icon" className="hidden lg:flex h-8 w-8 rounded-full hover:bg-white/50 dark:hover:bg-white/10" onClick={toggleSidebar}>
|
|
{sidebarOpen ? <ChevronLeft className="h-4 w-4 text-muted-foreground" /> : <Menu className="h-4 w-4 text-muted-foreground" />}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="lg:hidden h-8 w-8 rounded-full" onClick={() => setMobileMenu(!mobileMenuOpen)}>
|
|
<Menu className="h-4 w-4" />
|
|
</Button>
|
|
<div className="hidden md:flex items-center gap-3">
|
|
<div className="w-px h-4 bg-border/60 mx-1" />
|
|
<Breadcrumb />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="hidden md:flex relative group cursor-pointer" onClick={() => setCmdKOpen(true)}>
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground group-focus-within:text-primary transition-colors" />
|
|
<div className="flex items-center w-56 pl-9 pr-3 bg-white/50 dark:bg-black/20 border border-white/40 dark:border-white/10 hover:bg-white dark:hover:bg-slate-800 hover:border-primary/30 transition-all h-9 text-sm rounded-full shadow-sm text-muted-foreground">
|
|
<span className="flex-1 text-left">搜索菜单...</span>
|
|
<span className="text-[10px] border border-border px-1.5 rounded bg-background/50 leading-tight">⌘K</span>
|
|
</div>
|
|
</div>
|
|
<Button variant="ghost" size="icon" className="relative rounded-full h-9 w-9 hover:bg-white/50 dark:hover:bg-white/10"
|
|
onClick={() => document.documentElement.classList.toggle('dark')}>
|
|
<Sun className="h-4 w-4 text-muted-foreground block dark:hidden" />
|
|
<Moon className="h-4 w-4 text-muted-foreground hidden dark:block" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="relative rounded-full h-9 w-9 hover:bg-white/50 dark:hover:bg-white/10">
|
|
<Bell className="h-4 w-4 text-muted-foreground" />
|
|
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-red-500 rounded-full ring-2 ring-white dark:ring-slate-900" />
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
{/* TabBar */}
|
|
<TabBar />
|
|
{/* Content */}
|
|
<ScrollArea className="flex-1">
|
|
<main className="p-4 lg:p-6 relative z-0 min-h-full">
|
|
<div className="mx-auto w-full max-w-[1600px] space-y-6">
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={location.pathname}
|
|
initial={{ opacity: 0, y: 15 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -15 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<Outlet />
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</main>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|