diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 04e3f57..c717154 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: 2ccdc108a21abe67ad77c05af59bb34bac304093 -synchronized_at: 2026-09-15T14:49:27Z +wiki_revision: 0bca600ea0dfcb18141920f448954bb9e2fa067c +synchronized_at: 2026-09-15T15:10:08Z # 架构与代码地图 @@ -478,3 +478,22 @@ schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS` 复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。 学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面,**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。 + +## #37 章节级音频与章节插图(2026-09-15) + +**schema v11**:`lexgo_books` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`,与 v10 的章节作者共用同一个条件加列助手 `addAuthorColumn`(先查 `information_schema`,缺列才 `ALTER TABLE`,在语句列表之后执行),新建库的 v3 建表语句也带该列。`BookUpdateInput` 增加可选的 `author`(省略则保留原值、空串即清空、≤120 字符、去首尾空白),`BookSummary`/`BookRef` 都返回它;书籍编辑接口因此从「重命名」变成「编辑书籍」(书名+作者)。 + +**schema v10**:`lexgo_chapters` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`。MySQL 没有 `ADD COLUMN IF NOT EXISTS`,因此这一列由 Go 侧的条件步骤 `addChapterAuthorColumn` 添加(先查 `information_schema`,缺列才执行 `ALTER TABLE`),并在语句列表执行**之后**运行,保证「部分迁移可重试」「回退标记后可重新升级」这两条既有性质仍然成立;新建库的 v3 语句里也直接带上该列。章节编辑接口接受 `author`(可选、去首尾空白、≤120 字符、空串即清空),`ChapterSummary` 与 `ChapterSource` 都返回它,阅读页在标题下显示非空的作者。 + +**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。 + +| 层 | 变化 | +|---|---| +| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 | +| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` | +| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) | +| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` | + +复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。 + +学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的书籍页把封面压成**一行紧凑控件**(`.cover-row`:预览 120px +「上传/替换封面」「移除」,不含标题、字段名与规格提示文字),把高度让给章节列表;标题行用 `.title-line` 把**书级作者**显示在书名右侧(`data-testid="book-author"`,未设置则不显示);「编辑书名」改为**「编辑书籍」**,对话框内用同一套 `.field-row` 同行排版编辑**书名与作者**。**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index b2f6dde..538e798 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: 77e1f1291a201744bdbb556ed1ef7d81cef2f969 -synchronized_at: 2026-09-15T14:49:28Z +wiki_revision: b688ef278f83c8055e16f91843a239f9ea2dd492 +synchronized_at: 2026-09-15T15:10:08Z # 业务规则与术语 @@ -394,3 +394,25 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺 **删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。 **范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。 + +## #37 章级附件规则(2026-09-15) + +**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。 + +**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。 + +**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。 + +**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。 + +**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 ``/``,令牌不出现在 URL 中。 + +**书籍作者与章节作者**:两者是**互相独立**的可选自由文本(各 ≤120 字符,各自去首尾空白,留空即清空):书级作者在书籍页显示于书名右侧、在「编辑书籍」里维护;章节作者在阅读页显示于章节标题下、在章节「编辑」里维护。**章节不会从书级继承作者**,字段也不参与归类、检索或权限判断(如需继承或检索,需要单独确认)。省略作者字段的编辑请求保留原值。 + +**章节作者**:作者是**可选自由文本**(≤120 字符),随章节标题与正文一起保存,去首尾空白,留空即清空;它只用于展示(阅读页在章节标题下显示),不参与归类、检索或权限判断,也不要求与书级数据一致。 + +**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题、作者与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。 + +**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。 + +**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 14897e3..aba0404 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: 92144cd570153b560180e195c49524fdb95a1048 -synchronized_at: 2026-09-15T14:49:28Z +wiki_revision: 8985f93f7319b2e6a428fc4417f353a7b909d797 +synchronized_at: 2026-09-15T15:10:08Z # 本地开发与验证 @@ -990,3 +990,69 @@ schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playbac - Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。 - 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。 - 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。 + +## #37 章级附件验证(2026-09-15) + +### 迁移 + +schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。 + +| 命令 | 结果 | +|---|---| +| `go vet ./...` / `gofmt -l` | 通过、无输出 | +| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) | +| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 | +| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 | +| 真实 API+MySQL | **52 项检查通过**(见下) | +| #15 恢复演练(第三次) | **22 项检查通过**(见下) | + +### 真实链路(52 项) + +用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。 + +### 恢复演练(第三次,22 项) + +带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。 + +### 插图交互修订(2026-09-15,按用户要求) + +书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。 + +### 插图交互修订(2026-09-15,按用户要求) + +书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。 + +### 附件并入章节编辑对话框(2026-09-15,按用户要求) + +章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。 + +### 对话框浮层缺陷修复(2026-09-15,用户发现) + +用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。 + +**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。 + +### 作者字段与编辑对话框紧凑化(2026-09-15,按用户要求) + +按用户要求:①「章节标题」标签与输入框同行,下面新增「作者」标签与输入框;②精简插图/音频的提示文字,把高度让给正文编辑框。 + +- **schema v10**:`lexgo_chapters` 增加 `author VARCHAR(120) NOT NULL DEFAULT ''`。由 Go 条件步骤 `addChapterAuthorColumn`(先查 `information_schema` 再 `ALTER TABLE`)在语句列表之后执行,保持「部分迁移可重试 / 回退标记后可重新升级」;新建库的 v3 语句也带该列。开发库迁移后 schema = 10,既有 34 个章节的 author 均为空串(无数据变化)。 +- **接口**:章节编辑接受 `author`(可选、trim、≤120 字符、空串清空),`ChapterSummary`/`ChapterSource` 都返回它;仅改作者不会重新处理章节(状态保持不变)。 +- **界面**:`.field-row` 把标签与输入框放同一行(标题、作者各一行);正文 textarea 提升到 18 行;插图与音频压成各一两行提示(规格写在下方一行);阅读页在标题下显示非空作者(`data-testid="chapter-author-line"`)。 +- **验证**:Go 89 项顶层用例(新增 `TestMySQLChapterAuthorRoundTrip` 与 `TestMigrationFromV9AddsChapterAuthor`,后者覆盖「旧库缺列 → 迁移补列 → 回退标记 → 重新升级」);学习端 157 单测与 26 项 E2E 通过;真实链路实测:标题标签与输入框中线偏差 <8px、作者行位于标题下方且标签同行、正文高度 415px(原约 250px)、保存作者后阅读页显示、清空后该行消失(截图 `issue37-real-editor-layout.png`、`issue37-real-reader-author.png`)。 + +### 书级作者与书籍页精简(2026-09-15,按用户要求) + +按用户要求:①书籍页去掉「封面」「书籍封面」「JPG…4096×4096」「音频与插图按章节设置…」四段文字,把空间让给章节列表;②「编辑书名」改为**「编辑书籍」**并可编辑**书名与作者**;③书名右侧显示当前书籍作者。 + +- **schema v11**:`lexgo_books.author VARCHAR(120) NOT NULL DEFAULT ''`,复用 v10 的条件加列助手(改名 `addAuthorColumn`,表名取自本文件常量)。开发库迁移后 schema = 11,既有 25 本书的 author 均为空串;回退件 `.local/lexgo-pre-issue37c.exe`(回退写回版本号 10 即可)。 +- **接口**:`PATCH /api/v1/books/:id` 接受可选 `author`(省略保留、空串清空、trim、≤120),`BookSummary`/`BookRef` 返回它;书籍编辑不再只改书名。 +- **界面**:封面区块变成一行(预览 + 上传/替换 + 移除);`.title-line` 在书名右侧显示作者(未设置不显示);「编辑书籍」对话框用同行标签编辑书名与作者。 +- **验证**:Go **91 项顶层用例**(新增 `TestMySQLBookAuthorRoundTrip` 与 `TestMigrationFromV10AddsBookAuthor`);学习端 157 单测与 26 项 E2E 通过;真实链路实测封面行高 **68px**、章节列表紧随其后(间距 18px)、四段被删文字都不再出现、「编辑书籍」对话框含作者输入、保存后作者显示在书名右侧(同一行、位于书名之后)并在刷新后保留、清空后标签消失(截图 `issue37-real-book-page.png`)。 + +### 未验证 + +- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。 +- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。 +- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。 +- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index 280b073..a66a78d 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: 1fc22dc050d51e4d4013d2a58b914f7b972116ca -synchronized_at: 2026-09-15T14:49:29Z +wiki_revision: 21ca050a33bdbe8133c921b6b302fbfc8d8cf7fe +synchronized_at: 2026-09-15T15:10:10Z # 产品需求总览 @@ -323,3 +323,9 @@ F 范围之外的 MVP 增补项「书籍音频与封面附件」已实现,待 用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);章节作者为可选自由文本(≤120 字符),阅读页在标题下显示;schema 为 v10;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。 **已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。 + +## #37 交付状态更新(2026-09-15) + +用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);**书籍作者**与**章节作者**是两份互相独立的可选自由文本(各 ≤120 字符,留空即清空),书级作者显示在书籍页书名右侧并由「编辑书籍」维护,章节作者显示在阅读页标题下;schema 为 v11;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。 + +**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。 diff --git a/docs/README.md b/docs/README.md index d056023..4e0fc4d 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: dd5bc8b5423219fcc90d772bafeb56845644d16b -synchronized_at: 2026-09-15T14:49:27Z +wiki_revision: da0f9a0feb70b7099edcf8adf50a18a15435dd33 +synchronized_at: 2026-09-15T15:10:07Z # LexGo 文档入口 @@ -98,3 +98,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn #37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、正文与本章插图、音频(标题与正文点保存后生效,文件选中后立即上传)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。 #37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。 + +#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页的封面压成一行紧凑控件(预览+上传/替换/移除),书名右侧显示书级作者,「编辑书籍」可改书名与作者,章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。 diff --git a/learner/e2e/attachments.spec.ts b/learner/e2e/attachments.spec.ts index 5f4fe29..b07ad25 100644 --- a/learner/e2e/attachments.spec.ts +++ b/learner/e2e/attachments.spec.ts @@ -105,7 +105,9 @@ test('the library shows the cover and the chapter dialog manages its files', asy await page.getByRole('link', { name: 'Fictional reader', exact: true }).click() await expect(page.getByTestId('attachments')).toBeVisible() - await expect(page.getByTestId('cover-state')).toContainText('PNG') + // The cover block is one compact row: preview plus actions, no headings or size hints. + await expect(page.getByTestId('cover-preview')).toBeVisible() + await expect(page.locator('.cover-row')).not.toContainText('像素不超过') await expect(page.locator('.chapter-meta')).toContainText('有音频') // The chapter row carries no image: it only has the audio meta and the attachment entry point. await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0) @@ -224,7 +226,8 @@ test('a book without attachments shows placeholders and no player', async ({ pag // The management sections stay: adding a file must be possible before one exists. await page.getByRole('link', { name: 'Fictional reader', exact: true }).click() await expect(page.getByTestId('attachments')).toBeVisible() - await expect(page.getByTestId('cover-state')).toContainText('未设置') + await expect(page.getByTestId('cover-preview')).toHaveCount(0) + await expect(page.getByTestId('cover-input')).toBeAttached() await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0) await page.getByTestId('edit-chapter-3').click() await expect(page.getByTestId('illustration-state')).toContainText('未设置') diff --git a/learner/e2e/edit.spec.ts b/learner/e2e/edit.spec.ts index 0eedad3..d5f7567 100644 --- a/learner/e2e/edit.spec.ts +++ b/learner/e2e/edit.spec.ts @@ -43,6 +43,7 @@ test('rename the book, edit a chapter into a new version and delete both', async 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 (body.author !== undefined) first.author = body.author if (changed) { firstText = body.text as string processing = true @@ -70,11 +71,12 @@ test('rename the book, edit a chapter into a new version and delete both', async // Rename the book through the dialog. await page.getByTestId('edit-book').click() + await expect(page.getByRole('dialog', { name: '编辑书籍' })).toBeVisible() await expectModalOverlay(page, page.getByTestId('book-dialog')) 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.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. diff --git a/learner/src/__tests__/attachments.spec.ts b/learner/src/__tests__/attachments.spec.ts index 33dd204..4c3ef5e 100644 --- a/learner/src/__tests__/attachments.spec.ts +++ b/learner/src/__tests__/attachments.spec.ts @@ -12,7 +12,7 @@ import { useSessionStore } from '../stores/session' const user = { id: 42, username: 'fictional-attachment', role: 'learner' as const } const cover = { kind: 'cover' as const, mime: 'image/png', byteSize: 1234, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' } -const book = { id: 7, title: 'Fictional reader', language: 'en' } +const book = { id: 7, title: 'Fictional reader', author: '', language: 'en' } const pngBytes = new Uint8Array([1, 2, 3, 4]) const baseChapter = { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready' as const, charCount: 31, @@ -149,7 +149,13 @@ describe('chapter attachments on the book page', () => { it('shows the cover and the chapter audio state without any chapter image', async () => { const { fetchMock } = mockApi() const { view } = await mountWith(BookView, '/books/7') - expect(view.get('[data-testid="cover-state"]').text()).toContain('PNG') + // The cover block is one compact row now: preview and actions, no headings or hints. + await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy()) + await flushPromises() + expect(view.find('[data-testid="cover-preview"]').exists()).toBe(true) + expect(view.find('[data-testid="cover-input"]').exists()).toBe(true) + expect(view.text()).not.toContain('书籍封面') + expect(view.text()).not.toContain('像素不超过') await flushPromises() // The chapter list shows no illustration and does not fetch one. expect(view.find('[data-testid="chapter-thumb-3"]').exists()).toBe(false) diff --git a/learner/src/__tests__/edit.spec.ts b/learner/src/__tests__/edit.spec.ts index 8871e37..410f06a 100644 --- a/learner/src/__tests__/edit.spec.ts +++ b/learner/src/__tests__/edit.spec.ts @@ -8,7 +8,7 @@ import { useLibraryStore, type ChapterSource, type ChapterSummary } from '../sto 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 book = { id: 1, title: 'A small step', author: '', 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', author: '', status: 'ready', charCount: 12, @@ -49,8 +49,8 @@ function mockApi(overrides: Record Response | Pr 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 } }) + const body = JSON.parse(String((init as RequestInit | undefined)?.body ?? '{}')) as { title?: string; author?: string } + return ok({ book: { ...book, title: body.title ?? book.title, author: body.author ?? book.author } }) } if (url.endsWith('/books/1') && method === 'DELETE') return ok({ deleted: { bookId: 1, chapters: 3 } }) if (url.endsWith('/books/1')) return ok({ book }) @@ -71,24 +71,26 @@ describe('book editing store', () => { beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() }) afterEach(() => { vi.restoreAllMocks() }) - it('renames a book and keeps the list entry in step', async () => { + it('saves the title and the author 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 ') + const renamed = await library.updateBook(1, { title: ' New name ', author: ' Fictional author ' }) expect(renamed.title).toBe('New name') + expect(renamed.author).toBe('Fictional author') expect(library.book?.title).toBe('New name') expect(library.books[0]?.title).toBe('New name') + expect(library.books[0]?.author).toBe('Fictional author') 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' }) + expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ title: 'New name', author: 'Fictional author' }) }) it('refuses an invalid title before calling the API', async () => { const fetchMock = mockApi() const library = useLibraryStore() - await expect(library.renameBook(1, ' ')).rejects.toThrow('请填写标题') + await expect(library.updateBook(1, { title: ' ', author: '' })).rejects.toThrow('请填写标题') expect(fetchMock).not.toHaveBeenCalled() }) @@ -155,10 +157,14 @@ describe('book editing view', () => { const input = view.get('#book-title') expect((input.element as HTMLInputElement).value).toBe('A small step') await input.setValue(' Edited name ') + // The same dialog also carries the book author. + expect(view.find('[data-testid="book-author-input"]').exists()).toBe(true) + await view.get('[data-testid="book-author-input"]').setValue(' Fictional author ') 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('书名已更新') + expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited name', author: 'Fictional author' }) + expect(view.get('[data-testid="book-notice"]').text()).toContain('书籍信息已更新') + expect(view.get('[data-testid="book-author"]').text()).toBe('Fictional author') }) it('keeps the dialog open with the server message when renaming fails', async () => { diff --git a/learner/src/__tests__/library.spec.ts b/learner/src/__tests__/library.spec.ts index 7d1cabf..a2f7cec 100644 --- a/learner/src/__tests__/library.spec.ts +++ b/learner/src/__tests__/library.spec.ts @@ -13,7 +13,7 @@ import { useSessionStore } from '../stores/session' // All accounts, books and texts in these tests are deliberately fictitious. const user = { id: 7, username: 'fictional-reader', role: 'learner' as const } -const book = { id: 1, title: '虚构样例书', language: 'en' } +const book = { id: 1, title: '虚构样例书', author: '', language: 'en' } const navigation = { previousChapterId: null, nextChapterId: null } const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 }) diff --git a/learner/src/__tests__/progress.spec.ts b/learner/src/__tests__/progress.spec.ts index 2e20ca7..780d73d 100644 --- a/learner/src/__tests__/progress.spec.ts +++ b/learner/src/__tests__/progress.spec.ts @@ -175,7 +175,7 @@ describe('library completion action', () => { const url = String(input) if (url.includes('/chapters/3') && !url.includes('complete')) { return ok({ - book: { id: 7, title: 'Fictional reader', language: 'en' }, + book: { id: 7, title: 'Fictional reader', author: '', language: 'en' }, chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null }, }) @@ -226,7 +226,7 @@ describe('library completion action', () => { if (url.includes('/tokens')) return ok({ tokens: [] }) if (url.includes('/chapters/3')) { return ok({ - book: { id: 7, title: 'Fictional reader', language: 'en' }, + book: { id: 7, title: 'Fictional reader', author: '', language: 'en' }, chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null }, }) diff --git a/learner/src/stores/library.ts b/learner/src/stores/library.ts index a792187..baa4bef 100644 --- a/learner/src/stores/library.ts +++ b/learner/src/stores/library.ts @@ -33,6 +33,8 @@ export interface ChapterAttachment { export interface BookRef { id: number title: string + /** Optional free text for the book itself; chapters carry their own author. */ + author: string language: string attachments?: BookAttachments | null } @@ -520,17 +522,22 @@ export const useLibraryStore = defineStore('library', () => { } } - /** Renames one owned book; the reply is the stored book. */ - async function renameBook(id: number, title: string): Promise { + /** Saves the title (and the optional author) of one owned book; the reply is the stored book. */ + async function updateBook(id: number, input: { title: string; author: string }): Promise { const version = generation const owner = ownerId() - const problem = titleProblem(title) + const problem = titleProblem(input.title) if (problem) throw new Error(problem) - const result = await session.request<{ book: BookRef }>(`books/${id}`, 'PATCH', { title: title.trim() }) + const result = await session.request<{ book: BookRef }>(`books/${id}`, 'PATCH', { + title: input.title.trim(), author: input.author.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 + if (listed) { + listed.title = result.book.title + listed.author = result.book.author + } } return result.book } @@ -943,7 +950,7 @@ export const useLibraryStore = defineStore('library', () => { uploadCover, deleteCover, uploadChapterFile, deleteChapterFile, saveChapterPlayback, reportChapterPlayback, uploadingKind, uploadingId, coverUrls, illustrationUrls, audioUrl, audioChapterId, loadCover, loadChapterIllustration, loadChapterAudio, - renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter, + updateBook, updateChapter, loadChapterSource, deleteBook, deleteChapter, stopPolling, closeBook, closeChapter, reset, } }) diff --git a/learner/src/style.css b/learner/src/style.css index b10c452..56dac91 100644 --- a/learner/src/style.css +++ b/learner/src/style.css @@ -354,6 +354,18 @@ button:focus-visible, a:focus-visible, [role='button']:focus-visible, .illustration-thumb:focus-visible { outline: 3px solid var(--accent-marker); outline-offset: 3px; } .illustration-hint { font-size: 12px; color: var(--text-muted); } .illustration-full { display: block; max-width: min(88vw, 1200px); max-height: 78vh; width: auto; height: auto; object-fit: contain; } +/* The book page keeps the cover control to one compact row so the chapter list gets the space. */ +.cover-row { display: flex; align-items: center; gap: 14px; margin: 0 0 18px; } +.cover-preview { flex: 0 0 120px; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--accent-soft); } +.cover-preview img { width: 100%; height: 100%; object-fit: cover; display: block; } +.cover-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.cover-actions input[type='file'] { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; } +.cover-actions label { position: relative; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 20px; padding: 0 14px; background: var(--surface-raised); } +.cover-actions label.is-busy { opacity: 0.6; cursor: progress; } +/* The book title shows its author next to it. */ +.title-line { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } +.title-line h1 { margin: 14px 0 6px; } +.book-author { color: var(--text-muted); font-size: 15px; } /* Book covers: the card shows the stored image or a default block built from the title. */ .book-cover { display: block; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--accent-soft); text-decoration: none; } .book-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } diff --git a/learner/src/views/BookView.vue b/learner/src/views/BookView.vue index 320c417..a88cb83 100644 --- a/learner/src/views/BookView.vue +++ b/learner/src/views/BookView.vue @@ -16,6 +16,7 @@ const notice = ref('') // Renaming the book. const bookDialog = ref(false) const bookTitle = ref('') +const bookAuthor = ref('') const bookError = ref('') // Editing one chapter: title and text, loaded from the source endpoint. const chapterDialog = ref(false) @@ -214,6 +215,7 @@ async function retry(chapterId: number) { function openBookDialog() { bookTitle.value = library.book?.title ?? '' + bookAuthor.value = library.book?.author ?? '' bookError.value = '' bookDialog.value = true } @@ -224,9 +226,9 @@ async function saveBookTitle() { if (bookError.value) return saving.value = true try { - await library.renameBook(library.book.id, bookTitle.value) + await library.updateBook(library.book.id, { title: bookTitle.value, author: bookAuthor.value }) bookDialog.value = false - notice.value = '书名已更新。' + notice.value = '书籍信息已更新。' } catch (reason) { bookError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。' } finally { @@ -339,37 +341,31 @@ onUnmounted(() => library.closeBook()) - {{ library.book.title }} + + {{ library.book.title }} + {{ library.book.author }} + {{ library.chapters.length }} 个章节 · 语言 英语 - 编辑书名 + 编辑书籍 追加章节 删除书籍 {{ notice }} - - 封面 - - - - {{ library.book.title.slice(0, 1) }} - - - 书籍封面 - {{ coverState }} - {{ coverError }} - - - {{ library.book.attachments?.cover ? '替换封面' : '上传封面' }} - - - 移除 - - JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096。 - 音频与插图按章节设置,见下方章节列表的「附件」。 - + + + + {{ library.book.title.slice(0, 1) }} + + + + {{ library.book.attachments?.cover ? '替换封面' : '上传封面' }} + + + 移除 + {{ coverError }} {{ retryError }} @@ -402,10 +398,15 @@ onUnmounted(() => library.closeBook()) - - 书名 - - 封面使用系统默认样式。 + + + 书名 + + + + 作者 + + {{ bookError }} 取消 diff --git a/server/app/lexgo/database.go b/server/app/lexgo/database.go index 888573a..694b580 100644 --- a/server/app/lexgo/database.go +++ b/server/app/lexgo/database.go @@ -100,12 +100,17 @@ func Migrate(db *gorm.DB) error { return fmt.Errorf("migration statement %d failed", i+1) } } + // The author columns are added conditionally after the statements above: MySQL has no + // ADD COLUMN IF NOT EXISTS, an older database only gains its book and chapter tables in those + // statements, and the version row is still advanced only once everything succeeded, so a + // partially applied or rolled-back migration can be replayed. if current < 10 { - // v10 adds the chapter author. MySQL has no ADD COLUMN IF NOT EXISTS, so the column is added - // conditionally: it has to run after the statements above, because an older database only - // gains its chapter table there, and the version row is still only advanced once everything - // succeeded, so a rolled-back marker can be upgraded again. - if err = addChapterAuthorColumn(ctx, conn); err != nil { + if err = addAuthorColumn(ctx, conn, "lexgo_chapters"); err != nil { + return err + } + } + if current < 11 { + if err = addAuthorColumn(ctx, conn, "lexgo_books"); err != nil { return err } } @@ -115,20 +120,21 @@ func Migrate(db *gorm.DB) error { // SchemaVersion is the version an explicit migration leaves behind, and the // version the server requires before it starts. -const SchemaVersion = 10 +const SchemaVersion = 11 -// addChapterAuthorColumn adds lexgo_chapters.author when it is missing, so replaying the migration -// stays safe just like the CREATE TABLE IF NOT EXISTS statements around it. -func addChapterAuthorColumn(ctx context.Context, conn *sql.Conn) error { +// addAuthorColumn adds an optional author column when it is missing, so replaying the migration +// stays safe just like the CREATE TABLE IF NOT EXISTS statements around it. The table name is a +// constant from this file, never user input. +func addAuthorColumn(ctx context.Context, conn *sql.Conn, table string) error { var exists int if err := conn.QueryRowContext(ctx, - "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lexgo_chapters' AND COLUMN_NAME = 'author'").Scan(&exists); err != nil { + "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'author'", table).Scan(&exists); err != nil { return err } if exists > 0 { return nil } - _, err := conn.ExecContext(ctx, "ALTER TABLE lexgo_chapters ADD COLUMN author VARCHAR(120) NOT NULL DEFAULT ''") + _, err := conn.ExecContext(ctx, "ALTER TABLE `"+table+"` ADD COLUMN author VARCHAR(120) NOT NULL DEFAULT ''") return err } @@ -313,7 +319,8 @@ var schemaV2Statements = []string{ var schemaV3Statements = []string{ `CREATE TABLE IF NOT EXISTS lexgo_books ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL, - title VARCHAR(120) NOT NULL, language VARCHAR(16) NOT NULL DEFAULT 'en', + title VARCHAR(120) NOT NULL, author VARCHAR(120) NOT NULL DEFAULT '', + language VARCHAR(16) NOT NULL DEFAULT 'en', created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, INDEX (owner_id, updated_at, id), FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE diff --git a/server/app/lexgo/edit.go b/server/app/lexgo/edit.go index 9ba0e15..345e492 100644 --- a/server/app/lexgo/edit.go +++ b/server/app/lexgo/edit.go @@ -17,7 +17,8 @@ import ( // review rows or answers: those belong to the learner, not to a book or a chapter. type BookUpdateInput struct { - Title string `json:"title"` + Title string `json:"title"` + Author *string `json:"author"` } type ChapterUpdateInput struct { @@ -98,9 +99,20 @@ func RenameBook(tx *gorm.DB, owner int, bookID int64, input BookUpdateInput, now return BookRef{}, err } book.Title = title - book.UpdatedAt = stamp(now) + ts := stamp(now) + updates := map[string]any{"title": title, "updated_at": ts} + // The author is optional; omitting it leaves whatever the book already had. + if input.Author != nil { + author, err := editAuthor(*input.Author) + if err != nil { + return BookRef{}, err + } + book.Author = author + updates["author"] = author + } + book.UpdatedAt = ts if err = tx.Model(&Book{}).Where("id = ? AND owner_id = ?", book.ID, owner). - Updates(map[string]any{"title": title, "updated_at": book.UpdatedAt}).Error; err != nil { + Updates(updates).Error; err != nil { return BookRef{}, err } return bookRef(book), nil diff --git a/server/app/lexgo/edit_test.go b/server/app/lexgo/edit_test.go index 350c3cd..e7ae360 100644 --- a/server/app/lexgo/edit_test.go +++ b/server/app/lexgo/edit_test.go @@ -626,3 +626,96 @@ func TestMySQLChapterAuthorRoundTrip(t *testing.T) { t.Fatal("an empty edit must still be rejected") } } + +// TestMySQLBookAuthorRoundTrip covers the book author: it is saved with the title, list and detail +// carry it, it is trimmed and capped, and an empty author clears it. +func TestMySQLBookAuthorRoundTrip(t *testing.T) { + db, r, owner := libraryFixture(t) + learner := newLearner(t, r, owner.Token) + code, pasted := pasteBook(t, r, learner.Token, map[string]string{ + "requestId": "book-author-0001", "title": "Fictional book author", "text": "Curiosity opens the first door." + string(rune(10)), "language": "en"}) + if code != 201 { + t.Fatalf("paste %d", code) + } + drainIngest(t, db) + bookID := pasted.Chapter.BookID + + // A new book has no author. + book, _ := bookDetailOf(t, r, learner.Token, bookID) + if book.Author != "" { + t.Fatalf("a new book must have no author, got %q", book.Author) + } + + // The title and the author are saved together, and the author is trimmed. + code, msg, data := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token, + map[string]any{"title": "Fictional book author", "author": " 虚构书籍作者 "}) + if code != 200 { + t.Fatalf("book edit: %d %s", code, msg) + } + var edited struct { + Book BookRef + } + if err := json.Unmarshal(data, &edited); err != nil { + t.Fatal(err) + } + if edited.Book.Author != "虚构书籍作者" || edited.Book.Title != "Fictional book author" { + t.Fatalf("book edit result: %+v", edited.Book) + } + code, _, listed := callRaw(t, r, "GET", "/api/v1/books", learner.Token, nil) + if code != 200 { + t.Fatalf("book list %d", code) + } + var page struct { + Items []BookSummary + } + if err := json.Unmarshal(listed, &page); err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Items[0].Author != "虚构书籍作者" { + t.Fatalf("the book list must carry the author: %+v", page.Items) + } + book, _ = bookDetailOf(t, r, learner.Token, bookID) + if book.Author != "虚构书籍作者" { + t.Fatalf("the book detail must carry the author: %+v", book) + } + // The reader response carries the book too, so a chapter page can show it if needed. + code, _, reader := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil) + if code != 200 { + t.Fatalf("reader %d", code) + } + var read struct { + Book BookRef + } + if err := json.Unmarshal(reader, &read); err != nil { + t.Fatal(err) + } + if read.Book.Author != "虚构书籍作者" { + t.Fatalf("the reader response must carry the book author: %+v", read.Book) + } + + // Omitting the author keeps it, a blank author clears it, and the length is capped. + if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token, + map[string]any{"title": "Fictional book author"}); code != 200 { + t.Fatal("a title-only edit must be accepted") + } + if book, _ = bookDetailOf(t, r, learner.Token, bookID); book.Author != "虚构书籍作者" { + t.Fatalf("omitting the author must keep it, got %q", book.Author) + } + if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token, + map[string]any{"title": "Fictional book author", "author": " "}); code != 200 { + t.Fatal("clearing the author must be allowed") + } + if book, _ = bookDetailOf(t, r, learner.Token, bookID); book.Author != "" { + t.Fatalf("a blank author must clear the field, got %q", book.Author) + } + if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token, + map[string]any{"title": "Fictional book author", "author": strings.Repeat("a", 121)}); code != 400 { + t.Fatal("an over-long author must be rejected") + } + // Ownership still applies. + other := newLearner(t, r, owner.Token) + if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), other.Token, + map[string]any{"title": "stolen", "author": "stolen"}); code != 404 { + t.Fatal("another account must not edit the book") + } +} diff --git a/server/app/lexgo/library.go b/server/app/lexgo/library.go index 04c0249..12129e0 100644 --- a/server/app/lexgo/library.go +++ b/server/app/lexgo/library.go @@ -67,9 +67,11 @@ func reasonMessage(reason string) string { } type Book struct { - ID int64 `gorm:"primaryKey"` - OwnerID int - Title string + ID int64 `gorm:"primaryKey"` + OwnerID int + Title string + // Author is optional free text for the book itself; a chapter has its own author. + Author string Language string CreatedAt time.Time UpdatedAt time.Time @@ -116,6 +118,7 @@ func (IngestJob) TableName() string { return "lexgo_ingest_jobs" } type BookSummary struct { ID int64 `json:"id"` Title string `json:"title"` + Author string `json:"author"` Language string `json:"language"` ChapterCount int `json:"chapterCount"` PendingCount int `json:"pendingCount"` @@ -132,6 +135,7 @@ type BookSummary struct { type BookRef struct { ID int64 `json:"id"` Title string `json:"title"` + Author string `json:"author"` Language string `json:"language"` // Attachments is filled by the endpoints that show a book, so a cover can be rendered from the // list without a request per row. @@ -139,7 +143,7 @@ type BookRef struct { } func bookRef(book Book) BookRef { - return BookRef{ID: book.ID, Title: book.Title, Language: book.Language} + return BookRef{ID: book.ID, Title: book.Title, Author: book.Author, Language: book.Language} } type ChapterSummary struct { @@ -501,7 +505,7 @@ func ListBooks(db *gorm.DB, owner int) ([]BookSummary, error) { items := make([]BookSummary, 0, len(books)) ids := make([]int64, 0, len(books)) for _, b := range books { - items = append(items, BookSummary{ID: b.ID, Title: b.Title, Language: b.Language, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt}) + items = append(items, BookSummary{ID: b.ID, Title: b.Title, Author: b.Author, Language: b.Language, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt}) ids = append(ids, b.ID) } if len(ids) == 0 { diff --git a/server/app/lexgo/migration_test.go b/server/app/lexgo/migration_test.go index 576e993..eeb8566 100644 --- a/server/app/lexgo/migration_test.go +++ b/server/app/lexgo/migration_test.go @@ -727,3 +727,65 @@ func TestMigrationFromV9AddsChapterAuthor(t *testing.T) { t.Fatalf("re-upgrade must keep the author, got %q", restored.Author) } } + +func TestMigrationFromV10AddsBookAuthor(t *testing.T) { + db := emptyMigrationDB(t) + statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,10,'lexgo')"} + statements = append(statements, schemaV2Statements...) + statements = append(statements, schemaV3Statements...) + statements = append(statements, schemaV4Statements...) + statements = append(statements, schemaV5Statements...) + statements = append(statements, schemaV6Statements...) + statements = append(statements, schemaV7Statements...) + statements = append(statements, schemaV8Statements...) + statements = append(statements, schemaV9Statements...) + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + // Model the database v10 left behind: a book table without the author column. + if err := db.Exec("ALTER TABLE lexgo_books DROP COLUMN author").Error; err == nil { + t.Log("dropped the author column created by the v3 statements") + } + if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (61,'fixture_v10','fictional-not-a-real-hash',2)").Error; err != nil { + t.Fatal(err) + } + saved := stamp(time.Now()) + if err := db.Exec("INSERT INTO lexgo_books (id, owner_id, title, language, created_at, updated_at) VALUES (?,?,?,?,?,?)", + 900, 61, "Fictional v10 book", "en", saved, saved).Error; err != nil { + t.Fatal(err) + } + if err := CheckSchema(db); err == nil { + t.Fatal("old schema accepted before explicit migration") + } + if err := Migrate(db); err != nil { + t.Fatal(err) + } + if err := CheckSchema(db); err != nil { + t.Fatal(err) + } + var book Book + if err := db.Where("id = ?", 900).First(&book).Error; err != nil { + t.Fatal(err) + } + if book.Author != "" || book.Title != "Fictional v10 book" { + t.Fatalf("migration changed the book: %+v", book) + } + if err := db.Exec("UPDATE lexgo_books SET author = ? WHERE id = ?", "虚构书籍作者", 900).Error; err != nil { + t.Fatal(err) + } + // Replaying the migration must not fail on the column that is already there. + if err := db.Exec("UPDATE lexgo_schema SET version=10 WHERE id=1").Error; err != nil { + t.Fatal(err) + } + if err := Migrate(db); err != nil { + t.Fatal(err) + } + if err := db.Where("id = ?", 900).First(&book).Error; err != nil { + t.Fatal(err) + } + if book.Author != "虚构书籍作者" { + t.Fatalf("re-upgrade must keep the book author, got %q", book.Author) + } +}
{{ library.chapters.length }} 个章节 · 语言 英语
{{ notice }}
书籍封面
{{ coverState }}
{{ coverError }}
JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096。
音频与插图按章节设置,见下方章节列表的「附件」。
{{ retryError }}
封面使用系统默认样式。
{{ bookError }}