Skip to content

roadmap 3-5 · EPUB 长期阅读验收 —— 交接稿

物理位置~/reading-browser/docs/plans/epub-longform-acceptance-handoff.md(RB 仓,Tauri/Rust/TS) roadmap 条目docs/plans/product-iteration-roadmap-2026h2.md §三 3-5(第三梯队最后一条创建:2026-08-04(由 3-2 收尾会话代写,未动任何代码) 性质:纯 RB 验收任务。零 Supabase、零同步矩阵、零 RVH 交接、零 LLM、预期零 migration


0. 这条任务是什么,以及它不是什么

roadmap 原文(§三 3-5):

把报告 §六 P2 清单转为测试 checklist:全书搜索 / 书签划线 / 排版设置 / 滚动分页 / 脚注图表兼容 / 书库元数据 / 位置恢复 / 复习回跳稳定性只验收,缺什么再立项

  • 交付物 = 一份 checklist + 每项的实测结论(本文件收口时回填 §4 表格)。
  • 不在本次范围:修任何捞出来的缺陷。发现即docs/plans/backlog.md, 按影响面各自立项。唯一例外是「一行文案 / 一个明显笔误」级别的顺手修。
  • 为什么这条值得做:产品已对外发版,EPUB 是两个内容入口之一,却从未被系统性验证过 长文阅读。且这一类缺陷(位置恢复错、划线丢失、脚注错乱)Sentry 抓不到——不崩溃, 只是体验坏。5-6 明确把「EPUB 验收清单(3-5)」列为大版本人工 checklist 的组成部分。

来源报告(P2 清单的出处,需要原文时再读,别整篇灌进上下文): /Users/larry/Documents/Codex/2026-07-21/qin/ReadingBrowser_桌面端产品与竞品分析报告_2026-07-22.md §六


1. 🔴 先读这一节:EPUB 的实现形态与你的预期很可能不同

动手前必须知道的一条:本仓的 EPUB 不是 epubjs 渲染的分页阅读器。

事实证据
epubjs ^0.3.93package.json:38,但全仓零 importrg 'epubjs' src/ → 0 命中(只有 package.json 那一行)。它是未使用依赖
唯一的 Rust 命令是 prepare_epubcommands/epub.rs 里只有这一个 #[tauri::command](约 :357),lib.rs:545 注册
渲染 = 解压后的章节 XHTML 直接进普通 content webviewuseTabsStore.ts:317 addEpubTaburl = cacheUrl(firstChapter.href)rb-cache://),BrowserPage.tsxtype==='epub''web'同一条 webview 生命周期
commands/webview.rs 对 epub 零特化rg epub src-tauri/src/commands/webview.rs → 0 命中

由此可直接推断(但仍需实测确认,别当结论写进 checklist)

  • 「滚动分页」:一章 = 一个网页,只有滚动,没有分页概念。这一项的验收口径应改为 「长章节滚动是否流畅 / 章末能否自然接下一章」,而不是「翻页是否正确」。
  • 「全书搜索」find.js 是 content-script 特性,作用域 = 当前 webview = 当前章节。全书搜索大概率不存在
  • 「位置恢复」MainApp.tsx:255-330 / :461-510 是 epub 协调段,跟踪的是 章节级 epubCurrentIdx + computeNextTocAnchor。章内滚动偏移是否持久化 —— 未确认,必查
  • 「书签划线」:走的是通用 annotations.js + page_annotations 表(按 URL 归属), 而 epub 章节 URL 是 rb-cache://localhost/epub/<pathHash>/OEBPS/...pathHash = sha256(file_path)[..8]epub.rs::path_hash)——文件被移动/改名后 hash 变, 这是划线丢失的一个具体可测假设。
  • 「排版设置」:epub 章节是 rb-cache:// 网页 → 走的是 lib/fontScale.ts / useReadingPrefsStore(reader 字号)还是 domain_zoom(整页缩放)? domain_zoom域名存,rb-cache://localhost 所有 epub 共享同一条记录—— 若走这条,则「给某本书单独设字号」不成立。必查

这些不是缺陷判定,是验收前的假设清单。每条都要用实机 + 窄探针证实或推翻, 再写进 §4。别把推断写成结论——3-2 那轮的教训是推断错三次、白验三个假设。


2. 代码/数据锚点(省掉重新摸索)

前端

  • src/MainApp.tsx:255-330(TOC 注入 + 章节跟踪 + nextTocAnchor)、:461-510(TOC 点击导航)
  • src/stores/useTabsStore.ts:70TabType = 'web' | 'epub' | 'review-source')、:85-86epubInfo / epubCurrentIdx)、:317 addEpubTab
  • src/hooks/useNavigation.ts:103-141(开文件对话框 → prepareEpubaddEpubTablogResourceOpen('epub', …)
  • src/components/TocPanel.tsx(纯展示,onNavigate(href) 回调给 MainApp)
  • src/components/AddressBarReviewSegment.tsx:146第二个 epub 打开入口:复习回跳到 epub 来源) ← 「复习回跳稳定性」那一项的主战场
  • src/pages/BrowserPage.tsx:91/116-145/173(epub 与 web 共用的 webview 显隐/resize)

Rust

  • src-tauri/src/commands/epub.rsprepare_epub(解压到 cache + 解析 OPF/NCX → EpubInfo { title, author, chapters, cache_base, toc })、path_hash(:33,sha256[..8]
  • src-tauri/src/lib.rs:105/139-140(cache-index HTML 里的 "epub" 徽标)、:545(命令注册)
  • commands/{reading,storage}.rs(阅读日志 / 缓存文件落盘)

DB 探针字段src-tauri/assets/sql/schema.sql:230-254

  • reading_pagessource_type'web' | 'epub')、cached_file_pathstorage_pathlast_opened_atdeleted_at;索引 idx_pages_last_opened(user_id, last_opened_at DESC)
  • page_annotations(划线)、resource_history(书库元数据/最近打开)、domain_zoom(缩放)

content-script 特性src-tauri/src/content-script/features/find.js(搜索)· annotations.js(划线)· page-snapshot.js · review.js · highlight.js — 全部按 webview 作用域跑,对 epub 无特化。改任何一个 → 必跑 pnpm build:cs


3. 工作方式(分工铁律,别自己造轮子)

🔴 用户管「到达现场」,MCP 管「检查现场」。

  • 需要「打开某本书 / 进到某一章 / 划一条线 / 按某个键」→ 写成一次做完的编号步骤交给用户, 等一句「好了」,再一次性批量探测。
  • 禁止合成按键/点击去驱动:osascript keystroke 在本机对 dev app 根本不抵达 (2026-08-04 授了 Accessibility 仍无效;System Events click at {x,y} 报 -25204); rb_click / /debug/click 只触发 JS 事件、不改变原生焦点。 硬试的真实代价不是 token 而是误诊(上一轮据此构造了三个错误假设,全部白验)。
  • 需要一本真实 EPUB。开工第一句就问用户要文件路径(或让他直接打开), 别假设 ~/Downloads 里有。长书(≥10 万词、带脚注/插图/表格)才验得出东西。

省 token 的探测配方(比 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-reader-content > h1' --data-urlencode 'webview=content-4' \
  | jq -c '{found:.result.found, n:.result.total_matches}'

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

  • 探测前先确认进程活着:pgrep -f 'target/debug/app' >/dev/null && echo alive || echo DEADapp 不在时先问一句,别推断成「自行退出」(exit 0 + 无 panic 是手动关闭的特征)。
  • selector 要精确到目标小元素;用 :root:has(...) title 做便宜的存在性探针, 别 dump body / main(一次整页 dump 几千 token)。
  • rb_state 必须带 {store:"tabs"} 之类的窄参数;rb_state 读不出 Set 内容
  • rb_db_query 起手 COUNT(*) / LIMIT 5
  • 截图(rb_snapshot)最贵,只在需要人眼判断视觉观感且用户当下不在看时用; 用户在场的视觉判断交给用户。

4. Checklist 骨架(收口时回填「实测结论」列)

每项写清:验收口径(这条到底在验什么)→ 驱动步骤(🙋用户 / 🤖探针)→ 客观断言(DOM/state/DB,不是截图印象)→ 结论(✅ / ❌+已记 backlog / ⚪ 不适用)。

实测会话:2026-08-06。测试书 = ~/Downloads/The City of God, Volume I by Saint of Hippo Augustine (4505).epub (Gutenberg 45304,13 个章节 xhtml,632 处 class="footnote",3 张表,cache hash 4aa9d2707417279a)。 选它的理由:脚注密集 → 唯一能真正压到 #5 的书。dev 期解压缓存落 <repo>/temp/library/epub/<hash>/

#建议口径(按 §1 的形态修正过)结论
1全书搜索⌘F 能搜当前章?跨章?无跨章能力时用户有无可见提示❌ 见 E1
2书签划线划线落 page_annotations;关书重开仍在;书文件改名/移动后是否还在path_hash 假设)🔶 落库/重开 ✅,改名 ❌ 见 E2
3排版设置⌘+/-/0 生效于 epub?走字号还是 domain_zoom是否所有 epub 共享一条 rb-cache://localhost 记录✅ 见 E3(假设推翻)
4滚动分页长章滚动流畅度;章末能否自然接下一章computeNextTocAnchor);章间切换有无跳位/白屏❌ 见 E4 → 🔶 Sprint 1 后:章末有去处了(EPUB tab 的 ‹ › = 上一章/下一章)→ ✅ 2026-08-08 后:跳位已修(B5)→ ✅ 2026-08-12 压测完:最长章 h-6(232KB / 47 屏 / 2792 高亮)用户实测无掉帧、无空白块、无跳动;探针:滚动往返 4–5ms 且管线内外无差、快滚 20 次后目录联动仍精确、高亮落地位移仅 14 逻辑像素。详 backlog 第 11 条
5脚注图表兼容脚注锚点点击能否跳转+返回;图片/表格是否溢出面板宽度;rb-cache:// 相对资源是否 404❌ 见 E5 → ✅ 2026-08-08 后:往返定位(B5)+ TOC 高亮丢失(B6 附带缺陷①)+ 目录不自动滚(②)三条全修 → ✅ 2026-08-12 表格压测完:3 张表(含 6 列×87 行)默认与「200% 字号 + 560px 窄栏」极端下均不溢出、不裁切(scrollX 恒 0)。⚠️ 图片仍未压到 —— 本书除封面 SVG 外 0 张 <img>,需另找带插图的书
6书库元数据标题/作者取到(prepare_epub);resource_history 有行;HomePage「Continue Reading」露不露 epub🔶 见 E6 → ✅ Sprint 1 后:「继续阅读」的 epub 条目可点了(此前整条通路死,= B3);条目粒度问题未动(一章一行,= B9)
7位置恢复关标签/重启 app 后回到哪一层(书?章?章内偏移?);reading_pages.last_opened_at 是否 touch❌ 见 E7 → 🔶 Sprint 1 后书 ✅ 章 ✅ 章内偏移 ❌。章级恢复靠 source_ref 里的锚点(零 migration);章内偏移需新列 → 独立立项。last_opened_at由章节导航 touch(刻意,见 backlog B4)
8复习回跳稳定性从 epub 里存的词去复习 → 回跳(AddressBarReviewSegment.tsx:146)能否落回原章原句;书已移动/删除时的降级🔶 见 E8 → ✅ Sprint 1 后:「打开原书」两种情况(书已开着 / 没开)都实测通过,且会落到那条词的来源章锚点;落到原句仍未做(= B5 定位精度)

另有 6 条不在原 8 项内、但本轮捞到的缺陷 → 见 §4.1「计划外发现」。 其中 N1(开书即死路)与 N4(fragment 未剥,一因三处)的影响面大于表内多数条目。

实测证据(E1-E8,写成可复现形式)

E1 · 全书搜索 ❌

  • 驱动:内容区有焦点时按 ⌘F → 无反应;点主界面(地址栏)后按 ⌘F → 面板开,搜 Rome 显示「第 1 / 共 56 处」。
  • 断言:当前章节文件 h-0Rome 出现 56 次,全书 12 个章节文件合计 188 次 (grep -o -i Rome <每个 xhtml> | wc -l)→ 作用域 = 当前章节,全书搜索不存在。
  • 代码点:find.js:120 createTreeWalker(document.body …) 按单 webview 跑; events.js:110-138 content-script 只桥接 ⌘+/-/0 与 ⌘⌥R,无 ⌘F 桥(对照 useKeyboardShortcuts.ts:54 的注释「Content-webview-focus path comes via the 'zoom-shortcut' event instead」——find 没有对偶)。
  • 两个独立缺陷:① 内容区焦点下 ⌘F 不响应;② 计数「共 56 处」不含作用域说明,读起来像全书。

E2 · 书签划线 🔶(落库 ✅ / 关书重开 ✅ / 改名 ❌)

  • 落库 ✅:划线写 page_annotationsannotation_type='highlight'source_text 对得上正文、 user_id = 当前登录用户),存词写 word_page_links + word_cloze_contexts,全部正确。
  • 关书重开 ✅:关掉书 → 从「最近打开」重开(新 tab、新 webview)→ 导航回 EDITOR'S PREFACE, .rb-annotation 计数 = 2,高亮完整恢复。
  • 改名 ❌(path_hash 假设证实):把 …(4505).epub 改名为 …(4505)-RENAMED.epub 后重新打开, 同一章 .rb-annotation = 0。同一时刻的对照组(仍指向旧 hash 的另一个标签页,导航到同一章) = 2。→ 同页、同探针、同时刻的 A/B,排除探针误差。 机制:epub.rs:33 path_hash = sha256(file_path)[..8] → 改名后 4aa9d2707417279acdcc6a7211a9d56f → 缓存目录与 rb-cache:// URL 全变 → 按来源页归属的 page_annotations 失联。 移动文件(换目录)同理,因为 hash 吃的是完整路径
  • 附带:磁盘泄漏。改一次名就留下一份完整的解压副本(本书 2.1MB),旧目录永不回收。 实测后 temp/library/<uid>/epub/ 下同时躺着 4aa9d2707417279a(2.1M) + cdcc6a7211a9d56f(2.1M)。
  • 未测:改名后若在新副本里存词,「继续阅读」是否出现同一本书的重复条目 (来源页只在存词/划线时才建,本轮没在副本里存词,故无数据 —— 别写成结论)。
  • ⚠️ 本轮实测中途发生一次账号切换,clear_learning_data_if_user_changedauth.rs:196,由 save_session 调用、每次 token 刷新都跑)按 user_id IS NULL OR user_id != <当前> 清空了 12 张表。 这是设计内行为(红线 #5i),不是缺陷known_words 剩下的 211 行 user_id 全部是当前用户 = 清扫器工作正常;reading_log/word_encounters 按 v27/v31 设计不在清理清单里,完好)。 切回原账号后数据经 sync 回灌。下轮复验须在同一账号内一气做完,否则中途的 token 刷新会吃掉证据。

E3 · 排版设置 ✅(§1 的两个担忧都不成立,但代价是另一个)

  • 假设推翻:epub 不走 domain_zoom。⌘+ 之后 domain_zoom 表仍为(0 行)。 代码点:sizeActions.ts:105-112 applySizeShortcuttype==='epub' 路由到 applyFontStepapplyZoomStep:41 硬性要求 tab.type === 'web'。→ §1 里「所有 epub 共享一条 rb-cache://localhost 记录」的担忧不成立
  • 显示态与持久态一致 ✅:药丸显示 110%settings.font_size = 18fontScale.ts 阶梯 13/14/16/18/20/24/32 ↔ 80/90/100/110/125/150/200)。 过程中一度出现「显示 125% 而落库 18」的读数,复验未复现(疑为跨账号切换期间的读数错位), 故不作为缺陷记录。
  • 真正的代价:字号是全局的(useReadingPrefsStore.fontPxsettings.font_size, reader 与所有 EPUB 共用一个值)。「给某本书单独设字号」同样不成立 —— 只是坏在字号维度而非缩放维度。

E4 · 滚动分页 ❌(口径已按 §1 改为「章末衔接」,结论是根本没有衔接)

  • 章末没有「下一章 / Next」按钮或任何续读入口(实测)。一章 = 一个网页,滚到底就是尽头, 用户必须回目录才能继续 —— 而目录默认不开(见 N1)。
  • 全仓无任何翻章 UIEpubNavBar 已删除,computeNextTocAnchor 唯一消费者是 reader.js:504(reader 模式下的正文切片),不驱动界面。
  • 章间切换本身正常(TOC 点击跳转即时、无白屏);跨文件锚点定位失准的问题归在 E5。
  • 未压测:超长章的滚动流畅度(本书最大单章 xhtml 约 2MB)。下轮补时建议直接用 h-1.htm.xhtml(全书最长、Rome 出现 104 次那一章)连续滚到底计时。

E5 · 脚注图表兼容 ❌(往返定位坏了)2026-08-08 已修(B5+B6),本节三处结论按下方 ⚠️ 修正

⚠️ 复测(2026-08-08)推翻了本节的三条判断,as-built 见 epub-anchor-precision-handoff.md

  1. 「正文点 [1] → ✅ 正确落到 h-8」不成立 —— 去程同样偏(实测 top=1072 ≈ 1 屏)。 偏移量正比于锚点在文件里的深度,去程恰好浅所以当时没察觉。两个方向都坏。
  2. 归因错:位移里整页重排版占 98.7%loadSettingsrb-cache:// 页套 680px 窄栏 + Georgia + line-height:1.8 + 字号 + padding,文档高度 +85%), CEFR/短语高亮只占 1.3%(32px)。原文并列的「高亮注入回流 / 字体 settle」两个候选 都不是主因,那个判别步骤不必做。
  3. 附带缺陷①的机制描述准确,但「URL hash 是 Footnote_1_1」这点值得强调—— MainApp 当时的注释写「cross-file 导航 URL 通常不带锚点(webview 滚完就剥)」是错的, 实测 fragment 一路带到 navigation-state-changed。B6 从来不是"没 hash",是"hash 不是目录锚点"。
  • 脚注结构(源数据):正文 [1] = <a class="fnanchor" href="…h-8.htm.xhtml#Footnote_1_1">跨章节文件;h-8 的条目里有回链 <a href="…h-0.htm.xhtml#FNanchor_1_1">。源支持完整往返。
  • 实测:正文点 [1]✅ 正确落到 h-8 的第 1 条脚注; 点脚注回链 → ❌ 落到 h-0 的 CONTENTS 表中部(BOOK VI-VIII 一带),距目标约 2 屏,需手动下滚
  • 机制(已定位到对称性差异,未再细分):同文件锚点跳转走 MainApp.tsx:475-481evalInWebview(… scrollIntoView …)——页面早已 settle,定位准;跨文件跳转(脚注链接 与 MainApp.tsx:505 navigateTo)走原生导航 + 浏览器原生锚点滚动,发生在 content-script 注入 CEFR 下划线 span / 字体 settle 之前,锚点上方内容随后变高 → 视口停在目标上方。 未细分是「高亮注入回流」还是「字体 settle」——立项时用「关掉 CEFR 高亮再走一遍」判别。
  • 附带缺陷 ①:落到 h-8 后左侧 TOC 高亮整个消失MainApp.tsx:317-321base === spineBase && hash === urlHash 找 TOC 条目;URL hash 是 Footnote_1_1, 而 TOC 里 FOOTNOTES 条目的 hash 是 pgepubid00575 → 匹配失败 → 回落 spineHref不带 #) → TocPanel.tsx:45item.href === currentHref 全不命中 → 无任何高亮。
  • 附带缺陷 ②:TocPanel.tsx 全文scrollIntoView——即使高亮正确,长目录(本书 35 条) 里当前条目在折叠线以下时用户也看不到。
  • 图/表:本书正文无内嵌图(仅 1 张封面,wrap0000.xhtml<svg><image> 包裹,渲染正常); 未压到表格溢出场景。rb-cache:// 相对资源未见 404。
  • ⚪ 非缺陷:TOC 标签里的 BOOK FOURTH.[155] / BOOK FIFTH.[183] 尾巴是 Gutenberg 源 NCX 自带grep '<text>BOOK F' toc.ncx 原样如此),不是我们解析引入的。 另注:本书 toc.ncx 共 579 个 <text>,其中 526 个是 [Pg NNN] 翻页锚点垃圾, 我们的解析器只吐 35 条,说明过滤是有效的。

E6 · 书库元数据 🔶

  • prepare_epub 解析 ✅:title="The City of God, Volume I"author="Saint of Hippo Augustine"、 12 个 spine chapters、35 条 TOC。
  • HomePage「继续阅读」露出 epub ✅,但两个问题: ① 粒度是不是书——同一本书出现多行(实测同时有 … - EDITOR'S PREFACE.… - BOOK FIRST.), getContinueReading(5) 只取 5 条(HomePage.tsx:242),长书会把其它来源挤出榜单; ② 点击静默 no-op(见 E7)。
  • resource_history.title 存的是文件名useNavigation.ts:126filePath.split('/').pop()) 而非已解析出的 epubInfo.title —— 列表里显示带 .epub 后缀的文件名。

E7 · 位置恢复 ❌(一层都不恢复)

  • 「继续阅读」点 epub 条目 = 静默 no-op。根因:HomePage.tsx:482source_ref 原样传给 onOpenFile,而 source_ref 带锚点(/Users/…/xxx.epub#pgepubid00010); useNavigation.ts:110 的守卫 !filePath.toLowerCase().endsWith('.epub') → 直接 return (只留一条 logger.warn)。对照:Rust 侧 classify_source_type 明确写了「Strip fragment first so /book.epub#ch4 still matches」,TS 侧漏了这一步
  • 日志直证(决定性):点击瞬间控制台留下 [warn] [RB] Only EPUB files are supported: /Users/…/(4505).epub#pgepubid00010 —— 带锚点,只可能来自 HomePage.tsx:482(「最近打开」传的是 resource_history.uri,干净路径)。 全程没有 Failed to open file,说明 prepareEpub 从未被调用,不是解压/路径问题。
  • 断言(关键,避免被假象骗):点击前后 tabs state 完全不变——activeTabId 仍是原 tab、 lastActiveAt 一秒未动、没有新 tab 产生。若真的打开了,openFileByPath 会先关掉当前 tab、 再建一个新 id 的 tab。⚠️ 视觉上会"像是跳转成功了",因为 MainApp.tsx:825-828onOpenFilesetActiveModule('read') 再调 openFileByPath —— 模块切过去了、书没开,屏幕上显示的是 本来就开着的那个标签页。点「EDITOR'S PREFACE」和点「BOOK FIRST」画面完全相同 = no-op 指纹。
  • 对照:「最近打开」那条(entry.uri = 干净路径)能正常打开 ✅ —— 同一个 openFileByPath,差别只在有没有锚点。两条列表项外观极像,区别只在标题文案 (继续阅读带章节名 … - EDITOR'S PREFACE.;最近打开是带 .epub 后缀的文件名)。
  • 即使走通的路径也不恢复位置:useTabsStore.ts:325 addEpubTab 永远取 epubInfo.chapters[0] = Gutenberg 的 wrap0000.xhtml = 纯封面(实测落封面)。
  • 章内滚动偏移从未持久化(全仓无相关写入)。
  • MainApp.tsx:278if (url && tab.type !== 'review-source' && tab.type !== 'epub') 让 epub 跳过 logResourceOpen——即章节导航不刷新 resource_history / 不 touch reading_pages.last_opened_at; 这两处的行只在存词/划线(ensure_source_for_url)时才产生。
  • 结论:书 / 章 / 章内偏移三层全不恢复,且唯一露出「续读」承诺的入口本身是断的。

E8 · 复习回跳 🔶(右页 ✅ / 打开原书 ❌)

  • ✅ 落回原章原句:复习卡右页正确渲染 EDITOR'S PREFACE 并高亮目标语境。 但它走的不是 epub 缓存 —— reading_pages.cached_file_path = web/28ce7a0843385148.html, 是存词时打的页面快照(epub 章节被当普通页快照了一份)。
  • ✅ 书移动/删除时的降级:因为复习右页只依赖快照,完全不依赖原 .epub 文件是否还在 —— 交接稿问的这个子问题答案是「降级良好」。
  • ❌ 「打开原书」📂 按钮完全无反应,连错误日志都不留。根因是 source_url = rs.source_refsrs.rs:312-319 的 SELECT 直取),#pgepubid00010 锚点,于是 AddressBarReviewSegment.tsx 两条路一起失手:
    • :139 store.tabs.find(t => t.filePath === activeUrl) —— tab.filePath 是干净路径 → 永不匹配, 即使那本书正开着也切不过去
    • :145 activeUrl.toLowerCase().endsWith('.epub') → false → 整个 try 块跳过 → logger.error 无从触发(这就是"静默"的来源)。

4.1 计划外发现(不在原 8 项口径内,但同轮捞到)

N1 · 开书即死路 🔴(影响面大于表内多数条目) —— 三条独立事实叠加:

  1. addEpubTab 永远落 chapters[0],Gutenberg 的 spine[0] 是 wrap0000.xhtml = 纯封面<div class="x-ebookmaker-cover"><svg><image></svg></div>,无正文、页高=视口高、滚都滚不动);
  2. 全仓唯一能打开目录的入口是 AddressBar.tsx:381-386 那个列表图标,开书时不会自动打开
  3. 没有任何「下一章」UI —— EpubNavBar 组件已删(MainApp.tsx:250 注释尚存), computeNextTocAnchor 现在唯一消费者是 reader.js:504,用途是从多章合一的 xhtml 里切正文,不驱动 UI。

→ 新用户打开一本书:看到一张封面图,滚动无反应,没有下一章,没有目录, 唯一出路是发现地址栏里那个刚刚才出现的第 4 个图标。 修法候选:① 开书时 openLeftPanel('toc');② addEpubTab 跳过纯封面 spine 项;③ 章末补续读入口。 倾向 ①+③(② 要小心:不是所有书的 spine[0] 都是封面)。

N2 · TOC 间歇性空白且不自愈 🔴 —— 实测出现过一次:tab 里 epubInfo.toc 有 35 条、 chapters 12 条,而面板显示「暂无目录」。判别实验:新建一个空白标签页再切回来 → 35 条立刻出现。 根因在 MainApp.tsx:270-279 的 effect 只依赖 [activeTabId]

js
const tab = useTabsStore.getState().getActiveTab();
if (tab?.type === 'review-source') return;        // ← 早退不写 tocItems

openFileByPathcloseTab()addEpubTab() 之间隔着 await prepareEpub(filePath) (真实异步边界,React 会在中间提交渲染)。中间那次提交若让 getActiveTab() 落在 review tab 上 走了早退分支,tocItems 就停在空数组,此后不会自愈(它不认 tab 内容变化,只认 activeTabId 变化)。 配合 N1(目录是 EPUB 唯一导航入口)→ 这本书彻底打不开正文,只能关掉重开碰运气。

N3 · TOC 被右面板驱逐后不恢复 —— usePanelStore.ts:148/170openRightPanel/openTools 显式写 leftPanel: null(「单辅助面板互斥(3-3)」),而 closeRightPanel:149 只清右侧、不恢复左侧。 于是每次 ⌘F 都要手动重开目录。 判断:TOC 与其它左面板不是一类东西 —— Sites/History/RSS 是瞬时导航(点完就走),自动弹回是噪音; TOC 是文档结构框架,读书时半常驻。Kindle / Apple Books / 浏览器 PDF 阅读器都不会让查找框吃掉目录侧栏。 修法(窄):加 preemptedLeftPanel只在被右面板抢占时记录、只对 toc 生效, closeRightPanel 时恢复,期间用户手动动过任何左面板就清空。 (store 里已有 lastLeftPanel/openLastLeftPanel 可参照形状,但那个服务 ⌘B,语义不同,别直接复用。)

N4 · source_ref 的 fragment 从不剥离 —— 一个根因,三处断路 🔴reading_pages.source_ref 对 EPUB 存的是 /Users/…/book.epub#pgepubid00010(带锚点)。 Rust 侧 classify_source_type 明确写了「Strip fragment first so /book.epub#ch4 still matches .epub」, TS 侧三个消费点都漏了这一步

位置后果
useNavigation.ts:110(经 HomePage.tsx:482 传入)「继续阅读」→ EPUB 整条通路死,且被自动模块切换伪装成成功
AddressBarReviewSegment.tsx:139复习「打开原书」找不到已开着的同一本书
AddressBarReviewSegment.tsx:145复习「打开原书」也开不了新的,且静默无日志
统一修法:抽一个 stripFragment(path)(或复用 Rust 侧同名语义)在这三处入口先剥再判。

✅ 2026-08-07 已修(Sprint 1,as-built 见 epub-usability-sprint1-handoff.md §6)。 落地时多出一处:三处剥完 fragment 后,复习 📂 仍然点了没反应 —— AddressBarReviewSegment.handleOpen 从不切 workspace 模块,tab 在 store 里 active 了、 画面还停在复习卡上(实测 activeTabId:"2" / activeModule:"review")。已补 setActiveModule('read')。 上表第一行「被自动模块切换伪装成成功」只对「继续阅读」那处成立MainApp.tsxonOpenFile 包了 setActiveModule('read'));复习那处恰恰相反 —— 是根本不切。两者方向相反,写成同一句会误导。

N5 · 改名/移动 → 划线全丢 + 孤儿缓存不回收(证据见 E2)。 两半可以分开修:归属可改为按「文件内容指纹或稳定书标识」而非路径 hash;缓存回收可加 LRU/启动清理。

N6 · 「最近打开」把裸 URL 当标题显示 —— 列表里出现 rb-cache://localhost/text/b9bf3198-25f2-45a1-b57b-e73a7c9ac1e8.html(text 来源,非 epub)。 同一区块里 EPUB 条目显示的是.epub 后缀的文件名而非已解析出的书名(见 E6)。 两者同属「resource_history 的 title 取值不讲究」。


5. 顺手可以在同一次实机里收掉的东西

roadmap 5-5 尾巴docs/plans/backlog.md §🟡测试线收尾): docs/smoke-test-runbook.md 的断言层 2026-07-22 已实跑校准,待固化两件事—— ① 复习入口选择器、② 写驱动步骤(双击存词 / 评分)。 runbook 的 6 条核心链路里本来就有一条 「EPUB→位置恢复」,与本任务 #7 完全重合。 既然 dev app 已经开着、用户已经在手动驱动,顺手把这两处回填掉,5-5 就能收口 (之后挂 /release Step 1 前)。这是本次会话的可选第二交付物,不要因它挤掉 3-5 本体。


6. 收口动作

  1. 回填本文件 §4 全部 8 行 + 每项的断言证据(写成可复现的形式,下个版本能照着再跑一遍)。
  2. 捞出的缺陷 → docs/plans/backlog.md 各自立项(写清现象 + 已定位到的代码点 + 触发条件, 别只写「XX 不好用」)。
  3. docs/plans/product-iteration-roadmap-2026h2.md 3-5 标 ✅ + 链本文件; §进度行补一句(第三梯队至此全绿)。
  4. 若同时收了 5-5:更新 docs/smoke-test-runbook.md + backlog 那条 + roadmap 5-5 行。
  5. /doc-sync-check → commit(git add 只给具体文件路径,禁止 -A、禁止裸目录)。

7. 环境/纪律速查

  • 本仓 = ~/reading-browser(Tauri + React + Rust)。RVH(~/reading_vocab_helper)本任务零涉及; 真需要碰 → 新会话。
  • 红线 #11:schema.sql / v1 已冻结,新表新列只进新编号迁移,下一个从 v32 开始。 本任务预期不产生 migration——若发现「位置恢复需要新列」,那是立项,不是本次动手。
  • 红线 #2 无 LOWER()(用 COLLATE NOCASE)· #3 content-script 用 __TAURI_INTERNALS__.invoke() · #9 写 word 字段前过 lemmatizer::normalize()
  • content-script 是打包产物:改完 pnpm build:cs;grep 该 bundle 要 grep -a(否则被当二进制、静默 0 命中)。
  • 禁止无 pathspec 的 git reset --hard / git checkout . / git stash push (会抹掉整棵树的未提交改动,不看文件域)。
  • 动手前 git status + git worktree list;检查有无上一轮遗留的 pnpm tauri dev 孤儿进程 (两个 watcher 抢同一个 target/ 会互相打断)。worktree 删后 target/ 仍缓存其绝对路径 → 构建报 Info.plist 找不到时,touch src-tauri/build.rs 强制 build script 重跑即可,不必 cargo clean
  • 工具链完整配方见 docs/plans/archive/read-tier-followup-handoff.md §5(那份的 §5 是通用资产)。