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:
@@ -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 找不回;当前无真实用户,工单已记录。
|
||||
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user