init: initial commit

This commit is contained in:
Blizzard
2026-04-27 17:12:13 +08:00
commit 9af7fe7f37
81 changed files with 11646 additions and 0 deletions
+306
View File
@@ -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>
)
}