Compare commits

..
Author SHA1 Message Date
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
36 changed files with 2859 additions and 57 deletions
+3
View File
@@ -282,6 +282,9 @@ 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 用户确认 #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 转码、按空行自动分章与断点续传不在范围内。
+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;#10 编辑与删除本人书籍章节已通过用户验收,PR #29 已合入 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 完成章节与个人基础进度已实现,待用户验收(新增 schema v7 与显式迁移)。试用交付仍未实现。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、#10、#18 已验收,原规划中的 #5 已完成;剩余 #11~#15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#18 已验收,#13 已实现待验收,原规划中的 #5 已完成;剩余 #14、#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)
+10 -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: 08d992aee15b7fd5cea0cdbb4eb0b90583068449
synchronized_at: 2026-09-14T13:37:59Z
wiki_revision: 78760fdce51217ebd047a05207d327b4874691d2
synchronized_at: 2026-09-15T03:50:38Z
<!-- gitea-wiki-mirror:end -->
# LexGo 项目档案
@@ -162,3 +162,11 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必
## #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尚未实施。
+31 -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: cc1522fceb8e420cee17f509101f61986b3d7ff9
synchronized_at: 2026-09-14T14:21:32Z
wiki_revision: 9bc2de753de2249c586306cdc27b566052941da2
synchronized_at: 2026-09-15T04:31:24Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -306,7 +306,7 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
学习端 `BookView.vue` 增加书名编辑对话框、章节编辑对话框(标题 + 正文,正文来自 source 接口)与两处确认弹窗(`ElMessageBox`),章节行增加「编辑」入口;`LibraryView.vue` 在书库被删后显示「书籍已删除 · 已保存的生词和短语仍保留在生词本。」;`stores/library.ts` 增加 `renameBook`、`updateChapter`、`loadChapterSource`、`deleteBook`、`deleteChapter`。正文编辑通过浏览器 textarea 输入,因此该章的行尾统一为 LF(粘贴与 TXT 导入仍保留原始 CRLF)。
## #11 短语选择、保存与复习(2026-09-11)
## #11 短语选择、保存与复习(2026-09-11,已验收并合入 main)
短语与单词共用一张表和一套复习机制:`lexgo_terms` 的 `term` 列存身份键,**单词键不含空格、短语键以空格分隔**,所以「词或短语」不需要额外列,也不需要第二套排期/队列/作答逻辑。`kind` 与词数由身份键在服务端派生(`termKind`/`termWordCount`),视图与队列项随响应返回。
@@ -328,3 +328,31 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
| 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)
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` 写回章节列表与打开中的阅读器,不动其他字段。
+36 -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: fb3ce7506f1eb1940d7aa57226d22e4f8a270633
synchronized_at: 2026-09-14T14:21:32Z
wiki_revision: 67b43213bdbf87c5af03c324d40ccc1b37a11ea6
synchronized_at: 2026-09-15T04:31:24Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -242,3 +242,37 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
**复习**:短语进入同一个到期队列与同一套间隔表;卡片正面显示短语并把**整段短语挖成一个空**(例句里没有该短语时只显示短语本身),答案面显示个人释义;答对/答错/再学与幂等、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)。
+42 -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: 1643f05aa6987196d5dcc82a07e2daa07e2f2b4d
synchronized_at: 2026-09-14T14:21:32Z
wiki_revision: 8222610b19a406a74771ded2f3edfa94a69f6295
synchronized_at: 2026-09-15T04:31:24Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -434,3 +434,43 @@ node --test spikes/english/view.test.mjs
真实链路验证:真实 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 承接);阅读进度没有按书排序或搜索(进度页按书籍编号列出);没有大数据量下的统计性能压测(统计为带前缀索引的计数查询)。
+15 -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: 45077556545aceb54ad03ba7871e8d3614fe2e01
synchronized_at: 2026-09-14T14:21:33Z
wiki_revision: abf6629dfe44d838dd235b11543d9fdf94429899
synchronized_at: 2026-09-15T04:31:25Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -266,6 +266,18 @@ F01 的编辑与删除已于 2026-09-14 通过用户验收:学习端可改书
## #11 交付范围更新(2026-09-11)
F08 的短语学习与 F10 的短语复习已实现,待用户验收:阅读器支持连续选择(桌面原生拖选、手机系统手势、面板按词调整端点),保存个人释义、例句与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,同一短语跨章节只存一条记录并在出现处高亮;复习卡片把整段短语挖成一个空。范围规则(整词对齐、内部标点保留、最多 12 词、最左最长重叠)与失效引用回退(高亮消失、学习记录保留)已固化。本次没有数据库结构变化。
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 的完成阅读与基础进度已实现,待用户验收:阅读页提供显式「标记本章已读」(只记已读、不批量改变词语状态),书籍页章节列表显示已读,新的进度页面显示已读章节(分母只含可阅读章节)、待复习、已知词、学习中、新词、忽略与已保存词条数,以及每本书的已读进度;重复标记不重复计数,待复习与到期复习队列共用同一规则与时钟。正文改成新版本后该章回到未读并可在重读后更新同一记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移。
仍未实现并留给后续工单:桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24)。每日目标、日历、难度评分、统计导出明确不在范围内。另记录既有缺陷 #32(编辑章节正文回到曾用版本返回 500,已定位未修复,方案待用户确认)。
+7 -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: 08cde00c48a957705ea5b2a9d3055ff3788e64b8
synchronized_at: 2026-09-14T14:21:32Z
wiki_revision: 9ee88d82494e22e689086b053770c4f4620d3657
synchronized_at: 2026-09-15T04:31:22Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -79,4 +79,8 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#10 编辑与删除书籍章节已于 2026-09-14 通过用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化;旧处理结果不会覆盖新版本,PR #29 已 fast-forward-only 合入 main。
#11 短语选择、保存与复习已实现,待用户验收:在正文中连续选择一个范围(桌面原生拖选、手机系统手柄、面板端点按钮与 Shift 点击调整),保存个人释义与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,因此同一短语在不同章节只存一条记录并在出现的每处高亮,复习卡片把整段短语挖成一个空。短语与单词重叠时短语高亮覆盖、单词数据不变;编辑正文后短语不再出现时不报错,词条与复习排期保留。本次没有数据库结构变化。
#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 完成章节与个人基础进度已实现,待用户验收:阅读页正文下方提供显式的「标记本章已读」,只记录已读、不批量改变词语状态;重复标记返回同一条记录、不重复计数。书籍页章节列表显示「已读」,新的「进度」页面显示已读章节(只统计可阅读章节,形如「3 / 8 章(可阅读)」)、待复习、已知词、学习中、新词、忽略与已保存词条数,并按书显示已读进度;待复习与到期复习队列使用同一规则与同一时钟。正文被改成新版本后该章会回到未读(记录保留),重新标记即更新同一条记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移;开发库迁移前后各表计数一致。真实 API+MySQL 37 项检查与浏览器页面数字对照数据库直查全部一致。
+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')
})
+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)
})
+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)
})
})
+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('开始到期复习')
})
})
+21 -20
View File
@@ -1,7 +1,8 @@
<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
@@ -22,6 +23,9 @@ defineProps<{
const definition = defineModel<string>('definition', { required: true })
const examples = defineModel<string>('examples', { required: true })
const status = defineModel<TermStatus>('status', { required: true })
// 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)
@@ -74,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>
+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>
+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,
}
+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 }
})
+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,
}
})
+33
View File
@@ -73,6 +73,21 @@ h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
.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-read { background: #e7f0e9; border-color: #b9d3bf; color: #2c5b3d; }
/* 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: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; }
.chapter-complete .subtle { margin: 6px 0 0; }
.read-state { margin: 0; font-weight: 600; color: #2c5b3d; }
/* Progress page. */
.progress-stats { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); margin-top: 6px; }
.stat-card { background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 20px 22px; }
.stat-card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; color: #3d5b48; 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: #233d31; }
.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: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 16px 18px; }
/* Import form. */
.import-form { max-width: 720px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 28px; }
.field-label { display: block; font-size: 14px; margin-bottom: 10px; }
@@ -134,6 +149,22 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.review-page { max-width: 680px; }
.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 #dfe3d6; border-radius: 10px; background: #fffdf8; }
.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: #8b794e; 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 #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.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; }
@@ -166,6 +197,8 @@ 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; }
.chapter-complete { flex-direction: column; align-items: stretch; }
.chapter-complete .el-button { width: 100%; }
.reader-text { font-size: 16px; 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); }
+2 -1
View File
@@ -144,7 +144,7 @@ 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">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
@@ -182,6 +182,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)"
+1 -1
View File
@@ -127,7 +127,7 @@ 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">
<span class="account-name">{{ session.user.username }}</span>
</div>
+1 -1
View File
@@ -38,7 +38,7 @@ 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">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
+97
View File
@@ -0,0 +1,97 @@
<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'
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">
<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>
+37 -1
View File
@@ -97,6 +97,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
@@ -119,7 +142,7 @@ onUnmounted(() => library.closeChapter())
<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">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
@@ -195,6 +218,19 @@ onUnmounted(() => library.closeChapter())
<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="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>
+1 -1
View File
@@ -30,7 +30,7 @@ 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">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
+207
View File
@@ -0,0 +1,207 @@
<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 { 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">
<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 -1
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,7 +99,26 @@ 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`,
}
var schemaV6Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_term_reviews (
+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", ""},
+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
}
+12 -7
View File
@@ -169,18 +169,23 @@ func reviewItem(term Term, review TermReview) ReviewItem {
}
}
// 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
+2
View File
@@ -405,4 +405,6 @@ 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)
}
}