This commit is contained in:
2026-09-30 14:01:58 +08:00
parent f14e1a2ea3
commit 8c51e9c1cf
109 changed files with 1525 additions and 651 deletions
+149 -1
View File
@@ -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 },
+143 -7
View File
@@ -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>
+13
View File
@@ -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',
+5 -3
View File
@@ -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'])
+16
View File
@@ -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