Files

3083 lines
106 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Frontend (Web Reader & Shelf) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现 book-comic-library 前端 SPA:登录、书架(库/目录分组/搜索/进度/损坏态)、四个 reader(CBZ 虚拟滚动、PDF.js、epub.js、TXT/MD)、admin 用户与库管理、PWA 离线缓存,并把 `web/` 构建并入 Docker 部署替换占位页。
**Architecture:** React + Vite + TS 单 SPA,只依赖后端 `/api/*`(Plan 1 已交付并合并,路由表见 `backend/internal/api/router.go`)。TanStack Query 管服务端状态,React Router 管页面,无全局 store。后端鉴权**只认 `Authorization: Bearer`**(见 `backend/internal/api/api.go` authMw,无 query-token 后门),因此封面/页图/PDF/EPUB 一律经带 token 的 fetch 转 objectURL 渲染;进度关闭兜底用 `fetch keepalive` 而非 sendBeacon(beacon 无法携带 header,语义等价)。
**Tech Stack:** React 19、Vite 7、TypeScript 5(strict)、Tailwind CSS 4(深色默认)、TanStack Query 5、React Router 7、pdfjs-dist 5、epubjs 0.3、marked、DOMPurify、vite-plugin-pwa(Workbox)、vitest 3。
**Spec:** `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`(§8 前端、§6.3 离线、§9 前端错误处理、§10 前端测试)
**Backend 计划:** `docs/superpowers/plans/2026-09-04-backend.md`(已完成、已合并;本 plan 不改后端一行代码)
本仓库根 = `book-comic-library/`,以下所有相对路径以此为根。前端代码全部在 `web/`,命令在 `web/` 目录执行(除非写明)。
## 后端 API 契约(实测自已合并代码,前端类型以此为准)
```
POST /api/auth/login {username,password} → 200 {token} | 401 {"error":{code,message}}
GET /api/auth/me → {id,username,role}
GET /api/users ★ → [{id,username,role,created_at}]
POST /api/users ★ {username,password,role} → 201 {...} | 409 exists | 400 弱密码/坏 role
DELETE /api/users/{id} ★ → 204 | 400 删自己 | 404
GET /api/libraries → [{id,name,root_path,created_at}]
POST /api/libraries ★ {name,root_path(绝对路径)} → 201 | 409 root_path taken
POST /api/libraries/{id}/scan ★ → 202 {accepted}
POST /api/libraries/{id}/upload ★ multipart 字段名 file → 202 {accepted,path} | 400 bad_format
GET /api/books?library=&q= → [BookJSON]
GET /api/books/{id} → BookJSON
DELETE /api/books/{id} ★ → 204
GET /api/books/{id}/cover?v={hash} → image(任何书都有;缺封面时是内置 SVG 占位)
GET /api/books/{id}/file?v={hash} → 原始 pdf/epub/txt/md,支持 Range,Cache-Control: private
GET /api/books/{id}/pages → {count}(仅 cbz)
GET /api/books/{id}/pages/{n}?v={hash} → image,immutable(仅 cbz,n 从 0 起)
PUT /api/books/{id}/progress {locator,percent(0..1)} → 204
GET /api/progress → [{library_id,library,path,title,locator,percent,updated_at}]
```
`BookJSON`(见 `backend/internal/api/books.go` bookJSON):
```jsonc
{ "id":1, "library_id":2, "path":"series-a/01.cbz", "title":"01", "format":"cbz",
"size":123, "mtime":1700, "pages":24, "state":"ready", "error":"", "added_at":"…RFC3339…",
"percent":0.5, "cover_url":"/api/books/1/cover?v=…",
"library":"comics",
// format==="cbz" 时:
"pages_url":"/api/books/1/pages", "page_url_fmt":"/api/books/1/pages/%d?v=…",
// 否则:
"file_url":"/api/books/1/file?v=…" }
```
## Global Constraints
- 一切 HTTP 走 `web/src/api/client.ts`;token 存 `localStorage["booklib.token"]`,请求头 `Authorization: Bearer`;**任何资源 URL 不得携带 token**(不进 query、不进日志)。
- 任何响应 401 → 清 token + 派发 `window` 事件 `booklib:logout` → AuthContext 登出并跳 `/login`(spec §9)。
- 图片/文件渲染只允许两条路:blob objectURL(Cover/CBZ 页)或 arraybuffer(pdfjs/epubjs);禁止 `<img src="/api/...">` 直挂(必 401)。
- 进度:reader 统一 `onPositionChange(locator, percent)` 语义 = `saver.report(locator, percent)`,5s 节流;关页/切后台 `flush()` 用 `fetch keepalive`。locator 键按 spec §4:cbz/pdf `{page}`(0 基)、epub `{cfi}`、txt/md `{scrollFraction}`;后端不解释。upsert 成功后仅 invalidate `["book", id]`(spec §8)。
- 支持格式仅 `cbz pdf epub txt/md`;其余不入库也不会下发,reader 分发遇未知 format 显示占位。
- 写操作按钮(建库/上传/扫描/删书/用户 CRUD)仅 `role==="admin"` 可见;member 可 GET 一切 + PUT 自己的 progress。
- Tailwind 深色默认(`bg-zinc-950 text-zinc-100`),无主题切换。中文文案。
- 测试:`npm run check`(= `tsc --noEmit && vitest run && vite build`)是每任务唯一门槛;vitest 只测纯逻辑(client/group/virt/progress/authImage),不引 Testing Library/spec §10。
- Node ≥ 22。依赖只在 Task 1 一次装齐,后续任务禁止新增 npm 依赖。
- dev 联调:vite proxy `/api → http://localhost:8080`(起 `deploy/docker-compose.dev.yml` 的 PG/Redis + `go run ./cmd/server`);生产同源自 nginx,代码零改动。
## File Structure
```
web/
package.json tsconfig.json vite.config.ts vitest.config.ts index.html .gitignore
public/icon.svg
src/
main.tsx # 挂载:providers + router;Task 12 加 SW 注册
App.tsx # 路由表(每任务整文件替换,追加路由)
index.css # tailwind import + 全局深色底
api/types.ts # Book/Library/User/ProgressRow 等(与上方契约一致)
api/client.ts # token 存取、401 拦截、apiFetch/apiRaw、api.*、formatPageUrl
auth/AuthContext.tsx # AuthProvider/useAuth + RequireAuth/RequireAdmin
lib/qc.ts # 单例 QueryClient
lib/group.ts # 书架目录分组(纯函数,vitest)
lib/virt.ts # CBZ 虚拟滚动高度模型(纯类,vitest)
lib/progress.ts # 节流进度保存器(纯逻辑,vitest)
lib/authImage.ts # 带鉴权图片 → objectURL LRU 缓存(vitest)+ useAuthedImage hook
components/Toaster.tsx # 迷你全局 toast(spec 无组件库)
components/ErrorBoundary.tsx
components/ui.ts # btn/input/card 共享 className 常量
components/Cover.tsx # 鉴权封面图 + 失败重试占位
pages/Login.tsx
pages/Shelf.tsx # 书架
pages/Reader.tsx # /book/:id 按 format 分发(lazy)
readers/CbzReader.tsx readers/PdfReader.tsx readers/EpubReader.tsx readers/TextReader.tsx
pages/admin/Users.tsx pages/admin/Libraries.tsx
test/client.test.ts test/group.test.ts test/virt.test.ts test/progress.test.ts test/authImage.test.ts
deploy/Dockerfile.web # Task 12:整文件替换为多阶段(node build → nginx)
deploy/web-dist/ # Task 12:删除(占位页完成使命)
.dockerignore # Task 12:补 web 条目
scripts/smoke-web.sh # Task 12:compose 全栈冒烟(经 nginx)
README.md # Task 12:补前端 dev/部署说明
```
依赖方向:`pages/readers → lib/components → auth → api`。无循环。
---
### Task 1: 脚手架 — Vite+React+TS+Tailwind4+vitest+dev proxy
**Files:**
- Create: `web/package.json`(npm 生成)、`web/.gitignore`、`web/tsconfig.json`、`web/vite.config.ts`、`web/vitest.config.ts`、`web/index.html`、`web/public/icon.svg`、`web/src/index.css`、`web/src/main.tsx`、`web/src/App.tsx`
**Interfaces:**
- Consumes: 无
- Produces: 可 `npm run check` 的工程;`npm run dev`(:5173,`/api` 代理到 :8080)
- [x] **Step 1: 初始化工程并装依赖(一次性,后续任务不再装)**
```bash
cd web
npm init -y
npm pkg set name=booklib-web version=0.1.0 private=true type=module
npm pkg set scripts.dev=vite scripts.build="tsc --noEmit && vite build" scripts.preview="vite preview" scripts.test="vitest run" scripts.check="tsc --noEmit && vitest run && vite build"
npm install react react-dom react-router-dom @tanstack/react-query marked dompurify pdfjs-dist
npm install epubjs@0.3.93
npm install -D typescript vite @vitejs/plugin-react tailwindcss @tailwindcss/vite vitest @types/react @types/react-dom vite-plugin-pwa
```
Expected: 无 error 级输出;`node_modules`、`package-lock.json` 生成。epubjs 锁 0.3.93(上游停更,锁死可复现);其余用 latest。若 `npm install` 报 ERESOLVE(vite-plugin-pwa 与已装 vite 大版本 peer 冲突):降级安装最后一个兼容版 `npm i -D vite-plugin-pwa@0.21.2`,Task 12 的 `VitePWA({...})` 配置键在 0.21→1.x 间未变,后续代码不用改。
- [x] **Step 2: 写配置文件**
`web/.gitignore`:
```
node_modules
dist
dev-dist
```
`web/tsconfig.json`:
```json
{
"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"]
},
"include": ["src", "test"]
}
```
`web/vite.config.ts`:
```ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: { proxy: { "/api": "http://localhost:8080" } },
});
```
`web/vitest.config.ts`:
```ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true },
});
```
- [x] **Step 3: 写入口文件**
`web/index.html`:
```html
<!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>
```
`web/public/icon.svg`:
```html
<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>
```
`web/src/index.css`:
```css
@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;
}
```
`web/src/main.tsx`:
```tsx
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
```
`web/src/App.tsx`(占位,Task 3 整文件替换):
```tsx
export default function App() {
return <div className="p-8 text-zinc-400">booklib web</div>;
}
```
- [x] **Step 4: 跑门槛确认通过**
Run: `cd web && npm run check`
Expected: `tsc` 无错、vitest `No test files匹配 → passWithNoTests OK`、`vite build` 产出 `dist/`。
- [x] **Step 5: Commit**
```bash
git add web && git commit -m "feat(web): vite+react+ts+tailwind4 scaffold, vitest, /api dev proxy"
```
---
### Task 2: API 类型 + client(401 拦截、错误体、blob/arraybuffer 通道)
**Files:**
- Create: `web/src/api/types.ts`、`web/src/api/client.ts`
- Test: `web/test/client.test.ts`
**Interfaces:**
- Consumes: Task 1
- Produces(后续全部任务依赖,签名精确):
```ts
// types.ts
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" }
// client.ts
export const TOKEN_KEY = "booklib.token";
export const LOGOUT_EVENT = "booklib:logout";
export function getToken(): string | null
export function setToken(t: string | null): void
export class HttpError extends Error { status: number; code: string }
export function toHttpError(res: Response): Promise<HttpError>
export interface ReqOpts { method?: string; body?: unknown; form?: FormData; keepalive?: boolean; signal?: AbortSignal }
export function apiFetch<T>(path: string, opts?: ReqOpts): Promise<T> // 相对 path 自动加 /api 前缀;已 /api 开头则原样
export function apiRaw(path: string, opts?: { signal?: AbortSignal; keepalive?: boolean }): Promise<Response> // 供 blob/arrayBuffer,同样带 token、401 拦截
export function formatPageUrl(fmt: string, n: number): string
export const api: {
login(username: string, password: string): Promise<{ token: string }>
me(): Promise<Me>
listUsers(): Promise<User[]>
createUser(username: string, password: string, role: string): Promise<User>
deleteUser(id: number): Promise<void>
listLibraries(): Promise<Library[]>
createLibrary(name: string, root_path: string): Promise<Library>
scanLibrary(id: number): Promise<{ accepted: boolean }>
uploadBook(id: number, file: File): Promise<{ accepted: boolean; path: string }>
listBooks(params?: { library?: number; q?: string }): Promise<Book[]>
getBook(id: number): Promise<Book>
deleteBook(id: number): Promise<void>
pageCount(pagesUrl: string): Promise<{ count: number }>
putProgress(id: number, locator: Record<string, unknown>, percent: number, keepalive?: boolean): Promise<void>
listProgress(): Promise<ProgressRow[]>
}
```
- [x] **Step 1: 写失败测试 `web/test/client.test.ts`**
```ts
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');
});
```
- [x] **Step 2: 跑,确认失败**
Run: `cd web && npx vitest run test/client.test.ts`
Expected: FAIL(`../src/api/client` 不存在)
- [x] **Step 3: 实现 `src/api/types.ts` 与 `src/api/client.ts`**
`src/api/types.ts` — 内容即 Interfaces 块中 types.ts 声明,原样落成(加 `export` 每行):
```ts
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";
}
```
`src/api/client.ts`:
```ts
import type { Book, Library, Me, ProgressRow, User } from "./types";
export const TOKEN_KEY = "booklib.token";
export const LOGOUT_EVENT = "booklib:logout";
// 无 localStorage 环境(vitest node)退化为内存,仅测试路径生效
const mem = new Map<string, string>();
const hasLS = typeof localStorage !== "undefined";
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"),
};
```
- [x] **Step 4: 跑门槛确认通过**
Run: `cd web && npm run check`
Expected: vitest 7 个 case 全 PASS,tsc/build 无错。
- [x] **Step 5: Commit**
```bash
git add web && git commit -m "feat(web): typed api client with bearer token, 401 logout, binary channels"
```
---
### Task 3: 应用壳 — auth 上下文、登录页、路由守卫、Toaster、ErrorBoundary
**Files:**
- Create: `web/src/lib/qc.ts`、`web/src/auth/AuthContext.tsx`、`web/src/components/Toaster.tsx`、`web/src/components/ErrorBoundary.tsx`、`web/src/components/ui.ts`、`web/src/pages/Login.tsx`
- Modify: `web/src/main.tsx`(整文件替换)、`web/src/App.tsx`(整文件替换)
**Interfaces:**
- Consumes: Task 2 `api`、`getToken/setToken/LOGOUT_EVENT`
- Produces:
- `queryClient: QueryClient`(`lib/qc.ts`,main.tsx 与任意处 import 的单例)
- `AuthProvider({children})`、`useAuth(): { user: Me | null; isAdmin: boolean; ready: boolean; login(u,p): Promise<void>; logout(): void }`
- `RequireAuth({children})`、`RequireAdmin({children})`(react-router-dom)
- `toast(kind: "ok" | "err", text: string)` + `<Toaster/>`;`<ErrorBoundary>`
- className 常量:`btn`、`btnPrimary`、`input`、`card`
UI 装配任务无纯逻辑单测,门槛 = `npm run check`。
- [x] **Step 1: 实现 `src/lib/qc.ts`**
```ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } },
});
```
- [x] **Step 2: 实现 `src/components/ui.ts`**
```ts
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";
```
- [x] **Step 3: 实现 `src/components/Toaster.tsx`**
```tsx
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>
);
}
```
- [x] **Step 4: 实现 `src/components/ErrorBoundary.tsx`**
```tsx
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;
}
}
```
- [x] **Step 5: 实现 `src/auth/AuthContext.tsx`**
```tsx
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());
useEffect(() => {
const off = () => setTok(null); // client 的 401 拦截在这里回收 React 状态
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) 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);
qc.clear();
},
}),
[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 />;
}
```
- [x] **Step 6: 实现 `src/pages/Login.tsx`**
```tsx
import { useState, type FormEvent } from "react";
import { Navigate, useLocation, useNavigate } 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 nav = useNavigate();
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);
nav("/", { replace: true });
} 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>
);
}
```
- [x] **Step 7: 替换 `src/main.tsx` 与 `src/App.tsx`**
`src/main.tsx`:
```tsx
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";
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>,
);
```
`src/App.tsx`(Task 5/6/11 会再整文件替换):
```tsx
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, RequireAuth } from "./auth/AuthContext";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Toaster } from "./components/Toaster";
import Login from "./pages/Login";
function Placeholder({ name }: { name: string }) {
return <div className="p-8 text-zinc-500">{name}:待后续任务落地</div>;
}
export default function App() {
return (
<ErrorBoundary>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<Placeholder name="书架" />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster />
</AuthProvider>
</ErrorBoundary>
);
}
```
- [x] **Step 8: 门槛 + 人工冒烟(可选)**
Run: `cd web && npm run check` → 全绿。
人工(有环境时):起 dev 后端 + `npm run dev`,浏览器访问 :5173,未登录应跳 `/login`,错误密码出红色 toast。
- [x] **Step 9: Commit**
```bash
git add web && git commit -m "feat(web): auth context, login page, route guards, toast/error shell"
```
---
### Task 4: 鉴权图片通道 + 封面组件 + 目录分组(纯逻辑先行)
**Files:**
- Create: `web/src/lib/authImage.ts`、`web/src/lib/group.ts`、`web/src/components/Cover.tsx`
- Test: `web/test/authImage.test.ts`、`web/test/group.test.ts`
**Interfaces:**
- Consumes: Task 2 `apiRaw`;Task 3 `btn`
- Produces:
- `fetchObjectUrl(url: string, signal?: AbortSignal): Promise<string>` — 带鉴权取图 → objectURL;LRU(600)缓存;失败自动出缓存可重试;`__resetImageCache()` 供测试
- `useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void }`(`Cover.tsx` 内导出)
- `Cover({ book, className })`(3:4 占位、失败显示重试按钮,spec §9)
- `group.ts`:`interface Group { dir: string; books: Book[] }`;`groupByDir(books: Book[]): Group[]`
- [x] **Step 1: 写失败测试**
`test/group.test.ts`:
```ts
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([]);
});
```
`test/authImage.test.ts`:
```ts
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);
});
```
- [x] **Step 2: 跑,确认失败**
Run: `cd web && npx vitest run test/group.test.ts test/authImage.test.ts`
Expected: FAIL(模块不存在)
- [x] **Step 3: 实现 `src/lib/group.ts`**
```ts
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 }));
}
```
- [x] **Step 4: 实现 `src/lib/authImage.ts` 与 `src/components/Cover.tsx`**
`src/lib/authImage.ts`:
```ts
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();
}
```
`src/components/Cover.tsx`:
```tsx
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 ?? ""}`} />;
}
```
- [x] **Step 5: 跑门槛确认通过**
Run: `cd web && npm run check`
Expected: 新增 4 个用例 PASS;build 无错。
- [x] **Step 6: Commit**
```bash
git add web && git commit -m "feat(web): authed blob image pipeline with LRU, cover card, dir grouping"
```
---
### Task 5: 书架页 — 库 tab、搜索、分组卡片、继续阅读、损坏态、admin 删除/扫描
**Files:**
- Create: `web/src/components/TopBar.tsx`、`web/src/pages/Shelf.tsx`
- Modify: `web/src/App.tsx`(整文件替换)
**Interfaces:**
- Consumes: Task 2 `api`;Task 3 `useAuth`、`btn/btnPrimary/input/card`;Task 4 `Cover`、`groupByDir`;`queryClient`
- Produces: 路由 `/`(书架);`TopBar({ right?: ReactNode })`(admin 页在 Task 11 复用);TanStack Query key 约定:`["libraries"]`、`["books", libId, q]`、`["progress"]`、`["book", id]`
- [x] **Step 1: 实现 `src/components/TopBar.tsx`**
```tsx
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>
);
}
```
- [x] **Step 2: 实现 `src/pages/Shelf.tsx`**
```tsx
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>
);
}
```
- [x] **Step 3: 替换 `src/App.tsx`**
```tsx
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, RequireAuth } from "./auth/AuthContext";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Toaster } from "./components/Toaster";
import Login from "./pages/Login";
import Shelf from "./pages/Shelf";
function Placeholder({ name }: { name: string }) {
return <div className="p-8 text-zinc-500">{name}:待后续任务落地</div>;
}
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>
<Placeholder name="阅读器" />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster />
</AuthProvider>
</ErrorBoundary>
);
}
```
- [x] **Step 4: 门槛**
Run: `cd web && npm run check`
Expected: 全绿。
- [x] **Step 5: Commit**
```bash
git add web && git commit -m "feat(web): shelf with library tabs, search, dir groups, covers, continue-reading, admin delete/scan"
```
---
### Task 6: 进度内核(节流+keepalive)+ Reader 壳(按格式分发)
**Files:**
- Create: `web/src/lib/progress.ts`、`web/src/lib/useProgress.ts`、`web/src/pages/Reader.tsx`
- Modify: `web/src/App.tsx`(整文件替换,`/book/:id` 换成真 Reader)
- Test: `web/test/progress.test.ts`
**Interfaces:**
- Consumes: Task 2 `api.putProgress`(第 4 参 keepalive);Task 3 `queryClient`
- Produces:
- `lib/progress.ts`:`type ProgressSend = (locator: Record<string, unknown>, percent: number, keepalive: boolean) => Promise<unknown>`;`interface ProgressSaver { report(locator, percent): void; flush(): void }`;`createProgressSaver(send: ProgressSend, intervalMs?: number): ProgressSaver`(leading-edge + 尾部合并,flush 去重)
- `lib/useProgress.ts`:`interface ReaderProps { book: Book; initialLocator?: Record<string, unknown> }`;`useProgressSaver(bookId: number): ProgressSaver`(自动挂 pagehide/visibilitychange→flush)
- `pages/Reader.tsx`:`/book/:id` 壳;4 个 reader 任务各向 `READERS` 表加一行 lazy
- [x] **Step 1: 写失败测试 `test/progress.test.ts`**
```ts
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 消费,定时器不再补发
});
```
- [x] **Step 2: 跑,确认失败**
Run: `cd web && npx vitest run test/progress.test.ts`
Expected: FAIL(模块不存在)
- [x] **Step 3: 实现 `src/lib/progress.ts`**
```ts
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);
},
};
}
```
- [x] **Step 4: 实现 `src/lib/useProgress.ts`**
```ts
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;
}
```
- [x] **Step 5: 实现 `src/pages/Reader.tsx`**
每个 reader 任务会向 `READERS` 加一行(本任务是空表 → 一律"未知格式"占位,先验证壳)。`initialLocator` 由壳统一解析(等 `["progress"]` 加载完再挂载 reader,消除恢复竞态)。
```tsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Suspense, type ComponentType, type LazyExoticComponent } from "react"; // lazy 自 Task 7 起随首个 READERS 条目补进本行(noUnusedLocals)
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";
// reader 任务逐个往里加:Task 7 cbz → Task 8 txt/md → Task 9 pdf → Task 10 epub
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {};
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>
);
}
```
- [x] **Step 6: 替换 `src/App.tsx`**
```tsx
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, 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";
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="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster />
</AuthProvider>
</ErrorBoundary>
);
}
```
- [x] **Step 7: 跑门槛确认通过**
Run: `cd web && npm run check`
Expected: progress 2 用例 PASS;build 无错(空 READERS 是合法态)。
- [x] **Step 8: Commit**
```bash
git add web && git commit -m "feat(web): throttled progress saver with keepalive flush, reader dispatch shell"
```
---
### Task 7: CBZ Reader — 虚拟滚动页索引(virt 纯类先行)
**Files:**
- Create: `web/src/lib/virt.ts`、`web/src/readers/CbzReader.tsx`
- Modify: `web/src/pages/Reader.tsx`(READERS 表加 cbz)
- Test: `web/test/virt.test.ts`
**Interfaces:**
- Consumes: `api.pageCount(book.pages_url)`→`{count}`;`formatPageUrl(book.page_url_fmt, n)`;`fetchObjectUrl`;`useProgressSaver`/`ReaderProps`
- Produces: `lib/virt.ts`:`class PageHeights { constructor(n: number, est: number); set(i: number, h: number): number /* 高度增量 */; offset(i: number): number; total(): number; pageAt(y: number): number; range(scrollTop: number, viewport: number, overscan: number): [number, number] /* [start, end) */ }`;`readers/CbzReader.tsx` default export `(p: ReaderProps) => JSX`,locator `{page}`(0 基),percent `(page+1)/count`
- [x] **Step 1: 写失败测试 `test/virt.test.ts`**
```ts
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);
});
```
- [x] **Step 2: 跑,确认失败**
Run: `cd web && npx vitest run test/virt.test.ts`
Expected: FAIL(模块不存在)
- [x] **Step 3: 实现 `src/lib/virt.ts`**
```ts
// 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); // [start, end)
return [a, b];
}
}
```
- [x] **Step 4: 实现 `src/readers/CbzReader.tsx`**
```tsx
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 页
}
}
if (countQ.isPending) 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 [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(book.page_url_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>
);
}
```
- [x] **Step 5: `src/pages/Reader.tsx` 接线 cbz**
先把 react import 行还原为含 `lazy`(见 Task 6 注释),再整块替换 READERS:
```tsx
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
cbz: lazy(() => import("../readers/CbzReader")),
};
```
- [x] **Step 6: 跑门槛确认通过**
Run: `cd web && npm run check`
Expected: virt 3 用例 PASS;build 无错。
- [x] **Step 7: Commit**
```bash
git add web && git commit -m "feat(web): cbz reader — virtual scroll with height correction, prefetch, progress restore"
```
---
### Task 8: TXT/MD Reader(marked + DOMPurify,scrollFraction 进度)
**Files:**
- Create: `web/src/readers/TextReader.tsx`
- Modify: `web/src/pages/Reader.tsx`(READERS 加 txt/md)、`web/src/index.css`(加 md 正文样式)
**Interfaces:**
- Consumes: `apiRaw(book.file_url).text()`;`useProgressSaver`/`ReaderProps`
- Produces: `readers/TextReader.tsx` default export `(p: ReaderProps)`,同时服务 txt 与 md;locator `{scrollFraction}`(0..1),percent 同值
- [x] **Step 1: 实现 `src/readers/TextReader.tsx`**
```tsx
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>
);
}
```
- [x] **Step 2: `index.css` 末尾追加 md 正文样式(Tailwind4 无 typography 插件,不新增依赖)**
```css
/* 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; }
```
- [x] **Step 3: `src/pages/Reader.tsx` 接线**
`READERS` 整块替换:
```tsx
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
cbz: lazy(() => import("../readers/CbzReader")),
txt: lazy(() => import("../readers/TextReader")),
md: lazy(() => import("../readers/TextReader")),
};
```
- [x] **Step 4: 门槛**
Run: `cd web && npm run check`
Expected: 全绿(vitest 无新增;门槛=编译+构建)。
- [x] **Step 5: Commit**
```bash
git add web && git commit -m "feat(web): txt/md reader with dompurified markdown and scroll-fraction progress"
```
---
### Task 9: PDF Reader(PDF.js,单页画布渲染)
**Files:**
- Create: `web/src/readers/PdfReader.tsx`
- Modify: `web/src/pages/Reader.tsx`(READERS 加 pdf)
**Interfaces:**
- Consumes: `apiRaw(book.file_url).arrayBuffer()`;`useProgressSaver`/`ReaderProps`
- Produces: `readers/PdfReader.tsx` default export `(p: ReaderProps)`;locator `{page}`(0 基,与 CBZ 统一 0 基),percent `(page+1)/numPages`
- [x] **Step 1: 实现 `src/readers/PdfReader.tsx`**
```tsx
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 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 doc = await pdfjs.getDocument({ data: new Uint8Array(buf) }).promise;
if (dead) {
await doc.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();
docRef.current?.destroy();
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({ canvasContext: ctx, 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>
);
}
```
- [x] **Step 2: `src/pages/Reader.tsx` 接线**
`READERS` 整块替换:
```tsx
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")),
};
```
- [x] **Step 3: 门槛**
Run: `cd web && npm run check`
Expected: 全绿。若 `p.render({canvasContext, viewport})` 在已装 pdfjs-dist 大版本下类型不符(历史上参数名有 `canvas`/`canvasContext` 差异),按 node_modules 内 `types/display.d.xml`→`RenderParameters` 实际字段名调整这一处,**不改其余逻辑**。
- [x] **Step 4: Commit**
```bash
git add web && git commit -m "feat(web): pdf reader with dpr-aware canvas rendering and keyboard paging"
```
---
### Task 10: EPUB Reader(epub.js,CFI 进度)
**Files:**
- Create: `web/src/types/shims.d.ts`、`web/src/readers/EpubReader.tsx`
- Modify: `web/src/pages/Reader.tsx`(READERS 加 epub)
**Interfaces:**
- Consumes: `apiRaw(book.file_url).arrayBuffer()`;`useProgressSaver`/`ReaderProps`
- Produces: `readers/EpubReader.tsx` default export `(p: ReaderProps)`;locator `{cfi}`,percent `(sectionIndex+1)/spineLength`
- [x] **Step 1: 写 `src/types/shims.d.ts`(epubjs 0.3 类型不完备,兜底声明,包内自带类型时自动失效)**
```ts
declare module "epubjs";
```
- [x] **Step 2: 实现 `src/readers/EpubReader.tsx`**
```tsx
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): 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;
try {
r.display(cfi); // cfi 失效(书被替换等)时 epubjs 会抛
} catch {
r.display();
}
setReady(true);
})().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>
);
}
```
- [x] **Step 3: `src/pages/Reader.tsx` 接线**
`READERS` 整块替换:
```tsx
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")),
};
```
- [x] **Step 4: 门槛**
Run: `cd web && npm run check`
Expected: 全绿。
- [x] **Step 5: Commit**
```bash
git add web && git commit -m "feat(web): epub reader via epubjs arraybuffer bootstrap with cfi progress"
```
---
### Task 11: Admin — 用户管理 + 库管理(建库/扫描/上传)
**Files:**
- Create: `web/src/pages/admin/Users.tsx`、`web/src/pages/admin/Libraries.tsx`
- Modify: `web/src/App.tsx`(整文件替换)
**Interfaces:**
- Consumes: Task 2 `api.*`;Task 3 `useAuth/RequireAdmin`;Task 5 `TopBar`、ui 常量
- Produces: 路由 `/admin/users`、`/admin/libraries`(TopBar 已挂链接);query key `["users"]`
- [x] **Step 1: 实现 `src/pages/admin/Users.tsx`**
```tsx
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} 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>
);
}
```
- [x] **Step 2: 实现 `src/pages/admin/Libraries.tsx`**
```tsx
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,.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/pdf/epub/txt/md</span>
</div>
</div>
))}
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
<p className="text-sm text-zinc-500">还没有库,用上方表单创建。</p>
)}
</div>
</div>
</div>
);
}
```
- [x] **Step 3: 替换 `src/App.tsx`(补 admin 路由)**
```tsx
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>
);
}
```
- [x] **Step 4: 门槛**
Run: `cd web && npm run check`
Expected: 全绿。人工(可选):member 登录看不到 admin 链接,直敲 /admin/users 被弹回 `/`。
- [x] **Step 5: Commit**
```bash
git add web && git commit -m "feat(web): admin users CRUD + libraries create/scan/upload"
```
---
### Task 12: PWA(Service Worker)+ Docker 前端落地 + 全栈冒烟 + README
**Files:**
- Modify: `web/vite.config.ts`(整文件替换)、`web/tsconfig.json`(types 数组)、`web/src/main.tsx`(加 SW 注册)、`web/src/auth/AuthContext.tsx`(logout 清 SW 缓存)、`deploy/Dockerfile.web`(整文件替换)、`.dockerignore`(追加)、`README.md`(追加前端章节)
- Create: `web/src/pwa.ts`、`scripts/smoke-web.sh`
- Delete: `deploy/web-dist/`(占位页完成使命)
**Interfaces:**
- Consumes: Task 1-11 的全部产物;`web/dist`(vite build)
- Produces: 生产镜像 = SPA + nginx(`/api/` 反代);SW:不可变资源 cache-first、其余 /api network-first(spec §6.3)
- [x] **Step 1: 替换 `web/vite.config.ts`**
```ts
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.search.includes("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": "http://localhost:8080" } },
});
```
- [x] **Step 2: `web/tsconfig.json` 的 `"types"` 行替换**
```json
"types": ["vite/client", "vite-plugin-pwa/client"],
```
- [x] **Step 3: 新建 `web/src/pwa.ts` 并在 `main.tsx` 引入**
`src/pwa.ts`:
```ts
import { registerSW } from "virtual:pwa-register";
// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗
registerSW({ immediate: true });
```
`src/main.tsx` 在 `import "./index.css";` 之后加一行:
```ts
import "./pwa";
```
- [x] **Step 4: `src/auth/AuthContext.tsx` logout 加清 SW 缓存(同设备多账号隔离)**
`logout` 实现整块替换为:
```ts
logout: () => {
setToken(null);
setTok(null);
qc.clear();
if (typeof caches !== "undefined") {
void caches.delete("booklib-api");
void caches.delete("booklib-immutable");
}
},
```
- [x] **Step 5: 构建验证 SW 产物**
Run: `cd web && npm run check && ls dist | grep -E 'sw\.js|workbox|manifest'`
Expected: check 全绿;列出 `sw.js`、`workbox-*.js`、`manifest.webmanifest`。
- [x] **Step 6: 替换 `deploy/Dockerfile.web`、删除占位、补 .dockerignore**
```
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
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html
```
```bash
git rm -r deploy/web-dist
```
`.dockerignore` 末尾追加:
```
web/node_modules
web/dist
web/dev-dist
```
- [x] **Step 7: 写全栈冒烟 `scripts/smoke-web.sh`**
```bash
#!/usr/bin/env bash
# 全栈部署冒烟:经 nginx 验证 SPA 静态、/api 反代、登录、SW/manifest。
# 前提:.env 里有 JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD;宿主 :8080 空闲。
set -euo pipefail
BASE=${BASE:-http://localhost:8080}
[ -f .env ] && set -a && . ./.env && set +a
: "${JWT_SECRET:?JWT_SECRET 未设置}"; : "${ADMIN_USER:?ADMIN_USER 未设置}"; : "${ADMIN_PASSWORD:?ADMIN_PASSWORD 未设置}"
say(){ echo "smoke-web: $1"; }
die(){ echo "SMOKE-WEB FAIL: $1"; exit 1; }
say "build web + api images"
docker compose build web api
say "up --wait"
docker compose up -d --wait postgres redis api web
say "SPA index served by nginx"
INDEX=$(curl -fsS "$BASE/")
echo "$INDEX" | grep -q 'id="root"' || die "index 缺 #root"
ASSET=$(echo "$INDEX" | sed -nE 's#.*src="(/assets/[^"]+\.js)".*#\1#p' | head -1)
[ -n "$ASSET" ] || die "index 未引用 /assets/*.js"
curl -fsS -o /dev/null "$BASE$ASSET" || die "asset $ASSET 拉取失败"
say "SPA deep link falls back to index"
curl -fsS "$BASE/admin/users" | grep -q 'id="root"' || die "try_files 回退失败"
say "api via /api/ proxy"
curl -fsS "$BASE/api/healthz" >/dev/null || die "healthz via nginx"
TOK=$(curl -fsS "$BASE/api/auth/login" -H 'content-type: application/json' \
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASSWORD\"}" \
| sed -E 's/.*"token":"([^"]+)".*/\1/')
[ -n "$TOK" ] || die "nginx 反代登录失败"
curl -fsS "$BASE/api/auth/me" -H "authorization: Bearer $TOK" | grep -q "$ADMIN_USER" || die "me via nginx"
say "pwa artifacts"
curl -fsS "$BASE/sw.js" >/dev/null || die "sw.js 缺失"
curl -fsS "$BASE/manifest.webmanifest" >/dev/null || die "manifest 缺失"
say "ALL WEB SMOKE PASSED"
```
- [x] **Step 8: 跑冒烟**
```bash
chmod +x scripts/smoke-web.sh
docker compose down 2>/dev/null || true
bash scripts/smoke-web.sh
docker compose down
```
Expected: `ALL WEB SMOKE PASSED`(compose 栈与 Plan 1 `scripts/smoke.sh` 共用,若后端栈在跑先 down)。
- [x] **Step 9: README 追加前端章节**
在 `README.md` 末尾追加(若已有相关章节则合并去重,不重复端口说明):
```markdown
## 前端开发(web/)
- `cd web && npm install`;`npm run dev`(:5173,`/api` 代理到 :8080)。
- 后端起法:`docker compose -f deploy/docker-compose.dev.yml up -d --wait` 起 PG(:5433)/Redis(:6380),
然后 `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \
REDIS_URL=redis://localhost:6380 JWT_SECRET=dev go run ./cmd/server`。
- 门槛:`npm run check`(tsc + vitest + vite build)。
## 生产部署(含前端)
- `.env` 配 `JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD` 后 `docker compose up -d --build`;
打开 http://localhost:8080(web=SPA+nginx,/api 反代 api:8080)。
- PWA:不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
- 冒烟:`bash scripts/smoke.sh`(后端直连)、`bash scripts/smoke-web.sh`(经 nginx 全栈,需 :8080 空闲)。
```
- [x] **Step 10: 最终门槛 + Commit**
```bash
cd web && npm run check && cd ..
git add web deploy .dockerignore scripts/smoke-web.sh README.md
git commit -m "feat(web): pwa offline caching, dockerized SPA, full-stack nginx smoke"
```
---
## 完成后
全部 12 个任务完成且 `npm run check`、`scripts/smoke-web.sh` 双绿后:
**REQUIRED SUB-SKILL:** superpowers:finishing-a-development-branch(验证 → 选项 → 合并)。
建议合并前人工过一遍(spec §10 未要求 e2e 框架,以下靠肉眼):
1. admin 建库→上传 cbz→扫描→书架出封面;member 登录只见书架无删除按钮。
2. 四格式各开一本:翻页/翻篇后刷新同位置续上;关页秒保存。
3. 断网(F12 offline)重开:SPA 壳与读过的 CBZ 页可看。
## 执行修订记录(plan2-frontend 分支最终态)
执行期对上文计划文本的有效修订(评审/环境驱动,均已随代码落地):
- **sendBeacon → fetch keepalive**(写计划时已注):后端 Bearer-only,beacon 无法带 header,语义等价。
- **Task 5**:扫描按钮补 `isAdmin` 守卫(全局约束"写操作仅 admin 可见"优先于示例代码;已就地修订上文)。
- **Task 6/7**:Reader.tsx 的 `lazy` import 随首个 READERS 条目(Task 7)引入(`noUnusedLocals` 不允许空表时导入)。
- **Task 7**:`PageHeights.range` 返回 `[start, end)` 端点排除(brief 参考实现与 brief 测试矛盾,以测试为准;已就地修订上文)。
- **Task 9**:已装 pdfjs-dist 为 v6.3 — `page.render({ canvas, viewport })`(canvasContext 已废)、`destroy()` 移到 loading task;仅调用点适配。
- **Task 10/终评**:`display(cfi)` 失败是 promise reject 非同步 throw → 改为 promise 链回退无参 display 再 setErr;`setReady` 仅在 display 成功后。
- **Task 12**:新增 `deploy/entrypoint-resolver.sh` —— `deploy/nginx.conf` 恢复 spec §11 变量式 `proxy_pass`,`resolver` 地址启动时从 `/etc/resolv.conf` 首个 nameserver 注入(Docker=127.0.0.11 / podman=gateway 双运行时兼容;计划负责人裁决"保 spec 语义")。Dockerfile.web 的 nginx stage `COPY --chmod=755` 该脚本并覆写 ENTRYPOINT(exec "$@" 保留 CMD)。
- **终评分支修复波**(cc66a64):nginx 增加 CSP header(spec §7 的既定缓解);AuthContext 401 登出与显式 logout 统一 `clearSession()`(qc.clear + 双 SW 缓存删除);admin 密码框 `type=password`;SW 不可变匹配 `searchParams.has("v")`;CBZ 缺 pages_url 出错误占位。
- 冒烟脚本 `--wait` 在 podman-compose 下失败时回退 + healthz 轮询(仅脚本层,仓库文件未降级)。