主题
16 · 「快速标记已会词 → 排除词清单」功能说明(跨端交接 / RB 参考)
文档目的:RVH 端已上线「在 Me 页面通过行动卡片快速标记用户已经会、不需要再学习的单词,标记后进入排除词清单」功能。RB 端计划实现类似能力,本文完整描述 RVH 的产品意图、交互流程、数据模型、系统联动与已固化的跨端同步契约,供 RB 端设计与落地参考。
读者:RB 端开发 / 产品。 权威数据契约:排除词跨端同步走 Supabase
user_excluded_words,红线见 RVHCLAUDE.md§#5b / #5d / #5f / #6b 及 RB 对应镜像条款。本文第 6 节把这些红线在「排除词」场景下具体化。 落地位置:RVH 代码引用均带file:line,截至 2026-07-01(schema v54)。
1. 功能概述与产品意图
一句话:给用户一个低摩擦的入口,把「我早就会了、别再拿它来烦我」的单词一次性清出学习循环。
要解决的问题:预装词库有 12291 词(CEFR A1–C2 全覆盖)。对中高水平用户,A1/A2 甚至部分 B1 词大量属于「早已掌握」。若不剔除:
- OCR 拍照识词时这些词反复被当作生词提示 → 噪音
- 快速分级 / 学习候选里持续出现 → 浪费用户注意力
- 词汇进度统计被「其实早会但没标记」的词稀释
解法:排除词(Excluded Words)系统。被排除的词从「学习面」上彻底消失(OCR 不提示、分级不再出、统计不计入待学),但软删除保留记录,可随时恢复,且跨端同步。
两类排除词(source 字段区分):
| source | 来源 | 说明 |
|---|---|---|
recommended | Supabase 公共列表下发(约 211 个英语功能词:the/a/is/and…) | 首次启动 / 登录后 merge 进用户表,默认排除 |
user | 用户手动标记 | 本文主角,通过「快速分级」或「排除词管理页」产生 |
v42 起两类词都允许用户删除(软删 + 跨端传播),不再有「系统词不可动」的硬限制。
2. 入口 UI:Me 页面的行动卡片(MeActionCard)
文件:lib/features/me/presentation/widgets/me_action_card.dart
Me 页面进度卡下方有一个「行动卡片」槽位,按优先级只渲染一种场景(me_action_card.dart:14-19)。与本功能相关的是 Scenario A(入门级分级引导):
- 触发条件(
_shouldShowA,me_action_card.dart:32-44):A1+A2「待发现」词数 > 0,且用户未在近期关闭过该卡(MeTriageHintService.isDismissedRecently) - 外观(
_buildScenarioA,:133-155):💡 emoji + 标题Let's Master Basics+ 说明文 + 右上角关闭按钮 +Start TriageCTA 按钮(tertiaryContainer 淡色背景,OutlinedButton 弱化处理不与上方图表争焦点) - 点击 CTA:
Navigator.pushNamed(AppRoutes.quickTriage)→ 进入快速分级页 - 可关闭:点右上角 × →
service.markDismissed()记录时间戳,一段时间内不再打扰
Scenario B(今日复习到期)/ Scenario C(拍照引导)是同槽位的其他场景,与排除词无关,RB 可忽略。
二级入口:Me 页面下方还有文字链接「已排除的词」→ AppRoutes.excludedWords → 排除词管理页(见第 5 节的管理能力)。
3. 核心交互:快速分级(Quick Triage)滑卡流
文件:
- 页面
lib/features/quick_triage/presentation/pages/quick_triage_page.dart - 状态机
lib/features/quick_triage/presentation/providers/quick_triage_providers.dart
交互模型 = 逐词滑卡(Tinder 式),一次一词、即时生效:
┌─────────────────────────────┐
│ Level 进度条 (A1→C2, 可点跳级) │
│ 本轮已标记 N / 已浏览 M │
├─────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ 单词卡 │ │ ← TriageSwipeableCard
│ │ word + 释义 │ │
│ └─────────────┘ │
│ │
│ [ 我认识 ] [ 跳过 ] │ ← TriageActionButtons
│ 💡 提示行 │
└─────────────────────────────┘- 左滑 / 点「我认识」(
onKnown→notifier.markKnown(word),quick_triage_page.dart:138,144):把该词写入排除词清单,sessionMarkedCount++,游标前进,加载下一词。 - 右滑 / 点「跳过」(
onSkip→notifier.skip(word),:139,145):不写任何数据,仅游标前进到下一词。 - 跳级:点顶部进度条可选 CEFR 起始等级(
TriageLevelPickerSheet),二次确认后jumpToLevel。 - 一轮结束:候选耗尽 →
TriageCompleted→ 跳转完成页(triage_completion_page.dart),展示本轮标记数,可「重新开始」(游标回 A1)。
关键设计点(RB 可直接沿用):
- 即时单条生效,不是「勾选一批再提交」——每次左滑立刻落库一条排除词。低摩擦是核心。
- 只有「认识」写库,「跳过」不写。跳过纯粹是「这词我暂时不表态」,不污染任何表。
- 游标(cursor)驱动的分页续读,见第 4 节。保证跨会话不重复、不遗漏。
- 统计延迟刷新:排除会置
_statsDirty,在离开页面onDispose时才刷新 Me 页统计(quick_triage_providers.dart:143-146,175-178),保证滑卡过程丝滑。
markKnown 写库调用(quick_triage_providers.dart:87-115):
dart
final result = await excludedRepo.addUserExcludedWord(
word: word.word,
reason: 'quick_triage', // reason 标记来源,便于区分手动添加 vs 分级标记
);4. 候选词来源与筛选逻辑
文件:lib/features/vocabulary_filtering/data/datasources/local_vocabulary_datasource.dart:679-737(getTriageCandidates)
候选来自核心词库 vocabulary 表,按以下规则挑选:
sql
SELECT vi.* FROM vocabulary vi
WHERE vi.primary_cefr_level IN ('A1','A2','B1','B2','C1','C2')
AND ( ? = '' -- 首次无游标
OR vi.primary_cefr_level > ? -- 跨级游标
OR (vi.primary_cefr_level = ? AND LOWER(vi.word) > ?) ) -- 同级内词序游标
AND vi.word NOT IN ( -- 排除①:已在笔记本的词
SELECT word FROM notebook_entries WHERE user_id IS ? AND deleted_at IS NULL)
AND LOWER(vi.word) NOT IN ( -- 排除②:已排除的词
SELECT LOWER(word) FROM excluded_words WHERE user_id IS ? AND deleted_at IS NULL)
ORDER BY vi.primary_cefr_level ASC, LOWER(vi.word) ASC
LIMIT ?筛选/排序要点:
- 顺序:CEFR 由易到难(A1→C2),同级内字母升序。契合「从最基础的词开始清」的心智。
- 游标格式:
"{level}|{lower(word)}"(quick_triage_providers.dart:170-172的_advanceCursor),持久化在SharedPreferences(TriageCursorService),跨会话续读。 - 双重排除:已在笔记本的词(正在学)和已排除的词(已表态)都不再作为候选,避免重复打扰。
- 可跳级:
jumpToLevel/resetCursor允许用户直接从某等级开始或重来。
RB 若无 CEFR 分级维度,可用自身的词频/难度序替代排序键;核心是「有稳定顺序 + 游标续读 + 排除已表态词」。
5. 数据模型:excluded_words 表 + 写入/管理路径
5.1 表结构
DDL(assets/sql/01_create_tables.sql + 02_create_indexes.sql):
sql
CREATE TABLE excluded_words (
id TEXT PRIMARY KEY, -- UUID v4(推荐词用确定性 id 'rec:<id>:<userId>')
word TEXT NOT NULL, -- 单词,存储前 toLowerCase()
source TEXT NOT NULL, -- 'recommended' | 'user'
reason TEXT, -- 备注 / 来源标记(如 'quick_triage')
added_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
synced_at TEXT, -- 本地→Supabase 同步水位(NULL=未推)
deleted_at TEXT, -- 软删时间戳(NULL=活跃)
user_id TEXT -- per-user 过滤键
);
CREATE UNIQUE INDEX idx_excluded_words_user_word
ON excluded_words(user_id, word COLLATE NOCASE); -- 同用户同词唯一(大小写不敏感)
CREATE INDEX idx_excluded_words_word ON excluded_words(word); -- 供 OCR 无 user_id 过滤5.2 写入路径(add / revive)
调用栈:UI → AddUserExcludedWordUseCase → ExcludedWordsRepositoryImpl(构造期注入 userId,excluded_words_providers.dart:31-41)→ LocalExcludedWordsDataSource.addOrReviveUserWord(local_excluded_words_datasource.dart:99-172)。
add-or-revive 语义(:110-157):按 (user_id, LOWER(word)) 查行 → 命中则 deleted_at=NULL 复活并置 synced_at=NULL;未命中则 INSERT。避免软删过的词重新添加时撞 UNIQUE。
归一化:当前仅 word.toLowerCase()(:107),未走 lemmatizer。这是已知的技术债(RVH CLAUDE.md 红线 #9 backlog)——excluded_words.word 的跨端 PK 一致性目前依赖「双端都 lowercase + 简单功能词不发生屈折」。⚠️ RB 落地时建议对齐 RVH 的归一化策略(当前 = lowercase+trim),若任一端引入 lemmatizer 归一,必须双端同步,否则同一词在两端 normalize 出不同 key → 排除词跨端静默丢失。
5.3 管理页能力(excluded_words_page.dart)
供参考的完整管理面:搜索(大小写不敏感)、按 recommended/user 过滤芯片、左滑单条软删、批量选择删除、「批量按 CEFR 等级排除」(BatchExcludeSheet)、以及再次进入「快速分级」的入口。
6. 系统联动:被排除后到底发生什么
一个词进入排除清单(deleted_at IS NULL)后,在 RVH 各处被过滤:
| 场景 | 过滤点 | 文件 |
|---|---|---|
| OCR 拍照识词(最重要) | 识别出的原始词列表先减去排除词集合,被排除词不出现在结果里 | lib/shared/domain/usecases/recognize_and_filter_usecase.dart:101-102,270-283(excludedWordsRepository.getExcludedWordSet() → contains(word.toLowerCase()) 跳过) |
| 快速分级候选 | SQL NOT IN (SELECT ... FROM excluded_words WHERE deleted_at IS NULL) | local_vocabulary_datasource.dart:712-715 |
| 词汇进度统计 | 排除后置 _statsDirty,离开分级页刷新 | quick_triage_providers.dart |
| 浏览核心词库 | 已排除词以灰显/禁用态标记 | core_vocabulary_page.dart |
幂等 & 可逆:所有过滤都带 deleted_at IS NULL。软删恢复(deleted_at 置回 NULL)后,该词立即在所有过滤点重新可见。没有物理删除——软删记录要参与跨端传播(第 7 节)。
7. 跨端同步契约(RB 必须严格对齐的部分)
本地表 excluded_words ↔ Supabase 表 user_excluded_words。同步逻辑:lib/features/sync/data/repositories/sync_repository_impl.dart(_pushExcludedWords ~:805、_pullExcludedWords)。
7.1 PUSH(本地 → Supabase)
- 选取
synced_at IS NULL OR updated_at > synced_at OR (软删未推)的行。 - payload 字段:
id / user_id / word / source / reason / deleted_at / created_at / updated_at。- 不带
source_platform(会触发 PostgREST PGRST204)、不带vocabulary_id(v43 已删列)。
- 不带
- 【红线 #5f】必须显式传
onConflict: 'user_id,word'(user_excluded_words有UNIQUE(user_id, word))。不传则跨端并发推同一逻辑词(不同 id)撞 23505/409,UPSERT 不触发 → push 永久失败。 - push 成功后本地
UPDATE ... SET synced_at = now。
7.2 PULL(Supabase → 本地)
- 【红线 #5d】cursor 用
server_updated_at(trigger 维护的服务器时钟),禁用 clientupdated_at;watermark = 本轮跨表MAX(server_updated_at)。 - 软删传播:远端
deleted_at != null时,先INSERT OR IGNORE占位再UPDATE deleted_at,保证「A 端删、B 端也删」。 - 【红线 #6b】用
ON CONFLICT(id) DO UPDATE,只更新可变列(reason / updated_at / synced_at),保留word/source/added_at/created_at/user_id首次值。excluded_words 是叶子表,但统一此风格防红线漂移。
7.3 user_id 必填(红线 #5b)
- 写入路径必须填
user_id真值(currentUserIdProvider注入 Repository 构造期)。 - Supabase 端
user_idNOT NULL,漏写被 RLS/NOT NULL 拒 → 行永远不上行 → 跨端静默丢词。 - 本地 UNIQUE 在 NULL 上判
NULL≠NULL静默失效,会产生 dup row。
7.4 推荐词 merge(local_excluded_words_datasource.dart:326-418)
- 登录后把 Supabase 公共
recommended_excluded_words(~211 词)merge 进当前用户excluded_words:确定性 id'rec:<id>:<userId>'、source='recommended'、synced_at=now(标记已同步,用户未触碰则不 push)。幂等(INSERT OR IGNORE)。
7.5 同步顺序(FK 父表优先)
PUSH: Books → ReadingSources → NotebookEntries → WordSourceRelations → ExcludedWords
PULL: ReadingNotes → ReadingSources → NotebookEntries → WordSourceRelations → ExcludedWords8. 给 RB 端的落地建议清单
- 交互沿用「逐词滑卡、即时单条落库、跳过不写」——低摩擦是这个功能的价值核心,不要改成「批量勾选提交」。
- 候选序:RVH 用 CEFR+字母序+游标续读。RB 若无 CEFR,用自身难度/词频序 + 持久化游标即可。务必排除「已在学习库」和「已排除」两类词。
- 表结构与同步契约照第 5、7 节 1:1 对齐——
user_excluded_words是共享表,字段名/onConflict/server_updated_at/软删传播任一处偏差都会导致跨端丢词。 - 归一化对齐:当前双端都 =
lowercase + trim(未 lemmatize)。RB 落地保持一致;未来若一端要 lemmatize,必须双端原子升级(红线 #9)。 - 过滤点:RB 的「识词/高亮/推荐生词」管线要接入排除词过滤(等价 RVH 的 OCR 过滤),否则排除了却仍被提示,用户会困惑。
- 软删不物理删:恢复能力 + 跨端传播都依赖
deleted_at。 - 推荐词与用户词用
source区分,UI 上给不同标签;两类都允许删除(v42 对齐)。
附:关键 API 速查(RVH ExcludedWordsRepository)
| 操作 | 方法 |
|---|---|
| 添加/复活排除词 | addUserExcludedWord(word, reason) |
| 软删排除词 | removeUserExcludedWord(word) |
| 判断是否已排除 | isExcluded(word) |
| 取排除词集合(OCR 过滤用) | getExcludedWordSet() → Set<String> |
| 批量按 CEFR 排除 | batchExcludeByCefrLevels(levels, includeNotebook) |
| 取全部 | getAll() |
关联文档:RVH CLAUDE.md 红线 #5b/#5d/#5f/#6b/#9;RB 端 CLAUDE.md §4 对应镜像条款;excluded_words per-user 重构见 project_excluded_words_refactor_v42。
最后更新:2026-07-01(schema v54)