Skip to content

EPUB 可用性 Sprint 1 —— 交接稿

物理位置~/reading-browser/docs/plans/epub-usability-sprint1-handoff.md(RB 仓,Tauri/Rust/TS) 上游:roadmap 3-5 EPUB 长期阅读验收(2026-08-07 完成,commit d973d2a创建:2026-08-07(由验收会话代写,未动任何代码) 性质:纯 RB 修复任务。零 Supabase、零同步矩阵、零 RVH、预期零 migration


0. 先读这两份,别重新调查

文件里面有什么
~/reading-browser/docs/plans/epub-longform-acceptance-handoff.md §4 + §4.1每条缺陷的实测证据(可复现形式)+ 代码锚点 + 已排除的错误假设
~/reading-browser/docs/plans/backlog.md §🔴 EPUB 长期阅读 9 条9 条的立项条目(B1-B9),本 sprint 只做 B1-B4

证据已经很足,不需要再跑一遍验收。 直接进入修复。若某条的机制描述与你读到的代码对不上, 以代码为准并回头修正那两份文档(它们是活文档)。


1. 本 sprint 的范围:B1 + B2 + B3 + B4(部分)

选这 4 条打包,是因为它们共同构成「打开一本书之后能不能走下去」这一件事, 单修任何一条都不解决问题:

条目一句话为什么在同一个 sprint
B1 开书即死路落封面 + 目录不自动开 + 无「下一章」三条叠加 = 走不下去,本身就是复合缺陷
B2 TOC 间歇性空白且不自愈tocItems effect 依赖只有 [activeTabId]目录是 EPUB 唯一导航入口,B1 修了它还空 = 白修
B3 source_ref fragment 从不剥离(一因三处)「继续阅读」→EPUB 死;复习「打开原书」死进书的入口本身是断的
B4仅「恢复到章」addEpubTab 永远落 chapters[0]与 B3 纠缠:入口通了才谈得上恢复到哪

明确排除(不要顺手做)

  • B4 的「章内滚动偏移持久化」 —— 需要新列 → 触发红线 #11(schema.sql/v1 已冻结, 新列只进新编号迁移,下一个从 v32 开始)。这是独立立项,不在本 sprint。 本 sprint 的 B4 只做到「重开落回上次那一章」,用已有的 reading_pages.source_ref 里的锚点即可, 不需要任何 migration
  • B5-B9(锚点定位失准 / TOC 高亮丢失 / 全书搜索 / 改名丢划线 / 列表元数据)→ 各自独立会话。 B5+B6 是「导航精度」一组,B7-B9 是打磨。

2. 四条的修法建议(不是命令,你可以推翻,但推翻要说理由)

B3 最先做 —— 它是另外三条的地基,且必须三处一起改

reading_pages.source_ref 对 EPUB 存的是 /Users/…/book.epub#pgepubid00010。 Rust 侧早就处理了(commands/notes.rs:236 classify_source_type 的注释 「Strip fragment first so /book.epub#ch4 still matches .epub」; notes.rs:293 extract_domain 同样剥,好让 #ch0/#ch1 共用一条域名笔记规则)。 TS 侧没有任何同类 helper(全仓只有 SentenceWorkbench.tsx:67 一处手搓 split('#')[0])。

三处漏点:

位置现在的后果
src/hooks/useNavigation.ts:110(经 src/pages/HomePage.tsx:482 传入)「继续阅读」→ EPUB 整条通路死
src/components/AddressBarReviewSegment.tsx:139复习「打开原书」找不到已经开着的同一本书
src/components/AddressBarReviewSegment.tsx:145也开不了新的,且 try 块未进入 → 静默无日志

建议:在 src/lib/url.ts(已存在)加一个 stripFragment(path),三处统一调用。 一次改完三处——只改一处会让另外两处变成更难发现的残缺态。

⚠️ 验证这条时有个陷阱MainApp.tsx:825-828onOpenFilesetActiveModule('read') 再调 openFileByPath,所以即使打开失败,屏幕也会切到阅读模块并显示本来就开着的那个标签页, 看上去像成功了。必须断言 rb_state store=tabs:真打开会先关掉当前 tab、再产生一个新 id 的 tab, 且 lastActiveAt 会变。别信屏幕。

B2 次之 —— 一行依赖的问题,但会让 B1 的修复白费

src/MainApp.tsx:270-279

js
useEffect(() => {
  const tab = useTabsStore.getState().getActiveTab();
  if (tab?.type === 'review-source') return;      // ← 早退,不写 tocItems
  if (tab?.type === 'epub' && tab.epubInfo?.toc?.length) { setTocItems(...) }
  else { setTocItems([]); }
}, [activeTabId]);                                 // ← 只认 id 变化

openFileByPathcloseTab()addEpubTab() 之间隔着 await prepareEpub(filePath) (真实异步边界,React 会在中间提交渲染)。中间那次提交若让 getActiveTab() 落在 review tab 上 走了早退分支,tocItems 就停在 []永不自愈(它不认 tab 内容变化)。

建议:依赖补上 tab 的 epubInfo 身份(例如 [activeTabId, activeTab?.epubInfo]), 或在 addEpubTab 之后显式同步一次。注意早退那一支——它现在是"什么都不写", 可能需要改成"保持原值"或索性移到判断之后。

B1 —— 三条子改动,建议 ①+③,② 慎重

  1. 开书时自动打开目录openFileByPath 成功后 usePanelStore.getState().openLeftPanel('toc')。 最小、最直接。
  2. addEpubTab 跳过纯封面 spine 项src/stores/useTabsStore.ts:325): ⚠️ 慎重——并非所有书的 spine[0] 都是封面。若做,判据要基于内容 (如「无文本节点 / 只有一个 <svg><image>」)而不是文件名或索引。 本轮实测的 Gutenberg 书 spine[0] = wrap0000.xhtmlbody 只有一个 div.x-ebookmaker-cover > svg > image
  3. 章末补续读入口computeNextTocAnchor 已经在算下一章锚点了(现在只喂给 content-script/features/reader.js:504 做正文切片,不驱动任何 UI)。 EpubNavBar 组件已被删除(MainApp.tsx:250 注释尚存),要重新做一个落点。

B4(仅恢复到章)

useTabsStore.ts:325 addEpubTab 永远取 epubInfo.chapters[0]。 打开一本书时,可查 reading_pages 里该 source_ref剥掉 fragment 后匹配文件路径) last_opened_at 最新的那一行,用它锚点里的章节作为初始章。

  • 不需要 migration —— 数据已经在 source_ref 里。
  • 注意 MainApp.tsx:278 让 epub 跳过 logResourceOpen,所以 reading_pages 的行 只在存词/划线时才产生(经 ensure_source_for_url)。没存过词的书没有行 → 优雅回落到第一章。 要不要顺带让 epub 章节导航也 touch last_opened_at,是本条要做的一个判断(做了会让恢复更准, 但会让「继续阅读」列表更吵——见 B9 的粒度问题,别把 B9 一起做了)。

3. 工作方式(沿用验收会话的分工,已验证有效)

🔴 用户管「到达现场」,MCP 管「检查现场」。 需要人操作时写成一次做完的编号步骤交给用户, 等一句「好了」再批量探测。禁止 osascript 合成按键(本机对 dev app 不抵达,硬试会导致误诊)。

已实测固化的选择器 / 能力边界(细节见 docs/smoke-test-runbook.md §实跑校准记录 2026-08-07):

能做 ✅怎么做
点主 webview 里的 React 按钮/debug/click(合成 click 会被 React 委托监听接住)
进复习模块nav[aria-label] button:nth-of-type(3)(位置型,不吃 locale)
点 TOC 条目[class*="space-y-0"] > *:nth-child(N)(先用 rb_domspan 确认落点)
切标签页[title*="<书名片段>"](同名多标签用 :first-of-type
数划线内容 webview 里的 .rb-annotationannotations.js:14 MARK_CLASS
做不到 ❌(别再试原因
打开本地 .epubmacOS 原生文件面板,合成事件碰不到;地址栏也不接受本地路径(useNavigation.ts:78 一律按 URL 处理)
双击存词 / 选区划线需要真实 Selection 与 dblclick,/debug/click 只发单次 click

探测配方(比 MCP 工具便宜一个量级):

bash
P=$(cat "$TMPDIR/rb-debug-port")   # 注意是 $TMPDIR,不是 /tmp
curl -s --get "http://127.0.0.1:$P/debug/dom" \
  --data-urlencode 'selector=.rb-annotation' --data-urlencode 'webview=content-5' \
  | jq -c '{found:.result.found, n:.result.total_matches}'

路由:/debug/{snapshot,snapshot/tab,logs,state,dom} (GET)、/debug/{click,type,scroll,db/query} (POST)。

  • /debug/logs 返回 {capacity, console:[{kind,level,msg,source,ts}]} —— 验证 B3 时这里是金矿 (失败会留 [warn] [RB] Only EPUB files are supported: …#pgepubid00010)。
  • /debug/db/query只读的,写不了。
  • rb_state 读不到 readingPrefs store(未暴露);可读的有 auth/library/language/panel/review/rss/stats/tabs/theme/translation/workspace

⚠️ 测试数据卫生auth.rs:196 save_session 每次 token 刷新都会跑 clear_learning_data_if_user_changedWHERE user_id IS NULL OR user_id != <当前>,清 12 张表)。 这是设计内行为(红线 #5i),但意味着实测期间不要切账号,否则中途的刷新会吃掉你的证据。


4. 验收标准(改完怎么算过)

  1. docs/smoke-test-runbook.mdFlow 4 从头到尾走通(它已按 3-5 结论重写过,断言是真实的)。
  2. 四条各自的最小验收:
    • B1:打开一本没读过的书 → 能直接开始读(不需要先发现地址栏第 4 个图标);章末有去处。
    • B2:连续开关同一本书 5 次,目录每次都有内容(这条是间歇性的,单次通过不算数)。
    • B3:点「继续阅读」的 EPUB 条目 → rb_state store=tabs 出现新 id 的 tab(别信屏幕); 复习卡的 📂 在「书已开着」和「书没开」两种情况下都要验。
    • B4:存过词的书重开落回那一章;没存过词的书优雅落第一章、不报错。
  3. /build-check + /arch-check;若动了 .tsx 再跑 /ui-check
  4. 若改了 content-script(B1 的 ③ 可能会):必须 pnpm build:cs(它是打包产物, tauri dev 不跑打包器);grep 该 bundle 要 grep -a
  5. 改完回填:backlog.md 的 B1-B4 条目标 ✅ + 链 as-built; epub-longform-acceptance-handoff.md §4 对应行的结论更新(它是那 8 项的真相源)。

5. 环境/纪律速查

  • 本仓 = ~/reading-browserRVH(~/reading_vocab_helper)本任务零涉及;真需要碰 → 新会话。
  • 红线 #11:schema.sql / v1 已冻结,新表新列只进新编号迁移,下一个从 v32 开始。 本 sprint 预期零 migration —— 若你发现"非加列不可",那是立项信号,先停下来说,别顺手加。
  • 红线 #2 无 LOWER()(用 COLLATE NOCASE)· #3 content-script 用 __TAURI_INTERNALS__.invoke() · #8 组件内禁止 hex 字面量(用 lib/design-tokens.ts)· JSX 中禁止中文字面量(走 src/lib/strings/)。
  • 禁止无 pathspec 的 git reset --hard / git checkout . / git stash pushgit add 只给具体文件路径,禁止 -A、禁止裸目录。
  • 动手前 git status + git worktree list;检查有无遗留的 pnpm tauri dev 孤儿进程 (两个 watcher 抢同一个 target/ 会互相打断)。

6. As-built(2026-08-07 收口)

B1 / B2 / B3 全部闭环;B4 只做「恢复到章」(如 §1 计划)。零 migration,schema.sql 未动。

改了什么

文件改动
src/lib/url.ts新增 stripFragment / fragmentOf(B3 的共用地基)
src/lib/epubStart.ts新文件resolveEpubStartHref —— 「这本书从哪开始读」的唯一决策处
src-tauri/src/commands/epub.rsEpubInfostart_index;新增 visible_text_len / first_content_spine_index(B1②);新增命令 get_epub_last_read_ref(B4)
src-tauri/src/lib.rs注册新命令
src/lib/commands.tsEpubInfo.start_index + getEpubLastReadRef
src/stores/useTabsStore.tsOpenPayload.epubstartHrefpatchFor 按它算 url + spine 下标
src/hooks/useNavigation.tsopenFileByPath 先剥 fragment(B3)+ 解析起始章(B4)+ 成功后开目录(B1①)
src/components/AddressBarReviewSegment.tsx剥 fragment(B3 两处)+ 起始章 + setActiveModule('read')
src/MainApp.tsxTOC effect 依赖补 activeEpubInfo(B2);给 AddressBar 传 currentTocHref / onTocNavigate
src/components/AddressBar.tsxEPUB tab 上 ‹ › 改成上一章/下一章(B1③)
src/lib/strings/{panels,locales/{en,zh}/panels}/addressBar.ts4 条章节导航文案

三件与交接稿预期不同的事(后来者别按原稿走)

  1. B3 是四处不是三处。 前三处剥完 fragment,复习 📂 仍然「点了没反应」—— handleOpen 从不切 workspace 模块。复习是独立 module,只设 active tab 用户看不见 (Library 的「回原文」早就显式处理了,vocab/WordMemorySection.tsx::openSource)。

  2. §2 里 B3 的验证配方已过期,按它断言会得到假阴性。 原文说「真打开会先关掉当前 tab、 再产生一个新 id 的 tab」——那是 2026-08-06 收口前的行为。现在 openContent 见到空白 tab 会就地复用useTabsStore.ts::canOpenInPlace),tab id 不变。 正确断言:tabs 里出现 type:'epub'filePath = 该书路径的 tab。

  3. B2 不是「间歇性」,是确定性的。 原稿归因于 closeTab()/await prepareEpub() 之间的竞态, 但那条路径 2026-08-06 已不存在。真机制就是上一条的推论:空白 tab 就地复用 → activeTabId 不变 → 只依赖 [activeTabId] 的 effect 根本不触发。 判据:进 Read 模块自动建的那个空白 tab 还活着时开书,目录必空。

实测结论(全部经 /debug 探针断言,非看屏幕)

条目证据
B1①两次开书后 panel.leftPanel = "toc"
B1②磁盘直读:spine[0]=OEBPS/wrap0000.xhtml 可见字符 0spine[1]=157,403 → 落 epubCurrentIdx:1
B1③EPUB tab 上 ‹ › 的 aria-label 命中可用态(此前对 epub 恒 disabled)
B2走通确定性失败路径(activeTabId 全程为 "2" 未变),目录 35 条
B3① 继续阅读传入带 #pgepubid00010 的 source_ref → 开书成功,filePath 落成干净路径,无 Only EPUB files are supported warn
B3② 书已开着activeTabId 切到既有 tab,未新建;补模块切换后画面跟着走
B3③ 书没开新建 tab(id 3),落点 #pgepubid00010,无 error
B4 命中库里两行(08-07 #pgepubid00010 / 08-05 #pgepubid00021),取到新的那行
B4 回落Moby Dick(无 reading_pages 行)→ epubCurrentIdx:1、url 不带 #(= 走 start_index 而非查库)、目录 146 条、无 error

cargo test --lib 127 passed(含新增 epub::start_index_tests 5 条:SVG 封面 / head title 不计 / script·style 整块丢弃 / 真章节过线 / 中文按字符不按字节)· pnpm test 120 passed · cargo check + pnpm build 通过 · /arch-check H1-H6 零命中、S24=7 S25=0 S27=3 全持平基线 · /ui-check U1=2(永久豁免)U2=U3=0。未改 content-script,故无需 pnpm build:cs

留下的东西

  • B4 章内偏移:需新列 → 独立立项(红线 #11,下一个迁移从 v32 起)。
  • TOC 不带锚点的书source_ref 退化成纯路径、还原不出章节,只能落 start_index。同上立项。
  • 刻意没做:让 epub 章节导航 touch last_opened_at。会让章级恢复更准,但改变「继续阅读」 排序行为 = B9 的粒度问题。
  • 一条待观察的 warn本 sprint 引入,1/14 条日志): [RB] effectiveSourceUrl: rb-cache:// without review/epub metadata —— 来自复习 webview 加载卡片缓存快照页时 __RB_REVIEW_MODE 尚未 eval 到位(content-script/index.js 注明的既有时序竞态)。 本 sprint 不碰复习 webview 的导航与 meta 注入。要追的话它属于那族时序问题,不属于 B1-B9。