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
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
dev-dist
+6
View File
@@ -0,0 +1,6 @@
FROM node:22 AS dev
WORKDIR /app
COPY web/package.json web/package-lock.json ./
RUN npm ci
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+13
View File
@@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /src
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM nginx:1.27-alpine AS runner
COPY --chmod=755 deploy/entrypoint-resolver.sh /entrypoint-resolver.sh
COPY --from=build /src/dist /usr/share/nginx/html
# nginx 配置不 bake 进镜像:由 compose 挂到 /etc/booklib/(渲染产物),
# entrypoint 启动时拷入原位、注入运行时 resolver 再 exec nginx
ENTRYPOINT ["/entrypoint-resolver.sh"]
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#09090b" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<title>BookLib — 个人书库</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+8180
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "booklib-web",
"version": "0.1.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "vitest run",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"check": "tsc --noEmit && vitest run && vite build"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"private": "true",
"dependencies": {
"@tanstack/react-query": "^5.102.8",
"dompurify": "^3.4.15",
"epubjs": "^0.3.93",
"marked": "^18.0.11",
"pdfjs-dist": "^6.3.289",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.2.2",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^5.0.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="24" fill="#09090b"/><path d="M24 30h34v68H24zM70 30h34v68H70z" fill="none" stroke="#34d399" stroke-width="6"/><path d="M58 30v68" stroke="#34d399" stroke-width="6"/></svg>

After

Width:  |  Height:  |  Size: 269 B

+59
View File
@@ -0,0 +1,59 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Toaster } from "./components/Toaster";
import Login from "./pages/Login";
import Reader from "./pages/Reader";
import Shelf from "./pages/Shelf";
import AdminLibraries from "./pages/admin/Libraries";
import AdminUsers from "./pages/admin/Users";
export default function App() {
return (
<ErrorBoundary>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<Shelf />
</RequireAuth>
}
/>
<Route
path="/book/:id"
element={
<RequireAuth>
<Reader />
</RequireAuth>
}
/>
<Route
path="/admin/users"
element={
<RequireAuth>
<RequireAdmin>
<AdminUsers />
</RequireAdmin>
</RequireAuth>
}
/>
<Route
path="/admin/libraries"
element={
<RequireAuth>
<RequireAdmin>
<AdminLibraries />
</RequireAdmin>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster />
</AuthProvider>
</ErrorBoundary>
);
}
+153
View File
@@ -0,0 +1,153 @@
import type { Book, Library, Me, ProgressRow, User } from "./types";
export const TOKEN_KEY = "booklib.token";
export const LOGOUT_EVENT = "booklib:logout";
// 无 localStorage 环境(vitest node)退化为内存,仅测试路径生效。
// 探测方法而非 typeof:node 22+ 的 experimental webstorage 会暴露无方法的 localStorage 桩。
const mem = new Map<string, string>();
const hasLS = typeof globalThis.localStorage?.setItem === "function";
export function getToken(): string | null {
return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null;
}
export function setToken(t: string | null): void {
if (hasLS) {
if (t) localStorage.setItem(TOKEN_KEY, t);
else localStorage.removeItem(TOKEN_KEY);
} else if (t) {
mem.set(TOKEN_KEY, t);
} else {
mem.delete(TOKEN_KEY);
}
}
export class HttpError extends Error {
status: number;
code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
this.code = code;
}
}
export async function toHttpError(res: Response): Promise<HttpError> {
let code = "http_" + res.status;
let message = res.statusText;
try {
const j = await res.json();
if (j?.error?.code) code = j.error.code;
if (j?.error?.message) message = j.error.message;
} catch {
/* 非 JSON 错误体:用状态码兜底 */
}
return new HttpError(res.status, code, message);
}
function authHeaders(): Record<string, string> {
const h: Record<string, string> = {};
const tok = getToken();
if (tok) h.Authorization = "Bearer " + tok;
return h;
}
function handle401(res: Response): void {
if (res.status !== 401) return;
setToken(null);
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOGOUT_EVENT));
}
function full(path: string): string {
return path.startsWith("/api") ? path : "/api" + path;
}
export interface ReqOpts {
method?: string;
body?: unknown;
form?: FormData;
keepalive?: boolean;
signal?: AbortSignal;
}
export async function apiFetch<T>(path: string, opts: ReqOpts = {}): Promise<T> {
const headers = authHeaders();
let body: BodyInit | undefined;
if (opts.form) {
body = opts.form;
} else if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.body);
}
const res = await fetch(full(path), {
method: opts.method ?? "GET",
headers,
body,
keepalive: opts.keepalive,
signal: opts.signal,
});
handle401(res);
if (!res.ok) throw await toHttpError(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
// 二进制/原文通道:封面、CBZ 页、PDF/EPUB 字节。同 401 语义,不解析 JSON
export async function apiRaw(
path: string,
opts: { signal?: AbortSignal; keepalive?: boolean } = {},
): Promise<Response> {
const res = await fetch(full(path), {
headers: authHeaders(),
signal: opts.signal,
keepalive: opts.keepalive,
});
handle401(res);
if (!res.ok) throw await toHttpError(res);
return res;
}
export function formatPageUrl(fmt: string, n: number): string {
return fmt.replace("%d", String(n));
}
export const api = {
login: (username: string, password: string) =>
apiFetch<{ token: string }>("/auth/login", { method: "POST", body: { username, password } }),
me: () => apiFetch<Me>("/auth/me"),
listUsers: () => apiFetch<User[]>("/users"),
createUser: (username: string, password: string, role: string) =>
apiFetch<User>("/users", { method: "POST", body: { username, password, role } }),
deleteUser: (id: number) => apiFetch<void>(`/users/${id}`, { method: "DELETE" }),
listLibraries: () => apiFetch<Library[]>("/libraries"),
createLibrary: (name: string, root_path: string) =>
apiFetch<Library>("/libraries", { method: "POST", body: { name, root_path } }),
scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }),
uploadBook: (id: number, file: File) => {
const fd = new FormData();
fd.append("file", file);
return apiFetch<{ accepted: boolean; path: string }>(`/libraries/${id}/upload`, {
method: "POST",
form: fd,
});
},
listBooks: (params: { library?: number; q?: string } = {}) => {
const sp = new URLSearchParams();
if (params.library) sp.set("library", String(params.library));
if (params.q) sp.set("q", params.q);
const qs = sp.toString();
return apiFetch<Book[]>(`/books${qs ? "?" + qs : ""}`);
},
getBook: (id: number) => apiFetch<Book>(`/books/${id}`),
deleteBook: (id: number) => apiFetch<void>(`/books/${id}`, { method: "DELETE" }),
pageCount: (pagesUrl: string) => apiFetch<{ count: number }>(pagesUrl),
putProgress: (id: number, locator: Record<string, unknown>, percent: number, keepalive = false) =>
apiFetch<void>(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }),
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
};
+51
View File
@@ -0,0 +1,51 @@
export type Format = "cbz" | "pdf" | "epub" | "txt" | "md";
export interface Book {
id: number;
library_id: number;
path: string;
title: string;
format: Format;
size: number;
mtime: number;
pages: number;
state: "ready" | "error";
error: string;
added_at: string;
percent: number;
cover_url: string;
library?: string;
pages_url?: string;
page_url_fmt?: string;
file_url?: string;
}
export interface Library {
id: number;
name: string;
root_path: string;
created_at: string;
}
export interface User {
id: number;
username: string;
role: "admin" | "member";
created_at: string;
}
export interface ProgressRow {
library_id: number;
library: string;
path: string;
title: string;
locator: Record<string, unknown>;
percent: number;
updated_at: string;
}
export interface Me {
id: number;
username: string;
role: "admin" | "member";
}
+87
View File
@@ -0,0 +1,87 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { api, getToken, LOGOUT_EVENT, setToken } from "../api/client";
import type { Me } from "../api/types";
interface AuthState {
user: Me | null;
isAdmin: boolean;
ready: boolean;
login: (u: string, p: string) => Promise<void>;
logout: () => void;
}
const Ctx = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient();
const [token, setTok] = useState<string | null>(() => getToken());
// 401 到期登出与显式登出同样清理:跨用户数据不得残留在 query 缓存 / SW CacheStorage
const clearSession = () => {
qc.clear();
if (typeof caches !== "undefined") {
void caches.delete("booklib-api");
void caches.delete("booklib-immutable");
}
};
useEffect(() => {
const off = () => {
setTok(null); // client 的 401 拦截在这里回收 React 状态(token 已由 client.ts 清除)
clearSession();
};
window.addEventListener(LOGOUT_EVENT, off);
return () => window.removeEventListener(LOGOUT_EVENT, off);
}, []);
const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: !!token, retry: 0 });
useEffect(() => {
if (token && meQ.isError) {
setToken(null); // 非 401 失败也要清持久化 token,避免与 React 状态分叉导致 RequireAuth 永久加载
setTok(null);
}
}, [token, meQ.isError]);
const value = useMemo<AuthState>(
() => ({
user: meQ.data ?? null,
isAdmin: meQ.data?.role === "admin",
ready: !token || !meQ.isPending,
login: async (u, p) => {
const r = await api.login(u, p);
setToken(r.token);
setTok(r.token);
await qc.invalidateQueries({ queryKey: ["me"] });
},
logout: () => {
setToken(null);
setTok(null);
clearSession();
},
}),
[meQ.data, meQ.isPending, meQ.isError, token, qc],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useAuth(): AuthState {
const v = useContext(Ctx);
if (!v) throw new Error("useAuth 必须在 AuthProvider 内使用");
return v;
}
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, ready } = useAuth();
const loc = useLocation();
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
if (!ready || !user) return <div className="grid h-full place-items-center text-zinc-500">加载中…</div>;
return <>{children}</>;
}
export function RequireAdmin({ children }: { children: ReactNode }) {
const { isAdmin } = useAuth();
return isAdmin ? <>{children}</> : <Navigate to="/" replace />;
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import type { Book } from "../api/types";
import { fetchObjectUrl } from "../lib/authImage";
import { btn } from "./ui";
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false });
const [nonce, setNonce] = useState(0);
const key = (url ?? "") + "#" + nonce;
useEffect(() => {
if (!url) return;
let dead = false;
const k = url + "#" + nonce;
setState({ key: k, src: "", failed: false });
fetchObjectUrl(url)
.then((src) => !dead && setState({ key: k, src, failed: false }))
.catch(() => !dead && setState({ key: k, src: "", failed: true }));
return () => {
dead = true;
};
}, [url, nonce]);
const same = state.key === key;
return { src: same ? state.src : "", failed: same && state.failed, retry: () => setNonce((n) => n + 1) };
}
export function Cover({ book, className }: { book: Book; className?: string }) {
const { src, failed, retry } = useAuthedImage(book.cover_url);
if (failed)
return (
<div className={`grid place-items-center bg-zinc-900 ${className ?? ""}`}>
<button className={btn} onClick={retry} aria-label="封面加载失败,重试">
重试
</button>
</div>
);
if (!src) return <div className={`animate-pulse bg-zinc-800 ${className ?? ""}`} />;
return <img src={src} alt="" loading="lazy" className={`bg-zinc-900 object-cover ${className ?? ""}`} />;
}
+26
View File
@@ -0,0 +1,26 @@
import { Component, type ReactNode } from "react";
export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Error | null }> {
state: { err: Error | null } = { err: null };
static getDerivedStateFromError(err: Error) {
return { err };
}
render() {
if (this.state.err) {
return (
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-2 text-lg text-red-400">页面出错了</p>
<p className="mb-4 max-w-md text-sm break-all text-zinc-500">{String(this.state.err)}</p>
<button className="rounded-md bg-zinc-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
重试
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState, type ReactNode } from "react";
type Toast = { id: number; kind: "ok" | "err"; text: ReactNode };
const items: Toast[] = [];
const subs = new Set<(t: Toast[]) => void>();
let nextId = 1;
export function toast(kind: Toast["kind"], text: ReactNode): void {
const t: Toast = { id: nextId++, kind, text };
items.push(t);
subs.forEach((f) => f([...items]));
setTimeout(() => {
const i = items.findIndex((x) => x.id === t.id);
if (i >= 0) items.splice(i, 1);
subs.forEach((f) => f([...items]));
}, 4000);
}
export function Toaster() {
const [list, setList] = useState<Toast[]>([]);
useEffect(() => {
subs.add(setList);
return () => {
subs.delete(setList);
};
}, []);
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{list.map((t) => (
<div
key={t.id}
role="status"
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ${
t.kind === "ok" ? "bg-emerald-900 text-emerald-100" : "bg-red-900 text-red-100"
}`}
>
{t.text}
</div>
))}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { btn } from "./ui";
export function TopBar({ right }: { right?: ReactNode }) {
const { user, isAdmin, logout } = useAuth();
return (
<header className="flex shrink-0 items-center gap-4 border-b border-zinc-800 px-4 py-3">
<Link to="/" className="font-semibold">
BookLib
</Link>
{isAdmin && (
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/users">
用户
</Link>
)}
{isAdmin && (
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/libraries">
库管理
</Link>
)}
<div className="ml-auto flex items-center gap-3">
{right}
<span className="text-sm text-zinc-400">
{user?.username}
{isAdmin ? "(admin)" : ""}
</span>
<button className={btn} onClick={logout}>
退出
</button>
</div>
</header>
);
}
+7
View File
@@ -0,0 +1,7 @@
export const btn =
"rounded-md bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50";
export const btnPrimary =
"rounded-md bg-emerald-700 px-3 py-1.5 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50";
export const input =
"rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm outline-none focus:border-zinc-500";
export const card = "rounded-xl border border-zinc-800 bg-zinc-900";
+34
View File
@@ -0,0 +1,34 @@
@import "tailwindcss";
html,
body,
#root {
height: 100%;
}
body {
@apply bg-zinc-950 text-zinc-100 antialiased;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
@apply rounded bg-zinc-700;
}
/* markdown 正文(仅 .md-body 作用域) */
.md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; }
.md-body h2 { @apply mt-6 mb-2 text-xl font-semibold; }
.md-body h3 { @apply mt-4 mb-2 text-base font-semibold; }
.md-body p { @apply my-3; }
.md-body ul { @apply my-3 list-disc space-y-1 pl-6; }
.md-body ol { @apply my-3 list-decimal space-y-1 pl-6; }
.md-body blockquote { @apply my-3 border-l-2 border-zinc-600 pl-4 text-zinc-400; }
.md-body code { @apply rounded bg-zinc-800 px-1 py-0.5 text-[13px]; }
.md-body pre { @apply my-3 overflow-x-auto rounded-lg bg-zinc-900 p-3; }
.md-body pre code { @apply bg-transparent p-0; }
.md-body a { @apply text-emerald-400 underline; }
.md-body img { @apply my-3 max-w-full rounded; }
.md-body hr { @apply my-6 border-zinc-800; }
.md-body table { @apply my-3 w-full border-collapse text-sm; }
.md-body th, .md-body td { @apply border border-zinc-700 px-2 py-1 text-left; }
+30
View File
@@ -0,0 +1,30 @@
import { apiRaw } from "../api/client";
const MAX = 600;
const cache = new Map<string, Promise<string>>(); // 插入序 ≈ LRU(命中时重插)
// 带鉴权取图 → objectURL。后端 Bearer-only,<img>/<iframe> 直挂 /api 必 401,
// 故图片一律走此通道;URL 含内容 hash 不可变,会话内可安全长驻缓存。
export async function fetchObjectUrl(url: string, signal?: AbortSignal): Promise<string> {
const hit = cache.get(url);
if (hit) {
cache.delete(url);
cache.set(url, hit);
return hit;
}
const p = (async () => {
const res = await apiRaw(url, { signal });
return URL.createObjectURL(await res.blob());
})();
p.catch(() => cache.delete(url)); // 失败出缓存,下次调用重试
cache.set(url, p);
while (cache.size > MAX) {
const oldest = cache.keys().next().value as string;
cache.delete(oldest); // 不 revoke:可能仍被在屏 <img> 引用;≤600 条会话内 blob 可接受
}
return p;
}
export function __resetImageCache(): void {
cache.clear();
}
+23
View File
@@ -0,0 +1,23 @@
import type { Book } from "../api/types";
export interface Group {
dir: string; // path 去掉文件名即分组键(spec §4);根目录为 ""
books: Book[];
}
export function groupByDir(books: Book[]): Group[] {
const m = new Map<string, Book[]>();
for (const bk of books) {
const i = bk.path.lastIndexOf("/");
const dir = i < 0 ? "" : bk.path.slice(0, i);
let arr = m.get(dir);
if (!arr) {
arr = [];
m.set(dir, arr);
}
arr.push(bk);
}
return [...m.entries()]
.sort((a, b) => (a[0] === "" ? -1 : b[0] === "" ? 1 : a[0].localeCompare(b[0])))
.map(([dir, bs]) => ({ dir, books: bs }));
}
+57
View File
@@ -0,0 +1,57 @@
export type ProgressSend = (
locator: Record<string, unknown>,
percent: number,
keepalive: boolean,
) => Promise<unknown>;
export interface ProgressSaver {
report: (locator: Record<string, unknown>, percent: number) => void;
flush: () => void;
}
// spec §8:PUT 节流 5s(leading-edge + 尾部合并);关页兜底走 keepalive fetch
//(spec 原文 sendBeacon,但 beacon 无法带 Authorization,后端 Bearer-only,语义等价替换)
export function createProgressSaver(send: ProgressSend, intervalMs = 5000): ProgressSaver {
let lastKey = "";
let pending: { loc: Record<string, unknown>; pct: number } | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let lastAt = 0;
function doSend(keepalive: boolean): void {
if (!pending) return;
const { loc, pct } = pending;
pending = null;
const key = JSON.stringify([loc, pct]);
if (key === lastKey) return;
lastKey = key;
send(loc, pct, keepalive).catch(() => {
if (lastKey === key) lastKey = ""; // 失败允许下次重发同值
});
}
return {
report(locator, percent) {
pending = { loc: locator, pct: percent };
if (timer) return; // 已有尾部定时器:只更新挂起值
const t = Date.now();
if (lastAt === 0 || t - lastAt >= intervalMs) {
lastAt = t;
doSend(false);
} else {
timer = setTimeout(() => {
timer = null;
lastAt = Date.now();
doSend(false);
}, intervalMs - (t - lastAt));
}
},
flush() {
if (timer) {
clearTimeout(timer);
timer = null;
}
lastAt = Date.now();
doSend(true);
},
};
}
+5
View File
@@ -0,0 +1,5 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } },
});
+38
View File
@@ -0,0 +1,38 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { api } from "../api/client";
import type { Book } from "../api/types";
import { createProgressSaver, type ProgressSaver } from "./progress";
export interface ReaderProps {
book: Book;
initialLocator?: Record<string, unknown>;
}
export function useProgressSaver(bookId: number): ProgressSaver {
const qc = useQueryClient();
const saver = useMemo(
() =>
createProgressSaver(async (loc, pct, ka) => {
await api.putProgress(bookId, loc, pct, ka);
if (!ka) qc.invalidateQueries({ queryKey: ["book", bookId] }); // spec §8:只 invalidate 单本
}),
[bookId, qc],
);
useEffect(() => {
const onPageHide = () => saver.flush();
const onVis = () => {
if (document.visibilityState === "hidden") saver.flush();
};
globalThis.addEventListener?.("pagehide", onPageHide);
document?.addEventListener?.("visibilitychange", onVis);
return () => {
saver.flush();
globalThis.removeEventListener?.("pagehide", onPageHide);
document?.removeEventListener?.("visibilitychange", onVis);
};
}, [saver]);
return saver;
}
+63
View File
@@ -0,0 +1,63 @@
// CBZ 竖滚虚拟列表的高度模型:估高起步,页图加载完用真实高校正,
// 前缀和 + 二分定位。纯逻辑,无 DOM,vitest 全量覆盖。
export class PageHeights {
private actual: (number | undefined)[];
private cum: number[]; // cum[i] = 第 i 页顶部偏移;cum[n] = total
constructor(
private n: number,
private est: number,
) {
this.actual = new Array(n).fill(undefined);
this.cum = new Array(n + 1);
this.rebuild(0);
}
private rebuild(from: number): void {
if (from === 0) this.cum[0] = 0;
for (let i = from; i < this.n; i++) this.cum[i + 1] = this.cum[i] + (this.actual[i] ?? this.est);
}
/** 返回高度增量(相对旧值)。同值幂等返 0;越界/非法高度忽略。 */
set(i: number, h: number): number {
if (i < 0 || i >= this.n || !Number.isFinite(h) || h <= 0) return 0;
const old = this.actual[i];
if (old === h) return 0;
this.actual[i] = h;
this.rebuild(i);
return h - (old ?? this.est);
}
offset(i: number): number {
if (this.n === 0) return 0;
return this.cum[Math.max(0, Math.min(i, this.n))];
}
total(): number {
return this.cum[this.n];
}
/** 覆盖 y 的那一页。 */
pageAt(y: number): number {
if (this.n === 0) return 0;
let lo = 0;
let hi = this.n - 1;
let ans = 0;
const v = Math.max(0, y);
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (this.cum[mid] <= v) {
ans = mid;
lo = mid + 1;
} else hi = mid - 1;
}
return ans;
}
range(scrollTop: number, viewport: number, overscan: number): [number, number] {
if (this.n === 0) return [0, 0];
const a = Math.max(0, this.pageAt(scrollTop) - overscan);
const b = Math.min(this.n, this.pageAt(scrollTop + viewport) + overscan);
return [a, b];
}
}
+15
View File
@@ -0,0 +1,15 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { createRoot } from "react-dom/client";
import App from "./App";
import { queryClient } from "./lib/qc";
import "./index.css";
import "./pwa";
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>,
);
+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>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { registerSW } from "virtual:pwa-register";
// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗
registerSW({ immediate: true });
+146
View File
@@ -0,0 +1,146 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui";
import { fetchObjectUrl } from "../lib/authImage";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
import { PageHeights } from "../lib/virt";
const MAX_W = 720;
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
const [src, setSrc] = useState("");
const [failed, setFailed] = useState(false);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let dead = false;
setSrc("");
setFailed(false);
fetchObjectUrl(url)
.then((s) => !dead && setSrc(s))
.catch(() => !dead && setFailed(true));
return () => {
dead = true;
};
}, [url, nonce]);
if (failed)
return (
<div className="grid place-items-center gap-2 bg-zinc-900 py-8 text-sm text-zinc-500" style={{ width }}>
本页加载失败
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
重试
</button>
</div>
);
if (!src)
return <div className="animate-pulse bg-zinc-800" style={{ width, height: width * EST_RATIO }} />;
return (
<img
src={src}
alt=""
style={{ width }}
className="block"
onLoad={(e) => {
const el = e.currentTarget;
if (el.naturalWidth > 0) onLoaded((el.naturalHeight / el.naturalWidth) * width);
}}
/>
);
}
export default function CbzReader({ book, initialLocator }: ReaderProps) {
const countQ = useQuery({
queryKey: ["pages", book.id],
queryFn: () => api.pageCount(book.pages_url ?? ""),
enabled: !!book.pages_url,
retry: 0,
});
const count = countQ.data?.count ?? 0;
const saver = useProgressSaver(book.id);
const boxRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(480);
const [vh, setVh] = useState(600);
const [top, setTop] = useState(0);
const [, bump] = useState(0);
const restored = useRef(false);
const ph = useMemo(() => new PageHeights(count, width * EST_RATIO), [count, width]);
// 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准)
useEffect(() => {
const el = boxRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
setWidth(Math.min(el.clientWidth, MAX_W));
setVh(el.clientHeight);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// 首次拿到 count 后恢复进度页
useEffect(() => {
if (restored.current || !count || !boxRef.current) return;
restored.current = true;
const p = Number(initialLocator?.page);
if (Number.isInteger(p) && p > 0 && p < count) boxRef.current.scrollTop = ph.offset(p);
}, [count, initialLocator, ph]);
function onScroll() {
const el = boxRef.current;
if (!el) return;
const t = el.scrollTop;
setTop(t);
if (count) {
const cur = ph.pageAt(t + vh / 2);
saver.report({ page: cur }, (cur + 1) / count);
for (let j = cur + 1; j <= Math.min(count - 1, cur + 5); j++)
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); // 预取下方 5 页
}
}
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
if (countQ.isPending && !!book.pages_url)
return <div className="grid h-full place-items-center text-zinc-500">页索引加载中…</div>;
if (countQ.isError || !book.page_url_fmt)
return (
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
</div>
);
const fmt: string = book.page_url_fmt;
const [a, b] = ph.range(top, vh, 2);
const cur = ph.pageAt(top + vh / 2);
return (
<div className="relative h-full">
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto">
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: MAX_W }}>
{Array.from({ length: b - a }, (_, k) => {
const i = a + k;
return (
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
<PageImg
url={formatPageUrl(fmt, i)}
width={width}
onLoaded={(h) => {
const el = boxRef.current;
const topOfI = ph.offset(i);
const d = ph.set(i, h);
if (el && d !== 0 && topOfI < el.scrollTop) el.scrollTop += d; // 视口上方页高变化 → 补偿滚动
bump((x) => x + 1);
}}
/>
</div>
);
})}
</div>
</div>
<div className="pointer-events-none absolute bottom-3 right-3 rounded bg-zinc-900/80 px-2 py-1 text-xs text-zinc-300">
{cur + 1}/{count}
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from "react";
import { apiRaw } from "../api/client";
import { btn } from "../components/ui";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
interface EpubLocation {
start?: { cfi?: string; sectionIndex?: number };
}
interface EpubRendition {
display(target?: string): Promise<unknown> | unknown;
next(): unknown;
prev(): unknown;
on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
destroy(): void;
}
interface EpubBookLike {
ready: Promise<unknown>;
spine?: unknown[];
renderTo(el: HTMLElement, opts: Record<string, unknown>): EpubRendition;
destroy(): void;
}
async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> {
const m = (await import("epubjs")) as { default?: unknown };
return m.default as (data: Uint8Array) => EpubBookLike;
}
export default function EpubReader({ book, initialLocator }: ReaderProps) {
const hostRef = useRef<HTMLDivElement>(null);
const rendRef = useRef<EpubRendition | null>(null);
const saver = useProgressSaver(book.id);
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0);
const [ready, setReady] = useState(false);
useEffect(() => {
let dead = false;
let bookObj: EpubBookLike | null = null;
setErr("");
setReady(false);
(async () => {
const ePub = await loadEpub();
const buf = await apiRaw(book.file_url ?? "").then((r) => r.arrayBuffer());
if (dead) return;
bookObj = ePub(new Uint8Array(buf)); // 字节已在内存,内部 iframe 不再回源,绕开鉴权问题
await bookObj.ready;
if (dead || !hostRef.current) return;
const spineLen = bookObj.spine?.length ?? 1;
const r = bookObj.renderTo(hostRef.current, { width: "100%", height: "100%", flow: "paginated" });
rendRef.current = r;
r.on("relocated", (loc) => {
const cfi = loc.start?.cfi ?? "";
const idx = loc.start?.sectionIndex ?? 0;
const pct = spineLen > 1 ? (idx + 1) / spineLen : 0.5;
saver.report(cfi ? { cfi } : {}, Math.min(1, pct));
});
const cfi = typeof initialLocator?.cfi === "string" ? (initialLocator.cfi as string) : undefined;
// epubjs 对失效 cfi 以 reject Promise signalling,而非同步 throw → 走 then/catch 回退
Promise.resolve(cfi ? r.display(cfi) : r.display())
.then(() => setReady(true))
.catch(() => {
if (!cfi) {
setErr("EPUB 无法打开");
return;
}
Promise.resolve(r.display())
.then(() => setReady(true))
.catch(() => setErr("EPUB 无法打开"));
});
})().catch((e) => !dead && setErr(e instanceof Error ? e.message : "EPUB 加载失败"));
return () => {
dead = true;
rendRef.current?.destroy();
rendRef.current = null;
bookObj?.destroy();
};
}, [book.file_url, nonce]); // eslint-disable-line react-hooks/exhaustive-deps
if (err)
return (
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-3 text-red-400">{err}</p>
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
重试
</button>
</div>
</div>
);
return (
<div className="grid h-full grid-rows-[1fr_auto]">
<div className="relative min-h-0 bg-white">
<div ref={hostRef} className="absolute inset-0" />
{!ready && !err && <div className="grid h-full place-items-center bg-zinc-950 text-zinc-500">EPUB 加载中…</div>}
</div>
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
<button className={btn} onClick={() => rendRef.current?.prev()}>
上一页
</button>
<button className={btn} onClick={() => rendRef.current?.next()}>
下一页
</button>
</div>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import * as pdfjs from "pdfjs-dist";
import { apiRaw } from "../api/client";
import { btn } from "../components/ui";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
// Vite 原生 worker 语法;pdfjs v5 要求 module worker
pdfjs.GlobalWorkerOptions.workerPort = new Worker(
new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url),
{ type: "module" },
);
type RenderTask = { cancel(): void; promise: Promise<void> };
export default function PdfReader({ book, initialLocator }: ReaderProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
const docTaskRef = useRef<{ destroy(): Promise<void> } | null>(null);
const taskRef = useRef<RenderTask | null>(null);
const restored = useRef(false);
const saver = useProgressSaver(book.id);
const [num, setNum] = useState(0);
const [page, setPage] = useState(0); // 0 基
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0); // 仅加载重试
const [redraw, setRedraw] = useState(0); // 仅 resize 重绘(不重新下载文档)
useEffect(() => {
let dead = false;
setErr("");
(async () => {
const buf = await apiRaw(book.file_url ?? "").then((r) => r.arrayBuffer());
if (dead) return;
const task = pdfjs.getDocument({ data: new Uint8Array(buf) });
docTaskRef.current = task;
const doc = await task.promise;
if (dead) {
await task.destroy();
return;
}
docRef.current = doc;
setNum(doc.numPages);
if (!restored.current) {
restored.current = true;
const p = Number(initialLocator?.page);
if (Number.isInteger(p) && p > 0 && p < doc.numPages) setPage(p);
}
})().catch((e) => !dead && setErr(e instanceof Error ? e.message : "PDF 加载失败"));
return () => {
dead = true;
taskRef.current?.cancel();
docTaskRef.current?.destroy();
docTaskRef.current = null;
docRef.current = null;
};
}, [book.file_url, nonce]); // initialLocator 是查询缓存值,加载一次即稳定
// 渲染当前页
useEffect(() => {
const doc = docRef.current;
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!doc || !canvas || !wrap || num === 0) return;
let cancelled = false;
(async () => {
const p = await doc.getPage(page + 1);
if (cancelled) return;
const cssW = wrap.clientWidth;
const dpr = window.devicePixelRatio || 1;
const base = p.getViewport({ scale: 1 });
const vp = p.getViewport({ scale: (cssW * dpr) / base.width });
canvas.width = Math.floor(vp.width);
canvas.height = Math.floor(vp.height);
canvas.style.width = "100%";
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas 2d 上下文不可用");
const t = p.render({ canvas, viewport: vp }) as RenderTask;
taskRef.current = t;
await t.promise;
})().catch((e) => {
if (!cancelled && (e as { name?: string }).name !== "AbortException")
setErr(e instanceof Error ? e.message : "渲染失败");
});
saver.report({ page }, (page + 1) / num);
return () => {
cancelled = true;
taskRef.current?.cancel();
};
}, [page, num, redraw, saver]);
// 容器尺寸变化 → 重绘
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
let t: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(t);
t = setTimeout(() => setRedraw((x) => x + 1), 200);
});
ro.observe(wrap);
return () => {
clearTimeout(t);
ro.disconnect();
};
}, []);
function onKey(e: KeyboardEvent) {
if (e.key === "ArrowRight" || e.key === "PageDown") setPage((p) => Math.min(num - 1, p + 1));
else if (e.key === "ArrowLeft" || e.key === "PageUp") setPage((p) => Math.max(0, p - 1));
else return;
e.preventDefault();
}
if (err)
return (
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-3 text-red-400">{err}</p>
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
重试
</button>
</div>
</div>
);
if (num === 0) return <div className="grid h-full place-items-center text-zinc-500">PDF 加载中…</div>;
return (
<div tabIndex={0} onKeyDown={onKey} className="grid h-full grid-rows-[1fr_auto] focus:outline-none">
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-zinc-900 p-4">
<canvas ref={canvasRef} className="rounded shadow-lg" />
</div>
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
<button className={btn} disabled={page === 0} onClick={() => setPage(0)}>
⏮
</button>
<button className={btn} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
上一页
</button>
<span className="text-zinc-400">
{page + 1}/{num}
</span>
<button className={btn} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
下一页
</button>
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)}>
⏭
</button>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import { useEffect, useMemo, useRef, useState } from "react";
import { apiRaw } from "../api/client";
import { btn } from "../components/ui";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
export default function TextReader({ book, initialLocator }: ReaderProps) {
const boxRef = useRef<HTMLDivElement>(null);
const saver = useProgressSaver(book.id);
const restored = useRef(false);
const [text, setText] = useState<string | null>(null);
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0);
useEffect(() => {
let dead = false;
setText(null);
setErr("");
apiRaw(book.file_url ?? "")
.then((r) => r.text())
.then((t) => !dead && setText(t))
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
return () => {
dead = true;
};
}, [book.file_url, nonce]);
const html = useMemo(() => {
if (text == null || book.format !== "md") return null;
return DOMPurify.sanitize(marked.parse(text, { async: false }));
}, [text, book.format]);
useEffect(() => {
if (restored.current || text == null || !boxRef.current) return;
restored.current = true;
const f = Number(initialLocator?.scrollFraction);
if (Number.isFinite(f) && f > 0 && f <= 1) {
const el = boxRef.current;
el.scrollTop = f * (el.scrollHeight - el.clientHeight);
}
}, [text, initialLocator]);
function onScroll() {
const el = boxRef.current;
if (!el) return;
const max = el.scrollHeight - el.clientHeight;
const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
saver.report({ scrollFraction: frac }, frac);
}
if (err)
return (
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-3 text-red-400">{err}</p>
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
重试
</button>
</div>
</div>
);
if (text == null) return <div className="grid h-full place-items-center text-zinc-500">正文加载中…</div>;
return (
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto p-6">
{html !== null ? (
<article className="md-body mx-auto max-w-2xl text-[15px] leading-7" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<pre className="mx-auto max-w-2xl font-sans text-[15px] leading-7 break-words whitespace-pre-wrap">{text}</pre>
)}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
declare module "epubjs";
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { setToken } from "../src/api/client";
import { __resetImageCache, fetchObjectUrl } from "../src/lib/authImage";
beforeEach(() => {
__resetImageCache();
setToken("T");
(URL as any).createObjectURL = vi.fn(() => "blob:gen");
});
afterEach(() => vi.unstubAllGlobals());
it("blob → objectURL;同 URL 只发一次请求", async () => {
const f = vi.fn(async (_url: any, init: any) => {
expect(init.headers.Authorization).toBe("Bearer T");
return new Response(new Blob(["img"]), { status: 200 });
});
vi.stubGlobal("fetch", f);
expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen");
expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen");
expect(f).toHaveBeenCalledTimes(1);
});
it("失败不入缓存,重试重新请求", async () => {
let n = 0;
vi.stubGlobal("fetch", async () => {
n++;
return n === 1 ? new Response("x", { status: 500 }) : new Response(new Blob(["img"]), { status: 200 });
});
await expect(fetchObjectUrl("/api/books/2/pages/0?v=h")).rejects.toThrow();
expect(await fetchObjectUrl("/api/books/2/pages/0?v=h")).toBe("blob:gen");
expect(n).toBe(2);
});
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { api, apiRaw, formatPageUrl, getToken, HttpError, setToken } from "../src/api/client";
const jres = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
beforeEach(() => setToken(null));
afterEach(() => vi.unstubAllGlobals());
it("login 成功存 token,请求体带凭据", async () => {
const f = vi.fn(async (_url: any, _init: any) => jres(200, { token: "T" }));
vi.stubGlobal("fetch", f);
await expect(api.login("a", "b")).resolves.toEqual({ token: "T" });
expect(f.mock.calls[0][0]).toBe("/api/auth/login");
expect(String(f.mock.calls[0][1].body)).toContain('"username":"a"');
});
it("401 清 token 并抛 HttpError(parsed code)", async () => {
setToken("T");
vi.stubGlobal("fetch", async (_url: any, init: any) => {
expect(init.headers.Authorization).toBe("Bearer T");
return jres(401, { error: { code: "unauthorized", message: "bad credentials" } });
});
const e = await api.login("a", "b").catch((e) => e);
expect(e).toBeInstanceOf(HttpError);
expect((e as HttpError).code).toBe("unauthorized");
expect(getToken()).toBeNull();
});
it("非 JSON 错误体回退 http_<status>", async () => {
vi.stubGlobal("fetch", async () => new Response("oops", { status: 500 }));
const e = await api.listUsers().catch((e) => e);
expect((e as HttpError).code).toBe("http_500");
});
it("204 无体 resolve undefined", async () => {
vi.stubGlobal("fetch", async () => new Response(null, { status: 204 }));
await expect(api.deleteBook(1)).resolves.toBeUndefined();
});
it("相对路径补 /api 前缀,绝对 /api 原样;query 拼装", async () => {
const seen: string[] = [];
vi.stubGlobal("fetch", async (url: any) => {
seen.push(String(url));
return jres(200, []);
});
await api.listBooks({ library: 3, q: "x" });
await apiRaw("/api/books/1/cover?v=h");
expect(seen).toEqual(["/api/books?library=3&q=x", "/api/books/1/cover?v=h"]);
});
it("page_url_fmt %d 替换", () => {
expect(formatPageUrl("/api/books/9/pages/%d?v=h", 12)).toBe("/api/books/9/pages/12?v=h");
});
it("putProgress keepalive 透传 + JSON body", async () => {
const f = vi.fn(async (_url: any, _init: any) => new Response(null, { status: 204 }));
vi.stubGlobal("fetch", f);
await api.putProgress(1, { page: 2 }, 0.5, true);
expect(f.mock.calls[0][1].keepalive).toBe(true);
expect(String(f.mock.calls[0][1].body)).toContain('"percent":0.5');
});
+20
View File
@@ -0,0 +1,20 @@
import { expect, it } from "vitest";
import { groupByDir } from "../src/lib/group";
import type { Book } from "../src/api/types";
let seq = 1;
const mk = (path: string): Book => ({
id: seq++, library_id: 1, path, title: path, format: "cbz", size: 1, mtime: 1, pages: 0,
state: "ready", error: "", added_at: "", percent: 0, cover_url: "/api/books/0/cover",
});
it("根目录组为空串且排最前;目录组按字典序;组内保持输入顺序", () => {
const gs = groupByDir([mk("b.cbz"), mk("z/1.cbz"), mk("a/2.cbz"), mk("a/sub/3.cbz")]);
expect(gs.map((g) => g.dir)).toEqual(["", "a", "a/sub", "z"]);
expect(gs[0].books.map((x) => x.path)).toEqual(["b.cbz"]);
expect(gs[1].books.map((x) => x.path)).toEqual(["a/2.cbz"]);
});
it("空输入出空数组", () => {
expect(groupByDir([])).toEqual([]);
});
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createProgressSaver } from "../src/lib/progress";
type Sent = [Record<string, unknown>, number, boolean];
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("leading 立即发;间隔内多次 report 合并成一次 trailing", () => {
const sent: Sent[] = [];
const s = createProgressSaver((l, p, ka) => {
sent.push([l, p, ka]);
return Promise.resolve();
});
s.report({ page: 1 }, 0.1);
expect(sent.length).toBe(1);
s.report({ page: 2 }, 0.2);
s.report({ page: 3 }, 0.3);
expect(sent.length).toBe(1);
vi.advanceTimersByTime(4999);
expect(sent.length).toBe(1);
vi.advanceTimersByTime(1);
expect(sent.length).toBe(2);
expect(sent[1]).toEqual([{ page: 3 }, 0.3, false]);
});
it("flush 立刻尾随发送且 keepalive=true;重复值去重;清空挂起", () => {
const sent: Sent[] = [];
const s = createProgressSaver((l, p, ka) => {
sent.push([l, p, ka]);
return Promise.resolve();
});
s.report({ page: 0 }, 0);
s.report({ page: 5 }, 0.5);
s.flush();
expect(sent.length).toBe(2);
expect(sent[1]).toEqual([{ page: 5 }, 0.5, true]);
s.flush();
expect(sent.length).toBe(2);
vi.advanceTimersByTime(10_000);
expect(sent.length).toBe(2); // 挂起已被 flush 消费,定时器不再补发
});
+31
View File
@@ -0,0 +1,31 @@
import { expect, it } from "vitest";
import { PageHeights } from "../src/lib/virt";
it("估算高度:offset/total/pageAt/range(带 overscan、夹边)", () => {
const ph = new PageHeights(10, 100);
expect(ph.offset(3)).toBe(300);
expect(ph.total()).toBe(1000);
expect(ph.pageAt(350)).toBe(3);
expect(ph.pageAt(-10)).toBe(0);
expect(ph.pageAt(99_999)).toBe(9);
expect(ph.range(0, 100, 2)).toEqual([0, 3]);
expect(ph.range(950, 100, 2)).toEqual([7, 10]);
});
it("set 修正高度并平移后续 offset;返回增量;幂等", () => {
const ph = new PageHeights(10, 100);
expect(ph.set(2, 150)).toBe(50);
expect(ph.offset(2)).toBe(200); // 自身 offset 不受自己影响
expect(ph.offset(3)).toBe(350); // 后续整体 +50
expect(ph.total()).toBe(1050);
expect(ph.pageAt(349)).toBe(2);
expect(ph.pageAt(351)).toBe(3);
expect(ph.set(2, 150)).toBe(0);
});
it("n=0 时一切归空", () => {
const ph = new PageHeights(0, 100);
expect(ph.total()).toBe(0);
expect(ph.range(0, 100, 2)).toEqual([0, 0]);
expect(ph.set(0, 50)).toBe(0);
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client", "vite-plugin-pwa/client"]
},
"include": ["src", "test"]
}
+50
View File
@@ -0,0 +1,50 @@
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["icon.svg"],
manifest: {
name: "BookLib 个人书库",
short_name: "BookLib",
description: "个人书库/漫画库在线阅读",
theme_color: "#09090b",
background_color: "#09090b",
display: "standalone",
icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }],
},
workbox: {
globPatterns: ["**/*.{js,css,html,svg,woff2}"],
navigateFallback: "/index.html",
runtimeCaching: [
{
// ?v={hash} 的封面/CBZ 页/原文件 = 不可变 → cache-first(spec §6.1/§6.3)
urlPattern: ({ url }) =>
url.pathname.startsWith("/api/books/") &&
url.searchParams.has("v") &&
(/\/cover$/.test(url.pathname) || /\/pages\/\d+$/.test(url.pathname) || /\/file$/.test(url.pathname)),
handler: "CacheFirst",
options: { cacheName: "booklib-immutable", expiration: { maxEntries: 3000, purgeOnQuotaError: true } },
},
{
// 普通 API 请求 network-first,离线可读最近一次列表/进度
urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
handler: "NetworkFirst",
options: {
cacheName: "booklib-api",
networkTimeoutSeconds: 5,
expiration: { maxAgeSeconds: 3600, maxEntries: 200 },
},
},
],
},
}),
],
server: { proxy: { "/api": process.env.VITE_PROXY_TARGET ?? "http://localhost:8080" } },
});
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true },
});