diff --git a/AGENTS.md b/AGENTS.md index c8f9fc1..33a8a8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -280,3 +280,4 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 - #18 登录日志与操作审计已通过用户验收:schema v2 显式迁移;日志只保存白名单字段,禁止保存凭据、请求/响应正文及私人学习内容。仅管理员查询,默认保留 90 天;启动/每小时及 `python scripts/server.py audit-cleanup` 仅清理两张审计表的过期记录。 - #3 独立小样位于 `spikes/english/`,使用 `.local/nlp-venv/Scripts/python.exe`(3.12.12)运行;固定 spaCy 3.8.7、英语模型 3.8.0、NLTK 3.9.2、WordNet 3.0。资源仅显式准备时下载,摘要见 resources.json。不得把本机无账号的实验接口用于正式学习端;后续集成仍需 Go 授权、数据归属和任务设计。原文不归一化,位置区分 cp/UTF-8/UTF-16,lemma 不自动合并学习状态。 - #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。 +- 2026-09-11 用户确认正式 NLP/词典采用全 Go。#5 已验收并合入 main;#6 使用 Go WordNet 解析和词形候选、Go Unicode 原文分片、schema v4 共享词典资源表,不调用 Python NLP。WordNet 3.0 ZIP 来源与摘要见 `server/wordnet-resource.json`,许可保留在 `server/WORDNET-LICENSE.txt`。词形候选不等于上下文消歧,不自动合并个人学习状态;#3 Python 小样只保留历史验证。当前词典仅英语释义,个人释义输入为临时草稿,持久化归 #7。 diff --git a/README.md b/README.md index 301066d..db8bc0a 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ 面向自托管场景的阅读式语言学习项目,规划提供内容导入、阅读查词、词汇与短语、复习和实例管理。 -已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已实现,待用户验收;点词查词、词典、词语状态与复习仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。 +已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已实现,待用户验收。词语状态与复习仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。 - [文档入口](docs/README.md) · [线上 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Home) - [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口) - [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留) - [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md) -- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#18 已验收,剩余 #5~#15 与新增 #21 规划参考 44~71 人日;后续结合集成结果重估,旧全量研究仅供参考。 +- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#18 已验收,原规划中的 #5 已完成;剩余 #6~#15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。 - [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。 - [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。 - [工作流](docs/01-workflow.md) · [开发与验证](docs/04-local-development-and-verification.md) @@ -19,7 +19,15 @@ 本地入口:学习端 http://127.0.0.1:5173,管理端 http://127.0.0.1:5174。完整安装与测试命令见[开发与验证](docs/04-local-development-and-verification.md)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。 -本机三个服务已由 `D:/supervisord/supervisord.conf` 中的 lexgo-learner、lexgo-admin、lexgo-api 托管,使用时不要重复手动启动同端口。当前 schema v3;从 #2/#18 升级时停止 API,执行 build、migrate 后再启动。审计过期清理可执行 `python scripts/server.py audit-cleanup`,只影响超过 90 天的日志。 +本机三个服务已由 `D:/supervisord/supervisord.conf` 中的 lexgo-learner、lexgo-admin、lexgo-api 托管,使用时不要重复手动启动同端口。当前 schema v4;从 #2/#18 升级时停止 API,执行 build、migrate 后再启动。审计过期清理可执行 `python scripts/server.py audit-cleanup`,只影响超过 90 天的日志。 + +## 英语词典与点词查义(#6) + +正式后端为纯 Go,`lexgo.exe serve` 不启动 Python NLP。开发辅助脚本与历史小样保留。 + +管理员在“英语词典”页下载并导入指定 WordNet 3.0 ZIP;来源、固定摘要及许可见 [资源清单](server/wordnet-resource.json)。词典提供英语释义,保存在 MySQL 中;导入失败保留当前资源,重复导入不新增资源并重新启用。也可在管理页停用。 + +学习者打开本人就绪章节,点击词语或聚焦后按 Enter/空格查询;Escape/关闭返回阅读。精确词形优先,未命中再查规则候选,例如 `went → go`。个人释义当前是未保存的临时草稿,保存功能在 #7 实现。真机测试证据尚未补齐。 ## 文档与治理 diff --git a/admin/src/dictionaries.mjs b/admin/src/dictionaries.mjs new file mode 100644 index 0000000..091b0af --- /dev/null +++ b/admin/src/dictionaries.mjs @@ -0,0 +1,47 @@ +export const MAX_DICTIONARY_BYTES = 32 * 1024 * 1024 + +export function createDictionaryLoader(session, state) { + let revision = 0 + function invalidate() { + revision++ + Object.assign(state, { items: [], supported: null, loading: false, saving: false, error: '', notice: '' }) + } + async function perform(operation, saving, apply) { + const current = ++revision + const generation = session.state.generation + const stale = () => current !== revision || generation !== session.state.generation + Object.assign(state, { loading: !saving, saving, error: '', notice: '' }) + try { + const result = await operation() + if (stale()) return false + apply(result) + return true + } catch (error) { + if (!stale()) state.error = error instanceof Error ? error.message : '词典操作失败,请重试' + return false + } finally { + if (!stale()) { state.loading = false; state.saving = false } + } + } + return { + invalidate, + load() { + return perform(() => session.listDictionaries(), false, result => { + state.items = result.items + state.supported = result.supported + }) + }, + import(form) { + return perform(() => session.importDictionary(form), true, result => { + state.items = [result.resource] + state.notice = result.duplicate ? '该词典已存在,已启用,未重复导入。' : '词典导入成功。' + }) + }, + toggle(id, enabled) { + return perform(() => session.setDictionaryEnabled(id, enabled), true, result => { + state.items = state.items.map(item => item.id === result.resource.id ? result.resource : item) + state.notice = result.resource.enabled ? '词典已启用。' : '词典已停用。' + }) + } + } +} diff --git a/admin/src/layout/index.vue b/admin/src/layout/index.vue index a1600e6..fc39ff1 100644 --- a/admin/src/layout/index.vue +++ b/admin/src/layout/index.vue @@ -5,6 +5,7 @@
{{ sidebar.opened ? 'LexGo 管理' : 'LG' }}
账号管理 + 英语词典 登录日志 操作日志 diff --git a/admin/src/router/index.js b/admin/src/router/index.js index 4fec950..f605236 100644 --- a/admin/src/router/index.js +++ b/admin/src/router/index.js @@ -4,11 +4,13 @@ import Layout from '../layout/index.vue' import Login from '../views/Login.vue' import Accounts from '../views/Accounts.vue' import AuditLogs from '../views/AuditLogs.vue' +import Dictionaries from '../views/Dictionaries.vue' const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/login', component: Login }, { path: '/', component: Layout, children: [ { path: '', redirect: '/accounts' }, { path: 'accounts', component: Accounts, meta: { title: '账号管理' } }, + { path: 'dictionaries', component: Dictionaries, meta: { title: '英语词典' } }, { path: 'login-logs', component: AuditLogs, props: { kind: 'login' }, meta: { title: '登录日志' } }, { path: 'operation-logs', component: AuditLogs, props: { kind: 'operation' }, meta: { title: '操作日志' } } ] }, diff --git a/admin/src/session.mjs b/admin/src/session.mjs index e560654..078c493 100644 --- a/admin/src/session.mjs +++ b/admin/src/session.mjs @@ -20,10 +20,11 @@ export function createSession({ fetch, storage, changed = () => {} }) { if (generation !== state.generation) throw new Error('会话已变化,请重新操作') } async function request(path, method = 'GET', body, token = state.token, generation = state.generation) { + const multipart = typeof FormData !== 'undefined' && body instanceof FormData const result = await fetch('/api/v1' + path, { method, - headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: { ...(!multipart ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: 'Bearer ' + token } : {}) }, + ...(body === undefined ? {} : { body: multipart ? body : JSON.stringify(body) }), cache: 'no-store' }) const payload = await result.json() @@ -84,6 +85,20 @@ export function createSession({ fetch, storage, changed = () => {} }) { } catch (error) { if (generation === state.generation) clear(); throw error } }, async logout() { const token = state.token; clear(); await revoke(token) }, + async listDictionaries() { + authorized() + return request('/dictionaries') + }, + async importDictionary(form) { + authorized() + if (!(form instanceof FormData)) throw new Error('请选择词典文件') + return request('/dictionaries/import', 'POST', form) + }, + async setDictionaryEnabled(id, enabled) { + authorized() + if (!Number.isSafeInteger(id) || id <= 0 || typeof enabled !== 'boolean') throw new Error('词典参数无效') + return request('/dictionaries/' + id, 'PATCH', { enabled }) + }, async queryAuditLogs(kind, filters) { authorized() const generation = state.generation diff --git a/admin/src/views/Dictionaries.vue b/admin/src/views/Dictionaries.vue new file mode 100644 index 0000000..d7d386c --- /dev/null +++ b/admin/src/views/Dictionaries.vue @@ -0,0 +1,101 @@ + + + diff --git a/admin/tests/dictionaries.test.mjs b/admin/tests/dictionaries.test.mjs new file mode 100644 index 0000000..a5dfd95 --- /dev/null +++ b/admin/tests/dictionaries.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createSession } from '../src/session.mjs' + +const response = (data, status = 200) => ({ ok: status < 400, status, json: async () => ({ data, msg: '导入失败' }) }) +function setup() { + const pending = [] + const session = createSession({ fetch: (url, options) => new Promise(resolve => pending.push({ url, options, resolve })), storage: { getItem() {}, setItem() {}, removeItem() {} } }) + session.state.user = { id: 1, username: 'fixture.admin', role: 'admin' } + session.state.token = 'fictional-test-token' + return { session, pending } +} + +test('dictionary upload sends multipart with authorization and lets browser set boundary', async () => { + const { session, pending } = setup() + const form = new FormData() + form.append('name', 'Princeton WordNet') + form.append('file', new Blob(['fictional-archive']), 'wordnet.zip') + assert.equal(typeof session.importDictionary, 'function') + const request = session.importDictionary(form) + assert.equal(pending[0].url, '/api/v1/dictionaries/import') + assert.equal(pending[0].options.method, 'POST') + assert.equal(pending[0].options.body, form) + assert.equal(pending[0].options.headers['Content-Type'], undefined) + assert.equal(pending[0].options.headers.Authorization, 'Bearer fictional-test-token') + pending[0].resolve(response({ resource: { id: 1 }, duplicate: false })) + assert.deepEqual(await request, { resource: { id: 1 }, duplicate: false }) +}) + +test('dictionary read and toggle follow API contract, ordinary accounts cannot mutate', async () => { + const { session, pending } = setup() + assert.equal(typeof session.listDictionaries, 'function') + const read = session.listDictionaries() + assert.equal(pending[0].url, '/api/v1/dictionaries') + pending[0].resolve(response({ items: [] })) + await read + const toggle = session.setDictionaryEnabled(1, false) + assert.equal(pending[1].url, '/api/v1/dictionaries/1') + assert.deepEqual(JSON.parse(pending[1].options.body), { enabled: false }) + pending[1].resolve(response({ resource: { id: 1, enabled: false } })) + await toggle + session.state.user.role = 'learner' + await assert.rejects(session.importDictionary(new FormData()), /管理员/) + await assert.rejects(session.setDictionaryEnabled(1, true), /管理员/) + assert.equal(pending.length, 2) +}) + +test('dictionary response cannot cross a logout or account switch', async () => { + const { session, pending } = setup() + assert.equal(typeof session.importDictionary, 'function') + const request = session.importDictionary(new FormData()) + session.clear() + pending[0].resolve(response({ resource: { id: 1 }, duplicate: false })) + await assert.rejects(request, /会话已变化/) +}) diff --git a/admin/tests/dictionary-loader.test.mjs b/admin/tests/dictionary-loader.test.mjs new file mode 100644 index 0000000..66ff7dd --- /dev/null +++ b/admin/tests/dictionary-loader.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +async function fixture() { + const { createDictionaryLoader } = await import('../src/dictionaries.mjs') + const pending = [] + const later = () => new Promise((resolve, reject) => pending.push({ resolve, reject })) + const session = { state: { generation: 1 }, listDictionaries: later, importDictionary: later, setDictionaryEnabled: later } + const state = { items: [{ id: 1, name: 'Existing', enabled: true }], supported: null, loading: false, saving: false, error: '', notice: '' } + return { loader: createDictionaryLoader(session, state), state, session, pending } +} + +test('failed upload preserves the existing resource and exposes the error', async () => { + const { loader, state, pending } = await fixture() + const action = loader.import(new FormData()) + assert.equal(state.saving, true) + pending[0].reject(new Error('文件摘要不匹配')) + assert.equal(await action, false) + assert.equal(state.items[0].name, 'Existing') + assert.equal(state.error, '文件摘要不匹配') + assert.equal(state.saving, false) +}) + +test('accepted mutation replaces old state directly, without a second refresh dependency', async () => { + const { loader, state, pending } = await fixture() + const action = loader.toggle(1, false) + pending[0].resolve({ resource: { id: 1, name: 'Existing', enabled: false, status: 'disabled' } }) + assert.equal(await action, true) + assert.equal(state.items[0].enabled, false) + assert.equal(pending.length, 1) +}) + +test('an old list cannot overwrite a newer import and duplicate import is explicit', async () => { + const { loader, state, pending } = await fixture() + const read = loader.load() + const action = loader.import(new FormData()) + pending[1].resolve({ resource: { id: 1, name: 'Imported', enabled: true }, duplicate: true }) + await action + pending[0].resolve({ items: [{ id: 1, name: 'Old' }] }) + await read + assert.equal(state.items[0].name, 'Imported') + assert.match(state.notice, /已存在/) +}) + +test('page exit or session change discards late responses', async () => { + const { loader, state, pending, session } = await fixture() + const action = loader.import(new FormData()) + loader.invalidate() + pending[0].resolve({ resource: { id: 1, name: 'Late' } }) + assert.equal(await action, false) + assert.deepEqual(state.items, []) + const read = loader.load() + session.state.generation++ + pending[1].resolve({ items: [{ id: 1, name: 'Other session' }] }) + await read + assert.deepEqual(state.items, []) +}) diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 1433da3..9e2382a 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.- -wiki_revision: 40ae5e3a0521195da4f1a9cd9f42beec15da20d4 -synchronized_at: 2026-09-10T12:43:19Z +wiki_revision: 445504a7f95f9c018216f74e27d433edde521f73 +synchronized_at: 2026-09-11T03:58:56Z # LexGo 项目档案 @@ -37,7 +37,7 @@ LinguaCafe 参考基线由现有调研记录为 `c1ea298ce40c65b9dd33e9b26fd2e52 |---|---|---|---| | D01 | 管理端底座 | **已确认使用 `D:/github_project/goadmin` 的 go-admin + go-admin-ui**,依据用户指令“管理端用D:\github_project\goadmin”;替代 gin-vue-admin 建议 | M0 验证指定本地版本配套、MySQL 8 及数据隔离,不再比较管理底座 | | D02 | 数据库 | **已确认 MySQL 8**;依据 2026-09-10 用户原话“使用mysql8”。覆盖两份分析的数据库分歧,具体小版本待基线验证锁定 | 按单一 MySQL 8 数据层估算,不做 PostgreSQL 双库兼容 | -| D03 | NLP | 保留 Python 为建议,纯 Go 是否硬约束未确认 | 首发语言质量与开发量 | +| D03 | NLP | **用户于2026-09-11确认正式产品全 Go**;Python小样仅历史验证 | 使用词典词形候选,不承诺上下文消歧 | | D04 | 默认学习语言 | 用户已确认英语;中文/日语后续需独立范围与验收 | 分词、读音和 UI | | D05 | 用户范围 | **已确认:支持多账号、数据独立的自托管学习工具,先邀请少量用户使用** | 首版按多用户归属和隔离设计;不自动加入公开注册、邀请链接或组织租户 | | D06 | 旧数据 | 全量迁移是否需要未确认,CSV 与完整迁移不同 | 迁移另估 | @@ -136,3 +136,13 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必 ## 日志审计基线(#18) 管理端新增两个审计列表,后端为 LexGo 自有日志模型与接口,参照 go-admin 模块布局但不复制其原始参数/响应持久化逻辑。schema v2 新增 lexgo_login_logs、lexgo_operation_logs;保留 90 天,启动和每小时分批清理,也可显式 audit-cleanup。新建/重置密码均为 6~72 UTF-8 字节;初始 bootstrap 保留 10 字节下限。#2 已验收,#18 已验收;默认模块中的其他候选未纳入。 + + +## 全 Go 正式架构决定(2026-09-11,#6) + +用户已明确选择全 Go:正式英语分词、原文位置映射、本地词典解析和词形候选查询由 Go 后端完成,不运行 Python NLP 服务。此前“Python 建议/全 Go 未决”仅为历史决策记录,由本决定覆盖;spikes/english 保留历史验证,不接入产品。#6 按该方向实施,当前方案见工单最新启动评论;WordNet 3.0 仍为首个资源(英语释义),词形规则候选不等同于 spaCy 上下文消歧,原文及个人学习状态不按候选合并。 + + +## #6 当前工程状态(2026-09-11) + +#5已验收且相关前置PR均已合入main。#6正式词典/点词查义已按全Go实现,位于feat/6-go-dictionary-lookup,等待用户验收;server schema v4,管理端新增英语词典页,学习端加入Go分片及查词面板。正式Go进程不依赖Python NLP,WordNet包随数据库持久化。#3小样仍是历史验证;保存个人释义和状态归#7,音频封面#21、列表优化#24尚未实施。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index e1a3d4f..a7db91c 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: f981b86a7c6e5d2e353142d3006dcd6e0636c3ee -synchronized_at: 2026-09-11T03:01:48Z +wiki_revision: f317e21d651f2b5cb66810e9a4abf02831908559 +synchronized_at: 2026-09-11T03:58:59Z # 架构与代码地图 @@ -221,3 +221,25 @@ lexgo_books(owner_id, title, language)、lexgo_chapters(book_id, owner_id, ordin - 重试自愈(R4):`retryChapter` 先把重试返回的章节状态应用到列表与阅读器并重新安排轮询,再做静默刷新。 验证:Go 全量用例 20 项通过(新增运行期恢复与尝试上限两项);学习端单测 38 项通过,其中 7 项在整改前的代码上复现失败;真实联调确认追加路径可用、被中断的任务在运行中被自动恢复(约 0.5 秒,无需重启)、重试在首次刷新失败后仍自动显示最终结果。 + + +## 全 Go 正式架构决定(2026-09-11,#6) + +用户已明确选择全 Go:正式英语分词、原文位置映射、本地词典解析和词形候选查询由 Go 后端完成,不运行 Python NLP 服务。此前“Python 建议/全 Go 未决”仅为历史决策记录,由本决定覆盖;spikes/english 保留历史验证,不接入产品。#6 按该方向实施,当前方案见工单最新启动评论;WordNet 3.0 仍为首个资源(英语释义),词形规则候选不等同于 spaCy 上下文消歧,原文及个人学习状态不按候选合并。 + + +## #6 全 Go 词典与阅读器(2026-09-11,已实现待验收) + +`server/app/lexgo/wordnet.go` 负责固定 WordNet ZIP 校验/内存解析、Unicode 分词及词形候选;`dictionary.go` 负责资源与章节查词 API;`database.go` schema v4 新增单槽共享资源表 lexgo_dictionaries(元数据、enabled、SHA、ZIP LONGBLOB),已有学习数据不改写。每个 Router 按 SHA 缓存一个不可变词典,查询先读资源元数据,缓存未命中才读取 ZIP;进程重启从数据库恢复,不需要 Python NLP 或额外资源目录。 + +| 接口 | 权限与输入/输出 | +|---|---| +| GET /api/v1/dictionaries | 管理员;items + supported,状态 ready/disabled/unavailable,不返回 archive 或本机路径 | +| POST /api/v1/dictionaries/import | 管理员;multipart name/language/version/source/format/file;返回 resource + duplicate;格式/来源/版本固定 | +| PATCH /api/v1/dictionaries/:id | 管理员;{enabled:boolean};返回 resource | +| GET /api/v1/chapters/:id/tokens | 本人 ready 章节;{textSha256,tokens:[{text,start,end,startUtf16,endUtf16,kind}]} | +| POST /api/v1/lookup | {chapterId,start,end},cp半开范围;本人ready完整单词;返回 status/query/matchedForm/candidates/entries/resource | + +管理端 `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 不调用其实验服务。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index ab588a8..0b4b7c5 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: 4bd843d1f0134f5688407b7072d98333b40b6da1 -synchronized_at: 2026-09-11T03:01:48Z +wiki_revision: 935630005c9b1d7103550cf09eb10edfdbf8f3af +synchronized_at: 2026-09-11T03:59:01Z # 业务规则与术语 @@ -137,3 +137,19 @@ POST /lookup 接收 {surface,lemma?}。查词键单独 casefold/NFC/弯撇号转 - 运行期恢复不依赖重启:除了启动时的恢复,运行中的服务每次轮询都会把停留在 processing 且超过 15 秒的任务放回 pending,因此“领取已提交、完成事务失败”不会让章节永久卡在处理中。该阈值必须长于正常的领取到完成窗口;重复处理同一任务不会产生第二个章节,因为任务从不创建章节。 - 学习端离开页面时作废在途请求:目录页与阅读页在关闭时推进各自的请求序号,晚到的响应不会写回状态或重启轮询;导入页在提交过程中离开后,晚到的成功响应不会把用户导航回书籍页。 - 重试结果立即生效:重试被接受后先把返回的 pending 状态写入界面并继续轮询,因此紧随其后的一次刷新失败不会让页面停在处理失败。 + + +## 全 Go 正式架构决定(2026-09-11,#6) + +用户已明确选择全 Go:正式英语分词、原文位置映射、本地词典解析和词形候选查询由 Go 后端完成,不运行 Python NLP 服务。此前“Python 建议/全 Go 未决”仅为历史决策记录,由本决定覆盖;spikes/english 保留历史验证,不接入产品。#6 按该方向实施,当前方案见工单最新启动评论;WordNet 3.0 仍为首个资源(英语释义),词形规则候选不等同于 spaCy 上下文消歧,原文及个人学习状态不按候选合并。 + + +## #6 正式词典规则(2026-09-11) + +首个资源固定 Princeton WordNet 3.0,英语释义;ZIP 来源及 SHA 以 server/wordnet-resource.json 为准,LICENSE 原样保留于 server/WORDNET-LICENSE.txt。管理员上传指定包并配置显示名称,语言/来源/版本/格式固定;32MiB压缩、128MiB解压上限、成员与结构校验。资源为所有账号共享、仅管理员写;失败保持旧资源,重复上传复用id并启用,停用后查询返回 resource_missing。ZIP 随数据库备份,无运行时网络下载。 + +正式分词由 Go 完成:字母开始词,组合标记延续词,内部直/弯撇号连接字母;空白逐字保留,数字和符号为不可查询的 punctuation。start/end 是 Unicode code point 半开范围,另有UTF-16偏移;原文、CRLF、组合字符与emoji不归一化。只对查询键小写/NFC/撇号归一化。每次查询必须是本人ready章节内的完整单词,最大128码点,跨账号404,非法范围400。 + +exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺序n/v/a/r、最多12条释义,保留原数据s词性。返回lemma表示规则候选,不承诺上下文消歧;歧义不会合并个人学习状态。not_found与resource_missing区分,网络错误可重试,不阻断阅读。#3使用spaCy上下文lemma的实验路径由全Go规则候选替代。 + +个人释义目前仅当前选择的临时草稿,界面标记未保存;换词、关闭、换章、离页和身份变化清理。持久化与个人词汇状态由#7实现,不把临时输入宣传为保存成功。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 59ee197..debf8d3 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: 3bce5fae964b1050513c37319262679814d68f14 -synchronized_at: 2026-09-11T03:01:49Z +wiki_revision: bbd6e618f4339aa45d81bba0492045b60581a735 +synchronized_at: 2026-09-11T03:59:02Z # 本地开发与验证 @@ -305,3 +305,18 @@ node --test spikes/english/view.test.mjs 注意:MySQL 会话时区为 SYSTEM(本机为 UTC+8),而服务按 UTC 存储 DATETIME;核对任务时间时使用 UTC_TIMESTAMP 而不是 NOW(),否则会出现 8 小时的假偏差。 未在本轮验证:R3 的真实浏览器时序(离页与响应同时发生)只由单测覆盖;真机手机证据仍属 #4 缺口。 + + +## #6 部署与验证(2026-09-11) + +正式后端 schema v4,只增加 lexgo_dictionaries。显式 migrate 后启动纯Go lexgo.exe;Python scripts/server.py 仍是开发命令封装,产品运行不依赖Python NLP。 + +本机已从v3升级v4并重启lexgo-api,升级前后sys_user/lexgo_spaces/lexgo_sessions/lexgo_books/lexgo_chapters/lexgo_ingest_jobs计数一致。旧二进制保存在忽略的 .local/lexgo-pre-issue6.exe。回退:停止API,恢复旧二进制,将已确认v4的schema标记恢复3,保留新增资源表及全部学习数据,再启动旧API;不要删除数据或重新bootstrap。 + +管理端 http://127.0.0.1:5174 的“英语词典”页可下载指定包并导入/启停;本机已导入固定WordNet3.0,155287个词形/词性索引项。学习端 http://127.0.0.1:5173 打开本人ready章节,点词或Enter/空格查询,Escape关闭。went/mice应出现go/mouse候选。源包、本机凭据与测试证据仅存在忽略的.local,不进入Git。 + +验证:Go全包MySQL集成(专用lexgo_test_issue5)与go vet通过;学习端53单测、类型检查/构建、默认5173 Playwright3项通过;管理端31单测与lint通过,构建含已有Sass弃用与bundle体积提示;治理56测试与strict通过。实际ZIP解析、schema3→4原文/任务保留、权限、失败保留、重复启用、冷缓存路由重建都有覆盖;冷缓存测试不是完整备份恢复演练。 + +主审真实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验证另列。手机仅窄屏自动测试,真机证据仍未补齐。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index be25207..42a6966 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: 125df697d3d45dee098616d25970bb65a96727cd -synchronized_at: 2026-09-10T16:35:19Z +wiki_revision: 099f1c803ffb69edc3fe07582d22dcf9a57647a1 +synchronized_at: 2026-09-11T03:59:10Z # 产品需求总览 @@ -229,3 +229,13 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁 2026-09-10:#5“粘贴英语文本,处理后进入本人章节阅读”已实现并待用户验收,覆盖 F01 基础(书库与章节)、F02(粘贴导入)、F04 基础(处理状态与失败重试)与 F05 原文(可读原文与章节切换)。本节取代此前“阅读、导入尚未实现”的表述:粘贴导入与原文阅读已实现;点词查词、词典、个人词语状态、复习与统计仍未实现(#6~#15)。schema 升级为 v3,新增 lexgo_books、lexgo_chapters、lexgo_ingest_jobs。 范围边界不变:本单按 Go 处理,不接入 Python NLP,因此不产生 token、lemma 或词典索引;Go+Python NLP 与全 Go 路线仍未确认,正式接入前必须由用户确认。真机手机详细证据仍缺失(#4 缺口保持)。 + + +## 全 Go 正式架构决定(2026-09-11,#6) + +用户已明确选择全 Go:正式英语分词、原文位置映射、本地词典解析和词形候选查询由 Go 后端完成,不运行 Python NLP 服务。此前“Python 建议/全 Go 未决”仅为历史决策记录,由本决定覆盖;spikes/english 保留历史验证,不接入产品。#6 按该方向实施,当前方案见工单最新启动评论;WordNet 3.0 仍为首个资源(英语释义),词形规则候选不等同于 spaCy 上下文消歧,原文及个人学习状态不按候选合并。 + + +## #6 交付范围更新(2026-09-11) + +用户确认全Go后,英语词典配置与阅读器点词查义已实现待验收:共享WordNet3.0英语释义、管理员导入/启停、本人章节点击/键盘查词、加载/无结果/资源不可用/网络失败/关闭状态。词形结果是规则候选,不提供上下文词性消歧。个人释义输入是未保存临时草稿,#7才持久化。沿用已验收v1;手机底部45dvh面板自动测试通过,真机缺口保留。#5已关闭并合入main,#6尚不关闭。 diff --git a/docs/README.md b/docs/README.md index a938808..0088050 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: 5b936b75caeec89cd54f7147c842f0b66f901307 -synchronized_at: 2026-09-10T16:35:18Z +wiki_revision: 241fa63d8141ed9f1992421346d42f9b87453b0a +synchronized_at: 2026-09-11T03:58:55Z # LexGo 文档入口 @@ -65,3 +65,8 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn #4 阅读选择小样已实现桌面鼠标/键盘、范围调整与原文位置验证,入口 http://127.0.0.1:5184/;固定版本 LinguaCafe 四项源码对照已记录。用户已验收并关闭 #4;真实手机详细测试证据仍缺失,详见本地验证页。#21 仍待实施。 #5 粘贴导入与章节阅读已实现,待用户验收:schema v3 新增 books/chapters/ingest_jobs,学习端具备粘贴导入、书库、处理状态与原文阅读;查词、词典与复习仍未实现。本单按 Go 处理,Go+Python NLP 与全 Go 路线仍未决,正式接入前须用户确认。 + + +## 当前进度(2026-09-11) + +#5已验收,#17/#19/#20/#22/#23按依赖顺序合入main。#6已按用户确认的全Go方向实现英语词典与阅读点词,待验收;原文/账户隔离保留。正式NLP不使用Python服务。管理端“英语词典”导入指定WordNet3.0,学习端打开本人章节即可查词。个人释义当前不保存,后续#7实现。详见#6工单、架构和本地开发页面。 diff --git a/learner/e2e/reading.spec.ts b/learner/e2e/reading.spec.ts index 84e1fdf..46e562c 100644 --- a/learner/e2e/reading.spec.ts +++ b/learner/e2e/reading.spec.ts @@ -52,6 +52,17 @@ test('paste English text, watch a chapter finish processing, then read it verbat chapter: { ...chapterPayload(), contentSha256: 'fictional-sha256', ...(status === 'ready' ? { originalText: pasted } : {}) }, navigation: { previousChapterId: null, nextChapterId: null }, } + } else if (path === '/api/v1/chapters/55/tokens') { + let offset = 0 + const tokens = (pasted.match(/[A-Za-z]+|\s+|[^A-Za-z\s]+/g) ?? []).map(text => { + const start = offset + 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') { + 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.'] }] } } await route.fulfill({ status: statusCode, json: { code: 200, data } }) }) @@ -89,4 +100,25 @@ 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 }) + 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 }) + await word.click() + await expect(page.getByText('Coming before all others.')).toBeVisible() + const panelBounds = await page.locator('.lookup-panel').boundingBox() + expect(panelBounds!.y + panelBounds!.height).toBeLessThanOrEqual(845) + expect(panelBounds!.height).toBeLessThanOrEqual(844 * 0.46) + 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('') }) diff --git a/learner/src/__tests__/lookup.spec.ts b/learner/src/__tests__/lookup.spec.ts new file mode 100644 index 0000000..c4905a9 --- /dev/null +++ b/learner/src/__tests__/lookup.spec.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } 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 { 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']] +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 } + cp = token.end; utf16 = token.endUtf16 + return token +}) +const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', originalText: original, contentSha256: 'same-sha' } +const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data })) +const 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.'] }] : [] }) +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))) + return ok({ chapter, navigation: { previousChapterId: null, nextChapterId: 56 } }) + }) + useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' } + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/chapters/:id', component: ReaderView }, { path: '/', component: { template: '
' } }] }) + await router.push('/chapters/55') + wrapper = mount(ReaderView, { attachTo: document.body, global: { plugins: [router] } }) + await flushPromises() + return { fetchMock, router, view: wrapper } +} +describe('reader word lookup', () => { + beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() }) + afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() }) + it('keeps Unicode original text exact and sends only chapter and code-point offsets', async () => { + const { view, fetchMock } = await open() + expect(view.get('.reader-text').element.textContent).toBe(original) + 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'))! + 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 () => { + 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')))) + 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('虚构私人草稿') + finish(ok(result('OLD'))); await flushPromises() + expect(view.text()).not.toContain('Definition of OLD') + 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('') + }) + 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)) }) + await view.get('.reader-word').trigger('click'); await flushPromises() + await view.get('textarea').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) + await view.get('[data-testid="lookup-retry"]').trigger('click'); await flushPromises() + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/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) + 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) + }) + it('closes with Escape and restores the word focus without scrolling', async () => { + const { view } = await open() + const word = view.get('.reader-word').element as HTMLElement + word.focus() + await view.get('.reader-word').trigger('keydown', { key: 'Enter' }); await flushPromises() + const focus = vi.spyOn(word, 'focus') + await view.get('.lookup-panel').trigger('keydown', { key: 'Escape' }) + await flushPromises() + expect(view.find('.lookup-panel').exists()).toBe(false) + expect(document.activeElement).toBe(word) + expect(focus).toHaveBeenCalledWith({ preventScroll: true }) + }) + it('retries a failed token fetch without losing original text', async () => { + const { view, fetchMock } = await open() + fetchMock.mockRejectedValueOnce(new Error('网络暂不可用')) + // A new chapter load invalidates the old token rendering. + const { useLibraryStore } = await import('../stores/library') + useLibraryStore().chapter = { ...useLibraryStore().chapter!, contentSha256: 'retry-sha' } + await flushPromises() + expect(view.get('.reader-text').element.textContent).toBe(original) + expect(view.find('.reader-word').exists()).toBe(false) + fetchMock.mockResolvedValueOnce(ok({ textSha256: 'retry-sha', tokens })) + await view.get('[data-testid="tokens-retry"]').trigger('click'); await flushPromises() + 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 }) + let finish!: (response: Response) => void + fetchMock.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + await view.get('[data-testid="tokens-retry"]').trigger('click') + await useSessionStore().logout() + useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' } + finish(ok({ textSha256: 'same-sha', tokens })); await flushPromises() + expect(view.find('.reader-word').exists()).toBe(false) + }) + it('moves a covered selected word above the sheet and restores the prior scroll on close', async () => { + useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' } + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(result('cat'))) + const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation(() => {}) + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + const scope = effectScope() + const lookup = scope.run(() => useReaderLookup(ref(chapter as ChapterDetail)))! + const element = document.createElement('span') + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ bottom: 700 } as DOMRect) + lookup.select(tokens[2] as never, element) + lookup.keepSelectionVisible(500) + expect(scrollBy).toHaveBeenCalledWith({ top: 216, behavior: 'instant' }) + lookup.close() + expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'instant' }) + scope.stop() + }) + it('preserves a later manual reading scroll when closing the sheet', () => { + useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' } + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(result('cat'))) + let scrollY = 100 + vi.spyOn(window, 'scrollY', 'get').mockImplementation(() => scrollY) + vi.spyOn(window, 'scrollBy').mockImplementation((...args: unknown[]) => { + const options = args[0] as ScrollToOptions + scrollY += options.top ?? 0 + }) + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + const scope = effectScope() + const lookup = scope.run(() => useReaderLookup(ref(chapter as ChapterDetail)))! + const element = document.createElement('span') + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ bottom: 700 } as DOMRect) + lookup.select(tokens[2] as never, element) + lookup.keepSelectionVisible(500) + scrollY += 300 // The reader continues down the page while the sheet is open. + lookup.close() + expect(scrollTo).not.toHaveBeenCalled() + expect(scrollY).toBe(616) + scope.stop() + }) + 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 })) + await view.get('.reader-word').trigger('click') + if (action === 'logout') await useSessionStore().logout() + else if (action === 'chapter') await router.push('/chapters/56') + else { view.unmount(); wrapper = undefined } + finish(ok(result('OLD'))); await flushPromises() + if (action === 'unmount') expect(document.querySelector('.lookup-panel')).toBeNull() + else expect(view.find('.lookup-panel').exists()).toBe(false) + expect(view.text()).not.toContain('Definition of OLD') + }) +}) diff --git a/learner/src/components/LookupPanel.vue b/learner/src/components/LookupPanel.vue new file mode 100644 index 0000000..3371ab2 --- /dev/null +++ b/learner/src/components/LookupPanel.vue @@ -0,0 +1,55 @@ + + +