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

67 KiB
Raw Permalink Blame History

阅读器改版(②)实现计划 / 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

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:验证
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
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

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
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
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 略降):

[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(文件末尾)
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 绿(本任务无新消费点,编译即验证)。

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 绿。

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 设计(先写进文件头注释再实现)

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 绿 →

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
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
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 绿 →

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 顶部,双语):

- 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:先写失败单测,覆盖:

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
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
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,此处新组件独立实现):
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):

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 整体迁走):

<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 关闭)。

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 覆盖层由调用方包):

{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>:

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 文案:

- 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)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。
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 全绿。

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:先写失败单测,覆盖:

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
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
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 顶部,双语):

- 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.
- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。
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(精确内容,搜索断言依赖它)

第一章 样本

这是第一章的正文,包含标记词 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 绿。

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 创建后:

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 顶部,双语):

- 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 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。
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 翻页/首末页/键盘/书签/进度恢复。

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:先写测试)

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 顶部,双语):

- 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 日柱状图);打开书籍期间在设备本地记录阅读时长。
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:验证

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 条目,双语)
- 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 与组件。
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)

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 + 汇报
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 档位。