Compare commits

...
Author SHA1 Message Date
ila e069894c4e feat: 补齐桌面与手机体验、显示偏好与键盘操作 (#14)
- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机,
  账号切换不串、退出回默认;深色走 html[data-theme] 与 Element Plus 的 html.dark
- style.css 收敛为语义调色板::root 的 53 个变量是文件内仅有的颜色字面量,
  其余规则全部走 var(),暗色只覆盖变量
- 字号经 --reader-font-scale 只作用于阅读面,不做全局缩放
- 阅读位置按账号+章节保存滚动比例与该章 content_sha256,正文换版本后不恢复
- 复习页键盘:空格/Enter 显示答案、1/2/3 评分;输入控件与聚焦按钮的按键不被劫持
- 站点头部新增「显示」控件,七个学习页面共用
- Playwright 新增 390×844 hasTouch 的 mobile 项目与移动用例(无溢出、可返回、
  触摸滑动不误开面板、深色与字号持久化、账号隔离);新增主题变量回归用例
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-15 15:11:30 +08:00
ila 5f86f17ac4 docs: record issue 13 acceptance and merge (#13)
- Wiki:Home、Architecture、Product-Requirements 与 Project-Profile 标记 #13 已验收并合入 main
- README 同步当前进度,剩余工作为 #14、#15、#21、#24 与缺陷 #32
- docs/ 由 harness.py sync 导出,revision:Architecture 2cfb6d79、Home d40a8f10、
  Product-Requirements f1f5f972、Project-Profile 0c035449
2026-09-15 14:31:18 +08:00
ila a9952b3516 feat: 完成章节并查看个人基础进度 (#13)
- schema v7 新增 lexgo_chapter_progress(chapter_id 主键、read_sha256 内容版本门控、
  外键级联到章节/书籍/账号),仍然只用可重放的 CREATE TABLE IF NOT EXISTS
- POST /api/v1/chapters/:id/complete:只记已读、不改词语状态;只有 ready 章节可标记;
  重复调用返回同一行并带 duplicate,不移动已读时间也不重复计数;正文新版本后该章
  回到未读但保留记录,重新标记更新同一行
- GET /api/v1/progress:已读/可阅读章节、已知/学习中/新词/忽略、已保存词条、待复习
  与每本书的已读进度;到期数量与复习队列共用 dueTermsQuery 与同一时钟
- ChapterSummary 增加 readAt,书籍列表、阅读器与编辑响应都会解析
- 学习端新增 /progress 页面与导航、stores/progress.ts,阅读器新增显式「标记本章已读」
  区块,书籍页显示已读标记
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-15 12:31:53 +08:00
ila f2de395cd9 docs: record issue 12 acceptance and merge (#12)
- Wiki:Home、Architecture、Product-Requirements 与 Project-Profile 标记 #12 已验收
- README 与项目档案反映 #12 已通过验收,
2026-09-15 11:52:15 +08:00
ila 000d7e0dbf docs: 记录 #12 已确认的词汇库口径 (#12) 2026-09-14 23:48:25 +08:00
ila 3ebec5b11b feat: 词汇库搜索、筛选与编辑 (#12)
- GET /api/v1/terms 列表:查询键白名单、page/limit 边界(默认 20、上限 100)、
  排序 updated_at DESC;搜索同时匹配身份键、显示原文与个人释义,转小写后比较(term 为
  二进制排序规则),%/_/\ 按字面值转义;status 与 kind 可组合
- PATCH /api/v1/terms/:id 编辑:复用释义/例句与状态/等级校验,身份不可编辑;
  只有状态或等级变化才重排复习时间,历史作答记录与计数保留
- 学习端新增 /vocab 路由与导航、stores/vocabulary.ts、VocabView.vue(搜索、筛选、
  分页、空态与无结果清除、编辑对话框含 1~7 等级选择器、筛选与页码写入 URL)
- 释义/例句/状态/等级抽成 TermFormFields.vue,与阅读器面板共用,避免规则漂移
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-14 23:46:42 +08:00
ila 329ea7336a docs: record issue 11 acceptance and merge (#11)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #11 于 2026-09-14 通过用户验收,PR #30 已 fast-forward-only 合入 main
- 用户确认的 D3 存储方式(身份键派生 kind/词数、不新增列)随之记入长期文档;
  功能提交 bd77aa0 与审核整改提交 a658fa6 均与用户验收版本一致
2026-09-14 23:00:02 +08:00
ila a658fa6fd1 fix: 按审核结论补强短语实现与测试 (#11)
- adjustRange 改变范围时同时清空短语链接与已加载条目:调整端点后不再声称「已保存」,
  已输入文本保留,保存按当前范围确定的身份写入(可能更新另一条,但绝不重复)
- 新增 TestWordKeysNeverContainSpaces:显式锁定「词片段不含空白」这一不变量,
  termKind/termWordCount 完全依赖它,此前只有间接覆盖
- e2e 断言超时提高到 15s:11 个用例共用开发服务器并行运行时,首屏模块加载可能超过
  默认 5s 造成偶发失败(产品行为未变,连续两次并行全量运行均通过)
2026-09-14 22:47:14 +08:00
ila b1b76cfb7c docs: 记录 #11 已确认的短语口径 (#11) 2026-09-14 22:23:13 +08:00
ila bd77aa0a46 feat: 选择连续短语,保存并加入到期复习 (#11)
- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格,
  因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑
- POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400;
  章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount
- 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据
- 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截
  touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-14 22:21:51 +08:00
ila 1319c56cf8 docs: record issue 10 acceptance and merge (#10)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #10 于 2026-09-14 通过用户验收,PR #29 已 fast-forward-only 合入 main
- 功能提交 35ed692 与用户验收版本一致;本单没有数据库结构变化,schema 仍为 v6
2026-09-14 21:38:22 +08:00
65 changed files with 6097 additions and 173 deletions
+5
View File
@@ -282,6 +282,11 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。
- 2026-09-11 用户确认正式 NLP/词典采用全 Go。#5 已验收并合入 main;#6 使用 Go WordNet 解析和词形候选、Go Unicode 原文分片、schema v4 共享词典资源表,不调用 Python NLP。WordNet 3.0 ZIP 来源与摘要见 `server/wordnet-resource.json`,许可保留在 `server/WORDNET-LICENSE.txt`。词形候选不等于上下文消歧,不自动合并个人学习状态;#3 Python 小样只保留历史验证。当前词典仅英语释义,个人释义输入为临时草稿,持久化归 #7。
- 2026-09-11 用户确认 #7 个人词条口径(三项由 Agent 定案):身份为「学习者+语言+规范化词形」,大小写合并但**不按 lemma/候选合并**(`dog` 与 `dogs` 是两条记录);首次保存默认「新词」;状态为 新词/学习中/已知/忽略,只有「学习中」带 1~7 级,对应原版 stage 2/1/0/-1~-7;例句只保存手输内容,不自动关联原文句子。schema v5 新增 `lexgo_terms`(唯一键加状态/等级检查约束),个人释义与共享词典分离且不进入审计日志;等级编辑 UI 归 #8/#12。
- 2026-09-15 用户确认 #14 显示与键盘口径:`theme ∈ {浅色,深色,跟随系统}`(默认跟随系统)与正文字号 `{标准,大,特大}`(1.0/1.15/1.3)**按账号保存在本机** `lexgo-learner-display:<账号 id>`,切换账号即换成该账号偏好或默认,退出回到默认,**不跨设备同步**;字号经 `--reader-font-scale` 只作用于阅读面(正文、释义内容、复习卡),不做全局缩放;深色用 `html[data-theme]` + Element Plus 的 `html.dark`,`style.css` 的 `:root` 是文件内仅有的颜色字面量;阅读位置按账号+章节保存滚动比例与该章 `content_sha256`,**正文换新版本后不恢复**;复习页 `空格`/`Enter` 显示答案、`1/2/3` 评分,输入类控件与聚焦按钮的按键不被劫持,带修饰键不拦截;移动验证用 390×844+`hasTouch` 的 Playwright `mobile` 项目(桌面项目 `testIgnore: mobile-*`),**真机长按选择与手感仍需人工确认**,不得用模拟设备结果冒充真机。本单无 schema 与接口变化。
- 2026-09-15 用户确认 #13 完成阅读与进度口径:`POST /api/v1/chapters/:id/complete` 只记已读、**不批量改变词语状态或等级**,只有 `ready` 章节可标记(其他 409),重复调用返回同一行且带 `duplicate`(不移动时间、不重复计数);完成记录保存标记时的 `content_sha256`,**正文新版本后该章回到未读**(记录保留,重读后更新同一行),只改标题不影响,章节删除随外键级联;`GET /api/v1/progress` 统计只含本人与当前语言,已读与分母都只算可阅读(`ready`)章节,已知/学习中/新词/忽略分开计数,`dueNow` 与到期复习队列共用 `dueTermsQuery` 与同一服务端时钟;schema 升到 v7(新表 `lexgo_chapter_progress`,不用 ALTER TABLE),需显式 migrate。不做每日目标、日历、难度评分、统计导出与取消已读,也不做 X10 批量标已知。
- 2026-09-15 既有缺陷记录:#32 编辑章节正文回到曾经用过的版本返回 500(`lexgo_ingest_jobs` 请求键与 `edit:<章节>:<内容摘要>` 冲突),已定位未修复,方案待用户确认;#13 的真实验证脚本因此自建 fixture 而不改写既有正文。
- 2026-09-11 用户确认 #12 词汇库口径:`GET /api/v1/terms` 列表同时匹配规范化身份键、显示原文与个人释义,大小写不敏感(`term` 为二进制排序规则,查询先转小写),`%`/`_`/`\` 按字面值转义,`status` 与 `kind` 可与搜索组合;分页 `page ≥ 1`、`limit ≤ 100`(默认 20)、按最近更新倒序;`PATCH /api/v1/terms/:id` 复用同一套释义/例句与状态/等级校验,**身份不可编辑**,只有状态或等级变化才重排复习时间,历史作答记录与计数保留;筛选与页码写入 `/vocab` URL。原型编辑页的「来自某章节」**不实现**(词条按身份存储、不引用章节);CSV(X04)、复习范围筛选(X11)、删除与批量清理(X14)不在范围内。
- 2026-09-11 用户确认 #11 短语口径:短语与单词**共用 `lexgo_terms`**,身份键为按阅读顺序的规范化词形以单个空格连接(单词键不含空格),因此 `kind` 与词数由身份键派生,**不新增列、无迁移**;范围两端对齐整词、内部标点与换行保留但不参与身份比较,2~12 词、键 ≤128 字符、片段 ≤191 字符,切进单词的范围 400。跨章节匹配按连续词形比对,重叠取**最左最长**;短语高亮覆盖内部单词但**不修改单词数据**,点击已保存短语优先打开短语面板;失效引用回退=高亮消失但词条与复习排期保留。短语进同一到期队列与同一套幂等作答,复习卡把整段短语挖成一个空;选择用原生拖选与手机系统手柄,不拦截 touchmove。同义形式合并、词性消歧、跨书移动、批量编辑(#12)与真机手感不在范围内。
- 2026-09-11 用户确认 #10 编辑与删除口径:可改名、改章节标题、编辑章节正文;**只有正文变化才重新处理**,重复保存或改回原内容不新建任务,只改标题不改状态。版本键是 `content_sha256`:任务只在与章节版本一致时才能影响章节,过期版本任务标为 `error_reason=superseded` 且**完全不触碰章节**(认领、恢复扫描、重试都按版本裁决);存储文本重算 SHA 与存储 SHA 不一致时按 `content_changed` 失败。删除为事务内硬删除 + 外键级联,删章后重排序号;个人词条、复习排期与作答记录**不随删除清理**。回收站/撤销、批量操作、章节跨书移动与语言变更不在范围内。编辑器行尾归一为 LF 是已知边界。
- 2026-09-11 用户确认 #9 TXT 导入口径:只接受 UTF-8(允许可选 BOM,解码时剥离且不进入原文),非法字节整体拒绝、不使用替换字符;UTF-16 按 BOM 识别后明确拒绝,GB18030 等按非法 UTF-8 拒绝。文件字节上限 2 MiB,之后仍套用单章 100000 码点上限;换行与空白不归一化。文件只在内存中解码、不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有 `PasteBook`/`PasteChapter`,分章(一次提交一章)、`requestId` 幂等与任务恢复与粘贴一致;不改 schema。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在范围内。
- 2026-09-11 用户确认 #8 到期单词复习决策表:固定间隔表 1/2/4/7/15/30/60 天,答对升级封顶 7、答错降级最低 1、再学一次不改等级,答错与再学立即回队;已知/忽略不入队,新保存的词立即到期,显式「学习中 level N」排 now+间隔[N];只有新建或状态/等级实际变化才移动复习时间,编辑释义或例句保留原排期,保存未提及等级时保留已获得等级。到期判定用 UTC 绝对时刻(`due_at ≤ now`),不引入本地日边界。作答按 `answerId` 去重并以 `expectedDueAt` 判定过期标签页,重复提交、网络重发与双标签页都不得重复更新次数与间隔(作答响应 `result` 只取 applied/stale,重放另用 `duplicate` 标记并返回首次结果);`correct_count` 只计答对,`wrong_count` 计答错与再学。短语复习归 #11,进度统计归 #13,不做策略配置 UI(X11)、练习模式(X08)与 FSRS。
+2 -2
View File
@@ -2,13 +2,13 @@
面向自托管场景的阅读式语言学习项目,规划提供内容导入、阅读查词、词汇与短语、复习和实例管理。
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main;#7 个人词条与学习状态已通过用户验收,PR #26 已合入 main;#8 到期单词复习已通过用户验收,PR #27 已合入 main;#9 上传 TXT、校验编码后导入本人书库已通过用户验收,PR #28 已合入 main。短语、进度与词汇库仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main;#7 个人词条与学习状态已通过用户验收,PR #26 已合入 main;#8 到期单词复习已通过用户验收,PR #27 已合入 main;#9 上传 TXT、校验编码后导入本人书库已通过用户验收,PR #28 已合入 main;#10 编辑与删除本人书籍章节已通过用户验收,PR #29 已合入 main;#11 连续短语的选择、保存与复习已通过用户验收,PR #30 已合入 main;#12 词汇库的搜索、筛选与编辑已通过用户验收,PR #31 已合入 main。#13 完成章节与个人基础进度已通过用户验收,PR #33 已合入 main(schema v7 需显式迁移)。#14 桌面与手机体验、主题与键盘操作已实现,待用户验收。试用交付仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
- [文档入口](docs/README.md) · [线上 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Home)
- [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口)
- [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留)
- [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md)
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#18 已验收,原规划中的 #5 已完成;剩余 #10~#15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#18 已验收,#14 已实现待验收,原规划中的 #5 已完成;剩余 #15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
- [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。
- [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。
- [工作流](docs/01-workflow.md) · [开发与验证](docs/04-local-development-and-verification.md)
+18 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.-
wiki_revision: b85c7f4650aae95b1429c0b4f7d1412c440731ce
synchronized_at: 2026-09-13T14:36:36Z
wiki_revision: 0c0354499b49ef5973ffcdc47e9eba40f447536b
synchronized_at: 2026-09-15T06:30:56Z
<!-- gitea-wiki-mirror:end -->
# LexGo 项目档案
@@ -158,3 +158,19 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必
## #9 当前工程状态(2026-09-13)
#8已通过用户验收并合入main。#9 TXT文件导入已按用户确认的契约实现,并于2026-09-13通过用户验收,PR #28已fast-forward-only合入main;本单没有数据库结构变化,schema仍为v6。解码只接受UTF-8(可选BOM剥离且不进原文),非法字节整体拒绝、不使用替换字符,UTF-16按BOM识别后明确拒绝;文件字节上限2MiB并仍受单章100000码点约束;换行与空白不归一化。文件只在内存中解码,不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有PasteBook/PasteChapter,分章、任务幂等与恢复与粘贴一致。第4阶段已完成#9;剩余#10~#15与#21、#24尚未实施。
## #10 当前工程状态(2026-09-14)
#9已通过用户验收并合入main。#10编辑与删除书籍章节已按用户确认的契约实现,并于2026-09-14通过用户验收,PR #29已fast-forward-only合入main;本单没有数据库结构变化,schema仍为v6。可改书名、章节标题与章节正文;只有正文变化才重新处理,重复保存或改回原内容不新建任务,只改标题不改变处理状态。版本门控按content_sha256执行:过期版本任务被标为superseded且完全不触碰章节,认领、恢复扫描与重试都按版本裁决,存储文本重算SHA与存储SHA不一致时按content_changed失败——这修掉了「处理中编辑导致新版本被标失败」的既有缺陷。删除为事务内硬删除加外键级联,删章后重排序号;个人词条、复习排期与作答记录不随删除清理。第4阶段已完成#9与#10;剩余#11~#15与#21、#24尚未实施。
## #11 当前工程状态(2026-09-14)
#10已通过用户验收并合入main。#11连续短语的选择、保存与复习已按用户确认的契约实现,并于2026-09-14通过用户验收,PR #30已fast-forward-only合入main。用户在验收前明确确认了D3的存储方式:短语与单词共用lexgo_terms,单词键不含空格、短语键以空格分隔,因此kind与词数由身份键在服务端派生,**不新增列、无迁移**,schema仍为v6;唯一键、排期表、到期队列与作答幂等路径全部复用。范围规则为两端对齐整词、内部标点与换行保留、2~12词、键≤128字符、片段≤191字符;跨章节按连续词形匹配,重叠取最左最长,短语高亮覆盖内部单词但不修改单词数据;失效引用回退为高亮消失而条目与排期保留;选择使用原生拖选与手机系统手柄,不拦截touchmove。独立审核(Claude Code)判定代码达标并指出一处流程问题(先实现后确认数据结构变更),已在评论7912记录确认与整改。第4阶段已完成#9、#10与#11;剩余#12~#15与#21、#24尚未实施。
## #12 当前工程状态(2026-09-15)
#11已通过用户验收并合入main。#12词汇库的搜索、筛选与编辑已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #31已fast-forward-only合入main。本单没有数据库结构变化,schema仍为v6。`GET /api/v1/terms`提供本人当前语言的分页列表,搜索同时匹配规范化身份键、显示原文与个人释义且大小写不敏感(term列为二进制排序规则,查询先转小写),通配符按字面值转义,状态与类型可与搜索组合,分页默认20、上限100并按最近更新倒序;`PATCH /api/v1/terms/:id`复用同一套释义/例句与状态/等级校验,身份不可编辑,只有状态或等级变化才重排复习时间,历史作答记录与计数保留。学习端新增生词本页面,含搜索、筛选、分页、空态、无结果清除与编辑对话框(学习中显示1~7等级选择器),筛选与页码写入URL;表单字段抽成共享组件与阅读器面板复用。原型编辑页的「来自某章节」不实现,因为词条按身份存储、不引用章节。第4阶段已完成#9、#10、#11与#12;剩余#13~#15与#21、#24尚未实施。
## #13 当前工程状态(2026-09-15)
#12已通过用户验收并合入main。#13章节完成与个人基础进度已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #33已fast-forward-only合入main。schema升到v7,新增lexgo_chapter_progress(一章一行、按content_sha256做内容版本门控、级联到章节与账号),仍然只使用可重放的CREATE TABLE IF NOT EXISTS,迁移需显式执行。POST /api/v1/chapters/:id/complete只记已读、不改变任何词语状态或等级,只有处理完成的章节可标记,重复调用返回同一条记录且不重复计数;GET /api/v1/progress按本人与当前语言统计已读章节(分子与分母都只含可阅读章节)、已知、学习中、新词、忽略、已保存词条与待复习,并给出每本书的已读进度,其中待复习与到期复习队列共用同一查询与同一服务端时钟。学习端新增进度页面与阅读页的显式「标记本章已读」,书籍页显示已读标记。正文改成新版本后该章自动回到未读并可在重读后更新同一条记录,删除章节时记录随之消失。第4阶段已完成#9、#10、#11、#12与#13;剩余#14、#15与#21、#24尚未实施。此外发现并单独记录了既有缺陷#32(编辑章节正文回到曾用版本返回500,已定位未修复,修复方案待用户确认)。
+69 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
wiki_revision: ce95236e4c8de27c1063448571cfc528c895f937
synchronized_at: 2026-09-13T15:20:16Z
wiki_revision: a4fd3873678017ea046a6e708141d7f8140edcef
synchronized_at: 2026-09-15T07:11:02Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -288,7 +288,7 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
学习端 `ImportView.vue` 增加「粘贴文本 / TXT 文件」来源切换(沿用已验收 v1 的切换与状态行),`stores/library.ts` 增加 `upload()` 与 `fileProblem`/`fileSizeLabel`,`session.request` 支持 `FormData`(multipart 请求不再被 JSON 化,边界由浏览器提供)。客户端预检只提前反馈,服务端结论为最终结论。
## #10 编辑与删除书籍章节(2026-09-11)
## #10 编辑与删除书籍章节(2026-09-11,已验收并合入 main)
`server/app/lexgo/edit.go` 提供改名、编辑与删除;`ingest.go` 增加版本门控。**schema 无变化**:`chapters.content_sha256` 与 `jobs.content_sha256` 就是版本键,新增的 `superseded` 复用现有 `error_reason` 列。
@@ -305,3 +305,69 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
`unprocessableReason` 保留一条内容一致性检查:存储的正文重新计算出的 SHA 必须等于该章节存储的 SHA,用于兜住绕过 API 的直接写入(`content_changed`),与版本门控互不重复。
学习端 `BookView.vue` 增加书名编辑对话框、章节编辑对话框(标题 + 正文,正文来自 source 接口)与两处确认弹窗(`ElMessageBox`),章节行增加「编辑」入口;`LibraryView.vue` 在书库被删后显示「书籍已删除 · 已保存的生词和短语仍保留在生词本。」;`stores/library.ts` 增加 `renameBook`、`updateChapter`、`loadChapterSource`、`deleteBook`、`deleteChapter`。正文编辑通过浏览器 textarea 输入,因此该章的行尾统一为 LF(粘贴与 TXT 导入仍保留原始 CRLF)。
## #11 短语选择、保存与复习(2026-09-11,已验收并合入 main)
短语与单词共用一张表和一套复习机制:`lexgo_terms` 的 `term` 列存身份键,**单词键不含空格、短语键以空格分隔**,所以「词或短语」不需要额外列,也不需要第二套排期/队列/作答逻辑。`kind` 与词数由身份键在服务端派生(`termKind`/`termWordCount`),视图与队列项随响应返回。
`server/app/lexgo/phrase.go`:
| 部分 | 职责 |
|---|---|
| `phraseWords` | 从本人 ready 章节的 token 里取完全落在选区内的词;**切进单词的范围直接 400**,不静默丢弃;2~12 个词 |
| `phraseKey` / `phraseSource` | 身份键=按顺序的规范化词形以单个空格连接;显示片段=选区原文(内部标点与换行保留) |
| `phraseMatches` | 跨章节匹配:按首词分组后顺序比对词形,候选按 (起点, 长度降序, id) 排序并取**最左最长**的互不重叠集合 |
| `phrasesForChapter` | 按 `term LIKE '% %'` 取本人短语并匹配,供 tokens 响应使用 |
| `SavePhrase` | 由服务端推导身份后走与单词相同的 `saveTerm` upsert;`kind` 冲突返回 409 |
| 接口 | 说明 |
|---|---|
| POST /api/v1/phrases | `{chapterId,start,end,definition,examples[],status,level?}`;本人 ready 章节;服务端推导词序列与身份,不接受客户端身份;首次 201、重复 200 |
| GET /api/v1/terms/:id | 复用;响应增加 `kind` 与 `wordCount` |
| GET /api/v1/chapters/:id/tokens | 响应增加 `phrases:[{id,status,wordCount,startToken,endToken}]` |
| GET /api/v1/reviews/queue | 队列项增加 `kind` 与 `wordCount`;短语与单词同一队列、同一作答接口 |
学习端:`composables/readerRange.ts` 是纯函数层(整词对齐、内部保留、端点按词调整、命中优先级、区间换算),`composables/useTextSelection.ts` 监听 `selectionchange`(100ms 去抖)与 document 的 `pointerup` 读取浏览器原生选区并映射为 token 索引,**不拦截 touchmove、不 preventDefault**;`ReaderTokens.vue` 为每个 token 输出 `data-token-index` 与短语区间样式;`ReaderView.vue` 负责把选区变成短语、shift 点击扩展、以及面板端点调整;`LookupPanel.vue` 增加短语标题与四个端点按钮;复习卡用 `maskedPrompt` 把整段短语挖成一个空。已保存短语点击优先打开短语面板,单词数据不受影响。
## #12 词汇库:搜索、筛选与编辑(2026-09-11,已验收并合入 main)
`server/app/lexgo/vocabulary.go` 提供只读列表与按 id 编辑,**不改 schema**(沿用 #11 的「身份键派生 kind/词数」)。
| 接口 | 权限与输入/输出 |
|---|---|
| GET /api/v1/terms | 本人+当前语言;`query`、`status`、`kind`、`page`、`limit`;返回 `{items,total,page,limit}`;默认 `page=1`、`limit=20`,上限 100;排序 `updated_at DESC, id DESC` |
| PATCH /api/v1/terms/:id | 本人;`{definition?,examples?,status?,level?}`;返回 `{term}`;身份字段不接受输入 |
参数校验沿用审计列表的既有约定:查询键白名单、重复键与未知键 400、`page ≥ 1`、`limit ≤ 100`、枚举值非法 400。搜索同时匹配规范化身份键、显示原文与个人释义;因为 `term` 使用 `utf8mb4_bin`,查询会先转小写再比较;`%`、`_`、`\` 经 `escapeLike` 转义后作为字面值,避免 `%` 命中全部。`kind` 通过键是否含空格判断(`term NOT LIKE '% %'` / `term LIKE '% %'`)。
编辑复用同一套领域规则:`termContent` 校验释义与例句上限、`termLevel` 校验状态与等级边界(只有 `learning` 带 1~7 级),只有状态或等级变化才调用 `syncTermReview` 重排(`reschedule=false` 时只补建缺失的排期行),因此**只改文本不动排期**、历史作答记录与计数保留。
学习端 `stores/vocabulary.ts` 保存列表状态与筛选(打开/关闭编辑对话框、从阅读器返回都不会丢),`views/VocabView.vue` 提供搜索框与显式搜索按钮、状态与类型筛选、分页、空态与「没有匹配的词条 + 清除搜索与筛选」,并把筛选与页码同步到 `/vocab?...`(`router.replace`)。释义/例句/状态/等级四个字段抽成 `components/TermFormFields.vue`,与阅读器面板共用;等级选择器只在词汇库编辑对话框中出现(阅读器面板仍只有四个状态)。
## #13 章节完成与个人基础进度(2026-09-15,已验收并合入 main)
schema v7 新增 `lexgo_chapter_progress`:一章一行(`chapter_id` 主键),带 `owner_id`、`book_id`、`language`、`read_sha256`(标记时该章 `content_sha256` 的快照)、`read_at` 与时间戳,外键级联到章节、书籍与账号。仍然只加新表,不用 `ALTER TABLE ADD COLUMN`,保持每个版本 DDL 可重放。
| 接口 | 权限与输入/输出 |
|---|---|
| POST /api/v1/chapters/:id/complete | 本人;只有 `ready` 章节可标记,否则 409;重复调用返回同一行并带 `duplicate`;返回 `{progress:{chapterId,bookId,read,readAt,duplicate}}` |
| GET /api/v1/progress | 本人当前语言;返回 `{readChapters,totalChapters,knownTerms,learningTerms,newTerms,ignoredTerms,savedTerms,dueNow,books[]}` |
`server/app/lexgo/progress.go` 集中实现:`CompleteChapter`(事务内锁定章节,缺行则插入,内容相同则原样返回,内容不同则把同一行推进到新版本)、`readAtByChapter`(批量取章节的已读时间,只认可「标记快照=当前内容」且章节 `ready`)、`LearnerProgressFor`(阅读分母与分子都只算 `ready` 章节,词条按四种状态分组,`dueNow` 复用 `dueTermsQuery`)。`review.go` 抽出 `dueTermsQuery`,到期复习队列和进度页共用同一谓词与同一服务端时钟,避免两处漂移。`ChapterSummary` 增加 `readAt` 字段,`BookDetail`、`ChapterDetail` 与章节编辑响应都会解析它,所以列表、阅读器和编辑后的状态一致。
学习端新增 `/progress` 页面与导航「进度」(`stores/progress.ts`、`views/ProgressView.vue`):已读章节、待复习、已知词、学习中、新词、忽略六张卡片,加每本书的已读进度与进入书籍的链接、已保存词条总数、刷新与失败重试、无书时的引导。阅读器正文下方新增显式的「标记本章已读」区块(`ReaderView.vue`,`data-testid="mark-read"`),显示已读时间与重复标记提示;书籍页章节列表对已读章节显示「已读」标记(`BookView.vue`)。`stores/library.ts` 增加 `markChapterRead`,只把 `readAt` 写回章节列表与打开中的阅读器,不动其他字段。
## #14 桌面与手机体验、主题与键盘(2026-09-15)
**没有 schema 变化、没有新接口**:本单只改学习端与样式。事实先行:用带触摸的 390×844 移动视口逐个打开登录/书库/导入/书籍/阅读/查词面板/生词本/复习/进度,`scrollWidth` 全部等于 `innerWidth`,没有溢出元素——前几单的响应式基础成立,本单把它固化成自动化移动测试,并补上缺失的偏好与键盘操作。
| 新增文件 | 职责 |
|---|---|
| `learner/src/stores/preferences.ts` | 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大);按账号存在 `lexgo-learner-display:<账号 id>` |
| `learner/src/components/DisplaySettings.vue` | 站点头部统一的「显示」下拉,展示当前值并可键盘操作 |
| `learner/src/composables/readingPosition.ts` | 按账号+章节保存滚动比例与该章 `content_sha256` |
| `learner/src/composables/reviewShortcuts.ts` | 复习页键盘映射与「不抢输入框」的判断 |
`style.css` 收敛为一套语义调色板:`:root` 里 53 个变量是**文件内仅有的颜色字面量**,其余规则全部走 `var(...)`;`html[data-theme='dark']` 覆盖同一批变量,`html.dark` 同时映射 Element Plus 的暗色变量(`main.ts` 引入 `element-plus/theme-chalk/dark/css-vars.css`),所以页面与组件跟随同一个选择。字号经 `--reader-font-scale` 只作用于阅读面(章节正文、释义面板内容、复习卡的词与释义),不做全局缩放。`ReaderView.vue` 在章节就绪后把正文下方的位置恢复到上次比例(正文版本变化则不恢复,等待可滚动后再应用,最多约 1 秒后放弃),并在滚动时按 400ms 防抖保存。`ReviewView.vue` 加了 `useReviewShortcuts` 与一行快捷键提示;`DisplaySettings` 出现在七个学习页面的头部。
移动测试项目:`playwright.config.ts` 增加 `mobile`(390×844,`isMobile` + `hasTouch`,Chromium),桌面项目用 `testIgnore` 排除 `mobile-*.spec.ts`,移动项目用 `testMatch` 只跑它们。`e2e/mobile-fixtures.ts` 提供移动端共用的 mock、无溢出检查与经 CDP `Input.dispatchTouchEvent` 的真实触摸滑动。
+66 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
wiki_revision: 96eec89f74db744d8807baa24b1d1d611ff6bf8f
synchronized_at: 2026-09-13T15:20:16Z
wiki_revision: 5a44b1713b3fd2374baa78314872539f3e2e23ab
synchronized_at: 2026-09-15T07:11:02Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -226,3 +226,67 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
**归属与边界**:改名、编辑、删除、读取编辑用原文都严格按会话归属;他人资源与不存在资源统一 404,未登录 401,空标题/空正文/超长文本/未知字段 400。学习端正文编辑框的行尾会统一为 LF(浏览器 textarea 行为),粘贴与 TXT 导入路径仍然保留原始 CRLF 与空白。
**范围边界**:不做封面与音频附件(#21)、不做回收站/撤销、不做批量操作、不做章节跨书移动、不做语言变更。
## #11 短语规则(2026-09-11)
**身份**:短语身份=按阅读顺序的规范化词形以单个空格连接(`a small, step` 与 `a small step` 都是 `a small step`),归属 `(owner, language)`;显示片段保存最近一次保存时的原文(内部标点、换行、多余空格原样保留,仅用于显示)。与单词共用同一张表,因此同一短语在不同章节保存只会得到一条记录,也共用同一幂等键与同一复习排期。
**范围**:两端对齐整词;首尾若落在空白或标点则跳过;**内部**标点与换行保留但不参与身份比较;不切开代理对、ZWJ 与组合字符。选区内少于 2 个词不是短语(单个词走单词面板),超过 12 个词、身份键超过 128 字符或原文片段超过 191 字符都返回 400 并给出可读提示。切进单词中间的范围被拒绝,而不是静默丢弃那个词。
**跨章节匹配**:在章节的词片段序列中找**连续词**,其规范化词形逐个相等(中间允许任意标点与空白)。因此编辑正文后:短语仍出现则继续高亮;不再出现则该章不高亮,但**词条与复习排期保留**;章节被删除同样保留。短语不存章节锚点,所以不存在悬空引用。
**重叠与点击**:同一位置多个候选按 (起点升序, 长度降序) 取互不重叠者,即「最左最长」,结果与输入顺序无关。短语高亮覆盖其内部的单词高亮,但**不修改单词数据**(状态、排期、计数都保留);点击命中规则是「在已保存短语范围内 → 打开短语面板,否则打开单词面板」。保存后的高亮立即出现,不需要重新加载分词。
**选择交互**:桌面用浏览器原生拖选(`selectionchange` 去抖 + `pointerup`),手机依赖系统选择手柄且**不拦截 touchmove**,面板提供起点/终点四个按钮按词调整,Shift 点击可把范围从一个词扩展到一个词,Escape 取消并保留阅读位置。单击(折叠选区)仍然是单词查询。真实手感属于运行验证范围,真机证据缺口保留。
**复习**:短语进入同一个到期队列与同一套间隔表;卡片正面显示短语并把**整段短语挖成一个空**(例句里没有该短语时只显示短语本身),答案面显示个人释义;答对/答错/再学与幂等、stale 规则与单词完全一致;计数归属也一致(`correct_count` 只计答对,`wrong_count` 计答错与再学)。
**范围边界**:不做短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑(#12)、真机手柄精细手感。短语的例句同样是手输内容,不自动关联原文句子。
## #12 词汇库规则(2026-09-11)
**列表范围**:只返回当前会话本人、当前语言(英语空间)的词条,单词与短语都在同一列表里;任何查询都不接受用户或语言参数,越权编号与不存在编号同样得到 404。
**搜索规则**:搜索词去首尾空白后与**规范化身份键**、**显示原文**、**个人释义**三处做子串匹配,任一命中即返回。大小写不敏感:`term` 列是二进制排序规则,因此查询先转小写再比较;`original_form` 与 `definition` 用默认排序规则。不做分词、不做前缀优化。空查询=不过滤;查询超过 64 个字符返回 400。`%`、`_`、`\` 按字面值处理(转义后传入),所以搜索 `%` 得到的是包含百分号的词条,而不是全部词条。
**筛选与分页**:`status`(新词/学习中/已知/忽略)与 `kind`(词语/短语)可单独或组合使用,并可与搜索组合;非法枚举值 400,不静默忽略。分页为 `page`(从 1 开始)与 `limit`(默认 20,最大 100),按最近更新倒序排列,返回的 `total` 是过滤后的总数;超出末页返回空列表但 `total` 不变。筛选与页码写入 URL,刷新或返回列表都保留。
**编辑规则**:编辑按词条编号进行,沿用与阅读器保存相同的领域校验——释义最长 2000 字符、例句最多 5 条且每条最长 500 字符、只有「学习中」可以带 1~7 级、其他状态等级必须为 0。**身份不可编辑**:词形、原文与「词或短语」不由客户端决定,未知字段一律 400,所以编辑不会把一条记录变成另一条。只有状态或等级真正变化时才移动复习时间(进入「学习中」按该等级的间隔排期,改回「新词」立即到期,改成「已知」或「忽略」后不再进入到期队列);只改释义或例句保留原排期。编辑不改写历史:作答次数、答对与答错计数、复习记录都保留;同一内容重复提交没有额外副作用。
**界面契约**:列表行显示「原文 / 个人释义 · 状态」(短语标注「短语」并显示等级),提供编辑入口与「开始到期复习」入口;编辑对话框在「学习中」时显示 1~7 等级选择器,其他状态不显示;保存失败保留输入与对话框。空列表提示去阅读保存,「没有匹配的词条」提供一键清除搜索与筛选。
**已知偏差与边界**:原型的编辑页顶部有「来自 A small step」(来源章节),本单**不实现**——词条按身份存储、不引用章节(#10/#11 已确认规则),因此没有可显示的来源。不包含 CSV 导入导出(X04)、复杂复习范围筛选(X11)、删除词条或批量清理(X14),也不做批量操作。
## #13 完成阅读与统计规则(2026-09-15)
**完成阅读只记已读**:标记章节已读只写一条完成记录,**不批量改变任何词语的状态或等级**(批量标已知是 X10,明确不做)。只有处理完成的章节可以标记;处理中或失败的章节返回 409,因为那不是可读过的内容。标记是显式操作,滚动到底不会自动标记。
**重复完成不重复计数**:一章一行(`chapter_id` 主键)。重复请求返回同一行并带 `duplicate=true`,**不会移动已读时间也不会增加计数**;同一内容再次标记等同于第一次的结果。
**内容版本门控**:完成记录保存标记时该章的 `content_sha256`。阅读统计只认可「记录快照=章节当前内容」且章节 `ready` 的章节,因此:
- 正文被改成新版本后,该章立刻回到未读;重新读完再标记即更新同一行,不新增第二行。
- 只改标题不影响已读;把正文改回与记录相同的内容后重新标记即可恢复。
- 章节被删除时完成记录随外键级联消失,计数同时从分子与分母减去。
**统计口径**(全部按本人与本人当前语言过滤,管理员权限不放宽归属):
- **已读章节** = 内容匹配且 `ready` 的章节数;**分母** = 同一范围内 `ready` 的章节数,处理中或失败的章节不计入分母,界面显示为「已读章节 3 / 8 章(可阅读)」,把规则显示出来。
- **已知 / 学习中 / 新词 / 忽略**分别计数,不合并;忽略单独统计,不计入已知。**已保存词条**为本人该语言的全部词条(含忽略)。
- **待复习**使用与到期复习队列完全相同的谓词(状态为新词或学习中、且 `due_at` 不晚于当前服务端时钟)与同一时钟,页面数字与复习队列 `total` 一致。
**范围边界**:不含每日目标、日历、连续天数、难度评分;不含统计导出;不做「取消已读」(正文版本变化会自然回到未读);不包含复习范围筛选(X11)与练习模式(X08)。
## #14 显示偏好、阅读位置与键盘操作规则(2026-09-15)
**主题**:可选浅色、深色、跟随系统,默认跟随系统。显式选择不受系统变化影响;选择「跟随系统」时监听系统配色变化并实时切换。深色只改变颜色变量,不改变布局与功能。
**正文字号**:三档「标准 / 大 / 特大」(1.0 / 1.15 / 1.3 倍),作用于阅读面(章节正文、释义面板正文与例句、复习卡的词与释义)。这是有意的边界:不做全局 UI 缩放,避免窄屏下导航与按钮被挤坏。
**偏好的保存与账号隔离**:主题与字号保存在本机,键包含账号 id,因此**切换账号会立刻换成该账号自己的偏好或默认值,A 的选择不会带给 B**;未登录的登录页使用系统默认;退出登录后回到默认。**不跨设备同步**(范围外)。存储值读不出、不是合法取值或来自旧格式时回退到默认,而不是部分套用或抛错。
**保留阅读位置**:按「账号+章节」保存滚动比例,并记录当时的 `content_sha256`。再次打开同一章时恢复到该比例;**正文被改成新版本后不恢复**(旧偏移指向别的文字),同账号的其他章节互不影响。位置为 0(还没滚动)不保存;比例按窗口宽度换算,所以在桌面与手机上都能落在相近位置。本机行为,不跨设备。
**键盘操作**:复习页 `空格`/`Enter` 显示答案,`1` 认识/答对、`2` 不认识/答错、`3` 再学一次;界面给出快捷键提示,并保持「显示答案」与「答对」按钮自动获得焦点,键盘用户可以全程不碰鼠标。按键规则:**输入框、文本域、下拉与可编辑区域内的任何按键都不触发快捷键**;按下的键若属于当前聚焦控件(按钮与链接的 `空格`/`Enter`)则交给浏览器处理,避免重复触发;带修饰键的组合键一律不拦截。阅读器 `Esc` 关闭释义面板,面板内的 `Esc` 只关面板。
**触摸与滚动**:没有任何阻止默认行为的触摸处理;释义面板在窄屏是底部固定面板,页面为其预留底部空间,关闭后阅读位置不跳动。**长按原生选择与手柄拖动的手感仍需真机确认**,本单的触摸证据来自模拟设备。
+79 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
wiki_revision: 251fb5de71b8ba75da3cba6eee41454d5bbd22df
synchronized_at: 2026-09-13T15:20:16Z
wiki_revision: e6255c24e7b966573a9851b1e2356e370d3daef1
synchronized_at: 2026-09-15T07:11:02Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -416,3 +416,80 @@ node --test spikes/english/view.test.mjs
真实链路验证:真实 Go API+真实 MySQL 共 36 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行清理 fixture),覆盖改名、编辑产生新版本并重新处理、重复保存幂等、删除章节重排序号、删除书籍级联、个人词条与复习队列在删除后保留、越权与非法输入拒绝;随后用临时 Playwright 用例在真实学习端完成「导入→改名→编辑正文→新版就绪→删除章节→删除书籍→书库提示」的闭环,并核对阅读器原文等于新版本。截图保存在本机 `.local/evidence/`(issue10-book-after-edit.png、issue10-chapter-deleted.png、issue10-book-deleted.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。并发只覆盖「同一章节并发删除」与「处理中编辑」两类,没有做多用户压力测试。浏览器 textarea 会把该章的 CRLF 归一为 LF,属已知边界,已记入业务规则页。
## #11 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。**本单不新增数据库列或表**,schema 保持 v6,没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 64 个顶层用例全部通过、0 跳过;含 #11 新增 5 个短语用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 106 项单测、类型检查、构建、11 项 E2E 全部通过(含 #11 新增 12 单测与 4 项 E2E) |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
覆盖内容:身份键的标点/换行/大小写/弯撇号归一、词数上限(12 与 13)、身份键与片段长度上限、切进单词被拒绝(服务端)、单词语义不被当成短语、跨章节匹配(同一短语在不同章节写法不同仍是同一条记录,两章都高亮,第一次出现两次)、最左最长与输入顺序无关、短语覆盖内部单词而不改动单词记录、短语进同一到期队列并作答(201 应用、重复提交回放)、越权 404 与未登录 401、编辑正文后不再出现则不报错且词条与排期保留、删除章节后词条保留;前端纯函数层覆盖整词对齐、内部保留、端点按词调整不反向、命中优先级与区间换算;E2E 覆盖**真实鼠标拖选**(Chromium 真实输入)→ 短语面板 → 保存 → 两次出现同时高亮 → 端点调整 → 点击已保存短语 → 复习整段挖空。
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程,脚本自建两章 fixture 并在结束前删除书籍)。覆盖保存与身份键、显示原文保留标点、跨章节同一条记录与两处高亮、span 两端必须是词、短语内的单词仍是独立词条、单词/切词/伪造身份/越权/未登录的拒绝、到期队列与作答(含重复提交回放)、编辑正文后不再高亮但条目与排期保留、删除章节后条目保留。随后用临时 Playwright 用例在真实学习端完成「导入 → 选择范围 → 保存短语 → 两处高亮 → 点击已保存短语 → 复习整段挖空 → 清理 fixture」闭环,并用程序化选区在同一真实页面上验证范围映射与服务端身份一致。
未验证与已知限制:真实手机手柄与滚动的手感仍是 #4 起的既有缺口,本单只用桌面浏览器检查。**Playwright 的合成鼠标拖拽在真实页面上不会扩展原生选区**(在同一浏览器里对 mock 页面是成功的,程序化选区在真实页面也能唤起面板),因此真实链路的范围构建改用真实点击 + Shift 点击,连续拖选由 mock E2E 与程序化选区覆盖;真人鼠标拖选与真机手柄仍需人工复核。短语只在同一学习者与语言内匹配,不跨账号共享。
## #12 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。**本单不新增数据库列或表**,schema 保持 v6,没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 69 个顶层用例全部通过、0 跳过;含 #12 新增 4 个用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 120 项单测、类型检查、构建、13 项 E2E 全部通过(新增 13 单测与 2 项 E2E) |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
覆盖内容:查询解析与边界(未知键、重复键、`page=0`、`limit=101`、非法状态与类型、查询超长)、LIKE 通配符转义、空查询与去空白、三处命中(身份键/显示原文/中文释义)、大小写不敏感、搜索与状态/类型筛选的组合、分页(首/末/越界页与 `total` 稳定、排序稳定)、只返回本人与当前语言、编辑(只改文本不动排期、改状态重排、进入学习中按等级排期、已知或忽略立刻离开到期队列、改回新词立即到期、计数与作答记录保留、同内容重复提交无副作用)、等级与状态边界、身份不可编辑、空编辑与未知字段 400、越权 404 与未登录 401;前端覆盖列表与分页、搜索与筛选(含 URL 恢复)、空态与无结果清除、编辑对话框的等级选择器、保存失败保留输入、旧响应丢弃。
真实链路验证:真实 Go API+真实 MySQL 共 40 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行恢复 fixture),覆盖列表字段与排序、大小写不敏感搜索、中文释义搜索、通配符字面值、类型与状态筛选组合、分页与越界页、参数拒绝、两账号隔离、未登录、编辑的排期规则与身份不可编辑。随后用临时 Playwright 用例在真实学习端完成「进入生词本 → 按类型筛选 → 搜索 → 编辑释义 → 保存 → 刷新后筛选仍保留 → 还原释义」闭环。截图保存在本机 `.local/evidence/`(issue12-vocab-list.png、issue12-vocab-none.png、issue12-vocab-edit.png、issue12-real-list.png、issue12-real-search.png、issue12-real-edit.png),临时用例运行后删除。
未验证:真实手机触屏与滚动证据仍属 #4/#11 起的既有缺口(由 #14 承接);本单只用桌面浏览器检查。搜索只做子串匹配,未做分词或相关度排序;大词汇量的性能未做专门压测(单页上限 100,列表查询使用 `owner_id + language` 前缀的既有索引)。
## #13 迁移与验证(2026-09-15)
本单新增 schema v7(`lexgo_chapter_progress`),需要**显式**迁移:`python scripts/server.py migrate`。迁移只创建一张新表,不修改既有表与数据;回退时把 `lexgo_schema.version` 写回 6 并丢弃该表即可,业务数据不受影响(本机回退二进制保存在忽略的 `.local/lexgo-pre-issue13.exe`)。
开发库 lexgo_dev 迁移前后逐表计数一致(`sys_user` 6、`lexgo_books` 27、`lexgo_chapters` 33、`lexgo_ingest_jobs` 42、`lexgo_terms` 12、`lexgo_term_reviews` 12、`lexgo_review_answers` 55、`lexgo_dictionaries` 1、`lexgo_login_logs` 137、`lexgo_operation_logs` 5、`lexgo_sessions` 28),schema 6→7,快照见 `.local/issue13-before.json` 与 `.local/issue13-after.json`。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | 70 个顶层用例全部通过、0 跳过(基线 67;本单新增 3 个用例) |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 130 项单测、类型检查、构建、15 项 E2E 通过(本单新增 10 单测与 2 项 E2E) |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
覆盖内容:完成章节的幂等(第二次不移动时间、只有一行)、非 ready 章节 409、他人与不存在 404、未登录 401、阅读与阅读器/书籍列表的已读状态一致、正文新版本后回到未读(记录行保留、更新时间)、只改标题保持已读、章节删除后计数与记录同时消失、已知/忽略/学习中/新词分别计数、已保存词条总数、`dueNow` 与复习队列 `total` 一致、每本书的已读进度、另一账号与另一语言不串数据、迁移 v6→v7 与「回滚标记后重新升级」、新表一章一行的唯一性与级联删除。
真实链路验证:真实 Go API 与真实 MySQL 共 37 项检查通过(`.local/verify-issue13-api.py`,可重复运行,自建 fixture 书籍并在结尾删除),其中页面数字与 SQL 直查逐项比对(已达到/总可读章节、待复习、四种词条状态、已保存总数)全部相等;随后用临时 Playwright 用例在真实学习端完成「粘贴章节 → 处理完成 → 标记已读 → 书籍列表显示已读 → 进度页数字 → 刷新后一致 → 再次标记不重复计数」,并用 `.local/tmp/check-issue13-page.py` 把浏览器上显示的数字与数据库逐项比对(全部一致)后清理 fixture。截图保存在本机 `.local/evidence/`(issue13-reader-unread/read、issue13-progress 为 mock 流程;issue13-real-* 为真实链路),临时用例运行后删除。
**本单顺带记录一个既有缺陷**:编辑章节正文回到曾经用过的版本会返回 500(#32,`lexgo_ingest_jobs` 唯一请求键与 `edit:<章节>:<内容摘要>` 键冲突),不属于本单范围,未修改代码;真实验证脚本因此改为自建 fixture 而不改写既有正文。
未验证:真实手机触屏与滚动证据仍属既有缺口(#14 承接);阅读进度没有按书排序或搜索(进度页按书籍编号列出);没有大数据量下的统计性能压测(统计为带前缀索引的计数查询)。
## #14 移动与主题验证(2026-09-15)
本单**无 schema 变化、无迁移**,纯学习端与文档改动;回退只需回到上一提交。
| 命令 | 结果 |
|---|---|
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 147 项单测、类型检查、构建、**23 项 E2E**(桌面 19 + 移动 4)全部通过;本单新增 20 单测与 8 项 E2E |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端无改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | 70 项通过(后端未改动,仍复跑一遍确认) |
**移动项目**:`npx playwright test --project=mobile` 使用 390×844、`isMobile` 与 `hasTouch` 的 Chromium。覆盖内容:八个界面(书库/导入/书籍/阅读/查词面板/生词本/复习/进度)在 390px 下 `scrollWidth ≤ innerWidth` 且没有越界元素、每页都能返回书库、显示控件可用手指点开且高度不小于 36px、经 CDP 触摸事件的上滑真的滚动了章节且没有误开释义面板、面板不越过视口且页面为它预留底部空间、关闭面板后阅读位置不跳、深色与特大字号在刷新后保持、退出登录回到默认且同一账号回来仍是自己的选择。
**主题回归测试**:`e2e/theme.spec.ts` 逐个读回 16 个调色板变量,断言浅色与深色下都解析成非空值、页面背景不是透明、正文与背景不同色、深浅两套取值不同、Element Plus 的 `--el-bg-color` 也跟随。这条用例来自本单的一个真实教训:批量把颜色字面量替换为变量时,调色板自身的定义也被替换成了自引用变量,所有颜色静默失效——几何断言全过,只有焦点环探针把问题暴露出来,所以补了这条变量级回归。
**真实链路**:真实学习端+真实 Go API+真实 MySQL 走了一遍——粘贴 40 行长章节并处理完成,滚到 45% 后离开再返回,位置被恢复(偏差 < 150px,页面提示「已回到上次阅读位置」);切到深色+特大后刷新仍保持,正文实际字号变大(> 18px);在真实到期卡上按空格显示了答案(**故意不评分**,避免改动 fixture 账号的真实复习计划);另用 390×844 触摸上下文在真实数据上检查了书库/生词本/进度无横向溢出。fixture 书籍在结束时删除;截图见 `.local/evidence/issue14-real-*.png`、`.local/evidence/issue14-mobile-*.png`、`issue14-theme-*.png`、`issue14-keyboard-review.png`。临时用例运行后删除。
**未验证**:真实手机上的长按选择、手柄拖动、滚动惯性、软键盘遮挡与安全区表现无法由本机自动化产生——本单的触摸证据来自模拟设备;**真机验证仍需人工完成**,这一点在 #4、#11 就已记录,本单未能消除。也未做 Safari/Firefox 等其他引擎的验证(项目既有测试只在 Chromium 上跑)。
+27 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
wiki_revision: 82d714b949d38f20586fbe42797b2dec6b0f19f1
synchronized_at: 2026-09-13T15:20:17Z
wiki_revision: 912089b338bf3c978add09069e39e7fb19c94240
synchronized_at: 2026-09-15T07:11:02Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -260,6 +260,30 @@ F03 的 TXT 文件导入已于 2026-09-13 通过用户验收:学习端导入
## #10 交付范围更新(2026-09-11)
F01 的编辑与删除已实现,待用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
F01 的编辑与删除已于 2026-09-14 通过用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
仍未实现并留给后续工单:短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。封面与音频附件(#21)、回收站/撤销、批量操作、章节跨书移动与语言变更不在本单范围。
## #11 交付范围更新(2026-09-11)
F08 的短语学习与 F10 的短语复习已于 2026-09-14 通过用户验收:阅读器支持连续选择(桌面原生拖选、手机系统手势、面板按词调整端点),保存个人释义、例句与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,同一短语跨章节只存一条记录并在出现处高亮;复习卡片把整段短语挖成一个空。范围规则(整词对齐、内部标点保留、最多 12 词、最左最长重叠)与失效引用回退(高亮消失、学习记录保留)已固化。本次没有数据库结构变化。
仍未实现并留给后续工单:词汇库搜索与编辑(#12,含短语编辑界面)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑与真机手柄精细手感不在本单范围。
## #12 交付范围更新(2026-09-11)
F09 的词汇库已于 2026-09-15 通过用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、显示原文或个人释义,大小写不敏感、通配符按字面值)与状态/类型筛选,可编辑释义、例句、状态与 1~7 学习等级,提供「开始到期复习」入口、空态与「没有匹配的词条 + 清除搜索与筛选」。编辑沿用与阅读器相同的领域校验与排期规则:身份不可编辑、只有状态或等级变化才移动复习时间、历史作答记录与计数保留;筛选与页码写入 URL,刷新或返回列表都保留。原型编辑页的「来自某章节」不实现(词条按身份存储、不引用章节)。本次没有数据库结构变化。
仍未实现并留给后续工单:阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24)。CSV 导入导出(X04)、复杂复习范围筛选(X11)、删除词条或批量清理(X14)不在本轮范围。
## #13 交付范围更新(2026-09-15)
F11 的完成阅读与基础进度已于 2026-09-15 通过用户验收:阅读页提供显式「标记本章已读」(只记已读、不批量改变词语状态),书籍页章节列表显示已读,新的进度页面显示已读章节(分母只含可阅读章节)、待复习、已知词、学习中、新词、忽略与已保存词条数,以及每本书的已读进度;重复标记不重复计数,待复习与到期复习队列共用同一规则与时钟。正文改成新版本后该章回到未读并可在重读后更新同一记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移。
仍未实现并留给后续工单:桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24)。每日目标、日历、难度评分、统计导出明确不在范围内。另记录既有缺陷 #32(编辑章节正文回到曾用版本返回 500,已定位未修复,方案待用户确认)。
## #14 交付范围更新(2026-09-15)
F12 的电脑与手机基础体验已实现,待用户验收:新增「显示」设置(浅色/深色/跟随系统;正文标准/大/特大),偏好按账号保存在本机、账号切换不串、不跨设备同步;阅读位置按账号与章节保留,正文换新版本后不套用旧位置;复习页支持空格显示答案与 1/2/3 评分,输入框内不误触全局快捷键,焦点环可见;手机端以 390×844 触摸视口验证八个界面无横向溢出、均可返回、触摸滑动可滚动且不误开释义面板。本次无数据库与接口变化。
仍未实现并留给后续工单:自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24),以及已定位未修复的缺陷 #32。PWA、离线能力、自定义配色、全局 UI 缩放与偏好跨设备同步明确不在范围内。**真实手机的长按选择与操作手感仍需人工确认**,本单的触摸证据来自模拟设备。
+11 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
wiki_revision: ea264da6747bd141ec2d67011830320452563415
synchronized_at: 2026-09-13T15:20:16Z
wiki_revision: 591ce1b3b16af9401062af925ba159ebdc4b8c6a
synchronized_at: 2026-09-15T07:11:01Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -77,4 +77,12 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#9 TXT 文件导入已于 2026-09-13 通过用户验收:导入页新增「粘贴文本 / TXT 文件」来源切换,只接受 UTF-8(允许可选 BOM)且不替换损坏字符,UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;上传与粘贴共用同一分章、任务与幂等规则,重复上传同一文件只产生一章。本次没有数据库结构变化;PR #28 已 fast-forward-only 合入 main。
#10 编辑与删除书籍章节已实现,待用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
#10 编辑与删除书籍章节已于 2026-09-14 通过用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化;旧处理结果不会覆盖新版本,PR #29 已 fast-forward-only 合入 main。
#11 短语选择、保存与复习已于 2026-09-14 通过用户验收:在正文中连续选择一个范围(桌面原生拖选、手机系统手柄、面板端点按钮与 Shift 点击调整),保存个人释义与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,因此同一短语在不同章节只存一条记录并在出现的每处高亮,复习卡片把整段短语挖成一个空。短语与单词重叠时短语高亮覆盖、单词数据不变;编辑正文后短语不再出现时不报错,词条与复习排期保留。本次没有数据库结构变化;用户确认 kind 与词数由身份键派生,PR #30 已 fast-forward-only 合入 main。
#12 词汇库的搜索、筛选与编辑已于 2026-09-15 通过用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、原文或个人释义,大小写不敏感)与状态/类型筛选,并可编辑释义、例句、状态与 1~7 学习等级。编辑沿用与阅读器相同的领域规则:身份不可改、只有状态或等级变化才重排复习时间、历史作答记录保留;筛选与页码写入 URL,刷新或返回列表都保留。原型的「来自某章节」不实现,因为词条按身份存储、不引用章节。本次没有数据库结构变化;PR #31 已 fast-forward-only 合入 main。
#13 完成章节与个人基础进度已于 2026-09-15 通过用户验收:阅读页正文下方提供显式的「标记本章已读」,只记录已读、不批量改变词语状态;重复标记返回同一条记录、不重复计数。书籍页章节列表显示「已读」,新的「进度」页面显示已读章节(只统计可阅读章节,形如「3 / 8 章(可阅读)」)、待复习、已知词、学习中、新词、忽略与已保存词条数,并按书显示已读进度;待复习与到期复习队列使用同一规则与同一时钟。正文被改成新版本后该章会回到未读(记录保留),重新标记即更新同一条记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移;开发库迁移前后各表计数一致。真实 API+MySQL 37 项检查与浏览器页面数字对照数据库直查全部一致;PR #33 已 fast-forward-only 合入 main。
#14 桌面与手机体验、主题与键盘操作已实现,待用户验收:新增「显示」设置(浅色/深色/跟随系统,正文标准/大/特大),偏好按账号保存在本机,切换账号不会串;阅读位置按账号与章节保留,正文换成新版本后不再套用旧位置;复习页支持空格显示答案、1/2/3 评分,输入框内不会误触快捷键,所有可交互元素有可见焦点环。手机端用 390×844 触摸视口验证八个界面无横向溢出、都能返回、触摸滑动滚动而不误开释义面板,深色与特大字号刷新后保持。本次没有数据库与接口变化。真实手机的长按选择与手感仍需人工确认。
+94
View File
@@ -0,0 +1,94 @@
import { expect, test, type Page } from '@playwright/test'
const user = { id: 42, username: 'fictional-keyboard', role: 'learner' }
const item = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-01-02T00:00:00Z', reviewCount: 0 }
async function mockApi(page: Page): Promise<void> {
await page.route('**/api/v1/**', async route => {
const url = new URL(route.request().url())
const path = url.pathname
const method = route.request().method()
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/reviews/queue') data = { items: [item], total: 1 }
else if (path.endsWith('/answers') && method === 'POST') {
data = { result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning', levelBefore: 0, levelAfter: 1, dueAtBefore: item.dueAt, dueAtAfter: '2026-01-09T00:00:00Z', item }
} else if (path === '/api/v1/terms' && method === 'GET') {
data = { items: [{ ...item, updatedAt: '2026-01-02T00:00:00Z' }], total: 1, page: 1, limit: 20 }
}
await route.fulfill({ json: { code: 200, data } })
})
}
/** Presses Tab until the target holds the focus, so reachability is what is being tested. */
async function tabUntilFocused(page: Page, target: ReturnType<Page['getByTestId']>, limit = 15): Promise<boolean> {
for (let step = 0; step < limit; step += 1) {
if (await target.evaluate(element => element === document.activeElement).catch(() => false)) return true
await page.keyboard.press('Tab')
}
return target.evaluate(element => element === document.activeElement).catch(() => false)
}
async function login(page: Page): Promise<void> {
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
test('the due review can be driven from the keyboard alone', async ({ page }) => {
await mockApi(page)
await login(page)
await page.locator('.site-header nav').getByRole('link', { name: '到期复习' }).click()
await expect(page.getByTestId('review-card')).toBeVisible()
await expect(page.getByTestId('review-shortcuts')).toContainText('空格')
// The first action already holds the focus, so a keyboard user can keep going.
await expect(page.getByTestId('review-reveal')).toBeFocused()
// Space shows the answer, and the answer panel is announced as text.
await page.keyboard.press('Space')
await expect(page.getByTestId('review-definition')).toContainText('好奇心')
await expect(page.getByTestId('review-correct')).toBeFocused()
// A digit grades the card without touching the mouse.
await page.keyboard.press('1')
await expect(page.getByTestId('review-summary')).toBeVisible()
await page.screenshot({ path: '../.local/evidence/issue14-keyboard-review.png' })
// Tab reaches the closing action within a few presses, not after walking the whole page.
expect(await tabUntilFocused(page, page.getByTestId('review-finish'), 6)).toBe(true)
await page.keyboard.press('Enter')
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
})
test('typing in a form never triggers a global shortcut', async ({ page }) => {
await mockApi(page)
await login(page)
await page.locator('.site-header nav').getByRole('link', { name: '生词本' }).click()
await expect(page.locator('.vocab-row')).toHaveCount(1)
const search = page.getByLabel('搜索词 / 释义')
await search.click()
await search.type('curiosity 1 2 quit')
await expect(search).toHaveValue('curiosity 1 2 quit')
// The list only changes when the search is submitted, not while typing.
await expect(page.locator('.vocab-row')).toHaveCount(1)
})
test('the display control shows a visible focus ring', async ({ page }) => {
await mockApi(page)
await login(page)
const trigger = page.getByTestId('display-trigger')
// Reached by keyboard, which is what makes the focus ring appear in the first place.
expect(await tabUntilFocused(page, trigger)).toBe(true)
const outline = await trigger.evaluate(element => {
const style = getComputedStyle(element)
return { width: style.outlineWidth, style: style.outlineStyle }
})
expect(parseFloat(outline.width)).toBeGreaterThanOrEqual(2)
expect(outline.style).not.toBe('none')
})
+75
View File
@@ -0,0 +1,75 @@
import { expect, test } from '@playwright/test'
import { login, mockLearnerApi, touchSwipe } from './mobile-fixtures'
// Touch must scroll the chapter instead of being swallowed by the selection handling, and a tap
// must still open the word panel. This is emulated touch input through Chromium, not a real phone.
test('a touch swipe scrolls the chapter and does not open the word panel', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
// The words arrive with the token response, so wait for them instead of sampling once.
await expect.poll(async () => page.locator('.reader-word').count()).toBeGreaterThan(3)
// A swipe upwards scrolls the page down and leaves the panel closed.
await touchSwipe(page, 640, 240)
await expect.poll(async () => page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
expect(await page.locator('.lookup-panel').count()).toBe(0)
await page.screenshot({ path: '../.local/evidence/issue14-mobile-scrolled.png' })
// A tap on a word still opens the panel, which sits above the text without covering it.
await page.locator('.reader-word').nth(1).tap()
const panel = page.locator('.lookup-panel')
await expect(panel).toBeVisible()
const panelBox = (await panel.boundingBox())!
expect(panelBox.x).toBeGreaterThanOrEqual(0)
expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(391)
// The page reserves room for the fixed sheet, so the last paragraph can still be read.
const padding = await page.evaluate(() => getComputedStyle(document.querySelector('.reader-page') as Element).paddingBottom)
expect(parseFloat(padding)).toBeGreaterThan(100)
// Closing the panel keeps the reading position instead of jumping away.
const before = await page.evaluate(() => window.scrollY)
await page.getByRole('button', { name: '关闭释义' }).tap()
await expect(panel).toHaveCount(0)
expect(Math.abs((await page.evaluate(() => window.scrollY)) - before)).toBeLessThan(40)
})
test('the display choice survives a reload and belongs to the account', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
await page.getByTestId('display-trigger').tap()
await page.getByRole('menu').getByText('深色', { exact: true }).tap()
await page.getByTestId('display-trigger').tap()
await page.getByRole('menu').getByText('特大', { exact: true }).tap()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
const readerScale = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())
expect(readerScale).toBe('1.3')
// Reloading keeps both choices: they are stored, not just applied.
await page.reload()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())).toBe('1.3')
// The reading text really is larger, which is what the choice promises.
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
const size = await page.locator('.reader-text').evaluate(element => parseFloat(getComputedStyle(element).fontSize))
expect(size).toBeGreaterThan(16)
await page.screenshot({ path: '../.local/evidence/issue14-mobile-dark-large.png' })
// Signing out and back in as another account must not inherit the first account's choice.
await page.getByRole('button', { name: '退出登录' }).tap()
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await page.getByLabel('账号').fill('fictional-mobile')
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
// The same account keeps its own choice.
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
})
+101
View File
@@ -0,0 +1,101 @@
// Shared fixtures for the mobile (touch, 390x844) specs.
import { expect, type Page } from '@playwright/test'
export const mobileUser = { id: 42, username: 'fictional-mobile', role: 'learner' }
export const chapterText = [
'Curiosity opens the first door.',
'The second door stays closed until the reader asks.',
'A third line gives the page something to scroll.',
'Every paragraph is fictional and only used by the mobile checks.',
'Reading on a phone needs real scrolling, not a fixed frame.',
'The lookup panel must never swallow a swipe.',
'A tap still opens one word at a time.',
'Long enough text keeps the page taller than a phone screen.',
'The last line is here so the page can be scrolled to its end.',
].join('\n') + '\n'
/** A small mocked API with one book, one ready chapter and one due word. */
export async function mockLearnerApi(page: Page): Promise<void> {
const book = { id: 7, title: 'Fictional mobile book', language: 'en', chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, createdAt: '', updatedAt: '' }
const chapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional mobile chapter', status: 'ready', charCount: chapterText.length,
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
}
const term = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z' }
await page.route('**/api/v1/**', async route => {
const url = new URL(route.request().url())
const path = url.pathname
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user: mobileUser }
else if (path === '/api/v1/me') data = mobileUser
else if (path === '/api/v1/space') data = { ownerId: mobileUser.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [book] }
else if (path === '/api/v1/books/7') data = { book, chapters: [chapter] }
else if (path === '/api/v1/chapters/3/tokens') {
const tokens: unknown[] = []
let offset = 0
for (const piece of chapterText.match(/[A-Za-z]+|\s+|[^A-Za-z\s]+/g) ?? []) {
const start = offset
offset += piece.length
tokens.push({
text: piece, start, end: offset, startUtf16: start, endUtf16: offset,
kind: /^[A-Za-z]+$/.test(piece) ? 'word' : /^\s+$/.test(piece) ? 'space' : 'punctuation',
})
}
// The reader follows the tokens only while they describe the chapter's content version.
data = { textSha256: 'e'.repeat(64), tokens }
} else if (path === '/api/v1/chapters/3') {
data = { book, chapter: { ...chapter, contentSha256: 'e'.repeat(64), originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } }
} else if (path === '/api/v1/terms/lookup') {
data = { query: 'curiosity', terms: [{ term: 'curiosity', entries: [{ partOfSpeech: 'noun', definition: '好奇心' }] }], candidates: [] }
} else if (path === '/api/v1/terms' && route.request().method() === 'GET') {
data = { items: [term], total: 1, page: 1, limit: 20 }
} else if (path === '/api/v1/reviews/queue') {
data = { items: [{ ...term, dueAt: '2026-01-02T00:00:00Z', reviewCount: 0 }], total: 1 }
} else if (path === '/api/v1/progress') {
data = { readChapters: 0, totalChapters: 1, knownTerms: 0, learningTerms: 0, newTerms: 1, ignoredTerms: 0, savedTerms: 1, dueNow: 1, books: [{ id: 7, title: book.title, readChapters: 0, totalChapters: 1 }] }
}
await route.fulfill({ json: { code: 200, data } })
})
}
export async function login(page: Page): Promise<void> {
await page.goto('/')
await page.getByLabel('账号').fill(mobileUser.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
/** True when nothing sticks out sideways; the phone must never scroll left or right. */
export async function overflowOffenders(page: Page): Promise<string[]> {
return page.evaluate(() => {
const width = window.innerWidth
const offenders: string[] = []
for (const element of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
const box = element.getBoundingClientRect()
if (box.width === 0 && box.height === 0) continue
if (box.right > width + 1 || box.left < -1) {
offenders.push(`${element.tagName.toLowerCase()}.${(element.className || '').toString().split(' ').slice(0, 2).join('.')}`)
}
}
return offenders
})
}
/** A real touch swipe through CDP, which Chromium turns into scrolling. */
export async function touchSwipe(page: Page, fromY: number, toY: number): Promise<void> {
const client = await page.context().newCDPSession(page)
const send = (type: string, y: number) => client.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x: 195, y }],
})
await send('touchStart', fromY)
const steps = 8
for (let step = 1; step <= steps; step += 1) {
await send('touchMove', fromY + ((toY - fromY) * step) / steps)
}
await send('touchEnd', toY)
await client.detach()
}
+87
View File
@@ -0,0 +1,87 @@
import { expect, test } from '@playwright/test'
import { login, mockLearnerApi, overflowOffenders } from './mobile-fixtures'
// Every learner surface on a phone viewport: nothing sticks out sideways and every page can be
// left again. The measured truth in #14 was that the layouts already fit; this locks that in.
test('every page fits the phone width and can be left again', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
const surfaces: { name: string; open: () => Promise<void>; back: () => Promise<void> }[] = [
{
name: '书库',
open: async () => { await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '导入',
open: async () => { await page.getByRole('button', { name: '导入内容' }).click(); await expect(page.getByRole('heading', { name: '导入英文内容' })).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '书籍',
open: async () => { await page.getByRole('link', { name: 'Fictional mobile book' }).click(); await expect(page.locator('.chapter-list')).toBeVisible() },
back: async () => { await page.getByRole('link', { name: '我的书库' }).first().click() },
},
{
name: '阅读',
open: async () => {
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
},
back: async () => { await page.locator('.breadcrumb').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '查词面板',
open: async () => {
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
await page.locator('.reader-word').first().click()
await expect(page.locator('.lookup-panel')).toBeVisible()
},
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '生词本',
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '生词本' }).click(); await expect(page.getByRole('heading', { name: '生词本' })).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '复习',
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '到期复习' }).click(); await expect(page.getByTestId('review-card')).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '进度',
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '进度' }).click(); await expect(page.getByRole('heading', { name: '我的进度' })).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
]
for (const surface of surfaces) {
await surface.open()
expect(await overflowOffenders(page), `${surface.name} must not overflow sideways`).toEqual([])
const widths = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth }))
expect(widths.scroll, `${surface.name} must not scroll sideways`).toBeLessThanOrEqual(widths.inner)
await page.screenshot({ path: `../.local/evidence/issue14-mobile-${surface.name}.png`, fullPage: true })
await surface.back()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
})
test('the display control is reachable on a phone', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
const trigger = page.getByTestId('display-trigger')
await expect(trigger).toBeVisible()
const box = (await trigger.boundingBox())!
// A finger needs a target of at least 38 pixels; the control is in the header on every page.
expect(box.height).toBeGreaterThanOrEqual(36)
await trigger.tap()
await expect(page.getByRole('menu').getByText('主题')).toBeVisible()
await page.getByRole('menu').getByText('深色', { exact: true }).tap()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
await page.screenshot({ path: '../.local/evidence/issue14-mobile-dark.png' })
})
+130
View File
@@ -0,0 +1,130 @@
import { expect, test, type Page } from '@playwright/test'
// Real mouse selection over the rendered chapter, against a mocked API: the interaction #4
// verified is exercised as a continuous drag, not through a preset button.
const original = 'Take a small step\nevery day.\n'
const fragments: [string, 'word' | 'space' | 'punctuation'][] = [
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'],
['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'], ['.', 'punctuation'], ['\n', 'space'],
]
let cp = 0
let utf16 = 0
const tokens = fragments.map(([text, kind]) => {
const token = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
cp = token.end
utf16 = token.endUtf16
return token
})
const book = { id: 1, title: 'Phrase chapter', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const chapter = { id: 55, bookId: 1, ordinal: 1, title: 'Phrase chapter', status: 'ready', charCount: [...original].length, errorReason: '', errorMessage: '', jobId: 5, contentSha256: 'fictional-sha', originalText: original, ...timestamps }
async function openChapter(page: Page, options: { phrases?: unknown[]; onPhrase?: (body: Record<string, unknown>) => void } = {}) {
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/chapters/55/tokens') data = { textSha256: 'fictional-sha', tokens, phrases: options.phrases ?? [] }
else if (path === '/api/v1/chapters/55') data = { book, chapter, navigation: { previousChapterId: null, nextChapterId: null } }
else if (path === '/api/v1/lookup') data = { status: 'not_found', query: 'Take', matchedForm: null, candidates: [], entries: [] }
else if (path === '/api/v1/phrases' && method === 'POST') {
options.onPhrase?.(route.request().postDataJSON() as Record<string, unknown>)
status = 201
data = {
term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: [], status: 'new', level: 0, kind: 'phrase', wordCount: 3 },
created: true,
}
} else if (path === '/api/v1/terms/9') data = { term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: ['Take a small step.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3 } }
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/chapters/55')
await expect(page.locator('.reader-text')).toBeVisible()
}
/** Drags from the centre of one word to the centre of another, like a person selecting text. */
async function dragWords(page: Page, from: string, to: string) {
const words = page.locator('.reader-word')
const start = words.filter({ hasText: new RegExp(`^${from}$`) }).first()
const end = words.filter({ hasText: new RegExp(`^${to}$`) }).first()
const startBox = (await start.boundingBox())!
const endBox = (await end.boundingBox())!
await page.mouse.move(startBox.x + startBox.width / 2, startBox.y + startBox.height / 2)
await page.mouse.down()
await page.mouse.move(endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, { steps: 8 })
await page.mouse.up()
}
test('drag a continuous phrase, save it and highlight it', async ({ page }) => {
let body: Record<string, unknown> | undefined
await openChapter(page, { onPhrase: value => { body = value } })
await dragWords(page, 'Take', 'small')
const range = page.getByTestId('phrase-range')
await expect(range).toContainText('短语 · 3 个单词')
// A phrase that is not stored yet is labelled as a new entry.
await expect(page.locator('.lookup-panel')).toContainText('新词条')
await page.getByLabel(/^我的释义/).fill('拿一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-selected.png' })
await page.getByTestId('term-save').click()
await expect(page.getByText('已保存 · 新词')).toBeVisible()
// The code point range covers the whole run including its interior separator.
expect(body).toMatchObject({ chapterId: 55, start: 0, end: 12, definition: '拿一小步', status: 'new' })
// The saved phrase is underlined in the text.
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
})
test('adjust the endpoints by whole words from the panel', async ({ page }) => {
await openChapter(page)
await dragWords(page, 'Take', 'small')
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
await page.getByTestId('range-end-right').click()
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
await page.getByTestId('range-start-right').click()
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
await page.getByTestId('range-start-left').click()
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
})
test('open a saved phrase from its highlight', async ({ page }) => {
await openChapter(page, { phrases: [{ id: 9, status: 'new', wordCount: 3, startToken: 0, endToken: 4 }] })
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
await page.locator('.reader-word.is-phrase').first().click()
await expect(page.getByTestId('phrase-range')).toContainText('已保存')
await expect(page.getByLabel(/^我的释义/)).toHaveValue('拿一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-saved.png' })
})
test('a phrase review card masks the whole run as one blank', async ({ page }) => {
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/reviews/queue') {
data = {
items: [{
id: 9, term: 'a small step', originalForm: 'a small step', definition: '一小步',
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
dueAt: '2026-01-01T00:00:00Z', reviewCount: 0,
}],
total: 1,
}
}
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/review')
await expect(page.getByTestId('review-position')).toContainText('到期复习 · 1 / 1')
await expect(page.getByText('Take _____, every day.')).toBeVisible()
await expect(page.getByText('短语 · 3 个单词')).toBeVisible()
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-review.png' })
})
+104
View File
@@ -0,0 +1,104 @@
import { expect, test, type Page } from '@playwright/test'
// Completing a chapter and reading the progress page, against a mocked API.
test('marking a chapter read shows up in the book and on the progress page once', async ({ page }) => {
const user = { id: 42, username: 'fictional-progress', role: 'learner' }
const chapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31,
errorReason: '', errorMessage: '', jobId: 9, readAt: null as string | null, createdAt: '', updatedAt: '',
}
let completions = 0
await page.route('**/api/v1/**', async route => {
const url = new URL(route.request().url())
const path = url.pathname
const method = route.request().method()
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [{ id: 7, title: 'Fictional reader', language: 'en', chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, createdAt: '', updatedAt: '' }] }
else if (path === '/api/v1/books/7') data = { book: { id: 7, title: 'Fictional reader', language: 'en' }, chapters: [chapter] }
else if (path === '/api/v1/chapters/3/tokens') data = { tokens: [{ start: 0, end: 9, text: 'Curiosity', kind: 'word' }] }
else if (path === '/api/v1/chapters/3' && method === 'GET') {
data = { book: { id: 7, title: 'Fictional reader', language: 'en' }, chapter: { ...chapter, contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null } }
} else if (path === '/api/v1/chapters/3/complete') {
completions += 1
const readAt = '2026-09-15T03:04:05Z'
chapter.readAt = readAt
data = { progress: { chapterId: 3, bookId: 7, read: true, readAt, duplicate: completions > 1 } }
} else if (path === '/api/v1/progress') {
data = {
readChapters: chapter.readAt ? 1 : 0, totalChapters: 1,
knownTerms: 2, learningTerms: 1, newTerms: 3, ignoredTerms: 1, savedTerms: 7,
dueNow: chapter.readAt ? 1 : 4,
books: [{ id: 7, title: 'Fictional reader', readChapters: chapter.readAt ? 1 : 0, totalChapters: 1 }],
}
} else if (path === '/api/v1/reviews/queue') data = { items: [], total: 0 }
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
// The reader offers the explicit completion action and says what it does and does not do.
await page.goto('/chapters/3')
await expect(page.getByRole('heading', { name: 'Fictional chapter' })).toBeVisible()
await expect(page.getByTestId('mark-read')).toHaveText('标记本章已读')
await expect(page.locator('.chapter-complete')).toContainText('只记录已读,不改变词语状态')
await page.screenshot({ path: '../.local/evidence/issue13-reader-unread.png' })
// Completing shows the reading time, and repeating is answered without counting twice.
await page.getByTestId('mark-read').click()
await expect(page.getByTestId('chapter-read-state')).toContainText('本章已读')
await expect(page.getByTestId('complete-notice')).toContainText('已记为读完本章')
await page.screenshot({ path: '../.local/evidence/issue13-reader-read.png' })
await page.getByTestId('mark-read').click()
await expect(page.getByTestId('complete-notice')).toContainText('没有重复计数')
// The book list shows which chapters are read.
await page.goto('/books/7')
await expect(page.getByTestId('chapter-read-chip').first()).toHaveText('已读')
// The progress page reports the reading total and keeps the four word statuses apart.
await page.getByRole('link', { name: '进度' }).click()
await expect(page).toHaveURL(/\/progress/)
await expect(page.getByTestId('stat-read')).toContainText('1 / 1')
await expect(page.getByTestId('stat-known')).toContainText('2')
await expect(page.getByTestId('stat-ignored')).toContainText('1')
await expect(page.getByTestId('stat-due')).toContainText('1')
await expect(page.getByTestId('progress-books')).toContainText('Fictional reader')
await page.screenshot({ path: '../.local/evidence/issue13-progress.png' })
// Reloading keeps the same numbers: they come from the server, not from the page.
await page.reload()
await expect(page.getByTestId('stat-read')).toContainText('1 / 1')
expect(completions).toBe(2)
})
test('a fresh space explains the empty progress instead of showing zeros alone', async ({ page }) => {
const user = { id: 42, username: 'fictional-progress', role: 'learner' }
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/progress') {
data = { readChapters: 0, totalChapters: 0, knownTerms: 0, learningTerms: 0, newTerms: 0, ignoredTerms: 0, savedTerms: 0, dueNow: 0, books: [] }
}
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.goto('/progress')
await expect(page.getByTestId('stat-read')).toContainText('0 / 0')
await expect(page.getByTestId('progress-no-books')).toContainText('还没有书')
await expect(page.getByTestId('stat-ignored')).toContainText('0')
})
+79
View File
@@ -0,0 +1,79 @@
import { expect, test, type Page } from '@playwright/test'
const user = { id: 42, username: 'fictional-theme', role: 'learner' }
// The palette is one indirection deep: a CSS variable that references itself silently resolves to
// nothing and every surface loses its colour. These checks read the values back from the browser.
const palette = ['--bg', '--surface', '--surface-input', '--text', '--text-muted', '--border', '--accent',
'--accent-marker', '--notice-bg', '--status-ready-bg', '--status-read-bg', '--status-failed-bg', '--word-learning-bg',
'--saved', '--sense-bg', '--el-color-primary']
async function mockApi(page: Page): Promise<void> {
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
await route.fulfill({ json: { code: 200, data } })
})
}
async function login(page: Page): Promise<void> {
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
test('every palette colour resolves, in both themes', async ({ page }) => {
await mockApi(page)
await login(page)
const read = () => page.evaluate(names => {
const root = getComputedStyle(document.documentElement)
const values: Record<string, string> = {}
for (const name of names) values[name] = root.getPropertyValue(name).trim()
// The palette is declared on :root, so that is where the painted surface is read.
const html = getComputedStyle(document.documentElement)
return {
values,
body: html.backgroundColor,
text: getComputedStyle(document.body).color,
}
}, palette)
const light = await read()
for (const name of palette) {
expect(light.values[name], `${name} must resolve in the light theme`).not.toBe('')
}
// The page really paints with them: an unresolved background would be transparent.
expect(light.body).not.toBe('rgba(0, 0, 0, 0)')
expect(light.body).not.toBe('transparent')
expect(light.text).not.toBe(light.body)
await page.screenshot({ path: '../.local/evidence/issue14-theme-light.png' })
await page.getByTestId('display-trigger').click()
await page.getByRole('menu').getByText('深色', { exact: true }).click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
const dark = await read()
for (const name of palette) {
expect(dark.values[name], `${name} must resolve in the dark theme`).not.toBe('')
}
// The dark theme is a different surface, not the same one relabelled.
expect(dark.values['--bg']).not.toBe(light.values['--bg'])
expect(dark.body).not.toBe(light.body)
expect(dark.text).not.toBe(dark.body)
// Element Plus follows the same choice, so its components do not stay on a white card.
const epBackground = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--el-bg-color').trim())
expect(epBackground).not.toBe('')
await page.screenshot({ path: '../.local/evidence/issue14-theme-dark.png' })
// Back to light by hand, and the surface returns to the original value.
await page.getByTestId('display-trigger').click()
await page.getByRole('menu').getByText('浅色', { exact: true }).click()
expect((await read()).body).toBe(light.body)
})
+117
View File
@@ -0,0 +1,117 @@
import { expect, test, type Page } from '@playwright/test'
// The vocabulary list against a mocked API: search, filter, edit and the review entry.
test('search, filter and edit the saved words and phrases', async ({ page }) => {
const user = { id: 42, username: 'fictional-vocab', role: 'learner' }
const entries = [
{ id: 1, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Learning begins with curiosity.'], status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z' },
{ id: 2, term: 'a small step', originalForm: 'a small step', definition: '一小步', examples: ['Take a small step.'], status: 'learning', level: 3, kind: 'phrase', wordCount: 3, updatedAt: '2026-01-03T00:00:00Z' },
]
let list = [...entries]
await page.route('**/api/v1/**', async route => {
const url = new URL(route.request().url())
const path = url.pathname
const method = route.request().method()
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/terms' && method === 'GET') {
const query = (url.searchParams.get('query') ?? '').toLowerCase()
const status = url.searchParams.get('status') ?? ''
const kind = url.searchParams.get('kind') ?? ''
const page = Number(url.searchParams.get('page') ?? 1)
const limit = Number(url.searchParams.get('limit') ?? 20)
const filtered = list.filter(item =>
(!query || item.term.includes(query) || item.originalForm.toLowerCase().includes(query) || item.definition.includes(query))
&& (!status || item.status === status) && (!kind || item.kind === kind))
// The server orders by most recently updated first; the mock must not invent another order.
const ordered = [...filtered].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id - a.id)
data = { items: ordered.slice((page - 1) * limit, page * limit), total: ordered.length, page, limit }
} else if (path.startsWith('/api/v1/terms/') && method === 'PATCH') {
const body = route.request().postDataJSON() as Partial<typeof entries[number]>
const id = Number(path.split('/').pop())
list = list.map(item => (item.id === id ? { ...item, ...body } : item))
data = { term: list.find(item => item.id === id) }
}
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
// The vocabulary list is reachable from the navigation and lists both kinds.
await page.getByRole('link', { name: '生词本' }).click()
await expect(page).toHaveURL(/\/vocab/)
await expect(page.locator('.vocab-row')).toHaveCount(2)
await expect(page.locator('.vocab-row').first()).toContainText('a small step')
await expect(page.locator('.vocab-row').first()).toContainText('短语 · 学习中 · 等级 3')
await expect(page.locator('.vocab-row').last()).toContainText('curiosity')
await expect(page.getByText('保存的词语与短语,共 2 条')).toBeVisible()
await page.screenshot({ path: '../.local/evidence/issue12-vocab-list.png' })
// Searching keeps the filters in the URL and narrows the list.
await page.getByLabel('搜索词 / 释义').fill('small')
await page.getByTestId('vocab-search').click()
await expect(page.locator('.vocab-row')).toHaveCount(1)
await expect(page).toHaveURL(/query=small/)
await page.getByLabel('搜索词 / 释义').fill('zzzz')
await page.getByTestId('vocab-search').click()
await expect(page.getByTestId('vocab-none')).toContainText('没有匹配的词条')
await page.screenshot({ path: '../.local/evidence/issue12-vocab-none.png' })
await page.getByTestId('vocab-clear').click()
await expect(page.locator('.vocab-row')).toHaveCount(2)
// The status filter applies immediately.
await page.getByTestId('vocab-status').click()
await page.getByRole('option', { name: '新词' }).click()
await expect(page.locator('.vocab-row')).toHaveCount(1)
await expect(page.locator('.vocab-row').first()).toContainText('curiosity')
await expect(page).toHaveURL(/status=new/)
await page.getByTestId('vocab-status').click()
await page.getByRole('option', { name: '全部状态' }).click()
await expect(page.locator('.vocab-row')).toHaveCount(2)
// Editing keeps the filters and offers a level selector for a learning entry.
await page.getByTestId('vocab-edit-2').click()
await expect(page.getByLabel('我的释义')).toHaveValue('一小步')
await expect(page.getByTestId('term-level')).toBeVisible()
await page.getByLabel('我的释义').fill('一小步(改)')
await page.getByTestId('term-level').click()
await page.getByRole('option', { name: '5 级' }).click()
await page.screenshot({ path: '../.local/evidence/issue12-vocab-edit.png' })
await page.getByTestId('vocab-save').click()
await expect(page.getByTestId('vocab-notice')).toContainText('已保存')
await expect(page.locator('.vocab-row').first()).toContainText('一小步(改)')
await expect(page.locator('.vocab-row').first()).toContainText('等级 5')
await expect(page.getByLabel('搜索词 / 释义')).toBeVisible()
// The list links into the due review.
await expect(page.locator('.page-actions').getByRole('link', { name: '开始到期复习' })).toHaveAttribute('href', '/review')
})
test('an empty vocabulary invites reading instead of showing an empty list', async ({ page }) => {
const user = { id: 42, username: 'fictional-vocab', role: 'learner' }
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/terms') data = { items: [], total: 0, page: 1, limit: 20 }
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.goto('/vocab')
await expect(page.getByTestId('vocab-empty')).toContainText('生词本还是空的')
await expect(page.locator('.vocab-row')).toHaveCount(0)
})
+14 -1
View File
@@ -8,7 +8,20 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
// Every spec loads the whole SPA from the shared dev server, so a slow first module graph
// under parallel workers must not fail an assertion that the app itself would pass.
expect: { timeout: 15000 },
use: { baseURL: 'http://127.0.0.1:5173', headless: true, trace: 'off' },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' } }],
projects: [
// Desktop behaviour, including the keyboard paths.
{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' }, testIgnore: /mobile-.*\.spec\.ts$/ },
// A touch phone viewport: the same pages at 390x844 with touch enabled. This is an emulated
// device, not a real phone; the hand-feel of a long-press selection still needs a person.
{
name: 'mobile',
testMatch: /mobile-.*\.spec\.ts$/,
use: { ...devices['Pixel 7'], channel: 'chrome', viewport: { width: 390, height: 844 } },
},
],
webServer: { command: 'npm run dev -- --host 127.0.0.1', url: 'http://127.0.0.1:5173', reuseExistingServer: !process.env.CI },
})
+171
View File
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import DisplaySettings from '../components/DisplaySettings.vue'
import { useSessionStore } from '../stores/session'
import {
applyPreference, defaultPreference, DISPLAY_KEY_PREFIX, FONT_SCALES, readPreference, resolveTheme,
useDisplayStore, writePreference, type DisplayPreference,
} from '../stores/preferences'
const user = { id: 42, username: 'fictional-display', role: 'learner' as const }
/** A controllable stand-in for the operating system colour scheme. */
function fakeSystem(initialDark: boolean) {
const listeners = new Set<() => void>()
const query = {
matches: initialDark,
media: '(prefers-color-scheme: dark)',
addEventListener: (_: string, handler: () => void) => { listeners.add(handler) },
removeEventListener: (_: string, handler: () => void) => { listeners.delete(handler) },
}
vi.stubGlobal('matchMedia', vi.fn(() => query))
return {
set(dark: boolean) {
query.matches = dark
for (const handler of listeners) handler()
},
listenerCount: () => listeners.size,
}
}
let wrapper: VueWrapper | undefined
describe('display preference rules', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
it('keeps each account apart and never shares a choice', () => {
const storage = localStorage
writePreference(storage, 42, { theme: 'dark', font: 'xlarge' })
expect(readPreference(storage, 42)).toEqual({ theme: 'dark', font: 'xlarge' })
// Another account has never chosen anything, so it gets the defaults.
expect(readPreference(storage, 43)).toEqual(defaultPreference())
expect(Object.keys(storage)).toContain(`${DISPLAY_KEY_PREFIX}42`)
})
it('falls back to the default for unreadable or unknown stored values', () => {
const storage = localStorage
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, '{not json')
expect(readPreference(storage, 42)).toEqual(defaultPreference())
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'neon', font: 'huge' }))
expect(readPreference(storage, 42)).toEqual(defaultPreference())
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark' }))
expect(readPreference(storage, 42)).toEqual({ theme: 'dark', font: 'standard' })
// Without an account there is nothing to read or write.
expect(readPreference(storage, null)).toEqual(defaultPreference())
writePreference(storage, null, { theme: 'dark', font: 'large' })
expect(Object.keys(storage).some(key => key.endsWith(':null'))).toBe(false)
})
it('resolves the system choice and applies it to the document', () => {
expect(resolveTheme('system', true)).toBe('dark')
expect(resolveTheme('system', false)).toBe('light')
expect(resolveTheme('light', true)).toBe('light')
expect(resolveTheme('dark', false)).toBe('dark')
const root = document.createElement('div')
const preference: DisplayPreference = { theme: 'dark', font: 'large' }
expect(applyPreference(root, preference, false)).toBe('dark')
expect(root.dataset.theme).toBe('dark')
expect(root.dataset.themeChoice).toBe('dark')
expect(root.classList.contains('dark')).toBe(true)
expect(root.style.getPropertyValue('--reader-font-scale')).toBe(FONT_SCALES.large)
applyPreference(root, { theme: 'system', font: 'standard' }, true)
expect(root.dataset.theme).toBe('dark')
expect(root.dataset.themeChoice).toBe('system')
expect(root.style.getPropertyValue('--reader-font-scale')).toBe('1')
applyPreference(root, { theme: 'light', font: 'xlarge' }, true)
expect(root.dataset.theme).toBe('light')
// Light removes the Element Plus dark class, so its components follow too.
expect(root.classList.contains('dark')).toBe(false)
expect(root.style.getPropertyValue('--reader-font-scale')).toBe(FONT_SCALES.xlarge)
})
})
describe('display store', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
it('applies the signed-in account preference and saves later changes for it', () => {
const system = fakeSystem(false)
useSessionStore().user = { ...user }
localStorage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark', font: 'large' }))
const display = useDisplayStore()
display.load(42)
expect(display.theme).toBe('dark')
expect(display.font).toBe('large')
expect(display.isDark).toBe(true)
expect(document.documentElement.dataset.theme).toBe('dark')
display.setFont('xlarge')
expect(JSON.parse(localStorage.getItem(`${DISPLAY_KEY_PREFIX}42`)!)).toEqual({ theme: 'dark', font: 'xlarge' })
system.set(true)
// An explicit choice ignores the system change.
expect(document.documentElement.dataset.theme).toBe('dark')
})
it('follows the system while it is chosen, and stops when it is not', () => {
const system = fakeSystem(false)
useSessionStore().user = { ...user }
const display = useDisplayStore()
display.load(42)
expect(document.documentElement.dataset.theme).toBe('light')
system.set(true)
expect(document.documentElement.dataset.theme).toBe('dark')
display.setTheme('light')
system.set(false)
system.set(true)
expect(document.documentElement.dataset.theme).toBe('light')
expect(system.listenerCount()).toBe(0)
})
it('switches preferences with the account instead of carrying them over', async () => {
fakeSystem(false)
localStorage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark', font: 'xlarge' }))
const session = useSessionStore()
session.user = { ...user }
const display = useDisplayStore()
await flushPromises()
expect(display.isDark).toBe(true)
// The second account never chose anything: it must not inherit the first one's dark theme.
session.user = { id: 43, username: 'other', role: 'learner' as const }
await flushPromises()
expect(display.theme).toBe('system')
expect(display.font).toBe('standard')
expect(document.documentElement.dataset.theme).toBe('light')
// Signing out returns to the default as well.
session.user = null
await flushPromises()
expect(display.theme).toBe('system')
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
})
describe('display settings control', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
it('shows the current choice and stores a new one', async () => {
fakeSystem(false)
useSessionStore().user = { ...user }
const display = useDisplayStore()
wrapper = mount(DisplaySettings)
await flushPromises()
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('跟随系统')
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('标准')
display.setTheme('dark')
display.setFont('large')
await flushPromises()
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('深色')
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('大')
// The control is reachable by keyboard and announces itself.
const trigger = wrapper.get('[data-testid="display-trigger"]')
expect(trigger.attributes('aria-label')).toBe('显示设置')
expect(trigger.attributes('role')).toBe('button')
expect(trigger.attributes('tabindex')).toBe('0')
})
})
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import ReaderView from '../views/ReaderView.vue'
import ReviewView from '../views/ReviewView.vue'
import { POSITION_KEY_PREFIX } from '../composables/readingPosition'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-experience', role: 'learner' as const }
const sha = 'c'.repeat(64)
const text = 'Curiosity opens the first door.\nThe second door stays closed.\n'
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
let wrapper: VueWrapper | undefined
const reviewItem = {
id: 5, term: 'curiosity', originalForm: 'Curiosity', definition: '好奇心', examples: ['Curious minds ask.'],
status: 'new' as const, level: 0, kind: 'word' as const, wordCount: 1, dueAt: '2026-09-15T00:00:00Z', reviewCount: 0,
}
function mockApi(handlers: Record<string, (init?: RequestInit) => unknown>): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
for (const [fragment, handler] of Object.entries(handlers)) {
if (url.includes(fragment)) return ok(handler(init))
}
return ok({})
})
}
async function mountWith(component: unknown, path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>LibraryStub</div>' } },
{ path: '/chapters/:id', component: component as never },
{ path: '/review', component: component as never },
],
})
await router.push(path)
await router.isReady()
wrapper = mount(component as never, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
return wrapper
}
describe('review shortcuts in the page', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
it('reveals and grades from the keyboard', async () => {
const answers: string[] = []
useSessionStore().user = { ...user }
mockApi({
'/reviews/queue': () => ({ items: [reviewItem], total: 1 }),
'/answers': (init) => {
answers.push(JSON.parse(String(init?.body)).grade)
return {
result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning',
levelBefore: 0, levelAfter: 1, dueAtBefore: reviewItem.dueAt, dueAtAfter: '2026-09-20T00:00:00Z', item: reviewItem,
}
},
})
const view = await mountWith(ReviewView, '/review')
expect(view.find('[data-testid="review-card"]').exists()).toBe(true)
expect(view.get('[data-testid="review-shortcuts"]').text()).toContain('空格')
// Space shows the answer instead of grading anything.
document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
await flushPromises()
expect(view.get('[data-testid="review-definition"]').text()).toContain('好奇心')
expect(answers).toEqual([])
// A digit grades it; the queue then has nothing left.
document.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
await flushPromises()
expect(answers).toEqual(['correct'])
expect(view.find('[data-testid="review-summary"]').exists()).toBe(true)
})
it('ignores shortcuts typed into a field and with a modifier held', async () => {
const answers: string[] = []
useSessionStore().user = { ...user }
mockApi({
'/reviews/queue': () => ({ items: [reviewItem], total: 1 }),
'/answers': (init) => {
answers.push(JSON.parse(String(init?.body)).grade)
return { result: 'applied', duplicate: false, grade: 'wrong', requeued: true, statusBefore: 'new', statusAfter: 'new', levelBefore: 0, levelAfter: 0, dueAtBefore: reviewItem.dueAt, dueAtAfter: reviewItem.dueAt, item: reviewItem }
},
})
const view = await mountWith(ReviewView, '/review')
view.get('[data-testid="review-reveal"]').trigger('click')
await flushPromises()
// A key press inside an input belongs to the input, wherever the learner is typing.
const field = document.createElement('input')
document.body.append(field)
field.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true }))
document.dispatchEvent(new KeyboardEvent('keydown', { key: '2', ctrlKey: true, bubbles: true }))
await flushPromises()
expect(answers).toEqual([])
field.remove()
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true }))
await flushPromises()
expect(answers).toEqual(['wrong'])
})
})
describe('reading position in the reader', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
function readerApi() {
return mockApi({
'/tokens': () => ({ tokens: [{ start: 0, end: 9, text: 'Curiosity', kind: 'word' }] }),
'/chapters/7': () => ({
book: { id: 3, title: 'Fictional reader', language: 'en' },
chapter: {
id: 7, bookId: 3, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '',
errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: sha, originalText: text,
},
navigation: { previousChapterId: null, nextChapterId: null },
}),
})
}
it('restores the saved position for the same account and version', async () => {
useSessionStore().user = { ...user }
localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha, at: '2026-09-15T03:00:00Z' }))
const scrollTo = vi.fn()
vi.stubGlobal('scrollTo', scrollTo)
// A chapter long enough to have a scrolled position.
Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
readerApi()
const view = await mountWith(ReaderView, '/chapters/7')
expect(view.find('.reader-text').exists()).toBe(true)
await new Promise(resolve => setTimeout(resolve, 90))
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
await flushPromises()
expect(view.get('[data-testid="position-notice"]').text()).toContain('上次阅读位置')
})
it('does not restore a position taken from another content version', async () => {
useSessionStore().user = { ...user }
localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha: 'd'.repeat(64) }))
const scrollTo = vi.fn()
vi.stubGlobal('scrollTo', scrollTo)
Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
readerApi()
const view = await mountWith(ReaderView, '/chapters/7')
await new Promise(resolve => setTimeout(resolve, 90))
expect(scrollTo).not.toHaveBeenCalled()
expect(view.find('[data-testid="position-notice"]').exists()).toBe(false)
})
})
+2 -1
View File
@@ -21,7 +21,8 @@ const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', ori
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const result = (query: string, status = 'exact') => ({ status, query, matchedForm: query, candidates: [], entries: status === 'exact' ? [{ lemma: query, pos: 'noun', definition: `Definition of ${query}`, examples: ['A fictional example.'] }] : [] })
const savedTerm = (overrides: Partial<SavedTerm> = {}): SavedTerm => ({
id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2, ...overrides,
id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2,
kind: 'word', wordCount: 1, ...overrides,
})
let wrapper: VueWrapper | undefined
interface OpenOptions {
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import ReaderView from '../views/ReaderView.vue'
import { useSessionStore } from '../stores/session'
import type { PhraseSpan, ReaderToken } from '../composables/useReaderLookup'
// The same fixture shape the phrase tests use on the server side.
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
const fragments: [string, ReaderToken['kind']][] = [
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [',', 'punctuation'],
[' ', 'space'], ['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'],
['.', 'punctuation'], ['\n', 'space'], ['Mira', 'word'], [' ', 'space'], ['took', 'word'], [' ', 'space'],
['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'], ['step', 'word'], [' ', 'space'],
['again', 'word'], ['.', 'punctuation'], ['\n', 'space'],
]
let cp = 0
let utf16 = 0
const tokens: ReaderToken[] = fragments.map(([text, kind]) => {
const token: ReaderToken = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
cp = token.end
utf16 = token.endUtf16
return token
})
const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', originalText: original, contentSha256: 'same-sha' }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const savedPhrase = {
id: 7, term: 'a small step', originalForm: 'a small, step', definition: '一小步',
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
}
let wrapper: VueWrapper | undefined
interface OpenOptions {
phrases?: PhraseSpan[]
tokens?: ReaderToken[]
original?: string
save?: (body: Record<string, unknown>) => Promise<Response>
termRead?: () => Promise<Response>
}
async function open(options: OpenOptions = {}) {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/tokens')) return ok({ textSha256: 'same-sha', tokens: options.tokens ?? tokens, phrases: options.phrases ?? [] })
if (url.endsWith('/lookup')) return ok({ status: 'not_found', query: 'a', matchedForm: null, candidates: [], entries: [] })
if (url.includes('/terms/')) return options.termRead ? options.termRead() : ok({ term: savedPhrase })
if (url.endsWith('/phrases') || url.endsWith('/terms')) {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
if (options.save) return options.save(body)
return ok({ term: savedPhrase, created: true })
}
// A test that supplies its own tokens must also supply the matching chapter text.
const text2 = options.original ?? original
return ok({ chapter: { ...chapter, originalText: text2 }, navigation: { previousChapterId: null, nextChapterId: 56 } })
})
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/chapters/:id', component: ReaderView }, { path: '/', component: { template: '<div />' } }] })
await router.push('/chapters/55')
wrapper = mount(ReaderView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { fetchMock, router, view: wrapper }
}
/** Clicks one word and shift-clicks the next matching word after it to extend the range. */
async function shiftSelect(view: VueWrapper, first: string, last: string) {
const words = view.findAll('.reader-word')
const startIndex = words.findIndex(item => item.text() === first)
const endIndex = words.findIndex((item, index) => index > startIndex && item.text() === last)
await words[startIndex]!.trigger('click')
await flushPromises()
await words[endIndex]!.trigger('click', { shiftKey: true })
await flushPromises()
}
describe('phrase selection and panel', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
it('builds a phrase from a shift-click range and saves it through the phrases endpoint', async () => {
let body: Record<string, unknown> | undefined
const { view } = await open({ save: async value => { body = value; return ok({ term: savedPhrase, created: true }) } })
await shiftSelect(view, 'a', 'step')
// The panel names the phrase and shows how many words it holds.
expect(view.get('[data-testid="phrase-range"]').text()).toContain('短语 · 3 个单词')
expect(view.text()).toContain('新词条')
// A phrase has no dictionary lookup section.
expect(view.find('[data-testid="lookup-retry"]').exists()).toBe(false)
await view.get('#term-definition').setValue('一小步')
await view.get('[data-testid="term-save"]').trigger('click')
await flushPromises()
// The selection travels as code point offsets of the whole phrase, punctuation included.
expect(body).toMatchObject({ chapterId: 55, start: 5, end: 18, definition: '一小步', status: 'new' })
expect(view.text()).toContain('已保存')
// The saved phrase highlights immediately.
expect(view.findAll('.reader-word').some(item => item.classes().includes('is-phrase'))).toBe(true)
})
it('adjusts both ends by whole words from the panel', async () => {
const { view } = await open()
await shiftSelect(view, 'a', 'step')
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
await view.get('[data-testid="range-start-right"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
// Moving the start back restores the three-word phrase.
await view.get('[data-testid="range-start-left"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
// A two-word phrase cannot shrink further, so the shrinking buttons are disabled.
await view.get('[data-testid="range-start-right"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
expect(view.get('[data-testid="range-start-right"]').attributes('disabled')).toBeDefined()
expect(view.get('[data-testid="range-end-left"]').attributes('disabled')).toBeDefined()
// Growing it is still possible on either side.
expect(view.get('[data-testid="range-start-left"]').attributes('disabled')).toBeUndefined()
expect(view.get('[data-testid="range-end-right"]').attributes('disabled')).toBeUndefined()
})
it('opens the saved phrase when its highlighted range is clicked', async () => {
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
const { view, fetchMock } = await open({ phrases: [span] })
const inside = view.findAll('.reader-word').find(item => item.text() === 'small')!
expect(inside.classes()).toContain('is-phrase')
await inside.trigger('click')
await flushPromises()
// The stored entry is read through the same route a word uses, and the panel edits it.
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/terms/7'))).toBe(true)
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
})
it('stops claiming a stored phrase once its range is adjusted', async () => {
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
const { view } = await open({ phrases: [span] })
await view.findAll('.reader-word').find(item => item.text() === 'small')!.trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
await view.get('[data-testid="range-end-right"]').trigger('click')
await flushPromises()
// The adjusted range is another identity, so the panel no longer says it is stored, while
// the text the learner already had stays in the form.
expect(view.get('.lookup-panel').text()).toContain('新词条')
expect(view.get('.lookup-panel').text()).not.toContain('已保存')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
})
it('refuses a range longer than the phrase limit and keeps the panel closed', async () => {
// One word more than a phrase may hold.
const longTokens: ReaderToken[] = []
const words: string[] = []
let cursor = 0
for (let index = 0; index < 14; index++) {
const word = `w${index}`
words.push(word)
longTokens.push({ text: word, start: cursor, end: cursor + word.length, startUtf16: cursor, endUtf16: cursor + word.length, kind: 'word' })
cursor += word.length
longTokens.push({ text: ' ', start: cursor, end: cursor + 1, startUtf16: cursor, endUtf16: cursor + 1, kind: 'space' })
cursor += 1
}
const { view } = await open({ tokens: longTokens, original: longTokens.map(token => token.text).join('') })
await shiftSelect(view, words[0]!, words[13]!)
expect(view.get('[data-testid="range-notice"]').text()).toContain('短语最多 12 个单词')
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(false)
})
it('closes the phrase panel with Escape and restores the reading position', async () => {
const { view } = await open()
await shiftSelect(view, 'a', 'step')
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(true)
await view.get('.lookup-panel').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(view.find('.lookup-panel').exists()).toBe(false)
})
it('keeps the typed phrase text when saving fails', async () => {
const { view } = await open({ save: async () => new Response(JSON.stringify({ code: 400, msg: '短语过长,请缩短选择范围' }), { status: 400 }) })
await shiftSelect(view, 'a', 'step')
await view.get('#term-definition').setValue('虚构释义')
await view.get('[data-testid="term-save"]').trigger('click')
await flushPromises()
expect(view.text()).toContain('短语过长,请缩短选择范围')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('虚构释义')
})
})
+251
View File
@@ -0,0 +1,251 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import ProgressView from '../views/ProgressView.vue'
import ReaderView from '../views/ReaderView.vue'
import { useLibraryStore } from '../stores/library'
import { useProgressStore, type LearnerProgress } from '../stores/progress'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-progress', role: 'learner' as const }
const snapshot = (overrides: Partial<LearnerProgress> = {}): LearnerProgress => ({
readChapters: 2, totalChapters: 5, knownTerms: 4, learningTerms: 3, newTerms: 6, ignoredTerms: 1,
savedTerms: 14, dueNow: 2, books: [{ id: 7, title: 'Fictional reader', readChapters: 2, totalChapters: 5 }],
...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
let wrapper: VueWrapper | undefined
function mockApi(data: LearnerProgress, onComplete?: () => Response): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.includes('/progress')) return ok(data)
if (url.includes('/complete') && String(init?.method) === 'POST') {
return onComplete ? onComplete() : ok({ progress: { chapterId: 3, bookId: 7, read: true, readAt: '2026-09-15T03:04:05Z', duplicate: false } })
}
return ok({})
})
}
async function mountProgress(path = '/progress', data = snapshot()) {
mockApi(data)
useSessionStore().user = { ...user }
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>LibraryStub</div>' } },
{ path: '/review', component: { template: '<div>ReviewStub</div>' } },
{ path: '/books/:id', component: { template: '<div>BookStub</div>' } },
{ path: '/progress', component: ProgressView },
],
})
await router.push(path)
await router.isReady()
wrapper = mount(ProgressView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
return { view: wrapper, router }
}
describe('progress store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks() })
it('reads one snapshot and keeps the four word statuses apart', async () => {
mockApi(snapshot())
useSessionStore().user = { ...user }
const progress = useProgressStore()
await progress.load()
expect(progress.loaded).toBe(true)
expect(progress.data.readChapters).toBe(2)
expect(progress.data.totalChapters).toBe(5)
expect(progress.readLabel).toBe('2 / 5 章(可阅读)')
// The statuses are never merged into one number.
expect([progress.data.knownTerms, progress.data.learningTerms, progress.data.newTerms, progress.data.ignoredTerms])
.toEqual([4, 3, 6, 1])
expect(progress.data.ignoredTerms).toBe(1)
expect(progress.data.dueNow).toBe(2)
expect(progress.data.books).toHaveLength(1)
expect(progress.hasBooks).toBe(true)
})
it('keeps the last snapshot when a refresh fails and reports the error', async () => {
useSessionStore().user = { ...user }
const progress = useProgressStore()
mockApi(snapshot())
await progress.load()
vi.restoreAllMocks()
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ code: 500, msg: '服务器暂时不可用' }), { status: 500 }))
await progress.load()
expect(progress.error).not.toBe('')
expect(progress.data.readChapters).toBe(2)
})
it('drops another account\'s snapshot on a switch', async () => {
useSessionStore().user = { ...user }
const progress = useProgressStore()
mockApi(snapshot())
await progress.load()
expect(progress.loaded).toBe(true)
useSessionStore().user = { id: 99, username: 'other', role: 'learner' as const }
expect(progress.loaded).toBe(false)
expect(progress.data.totalChapters).toBe(0)
expect(progress.data.books).toEqual([])
})
it('ignores a late reply from the previous account', async () => {
useSessionStore().user = { ...user }
const progress = useProgressStore()
let release: (() => void) | null = null
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
await new Promise<void>(resolve => { release = () => resolve() })
return ok(snapshot())
})
const pending = progress.load()
useSessionStore().user = { id: 99, username: 'other', role: 'learner' as const }
release!()
await pending
expect(progress.data.totalChapters).toBe(0)
expect(progress.error).toBe('')
})
})
describe('progress view', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
it('shows the counts, the per-book reading and the review entry', async () => {
const { view } = await mountProgress()
expect(view.get('[data-testid="stat-read"]').text()).toContain('2 / 5')
expect(view.get('[data-testid="stat-due"]').text()).toContain('2')
expect(view.get('[data-testid="stat-known"]').text()).toContain('4')
expect(view.get('[data-testid="stat-learning"]').text()).toContain('3')
expect(view.get('[data-testid="stat-new"]').text()).toContain('6')
expect(view.get('[data-testid="stat-ignored"]').text()).toContain('1')
expect(view.get('[data-testid="progress-books"]').text()).toContain('Fictional reader')
expect(view.get('[data-testid="progress-books"]').text()).toContain('2 / 5 章已读')
expect(view.get('[data-testid="progress-saved"]').text()).toContain('14')
expect(view.find('.page-actions a[href="/review"]').exists()).toBe(true)
})
it('invites reading when there is no book yet', async () => {
const { view } = await mountProgress('/progress', snapshot({ books: [], readChapters: 0, totalChapters: 0 }))
expect(view.get('[data-testid="progress-no-books"]').text()).toContain('还没有书')
expect(view.find('[data-testid="progress-books"]').exists()).toBe(false)
expect(view.get('[data-testid="stat-read"]').text()).toContain('0 / 0')
})
it('offers a retry when the first load fails', async () => {
useSessionStore().user = { ...user }
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ code: 500, msg: '服务器暂时不可用' }), { status: 500 }))
const router = await viewAtWith(ProgressView)
wrapper = mount(ProgressView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('服务器暂时不可用')
fetchMock.mockImplementation(async () => ok(snapshot()))
await wrapper.get('[data-testid="progress-retry"]').trigger('click')
await flushPromises()
await flushPromises()
expect(wrapper.get('[data-testid="stat-read"]').text()).toContain('2 / 5')
})
})
async function viewAtWith(component: unknown): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div>LibraryStub</div>' } }, { path: '/progress', component: component as never }],
})
await router.push('/progress')
await router.isReady()
return router
}
describe('library completion action', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
it('marks the open chapter read without touching the word state', async () => {
useSessionStore().user = { ...user }
const library = useLibraryStore()
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.includes('/chapters/3') && !url.includes('complete')) {
return ok({
book: { id: 7, title: 'Fictional reader', language: 'en' },
chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' },
navigation: { previousChapterId: null, nextChapterId: null },
})
}
if (url.includes('/complete')) {
return ok({ progress: { chapterId: 3, bookId: 7, read: true, readAt: '2026-09-15T03:04:05Z', duplicate: false } })
}
return ok({})
})
await library.loadChapter(3)
expect(library.chapter?.readAt ?? null).toBeNull()
const result = await library.markChapterRead(3)
expect(result.read).toBe(true)
expect(library.chapter?.readAt).toBe('2026-09-15T03:04:05Z')
// Nothing about the chapter's words changes: the request carries no status or level.
const completion = fetchMock.mock.calls.find(([url]) => String(url).includes('/complete'))!
expect(String(completion[1]?.method)).toBe('POST')
expect(completion[1]?.body).toBeUndefined()
expect(library.chapter?.status).toBe('ready')
})
it('reports a failure without pretending the chapter is read', async () => {
useSessionStore().user = { ...user }
const library = useLibraryStore()
library.chapter = { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ code: 409, msg: '只有处理完成的章节可以标记已读' }), { status: 409 }))
await expect(library.markChapterRead(3)).rejects.toThrow('只有处理完成的章节可以标记已读')
expect(library.chapter.readAt ?? null).toBeNull()
expect(library.completingChapterId).toBeNull()
})
it('marks the reading state in the reader and repeats without counting twice', async () => {
useSessionStore().user = { ...user }
const library = useLibraryStore()
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div>LibraryStub</div>' } }, { path: '/chapters/:id', component: ReaderView }],
})
await router.push('/chapters/3')
await router.isReady()
let calls = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.includes('/complete')) {
calls += 1
return ok({ progress: { chapterId: 3, bookId: 7, read: true, readAt: '2026-09-15T03:04:05Z', duplicate: calls > 1 } })
}
if (url.includes('/tokens')) return ok({ tokens: [] })
if (url.includes('/chapters/3')) {
return ok({
book: { id: 7, title: 'Fictional reader', language: 'en' },
chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' },
navigation: { previousChapterId: null, nextChapterId: null },
})
}
return ok({})
})
wrapper = mount(ReaderView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
expect(wrapper.get('[data-testid="mark-read"]').text()).toBe('标记本章已读')
await wrapper.get('[data-testid="mark-read"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="chapter-read-state"]').text()).toContain('本章已读')
expect(wrapper.get('[data-testid="complete-notice"]').text()).toContain('已记为读完本章')
await wrapper.get('[data-testid="mark-read"]').trigger('click')
await flushPromises()
expect(calls).toBe(2)
expect(wrapper.get('[data-testid="complete-notice"]').text()).toContain('没有重复计数')
// The reader stays usable: the text and the chapter navigation are still on screen.
expect(wrapper.find('.reader-nav').exists()).toBe(true)
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, rangeOfWords, wordIndices } from '../composables/readerRange'
import type { ReaderToken } from '../composables/useReaderLookup'
// The same text #4 verified: interior punctuation, a line break inside the phrase, a repeated
// phrase and a combining accent.
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
const tokens: ReaderToken[] = [
{ text: 'Take', start: 0, end: 4, startUtf16: 0, endUtf16: 4, kind: 'word' },
{ text: ' ', start: 4, end: 5, startUtf16: 4, endUtf16: 5, kind: 'space' },
{ text: 'a', start: 5, end: 6, startUtf16: 5, endUtf16: 6, kind: 'word' },
{ text: ' ', start: 6, end: 7, startUtf16: 6, endUtf16: 7, kind: 'space' },
{ text: 'small', start: 7, end: 12, startUtf16: 7, endUtf16: 12, kind: 'word' },
{ text: ',', start: 12, end: 13, startUtf16: 12, endUtf16: 13, kind: 'punctuation' },
{ text: ' ', start: 13, end: 14, startUtf16: 13, endUtf16: 14, kind: 'space' },
{ text: 'step', start: 14, end: 18, startUtf16: 14, endUtf16: 18, kind: 'word' },
{ text: '\n', start: 18, end: 19, startUtf16: 18, endUtf16: 19, kind: 'space' },
{ text: 'every', start: 19, end: 24, startUtf16: 19, endUtf16: 24, kind: 'word' },
{ text: ' ', start: 24, end: 25, startUtf16: 24, endUtf16: 25, kind: 'space' },
{ text: 'day', start: 25, end: 28, startUtf16: 25, endUtf16: 28, kind: 'word' },
{ text: '.', start: 28, end: 29, startUtf16: 28, endUtf16: 29, kind: 'punctuation' },
{ text: '\n', start: 29, end: 30, startUtf16: 29, endUtf16: 30, kind: 'space' },
{ text: 'Mira', start: 30, end: 34, startUtf16: 30, endUtf16: 34, kind: 'word' },
{ text: ' ', start: 34, end: 35, startUtf16: 34, endUtf16: 35, kind: 'space' },
{ text: 'took', start: 35, end: 39, startUtf16: 35, endUtf16: 39, kind: 'word' },
{ text: ' ', start: 39, end: 40, startUtf16: 39, endUtf16: 40, kind: 'space' },
{ text: 'a', start: 40, end: 41, startUtf16: 40, endUtf16: 41, kind: 'word' },
{ text: ' ', start: 41, end: 42, startUtf16: 41, endUtf16: 42, kind: 'space' },
{ text: 'small', start: 42, end: 47, startUtf16: 42, endUtf16: 47, kind: 'word' },
{ text: ' ', start: 47, end: 48, startUtf16: 47, endUtf16: 48, kind: 'space' },
{ text: 'step', start: 48, end: 52, startUtf16: 48, endUtf16: 52, kind: 'word' },
{ text: ' ', start: 52, end: 53, startUtf16: 52, endUtf16: 53, kind: 'space' },
{ text: 'again', start: 53, end: 58, startUtf16: 53, endUtf16: 58, kind: 'word' },
{ text: '.', start: 58, end: 59, startUtf16: 58, endUtf16: 59, kind: 'punctuation' },
{ text: '\n', start: 59, end: 60, startUtf16: 59, endUtf16: 60, kind: 'space' },
]
describe('reader phrase ranges', () => {
it('keeps interior punctuation and line breaks in the selected text', () => {
const range = normalizeTokenRange(tokens, original, 2, 7)
expect(range).not.toBeNull()
expect(range).toMatchObject({ firstWord: 2, lastWord: 7, start: 5, end: 18, wordCount: 3 })
expect(range!.text).toBe('a small, step')
// The second occurrence keeps its own spelling and offsets.
const second = normalizeTokenRange(tokens, original, 18, 22)
expect(second!.text).toBe('a small step')
expect(second!.start).toBe(40)
})
it('aligns both ends to whole words and never selects one word as a phrase', () => {
// A selection that starts on a separator begins at the next whole word, and one that ends
// on a separator ends at the previous one: words outside the selection stay untouched.
expect(normalizeTokenRange(tokens, original, 3, 7)!.firstWord).toBe(4)
expect(normalizeTokenRange(tokens, original, 2, 5)!.lastWord).toBe(4)
expect(normalizeTokenRange(tokens, original, 2, 7)!.text).toBe('a small, step')
expect(normalizeTokenRange(tokens, original, 2, 3)).toBeNull()
expect(normalizeTokenRange(tokens, original, 5, 5)).toBeNull()
expect(normalizeTokenRange(tokens, original, -1, 7)).toBeNull()
expect(normalizeTokenRange(tokens, original, 2, tokens.length + 5)).toBeNull()
})
it('reverses a backwards selection and refuses more than twelve words', () => {
const range = normalizeTokenRange(tokens, original, 7, 2)
expect(range).toMatchObject({ firstWord: 2, lastWord: 7 })
// The fixture is exactly twelve words, which is still a phrase.
expect(normalizeTokenRange(tokens, original, 0, tokens.length - 1)!.wordCount).toBe(MAX_PHRASE_WORDS)
const longer: ReaderToken[] = []
let offset = 0
for (let index = 0; index < MAX_PHRASE_WORDS + 1; index++) {
const word = `word${index}`
longer.push({ text: word, start: offset, end: offset + word.length, startUtf16: offset, endUtf16: offset + word.length, kind: 'word' })
offset += word.length
longer.push({ text: ' ', start: offset, end: offset + 1, startUtf16: offset, endUtf16: offset + 1, kind: 'space' })
offset += 1
}
expect(normalizeTokenRange(longer, longer.map(token => token.text).join(''), 0, longer.length - 1)).toBeNull()
})
it('moves one end by whole words and never inverts the range', () => {
const range = normalizeTokenRange(tokens, original, 2, 7)!
const shorterStart = adjustTokenRange(tokens, original, range, 'start', 1)
expect(shorterStart).toMatchObject({ firstWord: 4, lastWord: 7, wordCount: 2 })
expect(shorterStart!.text).toBe('small, step')
// The start can move left while a word remains before it, and not past the first word.
expect(adjustTokenRange(tokens, original, range, 'start', -1)).toMatchObject({ firstWord: 0, lastWord: 7 })
expect(adjustTokenRange(tokens, original, rangeOfWords(tokens, original, 0, 7)!, 'start', -1)).toBeNull()
const longerEnd = adjustTokenRange(tokens, original, range, 'end', 1)
expect(longerEnd).toMatchObject({ firstWord: 2, lastWord: 9, wordCount: 4 })
expect(longerEnd!.text).toBe('a small, step\nevery')
const back = adjustTokenRange(tokens, original, longerEnd!, 'end', -1)!
expect(back.text).toBe(range.text)
// Moving the end before the start is refused even when the direction is legal.
const twoWords = rangeOfWords(tokens, original, 2, 4)!
expect(adjustTokenRange(tokens, original, twoWords, 'end', -1)).toBeNull()
})
it('finds the phrase covering a word and builds its range again', () => {
const phrases = [
{ id: 1, startToken: 2, endToken: 7, status: 'new', wordCount: 3 },
{ id: 2, startToken: 4, endToken: 7, status: 'learning', wordCount: 2 },
]
// Overlapping spans: the longer phrase wins for a word both cover.
expect(phrasesAt(tokens, phrases, 4)?.id).toBe(1)
expect(phrasesAt(tokens, phrases, 7)?.id).toBe(1)
expect(phrasesAt(tokens, phrases, 0)).toBeNull()
const span = rangeOfSpan(tokens, original, { startToken: 2, endToken: 7 })!
expect(span.text).toBe('a small, step')
expect(rangeOfWords(tokens, original, 4, 7)!.text).toBe('small, step')
// A span that covers a single word is not a phrase.
expect(rangeOfSpan(tokens, original, { startToken: 2, endToken: 2 })).toBeNull()
})
})
+11 -2
View File
@@ -4,11 +4,11 @@ import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import ReviewView from '../views/ReviewView.vue'
import { useSessionStore } from '../stores/session'
import { clozeSentence, useReviewStore, type ReviewItem } from '../stores/review'
import { clozeSentence, maskedPrompt, useReviewStore, type ReviewItem } from '../stores/review'
const item = (overrides: Partial<ReviewItem> = {}): ReviewItem => ({
id: 7, term: 'dogs', originalForm: 'Dogs', definition: '狗', examples: ['Dogs went home.'],
status: 'new', level: 0, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides,
status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const answer = (overrides: Record<string, unknown> = {}) => ({
@@ -20,6 +20,15 @@ const answerCalls = (fetchMock: MockInstance) => fetchMock.mock.calls.filter(cal
let wrapper: VueWrapper | undefined
describe('review prompt', () => {
it('masks a whole phrase as one blank and keeps the word rule for words', () => {
const phrase = item({ kind: 'phrase', wordCount: 3, term: 'a small step', originalForm: 'a small step', examples: ['Take a small step, every day.'] })
expect(maskedPrompt(phrase)).toBe('Take _____, every day.')
expect(maskedPrompt(item({ ...phrase, examples: ['Take a small,\nstep today.'] }))).toBe('Take _____ today.')
// A phrase that is not in the example is left alone, and a word still masks itself.
expect(maskedPrompt(item({ ...phrase, examples: ['Nothing to mask here.'] }))).toBe('Nothing to mask here.')
expect(maskedPrompt(item({ term: 'curiosity', originalForm: 'curiosity', examples: ['Learning begins with curiosity.'] }))).toBe('Learning begins with _____.')
})
it('masks the word in the first example and keeps other sentences usable', () => {
expect(clozeSentence(item())).toBe('_____ went home.')
expect(clozeSentence(item({ term: "isn't", originalForm: "Isn't", examples: ["It isn’t over."] }))).toBe('It _____ over.')
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import {
clearPosition, currentRatio, normalizeRatio, POSITION_KEY_PREFIX, positionKey, readPosition, writePosition,
} from '../composables/readingPosition'
import { ignoresShortcut, shortcutFor } from '../composables/reviewShortcuts'
const sha = 'a'.repeat(64)
const otherSha = 'b'.repeat(64)
describe('reading position storage', () => {
it('stores one position per account and chapter', () => {
const storage = localStorage
storage.clear()
writePosition(storage, 42, 7, sha, 0.5, new Date('2026-09-15T03:00:00Z'))
expect(readPosition(storage, 42, 7, sha)).toEqual({ ratio: 0.5, sha, at: '2026-09-15T03:00:00.000Z' })
// Another chapter, another account and the signed-out state know nothing about it.
expect(readPosition(storage, 42, 8, sha)).toBeNull()
expect(readPosition(storage, 43, 7, sha)).toBeNull()
expect(readPosition(storage, null, 7, sha)).toBeNull()
expect(positionKey(null, 7)).toBeNull()
expect(positionKey(42, 0)).toBeNull()
expect(Object.keys(storage)).toEqual([`${POSITION_KEY_PREFIX}42:7`])
})
it('drops a position taken from another content version', () => {
const storage = localStorage
storage.clear()
writePosition(storage, 42, 7, sha, 0.4)
expect(readPosition(storage, 42, 7, otherSha)).toBeNull()
// A position without a version is never restored either.
expect(readPosition(storage, 42, 7, '')).toBeNull()
})
it('ignores unreadable values and never restores the very top', () => {
const storage = localStorage
storage.clear()
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, '{broken')
expect(readPosition(storage, 42, 7, sha)).toBeNull()
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 'half', sha }))
expect(readPosition(storage, 42, 7, sha)).toBeNull()
expect(normalizeRatio(0)).toBeNull()
expect(normalizeRatio(-1)).toBeNull()
expect(normalizeRatio(Number.NaN)).toBeNull()
expect(normalizeRatio(3)).toBe(1)
expect(normalizeRatio(0.25)).toBe(0.25)
// A position of zero is not worth storing, so the top of a chapter needs no key at all.
storage.clear()
writePosition(storage, 42, 7, sha, 0)
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
writePosition(storage, 42, 7, sha, 0.5)
clearPosition(storage, 42, 7)
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
})
it('turns a scroll offset into a ratio and clamps it', () => {
expect(currentRatio({ scrollY: 0, innerHeight: 800 }, 4800)).toBe(0)
expect(currentRatio({ scrollY: 1000, innerHeight: 800 }, 4800)).toBeCloseTo(0.25, 5)
expect(currentRatio({ scrollY: 4000, innerHeight: 800 }, 4800)).toBe(1)
// A chapter shorter than the window cannot be scrolled.
expect(currentRatio({ scrollY: 0, innerHeight: 900 }, 900)).toBe(0)
})
})
describe('review keyboard shortcuts', () => {
it('maps space and enter to revealing and digits to grading', () => {
expect(shortcutFor(' ', false)).toBe('reveal')
expect(shortcutFor('Enter', false)).toBe('reveal')
// Before the answer is on screen a grade key means nothing.
expect(shortcutFor('1', false)).toBeNull()
expect(shortcutFor('1', true)).toBe('correct')
expect(shortcutFor('2', true)).toBe('wrong')
expect(shortcutFor('3', true)).toBe('again')
expect(shortcutFor('4', true)).toBeNull()
expect(shortcutFor('q', true)).toBeNull()
// Once revealed, space no longer reveals anything.
expect(shortcutFor(' ', true)).toBeNull()
})
it('never fires while the learner is typing or using a control', () => {
const form = document.createElement('form')
const input = document.createElement('input')
const textarea = document.createElement('textarea')
const button = document.createElement('button')
const contenteditable = document.createElement('div')
contenteditable.setAttribute('contenteditable', 'true')
const link = document.createElement('a')
link.setAttribute('href', '/vocab')
const plain = document.createElement('p')
form.append(input, textarea, button, contenteditable, link, plain)
for (const element of [input, textarea, contenteditable]) {
expect(ignoresShortcut(element, '1')).toBe(true)
expect(ignoresShortcut(element, ' ')).toBe(true)
}
// A focused button or link owns Space and Enter itself, but not the digit keys.
expect(ignoresShortcut(button, ' ')).toBe(true)
expect(ignoresShortcut(button, 'Enter')).toBe(true)
expect(ignoresShortcut(button, '1')).toBe(false)
expect(ignoresShortcut(link, ' ')).toBe(true)
expect(ignoresShortcut(link, '2')).toBe(false)
expect(ignoresShortcut(plain, '1')).toBe(false)
expect(ignoresShortcut(null, '1')).toBe(false)
})
})
+259
View File
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import { ElSelect } from 'element-plus'
import VocabView from '../views/VocabView.vue'
import { useSessionStore } from '../stores/session'
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
const user = { id: 42, username: 'fictional-vocab', role: 'learner' as const }
const word = (overrides: Partial<VocabularyTerm> = {}): VocabularyTerm => ({
id: 1, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Learning begins with curiosity.'],
status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z', ...overrides,
})
const phrase = (overrides: Partial<VocabularyTerm> = {}): VocabularyTerm => ({
id: 2, term: 'a small step', originalForm: 'a small step', definition: '一小步', examples: ['Take a small step.'],
status: 'learning', level: 3, kind: 'phrase', wordCount: 3, updatedAt: '2026-01-03T00:00:00Z', ...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
let wrapper: VueWrapper | undefined
function listCalls(fetchMock: MockInstance): URLSearchParams[] {
return fetchMock.mock.calls
.filter(([url]) => String(url).includes('/terms?'))
.map(([url]) => new URLSearchParams(String(url).split('?')[1]))
}
function mockApi(items: VocabularyTerm[], total = items.length, onPatch?: (body: Record<string, unknown>) => VocabularyTerm): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.includes('/terms?') || url.endsWith('/terms')) {
const params = new URLSearchParams(url.split('?')[1] ?? '')
const query = (params.get('query') ?? '').toLowerCase()
const status = params.get('status') ?? ''
const kind = params.get('kind') ?? ''
const page = Number(params.get('page') ?? 1)
const limit = Number(params.get('limit') ?? VOCAB_PAGE_SIZE)
const filtered = items.filter(item =>
(!query || item.term.includes(query) || item.originalForm.toLowerCase().includes(query) || item.definition.includes(query))
&& (!status || item.status === status) && (!kind || item.kind === kind))
return ok({ items: filtered.slice((page - 1) * limit, page * limit), total: Math.max(total, filtered.length), page, limit })
}
if (url.includes('/terms/') && String(init?.method) === 'PATCH') {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
const base = items.find(item => url.endsWith(`/terms/${item.id}`)) ?? word()
return ok({ term: onPatch ? onPatch(body) : { ...base, ...body } })
}
return ok({})
})
}
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div>LibraryStub</div>' } }, { path: '/vocab', component: VocabView }, { path: '/review', component: { template: '<div>ReviewStub</div>' } }],
})
await router.push(path)
await router.isReady()
return router
}
async function openVocab(path = '/vocab') {
useSessionStore().user = { ...user }
const router = await viewAt(path)
wrapper = mount(VocabView, { attachTo: document.body, global: { plugins: [router] } })
// The list lands one microtask after mount; flush twice so the first assertion sees it.
await flushPromises()
await flushPromises()
return { view: wrapper, router }
}
describe('vocabulary store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks() })
it('loads one page with the current filters and reports paging state', async () => {
const fetchMock = mockApi([word(), phrase()], 45)
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
const params = listCalls(fetchMock)[0]!
expect(params.get('page')).toBe('1')
expect(params.get('limit')).toBe(String(VOCAB_PAGE_SIZE))
expect(params.get('query')).toBeNull()
expect(vocabulary.items).toHaveLength(2)
expect(vocabulary.total).toBe(45)
expect(vocabulary.totalPages).toBe(3)
})
it('applies search and filters from the first page and can clear them', async () => {
const fetchMock = mockApi([word(), phrase()])
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
vocabulary.page = 3
vocabulary.query = 'small'
vocabulary.status = 'learning'
vocabulary.kind = 'phrase'
await vocabulary.apply()
expect(vocabulary.page).toBe(1)
const params = listCalls(fetchMock)[0]!
expect(params.get('query')).toBe('small')
expect(params.get('status')).toBe('learning')
expect(params.get('kind')).toBe('phrase')
expect(vocabulary.items.map(item => item.id)).toEqual([2])
await vocabulary.clearFilters()
expect(vocabulary.hasFilters).toBe(false)
expect(vocabulary.items).toHaveLength(2)
})
it('clamps paging to the available pages', async () => {
const fetchMock = mockApi([word()], 5)
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
await vocabulary.goToPage(99)
expect(vocabulary.page).toBe(1)
expect(listCalls(fetchMock)).toHaveLength(1)
})
it('keeps the list when loading fails and reports the message', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('网络暂不可用'))
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
expect(vocabulary.error).toContain('网络暂不可用')
expect(vocabulary.items).toHaveLength(0)
})
it('replaces the edited row and drops the previous account state', async () => {
mockApi([word(), phrase()], 2, body => ({ ...word(), definition: String(body.definition), status: 'learning', level: Number(body.level ?? 1) }))
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
const updated = await vocabulary.updateTerm(1, { definition: '好奇心(改)', examples: [], status: 'learning', level: 4 })
expect(updated.level).toBe(4)
expect(vocabulary.items[0]).toMatchObject({ definition: '好奇心(改)', status: 'learning', level: 4 })
useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' }
await flushPromises()
expect(vocabulary.items).toHaveLength(0)
expect(vocabulary.total).toBe(0)
})
})
describe('vocabulary view', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); vi.restoreAllMocks() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined })
it('searches and filters through the toolbar and shows the server result', async () => {
const fetchMock = mockApi([word(), phrase()], 2)
const { view } = await openVocab()
expect(view.findAll('.vocab-row')).toHaveLength(2)
// The phrase row carries its kind and level.
expect(view.text()).toContain('短语 · 学习中 · 等级 3')
await view.get('#vocab-query').setValue('small')
await view.get('[data-testid="vocab-search"]').trigger('click')
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('query')).toBe('small')
expect(view.findAll('.vocab-row')).toHaveLength(1)
expect(view.text()).toContain('一小步')
})
it('filters by status immediately and mirrors everything into the URL', async () => {
const fetchMock = mockApi([word(), phrase()], 2)
const { view, router } = await openVocab()
const statusSelect = view.findAllComponents(ElSelect)[0]!
statusSelect.vm.$emit('update:modelValue', 'learning')
statusSelect.vm.$emit('change', 'learning')
await flushPromises()
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('status')).toBe('learning')
expect(view.findAll('.vocab-row')).toHaveLength(1)
expect(router.currentRoute.value.query.status).toBe('learning')
})
it('restores the filters written in the URL', async () => {
const fetchMock = mockApi([word(), phrase()], 2)
const { view } = await openVocab('/vocab?query=curio&kind=word&page=1')
expect(listCalls(fetchMock)[0]!.get('query')).toBe('curio')
expect(listCalls(fetchMock)[0]!.get('kind')).toBe('word')
expect((view.get('#vocab-query').element as HTMLInputElement).value).toBe('curio')
expect(view.findAll('.vocab-row')).toHaveLength(1)
})
it('offers clearing the filters when nothing matches, and an empty state otherwise', async () => {
mockApi([word()], 1)
const { view } = await openVocab()
await view.get('#vocab-query').setValue('zzzz')
await view.get('[data-testid="vocab-search"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="vocab-none"]').text()).toContain('没有匹配的词条')
await view.get('[data-testid="vocab-clear"]').trigger('click')
await flushPromises()
expect(view.findAll('.vocab-row')).toHaveLength(1)
wrapper?.unmount()
mockApi([], 0)
const empty = await openVocab()
expect(empty.view.get('[data-testid="vocab-empty"]').text()).toContain('生词本还是空的')
})
it('edits an entry with a level selector and keeps the filters after saving', async () => {
const fetchMock = mockApi([word(), phrase()], 2, body => ({ ...phrase(), definition: String(body.definition), status: 'learning', level: Number(body.level ?? 3) }))
const { view } = await openVocab()
const statusSelect = view.findAllComponents(ElSelect)[0]!
statusSelect.vm.$emit('update:modelValue', 'learning')
statusSelect.vm.$emit('change', 'learning')
await flushPromises()
await view.get('[data-testid="vocab-edit-2"]').trigger('click')
await flushPromises()
// The dialog is prefilled and shows the level selector for a learning entry.
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
expect(view.find('[data-testid="term-level"]').exists()).toBe(true)
await view.get('#term-definition').setValue('一小步(改)')
await view.get('[data-testid="vocab-save"]').trigger('click')
await flushPromises()
const patch = fetchMock.mock.calls.find(([, init]) => String((init as RequestInit | undefined)?.method) === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toMatchObject({ definition: '一小步(改)', status: 'learning', level: 3 })
expect(view.get('[data-testid="vocab-notice"]').text()).toContain('已保存')
expect(view.get('#vocab-status')).toBeTruthy()
expect(view.findAll('.vocab-row')).toHaveLength(1)
})
it('hides the level selector for other statuses and keeps the input when saving fails', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (String(init?.method) === 'PATCH') return new Response(JSON.stringify({ code: 400, msg: '学习等级须为 1~7' }), { status: 400 })
return ok({ items: [word()], total: 1 })
})
const { view } = await openVocab()
await view.get('[data-testid="vocab-edit-1"]').trigger('click')
await flushPromises()
expect(view.find('[data-testid="term-level"]').exists()).toBe(false)
await view.get('#term-definition').setValue('改过的释义')
await view.get('[data-testid="vocab-save"]').trigger('click')
await flushPromises()
expect(view.text()).toContain('学习等级须为 1~7')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('改过的释义')
expect(view.find('[data-testid="vocab-dialog"]').isVisible()).toBe(true)
})
it('pages forward and back with the server totals', async () => {
// Three real pages of entries, so paging back and forth has content both ways.
const many = Array.from({ length: 45 }, (_, index) => word({ id: index + 1, term: `word ${index + 1}` }))
const fetchMock = mockApi(many, 45)
const { view } = await openVocab()
expect(view.get('.vocab-paging').text()).toContain('第 1 / 3 页')
expect(view.findAll('.vocab-row')).toHaveLength(VOCAB_PAGE_SIZE)
await view.get('[data-testid="vocab-next"]').trigger('click')
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('page')).toBe('2')
await view.get('[data-testid="vocab-prev"]').trigger('click')
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('page')).toBe('1')
})
it('links to the due review from the list', async () => {
mockApi([word()], 1)
const { view } = await openVocab()
expect(view.get('.page-actions a[href="/review"]').text()).toContain('开始到期复习')
})
})
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus'
import { FONT_CHOICES, FONT_LABELS, THEME_CHOICES, THEME_LABELS, useDisplayStore } from '../stores/preferences'
// One header control for both display choices. The menu shows the current values, so the
// learner can tell what is active without opening anything else.
const display = useDisplayStore()
</script>
<template>
<ElDropdown trigger="click" data-testid="display-settings" @command="(value: string) => {
const [group, choice] = value.split(':')
if (group === 'theme') display.setTheme(choice as never)
if (group === 'font') display.setFont(choice as never)
}">
<button type="button" class="display-trigger" aria-label="显示设置" data-testid="display-trigger">
显示
<span class="display-current">{{ THEME_LABELS[display.theme] }} · {{ FONT_LABELS[display.font] }}</span>
</button>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem disabled>主题</ElDropdownItem>
<ElDropdownItem
v-for="choice in THEME_CHOICES"
:key="choice"
:command="`theme:${choice}`"
:class="{ 'is-current': display.theme === choice }"
>{{ THEME_LABELS[choice] }}{{ display.theme === choice ? ' ✓' : '' }}</ElDropdownItem>
<ElDropdownItem divided disabled>正文字号</ElDropdownItem>
<ElDropdownItem
v-for="choice in FONT_CHOICES"
:key="choice"
:command="`font:${choice}`"
:class="{ 'is-current': display.font === choice }"
>{{ FONT_LABELS[choice] }}{{ display.font === choice ? ' ✓' : '' }}</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
+35 -22
View File
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { TERM_STATUSES, type LookupResult, type TermStatus } from '../composables/useReaderLookup'
import { ElButton } from 'element-plus'
import TermFormFields from './TermFormFields.vue'
import { type LookupResult, type TermStatus } from '../composables/useReaderLookup'
defineProps<{
word: string
// Set when the selection is a phrase: the panel edits that phrase instead of a word.
phrase: { wordCount: number; stored: boolean } | null
adjust: { startLeft: boolean; startRight: boolean; endLeft: boolean; endRight: boolean } | null
result: LookupResult | null
loading: boolean
error: string
@@ -19,7 +23,10 @@ defineProps<{
const definition = defineModel<string>('definition', { required: true })
const examples = defineModel<string>('examples', { required: true })
const status = defineModel<TermStatus>('status', { required: true })
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number] }>()
// The reader panel keeps the four statuses only, so the level field exists for the shared form
// but is never shown here and never sent: the server keeps the earned level.
const level = ref(1)
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number]; adjust: [edge: 'start' | 'end', direction: -1 | 1] }>()
const heading = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
let observer: ResizeObserver | undefined
@@ -44,7 +51,16 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
<h2 id="lookup-heading" ref="heading" tabindex="-1">{{ word }}</h2>
<ElButton text aria-label="关闭释义" @click="$emit('close')">关闭</ElButton>
</header>
<div class="lookup-content" aria-live="polite" :aria-busy="loading">
<div v-if="phrase" class="lookup-range" data-testid="phrase-range">
<p class="subtle">短语 · {{ phrase.wordCount }} 个单词{{ phrase.stored ? ' · 已保存' : '' }}</p>
<div class="lookup-range-actions">
<ElButton size="small" data-testid="range-start-left" :disabled="!adjust?.startLeft" @click="$emit('adjust', 'start', -1)">起点 ←</ElButton>
<ElButton size="small" data-testid="range-start-right" :disabled="!adjust?.startRight" @click="$emit('adjust', 'start', 1)">起点 →</ElButton>
<ElButton size="small" data-testid="range-end-left" :disabled="!adjust?.endLeft" @click="$emit('adjust', 'end', -1)">终点 ←</ElButton>
<ElButton size="small" data-testid="range-end-right" :disabled="!adjust?.endRight" @click="$emit('adjust', 'end', 1)">终点 →</ElButton>
</div>
</div>
<div v-if="!phrase" class="lookup-content" aria-live="polite" :aria-busy="loading">
<p v-if="loading" role="status" class="subtle">正在查询…</p>
<p v-else-if="error" role="alert" class="lookup-message">{{ error }}</p>
<template v-else-if="result">
@@ -62,24 +78,21 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
</template>
<ElButton v-if="!loading && (error || result?.status === 'resource_missing' || result?.status === 'not_found')" data-testid="lookup-retry" @click="$emit('retry')">重试查询</ElButton>
</div>
<div class="lookup-term">
<p class="lookup-term-title">学习状态</p>
<ElRadioGroup v-model="status" :disabled="saving || prefilling || !!prefillError" aria-label="学习状态">
<ElRadio v-for="item in TERM_STATUSES" :key="item.value" :value="item.value">{{ item.label }}</ElRadio>
</ElRadioGroup>
<p v-if="status === 'learning'" class="subtle">学习中按 1~7 级记录,等级由复习推进。</p>
<label for="term-definition">我的释义 <span>{{ savedTermId ? '已保存' : '新词条' }}</span></label>
<ElInput id="term-definition" v-model="definition" type="textarea" :rows="3" :disabled="saving || prefilling || !!prefillError" placeholder="记下当前词的释义" />
<label for="term-examples">例句 <span>每行一条,最多 5 条</span></label>
<ElInput id="term-examples" v-model="examples" type="textarea" :rows="2" :disabled="saving || prefilling || !!prefillError" placeholder="可选,每行一条" />
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
<div class="lookup-actions">
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
</div>
<TermFormFields
v-model:definition="definition"
v-model:examples="examples"
v-model:status="status"
v-model:level="level"
:hint="savedTermId ? '已保存' : '新词条'"
:disabled="saving || prefilling || !!prefillError"
/>
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
<div class="lookup-actions">
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
</div>
</aside>
</template>
+43 -5
View File
@@ -1,10 +1,48 @@
<script setup lang="ts">
import { termStatusOf, type ReaderToken } from '../composables/useReaderLookup'
defineProps<{ tokens: ReaderToken[]; original: string; selectedStart?: number }>()
const emit = defineEmits<{ select: [token: ReaderToken, element: HTMLElement] }>()
function select(token: ReaderToken, event: Event) { emit('select', token, event.currentTarget as HTMLElement) }
import { termStatusOf, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
defineProps<{ tokens: ReaderToken[]; phrases: PhraseSpan[]; original: string; selectedStart?: number; phraseStart?: number | null }>()
const emit = defineEmits<{
select: [token: ReaderToken, element: HTMLElement, extend: boolean]
selectPhrase: [span: PhraseSpan, element: HTMLElement]
}>()
// A token belongs to the phrase that covers it; the spans come from the server match, so the
// reader never re-derives which words form a phrase.
function spanOf(phrases: PhraseSpan[], index: number): PhraseSpan | null {
let best: PhraseSpan | null = null
for (const phrase of phrases) {
if (index < phrase.startToken || index > phrase.endToken) continue
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
}
return best
}
function select(token: ReaderToken, event: Event, phrase: PhraseSpan | null): void {
const element = event.currentTarget as HTMLElement
if (phrase && !(event as MouseEvent).shiftKey) {
emit('selectPhrase', phrase, element)
return
}
emit('select', token, element, (event as MouseEvent).shiftKey === true)
}
</script>
<template>
<article class="reader-text"><template v-if="tokens.length"><template v-for="token in tokens" :key="token.start"><span v-if="token.kind === 'word'" role="button" tabindex="0" class="reader-word" :class="[termStatusOf(token) ? `is-${termStatusOf(token)}` : '', { 'is-selected': selectedStart === token.start }]" :aria-label="termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`" :aria-pressed="selectedStart === token.start" @click="select(token, $event)" @keydown.enter.prevent="select(token, $event)" @keydown.space.prevent="select(token, $event)">{{ token.text }}</span><template v-else>{{ token.text }}</template></template></template><template v-else>{{ original }}</template></article>
<article class="reader-text"><template v-if="tokens.length"><template v-for="(token, index) in tokens" :key="token.start"><span
class="reader-token"
:data-token-index="index"
:class="[
token.kind === 'word' ? 'reader-word' : 'reader-separator',
token.kind === 'word' && termStatusOf(token) ? `is-${termStatusOf(token)}` : '',
spanOf(phrases, index) ? `is-phrase is-phrase-${spanOf(phrases, index)!.status}` : '',
spanOf(phrases, index)?.startToken === index ? 'is-phrase-start' : '',
spanOf(phrases, index)?.endToken === index ? 'is-phrase-end' : '',
{ 'is-selected': token.kind === 'word' && selectedStart === token.start, 'is-phrase-selected': spanOf(phrases, index)?.id === phraseStart },
]"
v-bind="token.kind === 'word' ? { role: 'button', tabindex: '0', 'aria-pressed': selectedStart === token.start } : {}"
:aria-label="token.kind === 'word' ? (spanOf(phrases, index) ? `短语中的 ${token.text}` : termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`) : undefined"
@click="select(token, $event, token.kind === 'word' ? spanOf(phrases, index) : null)"
@keydown.enter.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
@keydown.space.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
>{{ token.text }}</span></template></template><template v-else>{{ original }}</template></article>
</template>
+3 -3
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { ElButton } from 'element-plus'
import { clozeSentence, type ReviewItem } from '../stores/review'
import { maskedPrompt, type ReviewItem } from '../stores/review'
const props = defineProps<{ item: ReviewItem; position: number; total: number; revealed: boolean; busy: boolean; error: string }>()
const emit = defineEmits<{ reveal: []; grade: [grade: 'correct' | 'wrong' | 'again']; end: []; retry: [] }>()
// The word and its masked example are enough to answer; the definition stays hidden.
const prompt = () => clozeSentence(props.item)
const prompt = () => maskedPrompt(props.item)
const revealButton = ref<{ $el?: HTMLElement } | null>(null)
const correctButton = ref<{ $el?: HTMLElement } | null>(null)
// Keyboard users should reach the next action without tabbing through the page again.
@@ -26,7 +26,7 @@ watch(() => props.revealed, async value => {
<p class="subtle" data-testid="review-position">到期复习 · {{ position }} / {{ total }}</p>
<h2 id="review-word" tabindex="-1" class="review-word">{{ item.originalForm }}</h2>
<p v-if="prompt()" class="review-example" lang="en">{{ prompt() }}</p>
<p class="subtle">{{ item.status === 'learning' ? `学习中 · 等级 ${item.level}` : '新词 · 尚未复习' }}</p>
<p class="subtle">{{ item.kind === 'phrase' ? `短语 · ${item.wordCount} 个单词 · ` : '' }}{{ item.status === 'learning' ? `学习中 · 等级 ${item.level}` : '新词 · 尚未复习' }}</p>
<p v-if="error" role="alert" class="notice">{{ error }}</p>
<template v-if="!revealed">
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { TERM_STATUSES, type TermStatus } from '../composables/useReaderLookup'
// The reader panel and the vocabulary dialog edit the same fields, so they share this form
// instead of keeping two copies of the same rules.
const props = defineProps<{ disabled?: boolean; showLevel?: boolean; hint?: string }>()
const definition = defineModel<string>('definition', { required: true })
const examples = defineModel<string>('examples', { required: true })
const status = defineModel<TermStatus>('status', { required: true })
const level = defineModel<number>('level', { required: true })
const levelVisible = computed(() => props.showLevel === true && status.value === 'learning')
</script>
<template>
<div class="term-fields">
<p class="lookup-term-title">学习状态</p>
<ElRadioGroup v-model="status" :disabled="disabled" aria-label="学习状态">
<ElRadio v-for="item in TERM_STATUSES" :key="item.value" :value="item.value">{{ item.label }}</ElRadio>
</ElRadioGroup>
<p v-if="levelVisible" class="term-level">
<label for="term-level">学习等级</label>
<ElSelect id="term-level" v-model="level" :disabled="disabled" class="term-level-select" data-testid="term-level">
<ElOption v-for="value in 7" :key="value" :label="`${value} 级`" :value="value" />
</ElSelect>
</p>
<p v-else-if="status === 'learning'" class="subtle">学习中按 1~7 级记录,等级由复习推进。</p>
<label for="term-definition">我的释义 <span v-if="hint">{{ hint }}</span></label>
<ElInput id="term-definition" v-model="definition" type="textarea" :rows="3" :disabled="disabled" placeholder="记下当前词的释义" />
<label for="term-examples">例句 <span>每行一条,最多 5 条</span></label>
<ElInput id="term-examples" v-model="examples" type="textarea" :rows="2" :disabled="disabled" placeholder="可选,每行一条" />
</div>
</template>
+98
View File
@@ -0,0 +1,98 @@
import type { ReaderToken } from './useReaderLookup'
/** A phrase selection over the server's own tokens, or a candidate the reader can adjust. */
export interface TokenRange {
/** Token indices of the first and last word of the phrase. */
firstWord: number
lastWord: number
/** Code point range of the whole phrase inside the original text. */
start: number
end: number
/** The original text exactly as the chapter holds it. */
text: string
wordCount: number
}
export const MAX_PHRASE_WORDS = 12
/** Indices of the word tokens, in reading order. */
export function wordIndices(tokens: ReaderToken[]): number[] {
const indices: number[] = []
for (let index = 0; index < tokens.length; index++) {
if (tokens[index]!.kind === 'word') indices.push(index)
}
return indices
}
/**
* Builds a phrase range from two token indices, aligning both ends to whole words and
* keeping interior punctuation and line breaks in the text. It mirrors the rules verified in
* #4: separators at the edges are skipped, interior ones survive, and a single word is not a
* phrase.
*/
export function normalizeTokenRange(tokens: ReaderToken[], original: string, anchor: number, focus: number): TokenRange | null {
if (!Number.isInteger(anchor) || !Number.isInteger(focus)) return null
const from = Math.min(anchor, focus)
const to = Math.max(anchor, focus)
if (from < 0 || to >= tokens.length) return null
let firstWord = -1
let lastWord = -1
for (let index = from; index <= to; index++) {
if (tokens[index]!.kind !== 'word') continue
if (firstWord < 0) firstWord = index
lastWord = index
}
if (firstWord < 0 || lastWord < 0 || firstWord === lastWord) return null
return rangeOfWords(tokens, original, firstWord, lastWord)
}
/** The phrase range for a known first and last word token. */
export function rangeOfWords(tokens: ReaderToken[], original: string, firstWord: number, lastWord: number): TokenRange | null {
const first = tokens[firstWord]
const last = tokens[lastWord]
if (!first || !last || first.kind !== 'word' || last.kind !== 'word' || lastWord < firstWord) return null
const wordCount = wordIndices(tokens.slice(firstWord, lastWord + 1)).length
if (wordCount < 2 || wordCount > MAX_PHRASE_WORDS) return null
const text = [...original].slice(first.start, last.end).join('')
if (!text) return null
return { firstWord, lastWord, start: first.start, end: last.end, text, wordCount }
}
/**
* Moves one end of a phrase by whole words, skipping separators and never inverting the range.
* Returns the adjusted range, or null when the move is impossible.
*/
export function adjustTokenRange(tokens: ReaderToken[], original: string, range: TokenRange, edge: 'start' | 'end', direction: -1 | 1): TokenRange | null {
const words = wordIndices(tokens)
const positionOf = (tokenIndex: number) => words.indexOf(tokenIndex)
const firstPosition = positionOf(range.firstWord)
const lastPosition = positionOf(range.lastWord)
if (firstPosition < 0 || lastPosition < 0) return null
if (edge === 'start') {
const next = firstPosition + direction
if (next < 0 || next > lastPosition - 1) return null
return rangeOfWords(tokens, original, words[next]!, range.lastWord)
}
const next = lastPosition + direction
if (next >= words.length || next < firstPosition + 1) return null
return rangeOfWords(tokens, original, range.firstWord, words[next]!)
}
/** The saved phrases that cover a word token, longest first, so a click prefers the phrase. */
export function phrasesAt(tokens: ReaderToken[], phrases: { id: number; startToken: number; endToken: number }[], tokenIndex: number): { id: number; startToken: number; endToken: number } | null {
let best: { id: number; startToken: number; endToken: number } | null = null
for (const phrase of phrases) {
if (tokenIndex < phrase.startToken || tokenIndex > phrase.endToken) continue
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
}
return best
}
/** The phrase range a saved span covers, used when a highlighted phrase is clicked. */
export function rangeOfSpan(tokens: ReaderToken[], original: string, span: { startToken: number; endToken: number }): TokenRange | null {
const words = wordIndices(tokens.slice(span.startToken, span.endToken + 1))
if (words.length < 2) return null
const firstWord = span.startToken + words[0]!
const lastWord = span.startToken + words[words.length - 1]!
return rangeOfWords(tokens, original, firstWord, lastWord)
}
@@ -0,0 +1,79 @@
/**
* Remembers where the reader stopped in a chapter. The position belongs to one account and one
* chapter, and it is tied to the chapter's content version: after the text is replaced, the old
* offset would point at different words, so it is dropped instead of restored.
*
* The value is a scroll ratio rather than a pixel offset, because the same chapter is read at
* different window widths (desktop and phone) where a pixel offset would land in the wrong place.
* This is local to the browser: it is not synced across devices.
*/
export const POSITION_KEY_PREFIX = 'lexgo-learner-position:'
export interface ReadingPosition {
ratio: number
sha: string
at: string
}
export function positionKey(userId: number | null, chapterId: number): string | null {
if (userId === null || !Number.isInteger(chapterId) || chapterId <= 0) return null
return `${POSITION_KEY_PREFIX}${userId}:${chapterId}`
}
/** Clamps a stored ratio into [0,1]; anything else is treated as "no usable position". */
export function normalizeRatio(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null
if (value <= 0) return null
return Math.min(value, 1)
}
export function readPosition(storage: Storage, userId: number | null, chapterId: number, sha: string): ReadingPosition | null {
const key = positionKey(userId, chapterId)
if (key === null) return null
try {
const raw = storage.getItem(key)
if (raw === null) return null
const parsed = JSON.parse(raw) as Partial<ReadingPosition> | null
const ratio = normalizeRatio(parsed?.ratio)
// A different content version means the offset describes words that are no longer there.
if (ratio === null || !parsed || parsed.sha !== sha) return null
return { ratio, sha, at: typeof parsed.at === 'string' ? parsed.at : '' }
} catch {
return null
}
}
export function writePosition(storage: Storage, userId: number | null, chapterId: number, sha: string, ratio: number, now = new Date()): void {
const key = positionKey(userId, chapterId)
const usable = normalizeRatio(ratio)
if (key === null || usable === null || !sha) return
try {
storage.setItem(key, JSON.stringify({ ratio: usable, sha, at: now.toISOString() } satisfies ReadingPosition))
} catch {
// A full or disabled store must not break reading.
}
}
export function clearPosition(storage: Storage, userId: number | null, chapterId: number): void {
const key = positionKey(userId, chapterId)
if (key === null) return
try {
storage.removeItem(key)
} catch {
// Ignored for the same reason as above.
}
}
export interface ReadingPositionOptions {
/** The scrollable document the reader lives in. */
view: () => Window
/** The current scrollable height, used to turn a pixel offset into a ratio. */
scrollHeight: () => number
}
/** Turns the current window scroll offset into the ratio that is stored. */
export function currentRatio(view: Pick<Window, 'scrollY' | 'innerHeight'>, scrollHeight: number): number {
const scrollable = scrollHeight - view.innerHeight
if (scrollable <= 0) return 0
return Math.min(Math.max(view.scrollY / scrollable, 0), 1)
}
@@ -0,0 +1,58 @@
import { onScopeDispose } from 'vue'
export type ReviewShortcut = 'reveal' | 'correct' | 'wrong' | 'again'
export interface ReviewShortcutOptions {
/** Called for a shortcut that applies in the current card state. */
onShortcut: (action: ReviewShortcut) => void
/** False while the card is busy or an answer failed; then no shortcut is accepted. */
enabled: () => boolean
/** Whether the answer is already on screen. */
revealed: () => boolean
}
const GRADE_KEYS: Record<string, ReviewShortcut> = { '1': 'correct', '2': 'wrong', '3': 'again' }
/**
* True when the key belongs to whatever currently has the focus instead of the review.
*
* Typing fields and dropdowns own every key. A focused button or link owns Space and Enter,
* because the browser already activates it there and a second handler would answer twice — but a
* digit is not a button key, so grading from the keyboard keeps working while a button is focused.
*/
export function ignoresShortcut(target: EventTarget | null, key: string): boolean {
const element = target as HTMLElement | null
if (!element || typeof element.closest !== 'function') return false
if (element.isContentEditable) return true
if (element.closest('input, textarea, select, [contenteditable="true"], .el-select, .el-dropdown') !== null) return true
if (key === ' ' || key === 'Enter') {
return element.closest('button, a[href], [role="button"]') !== null
}
return false
}
/** Maps one key press to a review action, or null when the key is not ours. */
export function shortcutFor(key: string, revealed: boolean): ReviewShortcut | null {
if (key === ' ' || key === 'Enter') return revealed ? null : 'reveal'
if (!revealed) return null
return GRADE_KEYS[key] ?? null
}
/**
* Keyboard operations for the due review: Space or Enter shows the answer, then 1, 2 and 3 grade
* it. A modifier key means the learner is using a browser or system shortcut, so it is left alone.
*/
export function useReviewShortcuts(options: ReviewShortcutOptions) {
function onKeydown(event: KeyboardEvent): void {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return
if (!options.enabled()) return
if (ignoresShortcut(event.target, event.key)) return
const action = shortcutFor(event.key, options.revealed())
if (action === null) return
event.preventDefault()
options.onShortcut(action)
}
document.addEventListener('keydown', onKeydown)
onScopeDispose(() => document.removeEventListener('keydown', onKeydown))
}
+68 -8
View File
@@ -1,6 +1,7 @@
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
import type { ChapterDetail } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { adjustTokenRange, type TokenRange } from './readerRange'
export type TermStatus = 'new' | 'learning' | 'known' | 'ignored'
@@ -14,6 +15,8 @@ export interface ReaderToken {
kind: 'word' | 'space' | 'punctuation'
term?: TokenTerm | null
}
/** One saved phrase occurrence inside the chapter, in token indices. */
export interface PhraseSpan { id: number; status: TermStatus; wordCount: number; startToken: number; endToken: number }
export interface LookupResult {
status: 'exact' | 'lemma' | 'not_found' | 'resource_missing'
query: string
@@ -30,9 +33,11 @@ export interface SavedTerm {
examples: string[]
status: TermStatus
level: number
kind: 'word' | 'phrase'
wordCount: number
}
interface TermResponse { term: SavedTerm }
interface TokenResponse { textSha256: string; tokens: ReaderToken[] }
interface TokenResponse { textSha256: string; tokens: ReaderToken[]; phrases?: PhraseSpan[] }
// The four learner-visible states and the level rule behind them. Only a learning
// entry carries a level, so every other status reports level 0.
@@ -72,9 +77,13 @@ function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean {
export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
const session = useSessionStore()
const tokens = ref<ReaderToken[]>([])
const phrases = ref<PhraseSpan[]>([])
const tokensError = ref('')
const tokensLoading = ref(false)
const selected = ref<ReaderToken | null>(null)
// A phrase selection: either a drag over several words or a click on a saved phrase span.
const range = ref<TokenRange | null>(null)
const rangeTermId = ref<number | null>(null)
const result = ref<LookupResult | null>(null)
const loading = ref(false)
const error = ref('')
@@ -97,7 +106,7 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
// Sending is blocked until the stored text is on screen, so a slow or failed read
// can never let an empty form overwrite what the learner already wrote.
const canSave = computed(() => selected.value !== null && !saving.value && !prefilling.value && !prefillError.value)
const canSave = computed(() => (selected.value !== null || range.value !== null) && !saving.value && !prefilling.value && !prefillError.value)
function exampleLines(): string[] {
return examples.value.split('\n').map(line => line.trim()).filter(Boolean)
@@ -131,6 +140,8 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
termSequence++
saveSequence++
selected.value = null
range.value = null
rangeTermId.value = null
result.value = null
loading.value = false
error.value = ''
@@ -152,6 +163,7 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
function reset() {
tokenSequence++
tokens.value = []
phrases.value = []
tokensError.value = ''
tokensLoading.value = false
close(false)
@@ -167,16 +179,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
if (seq !== tokenSequence) return
if (!matchesChapter(data, current)) throw new Error('分词与正文不一致,请重试。')
tokens.value = data.tokens
phrases.value = Array.isArray(data.phrases) ? data.phrases : []
} catch (reason) {
if (seq !== tokenSequence) return
tokens.value = []
phrases.value = []
tokensError.value = reason instanceof Error ? reason.message : '单词暂时无法加载。'
} finally { if (seq === tokenSequence) tokensLoading.value = false }
}
async function lookup() {
const current = chapter.value
const token = selected.value
if (!session.user || !current || !token) return
if (!session.user || !current || !token || range.value) return
const seq = ++lookupSequence
result.value = null
error.value = ''
@@ -208,15 +222,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
}
async function save() {
const current = chapter.value
const selection = range.value
const token = selected.value
if (!session.user || !current || !token || !canSave.value) return
if (!session.user || !current || (!selection && !token) || !canSave.value) return
const seq = ++saveSequence
saving.value = true
saveError.value = ''
saved.value = ''
const span = selection ?? token!
const path = selection ? 'phrases' : 'terms'
try {
const data = await session.request<TermResponse & { created: boolean }>('terms', 'POST', {
chapterId: current.id, start: token.start, end: token.end,
const data = await session.request<TermResponse & { created: boolean }>(path, 'POST', {
chapterId: current.id, start: span.start, end: span.end,
definition: definition.value, examples: exampleLines(), status: status.value,
})
if (seq !== saveSequence) return
@@ -224,6 +241,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
examples.value = data.term.examples.join('\n')
status.value = data.term.status
applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level })
if (selection) {
// The saved phrase must highlight here without waiting for another token load.
rangeTermId.value = data.term.id
const savedSpan = {
id: data.term.id, status: data.term.status, wordCount: selection.wordCount,
startToken: selection.firstWord, endToken: selection.lastWord,
}
const existing = phrases.value.findIndex(item => item.id === data.term.id)
phrases.value = existing >= 0
? phrases.value.map((item, index) => (index === existing ? savedSpan : item))
: [...phrases.value, savedSpan]
}
saved.value = `已保存 · ${termStatusLabel(data.term.status)}`
} catch (reason) {
if (seq !== saveSequence) return
@@ -241,6 +270,37 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
}
void lookup()
}
/**
* Opens the phrase form for a dragged range or for a saved phrase span. A phrase is one
* entry in the same table as a word, so an existing phrase is read through the same route.
*/
function selectRange(next: TokenRange, element: HTMLElement | null, termId: number | null = null) {
close(false)
origin = element
range.value = next
rangeTermId.value = termId
if (termId !== null) void loadTerm(termId)
}
/** Moves one end of the phrase by whole words; the panel keeps the selection in sync. */
function adjustRange(edge: 'start' | 'end', direction: -1 | 1) {
const current = range.value
const currentChapter = chapter.value
if (!current || !currentChapter) return
const next = adjustTokenRange(tokens.value, currentChapter.originalText ?? '', current, edge, direction)
if (!next) return
// The adjusted range is a different identity, so the panel must stop claiming that the
// stored entry is what is on screen: both the phrase link and the entry the form was loaded
// from are dropped. The typed text stays, and saving it writes the phrase that matches the
// current range (which may update another entry, never two).
rangeTermId.value = null
savedTermId.value = null
range.value = next
}
/** True when the panel is editing an entry that is already stored. */
const rangeStored = computed(() => rangeTermId.value !== null)
watch(() => [chapter.value?.id, chapter.value?.status, chapter.value?.contentSha256, chapter.value?.originalText], () => {
reset()
void loadTokens()
@@ -250,8 +310,8 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
watch(() => session.user, reset, { flush: 'sync' })
onScopeDispose(reset)
return {
tokens, tokensError, tokensLoading, selected, result, loading, error,
tokens, phrases, tokensError, tokensLoading, selected, range, rangeTermId, rangeStored, result, loading, error,
definition, examples, status, savedTermId, prefilling, prefillError, saving, saveError, saved, canSave,
loadTokens, lookup, loadTerm, save, select, close, reset, keepSelectionVisible,
loadTokens, lookup, loadTerm, save, select, selectRange, adjustRange, close, reset, keepSelectionVisible,
}
}
@@ -0,0 +1,80 @@
import { onScopeDispose, type Ref } from 'vue'
/**
* Maps the browser's own selection onto the server tokens. Nothing here intercepts touch or
* pointer movement: a mouse drag and the phone's system selection handles both produce a
* selectionchange, which is the interaction #4 verified.
*/
export function tokenIndexOf(node: Node | null): number {
if (!node) return -1
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
const holder = element?.closest?.('[data-token-index]')
if (!holder) return -1
const value = Number((holder as HTMLElement).dataset.tokenIndex)
return Number.isInteger(value) ? value : -1
}
export interface TextSelectionOptions {
/** The element the selection has to start inside. */
container: Ref<HTMLElement | null>
/** Called with the first and last token index of a non-empty selection inside the reader. */
onSelect: (anchor: number, focus: number) => void
}
/**
* Watches the native selection and reports token indices. A collapsed selection (a plain
* click) reports nothing, so clicking a word keeps opening the word panel.
*/
export function useTextSelection(options: TextSelectionOptions) {
let timer: number | undefined
let disposed = false
function read(): void {
if (disposed) return
const root = options.container.value
const selection = window.getSelection?.()
if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) return
const range = selection.getRangeAt(0)
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return
const anchor = tokenIndexOf(range.startContainer)
const focus = tokenIndexOf(range.endContainer)
if (anchor < 0 || focus < 0) return
options.onSelect(Math.min(anchor, focus), Math.max(anchor, focus))
}
// The debounce matches the verified spike: it lets the browser finish a drag before the
// range is read, and it never blocks scrolling on a touch device.
function schedule(): void {
if (timer !== undefined) window.clearTimeout(timer)
timer = window.setTimeout(() => { timer = undefined; read() }, 100)
}
function pointerUp(event: PointerEvent): void {
// Only a pointerup inside the reader can finish a selection; a click on the panel must not
// re-read a range the learner already adjusted there.
const root = options.container.value
const target = event.target as Node | null
if (!root || !target || !root.contains(target)) return
if (timer !== undefined) window.clearTimeout(timer)
// A pointerup arrives before the browser finalises the range, so the read waits a tick.
timer = window.setTimeout(() => { timer = undefined; read() }, 0)
}
// Both listeners live on the document: the reader body only exists once a chapter is ready,
// so a listener bound to the element at setup time would miss every later selection.
document.addEventListener('selectionchange', schedule)
document.addEventListener('pointerup', pointerUp)
onScopeDispose(() => {
disposed = true
if (timer !== undefined) window.clearTimeout(timer)
document.removeEventListener('selectionchange', schedule)
document.removeEventListener('pointerup', pointerUp)
})
return {
/** Drops the native highlight, e.g. when the panel closes. */
clear() { window.getSelection?.()?.removeAllRanges() },
read,
}
}
+4
View File
@@ -8,6 +8,10 @@ import 'element-plus/es/components/radio/style/css'
import 'element-plus/es/components/radio-group/style/css'
import 'element-plus/es/components/select/style/css'
import 'element-plus/es/components/option/style/css'
import 'element-plus/es/components/dropdown/style/css'
import 'element-plus/es/components/dropdown-menu/style/css'
import 'element-plus/es/components/dropdown-item/style/css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './style.css'
createApp(App).use(createPinia()).use(router).mount('#app')
+2
View File
@@ -7,6 +7,8 @@ const router = createRouter({
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
{ path: '/', name: 'library', meta: { private: true }, component: () => import('../views/LibraryView.vue') },
{ path: '/review', name: 'review', meta: { private: true }, component: () => import('../views/ReviewView.vue') },
{ path: '/vocab', name: 'vocab', meta: { private: true }, component: () => import('../views/VocabView.vue') },
{ path: '/progress', name: 'progress', meta: { private: true }, component: () => import('../views/ProgressView.vue') },
{ path: '/import', name: 'import', meta: { private: true }, component: () => import('../views/ImportView.vue') },
{ path: '/books/:id', name: 'book', meta: { private: true }, component: () => import('../views/BookView.vue') },
{ path: '/chapters/:id', name: 'chapter', meta: { private: true }, component: () => import('../views/ReaderView.vue') },
+43 -2
View File
@@ -27,6 +27,9 @@ export interface ChapterSummary {
errorMessage: string
// Present wherever a chapter appears; null while the job id is unknown.
jobId: number | null
// Set only when this completion still matches the chapter's current content; a chapter
// edited into a new version reads as unread again.
readAt?: string | null
createdAt: string
updatedAt: string
}
@@ -66,6 +69,15 @@ export interface ChapterSource {
export interface ChapterEdit { chapter: ChapterSummary; job: Job | null; versionChanged: boolean }
export interface DeletionResult { bookId?: number; chapterId?: number; chapters?: number; remaining: number }
/** The answer to completing one chapter; a repeat answers with the same mark. */
export interface ChapterCompletion {
chapterId: number
bookId: number
read: boolean
readAt: string
duplicate: boolean
}
export type SubmitTarget = { mode: 'new' } | { mode: 'append'; bookId: number }
export interface SubmitInput { title: string; text: string; target: SubmitTarget }
@@ -175,6 +187,7 @@ export const useLibraryStore = defineStore('library', () => {
const submitting = ref(false)
const submitError = ref('')
const retryingChapterId = ref<number | null>(null)
const completingChapterId = ref<number | null>(null)
/** Reader text exists only for ready chapters and is never taken from a cache. */
const readerText = computed(() => (chapter.value?.status === 'ready' ? chapter.value.originalText ?? '' : ''))
@@ -562,6 +575,33 @@ export const useLibraryStore = defineStore('library', () => {
}
}
/**
* Marks one owned chapter as read. Completing only records the reading fact: word statuses and
* levels are left alone. A repeated request answers with the same mark instead of counting
* twice, and a chapter whose text was replaced is completed again for the new version.
*/
async function markChapterRead(chapterId: number): Promise<ChapterCompletion> {
const version = generation
const owner = ownerId()
completingChapterId.value = chapterId
try {
const result = await session.request<{ progress: ChapterCompletion }>(`chapters/${chapterId}/complete`, 'POST')
if (!isStale(version, owner)) {
// The same mark reaches the chapter list and the open reader; nothing else changes
// about the chapter, so no other field is touched.
const listed = chapters.value.find(item => item.id === chapterId)
if (listed) listed.readAt = result.progress.readAt
if (chapter.value !== null && chapter.value.id === chapterId) chapter.value.readAt = result.progress.readAt
}
return result.progress
} catch (reason) {
if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('标记失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('标记失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) completingChapterId.value = null
}
}
/**
* Releases the book view so polling stops when the page is left. The request sequence is
* advanced first, so a response that arrives after this call cannot repopulate the view or
@@ -605,6 +645,7 @@ export const useLibraryStore = defineStore('library', () => {
submitting.value = false
submitError.value = ''
retryingChapterId.value = null
completingChapterId.value = null
submissionKey = ''
submissionRequestId = ''
}
@@ -613,8 +654,8 @@ export const useLibraryStore = defineStore('library', () => {
books, booksLoading, booksError,
book, chapters, bookLoading, bookError,
chapter, chapterBook, navigation, chapterLoading, chapterError,
submitting, submitError, retryingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, upload, retryChapter,
submitting, submitError, retryingChapterId, completingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, upload, retryChapter, markChapterRead,
renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
stopPolling, closeBook, closeChapter, reset,
}
+155
View File
@@ -0,0 +1,155 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useSessionStore } from './session'
/** The three display choices the learner can make. */
export type ThemeChoice = 'light' | 'dark' | 'system'
export type FontChoice = 'standard' | 'large' | 'xlarge'
export const THEME_CHOICES: ThemeChoice[] = ['light', 'dark', 'system']
export const FONT_CHOICES: FontChoice[] = ['standard', 'large', 'xlarge']
export const THEME_LABELS: Record<ThemeChoice, string> = { light: '浅色', dark: '深色', system: '跟随系统' }
export const FONT_LABELS: Record<FontChoice, string> = { standard: '标准', large: '大', xlarge: '特大' }
/**
* The reader text scale. It multiplies the reading surfaces only (chapter text, lookup content,
* review card), so a larger step never squeezes the navigation or the buttons in a narrow window.
*/
export const FONT_SCALES: Record<FontChoice, string> = { standard: '1', large: '1.15', xlarge: '1.3' }
export interface DisplayPreference {
theme: ThemeChoice
font: FontChoice
}
/** Preferences are stored per account, so switching accounts cannot carry one across. */
export const DISPLAY_KEY_PREFIX = 'lexgo-learner-display:'
export function displayKey(userId: number | null): string | null {
return userId === null ? null : `${DISPLAY_KEY_PREFIX}${userId}`
}
export function defaultPreference(): DisplayPreference {
return { theme: 'system', font: 'standard' }
}
function isTheme(value: unknown): value is ThemeChoice {
return typeof value === 'string' && (THEME_CHOICES as string[]).includes(value)
}
function isFont(value: unknown): value is FontChoice {
return typeof value === 'string' && (FONT_CHOICES as string[]).includes(value)
}
/**
* Reads one account's stored display preference. Anything unreadable, unknown or from an older
* shape falls back to the default instead of throwing or half-applying a stored value.
*/
export function readPreference(storage: Storage, userId: number | null): DisplayPreference {
const key = displayKey(userId)
if (key === null) return defaultPreference()
try {
const raw = storage.getItem(key)
if (raw === null) return defaultPreference()
const parsed = JSON.parse(raw) as Partial<DisplayPreference> | null
const preference = defaultPreference()
if (parsed && isTheme(parsed.theme)) preference.theme = parsed.theme
if (parsed && isFont(parsed.font)) preference.font = parsed.font
return preference
} catch {
return defaultPreference()
}
}
export function writePreference(storage: Storage, userId: number | null, preference: DisplayPreference): void {
const key = displayKey(userId)
if (key === null) return
try {
storage.setItem(key, JSON.stringify({ theme: preference.theme, font: preference.font }))
} catch {
// A full or disabled store must not break the page: the choice still applies for this visit.
}
}
/** "system" resolves through the operating system preference; the other two are explicit. */
export function resolveTheme(theme: ThemeChoice, prefersDark: boolean): 'light' | 'dark' {
if (theme === 'system') return prefersDark ? 'dark' : 'light'
return theme
}
/**
* Applies a preference to the document: the palette attribute, the Element Plus dark class and
* the reading font scale. The dark class is what Element Plus components read.
*/
export function applyPreference(root: HTMLElement, preference: DisplayPreference, prefersDark: boolean): 'light' | 'dark' {
const resolved = resolveTheme(preference.theme, prefersDark)
root.dataset.theme = resolved
root.dataset.themeChoice = preference.theme
root.classList.toggle('dark', resolved === 'dark')
root.style.setProperty('--reader-font-scale', FONT_SCALES[preference.font])
return resolved
}
export function systemPrefersDark(): boolean {
return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
}
export const useDisplayStore = defineStore('display', () => {
const session = useSessionStore()
const preference = ref<DisplayPreference>(defaultPreference())
const resolved = ref<'light' | 'dark'>('light')
let listening: MediaQueryList | null = null
const theme = computed(() => preference.value.theme)
const font = computed(() => preference.value.font)
const isDark = computed(() => resolved.value === 'dark')
function storage(): Storage | null {
try {
return window.localStorage
} catch {
return null
}
}
function paint(): void {
if (typeof document === 'undefined') return
resolved.value = applyPreference(document.documentElement, preference.value, systemPrefersDark())
}
/** Watches the operating system only while the choice actually follows it. */
function followSystem(): void {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
listening?.removeEventListener('change', paint)
listening = null
if (preference.value.theme !== 'system') return
listening = window.matchMedia('(prefers-color-scheme: dark)')
listening.addEventListener('change', paint)
}
/** Loads the account's own preference (or the default) and applies it at once. */
function load(userId: number | null): void {
const store = storage()
preference.value = store ? readPreference(store, userId) : defaultPreference()
paint()
followSystem()
}
function save(next: Partial<DisplayPreference>): void {
if (next.theme !== undefined && isTheme(next.theme)) preference.value.theme = next.theme
if (next.font !== undefined && isFont(next.font)) preference.value.font = next.font
const store = storage()
if (store) writePreference(store, session.user?.id ?? null, preference.value)
paint()
followSystem()
}
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) load(next)
}, { immediate: true })
return { preference, theme, font, resolved, isDark, load, save, setTheme: (value: ThemeChoice) => save({ theme: value }), setFont: (value: FontChoice) => save({ font: value }) }
})
+102
View File
@@ -0,0 +1,102 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { ApiError, useSessionStore } from './session'
/** One book's share of the reading total. */
export interface BookProgress {
id: number
title: string
readChapters: number
totalChapters: number
}
/**
* The learner's own basic progress for the current language. Reading counts only chapters that
* can be read, and the four word statuses are reported apart, so "known" and "ignored" are never
* folded into one number.
*/
export interface LearnerProgress {
readChapters: number
totalChapters: number
knownTerms: number
learningTerms: number
newTerms: number
ignoredTerms: number
savedTerms: number
dueNow: number
books: BookProgress[]
}
function emptyProgress(): LearnerProgress {
return {
readChapters: 0, totalChapters: 0, knownTerms: 0, learningTerms: 0,
newTerms: 0, ignoredTerms: 0, savedTerms: 0, dueNow: 0, books: [],
}
}
/**
* The progress page reads one snapshot from the server. Every number is counted server-side under
* the caller's own identity, so the page never adds up rows it happens to have loaded.
*/
export const useProgressStore = defineStore('progress', () => {
const session = useSessionStore()
const data = ref<LearnerProgress>(emptyProgress())
const loading = ref(false)
const error = ref('')
const loaded = ref(false)
let sequence = 0
let generation = 0
const hasBooks = computed(() => data.value.books.length > 0)
const readLabel = computed(() => `${data.value.readChapters} / ${data.value.totalChapters} 章(可阅读)`)
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || owner !== ownerId()
}
async function load(): Promise<void> {
if (!session.user) return
const version = generation
const owner = ownerId()
const seq = ++sequence
loading.value = true
error.value = ''
try {
const result = await session.request<LearnerProgress>('progress')
if (seq !== sequence || isStale(version, owner)) return
data.value = {
...emptyProgress(),
...result,
books: Array.isArray(result?.books) ? result.books : [],
}
loaded.value = true
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
// Keep whatever is already on screen; a failed refresh is not a claim about the records.
error.value = reason instanceof ApiError
? reason.message
: '进度暂时无法加载,请稍后重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) loading.value = false
}
}
function reset(): void {
generation++
sequence++
data.value = emptyProgress()
loading.value = false
error.value = ''
loaded.value = false
}
return { data, loading, error, loaded, hasBooks, readLabel, load, reset }
})
+18
View File
@@ -15,6 +15,8 @@ export interface ReviewItem {
examples: string[]
status: 'new' | 'learning'
level: number
kind: 'word' | 'phrase'
wordCount: number
dueAt: string
reviewCount: number
}
@@ -57,6 +59,22 @@ export function clozeSentence(item: ReviewItem): string | null {
return masked
}
/**
* A phrase is masked as one blank covering the whole run, which is what the accepted prototype
* shows. Interior separators of any non-letter run are allowed, matching the server's phrase
* identity rule.
*/
export function maskedPrompt(item: ReviewItem): string | null {
const line = item.examples[0]
if (!line) return null
if (item.kind !== 'phrase') return clozeSentence(item)
const forms = item.term.split(' ').filter(Boolean)
if (forms.length < 2) return clozeSentence(item)
const parts = forms.map(form => escapeRegExp(form).replace(/'/g, "['’]"))
const pattern = new RegExp(parts.join(`[\\p{L}\\p{M}]*?[^\\p{L}\\p{M}]+[\\p{L}\\p{M}]*?`), 'iu')
return line.replace(pattern, '_____')
}
export const useReviewStore = defineStore('review', () => {
const session = useSessionStore()
+154
View File
@@ -0,0 +1,154 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useSessionStore } from './session'
import type { TermStatus } from '../composables/useReaderLookup'
/** One entry of the vocabulary list, exactly as the term endpoints return it. */
export interface VocabularyTerm {
id: number
term: string
originalForm: string
definition: string
examples: string[]
status: TermStatus
level: number
kind: 'word' | 'phrase'
wordCount: number
updatedAt: string
}
export interface VocabularyEdit {
definition: string
examples: string[]
status: TermStatus
level?: number
}
export const VOCAB_PAGE_SIZE = 20
/**
* The vocabulary list is one server page at a time. Filters live in the store so that opening
* and closing the edit dialog, or coming back from the reader, keeps what the learner searched
* for; the view mirrors them into the URL for reloads.
*/
export const useVocabularyStore = defineStore('vocabulary', () => {
const session = useSessionStore()
const items = ref<VocabularyTerm[]>([])
const total = ref(0)
const page = ref(1)
const query = ref('')
const status = ref<TermStatus | ''>('')
const kind = ref<'word' | 'phrase' | ''>('')
const loading = ref(false)
const error = ref('')
const saving = ref(false)
const saveError = ref('')
let sequence = 0
let generation = 0
const hasFilters = computed(() => query.value !== '' || status.value !== '' || kind.value !== '')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / VOCAB_PAGE_SIZE)))
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || ownerId() !== owner
}
function reset(): void {
generation++
sequence++
items.value = []
total.value = 0
page.value = 1
query.value = ''
status.value = ''
kind.value = ''
loading.value = false
error.value = ''
saving.value = false
saveError.value = ''
}
/** Loads the current page with the current filters. */
async function load(): Promise<void> {
const version = generation
const owner = ownerId()
const seq = ++sequence
loading.value = true
error.value = ''
try {
const params = new URLSearchParams()
if (query.value.trim()) params.set('query', query.value.trim())
if (status.value) params.set('status', status.value)
if (kind.value) params.set('kind', kind.value)
params.set('page', String(page.value))
params.set('limit', String(VOCAB_PAGE_SIZE))
const result = await session.request<{ items?: VocabularyTerm[]; total?: number }>(`terms?${params.toString()}`)
if (seq !== sequence || isStale(version, owner)) return
items.value = Array.isArray(result?.items) ? result.items : []
total.value = typeof result?.total === 'number' ? result.total : items.value.length
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
error.value = reason instanceof Error ? reason.message : '词汇库暂时无法加载,请稍后重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) loading.value = false
}
}
/** Applying a filter always starts from the first page. */
async function apply(): Promise<void> {
page.value = 1
await load()
}
async function goToPage(next: number): Promise<void> {
const target = Math.min(Math.max(1, next), totalPages.value)
if (target === page.value) return
page.value = target
await load()
}
async function clearFilters(): Promise<void> {
query.value = ''
status.value = ''
kind.value = ''
await apply()
}
/**
* Saves one entry through the same endpoint the reader uses. Identity is not editable, and the
* server applies the same status/level and schedule rules as a save from the reader.
*/
async function updateTerm(id: number, input: VocabularyEdit): Promise<VocabularyTerm> {
const version = generation
const owner = ownerId()
saving.value = true
saveError.value = ''
const body: Record<string, unknown> = { definition: input.definition, examples: input.examples, status: input.status }
if (input.status === 'learning' && input.level !== undefined) body.level = input.level
try {
const result = await session.request<{ term: VocabularyTerm }>(`terms/${id}`, 'PATCH', body)
if (!isStale(version, owner)) {
items.value = items.value.map(item => (item.id === id ? result.term : item))
}
return result.term
} catch (reason) {
if (!isStale(version, owner)) saveError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
throw reason instanceof Error ? reason : new Error('保存失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) saving.value = false
}
}
return {
items, total, page, query, status, kind, loading, error, saving, saveError,
hasFilters, totalPages, load, apply, goToPage, clearFilters, updateTerm, reset,
}
})
+249 -71
View File
@@ -1,143 +1,301 @@
:root {
/* One palette for both themes. Every surface, text and status colour in the rules below
resolves through these variables, so the dark theme only overrides values, never layout.
These are the only literal colours in the file. */
--bg: #f7f5ee;
--surface: #fffdf8;
--surface-raised: #fffdf7;
--surface-input: #fffefa;
--text: #233d31;
--text-strong: #264a35;
--text-muted: #748073;
--text-faint: #687568;
--border: #e0e3d8;
--border-strong: #d9decf;
--border-input: #d6dccf;
--accent: #315c43;
--accent-soft: #eef2eb;
--accent-marker: #bc803d;
--accent-gold: #ba8345;
--brand-panel: #284c38;
--brand-panel-text: #f7f5ee;
--brand-panel-muted: #d4dfd2;
--brand-panel-faint: #c4d1c0;
--brand-panel-line: #d6b980;
--status-pending-bg: #f5f2e4;
--status-pending-border: #e2dcc2;
--status-pending-text: #7a6a35;
--status-processing-bg: #eaf1f7;
--status-processing-border: #c9dcea;
--status-processing-text: #35566e;
--status-ready-bg: #eef2eb;
--status-ready-border: #cbd9c9;
--status-ready-text: #315c43;
--status-failed-bg: #fff0e7;
--status-failed-border: #ebc3a8;
--status-failed-text: #8b4324;
--status-read-bg: #e7f0e9;
--status-read-border: #b9d3bf;
--status-read-text: #2c5b3d;
--notice-bg: #fff0e7;
--notice-border: #ebc3a8;
--notice-text: #8b4324;
--highlight: #eef2eb;
--sense-bg: #fbf7ee;
--sense-border: #d6b980;
--sense-text: #6b5b3e;
--sense-muted: #8b794e;
--saved: #2f6b45;
--word-learning-bg: #f6e7c9;
--word-known-bg: #dee9d6;
--word-ignored-text: #949c94;
--word-selected-bg: #e3e9d9;
--reader-font-scale: 1;
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
color: #233d31;
background: #f7f5ee;
color: var(--text);
background: var(--bg);
font-synthesis: none;
--el-color-primary: #315c43;
--el-color-primary: var(--accent);
--el-color-primary-light-3: #597f66;
--el-color-primary-light-5: #8da493;
--el-color-primary-light-7: #bdccc0;
--el-color-primary-light-9: #eef2eb;
--el-color-primary-dark-2: #264a35;
--el-color-primary-light-9: var(--accent-soft);
--el-color-primary-dark-2: var(--text-strong);
--el-border-radius-base: 8px;
--el-font-size-base: 15px;
}
/* Dark palette. The Element Plus dark class is toggled by the display store, so components
follow the same choice as the page palette. */
html[data-theme='dark'] {
--bg: #171a17;
--surface: #1f231f;
--surface-raised: #1c201c;
--surface-input: #242824;
--text: #e7ece4;
--text-strong: #cfe0d3;
--text-muted: #9fae9c;
--text-faint: #8e9c8c;
--border: #333a33;
--border-strong: #3b433b;
--border-input: #3f483f;
--accent: #7db28f;
--accent-soft: #24312a;
--accent-marker: #d7a45f;
--accent-gold: #d7a45f;
--brand-panel: #10140f;
--brand-panel-text: #e7ece4;
--brand-panel-muted: #b9c7b6;
--brand-panel-faint: #97a695;
--brand-panel-line: #d7a45f;
--status-pending-bg: #33301f;
--status-pending-border: #4a442b;
--status-pending-text: #e0cf94;
--status-processing-bg: #1d2a33;
--status-processing-border: #2f4756;
--status-processing-text: #a8cbe0;
--status-ready-bg: #22301f;
--status-ready-border: #35492f;
--status-ready-text: #a9d0ac;
--status-failed-bg: #33231b;
--status-failed-border: #4d3527;
--status-failed-text: #e9ad86;
--status-read-bg: #1f3024;
--status-read-border: #33493a;
--status-read-text: #a5d3b1;
--notice-bg: #33231b;
--notice-border: #4d3527;
--notice-text: #e9ad86;
--highlight: #26332a;
--sense-bg: #23261d;
--sense-border: #4a4227;
--sense-text: #d9cba6;
--sense-muted: #c0ad7f;
--saved: #8ec69f;
--word-learning-bg: #3b3520;
--word-known-bg: #253120;
--word-ignored-text: #7d867d;
--word-selected-bg: #2b382d;
}
/* Element Plus reads its own dark variables; the shared palette keeps both in step. */
html.dark {
--el-bg-color: var(--surface);
--el-bg-color-overlay: var(--surface-raised);
--el-text-color-primary: var(--text);
--el-text-color-regular: var(--text);
--el-text-color-secondary: var(--text-muted);
--el-border-color: var(--border);
--el-border-color-light: var(--border);
--el-fill-color-blank: var(--surface-input);
--el-fill-color-light: var(--accent-soft);
color-scheme: dark;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input { font: inherit; }
button:focus-visible, a:focus-visible { outline: 3px solid #bc803d; outline-offset: 4px; }
a { color: inherit; }
.brand { font-family: Georgia, serif; font-size: 30px; font-weight: 700; letter-spacing: -1px; text-decoration: none; }
.brand-dot { color: #ba8345; }
.brand-dot { color: var(--accent-gold); }
.login-page { min-height: 100dvh; display: grid; grid-template-columns: minmax(340px, 1fr) minmax(400px, 1fr); }
.welcome { background: #284c38; color: #f7f5ee; padding: 46px 10%; display: flex; flex-direction: column; justify-content: space-between; }
.welcome .brand { color: #f7f5ee; }
.welcome { background: var(--brand-panel); color: var(--brand-panel-text); padding: 46px 10%; display: flex; flex-direction: column; justify-content: space-between; }
.welcome .brand { color: var(--brand-panel-text); }
.welcome-copy { padding: 80px 0; max-width: 450px; }
.welcome-copy h2 { font-family: Georgia, 'Microsoft YaHei', serif; font-weight: 400; font-size: clamp(32px, 3.3vw, 52px); line-height: 1.55; letter-spacing: 2px; margin: 26px 0; }
.welcome-copy p { font-size: 16px; color: #d4dfd2; line-height: 1.9; }
.book-mark { width: 60px; height: 60px; color: #d6b980; }
.welcome-foot { font-size: 13px; letter-spacing: 2px; color: #c4d1c0; }
.welcome-copy p { font-size: 16px; color: var(--brand-panel-muted); line-height: 1.9; }
.book-mark { width: 60px; height: 60px; color: var(--brand-panel-line); }
.welcome-foot { font-size: 13px; letter-spacing: 2px; color: var(--brand-panel-faint); }
.login-main { display: grid; place-items: center; padding: 48px 28px; }
.login-form { width: min(100%, 360px); }
.eyebrow { color: #687568; font-size: 12px; letter-spacing: 3px; }
.eyebrow { color: var(--text-faint); font-size: 12px; letter-spacing: 3px; }
h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
.subtle { color: #748073; font-size: 14px; line-height: 1.8; }
.subtle { color: var(--text-muted); font-size: 14px; line-height: 1.8; }
.login-form form { margin-top: 36px; }
.field { display: block; margin-bottom: 22px; }
.field label { display: block; font-size: 14px; margin-bottom: 10px; }
.el-input { --el-input-height: 46px; --el-input-bg-color: #fffefa; --el-input-border-color: #d6dccf; }
.el-input { --el-input-height: 46px; --el-input-bg-color: var(--surface-input); --el-input-border-color: var(--border-input); }
.el-button { min-height: 42px; }
.login-submit { width: 100%; margin-top: 8px; height: 48px; letter-spacing: 4px; }
.notice { padding: 12px 15px; background: #fff0e7; border: 1px solid #ebc3a8; color: #8b4324; border-radius: 6px; font-size: 14px; line-height: 1.6; }
.site-header { padding: 24px max(24px, calc((100vw - 1200px) / 2)); border-bottom: 1px solid #e0e3d8; display: flex; align-items: center; gap: 32px; background: #fffdf7; }
.notice { padding: 12px 15px; background: var(--notice-bg); border: 1px solid var(--notice-border); color: var(--notice-text); border-radius: 6px; font-size: 14px; line-height: 1.6; }
.site-header { padding: 24px max(24px, calc((100vw - 1200px) / 2)); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 32px; background: var(--surface-raised); }
.site-header nav { flex: 1; }
.active-nav { font-size: 15px; text-decoration: none; padding: 13px 0; border-bottom: 2px solid #315c43; }
.active-nav { font-size: 15px; text-decoration: none; padding: 13px 0; border-bottom: 2px solid var(--accent); }
.account { display: flex; align-items: center; gap: 18px; font-size: 14px; }
.account-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
.library { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
.library-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 38px; }
.language { border: 1px solid #d9decf; padding: 9px 17px; border-radius: 20px; font-size: 13px; background: #fffdf7; }
.empty-library { background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 44px 20px; text-align: center; }
.empty-library .book-mark { color: #6e8967; width: 56px; height: 56px; }
.language { border: 1px solid var(--border-strong); padding: 9px 17px; border-radius: 20px; font-size: 13px; background: var(--surface-raised); }
.empty-library { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 44px 20px; text-align: center; }
.empty-library .book-mark { color: var(--accent); width: 56px; height: 56px; }
.empty-library h2 { font-weight: 500; font-size: 21px; margin: 26px 0 4px; }
.loading { padding: 80px 24px; text-align: center; color: #748073; }
.loading { padding: 80px 24px; text-align: center; color: var(--text-muted); }
/* Import / book / reader pages share one shell. */
.page { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
.page-title { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-bottom: 28px; }
.page-title h1 { margin: 14px 0 6px; overflow-wrap: anywhere; }
.page-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.library-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.link-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 8px; color: #315c43; }
.breadcrumb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 4px; font-size: 14px; color: #748073; }
.breadcrumb a { color: #315c43; }
.link-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 8px; color: var(--accent); }
.breadcrumb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 4px; font-size: 14px; color: var(--text-muted); }
.breadcrumb a { color: var(--accent); }
/* Library book cards. */
.book-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); }
.book-card { display: flex; flex-direction: column; gap: 10px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 22px; }
.book-card { display: flex; flex-direction: column; gap: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; }
.book-card .subtle { margin: 0; }
.book-title { font-family: Georgia, 'Microsoft YaHei', serif; font-size: 21px; font-weight: 600; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
.book-title:hover { color: #315c43; text-decoration: underline; }
.status-summary { align-self: flex-start; margin: 0; padding: 6px 13px; border-radius: 20px; background: #eef2eb; color: #3d5b48; font-size: 13px; }
.book-title { font-family: Georgia, 'Microsoft YaHei', serif; font-size: 21px; font-weight: 600; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
.book-title:hover { color: var(--accent); text-decoration: underline; }
.status-summary { align-self: flex-start; margin: 0; padding: 6px 13px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); font-size: 13px; }
/* Chapter and job status, identical vocabulary for both. */
.status-chip { display: inline-flex; align-items: center; white-space: nowrap; padding: 5px 13px; border: 1px solid transparent; border-radius: 20px; font-size: 13px; }
.status-pending { background: #f5f2e4; border-color: #e2dcc2; color: #7a6a35; }
.status-processing { background: #eaf1f7; border-color: #c9dcea; color: #35566e; }
.status-ready { background: #eef2eb; border-color: #cbd9c9; color: #315c43; }
.status-failed { background: #fff0e7; border-color: #ebc3a8; color: #8b4324; }
.status-pending { background: var(--status-pending-bg); border-color: var(--status-pending-border); color: var(--status-pending-text); }
.status-processing { background: var(--status-processing-bg); border-color: var(--status-processing-border); color: var(--status-processing-text); }
.status-ready { background: var(--accent-soft); border-color: var(--status-ready-border); color: var(--accent); }
.status-failed { background: var(--notice-bg); border-color: var(--notice-border); color: var(--notice-text); }
.status-read { background: var(--status-read-bg); border-color: var(--status-read-border); color: var(--status-read-text); }
/* Completing a chapter: one explicit action, with the reading rule spelled out. */
.chapter-complete { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-top: 22px; padding: 18px 22px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
.chapter-complete .subtle { margin: 6px 0 0; }
.read-state { margin: 0; font-weight: 600; color: var(--status-read-text); }
/* Progress page. */
.progress-stats { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); margin-top: 6px; }
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px 22px; }
.stat-card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; color: var(--accent); letter-spacing: 0; }
.stat-card .subtle { margin: 6px 0 0; }
.stat-value { margin: 0; font-family: Georgia, 'Microsoft YaHei', serif; font-size: 30px; line-height: 1.2; color: var(--text); }
.progress-books { margin-top: 34px; }
.progress-books h2 { margin: 0 0 14px; font-size: 19px; }
.progress-book-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.progress-book-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
/* Import form. */
.import-form { max-width: 720px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 28px; }
.import-form { max-width: 720px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; }
.field-label { display: block; font-size: 14px; margin-bottom: 10px; }
.fixed-value { margin: 0; padding: 12px 15px; border: 1px solid #d6dccf; border-radius: 8px; background: #fffefa; font-size: 15px; }
.field-error { margin: 8px 0 0; color: #8b4324; font-size: 13px; line-height: 1.6; }
.counter { margin: 8px 0 0; color: #748073; font-size: 13px; }
.fixed-value { margin: 0; padding: 12px 15px; border: 1px solid var(--border-input); border-radius: 8px; background: var(--surface-input); font-size: 15px; }
.field-error { margin: 8px 0 0; color: var(--notice-text); font-size: 13px; line-height: 1.6; }
.counter { margin: 8px 0 0; color: var(--text-muted); font-size: 13px; }
.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 14px; }
.import-form .el-textarea__inner { min-height: 220px; line-height: 1.9; }
.book-select { width: 100%; }
/* Book chapters. */
.chapter-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.chapter-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 16px 18px; }
.chapter-ordinal { font-family: Georgia, serif; font-size: 17px; color: #8b9a8b; text-align: center; }
.chapter-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
.chapter-ordinal { font-family: Georgia, serif; font-size: 17px; color: var(--text-faint); text-align: center; }
.chapter-info { min-width: 0; }
.chapter-name { display: inline-block; font-size: 16px; font-weight: 500; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.chapter-meta { margin: 4px 0 0; color: #748073; font-size: 13px; overflow-wrap: anywhere; }
.chapter-name { display: inline-block; font-size: 16px; font-weight: 500; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
a.chapter-name:hover { color: var(--accent); text-decoration: underline; }
.chapter-meta { margin: 4px 0 0; color: var(--text-muted); font-size: 13px; overflow-wrap: anywhere; }
/* Reader keeps the pasted text exactly as it was, including line breaks and tabs. */
.reader-page { max-width: 820px; }
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: 17px; line-height: 2; }
.processing-hint { padding: 36px 0; color: #748073; }
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid #e0e3d8; }
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: calc(17px * var(--reader-font-scale)); line-height: 2; }
.processing-hint { padding: 36px 0; color: var(--text-muted); }
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid var(--border); }
.reader-page.has-lookup { max-width: 1120px; }
.reader-workspace { display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; align-items: start; }
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr) 320px; }
.reader-body { min-width: 0; }
.reader-word { cursor: pointer; border-radius: 3px; }
/* Saved words keep one style per status everywhere they appear. */
.reader-word.is-new { border-bottom: 2px dotted #bc803d; }
.reader-word.is-learning { background: #f6e7c9; }
.reader-word.is-known { background: #dee9d6; }
.reader-word.is-ignored { color: #949c94; }
.reader-word:hover, .reader-word.is-selected { background: #e3e9d9; color: #264a35; }
.reader-word:focus-visible { outline: 2px solid #bc803d; outline-offset: 2px; background: #eef2eb; }
.tokens-notice { color: #748073; font-size: 14px; margin-top: 24px; }
.lookup-panel { position: sticky; top: 24px; margin-top: 26px; padding: 22px; border: 1px solid #d9decf; border-radius: 12px; background: #fffdf8; max-height: calc(100dvh - 48px); overflow-y: auto; overflow-wrap: anywhere; }
.lookup-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; border-bottom: 1px solid #e0e3d8; padding-bottom: 12px; }
.reader-word.is-new { border-bottom: 2px dotted var(--accent-marker); }
.reader-word.is-learning { background: var(--word-learning-bg); }
.reader-word.is-known { background: var(--word-known-bg); }
.reader-word.is-ignored { color: var(--word-ignored-text); }
.reader-word:hover, .reader-word.is-selected { background: var(--word-selected-bg); color: var(--text-strong); }
.reader-word:focus-visible { outline: 2px solid var(--accent-marker); outline-offset: 2px; background: var(--accent-soft); }
.tokens-notice { color: var(--text-muted); font-size: 14px; margin-top: 24px; }
.lookup-panel { position: sticky; top: 24px; margin-top: 26px; padding: 22px; border: 1px solid var(--border-strong); border-radius: 12px; background: var(--surface); max-height: calc(100dvh - 48px); overflow-y: auto; overflow-wrap: anywhere; }
.lookup-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
.lookup-heading h2 { margin: 6px 0; font-family: Georgia, serif; font-size: 25px; }
.lookup-content { font-size: 15px; line-height: 1.7; }
.lookup-message { color: #8b4324; }
.lookup-content { font-size: calc(15px * var(--reader-font-scale)); line-height: 1.7; }
.lookup-message { color: var(--notice-text); }
.lookup-senses { padding-left: 22px; }
.lookup-senses li { padding-left: 3px; margin-bottom: 20px; }
.lookup-senses p { margin: 8px 0; }
.sense-heading span { color: #748073; font-size: 13px; }
.lookup-senses blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
.lookup-term { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; }
.sense-heading span { color: var(--text-muted); font-size: 13px; }
.lookup-senses blockquote { border-left: 2px solid var(--status-ready-border); margin: 10px 0; padding-left: 12px; color: var(--text-faint); font-style: italic; }
.lookup-range { border-bottom: 1px solid var(--border); padding-bottom: 14px; }
.lookup-range p { margin: 0 0 10px; }
.lookup-range-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* A saved phrase is one unit: the run is underlined and keeps its status colour. */
.reader-word.is-phrase { text-decoration: underline; text-decoration-style: double; text-underline-offset: 2px; border-radius: 3px; }
.reader-word.is-phrase-selected { outline: 2px solid var(--accent-marker); outline-offset: 1px; }
.lookup-term { border-top: 1px solid var(--border); padding-top: 18px; margin-top: 20px; }
.lookup-term label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
.lookup-term-title { display: flex; font-size: 14px; margin: 0; }
.lookup-term label span { color: #8b794e; font-size: 12px; }
.lookup-term label span { color: var(--sense-muted); font-size: 12px; }
.lookup-term .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
.lookup-term .el-textarea { margin-top: 8px; }
.lookup-term .el-textarea textarea { font: inherit; line-height: 1.6; }
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
.lookup-saved { color: var(--saved); font-size: 14px; margin: 12px 0 0; }
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.review-page { max-width: 680px; }
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.vocab-page { max-width: 760px; }
.vocab-search { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 22px; }
.vocab-search label { font-size: 14px; }
.vocab-search .el-input { max-width: 260px; }
.vocab-filter { width: 140px; }
.vocab-list { list-style: none; padding: 0; margin: 18px 0 0; }
.vocab-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
.vocab-info { min-width: 0; flex: 1; }
.vocab-term { margin: 0; font-family: Georgia, serif; font-size: 18px; overflow-wrap: anywhere; }
.vocab-paging { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; }
.term-fields label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
.term-fields label span { color: var(--sense-muted); font-size: 12px; }
.term-fields .el-textarea { margin-top: 8px; }
.term-fields .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
.term-level { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; font-size: 14px; }
.term-level-select { width: 120px; }
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--sense-bg); color: var(--sense-text); }
.chapter-list .chapter-row { flex-wrap: wrap; }
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; }
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--sense-bg); color: var(--sense-text); }
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface); }
.review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; }
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: 34px; }
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: calc(34px * var(--reader-font-scale)); }
.review-example { margin: 12px 0; font-family: Georgia, serif; font-size: 19px; line-height: 1.8; }
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid #e0e3d8; }
.review-definition { margin: 0 0 12px; font-size: 18px; }
.review-answer blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid var(--border); }
.review-definition { margin: 0 0 12px; font-size: calc(18px * var(--reader-font-scale)); }
.review-answer blockquote { border-left: 2px solid var(--status-ready-border); margin: 10px 0; padding-left: 12px; color: var(--text-faint); font-style: italic; }
.review-grades { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
.review-actions { margin: 16px 0 0; }
@media (max-width: 760px) {
@@ -160,10 +318,30 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.import-form { padding: 20px; }
.chapter-row { grid-template-columns: 30px minmax(0, 1fr); align-items: start; row-gap: 10px; padding: 15px 16px; }
.chapter-row .status-chip, .chapter-row .el-button { grid-column: 2; justify-self: start; }
.reader-text { font-size: 16px; line-height: 1.95; }
.chapter-complete { flex-direction: column; align-items: stretch; }
.chapter-complete .el-button { width: 100%; }
.reader-text { font-size: calc(16px * var(--reader-font-scale)); line-height: 1.95; }
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr); gap: 20px; }
.reader-page.has-lookup { padding-bottom: calc(45dvh + 28px); }
.lookup-panel { position: fixed; inset: auto 0 0; z-index: 20; max-height: 45dvh; margin-top: 0; padding: 16px 20px max(20px, env(safe-area-inset-bottom)); border-radius: 16px 16px 0 0; box-shadow: 0 -5px 24px #233d3114; }
.lookup-heading { position: sticky; top: -16px; z-index: 1; background: #fffdf8; }
.lookup-panel { position: fixed; inset: auto 0 0; z-index: 20; max-height: 45dvh; margin-top: 0; padding: 16px 20px max(20px, env(safe-area-inset-bottom)); border-radius: 16px 16px 0 0; box-shadow: 0 -5px 24px var(--text)14; }
.lookup-heading { position: sticky; top: -16px; z-index: 1; background: var(--surface); }
.reader-nav .el-button { flex: 1; }
}
/* One visible focus ring for every interactive element, including Element Plus controls. */
button:focus-visible, a:focus-visible, [role='button']:focus-visible,
.el-button:focus-visible, .el-input__inner:focus-visible, .el-select__wrapper:focus-visible {
outline: 3px solid var(--accent-marker);
outline-offset: 3px;
}
/* Header display control: theme and reader font size. */
.display-trigger { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 20px; background: var(--surface-raised); color: var(--text); font-size: 13px; cursor: pointer; }
.display-trigger:hover { border-color: var(--accent); }
/* Element Plus styles arrive after this file for lazy-loaded views, so the ring is stated
with enough weight to win the tie while a control from its own library is focused. */
.display-trigger:focus-visible, .display-trigger.el-tooltip__trigger:focus-visible { outline: 3px solid var(--accent-marker); outline-offset: 3px; }
.display-current { color: var(--text-muted); }
.el-dropdown-menu__item.is-current { color: var(--accent); font-weight: 600; }
/* Discoverable keyboard operations on the review page. */
.shortcut-hint { margin: 22px 0 0; color: var(--text-muted); font-size: 13px; }
+4 -1
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
import { canRetry, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
const session = useSessionStore()
const library = useLibraryStore()
@@ -144,8 +145,9 @@ onUnmounted(() => library.closeBook())
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
@@ -182,6 +184,7 @@ onUnmounted(() => library.closeBook())
<template v-if="item.status === 'failed' && item.errorMessage"> · {{ item.errorMessage }}</template>
</p>
</div>
<span v-if="item.status === 'ready' && item.readAt" class="status-chip status-read" data-testid="chapter-read-chip">已读</span>
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item.status) }}</span>
<ElButton
v-if="canRetry(item)"
+3 -1
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { LANGUAGE_LABEL, TEXT_MAX_CODE_POINTS, fileProblem, fileSizeLabel, textProblem, titleProblem, useLibraryStore, type SubmitTarget } from '../stores/library'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
const session = useSessionStore()
const library = useLibraryStore()
@@ -127,8 +128,9 @@ onUnmounted(() => {
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
</div>
</header>
+3 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import DisplaySettings from '../components/DisplaySettings.vue'
import BookMark from '../components/BookMark.vue'
import { statusSummary, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
@@ -38,8 +39,9 @@ onMounted(load)
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { useProgressStore } from '../stores/progress'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
const session = useSessionStore()
const progress = useProgressStore()
const router = useRouter()
onMounted(() => { void progress.load() })
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page progress-page">
<div class="page-title">
<div>
<h1>我的进度</h1>
<p class="subtle">只统计你自己的记录,按当前学习语言(英语)汇总。</p>
</div>
<div class="page-actions">
<RouterLink to="/review" class="link-button">开始到期复习</RouterLink>
<ElButton :loading="progress.loading" data-testid="progress-reload" @click="progress.load()">刷新</ElButton>
</div>
</div>
<p v-if="progress.loading && !progress.loaded" role="status" class="loading">正在加载…</p>
<div v-else-if="progress.error && !progress.loaded" class="notice">
<p role="alert">{{ progress.error }}</p>
<ElButton data-testid="progress-retry" @click="progress.load()">重试</ElButton>
</div>
<template v-else>
<p v-if="progress.error" role="alert" class="notice">{{ progress.error }}</p>
<section class="progress-stats" aria-label="学习统计">
<article class="stat-card" data-testid="stat-read">
<h2>已读章节</h2>
<p class="stat-value">{{ progress.data.readChapters }} / {{ progress.data.totalChapters }}</p>
<p class="subtle">章(可阅读)</p>
</article>
<article class="stat-card" data-testid="stat-due">
<h2>待复习</h2>
<p class="stat-value">{{ progress.data.dueNow }}</p>
<p class="subtle">个词条现在到期</p>
</article>
<article class="stat-card" data-testid="stat-known">
<h2>已知词</h2>
<p class="stat-value">{{ progress.data.knownTerms }}</p>
<p class="subtle">标记为已认识</p>
</article>
<article class="stat-card" data-testid="stat-learning">
<h2>学习中</h2>
<p class="stat-value">{{ progress.data.learningTerms }}</p>
<p class="subtle">按间隔复习</p>
</article>
<article class="stat-card" data-testid="stat-new">
<h2>新词</h2>
<p class="stat-value">{{ progress.data.newTerms }}</p>
<p class="subtle">刚保存,还没作答</p>
</article>
<article class="stat-card" data-testid="stat-ignored">
<h2>忽略</h2>
<p class="stat-value">{{ progress.data.ignoredTerms }}</p>
<p class="subtle">单独统计,不计入已知</p>
</article>
</section>
<section class="progress-books" aria-label="每本书的阅读进度">
<h2>每本书的阅读</h2>
<ul v-if="progress.hasBooks" class="progress-book-list" data-testid="progress-books">
<li v-for="item in progress.data.books" :key="item.id" class="progress-book-row">
<RouterLink :to="`/books/${item.id}`" class="chapter-name">{{ item.title }}</RouterLink>
<span class="subtle">{{ item.readChapters }} / {{ item.totalChapters }} 章已读</span>
</li>
</ul>
<p v-else class="subtle" data-testid="progress-no-books">还没有书。导入或粘贴一段英语,读完后在这里看到进度。</p>
<p class="subtle" data-testid="progress-saved">已保存词条 {{ progress.data.savedTerms }} 个(含忽略)。</p>
</section>
</template>
</main>
</div>
</template>
+204 -9
View File
@@ -4,8 +4,12 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { useReaderLookup } from '../composables/useReaderLookup'
import { useReaderLookup, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, wordIndices } from '../composables/readerRange'
import { currentRatio, readPosition, writePosition } from '../composables/readingPosition'
import { useTextSelection } from '../composables/useTextSelection'
import ReaderTokens from '../components/ReaderTokens.vue'
import DisplaySettings from '../components/DisplaySettings.vue'
import LookupPanel from '../components/LookupPanel.vue'
const session = useSessionStore()
@@ -13,6 +17,8 @@ const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const rangeNotice = ref('')
const readerBody = ref<HTMLElement | null>(null)
const chapterId = computed(() => Number(route.params.id))
const chapter = computed(() => library.chapter)
@@ -20,10 +26,120 @@ const lookup = useReaderLookup(chapter)
// Retry uses the job id the chapter carries, no matter where it was loaded from.
const retryable = computed(() => chapter.value !== null && canRetry(chapter.value))
/** A dragged selection becomes a phrase; a single word stays a plain word lookup. */
function pickRange(anchor: number, focus: number, element: HTMLElement | null): void {
const text = library.readerText
const count = wordIndices(lookup.tokens.value.slice(anchor, focus + 1)).length
if (count < 2) return
if (count > MAX_PHRASE_WORDS) {
rangeNotice.value = `短语最多 ${MAX_PHRASE_WORDS} 个单词,请缩短选择范围。`
return
}
const range = normalizeTokenRange(lookup.tokens.value, text, anchor, focus)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element)
}
const selection = useTextSelection({ container: readerBody, onSelect: (anchor, focus) => pickRange(anchor, focus, readerBody.value) })
/** Shift-clicking a word extends the phrase from the word that was picked first. */
function selectToken(token: ReaderToken, element: HTMLElement, extend: boolean): void {
rangeNotice.value = ''
const anchor = lookup.selected.value
if (extend && anchor) {
const from = Number(element.dataset.tokenIndex)
const first = lookup.tokens.value.findIndex(item => item.start === anchor.start)
if (from >= 0 && first >= 0) {
pickRange(Math.min(from, first), Math.max(from, first), element)
return
}
}
lookup.select(token, element)
}
/** Clicking inside a saved phrase edits the phrase instead of the word under the cursor. */
function selectPhrase(span: PhraseSpan, element: HTMLElement): void {
const range = rangeOfSpan(lookup.tokens.value, library.readerText, span)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element, span.id)
}
const adjustOptions = computed(() => {
const current = lookup.range.value
if (!current) return null
const text = library.readerText
return {
startLeft: adjustTokenRange(lookup.tokens.value, text, current, 'start', -1) !== null,
startRight: adjustTokenRange(lookup.tokens.value, text, current, 'start', 1) !== null,
endLeft: adjustTokenRange(lookup.tokens.value, text, current, 'end', -1) !== null,
endRight: adjustTokenRange(lookup.tokens.value, text, current, 'end', 1) !== null,
}
})
const panelWord = computed(() => lookup.range.value?.text ?? lookup.selected.value?.text ?? '')
function closePanel(): void {
lookup.close()
selection.clear()
}
async function load() {
lookup.reset()
retryError.value = ''
rangeNotice.value = ''
await library.loadChapter(chapterId.value)
restorePosition()
}
// Reading position: remembered per account and chapter, and only while the text is the version
// the position was taken from. The restore waits for the chapter to be on screen and for the
// browser to lay the paragraph out, otherwise the saved ratio would land in the wrong place.
const positionNotice = ref('')
let saveTimer: number | undefined
function storage(): Storage | null {
try { return window.localStorage } catch { return null }
}
function restorePosition(): void {
positionNotice.value = ''
const store = storage()
const sha = chapter.value?.contentSha256 ?? ''
const current = chapter.value
if (!store || !current || current.status !== 'ready' || !sha) return
const saved = readPosition(store, session.user?.id ?? null, current.id, sha)
if (!saved) return
// The chapter may still be laying out when the response arrives, so the restore waits for a
// scrollable page instead of trusting one timer; it gives up quietly after a short while.
let attempts = 0
const apply = () => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight
if (scrollable <= 0) {
attempts += 1
if (attempts <= 20) window.setTimeout(apply, 50)
return
}
window.scrollTo({ top: Math.round(saved.ratio * scrollable) })
positionNotice.value = '已回到上次阅读位置。'
}
window.setTimeout(apply, 40)
}
function savePosition(): void {
const store = storage()
const current = chapter.value
if (!store || !current || current.status !== 'ready') return
const ratio = currentRatio(window, document.documentElement.scrollHeight)
if (ratio <= 0) return
writePosition(store, session.user?.id ?? null, current.id, current.contentSha256, ratio)
}
function onScroll(): void {
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
// Debounced: scrolling must stay smooth and only the resting position is worth storing.
saveTimer = window.setTimeout(() => { saveTimer = undefined; savePosition() }, 400)
}
async function retry() {
@@ -33,6 +149,29 @@ async function retry() {
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
}
// Completing records only that this chapter was read. Word statuses and levels are untouched.
const completeNotice = ref('')
const readAt = computed(() => chapter.value?.readAt ?? null)
const readLabel = computed(() => (readAt.value ? `本章已读 · ${formatReadAt(readAt.value)}` : ''))
function formatReadAt(value: string): string {
const at = new Date(value)
if (Number.isNaN(at.getTime())) return '已记录'
const pad = (part: number) => String(part).padStart(2, '0')
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`
}
async function complete() {
if (chapter.value === null) return
completeNotice.value = ''
try {
const result = await library.markChapterRead(chapter.value.id)
completeNotice.value = result.duplicate ? '本章已经标记过已读,没有重复计数。' : '已记为读完本章。'
} catch (reason) {
completeNotice.value = reason instanceof Error ? reason.message : '标记失败,请稍后重试。'
}
}
// Switching chapters reuses this component; only the route param changes.
function goTo(id: number | null) {
if (id === null) return
@@ -45,23 +184,33 @@ async function logout() {
finally { await router.replace('/login') }
}
onMounted(load)
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
void load()
})
watch(chapterId, () => { void load() })
// Leaving the page releases the chapter so polling stops.
onUnmounted(() => library.closeChapter())
// Leaving the page releases the chapter so polling stops and stores the last position.
// There is no separate unmount hook: this one already handles the page going away.
onUnmounted(() => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
savePosition()
window.removeEventListener('scroll', onScroll)
library.closeChapter()
})
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value }" @keydown.esc="lookup.close()">
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value || lookup.range.value }" @keydown.esc="closePanel">
<p v-if="library.chapterLoading && !chapter" role="status" class="loading">正在加载…</p>
<div v-else-if="library.chapterError" class="notice">
<p role="alert">{{ library.chapterError }}</p>
@@ -85,20 +234,66 @@ onUnmounted(() => library.closeChapter())
</div>
<p v-else-if="chapter.status !== 'ready'" role="status" class="processing-hint">这一章还在{{ statusLabel(chapter.status) }},页面会自动刷新。</p>
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
<p v-if="rangeNotice" role="alert" class="notice" data-testid="range-notice">{{ rangeNotice }}</p>
<div v-if="chapter.status === 'ready'" class="reader-workspace">
<div class="reader-body">
<ReaderTokens :tokens="lookup.tokens.value" :original="library.readerText" :selected-start="lookup.selected.value?.start" @select="lookup.select" />
<div class="reader-body" ref="readerBody">
<ReaderTokens
:tokens="lookup.tokens.value"
:phrases="lookup.phrases.value"
:original="library.readerText"
:selected-start="lookup.selected.value?.start"
:phrase-start="lookup.rangeTermId.value"
@select="selectToken"
@select-phrase="selectPhrase"
/>
<div v-if="lookup.tokensError.value" class="tokens-notice">
<p role="status">{{ lookup.tokensError.value }}</p>
<ElButton data-testid="tokens-retry" :loading="lookup.tokensLoading.value" @click="lookup.loadTokens">重试加载单词</ElButton>
</div>
</div>
<LookupPanel v-if="lookup.selected.value" :word="lookup.selected.value.text" :result="lookup.result.value" :loading="lookup.loading.value" :error="lookup.error.value" :saving="lookup.saving.value" :save-error="lookup.saveError.value" :saved="lookup.saved.value" :saved-term-id="lookup.savedTermId.value" :prefilling="lookup.prefilling.value" :prefill-error="lookup.prefillError.value" :can-save="lookup.canSave.value" v-model:definition="lookup.definition.value" v-model:examples="lookup.examples.value" v-model:status="lookup.status.value" @close="lookup.close()" @retry="lookup.lookup" @save="lookup.save" @resize="lookup.keepSelectionVisible" />
<LookupPanel
v-if="lookup.selected.value || lookup.range.value"
:word="panelWord"
:phrase="lookup.range.value ? { wordCount: lookup.range.value.wordCount, stored: lookup.rangeStored.value } : null"
:adjust="adjustOptions"
:result="lookup.result.value"
:loading="lookup.loading.value"
:error="lookup.error.value"
:saving="lookup.saving.value"
:save-error="lookup.saveError.value"
:saved="lookup.saved.value"
:saved-term-id="lookup.savedTermId.value"
:prefilling="lookup.prefilling.value"
:prefill-error="lookup.prefillError.value"
:can-save="lookup.canSave.value"
v-model:definition="lookup.definition.value"
v-model:examples="lookup.examples.value"
v-model:status="lookup.status.value"
@close="closePanel"
@retry="lookup.lookup"
@save="lookup.save"
@adjust="(edge, direction) => { lookup.adjustRange(edge, direction); selection.clear() }"
@resize="lookup.keepSelectionVisible"
/>
</div>
<nav class="reader-nav" aria-label="章节切换">
<ElButton :disabled="library.navigation.previousChapterId === null" @click="goTo(library.navigation.previousChapterId)">上一章</ElButton>
<ElButton :disabled="library.navigation.nextChapterId === null" @click="goTo(library.navigation.nextChapterId)">下一章</ElButton>
</nav>
<section v-if="chapter.status === 'ready'" class="chapter-complete" aria-label="完成本章">
<div>
<p v-if="readAt" class="read-state" data-testid="chapter-read-state">{{ readLabel }}</p>
<p v-else class="subtle">读完后标记已读;只记录已读,不改变词语状态。</p>
<p v-if="positionNotice" role="status" class="subtle" data-testid="position-notice">{{ positionNotice }}</p>
<p v-if="completeNotice" role="status" class="subtle" data-testid="complete-notice">{{ completeNotice }}</p>
</div>
<ElButton
type="primary"
:loading="library.completingChapterId === chapter.id"
data-testid="mark-read"
@click="complete"
>{{ readAt ? '再次标记已读' : '标记本章已读' }}</ElButton>
</section>
</template>
</main>
</div>
+17 -1
View File
@@ -3,6 +3,8 @@ import { onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import ReviewCard from '../components/ReviewCard.vue'
import DisplaySettings from '../components/DisplaySettings.vue'
import { useReviewShortcuts, type ReviewShortcut } from '../composables/reviewShortcuts'
import { useReviewStore, type ReviewItem } from '../stores/review'
import { useSessionStore } from '../stores/session'
@@ -16,6 +18,18 @@ let lastGrade: 'correct' | 'wrong' | 'again' = 'correct'
function grade(value: 'correct' | 'wrong' | 'again') { lastGrade = value; void review.answer(value) }
function retry() { void review.answer(lastGrade) }
// Space or Enter shows the answer, then 1, 2 and 3 grade it. The shortcut never fires while the
// learner is typing, and a focused button keeps its own native key handling.
function onShortcut(action: ReviewShortcut): void {
if (action === 'reveal') { review.reveal(); return }
grade(action)
}
useReviewShortcuts({
onShortcut,
enabled: () => review.current !== null && !review.busy && review.error === '',
revealed: () => review.revealed,
})
onMounted(() => { void review.load() })
async function logout() {
try { await session.logout() }
@@ -30,8 +44,9 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review" class="active-nav">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
@@ -73,6 +88,7 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
@end="endReview"
@retry="retry"
/>
<p class="shortcut-hint" data-testid="review-shortcuts">键盘:空格 显示答案 · 1 答对 · 2 答错 · 3 再学一次</p>
</main>
</div>
</template>
+209
View File
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElDialog, ElInput, ElOption, ElSelect } from 'element-plus'
import TermFormFields from '../components/TermFormFields.vue'
import DisplaySettings from '../components/DisplaySettings.vue'
import { TERM_STATUSES, type TermStatus } from '../composables/useReaderLookup'
import { textProblem, titleProblem } from '../stores/library'
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const vocabulary = useVocabularyStore()
const route = useRoute()
const router = useRouter()
const search = ref('')
const editing = ref<VocabularyTerm | null>(null)
const definition = ref('')
const examples = ref('')
const status = ref<TermStatus>('new')
const level = ref(1)
const formError = ref('')
const notice = ref('')
const queryString = computed(() => ({ query: search.value.trim(), status: vocabulary.status, kind: vocabulary.kind, page: vocabulary.page }))
const levelLabel = (value: number) => (value === 0 ? '' : ` · 等级 ${value}`)
const rowLabel = (item: VocabularyTerm) => `${item.originalForm} / ${item.definition || '(未填写释义)'} · ${statusLabel(item)}`
const statusLabel = (item: VocabularyTerm) => {
const label = TERM_STATUSES.find(entry => entry.value === item.status)?.label ?? item.status
return item.kind === 'phrase' ? `短语 · ${label}${levelLabel(item.level)}` : `${label}${levelLabel(item.level)}`
}
function readFilters(): void {
const read = (value: unknown) => (Array.isArray(value) ? value[0] : value)
const query = read(route.query.query)
const statusValue = read(route.query.status)
const kindValue = read(route.query.kind)
const page = Number(read(route.query.page) ?? 1)
search.value = typeof query === 'string' ? query : ''
vocabulary.query = search.value
vocabulary.status = TERM_STATUSES.some(entry => entry.value === statusValue) ? (statusValue as TermStatus) : ''
vocabulary.kind = kindValue === 'word' || kindValue === 'phrase' ? kindValue : ''
vocabulary.page = Number.isSafeInteger(page) && page >= 1 ? page : 1
}
async function load(): Promise<void> {
await vocabulary.load()
}
async function submitSearch(): Promise<void> {
vocabulary.query = search.value
await vocabulary.apply()
}
async function clearFilters(): Promise<void> {
search.value = ''
await vocabulary.clearFilters()
}
function openEditor(item: VocabularyTerm): void {
editing.value = item
definition.value = item.definition
examples.value = item.examples.join('\n')
status.value = item.status
level.value = item.level > 0 ? item.level : 1
formError.value = ''
notice.value = ''
vocabulary.saveError = ''
}
function closeEditor(): void {
editing.value = null
formError.value = ''
}
async function save(): Promise<void> {
const item = editing.value
if (!item || vocabulary.saving) return
formError.value = textProblem(definition.value) && '' // an empty definition is allowed
if (formError.value) return
try {
const examples2 = examples.value.split('\n').map(line => line.trim()).filter(Boolean)
const updated = await vocabulary.updateTerm(item.id, {
definition: definition.value,
examples: examples2,
status: status.value,
level: status.value === 'learning' ? level.value : undefined,
})
editing.value = null
notice.value = `已保存 · ${statusLabel(updated)}`
} catch {
// vocabulary.saveError already carries the server message for the dialog.
}
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
onMounted(async () => {
readFilters()
await load()
})
// The URL mirrors the filters so a reload or a return keeps what the learner searched for.
watch(() => [search.value, vocabulary.status, vocabulary.kind, vocabulary.page], () => {
void router.replace({ path: '/vocab', query: Object.fromEntries(Object.entries(queryString.value).filter(([, value]) => value !== '' && value !== undefined).map(([key, value]) => [key, String(value)])) })
})
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab" class="active-nav">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page vocab-page">
<div class="page-title">
<div>
<h1>生词本</h1>
<p class="subtle">保存的词语与短语,共 {{ vocabulary.total }} 条。</p>
</div>
<div class="page-actions">
<RouterLink to="/review" class="link-button">开始到期复习</RouterLink>
</div>
</div>
<form class="vocab-search" novalidate @submit.prevent="submitSearch">
<label for="vocab-query">搜索词 / 释义</label>
<ElInput id="vocab-query" v-model="search" type="text" placeholder="输入词语或释义中的一部分" :disabled="vocabulary.loading" />
<ElButton type="primary" native-type="submit" :loading="vocabulary.loading" data-testid="vocab-search">搜索</ElButton>
<label for="vocab-status">状态</label>
<ElSelect id="vocab-status" v-model="vocabulary.status" class="vocab-filter" data-testid="vocab-status" @change="vocabulary.apply()">
<ElOption label="全部状态" value="" />
<ElOption v-for="item in TERM_STATUSES" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
<label for="vocab-kind">类型</label>
<ElSelect id="vocab-kind" v-model="vocabulary.kind" class="vocab-filter" data-testid="vocab-kind" @change="vocabulary.apply()">
<ElOption label="全部类型" value="" />
<ElOption label="词语" value="word" />
<ElOption label="短语" value="phrase" />
</ElSelect>
</form>
<p v-if="notice" role="status" class="chapter-notice" data-testid="vocab-notice">{{ notice }}</p>
<p v-if="vocabulary.loading" role="status" class="loading">正在加载…</p>
<div v-else-if="vocabulary.error" class="notice">
<p role="alert">{{ vocabulary.error }}</p>
<ElButton @click="load">重试</ElButton>
</div>
<template v-else-if="vocabulary.items.length">
<ul class="vocab-list" aria-label="词条列表">
<li v-for="item in vocabulary.items" :key="item.id" class="vocab-row">
<div class="vocab-info">
<p class="vocab-term">{{ item.originalForm }} <span class="subtle">/</span> {{ item.definition || '(未填写释义)' }}</p>
<p class="chapter-meta">
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item) }}</span>
<template v-if="item.examples.length"> · {{ item.examples[0] }}</template>
</p>
</div>
<ElButton size="small" :data-testid="`vocab-edit-${item.id}`" @click="openEditor(item)">编辑</ElButton>
</li>
</ul>
<nav v-if="vocabulary.totalPages > 1" class="vocab-paging" aria-label="分页">
<ElButton :disabled="vocabulary.page <= 1" data-testid="vocab-prev" @click="vocabulary.goToPage(vocabulary.page - 1)">上一页</ElButton>
<span class="subtle">第 {{ vocabulary.page }} / {{ vocabulary.totalPages }} 页</span>
<ElButton :disabled="vocabulary.page >= vocabulary.totalPages" data-testid="vocab-next" @click="vocabulary.goToPage(vocabulary.page + 1)">下一页</ElButton>
</nav>
</template>
<section v-else-if="vocabulary.hasFilters" class="empty-library" aria-label="搜索无结果" data-testid="vocab-none">
<h2>没有匹配的词条</h2>
<p class="subtle">试试其他关键词,或清除筛选。</p>
<ElButton data-testid="vocab-clear" @click="clearFilters">清除搜索与筛选</ElButton>
</section>
<section v-else class="empty-library" aria-label="空生词本" data-testid="vocab-empty">
<h2>生词本还是空的</h2>
<p class="subtle">阅读时点词或选择短语,保存后会出现在这里。</p>
<RouterLink to="/" class="link-button">返回书库</RouterLink>
</section>
<ElDialog :model-value="editing !== null" title="编辑词条" width="620" data-testid="vocab-dialog" @update:model-value="closeEditor">
<template v-if="editing">
<p class="subtle">{{ editing.originalForm }}{{ editing.kind === 'phrase' ? ` · 短语 · ${editing.wordCount} 个单词` : ' · 词语' }}</p>
<TermFormFields
v-model:definition="definition"
v-model:examples="examples"
v-model:status="status"
v-model:level="level"
:show-level="true"
:disabled="vocabulary.saving"
/>
<p v-if="formError" role="alert" class="field-error">{{ formError }}</p>
<p v-if="vocabulary.saveError" role="alert" class="field-error">{{ vocabulary.saveError }}</p>
</template>
<template #footer>
<ElButton data-testid="vocab-cancel" @click="closeEditor">取消</ElButton>
<ElButton type="primary" :loading="vocabulary.saving" data-testid="vocab-save" @click="save">保存修改</ElButton>
</template>
</ElDialog>
</main>
</div>
</template>
+23 -5
View File
@@ -81,6 +81,9 @@ func Migrate(db *gorm.DB) error {
if current < 6 {
statements = append(statements, schemaV6Statements...)
}
if current < 7 {
statements = append(statements, schemaV7Statements...)
}
for i, s := range statements {
if _, err = conn.ExecContext(ctx, s); err != nil {
var sqlErr *driver.MySQLError
@@ -96,12 +99,27 @@ func Migrate(db *gorm.DB) error {
// SchemaVersion is the version an explicit migration leaves behind, and the
// version the server requires before it starts.
const SchemaVersion = 6
const SchemaVersion = 7
// v7 records one learner's own reading completion per chapter. A chapter belongs to exactly
// one owner, so the chapter id alone identifies the row and repeating the completion cannot
// add a second one. read_sha256 snapshots the chapter content the person actually read: when
// the text is replaced by a new version the mark stops matching and the chapter counts as
// unread again, without deleting the row or touching any word state.
var schemaV7Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_chapter_progress (
chapter_id BIGINT UNSIGNED PRIMARY KEY,
owner_id BIGINT NOT NULL, book_id BIGINT UNSIGNED NOT NULL,
language VARCHAR(16) NOT NULL DEFAULT 'en',
read_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
read_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
INDEX idx_chapter_progress_owner (owner_id, language, read_at, chapter_id),
FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE,
FOREIGN KEY (book_id) REFERENCES lexgo_books(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
// v6 adds review scheduling as its own table instead of altering lexgo_terms: every
// statement stays additive and therefore retry-safe after a partial migration, and a
// binary restored to v5 keeps writing personal terms unchanged. Existing saved words
// enter the queue immediately, because a saved word has never been reviewed.
var schemaV6Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_term_reviews (
term_id BIGINT UNSIGNED PRIMARY KEY,
+10 -5
View File
@@ -58,8 +58,9 @@ type DictionaryImportResult struct {
Duplicate bool `json:"duplicate"`
}
type ChapterTokens struct {
TextSHA256 string `json:"textSha256"`
Tokens []TextToken `json:"tokens"`
TextSHA256 string `json:"textSha256"`
Tokens []TextToken `json:"tokens"`
Phrases []PhraseSpan `json:"phrases"`
}
// Each router keeps at most one immutable parsed corpus; no private chapter or
@@ -277,12 +278,16 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
if err != nil {
return nil, err
}
// The learner's own saved words are part of the chapter view, so the reader
// shows the same status here as everywhere else.
// The learner's own saved words and phrases are part of the chapter view, so the
// reader shows the same status here as everywhere else.
if err = attachTerms(tx, u.UserId, language, tokens); err != nil {
return nil, err
}
return ChapterTokens{chapter.ContentSHA256, tokens}, nil
phrases, err := phrasesForChapter(tx, u.UserId, language, tokens)
if err != nil {
return nil, err
}
return ChapterTokens{chapter.ContentSHA256, tokens, phrases}, nil
}))
v.POST("/lookup", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input struct {
+9 -1
View File
@@ -166,7 +166,15 @@ func UpdateChapter(tx *gorm.DB, owner int, chapterID int64, input ChapterUpdateI
if job != nil {
jobID = job.ID
}
result.Chapter = chapterSummaryWithJob(chapter, &jobID)
summary := chapterSummaryWithJob(chapter, &jobID)
// An edit can end the match between the stored completion and the chapter content, so the
// response resolves the read state instead of leaving a stale mark for the client to show.
if marks, err := readAtByChapter(tx, owner, []int64{chapter.ID}); err != nil {
return ChapterEdit{}, err
} else if at, ok := marks[chapter.ID]; ok {
summary.ReadAt = &at
}
result.Chapter = summary
return result, nil
}
+23 -4
View File
@@ -143,8 +143,11 @@ type ChapterSummary struct {
JobID *int64 `json:"jobId"`
ErrorReason string `json:"errorReason"`
ErrorMessage string `json:"errorMessage"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// ReadAt is set only where the completion was resolved and still matches the chapter's
// current content; a chapter edited into a newer version reads as unread again.
ReadAt *time.Time `json:"readAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ChapterView struct {
@@ -543,12 +546,20 @@ func BookDetail(db *gorm.DB, owner int, bookID int64) (BookRef, []ChapterSummary
if err != nil {
return BookRef{}, nil, err
}
marks, err := readAtByChapter(db, owner, ids)
if err != nil {
return BookRef{}, nil, err
}
for _, c := range chapters {
var jobID *int64
if id, ok := jobs[c.ID]; ok {
jobID = &id
}
items = append(items, chapterSummaryWithJob(c, jobID))
summary := chapterSummaryWithJob(c, jobID)
if at, ok := marks[c.ID]; ok {
summary.ReadAt = &at
}
items = append(items, summary)
}
return bookRef(book), items, nil
}
@@ -608,7 +619,15 @@ func ChapterDetail(db *gorm.DB, owner int, chapterID int64) (ReaderResponse, err
if id, ok := jobs[chapter.ID]; ok {
jobID = &id
}
return ReaderResponse{Book: bookRef(book), Chapter: chapterView(chapter, jobID), Navigation: navigation}, nil
marks, err := readAtByChapter(db, owner, []int64{chapter.ID})
if err != nil {
return ReaderResponse{}, err
}
view := chapterView(chapter, jobID)
if at, ok := marks[chapter.ID]; ok {
view.ReadAt = &at
}
return ReaderResponse{Book: bookRef(book), Chapter: view, Navigation: navigation}, nil
}
func JobDetail(db *gorm.DB, owner int, jobID int64) (JobView, error) {
+76
View File
@@ -293,6 +293,82 @@ func TestMigrationFromV5AddsReviewScheduling(t *testing.T) {
}
}
func TestMigrationFromV6AddsChapterProgress(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,6,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
statements = append(statements, schemaV6Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (21,'fixture_v6','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
book := Book{OwnerID: 21, Title: "Fictional v6 book", Language: "en", CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
text := "Curiosity opens the first door.\n"
chapter := Chapter{BookID: book.ID, OwnerID: 21, Ordinal: 1, Title: "Fictional v6", OriginalText: text,
ContentSHA256: contentSHA(text), CharCount: len([]rune(text)), Status: statusReady, CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&chapter).Error; err != nil {
t.Fatal(err)
}
// Nothing is read before the migration: a chapter never carries a mark by default.
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
var books, chapters int64
if err := db.Model(&Book{}).Count(&books).Error; err != nil || books != 1 {
t.Fatalf("migration changed the library: %d %v", books, err)
}
if err := db.Model(&Chapter{}).Count(&chapters).Error; err != nil || chapters != 1 {
t.Fatalf("migration changed the chapters: %d %v", chapters, err)
}
var rows int64
if err := db.Model(&ChapterProgress{}).Count(&rows).Error; err != nil || rows != 0 {
t.Fatalf("an upgrade must not invent reading progress: %d %v", rows, err)
}
// One chapter holds one completion row, and the row follows the chapter on delete.
progress := ChapterProgress{ChapterID: chapter.ID, OwnerID: 21, BookID: book.ID, Language: "en",
ReadSHA256: chapter.ContentSHA256, ReadAt: saved, CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&progress).Error; err != nil {
t.Fatal(err)
}
duplicate := progress
if err := db.Create(&duplicate).Error; err == nil {
t.Fatal("a chapter must hold only one completion row")
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
if err := db.Exec("UPDATE lexgo_schema SET version=6 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Model(&ChapterProgress{}).Count(&rows).Error; err != nil || rows != 1 {
t.Fatalf("re-upgrade must keep the completion: %d %v", rows, err)
}
if err := db.Where("id = ?", chapter.ID).Delete(&Chapter{}).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&ChapterProgress{}).Count(&rows).Error; err != nil || rows != 0 {
t.Fatalf("deleting a chapter must drop its completion: %d %v", rows, err)
}
}
func TestMigrationRefusesUnownedOrUnsupportedSchema(t *testing.T) {
for _, tc := range []struct{ name, marker string }{
{"empty_marker", ""},
+246
View File
@@ -0,0 +1,246 @@
package lexgo
import (
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
// A phrase is a run of consecutive words. Its identity is the ordered normalized word forms
// joined by single spaces, so interior punctuation, line breaks and extra whitespace never
// split one phrase into two records, and a phrase key can never collide with a word key.
const (
maxPhraseWords = 12
minPhraseWords = 2
maxPhraseKey = 128
maxPhraseSource = 191
)
// PhraseSpan is one saved phrase occurrence inside a chapter, expressed in token indices so
// the reader can style a run without re-deriving the match.
type PhraseSpan struct {
ID int64 `json:"id"`
Status string `json:"status"`
WordCount int `json:"wordCount"`
StartToken int `json:"startToken"`
EndToken int `json:"endToken"`
}
// phraseWords returns the word tokens fully inside a requested code point range. A range that
// cuts into a word is rejected instead of silently dropping it, so the client always learns
// that its selection was not a whole-word range.
func phraseWords(tokens []TextToken, start, end int) ([]TextToken, error) {
bad := failure(400, "请选择至少两个连续的完整单词")
if start < 0 || end <= start {
return nil, bad
}
words := make([]TextToken, 0, 4)
for _, token := range tokens {
if token.End <= start || token.Start >= end {
continue
}
if token.Kind != "word" {
continue
}
if token.Start < start || token.End > end {
return nil, failure(400, "请选择完整的单词,不要切在单词中间")
}
words = append(words, token)
}
if len(words) < minPhraseWords {
return nil, bad
}
if len(words) > maxPhraseWords {
return nil, failure(400, "短语最多 12 个单词")
}
return words, nil
}
// phraseKey is the identity of a phrase: the ordered normalized word forms joined by spaces.
func phraseKey(words []TextToken) string {
forms := make([]string, 0, len(words))
for _, word := range words {
forms = append(forms, normalizeWord(word.Text))
}
return strings.Join(forms, " ")
}
// phraseSource is the exact original text of the selection, punctuation and line breaks
// included, used for display only.
func phraseSource(text string, words []TextToken) string {
if len(words) == 0 {
return ""
}
runes := []rune(text)
start, end := words[0].Start, words[len(words)-1].End
if start < 0 || end > len(runes) || start >= end {
return ""
}
return string(runes[start:end])
}
// phraseMatches finds every saved phrase occurrence in a chapter. Candidates are ordered by
// their start and then longest first, and the leftmost-longest non-overlapping set is kept,
// which is the display rule #4 verified; nothing stored is modified by matching.
func phraseMatches(tokens []TextToken, phrases []Term) []PhraseSpan {
if len(phrases) == 0 {
return []PhraseSpan{}
}
// Index the phrases by their first word so a chapter only compares what can match.
words := make([]int, 0, len(tokens))
for index, token := range tokens {
if token.Kind == "word" {
words = append(words, index)
}
}
byFirst := map[string][]Term{}
for _, phrase := range phrases {
forms := strings.Split(phrase.Term, " ")
if len(forms) < minPhraseWords {
continue
}
byFirst[forms[0]] = append(byFirst[forms[0]], phrase)
}
type candidate struct {
startToken int
endToken int
phrase Term
}
found := make([]candidate, 0, 8)
for position, tokenIndex := range words {
first := normalizeWord(tokens[tokenIndex].Text)
for _, phrase := range byFirst[first] {
forms := strings.Split(phrase.Term, " ")
if position+len(forms) > len(words) {
continue
}
matched := true
for offset, form := range forms {
if normalizeWord(tokens[words[position+offset]].Text) != form {
matched = false
break
}
}
if matched {
found = append(found, candidate{words[position], words[position+len(forms)-1], phrase})
}
}
}
sort.SliceStable(found, func(i, j int) bool {
if found[i].startToken != found[j].startToken {
return found[i].startToken < found[j].startToken
}
if found[i].endToken != found[j].endToken {
return found[i].endToken > found[j].endToken
}
return found[i].phrase.ID < found[j].phrase.ID
})
spans := make([]PhraseSpan, 0, len(found))
lastEnd := -1
for _, item := range found {
if item.startToken <= lastEnd {
continue
}
spans = append(spans, PhraseSpan{
ID: item.phrase.ID, Status: item.phrase.Status, WordCount: termWordCount(item.phrase.Term),
StartToken: item.startToken, EndToken: item.endToken,
})
lastEnd = item.endToken
}
return spans
}
// phrasesForChapter loads the caller's phrases and matches them against one chapter.
func phrasesForChapter(tx *gorm.DB, owner int, language string, tokens []TextToken) ([]PhraseSpan, error) {
var phrases []Term
// A phrase key always contains a space and a word key never does, so this is the whole
// filter; it needs no column.
if err := tx.Select("id", "term", "status").
Where("owner_id = ? AND language = ? AND term LIKE ?", owner, language, "% %").
Find(&phrases).Error; err != nil {
return nil, err
}
return phraseMatches(tokens, phrases), nil
}
type PhraseInput struct {
ChapterID int64 `json:"chapterId"`
Start *int `json:"start"`
End *int `json:"end"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level *int `json:"level"`
}
// SavePhrase derives the identity from the server's own tokens of an owned ready chapter, so a
// client can neither name a phrase it did not select nor forge an owner or language. Saving
// reuses the term table and its idempotent upsert, so one phrase stays one record with one
// review schedule.
func SavePhrase(tx *gorm.DB, owner int, language string, input PhraseInput, now time.Time) (TermSave, error) {
if input.Start == nil || input.End == nil {
return TermSave{}, failure(400, "请选择至少两个连续的完整单词")
}
chapter, err := readyOwnedChapter(tx, owner, input.ChapterID)
if err != nil {
return TermSave{}, err
}
words, err := phraseWords(Tokenize(chapter.OriginalText), *input.Start, *input.End)
if err != nil {
return TermSave{}, err
}
key := phraseKey(words)
if utf8.RuneCountInString(key) > maxPhraseKey {
return TermSave{}, failure(400, "短语过长,请缩短选择范围")
}
source := phraseSource(chapter.OriginalText, words)
if source == "" || utf8.RuneCountInString(source) > maxPhraseSource {
return TermSave{}, failure(400, "短语原文过长,请缩短选择范围")
}
level, err := termLevel(input.Status, input.Level, Term{}, false)
if err != nil {
return TermSave{}, err
}
definition, examples, err := termContent(input.Definition, input.Examples)
if err != nil {
return TermSave{}, err
}
previous, exists, err := previousTerm(tx, owner, language, key)
if err != nil {
return TermSave{}, err
}
if exists && termKind(previous.Term) != termKindPhrase {
return TermSave{}, failure(409, "该内容已被记录为单词")
}
if exists {
level, err = termLevel(input.Status, input.Level, previous, true)
if err != nil {
return TermSave{}, err
}
}
fields := TermFields{
Definition: definition, Examples: examples, Status: input.Status, Level: level,
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
}
// The identity is the word-form key; the original selection is only the display text.
return saveTerm(tx, owner, language, key, source, fields, now)
}
func registerPhraseRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.POST("/phrases", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input PhraseInput
if err := decode(c, &input); err != nil {
return nil, err
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return SavePhrase(tx, u.UserId, language, input, now())
}))
}
+415
View File
@@ -0,0 +1,415 @@
package lexgo
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
// phraseFixtureText holds the cases #4 verified: interior punctuation, a line break inside a
// phrase, a repeated phrase and a combining accent.
const phraseFixtureText = "Take a small, step\nevery day.\nMira took a small step again.\n"
func phraseTokens(text string) []TextToken {
return Tokenize(text)
}
func spanOf(t *testing.T, tokens []TextToken, words ...string) (int, int) {
t.Helper()
var first, last TextToken
found := 0
for _, token := range tokens {
if token.Kind != "word" {
continue
}
if found < len(words) && normalizeWord(token.Text) == normalizeWord(words[found]) {
if found == 0 {
first = token
}
last = token
found++
}
}
if found != len(words) {
t.Fatalf("could not find %v in %q", words, text(tokens))
}
return first.Start, last.End
}
func text(tokens []TextToken) string {
var builder strings.Builder
for _, token := range tokens {
builder.WriteString(token.Text)
}
return builder.String()
}
// TestPhraseIdentityRules pins the range and identity rules of a phrase.
func TestPhraseIdentityRules(t *testing.T) {
tokens := phraseTokens(phraseFixtureText)
// Interior punctuation and a line break stay in the display text but not in the key.
start, end := spanOf(t, tokens, "a", "small", "step")
words, err := phraseWords(tokens, start, end)
if err != nil || len(words) != 3 {
t.Fatalf("phrase words: %v %v", words, err)
}
if key := phraseKey(words); key != "a small step" {
t.Fatalf("key %q, want %q", key, "a small step")
}
if source := phraseSource(phraseFixtureText, words); source != "a small, step\nevery day."[:0]+"a small, step" {
t.Fatalf("source %q", source)
}
// The same words separated by different punctuation are one identity.
secondTokens := phraseTokens("Mira took a small step again.")
secondStart, secondEnd := spanOf(t, secondTokens, "a", "small", "step")
other, err := phraseWords(secondTokens, secondStart, secondEnd)
if err != nil {
t.Fatal(err)
}
if phraseKey(other) != "a small step" {
t.Fatalf("a differently spaced phrase must share the key: %q", phraseKey(other))
}
// Case and curly apostrophes fold, exactly like word identity.
if key := phraseKey(mustWords(t, "Don’t Look Back")); key != "don't look back" {
t.Fatalf("normalized phrase key: %q", key)
}
// Endpoint alignment: a range that cuts into a word is refused instead of dropping it.
if _, err := phraseWords(tokens, start+3, end); err == nil {
t.Fatal("a range cutting into a word must be rejected")
}
if _, err := phraseWords(tokens, start, end-2); err == nil {
t.Fatal("a range cutting into the last word must be rejected")
}
// A range that starts on a separator simply begins at the next whole word.
trimmed, err := phraseWords(tokens, start+1, end)
if err != nil || len(trimmed) != 2 || normalizeWord(trimmed[0].Text) != "small" {
t.Fatalf("a separator boundary must select whole words: %v %v", trimmed, err)
}
// Surrounding whitespace and punctuation are allowed on both ends.
if _, err := phraseWords(tokens, start-1, end+1); err != nil {
t.Fatalf("surrounding separators must be accepted: %v", err)
}
// A single word, an empty range and a punctuation-only range are not phrases.
if _, err := phraseWords(tokens, words[0].Start, words[0].End); err == nil {
t.Fatal("one word is not a phrase")
}
if _, err := phraseWords(tokens, 0, 0); err == nil {
t.Fatal("an empty range is not a phrase")
}
if _, err := phraseWords(tokens, -1, 4); err == nil {
t.Fatal("a negative range is not a phrase")
}
// Limits: at most 12 words, and a key within the stored column.
long := make([]TextToken, 0, maxPhraseWords+1)
position := 0
for index := 0; index < maxPhraseWords+1; index++ {
word := "word"
long = append(long, TextToken{Text: word, Start: position, End: position + len(word), Kind: "word"})
position += len(word) + 1
}
if _, err := phraseWords(long, 0, position); err == nil {
t.Fatal("more than 12 words must be rejected")
}
if _, err := phraseWords(long[:maxPhraseWords], 0, len(long[maxPhraseWords-1].Text)+long[maxPhraseWords-1].Start); err != nil {
t.Fatalf("exactly 12 words must be accepted: %v", err)
}
}
// TestWordKeysNeverContainSpaces locks the premise the derived kind relies on: a word token
// never contains a space, so the identity key alone tells a word from a phrase and no column is
// needed for it.
func TestWordKeysNeverContainSpaces(t *testing.T) {
samples := []string{phraseFixtureText, "a b\tc\r\nd", "don’t stop-BELIEVING.", "🙂 café e\u0301 42"}
checked := 0
for _, sample := range samples {
for _, token := range Tokenize(sample) {
if token.Kind != "word" {
continue
}
checked++
if strings.ContainsAny(normalizeWord(token.Text), " \t\r\n") {
t.Fatalf("word key %q contains whitespace", normalizeWord(token.Text))
}
}
}
if checked < 8 {
t.Fatalf("expected several word tokens, checked %d", checked)
}
// The derived kind therefore agrees with the shape of the key in both directions.
if termKind("a small step") != termKindPhrase || termWordCount("a small step") != 3 {
t.Fatal("a key with spaces must be a three-word phrase")
}
if termKind("curiosity") != termKindWord || termWordCount("curiosity") != 1 {
t.Fatal("a key without spaces must be a word")
}
}
func mustWords(t *testing.T, text string) []TextToken {
t.Helper()
words, err := phraseWords(phraseTokens(text), 0, len([]rune(text)))
if err != nil {
t.Fatal(err)
}
return words
}
// TestPhraseMatchingAndOverlap pins the cross-chapter match and the leftmost-longest display.
func TestPhraseMatchingAndOverlap(t *testing.T) {
tokens := phraseTokens(phraseFixtureText)
step := Term{ID: 1, Term: "a small step", Status: termStatusNew}
small := Term{ID: 2, Term: "small step", Status: termStatusLearning}
spans := phraseMatches(tokens, []Term{step, small})
if len(spans) != 2 {
t.Fatalf("expected one occurrence of each phrase, got %+v", spans)
}
// The longer phrase starts first, so it wins and the shorter one is dropped where they
// overlap; the later occurrence of "a small step" is a separate match.
if spans[0].ID != step.ID || spans[1].ID != step.ID {
t.Fatalf("leftmost-longest rule: %+v", spans)
}
if spans[0].StartToken >= spans[1].StartToken {
t.Fatalf("spans must be ordered: %+v", spans)
}
// A phrase that no longer occurs is simply absent, and nothing is mutated.
if len(phraseMatches(phraseTokens("Nothing to see here."), []Term{step})) != 0 {
t.Fatal("a phrase that does not occur must not match")
}
// Two different phrases that both match at the same place keep a stable choice.
spans = phraseMatches(tokens, []Term{small, step})
if len(spans) != 2 || spans[0].ID != step.ID {
t.Fatalf("order of input must not change the display: %+v", spans)
}
// A single-word entry never participates as a phrase.
if len(phraseMatches(tokens, []Term{{ID: 9, Term: "step"}})) != 0 {
t.Fatal("a single-word key is not a phrase match")
}
}
// TestMySQLPhraseSaveAndCrossChapterHighlight is the core path: saving a phrase, finding it
// again in another chapter, and keeping one record per identity.
func TestMySQLPhraseSaveAndCrossChapterHighlight(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-fixture-0001", "title": "Phrase chapter", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
// A second chapter with the same phrase in a different form.
code, second := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-fixture-0002", "title": "Another chapter", "text": "Mira took a small step again.\n", "language": "en"})
if code != 201 {
t.Fatalf("second paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
body := map[string]any{
"chapterId": first.Chapter.ID, "start": start, "end": end,
"definition": "一小步", "examples": []string{"Take a small step, every day."}, "status": termStatusNew,
}
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body)
if code != 201 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
Created bool
}
json.Unmarshal(data, &saved)
if saved.Term.Kind != "phrase" || saved.Term.WordCount != 3 || saved.Term.Term != "a small step" {
t.Fatalf("saved phrase %+v", saved.Term)
}
if saved.Term.OriginalForm != "a small, step" {
t.Fatalf("the display text keeps the original punctuation: %q", saved.Term.OriginalForm)
}
// The same phrase saved from the other chapter, written differently, stays one record.
otherTokens := phraseTokens("Mira took a small step again.\n")
otherStart, otherEnd := spanOf(t, otherTokens, "a", "small", "step")
code, msg, data = callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
"chapterId": second.Chapter.ID, "start": otherStart, "end": otherEnd, "definition": "一小步(更新)", "status": termStatusNew,
})
if code != 200 {
t.Fatalf("repeat phrase %d (%s)", code, msg)
}
var again struct {
Term TermView
Created bool
}
json.Unmarshal(data, &again)
if again.Created || again.Term.ID != saved.Term.ID || again.Term.OriginalForm != "a small step" {
t.Fatalf("one phrase must stay one record: %+v", again)
}
var count int64
// A phrase key always contains a space, so the key shape is the whole filter.
db.Model(&Term{}).Where("owner_id = ? AND term LIKE ?", learner.ID, "% %").Count(&count)
if count != 1 {
t.Fatalf("phrase records: %d", count)
}
// Both chapters highlight the phrase, including the one where it was not saved. The first
// chapter holds the phrase twice with different interior punctuation.
wantOccurrences := map[int64]int{first.Chapter.ID: 2, second.Chapter.ID: 1}
for chapterID, want := range wantOccurrences {
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", chapterID), learner.Token, nil)
var analyzed ChapterTokens
json.Unmarshal(data, &analyzed)
if code != 200 || len(analyzed.Phrases) != want {
t.Fatalf("chapter %d phrases: %d %+v", chapterID, code, analyzed.Phrases)
}
for _, span := range analyzed.Phrases {
if span.ID != saved.Term.ID || span.WordCount != 3 || span.EndToken <= span.StartToken {
t.Fatalf("chapter %d span %+v", chapterID, span)
}
if analyzed.Tokens[span.StartToken].Kind != "word" || analyzed.Tokens[span.EndToken].Kind != "word" {
t.Fatalf("a phrase must span words: %+v", span)
}
}
}
// Another account sees no phrase at all.
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), other.Token, nil)
if code != 404 {
t.Fatalf("another account reading this chapter: %d", code)
}
// The phrase joins the review queue with its kind, and answers like a word.
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" || queue.Items[0].WordCount != 3 {
t.Fatalf("phrase queue: %d %+v", code, queue.Items)
}
code, applied := answerReview(t, r, learner.Token, saved.Term.ID, answerBody("phrase-answer-0001", reviewGradeCorrect, queue.Items[0].DueAt))
if code != 201 || applied.Result != "applied" || applied.LevelAfter != 1 || applied.Item.Kind != "phrase" {
t.Fatalf("phrase answer: %d %+v", code, applied)
}
// The word-level highlight is unaffected: the same words are still ordinary words.
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), learner.Token, nil)
var withPhrase ChapterTokens
json.Unmarshal(data, &withPhrase)
for _, index := range []int{withPhrase.Phrases[0].StartToken, withPhrase.Phrases[0].EndToken} {
if withPhrase.Tokens[index].Term != nil {
t.Fatal("a phrase must not create word-level records")
}
}
}
// TestMySQLPhraseRulesAndIsolation covers the rejected shapes and ownership.
func TestMySQLPhraseRulesAndIsolation(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-rules-0001", "title": "Phrase rules", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
singleStart, singleEnd := wordsAt(t, tokens, "a")
base := map[string]any{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", other.Token, base); code != 404 {
t.Fatal("another account must not save a phrase in this chapter")
}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", "", base); code != 401 {
t.Fatal("saving a phrase needs a session")
}
invalid := []map[string]any{
{"chapterId": pasted.Chapter.ID, "start": singleStart, "end": singleEnd, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "end": end, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "start": start + 3, "end": end, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": "deleted"},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusKnown, "level": 3},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "definition": strings.Repeat("a", termDefinitionLimit+1)},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "term": "forged"},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "ownerId": 9},
{"chapterId": 999999, "start": start, "end": end, "status": termStatusNew},
}
for _, body := range invalid {
if code, msg, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body); code != 400 && code != 404 {
t.Fatalf("invalid phrase %v -> %d (%s)", body, code, msg)
}
}
var count int64
db.Model(&Term{}).Where("owner_id = ?", learner.ID).Count(&count)
if count != 0 {
t.Fatalf("a rejected phrase was stored: %d", count)
}
// A phrase in a chapter that is not ready is refused, exactly like a word.
if err := db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).Update("status", statusPending).Error; err != nil {
t.Fatal(err)
}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, base); code != 409 {
t.Fatal("a pending chapter must refuse a phrase")
}
}
func wordsAt(t *testing.T, tokens []TextToken, word string) (int, int) {
t.Helper()
start, end := spanOf(t, tokens, word)
return start, end
}
// TestMySQLPhraseSurvivesTextEditsAndDeletion locks the fallback rule for stale references.
func TestMySQLPhraseSurvivesTextEditsAndDeletion(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-survive-0001", "title": "Phrase survival", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
"chapterId": pasted.Chapter.ID, "start": start, "end": end, "definition": "一小步", "status": termStatusNew})
if code != 201 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
}
json.Unmarshal(data, &saved)
// Editing the text so the phrase no longer occurs: the entry and its schedule stay.
if code, msg, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": "Nothing left of it.\n"}); code != 200 {
t.Fatalf("edit chapter %d (%s)", code, msg)
}
drainIngest(t, db)
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", pasted.Chapter.ID), learner.Token, nil)
var analyzed ChapterTokens
json.Unmarshal(data, &analyzed)
if code != 200 || len(analyzed.Phrases) != 0 {
t.Fatalf("a phrase that no longer occurs must not highlight: %d %+v", code, analyzed.Phrases)
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
t.Fatal("the entry must survive a text edit")
}
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 {
t.Fatalf("the schedule must survive a text edit: %d %+v", code, queue.Items)
}
// Deleting the chapter keeps the entry and its schedule too.
if code, msg, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil); code != 200 {
t.Fatalf("delete chapter %d (%s)", code, msg)
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
t.Fatal("the entry must survive a chapter deletion")
}
code, queue = reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" {
t.Fatalf("the schedule must survive a chapter deletion: %d %+v", code, queue.Items)
}
}
+244
View File
@@ -0,0 +1,244 @@
package lexgo
import (
"errors"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ChapterProgress is one learner's reading completion for one chapter. The chapter id is the
// primary key: a chapter belongs to exactly one owner, so repeating the completion updates the
// same row instead of adding a second one. read_sha256 snapshots the content that was actually
// read, which is what keeps the counters honest after the text is replaced.
type ChapterProgress struct {
ChapterID int64 `gorm:"primaryKey"`
OwnerID int
BookID int64
Language string
ReadSHA256 string
ReadAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (ChapterProgress) TableName() string { return "lexgo_chapter_progress" }
// CompletionResult answers a completion request. Duplicate reports that this chapter already
// held a mark for the same content, so nothing moved and the counters did not change.
type CompletionResult struct {
ChapterID int64 `json:"chapterId"`
BookID int64 `json:"bookId"`
Read bool `json:"read"`
ReadAt time.Time `json:"readAt"`
Duplicate bool `json:"duplicate"`
}
// CompleteChapter marks one owned chapter as read. Only a ready chapter can be completed: a
// chapter that is still processing or failed was never readable, so a mark would be a claim
// about text nobody saw. Completing a chapter never changes word status or levels, which is the
// boundary between F11 and the X10 batch-to-known switch.
func CompleteChapter(tx *gorm.DB, owner int, chapterID int64, now time.Time) (CompletionResult, error) {
ts := stamp(now)
var result CompletionResult
err := tx.Transaction(func(inner *gorm.DB) error {
var chapter Chapter
if err := inner.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", chapterID, owner).First(&chapter).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return failure(404, "章节不存在")
}
return err
}
if chapter.Status != statusReady {
return failure(409, "只有处理完成的章节可以标记已读")
}
language, err := languageOf(inner, owner)
if err != nil {
return err
}
var existing ChapterProgress
err = inner.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("chapter_id = ?", chapter.ID).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
row := ChapterProgress{ChapterID: chapter.ID, OwnerID: owner, BookID: chapter.BookID,
Language: language, ReadSHA256: chapter.ContentSHA256, ReadAt: ts, CreatedAt: ts, UpdatedAt: ts}
if err := inner.Create(&row).Error; err != nil {
return err
}
result = CompletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Read: true, ReadAt: ts}
case err != nil:
return err
default:
if existing.OwnerID != owner {
return failure(404, "章节不存在")
}
// The same content marked again keeps the original date, so a repeated request cannot
// move the timestamp or count twice.
if existing.ReadSHA256 == chapter.ContentSHA256 {
result = CompletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Read: true,
ReadAt: existing.ReadAt, Duplicate: true}
return nil
}
// A new content version was read: the one row moves forward to the text the person
// just finished instead of accumulating a second completion.
if err := inner.Model(&ChapterProgress{}).Where("chapter_id = ?", chapter.ID).
Updates(map[string]any{"read_sha256": chapter.ContentSHA256, "read_at": ts, "updated_at": ts,
"book_id": chapter.BookID, "language": language}).Error; err != nil {
return err
}
result = CompletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Read: true, ReadAt: ts}
}
return nil
})
if err != nil {
return CompletionResult{}, err
}
return result, nil
}
// readAtByChapter lists the chapters of one owner whose completion still matches the content
// they hold and which are ready to read. A chapter edited into a newer version therefore reads
// as unread again without deleting the row, and a chapter being reprocessed carries no mark.
func readAtByChapter(db *gorm.DB, owner int, chapterIDs []int64) (map[int64]time.Time, error) {
marks := make(map[int64]time.Time)
if len(chapterIDs) == 0 {
return marks, nil
}
var rows []struct {
ChapterID int64
ReadAt time.Time
ReadSHA256 string
ContentSHA256 string
}
if err := db.Table("lexgo_chapter_progress AS p").
Select("p.chapter_id, p.read_at, p.read_sha256, c.content_sha256").
Joins("JOIN lexgo_chapters AS c ON c.id = p.chapter_id").
Where("p.owner_id = ? AND c.owner_id = ? AND c.status = ? AND p.chapter_id IN ?", owner, owner, statusReady, chapterIDs).
Scan(&rows).Error; err != nil {
return marks, err
}
for _, row := range rows {
if row.ReadSHA256 == row.ContentSHA256 {
marks[row.ChapterID] = row.ReadAt
}
}
return marks, nil
}
// BookProgress is one book's share of the reading total.
type BookProgress struct {
ID int64 `json:"id"`
Title string `json:"title"`
ReadChapters int `json:"readChapters"`
TotalChapters int `json:"totalChapters"`
}
// LearnerProgress is the learner's own basic progress for the current language. Reading counts
// only chapters that can actually be read, and the word counts keep the four statuses apart so
// "known" and "ignored" are never merged into one number.
type LearnerProgress struct {
ReadChapters int `json:"readChapters"`
TotalChapters int `json:"totalChapters"`
KnownTerms int `json:"knownTerms"`
LearningTerms int `json:"learningTerms"`
NewTerms int `json:"newTerms"`
IgnoredTerms int `json:"ignoredTerms"`
SavedTerms int `json:"savedTerms"`
DueNow int `json:"dueNow"`
Books []BookProgress `json:"books"`
}
// LearnerProgressFor counts the caller's own reading and word state for one language. Every
// number is owner and language scoped; an administrator role does not widen the filter. The due
// count uses the same query builder as the review queue, so the progress page and the queue
// cannot drift apart.
func LearnerProgressFor(db *gorm.DB, owner int, language string, now time.Time) (LearnerProgress, error) {
progress := LearnerProgress{Books: []BookProgress{}}
// Reading covers ready chapters of the owner's books in this language. A chapter still being
// processed is excluded from both sides of the ratio instead of making the total unreachable.
readable := func() *gorm.DB {
return db.Table("lexgo_chapters AS c").
Joins("JOIN lexgo_books AS b ON b.id = c.book_id").
Where("c.owner_id = ? AND b.owner_id = ? AND b.language = ?", owner, owner, language).
Where("c.status = ?", statusReady)
}
completions := "LEFT JOIN lexgo_chapter_progress AS p ON p.chapter_id = c.id AND p.owner_id = c.owner_id AND p.read_sha256 = c.content_sha256"
readSum := "COALESCE(SUM(CASE WHEN p.chapter_id IS NULL THEN 0 ELSE 1 END),0) AS chapter_read"
var totals struct {
Total int
Read int `gorm:"column:chapter_read"`
}
if err := readable().Select("COUNT(*) AS total, " + readSum).Joins(completions).Scan(&totals).Error; err != nil {
return progress, err
}
progress.TotalChapters, progress.ReadChapters = totals.Total, totals.Read
var books []struct {
ID int64
Title string
Total int
Read int `gorm:"column:chapter_read"`
}
if err := readable().
Select("b.id, b.title, COUNT(*) AS total, " + readSum).
Joins(completions).Group("b.id, b.title").Order("b.id ASC").Scan(&books).Error; err != nil {
return progress, err
}
for _, book := range books {
progress.Books = append(progress.Books, BookProgress{ID: book.ID, Title: book.Title,
ReadChapters: book.Read, TotalChapters: book.Total})
}
var counts []struct {
Status string
Total int
}
if err := db.Table("lexgo_terms").Select("status, COUNT(*) AS total").
Where("owner_id = ? AND language = ?", owner, language).
Group("status").Scan(&counts).Error; err != nil {
return progress, err
}
for _, row := range counts {
progress.SavedTerms += row.Total
switch row.Status {
case termStatusKnown:
progress.KnownTerms = row.Total
case termStatusLearning:
progress.LearningTerms = row.Total
case termStatusNew:
progress.NewTerms = row.Total
case termStatusIgnored:
progress.IgnoredTerms = row.Total
}
}
var due int64
if err := dueTermsQuery(db, owner, language, now).Count(&due).Error; err != nil {
return progress, err
}
progress.DueNow = int(due)
return progress, nil
}
func registerProgressRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.POST("/chapters/:id/complete", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
result, err := CompleteChapter(tx, u.UserId, id, now())
if err != nil {
return nil, err
}
return gin.H{"progress": result}, nil
}))
v.GET("/progress", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return LearnerProgressFor(tx, u.UserId, language, now())
}))
}
+360
View File
@@ -0,0 +1,360 @@
package lexgo
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
func completeChapter(t *testing.T, r *gin.Engine, token string, chapterID int64) (int, CompletionResult) {
t.Helper()
code, data := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/chapters/%d/complete", chapterID), token, nil)
var payload struct {
Progress CompletionResult
}
if data != nil {
json.Unmarshal(data, &payload)
}
return code, payload.Progress
}
func learnerProgress(t *testing.T, r *gin.Engine, token string) (int, LearnerProgress) {
t.Helper()
code, data := callAPI(t, r, "GET", "/api/v1/progress", token, nil)
var progress LearnerProgress
if data != nil {
json.Unmarshal(data, &progress)
}
return code, progress
}
// bookChapters reads one book through the API so the test sees the same read state a page sees.
func bookChapters(t *testing.T, r *gin.Engine, token string, bookID int64) map[int64]*time.Time {
t.Helper()
code, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/books/%d", bookID), token, nil)
if code != 200 {
t.Fatalf("book detail %d", code)
}
var payload struct {
Chapters []ChapterSummary
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatalf("book detail payload: %v", err)
}
marks := make(map[int64]*time.Time, len(payload.Chapters))
for _, chapter := range payload.Chapters {
marks[chapter.ID] = chapter.ReadAt
}
return marks
}
func chapterProgressRows(t *testing.T, db *gorm.DB, chapterID int64) int64 {
t.Helper()
var rows int64
if err := db.Model(&ChapterProgress{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
// TestMySQLChapterCompletionIsIdempotentAndContentBound covers marking a chapter read: one row
// per chapter, a stable timestamp on a repeat, and a new content version reading as unread.
func TestMySQLChapterCompletionIsIdempotentAndContentBound(t *testing.T) {
db := testDB(t)
clock := time.Now().UTC().Truncate(time.Millisecond)
r := Router(db, func() time.Time { return clock })
learner := admin.SysUser{Username: randomName("prog"), Password: fixturePassword, RoleId: 2, Status: "2"}
if err := db.Create(&learner).Error; err != nil {
t.Fatal(err)
}
token := loginToken(t, r, learner.Username, fixturePassword)
paste := func(requestID, text string) pasteResponse {
code, pasted := pasteBook(t, r, token, map[string]string{"requestId": requestID, "title": "Fictional progress", "text": text, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
return pasted
}
first := paste("progress-complete-01", "Curiosity opens the first door.\n")
second := paste("progress-complete-02", "A second chapter waits here.\n")
// A ready chapter can be completed, and the response describes the new mark.
code, done := completeChapter(t, r, token, first.Chapter.ID)
if code != 200 || !done.Read || done.Duplicate || done.ChapterID != first.Chapter.ID {
t.Fatalf("first completion: %d %+v", code, done)
}
if !done.ReadAt.Equal(stamp(clock)) {
t.Fatalf("read at %v, want %v", done.ReadAt, stamp(clock))
}
rows := chapterProgressRows(t, db, first.Chapter.ID)
if rows != 1 {
t.Fatalf("one chapter must hold one completion row, got %d", rows)
}
// Repeating the request answers the same way and does not move the stored timestamp, even
// after the clock moved on.
clock = clock.Add(5 * time.Minute)
code, again := completeChapter(t, r, token, first.Chapter.ID)
if code != 200 || !again.Duplicate || !again.ReadAt.Equal(done.ReadAt) {
t.Fatalf("repeated completion: %d %+v (first %+v)", code, again, done)
}
if rows := chapterProgressRows(t, db, first.Chapter.ID); rows != 1 {
t.Fatalf("a repeat must not add a row, got %d", rows)
}
// The book list and the reader show the mark, and the progress page counts it.
marks := bookChapters(t, r, token, first.Chapter.BookID)
if marks[first.Chapter.ID] == nil || marks[second.Chapter.ID] != nil {
t.Fatalf("book marks: %+v", marks)
}
var reader struct {
Chapter ChapterView
}
if code, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), token, nil); code != 200 {
t.Fatalf("reader %d", code)
} else {
json.Unmarshal(data, &reader)
}
if reader.Chapter.ReadAt == nil {
t.Fatal("the reader must carry the read state")
}
if code, progress := learnerProgress(t, r, token); code != 200 || progress.ReadChapters != 1 || progress.TotalChapters != 2 {
t.Fatalf("progress after one chapter: %d %+v", code, progress)
}
// Replacing the text creates a new version, so the chapter reads as unread again while the
// stored completion row stays: it describes the text that was finished, not a counter.
if code, edited := editChapter(t, r, token, first.Chapter.ID, map[string]string{"text": "Curiosity opens a different door.\n"}); code != 200 || edited.Chapter.ReadAt != nil {
t.Fatalf("editing the text must end the mark: %d %+v", code, edited.Chapter)
}
if marks := bookChapters(t, r, token, first.Chapter.BookID); marks[first.Chapter.ID] != nil {
t.Fatal("an edited chapter must read as unread in the list")
}
if code, progress := learnerProgress(t, r, token); code != 200 || progress.ReadChapters != 0 {
t.Fatalf("an edited chapter must not count as read: %d %+v", code, progress)
}
if rows := chapterProgressRows(t, db, first.Chapter.ID); rows != 1 {
t.Fatalf("the completion row must survive an edit, got %d", rows)
}
// Completing the new version moves the same row forward instead of accumulating a second.
drainIngest(t, db)
code, reread := completeChapter(t, r, token, first.Chapter.ID)
if code != 200 || reread.Duplicate || !reread.ReadAt.Equal(stamp(clock)) {
t.Fatalf("re-reading the new version: %d %+v", code, reread)
}
if rows := chapterProgressRows(t, db, first.Chapter.ID); rows != 1 {
t.Fatalf("re-reading must reuse the row, got %d", rows)
}
if code, progress := learnerProgress(t, r, token); code != 200 || progress.ReadChapters != 1 {
t.Fatalf("progress after re-reading: %d %+v", code, progress)
}
// Renaming the chapter keeps the mark: the reader saw this text.
if code, edited := editChapter(t, r, token, first.Chapter.ID, map[string]string{"title": "Fictional renamed"}); code != 200 || edited.Chapter.ReadAt == nil {
t.Fatalf("a title edit must keep the mark: %d %+v", code, edited.Chapter)
}
// A chapter still being processed cannot be completed, and the mark needs a ready chapter.
pending := paste("progress-complete-03", "A third chapter arrives later.\n")
if err := db.Model(&Chapter{}).Where("id = ?", pending.Chapter.ID).
Updates(map[string]any{"status": statusPending, "updated_at": stamp(clock)}).Error; err != nil {
t.Fatal(err)
}
if code, _ := completeChapter(t, r, token, pending.Chapter.ID); code != 409 {
t.Fatalf("a pending chapter must be refused, got %d", code)
}
// Deleting a chapter takes its completion row with it, so the counters follow the library.
// Every paste created its own book, so this deletion empties its book.
if code, deleted := deleteChapter(t, r, token, first.Chapter.ID); code != 200 || deleted.Remaining != 0 {
t.Fatalf("delete chapter: %d %+v", code, deleted)
}
if rows := chapterProgressRows(t, db, first.Chapter.ID); rows != 0 {
t.Fatalf("deleting a chapter must drop its completion, got %d rows", rows)
}
// Ownership: another account cannot complete or observe this chapter, and anonymous callers
// are refused before anything is read.
_, adminEngine, adminAccount := libraryFixture(t)
other := newLearner(t, adminEngine, adminAccount.Token)
if code, _ := completeChapter(t, r, other.Token, second.Chapter.ID); code != 404 {
t.Fatalf("another account must not complete this chapter, got %d", code)
}
if code, progress := learnerProgress(t, r, other.Token); code != 200 || progress.ReadChapters != 0 || progress.TotalChapters != 0 {
t.Fatalf("another account must count nothing: %d %+v", code, progress)
}
if code, _ := completeChapter(t, r, "", second.Chapter.ID); code != 401 {
t.Fatal("completing needs a session")
}
if code, _ := completeChapter(t, r, token, 999999); code != 404 {
t.Fatal("an unknown chapter must be 404")
}
if code, _ := completeChapter(t, r, token, 0); code != 404 {
t.Fatal("a malformed chapter id must be 404")
}
if code, _ := learnerProgress(t, r, ""); code != 401 {
t.Fatal("the progress page needs a session")
}
}
// TestMySQLProgressCountsMatchRecords checks that every number on the progress page comes from
// the caller's own records, that the four word statuses stay apart, and that the due count is
// the same rule the review queue uses.
func TestMySQLProgressCountsMatchRecords(t *testing.T) {
db := testDB(t)
clock := time.Now().UTC().Truncate(time.Millisecond)
r := Router(db, func() time.Time { return clock })
learner, token, _ := termFixture(t, db, r, "Curiosity opens the first door.\n", "The second door is closed.\n", "A third chapter waits.\n")
var chapters []Chapter
if err := db.Where("owner_id = ?", learner.UserId).Order("ordinal ASC").Find(&chapters).Error; err != nil {
t.Fatal(err)
}
// The third chapter is still being processed, so it belongs to neither side of the ratio.
if err := db.Model(&Chapter{}).Where("id = ?", chapters[2].ID).
Updates(map[string]any{"status": statusPending, "updated_at": stamp(clock)}).Error; err != nil {
t.Fatal(err)
}
// Four words in four statuses, one of them answered correctly so it is no longer due.
known := saveWord(t, r, token, chapters[0].ID, 0, 9, termStatusNew, nil)
ignored := saveWord(t, r, token, chapters[0].ID, 10, 15, termStatusNew, nil)
fresh := saveWord(t, r, token, chapters[0].ID, 20, 25, termStatusNew, nil)
learningLevel := 1
learning := saveWord(t, r, token, chapters[1].ID, 4, 10, termStatusLearning, &learningLevel)
answered := saveWord(t, r, token, chapters[1].ID, 11, 15, termStatusNew, nil)
if learning.Term.Status != termStatusLearning || fresh.Term.Status != termStatusNew || answered.Term.Status != termStatusNew {
t.Fatalf("fixture statuses: %+v %+v %+v", learning.Term, fresh.Term, answered.Term)
}
if code, knownTerm := editTerm(t, r, token, known.Term.ID, map[string]any{"status": termStatusKnown}); code != 200 || knownTerm.Level != 0 {
t.Fatalf("known: %d %+v", code, knownTerm)
}
if code, ignoredTerm := editTerm(t, r, token, ignored.Term.ID, map[string]any{"status": termStatusIgnored}); code != 200 || ignoredTerm.Level != 0 {
t.Fatalf("ignored: %d %+v", code, ignoredTerm)
}
// The learning word is scheduled a day ahead, so the queue holds the two still-new words.
_, queue := reviewQueue(t, r, token)
if len(queue.Items) != 2 {
t.Fatalf("fixture queue: %+v", queue.Items)
}
target := queue.Items[0]
for _, item := range queue.Items {
if item.ID == answered.Term.ID {
target = item
}
}
if target.ID != answered.Term.ID {
t.Fatalf("the answered word must be due: %+v", queue.Items)
}
if code, applied := answerReview(t, r, token, target.ID, answerBody("progress-count-answer-1", reviewGradeCorrect, target.DueAt)); code != 201 || applied.LevelAfter != 1 {
t.Fatalf("answer: %d %+v", code, applied)
}
if code, marked := completeChapter(t, r, token, chapters[0].ID); code != 200 || !marked.Read {
t.Fatalf("complete: %d %+v", code, marked)
}
code, progress := learnerProgress(t, r, token)
if code != 200 {
t.Fatalf("progress %d", code)
}
// Two readable chapters, one of them read; the processed third was flipped to pending.
if progress.TotalChapters != 2 || progress.ReadChapters != 1 {
t.Fatalf("reading counts: %+v", progress)
}
// Word counts: one known, one ignored, two learning (the answered word advanced) and one
// still new; nothing is merged.
if progress.KnownTerms != 1 || progress.IgnoredTerms != 1 || progress.LearningTerms != 2 || progress.NewTerms != 1 || progress.SavedTerms != 5 {
t.Fatalf("word counts: %+v", progress)
}
// The due count is exactly the review queue's total, computed by the same query.
if _, queue = reviewQueue(t, r, token); progress.DueNow != queue.Total {
t.Fatalf("due now %d, queue total %d", progress.DueNow, queue.Total)
}
// Per book: one readable chapter, one read, and the book is the caller's own.
if len(progress.Books) != 1 || progress.Books[0].ID != chapters[0].BookID || progress.Books[0].TotalChapters != 2 || progress.Books[0].ReadChapters != 1 {
t.Fatalf("book progress: %+v", progress.Books)
}
if progress.Books[0].Title == "" {
t.Fatal("the book row needs a title")
}
// The numbers are the records: the same counting done straight against the tables agrees.
var readable, read int64
if err := db.Table("lexgo_chapters AS c").Joins("JOIN lexgo_books AS b ON b.id = c.book_id").
Where("c.owner_id = ? AND b.owner_id = ? AND b.language = ? AND c.status = ?", learner.UserId, learner.UserId, "en", statusReady).
Count(&readable).Error; err != nil {
t.Fatal(err)
}
if err := db.Table("lexgo_chapter_progress AS p").Joins("JOIN lexgo_chapters AS c ON c.id = p.chapter_id").
Where("p.owner_id = ? AND c.status = ? AND p.read_sha256 = c.content_sha256", learner.UserId, statusReady).
Count(&read).Error; err != nil {
t.Fatal(err)
}
if int64(progress.TotalChapters) != readable || int64(progress.ReadChapters) != read {
t.Fatalf("progress %d/%d, records %d/%d", progress.ReadChapters, progress.TotalChapters, read, readable)
}
// The counters follow the library: deleting a read chapter removes it from both sides.
if code, deleted := deleteChapter(t, r, token, chapters[0].ID); code != 200 || deleted.Remaining != 2 {
t.Fatalf("delete: %d %+v", code, deleted)
}
if code, after := learnerProgress(t, r, token); code != 200 || after.ReadChapters != 0 || after.TotalChapters != 1 {
t.Fatalf("progress after deletion: %d %+v", code, after)
}
// Another account counts only its own records, and a different study language does not pick
// up the English chapters of the same instance.
_, adminEngine, adminAccount := libraryFixture(t)
other := newLearner(t, adminEngine, adminAccount.Token)
if code, blank := learnerProgress(t, r, other.Token); code != 200 || blank.SavedTerms != 0 || blank.TotalChapters != 0 || blank.DueNow != 0 || len(blank.Books) != 0 {
t.Fatalf("another account must start empty: %d %+v", code, blank)
}
if err := db.Model(&Space{}).Where("owner_id = ?", other.ID).Update("language", "fr").Error; err != nil {
t.Fatal(err)
}
otherBook := Book{OwnerID: other.ID, Title: "Fictional French book", Language: "en", CreatedAt: stamp(clock), UpdatedAt: stamp(clock)}
if err := db.Create(&otherBook).Error; err != nil {
t.Fatal(err)
}
otherChapter := Chapter{BookID: otherBook.ID, OwnerID: other.ID, Ordinal: 1, Title: "Fictional English",
OriginalText: "Curiosity.\n", ContentSHA256: contentSHA("Curiosity.\n"), CharCount: 11, Status: statusReady,
CreatedAt: stamp(clock), UpdatedAt: stamp(clock)}
if err := db.Create(&otherChapter).Error; err != nil {
t.Fatal(err)
}
if code, isolated := learnerProgress(t, r, other.Token); code != 200 || isolated.TotalChapters != 0 || isolated.ReadChapters != 0 {
t.Fatalf("a French space must not count English chapters: %d %+v", code, isolated)
}
// The same account in English still sees its own numbers.
if code, mine := learnerProgress(t, r, token); code != 200 || mine.SavedTerms != 5 {
t.Fatalf("the owner keeps their own numbers: %d %+v", code, mine)
}
}
func deleteChapter(t *testing.T, r *gin.Engine, token string, chapterID int64) (int, DeletionResult) {
t.Helper()
code, data := callAPI(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, nil)
var payload struct {
Deleted DeletionResult
}
if data != nil {
json.Unmarshal(data, &payload)
}
return code, payload.Deleted
}
func editChapter(t *testing.T, r *gin.Engine, token string, chapterID int64, body map[string]string) (int, ChapterEdit) {
t.Helper()
code, data := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, body)
var edited ChapterEdit
if data != nil {
json.Unmarshal(data, &edited)
}
return code, edited
}
+16 -7
View File
@@ -80,6 +80,8 @@ type ReviewItem struct {
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
Kind string `json:"kind"`
WordCount int `json:"wordCount"`
DueAt time.Time `json:"dueAt"`
ReviewCount int `json:"reviewCount"`
}
@@ -162,22 +164,28 @@ func reviewItem(term Term, review TermReview) ReviewItem {
return ReviewItem{
ID: term.ID, Term: term.Term, OriginalForm: term.OriginalForm, Definition: term.Definition,
Examples: splitExamples(term.Examples), Status: term.Status, Level: term.Level,
Kind: termKind(term.Term), WordCount: termWordCount(term.Term),
DueAt: review.DueAt, ReviewCount: review.ReviewCount,
}
}
// dueTermsQuery is the single definition of "due now": a saved word or phrase that is still
// new or learning and whose scheduled instant is not in the future. The progress page counts
// the same predicate as the review queue, so the two numbers cannot drift apart.
func dueTermsQuery(tx *gorm.DB, owner int, language string, now time.Time) *gorm.DB {
return tx.Table("lexgo_terms AS t").
Joins("JOIN lexgo_term_reviews AS r ON r.term_id = t.id").
Where("t.owner_id = ? AND r.owner_id = ? AND t.language = ? AND r.language = ?", owner, owner, language, language).
Where("t.status IN ?", []string{termStatusNew, termStatusLearning}).
Where("r.due_at <= ?", stamp(now))
}
// ReviewQueueFor returns the caller's due words for the current language. Known and
// ignored words stay out of the queue, and the queue is an absolute-instant comparison:
// an item is due as soon as due_at is not in the future.
func ReviewQueueFor(tx *gorm.DB, owner int, language string, now time.Time) (ReviewQueue, error) {
queue := ReviewQueue{Items: []ReviewItem{}}
due := func() *gorm.DB {
return tx.Table("lexgo_terms AS t").
Joins("JOIN lexgo_term_reviews AS r ON r.term_id = t.id").
Where("t.owner_id = ? AND r.owner_id = ? AND t.language = ? AND r.language = ?", owner, owner, language, language).
Where("t.status IN ?", []string{termStatusNew, termStatusLearning}).
Where("r.due_at <= ?", stamp(now))
}
due := func() *gorm.DB { return dueTermsQuery(tx, owner, language, now) }
var total int64
if err := due().Count(&total).Error; err != nil {
return queue, err
@@ -203,6 +211,7 @@ func ReviewQueueFor(tx *gorm.DB, owner int, language string, now time.Time) (Rev
queue.Items = append(queue.Items, ReviewItem{
ID: row.ID, Term: row.Term, OriginalForm: row.OriginalForm, Definition: row.Definition,
Examples: splitExamples(row.Examples), Status: row.Status, Level: row.Level,
Kind: termKind(row.Term), WordCount: termWordCount(row.Term),
DueAt: row.DueAt, ReviewCount: row.ReviewCount,
})
}
+2 -2
View File
@@ -137,8 +137,8 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
status = 201
}
}
// Saving the same word again is an update of one record, not a new resource.
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/terms" {
// Saving the same entry again is an update of one record, not a new resource.
if c.Request.Method == "POST" && (c.FullPath() == "/api/v1/terms" || c.FullPath() == "/api/v1/phrases") {
if saved, ok := data.(TermSave); ok && saved.Created {
status = 201
}
+41 -4
View File
@@ -26,6 +26,13 @@ const (
termStatusIgnored = "ignored"
)
// One table holds both entries: a word and a phrase differ in kind and word count, and a
// phrase key always contains a space, so the identity keys cannot collide.
const (
termKindWord = "word"
termKindPhrase = "phrase"
)
const (
termFormLimit = 128
termDefinitionLimit = 2000
@@ -60,6 +67,23 @@ type Term struct {
UpdatedAt time.Time
}
// Kind and word count need no columns: a phrase key is the ordered normalized word forms
// joined by single spaces, and a word form can never contain a space, so the identity key
// itself carries both facts. One helper keeps that invariant in a single place.
func termKind(key string) string {
if strings.Contains(key, " ") {
return termKindPhrase
}
return termKindWord
}
func termWordCount(key string) int {
if !strings.Contains(key, " ") {
return 1
}
return len(strings.Split(key, " "))
}
func (Term) TableName() string { return "lexgo_terms" }
type TermView struct {
@@ -71,6 +95,8 @@ type TermView struct {
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
Kind string `json:"kind"`
WordCount int `json:"wordCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -97,7 +123,12 @@ func splitExamples(text string) []string {
}
func termView(t Term) TermView {
return TermView{t.ID, t.Language, t.Term, t.OriginalForm, t.Definition, splitExamples(t.Examples), t.Status, t.Level, t.CreatedAt, t.UpdatedAt}
return TermView{
ID: t.ID, Language: t.Language, Term: t.Term, OriginalForm: t.OriginalForm, Definition: t.Definition,
Examples: splitExamples(t.Examples), Status: t.Status, Level: t.Level,
Kind: termKind(t.Term), WordCount: termWordCount(t.Term),
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
}
}
// termLevel enforces the documented status/level boundary: only a learning entry carries a
@@ -215,10 +246,13 @@ func previousTerm(tx *gorm.DB, owner int, language, term string) (Term, bool, er
// saveTerm writes one identity with INSERT ... ON DUPLICATE KEY UPDATE: a repeated
// save updates the same row instead of adding a second, conflicting record, and
// two concurrent saves of the same word still leave exactly one.
func saveTerm(tx *gorm.DB, owner int, language, word string, fields TermFields, now time.Time) (TermSave, error) {
// saveTerm stores one identity: key is the canonical form used for lookup, display is what the
// learner sees. For a word both are the word form; for a phrase the key joins normalized word
// forms and the display keeps the original punctuation.
func saveTerm(tx *gorm.DB, owner int, language, key, display string, fields TermFields, now time.Time) (TermSave, error) {
when := stamp(now)
row := Term{
OwnerID: owner, Language: language, Term: normalizeWord(word), OriginalForm: word,
OwnerID: owner, Language: language, Term: key, OriginalForm: display,
Definition: fields.Definition, Examples: fields.Examples, Status: fields.Status, Level: fields.Level,
CreatedAt: when, UpdatedAt: when,
}
@@ -353,7 +387,7 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
Definition: definition, Examples: examples, Status: input.Status, Level: level,
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
}
return saveTerm(tx, u.UserId, language, word, fields, now())
return saveTerm(tx, u.UserId, language, normalizeWord(word), word, fields, now())
}))
v.GET("/terms/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -370,4 +404,7 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
}
return gin.H{"term": termView(term)}, nil
}))
registerPhraseRoutes(v, protect, now)
registerVocabularyRoutes(v, protect, now)
registerProgressRoutes(v, protect, now)
}
+213
View File
@@ -0,0 +1,213 @@
package lexgo
import (
"errors"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The vocabulary list reuses the audit list's paging contract: a whitelisted query, page >= 1,
// a bounded limit and a rejection instead of a silent default for anything malformed.
const (
maxVocabularyPageSize = 100
maxVocabularyPage = 1000000
maxVocabularyQuery = 64
)
type TermListFilter struct {
Query string
Status string
Kind string
Page int
Limit int
}
type TermListPage struct {
Items []TermView `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// escapeLike turns a value into a literal LIKE operand. Without it a query of "%" would match
// every entry, which is a wrong answer rather than a syntax error.
func escapeLike(value string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
}
func parseTermListQuery(q url.Values) (TermListFilter, error) {
f := TermListFilter{Page: 1, Limit: 20}
bad := failure(400, "词汇库筛选参数无效")
for key, values := range q {
if len(values) != 1 {
return f, bad
}
switch key {
case "query", "status", "kind", "page", "limit":
default:
return f, bad
}
}
for key, dest := range map[string]*int{"page": &f.Page, "limit": &f.Limit} {
if value := q.Get(key); value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 1 {
return f, bad
}
*dest = n
}
}
if f.Limit > maxVocabularyPageSize || f.Page > maxVocabularyPage {
return f, bad
}
if f.Status = q.Get("status"); f.Status != "" && !termStatuses[f.Status] {
return f, bad
}
if f.Kind = q.Get("kind"); f.Kind != "" && f.Kind != termKindWord && f.Kind != termKindPhrase {
return f, bad
}
f.Query = strings.TrimSpace(q.Get("query"))
if utf8.RuneCountInString(f.Query) > maxVocabularyQuery {
return f, bad
}
return f, nil
}
// ListTerms returns one page of the caller's own entries for one language. The identity key,
// the original spelling and the personal definition are all searched, and the key is compared
// lower case because that column uses a binary collation.
func ListTerms(tx *gorm.DB, owner int, language string, f TermListFilter) (TermListPage, error) {
query := func() *gorm.DB {
rows := tx.Model(&Term{}).Where("owner_id = ? AND language = ?", owner, language)
if f.Status != "" {
rows = rows.Where("status = ?", f.Status)
}
// A phrase key always contains a space and a word key never does.
if f.Kind == termKindWord {
rows = rows.Where("term NOT LIKE ?", "% %")
}
if f.Kind == termKindPhrase {
rows = rows.Where("term LIKE ?", "% %")
}
if f.Query != "" {
key := "%" + escapeLike(strings.ToLower(f.Query)) + "%"
text := "%" + escapeLike(f.Query) + "%"
rows = rows.Where("term LIKE ? OR original_form LIKE ? OR definition LIKE ?", key, text, text)
}
return rows
}
page := TermListPage{Items: []TermView{}, Page: f.Page, Limit: f.Limit}
var total int64
if err := query().Count(&total).Error; err != nil {
return page, err
}
page.Total = int(total)
var rows []Term
if err := query().Order("updated_at DESC, id DESC").
Offset((f.Page - 1) * f.Limit).Limit(f.Limit).Find(&rows).Error; err != nil {
return page, err
}
for _, row := range rows {
page.Items = append(page.Items, termView(row))
}
return page, nil
}
type TermUpdateInput struct {
Definition *string `json:"definition"`
Examples *[]string `json:"examples"`
Status *string `json:"status"`
Level *int `json:"level"`
}
// UpdateTerm edits one owned entry by id. Identity cannot be edited: the key, the original
// spelling and the kind stay as stored, so the entry keeps matching the same text. Only a real
// status or level change moves the review schedule, exactly like a save from the reader.
func UpdateTerm(tx *gorm.DB, owner int, termID int64, input TermUpdateInput, now time.Time) (TermView, error) {
if input.Definition == nil && input.Examples == nil && input.Status == nil && input.Level == nil {
return TermView{}, failure(400, "请选择要修改的内容")
}
var term Term
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", termID, owner).First(&term).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return TermView{}, failure(404, "词条不存在")
}
return TermView{}, err
}
definition, examples := term.Definition, splitExamples(term.Examples)
if input.Definition != nil {
definition = *input.Definition
}
if input.Examples != nil {
examples = *input.Examples
}
definition, exampleText, err := termContent(definition, examples)
if err != nil {
return TermView{}, err
}
status := term.Status
if input.Status != nil {
status = *input.Status
}
// A level on its own is validated against the status the entry already has.
level, err := termLevel(status, input.Level, term, true)
if err != nil {
return TermView{}, err
}
when := stamp(now)
updates := map[string]any{"definition": definition, "examples": exampleText, "updated_at": when}
reschedule := status != term.Status || level != term.Level
if reschedule {
updates["status"] = status
updates["level"] = level
}
if err := tx.Model(&Term{}).Where("id = ? AND owner_id = ?", term.ID, owner).Updates(updates).Error; err != nil {
return TermView{}, err
}
term.Definition, term.Examples, term.UpdatedAt = definition, exampleText, when
if reschedule {
term.Status, term.Level = status, level
}
if err := syncTermReview(tx, term, reschedule, now); err != nil {
return TermView{}, err
}
return termView(term), nil
}
func registerVocabularyRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.GET("/terms", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
filter, err := parseTermListQuery(c.Request.URL.Query())
if err != nil {
return nil, err
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return ListTerms(tx, u.UserId, language, filter)
}))
v.PATCH("/terms/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "词条不存在")
if err != nil {
return nil, err
}
var input TermUpdateInput
if err := decode(c, &input); err != nil {
return nil, err
}
term, err := UpdateTerm(tx, u.UserId, id, input, now())
if err != nil {
return nil, err
}
return gin.H{"term": term}, nil
}))
}
+285
View File
@@ -0,0 +1,285 @@
package lexgo
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func vocabularyList(t *testing.T, r *gin.Engine, token, query string) (int, TermListPage) {
t.Helper()
path := "/api/v1/terms"
if query != "" {
path += "?" + query
}
code, _, data := callRaw(t, r, "GET", path, token, nil)
var page TermListPage
if len(data) > 0 {
json.Unmarshal(data, &page)
}
return code, page
}
func editTerm(t *testing.T, r *gin.Engine, token string, termID int64, body map[string]any) (int, TermView) {
t.Helper()
code, _, data := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/terms/%d", termID), token, body)
var payload struct {
Term TermView
}
if len(data) > 0 {
json.Unmarshal(data, &payload)
}
return code, payload.Term
}
func savePhraseAPI(t *testing.T, r *gin.Engine, token string, chapterID int64, text string, words []string, status string, level *int) TermSave {
t.Helper()
tokens := phraseTokens(text)
start, end := spanOf(t, tokens, words...)
body := map[string]any{"chapterId": chapterID, "start": start, "end": end, "definition": "短语释义", "status": status}
if level != nil {
body["level"] = *level
}
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", token, body)
if code != 201 && code != 200 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
Created bool
}
json.Unmarshal(data, &saved)
return TermSave{Term: saved.Term, Created: saved.Created}
}
// TestMySQLVocabularyListSearchFiltersAndPaging covers the list contract end to end.
func TestMySQLVocabularyListSearchFiltersAndPaging(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "vocab-list-0001", "title": "Vocabulary list", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
take := saveWord(t, r, learner.Token, pasted.Chapter.ID, 0, 4, termStatusNew, nil)
level := 2
phrase := savePhraseAPI(t, r, learner.Token, pasted.Chapter.ID, phraseFixtureText, []string{"a", "small", "step"}, termStatusLearning, &level)
code, page := vocabularyList(t, r, learner.Token, "")
if code != 200 || page.Total != 2 || len(page.Items) != 2 || page.Page != 1 || page.Limit != 20 {
t.Fatalf("list: %d %+v", code, page)
}
// The most recently updated entry comes first, and each row carries its kind.
if page.Items[0].ID != phrase.Term.ID || page.Items[0].Kind != "phrase" || page.Items[0].WordCount != 3 {
t.Fatalf("order and kind: %+v", page.Items)
}
if page.Items[1].ID != take.Term.ID || page.Items[1].Kind != "word" || page.Items[1].WordCount != 1 {
t.Fatalf("word row: %+v", page.Items[1])
}
// Search matches the identity key, and case does not matter even though the key column is
// binary collated.
code, page = vocabularyList(t, r, learner.Token, url.Values{"query": {"SMALL"}}.Encode())
if code != 200 || page.Total != 1 || page.Items[0].ID != phrase.Term.ID {
t.Fatalf("search by key: %d %+v", code, page)
}
// Search matches the personal definition, including Chinese text.
code, page = vocabularyList(t, r, learner.Token, url.Values{"query": {"短语"}}.Encode())
if code != 200 || page.Total != 1 || page.Items[0].ID != phrase.Term.ID {
t.Fatalf("search by definition: %d %+v", code, page)
}
// Search matches the original spelling with its interior punctuation.
code, page = vocabularyList(t, r, learner.Token, url.Values{"query": {"a small, step"}}.Encode())
if code != 200 || page.Total != 1 {
t.Fatalf("search by original form: %d %+v", code, page)
}
// A wildcard is a literal, not a pattern.
for _, literal := range []string{"%", "_", "sma_ll"} {
code, page = vocabularyList(t, r, learner.Token, url.Values{"query": {literal}}.Encode())
if code != 200 || page.Total != 0 {
t.Fatalf("wildcard %q must not match: %d %+v", literal, code, page)
}
}
// Filters combine with search and with each other.
cases := []struct {
query string
want []int64
}{
{url.Values{"status": {termStatusNew}}.Encode(), []int64{take.Term.ID}},
{url.Values{"kind": {termKindPhrase}}.Encode(), []int64{phrase.Term.ID}},
{url.Values{"kind": {termKindWord}, "status": {termStatusLearning}}.Encode(), []int64{}},
{url.Values{"kind": {termKindPhrase}, "status": {termStatusLearning}, "query": {"small"}}.Encode(), []int64{phrase.Term.ID}},
{url.Values{"kind": {termKindPhrase}, "query": {"take"}}.Encode(), []int64{}},
}
for _, tc := range cases {
code, page = vocabularyList(t, r, learner.Token, tc.query)
got := make([]int64, 0, len(page.Items))
for _, item := range page.Items {
got = append(got, item.ID)
}
if code != 200 || len(got) != len(tc.want) {
t.Fatalf("%s -> %d %+v", tc.query, code, page)
}
for index := range tc.want {
if got[index] != tc.want[index] {
t.Fatalf("%s -> %+v, want %+v", tc.query, got, tc.want)
}
}
}
// Paging: one row per page, a page past the end is empty, and total stays the full count.
code, first := vocabularyList(t, r, learner.Token, "limit=1&page=1")
code2, second := vocabularyList(t, r, learner.Token, "limit=1&page=2")
code3, beyond := vocabularyList(t, r, learner.Token, "limit=1&page=9")
if code != 200 || code2 != 200 || code3 != 200 || first.Total != 2 || second.Total != 2 || beyond.Total != 2 {
t.Fatalf("paging totals: %+v %+v %+v", first, second, beyond)
}
if len(first.Items) != 1 || len(second.Items) != 1 || first.Items[0].ID == second.Items[0].ID {
t.Fatalf("paging pages: %+v %+v", first.Items, second.Items)
}
if len(beyond.Items) != 0 {
t.Fatalf("a page past the end must be empty: %+v", beyond.Items)
}
// Rejected parameters, and the caller's own entries only.
for _, query := range []string{"limit=101", "page=0", "status=deleted", "kind=other", "other=1", "page=1&page=2"} {
if code, _ := vocabularyList(t, r, learner.Token, query); code != 400 {
t.Fatalf("%s must be rejected, got %d", query, code)
}
}
if code, page := vocabularyList(t, r, other.Token, ""); code != 200 || page.Total != 0 {
t.Fatalf("another account must see nothing: %d %+v", code, page)
}
if code, _ := vocabularyList(t, r, "", ""); code != 401 {
t.Fatal("the list needs a session")
}
}
// TestMySQLVocabularyEditKeepsDomainRules covers editing by id: the same domain rules and the
// same schedule rule as a save from the reader.
func TestMySQLVocabularyEditKeepsDomainRules(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "vocab-edit-0001", "title": "Vocabulary edit", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
saved := saveWord(t, r, learner.Token, pasted.Chapter.ID, 0, 4, termStatusNew, nil)
termID := saved.Term.ID
before := termReviewRow(t, db, termID)
// Editing only the text keeps the schedule, the level and the counters untouched.
code, edited := editTerm(t, r, learner.Token, termID, map[string]any{"definition": "拿起;接受", "examples": []string{"Take a small step."}})
if code != 200 || edited.Definition != "拿起;接受" || len(edited.Examples) != 1 {
t.Fatalf("text edit: %d %+v", code, edited)
}
if edited.Level != 0 || edited.Status != termStatusNew || edited.Kind != "word" || edited.WordCount != 1 {
t.Fatalf("a text edit changed the state: %+v", edited)
}
after := termReviewRow(t, db, termID)
if !after.DueAt.Equal(before.DueAt) || after.ReviewCount != before.ReviewCount || after.CorrectCount != before.CorrectCount {
t.Fatalf("a text edit moved the schedule: %+v -> %+v", before, after)
}
// Entering learning with a level reschedules by that level's interval and leaves today's queue.
clock := time.Now().UTC()
code, learned := editTerm(t, r, learner.Token, termID, map[string]any{"status": termStatusLearning, "level": 5})
if code != 200 || learned.Level != 5 || learned.Status != termStatusLearning {
t.Fatalf("learning edit: %d %+v", code, learned)
}
after = termReviewRow(t, db, termID)
if after.DueAt.Before(clock.AddDate(0, 0, 15).Add(-time.Minute)) {
t.Fatalf("level 5 must schedule 15 days out: %v", after.DueAt)
}
if code, queue := reviewQueue(t, r, learner.Token); code != 200 || len(queue.Items) != 0 {
t.Fatalf("a scheduled entry must leave today's queue: %d %+v", code, queue.Items)
}
// A level without learning, and an unknown status, are refused by the shared rules.
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"status": termStatusKnown, "level": 3}); code != 400 {
t.Fatal("a level outside learning must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"status": "deleted"}); code != 400 {
t.Fatal("an unknown status must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"definition": strings.Repeat("a", termDefinitionLimit+1)}); code != 400 {
t.Fatal("an over-long definition must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"examples": []string{"1", "2", "3", "4", "5", "6"}}); code != 400 {
t.Fatal("too many examples must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"term": "forged"}); code != 400 {
t.Fatal("identity must not be editable")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"ownerId": 9}); code != 400 {
t.Fatal("an unknown field must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{}); code != 400 {
t.Fatal("an empty edit must be rejected")
}
// Marking it known takes it out of the queue; going back to a new word makes it due again.
if code, known := editTerm(t, r, learner.Token, termID, map[string]any{"status": termStatusKnown}); code != 200 || known.Level != 0 {
t.Fatalf("known edit: %d %+v", code, known)
}
if code, queue := reviewQueue(t, r, learner.Token); code != 200 || len(queue.Items) != 0 {
t.Fatalf("a known entry must stay out of the queue: %d %+v", code, queue.Items)
}
if code, fresh := editTerm(t, r, learner.Token, termID, map[string]any{"status": termStatusNew}); code != 200 || fresh.Level != 0 {
t.Fatalf("back to new: %d %+v", code, fresh)
}
if code, queue := reviewQueue(t, r, learner.Token); code != 200 || len(queue.Items) != 1 || queue.Items[0].ID != termID {
t.Fatalf("a new entry must be due at once: %d %+v", code, queue.Items)
}
// Answering once, then editing, keeps the history and does not re-count anything.
_, queue := reviewQueue(t, r, learner.Token)
if code, applied := answerReview(t, r, learner.Token, termID, answerBody("vocab-edit-answer-01", reviewGradeCorrect, queue.Items[0].DueAt)); code != 201 || applied.LevelAfter != 1 {
t.Fatalf("answer: %d %+v", code, applied)
}
counted := termReviewRow(t, db, termID)
if code, edited := editTerm(t, r, learner.Token, termID, map[string]any{"definition": "拿起(改过)"}); code != 200 || edited.Level != 1 {
t.Fatalf("edit after an answer: %d %+v", code, edited)
}
if now := termReviewRow(t, db, termID); now.ReviewCount != counted.ReviewCount || now.CorrectCount != counted.CorrectCount || !now.DueAt.Equal(counted.DueAt) {
t.Fatalf("an edit must not rewrite history: %+v -> %+v", counted, now)
}
// The same edit twice is answered the same way with no further side effects.
code, again := editTerm(t, r, learner.Token, termID, map[string]any{"definition": "拿起(改过)"})
if code != 200 || again.Definition != "拿起(改过)" || !termReviewRow(t, db, termID).DueAt.Equal(counted.DueAt) {
t.Fatalf("a repeated edit: %d %+v", code, again)
}
// Ownership and input shape.
if code, _ := editTerm(t, r, other.Token, termID, map[string]any{"definition": "stolen"}); code != 404 {
t.Fatal("another account must not edit this entry")
}
if code, _ := editTerm(t, r, learner.Token, 999999, map[string]any{"definition": "x"}); code != 404 {
t.Fatal("an unknown entry must be 404")
}
if code, _ := editTerm(t, r, "", termID, map[string]any{"definition": "x"}); code != 401 {
t.Fatal("editing needs a session")
}
// A phrase is edited through the same endpoint and keeps its kind and word count.
phraseLevel := 1
phrase := savePhraseAPI(t, r, learner.Token, pasted.Chapter.ID, phraseFixtureText, []string{"a", "small", "step"}, termStatusNew, nil)
code, editedPhrase := editTerm(t, r, learner.Token, phrase.Term.ID, map[string]any{"status": termStatusLearning, "level": phraseLevel, "definition": "一小步(改)"})
if code != 200 || editedPhrase.Kind != "phrase" || editedPhrase.WordCount != 3 || editedPhrase.Level != phraseLevel {
t.Fatalf("phrase edit: %d %+v", code, editedPhrase)
}
}