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:
2026-09-07 21:24:33 +08:00
parent 95317a0108
commit 961de429f9
45 changed files with 7 additions and 3 deletions
+107
View File
@@ -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>
);
}