表格组件
<!-- glpTable.vue - 表格组件 -->
<template>
<div class="table-content">
<div
v-if="toolbar"
class="table-toolbar flex justify-between items-center mb-10px"
>
<div class="flex-center">
<XButton
v-for="btn in toolBtnList"
:key="btn.label"
:preIcon="btn.preIcon"
v-bind="toolBtnAttrs(btn)"
:title="btn.label"
@click="btn.onClick(row)"
/>
</div>
<!-- 控制列显隐按钮 -->
<TableHeaderSetting
v-if="isShowHeaderSetting && displayMainColumns.length > 7"
:defaultColumn="displayMainColumns"
@submit="setColumnConfig"
/>
</div>
<div
v-loading="loading"
class="flex-1 flex-column gap-20px overflow-hidden"
>
<ElTable
ref="elTableRef"
:data="data"
@selection-change="handleSelectionChange"
stripe
highlight-current-row
show-overflow-tooltip
v-bind="tableAttrs"
>
<template #empty>
<div class="flex-column flex-center h-210px">
<img src="@/assets/imgs/notData.png" class="h-120px w-120px" />
<span>暂无数据</span>
</div>
</template>
<!-- 展开行 -->
<ElTableColumn
v-if="expand"
type="expand"
:align="align"
:header-align="headerAlign"
>
<template #default="data">
<slot name="expand" v-bind="data" />
</template>
</ElTableColumn>
<!-- 多选框 -->
<ElTableColumn
v-if="selection"
type="selection"
:reserve-selection="reserveSelection"
:align="align"
:header-align="headerAlign"
width="50"
/>
<el-table-column v-if="!expand" label="序号" type="index" width="60" />
<!-- 主表列 -->
<template
v-for="column in tableHeader"
:key="`main-${column.field || column.prop || column.label}`"
>
<!-- 普通列 -->
<ElTableColumn
:prop="column.field || column.prop"
:label="column.label"
:width="column.width"
:min-width="column.minWidth"
:fixed="column.fixed"
:sortable="column.sortable"
:show-overflow-tooltip="
column.showOverflowTooltip ?? showOverflowTooltip
"
:align="column.align || align"
:header-align="column.headerAlign || headerAlign"
v-bind="getColumnProps(column)"
>
<!-- 表头插槽 -->
<template #header>
<slot
:name="column.field || column.prop + '-header'"
v-if="hasSlot(column.field || column.prop + '-header')"
>
{{ column.label }}
</slot>
<template v-else>{{ column.label }}</template>
</template>
<!-- 列内容 -->
<template #default="data">
<slot
:name="column.field || column.prop"
v-if="hasSlot(column.field || column.prop)"
v-bind="data"
>
{{ formatCellValue(column, data) }}
</slot>
<template v-else>{{ formatCellValue(column, data) }}</template>
</template>
</ElTableColumn>
</template>
<!-- 扩展表列 -->
<template
v-for="column in finalExtendColumns"
:key="`extend-${column.field || column.prop || column.label}`"
>
<ElTableColumn
:prop="column.field || column.prop"
:label="column.label"
:width="column.width"
:min-width="column.minWidth"
:fixed="column.fixed"
:sortable="column.sortable"
:show-overflow-tooltip="
column.showOverflowTooltip ?? showOverflowTooltip
"
:align="column.align || align"
:header-align="column.headerAlign || headerAlign"
v-bind="getColumnProps(column)"
>
<!-- 表头插槽 -->
<template #header>
<slot
:name="`extend-${column.field || column.prop}` + '-header'"
v-if="
hasSlot(`extend-${column.field || column.prop}` + '-header')
"
>
{{ column.label }}
</slot>
<template v-else>{{ column.label }}</template>
</template>
<!-- 列内容 -->
<template #default="data">
<slot
:name="`extend-${column.field || column.prop}`"
v-if="hasSlot(`extend-${column.field || column.prop}`)"
v-bind="data"
>
{{ formatCellValue(column, data) }}
</slot>
<template v-else>{{ formatCellValue(column, data) }}</template>
</template>
</ElTableColumn>
</template>
<!-- 操作列 - 使用新的操作按钮组件 -->
<ElTableColumn
v-if="hasOperationColumn"
:label="operationColumn.label || '操作'"
:width="operationColumn.width || '200px'"
:fixed="operationColumn.fixed || 'right'"
:align="operationColumn.align || align"
:header-align="operationColumn.headerAlign || headerAlign"
v-bind="operationColumn"
>
<template #default="data">
<!-- 使用自定义操作按钮组件 -->
<OperationButtons
:list-btns="getOperationButtons(data.row)"
:row="data.row"
/>
</template>
</ElTableColumn>
</ElTable>
<!-- 追加内容 -->
<template v-if="hasSlot('append')">
<slot name="append" />
</template>
<!-- 分页 -->
<ElPagination
v-if="pagination"
v-model:page-size="internalPageSize"
v-model:current-page="internalCurrentPage"
v-bind="mergedPagination"
/>
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
watch,
onMounted,
useAttrs,
useSlots,
defineProps,
defineEmits,
defineOptions,
} from 'vue'
import { ElTable, ElTableColumn, ElPagination } from 'element-plus'
import OperationButtons from '@/components/OperationButtons/index.vue' // 引入操作按钮组件
import TableHeaderSetting from '@/components/CustomCommon/CommonTableHeaderSetting.vue'
// 定义组件名称
defineOptions({
name: 'ProTable',
})
// 主表默认字段(硬编码)
const DEFAULT_MAIN_COLUMNS = [
{ type: 'index', label: '序号', width: '65px' },
{ field: 'id', label: 'ID', width: '80px', sortable: true },
{ field: 'name', label: '名称', minWidth: '120px' },
{ field: 'code', label: '编码', minWidth: '100px' },
{
field: 'status',
label: '状态',
width: '100px',
formatter: (row) => {
return row.status === 'active' ? '启用' : '禁用'
},
},
]
// 定义Props
const props = defineProps({
// 基础配置
pageSize: {
type: Number,
default: 10,
},
currentPage: {
type: Number,
default: 1,
},
total: {
type: Number,
default: 0,
},
selection: {
type: Boolean,
default: false,
},
showOverflowTooltip: {
type: Boolean,
default: true,
},
expand: {
type: Boolean,
default: false,
},
pagination: {
type: [Object, Boolean],
default: undefined,
},
reserveSelection: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
extendLoading: {
type: Boolean,
default: false,
},
reserveIndex: {
type: Boolean,
default: false,
},
align: {
type: String,
validator: (v) => ['left', 'center', 'right'].includes(v),
default: 'left',
},
headerAlign: {
type: String,
validator: (v) => ['left', 'center', 'right'].includes(v),
default: 'left',
},
toolbar: {
type: Boolean,
default: true,
},
toolBtnList: {
type: Array,
default: () => [],
},
isShowHeaderSetting: {
type: Boolean,
default: true,
},
// 数据源配置
data: {
type: Array,
default: () => [],
},
// 列配置
mainColumns: {
type: Array,
default: () => [],
},
// 扩展字段
extendColumns: {
type: Array,
default: () => [],
},
// 扩展字段加载配置
autoLoadExtendColumns: {
type: Boolean,
default: false,
},
// 扩展字段接口名称
fetchExtendColumnsApi: {
type: Function,
default: null,
},
// 扩展字段接口入参
extendColumnsParams: {
type: Object,
default: () => ({}),
},
// 操作列配置
operationColumn: {
type: Object,
default: () => ({
label: '操作',
width: '200px',
fixed: 'right',
buttons: [], // 操作按钮配置
}),
},
})
// 定义Emits
const emit = defineEmits([
'update:pageSize',
'update:currentPage',
'register',
'selection-change',
'action-click',
'extend-columns-loaded',
'update:extendLoading',
'page-change',
'size-change',
'pagination-change',
])
// 获取插槽
const slots = useSlots()
// 表格Ref
const elTableRef = ref(null)
// 内部分页状态
const internalPageSize = ref(props.pageSize)
const internalCurrentPage = ref(props.currentPage)
// 选中的数据
const selections = ref([])
// 内部扩展列状态
const internalExtendColumns = ref([])
// 属性继承
const attrs = useAttrs()
// 表格属性(排除特定配置项)
const tableAttrs = computed(() => {
const attr = { ...attrs }
const excludeKeys = [
'data',
'mainColumns',
'extendColumns',
'operationColumn',
'pagination',
'total',
]
Object.keys(props).forEach((key) => {
if (!excludeKeys.includes(key)) {
attr[key] = props[key]
}
})
return attr
})
const toolBtnAttrs = (btn) => {
const delArr = ['title', 'preIcon', 'postIcon', 'onClick']
const obj = { ...btn }
for (const key in obj) {
if (delArr.indexOf(key) !== -1) {
delete obj[key]
}
}
return obj
}
// 最终的主表列
const finalMainColumns = computed(() => {
return props.mainColumns.length > 0 ? props.mainColumns : DEFAULT_MAIN_COLUMNS
})
// 最终的扩展列
const finalExtendColumns = computed(() => {
return [...props.extendColumns, ...internalExtendColumns.value]
})
// 是否显示主表列
const displayMainColumns = computed(() => {
if (finalExtendColumns.value.length > 0 && props.mainColumns.length === 0) {
return finalMainColumns.value.filter(
(col) => col.type === 'index' || ['id', 'name'].includes(col.field),
)
}
return finalMainColumns.value
})
const tableHeader = ref([...displayMainColumns.value])
const setColumnConfig = (data) => {
if (data && data.length) {
tableHeader.value = data.filter((ele) => ele.checked)
} else {
tableHeader.value = [...displayMainColumns]
}
}
// 分页配置合并
const mergedPagination = computed(() => {
const defaultPagination = {
small: false,
background: true,
pagerCount: document.body.clientWidth < 992 ? 5 : 7,
layout: 'total, sizes,->, prev, pager, next, jumper',
pageSizes: [10, 20, 30, 50, 100],
disabled: false,
hideOnSinglePage: false,
total: props.total,
}
if (props.pagination === true) {
return defaultPagination
} else if (typeof props.pagination === 'object') {
return {
...defaultPagination,
...props.pagination,
total:
props.pagination.total !== undefined
? props.pagination.total
: props.total,
}
}
return defaultPagination
})
// 是否有操作列
const hasOperationColumn = computed(() => {
// 如果有操作列插槽
if (hasSlot('operation')) return true
// 如果operationColumn配置了buttons
return (
props.operationColumn &&
props.operationColumn.buttons &&
props.operationColumn.buttons.length > 0
)
})
// 获取操作按钮配置
const getOperationButtons = (row) => {
if (!props.operationColumn || !props.operationColumn.buttons) return []
// 处理动态按钮配置(支持函数)
return props.operationColumn.buttons
.map((btn) => {
// 如果是函数,执行获取配置
if (typeof btn === 'function') {
return btn(row)
}
// 如果有hidden条件,处理显示隐藏
if (
btn.hidden &&
(typeof btn.hidden === 'function' ? btn.hidden(row) : btn.hidden)
) {
return null
}
// 包装点击事件,触发action-click事件
const onClick = (...args) => {
if (btn.onClick) {
btn.onClick(...args)
}
emit('action-click', btn, row, ...args)
}
return {
...btn,
onClick,
}
})
.filter(Boolean) // 过滤掉null项
}
// 格式化单元格值
const formatCellValue = (column, data) => {
if (column.formatter) {
return column.formatter(
data.row,
data.column,
data.row[column.field || column.prop],
data.$index,
)
}
return data.row[column.field || column.prop]
}
// 获取列属性(排除children)
const getColumnProps = (column) => {
const props = { ...column }
delete props.children
return props
}
// 检查是否存在插槽
const hasSlot = (slotName) => {
return !!slots[slotName]
}
// 加载扩展字段
const loadExtendColumns = async () => {
if (!props.fetchExtendColumnsApi || !props.autoLoadExtendColumns) return
try {
emit('update:extendLoading', true)
const response = await props.fetchExtendColumnsApi(
props.extendColumnsParams,
)
if (response && response.code === 200 && Array.isArray(response.data)) {
internalExtendColumns.value = response.data.map((col) => ({
...col,
showOverflowTooltip:
col.showOverflowTooltip ?? props.showOverflowTooltip,
align: col.align ?? props.align,
headerAlign: col.headerAlign ?? props.headerAlign,
}))
emit('extend-columns-loaded', internalExtendColumns.value)
}
} catch (error) {
console.error('加载扩展字段失败:', error)
internalExtendColumns.value = []
} finally {
emit('update:extendLoading', false)
}
}
// 监听Props变化
watch(
() => props.pageSize,
(val) => {
internalPageSize.value = val
},
{ immediate: true },
)
watch(
() => props.currentPage,
(val) => {
internalCurrentPage.value = val
},
{ immediate: true },
)
// 监听total变化
watch(
() => props.total,
() => {
// total变化时会自动更新mergedPagination
},
)
// 监听每页条数变化
watch(
() => internalPageSize.value,
(val) => {
emit('update:pageSize', val)
emit('size-change', val)
emit('pagination-change', {
pageSize: val,
currentPage: internalCurrentPage.value,
})
if (internalCurrentPage.value > 1) {
internalCurrentPage.value = 1
}
},
)
// 监听当前页码变化
watch(
() => internalCurrentPage.value,
(val) => {
emit('update:currentPage', val)
emit('page-change', val)
emit('pagination-change', {
pageSize: internalPageSize.value,
currentPage: val,
})
},
)
// 注册表格实例并加载扩展字段
onMounted(() => {
if (elTableRef.value) {
emit('register', elTableRef.value.$parent, elTableRef.value)
}
console.log(props.pageSize)
if (props.autoLoadExtendColumns) {
loadExtendColumns()
}
})
// 处理选择变化
const handleSelectionChange = (selection) => {
selections.value = selection
emit('selection-change', selection)
}
// 暴露方法
defineExpose({
elTableRef,
selections,
loadExtendColumns,
internalPageSize,
internalCurrentPage,
})
</script>
<style lang="scss" scoped>
.table-toolbar {
.el-button {
min-width: 86px;
}
}
.table-content {
flex: 1;
background-color: #fff;
box-shadow: 0px 3px 8px 0px rgba(0, 0, 0, 0.07);
border-radius: 4px;
padding: 20px 24px;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
操作列组件
<template>
<div class="operation-btns">
<template v-for="(btn, index) in preList" :key="btn.label">
<el-divider direction="vertical" v-if="index > 0" />
<XTextButton type="primary" :preIcon="btn.icon" :title="btn.label" @click="btn.onClick(row)" />
</template>
<el-divider direction="vertical" v-if="isShowMore" />
<el-dropdown placement="bottom-end" trigger="click" v-if="isShowMore">
<el-icon style="cursor: pointer" color="#999"><MoreFilled /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="license" v-for="btn in postList" :key="btn.label">
<XTextButton
:type="btn.type"
:preIcon="btn.icon"
:title="btn.label"
@click="btn.onClick(row)"
/>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
const props = defineProps({
listBtns: {
type: Array,
default: () => [
// 数据源示例
// {
// label: '编辑',
// icon: 'ep:edit',
// type: 'primary',
// onClick: () => {
// console.log(11111)
// }
// },
// {
// label: '新增',
// icon: 'ep:circle-plus',
// type: 'primary',
// onClick: () => {
// console.log(11111)
// }
// },
// {
// label: '角色授权',
// icon: 'svg-icon:roleLicensing',
// type: '',
// onClick: () => {
// console.log(11111)
// }
// },
// {
// label: '删除',
// icon: 'ep:delete',
// type: 'danger',
// onClick: () => {
// console.log(11111)
// }
// }
]
},
row: Object
})
// 是否显示更多按钮
const isShowMore = computed(() => {
return props.listBtns.length > 3
})
const preList = computed(() => {
return isShowMore.value ? props.listBtns.slice(0, 2) : props.listBtns
})
const postList = computed(() => {
return isShowMore.value ? props.listBtns.slice(2, props.listBtns.length) : []
})
</script>
<style lang="scss" scoped>
.operation-btns {
display: flex;
align-items: center;
}
</style>
控制操作列显隐组件
<template>
<div class="config" @click="handleheaderSettingClick">
<Icon icon="svg-icon:setting" :size="16" />
<ElDialog
class="header-setting-dialog-class"
v-model="dialogVisible"
width="500px"
:scroll="true"
append-to-body
top="10vh"
maxHeight="580px"
>
<template #header>
<div class="header">列表字段定制</div>
</template>
<div>
<div class="groupTitle"> 基本属性 <span>(可拖动协调顺序)</span></div>
<el-checkbox-group v-model="checkedItems" class="checkbox-group">
<div class="draggable-list" @dragover.prevent @drop="handleDrop">
<div
v-for="(item, index) in listItems"
:key="item.prop"
class="draggable-item"
:draggable="!item.disabled"
@dragstart="handleDragStart(index)"
@dragend="handleDragEnd"
>
<el-checkbox
:label="item.prop"
:disabled="item.disabled"
class="item-checkbox"
>
<span class="item-label">{{ item.label }}</span>
</el-checkbox>
</div>
</div>
</el-checkbox-group>
</div>
<template #footer>
<el-button @click="handleSave" type="primary">确定</el-button>
</template>
</ElDialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
const { wsCache } = useCache()
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
const $emit = defineEmits(['submit'])
const props = defineProps({
// 页面表格编码,需系统唯一
pageCode: {
type: String,
default: '',
},
defaultColumn: {
type: Array,
default: () => [],
required: true,
},
modelValue: {
type: Array,
default: () => [],
},
})
const dialogVisible = ref(false)
const listItems = ref([])
const checkedItems = ref([])
const draggedIndex = ref(null)
// 拖拽排序相关方法
const handleDragStart = (index) => {
draggedIndex.value = index
// 添加拖拽时的视觉效果类
setTimeout(() => {
const items = document.querySelectorAll('.draggable-item')
if (items[index]) {
items[index].classList.add('dragging')
}
}, 0)
}
const handleDragEnd = () => {
draggedIndex.value = null
// 移除拖拽时的视觉效果类
document.querySelectorAll('.draggable-item').forEach((item) => {
item.classList.remove('dragging')
})
}
const handleDrop = (e) => {
e.preventDefault()
if (draggedIndex.value === null) {
return
}
// 获取放置目标的索引
const dropTarget = e.target.closest('.draggable-item')
if (!dropTarget) {
return
}
const dropIndex = Array.from(dropTarget.parentElement.children).indexOf(
dropTarget,
)
// 避免在同一位置放置
if (draggedIndex.value !== dropIndex) {
// 复制数组并重新排序
const newItems = [...listItems.value]
const [movedItem] = newItems.splice(draggedIndex.value, 1)
newItems.splice(dropIndex, 0, movedItem)
// 更新列表
listItems.value = newItems
}
}
const handleheaderSettingClick = () => {
dialogVisible.value = true
}
const handleSave = async () => {
console.log(checkedItems.value)
listItems.value.forEach((ele) => {
ele.checked = checkedItems.value.includes(ele.prop)
})
if (props.pageCode) {
const headerSetting = wsCache.get(CACHE_KEY.TABLE_HEADER_SETTING) || {}
headerSetting[props.pageCode] = listItems.value
wsCache.set(CACHE_KEY.TABLE_HEADER_SETTING, headerSetting, { exp: 600 })
}
initColumn()
dialogVisible.value = false
}
const initColumn = () => {
if (props.pageCode) {
const headerSetting = wsCache.get(CACHE_KEY.TABLE_HEADER_SETTING) || {}
if (headerSetting[props.pageCode]?.length) {
let list = [...headerSetting[props.pageCode]]
handleData(list)
} else {
handleData(props.defaultColumn)
}
} else {
handleData(props.defaultColumn)
}
}
const handleData = (newVal) => {
listItems.value = newVal
const checkedList = listItems.value
.filter((s) => s.checked)
.map((e) => e.prop)
checkedItems.value = [...checkedList, ...props.modelValue]
console.log(checkedItems.value, 111111111111, listItems.value)
$emit('submit', listItems.value)
}
initColumn()
</script>
<style lang="scss">
.header-setting-dialog-class {
.el-dialog__body {
height: 400px;
overflow: hidden;
overflow-y: auto;
font-size: 16px;
color: #333333;
line-height: 28px;
display: flex;
padding: 30px 50px;
.groupTitle {
font-size: 14px;
color: #333333;
font-weight: 700;
margin-left: 15px;
margin-bottom: 10px;
span {
font-size: 14px;
color: #999999;
font-weight: 400;
}
}
.el-checkbox-group {
display: flex;
flex-direction: column;
.el-checkbox__label {
color: #333333;
}
.el-checkbox {
margin-left: 15px;
}
}
}
/* 仅在拖动状态添加边框 */
.draggable-item.dragging {
background: rgba(48, 126, 255, 0.07);
border: 1px dashed rgba(48, 126, 255, 0.7);
border-radius: 2px;
}
}
</style>
<style scoped>
.config {
width: 30px;
height: 30px;
background: #ffffff;
border: 1px solid rgba(217, 217, 217, 1);
border-radius: 3px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
</style>