3083 lines
106 KiB
Markdown
3083 lines
106 KiB
Markdown
# 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);禁止 `<img src="/api/...">` 直挂(必 401)。
|
||
- 进度:reader 统一 `onPositionChange(locator, percent)` 语义 = `saver.report(locator, percent)`,5s 节流;关页/切后台 `flush()` 用 `fetch keepalive`。locator 键按 spec §4:cbz/pdf `{page}`(0 基)、epub `{cfi}`、txt/md `{scrollFraction}`;后端不解释。upsert 成功后仅 invalidate `["book", id]`(spec §8)。
|
||
- 支持格式仅 `cbz pdf epub txt/md`;其余不入库也不会下发,reader 分发遇未知 format 显示占位。
|
||
- 写操作按钮(建库/上传/扫描/删书/用户 CRUD)仅 `role==="admin"` 可见;member 可 GET 一切 + PUT 自己的 progress。
|
||
- Tailwind 深色默认(`bg-zinc-950 text-zinc-100`),无主题切换。中文文案。
|
||
- 测试:`npm run check`(= `tsc --noEmit && vitest run && vite build`)是每任务唯一门槛;vitest 只测纯逻辑(client/group/virt/progress/authImage),不引 Testing Library/spec §10。
|
||
- Node ≥ 22。依赖只在 Task 1 一次装齐,后续任务禁止新增 npm 依赖。
|
||
- dev 联调:vite proxy `/api → http://localhost:8080`(起 `deploy/docker-compose.dev.yml` 的 PG/Redis + `go run ./cmd/server`);生产同源自 nginx,代码零改动。
|
||
|
||
## File Structure
|
||
|
||
```
|
||
web/
|
||
package.json tsconfig.json vite.config.ts vitest.config.ts index.html .gitignore
|
||
public/icon.svg
|
||
src/
|
||
main.tsx # 挂载:providers + router;Task 12 加 SW 注册
|
||
App.tsx # 路由表(每任务整文件替换,追加路由)
|
||
index.css # tailwind import + 全局深色底
|
||
api/types.ts # Book/Library/User/ProgressRow 等(与上方契约一致)
|
||
api/client.ts # token 存取、401 拦截、apiFetch/apiRaw、api.*、formatPageUrl
|
||
auth/AuthContext.tsx # AuthProvider/useAuth + RequireAuth/RequireAdmin
|
||
lib/qc.ts # 单例 QueryClient
|
||
lib/group.ts # 书架目录分组(纯函数,vitest)
|
||
lib/virt.ts # CBZ 虚拟滚动高度模型(纯类,vitest)
|
||
lib/progress.ts # 节流进度保存器(纯逻辑,vitest)
|
||
lib/authImage.ts # 带鉴权图片 → objectURL LRU 缓存(vitest)+ useAuthedImage hook
|
||
components/Toaster.tsx # 迷你全局 toast(spec 无组件库)
|
||
components/ErrorBoundary.tsx
|
||
components/ui.ts # btn/input/card 共享 className 常量
|
||
components/Cover.tsx # 鉴权封面图 + 失败重试占位
|
||
pages/Login.tsx
|
||
pages/Shelf.tsx # 书架
|
||
pages/Reader.tsx # /book/:id 按 format 分发(lazy)
|
||
readers/CbzReader.tsx readers/PdfReader.tsx readers/EpubReader.tsx readers/TextReader.tsx
|
||
pages/admin/Users.tsx pages/admin/Libraries.tsx
|
||
test/client.test.ts test/group.test.ts test/virt.test.ts test/progress.test.ts test/authImage.test.ts
|
||
deploy/Dockerfile.web # Task 12:整文件替换为多阶段(node build → nginx)
|
||
deploy/web-dist/ # Task 12:删除(占位页完成使命)
|
||
.dockerignore # Task 12:补 web 条目
|
||
scripts/smoke-web.sh # Task 12:compose 全栈冒烟(经 nginx)
|
||
README.md # Task 12:补前端 dev/部署说明
|
||
```
|
||
|
||
依赖方向:`pages/readers → lib/components → auth → api`。无循环。
|
||
|
||
---
|
||
|
||
### Task 1: 脚手架 — Vite+React+TS+Tailwind4+vitest+dev proxy
|
||
|
||
**Files:**
|
||
- Create: `web/package.json`(npm 生成)、`web/.gitignore`、`web/tsconfig.json`、`web/vite.config.ts`、`web/vitest.config.ts`、`web/index.html`、`web/public/icon.svg`、`web/src/index.css`、`web/src/main.tsx`、`web/src/App.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: 无
|
||
- Produces: 可 `npm run check` 的工程;`npm run dev`(:5173,`/api` 代理到 :8080)
|
||
|
||
- [ ] **Step 1: 初始化工程并装依赖(一次性,后续任务不再装)**
|
||
|
||
```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
|
||
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<meta name="theme-color" content="#09090b" />
|
||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||
<title>BookLib — 个人书库</title>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
`web/public/icon.svg`:
|
||
|
||
```html
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="24" fill="#09090b"/><path d="M24 30h34v68H24zM70 30h34v68H70z" fill="none" stroke="#34d399" stroke-width="6"/><path d="M58 30v68" stroke="#34d399" stroke-width="6"/></svg>
|
||
```
|
||
|
||
`web/src/index.css`:
|
||
|
||
```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(<App />);
|
||
```
|
||
|
||
`web/src/App.tsx`(占位,Task 3 整文件替换):
|
||
|
||
```tsx
|
||
export default function App() {
|
||
return <div className="p-8 text-zinc-400">booklib web</div>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 跑门槛确认通过**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: `tsc` 无错、vitest `No test files匹配 → passWithNoTests OK`、`vite build` 产出 `dist/`。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```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<string, unknown>; percent: number; updated_at: string }
|
||
export interface Me { id: number; username: string; role: "admin" | "member" }
|
||
|
||
// client.ts
|
||
export const TOKEN_KEY = "booklib.token";
|
||
export const LOGOUT_EVENT = "booklib:logout";
|
||
export function getToken(): string | null
|
||
export function setToken(t: string | null): void
|
||
export class HttpError extends Error { status: number; code: string }
|
||
export function toHttpError(res: Response): Promise<HttpError>
|
||
export interface ReqOpts { method?: string; body?: unknown; form?: FormData; keepalive?: boolean; signal?: AbortSignal }
|
||
export function apiFetch<T>(path: string, opts?: ReqOpts): Promise<T> // 相对 path 自动加 /api 前缀;已 /api 开头则原样
|
||
export function apiRaw(path: string, opts?: { signal?: AbortSignal; keepalive?: boolean }): Promise<Response> // 供 blob/arrayBuffer,同样带 token、401 拦截
|
||
export function formatPageUrl(fmt: string, n: number): string
|
||
export const api: {
|
||
login(username: string, password: string): Promise<{ token: string }>
|
||
me(): Promise<Me>
|
||
listUsers(): Promise<User[]>
|
||
createUser(username: string, password: string, role: string): Promise<User>
|
||
deleteUser(id: number): Promise<void>
|
||
listLibraries(): Promise<Library[]>
|
||
createLibrary(name: string, root_path: string): Promise<Library>
|
||
scanLibrary(id: number): Promise<{ accepted: boolean }>
|
||
uploadBook(id: number, file: File): Promise<{ accepted: boolean; path: string }>
|
||
listBooks(params?: { library?: number; q?: string }): Promise<Book[]>
|
||
getBook(id: number): Promise<Book>
|
||
deleteBook(id: number): Promise<void>
|
||
pageCount(pagesUrl: string): Promise<{ count: number }>
|
||
putProgress(id: number, locator: Record<string, unknown>, percent: number, keepalive?: boolean): Promise<void>
|
||
listProgress(): Promise<ProgressRow[]>
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1: 写失败测试 `web/test/client.test.ts`**
|
||
|
||
```ts
|
||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||
import { api, apiRaw, formatPageUrl, getToken, HttpError, setToken } from "../src/api/client";
|
||
|
||
const jres = (status: number, body: unknown) =>
|
||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||
|
||
beforeEach(() => setToken(null));
|
||
afterEach(() => vi.unstubAllGlobals());
|
||
|
||
it("login 成功存 token,请求体带凭据", async () => {
|
||
const f = vi.fn(async (_url: any, init: any) => jres(200, { token: "T" }));
|
||
vi.stubGlobal("fetch", f);
|
||
await expect(api.login("a", "b")).resolves.toEqual({ token: "T" });
|
||
expect(f.mock.calls[0][0]).toBe("/api/auth/login");
|
||
expect(String(f.mock.calls[0][1].body)).toContain('"username":"a"');
|
||
});
|
||
|
||
it("401 清 token 并抛 HttpError(parsed code)", async () => {
|
||
setToken("T");
|
||
vi.stubGlobal("fetch", async (_url: any, init: any) => {
|
||
expect(init.headers.Authorization).toBe("Bearer T");
|
||
return jres(401, { error: { code: "unauthorized", message: "bad credentials" } });
|
||
});
|
||
const e = await api.login("a", "b").catch((e) => e);
|
||
expect(e).toBeInstanceOf(HttpError);
|
||
expect((e as HttpError).code).toBe("unauthorized");
|
||
expect(getToken()).toBeNull();
|
||
});
|
||
|
||
it("非 JSON 错误体回退 http_<status>", async () => {
|
||
vi.stubGlobal("fetch", async () => new Response("oops", { status: 500 }));
|
||
const e = await api.listUsers().catch((e) => e);
|
||
expect((e as HttpError).code).toBe("http_500");
|
||
});
|
||
|
||
it("204 无体 resolve undefined", async () => {
|
||
vi.stubGlobal("fetch", async () => new Response(null, { status: 204 }));
|
||
await expect(api.deleteBook(1)).resolves.toBeUndefined();
|
||
});
|
||
|
||
it("相对路径补 /api 前缀,绝对 /api 原样;query 拼装", async () => {
|
||
const seen: string[] = [];
|
||
vi.stubGlobal("fetch", async (url: any) => {
|
||
seen.push(String(url));
|
||
return jres(200, []);
|
||
});
|
||
await api.listBooks({ library: 3, q: "x" });
|
||
await apiRaw("/api/books/1/cover?v=h");
|
||
expect(seen).toEqual(["/api/books?library=3&q=x", "/api/books/1/cover?v=h"]);
|
||
});
|
||
|
||
it("page_url_fmt %d 替换", () => {
|
||
expect(formatPageUrl("/api/books/9/pages/%d?v=h", 12)).toBe("/api/books/9/pages/12?v=h");
|
||
});
|
||
|
||
it("putProgress keepalive 透传 + JSON body", async () => {
|
||
const f = vi.fn(async (_url: any, _init: any) => new Response(null, { status: 204 }));
|
||
vi.stubGlobal("fetch", f);
|
||
await api.putProgress(1, { page: 2 }, 0.5, true);
|
||
expect(f.mock.calls[0][1].keepalive).toBe(true);
|
||
expect(String(f.mock.calls[0][1].body)).toContain('"percent":0.5');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑,确认失败**
|
||
|
||
Run: `cd web && npx vitest run test/client.test.ts`
|
||
Expected: FAIL(`../src/api/client` 不存在)
|
||
|
||
- [ ] **Step 3: 实现 `src/api/types.ts` 与 `src/api/client.ts`**
|
||
|
||
`src/api/types.ts` — 内容即 Interfaces 块中 types.ts 声明,原样落成(加 `export` 每行):
|
||
|
||
```ts
|
||
export type Format = "cbz" | "pdf" | "epub" | "txt" | "md";
|
||
|
||
export interface Book {
|
||
id: number;
|
||
library_id: number;
|
||
path: string;
|
||
title: string;
|
||
format: Format;
|
||
size: number;
|
||
mtime: number;
|
||
pages: number;
|
||
state: "ready" | "error";
|
||
error: string;
|
||
added_at: string;
|
||
percent: number;
|
||
cover_url: string;
|
||
library?: string;
|
||
pages_url?: string;
|
||
page_url_fmt?: string;
|
||
file_url?: string;
|
||
}
|
||
|
||
export interface Library {
|
||
id: number;
|
||
name: string;
|
||
root_path: string;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface User {
|
||
id: number;
|
||
username: string;
|
||
role: "admin" | "member";
|
||
created_at: string;
|
||
}
|
||
|
||
export interface ProgressRow {
|
||
library_id: number;
|
||
library: string;
|
||
path: string;
|
||
title: string;
|
||
locator: Record<string, unknown>;
|
||
percent: number;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface Me {
|
||
id: number;
|
||
username: string;
|
||
role: "admin" | "member";
|
||
}
|
||
```
|
||
|
||
`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<string, string>();
|
||
const hasLS = typeof localStorage !== "undefined";
|
||
|
||
export function getToken(): string | null {
|
||
return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null;
|
||
}
|
||
|
||
export function setToken(t: string | null): void {
|
||
if (hasLS) {
|
||
if (t) localStorage.setItem(TOKEN_KEY, t);
|
||
else localStorage.removeItem(TOKEN_KEY);
|
||
} else if (t) {
|
||
mem.set(TOKEN_KEY, t);
|
||
} else {
|
||
mem.delete(TOKEN_KEY);
|
||
}
|
||
}
|
||
|
||
export class HttpError extends Error {
|
||
status: number;
|
||
code: string;
|
||
constructor(status: number, code: string, message: string) {
|
||
super(message);
|
||
this.name = "HttpError";
|
||
this.status = status;
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
export async function toHttpError(res: Response): Promise<HttpError> {
|
||
let code = "http_" + res.status;
|
||
let message = res.statusText;
|
||
try {
|
||
const j = await res.json();
|
||
if (j?.error?.code) code = j.error.code;
|
||
if (j?.error?.message) message = j.error.message;
|
||
} catch {
|
||
/* 非 JSON 错误体:用状态码兜底 */
|
||
}
|
||
return new HttpError(res.status, code, message);
|
||
}
|
||
|
||
function authHeaders(): Record<string, string> {
|
||
const h: Record<string, string> = {};
|
||
const tok = getToken();
|
||
if (tok) h.Authorization = "Bearer " + tok;
|
||
return h;
|
||
}
|
||
|
||
function handle401(res: Response): void {
|
||
if (res.status !== 401) return;
|
||
setToken(null);
|
||
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOGOUT_EVENT));
|
||
}
|
||
|
||
function full(path: string): string {
|
||
return path.startsWith("/api") ? path : "/api" + path;
|
||
}
|
||
|
||
export interface ReqOpts {
|
||
method?: string;
|
||
body?: unknown;
|
||
form?: FormData;
|
||
keepalive?: boolean;
|
||
signal?: AbortSignal;
|
||
}
|
||
|
||
export async function apiFetch<T>(path: string, opts: ReqOpts = {}): Promise<T> {
|
||
const headers = authHeaders();
|
||
let body: BodyInit | undefined;
|
||
if (opts.form) {
|
||
body = opts.form;
|
||
} else if (opts.body !== undefined) {
|
||
headers["Content-Type"] = "application/json";
|
||
body = JSON.stringify(opts.body);
|
||
}
|
||
const res = await fetch(full(path), {
|
||
method: opts.method ?? "GET",
|
||
headers,
|
||
body,
|
||
keepalive: opts.keepalive,
|
||
signal: opts.signal,
|
||
});
|
||
handle401(res);
|
||
if (!res.ok) throw await toHttpError(res);
|
||
if (res.status === 204) return undefined as T;
|
||
return (await res.json()) as T;
|
||
}
|
||
|
||
// 二进制/原文通道:封面、CBZ 页、PDF/EPUB 字节。同 401 语义,不解析 JSON
|
||
export async function apiRaw(
|
||
path: string,
|
||
opts: { signal?: AbortSignal; keepalive?: boolean } = {},
|
||
): Promise<Response> {
|
||
const res = await fetch(full(path), {
|
||
headers: authHeaders(),
|
||
signal: opts.signal,
|
||
keepalive: opts.keepalive,
|
||
});
|
||
handle401(res);
|
||
if (!res.ok) throw await toHttpError(res);
|
||
return res;
|
||
}
|
||
|
||
export function formatPageUrl(fmt: string, n: number): string {
|
||
return fmt.replace("%d", String(n));
|
||
}
|
||
|
||
export const api = {
|
||
login: (username: string, password: string) =>
|
||
apiFetch<{ token: string }>("/auth/login", { method: "POST", body: { username, password } }),
|
||
me: () => apiFetch<Me>("/auth/me"),
|
||
|
||
listUsers: () => apiFetch<User[]>("/users"),
|
||
createUser: (username: string, password: string, role: string) =>
|
||
apiFetch<User>("/users", { method: "POST", body: { username, password, role } }),
|
||
deleteUser: (id: number) => apiFetch<void>(`/users/${id}`, { method: "DELETE" }),
|
||
|
||
listLibraries: () => apiFetch<Library[]>("/libraries"),
|
||
createLibrary: (name: string, root_path: string) =>
|
||
apiFetch<Library>("/libraries", { method: "POST", body: { name, root_path } }),
|
||
scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }),
|
||
uploadBook: (id: number, file: File) => {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
return apiFetch<{ accepted: boolean; path: string }>(`/libraries/${id}/upload`, {
|
||
method: "POST",
|
||
form: fd,
|
||
});
|
||
},
|
||
|
||
listBooks: (params: { library?: number; q?: string } = {}) => {
|
||
const sp = new URLSearchParams();
|
||
if (params.library) sp.set("library", String(params.library));
|
||
if (params.q) sp.set("q", params.q);
|
||
const qs = sp.toString();
|
||
return apiFetch<Book[]>(`/books${qs ? "?" + qs : ""}`);
|
||
},
|
||
getBook: (id: number) => apiFetch<Book>(`/books/${id}`),
|
||
deleteBook: (id: number) => apiFetch<void>(`/books/${id}`, { method: "DELETE" }),
|
||
pageCount: (pagesUrl: string) => apiFetch<{ count: number }>(pagesUrl),
|
||
|
||
putProgress: (id: number, locator: Record<string, unknown>, percent: number, keepalive = false) =>
|
||
apiFetch<void>(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }),
|
||
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: 跑门槛确认通过**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: vitest 7 个 case 全 PASS,tsc/build 无错。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```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<void>; logout(): void }`
|
||
- `RequireAuth({children})`、`RequireAdmin({children})`(react-router-dom)
|
||
- `toast(kind: "ok" | "err", text: string)` + `<Toaster/>`;`<ErrorBoundary>`
|
||
- className 常量:`btn`、`btnPrimary`、`input`、`card`
|
||
|
||
UI 装配任务无纯逻辑单测,门槛 = `npm run check`。
|
||
|
||
- [ ] **Step 1: 实现 `src/lib/qc.ts`**
|
||
|
||
```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<Toast[]>([]);
|
||
useEffect(() => {
|
||
subs.add(setList);
|
||
return () => {
|
||
subs.delete(setList);
|
||
};
|
||
}, []);
|
||
return (
|
||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||
{list.map((t) => (
|
||
<div
|
||
key={t.id}
|
||
role="status"
|
||
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ${
|
||
t.kind === "ok" ? "bg-emerald-900 text-emerald-100" : "bg-red-900 text-red-100"
|
||
}`}
|
||
>
|
||
{t.text}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 实现 `src/components/ErrorBoundary.tsx`**
|
||
|
||
```tsx
|
||
import { Component, type ReactNode } from "react";
|
||
|
||
export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Error | null }> {
|
||
state: { err: Error | null } = { err: null };
|
||
|
||
static getDerivedStateFromError(err: Error) {
|
||
return { err };
|
||
}
|
||
|
||
render() {
|
||
if (this.state.err) {
|
||
return (
|
||
<div className="grid h-full place-items-center p-8 text-center">
|
||
<div>
|
||
<p className="mb-2 text-lg text-red-400">页面出错了</p>
|
||
<p className="mb-4 max-w-md text-sm break-all text-zinc-500">{String(this.state.err)}</p>
|
||
<button className="rounded-md bg-zinc-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
|
||
重试
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return this.props.children;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 实现 `src/auth/AuthContext.tsx`**
|
||
|
||
```tsx
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||
import { Navigate, useLocation } from "react-router-dom";
|
||
import { api, getToken, LOGOUT_EVENT, setToken } from "../api/client";
|
||
import type { Me } from "../api/types";
|
||
|
||
interface AuthState {
|
||
user: Me | null;
|
||
isAdmin: boolean;
|
||
ready: boolean;
|
||
login: (u: string, p: string) => Promise<void>;
|
||
logout: () => void;
|
||
}
|
||
|
||
const Ctx = createContext<AuthState | null>(null);
|
||
|
||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||
const qc = useQueryClient();
|
||
const [token, setTok] = useState<string | null>(() => getToken());
|
||
|
||
useEffect(() => {
|
||
const off = () => setTok(null); // client 的 401 拦截在这里回收 React 状态
|
||
window.addEventListener(LOGOUT_EVENT, off);
|
||
return () => window.removeEventListener(LOGOUT_EVENT, off);
|
||
}, []);
|
||
|
||
const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: !!token, retry: 0 });
|
||
useEffect(() => {
|
||
if (token && meQ.isError) setTok(null);
|
||
}, [token, meQ.isError]);
|
||
|
||
const value = useMemo<AuthState>(
|
||
() => ({
|
||
user: meQ.data ?? null,
|
||
isAdmin: meQ.data?.role === "admin",
|
||
ready: !token || !meQ.isPending,
|
||
login: async (u, p) => {
|
||
const r = await api.login(u, p);
|
||
setToken(r.token);
|
||
setTok(r.token);
|
||
await qc.invalidateQueries({ queryKey: ["me"] });
|
||
},
|
||
logout: () => {
|
||
setToken(null);
|
||
setTok(null);
|
||
qc.clear();
|
||
},
|
||
}),
|
||
[meQ.data, meQ.isPending, meQ.isError, token, qc],
|
||
);
|
||
|
||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||
}
|
||
|
||
export function useAuth(): AuthState {
|
||
const v = useContext(Ctx);
|
||
if (!v) throw new Error("useAuth 必须在 AuthProvider 内使用");
|
||
return v;
|
||
}
|
||
|
||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||
const { user, ready } = useAuth();
|
||
const loc = useLocation();
|
||
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
|
||
if (!ready || !user) return <div className="grid h-full place-items-center text-zinc-500">加载中…</div>;
|
||
return <>{children}</>;
|
||
}
|
||
|
||
export function RequireAdmin({ children }: { children: ReactNode }) {
|
||
const { isAdmin } = useAuth();
|
||
return isAdmin ? <>{children}</> : <Navigate to="/" replace />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 实现 `src/pages/Login.tsx`**
|
||
|
||
```tsx
|
||
import { useState, type FormEvent } from "react";
|
||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { toast } from "../components/Toaster";
|
||
import { btnPrimary, card, input } from "../components/ui";
|
||
|
||
export default function Login() {
|
||
const { login, user } = useAuth();
|
||
const loc = useLocation() as { state?: { from?: string } };
|
||
const nav = useNavigate();
|
||
const [u, setU] = useState("");
|
||
const [p, setP] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
if (user) return <Navigate to={loc.state?.from ?? "/"} replace />;
|
||
|
||
async function submit(e: FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
try {
|
||
await login(u, p);
|
||
nav("/", { replace: true });
|
||
} catch (err) {
|
||
toast("err", err instanceof Error ? err.message : "登录失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="grid h-full place-items-center p-4">
|
||
<form onSubmit={submit} className={`${card} w-full max-w-sm space-y-4 p-6`}>
|
||
<h1 className="text-lg font-semibold">BookLib 登录</h1>
|
||
<input className={input + " w-full"} value={u} onChange={(e) => setU(e.target.value)}
|
||
placeholder="用户名" autoFocus autoComplete="username" />
|
||
<input className={input + " w-full"} type="password" value={p} onChange={(e) => setP(e.target.value)}
|
||
placeholder="密码" autoComplete="current-password" />
|
||
<button className={btnPrimary + " w-full"} disabled={busy || !u || !p}>
|
||
{busy ? "登录中…" : "登录"}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 替换 `src/main.tsx` 与 `src/App.tsx`**
|
||
|
||
`src/main.tsx`:
|
||
|
||
```tsx
|
||
import { QueryClientProvider } from "@tanstack/react-query";
|
||
import { BrowserRouter } from "react-router-dom";
|
||
import { createRoot } from "react-dom/client";
|
||
import App from "./App";
|
||
import { queryClient } from "./lib/qc";
|
||
import "./index.css";
|
||
|
||
createRoot(document.getElementById("root")!).render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<BrowserRouter>
|
||
<App />
|
||
</BrowserRouter>
|
||
</QueryClientProvider>,
|
||
);
|
||
```
|
||
|
||
`src/App.tsx`(Task 5/6/11 会再整文件替换):
|
||
|
||
```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 <div className="p-8 text-zinc-500">{name}:待后续任务落地</div>;
|
||
}
|
||
|
||
export default function App() {
|
||
return (
|
||
<ErrorBoundary>
|
||
<AuthProvider>
|
||
<Routes>
|
||
<Route path="/login" element={<Login />} />
|
||
<Route
|
||
path="/"
|
||
element={
|
||
<RequireAuth>
|
||
<Placeholder name="书架" />
|
||
</RequireAuth>
|
||
}
|
||
/>
|
||
<Route path="*" element={<Navigate to="/" replace />} />
|
||
</Routes>
|
||
<Toaster />
|
||
</AuthProvider>
|
||
</ErrorBoundary>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: 门槛 + 人工冒烟(可选)**
|
||
|
||
Run: `cd web && npm run check` → 全绿。
|
||
人工(有环境时):起 dev 后端 + `npm run dev`,浏览器访问 :5173,未登录应跳 `/login`,错误密码出红色 toast。
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```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<string>` — 带鉴权取图 → objectURL;LRU(600)缓存;失败自动出缓存可重试;`__resetImageCache()` 供测试
|
||
- `useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void }`(`Cover.tsx` 内导出)
|
||
- `Cover({ book, className })`(3:4 占位、失败显示重试按钮,spec §9)
|
||
- `group.ts`:`interface Group { dir: string; books: Book[] }`;`groupByDir(books: Book[]): Group[]`
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
`test/group.test.ts`:
|
||
|
||
```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<string, Book[]>();
|
||
for (const bk of books) {
|
||
const i = bk.path.lastIndexOf("/");
|
||
const dir = i < 0 ? "" : bk.path.slice(0, i);
|
||
let arr = m.get(dir);
|
||
if (!arr) {
|
||
arr = [];
|
||
m.set(dir, arr);
|
||
}
|
||
arr.push(bk);
|
||
}
|
||
return [...m.entries()]
|
||
.sort((a, b) => (a[0] === "" ? -1 : b[0] === "" ? 1 : a[0].localeCompare(b[0])))
|
||
.map(([dir, bs]) => ({ dir, books: bs }));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 实现 `src/lib/authImage.ts` 与 `src/components/Cover.tsx`**
|
||
|
||
`src/lib/authImage.ts`:
|
||
|
||
```ts
|
||
import { apiRaw } from "../api/client";
|
||
|
||
const MAX = 600;
|
||
const cache = new Map<string, Promise<string>>(); // 插入序 ≈ LRU(命中时重插)
|
||
|
||
// 带鉴权取图 → objectURL。后端 Bearer-only,<img>/<iframe> 直挂 /api 必 401,
|
||
// 故图片一律走此通道;URL 含内容 hash 不可变,会话内可安全长驻缓存。
|
||
export async function fetchObjectUrl(url: string, signal?: AbortSignal): Promise<string> {
|
||
const hit = cache.get(url);
|
||
if (hit) {
|
||
cache.delete(url);
|
||
cache.set(url, hit);
|
||
return hit;
|
||
}
|
||
const p = (async () => {
|
||
const res = await apiRaw(url, { signal });
|
||
return URL.createObjectURL(await res.blob());
|
||
})();
|
||
p.catch(() => cache.delete(url)); // 失败出缓存,下次调用重试
|
||
cache.set(url, p);
|
||
while (cache.size > MAX) {
|
||
const oldest = cache.keys().next().value as string;
|
||
cache.delete(oldest); // 不 revoke:可能仍被在屏 <img> 引用;≤600 条会话内 blob 可接受
|
||
}
|
||
return p;
|
||
}
|
||
|
||
export function __resetImageCache(): void {
|
||
cache.clear();
|
||
}
|
||
```
|
||
|
||
`src/components/Cover.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import type { Book } from "../api/types";
|
||
import { fetchObjectUrl } from "../lib/authImage";
|
||
import { btn } from "./ui";
|
||
|
||
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
|
||
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false });
|
||
const [nonce, setNonce] = useState(0);
|
||
const key = (url ?? "") + "#" + nonce;
|
||
|
||
useEffect(() => {
|
||
if (!url) return;
|
||
let dead = false;
|
||
const k = url + "#" + nonce;
|
||
setState({ key: k, src: "", failed: false });
|
||
fetchObjectUrl(url)
|
||
.then((src) => !dead && setState({ key: k, src, failed: false }))
|
||
.catch(() => !dead && setState({ key: k, src: "", failed: true }));
|
||
return () => {
|
||
dead = true;
|
||
};
|
||
}, [url, nonce]);
|
||
|
||
const same = state.key === key;
|
||
return { src: same ? state.src : "", failed: same && state.failed, retry: () => setNonce((n) => n + 1) };
|
||
}
|
||
|
||
export function Cover({ book, className }: { book: Book; className?: string }) {
|
||
const { src, failed, retry } = useAuthedImage(book.cover_url);
|
||
if (failed)
|
||
return (
|
||
<div className={`grid place-items-center bg-zinc-900 ${className ?? ""}`}>
|
||
<button className={btn} onClick={retry} aria-label="封面加载失败,重试">
|
||
重试
|
||
</button>
|
||
</div>
|
||
);
|
||
if (!src) return <div className={`animate-pulse bg-zinc-800 ${className ?? ""}`} />;
|
||
return <img src={src} alt="" loading="lazy" className={`bg-zinc-900 object-cover ${className ?? ""}`} />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 跑门槛确认通过**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: 新增 4 个用例 PASS;build 无错。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add web && git commit -m "feat(web): authed blob image pipeline with LRU, cover card, dir grouping"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 书架页 — 库 tab、搜索、分组卡片、继续阅读、损坏态、admin 删除/扫描
|
||
|
||
**Files:**
|
||
- Create: `web/src/components/TopBar.tsx`、`web/src/pages/Shelf.tsx`
|
||
- Modify: `web/src/App.tsx`(整文件替换)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 2 `api`;Task 3 `useAuth`、`btn/btnPrimary/input/card`;Task 4 `Cover`、`groupByDir`;`queryClient`
|
||
- Produces: 路由 `/`(书架);`TopBar({ right?: ReactNode })`(admin 页在 Task 11 复用);TanStack Query key 约定:`["libraries"]`、`["books", libId, q]`、`["progress"]`、`["book", id]`
|
||
|
||
- [ ] **Step 1: 实现 `src/components/TopBar.tsx`**
|
||
|
||
```tsx
|
||
import type { ReactNode } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { btn } from "./ui";
|
||
|
||
export function TopBar({ right }: { right?: ReactNode }) {
|
||
const { user, isAdmin, logout } = useAuth();
|
||
return (
|
||
<header className="flex shrink-0 items-center gap-4 border-b border-zinc-800 px-4 py-3">
|
||
<Link to="/" className="font-semibold">
|
||
BookLib
|
||
</Link>
|
||
{isAdmin && (
|
||
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/users">
|
||
用户
|
||
</Link>
|
||
)}
|
||
{isAdmin && (
|
||
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/libraries">
|
||
库管理
|
||
</Link>
|
||
)}
|
||
<div className="ml-auto flex items-center gap-3">
|
||
{right}
|
||
<span className="text-sm text-zinc-400">
|
||
{user?.username}
|
||
{isAdmin ? "(admin)" : ""}
|
||
</span>
|
||
<button className={btn} onClick={logout}>
|
||
退出
|
||
</button>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 实现 `src/pages/Shelf.tsx`**
|
||
|
||
```tsx
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { api } from "../api/client";
|
||
import type { Book } from "../api/types";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { Cover } from "../components/Cover";
|
||
import { TopBar } from "../components/TopBar";
|
||
import { toast } from "../components/Toaster";
|
||
import { btn, btnPrimary, card, input } from "../components/ui";
|
||
import { groupByDir } from "../lib/group";
|
||
|
||
function BookCard({
|
||
book,
|
||
onOpen,
|
||
onDelete,
|
||
}: {
|
||
book: Book;
|
||
onOpen: () => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
const { isAdmin } = useAuth();
|
||
return (
|
||
<div className="group relative w-32 shrink-0">
|
||
<button onClick={onOpen} className="block w-full text-left">
|
||
<Cover book={book} className="aspect-[3/4] w-32 rounded-lg" />
|
||
<p className="mt-1 truncate text-xs text-zinc-300">{book.title}</p>
|
||
<p className="truncate text-[10px] text-zinc-500">
|
||
{book.format} · {book.path}
|
||
</p>
|
||
</button>
|
||
{book.state === "error" && (
|
||
<span
|
||
className="absolute left-1 top-1 rounded bg-red-900 px-1 text-[10px] text-red-100"
|
||
title={book.error}
|
||
>
|
||
损坏
|
||
</span>
|
||
)}
|
||
{book.percent > 0 && (
|
||
<div className="mt-1 h-1 w-32 overflow-hidden rounded bg-zinc-800">
|
||
<div className="h-full bg-emerald-600" style={{ width: `${Math.round(book.percent * 100)}%` }} />
|
||
</div>
|
||
)}
|
||
{isAdmin && (
|
||
<button
|
||
className="absolute right-1 top-1 hidden rounded bg-zinc-900/80 px-1.5 text-xs text-red-300 group-hover:block"
|
||
title="删除(连磁盘文件)"
|
||
onClick={() => {
|
||
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) onDelete();
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Shelf() {
|
||
const qc = useQueryClient();
|
||
const nav = useNavigate();
|
||
const { isAdmin } = useAuth();
|
||
const [lib, setLib] = useState(0); // 0 = 全部库
|
||
const [qRaw, setQRaw] = useState("");
|
||
const [q, setQ] = useState("");
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setQ(qRaw.trim()), 300); // 搜索防抖
|
||
return () => clearTimeout(t);
|
||
}, [qRaw]);
|
||
|
||
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||
const booksQ = useQuery({
|
||
queryKey: ["books", lib, q],
|
||
queryFn: () => api.listBooks({ library: lib || undefined, q }),
|
||
});
|
||
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||
// 继续阅读行 → book id 解析用(键与 booksQ 默认态一致,天然共享缓存)
|
||
const allQ = useQuery({ queryKey: ["books", 0, ""], queryFn: () => api.listBooks() });
|
||
|
||
const del = useMutation({
|
||
mutationFn: (id: number) => api.deleteBook(id),
|
||
onSuccess: () => {
|
||
toast("ok", "已删除");
|
||
qc.invalidateQueries({ queryKey: ["books"] });
|
||
},
|
||
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
|
||
});
|
||
const scan = useMutation({
|
||
mutationFn: (id: number) => api.scanLibrary(id),
|
||
onSuccess: () => {
|
||
toast("ok", "扫描已触发");
|
||
setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000); // 给周期扫描留出时间
|
||
},
|
||
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
|
||
});
|
||
|
||
const groups = useMemo(() => groupByDir(booksQ.data ?? []), [booksQ.data]);
|
||
const booksByKey = useMemo(() => {
|
||
const m = new Map<string, Book>();
|
||
for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b);
|
||
return m;
|
||
}, [allQ.data]);
|
||
|
||
const continueRows = (progQ.data ?? []).slice(0, 12);
|
||
const libs = libsQ.data ?? [];
|
||
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<TopBar
|
||
right={
|
||
<>
|
||
<button className={btn} onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
|
||
刷新
|
||
</button>
|
||
{isAdmin && lib > 0 && (
|
||
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(lib)}>
|
||
{scan.isPending ? "扫描中…" : "扫描此库"}
|
||
</button>
|
||
)}
|
||
<input
|
||
className={input + " w-44"}
|
||
placeholder="搜索标题…"
|
||
value={qRaw}
|
||
onChange={(e) => setQRaw(e.target.value)}
|
||
/>
|
||
</>
|
||
}
|
||
/>
|
||
<div className="flex-1 overflow-y-auto p-4">
|
||
{libs.length === 0 && (
|
||
<p className="text-sm text-zinc-500">还没有书库。admin 请到「库管理」建库并扫描。</p>
|
||
)}
|
||
<div className="mb-4 flex flex-wrap gap-2">
|
||
<button className={lib === 0 ? btnPrimary : btn} onClick={() => setLib(0)}>
|
||
全部
|
||
</button>
|
||
{libs.map((l) => (
|
||
<button key={l.id} className={lib === l.id ? btnPrimary : btn} onClick={() => setLib(l.id)}>
|
||
{l.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{continueRows.length > 0 && (
|
||
<section className="mb-8">
|
||
<h2 className="mb-2 text-sm font-semibold text-zinc-400">继续阅读</h2>
|
||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||
{continueRows.map((r) => {
|
||
const b = booksByKey.get(`${r.library_id}/${r.path}`);
|
||
if (!b)
|
||
return (
|
||
<div key={`${r.library_id}/${r.path}`} className={`w-28 shrink-0 p-2 text-xs text-zinc-600 ${card}`}>
|
||
<p className="truncate">{r.title || "已删除的书"}</p>
|
||
<p>{Math.round(r.percent * 100)}%</p>
|
||
</div>
|
||
);
|
||
return (
|
||
<BookCard
|
||
key={`${r.library_id}/${r.path}`}
|
||
book={b}
|
||
onOpen={() => nav(`/book/${b.id}`)}
|
||
onDelete={() => del.mutate(b.id)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{booksQ.isError && (
|
||
<p className="mb-4 text-sm text-red-400">
|
||
加载失败:{booksQ.error instanceof Error ? booksQ.error.message : ""}{" "}
|
||
<button className={btn} onClick={() => booksQ.refetch()}>
|
||
重试
|
||
</button>
|
||
</p>
|
||
)}
|
||
{!booksQ.isPending && groups.length === 0 && !booksQ.isError && (
|
||
<p className="text-sm text-zinc-500">此库暂无书目(放入文件后等待扫描或点「扫描此库」)。</p>
|
||
)}
|
||
{groups.map((g) => (
|
||
<section key={g.dir || "//"} className="mb-8">
|
||
<h2 className="mb-2 text-sm font-semibold text-zinc-400">{g.dir || "根目录"}</h2>
|
||
<div className="flex flex-wrap gap-4">
|
||
{g.books.map((b) => (
|
||
<BookCard key={b.id} book={b} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 替换 `src/App.tsx`**
|
||
|
||
```tsx
|
||
import { Navigate, Route, Routes } from "react-router-dom";
|
||
import { AuthProvider, RequireAuth } from "./auth/AuthContext";
|
||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||
import { Toaster } from "./components/Toaster";
|
||
import Login from "./pages/Login";
|
||
import Shelf from "./pages/Shelf";
|
||
|
||
function Placeholder({ name }: { name: string }) {
|
||
return <div className="p-8 text-zinc-500">{name}:待后续任务落地</div>;
|
||
}
|
||
|
||
export default function App() {
|
||
return (
|
||
<ErrorBoundary>
|
||
<AuthProvider>
|
||
<Routes>
|
||
<Route path="/login" element={<Login />} />
|
||
<Route
|
||
path="/"
|
||
element={
|
||
<RequireAuth>
|
||
<Shelf />
|
||
</RequireAuth>
|
||
}
|
||
/>
|
||
<Route
|
||
path="/book/:id"
|
||
element={
|
||
<RequireAuth>
|
||
<Placeholder name="阅读器" />
|
||
</RequireAuth>
|
||
}
|
||
/>
|
||
<Route path="*" element={<Navigate to="/" replace />} />
|
||
</Routes>
|
||
<Toaster />
|
||
</AuthProvider>
|
||
</ErrorBoundary>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 门槛**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: 全绿。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web && git commit -m "feat(web): shelf with library tabs, search, dir groups, covers, continue-reading, admin delete/scan"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 进度内核(节流+keepalive)+ Reader 壳(按格式分发)
|
||
|
||
**Files:**
|
||
- Create: `web/src/lib/progress.ts`、`web/src/lib/useProgress.ts`、`web/src/pages/Reader.tsx`
|
||
- Modify: `web/src/App.tsx`(整文件替换,`/book/:id` 换成真 Reader)
|
||
- Test: `web/test/progress.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 2 `api.putProgress`(第 4 参 keepalive);Task 3 `queryClient`
|
||
- Produces:
|
||
- `lib/progress.ts`:`type ProgressSend = (locator: Record<string, unknown>, percent: number, keepalive: boolean) => Promise<unknown>`;`interface ProgressSaver { report(locator, percent): void; flush(): void }`;`createProgressSaver(send: ProgressSend, intervalMs?: number): ProgressSaver`(leading-edge + 尾部合并,flush 去重)
|
||
- `lib/useProgress.ts`:`interface ReaderProps { book: Book; initialLocator?: Record<string, unknown> }`;`useProgressSaver(bookId: number): ProgressSaver`(自动挂 pagehide/visibilitychange→flush)
|
||
- `pages/Reader.tsx`:`/book/:id` 壳;4 个 reader 任务各向 `READERS` 表加一行 lazy
|
||
|
||
- [ ] **Step 1: 写失败测试 `test/progress.test.ts`**
|
||
|
||
```ts
|
||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||
import { createProgressSaver } from "../src/lib/progress";
|
||
|
||
type Sent = [Record<string, unknown>, number, boolean];
|
||
|
||
beforeEach(() => vi.useFakeTimers());
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
it("leading 立即发;间隔内多次 report 合并成一次 trailing", () => {
|
||
const sent: Sent[] = [];
|
||
const s = createProgressSaver((l, p, ka) => {
|
||
sent.push([l, p, ka]);
|
||
return Promise.resolve();
|
||
});
|
||
s.report({ page: 1 }, 0.1);
|
||
expect(sent.length).toBe(1);
|
||
s.report({ page: 2 }, 0.2);
|
||
s.report({ page: 3 }, 0.3);
|
||
expect(sent.length).toBe(1);
|
||
vi.advanceTimersByTime(4999);
|
||
expect(sent.length).toBe(1);
|
||
vi.advanceTimersByTime(1);
|
||
expect(sent.length).toBe(2);
|
||
expect(sent[1]).toEqual([{ page: 3 }, 0.3, false]);
|
||
});
|
||
|
||
it("flush 立刻尾随发送且 keepalive=true;重复值去重;清空挂起", () => {
|
||
const sent: Sent[] = [];
|
||
const s = createProgressSaver((l, p, ka) => {
|
||
sent.push([l, p, ka]);
|
||
return Promise.resolve();
|
||
});
|
||
s.report({ page: 0 }, 0);
|
||
s.report({ page: 5 }, 0.5);
|
||
s.flush();
|
||
expect(sent.length).toBe(2);
|
||
expect(sent[1]).toEqual([{ page: 5 }, 0.5, true]);
|
||
s.flush();
|
||
expect(sent.length).toBe(2);
|
||
vi.advanceTimersByTime(10_000);
|
||
expect(sent.length).toBe(2); // 挂起已被 flush 消费,定时器不再补发
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑,确认失败**
|
||
|
||
Run: `cd web && npx vitest run test/progress.test.ts`
|
||
Expected: FAIL(模块不存在)
|
||
|
||
- [ ] **Step 3: 实现 `src/lib/progress.ts`**
|
||
|
||
```ts
|
||
export type ProgressSend = (
|
||
locator: Record<string, unknown>,
|
||
percent: number,
|
||
keepalive: boolean,
|
||
) => Promise<unknown>;
|
||
|
||
export interface ProgressSaver {
|
||
report: (locator: Record<string, unknown>, percent: number) => void;
|
||
flush: () => void;
|
||
}
|
||
|
||
// spec §8:PUT 节流 5s(leading-edge + 尾部合并);关页兜底走 keepalive fetch
|
||
//(spec 原文 sendBeacon,但 beacon 无法带 Authorization,后端 Bearer-only,语义等价替换)
|
||
export function createProgressSaver(send: ProgressSend, intervalMs = 5000): ProgressSaver {
|
||
let lastKey = "";
|
||
let pending: { loc: Record<string, unknown>; pct: number } | null = null;
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
let lastAt = 0;
|
||
|
||
function doSend(keepalive: boolean): void {
|
||
if (!pending) return;
|
||
const { loc, pct } = pending;
|
||
pending = null;
|
||
const key = JSON.stringify([loc, pct]);
|
||
if (key === lastKey) return;
|
||
lastKey = key;
|
||
send(loc, pct, keepalive).catch(() => {
|
||
if (lastKey === key) lastKey = ""; // 失败允许下次重发同值
|
||
});
|
||
}
|
||
|
||
return {
|
||
report(locator, percent) {
|
||
pending = { loc: locator, pct: percent };
|
||
if (timer) return; // 已有尾部定时器:只更新挂起值
|
||
const t = Date.now();
|
||
if (lastAt === 0 || t - lastAt >= intervalMs) {
|
||
lastAt = t;
|
||
doSend(false);
|
||
} else {
|
||
timer = setTimeout(() => {
|
||
timer = null;
|
||
lastAt = Date.now();
|
||
doSend(false);
|
||
}, intervalMs - (t - lastAt));
|
||
}
|
||
},
|
||
flush() {
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
timer = null;
|
||
}
|
||
lastAt = Date.now();
|
||
doSend(true);
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 实现 `src/lib/useProgress.ts`**
|
||
|
||
```ts
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { useEffect, useMemo } from "react";
|
||
import { api } from "../api/client";
|
||
import type { Book } from "../api/types";
|
||
import { createProgressSaver, type ProgressSaver } from "./progress";
|
||
|
||
export interface ReaderProps {
|
||
book: Book;
|
||
initialLocator?: Record<string, unknown>;
|
||
}
|
||
|
||
export function useProgressSaver(bookId: number): ProgressSaver {
|
||
const qc = useQueryClient();
|
||
const saver = useMemo(
|
||
() =>
|
||
createProgressSaver(async (loc, pct, ka) => {
|
||
await api.putProgress(bookId, loc, pct, ka);
|
||
if (!ka) qc.invalidateQueries({ queryKey: ["book", bookId] }); // spec §8:只 invalidate 单本
|
||
}),
|
||
[bookId, qc],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const onPageHide = () => saver.flush();
|
||
const onVis = () => {
|
||
if (document.visibilityState === "hidden") saver.flush();
|
||
};
|
||
globalThis.addEventListener?.("pagehide", onPageHide);
|
||
document?.addEventListener?.("visibilitychange", onVis);
|
||
return () => {
|
||
saver.flush();
|
||
globalThis.removeEventListener?.("pagehide", onPageHide);
|
||
document?.removeEventListener?.("visibilitychange", onVis);
|
||
};
|
||
}, [saver]);
|
||
|
||
return saver;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 实现 `src/pages/Reader.tsx`**
|
||
|
||
每个 reader 任务会向 `READERS` 加一行(本任务是空表 → 一律"未知格式"占位,先验证壳)。`initialLocator` 由壳统一解析(等 `["progress"]` 加载完再挂载 reader,消除恢复竞态)。
|
||
|
||
```tsx
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { Suspense, type ComponentType, type LazyExoticComponent } from "react"; // lazy 自 Task 7 起随首个 READERS 条目补进本行(noUnusedLocals)
|
||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||
import { api } from "../api/client";
|
||
import type { Format } from "../api/types";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { toast } from "../components/Toaster";
|
||
import { btn } from "../components/ui";
|
||
import type { ReaderProps } from "../lib/useProgress";
|
||
|
||
// reader 任务逐个往里加:Task 7 cbz → Task 8 txt/md → Task 9 pdf → Task 10 epub
|
||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {};
|
||
|
||
export default function Reader() {
|
||
const { id: idStr } = useParams();
|
||
const id = Number(idStr);
|
||
const qc = useQueryClient();
|
||
const nav = useNavigate();
|
||
const { isAdmin } = useAuth();
|
||
|
||
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
|
||
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||
const del = useMutation({
|
||
mutationFn: () => api.deleteBook(id),
|
||
onSuccess: () => {
|
||
toast("ok", "已删除");
|
||
qc.invalidateQueries({ queryKey: ["books"] });
|
||
nav("/");
|
||
},
|
||
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
|
||
});
|
||
|
||
const book = bookQ.data;
|
||
const body = (() => {
|
||
if (!Number.isFinite(id)) return <Msg text="无效的书 id" />;
|
||
if (bookQ.isPending) return <Msg text="加载中…" />;
|
||
if (bookQ.isError)
|
||
return (
|
||
<Msg
|
||
text={"加载失败:" + (bookQ.error instanceof Error ? bookQ.error.message : "")}
|
||
retry={() => bookQ.refetch()}
|
||
/>
|
||
);
|
||
if (!book) return <Msg text="未找到这本书" />;
|
||
if (book.state === "error") return <Msg text={`文件损坏:${book.error}`} />;
|
||
if (progQ.isPending) return <Msg text="恢复进度…" />;
|
||
const R = READERS[book.format];
|
||
if (!R) return <Msg text={`暂不支持的格式:${book.format}`} />;
|
||
const row = (progQ.data ?? []).find((r) => r.library_id === book.library_id && r.path === book.path);
|
||
return <R book={book} initialLocator={row?.locator} />;
|
||
})();
|
||
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<header className="flex shrink-0 items-center gap-3 border-b border-zinc-800 px-4 py-2 text-sm">
|
||
<Link to="/" className="text-zinc-400 hover:text-zinc-100">
|
||
← 书架
|
||
</Link>
|
||
<span className="truncate font-medium">{book?.title ?? "…"}</span>
|
||
<span className="text-zinc-500">
|
||
{book?.library ? book.library + " · " : ""}
|
||
{book?.format}
|
||
{book ? ` · ${Math.round(book.percent * 100)}%` : ""}
|
||
</span>
|
||
{isAdmin && book && (
|
||
<button
|
||
className={btn + " ml-auto text-red-300"}
|
||
onClick={() => {
|
||
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) del.mutate();
|
||
}}
|
||
>
|
||
删除
|
||
</button>
|
||
)}
|
||
</header>
|
||
<div className="min-h-0 flex-1">
|
||
<Suspense fallback={<Msg text="加载阅读器…" />}>{body}</Suspense>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Msg({ text, retry }: { text: string; retry?: () => void }) {
|
||
return (
|
||
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
|
||
<div>
|
||
<p className="mb-3">{text}</p>
|
||
{retry && (
|
||
<button className={btn} onClick={retry}>
|
||
重试
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 替换 `src/App.tsx`**
|
||
|
||
```tsx
|
||
import { Navigate, Route, Routes } from "react-router-dom";
|
||
import { AuthProvider, RequireAuth } from "./auth/AuthContext";
|
||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||
import { Toaster } from "./components/Toaster";
|
||
import Login from "./pages/Login";
|
||
import Reader from "./pages/Reader";
|
||
import Shelf from "./pages/Shelf";
|
||
|
||
export default function App() {
|
||
return (
|
||
<ErrorBoundary>
|
||
<AuthProvider>
|
||
<Routes>
|
||
<Route path="/login" element={<Login />} />
|
||
<Route
|
||
path="/"
|
||
element={
|
||
<RequireAuth>
|
||
<Shelf />
|
||
</RequireAuth>
|
||
}
|
||
/>
|
||
<Route
|
||
path="/book/:id"
|
||
element={
|
||
<RequireAuth>
|
||
<Reader />
|
||
</RequireAuth>
|
||
}
|
||
/>
|
||
<Route path="*" element={<Navigate to="/" replace />} />
|
||
</Routes>
|
||
<Toaster />
|
||
</AuthProvider>
|
||
</ErrorBoundary>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 跑门槛确认通过**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: progress 2 用例 PASS;build 无错(空 READERS 是合法态)。
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add web && git commit -m "feat(web): throttled progress saver with keepalive flush, reader dispatch shell"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: CBZ Reader — 虚拟滚动页索引(virt 纯类先行)
|
||
|
||
**Files:**
|
||
- Create: `web/src/lib/virt.ts`、`web/src/readers/CbzReader.tsx`
|
||
- Modify: `web/src/pages/Reader.tsx`(READERS 表加 cbz)
|
||
- Test: `web/test/virt.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `api.pageCount(book.pages_url)`→`{count}`;`formatPageUrl(book.page_url_fmt, n)`;`fetchObjectUrl`;`useProgressSaver`/`ReaderProps`
|
||
- Produces: `lib/virt.ts`:`class PageHeights { constructor(n: number, est: number); set(i: number, h: number): number /* 高度增量 */; offset(i: number): number; total(): number; pageAt(y: number): number; range(scrollTop: number, viewport: number, overscan: number): [number, number] /* [start, end) */ }`;`readers/CbzReader.tsx` default export `(p: ReaderProps) => JSX`,locator `{page}`(0 基),percent `(page+1)/count`
|
||
|
||
- [ ] **Step 1: 写失败测试 `test/virt.test.ts`**
|
||
|
||
```ts
|
||
import { expect, it } from "vitest";
|
||
import { PageHeights } from "../src/lib/virt";
|
||
|
||
it("估算高度:offset/total/pageAt/range(带 overscan、夹边)", () => {
|
||
const ph = new PageHeights(10, 100);
|
||
expect(ph.offset(3)).toBe(300);
|
||
expect(ph.total()).toBe(1000);
|
||
expect(ph.pageAt(350)).toBe(3);
|
||
expect(ph.pageAt(-10)).toBe(0);
|
||
expect(ph.pageAt(99_999)).toBe(9);
|
||
expect(ph.range(0, 100, 2)).toEqual([0, 3]);
|
||
expect(ph.range(950, 100, 2)).toEqual([7, 10]);
|
||
});
|
||
|
||
it("set 修正高度并平移后续 offset;返回增量;幂等", () => {
|
||
const ph = new PageHeights(10, 100);
|
||
expect(ph.set(2, 150)).toBe(50);
|
||
expect(ph.offset(2)).toBe(200); // 自身 offset 不受自己影响
|
||
expect(ph.offset(3)).toBe(350); // 后续整体 +50
|
||
expect(ph.total()).toBe(1050);
|
||
expect(ph.pageAt(349)).toBe(2);
|
||
expect(ph.pageAt(351)).toBe(3);
|
||
expect(ph.set(2, 150)).toBe(0);
|
||
});
|
||
|
||
it("n=0 时一切归空", () => {
|
||
const ph = new PageHeights(0, 100);
|
||
expect(ph.total()).toBe(0);
|
||
expect(ph.range(0, 100, 2)).toEqual([0, 0]);
|
||
expect(ph.set(0, 50)).toBe(0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑,确认失败**
|
||
|
||
Run: `cd web && npx vitest run test/virt.test.ts`
|
||
Expected: FAIL(模块不存在)
|
||
|
||
- [ ] **Step 3: 实现 `src/lib/virt.ts`**
|
||
|
||
```ts
|
||
// CBZ 竖滚虚拟列表的高度模型:估高起步,页图加载完用真实高校正,
|
||
// 前缀和 + 二分定位。纯逻辑,无 DOM,vitest 全量覆盖。
|
||
export class PageHeights {
|
||
private actual: (number | undefined)[];
|
||
private cum: number[]; // cum[i] = 第 i 页顶部偏移;cum[n] = total
|
||
|
||
constructor(
|
||
private n: number,
|
||
private est: number,
|
||
) {
|
||
this.actual = new Array(n).fill(undefined);
|
||
this.cum = new Array(n + 1);
|
||
this.rebuild(0);
|
||
}
|
||
|
||
private rebuild(from: number): void {
|
||
if (from === 0) this.cum[0] = 0;
|
||
for (let i = from; i < this.n; i++) this.cum[i + 1] = this.cum[i] + (this.actual[i] ?? this.est);
|
||
}
|
||
|
||
/** 返回高度增量(相对旧值)。同值幂等返 0;越界/非法高度忽略。 */
|
||
set(i: number, h: number): number {
|
||
if (i < 0 || i >= this.n || !Number.isFinite(h) || h <= 0) return 0;
|
||
const old = this.actual[i];
|
||
if (old === h) return 0;
|
||
this.actual[i] = h;
|
||
this.rebuild(i);
|
||
return h - (old ?? this.est);
|
||
}
|
||
|
||
offset(i: number): number {
|
||
if (this.n === 0) return 0;
|
||
return this.cum[Math.max(0, Math.min(i, this.n))];
|
||
}
|
||
|
||
total(): number {
|
||
return this.cum[this.n];
|
||
}
|
||
|
||
/** 覆盖 y 的那一页。 */
|
||
pageAt(y: number): number {
|
||
if (this.n === 0) return 0;
|
||
let lo = 0;
|
||
let hi = this.n - 1;
|
||
let ans = 0;
|
||
const v = Math.max(0, y);
|
||
while (lo <= hi) {
|
||
const mid = (lo + hi) >> 1;
|
||
if (this.cum[mid] <= v) {
|
||
ans = mid;
|
||
lo = mid + 1;
|
||
} else hi = mid - 1;
|
||
}
|
||
return ans;
|
||
}
|
||
|
||
range(scrollTop: number, viewport: number, overscan: number): [number, number] {
|
||
if (this.n === 0) return [0, 0];
|
||
const a = Math.max(0, this.pageAt(scrollTop) - overscan);
|
||
const b = Math.min(this.n, this.pageAt(scrollTop + viewport) + overscan); // [start, end)
|
||
return [a, b];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 实现 `src/readers/CbzReader.tsx`**
|
||
|
||
```tsx
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { api, formatPageUrl } from "../api/client";
|
||
import { btn } from "../components/ui";
|
||
import { fetchObjectUrl } from "../lib/authImage";
|
||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||
import { PageHeights } from "../lib/virt";
|
||
|
||
const MAX_W = 720;
|
||
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
|
||
|
||
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
|
||
const [src, setSrc] = useState("");
|
||
const [failed, setFailed] = useState(false);
|
||
const [nonce, setNonce] = useState(0);
|
||
useEffect(() => {
|
||
let dead = false;
|
||
setSrc("");
|
||
setFailed(false);
|
||
fetchObjectUrl(url)
|
||
.then((s) => !dead && setSrc(s))
|
||
.catch(() => !dead && setFailed(true));
|
||
return () => {
|
||
dead = true;
|
||
};
|
||
}, [url, nonce]);
|
||
if (failed)
|
||
return (
|
||
<div className="grid place-items-center gap-2 bg-zinc-900 py-8 text-sm text-zinc-500" style={{ width }}>
|
||
本页加载失败
|
||
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
|
||
重试
|
||
</button>
|
||
</div>
|
||
);
|
||
if (!src)
|
||
return <div className="animate-pulse bg-zinc-800" style={{ width, height: width * EST_RATIO }} />;
|
||
return (
|
||
<img
|
||
src={src}
|
||
alt=""
|
||
style={{ width }}
|
||
className="block"
|
||
onLoad={(e) => {
|
||
const el = e.currentTarget;
|
||
if (el.naturalWidth > 0) onLoaded((el.naturalHeight / el.naturalWidth) * width);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export default function CbzReader({ book, initialLocator }: ReaderProps) {
|
||
const countQ = useQuery({
|
||
queryKey: ["pages", book.id],
|
||
queryFn: () => api.pageCount(book.pages_url ?? ""),
|
||
enabled: !!book.pages_url,
|
||
retry: 0,
|
||
});
|
||
const count = countQ.data?.count ?? 0;
|
||
const saver = useProgressSaver(book.id);
|
||
const boxRef = useRef<HTMLDivElement>(null);
|
||
const [width, setWidth] = useState(480);
|
||
const [vh, setVh] = useState(600);
|
||
const [top, setTop] = useState(0);
|
||
const [, bump] = useState(0);
|
||
const restored = useRef(false);
|
||
|
||
const ph = useMemo(() => new PageHeights(count, width * EST_RATIO), [count, width]);
|
||
|
||
// 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准)
|
||
useEffect(() => {
|
||
const el = boxRef.current;
|
||
if (!el) return;
|
||
const ro = new ResizeObserver(() => {
|
||
setWidth(Math.min(el.clientWidth, MAX_W));
|
||
setVh(el.clientHeight);
|
||
});
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
|
||
// 首次拿到 count 后恢复进度页
|
||
useEffect(() => {
|
||
if (restored.current || !count || !boxRef.current) return;
|
||
restored.current = true;
|
||
const p = Number(initialLocator?.page);
|
||
if (Number.isInteger(p) && p > 0 && p < count) boxRef.current.scrollTop = ph.offset(p);
|
||
}, [count, initialLocator, ph]);
|
||
|
||
function onScroll() {
|
||
const el = boxRef.current;
|
||
if (!el) return;
|
||
const t = el.scrollTop;
|
||
setTop(t);
|
||
if (count) {
|
||
const cur = ph.pageAt(t + vh / 2);
|
||
saver.report({ page: cur }, (cur + 1) / count);
|
||
for (let j = cur + 1; j <= Math.min(count - 1, cur + 5); j++)
|
||
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); // 预取下方 5 页
|
||
}
|
||
}
|
||
|
||
if (countQ.isPending) return <div className="grid h-full place-items-center text-zinc-500">页索引加载中…</div>;
|
||
if (countQ.isError || !book.page_url_fmt)
|
||
return (
|
||
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
|
||
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
|
||
</div>
|
||
);
|
||
|
||
const [a, b] = ph.range(top, vh, 2);
|
||
const cur = ph.pageAt(top + vh / 2);
|
||
|
||
return (
|
||
<div className="relative h-full">
|
||
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto">
|
||
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: MAX_W }}>
|
||
{Array.from({ length: b - a }, (_, k) => {
|
||
const i = a + k;
|
||
return (
|
||
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
|
||
<PageImg
|
||
url={formatPageUrl(book.page_url_fmt, i)}
|
||
width={width}
|
||
onLoaded={(h) => {
|
||
const el = boxRef.current;
|
||
const topOfI = ph.offset(i);
|
||
const d = ph.set(i, h);
|
||
if (el && d !== 0 && topOfI < el.scrollTop) el.scrollTop += d; // 视口上方页高变化 → 补偿滚动
|
||
bump((x) => x + 1);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="pointer-events-none absolute bottom-3 right-3 rounded bg-zinc-900/80 px-2 py-1 text-xs text-zinc-300">
|
||
{cur + 1}/{count}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: `src/pages/Reader.tsx` 接线 cbz**
|
||
|
||
先把 react import 行还原为含 `lazy`(见 Task 6 注释),再整块替换 READERS:
|
||
|
||
```tsx
|
||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||
cbz: lazy(() => import("../readers/CbzReader")),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 6: 跑门槛确认通过**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: virt 3 用例 PASS;build 无错。
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add web && git commit -m "feat(web): cbz reader — virtual scroll with height correction, prefetch, progress restore"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: TXT/MD Reader(marked + DOMPurify,scrollFraction 进度)
|
||
|
||
**Files:**
|
||
- Create: `web/src/readers/TextReader.tsx`
|
||
- Modify: `web/src/pages/Reader.tsx`(READERS 加 txt/md)、`web/src/index.css`(加 md 正文样式)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `apiRaw(book.file_url).text()`;`useProgressSaver`/`ReaderProps`
|
||
- Produces: `readers/TextReader.tsx` default export `(p: ReaderProps)`,同时服务 txt 与 md;locator `{scrollFraction}`(0..1),percent 同值
|
||
|
||
- [ ] **Step 1: 实现 `src/readers/TextReader.tsx`**
|
||
|
||
```tsx
|
||
import DOMPurify from "dompurify";
|
||
import { marked } from "marked";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { apiRaw } from "../api/client";
|
||
import { btn } from "../components/ui";
|
||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||
|
||
export default function TextReader({ book, initialLocator }: ReaderProps) {
|
||
const boxRef = useRef<HTMLDivElement>(null);
|
||
const saver = useProgressSaver(book.id);
|
||
const restored = useRef(false);
|
||
const [text, setText] = useState<string | null>(null);
|
||
const [err, setErr] = useState("");
|
||
const [nonce, setNonce] = useState(0);
|
||
|
||
useEffect(() => {
|
||
let dead = false;
|
||
setText(null);
|
||
setErr("");
|
||
apiRaw(book.file_url ?? "")
|
||
.then((r) => r.text())
|
||
.then((t) => !dead && setText(t))
|
||
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
|
||
return () => {
|
||
dead = true;
|
||
};
|
||
}, [book.file_url, nonce]);
|
||
|
||
const html = useMemo(() => {
|
||
if (text == null || book.format !== "md") return null;
|
||
return DOMPurify.sanitize(marked.parse(text, { async: false }));
|
||
}, [text, book.format]);
|
||
|
||
useEffect(() => {
|
||
if (restored.current || text == null || !boxRef.current) return;
|
||
restored.current = true;
|
||
const f = Number(initialLocator?.scrollFraction);
|
||
if (Number.isFinite(f) && f > 0 && f <= 1) {
|
||
const el = boxRef.current;
|
||
el.scrollTop = f * (el.scrollHeight - el.clientHeight);
|
||
}
|
||
}, [text, initialLocator]);
|
||
|
||
function onScroll() {
|
||
const el = boxRef.current;
|
||
if (!el) return;
|
||
const max = el.scrollHeight - el.clientHeight;
|
||
const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
||
saver.report({ scrollFraction: frac }, frac);
|
||
}
|
||
|
||
if (err)
|
||
return (
|
||
<div className="grid h-full place-items-center p-8 text-center">
|
||
<div>
|
||
<p className="mb-3 text-red-400">{err}</p>
|
||
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
|
||
重试
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
if (text == null) return <div className="grid h-full place-items-center text-zinc-500">正文加载中…</div>;
|
||
|
||
return (
|
||
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto p-6">
|
||
{html !== null ? (
|
||
<article className="md-body mx-auto max-w-2xl text-[15px] leading-7" dangerouslySetInnerHTML={{ __html: html }} />
|
||
) : (
|
||
<pre className="mx-auto max-w-2xl font-sans text-[15px] leading-7 break-words whitespace-pre-wrap">{text}</pre>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: `index.css` 末尾追加 md 正文样式(Tailwind4 无 typography 插件,不新增依赖)**
|
||
|
||
```css
|
||
/* markdown 正文(仅 .md-body 作用域) */
|
||
.md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; }
|
||
.md-body h2 { @apply mt-6 mb-2 text-xl font-semibold; }
|
||
.md-body h3 { @apply mt-4 mb-2 text-base font-semibold; }
|
||
.md-body p { @apply my-3; }
|
||
.md-body ul { @apply my-3 list-disc space-y-1 pl-6; }
|
||
.md-body ol { @apply my-3 list-decimal space-y-1 pl-6; }
|
||
.md-body blockquote { @apply my-3 border-l-2 border-zinc-600 pl-4 text-zinc-400; }
|
||
.md-body code { @apply rounded bg-zinc-800 px-1 py-0.5 text-[13px]; }
|
||
.md-body pre { @apply my-3 overflow-x-auto rounded-lg bg-zinc-900 p-3; }
|
||
.md-body pre code { @apply bg-transparent p-0; }
|
||
.md-body a { @apply text-emerald-400 underline; }
|
||
.md-body img { @apply my-3 max-w-full rounded; }
|
||
.md-body hr { @apply my-6 border-zinc-800; }
|
||
.md-body table { @apply my-3 w-full border-collapse text-sm; }
|
||
.md-body th, .md-body td { @apply border border-zinc-700 px-2 py-1 text-left; }
|
||
```
|
||
|
||
- [ ] **Step 3: `src/pages/Reader.tsx` 接线**
|
||
|
||
`READERS` 整块替换:
|
||
|
||
```tsx
|
||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||
cbz: lazy(() => import("../readers/CbzReader")),
|
||
txt: lazy(() => import("../readers/TextReader")),
|
||
md: lazy(() => import("../readers/TextReader")),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: 门槛**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: 全绿(vitest 无新增;门槛=编译+构建)。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web && git commit -m "feat(web): txt/md reader with dompurified markdown and scroll-fraction progress"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: PDF Reader(PDF.js,单页画布渲染)
|
||
|
||
**Files:**
|
||
- Create: `web/src/readers/PdfReader.tsx`
|
||
- Modify: `web/src/pages/Reader.tsx`(READERS 加 pdf)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `apiRaw(book.file_url).arrayBuffer()`;`useProgressSaver`/`ReaderProps`
|
||
- Produces: `readers/PdfReader.tsx` default export `(p: ReaderProps)`;locator `{page}`(0 基,与 CBZ 统一 0 基),percent `(page+1)/numPages`
|
||
|
||
- [ ] **Step 1: 实现 `src/readers/PdfReader.tsx`**
|
||
|
||
```tsx
|
||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||
import * as pdfjs from "pdfjs-dist";
|
||
import { apiRaw } from "../api/client";
|
||
import { btn } from "../components/ui";
|
||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||
|
||
// Vite 原生 worker 语法;pdfjs v5 要求 module worker
|
||
pdfjs.GlobalWorkerOptions.workerPort = new Worker(
|
||
new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url),
|
||
{ type: "module" },
|
||
);
|
||
|
||
type RenderTask = { cancel(): void; promise: Promise<void> };
|
||
|
||
export default function PdfReader({ book, initialLocator }: ReaderProps) {
|
||
const wrapRef = useRef<HTMLDivElement>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
|
||
const taskRef = useRef<RenderTask | null>(null);
|
||
const restored = useRef(false);
|
||
const saver = useProgressSaver(book.id);
|
||
const [num, setNum] = useState(0);
|
||
const [page, setPage] = useState(0); // 0 基
|
||
const [err, setErr] = useState("");
|
||
const [nonce, setNonce] = useState(0); // 仅加载重试
|
||
const [redraw, setRedraw] = useState(0); // 仅 resize 重绘(不重新下载文档)
|
||
|
||
useEffect(() => {
|
||
let dead = false;
|
||
setErr("");
|
||
(async () => {
|
||
const buf = await apiRaw(book.file_url ?? "").then((r) => r.arrayBuffer());
|
||
if (dead) return;
|
||
const doc = await pdfjs.getDocument({ data: new Uint8Array(buf) }).promise;
|
||
if (dead) {
|
||
await doc.destroy();
|
||
return;
|
||
}
|
||
docRef.current = doc;
|
||
setNum(doc.numPages);
|
||
if (!restored.current) {
|
||
restored.current = true;
|
||
const p = Number(initialLocator?.page);
|
||
if (Number.isInteger(p) && p > 0 && p < doc.numPages) setPage(p);
|
||
}
|
||
})().catch((e) => !dead && setErr(e instanceof Error ? e.message : "PDF 加载失败"));
|
||
return () => {
|
||
dead = true;
|
||
taskRef.current?.cancel();
|
||
docRef.current?.destroy();
|
||
docRef.current = null;
|
||
};
|
||
}, [book.file_url, nonce]); // initialLocator 是查询缓存值,加载一次即稳定
|
||
|
||
// 渲染当前页
|
||
useEffect(() => {
|
||
const doc = docRef.current;
|
||
const canvas = canvasRef.current;
|
||
const wrap = wrapRef.current;
|
||
if (!doc || !canvas || !wrap || num === 0) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
const p = await doc.getPage(page + 1);
|
||
if (cancelled) return;
|
||
const cssW = wrap.clientWidth;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const base = p.getViewport({ scale: 1 });
|
||
const vp = p.getViewport({ scale: (cssW * dpr) / base.width });
|
||
canvas.width = Math.floor(vp.width);
|
||
canvas.height = Math.floor(vp.height);
|
||
canvas.style.width = "100%";
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) throw new Error("canvas 2d 上下文不可用");
|
||
const t = p.render({ canvasContext: ctx, viewport: vp }) as RenderTask;
|
||
taskRef.current = t;
|
||
await t.promise;
|
||
})().catch((e) => {
|
||
if (!cancelled && (e as { name?: string }).name !== "AbortException")
|
||
setErr(e instanceof Error ? e.message : "渲染失败");
|
||
});
|
||
saver.report({ page }, (page + 1) / num);
|
||
return () => {
|
||
cancelled = true;
|
||
taskRef.current?.cancel();
|
||
};
|
||
}, [page, num, redraw, saver]);
|
||
|
||
// 容器尺寸变化 → 重绘
|
||
useEffect(() => {
|
||
const wrap = wrapRef.current;
|
||
if (!wrap) return;
|
||
let t: ReturnType<typeof setTimeout>;
|
||
const ro = new ResizeObserver(() => {
|
||
clearTimeout(t);
|
||
t = setTimeout(() => setRedraw((x) => x + 1), 200);
|
||
});
|
||
ro.observe(wrap);
|
||
return () => {
|
||
clearTimeout(t);
|
||
ro.disconnect();
|
||
};
|
||
}, []);
|
||
|
||
function onKey(e: KeyboardEvent) {
|
||
if (e.key === "ArrowRight" || e.key === "PageDown") setPage((p) => Math.min(num - 1, p + 1));
|
||
else if (e.key === "ArrowLeft" || e.key === "PageUp") setPage((p) => Math.max(0, p - 1));
|
||
else return;
|
||
e.preventDefault();
|
||
}
|
||
|
||
if (err)
|
||
return (
|
||
<div className="grid h-full place-items-center p-8 text-center">
|
||
<div>
|
||
<p className="mb-3 text-red-400">{err}</p>
|
||
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
|
||
重试
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
if (num === 0) return <div className="grid h-full place-items-center text-zinc-500">PDF 加载中…</div>;
|
||
|
||
return (
|
||
<div tabIndex={0} onKeyDown={onKey} className="grid h-full grid-rows-[1fr_auto] focus:outline-none">
|
||
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-zinc-900 p-4">
|
||
<canvas ref={canvasRef} className="rounded shadow-lg" />
|
||
</div>
|
||
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
|
||
<button className={btn} disabled={page === 0} onClick={() => setPage(0)}>
|
||
⏮
|
||
</button>
|
||
<button className={btn} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
|
||
上一页
|
||
</button>
|
||
<span className="text-zinc-400">
|
||
{page + 1}/{num}
|
||
</span>
|
||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
|
||
下一页
|
||
</button>
|
||
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)}>
|
||
⏭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: `src/pages/Reader.tsx` 接线**
|
||
|
||
`READERS` 整块替换:
|
||
|
||
```tsx
|
||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||
cbz: lazy(() => import("../readers/CbzReader")),
|
||
txt: lazy(() => import("../readers/TextReader")),
|
||
md: lazy(() => import("../readers/TextReader")),
|
||
pdf: lazy(() => import("../readers/PdfReader")),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: 门槛**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: 全绿。若 `p.render({canvasContext, viewport})` 在已装 pdfjs-dist 大版本下类型不符(历史上参数名有 `canvas`/`canvasContext` 差异),按 node_modules 内 `types/display.d.xml`→`RenderParameters` 实际字段名调整这一处,**不改其余逻辑**。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web && git commit -m "feat(web): pdf reader with dpr-aware canvas rendering and keyboard paging"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: EPUB Reader(epub.js,CFI 进度)
|
||
|
||
**Files:**
|
||
- Create: `web/src/types/shims.d.ts`、`web/src/readers/EpubReader.tsx`
|
||
- Modify: `web/src/pages/Reader.tsx`(READERS 加 epub)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `apiRaw(book.file_url).arrayBuffer()`;`useProgressSaver`/`ReaderProps`
|
||
- Produces: `readers/EpubReader.tsx` default export `(p: ReaderProps)`;locator `{cfi}`,percent `(sectionIndex+1)/spineLength`
|
||
|
||
- [ ] **Step 1: 写 `src/types/shims.d.ts`(epubjs 0.3 类型不完备,兜底声明,包内自带类型时自动失效)**
|
||
|
||
```ts
|
||
declare module "epubjs";
|
||
```
|
||
|
||
- [ ] **Step 2: 实现 `src/readers/EpubReader.tsx`**
|
||
|
||
```tsx
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { apiRaw } from "../api/client";
|
||
import { btn } from "../components/ui";
|
||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||
|
||
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
||
interface EpubLocation {
|
||
start?: { cfi?: string; sectionIndex?: number };
|
||
}
|
||
interface EpubRendition {
|
||
display(target?: string): unknown;
|
||
next(): unknown;
|
||
prev(): unknown;
|
||
on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
|
||
destroy(): void;
|
||
}
|
||
interface EpubBookLike {
|
||
ready: Promise<unknown>;
|
||
spine?: unknown[];
|
||
renderTo(el: HTMLElement, opts: Record<string, unknown>): EpubRendition;
|
||
destroy(): void;
|
||
}
|
||
|
||
async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> {
|
||
const m = (await import("epubjs")) as { default?: unknown };
|
||
return m.default as (data: Uint8Array) => EpubBookLike;
|
||
}
|
||
|
||
export default function EpubReader({ book, initialLocator }: ReaderProps) {
|
||
const hostRef = useRef<HTMLDivElement>(null);
|
||
const rendRef = useRef<EpubRendition | null>(null);
|
||
const saver = useProgressSaver(book.id);
|
||
const [err, setErr] = useState("");
|
||
const [nonce, setNonce] = useState(0);
|
||
const [ready, setReady] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let dead = false;
|
||
let bookObj: EpubBookLike | null = null;
|
||
setErr("");
|
||
setReady(false);
|
||
(async () => {
|
||
const ePub = await loadEpub();
|
||
const buf = await apiRaw(book.file_url ?? "").then((r) => r.arrayBuffer());
|
||
if (dead) return;
|
||
bookObj = ePub(new Uint8Array(buf)); // 字节已在内存,内部 iframe 不再回源,绕开鉴权问题
|
||
await bookObj.ready;
|
||
if (dead || !hostRef.current) return;
|
||
const spineLen = bookObj.spine?.length ?? 1;
|
||
const r = bookObj.renderTo(hostRef.current, { width: "100%", height: "100%", flow: "paginated" });
|
||
rendRef.current = r;
|
||
r.on("relocated", (loc) => {
|
||
const cfi = loc.start?.cfi ?? "";
|
||
const idx = loc.start?.sectionIndex ?? 0;
|
||
const pct = spineLen > 1 ? (idx + 1) / spineLen : 0.5;
|
||
saver.report(cfi ? { cfi } : {}, Math.min(1, pct));
|
||
});
|
||
const cfi = typeof initialLocator?.cfi === "string" ? (initialLocator.cfi as string) : undefined;
|
||
try {
|
||
r.display(cfi); // cfi 失效(书被替换等)时 epubjs 会抛
|
||
} catch {
|
||
r.display();
|
||
}
|
||
setReady(true);
|
||
})().catch((e) => !dead && setErr(e instanceof Error ? e.message : "EPUB 加载失败"));
|
||
return () => {
|
||
dead = true;
|
||
rendRef.current?.destroy();
|
||
rendRef.current = null;
|
||
bookObj?.destroy();
|
||
};
|
||
}, [book.file_url, nonce]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
if (err)
|
||
return (
|
||
<div className="grid h-full place-items-center p-8 text-center">
|
||
<div>
|
||
<p className="mb-3 text-red-400">{err}</p>
|
||
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
|
||
重试
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="grid h-full grid-rows-[1fr_auto]">
|
||
<div className="relative min-h-0 bg-white">
|
||
<div ref={hostRef} className="absolute inset-0" />
|
||
{!ready && !err && <div className="grid h-full place-items-center bg-zinc-950 text-zinc-500">EPUB 加载中…</div>}
|
||
</div>
|
||
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
|
||
<button className={btn} onClick={() => rendRef.current?.prev()}>
|
||
上一页
|
||
</button>
|
||
<button className={btn} onClick={() => rendRef.current?.next()}>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: `src/pages/Reader.tsx` 接线**
|
||
|
||
`READERS` 整块替换:
|
||
|
||
```tsx
|
||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||
cbz: lazy(() => import("../readers/CbzReader")),
|
||
txt: lazy(() => import("../readers/TextReader")),
|
||
md: lazy(() => import("../readers/TextReader")),
|
||
pdf: lazy(() => import("../readers/PdfReader")),
|
||
epub: lazy(() => import("../readers/EpubReader")),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: 门槛**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: 全绿。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web && git commit -m "feat(web): epub reader via epubjs arraybuffer bootstrap with cfi progress"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Admin — 用户管理 + 库管理(建库/扫描/上传)
|
||
|
||
**Files:**
|
||
- Create: `web/src/pages/admin/Users.tsx`、`web/src/pages/admin/Libraries.tsx`
|
||
- Modify: `web/src/App.tsx`(整文件替换)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 2 `api.*`;Task 3 `useAuth/RequireAdmin`;Task 5 `TopBar`、ui 常量
|
||
- Produces: 路由 `/admin/users`、`/admin/libraries`(TopBar 已挂链接);query key `["users"]`
|
||
|
||
- [ ] **Step 1: 实现 `src/pages/admin/Users.tsx`**
|
||
|
||
```tsx
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useState, type FormEvent } from "react";
|
||
import { api } from "../../api/client";
|
||
import { useAuth } from "../../auth/AuthContext";
|
||
import { TopBar } from "../../components/TopBar";
|
||
import { toast } from "../../components/Toaster";
|
||
import { btn, btnPrimary, card, input } from "../../components/ui";
|
||
|
||
export default function AdminUsers() {
|
||
const qc = useQueryClient();
|
||
const { user: me } = useAuth();
|
||
const usersQ = useQuery({ queryKey: ["users"], queryFn: api.listUsers });
|
||
const [u, setU] = useState("");
|
||
const [p, setP] = useState("");
|
||
const [role, setRole] = useState<"member" | "admin">("member");
|
||
|
||
const create = useMutation({
|
||
mutationFn: () => api.createUser(u.trim(), p, role),
|
||
onSuccess: () => {
|
||
toast("ok", "用户已创建");
|
||
setU("");
|
||
setP("");
|
||
void qc.invalidateQueries({ queryKey: ["users"] });
|
||
},
|
||
onError: (e) => toast("err", e instanceof Error ? e.message : "创建失败"),
|
||
});
|
||
const del = useMutation({
|
||
mutationFn: (id: number) => api.deleteUser(id),
|
||
onSuccess: () => {
|
||
toast("ok", "用户已删除(其阅读进度一并删除)");
|
||
void qc.invalidateQueries({ queryKey: ["users"] });
|
||
},
|
||
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
|
||
});
|
||
|
||
function submit(e: FormEvent) {
|
||
e.preventDefault();
|
||
create.mutate();
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<TopBar />
|
||
<div className="flex-1 overflow-y-auto p-4">
|
||
<h1 className="mb-4 text-lg font-semibold">用户管理</h1>
|
||
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
||
<input className={input} placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
|
||
<input className={input} placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
|
||
<select className={input} value={role} onChange={(e) => setRole(e.target.value as "member" | "admin")}>
|
||
<option value="member">member</option>
|
||
<option value="admin">admin</option>
|
||
</select>
|
||
<button className={btnPrimary} disabled={create.isPending || !u.trim() || p.length < 8}>
|
||
{create.isPending ? "创建中…" : "创建"}
|
||
</button>
|
||
</form>
|
||
<table className="w-full max-w-2xl text-sm">
|
||
<thead className="text-left text-zinc-500">
|
||
<tr>
|
||
<th className="py-1">ID</th>
|
||
<th>用户名</th>
|
||
<th>角色</th>
|
||
<th>创建时间</th>
|
||
<th />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(usersQ.data ?? []).map((x) => (
|
||
<tr key={x.id} className="border-t border-zinc-800">
|
||
<td className="py-1 text-zinc-500">{x.id}</td>
|
||
<td>{x.username}</td>
|
||
<td>{x.role}</td>
|
||
<td className="text-zinc-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
|
||
<td className="py-1 text-right">
|
||
<button
|
||
className={btn + " text-red-300"}
|
||
disabled={x.id === me?.id}
|
||
title={x.id === me?.id ? "不能删除自己" : undefined}
|
||
onClick={() => {
|
||
if (confirm(`删除用户 ${x.username}?`)) del.mutate(x.id);
|
||
}}
|
||
>
|
||
删除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 实现 `src/pages/admin/Libraries.tsx`**
|
||
|
||
```tsx
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useRef, useState, type FormEvent } from "react";
|
||
import { api } from "../../api/client";
|
||
import { TopBar } from "../../components/TopBar";
|
||
import { toast } from "../../components/Toaster";
|
||
import { btn, btnPrimary, card, input } from "../../components/ui";
|
||
|
||
export default function AdminLibraries() {
|
||
const qc = useQueryClient();
|
||
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||
const [name, setName] = useState("");
|
||
const [root, setRoot] = useState("");
|
||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||
|
||
const create = useMutation({
|
||
mutationFn: () => api.createLibrary(name.trim(), root.trim()),
|
||
onSuccess: (lib) => {
|
||
toast("ok", `库「${lib.name}」已创建`);
|
||
setName("");
|
||
setRoot("");
|
||
void qc.invalidateQueries({ queryKey: ["libraries"] });
|
||
},
|
||
onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"),
|
||
});
|
||
const scan = useMutation({
|
||
mutationFn: (id: number) => api.scanLibrary(id),
|
||
onSuccess: () => {
|
||
toast("ok", "扫描已触发");
|
||
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 3000);
|
||
},
|
||
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
|
||
});
|
||
|
||
async function onFiles(libId: number, list: FileList | null) {
|
||
if (!list?.length) return;
|
||
try {
|
||
for (const f of Array.from(list)) await api.uploadBook(libId, f); // 串行,单个失败即止并报错
|
||
toast("ok", `已上传 ${list.length} 个文件,等待 scanner 收编`);
|
||
void qc.invalidateQueries({ queryKey: ["books"] });
|
||
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
|
||
} catch (e) {
|
||
toast("err", e instanceof Error ? e.message : "上传失败");
|
||
}
|
||
}
|
||
|
||
function submit(e: FormEvent) {
|
||
e.preventDefault();
|
||
create.mutate();
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<TopBar />
|
||
<div className="flex-1 overflow-y-auto p-4">
|
||
<h1 className="mb-4 text-lg font-semibold">库管理</h1>
|
||
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
|
||
<input className={input} placeholder="库名(如 comics)" value={name} onChange={(e) => setName(e.target.value)} />
|
||
<input
|
||
className={input + " flex-1"}
|
||
placeholder="root_path(服务端绝对路径,须位于 BOOKS_DIR 下,如 /data/books/comics)"
|
||
value={root}
|
||
onChange={(e) => setRoot(e.target.value)}
|
||
/>
|
||
<button className={btnPrimary} disabled={create.isPending || !name.trim() || !root.startsWith("/")}>
|
||
{create.isPending ? "创建中…" : "建库"}
|
||
</button>
|
||
</form>
|
||
<div className="flex max-w-2xl flex-col gap-4">
|
||
{(libsQ.data ?? []).map((l) => (
|
||
<div key={l.id} className={`${card} p-4`}>
|
||
<div className="mb-1 flex items-baseline gap-3">
|
||
<span className="font-medium">{l.name}</span>
|
||
<span className="text-xs text-zinc-500">#{l.id}</span>
|
||
</div>
|
||
<p className="mb-3 text-xs break-all text-zinc-500">{l.root_path}</p>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
|
||
{scan.isPending ? "扫描中…" : "扫描"}
|
||
</button>
|
||
<input
|
||
ref={(el) => {
|
||
fileRefs.current[l.id] = el;
|
||
}}
|
||
type="file"
|
||
multiple
|
||
accept=".cbz,.pdf,.epub,.txt,.md"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
void onFiles(l.id, e.target.files);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
<button className={btn} onClick={() => fileRefs.current[l.id]?.click()}>
|
||
上传文件
|
||
</button>
|
||
<span className="text-xs text-zinc-600">白名单:cbz/pdf/epub/txt/md</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
|
||
<p className="text-sm text-zinc-500">还没有库,用上方表单创建。</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 替换 `src/App.tsx`(补 admin 路由)**
|
||
|
||
```tsx
|
||
import { Navigate, Route, Routes } from "react-router-dom";
|
||
import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext";
|
||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||
import { Toaster } from "./components/Toaster";
|
||
import Login from "./pages/Login";
|
||
import Reader from "./pages/Reader";
|
||
import Shelf from "./pages/Shelf";
|
||
import AdminLibraries from "./pages/admin/Libraries";
|
||
import AdminUsers from "./pages/admin/Users";
|
||
|
||
export default function App() {
|
||
return (
|
||
<ErrorBoundary>
|
||
<AuthProvider>
|
||
<Routes>
|
||
<Route path="/login" element={<Login />} />
|
||
<Route
|
||
path="/"
|
||
element={
|
||
<RequireAuth>
|
||
<Shelf />
|
||
</RequireAuth>
|
||
}
|
||
/>
|
||
<Route
|
||
path="/book/:id"
|
||
element={
|
||
<RequireAuth>
|
||
<Reader />
|
||
</RequireAuth>
|
||
}
|
||
/>
|
||
<Route
|
||
path="/admin/users"
|
||
element={
|
||
<RequireAuth>
|
||
<RequireAdmin>
|
||
<AdminUsers />
|
||
</RequireAdmin>
|
||
</RequireAuth>
|
||
}
|
||
/>
|
||
<Route
|
||
path="/admin/libraries"
|
||
element={
|
||
<RequireAuth>
|
||
<RequireAdmin>
|
||
<AdminLibraries />
|
||
</RequireAdmin>
|
||
</RequireAuth>
|
||
}
|
||
/>
|
||
<Route path="*" element={<Navigate to="/" replace />} />
|
||
</Routes>
|
||
<Toaster />
|
||
</AuthProvider>
|
||
</ErrorBoundary>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 门槛**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: 全绿。人工(可选):member 登录看不到 admin 链接,直敲 /admin/users 被弹回 `/`。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web && git commit -m "feat(web): admin users CRUD + libraries create/scan/upload"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: PWA(Service Worker)+ Docker 前端落地 + 全栈冒烟 + README
|
||
|
||
**Files:**
|
||
- Modify: `web/vite.config.ts`(整文件替换)、`web/tsconfig.json`(types 数组)、`web/src/main.tsx`(加 SW 注册)、`web/src/auth/AuthContext.tsx`(logout 清 SW 缓存)、`deploy/Dockerfile.web`(整文件替换)、`.dockerignore`(追加)、`README.md`(追加前端章节)
|
||
- Create: `web/src/pwa.ts`、`scripts/smoke-web.sh`
|
||
- Delete: `deploy/web-dist/`(占位页完成使命)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1-11 的全部产物;`web/dist`(vite build)
|
||
- Produces: 生产镜像 = SPA + nginx(`/api/` 反代);SW:不可变资源 cache-first、其余 /api network-first(spec §6.3)
|
||
|
||
- [ ] **Step 1: 替换 `web/vite.config.ts`**
|
||
|
||
```ts
|
||
import react from "@vitejs/plugin-react";
|
||
import { VitePWA } from "vite-plugin-pwa";
|
||
import tailwindcss from "@tailwindcss/vite";
|
||
import { defineConfig } from "vite";
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
react(),
|
||
tailwindcss(),
|
||
VitePWA({
|
||
registerType: "autoUpdate",
|
||
includeAssets: ["icon.svg"],
|
||
manifest: {
|
||
name: "BookLib 个人书库",
|
||
short_name: "BookLib",
|
||
description: "个人书库/漫画库在线阅读",
|
||
theme_color: "#09090b",
|
||
background_color: "#09090b",
|
||
display: "standalone",
|
||
icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }],
|
||
},
|
||
workbox: {
|
||
globPatterns: ["**/*.{js,css,html,svg,woff2}"],
|
||
navigateFallback: "/index.html",
|
||
runtimeCaching: [
|
||
{
|
||
// ?v={hash} 的封面/CBZ 页/原文件 = 不可变 → cache-first(spec §6.1/§6.3)
|
||
urlPattern: ({ url }) =>
|
||
url.pathname.startsWith("/api/books/") &&
|
||
url.search.includes("v=") &&
|
||
(/\/cover$/.test(url.pathname) || /\/pages\/\d+$/.test(url.pathname) || /\/file$/.test(url.pathname)),
|
||
handler: "CacheFirst",
|
||
options: { cacheName: "booklib-immutable", expiration: { maxEntries: 3000, purgeOnQuotaError: true } },
|
||
},
|
||
{
|
||
// 普通 API 请求 network-first,离线可读最近一次列表/进度
|
||
urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
|
||
handler: "NetworkFirst",
|
||
options: {
|
||
cacheName: "booklib-api",
|
||
networkTimeoutSeconds: 5,
|
||
expiration: { maxAgeSeconds: 3600, maxEntries: 200 },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
}),
|
||
],
|
||
server: { proxy: { "/api": "http://localhost:8080" } },
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: `web/tsconfig.json` 的 `"types"` 行替换**
|
||
|
||
```json
|
||
"types": ["vite/client", "vite-plugin-pwa/client"],
|
||
```
|
||
|
||
- [ ] **Step 3: 新建 `web/src/pwa.ts` 并在 `main.tsx` 引入**
|
||
|
||
`src/pwa.ts`:
|
||
|
||
```ts
|
||
import { registerSW } from "virtual:pwa-register";
|
||
|
||
// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗
|
||
registerSW({ immediate: true });
|
||
```
|
||
|
||
`src/main.tsx` 在 `import "./index.css";` 之后加一行:
|
||
|
||
```ts
|
||
import "./pwa";
|
||
```
|
||
|
||
- [ ] **Step 4: `src/auth/AuthContext.tsx` logout 加清 SW 缓存(同设备多账号隔离)**
|
||
|
||
`logout` 实现整块替换为:
|
||
|
||
```ts
|
||
logout: () => {
|
||
setToken(null);
|
||
setTok(null);
|
||
qc.clear();
|
||
if (typeof caches !== "undefined") {
|
||
void caches.delete("booklib-api");
|
||
void caches.delete("booklib-immutable");
|
||
}
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 5: 构建验证 SW 产物**
|
||
|
||
Run: `cd web && npm run check && ls dist | grep -E 'sw\.js|workbox|manifest'`
|
||
Expected: check 全绿;列出 `sw.js`、`workbox-*.js`、`manifest.webmanifest`。
|
||
|
||
- [ ] **Step 6: 替换 `deploy/Dockerfile.web`、删除占位、补 .dockerignore**
|
||
|
||
```
|
||
FROM node:22-alpine AS build
|
||
WORKDIR /src
|
||
COPY web/package.json web/package-lock.json ./
|
||
RUN npm ci
|
||
COPY web/ ./
|
||
RUN npm run build
|
||
|
||
FROM nginx:1.27-alpine
|
||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||
COPY --from=build /src/dist /usr/share/nginx/html
|
||
```
|
||
|
||
```bash
|
||
git rm -r deploy/web-dist
|
||
```
|
||
|
||
`.dockerignore` 末尾追加:
|
||
|
||
```
|
||
web/node_modules
|
||
web/dist
|
||
web/dev-dist
|
||
```
|
||
|
||
- [ ] **Step 7: 写全栈冒烟 `scripts/smoke-web.sh`**
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# 全栈部署冒烟:经 nginx 验证 SPA 静态、/api 反代、登录、SW/manifest。
|
||
# 前提:.env 里有 JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD;宿主 :8080 空闲。
|
||
set -euo pipefail
|
||
BASE=${BASE:-http://localhost:8080}
|
||
[ -f .env ] && set -a && . ./.env && set +a
|
||
: "${JWT_SECRET:?JWT_SECRET 未设置}"; : "${ADMIN_USER:?ADMIN_USER 未设置}"; : "${ADMIN_PASSWORD:?ADMIN_PASSWORD 未设置}"
|
||
|
||
say(){ echo "smoke-web: $1"; }
|
||
die(){ echo "SMOKE-WEB FAIL: $1"; exit 1; }
|
||
|
||
say "build web + api images"
|
||
docker compose build web api
|
||
|
||
say "up --wait"
|
||
docker compose up -d --wait postgres redis api web
|
||
|
||
say "SPA index served by nginx"
|
||
INDEX=$(curl -fsS "$BASE/")
|
||
echo "$INDEX" | grep -q 'id="root"' || die "index 缺 #root"
|
||
ASSET=$(echo "$INDEX" | sed -nE 's#.*src="(/assets/[^"]+\.js)".*#\1#p' | head -1)
|
||
[ -n "$ASSET" ] || die "index 未引用 /assets/*.js"
|
||
curl -fsS -o /dev/null "$BASE$ASSET" || die "asset $ASSET 拉取失败"
|
||
|
||
say "SPA deep link falls back to index"
|
||
curl -fsS "$BASE/admin/users" | grep -q 'id="root"' || die "try_files 回退失败"
|
||
|
||
say "api via /api/ proxy"
|
||
curl -fsS "$BASE/api/healthz" >/dev/null || die "healthz via nginx"
|
||
TOK=$(curl -fsS "$BASE/api/auth/login" -H 'content-type: application/json' \
|
||
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASSWORD\"}" \
|
||
| sed -E 's/.*"token":"([^"]+)".*/\1/')
|
||
[ -n "$TOK" ] || die "nginx 反代登录失败"
|
||
curl -fsS "$BASE/api/auth/me" -H "authorization: Bearer $TOK" | grep -q "$ADMIN_USER" || die "me via nginx"
|
||
|
||
say "pwa artifacts"
|
||
curl -fsS "$BASE/sw.js" >/dev/null || die "sw.js 缺失"
|
||
curl -fsS "$BASE/manifest.webmanifest" >/dev/null || die "manifest 缺失"
|
||
|
||
say "ALL WEB SMOKE PASSED"
|
||
```
|
||
|
||
- [ ] **Step 8: 跑冒烟**
|
||
|
||
```bash
|
||
chmod +x scripts/smoke-web.sh
|
||
docker compose down 2>/dev/null || true
|
||
bash scripts/smoke-web.sh
|
||
docker compose down
|
||
```
|
||
|
||
Expected: `ALL WEB SMOKE PASSED`(compose 栈与 Plan 1 `scripts/smoke.sh` 共用,若后端栈在跑先 down)。
|
||
|
||
- [ ] **Step 9: README 追加前端章节**
|
||
|
||
在 `README.md` 末尾追加(若已有相关章节则合并去重,不重复端口说明):
|
||
|
||
```markdown
|
||
## 前端开发(web/)
|
||
|
||
- `cd web && npm install`;`npm run dev`(:5173,`/api` 代理到 :8080)。
|
||
- 后端起法:`docker compose -f deploy/docker-compose.dev.yml up -d --wait` 起 PG(:5433)/Redis(:6380),
|
||
然后 `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \
|
||
REDIS_URL=redis://localhost:6380 JWT_SECRET=dev go run ./cmd/server`。
|
||
- 门槛:`npm run check`(tsc + vitest + vite build)。
|
||
|
||
## 生产部署(含前端)
|
||
|
||
- `.env` 配 `JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD` 后 `docker compose up -d --build`;
|
||
打开 http://localhost:8080(web=SPA+nginx,/api 反代 api:8080)。
|
||
- PWA:不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
|
||
- 冒烟:`bash scripts/smoke.sh`(后端直连)、`bash scripts/smoke-web.sh`(经 nginx 全栈,需 :8080 空闲)。
|
||
```
|
||
|
||
- [ ] **Step 10: 最终门槛 + Commit**
|
||
|
||
```bash
|
||
cd web && npm run check && cd ..
|
||
git add web deploy .dockerignore scripts/smoke-web.sh README.md
|
||
git commit -m "feat(web): pwa offline caching, dockerized SPA, full-stack nginx smoke"
|
||
```
|
||
|
||
---
|
||
|
||
## 完成后
|
||
|
||
全部 12 个任务完成且 `npm run check`、`scripts/smoke-web.sh` 双绿后:
|
||
**REQUIRED SUB-SKILL:** superpowers:finishing-a-development-branch(验证 → 选项 → 合并)。
|
||
|
||
建议合并前人工过一遍(spec §10 未要求 e2e 框架,以下靠肉眼):
|
||
1. admin 建库→上传 cbz→扫描→书架出封面;member 登录只见书架无删除按钮。
|
||
2. 四格式各开一本:翻页/翻篇后刷新同位置续上;关页秒保存。
|
||
3. 断网(F12 offline)重开:SPA 壳与读过的 CBZ 页可看。
|
||
|
||
## 执行修订记录(plan2-frontend 分支最终态)
|
||
|
||
执行期对上文计划文本的有效修订(评审/环境驱动,均已随代码落地):
|
||
|
||
- **sendBeacon → fetch keepalive**(写计划时已注):后端 Bearer-only,beacon 无法带 header,语义等价。
|
||
- **Task 5**:扫描按钮补 `isAdmin` 守卫(全局约束"写操作仅 admin 可见"优先于示例代码;已就地修订上文)。
|
||
- **Task 6/7**:Reader.tsx 的 `lazy` import 随首个 READERS 条目(Task 7)引入(`noUnusedLocals` 不允许空表时导入)。
|
||
- **Task 7**:`PageHeights.range` 返回 `[start, end)` 端点排除(brief 参考实现与 brief 测试矛盾,以测试为准;已就地修订上文)。
|
||
- **Task 9**:已装 pdfjs-dist 为 v6.3 — `page.render({ canvas, viewport })`(canvasContext 已废)、`destroy()` 移到 loading task;仅调用点适配。
|
||
- **Task 10/终评**:`display(cfi)` 失败是 promise reject 非同步 throw → 改为 promise 链回退无参 display 再 setErr;`setReady` 仅在 display 成功后。
|
||
- **Task 12**:新增 `deploy/entrypoint-resolver.sh` —— `deploy/nginx.conf` 恢复 spec §11 变量式 `proxy_pass`,`resolver` 地址启动时从 `/etc/resolv.conf` 首个 nameserver 注入(Docker=127.0.0.11 / podman=gateway 双运行时兼容;计划负责人裁决"保 spec 语义")。Dockerfile.web 的 nginx stage `COPY --chmod=755` 该脚本并覆写 ENTRYPOINT(exec "$@" 保留 CMD)。
|
||
- **终评分支修复波**(cc66a64):nginx 增加 CSP header(spec §7 的既定缓解);AuthContext 401 登出与显式 logout 统一 `clearSession()`(qc.clear + 双 SW 缓存删除);admin 密码框 `type=password`;SW 不可变匹配 `searchParams.has("v")`;CBZ 缺 pages_url 出错误占位。
|
||
- 冒烟脚本 `--wait` 在 podman-compose 下失败时回退 + healthz 轮询(仅脚本层,仓库文件未降级)。
|