fix(admin): 完善弹窗滚动与表单交互布局

This commit is contained in:
2026-10-21 07:40:08 +08:00
parent 2f9a3330e5
commit 0a1968a134
41 changed files with 614 additions and 131 deletions
@@ -0,0 +1,149 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { compileStyle, parse } from 'vue/compiler-sfc'
import AppDialog from './AppDialog.vue'
import AppDrawer from './AppDrawer.vue'
let wrapper: VueWrapper | undefined
const styles: HTMLStyleElement[] = []
function installStyles() {
const add = (css: string) => {
const style = document.createElement('style')
style.textContent = css.replace(/@import[^;]+;/g, '')
document.head.append(style)
styles.push(style)
}
for (const name of ['base', 'el-dialog', 'el-drawer', 'el-form', 'el-form-item', 'el-button']) {
add(readFileSync(resolve(process.cwd(), `node_modules/element-plus/theme-chalk/${name}.css`), 'utf8'))
}
for (const [name, component] of [['AppDialog', AppDialog], ['AppDrawer', AppDrawer]] as const) {
const source = readFileSync(resolve(process.cwd(), `src/shared/components/${name}.vue`), 'utf8')
for (const style of parse(source).descriptor.styles) {
const compiled = compileStyle({ source: style.content, filename: `${name}.vue`, scoped: style.scoped, id: component.__scopeId || 'data-v-layout' })
expect(compiled.errors).toEqual([])
add(compiled.code)
}
}
for (const name of ['adminDensityTokens', 'adminDensity', 'adminWorkbenchTokens', 'adminWorkbench']) {
add(readFileSync(resolve(process.cwd(), `src/styles/${name}.css`), 'utf8'))
}
}
async function render(template: string) {
installStyles()
wrapper = mount(defineComponent({
components: { AppDialog, AppDrawer },
setup: () => ({ open: ref(true), childOpen: ref(true), name: ref(''), valid: ref(false) }),
template,
}), { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
return wrapper
}
function element(selector: string) {
const result = document.querySelector<HTMLElement>(selector)
expect(result, selector).not.toBeNull()
return result!
}
const computed = (selector: string) => getComputedStyle(element(selector))
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
styles.splice(0).forEach(style => style.remove())
document.body.innerHTML = ''
})
describe('real Element floating frame boundaries', () => {
it('keeps a comfortable parent from changing nested dense and native dialog chrome', async () => {
await render(`
<AppDialog v-model="open" title="父窗口" class="outer admin-density-dialog admin-density-dialog--comfortable admin-workbench is-dialog" fullscreenable>
<AppDialog v-model="childOpen" title="子窗口" class="inner admin-density-dialog admin-workbench is-dialog"><p>子窗口正文</p></AppDialog>
<el-dialog :model-value="true" title="原生预览" class="nested-native"><p>预览内容</p></el-dialog>
</AppDialog>
<AppDialog :model-value="true" title="独立窗口" class="reference admin-density-dialog admin-workbench is-dialog"><p>独立正文</p></AppDialog>
<el-dialog :model-value="true" title="独立预览" class="reference-native"><p>预览内容</p></el-dialog>
`)
expect(element('.inner').closest('.outer')).not.toBeNull()
expect(element('.outer').classList.contains('el-dialog')).toBe(true)
for (const part of ['header', 'body', 'footer']) {
expect(computed(`.inner > .el-dialog__${part}`).padding).toBe(computed(`.reference > .el-dialog__${part}`).padding)
}
expect(computed('.nested-native > .el-dialog__header').padding).toBe(computed('.reference-native > .el-dialog__header').padding)
expect(computed('.nested-native > .el-dialog__body').overflowY).toBe(computed('.reference-native > .el-dialog__body').overflowY)
expect(computed('.inner > .el-dialog__header > .el-dialog__title').paddingRight).toBe(computed('.reference > .el-dialog__header > .el-dialog__title').paddingRight)
})
it('isolates drawer chrome and honors an explicitly teleported nested dialog', async () => {
await render(`
<AppDrawer v-model="open" title="详情" class="outer-drawer admin-density-drawer admin-workbench is-drawer" size="900px">
<el-drawer :model-value="true" title="原生抽屉" class="nested-drawer"><p>详情</p></el-drawer>
<AppDialog v-model="childOpen" title="编辑" append-to-body class="portal admin-density-dialog admin-workbench is-dialog"><p>编辑内容</p></AppDialog>
</AppDrawer>
<el-drawer :model-value="true" title="独立抽屉" class="reference-drawer"><p>详情</p></el-drawer>
`)
expect(element('.outer-drawer').classList.contains('el-drawer')).toBe(true)
expect(element('.nested-drawer').closest('.outer-drawer')).not.toBeNull()
expect(element('.portal').closest('.outer-drawer')).toBeNull()
expect(element('.portal').classList.contains('admin-workbench')).toBe(true)
for (const part of ['header', 'body']) {
expect(computed(`.nested-drawer > .el-drawer__${part}`).padding).toBe(computed(`.reference-drawer > .el-drawer__${part}`).padding)
}
})
it('keeps the native header and footer around a flexible body through fullscreen and reopen', async () => {
const view = await render(`
<AppDialog v-model="open" title="船期表单" fullscreenable width="760px" class="editor admin-density-dialog admin-workbench is-dialog">
<el-input v-model="name" aria-label="船名" />
<template #footer><el-button>取消</el-button><el-button>保存模板</el-button><el-button>预览生成</el-button><el-button type="primary">生成船期</el-button></template>
</AppDialog>
`)
expect(element('.editor > .el-dialog__header > .el-dialog__title').textContent).toBe('船期表单')
expect(computed('.editor > .el-dialog__body').flexGrow).toBe('1')
expect(computed('.editor > .el-dialog__body').minHeight).toBe('0px')
expect(computed('.editor > .el-dialog__body').overflowY).toBe('auto')
expect(computed('.editor > .el-dialog__footer').flexShrink).toBe('0')
expect(computed('.editor > .el-dialog__footer').flexWrap).toBe('wrap')
await view.get('input[aria-label="船名"]').setValue('已填写船名')
element('.editor button[aria-label="展开全屏"]').click()
await flushPromises()
expect(element('.editor').classList.contains('is-fullscreen')).toBe(true)
expect(computed('.editor > .el-dialog__body').flexGrow).toBe('1')
expect((element('.editor input') as HTMLInputElement).value).toBe('已填写船名')
const dialog = view.findComponent(AppDialog)
dialog.vm.$emit('update:modelValue', false)
await flushPromises()
dialog.vm.$emit('update:modelValue', true)
await flushPromises()
expect(element('.editor').classList.contains('is-fullscreen')).toBe(false)
expect((element('.editor input') as HTMLInputElement).value).toBe('已填写船名')
expect(element('.editor > .el-dialog__footer').querySelectorAll('button')).toHaveLength(4)
})
it('preserves workspace dimensions and lets multiline cell errors occupy space inside long forms', async () => {
await render(`
<AppDialog v-model="open" title="长表单" width="1560px" fullscreenable class="workspace admin-density-dialog admin-density-workspace admin-workbench is-dialog">
<el-form class="density-form">
<table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="必填说明" required error="请补充完整的说明,长错误文字应在当前单元格内换行,并为下一行字段留出空间。"><el-input v-model="name" /></el-form-item></td></tr>
<tr v-for="index in 30" :key="index"><td>明细 {{ index }}</td></tr>
</tbody></table>
</el-form>
</AppDialog>
`)
expect(computed('.workspace').height).toBe('92vh')
expect(computed('.workspace').maxHeight).toBe('92vh')
expect(element('.workspace .el-form-item.is-required')).toBeDefined()
await vi.waitFor(() => expect(document.querySelector('.workspace .el-form-item__error')).not.toBeNull())
expect(element('.workspace .el-form-item__error').textContent).toContain('下一行字段')
expect(computed('.workspace .el-form-item__error').position).toBe('static')
expect(computed('.workspace .el-form-item__error').flexBasis).toBe('100%')
expect(element('.workspace table').querySelectorAll('tr')).toHaveLength(31)
expect(element('.workspace > .el-dialog__footer').closest('.el-dialog__body')).toBeNull()
})
})
+1 -1
View File
@@ -104,7 +104,7 @@ describe('AppDialog', () => {
expect(source).toContain(':global(.app-dialog.el-dialog) { position: relative; display: flex;')
expect(source).toContain('width: 100%; max-width: 100%; height: 100vh;')
expect(source).toContain(':global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto;')
expect(source).toContain(':global(.app-dialog > .el-dialog__body) { flex: 1 1 auto; min-height: 0; overflow-y: auto;')
expect(source).toContain('max-height: calc(100svh - 20px)')
})
+7 -5
View File
@@ -136,9 +136,11 @@ function closeDialog() {
<style scoped>
:global(.app-dialog.el-dialog) { position: relative; display: flex; max-height: min(90vh, 900px); flex-direction: column; overflow: hidden; }
:global(.app-dialog.el-dialog.is-fullscreen) { width: 100%; max-width: 100%; height: 100vh; max-height: 100vh; margin: 0 !important; border-radius: 0; }
:global(.app-dialog .el-dialog__header), :global(.app-dialog .el-dialog__footer) { flex: 0 0 auto; }
:global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
:global(.app-dialog.has-native-fullscreen-control .el-dialog__title) { display: block; padding-right: 44px; }
:global(.app-dialog > .el-dialog__header), :global(.app-dialog > .el-dialog__footer) { flex: 0 0 auto; }
:global(.app-dialog > .el-dialog__body) { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
:global(.app-dialog > .el-dialog__footer:has(> .el-button)) { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; }
:global(.app-dialog > .el-dialog__footer > .el-button) { margin-left: 0; }
:global(.app-dialog.has-native-fullscreen-control > .el-dialog__header > .el-dialog__title) { display: block; padding-right: 44px; }
.app-dialog__header { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; color: var(--cc-text); font-size: 18px; font-weight: 600; line-height: 24px; }
.app-dialog__header-content { min-width: 0; flex: 1; }
.app-dialog__header > :deep(.el-button) { flex: 0 0 auto; margin-right: 24px; color: var(--cc-muted); }
@@ -146,6 +148,6 @@ function closeDialog() {
.app-dialog__native-fullscreen-control.is-without-close { right: 12px; }
.app-dialog__body { min-height: 0; }
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-dialog__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { :global(.app-dialog.el-dialog) { max-height: calc(100svh - 20px); margin-block: 10px !important; } .app-dialog__footer :deep(.el-button) { min-height: 44px; } }
.app-dialog__footer > :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { :global(.app-dialog.el-dialog) { max-height: calc(100svh - 20px); margin-block: 10px !important; } .app-dialog__footer > :deep(.el-button) { min-height: 44px; } }
</style>
+1 -1
View File
@@ -60,7 +60,7 @@ describe('AppDrawer', () => {
it('keeps the drawer chrome fixed while long content scrolls in the body', () => {
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDrawer.vue'), 'utf8')
expect(source).toContain(':global(.app-drawer .el-drawer__body) { min-height: 0; overflow-y: auto;')
expect(source).toContain(':global(.app-drawer > .el-drawer__body) { min-height: 0; overflow-y: auto;')
expect(source).toContain('max-width: 94vw')
})
})
+6 -4
View File
@@ -88,10 +88,12 @@ function closeDrawer() {
<style scoped>
:global(.app-drawer) { max-width: calc(100vw - 20px); }
:global(.app-drawer .el-drawer__header), :global(.app-drawer .el-drawer__footer) { flex: 0 0 auto; }
:global(.app-drawer .el-drawer__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
:global(.app-drawer > .el-drawer__header), :global(.app-drawer > .el-drawer__footer) { flex: 0 0 auto; }
:global(.app-drawer > .el-drawer__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
:global(.app-drawer > .el-drawer__footer:has(> .el-button)) { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; }
:global(.app-drawer > .el-drawer__footer > .el-button) { margin-left: 0; }
.app-drawer__body { min-height: 0; }
.app-drawer__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-drawer__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { :global(.app-drawer) { max-width: 94vw; } .app-drawer__footer :deep(.el-button) { min-height: 44px; } }
.app-drawer__footer > :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { :global(.app-drawer) { max-width: 94vw; } .app-drawer__footer > :deep(.el-button) { min-height: 44px; } }
</style>