Files
book-comic-library/docs/superpowers/plans/2026-09-04-frontend.md
T

104 KiB

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):

{ "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)

  • Step 1: 初始化工程并装依赖(一次性,后续任务不再装)

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 间未变,后续代码不用改。

  • Step 2: 写配置文件

web/.gitignore:

node_modules
dist
dev-dist

web/tsconfig.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:

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:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true },
});
  • Step 3: 写入口文件

web/index.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:

<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:

@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:

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 整文件替换):

export default function App() {
  return <div className="p-8 text-zinc-400">booklib web</div>;
}
  • Step 4: 跑门槛确认通过

Run: cd web && npm run check Expected: tsc 无错、vitest No test files匹配 → passWithNoTests OK、vite build 产出 dist/。

  • Step 5: Commit
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(后续全部任务依赖,签名精确):
// 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[]>
}
  • Step 1: 写失败测试 web/test/client.test.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');
});
  • Step 2: 跑,确认失败

Run: cd web && npx vitest run test/client.test.ts Expected: FAIL(../src/api/client 不存在)

  • Step 3: 实现 src/api/types.ts 与 src/api/client.ts

src/api/types.ts — 内容即 Interfaces 块中 types.ts 声明,原样落成(加 export 每行):

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:

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"),
};
  • Step 4: 跑门槛确认通过

Run: cd web && npm run check Expected: vitest 7 个 case 全 PASS,tsc/build 无错。

  • Step 5: Commit
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。

  • Step 1: 实现 src/lib/qc.ts
import { QueryClient } from "@tanstack/react-query";

export const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } },
});
  • Step 2: 实现 src/components/ui.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";
  • Step 3: 实现 src/components/Toaster.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>
  );
}
  • Step 4: 实现 src/components/ErrorBoundary.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;
  }
}
  • Step 5: 实现 src/auth/AuthContext.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 />;
}
  • Step 6: 实现 src/pages/Login.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>
  );
}
  • Step 7: 替换 src/main.tsx 与 src/App.tsx

src/main.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 会再整文件替换):

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>
  );
}
  • Step 8: 门槛 + 人工冒烟(可选)

Run: cd web && npm run check → 全绿。 人工(有环境时):起 dev 后端 + npm run dev,浏览器访问 :5173,未登录应跳 /login,错误密码出红色 toast。

  • Step 9: Commit
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[]
  • Step 1: 写失败测试

test/group.test.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:

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);
});
  • Step 2: 跑,确认失败

Run: cd web && npx vitest run test/group.test.ts test/authImage.test.ts Expected: FAIL(模块不存在)

  • Step 3: 实现 src/lib/group.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 }));
}
  • Step 4: 实现 src/lib/authImage.ts 与 src/components/Cover.tsx

src/lib/authImage.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:

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 ?? ""}`} />;
}
  • Step 5: 跑门槛确认通过

Run: cd web && npm run check Expected: 新增 4 个用例 PASS;build 无错。

  • Step 6: Commit
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]

  • Step 1: 实现 src/components/TopBar.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>
  );
}
  • Step 2: 实现 src/pages/Shelf.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>
  );
}
  • Step 3: 替换 src/App.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>
  );
}
  • Step 4: 门槛

Run: cd web && npm run check Expected: 全绿。

  • Step 5: Commit
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
  • Step 1: 写失败测试 test/progress.test.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 消费,定时器不再补发
});
  • Step 2: 跑,确认失败

Run: cd web && npx vitest run test/progress.test.ts Expected: FAIL(模块不存在)

  • Step 3: 实现 src/lib/progress.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);
    },
  };
}
  • Step 4: 实现 src/lib/useProgress.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;
}
  • Step 5: 实现 src/pages/Reader.tsx

每个 reader 任务会向 READERS 加一行(本任务是空表 → 一律"未知格式"占位,先验证壳)。initialLocator 由壳统一解析(等 ["progress"] 加载完再挂载 reader,消除恢复竞态)。

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>
  );
}
  • Step 6: 替换 src/App.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>
  );
}
  • Step 7: 跑门槛确认通过

Run: cd web && npm run check Expected: progress 2 用例 PASS;build 无错(空 READERS 是合法态)。

  • Step 8: Commit
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

  • Step 1: 写失败测试 test/virt.test.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);
});
  • Step 2: 跑,确认失败

Run: cd web && npx vitest run test/virt.test.ts Expected: FAIL(模块不存在)

  • Step 3: 实现 src/lib/virt.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 - 1, this.pageAt(scrollTop + viewport) + overscan);
    return [a, b + 1];
  }
}
  • Step 4: 实现 src/readers/CbzReader.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>
  );
}
  • Step 5: src/pages/Reader.tsx 接线 cbz

先把 react import 行还原为含 lazy(见 Task 6 注释),再整块替换 READERS:

const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
  cbz: lazy(() => import("../readers/CbzReader")),
};
  • Step 6: 跑门槛确认通过

Run: cd web && npm run check Expected: virt 3 用例 PASS;build 无错。

  • Step 7: Commit
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 同值

  • Step 1: 实现 src/readers/TextReader.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>
  );
}
  • Step 2: index.css 末尾追加 md 正文样式(Tailwind4 无 typography 插件,不新增依赖)
/* 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; }
  • Step 3: src/pages/Reader.tsx 接线

READERS 整块替换:

const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
  cbz: lazy(() => import("../readers/CbzReader")),
  txt: lazy(() => import("../readers/TextReader")),
  md: lazy(() => import("../readers/TextReader")),
};
  • Step 4: 门槛

Run: cd web && npm run check Expected: 全绿(vitest 无新增;门槛=编译+构建)。

  • Step 5: Commit
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

  • Step 1: 实现 src/readers/PdfReader.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>
  );
}
  • Step 2: src/pages/Reader.tsx 接线

READERS 整块替换:

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")),
};
  • Step 3: 门槛

Run: cd web && npm run check Expected: 全绿。若 p.render({canvasContext, viewport}) 在已装 pdfjs-dist 大版本下类型不符(历史上参数名有 canvas/canvasContext 差异),按 node_modules 内 types/display.d.xml→RenderParameters 实际字段名调整这一处,不改其余逻辑。

  • Step 4: Commit
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

  • Step 1: 写 src/types/shims.d.ts(epubjs 0.3 类型不完备,兜底声明,包内自带类型时自动失效)

declare module "epubjs";
  • Step 2: 实现 src/readers/EpubReader.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>
  );
}
  • Step 3: src/pages/Reader.tsx 接线

READERS 整块替换:

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")),
};
  • Step 4: 门槛

Run: cd web && npm run check Expected: 全绿。

  • Step 5: Commit
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"]

  • Step 1: 实现 src/pages/admin/Users.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>
  );
}
  • Step 2: 实现 src/pages/admin/Libraries.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>
  );
}
  • Step 3: 替换 src/App.tsx(补 admin 路由)
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>
  );
}
  • Step 4: 门槛

Run: cd web && npm run check Expected: 全绿。人工(可选):member 登录看不到 admin 链接,直敲 /admin/users 被弹回 /。

  • Step 5: Commit
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)

  • Step 1: 替换 web/vite.config.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" } },
});
  • Step 2: web/tsconfig.json 的 "types" 行替换
    "types": ["vite/client", "vite-plugin-pwa/client"],
  • Step 3: 新建 web/src/pwa.ts 并在 main.tsx 引入

src/pwa.ts:

import { registerSW } from "virtual:pwa-register";

// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗
registerSW({ immediate: true });

src/main.tsx 在 import "./index.css"; 之后加一行:

import "./pwa";
  • Step 4: src/auth/AuthContext.tsx logout 加清 SW 缓存(同设备多账号隔离)

logout 实现整块替换为:

      logout: () => {
        setToken(null);
        setTok(null);
        qc.clear();
        if (typeof caches !== "undefined") {
          void caches.delete("booklib-api");
          void caches.delete("booklib-immutable");
        }
      },
  • 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。

  • 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
git rm -r deploy/web-dist

.dockerignore 末尾追加:

web/node_modules
web/dist
web/dev-dist
  • Step 7: 写全栈冒烟 scripts/smoke-web.sh
#!/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"
  • Step 8: 跑冒烟
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)。

  • Step 9: README 追加前端章节

在 README.md 末尾追加(若已有相关章节则合并去重,不重复端口说明):

## 前端开发(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 空闲)。
  • Step 10: 最终门槛 + Commit
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 页可看。