Files
book-comic-library/docs/superpowers/plans/2026-09-16-reader-revamp.md

1296 lines
67 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阅读器改版(②)实现计划 / Reader revamp implementation plan
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 按 spec `docs/superpowers/specs/2026-09-16-reader-revamp-design.md` 落地六批次:B0 横切基建(prefs v2/主题联动/设置抽屉/统计层/tsc 修复)→ B1 CBZ(迁移+页模式+RTL)→ B2 Text(迁移+搜索+排版)→ B3 EPUB(迁移+排版)→ B4 PDF+统计卡 → B5 清理收严(删 ui.ts/icons.tsx/rd-*、eslint/axe 收严)。
**架构:** 全部改动限于 `frontend/` + `docs/`。纯逻辑抽 `lib/`(单测),chrome 换 shadcn token + radix 原语(新增 `ui/sheet|tabs|slider.tsx`),行为逻辑(章锁/连读/预读/书签/进度上报)原样保留。无后端改动、无 localStorage 兼容负担。
**技术栈:** React 19、radix-ui(已装)、shadcn 模式组件、vitest(unit=node / components=jsdom 双 project)、Playwright + axe(④ 基建)、epubjs themes、lucide-react。
**实施分支:** `feat/reader-revamp`(从 `docs/reader-revamp-spec` 建,spec 随实施分支合入)。**master 上不 commit。**
## 全局约束 / Global constraints
- npm 依赖只在 **web 容器内**装:`docker compose -f deploy/docker-compose.dev.yml exec web npm install ...`(宿主 docker 实为 podman,告警无害);lockfile 随批提交。`frontend/.npmrc` 已有 `legacy-peer-deps=true`,裸 `npm install` 即可。
- 每批验收底线:`npm run check` 绿 + `npm run e2e` 绿(dev 栈起、凭据 `set -a; source deploy/.env; set +a`,e2e 在容器内跑,命令模板见任务 10)。
- changelog:用户可见条目记 `docs/CHANGELOG_web.md`(`## [Unreleased]`,英中相邻两行、条目间空行、新条目在小节顶部),每批随批记。
- **零新运行时依赖**:radix-ui/cva/lucide/clsx/tailwind-merge 已装;`ui/sheet|tabs|slider` 手写(模式对齐现有 `ui/dialog.tsx`:`import { X as XPrimitive } from "radix-ui"`、`data-slot` 属性、`cn()`)。dev 依赖仅可能新增 `@types/node`(任务 1)。
- **行为保留红线**:翻页/翻屏、CBZ 章锁/连读/预读、书签 seek、进度上报、编码回退、分章/分卷逻辑不改语义;④ 的渲染期重置修复不回退。`strip` 模式的 CbzReader 滚动/量高/重锚代码(现 L115-299)**一行不动**地保留。
- 旧 localStorage 键 `cbz-continuous`/`cbz-prefetch` 废弃(不读不写不迁移);`reader-prefs` 换新结构宽容解析。
- e2e 选择器锚点若与 DOM 实况有出入,以实况修选择器、用例语义不变(④ 惯例)。
- prettier/eslint 对所有新文件生效;行级 disable 必须带中文理由注释。
## 文件结构 / File structure
**B0 创建/修改:**
- 修改 `frontend/tsconfig.json`(include 扩 e2e/*.config.ts)、`frontend/vitest.config.ts`(components include 收紧为 `src/**/*.test.{ts,tsx}`)
- 重写 `frontend/src/lib/readerPrefs.ts`(v2 结构)+ 新建 `frontend/test/readerPrefs.test.ts`
- 修改 `frontend/src/components/theme.tsx`(导出 `useGlobalDark`)
- 修改 `frontend/src/index.css`(--rd-* token 组扩展)
- 新建 `frontend/src/components/ui/sheet.tsx`、`ui/tabs.tsx`、`ui/slider.tsx`
- 新建 `frontend/src/components/reader-settings.tsx` + `frontend/src/components/reader-settings.test.tsx`
- 新建 `frontend/src/lib/readingStats.ts` + `frontend/test/readingStats.test.ts`
- 新建 `frontend/src/lib/useReadingSession.ts`
**B1:** 新建 `frontend/src/lib/cbzPages.ts` + `frontend/test/cbzPages.test.ts`;修改 `pages/Reader.tsx`、`components/reader-nav.tsx`、`components/Bookmarks.tsx`、`readers/CbzReader.tsx`(PageView 新渲染路径 + reader-settings 接线);删 `components/rd-slider.tsx`;e2e 扩展。
**B2:** 新建 `frontend/src/lib/search.ts` + `frontend/test/search.test.ts`、`e2e/fixtures/e2e-sample.txt`;修改 `readers/TextReader.tsx`、`components/reader-nav.tsx`(搜索 tab);e2e 搜索冒烟。
**B3:** 修改 `readers/EpubReader.tsx`。
**B4:** 修改 `readers/PdfReader.tsx`、`pages/Shelf.tsx`(统计卡)、`pages/Reader.tsx`(useReadingSession 接线);新建 `components/reading-stats-card.tsx` + 测试。
**B5:** 删 `components/ui.ts`、`components/icons.tsx`;新建 `components/format-badge.tsx`;修改 `index.css`(rd-* 组件类删除)、`eslint.config.js`(reader override 删除)、`e2e/helpers/axe.ts`(WAIVERS 清空)、Shelf/Reader 等使用点。
---
## B0 批次 0:横切基建
### 任务 1:tsc 盲区修复 + vitest include 收紧
**文件:**
- 修改:`frontend/tsconfig.json`、`frontend/vitest.config.ts`、`frontend/package.json`(devDep)
- [ ] **步骤 1.1:容器内装 `@types/node`**
```bash
docker compose -f deploy/docker-compose.dev.yml exec web npm install -D @types/node
```
- [ ] **步骤 1.2:`frontend/tsconfig.json` 两处修改**
`"include": ["src", "test"]` 改为 `"include": ["src", "test", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"]`;`"types": ["vite/client", "vite-plugin-pwa/client"]` 改为 `"types": ["vite/client", "vite-plugin-pwa/client", "node"]`。
- [ ] **步骤 1.3:`frontend/vitest.config.ts` components project 的 include 收紧**
`include: ["src/**/*.test.tsx"]` 改为 `include: ["src/**/*.test.{ts,tsx}"]`(消除 `src/**/*.test.ts` 静默缝隙——④ 账本 backlog 项),并同步更新文件顶部注释(「放 test/ 下的 .test.tsx 不会被捕获」的说明仍成立)。
- [ ] **步骤 1.4:验证**
```bash
docker compose -f deploy/docker-compose.dev.yml exec web npx tsc --noEmit
```
预期:0 error(e2e/config 首次纳入类型检查;若报错,修类型问题——`process.env` 等由 @types/node 解决;**不许**用 exclude 回避)。随后 `npm run check` 全绿。
- [ ] **步骤 1.5:Commit**
```bash
git add frontend/tsconfig.json frontend/vitest.config.ts frontend/package.json frontend/package-lock.json
git commit -m "chore(frontend): extend tsc coverage to e2e/configs, tighten vitest include (B0)"
```
### 任务 2:readerPrefs v2(TDD)
**文件:**
- 重写:`frontend/src/lib/readerPrefs.ts`
- 测试:`frontend/test/readerPrefs.test.ts`
- [ ] **步骤 2.1:先写失败的单测 `frontend/test/readerPrefs.test.ts`**
```ts
import { describe, expect, it } from "vitest";
import { DEFAULT_PREFS, parsePrefs, resolveRdTheme } from "../src/lib/readerPrefs";
describe("parsePrefs", () => {
it("returns defaults for null/garbage", () => {
expect(parsePrefs(null)).toEqual(DEFAULT_PREFS);
expect(parsePrefs("{oops")).toEqual(DEFAULT_PREFS);
});
it("merges partial legacy-free structures with defaults", () => {
const p = parsePrefs(JSON.stringify({ themeMode: "night", cbz: { mode: "page" } }));
expect(p.themeMode).toBe("night");
expect(p.cbz).toEqual({ ...DEFAULT_PREFS.cbz, mode: "page" });
expect(p.text).toEqual(DEFAULT_PREFS.text);
});
it("rejects out-of-range values back to defaults", () => {
const p = parsePrefs(
JSON.stringify({ text: { sizeIdx: 99, lineIdx: -1, margin: "huge" }, cbz: { prefetch: 7 }, epub: { sizeIdx: 2, lineIdx: 0, marginIdx: 9 } }),
);
expect(p.text).toEqual(DEFAULT_PREFS.text);
expect(p.cbz.prefetch).toBe(DEFAULT_PREFS.cbz.prefetch);
expect(p.epub.marginIdx).toBe(DEFAULT_PREFS.epub.marginIdx);
});
});
describe("resolveRdTheme", () => {
it("auto follows global dark/light", () => {
expect(resolveRdTheme("auto", true)).toBe("night");
expect(resolveRdTheme("auto", false)).toBe("paper");
});
it("manual overrides global", () => {
expect(resolveRdTheme("sepia", true)).toBe("sepia");
expect(resolveRdTheme("night", false)).toBe("night");
});
});
```
- [ ] **步骤 2.2:跑测试确认失败**
`docker compose -f deploy/docker-compose.dev.yml exec web npx vitest run readerPrefs` — 预期 FAIL(parsePrefs/resolveRdTheme/DEFAULT_PREFS 不存在)。
- [ ] **步骤 2.3:重写 `frontend/src/lib/readerPrefs.ts`**
```ts
import { useCallback, useEffect, useState } from "react";
export const RD_THEMES = ["paper", "sepia", "night"] as const;
export type RdTheme = (typeof RD_THEMES)[number];
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
export const FONT_SIZES = [16, 18, 20, 22, 25, 28];
export const LINE_HEIGHTS = [1.2, 1.4, 1.6, 1.8, 2.0];
export const MARGINS = ["narrow", "medium", "wide"] as const;
export type Margin = (typeof MARGINS)[number];
export const MARGIN_LABEL: Record<Margin, string> = { narrow: "窄", medium: "中", wide: "宽" };
/** 正文限宽档位:窄=宽度量、宽=窄度量(阅读习惯:边距越大行宽越窄) */
export const MARGIN_MAXW: Record<Margin, string> = { narrow: "42em", medium: "34em", wide: "26em" };
export const CBZ_MODES = ["strip", "page", "spread"] as const;
export type CbzMode = (typeof CBZ_MODES)[number];
export const CBZ_MODE_LABEL: Record<CbzMode, string> = { strip: "长卷", page: "单页", spread: "双页" };
const KEY = "reader-prefs";
export interface ReaderPrefs {
themeMode: "auto" | RdTheme;
text: { sizeIdx: number; lineIdx: number; margin: Margin };
cbz: { mode: CbzMode; rtl: boolean; continuous: boolean; prefetch: number };
epub: { sizeIdx: number; lineIdx: number; marginIdx: number };
}
export const DEFAULT_PREFS: ReaderPrefs = {
themeMode: "auto",
text: { sizeIdx: 2, lineIdx: 2, margin: "medium" },
cbz: { mode: "strip", rtl: false, continuous: false, prefetch: 2 },
epub: { sizeIdx: 2, lineIdx: 2, marginIdx: 1 },
};
function idx(v: unknown, len: number, def: number): number {
return Number.isInteger(v) && (v as number) >= 0 && (v as number) < len ? (v as number) : def;
}
/** 宽容解析:逐字段校验,缺失/坏值回默认(新结构,无旧键迁移——spec 决策)。 */
export function parsePrefs(raw: string | null): ReaderPrefs {
let o: Record<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(raw ?? "");
if (parsed && typeof parsed === "object") o = parsed as Record<string, unknown>;
} catch {
/* 首次使用或坏值 */
}
const t = (o.text ?? {}) as Record<string, unknown>;
const c = (o.cbz ?? {}) as Record<string, unknown>;
const e = (o.epub ?? {}) as Record<string, unknown>;
const mode = o.themeMode;
return {
themeMode: mode === "auto" || RD_THEMES.includes(mode as RdTheme) ? (mode as ReaderPrefs["themeMode"]) : DEFAULT_PREFS.themeMode,
text: {
sizeIdx: idx(t.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.text.sizeIdx),
lineIdx: idx(t.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.text.lineIdx),
margin: MARGINS.includes(t.margin as Margin) ? (t.margin as Margin) : DEFAULT_PREFS.text.margin,
},
cbz: {
mode: CBZ_MODES.includes(c.mode as CbzMode) ? (c.mode as CbzMode) : DEFAULT_PREFS.cbz.mode,
rtl: typeof c.rtl === "boolean" ? c.rtl : DEFAULT_PREFS.cbz.rtl,
continuous: typeof c.continuous === "boolean" ? c.continuous : DEFAULT_PREFS.cbz.continuous,
prefetch: Number.isInteger(c.prefetch) && (c.prefetch as number) >= 0 && (c.prefetch as number) <= 3 ? (c.prefetch as number) : DEFAULT_PREFS.cbz.prefetch,
},
epub: {
sizeIdx: idx(e.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.epub.sizeIdx),
lineIdx: idx(e.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.epub.lineIdx),
marginIdx: idx(e.marginIdx, MARGINS.length, DEFAULT_PREFS.epub.marginIdx),
},
};
}
/** auto = 全局 dark→夜、light→纸;手动值直通(spec S1 联动规则)。 */
export function resolveRdTheme(mode: ReaderPrefs["themeMode"], globalDark: boolean): RdTheme {
if (mode !== "auto") return mode;
return globalDark ? "night" : "paper";
}
export function useReaderPrefs() {
const [prefs, setPrefs] = useState<ReaderPrefs>(() => parsePrefs(localStorage.getItem(KEY)));
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
const update = useCallback((fn: (p: ReaderPrefs) => ReaderPrefs) => setPrefs(fn), []);
return {
prefs,
update,
fontSize: FONT_SIZES[prefs.text.sizeIdx],
lineHeight: LINE_HEIGHTS[prefs.text.lineIdx],
marginMaxW: MARGIN_MAXW[prefs.text.margin],
epubFontSize: FONT_SIZES[prefs.epub.sizeIdx],
setThemeMode: (themeMode: ReaderPrefs["themeMode"]) => update((p) => ({ ...p, themeMode })),
bumpText: (d: number) =>
update((p) => ({ ...p, text: { ...p.text, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.text.sizeIdx + d)) } })),
bumpEpub: (d: number) =>
update((p) => ({ ...p, epub: { ...p.epub, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.epub.sizeIdx + d)) } })),
};
}
export type ReaderPrefsApi = ReturnType<typeof useReaderPrefs>;
```
**注意**:现有消费点(TextReader)在本任务后会暂时类型不匹配(`pr.prefs.theme`/`pr.setTheme`/`pr.bump` 不存在)——**预期红**,B2 任务 12 迁移 TextReader 时修复。为保持每任务 check 绿,本任务同步做**最小适配**:TextReader 内 `pr.prefs.theme` → `resolveRdTheme(pr.prefs.themeMode, false)`(临时,B2 换成 useGlobalDark 版)、`pr.setTheme(t)` → `pr.setThemeMode(t)`、`pr.bump(d)` → `pr.bumpText(d)`;仅改这三个调用点,不做其他迁移。
- [ ] **步骤 2.4:跑测试确认通过 + check 全绿**
`npx vitest run readerPrefs` → PASS;`npm run check` → exit 0。
- [ ] **步骤 2.5:Commit**
```bash
git add frontend/src/lib/readerPrefs.ts frontend/test/readerPrefs.test.ts frontend/src/readers/TextReader.tsx
git commit -m "feat(frontend): readerPrefs v2 unified structure (TDD)"
```
### 任务 3:主题 token 扩展 + useGlobalDark
**文件:**
- 修改:`frontend/src/index.css`、`frontend/src/components/theme.tsx`
- [ ] **步骤 3.1:`index.css` 三个 `[data-rd=...]` 块各补三个 token**
在现有 `--rd-bg/--rd-fg/--rd-link` 之后(值与既有色板协调,chroma 略降):
```css
[data-rd="paper"] {
/* 既有 3 行不动 */
--rd-muted: #8a8378;
--rd-accent: #b45309;
--rd-border: #e4ded2;
}
[data-rd="sepia"] {
--rd-muted: #857a63;
--rd-accent: #92400e;
--rd-border: #ddd0b4;
}
[data-rd="night"] {
--rd-muted: #8b8579;
--rd-accent: #e0a458;
--rd-border: #322e27;
}
```
- [ ] **步骤 3.2:`theme.tsx` 追加导出 `useGlobalDark`(文件末尾)**
```tsx
const MQ = "(prefers-color-scheme: dark)";
/** 全局主题的有效暗色态:dark→true;system→跟随媒体查询;light→false。供阅读主题 auto 联动。 */
export function useGlobalDark(): boolean {
const { theme } = useTheme();
const [sysDark, setSysDark] = useState(() => typeof matchMedia === "function" && matchMedia(MQ).matches);
useEffect(() => {
if (theme !== "system") return;
const mq = matchMedia(MQ);
const on = () => setSysDark(mq.matches);
mq.addEventListener("change", on);
return () => mq.removeEventListener("change", on);
}, [theme]);
return theme === "dark" || (theme === "system" && sysDark);
}
```
(`useState/useEffect` 已在该文件 import。)
- [ ] **步骤 3.3:验证 + Commit**
`npm run check` 绿(本任务无新消费点,编译即验证)。
```bash
git add frontend/src/index.css frontend/src/components/theme.tsx
git commit -m "feat(frontend): reading theme token group + useGlobalDark for auto linkage (B0)"
```
### 任务 4:ui/sheet + ui/tabs + ui/slider
**文件:**
- 创建:`frontend/src/components/ui/sheet.tsx`、`ui/tabs.tsx`、`ui/slider.tsx`
- [ ] **步骤 4.1:手写三个 shadcn 原语(模式对齐 `ui/dialog.tsx`:`radix-ui` 单包导入、`data-slot`、`cn()`)**
`sheet.tsx`——Sheet = Dialog 原语 + side 变体(cva),导出 `Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription`;`SheetContent` 带 `side?: "top"|"right"|"bottom"|"left"`(默认 right),各 side 的 fixed 定位/滑入动画 class 用 shadcn 标准写法(`data-[state=open]:slide-in-from-left` 等,tailwind v4 + tw-animate-css 已装);含 Overlay(`bg-black/50`)与右上角关闭钮(side=right/top 时)。
`tabs.tsx`——Tabs 原语封装,导出 `Tabs, TabsList, TabsTrigger, TabsContent`;TabsList `bg-muted rounded-lg p-1`,TabsTrigger `data-[state=active]:bg-background data-[state=active]:text-foreground` 标准样式。
`slider.tsx`——Slider 原语封装,导出 `Slider`(props 透传 `React.ComponentProps<typeof SliderPrimitive.Root>`);标准结构:Track(`bg-muted h-1.5 rounded-full`)+ Range(`bg-primary`)+ Thumb(`border-primary/50 bg-background size-4 rounded-full shadow`,`block` 类补 focus-visible 环)。多 thumb 支持按 `value` 数组长度渲染 Thumb(shadcn 标准 `Array.from({length: ...})` 写法)。
三个文件全部带 `data-slot` 属性、无 `any`、prettier 干净。**验收标准**(审查用):`SheetContent side="left"` 能作为导航抽屉容器;`Slider` 受控 `value={[n]}` + `onValueChange`;`Tabs` 受控 `value` + `onValueChange`。
- [ ] **步骤 4.2:验证 + Commit**
`npm run check` 绿。
```bash
git add frontend/src/components/ui/sheet.tsx frontend/src/components/ui/tabs.tsx frontend/src/components/ui/slider.tsx
git commit -m "feat(frontend): shadcn sheet/tabs/slider primitives (B0)"
```
### 任务 5:reader-settings 统一设置抽屉(TDD 组件测试)
**文件:**
- 创建:`frontend/src/components/reader-settings.tsx`、`frontend/src/components/reader-settings.test.tsx`
- [ ] **步骤 5.1:组件 API 设计(先写进文件头注释再实现)**
```tsx
export interface ReaderSettingsProps {
/** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数 */
slider: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string };
/** 位置文本,如 "3/12 · 25%" */
position?: string;
/** 左半区按钮(导航/上一章/下一章等),调用方给 */
left?: ReactNode;
/** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */
showFont?: boolean;
/** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */
extra?: ReactNode;
}
```
内部:`useReaderPrefs()` + `useGlobalDark()` + `resolveRdTheme` 得当前有效主题;常驻行 = Slider + position + left 区 + 主题卡(三枚色卡按钮,aria-label `${RD_THEME_LABEL[t]}色主题`,`aria-pressed` = 当前手动值;auto 态时额外一枚「跟随全局」按钮 aria-pressed=true,点色卡即转手动,点「跟随」回 auto)+ showFont 时 A−/A+(aria-label 减小字号/加大字号,复用 ④ 前的文案)+ extra 存在时「更多设置」展开钮(aria-label="更多阅读设置",aria-expanded,ChevronUp/Down 图标)。展开区渲染 extra。容器样式:shadcn token(`border-t bg-background/95 text-foreground backdrop-blur`)+ safe-area padding(沿用 `pb-[max(0.5rem,env(safe-area-inset-bottom))]`)。
**注意**:本组件是普通底栏(Text 的 grid 行)与覆盖层(CBZ 的 absolute)两用——定位类由调用方包一层 div 决定,组件本身不含 absolute/fixed。
- [ ] **步骤 5.2:组件测试 `reader-settings.test.tsx`(测试需在 ThemeProvider 内渲染)**
覆盖:① 常驻行渲染(滑条 aria-label、位置文本);② 主题卡点击→localStorage `reader-prefs` 的 themeMode 变手动值;③「跟随全局」按钮存在且点击回 auto;④ showFont=false 时无 A−/A+;⑤「更多设置」展开/收起(aria-expanded 翻转、extra 内容可见性);⑥ A+ 点击→text.sizeIdx 增加。jsdom 无 matchMedia 时用 ④ 建好的 stub(setup.ts 已配)。
- [ ] **步骤 5.3:验证 + Commit**
`npx vitest run reader-settings` 绿 → `npm run check` 绿 →
```bash
git add frontend/src/components/reader-settings.tsx frontend/src/components/reader-settings.test.tsx
git commit -m "feat(frontend): unified reader settings bar with theme linkage (B0)"
```
### 任务 6:readingStats 统计层(TDD)+ useReadingSession
**文件:**
- 创建:`frontend/src/lib/readingStats.ts`、`frontend/test/readingStats.test.ts`、`frontend/src/lib/useReadingSession.ts`
- [ ] **步骤 6.1:先写失败单测 `frontend/test/readingStats.test.ts`**
覆盖纯函数:`dayKey`(本地时区 YYYY-MM-DD)、`weeklySum`(近 7 天含今天的按日数组与合计、缺日补 0)、`streakOf`(今天有记录→连续天数;今天无但昨天有→连续天数;断档即止;空→0)、`recordInto`(不可变累加)。
- [ ] **步骤 6.2:实现 `frontend/src/lib/readingStats.ts`**
```ts
export interface DayBucket { day: string; seconds: number }
export interface StatsStore {
record(seconds: number): void;
weekly(): { perDay: DayBucket[]; total: number };
streak(): number;
}
const KEY = "reading-stats";
export type Buckets = Record<string, number>;
export function dayKey(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
export function recordInto(b: Buckets, day: string, seconds: number): Buckets {
return { ...b, [day]: Math.max(0, Math.round((b[day] ?? 0) + seconds)) };
}
export function weeklySum(b: Buckets, today: Date): { perDay: DayBucket[]; total: number } {
const perDay: DayBucket[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const day = dayKey(d);
perDay.push({ day, seconds: b[day] ?? 0 });
}
return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) };
}
export function streakOf(b: Buckets, today: Date): number {
let n = 0;
const d = new Date(today);
if (!(b[dayKey(d)] > 0)) d.setDate(d.getDate() - 1); // 今天还没读不算断
while (b[dayKey(d)] > 0) {
n++;
d.setDate(d.getDate() - 1);
}
return n;
}
function load(): Buckets {
try {
const o: unknown = JSON.parse(localStorage.getItem(KEY) ?? "{}");
return o && typeof o === "object" ? (o as Buckets) : {};
} catch {
return {};
}
}
/** localStorage 实现;③ 可换服务端实现(spec:可替换接口)。 */
export function createLocalStats(): StatsStore {
return {
record(seconds) {
if (seconds <= 0) return;
localStorage.setItem(KEY, JSON.stringify(recordInto(load(), dayKey(new Date()), seconds)));
},
weekly() {
return weeklySum(load(), new Date());
},
streak() {
return streakOf(load(), new Date());
},
};
}
```
- [ ] **步骤 6.3:`frontend/src/lib/useReadingSession.ts`**
```ts
import { useEffect, useRef } from "react";
import { createLocalStats, type StatsStore } from "./readingStats";
const TICK = 30; // 秒:可见期间心跳粒度
/** Reader 页挂一次:可见期间每 30s 落一笔,隐藏/卸载即 flush。 */
export function useReadingSession(store: StatsStore = createLocalStats()) {
const acc = useRef(0);
const last = useRef(Date.now());
useEffect(() => {
const flush = () => {
const now = Date.now();
if (document.visibilityState === "visible") acc.current += (now - last.current) / 1000;
last.current = now;
if (acc.current >= 1) {
store.record(acc.current);
acc.current = 0;
}
};
const t = setInterval(flush, TICK * 1000);
const onVis = () => flush();
document.addEventListener("visibilitychange", onVis);
globalThis.addEventListener?.("pagehide", onVis);
return () => {
clearInterval(t);
document.removeEventListener("visibilitychange", onVis);
globalThis.removeEventListener?.("pagehide", onVis);
flush();
};
}, [store]);
}
```
(store 默认参数每次渲染新建会重跑 effect——调用方在模块级或 useMemo 传入单例;Reader.tsx 接线时 `const stats = useMemo(() => createLocalStats(), [])`。)
- [ ] **步骤 6.4:验证 + Commit**
`npx vitest run readingStats` 绿 → `npm run check` 绿 →
```bash
git add frontend/src/lib/readingStats.ts frontend/test/readingStats.test.ts frontend/src/lib/useReadingSession.ts
git commit -m "feat(frontend): reading stats store (TDD) + session heartbeat hook (B0)"
```
**B0 changelog**(任务 6 commit 时一并写入 `docs/CHANGELOG_web.md` Added 顶部,双语):
```markdown
- Reading preferences are unified into a single store with a new "follow global theme" mode (纸/米/夜 auto-follows light/dark until manually overridden); a reading-time tracker lays the groundwork for the shelf stats card.
- 阅读偏好统一为单一存储,新增「跟随全局主题」模式(纸/米/夜随 light/dark 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。
```
---
## B1 批次 1:CBZ(迁移 + 页模式 + RTL)
### 任务 7:cbzPages 纯函数(TDD)
**文件:**
- 创建:`frontend/src/lib/cbzPages.ts`、`frontend/test/cbzPages.test.ts`
- [ ] **步骤 7.1:先写失败单测**,覆盖:
```ts
import { describe, expect, it } from "vitest";
import { spreadPages, pageIndexToView, turnView, viewCount, viewToPageIndex } from "../src/lib/cbzPages";
describe("cbzPages", () => {
it("page mode: one page per view", () => {
expect(viewCount(5, "page")).toBe(5);
expect(viewToPageIndex(2, "page")).toBe(2);
expect(pageIndexToView(2, "page")).toBe(2);
});
it("spread mode: pairs [0,1] [2,3] [4]", () => {
expect(viewCount(5, "spread")).toBe(3);
expect(pageIndexToView(3, "spread")).toBe(1);
expect(viewToPageIndex(1, "spread")).toBe(2);
expect(spreadPages(2, 5, false)).toEqual([2, 3]);
expect(spreadPages(4, 5, false)).toEqual([4]); // 落单页
expect(spreadPages(2, 5, true)).toEqual([3, 2]); // RTL 右页为先
});
it("turnView clamps and RTL flips direction", () => {
expect(turnView(0, 1, false, 3)).toBe(1);
expect(turnView(2, 1, false, 3)).toBe(2); // 右边界钳制
expect(turnView(0, -1, false, 3)).toBe(0); // 左边界钳制
expect(turnView(1, 1, true, 3)).toBe(0); // RTL:d=+1 变后退
});
});
```
- [ ] **步骤 7.2:实现 `frontend/src/lib/cbzPages.ts`**
```ts
export type PageMode = "page" | "spread";
export function viewCount(count: number, mode: PageMode): number {
return mode === "page" ? count : Math.ceil(count / 2);
}
export function pageIndexToView(i: number, mode: PageMode): number {
return mode === "page" ? i : Math.floor(i / 2);
}
export function viewToPageIndex(v: number, mode: PageMode): number {
return mode === "page" ? v : v * 2;
}
/** 一个 spread 视图要渲染的页,按显示顺序(LTR=[左,右];RTL=[右,左] 即先读页在前)。 */
export function spreadPages(first: number, count: number, rtl: boolean): number[] {
const pages = [first, ...(first + 1 < count ? [first + 1] : [])];
return rtl ? [...pages].reverse() : pages;
}
/** 翻一屏/页;rtl 时方向翻转;越界钳制。 */
export function turnView(v: number, d: number, rtl: boolean, views: number): number {
const step = rtl ? -d : d;
return Math.max(0, Math.min(views - 1, v + step));
}
```
- [ ] **步骤 7.3:绿 → Commit**
```bash
git add frontend/src/lib/cbzPages.ts frontend/test/cbzPages.test.ts
git commit -m "feat(frontend): cbz page/spread view math (TDD)"
```
### 任务 8:共享 chrome 迁移(Reader 头栏 / reader-nav / Bookmarks)
**文件:**
- 修改:`frontend/src/pages/Reader.tsx`、`frontend/src/components/reader-nav.tsx`、`frontend/src/components/Bookmarks.tsx`
- 删除:`frontend/src/components/rd-slider.tsx`(唯一使用点 CbzReader 的预读滑条将在任务 9 移入 reader-settings;本任务先删文件会破坏编译——**顺序**:本任务先把 CbzReader 里 `RdSlider` 的 import 与使用处**临时替换**为等价的原生 range(保留 `rd-range` class 与 aria-label「预读章数」,任务 9 会整体换掉),再删 rd-slider.tsx)
- [ ] **步骤 8.1:`pages/Reader.tsx` 头栏迁移(行为不变,只换壳)**
映射表(现 L52-70):
- `<header>` 类:`border-stone-800/80 bg-stone-950/80` → `border-border bg-background/90 text-foreground`;高度/过渡/收放逻辑(chromeOn 三元)不变;
- 返回 Link:`btnGhost` → `<Button variant="ghost" size="sm" asChild>` 包 `<Link to="/">`,图标 `IconArrowLeft` → lucide `ArrowLeft`(`size-4`),文案「书架」不变;
- 格式徽章 `formatBadge(book.format)` → `<FormatBadge fmt={book.format} />`——**本任务先建 `components/format-badge.tsx`**(B5 才删 ui.ts,此处新组件独立实现):
```tsx
import { Badge } from "@/components/ui/badge";
import type { Format } from "@/api/types";
const CLASSES: Record<Format, string> = {
cbz: "bg-orange-500/15 text-orange-600 ring-orange-500/25 dark:text-orange-300",
pdf: "bg-rose-500/15 text-rose-600 ring-rose-500/25 dark:text-rose-300",
epub: "bg-sky-500/15 text-sky-600 ring-sky-500/25 dark:text-sky-300",
txt: "bg-emerald-500/15 text-emerald-600 ring-emerald-500/25 dark:text-emerald-300",
md: "bg-teal-500/15 text-teal-600 ring-teal-600/25 dark:text-teal-300",
};
/** 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义(承接旧 ui.ts formatBadge)。 */
export function FormatBadge({ fmt, className }: { fmt: Format; className?: string }) {
return (
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${CLASSES[fmt]} ${className ?? ""}`}>
{fmt}
</Badge>
);
}
```
- 库名/百分比 span:`text-stone-500` → `text-muted-foreground`;
- `Msg` 组件(L78-91):`text-stone-500` → `text-muted-foreground`;`btn` → `<Button variant="secondary">`;
- import 清理:删 `btn/btnGhost/formatBadge`(from `../components/ui`)与 `IconArrowLeft`。
- [ ] **步骤 8.2:`components/reader-nav.tsx` → radix Sheet + Tabs**
整体重写(组件 API 保持不变:`tab/onTab/onClose/toc/bookmarks`,NavTab 扩为 `"toc" | "bm" | "search"` 预留 B2):
```tsx
import { Bookmark, List, Search } from "lucide-react";
import type { ReactNode } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export type NavTab = "toc" | "bm" | "search";
/** 阅读器导航抽屉(radix Sheet, side=left):目录/书签/搜索选项卡;按传入内容决定可见 tab。 */
export function ReaderNav({
tab,
onTab,
onClose,
toc,
bookmarks,
search,
}: {
tab: NavTab;
onTab?: (t: NavTab) => void;
onClose: () => void;
toc?: ReactNode;
bookmarks: ReactNode;
search?: ReactNode;
}) {
const tabs: { id: NavTab; label: string; icon: typeof List; node: ReactNode }[] = [
...(toc != null ? [{ id: "toc" as const, label: "目录", icon: List, node: toc }] : []),
{ id: "bm" as const, label: "书签", icon: Bookmark, node: bookmarks },
...(search != null ? [{ id: "search" as const, label: "搜索", icon: Search, node: search }] : []),
];
const active = tabs.find((t) => t.id === tab) ?? tabs[0];
return (
<Sheet open onOpenChange={(o) => !o && onClose()}>
<SheetContent side="left" className="w-72 p-0" aria-describedby={undefined}>
<SheetHeader className="sr-only">
<SheetTitle>导航</SheetTitle>
</SheetHeader>
<div id="reader-nav" className="flex h-full flex-col">
<Tabs value={active.id} onValueChange={(v) => onTab?.(v as NavTab)} className="flex min-h-0 flex-1 flex-col">
<TabsList className="m-2 shrink-0 self-start">
{tabs.map(({ id, label, icon: Icon }) => (
<TabsTrigger key={id} value={id} className="gap-1.5">
<Icon size={14} /> {label}
</TabsTrigger>
))}
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{active.node}</div>
</Tabs>
</div>
</SheetContent>
</Sheet>
);
}
```
调用方影响:原「无 toc 时退化为纯书签 span」语义由 tabs 数组自然覆盖;`aria-controls="reader-nav"` 的调用方按钮保留(id 移到内层 div)。e2e 锚点「关闭导航」按钮改为 radix Sheet 自带关闭钮(`aria-label` 由 sheet.tsx 提供「关闭」——**注意**:e2e auth-shelf 用的 `关闭导航` 选择器将失配,任务 10 同步更新为 `关闭`)。
- [ ] **步骤 8.3:`components/Bookmarks.tsx` 去 rd-*/stone-***
映射:两处 `<input className="...border-stone-700 bg-stone-900...">` → `<Input>`(`@/components/ui/input`,保留 placeholder/maxLength/value/onChange/onKeyDown);「加书签/保存备注/取消编辑/改备注/删除书签」按钮 `rd-btn` → `<Button variant="ghost" size="icon-xs">`(保留 aria-label 与图标);条目行 `rd-row` → `className="min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"`;`text-stone-500` → `text-muted-foreground`;`opacity-70/50` 保留。行为(mutation/invalidate/toast/Enter 提交)一行不动。
- [ ] **步骤 8.4:CbzReader 的 RdSlider 临时替换 + 删 rd-slider.tsx**
CbzReader L8 import 删除;L443-451 的 `<RdSlider .../>` 替换为等价块(保留在「更多」dropdown 内,任务 9 整体迁走):
```tsx
<div className="px-2.5 pb-1.5 pt-2">
<div className="mb-1 text-sm">预读:锁章末尾时提前挂载其后 N 章</div>
<input
type="range"
min={0}
max={3}
step={1}
value={prefetch}
aria-label="预读章数"
className="rd-range"
onChange={(e) => setPrefetch(Number(e.target.value))}
/>
</div>
```
然后 `git rm frontend/src/components/rd-slider.tsx`。
- [ ] **步骤 8.5:e2e 锚点同步**
`e2e/auth-shelf.spec.ts` 中 `getByRole("button", { name: "关闭导航" })` → `getByRole("button", { name: "关闭" })`(以 sheet.tsx 关闭钮实际 aria-label 为准;若 shadcn sheet 用 sr-only "Close",则中文界面下按钮可访问名为 "Close"——**以 DOM 实况定,语义不变**)。
- [ ] **步骤 8.6:验证 + Commit**
`npm run check` 绿;起 dev 栈跑 `npm run e2e`(凭据注入见全局约束)2 spec 全绿;手工过一遍 CBZ/Text 抽屉开合(Sheet 焦点收拢、Esc 关闭)。
```bash
git add -A frontend
git commit -m "refactor(frontend): shared reader chrome to shadcn/radix (header, nav Sheet, bookmarks)"
```
### 任务 9:CbzReader 迁移 + page/spread/RTL
**文件:**
- 修改:`frontend/src/readers/CbzReader.tsx`
- [ ] **步骤 9.1:接线 reader-settings(替换 L364-466 整个 chrome.on 三元块)**
- `useStoredBool`/`useStoredPrefetch` 两个 hook(L17-35)删除;改用 `useReaderPrefs()`:`continuous = prefs.cbz.continuous`、`prefetch = prefs.cbz.prefetch`(写回走 `update`)。**注意**:`chapterWin(chapters, count, ci, continuous, prefetch)` 等下游消费不变。
- chrome.on 时渲染(absolute 覆盖层由调用方包):
```tsx
{chrome.on && (
<div className="absolute inset-x-0 bottom-0 z-20">
<ReaderSettings
slider={{ value: scrub ?? cur, max: Math.max(0, count - 1), onChange: (v) => { setScrub(v); goPage(v, false); }, ariaLabel: "页码跳转" }}
position={`${(scrub ?? cur) + 1}/${count} · ${Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%`}
left={
<>
<Button variant="ghost" size="sm" className="gap-1.5" aria-expanded={!!nav} aria-controls="reader-nav"
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}>
<PanelLeft size={14} /> 导航
</Button>
{chapters && (
<>
<span className="text-xs tabular-nums text-muted-foreground">{ci + 1}/{chapters.length}</span>
<Button variant="ghost" size="sm" disabled={ci === 0} onClick={() => goPage(chapters[ci - 1].start)}>
<ChevronLeft size={14} /> 上一章
</Button>
<Button variant="ghost" size="sm" disabled={ci === chapters.length - 1} onClick={() => goPage(chapters[ci + 1].start)}>
下一章 <ChevronRight size={14} />
</Button>
</>
)}
</>
}
extra={
<>
<SettingRow label="翻页模式">
{CBZ_MODES.map((m) => (
<Button key={m} variant={prefs.cbz.mode === m ? "secondary" : "ghost"} size="xs" onClick={() => setMode(m)}>
{CBZ_MODE_LABEL[m]}
</Button>
))}
</SettingRow>
{prefs.cbz.mode !== "strip" && (
<SettingRow label="阅读方向">
<Button variant={prefs.cbz.rtl ? "secondary" : "ghost"} size="xs" onClick={() => patchCbz({ rtl: !prefs.cbz.rtl })}>
右开本(RTL)
</Button>
</SettingRow>
)}
{chapters && chapters.length >= 2 && (
<SettingRow label="连读(跨章)">
<Button variant={prefs.cbz.continuous ? "secondary" : "ghost"} size="xs" onClick={() => patchCbz({ continuous: !prefs.cbz.continuous })}>
{prefs.cbz.continuous ? "开" : "关"}
</Button>
</SettingRow>
)}
{locked && (
<SettingRow label="预读章数">
<Slider className="w-32" min={0} max={3} step={1} value={[prefs.cbz.prefetch]}
onValueChange={([v]) => patchCbz({ prefetch: v })} aria-label="预读章数" />
</SettingRow>
)}
</>
}
/>
</div>
)}
```
`SettingRow`(本文件内小组件):`<div className="flex items-center justify-between gap-3 px-1 py-1.5 text-sm"><span className="text-muted-foreground">{label}</span><span className="flex items-center gap-1">{children}</span></div>`。`setMode(m)`:切到 page/spread 时把当前 `cur` 页换算为新视图起点(任务 9.2 的 pv state 同步),切回 strip 时 `goPage(viewToPageIndex(pv, mode))` 落位。旧「更多」DropdownMenu(L417-456)与旧滑条/HUD 块整体删除;chrome.off 时的右下角 HUD(L459-466)保留但换 token:`bg-stone-900/85 ring-stone-700/60 text-stone-200` → `bg-background/85 ring-border text-foreground`。
- [ ] **步骤 9.2:page/spread 渲染路径(新组件,同文件内)**
在 CbzReader 组件内按 `prefs.cbz.mode` 分支:`strip` 走现有 JSX(L301-363 的滚动容器 + 量高 + 章末卡片 + 预读层,**一行不动**);`page`/`spread` 渲染 `<PageView>`:
```tsx
function PageView({
count, chapters, locked, ci, setCi, continuous, fmt, rtl, mode, saver, chrome, initialPage,
}: {
count: number;
chapters: { title: string; start: number }[] | null;
locked: boolean;
ci: number;
setCi: (i: number) => void;
continuous: boolean;
fmt: string;
rtl: boolean;
mode: PageMode;
saver: ReturnType<typeof useProgressSaver>;
chrome: Chrome;
initialPage: number;
}) {
// 章窗口:locked 时视图范围 = 本章页;continuous 时全书
const win = locked && chapters ? { start: chapters[ci].start, end: ci + 1 < chapters.length ? chapters[ci + 1].start : count } : { start: 0, end: count };
const winViews = viewCount(win.end - win.start, mode);
const [v, setV] = useState(() => Math.min(winViews - 1, pageIndexToView(Math.max(win.start, Math.min(win.end - 1, initialPage)) - win.start, mode)));
const atEnd = v >= winViews - 1; // 章末卡片视图 = winViews(虚拟追加一格)
const [showEndCard, setShowEndCard] = useState(false);
const page0 = win.start + viewToPageIndex(v, mode);
useEffect(() => {
if (showEndCard) return;
saver.report({ page: page0 }, (page0 + 1) / count);
}, [page0, showEndCard, count, saver]);
function turn(d: number) {
chrome.show();
if (showEndCard) {
if (d > 0) advanceChapter();
else setShowEndCard(false);
return;
}
const nv = turnView(v, d, rtl, winViews);
if (nv === v && atEnd && (rtl ? d < 0 : d > 0)) {
// 章末:locked 且非 continuous → 卡片;否则进下一章
if (locked && !continuous) setShowEndCard(true);
else advanceChapter();
return;
}
setV(nv);
}
function advanceChapter() {
if (!chapters || ci + 1 >= chapters.length) return; // 全书完:卡片显示「— 全书完 —」
setCi(ci + 1);
setShowEndCard(false);
setV(0);
}
// ci 变化(外部 goPage/目录/书签)时重定位视图
...
}
```
**实现者注意**(计划给结构与语义,细节以现有 strip 路径的语义为准对齐):
- 外部 `goPage(i)`(书签/目录/滑条/恢复进度)在页模式下 = `setCi(chapterIndexAt(chapters, i))`(locked 时)+ 换算 `v`;用 `pending` ref 模式与现有代码一致(ci 生效后落位)。
- 渲染:`spreadPages(page0, win.end, rtl)` 得到的页数组,每页 `<PageImg url={formatPageUrl(fmt, i)} width={...} onLoaded={noop}/>`;单页宽 = `min(容器宽, MAX_W)`,双页各占一半(flex 行,`items-center justify-center`,容器 `h-full overflow-hidden` 点区沿用 `onZoneClick` 语义:两侧翻页(RTL 由 turn 内部翻转)、中央 toggle chrome)。
- 相邻预载:`useEffect` 里对 `page0±2` 调 `fetchObjectUrl(formatPageUrl(fmt, j)).catch(() => {})`。
- 章末卡片:`showEndCard` 时全屏卡片(`win.end >= count ? "— 全书完 —" : <Button>本章完 · 下一章</Button>`),样式对齐现有 strip 章末卡片(shadcn token)。
- 键盘:外层容器 onKeyDown ←/→/PageUp/PageDown → `turn(±1)`(RTL 翻转在 turn 内);容器 tabIndex={0}(沿用 ④ 的 a11y 修复)。
- 页码 HUD/滑条读数:`cur` 在页模式下 = `page0`(供 position 文本与 slider value)。
- `initialPage`:挂载时来自恢复进度/当前 strip 位置。
- [ ] **步骤 9.3:验证**
`npm run check` 绿;`npm run e2e` 绿(现有 auth-shelf 的 CBZ 流程是 strip 模式,应无回归);手工走查清单:strip 全部旧行为(章锁/连读/预读/滑条/书签/目录/进度恢复)+ page/spread 翻页、RTL 方向、双页配对、章末卡片、模式切换保持当前页、主题卡对 CBZ 背景生效(`--rd-bg` 用于页模式容器底色)。
- [ ] **步骤 9.4:Commit(含 B1 changelog,双语,Added 顶部)**
changelog 文案:
```markdown
- CBZ reader gains horizontal reading modes — 单页 / 双页 with a 右开本 (RTL) toggle — alongside the existing vertical strip; mode, direction, continuous reading and prefetch now live in a unified settings bar with the 纸/米/夜 theme swatches and follow-global-theme support.
- CBZ 阅读器新增横向阅读模式——单页/双页,带右开本(RTL)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。
```
```bash
git add frontend docs/CHANGELOG_web.md
git commit -m "feat(frontend): CBZ page/spread modes + RTL, chrome migration (B1)"
```
### 任务 10:CBZ 测试与 e2e 扩展
**文件:**
- 创建:`frontend/src/readers/CbzReader.pagemode.test.tsx`(组件测试,可测部分)
- 修改:`frontend/e2e/auth-shelf.spec.ts`(页模式 + RTL 冒烟段)
- [ ] **步骤 10.1:组件测试**:PageView 依赖网络图片与 react-query,全组件测试成本高——本任务测**可注入部分**:设置面板 extra 区的模式切换按钮渲染与点击回调(通过 ReaderSettings 单测已覆盖展开逻辑,此处补 CbzReader 特有:mock `api.pageCount` 返回 3 页扁平书,渲染 CbzReader,切「单页」→ 断言出现页视图容器(aria-label 含「漫画阅读器」仍成立)与 position 文本 `1/3`;切「双页」→ position 仍 `1/3`(首页不变)。fetch/ObjectURL 用 vi.stubGlobal mock。**若 mock 面过大导致测试脆弱,降级为只测设置区交互 + lib 纯函数(任务 7 已覆盖),并在报告说明**——不许写空洞断言凑数。
- [ ] **步骤 10.2:e2e 扩展**:auth-shelf.spec.ts 的阅读器段落之后追加:打开「更多阅读设置」→ 点「单页」→ 断言 position 含 `1/3` → 右缘点击 → `2/3` → 点「右开本(RTL)」→ 右缘点击 → 回 `1/3` → 切回「长卷」。选择器以 DOM 实况为准(aria-label「更多阅读设置」、按钮文本 单页/右开本(RTL)/长卷)。
- [ ] **步骤 10.3:验证 + Commit**
`npm run check` 绿 + `npm run e2e` 全绿。
```bash
git add frontend
git commit -m "test(frontend): CBZ page-mode component test + e2e mode/RTL smoke (B1)"
```
---
## B2 批次 2:Text(迁移 + 搜索 + 排版)
### 任务 11:search 纯函数(TDD)
**文件:**
- 创建:`frontend/src/lib/search.ts`、`frontend/test/search.test.ts`
- [ ] **步骤 11.1:先写失败单测**,覆盖:
```ts
import { describe, expect, it } from "vitest";
import { searchChapters, splitHighlight } from "../src/lib/search";
import { splitChapters } from "../src/lib/chapters";
const TEXT = ["第一章 起风", "风来了又走。", "第二章 落雨", "雨点敲窗,风声相伴。", "风止"].join("\n");
describe("searchChapters", () => {
const chs = splitChapters(TEXT);
it("finds case-insensitive hits with chapter index and position", () => {
const hits = searchChapters(TEXT, chs, "风");
expect(hits.length).toBeGreaterThanOrEqual(3);
expect(hits[0].ch).toBe(0);
expect(hits.every((h) => h.snippet.includes("风"))).toBe(true);
});
it("returns empty for no match / empty query", () => {
expect(searchChapters(TEXT, chs, "不存在")).toEqual([]);
expect(searchChapters(TEXT, chs, " ")).toEqual([]);
});
it("caps at limit", () => {
expect(searchChapters(TEXT, chs, "风", 2)).toHaveLength(2);
});
});
describe("splitHighlight", () => {
it("splits snippet into before/match/after", () => {
expect(splitHighlight("abc风def", 3, 1)).toEqual({ before: "abc", match: "风", after: "def" });
});
});
```
- [ ] **步骤 11.2:实现 `frontend/src/lib/search.ts`**
```ts
import { chapterText, type TxtChapter } from "./chapters";
export interface SearchHit {
ch: number;
/** 命中在章内文本的起点 */
pos: number;
snippet: string;
matchStart: number; // snippet 内
matchLen: number;
}
const CTX = 20; // snippet 前后文
/** 全书线性扫描(文本已分章在内存,量级足够;大小写不敏感,不折叠全半角)。 */
export function searchChapters(text: string, chapters: TxtChapter[], q: string, limit = 500): SearchHit[] {
const needle = q.trim().toLowerCase();
if (!needle) return [];
const hits: SearchHit[] = [];
for (let ci = 0; ci < chapters.length && hits.length < limit; ci++) {
const body = chapterText(text, chapters, ci).toLowerCase();
let from = 0;
for (;;) {
const at = body.indexOf(needle, from);
if (at < 0 || hits.length >= limit) break;
const raw = chapterText(text, chapters, ci);
const s = Math.max(0, at - CTX);
const e = Math.min(raw.length, at + needle.length + CTX);
hits.push({
ch: ci,
pos: at,
snippet: (s > 0 ? "…" : "") + raw.slice(s, e) + (e < raw.length ? "…" : ""),
matchStart: at - s + (s > 0 ? 1 : 0),
matchLen: needle.length,
});
from = at + needle.length;
}
}
return hits;
}
export function splitHighlight(snippet: string, matchStart: number, matchLen: number) {
return {
before: snippet.slice(0, matchStart),
match: snippet.slice(matchStart, matchStart + matchLen),
after: snippet.slice(matchStart + matchLen),
};
}
```
(性能注意:`chapterText` 在循环里重复调用——实现时可先把各章文本缓存进数组再扫,以测试为准优化,接口不变。)
- [ ] **步骤 11.3:绿 → Commit**
```bash
git add frontend/src/lib/search.ts frontend/test/search.test.ts
git commit -m "feat(frontend): in-book text search core (TDD)"
```
### 任务 12:TextReader 迁移 + 搜索 tab + 排版设置
**文件:**
- 修改:`frontend/src/readers/TextReader.tsx`、`frontend/src/components/reader-nav.tsx`(search 内容传入已支持,无需再改)
- [ ] **步骤 12.1:删除本地 `ReaderSheet`(L60-127)与 `SWATCH`,MdView/TxtView 换 ReaderSettings**
- `pr = useReaderPrefs()` 保留;新增 `const globalDark = useGlobalDark(); const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);`,`data-rd={rd}`(替换 `pr.prefs.theme`,两处 L179/L302);
- 任务 2 的临时适配(`resolveRdTheme(pr.prefs.themeMode, false)`)换为上述真实联动;
- MdView 底部(chrome.on 时):`<ReaderSettings slider={{ value: Math.round(frac*1000), max: 1000, onChange: (v) => seek(v/1000), ariaLabel: "阅读进度" }} position={`${Math.round(frac*100)}%`} left={导航钮(同现 L194-201,换 Button)} showFont extra={行距/边距 SettingRow} />`;
- TxtView 底部:slider 用 `Math.round(total*1000)`,left 区含 导航 + 章计数 + 上一章/下一章(现 L318-346,换 `Button variant=ghost size=sm`),extra = 行距(LINE_HEIGHTS 五档按钮组,当前档 `variant=secondary`)+ 边距(MARGINS 三档,MARGIN_LABEL);
- 正文样式接排版设置:MdView `<article>` 的 `leading-[1.85]` → `style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight }}`、`max-w-[46rem]` → `max-w-[46rem]` 保留为 md 上限但受边距档影响?——**裁定**:md 正文限宽沿用 46rem 不接边距档(md 是排版文档,非流式小说),行距接入;TxtView `<pre>` 的 `max-w-[34em]` → `pr.marginMaxW`、`lineHeight: 1.9` → `pr.lineHeight`;
- A−/A+ 由 ReaderSettings 常驻行提供(showFont),MdView 用 `bumpText`(**裁定**:md 与 txt 共用 text 档位,简化;epub 独立档位在 B3 接 `bumpEpub`)。ReaderSettings 的 bump 需要知道调哪个——组件加可选 prop `onFont?: (d: number) => void`,Text 传 `pr.bumpText`、EPUB 传 `pr.bumpEpub`,未传时 A−/A+ 隐藏。
- [ ] **步骤 12.2:TxtView 接搜索 tab**
- state:`const [query, setQuery] = useState(""); const [hits, setHits] = useState<SearchHit[] | null>(null);` + 250ms 防抖 effect(query 变化 → setTimeout 重算 `searchChapters(text, chapters, query)`,清理 timer;空 query → hits=null);
- 传给 ReaderNav:`search={<SearchPanel .../>}`(同文件内小组件):顶部 `<Input placeholder="搜索全书" value={query} ...>`(autofocus 用 ref+useEffect,避免 jsx-a11y/no-autofocus error),结果 `<ul>`:每行按钮 = 章节标题(`chs[h.ch].title`)+ snippet(`splitHighlight` 三段,match 段 `<mark className="bg-accent text-accent-foreground rounded px-0.5">`);点击 → `goChapter(h.ch, intraFrac)` 其中 `intraFrac` 由命中位置估算:`h.pos / chapterText(...).length`(钳制 0..1)→ 现有 goChapter 的 scrollFraction 机制自然滚动到大致位置 + `setNav(null)`;无结果显示「无结果」;hits 为 null(未搜)显示提示文案。
- 命中处的「短暂高亮」:`goChapter` 后正文无锚点元素可指——**裁定**:以滚动定位为准,不做正文内高亮标记(正文是 `<pre>` 纯文本,插标记会破坏选区/进度语义;spec 的「短暂高亮」降级为「定位到大致位置」,如实记录)。
- MdView 不加搜索(md 是单文档渲染,无分章结构;范围外)。
- [ ] **步骤 12.3:TxtView disable 重估(④ 遗留)**
L248 的 `react-hooks/set-state-in-effect` disable:迁移后重估——初始化 effect(L240-257)逻辑不变则保留 disable 与注释;若顺手可改为渲染期重置(chapters 首次非空时按 locator 落位)且行为等价,则修掉并删注释。以保守为先,改动写进报告。
- [ ] **步骤 12.4:验证 + Commit(含 B2 changelog)**
`npm run check` 绿 + `npm run e2e` 绿 + 手工走查(txt 分章/分卷/进度恢复/书签/搜索跳转/行距边距即时生效/auto 主题联动)。
changelog(Added 顶部,双语):
```markdown
- Text reader gains full-book search (搜索 tab in the navigation drawer: debounced input, chapter + snippet results, tap to jump) and typography settings (line height ×5, margin width ×3); the reading toolbar is now the unified settings bar with theme swatches and follow-global-theme.
- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。
```
```bash
git add frontend docs/CHANGELOG_web.md
git commit -m "feat(frontend): text reader search + typography, chrome migration (B2)"
```
### 任务 13:Text e2e fixture + 搜索冒烟
**文件:**
- 创建:`frontend/e2e/fixtures/e2e-sample.txt`、`frontend/e2e/text-search.spec.ts`
- [ ] **步骤 13.1:fixture(精确内容,搜索断言依赖它)**
```text
第一章 样本
这是第一章的正文,包含标记词 ZEBRA-XC42 用于搜索断言。
第二章 落雨
雨点敲窗。第二章也有 zebra-xc42 的小写变体。
第三章 风止
wind 结尾章,无标记词。
```
- [ ] **步骤 13.2:spec `text-search.spec.ts`**
复用 helpers(`adminApi` + 泛化 `ensureSampleBook`——现 helper 写死 cbz fixture;实现时把 `ensureSampleBook(ctx, libName)` 重构为 `ensureBook(ctx, libName, fileName, absPath)` 并保留 `ensureSampleBook` 为 cbz 便捷封装,auth-shelf 不受影响)。流程:上传 e2e-sample.txt 到 `e2e` 库 → 扫描 → waitBook("e2e-sample")——**注意**与 cbz 样本书同名冲突:txt fixture 文件名用 `e2e-sample-txt.txt`(title=e2e-sample-txt)。UI:登录 → 进书 → 导航 → 「搜索」tab → 输入 `zebra-xc42` → 断言结果 ≥2 行(大小写不敏感命中两章)且 snippet 含 `<mark>` → 点第二条 → 断言抽屉关闭 + 章计数文本 `2/3`。axe:本页扫描(reader-chrome)保持既有 WAIVERS 策略(B5 统一收严)。
- [ ] **步骤 13.3:验证 + Commit**
`npm run e2e` 3 spec 全绿;`npm run check` 绿。
```bash
git add frontend
git commit -m "test(frontend): text search e2e with txt fixture (B2)"
```
---
## B3 批次 3:EPUB(迁移 + 排版设置)
### 任务 14:EpubReader 迁移 + epubjs themes
**文件:**
- 修改:`frontend/src/readers/EpubReader.tsx`
- [ ] **步骤 14.1:chrome 迁移**
底栏(L112-127)→ ReaderSettings:`slider` 不用(EPUB 无可靠全局分数——现状也没有滑条,保持无滑条:ReaderSettings 的 slider prop 改为可选 `slider?`,EPUB 不传,position 也不传;组件内 slider 缺省时不渲染该行);left = 上一页/下一页(`rendRef.current?.prev()/next()`,Button ghost sm)+ 导航钮;showFont(onFont=`pr.bumpEpub`);extra = 行距/边距档位按钮(同 Text 的 SettingRow 模式,档位存 `prefs.epub.lineIdx/marginIdx`)。容器 `bg-white ring-stone-800` → `bg-background ring-border`;加载态 `bg-stone-950 text-stone-500` → `bg-muted text-muted-foreground`;`btn` 重试钮 → `<Button variant="secondary">`。
- [ ] **步骤 14.2:epubjs themes 接线**
`EpubRendition` 接口扩最小 subset:`themes: { register(name: string, rules: Record<string, unknown>): void; select(name: string): void; default(key: string, value: unknown): void }`。加载 effect 内 rendition 创建后:
```ts
const applyTheme = () => {
const r = rendRef.current;
if (!r) return;
r.themes.register("booklib", {
body: {
background: `var(--rd-bg)`, // 注意:iframe 内无宿主变量 → 用解析后的具体色值
},
});
};
```
**iframe 隔离裁定**:epubjs iframe 拿不到宿主 CSS 变量——实现为 `THEME_CSS: Record<RdTheme, { bg: string; fg: string }>` 常量(值取 index.css 三主题的 --rd-bg/--rd-fg 具体色:paper `#faf8f4/#292524`、sepia `#f0e6d2/#433a29`、night `#171512/#c6c0b6`),`themes.register("booklib", { body: { background: bg, color: fg, "line-height": String(LINE_HEIGHTS[lineIdx]), "font-size": `${FONT_SIZES[sizeIdx]}px`, padding: `${[0.5,1,1.75][marginIdx]}rem 0` } })` + `themes.select("booklib")`。prefs.epub / rd(resolveRdTheme)变化时重新 register+select(useEffect 依赖 [rd, prefs.epub],rendition 就绪后执行)。宿主容器背景同步 `style={{ background: "var(--rd-bg)" }}` + `data-rd={rd}`。
- [ ] **步骤 14.3:验证 + Commit(含 B3 changelog)**
`npm run check` 绿 + `npm run e2e` 绿(EPUB 无 e2e——手工清单:开书 → 字号/行距/边距各改一次目测生效 → 三主题切换正文底色/文字色生效 → auto 联动(全局切 dark → EPUB 正文变夜)→ 进度恢复/书签)。
changelog(Added 顶部,双语):
```markdown
- EPUB reader gains typography settings (font size, line height, margins) and honors the 纸/米/夜 reading themes including follow-global-theme; its toolbar moves to the unified settings bar.
- EPUB 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。
```
```bash
git add frontend docs/CHANGELOG_web.md
git commit -m "feat(frontend): EPUB typography themes + chrome migration (B3)"
```
---
## B4 批次 4:PDF 迁移 + 书架统计卡
### 任务 15:PdfReader 迁移
**文件:**
- 修改:`frontend/src/readers/PdfReader.tsx`
- [ ] **步骤 15.1:底栏(L148-190)→ ReaderSettings**
left = 首页/上一页/页计数/下一页/末页(现五钮一 span,`btn` → `Button variant=ghost size=sm`,页计数 `text-stone-400` → `text-muted-foreground`)+ 导航钮;slider 不传(PDF 保持按钮翻页现状,不加滑条);showFont=false;extra 无(PDF 无设置项——「更多设置」钮自然不渲染)。容器:`bg-stone-900/70` → `bg-muted/50`;canvas `ring-stone-700/50` → `ring-border`;加载/错误态 stone → token;`btn` 重试 → Button。渲染/键盘/进度逻辑一行不动。
- [ ] **步骤 15.2:验证 + Commit**
`npm run check` + `npm run e2e` 绿;手工:PDF 翻页/首末页/键盘/书签/进度恢复。
```bash
git add frontend
git commit -m "refactor(frontend): PDF reader chrome to shadcn (B4)"
```
### 任务 16:书架统计卡 + 会话接线
**文件:**
- 创建:`frontend/src/components/reading-stats-card.tsx`、`frontend/src/components/reading-stats-card.test.tsx`
- 修改:`frontend/src/pages/Shelf.tsx`、`frontend/src/pages/Reader.tsx`
- [ ] **步骤 16.1:`reading-stats-card.tsx`(TDD:先写测试)**
```tsx
import { useMemo, useState } from "react";
import { BarChart3, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createLocalStats, type StatsStore } from "@/lib/readingStats";
const FOLD_KEY = "stats-card-folded";
function fmtDur(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.round((s % 3600) / 60);
return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`;
}
/** 书架顶部阅读统计卡:本周时长 + 连续天数,展开显示近 7 日柱状图。store 可注入(测试/③ 服务端实现)。 */
export function ReadingStatsCard({ store }: { store?: StatsStore }) {
const stats = useMemo(() => store ?? createLocalStats(), [store]);
const [folded, setFolded] = useState(() => localStorage.getItem(FOLD_KEY) === "1");
const { perDay, total } = stats.weekly();
const streak = stats.streak();
const max = Math.max(1, ...perDay.map((d) => d.seconds));
return (
<section aria-label="阅读统计" className="rounded-lg border bg-card px-4 py-3 text-card-foreground">
<div className="flex items-center gap-2 text-sm">
<BarChart3 className="size-4 text-muted-foreground" />
<span>
本周阅读 <strong className="tabular-nums">{fmtDur(total)}</strong>
{streak > 0 && <span className="text-muted-foreground"> · 连续 {streak} 天</span>}
</span>
<Button variant="ghost" size="icon-xs" className="ml-auto" aria-label={folded ? "展开统计" : "折叠统计"}
onClick={() => {
const v = !folded;
setFolded(v);
localStorage.setItem(FOLD_KEY, v ? "1" : "0");
}}>
{folded ? <ChevronDown className="size-4" /> : <ChevronUp className="size-4" />}
</Button>
</div>
{!folded && (
<div className="mt-2 flex h-16 items-end gap-1.5" role="img" aria-label="近 7 日阅读时长柱状图">
{perDay.map((d) => (
<div key={d.day} className="flex flex-1 flex-col items-center gap-1">
<div className="w-full rounded-sm bg-primary/70" style={{ height: `${Math.max(2, (d.seconds / max) * 100)}%` }} />
<span className="text-[10px] text-muted-foreground tabular-nums">{d.day.slice(8)}</span>
</div>
))}
</div>
)}
</section>
);
}
```
组件测试(fake store 注入):本周文案含时长、连续天数条件渲染、展开/收起(aria-label 翻转 + 柱状图可见性 + localStorage 记忆)、7 根柱。
- [ ] **步骤 16.2:Shelf 接线**:`Shelf.tsx` 主内容区顶部(筛选/搜索栏之下、书网格之上)插 `<ReadingStatsCard />`。
- [ ] **步骤 16.3:Reader 会话接线**:`pages/Reader.tsx` 组件顶部 `const stats = useMemo(() => createLocalStats(), []); useReadingSession(stats);`。
- [ ] **步骤 16.4:e2e**:auth-shelf 登录后书架段加一行 `await expect(page.getByRole("region", { name: "阅读统计" })).toBeVisible();`(section aria-label)。
- [ ] **步骤 16.5:验证 + Commit(含 B4 changelog)**
changelog(Added 顶部,双语):
```markdown
- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open.
- 书架页新增阅读统计卡(本周时长、连续天数、可展开的近 7 日柱状图);打开书籍期间在设备本地记录阅读时长。
```
```bash
git add frontend docs/CHANGELOG_web.md
git commit -m "feat(frontend): shelf reading stats card + session tracking (B4)"
```
---
## B5 批次 5:清理与收严
### 任务 17:删除 ui.ts / icons.tsx / rd-* 类
**文件:**
- 删除:`frontend/src/components/ui.ts`、`frontend/src/components/icons.tsx`
- 修改:`frontend/src/pages/Shelf.tsx`、`frontend/src/index.css`、其余引用点(`grep -rn 'components/ui"\|components/icons' src/` 清点)
- [ ] **步骤 17.1:使用点迁移**:Shelf.tsx 的 `formatBadge(book.format, "absolute left-1.5 top-1.5")` → `<FormatBadge fmt={book.format} className="absolute left-1.5 top-1.5" />`(任务 8 已建组件);全库 grep `btn\b|btnGhost|formatBadge|IconArrowLeft` 清零后 `git rm` 两文件。
- [ ] **步骤 17.2:index.css 清扫**:删除 `.rd-btn/.rd-row/.rd-btn-on/.rd-highlight/.rd-divider/.rd-range`(含伪元素)与 `.rd-sheet` 等全部 rd-* 组件类及其注释;**保留** `[data-rd=...]` token 块、`.rd-surface`(含 ::selection)、`fx-rise`、`md-body`。清扫 `src/**/*.tsx` 内全部 `rd-` class 引用与 stone-*/amber-* 裸色(grep 清单逐个换 token;`--rd-*` var() 引用是合法的保留)。
- [ ] **步骤 17.3:验证**
```bash
grep -rn 'rd-' frontend/src --include='*.tsx' | grep -v 'data-rd\|--rd-' # 预期零输出
grep -rn 'stone-\|amber-' frontend/src --include='*.tsx' # 预期零输出(读者区)
npm run check && npm run e2e
```
- [ ] **步骤 17.4:Commit(含 changelog Changed 条目,双语)**
```markdown
- Reader UI cleanup: the legacy style helpers (ui.ts / icons.tsx) and all rd-* classes are gone; every reader surface now uses the shared design tokens and components.
- 阅读器 UI 清理:旧样式助手(ui.ts / icons.tsx)与全部 rd-* 类删除;所有阅读器表面统一使用共享设计 token 与组件。
```
```bash
git add -A frontend docs/CHANGELOG_web.md
git commit -m "refactor(frontend): remove ui.ts/icons.tsx and all rd-* legacy classes (B5)"
```
### 任务 18:收严回收 + 总验收
**文件:**
- 修改:`frontend/eslint.config.js`、`frontend/e2e/helpers/axe.ts`、(视重估结果)`frontend/src/readers/TextReader.tsx`、`frontend/src/readers/CbzReader.tsx`
- [ ] **步骤 18.1:eslint 收严**:删除 `eslint.config.js` 的 reader 豁免 override 整块(files 列表 + 3 条 warn 规则 + 注释);`npm run lint` 若有新 error 逐个真修(迁移后的代码应达标;确属误报的规则按全局约束处理并注释)。
- [ ] **步骤 18.2:axe WAIVERS 清空**:`helpers/axe.ts` 的 `WAIVERS` 数组清空(保留机制与注释);`npm run e2e` 重扫 login/shelf/reader-chrome/text-search 全部页面——若 reader 页仍有 critical/serious:真修(此时旧 stone chrome 已不存在,剩余多可修);确实无法本期修的**上报控制者裁定**,不得自行回填 WAIVERS。
- [ ] **步骤 18.3:行级 disable 重估**:CbzReader 滚动容器 tabIndex 的 `jsx-a11y/no-noninteractive-tabindex` disable——迁移后容器若仍是非交互 div+tabIndex,disable 保留但注释更新(去掉「② 迁移后」字样,写明滚动区可聚焦的通行做法理由);TxtView 的 set-state disable 按任务 12 结论收尾。AuthContext 的 disable(与阅读器无关)不动。
- [ ] **步骤 18.4:e2e 补设置面板冒烟**:auth-shelf 或 text-search 内追加:打开「更多阅读设置」→ 改字号/行距一档 → 断言正文 style 变化(`toHaveCSS` 或 style 属性断言)。
- [ ] **步骤 18.5:总验收(spec S7 DoD)**
```bash
npm run check # 含 tsc(e2e/config)/eslint(无豁免)/prettier/vitest/build
npm run e2e # 全部 spec + axe 零豁免红
grep -rn 'rd-' frontend/src --include='*.tsx' | grep -v 'data-rd\|--rd-' # 零
ls frontend/src/components/ui.ts frontend/src/components/icons.tsx 2>&1 # 均不存在
grep -c 'WAIVERS: Waiver\[\] = \[\]' frontend/e2e/helpers/axe.ts # 1(空数组)
git status --short && git log --oneline master..HEAD | wc -l
```
手工走查清单(spec S7-2):四阅读器 × 纸/米/夜+auto × 375/768/1280 × light/dark;旧行为回归项:翻页/翻屏、章锁/连读/预读、书签 seek、进度恢复、沉浸收放、编码回退(GBK txt)、分章/分卷。
- [ ] **步骤 18.6:Commit + 汇报**
```bash
git add frontend docs
git commit -m "chore(frontend): tighten eslint/axe gates, remove reader waivers (B5 done, spec ② complete)"
git log --oneline master..HEAD
```
向控制者汇报 DoD 结果;控制者走最终整分支审查 + finishing-a-development-branch。
---
## 自检记录 / Self-check
- 规格覆盖度:S1→任务 1-6;S2→任务 7-10;S3→任务 11-13;S4→任务 14;S5→任务 15-16;S6→任务 17-18;S7→任务 18.5;S8 范围外无对应任务(正确)。backlog 的 vitest 缝隙→任务 1.3 已纳入。
- 占位符扫描:新模块全代码;迁移任务给出逐 file:line 映射表与目标结构;PageView 给出完整骨架 + 语义对齐说明(strip 路径引用现有代码不重复贴)。任务 9.2 的 `...`(ci 变化重定位)已用文字语义补全(pending ref 模式对齐现有代码)。
- 类型一致性:`ReaderPrefs`/`resolveRdTheme`/`useReaderPrefs` API(任务 2)与 reader-settings(任务 5)、各阅读器接线(任务 9/12/14)一致;`StatsStore`(任务 6)与统计卡(任务 16)一致;`SearchHit/splitHighlight`(任务 11)与任务 12 消费一致;`PageMode/viewCount/turnView/spreadPages`(任务 7)与任务 9 PageView 一致;`NavTab "search"`(任务 8 预留)与任务 12 使用一致;`FormatBadge`(任务 8)与任务 17 使用一致。
- 已知裁定(写死在计划里,防止实现者漂移):md 正文不接边距档;搜索跳转不做正文内高亮(定位到大致位置);EPUB 无滑条(slider prop 可选化);EPUB iframe 用具体色值常量表;onFont prop 区分 text/epub 档位。