优化
This commit is contained in:
@@ -12,7 +12,7 @@ const TableStub = defineComponent({
|
||||
})
|
||||
const ColumnStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { label: String, prop: String, fixed: [String, Boolean], showOverflowTooltip: Boolean, className: String },
|
||||
props: { label: String, prop: String, columnKey: String, width: [String, Number], minWidth: [String, Number], fixed: [String, Boolean], showOverflowTooltip: Boolean, className: String },
|
||||
template: '<div class="column-stub" :data-label="label" :data-prop="prop" :data-fixed="String(fixed || \'\')" :data-overflow-tooltip="String(showOverflowTooltip)" :data-class-name="className"><slot /></div>',
|
||||
})
|
||||
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
|
||||
@@ -91,9 +91,157 @@ describe('DataTable pagination helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('DataTable list notices', () => {
|
||||
it('lets a custom results workspace own its pagination and page-size choices', async () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: { data: [], columns: [], page: 2, pageSize: 5 },
|
||||
slots: { results: '<section>柜子工作台</section>' },
|
||||
global: { stubs: { ElTable: TableStub }, directives: { loading: {} } },
|
||||
})
|
||||
expect(wrapper.text()).toContain('柜子工作台')
|
||||
expect(wrapper.findComponent(TableStub).exists()).toBe(false)
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
expect(wrapper.emitted('update:pageSize')).toBeUndefined()
|
||||
await wrapper.setProps({ page: 3 })
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps filters and retry notices while hiding failed results, then restores the table and pagination', async () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: { data: [], columns: [], total: 0, page: 3, pageSize: 20, appearance: 'integrated', resultsVisible: false },
|
||||
slots: { filters: '<button>查询</button>', notices: '<button>重新加载</button>', 'before-results': '<p>汇总</p>' },
|
||||
global: { stubs: { ElTable: TableStub }, directives: { loading: {} } },
|
||||
})
|
||||
expect(wrapper.get('.data-table__filters').text()).toBe('查询')
|
||||
expect(wrapper.get('.data-table__notices').text()).toBe('重新加载')
|
||||
expect(wrapper.findComponent(TableStub).exists()).toBe(false)
|
||||
expect(wrapper.find('.data-table__pagination').exists()).toBe(false)
|
||||
expect(wrapper.find('.data-table__summary').exists()).toBe(false)
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
await wrapper.setProps({ resultsVisible: true, total: 100 })
|
||||
expect(wrapper.findComponent(TableStub).exists()).toBe(true)
|
||||
expect(wrapper.find('.data-table__pagination').exists()).toBe(true)
|
||||
expect(wrapper.get('.data-table__summary').text()).toBe('汇总')
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not hide valid results for a nonblocking notice or render an empty notice region', () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: { data: [], columns: [] },
|
||||
slots: { notices: '<p v-if="false">空提示</p>' },
|
||||
global: { stubs: { ElTable: TableStub }, directives: { loading: {} } },
|
||||
})
|
||||
expect(wrapper.find('.data-table__notices').exists()).toBe(false)
|
||||
expect(wrapper.findComponent(TableStub).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DataTable column preferences', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('persists dragged widths by stable key through reordering, hiding and remounting', async () => {
|
||||
const wrapper = mountTable()
|
||||
expect(wrapper.findAllComponents(ColumnStub).map(column => column.props('columnKey'))).toEqual(['actions', 'name', 'status'])
|
||||
wrapper.findComponent(TableStub).vm.$emit('header-dragend', 245, 100, { columnKey: 'name' })
|
||||
wrapper.findComponent(TableStub).vm.$emit('header-dragend', 190, 100, { columnKey: 'actions' })
|
||||
await wrapper.vm.$nextTick()
|
||||
const settings = wrapper.findComponent(LayoutPreferenceList)
|
||||
settings.vm.$emit('move', 'name', 1)
|
||||
settings.vm.$emit('visibility', 'name', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
wrapper.unmount()
|
||||
|
||||
const restored = mountTable()
|
||||
expect(restored.findAllComponents(ColumnStub).map(column => column.props('columnKey'))).toEqual(['actions', 'status'])
|
||||
restored.findComponent(LayoutPreferenceList).vm.$emit('visibility', 'name', true)
|
||||
await restored.vm.$nextTick()
|
||||
expect(restored.findAllComponents(ColumnStub).map(column => [column.props('columnKey'), column.props('width')]))
|
||||
.toEqual([['actions', 190], ['status', undefined], ['name', 245]])
|
||||
restored.unmount()
|
||||
})
|
||||
|
||||
it('resets saved widths to declared widths and min-widths along with other preferences', async () => {
|
||||
const wrapper = mountTable()
|
||||
await wrapper.setProps({ columns: [
|
||||
{ key: 'actions', label: '操作', width: 120, fixed: 'left' },
|
||||
{ key: 'name', label: '名称', minWidth: 160 },
|
||||
{ key: 'status', label: '状态' },
|
||||
] })
|
||||
wrapper.findComponent(TableStub).vm.$emit('header-dragend', 250, 120, { columnKey: 'actions' })
|
||||
wrapper.findComponent(TableStub).vm.$emit('header-dragend', 300, 160, { columnKey: 'name' })
|
||||
await wrapper.vm.$nextTick()
|
||||
wrapper.findComponent(LayoutPreferenceList).vm.$emit('reset')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findAllComponents(ColumnStub).map(column => column.props('width'))).toEqual([120, undefined, undefined])
|
||||
expect(wrapper.findAllComponents(ColumnStub)[1].props('minWidth')).toBe(160)
|
||||
expect(localStorage.getItem(preferenceStorageKey('table', 'user-1', 'orders-table'))).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('maps explicit column keys back to preference keys and isolates table preferences', async () => {
|
||||
const wrapper = mountTable()
|
||||
await wrapper.setProps({ columns: [{ key: 'name', columnKey: 'filter-name', prop: 'displayName', label: '名称' }] })
|
||||
wrapper.findComponent(TableStub).vm.$emit('header-dragend', 210, 100, { columnKey: 'filter-name' })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findComponent(ColumnStub).props('width')).toBe(210)
|
||||
expect(JSON.parse(localStorage.getItem(preferenceStorageKey('table', 'user-1', 'orders-table'))!).widths).toEqual({ name: 210 })
|
||||
await wrapper.setProps({ preferenceKey: 'other-table' })
|
||||
expect(wrapper.findComponent(ColumnStub).props('width')).toBeUndefined()
|
||||
await wrapper.setProps({ preferenceKey: 'orders-table' })
|
||||
expect(wrapper.findComponent(ColumnStub).props('width')).toBe(210)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores invalid resize events and does not persist when column preferences are disabled', async () => {
|
||||
const wrapper = mountTable()
|
||||
for (const width of [Number.NaN, Number.POSITIVE_INFINITY, -20, 0, 29]) {
|
||||
wrapper.findComponent(TableStub).vm.$emit('header-dragend', width, 100, { columnKey: 'name' })
|
||||
}
|
||||
wrapper.findComponent(TableStub).vm.$emit('header-dragend', 200, 100, { columnKey: 'missing' })
|
||||
await wrapper.setProps({ columnSettingsMode: 'hide' })
|
||||
wrapper.findComponent(TableStub).vm.$emit('header-dragend', 200, 100, { columnKey: 'name' })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(localStorage.getItem(preferenceStorageKey('table', 'user-1', 'orders-table'))).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps integrated presentation opt-in and preserves column preferences when enabled', async () => {
|
||||
const wrapper = mountTable()
|
||||
expect(wrapper.classes()).not.toContain('data-table--integrated')
|
||||
await wrapper.setProps({ appearance: 'integrated' })
|
||||
expect(wrapper.classes()).toContain('data-table--integrated')
|
||||
expect(wrapper.get('button[aria-label="列设置"]').text()).toBe('字段设置')
|
||||
wrapper.findComponent(LayoutPreferenceList).vm.$emit('visibility', 'name', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findAll('.column-stub').map(column => column.attributes('data-label'))).toEqual(['操作', '状态'])
|
||||
const restored = mountTable()
|
||||
await restored.setProps({ appearance: 'integrated' })
|
||||
expect(restored.findAll('.column-stub').map(column => column.attributes('data-label'))).toEqual(['操作', '状态'])
|
||||
})
|
||||
|
||||
it('keeps header actions and filters available when a result error replaces the table', () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: { data: [], columns: configurableColumns, appearance: 'integrated', total: 10, preferenceKey: 'orders-table', columnSettingsMode: 'show' },
|
||||
slots: {
|
||||
'toolbar-start': '<h1>列表</h1>',
|
||||
'toolbar-actions': '<button>导出</button>',
|
||||
filters: '<input aria-label="搜索" />',
|
||||
state: '<button>重试</button>',
|
||||
},
|
||||
global: {
|
||||
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
|
||||
directives: { loading: {} },
|
||||
},
|
||||
})
|
||||
expect(wrapper.get('.data-table__toolbar h1').text()).toBe('列表')
|
||||
expect(wrapper.get('.data-table__business-actions').text()).toBe('导出')
|
||||
expect(wrapper.find('button[aria-label="列设置"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.data-table__filters input').exists()).toBe(true)
|
||||
expect(wrapper.get('.data-table__state').text()).toBe('重试')
|
||||
expect(wrapper.find('.table-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('.data-table__pagination').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('enables bordered striped business tables by default and allows explicit opt-out', () => {
|
||||
const defaultTable = shallowMount(DataTable, {
|
||||
props: { data: [], columns: configurableColumns },
|
||||
|
||||
@@ -5,6 +5,9 @@ export interface DataTableColumn {
|
||||
prop?: string
|
||||
property?: string
|
||||
columnKey?: string
|
||||
width?: string | number
|
||||
minWidth?: string | number
|
||||
resizable?: boolean
|
||||
slotName?: string
|
||||
headerSlotName?: string
|
||||
hideable?: boolean
|
||||
@@ -52,11 +55,12 @@ export function createPaginationItems(page: number, pageCount: number): Paginati
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref, useSlots, watch } from 'vue'
|
||||
import { Comment, Fragment, Text, computed, inject, isVNode, nextTick, ref, useSlots, watch } from 'vue'
|
||||
import { ChevronLeft, ChevronRight, Settings2 } from '@lucide/vue'
|
||||
import LayoutPreferenceList, { type LayoutPreferenceItem } from './LayoutPreferenceList.vue'
|
||||
import {
|
||||
UI_PREFERENCE_SCOPE,
|
||||
normalizeColumnWidth,
|
||||
readLayoutPreference,
|
||||
removeLayoutPreference,
|
||||
writeLayoutPreference,
|
||||
@@ -77,6 +81,8 @@ const props = withDefaults(defineProps<{
|
||||
columnSettingsMode?: 'auto' | 'show' | 'hide'
|
||||
border?: boolean
|
||||
stripe?: boolean
|
||||
appearance?: 'default' | 'integrated'
|
||||
resultsVisible?: boolean
|
||||
}>(), {
|
||||
loading: false,
|
||||
emptyText: '暂无数据',
|
||||
@@ -86,6 +92,8 @@ const props = withDefaults(defineProps<{
|
||||
columnSettingsMode: 'auto',
|
||||
border: true,
|
||||
stripe: true,
|
||||
appearance: 'default',
|
||||
resultsVisible: true,
|
||||
})
|
||||
|
||||
const page = defineModel<number>('page', { default: 1 })
|
||||
@@ -183,7 +191,19 @@ const preferenceItems = computed<LayoutPreferenceItem[]>(() => orderedColumns.va
|
||||
}))
|
||||
|
||||
function persistPreference(order: string[], hidden = preference.value.hidden) {
|
||||
preference.value = { version: 1, order, hidden }
|
||||
preference.value = { ...preference.value, order, hidden }
|
||||
writeLayoutPreference('table', preferenceScope.value, props.preferenceKey || '', preference.value)
|
||||
}
|
||||
|
||||
function saveColumnWidth(newWidth: number, _oldWidth: number, resizedColumn: { columnKey?: string }) {
|
||||
if (!preferenceEnabled.value || !resizedColumn.columnKey) return
|
||||
const column = configurableColumns.value.find(candidate => (
|
||||
(candidate.columnKey ?? columnId(candidate)) === resizedColumn.columnKey
|
||||
))
|
||||
const key = column && columnId(column)
|
||||
const width = normalizeColumnWidth(newWidth)
|
||||
if (!key || width === undefined || column?.resizable === false) return
|
||||
preference.value = { ...preference.value, widths: { ...preference.value.widths, [key]: width } }
|
||||
writeLayoutPreference('table', preferenceScope.value, props.preferenceKey || '', preference.value)
|
||||
}
|
||||
|
||||
@@ -232,6 +252,7 @@ const currentPage = computed(() => normalizePage(page.value, pageCount.value))
|
||||
const paginationItems = computed(() => createPaginationItems(currentPage.value, pageCount.value))
|
||||
|
||||
watch([pageSize, normalizedPageSizes], ([value, sizes]) => {
|
||||
if (slots.results) return
|
||||
const integerValue = Number.isFinite(value) ? Math.trunc(value) : sizes[0]
|
||||
const nextValue = sizes.includes(integerValue) ? integerValue : sizes[0]
|
||||
if (value === nextValue) return
|
||||
@@ -240,11 +261,16 @@ watch([pageSize, normalizedPageSizes], ([value, sizes]) => {
|
||||
}, { immediate: true })
|
||||
|
||||
watch([page, pageCount], ([value, count]) => {
|
||||
if (!props.resultsVisible || slots.state || slots.results) return
|
||||
const nextValue = normalizePage(value, count)
|
||||
if (value !== nextValue) page.value = nextValue
|
||||
}, { immediate: true })
|
||||
|
||||
function columnAttrs({ key, slotName, headerSlotName, hideable, movable, defaultVisible, singleLine, ...attrs }: DataTableColumn) {
|
||||
function columnAttrs(column: DataTableColumn) {
|
||||
const { key, slotName, headerSlotName, hideable, movable, defaultVisible, singleLine, ...attrs } = column
|
||||
const id = columnId(column)
|
||||
attrs.columnKey ??= id
|
||||
attrs.width = (preferenceEnabled.value && id ? preference.value.widths?.[id] : undefined) ?? attrs.width
|
||||
const hasDataProperty = attrs.prop !== undefined || attrs.property !== undefined
|
||||
const isSpecialColumn = slotName || ['selection', 'expand', 'index'].includes(String(attrs.type || ''))
|
||||
if (hasDataProperty || isSpecialColumn || key === undefined || key === null || key === '') return attrs
|
||||
@@ -284,6 +310,18 @@ function hasSlot(name?: string) {
|
||||
return Boolean(name && slots[name])
|
||||
}
|
||||
|
||||
function hasRenderableContent(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(hasRenderableContent)
|
||||
if (!isVNode(value)) return typeof value === 'string' ? value.trim().length > 0 : value != null && typeof value !== 'boolean'
|
||||
if (value.type === Comment) return false
|
||||
if (value.type === Text || value.type === Fragment) return hasRenderableContent(value.children)
|
||||
return true
|
||||
}
|
||||
|
||||
function hasRegion(name: string) {
|
||||
return hasRenderableContent(slots[name]?.())
|
||||
}
|
||||
|
||||
function updatePageSize(value: number) {
|
||||
const nextValue = normalizedPageSizes.value.includes(value) ? value : normalizedPageSizes.value[0]
|
||||
if (nextValue === pageSize.value) return
|
||||
@@ -297,7 +335,7 @@ function setPage(value: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-table">
|
||||
<div class="data-table" :class="{ 'data-table--integrated': appearance === 'integrated' }">
|
||||
<div
|
||||
v-if="$slots['toolbar-start'] || $slots['toolbar-actions'] || preferenceEnabled || $slots['toolbar-end']"
|
||||
class="data-table__toolbar"
|
||||
@@ -309,8 +347,9 @@ function setPage(value: number) {
|
||||
<div v-if="preferenceEnabled || $slots['toolbar-end']" class="data-table__utility-actions">
|
||||
<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>
|
||||
<el-button aria-label="列设置" title="列设置"><Settings2 :size="16" /><span v-if="appearance === 'integrated'">字段设置</span></el-button>
|
||||
</template>
|
||||
<p class="data-table__preference-hint">拖动表头边界后自动保存列宽;恢复默认会同时重置列宽。</p>
|
||||
<LayoutPreferenceList
|
||||
:items="preferenceItems"
|
||||
allow-visibility
|
||||
@@ -323,7 +362,12 @@ function setPage(value: number) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :border="border" :stripe="stripe" :max-height="maxHeight" :aria-busy="loading">
|
||||
<div v-if="$slots.filters" class="data-table__filters"><slot name="filters" /></div>
|
||||
<div v-if="hasRegion('notices')" class="data-table__notices"><slot name="notices" /></div>
|
||||
<div v-if="resultsVisible && hasRegion('before-results')" class="data-table__summary"><slot name="before-results" /></div>
|
||||
<div v-if="$slots.state" class="data-table__state"><slot name="state" /></div>
|
||||
<slot v-else-if="resultsVisible" name="results">
|
||||
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :border="border" :stripe="stripe" :max-height="maxHeight" :aria-busy="loading" @header-dragend="saveColumnWidth">
|
||||
<el-table-column
|
||||
v-for="(column, index) in visibleColumns"
|
||||
:key="columnRenderKey(column, index)"
|
||||
@@ -340,7 +384,8 @@ function setPage(value: number) {
|
||||
</el-table-column>
|
||||
<template v-if="$slots.empty" #empty><slot name="empty" /></template>
|
||||
</el-table>
|
||||
<footer v-if="total !== undefined" class="data-table__pagination" aria-label="表格分页">
|
||||
</slot>
|
||||
<footer v-if="resultsVisible && !$slots.state && total !== undefined" class="data-table__pagination" aria-label="表格分页">
|
||||
<div class="data-table__pagination-left">
|
||||
<slot name="pagination-left" :total="normalizedTotal" :page="currentPage" :page-size="effectivePageSize" :page-count="pageCount">
|
||||
<span v-if="pageCount > 0">共 {{ normalizedTotal }} 条 · 第 {{ currentPage }} / {{ pageCount }} 页</span>
|
||||
@@ -379,6 +424,7 @@ function setPage(value: number) {
|
||||
|
||||
<style scoped>
|
||||
.data-table { min-width: 0; }
|
||||
.data-table__preference-hint { margin: 0 0 10px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.data-table :deep(.el-table) { font-size: var(--cc-list-table-font-size); }
|
||||
.data-table :deep(.el-table th.el-table__cell) { font-size: var(--cc-list-table-font-size); font-weight: var(--cc-list-table-header-font-weight); }
|
||||
.data-table :deep(.el-table .el-button) { font-size: var(--cc-list-action-font-size); }
|
||||
@@ -420,4 +466,94 @@ function setPage(value: number) {
|
||||
}
|
||||
|
||||
@media (max-width: 720px) { .data-table__toolbar, .data-table__toolbar.is-compact { min-height: 0; align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: wrap; justify-content: flex-start; } .data-table__toolbar-actions.has-business-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; } .data-table__business-actions { justify-content: flex-start; } .data-table__utility-actions { align-items: flex-start; justify-content: flex-end; } .data-table__toolbar-end { justify-content: flex-start; } .data-table__toolbar-actions :deep(.el-button) { max-width: 100%; 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; } }
|
||||
/* Opt-in list surface: header, filters and results share one continuous card. */
|
||||
.data-table--integrated {
|
||||
--list-card-border: #e8edf5;
|
||||
--list-card-muted-bg: #f7f9fc;
|
||||
--list-card-radius: 12px;
|
||||
--list-card-accent: linear-gradient(135deg, #667eea, #764ba2);
|
||||
--el-component-size: 36px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--list-card-border);
|
||||
border-radius: var(--list-card-radius);
|
||||
background: var(--admin-surface, #fff);
|
||||
}
|
||||
.data-table--integrated > .data-table__toolbar {
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 20px;
|
||||
border-color: var(--list-card-border);
|
||||
}
|
||||
.data-table--integrated .data-table__toolbar-start :deep(h1),
|
||||
.data-table--integrated .data-table__toolbar-start :deep(h2) {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: var(--admin-font-title, 16px);
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.data-table--integrated .data-table__toolbar-actions :deep(.el-button) {
|
||||
min-height: 36px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.data-table--integrated .data-table__utility-actions :deep(.el-button > span) { gap: 6px; }
|
||||
.data-table--integrated .data-table__business-actions :deep(.el-button--primary:not(:disabled)),
|
||||
.data-table--integrated .data-table__page-button[aria-current="page"]:not(:disabled) {
|
||||
border-color: transparent;
|
||||
background: var(--list-card-accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgb(102 126 234 / 20%);
|
||||
}
|
||||
.data-table--integrated .data-table__business-actions :deep(.el-button--primary:hover:not(:disabled)),
|
||||
.data-table--integrated .data-table__page-button[aria-current="page"]:hover:not(:disabled) { filter: brightness(1.06); }
|
||||
.data-table--integrated > .data-table__filters {
|
||||
min-width: 0;
|
||||
background: var(--list-card-muted-bg);
|
||||
border-bottom: 1px solid var(--list-card-border);
|
||||
}
|
||||
.data-table--integrated .data-table__filters :deep(.query-toolbar) {
|
||||
margin: 0;
|
||||
padding: 16px 20px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.data-table--integrated .data-table__filters :deep(.el-input__wrapper),
|
||||
.data-table--integrated .data-table__filters :deep(.el-select__wrapper),
|
||||
.data-table--integrated .data-table__filters :deep(.el-button) { border-radius: 8px; }
|
||||
.data-table--integrated .data-table__filters :deep(.el-button--primary:not(:disabled)) {
|
||||
--el-button-bg-color: #1677ff;
|
||||
--el-button-border-color: #1677ff;
|
||||
--el-button-hover-bg-color: #4096ff;
|
||||
--el-button-hover-border-color: #4096ff;
|
||||
--el-button-active-bg-color: #0958d9;
|
||||
--el-button-active-border-color: #0958d9;
|
||||
}
|
||||
.data-table--integrated > :deep(.el-table) {
|
||||
--el-table-border-color: var(--list-card-border);
|
||||
--el-table-header-bg-color: var(--list-card-muted-bg);
|
||||
--el-table-row-hover-bg-color: #f5f8ff;
|
||||
--admin-row-height: 44px;
|
||||
color: #526078;
|
||||
}
|
||||
.data-table--integrated :deep(.el-table th.el-table__cell) { color: #526078; }
|
||||
.data-table.data-table--integrated > .data-table__pagination {
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
padding: 14px 20px;
|
||||
border-color: var(--list-card-border);
|
||||
}
|
||||
.data-table--integrated .data-table__pagination-left { flex: 0 1 auto; }
|
||||
.data-table--integrated .data-table__page-button { border-radius: 8px; }
|
||||
.data-table--integrated > .data-table__state { padding: 20px; }
|
||||
.data-table--integrated > .data-table__notices,
|
||||
.data-table--integrated > .data-table__summary { padding: 12px 20px; }
|
||||
.data-table--integrated .data-table__filters :deep(.surface-panel) { border: 0; border-radius: 0; box-shadow: none; }
|
||||
.data-table--integrated .data-table__filters :deep(.integrated-query-group) { display: grid; min-width: 0; gap: 0; }
|
||||
.data-table--integrated .data-table__filters :deep(.integrated-query-group > .el-radio-group) { padding: 12px 20px 0; }
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,19 @@ const actions: RowActionItem[] = [
|
||||
]
|
||||
|
||||
describe('RowActions', () => {
|
||||
it('uses a text more action and scopes the teleported menu without losing danger semantics', async () => {
|
||||
const wrapper = mount(RowActions, {
|
||||
props: { actions, textActions: true },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('admin-text-actions')
|
||||
expect(wrapper.get('.el-dropdown .el-button').text()).toBe('更多')
|
||||
expect(wrapper.get('.el-dropdown .el-button').find('svg').exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'ElDropdown' }).props('popperClass')).toBe('admin-text-actions')
|
||||
await wrapper.get('.el-dropdown .el-button').trigger('click')
|
||||
expect(document.querySelector('.admin-text-actions .danger-menu-item')?.textContent).toBe('取消')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([
|
||||
'src/modules/container-loading/pages/ContainersView.vue',
|
||||
'src/modules/customers/pages/CustomersView.vue',
|
||||
|
||||
@@ -20,9 +20,11 @@ const props = withDefaults(defineProps<{
|
||||
actions: RowActionItem[]
|
||||
maxVisible?: number
|
||||
moreLabel?: string
|
||||
textActions?: boolean
|
||||
}>(), {
|
||||
maxVisible: 3,
|
||||
moreLabel: '更多操作',
|
||||
textActions: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -49,7 +51,7 @@ function select(action: RowActionItem) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="density-actions row-actions">
|
||||
<div class="density-actions row-actions" :class="{ 'admin-text-actions': textActions }">
|
||||
<el-button
|
||||
v-for="action in directActions"
|
||||
:key="action.key"
|
||||
@@ -62,7 +64,7 @@ function select(action: RowActionItem) {
|
||||
:title="action.ariaLabel"
|
||||
@click.stop="select(action)"
|
||||
>{{ action.label }}</el-button>
|
||||
<el-dropdown v-if="overflowActions.length" trigger="click">
|
||||
<el-dropdown v-if="overflowActions.length" trigger="click" :popper-class="textActions ? 'admin-text-actions' : undefined">
|
||||
<el-button
|
||||
class="density-action"
|
||||
data-tone="neutral"
|
||||
@@ -70,7 +72,7 @@ function select(action: RowActionItem) {
|
||||
:aria-label="moreLabel"
|
||||
:title="moreLabel"
|
||||
@click.stop
|
||||
><MoreHorizontal :size="15" aria-hidden="true" /></el-button>
|
||||
><span v-if="textActions">更多</span><MoreHorizontal v-else :size="15" aria-hidden="true" /></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
|
||||
@@ -10,6 +10,26 @@ import {
|
||||
describe('UI layout preferences', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('restores only finite valid widths for current table columns', () => {
|
||||
localStorage.setItem(preferenceStorageKey('table', 'user-1', 'orders'), JSON.stringify({
|
||||
version: 1, order: ['name', 'status'], hidden: [],
|
||||
widths: { name: 215.6, status: '200', small: 29, negative: -10, missing: 150, nullWidth: null },
|
||||
}))
|
||||
expect(readLayoutPreference('table', 'user-1', 'orders', ['name', 'status', 'small', 'negative', 'nullWidth']).widths).toEqual({ name: 216 })
|
||||
expect(readLayoutPreference('table', 'user-2', 'orders', ['name']).widths).toBeUndefined()
|
||||
expect(readLayoutPreference('table', 'user-1', 'other-orders', ['name']).widths).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores malformed width maps without losing valid order and visibility', () => {
|
||||
for (const widths of [null, 'bad', [150, 200]]) {
|
||||
localStorage.setItem(preferenceStorageKey('table', 'user-1', 'orders'), JSON.stringify({
|
||||
version: 1, order: ['status', 'name'], hidden: ['name'], widths,
|
||||
}))
|
||||
expect(readLayoutPreference('table', 'user-1', 'orders', ['name', 'status']))
|
||||
.toEqual({ version: 1, order: ['status', 'name'], hidden: ['name'], widths: {} })
|
||||
}
|
||||
})
|
||||
|
||||
it('reconciles stored order with the current stable keys', () => {
|
||||
expect(reconcileOrder(['actions', 'name', 'status', 'createdAt'], ['status', 'removed', 'name', 'status']))
|
||||
.toEqual(['status', 'name', 'actions', 'createdAt'])
|
||||
|
||||
@@ -6,6 +6,21 @@ export interface LayoutPreferenceSnapshot {
|
||||
version: 1
|
||||
order: string[]
|
||||
hidden: string[]
|
||||
widths?: Record<string, number>
|
||||
}
|
||||
|
||||
export function normalizeColumnWidth(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 30
|
||||
? Math.round(value)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function reconcileWidths(value: unknown, availableKeys: readonly string[]) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return Object.fromEntries(Object.entries(value).flatMap(([key, width]) => {
|
||||
const normalized = normalizeColumnWidth(width)
|
||||
return availableKeys.includes(key) && normalized !== undefined ? [[key, normalized]] : []
|
||||
}))
|
||||
}
|
||||
|
||||
export const UI_PREFERENCE_SCOPE: InjectionKey<ComputedRef<string>> = Symbol('ui-preference-scope')
|
||||
@@ -53,6 +68,7 @@ export function readLayoutPreference(
|
||||
version: 1,
|
||||
order: reconcileOrder(availableKeys, parsed.order),
|
||||
hidden: uniqueKeys([...parsed.hidden, ...newlyDefaultHidden]).filter((key) => availableKeys.includes(key)),
|
||||
...(kind === 'table' && parsed.widths !== undefined ? { widths: reconcileWidths(parsed.widths, availableKeys) } : {}),
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
|
||||
Reference in New Issue
Block a user