# Frontend (Web Reader & Shelf) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现 book-comic-library 前端 SPA:登录、书架(库/目录分组/搜索/进度/损坏态)、四个 reader(CBZ 虚拟滚动、PDF.js、epub.js、TXT/MD)、admin 用户与库管理、PWA 离线缓存,并把 `web/` 构建并入 Docker 部署替换占位页。 **Architecture:** React + Vite + TS 单 SPA,只依赖后端 `/api/*`(Plan 1 已交付并合并,路由表见 `backend/internal/api/router.go`)。TanStack Query 管服务端状态,React Router 管页面,无全局 store。后端鉴权**只认 `Authorization: Bearer`**(见 `backend/internal/api/api.go` authMw,无 query-token 后门),因此封面/页图/PDF/EPUB 一律经带 token 的 fetch 转 objectURL 渲染;进度关闭兜底用 `fetch keepalive` 而非 sendBeacon(beacon 无法携带 header,语义等价)。 **Tech Stack:** React 19、Vite 7、TypeScript 5(strict)、Tailwind CSS 4(深色默认)、TanStack Query 5、React Router 7、pdfjs-dist 5、epubjs 0.3、marked、DOMPurify、vite-plugin-pwa(Workbox)、vitest 3。 **Spec:** `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`(§8 前端、§6.3 离线、§9 前端错误处理、§10 前端测试) **Backend 计划:** `docs/superpowers/plans/2026-09-04-backend.md`(已完成、已合并;本 plan 不改后端一行代码) 本仓库根 = `book-comic-library/`,以下所有相对路径以此为根。前端代码全部在 `web/`,命令在 `web/` 目录执行(除非写明)。 ## 后端 API 契约(实测自已合并代码,前端类型以此为准) ``` POST /api/auth/login {username,password} → 200 {token} | 401 {"error":{code,message}} GET /api/auth/me → {id,username,role} GET /api/users ★ → [{id,username,role,created_at}] POST /api/users ★ {username,password,role} → 201 {...} | 409 exists | 400 弱密码/坏 role DELETE /api/users/{id} ★ → 204 | 400 删自己 | 404 GET /api/libraries → [{id,name,root_path,created_at}] POST /api/libraries ★ {name,root_path(绝对路径)} → 201 | 409 root_path taken POST /api/libraries/{id}/scan ★ → 202 {accepted} POST /api/libraries/{id}/upload ★ multipart 字段名 file → 202 {accepted,path} | 400 bad_format GET /api/books?library=&q= → [BookJSON] GET /api/books/{id} → BookJSON DELETE /api/books/{id} ★ → 204 GET /api/books/{id}/cover?v={hash} → image(任何书都有;缺封面时是内置 SVG 占位) GET /api/books/{id}/file?v={hash} → 原始 pdf/epub/txt/md,支持 Range,Cache-Control: private GET /api/books/{id}/pages → {count}(仅 cbz) GET /api/books/{id}/pages/{n}?v={hash} → image,immutable(仅 cbz,n 从 0 起) PUT /api/books/{id}/progress {locator,percent(0..1)} → 204 GET /api/progress → [{library_id,library,path,title,locator,percent,updated_at}] ``` `BookJSON`(见 `backend/internal/api/books.go` bookJSON): ```jsonc { "id":1, "library_id":2, "path":"series-a/01.cbz", "title":"01", "format":"cbz", "size":123, "mtime":1700, "pages":24, "state":"ready", "error":"", "added_at":"…RFC3339…", "percent":0.5, "cover_url":"/api/books/1/cover?v=…", "library":"comics", // format==="cbz" 时: "pages_url":"/api/books/1/pages", "page_url_fmt":"/api/books/1/pages/%d?v=…", // 否则: "file_url":"/api/books/1/file?v=…" } ``` ## Global Constraints - 一切 HTTP 走 `web/src/api/client.ts`;token 存 `localStorage["booklib.token"]`,请求头 `Authorization: Bearer`;**任何资源 URL 不得携带 token**(不进 query、不进日志)。 - 任何响应 401 → 清 token + 派发 `window` 事件 `booklib:logout` → AuthContext 登出并跳 `/login`(spec §9)。 - 图片/文件渲染只允许两条路:blob objectURL(Cover/CBZ 页)或 arraybuffer(pdfjs/epubjs);禁止 `` 直挂(必 401)。 - 进度:reader 统一 `onPositionChange(locator, percent)` 语义 = `saver.report(locator, percent)`,5s 节流;关页/切后台 `flush()` 用 `fetch keepalive`。locator 键按 spec §4:cbz/pdf `{page}`(0 基)、epub `{cfi}`、txt/md `{scrollFraction}`;后端不解释。upsert 成功后仅 invalidate `["book", id]`(spec §8)。 - 支持格式仅 `cbz pdf epub txt/md`;其余不入库也不会下发,reader 分发遇未知 format 显示占位。 - 写操作按钮(建库/上传/扫描/删书/用户 CRUD)仅 `role==="admin"` 可见;member 可 GET 一切 + PUT 自己的 progress。 - Tailwind 深色默认(`bg-zinc-950 text-zinc-100`),无主题切换。中文文案。 - 测试:`npm run check`(= `tsc --noEmit && vitest run && vite build`)是每任务唯一门槛;vitest 只测纯逻辑(client/group/virt/progress/authImage),不引 Testing Library/spec §10。 - Node ≥ 22。依赖只在 Task 1 一次装齐,后续任务禁止新增 npm 依赖。 - dev 联调:vite proxy `/api → http://localhost:8080`(起 `deploy/docker-compose.dev.yml` 的 PG/Redis + `go run ./cmd/server`);生产同源自 nginx,代码零改动。 ## File Structure ``` web/ package.json tsconfig.json vite.config.ts vitest.config.ts index.html .gitignore public/icon.svg src/ main.tsx # 挂载:providers + router;Task 12 加 SW 注册 App.tsx # 路由表(每任务整文件替换,追加路由) index.css # tailwind import + 全局深色底 api/types.ts # Book/Library/User/ProgressRow 等(与上方契约一致) api/client.ts # token 存取、401 拦截、apiFetch/apiRaw、api.*、formatPageUrl auth/AuthContext.tsx # AuthProvider/useAuth + RequireAuth/RequireAdmin lib/qc.ts # 单例 QueryClient lib/group.ts # 书架目录分组(纯函数,vitest) lib/virt.ts # CBZ 虚拟滚动高度模型(纯类,vitest) lib/progress.ts # 节流进度保存器(纯逻辑,vitest) lib/authImage.ts # 带鉴权图片 → objectURL LRU 缓存(vitest)+ useAuthedImage hook components/Toaster.tsx # 迷你全局 toast(spec 无组件库) components/ErrorBoundary.tsx components/ui.ts # btn/input/card 共享 className 常量 components/Cover.tsx # 鉴权封面图 + 失败重试占位 pages/Login.tsx pages/Shelf.tsx # 书架 pages/Reader.tsx # /book/:id 按 format 分发(lazy) readers/CbzReader.tsx readers/PdfReader.tsx readers/EpubReader.tsx readers/TextReader.tsx pages/admin/Users.tsx pages/admin/Libraries.tsx test/client.test.ts test/group.test.ts test/virt.test.ts test/progress.test.ts test/authImage.test.ts deploy/Dockerfile.web # Task 12:整文件替换为多阶段(node build → nginx) deploy/web-dist/ # Task 12:删除(占位页完成使命) .dockerignore # Task 12:补 web 条目 scripts/smoke-web.sh # Task 12:compose 全栈冒烟(经 nginx) README.md # Task 12:补前端 dev/部署说明 ``` 依赖方向:`pages/readers → lib/components → auth → api`。无循环。 --- ### Task 1: 脚手架 — Vite+React+TS+Tailwind4+vitest+dev proxy **Files:** - Create: `web/package.json`(npm 生成)、`web/.gitignore`、`web/tsconfig.json`、`web/vite.config.ts`、`web/vitest.config.ts`、`web/index.html`、`web/public/icon.svg`、`web/src/index.css`、`web/src/main.tsx`、`web/src/App.tsx` **Interfaces:** - Consumes: 无 - Produces: 可 `npm run check` 的工程;`npm run dev`(:5173,`/api` 代理到 :8080) - [x] **Step 1: 初始化工程并装依赖(一次性,后续任务不再装)** ```bash cd web npm init -y npm pkg set name=booklib-web version=0.1.0 private=true type=module npm pkg set scripts.dev=vite scripts.build="tsc --noEmit && vite build" scripts.preview="vite preview" scripts.test="vitest run" scripts.check="tsc --noEmit && vitest run && vite build" npm install react react-dom react-router-dom @tanstack/react-query marked dompurify pdfjs-dist npm install epubjs@0.3.93 npm install -D typescript vite @vitejs/plugin-react tailwindcss @tailwindcss/vite vitest @types/react @types/react-dom vite-plugin-pwa ``` Expected: 无 error 级输出;`node_modules`、`package-lock.json` 生成。epubjs 锁 0.3.93(上游停更,锁死可复现);其余用 latest。若 `npm install` 报 ERESOLVE(vite-plugin-pwa 与已装 vite 大版本 peer 冲突):降级安装最后一个兼容版 `npm i -D vite-plugin-pwa@0.21.2`,Task 12 的 `VitePWA({...})` 配置键在 0.21→1.x 间未变,后续代码不用改。 - [x] **Step 2: 写配置文件** `web/.gitignore`: ``` node_modules dist dev-dist ``` `web/tsconfig.json`: ```json { "compilerOptions": { "target": "ES2022", "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "isolatedModules": true, "skipLibCheck": true, "noEmit": true, "types": ["vite/client"] }, "include": ["src", "test"] } ``` `web/vite.config.ts`: ```ts import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [react(), tailwindcss()], server: { proxy: { "/api": "http://localhost:8080" } }, }); ``` `web/vitest.config.ts`: ```ts import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true }, }); ``` - [x] **Step 3: 写入口文件** `web/index.html`: ```html BookLib — 个人书库
``` `web/public/icon.svg`: ```html ``` `web/src/index.css`: ```css @import "tailwindcss"; html, body, #root { height: 100%; } body { @apply bg-zinc-950 text-zinc-100 antialiased; } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { @apply rounded bg-zinc-700; } ``` `web/src/main.tsx`: ```tsx import { createRoot } from "react-dom/client"; import App from "./App"; import "./index.css"; createRoot(document.getElementById("root")!).render(); ``` `web/src/App.tsx`(占位,Task 3 整文件替换): ```tsx export default function App() { return
booklib web
; } ``` - [x] **Step 4: 跑门槛确认通过** Run: `cd web && npm run check` Expected: `tsc` 无错、vitest `No test files匹配 → passWithNoTests OK`、`vite build` 产出 `dist/`。 - [x] **Step 5: Commit** ```bash git add web && git commit -m "feat(web): vite+react+ts+tailwind4 scaffold, vitest, /api dev proxy" ``` --- ### Task 2: API 类型 + client(401 拦截、错误体、blob/arraybuffer 通道) **Files:** - Create: `web/src/api/types.ts`、`web/src/api/client.ts` - Test: `web/test/client.test.ts` **Interfaces:** - Consumes: Task 1 - Produces(后续全部任务依赖,签名精确): ```ts // types.ts export type Format = "cbz" | "pdf" | "epub" | "txt" | "md"; export interface Book { id: number; library_id: number; path: string; title: string; format: Format; size: number; mtime: number; pages: number; state: "ready" | "error"; error: string; added_at: string; percent: number; cover_url: string; library?: string; pages_url?: string; page_url_fmt?: string; file_url?: string; } export interface Library { id: number; name: string; root_path: string; created_at: string } export interface User { id: number; username: string; role: "admin" | "member"; created_at: string } export interface ProgressRow { library_id: number; library: string; path: string; title: string; locator: Record; 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 export interface ReqOpts { method?: string; body?: unknown; form?: FormData; keepalive?: boolean; signal?: AbortSignal } export function apiFetch(path: string, opts?: ReqOpts): Promise // 相对 path 自动加 /api 前缀;已 /api 开头则原样 export function apiRaw(path: string, opts?: { signal?: AbortSignal; keepalive?: boolean }): Promise // 供 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 listUsers(): Promise createUser(username: string, password: string, role: string): Promise deleteUser(id: number): Promise listLibraries(): Promise createLibrary(name: string, root_path: string): Promise scanLibrary(id: number): Promise<{ accepted: boolean }> uploadBook(id: number, file: File): Promise<{ accepted: boolean; path: string }> listBooks(params?: { library?: number; q?: string }): Promise getBook(id: number): Promise deleteBook(id: number): Promise pageCount(pagesUrl: string): Promise<{ count: number }> putProgress(id: number, locator: Record, percent: number, keepalive?: boolean): Promise listProgress(): Promise } ``` - [x] **Step 1: 写失败测试 `web/test/client.test.ts`** ```ts import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { api, apiRaw, formatPageUrl, getToken, HttpError, setToken } from "../src/api/client"; const jres = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); beforeEach(() => setToken(null)); afterEach(() => vi.unstubAllGlobals()); it("login 成功存 token,请求体带凭据", async () => { const f = vi.fn(async (_url: any, init: any) => jres(200, { token: "T" })); vi.stubGlobal("fetch", f); await expect(api.login("a", "b")).resolves.toEqual({ token: "T" }); expect(f.mock.calls[0][0]).toBe("/api/auth/login"); expect(String(f.mock.calls[0][1].body)).toContain('"username":"a"'); }); it("401 清 token 并抛 HttpError(parsed code)", async () => { setToken("T"); vi.stubGlobal("fetch", async (_url: any, init: any) => { expect(init.headers.Authorization).toBe("Bearer T"); return jres(401, { error: { code: "unauthorized", message: "bad credentials" } }); }); const e = await api.login("a", "b").catch((e) => e); expect(e).toBeInstanceOf(HttpError); expect((e as HttpError).code).toBe("unauthorized"); expect(getToken()).toBeNull(); }); it("非 JSON 错误体回退 http_", async () => { vi.stubGlobal("fetch", async () => new Response("oops", { status: 500 })); const e = await api.listUsers().catch((e) => e); expect((e as HttpError).code).toBe("http_500"); }); it("204 无体 resolve undefined", async () => { vi.stubGlobal("fetch", async () => new Response(null, { status: 204 })); await expect(api.deleteBook(1)).resolves.toBeUndefined(); }); it("相对路径补 /api 前缀,绝对 /api 原样;query 拼装", async () => { const seen: string[] = []; vi.stubGlobal("fetch", async (url: any) => { seen.push(String(url)); return jres(200, []); }); await api.listBooks({ library: 3, q: "x" }); await apiRaw("/api/books/1/cover?v=h"); expect(seen).toEqual(["/api/books?library=3&q=x", "/api/books/1/cover?v=h"]); }); it("page_url_fmt %d 替换", () => { expect(formatPageUrl("/api/books/9/pages/%d?v=h", 12)).toBe("/api/books/9/pages/12?v=h"); }); it("putProgress keepalive 透传 + JSON body", async () => { const f = vi.fn(async (_url: any, _init: any) => new Response(null, { status: 204 })); vi.stubGlobal("fetch", f); await api.putProgress(1, { page: 2 }, 0.5, true); expect(f.mock.calls[0][1].keepalive).toBe(true); expect(String(f.mock.calls[0][1].body)).toContain('"percent":0.5'); }); ``` - [x] **Step 2: 跑,确认失败** Run: `cd web && npx vitest run test/client.test.ts` Expected: FAIL(`../src/api/client` 不存在) - [x] **Step 3: 实现 `src/api/types.ts` 与 `src/api/client.ts`** `src/api/types.ts` — 内容即 Interfaces 块中 types.ts 声明,原样落成(加 `export` 每行): ```ts export type Format = "cbz" | "pdf" | "epub" | "txt" | "md"; export interface Book { id: number; library_id: number; path: string; title: string; format: Format; size: number; mtime: number; pages: number; state: "ready" | "error"; error: string; added_at: string; percent: number; cover_url: string; library?: string; pages_url?: string; page_url_fmt?: string; file_url?: string; } export interface Library { id: number; name: string; root_path: string; created_at: string; } export interface User { id: number; username: string; role: "admin" | "member"; created_at: string; } export interface ProgressRow { library_id: number; library: string; path: string; title: string; locator: Record; percent: number; updated_at: string; } export interface Me { id: number; username: string; role: "admin" | "member"; } ``` `src/api/client.ts`: ```ts import type { Book, Library, Me, ProgressRow, User } from "./types"; export const TOKEN_KEY = "booklib.token"; export const LOGOUT_EVENT = "booklib:logout"; // 无 localStorage 环境(vitest node)退化为内存,仅测试路径生效 const mem = new Map(); 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 { 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 { const h: Record = {}; 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(path: string, opts: ReqOpts = {}): Promise { 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 { 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("/auth/me"), listUsers: () => apiFetch("/users"), createUser: (username: string, password: string, role: string) => apiFetch("/users", { method: "POST", body: { username, password, role } }), deleteUser: (id: number) => apiFetch(`/users/${id}`, { method: "DELETE" }), listLibraries: () => apiFetch("/libraries"), createLibrary: (name: string, root_path: string) => apiFetch("/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(`/books${qs ? "?" + qs : ""}`); }, getBook: (id: number) => apiFetch(`/books/${id}`), deleteBook: (id: number) => apiFetch(`/books/${id}`, { method: "DELETE" }), pageCount: (pagesUrl: string) => apiFetch<{ count: number }>(pagesUrl), putProgress: (id: number, locator: Record, percent: number, keepalive = false) => apiFetch(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }), listProgress: () => apiFetch("/progress"), }; ``` - [x] **Step 4: 跑门槛确认通过** Run: `cd web && npm run check` Expected: vitest 7 个 case 全 PASS,tsc/build 无错。 - [x] **Step 5: Commit** ```bash git add web && git commit -m "feat(web): typed api client with bearer token, 401 logout, binary channels" ``` --- ### Task 3: 应用壳 — auth 上下文、登录页、路由守卫、Toaster、ErrorBoundary **Files:** - Create: `web/src/lib/qc.ts`、`web/src/auth/AuthContext.tsx`、`web/src/components/Toaster.tsx`、`web/src/components/ErrorBoundary.tsx`、`web/src/components/ui.ts`、`web/src/pages/Login.tsx` - Modify: `web/src/main.tsx`(整文件替换)、`web/src/App.tsx`(整文件替换) **Interfaces:** - Consumes: Task 2 `api`、`getToken/setToken/LOGOUT_EVENT` - Produces: - `queryClient: QueryClient`(`lib/qc.ts`,main.tsx 与任意处 import 的单例) - `AuthProvider({children})`、`useAuth(): { user: Me | null; isAdmin: boolean; ready: boolean; login(u,p): Promise; logout(): void }` - `RequireAuth({children})`、`RequireAdmin({children})`(react-router-dom) - `toast(kind: "ok" | "err", text: string)` + ``;`` - className 常量:`btn`、`btnPrimary`、`input`、`card` UI 装配任务无纯逻辑单测,门槛 = `npm run check`。 - [x] **Step 1: 实现 `src/lib/qc.ts`** ```ts import { QueryClient } from "@tanstack/react-query"; export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } }, }); ``` - [x] **Step 2: 实现 `src/components/ui.ts`** ```ts export const btn = "rounded-md bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"; export const btnPrimary = "rounded-md bg-emerald-700 px-3 py-1.5 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50"; export const input = "rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm outline-none focus:border-zinc-500"; export const card = "rounded-xl border border-zinc-800 bg-zinc-900"; ``` - [x] **Step 3: 实现 `src/components/Toaster.tsx`** ```tsx import { useEffect, useState, type ReactNode } from "react"; type Toast = { id: number; kind: "ok" | "err"; text: ReactNode }; const items: Toast[] = []; const subs = new Set<(t: Toast[]) => void>(); let nextId = 1; export function toast(kind: Toast["kind"], text: ReactNode): void { const t: Toast = { id: nextId++, kind, text }; items.push(t); subs.forEach((f) => f([...items])); setTimeout(() => { const i = items.findIndex((x) => x.id === t.id); if (i >= 0) items.splice(i, 1); subs.forEach((f) => f([...items])); }, 4000); } export function Toaster() { const [list, setList] = useState([]); useEffect(() => { subs.add(setList); return () => { subs.delete(setList); }; }, []); return (
{list.map((t) => (
{t.text}
))}
); } ``` - [x] **Step 4: 实现 `src/components/ErrorBoundary.tsx`** ```tsx import { Component, type ReactNode } from "react"; export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Error | null }> { state: { err: Error | null } = { err: null }; static getDerivedStateFromError(err: Error) { return { err }; } render() { if (this.state.err) { return (

页面出错了

{String(this.state.err)}

); } return this.props.children; } } ``` - [x] **Step 5: 实现 `src/auth/AuthContext.tsx`** ```tsx import { useQuery, useQueryClient } from "@tanstack/react-query"; import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { api, getToken, LOGOUT_EVENT, setToken } from "../api/client"; import type { Me } from "../api/types"; interface AuthState { user: Me | null; isAdmin: boolean; ready: boolean; login: (u: string, p: string) => Promise; logout: () => void; } const Ctx = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const qc = useQueryClient(); const [token, setTok] = useState(() => 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( () => ({ 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 {children}; } 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 ; if (!ready || !user) return
加载中…
; return <>{children}; } export function RequireAdmin({ children }: { children: ReactNode }) { const { isAdmin } = useAuth(); return isAdmin ? <>{children} : ; } ``` - [x] **Step 6: 实现 `src/pages/Login.tsx`** ```tsx import { useState, type FormEvent } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; import { toast } from "../components/Toaster"; import { btnPrimary, card, input } from "../components/ui"; export default function Login() { const { login, user } = useAuth(); const loc = useLocation() as { state?: { from?: string } }; const nav = useNavigate(); const [u, setU] = useState(""); const [p, setP] = useState(""); const [busy, setBusy] = useState(false); if (user) return ; 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 (

BookLib 登录

setU(e.target.value)} placeholder="用户名" autoFocus autoComplete="username" /> setP(e.target.value)} placeholder="密码" autoComplete="current-password" />
); } ``` - [x] **Step 7: 替换 `src/main.tsx` 与 `src/App.tsx`** `src/main.tsx`: ```tsx import { QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter } from "react-router-dom"; import { createRoot } from "react-dom/client"; import App from "./App"; import { queryClient } from "./lib/qc"; import "./index.css"; createRoot(document.getElementById("root")!).render( , ); ``` `src/App.tsx`(Task 5/6/11 会再整文件替换): ```tsx import { Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider, RequireAuth } from "./auth/AuthContext"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { Toaster } from "./components/Toaster"; import Login from "./pages/Login"; function Placeholder({ name }: { name: string }) { return
{name}:待后续任务落地
; } export default function App() { return ( } /> } /> } /> ); } ``` - [x] **Step 8: 门槛 + 人工冒烟(可选)** Run: `cd web && npm run check` → 全绿。 人工(有环境时):起 dev 后端 + `npm run dev`,浏览器访问 :5173,未登录应跳 `/login`,错误密码出红色 toast。 - [x] **Step 9: Commit** ```bash git add web && git commit -m "feat(web): auth context, login page, route guards, toast/error shell" ``` --- ### Task 4: 鉴权图片通道 + 封面组件 + 目录分组(纯逻辑先行) **Files:** - Create: `web/src/lib/authImage.ts`、`web/src/lib/group.ts`、`web/src/components/Cover.tsx` - Test: `web/test/authImage.test.ts`、`web/test/group.test.ts` **Interfaces:** - Consumes: Task 2 `apiRaw`;Task 3 `btn` - Produces: - `fetchObjectUrl(url: string, signal?: AbortSignal): Promise` — 带鉴权取图 → objectURL;LRU(600)缓存;失败自动出缓存可重试;`__resetImageCache()` 供测试 - `useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void }`(`Cover.tsx` 内导出) - `Cover({ book, className })`(3:4 占位、失败显示重试按钮,spec §9) - `group.ts`:`interface Group { dir: string; books: Book[] }`;`groupByDir(books: Book[]): Group[]` - [x] **Step 1: 写失败测试** `test/group.test.ts`: ```ts import { expect, it } from "vitest"; import { groupByDir } from "../src/lib/group"; import type { Book } from "../src/api/types"; let seq = 1; const mk = (path: string): Book => ({ id: seq++, library_id: 1, path, title: path, format: "cbz", size: 1, mtime: 1, pages: 0, state: "ready", error: "", added_at: "", percent: 0, cover_url: "/api/books/0/cover", }); it("根目录组为空串且排最前;目录组按字典序;组内保持输入顺序", () => { const gs = groupByDir([mk("b.cbz"), mk("z/1.cbz"), mk("a/2.cbz"), mk("a/sub/3.cbz")]); expect(gs.map((g) => g.dir)).toEqual(["", "a", "a/sub", "z"]); expect(gs[0].books.map((x) => x.path)).toEqual(["b.cbz"]); expect(gs[1].books.map((x) => x.path)).toEqual(["a/2.cbz"]); }); it("空输入出空数组", () => { expect(groupByDir([])).toEqual([]); }); ``` `test/authImage.test.ts`: ```ts import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { setToken } from "../src/api/client"; import { __resetImageCache, fetchObjectUrl } from "../src/lib/authImage"; beforeEach(() => { __resetImageCache(); setToken("T"); (URL as any).createObjectURL = vi.fn(() => "blob:gen"); }); afterEach(() => vi.unstubAllGlobals()); it("blob → objectURL;同 URL 只发一次请求", async () => { const f = vi.fn(async (_url: any, init: any) => { expect(init.headers.Authorization).toBe("Bearer T"); return new Response(new Blob(["img"]), { status: 200 }); }); vi.stubGlobal("fetch", f); expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen"); expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen"); expect(f).toHaveBeenCalledTimes(1); }); it("失败不入缓存,重试重新请求", async () => { let n = 0; vi.stubGlobal("fetch", async () => { n++; return n === 1 ? new Response("x", { status: 500 }) : new Response(new Blob(["img"]), { status: 200 }); }); await expect(fetchObjectUrl("/api/books/2/pages/0?v=h")).rejects.toThrow(); expect(await fetchObjectUrl("/api/books/2/pages/0?v=h")).toBe("blob:gen"); expect(n).toBe(2); }); ``` - [x] **Step 2: 跑,确认失败** Run: `cd web && npx vitest run test/group.test.ts test/authImage.test.ts` Expected: FAIL(模块不存在) - [x] **Step 3: 实现 `src/lib/group.ts`** ```ts import type { Book } from "../api/types"; export interface Group { dir: string; // path 去掉文件名即分组键(spec §4);根目录为 "" books: Book[]; } export function groupByDir(books: Book[]): Group[] { const m = new Map(); for (const bk of books) { const i = bk.path.lastIndexOf("/"); const dir = i < 0 ? "" : bk.path.slice(0, i); let arr = m.get(dir); if (!arr) { arr = []; m.set(dir, arr); } arr.push(bk); } return [...m.entries()] .sort((a, b) => (a[0] === "" ? -1 : b[0] === "" ? 1 : a[0].localeCompare(b[0]))) .map(([dir, bs]) => ({ dir, books: bs })); } ``` - [x] **Step 4: 实现 `src/lib/authImage.ts` 与 `src/components/Cover.tsx`** `src/lib/authImage.ts`: ```ts import { apiRaw } from "../api/client"; const MAX = 600; const cache = new Map>(); // 插入序 ≈ LRU(命中时重插) // 带鉴权取图 → objectURL。后端 Bearer-only,/