diff --git a/docs/superpowers/plans/2026-09-04-frontend.md b/docs/superpowers/plans/2026-09-04-frontend.md
new file mode 100644
index 0000000..2f7b0ce
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-04-frontend.md
@@ -0,0 +1,3067 @@
+# 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)
+
+- [ ] **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 间未变,后续代码不用改。
+
+- [ ] **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 },
+});
+```
+
+- [ ] **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
;
+}
+```
+
+- [ ] **Step 4: 跑门槛确认通过**
+
+Run: `cd web && npm run check`
+Expected: `tsc` 无错、vitest `No test files匹配 → passWithNoTests OK`、`vite build` 产出 `dist/`。
+
+- [ ] **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
+}
+```
+
+- [ ] **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');
+});
+```
+
+- [ ] **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` 每行):
+
+```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"),
+};
+```
+
+- [ ] **Step 4: 跑门槛确认通过**
+
+Run: `cd web && npm run check`
+Expected: vitest 7 个 case 全 PASS,tsc/build 无错。
+
+- [ ] **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`。
+
+- [ ] **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 } },
+});
+```
+
+- [ ] **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";
+```
+
+- [ ] **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}
+
+ ))}
+
+ );
+}
+```
+
+- [ ] **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;
+ }
+}
+```
+
+- [ ] **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}> : ;
+}
+```
+
+- [ ] **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 (
+
+
+
+ );
+}
+```
+
+- [ ] **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 (
+
+
+
+ } />
+
+
+
+ }
+ />
+ } />
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 8: 门槛 + 人工冒烟(可选)**
+
+Run: `cd web && npm run check` → 全绿。
+人工(有环境时):起 dev 后端 + `npm run dev`,浏览器访问 :5173,未登录应跳 `/login`,错误密码出红色 toast。
+
+- [ ] **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[]`
+
+- [ ] **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);
+});
+```
+
+- [ ] **Step 2: 跑,确认失败**
+
+Run: `cd web && npx vitest run test/group.test.ts test/authImage.test.ts`
+Expected: FAIL(模块不存在)
+
+- [ ] **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 }));
+}
+```
+
+- [ ] **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,
/