fix: 导入 Element Plus 对话框与确认框样式,弹窗不再是文档流块 (#37)

- 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 记录缺陷、根因、修复与新增回归断言
This commit is contained in:
ila
2026-09-15 22:22:50 +08:00
parent 771c3df976
commit c22f9c3f06
5 changed files with 102 additions and 4 deletions
+50 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
wiki_revision: 5459d79e2bf6339450af5add58a61ef22b0467ea
synchronized_at: 2026-09-15T14:13:31Z
wiki_revision: 1608947699ae456fa13b5d6e75846c50cb96b408
synchronized_at: 2026-09-15T14:22:36Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -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 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 对话框浮层缺陷修复(2026-09-15,用户发现)
用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。
**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
+4 -2
View File
@@ -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')
+4
View File
@@ -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)
+42
View File
@@ -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<void> {
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<void> {
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')
}
+2
View File
@@ -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'