diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 10501e3..c39538a 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: b9666699e8f01a117412b0b65880b0402b1bef0c -synchronized_at: 2026-09-15T13:35:21Z +wiki_revision: c75593d72432e5195ea84c766a81449ccb91f648 +synchronized_at: 2026-09-15T14:13:31Z # 架构与代码地图 @@ -446,3 +446,18 @@ 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="attachment-dialog"`:插图与音频各自的预览、状态、上传/替换/移除与规格提示),章节列表行新增缩略图列与「附件」按钮;`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订)。 + +## #37 章节级音频与章节插图(2026-09-15) + +**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` 的封面区块收窄为封面,**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑标题、正文、插图与音频(插图/音频区块带预览、状态、上传/替换/移除与规格提示,并明确写出「标题与正文点保存后生效,文件选中后立即上传」),`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 175322c..ee60c5c 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: a226a6026e8415d6f2981fc87b52396f30ab3708 -synchronized_at: 2026-09-15T13:35:21Z +wiki_revision: 081daac73fa8d172a477d196e964382735122466 +synchronized_at: 2026-09-15T14:13:31Z # 业务规则与术语 @@ -356,3 +356,21 @@ 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 中。 + +**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。 + +**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。 + +**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 3f6bf01..e735b9f 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: 0550efcfd3844fb0af2dd7e59471f24f444eadf0 -synchronized_at: 2026-09-15T13:35:21Z +wiki_revision: 5459d79e2bf6339450af5add58a61ef22b0467ea +synchronized_at: 2026-09-15T14:13:31Z # 本地开发与验证 @@ -843,3 +843,45 @@ 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`)。 + +### 未验证 + +- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。 +- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。 +- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。 +- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index 3572586..bc2c487 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: a54b9c33ece05a90cc28254273777dff9388167b -synchronized_at: 2026-09-15T13:35:22Z +wiki_revision: 5ecd946c6731afbbb87f0ab3d68988583fa4e2bb +synchronized_at: 2026-09-15T14:13:33Z # 产品需求总览 @@ -311,3 +311,9 @@ F 范围之外的 MVP 增补项「书籍音频与封面附件」已实现,待 用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页每个章节行显示插图缩略图与「附件」入口,附件对话框与正文编辑分开;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。 **已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。 + +## #37 交付状态更新(2026-09-15) + +用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、正文、插图与音频在同一对话框内编辑(标题与正文点保存后生效,文件选中后立即上传);播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(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 f5d5385..e8bea15 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: 6ff5329b98016970310da9f8f6d2c5e821e39714 -synchronized_at: 2026-09-15T13:35:21Z +wiki_revision: 7a96206070fbd799d76a12ec07dd4520c2be1a81 +synchronized_at: 2026-09-15T14:13:31Z # LexGo 文档入口 @@ -94,3 +94,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 6963acd..f9b1846 100644 --- a/learner/e2e/attachments.spec.ts +++ b/learner/e2e/attachments.spec.ts @@ -80,6 +80,10 @@ async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean } }) return json({ textSha256: 'sha', tokens }) } + if (path === '/api/v1/chapters/3/source') { + return json({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: chapterText, status: 'ready', contentSha256: 'sha', charCount: chapterText.length } }) + } + if (path === '/api/v1/chapters/3' && method === 'PATCH') return json({ chapter: chapterFiles(), job: null, versionChanged: false }) if (path === '/api/v1/chapters/3') return json({ book: { ...book, attachments: attachments() }, chapter: { ...chapterFiles(), contentSha256: 'sha', originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } }) return json({}) }) @@ -105,11 +109,14 @@ test('the library shows the cover and the chapter dialog manages its files', asy // 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) await expect(page.locator('.chapter-thumb')).toHaveCount(0) - await expect(page.getByTestId('chapter-attachments-3')).toHaveText('附件') + // One entry point per row: the editor holds the title, the text and both files. + await expect(page.getByTestId('chapter-attachments-3')).toHaveCount(0) await page.screenshot({ path: '../.local/evidence/issue37-chapter-row.png' }) - await page.getByTestId('chapter-attachments-3').click() - await expect(page.getByTestId('attachment-dialog')).toBeVisible() + await page.getByTestId('edit-chapter-3').click() + await expect(page.getByTestId('chapter-dialog')).toBeVisible() + await expect(page.locator('#chapter-title')).toBeVisible() + await expect(page.getByTestId('attachment-timing')).toContainText('立即上传') await expect(page.getByTestId('illustration-state')).toContainText('已设置') await expect(page.getByTestId('chapter-audio-state')).toContainText('已设置') await page.screenshot({ path: '../.local/evidence/issue37-attachment-dialog.png' }) @@ -123,7 +130,7 @@ test('the library shows the cover and the chapter dialog manages its files', asy // A file of the wrong type is refused in the page, before any request. await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'song.wav', mimeType: 'audio/wav', buffer: Buffer.from('RIFF....') }) - await expect(page.locator('[data-testid="attachment-dialog"] .field-error')).toContainText('音频只支持 MP3') + await expect(page.locator('[data-testid="chapter-dialog"] .field-error')).toContainText('音频只支持 MP3') expect(calls.posted).toEqual(['illustration', 'audio']) // Removing the audio asks first and then reports the empty state. @@ -215,8 +222,7 @@ test('a book without attachments shows placeholders and no player', async ({ pag await expect(page.getByTestId('attachments')).toBeVisible() await expect(page.getByTestId('cover-state')).toContainText('未设置') await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0) - await expect(page.getByTestId('chapter-attachments-3')).toHaveText('附件') - await page.getByTestId('chapter-attachments-3').click() + await page.getByTestId('edit-chapter-3').click() await expect(page.getByTestId('illustration-state')).toContainText('未设置') await expect(page.getByTestId('chapter-audio-state')).toContainText('未设置') await page.goto('/chapters/3') diff --git a/learner/src/__tests__/attachments.spec.ts b/learner/src/__tests__/attachments.spec.ts index bb4602b..dc8ed8f 100644 --- a/learner/src/__tests__/attachments.spec.ts +++ b/learner/src/__tests__/attachments.spec.ts @@ -70,13 +70,18 @@ function mockApi(options: { illustration?: boolean; audio?: boolean; playbackSec if (url.endsWith('/books') && method === 'GET') { return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: 'cover-sha', hasAudio: false, createdAt: '', updatedAt: '' }] }) } - if (url.endsWith('/books/7/cover') || url.includes('/chapters/3/')) { + if (url.endsWith('/books/7/cover') || url.endsWith('/chapters/3/audio') || url.endsWith('/chapters/3/illustration') + || url.endsWith('/chapters/3/playback')) { if (method === 'GET') { return new Response(pngBytes, { status: 200, headers: { 'Content-Type': 'application/octet-stream' } }) } return ok({ attachment: { kind: url.includes('illustration') ? 'illustration' : 'audio', mime: 'application/octet-stream', byteSize: 2048, version: 'new-sha', updatedAt: '' }, deleted: true, playbackSeconds: 12 }) } if (url.endsWith('/books/7')) return ok({ book: { ...book, attachments: { cover } }, chapters: [chapter] }) + if (url.endsWith('/chapters/3/source')) { + return ok({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: 'Curiosity opens the first door.' + String.fromCharCode(10), status: 'ready', contentSha256: 'sha', charCount: 31 } }) + } + if (url.endsWith('/chapters/3') && method === 'PATCH') return ok({ chapter: baseChapter, job: null, versionChanged: false }) if (url.includes('/chapters/3/tokens')) return ok({ textSha256: 'sha', tokens: [] }) if (url.endsWith('/chapters/3')) { return ok({ book: { ...book, attachments: { cover } }, chapter: { ...chapter, contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null } }) @@ -152,16 +157,23 @@ describe('chapter attachments on the book page', () => { expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/illustration'))).toBe(false) expect(view.get('.chapter-meta').text()).toContain('有音频') expect(view.get('.chapter-meta').text()).toContain('上次 1:05') - // The book-level audio row is gone: audio lives in the chapter dialog now. + // The book-level audio row is gone: audio lives in the chapter editor now, and the row has + // exactly one entry point. expect(view.text()).not.toContain('音频 · 3.4 MB') - expect(view.get('[data-testid="chapter-attachments-3"]').text()).toBe('附件') + expect(view.find('[data-testid="chapter-attachments-3"]').exists()).toBe(false) + expect(view.find('[data-testid="edit-chapter-3"]').exists()).toBe(true) }) - it('uploads an illustration and an audio file from the chapter dialog', async () => { + it('edits the title, the text and both files in one chapter editor', async () => { const { fetchMock } = mockApi({ illustration: false, audio: false, playbackSeconds: 0 }) const { view } = await mountWith(BookView, '/books/7') - await view.get('[data-testid="chapter-attachments-3"]').trigger('click') + await view.get('[data-testid="edit-chapter-3"]').trigger('click') await flushPromises() + // One dialog holds the title, the text and both files. + expect(view.find('[data-testid="chapter-dialog"]').exists()).toBe(true) + expect(view.find('#chapter-title').exists()).toBe(true) + expect(view.find('#chapter-text').exists()).toBe(true) + expect(view.get('[data-testid="attachment-timing"]').text()).toContain('立即上传') expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置') expect(view.get('[data-testid="chapter-audio-state"]').text()).toBe('未设置') @@ -170,13 +182,19 @@ describe('chapter attachments on the book page', () => { await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(64)], 'track.mp3', { type: 'audio/mpeg' })) expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/3/audio') && String(init?.method) === 'POST')).toBe(true) expect(view.get('[data-testid="book-notice"]').text()).toContain('音频已更新') + + // The text is still saved by its own button, inside the same dialog. + await view.get('#chapter-title').setValue('Fictional chapter renamed') + await view.get('[data-testid="save-chapter"]').trigger('click') + await flushPromises() + expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes('/chapters/3') && String(init?.method) === 'PATCH')).toBe(true) }) it('refuses a wrong type before uploading and removes after confirmation', async () => { const { fetchMock } = mockApi() vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never) const { view } = await mountWith(BookView, '/books/7') - await view.get('[data-testid="chapter-attachments-3"]').trigger('click') + await view.get('[data-testid="edit-chapter-3"]').trigger('click') await flushPromises() await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(16)], 'song.wav', { type: 'audio/wav' })) diff --git a/learner/src/style.css b/learner/src/style.css index 9ca98c1..93b886c 100644 --- a/learner/src/style.css +++ b/learner/src/style.css @@ -367,6 +367,7 @@ button:focus-visible, a:focus-visible, [role='button']:focus-visible, .attachment-info { min-width: 0; flex: 1; } .attachment-label { margin: 0 0 4px; font-weight: 600; } .attachment-info .subtle { margin: 4px 0 0; } +.attachment-heading { margin: 22px 0 4px; font-size: 15px; font-weight: 600; color: var(--text-strong); } .attachment-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 10px 0 0; } .attachment-actions input[type='file'] { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; } .attachment-actions label { position: relative; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 20px; padding: 0 14px; background: var(--surface-raised); } diff --git a/learner/src/views/BookView.vue b/learner/src/views/BookView.vue index b82a07c..d742196 100644 --- a/learner/src/views/BookView.vue +++ b/learner/src/views/BookView.vue @@ -29,9 +29,7 @@ const saving = ref(false) const bookId = computed(() => Number(route.params.id)) const chapterLength = computed(() => [...chapterText.value].length) const coverError = ref('') -// The chapter attachment dialog: one chapter at a time, with its own errors per file. -const attachmentDialog = ref(false) -const attachmentChapter = ref(null) +// Errors for the chapter's files, shown inside the chapter editor next to the file they belong to. const illustrationError = ref('') const audioError = ref('') @@ -134,18 +132,15 @@ async function removeCover(): Promise { } } -function openAttachmentDialog(item: ChapterSummary): void { - attachmentChapter.value = item - illustrationError.value = '' - audioError.value = '' - attachmentDialog.value = true -} - -/** The list entry is replaced by the store after an upload, so the dialog reads the fresh copy. */ +/** + * The chapter being edited, read from the list so an upload is reflected immediately. The editor + * holds the title and the text, and the same dialog also manages the chapter's illustration and + * audio: one place to edit a chapter, with one entry point per row. + */ const attachmentLive = computed(() => { - const current = attachmentChapter.value - if (!current) return null - return library.chapters.find(item => item.id === current.id) ?? current + const id = chapterSource.value?.id + if (id === undefined) return null + return library.chapters.find(item => item.id === id) ?? null }) async function uploadChapterFile(kind: 'illustration' | 'audio', file: File): Promise { @@ -242,6 +237,8 @@ async function openChapterDialog(chapterId: number) { chapterDialog.value = true chapterLoading.value = true chapterError.value = '' + illustrationError.value = '' + audioError.value = '' chapterSource.value = null try { const source = await library.loadChapterSource(chapterId) @@ -392,7 +389,6 @@ onUnmounted(() => library.closeBook()) :loading="library.retryingChapterId === item.id" @click="retry(item.id)" >重试 - 附件 编辑 @@ -413,57 +409,54 @@ onUnmounted(() => library.closeBook()) - - - - - - 图 - - - 插图 - {{ fileState(attachmentLive, 'illustration') }} - {{ illustrationError }} - - - {{ attachmentLive.illustrationVersion ? '替换插图' : '上传插图' }} - - - 移除 - - JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096;显示在本章正文开头。 - - - - - 音频 - {{ fileState(attachmentLive, 'audio') }} - {{ audioError }} - - - {{ attachmentLive.audioVersion ? '替换音频' : '上传音频' }} - - - 移除 - - MP3,最大 20 MiB;只在阅读这一章时出现,不与文本同步;替换或移除会重置该章位置。 - - - - - 关闭 - - - 正在加载… 章节标题 正文 - + {{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符 保存后会重新处理这一章;只改标题不会重新处理。 + + 本章插图 + + + + + 图 + + + {{ fileState(attachmentLive, 'illustration') }} + {{ illustrationError }} + + + {{ attachmentLive.illustrationVersion ? '替换插图' : '上传插图' }} + + + 移除 + + JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096;在阅读页显示为缩略图,点击可看原图。 + + + + 本章音频 + + + {{ fileState(attachmentLive, 'audio') }} + {{ audioError }} + + + {{ attachmentLive.audioVersion ? '替换音频' : '上传音频' }} + + + 移除 + + MP3,最大 20 MiB;只在阅读这一章时出现,不与文本同步;替换或移除会重置该章播放位置。 + + + 标题与正文点「保存章节」后生效;插图与音频在选中文件后立即上传,取消不会撤销已上传的文件。 + {{ chapterError }}
插图
{{ fileState(attachmentLive, 'illustration') }}
{{ illustrationError }}
JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096;显示在本章正文开头。
音频
{{ fileState(attachmentLive, 'audio') }}
{{ audioError }}
MP3,最大 20 MiB;只在阅读这一章时出现,不与文本同步;替换或移除会重置该章位置。
正在加载…
{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符
保存后会重新处理这一章;只改标题不会重新处理。
JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096;在阅读页显示为缩略图,点击可看原图。
MP3,最大 20 MiB;只在阅读这一章时出现,不与文本同步;替换或移除会重置该章播放位置。
标题与正文点「保存章节」后生效;插图与音频在选中文件后立即上传,取消不会撤销已上传的文件。
{{ chapterError }}