feat: 书籍页精简封面、可编辑书名与作者、标题旁显示作者 (#37)

- schema v11:lexgo_books 增加 author VARCHAR(120) NOT NULL DEFAULT '',复用 v10 的条件加列
  助手(改名 addAuthorColumn,表名取自本文件常量),保持迁移可重放;新建库 v3 语句也带该列
- PATCH /api/v1/books/:id 接受可选 author(省略保留、空串清空、trim、≤120),BookSummary 与
  BookRef 返回它;「编辑书名」改为「编辑书籍」,对话框同行编辑书名与作者
- 书籍页封面压成一行紧凑控件(预览+上传/替换/移除),去掉「封面」「书籍封面」规格与
  「音频与插图按章节设置」等文字,把高度让给章节列表;书名右侧显示书级作者(未设置不显示)
- 测试:Go 91 项(新增书级作者往返与 v10→v11 迁移用例)、学习端 157 单测与 26 项 E2E
- 真实链路:封面行高 68px、删掉的四段文字不再出现、作者显示在书名同一行右侧且刷新后保留
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
This commit was merged in pull request #38.
This commit is contained in:
ila
2026-09-15 23:10:36 +08:00
parent 3fe9bcb058
commit 728f7d6c1e
19 changed files with 412 additions and 82 deletions
+21 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -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 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
+24 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**书籍作者与章节作者**:两者是**互相独立**的可选自由文本(各 ≤120 字符,各自去首尾空白,留空即清空):书级作者在书籍页显示于书名右侧、在「编辑书籍」里维护;章节作者在阅读页显示于章节标题下、在章节「编辑」里维护。**章节不会从书级继承作者**,字段也不参与归类、检索或权限判断(如需继承或检索,需要单独确认)。省略作者字段的编辑请求保留原值。
**章节作者**:作者是**可选自由文本**(≤120 字符),随章节标题与正文一起保存,去首尾空白,留空即清空;它只用于展示(阅读页在章节标题下显示),不参与归类、检索或权限判断,也不要求与书级数据一致。
**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题、作者与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
+68 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -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 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 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 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 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 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
+8 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -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。
+4 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 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 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
+5 -2
View File
@@ -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('未设置')
+3 -1
View File
@@ -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.
+8 -2
View File
@@ -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)
+15 -9
View File
@@ -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> = {}): ChapterSummary => ({
id: 9, bookId: 1, ordinal: 1, title: 'First chapter', author: '', status: 'ready', charCount: 12,
@@ -49,8 +49,8 @@ function mockApi(overrides: Record<string, (init?: RequestInit) => 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 () => {
+1 -1
View File
@@ -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 })
+2 -2
View File
@@ -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 },
})
+13 -6
View File
@@ -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<BookRef> {
/** 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<BookRef> {
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,
}
})
+12
View File
@@ -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; }
+30 -29
View File
@@ -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())
<template v-else-if="library.book">
<div class="page-title">
<div>
<h1>{{ library.book.title }}</h1>
<div class="title-line">
<h1>{{ library.book.title }}</h1>
<span v-if="library.book.author" class="book-author" data-testid="book-author">{{ library.book.author }}</span>
</div>
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语</p>
</div>
<div class="page-actions">
<ElButton data-testid="edit-book" @click="openBookDialog">编辑书名</ElButton>
<ElButton data-testid="edit-book" @click="openBookDialog">编辑书籍</ElButton>
<RouterLink :to="`/import?book=${library.book.id}`" class="link-button">追加章节</RouterLink>
<ElButton type="danger" plain data-testid="delete-book" @click="confirmDeleteBook">删除书籍</ElButton>
</div>
</div>
<p v-if="notice" role="status" class="chapter-notice" data-testid="book-notice">{{ notice }}</p>
<section class="attachments" aria-label="封面与章节附件" data-testid="attachments">
<h2>封面</h2>
<div class="attachment-row">
<div class="attachment-preview">
<img v-if="library.coverUrls[library.book.id]" :src="library.coverUrls[library.book.id]" alt="当前封面" data-testid="cover-preview" />
<span v-else class="book-cover-default" aria-hidden="true">{{ library.book.title.slice(0, 1) }}</span>
</div>
<div class="attachment-info">
<p class="attachment-label">书籍封面</p>
<p class="subtle" data-testid="cover-state">{{ coverState }}</p>
<p v-if="coverError" role="alert" class="field-error">{{ coverError }}</p>
<div class="attachment-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'cover' }">
{{ library.book.attachments?.cover ? '替换封面' : '上传封面' }}
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="cover-input" :disabled="library.uploadingKind !== null" @change="onCoverPicked" />
</label>
<ElButton v-if="library.book.attachments?.cover" size="small" :disabled="library.uploadingKind !== null" data-testid="cover-remove" @click="removeCover">移除</ElButton>
</div>
<p class="subtle">JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096。</p>
<p class="subtle">音频与插图按章节设置,见下方章节列表的「附件」。</p>
</div>
<section class="cover-row" aria-label="书籍封面" data-testid="attachments">
<div class="cover-preview">
<img v-if="library.coverUrls[library.book.id]" :src="library.coverUrls[library.book.id]" alt="当前封面" data-testid="cover-preview" />
<span v-else class="book-cover-default" aria-hidden="true">{{ library.book.title.slice(0, 1) }}</span>
</div>
<div class="cover-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'cover' }">
{{ library.book.attachments?.cover ? '替换封面' : '上传封面' }}
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="cover-input" :disabled="library.uploadingKind !== null" @change="onCoverPicked" />
</label>
<ElButton v-if="library.book.attachments?.cover" size="small" text :disabled="library.uploadingKind !== null" data-testid="cover-remove" @click="removeCover">移除</ElButton>
<span v-if="coverError" role="alert" class="field-error">{{ coverError }}</span>
</div>
</section>
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
@@ -402,10 +398,15 @@ onUnmounted(() => library.closeBook())
</section>
</template>
<ElDialog v-model="bookDialog" title="编辑书名" width="420" data-testid="book-dialog">
<label for="book-title">书名</label>
<ElInput id="book-title" v-model="bookTitle" type="text" maxlength="200" :disabled="saving" />
<p class="subtle">封面使用系统默认样式。</p>
<ElDialog v-model="bookDialog" title="编辑书籍" width="460" data-testid="book-dialog">
<div class="field-row">
<label for="book-title">书名</label>
<ElInput id="book-title" v-model="bookTitle" type="text" maxlength="200" :disabled="saving" />
</div>
<div class="field-row">
<label for="book-author">作者</label>
<ElInput id="book-author" v-model="bookAuthor" type="text" maxlength="120" placeholder="可留空" :disabled="saving" data-testid="book-author-input" />
</div>
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
<template #footer>
<ElButton @click="bookDialog = false">取消</ElButton>
+19 -12
View File
@@ -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
+15 -3
View File
@@ -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
+93
View File
@@ -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")
}
}
+9 -5
View File
@@ -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 {
+62
View File
@@ -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)
}
}