feat(ui): AppShell layout route — desktop rail, tablet icon rail, mobile tabs, theme toggle

This commit is contained in:
2026-09-08 17:35:14 +08:00
parent 913f3286bb
commit 7957e5cee5
3 changed files with 170 additions and 23 deletions
+131
View File
@@ -0,0 +1,131 @@
import { useQuery } from "@tanstack/react-query";
import { BookMarked, BookOpen, FolderCog, LogOut, Users } from "lucide-react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { api } from "@/api/client";
import { useAuth } from "@/auth/AuthContext";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "./theme-toggle";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
function Brand({ compact }: { compact?: boolean }) {
return (
<NavLink to="/" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2">
<span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
<BookMarked className="size-4" />
</span>
{!compact && <span className="text-[15px] font-semibold tracking-tight">BookLib</span>}
</NavLink>
);
}
function NavItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) {
return (
<NavLink
to={to + (search ?? "")}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground",
isActive && "bg-secondary font-medium text-foreground",
)
}
>
{icon}
<span className="hidden lg:inline">{label}</span>
</NavLink>
);
}
export function AppShell() {
const { user, isAdmin, logout } = useAuth();
const loc = useLocation();
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const libs = libsQ.data ?? [];
const account = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-2">
<span className="grid size-7 place-items-center rounded-full bg-accent/15 text-xs font-semibold text-accent">
{Array.from(user?.username ?? "?")[0]?.toUpperCase()}
</span>
<span className="hidden max-w-28 truncate text-sm lg:inline">{user?.username}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<span className="cursor-pointer" onClick={logout}>
<LogOut /> 退出登录
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<div className="flex h-full">
{/* 桌面/平板侧栏;lg 全文标签,md 图标栏 */}
<aside className="flex w-14 shrink-0 flex-col gap-1 border-r bg-card/60 p-2 lg:w-60">
<Brand compact />
<nav aria-label="主导航" className="flex flex-col gap-1">
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" />
{isAdmin && (
<>
<NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" />
<NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" />
</>
)}
</nav>
{libs.length > 0 && (
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex">
<p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">书库</p>
<div className="min-h-0 flex-1 overflow-y-auto">
<NavItem to="/" search="?lib=0" icon={<span className="size-1.5 shrink-0 rounded-full bg-muted-foreground/50" />} label="全部" />
{libs.map((l) => (
<NavItem key={l.id} to="/" search={`?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} />
))}
</div>
</div>
)}
<Separator className="my-2 hidden lg:block" />
<div className="mt-auto hidden items-center justify-between px-1 lg:flex">
<ThemeToggle />
{account}
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* 移动端顶栏 */}
<header className="flex h-12 shrink-0 items-center justify-between border-b bg-card/60 px-3 lg:hidden">
<Brand />
<div className="flex items-center gap-1">
<ThemeToggle />
{account}
</div>
</header>
<main key={loc.pathname} className="min-h-0 flex-1 overflow-y-auto">
<Outlet />
</main>
{/* 移动端底 Tab */}
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-card/90 backdrop-blur md:hidden">
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" search="?lib=0" />
{isAdmin && <TabItem to="/admin/libraries" icon={<FolderCog className="size-5" />} label="库管理" />}
{isAdmin && <TabItem to="/admin/users" icon={<Users className="size-5" />} label="用户" />}
</nav>
</div>
</div>
);
}
function TabItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) {
return (
<NavLink to={to + (search ?? "")} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent")}>
{icon}
{label}
</NavLink>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Check, Monitor, Moon, Sun } from "lucide-react";
import { useTheme, type Theme } from "./theme";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
{ v: "light", label: "浅色", icon: Sun },
{ v: "dark", label: "深色", icon: Moon },
{ v: "system", label: "跟随系统", icon: Monitor },
];
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="切换主题" aria-pressed="false">
{theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{ITEMS.map(({ v, label, icon: Icon }) => (
<DropdownMenuItem key={v} onClick={() => setTheme(v)}>
<Icon />
{label}
{theme === v && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}