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,3 @@
|
||||
node_modules
|
||||
dist
|
||||
dev-dist
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
Generated
+8180
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 ?? ""}`} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗
|
||||
registerSW({ immediate: true });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "epubjs";
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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 消费,定时器不再补发
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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" } },
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true },
|
||||
});
|
||||
Reference in New Issue
Block a user