el-table-v2 虚拟表格组件

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. 注意事项

  1. 必须给行固定高度 :虚拟滚动依赖 rowHeight 估算位置,行内容不要换行;若需多行,请调大 rowHeight。
  2. height 不支持百分比('60%' 会回退 'adaptive' 并在控制台警告)。
  3. 列宽拖拽手柄仅在 border=true 时注入;拖拽中双击手柄可恢复该列配置宽度。
  4. 树形数据下本地排序只对根层级生效,子级保持原顺序;需要逐级排序请在数据侧排好或改用远程排序。
  5. 单元格里使用具名插槽时,作用域里同时提供 cellData(经过 formatter 的值)和 value(原始字段值)。
  6. 单例 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>
相关推荐
OpsEye1 小时前
企业大模型用量成本该如何监控和管控?
javascript·ai编程
创新技术阁1 小时前
FastapiAdmin代码生成详细解释
前端·后端·fastapi
杨利杰YJlio1 小时前
ITSK 万能驱动 26V5:新驱动批量更新
前端·javascript·后端
deli0071 小时前
克拉德尼图形:调一下频率,沙子自己排成对称花纹
前端
光影少年1 小时前
TurboModule原理
前端·前端框架·turbopack
亿元程序员1 小时前
自从有了 AI,我就再也不想拼 UI 了……
前端
数据狐(Datafox)1 小时前
淘宝商品详情API实战:多语言代购商城自动同步数据完整方案
开发语言·前端·数据库·爬虫·json
hasty2 小时前
HTML 已经转义,为何仍有 XSS?Sharp srcdoc 漏洞中的第二次解析
前端·html·xss
涛涛ing2 小时前
WebMCP正式进入Origin Trial:AI Agent不再“装人”,前端交互范式正在被重写
前端