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
+42
View File
@@ -0,0 +1,42 @@
import { useState, type FormEvent } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { toast } from "../components/Toaster";
import { btnPrimary, card, input } from "../components/ui";
export default function Login() {
const { login, user } = useAuth();
const loc = useLocation() as { state?: { from?: string } };
const [u, setU] = useState("");
const [p, setP] = useState("");
const [busy, setBusy] = useState(false);
if (user) return <Navigate to={loc.state?.from ?? "/"} replace />;
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
try {
await login(u, p);
} catch (err) {
toast("err", err instanceof Error ? err.message : "登录失败");
} finally {
setBusy(false);
}
}
return (
<div className="grid h-full place-items-center p-4">
<form onSubmit={submit} className={`${card} w-full max-w-sm space-y-4 p-6`}>
<h1 className="text-lg font-semibold">BookLib 登录</h1>
<input className={input + " w-full"} value={u} onChange={(e) => setU(e.target.value)}
placeholder="用户名" autoFocus autoComplete="username" />
<input className={input + " w-full"} type="password" value={p} onChange={(e) => setP(e.target.value)}
placeholder="密码" autoComplete="current-password" />
<button className={btnPrimary + " w-full"} disabled={busy || !u || !p}>
{busy ? "登录中…" : "登录"}
</button>
</form>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Suspense, lazy, type ComponentType, type LazyExoticComponent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { Format } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { toast } from "../components/Toaster";
import { btn } from "../components/ui";
import type { ReaderProps } from "../lib/useProgress";
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
cbz: lazy(() => import("../readers/CbzReader")),
txt: lazy(() => import("../readers/TextReader")),
md: lazy(() => import("../readers/TextReader")),
pdf: lazy(() => import("../readers/PdfReader")),
epub: lazy(() => import("../readers/EpubReader")),
};
export default function Reader() {
const { id: idStr } = useParams();
const id = Number(idStr);
const qc = useQueryClient();
const nav = useNavigate();
const { isAdmin } = useAuth();
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
const del = useMutation({
mutationFn: () => api.deleteBook(id),
onSuccess: () => {
toast("ok", "已删除");
qc.invalidateQueries({ queryKey: ["books"] });
nav("/");
},
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
});
const book = bookQ.data;
const body = (() => {
if (!Number.isFinite(id)) return <Msg text="无效的书 id" />;
if (bookQ.isPending) return <Msg text="加载中…" />;
if (bookQ.isError)
return (
<Msg
text={"加载失败:" + (bookQ.error instanceof Error ? bookQ.error.message : "")}
retry={() => bookQ.refetch()}
/>
);
if (!book) return <Msg text="未找到这本书" />;
if (book.state === "error") return <Msg text={`文件损坏:${book.error}`} />;
if (progQ.isPending) return <Msg text="恢复进度…" />;
const R = READERS[book.format];
if (!R) return <Msg text={`暂不支持的格式:${book.format}`} />;
const row = (progQ.data ?? []).find((r) => r.library_id === book.library_id && r.path === book.path);
return <R book={book} initialLocator={row?.locator} />;
})();
return (
<div className="flex h-full flex-col">
<header className="flex shrink-0 items-center gap-3 border-b border-zinc-800 px-4 py-2 text-sm">
<Link to="/" className="text-zinc-400 hover:text-zinc-100">
← 书架
</Link>
<span className="truncate font-medium">{book?.title ?? "…"}</span>
<span className="text-zinc-500">
{book?.library ? book.library + " · " : ""}
{book?.format}
{book ? ` · ${Math.round(book.percent * 100)}%` : ""}
</span>
{isAdmin && book && (
<button
className={btn + " ml-auto text-red-300"}
onClick={() => {
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) del.mutate();
}}
>
删除
</button>
)}
</header>
<div className="min-h-0 flex-1">
<Suspense fallback={<Msg text="加载阅读器…" />}>{body}</Suspense>
</div>
</div>
);
}
function Msg({ text, retry }: { text: string; retry?: () => void }) {
return (
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
<div>
<p className="mb-3">{text}</p>
{retry && (
<button className={btn} onClick={retry}>
重试
</button>
)}
</div>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { Book } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { Cover } from "../components/Cover";
import { TopBar } from "../components/TopBar";
import { toast } from "../components/Toaster";
import { btn, btnPrimary, card, input } from "../components/ui";
import { groupByDir } from "../lib/group";
function BookCard({
book,
onOpen,
onDelete,
}: {
book: Book;
onOpen: () => void;
onDelete: () => void;
}) {
const { isAdmin } = useAuth();
return (
<div className="group relative w-32 shrink-0">
<button onClick={onOpen} className="block w-full text-left">
<Cover book={book} className="aspect-[3/4] w-32 rounded-lg" />
<p className="mt-1 truncate text-xs text-zinc-300">{book.title}</p>
<p className="truncate text-[10px] text-zinc-500">
{book.format} · {book.path}
</p>
</button>
{book.state === "error" && (
<span
className="absolute left-1 top-1 rounded bg-red-900 px-1 text-[10px] text-red-100"
title={book.error}
>
损坏
</span>
)}
{book.percent > 0 && (
<div className="mt-1 h-1 w-32 overflow-hidden rounded bg-zinc-800">
<div className="h-full bg-emerald-600" style={{ width: `${Math.round(book.percent * 100)}%` }} />
</div>
)}
{isAdmin && (
<button
className="absolute right-1 top-1 hidden rounded bg-zinc-900/80 px-1.5 text-xs text-red-300 group-hover:block"
title="删除(连磁盘文件)"
onClick={() => {
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) onDelete();
}}
>
✕
</button>
)}
</div>
);
}
export default function Shelf() {
const qc = useQueryClient();
const nav = useNavigate();
const { isAdmin } = useAuth();
const [lib, setLib] = useState(0); // 0 = 全部库
const [qRaw, setQRaw] = useState("");
const [q, setQ] = useState("");
useEffect(() => {
const t = setTimeout(() => setQ(qRaw.trim()), 300); // 搜索防抖
return () => clearTimeout(t);
}, [qRaw]);
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const booksQ = useQuery({
queryKey: ["books", lib, q],
queryFn: () => api.listBooks({ library: lib || undefined, q }),
});
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
// 继续阅读行 → book id 解析用(键与 booksQ 默认态一致,天然共享缓存)
const allQ = useQuery({ queryKey: ["books", 0, ""], queryFn: () => api.listBooks() });
const del = useMutation({
mutationFn: (id: number) => api.deleteBook(id),
onSuccess: () => {
toast("ok", "已删除");
qc.invalidateQueries({ queryKey: ["books"] });
},
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
});
const scan = useMutation({
mutationFn: (id: number) => api.scanLibrary(id),
onSuccess: () => {
toast("ok", "扫描已触发");
setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000); // 给周期扫描留出时间
},
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
});
const groups = useMemo(() => groupByDir(booksQ.data ?? []), [booksQ.data]);
const booksByKey = useMemo(() => {
const m = new Map<string, Book>();
for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b);
return m;
}, [allQ.data]);
const continueRows = (progQ.data ?? []).slice(0, 12);
const libs = libsQ.data ?? [];
return (
<div className="flex h-full flex-col">
<TopBar
right={
<>
<button className={btn} onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
刷新
</button>
{isAdmin && lib > 0 && (
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(lib)}>
{scan.isPending ? "扫描中…" : "扫描此库"}
</button>
)}
<input
className={input + " w-44"}
placeholder="搜索标题…"
value={qRaw}
onChange={(e) => setQRaw(e.target.value)}
/>
</>
}
/>
<div className="flex-1 overflow-y-auto p-4">
{libs.length === 0 && (
<p className="text-sm text-zinc-500">还没有书库。admin 请到「库管理」建库并扫描。</p>
)}
<div className="mb-4 flex flex-wrap gap-2">
<button className={lib === 0 ? btnPrimary : btn} onClick={() => setLib(0)}>
全部
</button>
{libs.map((l) => (
<button key={l.id} className={lib === l.id ? btnPrimary : btn} onClick={() => setLib(l.id)}>
{l.name}
</button>
))}
</div>
{continueRows.length > 0 && (
<section className="mb-8">
<h2 className="mb-2 text-sm font-semibold text-zinc-400">继续阅读</h2>
<div className="flex gap-4 overflow-x-auto pb-2">
{continueRows.map((r) => {
const b = booksByKey.get(`${r.library_id}/${r.path}`);
if (!b)
return (
<div key={`${r.library_id}/${r.path}`} className={`w-28 shrink-0 p-2 text-xs text-zinc-600 ${card}`}>
<p className="truncate">{r.title || "已删除的书"}</p>
<p>{Math.round(r.percent * 100)}%</p>
</div>
);
return (
<BookCard
key={`${r.library_id}/${r.path}`}
book={b}
onOpen={() => nav(`/book/${b.id}`)}
onDelete={() => del.mutate(b.id)}
/>
);
})}
</div>
</section>
)}
{booksQ.isError && (
<p className="mb-4 text-sm text-red-400">
加载失败:{booksQ.error instanceof Error ? booksQ.error.message : ""}{" "}
<button className={btn} onClick={() => booksQ.refetch()}>
重试
</button>
</p>
)}
{!booksQ.isPending && groups.length === 0 && !booksQ.isError && (
<p className="text-sm text-zinc-500">此库暂无书目(放入文件后等待扫描或点「扫描此库」)。</p>
)}
{groups.map((g) => (
<section key={g.dir || "//"} className="mb-8">
<h2 className="mb-2 text-sm font-semibold text-zinc-400">{g.dir || "根目录"}</h2>
<div className="flex flex-wrap gap-4">
{g.books.map((b) => (
<BookCard key={b.id} book={b} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} />
))}
</div>
</section>
))}
</div>
</div>
);
}
+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>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { api } from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
import { TopBar } from "../../components/TopBar";
import { toast } from "../../components/Toaster";
import { btn, btnPrimary, card, input } from "../../components/ui";
export default function AdminUsers() {
const qc = useQueryClient();
const { user: me } = useAuth();
const usersQ = useQuery({ queryKey: ["users"], queryFn: api.listUsers });
const [u, setU] = useState("");
const [p, setP] = useState("");
const [role, setRole] = useState<"member" | "admin">("member");
const create = useMutation({
mutationFn: () => api.createUser(u.trim(), p, role),
onSuccess: () => {
toast("ok", "用户已创建");
setU("");
setP("");
void qc.invalidateQueries({ queryKey: ["users"] });
},
onError: (e) => toast("err", e instanceof Error ? e.message : "创建失败"),
});
const del = useMutation({
mutationFn: (id: number) => api.deleteUser(id),
onSuccess: () => {
toast("ok", "用户已删除(其阅读进度一并删除)");
void qc.invalidateQueries({ queryKey: ["users"] });
},
onError: (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="用户名" value={u} onChange={(e) => setU(e.target.value)} />
<input className={input} type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
<select className={input} value={role} onChange={(e) => setRole(e.target.value as "member" | "admin")}>
<option value="member">member</option>
<option value="admin">admin</option>
</select>
<button className={btnPrimary} disabled={create.isPending || !u.trim() || p.length < 8}>
{create.isPending ? "创建中…" : "创建"}
</button>
</form>
<table className="w-full max-w-2xl text-sm">
<thead className="text-left text-zinc-500">
<tr>
<th className="py-1">ID</th>
<th>用户名</th>
<th>角色</th>
<th>创建时间</th>
<th />
</tr>
</thead>
<tbody>
{(usersQ.data ?? []).map((x) => (
<tr key={x.id} className="border-t border-zinc-800">
<td className="py-1 text-zinc-500">{x.id}</td>
<td>{x.username}</td>
<td>{x.role}</td>
<td className="text-zinc-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
<td className="py-1 text-right">
<button
className={btn + " text-red-300"}
disabled={x.id === me?.id}
title={x.id === me?.id ? "不能删除自己" : undefined}
onClick={() => {
if (confirm(`删除用户 ${x.username}?`)) del.mutate(x.id);
}}
>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}