feat(web): table-style 库管理, admin entries under avatar 系统管理, shelf-view upload button

This commit is contained in:
2026-09-08 21:45:03 +08:00
parent 3178c2802f
commit fd227c1f00
5 changed files with 124 additions and 72 deletions
+16 -8
View File
@@ -58,6 +58,22 @@ export function AppShell() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>系统管理</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to="/admin/libraries">
<FolderCog /> 库管理
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/users">
<Users /> 用户管理
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={logout}>
<LogOut /> 退出登录
@@ -73,12 +89,6 @@ export function AppShell() {
<Brand />
<nav aria-label="主导航" className="flex flex-col gap-1">
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" active={loc.pathname === "/" && !libParam} />
{isAdmin && (
<>
<NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" active={loc.pathname.startsWith("/admin/libraries")} />
<NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" active={loc.pathname.startsWith("/admin/users")} />
</>
)}
</nav>
{libs.length > 0 && (
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex">
@@ -112,8 +122,6 @@ export function AppShell() {
{/* 移动端底 Tab */}
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden">
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" />
{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>
+59
View File
@@ -0,0 +1,59 @@
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useRef, useState, type ComponentProps } from "react";
import { Upload } from "lucide-react";
import { api } from "@/api/client";
import { Button } from "@/components/ui/button";
import { toast } from "./Toaster";
export async function uploadToLib(qc: QueryClient, libId: number, list: FileList | File[]): Promise<boolean> {
const files = Array.from(list); // FileList 活引用,先快照
if (!files.length) return false;
try {
for (const f of files) await api.uploadBook(libId, f);
toast("ok", `已上传 ${files.length} 个文件,等待 scanner 收编`);
void qc.invalidateQueries({ queryKey: ["books"] });
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
return true;
} catch (e) {
toast("err", e instanceof Error ? e.message : "上传失败");
return false;
}
}
/** 上传按钮(隐藏 file input),白名单:cbz/zip/pdf/epub/txt/md. */
export function UploadButton({
libId,
variant,
size = "sm",
label = "上传",
}: {
libId: number;
variant?: ComponentProps<typeof Button>["variant"];
size?: ComponentProps<typeof Button>["size"];
label?: string;
}) {
const qc = useQueryClient();
const ref = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
return (
<>
<input
ref={ref}
type="file"
multiple
accept=".cbz,.zip,.pdf,.epub,.txt,.md"
className="hidden"
onChange={async (e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
setBusy(true);
await uploadToLib(qc, libId, files);
setBusy(false);
}}
/>
<Button variant={variant} size={size} disabled={busy} onClick={() => ref.current?.click()}>
<Upload /> {busy ? "上传中…" : label}
</Button>
</>
);
}