diff --git a/AGENTS.md b/AGENTS.md index 23e99c7..8da07ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,6 +287,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 - #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。 - 2026-09-11 用户确认正式 NLP/词典采用全 Go。#5 已验收并合入 main;#6 使用 Go WordNet 解析和词形候选、Go Unicode 原文分片、schema v4 共享词典资源表,不调用 Python NLP。WordNet 3.0 ZIP 来源与摘要见 `server/wordnet-resource.json`,许可保留在 `server/WORDNET-LICENSE.txt`。词形候选不等于上下文消歧,不自动合并个人学习状态;#3 Python 小样只保留历史验证。当前词典仅英语释义,个人释义输入为临时草稿,持久化归 #7。 - 2026-09-11 用户确认 #7 个人词条口径(三项由 Agent 定案):身份为「学习者+语言+规范化词形」,大小写合并但**不按 lemma/候选合并**(`dog` 与 `dogs` 是两条记录);首次保存默认「新词」;状态为 新词/学习中/已知/忽略,只有「学习中」带 1~7 级,对应原版 stage 2/1/0/-1~-7;例句只保存手输内容,不自动关联原文句子。schema v5 新增 `lexgo_terms`(唯一键加状态/等级检查约束),个人释义与共享词典分离且不进入审计日志;等级编辑 UI 归 #8/#12。 +- 2026-09-15 用户确认 #37 章级附件口径:封面维持**书级**;音频与插图改为**章节级**(一章各一张,插图固定显示在该章正文开头、可复用为章节列表缩略图);播放位置粒度改为**账号+章节**(播放中每 5 秒、暂停、离开或切换章节时上报,替换/移除音频时重置);类型与上限沿用 #21(MP3 ≤20 MiB;图片 JPG/PNG/WebP ≤2 MiB 且 ≤4096×4096,按内容判定);读取需会话并支持 Range/206、416、ETag/304;**书级音频接口与书级播放位置接口下线**,schema v9 新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions` 并用幂等语句清空书级音频历史行(测试期数据,不迁移、回退找不回);删除章节级联清空章级附件与位置。范围外:多图与图文混排、缩略图墙、多音轨、字幕同步、转写、TTS、转码、自动搜图、公开分享。 - 2026-09-15 用户确认 #21 附件口径:每本书可选**一张封面**(JPG/PNG/WebP,≤2 MiB,≤4096×4096)与**一段 MP3**(≤20 MiB),**按文件内容 magic bytes 判定类型、不看扩展名**;附件与播放位置**存 MySQL**(dump 即完整备份、属主校验一致、删书无孤儿文件);读取需会话并支持 **HTTP Range(206)**与 ETag/304,界面用带凭据的 fetch + 对象 URL(**令牌不进 URL**,代价是无渐进式流式播放);不自动播放、**不与文本同步**、不解析时长;播放位置按账号+书籍保存(播放中每 5 秒与暂停/离开上报),**替换或移除音频时重置**;跨账号一律 404;schema v8 新增两张表(可重放 DDL,回退写回版本号 7)。范围外:多音轨/分章音轨、字幕同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享。**浏览器是否真实解码播放尚未验证**(测试中媒体元素被桩替换),真机听感待人工确认。 - 2026-09-15 用户确认 #15 交付口径:只交付本机可复现的安装/备份/恢复材料并在本机演练,**不对外部署、不创建 release/tag、不邀请用户**;生产入口与 HTTPS 只写入文档;前端由反向代理托管 dist(不改后端代码);试用实例从**空库**开始、管理员由显式 bootstrap 建立、不带默认密码;备份=MySQL 全库 dump + 环境配置(凭据只存运维密码库,不进仓库/日志),不新增定时备份;性能用人造数据集实测并写明环境,只作观察不给承诺。备份/恢复规则:`restore` 必须 `--confirm`、默认只写空库、覆盖需 `--force`、库名必须含 lexgo 且不能是系统库、拒绝带 CREATE DATABASE/USE 的 dump,恢复前后比对源库逐表内容校验和。**附件(#21)尚未实施,恢复契约目前只覆盖数据库**;真实回滚、HTTPS、多机与定时备份仍未验证。部署与运维规则见 Wiki 页 `Deployment-and-Operations`(镜像 `docs/11-deployment-and-operations.md`)。 - 2026-09-15 用户确认 #14 显示与键盘口径:`theme ∈ {浅色,深色,跟随系统}`(默认跟随系统)与正文字号 `{标准,大,特大}`(1.0/1.15/1.3)**按账号保存在本机** `lexgo-learner-display:<账号 id>`,切换账号即换成该账号偏好或默认,退出回到默认,**不跨设备同步**;字号经 `--reader-font-scale` 只作用于阅读面(正文、释义内容、复习卡),不做全局缩放;深色用 `html[data-theme]` + Element Plus 的 `html.dark`,`style.css` 的 `:root` 是文件内仅有的颜色字面量;阅读位置按账号+章节保存滚动比例与该章 `content_sha256`,**正文换新版本后不恢复**;复习页 `空格`/`Enter` 显示答案、`1/2/3` 评分,输入类控件与聚焦按钮的按键不被劫持,带修饰键不拦截;移动验证用 390×844+`hasTouch` 的 Playwright `mobile` 项目(桌面项目 `testIgnore: mobile-*`),**真机长按选择与手感仍需人工确认**,不得用模拟设备结果冒充真机。本单无 schema 与接口变化。 diff --git a/README.md b/README.md index 8ccccfd..817990b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口) - [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留) - [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md) -- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#14、#18 已验收,原规划中的 #5 已完成;#15 与 #21 已验收;#37(章级音频与章节插图)待确认;#24 待排期;缺陷 #32 待确认修复方案;后续结合集成结果重估,旧全量研究仅供参考。 +- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#14、#18 已验收,原规划中的 #5 已完成;#15 与 #21 已验收;#37(章级音频与章节插图)已实现待验收;#24 待排期;缺陷 #32 待确认修复方案;后续结合集成结果重估,旧全量研究仅供参考。 - [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。 - [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。 - 运维命令:`./lexgo migrate|bootstrap|serve|backup|restore|verify`(纯二进制),开发便利与交叉验证走 `python scripts/server.py …` / `python scripts/ops.py …` @@ -16,7 +16,7 @@ ## 本地工程基础 -运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`(等价纯二进制路径:`./server/lexgo migrate` / `bootstrap`);随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。书籍封面与音频附件(#21)存于数据库,包含在 `scripts/ops.py backup` 与 `./server/lexgo backup` 的备份范围内。 +运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`(等价纯二进制路径:`./server/lexgo migrate` / `bootstrap`);随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。书籍封面(书级)与章节音频、插图(章级)都存于数据库,包含在 `scripts/ops.py backup` 与 `./server/lexgo backup` 的备份范围内。 本地入口:学习端 http://127.0.0.1:5173,管理端 http://127.0.0.1:5174。完整安装与测试命令见[开发与验证](docs/04-local-development-and-verification.md)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 4ae8615..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: 08472d2c98a5b4ac1b0354b09c6319ac74d321f7 -synchronized_at: 2026-09-15T12:25:16Z +wiki_revision: 0bca600ea0dfcb18141920f448954bb9e2fa067c +synchronized_at: 2026-09-15T15:10:08Z # 架构与代码地图 @@ -416,3 +416,84 @@ schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS` | `GET /api/v1/books/:id`、`GET /api/v1/chapters/:id` | 书籍对象带 `attachments`:封面/音频元数据与该账号的 `playbackSeconds` | 学习端:`stores/library.ts` 用带鉴权的 `session.requestBlob` 取回字节并转成对象 URL(**不把令牌放进 URL**),库列表批量预取封面,阅读器按需取音频;`components/AudioPlayer.vue` 是播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试,播放中每 5 秒与暂停/离开时上报位置);`views/LibraryView.vue` 显示封面(`aria-hidden` 的重复链接,标题链接仍是唯一可访问入口)、`views/BookView.vue` 新增「音频与封面」区块(上传/替换/移除、像素与体积提示、失败保留旧附件)、`views/ReaderView.vue` 在正文上方放常驻播放器条。 + +## #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="attachment-dialog"`:插图与音频各自的预览、状态、上传/替换/移除与规格提示),章节列表行新增缩略图列与「附件」按钮;`views/ReaderView.vue` 在正文上方渲染本章插图,播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置。 + +## #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="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 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。 + +## #37 章节级音频与章节插图(2026-09-15) + +**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` 的封面区块收窄为封面,**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`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 7bded2b..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: 34a1994dae20957a68f2bd60e3a7dd97530cffac -synchronized_at: 2026-09-15T10:33:08Z +wiki_revision: b688ef278f83c8055e16f91843a239f9ea2dd492 +synchronized_at: 2026-09-15T15:10:08Z # 业务规则与术语 @@ -320,3 +320,99 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺 **删除与备份**:删除书籍级联删除附件与所有账号的该书进度。附件存于数据库,所以 #15 的备份与恢复契约自动覆盖附件与进度;恢复后附件可继续读取与播放,这一点已由 2026-09-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 中。 + +**界面**:书籍页的「附件」按钮打开该章的附件对话框,与「编辑」分开——改正文会重新处理章节,改附件不会。阅读页里插图固定显示在本章正文开头(章节标题与播放器条之下),不参与划词;播放器条仍为 sticky,判据改为「本章有音频」。 + +**删除与备份**:删除章节(#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、转码、自动搜图、在线音频地址、公开分享与跨用户共享。 + +## #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、转码、自动搜图、在线音频地址、公开分享与跨用户共享。 + +## #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 字符),随章节标题与正文一起保存,去首尾空白,留空即清空;它只用于展示(阅读页在章节标题下显示),不参与归类、检索或权限判断,也不要求与书级数据一致。 + +**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题、作者与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。 + +**删除与备份**:删除章节(#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 80fcad0..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: ba5e3acc746432e55c186fd3a1fdf3c158fd121b -synchronized_at: 2026-09-15T10:33:08Z +wiki_revision: 8985f93f7319b2e6a428fc4417f353a7b909d797 +synchronized_at: 2026-09-15T15:10:08Z # 本地开发与验证 @@ -779,3 +779,280 @@ schema v7 → v8:新增 `lexgo_book_attachments` 与 `lexgo_playback_positions - 未验证 Safari/Firefox(项目既有测试只在 Chromium 上跑);WebP 只验证了容器维度解析,未在各浏览器实测显示。 - 20 MiB 上限下的播放启动延迟未测;附件进库对 dump 体积与恢复耗时的影响只在小数据量下观察。 - 未做音频转码与格式探测,非 MP3 一律拒绝。 + +## #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`。 + +### 未验证 + +- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。 +- 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,实际使用该账号自己的书。 + +### 未验证 + +- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。 +- 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 找不回;当前无真实用户,工单已记录。 +- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。 + +## #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`)。 + +### 未验证 + +- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。 +- 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`)。 + +### 未验证 + +- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。 +- 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 5395f6f..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: 2d6563574e807c9a167b90b7c772ee8b9b81e76d -synchronized_at: 2026-09-15T12:25:17Z +wiki_revision: 21ca050a33bdbe8133c921b6b302fbfc8d8cf7fe +synchronized_at: 2026-09-15T15:10:10Z # 产品需求总览 @@ -299,3 +299,33 @@ B04 与 F01–F12 的集成交付已实现,待用户验收:新部署与运 F 范围之外的 MVP 增补项「书籍音频与封面附件」已实现,待用户验收:每本书可选一张封面与一段 MP3,书库显示封面,书籍页可上传/替换/移除,阅读页播放器支持播放、拖动与倍速,不自动播放也不与文本同步;播放位置按账号与书籍保存,替换或移除音频时重置;附件按文件内容校验类型(不看扩展名),音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096×4096;附件读取需会话并支持 HTTP Range。schema 升到 v8(两张新表,需显式迁移)。附件存于数据库,因此包含在 #15 的备份与完整恢复范围内,恢复演练已验证附件按字节恢复且仍可读取与拖动。 非目标保持不变:多音轨、分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜索封面、在线音频地址抓取、公开分享与跨用户共享。**浏览器真实解码播放(测试中媒体元素被桩替换)与真机听感仍需人工确认**;Safari/Firefox 未验证。其余待办:书籍音频与封面已在 #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。 + +## #37 交付状态更新(2026-09-15) + +用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页每个章节行显示插图缩略图与「附件」入口,附件对话框与正文编辑分开;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(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。 + +## #37 交付状态更新(2026-09-15) + +用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);章节作者为可选自由文本(≤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/11-deployment-and-operations.md b/docs/11-deployment-and-operations.md index 6bfa2c6..ee649cb 100644 --- a/docs/11-deployment-and-operations.md +++ b/docs/11-deployment-and-operations.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Deployment-and-Operations.- -wiki_revision: dacdad8a9385b2fde153a0dd1fe2579b57a810fd -synchronized_at: 2026-09-15T10:33:09Z +wiki_revision: b9eda78d3e71dab02dbf2e3af700c81d9c725f8d +synchronized_at: 2026-09-15T13:07:36Z # 部署与运维 @@ -32,7 +32,7 @@ synchronized_at: 2026-09-15T10:33:09Z | 运行时 | 后端为单个静态二进制,无需运行时依赖;构建需要 Go 1.26.5、Node 22、pnpm 9 | | 进程托管 | supervisor(本项目开发机即为 `lexgo-api` / `lexgo-learner` / `lexgo-admin` 三个 program) | | 日志 | supervisor 的 `stdout`/`stderr` 日志文件;应用自身不写文件日志 | -| 备份对象 | MySQL 全库(用户数据、原文、词典归档、审计、**书籍封面与音频附件及播放位置**)+ `.env.local`(单独从密码库取) | +| 备份对象 | MySQL 全库(用户数据、原文、词典归档、审计、**书籍封面以及章节音频/插图与播放位置**)+ `.env.local`(单独从密码库取) | ## 环境要求 @@ -212,7 +212,7 @@ python scripts/ops.py smoke --api http://127.0.0.1:<端口> --admin-user <管理 - **真实回滚演练未做**:本文档给出规则,但没有在真实实例上执行过「升级 → 回滚」全过程。 - 未验证 HTTPS、域名、多机与灰度部署;本机演练只用 `127.0.0.1` 与模拟触摸视口。 - 未启用定时备份、监控与告警;备份由人工触发。 -- **封面与音频附件(#21)已存进数据库**,因此 dump 自动包含附件与播放位置,恢复后仍可按字节读取并拖动;**附件会让 dump 变大**(单音频上限 20 MiB,封面上限 2 MiB),容量与备份体积要按试用量估算。 +- **封面(书级)与音频、插图(章级)都存进数据库**,因此 dump 自动包含它们与播放位置,恢复后仍可按字节读取并拖动;**附件会让 dump 变大**(单音频上限 20 MiB,图片上限 2 MiB),容量与备份体积要按试用量估算。#37 起附件挂在章节上,书级音频接口已下线。 - 附件读取需要会话并支持 HTTP Range;客户端用带凭据的 fetch 取字节后交给媒体元素,令牌不出现在 URL 里,但浏览器会整份取回后再播放(未做渐进式流式播放)。 - 恢复演练是单机顺序执行,未验证大库恢复耗时与磁盘空间上限。 - 接口级的两账号闭环与越权验证需要 HTTP 客户端,目前只在 Python 工具通道提供;Go 二进制提供数据库层的备份、恢复与校验。 diff --git a/docs/README.md b/docs/README.md index af6a993..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: eef77f39332c2bef01d5e9788a2c6c89796b2a1a -synchronized_at: 2026-09-15T12:25:15Z +wiki_revision: da0f9a0feb70b7099edcf8adf50a18a15435dd33 +synchronized_at: 2026-09-15T15:10:07Z # LexGo 文档入口 @@ -90,3 +90,13 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn #15 自托管试用交付已于 2026-09-15 通过用户验收:备份、恢复与校验同时提供**纯二进制子命令**(`lexgo backup|restore|verify`,部署机只需要二进制与 MySQL 客户端)与 Python 工具通道(交叉验证与开发便利),两条通道的 dump 与 manifest 格式一致、可互相恢复;新增运维工具(`scripts/ops.py`:依赖检查、建空库、备份、恢复、校验、两账号闭环演练;`scripts/bench.py`:写明规模的人造数据集性能测量)、新的 Wiki 部署与运维页,以及一次完整的本机演练——空库安装(建库 → 迁移 → 首次 bootstrap 建管理员 → 管理员建两个虚构演练账号 → 走通粘贴/阅读/查词/保存/复习/完成章节/进度)、备份 lexgo_dev、恢复到空实例并用第二个 API 实例完成两账号闭环与越权校验,恢复后逐表行数与内容校验和都和源库一致、源库未被改动。备份含全部用户数据(数据库)与环境配置(单独保管凭据),恢复默认只写空库、覆盖需要显式确认与 `--force`、拒绝写入系统库。性能在 20 章/2000 词条/8000 条作答的人造数据集上实测并记录环境,只作观察不给承诺。两条通道已在本机交叉验证(19 项);纯二进制安装另有 9 项演练、完整恢复演练 27 项、附件版恢复演练 22 项。真实回滚(升级后切回旧二进制)、HTTPS、多机与定时备份仍未验证,已写入部署页的已知限制。PR #35 已 fast-forward-only 合入 main。 #21 书籍音频与封面附件已于 2026-09-15 通过用户验收:每本书可以上传一张封面(JPG/PNG/WebP,≤2 MiB,≤4096×4096)和一段 MP3(≤20 MiB);书库卡片显示封面,书籍页新增「音频与封面」区块可上传、替换、移除,阅读页在正文上方显示常驻播放器(播放/暂停、拖动进度、0.75–1.5 倍速),不自动播放、不与文本同步。播放位置按账号与书籍保存,替换或移除音频会重置位置;附件与进度按账号隔离,读取需要会话并支持 HTTP Range。附件存于 MySQL,因此 #15 的备份恢复天然覆盖:2026-09-15 的恢复演练验证了恢复后附件按字节相同地返回且 Range 仍可用。真实浏览器是否解码播放(媒体元素在测试中被桩替换)以及真机听感仍需人工确认。 + +#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 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。 + +#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 6f05330..b07ad25 100644 --- a/learner/e2e/attachments.spec.ts +++ b/learner/e2e/attachments.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from '@playwright/test' +import { expectModalOverlay } from './overlay' // Book audio and covers against a mocked API: upload, replace, remove, and the reader's player. const user = { id: 42, username: 'fictional-attachment', role: 'learner' } @@ -8,13 +9,17 @@ const chapter = { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: chapterText.length, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', } -const pngBytes = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex') +const pngBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", + "base64", + ) -interface Attachment { kind: 'audio' | 'cover'; mime: string; byteSize: number; version: string; updatedAt: string } +interface Attachment { kind: 'audio' | 'cover' | 'illustration'; mime: string; byteSize: number; version: string; updatedAt: string } async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean } = {}): Promise<{ posted: string[]; deleted: string[]; positions: number[] }> { let audio: Attachment | null = options.audio === false ? null : { kind: 'audio', mime: 'audio/mpeg', byteSize: 4096, version: 'audio-sha', updatedAt: '2026-09-15T00:00:00Z' } let cover: Attachment | null = options.cover === false ? null : { kind: 'cover', mime: 'image/png', byteSize: pngBytes.length, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' } + let illustration: Attachment | null = options.cover === false ? null : { kind: 'illustration', mime: 'image/png', byteSize: pngBytes.length, version: 'illustration-sha', updatedAt: '2026-09-15T00:00:00Z' } let playbackSeconds = 0 const posted: string[] = [] const deleted: string[] = [] @@ -25,7 +30,8 @@ async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean } const path = url.pathname const method = route.request().method() const json = (data: unknown, status = 200) => route.fulfill({ status, json: { code: 200, data } }) - const attachments = () => ({ cover, audio, playbackSeconds }) + const attachments = () => ({ cover }) + const chapterFiles = () => ({ ...chapter, illustrationVersion: illustration?.version ?? '', audioVersion: audio?.version ?? '', playbackSeconds }) if (path === '/api/v1/login') return json({ token: 'fictional-session', user }) if (path === '/api/v1/me') return json(user) @@ -33,29 +39,34 @@ async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean } if (path === '/api/v1/books' && method === 'GET') { return json({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: cover?.version ?? '', hasAudio: audio !== null, createdAt: '', updatedAt: '' }] }) } - if (path === '/api/v1/books/7' && method === 'GET') return json({ book: { ...book, attachments: attachments() }, chapters: [chapter] }) - if (path === '/api/v1/books/7/cover' || path === '/api/v1/books/7/audio') { - const kind = path.endsWith('cover') ? 'cover' : 'audio' + if (path === '/api/v1/books/7' && method === 'GET') return json({ book: { ...book, attachments: attachments() }, chapters: [chapterFiles()] }) + const attachmentPath = path === '/api/v1/books/7/cover' ? 'cover' + : path === '/api/v1/chapters/3/illustration' ? 'illustration' + : path === '/api/v1/chapters/3/audio' ? 'audio' : null + if (attachmentPath !== null) { + const kind = attachmentPath as 'cover' | 'illustration' | 'audio' if (method === 'GET') { - const body = kind === 'cover' ? pngBytes : Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)]) - return route.fulfill({ status: 200, headers: { 'Content-Type': kind === 'cover' ? 'image/png' : 'audio/mpeg' }, body }) + const body = kind === 'audio' ? Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)]) : pngBytes + return route.fulfill({ status: 200, headers: { 'Content-Type': kind === 'audio' ? 'audio/mpeg' : 'image/png' }, body }) } if (method === 'POST') { posted.push(kind) // The mock answers exactly like the backend: the new version replaces the old one. - const next: Attachment = { kind: kind as 'cover' | 'audio', mime: kind === 'cover' ? 'image/png' : 'audio/mpeg', byteSize: 2048, version: `${kind}-new`, updatedAt: '2026-09-15T01:00:00Z' } + const next: Attachment = { kind, mime: kind === 'audio' ? 'audio/mpeg' : 'image/png', byteSize: 2048, version: `${kind}-new`, updatedAt: '2026-09-15T01:00:00Z' } if (kind === 'cover') cover = next + else if (kind === 'illustration') illustration = next else { audio = next; playbackSeconds = 0 } return json({ attachment: next }) } if (method === 'DELETE') { deleted.push(kind) if (kind === 'cover') cover = null + else if (kind === 'illustration') illustration = null else audio = null return json({ deleted: true }) } } - if (path === '/api/v1/books/7/playback' && method === 'PUT') { + if (path === '/api/v1/chapters/3/playback' && method === 'PUT') { const body = route.request().postDataJSON() as { positionSeconds: number } positions.push(body.positionSeconds) playbackSeconds = body.positionSeconds @@ -70,7 +81,11 @@ async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean } }) return json({ textSha256: 'sha', tokens }) } - if (path === '/api/v1/chapters/3') return json({ book: { ...book, attachments: attachments() }, chapter: { ...chapter, contentSha256: 'sha', originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } }) + 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({}) }) return { posted, deleted, positions } @@ -82,7 +97,7 @@ async function login(page: Page): Promise { await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible() } -test('the library shows the stored cover and the book page manages attachments', async ({ page }) => { +test('the library shows the cover and the chapter dialog manages its files', async ({ page }) => { const calls = await mockApi(page) await login(page) await expect(page.getByTestId('book-cover')).toBeVisible() @@ -90,29 +105,44 @@ test('the library shows the stored cover and the book page manages attachments', await page.getByRole('link', { name: 'Fictional reader', exact: true }).click() await expect(page.getByTestId('attachments')).toBeVisible() - await expect(page.getByTestId('cover-state')).toContainText('PNG') - await expect(page.getByTestId('audio-state')).toContainText('MP3') - await page.screenshot({ path: '../.local/evidence/issue21-attachments.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) + await expect(page.locator('.chapter-thumb')).toHaveCount(0) + // 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' }) - // Replacing the cover uploads the picked file and reports the new state. - await page.setInputFiles('[data-testid="cover-input"]', { name: 'cover.png', mimeType: 'image/png', buffer: pngBytes }) - await expect(page.getByTestId('book-notice')).toContainText('封面已更新') - expect(calls.posted).toEqual(['cover']) + await page.getByTestId('edit-chapter-3').click() + // A real modal, not content appended below the chapter list. + await expectModalOverlay(page, page.getByTestId('chapter-dialog')) + await expect(page.locator('#chapter-title')).toBeVisible() + await expect(page.getByTestId('chapter-author')).toBeVisible() + await expect(page.locator('.field-row label[for="chapter-author"]')).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' }) + + // Replacing the illustration and the audio goes to the chapter endpoints. + await page.setInputFiles('[data-testid="illustration-input"]', { name: 'chapter.png', mimeType: 'image/png', buffer: pngBytes }) + await expect(page.getByTestId('book-notice')).toContainText('插图已更新') + await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'track.mp3', mimeType: 'audio/mpeg', buffer: Buffer.concat([Buffer.from('ID3'), Buffer.alloc(2048)]) }) + await expect(page.getByTestId('book-notice')).toContainText('音频已更新') + expect(calls.posted).toEqual(['illustration', 'audio']) // A file of the wrong type is refused in the page, before any request. - await page.setInputFiles('[data-testid="audio-input"]', { name: 'song.wav', mimeType: 'audio/wav', buffer: Buffer.from('RIFF....') }) - await expect(page.locator('.field-error')).toContainText('音频只支持 MP3') - expect(calls.posted).toEqual(['cover']) + await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'song.wav', mimeType: 'audio/wav', buffer: Buffer.from('RIFF....') }) + await expect(page.locator('[data-testid="chapter-dialog"] .field-error')).toContainText('音频只支持 MP3') + expect(calls.posted).toEqual(['illustration', 'audio']) - // Uploading audio clears the stored position, which the state line shows. - await page.setInputFiles('[data-testid="audio-input"]', { name: 'track.mp3', mimeType: 'audio/mpeg', buffer: Buffer.concat([Buffer.from('ID3'), Buffer.alloc(2048)]) }) - await expect(page.getByTestId('audio-state')).toContainText('MP3') - expect(calls.posted).toEqual(['cover', 'audio']) - - // Removing asks first and then reports the empty state. - await page.getByTestId('audio-remove').click() + // Removing the audio asks first and then reports the empty state. + await page.getByTestId('chapter-audio-remove').click() await page.getByRole('button', { name: '确认移除' }).click() - await expect(page.getByTestId('audio-state')).toContainText('未设置') + await expect(page.getByTestId('chapter-audio-state')).toContainText('未设置') expect(calls.deleted).toEqual(['audio']) }) @@ -151,6 +181,16 @@ test('the reader plays the audio without starting it and remembers the position' await login(page) await page.goto('/chapters/3') await expect(page.locator('.reader-text')).toBeVisible() + // The chapter's illustration is a thumbnail above the text; clicking it shows the original. + const thumbnail = page.getByTestId('chapter-illustration') + await expect(thumbnail).toBeVisible() + await expect(thumbnail).toContainText('点击查看大图') + await thumbnail.click() + await expectModalOverlay(page, page.getByTestId('illustration-dialog')) + await expect(page.getByTestId('illustration-dialog').locator('img')).toHaveAttribute('src', /^blob:/) + await page.screenshot({ path: '../.local/evidence/issue37-illustration-dialog.png' }) + await page.keyboard.press('Escape') + await expect(page.getByTestId('illustration-dialog')).toBeHidden() const player = page.getByTestId('audio-player') await expect(player).toBeVisible() // The player is sticky above the text so the controls stay reachable while reading. @@ -179,16 +219,21 @@ test('the reader plays the audio without starting it and remembers the position' await expect(page.getByTestId('audio-rate-1.5')).toHaveAttribute('aria-pressed', 'true') }) -test('a book without audio shows no player at all', async ({ page }) => { +test('a book without attachments shows placeholders and no player', async ({ page }) => { await mockApi(page, { audio: false, cover: false }) await login(page) await expect(page.getByTestId('book-cover')).toHaveCount(0) - // The management section stays: adding an attachment must be possible before one exists. + // 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('audio-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('未设置') + await expect(page.getByTestId('chapter-audio-state')).toContainText('未设置') await page.goto('/chapters/3') await expect(page.locator('.reader-text')).toBeVisible() await expect(page.getByTestId('audio-player')).toHaveCount(0) + await expect(page.getByTestId('chapter-illustration')).toHaveCount(0) }) diff --git a/learner/e2e/edit.spec.ts b/learner/e2e/edit.spec.ts index e90edfe..d5f7567 100644 --- a/learner/e2e/edit.spec.ts +++ b/learner/e2e/edit.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from '@playwright/test' +import { expectMessageBoxOverlay, expectModalOverlay } from './overlay' // Renaming, editing and deleting through the real dialogs of the accepted prototype, against // a mocked API. @@ -42,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 @@ -69,10 +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. @@ -93,6 +97,8 @@ test('rename the book, edit a chapter into a new version and delete both', async await page.locator('.el-message-box').last().getByRole('button', { name: '取消' }).click() expect(requests.filter(entry => entry === 'DELETE /api/v1/chapters/9')).toHaveLength(0) await page.getByTestId('delete-chapter').click() + // A confirmation is a modal overlay too, not text appended to the page. + await expectMessageBoxOverlay(page) await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除章节' }).click() await expect(page.getByTestId('book-notice')).toContainText('章节已删除 · 剩余 1 章') await expect(page.locator('.chapter-row')).toHaveCount(1) diff --git a/learner/e2e/mobile-fixtures.ts b/learner/e2e/mobile-fixtures.ts index 0a57173..31806fd 100644 --- a/learner/e2e/mobile-fixtures.ts +++ b/learner/e2e/mobile-fixtures.ts @@ -22,13 +22,16 @@ export async function mockLearnerApi(page: Page): Promise { errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', } const term = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z' } - // The phone audit also covers the attachment surfaces, so the fixture book carries both. - const pngBytes = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex') + // The phone audit also covers the attachment surfaces: a book cover plus a chapter illustration + // and audio, which is where those files live since #37. + const pngBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", + "base64", + ) const attachments = { cover: { kind: 'cover', mime: 'image/png', byteSize: pngBytes.length, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' }, - audio: { kind: 'audio', mime: 'audio/mpeg', byteSize: 4096, version: 'audio-sha', updatedAt: '2026-09-15T00:00:00Z' }, - playbackSeconds: 65, } + const chapterWithFiles = { ...chapter, illustrationVersion: 'illustration-sha', audioVersion: 'audio-sha', playbackSeconds: 65 } await page.route('**/api/v1/**', async route => { const url = new URL(route.request().url()) @@ -38,12 +41,15 @@ export async function mockLearnerApi(page: Page): Promise { else if (path === '/api/v1/me') data = mobileUser else if (path === '/api/v1/space') data = { ownerId: mobileUser.id, language: 'en' } else if (path === '/api/v1/books') data = { items: [{ ...book, coverVersion: attachments.cover.version, hasAudio: true }] } - else if (path === '/api/v1/books/7/cover' || path === '/api/v1/books/7/audio') { - const body = path.endsWith('cover') ? pngBytes : Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)]) - await route.fulfill({ status: 200, headers: { 'Content-Type': path.endsWith('cover') ? 'image/png' : 'audio/mpeg' }, body }) + else if (path === '/api/v1/books/7/cover' || path === '/api/v1/chapters/3/illustration' || path === '/api/v1/chapters/3/audio') { + const body = path.endsWith('illustration') || path.endsWith('cover') + ? pngBytes + : Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)]) + const type = path.endsWith('illustration') || path.endsWith('cover') ? 'image/png' : 'audio/mpeg' + await route.fulfill({ status: 200, headers: { 'Content-Type': type }, body }) return - } else if (path === '/api/v1/books/7/playback') data = { playbackSeconds: 65 } - else if (path === '/api/v1/books/7') data = { book: { ...book, attachments }, chapters: [chapter] } + } else if (path === '/api/v1/chapters/3/playback') data = { playbackSeconds: 65 } + else if (path === '/api/v1/books/7') data = { book: { ...book, attachments }, chapters: [chapterWithFiles] } else if (path === '/api/v1/chapters/3/tokens') { const tokens: unknown[] = [] let offset = 0 @@ -58,7 +64,7 @@ export async function mockLearnerApi(page: Page): Promise { // The reader follows the tokens only while they describe the chapter's content version. data = { textSha256: 'e'.repeat(64), tokens } } else if (path === '/api/v1/chapters/3') { - data = { book: { ...book, attachments }, chapter: { ...chapter, contentSha256: 'e'.repeat(64), originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } } + data = { book: { ...book, attachments }, chapter: { ...chapterWithFiles, contentSha256: 'e'.repeat(64), originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } } } else if (path === '/api/v1/terms/lookup') { data = { query: 'curiosity', terms: [{ term: 'curiosity', entries: [{ partOfSpeech: 'noun', definition: '好奇心' }] }], candidates: [] } } else if (path === '/api/v1/terms' && route.request().method() === 'GET') { diff --git a/learner/e2e/overlay.ts b/learner/e2e/overlay.ts new file mode 100644 index 0000000..99f9b2b --- /dev/null +++ b/learner/e2e/overlay.ts @@ -0,0 +1,42 @@ +import { expect, type Locator, type Page } from '@playwright/test' + +/** + * Asserts that a dialog is a modal overlay rather than a block in the document flow. + * + * A missing Element Plus stylesheet is invisible to `toBeVisible()` and to the unit tests, but it + * turns every dialog into content appended after the list it was opened from — which is exactly how + * a reader reported it. These checks need a real browser with the real stylesheets. + */ +export async function expectModalOverlay(page: Page, dialog: Locator): Promise { + await expect(dialog).toBeVisible() + const layout = await dialog.evaluate(element => { + const box = element.getBoundingClientRect() + const overlay = element.closest('.el-overlay') + return { + overlay: overlay ? getComputedStyle(overlay).position : 'none', + position: getComputedStyle(element).position, + top: Math.round(box.top), + left: Math.round(box.left), + width: Math.round(box.width), + viewportHeight: window.innerHeight, + viewportWidth: window.innerWidth, + } + }) + expect(layout.overlay, 'the dialog must render inside a fixed overlay').toBe('fixed') + expect(layout.position, 'the dialog itself is positioned by the overlay').not.toBe('static') + // It is laid out over the viewport, not below the page content that opened it. + expect(layout.top, 'the dialog must start inside the viewport').toBeGreaterThanOrEqual(0) + expect(layout.top, 'the dialog must not be pushed below the viewport').toBeLessThan(layout.viewportHeight) + expect(layout.viewportWidth - (layout.left + layout.width), 'the dialog must fit horizontally').toBeGreaterThanOrEqual(-2) +} + +/** Asserts that a message box is a modal overlay as well. */ +export async function expectMessageBoxOverlay(page: Page): Promise { + const box = page.locator('.el-message-box') + await expect(box).toBeVisible() + const overlay = await box.evaluate(element => { + const wrapper = element.closest('.el-overlay') + return wrapper ? getComputedStyle(wrapper).position : 'none' + }) + expect(overlay, 'the confirmation must render inside a fixed overlay').toBe('fixed') +} diff --git a/learner/src/__tests__/attachments.spec.ts b/learner/src/__tests__/attachments.spec.ts index 3b945bd..4c3ef5e 100644 --- a/learner/src/__tests__/attachments.spec.ts +++ b/learner/src/__tests__/attachments.spec.ts @@ -12,24 +12,18 @@ 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 audio = { kind: 'audio' as const, mime: 'audio/mpeg', byteSize: 4096, version: 'audio-sha', updatedAt: '2026-09-15T00:00:00Z' } -const book = { id: 7, title: 'Fictional reader', language: 'en' } -const chapter = { - id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', - jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n', +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, + errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', } let wrapper: VueWrapper | undefined -let createdUrls: string[] /** jsdom has no object URLs, so the two functions are added to the existing URL object. */ function stubObjectUrls(): void { - createdUrls = [] let counter = 0 - ;(URL as unknown as Record).createObjectURL = vi.fn(() => { - const url = `blob:fictional-${++counter}` - createdUrls.push(url) - return url - }) + ;(URL as unknown as Record).createObjectURL = vi.fn(() => `blob:fictional-${++counter}`) ;(URL as unknown as Record).revokeObjectURL = vi.fn() } @@ -38,6 +32,12 @@ function putBackObjectUrls(): void { delete (URL as unknown as Record).revokeObjectURL } +function stubMedia(): { play: MockInstance; pause: MockInstance } { + const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(async () => undefined) + const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined) + return { play, pause } +} + /** Sets the files of a hidden input the way a file picker would, then fires the change event. */ async function pickFile(view: VueWrapper, testid: string, file: File): Promise { const input = view.get(`[data-testid="${testid}"]`).element as HTMLInputElement @@ -46,40 +46,49 @@ async function pickFile(view: VueWrapper, testid: string, file: File): Promise undefined) - const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined) - return { play, pause } +interface Fixture { + chapter: typeof baseChapter & { illustrationVersion?: string; audioVersion?: string; playbackSeconds?: number } + requests: string[] } -/** An API mock whose book and chapter carry attachments. */ -function mockApi(options: { attachments?: { cover: unknown; audio: unknown; playbackSeconds: number } } = {}): MockInstance { - const attachments = options.attachments ?? { cover, audio, playbackSeconds: 90 } - return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { +/** An API mock whose chapter carries an illustration, audio and a stored position. */ +function mockApi(options: { illustration?: boolean; audio?: boolean; playbackSeconds?: number } = {}): Fixture & { fetchMock: MockInstance } { + const chapter = { + ...baseChapter, + illustrationVersion: options.illustration === false ? '' : 'illustration-sha', + audioVersion: options.audio === false ? '' : 'audio-sha', + playbackSeconds: options.playbackSeconds ?? 65, + } + const requests: string[] = [] + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { const url = String(input) const method = String(init?.method ?? 'GET') + requests.push(`${method} ${url}`) const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data })) if (url.endsWith('/me')) return ok(user) if (url.endsWith('/space')) return ok({ ownerId: user.id, language: 'en' }) if (url.endsWith('/books') && method === 'GET') { - return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: attachments.cover ? 'cover-sha' : '', hasAudio: Boolean(attachments.audio), createdAt: '', updatedAt: '' }] }) + return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: 'cover-sha', hasAudio: false, createdAt: '', updatedAt: '' }] }) } - if (url.includes('/books/7/cover') || url.includes('/books/7/audio')) { - // A body of bytes rather than a jsdom Blob: the Response constructor cannot stream that one. - if (method === 'GET') return new Response(new Uint8Array([1, 2, 3, 4]), { status: 200, headers: { 'Content-Type': 'application/octet-stream' } }) - return ok({ attachment: url.includes('cover') ? cover : audio }) + 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.includes('/books/7/playback')) { - const body = typeof init?.body === 'string' ? JSON.parse(init.body) : { positionSeconds: 0 } - return ok({ playbackSeconds: Number(body.positionSeconds) }) + 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.includes('/books/7')) return ok({ book: { ...book, attachments }, chapters: [chapter] }) + 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.includes('/chapters/3')) return ok({ book: { ...book, attachments }, chapter, navigation: { previousChapterId: null, nextChapterId: null } }) - if (url.includes('/terms')) return ok({ items: [], total: 0, page: 1, limit: 20 }) - if (url.includes('/progress')) return ok({}) + 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 } }) + } return ok({}) }) + return { chapter, requests, fetchMock } } async function mountWith(component: unknown, path: string): Promise<{ view: VueWrapper; router: Router }> { @@ -103,89 +112,119 @@ async function mountWith(component: unknown, path: string): Promise<{ view: VueW return { view: wrapper, router } } -describe('book covers in the library', () => { +describe('book cover in the library', () => { beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() }) - it('shows the stored cover and falls back to a default block', async () => { - mockApi() + it('shows the stored cover and loads its bytes once', async () => { + const { fetchMock } = mockApi() const { view } = await mountWith(LibraryView, '/') - // The cover is fetched after the list answers, so the test waits for the blob instead of - // assuming that two microtask flushes are enough. await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy()) await flushPromises() - const image = view.get('[data-testid="book-cover"]') - expect(image.attributes('src')).toMatch(/^blob:fictional-/) - // The cover repeats the title link, so it is decorative and hidden from the accessibility tree. - expect(image.attributes('alt')).toBe('') - expect(view.get('.book-cover').attributes('aria-hidden')).toBe('true') - expect(useLibraryStore().coverUrls[7]).toMatch(/^blob:fictional-/) + expect(view.get('[data-testid="book-cover"]').attributes('src')).toMatch(/^blob:fictional-/) + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/books/7/cover'))).toHaveLength(1) }) it('keeps the default cover when the book has none', async () => { - mockApi({ attachments: { cover: null, audio: null, playbackSeconds: 0 } }) + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + const url = String(input) + const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data })) + if (url.endsWith('/me')) return ok(user) + if (url.endsWith('/space')) return ok({ ownerId: user.id, language: 'en' }) + if (url.endsWith('/books')) return ok({ items: [{ ...book, chapterCount: 0, pendingCount: 0, processingCount: 0, readyCount: 0, failedCount: 0, createdAt: '', updatedAt: '' }] }) + return ok({}) + }) const { view } = await mountWith(LibraryView, '/') + await flushPromises() expect(view.find('[data-testid="book-cover"]').exists()).toBe(false) expect(view.get('.book-cover-default').text()).toBe('F') + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/cover'))).toBe(false) }) }) -describe('attachments on the book page', () => { +describe('chapter attachments on the book page', () => { beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() }) - it('describes what is stored and where the audio stopped', async () => { - mockApi() + 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') - expect(view.get('[data-testid="audio-state"]').text()).toContain('MP3') - expect(view.get('[data-testid="audio-state"]').text()).toContain('1:30') - expect(view.find('[data-testid="cover-remove"]').exists()).toBe(true) - expect(view.find('[data-testid="audio-remove"]').exists()).toBe(true) + // 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) + expect(view.find('.chapter-thumb').exists()).toBe(false) + 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 editor now, and the row has + // exactly one entry point. + expect(view.text()).not.toContain('音频 · 3.4 MB') + expect(view.find('[data-testid="chapter-attachments-3"]').exists()).toBe(false) + expect(view.find('[data-testid="edit-chapter-3"]').exists()).toBe(true) }) - it('refuses an oversized or wrong-typed file before uploading', async () => { - const fetchMock = mockApi() + 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') - const big = new File([new Uint8Array(3 << 20)], 'big.png', { type: 'image/png' }) - await pickFile(view, 'cover-input', big) - expect(view.get('.field-error').text()).toContain('2 MiB') - expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/cover') && String(url).includes('books/7'))).toBe(true) // only the initial read - expect(fetchMock.mock.calls.filter(([, init]) => String(init?.method) === 'POST')).toHaveLength(0) + 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) + // The author shares the label row layout and is optional. + expect(view.find('[data-testid="chapter-author"]').exists()).toBe(true) + expect(view.find('.field-row label[for="chapter-author"]').exists()).toBe(true) + expect(view.find('#chapter-text').exists()).toBe(true) + expect(view.get('[data-testid="attachment-timing"]').text()).toContain('立即上传') + // The file hints are one short line each, so the height goes to the text editor. + expect(view.get('.attachment-block').text()).toContain('≤2 MiB') + expect(view.text()).toContain('≤4096×4096') + expect(view.text()).not.toContain('在阅读页显示为缩略图') + expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置') + expect(view.get('[data-testid="chapter-audio-state"]').text()).toBe('未设置') - const wrong = new File([new Uint8Array(16)], 'song.wav', { type: 'audio/wav' }) - await pickFile(view, 'audio-input', wrong) - expect(view.text()).toContain('音频只支持 MP3') + await pickFile(view, 'illustration-input', new File([pngBytes], 'chapter.png', { type: 'image/png' })) + expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/3/illustration') && String(init?.method) === 'POST')).toBe(true) + 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('uploads a replacement and reports the new state', async () => { - const fetchMock = mockApi() - const { view } = await mountWith(BookView, '/books/7') - const file = new File([new Uint8Array(2048)], 'cover.png', { type: 'image/png' }) - await pickFile(view, 'cover-input', file) - const posted = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'POST') - expect(String(posted?.[0])).toContain('/api/v1/books/7/cover') - expect(posted?.[1]?.body).toBeInstanceOf(FormData) - expect(view.get('[data-testid="book-notice"]').text()).toContain('封面已更新') - }) - - it('removes an attachment after confirmation', async () => { - const fetchMock = mockApi() + 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="audio-remove"]').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' })) + expect(view.get('.field-error').text()).toContain('音频只支持 MP3') + expect(fetchMock.mock.calls.filter(([, init]) => String(init?.method) === 'POST')).toHaveLength(0) + + await view.get('[data-testid="illustration-remove"]').trigger('click') await flushPromises() const deleted = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'DELETE') - expect(String(deleted?.[0])).toContain('/api/v1/books/7/audio') - expect(view.get('[data-testid="book-notice"]').text()).toContain('音频已移除') - // The audio state falls back to "not set" once the file is gone. - expect(view.get('[data-testid="audio-state"]').text()).toContain('未设置') + expect(String(deleted?.[0])).toContain('/chapters/3/illustration') + expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置') }) }) -describe('audio player', () => { +describe('audio player component', () => { beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) }) - afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() }) + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); wrapper?.unmount() }) it('formats the clock, changes the rate and reports an error', async () => { stubMedia() @@ -217,33 +256,52 @@ describe('audio player', () => { }) }) -describe('player inside the reader', () => { +describe('chapter illustration and player in the reader', () => { beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); stubMedia(); useSessionStore().user = { ...user } }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() }) - it('appears for a book with audio and stays away otherwise', async () => { + it('shows a chapter thumbnail above the text and opens the original in a dialog', async () => { mockApi() const { view } = await mountWith(ReaderView, '/chapters/3') expect(view.find('.reader-text').exists()).toBe(true) await vi.waitFor(() => expect(useLibraryStore().audioUrl).not.toBe('')) await flushPromises() - expect(view.find('[data-testid="audio-player"]').exists()).toBe(true) - wrapper?.unmount() - mockApi({ attachments: { cover, audio: null, playbackSeconds: 0 } }) - const second = await mountWith(ReaderView, '/chapters/3') - expect(second.view.find('[data-testid="audio-player"]').exists()).toBe(false) + const thumb = view.get('[data-testid="chapter-illustration"]') + expect(thumb.element.tagName).toBe('BUTTON') + expect(thumb.attributes('aria-label')).toContain('插图大图') + expect(thumb.find('img').exists()).toBe(true) + // The thumbnail sits between the heading and the text, and the dialog starts closed. + const html = view.html() + expect(html.indexOf('chapter-illustration')).toBeLessThan(html.indexOf('reader-workspace')) + expect(document.querySelector('[data-testid="illustration-dialog"]')).toBeNull() + + await thumb.trigger('click') + await flushPromises() + const dialog = document.querySelector('[data-testid="illustration-dialog"]') + expect(dialog).not.toBeNull() + expect(dialog?.querySelector('img')?.getAttribute('src')).toMatch(/^blob:fictional-/) + expect(view.find('[data-testid="audio-player"]').exists()).toBe(true) + expect(useLibraryStore().audioChapterId).toBe(3) }) - it('sends the position to the book instead of only keeping it locally', async () => { - const fetchMock = mockApi() + it('stays silent for a chapter without audio or illustration', async () => { + mockApi({ audio: false, illustration: false, playbackSeconds: 0 }) + const { view } = await mountWith(ReaderView, '/chapters/3') + await flushPromises() + expect(view.find('[data-testid="audio-player"]').exists()).toBe(false) + expect(view.find('[data-testid="chapter-illustration"]').exists()).toBe(false) + }) + + it('sends the position to the chapter instead of only keeping it locally', async () => { + const { fetchMock } = mockApi() const { view } = await mountWith(ReaderView, '/chapters/3') const element = view.get('[data-testid="audio-element"]').element as HTMLAudioElement element.currentTime = 77 await view.get('[data-testid="audio-element"]').trigger('pause') await flushPromises() const reported = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'PUT') - expect(String(reported?.[0])).toContain('/api/v1/books/7/playback') + expect(String(reported?.[0])).toContain('/api/v1/chapters/3/playback') expect(JSON.parse(String(reported?.[1]?.body))).toEqual({ positionSeconds: 77 }) }) }) diff --git a/learner/src/__tests__/edit.spec.ts b/learner/src/__tests__/edit.spec.ts index d9b10ff..410f06a 100644 --- a/learner/src/__tests__/edit.spec.ts +++ b/learner/src/__tests__/edit.spec.ts @@ -8,13 +8,13 @@ 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', status: 'ready', charCount: 12, + id: 9, bookId: 1, ordinal: 1, title: 'First chapter', author: '', status: 'ready', charCount: 12, errorReason: '', errorMessage: '', jobId: 5, ...timestamps, ...overrides, }) -const source: ChapterSource = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', text: 'Mira opened the workshop.\n', status: 'ready', contentSha256: 'sha-a', charCount: 24 } +const source: ChapterSource = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', author: '', text: 'Mira opened the workshop.\n', status: 'ready', contentSha256: 'sha-a', charCount: 24 } const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data })) const fail = (msg: string, status = 400) => new Response(JSON.stringify({ code: status, msg }), { status }) let wrapper: VueWrapper | undefined @@ -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 () => { @@ -202,7 +208,7 @@ describe('book editing view', () => { await view.get('#chapter-text').setValue('A new body.\n') await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises() const patch = fetchMock.mock.calls.find(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'PATCH')! - expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited chapter', text: 'A new body.\n' }) + expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited chapter', author: '', text: 'A new body.' + String.fromCharCode(10) }) expect(view.get('[data-testid="book-notice"]').text()).toContain('已保存为新版本,正在重新处理') }) diff --git a/learner/src/__tests__/library.spec.ts b/learner/src/__tests__/library.spec.ts index 8182398..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 }) @@ -24,6 +24,7 @@ const chapter = (overrides: Partial = {}): ChapterDetail => ({ id: 55, bookId: 1, ordinal: 1, + author: '', title: '第一篇', status: 'pending', charCount: 120, diff --git a/learner/src/__tests__/progress.spec.ts b/learner/src/__tests__/progress.spec.ts index 64a5a88..780d73d 100644 --- a/learner/src/__tests__/progress.spec.ts +++ b/learner/src/__tests__/progress.spec.ts @@ -175,8 +175,8 @@ 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' }, - chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, + 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 }, }) } @@ -200,7 +200,7 @@ describe('library completion action', () => { it('reports a failure without pretending the chapter is read', async () => { useSessionStore().user = { ...user } const library = useLibraryStore() - library.chapter = { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' } + library.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' } vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ code: 409, msg: '只有处理完成的章节可以标记已读' }), { status: 409 })) await expect(library.markChapterRead(3)).rejects.toThrow('只有处理完成的章节可以标记已读') expect(library.chapter.readAt ?? null).toBeNull() @@ -226,8 +226,8 @@ 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' }, - chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, + 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/main.ts b/learner/src/main.ts index 490a6de..4f44b14 100644 --- a/learner/src/main.ts +++ b/learner/src/main.ts @@ -8,6 +8,8 @@ import 'element-plus/es/components/radio/style/css' import 'element-plus/es/components/radio-group/style/css' import 'element-plus/es/components/select/style/css' import 'element-plus/es/components/option/style/css' +import 'element-plus/es/components/dialog/style/css' +import 'element-plus/es/components/message-box/style/css' import 'element-plus/es/components/dropdown/style/css' import 'element-plus/es/components/dropdown-menu/style/css' import 'element-plus/es/components/dropdown-item/style/css' diff --git a/learner/src/stores/library.ts b/learner/src/stores/library.ts index 4ffa355..baa4bef 100644 --- a/learner/src/stores/library.ts +++ b/learner/src/stores/library.ts @@ -16,16 +16,25 @@ export interface BookAttachment { updatedAt: string } +/** The book keeps its cover; audio and illustrations belong to a chapter. */ export interface BookAttachments { cover: BookAttachment | null - audio: BookAttachment | null - /** Where this account stopped in this book, in seconds. */ - playbackSeconds: number +} + +export interface ChapterAttachment { + kind: 'audio' | 'illustration' + mime: string + byteSize: number + /** Content digest, used as the cache key so a replaced file is fetched again. */ + version: string + updatedAt: string } 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 } @@ -47,6 +56,8 @@ export interface ChapterSummary { bookId: number ordinal: number title: string + /** Optional free text the learner can set while editing the chapter. */ + author: string status: ChapterStatus charCount: number errorReason: string @@ -56,6 +67,11 @@ export interface ChapterSummary { // Set only when this completion still matches the chapter's current content; a chapter // edited into a new version reads as unread again. readAt?: string | null + /** Content digests of this chapter's files; empty when that file is absent. */ + illustrationVersion?: string + audioVersion?: string + /** Where this account stopped in this chapter, in seconds. */ + playbackSeconds?: number createdAt: string updatedAt: string } @@ -86,6 +102,7 @@ export interface ChapterSource { bookId: number ordinal: number title: string + author: string text: string status: ChapterStatus contentSha256: string @@ -214,7 +231,9 @@ export const useLibraryStore = defineStore('library', () => { const submitError = ref('') const retryingChapterId = ref(null) const completingChapterId = ref(null) - const uploadingKind = ref(null) + const uploadingKind = ref(null) + /** Which book or chapter the current upload belongs to, so only that row shows progress. */ + const uploadingId = ref(null) /** Reader text exists only for ready chapters and is never taken from a cache. */ const readerText = computed(() => (chapter.value?.status === 'ready' ? chapter.value.originalText ?? '' : '')) @@ -333,6 +352,8 @@ export const useLibraryStore = defineStore('library', () => { // The book is always present in this response; the guard also covers a stale server reply. const cover = result.book?.attachments?.cover if (cover) void loadCover(result.book.id, cover.version) + // Illustrations are loaded by the reader only: the chapter list does not show them, so the + // list does not fetch every image of the book. schedulePolling() } catch (reason) { if (seq !== bookSeq || isStale(version, owner)) return @@ -374,12 +395,16 @@ export const useLibraryStore = defineStore('library', () => { chapter.value = result.chapter navigation.value = result.navigation ?? emptyNavigation() chapterError.value = '' - const audio = result.book?.attachments?.audio + // The chapter's own files: an illustration to show and, when present, the audio to play. + const illustration = result.chapter.illustrationVersion + if (illustration) void loadChapterIllustration(result.chapter.id, illustration) + const audio = result.chapter.audioVersion if (audio) { // A failure here is shown by the player itself; the chapter stays readable without audio. - void loadAudio(result.book.id, audio.version).catch(() => undefined) + void loadChapterAudio(result.chapter.id, audio).catch(() => undefined) } else { audioUrl.value = '' + audioChapterId.value = null } schedulePolling() } catch (reason) { @@ -497,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 } @@ -516,7 +546,7 @@ export const useLibraryStore = defineStore('library', () => { * Saves a chapter title and/or text. A changed text becomes a new version and re-processes; * the same text answered again changes nothing. */ - async function updateChapter(id: number, input: { title?: string; text?: string }): Promise { + async function updateChapter(id: number, input: { title?: string; author?: string; text?: string }): Promise { const version = generation const owner = ownerId() if (input.title !== undefined) { @@ -527,8 +557,9 @@ export const useLibraryStore = defineStore('library', () => { const problem = textProblem(input.text) if (problem) throw new Error(problem) } - const body: { title?: string; text?: string } = {} + const body: { title?: string; author?: string; text?: string } = {} if (input.title !== undefined) body.title = input.title.trim() + if (input.author !== undefined) body.author = input.author.trim() if (input.text !== undefined) body.text = input.text const result = await session.request(`chapters/${id}`, 'PATCH', body) if (isStale(version, owner)) return result @@ -616,83 +647,139 @@ export const useLibraryStore = defineStore('library', () => { } /** - * Uploads or replaces one attachment of an owned book. The reply is the stored metadata, which is + * Uploads or replaces the cover of an owned book. The reply is the stored metadata, which is * written back into the open book and into the library list so a cover appears without a reload. */ - async function uploadAttachment(bookId: number, kind: AttachmentKind, file: File): Promise { + async function uploadCover(bookId: number, file: File): Promise { + return await sendAttachment(`books/${bookId}/cover`, bookId, 'cover', file) as BookAttachment + } + + /** Removes the cover of an owned book. */ + async function deleteCover(bookId: number): Promise { + return dropAttachment(`books/${bookId}/cover`, bookId, 'cover') + } + + /** Uploads or replaces one file of an owned chapter: its audio or its illustration. */ + async function uploadChapterFile(chapterId: number, kind: ChapterAttachment['kind'], file: File): Promise { + const folder = kind === 'illustration' ? 'illustration' : 'audio' + return await sendAttachment(`chapters/${chapterId}/${folder}`, chapterId, kind, file) as ChapterAttachment + } + + /** Removes one file of an owned chapter; removing the audio also clears the stored position. */ + async function deleteChapterFile(chapterId: number, kind: ChapterAttachment['kind']): Promise { + const folder = kind === 'illustration' ? 'illustration' : 'audio' + return dropAttachment(`chapters/${chapterId}/${folder}`, chapterId, kind) + } + + /** Shared upload path for a book cover and for chapter files. */ + async function sendAttachment(url: string, id: number, kind: string, file: File): Promise { const version = generation const owner = ownerId() uploadingKind.value = kind + uploadingId.value = id try { const form = new FormData() form.append('file', file, file.name) - const result = await session.request<{ attachment: BookAttachment }>(`books/${bookId}/${kind}`, 'POST', form) - if (!isStale(version, owner)) applyAttachment(bookId, kind, result.attachment) + const result = await session.request<{ attachment: BookAttachment & ChapterAttachment }>(url, 'POST', form) + if (!isStale(version, owner)) applyAttachment(id, kind, result.attachment) return result.attachment } catch (reason) { if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。') throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。') } finally { - if (!isStale(version, owner)) uploadingKind.value = null + if (!isStale(version, owner)) { + uploadingKind.value = null + uploadingId.value = null + } } } - /** Removes one attachment of an owned book; removing the audio also clears the position. */ - async function deleteAttachment(bookId: number, kind: AttachmentKind): Promise { + /** Shared delete path for a book cover and for chapter files. */ + async function dropAttachment(url: string, id: number, kind: string): Promise { const version = generation const owner = ownerId() uploadingKind.value = kind + uploadingId.value = id try { - await session.request<{ deleted: boolean }>(`books/${bookId}/${kind}`, 'DELETE') - if (!isStale(version, owner)) applyAttachment(bookId, kind, null) + await session.request<{ deleted: boolean }>(url, 'DELETE') + if (!isStale(version, owner)) applyAttachment(id, kind, null) } catch (reason) { if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('删除失败,请稍后重试。') throw reason instanceof Error ? reason : new Error('删除失败,请稍后重试。') } finally { - if (!isStale(version, owner)) uploadingKind.value = null + if (!isStale(version, owner)) { + uploadingKind.value = null + uploadingId.value = null + } } } - /** Writes the attachment change into every view that shows this book. */ - function applyAttachment(bookId: number, kind: AttachmentKind, attachment: BookAttachment | null): void { - const current = book.value?.id === bookId - ? book.value.attachments ?? { cover: null, audio: null, playbackSeconds: 0 } - : null - if (current) { - const next: BookAttachments = { ...current, [kind]: attachment } - // A new audio file has nothing to do with the old position. - if (kind === 'audio') next.playbackSeconds = 0 - book.value = { ...book.value!, attachments: next } - } - const listed = books.value.find(item => item.id === bookId) - if (listed) { - if (kind === 'cover') listed.coverVersion = attachment?.version ?? '' - else listed.hasAudio = attachment !== null - } + /** Writes an attachment change into every view that shows that book or chapter. */ + function applyAttachment(id: number, kind: string, attachment: BookAttachment | ChapterAttachment | null): void { if (kind === 'cover') { - // The cached image belongs to the previous file, so it is dropped and loaded again. - forgetCover(bookId) - if (attachment) void loadCover(bookId, attachment.version) + const cover = attachment as BookAttachment | null + if (book.value?.id === id) book.value = { ...book.value, attachments: { cover } } + const listed = books.value.find(item => item.id === id) + if (listed) listed.coverVersion = cover?.version ?? '' + forgetCover(id) + if (cover) void loadCover(id, cover.version) + return + } + // A chapter file: the chapter list entry and the open reader both carry the version. + const file = attachment as ChapterAttachment | null + const field: 'illustrationVersion' | 'audioVersion' = kind === 'illustration' ? 'illustrationVersion' : 'audioVersion' + const listed = chapters.value.find(item => item.id === id) + if (listed) { + listed[field] = file?.version ?? '' + // The stored position belonged to the replaced file, so nothing is restored for the new one. + if (kind === 'audio') listed.playbackSeconds = 0 + } + const open = chapter.value !== null && chapter.value.id === id ? chapter.value : null + if (open) { + open[field] = file?.version ?? '' + if (kind === 'audio') open.playbackSeconds = 0 } if (kind === 'audio') { - loadedVersions.delete(`audio:${bookId}`) + loadedVersions.delete(`chapter-audio:${id}`) audioUrl.value = '' + audioChapterId.value = null + if (file) void loadChapterAudio(id, file.version).catch(() => undefined) + return } + forgetIllustration(id) + if (file) void loadChapterIllustration(id, file.version) } - /** Remembers where this account stopped in one book, so reading can continue later. */ - async function savePlaybackPosition(bookId: number, seconds: number): Promise { + /** + * Reports the position of one chapter from the value already loaded. The reader calls this when it + * leaves a chapter, so the stored position matches what the person actually heard. + */ + function reportChapterPlayback(chapterId: number, seconds?: number): void { + const listed = chapters.value.find(item => item.id === chapterId) + const open = chapter.value !== null && chapter.value.id === chapterId ? chapter.value : null + const value = seconds ?? open?.playbackSeconds ?? listed?.playbackSeconds ?? 0 + if (value <= 0) return + void saveChapterPlayback(chapterId, value).catch(() => undefined) + } + + /** Remembers where this account stopped in one chapter, so reading can continue later. */ + async function saveChapterPlayback(chapterId: number, seconds: number): Promise { const version = generation const owner = ownerId() - const result = await session.request<{ playbackSeconds: number }>(`books/${bookId}/playback`, 'PUT', { positionSeconds: Math.max(0, Math.floor(seconds)) }) - if (!isStale(version, owner) && book.value?.id === bookId && book.value.attachments) { - book.value = { ...book.value, attachments: { ...book.value.attachments, playbackSeconds: result.playbackSeconds } } - } + const result = await session.request<{ playbackSeconds: number }>( + `chapters/${chapterId}/playback`, 'PUT', { positionSeconds: Math.max(0, Math.floor(seconds)) }) + if (isStale(version, owner)) return + const listed = chapters.value.find(item => item.id === chapterId) + if (listed) listed.playbackSeconds = result.playbackSeconds + if (chapter.value !== null && chapter.value.id === chapterId) chapter.value.playbackSeconds = result.playbackSeconds } /** Object URLs handed to /; the token never appears in a URL the browser sees. */ const coverUrls = ref>({}) + const illustrationUrls = ref>({}) const audioUrl = ref('') + /** Which chapter the loaded audio belongs to, so a stale player is never shown. */ + const audioChapterId = ref(null) const liveBlobUrls = new Set() const loadedVersions = new Map() @@ -707,7 +794,9 @@ export const useLibraryStore = defineStore('library', () => { liveBlobUrls.clear() loadedVersions.clear() coverUrls.value = {} + illustrationUrls.value = {} audioUrl.value = '' + audioChapterId.value = null } /** Loads a cover for the library list, once per version. */ @@ -723,14 +812,28 @@ export const useLibraryStore = defineStore('library', () => { } } - /** Loads the audio of the open book, once per version, for the player in the reader. */ - async function loadAudio(bookId: number, version: string): Promise { - if (!version || loadedVersions.get(`audio:${bookId}`) === version) return + /** Loads the illustration of a chapter, once per version, for the chapter row and the reader. */ + async function loadChapterIllustration(chapterId: number, version: string): Promise { + if (!version || loadedVersions.get(`illustration:${chapterId}`) === version) return try { - const blob = await session.requestBlob(`books/${bookId}/audio`) + const blob = await session.requestBlob(`chapters/${chapterId}/illustration`) if (!session.user) return - loadedVersions.set(`audio:${bookId}`, version) + loadedVersions.set(`illustration:${chapterId}`, version) + illustrationUrls.value = { ...illustrationUrls.value, [chapterId]: rememberBlob(blob) } + } catch { + // An illustration that cannot be read simply keeps the default styling. + } + } + + /** Loads the audio of one chapter, once per version, for the player in the reader. */ + async function loadChapterAudio(chapterId: number, version: string): Promise { + if (!version || loadedVersions.get(`chapter-audio:${chapterId}`) === version) return + try { + const blob = await session.requestBlob(`chapters/${chapterId}/audio`) + if (!session.user) return + loadedVersions.set(`chapter-audio:${chapterId}`, version) audioUrl.value = rememberBlob(blob) + audioChapterId.value = chapterId } catch (reason) { throw reason instanceof Error ? reason : new Error('音频暂时无法加载。') } @@ -740,13 +843,24 @@ export const useLibraryStore = defineStore('library', () => { function forgetCover(bookId: number): void { loadedVersions.delete(`cover:${bookId}`) const url = coverUrls.value[bookId] - if (url) { - URL.revokeObjectURL(url) - liveBlobUrls.delete(url) - const next = { ...coverUrls.value } - delete next[bookId] - coverUrls.value = next - } + if (!url) return + URL.revokeObjectURL(url) + liveBlobUrls.delete(url) + const next = { ...coverUrls.value } + delete next[bookId] + coverUrls.value = next + } + + /** Drops one cached illustration so a replaced file is fetched again. */ + function forgetIllustration(chapterId: number): void { + loadedVersions.delete(`illustration:${chapterId}`) + const url = illustrationUrls.value[chapterId] + if (!url) return + URL.revokeObjectURL(url) + liveBlobUrls.delete(url) + const next = { ...illustrationUrls.value } + delete next[chapterId] + illustrationUrls.value = next } /** @@ -821,6 +935,7 @@ export const useLibraryStore = defineStore('library', () => { retryingChapterId.value = null completingChapterId.value = null uploadingKind.value = null + uploadingId.value = null releaseBlobs() submissionKey = '' submissionRequestId = '' @@ -832,9 +947,10 @@ export const useLibraryStore = defineStore('library', () => { chapter, chapterBook, navigation, chapterLoading, chapterError, submitting, submitError, retryingChapterId, completingChapterId, readerText, loadBooks, loadBook, loadChapter, submit, upload, retryChapter, markChapterRead, - uploadAttachment, deleteAttachment, savePlaybackPosition, uploadingKind, - coverUrls, audioUrl, loadCover, loadAudio, - renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter, + uploadCover, deleteCover, uploadChapterFile, deleteChapterFile, saveChapterPlayback, reportChapterPlayback, + uploadingKind, uploadingId, + coverUrls, illustrationUrls, audioUrl, audioChapterId, loadCover, loadChapterIllustration, loadChapterAudio, + updateBook, updateChapter, loadChapterSource, deleteBook, deleteChapter, stopPolling, closeBook, closeChapter, reset, } }) diff --git a/learner/src/style.css b/learner/src/style.css index f86037e..56dac91 100644 --- a/learner/src/style.css +++ b/learner/src/style.css @@ -228,6 +228,7 @@ a.chapter-name:hover { color: var(--accent); text-decoration: underline; } .chapter-meta { margin: 4px 0 0; color: var(--text-muted); font-size: 13px; overflow-wrap: anywhere; } /* Reader keeps the pasted text exactly as it was, including line breaks and tabs. */ .reader-page { max-width: 820px; } +.chapter-author { margin: -6px 0 0; color: var(--text-muted); font-size: 14px; } .reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: calc(17px * var(--reader-font-scale)); line-height: 2; } .processing-hint { padding: 36px 0; color: var(--text-muted); } .reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid var(--border); } @@ -346,6 +347,25 @@ button:focus-visible, a:focus-visible, [role='button']:focus-visible, /* Discoverable keyboard operations on the review page. */ .shortcut-hint { margin: 22px 0 0; color: var(--text-muted); font-size: 13px; } +/* Chapter illustration: a small thumbnail that opens the original in a dialog. */ +.illustration-thumb { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 6px; margin: 0 0 18px; padding: 8px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); cursor: zoom-in; } +.illustration-thumb img { display: block; height: 120px; width: auto; max-width: 100%; border-radius: 8px; object-fit: cover; } +.illustration-thumb:hover { border-color: var(--accent); } +.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; } @@ -360,6 +380,18 @@ 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; } +/* A label and its input share one line, so the dialog spends its height on the text being edited. */ +.field-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } +.field-row label { flex: 0 0 76px; margin: 0; font-size: 14px; } +.field-row .el-input { flex: 1; } +/* One compact line per attachment: label, state, actions, then the size rule underneath. */ +.attachment-block { display: flex; gap: 14px; align-items: flex-start; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); } +.attachment-block .attachment-preview { flex: 0 0 96px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; background: var(--accent-soft); } +.attachment-block .attachment-info { min-width: 0; flex: 1; } +.attachment-block .attachment-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0; } +.attachment-block .attachment-label { font-weight: 600; } +.attachment-block .subtle { margin: 2px 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 dd7c532..a88cb83 100644 --- a/learner/src/views/BookView.vue +++ b/learner/src/views/BookView.vue @@ -2,7 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { RouterLink, useRoute, useRouter } from 'vue-router' import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus' -import { canRetry, fileSizeLabel, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library' +import { canRetry, fileSizeLabel, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource, type ChapterSummary } from '../stores/library' import { useSessionStore } from '../stores/session' import DisplaySettings from '../components/DisplaySettings.vue' @@ -12,94 +12,17 @@ const route = useRoute() const router = useRouter() const retryError = ref('') const notice = ref('') -const attachmentError = ref<{ cover: string; audio: string }>({ cover: '', audio: '' }) - -const ATTACHMENT_RULES = { - cover: { types: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 2 << 20, label: '封面只支持 JPG、PNG 或 WebP 图片,且不超过 2 MiB。' }, - audio: { types: ['audio/mpeg'], maxBytes: 20 << 20, label: '音频只支持 MP3,且不超过 20 MiB。' }, -} as const - -const coverState = computed(() => { - const cover = library.book?.attachments?.cover - if (!cover) return '未设置(显示默认封面)' - return `${cover.mime === 'image/png' ? 'PNG' : cover.mime === 'image/webp' ? 'WebP' : 'JPG'} · ${fileSizeLabel(cover.byteSize)}` -}) - -const audioState = computed(() => { - const audio = library.book?.attachments?.audio - if (!audio) return '未设置(阅读时可正常使用)' - const position = library.book?.attachments?.playbackSeconds ?? 0 - const detail = `${fileSizeLabel(audio.byteSize)}${position > 0 ? ` · 上次播放位置 ${Math.floor(position / 60)}:${String(position % 60).padStart(2, '0')}` : ''}` - return `MP3 · ${detail}` -}) - -/** The browser reports a type, but the server checks the bytes: this only avoids an obvious mistake. */ -function reject(kind: 'cover' | 'audio', file: File): string { - const rules = ATTACHMENT_RULES[kind] - if (file.size === 0) return '文件为空,请重新选择。' - if (file.size > rules.maxBytes) return rules.label - if (file.type && !(rules.types as readonly string[]).includes(file.type)) return rules.label - return '' -} - -async function upload(kind: 'cover' | 'audio', file: File): Promise { - if (library.book === null) return - attachmentError.value = { ...attachmentError.value, [kind]: '' } - const problem = reject(kind, file) - if (problem) { - attachmentError.value = { ...attachmentError.value, [kind]: problem } - return - } - try { - await library.uploadAttachment(library.book.id, kind, file) - notice.value = kind === 'cover' ? '封面已更新。' : '音频已更新,播放位置已重置。' - } catch (reason) { - attachmentError.value = { ...attachmentError.value, [kind]: reason instanceof Error ? reason.message : '上传失败,请稍后重试。' } - } -} - -function onCoverPicked(event: Event): void { - const input = event.target as HTMLInputElement - const file = input.files?.[0] - input.value = '' - if (file) void upload('cover', file) -} - -function onAudioPicked(event: Event): void { - const input = event.target as HTMLInputElement - const file = input.files?.[0] - input.value = '' - if (file) void upload('audio', file) -} - -async function removeAttachment(kind: 'cover' | 'audio'): Promise { - if (library.book === null) return - const question = kind === 'cover' ? '移除这本书的封面?' : '移除这本书的音频?播放位置会一起清除。' - try { - await ElMessageBox.confirm(question, kind === 'cover' ? '移除封面' : '移除音频', { - confirmButtonText: '确认移除', cancelButtonText: '取消', type: 'warning', - }) - } catch { return } - attachmentError.value = { ...attachmentError.value, [kind]: '' } - try { - await library.deleteAttachment(library.book.id, kind) - notice.value = kind === 'cover' ? '封面已移除。' : '音频已移除。' - } catch (reason) { - attachmentError.value = { ...attachmentError.value, [kind]: reason instanceof Error ? reason.message : '移除失败,请稍后重试。' } - } -} - -function removeCover(): void { void removeAttachment('cover') } -function removeAudio(): void { void removeAttachment('audio') } // 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) const chapterSource = ref(null) const chapterTitle = ref('') +const chapterAuthor = ref('') const chapterText = ref('') const chapterError = ref('') const chapterLoading = ref(false) @@ -107,6 +30,177 @@ const saving = ref(false) const bookId = computed(() => Number(route.params.id)) const chapterLength = computed(() => [...chapterText.value].length) +const coverError = ref('') +// Errors for the chapter's files, shown inside the chapter editor next to the file they belong to. +const illustrationError = ref('') +const audioError = ref('') + +const COVER_RULES = { types: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 2 << 20, label: '封面只支持 JPG、PNG 或 WebP 图片,且不超过 2 MiB。' } +const ILLUSTRATION_RULES = { types: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 2 << 20, label: '插图只支持 JPG、PNG 或 WebP 图片,且不超过 2 MiB。' } +const AUDIO_RULES = { types: ['audio/mpeg'], maxBytes: 20 << 20, label: '音频只支持 MP3,且不超过 20 MiB。' } + +const coverState = computed(() => { + const cover = library.book?.attachments?.cover + if (!cover) return '未设置(显示默认封面)' + return `${imageLabel(cover.mime)} · ${fileSizeLabel(cover.byteSize)}` +}) + +function imageLabel(mime: string): string { + if (mime === 'image/png') return 'PNG' + if (mime === 'image/webp') return 'WebP' + return 'JPG' +} + +function clock(seconds: number): string { + const total = Math.max(0, Math.floor(seconds)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}` +} + +/** + * The meta line of one chapter row: audio and its position only. The illustration is deliberately + * not shown here (it is read inside the chapter), so the row stays compact and the list does not + * fetch every image. + */ +function chapterMeta(item: ChapterSummary): string { + const parts: string[] = [] + if (item.audioVersion) { + parts.push('有音频') + if (item.playbackSeconds) parts.push(`上次 ${clock(item.playbackSeconds)}`) + } + return parts.join(' · ') +} + +function attachmentSummary(item: ChapterSummary | null): string { + if (!item) return '' + const files: string[] = [] + if (item.illustrationVersion) files.push(`插图 ${fileSizeLabel(0)}` .trim()) + return files.join(' · ') +} + +/** The state line of one attachment inside the dialog. */ +function fileState(item: ChapterSummary | null, kind: 'illustration' | 'audio'): string { + if (!item) return '未设置' + const version = kind === 'illustration' ? item.illustrationVersion : item.audioVersion + if (!version) return '未设置' + if (kind === 'illustration') return '已设置' + const position = item.playbackSeconds ?? 0 + return position > 0 ? `已设置 · 上次播放位置 ${clock(position)}` : '已设置' +} + +/** The browser reports a type, but the server checks the bytes: this only avoids an obvious mistake. */ +function reject(rules: { types: readonly string[]; maxBytes: number; label: string }, file: File): string { + if (file.size === 0) return '文件为空,请重新选择。' + if (file.size > rules.maxBytes) return rules.label + if (file.type && !rules.types.includes(file.type)) return rules.label + return '' +} + +async function uploadCoverFile(file: File): Promise { + if (library.book === null) return + coverError.value = '' + const problem = reject(COVER_RULES, file) + if (problem) { + coverError.value = problem + return + } + try { + await library.uploadCover(library.book.id, file) + notice.value = '封面已更新。' + } catch (reason) { + coverError.value = reason instanceof Error ? reason.message : '上传失败,请稍后重试。' + } +} + +function onCoverPicked(event: Event): void { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (file) void uploadCoverFile(file) +} + +async function removeCover(): Promise { + if (library.book === null) return + try { + await ElMessageBox.confirm('移除这本书的封面?', '移除封面', { + confirmButtonText: '确认移除', cancelButtonText: '取消', type: 'warning', + }) + } catch { return } + coverError.value = '' + try { + await library.deleteCover(library.book.id) + notice.value = '封面已移除。' + } catch (reason) { + coverError.value = reason instanceof Error ? reason.message : '移除失败,请稍后重试。' + } +} + +/** + * 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 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 { + const chapter = attachmentLive.value + if (!chapter) return + const rules = kind === 'illustration' ? ILLUSTRATION_RULES : AUDIO_RULES + if (kind === 'illustration') illustrationError.value = '' + else audioError.value = '' + const problem = reject(rules, file) + if (problem) { + if (kind === 'illustration') illustrationError.value = problem + else audioError.value = problem + return + } + try { + await library.uploadChapterFile(chapter.id, kind, file) + notice.value = kind === 'illustration' ? '插图已更新。' : '音频已更新,播放位置已重置。' + } catch (reason) { + const message = reason instanceof Error ? reason.message : '上传失败,请稍后重试。' + if (kind === 'illustration') illustrationError.value = message + else audioError.value = message + } +} + +function onIllustrationPicked(event: Event): void { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (file) void uploadChapterFile('illustration', file) +} + +function onChapterAudioPicked(event: Event): void { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (file) void uploadChapterFile('audio', file) +} + +async function removeChapterFile(kind: 'illustration' | 'audio'): Promise { + const chapter = attachmentLive.value + if (!chapter) return + const question = kind === 'illustration' ? '移除这一章的插图?' : '移除这一章的音频?播放位置会一起清除。' + try { + await ElMessageBox.confirm(question, kind === 'illustration' ? '移除插图' : '移除音频', { + confirmButtonText: '确认移除', cancelButtonText: '取消', type: 'warning', + }) + } catch { return } + if (kind === 'illustration') illustrationError.value = '' + else audioError.value = '' + try { + await library.deleteChapterFile(chapter.id, kind) + notice.value = kind === 'illustration' ? '插图已移除。' : '音频已移除。' + } catch (reason) { + const message = reason instanceof Error ? reason.message : '移除失败,请稍后重试。' + if (kind === 'illustration') illustrationError.value = message + else audioError.value = message + } +} async function load() { retryError.value = '' @@ -121,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 } @@ -131,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 { @@ -145,11 +240,14 @@ 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) chapterSource.value = source chapterTitle.value = source.title + chapterAuthor.value = source.author chapterText.value = source.text } catch (reason) { chapterError.value = reason instanceof Error ? reason.message : '章节内容暂时无法加载,请稍后重试。' @@ -165,7 +263,9 @@ async function saveChapter() { if (chapterError.value) return saving.value = true try { - const edited = await library.updateChapter(source.id, { title: chapterTitle.value, text: chapterText.value }) + const edited = await library.updateChapter(source.id, { + title: chapterTitle.value, author: chapterAuthor.value, text: chapterText.value, + }) chapterDialog.value = false notice.value = edited.versionChanged ? '章节已保存为新版本,正在重新处理。' : '章节已保存。' } catch (reason) { @@ -241,51 +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 }} - {{ attachmentError.cover }} - - - {{ library.coverUrls[library.book.id] ? '替换封面' : '上传封面' }} - - - 移除 - - JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096。 - + + + + {{ library.book.title.slice(0, 1) }} - - - 音频 - {{ audioState }} - {{ attachmentError.audio }} - - - {{ library.book.attachments?.audio ? '替换音频' : '上传音频' }} - - - 移除 - - MP3,最大 20 MiB;播放不与文本同步。替换或移除后播放位置会重置。 - + + + {{ library.book.attachments?.cover ? '替换封面' : '上传封面' }} + + + 移除 + {{ coverError }} {{ retryError }} @@ -297,6 +377,7 @@ onUnmounted(() => library.closeBook()) {{ item.title }} {{ item.charCount }} 字符 + · {{ chapterMeta(item) }} · {{ item.errorMessage }} @@ -317,10 +398,15 @@ onUnmounted(() => library.closeBook()) - - 书名 - - 封面使用系统默认样式。 + + + 书名 + + + + 作者 + + {{ bookError }} 取消 @@ -331,12 +417,54 @@ onUnmounted(() => library.closeBook()) 正在加载… - 章节标题 - + + 章节标题 + + + + 作者 + + 正文 - - {{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符 - 保存后会重新处理这一章;只改标题不会重新处理。 + + {{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符 · 保存后重新处理这一章 + + + + + 图 + + + + 插图 + {{ attachmentLive ? fileState(attachmentLive, 'illustration') : '未设置' }} + + {{ attachmentLive?.illustrationVersion ? '替换' : '上传' }} + + + 移除 + + {{ illustrationError }} + JPG/PNG/WebP · ≤2 MiB · ≤4096×4096 + + + + + + + 音频 + {{ attachmentLive ? fileState(attachmentLive, 'audio') : '未设置' }} + + {{ attachmentLive?.audioVersion ? '替换' : '上传' }} + + + 移除 + + {{ audioError }} + MP3 · ≤20 MiB · 替换或移除会重置位置 + + + 标题、作者与正文点保存后生效;插图与音频选中文件后立即上传。 {{ chapterError }} diff --git a/learner/src/views/ReaderView.vue b/learner/src/views/ReaderView.vue index 707c797..eae3e4b 100644 --- a/learner/src/views/ReaderView.vue +++ b/learner/src/views/ReaderView.vue @@ -1,7 +1,7 @@
{{ library.chapters.length }} 个章节 · 语言 英语
{{ notice }}
封面
{{ coverState }}
{{ attachmentError.cover }}
JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096。
音频
{{ audioState }}
{{ attachmentError.audio }}
MP3,最大 20 MiB;播放不与文本同步。替换或移除后播放位置会重置。
{{ retryError }}
{{ item.charCount }} 字符 + · {{ chapterMeta(item) }} · {{ item.errorMessage }}
封面使用系统默认样式。
{{ bookError }}
正在加载…
{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符
保存后会重新处理这一章;只改标题不会重新处理。
{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符 · 保存后重新处理这一章
{{ illustrationError }}
JPG/PNG/WebP · ≤2 MiB · ≤4096×4096
{{ audioError }}
MP3 · ≤20 MiB · 替换或移除会重置位置
标题、作者与正文点保存后生效;插图与音频选中文件后立即上传。
{{ chapterError }}