Skip to content

16 · 「快速标记已会词 → 排除词清单」功能说明(跨端交接 / RB 参考)

文档目的:RVH 端已上线「在 Me 页面通过行动卡片快速标记用户已经会、不需要再学习的单词,标记后进入排除词清单」功能。RB 端计划实现类似能力,本文完整描述 RVH 的产品意图、交互流程、数据模型、系统联动与已固化的跨端同步契约,供 RB 端设计与落地参考。

读者:RB 端开发 / 产品。 权威数据契约:排除词跨端同步走 Supabase user_excluded_words,红线见 RVH CLAUDE.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来源说明
recommendedSupabase 公共列表下发(约 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(入门级分级引导)

  • 触发条件_shouldShowAme_action_card.dart:32-44):A1+A2「待发现」词数 > 0,且用户未在近期关闭过该卡(MeTriageHintService.isDismissedRecently
  • 外观_buildScenarioA:133-155):💡 emoji + 标题 Let's Master Basics + 说明文 + 右上角关闭按钮 + Start Triage CTA 按钮(tertiaryContainer 淡色背景,OutlinedButton 弱化处理不与上方图表争焦点)
  • 点击 CTANavigator.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
│   💡 提示行                   │
└─────────────────────────────┘
  • 左滑 / 点「我认识」onKnownnotifier.markKnown(word)quick_triage_page.dart:138,144):把该词写入排除词清单,sessionMarkedCount++,游标前进,加载下一词。
  • 右滑 / 点「跳过」onSkipnotifier.skip(word):139,145):不写任何数据,仅游标前进到下一词。
  • 跳级:点顶部进度条可选 CEFR 起始等级(TriageLevelPickerSheet),二次确认后 jumpToLevel
  • 一轮结束:候选耗尽 → TriageCompleted → 跳转完成页(triage_completion_page.dart),展示本轮标记数,可「重新开始」(游标回 A1)。

关键设计点(RB 可直接沿用)

  1. 即时单条生效,不是「勾选一批再提交」——每次左滑立刻落库一条排除词。低摩擦是核心。
  2. 只有「认识」写库,「跳过」不写。跳过纯粹是「这词我暂时不表态」,不污染任何表。
  3. 游标(cursor)驱动的分页续读,见第 4 节。保证跨会话不重复、不遗漏。
  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-737getTriageCandidates

候选来自核心词库 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),持久化在 SharedPreferencesTriageCursorService),跨会话续读。
  • 双重排除:已在笔记本的词(正在学)和已排除的词(已表态)都不再作为候选,避免重复打扰。
  • 可跳级jumpToLevel / resetCursor 允许用户直接从某等级开始或重来。

RB 若无 CEFR 分级维度,可用自身的词频/难度序替代排序键;核心是「有稳定顺序 + 游标续读 + 排除已表态词」。


5. 数据模型:excluded_words 表 + 写入/管理路径

5.1 表结构

DDLassets/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 → AddUserExcludedWordUseCaseExcludedWordsRepositoryImpl(构造期注入 userIdexcluded_words_providers.dart:31-41)→ LocalExcludedWordsDataSource.addOrReviveUserWordlocal_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-283excludedWordsRepository.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_wordsSupabase 表 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_wordsUNIQUE(user_id, word))。不传则跨端并发推同一逻辑词(不同 id)撞 23505/409,UPSERT 不触发 → push 永久失败。
  • push 成功后本地 UPDATE ... SET synced_at = now

7.2 PULL(Supabase → 本地)

  • 【红线 #5d】cursor 用 server_updated_at(trigger 维护的服务器时钟),禁用 client updated_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_id NOT 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 → ExcludedWords

8. 给 RB 端的落地建议清单

  1. 交互沿用「逐词滑卡、即时单条落库、跳过不写」——低摩擦是这个功能的价值核心,不要改成「批量勾选提交」。
  2. 候选序:RVH 用 CEFR+字母序+游标续读。RB 若无 CEFR,用自身难度/词频序 + 持久化游标即可。务必排除「已在学习库」和「已排除」两类词。
  3. 表结构与同步契约照第 5、7 节 1:1 对齐——user_excluded_words 是共享表,字段名/onConflict/server_updated_at/软删传播任一处偏差都会导致跨端丢词。
  4. 归一化对齐:当前双端都 = lowercase + trim lemmatize)。RB 落地保持一致;未来若一端要 lemmatize,必须双端原子升级(红线 #9)。
  5. 过滤点:RB 的「识词/高亮/推荐生词」管线要接入排除词过滤(等价 RVH 的 OCR 过滤),否则排除了却仍被提示,用户会困惑。
  6. 软删不物理删:恢复能力 + 跨端传播都依赖 deleted_at
  7. 推荐词与用户词用 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)