# 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,
/