This commit is contained in:
2026-08-06 19:03:13 +08:00
parent 7034a40aa8
commit 8695eee8c8
28 changed files with 1100 additions and 162 deletions
+60 -2
View File
@@ -1,5 +1,40 @@
import { describe, expect, it } from 'vitest'
import { createPaginationItems, normalizePage, normalizePageSizes } from './DataTable.vue'
import { computed, defineComponent } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import DataTable, { createPaginationItems, normalizePage, normalizePageSizes, type DataTableColumn } from './DataTable.vue'
import LayoutPreferenceList from './LayoutPreferenceList.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
const TableStub = defineComponent({ template: '<div><slot /></div>', methods: { doLayout() {} } })
const ColumnStub = defineComponent({
inheritAttrs: false,
props: { label: String },
template: '<div class="column-stub" :data-label="label"><slot /></div>',
})
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
const configurableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left' },
{ key: 'name', prop: 'name', label: '名称' },
{ key: 'status', prop: 'status', label: '状态' },
]
function mountTable() {
return shallowMount(DataTable, {
props: { data: [], columns: configurableColumns, preferenceKey: 'orders-table' },
global: {
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
stubs: {
ElTable: TableStub,
ElTableColumn: ColumnStub,
ElButton: ButtonStub,
ElPopover: PopoverStub,
},
directives: { loading: {} },
},
})
}
describe('DataTable pagination helpers', () => {
it('normalizes page-size options', () => {
@@ -30,3 +65,26 @@ describe('DataTable pagination helpers', () => {
expect(createPaginationItems(30, 30)).toEqual([1, 'ellipsis-start', 26, 27, 28, 29, 30])
})
})
describe('DataTable column preferences', () => {
beforeEach(() => localStorage.clear())
it('restores column order and visibility while locking the actions column', async () => {
localStorage.setItem(preferenceStorageKey('table', 'user-1', 'orders-table'), JSON.stringify({
version: 1,
order: ['actions', 'status', 'name'],
hidden: ['name', 'actions'],
}))
const wrapper = mountTable()
expect(wrapper.findAll('.column-stub').map((column) => column.attributes('data-label'))).toEqual(['操作', '状态'])
const settings = wrapper.findComponent(LayoutPreferenceList)
const items = settings.props('items') as Array<{ key: string; movable: boolean; hideable: boolean }>
expect(items.find((item) => item.key === 'actions')).toMatchObject({ movable: false, hideable: false })
settings.vm.$emit('visibility', 'name', true)
settings.vm.$emit('move', 'name', 0)
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.column-stub').map((column) => column.attributes('data-label'))).toEqual(['操作', '名称', '状态'])
})
})
+144 -6
View File
@@ -7,6 +7,9 @@ export interface DataTableColumn {
columnKey?: string
slotName?: string
headerSlotName?: string
hideable?: boolean
movable?: boolean
defaultVisible?: boolean
[attribute: string]: unknown
}
@@ -39,8 +42,16 @@ export function createPaginationItems(page: number, pageCount: number): Paginati
</script>
<script setup lang="ts">
import { computed, useSlots, watch } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed, inject, nextTick, ref, useSlots, watch } from 'vue'
import { ChevronLeft, ChevronRight, Settings2 } from '@lucide/vue'
import LayoutPreferenceList, { type LayoutPreferenceItem } from './LayoutPreferenceList.vue'
import {
UI_PREFERENCE_SCOPE,
readLayoutPreference,
removeLayoutPreference,
writeLayoutPreference,
type LayoutPreferenceSnapshot,
} from '@/shared/utils/uiLayoutPreference'
defineOptions({ inheritAttrs: false })
@@ -51,6 +62,7 @@ const props = withDefaults(defineProps<{
emptyText?: string
total?: number
pageSizes?: number[]
preferenceKey?: string
}>(), {
loading: false,
emptyText: '暂无数据',
@@ -61,6 +73,114 @@ const props = withDefaults(defineProps<{
const page = defineModel<number>('page', { default: 1 })
const pageSize = defineModel<number>('pageSize', { default: 10 })
const slots = useSlots()
const preferenceScope = inject(UI_PREFERENCE_SCOPE, computed(() => 'anonymous'))
const preference = ref<LayoutPreferenceSnapshot>({ version: 1, order: [], hidden: [] })
const tableRef = ref<{ doLayout: () => void }>()
const preferenceEnabled = computed(() => Boolean(props.preferenceKey))
function columnId(column: DataTableColumn) {
const value = column.key ?? column.columnKey ?? column.prop ?? column.property
return value === undefined || value === null || value === '' ? undefined : String(value)
}
function systemColumn(column: DataTableColumn) {
const id = columnId(column)
return id === 'actions'
|| column.fixed === 'left'
|| ['selection', 'expand', 'index'].includes(String(column.type || ''))
}
function columnMovable(column: DataTableColumn) {
return column.movable ?? !systemColumn(column)
}
function columnHideable(column: DataTableColumn) {
return column.hideable ?? !systemColumn(column)
}
const configurableColumns = computed(() => props.columns.filter((column) => columnId(column)))
const columnKeys = computed(() => configurableColumns.value.map((column) => columnId(column) as string))
function applyPreference() {
const defaultHidden = configurableColumns.value.flatMap((column) => column.defaultVisible === false ? [columnId(column) as string] : [])
preference.value = readLayoutPreference(
'table',
preferenceScope.value,
props.preferenceKey || '',
columnKeys.value,
defaultHidden,
)
}
watch(
[preferenceScope, () => props.preferenceKey, () => columnKeys.value.join('\u0000')],
applyPreference,
{ immediate: true },
)
const orderedColumns = computed(() => {
if (!preferenceEnabled.value) return props.columns
const columnsByKey = new Map(configurableColumns.value.map((column) => [columnId(column) as string, column]))
return preference.value.order.flatMap((key) => {
const column = columnsByKey.get(key)
return column ? [column] : []
})
})
const visibleColumns = computed(() => orderedColumns.value.filter((column) => {
const id = columnId(column)
return !id || !columnHideable(column) || !preference.value.hidden.includes(id)
}))
const visibleHideableCount = computed(() => visibleColumns.value.filter(columnHideable).length)
const preferenceItems = computed<LayoutPreferenceItem[]>(() => orderedColumns.value.flatMap((column) => {
const key = columnId(column)
if (!key) return []
const visible = !preference.value.hidden.includes(key)
const hideable = columnHideable(column)
return [{
key,
label: column.label || (String(column.type || '') === 'selection' ? '选择' : key),
visible,
hideable,
movable: columnMovable(column),
hideDisabled: visible && hideable && visibleHideableCount.value <= 1,
}]
}))
function persistPreference(order: string[], hidden = preference.value.hidden) {
preference.value = { version: 1, order, hidden }
writeLayoutPreference('table', preferenceScope.value, props.preferenceKey || '', preference.value)
}
function moveColumn(key: string, targetIndex: number) {
const movableKeys = orderedColumns.value.filter(columnMovable).map((column) => columnId(column) as string)
const currentIndex = movableKeys.indexOf(key)
if (currentIndex < 0) return
movableKeys.splice(currentIndex, 1)
movableKeys.splice(Math.min(Math.max(targetIndex, 0), movableKeys.length), 0, key)
const movableSet = new Set(movableKeys)
let nextMovableIndex = 0
const order = preference.value.order.map((id) => movableSet.has(id) ? movableKeys[nextMovableIndex++] : id)
persistPreference(order)
}
function setColumnVisibility(key: string, visible: boolean) {
const column = configurableColumns.value.find((candidate) => columnId(candidate) === key)
if (!column || !columnHideable(column)) return
if (!visible && visibleHideableCount.value <= 1) return
const hidden = visible
? preference.value.hidden.filter((id) => id !== key)
: [...new Set([...preference.value.hidden, key])]
persistPreference(preference.value.order, hidden)
}
function resetPreference() {
removeLayoutPreference('table', preferenceScope.value, props.preferenceKey || '')
applyPreference()
}
watch(visibleColumns, () => {
void nextTick(() => tableRef.value?.doLayout())
})
const normalizedTotal = computed(() => {
const value = props.total ?? 0
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0
@@ -88,7 +208,7 @@ watch([page, pageCount], ([value, count]) => {
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
function columnAttrs({ key, slotName, headerSlotName, ...attrs }: DataTableColumn) {
function columnAttrs({ key, slotName, headerSlotName, hideable, movable, defaultVisible, ...attrs }: DataTableColumn) {
return attrs
}
@@ -114,9 +234,24 @@ function setPage(value: number) {
<template>
<div class="data-table">
<el-table v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<div v-if="$slots['toolbar-start'] || preferenceEnabled" 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>
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<el-table-column
v-for="(column, index) in columns"
v-for="(column, index) in visibleColumns"
:key="columnRenderKey(column, index)"
v-bind="columnAttrs(column)"
>
@@ -168,6 +303,9 @@ 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__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; }
@@ -176,5 +314,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__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: 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; } }
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Sortable from 'sortablejs'
import { ChevronDown, ChevronUp, GripVertical, RotateCcw } from '@lucide/vue'
export interface LayoutPreferenceItem {
key: string
label: string
visible: boolean
hideable: boolean
movable: boolean
hideDisabled?: boolean
}
const props = withDefaults(defineProps<{
items: LayoutPreferenceItem[]
allowVisibility?: boolean
}>(), {
allowVisibility: false,
})
const emit = defineEmits<{
move: [key: string, targetIndex: number]
visibility: [key: string, visible: boolean]
reset: []
}>()
const sortableList = ref<HTMLElement>()
const movableItems = computed(() => props.items.filter((item) => item.movable))
const lockedItems = computed(() => props.items.filter((item) => !item.movable))
let sortable: Sortable | undefined
function syncSortableOrder() {
void nextTick(() => sortable?.sort(movableItems.value.map((item) => item.key), false))
}
function createSortable() {
if (!sortableList.value) return
sortable?.destroy()
sortable = Sortable.create(sortableList.value, {
animation: 150,
handle: '.layout-preference-list__handle',
ghostClass: 'is-dragging',
dataIdAttr: 'data-key',
onEnd(event) {
if (event.oldIndex === undefined || event.newIndex === undefined || event.oldIndex === event.newIndex) return
const item = movableItems.value[event.oldIndex]
if (item) emit('move', item.key, event.newIndex)
syncSortableOrder()
},
})
}
function moveBy(key: string, offset: number) {
const index = movableItems.value.findIndex((item) => item.key === key)
if (index < 0) return
emit('move', key, index + offset)
}
onMounted(createSortable)
onBeforeUnmount(() => sortable?.destroy())
watch(() => props.items.map((item) => item.key), syncSortableOrder)
</script>
<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>
<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__hint { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.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__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; }
.layout-preference-list__handle { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; padding: 0; border: 0; background: transparent; color: var(--cc-muted); cursor: grab; }
.layout-preference-list__handle:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 1px; }
.layout-preference-list__handle.is-disabled { cursor: default; opacity: .45; }
.layout-preference-list__label { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.layout-preference-list__fixed { color: var(--cc-muted); font-size: 11px; }
.layout-preference-list__moves { display: flex; gap: 0; }
.layout-preference-list__moves :deep(.el-button) { width: 30px; height: 30px; margin-left: 0; padding: 0; }
.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>
@@ -0,0 +1,63 @@
import { computed, defineComponent } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import QueryToolbar, { type QueryField } from './QueryToolbar.vue'
import LayoutPreferenceList from './LayoutPreferenceList.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
const FieldStub = defineComponent({
inheritAttrs: false,
props: { placeholder: String },
template: '<input :data-placeholder="placeholder" />',
})
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '关键词' },
{ key: 'status', type: 'select', label: '状态', placeholder: '状态' },
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '创建日期' },
]
function mountToolbar() {
return shallowMount(QueryToolbar, {
props: {
modelValue: { keyword: '', status: '', createdRange: [] },
'onUpdate:modelValue': () => undefined,
ariaLabel: '订单筛选',
fields,
preferenceKey: 'orders-query',
},
global: {
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
stubs: {
ElInput: FieldStub,
ElSelect: FieldStub,
ElDatePicker: FieldStub,
ElButton: ButtonStub,
ElPopover: PopoverStub,
},
},
})
}
describe('QueryToolbar layout preferences', () => {
beforeEach(() => localStorage.clear())
it('restores and updates the user field order', async () => {
localStorage.setItem(preferenceStorageKey('query', 'user-1', 'orders-query'), JSON.stringify({
version: 1,
order: ['status', 'keyword', 'createdRange'],
hidden: [],
}))
const wrapper = mountToolbar()
expect(wrapper.findAll('input').map((input) => input.attributes('data-placeholder')))
.toEqual(['状态', '关键词', '创建日期'])
wrapper.findComponent(LayoutPreferenceList).vm.$emit('move', 'createdRange', 0)
await wrapper.vm.$nextTick()
expect(wrapper.findAll('input').map((input) => input.attributes('data-placeholder')))
.toEqual(['创建日期', '状态', '关键词'])
})
})
+84 -3
View File
@@ -1,5 +1,14 @@
<script setup lang="ts">
import { Search, RotateCcw } from '@lucide/vue'
import { computed, inject, ref, watch } from 'vue'
import { Search, RotateCcw, Settings2 } from '@lucide/vue'
import LayoutPreferenceList, { type LayoutPreferenceItem } from './LayoutPreferenceList.vue'
import {
UI_PREFERENCE_SCOPE,
readLayoutPreference,
removeLayoutPreference,
writeLayoutPreference,
type LayoutPreferenceSnapshot,
} from '@/shared/utils/uiLayoutPreference'
defineOptions({ inheritAttrs: false })
@@ -16,7 +25,7 @@ export interface QueryOption {
export interface QueryField {
key: string
type: QueryFieldType
label?: string
label: string
width?: string | number
placeholder?: string
options?: QueryOption[]
@@ -26,6 +35,8 @@ export interface QueryField {
const props = withDefaults(defineProps<{
ariaLabel: string
fields: QueryField[]
availableFields?: QueryField[]
preferenceKey?: string
searchText?: string
resetText?: string
searchOnReset?: boolean
@@ -40,6 +51,69 @@ const emit = defineEmits<{
search: [model: QueryModel]
reset: [model: QueryModel]
}>()
const preferenceScope = inject(UI_PREFERENCE_SCOPE, computed(() => 'anonymous'))
const preference = ref<LayoutPreferenceSnapshot>({ version: 1, order: [], hidden: [] })
const preferenceFields = computed(() => props.availableFields ?? props.fields)
const preferenceFieldKeys = computed(() => preferenceFields.value.map((field) => field.key))
const preferenceEnabled = computed(() => Boolean(props.preferenceKey))
function fieldLabel(field: QueryField) {
const ariaLabel = field.props?.['aria-label']
return field.label || (typeof ariaLabel === 'string' ? ariaLabel : '') || field.placeholder || field.key
}
function applyPreference() {
preference.value = readLayoutPreference(
'query',
preferenceScope.value,
props.preferenceKey || '',
preferenceFieldKeys.value,
)
}
watch(
[preferenceScope, () => props.preferenceKey, () => preferenceFieldKeys.value.join('\u0000')],
applyPreference,
{ immediate: true },
)
const orderedPreferenceFields = computed(() => {
const fieldsByKey = new Map(preferenceFields.value.map((field) => [field.key, field]))
return preference.value.order.flatMap((key) => {
const field = fieldsByKey.get(key)
return field ? [field] : []
})
})
const orderedFields = computed(() => {
const visibleKeys = new Set(props.fields.map((field) => field.key))
return orderedPreferenceFields.value.filter((field) => visibleKeys.has(field.key))
})
const preferenceItems = computed<LayoutPreferenceItem[]>(() => orderedPreferenceFields.value.map((field) => ({
key: field.key,
label: fieldLabel(field),
visible: true,
hideable: false,
movable: true,
})))
function persistPreference(order: string[]) {
preference.value = { version: 1, order, hidden: [] }
writeLayoutPreference('query', preferenceScope.value, props.preferenceKey || '', preference.value)
}
function moveField(key: string, targetIndex: number) {
const order = [...preference.value.order]
const currentIndex = order.indexOf(key)
if (currentIndex < 0) return
order.splice(currentIndex, 1)
order.splice(Math.min(Math.max(targetIndex, 0), order.length), 0, key)
persistPreference(order)
}
function resetPreference() {
removeLayoutPreference('query', preferenceScope.value, props.preferenceKey || '')
applyPreference()
}
function updateField(key: string, value: QueryValue) {
model.value = { ...model.value, [key]: value }
@@ -87,7 +161,7 @@ defineExpose({ search, reset })
:aria-label="ariaLabel"
>
<div class="query-toolbar__filters">
<div v-for="field in fields" :key="field.key" class="query-toolbar__field" :style="fieldStyle(field)">
<div v-for="field in orderedFields" :key="field.key" class="query-toolbar__field" :style="fieldStyle(field)">
<el-input
v-if="field.type === 'input'"
:model-value="model[field.key] as string"
@@ -127,6 +201,12 @@ defineExpose({ search, reset })
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="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>
</template>
<LayoutPreferenceList :items="preferenceItems" @move="moveField" @reset="resetPreference" />
</el-popover>
</div>
</section>
</template>
@@ -138,5 +218,6 @@ defineExpose({ search, reset })
.query-toolbar__field :deep(.el-input), .query-toolbar__field :deep(.el-select), .query-toolbar__field :deep(.el-date-editor) { width: 100%; max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
:global(.cc-layout-preference-popper) { max-width: calc(100vw - 24px); }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
</style>
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
preferenceStorageKey,
readLayoutPreference,
reconcileOrder,
removeLayoutPreference,
writeLayoutPreference,
} from './uiLayoutPreference'
describe('UI layout preferences', () => {
beforeEach(() => localStorage.clear())
it('reconciles stored order with the current stable keys', () => {
expect(reconcileOrder(['actions', 'name', 'status', 'createdAt'], ['status', 'removed', 'name', 'status']))
.toEqual(['status', 'name', 'actions', 'createdAt'])
})
it('falls back safely when cached JSON is invalid', () => {
localStorage.setItem(preferenceStorageKey('table', 'user-1', 'customers'), '{broken')
expect(readLayoutPreference('table', 'user-1', 'customers', ['actions', 'name'], ['name']))
.toEqual({ version: 1, order: ['actions', 'name'], hidden: ['name'] })
})
it('keeps preferences isolated by user and removes them on reset', () => {
writeLayoutPreference('query', 'user-1', 'orders', { version: 1, order: ['status', 'keyword'], hidden: [] })
expect(readLayoutPreference('query', 'user-1', 'orders', ['keyword', 'status']).order).toEqual(['status', 'keyword'])
expect(readLayoutPreference('query', 'user-2', 'orders', ['keyword', 'status']).order).toEqual(['keyword', 'status'])
removeLayoutPreference('query', 'user-1', 'orders')
expect(readLayoutPreference('query', 'user-1', 'orders', ['keyword', 'status']).order).toEqual(['keyword', 'status'])
})
})
+82
View File
@@ -0,0 +1,82 @@
import type { ComputedRef, InjectionKey } from 'vue'
export type LayoutPreferenceKind = 'query' | 'table'
export interface LayoutPreferenceSnapshot {
version: 1
order: string[]
hidden: string[]
}
export const UI_PREFERENCE_SCOPE: InjectionKey<ComputedRef<string>> = Symbol('ui-preference-scope')
function uniqueKeys(values: readonly unknown[]) {
const seen = new Set<string>()
return values.flatMap((value) => {
if (typeof value !== 'string' || !value || seen.has(value)) return []
seen.add(value)
return [value]
})
}
export function preferenceStorageKey(kind: LayoutPreferenceKind, scope: string, preferenceKey: string) {
return `cc-admin-${kind}-layout:v1:${scope}:${preferenceKey}`
}
export function reconcileOrder(availableKeys: readonly string[], storedOrder: readonly string[]) {
const available = uniqueKeys(availableKeys)
const availableSet = new Set(available)
const retained = uniqueKeys(storedOrder).filter((key) => availableSet.has(key))
const retainedSet = new Set(retained)
return [...retained, ...available.filter((key) => !retainedSet.has(key))]
}
export function readLayoutPreference(
kind: LayoutPreferenceKind,
scope: string,
preferenceKey: string,
availableKeys: readonly string[],
defaultHidden: readonly string[] = [],
): LayoutPreferenceSnapshot {
const fallback: LayoutPreferenceSnapshot = {
version: 1,
order: reconcileOrder(availableKeys, []),
hidden: uniqueKeys(defaultHidden).filter((key) => availableKeys.includes(key)),
}
if (!scope || !preferenceKey) return fallback
try {
const parsed = JSON.parse(localStorage.getItem(preferenceStorageKey(kind, scope, preferenceKey)) || 'null') as Partial<LayoutPreferenceSnapshot> | null
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.order) || !Array.isArray(parsed.hidden)) return fallback
return {
version: 1,
order: reconcileOrder(availableKeys, parsed.order),
hidden: uniqueKeys(parsed.hidden).filter((key) => availableKeys.includes(key)),
}
} catch {
return fallback
}
}
export function writeLayoutPreference(
kind: LayoutPreferenceKind,
scope: string,
preferenceKey: string,
snapshot: LayoutPreferenceSnapshot,
) {
if (!scope || !preferenceKey) return
try {
localStorage.setItem(preferenceStorageKey(kind, scope, preferenceKey), JSON.stringify(snapshot))
} catch {
// Layout preferences are optional and must never block business workflows.
}
}
export function removeLayoutPreference(kind: LayoutPreferenceKind, scope: string, preferenceKey: string) {
if (!scope || !preferenceKey) return
try {
localStorage.removeItem(preferenceStorageKey(kind, scope, preferenceKey))
} catch {
// Ignore unavailable browser storage and keep the in-memory default layout.
}
}