<!-- 表格组件 -->
|
<!-- 支持:el-table 全部属性、事件、插槽,同官方文档写法 -->
|
<!-- 扩展功能:分页组件、渲染自定义列、loading、表格全局边框、斑马纹、表格尺寸、表头背景配置 -->
|
<!-- 获取 ref:默认暴露了 elTableRef 外部通过 ref.value.elTableRef 可以调用 el-table 方法 -->
|
<template>
|
<div class="art-table" :class="{ 'is-empty': isEmpty }" :style="containerHeight">
|
<ElTable ref="elTableRef" v-loading="!!loading" v-bind="mergedTableProps">
|
<template v-for="col in columns" :key="col.prop || col.type">
|
<!-- 渲染全局序号列 -->
|
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
|
<template #default="{ $index }">
|
<span>{{ getGlobalIndex($index) }}</span>
|
</template>
|
</ElTableColumn>
|
|
<!-- 渲染展开行 -->
|
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
|
<template #default="{ row }">
|
<component :is="col.formatter ? col.formatter(row) : null" />
|
</template>
|
</ElTableColumn>
|
|
<!-- 渲染普通列 -->
|
<ElTableColumn v-else v-bind="cleanColumnProps(col)">
|
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
|
<slot
|
:name="col.headerSlotName || `${col.prop}-header`"
|
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
|
>
|
{{ col.label }}
|
</slot>
|
</template>
|
<template v-if="col.useSlot && col.prop" #default="slotScope">
|
<slot
|
v-if="shouldRenderSlotScope(slotScope)"
|
:name="col.slotName || col.prop"
|
v-bind="{
|
...slotScope,
|
prop: col.prop,
|
value: col.prop ? slotScope.row[col.prop] : undefined
|
}"
|
/>
|
</template>
|
</ElTableColumn>
|
</template>
|
|
<template v-if="$slots.default" #default><slot /></template>
|
|
<template #empty>
|
<div v-if="loading"></div>
|
<ElEmpty v-else :description="emptyText" :image-size="120" />
|
</template>
|
</ElTable>
|
|
<div
|
class="pagination custom-pagination"
|
v-if="showPagination"
|
:class="mergedPaginationOptions?.align"
|
ref="paginationRef"
|
>
|
<ElPagination
|
v-bind="mergedPaginationOptions"
|
:total="pagination?.total"
|
:disabled="loading"
|
:page-size="pagination?.size"
|
:current-page="pagination?.current"
|
@size-change="handleSizeChange"
|
@current-change="handleCurrentChange"
|
/>
|
</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, computed, nextTick, watchEffect, getCurrentInstance, useAttrs } from 'vue'
|
import { storeToRefs } from 'pinia'
|
import { useTableStore } from '@/store/modules/table'
|
import { useCommon } from '@/hooks/core/useCommon'
|
import { useTableHeight } from '@/hooks/core/useTableHeight'
|
import { useResizeObserver, useWindowSize } from '@vueuse/core'
|
defineOptions({ name: 'ArtTable' })
|
const { width } = useWindowSize()
|
const elTableRef = ref(null)
|
const paginationRef = ref()
|
const tableHeaderRef = ref()
|
const tableStore = useTableStore()
|
const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground } = storeToRefs(tableStore)
|
const props = defineProps({
|
loading: { required: false, default: false },
|
columns: { required: false, default: () => [] },
|
pagination: { required: false },
|
paginationOptions: { required: false },
|
fit: { required: false, default: true },
|
showHeader: { required: false, default: true },
|
stripe: { required: false, default: void 0 },
|
border: { required: false, default: void 0 },
|
size: { required: false, default: void 0 },
|
emptyHeight: { required: false, default: '100%' },
|
emptyText: { required: false, default: '暂无数据' },
|
showTableHeader: { required: false, default: true }
|
})
|
const instance = getCurrentInstance()
|
const attrs = useAttrs()
|
const LAYOUT = {
|
MOBILE: 'prev, pager, next, sizes, jumper, total',
|
IPAD: 'prev, pager, next, jumper, total',
|
DESKTOP: 'total, prev, pager, next, sizes, jumper'
|
}
|
const layout = computed(() => {
|
if (width.value < 768) {
|
return LAYOUT.MOBILE
|
} else if (width.value < 1024) {
|
return LAYOUT.IPAD
|
} else {
|
return LAYOUT.DESKTOP
|
}
|
})
|
const DEFAULT_PAGINATION_OPTIONS = {
|
pageSizes: [10, 20, 30, 50, 100],
|
align: 'center',
|
background: true,
|
layout: layout.value,
|
hideOnSinglePage: false,
|
size: 'default',
|
pagerCount: width.value > 1200 ? 7 : 5
|
}
|
const mergedPaginationOptions = computed(() => ({
|
...DEFAULT_PAGINATION_OPTIONS,
|
...props.paginationOptions
|
}))
|
const border = computed(() => props.border ?? isBorder.value)
|
const stripe = computed(() => props.stripe ?? isZebra.value)
|
const size = computed(() => props.size ?? tableSize.value)
|
const isEmpty = computed(() => props.data?.length === 0)
|
const paginationHeight = ref(0)
|
const tableHeaderHeight = ref(0)
|
useResizeObserver(paginationRef, (entries) => {
|
const entry = entries[0]
|
if (entry) {
|
requestAnimationFrame(() => {
|
paginationHeight.value = entry.contentRect.height
|
})
|
}
|
})
|
useResizeObserver(tableHeaderRef, (entries) => {
|
const entry = entries[0]
|
if (entry) {
|
requestAnimationFrame(() => {
|
tableHeaderHeight.value = entry.contentRect.height
|
})
|
}
|
})
|
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15))
|
const { containerHeight } = useTableHeight({
|
showTableHeader: computed(() => props.showTableHeader),
|
paginationHeight,
|
tableHeaderHeight,
|
paginationSpacing: PAGINATION_SPACING
|
})
|
const height = computed(() => {
|
if (isFullScreen.value) return '100%'
|
if (isEmpty.value && !props.loading) return props.emptyHeight
|
if (props.height) return props.height
|
return '100%'
|
})
|
const headerCellStyle = computed(() => ({
|
background: isHeaderBackground.value
|
? 'var(--el-fill-color-lighter)'
|
: 'var(--default-box-color)',
|
...(props.headerCellStyle || {})
|
// 合并用户传入的样式
|
}))
|
const hasExplicitTableProp = (propName) => {
|
const rawProps = instance?.vnode.props || {}
|
const kebabName = propName.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
|
return propName in rawProps || kebabName in rawProps
|
}
|
const mergedTableProps = computed(() => ({
|
...attrs,
|
...props,
|
height: height.value,
|
stripe: stripe.value,
|
border: border.value,
|
size: size.value,
|
headerCellStyle: headerCellStyle.value,
|
// Element Plus 默认值为 true,未显式传入时不应被 ArtTable 覆盖成 false。
|
selectOnIndeterminate: hasExplicitTableProp('selectOnIndeterminate')
|
? props.selectOnIndeterminate
|
: void 0
|
}))
|
const showPagination = computed(() => props.pagination && !isEmpty.value)
|
const shouldRenderSlotScope = (slotScope) => {
|
return slotScope.$index === void 0 || slotScope.$index >= 0
|
}
|
const cleanColumnProps = (col) => {
|
const columnProps = { ...col }
|
delete columnProps.useHeaderSlot
|
delete columnProps.headerSlotName
|
delete columnProps.useSlot
|
delete columnProps.slotName
|
return columnProps
|
}
|
const handleSizeChange = (val) => {
|
emit('pagination:size-change', val)
|
}
|
const handleCurrentChange = (val) => {
|
emit('pagination:current-change', val)
|
scrollToTop()
|
}
|
const { scrollToTop: scrollPageToTop } = useCommon()
|
const scrollToTop = () => {
|
nextTick(() => {
|
elTableRef.value?.setScrollTop(0)
|
scrollPageToTop()
|
})
|
}
|
const getGlobalIndex = (index) => {
|
if (!props.pagination) return index + 1
|
const { current, size: size2 } = props.pagination
|
return (current - 1) * size2 + index + 1
|
}
|
const emit = defineEmits(['pagination:size-change', 'pagination:current-change'])
|
const findTableHeader = () => {
|
if (!props.showTableHeader) {
|
tableHeaderRef.value = void 0
|
return
|
}
|
const tableHeader = document.getElementById('art-table-header')
|
if (tableHeader) {
|
tableHeaderRef.value = tableHeader
|
} else {
|
tableHeaderRef.value = void 0
|
}
|
}
|
watchEffect(
|
() => {
|
void props.data?.length
|
const shouldShow = props.showTableHeader
|
if (shouldShow) {
|
nextTick(() => {
|
findTableHeader()
|
})
|
} else {
|
tableHeaderRef.value = void 0
|
}
|
},
|
{ flush: 'post' }
|
)
|
defineExpose({
|
scrollToTop,
|
elTableRef
|
})
|
</script>
|
|
<style lang="scss" scoped>
|
@use './style';
|
</style>
|