Skip to content

模块状态保持 + 「首次遇见于」进回顾

物理仓库:/Users/larry/reading-browser(RB 桌面端) 创建:2026-08-11 · 状态:✅ 已完成(2026-08-12,M1-M7 全部落地,见文末「as-built」) 缘起:用户补充需求(2026-08-11)——「其他模块比如 review、词汇与笔记也应该保存状态」 姊妹计划:data-hygiene-identity-plan.md · popup-and-entry-points-plan.md


0. TL;DR

用户从「生词本『首次遇见于』想跳进回顾模块,且要能返回」这个具体需求,推广出一条 通用原则:模块切换应保持状态。核查确认这个直觉对,且Review 那侧的现状比 "没保存状态"更糟——它是主动销毁

模块现状判定
Readtabs 在 useTabsStore、webview 不销毁✅ 已保持
LibraryuseLibraryStore 存了 tab/search/filter;但 page / selectedDetailId / entries 是组件 local useState🟡 保持了一半
Review切走即 endSession()主动清空 currentCard / positions / isRevealed / revisitNoteId🔴 丢用户进度

建议确立的统一原则模块切换只换视图,不销毁状态;销毁只由用户显式动作触发。 定下这条,以后新模块不必逐个讨论。


1. Review:endSession 与「切模块」解绑(本计划优先级最高

1.1 现状是主动销毁,不是"忘了保存"

ActivityBar.tsx:152-157

js
const fromReview = useWorkspaceStore.getState().activeModule === 'review';
if (m !== 'review' && fromReview) {
  useReviewStore.getState().endSession();     // ← 离开 review = 立刻销毁会话
}

endSession()useReviewStore.ts:148-178)会清空 currentCard / positions / activePositionIdx / isRevealed / sessionFilter / reviewedCount / activityMode / revisitNoteId

体感:复习到第 7 张卡 → 想去查点东西 → 回来从头开始

1.2 这个矛盾已经被打过三次补丁,根因没动

补丁位置注释原话
Settings/Account 改弹 Modal 逃逸(Item C)ActivityBar.tsx:148「switchModule→endSession 会丢当前会话快照/进度
复习卡「打开原文」只切 module 不动会话AddressBarReviewSegment.tsx:140-141「只切 module 不动 isInFocusSession:复习会话原样留着……不会因为看了眼原文就把进度清掉」
endSession 里 activeTabId 回落 '' 的守卫useReviewStore.ts:159-1662026-08-06 实测「点阅读卡住」

三处各自绕过,说明问题早被识别,只是当时选择了绕行。 本计划正面解决。

1.3 改法

  1. ActivityBar.switchModule 不再调 endSession()ActivityBar.tsx:154)—— 切走只是换视图
  2. endSession() 的合法调用方收敛为两类(审核修正:初稿写成"只由用户显式结束触发", 漏了登出这一支):
    • 用户显式结束:复习完成页 / 显式「结束复习」按钮 —— 需新增(现在没有这个按钮, 切模块就是唯一的结束方式,这正是 §1.1 问题的来源)
    • 🔒 登出useAuthStore.ts:139-141 必须保留。红线 I3——不结束会话则 isInFocusSession 残留会让下一用户登录瞬间看到 FocusReviewHeader, 且其 snapshot 指向已被 resetAllTabs() 销毁的 tab。这一支不许动。
  3. enterReviewModule() 现有的早退 if (get().isInFocusSession) return; 正好成为 "回来接着复习"的天然实现——它本来就是为此写的,只是此前永远走不到 (因为切走时已经 endSessionisInFocusSession 置 false 了)
  4. Item C 的 Modal 逃逸可以退役ActivityBar.tsx:148-151):Settings/Account 恢复成正常模块切换。⚠️ 但退役与否单独判断——Modal 形态本身可能有其它体验理由 (复习中不希望整屏切走)。建议本轮先保留,只解绑 endSession,两件事不耦合。

1.4 需要一并想清楚的两个副作用

  • preFocusSnapshot 的语义变了:现在它在 enterReviewModule 拍、endSession 还原。 解绑后,"进 review 时的 tab/panel 现场"会跨越多次模块往返存活。需确认 endSession 还原时快照里的 tab 可能早已不存在——现有守卫已处理useReviewStore.ts:166snapshotExists 回落 ''),沿用即可。
  • due 角标 / 数据新鲜度:会话保活意味着 currentCard 可能是 10 分钟前取的。 若期间在别处改了该词(如在 Library 里标了熟词),卡片会是陈旧的。 建议:回到 review 模块时对 currentCard 做一次存在性校验(词还在生词本且未软删), 失效则静默取下一张。不做全量重取(那等于变相 endSession)。

2. Library:把「我正在看哪个词」提进 store

useLibraryStore 已存 activeTab / lastVocabTab / 各 tab 的 search 与 filter (注释明写「便于 tab 切回时保留筛选状态」)。缺的是 VocabPanel.tsx 里这些 local state:

字段建议理由
selectedDetailId提进 store「我正在看这个词」是明确的位置感,丢了最难受
page(翻到第几页)留 local重拉是正确的——数据可能已变
entries / isLoading留 local同上,缓存陈旧数据得不偿失
selectedIds(多选)留 local多选是瞬态操作意图,跨模块保留反而危险

四个 tab(notebook / known / browse + notes)各自一个 selectedDetailId 槽位, 不要共用一个(不同 tab 的 id 空间不同)。


3. 「首次遇见于」跳转到回顾模块

3.1 现状

WordMemorySection.tsx:23openSource() = setActiveModule('read') + openContent({kind:'url', url}) —— 开一个普通浏览 tab,只看内容、不进复习语境。

用户要的是进 Review 模块的 revisit 活动。管道已存在SentenceRevisitPanel.openPage()SentenceRevisitPanel.tsx:90-98)做的正是 openRightPanel('annotations') + navigateRevisitTab(...)

3.2 顺带修一个既有 bug:粘贴文档在「回顾」里显示"无来源"

SentenceRevisitPanel.tsx:93 只认 cached_file_path,没有就 showReviewEmptyState('nosource')

粘贴文本的正文本身就是 rb-cache 文件,不需要另存快照——它的 source_ref 形如 rb-cache://localhost/text/<id>.html,内容好端端在磁盘上。 实测:21 条 text 页里 10 条 cached_file_path 为空,它们在「回顾」里全都打不开。

修法:判据从"有没有 cached_file_path"改成**"有没有可读的本地 URL"**—— cached_file_path 优先,回落 source_ref(当它以 rb-cache:// 开头时)。 抽成一个共享 helper(WordMemorySectionSentenceRevisitPanel 都要用)。

📌 2026-08-11 数据卫生落地后的前置事实(做本条前必读): 新增了 notes::canonical_source_url(conn, url)commands/notes.rs)—— 把 rb-cache://localhost/web/<hash>.htmlreading_pages.cached_file_path反查回真实 source_ref,已在 5 个写入命令入口生效。 影响本条两点: ① 在快照/回顾页上查词、划线,来源会正确归到原文那一行,不再分裂 → M5「首次遇见于进 revisit」不必再担心把学习痕迹写到 rb-cache 假来源上; ② content-script/core/ipc.js::effectiveSourceUrl 现在对快照页返回 href (此前返回空串)。若 M4/M5 要给 revisit 页注入 __RB_REVIEW_MODE, 注意它优先级更高(ipc.js:15-16),会盖过这条新回落——两者不冲突, 但别以为改了 ipc.js 就万事大吉。

⚠️ 另:实测确认 EPUB 章节的 cached_file_path 也是 web/<hash>.html (章节快照),不是 epub/…。本条要写的 helper 若按路径前缀分派,会踩同一个坑。

3.3 关于「无快照就进不了回顾」这个约束

用户的推理:语境句寄生在快照页面,没有快照就不该显示语境句、也不会让用户点击。方向正确,但要区分两个东西

  • 语境句存在 word_cloze_contexts 表里,是独立的持久数据,与快照无关 → 照常显示(它是用户的学习记录)
  • 「回到这一页重温」的入口依赖可读的页面 → 处理完 §3.2 的例外后, 真正无法还原的只剩 live-only 网页(实测 3 条,2 条带词)。这类"你读的原文 已不在本地"是真实存在的 → 页面入口置灰不可点(配一句说明,不要静默无反应)

即:不可点的是页面入口,不是句子

3.4 「返回生词本」入口 —— 需要新状态

PreFocusSnapshotuseReviewStore.ts:118-122)只存 leftPanel / rightPanel / activeTabId不含 activeModule(模块切换归 ActivityBar)。

改法PreFocusSnapshotfromModule: ModuleId,由进入方写入 (从 Library 进则记 'library');focus chrome 顶部据此渲染「← 返回生词本」。 fromModule 缺省(正常从 ActivityBar 进 review)时不渲染该按钮。

⚠️ 依赖 §1:必须先解绑 endSession,否则"从生词本进回顾"会顺手清掉用户 原本的复习进度——把一个新入口做成了新的丢进度路径。§1 是 §3 的前置,不可颠倒。

3.5 一个待定的一致性问题

本改动会让**「首次遇见于」(进 revisit)与复习卡里的「打开原文」** (AddressBarReviewSegment.tsx:142,刻意只切 module、开普通 tab)行为分叉。

两条动线要不要统一,建议实施时一并决定,别让它成为下一处静默不一致。 倾向:保持分叉但理由写清——前者是"我要重温这一页",后者是"我要瞄一眼原文再回来答题", 确实是两种意图。


4. 任务分解

ID任务文件量级依赖
M1switchModule 不再 endSessionendSession 只由显式结束触发ActivityBar.tsxuseReviewStore.tsS
M2回 review 时校验 currentCard 仍有效,失效取下一张useReviewStore.tsSM1
M3selectedDetailId 四槽位提进 useLibraryStoreuseLibraryStore.tsVocabPanel.tsxS
M4resolveRevisitUrl()(cached_file_path → rb-cache source_ref 回落);接 SentenceRevisitPanellib/ + 1 组件S
M5WordMemorySection 的「首次遇见于」改进 revisit;无可读 URL 时置灰WordMemorySection.tsxuseReviewStore.tsMM1, M4
M6PreFocusSnapshot.fromModule + focus chrome「← 返回生词本」useReviewStore.ts + focus chrome 组件SM1, M5
M7质量闸 /arch-check/ui-check/build-check/code-review全部

⚠️ 既有测试会受影响src/stores/reviewTabRestore.test.ts 有 5 个用例直接调 endSession() 验证 tab 还原(其中「falls back to the empty state when the snapshotted tab was closed mid-review」正是 2026-08-06 那次实测 bug 的回归锁)。M1 不改 endSession 本身的行为、只改谁来调它,所以这些用例应当全绿——若变红说明改动 超出了预期范围,停下来查,别改测试去迁就实现

跨端:零。纯前端 store / 组件,不动 schema、sync、SM-2、预装库。零 migration。

验收

  1. 复习到第 3 张卡 → 切 Library → 切回 Review → 仍是第 3 张、揭晓态保持
  2. Library 选中某词 → 切走 → 切回 → 仍选中该词
  3. 生词本「首次遇见于」→ 进 Review/revisit 且开「本页精读」右栏 → 顶部有「← 返回生词本」
  4. 粘贴文档在「回顾」里可正常打开(当前是"无来源")
  5. live-only 网页来源 → 入口置灰 + 有说明,不是静默无反应
  6. 复习中从生词本进 revisit 再返回 → 原复习进度仍在(§3.4 的核心风险点)

5. as-built(2026-08-12 落地记录)

M1-M7 全部完成。零 migration、零 Supabase、零 RVH(与 §4 预判一致)。

5.1 实施与计划的差异

计划实修
切模块逻辑落点ActivityBar.switchModule抽到 lib/moduleNav.ts —— 计划期只有左 rail 一个调用方,M5/M6 各带来一个,再抄一遍就是三份各自演化的模块切换规则
离开 review「不再 endSession」不止「不做事」,还要挂起leaveReviewModule 存复习侧现场 + 还原进来前现场(否则 Read 会拿着复习用的 webview 当可浏览 tab)。ChromeSnapshotPreFocusSnapshot 互为往返
卡侧栏计划未提必须改「挂载 ≠ 可见」ReviewSession 的 cards/currentIndex/revealed 是 local state,只改 store 不改挂载条件,进度照样从头开始(§1 的目标根本达不到)。代价:它在别的模块里仍挂载着,←/→ 监听必须再判 activeModule,否则浏览网页时方向键被劫走
§1.4 卡片校验「失效则静默取下一张」拆出不计数goNextCardadvanceCard = bumpReviewedCount + 它)。用户没复习它,不该进 reviewedCount
显式结束「需新增按钮」按钮 + 必须连带切模块endReviewAndLeave):endSession 一置假 isInFocusSession,review 模块就没有 body 了。完成页的 Done 此前正是这个下场
endSession* 文案在 Sprint 2 后期 P2 被删过一轮(理由:切走就等于结束),本次回归。strings/panels/focusReview.ts 头注释已记来回原因,免得下一轮又当死代码删掉

5.2 §3.5 的待定项 —— 决定:保持分叉

「首次遇见于」(进 revisit)与复习卡「打开原文」(开普通浏览 tab)不统一。两者是不同意图: 前者「我要重温这一页」(带着当时的痕迹进去),后者「瞄一眼原文再回来答题」。理由写进 lib/moduleNav.openRevisitPage 头注释,防止后来者当成漏改去"修正"。

5.3 §3.3 的代价(已知、已接受)

live-only 网页来源失去了旧 resolveTarget 的「开今天的实时页面」回落(旧行为:无快照但 source_type==='web' 且 URL 是 http → 开 live 页)。现在一律置灰 + sourceUnavailableTitle。 这是 §3.3 明确选择的结果——新动作是「重温」,live 页给不了当时的痕迹。若日后想两者都要, 应做成两个入口而非让一个入口语义漂移。

5.4 M7 质量闸结果

  • /arch-check:硬规则 6/6 过;i18n 三件套(S19/S22/S23)0/0/0;无死 store;S18=6 / S24=7 持平基线
  • /ui-check:U1=2/2(永久豁免)· U2=0 · U3=0 · U4 无上升;未碰 chrome/toolbar,U7-U9 不适用
  • /build-checkpnpm build 过;214/214 测试全绿,含 §4 点名的 5 个 endSession 回归锁(按预期全绿,未改测试去迁就实现)
  • /code-review:发现并修掉 1 处 P1 —— 指定重温页找不到时静默落到第 1 页(通路: get_word_memoryreading_notes 是 LEFT JOIN,而清单源 get_revisit_sources 是 INNER JOIN, 悬挂 note_id 的孤儿页在生词本可点却不在清单里;本机实测 0 条,属潜伏)。改为明确 nosource 空态,并把「消费 target」提到 pagesForView 判空之前(否则本组为空时 target 滞留、 下次进别的组被误消费)。修后重跑 build + 214 测试仍全绿

5.5 未做的验收项

§4 验收 1-6 全部是需要真机点的动线,本轮只做到静态/单测层(新增 4 个 store 往返用例钉住 挂起/恢复不丢会话)。tauri dev 手动走一遍 1-6 仍建议做,尤其:

  • 验收 5(live-only 置灰)—— 本机两个库都没有 live-only 带词来源,构造不出来
  • 验收 6(复习中从生词本进 revisit 再返回,原进度仍在)—— §3.4 点名的核心风险点

5.6 真机验证结果(2026-08-12,rb-debug 驱动)

在 dev app 上实走 §4 验收动线,抓出两个静态闸门与单测都看不见的缺陷(记录在此,因为 两者的根因都是"判据选错"而非笔误,同类改动容易重犯)。

验收结果说明
1 复习中切走再回来卡片仍是 rainforest 且仍停在揭晓面。后者是关键证据:revealed 是 ReviewSession 的 local state,组件被卸载重挂就会翻回正面。顺带验证挂起时 activeTabId 还给了阅读区("点阅读卡住"的守卫)
2 Library 选中跨模块保留⚠️→✅首轮失败,见下 ①
3 「首次遇见于」进重温⚠️→✅首轮部分失败,见下 ②。rightPanel=annotations + 「← 返回生词本」渲染均正确
4 粘贴文档可打开⛔ 无法验证本机 0 个 source_type='text' 页(且 0 个 cached_file_path 为空的页)——构造不出来
5 live-only 置灰⛔ 无法验证同上:所有页都有快照
6 复习中经新入口绕一圈回来走完 Library→首次遇见于→重温→返回生词本→Library→Review→复习,卡片仍在揭晓面。§3.4 核心风险点通过

isFirstLoadRef 判据被 StrictMode 打穿VocabPanel.tsx 三个 tab)。 原修法用「是不是首次挂载」决定要不要清 selectedDetailId。StrictMode 把挂载 effect 跑 两遍(setup→cleanup→setup,同一实例、ref 重置):第一遍把 ref 置 false, 第二遍就把跨模块刚恢复的选中清掉了。prod 只跑一遍所以不复现——这类"dev 坏 prod 好" 的差异最难查,且判据本身脆:日后谁往 load 的依赖里加一个 store 派生值,prod 也会每次 返回都清。改为按 queryKey(筛选条件快照)比对,对重复调用天然幂等。

② 我在 code-review 阶段的修复本身引入了更糟的行为SentenceRevisitPanel.tsx)。 为了防 target 滞留,我把「消费 target」提到了 pagesForView 判空之前——但 pagesForViewsources 还是 null 的首帧天然为空,于是首帧就消费掉 target 并立刻 判定"找不到" → 报 nosource;等数据到了 target 已是 null,只能走回落页。单页库里回落 恰好是同一页,所以静态检查、单测、甚至肉眼都看不出;多页库上就是"点 A 页却打开 B 页" 外加闪一下 nosource。判据应是「加载完了没有」(sources !== null)而不是「列表为空」。

教训:nosource 这个信号是靠 reviewEmptyReason 粘性字段发现的,而它只由 showReviewEmptyState 改写、navigateRevisitTab 不重置——所以"当前值是 nosource" 并不证明本次触发。判定要先把它复位成 picker(切「重温」且未选组)再重跑动线。

未覆盖←/→ 切语境的 activeModule 守卫没验到——当前卡只有 1 条语境, contextCount <= 1 时该 effect 根本不注册。需要一个多语境词才能验。

5.7 补验:粘贴文档 + 目标页判别(2026-08-12,用户现场粘了两篇)

用户当场粘入两篇文本(同属「粘贴文本」笔记,各带 2 个词),补验 §5.6 里 ⛔ 的两条。

验收 4 = ✅ 能打开,但这组数据验不出「修的是什么」。 新粘的 text 页 cached_file_path有值text/<id>.html),而 cacheUrl('text/<id>.html') 恰好 等于 source_refrb-cache://localhost/text/<id>.html)—— 两条分支输出同一字符串,旧代码同样能打开。 即 §3.2 说的「21 条里 10 条 cached_file_path 为空」是历史数据形状,当前粘贴流程总会 写这一列,那个触发条件用现在的代码构造不出来。回落分支仍是对的防御(覆盖旧行 / 该列 因故为空的行),但本轮没有真正验证过它——别把 §5.7 的 ✅ 当成回落分支已验。

意外收获:验收 3 的「落到指定那一页」这下才真正可判别。 §5.6 时全库只有 1 页, 回落页与目标页是同一页,测不出区别。现在「粘贴文本」笔记下有 2 页,且清单按 COALESCE(last_opened_at, created_at) DESC 排序 → 回落页(page[0]) = 较新的 "The Trump administration…"。点较旧那页的词(democratic)的「首次遇见于」:

  • review tab 落到 text/6ad69839…(= "Here's the latest",目标页
  • 不是 text/7729ca1b…(回落页)
  • reviewEmptyReason 全程保持 picker → §5.6② 的 nosource 分支未触发
  • 清单里高亮的是第二行(目标页)而非第一行 → active 判定同样走 resolveRevisitUrl

结论:target 匹配赢过回落,§5.6② 的修复得到判别性验证。

仍未验:live-only 置灰(验收 5,所有页都有快照);←/→activeModule 守卫 (需一个多语境词)。两条都是「构造不出触发条件」,不是「验了没通过」。

5.8 补验:←/→ 守卫 + currentIndex 保持(2026-08-12,用户授权点卡片)

§5.6/§5.7 遗留的两条,用户指出 rainforest/observe 现已各有 2 条语境(§5.6 那次 dump 时 只有 1 条,语境是之后加的)并授权评分,两条都补上了。

←/→activeModule 守卫 = ✅ 判别性通过。

设计上有个坑:handleSwitchContext 在边界夹紧next<0 || next>=length 直接 return), 所以单次往返读不出结论——"守卫生效"与"守卫失效但被夹住"终态相同。必须分两步、每步各探一次:

动作结果
处理Library 里(先点空白处避开搜索框)按 → 2~3 下语境 1 / 共 2 不变
对照Review 按 → 一下语境 2 / 共 2

对照组不可省:只有它能证明按键确实到达了 app、监听器本身工作正常,否则处理组"什么都没 发生"什么也证明不了。另注意必须先点空白处——输入框聚焦时处理器本来就早退(INPUT/ TEXTAREA/contentEditable 判据),停在搜索框里按会得到一个假通过

探针用的是 ClozeRevealCard 圆点组的 aria-labelspan[role="group"][aria-label^="语境"]), 它直接编码 activePositionIdx + 1,比 dump 整个 review store 便宜得多。

currentIndex 保持 = ✅ 通过(这是「挂载 ≠ 可见」最有力的验证)。

此前无法验:要让 currentIndex > 0 必须真的评分(写 SM-2)。用户授权后: 卡1 rainforestHard → 推进到卡2 monkey、进度 2/8 → 切「阅读」→ 切回「回顾」 → 仍是 monkey、仍 2/8cards 数组与 currentIndex 都是 ReviewSession 的 local state,组件若被卸载,loadCards() 会重取并把 currentIndex 归 0 回到第一张。

评分写库正确(rainforest:easy_factor 2.5→1.96、interval 0→1、last_review_quality=1、 next_review 明天 —— Hard 在 repetitions=0 上的标准 SM-2 行为)。选 Hard 而非 Easy 是为了 让该词很快回到队列,对用户真实排期扰动最小。

至此仅剩验收 5(live-only 置灰)未验:需要一个"当时没存下快照"的网页来源,本机所有页 都有快照,构造不出触发条件(不是验了没通过)。