Files
client-web/src/components/common/DataTable.vue
T

261 lines
13 KiB
Vue
Raw Normal View History

<script lang="ts">
export interface DataTableColumn {
key?: string | number
label?: string
prop?: string
property?: string
columnKey?: string
slotName?: string
headerSlotName?: string
2026-09-07 19:04:00 +08:00
singleLine?: boolean
showOverflowTooltip?: boolean
className?: string
[attribute: string]: unknown
}
export type PaginationItem = number | 'ellipsis-start' | 'ellipsis-end'
export function normalizePageSizes(values: readonly number[]) {
const sizes = values
.filter((value) => Number.isFinite(value))
.map((value) => Math.trunc(value))
.filter((value) => value > 0)
const uniqueSizes = [...new Set(sizes)]
return uniqueSizes.length > 0 ? uniqueSizes : [10]
}
export function normalizePage(value: number, pageCount: number) {
const maximum = Number.isFinite(pageCount) && pageCount > 0 ? Math.trunc(pageCount) : 1
if (!Number.isFinite(value)) return 1
return Math.min(Math.max(Math.trunc(value), 1), maximum)
}
export function createPaginationItems(page: number, pageCount: number): PaginationItem[] {
if (pageCount <= 0) return []
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1)
if (page <= 4) return [1, 2, 3, 4, 5, 'ellipsis-end', pageCount]
if (page >= pageCount - 3) {
return [1, 'ellipsis-start', pageCount - 4, pageCount - 3, pageCount - 2, pageCount - 1, pageCount]
}
return [1, 'ellipsis-start', page - 1, page, page + 1, 'ellipsis-end', pageCount]
}
</script>
<script setup lang="ts">
import { computed, useSlots, watch } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
2026-09-15 18:17:21 +08:00
import { splitTableActions, runTableAction, type TableAction } from './tableActions'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
data: object[]
columns: DataTableColumn[]
loading?: boolean
emptyText?: string
total?: number
pageSizes?: number[]
2026-09-15 18:17:21 +08:00
rowActions?: (row: object) => TableAction[]
}>(), {
loading: false,
emptyText: '暂无数据',
total: undefined,
pageSizes: () => [10, 20, 50, 100],
})
const page = defineModel<number>('page', { default: 1 })
const pageSize = defineModel<number>('pageSize', { default: 10 })
const slots = useSlots()
const normalizedTotal = computed(() => {
const value = props.total ?? 0
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0
})
const normalizedPageSizes = computed(() => normalizePageSizes(props.pageSizes))
const effectivePageSize = computed(() => (
normalizedPageSizes.value.includes(pageSize.value) ? pageSize.value : normalizedPageSizes.value[0]
))
const pageCount = computed(() => (
normalizedTotal.value === 0 ? 0 : Math.ceil(normalizedTotal.value / effectivePageSize.value)
))
const currentPage = computed(() => normalizePage(page.value, pageCount.value))
const paginationItems = computed(() => createPaginationItems(currentPage.value, pageCount.value))
watch([pageSize, normalizedPageSizes], ([value, sizes]) => {
const integerValue = Number.isFinite(value) ? Math.trunc(value) : sizes[0]
const nextValue = sizes.includes(integerValue) ? integerValue : sizes[0]
if (value === nextValue) return
pageSize.value = nextValue
page.value = 1
}, { immediate: true })
watch([page, pageCount, () => props.loading, () => Boolean(slots.error)], ([value, count, loading, hasError]) => {
// A cleared total is not an authoritative page count while loading or showing a request error.
if (loading || hasError) return
const nextValue = normalizePage(value, count)
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
2026-09-07 19:04:00 +08:00
function columnAttrs({ key, slotName, headerSlotName, singleLine, ...attrs }: DataTableColumn) {
return attrs
}
2026-09-07 19:04:00 +08:00
function systemColumn(column: DataTableColumn) {
return column.type === 'selection'
|| column.type === 'index'
|| column.type === 'expand'
|| column.slotName === 'actions'
|| column.label === '操作'
}
function singleLineColumn(column: DataTableColumn) {
return column.singleLine !== false && !systemColumn(column)
}
function columnOverflowTooltip(column: DataTableColumn) {
return singleLineColumn(column) ? column.showOverflowTooltip ?? true : column.showOverflowTooltip
}
function columnClassName(column: DataTableColumn) {
return [
column.className,
systemColumn(column) ? 'data-table__system-cell' : '',
singleLineColumn(column) ? 'data-table__single-line-cell' : '',
].filter(Boolean).join(' ')
}
function columnRenderKey(column: DataTableColumn, index: number) {
return String(column.key ?? column.columnKey ?? column.prop ?? column.property ?? index)
}
function hasSlot(name?: string) {
return Boolean(name && slots[name])
}
function updatePageSize(value: number) {
const nextValue = normalizedPageSizes.value.includes(value) ? value : normalizedPageSizes.value[0]
if (nextValue === pageSize.value) return
pageSize.value = nextValue
page.value = 1
}
function setPage(value: number) {
page.value = normalizePage(value, pageCount.value)
}
</script>
<template>
2026-09-15 18:20:34 +08:00
<div class="data-table" :class="$attrs.class">
2026-09-19 17:02:11 +08:00
<header v-if="$slots.toolbar || $slots['toolbar-actions']" class="data-table__toolbar" :class="{ 'data-table__toolbar--actions-only': !$slots.toolbar }">
<div v-if="$slots.toolbar" class="data-table__toolbar-heading"><slot name="toolbar" /></div>
2026-09-15 18:17:21 +08:00
<div class="data-table__toolbar-actions"><slot name="toolbar-actions" /></div>
</header>
<slot name="before-table" />
<slot v-if="$slots.error" name="error" />
<template v-else>
2026-09-15 18:20:34 +08:00
<el-table v-bind="{ ...$attrs, class: undefined }" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<el-table-column
v-for="(column, index) in columns"
:key="columnRenderKey(column, index)"
v-bind="columnAttrs(column)"
2026-09-07 19:04:00 +08:00
:class-name="columnClassName(column)"
:show-overflow-tooltip="columnOverflowTooltip(column)"
>
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
<slot :name="column.headerSlotName" v-bind="scope" />
</template>
2026-09-15 18:17:21 +08:00
<template v-if="column.slotName === 'actions' && rowActions" #default="{ row }">
<div class="data-table__row-actions">
2026-09-18 16:48:35 +08:00
<el-button v-for="action in splitTableActions(rowActions(row)).visible" :key="action.key" link type="primary"
2026-09-15 18:17:21 +08:00
:disabled="action.disabled" :loading="action.loading" @click="runTableAction(action)">
{{ action.label }}
2026-09-15 18:17:21 +08:00
</el-button>
<el-dropdown v-if="splitTableActions(rowActions(row)).overflow.length" trigger="click">
<el-button class="data-table__more" aria-label="更多操作" title="更多操作">…</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="action in splitTableActions(rowActions(row)).overflow" :key="action.key"
:disabled="action.disabled || action.loading" @click="runTableAction(action)">
{{ action.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<template v-else-if="hasSlot(column.slotName)" #default="scope">
<slot :name="column.slotName" v-bind="scope" />
</template>
</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="表格分页">
<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>
<span v-else>共 0 条</span>
</slot>
</div>
<div class="data-table__pagination-controls">
<label class="data-table__page-size">
<span>每页</span>
<el-select :model-value="effectivePageSize" :disabled="loading" aria-label="每页条数" @update:model-value="updatePageSize">
<el-option v-for="size in normalizedPageSizes" :key="size" :label="String(size)" :value="size" />
</el-select>
<span>条</span>
</label>
<nav v-if="pageCount > 0" class="data-table__page-buttons" aria-label="页码">
<el-button class="data-table__page-button" :disabled="loading || currentPage <= 1" aria-label="上一页" title="上一页" @click="setPage(currentPage - 1)"><ChevronLeft :size="16" /></el-button>
<template v-for="item in paginationItems" :key="item">
<span v-if="typeof item !== 'number'" class="data-table__page-ellipsis" aria-hidden="true">...</span>
<el-button
v-else
class="data-table__page-button"
:type="item === currentPage ? 'primary' : undefined"
:disabled="loading"
:aria-current="item === currentPage ? 'page' : undefined"
:aria-label="`第 ${item} 页`"
:title="`第 ${item} 页`"
@click="setPage(item)"
>{{ item }}</el-button>
</template>
<el-button class="data-table__page-button" :disabled="loading || currentPage >= pageCount" aria-label="下一页" title="下一页" @click="setPage(currentPage + 1)"><ChevronRight :size="16" /></el-button>
</nav>
</div>
</footer>
2026-09-15 18:17:21 +08:00
</template>
</div>
</template>
<style scoped>
.data-table { min-width: 0; }
2026-09-15 18:17:21 +08:00
.data-table__toolbar { display: flex; min-height: 48px; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--client-gap); padding: var(--client-toolbar-padding); border-bottom: 1px solid var(--client-border); }
2026-09-19 17:02:11 +08:00
.data-table__toolbar--actions-only { justify-content: flex-start; }
2026-09-15 18:17:21 +08:00
.data-table__toolbar-heading, .data-table__toolbar-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--client-gap); }
.data-table__toolbar-heading { color: var(--client-muted); font-size: 12px; }
.data-table__toolbar-heading :slotted(strong) { color: var(--client-text); font-size: 14px; }
.data-table__toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
.data-table__row-actions { display: flex; align-items: center; gap: var(--client-gap); white-space: nowrap; }
2026-09-18 16:48:35 +08:00
.data-table__row-actions :deep(.el-button) { height: var(--client-row-action-height); padding: 0 4px; margin: 0; }
.data-table__row-actions :deep(.el-button.is-link + .el-button.is-link) { position: relative; }
.data-table__row-actions :deep(.el-button.is-link + .el-button.is-link)::before { position: absolute; left: -5px; width: 1px; height: 12px; background: var(--client-border); content: ''; }
2026-09-15 18:20:34 +08:00
.data-table :deep(.data-table__system-cell .data-table__row-actions .el-button + .el-button), .data-table :deep(.data-table__system-cell .data-table__row-actions .el-button + .el-dropdown) { margin-left: 0; }
2026-09-18 16:48:35 +08:00
.data-table__more { min-width: 28px; width: 28px; padding: 0; }
2026-09-15 18:17:21 +08:00
:global(.client-ui .data-table .el-table) { color: var(--client-text); font-size: var(--client-font-size); --el-table-border-color: var(--client-border); --el-table-header-bg-color: var(--client-header-bg); --el-table-row-hover-bg-color: var(--client-hover-bg); }
:global(.client-ui .data-table th.el-table__cell) { height: var(--client-table-header-height); padding: 0; color: var(--client-muted); font-weight: 600; }
:global(.client-ui .data-table td.el-table__cell) { height: var(--client-table-row-height); padding: 0; }
:global(.client-ui .data-table .el-table .cell) { padding: 0 12px; line-height: 20px; }
:global(.client-ui .data-table .el-table__row:nth-child(even)) { --el-table-tr-bg-color: #fcfcfd; }
2026-09-07 19:04:00 +08:00
.data-table :deep(.data-table__single-line-cell .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table :deep(.data-table__system-cell .cell) { white-space: nowrap; }
.data-table :deep(.data-table__system-cell .el-button + .el-button), .data-table :deep(.data-table__system-cell .el-button + .el-dropdown) { margin-left: 8px; }
2026-09-15 18:17:21 +08:00
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: var(--client-pagination-padding); border-top: 1px solid var(--border); color: var(--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; }
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
.data-table__page-size :deep(.el-select) { width: 82px; }
.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; } }
</style>