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