diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 224e2b6..d3ec2f3 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: 0cd603320b62774c0bee5670c58c92880df50f0f -synchronized_at: 2026-09-11T06:46:49Z +wiki_revision: cb6d420632527a436f8b9a7a6a18413a0773f93d +synchronized_at: 2026-09-11T08:31:30Z # 架构与代码地图 @@ -243,3 +243,17 @@ lexgo_books(owner_id, title, language)、lexgo_chapters(book_id, owner_id, ordin 管理端 `Dictionaries.vue` + `dictionaries.mjs` + session multipart 方法,复用 go-admin 导航/表单与身份失效保护。学习端 `useReaderLookup.ts` 校验原文片段/SHA/所有位置,`ReaderTokens.vue` 渲染可聚焦单词,`LookupPanel.vue` 展示释义及临时个人草稿。桌面侧栏,手机固定底部45dvh面板;关闭恢复焦点,仅无后续手动滚动时恢复自动调整前位置。旧响应在换词/换章/退出/离页后失效。 参考:[WordNet 数据格式](https://wordnet.princeton.edu/documentation/wndb5wn)、[词形规则](https://wordnet.princeton.edu/documentation/morphy7wn)。#3 仅历史实验,#6 不调用其实验服务。 + +## #7 个人词条与阅读器状态(2026-09-11) + +schema v5 新增 lexgo_terms:一个学习者对一个词形一条记录。身份键为 `(owner_id, language, term)`,`term` 是 Go 侧 `normalizeWord` 的结果(NFC、小写、弯撇号转直撇号),列使用 `utf8mb4_bin`,避免折叠 `resume`/`résumé`;`original_form` 保存最近一次保存的原词形供显示。`definition`/`examples` 是学习者自己的文本,例句按行存储;共享词典仍只在 `lexgo_dictionaries`,两者不混存。`status` 与 `level` 由数据库检查约束守住:只有 `learning` 允许 1~7,其他状态必须为 0。 + +| 接口 | 权限与输入/输出 | +|---|---| +| POST /api/v1/terms | 本人;`{chapterId,start,end,definition,examples[],status,level?}`;服务端按 #6 同一套 token 范围反推词形,`owner`/`language`/`term` 一律不接受客户端输入;唯一键 upsert,重复保存更新同一行;首次 201、更新 200,返回 `{term,created}` | +| GET /api/v1/terms/:id | 本人;他人编号与不存在编号统一 404,不泄露存在性 | +| GET /api/v1/chapters/:id/tokens | 在原响应上为 word 片段增加可选 `term:{id,status,level}`;服务端按本章词形分批(每批 500)查本人词条,其他章节保存的同形词同样命中 | + +`server/app/lexgo/terms.go` 负责身份、状态/等级边界、文本上限、upsert 与章节点词状态;`database.go` 提供 v5;`dictionary.go` 的 tokens 与 lookup 共用 `wordAtRange`,保存与查询必须落在同一个完整单词范围上,所以客户端无法命名自己没有读到的词。个人词条不写审计日志。 + +学习端 `useReaderLookup.ts` 在原有查询状态上增加个人释义、例句、状态、已保存编号、预填与保存;打开已保存词先读 `GET /terms/:id`,读取失败时禁用保存,避免用空表单覆盖原内容。`LookupPanel.vue` 提供状态单选、释义与例句输入、保存与简短反馈;`ReaderTokens.vue` 按状态高亮 `is-new`/`is-learning`/`is-known`/`is-ignored`。换词、换章、离页、退出或切换账号都会清空表单、状态与高亮。管理端无改动。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 0b4b7c5..fe2738f 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: 935630005c9b1d7103550cf09eb10edfdbf8f3af -synchronized_at: 2026-09-11T03:59:01Z +wiki_revision: 71389f97df86e17699f4af930022251fa354631b +synchronized_at: 2026-09-11T08:31:32Z # 业务规则与术语 @@ -153,3 +153,22 @@ POST /lookup 接收 {surface,lemma?}。查词键单独 casefold/NFC/弯撇号转 exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺序n/v/a/r、最多12条释义,保留原数据s词性。返回lemma表示规则候选,不承诺上下文消歧;歧义不会合并个人学习状态。not_found与resource_missing区分,网络错误可重试,不阻断阅读。#3使用spaCy上下文lemma的实验路径由全Go规则候选替代。 个人释义目前仅当前选择的临时草稿,界面标记未保存;换词、关闭、换章、离页和身份变化清理。持久化与个人词汇状态由#7实现,不把临时输入宣传为保存成功。 + +## #7 个人词条规则(2026-09-11) + +个人词条身份是「学习者+语言+规范化词形」:`normalizeWord` 做 NFC、小写与弯撇号转换,而原文、章节文本与显示用的原词形都不归一化。同一词形不同大小写是同一条记录;不同词形(`dog` 与 `dogs`)是不同记录,不按 WordNet 候选或 lemma 合并,与 #6「不按候选合并个人学习状态」一致。语言取自已登录学习者的英语空间,不从请求读取。 + +| 业务状态 | level | 含义 | 进入到期复习 | 原版 stage | +|---|---|---|---|---| +| new 新词 | 0 | 已保存、尚未开始学习;首次保存默认 | 由 #8 决定 | 2 | +| learning 学习中 | 1~7 | 正在复习,越接近 7 越熟 | 是 | -1~-7 | +| known 已知 | 0 | 已掌握,不再进入到期复习 | 否 | 0 | +| ignored 忽略 | 0 | 明确忽略,不计入已知 | 否 | 1 | + +上表是原版合并编码(状态与等级在同一字段)的显式替代,供 CSV 导出与旧数据迁移映射;#7 只保存与返回等级,#8 负责复习推进与到期时间。非 `learning` 状态携带非 0 等级、`learning` 等级超出 1~7、以及未知状态一律拒绝,`learning` 缺省等级为 1。 + +个人释义可为空(允许只记录状态),最长 2000 字符,可含换行与制表符;例句最多 5 条、每条最长 500 字符,不能为空行或含换行。保存失败保留学习者已输入的内容,成功后显示「已保存 · 状态」并立即更新正文高亮。 + +保存幂等由唯一键承担:重复提交同一词形只更新同一行,不产生第二条冲突记录;同一账号多端同时编辑为最后写入生效,本版不引入版本冲突拒绝。所有读写都属于会话本人:章节必须本人且已就绪,篡改 id、owner、language、term 或携带未知字段返回 400,他人编号与本人不可见编号统一 404。跨账号不共享任何数据与前端缓存,退出或切换账号后不保留前一账号的词条与高亮。 + +例句只保存学习者手输内容,不自动关联原文句子:当前分词只有词/空白/标点边界,没有句子切分规则。原型 v1 中「原文例句已关联到词条」是演示文案,不作为契约。等级选择器属于 #8/#12 的编辑界面,阅读器面板只提供四个状态。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index debf8d3..ec13779 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: bbd6e618f4339aa45d81bba0492045b60581a735 -synchronized_at: 2026-09-11T03:59:02Z +wiki_revision: 919545b0d716180a49ac7dd2fe96bbeccdba0880 +synchronized_at: 2026-09-11T08:31:32Z # 本地开发与验证 @@ -320,3 +320,23 @@ node --test spikes/english/view.test.mjs 主审真实API联调使用真实管理端session模块经5174代理上传;经5173代理两测试账号分别创建虚构章节并验证精确/不规则词形、Unicode原文片段、越权404/普通用户管理403、停用/重复导入启用、错误ZIP保留资源。新增测试书籍id3/4、章节id8/9归issue5_a/issue5_b,没有修改其他账号的书籍。 常驻5173一度返回空白页:Vue模块转换500、代理缺失;同代码隔离服务正常,只重启lexgo-learner加载配置后恢复,默认E2E通过,未改启动配置。桌面交互浏览器工具因旧会话失效未完成手工真实UI联调;已有项目Playwright使用模拟API,真实API验证另列。手机仅窄屏自动测试,真机证据仍未补齐。 + +## #7 验证与迁移(2026-09-11) + +仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue7,不借用其他测试库。 + +| 命令 | 结果 | +|---|---| +| `go vet ./...` | 通过 | +| `LEXGO_TEST_DB_NAME=lexgo_test_issue7 python scripts/server.py test-integration` | 34 个顶层用例全部通过、0 跳过;含 #7 新增 3 个 MySQL 用例、4 个单元用例和 1 个 v4→v5 迁移用例 | +| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 58 项单测、类型检查、构建、3 项默认 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 项与严格检查通过 | + +覆盖内容:非法状态与等级边界、文本上限、词形身份与大小写合并、词形不按 lemma 合并、重复与并发保存只留一条记录、跨章节同形词状态一致、两账号互不影响、篡改 owner/language/term 被拒绝、非本人章节 404、未登录 401、未就绪章节 409、v4→v5 迁移保留既有数据与检查约束。 + +本机开发库 lexgo_dev 已显式从 v4 升级到 v5:升级前后 sys_user 6、lexgo_spaces 6、lexgo_sessions 8、lexgo_books 4、lexgo_chapters 9、lexgo_ingest_jobs 9、lexgo_dictionaries 1 全部不变,新增空的 lexgo_terms。旧二进制备份在忽略的 `.local/lexgo-pre-issue7.exe`。回退:停止 lexgo-api,恢复旧二进制,把 `lexgo_schema` 标记改回 4 后启动;保留 lexgo_terms 与全部既有数据,不删除数据、不重新 bootstrap。 + +真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程),覆盖两个虚构测试账号 issue5_a/issue5_b 的登录、保存、幂等、状态边界、跨章节一致、跨账号隔离与越权拒绝;随后用临时 Playwright 用例在真实学习端+真实 API 上以两个账号复核保存、重新加载后的高亮与预填,以及 390×844 窄屏底部面板。截图保存在本机 `.local/evidence/`(issue7-reader-desktop.png、issue7-reader-mobile-390.png、issue7-account-b.png),临时用例运行后删除。 + +未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器窄屏检查,不当作真机结果。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index 42a6966..e415271 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: 099f1c803ffb69edc3fe07582d22dcf9a57647a1 -synchronized_at: 2026-09-11T03:59:10Z +wiki_revision: b5eaaf1d0664e1e3c564079c857ccb5042540022 +synchronized_at: 2026-09-11T08:31:32Z # 产品需求总览 @@ -239,3 +239,9 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁 ## #6 交付范围更新(2026-09-11) 用户确认全Go后,英语词典配置与阅读器点词查义已实现待验收:共享WordNet3.0英语释义、管理员导入/启停、本人章节点击/键盘查词、加载/无结果/资源不可用/网络失败/关闭状态。词形结果是规则候选,不提供上下文词性消歧。个人释义输入是未保存临时草稿,#7才持久化。沿用已验收v1;手机底部45dvh面板自动测试通过,真机缺口保留。#5已关闭并合入main,#6尚不关闭。 + +## #7 交付范围更新(2026-09-11) + +F07 的个人词语记录已实现,待用户验收:阅读器可以保存与修改个人释义、例句和状态(新词/学习中/已知/忽略),同一词形在本人其他章节显示相同状态与高亮,两个账号的数据互不影响。个人释义与共享 WordNet 词典分开存储,个人释义不进入审计日志。schema 升级为 v5,新增 lexgo_terms。 + +仍未实现并留给后续工单:词汇库搜索与编辑(#12)、短语(#11)、到期复习与等级推进(#8)、阅读完成与进度(#13)、TXT 导入(#9)、书籍章节编辑删除(#10)。词形候选仍不提供上下文消歧,个人学习状态不按候选合并;例句不自动关联原文句子。 diff --git a/docs/README.md b/docs/README.md index 3453a42..5c85196 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: c41ce5131153be66a9b5bd762bb73b52d76dfa38 -synchronized_at: 2026-09-11T06:46:45Z +wiki_revision: 6fd4c754e3bb6ab215edb6077974fbd401f24103 +synchronized_at: 2026-09-11T08:31:29Z # LexGo 文档入口 @@ -69,4 +69,6 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn ## 当前进度(2026-09-11) -#5已验收,#17/#19/#20/#22/#23按依赖顺序合入main。#6已按用户确认的全Go方向实现英语词典与阅读点词,2026-09-11通过用户验收,PR #25已合入main;原文/账户隔离保留。正式NLP不使用Python服务。管理端“英语词典”导入指定WordNet3.0,学习端打开本人章节即可查词。个人释义当前不保存,后续#7实现。详见#6工单、架构和本地开发页面。 +#5已验收,#17/#19/#20/#22/#23按依赖顺序合入main。#6已按用户确认的全Go方向实现英语词典与阅读点词,2026-09-11通过用户验收,PR #25已合入main;原文/账户隔离保留。正式NLP不使用Python服务。管理端“英语词典”导入指定WordNet3.0,学习端打开本人章节即可查词。个人释义与学习状态已实现持久化(#7,待用户验收)。详见#6工单、架构和本地开发页面。 + +#7 已实现个人词条(schema v5 新增 lexgo_terms):阅读器可保存释义、例句与状态,同一词形在本人其他章节显示一致高亮,两个账号数据独立;保存幂等,跨账号与篡改身份均被拒绝。本单待用户验收。 diff --git a/learner/e2e/reading.spec.ts b/learner/e2e/reading.spec.ts index 46e562c..67e9b8b 100644 --- a/learner/e2e/reading.spec.ts +++ b/learner/e2e/reading.spec.ts @@ -9,6 +9,8 @@ test('paste English text, watch a chapter finish processing, then read it verbat const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' } // The worker reports the fresh chapter as processing until the worker settles it. let status: 'processing' | 'ready' = 'processing' + // The personal record the learner saves during this run, served back on reload. + let savedTerm: { id: number; term: string; originalForm: string; definition: string; examples: string[]; status: string; level: number } | null = null const chapterPayload = () => ({ id: 55, bookId: book.id, @@ -59,8 +61,20 @@ test('paste English text, watch a chapter finish processing, then read it verbat offset += text.length return { text, start, end: offset, startUtf16: start, endUtf16: offset, kind: /^[A-Za-z]+$/.test(text) ? 'word' : /^\s+$/.test(text) ? 'space' : 'punctuation' } }) - data = { textSha256: 'fictional-sha256', tokens } - } else if (path === '/api/v1/lookup') { + data = { + textSha256: 'fictional-sha256', + tokens: tokens.map(token => savedTerm && token.text === 'First' + ? { ...token, term: { id: savedTerm.id, status: savedTerm.status, level: savedTerm.level } } + : token), + } + } else if (path === '/api/v1/terms' && method === 'POST') { + const body = route.request().postDataJSON() as { chapterId: number; start: number; end: number; definition: string; examples: string[]; status: string } + expect(body).toEqual({ chapterId: 55, start: 0, end: 5, definition: '虚构的个人释义', examples: ['A fictional example.'], status: 'new' }) + savedTerm = { id: 9, term: 'first', originalForm: 'First', definition: body.definition, examples: body.examples, status: body.status, level: 0 } + statusCode = 201 + data = { term: savedTerm, created: true } + } else if (path === '/api/v1/terms/9') data = { term: savedTerm } + else if (path === '/api/v1/lookup') { expect(route.request().postDataJSON()).toEqual({ chapterId: 55, start: 0, end: 5 }) data = { status: 'exact', query: 'first', matchedForm: 'first', candidates: [], entries: [{ lemma: 'first', pos: 'adjective', definition: 'Coming before all others.', examples: ['The first fictional chapter.'] }] } } @@ -100,15 +114,12 @@ test('paste English text, watch a chapter finish processing, then read it verbat expect(await readerText.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap') await expect(page.getByRole('button', { name: '上一章' })).toBeDisabled() await expect(page.getByRole('button', { name: '下一章' })).toBeDisabled() - const word = page.getByRole('button', { name: '查询 First', exact: true }) + const word = page.locator('.reader-word').first() + await expect(word).toHaveAttribute('aria-label', '查询 First') await word.focus() await word.press('Enter') await expect(page.getByText('Coming before all others.')).toBeVisible() expect(await readerText.evaluate(element => element.textContent)).toBe(pasted) - await page.getByLabel('个人释义 未保存').fill('虚构的本地草稿') - await page.getByRole('button', { name: '关闭释义' }).press('Escape') - await expect(page.locator('.lookup-panel')).toHaveCount(0) - await expect(word).toBeFocused() // Browser narrow viewport check only; this is not real-device acceptance. await page.setViewportSize({ width: 390, height: 844 }) @@ -120,5 +131,27 @@ test('paste English text, watch a chapter finish processing, then read it verbat expect(await page.locator('.lookup-panel').evaluate(element => getComputedStyle(element).position)).toBe('fixed') const wordBounds = await word.boundingBox() expect(wordBounds!.y + wordBounds!.height).toBeLessThanOrEqual(panelBounds!.y) - expect(await page.getByLabel('个人释义 未保存').inputValue()).toBe('') + expect(await page.getByLabel('我的释义 新词条').inputValue()).toBe('') + + // Save a personal record: definition, example and the default status. + await page.setViewportSize({ width: 1280, height: 900 }) + await page.getByLabel('我的释义 新词条').fill('虚构的个人释义') + await page.getByLabel('例句 每行一条,最多 5 条').fill('A fictional example.') + await page.getByRole('button', { name: '保存到生词本' }).click() + await expect(page.getByText('已保存 · 新词')).toBeVisible() + await expect(word).toHaveClass(/is-new/) + expect(await readerText.evaluate(element => element.textContent)).toBe(pasted) + await page.getByRole('button', { name: '关闭释义' }).press('Escape') + await expect(page.locator('.lookup-panel')).toHaveCount(0) + await expect(word).toBeFocused() + + // Returning to the chapter shows the same state and the stored text. + await page.reload() + const reloadedWord = page.locator('.reader-word').first() + await expect(reloadedWord).toHaveAttribute('aria-label', '查询 First,已保存') + await expect(reloadedWord).toHaveClass(/is-new/) + await reloadedWord.click() + await expect(page.getByLabel('我的释义 已保存')).toHaveValue('虚构的个人释义') + await expect(page.getByLabel('例句 每行一条,最多 5 条')).toHaveValue('A fictional example.') + await expect(page.getByRole('radio', { name: '新词' })).toBeChecked() }) diff --git a/learner/src/__tests__/lookup.spec.ts b/learner/src/__tests__/lookup.spec.ts index c4905a9..f81bbd8 100644 --- a/learner/src/__tests__/lookup.spec.ts +++ b/learner/src/__tests__/lookup.spec.ts @@ -1,16 +1,16 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +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 { createMemoryHistory, createRouter } from 'vue-router' import ReaderView from '../views/ReaderView.vue' import { useSessionStore } from '../stores/session' -import { useReaderLookup } from '../composables/useReaderLookup' +import { useReaderLookup, type SavedTerm } from '../composables/useReaderLookup' import { effectScope, ref } from 'vue' import type { ChapterDetail } from '../stores/library' // Fictitious text includes astral, combining, CRLF and repeated whitespace. -const original = '😀 Cats\r\n café!' -const fragments = [['😀', 'punctuation'], [' ', 'space'], ['Cats', 'word'], ['\r\n ', 'space'], ['café', 'word'], ['!', 'punctuation']] +const original = '😀 Cats\r\n café!' +const fragments = [['😀', 'punctuation'], [' ', 'space'], ['Cats', 'word'], ['\r\n ', 'space'], ['café', 'word'], ['!', 'punctuation']] let cp = 0, utf16 = 0 const tokens = fragments.map(([text = '', kind]) => { const token = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length } @@ -20,11 +20,24 @@ const tokens = fragments.map(([text = '', kind]) => { const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', originalText: original, contentSha256: 'same-sha' } const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data })) const result = (query: string, status = 'exact') => ({ status, query, matchedForm: query, candidates: [], entries: status === 'exact' ? [{ lemma: query, pos: 'noun', definition: `Definition of ${query}`, examples: ['A fictional example.'] }] : [] }) +const savedTerm = (overrides: Partial = {}): SavedTerm => ({ + id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2, ...overrides, +}) let wrapper: VueWrapper | undefined -async function open(mockLookup: (body: Record) => Promise = async () => ok(result('cat')), tokenData: unknown = { textSha256: 'same-sha', tokens }) { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, options) => { - if (String(input).endsWith('/tokens')) return ok(tokenData) - if (String(input).endsWith('/lookup')) return mockLookup(JSON.parse(String(options?.body))) +interface OpenOptions { + lookup?: (body: Record) => Promise + tokenData?: unknown + termRead?: () => Promise + termWrite?: (body: Record) => Promise +} +async function open(options: OpenOptions = {}) { + const { lookup = async () => ok(result('cat')), tokenData = { textSha256: 'same-sha', tokens } } = options + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = String(input) + if (url.endsWith('/tokens')) return ok(tokenData) + if (url.endsWith('/lookup')) return lookup(JSON.parse(String(init?.body))) + if (url.includes('/terms/')) return options.termRead ? options.termRead() : ok({ term: savedTerm() }) + if (url.endsWith('/terms')) return options.termWrite ? options.termWrite(JSON.parse(String(init?.body))) : ok({ term: savedTerm(), created: true }) return ok({ chapter, navigation: { previousChapterId: null, nextChapterId: 56 } }) }) useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' } @@ -34,6 +47,12 @@ async function open(mockLookup: (body: Record) => Promise fetchMock.mock.calls.filter(call => String(call[0]).endsWith(suffix)) +// Personal state is attached to the word itself, never to a space or punctuation token. +const withTerm = (text: string, term: { id: number; status: string; level: number }) => ({ + textSha256: 'same-sha', + tokens: tokens.map(token => token.text === text ? { ...token, term } : token), +}) describe('reader word lookup', () => { beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() }) afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() }) @@ -43,38 +62,38 @@ describe('reader word lookup', () => { const words = view.findAll('.reader-word') expect(words).toHaveLength(2) await words[0]!.trigger('click'); await flushPromises() - const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/lookup'))! + const call = callsTo(fetchMock, '/lookup')[0]! expect(call[1]?.method).toBe('POST') expect(JSON.parse(String(call[1]?.body))).toEqual({ chapterId: 55, start: 2, end: 6 }) expect(view.text()).toContain('Definition of cat') expect(view.text()).toContain('A fictional example.') }) - it('discards older word responses and clears the selection draft', async () => { + it('discards older word responses and clears the selection form', async () => { let finish!: (response: Response) => void - const { view } = await open(body => body.start === 2 ? new Promise(resolve => { finish = resolve }) : Promise.resolve(ok(result('cafe', 'not_found')))) + const { view } = await open({ lookup: body => body.start === 2 ? new Promise(resolve => { finish = resolve }) : Promise.resolve(ok(result('cafe', 'not_found'))) }) await view.findAll('.reader-word')[0]!.trigger('click') expect(view.text()).toContain('正在查询') await view.findAll('.reader-word')[1]!.trigger('click'); await flushPromises() - await view.get('textarea').setValue('虚构私人草稿') + await view.get('#term-definition').setValue('虚构私人草稿') finish(ok(result('OLD'))); await flushPromises() expect(view.text()).not.toContain('Definition of OLD') expect(view.text()).toContain('未找到释义') - expect(view.text()).toContain('未保存') + expect(view.text()).toContain('新词条') await view.findAll('.reader-word')[0]!.trigger('click') - expect((view.get('textarea').element as HTMLTextAreaElement).value).toBe('') + expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('') }) - it.each(['resource_missing', 'not_found', 'error'])('keeps reading and permits a local draft and retry for %s', async status => { - const { view, fetchMock } = await open(async () => { if (status === 'error') throw new Error('暂时无法查询'); return ok(result('cat', status)) }) + it.each(['resource_missing', 'not_found', 'error'])('keeps reading and permits a saved word and retry for %s', async status => { + const { view, fetchMock } = await open({ lookup: async () => { if (status === 'error') throw new Error('暂时无法查询'); return ok(result('cat', status)) } }) await view.get('.reader-word').trigger('click'); await flushPromises() - await view.get('textarea').setValue('仅当前词的虚构草稿') + await view.get('#term-definition').setValue('仅当前词的虚构释义') expect(view.get('.reader-text').element.textContent).toBe(original) - expect(view.text()).toContain('未保存') - expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/lookup'))).toHaveLength(1) + expect(view.get('[data-testid="term-save"]').attributes('disabled')).toBeUndefined() + expect(callsTo(fetchMock, '/lookup')).toHaveLength(1) await view.get('[data-testid="lookup-retry"]').trigger('click'); await flushPromises() - expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/lookup'))).toHaveLength(2) + expect(callsTo(fetchMock, '/lookup')).toHaveLength(2) }) it.each([{ textSha256: 'wrong', tokens }, { textSha256: 'same-sha', tokens: tokens.slice(1) }])('falls back to original when tokens do not match', async data => { - const { view } = await open(undefined, data) + const { view } = await open({ tokenData: data }) expect(view.get('.reader-text').element.textContent).toBe(original) expect(view.find('.reader-word').exists()).toBe(false) expect(view.find('[data-testid="tokens-retry"]').exists()).toBe(true) @@ -105,7 +124,7 @@ describe('reader word lookup', () => { expect(view.findAll('.reader-word')).toHaveLength(2) }) it('does not accept a late token response after identity changes', async () => { - const { view, fetchMock } = await open(undefined, { textSha256: 'wrong', tokens }) + const { view, fetchMock } = await open({ tokenData: { textSha256: 'wrong', tokens } }) let finish!: (response: Response) => void fetchMock.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) await view.get('[data-testid="tokens-retry"]').trigger('click') @@ -130,7 +149,7 @@ describe('reader word lookup', () => { expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'instant' }) scope.stop() }) - it('preserves a later manual reading scroll when closing the sheet', () => { + it('preserves a later manual reading scroll when closing the sheet', async () => { useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' } vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(result('cat'))) let scrollY = 100 @@ -154,7 +173,7 @@ describe('reader word lookup', () => { }) it.each(['logout', 'chapter', 'unmount'])('invalidates pending lookup on %s', async action => { let finish!: (response: Response) => void - const { view, router } = await open(() => new Promise(resolve => { finish = resolve })) + const { view, router } = await open({ lookup: () => new Promise(resolve => { finish = resolve }) }) await view.get('.reader-word').trigger('click') if (action === 'logout') await useSessionStore().logout() else if (action === 'chapter') await router.push('/chapters/56') @@ -165,3 +184,69 @@ describe('reader word lookup', () => { expect(view.text()).not.toContain('Definition of OLD') }) }) +describe('personal word records', () => { + beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() }) + afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() }) + it('saves the definition, examples and status, then marks the word in the text', async () => { + let body: Record | undefined + const { view, fetchMock } = await open({ termWrite: async value => { body = value; return ok({ term: savedTerm({ status: 'learning', level: 2 }), created: true }) } }) + await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises() + await view.get('#term-definition').setValue(' 猫 ') + await view.get('#term-examples').setValue('A fictional example.\n\n Second line ') + await view.findAll('input[type="radio"]')[1]!.setValue() + await view.get('[data-testid="term-save"]').trigger('click'); await flushPromises() + expect(body).toEqual({ chapterId: 55, start: 2, end: 6, definition: ' 猫 ', examples: ['A fictional example.', 'Second line'], status: 'learning' }) + expect(view.text()).toContain('已保存 · 学习中') + expect(view.findAll('.reader-word')[0]!.classes()).toContain('is-learning') + expect(callsTo(fetchMock, '/terms')).toHaveLength(1) + }) + it('keeps the typed input and reports the failure when saving fails', async () => { + const { view } = await open({ termWrite: async () => { throw new Error('保存暂时不可用') } }) + await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises() + await view.get('#term-definition').setValue('虚构释义') + await view.get('[data-testid="term-save"]').trigger('click'); await flushPromises() + expect(view.text()).toContain('保存暂时不可用') + expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('虚构释义') + expect(view.find('.lookup-panel').exists()).toBe(true) + expect(view.findAll('.reader-word')[0]!.classes()).not.toContain('is-new') + }) + it('loads the stored record when a saved word is opened and marks it in the text', async () => { + const tokenData = withTerm('Cats', { id: 7, status: 'known', level: 0 }) + const { view, fetchMock } = await open({ tokenData, termRead: async () => ok({ term: savedTerm({ status: 'known', level: 0 }) }) }) + expect(view.findAll('.reader-word')[0]!.classes()).toContain('is-known') + expect(view.findAll('.reader-word')[0]!.attributes('aria-label')).toContain('已保存') + await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises() + expect(callsTo(fetchMock, '/terms/7')).toHaveLength(1) + expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('猫') + expect((view.get('#term-examples').element as HTMLTextAreaElement).value).toBe('A fictional example.') + expect((view.findAll('input[type="radio"]')[2]!.element as HTMLInputElement).checked).toBe(true) + expect(view.text()).toContain('已保存') + }) + it('blocks saving until a failed read of the stored record is retried', async () => { + const tokenData = withTerm('Cats', { id: 7, status: 'learning', level: 1 }) + let attempts = 0 + const { view, fetchMock } = await open({ tokenData, termRead: async () => { attempts++; if (attempts === 1) throw new Error('已保存的内容暂时无法读取'); return ok({ term: savedTerm() }) } }) + await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises() + expect(view.text()).toContain('已保存的内容暂时无法读取') + expect(view.get('[data-testid="term-save"]').attributes('disabled')).toBeDefined() + await view.get('.reader-word').trigger('click'); await flushPromises() + expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('猫') + expect(view.get('[data-testid="term-save"]').attributes('disabled')).toBeUndefined() + expect(callsTo(fetchMock, '/terms')).toHaveLength(0) + }) + it('drops the previous account words and highlights after an identity change', async () => { + const { view, fetchMock } = await open({ termWrite: async () => ok({ term: savedTerm({ status: 'new', level: 0 }), created: true }) }) + await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises() + await view.get('[data-testid="term-save"]').trigger('click'); await flushPromises() + expect(view.findAll('.reader-word')[0]!.classes()).toContain('is-new') + fetchMock.mockResolvedValueOnce(ok({ textSha256: 'same-sha', tokens })) + useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' } + await flushPromises() + expect(view.find('.lookup-panel').exists()).toBe(false) + // The previous account's words and chapter are gone: the reader falls back to + // nothing until the new identity loads its own chapter. + expect(view.find('.reader-word').exists()).toBe(false) + expect(view.find('.reader-text').exists()).toBe(false) + expect(view.text()).not.toContain('已保存') + }) +}) diff --git a/learner/src/components/LookupPanel.vue b/learner/src/components/LookupPanel.vue index 3371ab2..e6a4f10 100644 --- a/learner/src/components/LookupPanel.vue +++ b/learner/src/components/LookupPanel.vue @@ -1,10 +1,25 @@ diff --git a/learner/src/composables/useReaderLookup.ts b/learner/src/composables/useReaderLookup.ts index 12bbe0a..91f5fe3 100644 --- a/learner/src/composables/useReaderLookup.ts +++ b/learner/src/composables/useReaderLookup.ts @@ -1,8 +1,19 @@ -import { onScopeDispose, ref, watch, type Ref } from 'vue' +import { computed, onScopeDispose, ref, watch, type Ref } from 'vue' import type { ChapterDetail } from '../stores/library' import { useSessionStore } from '../stores/session' -export interface ReaderToken { text: string; start: number; end: number; startUtf16: number; endUtf16: number; kind: 'word' | 'space' | 'punctuation' } +export type TermStatus = 'new' | 'learning' | 'known' | 'ignored' + +export interface TokenTerm { id: number; status: TermStatus; level: number } +export interface ReaderToken { + text: string + start: number + end: number + startUtf16: number + endUtf16: number + kind: 'word' | 'space' | 'punctuation' + term?: TokenTerm | null +} export interface LookupResult { status: 'exact' | 'lemma' | 'not_found' | 'resource_missing' query: string @@ -11,8 +22,38 @@ export interface LookupResult { entries: { lemma: string; pos: string; definition: string; examples: string[] }[] resource?: { name: string; version: string } } +export interface SavedTerm { + id: number + term: string + originalForm: string + definition: string + examples: string[] + status: TermStatus + level: number +} +interface TermResponse { term: SavedTerm } interface TokenResponse { textSha256: string; tokens: ReaderToken[] } +// The four learner-visible states and the level rule behind them. Only a learning +// entry carries a level, so every other status reports level 0. +export const TERM_STATUSES: { value: TermStatus; label: string }[] = [ + { value: 'new', label: '新词' }, + { value: 'learning', label: '学习中' }, + { value: 'known', label: '已知' }, + { value: 'ignored', label: '忽略' }, +] + +export function termStatusLabel(status: TermStatus): string { + return TERM_STATUSES.find(item => item.value === status)?.label ?? status +} + +/** Unknown or malformed personal state never becomes a highlight class. */ +export function termStatusOf(token: ReaderToken): TermStatus | null { + const term = token.term + if (!term || typeof term.id !== 'number' || term.id <= 0) return null + return TERM_STATUSES.some(item => item.value === term.status) ? term.status : null +} + // Validate every coordinate before enabling selection; original text is always the fallback. function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean { if (!data || data.textSha256 !== chapter.contentSha256 || !Array.isArray(data.tokens)) return false @@ -37,12 +78,38 @@ export function useReaderLookup(chapter: Ref) { const result = ref(null) const loading = ref(false) const error = ref('') - const draft = ref('') - let tokenSequence = 0, lookupSequence = 0 + + // The learner's own record for the selected word. + const definition = ref('') + const examples = ref('') + const status = ref('new') + const savedTermId = ref(null) + const prefilling = ref(false) + const prefillError = ref('') + const saving = ref(false) + const saveError = ref('') + const saved = ref('') + + let tokenSequence = 0, lookupSequence = 0, termSequence = 0, saveSequence = 0 let origin: HTMLElement | null = null let scrollBeforeAdjustment: { top: number; left: number } | null = null let lastAdjustment: { top: number; left: number } | null = null + // Sending is blocked until the stored text is on screen, so a slow or failed read + // can never let an empty form overwrite what the learner already wrote. + const canSave = computed(() => selected.value !== null && !saving.value && !prefilling.value && !prefillError.value) + + function exampleLines(): string[] { + return examples.value.split('\n').map(line => line.trim()).filter(Boolean) + } + + function applyTerm(next: TokenTerm) { + savedTermId.value = next.id + const index = tokens.value.findIndex(token => token.start === selected.value?.start) + const target = index >= 0 ? tokens.value[index] : undefined + if (target) tokens.value[index] = { ...target, term: next } + } + function stillAtAdjustment() { return lastAdjustment !== null && Math.abs(window.scrollY - lastAdjustment.top) <= 2 && Math.abs(window.scrollX - lastAdjustment.left) <= 2 @@ -61,11 +128,21 @@ export function useReaderLookup(chapter: Ref) { function close(restoreFocus = true) { lookupSequence++ + termSequence++ + saveSequence++ selected.value = null result.value = null loading.value = false error.value = '' - draft.value = '' + definition.value = '' + examples.value = '' + status.value = 'new' + savedTermId.value = null + prefilling.value = false + prefillError.value = '' + saving.value = false + saveError.value = '' + saved.value = '' if (restoreFocus && origin?.isConnected) origin.focus({ preventScroll: true }) if (restoreFocus && scrollBeforeAdjustment && stillAtAdjustment()) window.scrollTo({ ...scrollBeforeAdjustment, behavior: 'instant' }) scrollBeforeAdjustment = null @@ -111,18 +188,70 @@ export function useReaderLookup(chapter: Ref) { if (seq === lookupSequence) error.value = reason instanceof Error ? reason.message : '暂时无法查询,请重试。' } finally { if (seq === lookupSequence) loading.value = false } } + // Opening a word the learner already saved loads that record, so the panel shows + // the stored text and the same id other chapters show. + async function loadTerm(id: number) { + const seq = ++termSequence + prefilling.value = true + prefillError.value = '' + try { + const data = await session.request(`terms/${id}`) + if (seq !== termSequence) return + definition.value = data.term.definition + examples.value = data.term.examples.join('\n') + status.value = data.term.status + applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level }) + } catch (reason) { + if (seq !== termSequence) return + prefillError.value = reason instanceof Error ? reason.message : '已保存的内容暂时无法读取。' + } finally { if (seq === termSequence) prefilling.value = false } + } + async function save() { + const current = chapter.value + const token = selected.value + if (!session.user || !current || !token || !canSave.value) return + const seq = ++saveSequence + saving.value = true + saveError.value = '' + saved.value = '' + try { + const data = await session.request('terms', 'POST', { + chapterId: current.id, start: token.start, end: token.end, + definition: definition.value, examples: exampleLines(), status: status.value, + }) + if (seq !== saveSequence) return + definition.value = data.term.definition + examples.value = data.term.examples.join('\n') + status.value = data.term.status + applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level }) + saved.value = `已保存 · ${termStatusLabel(data.term.status)}` + } catch (reason) { + if (seq !== saveSequence) return + saveError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。' + } finally { if (seq === saveSequence) saving.value = false } + } function select(token: ReaderToken, element: HTMLElement) { close(false) origin = element selected.value = token + const existing = termStatusOf(token) + if (existing !== null && token.term) { + status.value = existing + void loadTerm(token.term.id) + } void lookup() } watch(() => [chapter.value?.id, chapter.value?.status, chapter.value?.contentSha256, chapter.value?.originalText], () => { reset() void loadTokens() }, { immediate: true, flush: 'sync' }) - // Watch the identity object, including clear → login for the same account. + // Watch the identity object, including clear → login for the same account: leaving + // or switching an account drops every word saved by the previous one. watch(() => session.user, reset, { flush: 'sync' }) onScopeDispose(reset) - return { tokens, tokensError, tokensLoading, selected, result, loading, error, draft, loadTokens, lookup, select, close, reset, keepSelectionVisible } + return { + tokens, tokensError, tokensLoading, selected, result, loading, error, + definition, examples, status, savedTermId, prefilling, prefillError, saving, saveError, saved, canSave, + loadTokens, lookup, loadTerm, save, select, close, reset, keepSelectionVisible, + } } diff --git a/learner/src/style.css b/learner/src/style.css index d43363c..9341d3b 100644 --- a/learner/src/style.css +++ b/learner/src/style.css @@ -100,6 +100,11 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; } .has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr) 320px; } .reader-body { min-width: 0; } .reader-word { cursor: pointer; border-radius: 3px; } +/* Saved words keep one style per status everywhere they appear. */ +.reader-word.is-new { border-bottom: 2px dotted #bc803d; } +.reader-word.is-learning { background: #f6e7c9; } +.reader-word.is-known { background: #dee9d6; } +.reader-word.is-ignored { color: #949c94; } .reader-word:hover, .reader-word.is-selected { background: #e3e9d9; color: #264a35; } .reader-word:focus-visible { outline: 2px solid #bc803d; outline-offset: 2px; background: #eef2eb; } .tokens-notice { color: #748073; font-size: 14px; margin-top: 24px; } @@ -113,11 +118,15 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; } .lookup-senses p { margin: 8px 0; } .sense-heading span { color: #748073; font-size: 13px; } .lookup-senses blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; } -.lookup-draft { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; } -.lookup-draft label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; } -.lookup-draft label span { color: #8b794e; font-size: 12px; } -.lookup-draft textarea { width: 100%; resize: vertical; padding: 12px; margin-top: 10px; font: inherit; line-height: 1.6; color: #233d31; border: 1px solid #d6dccf; border-radius: 8px; background: #fffefa; } -.lookup-draft textarea:focus-visible { outline: 2px solid #bc803d; outline-offset: 2px; } +.lookup-term { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; } +.lookup-term label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; } +.lookup-term-title { display: flex; font-size: 14px; margin: 0; } +.lookup-term label span { color: #8b794e; font-size: 12px; } +.lookup-term .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; } +.lookup-term .el-textarea { margin-top: 8px; } +.lookup-term .el-textarea textarea { font: inherit; line-height: 1.6; } +.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; } +.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; } @media (max-width: 760px) { .login-page { grid-template-columns: 1fr; } .welcome { padding: 28px; } diff --git a/learner/src/views/ReaderView.vue b/learner/src/views/ReaderView.vue index 4c60d51..6c53f43 100644 --- a/learner/src/views/ReaderView.vue +++ b/learner/src/views/ReaderView.vue @@ -93,7 +93,7 @@ onUnmounted(() => library.closeChapter()) 重试加载单词 - +