kimi2.6.0 / web /src /components /layout /AppLayout.tsx
heyeyydss's picture
Upload 120 files
d18adc9 verified
Raw
History Blame Contribute Delete
10.1 kB
import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom"
import {
LayoutDashboard,
Key,
ShieldCheck,
ClipboardList,
LogOut,
ChevronDown,
User,
Palette,
Check,
} from "lucide-react"
import { useAuth } from "@/hooks/use-auth"
import { useDashboardTheme } from "@/components/theme/theme-context"
import { LogoMark } from "@/components/shared/LogoMark"
import { buttonVariants } from "@/components/ui/button-variants"
import { cn } from "@/lib/utils"
import { useState, useRef, useEffect, type SVGProps } from "react"
const GITHUB_REPO_URL = "https://github.com/chopper1026/kimi2api"
const navItems = [
{ to: "/admin/dashboard", label: "概览", icon: LayoutDashboard },
{ to: "/admin/token", label: "账号管理", icon: ShieldCheck },
{ to: "/admin/keys", label: "API Keys", icon: Key },
{ to: "/admin/logs", label: "请求日志", icon: ClipboardList },
]
const pageTitles: Record<string, string> = {
"/admin/dashboard": "概览",
"/admin/token": "账号管理",
"/admin/keys": "API Keys",
"/admin/logs": "请求日志",
}
function GitHubMark(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.09.68-.22.68-.49 0-.24-.01-1.04-.01-1.89-2.51.47-3.16-.63-3.36-1.2-.11-.29-.6-1.2-1.03-1.45-.35-.2-.85-.69-.01-.7.79-.01 1.35.74 1.54 1.05.9 1.55 2.34 1.11 2.91.85.09-.67.35-1.11.64-1.37-2.22-.26-4.55-1.14-4.55-5.06 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.28 9.28 0 0 1 12 7.01c.85 0 1.71.12 2.51.34 1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.93-2.34 4.8-4.57 5.06.36.32.68.93.68 1.89 0 1.37-.01 2.47-.01 2.81 0 .27.18.59.69.49A10.17 10.17 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z" />
</svg>
)
}
function UserMenu({ onLogout }: { onLogout: () => void }) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const { mode, theme, options, setMode } = useDashboardTheme()
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handler)
return () => document.removeEventListener("mousedown", handler)
}, [open])
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<div className="flex size-7 items-center justify-center rounded-full bg-primary/10 text-primary">
<User className="size-3.5" />
</div>
<span className="hidden sm:inline">Admin</span>
<ChevronDown className="size-3.5 text-muted-foreground" />
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 min-w-[230px] overflow-hidden rounded-lg border border-border bg-popover py-1 shadow-xl">
<div className="px-2 py-2">
<div className="mb-1 flex items-center gap-1.5 px-2 text-[11px] font-medium text-muted-foreground">
<Palette className="size-3.5" />
主题
</div>
<div className="space-y-0.5">
{options.map((option) => {
const selected = option.mode === mode
const description =
option.mode === "system"
? `当前:${theme.appearance === "dark" ? "深色" : "浅色"}`
: option.description
return (
<button
key={option.mode}
type="button"
onClick={() => setMode(option.mode)}
className={cn(
"flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left transition-colors",
"text-popover-foreground hover:bg-accent",
selected && "bg-accent text-accent-foreground",
)}
>
<span>
<span className="block text-xs font-medium">
{option.label}
</span>
<span className="block text-[10px] text-muted-foreground">
{description}
</span>
</span>
{selected && <Check className="size-3.5 text-primary" />}
</button>
)
})}
</div>
</div>
<div className="border-t border-border" />
<button
onClick={() => {
setOpen(false)
onLogout()
}}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-destructive transition-colors hover:bg-destructive/10"
>
<LogOut className="size-4" />
退出登录
</button>
</div>
)}
</div>
)
}
export default function AppLayout() {
const { isAuthenticated, isLoading, logout } = useAuth()
const navigate = useNavigate()
const location = useLocation()
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-muted-foreground text-sm">加载中...</div>
</div>
)
}
if (!isAuthenticated) {
navigate("/admin/login", { replace: true })
return null
}
const handleLogout = async () => {
await logout()
navigate("/admin/login", { replace: true })
}
const title =
pageTitles[location.pathname] ||
(location.pathname.startsWith("/admin/logs/") ? "日志详情" : "Kimi2API")
return (
<div
data-admin-shell
className="admin-shell-enter flex min-h-dvh flex-col bg-background md:h-screen md:flex-row"
>
{/* Sidebar */}
<aside className="hidden w-60 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground md:flex">
{/* Brand */}
<div className="flex items-center gap-3 px-5 py-5">
<LogoMark className="size-8" />
<div>
<h1 className="text-sm font-semibold tracking-tight">
Kimi2API
</h1>
<p className="text-[11px] text-sidebar-foreground/50">
管理控制台
</p>
</div>
</div>
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-0.5 px-3 pt-2">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium transition-all ${
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground ring-1 ring-sidebar-ring/15"
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/55 hover:text-sidebar-foreground"
}`
}
>
<item.icon className="size-4" />
{item.label}
</NavLink>
))}
</nav>
{/* Sidebar footer */}
<div className="border-t border-sidebar-border p-3">
<button
onClick={handleLogout}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent/55 hover:text-sidebar-foreground"
>
<LogOut className="size-4" />
退出登录
</button>
</div>
</aside>
{/* Main area */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Top bar */}
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border bg-background/80 px-4 md:px-6">
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
{title}
</h2>
<div className="flex shrink-0 items-center gap-1.5">
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noreferrer"
aria-label="打开 GitHub 项目"
title="GitHub 项目"
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"size-10 text-muted-foreground hover:text-foreground",
)}
>
<GitHubMark className="size-6" />
</a>
<UserMenu onLogout={handleLogout} />
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-auto p-4 pb-24 md:p-6">
<div
key={location.pathname}
data-route-content
className="admin-route-content min-h-full"
>
<Outlet />
</div>
</main>
</div>
<nav
aria-label="移动端导航"
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 px-2 pb-[calc(env(safe-area-inset-bottom)+0.5rem)] pt-2 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden"
>
<div className="grid grid-cols-4 gap-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex min-h-12 flex-col items-center justify-center gap-1 rounded-lg px-1 text-[11px] font-medium transition-colors ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`
}
>
<item.icon className="size-4" />
<span className="truncate">{item.label}</span>
</NavLink>
))}
</div>
</nav>
</div>
)
}