feat: 词汇库搜索、筛选与编辑 (#12)
- GET /api/v1/terms 列表:查询键白名单、page/limit 边界(默认 20、上限 100)、 排序 updated_at DESC;搜索同时匹配身份键、显示原文与个人释义,转小写后比较(term 为 二进制排序规则),%/_/\ 按字面值转义;status 与 kind 可组合 - PATCH /api/v1/terms/:id 编辑:复用释义/例句与状态/等级校验,身份不可编辑; 只有状态或等级变化才重排复习时间,历史作答记录与计数保留 - 学习端新增 /vocab 路由与导航、stores/vocabulary.ts、VocabView.vue(搜索、筛选、 分页、空态与无结果清除、编辑对话框含 1~7 等级选择器、筛选与页码写入 URL) - 释义/例句/状态/等级抽成 TermFormFields.vue,与阅读器面板共用,避免规则漂移 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
This commit is contained in:
@@ -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: 20673392cd1d63088050b542f566133a2bdd1558
|
||||
synchronized_at: 2026-09-14T14:59:41Z
|
||||
wiki_revision: 3fb79b56d07fa08eb9231642bff43a5270e8ef9a
|
||||
synchronized_at: 2026-09-14T15:45:40Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -328,3 +328,18 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
|
||||
| GET /api/v1/reviews/queue | 队列项增加 `kind` 与 `wordCount`;短语与单词同一队列、同一作答接口 |
|
||||
|
||||
学习端:`composables/readerRange.ts` 是纯函数层(整词对齐、内部保留、端点按词调整、命中优先级、区间换算),`composables/useTextSelection.ts` 监听 `selectionchange`(100ms 去抖)与 document 的 `pointerup` 读取浏览器原生选区并映射为 token 索引,**不拦截 touchmove、不 preventDefault**;`ReaderTokens.vue` 为每个 token 输出 `data-token-index` 与短语区间样式;`ReaderView.vue` 负责把选区变成短语、shift 点击扩展、以及面板端点调整;`LookupPanel.vue` 增加短语标题与四个端点按钮;复习卡用 `maskedPrompt` 把整段短语挖成一个空。已保存短语点击优先打开短语面板,单词数据不受影响。
|
||||
|
||||
## #12 词汇库:搜索、筛选与编辑(2026-09-11)
|
||||
|
||||
`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`,与阅读器面板共用;等级选择器只在词汇库编辑对话框中出现(阅读器面板仍只有四个状态)。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: fb3ce7506f1eb1940d7aa57226d22e4f8a270633
|
||||
synchronized_at: 2026-09-14T14:21:32Z
|
||||
wiki_revision: a47d8a5e230d2aea2067ed40004694af77ce3527
|
||||
synchronized_at: 2026-09-14T15:45:41Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -242,3 +242,17 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
|
||||
**复习**:短语进入同一个到期队列与同一套间隔表;卡片正面显示短语并把**整段短语挖成一个空**(例句里没有该短语时只显示短语本身),答案面显示个人释义;答对/答错/再学与幂等、stale 规则与单词完全一致;计数归属也一致(`correct_count` 只计答对,`wrong_count` 计答错与再学)。
|
||||
|
||||
**范围边界**:不做短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑(#12)、真机手柄精细手感。短语的例句同样是手输内容,不自动关联原文句子。
|
||||
|
||||
## #12 词汇库规则(2026-09-11)
|
||||
|
||||
**列表范围**:只返回当前会话本人、当前语言(英语空间)的词条,单词与短语都在同一列表里;任何查询都不接受用户或语言参数,越权编号与不存在编号同样得到 404。
|
||||
|
||||
**搜索规则**:搜索词去首尾空白后与**规范化身份键**、**显示原文**、**个人释义**三处做子串匹配,任一命中即返回。大小写不敏感:`term` 列是二进制排序规则,因此查询先转小写再比较;`original_form` 与 `definition` 用默认排序规则。不做分词、不做前缀优化。空查询=不过滤;查询超过 64 个字符返回 400。`%`、`_`、`\` 按字面值处理(转义后传入),所以搜索 `%` 得到的是包含百分号的词条,而不是全部词条。
|
||||
|
||||
**筛选与分页**:`status`(新词/学习中/已知/忽略)与 `kind`(词语/短语)可单独或组合使用,并可与搜索组合;非法枚举值 400,不静默忽略。分页为 `page`(从 1 开始)与 `limit`(默认 20,最大 100),按最近更新倒序排列,返回的 `total` 是过滤后的总数;超出末页返回空列表但 `total` 不变。筛选与页码写入 URL,刷新或返回列表都保留。
|
||||
|
||||
**编辑规则**:编辑按词条编号进行,沿用与阅读器保存相同的领域校验——释义最长 2000 字符、例句最多 5 条且每条最长 500 字符、只有「学习中」可以带 1~7 级、其他状态等级必须为 0。**身份不可编辑**:词形、原文与「词或短语」不由客户端决定,未知字段一律 400,所以编辑不会把一条记录变成另一条。只有状态或等级真正变化时才移动复习时间(进入「学习中」按该等级的间隔排期,改回「新词」立即到期,改成「已知」或「忽略」后不再进入到期队列);只改释义或例句保留原排期。编辑不改写历史:作答次数、答对与答错计数、复习记录都保留;同一内容重复提交没有额外副作用。
|
||||
|
||||
**界面契约**:列表行显示「原文 / 个人释义 · 状态」(短语标注「短语」并显示等级),提供编辑入口与「开始到期复习」入口;编辑对话框在「学习中」时显示 1~7 等级选择器,其他状态不显示;保存失败保留输入与对话框。空列表提示去阅读保存,「没有匹配的词条」提供一键清除搜索与筛选。
|
||||
|
||||
**已知偏差与边界**:原型的编辑页顶部有「来自 A small step」(来源章节),本单**不实现**——词条按身份存储、不引用章节(#10/#11 已确认规则),因此没有可显示的来源。不包含 CSV 导入导出(X04)、复杂复习范围筛选(X11)、删除词条或批量清理(X14),也不做批量操作。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 1643f05aa6987196d5dcc82a07e2daa07e2f2b4d
|
||||
synchronized_at: 2026-09-14T14:21:32Z
|
||||
wiki_revision: de3225fdb9662684a3d61e8eedd5bf3c0043c0f6
|
||||
synchronized_at: 2026-09-14T15:45:41Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -434,3 +434,21 @@ node --test spikes/english/view.test.mjs
|
||||
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程,脚本自建两章 fixture 并在结束前删除书籍)。覆盖保存与身份键、显示原文保留标点、跨章节同一条记录与两处高亮、span 两端必须是词、短语内的单词仍是独立词条、单词/切词/伪造身份/越权/未登录的拒绝、到期队列与作答(含重复提交回放)、编辑正文后不再高亮但条目与排期保留、删除章节后条目保留。随后用临时 Playwright 用例在真实学习端完成「导入 → 选择范围 → 保存短语 → 两处高亮 → 点击已保存短语 → 复习整段挖空 → 清理 fixture」闭环,并用程序化选区在同一真实页面上验证范围映射与服务端身份一致。
|
||||
|
||||
未验证与已知限制:真实手机手柄与滚动的手感仍是 #4 起的既有缺口,本单只用桌面浏览器检查。**Playwright 的合成鼠标拖拽在真实页面上不会扩展原生选区**(在同一浏览器里对 mock 页面是成功的,程序化选区在真实页面也能唤起面板),因此真实链路的范围构建改用真实点击 + Shift 点击,连续拖选由 mock E2E 与程序化选区覆盖;真人鼠标拖选与真机手柄仍需人工复核。短语只在同一学习者与语言内匹配,不跨账号共享。
|
||||
|
||||
## #12 验证与迁移(2026-09-11)
|
||||
|
||||
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。**本单不新增数据库列或表**,schema 保持 v6,没有迁移步骤,回退只需换回旧二进制。
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `go vet ./...` | 通过 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 69 个顶层用例全部通过、0 跳过;含 #12 新增 4 个用例 |
|
||||
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 120 项单测、类型检查、构建、13 项 E2E 全部通过(新增 13 单测与 2 项 E2E) |
|
||||
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
|
||||
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
|
||||
|
||||
覆盖内容:查询解析与边界(未知键、重复键、`page=0`、`limit=101`、非法状态与类型、查询超长)、LIKE 通配符转义、空查询与去空白、三处命中(身份键/显示原文/中文释义)、大小写不敏感、搜索与状态/类型筛选的组合、分页(首/末/越界页与 `total` 稳定、排序稳定)、只返回本人与当前语言、编辑(只改文本不动排期、改状态重排、进入学习中按等级排期、已知或忽略立刻离开到期队列、改回新词立即到期、计数与作答记录保留、同内容重复提交无副作用)、等级与状态边界、身份不可编辑、空编辑与未知字段 400、越权 404 与未登录 401;前端覆盖列表与分页、搜索与筛选(含 URL 恢复)、空态与无结果清除、编辑对话框的等级选择器、保存失败保留输入、旧响应丢弃。
|
||||
|
||||
真实链路验证:真实 Go API+真实 MySQL 共 40 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行恢复 fixture),覆盖列表字段与排序、大小写不敏感搜索、中文释义搜索、通配符字面值、类型与状态筛选组合、分页与越界页、参数拒绝、两账号隔离、未登录、编辑的排期规则与身份不可编辑。随后用临时 Playwright 用例在真实学习端完成「进入生词本 → 按类型筛选 → 搜索 → 编辑释义 → 保存 → 刷新后筛选仍保留 → 还原释义」闭环。截图保存在本机 `.local/evidence/`(issue12-vocab-list.png、issue12-vocab-none.png、issue12-vocab-edit.png、issue12-real-list.png、issue12-real-search.png、issue12-real-edit.png),临时用例运行后删除。
|
||||
|
||||
未验证:真实手机触屏与滚动证据仍属 #4/#11 起的既有缺口(由 #14 承接);本单只用桌面浏览器检查。搜索只做子串匹配,未做分词或相关度排序;大词汇量的性能未做专门压测(单页上限 100,列表查询使用 `owner_id + language` 前缀的既有索引)。
|
||||
|
||||
@@ -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: 2ba4f8f51db58c9abd8c255145214fde353e37d4
|
||||
synchronized_at: 2026-09-14T14:59:42Z
|
||||
wiki_revision: 3e8c682ff6f129e0783edb8cfbcb63eb2995de0c
|
||||
synchronized_at: 2026-09-14T15:45:41Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -269,3 +269,9 @@ F01 的编辑与删除已于 2026-09-14 通过用户验收:学习端可改书
|
||||
F08 的短语学习与 F10 的短语复习已于 2026-09-14 通过用户验收:阅读器支持连续选择(桌面原生拖选、手机系统手势、面板按词调整端点),保存个人释义、例句与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,同一短语跨章节只存一条记录并在出现处高亮;复习卡片把整段短语挖成一个空。范围规则(整词对齐、内部标点保留、最多 12 词、最左最长重叠)与失效引用回退(高亮消失、学习记录保留)已固化。本次没有数据库结构变化。
|
||||
|
||||
仍未实现并留给后续工单:词汇库搜索与编辑(#12,含短语编辑界面)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑与真机手柄精细手感不在本单范围。
|
||||
|
||||
## #12 交付范围更新(2026-09-11)
|
||||
|
||||
F09 的词汇库已实现,待用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、显示原文或个人释义,大小写不敏感、通配符按字面值)与状态/类型筛选,可编辑释义、例句、状态与 1~7 学习等级,提供「开始到期复习」入口、空态与「没有匹配的词条 + 清除搜索与筛选」。编辑沿用与阅读器相同的领域校验与排期规则:身份不可编辑、只有状态或等级变化才移动复习时间、历史作答记录与计数保留;筛选与页码写入 URL,刷新或返回列表都保留。原型编辑页的「来自某章节」不实现(词条按身份存储、不引用章节)。本次没有数据库结构变化。
|
||||
|
||||
仍未实现并留给后续工单:阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24)。CSV 导入导出(X04)、复杂复习范围筛选(X11)、删除词条或批量清理(X14)不在本轮范围。
|
||||
|
||||
+4
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
|
||||
wiki_revision: 5e9459f1932951cb50fb25c88ce7a0b442ae2087
|
||||
synchronized_at: 2026-09-14T14:59:41Z
|
||||
wiki_revision: fe4d28092eb34171dd2e8d43b28984295b6066ce
|
||||
synchronized_at: 2026-09-14T15:45:40Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 文档入口
|
||||
@@ -80,3 +80,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
|
||||
#10 编辑与删除书籍章节已于 2026-09-14 通过用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化;旧处理结果不会覆盖新版本,PR #29 已 fast-forward-only 合入 main。
|
||||
|
||||
#11 短语选择、保存与复习已于 2026-09-14 通过用户验收:在正文中连续选择一个范围(桌面原生拖选、手机系统手柄、面板端点按钮与 Shift 点击调整),保存个人释义与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,因此同一短语在不同章节只存一条记录并在出现的每处高亮,复习卡片把整段短语挖成一个空。短语与单词重叠时短语高亮覆盖、单词数据不变;编辑正文后短语不再出现时不报错,词条与复习排期保留。本次没有数据库结构变化;用户确认 kind 与词数由身份键派生,PR #30 已 fast-forward-only 合入 main。
|
||||
|
||||
#12 词汇库的搜索、筛选与编辑已实现,待用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、原文或个人释义,大小写不敏感)与状态/类型筛选,并可编辑释义、例句、状态与 1~7 学习等级。编辑沿用与阅读器相同的领域规则:身份不可改、只有状态或等级变化才重排复习时间、历史作答记录保留;筛选与页码写入 URL,刷新或返回列表都保留。原型的「来自某章节」不实现,因为词条按身份存储、不引用章节。本次没有数据库结构变化。
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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('开始到期复习')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
|
||||
import { TERM_STATUSES, type LookupResult, type TermStatus } from '../composables/useReaderLookup'
|
||||
import { ElButton } from 'element-plus'
|
||||
import TermFormFields from './TermFormFields.vue'
|
||||
import { type LookupResult, type TermStatus } from '../composables/useReaderLookup'
|
||||
|
||||
defineProps<{
|
||||
word: string
|
||||
@@ -22,6 +23,9 @@ defineProps<{
|
||||
const definition = defineModel<string>('definition', { required: true })
|
||||
const examples = defineModel<string>('examples', { required: true })
|
||||
const status = defineModel<TermStatus>('status', { required: true })
|
||||
// The reader panel keeps the four statuses only, so the level field exists for the shared form
|
||||
// but is never shown here and never sent: the server keeps the earned level.
|
||||
const level = ref(1)
|
||||
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number]; adjust: [edge: 'start' | 'end', direction: -1 | 1] }>()
|
||||
const heading = ref<HTMLElement | null>(null)
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
@@ -74,24 +78,21 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
|
||||
</template>
|
||||
<ElButton v-if="!loading && (error || result?.status === 'resource_missing' || result?.status === 'not_found')" data-testid="lookup-retry" @click="$emit('retry')">重试查询</ElButton>
|
||||
</div>
|
||||
<div class="lookup-term">
|
||||
<p class="lookup-term-title">学习状态</p>
|
||||
<ElRadioGroup v-model="status" :disabled="saving || prefilling || !!prefillError" aria-label="学习状态">
|
||||
<ElRadio v-for="item in TERM_STATUSES" :key="item.value" :value="item.value">{{ item.label }}</ElRadio>
|
||||
</ElRadioGroup>
|
||||
<p v-if="status === 'learning'" class="subtle">学习中按 1~7 级记录,等级由复习推进。</p>
|
||||
<label for="term-definition">我的释义 <span>{{ savedTermId ? '已保存' : '新词条' }}</span></label>
|
||||
<ElInput id="term-definition" v-model="definition" type="textarea" :rows="3" :disabled="saving || prefilling || !!prefillError" placeholder="记下当前词的释义" />
|
||||
<label for="term-examples">例句 <span>每行一条,最多 5 条</span></label>
|
||||
<ElInput id="term-examples" v-model="examples" type="textarea" :rows="2" :disabled="saving || prefilling || !!prefillError" placeholder="可选,每行一条" />
|
||||
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
|
||||
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
|
||||
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
|
||||
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
|
||||
<div class="lookup-actions">
|
||||
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
|
||||
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
|
||||
</div>
|
||||
<TermFormFields
|
||||
v-model:definition="definition"
|
||||
v-model:examples="examples"
|
||||
v-model:status="status"
|
||||
v-model:level="level"
|
||||
:hint="savedTermId ? '已保存' : '新词条'"
|
||||
:disabled="saving || prefilling || !!prefillError"
|
||||
/>
|
||||
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
|
||||
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
|
||||
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
|
||||
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
|
||||
<div class="lookup-actions">
|
||||
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
|
||||
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -7,6 +7,7 @@ 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: '/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') },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -134,6 +134,22 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
|
||||
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
|
||||
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.review-page { max-width: 680px; }
|
||||
.vocab-page { max-width: 760px; }
|
||||
.vocab-search { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 22px; }
|
||||
.vocab-search label { font-size: 14px; }
|
||||
.vocab-search .el-input { max-width: 260px; }
|
||||
.vocab-filter { width: 140px; }
|
||||
.vocab-list { list-style: none; padding: 0; margin: 18px 0 0; }
|
||||
.vocab-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid #dfe3d6; border-radius: 10px; background: #fffdf8; }
|
||||
.vocab-info { min-width: 0; flex: 1; }
|
||||
.vocab-term { margin: 0; font-family: Georgia, serif; font-size: 18px; overflow-wrap: anywhere; }
|
||||
.vocab-paging { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; }
|
||||
.term-fields label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
|
||||
.term-fields label span { color: #8b794e; font-size: 12px; }
|
||||
.term-fields .el-textarea { margin-top: 8px; }
|
||||
.term-fields .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.term-level { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; font-size: 14px; }
|
||||
.term-level-select { width: 120px; }
|
||||
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
|
||||
.chapter-list .chapter-row { flex-wrap: wrap; }
|
||||
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
|
||||
|
||||
@@ -38,7 +38,7 @@ onMounted(load)
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
|
||||
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
|
||||
<div class="account">
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
|
||||
@@ -119,7 +119,7 @@ onUnmounted(() => library.closeChapter())
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
|
||||
<div class="account">
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
|
||||
@@ -30,7 +30,7 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink></nav>
|
||||
<div class="account">
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElDialog, ElInput, ElOption, ElSelect } from 'element-plus'
|
||||
import TermFormFields from '../components/TermFormFields.vue'
|
||||
import { TERM_STATUSES, type TermStatus } from '../composables/useReaderLookup'
|
||||
import { textProblem, titleProblem } from '../stores/library'
|
||||
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
const session = useSessionStore()
|
||||
const vocabulary = useVocabularyStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const search = ref('')
|
||||
const editing = ref<VocabularyTerm | null>(null)
|
||||
const definition = ref('')
|
||||
const examples = ref('')
|
||||
const status = ref<TermStatus>('new')
|
||||
const level = ref(1)
|
||||
const formError = ref('')
|
||||
const notice = ref('')
|
||||
|
||||
const queryString = computed(() => ({ query: search.value.trim(), status: vocabulary.status, kind: vocabulary.kind, page: vocabulary.page }))
|
||||
const levelLabel = (value: number) => (value === 0 ? '' : ` · 等级 ${value}`)
|
||||
const rowLabel = (item: VocabularyTerm) => `${item.originalForm} / ${item.definition || '(未填写释义)'} · ${statusLabel(item)}`
|
||||
const statusLabel = (item: VocabularyTerm) => {
|
||||
const label = TERM_STATUSES.find(entry => entry.value === item.status)?.label ?? item.status
|
||||
return item.kind === 'phrase' ? `短语 · ${label}${levelLabel(item.level)}` : `${label}${levelLabel(item.level)}`
|
||||
}
|
||||
|
||||
function readFilters(): void {
|
||||
const read = (value: unknown) => (Array.isArray(value) ? value[0] : value)
|
||||
const query = read(route.query.query)
|
||||
const statusValue = read(route.query.status)
|
||||
const kindValue = read(route.query.kind)
|
||||
const page = Number(read(route.query.page) ?? 1)
|
||||
search.value = typeof query === 'string' ? query : ''
|
||||
vocabulary.query = search.value
|
||||
vocabulary.status = TERM_STATUSES.some(entry => entry.value === statusValue) ? (statusValue as TermStatus) : ''
|
||||
vocabulary.kind = kindValue === 'word' || kindValue === 'phrase' ? kindValue : ''
|
||||
vocabulary.page = Number.isSafeInteger(page) && page >= 1 ? page : 1
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await vocabulary.load()
|
||||
}
|
||||
|
||||
async function submitSearch(): Promise<void> {
|
||||
vocabulary.query = search.value
|
||||
await vocabulary.apply()
|
||||
}
|
||||
|
||||
async function clearFilters(): Promise<void> {
|
||||
search.value = ''
|
||||
await vocabulary.clearFilters()
|
||||
}
|
||||
|
||||
function openEditor(item: VocabularyTerm): void {
|
||||
editing.value = item
|
||||
definition.value = item.definition
|
||||
examples.value = item.examples.join('\n')
|
||||
status.value = item.status
|
||||
level.value = item.level > 0 ? item.level : 1
|
||||
formError.value = ''
|
||||
notice.value = ''
|
||||
vocabulary.saveError = ''
|
||||
}
|
||||
|
||||
function closeEditor(): void {
|
||||
editing.value = null
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const item = editing.value
|
||||
if (!item || vocabulary.saving) return
|
||||
formError.value = textProblem(definition.value) && '' // an empty definition is allowed
|
||||
if (formError.value) return
|
||||
try {
|
||||
const examples2 = examples.value.split('\n').map(line => line.trim()).filter(Boolean)
|
||||
const updated = await vocabulary.updateTerm(item.id, {
|
||||
definition: definition.value,
|
||||
examples: examples2,
|
||||
status: status.value,
|
||||
level: status.value === 'learning' ? level.value : undefined,
|
||||
})
|
||||
editing.value = null
|
||||
notice.value = `已保存 · ${statusLabel(updated)}`
|
||||
} catch {
|
||||
// vocabulary.saveError already carries the server message for the dialog.
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await session.logout() }
|
||||
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
|
||||
finally { await router.replace('/login') }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
readFilters()
|
||||
await load()
|
||||
})
|
||||
// The URL mirrors the filters so a reload or a return keeps what the learner searched for.
|
||||
watch(() => [search.value, vocabulary.status, vocabulary.kind, vocabulary.page], () => {
|
||||
void router.replace({ path: '/vocab', query: Object.fromEntries(Object.entries(queryString.value).filter(([, value]) => value !== '' && value !== undefined).map(([key, value]) => [key, String(value)])) })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="session.user">
|
||||
<header class="site-header">
|
||||
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
|
||||
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab" class="active-nav">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
|
||||
<div class="account">
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page vocab-page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>生词本</h1>
|
||||
<p class="subtle">保存的词语与短语,共 {{ vocabulary.total }} 条。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<RouterLink to="/review" class="link-button">开始到期复习</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="vocab-search" novalidate @submit.prevent="submitSearch">
|
||||
<label for="vocab-query">搜索词 / 释义</label>
|
||||
<ElInput id="vocab-query" v-model="search" type="text" placeholder="输入词语或释义中的一部分" :disabled="vocabulary.loading" />
|
||||
<ElButton type="primary" native-type="submit" :loading="vocabulary.loading" data-testid="vocab-search">搜索</ElButton>
|
||||
<label for="vocab-status">状态</label>
|
||||
<ElSelect id="vocab-status" v-model="vocabulary.status" class="vocab-filter" data-testid="vocab-status" @change="vocabulary.apply()">
|
||||
<ElOption label="全部状态" value="" />
|
||||
<ElOption v-for="item in TERM_STATUSES" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
<label for="vocab-kind">类型</label>
|
||||
<ElSelect id="vocab-kind" v-model="vocabulary.kind" class="vocab-filter" data-testid="vocab-kind" @change="vocabulary.apply()">
|
||||
<ElOption label="全部类型" value="" />
|
||||
<ElOption label="词语" value="word" />
|
||||
<ElOption label="短语" value="phrase" />
|
||||
</ElSelect>
|
||||
</form>
|
||||
|
||||
<p v-if="notice" role="status" class="chapter-notice" data-testid="vocab-notice">{{ notice }}</p>
|
||||
<p v-if="vocabulary.loading" role="status" class="loading">正在加载…</p>
|
||||
<div v-else-if="vocabulary.error" class="notice">
|
||||
<p role="alert">{{ vocabulary.error }}</p>
|
||||
<ElButton @click="load">重试</ElButton>
|
||||
</div>
|
||||
<template v-else-if="vocabulary.items.length">
|
||||
<ul class="vocab-list" aria-label="词条列表">
|
||||
<li v-for="item in vocabulary.items" :key="item.id" class="vocab-row">
|
||||
<div class="vocab-info">
|
||||
<p class="vocab-term">{{ item.originalForm }} <span class="subtle">/</span> {{ item.definition || '(未填写释义)' }}</p>
|
||||
<p class="chapter-meta">
|
||||
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item) }}</span>
|
||||
<template v-if="item.examples.length"> · {{ item.examples[0] }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<ElButton size="small" :data-testid="`vocab-edit-${item.id}`" @click="openEditor(item)">编辑</ElButton>
|
||||
</li>
|
||||
</ul>
|
||||
<nav v-if="vocabulary.totalPages > 1" class="vocab-paging" aria-label="分页">
|
||||
<ElButton :disabled="vocabulary.page <= 1" data-testid="vocab-prev" @click="vocabulary.goToPage(vocabulary.page - 1)">上一页</ElButton>
|
||||
<span class="subtle">第 {{ vocabulary.page }} / {{ vocabulary.totalPages }} 页</span>
|
||||
<ElButton :disabled="vocabulary.page >= vocabulary.totalPages" data-testid="vocab-next" @click="vocabulary.goToPage(vocabulary.page + 1)">下一页</ElButton>
|
||||
</nav>
|
||||
</template>
|
||||
<section v-else-if="vocabulary.hasFilters" class="empty-library" aria-label="搜索无结果" data-testid="vocab-none">
|
||||
<h2>没有匹配的词条</h2>
|
||||
<p class="subtle">试试其他关键词,或清除筛选。</p>
|
||||
<ElButton data-testid="vocab-clear" @click="clearFilters">清除搜索与筛选</ElButton>
|
||||
</section>
|
||||
<section v-else class="empty-library" aria-label="空生词本" data-testid="vocab-empty">
|
||||
<h2>生词本还是空的</h2>
|
||||
<p class="subtle">阅读时点词或选择短语,保存后会出现在这里。</p>
|
||||
<RouterLink to="/" class="link-button">返回书库</RouterLink>
|
||||
</section>
|
||||
|
||||
<ElDialog :model-value="editing !== null" title="编辑词条" width="620" data-testid="vocab-dialog" @update:model-value="closeEditor">
|
||||
<template v-if="editing">
|
||||
<p class="subtle">{{ editing.originalForm }}{{ editing.kind === 'phrase' ? ` · 短语 · ${editing.wordCount} 个单词` : ' · 词语' }}</p>
|
||||
<TermFormFields
|
||||
v-model:definition="definition"
|
||||
v-model:examples="examples"
|
||||
v-model:status="status"
|
||||
v-model:level="level"
|
||||
:show-level="true"
|
||||
:disabled="vocabulary.saving"
|
||||
/>
|
||||
<p v-if="formError" role="alert" class="field-error">{{ formError }}</p>
|
||||
<p v-if="vocabulary.saveError" role="alert" class="field-error">{{ vocabulary.saveError }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ElButton data-testid="vocab-cancel" @click="closeEditor">取消</ElButton>
|
||||
<ElButton type="primary" :loading="vocabulary.saving" data-testid="vocab-save" @click="save">保存修改</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -405,4 +405,5 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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