Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e069894c4e | ||
|
|
5f86f17ac4 | ||
|
|
a9952b3516 | ||
|
|
f2de395cd9 | ||
|
|
000d7e0dbf | ||
|
|
3ebec5b11b | ||
|
|
329ea7336a | ||
|
|
a658fa6fd1 | ||
|
|
b1b76cfb7c | ||
|
|
bd77aa0a46 | ||
|
|
1319c56cf8 |
@@ -282,6 +282,11 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
|
||||
- #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。
|
||||
- 2026-09-11 用户确认正式 NLP/词典采用全 Go。#5 已验收并合入 main;#6 使用 Go WordNet 解析和词形候选、Go Unicode 原文分片、schema v4 共享词典资源表,不调用 Python NLP。WordNet 3.0 ZIP 来源与摘要见 `server/wordnet-resource.json`,许可保留在 `server/WORDNET-LICENSE.txt`。词形候选不等于上下文消歧,不自动合并个人学习状态;#3 Python 小样只保留历史验证。当前词典仅英语释义,个人释义输入为临时草稿,持久化归 #7。
|
||||
- 2026-09-11 用户确认 #7 个人词条口径(三项由 Agent 定案):身份为「学习者+语言+规范化词形」,大小写合并但**不按 lemma/候选合并**(`dog` 与 `dogs` 是两条记录);首次保存默认「新词」;状态为 新词/学习中/已知/忽略,只有「学习中」带 1~7 级,对应原版 stage 2/1/0/-1~-7;例句只保存手输内容,不自动关联原文句子。schema v5 新增 `lexgo_terms`(唯一键加状态/等级检查约束),个人释义与共享词典分离且不进入审计日志;等级编辑 UI 归 #8/#12。
|
||||
- 2026-09-15 用户确认 #14 显示与键盘口径:`theme ∈ {浅色,深色,跟随系统}`(默认跟随系统)与正文字号 `{标准,大,特大}`(1.0/1.15/1.3)**按账号保存在本机** `lexgo-learner-display:<账号 id>`,切换账号即换成该账号偏好或默认,退出回到默认,**不跨设备同步**;字号经 `--reader-font-scale` 只作用于阅读面(正文、释义内容、复习卡),不做全局缩放;深色用 `html[data-theme]` + Element Plus 的 `html.dark`,`style.css` 的 `:root` 是文件内仅有的颜色字面量;阅读位置按账号+章节保存滚动比例与该章 `content_sha256`,**正文换新版本后不恢复**;复习页 `空格`/`Enter` 显示答案、`1/2/3` 评分,输入类控件与聚焦按钮的按键不被劫持,带修饰键不拦截;移动验证用 390×844+`hasTouch` 的 Playwright `mobile` 项目(桌面项目 `testIgnore: mobile-*`),**真机长按选择与手感仍需人工确认**,不得用模拟设备结果冒充真机。本单无 schema 与接口变化。
|
||||
- 2026-09-15 用户确认 #13 完成阅读与进度口径:`POST /api/v1/chapters/:id/complete` 只记已读、**不批量改变词语状态或等级**,只有 `ready` 章节可标记(其他 409),重复调用返回同一行且带 `duplicate`(不移动时间、不重复计数);完成记录保存标记时的 `content_sha256`,**正文新版本后该章回到未读**(记录保留,重读后更新同一行),只改标题不影响,章节删除随外键级联;`GET /api/v1/progress` 统计只含本人与当前语言,已读与分母都只算可阅读(`ready`)章节,已知/学习中/新词/忽略分开计数,`dueNow` 与到期复习队列共用 `dueTermsQuery` 与同一服务端时钟;schema 升到 v7(新表 `lexgo_chapter_progress`,不用 ALTER TABLE),需显式 migrate。不做每日目标、日历、难度评分、统计导出与取消已读,也不做 X10 批量标已知。
|
||||
- 2026-09-15 既有缺陷记录:#32 编辑章节正文回到曾经用过的版本返回 500(`lexgo_ingest_jobs` 请求键与 `edit:<章节>:<内容摘要>` 冲突),已定位未修复,方案待用户确认;#13 的真实验证脚本因此自建 fixture 而不改写既有正文。
|
||||
- 2026-09-11 用户确认 #12 词汇库口径:`GET /api/v1/terms` 列表同时匹配规范化身份键、显示原文与个人释义,大小写不敏感(`term` 为二进制排序规则,查询先转小写),`%`/`_`/`\` 按字面值转义,`status` 与 `kind` 可与搜索组合;分页 `page ≥ 1`、`limit ≤ 100`(默认 20)、按最近更新倒序;`PATCH /api/v1/terms/:id` 复用同一套释义/例句与状态/等级校验,**身份不可编辑**,只有状态或等级变化才重排复习时间,历史作答记录与计数保留;筛选与页码写入 `/vocab` URL。原型编辑页的「来自某章节」**不实现**(词条按身份存储、不引用章节);CSV(X04)、复习范围筛选(X11)、删除与批量清理(X14)不在范围内。
|
||||
- 2026-09-11 用户确认 #11 短语口径:短语与单词**共用 `lexgo_terms`**,身份键为按阅读顺序的规范化词形以单个空格连接(单词键不含空格),因此 `kind` 与词数由身份键派生,**不新增列、无迁移**;范围两端对齐整词、内部标点与换行保留但不参与身份比较,2~12 词、键 ≤128 字符、片段 ≤191 字符,切进单词的范围 400。跨章节匹配按连续词形比对,重叠取**最左最长**;短语高亮覆盖内部单词但**不修改单词数据**,点击已保存短语优先打开短语面板;失效引用回退=高亮消失但词条与复习排期保留。短语进同一到期队列与同一套幂等作答,复习卡把整段短语挖成一个空;选择用原生拖选与手机系统手柄,不拦截 touchmove。同义形式合并、词性消歧、跨书移动、批量编辑(#12)与真机手感不在范围内。
|
||||
- 2026-09-11 用户确认 #10 编辑与删除口径:可改名、改章节标题、编辑章节正文;**只有正文变化才重新处理**,重复保存或改回原内容不新建任务,只改标题不改状态。版本键是 `content_sha256`:任务只在与章节版本一致时才能影响章节,过期版本任务标为 `error_reason=superseded` 且**完全不触碰章节**(认领、恢复扫描、重试都按版本裁决);存储文本重算 SHA 与存储 SHA 不一致时按 `content_changed` 失败。删除为事务内硬删除 + 外键级联,删章后重排序号;个人词条、复习排期与作答记录**不随删除清理**。回收站/撤销、批量操作、章节跨书移动与语言变更不在范围内。编辑器行尾归一为 LF 是已知边界。
|
||||
- 2026-09-11 用户确认 #9 TXT 导入口径:只接受 UTF-8(允许可选 BOM,解码时剥离且不进入原文),非法字节整体拒绝、不使用替换字符;UTF-16 按 BOM 识别后明确拒绝,GB18030 等按非法 UTF-8 拒绝。文件字节上限 2 MiB,之后仍套用单章 100000 码点上限;换行与空白不归一化。文件只在内存中解码、不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有 `PasteBook`/`PasteChapter`,分章(一次提交一章)、`requestId` 幂等与任务恢复与粘贴一致;不改 schema。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在范围内。
|
||||
- 2026-09-11 用户确认 #8 到期单词复习决策表:固定间隔表 1/2/4/7/15/30/60 天,答对升级封顶 7、答错降级最低 1、再学一次不改等级,答错与再学立即回队;已知/忽略不入队,新保存的词立即到期,显式「学习中 level N」排 now+间隔[N];只有新建或状态/等级实际变化才移动复习时间,编辑释义或例句保留原排期,保存未提及等级时保留已获得等级。到期判定用 UTC 绝对时刻(`due_at ≤ now`),不引入本地日边界。作答按 `answerId` 去重并以 `expectedDueAt` 判定过期标签页,重复提交、网络重发与双标签页都不得重复更新次数与间隔(作答响应 `result` 只取 applied/stale,重放另用 `duplicate` 标记并返回首次结果);`correct_count` 只计答对,`wrong_count` 计答错与再学。短语复习归 #11,进度统计归 #13,不做策略配置 UI(X11)、练习模式(X08)与 FSRS。
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
面向自托管场景的阅读式语言学习项目,规划提供内容导入、阅读查词、词汇与短语、复习和实例管理。
|
||||
|
||||
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main;#7 个人词条与学习状态已通过用户验收,PR #26 已合入 main;#8 到期单词复习已通过用户验收,PR #27 已合入 main;#9 上传 TXT、校验编码后导入本人书库已通过用户验收,PR #28 已合入 main。短语、进度与词汇库仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
|
||||
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main;#7 个人词条与学习状态已通过用户验收,PR #26 已合入 main;#8 到期单词复习已通过用户验收,PR #27 已合入 main;#9 上传 TXT、校验编码后导入本人书库已通过用户验收,PR #28 已合入 main;#10 编辑与删除本人书籍章节已通过用户验收,PR #29 已合入 main;#11 连续短语的选择、保存与复习已通过用户验收,PR #30 已合入 main;#12 词汇库的搜索、筛选与编辑已通过用户验收,PR #31 已合入 main。#13 完成章节与个人基础进度已通过用户验收,PR #33 已合入 main(schema v7 需显式迁移)。#14 桌面与手机体验、主题与键盘操作已实现,待用户验收。试用交付仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
|
||||
|
||||
- [文档入口](docs/README.md) · [线上 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Home)
|
||||
- [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口)
|
||||
- [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留)
|
||||
- [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md)
|
||||
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#18 已验收,原规划中的 #5 已完成;剩余 #10~#15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
|
||||
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#18 已验收,#14 已实现待验收,原规划中的 #5 已完成;剩余 #15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
|
||||
- [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。
|
||||
- [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。
|
||||
- [工作流](docs/01-workflow.md) · [开发与验证](docs/04-local-development-and-verification.md)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.-
|
||||
wiki_revision: b85c7f4650aae95b1429c0b4f7d1412c440731ce
|
||||
synchronized_at: 2026-09-13T14:36:36Z
|
||||
wiki_revision: 0c0354499b49ef5973ffcdc47e9eba40f447536b
|
||||
synchronized_at: 2026-09-15T06:30:56Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 项目档案
|
||||
@@ -158,3 +158,19 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必
|
||||
## #9 当前工程状态(2026-09-13)
|
||||
|
||||
#8已通过用户验收并合入main。#9 TXT文件导入已按用户确认的契约实现,并于2026-09-13通过用户验收,PR #28已fast-forward-only合入main;本单没有数据库结构变化,schema仍为v6。解码只接受UTF-8(可选BOM剥离且不进原文),非法字节整体拒绝、不使用替换字符,UTF-16按BOM识别后明确拒绝;文件字节上限2MiB并仍受单章100000码点约束;换行与空白不归一化。文件只在内存中解码,不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有PasteBook/PasteChapter,分章、任务幂等与恢复与粘贴一致。第4阶段已完成#9;剩余#10~#15与#21、#24尚未实施。
|
||||
|
||||
## #10 当前工程状态(2026-09-14)
|
||||
|
||||
#9已通过用户验收并合入main。#10编辑与删除书籍章节已按用户确认的契约实现,并于2026-09-14通过用户验收,PR #29已fast-forward-only合入main;本单没有数据库结构变化,schema仍为v6。可改书名、章节标题与章节正文;只有正文变化才重新处理,重复保存或改回原内容不新建任务,只改标题不改变处理状态。版本门控按content_sha256执行:过期版本任务被标为superseded且完全不触碰章节,认领、恢复扫描与重试都按版本裁决,存储文本重算SHA与存储SHA不一致时按content_changed失败——这修掉了「处理中编辑导致新版本被标失败」的既有缺陷。删除为事务内硬删除加外键级联,删章后重排序号;个人词条、复习排期与作答记录不随删除清理。第4阶段已完成#9与#10;剩余#11~#15与#21、#24尚未实施。
|
||||
|
||||
## #11 当前工程状态(2026-09-14)
|
||||
|
||||
#10已通过用户验收并合入main。#11连续短语的选择、保存与复习已按用户确认的契约实现,并于2026-09-14通过用户验收,PR #30已fast-forward-only合入main。用户在验收前明确确认了D3的存储方式:短语与单词共用lexgo_terms,单词键不含空格、短语键以空格分隔,因此kind与词数由身份键在服务端派生,**不新增列、无迁移**,schema仍为v6;唯一键、排期表、到期队列与作答幂等路径全部复用。范围规则为两端对齐整词、内部标点与换行保留、2~12词、键≤128字符、片段≤191字符;跨章节按连续词形匹配,重叠取最左最长,短语高亮覆盖内部单词但不修改单词数据;失效引用回退为高亮消失而条目与排期保留;选择使用原生拖选与手机系统手柄,不拦截touchmove。独立审核(Claude Code)判定代码达标并指出一处流程问题(先实现后确认数据结构变更),已在评论7912记录确认与整改。第4阶段已完成#9、#10与#11;剩余#12~#15与#21、#24尚未实施。
|
||||
|
||||
## #12 当前工程状态(2026-09-15)
|
||||
|
||||
#11已通过用户验收并合入main。#12词汇库的搜索、筛选与编辑已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #31已fast-forward-only合入main。本单没有数据库结构变化,schema仍为v6。`GET /api/v1/terms`提供本人当前语言的分页列表,搜索同时匹配规范化身份键、显示原文与个人释义且大小写不敏感(term列为二进制排序规则,查询先转小写),通配符按字面值转义,状态与类型可与搜索组合,分页默认20、上限100并按最近更新倒序;`PATCH /api/v1/terms/:id`复用同一套释义/例句与状态/等级校验,身份不可编辑,只有状态或等级变化才重排复习时间,历史作答记录与计数保留。学习端新增生词本页面,含搜索、筛选、分页、空态、无结果清除与编辑对话框(学习中显示1~7等级选择器),筛选与页码写入URL;表单字段抽成共享组件与阅读器面板复用。原型编辑页的「来自某章节」不实现,因为词条按身份存储、不引用章节。第4阶段已完成#9、#10、#11与#12;剩余#13~#15与#21、#24尚未实施。
|
||||
|
||||
## #13 当前工程状态(2026-09-15)
|
||||
|
||||
#12已通过用户验收并合入main。#13章节完成与个人基础进度已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #33已fast-forward-only合入main。schema升到v7,新增lexgo_chapter_progress(一章一行、按content_sha256做内容版本门控、级联到章节与账号),仍然只使用可重放的CREATE TABLE IF NOT EXISTS,迁移需显式执行。POST /api/v1/chapters/:id/complete只记已读、不改变任何词语状态或等级,只有处理完成的章节可标记,重复调用返回同一条记录且不重复计数;GET /api/v1/progress按本人与当前语言统计已读章节(分子与分母都只含可阅读章节)、已知、学习中、新词、忽略、已保存词条与待复习,并给出每本书的已读进度,其中待复习与到期复习队列共用同一查询与同一服务端时钟。学习端新增进度页面与阅读页的显式「标记本章已读」,书籍页显示已读标记。正文改成新版本后该章自动回到未读并可在重读后更新同一条记录,删除章节时记录随之消失。第4阶段已完成#9、#10、#11、#12与#13;剩余#14、#15与#21、#24尚未实施。此外发现并单独记录了既有缺陷#32(编辑章节正文回到曾用版本返回500,已定位未修复,修复方案待用户确认)。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: ce95236e4c8de27c1063448571cfc528c895f937
|
||||
synchronized_at: 2026-09-13T15:20:16Z
|
||||
wiki_revision: a4fd3873678017ea046a6e708141d7f8140edcef
|
||||
synchronized_at: 2026-09-15T07:11:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -288,7 +288,7 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
|
||||
|
||||
学习端 `ImportView.vue` 增加「粘贴文本 / TXT 文件」来源切换(沿用已验收 v1 的切换与状态行),`stores/library.ts` 增加 `upload()` 与 `fileProblem`/`fileSizeLabel`,`session.request` 支持 `FormData`(multipart 请求不再被 JSON 化,边界由浏览器提供)。客户端预检只提前反馈,服务端结论为最终结论。
|
||||
|
||||
## #10 编辑与删除书籍章节(2026-09-11)
|
||||
## #10 编辑与删除书籍章节(2026-09-11,已验收并合入 main)
|
||||
|
||||
`server/app/lexgo/edit.go` 提供改名、编辑与删除;`ingest.go` 增加版本门控。**schema 无变化**:`chapters.content_sha256` 与 `jobs.content_sha256` 就是版本键,新增的 `superseded` 复用现有 `error_reason` 列。
|
||||
|
||||
@@ -305,3 +305,69 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
|
||||
`unprocessableReason` 保留一条内容一致性检查:存储的正文重新计算出的 SHA 必须等于该章节存储的 SHA,用于兜住绕过 API 的直接写入(`content_changed`),与版本门控互不重复。
|
||||
|
||||
学习端 `BookView.vue` 增加书名编辑对话框、章节编辑对话框(标题 + 正文,正文来自 source 接口)与两处确认弹窗(`ElMessageBox`),章节行增加「编辑」入口;`LibraryView.vue` 在书库被删后显示「书籍已删除 · 已保存的生词和短语仍保留在生词本。」;`stores/library.ts` 增加 `renameBook`、`updateChapter`、`loadChapterSource`、`deleteBook`、`deleteChapter`。正文编辑通过浏览器 textarea 输入,因此该章的行尾统一为 LF(粘贴与 TXT 导入仍保留原始 CRLF)。
|
||||
|
||||
## #11 短语选择、保存与复习(2026-09-11,已验收并合入 main)
|
||||
|
||||
短语与单词共用一张表和一套复习机制:`lexgo_terms` 的 `term` 列存身份键,**单词键不含空格、短语键以空格分隔**,所以「词或短语」不需要额外列,也不需要第二套排期/队列/作答逻辑。`kind` 与词数由身份键在服务端派生(`termKind`/`termWordCount`),视图与队列项随响应返回。
|
||||
|
||||
`server/app/lexgo/phrase.go`:
|
||||
|
||||
| 部分 | 职责 |
|
||||
|---|---|
|
||||
| `phraseWords` | 从本人 ready 章节的 token 里取完全落在选区内的词;**切进单词的范围直接 400**,不静默丢弃;2~12 个词 |
|
||||
| `phraseKey` / `phraseSource` | 身份键=按顺序的规范化词形以单个空格连接;显示片段=选区原文(内部标点与换行保留) |
|
||||
| `phraseMatches` | 跨章节匹配:按首词分组后顺序比对词形,候选按 (起点, 长度降序, id) 排序并取**最左最长**的互不重叠集合 |
|
||||
| `phrasesForChapter` | 按 `term LIKE '% %'` 取本人短语并匹配,供 tokens 响应使用 |
|
||||
| `SavePhrase` | 由服务端推导身份后走与单词相同的 `saveTerm` upsert;`kind` 冲突返回 409 |
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| POST /api/v1/phrases | `{chapterId,start,end,definition,examples[],status,level?}`;本人 ready 章节;服务端推导词序列与身份,不接受客户端身份;首次 201、重复 200 |
|
||||
| GET /api/v1/terms/:id | 复用;响应增加 `kind` 与 `wordCount` |
|
||||
| GET /api/v1/chapters/:id/tokens | 响应增加 `phrases:[{id,status,wordCount,startToken,endToken}]` |
|
||||
| GET /api/v1/reviews/queue | 队列项增加 `kind` 与 `wordCount`;短语与单词同一队列、同一作答接口 |
|
||||
|
||||
学习端:`composables/readerRange.ts` 是纯函数层(整词对齐、内部保留、端点按词调整、命中优先级、区间换算),`composables/useTextSelection.ts` 监听 `selectionchange`(100ms 去抖)与 document 的 `pointerup` 读取浏览器原生选区并映射为 token 索引,**不拦截 touchmove、不 preventDefault**;`ReaderTokens.vue` 为每个 token 输出 `data-token-index` 与短语区间样式;`ReaderView.vue` 负责把选区变成短语、shift 点击扩展、以及面板端点调整;`LookupPanel.vue` 增加短语标题与四个端点按钮;复习卡用 `maskedPrompt` 把整段短语挖成一个空。已保存短语点击优先打开短语面板,单词数据不受影响。
|
||||
|
||||
## #12 词汇库:搜索、筛选与编辑(2026-09-11,已验收并合入 main)
|
||||
|
||||
`server/app/lexgo/vocabulary.go` 提供只读列表与按 id 编辑,**不改 schema**(沿用 #11 的「身份键派生 kind/词数」)。
|
||||
|
||||
| 接口 | 权限与输入/输出 |
|
||||
|---|---|
|
||||
| GET /api/v1/terms | 本人+当前语言;`query`、`status`、`kind`、`page`、`limit`;返回 `{items,total,page,limit}`;默认 `page=1`、`limit=20`,上限 100;排序 `updated_at DESC, id DESC` |
|
||||
| PATCH /api/v1/terms/:id | 本人;`{definition?,examples?,status?,level?}`;返回 `{term}`;身份字段不接受输入 |
|
||||
|
||||
参数校验沿用审计列表的既有约定:查询键白名单、重复键与未知键 400、`page ≥ 1`、`limit ≤ 100`、枚举值非法 400。搜索同时匹配规范化身份键、显示原文与个人释义;因为 `term` 使用 `utf8mb4_bin`,查询会先转小写再比较;`%`、`_`、`\` 经 `escapeLike` 转义后作为字面值,避免 `%` 命中全部。`kind` 通过键是否含空格判断(`term NOT LIKE '% %'` / `term LIKE '% %'`)。
|
||||
|
||||
编辑复用同一套领域规则:`termContent` 校验释义与例句上限、`termLevel` 校验状态与等级边界(只有 `learning` 带 1~7 级),只有状态或等级变化才调用 `syncTermReview` 重排(`reschedule=false` 时只补建缺失的排期行),因此**只改文本不动排期**、历史作答记录与计数保留。
|
||||
|
||||
学习端 `stores/vocabulary.ts` 保存列表状态与筛选(打开/关闭编辑对话框、从阅读器返回都不会丢),`views/VocabView.vue` 提供搜索框与显式搜索按钮、状态与类型筛选、分页、空态与「没有匹配的词条 + 清除搜索与筛选」,并把筛选与页码同步到 `/vocab?...`(`router.replace`)。释义/例句/状态/等级四个字段抽成 `components/TermFormFields.vue`,与阅读器面板共用;等级选择器只在词汇库编辑对话框中出现(阅读器面板仍只有四个状态)。
|
||||
|
||||
## #13 章节完成与个人基础进度(2026-09-15,已验收并合入 main)
|
||||
|
||||
schema v7 新增 `lexgo_chapter_progress`:一章一行(`chapter_id` 主键),带 `owner_id`、`book_id`、`language`、`read_sha256`(标记时该章 `content_sha256` 的快照)、`read_at` 与时间戳,外键级联到章节、书籍与账号。仍然只加新表,不用 `ALTER TABLE ADD COLUMN`,保持每个版本 DDL 可重放。
|
||||
|
||||
| 接口 | 权限与输入/输出 |
|
||||
|---|---|
|
||||
| POST /api/v1/chapters/:id/complete | 本人;只有 `ready` 章节可标记,否则 409;重复调用返回同一行并带 `duplicate`;返回 `{progress:{chapterId,bookId,read,readAt,duplicate}}` |
|
||||
| GET /api/v1/progress | 本人当前语言;返回 `{readChapters,totalChapters,knownTerms,learningTerms,newTerms,ignoredTerms,savedTerms,dueNow,books[]}` |
|
||||
|
||||
`server/app/lexgo/progress.go` 集中实现:`CompleteChapter`(事务内锁定章节,缺行则插入,内容相同则原样返回,内容不同则把同一行推进到新版本)、`readAtByChapter`(批量取章节的已读时间,只认可「标记快照=当前内容」且章节 `ready`)、`LearnerProgressFor`(阅读分母与分子都只算 `ready` 章节,词条按四种状态分组,`dueNow` 复用 `dueTermsQuery`)。`review.go` 抽出 `dueTermsQuery`,到期复习队列和进度页共用同一谓词与同一服务端时钟,避免两处漂移。`ChapterSummary` 增加 `readAt` 字段,`BookDetail`、`ChapterDetail` 与章节编辑响应都会解析它,所以列表、阅读器和编辑后的状态一致。
|
||||
|
||||
学习端新增 `/progress` 页面与导航「进度」(`stores/progress.ts`、`views/ProgressView.vue`):已读章节、待复习、已知词、学习中、新词、忽略六张卡片,加每本书的已读进度与进入书籍的链接、已保存词条总数、刷新与失败重试、无书时的引导。阅读器正文下方新增显式的「标记本章已读」区块(`ReaderView.vue`,`data-testid="mark-read"`),显示已读时间与重复标记提示;书籍页章节列表对已读章节显示「已读」标记(`BookView.vue`)。`stores/library.ts` 增加 `markChapterRead`,只把 `readAt` 写回章节列表与打开中的阅读器,不动其他字段。
|
||||
|
||||
## #14 桌面与手机体验、主题与键盘(2026-09-15)
|
||||
|
||||
**没有 schema 变化、没有新接口**:本单只改学习端与样式。事实先行:用带触摸的 390×844 移动视口逐个打开登录/书库/导入/书籍/阅读/查词面板/生词本/复习/进度,`scrollWidth` 全部等于 `innerWidth`,没有溢出元素——前几单的响应式基础成立,本单把它固化成自动化移动测试,并补上缺失的偏好与键盘操作。
|
||||
|
||||
| 新增文件 | 职责 |
|
||||
|---|---|
|
||||
| `learner/src/stores/preferences.ts` | 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大);按账号存在 `lexgo-learner-display:<账号 id>` |
|
||||
| `learner/src/components/DisplaySettings.vue` | 站点头部统一的「显示」下拉,展示当前值并可键盘操作 |
|
||||
| `learner/src/composables/readingPosition.ts` | 按账号+章节保存滚动比例与该章 `content_sha256` |
|
||||
| `learner/src/composables/reviewShortcuts.ts` | 复习页键盘映射与「不抢输入框」的判断 |
|
||||
|
||||
`style.css` 收敛为一套语义调色板:`:root` 里 53 个变量是**文件内仅有的颜色字面量**,其余规则全部走 `var(...)`;`html[data-theme='dark']` 覆盖同一批变量,`html.dark` 同时映射 Element Plus 的暗色变量(`main.ts` 引入 `element-plus/theme-chalk/dark/css-vars.css`),所以页面与组件跟随同一个选择。字号经 `--reader-font-scale` 只作用于阅读面(章节正文、释义面板内容、复习卡的词与释义),不做全局缩放。`ReaderView.vue` 在章节就绪后把正文下方的位置恢复到上次比例(正文版本变化则不恢复,等待可滚动后再应用,最多约 1 秒后放弃),并在滚动时按 400ms 防抖保存。`ReviewView.vue` 加了 `useReviewShortcuts` 与一行快捷键提示;`DisplaySettings` 出现在七个学习页面的头部。
|
||||
|
||||
移动测试项目:`playwright.config.ts` 增加 `mobile`(390×844,`isMobile` + `hasTouch`,Chromium),桌面项目用 `testIgnore` 排除 `mobile-*.spec.ts`,移动项目用 `testMatch` 只跑它们。`e2e/mobile-fixtures.ts` 提供移动端共用的 mock、无溢出检查与经 CDP `Input.dispatchTouchEvent` 的真实触摸滑动。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 96eec89f74db744d8807baa24b1d1d611ff6bf8f
|
||||
synchronized_at: 2026-09-13T15:20:16Z
|
||||
wiki_revision: 5a44b1713b3fd2374baa78314872539f3e2e23ab
|
||||
synchronized_at: 2026-09-15T07:11:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -226,3 +226,67 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
|
||||
**归属与边界**:改名、编辑、删除、读取编辑用原文都严格按会话归属;他人资源与不存在资源统一 404,未登录 401,空标题/空正文/超长文本/未知字段 400。学习端正文编辑框的行尾会统一为 LF(浏览器 textarea 行为),粘贴与 TXT 导入路径仍然保留原始 CRLF 与空白。
|
||||
|
||||
**范围边界**:不做封面与音频附件(#21)、不做回收站/撤销、不做批量操作、不做章节跨书移动、不做语言变更。
|
||||
|
||||
## #11 短语规则(2026-09-11)
|
||||
|
||||
**身份**:短语身份=按阅读顺序的规范化词形以单个空格连接(`a small, step` 与 `a small step` 都是 `a small step`),归属 `(owner, language)`;显示片段保存最近一次保存时的原文(内部标点、换行、多余空格原样保留,仅用于显示)。与单词共用同一张表,因此同一短语在不同章节保存只会得到一条记录,也共用同一幂等键与同一复习排期。
|
||||
|
||||
**范围**:两端对齐整词;首尾若落在空白或标点则跳过;**内部**标点与换行保留但不参与身份比较;不切开代理对、ZWJ 与组合字符。选区内少于 2 个词不是短语(单个词走单词面板),超过 12 个词、身份键超过 128 字符或原文片段超过 191 字符都返回 400 并给出可读提示。切进单词中间的范围被拒绝,而不是静默丢弃那个词。
|
||||
|
||||
**跨章节匹配**:在章节的词片段序列中找**连续词**,其规范化词形逐个相等(中间允许任意标点与空白)。因此编辑正文后:短语仍出现则继续高亮;不再出现则该章不高亮,但**词条与复习排期保留**;章节被删除同样保留。短语不存章节锚点,所以不存在悬空引用。
|
||||
|
||||
**重叠与点击**:同一位置多个候选按 (起点升序, 长度降序) 取互不重叠者,即「最左最长」,结果与输入顺序无关。短语高亮覆盖其内部的单词高亮,但**不修改单词数据**(状态、排期、计数都保留);点击命中规则是「在已保存短语范围内 → 打开短语面板,否则打开单词面板」。保存后的高亮立即出现,不需要重新加载分词。
|
||||
|
||||
**选择交互**:桌面用浏览器原生拖选(`selectionchange` 去抖 + `pointerup`),手机依赖系统选择手柄且**不拦截 touchmove**,面板提供起点/终点四个按钮按词调整,Shift 点击可把范围从一个词扩展到一个词,Escape 取消并保留阅读位置。单击(折叠选区)仍然是单词查询。真实手感属于运行验证范围,真机证据缺口保留。
|
||||
|
||||
**复习**:短语进入同一个到期队列与同一套间隔表;卡片正面显示短语并把**整段短语挖成一个空**(例句里没有该短语时只显示短语本身),答案面显示个人释义;答对/答错/再学与幂等、stale 规则与单词完全一致;计数归属也一致(`correct_count` 只计答对,`wrong_count` 计答错与再学)。
|
||||
|
||||
**范围边界**:不做短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑(#12)、真机手柄精细手感。短语的例句同样是手输内容,不自动关联原文句子。
|
||||
|
||||
## #12 词汇库规则(2026-09-11)
|
||||
|
||||
**列表范围**:只返回当前会话本人、当前语言(英语空间)的词条,单词与短语都在同一列表里;任何查询都不接受用户或语言参数,越权编号与不存在编号同样得到 404。
|
||||
|
||||
**搜索规则**:搜索词去首尾空白后与**规范化身份键**、**显示原文**、**个人释义**三处做子串匹配,任一命中即返回。大小写不敏感:`term` 列是二进制排序规则,因此查询先转小写再比较;`original_form` 与 `definition` 用默认排序规则。不做分词、不做前缀优化。空查询=不过滤;查询超过 64 个字符返回 400。`%`、`_`、`\` 按字面值处理(转义后传入),所以搜索 `%` 得到的是包含百分号的词条,而不是全部词条。
|
||||
|
||||
**筛选与分页**:`status`(新词/学习中/已知/忽略)与 `kind`(词语/短语)可单独或组合使用,并可与搜索组合;非法枚举值 400,不静默忽略。分页为 `page`(从 1 开始)与 `limit`(默认 20,最大 100),按最近更新倒序排列,返回的 `total` 是过滤后的总数;超出末页返回空列表但 `total` 不变。筛选与页码写入 URL,刷新或返回列表都保留。
|
||||
|
||||
**编辑规则**:编辑按词条编号进行,沿用与阅读器保存相同的领域校验——释义最长 2000 字符、例句最多 5 条且每条最长 500 字符、只有「学习中」可以带 1~7 级、其他状态等级必须为 0。**身份不可编辑**:词形、原文与「词或短语」不由客户端决定,未知字段一律 400,所以编辑不会把一条记录变成另一条。只有状态或等级真正变化时才移动复习时间(进入「学习中」按该等级的间隔排期,改回「新词」立即到期,改成「已知」或「忽略」后不再进入到期队列);只改释义或例句保留原排期。编辑不改写历史:作答次数、答对与答错计数、复习记录都保留;同一内容重复提交没有额外副作用。
|
||||
|
||||
**界面契约**:列表行显示「原文 / 个人释义 · 状态」(短语标注「短语」并显示等级),提供编辑入口与「开始到期复习」入口;编辑对话框在「学习中」时显示 1~7 等级选择器,其他状态不显示;保存失败保留输入与对话框。空列表提示去阅读保存,「没有匹配的词条」提供一键清除搜索与筛选。
|
||||
|
||||
**已知偏差与边界**:原型的编辑页顶部有「来自 A small step」(来源章节),本单**不实现**——词条按身份存储、不引用章节(#10/#11 已确认规则),因此没有可显示的来源。不包含 CSV 导入导出(X04)、复杂复习范围筛选(X11)、删除词条或批量清理(X14),也不做批量操作。
|
||||
|
||||
## #13 完成阅读与统计规则(2026-09-15)
|
||||
|
||||
**完成阅读只记已读**:标记章节已读只写一条完成记录,**不批量改变任何词语的状态或等级**(批量标已知是 X10,明确不做)。只有处理完成的章节可以标记;处理中或失败的章节返回 409,因为那不是可读过的内容。标记是显式操作,滚动到底不会自动标记。
|
||||
|
||||
**重复完成不重复计数**:一章一行(`chapter_id` 主键)。重复请求返回同一行并带 `duplicate=true`,**不会移动已读时间也不会增加计数**;同一内容再次标记等同于第一次的结果。
|
||||
|
||||
**内容版本门控**:完成记录保存标记时该章的 `content_sha256`。阅读统计只认可「记录快照=章节当前内容」且章节 `ready` 的章节,因此:
|
||||
|
||||
- 正文被改成新版本后,该章立刻回到未读;重新读完再标记即更新同一行,不新增第二行。
|
||||
- 只改标题不影响已读;把正文改回与记录相同的内容后重新标记即可恢复。
|
||||
- 章节被删除时完成记录随外键级联消失,计数同时从分子与分母减去。
|
||||
|
||||
**统计口径**(全部按本人与本人当前语言过滤,管理员权限不放宽归属):
|
||||
|
||||
- **已读章节** = 内容匹配且 `ready` 的章节数;**分母** = 同一范围内 `ready` 的章节数,处理中或失败的章节不计入分母,界面显示为「已读章节 3 / 8 章(可阅读)」,把规则显示出来。
|
||||
- **已知 / 学习中 / 新词 / 忽略**分别计数,不合并;忽略单独统计,不计入已知。**已保存词条**为本人该语言的全部词条(含忽略)。
|
||||
- **待复习**使用与到期复习队列完全相同的谓词(状态为新词或学习中、且 `due_at` 不晚于当前服务端时钟)与同一时钟,页面数字与复习队列 `total` 一致。
|
||||
|
||||
**范围边界**:不含每日目标、日历、连续天数、难度评分;不含统计导出;不做「取消已读」(正文版本变化会自然回到未读);不包含复习范围筛选(X11)与练习模式(X08)。
|
||||
|
||||
## #14 显示偏好、阅读位置与键盘操作规则(2026-09-15)
|
||||
|
||||
**主题**:可选浅色、深色、跟随系统,默认跟随系统。显式选择不受系统变化影响;选择「跟随系统」时监听系统配色变化并实时切换。深色只改变颜色变量,不改变布局与功能。
|
||||
|
||||
**正文字号**:三档「标准 / 大 / 特大」(1.0 / 1.15 / 1.3 倍),作用于阅读面(章节正文、释义面板正文与例句、复习卡的词与释义)。这是有意的边界:不做全局 UI 缩放,避免窄屏下导航与按钮被挤坏。
|
||||
|
||||
**偏好的保存与账号隔离**:主题与字号保存在本机,键包含账号 id,因此**切换账号会立刻换成该账号自己的偏好或默认值,A 的选择不会带给 B**;未登录的登录页使用系统默认;退出登录后回到默认。**不跨设备同步**(范围外)。存储值读不出、不是合法取值或来自旧格式时回退到默认,而不是部分套用或抛错。
|
||||
|
||||
**保留阅读位置**:按「账号+章节」保存滚动比例,并记录当时的 `content_sha256`。再次打开同一章时恢复到该比例;**正文被改成新版本后不恢复**(旧偏移指向别的文字),同账号的其他章节互不影响。位置为 0(还没滚动)不保存;比例按窗口宽度换算,所以在桌面与手机上都能落在相近位置。本机行为,不跨设备。
|
||||
|
||||
**键盘操作**:复习页 `空格`/`Enter` 显示答案,`1` 认识/答对、`2` 不认识/答错、`3` 再学一次;界面给出快捷键提示,并保持「显示答案」与「答对」按钮自动获得焦点,键盘用户可以全程不碰鼠标。按键规则:**输入框、文本域、下拉与可编辑区域内的任何按键都不触发快捷键**;按下的键若属于当前聚焦控件(按钮与链接的 `空格`/`Enter`)则交给浏览器处理,避免重复触发;带修饰键的组合键一律不拦截。阅读器 `Esc` 关闭释义面板,面板内的 `Esc` 只关面板。
|
||||
|
||||
**触摸与滚动**:没有任何阻止默认行为的触摸处理;释义面板在窄屏是底部固定面板,页面为其预留底部空间,关闭后阅读位置不跳动。**长按原生选择与手柄拖动的手感仍需真机确认**,本单的触摸证据来自模拟设备。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 251fb5de71b8ba75da3cba6eee41454d5bbd22df
|
||||
synchronized_at: 2026-09-13T15:20:16Z
|
||||
wiki_revision: e6255c24e7b966573a9851b1e2356e370d3daef1
|
||||
synchronized_at: 2026-09-15T07:11:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -416,3 +416,80 @@ node --test spikes/english/view.test.mjs
|
||||
真实链路验证:真实 Go API+真实 MySQL 共 36 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行清理 fixture),覆盖改名、编辑产生新版本并重新处理、重复保存幂等、删除章节重排序号、删除书籍级联、个人词条与复习队列在删除后保留、越权与非法输入拒绝;随后用临时 Playwright 用例在真实学习端完成「导入→改名→编辑正文→新版就绪→删除章节→删除书籍→书库提示」的闭环,并核对阅读器原文等于新版本。截图保存在本机 `.local/evidence/`(issue10-book-after-edit.png、issue10-chapter-deleted.png、issue10-book-deleted.png),临时用例运行后删除。
|
||||
|
||||
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。并发只覆盖「同一章节并发删除」与「处理中编辑」两类,没有做多用户压力测试。浏览器 textarea 会把该章的 CRLF 归一为 LF,属已知边界,已记入业务规则页。
|
||||
|
||||
## #11 验证与迁移(2026-09-11)
|
||||
|
||||
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。**本单不新增数据库列或表**,schema 保持 v6,没有迁移步骤,回退只需换回旧二进制。
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `go vet ./...` | 通过 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 64 个顶层用例全部通过、0 跳过;含 #11 新增 5 个短语用例 |
|
||||
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 106 项单测、类型检查、构建、11 项 E2E 全部通过(含 #11 新增 12 单测与 4 项 E2E) |
|
||||
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
|
||||
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
|
||||
|
||||
覆盖内容:身份键的标点/换行/大小写/弯撇号归一、词数上限(12 与 13)、身份键与片段长度上限、切进单词被拒绝(服务端)、单词语义不被当成短语、跨章节匹配(同一短语在不同章节写法不同仍是同一条记录,两章都高亮,第一次出现两次)、最左最长与输入顺序无关、短语覆盖内部单词而不改动单词记录、短语进同一到期队列并作答(201 应用、重复提交回放)、越权 404 与未登录 401、编辑正文后不再出现则不报错且词条与排期保留、删除章节后词条保留;前端纯函数层覆盖整词对齐、内部保留、端点按词调整不反向、命中优先级与区间换算;E2E 覆盖**真实鼠标拖选**(Chromium 真实输入)→ 短语面板 → 保存 → 两次出现同时高亮 → 端点调整 → 点击已保存短语 → 复习整段挖空。
|
||||
|
||||
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程,脚本自建两章 fixture 并在结束前删除书籍)。覆盖保存与身份键、显示原文保留标点、跨章节同一条记录与两处高亮、span 两端必须是词、短语内的单词仍是独立词条、单词/切词/伪造身份/越权/未登录的拒绝、到期队列与作答(含重复提交回放)、编辑正文后不再高亮但条目与排期保留、删除章节后条目保留。随后用临时 Playwright 用例在真实学习端完成「导入 → 选择范围 → 保存短语 → 两处高亮 → 点击已保存短语 → 复习整段挖空 → 清理 fixture」闭环,并用程序化选区在同一真实页面上验证范围映射与服务端身份一致。
|
||||
|
||||
未验证与已知限制:真实手机手柄与滚动的手感仍是 #4 起的既有缺口,本单只用桌面浏览器检查。**Playwright 的合成鼠标拖拽在真实页面上不会扩展原生选区**(在同一浏览器里对 mock 页面是成功的,程序化选区在真实页面也能唤起面板),因此真实链路的范围构建改用真实点击 + Shift 点击,连续拖选由 mock E2E 与程序化选区覆盖;真人鼠标拖选与真机手柄仍需人工复核。短语只在同一学习者与语言内匹配,不跨账号共享。
|
||||
|
||||
## #12 验证与迁移(2026-09-11)
|
||||
|
||||
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。**本单不新增数据库列或表**,schema 保持 v6,没有迁移步骤,回退只需换回旧二进制。
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `go vet ./...` | 通过 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 69 个顶层用例全部通过、0 跳过;含 #12 新增 4 个用例 |
|
||||
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 120 项单测、类型检查、构建、13 项 E2E 全部通过(新增 13 单测与 2 项 E2E) |
|
||||
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
|
||||
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
|
||||
|
||||
覆盖内容:查询解析与边界(未知键、重复键、`page=0`、`limit=101`、非法状态与类型、查询超长)、LIKE 通配符转义、空查询与去空白、三处命中(身份键/显示原文/中文释义)、大小写不敏感、搜索与状态/类型筛选的组合、分页(首/末/越界页与 `total` 稳定、排序稳定)、只返回本人与当前语言、编辑(只改文本不动排期、改状态重排、进入学习中按等级排期、已知或忽略立刻离开到期队列、改回新词立即到期、计数与作答记录保留、同内容重复提交无副作用)、等级与状态边界、身份不可编辑、空编辑与未知字段 400、越权 404 与未登录 401;前端覆盖列表与分页、搜索与筛选(含 URL 恢复)、空态与无结果清除、编辑对话框的等级选择器、保存失败保留输入、旧响应丢弃。
|
||||
|
||||
真实链路验证:真实 Go API+真实 MySQL 共 40 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行恢复 fixture),覆盖列表字段与排序、大小写不敏感搜索、中文释义搜索、通配符字面值、类型与状态筛选组合、分页与越界页、参数拒绝、两账号隔离、未登录、编辑的排期规则与身份不可编辑。随后用临时 Playwright 用例在真实学习端完成「进入生词本 → 按类型筛选 → 搜索 → 编辑释义 → 保存 → 刷新后筛选仍保留 → 还原释义」闭环。截图保存在本机 `.local/evidence/`(issue12-vocab-list.png、issue12-vocab-none.png、issue12-vocab-edit.png、issue12-real-list.png、issue12-real-search.png、issue12-real-edit.png),临时用例运行后删除。
|
||||
|
||||
未验证:真实手机触屏与滚动证据仍属 #4/#11 起的既有缺口(由 #14 承接);本单只用桌面浏览器检查。搜索只做子串匹配,未做分词或相关度排序;大词汇量的性能未做专门压测(单页上限 100,列表查询使用 `owner_id + language` 前缀的既有索引)。
|
||||
|
||||
## #13 迁移与验证(2026-09-15)
|
||||
|
||||
本单新增 schema v7(`lexgo_chapter_progress`),需要**显式**迁移:`python scripts/server.py migrate`。迁移只创建一张新表,不修改既有表与数据;回退时把 `lexgo_schema.version` 写回 6 并丢弃该表即可,业务数据不受影响(本机回退二进制保存在忽略的 `.local/lexgo-pre-issue13.exe`)。
|
||||
|
||||
开发库 lexgo_dev 迁移前后逐表计数一致(`sys_user` 6、`lexgo_books` 27、`lexgo_chapters` 33、`lexgo_ingest_jobs` 42、`lexgo_terms` 12、`lexgo_term_reviews` 12、`lexgo_review_answers` 55、`lexgo_dictionaries` 1、`lexgo_login_logs` 137、`lexgo_operation_logs` 5、`lexgo_sessions` 28),schema 6→7,快照见 `.local/issue13-before.json` 与 `.local/issue13-after.json`。
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `go vet ./...` | 通过 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | 70 个顶层用例全部通过、0 跳过(基线 67;本单新增 3 个用例) |
|
||||
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 130 项单测、类型检查、构建、15 项 E2E 通过(本单新增 10 单测与 2 项 E2E) |
|
||||
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
|
||||
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
|
||||
|
||||
覆盖内容:完成章节的幂等(第二次不移动时间、只有一行)、非 ready 章节 409、他人与不存在 404、未登录 401、阅读与阅读器/书籍列表的已读状态一致、正文新版本后回到未读(记录行保留、更新时间)、只改标题保持已读、章节删除后计数与记录同时消失、已知/忽略/学习中/新词分别计数、已保存词条总数、`dueNow` 与复习队列 `total` 一致、每本书的已读进度、另一账号与另一语言不串数据、迁移 v6→v7 与「回滚标记后重新升级」、新表一章一行的唯一性与级联删除。
|
||||
|
||||
真实链路验证:真实 Go API 与真实 MySQL 共 37 项检查通过(`.local/verify-issue13-api.py`,可重复运行,自建 fixture 书籍并在结尾删除),其中页面数字与 SQL 直查逐项比对(已达到/总可读章节、待复习、四种词条状态、已保存总数)全部相等;随后用临时 Playwright 用例在真实学习端完成「粘贴章节 → 处理完成 → 标记已读 → 书籍列表显示已读 → 进度页数字 → 刷新后一致 → 再次标记不重复计数」,并用 `.local/tmp/check-issue13-page.py` 把浏览器上显示的数字与数据库逐项比对(全部一致)后清理 fixture。截图保存在本机 `.local/evidence/`(issue13-reader-unread/read、issue13-progress 为 mock 流程;issue13-real-* 为真实链路),临时用例运行后删除。
|
||||
|
||||
**本单顺带记录一个既有缺陷**:编辑章节正文回到曾经用过的版本会返回 500(#32,`lexgo_ingest_jobs` 唯一请求键与 `edit:<章节>:<内容摘要>` 键冲突),不属于本单范围,未修改代码;真实验证脚本因此改为自建 fixture 而不改写既有正文。
|
||||
|
||||
未验证:真实手机触屏与滚动证据仍属既有缺口(#14 承接);阅读进度没有按书排序或搜索(进度页按书籍编号列出);没有大数据量下的统计性能压测(统计为带前缀索引的计数查询)。
|
||||
|
||||
## #14 移动与主题验证(2026-09-15)
|
||||
|
||||
本单**无 schema 变化、无迁移**,纯学习端与文档改动;回退只需回到上一提交。
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 147 项单测、类型检查、构建、**23 项 E2E**(桌面 19 + 移动 4)全部通过;本单新增 20 单测与 8 项 E2E |
|
||||
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端无改动 |
|
||||
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | 70 项通过(后端未改动,仍复跑一遍确认) |
|
||||
|
||||
**移动项目**:`npx playwright test --project=mobile` 使用 390×844、`isMobile` 与 `hasTouch` 的 Chromium。覆盖内容:八个界面(书库/导入/书籍/阅读/查词面板/生词本/复习/进度)在 390px 下 `scrollWidth ≤ innerWidth` 且没有越界元素、每页都能返回书库、显示控件可用手指点开且高度不小于 36px、经 CDP 触摸事件的上滑真的滚动了章节且没有误开释义面板、面板不越过视口且页面为它预留底部空间、关闭面板后阅读位置不跳、深色与特大字号在刷新后保持、退出登录回到默认且同一账号回来仍是自己的选择。
|
||||
|
||||
**主题回归测试**:`e2e/theme.spec.ts` 逐个读回 16 个调色板变量,断言浅色与深色下都解析成非空值、页面背景不是透明、正文与背景不同色、深浅两套取值不同、Element Plus 的 `--el-bg-color` 也跟随。这条用例来自本单的一个真实教训:批量把颜色字面量替换为变量时,调色板自身的定义也被替换成了自引用变量,所有颜色静默失效——几何断言全过,只有焦点环探针把问题暴露出来,所以补了这条变量级回归。
|
||||
|
||||
**真实链路**:真实学习端+真实 Go API+真实 MySQL 走了一遍——粘贴 40 行长章节并处理完成,滚到 45% 后离开再返回,位置被恢复(偏差 < 150px,页面提示「已回到上次阅读位置」);切到深色+特大后刷新仍保持,正文实际字号变大(> 18px);在真实到期卡上按空格显示了答案(**故意不评分**,避免改动 fixture 账号的真实复习计划);另用 390×844 触摸上下文在真实数据上检查了书库/生词本/进度无横向溢出。fixture 书籍在结束时删除;截图见 `.local/evidence/issue14-real-*.png`、`.local/evidence/issue14-mobile-*.png`、`issue14-theme-*.png`、`issue14-keyboard-review.png`。临时用例运行后删除。
|
||||
|
||||
**未验证**:真实手机上的长按选择、手柄拖动、滚动惯性、软键盘遮挡与安全区表现无法由本机自动化产生——本单的触摸证据来自模拟设备;**真机验证仍需人工完成**,这一点在 #4、#11 就已记录,本单未能消除。也未做 Safari/Firefox 等其他引擎的验证(项目既有测试只在 Chromium 上跑)。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: 82d714b949d38f20586fbe42797b2dec6b0f19f1
|
||||
synchronized_at: 2026-09-13T15:20:17Z
|
||||
wiki_revision: 912089b338bf3c978add09069e39e7fb19c94240
|
||||
synchronized_at: 2026-09-15T07:11:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -260,6 +260,30 @@ F03 的 TXT 文件导入已于 2026-09-13 通过用户验收:学习端导入
|
||||
|
||||
## #10 交付范围更新(2026-09-11)
|
||||
|
||||
F01 的编辑与删除已实现,待用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
|
||||
F01 的编辑与删除已于 2026-09-14 通过用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
|
||||
|
||||
仍未实现并留给后续工单:短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。封面与音频附件(#21)、回收站/撤销、批量操作、章节跨书移动与语言变更不在本单范围。
|
||||
|
||||
## #11 交付范围更新(2026-09-11)
|
||||
|
||||
F08 的短语学习与 F10 的短语复习已于 2026-09-14 通过用户验收:阅读器支持连续选择(桌面原生拖选、手机系统手势、面板按词调整端点),保存个人释义、例句与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,同一短语跨章节只存一条记录并在出现处高亮;复习卡片把整段短语挖成一个空。范围规则(整词对齐、内部标点保留、最多 12 词、最左最长重叠)与失效引用回退(高亮消失、学习记录保留)已固化。本次没有数据库结构变化。
|
||||
|
||||
仍未实现并留给后续工单:词汇库搜索与编辑(#12,含短语编辑界面)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑与真机手柄精细手感不在本单范围。
|
||||
|
||||
## #12 交付范围更新(2026-09-11)
|
||||
|
||||
F09 的词汇库已于 2026-09-15 通过用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、显示原文或个人释义,大小写不敏感、通配符按字面值)与状态/类型筛选,可编辑释义、例句、状态与 1~7 学习等级,提供「开始到期复习」入口、空态与「没有匹配的词条 + 清除搜索与筛选」。编辑沿用与阅读器相同的领域校验与排期规则:身份不可编辑、只有状态或等级变化才移动复习时间、历史作答记录与计数保留;筛选与页码写入 URL,刷新或返回列表都保留。原型编辑页的「来自某章节」不实现(词条按身份存储、不引用章节)。本次没有数据库结构变化。
|
||||
|
||||
仍未实现并留给后续工单:阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24)。CSV 导入导出(X04)、复杂复习范围筛选(X11)、删除词条或批量清理(X14)不在本轮范围。
|
||||
|
||||
## #13 交付范围更新(2026-09-15)
|
||||
|
||||
F11 的完成阅读与基础进度已于 2026-09-15 通过用户验收:阅读页提供显式「标记本章已读」(只记已读、不批量改变词语状态),书籍页章节列表显示已读,新的进度页面显示已读章节(分母只含可阅读章节)、待复习、已知词、学习中、新词、忽略与已保存词条数,以及每本书的已读进度;重复标记不重复计数,待复习与到期复习队列共用同一规则与时钟。正文改成新版本后该章回到未读并可在重读后更新同一记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移。
|
||||
|
||||
仍未实现并留给后续工单:桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24)。每日目标、日历、难度评分、统计导出明确不在范围内。另记录既有缺陷 #32(编辑章节正文回到曾用版本返回 500,已定位未修复,方案待用户确认)。
|
||||
|
||||
## #14 交付范围更新(2026-09-15)
|
||||
|
||||
F12 的电脑与手机基础体验已实现,待用户验收:新增「显示」设置(浅色/深色/跟随系统;正文标准/大/特大),偏好按账号保存在本机、账号切换不串、不跨设备同步;阅读位置按账号与章节保留,正文换新版本后不套用旧位置;复习页支持空格显示答案与 1/2/3 评分,输入框内不误触全局快捷键,焦点环可见;手机端以 390×844 触摸视口验证八个界面无横向溢出、均可返回、触摸滑动可滚动且不误开释义面板。本次无数据库与接口变化。
|
||||
|
||||
仍未实现并留给后续工单:自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24),以及已定位未修复的缺陷 #32。PWA、离线能力、自定义配色、全局 UI 缩放与偏好跨设备同步明确不在范围内。**真实手机的长按选择与操作手感仍需人工确认**,本单的触摸证据来自模拟设备。
|
||||
|
||||
+11
-3
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
|
||||
wiki_revision: ea264da6747bd141ec2d67011830320452563415
|
||||
synchronized_at: 2026-09-13T15:20:16Z
|
||||
wiki_revision: 591ce1b3b16af9401062af925ba159ebdc4b8c6a
|
||||
synchronized_at: 2026-09-15T07:11:01Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 文档入口
|
||||
@@ -77,4 +77,12 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
|
||||
|
||||
#9 TXT 文件导入已于 2026-09-13 通过用户验收:导入页新增「粘贴文本 / TXT 文件」来源切换,只接受 UTF-8(允许可选 BOM)且不替换损坏字符,UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;上传与粘贴共用同一分章、任务与幂等规则,重复上传同一文件只产生一章。本次没有数据库结构变化;PR #28 已 fast-forward-only 合入 main。
|
||||
|
||||
#10 编辑与删除书籍章节已实现,待用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
|
||||
#10 编辑与删除书籍章节已于 2026-09-14 通过用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化;旧处理结果不会覆盖新版本,PR #29 已 fast-forward-only 合入 main。
|
||||
|
||||
#11 短语选择、保存与复习已于 2026-09-14 通过用户验收:在正文中连续选择一个范围(桌面原生拖选、手机系统手柄、面板端点按钮与 Shift 点击调整),保存个人释义与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,因此同一短语在不同章节只存一条记录并在出现的每处高亮,复习卡片把整段短语挖成一个空。短语与单词重叠时短语高亮覆盖、单词数据不变;编辑正文后短语不再出现时不报错,词条与复习排期保留。本次没有数据库结构变化;用户确认 kind 与词数由身份键派生,PR #30 已 fast-forward-only 合入 main。
|
||||
|
||||
#12 词汇库的搜索、筛选与编辑已于 2026-09-15 通过用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、原文或个人释义,大小写不敏感)与状态/类型筛选,并可编辑释义、例句、状态与 1~7 学习等级。编辑沿用与阅读器相同的领域规则:身份不可改、只有状态或等级变化才重排复习时间、历史作答记录保留;筛选与页码写入 URL,刷新或返回列表都保留。原型的「来自某章节」不实现,因为词条按身份存储、不引用章节。本次没有数据库结构变化;PR #31 已 fast-forward-only 合入 main。
|
||||
|
||||
#13 完成章节与个人基础进度已于 2026-09-15 通过用户验收:阅读页正文下方提供显式的「标记本章已读」,只记录已读、不批量改变词语状态;重复标记返回同一条记录、不重复计数。书籍页章节列表显示「已读」,新的「进度」页面显示已读章节(只统计可阅读章节,形如「3 / 8 章(可阅读)」)、待复习、已知词、学习中、新词、忽略与已保存词条数,并按书显示已读进度;待复习与到期复习队列使用同一规则与同一时钟。正文被改成新版本后该章会回到未读(记录保留),重新标记即更新同一条记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移;开发库迁移前后各表计数一致。真实 API+MySQL 37 项检查与浏览器页面数字对照数据库直查全部一致;PR #33 已 fast-forward-only 合入 main。
|
||||
|
||||
#14 桌面与手机体验、主题与键盘操作已实现,待用户验收:新增「显示」设置(浅色/深色/跟随系统,正文标准/大/特大),偏好按账号保存在本机,切换账号不会串;阅读位置按账号与章节保留,正文换成新版本后不再套用旧位置;复习页支持空格显示答案、1/2/3 评分,输入框内不会误触快捷键,所有可交互元素有可见焦点环。手机端用 390×844 触摸视口验证八个界面无横向溢出、都能返回、触摸滑动滚动而不误开释义面板,深色与特大字号刷新后保持。本次没有数据库与接口变化。真实手机的长按选择与手感仍需人工确认。
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
const user = { id: 42, username: 'fictional-keyboard', role: 'learner' }
|
||||
const item = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-01-02T00:00:00Z', reviewCount: 0 }
|
||||
|
||||
async function mockApi(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
const path = url.pathname
|
||||
const method = route.request().method()
|
||||
let data: unknown = null
|
||||
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
|
||||
else if (path === '/api/v1/me') data = user
|
||||
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
|
||||
else if (path === '/api/v1/books') data = { items: [] }
|
||||
else if (path === '/api/v1/reviews/queue') data = { items: [item], total: 1 }
|
||||
else if (path.endsWith('/answers') && method === 'POST') {
|
||||
data = { result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning', levelBefore: 0, levelAfter: 1, dueAtBefore: item.dueAt, dueAtAfter: '2026-01-09T00:00:00Z', item }
|
||||
} else if (path === '/api/v1/terms' && method === 'GET') {
|
||||
data = { items: [{ ...item, updatedAt: '2026-01-02T00:00:00Z' }], total: 1, page: 1, limit: 20 }
|
||||
}
|
||||
await route.fulfill({ json: { code: 200, data } })
|
||||
})
|
||||
}
|
||||
|
||||
/** Presses Tab until the target holds the focus, so reachability is what is being tested. */
|
||||
async function tabUntilFocused(page: Page, target: ReturnType<Page['getByTestId']>, limit = 15): Promise<boolean> {
|
||||
for (let step = 0; step < limit; step += 1) {
|
||||
if (await target.evaluate(element => element === document.activeElement).catch(() => false)) return true
|
||||
await page.keyboard.press('Tab')
|
||||
}
|
||||
return target.evaluate(element => element === document.activeElement).catch(() => false)
|
||||
}
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await page.getByLabel('账号').fill(user.username)
|
||||
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
}
|
||||
|
||||
test('the due review can be driven from the keyboard alone', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await login(page)
|
||||
await page.locator('.site-header nav').getByRole('link', { name: '到期复习' }).click()
|
||||
await expect(page.getByTestId('review-card')).toBeVisible()
|
||||
await expect(page.getByTestId('review-shortcuts')).toContainText('空格')
|
||||
|
||||
// The first action already holds the focus, so a keyboard user can keep going.
|
||||
await expect(page.getByTestId('review-reveal')).toBeFocused()
|
||||
|
||||
// Space shows the answer, and the answer panel is announced as text.
|
||||
await page.keyboard.press('Space')
|
||||
await expect(page.getByTestId('review-definition')).toContainText('好奇心')
|
||||
await expect(page.getByTestId('review-correct')).toBeFocused()
|
||||
|
||||
// A digit grades the card without touching the mouse.
|
||||
await page.keyboard.press('1')
|
||||
await expect(page.getByTestId('review-summary')).toBeVisible()
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-keyboard-review.png' })
|
||||
|
||||
// Tab reaches the closing action within a few presses, not after walking the whole page.
|
||||
expect(await tabUntilFocused(page, page.getByTestId('review-finish'), 6)).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('typing in a form never triggers a global shortcut', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await login(page)
|
||||
await page.locator('.site-header nav').getByRole('link', { name: '生词本' }).click()
|
||||
await expect(page.locator('.vocab-row')).toHaveCount(1)
|
||||
const search = page.getByLabel('搜索词 / 释义')
|
||||
await search.click()
|
||||
await search.type('curiosity 1 2 quit')
|
||||
await expect(search).toHaveValue('curiosity 1 2 quit')
|
||||
// The list only changes when the search is submitted, not while typing.
|
||||
await expect(page.locator('.vocab-row')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('the display control shows a visible focus ring', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await login(page)
|
||||
const trigger = page.getByTestId('display-trigger')
|
||||
// Reached by keyboard, which is what makes the focus ring appear in the first place.
|
||||
expect(await tabUntilFocused(page, trigger)).toBe(true)
|
||||
const outline = await trigger.evaluate(element => {
|
||||
const style = getComputedStyle(element)
|
||||
return { width: style.outlineWidth, style: style.outlineStyle }
|
||||
})
|
||||
expect(parseFloat(outline.width)).toBeGreaterThanOrEqual(2)
|
||||
expect(outline.style).not.toBe('none')
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { login, mockLearnerApi, touchSwipe } from './mobile-fixtures'
|
||||
|
||||
// Touch must scroll the chapter instead of being swallowed by the selection handling, and a tap
|
||||
// must still open the word panel. This is emulated touch input through Chromium, not a real phone.
|
||||
test('a touch swipe scrolls the chapter and does not open the word panel', async ({ page }) => {
|
||||
await mockLearnerApi(page)
|
||||
await login(page)
|
||||
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
|
||||
await page.locator('.chapter-list a.chapter-name').first().click()
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
// The words arrive with the token response, so wait for them instead of sampling once.
|
||||
await expect.poll(async () => page.locator('.reader-word').count()).toBeGreaterThan(3)
|
||||
|
||||
// A swipe upwards scrolls the page down and leaves the panel closed.
|
||||
await touchSwipe(page, 640, 240)
|
||||
await expect.poll(async () => page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
|
||||
expect(await page.locator('.lookup-panel').count()).toBe(0)
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-mobile-scrolled.png' })
|
||||
|
||||
// A tap on a word still opens the panel, which sits above the text without covering it.
|
||||
await page.locator('.reader-word').nth(1).tap()
|
||||
const panel = page.locator('.lookup-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
const panelBox = (await panel.boundingBox())!
|
||||
expect(panelBox.x).toBeGreaterThanOrEqual(0)
|
||||
expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(391)
|
||||
// The page reserves room for the fixed sheet, so the last paragraph can still be read.
|
||||
const padding = await page.evaluate(() => getComputedStyle(document.querySelector('.reader-page') as Element).paddingBottom)
|
||||
expect(parseFloat(padding)).toBeGreaterThan(100)
|
||||
|
||||
// Closing the panel keeps the reading position instead of jumping away.
|
||||
const before = await page.evaluate(() => window.scrollY)
|
||||
await page.getByRole('button', { name: '关闭释义' }).tap()
|
||||
await expect(panel).toHaveCount(0)
|
||||
expect(Math.abs((await page.evaluate(() => window.scrollY)) - before)).toBeLessThan(40)
|
||||
})
|
||||
|
||||
test('the display choice survives a reload and belongs to the account', async ({ page }) => {
|
||||
await mockLearnerApi(page)
|
||||
await login(page)
|
||||
await page.getByTestId('display-trigger').tap()
|
||||
await page.getByRole('menu').getByText('深色', { exact: true }).tap()
|
||||
await page.getByTestId('display-trigger').tap()
|
||||
await page.getByRole('menu').getByText('特大', { exact: true }).tap()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
|
||||
const readerScale = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())
|
||||
expect(readerScale).toBe('1.3')
|
||||
|
||||
// Reloading keeps both choices: they are stored, not just applied.
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())).toBe('1.3')
|
||||
|
||||
// The reading text really is larger, which is what the choice promises.
|
||||
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
|
||||
await page.locator('.chapter-list a.chapter-name').first().click()
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
const size = await page.locator('.reader-text').evaluate(element => parseFloat(getComputedStyle(element).fontSize))
|
||||
expect(size).toBeGreaterThan(16)
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-mobile-dark-large.png' })
|
||||
|
||||
// Signing out and back in as another account must not inherit the first account's choice.
|
||||
await page.getByRole('button', { name: '退出登录' }).tap()
|
||||
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
await page.getByLabel('账号').fill('fictional-mobile')
|
||||
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
// The same account keeps its own choice.
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
// Shared fixtures for the mobile (touch, 390x844) specs.
|
||||
import { expect, type Page } from '@playwright/test'
|
||||
|
||||
export const mobileUser = { id: 42, username: 'fictional-mobile', role: 'learner' }
|
||||
export const chapterText = [
|
||||
'Curiosity opens the first door.',
|
||||
'The second door stays closed until the reader asks.',
|
||||
'A third line gives the page something to scroll.',
|
||||
'Every paragraph is fictional and only used by the mobile checks.',
|
||||
'Reading on a phone needs real scrolling, not a fixed frame.',
|
||||
'The lookup panel must never swallow a swipe.',
|
||||
'A tap still opens one word at a time.',
|
||||
'Long enough text keeps the page taller than a phone screen.',
|
||||
'The last line is here so the page can be scrolled to its end.',
|
||||
].join('\n') + '\n'
|
||||
|
||||
/** A small mocked API with one book, one ready chapter and one due word. */
|
||||
export async function mockLearnerApi(page: Page): Promise<void> {
|
||||
const book = { id: 7, title: 'Fictional mobile book', language: 'en', chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, createdAt: '', updatedAt: '' }
|
||||
const chapter = {
|
||||
id: 3, bookId: 7, ordinal: 1, title: 'Fictional mobile chapter', status: 'ready', charCount: chapterText.length,
|
||||
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
|
||||
}
|
||||
const term = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z' }
|
||||
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
const path = url.pathname
|
||||
let data: unknown = null
|
||||
if (path === '/api/v1/login') data = { token: 'fictional-session', user: mobileUser }
|
||||
else if (path === '/api/v1/me') data = mobileUser
|
||||
else if (path === '/api/v1/space') data = { ownerId: mobileUser.id, language: 'en' }
|
||||
else if (path === '/api/v1/books') data = { items: [book] }
|
||||
else if (path === '/api/v1/books/7') data = { book, chapters: [chapter] }
|
||||
else if (path === '/api/v1/chapters/3/tokens') {
|
||||
const tokens: unknown[] = []
|
||||
let offset = 0
|
||||
for (const piece of chapterText.match(/[A-Za-z]+|\s+|[^A-Za-z\s]+/g) ?? []) {
|
||||
const start = offset
|
||||
offset += piece.length
|
||||
tokens.push({
|
||||
text: piece, start, end: offset, startUtf16: start, endUtf16: offset,
|
||||
kind: /^[A-Za-z]+$/.test(piece) ? 'word' : /^\s+$/.test(piece) ? 'space' : 'punctuation',
|
||||
})
|
||||
}
|
||||
// The reader follows the tokens only while they describe the chapter's content version.
|
||||
data = { textSha256: 'e'.repeat(64), tokens }
|
||||
} else if (path === '/api/v1/chapters/3') {
|
||||
data = { book, chapter: { ...chapter, contentSha256: 'e'.repeat(64), originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } }
|
||||
} else if (path === '/api/v1/terms/lookup') {
|
||||
data = { query: 'curiosity', terms: [{ term: 'curiosity', entries: [{ partOfSpeech: 'noun', definition: '好奇心' }] }], candidates: [] }
|
||||
} else if (path === '/api/v1/terms' && route.request().method() === 'GET') {
|
||||
data = { items: [term], total: 1, page: 1, limit: 20 }
|
||||
} else if (path === '/api/v1/reviews/queue') {
|
||||
data = { items: [{ ...term, dueAt: '2026-01-02T00:00:00Z', reviewCount: 0 }], total: 1 }
|
||||
} else if (path === '/api/v1/progress') {
|
||||
data = { readChapters: 0, totalChapters: 1, knownTerms: 0, learningTerms: 0, newTerms: 1, ignoredTerms: 0, savedTerms: 1, dueNow: 1, books: [{ id: 7, title: book.title, readChapters: 0, totalChapters: 1 }] }
|
||||
}
|
||||
await route.fulfill({ json: { code: 200, data } })
|
||||
})
|
||||
}
|
||||
|
||||
export async function login(page: Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await page.getByLabel('账号').fill(mobileUser.username)
|
||||
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
}
|
||||
|
||||
/** True when nothing sticks out sideways; the phone must never scroll left or right. */
|
||||
export async function overflowOffenders(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const width = window.innerWidth
|
||||
const offenders: string[] = []
|
||||
for (const element of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
|
||||
const box = element.getBoundingClientRect()
|
||||
if (box.width === 0 && box.height === 0) continue
|
||||
if (box.right > width + 1 || box.left < -1) {
|
||||
offenders.push(`${element.tagName.toLowerCase()}.${(element.className || '').toString().split(' ').slice(0, 2).join('.')}`)
|
||||
}
|
||||
}
|
||||
return offenders
|
||||
})
|
||||
}
|
||||
|
||||
/** A real touch swipe through CDP, which Chromium turns into scrolling. */
|
||||
export async function touchSwipe(page: Page, fromY: number, toY: number): Promise<void> {
|
||||
const client = await page.context().newCDPSession(page)
|
||||
const send = (type: string, y: number) => client.send('Input.dispatchTouchEvent', {
|
||||
type,
|
||||
touchPoints: type === 'touchEnd' ? [] : [{ x: 195, y }],
|
||||
})
|
||||
await send('touchStart', fromY)
|
||||
const steps = 8
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
await send('touchMove', fromY + ((toY - fromY) * step) / steps)
|
||||
}
|
||||
await send('touchEnd', toY)
|
||||
await client.detach()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { login, mockLearnerApi, overflowOffenders } from './mobile-fixtures'
|
||||
|
||||
// Every learner surface on a phone viewport: nothing sticks out sideways and every page can be
|
||||
// left again. The measured truth in #14 was that the layouts already fit; this locks that in.
|
||||
test('every page fits the phone width and can be left again', async ({ page }) => {
|
||||
await mockLearnerApi(page)
|
||||
await login(page)
|
||||
|
||||
const surfaces: { name: string; open: () => Promise<void>; back: () => Promise<void> }[] = [
|
||||
{
|
||||
name: '书库',
|
||||
open: async () => { await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '导入',
|
||||
open: async () => { await page.getByRole('button', { name: '导入内容' }).click(); await expect(page.getByRole('heading', { name: '导入英文内容' })).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '书籍',
|
||||
open: async () => { await page.getByRole('link', { name: 'Fictional mobile book' }).click(); await expect(page.locator('.chapter-list')).toBeVisible() },
|
||||
back: async () => { await page.getByRole('link', { name: '我的书库' }).first().click() },
|
||||
},
|
||||
{
|
||||
name: '阅读',
|
||||
open: async () => {
|
||||
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
|
||||
await page.locator('.chapter-list a.chapter-name').first().click()
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
},
|
||||
back: async () => { await page.locator('.breadcrumb').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '查词面板',
|
||||
open: async () => {
|
||||
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
|
||||
await page.locator('.chapter-list a.chapter-name').first().click()
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
await page.locator('.reader-word').first().click()
|
||||
await expect(page.locator('.lookup-panel')).toBeVisible()
|
||||
},
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '生词本',
|
||||
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '生词本' }).click(); await expect(page.getByRole('heading', { name: '生词本' })).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '复习',
|
||||
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '到期复习' }).click(); await expect(page.getByTestId('review-card')).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
{
|
||||
name: '进度',
|
||||
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '进度' }).click(); await expect(page.getByRole('heading', { name: '我的进度' })).toBeVisible() },
|
||||
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
|
||||
},
|
||||
]
|
||||
|
||||
for (const surface of surfaces) {
|
||||
await surface.open()
|
||||
expect(await overflowOffenders(page), `${surface.name} must not overflow sideways`).toEqual([])
|
||||
const widths = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth }))
|
||||
expect(widths.scroll, `${surface.name} must not scroll sideways`).toBeLessThanOrEqual(widths.inner)
|
||||
await page.screenshot({ path: `../.local/evidence/issue14-mobile-${surface.name}.png`, fullPage: true })
|
||||
await surface.back()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('the display control is reachable on a phone', async ({ page }) => {
|
||||
await mockLearnerApi(page)
|
||||
await login(page)
|
||||
const trigger = page.getByTestId('display-trigger')
|
||||
await expect(trigger).toBeVisible()
|
||||
const box = (await trigger.boundingBox())!
|
||||
// A finger needs a target of at least 38 pixels; the control is in the header on every page.
|
||||
expect(box.height).toBeGreaterThanOrEqual(36)
|
||||
await trigger.tap()
|
||||
await expect(page.getByRole('menu').getByText('主题')).toBeVisible()
|
||||
await page.getByRole('menu').getByText('深色', { exact: true }).tap()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-mobile-dark.png' })
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
// Real mouse selection over the rendered chapter, against a mocked API: the interaction #4
|
||||
// verified is exercised as a continuous drag, not through a preset button.
|
||||
const original = 'Take a small step\nevery day.\n'
|
||||
const fragments: [string, 'word' | 'space' | 'punctuation'][] = [
|
||||
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'],
|
||||
['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'], ['.', 'punctuation'], ['\n', 'space'],
|
||||
]
|
||||
let cp = 0
|
||||
let utf16 = 0
|
||||
const tokens = fragments.map(([text, kind]) => {
|
||||
const token = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
|
||||
cp = token.end
|
||||
utf16 = token.endUtf16
|
||||
return token
|
||||
})
|
||||
const book = { id: 1, title: 'Phrase chapter', language: 'en' }
|
||||
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
|
||||
const chapter = { id: 55, bookId: 1, ordinal: 1, title: 'Phrase chapter', status: 'ready', charCount: [...original].length, errorReason: '', errorMessage: '', jobId: 5, contentSha256: 'fictional-sha', originalText: original, ...timestamps }
|
||||
|
||||
async function openChapter(page: Page, options: { phrases?: unknown[]; onPhrase?: (body: Record<string, unknown>) => void } = {}) {
|
||||
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
|
||||
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
const method = route.request().method()
|
||||
let data: unknown = null
|
||||
let status = 200
|
||||
if (path === '/api/v1/me') data = user
|
||||
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
|
||||
else if (path === '/api/v1/books') data = { items: [] }
|
||||
else if (path === '/api/v1/chapters/55/tokens') data = { textSha256: 'fictional-sha', tokens, phrases: options.phrases ?? [] }
|
||||
else if (path === '/api/v1/chapters/55') data = { book, chapter, navigation: { previousChapterId: null, nextChapterId: null } }
|
||||
else if (path === '/api/v1/lookup') data = { status: 'not_found', query: 'Take', matchedForm: null, candidates: [], entries: [] }
|
||||
else if (path === '/api/v1/phrases' && method === 'POST') {
|
||||
options.onPhrase?.(route.request().postDataJSON() as Record<string, unknown>)
|
||||
status = 201
|
||||
data = {
|
||||
term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: [], status: 'new', level: 0, kind: 'phrase', wordCount: 3 },
|
||||
created: true,
|
||||
}
|
||||
} else if (path === '/api/v1/terms/9') data = { term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: ['Take a small step.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3 } }
|
||||
await route.fulfill({ status, json: { code: 200, data } })
|
||||
})
|
||||
await page.goto('/chapters/55')
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
}
|
||||
|
||||
/** Drags from the centre of one word to the centre of another, like a person selecting text. */
|
||||
async function dragWords(page: Page, from: string, to: string) {
|
||||
const words = page.locator('.reader-word')
|
||||
const start = words.filter({ hasText: new RegExp(`^${from}$`) }).first()
|
||||
const end = words.filter({ hasText: new RegExp(`^${to}$`) }).first()
|
||||
const startBox = (await start.boundingBox())!
|
||||
const endBox = (await end.boundingBox())!
|
||||
await page.mouse.move(startBox.x + startBox.width / 2, startBox.y + startBox.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
test('drag a continuous phrase, save it and highlight it', async ({ page }) => {
|
||||
let body: Record<string, unknown> | undefined
|
||||
await openChapter(page, { onPhrase: value => { body = value } })
|
||||
|
||||
await dragWords(page, 'Take', 'small')
|
||||
const range = page.getByTestId('phrase-range')
|
||||
await expect(range).toContainText('短语 · 3 个单词')
|
||||
// A phrase that is not stored yet is labelled as a new entry.
|
||||
await expect(page.locator('.lookup-panel')).toContainText('新词条')
|
||||
await page.getByLabel(/^我的释义/).fill('拿一小步')
|
||||
await page.screenshot({ path: '../.local/evidence/issue11-phrase-selected.png' })
|
||||
await page.getByTestId('term-save').click()
|
||||
await expect(page.getByText('已保存 · 新词')).toBeVisible()
|
||||
// The code point range covers the whole run including its interior separator.
|
||||
expect(body).toMatchObject({ chapterId: 55, start: 0, end: 12, definition: '拿一小步', status: 'new' })
|
||||
// The saved phrase is underlined in the text.
|
||||
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('adjust the endpoints by whole words from the panel', async ({ page }) => {
|
||||
await openChapter(page)
|
||||
await dragWords(page, 'Take', 'small')
|
||||
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
|
||||
await page.getByTestId('range-end-right').click()
|
||||
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
|
||||
await page.getByTestId('range-start-right').click()
|
||||
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
|
||||
await page.getByTestId('range-start-left').click()
|
||||
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
|
||||
})
|
||||
|
||||
test('open a saved phrase from its highlight', async ({ page }) => {
|
||||
await openChapter(page, { phrases: [{ id: 9, status: 'new', wordCount: 3, startToken: 0, endToken: 4 }] })
|
||||
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
|
||||
await page.locator('.reader-word.is-phrase').first().click()
|
||||
await expect(page.getByTestId('phrase-range')).toContainText('已保存')
|
||||
await expect(page.getByLabel(/^我的释义/)).toHaveValue('拿一小步')
|
||||
await page.screenshot({ path: '../.local/evidence/issue11-phrase-saved.png' })
|
||||
})
|
||||
|
||||
test('a phrase review card masks the whole run as one blank', async ({ page }) => {
|
||||
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
|
||||
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
let data: unknown = null
|
||||
if (path === '/api/v1/me') data = user
|
||||
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
|
||||
else if (path === '/api/v1/reviews/queue') {
|
||||
data = {
|
||||
items: [{
|
||||
id: 9, term: 'a small step', originalForm: 'a small step', definition: '一小步',
|
||||
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
|
||||
dueAt: '2026-01-01T00:00:00Z', reviewCount: 0,
|
||||
}],
|
||||
total: 1,
|
||||
}
|
||||
}
|
||||
await route.fulfill({ json: { code: 200, data } })
|
||||
})
|
||||
await page.goto('/review')
|
||||
await expect(page.getByTestId('review-position')).toContainText('到期复习 · 1 / 1')
|
||||
await expect(page.getByText('Take _____, every day.')).toBeVisible()
|
||||
await expect(page.getByText('短语 · 3 个单词')).toBeVisible()
|
||||
await page.getByTestId('review-reveal').click()
|
||||
await expect(page.getByTestId('review-definition')).toHaveText('一小步')
|
||||
await page.screenshot({ path: '../.local/evidence/issue11-phrase-review.png' })
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
const user = { id: 42, username: 'fictional-theme', role: 'learner' }
|
||||
|
||||
// The palette is one indirection deep: a CSS variable that references itself silently resolves to
|
||||
// nothing and every surface loses its colour. These checks read the values back from the browser.
|
||||
const palette = ['--bg', '--surface', '--surface-input', '--text', '--text-muted', '--border', '--accent',
|
||||
'--accent-marker', '--notice-bg', '--status-ready-bg', '--status-read-bg', '--status-failed-bg', '--word-learning-bg',
|
||||
'--saved', '--sense-bg', '--el-color-primary']
|
||||
|
||||
async function mockApi(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
let data: unknown = null
|
||||
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
|
||||
else if (path === '/api/v1/me') data = user
|
||||
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
|
||||
else if (path === '/api/v1/books') data = { items: [] }
|
||||
await route.fulfill({ json: { code: 200, data } })
|
||||
})
|
||||
}
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
await page.goto('/')
|
||||
await page.getByLabel('账号').fill(user.username)
|
||||
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
}
|
||||
|
||||
test('every palette colour resolves, in both themes', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await login(page)
|
||||
|
||||
const read = () => page.evaluate(names => {
|
||||
const root = getComputedStyle(document.documentElement)
|
||||
const values: Record<string, string> = {}
|
||||
for (const name of names) values[name] = root.getPropertyValue(name).trim()
|
||||
// The palette is declared on :root, so that is where the painted surface is read.
|
||||
const html = getComputedStyle(document.documentElement)
|
||||
return {
|
||||
values,
|
||||
body: html.backgroundColor,
|
||||
text: getComputedStyle(document.body).color,
|
||||
}
|
||||
}, palette)
|
||||
|
||||
const light = await read()
|
||||
for (const name of palette) {
|
||||
expect(light.values[name], `${name} must resolve in the light theme`).not.toBe('')
|
||||
}
|
||||
// The page really paints with them: an unresolved background would be transparent.
|
||||
expect(light.body).not.toBe('rgba(0, 0, 0, 0)')
|
||||
expect(light.body).not.toBe('transparent')
|
||||
expect(light.text).not.toBe(light.body)
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-theme-light.png' })
|
||||
|
||||
await page.getByTestId('display-trigger').click()
|
||||
await page.getByRole('menu').getByText('深色', { exact: true }).click()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
|
||||
const dark = await read()
|
||||
for (const name of palette) {
|
||||
expect(dark.values[name], `${name} must resolve in the dark theme`).not.toBe('')
|
||||
}
|
||||
// The dark theme is a different surface, not the same one relabelled.
|
||||
expect(dark.values['--bg']).not.toBe(light.values['--bg'])
|
||||
expect(dark.body).not.toBe(light.body)
|
||||
expect(dark.text).not.toBe(dark.body)
|
||||
// Element Plus follows the same choice, so its components do not stay on a white card.
|
||||
const epBackground = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--el-bg-color').trim())
|
||||
expect(epBackground).not.toBe('')
|
||||
await page.screenshot({ path: '../.local/evidence/issue14-theme-dark.png' })
|
||||
|
||||
// Back to light by hand, and the surface returns to the original value.
|
||||
await page.getByTestId('display-trigger').click()
|
||||
await page.getByRole('menu').getByText('浅色', { exact: true }).click()
|
||||
expect((await read()).body).toBe(light.body)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -8,7 +8,20 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
// Every spec loads the whole SPA from the shared dev server, so a slow first module graph
|
||||
// under parallel workers must not fail an assertion that the app itself would pass.
|
||||
expect: { timeout: 15000 },
|
||||
use: { baseURL: 'http://127.0.0.1:5173', headless: true, trace: 'off' },
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' } }],
|
||||
projects: [
|
||||
// Desktop behaviour, including the keyboard paths.
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' }, testIgnore: /mobile-.*\.spec\.ts$/ },
|
||||
// A touch phone viewport: the same pages at 390x844 with touch enabled. This is an emulated
|
||||
// device, not a real phone; the hand-feel of a long-press selection still needs a person.
|
||||
{
|
||||
name: 'mobile',
|
||||
testMatch: /mobile-.*\.spec\.ts$/,
|
||||
use: { ...devices['Pixel 7'], channel: 'chrome', viewport: { width: 390, height: 844 } },
|
||||
},
|
||||
],
|
||||
webServer: { command: 'npm run dev -- --host 127.0.0.1', url: 'http://127.0.0.1:5173', reuseExistingServer: !process.env.CI },
|
||||
})
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import {
|
||||
applyPreference, defaultPreference, DISPLAY_KEY_PREFIX, FONT_SCALES, readPreference, resolveTheme,
|
||||
useDisplayStore, writePreference, type DisplayPreference,
|
||||
} from '../stores/preferences'
|
||||
|
||||
const user = { id: 42, username: 'fictional-display', role: 'learner' as const }
|
||||
|
||||
/** A controllable stand-in for the operating system colour scheme. */
|
||||
function fakeSystem(initialDark: boolean) {
|
||||
const listeners = new Set<() => void>()
|
||||
const query = {
|
||||
matches: initialDark,
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
addEventListener: (_: string, handler: () => void) => { listeners.add(handler) },
|
||||
removeEventListener: (_: string, handler: () => void) => { listeners.delete(handler) },
|
||||
}
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => query))
|
||||
return {
|
||||
set(dark: boolean) {
|
||||
query.matches = dark
|
||||
for (const handler of listeners) handler()
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
}
|
||||
}
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
describe('display preference rules', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
it('keeps each account apart and never shares a choice', () => {
|
||||
const storage = localStorage
|
||||
writePreference(storage, 42, { theme: 'dark', font: 'xlarge' })
|
||||
expect(readPreference(storage, 42)).toEqual({ theme: 'dark', font: 'xlarge' })
|
||||
// Another account has never chosen anything, so it gets the defaults.
|
||||
expect(readPreference(storage, 43)).toEqual(defaultPreference())
|
||||
expect(Object.keys(storage)).toContain(`${DISPLAY_KEY_PREFIX}42`)
|
||||
})
|
||||
|
||||
it('falls back to the default for unreadable or unknown stored values', () => {
|
||||
const storage = localStorage
|
||||
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, '{not json')
|
||||
expect(readPreference(storage, 42)).toEqual(defaultPreference())
|
||||
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'neon', font: 'huge' }))
|
||||
expect(readPreference(storage, 42)).toEqual(defaultPreference())
|
||||
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark' }))
|
||||
expect(readPreference(storage, 42)).toEqual({ theme: 'dark', font: 'standard' })
|
||||
// Without an account there is nothing to read or write.
|
||||
expect(readPreference(storage, null)).toEqual(defaultPreference())
|
||||
writePreference(storage, null, { theme: 'dark', font: 'large' })
|
||||
expect(Object.keys(storage).some(key => key.endsWith(':null'))).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves the system choice and applies it to the document', () => {
|
||||
expect(resolveTheme('system', true)).toBe('dark')
|
||||
expect(resolveTheme('system', false)).toBe('light')
|
||||
expect(resolveTheme('light', true)).toBe('light')
|
||||
expect(resolveTheme('dark', false)).toBe('dark')
|
||||
|
||||
const root = document.createElement('div')
|
||||
const preference: DisplayPreference = { theme: 'dark', font: 'large' }
|
||||
expect(applyPreference(root, preference, false)).toBe('dark')
|
||||
expect(root.dataset.theme).toBe('dark')
|
||||
expect(root.dataset.themeChoice).toBe('dark')
|
||||
expect(root.classList.contains('dark')).toBe(true)
|
||||
expect(root.style.getPropertyValue('--reader-font-scale')).toBe(FONT_SCALES.large)
|
||||
|
||||
applyPreference(root, { theme: 'system', font: 'standard' }, true)
|
||||
expect(root.dataset.theme).toBe('dark')
|
||||
expect(root.dataset.themeChoice).toBe('system')
|
||||
expect(root.style.getPropertyValue('--reader-font-scale')).toBe('1')
|
||||
|
||||
applyPreference(root, { theme: 'light', font: 'xlarge' }, true)
|
||||
expect(root.dataset.theme).toBe('light')
|
||||
// Light removes the Element Plus dark class, so its components follow too.
|
||||
expect(root.classList.contains('dark')).toBe(false)
|
||||
expect(root.style.getPropertyValue('--reader-font-scale')).toBe(FONT_SCALES.xlarge)
|
||||
})
|
||||
})
|
||||
|
||||
describe('display store', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
it('applies the signed-in account preference and saves later changes for it', () => {
|
||||
const system = fakeSystem(false)
|
||||
useSessionStore().user = { ...user }
|
||||
localStorage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark', font: 'large' }))
|
||||
const display = useDisplayStore()
|
||||
display.load(42)
|
||||
expect(display.theme).toBe('dark')
|
||||
expect(display.font).toBe('large')
|
||||
expect(display.isDark).toBe(true)
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
|
||||
display.setFont('xlarge')
|
||||
expect(JSON.parse(localStorage.getItem(`${DISPLAY_KEY_PREFIX}42`)!)).toEqual({ theme: 'dark', font: 'xlarge' })
|
||||
system.set(true)
|
||||
// An explicit choice ignores the system change.
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('follows the system while it is chosen, and stops when it is not', () => {
|
||||
const system = fakeSystem(false)
|
||||
useSessionStore().user = { ...user }
|
||||
const display = useDisplayStore()
|
||||
display.load(42)
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
system.set(true)
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
display.setTheme('light')
|
||||
system.set(false)
|
||||
system.set(true)
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
expect(system.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('switches preferences with the account instead of carrying them over', async () => {
|
||||
fakeSystem(false)
|
||||
localStorage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark', font: 'xlarge' }))
|
||||
const session = useSessionStore()
|
||||
session.user = { ...user }
|
||||
const display = useDisplayStore()
|
||||
await flushPromises()
|
||||
expect(display.isDark).toBe(true)
|
||||
// The second account never chose anything: it must not inherit the first one's dark theme.
|
||||
session.user = { id: 43, username: 'other', role: 'learner' as const }
|
||||
await flushPromises()
|
||||
expect(display.theme).toBe('system')
|
||||
expect(display.font).toBe('standard')
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
// Signing out returns to the default as well.
|
||||
session.user = null
|
||||
await flushPromises()
|
||||
expect(display.theme).toBe('system')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('display settings control', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
it('shows the current choice and stores a new one', async () => {
|
||||
fakeSystem(false)
|
||||
useSessionStore().user = { ...user }
|
||||
const display = useDisplayStore()
|
||||
wrapper = mount(DisplaySettings)
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('跟随系统')
|
||||
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('标准')
|
||||
|
||||
display.setTheme('dark')
|
||||
display.setFont('large')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('深色')
|
||||
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('大')
|
||||
// The control is reachable by keyboard and announces itself.
|
||||
const trigger = wrapper.get('[data-testid="display-trigger"]')
|
||||
expect(trigger.attributes('aria-label')).toBe('显示设置')
|
||||
expect(trigger.attributes('role')).toBe('button')
|
||||
expect(trigger.attributes('tabindex')).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ReaderView from '../views/ReaderView.vue'
|
||||
import ReviewView from '../views/ReviewView.vue'
|
||||
import { POSITION_KEY_PREFIX } from '../composables/readingPosition'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
const user = { id: 42, username: 'fictional-experience', role: 'learner' as const }
|
||||
const sha = 'c'.repeat(64)
|
||||
const text = 'Curiosity opens the first door.\nThe second door stays closed.\n'
|
||||
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
const reviewItem = {
|
||||
id: 5, term: 'curiosity', originalForm: 'Curiosity', definition: '好奇心', examples: ['Curious minds ask.'],
|
||||
status: 'new' as const, level: 0, kind: 'word' as const, wordCount: 1, dueAt: '2026-09-15T00:00:00Z', reviewCount: 0,
|
||||
}
|
||||
|
||||
function mockApi(handlers: Record<string, (init?: RequestInit) => unknown>): MockInstance {
|
||||
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = String(input)
|
||||
for (const [fragment, handler] of Object.entries(handlers)) {
|
||||
if (url.includes(fragment)) return ok(handler(init))
|
||||
}
|
||||
return ok({})
|
||||
})
|
||||
}
|
||||
|
||||
async function mountWith(component: unknown, path: string) {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div>LibraryStub</div>' } },
|
||||
{ path: '/chapters/:id', component: component as never },
|
||||
{ path: '/review', component: component as never },
|
||||
],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
wrapper = mount(component as never, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('review shortcuts in the page', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
it('reveals and grades from the keyboard', async () => {
|
||||
const answers: string[] = []
|
||||
useSessionStore().user = { ...user }
|
||||
mockApi({
|
||||
'/reviews/queue': () => ({ items: [reviewItem], total: 1 }),
|
||||
'/answers': (init) => {
|
||||
answers.push(JSON.parse(String(init?.body)).grade)
|
||||
return {
|
||||
result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning',
|
||||
levelBefore: 0, levelAfter: 1, dueAtBefore: reviewItem.dueAt, dueAtAfter: '2026-09-20T00:00:00Z', item: reviewItem,
|
||||
}
|
||||
},
|
||||
})
|
||||
const view = await mountWith(ReviewView, '/review')
|
||||
expect(view.find('[data-testid="review-card"]').exists()).toBe(true)
|
||||
expect(view.get('[data-testid="review-shortcuts"]').text()).toContain('空格')
|
||||
|
||||
// Space shows the answer instead of grading anything.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(view.get('[data-testid="review-definition"]').text()).toContain('好奇心')
|
||||
expect(answers).toEqual([])
|
||||
|
||||
// A digit grades it; the queue then has nothing left.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(answers).toEqual(['correct'])
|
||||
expect(view.find('[data-testid="review-summary"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores shortcuts typed into a field and with a modifier held', async () => {
|
||||
const answers: string[] = []
|
||||
useSessionStore().user = { ...user }
|
||||
mockApi({
|
||||
'/reviews/queue': () => ({ items: [reviewItem], total: 1 }),
|
||||
'/answers': (init) => {
|
||||
answers.push(JSON.parse(String(init?.body)).grade)
|
||||
return { result: 'applied', duplicate: false, grade: 'wrong', requeued: true, statusBefore: 'new', statusAfter: 'new', levelBefore: 0, levelAfter: 0, dueAtBefore: reviewItem.dueAt, dueAtAfter: reviewItem.dueAt, item: reviewItem }
|
||||
},
|
||||
})
|
||||
const view = await mountWith(ReviewView, '/review')
|
||||
view.get('[data-testid="review-reveal"]').trigger('click')
|
||||
await flushPromises()
|
||||
// A key press inside an input belongs to the input, wherever the learner is typing.
|
||||
const field = document.createElement('input')
|
||||
document.body.append(field)
|
||||
field.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true }))
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '2', ctrlKey: true, bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(answers).toEqual([])
|
||||
field.remove()
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(answers).toEqual(['wrong'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reading position in the reader', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
|
||||
|
||||
function readerApi() {
|
||||
return mockApi({
|
||||
'/tokens': () => ({ tokens: [{ start: 0, end: 9, text: 'Curiosity', kind: 'word' }] }),
|
||||
'/chapters/7': () => ({
|
||||
book: { id: 3, title: 'Fictional reader', language: 'en' },
|
||||
chapter: {
|
||||
id: 7, bookId: 3, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '',
|
||||
errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: sha, originalText: text,
|
||||
},
|
||||
navigation: { previousChapterId: null, nextChapterId: null },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
it('restores the saved position for the same account and version', async () => {
|
||||
useSessionStore().user = { ...user }
|
||||
localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha, at: '2026-09-15T03:00:00Z' }))
|
||||
const scrollTo = vi.fn()
|
||||
vi.stubGlobal('scrollTo', scrollTo)
|
||||
// A chapter long enough to have a scrolled position.
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true })
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
|
||||
readerApi()
|
||||
const view = await mountWith(ReaderView, '/chapters/7')
|
||||
expect(view.find('.reader-text').exists()).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 90))
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
|
||||
await flushPromises()
|
||||
expect(view.get('[data-testid="position-notice"]').text()).toContain('上次阅读位置')
|
||||
})
|
||||
|
||||
it('does not restore a position taken from another content version', async () => {
|
||||
useSessionStore().user = { ...user }
|
||||
localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha: 'd'.repeat(64) }))
|
||||
const scrollTo = vi.fn()
|
||||
vi.stubGlobal('scrollTo', scrollTo)
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true })
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
|
||||
readerApi()
|
||||
const view = await mountWith(ReaderView, '/chapters/7')
|
||||
await new Promise(resolve => setTimeout(resolve, 90))
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
expect(view.find('[data-testid="position-notice"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,8 @@ const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', ori
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
|
||||
const result = (query: string, status = 'exact') => ({ status, query, matchedForm: query, candidates: [], entries: status === 'exact' ? [{ lemma: query, pos: 'noun', definition: `Definition of ${query}`, examples: ['A fictional example.'] }] : [] })
|
||||
const savedTerm = (overrides: Partial<SavedTerm> = {}): SavedTerm => ({
|
||||
id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2, ...overrides,
|
||||
id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2,
|
||||
kind: 'word', wordCount: 1, ...overrides,
|
||||
})
|
||||
let wrapper: VueWrapper | undefined
|
||||
interface OpenOptions {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ReaderView from '../views/ReaderView.vue'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import type { PhraseSpan, ReaderToken } from '../composables/useReaderLookup'
|
||||
|
||||
// The same fixture shape the phrase tests use on the server side.
|
||||
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
|
||||
const fragments: [string, ReaderToken['kind']][] = [
|
||||
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [',', 'punctuation'],
|
||||
[' ', 'space'], ['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'],
|
||||
['.', 'punctuation'], ['\n', 'space'], ['Mira', 'word'], [' ', 'space'], ['took', 'word'], [' ', 'space'],
|
||||
['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'], ['step', 'word'], [' ', 'space'],
|
||||
['again', 'word'], ['.', 'punctuation'], ['\n', 'space'],
|
||||
]
|
||||
let cp = 0
|
||||
let utf16 = 0
|
||||
const tokens: ReaderToken[] = fragments.map(([text, kind]) => {
|
||||
const token: ReaderToken = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
|
||||
cp = token.end
|
||||
utf16 = token.endUtf16
|
||||
return token
|
||||
})
|
||||
const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', originalText: original, contentSha256: 'same-sha' }
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
|
||||
const savedPhrase = {
|
||||
id: 7, term: 'a small step', originalForm: 'a small, step', definition: '一小步',
|
||||
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
|
||||
}
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
interface OpenOptions {
|
||||
phrases?: PhraseSpan[]
|
||||
tokens?: ReaderToken[]
|
||||
original?: string
|
||||
save?: (body: Record<string, unknown>) => Promise<Response>
|
||||
termRead?: () => Promise<Response>
|
||||
}
|
||||
|
||||
async function open(options: OpenOptions = {}) {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/tokens')) return ok({ textSha256: 'same-sha', tokens: options.tokens ?? tokens, phrases: options.phrases ?? [] })
|
||||
if (url.endsWith('/lookup')) return ok({ status: 'not_found', query: 'a', matchedForm: null, candidates: [], entries: [] })
|
||||
if (url.includes('/terms/')) return options.termRead ? options.termRead() : ok({ term: savedPhrase })
|
||||
if (url.endsWith('/phrases') || url.endsWith('/terms')) {
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
if (options.save) return options.save(body)
|
||||
return ok({ term: savedPhrase, created: true })
|
||||
}
|
||||
// A test that supplies its own tokens must also supply the matching chapter text.
|
||||
const text2 = options.original ?? original
|
||||
return ok({ chapter: { ...chapter, originalText: text2 }, navigation: { previousChapterId: null, nextChapterId: 56 } })
|
||||
})
|
||||
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/chapters/:id', component: ReaderView }, { path: '/', component: { template: '<div />' } }] })
|
||||
await router.push('/chapters/55')
|
||||
wrapper = mount(ReaderView, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
return { fetchMock, router, view: wrapper }
|
||||
}
|
||||
|
||||
/** Clicks one word and shift-clicks the next matching word after it to extend the range. */
|
||||
async function shiftSelect(view: VueWrapper, first: string, last: string) {
|
||||
const words = view.findAll('.reader-word')
|
||||
const startIndex = words.findIndex(item => item.text() === first)
|
||||
const endIndex = words.findIndex((item, index) => index > startIndex && item.text() === last)
|
||||
await words[startIndex]!.trigger('click')
|
||||
await flushPromises()
|
||||
await words[endIndex]!.trigger('click', { shiftKey: true })
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('phrase selection and panel', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
|
||||
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
|
||||
|
||||
it('builds a phrase from a shift-click range and saves it through the phrases endpoint', async () => {
|
||||
let body: Record<string, unknown> | undefined
|
||||
const { view } = await open({ save: async value => { body = value; return ok({ term: savedPhrase, created: true }) } })
|
||||
await shiftSelect(view, 'a', 'step')
|
||||
// The panel names the phrase and shows how many words it holds.
|
||||
expect(view.get('[data-testid="phrase-range"]').text()).toContain('短语 · 3 个单词')
|
||||
expect(view.text()).toContain('新词条')
|
||||
// A phrase has no dictionary lookup section.
|
||||
expect(view.find('[data-testid="lookup-retry"]').exists()).toBe(false)
|
||||
await view.get('#term-definition').setValue('一小步')
|
||||
await view.get('[data-testid="term-save"]').trigger('click')
|
||||
await flushPromises()
|
||||
// The selection travels as code point offsets of the whole phrase, punctuation included.
|
||||
expect(body).toMatchObject({ chapterId: 55, start: 5, end: 18, definition: '一小步', status: 'new' })
|
||||
expect(view.text()).toContain('已保存')
|
||||
// The saved phrase highlights immediately.
|
||||
expect(view.findAll('.reader-word').some(item => item.classes().includes('is-phrase'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adjusts both ends by whole words from the panel', async () => {
|
||||
const { view } = await open()
|
||||
await shiftSelect(view, 'a', 'step')
|
||||
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
|
||||
await view.get('[data-testid="range-start-right"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
|
||||
// Moving the start back restores the three-word phrase.
|
||||
await view.get('[data-testid="range-start-left"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
|
||||
// A two-word phrase cannot shrink further, so the shrinking buttons are disabled.
|
||||
await view.get('[data-testid="range-start-right"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
|
||||
expect(view.get('[data-testid="range-start-right"]').attributes('disabled')).toBeDefined()
|
||||
expect(view.get('[data-testid="range-end-left"]').attributes('disabled')).toBeDefined()
|
||||
// Growing it is still possible on either side.
|
||||
expect(view.get('[data-testid="range-start-left"]').attributes('disabled')).toBeUndefined()
|
||||
expect(view.get('[data-testid="range-end-right"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('opens the saved phrase when its highlighted range is clicked', async () => {
|
||||
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
|
||||
const { view, fetchMock } = await open({ phrases: [span] })
|
||||
const inside = view.findAll('.reader-word').find(item => item.text() === 'small')!
|
||||
expect(inside.classes()).toContain('is-phrase')
|
||||
await inside.trigger('click')
|
||||
await flushPromises()
|
||||
// The stored entry is read through the same route a word uses, and the panel edits it.
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/terms/7'))).toBe(true)
|
||||
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
|
||||
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
|
||||
})
|
||||
|
||||
it('stops claiming a stored phrase once its range is adjusted', async () => {
|
||||
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
|
||||
const { view } = await open({ phrases: [span] })
|
||||
await view.findAll('.reader-word').find(item => item.text() === 'small')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
|
||||
await view.get('[data-testid="range-end-right"]').trigger('click')
|
||||
await flushPromises()
|
||||
// The adjusted range is another identity, so the panel no longer says it is stored, while
|
||||
// the text the learner already had stays in the form.
|
||||
expect(view.get('.lookup-panel').text()).toContain('新词条')
|
||||
expect(view.get('.lookup-panel').text()).not.toContain('已保存')
|
||||
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
|
||||
})
|
||||
|
||||
it('refuses a range longer than the phrase limit and keeps the panel closed', async () => {
|
||||
// One word more than a phrase may hold.
|
||||
const longTokens: ReaderToken[] = []
|
||||
const words: string[] = []
|
||||
let cursor = 0
|
||||
for (let index = 0; index < 14; index++) {
|
||||
const word = `w${index}`
|
||||
words.push(word)
|
||||
longTokens.push({ text: word, start: cursor, end: cursor + word.length, startUtf16: cursor, endUtf16: cursor + word.length, kind: 'word' })
|
||||
cursor += word.length
|
||||
longTokens.push({ text: ' ', start: cursor, end: cursor + 1, startUtf16: cursor, endUtf16: cursor + 1, kind: 'space' })
|
||||
cursor += 1
|
||||
}
|
||||
const { view } = await open({ tokens: longTokens, original: longTokens.map(token => token.text).join('') })
|
||||
await shiftSelect(view, words[0]!, words[13]!)
|
||||
expect(view.get('[data-testid="range-notice"]').text()).toContain('短语最多 12 个单词')
|
||||
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('closes the phrase panel with Escape and restores the reading position', async () => {
|
||||
const { view } = await open()
|
||||
await shiftSelect(view, 'a', 'step')
|
||||
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(true)
|
||||
await view.get('.lookup-panel').trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(view.find('.lookup-panel').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the typed phrase text when saving fails', async () => {
|
||||
const { view } = await open({ save: async () => new Response(JSON.stringify({ code: 400, msg: '短语过长,请缩短选择范围' }), { status: 400 }) })
|
||||
await shiftSelect(view, 'a', 'step')
|
||||
await view.get('#term-definition').setValue('虚构释义')
|
||||
await view.get('[data-testid="term-save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.text()).toContain('短语过长,请缩短选择范围')
|
||||
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('虚构释义')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, rangeOfWords, wordIndices } from '../composables/readerRange'
|
||||
import type { ReaderToken } from '../composables/useReaderLookup'
|
||||
|
||||
// The same text #4 verified: interior punctuation, a line break inside the phrase, a repeated
|
||||
// phrase and a combining accent.
|
||||
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
|
||||
const tokens: ReaderToken[] = [
|
||||
{ text: 'Take', start: 0, end: 4, startUtf16: 0, endUtf16: 4, kind: 'word' },
|
||||
{ text: ' ', start: 4, end: 5, startUtf16: 4, endUtf16: 5, kind: 'space' },
|
||||
{ text: 'a', start: 5, end: 6, startUtf16: 5, endUtf16: 6, kind: 'word' },
|
||||
{ text: ' ', start: 6, end: 7, startUtf16: 6, endUtf16: 7, kind: 'space' },
|
||||
{ text: 'small', start: 7, end: 12, startUtf16: 7, endUtf16: 12, kind: 'word' },
|
||||
{ text: ',', start: 12, end: 13, startUtf16: 12, endUtf16: 13, kind: 'punctuation' },
|
||||
{ text: ' ', start: 13, end: 14, startUtf16: 13, endUtf16: 14, kind: 'space' },
|
||||
{ text: 'step', start: 14, end: 18, startUtf16: 14, endUtf16: 18, kind: 'word' },
|
||||
{ text: '\n', start: 18, end: 19, startUtf16: 18, endUtf16: 19, kind: 'space' },
|
||||
{ text: 'every', start: 19, end: 24, startUtf16: 19, endUtf16: 24, kind: 'word' },
|
||||
{ text: ' ', start: 24, end: 25, startUtf16: 24, endUtf16: 25, kind: 'space' },
|
||||
{ text: 'day', start: 25, end: 28, startUtf16: 25, endUtf16: 28, kind: 'word' },
|
||||
{ text: '.', start: 28, end: 29, startUtf16: 28, endUtf16: 29, kind: 'punctuation' },
|
||||
{ text: '\n', start: 29, end: 30, startUtf16: 29, endUtf16: 30, kind: 'space' },
|
||||
{ text: 'Mira', start: 30, end: 34, startUtf16: 30, endUtf16: 34, kind: 'word' },
|
||||
{ text: ' ', start: 34, end: 35, startUtf16: 34, endUtf16: 35, kind: 'space' },
|
||||
{ text: 'took', start: 35, end: 39, startUtf16: 35, endUtf16: 39, kind: 'word' },
|
||||
{ text: ' ', start: 39, end: 40, startUtf16: 39, endUtf16: 40, kind: 'space' },
|
||||
{ text: 'a', start: 40, end: 41, startUtf16: 40, endUtf16: 41, kind: 'word' },
|
||||
{ text: ' ', start: 41, end: 42, startUtf16: 41, endUtf16: 42, kind: 'space' },
|
||||
{ text: 'small', start: 42, end: 47, startUtf16: 42, endUtf16: 47, kind: 'word' },
|
||||
{ text: ' ', start: 47, end: 48, startUtf16: 47, endUtf16: 48, kind: 'space' },
|
||||
{ text: 'step', start: 48, end: 52, startUtf16: 48, endUtf16: 52, kind: 'word' },
|
||||
{ text: ' ', start: 52, end: 53, startUtf16: 52, endUtf16: 53, kind: 'space' },
|
||||
{ text: 'again', start: 53, end: 58, startUtf16: 53, endUtf16: 58, kind: 'word' },
|
||||
{ text: '.', start: 58, end: 59, startUtf16: 58, endUtf16: 59, kind: 'punctuation' },
|
||||
{ text: '\n', start: 59, end: 60, startUtf16: 59, endUtf16: 60, kind: 'space' },
|
||||
]
|
||||
|
||||
describe('reader phrase ranges', () => {
|
||||
it('keeps interior punctuation and line breaks in the selected text', () => {
|
||||
const range = normalizeTokenRange(tokens, original, 2, 7)
|
||||
expect(range).not.toBeNull()
|
||||
expect(range).toMatchObject({ firstWord: 2, lastWord: 7, start: 5, end: 18, wordCount: 3 })
|
||||
expect(range!.text).toBe('a small, step')
|
||||
// The second occurrence keeps its own spelling and offsets.
|
||||
const second = normalizeTokenRange(tokens, original, 18, 22)
|
||||
expect(second!.text).toBe('a small step')
|
||||
expect(second!.start).toBe(40)
|
||||
})
|
||||
|
||||
it('aligns both ends to whole words and never selects one word as a phrase', () => {
|
||||
// A selection that starts on a separator begins at the next whole word, and one that ends
|
||||
// on a separator ends at the previous one: words outside the selection stay untouched.
|
||||
expect(normalizeTokenRange(tokens, original, 3, 7)!.firstWord).toBe(4)
|
||||
expect(normalizeTokenRange(tokens, original, 2, 5)!.lastWord).toBe(4)
|
||||
expect(normalizeTokenRange(tokens, original, 2, 7)!.text).toBe('a small, step')
|
||||
expect(normalizeTokenRange(tokens, original, 2, 3)).toBeNull()
|
||||
expect(normalizeTokenRange(tokens, original, 5, 5)).toBeNull()
|
||||
expect(normalizeTokenRange(tokens, original, -1, 7)).toBeNull()
|
||||
expect(normalizeTokenRange(tokens, original, 2, tokens.length + 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('reverses a backwards selection and refuses more than twelve words', () => {
|
||||
const range = normalizeTokenRange(tokens, original, 7, 2)
|
||||
expect(range).toMatchObject({ firstWord: 2, lastWord: 7 })
|
||||
// The fixture is exactly twelve words, which is still a phrase.
|
||||
expect(normalizeTokenRange(tokens, original, 0, tokens.length - 1)!.wordCount).toBe(MAX_PHRASE_WORDS)
|
||||
const longer: ReaderToken[] = []
|
||||
let offset = 0
|
||||
for (let index = 0; index < MAX_PHRASE_WORDS + 1; index++) {
|
||||
const word = `word${index}`
|
||||
longer.push({ text: word, start: offset, end: offset + word.length, startUtf16: offset, endUtf16: offset + word.length, kind: 'word' })
|
||||
offset += word.length
|
||||
longer.push({ text: ' ', start: offset, end: offset + 1, startUtf16: offset, endUtf16: offset + 1, kind: 'space' })
|
||||
offset += 1
|
||||
}
|
||||
expect(normalizeTokenRange(longer, longer.map(token => token.text).join(''), 0, longer.length - 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('moves one end by whole words and never inverts the range', () => {
|
||||
const range = normalizeTokenRange(tokens, original, 2, 7)!
|
||||
const shorterStart = adjustTokenRange(tokens, original, range, 'start', 1)
|
||||
expect(shorterStart).toMatchObject({ firstWord: 4, lastWord: 7, wordCount: 2 })
|
||||
expect(shorterStart!.text).toBe('small, step')
|
||||
// The start can move left while a word remains before it, and not past the first word.
|
||||
expect(adjustTokenRange(tokens, original, range, 'start', -1)).toMatchObject({ firstWord: 0, lastWord: 7 })
|
||||
expect(adjustTokenRange(tokens, original, rangeOfWords(tokens, original, 0, 7)!, 'start', -1)).toBeNull()
|
||||
const longerEnd = adjustTokenRange(tokens, original, range, 'end', 1)
|
||||
expect(longerEnd).toMatchObject({ firstWord: 2, lastWord: 9, wordCount: 4 })
|
||||
expect(longerEnd!.text).toBe('a small, step\nevery')
|
||||
const back = adjustTokenRange(tokens, original, longerEnd!, 'end', -1)!
|
||||
expect(back.text).toBe(range.text)
|
||||
// Moving the end before the start is refused even when the direction is legal.
|
||||
const twoWords = rangeOfWords(tokens, original, 2, 4)!
|
||||
expect(adjustTokenRange(tokens, original, twoWords, 'end', -1)).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the phrase covering a word and builds its range again', () => {
|
||||
const phrases = [
|
||||
{ id: 1, startToken: 2, endToken: 7, status: 'new', wordCount: 3 },
|
||||
{ id: 2, startToken: 4, endToken: 7, status: 'learning', wordCount: 2 },
|
||||
]
|
||||
// Overlapping spans: the longer phrase wins for a word both cover.
|
||||
expect(phrasesAt(tokens, phrases, 4)?.id).toBe(1)
|
||||
expect(phrasesAt(tokens, phrases, 7)?.id).toBe(1)
|
||||
expect(phrasesAt(tokens, phrases, 0)).toBeNull()
|
||||
const span = rangeOfSpan(tokens, original, { startToken: 2, endToken: 7 })!
|
||||
expect(span.text).toBe('a small, step')
|
||||
expect(rangeOfWords(tokens, original, 4, 7)!.text).toBe('small, step')
|
||||
// A span that covers a single word is not a phrase.
|
||||
expect(rangeOfSpan(tokens, original, { startToken: 2, endToken: 2 })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,11 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import ReviewView from '../views/ReviewView.vue'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { clozeSentence, useReviewStore, type ReviewItem } from '../stores/review'
|
||||
import { clozeSentence, maskedPrompt, useReviewStore, type ReviewItem } from '../stores/review'
|
||||
|
||||
const item = (overrides: Partial<ReviewItem> = {}): ReviewItem => ({
|
||||
id: 7, term: 'dogs', originalForm: 'Dogs', definition: '狗', examples: ['Dogs went home.'],
|
||||
status: 'new', level: 0, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides,
|
||||
status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides,
|
||||
})
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
|
||||
const answer = (overrides: Record<string, unknown> = {}) => ({
|
||||
@@ -20,6 +20,15 @@ const answerCalls = (fetchMock: MockInstance) => fetchMock.mock.calls.filter(cal
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
describe('review prompt', () => {
|
||||
it('masks a whole phrase as one blank and keeps the word rule for words', () => {
|
||||
const phrase = item({ kind: 'phrase', wordCount: 3, term: 'a small step', originalForm: 'a small step', examples: ['Take a small step, every day.'] })
|
||||
expect(maskedPrompt(phrase)).toBe('Take _____, every day.')
|
||||
expect(maskedPrompt(item({ ...phrase, examples: ['Take a small,\nstep today.'] }))).toBe('Take _____ today.')
|
||||
// A phrase that is not in the example is left alone, and a word still masks itself.
|
||||
expect(maskedPrompt(item({ ...phrase, examples: ['Nothing to mask here.'] }))).toBe('Nothing to mask here.')
|
||||
expect(maskedPrompt(item({ term: 'curiosity', originalForm: 'curiosity', examples: ['Learning begins with curiosity.'] }))).toBe('Learning begins with _____.')
|
||||
})
|
||||
|
||||
it('masks the word in the first example and keeps other sentences usable', () => {
|
||||
expect(clozeSentence(item())).toBe('_____ went home.')
|
||||
expect(clozeSentence(item({ term: "isn't", originalForm: "Isn't", examples: ["It isn’t over."] }))).toBe('It _____ over.')
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearPosition, currentRatio, normalizeRatio, POSITION_KEY_PREFIX, positionKey, readPosition, writePosition,
|
||||
} from '../composables/readingPosition'
|
||||
import { ignoresShortcut, shortcutFor } from '../composables/reviewShortcuts'
|
||||
|
||||
const sha = 'a'.repeat(64)
|
||||
const otherSha = 'b'.repeat(64)
|
||||
|
||||
describe('reading position storage', () => {
|
||||
it('stores one position per account and chapter', () => {
|
||||
const storage = localStorage
|
||||
storage.clear()
|
||||
writePosition(storage, 42, 7, sha, 0.5, new Date('2026-09-15T03:00:00Z'))
|
||||
expect(readPosition(storage, 42, 7, sha)).toEqual({ ratio: 0.5, sha, at: '2026-09-15T03:00:00.000Z' })
|
||||
// Another chapter, another account and the signed-out state know nothing about it.
|
||||
expect(readPosition(storage, 42, 8, sha)).toBeNull()
|
||||
expect(readPosition(storage, 43, 7, sha)).toBeNull()
|
||||
expect(readPosition(storage, null, 7, sha)).toBeNull()
|
||||
expect(positionKey(null, 7)).toBeNull()
|
||||
expect(positionKey(42, 0)).toBeNull()
|
||||
expect(Object.keys(storage)).toEqual([`${POSITION_KEY_PREFIX}42:7`])
|
||||
})
|
||||
|
||||
it('drops a position taken from another content version', () => {
|
||||
const storage = localStorage
|
||||
storage.clear()
|
||||
writePosition(storage, 42, 7, sha, 0.4)
|
||||
expect(readPosition(storage, 42, 7, otherSha)).toBeNull()
|
||||
// A position without a version is never restored either.
|
||||
expect(readPosition(storage, 42, 7, '')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores unreadable values and never restores the very top', () => {
|
||||
const storage = localStorage
|
||||
storage.clear()
|
||||
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, '{broken')
|
||||
expect(readPosition(storage, 42, 7, sha)).toBeNull()
|
||||
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 'half', sha }))
|
||||
expect(readPosition(storage, 42, 7, sha)).toBeNull()
|
||||
expect(normalizeRatio(0)).toBeNull()
|
||||
expect(normalizeRatio(-1)).toBeNull()
|
||||
expect(normalizeRatio(Number.NaN)).toBeNull()
|
||||
expect(normalizeRatio(3)).toBe(1)
|
||||
expect(normalizeRatio(0.25)).toBe(0.25)
|
||||
// A position of zero is not worth storing, so the top of a chapter needs no key at all.
|
||||
storage.clear()
|
||||
writePosition(storage, 42, 7, sha, 0)
|
||||
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
|
||||
writePosition(storage, 42, 7, sha, 0.5)
|
||||
clearPosition(storage, 42, 7)
|
||||
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
|
||||
})
|
||||
|
||||
it('turns a scroll offset into a ratio and clamps it', () => {
|
||||
expect(currentRatio({ scrollY: 0, innerHeight: 800 }, 4800)).toBe(0)
|
||||
expect(currentRatio({ scrollY: 1000, innerHeight: 800 }, 4800)).toBeCloseTo(0.25, 5)
|
||||
expect(currentRatio({ scrollY: 4000, innerHeight: 800 }, 4800)).toBe(1)
|
||||
// A chapter shorter than the window cannot be scrolled.
|
||||
expect(currentRatio({ scrollY: 0, innerHeight: 900 }, 900)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('review keyboard shortcuts', () => {
|
||||
it('maps space and enter to revealing and digits to grading', () => {
|
||||
expect(shortcutFor(' ', false)).toBe('reveal')
|
||||
expect(shortcutFor('Enter', false)).toBe('reveal')
|
||||
// Before the answer is on screen a grade key means nothing.
|
||||
expect(shortcutFor('1', false)).toBeNull()
|
||||
expect(shortcutFor('1', true)).toBe('correct')
|
||||
expect(shortcutFor('2', true)).toBe('wrong')
|
||||
expect(shortcutFor('3', true)).toBe('again')
|
||||
expect(shortcutFor('4', true)).toBeNull()
|
||||
expect(shortcutFor('q', true)).toBeNull()
|
||||
// Once revealed, space no longer reveals anything.
|
||||
expect(shortcutFor(' ', true)).toBeNull()
|
||||
})
|
||||
|
||||
it('never fires while the learner is typing or using a control', () => {
|
||||
const form = document.createElement('form')
|
||||
const input = document.createElement('input')
|
||||
const textarea = document.createElement('textarea')
|
||||
const button = document.createElement('button')
|
||||
const contenteditable = document.createElement('div')
|
||||
contenteditable.setAttribute('contenteditable', 'true')
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', '/vocab')
|
||||
const plain = document.createElement('p')
|
||||
form.append(input, textarea, button, contenteditable, link, plain)
|
||||
for (const element of [input, textarea, contenteditable]) {
|
||||
expect(ignoresShortcut(element, '1')).toBe(true)
|
||||
expect(ignoresShortcut(element, ' ')).toBe(true)
|
||||
}
|
||||
// A focused button or link owns Space and Enter itself, but not the digit keys.
|
||||
expect(ignoresShortcut(button, ' ')).toBe(true)
|
||||
expect(ignoresShortcut(button, 'Enter')).toBe(true)
|
||||
expect(ignoresShortcut(button, '1')).toBe(false)
|
||||
expect(ignoresShortcut(link, ' ')).toBe(true)
|
||||
expect(ignoresShortcut(link, '2')).toBe(false)
|
||||
expect(ignoresShortcut(plain, '1')).toBe(false)
|
||||
expect(ignoresShortcut(null, '1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
|
||||
import { ElSelect } from 'element-plus'
|
||||
import VocabView from '../views/VocabView.vue'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
|
||||
|
||||
const user = { id: 42, username: 'fictional-vocab', role: 'learner' as const }
|
||||
const word = (overrides: Partial<VocabularyTerm> = {}): VocabularyTerm => ({
|
||||
id: 1, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Learning begins with curiosity.'],
|
||||
status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z', ...overrides,
|
||||
})
|
||||
const phrase = (overrides: Partial<VocabularyTerm> = {}): VocabularyTerm => ({
|
||||
id: 2, term: 'a small step', originalForm: 'a small step', definition: '一小步', examples: ['Take a small step.'],
|
||||
status: 'learning', level: 3, kind: 'phrase', wordCount: 3, updatedAt: '2026-01-03T00:00:00Z', ...overrides,
|
||||
})
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
function listCalls(fetchMock: MockInstance): URLSearchParams[] {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([url]) => String(url).includes('/terms?'))
|
||||
.map(([url]) => new URLSearchParams(String(url).split('?')[1]))
|
||||
}
|
||||
|
||||
function mockApi(items: VocabularyTerm[], total = items.length, onPatch?: (body: Record<string, unknown>) => VocabularyTerm): MockInstance {
|
||||
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/terms?') || url.endsWith('/terms')) {
|
||||
const params = new URLSearchParams(url.split('?')[1] ?? '')
|
||||
const query = (params.get('query') ?? '').toLowerCase()
|
||||
const status = params.get('status') ?? ''
|
||||
const kind = params.get('kind') ?? ''
|
||||
const page = Number(params.get('page') ?? 1)
|
||||
const limit = Number(params.get('limit') ?? VOCAB_PAGE_SIZE)
|
||||
const filtered = items.filter(item =>
|
||||
(!query || item.term.includes(query) || item.originalForm.toLowerCase().includes(query) || item.definition.includes(query))
|
||||
&& (!status || item.status === status) && (!kind || item.kind === kind))
|
||||
return ok({ items: filtered.slice((page - 1) * limit, page * limit), total: Math.max(total, filtered.length), page, limit })
|
||||
}
|
||||
if (url.includes('/terms/') && String(init?.method) === 'PATCH') {
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
const base = items.find(item => url.endsWith(`/terms/${item.id}`)) ?? word()
|
||||
return ok({ term: onPatch ? onPatch(body) : { ...base, ...body } })
|
||||
}
|
||||
return ok({})
|
||||
})
|
||||
}
|
||||
|
||||
async function viewAt(path: string): Promise<Router> {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div>LibraryStub</div>' } }, { path: '/vocab', component: VocabView }, { path: '/review', component: { template: '<div>ReviewStub</div>' } }],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
return router
|
||||
}
|
||||
|
||||
async function openVocab(path = '/vocab') {
|
||||
useSessionStore().user = { ...user }
|
||||
const router = await viewAt(path)
|
||||
wrapper = mount(VocabView, { attachTo: document.body, global: { plugins: [router] } })
|
||||
// The list lands one microtask after mount; flush twice so the first assertion sees it.
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
return { view: wrapper, router }
|
||||
}
|
||||
|
||||
describe('vocabulary store', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('loads one page with the current filters and reports paging state', async () => {
|
||||
const fetchMock = mockApi([word(), phrase()], 45)
|
||||
useSessionStore().user = { ...user }
|
||||
const vocabulary = useVocabularyStore()
|
||||
await vocabulary.load()
|
||||
const params = listCalls(fetchMock)[0]!
|
||||
expect(params.get('page')).toBe('1')
|
||||
expect(params.get('limit')).toBe(String(VOCAB_PAGE_SIZE))
|
||||
expect(params.get('query')).toBeNull()
|
||||
expect(vocabulary.items).toHaveLength(2)
|
||||
expect(vocabulary.total).toBe(45)
|
||||
expect(vocabulary.totalPages).toBe(3)
|
||||
})
|
||||
|
||||
it('applies search and filters from the first page and can clear them', async () => {
|
||||
const fetchMock = mockApi([word(), phrase()])
|
||||
useSessionStore().user = { ...user }
|
||||
const vocabulary = useVocabularyStore()
|
||||
vocabulary.page = 3
|
||||
vocabulary.query = 'small'
|
||||
vocabulary.status = 'learning'
|
||||
vocabulary.kind = 'phrase'
|
||||
await vocabulary.apply()
|
||||
expect(vocabulary.page).toBe(1)
|
||||
const params = listCalls(fetchMock)[0]!
|
||||
expect(params.get('query')).toBe('small')
|
||||
expect(params.get('status')).toBe('learning')
|
||||
expect(params.get('kind')).toBe('phrase')
|
||||
expect(vocabulary.items.map(item => item.id)).toEqual([2])
|
||||
await vocabulary.clearFilters()
|
||||
expect(vocabulary.hasFilters).toBe(false)
|
||||
expect(vocabulary.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clamps paging to the available pages', async () => {
|
||||
const fetchMock = mockApi([word()], 5)
|
||||
useSessionStore().user = { ...user }
|
||||
const vocabulary = useVocabularyStore()
|
||||
await vocabulary.load()
|
||||
await vocabulary.goToPage(99)
|
||||
expect(vocabulary.page).toBe(1)
|
||||
expect(listCalls(fetchMock)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the list when loading fails and reports the message', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('网络暂不可用'))
|
||||
useSessionStore().user = { ...user }
|
||||
const vocabulary = useVocabularyStore()
|
||||
await vocabulary.load()
|
||||
expect(vocabulary.error).toContain('网络暂不可用')
|
||||
expect(vocabulary.items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('replaces the edited row and drops the previous account state', async () => {
|
||||
mockApi([word(), phrase()], 2, body => ({ ...word(), definition: String(body.definition), status: 'learning', level: Number(body.level ?? 1) }))
|
||||
useSessionStore().user = { ...user }
|
||||
const vocabulary = useVocabularyStore()
|
||||
await vocabulary.load()
|
||||
const updated = await vocabulary.updateTerm(1, { definition: '好奇心(改)', examples: [], status: 'learning', level: 4 })
|
||||
expect(updated.level).toBe(4)
|
||||
expect(vocabulary.items[0]).toMatchObject({ definition: '好奇心(改)', status: 'learning', level: 4 })
|
||||
useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' }
|
||||
await flushPromises()
|
||||
expect(vocabulary.items).toHaveLength(0)
|
||||
expect(vocabulary.total).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vocabulary view', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); vi.restoreAllMocks() })
|
||||
afterEach(() => { wrapper?.unmount(); wrapper = undefined })
|
||||
|
||||
it('searches and filters through the toolbar and shows the server result', async () => {
|
||||
const fetchMock = mockApi([word(), phrase()], 2)
|
||||
const { view } = await openVocab()
|
||||
expect(view.findAll('.vocab-row')).toHaveLength(2)
|
||||
// The phrase row carries its kind and level.
|
||||
expect(view.text()).toContain('短语 · 学习中 · 等级 3')
|
||||
await view.get('#vocab-query').setValue('small')
|
||||
await view.get('[data-testid="vocab-search"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('query')).toBe('small')
|
||||
expect(view.findAll('.vocab-row')).toHaveLength(1)
|
||||
expect(view.text()).toContain('一小步')
|
||||
})
|
||||
|
||||
it('filters by status immediately and mirrors everything into the URL', async () => {
|
||||
const fetchMock = mockApi([word(), phrase()], 2)
|
||||
const { view, router } = await openVocab()
|
||||
const statusSelect = view.findAllComponents(ElSelect)[0]!
|
||||
statusSelect.vm.$emit('update:modelValue', 'learning')
|
||||
statusSelect.vm.$emit('change', 'learning')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('status')).toBe('learning')
|
||||
expect(view.findAll('.vocab-row')).toHaveLength(1)
|
||||
expect(router.currentRoute.value.query.status).toBe('learning')
|
||||
})
|
||||
|
||||
it('restores the filters written in the URL', async () => {
|
||||
const fetchMock = mockApi([word(), phrase()], 2)
|
||||
const { view } = await openVocab('/vocab?query=curio&kind=word&page=1')
|
||||
expect(listCalls(fetchMock)[0]!.get('query')).toBe('curio')
|
||||
expect(listCalls(fetchMock)[0]!.get('kind')).toBe('word')
|
||||
expect((view.get('#vocab-query').element as HTMLInputElement).value).toBe('curio')
|
||||
expect(view.findAll('.vocab-row')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('offers clearing the filters when nothing matches, and an empty state otherwise', async () => {
|
||||
mockApi([word()], 1)
|
||||
const { view } = await openVocab()
|
||||
await view.get('#vocab-query').setValue('zzzz')
|
||||
await view.get('[data-testid="vocab-search"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('[data-testid="vocab-none"]').text()).toContain('没有匹配的词条')
|
||||
await view.get('[data-testid="vocab-clear"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.findAll('.vocab-row')).toHaveLength(1)
|
||||
wrapper?.unmount()
|
||||
mockApi([], 0)
|
||||
const empty = await openVocab()
|
||||
expect(empty.view.get('[data-testid="vocab-empty"]').text()).toContain('生词本还是空的')
|
||||
})
|
||||
|
||||
it('edits an entry with a level selector and keeps the filters after saving', async () => {
|
||||
const fetchMock = mockApi([word(), phrase()], 2, body => ({ ...phrase(), definition: String(body.definition), status: 'learning', level: Number(body.level ?? 3) }))
|
||||
const { view } = await openVocab()
|
||||
const statusSelect = view.findAllComponents(ElSelect)[0]!
|
||||
statusSelect.vm.$emit('update:modelValue', 'learning')
|
||||
statusSelect.vm.$emit('change', 'learning')
|
||||
await flushPromises()
|
||||
await view.get('[data-testid="vocab-edit-2"]').trigger('click')
|
||||
await flushPromises()
|
||||
// The dialog is prefilled and shows the level selector for a learning entry.
|
||||
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
|
||||
expect(view.find('[data-testid="term-level"]').exists()).toBe(true)
|
||||
await view.get('#term-definition').setValue('一小步(改)')
|
||||
await view.get('[data-testid="vocab-save"]').trigger('click')
|
||||
await flushPromises()
|
||||
const patch = fetchMock.mock.calls.find(([, init]) => String((init as RequestInit | undefined)?.method) === 'PATCH')!
|
||||
expect(JSON.parse(String((patch[1] as RequestInit).body))).toMatchObject({ definition: '一小步(改)', status: 'learning', level: 3 })
|
||||
expect(view.get('[data-testid="vocab-notice"]').text()).toContain('已保存')
|
||||
expect(view.get('#vocab-status')).toBeTruthy()
|
||||
expect(view.findAll('.vocab-row')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides the level selector for other statuses and keeps the input when saving fails', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
if (String(init?.method) === 'PATCH') return new Response(JSON.stringify({ code: 400, msg: '学习等级须为 1~7' }), { status: 400 })
|
||||
return ok({ items: [word()], total: 1 })
|
||||
})
|
||||
const { view } = await openVocab()
|
||||
await view.get('[data-testid="vocab-edit-1"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.find('[data-testid="term-level"]').exists()).toBe(false)
|
||||
await view.get('#term-definition').setValue('改过的释义')
|
||||
await view.get('[data-testid="vocab-save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.text()).toContain('学习等级须为 1~7')
|
||||
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('改过的释义')
|
||||
expect(view.find('[data-testid="vocab-dialog"]').isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('pages forward and back with the server totals', async () => {
|
||||
// Three real pages of entries, so paging back and forth has content both ways.
|
||||
const many = Array.from({ length: 45 }, (_, index) => word({ id: index + 1, term: `word ${index + 1}` }))
|
||||
const fetchMock = mockApi(many, 45)
|
||||
const { view } = await openVocab()
|
||||
expect(view.get('.vocab-paging').text()).toContain('第 1 / 3 页')
|
||||
expect(view.findAll('.vocab-row')).toHaveLength(VOCAB_PAGE_SIZE)
|
||||
await view.get('[data-testid="vocab-next"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('page')).toBe('2')
|
||||
await view.get('[data-testid="vocab-prev"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('page')).toBe('1')
|
||||
})
|
||||
|
||||
it('links to the due review from the list', async () => {
|
||||
mockApi([word()], 1)
|
||||
const { view } = await openVocab()
|
||||
expect(view.get('.page-actions a[href="/review"]').text()).toContain('开始到期复习')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus'
|
||||
import { FONT_CHOICES, FONT_LABELS, THEME_CHOICES, THEME_LABELS, useDisplayStore } from '../stores/preferences'
|
||||
|
||||
// One header control for both display choices. The menu shows the current values, so the
|
||||
// learner can tell what is active without opening anything else.
|
||||
const display = useDisplayStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDropdown trigger="click" data-testid="display-settings" @command="(value: string) => {
|
||||
const [group, choice] = value.split(':')
|
||||
if (group === 'theme') display.setTheme(choice as never)
|
||||
if (group === 'font') display.setFont(choice as never)
|
||||
}">
|
||||
<button type="button" class="display-trigger" aria-label="显示设置" data-testid="display-trigger">
|
||||
显示
|
||||
<span class="display-current">{{ THEME_LABELS[display.theme] }} · {{ FONT_LABELS[display.font] }}</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem disabled>主题</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-for="choice in THEME_CHOICES"
|
||||
:key="choice"
|
||||
:command="`theme:${choice}`"
|
||||
:class="{ 'is-current': display.theme === choice }"
|
||||
>{{ THEME_LABELS[choice] }}{{ display.theme === choice ? ' ✓' : '' }}</ElDropdownItem>
|
||||
<ElDropdownItem divided disabled>正文字号</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-for="choice in FONT_CHOICES"
|
||||
:key="choice"
|
||||
:command="`font:${choice}`"
|
||||
:class="{ 'is-current': display.font === choice }"
|
||||
>{{ FONT_LABELS[choice] }}{{ display.font === choice ? ' ✓' : '' }}</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
|
||||
import { TERM_STATUSES, type LookupResult, type TermStatus } from '../composables/useReaderLookup'
|
||||
import { ElButton } from 'element-plus'
|
||||
import TermFormFields from './TermFormFields.vue'
|
||||
import { type LookupResult, type TermStatus } from '../composables/useReaderLookup'
|
||||
|
||||
defineProps<{
|
||||
word: string
|
||||
// Set when the selection is a phrase: the panel edits that phrase instead of a word.
|
||||
phrase: { wordCount: number; stored: boolean } | null
|
||||
adjust: { startLeft: boolean; startRight: boolean; endLeft: boolean; endRight: boolean } | null
|
||||
result: LookupResult | null
|
||||
loading: boolean
|
||||
error: string
|
||||
@@ -19,7 +23,10 @@ defineProps<{
|
||||
const definition = defineModel<string>('definition', { required: true })
|
||||
const examples = defineModel<string>('examples', { required: true })
|
||||
const status = defineModel<TermStatus>('status', { required: true })
|
||||
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number] }>()
|
||||
// The reader panel keeps the four statuses only, so the level field exists for the shared form
|
||||
// but is never shown here and never sent: the server keeps the earned level.
|
||||
const level = ref(1)
|
||||
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number]; adjust: [edge: 'start' | 'end', direction: -1 | 1] }>()
|
||||
const heading = ref<HTMLElement | null>(null)
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
let observer: ResizeObserver | undefined
|
||||
@@ -44,7 +51,16 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
|
||||
<h2 id="lookup-heading" ref="heading" tabindex="-1">{{ word }}</h2>
|
||||
<ElButton text aria-label="关闭释义" @click="$emit('close')">关闭</ElButton>
|
||||
</header>
|
||||
<div class="lookup-content" aria-live="polite" :aria-busy="loading">
|
||||
<div v-if="phrase" class="lookup-range" data-testid="phrase-range">
|
||||
<p class="subtle">短语 · {{ phrase.wordCount }} 个单词{{ phrase.stored ? ' · 已保存' : '' }}</p>
|
||||
<div class="lookup-range-actions">
|
||||
<ElButton size="small" data-testid="range-start-left" :disabled="!adjust?.startLeft" @click="$emit('adjust', 'start', -1)">起点 ←</ElButton>
|
||||
<ElButton size="small" data-testid="range-start-right" :disabled="!adjust?.startRight" @click="$emit('adjust', 'start', 1)">起点 →</ElButton>
|
||||
<ElButton size="small" data-testid="range-end-left" :disabled="!adjust?.endLeft" @click="$emit('adjust', 'end', -1)">终点 ←</ElButton>
|
||||
<ElButton size="small" data-testid="range-end-right" :disabled="!adjust?.endRight" @click="$emit('adjust', 'end', 1)">终点 →</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!phrase" class="lookup-content" aria-live="polite" :aria-busy="loading">
|
||||
<p v-if="loading" role="status" class="subtle">正在查询…</p>
|
||||
<p v-else-if="error" role="alert" class="lookup-message">{{ error }}</p>
|
||||
<template v-else-if="result">
|
||||
@@ -62,24 +78,21 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
|
||||
</template>
|
||||
<ElButton v-if="!loading && (error || result?.status === 'resource_missing' || result?.status === 'not_found')" data-testid="lookup-retry" @click="$emit('retry')">重试查询</ElButton>
|
||||
</div>
|
||||
<div class="lookup-term">
|
||||
<p class="lookup-term-title">学习状态</p>
|
||||
<ElRadioGroup v-model="status" :disabled="saving || prefilling || !!prefillError" aria-label="学习状态">
|
||||
<ElRadio v-for="item in TERM_STATUSES" :key="item.value" :value="item.value">{{ item.label }}</ElRadio>
|
||||
</ElRadioGroup>
|
||||
<p v-if="status === 'learning'" class="subtle">学习中按 1~7 级记录,等级由复习推进。</p>
|
||||
<label for="term-definition">我的释义 <span>{{ savedTermId ? '已保存' : '新词条' }}</span></label>
|
||||
<ElInput id="term-definition" v-model="definition" type="textarea" :rows="3" :disabled="saving || prefilling || !!prefillError" placeholder="记下当前词的释义" />
|
||||
<label for="term-examples">例句 <span>每行一条,最多 5 条</span></label>
|
||||
<ElInput id="term-examples" v-model="examples" type="textarea" :rows="2" :disabled="saving || prefilling || !!prefillError" placeholder="可选,每行一条" />
|
||||
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
|
||||
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
|
||||
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
|
||||
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
|
||||
<div class="lookup-actions">
|
||||
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
|
||||
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
|
||||
</div>
|
||||
<TermFormFields
|
||||
v-model:definition="definition"
|
||||
v-model:examples="examples"
|
||||
v-model:status="status"
|
||||
v-model:level="level"
|
||||
:hint="savedTermId ? '已保存' : '新词条'"
|
||||
:disabled="saving || prefilling || !!prefillError"
|
||||
/>
|
||||
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
|
||||
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
|
||||
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
|
||||
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
|
||||
<div class="lookup-actions">
|
||||
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
|
||||
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { termStatusOf, type ReaderToken } from '../composables/useReaderLookup'
|
||||
defineProps<{ tokens: ReaderToken[]; original: string; selectedStart?: number }>()
|
||||
const emit = defineEmits<{ select: [token: ReaderToken, element: HTMLElement] }>()
|
||||
function select(token: ReaderToken, event: Event) { emit('select', token, event.currentTarget as HTMLElement) }
|
||||
import { termStatusOf, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
|
||||
defineProps<{ tokens: ReaderToken[]; phrases: PhraseSpan[]; original: string; selectedStart?: number; phraseStart?: number | null }>()
|
||||
const emit = defineEmits<{
|
||||
select: [token: ReaderToken, element: HTMLElement, extend: boolean]
|
||||
selectPhrase: [span: PhraseSpan, element: HTMLElement]
|
||||
}>()
|
||||
|
||||
// A token belongs to the phrase that covers it; the spans come from the server match, so the
|
||||
// reader never re-derives which words form a phrase.
|
||||
function spanOf(phrases: PhraseSpan[], index: number): PhraseSpan | null {
|
||||
let best: PhraseSpan | null = null
|
||||
for (const phrase of phrases) {
|
||||
if (index < phrase.startToken || index > phrase.endToken) continue
|
||||
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function select(token: ReaderToken, event: Event, phrase: PhraseSpan | null): void {
|
||||
const element = event.currentTarget as HTMLElement
|
||||
if (phrase && !(event as MouseEvent).shiftKey) {
|
||||
emit('selectPhrase', phrase, element)
|
||||
return
|
||||
}
|
||||
emit('select', token, element, (event as MouseEvent).shiftKey === true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="reader-text"><template v-if="tokens.length"><template v-for="token in tokens" :key="token.start"><span v-if="token.kind === 'word'" role="button" tabindex="0" class="reader-word" :class="[termStatusOf(token) ? `is-${termStatusOf(token)}` : '', { 'is-selected': selectedStart === token.start }]" :aria-label="termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`" :aria-pressed="selectedStart === token.start" @click="select(token, $event)" @keydown.enter.prevent="select(token, $event)" @keydown.space.prevent="select(token, $event)">{{ token.text }}</span><template v-else>{{ token.text }}</template></template></template><template v-else>{{ original }}</template></article>
|
||||
<article class="reader-text"><template v-if="tokens.length"><template v-for="(token, index) in tokens" :key="token.start"><span
|
||||
class="reader-token"
|
||||
:data-token-index="index"
|
||||
:class="[
|
||||
token.kind === 'word' ? 'reader-word' : 'reader-separator',
|
||||
token.kind === 'word' && termStatusOf(token) ? `is-${termStatusOf(token)}` : '',
|
||||
spanOf(phrases, index) ? `is-phrase is-phrase-${spanOf(phrases, index)!.status}` : '',
|
||||
spanOf(phrases, index)?.startToken === index ? 'is-phrase-start' : '',
|
||||
spanOf(phrases, index)?.endToken === index ? 'is-phrase-end' : '',
|
||||
{ 'is-selected': token.kind === 'word' && selectedStart === token.start, 'is-phrase-selected': spanOf(phrases, index)?.id === phraseStart },
|
||||
]"
|
||||
v-bind="token.kind === 'word' ? { role: 'button', tabindex: '0', 'aria-pressed': selectedStart === token.start } : {}"
|
||||
:aria-label="token.kind === 'word' ? (spanOf(phrases, index) ? `短语中的 ${token.text}` : termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`) : undefined"
|
||||
@click="select(token, $event, token.kind === 'word' ? spanOf(phrases, index) : null)"
|
||||
@keydown.enter.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
|
||||
@keydown.space.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
|
||||
>{{ token.text }}</span></template></template><template v-else>{{ original }}</template></article>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { ElButton } from 'element-plus'
|
||||
import { clozeSentence, type ReviewItem } from '../stores/review'
|
||||
import { maskedPrompt, type ReviewItem } from '../stores/review'
|
||||
|
||||
const props = defineProps<{ item: ReviewItem; position: number; total: number; revealed: boolean; busy: boolean; error: string }>()
|
||||
const emit = defineEmits<{ reveal: []; grade: [grade: 'correct' | 'wrong' | 'again']; end: []; retry: [] }>()
|
||||
// The word and its masked example are enough to answer; the definition stays hidden.
|
||||
const prompt = () => clozeSentence(props.item)
|
||||
const prompt = () => maskedPrompt(props.item)
|
||||
const revealButton = ref<{ $el?: HTMLElement } | null>(null)
|
||||
const correctButton = ref<{ $el?: HTMLElement } | null>(null)
|
||||
// Keyboard users should reach the next action without tabbing through the page again.
|
||||
@@ -26,7 +26,7 @@ watch(() => props.revealed, async value => {
|
||||
<p class="subtle" data-testid="review-position">到期复习 · {{ position }} / {{ total }}</p>
|
||||
<h2 id="review-word" tabindex="-1" class="review-word">{{ item.originalForm }}</h2>
|
||||
<p v-if="prompt()" class="review-example" lang="en">{{ prompt() }}</p>
|
||||
<p class="subtle">{{ item.status === 'learning' ? `学习中 · 等级 ${item.level}` : '新词 · 尚未复习' }}</p>
|
||||
<p class="subtle">{{ item.kind === 'phrase' ? `短语 · ${item.wordCount} 个单词 · ` : '' }}{{ item.status === 'learning' ? `学习中 · 等级 ${item.level}` : '新词 · 尚未复习' }}</p>
|
||||
<p v-if="error" role="alert" class="notice">{{ error }}</p>
|
||||
|
||||
<template v-if="!revealed">
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ReaderToken } from './useReaderLookup'
|
||||
|
||||
/** A phrase selection over the server's own tokens, or a candidate the reader can adjust. */
|
||||
export interface TokenRange {
|
||||
/** Token indices of the first and last word of the phrase. */
|
||||
firstWord: number
|
||||
lastWord: number
|
||||
/** Code point range of the whole phrase inside the original text. */
|
||||
start: number
|
||||
end: number
|
||||
/** The original text exactly as the chapter holds it. */
|
||||
text: string
|
||||
wordCount: number
|
||||
}
|
||||
|
||||
export const MAX_PHRASE_WORDS = 12
|
||||
|
||||
/** Indices of the word tokens, in reading order. */
|
||||
export function wordIndices(tokens: ReaderToken[]): number[] {
|
||||
const indices: number[] = []
|
||||
for (let index = 0; index < tokens.length; index++) {
|
||||
if (tokens[index]!.kind === 'word') indices.push(index)
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a phrase range from two token indices, aligning both ends to whole words and
|
||||
* keeping interior punctuation and line breaks in the text. It mirrors the rules verified in
|
||||
* #4: separators at the edges are skipped, interior ones survive, and a single word is not a
|
||||
* phrase.
|
||||
*/
|
||||
export function normalizeTokenRange(tokens: ReaderToken[], original: string, anchor: number, focus: number): TokenRange | null {
|
||||
if (!Number.isInteger(anchor) || !Number.isInteger(focus)) return null
|
||||
const from = Math.min(anchor, focus)
|
||||
const to = Math.max(anchor, focus)
|
||||
if (from < 0 || to >= tokens.length) return null
|
||||
let firstWord = -1
|
||||
let lastWord = -1
|
||||
for (let index = from; index <= to; index++) {
|
||||
if (tokens[index]!.kind !== 'word') continue
|
||||
if (firstWord < 0) firstWord = index
|
||||
lastWord = index
|
||||
}
|
||||
if (firstWord < 0 || lastWord < 0 || firstWord === lastWord) return null
|
||||
return rangeOfWords(tokens, original, firstWord, lastWord)
|
||||
}
|
||||
|
||||
/** The phrase range for a known first and last word token. */
|
||||
export function rangeOfWords(tokens: ReaderToken[], original: string, firstWord: number, lastWord: number): TokenRange | null {
|
||||
const first = tokens[firstWord]
|
||||
const last = tokens[lastWord]
|
||||
if (!first || !last || first.kind !== 'word' || last.kind !== 'word' || lastWord < firstWord) return null
|
||||
const wordCount = wordIndices(tokens.slice(firstWord, lastWord + 1)).length
|
||||
if (wordCount < 2 || wordCount > MAX_PHRASE_WORDS) return null
|
||||
const text = [...original].slice(first.start, last.end).join('')
|
||||
if (!text) return null
|
||||
return { firstWord, lastWord, start: first.start, end: last.end, text, wordCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves one end of a phrase by whole words, skipping separators and never inverting the range.
|
||||
* Returns the adjusted range, or null when the move is impossible.
|
||||
*/
|
||||
export function adjustTokenRange(tokens: ReaderToken[], original: string, range: TokenRange, edge: 'start' | 'end', direction: -1 | 1): TokenRange | null {
|
||||
const words = wordIndices(tokens)
|
||||
const positionOf = (tokenIndex: number) => words.indexOf(tokenIndex)
|
||||
const firstPosition = positionOf(range.firstWord)
|
||||
const lastPosition = positionOf(range.lastWord)
|
||||
if (firstPosition < 0 || lastPosition < 0) return null
|
||||
if (edge === 'start') {
|
||||
const next = firstPosition + direction
|
||||
if (next < 0 || next > lastPosition - 1) return null
|
||||
return rangeOfWords(tokens, original, words[next]!, range.lastWord)
|
||||
}
|
||||
const next = lastPosition + direction
|
||||
if (next >= words.length || next < firstPosition + 1) return null
|
||||
return rangeOfWords(tokens, original, range.firstWord, words[next]!)
|
||||
}
|
||||
|
||||
/** The saved phrases that cover a word token, longest first, so a click prefers the phrase. */
|
||||
export function phrasesAt(tokens: ReaderToken[], phrases: { id: number; startToken: number; endToken: number }[], tokenIndex: number): { id: number; startToken: number; endToken: number } | null {
|
||||
let best: { id: number; startToken: number; endToken: number } | null = null
|
||||
for (const phrase of phrases) {
|
||||
if (tokenIndex < phrase.startToken || tokenIndex > phrase.endToken) continue
|
||||
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** The phrase range a saved span covers, used when a highlighted phrase is clicked. */
|
||||
export function rangeOfSpan(tokens: ReaderToken[], original: string, span: { startToken: number; endToken: number }): TokenRange | null {
|
||||
const words = wordIndices(tokens.slice(span.startToken, span.endToken + 1))
|
||||
if (words.length < 2) return null
|
||||
const firstWord = span.startToken + words[0]!
|
||||
const lastWord = span.startToken + words[words.length - 1]!
|
||||
return rangeOfWords(tokens, original, firstWord, lastWord)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Remembers where the reader stopped in a chapter. The position belongs to one account and one
|
||||
* chapter, and it is tied to the chapter's content version: after the text is replaced, the old
|
||||
* offset would point at different words, so it is dropped instead of restored.
|
||||
*
|
||||
* The value is a scroll ratio rather than a pixel offset, because the same chapter is read at
|
||||
* different window widths (desktop and phone) where a pixel offset would land in the wrong place.
|
||||
* This is local to the browser: it is not synced across devices.
|
||||
*/
|
||||
export const POSITION_KEY_PREFIX = 'lexgo-learner-position:'
|
||||
|
||||
export interface ReadingPosition {
|
||||
ratio: number
|
||||
sha: string
|
||||
at: string
|
||||
}
|
||||
|
||||
export function positionKey(userId: number | null, chapterId: number): string | null {
|
||||
if (userId === null || !Number.isInteger(chapterId) || chapterId <= 0) return null
|
||||
return `${POSITION_KEY_PREFIX}${userId}:${chapterId}`
|
||||
}
|
||||
|
||||
/** Clamps a stored ratio into [0,1]; anything else is treated as "no usable position". */
|
||||
export function normalizeRatio(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null
|
||||
if (value <= 0) return null
|
||||
return Math.min(value, 1)
|
||||
}
|
||||
|
||||
export function readPosition(storage: Storage, userId: number | null, chapterId: number, sha: string): ReadingPosition | null {
|
||||
const key = positionKey(userId, chapterId)
|
||||
if (key === null) return null
|
||||
try {
|
||||
const raw = storage.getItem(key)
|
||||
if (raw === null) return null
|
||||
const parsed = JSON.parse(raw) as Partial<ReadingPosition> | null
|
||||
const ratio = normalizeRatio(parsed?.ratio)
|
||||
// A different content version means the offset describes words that are no longer there.
|
||||
if (ratio === null || !parsed || parsed.sha !== sha) return null
|
||||
return { ratio, sha, at: typeof parsed.at === 'string' ? parsed.at : '' }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writePosition(storage: Storage, userId: number | null, chapterId: number, sha: string, ratio: number, now = new Date()): void {
|
||||
const key = positionKey(userId, chapterId)
|
||||
const usable = normalizeRatio(ratio)
|
||||
if (key === null || usable === null || !sha) return
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ ratio: usable, sha, at: now.toISOString() } satisfies ReadingPosition))
|
||||
} catch {
|
||||
// A full or disabled store must not break reading.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPosition(storage: Storage, userId: number | null, chapterId: number): void {
|
||||
const key = positionKey(userId, chapterId)
|
||||
if (key === null) return
|
||||
try {
|
||||
storage.removeItem(key)
|
||||
} catch {
|
||||
// Ignored for the same reason as above.
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReadingPositionOptions {
|
||||
/** The scrollable document the reader lives in. */
|
||||
view: () => Window
|
||||
/** The current scrollable height, used to turn a pixel offset into a ratio. */
|
||||
scrollHeight: () => number
|
||||
}
|
||||
|
||||
/** Turns the current window scroll offset into the ratio that is stored. */
|
||||
export function currentRatio(view: Pick<Window, 'scrollY' | 'innerHeight'>, scrollHeight: number): number {
|
||||
const scrollable = scrollHeight - view.innerHeight
|
||||
if (scrollable <= 0) return 0
|
||||
return Math.min(Math.max(view.scrollY / scrollable, 0), 1)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
export type ReviewShortcut = 'reveal' | 'correct' | 'wrong' | 'again'
|
||||
|
||||
export interface ReviewShortcutOptions {
|
||||
/** Called for a shortcut that applies in the current card state. */
|
||||
onShortcut: (action: ReviewShortcut) => void
|
||||
/** False while the card is busy or an answer failed; then no shortcut is accepted. */
|
||||
enabled: () => boolean
|
||||
/** Whether the answer is already on screen. */
|
||||
revealed: () => boolean
|
||||
}
|
||||
|
||||
const GRADE_KEYS: Record<string, ReviewShortcut> = { '1': 'correct', '2': 'wrong', '3': 'again' }
|
||||
|
||||
/**
|
||||
* True when the key belongs to whatever currently has the focus instead of the review.
|
||||
*
|
||||
* Typing fields and dropdowns own every key. A focused button or link owns Space and Enter,
|
||||
* because the browser already activates it there and a second handler would answer twice — but a
|
||||
* digit is not a button key, so grading from the keyboard keeps working while a button is focused.
|
||||
*/
|
||||
export function ignoresShortcut(target: EventTarget | null, key: string): boolean {
|
||||
const element = target as HTMLElement | null
|
||||
if (!element || typeof element.closest !== 'function') return false
|
||||
if (element.isContentEditable) return true
|
||||
if (element.closest('input, textarea, select, [contenteditable="true"], .el-select, .el-dropdown') !== null) return true
|
||||
if (key === ' ' || key === 'Enter') {
|
||||
return element.closest('button, a[href], [role="button"]') !== null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Maps one key press to a review action, or null when the key is not ours. */
|
||||
export function shortcutFor(key: string, revealed: boolean): ReviewShortcut | null {
|
||||
if (key === ' ' || key === 'Enter') return revealed ? null : 'reveal'
|
||||
if (!revealed) return null
|
||||
return GRADE_KEYS[key] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard operations for the due review: Space or Enter shows the answer, then 1, 2 and 3 grade
|
||||
* it. A modifier key means the learner is using a browser or system shortcut, so it is left alone.
|
||||
*/
|
||||
export function useReviewShortcuts(options: ReviewShortcutOptions) {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return
|
||||
if (!options.enabled()) return
|
||||
if (ignoresShortcut(event.target, event.key)) return
|
||||
const action = shortcutFor(event.key, options.revealed())
|
||||
if (action === null) return
|
||||
event.preventDefault()
|
||||
options.onShortcut(action)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
onScopeDispose(() => document.removeEventListener('keydown', onKeydown))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||
import type { ChapterDetail } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { adjustTokenRange, type TokenRange } from './readerRange'
|
||||
|
||||
export type TermStatus = 'new' | 'learning' | 'known' | 'ignored'
|
||||
|
||||
@@ -14,6 +15,8 @@ export interface ReaderToken {
|
||||
kind: 'word' | 'space' | 'punctuation'
|
||||
term?: TokenTerm | null
|
||||
}
|
||||
/** One saved phrase occurrence inside the chapter, in token indices. */
|
||||
export interface PhraseSpan { id: number; status: TermStatus; wordCount: number; startToken: number; endToken: number }
|
||||
export interface LookupResult {
|
||||
status: 'exact' | 'lemma' | 'not_found' | 'resource_missing'
|
||||
query: string
|
||||
@@ -30,9 +33,11 @@ export interface SavedTerm {
|
||||
examples: string[]
|
||||
status: TermStatus
|
||||
level: number
|
||||
kind: 'word' | 'phrase'
|
||||
wordCount: number
|
||||
}
|
||||
interface TermResponse { term: SavedTerm }
|
||||
interface TokenResponse { textSha256: string; tokens: ReaderToken[] }
|
||||
interface TokenResponse { textSha256: string; tokens: ReaderToken[]; phrases?: PhraseSpan[] }
|
||||
|
||||
// The four learner-visible states and the level rule behind them. Only a learning
|
||||
// entry carries a level, so every other status reports level 0.
|
||||
@@ -72,9 +77,13 @@ function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean {
|
||||
export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
const session = useSessionStore()
|
||||
const tokens = ref<ReaderToken[]>([])
|
||||
const phrases = ref<PhraseSpan[]>([])
|
||||
const tokensError = ref('')
|
||||
const tokensLoading = ref(false)
|
||||
const selected = ref<ReaderToken | null>(null)
|
||||
// A phrase selection: either a drag over several words or a click on a saved phrase span.
|
||||
const range = ref<TokenRange | null>(null)
|
||||
const rangeTermId = ref<number | null>(null)
|
||||
const result = ref<LookupResult | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
@@ -97,7 +106,7 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
|
||||
// Sending is blocked until the stored text is on screen, so a slow or failed read
|
||||
// can never let an empty form overwrite what the learner already wrote.
|
||||
const canSave = computed(() => selected.value !== null && !saving.value && !prefilling.value && !prefillError.value)
|
||||
const canSave = computed(() => (selected.value !== null || range.value !== null) && !saving.value && !prefilling.value && !prefillError.value)
|
||||
|
||||
function exampleLines(): string[] {
|
||||
return examples.value.split('\n').map(line => line.trim()).filter(Boolean)
|
||||
@@ -131,6 +140,8 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
termSequence++
|
||||
saveSequence++
|
||||
selected.value = null
|
||||
range.value = null
|
||||
rangeTermId.value = null
|
||||
result.value = null
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
@@ -152,6 +163,7 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
function reset() {
|
||||
tokenSequence++
|
||||
tokens.value = []
|
||||
phrases.value = []
|
||||
tokensError.value = ''
|
||||
tokensLoading.value = false
|
||||
close(false)
|
||||
@@ -167,16 +179,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
if (seq !== tokenSequence) return
|
||||
if (!matchesChapter(data, current)) throw new Error('分词与正文不一致,请重试。')
|
||||
tokens.value = data.tokens
|
||||
phrases.value = Array.isArray(data.phrases) ? data.phrases : []
|
||||
} catch (reason) {
|
||||
if (seq !== tokenSequence) return
|
||||
tokens.value = []
|
||||
phrases.value = []
|
||||
tokensError.value = reason instanceof Error ? reason.message : '单词暂时无法加载。'
|
||||
} finally { if (seq === tokenSequence) tokensLoading.value = false }
|
||||
}
|
||||
async function lookup() {
|
||||
const current = chapter.value
|
||||
const token = selected.value
|
||||
if (!session.user || !current || !token) return
|
||||
if (!session.user || !current || !token || range.value) return
|
||||
const seq = ++lookupSequence
|
||||
result.value = null
|
||||
error.value = ''
|
||||
@@ -208,15 +222,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
}
|
||||
async function save() {
|
||||
const current = chapter.value
|
||||
const selection = range.value
|
||||
const token = selected.value
|
||||
if (!session.user || !current || !token || !canSave.value) return
|
||||
if (!session.user || !current || (!selection && !token) || !canSave.value) return
|
||||
const seq = ++saveSequence
|
||||
saving.value = true
|
||||
saveError.value = ''
|
||||
saved.value = ''
|
||||
const span = selection ?? token!
|
||||
const path = selection ? 'phrases' : 'terms'
|
||||
try {
|
||||
const data = await session.request<TermResponse & { created: boolean }>('terms', 'POST', {
|
||||
chapterId: current.id, start: token.start, end: token.end,
|
||||
const data = await session.request<TermResponse & { created: boolean }>(path, 'POST', {
|
||||
chapterId: current.id, start: span.start, end: span.end,
|
||||
definition: definition.value, examples: exampleLines(), status: status.value,
|
||||
})
|
||||
if (seq !== saveSequence) return
|
||||
@@ -224,6 +241,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
examples.value = data.term.examples.join('\n')
|
||||
status.value = data.term.status
|
||||
applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level })
|
||||
if (selection) {
|
||||
// The saved phrase must highlight here without waiting for another token load.
|
||||
rangeTermId.value = data.term.id
|
||||
const savedSpan = {
|
||||
id: data.term.id, status: data.term.status, wordCount: selection.wordCount,
|
||||
startToken: selection.firstWord, endToken: selection.lastWord,
|
||||
}
|
||||
const existing = phrases.value.findIndex(item => item.id === data.term.id)
|
||||
phrases.value = existing >= 0
|
||||
? phrases.value.map((item, index) => (index === existing ? savedSpan : item))
|
||||
: [...phrases.value, savedSpan]
|
||||
}
|
||||
saved.value = `已保存 · ${termStatusLabel(data.term.status)}`
|
||||
} catch (reason) {
|
||||
if (seq !== saveSequence) return
|
||||
@@ -241,6 +270,37 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
}
|
||||
void lookup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the phrase form for a dragged range or for a saved phrase span. A phrase is one
|
||||
* entry in the same table as a word, so an existing phrase is read through the same route.
|
||||
*/
|
||||
function selectRange(next: TokenRange, element: HTMLElement | null, termId: number | null = null) {
|
||||
close(false)
|
||||
origin = element
|
||||
range.value = next
|
||||
rangeTermId.value = termId
|
||||
if (termId !== null) void loadTerm(termId)
|
||||
}
|
||||
|
||||
/** Moves one end of the phrase by whole words; the panel keeps the selection in sync. */
|
||||
function adjustRange(edge: 'start' | 'end', direction: -1 | 1) {
|
||||
const current = range.value
|
||||
const currentChapter = chapter.value
|
||||
if (!current || !currentChapter) return
|
||||
const next = adjustTokenRange(tokens.value, currentChapter.originalText ?? '', current, edge, direction)
|
||||
if (!next) return
|
||||
// The adjusted range is a different identity, so the panel must stop claiming that the
|
||||
// stored entry is what is on screen: both the phrase link and the entry the form was loaded
|
||||
// from are dropped. The typed text stays, and saving it writes the phrase that matches the
|
||||
// current range (which may update another entry, never two).
|
||||
rangeTermId.value = null
|
||||
savedTermId.value = null
|
||||
range.value = next
|
||||
}
|
||||
|
||||
/** True when the panel is editing an entry that is already stored. */
|
||||
const rangeStored = computed(() => rangeTermId.value !== null)
|
||||
watch(() => [chapter.value?.id, chapter.value?.status, chapter.value?.contentSha256, chapter.value?.originalText], () => {
|
||||
reset()
|
||||
void loadTokens()
|
||||
@@ -250,8 +310,8 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
||||
watch(() => session.user, reset, { flush: 'sync' })
|
||||
onScopeDispose(reset)
|
||||
return {
|
||||
tokens, tokensError, tokensLoading, selected, result, loading, error,
|
||||
tokens, phrases, tokensError, tokensLoading, selected, range, rangeTermId, rangeStored, result, loading, error,
|
||||
definition, examples, status, savedTermId, prefilling, prefillError, saving, saveError, saved, canSave,
|
||||
loadTokens, lookup, loadTerm, save, select, close, reset, keepSelectionVisible,
|
||||
loadTokens, lookup, loadTerm, save, select, selectRange, adjustRange, close, reset, keepSelectionVisible,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { onScopeDispose, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Maps the browser's own selection onto the server tokens. Nothing here intercepts touch or
|
||||
* pointer movement: a mouse drag and the phone's system selection handles both produce a
|
||||
* selectionchange, which is the interaction #4 verified.
|
||||
*/
|
||||
export function tokenIndexOf(node: Node | null): number {
|
||||
if (!node) return -1
|
||||
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
|
||||
const holder = element?.closest?.('[data-token-index]')
|
||||
if (!holder) return -1
|
||||
const value = Number((holder as HTMLElement).dataset.tokenIndex)
|
||||
return Number.isInteger(value) ? value : -1
|
||||
}
|
||||
|
||||
export interface TextSelectionOptions {
|
||||
/** The element the selection has to start inside. */
|
||||
container: Ref<HTMLElement | null>
|
||||
/** Called with the first and last token index of a non-empty selection inside the reader. */
|
||||
onSelect: (anchor: number, focus: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the native selection and reports token indices. A collapsed selection (a plain
|
||||
* click) reports nothing, so clicking a word keeps opening the word panel.
|
||||
*/
|
||||
export function useTextSelection(options: TextSelectionOptions) {
|
||||
let timer: number | undefined
|
||||
let disposed = false
|
||||
|
||||
function read(): void {
|
||||
if (disposed) return
|
||||
const root = options.container.value
|
||||
const selection = window.getSelection?.()
|
||||
if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) return
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return
|
||||
const anchor = tokenIndexOf(range.startContainer)
|
||||
const focus = tokenIndexOf(range.endContainer)
|
||||
if (anchor < 0 || focus < 0) return
|
||||
options.onSelect(Math.min(anchor, focus), Math.max(anchor, focus))
|
||||
}
|
||||
|
||||
// The debounce matches the verified spike: it lets the browser finish a drag before the
|
||||
// range is read, and it never blocks scrolling on a touch device.
|
||||
function schedule(): void {
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => { timer = undefined; read() }, 100)
|
||||
}
|
||||
|
||||
function pointerUp(event: PointerEvent): void {
|
||||
// Only a pointerup inside the reader can finish a selection; a click on the panel must not
|
||||
// re-read a range the learner already adjusted there.
|
||||
const root = options.container.value
|
||||
const target = event.target as Node | null
|
||||
if (!root || !target || !root.contains(target)) return
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
// A pointerup arrives before the browser finalises the range, so the read waits a tick.
|
||||
timer = window.setTimeout(() => { timer = undefined; read() }, 0)
|
||||
}
|
||||
|
||||
// Both listeners live on the document: the reader body only exists once a chapter is ready,
|
||||
// so a listener bound to the element at setup time would miss every later selection.
|
||||
document.addEventListener('selectionchange', schedule)
|
||||
document.addEventListener('pointerup', pointerUp)
|
||||
|
||||
onScopeDispose(() => {
|
||||
disposed = true
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
document.removeEventListener('selectionchange', schedule)
|
||||
document.removeEventListener('pointerup', pointerUp)
|
||||
})
|
||||
|
||||
return {
|
||||
/** Drops the native highlight, e.g. when the panel closes. */
|
||||
clear() { window.getSelection?.()?.removeAllRanges() },
|
||||
read,
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import 'element-plus/es/components/radio/style/css'
|
||||
import 'element-plus/es/components/radio-group/style/css'
|
||||
import 'element-plus/es/components/select/style/css'
|
||||
import 'element-plus/es/components/option/style/css'
|
||||
import 'element-plus/es/components/dropdown/style/css'
|
||||
import 'element-plus/es/components/dropdown-menu/style/css'
|
||||
import 'element-plus/es/components/dropdown-item/style/css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useSessionStore } from './session'
|
||||
|
||||
/** The three display choices the learner can make. */
|
||||
export type ThemeChoice = 'light' | 'dark' | 'system'
|
||||
export type FontChoice = 'standard' | 'large' | 'xlarge'
|
||||
|
||||
export const THEME_CHOICES: ThemeChoice[] = ['light', 'dark', 'system']
|
||||
export const FONT_CHOICES: FontChoice[] = ['standard', 'large', 'xlarge']
|
||||
|
||||
export const THEME_LABELS: Record<ThemeChoice, string> = { light: '浅色', dark: '深色', system: '跟随系统' }
|
||||
export const FONT_LABELS: Record<FontChoice, string> = { standard: '标准', large: '大', xlarge: '特大' }
|
||||
|
||||
/**
|
||||
* The reader text scale. It multiplies the reading surfaces only (chapter text, lookup content,
|
||||
* review card), so a larger step never squeezes the navigation or the buttons in a narrow window.
|
||||
*/
|
||||
export const FONT_SCALES: Record<FontChoice, string> = { standard: '1', large: '1.15', xlarge: '1.3' }
|
||||
|
||||
export interface DisplayPreference {
|
||||
theme: ThemeChoice
|
||||
font: FontChoice
|
||||
}
|
||||
|
||||
/** Preferences are stored per account, so switching accounts cannot carry one across. */
|
||||
export const DISPLAY_KEY_PREFIX = 'lexgo-learner-display:'
|
||||
|
||||
export function displayKey(userId: number | null): string | null {
|
||||
return userId === null ? null : `${DISPLAY_KEY_PREFIX}${userId}`
|
||||
}
|
||||
|
||||
export function defaultPreference(): DisplayPreference {
|
||||
return { theme: 'system', font: 'standard' }
|
||||
}
|
||||
|
||||
function isTheme(value: unknown): value is ThemeChoice {
|
||||
return typeof value === 'string' && (THEME_CHOICES as string[]).includes(value)
|
||||
}
|
||||
|
||||
function isFont(value: unknown): value is FontChoice {
|
||||
return typeof value === 'string' && (FONT_CHOICES as string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one account's stored display preference. Anything unreadable, unknown or from an older
|
||||
* shape falls back to the default instead of throwing or half-applying a stored value.
|
||||
*/
|
||||
export function readPreference(storage: Storage, userId: number | null): DisplayPreference {
|
||||
const key = displayKey(userId)
|
||||
if (key === null) return defaultPreference()
|
||||
try {
|
||||
const raw = storage.getItem(key)
|
||||
if (raw === null) return defaultPreference()
|
||||
const parsed = JSON.parse(raw) as Partial<DisplayPreference> | null
|
||||
const preference = defaultPreference()
|
||||
if (parsed && isTheme(parsed.theme)) preference.theme = parsed.theme
|
||||
if (parsed && isFont(parsed.font)) preference.font = parsed.font
|
||||
return preference
|
||||
} catch {
|
||||
return defaultPreference()
|
||||
}
|
||||
}
|
||||
|
||||
export function writePreference(storage: Storage, userId: number | null, preference: DisplayPreference): void {
|
||||
const key = displayKey(userId)
|
||||
if (key === null) return
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ theme: preference.theme, font: preference.font }))
|
||||
} catch {
|
||||
// A full or disabled store must not break the page: the choice still applies for this visit.
|
||||
}
|
||||
}
|
||||
|
||||
/** "system" resolves through the operating system preference; the other two are explicit. */
|
||||
export function resolveTheme(theme: ThemeChoice, prefersDark: boolean): 'light' | 'dark' {
|
||||
if (theme === 'system') return prefersDark ? 'dark' : 'light'
|
||||
return theme
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a preference to the document: the palette attribute, the Element Plus dark class and
|
||||
* the reading font scale. The dark class is what Element Plus components read.
|
||||
*/
|
||||
export function applyPreference(root: HTMLElement, preference: DisplayPreference, prefersDark: boolean): 'light' | 'dark' {
|
||||
const resolved = resolveTheme(preference.theme, prefersDark)
|
||||
root.dataset.theme = resolved
|
||||
root.dataset.themeChoice = preference.theme
|
||||
root.classList.toggle('dark', resolved === 'dark')
|
||||
root.style.setProperty('--reader-font-scale', FONT_SCALES[preference.font])
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function systemPrefersDark(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false
|
||||
}
|
||||
|
||||
export const useDisplayStore = defineStore('display', () => {
|
||||
const session = useSessionStore()
|
||||
const preference = ref<DisplayPreference>(defaultPreference())
|
||||
const resolved = ref<'light' | 'dark'>('light')
|
||||
let listening: MediaQueryList | null = null
|
||||
|
||||
const theme = computed(() => preference.value.theme)
|
||||
const font = computed(() => preference.value.font)
|
||||
const isDark = computed(() => resolved.value === 'dark')
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return window.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
if (typeof document === 'undefined') return
|
||||
resolved.value = applyPreference(document.documentElement, preference.value, systemPrefersDark())
|
||||
}
|
||||
|
||||
/** Watches the operating system only while the choice actually follows it. */
|
||||
function followSystem(): void {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
||||
listening?.removeEventListener('change', paint)
|
||||
listening = null
|
||||
if (preference.value.theme !== 'system') return
|
||||
listening = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
listening.addEventListener('change', paint)
|
||||
}
|
||||
|
||||
/** Loads the account's own preference (or the default) and applies it at once. */
|
||||
function load(userId: number | null): void {
|
||||
const store = storage()
|
||||
preference.value = store ? readPreference(store, userId) : defaultPreference()
|
||||
paint()
|
||||
followSystem()
|
||||
}
|
||||
|
||||
function save(next: Partial<DisplayPreference>): void {
|
||||
if (next.theme !== undefined && isTheme(next.theme)) preference.value.theme = next.theme
|
||||
if (next.font !== undefined && isFont(next.font)) preference.value.font = next.font
|
||||
const store = storage()
|
||||
if (store) writePreference(store, session.user?.id ?? null, preference.value)
|
||||
paint()
|
||||
followSystem()
|
||||
}
|
||||
|
||||
watch(() => session.user?.id ?? null, (next, previous) => {
|
||||
if (next !== previous) load(next)
|
||||
}, { immediate: true })
|
||||
|
||||
return { preference, theme, font, resolved, isDark, load, save, setTheme: (value: ThemeChoice) => save({ theme: value }), setFont: (value: FontChoice) => save({ font: value }) }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -15,6 +15,8 @@ export interface ReviewItem {
|
||||
examples: string[]
|
||||
status: 'new' | 'learning'
|
||||
level: number
|
||||
kind: 'word' | 'phrase'
|
||||
wordCount: number
|
||||
dueAt: string
|
||||
reviewCount: number
|
||||
}
|
||||
@@ -57,6 +59,22 @@ export function clozeSentence(item: ReviewItem): string | null {
|
||||
return masked
|
||||
}
|
||||
|
||||
/**
|
||||
* A phrase is masked as one blank covering the whole run, which is what the accepted prototype
|
||||
* shows. Interior separators of any non-letter run are allowed, matching the server's phrase
|
||||
* identity rule.
|
||||
*/
|
||||
export function maskedPrompt(item: ReviewItem): string | null {
|
||||
const line = item.examples[0]
|
||||
if (!line) return null
|
||||
if (item.kind !== 'phrase') return clozeSentence(item)
|
||||
const forms = item.term.split(' ').filter(Boolean)
|
||||
if (forms.length < 2) return clozeSentence(item)
|
||||
const parts = forms.map(form => escapeRegExp(form).replace(/'/g, "['’]"))
|
||||
const pattern = new RegExp(parts.join(`[\\p{L}\\p{M}]*?[^\\p{L}\\p{M}]+[\\p{L}\\p{M}]*?`), 'iu')
|
||||
return line.replace(pattern, '_____')
|
||||
}
|
||||
|
||||
export const useReviewStore = defineStore('review', () => {
|
||||
const session = useSessionStore()
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useSessionStore } from './session'
|
||||
import type { TermStatus } from '../composables/useReaderLookup'
|
||||
|
||||
/** One entry of the vocabulary list, exactly as the term endpoints return it. */
|
||||
export interface VocabularyTerm {
|
||||
id: number
|
||||
term: string
|
||||
originalForm: string
|
||||
definition: string
|
||||
examples: string[]
|
||||
status: TermStatus
|
||||
level: number
|
||||
kind: 'word' | 'phrase'
|
||||
wordCount: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface VocabularyEdit {
|
||||
definition: string
|
||||
examples: string[]
|
||||
status: TermStatus
|
||||
level?: number
|
||||
}
|
||||
|
||||
export const VOCAB_PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* The vocabulary list is one server page at a time. Filters live in the store so that opening
|
||||
* and closing the edit dialog, or coming back from the reader, keeps what the learner searched
|
||||
* for; the view mirrors them into the URL for reloads.
|
||||
*/
|
||||
export const useVocabularyStore = defineStore('vocabulary', () => {
|
||||
const session = useSessionStore()
|
||||
const items = ref<VocabularyTerm[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const query = ref('')
|
||||
const status = ref<TermStatus | ''>('')
|
||||
const kind = ref<'word' | 'phrase' | ''>('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
const saveError = ref('')
|
||||
let sequence = 0
|
||||
let generation = 0
|
||||
|
||||
const hasFilters = computed(() => query.value !== '' || status.value !== '' || kind.value !== '')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / VOCAB_PAGE_SIZE)))
|
||||
|
||||
watch(() => session.user?.id ?? null, (next, previous) => {
|
||||
if (next !== previous) reset()
|
||||
}, { flush: 'sync' })
|
||||
|
||||
function ownerId(): number | null {
|
||||
return session.user?.id ?? null
|
||||
}
|
||||
|
||||
function isStale(version: number, owner: number | null): boolean {
|
||||
return version !== generation || ownerId() !== owner
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
generation++
|
||||
sequence++
|
||||
items.value = []
|
||||
total.value = 0
|
||||
page.value = 1
|
||||
query.value = ''
|
||||
status.value = ''
|
||||
kind.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
saving.value = false
|
||||
saveError.value = ''
|
||||
}
|
||||
|
||||
/** Loads the current page with the current filters. */
|
||||
async function load(): Promise<void> {
|
||||
const version = generation
|
||||
const owner = ownerId()
|
||||
const seq = ++sequence
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (query.value.trim()) params.set('query', query.value.trim())
|
||||
if (status.value) params.set('status', status.value)
|
||||
if (kind.value) params.set('kind', kind.value)
|
||||
params.set('page', String(page.value))
|
||||
params.set('limit', String(VOCAB_PAGE_SIZE))
|
||||
const result = await session.request<{ items?: VocabularyTerm[]; total?: number }>(`terms?${params.toString()}`)
|
||||
if (seq !== sequence || isStale(version, owner)) return
|
||||
items.value = Array.isArray(result?.items) ? result.items : []
|
||||
total.value = typeof result?.total === 'number' ? result.total : items.value.length
|
||||
} catch (reason) {
|
||||
if (seq !== sequence || isStale(version, owner)) return
|
||||
error.value = reason instanceof Error ? reason.message : '词汇库暂时无法加载,请稍后重试。'
|
||||
} finally {
|
||||
if (seq === sequence && !isStale(version, owner)) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Applying a filter always starts from the first page. */
|
||||
async function apply(): Promise<void> {
|
||||
page.value = 1
|
||||
await load()
|
||||
}
|
||||
|
||||
async function goToPage(next: number): Promise<void> {
|
||||
const target = Math.min(Math.max(1, next), totalPages.value)
|
||||
if (target === page.value) return
|
||||
page.value = target
|
||||
await load()
|
||||
}
|
||||
|
||||
async function clearFilters(): Promise<void> {
|
||||
query.value = ''
|
||||
status.value = ''
|
||||
kind.value = ''
|
||||
await apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves one entry through the same endpoint the reader uses. Identity is not editable, and the
|
||||
* server applies the same status/level and schedule rules as a save from the reader.
|
||||
*/
|
||||
async function updateTerm(id: number, input: VocabularyEdit): Promise<VocabularyTerm> {
|
||||
const version = generation
|
||||
const owner = ownerId()
|
||||
saving.value = true
|
||||
saveError.value = ''
|
||||
const body: Record<string, unknown> = { definition: input.definition, examples: input.examples, status: input.status }
|
||||
if (input.status === 'learning' && input.level !== undefined) body.level = input.level
|
||||
try {
|
||||
const result = await session.request<{ term: VocabularyTerm }>(`terms/${id}`, 'PATCH', body)
|
||||
if (!isStale(version, owner)) {
|
||||
items.value = items.value.map(item => (item.id === id ? result.term : item))
|
||||
}
|
||||
return result.term
|
||||
} catch (reason) {
|
||||
if (!isStale(version, owner)) saveError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
|
||||
throw reason instanceof Error ? reason : new Error('保存失败,请稍后重试。')
|
||||
} finally {
|
||||
if (!isStale(version, owner)) saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items, total, page, query, status, kind, loading, error, saving, saveError,
|
||||
hasFilters, totalPages, load, apply, goToPage, clearFilters, updateTerm, reset,
|
||||
}
|
||||
})
|
||||
+249
-71
@@ -1,143 +1,301 @@
|
||||
:root {
|
||||
/* One palette for both themes. Every surface, text and status colour in the rules below
|
||||
resolves through these variables, so the dark theme only overrides values, never layout.
|
||||
These are the only literal colours in the file. */
|
||||
--bg: #f7f5ee;
|
||||
--surface: #fffdf8;
|
||||
--surface-raised: #fffdf7;
|
||||
--surface-input: #fffefa;
|
||||
--text: #233d31;
|
||||
--text-strong: #264a35;
|
||||
--text-muted: #748073;
|
||||
--text-faint: #687568;
|
||||
--border: #e0e3d8;
|
||||
--border-strong: #d9decf;
|
||||
--border-input: #d6dccf;
|
||||
--accent: #315c43;
|
||||
--accent-soft: #eef2eb;
|
||||
--accent-marker: #bc803d;
|
||||
--accent-gold: #ba8345;
|
||||
--brand-panel: #284c38;
|
||||
--brand-panel-text: #f7f5ee;
|
||||
--brand-panel-muted: #d4dfd2;
|
||||
--brand-panel-faint: #c4d1c0;
|
||||
--brand-panel-line: #d6b980;
|
||||
--status-pending-bg: #f5f2e4;
|
||||
--status-pending-border: #e2dcc2;
|
||||
--status-pending-text: #7a6a35;
|
||||
--status-processing-bg: #eaf1f7;
|
||||
--status-processing-border: #c9dcea;
|
||||
--status-processing-text: #35566e;
|
||||
--status-ready-bg: #eef2eb;
|
||||
--status-ready-border: #cbd9c9;
|
||||
--status-ready-text: #315c43;
|
||||
--status-failed-bg: #fff0e7;
|
||||
--status-failed-border: #ebc3a8;
|
||||
--status-failed-text: #8b4324;
|
||||
--status-read-bg: #e7f0e9;
|
||||
--status-read-border: #b9d3bf;
|
||||
--status-read-text: #2c5b3d;
|
||||
--notice-bg: #fff0e7;
|
||||
--notice-border: #ebc3a8;
|
||||
--notice-text: #8b4324;
|
||||
--highlight: #eef2eb;
|
||||
--sense-bg: #fbf7ee;
|
||||
--sense-border: #d6b980;
|
||||
--sense-text: #6b5b3e;
|
||||
--sense-muted: #8b794e;
|
||||
--saved: #2f6b45;
|
||||
--word-learning-bg: #f6e7c9;
|
||||
--word-known-bg: #dee9d6;
|
||||
--word-ignored-text: #949c94;
|
||||
--word-selected-bg: #e3e9d9;
|
||||
--reader-font-scale: 1;
|
||||
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
color: #233d31;
|
||||
background: #f7f5ee;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
--el-color-primary: #315c43;
|
||||
--el-color-primary: var(--accent);
|
||||
--el-color-primary-light-3: #597f66;
|
||||
--el-color-primary-light-5: #8da493;
|
||||
--el-color-primary-light-7: #bdccc0;
|
||||
--el-color-primary-light-9: #eef2eb;
|
||||
--el-color-primary-dark-2: #264a35;
|
||||
--el-color-primary-light-9: var(--accent-soft);
|
||||
--el-color-primary-dark-2: var(--text-strong);
|
||||
--el-border-radius-base: 8px;
|
||||
--el-font-size-base: 15px;
|
||||
}
|
||||
|
||||
/* Dark palette. The Element Plus dark class is toggled by the display store, so components
|
||||
follow the same choice as the page palette. */
|
||||
html[data-theme='dark'] {
|
||||
--bg: #171a17;
|
||||
--surface: #1f231f;
|
||||
--surface-raised: #1c201c;
|
||||
--surface-input: #242824;
|
||||
--text: #e7ece4;
|
||||
--text-strong: #cfe0d3;
|
||||
--text-muted: #9fae9c;
|
||||
--text-faint: #8e9c8c;
|
||||
--border: #333a33;
|
||||
--border-strong: #3b433b;
|
||||
--border-input: #3f483f;
|
||||
--accent: #7db28f;
|
||||
--accent-soft: #24312a;
|
||||
--accent-marker: #d7a45f;
|
||||
--accent-gold: #d7a45f;
|
||||
--brand-panel: #10140f;
|
||||
--brand-panel-text: #e7ece4;
|
||||
--brand-panel-muted: #b9c7b6;
|
||||
--brand-panel-faint: #97a695;
|
||||
--brand-panel-line: #d7a45f;
|
||||
--status-pending-bg: #33301f;
|
||||
--status-pending-border: #4a442b;
|
||||
--status-pending-text: #e0cf94;
|
||||
--status-processing-bg: #1d2a33;
|
||||
--status-processing-border: #2f4756;
|
||||
--status-processing-text: #a8cbe0;
|
||||
--status-ready-bg: #22301f;
|
||||
--status-ready-border: #35492f;
|
||||
--status-ready-text: #a9d0ac;
|
||||
--status-failed-bg: #33231b;
|
||||
--status-failed-border: #4d3527;
|
||||
--status-failed-text: #e9ad86;
|
||||
--status-read-bg: #1f3024;
|
||||
--status-read-border: #33493a;
|
||||
--status-read-text: #a5d3b1;
|
||||
--notice-bg: #33231b;
|
||||
--notice-border: #4d3527;
|
||||
--notice-text: #e9ad86;
|
||||
--highlight: #26332a;
|
||||
--sense-bg: #23261d;
|
||||
--sense-border: #4a4227;
|
||||
--sense-text: #d9cba6;
|
||||
--sense-muted: #c0ad7f;
|
||||
--saved: #8ec69f;
|
||||
--word-learning-bg: #3b3520;
|
||||
--word-known-bg: #253120;
|
||||
--word-ignored-text: #7d867d;
|
||||
--word-selected-bg: #2b382d;
|
||||
}
|
||||
|
||||
/* Element Plus reads its own dark variables; the shared palette keeps both in step. */
|
||||
html.dark {
|
||||
--el-bg-color: var(--surface);
|
||||
--el-bg-color-overlay: var(--surface-raised);
|
||||
--el-text-color-primary: var(--text);
|
||||
--el-text-color-regular: var(--text);
|
||||
--el-text-color-secondary: var(--text-muted);
|
||||
--el-border-color: var(--border);
|
||||
--el-border-color-light: var(--border);
|
||||
--el-fill-color-blank: var(--surface-input);
|
||||
--el-fill-color-light: var(--accent-soft);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
button, input { font: inherit; }
|
||||
button:focus-visible, a:focus-visible { outline: 3px solid #bc803d; outline-offset: 4px; }
|
||||
|
||||
a { color: inherit; }
|
||||
.brand { font-family: Georgia, serif; font-size: 30px; font-weight: 700; letter-spacing: -1px; text-decoration: none; }
|
||||
.brand-dot { color: #ba8345; }
|
||||
.brand-dot { color: var(--accent-gold); }
|
||||
.login-page { min-height: 100dvh; display: grid; grid-template-columns: minmax(340px, 1fr) minmax(400px, 1fr); }
|
||||
.welcome { background: #284c38; color: #f7f5ee; padding: 46px 10%; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.welcome .brand { color: #f7f5ee; }
|
||||
.welcome { background: var(--brand-panel); color: var(--brand-panel-text); padding: 46px 10%; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.welcome .brand { color: var(--brand-panel-text); }
|
||||
.welcome-copy { padding: 80px 0; max-width: 450px; }
|
||||
.welcome-copy h2 { font-family: Georgia, 'Microsoft YaHei', serif; font-weight: 400; font-size: clamp(32px, 3.3vw, 52px); line-height: 1.55; letter-spacing: 2px; margin: 26px 0; }
|
||||
.welcome-copy p { font-size: 16px; color: #d4dfd2; line-height: 1.9; }
|
||||
.book-mark { width: 60px; height: 60px; color: #d6b980; }
|
||||
.welcome-foot { font-size: 13px; letter-spacing: 2px; color: #c4d1c0; }
|
||||
.welcome-copy p { font-size: 16px; color: var(--brand-panel-muted); line-height: 1.9; }
|
||||
.book-mark { width: 60px; height: 60px; color: var(--brand-panel-line); }
|
||||
.welcome-foot { font-size: 13px; letter-spacing: 2px; color: var(--brand-panel-faint); }
|
||||
.login-main { display: grid; place-items: center; padding: 48px 28px; }
|
||||
.login-form { width: min(100%, 360px); }
|
||||
.eyebrow { color: #687568; font-size: 12px; letter-spacing: 3px; }
|
||||
.eyebrow { color: var(--text-faint); font-size: 12px; letter-spacing: 3px; }
|
||||
h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
|
||||
.subtle { color: #748073; font-size: 14px; line-height: 1.8; }
|
||||
.subtle { color: var(--text-muted); font-size: 14px; line-height: 1.8; }
|
||||
.login-form form { margin-top: 36px; }
|
||||
.field { display: block; margin-bottom: 22px; }
|
||||
.field label { display: block; font-size: 14px; margin-bottom: 10px; }
|
||||
.el-input { --el-input-height: 46px; --el-input-bg-color: #fffefa; --el-input-border-color: #d6dccf; }
|
||||
.el-input { --el-input-height: 46px; --el-input-bg-color: var(--surface-input); --el-input-border-color: var(--border-input); }
|
||||
.el-button { min-height: 42px; }
|
||||
.login-submit { width: 100%; margin-top: 8px; height: 48px; letter-spacing: 4px; }
|
||||
.notice { padding: 12px 15px; background: #fff0e7; border: 1px solid #ebc3a8; color: #8b4324; border-radius: 6px; font-size: 14px; line-height: 1.6; }
|
||||
.site-header { padding: 24px max(24px, calc((100vw - 1200px) / 2)); border-bottom: 1px solid #e0e3d8; display: flex; align-items: center; gap: 32px; background: #fffdf7; }
|
||||
.notice { padding: 12px 15px; background: var(--notice-bg); border: 1px solid var(--notice-border); color: var(--notice-text); border-radius: 6px; font-size: 14px; line-height: 1.6; }
|
||||
.site-header { padding: 24px max(24px, calc((100vw - 1200px) / 2)); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 32px; background: var(--surface-raised); }
|
||||
.site-header nav { flex: 1; }
|
||||
.active-nav { font-size: 15px; text-decoration: none; padding: 13px 0; border-bottom: 2px solid #315c43; }
|
||||
.active-nav { font-size: 15px; text-decoration: none; padding: 13px 0; border-bottom: 2px solid var(--accent); }
|
||||
.account { display: flex; align-items: center; gap: 18px; font-size: 14px; }
|
||||
.account-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.library { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
|
||||
.library-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 38px; }
|
||||
.language { border: 1px solid #d9decf; padding: 9px 17px; border-radius: 20px; font-size: 13px; background: #fffdf7; }
|
||||
.empty-library { background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 44px 20px; text-align: center; }
|
||||
.empty-library .book-mark { color: #6e8967; width: 56px; height: 56px; }
|
||||
.language { border: 1px solid var(--border-strong); padding: 9px 17px; border-radius: 20px; font-size: 13px; background: var(--surface-raised); }
|
||||
.empty-library { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 44px 20px; text-align: center; }
|
||||
.empty-library .book-mark { color: var(--accent); width: 56px; height: 56px; }
|
||||
.empty-library h2 { font-weight: 500; font-size: 21px; margin: 26px 0 4px; }
|
||||
.loading { padding: 80px 24px; text-align: center; color: #748073; }
|
||||
.loading { padding: 80px 24px; text-align: center; color: var(--text-muted); }
|
||||
/* Import / book / reader pages share one shell. */
|
||||
.page { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
|
||||
.page-title { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-bottom: 28px; }
|
||||
.page-title h1 { margin: 14px 0 6px; overflow-wrap: anywhere; }
|
||||
.page-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.library-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||
.link-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 8px; color: #315c43; }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 4px; font-size: 14px; color: #748073; }
|
||||
.breadcrumb a { color: #315c43; }
|
||||
.link-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 8px; color: var(--accent); }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 4px; font-size: 14px; color: var(--text-muted); }
|
||||
.breadcrumb a { color: var(--accent); }
|
||||
/* Library book cards. */
|
||||
.book-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); }
|
||||
.book-card { display: flex; flex-direction: column; gap: 10px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 22px; }
|
||||
.book-card { display: flex; flex-direction: column; gap: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; }
|
||||
.book-card .subtle { margin: 0; }
|
||||
.book-title { font-family: Georgia, 'Microsoft YaHei', serif; font-size: 21px; font-weight: 600; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
|
||||
.book-title:hover { color: #315c43; text-decoration: underline; }
|
||||
.status-summary { align-self: flex-start; margin: 0; padding: 6px 13px; border-radius: 20px; background: #eef2eb; color: #3d5b48; font-size: 13px; }
|
||||
.book-title { font-family: Georgia, 'Microsoft YaHei', serif; font-size: 21px; font-weight: 600; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
|
||||
.book-title:hover { color: var(--accent); text-decoration: underline; }
|
||||
.status-summary { align-self: flex-start; margin: 0; padding: 6px 13px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); font-size: 13px; }
|
||||
/* Chapter and job status, identical vocabulary for both. */
|
||||
.status-chip { display: inline-flex; align-items: center; white-space: nowrap; padding: 5px 13px; border: 1px solid transparent; border-radius: 20px; font-size: 13px; }
|
||||
.status-pending { background: #f5f2e4; border-color: #e2dcc2; color: #7a6a35; }
|
||||
.status-processing { background: #eaf1f7; border-color: #c9dcea; color: #35566e; }
|
||||
.status-ready { background: #eef2eb; border-color: #cbd9c9; color: #315c43; }
|
||||
.status-failed { background: #fff0e7; border-color: #ebc3a8; color: #8b4324; }
|
||||
.status-pending { background: var(--status-pending-bg); border-color: var(--status-pending-border); color: var(--status-pending-text); }
|
||||
.status-processing { background: var(--status-processing-bg); border-color: var(--status-processing-border); color: var(--status-processing-text); }
|
||||
.status-ready { background: var(--accent-soft); border-color: var(--status-ready-border); color: var(--accent); }
|
||||
.status-failed { background: var(--notice-bg); border-color: var(--notice-border); color: var(--notice-text); }
|
||||
.status-read { background: var(--status-read-bg); border-color: var(--status-read-border); color: var(--status-read-text); }
|
||||
/* Completing a chapter: one explicit action, with the reading rule spelled out. */
|
||||
.chapter-complete { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-top: 22px; padding: 18px 22px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
|
||||
.chapter-complete .subtle { margin: 6px 0 0; }
|
||||
.read-state { margin: 0; font-weight: 600; color: var(--status-read-text); }
|
||||
/* Progress page. */
|
||||
.progress-stats { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); margin-top: 6px; }
|
||||
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px 22px; }
|
||||
.stat-card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; color: var(--accent); letter-spacing: 0; }
|
||||
.stat-card .subtle { margin: 6px 0 0; }
|
||||
.stat-value { margin: 0; font-family: Georgia, 'Microsoft YaHei', serif; font-size: 30px; line-height: 1.2; color: var(--text); }
|
||||
.progress-books { margin-top: 34px; }
|
||||
.progress-books h2 { margin: 0 0 14px; font-size: 19px; }
|
||||
.progress-book-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.progress-book-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
|
||||
/* Import form. */
|
||||
.import-form { max-width: 720px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 28px; }
|
||||
.import-form { max-width: 720px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; }
|
||||
.field-label { display: block; font-size: 14px; margin-bottom: 10px; }
|
||||
.fixed-value { margin: 0; padding: 12px 15px; border: 1px solid #d6dccf; border-radius: 8px; background: #fffefa; font-size: 15px; }
|
||||
.field-error { margin: 8px 0 0; color: #8b4324; font-size: 13px; line-height: 1.6; }
|
||||
.counter { margin: 8px 0 0; color: #748073; font-size: 13px; }
|
||||
.fixed-value { margin: 0; padding: 12px 15px; border: 1px solid var(--border-input); border-radius: 8px; background: var(--surface-input); font-size: 15px; }
|
||||
.field-error { margin: 8px 0 0; color: var(--notice-text); font-size: 13px; line-height: 1.6; }
|
||||
.counter { margin: 8px 0 0; color: var(--text-muted); font-size: 13px; }
|
||||
.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 14px; }
|
||||
.import-form .el-textarea__inner { min-height: 220px; line-height: 1.9; }
|
||||
.book-select { width: 100%; }
|
||||
/* Book chapters. */
|
||||
.chapter-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.chapter-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 16px 18px; }
|
||||
.chapter-ordinal { font-family: Georgia, serif; font-size: 17px; color: #8b9a8b; text-align: center; }
|
||||
.chapter-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
|
||||
.chapter-ordinal { font-family: Georgia, serif; font-size: 17px; color: var(--text-faint); text-align: center; }
|
||||
.chapter-info { min-width: 0; }
|
||||
.chapter-name { display: inline-block; font-size: 16px; font-weight: 500; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
|
||||
a.chapter-name:hover { color: #315c43; text-decoration: underline; }
|
||||
.chapter-meta { margin: 4px 0 0; color: #748073; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.chapter-name { display: inline-block; font-size: 16px; font-weight: 500; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
|
||||
a.chapter-name:hover { color: var(--accent); text-decoration: underline; }
|
||||
.chapter-meta { margin: 4px 0 0; color: var(--text-muted); font-size: 13px; overflow-wrap: anywhere; }
|
||||
/* Reader keeps the pasted text exactly as it was, including line breaks and tabs. */
|
||||
.reader-page { max-width: 820px; }
|
||||
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: 17px; line-height: 2; }
|
||||
.processing-hint { padding: 36px 0; color: #748073; }
|
||||
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid #e0e3d8; }
|
||||
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: calc(17px * var(--reader-font-scale)); line-height: 2; }
|
||||
.processing-hint { padding: 36px 0; color: var(--text-muted); }
|
||||
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid var(--border); }
|
||||
.reader-page.has-lookup { max-width: 1120px; }
|
||||
.reader-workspace { display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; align-items: start; }
|
||||
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr) 320px; }
|
||||
.reader-body { min-width: 0; }
|
||||
.reader-word { cursor: pointer; border-radius: 3px; }
|
||||
/* Saved words keep one style per status everywhere they appear. */
|
||||
.reader-word.is-new { border-bottom: 2px dotted #bc803d; }
|
||||
.reader-word.is-learning { background: #f6e7c9; }
|
||||
.reader-word.is-known { background: #dee9d6; }
|
||||
.reader-word.is-ignored { color: #949c94; }
|
||||
.reader-word:hover, .reader-word.is-selected { background: #e3e9d9; color: #264a35; }
|
||||
.reader-word:focus-visible { outline: 2px solid #bc803d; outline-offset: 2px; background: #eef2eb; }
|
||||
.tokens-notice { color: #748073; font-size: 14px; margin-top: 24px; }
|
||||
.lookup-panel { position: sticky; top: 24px; margin-top: 26px; padding: 22px; border: 1px solid #d9decf; border-radius: 12px; background: #fffdf8; max-height: calc(100dvh - 48px); overflow-y: auto; overflow-wrap: anywhere; }
|
||||
.lookup-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; border-bottom: 1px solid #e0e3d8; padding-bottom: 12px; }
|
||||
.reader-word.is-new { border-bottom: 2px dotted var(--accent-marker); }
|
||||
.reader-word.is-learning { background: var(--word-learning-bg); }
|
||||
.reader-word.is-known { background: var(--word-known-bg); }
|
||||
.reader-word.is-ignored { color: var(--word-ignored-text); }
|
||||
.reader-word:hover, .reader-word.is-selected { background: var(--word-selected-bg); color: var(--text-strong); }
|
||||
.reader-word:focus-visible { outline: 2px solid var(--accent-marker); outline-offset: 2px; background: var(--accent-soft); }
|
||||
.tokens-notice { color: var(--text-muted); font-size: 14px; margin-top: 24px; }
|
||||
.lookup-panel { position: sticky; top: 24px; margin-top: 26px; padding: 22px; border: 1px solid var(--border-strong); border-radius: 12px; background: var(--surface); max-height: calc(100dvh - 48px); overflow-y: auto; overflow-wrap: anywhere; }
|
||||
.lookup-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||
.lookup-heading h2 { margin: 6px 0; font-family: Georgia, serif; font-size: 25px; }
|
||||
.lookup-content { font-size: 15px; line-height: 1.7; }
|
||||
.lookup-message { color: #8b4324; }
|
||||
.lookup-content { font-size: calc(15px * var(--reader-font-scale)); line-height: 1.7; }
|
||||
.lookup-message { color: var(--notice-text); }
|
||||
.lookup-senses { padding-left: 22px; }
|
||||
.lookup-senses li { padding-left: 3px; margin-bottom: 20px; }
|
||||
.lookup-senses p { margin: 8px 0; }
|
||||
.sense-heading span { color: #748073; font-size: 13px; }
|
||||
.lookup-senses blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
|
||||
.lookup-term { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; }
|
||||
.sense-heading span { color: var(--text-muted); font-size: 13px; }
|
||||
.lookup-senses blockquote { border-left: 2px solid var(--status-ready-border); margin: 10px 0; padding-left: 12px; color: var(--text-faint); font-style: italic; }
|
||||
.lookup-range { border-bottom: 1px solid var(--border); padding-bottom: 14px; }
|
||||
.lookup-range p { margin: 0 0 10px; }
|
||||
.lookup-range-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
/* A saved phrase is one unit: the run is underlined and keeps its status colour. */
|
||||
.reader-word.is-phrase { text-decoration: underline; text-decoration-style: double; text-underline-offset: 2px; border-radius: 3px; }
|
||||
.reader-word.is-phrase-selected { outline: 2px solid var(--accent-marker); outline-offset: 1px; }
|
||||
.lookup-term { border-top: 1px solid var(--border); padding-top: 18px; margin-top: 20px; }
|
||||
.lookup-term label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
|
||||
.lookup-term-title { display: flex; font-size: 14px; margin: 0; }
|
||||
.lookup-term label span { color: #8b794e; font-size: 12px; }
|
||||
.lookup-term label span { color: var(--sense-muted); font-size: 12px; }
|
||||
.lookup-term .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.lookup-term .el-textarea { margin-top: 8px; }
|
||||
.lookup-term .el-textarea textarea { font: inherit; line-height: 1.6; }
|
||||
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
|
||||
.lookup-saved { color: var(--saved); font-size: 14px; margin: 12px 0 0; }
|
||||
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.review-page { max-width: 680px; }
|
||||
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
|
||||
.vocab-page { max-width: 760px; }
|
||||
.vocab-search { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 22px; }
|
||||
.vocab-search label { font-size: 14px; }
|
||||
.vocab-search .el-input { max-width: 260px; }
|
||||
.vocab-filter { width: 140px; }
|
||||
.vocab-list { list-style: none; padding: 0; margin: 18px 0 0; }
|
||||
.vocab-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
|
||||
.vocab-info { min-width: 0; flex: 1; }
|
||||
.vocab-term { margin: 0; font-family: Georgia, serif; font-size: 18px; overflow-wrap: anywhere; }
|
||||
.vocab-paging { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; }
|
||||
.term-fields label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
|
||||
.term-fields label span { color: var(--sense-muted); font-size: 12px; }
|
||||
.term-fields .el-textarea { margin-top: 8px; }
|
||||
.term-fields .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.term-level { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; font-size: 14px; }
|
||||
.term-level-select { width: 120px; }
|
||||
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--sense-bg); color: var(--sense-text); }
|
||||
.chapter-list .chapter-row { flex-wrap: wrap; }
|
||||
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
|
||||
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; }
|
||||
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--sense-bg); color: var(--sense-text); }
|
||||
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface); }
|
||||
.review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; }
|
||||
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: 34px; }
|
||||
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: calc(34px * var(--reader-font-scale)); }
|
||||
.review-example { margin: 12px 0; font-family: Georgia, serif; font-size: 19px; line-height: 1.8; }
|
||||
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid #e0e3d8; }
|
||||
.review-definition { margin: 0 0 12px; font-size: 18px; }
|
||||
.review-answer blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
|
||||
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
.review-definition { margin: 0 0 12px; font-size: calc(18px * var(--reader-font-scale)); }
|
||||
.review-answer blockquote { border-left: 2px solid var(--status-ready-border); margin: 10px 0; padding-left: 12px; color: var(--text-faint); font-style: italic; }
|
||||
.review-grades { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.review-actions { margin: 16px 0 0; }
|
||||
@media (max-width: 760px) {
|
||||
@@ -160,10 +318,30 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
|
||||
.import-form { padding: 20px; }
|
||||
.chapter-row { grid-template-columns: 30px minmax(0, 1fr); align-items: start; row-gap: 10px; padding: 15px 16px; }
|
||||
.chapter-row .status-chip, .chapter-row .el-button { grid-column: 2; justify-self: start; }
|
||||
.reader-text { font-size: 16px; line-height: 1.95; }
|
||||
.chapter-complete { flex-direction: column; align-items: stretch; }
|
||||
.chapter-complete .el-button { width: 100%; }
|
||||
.reader-text { font-size: calc(16px * var(--reader-font-scale)); line-height: 1.95; }
|
||||
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr); gap: 20px; }
|
||||
.reader-page.has-lookup { padding-bottom: calc(45dvh + 28px); }
|
||||
.lookup-panel { position: fixed; inset: auto 0 0; z-index: 20; max-height: 45dvh; margin-top: 0; padding: 16px 20px max(20px, env(safe-area-inset-bottom)); border-radius: 16px 16px 0 0; box-shadow: 0 -5px 24px #233d3114; }
|
||||
.lookup-heading { position: sticky; top: -16px; z-index: 1; background: #fffdf8; }
|
||||
.lookup-panel { position: fixed; inset: auto 0 0; z-index: 20; max-height: 45dvh; margin-top: 0; padding: 16px 20px max(20px, env(safe-area-inset-bottom)); border-radius: 16px 16px 0 0; box-shadow: 0 -5px 24px var(--text)14; }
|
||||
.lookup-heading { position: sticky; top: -16px; z-index: 1; background: var(--surface); }
|
||||
.reader-nav .el-button { flex: 1; }
|
||||
}
|
||||
|
||||
/* One visible focus ring for every interactive element, including Element Plus controls. */
|
||||
button:focus-visible, a:focus-visible, [role='button']:focus-visible,
|
||||
.el-button:focus-visible, .el-input__inner:focus-visible, .el-select__wrapper:focus-visible {
|
||||
outline: 3px solid var(--accent-marker);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
/* Header display control: theme and reader font size. */
|
||||
.display-trigger { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 20px; background: var(--surface-raised); color: var(--text); font-size: 13px; cursor: pointer; }
|
||||
.display-trigger:hover { border-color: var(--accent); }
|
||||
/* Element Plus styles arrive after this file for lazy-loaded views, so the ring is stated
|
||||
with enough weight to win the tie while a control from its own library is focused. */
|
||||
.display-trigger:focus-visible, .display-trigger.el-tooltip__trigger:focus-visible { outline: 3px solid var(--accent-marker); outline-offset: 3px; }
|
||||
.display-current { color: var(--text-muted); }
|
||||
.el-dropdown-menu__item.is-current { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* Discoverable keyboard operations on the review page. */
|
||||
.shortcut-hint { margin: 22px 0 0; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
|
||||
import { canRetry, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
|
||||
const session = useSessionStore()
|
||||
const library = useLibraryStore()
|
||||
@@ -144,8 +145,9 @@ onUnmounted(() => library.closeBook())
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
@@ -182,6 +184,7 @@ onUnmounted(() => library.closeBook())
|
||||
<template v-if="item.status === 'failed' && item.errorMessage"> · {{ item.errorMessage }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="item.status === 'ready' && item.readAt" class="status-chip status-read" data-testid="chapter-read-chip">已读</span>
|
||||
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item.status) }}</span>
|
||||
<ElButton
|
||||
v-if="canRetry(item)"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
|
||||
import { LANGUAGE_LABEL, TEXT_MAX_CODE_POINTS, fileProblem, fileSizeLabel, textProblem, titleProblem, useLibraryStore, type SubmitTarget } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
|
||||
const session = useSessionStore()
|
||||
const library = useLibraryStore()
|
||||
@@ -127,8 +128,9 @@ onUnmounted(() => {
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton } from 'element-plus'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import BookMark from '../components/BookMark.vue'
|
||||
import { statusSummary, useLibraryStore } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
@@ -38,8 +39,9 @@ onMounted(load)
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
|
||||
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { ElButton } from 'element-plus'
|
||||
import { useProgressStore } from '../stores/progress'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
|
||||
const session = useSessionStore()
|
||||
const progress = useProgressStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => { void progress.load() })
|
||||
|
||||
async function logout() {
|
||||
try { await session.logout() }
|
||||
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
|
||||
finally { await router.replace('/login') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page progress-page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>我的进度</h1>
|
||||
<p class="subtle">只统计你自己的记录,按当前学习语言(英语)汇总。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<RouterLink to="/review" class="link-button">开始到期复习</RouterLink>
|
||||
<ElButton :loading="progress.loading" data-testid="progress-reload" @click="progress.load()">刷新</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="progress.loading && !progress.loaded" role="status" class="loading">正在加载…</p>
|
||||
<div v-else-if="progress.error && !progress.loaded" class="notice">
|
||||
<p role="alert">{{ progress.error }}</p>
|
||||
<ElButton data-testid="progress-retry" @click="progress.load()">重试</ElButton>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p v-if="progress.error" role="alert" class="notice">{{ progress.error }}</p>
|
||||
<section class="progress-stats" aria-label="学习统计">
|
||||
<article class="stat-card" data-testid="stat-read">
|
||||
<h2>已读章节</h2>
|
||||
<p class="stat-value">{{ progress.data.readChapters }} / {{ progress.data.totalChapters }}</p>
|
||||
<p class="subtle">章(可阅读)</p>
|
||||
</article>
|
||||
<article class="stat-card" data-testid="stat-due">
|
||||
<h2>待复习</h2>
|
||||
<p class="stat-value">{{ progress.data.dueNow }}</p>
|
||||
<p class="subtle">个词条现在到期</p>
|
||||
</article>
|
||||
<article class="stat-card" data-testid="stat-known">
|
||||
<h2>已知词</h2>
|
||||
<p class="stat-value">{{ progress.data.knownTerms }}</p>
|
||||
<p class="subtle">标记为已认识</p>
|
||||
</article>
|
||||
<article class="stat-card" data-testid="stat-learning">
|
||||
<h2>学习中</h2>
|
||||
<p class="stat-value">{{ progress.data.learningTerms }}</p>
|
||||
<p class="subtle">按间隔复习</p>
|
||||
</article>
|
||||
<article class="stat-card" data-testid="stat-new">
|
||||
<h2>新词</h2>
|
||||
<p class="stat-value">{{ progress.data.newTerms }}</p>
|
||||
<p class="subtle">刚保存,还没作答</p>
|
||||
</article>
|
||||
<article class="stat-card" data-testid="stat-ignored">
|
||||
<h2>忽略</h2>
|
||||
<p class="stat-value">{{ progress.data.ignoredTerms }}</p>
|
||||
<p class="subtle">单独统计,不计入已知</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="progress-books" aria-label="每本书的阅读进度">
|
||||
<h2>每本书的阅读</h2>
|
||||
<ul v-if="progress.hasBooks" class="progress-book-list" data-testid="progress-books">
|
||||
<li v-for="item in progress.data.books" :key="item.id" class="progress-book-row">
|
||||
<RouterLink :to="`/books/${item.id}`" class="chapter-name">{{ item.title }}</RouterLink>
|
||||
<span class="subtle">{{ item.readChapters }} / {{ item.totalChapters }} 章已读</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="subtle" data-testid="progress-no-books">还没有书。导入或粘贴一段英语,读完后在这里看到进度。</p>
|
||||
<p class="subtle" data-testid="progress-saved">已保存词条 {{ progress.data.savedTerms }} 个(含忽略)。</p>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,8 +4,12 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton } from 'element-plus'
|
||||
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { useReaderLookup } from '../composables/useReaderLookup'
|
||||
import { useReaderLookup, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
|
||||
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, wordIndices } from '../composables/readerRange'
|
||||
import { currentRatio, readPosition, writePosition } from '../composables/readingPosition'
|
||||
import { useTextSelection } from '../composables/useTextSelection'
|
||||
import ReaderTokens from '../components/ReaderTokens.vue'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import LookupPanel from '../components/LookupPanel.vue'
|
||||
|
||||
const session = useSessionStore()
|
||||
@@ -13,6 +17,8 @@ const library = useLibraryStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const retryError = ref('')
|
||||
const rangeNotice = ref('')
|
||||
const readerBody = ref<HTMLElement | null>(null)
|
||||
|
||||
const chapterId = computed(() => Number(route.params.id))
|
||||
const chapter = computed(() => library.chapter)
|
||||
@@ -20,10 +26,120 @@ const lookup = useReaderLookup(chapter)
|
||||
// Retry uses the job id the chapter carries, no matter where it was loaded from.
|
||||
const retryable = computed(() => chapter.value !== null && canRetry(chapter.value))
|
||||
|
||||
/** A dragged selection becomes a phrase; a single word stays a plain word lookup. */
|
||||
function pickRange(anchor: number, focus: number, element: HTMLElement | null): void {
|
||||
const text = library.readerText
|
||||
const count = wordIndices(lookup.tokens.value.slice(anchor, focus + 1)).length
|
||||
if (count < 2) return
|
||||
if (count > MAX_PHRASE_WORDS) {
|
||||
rangeNotice.value = `短语最多 ${MAX_PHRASE_WORDS} 个单词,请缩短选择范围。`
|
||||
return
|
||||
}
|
||||
const range = normalizeTokenRange(lookup.tokens.value, text, anchor, focus)
|
||||
if (!range) return
|
||||
rangeNotice.value = ''
|
||||
lookup.selectRange(range, element)
|
||||
}
|
||||
|
||||
const selection = useTextSelection({ container: readerBody, onSelect: (anchor, focus) => pickRange(anchor, focus, readerBody.value) })
|
||||
|
||||
/** Shift-clicking a word extends the phrase from the word that was picked first. */
|
||||
function selectToken(token: ReaderToken, element: HTMLElement, extend: boolean): void {
|
||||
rangeNotice.value = ''
|
||||
const anchor = lookup.selected.value
|
||||
if (extend && anchor) {
|
||||
const from = Number(element.dataset.tokenIndex)
|
||||
const first = lookup.tokens.value.findIndex(item => item.start === anchor.start)
|
||||
if (from >= 0 && first >= 0) {
|
||||
pickRange(Math.min(from, first), Math.max(from, first), element)
|
||||
return
|
||||
}
|
||||
}
|
||||
lookup.select(token, element)
|
||||
}
|
||||
|
||||
/** Clicking inside a saved phrase edits the phrase instead of the word under the cursor. */
|
||||
function selectPhrase(span: PhraseSpan, element: HTMLElement): void {
|
||||
const range = rangeOfSpan(lookup.tokens.value, library.readerText, span)
|
||||
if (!range) return
|
||||
rangeNotice.value = ''
|
||||
lookup.selectRange(range, element, span.id)
|
||||
}
|
||||
|
||||
const adjustOptions = computed(() => {
|
||||
const current = lookup.range.value
|
||||
if (!current) return null
|
||||
const text = library.readerText
|
||||
return {
|
||||
startLeft: adjustTokenRange(lookup.tokens.value, text, current, 'start', -1) !== null,
|
||||
startRight: adjustTokenRange(lookup.tokens.value, text, current, 'start', 1) !== null,
|
||||
endLeft: adjustTokenRange(lookup.tokens.value, text, current, 'end', -1) !== null,
|
||||
endRight: adjustTokenRange(lookup.tokens.value, text, current, 'end', 1) !== null,
|
||||
}
|
||||
})
|
||||
|
||||
const panelWord = computed(() => lookup.range.value?.text ?? lookup.selected.value?.text ?? '')
|
||||
|
||||
function closePanel(): void {
|
||||
lookup.close()
|
||||
selection.clear()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
lookup.reset()
|
||||
retryError.value = ''
|
||||
rangeNotice.value = ''
|
||||
await library.loadChapter(chapterId.value)
|
||||
restorePosition()
|
||||
}
|
||||
|
||||
// Reading position: remembered per account and chapter, and only while the text is the version
|
||||
// the position was taken from. The restore waits for the chapter to be on screen and for the
|
||||
// browser to lay the paragraph out, otherwise the saved ratio would land in the wrong place.
|
||||
const positionNotice = ref('')
|
||||
let saveTimer: number | undefined
|
||||
|
||||
function storage(): Storage | null {
|
||||
try { return window.localStorage } catch { return null }
|
||||
}
|
||||
|
||||
function restorePosition(): void {
|
||||
positionNotice.value = ''
|
||||
const store = storage()
|
||||
const sha = chapter.value?.contentSha256 ?? ''
|
||||
const current = chapter.value
|
||||
if (!store || !current || current.status !== 'ready' || !sha) return
|
||||
const saved = readPosition(store, session.user?.id ?? null, current.id, sha)
|
||||
if (!saved) return
|
||||
// The chapter may still be laying out when the response arrives, so the restore waits for a
|
||||
// scrollable page instead of trusting one timer; it gives up quietly after a short while.
|
||||
let attempts = 0
|
||||
const apply = () => {
|
||||
const scrollable = document.documentElement.scrollHeight - window.innerHeight
|
||||
if (scrollable <= 0) {
|
||||
attempts += 1
|
||||
if (attempts <= 20) window.setTimeout(apply, 50)
|
||||
return
|
||||
}
|
||||
window.scrollTo({ top: Math.round(saved.ratio * scrollable) })
|
||||
positionNotice.value = '已回到上次阅读位置。'
|
||||
}
|
||||
window.setTimeout(apply, 40)
|
||||
}
|
||||
|
||||
function savePosition(): void {
|
||||
const store = storage()
|
||||
const current = chapter.value
|
||||
if (!store || !current || current.status !== 'ready') return
|
||||
const ratio = currentRatio(window, document.documentElement.scrollHeight)
|
||||
if (ratio <= 0) return
|
||||
writePosition(store, session.user?.id ?? null, current.id, current.contentSha256, ratio)
|
||||
}
|
||||
|
||||
function onScroll(): void {
|
||||
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
|
||||
// Debounced: scrolling must stay smooth and only the resting position is worth storing.
|
||||
saveTimer = window.setTimeout(() => { saveTimer = undefined; savePosition() }, 400)
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
@@ -33,6 +149,29 @@ async function retry() {
|
||||
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
|
||||
}
|
||||
|
||||
// Completing records only that this chapter was read. Word statuses and levels are untouched.
|
||||
const completeNotice = ref('')
|
||||
const readAt = computed(() => chapter.value?.readAt ?? null)
|
||||
const readLabel = computed(() => (readAt.value ? `本章已读 · ${formatReadAt(readAt.value)}` : ''))
|
||||
|
||||
function formatReadAt(value: string): string {
|
||||
const at = new Date(value)
|
||||
if (Number.isNaN(at.getTime())) return '已记录'
|
||||
const pad = (part: number) => String(part).padStart(2, '0')
|
||||
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
if (chapter.value === null) return
|
||||
completeNotice.value = ''
|
||||
try {
|
||||
const result = await library.markChapterRead(chapter.value.id)
|
||||
completeNotice.value = result.duplicate ? '本章已经标记过已读,没有重复计数。' : '已记为读完本章。'
|
||||
} catch (reason) {
|
||||
completeNotice.value = reason instanceof Error ? reason.message : '标记失败,请稍后重试。'
|
||||
}
|
||||
}
|
||||
|
||||
// Switching chapters reuses this component; only the route param changes.
|
||||
function goTo(id: number | null) {
|
||||
if (id === null) return
|
||||
@@ -45,23 +184,33 @@ async function logout() {
|
||||
finally { await router.replace('/login') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
void load()
|
||||
})
|
||||
watch(chapterId, () => { void load() })
|
||||
// Leaving the page releases the chapter so polling stops.
|
||||
onUnmounted(() => library.closeChapter())
|
||||
// Leaving the page releases the chapter so polling stops and stores the last position.
|
||||
// There is no separate unmount hook: this one already handles the page going away.
|
||||
onUnmounted(() => {
|
||||
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
|
||||
savePosition()
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
library.closeChapter()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value }" @keydown.esc="lookup.close()">
|
||||
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value || lookup.range.value }" @keydown.esc="closePanel">
|
||||
<p v-if="library.chapterLoading && !chapter" role="status" class="loading">正在加载…</p>
|
||||
<div v-else-if="library.chapterError" class="notice">
|
||||
<p role="alert">{{ library.chapterError }}</p>
|
||||
@@ -85,20 +234,66 @@ onUnmounted(() => library.closeChapter())
|
||||
</div>
|
||||
<p v-else-if="chapter.status !== 'ready'" role="status" class="processing-hint">这一章还在{{ statusLabel(chapter.status) }},页面会自动刷新。</p>
|
||||
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
|
||||
<p v-if="rangeNotice" role="alert" class="notice" data-testid="range-notice">{{ rangeNotice }}</p>
|
||||
<div v-if="chapter.status === 'ready'" class="reader-workspace">
|
||||
<div class="reader-body">
|
||||
<ReaderTokens :tokens="lookup.tokens.value" :original="library.readerText" :selected-start="lookup.selected.value?.start" @select="lookup.select" />
|
||||
<div class="reader-body" ref="readerBody">
|
||||
<ReaderTokens
|
||||
:tokens="lookup.tokens.value"
|
||||
:phrases="lookup.phrases.value"
|
||||
:original="library.readerText"
|
||||
:selected-start="lookup.selected.value?.start"
|
||||
:phrase-start="lookup.rangeTermId.value"
|
||||
@select="selectToken"
|
||||
@select-phrase="selectPhrase"
|
||||
/>
|
||||
<div v-if="lookup.tokensError.value" class="tokens-notice">
|
||||
<p role="status">{{ lookup.tokensError.value }}</p>
|
||||
<ElButton data-testid="tokens-retry" :loading="lookup.tokensLoading.value" @click="lookup.loadTokens">重试加载单词</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<LookupPanel v-if="lookup.selected.value" :word="lookup.selected.value.text" :result="lookup.result.value" :loading="lookup.loading.value" :error="lookup.error.value" :saving="lookup.saving.value" :save-error="lookup.saveError.value" :saved="lookup.saved.value" :saved-term-id="lookup.savedTermId.value" :prefilling="lookup.prefilling.value" :prefill-error="lookup.prefillError.value" :can-save="lookup.canSave.value" v-model:definition="lookup.definition.value" v-model:examples="lookup.examples.value" v-model:status="lookup.status.value" @close="lookup.close()" @retry="lookup.lookup" @save="lookup.save" @resize="lookup.keepSelectionVisible" />
|
||||
<LookupPanel
|
||||
v-if="lookup.selected.value || lookup.range.value"
|
||||
:word="panelWord"
|
||||
:phrase="lookup.range.value ? { wordCount: lookup.range.value.wordCount, stored: lookup.rangeStored.value } : null"
|
||||
:adjust="adjustOptions"
|
||||
:result="lookup.result.value"
|
||||
:loading="lookup.loading.value"
|
||||
:error="lookup.error.value"
|
||||
:saving="lookup.saving.value"
|
||||
:save-error="lookup.saveError.value"
|
||||
:saved="lookup.saved.value"
|
||||
:saved-term-id="lookup.savedTermId.value"
|
||||
:prefilling="lookup.prefilling.value"
|
||||
:prefill-error="lookup.prefillError.value"
|
||||
:can-save="lookup.canSave.value"
|
||||
v-model:definition="lookup.definition.value"
|
||||
v-model:examples="lookup.examples.value"
|
||||
v-model:status="lookup.status.value"
|
||||
@close="closePanel"
|
||||
@retry="lookup.lookup"
|
||||
@save="lookup.save"
|
||||
@adjust="(edge, direction) => { lookup.adjustRange(edge, direction); selection.clear() }"
|
||||
@resize="lookup.keepSelectionVisible"
|
||||
/>
|
||||
</div>
|
||||
<nav class="reader-nav" aria-label="章节切换">
|
||||
<ElButton :disabled="library.navigation.previousChapterId === null" @click="goTo(library.navigation.previousChapterId)">上一章</ElButton>
|
||||
<ElButton :disabled="library.navigation.nextChapterId === null" @click="goTo(library.navigation.nextChapterId)">下一章</ElButton>
|
||||
</nav>
|
||||
<section v-if="chapter.status === 'ready'" class="chapter-complete" aria-label="完成本章">
|
||||
<div>
|
||||
<p v-if="readAt" class="read-state" data-testid="chapter-read-state">{{ readLabel }}</p>
|
||||
<p v-else class="subtle">读完后标记已读;只记录已读,不改变词语状态。</p>
|
||||
<p v-if="positionNotice" role="status" class="subtle" data-testid="position-notice">{{ positionNotice }}</p>
|
||||
<p v-if="completeNotice" role="status" class="subtle" data-testid="complete-notice">{{ completeNotice }}</p>
|
||||
</div>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="library.completingChapterId === chapter.id"
|
||||
data-testid="mark-read"
|
||||
@click="complete"
|
||||
>{{ readAt ? '再次标记已读' : '标记本章已读' }}</ElButton>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { ElButton } from 'element-plus'
|
||||
import ReviewCard from '../components/ReviewCard.vue'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import { useReviewShortcuts, type ReviewShortcut } from '../composables/reviewShortcuts'
|
||||
import { useReviewStore, type ReviewItem } from '../stores/review'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
@@ -16,6 +18,18 @@ let lastGrade: 'correct' | 'wrong' | 'again' = 'correct'
|
||||
function grade(value: 'correct' | 'wrong' | 'again') { lastGrade = value; void review.answer(value) }
|
||||
function retry() { void review.answer(lastGrade) }
|
||||
|
||||
// Space or Enter shows the answer, then 1, 2 and 3 grade it. The shortcut never fires while the
|
||||
// learner is typing, and a focused button keeps its own native key handling.
|
||||
function onShortcut(action: ReviewShortcut): void {
|
||||
if (action === 'reveal') { review.reveal(); return }
|
||||
grade(action)
|
||||
}
|
||||
useReviewShortcuts({
|
||||
onShortcut,
|
||||
enabled: () => review.current !== null && !review.busy && review.error === '',
|
||||
revealed: () => review.revealed,
|
||||
})
|
||||
|
||||
onMounted(() => { void review.load() })
|
||||
async function logout() {
|
||||
try { await session.logout() }
|
||||
@@ -30,8 +44,9 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review" class="active-nav">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
@@ -73,6 +88,7 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
|
||||
@end="endReview"
|
||||
@retry="retry"
|
||||
/>
|
||||
<p class="shortcut-hint" data-testid="review-shortcuts">键盘:空格 显示答案 · 1 答对 · 2 答错 · 3 再学一次</p>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElDialog, ElInput, ElOption, ElSelect } from 'element-plus'
|
||||
import TermFormFields from '../components/TermFormFields.vue'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import { TERM_STATUSES, type TermStatus } from '../composables/useReaderLookup'
|
||||
import { textProblem, titleProblem } from '../stores/library'
|
||||
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
const session = useSessionStore()
|
||||
const vocabulary = useVocabularyStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const search = ref('')
|
||||
const editing = ref<VocabularyTerm | null>(null)
|
||||
const definition = ref('')
|
||||
const examples = ref('')
|
||||
const status = ref<TermStatus>('new')
|
||||
const level = ref(1)
|
||||
const formError = ref('')
|
||||
const notice = ref('')
|
||||
|
||||
const queryString = computed(() => ({ query: search.value.trim(), status: vocabulary.status, kind: vocabulary.kind, page: vocabulary.page }))
|
||||
const levelLabel = (value: number) => (value === 0 ? '' : ` · 等级 ${value}`)
|
||||
const rowLabel = (item: VocabularyTerm) => `${item.originalForm} / ${item.definition || '(未填写释义)'} · ${statusLabel(item)}`
|
||||
const statusLabel = (item: VocabularyTerm) => {
|
||||
const label = TERM_STATUSES.find(entry => entry.value === item.status)?.label ?? item.status
|
||||
return item.kind === 'phrase' ? `短语 · ${label}${levelLabel(item.level)}` : `${label}${levelLabel(item.level)}`
|
||||
}
|
||||
|
||||
function readFilters(): void {
|
||||
const read = (value: unknown) => (Array.isArray(value) ? value[0] : value)
|
||||
const query = read(route.query.query)
|
||||
const statusValue = read(route.query.status)
|
||||
const kindValue = read(route.query.kind)
|
||||
const page = Number(read(route.query.page) ?? 1)
|
||||
search.value = typeof query === 'string' ? query : ''
|
||||
vocabulary.query = search.value
|
||||
vocabulary.status = TERM_STATUSES.some(entry => entry.value === statusValue) ? (statusValue as TermStatus) : ''
|
||||
vocabulary.kind = kindValue === 'word' || kindValue === 'phrase' ? kindValue : ''
|
||||
vocabulary.page = Number.isSafeInteger(page) && page >= 1 ? page : 1
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await vocabulary.load()
|
||||
}
|
||||
|
||||
async function submitSearch(): Promise<void> {
|
||||
vocabulary.query = search.value
|
||||
await vocabulary.apply()
|
||||
}
|
||||
|
||||
async function clearFilters(): Promise<void> {
|
||||
search.value = ''
|
||||
await vocabulary.clearFilters()
|
||||
}
|
||||
|
||||
function openEditor(item: VocabularyTerm): void {
|
||||
editing.value = item
|
||||
definition.value = item.definition
|
||||
examples.value = item.examples.join('\n')
|
||||
status.value = item.status
|
||||
level.value = item.level > 0 ? item.level : 1
|
||||
formError.value = ''
|
||||
notice.value = ''
|
||||
vocabulary.saveError = ''
|
||||
}
|
||||
|
||||
function closeEditor(): void {
|
||||
editing.value = null
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const item = editing.value
|
||||
if (!item || vocabulary.saving) return
|
||||
formError.value = textProblem(definition.value) && '' // an empty definition is allowed
|
||||
if (formError.value) return
|
||||
try {
|
||||
const examples2 = examples.value.split('\n').map(line => line.trim()).filter(Boolean)
|
||||
const updated = await vocabulary.updateTerm(item.id, {
|
||||
definition: definition.value,
|
||||
examples: examples2,
|
||||
status: status.value,
|
||||
level: status.value === 'learning' ? level.value : undefined,
|
||||
})
|
||||
editing.value = null
|
||||
notice.value = `已保存 · ${statusLabel(updated)}`
|
||||
} catch {
|
||||
// vocabulary.saveError already carries the server message for the dialog.
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await session.logout() }
|
||||
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
|
||||
finally { await router.replace('/login') }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
readFilters()
|
||||
await load()
|
||||
})
|
||||
// The URL mirrors the filters so a reload or a return keeps what the learner searched for.
|
||||
watch(() => [search.value, vocabulary.status, vocabulary.kind, vocabulary.page], () => {
|
||||
void router.replace({ path: '/vocab', query: Object.fromEntries(Object.entries(queryString.value).filter(([, value]) => value !== '' && value !== undefined).map(([key, value]) => [key, String(value)])) })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab" class="active-nav">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||||
<div class="account">
|
||||
<DisplaySettings />
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page vocab-page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>生词本</h1>
|
||||
<p class="subtle">保存的词语与短语,共 {{ vocabulary.total }} 条。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<RouterLink to="/review" class="link-button">开始到期复习</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="vocab-search" novalidate @submit.prevent="submitSearch">
|
||||
<label for="vocab-query">搜索词 / 释义</label>
|
||||
<ElInput id="vocab-query" v-model="search" type="text" placeholder="输入词语或释义中的一部分" :disabled="vocabulary.loading" />
|
||||
<ElButton type="primary" native-type="submit" :loading="vocabulary.loading" data-testid="vocab-search">搜索</ElButton>
|
||||
<label for="vocab-status">状态</label>
|
||||
<ElSelect id="vocab-status" v-model="vocabulary.status" class="vocab-filter" data-testid="vocab-status" @change="vocabulary.apply()">
|
||||
<ElOption label="全部状态" value="" />
|
||||
<ElOption v-for="item in TERM_STATUSES" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
<label for="vocab-kind">类型</label>
|
||||
<ElSelect id="vocab-kind" v-model="vocabulary.kind" class="vocab-filter" data-testid="vocab-kind" @change="vocabulary.apply()">
|
||||
<ElOption label="全部类型" value="" />
|
||||
<ElOption label="词语" value="word" />
|
||||
<ElOption label="短语" value="phrase" />
|
||||
</ElSelect>
|
||||
</form>
|
||||
|
||||
<p v-if="notice" role="status" class="chapter-notice" data-testid="vocab-notice">{{ notice }}</p>
|
||||
<p v-if="vocabulary.loading" role="status" class="loading">正在加载…</p>
|
||||
<div v-else-if="vocabulary.error" class="notice">
|
||||
<p role="alert">{{ vocabulary.error }}</p>
|
||||
<ElButton @click="load">重试</ElButton>
|
||||
</div>
|
||||
<template v-else-if="vocabulary.items.length">
|
||||
<ul class="vocab-list" aria-label="词条列表">
|
||||
<li v-for="item in vocabulary.items" :key="item.id" class="vocab-row">
|
||||
<div class="vocab-info">
|
||||
<p class="vocab-term">{{ item.originalForm }} <span class="subtle">/</span> {{ item.definition || '(未填写释义)' }}</p>
|
||||
<p class="chapter-meta">
|
||||
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item) }}</span>
|
||||
<template v-if="item.examples.length"> · {{ item.examples[0] }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<ElButton size="small" :data-testid="`vocab-edit-${item.id}`" @click="openEditor(item)">编辑</ElButton>
|
||||
</li>
|
||||
</ul>
|
||||
<nav v-if="vocabulary.totalPages > 1" class="vocab-paging" aria-label="分页">
|
||||
<ElButton :disabled="vocabulary.page <= 1" data-testid="vocab-prev" @click="vocabulary.goToPage(vocabulary.page - 1)">上一页</ElButton>
|
||||
<span class="subtle">第 {{ vocabulary.page }} / {{ vocabulary.totalPages }} 页</span>
|
||||
<ElButton :disabled="vocabulary.page >= vocabulary.totalPages" data-testid="vocab-next" @click="vocabulary.goToPage(vocabulary.page + 1)">下一页</ElButton>
|
||||
</nav>
|
||||
</template>
|
||||
<section v-else-if="vocabulary.hasFilters" class="empty-library" aria-label="搜索无结果" data-testid="vocab-none">
|
||||
<h2>没有匹配的词条</h2>
|
||||
<p class="subtle">试试其他关键词,或清除筛选。</p>
|
||||
<ElButton data-testid="vocab-clear" @click="clearFilters">清除搜索与筛选</ElButton>
|
||||
</section>
|
||||
<section v-else class="empty-library" aria-label="空生词本" data-testid="vocab-empty">
|
||||
<h2>生词本还是空的</h2>
|
||||
<p class="subtle">阅读时点词或选择短语,保存后会出现在这里。</p>
|
||||
<RouterLink to="/" class="link-button">返回书库</RouterLink>
|
||||
</section>
|
||||
|
||||
<ElDialog :model-value="editing !== null" title="编辑词条" width="620" data-testid="vocab-dialog" @update:model-value="closeEditor">
|
||||
<template v-if="editing">
|
||||
<p class="subtle">{{ editing.originalForm }}{{ editing.kind === 'phrase' ? ` · 短语 · ${editing.wordCount} 个单词` : ' · 词语' }}</p>
|
||||
<TermFormFields
|
||||
v-model:definition="definition"
|
||||
v-model:examples="examples"
|
||||
v-model:status="status"
|
||||
v-model:level="level"
|
||||
:show-level="true"
|
||||
:disabled="vocabulary.saving"
|
||||
/>
|
||||
<p v-if="formError" role="alert" class="field-error">{{ formError }}</p>
|
||||
<p v-if="vocabulary.saveError" role="alert" class="field-error">{{ vocabulary.saveError }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ElButton data-testid="vocab-cancel" @click="closeEditor">取消</ElButton>
|
||||
<ElButton type="primary" :loading="vocabulary.saving" data-testid="vocab-save" @click="save">保存修改</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -81,6 +81,9 @@ func Migrate(db *gorm.DB) error {
|
||||
if current < 6 {
|
||||
statements = append(statements, schemaV6Statements...)
|
||||
}
|
||||
if current < 7 {
|
||||
statements = append(statements, schemaV7Statements...)
|
||||
}
|
||||
for i, s := range statements {
|
||||
if _, err = conn.ExecContext(ctx, s); err != nil {
|
||||
var sqlErr *driver.MySQLError
|
||||
@@ -96,12 +99,27 @@ func Migrate(db *gorm.DB) error {
|
||||
|
||||
// SchemaVersion is the version an explicit migration leaves behind, and the
|
||||
// version the server requires before it starts.
|
||||
const SchemaVersion = 6
|
||||
const SchemaVersion = 7
|
||||
|
||||
// v7 records one learner's own reading completion per chapter. A chapter belongs to exactly
|
||||
// one owner, so the chapter id alone identifies the row and repeating the completion cannot
|
||||
// add a second one. read_sha256 snapshots the chapter content the person actually read: when
|
||||
// the text is replaced by a new version the mark stops matching and the chapter counts as
|
||||
// unread again, without deleting the row or touching any word state.
|
||||
var schemaV7Statements = []string{
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_chapter_progress (
|
||||
chapter_id BIGINT UNSIGNED PRIMARY KEY,
|
||||
owner_id BIGINT NOT NULL, book_id BIGINT UNSIGNED NOT NULL,
|
||||
language VARCHAR(16) NOT NULL DEFAULT 'en',
|
||||
read_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
read_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
|
||||
INDEX idx_chapter_progress_owner (owner_id, language, read_at, chapter_id),
|
||||
FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (book_id) REFERENCES lexgo_books(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
}
|
||||
|
||||
// v6 adds review scheduling as its own table instead of altering lexgo_terms: every
|
||||
// statement stays additive and therefore retry-safe after a partial migration, and a
|
||||
// binary restored to v5 keeps writing personal terms unchanged. Existing saved words
|
||||
// enter the queue immediately, because a saved word has never been reviewed.
|
||||
var schemaV6Statements = []string{
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_term_reviews (
|
||||
term_id BIGINT UNSIGNED PRIMARY KEY,
|
||||
|
||||
@@ -58,8 +58,9 @@ type DictionaryImportResult struct {
|
||||
Duplicate bool `json:"duplicate"`
|
||||
}
|
||||
type ChapterTokens struct {
|
||||
TextSHA256 string `json:"textSha256"`
|
||||
Tokens []TextToken `json:"tokens"`
|
||||
TextSHA256 string `json:"textSha256"`
|
||||
Tokens []TextToken `json:"tokens"`
|
||||
Phrases []PhraseSpan `json:"phrases"`
|
||||
}
|
||||
|
||||
// Each router keeps at most one immutable parsed corpus; no private chapter or
|
||||
@@ -277,12 +278,16 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The learner's own saved words are part of the chapter view, so the reader
|
||||
// shows the same status here as everywhere else.
|
||||
// The learner's own saved words and phrases are part of the chapter view, so the
|
||||
// reader shows the same status here as everywhere else.
|
||||
if err = attachTerms(tx, u.UserId, language, tokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ChapterTokens{chapter.ContentSHA256, tokens}, nil
|
||||
phrases, err := phrasesForChapter(tx, u.UserId, language, tokens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ChapterTokens{chapter.ContentSHA256, tokens, phrases}, nil
|
||||
}))
|
||||
v.POST("/lookup", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
var input struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", ""},
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
admin "go-admin/app/admin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// A phrase is a run of consecutive words. Its identity is the ordered normalized word forms
|
||||
// joined by single spaces, so interior punctuation, line breaks and extra whitespace never
|
||||
// split one phrase into two records, and a phrase key can never collide with a word key.
|
||||
const (
|
||||
maxPhraseWords = 12
|
||||
minPhraseWords = 2
|
||||
maxPhraseKey = 128
|
||||
maxPhraseSource = 191
|
||||
)
|
||||
|
||||
// PhraseSpan is one saved phrase occurrence inside a chapter, expressed in token indices so
|
||||
// the reader can style a run without re-deriving the match.
|
||||
type PhraseSpan struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
WordCount int `json:"wordCount"`
|
||||
StartToken int `json:"startToken"`
|
||||
EndToken int `json:"endToken"`
|
||||
}
|
||||
|
||||
// phraseWords returns the word tokens fully inside a requested code point range. A range that
|
||||
// cuts into a word is rejected instead of silently dropping it, so the client always learns
|
||||
// that its selection was not a whole-word range.
|
||||
func phraseWords(tokens []TextToken, start, end int) ([]TextToken, error) {
|
||||
bad := failure(400, "请选择至少两个连续的完整单词")
|
||||
if start < 0 || end <= start {
|
||||
return nil, bad
|
||||
}
|
||||
words := make([]TextToken, 0, 4)
|
||||
for _, token := range tokens {
|
||||
if token.End <= start || token.Start >= end {
|
||||
continue
|
||||
}
|
||||
if token.Kind != "word" {
|
||||
continue
|
||||
}
|
||||
if token.Start < start || token.End > end {
|
||||
return nil, failure(400, "请选择完整的单词,不要切在单词中间")
|
||||
}
|
||||
words = append(words, token)
|
||||
}
|
||||
if len(words) < minPhraseWords {
|
||||
return nil, bad
|
||||
}
|
||||
if len(words) > maxPhraseWords {
|
||||
return nil, failure(400, "短语最多 12 个单词")
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
|
||||
// phraseKey is the identity of a phrase: the ordered normalized word forms joined by spaces.
|
||||
func phraseKey(words []TextToken) string {
|
||||
forms := make([]string, 0, len(words))
|
||||
for _, word := range words {
|
||||
forms = append(forms, normalizeWord(word.Text))
|
||||
}
|
||||
return strings.Join(forms, " ")
|
||||
}
|
||||
|
||||
// phraseSource is the exact original text of the selection, punctuation and line breaks
|
||||
// included, used for display only.
|
||||
func phraseSource(text string, words []TextToken) string {
|
||||
if len(words) == 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
start, end := words[0].Start, words[len(words)-1].End
|
||||
if start < 0 || end > len(runes) || start >= end {
|
||||
return ""
|
||||
}
|
||||
return string(runes[start:end])
|
||||
}
|
||||
|
||||
// phraseMatches finds every saved phrase occurrence in a chapter. Candidates are ordered by
|
||||
// their start and then longest first, and the leftmost-longest non-overlapping set is kept,
|
||||
// which is the display rule #4 verified; nothing stored is modified by matching.
|
||||
func phraseMatches(tokens []TextToken, phrases []Term) []PhraseSpan {
|
||||
if len(phrases) == 0 {
|
||||
return []PhraseSpan{}
|
||||
}
|
||||
// Index the phrases by their first word so a chapter only compares what can match.
|
||||
words := make([]int, 0, len(tokens))
|
||||
for index, token := range tokens {
|
||||
if token.Kind == "word" {
|
||||
words = append(words, index)
|
||||
}
|
||||
}
|
||||
byFirst := map[string][]Term{}
|
||||
for _, phrase := range phrases {
|
||||
forms := strings.Split(phrase.Term, " ")
|
||||
if len(forms) < minPhraseWords {
|
||||
continue
|
||||
}
|
||||
byFirst[forms[0]] = append(byFirst[forms[0]], phrase)
|
||||
}
|
||||
type candidate struct {
|
||||
startToken int
|
||||
endToken int
|
||||
phrase Term
|
||||
}
|
||||
found := make([]candidate, 0, 8)
|
||||
for position, tokenIndex := range words {
|
||||
first := normalizeWord(tokens[tokenIndex].Text)
|
||||
for _, phrase := range byFirst[first] {
|
||||
forms := strings.Split(phrase.Term, " ")
|
||||
if position+len(forms) > len(words) {
|
||||
continue
|
||||
}
|
||||
matched := true
|
||||
for offset, form := range forms {
|
||||
if normalizeWord(tokens[words[position+offset]].Text) != form {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
found = append(found, candidate{words[position], words[position+len(forms)-1], phrase})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(found, func(i, j int) bool {
|
||||
if found[i].startToken != found[j].startToken {
|
||||
return found[i].startToken < found[j].startToken
|
||||
}
|
||||
if found[i].endToken != found[j].endToken {
|
||||
return found[i].endToken > found[j].endToken
|
||||
}
|
||||
return found[i].phrase.ID < found[j].phrase.ID
|
||||
})
|
||||
spans := make([]PhraseSpan, 0, len(found))
|
||||
lastEnd := -1
|
||||
for _, item := range found {
|
||||
if item.startToken <= lastEnd {
|
||||
continue
|
||||
}
|
||||
spans = append(spans, PhraseSpan{
|
||||
ID: item.phrase.ID, Status: item.phrase.Status, WordCount: termWordCount(item.phrase.Term),
|
||||
StartToken: item.startToken, EndToken: item.endToken,
|
||||
})
|
||||
lastEnd = item.endToken
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// phrasesForChapter loads the caller's phrases and matches them against one chapter.
|
||||
func phrasesForChapter(tx *gorm.DB, owner int, language string, tokens []TextToken) ([]PhraseSpan, error) {
|
||||
var phrases []Term
|
||||
// A phrase key always contains a space and a word key never does, so this is the whole
|
||||
// filter; it needs no column.
|
||||
if err := tx.Select("id", "term", "status").
|
||||
Where("owner_id = ? AND language = ? AND term LIKE ?", owner, language, "% %").
|
||||
Find(&phrases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return phraseMatches(tokens, phrases), nil
|
||||
}
|
||||
|
||||
type PhraseInput struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
Start *int `json:"start"`
|
||||
End *int `json:"end"`
|
||||
Definition string `json:"definition"`
|
||||
Examples []string `json:"examples"`
|
||||
Status string `json:"status"`
|
||||
Level *int `json:"level"`
|
||||
}
|
||||
|
||||
// SavePhrase derives the identity from the server's own tokens of an owned ready chapter, so a
|
||||
// client can neither name a phrase it did not select nor forge an owner or language. Saving
|
||||
// reuses the term table and its idempotent upsert, so one phrase stays one record with one
|
||||
// review schedule.
|
||||
func SavePhrase(tx *gorm.DB, owner int, language string, input PhraseInput, now time.Time) (TermSave, error) {
|
||||
if input.Start == nil || input.End == nil {
|
||||
return TermSave{}, failure(400, "请选择至少两个连续的完整单词")
|
||||
}
|
||||
chapter, err := readyOwnedChapter(tx, owner, input.ChapterID)
|
||||
if err != nil {
|
||||
return TermSave{}, err
|
||||
}
|
||||
words, err := phraseWords(Tokenize(chapter.OriginalText), *input.Start, *input.End)
|
||||
if err != nil {
|
||||
return TermSave{}, err
|
||||
}
|
||||
key := phraseKey(words)
|
||||
if utf8.RuneCountInString(key) > maxPhraseKey {
|
||||
return TermSave{}, failure(400, "短语过长,请缩短选择范围")
|
||||
}
|
||||
source := phraseSource(chapter.OriginalText, words)
|
||||
if source == "" || utf8.RuneCountInString(source) > maxPhraseSource {
|
||||
return TermSave{}, failure(400, "短语原文过长,请缩短选择范围")
|
||||
}
|
||||
level, err := termLevel(input.Status, input.Level, Term{}, false)
|
||||
if err != nil {
|
||||
return TermSave{}, err
|
||||
}
|
||||
definition, examples, err := termContent(input.Definition, input.Examples)
|
||||
if err != nil {
|
||||
return TermSave{}, err
|
||||
}
|
||||
previous, exists, err := previousTerm(tx, owner, language, key)
|
||||
if err != nil {
|
||||
return TermSave{}, err
|
||||
}
|
||||
if exists && termKind(previous.Term) != termKindPhrase {
|
||||
return TermSave{}, failure(409, "该内容已被记录为单词")
|
||||
}
|
||||
if exists {
|
||||
level, err = termLevel(input.Status, input.Level, previous, true)
|
||||
if err != nil {
|
||||
return TermSave{}, err
|
||||
}
|
||||
}
|
||||
fields := TermFields{
|
||||
Definition: definition, Examples: examples, Status: input.Status, Level: level,
|
||||
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
|
||||
}
|
||||
// The identity is the word-form key; the original selection is only the display text.
|
||||
return saveTerm(tx, owner, language, key, source, fields, now)
|
||||
}
|
||||
|
||||
func registerPhraseRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
|
||||
v.POST("/phrases", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
var input PhraseInput
|
||||
if err := decode(c, &input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
language, err := languageOf(tx, u.UserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SavePhrase(tx, u.UserId, language, input, now())
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// phraseFixtureText holds the cases #4 verified: interior punctuation, a line break inside a
|
||||
// phrase, a repeated phrase and a combining accent.
|
||||
const phraseFixtureText = "Take a small, step\nevery day.\nMira took a small step again.\n"
|
||||
|
||||
func phraseTokens(text string) []TextToken {
|
||||
return Tokenize(text)
|
||||
}
|
||||
|
||||
func spanOf(t *testing.T, tokens []TextToken, words ...string) (int, int) {
|
||||
t.Helper()
|
||||
var first, last TextToken
|
||||
found := 0
|
||||
for _, token := range tokens {
|
||||
if token.Kind != "word" {
|
||||
continue
|
||||
}
|
||||
if found < len(words) && normalizeWord(token.Text) == normalizeWord(words[found]) {
|
||||
if found == 0 {
|
||||
first = token
|
||||
}
|
||||
last = token
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != len(words) {
|
||||
t.Fatalf("could not find %v in %q", words, text(tokens))
|
||||
}
|
||||
return first.Start, last.End
|
||||
}
|
||||
|
||||
func text(tokens []TextToken) string {
|
||||
var builder strings.Builder
|
||||
for _, token := range tokens {
|
||||
builder.WriteString(token.Text)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// TestPhraseIdentityRules pins the range and identity rules of a phrase.
|
||||
func TestPhraseIdentityRules(t *testing.T) {
|
||||
tokens := phraseTokens(phraseFixtureText)
|
||||
|
||||
// Interior punctuation and a line break stay in the display text but not in the key.
|
||||
start, end := spanOf(t, tokens, "a", "small", "step")
|
||||
words, err := phraseWords(tokens, start, end)
|
||||
if err != nil || len(words) != 3 {
|
||||
t.Fatalf("phrase words: %v %v", words, err)
|
||||
}
|
||||
if key := phraseKey(words); key != "a small step" {
|
||||
t.Fatalf("key %q, want %q", key, "a small step")
|
||||
}
|
||||
if source := phraseSource(phraseFixtureText, words); source != "a small, step\nevery day."[:0]+"a small, step" {
|
||||
t.Fatalf("source %q", source)
|
||||
}
|
||||
// The same words separated by different punctuation are one identity.
|
||||
secondTokens := phraseTokens("Mira took a small step again.")
|
||||
secondStart, secondEnd := spanOf(t, secondTokens, "a", "small", "step")
|
||||
other, err := phraseWords(secondTokens, secondStart, secondEnd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if phraseKey(other) != "a small step" {
|
||||
t.Fatalf("a differently spaced phrase must share the key: %q", phraseKey(other))
|
||||
}
|
||||
// Case and curly apostrophes fold, exactly like word identity.
|
||||
if key := phraseKey(mustWords(t, "Don’t Look Back")); key != "don't look back" {
|
||||
t.Fatalf("normalized phrase key: %q", key)
|
||||
}
|
||||
|
||||
// Endpoint alignment: a range that cuts into a word is refused instead of dropping it.
|
||||
if _, err := phraseWords(tokens, start+3, end); err == nil {
|
||||
t.Fatal("a range cutting into a word must be rejected")
|
||||
}
|
||||
if _, err := phraseWords(tokens, start, end-2); err == nil {
|
||||
t.Fatal("a range cutting into the last word must be rejected")
|
||||
}
|
||||
// A range that starts on a separator simply begins at the next whole word.
|
||||
trimmed, err := phraseWords(tokens, start+1, end)
|
||||
if err != nil || len(trimmed) != 2 || normalizeWord(trimmed[0].Text) != "small" {
|
||||
t.Fatalf("a separator boundary must select whole words: %v %v", trimmed, err)
|
||||
}
|
||||
// Surrounding whitespace and punctuation are allowed on both ends.
|
||||
if _, err := phraseWords(tokens, start-1, end+1); err != nil {
|
||||
t.Fatalf("surrounding separators must be accepted: %v", err)
|
||||
}
|
||||
// A single word, an empty range and a punctuation-only range are not phrases.
|
||||
if _, err := phraseWords(tokens, words[0].Start, words[0].End); err == nil {
|
||||
t.Fatal("one word is not a phrase")
|
||||
}
|
||||
if _, err := phraseWords(tokens, 0, 0); err == nil {
|
||||
t.Fatal("an empty range is not a phrase")
|
||||
}
|
||||
if _, err := phraseWords(tokens, -1, 4); err == nil {
|
||||
t.Fatal("a negative range is not a phrase")
|
||||
}
|
||||
// Limits: at most 12 words, and a key within the stored column.
|
||||
long := make([]TextToken, 0, maxPhraseWords+1)
|
||||
position := 0
|
||||
for index := 0; index < maxPhraseWords+1; index++ {
|
||||
word := "word"
|
||||
long = append(long, TextToken{Text: word, Start: position, End: position + len(word), Kind: "word"})
|
||||
position += len(word) + 1
|
||||
}
|
||||
if _, err := phraseWords(long, 0, position); err == nil {
|
||||
t.Fatal("more than 12 words must be rejected")
|
||||
}
|
||||
if _, err := phraseWords(long[:maxPhraseWords], 0, len(long[maxPhraseWords-1].Text)+long[maxPhraseWords-1].Start); err != nil {
|
||||
t.Fatalf("exactly 12 words must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWordKeysNeverContainSpaces locks the premise the derived kind relies on: a word token
|
||||
// never contains a space, so the identity key alone tells a word from a phrase and no column is
|
||||
// needed for it.
|
||||
func TestWordKeysNeverContainSpaces(t *testing.T) {
|
||||
samples := []string{phraseFixtureText, "a b\tc\r\nd", "don’t stop-BELIEVING.", "🙂 café e\u0301 42"}
|
||||
checked := 0
|
||||
for _, sample := range samples {
|
||||
for _, token := range Tokenize(sample) {
|
||||
if token.Kind != "word" {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if strings.ContainsAny(normalizeWord(token.Text), " \t\r\n") {
|
||||
t.Fatalf("word key %q contains whitespace", normalizeWord(token.Text))
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked < 8 {
|
||||
t.Fatalf("expected several word tokens, checked %d", checked)
|
||||
}
|
||||
// The derived kind therefore agrees with the shape of the key in both directions.
|
||||
if termKind("a small step") != termKindPhrase || termWordCount("a small step") != 3 {
|
||||
t.Fatal("a key with spaces must be a three-word phrase")
|
||||
}
|
||||
if termKind("curiosity") != termKindWord || termWordCount("curiosity") != 1 {
|
||||
t.Fatal("a key without spaces must be a word")
|
||||
}
|
||||
}
|
||||
|
||||
func mustWords(t *testing.T, text string) []TextToken {
|
||||
t.Helper()
|
||||
words, err := phraseWords(phraseTokens(text), 0, len([]rune(text)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// TestPhraseMatchingAndOverlap pins the cross-chapter match and the leftmost-longest display.
|
||||
func TestPhraseMatchingAndOverlap(t *testing.T) {
|
||||
tokens := phraseTokens(phraseFixtureText)
|
||||
step := Term{ID: 1, Term: "a small step", Status: termStatusNew}
|
||||
small := Term{ID: 2, Term: "small step", Status: termStatusLearning}
|
||||
|
||||
spans := phraseMatches(tokens, []Term{step, small})
|
||||
if len(spans) != 2 {
|
||||
t.Fatalf("expected one occurrence of each phrase, got %+v", spans)
|
||||
}
|
||||
// The longer phrase starts first, so it wins and the shorter one is dropped where they
|
||||
// overlap; the later occurrence of "a small step" is a separate match.
|
||||
if spans[0].ID != step.ID || spans[1].ID != step.ID {
|
||||
t.Fatalf("leftmost-longest rule: %+v", spans)
|
||||
}
|
||||
if spans[0].StartToken >= spans[1].StartToken {
|
||||
t.Fatalf("spans must be ordered: %+v", spans)
|
||||
}
|
||||
// A phrase that no longer occurs is simply absent, and nothing is mutated.
|
||||
if len(phraseMatches(phraseTokens("Nothing to see here."), []Term{step})) != 0 {
|
||||
t.Fatal("a phrase that does not occur must not match")
|
||||
}
|
||||
// Two different phrases that both match at the same place keep a stable choice.
|
||||
spans = phraseMatches(tokens, []Term{small, step})
|
||||
if len(spans) != 2 || spans[0].ID != step.ID {
|
||||
t.Fatalf("order of input must not change the display: %+v", spans)
|
||||
}
|
||||
// A single-word entry never participates as a phrase.
|
||||
if len(phraseMatches(tokens, []Term{{ID: 9, Term: "step"}})) != 0 {
|
||||
t.Fatal("a single-word key is not a phrase match")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLPhraseSaveAndCrossChapterHighlight is the core path: saving a phrase, finding it
|
||||
// again in another chapter, and keeping one record per identity.
|
||||
func TestMySQLPhraseSaveAndCrossChapterHighlight(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
other := newLearner(t, r, owner.Token)
|
||||
drainIngest(t, db)
|
||||
|
||||
code, first := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "phrase-fixture-0001", "title": "Phrase chapter", "text": phraseFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste %d", code)
|
||||
}
|
||||
// A second chapter with the same phrase in a different form.
|
||||
code, second := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "phrase-fixture-0002", "title": "Another chapter", "text": "Mira took a small step again.\n", "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("second paste %d", code)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
|
||||
tokens := phraseTokens(phraseFixtureText)
|
||||
start, end := spanOf(t, tokens, "a", "small", "step")
|
||||
body := map[string]any{
|
||||
"chapterId": first.Chapter.ID, "start": start, "end": end,
|
||||
"definition": "一小步", "examples": []string{"Take a small step, every day."}, "status": termStatusNew,
|
||||
}
|
||||
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body)
|
||||
if code != 201 {
|
||||
t.Fatalf("save phrase %d (%s)", code, msg)
|
||||
}
|
||||
var saved struct {
|
||||
Term TermView
|
||||
Created bool
|
||||
}
|
||||
json.Unmarshal(data, &saved)
|
||||
if saved.Term.Kind != "phrase" || saved.Term.WordCount != 3 || saved.Term.Term != "a small step" {
|
||||
t.Fatalf("saved phrase %+v", saved.Term)
|
||||
}
|
||||
if saved.Term.OriginalForm != "a small, step" {
|
||||
t.Fatalf("the display text keeps the original punctuation: %q", saved.Term.OriginalForm)
|
||||
}
|
||||
|
||||
// The same phrase saved from the other chapter, written differently, stays one record.
|
||||
otherTokens := phraseTokens("Mira took a small step again.\n")
|
||||
otherStart, otherEnd := spanOf(t, otherTokens, "a", "small", "step")
|
||||
code, msg, data = callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
|
||||
"chapterId": second.Chapter.ID, "start": otherStart, "end": otherEnd, "definition": "一小步(更新)", "status": termStatusNew,
|
||||
})
|
||||
if code != 200 {
|
||||
t.Fatalf("repeat phrase %d (%s)", code, msg)
|
||||
}
|
||||
var again struct {
|
||||
Term TermView
|
||||
Created bool
|
||||
}
|
||||
json.Unmarshal(data, &again)
|
||||
if again.Created || again.Term.ID != saved.Term.ID || again.Term.OriginalForm != "a small step" {
|
||||
t.Fatalf("one phrase must stay one record: %+v", again)
|
||||
}
|
||||
var count int64
|
||||
// A phrase key always contains a space, so the key shape is the whole filter.
|
||||
db.Model(&Term{}).Where("owner_id = ? AND term LIKE ?", learner.ID, "% %").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("phrase records: %d", count)
|
||||
}
|
||||
|
||||
// Both chapters highlight the phrase, including the one where it was not saved. The first
|
||||
// chapter holds the phrase twice with different interior punctuation.
|
||||
wantOccurrences := map[int64]int{first.Chapter.ID: 2, second.Chapter.ID: 1}
|
||||
for chapterID, want := range wantOccurrences {
|
||||
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", chapterID), learner.Token, nil)
|
||||
var analyzed ChapterTokens
|
||||
json.Unmarshal(data, &analyzed)
|
||||
if code != 200 || len(analyzed.Phrases) != want {
|
||||
t.Fatalf("chapter %d phrases: %d %+v", chapterID, code, analyzed.Phrases)
|
||||
}
|
||||
for _, span := range analyzed.Phrases {
|
||||
if span.ID != saved.Term.ID || span.WordCount != 3 || span.EndToken <= span.StartToken {
|
||||
t.Fatalf("chapter %d span %+v", chapterID, span)
|
||||
}
|
||||
if analyzed.Tokens[span.StartToken].Kind != "word" || analyzed.Tokens[span.EndToken].Kind != "word" {
|
||||
t.Fatalf("a phrase must span words: %+v", span)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Another account sees no phrase at all.
|
||||
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), other.Token, nil)
|
||||
if code != 404 {
|
||||
t.Fatalf("another account reading this chapter: %d", code)
|
||||
}
|
||||
// The phrase joins the review queue with its kind, and answers like a word.
|
||||
code, queue := reviewQueue(t, r, learner.Token)
|
||||
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" || queue.Items[0].WordCount != 3 {
|
||||
t.Fatalf("phrase queue: %d %+v", code, queue.Items)
|
||||
}
|
||||
code, applied := answerReview(t, r, learner.Token, saved.Term.ID, answerBody("phrase-answer-0001", reviewGradeCorrect, queue.Items[0].DueAt))
|
||||
if code != 201 || applied.Result != "applied" || applied.LevelAfter != 1 || applied.Item.Kind != "phrase" {
|
||||
t.Fatalf("phrase answer: %d %+v", code, applied)
|
||||
}
|
||||
// The word-level highlight is unaffected: the same words are still ordinary words.
|
||||
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), learner.Token, nil)
|
||||
var withPhrase ChapterTokens
|
||||
json.Unmarshal(data, &withPhrase)
|
||||
for _, index := range []int{withPhrase.Phrases[0].StartToken, withPhrase.Phrases[0].EndToken} {
|
||||
if withPhrase.Tokens[index].Term != nil {
|
||||
t.Fatal("a phrase must not create word-level records")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLPhraseRulesAndIsolation covers the rejected shapes and ownership.
|
||||
func TestMySQLPhraseRulesAndIsolation(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
other := newLearner(t, r, owner.Token)
|
||||
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "phrase-rules-0001", "title": "Phrase rules", "text": phraseFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste %d", code)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
tokens := phraseTokens(phraseFixtureText)
|
||||
start, end := spanOf(t, tokens, "a", "small", "step")
|
||||
singleStart, singleEnd := wordsAt(t, tokens, "a")
|
||||
base := map[string]any{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew}
|
||||
|
||||
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", other.Token, base); code != 404 {
|
||||
t.Fatal("another account must not save a phrase in this chapter")
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", "", base); code != 401 {
|
||||
t.Fatal("saving a phrase needs a session")
|
||||
}
|
||||
invalid := []map[string]any{
|
||||
{"chapterId": pasted.Chapter.ID, "start": singleStart, "end": singleEnd, "status": termStatusNew},
|
||||
{"chapterId": pasted.Chapter.ID, "end": end, "status": termStatusNew},
|
||||
{"chapterId": pasted.Chapter.ID, "start": start + 3, "end": end, "status": termStatusNew},
|
||||
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": "deleted"},
|
||||
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusKnown, "level": 3},
|
||||
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "definition": strings.Repeat("a", termDefinitionLimit+1)},
|
||||
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "term": "forged"},
|
||||
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "ownerId": 9},
|
||||
{"chapterId": 999999, "start": start, "end": end, "status": termStatusNew},
|
||||
}
|
||||
for _, body := range invalid {
|
||||
if code, msg, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body); code != 400 && code != 404 {
|
||||
t.Fatalf("invalid phrase %v -> %d (%s)", body, code, msg)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
db.Model(&Term{}).Where("owner_id = ?", learner.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("a rejected phrase was stored: %d", count)
|
||||
}
|
||||
|
||||
// A phrase in a chapter that is not ready is refused, exactly like a word.
|
||||
if err := db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).Update("status", statusPending).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, base); code != 409 {
|
||||
t.Fatal("a pending chapter must refuse a phrase")
|
||||
}
|
||||
}
|
||||
|
||||
func wordsAt(t *testing.T, tokens []TextToken, word string) (int, int) {
|
||||
t.Helper()
|
||||
start, end := spanOf(t, tokens, word)
|
||||
return start, end
|
||||
}
|
||||
|
||||
// TestMySQLPhraseSurvivesTextEditsAndDeletion locks the fallback rule for stale references.
|
||||
func TestMySQLPhraseSurvivesTextEditsAndDeletion(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "phrase-survive-0001", "title": "Phrase survival", "text": phraseFixtureText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste %d", code)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
tokens := phraseTokens(phraseFixtureText)
|
||||
start, end := spanOf(t, tokens, "a", "small", "step")
|
||||
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
|
||||
"chapterId": pasted.Chapter.ID, "start": start, "end": end, "definition": "一小步", "status": termStatusNew})
|
||||
if code != 201 {
|
||||
t.Fatalf("save phrase %d (%s)", code, msg)
|
||||
}
|
||||
var saved struct {
|
||||
Term TermView
|
||||
}
|
||||
json.Unmarshal(data, &saved)
|
||||
|
||||
// Editing the text so the phrase no longer occurs: the entry and its schedule stay.
|
||||
if code, msg, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": "Nothing left of it.\n"}); code != 200 {
|
||||
t.Fatalf("edit chapter %d (%s)", code, msg)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", pasted.Chapter.ID), learner.Token, nil)
|
||||
var analyzed ChapterTokens
|
||||
json.Unmarshal(data, &analyzed)
|
||||
if code != 200 || len(analyzed.Phrases) != 0 {
|
||||
t.Fatalf("a phrase that no longer occurs must not highlight: %d %+v", code, analyzed.Phrases)
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
|
||||
t.Fatal("the entry must survive a text edit")
|
||||
}
|
||||
code, queue := reviewQueue(t, r, learner.Token)
|
||||
if code != 200 || len(queue.Items) != 1 {
|
||||
t.Fatalf("the schedule must survive a text edit: %d %+v", code, queue.Items)
|
||||
}
|
||||
// Deleting the chapter keeps the entry and its schedule too.
|
||||
if code, msg, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil); code != 200 {
|
||||
t.Fatalf("delete chapter %d (%s)", code, msg)
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
|
||||
t.Fatal("the entry must survive a chapter deletion")
|
||||
}
|
||||
code, queue = reviewQueue(t, r, learner.Token)
|
||||
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" {
|
||||
t.Fatalf("the schedule must survive a chapter deletion: %d %+v", code, queue.Items)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -80,6 +80,8 @@ type ReviewItem struct {
|
||||
Examples []string `json:"examples"`
|
||||
Status string `json:"status"`
|
||||
Level int `json:"level"`
|
||||
Kind string `json:"kind"`
|
||||
WordCount int `json:"wordCount"`
|
||||
DueAt time.Time `json:"dueAt"`
|
||||
ReviewCount int `json:"reviewCount"`
|
||||
}
|
||||
@@ -162,22 +164,28 @@ func reviewItem(term Term, review TermReview) ReviewItem {
|
||||
return ReviewItem{
|
||||
ID: term.ID, Term: term.Term, OriginalForm: term.OriginalForm, Definition: term.Definition,
|
||||
Examples: splitExamples(term.Examples), Status: term.Status, Level: term.Level,
|
||||
Kind: termKind(term.Term), WordCount: termWordCount(term.Term),
|
||||
DueAt: review.DueAt, ReviewCount: review.ReviewCount,
|
||||
}
|
||||
}
|
||||
|
||||
// dueTermsQuery is the single definition of "due now": a saved word or phrase that is still
|
||||
// new or learning and whose scheduled instant is not in the future. The progress page counts
|
||||
// the same predicate as the review queue, so the two numbers cannot drift apart.
|
||||
func dueTermsQuery(tx *gorm.DB, owner int, language string, now time.Time) *gorm.DB {
|
||||
return tx.Table("lexgo_terms AS t").
|
||||
Joins("JOIN lexgo_term_reviews AS r ON r.term_id = t.id").
|
||||
Where("t.owner_id = ? AND r.owner_id = ? AND t.language = ? AND r.language = ?", owner, owner, language, language).
|
||||
Where("t.status IN ?", []string{termStatusNew, termStatusLearning}).
|
||||
Where("r.due_at <= ?", stamp(now))
|
||||
}
|
||||
|
||||
// ReviewQueueFor returns the caller's due words for the current language. Known and
|
||||
// ignored words stay out of the queue, and the queue is an absolute-instant comparison:
|
||||
// an item is due as soon as due_at is not in the future.
|
||||
func ReviewQueueFor(tx *gorm.DB, owner int, language string, now time.Time) (ReviewQueue, error) {
|
||||
queue := ReviewQueue{Items: []ReviewItem{}}
|
||||
due := func() *gorm.DB {
|
||||
return tx.Table("lexgo_terms AS t").
|
||||
Joins("JOIN lexgo_term_reviews AS r ON r.term_id = t.id").
|
||||
Where("t.owner_id = ? AND r.owner_id = ? AND t.language = ? AND r.language = ?", owner, owner, language, language).
|
||||
Where("t.status IN ?", []string{termStatusNew, termStatusLearning}).
|
||||
Where("r.due_at <= ?", stamp(now))
|
||||
}
|
||||
due := func() *gorm.DB { return dueTermsQuery(tx, owner, language, now) }
|
||||
var total int64
|
||||
if err := due().Count(&total).Error; err != nil {
|
||||
return queue, err
|
||||
@@ -203,6 +211,7 @@ func ReviewQueueFor(tx *gorm.DB, owner int, language string, now time.Time) (Rev
|
||||
queue.Items = append(queue.Items, ReviewItem{
|
||||
ID: row.ID, Term: row.Term, OriginalForm: row.OriginalForm, Definition: row.Definition,
|
||||
Examples: splitExamples(row.Examples), Status: row.Status, Level: row.Level,
|
||||
Kind: termKind(row.Term), WordCount: termWordCount(row.Term),
|
||||
DueAt: row.DueAt, ReviewCount: row.ReviewCount,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,8 +137,8 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
|
||||
status = 201
|
||||
}
|
||||
}
|
||||
// Saving the same word again is an update of one record, not a new resource.
|
||||
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/terms" {
|
||||
// Saving the same entry again is an update of one record, not a new resource.
|
||||
if c.Request.Method == "POST" && (c.FullPath() == "/api/v1/terms" || c.FullPath() == "/api/v1/phrases") {
|
||||
if saved, ok := data.(TermSave); ok && saved.Created {
|
||||
status = 201
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ const (
|
||||
termStatusIgnored = "ignored"
|
||||
)
|
||||
|
||||
// One table holds both entries: a word and a phrase differ in kind and word count, and a
|
||||
// phrase key always contains a space, so the identity keys cannot collide.
|
||||
const (
|
||||
termKindWord = "word"
|
||||
termKindPhrase = "phrase"
|
||||
)
|
||||
|
||||
const (
|
||||
termFormLimit = 128
|
||||
termDefinitionLimit = 2000
|
||||
@@ -60,6 +67,23 @@ type Term struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Kind and word count need no columns: a phrase key is the ordered normalized word forms
|
||||
// joined by single spaces, and a word form can never contain a space, so the identity key
|
||||
// itself carries both facts. One helper keeps that invariant in a single place.
|
||||
func termKind(key string) string {
|
||||
if strings.Contains(key, " ") {
|
||||
return termKindPhrase
|
||||
}
|
||||
return termKindWord
|
||||
}
|
||||
|
||||
func termWordCount(key string) int {
|
||||
if !strings.Contains(key, " ") {
|
||||
return 1
|
||||
}
|
||||
return len(strings.Split(key, " "))
|
||||
}
|
||||
|
||||
func (Term) TableName() string { return "lexgo_terms" }
|
||||
|
||||
type TermView struct {
|
||||
@@ -71,6 +95,8 @@ type TermView struct {
|
||||
Examples []string `json:"examples"`
|
||||
Status string `json:"status"`
|
||||
Level int `json:"level"`
|
||||
Kind string `json:"kind"`
|
||||
WordCount int `json:"wordCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -97,7 +123,12 @@ func splitExamples(text string) []string {
|
||||
}
|
||||
|
||||
func termView(t Term) TermView {
|
||||
return TermView{t.ID, t.Language, t.Term, t.OriginalForm, t.Definition, splitExamples(t.Examples), t.Status, t.Level, t.CreatedAt, t.UpdatedAt}
|
||||
return TermView{
|
||||
ID: t.ID, Language: t.Language, Term: t.Term, OriginalForm: t.OriginalForm, Definition: t.Definition,
|
||||
Examples: splitExamples(t.Examples), Status: t.Status, Level: t.Level,
|
||||
Kind: termKind(t.Term), WordCount: termWordCount(t.Term),
|
||||
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// termLevel enforces the documented status/level boundary: only a learning entry carries a
|
||||
@@ -215,10 +246,13 @@ func previousTerm(tx *gorm.DB, owner int, language, term string) (Term, bool, er
|
||||
// saveTerm writes one identity with INSERT ... ON DUPLICATE KEY UPDATE: a repeated
|
||||
// save updates the same row instead of adding a second, conflicting record, and
|
||||
// two concurrent saves of the same word still leave exactly one.
|
||||
func saveTerm(tx *gorm.DB, owner int, language, word string, fields TermFields, now time.Time) (TermSave, error) {
|
||||
// saveTerm stores one identity: key is the canonical form used for lookup, display is what the
|
||||
// learner sees. For a word both are the word form; for a phrase the key joins normalized word
|
||||
// forms and the display keeps the original punctuation.
|
||||
func saveTerm(tx *gorm.DB, owner int, language, key, display string, fields TermFields, now time.Time) (TermSave, error) {
|
||||
when := stamp(now)
|
||||
row := Term{
|
||||
OwnerID: owner, Language: language, Term: normalizeWord(word), OriginalForm: word,
|
||||
OwnerID: owner, Language: language, Term: key, OriginalForm: display,
|
||||
Definition: fields.Definition, Examples: fields.Examples, Status: fields.Status, Level: fields.Level,
|
||||
CreatedAt: when, UpdatedAt: when,
|
||||
}
|
||||
@@ -353,7 +387,7 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
|
||||
Definition: definition, Examples: examples, Status: input.Status, Level: level,
|
||||
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
|
||||
}
|
||||
return saveTerm(tx, u.UserId, language, word, fields, now())
|
||||
return saveTerm(tx, u.UserId, language, normalizeWord(word), word, fields, now())
|
||||
}))
|
||||
v.GET("/terms/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
@@ -370,4 +404,7 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
|
||||
}
|
||||
return gin.H{"term": termView(term)}, nil
|
||||
}))
|
||||
registerPhraseRoutes(v, protect, now)
|
||||
registerVocabularyRoutes(v, protect, now)
|
||||
registerProgressRoutes(v, protect, now)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user