feat: 书籍封面与音频附件、播放器与播放位置 (#21)

- schema v8:lexgo_book_attachments(按 book_id+kind 唯一,bytes MEDIUMBLOB)与
  lexgo_playback_positions(owner_id+book_id 主键),只用可重放 DDL,外键级联删书
- 后端:按文件头 magic bytes 判定类型(MP3 / JPG / PNG / WebP,含 WebP 维度解析),
  音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096²;上传/替换/移除;binaryResponse 交给
  http.ServeContent 提供 Range 206、416、ETag 与 304;播放位置 upsert,替换音频时重置
- 学习端:带凭据的 fetch + 对象 URL(令牌不进 URL),书库封面、书籍页「音频与封面」
  区块、阅读页常驻播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试、位置上报)
- 测试:Go 86 项(新增附件单测/集成与 v7→v8 迁移)、学习端 157 单测与 26 项 E2E
- 顺带修掉 e2e/phrase.spec.ts 的偶发失败:重试条件改为断言期望词数、重读坐标、关面板后重试
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home /
  Deployment-and-Operations(备份范围含附件与体积提示)
This commit was merged in pull request #36.
This commit is contained in:
ila
2026-09-15 18:39:56 +08:00
parent dde2ac127a
commit ab23ff60e5
30 changed files with 2339 additions and 54 deletions
+28
View File
@@ -345,3 +345,31 @@ 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; }
/* 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; }
.book-cover-default { display: grid; place-items: center; width: 100%; height: 100%; font-family: Georgia, serif; font-size: 40px; color: var(--accent); }
/* Book attachments: metadata, preview and the upload controls. */
.attachments { margin-top: 26px; padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
.attachments h2 { margin: 0 0 16px; font-size: 19px; }
.attachment-row { display: flex; gap: 18px; align-items: flex-start; padding: 14px 0; border-top: 1px solid var(--border); }
.attachment-row:first-of-type { border-top: 0; padding-top: 0; }
.attachment-preview { flex: 0 0 160px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; background: var(--accent-soft); }
.attachment-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attachment-info { min-width: 0; flex: 1; }
.attachment-label { margin: 0 0 4px; font-weight: 600; }
.attachment-info .subtle { margin: 4px 0 0; }
.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); }
.attachment-actions label.is-busy { opacity: 0.6; cursor: progress; }
/* Audio player: sticky so the controls stay reachable while the chapter is read. */
.audio-player { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 18px; padding: 12px 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: 0 2px 10px #233d3114; }
.audio-clock { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.audio-range { flex: 1 1 160px; min-width: 120px; accent-color: var(--accent); }
.audio-rates { display: flex; gap: 4px; }
.audio-rate { min-width: 44px; min-height: 32px; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface-raised); color: var(--text); font-size: 12px; cursor: pointer; }
.audio-rate.is-active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.audio-error { display: inline-flex; align-items: center; gap: 6px; color: var(--notice-text); font-size: 13px; }
.audio-player audio { display: none; }