Skip to content

useTable 表格管理

表格数据管理组合函数,提供分页、排序、筛选、导出等完整的表格操作功能。

📋 基础用法

简单表格

vue
<template>
  <div>
    <!-- 搜索表单 -->
    <el-form :model="queryParams" inline>
      <el-form-item label="用户名">
        <el-input
          v-model="queryParams.username"
          placeholder="请输入用户名"
          clearable
        />
      </el-form-item>
      <el-form-item label="状态">
        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
          <el-option label="正常" value="0" />
          <el-option label="停用" value="1" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="handleQuery">
          <el-icon><Search /></el-icon>
          搜索
        </el-button>
        <el-button @click="resetQuery">
          <el-icon><Refresh /></el-icon>
          重置
        </el-button>
      </el-form-item>
    </el-form>

    <!-- 操作按钮 -->
    <el-row :gutter="10" class="mb8">
      <el-col :span="1.5">
        <el-button type="primary" @click="handleAdd">
          <el-icon><Plus /></el-icon>
          新增
        </el-button>
      </el-col>
      <el-col :span="1.5">
        <el-button
          type="success"
          :disabled="!multipleSelection.length"
          @click="handleBatchEdit"
        >
          <el-icon><Edit /></el-icon>
          批量修改
        </el-button>
      </el-col>
      <el-col :span="1.5">
        <el-button
          type="danger"
          :disabled="!multipleSelection.length"
          @click="handleBatchDelete"
        >
          <el-icon><Delete /></el-icon>
          批量删除
        </el-button>
      </el-col>
      <el-col :span="1.5">
        <el-button type="info" @click="handleExport">
          <el-icon><Download /></el-icon>
          导出
        </el-button>
      </el-col>
    </el-row>

    <!-- 数据表格 -->
    <el-table
      v-loading="loading"
      :data="dataList"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
    >
      <el-table-column type="selection" width="55" />
      <el-table-column
        prop="id"
        label="ID"
        width="80"
        sortable="custom"
      />
      <el-table-column
        prop="username"
        label="用户名"
        sortable="custom"
        show-overflow-tooltip
      />
      <el-table-column prop="nickname" label="昵称" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column
        prop="status"
        label="状态"
        width="100"
      >
        <template #default="{ row }">
          <el-tag :type="row.status === '0' ? 'success' : 'danger'">
            {{ row.status === '0' ? '正常' : '停用' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column
        prop="createTime"
        label="创建时间"
        width="180"
        sortable="custom"
      />
      <el-table-column label="操作" width="200" fixed="right">
        <template #default="{ row }">
          <el-button
            type="primary"
            size="small"
            @click="handleEdit(row)"
          >
            编辑
          </el-button>
          <el-button
            type="danger"
            size="small"
            @click="handleDelete(row)"
          >
            删除
          </el-button>
        </template>
      </el-table-column>
    </el-table>

    <!-- 分页组件 -->
    <pagination
      v-show="total > 0"
      :total="total"
      v-model:page="queryParams.pageNum"
      v-model:limit="queryParams.pageSize"
      @pagination="getList"
    />
  </div>
</template>

<script setup lang="ts">
import { useTable } from '@/composables/use-table'
import { getUserList, deleteUser, batchDeleteUser } from '@/api/system/user'

// 查询参数
interface QueryParams {
  pageNum: number
  pageSize: number
  username?: string
  status?: string
  orderByColumn?: string
  isAsc?: string
}

const {
  // 数据状态
  loading,
  dataList,
  total,
  queryParams,
  multipleSelection,

  // 核心方法
  getList,
  handleQuery,
  resetQuery,
  handleSelectionChange,
  handleSortChange,

  // 导出功能
  handleExport
} = useTable<QueryParams>({
  // API配置
  fetchApi: getUserList,
  deleteApi: deleteUser,
  batchDeleteApi: batchDeleteUser,

  // 查询参数默认值
  defaultQuery: {
    pageNum: 1,
    pageSize: 10
  },

  // 导出配置
  exportConfig: {
    filename: '用户数据',
    api: exportUserList
  }
})

// 自定义操作
const handleAdd = () => {
  console.log('新增用户')
}

const handleEdit = (row: any) => {
  console.log('编辑用户:', row)
}

const handleDelete = (row: any) => {
  console.log('删除用户:', row)
}

const handleBatchEdit = () => {
  console.log('批量编辑:', multipleSelection.value)
}

const handleBatchDelete = () => {
  console.log('批量删除:', multipleSelection.value)
}

// 初始化数据
onMounted(() => {
  getList()
})
</script>

🎯 核心功能

useTable 实现

typescript
// composables/use-table.ts
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'

export interface TableConfig<T = any> {
  // API配置
  fetchApi: (params: any) => Promise<any>
  deleteApi?: (id: string | number) => Promise<any>
  batchDeleteApi?: (ids: (string | number)[]) => Promise<any>

  // 查询参数
  defaultQuery?: Partial<T>

  // 分页配置
  pagination?: {
    pageNum?: number
    pageSize?: number
    pageSizes?: number[]
  }

  // 导出配置
  exportConfig?: {
    filename?: string
    api?: (params: any) => Promise<any>
    headers?: string[]
    transform?: (data: any[]) => any[]
  }

  // 其他配置
  immediate?: boolean // 是否立即执行查询
  showMessage?: boolean // 是否显示操作提示
}

export function useTable<QueryParams = any>(config: TableConfig<QueryParams>) {
  const {
    fetchApi,
    deleteApi,
    batchDeleteApi,
    defaultQuery = {},
    pagination = {},
    exportConfig,
    immediate = true,
    showMessage = true
  } = config

  // 数据状态
  const loading = ref(false)
  const dataList = ref<any[]>([])
  const total = ref(0)
  const multipleSelection = ref<any[]>([])

  // 查询参数
  const queryParams = reactive({
    pageNum: pagination.pageNum || 1,
    pageSize: pagination.pageSize || 10,
    ...defaultQuery
  } as QueryParams & { pageNum: number; pageSize: number })

  // 分页大小选项
  const pageSizes = computed(() =>
    pagination.pageSizes || [10, 20, 50, 100]
  )

  /**
   * 获取数据列表
   */
  const getList = async (resetPage = false) => {
    if (resetPage) {
      queryParams.pageNum = 1
    }

    loading.value = true

    try {
      const response = await fetchApi(queryParams)

      if (response.code === 200) {
        dataList.value = response.data?.records || response.data || []
        total.value = response.data?.total || 0
      } else {
        if (showMessage) {
          ElMessage.error(response.msg || '获取数据失败')
        }
      }
    } catch (error) {
      console.error('获取数据失败:', error)
      if (showMessage) {
        ElMessage.error('获取数据失败')
      }
    } finally {
      loading.value = false
    }
  }

  /**
   * 查询
   */
  const handleQuery = () => {
    getList(true)
  }

  /**
   * 重置查询
   */
  const resetQuery = () => {
    Object.assign(queryParams, {
      pageNum: 1,
      pageSize: pagination.pageSize || 10,
      ...defaultQuery
    })
    getList()
  }

  /**
   * 选择变更
   */
  const handleSelectionChange = (selection: any[]) => {
    multipleSelection.value = selection
  }

  /**
   * 排序变更
   */
  const handleSortChange = (sort: { column: any; prop: string; order: string }) => {
    if (sort.order) {
      ;(queryParams as any).orderByColumn = sort.prop
      ;(queryParams as any).isAsc = sort.order === 'ascending' ? 'asc' : 'desc'
    } else {
      delete (queryParams as any).orderByColumn
      delete (queryParams as any).isAsc
    }
    getList()
  }

  /**
   * 删除单行
   */
  const handleDelete = async (row: any, idField = 'id') => {
    if (!deleteApi) {
      console.warn('未配置删除API')
      return
    }

    try {
      await ElMessageBox.confirm('确定删除这条记录吗?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      })

      loading.value = true
      const response = await deleteApi(row[idField])

      if (response.code === 200) {
        if (showMessage) {
          ElMessage.success('删除成功')
        }
        await getList()
      } else {
        if (showMessage) {
          ElMessage.error(response.msg || '删除失败')
        }
      }
    } catch (error) {
      if (error !== 'cancel') {
        console.error('删除失败:', error)
        if (showMessage) {
          ElMessage.error('删除失败')
        }
      }
    } finally {
      loading.value = false
    }
  }

  /**
   * 批量删除
   */
  const handleBatchDelete = async (idField = 'id') => {
    if (!batchDeleteApi) {
      console.warn('未配置批量删除API')
      return
    }

    if (multipleSelection.value.length === 0) {
      ElMessage.warning('请选择要删除的数据')
      return
    }

    try {
      await ElMessageBox.confirm(
        `确定删除选中的 ${multipleSelection.value.length} 条记录吗?`,
        '提示',
        {
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          type: 'warning'
        }
      )

      loading.value = true
      const ids = multipleSelection.value.map(item => item[idField])
      const response = await batchDeleteApi(ids)

      if (response.code === 200) {
        if (showMessage) {
          ElMessage.success('删除成功')
        }
        multipleSelection.value = []
        await getList()
      } else {
        if (showMessage) {
          ElMessage.error(response.msg || '删除失败')
        }
      }
    } catch (error) {
      if (error !== 'cancel') {
        console.error('批量删除失败:', error)
        if (showMessage) {
          ElMessage.error('批量删除失败')
        }
      }
    } finally {
      loading.value = false
    }
  }

  /**
   * 导出数据
   */
  const handleExport = async () => {
    if (!exportConfig?.api) {
      console.warn('未配置导出API')
      return
    }

    try {
      await ElMessageBox.confirm('确定导出数据吗?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'info'
      })

      loading.value = true

      // 导出全部数据(不分页)
      const exportParams = { ...queryParams }
      delete (exportParams as any).pageNum
      delete (exportParams as any).pageSize

      const response = await exportConfig.api(exportParams)

      // 处理文件下载
      const blob = new Blob([response], {
        type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
      })

      const url = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = url
      link.download = `${exportConfig.filename || '数据导出'}_${new Date().getTime()}.xlsx`
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      window.URL.revokeObjectURL(url)

      if (showMessage) {
        ElMessage.success('导出成功')
      }
    } catch (error) {
      if (error !== 'cancel') {
        console.error('导出失败:', error)
        if (showMessage) {
          ElMessage.error('导出失败')
        }
      }
    } finally {
      loading.value = false
    }
  }

  /**
   * 刷新数据
   */
  const refresh = () => {
    getList()
  }

  // 立即执行查询
  if (immediate) {
    onMounted(() => {
      getList()
    })
  }

  return {
    // 数据状态
    loading,
    dataList,
    total,
    queryParams,
    multipleSelection,
    pageSizes,

    // 核心方法
    getList,
    handleQuery,
    resetQuery,
    handleSelectionChange,
    handleSortChange,

    // 删除操作
    handleDelete,
    handleBatchDelete,

    // 导出功能
    handleExport,

    // 工具方法
    refresh
  }
}

🔄 高级功能

表格缓存

typescript
// composables/use-table-cache.ts
export function useTableCache(cacheKey: string) {
  const CACHE_DURATION = 5 * 60 * 1000 // 5分钟

  /**
   * 保存表格数据到缓存
   */
  const saveTableCache = (data: {
    list: any[]
    total: number
    queryParams: any
    timestamp: number
  }) => {
    const cacheData = {
      ...data,
      timestamp: Date.now()
    }
    sessionStorage.setItem(cacheKey, JSON.stringify(cacheData))
  }

  /**
   * 从缓存获取表格数据
   */
  const getTableCache = () => {
    try {
      const cached = sessionStorage.getItem(cacheKey)
      if (!cached) return null

      const data = JSON.parse(cached)

      // 检查缓存是否过期
      if (Date.now() - data.timestamp > CACHE_DURATION) {
        clearTableCache()
        return null
      }

      return data
    } catch (error) {
      console.error('获取表格缓存失败:', error)
      return null
    }
  }

  /**
   * 清除表格缓存
   */
  const clearTableCache = () => {
    sessionStorage.removeItem(cacheKey)
  }

  return {
    saveTableCache,
    getTableCache,
    clearTableCache
  }
}

// 带缓存的表格Hook
export function useTableWithCache<T>(
  cacheKey: string,
  config: TableConfig<T>
) {
  const { saveTableCache, getTableCache, clearTableCache } = useTableCache(cacheKey)

  const tableHook = useTable({
    ...config,
    immediate: false
  })

  // 扩展getList方法以支持缓存
  const originalGetList = tableHook.getList
  const getList = async (resetPage = false, useCache = true) => {
    // 如果重置页面或不使用缓存,直接请求
    if (resetPage || !useCache) {
      await originalGetList(resetPage)
      saveTableCache({
        list: tableHook.dataList.value,
        total: tableHook.total.value,
        queryParams: tableHook.queryParams,
        timestamp: Date.now()
      })
      return
    }

    // 尝试从缓存获取
    const cached = getTableCache()
    if (cached) {
      tableHook.dataList.value = cached.list
      tableHook.total.value = cached.total
      Object.assign(tableHook.queryParams, cached.queryParams)
      return
    }

    // 缓存未命中,请求数据
    await originalGetList()
    saveTableCache({
      list: tableHook.dataList.value,
      total: tableHook.total.value,
      queryParams: tableHook.queryParams,
      timestamp: Date.now()
    })
  }

  // 初始化时尝试使用缓存
  onMounted(() => {
    getList()
  })

  return {
    ...tableHook,
    getList,
    clearTableCache
  }
}

表格状态管理

typescript
// composables/use-table-state.ts
export interface TableState {
  selectedRows: any[]
  expandedRows: any[]
  columnWidths: Record<string, number>
  sortConfig: {
    prop?: string
    order?: 'ascending' | 'descending'
  }
  filterConfig: Record<string, any>
}

export function useTableState(tableId: string) {
  const state = ref<TableState>({
    selectedRows: [],
    expandedRows: [],
    columnWidths: {},
    sortConfig: {},
    filterConfig: {}
  })

  // 状态持久化key
  const storageKey = `table_state_${tableId}`

  /**
   * 保存表格状态
   */
  const saveState = () => {
    try {
      localStorage.setItem(storageKey, JSON.stringify(state.value))
    } catch (error) {
      console.error('保存表格状态失败:', error)
    }
  }

  /**
   * 恢复表格状态
   */
  const restoreState = () => {
    try {
      const saved = localStorage.getItem(storageKey)
      if (saved) {
        state.value = { ...state.value, ...JSON.parse(saved) }
      }
    } catch (error) {
      console.error('恢复表格状态失败:', error)
    }
  }

  /**
   * 清除表格状态
   */
  const clearState = () => {
    state.value = {
      selectedRows: [],
      expandedRows: [],
      columnWidths: {},
      sortConfig: {},
      filterConfig: {}
    }
    localStorage.removeItem(storageKey)
  }

  /**
   * 更新选中行
   */
  const updateSelectedRows = (rows: any[]) => {
    state.value.selectedRows = rows
    saveState()
  }

  /**
   * 更新展开行
   */
  const updateExpandedRows = (rows: any[]) => {
    state.value.expandedRows = rows
    saveState()
  }

  /**
   * 更新列宽
   */
  const updateColumnWidth = (prop: string, width: number) => {
    state.value.columnWidths[prop] = width
    saveState()
  }

  /**
   * 更新排序配置
   */
  const updateSort = (prop?: string, order?: 'ascending' | 'descending') => {
    state.value.sortConfig = { prop, order }
    saveState()
  }

  /**
   * 更新过滤配置
   */
  const updateFilter = (filters: Record<string, any>) => {
    state.value.filterConfig = filters
    saveState()
  }

  // 初始化时恢复状态
  onMounted(() => {
    restoreState()
  })

  return {
    state,
    saveState,
    restoreState,
    clearState,
    updateSelectedRows,
    updateExpandedRows,
    updateColumnWidth,
    updateSort,
    updateFilter
  }
}

虚拟滚动表格

typescript
// composables/use-virtual-table.ts
export interface VirtualTableConfig {
  itemHeight: number
  visibleCount: number
  bufferCount?: number
}

export function useVirtualTable<T>(
  data: Ref<T[]>,
  config: VirtualTableConfig
) {
  const { itemHeight, visibleCount, bufferCount = 5 } = config

  const containerRef = ref<HTMLElement>()
  const scrollTop = ref(0)

  // 计算可见数据
  const visibleData = computed(() => {
    const startIndex = Math.max(0, Math.floor(scrollTop.value / itemHeight) - bufferCount)
    const endIndex = Math.min(
      data.value.length,
      startIndex + visibleCount + bufferCount * 2
    )

    return {
      startIndex,
      endIndex,
      items: data.value.slice(startIndex, endIndex),
      offsetY: startIndex * itemHeight
    }
  })

  // 总高度
  const totalHeight = computed(() => data.value.length * itemHeight)

  /**
   * 处理滚动
   */
  const handleScroll = (event: Event) => {
    const target = event.target as HTMLElement
    scrollTop.value = target.scrollTop
  }

  /**
   * 滚动到指定索引
   */
  const scrollToIndex = (index: number) => {
    if (containerRef.value) {
      const targetScrollTop = Math.max(0, index * itemHeight)
      containerRef.value.scrollTop = targetScrollTop
    }
  }

  /**
   * 滚动到顶部
   */
  const scrollToTop = () => {
    scrollToIndex(0)
  }

  /**
   * 滚动到底部
   */
  const scrollToBottom = () => {
    scrollToIndex(data.value.length - 1)
  }

  return {
    containerRef,
    visibleData,
    totalHeight,
    handleScroll,
    scrollToIndex,
    scrollToTop,
    scrollToBottom
  }
}

📊 表格工具组件

表格工具栏

vue
<!-- TableToolbar.vue -->
<template>
  <div class="table-toolbar">
    <div class="toolbar-left">
      <slot name="left" />
    </div>

    <div class="toolbar-right">
      <el-tooltip content="刷新" placement="top">
        <el-button circle @click="$emit('refresh')">
          <el-icon><Refresh /></el-icon>
        </el-button>
      </el-tooltip>

      <el-tooltip content="密度" placement="top">
        <el-dropdown @command="handleDensityChange">
          <el-button circle>
            <el-icon><Grid /></el-icon>
          </el-button>
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item
                :class="{ active: density === 'large' }"
                command="large"
              >
                宽松
              </el-dropdown-item>
              <el-dropdown-item
                :class="{ active: density === 'default' }"
                command="default"
              >
                默认
              </el-dropdown-item>
              <el-dropdown-item
                :class="{ active: density === 'small' }"
                command="small"
              >
                紧凑
              </el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>
      </el-tooltip>

      <el-tooltip content="列设置" placement="top">
        <el-button circle @click="columnSettingVisible = true">
          <el-icon><Setting /></el-icon>
        </el-button>
      </el-tooltip>

      <el-tooltip content="全屏" placement="top">
        <el-button circle @click="toggleFullscreen">
          <el-icon><FullScreen /></el-icon>
        </el-button>
      </el-tooltip>
    </div>

    <!-- 列设置弹窗 -->
    <el-drawer
      v-model="columnSettingVisible"
      title="列设置"
      direction="rtl"
      size="300px"
    >
      <div class="column-setting">
        <div class="setting-header">
          <el-checkbox
            :model-value="isAllChecked"
            :indeterminate="isIndeterminate"
            @change="handleCheckAll"
          >
            列展示
          </el-checkbox>
          <el-button text @click="resetColumns">重置</el-button>
        </div>

        <el-divider />

        <div class="column-list">
          <div
            v-for="column in columnConfig"
            :key="column.prop"
            class="column-item"
          >
            <el-checkbox
              v-model="column.visible"
              @change="handleColumnChange"
            >
              {{ column.label }}
            </el-checkbox>
            <el-icon class="drag-handle">
              <Rank />
            </el-icon>
          </div>
        </div>
      </div>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
interface Column {
  prop: string
  label: string
  visible: boolean
  fixed?: boolean
}

interface Props {
  columns: Column[]
  density?: 'large' | 'default' | 'small'
}

interface Emits {
  (e: 'refresh'): void
  (e: 'density-change', density: string): void
  (e: 'column-change', columns: Column[]): void
}

const props = withDefaults(defineProps<Props>(), {
  density: 'default'
})

const emit = defineEmits<Emits>()

const density = ref(props.density)
const columnSettingVisible = ref(false)
const columnConfig = ref<Column[]>([...props.columns])

// 全选状态
const isAllChecked = computed(() =>
  columnConfig.value.every(col => col.visible)
)

const isIndeterminate = computed(() => {
  const checkedCount = columnConfig.value.filter(col => col.visible).length
  return checkedCount > 0 && checkedCount < columnConfig.value.length
})

/**
 * 密度变更
 */
const handleDensityChange = (command: string) => {
  density.value = command as any
  emit('density-change', command)
}

/**
 * 全选/取消全选
 */
const handleCheckAll = (checked: boolean) => {
  columnConfig.value.forEach(col => {
    if (!col.fixed) {
      col.visible = checked
    }
  })
  handleColumnChange()
}

/**
 * 列显示变更
 */
const handleColumnChange = () => {
  emit('column-change', [...columnConfig.value])
}

/**
 * 重置列设置
 */
const resetColumns = () => {
  columnConfig.value = [...props.columns]
  handleColumnChange()
}

/**
 * 全屏切换
 */
const toggleFullscreen = () => {
  if (document.fullscreenElement) {
    document.exitFullscreen()
  } else {
    document.documentElement.requestFullscreen()
  }
}

// 监听columns变化
watch(
  () => props.columns,
  (newColumns) => {
    columnConfig.value = [...newColumns]
  },
  { deep: true }
)
</script>

<style scoped>
.table-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
}

.toolbar-right {
  display: flex;
  gap: 8px;
}

.column-setting {
  .setting-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .column-list {
    .column-item {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 8px 0;
      cursor: move;

      &:hover {
        background-color: var(--el-bg-color-page);
      }

      .drag-handle {
        color: var(--el-text-color-placeholder);
        cursor: grab;

        &:active {
          cursor: grabbing;
        }
      }
    }
  }
}

.active {
  color: var(--el-color-primary);
  font-weight: bold;
}
</style>

useTable组合函数为Vue3应用提供了完整的表格管理解决方案,支持分页、排序、筛选、导出等常用功能,并提供了缓存、虚拟滚动等高级特性。