码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
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>
  )
}