码桶
发现社区成员的开源项目
AppShell.tsx8.9 KB
import { useState, useEffect, type ReactNode } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Store, User, Menu, PanelLeftClose, PanelLeftOpen, CalendarClock, Puzzle, Sparkles, Github, Bot, Sun, Moon, Monitor, Settings, LogOut, Terminal, Globe, BarChart3 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
import { useAuth } from '@/lib/auth'
import { useSite } from '@/lib/site'
import { useTheme } from '@/lib/theme'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
const NAV = [
{ to: '/', label: '控制台', icon: LayoutDashboard },
{ to: '/market', label: '插件市场', icon: Store },
{ to: '/mcp', label: 'MCP 管理', icon: Puzzle },
{ to: '/skill', label: '技能管理', icon: Sparkles },
{ to: '/automation', label: '自动化设置', icon: CalendarClock },
{ to: '/smart', label: '智能助手', icon: Bot },
{ to: '/logs', label: '日志控制台', icon: Terminal },
{ to: '/proxy', label: '代理设置', icon: Globe },
{ to: '/stats', label: '系统统计', icon: BarChart3 },
{ to: '/settings', label: 'AI 配置', icon: Settings },
{ to: '/profile', label: '个人中心', icon: User },
]
export function AppShell({ children }: { children: ReactNode }) {
const { me, refresh } = useAuth()
const site = useSite()
const { theme, setTheme } = useTheme()
const location = useLocation()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const [collapsed, setCollapsed] = useState(() => {
try { return localStorage.getItem('sidebar-collapsed') === '1' } catch { return false }
})
function toggleCollapse() {
setCollapsed(c => {
const next = !c
try { localStorage.setItem('sidebar-collapsed', next ? '1' : '0') } catch { /* ignore */ }
return next
})
}
function SidebarContent({ collapsed = false }: { collapsed?: boolean }) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className={cn('flex h-14 items-center gap-2 px-5 font-bold text-[15px]', collapsed && 'justify-center px-0')}>
{site.site_icon ? (
<img src={site.site_icon} alt="logo" className="h-[26px] w-[26px] shrink-0 rounded object-contain" />
) : (
<img src="https://cdn.naigou.cn/logo.png" alt="logo" className="h-[26px] w-[26px] shrink-0 object-contain" />
)}
{!collapsed && <span>{site.site_name}</span>}
</div>
<nav className="flex-1 space-y-1 px-3 py-2">
{NAV.map((item) => {
const active = item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to)
return (
<Link
key={item.to}
to={item.to}
onClick={() => setOpen(false)}
title={collapsed ? item.label : undefined}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
collapsed && 'justify-center px-0',
active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)}
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
{!collapsed && item.label}
</Link>
)
})}
</nav>
<div className="border-t p-3 text-xs text-muted-foreground space-y-2">
{collapsed ? (
<>
<div className="flex justify-center">
<a href={site.github} target="_blank" rel="noreferrer" title="GitHub 开源仓库">
<Github className="h-4 w-4 transition-colors hover:text-foreground" />
</a>
</div>
</>
) : (
<>
<a
href={site.github}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 px-2 transition-colors hover:text-foreground"
>
<Github className="h-4 w-4" />
GitHub 开源仓库
</a>
</>
)}
</div>
</div>
)
}
return (
<div className="flex min-h-screen bg-muted/30">
{/* 桌面侧栏(可折叠) */}
<aside className={cn('hidden shrink-0 flex-col border-r bg-background transition-[width] duration-200 md:sticky md:top-0 md:h-screen md:self-start md:flex', collapsed ? 'w-16' : 'w-60')}>
<SidebarContent collapsed={collapsed} />
<button
onClick={toggleCollapse}
className="flex items-center gap-2 border-t p-3 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
title={collapsed ? '展开侧栏' : '收起侧栏'}
>
{collapsed ? <PanelLeftOpen className="mx-auto h-4 w-4" /> : (<><PanelLeftClose className="h-4 w-4" /><span>收起侧栏</span></>)}
</button>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* 顶栏 */}
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b bg-background/95 px-3 backdrop-blur md:px-5">
<div className="flex items-center gap-2">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-60 p-0">
<SheetHeader className="sr-only">
<SheetTitle>导航</SheetTitle>
</SheetHeader>
<SidebarContent collapsed={false} />
</SheetContent>
</Sheet>
<span className="text-sm font-semibold">{site.site_name} 控制台</span>
</div>
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9" title="主题切换">
{theme === 'dark' ? <Moon className="h-[18px] w-[18px]" /> : theme === 'light' ? <Sun className="h-[18px] w-[18px]" /> : <Monitor className="h-[18px] w-[18px]" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="h-4 w-4" /> 浅色
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="h-4 w-4" /> 暗色
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<Monitor className="h-4 w-4" /> 跟随系统
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{me && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-full outline-none">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary text-primary-foreground text-xs">
{me.username.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium sm:inline">{me.username}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>{me.username}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="h-4 w-4" /> 个人中心
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
try { await api.post('/api/logout') } catch { /* ignore */ }
await refresh()
navigate('/login')
}}
>
<LogOut className="h-4 w-4" /> 退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
<main className="min-w-0 flex-1">{children}</main>
</div>
</div>
)
}