feat(web): table-style 库管理, admin entries under avatar 系统管理, shelf-view upload button
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { Book } from "@/api/types";
|
||||
import { useAuth } from "@/auth/AuthContext";
|
||||
import { Cover } from "@/components/Cover";
|
||||
import { toast } from "@/components/Toaster";
|
||||
import { UploadButton } from "@/components/upload-button";
|
||||
import { formatBadge } from "@/components/ui";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
@@ -177,10 +178,13 @@ export default function Shelf() {
|
||||
))}
|
||||
</div>
|
||||
{isAdmin && lib > LIB0 && (
|
||||
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5">
|
||||
<ScanLine className="size-4" />
|
||||
{scan.isPending ? "扫描中…" : "扫描此库"}
|
||||
</Button>
|
||||
<>
|
||||
<UploadButton libId={lib} variant="outline" label="上传文件" />
|
||||
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5">
|
||||
<ScanLine className="size-4" />
|
||||
{scan.isPending ? "扫描中…" : "扫描此库"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" aria-label="刷新书目" onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
|
||||
<RefreshCw className="size-4" />
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef, useState, type DragEvent, type FormEvent } from "react";
|
||||
import { RefreshCw, Upload } from "lucide-react";
|
||||
import { useState, type DragEvent, type FormEvent } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { api } from "../../api/client";
|
||||
import { toast } from "../../components/Toaster";
|
||||
import { UploadButton, uploadToLib } from "@/components/upload-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
export default function AdminLibraries() {
|
||||
const qc = useQueryClient();
|
||||
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||||
const [name, setName] = useState("");
|
||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.createLibrary(name.trim()),
|
||||
@@ -32,22 +32,9 @@ export default function AdminLibraries() {
|
||||
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) {
|
||||
e.preventDefault();
|
||||
void onFiles(libId, e.dataTransfer.files);
|
||||
void uploadToLib(qc, libId, Array.from(e.dataTransfer.files));
|
||||
}
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
@@ -65,49 +52,40 @@ export default function AdminLibraries() {
|
||||
{create.isPending ? "创建中…" : "建库"}
|
||||
</Button>
|
||||
</form>
|
||||
{libsQ.isPending && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 w-full" />
|
||||
))}
|
||||
</div>
|
||||
{libsQ.isPending ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : libs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">还没有库,用上方表单创建。</p>
|
||||
) : (
|
||||
<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">
|
||||
{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>
|
||||
<p className="mt-3 text-xs text-muted-foreground">上传白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进行</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user