Skip to content

Z-Table 虚拟滚动功能

📚 概述

Z-Table 组件现在支持基于 Element Plus TableV2 的虚拟滚动功能,可以高效处理大量数据,提供流畅的用户体验。

🚀 核心特性

  • 高性能渲染: 基于 Element Plus TableV2,支持 10 万+ 数据流畅滚动
  • 智能切换: 自动或手动在普通表格和虚拟表格间切换
  • 完整功能: 支持排序、固定列、多选等核心表格功能
  • 样式一致: 保持与原表格相同的视觉体验
  • 极致性能: DOM 节点数量恒定,内存占用稳定

✅ 兼容性说明

  • 完全兼容: 排序、筛选、多选、选择功能、展开功能、索引列、固定列、样式主题、列显隐、列提示、水印功能
  • 高级功能: 可编辑表格、自定义列渲染、自定义表头、插槽模板
  • API 一致: 与普通表格相同的 props 和事件
  • 渐进增强: 可在运行时动态开启/关闭

🚀 快速开始

三步启用虚拟滚动

vue
<!-- 步骤1: 添加 virtual 属性 -->
<!-- 步骤2: 设置固定高度 -->
<!-- 步骤3: 配置列信息 -->
<z-table
  :data="largeData"
  :columns="columns"
  :virtual="true"
  height="400px"
/>

基础配置示例

vue
<template>
  <ZTable
    :data="tableData"
    :columns="columns"
    :virtual="true"
    height="600px"
  />
</template>

<script setup>
// 列配置无需指定 width,系统会自动处理
const columns = [
  { prop: 'name', label: '姓名' },
  { prop: 'email', label: '邮箱' },
  { prop: 'department', label: '部门' }
]
</script>

高级配置示例

vue
<template>
  <ZTable
    :data="tableData"
    :columns="columns"
    :virtual="virtualConfig"
    height="600px"
  />
</template>

<script setup>
const virtualConfig = {
  enabled: true,
  itemHeight: 48,        // 行高度
  threshold: 100,        // 启用阈值
  footerHeight: 60       // Footer 高度
}
</script>

🎯 基础功能

基础表格

基础的虚拟表格功能。支持大量数据的高效渲染。

<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { TableV2FixedDir } from 'element-plus'

// Generate mock data
function generateData(count: number) {
  const data = []
  for (let i = 1; i <= count; i++) {
    data.push({
      id: i,
      name: `User ${i}`,
      email: `user${i}@example.com`,
      phone: `138${String(i).padStart(8, '0')}`,
      department: ['Engineering', 'Product', 'Design', 'Operations'][i % 4],
      position: ['Engineer', 'Product Manager', 'Designer', 'Operations Specialist'][i % 4],
      status: ['Active', 'Departed', 'Probation'][i % 3],
      createTime: new Date(2024, 0, 1 + (i % 365)).toLocaleDateString(),
    })
  }
  return data
}

const tableData = ref(generateData(5000))
const dataCount = ref(5000)

const columns = ref([
  {
    prop: 'id',
    label: 'ID',
  },
  {
    prop: 'name',
    label: 'Name',
    fixed: TableV2FixedDir.LEFT
  },
  {
    prop: 'email',
    label: 'Email',
  },
  {
    prop: 'phone',
    label: 'Phone',
  },
  {
    prop: 'department',
    label: 'Department',
  },
  {
    prop: 'position',
    label: 'Position',
  },
  {
    prop: 'status',
    label: 'Status',
  },
  {
    prop: 'createTime',
    label: 'Created At',
    fixed: TableV2FixedDir.RIGHT,
  },
])

// Virtual scrolling config
const virtualConfig = computed(() => ({
  enabled: true,
  itemHeight: 48,
  threshold: 100,
}))

function updateData() {
  tableData.value = generateData(dataCount.value)
}

function handleRefresh() {
  console.log('Refresh data')
  updateData()
}
</script>

<template>
  <div>
    <div style="margin-bottom: 16px;">
      <el-space wrap>
        <span>Row count:</span>
        <el-input-number
          v-model="dataCount"
          :min="100"
          :max="50000"
          :step="1000"
          controls-position="right"
          style="width: 150px;"
        />
        <el-button @click="updateData" type="primary">
          Update data
        </el-button>
        <span style="color: #666;">Current: {{ tableData.length.toLocaleString() }} rows</span>
      </el-space>
    </div>

    <z-table
      v-model:data="tableData"
      :columns="columns"
      :virtual="virtualConfig"
      height="500px"
      border
      stripe
      @refresh="handleRefresh"
    />
  </div>
</template>

操作按钮

验证操作按钮在虚拟表格中的完整功能,包括点击事件、样式等。

Virtual table actions demo

Demonstrates action buttons inside a virtual table, including click handlers and styling.

<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, h, ref, resolveComponent } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'

interface RowData {
  id: number
  name: string
  email: string
  department: string
  salary: number
  status: string
}

// Generate mock data
function generateData(count: number): RowData[] {
  const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
  const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
  const statuses = ['Active', 'Departed', 'Probation']
  const data: RowData[] = []

  for (let i = 0; i < count; i++) {
    data.push({
      id: i + 1,
      name: `${names[i % names.length]}-${i + 1}`,
      email: `user${i + 1}@example.com`,
      department: departments[i % departments.length],
      salary: Math.floor(Math.random() * 50000) + 8000,
      status: statuses[i % statuses.length],
    })
  }
  return data
}

const tableData = ref<RowData[]>(generateData(1000))

const columns = ref([
  {
    prop: 'id',
    label: 'ID',
  },
  {
    prop: 'name',
    label: 'Name',
  },
  {
    prop: 'email',
    label: 'Email',
  },
  {
    prop: 'department',
    label: 'Department',
  },
  {
    prop: 'salary',
    label: 'Salary',
    render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
  },
  {
    prop: 'status',
    label: 'Status',
  },
  {
    prop: 'operation',
    label: 'Actions',
    render: (scope: any) => [
      h(resolveComponent('el-button'), {
        type: 'primary',
        onClick: () => handleEdit(scope.row)
      }, () => 'Edit'),
      h(resolveComponent('el-button'), {
        type: 'danger',
        onClick: () => handleDelete(scope.row)
      }, () => 'Delete')
    ],
  },
])

// Virtual scrolling config
const virtualConfig = computed(() => ({
  enabled: true,
  itemHeight: 48,
  threshold: 100,
}))

function handleEdit(row: RowData) {
  console.log('Edit action:', row)
  ElMessage.success(`Editing: ${row.name}`)
}

function handleDelete(row: RowData) {
  console.log('Delete action:', row)
  ElMessageBox.confirm(`Delete ${row.name}?`, 'Confirm delete', {
    confirmButtonText: 'Confirm',
    cancelButtonText: 'Cancel',
    type: 'warning',
  }).then(() => {
    const index = tableData.value.findIndex(item => item.id === row.id)
    if (index > -1) {
      tableData.value.splice(index, 1)
      ElMessage.success('Deleted successfully')
    }
  }).catch(() => {
    ElMessage.info('Deletion cancelled')
  })
}
</script>

<template>
  <div>
    <div style="margin-bottom: 16px;">
      <h3>Virtual table actions demo</h3>
      <p style="margin: 8px 0; color: #666;">
        Demonstrates action buttons inside a virtual table, including click handlers and styling.
      </p>
    </div>

    <z-table
      v-model:data="tableData"
      :columns="columns"
      :virtual="virtualConfig"
      height="500px"
      border
      stripe
    />
  </div>
</template>

列类型

测试各种列类型在虚拟表格中的兼容性:选择列、索引列、表单组件等。

Virtual Table Column Type Demo

Demonstrates compatibility of various column types in a virtual table: selection columns, index columns, form components, and more.

Selected: 0 items

<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, h, ref } from 'vue'

interface RowData {
  id: number
  name: string
  email: string
  department: string
  position: string
  salary: number
  status: string
}

// Generate mock data
function generateData(count: number): RowData[] {
  const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
  const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
  const positions = ['Engineer', 'Product Manager', 'Designer', 'Operations Specialist', 'Marketing Specialist']
  const statuses = ['Active', 'Departed', 'Probation']
  const data: RowData[] = []

  for (let i = 0; i < count; i++) {
    data.push({
      id: i + 1,
      name: `${names[i % names.length]}-${i + 1}`,
      email: `user${i + 1}@example.com`,
      department: departments[i % departments.length],
      position: positions[i % positions.length],
      salary: Math.floor(Math.random() * 50000) + 8000,
      status: statuses[i % statuses.length],
    })
  }
  return data
}

const tableData = ref<RowData[]>(generateData(1000))

const columns = ref([
  {
    type: 'selection',
    label: 'Selection',
  },
  {
    type: 'index',
    label: 'Index',
    index: 1,
  },
  {
    prop: 'id',
    label: 'ID',
  },
  {
    prop: 'name',
    label: 'Name',
  },
  {
    prop: 'email',
    label: 'Email',
  },
  {
    prop: 'department',
    label: 'Department',
  },
  {
    prop: 'position',
    label: 'Position',
  },
  {
    prop: 'salary',
    label: 'Salary',
    render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
  },
  {
    prop: 'status',
    label: 'Status',
    render: ({ row }: any) => {
      const type = row.status === 'Active' ? 'success' : row.status === 'Probation' ? 'warning' : 'danger'
      return h('el-tag', { type, size: 'small' }, row.status)
    },
  },
])

// Virtual scrolling config
const virtualConfig = computed(() => ({
  enabled: true,
  itemHeight: 48,
  threshold: 100,
}))

const selectedRows = ref<RowData[]>([])

function handleSelectionChange(selection: RowData[]) {
  selectedRows.value = selection
  console.log('Selection changed:', selection)
}
</script>

<template>
  <div>
    <div style="margin-bottom: 16px;">
      <h3>Virtual Table Column Type Demo</h3>
      <p style="margin: 8px 0; color: #666;">
        Demonstrates compatibility of various column types in a virtual table: selection columns, index columns, form components, and more.
      </p>
      <p style="margin: 8px 0; color: #666;">Selected: {{ selectedRows.length }} items</p>
    </div>

    <z-table
      v-model:data="tableData"
      :columns="columns"
      :virtual="virtualConfig"
      height="500px"
      border
      stripe
      @selection-change="handleSelectionChange"
    />
  </div>
</template>

列选择

测试 type: 'selection' 列在虚拟表格中的支持情况。

Virtual table selection demo

The virtual table fully supports multi-select operations including select-all, clearing, and custom actions.

<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, ref } from 'vue'

interface RowData {
  id: number
  name: string
  email: string
  department: string
  position: string
  salary: number
  status: string
}

// Generate mock data
function generateData(count: number): RowData[] {
  const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
  const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
  const positions = ['Engineer', 'Product Manager', 'Designer', 'Operations Specialist', 'Marketing Specialist']
  const statuses = ['Active', 'Departed', 'Probation']
  const data: RowData[] = []

  for (let i = 0; i < count; i++) {
    data.push({
      id: i + 1,
      name: `${names[i % names.length]}-${i + 1}`,
      email: `user${i + 1}@example.com`,
      department: departments[i % departments.length],
      position: positions[i % positions.length],
      salary: Math.floor(Math.random() * 50000) + 8000,
      status: statuses[i % statuses.length],
    })
  }
  return data
}

const tableData = ref<RowData[]>(generateData(2000))
const selectedRows = ref<RowData[]>([])

const columns = ref([
  {
    type: 'selection',
    label: 'Selection',
  },
  {
    prop: 'id',
    label: 'ID',
  },
  {
    prop: 'name',
    label: 'Name',
  },
  {
    prop: 'email',
    label: 'Email',
  },
  {
    prop: 'department',
    label: 'Department',
  },
  {
    prop: 'position',
    label: 'Position',
  },
  {
    prop: 'salary',
    label: 'Salary',
    render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
  },
  {
    prop: 'status',
    label: 'Status',
  },
])

// Virtual scrolling config
const virtualConfig = computed(() => ({
  enabled: true,
  itemHeight: 48,
  threshold: 100,
}))

const tableRef = ref()

function handleSelectionChange(selection: RowData[]) {
  console.log('Selection changed:', selection)
  selectedRows.value = selection
}

function selectAll() {
  tableRef.value?.toggleAllSelection()
}

function clearSelection() {
  tableRef.value?.clearSelection()
  selectedRows.value = []
}

function selectFirst10() {
  clearSelection()
  for (let i = 0; i < Math.min(10, tableData.value.length); i++) {
    tableRef.value?.toggleRowSelection(tableData.value[i], true)
  }
}

function selectHighSalary() {
  clearSelection()
  const highSalaryRows = tableData.value.filter(row => row.salary > 30000)
  highSalaryRows.forEach(row => {
    tableRef.value?.toggleRowSelection(row, true)
  })
}
</script>

<template>
  <div>
    <div style="margin-bottom: 16px;">
      <h3>Virtual table selection demo</h3>
      <p style="margin: 8px 0; color: #666;">
        The virtual table fully supports multi-select operations including select-all, clearing, and custom actions.
      </p>

      <el-space wrap>
        <el-button @click="selectAll" type="primary">
          Select all
        </el-button>
        <el-button @click="clearSelection" type="warning">
          Clear selection
        </el-button>
        <el-button @click="selectFirst10" type="success">
          Select first 10
        </el-button>
        <el-button @click="selectHighSalary" type="info">
          Select salary > 30k
        </el-button>
        <span style="color: #666;">Selected: {{ selectedRows.length }} rows</span>
      </el-space>
    </div>

    <z-table
      ref="tableRef"
      v-model:data="tableData"
      :columns="columns"
      :virtual="virtualConfig"
      height="500px"
      border
      stripe
      @selection-change="handleSelectionChange"
    />

    <div v-if="selectedRows.length > 0" style="padding: 16px; margin-top: 16px; background: #f5f7fa; border-radius: 4px;">
      <h4 style="margin: 0 0 8px; color: #303133;">Selected rows ({{ selectedRows.length }}):</h4>
      <div style="max-height: 200px; overflow-y: auto;">
        <div
          v-for="row in selectedRows"
           :key="row.id"
           style="
             display: flex;
             align-items: center;
             justify-content: space-between;
             padding: 8px;
             margin-bottom: 4px;
             background: white;
             border: 1px solid #e4e7ed;
             border-radius: 4px;
           "
        >
          <span><strong>{{ row.name }}</strong> ({{ row.email }})</span>
          <span style="color: #67c23a;">¥{{ row.salary.toLocaleString() }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

列展开

虚拟表格完全支持展开功能,基于Element Plus TableV2原生能力。

功能特点:

  • 展开列配置{ type: 'expand' } 列配置,TableV2自动为有children的行添加展开按钮
  • 双向绑定v-model:expanded-row-keys 支持双向绑定展开状态
  • 事件支持@expand-change@row-expand 事件 (TableV2原生事件)
  • 方法支持toggleRowExpansion 方法
  • 插槽支持#expand 插槽自定义展开内容
  • 数据结构:支持children字段的嵌套数据结构

Virtual Table – Expand Feature

Expand state:

Current expanded row IDs: None

Expanded row count: 0

<template>
  <div>
    <h3>Virtual Table – Expand Feature</h3>

    <div style="margin-bottom: 16px;">
      <el-space>
        <el-button @click="expandAll">
          Expand all
        </el-button>
        <el-button @click="collapseAll">
          Collapse all
        </el-button>
        <el-button @click="expandFirst5">
          Expand first 5 rows
        </el-button>
      </el-space>
    </div>

    <el-alert
      title="✅ Expand feature ready"
      type="success"
      :closable="false"
      style="margin-bottom: 16px;"
    >
      <template #default>
        <p><strong>✅ Expand column support</strong>: Fully supports <code>type: 'expand'</code>; TableV2 handles rows with children automatically.</p>
        <p><strong>✅ Two-way binding</strong>: <code>v-model:expanded-row-keys</code> keeps expanded state in sync.</p>
        <p><strong>✅ Event support</strong>: <code>@expand-change</code> and <code>@row-expand</code> events.</p>
        <p><strong>✅ Method support</strong>: <code>toggleRowExpansion</code> API.</p>
        <p><strong>✅ Slot support</strong>: Customize expanded content via the <code>#expand</code> slot.</p>
      </template>
    </el-alert>

        <z-table
      ref="tableRef"
      :data="tableData"
      :columns="columns"
      v-model:expanded-row-keys="expandedKeys"
      :virtual="{ enabled: true, itemHeight: 48, threshold: 100, estimatedRowHeight: 48  }"
      height="500px"
      border
      stripe
      row-key="id"
      @expanded-rows-change="handleExpandedRowsChange"
      @row-expand="handleRowExpand"
    >
      <template #expand="props">
        <Row v-bind="props" />
      </template>
    </z-table>

    <div style="margin-top: 16px;">
      <h4>Expand state:</h4>
      <p>Current expanded row IDs: {{ expandedKeys.join(', ') || 'None' }}</p>
      <p>Expanded row count: {{ expandedKeys.length }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { h, ref } from 'vue'

const detailedText = `Detailed expand content goes here. Include work history, key skills, project experience, and other information.
This longer text renders nicely inside the expand area and supports rich text.
You can insert complex UI components and interactive elements as needed.`

// Generate mock data
function generateData() {
  const data = []
  for (let i = 1; i <= 2000; i++) {
        const rowData: any = {
      id: i,
      name: `User ${i}`,
      email: `user${i}@example.com`,
      age: 20 + (i % 50),
      department: ['Engineering', 'Marketing', 'Product', 'Operations'][i % 4],
      salary: 5000 + (i % 100) * 100,
      createTime: new Date(2020 + (i % 4), (i % 12), (i % 28) + 1).toLocaleDateString(),
      status: i % 3 === 0 ? 'active' : 'inactive',
    }

    // Add children for expand content
    rowData.children = [
      {
        id: `${rowData.id}-detail-content`,
        detail: detailedText,
        parentData: rowData
      }
    ]

    data.push(rowData)
  }
  return data
}

const tableData = ref(generateData())
const expandedKeys = ref<(string | number)[]>([])
const tableRef = ref()

// Row component used to render expand content
function Row({ cells, rowData }: any) {
  if (rowData.detail) {
    return h('div', {
      class: 'z-table-expand-content',
      style: { padding: '20px', backgroundColor: '#f5f7fa' }
    }, [
      h('h4', {}, `${rowData.parentData?.name} details`),
      h('div', { style: { marginTop: '16px' } }, [
        h('p', {}, [
          h('strong', {}, () => 'Description: '),
          rowData.detail
        ]),
        h('p', {}, [
          h('strong', {}, () => 'Created at: '),
          rowData.parentData?.createTime
        ]),
        h('p', {}, [
          h('strong', {}, () => 'Status: '),
          h('span', {
            style: {
              color: rowData.parentData?.status === 'active' ? '#67C23A' : '#E6A23C',
              fontWeight: 'bold'
            }
          }, rowData.parentData?.status === 'active' ? 'Active' : 'Inactive')
        ])
      ])
    ])
  }
  return cells
}

Row.inheritAttrs = false

// Column config
const columns = [
  {
    type: 'expand',
    width: 60
  },
  {
    prop: 'id',
    label: 'ID',
  },
  {
    prop: 'name',
    label: 'Name',
  },
  {
    prop: 'email',
    label: 'Email',
  },
  {
    prop: 'age',
    label: 'Age',
  },
  {
    prop: 'department',
    label: 'Department',
  },
  {
    prop: 'salary',
    label: 'Salary',
    render: ({ row }: any) => `¥${row.salary.toLocaleString()}`
  }
]

// Expanded row key change
function handleExpandedRowsChange(_keys: (string | number)[]) {
  // console.log('Expanded row keys changed (expanded-rows-change):', _keys)
}

// Row expand event
function handleRowExpand(_params: any) {
  // console.log('Row expand event (row-expand):', _params)
}

// Expand all
function expandAll() {
  expandedKeys.value = tableData.value.map(row => row.id)
}

// Collapse all
function collapseAll() {
  expandedKeys.value = []
}

// Expand first five rows
function expandFirst5() {
  expandedKeys.value = tableData.value.slice(0, 5).map(row => row.id)
}
</script>

<style scoped>
.z-table-expand-content {
  background-color: #f5f7fa;
}
</style>

索引列

虚拟表格完全支持索引列功能。

功能特点:

  • 索引列配置{ type: 'index' } 列配置
  • 自定义起始索引:支持数字类型的index属性
  • 函数索引:支持函数类型的index属性自定义显示逻辑

Virtual Table Index Column Demo

The virtual table fully supports index columns, including numeric indexes and custom index functions.

<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, ref } from 'vue'

interface RowData {
  id: number
  name: string
  email: string
  department: string
  position: string
  salary: number
}

// Generate mock data
function generateData(count: number): RowData[] {
  const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
  const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
  const positions = ['Engineer', 'Product Manager', 'Designer', 'Operations Specialist', 'Marketing Specialist']
  const data: RowData[] = []

  for (let i = 0; i < count; i++) {
    data.push({
      id: i + 1,
      name: `${names[i % names.length]}-${i + 1}`,
      email: `user${i + 1}@example.com`,
      department: departments[i % departments.length],
      position: positions[i % positions.length],
      salary: Math.floor(Math.random() * 50000) + 8000,
    })
  }
  return data
}

const tableData = ref<RowData[]>(generateData(2000))
const indexType = ref('number')

const columns = computed(() => [
  {
    type: 'index',
    label: 'Index',
    index: indexType.value === 'number' ? 1 :
           indexType.value === 'function' ? (index: number) => `No.${index + 1}` :
           undefined,
  },
  {
    prop: 'id',
    label: 'ID',
  },
  {
    prop: 'name',
    label: 'Name',
  },
  {
    prop: 'email',
    label: 'Email',
  },
  {
    prop: 'department',
    label: 'Department',
  },
  {
    prop: 'position',
    label: 'Position',
  },
  {
    prop: 'salary',
    label: 'Salary',
    render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
  },
])

// Virtual scrolling config
const virtualConfig = computed(() => ({
  enabled: true,
  itemHeight: 48,
  threshold: 100,
}))

function changeIndexType(type: string) {
  indexType.value = type
}
</script>

<template>
  <div>
    <div style="margin-bottom: 16px;">
      <h3>Virtual Table Index Column Demo</h3>
      <p style="margin: 8px 0; color: #666;">
        The virtual table fully supports index columns, including numeric indexes and custom index functions.
      </p>

      <el-space wrap>
        <span>Index type:</span>
        <el-radio-group v-model="indexType" @change="changeIndexType">
          <el-radio-button value="number">Numeric index</el-radio-button>
          <el-radio-button value="function">Function index</el-radio-button>
          <el-radio-button value="default">Default index</el-radio-button>
        </el-radio-group>
      </el-space>
    </div>

    <z-table
      v-model:data="tableData"
      :columns="columns"
      :virtual="virtualConfig"
      height="500px"
      border
      stripe
    />
  </div>
</template>

<style scoped>
ul {
  padding-left: 20px;
  margin: 0;
}

li {
  margin: 4px 0;
}
</style>

可编辑表格

测试大数据量下的表格编辑功能,支持多种表单组件。

Editable table demo (800 rows)

💡 Hint:

  • Supports numerous form components: input, select, number input, date picker, etc.
  • All edits sync to the data model in real time.
  • Virtual scrolling keeps editing smooth with large datasets.
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { ref } from 'vue'

// Generate a large dataset
function generateLargeData(count: number) {
  const names = ['Steven', 'Helen', 'Nancy', 'Jack', 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
  const genders = ['1', '2']
  const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
  const data = []

  for (let i = 0; i < count; i++) {
    data.push({
      id: i + 1,
      name: `${names[i % names.length]}-${i + 1}`,
      gender: genders[i % genders.length],
      age: 18 + (i % 50),
      time: `202${(i % 4) + 0}-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
      salary: Math.floor(Math.random() * 50000) + 5000,
      department: departments[i % departments.length],
    })
  }
  return data
}

const tableData = ref(generateLargeData(800))

const columns = ref([
  {
    prop: 'id',
    label: 'ID',
    width: 100,
  },
  {
    component: 'input',
    prop: 'name',
    label: 'Name',
    fieldProps: {
      placeholder: 'Please enter a name',
    },
  },
  {
    component: 'select',
    prop: 'gender',
    label: 'Gender',
    fieldProps: {
      placeholder: 'Please select a gender',
    },
  },
  {
    component: 'el-input-number',
    prop: 'age',
    label: 'Age',
    fieldProps: {
      min: 18,
      max: 65,
    },
  },
  {
    component: 'el-input-number',
    prop: 'salary',
    label: 'Salary',
    fieldProps: {
      min: 0,
      controls: false,
    },
  },
  {
    component: 'select',
    prop: 'department',
    label: 'Department',
  },
  {
    component: 'el-date-picker',
    prop: 'time',
    label: 'Hire Date',
    fieldProps: {
      valueFormat: 'YYYY-MM-DD',
      placeholder: 'Select date',
    },
  },
])

const options = {
  gender: [
    { label: 'Male', value: '1' },
    { label: 'Female', value: '2' },
  ],
  department: [
    { label: 'Engineering', value: 'Engineering' },
    { label: 'Product', value: 'Product' },
    { label: 'Design', value: 'Design' },
    { label: 'Operations', value: 'Operations' },
    { label: 'Marketing', value: 'Marketing' },
  ],
}

function handleSave() {
  console.log('Save table data:', tableData.value)
}

function handleReset() {
  tableData.value = generateLargeData(800)
  console.log('Data reset complete')
}
</script>

<template>
  <div>
    <div style="margin-bottom: 16px;">
      <h4>Editable table demo (800 rows)</h4>
      <el-space>
        <el-button type="primary" @click="handleSave">
          Save data
        </el-button>
        <el-button @click="handleReset">
          Reset data
        </el-button>
      </el-space>
    </div>

    <z-table
      v-model:data="tableData"
      :columns="columns"
      :options="options"
      :editable="true"
      :virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
      height="500px"
      border
      stripe
    />

    <div style="margin-top: 16px; color: #666; font-size: 14px;">
      <p>💡 Hint:</p>
      <ul>
        <li>Supports numerous form components: input, select, number input, date picker, etc.</li>
        <li>All edits sync to the data model in real time.</li>
        <li>Virtual scrolling keeps editing smooth with large datasets.</li>
      </ul>
    </div>
  </div>
</template>

表单组件支持

虚拟表格完全支持各种表单组件,包括输入框、选择器、单选框、多选框、日期选择器、开关等。

Virtual Table Form Components Demo

Form component mapping:

  • Name: input field
  • Gender: select dropdown
  • Age: input-number
  • Department: multi-select
  • Rating: radio group
  • Skills: checkbox group
  • Hire date: date picker
  • Status: switch
<!-- eslint-disable no-console -->
<template>
  <div class="virtual-form-components-demo">
    <h2>Virtual Table Form Components Demo</h2>

    <div style="margin-bottom: 16px;">
      <ElSpace>
        <ElButton @click="generateData(1000)">Generate 1,000 rows</ElButton>
        <ElButton @click="generateData(5000)">Generate 5,000 rows</ElButton>
        <ElButton @click="validateData">Validate data</ElButton>
        <ElButton @click="showSelected">Show selected data</ElButton>
      </ElSpace>
    </div>

    <ZTable
      ref="tableRef"
      :data="tableData"
      :columns="columns"
      :virtual="true"
      :options="formOptions"
      height="600px"
      @update:data="handleDataUpdate"
      @selection-change="handleSelectionChange"
    />

    <div style="margin-top: 16px;">
      <h3>Form component mapping:</h3>
      <ul>
        <li><strong>Name</strong>: input field</li>
        <li><strong>Gender</strong>: select dropdown</li>
        <li><strong>Age</strong>: input-number</li>
        <li><strong>Department</strong>: multi-select</li>
        <li><strong>Rating</strong>: radio group</li>
        <li><strong>Skills</strong>: checkbox group</li>
        <li><strong>Hire date</strong>: date picker</li>
        <li><strong>Status</strong>: switch</li>
      </ul>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElButton, ElMessage, ElSpace } from 'element-plus'

const tableRef = ref()
const tableData = ref<any[]>([])

// Form option config
const formOptions = computed(() => ({
  gender: [
    { label: 'Male', value: 'male' },
    { label: 'Female', value: 'female' },
    { label: 'Other', value: 'other' }
  ],
  department: [
    { label: 'Engineering', value: 'tech' },
    { label: 'Product', value: 'product' },
    { label: 'Design', value: 'design' },
    { label: 'Operations', value: 'operation' },
    { label: 'Marketing', value: 'marketing' }
  ],
  rating: [
    { label: 'Level A', value: 'A' },
    { label: 'Level B', value: 'B' },
    { label: 'Level C', value: 'C' },
    { label: 'Level D', value: 'D' }
  ],
  skills: [
    { label: 'JavaScript', value: 'js' },
    { label: 'Vue', value: 'vue' },
    { label: 'React', value: 'react' },
    { label: 'Node.js', value: 'nodejs' },
    { label: 'Python', value: 'python' }
  ]
}))

// Table column config
const columns = ref([
  {
    type: 'selection',
    label: 'Selection',
  },
  {
    type: 'index',
    label: 'Index',
    index: 1,
  },
  {
    prop: 'name',
    label: 'Name',
    component: 'input',
    required: true,
    fieldProps: {
      placeholder: 'Please enter a name',
    },
  },
  {
    prop: 'gender',
    label: 'Gender',
    component: 'select',
    fieldProps: {
      placeholder: 'Please select a gender',
    },
  },
  {
    prop: 'age',
    label: 'Age',
    component: 'el-input-number',
    fieldProps: {
      min: 18,
      max: 100,
      placeholder: 'Please enter an age',
    },
  },
  // {
  //   prop: 'department',
  //   label: 'Department',
  //   component: 'select',
  //   fieldProps: {
  //     placeholder: 'Please select departments',
  //     multiple: true,
  //     appendToBody: true,
  //   },
  // },
  {
    prop: 'rating',
    label: 'Rating',
    component: 'radio',
    fieldProps: {
      placeholder: 'Please select a rating',
    },
  },
  {
    prop: 'skills',
    label: 'Skills',
    component: 'checkbox',
    fieldProps: {
      placeholder: 'Please select skills',
    },
  },
  // {
  //   prop: 'joinDate',
  //   label: 'Hire date',
  //   component: 'el-date-picker',
  //   fieldProps: {
  //     type: 'date',
  //     placeholder: 'Please select a hire date',
  //     format: 'YYYY-MM-DD',
  //     valueFormat: 'YYYY-MM-DD',
  //   },
  // },
  {
    prop: 'status',
    label: 'Status',
    component: 'el-switch',
    fieldProps: {
      activeText: 'Active',
      inactiveText: 'Inactive',
    },
  },
])

// Generate mock data
function generateData(count: number) {
  const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah']
  const genders = ['male', 'female', 'other']
  const departments = ['tech', 'product', 'design', 'operation', 'marketing']
  const ratings = ['A', 'B', 'C', 'D']
  const skills = ['js', 'vue', 'react', 'nodejs', 'python']

  const data = []
  for (let i = 0; i < count; i++) {
    data.push({
      id: i + 1,
      name: `${names[i % names.length]}_${i + 1}`,
      gender: genders[i % genders.length],
      age: Math.floor(Math.random() * 40) + 20,
      department: [departments[i % departments.length], departments[(i + 1) % departments.length]],
      rating: ratings[i % ratings.length],
      skills: [skills[i % skills.length], skills[(i + 1) % skills.length]],
      joinDate: `2020-${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
      status: Math.random() > 0.5,
      __isEdit: false,
    })
  }

  tableData.value = data
}

// Handle data updates
function handleDataUpdate(newData: any[]) {
  tableData.value = newData
  // console.log('Data updated:', newData)
}

// Validate data
function validateData() {
  const invalidData = tableData.value.filter(item =>
    !item.name || !item.gender || !item.age || !item.joinDate
  )

  if (invalidData.length > 0) {
    ElMessage.error(`Found ${invalidData.length} invalid rows`)
    // console.log('Invalid rows:', invalidData)
  } else {
    ElMessage.success('All rows passed validation')
  }
}

// Show selected data
function showSelected() {
  // eslint-disable-next-line no-console
  console.log('Table data', tableData.value)
}

function handleSelectionChange(selection: any[]) {
  // eslint-disable-next-line no-console
  console.log('Selected rows', selection)
}

// Initialize data
generateData(1000)
</script>

<style scoped>
.virtual-form-components-demo {
  padding: 20px;
}

h2 {
  margin-bottom: 20px;
  color: #333;
}

h3 {
  margin-bottom: 10px;
  color: #666;
}

ul {
  padding-left: 20px;
  margin: 0;
}

li {
  margin-bottom: 5px;
  color: #666;
}
</style>

列显隐功能

测试动态显示和隐藏列的功能,支持函数式和静态配置。

功能特点:

  • 🔄 动态显示/隐藏列
  • 🎯 函数式配置:hide: () => boolean
  • 🔧 静态配置:hide: boolean

Column visibility demo (2,000 rows)

<script lang="ts" setup>
import { ref } from 'vue'

const isHide = ref(false)

// Generate large dataset
function generateLargeData(count: number) {
  const names = ['Steven', 'Helen', 'Nancy', 'Jack', 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
  const genders = ['male', 'female']
  const data = []

  for (let i = 0; i < count; i++) {
    data.push({
      id: i + 1,
      name: `${names[i % names.length]}-${i + 1}`,
      gender: genders[i % genders.length],
      age: 18 + (i % 50),
      time: `202${(i % 4) + 0}-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
    })
  }
  return data
}

const tableData = ref(generateLargeData(2000))

const columns = ref([
  {
    prop: 'id',
    label: 'ID',
    width: 200,
  },
  {
    prop: 'name',
    label: 'Name',
    hide: () => isHide.value,
    width: 200
  },
  {
    prop: 'gender',
    label: 'Gender',
    hide: true, // Hidden by default
    width: 200
  },
  {
    prop: 'age',
    label: 'Age',
    width: 200
  },
  {
    prop: 'time',
    label: 'Date',
    width: 200
  },
])

function changeVisible() {
  isHide.value = !isHide.value
}

function toggleGender() {
  const genderColumn = columns.value.find(col => col.prop === 'gender')
  if (genderColumn) {
    genderColumn.hide = !genderColumn.hide
  }
}
</script>

<template>
  <div>
    <div style="margin-bottom: 16px;">
      <h4>Column visibility demo (2,000 rows)</h4>
      <el-space>
        <el-button @click="changeVisible">
          {{ isHide ? 'Show' : 'Hide' }} Name column
        </el-button>
        <el-button @click="toggleGender">
          Toggle Gender column
        </el-button>
      </el-space>
    </div>

    <z-table
      :data="tableData"
      :columns="columns"
      :virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
      height="400px"
      border
      stripe
    />
  </div>
</template>

列提示功能

虚拟表格完全支持表头tooltip功能,与z-table普通表格的tooltip实现保持一致。

功能特点:

  • 字符串提示tooltip: '提示内容'
  • 函数提示tooltip: (scope) => '动态内容',支持传递scope参数
  • 对象配置tooltip: { content: '内容', placement: 'top', effect: 'dark' }

Virtual Table – Column Tooltip Demo

<script lang="ts" setup>
import { ref } from 'vue'

// Generate mock data
function generateData() {
  const departments = ['Engineering', 'Marketing', 'Product', 'Operations', 'Design']
  const levels = ['Junior', 'Mid-level', 'Senior', 'Principal']
  const data = []
  for (let i = 1; i <= 2000; i++) {
    data.push({
      id: i,
      name: `User ${i}`,
      email: `user${i}@example.com`,
      age: 20 + (i % 50),
      department: departments[i % departments.length],
      salary: 5000 + (i % 100) * 100,
      level: levels[i % levels.length],
      status: i % 3 === 0 ? 'Active' : 'Departed',
      joinDate: new Date(2020 + (i % 4), (i % 12), (i % 28) + 1).toLocaleDateString(),
      performance: Math.floor(Math.random() * 100),
    })
  }
  return data
}

const tableData = ref(generateData())

// Aggregated stats
const totalUsers = tableData.value.length
const avgSalary = Math.round(tableData.value.reduce((sum, item) => sum + item.salary, 0) / totalUsers)
const departmentCount = new Set(tableData.value.map(item => item.department)).size

// Column config
const columns = [
  {
    type: 'index',
    label: 'Index',
    tooltip: 'Displays the row index starting from 1.'
  },
  {
    prop: 'name',
    label: 'User Name',
    tooltip: 'The user’s display name for identification.'
  },
  {
    prop: 'email',
    label: 'Email Address',
    tooltip: {
      content: 'User email address used for contact and notifications.',
      placement: 'bottom',
      effect: 'light'
    }
  },
  {
    prop: 'age',
    label: 'Age',
    tooltip: (scope: any) => {
      const minAge = Math.min(...tableData.value.map(item => item.age))
      const maxAge = Math.max(...tableData.value.map(item => item.age))
      return `Age range: ${minAge} - ${maxAge} (column index: ${scope.$index})`
    }
  },
  {
    prop: 'department',
    label: 'Department',
    tooltip: {
      content: (scope: any) => {
        return `There are ${departmentCount} departments (column: ${scope.column.prop}).`
      },
      placement: 'top-start',
      effect: 'dark'
    }
  },
  {
    prop: 'salary',
    label: 'Salary',
    tooltip: (scope: any) => {
      return `Average salary: ¥${avgSalary.toLocaleString()} (column index: ${scope.$index})`
    }
  },
  {
    prop: 'level',
    label: 'Level',
    tooltip: {
      content: 'Employee seniority level: junior, mid-level, senior, or principal.',
      placement: 'right',
      showAfter: 500,
      hideAfter: 100
    }
  },
  {
    prop: 'status',
    label: 'Employment Status',
    tooltip: {
      content: (scope: any) => {
        const activeCount = tableData.value.filter(item => item.status === 'Active').length
        const inactiveCount = totalUsers - activeCount
        return `Active: ${activeCount} · Departed: ${inactiveCount} (column: ${scope.column.label})`
      },
      placement: 'left',
      effect: 'light'
    }
  },
  {
    prop: 'joinDate',
    label: 'Hire Date',
    tooltip: 'Date the employee joined the company.'
  },
  {
    prop: 'performance',
    label: 'Performance Score',
    tooltip: {
      content: (scope: any) => {
        const avgPerformance = Math.round(
          tableData.value.reduce((sum, item) => sum + item.performance, 0) / totalUsers
        )
        return `Average performance: ${avgPerformance} (column index: ${scope.$index})`
      },
      placement: 'bottom-end',
      effect: 'dark',
      showAfter: 300
    }
  }
]
</script>

<template>
  <div>
    <h3>Virtual Table – Column Tooltip Demo</h3>

    <el-alert
      title="✅ Column tooltip support"
      type="success"
      :closable="false"
      style="margin-bottom: 16px;"
    >
      <template #default>
        <p><strong>✅ String tooltips</strong>: Provide a literal string as tooltip content.</p>
        <p><strong>✅ Function tooltips</strong>: Generate tooltip text dynamically with full scope access.</p>
        <p><strong>✅ Object config</strong>: Configure placement, theme, delay, and more.</p>
        <p><strong>✅ Scope payload</strong>: Functions receive <code>scope.column</code> and <code>scope.$index</code>.</p>
      </template>
    </el-alert>

    <z-table
      :data="tableData"
      :columns="columns"
      :virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
      height="500px"
      border
      stripe
      row-key="id"
    />
  </div>
</template>

<style scoped>
:deep(.el-collapse-item__content) {
  padding: 12px !important;
}
</style>

水印功能

虚拟表格完全兼容z-table组件的水印功能。

功能特点:

  • 字符串水印:直接传入字符串作为水印内容
  • 对象水印:传入配置对象,自定义水印的字体、颜色、角度、间距等

Virtual Table – Watermark Support

Feature notes:

  • String watermark: Supply a literal string.
  • Object watermark: Provide a configuration object.
  • Virtual scroll: Watermarks render above table rows.
  • Performance: Watermarks do not impact scrolling performance.

Current configuration:

false
<template>
  <div>
    <h3>Virtual Table – Watermark Support</h3>

    <div style="margin-bottom: 16px;">
      <el-space>
        <el-button @click="toggleWatermark">
          {{ currentWatermarkType === 'none' ? 'Enable string watermark' : currentWatermarkType === 'string' ? 'Switch to object watermark' : 'Disable watermark' }}
        </el-button>
        <el-text type="info">Current mode: {{ watermarkModeText }}</el-text>
      </el-space>
    </div>

    <el-alert
      title="✅ Watermarks work with virtual tables"
      type="success"
      :closable="false"
      style="margin-bottom: 16px;"
    >
      <template #default>
        <p><strong>✅ String watermark</strong>: Provide a simple string as the watermark.</p>
        <p><strong>✅ Object watermark</strong>: Configure font, color, angle, spacing, and more.</p>
        <p><strong>✅ Dynamic switching</strong>: Toggle or update the watermark at runtime.</p>
        <p><strong>✅ Virtual scroll compatible</strong>: Watermarks render correctly over virtualized content.</p>
      </template>
    </el-alert>

    <z-table
      :data="tableData"
      :columns="columns"
      :virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
      :watermark="currentWatermark"
      height="500px"
      border
      stripe
      row-key="id"
    />

    <div style="margin-top: 16px;">
      <h4>Feature notes:</h4>
      <ul>
        <li><strong>String watermark</strong>: Supply a literal string.</li>
        <li><strong>Object watermark</strong>: Provide a configuration object.</li>
        <li><strong>Virtual scroll</strong>: Watermarks render above table rows.</li>
        <li><strong>Performance</strong>: Watermarks do not impact scrolling performance.</li>
      </ul>
    </div>

    <div style="margin-top: 16px;">
      <h4>Current configuration:</h4>
      <pre style="padding: 12px; font-size: 12px; background: #f5f7fa; border-radius: 4px;">{{ JSON.stringify(currentWatermark, null, 2) }}</pre>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'

// Generate mock data
function generateLargeData(count: number) {
  const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
  const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing', 'Sales', 'HR', 'Finance']
  const data = []

  for (let i = 0; i < count; i++) {
    data.push({
      id: i + 1,
      name: `${names[i % names.length]} ${Math.floor(i / names.length) + 1}`,
      email: `user${i + 1}@company.com`,
      age: 22 + (i % 40),
      department: departments[i % departments.length],
      salary: 5000 + Math.floor(Math.random() * 10000),
      status: i % 3 === 0 ? 'active' : 'inactive',
      joinDate: `2023-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`
    })
  }
  return data
}

const tableData = ref(generateLargeData(2000))

const columns = ref([
  {
    type: 'selection',
  },
  {
    type: 'index',
    label: 'Index',
  },
  {
    prop: 'name',
    label: 'Name',
  },
  {
    prop: 'email',
    label: 'Email',
  },
  {
    prop: 'age',
    label: 'Age',
  },
  {
    prop: 'department',
    label: 'Department',
  },
  {
    prop: 'salary',
    label: 'Salary',
    render: ({ row }: any) => `¥${row.salary.toLocaleString()}`
  },
  {
    prop: 'status',
    label: 'Status',
    render: ({ row }: any) => row.status === 'active' ? 'Active' : 'Inactive'
  },
  {
    prop: 'joinDate',
    label: 'Hire Date',
  }
])

// Watermark state
const currentWatermarkType = ref<'none' | 'string' | 'object'>('none')

const currentWatermark = computed(() => {
  switch (currentWatermarkType.value) {
    case 'string':
      return 'ideaz-element virtual table watermark'
    case 'object':
      return {
        content: 'ideaz-element',
        fontColor: 'rgba(0, 0, 0, 0.15)',
        rotate: -20,
        gap: [100, 100],
        offset: [50, 50]
      }
    default:
      return false
  }
})

const watermarkModeText = computed(() => {
  switch (currentWatermarkType.value) {
    case 'string':
      return 'String watermark'
    case 'object':
      return 'Object watermark'
    default:
      return 'No watermark'
  }
})

function toggleWatermark() {
  if (currentWatermarkType.value === 'none') {
    currentWatermarkType.value = 'string'
  } else if (currentWatermarkType.value === 'string') {
    currentWatermarkType.value = 'object'
  } else {
    currentWatermarkType.value = 'none'
  }
}
</script>

虚拟表格完全支持 Element Plus TableV2 的 footer 功能。

功能特点:

  • Footer 插槽:通过 #footer 插槽自定义底部内容
  • 高度配置:通过 virtual.footerHeight 配置 footer 区域高度

Virtual Table Footer Demo

The virtual table exposes a footer slot so you can display aggregated statistics or any custom content.

<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, h, ref } from 'vue'

interface RowData {
  id: number
  name: string
  department: string
  salary: number
  bonus: number
  total: number
  status: string
}

// Generate mock data
function generateData(count: number): RowData[] {
  const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
  const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
  const statuses = ['Active', 'Departed', 'Probation']
  const data: RowData[] = []

  for (let i = 0; i < count; i++) {
    const salary = Math.floor(Math.random() * 30000) + 8000
    const bonus = Math.floor(Math.random() * 5000) + 1000
    data.push({
      id: i + 1,
      name: `${names[i % names.length]}-${i + 1}`,
      department: departments[i % departments.length],
      salary,
      bonus,
      total: salary + bonus,
      status: statuses[i % statuses.length],
    })
  }
  return data
}

const tableData = ref<RowData[]>(generateData(1000))
const footerHeight = ref(80)
const showFooter = ref(true)

const columns = ref([
  {
    prop: 'id',
    label: 'ID',
  },
  {
    prop: 'name',
    label: 'Name',
  },
  {
    prop: 'department',
    label: 'Department',
  },
  {
    prop: 'salary',
    label: 'Base Salary',
    render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
  },
  {
    prop: 'bonus',
    label: 'Bonus',
    render: ({ row }: any) => `¥${row.bonus.toLocaleString()}`,
  },
  {
    prop: 'total',
    label: 'Total Income',
    render: ({ row }: any) => h('span', {
      style: { color: '#67c23a', fontWeight: 'bold' }
    }, `¥${row.total.toLocaleString()}`),
  },
  {
    prop: 'status',
    label: 'Status',
    render: ({ row }: any) => h('span', {
      style: {
        color: row.status === 'Active' ? '#67c23a' : row.status === 'Probation' ? '#e6a23c' : '#f56c6c',
        fontWeight: 'bold'
      }
    }, row.status),
  },
])

// Aggregate stats
const statistics = computed(() => {
  const validData = tableData.value.filter(item => item.status !== 'Departed')
  return {
    totalEmployees: validData.length,
    totalSalary: validData.reduce((sum, item) => sum + item.salary, 0),
    totalBonus: validData.reduce((sum, item) => sum + item.bonus, 0),
    totalIncome: validData.reduce((sum, item) => sum + item.total, 0),
    averageSalary: Math.round(validData.reduce((sum, item) => sum + item.salary, 0) / validData.length),
  }
})

function resetData() {
  tableData.value = generateData(1000)
}
</script>

<template>
  <div>
    <div style="margin-bottom: 16px;">
      <h3>Virtual Table Footer Demo</h3>
      <p style="margin: 8px 0; color: #666;">
        The virtual table exposes a footer slot so you can display aggregated statistics or any custom content.
      </p>

      <el-space wrap>
        <el-button @click="showFooter = !showFooter" :type="showFooter ? 'danger' : 'primary'">
          {{ showFooter ? 'Hide' : 'Show' }} footer
        </el-button>
        <el-input-number
          v-model="footerHeight"
          :min="50"
          :max="200"
          :step="10"
          controls-position="right"
          style="width: 150px;"
        />
        <span style="color: #666;">Footer height: {{ footerHeight }}px</span>
        <el-button @click="resetData" type="success">
          Regenerate data
        </el-button>
      </el-space>
    </div>

    <z-table
      v-model:data="tableData"
      :columns="columns"
      :virtual="{
        enabled: true,
        itemHeight: 48,
        threshold: 50,
        footerHeight: showFooter ? footerHeight : 0
      }"
      height="500px"
      border
      stripe
    >
      <!-- Footer slot - statistics -->
      <template #footer v-if="showFooter">
        <div class="table-footer">
          <div class="footer-content">
                        <div class="statistics-section">
              <div class="stat-item">
                <div class="stat-info">
                  <div class="stat-value">{{ statistics.totalEmployees }}</div>
                  <div class="stat-label">Active employees</div>
                </div>
              </div>

              <div class="divider">|</div>

              <div class="stat-item">
                <div class="stat-info">
                  <div class="stat-value">¥{{ statistics.totalSalary.toLocaleString() }}</div>
                  <div class="stat-label">Total base salary</div>
                </div>
              </div>

              <div class="divider">|</div>

              <div class="stat-item">
                <div class="stat-info">
                  <div class="stat-value">¥{{ statistics.totalBonus.toLocaleString() }}</div>
                  <div class="stat-label">Total bonus</div>
                </div>
              </div>

              <div class="divider">|</div>

              <div class="stat-item">
                <div class="stat-info">
                  <div class="stat-value">¥{{ statistics.totalIncome.toLocaleString() }}</div>
                  <div class="stat-label">Total income</div>
                </div>
              </div>

              <div class="divider">|</div>

              <div class="stat-item">
                <div class="stat-info">
                  <div class="stat-value">¥{{ statistics.averageSalary.toLocaleString() }}</div>
                  <div class="stat-label">Average salary</div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </template>
    </z-table>
  </div>
</template>

<style scoped>
.table-footer {
  display: flex;
  align-items: center;
  height: 100%;
  padding: 0 20px;
  color: white;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.footer-content {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
}

.statistics-section {
  display: flex;
  gap: 16px;
  align-items: center;
}

.stat-item {
  display: flex;
  gap: 8px;
  align-items: center;
}

.divider {
  margin: 0 8px;
  font-size: 16px;
  color: rgba(255, 255, 255, 60%);
}

.stat-info {
  text-align: left;
}

.stat-value {
  font-size: 16px;
  font-weight: bold;
  line-height: 1;
}

.stat-label {
  margin-top: 2px;
  font-size: 12px;
  opacity: 80%;
}

.action-section {
  display: flex;
  gap: 8px;
}


</style>

自定义列渲染

验证列内容的自定义渲染,支持render函数和slot插槽两种方式。

Virtual Table – Column Customization

Current mode: Slot mode
<template>
  <div>
    <h3>Virtual Table – Column Customization</h3>

    <div style="margin-bottom: 16px;">
      <el-space>
        <el-button @click="toggleCustomMode">
          {{ useSlotMode ? 'Switch to Render mode' : 'Switch to Slot mode' }}
        </el-button>
        <el-text type="info">Current mode: {{ useSlotMode ? 'Slot mode' : 'Render function' }}</el-text>
      </el-space>
    </div>

    <el-alert
      title="✅ Column customization"
      type="success"
      :closable="false"
      style="margin-bottom: 16px;"
    >
      <template #default>
        <p><strong>✅ Render function</strong>: Customize column content via a render function</p>
        <p><strong>✅ Slot mode</strong>: Customize column content via slots</p>
        <p><strong>✅ Parameter passing</strong>: Full scope payload including row, column, cellData, $index, etc.</p>
        <p><strong>✅ Event support</strong>: Bind events and interactions inside custom content</p>
        <p><strong>✅ Component support</strong>: Works with any Element Plus component</p>
      </template>
    </el-alert>

    <z-table
      ref="tableRef"
      :data="tableData"
      :columns="columns"
      :virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
      height="500px"
      border
      stripe
      row-key="id"
    >
      <!-- Slot-based column customization -->
      <template #avatar-slot="scope">
        <div style="display: flex; gap: 8px; align-items: center;">
          <el-avatar :size="32" :src="scope.row.avatar" />
          <div>
            <div style="font-weight: bold;">{{ scope.row.name }}</div>
            <div style="font-size: 12px; color: #909399;">{{ scope.row.email }}</div>
          </div>
        </div>
      </template>

      <template #salary-slot="scope">
        <div @click="handleSalaryClick(scope)" style="cursor: pointer;">
          <el-tag
            :type="scope.row.salary > 8000 ? 'success' : scope.row.salary > 6000 ? 'warning' : 'danger'"
            effect="dark"
          >
            <el-icon style="margin-right: 4px;"><Money /></el-icon>
            ¥{{ scope.row.salary.toLocaleString() }}
          </el-tag>
        </div>
      </template>

      <template #status-slot="scope">
        <el-switch
          v-model="scope.row.status"
          active-value="active"
          inactive-value="inactive"
          active-text="Active"
          inactive-text="Inactive"
          style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
          @change="handleStatusChange(scope)"
        />
      </template>

      <template #progress-slot="scope">
        <div style="display: flex; gap: 8px; align-items: center;">
          <el-progress
            :percentage="scope.row.progress"
            :stroke-width="8"
            :show-text="false"
            style="min-width: 50px"
          />
          <span style="min-width: 35px; font-size: 12px; color: #606266;">
            {{ scope.row.progress }}%
          </span>
        </div>
      </template>

      <template #actions-slot="scope">
        <el-space size="small">
          <el-button
            size="small"
            type="primary"
            :icon="Edit"
            @click="handleEdit(scope)"
          >
            Edit
          </el-button>
          <el-button
            size="small"
            type="success"
            :icon="View"
            @click="handleView(scope)"
          >
            View
          </el-button>
          <el-popconfirm
            title="Are you sure you want to delete?"
            @confirm="handleDelete(scope)"
          >
            <template #reference>
              <el-button
                size="small"
                type="danger"
                :icon="Delete"
              >
                Delete
              </el-button>
            </template>
          </el-popconfirm>
        </el-space>
      </template>
    </z-table>

    <!-- Parameter logs -->
    <div style="margin-top: 16px;">
      <el-collapse v-model="activeCollapse">
        <el-collapse-item title="Latest parameter logs" name="1">
          <div style="max-height: 200px; padding: 12px; border-radius: 4px; background: #f5f7fa; overflow-y: auto;">
            <div v-for="(log, index) in paramLogs" :key="index" style="margin-bottom: 8px; font-family: monospace; font-size: 12px;">
              <span style="color: #909399;">[{{ log.timestamp }}]</span>
              <span style="font-weight: bold; color: #409eff;">{{ log.type }}:</span>
              <span style="color: #606266;">{{ log.message }}</span>
            </div>
          </div>
        </el-collapse-item>
      </el-collapse>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, h, ref } from 'vue'
import { Delete, Edit, Money, View } from '@element-plus/icons-vue'
import { ElAvatar, ElIcon, ElSwitch, ElProgress, ElSpace, ElButton, ElPopconfirm, ElTag } from 'element-plus'

// Generate mock data
function generateData() {
  const departments = ['Engineering', 'Marketing', 'Product', 'Operations', 'Design']
  const data = []
  for (let i = 1; i <= 2000; i++) {
    data.push({
      id: i,
      name: `User ${i}`,
      email: `user${i}@example.com`,
      age: 20 + (i % 50),
      department: departments[i % departments.length],
      salary: 5000 + (i % 100) * 100,
      status: i % 3 === 0 ? 'active' : 'inactive',
      progress: Math.floor(Math.random() * 100),
      avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=user${i}`,
    })
  }
  return data
}

const tableData = ref(generateData())
const tableRef = ref()
const useSlotMode = ref(true)
const activeCollapse = ref(['1'])
const paramLogs = ref<any[]>([])

// Append log entry
function addLog(type: string, message: string, params?: any) {
  const timestamp = new Date().toLocaleTimeString()
  paramLogs.value.unshift({
    timestamp,
    type,
    message,
    params
  })

  // Keep only the latest 20 logs
  if (paramLogs.value.length > 20) {
    paramLogs.value = paramLogs.value.slice(0, 20)
  }

  // eslint-disable-next-line no-console
  console.log(`[${type}]`, message, params)
}

// Column config
const columns = computed(() => [
  {
    type: 'index',
    label: 'Index',
  },
  {
    prop: 'name',
    label: 'User Info',
    // Choose render or slot mode
    ...(useSlotMode.value ? {
      slot: 'avatar-slot'
    } : {
      render: (scope: any) => {
        return h('div', {
          style: { display: 'flex', gap: '8px', alignItems: 'center' }
        }, [
          h(ElAvatar, {
            size: 32,
            src: scope.row.avatar
          }),
          h('div', {}, [
            h('div', { style: { fontWeight: 'bold' } }, scope.row.name),
            h('div', {
              style: { fontSize: '12px', color: '#909399' }
            }, scope.row.email)
          ])
        ])
      }
    })
  },
  {
    prop: 'department',
    label: 'Department',
  },
  {
    prop: 'age',
    label: 'Age',
  },
  {
    prop: 'salary',
    label: 'Salary',
    ...(useSlotMode.value ? {
      slot: 'salary-slot'
    } : {
      render: (scope: any) => {
        const getType = (salary: number) => {
          if (salary > 8000) return 'success'
          if (salary > 6000) return 'warning'
          return 'danger'
        }
        return h('div', {
          onClick: () => handleSalaryClick(scope),
          style: { cursor: 'pointer' }
        }, [
          h(ElTag, {
            type: getType(scope.row.salary),
            effect: 'dark'
          }, {
            default: () => [
              h(ElIcon, { style: { marginRight: '4px' } }, {
                default: () => h(Money)
              }),
              `¥${scope.row.salary.toLocaleString()}`
            ]
          })
        ])
      }
    })
  },
  {
    prop: 'status',
    label: 'Status',
    ...(useSlotMode.value ? {
      slot: 'status-slot'
    } : {
      render: (scope: any) => {
        return h(ElSwitch, {
          modelValue: scope.row.status,
          activeValue: 'active',
          inactiveValue: 'inactive',
          activeText: 'Active',
          inactiveText: 'Inactive',
          style: '--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949',
          'onUpdate:modelValue': (value: string) => {
            scope.row.status = value
            handleStatusChange(scope)
          }
        })
      }
    })
  },
  {
    prop: 'progress',
    label: 'Project Progress',
    ...(useSlotMode.value ? {
      slot: 'progress-slot'
    } : {
      render: (scope: any) => {
        return h('div', {
          style: { display: 'flex', gap: '8px', alignItems: 'center' }
        }, [
          h(ElProgress, {
            percentage: scope.row.progress,
            strokeWidth: 8,
            showText: false,
            style: { minWidth: '50px' }
          }),
          h('span', {
            style: { minWidth: '35px', fontSize: '12px', color: '#606266' }
          }, `${scope.row.progress}%`)
        ])
      }
    })
  },
  {
    prop: 'actions',
    label: 'Actions',
    fixed: 'right',
    ...(useSlotMode.value ? {
      slot: 'actions-slot'
    } : {
      render: (scope: any) => {
        return h(ElSpace, { size: 'small' }, {
          default: () => [
            h(ElButton, {
              size: 'small',
              type: 'primary',
              icon: Edit,
              onClick: () => handleEdit(scope)
            }, {
              default: () => 'Edit'
            }),
            h(ElButton, {
              size: 'small',
              type: 'success',
              icon: View,
              onClick: () => handleView(scope)
            }, {
              default: () => 'View'
            }),
            h(ElPopconfirm, {
              title: 'Are you sure you want to delete?',
              onConfirm: () => handleDelete(scope)
            }, {
              reference: () => h(ElButton, {
                size: 'small',
                type: 'danger',
                icon: Delete
              }, {
                default: () => 'Delete'
              })
            })
          ]
        })
      }
    })
  }
])

// Event handlers
function handleSalaryClick(scope: any) {
  addLog('Slot event', `Clicked salary - row ${scope.$index}, user: ${scope.row.name}, salary: ${scope.row.salary}`, scope)
}

function handleStatusChange(scope: any) {
  addLog('Slot event', `Status changed - row ${scope.$index}, user: ${scope.row.name}, new status: ${scope.row.status}`, scope)
}

function handleEdit(scope: any) {
  addLog('Slot event', `Edit user - row ${scope.$index}, user: ${scope.row.name}`, scope)
}

function handleView(scope: any) {
  addLog('Slot event', `View user - row ${scope.$index}, user: ${scope.row.name}`, scope)
}

function handleDelete(scope: any) {
  addLog('Slot event', `Delete user - row ${scope.$index}, user: ${scope.row.name}`, scope)
}

function toggleCustomMode() {
  useSlotMode.value = !useSlotMode.value
  addLog('Mode toggle', `Switched to ${useSlotMode.value ? 'Slot' : 'Render'} mode`)
}
</script>

<style scoped>
:deep(.el-collapse-item__content) {
  padding: 12px !important;
}
</style>

自定义表头

测试表头的自定义功能,包括图标、徽章、下拉菜单等交互元素。

Virtual Table – Header Customization

<template>
  <div>
    <h3>Virtual Table – Header Customization</h3>

    <div style="margin-bottom: 16px;">
      <el-space>
        <el-button @click="toggleCustomMode">
          {{ useSlotMode ? 'Switch to Render mode' : 'Switch to Slot mode' }}
        </el-button>
        <el-text type="info">Current mode: {{ useSlotMode ? 'Slot mode' : 'Render function' }}</el-text>
      </el-space>
    </div>

    <el-alert
      title="✅ Header customization"
      type="success"
      :closable="false"
      style="margin-bottom: 16px;"
    >
      <template #default>
        <p><strong>✅ Render function</strong>: Customize headers via render functions</p>
        <p><strong>✅ Slot mode</strong>: Customize headers via slots</p>
        <p><strong>✅ Icon support</strong>: Icons, hints, badges, and other rich content</p>
        <p><strong>✅ Interaction support</strong>: Click handlers and interactive elements in headers</p>
        <p><strong>✅ Style customization</strong>: Full control over layout and styling</p>
      </template>
    </el-alert>

    <z-table
      ref="tableRef"
      :data="tableData"
      :columns="columns"
      :virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
      height="500px"
      border
      stripe
      row-key="id"
    >
      <!-- Slot-based header customization -->
      <template #user-header>
        <div style="display: flex; gap: 6px; align-items: center;">
          <el-icon color="#409eff" size="16"><User /></el-icon>
          <span>User info</span>
          <el-tooltip content="Includes user name, email, and other basic information" placement="top">
            <el-icon color="#909399" size="14" style="cursor: help;"><QuestionFilled /></el-icon>
          </el-tooltip>
        </div>
      </template>

      <template #department-header>
        <div style="display: flex; gap: 6px; align-items: center;">
          <el-icon color="#67c23a" size="16"><OfficeBuilding /></el-icon>
          <span>Department info</span>
          <el-badge :value="departmentCount" type="primary" :max="99">
            <span></span>
          </el-badge>
        </div>
      </template>

      <template #salary-header>
        <div
          style="display: flex; gap: 6px; align-items: center; cursor: pointer;"
          @click="handleSalaryHeaderClick"
        >
          <el-icon color="#f56c6c" size="16"><Money /></el-icon>
          <span>Salary</span>
          <el-tag size="small" type="warning">¥{{ avgSalary }}</el-tag>
        </div>
      </template>

      <template #status-header>
        <div style="display: flex; gap: 6px; align-items: center;">
          <el-icon color="#909399" size="16"><SwitchButton /></el-icon>
          <span>Employment status</span>
          <div style="display: flex; gap: 4px;">
            <el-tag size="small" type="success">{{ activeCount }} active</el-tag>
            <el-tag size="small" type="info">{{ inactiveCount }} inactive</el-tag>
          </div>
        </div>
      </template>

      <template #actions-header>
        <div style="display: flex; gap: 6px; align-items: center;">
          <el-icon color="#e6a23c" size="16"><Setting /></el-icon>
          <span>Actions</span>
          <el-dropdown @command="handleHeaderCommand">
            <el-icon style="cursor: pointer;" color="#409eff"><More /></el-icon>
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="export">Export data</el-dropdown-item>
                <el-dropdown-item command="import">Import data</el-dropdown-item>
                <el-dropdown-item command="settings">Column settings</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
        </div>
      </template>
    </z-table>

    <!-- Parameter logs -->
    <div style="margin-top: 16px;">
      <el-collapse v-model="activeCollapse">
        <el-collapse-item title="Latest parameter logs" name="1">
          <div style="max-height: 200px; padding: 12px; overflow-y: auto; background: #f5f7fa; border-radius: 4px;">
            <div v-for="(log, index) in paramLogs" :key="index" style="margin-bottom: 8px; font-family: monospace; font-size: 12px;">
              <span style="color: #909399;">[{{ log.timestamp }}]</span>
              <span style="font-weight: bold; color: #409eff;">{{ log.type }}:</span>
              <span style="color: #606266;">{{ log.message }}</span>
            </div>
          </div>
        </el-collapse-item>
      </el-collapse>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, h, ref } from 'vue'
import {
  Money,
  More,
  OfficeBuilding,
  QuestionFilled,
  Setting,
  SwitchButton,
  User
} from '@element-plus/icons-vue'
import { ElBadge, ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon, ElTag, ElTooltip } from 'element-plus'

// Generate mock data
function generateData() {
  const departments = ['Engineering', 'Marketing', 'Product', 'Operations', 'Design']
  const data = []
  for (let i = 1; i <= 2000; i++) {
    data.push({
      id: i,
      name: `User ${i}`,
      email: `user${i}@example.com`,
      age: 20 + (i % 50),
      department: departments[i % departments.length],
      salary: 5000 + (i % 100) * 100,
      status: i % 3 === 0 ? 'active' : 'inactive',
    })
  }
  return data
}

const tableData = ref(generateData())
const tableRef = ref()
const useSlotMode = ref(true)
const activeCollapse = ref(['1'])
const paramLogs = ref<any[]>([])

// Derived statistics
const departmentCount = computed(() => {
  const depts = new Set(tableData.value.map(item => item.department))
  return depts.size
})

const avgSalary = computed(() => {
  const total = tableData.value.reduce((sum, item) => sum + item.salary, 0)
  return Math.round(total / tableData.value.length)
})

const activeCount = computed(() => {
  return tableData.value.filter(item => item.status === 'active').length
})

const inactiveCount = computed(() => {
  return tableData.value.filter(item => item.status === 'inactive').length
})

// Append log entry
function addLog(type: string, message: string, params?: any) {
  const timestamp = new Date().toLocaleTimeString()
  paramLogs.value.unshift({
    timestamp,
    type,
    message,
    params
  })

  // Keep only the latest 20 logs
  if (paramLogs.value.length > 20) {
    paramLogs.value = paramLogs.value.slice(0, 20)
  }

  // eslint-disable-next-line no-console
  console.log(`[${type}]`, message, params)
}

// Column config
const columns = computed(() => [
  {
    type: 'index',
    label: 'Index',
  },
  {
    prop: 'name',
    // Choose header behavior based on mode
    ...(useSlotMode.value ? {
      label: 'user-header'
    } : {
      label: () => {
        return h('div', {
          style: { display: 'flex', gap: '6px', alignItems: 'center' }
        }, [
          h(ElIcon, { color: '#409eff', size: 16 }, {
            default: () => h(User)
          }),
          h('span', null, 'User info'),
          h(ElTooltip, {
            content: 'Includes user name, email, and other core details',
            placement: 'top'
          }, {
            default: () => h(ElIcon, {
              color: '#909399',
              size: 14,
              style: { cursor: 'help' }
            }, {
              default: () => h(QuestionFilled)
            })
          })
        ])
      }
    })
  },
  {
    prop: 'email',
    label: 'Email',
  },
  {
    prop: 'age',
    label: 'Age',
  },
  {
    prop: 'department',
    ...(useSlotMode.value ? {
      label: 'department-header'
    } : {
      label: () => {
        return h('div', {
          style: { display: 'flex', gap: '6px', alignItems: 'center' }
        }, [
          h(ElIcon, { color: '#67c23a', size: 16 }, {
            default: () => h(OfficeBuilding)
          }),
          h('span', null, 'Department info'),
          h(ElBadge, {
            value: departmentCount.value,
            type: 'primary',
            max: 99
          }, {
            default: () => h('span')
          })
        ])
      }
    })
  },
  {
    prop: 'salary',
    ...(useSlotMode.value ? {
      label: 'salary-header'
    } : {
      label: () => {
        return h('div', {
          style: { display: 'flex', gap: '6px', alignItems: 'center', cursor: 'pointer' },
          onClick: handleSalaryHeaderClick
        }, [
          h(ElIcon, { color: '#f56c6c', size: 16 }, {
            default: () => h(Money)
          }),
          h('span', null, 'Salary'),
          h(ElTag, {
            size: 'small',
            type: 'warning'
          }, {
            default: () => `¥${avgSalary.value}`
          })
        ])
      }
    })
  },
  {
    prop: 'status',
    ...(useSlotMode.value ? {
      label: 'status-header'
    } : {
      label: () => {
        return h('div', {
          style: { display: 'flex', gap: '6px', alignItems: 'center' }
        }, [
          h(ElIcon, { color: '#909399', size: 16 }, {
            default: () => h(SwitchButton)
          }),
          h('span', null, 'Employment status'),
          h('div', {
            style: { display: 'flex', gap: '4px' }
          }, [
            h(ElTag, {
              size: 'small',
              type: 'success'
            }, {
              default: () => `${activeCount.value} active`
            }),
            h(ElTag, {
              size: 'small',
              type: 'info'
            }, {
              default: () => `${inactiveCount.value} inactive`
            })
          ])
        ])
      }
    })
  },
  {
    prop: 'actions',
    fixed: 'right',
    ...(useSlotMode.value ? {
      label: 'actions-header'
    } : {
      label: () => {
        return h('div', {
          style: { display: 'flex', gap: '6px', alignItems: 'center' }
        }, [
          h(ElIcon, { color: '#e6a23c', size: 16 }, {
            default: () => h(Setting)
          }),
          h('span', null, 'Actions'),
          h(ElDropdown, {
            onCommand: handleHeaderCommand
          }, {
            default: () => h(ElIcon, {
              style: { cursor: 'pointer' },
              color: '#409eff'
            }, {
              default: () => h(More)
            }),
            dropdown: () => h(ElDropdownMenu, null, () => [
              h(ElDropdownItem, { command: 'export' }, {
                default: () => 'Export data'
              }),
              h(ElDropdownItem, { command: 'import' }, {
                default: () => 'Import data'
              }),
              h(ElDropdownItem, { command: 'settings' }, {
                default: () => 'Column settings'
              })
            ])
          })
        ])
      }
    })
  }
])

// Event handlers
function handleSalaryHeaderClick() {
  addLog('Header event', `Clicked salary header – average salary: ${avgSalary.value}`)
}

function handleHeaderCommand(command: string) {
  addLog('Header event', `Actions header dropdown – command: ${command}`)
}

function toggleCustomMode() {
  useSlotMode.value = !useSlotMode.value
  addLog('Mode toggle', `Switched to ${useSlotMode.value ? 'Slot' : 'Render'} mode`)
}
</script>

<style scoped>
:deep(.el-collapse-item__content) {
  padding: 12px !important;
}
</style>

方法和事件

测试表格的各种方法调用和事件处理。

Virtual table method tests

Virtual-scroll specific methods

<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { ref } from 'vue'

interface RowData {
  id: number
  name: string
  gender: string
  age: number
  time: string
}

// Generate a large dataset
function generateLargeData(count: number): RowData[] {
  const names = ['Steven', 'Helen', 'Nancy', 'Jack', 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
  const genders = ['male', 'female']
  const data: RowData[] = []

  for (let i = 0; i < count; i++) {
    data.push({
      id: i + 1,
      name: `${names[i % names.length]}-${i + 1}`,
      gender: genders[i % genders.length],
      age: 18 + (i % 50),
      time: `202${(i % 4) + 0}-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
    })
  }
  return data
}

const tableData = ref<RowData[]>(generateLargeData(1500))
const tableRef = ref()

const columns = ref([
  {
    type: 'selection',
  },
  {
    type: 'index',
  },
  {
    prop: 'id',
    label: 'ID',
  },
  {
    prop: 'name',
    label: 'Name',
  },
  {
    prop: 'gender',
    label: 'Gender',
  },
  {
    prop: 'age',
    label: 'Age',
    sortable: true,
  },
  {
    prop: 'time',
    label: 'Date',
  },
])

// Table method demos
function setCurrentRow() {
  const row = tableData.value[10]
  tableRef.value?.setCurrentRow(row)
  console.log('Set current row:', row)
}

function toggleRowSelection() {
  const row = tableData.value[5]
  tableRef.value?.toggleRowSelection(row, true)
  console.log('Toggle row selection:', row)
}

function clearSelection() {
  tableRef.value?.clearSelection()
  console.log('Clear selection')
}

function toggleAllSelection() {
  tableRef.value?.toggleAllSelection()
  console.log('Toggle select all')
}

function clearSort() {
  tableRef.value?.clearSort()
  console.log('Clear sort')
}

function scrollToTop() {
  tableRef.value?.scrollTo({ scrollTop: 0 })
  console.log('Scroll to top')
}

function scrollToBottom() {
  tableRef.value?.scrollTo({ scrollTop: 999999 })
  console.log('Scroll to bottom')
}

function scrollToRow() {
  const index = 500
  tableRef.value?.scrollToRow(index)
  console.log(`Scroll to row ${index + 1}`)
}

// Event handlers
function handleCurrentChange(currentRow: RowData, oldCurrentRow: RowData) {
  console.log('Current row changed:', currentRow, oldCurrentRow)
}

function handleSelectionChange(selection: RowData[]) {
  console.log('Selection changed:', selection.length, 'rows')
}

function handleSortChange({ column, prop, order }: any) {
  console.log('Sort changed:', { column, prop, order })
}
</script>

<template>
  <div>
    <div style="margin-bottom: 16px;">
      <h4>Virtual table method tests</h4>
      <el-button @click="setCurrentRow">Set current row (row 11)</el-button>
      <el-button @click="toggleRowSelection">Select row 6</el-button>
      <el-button @click="clearSelection">Clear selection</el-button>
      <el-button @click="toggleAllSelection">Toggle select all</el-button>
      <el-button @click="clearSort">Clear sorting</el-button>
    </div>

    <div style="margin-bottom: 16px;">
      <h4>Virtual-scroll specific methods</h4>
      <el-button @click="scrollToTop">Scroll to top</el-button>
      <el-button @click="scrollToBottom">Scroll to bottom</el-button>
      <el-button @click="scrollToRow">Scroll to row 501</el-button>
    </div>

    <z-table
      ref="tableRef"
      v-model:data="tableData"
      :columns="columns"
      :virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
      height="500px"
      row-key="id"
      highlight-current-row
      @current-change="handleCurrentChange"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
    />
  </div>
</template>

📖 配置参考

配置选项

参数类型默认值说明
enabledbooleanfalse是否启用虚拟滚动
itemHeightnumber48每行固定高度(px)
estimatedRowHeightnumber48动态高度时的预估行高
thresholdnumber100数据量超过此值自动启用
footerHeightnumber50Footer 区域高度(px)

API 方法

虚拟滚动专用方法

javascript
// 滚动到指定位置
tableRef.value.scrollTo({
  scrollTop: 1000,
  scrollLeft: 200
})

// 滚动到指定行
tableRef.value.scrollToRow(500, 'center')

通用表格方法

所有原有的表格方法在虚拟模式下依然可用:

javascript
// 设置当前行
tableRef.value.setCurrentRow(row)

// 切换行选择
tableRef.value.toggleRowSelection(row, true)

// 清空选择
tableRef.value.clearSelection()

// 排序
tableRef.value.sort('column', 'ascending')

使用建议

🎯 何时使用虚拟滚动

  • ✅ 数据量 > 1000 条
  • ✅ 需要一次性展示所有数据
  • ✅ 用户需要快速浏览和定位
  • ✅ 性能敏感的应用场景

⚠️ 注意事项

  1. 固定高度要求:

    • ✅ 必须设置 height 属性
    • ❌ 不支持动态行高
  2. 性能优化建议:

    • 💡 数据量 > 1000 条时启用
    • 💡 避免在列中使用过于复杂的组件
    • 💡 列配置无需手动指定 width,系统自动处理ß

Released under the MIT License.