init: initial commit
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
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,
|
||||
} 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 {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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" />,
|
||||
}
|
||||
|
||||
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?.map(convertMenuToNavItem),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 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.map(convertMenuToNavItem)
|
||||
return [{ title: '仪表盘', href: '/dashboard', icon: <LayoutDashboard className="h-4 w-4" /> }]
|
||||
}, [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 ====================
|
||||
|
||||
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
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background p-2 lg:p-3 gap-2 lg:gap-3">
|
||||
{/* 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/40 dark:border-white/5 glass-panel 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-gradient-to-br from-emerald-500 to-teal-500 text-white shadow-lg shadow-emerald-500/20">
|
||||
<Leaf className="h-4 w-4" />
|
||||
</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 Console</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(
|
||||
'flex-1 flex flex-col min-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1.5rem)] transition-all duration-300 bg-white/40 dark:bg-slate-900/40 rounded-2xl border border-white/50 dark:border-white/5 shadow-soft overflow-hidden relative backdrop-blur-xl',
|
||||
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">
|
||||
<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-emerald-500 transition-colors" />
|
||||
<Input placeholder="全局搜索..." className="w-56 focus:w-72 pl-9 bg-white/50 dark:bg-black/20 border-white/40 dark:border-white/10 focus:bg-white dark:focus:bg-slate-800 focus:border-emerald-500/30 transition-all h-9 text-sm rounded-full shadow-sm" />
|
||||
</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>
|
||||
{/* 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] animate-fade-in-up space-y-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user