# 阅读器改版(②)实现计划 / 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 = { 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 = { narrow: "窄", medium: "中", wide: "宽" }; /** 正文限宽档位:窄=宽度量、宽=窄度量(阅读习惯:边距越大行宽越窄) */ export const MARGIN_MAXW: Record = { 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 = { 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 = {}; try { const parsed: unknown = JSON.parse(raw ?? ""); if (parsed && typeof parsed === "object") o = parsed as Record; } catch { /* 首次使用或坏值 */ } const t = (o.text ?? {}) as Record; const c = (o.cbz ?? {}) as Record; const e = (o.epub ?? {}) as Record; 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(() => 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; ``` **注意**:现有消费点(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`);标准结构: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; 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): - `
` 类:`border-stone-800/80 bg-stone-950/80` → `border-border bg-background/90 text-foreground`;高度/过渡/收放逻辑(chromeOn 三元)不变; - 返回 Link:`btnGhost` → ` {chapters && ( <> {ci + 1}/{chapters.length} )} } extra={ <> {CBZ_MODES.map((m) => ( ))} {prefs.cbz.mode !== "strip" && ( )} {chapters && chapters.length >= 2 && ( )} {locked && ( patchCbz({ prefetch: v })} aria-label="预读章数" /> )} } /> )} ``` `SettingRow`(本文件内小组件):`
{label}{children}
`。`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` 渲染 ``: ```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; 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)` 得到的页数组,每页 ``;单页宽 = `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 ? "— 全书完 —" : `),样式对齐现有 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 时):` 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 `
` 的 `leading-[1.85]` → `style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight }}`、`max-w-[46rem]` → `max-w-[46rem]` 保留为 md 上限但受边距档影响?——**裁定**:md 正文限宽沿用 46rem 不接边距档(md 是排版文档,非流式小说),行距接入;TxtView `
` 的 `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(null);` + 250ms 防抖 effect(query 变化 → setTimeout 重算 `searchChapters(text, chapters, query)`,清理 timer;空 query → hits=null);
- 传给 ReaderNav:`search={}`(同文件内小组件):顶部 ``(autofocus 用 ref+useEffect,避免 jsx-a11y/no-autofocus error),结果 `
    `:每行按钮 = 章节标题(`chs[h.ch].title`)+ snippet(`splitHighlight` 三段,match 段 ``);点击 → `goChapter(h.ch, intraFrac)` 其中 `intraFrac` 由命中位置估算:`h.pos / chapterText(...).length`(钳制 0..1)→ 现有 goChapter 的 scrollFraction 机制自然滚动到大致位置 + `setNav(null)`;无结果显示「无结果」;hits 为 null(未搜)显示提示文案。 - 命中处的「短暂高亮」:`goChapter` 后正文无锚点元素可指——**裁定**:以滚动定位为准,不做正文内高亮标记(正文是 `
    ` 纯文本,插标记会破坏选区/进度语义;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 含 `` → 点第二条 → 断言抽屉关闭 + 章计数文本 `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` 重试钮 → `
          
          {!folded && (
            
    {perDay.map((d) => (
    {d.day.slice(8)}
    ))}
    )} ); } ``` 组件测试(fake store 注入):本周文案含时长、连续天数条件渲染、展开/收起(aria-label 翻转 + 柱状图可见性 + localStorage 记忆)、7 根柱。 - [ ] **步骤 16.2:Shelf 接线**:`Shelf.tsx` 主内容区顶部(筛选/搜索栏之下、书网格之上)插 ``。 - [ ] **步骤 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")` → ``(任务 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 档位。