fix(deploy): pin distinct image tags per compose (booklib/{api,web}:{dev,prod}); same project name shared default tags so dev up without --build ran the prod image
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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 [root, setRoot] = useState("");
|
||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.createLibrary(name.trim(), root.trim()),
|
||||
onSuccess: (lib) => {
|
||||
toast("ok", `库「${lib.name}」已创建`);
|
||||
setName("");
|
||||
setRoot("");
|
||||
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} placeholder="库名(如 comics)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input
|
||||
className={input + " flex-1"}
|
||||
placeholder="root_path(服务端绝对路径,须位于 BOOKS_DIR 下,如 /data/books/comics)"
|
||||
value={root}
|
||||
onChange={(e) => setRoot(e.target.value)}
|
||||
/>
|
||||
<button className={btnPrimary} disabled={create.isPending || !name.trim() || !root.startsWith("/")}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user