diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index c5378f4..c6aceae 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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 # 架构与代码地图 @@ -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`,与阅读器面板共用;等级选择器只在词汇库编辑对话框中出现(阅读器面板仍只有四个状态)。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 088614e..29aea26 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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 # 业务规则与术语 @@ -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),也不做批量操作。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 3ae550f..36b217a 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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 # 本地开发与验证 @@ -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` 前缀的既有索引)。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index 75d693f..a1ebecb 100644 --- a/docs/09-product-requirements-overview.md +++ b/docs/09-product-requirements-overview.md @@ -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 # 产品需求总览 @@ -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)不在本轮范围。 diff --git a/docs/README.md b/docs/README.md index aaa639f..a94b0a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 # 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,刷新或返回列表都保留。原型的「来自某章节」不实现,因为词条按身份存储、不引用章节。本次没有数据库结构变化。 diff --git a/learner/e2e/vocabulary.spec.ts b/learner/e2e/vocabulary.spec.ts new file mode 100644 index 0000000..657c9ba --- /dev/null +++ b/learner/e2e/vocabulary.spec.ts @@ -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 + 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) +}) diff --git a/learner/src/__tests__/vocabulary.spec.ts b/learner/src/__tests__/vocabulary.spec.ts new file mode 100644 index 0000000..9832436 --- /dev/null +++ b/learner/src/__tests__/vocabulary.spec.ts @@ -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 => ({ + 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 => ({ + 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) => 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 + 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 { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
LibraryStub
' } }, { path: '/vocab', component: VocabView }, { path: '/review', component: { template: '
ReviewStub
' } }], + }) + 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('开始到期复习') + }) +}) diff --git a/learner/src/components/LookupPanel.vue b/learner/src/components/LookupPanel.vue index 0c7a06a..6fdac3d 100644 --- a/learner/src/components/LookupPanel.vue +++ b/learner/src/components/LookupPanel.vue @@ -1,7 +1,8 @@ + + diff --git a/learner/src/router/index.ts b/learner/src/router/index.ts index 96f467a..0a39f5f 100644 --- a/learner/src/router/index.ts +++ b/learner/src/router/index.ts @@ -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') }, diff --git a/learner/src/stores/vocabulary.ts b/learner/src/stores/vocabulary.ts new file mode 100644 index 0000000..63ecbab --- /dev/null +++ b/learner/src/stores/vocabulary.ts @@ -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([]) + const total = ref(0) + const page = ref(1) + const query = ref('') + const status = ref('') + 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 { + 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 { + page.value = 1 + await load() + } + + async function goToPage(next: number): Promise { + const target = Math.min(Math.max(1, next), totalPages.value) + if (target === page.value) return + page.value = target + await load() + } + + async function clearFilters(): Promise { + 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 { + const version = generation + const owner = ownerId() + saving.value = true + saveError.value = '' + const body: Record = { 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, + } +}) diff --git a/learner/src/style.css b/learner/src/style.css index aff7006..7ed23b6 100644 --- a/learner/src/style.css +++ b/learner/src/style.css @@ -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; } diff --git a/learner/src/views/LibraryView.vue b/learner/src/views/LibraryView.vue index a0d90f7..3075510 100644 --- a/learner/src/views/LibraryView.vue +++ b/learner/src/views/LibraryView.vue @@ -38,7 +38,7 @@ onMounted(load)