ProTableV2 虚拟滚动表格
基于 element-plus 的 el-table-v2(虚拟滚动表格)二次封装,内置配置化列、单选/多选、本地/远程排序、树形数据展开、列宽拖拽、单元格省略 tooltip、高度自适应等能力。适合十万行级大数据量 或树形结构的高性能表格场景。
组件名:
ProTableV2底层:
el-table-v2+el-pagination适用场景:超大数据量、树形展开、对滚动性能敏感的列表。
1. 最小示例
vue
<template>
<ProTableV2
:data="list"
:columns="columns"
:total="total"
v-model:page="page"
v-model:size="size"
@pagination-change="loadList"
@selection-change="onSelect"
>
<template #op="{ row }">
<el-button link type="primary" @click="edit(row)">编辑</el-button>
</template>
</ProTableV2>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import ProTableV2 from './ProTableV2.vue'
const list = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const columns = [
{ prop: 'code', label: '编号', width: 180 },
{ prop: 'name', label: '名称', minWidth: 160 },
{ prop: 'price', label: '单价', width: 120, align: 'right', sortable: true },
{ prop: 'op', label: '操作', width: 140, slot: 'op', showOverflowTooltip: false },
]
const loadList = async () => { /* 接口 */ }
onMounted(loadList)
</script>
2. Props
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
data |
Array |
[] |
表格数据;行内有非空 children 字段时自动启用树形 |
columns |
Array |
[] |
列配置,见下表 |
rowKey |
String |
'id' |
行主键 |
border |
Boolean |
false |
外边框 + 单元格分隔线;开启后才支持列宽拖拽 |
stripe |
Boolean |
false |
斑马纹 |
autoFillLastColumn |
Boolean |
true |
所有列固定宽时,剩余宽度自动补到 minWidth 列或最后一列 |
height |
Number / String |
'adaptive' |
'adaptive' 撑满视口剩余空间;'auto' 随行数撑开(上限视口剩余);数字/'600px' 固定高度。不支持百分比,会回退 adaptive 并警告 |
bottomOffset |
Number |
5 |
adaptive/auto 模式底部预留(px) |
minHeight |
Number |
200 |
最小高度(px) |
rowHeight |
Number |
44 |
行高(px),虚拟滚动按此估算 |
headerHeight |
Number |
44 |
表头高度(px) |
cache |
Number |
5 |
虚拟滚动缓冲区(可视区外上下多渲染行数) |
rowClass |
String / Function |
--- | 行类名,({ columns, rowData, rowIndex }) => string |
selectionType |
'radio' / 'checkbox' / 'none' |
'none' |
选择列类型;radio 点击行选中 |
selectionColumnWidth |
Number |
48 |
选择列宽度 |
defaultSelectedKeys |
Array |
[] |
默认选中主键数组,仅首次数据到达应用一次(树形含子行) |
disabledRow |
(row) => Boolean |
() => false |
禁用行 |
sortBy |
`{ key, order: 'asc' | 'desc' }` | null |
localSort |
Boolean |
true |
true 点表头本地排序;false 只 emit sort-change 由远程排序 |
expandColumnKey |
String |
null |
树形展开图标所在列的 key;不传自动取第一个数据列 |
expandedRowKeys |
Array |
null |
受控展开行 key 数组,配合 v-model:expanded-row-keys;不传组件内部维护 |
defaultExpandAll |
Boolean |
false |
数据到达时自动展开全部层级(每次数据刷新后重新应用) |
childrenKey |
String |
'children' |
树形子级字段名;非 children 时组件内部递归归一化为 children |
rowEventHandlers |
Object |
null |
v2 原生行事件 { onClick, onDblclick, onContextmenu, onMouseenter, onMouseleave } |
page |
Number |
1 |
当前页码,v-model:page |
size |
Number |
20 |
每页条数,v-model:size |
total |
Number |
0 |
总条数 |
showPagination |
Boolean |
true |
是否显示分页 |
paginationOptions |
Object |
{} |
透传 el-pagination |
columns 列配置项
| 字段 | 类型 | 说明 |
|---|---|---|
prop |
String |
字段名,支持点路径如 'sample.price' |
label / title |
String |
表头标题 |
key / dataKey |
String |
列唯一 key;不传自动用 prop 或列序号 |
width / minWidth / maxWidth |
Number / String |
列宽;写 '120px' 也会自动转数字 |
align |
'left' / 'center' / 'right' |
对齐,默认 left |
fixed |
Boolean / 'left' / 'right' |
固定列 |
sortable |
Boolean |
列是否可排序 |
formatter |
(rowData, column, cellValue, rowIndex) => any |
单元格格式化 |
slot |
String |
自定义插槽名 |
showOverflowTooltip |
Boolean |
默认 true,文本被截断时 hover 弹单例 tooltip(可选中复制) |
class / headerClass |
String |
自定义单元格/表头类名 |
hidden |
Boolean |
隐藏列 |
flexGrow / flexShrink |
Number |
v2 原生 flex 属性 |
headerCellRenderer |
Function |
自定义表头渲染函数(高级) |
cellRenderer |
Function |
自定义单元格渲染函数(v2 原生 render,优先级高于 slot/formatter) |
3. 事件
| 事件名 | 回调参数 | 说明 |
|---|---|---|
update:page |
(page) |
v-model:page |
update:size |
(size) |
v-model:size |
update:sortBy |
({ key, order }) |
v-model:sort-by |
update:expandedRowKeys |
(keys: []) |
v-model:expanded-row-keys |
pagination-change |
({ page, size, type }) |
翻页统一回调 |
selection-change |
(rows: Row[]) |
选中变化 |
current-change |
(row, null) |
radio 选中时触发 |
sort-change |
({ key, prop, order, column }) |
点击表头排序 |
column-resize |
({ key, prop, width, column }) |
列宽拖拽结束 |
row-click |
(rowData, { rowIndex }, event) |
行单击 |
row-dblclick |
(rowData, { rowIndex }, event) |
行双击 |
row-contextmenu |
(rowData, { rowIndex }, event) |
行右键 |
4. 插槽
| 插槽名 | 作用域 | 说明 |
|---|---|---|
| 列具名插槽 | { row, rowIndex, $index, column, cellData, value } |
列配置里 slot: 'xxx' |
footer |
v2 原生 footer 作用域 | 表尾 |
empty |
v2 原生 empty 作用域 | 空数据 |
overlay |
v2 原生 overlay 作用域 | 自定义遮罩(loading 等) |
5. 通过 ref 调用的方法
ts
const tableRef = ref()
// <ProTableV2 ref="tableRef" />
| 方法 | 签名 | 说明 |
|---|---|---|
tableRef |
--- | 原生 el-table-v2 实例,可调用 scrollTo / scrollToLeft / scrollToTop / scrollToRow |
getSelectionRows() |
() => Row[] |
当前多选选中行(跨页仍按 rowKey 缓存完整行对象) |
getRadioSelectedRow() |
`() => Row | null` |
clearSelection() |
() => void |
清空多选 |
toggleRowSelection(row, selected?) |
(row, selected?) => void |
程序化勾选 |
clearRadio() |
() => void |
清空单选 |
resetColumnWidth(key?) |
(key?) => void |
重置列宽;不传 key 重置全部 |
expandAll() |
() => void |
树形全部展开 |
collapseAll() |
() => void |
树形全部收起 |
getExpandedRowKeys() |
() => [] |
当前展开的行 key 数组 |
recalculate() |
() => void |
手动重算高度/宽度 |
6. 常用场景
6.1 树形数据
只要 data 里任意行带 children 字段就自动启用树形,无需额外配置:
js
const list = ref([
{ id: 1, name: '父1', children: [
{ id: 11, name: '子1-1' },
{ id: 12, name: '子1-2' },
]},
])
子级字段不叫 children 时:
vue
<ProTableV2 :data="list" :columns="columns" children-key="subList" />
默认全部展开:
vue
<ProTableV2 :data="list" :columns="columns" default-expand-all />
6.2 远程排序
vue
<ProTableV2
:data="list"
:columns="columns"
:local-sort="false"
v-model:sort-by="sortBy"
@sort-change="onSortChange"
/>
6.3 受控展开
vue
<ProTableV2
:data="list"
:columns="columns"
v-model:expanded-row-keys="expandedKeys"
/>
6.4 行事件
vue
<ProTableV2
:data="list"
:columns="columns"
@row-click="onRowClick"
@row-dblclick="onRowDblclick"
@row-contextmenu="onRowRight"
/>
7. 注意事项
- 必须给行固定高度 :虚拟滚动依赖
rowHeight估算位置,行内容不要换行;若需多行,请调大rowHeight。 height不支持百分比('60%'会回退'adaptive'并在控制台警告)。- 列宽拖拽手柄仅在
border=true时注入;拖拽中双击手柄可恢复该列配置宽度。 - 树形数据下本地排序只对根层级生效,子级保持原顺序;需要逐级排序请在数据侧排好或改用远程排序。
- 单元格里使用具名插槽时,作用域里同时提供
cellData(经过 formatter 的值)和value(原始字段值)。 - 单例 tooltip 是为了解决 v2 虚拟滚动里多 tooltip 残留问题;它是非受控的,hover 截断文本即弹出,移入弹层可选中复制。
8. 组件源码
javascript
<template>
<div ref="rootRef" class="pro-table-v2" :class="rootClass" :style="attrs.style">
<el-table-v2 ref="tableRef" class="pro-table-v2__table" v-bind="tableAttrs" :columns="tableColumns"
:data="tableData" :row-key="rowKey" :width="tableWidth" :height="tableHeight" :row-height="rowHeight"
:header-height="headerHeight" :row-class="finalRowClass" :row-event-handlers="finalRowEventHandlers"
:sort-by="currentSortBy" :cache="cache" fixed :expand-column-key="finalExpandColumnKey"
:expanded-row-keys="finalExpandedRowKeys" @column-sort="handleColumnSort"
@expanded-rows-change="handleExpandedRowsChange" @scroll="hideCellTooltip">
<template v-if="$slots.footer" #footer="scope">
<slot name="footer" v-bind="scope" />
</template>
<template v-if="$slots.empty" #empty="scope">
<slot name="empty" v-bind="scope" />
</template>
<template v-if="$slots.overlay" #overlay="scope">
<slot name="overlay" v-bind="scope" />
</template>
</el-table-v2>
<!-- 单元格截断提示:非受控单例,显隐走 el-tooltip 原生 onOpen/onClose,
移入弹层保持显示(enterable)等全部为默认行为 -->
<el-tooltip ref="tooltipRef" :content="cellTooltipContent" :virtual-ref="cellTooltipEl" virtual-triggering />
<el-pagination v-if="showPagination" ref="paginationRef" class="pro-table-v2__pagination" v-bind="paginationProps"
:current-page="page" :page-size="size" :total="total" @current-change="handlePageChange"
@size-change="handleSizeChange" />
</div>
</template>
<script setup>
import { computed, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, useAttrs, useSlots, watch } from 'vue'
import { ElCheckbox, ElRadio } from 'element-plus'
defineOptions({ name: 'ProTableV2', inheritAttrs: false })
const props = defineProps({
// 表格数据源
data: { type: Array, default: () => [] },
// 表格列配置 label/prop/width/minWidth/fixed/sortable/align/formatter/slot)
columns: { type: Array, default: () => [] },
// 行数据的主键字段(单选、多选、默认选中都依赖它)
rowKey: { type: String, default: 'id' },
// 是否显示边框(外边框 + 单元格分隔线)
border: { type: Boolean, default: false },
// 斑马纹
stripe: { type: Boolean, default: false },
// 所有列都固定宽时,最后一列是否自动伸展占满剩余宽度
autoFillLastColumn: { type: Boolean, default: true },
// 表格高度:
// - 'adaptive'(默认):撑满「组件顶部 → 视口底部」剩余空间,表体内部滚动
// - 'auto':随数据行数撑开(行数 × rowHeight + headerHeight),不超过视口剩余空间
// - 数字 / '600' / '600px':固定高度;'60%' 等百分比不支持,会回退到 adaptive 并警告
height: { type: [Number, String], default: 'adaptive' },
// adaptive/auto 模式下的额外底部预留(px)
bottomOffset: { type: Number, default: 5 },
// 表格最小高度(px)
minHeight: { type: Number, default: 200 },
// 行高(px),虚拟滚动按此估算
rowHeight: { type: Number, default: 44 },
// 表头高度(px)
headerHeight: { type: Number, default: 44 },
// 虚拟滚动缓冲区(可视区外上下各多渲染的行数)
cache: { type: Number, default: 5 },
// 行类名:字符串或 ({ columns, rowData, rowIndex }) => string,与内置选中/斑马纹类名合并
rowClass: { type: [String, Function], default: undefined },
// 选择类型:'radio'(单选)/ 'checkbox'(多选)/ 'none'(默认无选择列)
selectionType: {
type: String,
default: 'none',
validator: (value) => ['radio', 'checkbox', 'none'].includes(value),
},
// 选择列宽度
selectionColumnWidth: { type: Number, default: 48 },
// 默认选中的行主键数组(radio 取第一个,checkbox 全部生效,仅首次数据到达应用一次)
defaultSelectedKeys: { type: Array, default: () => [] },
// 行禁用判断函数 (row) => boolean,禁用行不可勾选/单选
disabledRow: { type: Function, default: () => false },
// 排序:{ key, order },order 为 'asc' | 'desc';传入了则受控(配合 update:sortBy)
sortBy: { type: Object, default: null },
// 是否本地排序:true 点击表头自动对当前 data 排序;false 只回调 sort-change 由远程排序
localSort: { type: Boolean, default: true },
// ================= 树形数据 =================
// 数据行含非空 children 时自动启用树形;缩进/展开图标由 v2 内置注入,无需自定义单元格渲染器
// 展开图标所在列的 key;不传自动取第一个数据列(必须对应某一列的 key,否则不显示展开图标)
expandColumnKey: { type: String, default: null },
// 展开的行 key 数组(v-model:expanded-row-keys 受控;不传由组件内部维护)
expandedRowKeys: { type: Array, default: null },
// 数据到达时自动展开全部层级(每次数据刷新/翻页后重新应用)
defaultExpandAll: { type: Boolean, default: false },
// 树形子级的字段名:数据里子级不在 children 时传入(如 'experimentalDataIdList');
// v2 拍平硬编码读取 row.children,组件内部会把该字段递归归一化为 children 再交给 v2
childrenKey: { type: String, default: 'children' },
// v2 原生行事件钩子:{ onClick, onDblclick, onContextmenu, onMouseenter, onMouseleave }
// onClick/onDblclick/onContextmenu 会与 row-click/row-dblclick/row-contextmenu 事件同时触发
rowEventHandlers: { type: Object, default: null },
// 当前页码(支持 v-model:currentPage)
page: { type: Number, default: 1 },
// 当前每页条数(支持 v-model:pageSize)
size: { type: Number, default: 20 },
// 数据总条数
total: { type: Number, default: 0 },
// 是否显示分页组件
showPagination: { type: Boolean, default: true },
// 分页组件额外配置项:layout / pageSizes / background / pager-count 等原生属性透传
paginationOptions: { type: Object, default: () => ({}) },
})
const emit = defineEmits([
'update:page',
'update:size',
'update:sortBy',
'update:expandedRowKeys',
'pagination-change',
'selection-change',
'current-change',
'sort-change',
'column-resize',
'row-click',
'row-dblclick',
'row-contextmenu',
])
const attrs = useAttrs()
const slots = useSlots()
// ================= 属性透传过滤 =================
// 已声明为 prop 的不会进 attrs;这里再剔除需要接管/改写的 v2 属性,其余全部透传给 el-table-v2
// (estimatedRowHeight / defaultExpandedRowKeys / indentSize / iconSize / onRowExpand / onScroll 等
// 未接管的属性可直接透传;expandColumnKey / expandedRowKeys / onExpandedRowsChange 已由组件接管)
const BLOCK_ATTRS = new Set([
'class', 'style',
'width', 'height', 'rowHeight', 'row-height', 'headerHeight', 'header-height',
'rowClass', 'row-class', 'rowEventHandlers', 'row-event-handlers',
'sortBy', 'sort-by', 'onColumnSort', 'fixed',
'expandColumnKey', 'expand-column-key',
'expandedRowKeys', 'expanded-row-keys',
'onExpandedRowsChange', 'onExpanded-rows-change',
'onUpdate:expandedRowKeys', 'onUpdate:expanded-row-keys',
])
const tableAttrs = computed(() => {
const out = {}
Object.keys(attrs).forEach((key) => {
if (!BLOCK_ATTRS.has(key)) out[key] = attrs[key]
})
return out
})
const rootClass = computed(() => [attrs.class, { 'pro-table-v2--border': props.border }])
// ================= 递归遍历工具 =================
// 树形数据时子行也参与缓存/默认选中/全选;平铺数据等价于顶层遍历
const eachRow = (rows, fn) => (rows ?? []).forEach((row) => { fn(row); eachRow(row.children, fn) })
const flattenAllRows = (rows, out = []) => {
; (rows ?? []).forEach((row) => { out.push(row); flattenAllRows(row.children, out) })
return out
}
// ================= 数据归一化 =================
// childrenKey ≠ 'children' 时把自定义子级字段递归映射为 children(不改原数据,生成浅拷贝);
// v2 的拍平逻辑(use-data.ts)硬编码读取 row.children,因此内部遍历与传给 v2 的数据统一走 sourceData
const sourceData = computed(() => {
if (props.childrenKey === 'children') return props.data
const map = (rows) =>
(rows ?? []).map((row) => {
const list = row[props.childrenKey]
return { ...row, children: Array.isArray(list) && list.length > 0 ? map(list) : undefined }
})
return map(props.data)
})
// ================= 行选择状态 =================
const tableRef = ref(null)
const selectedRows = ref([])
const radioSelectedKey = ref(null)
const selectedKeys = ref(new Set())
const rowCache = new Map()
let defaultsApplied = false
const isRowDisabled = (row) => props.disabledRow?.(row) === true
// 数据到达时缓存行对象(跨页勾选后 getSelectionRows 仍能返回完整行,与 el-table reserve-selection 行为一致;
// 树形数据时递归缓存子级行)
watch(
sourceData,
(rows) => eachRow(rows, (row) => rowCache.set(row[props.rowKey], row)),
{ immediate: true }
)
const refreshSelectedRows = () => {
selectedRows.value = [...selectedKeys.value].map((key) => rowCache.get(key)).filter((row) => row !== undefined)
}
const handleCheckRow = (row, checked) => {
const key = row[props.rowKey]
const next = new Set(selectedKeys.value)
checked ? next.add(key) : next.delete(key)
selectedKeys.value = next
rowCache.set(key, row)
refreshSelectedRows()
emit('selection-change', selectedRows.value)
}
const toggleSelectAll = (selectableRows) => {
const allSelected = selectableRows.length > 0 && selectableRows.every((row) => selectedKeys.value.has(row[props.rowKey]))
const next = new Set(selectedKeys.value)
selectableRows.forEach((row) => {
const key = row[props.rowKey]
allSelected ? next.delete(key) : next.add(key)
rowCache.set(key, row)
})
selectedKeys.value = next
refreshSelectedRows()
emit('selection-change', selectedRows.value)
}
const handleRadioSelect = (row) => {
if (isRowDisabled(row)) return
const key = row[props.rowKey]
if (radioSelectedKey.value === key) return
radioSelectedKey.value = key
emit('selection-change', [row])
emit('current-change', row, null)
}
// 默认选中:数据首次到达后应用一次(radio 取第一个 key,checkbox 全部应用;树形数据含子级行)
const applyDefaultSelection = () => {
if (defaultsApplied || !sourceData.value.length || !props.defaultSelectedKeys.length) return
defaultsApplied = true
const keys = props.defaultSelectedKeys
if (props.selectionType === 'radio') {
const row = flattenAllRows(sourceData.value).find((item) => item[props.rowKey] === keys[0] && !isRowDisabled(item))
if (row) {
radioSelectedKey.value = row[props.rowKey]
emit('selection-change', [row])
emit('current-change', row, null)
}
} else if (props.selectionType === 'checkbox') {
const next = new Set(selectedKeys.value)
flattenAllRows(sourceData.value).forEach((row) => {
if (keys.includes(row[props.rowKey]) && !isRowDisabled(row)) next.add(row[props.rowKey])
})
selectedKeys.value = next
refreshSelectedRows()
emit('selection-change', selectedRows.value)
}
}
watch(() => [sourceData.value, props.selectionType], () => nextTick(applyDefaultSelection), { immediate: true })
// ================= 树形数据 =================
// v2 的树形是「扁平化」实现(源码 table-v2/src/composables/use-data.ts):
// 设置 expandColumnKey 后,内部按 expandedRowKeys 把各层 children BFS 拍平渲染,
// depthMap 记录层级;展开图标/占位与缩进由默认单元格渲染器注入(cell.tsx),无需自定义渲染器。
// 组件侧要打通的五件事:
// 1) 检测到 children 数据自动设置 expandColumnKey(默认第一个数据列)
// 2) 接管 expandedRowKeys 的非受控/受控管理(与 sortBy 同一套模式)
// 3) defaultExpandAll / expandAll / collapseAll / getExpandedRowKeys
// 4) height='auto' 与树形联动:按展开后的可见行数估算总高度
// 5) 展开图标点击会冒泡成行点击,radio 模式下排除,避免误选中
const innerExpandedRowKeys = ref([...(attrs.defaultExpandedRowKeys ?? attrs['default-expanded-row-keys'] ?? [])])
const isExpandedControlled = computed(() => props.expandedRowKeys != null)
const currentExpandedRowKeys = computed(() => (isExpandedControlled.value ? props.expandedRowKeys : innerExpandedRowKeys.value))
const setExpandedRowKeys = (keys) => {
if (!isExpandedControlled.value) innerExpandedRowKeys.value = keys
emit('update:expandedRowKeys', keys)
}
// v2 内部展开状态变化统一从这里同步回非受控副本,并透传给 v-model 与外部监听
const handleExpandedRowsChange = (keys) => {
if (!isExpandedControlled.value) innerExpandedRowKeys.value = keys
emit('update:expandedRowKeys', keys)
const userHandler = attrs.onExpandedRowsChange ?? attrs['onExpanded-rows-change']
userHandler?.(keys)
}
// 是否树形数据:任意层级存在非空 children 即启用
const isTreeData = computed(() => {
const hasChildren = (rows) => rows?.some((row) => (Array.isArray(row.children) && row.children.length > 0) || hasChildren(row.children)) ?? false
return hasChildren(sourceData.value)
})
const finalExpandColumnKey = computed(() => {
if (!isTreeData.value) return undefined
// 未指定时取第一个数据列(与 buildColumns 的 key 规则一致,不含选择列)
const firstCol = props.columns[0]
const fallbackKey = firstCol ? String(firstCol.key ?? firstCol.prop ?? firstCol.dataKey ?? '__col_0') : null
return props.expandColumnKey || fallbackKey || undefined
})
const finalExpandedRowKeys = computed(() => (isTreeData.value ? currentExpandedRowKeys.value : undefined))
// 收集全部可展开节点 key(所有层级含非空 children 的行)
const collectExpandableKeys = (rows, keys = []) => {
; (rows ?? []).forEach((row) => {
if (Array.isArray(row.children) && row.children.length > 0) {
keys.push(row[props.rowKey])
collectExpandableKeys(row.children, keys)
}
})
return keys
}
// defaultExpandAll:数据每次到达时重新展开全部(刷新/翻页后保持展开视图)
watch(
() => sourceData.value,
() => {
if (!props.defaultExpandAll || !isTreeData.value) return
setExpandedRowKeys(collectExpandableKeys(sourceData.value))
},
{ immediate: true }
)
const expandAll = () => setExpandedRowKeys(collectExpandableKeys(sourceData.value))
const collapseAll = () => setExpandedRowKeys([])
// 展开后的可见行数:与 v2 扁平化规则一致(仅下钻已展开的节点),height='auto' 模式按此估算总高度
const visibleRowCount = computed(() => {
if (!finalExpandColumnKey.value) return tableData.value.length
const expandedSet = new Set(currentExpandedRowKeys.value)
let count = 0
const walk = (rows) => {
rows.forEach((row) => {
count += 1
if (expandedSet.has(row[props.rowKey]) && Array.isArray(row.children) && row.children.length > 0) walk(row.children)
})
}
walk(tableData.value)
return count
})
// ================= 列配置映射 =================
const DEFAULT_WIDTH = 100
// 未配置 minWidth 的列拖拽时的最小宽度下限(px)
const MIN_COLUMN_WIDTH = 40
// ================= 列宽拖拽 =================
// 原理:el-table-v2 的列宽完全由 column.width 数字驱动(calcColumnStyle 生成每个单元格样式),
// 拖拽时把用户宽度记入 widthOverrides 并合入 tableColumns,触发整表按新列宽重渲染;
// 手柄通过包装列的 headerCellRenderer 注入表头右缘,不侵入 v2 内部 DOM。
// widthOverrides 只在拖拽中/拖拽后生效,优先级高于列配置的 width
const widthOverrides = ref({})
const resizingKey = ref(null)
let resizeCleanup = null
// 恢复列宽:清除拖拽覆盖,回到配置宽度;不传 key 清除全部
const resetColumnWidth = (key) => {
if (key == null) {
widthOverrides.value = {}
return
}
if (!(key in widthOverrides.value)) return
const next = { ...widthOverrides.value }
delete next[key]
widthOverrides.value = next
}
// 包装表头单元格渲染器:原内容 + 右缘拖拽手柄(浮动竖线,hover/拖拽中高亮)
const makeResizableHeader = (col, key, userRenderer) => (headerProps) => {
// 无自定义表头时还原 v2 默认渲染(el-table-v2__header-cell-text + title)
const inner = userRenderer
? userRenderer(headerProps)
: h('div', { class: headerProps.class, title: headerProps.column?.title }, [headerProps.column?.title])
return h('div', { class: 'pro-table-v2__header-cell-inner' }, [
inner,
h('span', {
class: ['pro-table-v2__column-resizer', resizingKey.value === key && 'is-resizing'],
onMousedown: (event) => startColumnResize(event, key, col),
onDblclick: (event) => {
event.stopPropagation()
resetColumnWidth(key)
},
// 手柄上的 click 不冒泡到表头单元格,避免触发排序
onClick: (event) => event.stopPropagation(),
}),
])
}
const startColumnResize = (event, key, col) => {
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
// 初始宽度从渲染 DOM 量取:autoFill 补偿后的显示宽即为真实起点,拖动 1:1 跟手无跳变
const headerCell = event.currentTarget.closest('.el-table-v2__header-cell')
const startX = event.clientX
const startWidth = headerCell?.getBoundingClientRect().width ?? DEFAULT_WIDTH
const v2Column = tableColumns.value.find((item) => item.key === key)
const minWidth = Math.max(v2Column?.minWidth ?? MIN_COLUMN_WIDTH, MIN_COLUMN_WIDTH)
const maxWidth = v2Column?.maxWidth ?? Infinity
resizingKey.value = key
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
let frame = 0
let moved = false
let nextWidth = startWidth
const onMove = (moveEvent) => {
nextWidth = Math.round(Math.min(maxWidth, Math.max(minWidth, startWidth + moveEvent.clientX - startX)))
moved = true
if (frame) return
// 同一帧多次 mousemove 合并成一次列宽更新
frame = requestAnimationFrame(() => {
frame = 0
widthOverrides.value = { ...widthOverrides.value, [key]: nextWidth }
})
}
const cleanup = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
if (frame) cancelAnimationFrame(frame)
document.body.style.cursor = ''
document.body.style.userSelect = ''
resizingKey.value = null
resizeCleanup = null
}
const onUp = () => {
cleanup()
// 未发生拖动视为普通点击,不抛事件
if (moved) emit('column-resize', { key, prop: col.dataKey ?? col.prop ?? key, width: nextWidth, column: col })
}
resizeCleanup = cleanup
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
// ================= 高度/宽度自适应 =================
// el-table-v2 必须接收数字 width/height:宽度取容器宽,高度按三种模式计算。
// 渲染器工厂(全选表头)依赖 tableWidth,故先于列配置声明。
const rootRef = ref(null)
const paginationRef = ref(null)
const tableWidth = ref(0)
const tableHeight = ref(props.minHeight)
let warnedPercentHeight = false
const heightMode = computed(() => {
const height = props.height
if (height === 'adaptive' || height === undefined || height === null || height === '') return 'adaptive'
if (height === 'auto') return 'auto'
return 'fixed'
})
const parseFixedHeight = (height) => {
if (typeof height === 'number') return height
const matched = /^(\d+(?:\.\d+)?)\s*(px)?$/i.exec(String(height).trim())
if (matched) return Math.floor(parseFloat(matched[1]))
if (!warnedPercentHeight) {
warnedPercentHeight = true
console.warn(`[ProTableV2] height="${height}" 不支持百分比,已回退为 'adaptive'。可用:'adaptive' | 'auto' | 数字 | '600' | '600px'`)
}
return null
}
// 可用高度 = 视口底部 - 组件顶部 - 分页栏占位 - 额外底部预留
const measureHeight = () => {
if (!rootRef.value) return
const width = rootRef.value.clientWidth
if (width > 0) tableWidth.value = width
const mode = heightMode.value
if (mode === 'fixed') {
const fixed = parseFixedHeight(props.height)
if (fixed != null) {
tableHeight.value = fixed
return
}
}
const rootTop = rootRef.value.getBoundingClientRect().top
let reserved = props.bottomOffset
const paginationEl = paginationRef.value?.$el
if (paginationEl) {
const style = window.getComputedStyle(paginationEl)
reserved += paginationEl.offsetHeight + (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0)
}
const available = Math.max(Math.floor(window.innerHeight - rootTop - reserved), props.minHeight)
if (mode === 'adaptive') {
tableHeight.value = available
} else {
// auto:随数据行数撑开,上限为视口剩余空间;树形按展开后的可见行数估算
const headerH = Array.isArray(props.headerHeight) ? props.headerHeight[0] ?? 44 : props.headerHeight
const contentHeight = headerH + visibleRowCount.value * props.rowHeight
tableHeight.value = Math.min(Math.max(contentHeight, Math.min(props.minHeight, available)), available)
}
}
// 同一帧内的多次触发合并成一次测量(仅用于 resize/延时兜底等非 RO 场景)
let measurePending = false
const scheduleMeasure = () => {
if (measurePending) return
measurePending = true
requestAnimationFrame(() => {
measurePending = false
measureHeight()
})
}
const toPx = (value) => {
if (value == null || value === '') return null
if (typeof value === 'number') return value
const matched = /^(\d+(?:\.\d+)?)\s*(px)?$/i.exec(String(value).trim())
return matched ? Math.floor(parseFloat(matched[1])) : null
}
// 单元格渲染:formatter → 具名插槽 → 纯文本;col.cellRenderer 为 v2 原生渲染函数(高级用法)
const makeCellRenderer = (col) => ({ cellData, rowData, rowIndex, column }) => {
let value = cellData
if (col.formatter) value = col.formatter(rowData, { ...column, property: col.prop, label: col.label }, cellData, rowIndex)
if (col.cellRenderer) return col.cellRenderer({ cellData: value, rowData, rowIndex, column, columns: column.columns })
if (col.slot) {
const slotFn = slots[col.slot]
const rendered = slotFn?.({ row: rowData, rowIndex, $index: rowIndex, column, cellData: value, value })
if (rendered) return rendered
}
const text = value == null ? '' : String(value)
// 列级开关 col.showOverflowTooltip(默认 true):hover 且文本被截断时弹单例 tooltip;
// 弹层内容可选中复制(show-overflow-tooltip),原生 title 做不到
if (col.showOverflowTooltip === false) return h('span', { class: 'pro-table-v2__cell-text' }, text)
return h('span', {
class: 'pro-table-v2__cell-text',
onMouseenter: (event) => showCellTooltip(event, text),
onMouseleave: hideCellTooltip,
}, text)
}
// ========== 单元格省略提示(单例 tooltip)==========
// 非受控模式:不传 visible,不接管 enterable/延迟等任何默认行为。
// 单元格 mouseenter 时把元素挂到 virtual-ref 并调 onOpen();同元素后续的
// hover 进出、移入弹层保持显示全部由 el-tooltip 原生监听接管
const tooltipRef = ref(null)
const cellTooltipEl = ref(null)
const cellTooltipContent = ref('')
const showCellTooltip = (event, text) => {
const el = event.currentTarget
// 非截断格子:掠过时收起上一个弹层
if (!el || el.scrollWidth <= el.offsetWidth) {
hideCellTooltip()
return
}
cellTooltipEl.value = el
cellTooltipContent.value = text
tooltipRef.value?.onOpen?.()
}
const hideCellTooltip = () => tooltipRef.value?.onClose?.()
// 多选列表头全选框(作用于全部可选择行,树形数据时含各级子行)
const makeSelectionHeaderRenderer = () => () => {
const selectableRows = flattenAllRows(sourceData.value).filter((row) => !isRowDisabled(row))
const total = selectableRows.length
const checked = selectableRows.filter((row) => selectedKeys.value.has(row[props.rowKey])).length
return h('div', { class: 'pro-table-v2__selection-header' }, [
h(ElCheckbox, {
modelValue: total > 0 && checked === total,
indeterminate: checked > 0 && checked < total,
disabled: total === 0,
onChange: () => toggleSelectAll(selectableRows),
}),
])
}
const buildColumns = () => {
const cellBorderedClass = props.border ? 'pro-table-v2__cell--bordered' : undefined
const cols = []
if (props.selectionType === 'radio') {
cols.push({
key: '__pro_radio__',
dataKey: '__pro_radio__',
title: '',
width: props.selectionColumnWidth,
align: 'center',
fixed: 'left',
class: cellBorderedClass,
headerClass: cellBorderedClass,
cellRenderer: ({ rowData }) =>
h(ElRadio, {
class: 'pro-table-v2__radio',
modelValue: radioSelectedKey.value,
label: rowData[props.rowKey],
disabled: isRowDisabled(rowData),
onChange: () => handleRadioSelect(rowData),
}, { default: () => null }),
})
} else if (props.selectionType === 'checkbox') {
cols.push({
key: '__pro_selection__',
dataKey: '__pro_selection__',
title: '',
width: props.selectionColumnWidth,
align: 'center',
fixed: 'left',
class: cellBorderedClass,
headerClass: cellBorderedClass,
cellRenderer: ({ rowData }) =>
h(ElCheckbox, {
modelValue: selectedKeys.value.has(rowData[props.rowKey]),
disabled: isRowDisabled(rowData),
onChange: (checked) => handleCheckRow(rowData, checked),
}),
headerCellRenderer: makeSelectionHeaderRenderer(),
})
}
props.columns.forEach((col, index) => {
const key = String(col.key ?? col.prop ?? col.dataKey ?? `__col_${index}`)
const width = toPx(col.width)
const minWidth = toPx(col.minWidth)
const maxWidth = toPx(col.maxWidth)
const column = {
key,
dataKey: col.dataKey ?? col.prop ?? key,
title: col.title ?? col.label ?? '',
width: widthOverrides.value[key] ?? width ?? (minWidth ?? DEFAULT_WIDTH),
minWidth,
maxWidth,
align: col.align ?? 'left',
fixed: col.fixed === true ? 'left' : col.fixed,
sortable: !!col.sortable,
class: col.class ?? cellBorderedClass,
headerClass: col.headerClass ?? cellBorderedClass,
hidden: col.hidden,
flexGrow: col.flexGrow,
flexShrink: col.flexShrink,
// resizable 时包装表头渲染器注入拖拽手柄;原 headerCellRenderer(如全选框、自定义表头)透传保留
headerCellRenderer: props.border ? makeResizableHeader(col, key, col.headerCellRenderer) : col.headerCellRenderer,
cellRenderer: makeCellRenderer(col),
}
// minWidth 列自动伸展(fixed 模式下由剩余宽度分配实现,非 fixed 模式走 v2 原生 flexGrow)
if (column.minWidth != null && col.flexGrow === undefined) column.flexGrow = 1
cols.push(column)
})
// 所有列都是固定宽时,把剩余宽度补进列宽占满整行(对齐 el-table 观感)。
// fixed 模式下 v2 强制 flexGrow:0,不能用 flex 伸展,必须显式加宽列。
// 原理:列总宽 Σ < 视口宽时补到 Σ' = 视口宽 → 内容宽 = props.width →
// 虚拟网格横向 ratio = 100% 滚动条自动隐藏,行宽也恰好铺满;
// Σ ≥ 视口宽时不补,列保持配置宽度 → 内容宽 > 视口 → 横向滚动条出现。
// 剩余宽度优先按 minWidth 占比分给 min-width 列(对齐 el-table 策略),没有则给末列
if (props.autoFillLastColumn && cols.length > 0) {
const sumWidths = cols.reduce((sum, col) => sum + col.width, 0)
const surplus = tableWidth.value - sumWidths
if (surplus > 0) {
// 用户拖拽过的列不参与剩余宽度分配:否则拖窄后 surplus 变大又被补回来,出现"拖不动"
const flexColumns = cols.filter((col) => col.minWidth != null && widthOverrides.value[col.key] == null)
if (flexColumns.length > 0) {
const sumMin = flexColumns.reduce((sum, col) => sum + col.minWidth, 0)
flexColumns.forEach((col, index) => {
const isLast = index === flexColumns.length - 1
const share = isLast
? surplus - flexColumns.slice(0, index).reduce((sum, c) => sum + c.__fill, 0)
: Math.floor((surplus * col.minWidth) / sumMin)
col.__fill = share
col.width += share
})
} else {
// 剩余宽度给最后一个未被用户拖拽过的列;全部拖过则不再补(避免拖拽被抵消)
for (let i = cols.length - 1; i >= 0; i--) {
if (widthOverrides.value[cols[i].key] == null) {
cols[i].width += surplus
break
}
}
}
}
}
// 清理内部分配标记,避免泄漏到 v2(多余字段虽无害,但保持列对象干净)
cols.forEach((col) => delete col.__fill)
return cols
}
const tableColumns = computed(buildColumns)
// ================= 排序 =================
const innerSortBy = ref({ key: '', order: 'asc' })
const isSortControlled = computed(() => props.sortBy != null)
const currentSortBy = computed(() => (isSortControlled.value ? props.sortBy : innerSortBy.value))
// el-table-v2 不做本地排序,点击表头只回调;这里统一接管:
// 受控/非受控维护 sortBy,localSort 时对当前 data 排序,并抛出 sort-change
const handleColumnSort = ({ key, column }) => {
const prev = currentSortBy.value
const order = prev.key === key ? (prev.order === 'asc' ? 'desc' : 'asc') : 'asc'
const next = { key, order }
if (!isSortControlled.value) innerSortBy.value = next
emit('update:sortBy', next)
emit('sort-change', { key, prop: key, order, column })
}
const getValueByPath = (obj, path) => (path ? String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), obj) : obj)
const compareValues = (a, b) => {
const emptyA = a == null || a === ''
const emptyB = b == null || b === ''
if (emptyA || emptyB) return emptyA && emptyB ? 0 : emptyA ? -1 : 1
if (typeof a === 'number' && typeof b === 'number') return a - b
const numA = Number(a)
const numB = Number(b)
if (!Number.isNaN(numA) && !Number.isNaN(numB)) return numA - numB
return String(a).localeCompare(String(b))
}
// 注意:树形数据下本地排序只对根层级生效(子级保持原有顺序,仍挂在各自父节点下);
// 需要逐级排序时在数据侧排好,或改用远程排序
const tableData = computed(() => {
if (!props.localSort) return sourceData.value
const { key, order } = currentSortBy.value
if (!key || !order) return sourceData.value
const direction = order === 'asc' ? 1 : -1
return [...sourceData.value].sort((rowA, rowB) => compareValues(getValueByPath(rowA, key), getValueByPath(rowB, key)) * direction)
})
// ================= 行类名 / 行事件 =================
const finalRowClass = computed(() => {
const userRowClass = props.rowClass
return ({ columns, rowData, rowIndex }) => {
const classes = []
if (props.stripe && rowIndex % 2 === 1) classes.push('pro-table-v2__row--striped')
const key = rowData?.[props.rowKey]
if (key != null) {
if (radioSelectedKey.value === key) classes.push('pro-table-v2__row--current')
if (selectedKeys.value.has(key) || radioSelectedKey.value === key) classes.push('pro-table-v2__row--selected')
if (isRowDisabled(rowData)) classes.push('pro-table-v2__row--disabled')
}
const extra = typeof userRowClass === 'function' ? userRowClass({ columns, rowData, rowIndex }) : userRowClass
if (extra) classes.push(extra)
return classes.join(' ')
}
})
// v2 行事件是 rowEventHandlers prop;包装成 el-table 风格事件抛出,
// radio 模式下点击行任意位置选中
const finalRowEventHandlers = computed(() => {
const user = props.rowEventHandlers ?? {}
const wrap = (name, eventName) => (params) => {
user[name]?.(params)
if (eventName) emit(eventName, params.rowData, { rowIndex: params.rowIndex }, params.event)
if (name === 'onClick' && params.rowData && props.selectionType === 'radio') {
// 树形展开图标的点击会冒泡成行点击,排除避免误选中
if (params.event?.target?.closest?.('.el-table-v2__expand-icon')) return
handleRadioSelect(params.rowData)
}
}
const handlers = {
onClick: wrap('onClick', 'row-click'),
onDblclick: wrap('onDblclick', 'row-dblclick'),
onContextmenu: wrap('onContextmenu', 'row-contextmenu'),
}
if (user.onMouseenter) handlers.onMouseenter = user.onMouseenter
if (user.onMouseleave) handlers.onMouseleave = user.onMouseleave
return handlers
})
// ================= 分页 =================
const paginationProps = computed(() => ({
layout: 'total, sizes, prev, pager, next, jumper',
pageSizes: [20, 50, 100, 200, 500],
background: true,
...props.paginationOptions,
}))
// 改每页条数时 el-pagination 可能因页码钳制连发 size-change + current-change,
// 同一轮事件流的多次触发合并成一次 pagination-change,父组件只收到一次回调
let paginationTimer = null
const pendingPagination = { page: null, size: null, type: 'page' }
const schedulePaginationChange = (patch) => {
if (patch.page !== undefined) pendingPagination.page = patch.page
if (patch.size !== undefined) {
pendingPagination.size = patch.size
pendingPagination.type = 'size'
} else if (pendingPagination.size === null) {
pendingPagination.type = 'page'
}
clearTimeout(paginationTimer)
paginationTimer = setTimeout(() => {
emit('pagination-change', {
page: pendingPagination.page ?? props.page,
size: pendingPagination.size ?? props.size,
})
pendingPagination.page = null
pendingPagination.size = null
pendingPagination.type = 'page'
})
}
const handlePageChange = (page) => {
emit('update:page', page)
schedulePaginationChange({ page })
}
const handleSizeChange = (size) => {
emit('update:size', size)
schedulePaginationChange({ size, page: 1 })
}
// ================= 生命周期:测量挂载 =================
let rootObserver = null
let parentObserver = null
onMounted(() => {
measureHeight()
window.addEventListener('resize', scheduleMeasure)
// 挂载后补测两次,兼容 el-dialog 等带过渡动画的容器(动画期间 rect.top 不准)
setTimeout(measureHeight, 120)
setTimeout(measureHeight, 400)
// RO 回调同步测量(不走 rAF):筛选区展开/收起等场景同一帧完成二次布局,避免闪屏
rootObserver = new ResizeObserver(measureHeight)
if (rootRef.value) rootObserver.observe(rootRef.value)
if (rootRef.value?.parentElement) {
parentObserver = new ResizeObserver(measureHeight)
parentObserver.observe(rootRef.value.parentElement)
}
})
// keep-alive 页面切回来时重算一次
onActivated(() => nextTick(measureHeight))
onBeforeUnmount(() => {
window.removeEventListener('resize', scheduleMeasure)
rootObserver?.disconnect()
parentObserver?.disconnect()
clearTimeout(paginationTimer)
// 卸载时若正在拖拽列宽,恢复全局 body 状态并移除 window 监听
resizeCleanup?.()
})
watch(
() => [props.height, props.showPagination, props.bottomOffset, props.minHeight, props.rowHeight, props.headerHeight, sourceData.value.length, visibleRowCount.value],
() => nextTick(measureHeight)
)
// ================= 对外暴露的方法 =================
const clearSelection = () => {
if (props.selectionType === 'radio') {
radioSelectedKey.value = null
return
}
selectedKeys.value = new Set()
selectedRows.value = []
emit('selection-change', [])
}
const toggleRowSelection = (row, selected) => {
if (props.selectionType !== 'checkbox' || !row) return
const key = row[props.rowKey]
const has = selectedKeys.value.has(key)
const target = typeof selected === 'boolean' ? selected : !has
if (target === has) return
const next = new Set(selectedKeys.value)
target ? next.add(key) : next.delete(key)
selectedKeys.value = next
rowCache.set(key, row)
refreshSelectedRows()
emit('selection-change', selectedRows.value)
}
const recalculate = () => nextTick(measureHeight)
defineExpose({
// el-table-v2 实例,可调用其原生 scrollTo / scrollToLeft / scrollToTop / scrollToRow
tableRef,
getSelectionRows: () => selectedRows.value,
getRadioSelectedRow: () => {
const key = radioSelectedKey.value
if (key == null) return null
return flattenAllRows(sourceData.value).find((row) => row[props.rowKey] === key) ?? rowCache.get(key) ?? null
},
clearSelection,
toggleRowSelection,
clearRadio: () => {
radioSelectedKey.value = null
},
// 重置列宽:清除拖拽覆盖恢复配置宽度;不传 key 清除全部列
resetColumnWidth,
// 树形:全部展开 / 全部收起 / 读取当前展开的行 key 数组
expandAll,
collapseAll,
getExpandedRowKeys: () => [...currentExpandedRowKeys.value],
// 手动触发一次高度/宽度重算(特殊容器/时机下自救用)
recalculate,
})
</script>
<style scoped lang="less">
.pro-table-v2 {
display: flex;
flex-direction: column;
min-height: 0;
/* 文字颜色对齐 el-table(el-table 根元素应用了 --el-table-text-color,v2 根没有) */
color: #606266;
// padding: 0px 10px;
}
.pro-table-v2--border {
--el-table-border-color: var(--el-border-color-lighter);
border: 1px solid var(--el-table-border-color);
}
/* 分页回归文档流,跟随在表格下方;自适应模式下其占位会计入高度测量 */
.pro-table-v2__pagination {
justify-content: flex-end;
flex-shrink: 0;
margin: 10px 15px 0px 0px;
}
/* 自定义文本单元格:溢出省略(hover 截断时由单例 tooltip 展示全文,
弹层为 el-tooltip 默认形态,文字可选中复制) */
.pro-table-v2 :deep(.pro-table-v2__cell-text) {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.pro-table-v2 :deep(.pro-table-v2__cell--bordered) {
border-right: 1px solid var(--el-table-border-color);
}
.pro-table-v2--border :deep(.el-table-v2__main .pro-table-v2__cell--bordered:last-child),
.pro-table-v2--border :deep(.el-table-v2__right .pro-table-v2__cell--bordered:last-child) {
border-right: none;
}
/* 行状态 */
.pro-table-v2 :deep(.el-table-v2__row) {
transition: background-color 0.15s ease;
}
.pro-table-v2 :deep(.el-table-v2__row:hover) {
background-color: var(--el-fill-color-light);
}
.pro-table-v2 :deep(.el-table-v2__row.pro-table-v2__row--striped) {
background-color: var(--el-fill-color-lighter);
}
.pro-table-v2 :deep(.el-table-v2__row.pro-table-v2__row--current) {
background-color: var(--el-color-primary-light-9);
}
.pro-table-v2 :deep(.el-table-v2__row.pro-table-v2__row--selected) {
font-weight: 600;
}
.pro-table-v2 :deep(.el-table-v2__row.pro-table-v2__row--disabled) {
color: var(--el-text-color-placeholder);
}
.pro-table-v2 :deep(.pro-table-v2__selection-header) {
display: flex;
width: 100%;
justify-content: center;
}
/* 单选列:隐藏 radio 的占位 label */
.pro-table-v2 :deep(.pro-table-v2__radio) {
margin-right: 0;
}
.pro-table-v2 :deep(.pro-table-v2__radio .el-radio__label) {
display: none;
}
/* ================= 列宽拖拽 ================= */
/* 表头单元格作为手柄的定位上下文 */
.pro-table-v2 :deep(.el-table-v2__header-cell) {
position: relative;
}
/* 包装层:包住原表头内容;宽度随内容自适应(flex-grow:0),
不破坏 v2 外层单元格的 justify-content(居中/右对齐)与排序图标紧贴文字的默认布局;
拖拽手柄 absolute 定位挂在最近的定位上下文(.el-table-v2__header-cell)上 */
.pro-table-v2 :deep(.pro-table-v2__header-cell-inner) {
display: flex;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
align-items: center;
}
/* 拖拽手柄:贴右缘 6px 命中区;中缝竖线 hover/拖拽中高亮为主题色 */
.pro-table-v2 :deep(.pro-table-v2__column-resizer) {
position: absolute;
top: 0;
right: 0;
z-index: 3;
width: 6px;
height: 100%;
cursor: col-resize;
&::after {
content: '';
position: absolute;
top: 25%;
right: 2px;
width: 2px;
height: 50%;
border-radius: 1px;
background-color: transparent;
transition: background-color 0.15s ease;
}
&:hover::after,
&.is-resizing::after {
background-color: var(--el-color-primary);
}
}
</style>