From c22f9c3f0624ab7d77c50cee558237db962d60ab Mon Sep 17 00:00:00 2001 From: QiuSW Date: Tue, 15 Sep 2026 22:22:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=BC=E5=85=A5=20Element=20Plus=20?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E4=B8=8E=E7=A1=AE=E8=AE=A4=E6=A1=86?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=EF=BC=8C=E5=BC=B9=E7=AA=97=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=98=AF=E6=96=87=E6=A1=A3=E6=B5=81=E5=9D=97=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.ts 补上 dialog 与 message-box 的样式导入:自 #10 起 ElDialog/ElMessageBox 一直 缺少样式,退化成列表后面的普通块(用户报告「编辑区显示在章节列表下面」) - 新增 e2e/overlay.ts:expectModalOverlay / expectMessageBoxOverlay 断言弹窗位于 .el-overlay(position: fixed)、在视口内且横向不溢出 - attachments.spec.ts(章节编辑对话框、插图弹窗)与 edit.spec.ts(书名对话框、删除确认) 都改用该断言,堵住「只看可见性、不看是否浮层」的验证盲区 - 真实链路确认:对话框遮罩为 rgba(0,0,0,0.5)、居中于视口;确认框同样为固定浮层 - 文档:Local-Development 记录缺陷、根因、修复与新增回归断言 --- docs/04-local-development-and-verification.md | 52 ++++++++++++++++++- learner/e2e/attachments.spec.ts | 6 ++- learner/e2e/edit.spec.ts | 4 ++ learner/e2e/overlay.ts | 42 +++++++++++++++ learner/src/main.ts | 2 + 5 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 learner/e2e/overlay.ts diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index e735b9f..146e06f 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: 5459d79e2bf6339450af5add58a61ef22b0467ea -synchronized_at: 2026-09-15T14:13:31Z +wiki_revision: 1608947699ae456fa13b5d6e75846c50cb96b408 +synchronized_at: 2026-09-15T14:22:36Z # 本地开发与验证 @@ -885,3 +885,51 @@ schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playbac - Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。 - 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。 - 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。 + +## #37 章级附件验证(2026-09-15) + +### 迁移 + +schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。 + +| 命令 | 结果 | +|---|---| +| `go vet ./...` / `gofmt -l` | 通过、无输出 | +| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) | +| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 | +| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 | +| 真实 API+MySQL | **52 项检查通过**(见下) | +| #15 恢复演练(第三次) | **22 项检查通过**(见下) | + +### 真实链路(52 项) + +用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。 + +### 恢复演练(第三次,22 项) + +带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。 + +### 插图交互修订(2026-09-15,按用户要求) + +书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。 + +### 插图交互修订(2026-09-15,按用户要求) + +书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。 + +### 附件并入章节编辑对话框(2026-09-15,按用户要求) + +章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。 + +### 对话框浮层缺陷修复(2026-09-15,用户发现) + +用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。 + +**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。 + +### 未验证 + +- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。 +- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。 +- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。 +- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。 diff --git a/learner/e2e/attachments.spec.ts b/learner/e2e/attachments.spec.ts index f9b1846..3a8e509 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' } @@ -114,7 +115,8 @@ test('the library shows the cover and the chapter dialog manages its files', asy await page.screenshot({ path: '../.local/evidence/issue37-chapter-row.png' }) await page.getByTestId('edit-chapter-3').click() - await expect(page.getByTestId('chapter-dialog')).toBeVisible() + // 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('attachment-timing')).toContainText('立即上传') await expect(page.getByTestId('illustration-state')).toContainText('已设置') @@ -180,7 +182,7 @@ test('the reader plays the audio without starting it and remembers the position' await expect(thumbnail).toBeVisible() await expect(thumbnail).toContainText('点击查看大图') await thumbnail.click() - await expect(page.getByTestId('illustration-dialog')).toBeVisible() + 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') diff --git a/learner/e2e/edit.spec.ts b/learner/e2e/edit.spec.ts index e90edfe..0eedad3 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. @@ -69,6 +70,7 @@ 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 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() @@ -93,6 +95,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/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/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'