diff --git a/AGENTS.md b/AGENTS.md index 07ea087..094f28f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,5 +282,6 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 - #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。 - 2026-09-11 用户确认正式 NLP/词典采用全 Go。#5 已验收并合入 main;#6 使用 Go WordNet 解析和词形候选、Go Unicode 原文分片、schema v4 共享词典资源表,不调用 Python NLP。WordNet 3.0 ZIP 来源与摘要见 `server/wordnet-resource.json`,许可保留在 `server/WORDNET-LICENSE.txt`。词形候选不等于上下文消歧,不自动合并个人学习状态;#3 Python 小样只保留历史验证。当前词典仅英语释义,个人释义输入为临时草稿,持久化归 #7。 - 2026-09-11 用户确认 #7 个人词条口径(三项由 Agent 定案):身份为「学习者+语言+规范化词形」,大小写合并但**不按 lemma/候选合并**(`dog` 与 `dogs` 是两条记录);首次保存默认「新词」;状态为 新词/学习中/已知/忽略,只有「学习中」带 1~7 级,对应原版 stage 2/1/0/-1~-7;例句只保存手输内容,不自动关联原文句子。schema v5 新增 `lexgo_terms`(唯一键加状态/等级检查约束),个人释义与共享词典分离且不进入审计日志;等级编辑 UI 归 #8/#12。 +- 2026-09-11 用户确认 #10 编辑与删除口径:可改名、改章节标题、编辑章节正文;**只有正文变化才重新处理**,重复保存或改回原内容不新建任务,只改标题不改状态。版本键是 `content_sha256`:任务只在与章节版本一致时才能影响章节,过期版本任务标为 `error_reason=superseded` 且**完全不触碰章节**(认领、恢复扫描、重试都按版本裁决);存储文本重算 SHA 与存储 SHA 不一致时按 `content_changed` 失败。删除为事务内硬删除 + 外键级联,删章后重排序号;个人词条、复习排期与作答记录**不随删除清理**。回收站/撤销、批量操作、章节跨书移动与语言变更不在范围内。编辑器行尾归一为 LF 是已知边界。 - 2026-09-11 用户确认 #9 TXT 导入口径:只接受 UTF-8(允许可选 BOM,解码时剥离且不进入原文),非法字节整体拒绝、不使用替换字符;UTF-16 按 BOM 识别后明确拒绝,GB18030 等按非法 UTF-8 拒绝。文件字节上限 2 MiB,之后仍套用单章 100000 码点上限;换行与空白不归一化。文件只在内存中解码、不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有 `PasteBook`/`PasteChapter`,分章(一次提交一章)、`requestId` 幂等与任务恢复与粘贴一致;不改 schema。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在范围内。 - 2026-09-11 用户确认 #8 到期单词复习决策表:固定间隔表 1/2/4/7/15/30/60 天,答对升级封顶 7、答错降级最低 1、再学一次不改等级,答错与再学立即回队;已知/忽略不入队,新保存的词立即到期,显式「学习中 level N」排 now+间隔[N];只有新建或状态/等级实际变化才移动复习时间,编辑释义或例句保留原排期,保存未提及等级时保留已获得等级。到期判定用 UTC 绝对时刻(`due_at ≤ now`),不引入本地日边界。作答按 `answerId` 去重并以 `expectedDueAt` 判定过期标签页,重复提交、网络重发与双标签页都不得重复更新次数与间隔(作答响应 `result` 只取 applied/stale,重放另用 `duplicate` 标记并返回首次结果);`correct_count` 只计答对,`wrong_count` 计答错与再学。短语复习归 #11,进度统计归 #13,不做策略配置 UI(X11)、练习模式(X08)与 FSRS。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 024af27..155099d 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: 5e22c6023b065287ac47d92e6b8748e34d21763b -synchronized_at: 2026-09-13T14:36:36Z +wiki_revision: ce95236e4c8de27c1063448571cfc528c895f937 +synchronized_at: 2026-09-13T15:20:16Z # 架构与代码地图 @@ -287,3 +287,21 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at 解码规则见业务规则页;实现上 `decodeTextUpload` 先按 UTF-16 BOM 识别并给出针对性提示,再剥离可选 UTF-8 BOM,然后用 `utf8.Valid` 整体校验,最后交给 `validatePaste`(非空、≤100000 码点)。因此上传与粘贴共享同一分章与任务规则:一次提交一章,`requestId` + 内容 SHA 幂等,worker 只发布已落库的原文。 学习端 `ImportView.vue` 增加「粘贴文本 / TXT 文件」来源切换(沿用已验收 v1 的切换与状态行),`stores/library.ts` 增加 `upload()` 与 `fileProblem`/`fileSizeLabel`,`session.request` 支持 `FormData`(multipart 请求不再被 JSON 化,边界由浏览器提供)。客户端预检只提前反馈,服务端结论为最终结论。 + +## #10 编辑与删除书籍章节(2026-09-11) + +`server/app/lexgo/edit.go` 提供改名、编辑与删除;`ingest.go` 增加版本门控。**schema 无变化**:`chapters.content_sha256` 与 `jobs.content_sha256` 就是版本键,新增的 `superseded` 复用现有 `error_reason` 列。 + +| 接口 | 权限与输入/输出 | +|---|---| +| PATCH /api/v1/books/:id | 本人;`{title}`;返回 `{book}` | +| GET /api/v1/chapters/:id/source | 本人任意状态;返回 `{source:{id,bookId,ordinal,title,text,status,contentSha256,charCount}}`,供编辑使用;不接受查询参数 | +| PATCH /api/v1/chapters/:id | 本人;`{title?,text?}`;返回 `{chapter,job,versionChanged}`;正文变化才新建任务 | +| DELETE /api/v1/books/:id | 本人;返回 `{deleted:{bookId,chapters}}`;FC 级联删除章节与任务 | +| DELETE /api/v1/chapters/:id | 本人;返回 `{deleted:{chapterId,bookId,remaining}}`;删除后重排序号 | + +**版本门控**(本单修掉的缺陷):任务只在 `job.content_sha256 == chapter.content_sha256` 时才能影响章节。认领任务时用 JOIN 只取版本匹配的行,并先把过期版本任务一次性标为 `failed/superseded`;发布前再比对一次,不匹配就只把任务标为 `superseded` 并**完全不触碰章节**;恢复扫描同样先作废过期版本任务、只重排版本匹配的中断任务;重试接口拒绝版本不匹配的任务(409)。删除期间在途任务找不到章节时视为无事可做(级联已删除其任务行)。 + +`unprocessableReason` 保留一条内容一致性检查:存储的正文重新计算出的 SHA 必须等于该章节存储的 SHA,用于兜住绕过 API 的直接写入(`content_changed`),与版本门控互不重复。 + +学习端 `BookView.vue` 增加书名编辑对话框、章节编辑对话框(标题 + 正文,正文来自 source 接口)与两处确认弹窗(`ElMessageBox`),章节行增加「编辑」入口;`LibraryView.vue` 在书库被删后显示「书籍已删除 · 已保存的生词和短语仍保留在生词本。」;`stores/library.ts` 增加 `renameBook`、`updateChapter`、`loadChapterSource`、`deleteBook`、`deleteChapter`。正文编辑通过浏览器 textarea 输入,因此该章的行尾统一为 LF(粘贴与 TXT 导入仍保留原始 CRLF)。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 50e085e..3be078f 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: 76289713c11902383031764c90ae9da90bd0ce07 -synchronized_at: 2026-09-11T15:36:44Z +wiki_revision: 96eec89f74db744d8807baa24b1d1d611ff6bf8f +synchronized_at: 2026-09-13T15:20:16Z # 业务规则与术语 @@ -206,3 +206,23 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺 **导入与幂等**:上传与粘贴共用同一套规则——一次提交一章,`requestId` + 内容 SHA 保证重复上传同一文件只产生一章(返回第一次的章节并标记 `duplicate`),同一 `requestId` 换成其他内容返回 409。任务状态、失败重试与崩溃恢复沿用 #5 的任务机制,不新增状态。标题规则与粘贴完全相同(去空白后非空、≤120 字符);省略 `language` 时默认英语,与粘贴一致;追加章节不接受 `language`。 **范围边界**:不包含 EPUB、PDF、字幕与其他文件格式;不做按空行自动分章;不做 UTF-16/GB18030 转码;不做断点续传;不把来源文件名持久化(若将来需要「导入来源」溯源,另立范围)。 + +## #10 编辑、版本与删除规则(2026-09-11) + +**可编辑内容**:书名、章节标题、章节正文。只有正文变化才重新处理;只改标题不改变处理状态,也不新建任务。 + +**版本规则**:`chapters.content_sha256` 是章节的版本键,`jobs.content_sha256` 是任务被创建时对应的版本。任务只能在版本匹配时影响章节: +- 过期版本的任务被标为 `error_reason=superseded`(「章节内容已更新为新版本,本次处理已作废」),**不会**把章节标成失败、也不会发布旧文本;界面上失败章节的重试按钮只出现于当前版本的任务。 +- 任务重试要求版本匹配,否则 409,避免把当前章节拉回旧版本再失败一次。 +- 重复保存同一正文不是新版本:不新建任务、不改变状态。改回原内容(内容相同)同样不触发处理。 +- 编辑章节正文会保留章节编号与阅读入口(URL 不变),章节状态回到待处理,处理完成后原文即新版本。 + +**存储文本与版本一致性**:章节存储的正文重新计算出的 SHA 必须等于存储的 SHA;出现不一致(绕过 API 的直接写入)时按 `content_changed` 失败,不发布不确定内容。 + +**删除规则**:删除在事务内**硬删除**,并沿用现有外键级联清理:书籍 → 章节 → 任务。删除章节后剩余章节序号重排为连续(原型显示「剩余 N 章」,导航按序号取值);并发删除同一章由书籍行锁序列化,结果是一个成功、一个 404。重复删除返回 404,不把「已经不存在」当成成功。恢复路径是数据库备份与完整恢复(#15 演练范围),产品不提供回收站或撤销。 + +**个人学习记录保留**:删除书籍或章节**不删除**个人词条、复习排期与作答记录,因为它们按学习者归属、不引用章节;界面也没有「来自某章节」的引用(例句是学习者输入的副本)。所以删除只影响书籍、章节与处理任务,这一点在原型确认文案中就写明:「本章正文将被删除,已保存词条保留」「删除这本书及其章节?已保存的生词和短语将保留」。 + +**归属与边界**:改名、编辑、删除、读取编辑用原文都严格按会话归属;他人资源与不存在资源统一 404,未登录 401,空标题/空正文/超长文本/未知字段 400。学习端正文编辑框的行尾会统一为 LF(浏览器 textarea 行为),粘贴与 TXT 导入路径仍然保留原始 CRLF 与空白。 + +**范围边界**:不做封面与音频附件(#21)、不做回收站/撤销、不做批量操作、不做章节跨书移动、不做语言变更。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index e586692..62ce6a9 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: 8c0886a74112ea7bff734c04775b56c571797c3d -synchronized_at: 2026-09-11T15:36:44Z +wiki_revision: 251fb5de71b8ba75da3cba6eee41454d5bbd22df +synchronized_at: 2026-09-13T15:20:16Z # 本地开发与验证 @@ -398,3 +398,21 @@ node --test spikes/english/view.test.mjs 真实链路验证:真实 Go API+真实 MySQL 共 26 项检查通过(凭据只从本机安全配置读入进程),覆盖有效文件与阅读器原文逐字节一致、BOM 不进入原文、五类无效文件、越权与未登录拒绝、两账号隔离、恶意文件名不泄漏;随后用临时 Playwright 用例在真实学习端完成「登录→切换 TXT→选择真实 UTF-8 文件→上传→处理中就绪→阅读器原文逐字符一致」的闭环,并复核 UTF-16 文件在浏览器预检阶段被拒。截图保存在本机 `.local/evidence/`(issue9-invalid-encoding.png、issue9-upload-processing.png、issue9-reader.png),临时用例运行后删除。 未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。大文件并发上传只按单槽并发门设计,没有做多用户压力测试。UTF-16/GB18030 转码与按空行自动分章不在本单。 + +## #10 验证与迁移(2026-09-11) + +仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9(沿用上一单的库),不借用其他测试库。本单**不改动数据库结构**,没有迁移步骤,回退只需换回旧二进制。 + +| 命令 | 结果 | +|---|---| +| `go vet ./...` | 通过 | +| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 59 个顶层用例全部通过、0 跳过;含 #10 新增 9 个用例 | +| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 94 项单测、类型检查、构建、7 项 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 项与严格检查通过 | + +覆盖内容:改名不改状态也不新建任务、标题/正文校验、正文变化产生新版本且保留章节编号、**处理中改正文后旧任务只标 `superseded` 且不触碰新版本**、旧版本任务重试 409、重复保存与改回原内容不新建任务、内容与版本不一致时按 `content_changed` 失败、恢复扫描作废过期版本且不重排新版本、编辑用原文对任意状态可读、删除章节后序号连续且导航正确、删除书籍级联清理章节与任务、删除期间在途任务不复活内容、并发删除同一章一个成功一个 404、重复删除 404、跨用户改名/编辑/删除/读原文一律 404、未登录 401、个人词条与复习排期在删除后完全保留。 + +真实链路验证:真实 Go API+真实 MySQL 共 36 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行清理 fixture),覆盖改名、编辑产生新版本并重新处理、重复保存幂等、删除章节重排序号、删除书籍级联、个人词条与复习队列在删除后保留、越权与非法输入拒绝;随后用临时 Playwright 用例在真实学习端完成「导入→改名→编辑正文→新版就绪→删除章节→删除书籍→书库提示」的闭环,并核对阅读器原文等于新版本。截图保存在本机 `.local/evidence/`(issue10-book-after-edit.png、issue10-chapter-deleted.png、issue10-book-deleted.png),临时用例运行后删除。 + +未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。并发只覆盖「同一章节并发删除」与「处理中编辑」两类,没有做多用户压力测试。浏览器 textarea 会把该章的 CRLF 归一为 LF,属已知边界,已记入业务规则页。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index 5dbdacb..b5ba810 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: 9bb9f570044fb7c9e04c5f1bd04beeaf89840d86 -synchronized_at: 2026-09-13T14:36:37Z +wiki_revision: 82d714b949d38f20586fbe42797b2dec6b0f19f1 +synchronized_at: 2026-09-13T15:20:17Z # 产品需求总览 @@ -257,3 +257,9 @@ F10 的单词到期复习已于 2026-09-11 通过用户验收(包含独立审 F03 的 TXT 文件导入已于 2026-09-13 通过用户验收:学习端导入页新增「粘贴文本 / TXT 文件」来源切换,选择 UTF-8 的 .txt 文件后经大小、空文件与编码校验进入与粘贴相同的处理与阅读流程,失败可重试。只支持 UTF-8(允许可选 BOM)且不替换损坏字符;UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;重复上传同一文件只产生一章。schema 无变化。 仍未实现并留给后续工单:书籍与章节的编辑删除(#10)、短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在本单范围。 + +## #10 交付范围更新(2026-09-11) + +F01 的编辑与删除已实现,待用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。 + +仍未实现并留给后续工单:短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。封面与音频附件(#21)、回收站/撤销、批量操作、章节跨书移动与语言变更不在本单范围。 diff --git a/docs/README.md b/docs/README.md index 7838094..4ccfb56 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: 03e9280eedbd7506ec567cef61a54745873d71e2 -synchronized_at: 2026-09-13T14:36:36Z +wiki_revision: ea264da6747bd141ec2d67011830320452563415 +synchronized_at: 2026-09-13T15:20:16Z # LexGo 文档入口 @@ -76,3 +76,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn #8 单词到期复习已于 2026-09-11 通过用户验收(schema v6 新增 lexgo_term_reviews 与 lexgo_review_answers):固定间隔表(1/2/4/7/15/30/60 天)、答对升级、答错或再学立即回队、已知与忽略不入队;重复提交、网络重发与双标签页都只记账一次,到期判定用 UTC 绝对时刻而不引入本地日边界。学习端新增「到期复习」入口,卡片正面显示词与挖空例句、答案面显示个人释义,并有完成页与空队列页。独立审核指出的并发同键 500、编辑文本重排复习与面板保存重置等级三项已整改并复测;PR #27 已 fast-forward-only 合入 main。第 3 阶段「首条学习闭环」#5~#8 全部验收。 #9 TXT 文件导入已于 2026-09-13 通过用户验收:导入页新增「粘贴文本 / TXT 文件」来源切换,只接受 UTF-8(允许可选 BOM)且不替换损坏字符,UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;上传与粘贴共用同一分章、任务与幂等规则,重复上传同一文件只产生一章。本次没有数据库结构变化;PR #28 已 fast-forward-only 合入 main。 + +#10 编辑与删除书籍章节已实现,待用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。 diff --git a/learner/e2e/edit.spec.ts b/learner/e2e/edit.spec.ts new file mode 100644 index 0000000..3b66f64 --- /dev/null +++ b/learner/e2e/edit.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from '@playwright/test' + +// Renaming, editing and deleting through the real dialogs of the accepted prototype, against +// a mocked API. +test('rename the book, edit a chapter into a new version and delete both', async ({ page }) => { + const user = { id: 42, username: 'fictional-editor', role: 'learner' } + const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' } + const first = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 24, errorReason: '', errorMessage: '', jobId: 5, ...timestamps } + const second = { id: 10, bookId: 1, ordinal: 2, title: 'Second chapter', status: 'ready', charCount: 12, errorReason: '', errorMessage: '', jobId: 6, ...timestamps } + let bookTitle = 'A small step' + let chapters = [first, second] + let firstText = 'Mira opened the workshop.\n' + let processing = false + const requests: string[] = [] + + const listBook = () => ({ + book: { id: 1, title: bookTitle, language: 'en' }, + chapters: chapters.map(item => (item.id === 9 && processing ? { ...item, status: 'processing' } : item)), + }) + + await page.route('**/api/v1/**', async route => { + const path = new URL(route.request().url()).pathname + const method = route.request().method() + requests.push(`${method} ${path}`) + let data: unknown = null + let status = 200 + 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' && method === 'GET') { + data = { items: bookTitle === '' ? [] : [{ id: 1, title: bookTitle, language: 'en', chapterCount: chapters.length, pendingCount: 0, processingCount: 0, readyCount: chapters.length, failedCount: 0, ...timestamps }] } + } else if (path === '/api/v1/books/1' && method === 'PATCH') { + bookTitle = (route.request().postDataJSON() as { title: string }).title + data = { book: { id: 1, title: bookTitle, language: 'en' } } + } else if (path === '/api/v1/books/1' && method === 'DELETE') { + chapters = [] + bookTitle = '' + data = { deleted: { bookId: 1, chapters: 2, remaining: 0 } } + } else if (path === '/api/v1/books/1') data = listBook() + else if (path === '/api/v1/chapters/9/source') data = { source: { id: 9, bookId: 1, ordinal: 1, title: first.title, text: firstText, status: 'ready', contentSha256: 'sha-a', charCount: [...firstText].length } } + else if (path === '/api/v1/chapters/9' && method === 'PATCH') { + const body = route.request().postDataJSON() as { title?: string; text?: string } + const changed = body.text !== undefined && body.text !== firstText + if (body.title !== undefined) first.title = body.title + if (changed) { + firstText = body.text as string + processing = true + first.status = 'pending' + setTimeout(() => { processing = false; first.status = 'ready' }, 400) + } + status = 200 + data = { chapter: first, job: changed ? { id: 7, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps } : null, versionChanged: changed } + } else if (path === '/api/v1/chapters/9' && method === 'DELETE') { + chapters = chapters.filter(item => item.id !== 9).map((item, index) => ({ ...item, ordinal: index + 1 })) + data = { deleted: { chapterId: 9, bookId: 1, remaining: chapters.length } } + } + await route.fulfill({ status, 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.getByRole('link', { name: 'A small step' }).click() + await expect(page.getByRole('heading', { name: 'A small step' })).toBeVisible() + await expect(page.getByText('封面使用系统默认样式')).toBeVisible() + + // Rename the book through the dialog. + await page.getByTestId('edit-book').click() + await expect(page.getByLabel('书名', { exact: true })).toHaveValue('A small step') + await page.getByLabel('书名', { exact: true }).fill('A long step') + await page.getByTestId('save-book').click() + await expect(page.getByTestId('book-notice')).toContainText('书名已更新') + await expect(page.getByRole('heading', { name: 'A long step' })).toBeVisible() + + // Edit the chapter text: the new version re-processes and later becomes ready again. + await page.getByTestId('edit-chapter-9').click() + await expect(page.getByLabel('章节标题', { exact: true })).toHaveValue('First chapter') + await expect(page.getByLabel('正文', { exact: true })).toHaveValue(firstText) + await page.getByLabel('正文', { exact: true }).fill('A replacement body.\n') + await page.getByTestId('save-chapter').click() + await expect(page.getByTestId('book-notice')).toContainText('已保存为新版本,正在重新处理') + // The saved version is queued first and becomes readable again when the worker finishes. + await expect(page.locator('.chapter-row').first()).toContainText('待处理') + await expect(page.locator('.chapter-row').first()).toContainText('已就绪', { timeout: 10000 }) + + // Deleting a chapter asks first, then reports the remaining count. + await page.getByTestId('edit-chapter-9').click() + await page.getByTestId('delete-chapter').click() + await expect(page.locator('.el-message-box__message').last()).toContainText('本章正文将被删除,已保存词条保留') + await page.locator('.el-message-box').last().getByRole('button', { name: '取消' }).click() + expect(requests.filter(entry => entry === 'DELETE /api/v1/chapters/9')).toHaveLength(0) + await page.getByTestId('delete-chapter').click() + await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除章节' }).click() + await expect(page.getByTestId('book-notice')).toContainText('章节已删除 · 剩余 1 章') + await expect(page.locator('.chapter-row')).toHaveCount(1) + + // Deleting the book asks first and returns to the library without it. + await page.getByTestId('delete-book').click() + await expect(page.locator('.el-message-box__message').last()).toContainText('已保存的生词和短语将保留') + await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除' }).click() + await expect(page).toHaveURL(/\?deleted=\d+$/) + await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible() + await expect(page.getByTestId('library-notice')).toContainText('书籍已删除 · 已保存的生词和短语仍保留在生词本') + await expect(page.getByRole('link', { name: 'A long step' })).toHaveCount(0) +}) diff --git a/learner/src/__tests__/edit.spec.ts b/learner/src/__tests__/edit.spec.ts new file mode 100644 index 0000000..d9b10ff --- /dev/null +++ b/learner/src/__tests__/edit.spec.ts @@ -0,0 +1,237 @@ +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 { ElMessageBox } from 'element-plus' +import BookView from '../views/BookView.vue' +import { useLibraryStore, type ChapterSource, type ChapterSummary } from '../stores/library' +import { useSessionStore } from '../stores/session' + +const user = { id: 42, username: 'fictional-editor', role: 'learner' as const } +const book = { id: 1, title: 'A small step', language: 'en' } +const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' } +const chapter = (overrides: Partial = {}): ChapterSummary => ({ + id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 12, + errorReason: '', errorMessage: '', jobId: 5, ...timestamps, ...overrides, +}) +const source: ChapterSource = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', text: 'Mira opened the workshop.\n', status: 'ready', contentSha256: 'sha-a', charCount: 24 } +const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data })) +const fail = (msg: string, status = 400) => new Response(JSON.stringify({ code: status, msg }), { status }) +let wrapper: VueWrapper | undefined + +function stub(name: string) { + return { template: `
${name}
` } +} + +async function viewAt(path: string): Promise { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: stub('LibraryStub') }, + { path: '/books/:id', component: stub('BookStub') }, + { path: '/import', component: stub('ImportStub') }, + ], + }) + await router.push(path) + await router.isReady() + return router +} + +/** Routes the book page's own calls; a test can override any of them. */ +function mockApi(overrides: Record Response | Promise> = {}): MockInstance { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = String(input) + const method = String((init as RequestInit | undefined)?.method ?? 'GET') + for (const [key, handler] of Object.entries(overrides)) { + if (url.includes(key)) return handler(init as RequestInit) + } + if (url.endsWith('/books/1') && method === 'GET') return ok({ book, chapters: [chapter()] }) + if (url.includes('/chapters/9/source')) return ok({ source }) + // A rename answers with the stored book, and a delete reports what it removed. + if (url.endsWith('/books/1') && method === 'PATCH') { + const body = JSON.parse(String((init as RequestInit | undefined)?.body ?? '{}')) as { title?: string } + return ok({ book: { ...book, title: body.title ?? book.title } }) + } + if (url.endsWith('/books/1') && method === 'DELETE') return ok({ deleted: { bookId: 1, chapters: 3 } }) + if (url.endsWith('/books/1')) return ok({ book }) + if (url.includes('/chapters/9')) return ok({ chapter: chapter(), job: null, versionChanged: false }) + return ok({}) + }) +} + +async function openBook() { + useSessionStore().user = { ...user } + const router = await viewAt('/books/1') + wrapper = mount(BookView, { attachTo: document.body, global: { plugins: [router] } }) + await flushPromises() + return { view: wrapper, router } +} + +describe('book editing store', () => { + beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() }) + afterEach(() => { vi.restoreAllMocks() }) + + it('renames a book and keeps the list entry in step', async () => { + const fetchMock = mockApi() + const library = useLibraryStore() + library.books = [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }] + library.book = { ...book } + const renamed = await library.renameBook(1, ' New name ') + expect(renamed.title).toBe('New name') + expect(library.book?.title).toBe('New name') + expect(library.books[0]?.title).toBe('New name') + const call = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')! + expect(String(call[0])).toBe('/api/v1/books/1') + expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ title: 'New name' }) + }) + + it('refuses an invalid title before calling the API', async () => { + const fetchMock = mockApi() + const library = useLibraryStore() + await expect(library.renameBook(1, ' ')).rejects.toThrow('请填写标题') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('applies a new chapter version and drops the stale reader text', async () => { + mockApi({ '/chapters/9': () => ok({ chapter: chapter({ status: 'pending', title: 'Edited' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true }) }) + const library = useLibraryStore() + library.chapters = [chapter()] + library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'old text' } + const result = await library.updateChapter(9, { title: 'Edited', text: 'new text' }) + expect(result.versionChanged).toBe(true) + expect(library.chapters[0]).toMatchObject({ status: 'pending', title: 'Edited', jobId: 6 }) + // The reader must not keep showing text that is no longer the stored version. + expect(library.chapter?.originalText).toBeUndefined() + }) + + it('keeps the reader text when only the title changed', async () => { + mockApi({ '/chapters/9': () => ok({ chapter: chapter({ title: 'Renamed' }), job: null, versionChanged: false }) }) + const library = useLibraryStore() + library.chapters = [chapter()] + library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'kept text' } + await library.updateChapter(9, { title: 'Renamed' }) + expect(library.chapter?.originalText).toBe('kept text') + expect(library.chapter?.title).toBe('Renamed') + }) + + it('deletes a chapter, reloads the book and removes a deleted book from the list', async () => { + mockApi({ + '/chapters/9': () => ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }), + '/books/1': (init) => (init?.method === 'DELETE' + ? ok({ deleted: { bookId: 1, chapters: 3 } }) + : ok({ book, chapters: [] })), + }) + const library = useLibraryStore() + library.books = [{ ...book, chapterCount: 3, pendingCount: 0, processingCount: 0, readyCount: 3, failedCount: 0, ...timestamps }] + library.book = { ...book } + library.chapters = [chapter(), chapter({ id: 10, ordinal: 2 }), chapter({ id: 11, ordinal: 3 })] + const deleted = await library.deleteChapter(9) + expect(deleted.remaining).toBe(2) + expect(library.chapters.map(item => item.id)).toEqual([]) + expect(library.book?.id).toBe(1) + const removed = await library.deleteBook(1) + expect(removed.chapters).toBe(3) + expect(library.books).toHaveLength(0) + expect(library.book).toBeNull() + }) + + it('reads the editable source of any chapter state', async () => { + mockApi({ '/chapters/9/source': () => ok({ source: { ...source, status: 'failed' } }) }) + const library = useLibraryStore() + const loaded = await library.loadChapterSource(9) + expect(loaded.text).toBe(source.text) + expect(loaded.status).toBe('failed') + }) +}) + +describe('book editing view', () => { + beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); vi.restoreAllMocks() }) + afterEach(() => { wrapper?.unmount(); wrapper = undefined; useLibraryStore().stopPolling() }) + + it('renames the book through the dialog and reports it', async () => { + const fetchMock = mockApi() + const { view } = await openBook() + await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises() + const input = view.get('#book-title') + expect((input.element as HTMLInputElement).value).toBe('A small step') + await input.setValue(' Edited name ') + await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises() + const patch = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')! + expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited name' }) + expect(view.get('[data-testid="book-notice"]').text()).toContain('书名已更新') + }) + + it('keeps the dialog open with the server message when renaming fails', async () => { + mockApi({ '/books/1': (init) => (init?.method === 'PATCH' ? fail('书名已存在') : ok({ book, chapters: [chapter()] })) }) + const { view } = await openBook() + await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises() + await view.get('#book-title').setValue('Rejected') + await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises() + expect(view.text()).toContain('书名已存在') + expect(view.get('#book-title')).toBeTruthy() + }) + + it('asks before deleting the book and does nothing when the learner cancels', async () => { + const fetchMock = mockApi() + const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel') + const { view } = await openBook() + await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises() + expect(confirm).toHaveBeenCalledWith(expect.stringContaining('已保存的生词和短语将保留'), '删除书籍', expect.anything()) + expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'DELETE')).toBe(false) + }) + + it('deletes the book after confirmation and returns to the library', async () => { + const fetchMock = mockApi() + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never) + const { view, router } = await openBook() + await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises() + expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/books/1') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true) + expect(router.currentRoute.value.path).toBe('/') + expect(router.currentRoute.value.query.deleted).toBe('3') + }) + + it('edits a chapter: loads the source, saves title and text and reports the new version', async () => { + const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'PATCH' + ? ok({ chapter: chapter({ status: 'pending', title: 'Edited chapter' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true }) + : ok({ source })) }) + const { view } = await openBook() + await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises() + expect((view.get('#chapter-title').element as HTMLInputElement).value).toBe('First chapter') + expect((view.get('#chapter-text').element as HTMLTextAreaElement).value).toBe(source.text) + await view.get('#chapter-title').setValue('Edited chapter') + await view.get('#chapter-text').setValue('A new body.\n') + await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises() + const patch = fetchMock.mock.calls.find(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'PATCH')! + expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited chapter', text: 'A new body.\n' }) + expect(view.get('[data-testid="book-notice"]').text()).toContain('已保存为新版本,正在重新处理') + }) + + it('refuses an empty chapter body locally and keeps the dialog', async () => { + const fetchMock = mockApi() + const { view } = await openBook() + await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises() + await view.get('#chapter-text').setValue(' \n\t ') + await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises() + expect(view.text()).toContain('请粘贴要导入的英文正文。') + expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')).toBe(false) + }) + + it('deletes a chapter after confirmation and reports the remaining count', async () => { + const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'DELETE' ? ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }) : ok({ source })) }) + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never) + const { view } = await openBook() + await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises() + await view.get('[data-testid="delete-chapter"]').trigger('click'); await flushPromises() + expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true) + expect(view.get('[data-testid="book-notice"]').text()).toContain('章节已删除 · 剩余 2 章') + // Element Plus keeps a closed dialog in the DOM, so the closed state is what matters. + expect(view.get('[data-testid="chapter-dialog"]').isVisible()).toBe(false) + }) + + it('shows an empty chapter list with the import hint', async () => { + mockApi({ '/books/1': () => ok({ book, chapters: [] }) }) + const { view } = await openBook() + expect(view.get('[data-testid="empty-chapters"]').text()).toContain('这一本书还没有章节') + expect(view.get('[data-testid="delete-book"]')).toBeTruthy() + }) +}) diff --git a/learner/src/stores/library.ts b/learner/src/stores/library.ts index ac27d76..de0c738 100644 --- a/learner/src/stores/library.ts +++ b/learner/src/stores/library.ts @@ -51,6 +51,21 @@ export interface Job { export interface ChapterNavigation { previousChapterId: number | null; nextChapterId: number | null } +/** The editable text of one owned chapter; separate from the ready-only reader payload. */ +export interface ChapterSource { + id: number + bookId: number + ordinal: number + title: string + text: string + status: ChapterStatus + contentSha256: string + charCount: number +} + +export interface ChapterEdit { chapter: ChapterSummary; job: Job | null; versionChanged: boolean } +export interface DeletionResult { bookId?: number; chapterId?: number; chapters?: number; remaining: number } + export type SubmitTarget = { mode: 'new' } | { mode: 'append'; bookId: number } export interface SubmitInput { title: string; text: string; target: SubmitTarget } @@ -429,6 +444,80 @@ export const useLibraryStore = defineStore('library', () => { } } + /** Renames one owned book; the reply is the stored book. */ + async function renameBook(id: number, title: string): Promise { + const version = generation + const owner = ownerId() + const problem = titleProblem(title) + if (problem) throw new Error(problem) + const result = await session.request<{ book: BookRef }>(`books/${id}`, 'PATCH', { title: title.trim() }) + if (!isStale(version, owner)) { + book.value = result.book + const listed = books.value.find(item => item.id === id) + if (listed) listed.title = result.book.title + } + return result.book + } + + /** + * Saves a chapter title and/or text. A changed text becomes a new version and re-processes; + * the same text answered again changes nothing. + */ + async function updateChapter(id: number, input: { title?: string; text?: string }): Promise { + const version = generation + const owner = ownerId() + if (input.title !== undefined) { + const problem = titleProblem(input.title) + if (problem) throw new Error(problem) + } + if (input.text !== undefined) { + const problem = textProblem(input.text) + if (problem) throw new Error(problem) + } + const body: { title?: string; text?: string } = {} + if (input.title !== undefined) body.title = input.title.trim() + if (input.text !== undefined) body.text = input.text + const result = await session.request(`chapters/${id}`, 'PATCH', body) + if (isStale(version, owner)) return result + const merged = { ...result.chapter, jobId: result.job?.id ?? result.chapter.jobId } + applyChapterSummary(merged) + if (chapter.value !== null && chapter.value.id === id && result.versionChanged) { + // The new version is not readable yet, so the reader must drop the previous text. + chapter.value = { ...chapter.value, ...merged, originalText: undefined } + } + schedulePolling() + return result + } + + /** Reads the editable text of one owned chapter, in any processing state. */ + async function loadChapterSource(id: number): Promise { + const result = await session.request<{ source: ChapterSource }>(`chapters/${id}/source`) + return result.source + } + + /** Deletes one owned book with its chapters and jobs; personal records stay. */ + async function deleteBook(id: number): Promise { + const version = generation + const owner = ownerId() + const result = await session.request<{ deleted: DeletionResult }>(`books/${id}`, 'DELETE') + if (!isStale(version, owner)) { + books.value = books.value.filter(item => item.id !== id) + if (book.value?.id === id) closeBook() + } + return result.deleted + } + + /** Deletes one owned chapter and closes the gap in the chapter order. */ + async function deleteChapter(id: number): Promise { + const version = generation + const owner = ownerId() + const result = await session.request<{ deleted: DeletionResult }>(`chapters/${id}`, 'DELETE') + if (isStale(version, owner)) return result.deleted + chapters.value = chapters.value.filter(item => item.id !== id) + if (book.value !== null) await loadBook(book.value.id, { silent: true }) + return result.deleted + } + /** The job id comes from the chapter itself, wherever that chapter was loaded from. */ function jobIdOf(chapterId: number): number | null { const target = chapters.value.find(item => item.id === chapterId) @@ -526,6 +615,7 @@ export const useLibraryStore = defineStore('library', () => { chapter, chapterBook, navigation, chapterLoading, chapterError, submitting, submitError, retryingChapterId, readerText, loadBooks, loadBook, loadChapter, submit, upload, retryChapter, + renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter, stopPolling, closeBook, closeChapter, reset, } }) diff --git a/learner/src/style.css b/learner/src/style.css index d7fe386..27eafc5 100644 --- a/learner/src/style.css +++ b/learner/src/style.css @@ -128,6 +128,8 @@ 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; } +.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; } .review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; } .review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; } diff --git a/learner/src/views/BookView.vue b/learner/src/views/BookView.vue index 6a7902f..d70f3e5 100644 --- a/learner/src/views/BookView.vue +++ b/learner/src/views/BookView.vue @@ -1,8 +1,8 @@