181 lines
7.9 KiB
Vue
181 lines
7.9 KiB
Vue
<script lang="ts">
|
|||
|
|
export interface DataTableColumn {
|
||
|
|
key?: string | number
|
||
|
|
label?: string
|
||
|
|
prop?: string
|
||
|
|
property?: string
|
||
|
|
columnKey?: string
|
||
|
|
slotName?: string
|
||
|
|
headerSlotName?: 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'
|
||
|
|
|
||
|
|
defineOptions({ inheritAttrs: false })
|
||
|
|
|
||
|
|
const props = withDefaults(defineProps<{
|
||
|
|
data: object[]
|
||
|
|
columns: DataTableColumn[]
|
||
|
|
loading?: boolean
|
||
|
|
emptyText?: string
|
||
|
|
total?: number
|
||
|
|
pageSizes?: number[]
|
||
|
|
}>(), {
|
||
|
|
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], ([value, count]) => {
|
||
|
|
const nextValue = normalizePage(value, count)
|
||
|
|
if (value !== nextValue) page.value = nextValue
|
||
|
|
}, { immediate: true })
|
||
|
|
|
||
|
|
function columnAttrs({ key, slotName, headerSlotName, ...attrs }: DataTableColumn) {
|
||
|
|
return attrs
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
<div class="data-table">
|
||
|
|
<el-table v-bind="$attrs" 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)"
|
||
|
|
>
|
||
|
|
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
|
||
|
|
<slot :name="column.headerSlotName" v-bind="scope" />
|
||
|
|
</template>
|
||
|
|
<template v-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>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.data-table { min-width: 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; }
|
||
|
|
.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>
|