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
+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>