From b730b7acb479af66c448d683c2bc0d0a1caee7ca Mon Sep 17 00:00:00 2001 From: XingfenD Date: Mon, 7 Sep 2026 15:14:58 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20frontend=20Plan=202=20=E2=80=94?= =?UTF-8?q?=20shelf,=20four=20readers,=20admin,=20PWA,=20dockerized=20SPA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-09-04-frontend.md | 3067 +++++++++++++++++ 1 file changed, 3067 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-frontend.md 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 ( +
+
+

BookLib 登录

+ setU(e.target.value)} + placeholder="用户名" autoFocus autoComplete="username" /> + setP(e.target.value)} + placeholder="密码" autoComplete="current-password" /> + +
+
+ ); +} +``` + +- [ ] **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,/