This commit is contained in:
2026-08-06 20:14:56 +08:00
parent 17c5102e8a
commit e5f02c2530
25 changed files with 224 additions and 229 deletions
+28
View File
@@ -87,4 +87,32 @@ describe('DataTable column preferences', () => {
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.column-stub').map((column) => column.attributes('data-label'))).toEqual(['操作', '名称', '状态'])
})
it('keeps business actions, column settings, and trailing controls in one toolbar', () => {
const wrapper = shallowMount(DataTable, {
props: { data: [], columns: configurableColumns, preferenceKey: 'orders-table' },
slots: {
'toolbar-start': '<h2>订单列表</h2>',
'toolbar-actions': '<button>新增订单</button>',
'toolbar-end': '<button>刷新</button>',
},
global: {
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
stubs: {
ElTable: TableStub,
ElTableColumn: ColumnStub,
ElButton: ButtonStub,
ElPopover: PopoverStub,
},
directives: { loading: {} },
},
})
expect(wrapper.findAll('.data-table__toolbar')).toHaveLength(1)
expect(wrapper.get('.data-table__toolbar-start').text()).toBe('订单列表')
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('新增订单')
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('刷新')
expect(wrapper.get('.data-table__business-actions').text()).toBe('新增订单')
expect(wrapper.get('.data-table__toolbar-end').text()).toBe('刷新')
})
})
+25 -17
View File
@@ -250,20 +250,24 @@ function setPage(value: number) {
<template>
<div class="data-table">
<div v-if="$slots['toolbar-start'] || preferenceEnabled" class="data-table__toolbar">
<div v-if="$slots['toolbar-start'] || $slots['toolbar-actions'] || preferenceEnabled || $slots['toolbar-end']" class="data-table__toolbar">
<div class="data-table__toolbar-start"><slot name="toolbar-start" /></div>
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
<template #reference>
<el-button aria-label="列设置" title="列设置"><Settings2 :size="16" /></el-button>
</template>
<LayoutPreferenceList
:items="preferenceItems"
allow-visibility
@move="moveColumn"
@visibility="setColumnVisibility"
@reset="resetPreference"
/>
</el-popover>
<div class="data-table__toolbar-actions">
<div v-if="$slots['toolbar-actions']" class="data-table__business-actions"><slot name="toolbar-actions" /></div>
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
<template #reference>
<el-button aria-label="列设置" title="列设置"><Settings2 :size="16" /></el-button>
</template>
<LayoutPreferenceList
:items="preferenceItems"
allow-visibility
@move="moveColumn"
@visibility="setColumnVisibility"
@reset="resetPreference"
/>
</el-popover>
<div v-if="$slots['toolbar-end']" class="data-table__toolbar-end"><slot name="toolbar-end" /></div>
</div>
</div>
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<el-table-column
@@ -319,9 +323,13 @@ function setPage(value: number) {
<style scoped>
.data-table { min-width: 0; }
.data-table__toolbar { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 16px; border-bottom: 1px solid var(--cc-border); }
.data-table__toolbar-start { display: flex; min-width: 0; flex: 1; align-items: center; flex-wrap: wrap; gap: 8px; }
.data-table__toolbar :deep(.el-button + .el-button) { margin-left: 0; }
.data-table__toolbar { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }
.data-table__toolbar-start { min-width: 0; flex: 1; }
.data-table__toolbar-start :deep(h2) { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
.data-table__toolbar-start :deep(p) { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.data-table__toolbar-actions { display: flex; flex: 0 0 auto; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.data-table__business-actions, .data-table__toolbar-end { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.data-table__toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
@@ -330,5 +338,5 @@ function setPage(value: number) {
.data-table__page-buttons { gap: 4px; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
@media (max-width: 720px) { .data-table__toolbar { align-items: flex-start; } .data-table__toolbar > :deep(.el-button) { width: 44px; min-width: 44px; height: 44px; padding: 0; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
@media (max-width: 720px) { .data-table__toolbar { align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: nowrap; justify-content: flex-start; } .data-table__business-actions { flex: 1 1 auto; justify-content: flex-start; } .data-table__toolbar-end { flex: 0 1 auto; justify-content: flex-start; } .data-table__toolbar-actions :deep(.el-button) { min-height: 40px; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
</style>
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/LayoutPreferenceList.vue'), 'utf8')
describe('LayoutPreferenceList overflow layout', () => {
it('keeps long item collections inside a viewport-bounded scroll area', () => {
expect(source).toContain('grid-template-rows: auto minmax(0, 1fr) auto')
expect(source).toContain('max-height: min(520px, calc(100dvh - 120px))')
expect(source).toContain('.layout-preference-list__scroll { min-height: 0;')
expect(source).toContain('overflow-y: auto')
})
})
+32 -29
View File
@@ -65,41 +65,45 @@ watch(() => props.items.map((item) => item.key), syncSortableOrder)
<template>
<div class="layout-preference-list">
<p class="layout-preference-list__hint">拖动调整顺序,也可使用上下移动按钮。</p>
<ul v-if="lockedItems.length" class="layout-preference-list__items is-locked" aria-label="固定项目">
<li v-for="item in lockedItems" :key="item.key" class="layout-preference-list__item is-locked">
<span class="layout-preference-list__handle is-disabled" aria-hidden="true"><GripVertical :size="16" /></span>
<el-checkbox v-if="allowVisibility" :model-value="item.visible" disabled>{{ item.label }}</el-checkbox>
<span v-else class="layout-preference-list__label">{{ item.label }}</span>
<span class="layout-preference-list__fixed">固定</span>
</li>
</ul>
<ul ref="sortableList" class="layout-preference-list__items" aria-label="可排序项目">
<li v-for="(item, index) in movableItems" :key="item.key" :data-key="item.key" class="layout-preference-list__item">
<button class="layout-preference-list__handle" type="button" :aria-label="`拖动${item.label}`" :title="`拖动${item.label}`">
<GripVertical :size="16" />
</button>
<el-checkbox
v-if="allowVisibility"
:model-value="item.visible"
:disabled="!item.hideable || item.hideDisabled"
@update:model-value="emit('visibility', item.key, Boolean($event))"
>{{ item.label }}</el-checkbox>
<span v-else class="layout-preference-list__label">{{ item.label }}</span>
<div class="layout-preference-list__moves">
<el-button text :disabled="index === 0" :aria-label="`上移${item.label}`" :title="`上移${item.label}`" @click="moveBy(item.key, -1)"><ChevronUp :size="16" /></el-button>
<el-button text :disabled="index === movableItems.length - 1" :aria-label="`下移${item.label}`" :title="`下移${item.label}`" @click="moveBy(item.key, 1)"><ChevronDown :size="16" /></el-button>
</div>
</li>
</ul>
<div class="layout-preference-list__scroll" tabindex="0" aria-label="布局项目">
<ul v-if="lockedItems.length" class="layout-preference-list__items is-locked" aria-label="固定项目">
<li v-for="item in lockedItems" :key="item.key" class="layout-preference-list__item is-locked">
<span class="layout-preference-list__handle is-disabled" aria-hidden="true"><GripVertical :size="16" /></span>
<el-checkbox v-if="allowVisibility" :model-value="item.visible" disabled>{{ item.label }}</el-checkbox>
<span v-else class="layout-preference-list__label">{{ item.label }}</span>
<span class="layout-preference-list__fixed">固定</span>
</li>
</ul>
<ul ref="sortableList" class="layout-preference-list__items" aria-label="可排序项目">
<li v-for="(item, index) in movableItems" :key="item.key" :data-key="item.key" class="layout-preference-list__item">
<button class="layout-preference-list__handle" type="button" :aria-label="`拖动${item.label}`" :title="`拖动${item.label}`">
<GripVertical :size="16" />
</button>
<el-checkbox
v-if="allowVisibility"
:model-value="item.visible"
:disabled="!item.hideable || item.hideDisabled"
@update:model-value="emit('visibility', item.key, Boolean($event))"
>{{ item.label }}</el-checkbox>
<span v-else class="layout-preference-list__label">{{ item.label }}</span>
<div class="layout-preference-list__moves">
<el-button text :disabled="index === 0" :aria-label="`上移${item.label}`" :title="`上移${item.label}`" @click="moveBy(item.key, -1)"><ChevronUp :size="16" /></el-button>
<el-button text :disabled="index === movableItems.length - 1" :aria-label="`下移${item.label}`" :title="`下移${item.label}`" @click="moveBy(item.key, 1)"><ChevronDown :size="16" /></el-button>
</div>
</li>
</ul>
</div>
<el-button class="layout-preference-list__reset" text @click="emit('reset')"><RotateCcw :size="15" />恢复默认</el-button>
</div>
</template>
<style scoped>
.layout-preference-list { display: grid; gap: 10px; }
.layout-preference-list { display: grid; max-height: min(360px, calc(100vh - 180px)); max-height: min(360px, calc(100dvh - 180px)); grid-template-rows: auto minmax(0, 1fr) auto; gap: 10px; overflow: hidden; }
.layout-preference-list__hint { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.layout-preference-list__scroll { min-height: 0; padding-right: 4px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.layout-preference-list__scroll:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 1px; }
.layout-preference-list__items { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.layout-preference-list__items.is-locked { padding-bottom: 8px; border-bottom: 1px solid var(--cc-border); }
.layout-preference-list__items.is-locked { margin-bottom: 4px; padding-bottom: 8px; border-bottom: 1px solid var(--cc-border); }
.layout-preference-list__item { display: grid; min-height: 38px; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 6px; padding: 3px 4px; border: 1px solid transparent; background: #fff; }
.layout-preference-list__item:hover { border-color: var(--cc-border); background: #f8fafc; }
.layout-preference-list__item.is-dragging { border-color: var(--cc-primary); background: #eef5ff; }
@@ -113,4 +117,3 @@ watch(() => props.items.map((item) => item.key), syncSortableOrder)
.layout-preference-list__reset { justify-self: start; margin-left: 0; }
@media (max-width: 720px) { .layout-preference-list__item { min-height: 44px; } .layout-preference-list__moves :deep(.el-button) { width: 40px; height: 40px; } }
</style>