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
+3
View File
@@ -31,6 +31,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
### Changed / 变更 ### Changed / 变更
- Admin polish: 库管理 is now a table styled like 用户管理 (scan/upload per row, drag-to-row upload kept); both admin pages moved out of the sidebar and bottom tabs into a 系统管理 group under the avatar menu; admins can upload files straight from a library's shelf view via a new 上传文件 button next to 扫描此库.
- 后台入口统一:库管理改为用户管理同款表格(行内扫描/上传,仍支持拖文件到行);两个管理页从侧边栏与移动端底 Tab 收进头像菜单的「系统管理」分组;书架进入某个库时,管理员可在「扫描此库」旁直接「上传文件」。
- Reader chrome is no longer text-only: lucide icons accompany 导航/上一章/下一章/翻页/抽屉选项卡/书签操作, while 连读 and 预读 (now a 0–3 slider) collapse into a 更多 menu in the CBZ bar. - Reader chrome is no longer text-only: lucide icons accompany 导航/上一章/下一章/翻页/抽屉选项卡/书签操作, while 连读 and 预读 (now a 0–3 slider) collapse into a 更多 menu in the CBZ bar.
- 阅读器工具条告别纯文字:导航/上一章/下一章/翻页/抽屉选项卡/书签操作均配 lucide 图标;连读与预读(改为 0–3 滑条)收进 CBZ 工具条的「更多」菜单。 - 阅读器工具条告别纯文字:导航/上一章/下一章/翻页/抽屉选项卡/书签操作均配 lucide 图标;连读与预读(改为 0–3 滑条)收进 CBZ 工具条的「更多」菜单。
+16 -8
View File
@@ -58,6 +58,22 @@ export function AppShell() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel> <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 /> <DropdownMenuSeparator />
<DropdownMenuItem onSelect={logout}> <DropdownMenuItem onSelect={logout}>
<LogOut /> 退出登录 <LogOut /> 退出登录
@@ -73,12 +89,6 @@ export function AppShell() {
<Brand /> <Brand />
<nav aria-label="主导航" className="flex flex-col gap-1"> <nav aria-label="主导航" className="flex flex-col gap-1">
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" active={loc.pathname === "/" && !libParam} /> <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> </nav>
{libs.length > 0 && ( {libs.length > 0 && (
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex"> <div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex">
@@ -112,8 +122,6 @@ export function AppShell() {
{/* 移动端底 Tab */} {/* 移动端底 Tab */}
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden"> <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="书架" /> <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> </nav>
</div> </div>
</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>
</>
);
}
+8 -4
View File
@@ -7,6 +7,7 @@ import type { Book } from "@/api/types";
import { useAuth } from "@/auth/AuthContext"; import { useAuth } from "@/auth/AuthContext";
import { Cover } from "@/components/Cover"; import { Cover } from "@/components/Cover";
import { toast } from "@/components/Toaster"; import { toast } from "@/components/Toaster";
import { UploadButton } from "@/components/upload-button";
import { formatBadge } from "@/components/ui"; import { formatBadge } from "@/components/ui";
import { import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
@@ -177,10 +178,13 @@ export default function Shelf() {
))} ))}
</div> </div>
{isAdmin && lib > LIB0 && ( {isAdmin && lib > LIB0 && (
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5"> <>
<ScanLine className="size-4" /> <UploadButton libId={lib} variant="outline" label="上传文件" />
{scan.isPending ? "扫描中…" : "扫描此库"} <Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5">
</Button> <ScanLine className="size-4" />
{scan.isPending ? "扫描中…" : "扫描此库"}
</Button>
</>
)} )}
<Button variant="ghost" size="icon" aria-label="刷新书目" onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}> <Button variant="ghost" size="icon" aria-label="刷新书目" onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
<RefreshCw className="size-4" /> <RefreshCw className="size-4" />
+38 -60
View File
@@ -1,18 +1,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRef, useState, type DragEvent, type FormEvent } from "react"; import { useState, type DragEvent, type FormEvent } from "react";
import { RefreshCw, Upload } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { api } from "../../api/client"; import { api } from "../../api/client";
import { toast } from "../../components/Toaster"; import { toast } from "../../components/Toaster";
import { UploadButton, uploadToLib } from "@/components/upload-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export default function AdminLibraries() { export default function AdminLibraries() {
const qc = useQueryClient(); const qc = useQueryClient();
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const [name, setName] = useState(""); const [name, setName] = useState("");
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
const create = useMutation({ const create = useMutation({
mutationFn: () => api.createLibrary(name.trim()), mutationFn: () => api.createLibrary(name.trim()),
@@ -32,22 +32,9 @@ export default function AdminLibraries() {
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"), onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
}); });
async function onFiles(libId: number, list: FileList | null) {
const files = Array.from(list ?? []); // FileList 活引用,先快照
if (!files.length) return;
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);
} catch (e) {
toast("err", e instanceof Error ? e.message : "上传失败");
}
}
function onDrop(e: DragEvent, libId: number) { function onDrop(e: DragEvent, libId: number) {
e.preventDefault(); e.preventDefault();
void onFiles(libId, e.dataTransfer.files); void uploadToLib(qc, libId, Array.from(e.dataTransfer.files));
} }
function submit(e: FormEvent) { function submit(e: FormEvent) {
@@ -65,49 +52,40 @@ export default function AdminLibraries() {
{create.isPending ? "创建中…" : "建库"} {create.isPending ? "创建中…" : "建库"}
</Button> </Button>
</form> </form>
{libsQ.isPending && ( {libsQ.isPending ? (
<div className="space-y-3"> <Skeleton className="h-40 w-full" />
{Array.from({ length: 3 }).map((_, i) => ( ) : libs.length === 0 ? (
<Skeleton key={i} className="h-28 w-full" /> <p className="text-sm text-muted-foreground">还没有库,用上方表单创建。</p>
))} ) : (
</div> <Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead>库名</TableHead>
<TableHead>服务端路径</TableHead>
<TableHead className="w-44" />
</TableRow>
</TableHeader>
<TableBody>
{libs.map((l) => (
<TableRow key={l.id} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}>
<TableCell className="text-muted-foreground">{l.id}</TableCell>
<TableCell className="font-medium">{l.name}</TableCell>
<TableCell className="text-muted-foreground">{l.root_path}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
<RefreshCw /> 扫描
</Button>
<UploadButton libId={l.id} variant="ghost" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)} )}
<div className="flex flex-col gap-4"> <p className="mt-3 text-xs text-muted-foreground">上传白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进行</p>
{libs.map((l) => (
<Card key={l.id} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}>
<CardHeader className="flex-row items-baseline justify-between gap-3 space-y-0 py-3">
<CardTitle className="flex items-baseline gap-2 text-base">
{l.name}
<span className="text-xs font-normal text-muted-foreground">#{l.id}</span>
</CardTitle>
<p className="text-xs text-muted-foreground">{l.root_path}</p>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2 pt-0">
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
<RefreshCw /> {scan.isPending ? "扫描中…" : "扫描"}
</Button>
<input
ref={(el) => {
fileRefs.current[l.id] = el;
}}
type="file"
multiple
accept=".cbz,.zip,.pdf,.epub,.txt,.md"
className="hidden"
onChange={(e) => {
void onFiles(l.id, e.target.files);
e.target.value = "";
}}
/>
<Button size="sm" onClick={() => fileRefs.current[l.id]?.click()}>
<Upload /> 上传文件
</Button>
<span className="text-xs text-muted-foreground">白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进卡片</span>
</CardContent>
</Card>
))}
{libs.length === 0 && !libsQ.isPending && <p className="text-sm text-muted-foreground">还没有库,用上方表单创建。</p>}
</div>
</div> </div>
); );
} }