Files
BMS/bms-front/src/components/BasicTable/index.vue
T
2026-11-10 17:24:52 +08:00

841 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="basic-table" v-text-drag-copy="props.textDragCopy">
<!-- 搜索栏 -->
<basic-form
v-if="searchColumns.length"
ref="searchFormRef"
:model-value="queryParams"
:schemas="searchColumns"
:rules="searchRules"
:label-width="searchLabelWidth"
:action-align="searchActionAlign"
:per-row="searchPerRow"
:size="size"
inline
v-show="showSearch"
>
<template v-for="name in searchSlotNames" :key="name" #[toFormSlot(name)]>
<slot
:name="name"
:model="queryParams"
:item="props.searchColumns.find((item) => item.prop === name.slice('search-'.length))"
/>
</template>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
<slot name="search-extra" />
</basic-form>
<!-- 工具栏 -->
<el-row v-if="showToolbar" :gutter="10" class="mb8">
<slot name="toolbar" :selection="selectionList" :ids="selectionIds" />
<el-col :span="1.5" v-if="!$slots.toolbar">
<el-button type="primary" plain icon="Plus" @click="$emit('add')" v-if="showAdd">新增</el-button>
</el-col>
<right-toolbar
v-model:showSearch="showSearchInternal"
:columns="toolbarColumns"
:search="searchColumns.length > 0"
@query-table="getList"
/>
</el-row>
<!-- 虚拟滚动:只渲染可视行 -->
<div
v-if="virtual"
ref="virtualHostRef"
class="basic-table-virtual"
v-loading="loading"
>
<el-table-v2
v-if="virtualWidth > 0 && tableData.length"
:columns="virtualColumns"
:data="tableData"
:width="virtualWidth"
:height="virtualHeight"
:row-height="32"
:header-height="32"
:row-key="virtualRowKey"
fixed
:row-event-handlers="virtualRowHandlers"
/>
<el-empty v-else-if="!loading" />
</div>
<!-- 表格 -->
<el-table
v-else
ref="tableRef"
v-loading="loading"
:data="tableData"
:row-key="rowKey"
:border="border"
:stripe="stripe"
:size="size"
:height="height"
:max-height="maxHeight"
:default-sort="defaultSort"
:span-method="spanMethod"
@selection-change="handleSelectionChange"
@row-click="(row, col, e) => $emit('row-click', row, col, e)"
@sort-change="(c) => $emit('sort-change', c)"
>
<!-- 多选列 -->
<el-table-column v-if="selection" type="selection" width="55" align="center" :fixed="leftFixed ? 'left' : false" />
<!-- 序号列 -->
<el-table-column v-if="showIndex" type="index" label="序号" width="55" align="center" />
<!-- 动态列 -->
<el-table-column
v-for="col in visibleColumns"
:key="col.key"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align || 'center'"
:fixed="col.fixed"
:sortable="col.sortable"
:show-overflow-tooltip="col.showOverflowTooltip !== false"
>
<template #header="scope">
<slot :name="`header-${col.prop}`" v-bind="scope">{{ col.label }}</slot>
</template>
<template #default="scope">
<slot :name="`col-${col.prop}`" v-bind="scope" :row="scope.row">
<!-- 字典列 -->
<dict-tag
v-if="col.dict"
:options="getDictOptions(col.dict)"
:value="scope.row[col.prop]"
/>
<!-- 时间格式化 -->
<span v-else-if="col.parseTime">{{ parseTime(scope.row[col.prop], col.parseTime === true ? undefined : col.parseTime) }}</span>
<!-- 自定义格式化 -->
<span v-else-if="col.formatter">{{ col.formatter(scope.row, col, scope.row[col.prop], scope.$index) }}</span>
<!-- 默认显示 -->
<span v-else>{{ scope.row[col.prop] }}</span>
</slot>
</template>
</el-table-column>
<!-- 空数据 -->
<template #empty>
<slot name="empty"><el-empty /></slot>
</template>
</el-table>
<!-- 分页;可选的分页前置内容用于展示汇总等信息。 -->
<div class="basic-table-pagination" :class="{ 'basic-table-pagination--with-extra': $slots['pagination-extra'] }">
<slot name="pagination-extra" />
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
:page-sizes="pageSizes"
@pagination="getList"
/>
</div>
</div>
</template>
<script setup>
import { ElCheckbox, ElTooltip, TableV2Alignment, TableV2FixedDir } from 'element-plus'
/**
* 公共表格组件 BasicTable
* 集成搜索栏 + 工具栏 + 表格 + 分页,基于 columns/searchColumns 配置驱动,内置复用 BasicForm、RightToolbar、Pagination、DictTag。
*
* 用法示例:
* <basic-table
* ref="tableRef"
* :columns="columns"
* :search-columns="searchColumns"
* :query="listPost"
* :text-drag-copy="false" // 可关闭表格文字拖选复制
* @add="handleAdd"
* >
* <template #toolbar="{ selection, ids }">
* <el-button type="primary" icon="Plus" @click="handleAdd">新增</el-button>
* </template>
* <template #col-status="{ row }">
* <dict-tag :options="sys_normal_disable" :value="row.status" />
* </template>
* </basic-table>
*
* column 配置:
* {
* label: '岗位名称', // 列标题
* prop: 'postName', // 字段名
* width: 120, // 列宽
* minWidth: 120,
* align: 'center', // 对齐(默认 center)
* fixed: 'left'|'right',
* sortable: true,
* showOverflowTooltip: true, // 默认 true
* dict: 'sys_normal_disable', // 用字典标签渲染
* parseTime: true | 'YYYY-MM-DD', // 时间格式化(true 用默认格式)
* formatter: (row,col,val,index)=> ..., // 自定义格式化
* show: true, // 是否显示(参与显隐列)
* showInToolbar: true, // 是否在显隐列工具栏中显示(默认 true)
* visible: true // 初始是否可见(显隐列控制)
* }
*
* searchColumns 配置:同 BasicForm 的 schemas。
*/
defineOptions({ name: 'BasicTable' })
const props = defineProps({
// 列配置数组
columns: {
type: Array,
default: () => []
},
// 搜索表单配置(同 BasicForm schemas)
searchColumns: {
type: Array,
default: () => []
},
// 搜索表单校验规则
searchRules: {
type: Object,
default: () => ({})
},
// 搜索表单标签宽度
searchLabelWidth: {
type: [String, Number],
default: 'auto'
},
// 搜索按钮区对齐:left / center / right
searchActionAlign: {
type: String,
default: 'left'
},
// 搜索区固定每行项数(>0 时启用栅格布局,多余项自动换行;0 为流式换行)
searchPerRow: {
type: Number,
default: 0
},
// 列表加载函数:(params) => Promise<{rows, total}> | {rows, total}
query: {
type: Function,
required: true
},
// 额外固定查询参数(每次请求合并传入,不参与搜索表单重置)
extraParams: {
type: Object,
default: () => ({})
},
// 初始查询参数(搜索字段的默认值)
defaultParams: {
type: Object,
default: () => ({})
},
// 是否显示多选列
selection: {
type: Boolean,
default: true
},
// 最多勾选条数,0 表示不限制
selectionMax: {
type: Number,
default: 0
},
// 是否显示序号列
showIndex: {
type: Boolean,
default: false
},
// 是否显示工具栏
showToolbar: {
type: Boolean,
default: true
},
// 是否支持在同一列中拖选文字并复制(特殊交互表格可关闭)
textDragCopy: {
type: Boolean,
default: true
},
// 是否显示默认新增按钮(无 toolbar 插槽时生效)
showAdd: {
type: Boolean,
default: false
},
// 是否显示搜索栏(可双向控制)
showSearch: {
type: Boolean,
default: true
},
// 行 key
rowKey: {
type: [String, Function],
default: undefined
},
// 边框
border: {
type: Boolean,
default: false
},
// 斑马纹
stripe: {
type: Boolean,
default: false
},
// 表格/表单尺寸(large/default/small)
size: {
type: String,
default: undefined
},
// 表格高度
height: { type: [String, Number], default: undefined },
maxHeight: { type: [String, Number], default: undefined },
// 默认排序(透传 el-table default-sort,如 { prop: 'createTime', order: 'descending' })
defaultSort: {
type: Object,
default: undefined
},
// 单元格合并方法(透传 el-table span-method),签名同 el-table:({ row, column, rowIndex, columnIndex }) => { rowspan, colspan }
spanMethod: {
type: Function,
default: null
},
// 每页条数可选项
pageSizes: {
type: Array,
default: () => [10, 20, 30, 50]
},
// 默认每页条数
pageSize: {
type: Number,
default: 10
},
// 是否在挂载后自动加载
autoLoad: {
type: Boolean,
default: true
},
// Render only visible rows. Needs a bounded height; falls back to the viewport.
virtual: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['add', 'update:showSearch', 'row-click', 'sort-change', 'selection-change'])
const { proxy } = getCurrentInstance()
const tableRef = ref()
const searchFormRef = ref()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const selectionList = ref([])
const showSearchInternal = ref(props.showSearch)
// 查询参数:搜索字段 + 分页
const queryParams = reactive({
pageNum: 1,
pageSize: props.pageSize,
...props.defaultParams
})
watch(() => props.showSearch, (v) => (showSearchInternal.value = v))
watch(showSearchInternal, (v) => emit('update:showSearch', v))
// ====== 列显隐状态 ======
// 内部维护列状态(带 key/visible),供 RightToolbar 控制显隐
const tableColumns = ref([])
function initColumns() {
tableColumns.value = props.columns.map((col, index) => ({
...col,
key: col.key || col.prop || index,
visible: col.visible !== false && col.show !== false
}))
}
initColumns()
watch(
() => props.columns,
() => initColumns(),
{ deep: false }
)
// 实际渲染的列(visible !== false)
const visibleColumns = computed(() => tableColumns.value.filter((c) => c.visible !== false))
const leftFixed = computed(() => visibleColumns.value.some((c) => c.fixed === 'left' || c.fixed === true))
// 参与显隐工具栏的列(有 label 且未禁用工具栏显示)
const toolbarColumns = computed(() =>
tableColumns.value.filter((c) => c.label && c.showInToolbar !== false)
)
// ====== 字典加载(用于表格列 dict 渲染) ======
const dictMap = reactive({})
const dictTypes = computed(() => {
const set = new Set()
props.columns.forEach((c) => c.dict && set.add(c.dict))
props.searchColumns.forEach((c) => c.dict && set.add(c.dict))
return [...set]
})
watch(
dictTypes,
(types) => {
if (!types.length) return
const dictRefs = proxy.useDict(...types)
types.forEach((t) => {
const d = dictRefs[t]
dictMap[t] = isRef(d) ? d : ref(d)
})
},
{ immediate: true }
)
function getDictOptions(type) {
const d = dictMap[type]
return isRef(d) ? d.value : d || []
}
// ====== 搜索栏插槽透传:search-xxx -> form-xxx ======
const slots = useSlots()
const searchSlotNames = computed(() =>
Object.keys(slots).filter((n) => n.startsWith('search-') && n !== 'search-extra')
)
function toFormSlot(name) {
return 'form-' + name.slice('search-'.length)
}
// ====== 数据加载 ======
async function getList() {
loading.value = true
try {
const params = { ...queryParams, ...props.extraParams }
const res = await props.query(params)
tableData.value = res?.rows || []
total.value = res?.total || 0
if (props.virtual) selectionList.value = []
} catch (e) {
console.error('BasicTable 加载失败', e)
// 查询失败时清空旧结果,避免用户误以为旧列表是本次查询结果。
tableData.value = []
total.value = 0
selectionList.value = []
} finally {
loading.value = false
}
}
// 搜索
function handleQuery() {
queryParams.pageNum = 1
getList()
}
// 重置搜索
function resetQuery() {
searchFormRef.value?.resetFields()
Object.keys(props.defaultParams || {}).forEach((k) => {
queryParams[k] = props.defaultParams[k]
})
queryParams.pageNum = 1
getList()
}
// 选中变化。超过上限时只保留先勾到的行,并同步虚拟表和普通表的勾选状态。
let cappingSelection = false
function handleSelectionChange(val) {
if (cappingSelection) return
const rows = Array.isArray(val) ? val.slice() : []
const max = Number(props.selectionMax) || 0
if (max > 0 && rows.length > max) {
const kept = rows.slice(0, max)
cappingSelection = true
selectionList.value = kept
emit('selection-change', kept)
proxy.$modal.msgWarning(`单次最多勾选 ${max} 条`)
nextTick(() => {
const table = tableRef.value
if (table?.toggleRowSelection) {
const keys = new Set(kept.map((row) => virtualRowId(row)))
tableData.value.forEach((row) => table.toggleRowSelection(row, keys.has(virtualRowId(row))))
}
cappingSelection = false
})
return
}
selectionList.value = rows
emit('selection-change', rows)
}
const selectionIds = computed(() =>
selectionList.value.map((r) => (props.rowKey ? r[props.rowKey] : r.id)).filter((v) => v !== undefined)
)
// ====== 暴露方法 ======
function refresh() {
getList()
}
function reload() {
queryParams.pageNum = 1
getList()
}
function getSelection() {
return selectionList.value
}
function getSelectionIds() {
return selectionIds.value
}
function clearSelection() {
if (props.virtual) {
handleSelectionChange([])
return
}
tableRef.value?.clearSelection()
}
function toggleRowSelection(row, selected) {
if (props.virtual) {
toggleVirtualRow(row, selected !== false)
return
}
tableRef.value?.toggleRowSelection(row, selected)
}
function getTableRef() {
return tableRef.value
}
function getSearchFormRef() {
return searchFormRef.value
}
function getQueryParams() {
return { ...queryParams, ...props.extraParams }
}
function setQueryParams(p) {
Object.assign(queryParams, p)
}
defineExpose({
refresh,
reload,
getList,
handleQuery,
resetQuery,
getSelection,
getSelectionIds,
clearSelection,
toggleRowSelection,
getTableRef,
getSearchFormRef,
getQueryParams,
setQueryParams,
getData: () => tableData.value,
/** 当前页数据(响应式,父组件 span-method 可直接订阅) */
data: tableData
})
const virtualHostRef = ref()
const virtualWidth = ref(0)
const virtualHeight = ref(480)
let virtualResizeObserver = null
function measureVirtual() {
const host = virtualHostRef.value
if (!host) return
virtualWidth.value = Math.max(0, Math.floor(host.clientWidth))
const passed = Number(props.height)
if (Number.isFinite(passed) && passed > 0) {
virtualHeight.value = passed
return
}
const top = host.getBoundingClientRect().top
virtualHeight.value = Math.max(240, Math.floor(window.innerHeight - top - 70))
}
function virtualRowId(row) {
if (!row) return undefined
if (typeof props.rowKey === 'function') return props.rowKey(row)
if (props.rowKey) return row[props.rowKey]
return row.id
}
const virtualRowKey = computed(() => (typeof props.rowKey === 'string' && props.rowKey) ? props.rowKey : 'id')
function isRowSelected(row) {
const key = virtualRowId(row)
return selectionList.value.some((item) => virtualRowId(item) === key)
}
function allRowsSelected() {
return tableData.value.length > 0 && tableData.value.every((row) => isRowSelected(row))
}
function someRowsSelected() {
return selectionList.value.length > 0 && !allRowsSelected()
}
function toggleVirtualRow(row, checked) {
const key = virtualRowId(row)
const next = selectionList.value.filter((item) => virtualRowId(item) !== key)
if (checked) next.push(row)
handleSelectionChange(next)
}
function toggleVirtualAll(checked) {
handleSelectionChange(checked ? tableData.value.slice() : [])
}
function renderVirtualCheckbox(checked, indeterminate, onChange) {
return h(ElCheckbox, {
modelValue: checked,
indeterminate,
'onUpdate:modelValue': onChange,
onClick: (event) => event.stopPropagation()
})
}
function columnWidth(col) {
const width = Number(col.width || col.minWidth)
return Number.isFinite(width) && width > 0 ? width : 140
}
function columnAlign() {
return TableV2Alignment.LEFT
}
function columnFixed(col) {
if (col.fixed === 'left' || col.fixed === true) return TableV2FixedDir.LEFT
if (col.fixed === 'right') return TableV2FixedDir.RIGHT
return undefined
}
function renderVirtualText(col, row, rowIndex) {
let text = row ? row[col.prop] : ''
if (col.parseTime) {
text = proxy.parseTime(text, col.parseTime === true ? undefined : col.parseTime)
} else if (col.formatter) {
text = col.formatter(row, col, text, rowIndex)
}
if (text == null || text === '') return ''
return String(text)
}
/** Flatten slot VNode text for virtual-table overflow tooltip. */
function extractSlotText(nodes) {
if (nodes == null || nodes === false) return ''
if (typeof nodes === 'string' || typeof nodes === 'number') return String(nodes)
if (Array.isArray(nodes)) return nodes.map(extractSlotText).join('')
if (typeof nodes === 'object') {
if (typeof nodes.children === 'string' || typeof nodes.children === 'number') {
return String(nodes.children)
}
if (Array.isArray(nodes.children)) return extractSlotText(nodes.children)
}
return ''
}
/** True when slot tree contains a component (button/tag/...), skip auto tooltip. */
function slotHasComponent(nodes) {
if (nodes == null || nodes === false) return false
if (Array.isArray(nodes)) return nodes.some(slotHasComponent)
if (typeof nodes !== 'object') return false
if (typeof nodes.type === 'object' || typeof nodes.type === 'function') return true
if (Array.isArray(nodes.children)) return slotHasComponent(nodes.children)
return false
}
function resolveVirtualTooltip(col, rowData, rowIndex, slotNodes) {
if (col.showOverflowTooltip === false) return ''
if (typeof col.tooltipFormatter === 'function') {
const tip = col.tooltipFormatter(rowData, col, rowData ? rowData[col.prop] : undefined, rowIndex)
return tip == null || tip === '' ? '' : String(tip)
}
if (slotNodes != null) {
if (slotHasComponent(slotNodes)) return ''
return extractSlotText(slotNodes).trim()
}
return renderVirtualText(col, rowData, rowIndex)
}
function wrapVirtualTooltip(tip, cell) {
if (!tip) return cell
return h(ElTooltip, {
content: tip,
placement: 'top',
showAfter: 300,
enterable: false
}, {
default: () => cell
})
}
const virtualColumns = computed(() => {
const cols = []
if (props.selection) {
cols.push({
key: '__selection',
dataKey: '__selection',
width: 55,
align: TableV2Alignment.CENTER,
fixed: leftFixed.value ? TableV2FixedDir.LEFT : undefined,
headerCellRenderer: () => renderVirtualCheckbox(allRowsSelected(), someRowsSelected(), toggleVirtualAll),
cellRenderer: ({ rowData }) => renderVirtualCheckbox(isRowSelected(rowData), false, (checked) => toggleVirtualRow(rowData, checked))
})
}
if (props.showIndex) {
cols.push({
key: '__index',
dataKey: '__index',
title: '#',
width: 55,
align: TableV2Alignment.CENTER,
cellRenderer: ({ rowIndex }) => String((queryParams.pageNum - 1) * queryParams.pageSize + rowIndex + 1)
})
}
visibleColumns.value.forEach((col) => {
const slotFn = slots[`col-${col.prop}`]
const headerSlot = slots[`header-${col.prop}`]
const column = {
key: String(col.key || col.prop),
dataKey: col.prop,
title: col.label,
width: columnWidth(col),
align: columnAlign(col),
fixed: columnFixed(col),
cellRenderer: ({ rowData, rowIndex }) => {
if (col.dict) {
return h(resolveComponent('DictTag'), {
options: getDictOptions(col.dict),
value: rowData[col.prop]
})
}
if (slotFn) {
const nodes = slotFn({ row: rowData, $index: rowIndex })
const cell = h('div', { class: 'basic-table-vcell' }, nodes)
return wrapVirtualTooltip(resolveVirtualTooltip(col, rowData, rowIndex, nodes), cell)
}
const text = renderVirtualText(col, rowData, rowIndex)
const cell = h('div', { class: 'basic-table-vcell' }, text || '')
return wrapVirtualTooltip(resolveVirtualTooltip(col, rowData, rowIndex), cell)
}
}
if (headerSlot) column.headerCellRenderer = () => headerSlot({ column: col })
cols.push(column)
})
return cols
})
const virtualRowHandlers = {
onClick({ rowData, event }) {
emit('row-click', rowData, null, event)
}
}
watch(() => props.height, () => nextTick(measureVirtual))
if (props.autoLoad) {
onMounted(getList)
}
onMounted(() => {
if (!props.virtual) return
nextTick(() => {
measureVirtual()
if (virtualHostRef.value && typeof ResizeObserver !== 'undefined') {
virtualResizeObserver = new ResizeObserver(() => measureVirtual())
virtualResizeObserver.observe(virtualHostRef.value)
}
window.addEventListener('resize', measureVirtual)
})
})
onBeforeUnmount(() => {
if (virtualResizeObserver) virtualResizeObserver.disconnect()
window.removeEventListener('resize', measureVirtual)
})
</script>
<style scoped>
.basic-table {
width: 100%;
}
.basic-table-pagination--with-extra {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.basic-table-pagination--with-extra :deep(.pagination-container) {
margin-left: auto;
}
.basic-table-virtual {
width: 100%;
min-height: 240px;
border: 1px solid #ebeef5;
background: #fff;
}
.basic-table-virtual :deep(.el-table-v2) {
--el-table-header-bg-color: #f8f8f9;
--el-table-header-text-color: #515a6e;
--el-table-border-color: #ebeef5;
--el-table-row-hover-bg-color: #f5f7fa;
--el-table-text-color: #606266;
font-size: 13px;
color: #606266;
}
.basic-table-virtual :deep(.el-table-v2__header-row) {
background: #f8f8f9;
border-bottom: 1px solid #ebeef5;
}
.basic-table-virtual :deep(.el-table-v2__header-cell) {
background: #f8f8f9;
color: #515a6e;
font-size: 13px;
font-weight: 600;
border-right: 1px solid #ebeef5;
overflow: hidden;
white-space: nowrap;
justify-content: flex-start !important;
text-align: left;
}
.basic-table-virtual :deep(.el-table-v2__header-cell-text) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.basic-table-virtual :deep(.el-table-v2__header-cell:last-child),
.basic-table-virtual :deep(.el-table-v2__row-cell:last-child) {
border-right: none;
}
.basic-table-virtual :deep(.el-table-v2__row) {
border-bottom: 1px solid #ebeef5;
}
.basic-table-virtual :deep(.el-table-v2__row:hover) {
background: #f5f7fa;
}
.basic-table-virtual :deep(.el-table-v2__row-cell) {
color: #606266;
font-size: 13px;
border-right: 1px solid #ebeef5;
padding: 0 8px;
overflow: hidden;
white-space: nowrap;
justify-content: flex-start;
text-align: left;
min-width: 0;
}
.basic-table-virtual :deep(.el-table-v2__row-cell .el-tooltip__trigger) {
display: block;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.basic-table-vcell {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
min-width: 0;
line-height: 20px;
}
</style>