105 lines
4.2 KiB
TypeScript
105 lines
4.2 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useRef, useState, type FormEvent } from "react";
|
|
import { api } from "../../api/client";
|
|
import { TopBar } from "../../components/TopBar";
|
|
import { toast } from "../../components/Toaster";
|
|
import { btn, btnPrimary, card, input } from "../../components/ui";
|
|
|
|
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()),
|
|
onSuccess: (lib) => {
|
|
toast("ok", `库「${lib.name}」已创建`);
|
|
setName("");
|
|
void qc.invalidateQueries({ queryKey: ["libraries"] });
|
|
},
|
|
onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"),
|
|
});
|
|
const scan = useMutation({
|
|
mutationFn: (id: number) => api.scanLibrary(id),
|
|
onSuccess: () => {
|
|
toast("ok", "扫描已触发");
|
|
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 3000);
|
|
},
|
|
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
|
|
});
|
|
|
|
async function onFiles(libId: number, list: FileList | null) {
|
|
if (!list?.length) return;
|
|
try {
|
|
for (const f of Array.from(list)) await api.uploadBook(libId, f); // 串行,单个失败即止并报错
|
|
toast("ok", `已上传 ${list.length} 个文件,等待 scanner 收编`);
|
|
void qc.invalidateQueries({ queryKey: ["books"] });
|
|
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
|
|
} catch (e) {
|
|
toast("err", e instanceof Error ? e.message : "上传失败");
|
|
}
|
|
}
|
|
|
|
function submit(e: FormEvent) {
|
|
e.preventDefault();
|
|
create.mutate();
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<TopBar />
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
<h1 className="mb-4 text-lg font-semibold">库管理</h1>
|
|
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
|
<input
|
|
className={input + " flex-1"}
|
|
placeholder="库名(如 comics,服务端目录自动落在 BOOKS_DIR/<库名> 下)"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
<button className={btnPrimary} disabled={create.isPending || !name.trim()}>
|
|
{create.isPending ? "创建中…" : "建库"}
|
|
</button>
|
|
</form>
|
|
<div className="flex max-w-2xl flex-col gap-4">
|
|
{(libsQ.data ?? []).map((l) => (
|
|
<div key={l.id} className={`${card} p-4`}>
|
|
<div className="mb-1 flex items-baseline gap-3">
|
|
<span className="font-medium">{l.name}</span>
|
|
<span className="text-xs text-zinc-500">#{l.id}</span>
|
|
</div>
|
|
<p className="mb-3 text-xs break-all text-zinc-500">{l.root_path}</p>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
|
|
{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 className={btn} onClick={() => fileRefs.current[l.id]?.click()}>
|
|
上传文件
|
|
</button>
|
|
<span className="text-xs text-zinc-600">白名单:cbz/zip/pdf/epub/txt/md</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
|
|
<p className="text-sm text-zinc-500">还没有库,用上方表单创建。</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|