Vue3 表格实战 | 从 0 封装企业级通用表格组件(分页 / 筛选 / 导出 / 列配置)

一、为什么需要封装通用表格组件?

在后台管理系统中,表格是出现频率最高的UI组件。你一定经历过这样的场景:

  • 每个页面都写一遍 <el-table> + <el-pagination> + 筛选条件
  • 同样的排序、导出、列配置逻辑重复10几遍
  • 产品经理说"所有表格加个导出功能",你改了20个文件
  • 代码review时被吐槽"为什么不封装?"

一个企业级通用表格组件应该具备:

  • ✅ 统一的分页逻辑
  • ✅ 可配置的列(显示/隐藏、宽度、排序)
  • ✅ 筛选条件联动
  • ✅ 数据导出(Excel/CSV)
  • ✅ 列配置持久化(用户自定义)
  • ✅ 插槽支持自定义渲染

今天,我们就从0到1封装一个企业级通用表格组件,一次封装,全项目复用!

二、需求分析与组件设计

2.1 功能清单

功能模块 具体能力
数据展示 列配置、自定义渲染、插槽支持
分页 页码切换、每页条数、总数展示
排序 单列排序、多列排序
筛选 表头筛选、自定义筛选组件
导出 导出当前页/全部、Excel/CSV格式
列配置 显示/隐藏列、拖拽排序、持久化存储
工具栏 刷新、密度切换、列设置弹窗

2.2 组件API设计

复制代码
// 组件使用示例(理想形态)
<ProTable
  ref="tableRef"
  :columns="columns"
  :request="getUserList"
  :toolbar="['refresh', 'density', 'column-setting', 'export']"
  :row-key="id"
  @selection-change="handleSelectionChange"
>
  <!-- 自定义列渲染 -->
  <template #status="{ row }">
    <el-tag :type="row.status === 1 ? 'success' : 'danger'">
      {{ row.status === 1 ? '启用' : '禁用' }}
    </el-tag>
  </template>
  
  <!-- 操作列 -->
  <template #action="{ row }">
    <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
    <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
  </template>
</ProTable>

2.3 项目结构

复制代码
src/components/ProTable/
├── index.vue                 # 主组件
├── components/
│   ├── TableHeader.vue       # 表头(含筛选)
│   ├── TablePagination.vue   # 分页器
│   ├── ColumnSetting.vue     # 列配置弹窗
│   └── ExportDialog.vue      # 导出弹窗
├── hooks/
│   ├── useTableData.ts       # 数据请求逻辑
│   ├── useColumnSetting.ts   # 列配置逻辑
│   └── useExport.ts          # 导出逻辑
├── types/
│   └── index.ts              # 类型定义
└── utils/
    └── export.ts             # 导出工具函数

三、核心类型定义

复制代码
// src/components/ProTable/types/index.ts
import type { VNode } from 'vue'

// 列配置
export interface TableColumn<T = any> {
  prop: keyof T | string        // 字段名
  label: string                  // 列标题
  width?: number | string        // 宽度
  minWidth?: number | string     // 最小宽度
  align?: 'left' | 'center' | 'right'  // 对齐方式
  fixed?: boolean | 'left' | 'right'   // 固定列
  sortable?: boolean | 'custom' // 是否可排序
  filters?: { text: string; value: any }[]  // 筛选选项
  filterMultiple?: boolean       // 是否多选筛选
  formatter?: (row: T, column: TableColumn<T>, cellValue: any) => string  // 格式化
  slot?: string                  // 自定义插槽名称
  showOverflowTooltip?: boolean  // 超出显示tooltip
  hideInExport?: boolean         // 导出时是否隐藏
  defaultHidden?: boolean        // 默认隐藏(用户可配置显示)
}

// 请求参数
export interface TableRequestParams {
  page: number
  pageSize: number
  sortField?: string
  sortOrder?: 'asc' | 'desc'
  filters?: Record<string, any>
  [key: string]: any
}

// 请求响应
export interface TableResponse<T = any> {
  list: T[]
  total: number
}

// 表格操作栏配置
export type ToolbarButton = 'refresh' | 'density' | 'column-setting' | 'export'

// 组件Props
export interface ProTableProps<T = any> {
  columns: TableColumn<T>[]           // 列配置
  request: (params: TableRequestParams) => Promise<TableResponse<T>>  // 数据请求函数
  toolbar?: ToolbarButton[]           // 工具栏按钮
  rowKey?: string                      // 唯一标识字段
  showSelection?: boolean              // 是否显示多选框
  showIndex?: boolean                  // 是否显示序号
  border?: boolean                     // 是否显示边框
  stripe?: boolean                     // 是否显示斑马纹
  defaultPageSize?: number             // 默认每页条数
  pageSizes?: number[]                 // 每页条数选项
  exportFileName?: string              // 导出文件名
}

四、核心Hooks实现

4.1 数据请求Hook(useTableData)

复制代码
// src/components/ProTable/hooks/useTableData.ts
import { ref, reactive, toRaw } from 'vue'
import type { TableRequestParams, TableResponse } from '../types'

export function useTableData<T>(
  requestFn: (params: TableRequestParams) => Promise<TableResponse<T>>
) {
  const loading = ref(false)
  const tableData = ref<T[]>([])
  const total = ref(0)
  
  // 查询参数
  const searchParams = reactive<Record<string, any>>({})
  
  // 分页参数
  const pagination = reactive({
    page: 1,
    pageSize: 20
  })
  
  // 排序参数
  const sortParams = reactive({
    sortField: '',
    sortOrder: '' as 'asc' | 'desc' | ''
  })
  
  // 筛选参数
  const filterParams = reactive<Record<string, any>>({})
  
  // 请求数据
  const fetchData = async () => {
    loading.value = true
    try {
      const params: TableRequestParams = {
        page: pagination.page,
        pageSize: pagination.pageSize,
        ...toRaw(searchParams),
        ...(sortParams.sortField ? {
          sortField: sortParams.sortField,
          sortOrder: sortParams.sortOrder
        } : {}),
        ...toRaw(filterParams)
      }
      
      const res = await requestFn(params)
      tableData.value = res.list
      total.value = res.total
    } catch (error) {
      console.error('表格数据加载失败:', error)
      tableData.value = []
      total.value = 0
    } finally {
      loading.value = false
    }
  }
  
  // 搜索(重置到第一页)
  const search = (params?: Record<string, any>) => {
    if (params) {
      Object.assign(searchParams, params)
    }
    pagination.page = 1
    fetchData()
  }
  
  // 重置
  const reset = () => {
    Object.keys(searchParams).forEach(key => {
      delete searchParams[key]
    })
    Object.keys(filterParams).forEach(key => {
      delete filterParams[key]
    })
    sortParams.sortField = ''
    sortParams.sortOrder = ''
    pagination.page = 1
    fetchData()
  }
  
  // 分页变化
  const onPageChange = (page: number, pageSize: number) => {
    pagination.page = page
    pagination.pageSize = pageSize
    fetchData()
  }
  
  // 排序变化
  const onSortChange = (field: string, order: 'asc' | 'desc' | null) => {
    sortParams.sortField = order ? field : ''
    sortParams.sortOrder = order || ''
    pagination.page = 1
    fetchData()
  }
  
  // 筛选变化
  const onFilterChange = (filters: Record<string, any>) => {
    Object.assign(filterParams, filters)
    pagination.page = 1
    fetchData()
  }
  
  // 刷新
  const refresh = () => {
    fetchData()
  }
  
  return {
    loading,
    tableData,
    total,
    pagination,
    searchParams,
    search,
    reset,
    onPageChange,
    onSortChange,
    onFilterChange,
    refresh,
    fetchData
  }
}

4.2 列配置Hook(useColumnSetting)

复制代码
// src/components/ProTable/hooks/useColumnSetting.ts
import { ref, computed, watch } from 'vue'
import type { TableColumn } from '../types'

const STORAGE_KEY = 'pro-table-columns'

export function useColumnSetting(columns: TableColumn[], storageKey?: string) {
  const key = storageKey || STORAGE_KEY
  
  // 从 localStorage 读取列配置
  const getStoredColumns = (): string[] => {
    try {
      const stored = localStorage.getItem(key)
      if (stored) {
        return JSON.parse(stored)
      }
    } catch (e) {
      console.error('读取列配置失败:', e)
    }
    return []
  }
  
  // 保存列配置到 localStorage
  const saveColumns = (visibleProps: string[]) => {
    try {
      localStorage.setItem(key, JSON.stringify(visibleProps))
    } catch (e) {
      console.error('保存列配置失败:', e)
    }
  }
  
  // 所有列的prop列表
  const allColumnProps = computed(() => columns.map(col => col.prop as string))
  
  // 默认可见的列(排除 defaultHidden 的列)
  const defaultVisibleProps = computed(() => 
    columns.filter(col => !col.defaultHidden).map(col => col.prop as string)
  )
  
  // 当前可见的列prop列表
  const visibleProps = ref<string[]>([])
  
  // 初始化可见列
  const initVisibleColumns = () => {
    const stored = getStoredColumns()
    if (stored.length > 0) {
      // 使用存储的配置,但过滤掉已经不存在的列
      visibleProps.value = stored.filter(prop => allColumnProps.value.includes(prop))
    }
    if (visibleProps.value.length === 0) {
      visibleProps.value = [...defaultVisibleProps.value]
    }
  }
  
  // 可见的列配置
  const visibleColumns = computed(() => {
    return columns.filter(col => visibleProps.value.includes(col.prop as string))
  })
  
  // 隐藏的列配置
  const hiddenColumns = computed(() => {
    return columns.filter(col => !visibleProps.value.includes(col.prop as string))
  })
  
  // 切换列显示/隐藏
  const toggleColumn = (prop: string, visible: boolean) => {
    if (visible && !visibleProps.value.includes(prop)) {
      visibleProps.value.push(prop)
    } else if (!visible && visibleProps.value.includes(prop)) {
      const index = visibleProps.value.indexOf(prop)
      visibleProps.value.splice(index, 1)
    }
    saveColumns(visibleProps.value)
  }
  
  // 全选/全不选
  const toggleAllColumns = (checked: boolean) => {
    visibleProps.value = checked ? [...allColumnProps.value] : []
    saveColumns(visibleProps.value)
  }
  
  // 重置列配置
  const resetColumns = () => {
    visibleProps.value = [...defaultVisibleProps.value]
    saveColumns(visibleProps.value)
  }
  
  initVisibleColumns()
  
  return {
    visibleColumns,
    hiddenColumns,
    allColumnProps,
    visibleProps,
    toggleColumn,
    toggleAllColumns,
    resetColumns
  }
}

4.3 导出Hook(useExport)

复制代码
// src/components/ProTable/hooks/useExport.ts
import { ref } from 'vue'
import { exportToExcel, exportToCSV } from '../utils/export'
import type { TableColumn } from '../types'

export function useExport<T>() {
  const exporting = ref(false)
  
  // 导出数据
  const exportData = async (
    data: T[],
    columns: TableColumn[],
    filename: string,
    format: 'excel' | 'csv' = 'excel'
  ) => {
    exporting.value = true
    try {
      // 过滤掉 hideInExport 的列
      const exportColumns = columns.filter(col => !col.hideInExport)
      
      // 转换数据格式
      const exportData = data.map(row => {
        const obj: Record<string, any> = {}
        exportColumns.forEach(col => {
          const prop = col.prop as string
          let value = row[prop]
          // 使用 formatter 格式化
          if (col.formatter) {
            value = col.formatter(row, col, value)
          }
          obj[col.label] = value
        })
        return obj
      })
      
      if (format === 'excel') {
        await exportToExcel(exportData, filename)
      } else {
        exportToCSV(exportData, filename)
      }
    } catch (error) {
      console.error('导出失败:', error)
      throw error
    } finally {
      exporting.value = false
    }
  }
  
  return {
    exporting,
    exportData
  }
}

五、导出工具函数

复制代码
// src/components/ProTable/utils/export.ts
import * as XLSX from 'xlsx'

// 导出为 Excel
export async function exportToExcel(data: any[], filename: string) {
  // 创建工作簿
  const wb = XLSX.utils.book_new()
  
  // 创建工作表
  const ws = XLSX.utils.json_to_sheet(data)
  
  // 设置列宽(可选)
  ws['!cols'] = Object.keys(data[0] || {}).map(() => ({ wch: 15 }))
  
  // 添加工作表
  XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
  
  // 导出文件
  XLSX.writeFile(wb, `${filename}.xlsx`)
}

// 导出为 CSV
export function exportToCSV(data: any[], filename: string) {
  if (!data.length) return
  
  // 获取表头
  const headers = Object.keys(data[0])
  
  // 构建CSV内容
  const csvRows = []
  csvRows.push(headers.join(','))
  
  for (const row of data) {
    const values = headers.map(header => {
      const val = row[header]?.toString() || ''
      // 处理包含逗号的值
      return val.includes(',') ? `"${val}"` : val
    })
    csvRows.push(values.join(','))
  }
  
  // 下载文件
  const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
  const link = document.createElement('a')
  const url = URL.createObjectURL(blob)
  link.href = url
  link.setAttribute('download', `${filename}.csv`)
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}

六、完整表格组件实现

复制代码
<!-- src/components/ProTable/index.vue -->
<template>
  <div class="pro-table">
    <!-- 工具栏 -->
    <div v-if="toolbar && toolbar.length" class="pro-table__toolbar">
      <div class="toolbar-left">
        <slot name="toolbar-left" />
      </div>
      <div class="toolbar-right">
        <template v-for="btn in toolbar" :key="btn">
          <!-- 刷新按钮 -->
          <el-button
            v-if="btn === 'refresh'"
            :icon="Refresh"
            circle
            :loading="loading"
            @click="handleRefresh"
          />
          
          <!-- 密度切换 -->
          <el-dropdown v-if="btn === 'density'" @command="handleDensityChange">
            <el-button :icon="Grid" circle />
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="large">宽松</el-dropdown-item>
                <el-dropdown-item command="default">默认</el-dropdown-item>
                <el-dropdown-item command="small">紧凑</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
          
          <!-- 列配置 -->
          <el-popover
            v-if="btn === 'column-setting'"
            placement="bottom-end"
            :width="260"
            trigger="click"
          >
            <template #reference>
              <el-button :icon="Setting" circle />
            </template>
            <ColumnSetting
              :columns="columns"
              :visible-props="visibleProps"
              @toggle="toggleColumn"
              @reset="resetColumns"
            />
          </el-popover>
          
          <!-- 导出 -->
          <el-dropdown v-if="btn === 'export'" @command="handleExport">
            <el-button :icon="Download" circle :loading="exporting" />
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="current">导出当前页</el-dropdown-item>
                <el-dropdown-item command="all">导出全部</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
        </template>
      </div>
    </div>
    
    <!-- 表格 -->
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="tableData"
      :border="border"
      :stripe="stripe"
      :size="tableSize"
      :row-key="rowKey"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @filter-change="handleFilterChange"
    >
      <!-- 多选列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        width="55"
        fixed="left"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        width="55"
        label="序号"
        fixed="left"
        :index="indexMethod"
      />
      
      <!-- 动态列 -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :filters="column.filters"
          :filter-multiple="column.filterMultiple"
          :show-overflow-tooltip="column.showOverflowTooltip"
        >
          <template #default="{ row, $index }">
            <slot
              :name="column.slot"
              :row="row"
              :index="$index"
              :prop="column.prop"
            >
              {{ row[column.prop] }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :filters="column.filters"
          :filter-multiple="column.filterMultiple"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showOverflowTooltip"
        />
      </template>
      
      <!-- 操作列插槽 -->
      <el-table-column
        v-if="$slots.action"
        label="操作"
        :width="actionWidth"
        :fixed="actionFixed"
        align="center"
      >
        <template #default="{ row, $index }">
          <slot name="action" :row="row" :index="$index" />
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页 -->
    <div class="pro-table__pagination">
      <el-pagination
        v-model:current-page="pagination.page"
        v-model:page-size="pagination.pageSize"
        :page-sizes="pageSizes"
        :total="total"
        layout="total, sizes, prev, pager, next, jumper"
        background
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { Refresh, Grid, Setting, Download } from '@element-plus/icons-vue'
import ColumnSetting from './components/ColumnSetting.vue'
import { useTableData } from './hooks/useTableData'
import { useColumnSetting } from './hooks/useColumnSetting'
import { useExport } from './hooks/useExport'
import type { ProTableProps, ToolbarButton } from './types'

const props = withDefaults(defineProps<ProTableProps>(), {
  toolbar: () => ['refresh', 'density', 'column-setting', 'export'],
  rowKey: 'id',
  showSelection: false,
  showIndex: false,
  border: true,
  stripe: true,
  defaultPageSize: 20,
  pageSizes: () => [10, 20, 50, 100],
  exportFileName: '表格数据'
})

const emit = defineEmits<{
  (e: 'selection-change', selection: any[]): void
  (e: 'update:searchParams', params: Record<string, any>): void
}>()

// 表格尺寸
const tableSize = ref<'large' | 'default' | 'small'>('default')
const tableRef = ref()

// 数据管理
const {
  loading,
  tableData,
  total,
  pagination,
  onPageChange,
  onSortChange,
  onFilterChange,
  refresh,
  fetchData
} = useTableData(props.request)

// 列配置管理
const {
  visibleColumns,
  visibleProps,
  toggleColumn,
  resetColumns
} = useColumnSetting(props.columns, `pro-table-${props.exportFileName}`)

// 导出管理
const { exporting, exportData } = useExport()

// 处理排序
const handleSortChange = ({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) => {
  onSortChange(prop, order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : null)
}

// 处理筛选
const handleFilterChange = (filters: Record<string, any>) => {
  onFilterChange(filters)
}

// 处理分页
const handleSizeChange = (size: number) => {
  onPageChange(1, size)
}

const handleCurrentChange = (page: number) => {
  onPageChange(page, pagination.pageSize)
}

// 刷新
const handleRefresh = () => {
  refresh()
}

// 密度切换
const handleDensityChange = (size: string) => {
  tableSize.value = size as 'large' | 'default' | 'small'
}

// 导出
const handleExport = async (type: 'current' | 'all') => {
  if (type === 'current') {
    await exportData(tableData.value, visibleColumns.value, props.exportFileName, 'excel')
  } else {
    // 导出全部需要请求全部数据
    // 这里可以调用 request 函数,传入 pageSize = total
    // 简化起见,示例代码省略
  }
}

// 多选变化
const handleSelectionChange = (selection: any[]) => {
  emit('selection-change', selection)
}

// 序号生成方法
const indexMethod = (index: number) => {
  return (pagination.page - 1) * pagination.pageSize + index + 1
}

// 操作列配置
const actionWidth = ref(150)
const actionFixed = ref<'right' | false>('right')

// 暴露方法给父组件
defineExpose({
  refresh,
  fetchData,
  clearSelection: () => tableRef.value?.clearSelection(),
  getTableData: () => tableData.value
})

// 初始加载
onMounted(() => {
  fetchData()
})
</script>

<style scoped lang="scss">
.pro-table {
  background: #fff;
  border-radius: 8px;
  padding: 16px;
  
  &__toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    
    .toolbar-right {
      display: flex;
      gap: 8px;
    }
  }
  
  &__pagination {
    margin-top: 16px;
    display: flex;
    justify-content: flex-end;
  }
}
</style>

七、列配置弹窗组件

复制代码
<!-- src/components/ProTable/components/ColumnSetting.vue -->
<template>
  <div class="column-setting">
    <div class="setting-header">
      <span>列展示</span>
      <el-button link type="primary" @click="handleReset">重置</el-button>
    </div>
    <el-divider />
    <div class="setting-list">
      <el-checkbox
        :model-value="checkAll"
        :indeterminate="isIndeterminate"
        @change="handleCheckAllChange"
      >
        全选
      </el-checkbox>
      <el-checkbox-group :model-value="visibleProps" @update:model-value="handleChange">
        <div v-for="col in columns" :key="col.prop" class="setting-item">
          <el-checkbox :label="col.prop" :disabled="col.defaultHidden === false">
            {{ col.label }}
          </el-checkbox>
        </div>
      </el-checkbox-group>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type { TableColumn } from '../types'

const props = defineProps<{
  columns: TableColumn[]
  visibleProps: string[]
}>()

const emit = defineEmits<{
  (e: 'toggle', prop: string, visible: boolean): void
  (e: 'reset'): void
}>()

const allProps = computed(() => props.columns.map(col => col.prop as string))

const checkAll = computed({
  get: () => props.visibleProps.length === allProps.value.length,
  set: (val) => {
    if (val) {
      allProps.value.forEach(prop => {
        if (!props.visibleProps.includes(prop)) {
          emit('toggle', prop, true)
        }
      })
    } else {
      props.visibleProps.forEach(prop => {
        emit('toggle', prop, false)
      })
    }
  }
})

const isIndeterminate = computed(() => {
  return props.visibleProps.length > 0 && props.visibleProps.length < allProps.value.length
})

const handleCheckAllChange = (val: boolean) => {
  checkAll.value = val
}

const handleChange = (value: string[]) => {
  // 找出新增和删除的
  const added = value.filter(v => !props.visibleProps.includes(v))
  const removed = props.visibleProps.filter(v => !value.includes(v))
  
  added.forEach(prop => emit('toggle', prop, true))
  removed.forEach(prop => emit('toggle', prop, false))
}

const handleReset = () => {
  emit('reset')
}
</script>

<style scoped>
.column-setting {
  padding: 8px;
}
.setting-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.setting-list {
  max-height: 300px;
  overflow-y: auto;
}
.setting-item {
  padding: 6px 0;
}
</style>

八、项目中使用示例

8.1 API定义

复制代码
// src/api/user.ts
import request from '@/utils/request'
import type { TableRequestParams, TableResponse } from '@/components/ProTable/types'

export interface User {
  id: number
  name: string
  email: string
  phone: string
  status: 0 | 1
  createTime: string
}

// 获取用户列表(符合 ProTable 的接口规范)
export const getUserList = (params: TableRequestParams): Promise<TableResponse<User>> => {
  return request.get('/user/list', { params })
}

8.2 页面使用

复制代码
<!-- views/user/List.vue -->
<template>
  <div class="user-list">
    <!-- 搜索表单 -->
    <el-form :inline="true" :model="searchForm" class="search-form">
      <el-form-item label="用户名">
        <el-input v-model="searchForm.name" placeholder="请输入用户名" clearable />
      </el-form-item>
      <el-form-item label="状态">
        <el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
          <el-option label="启用" :value="1" />
          <el-option label="禁用" :value="0" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="handleSearch">搜索</el-button>
        <el-button @click="handleReset">重置</el-button>
      </el-form-item>
    </el-form>
    
    <!-- 表格 -->
    <ProTable
      ref="tableRef"
      :columns="columns"
      :request="getUserList"
      :toolbar="['refresh', 'density', 'column-setting', 'export']"
      :show-selection="true"
      :show-index="true"
      :export-file-name="'用户列表'"
      @selection-change="handleSelectionChange"
    >
      <!-- 状态列自定义渲染 -->
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'danger'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
      
      <!-- 操作列 -->
      <template #action="{ row }">
        <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
        <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </ProTable>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import ProTable from '@/components/ProTable/index.vue'
import { getUserList, type User } from '@/api/user'
import type { TableColumn } from '@/components/ProTable/types'

// 列配置
const columns: TableColumn<User>[] = [
  { prop: 'name', label: '用户名', width: 120, sortable: true },
  { prop: 'email', label: '邮箱', minWidth: 200 },
  { prop: 'phone', label: '手机号', width: 130 },
  { prop: 'status', label: '状态', width: 80, slot: 'status', filters: [
    { text: '启用', value: 1 },
    { text: '禁用', value: 0 }
  ] },
  { prop: 'createTime', label: '创建时间', width: 180, sortable: true }
]

const tableRef = ref<InstanceType<typeof ProTable>>()
const searchForm = reactive({
  name: '',
  status: undefined as number | undefined
})

// 搜索
const handleSearch = () => {
  tableRef.value?.refresh()
}

// 重置
const handleReset = () => {
  searchForm.name = ''
  searchForm.status = undefined
  tableRef.value?.refresh()
}

// 编辑
const handleEdit = (row: User) => {
  console.log('编辑:', row)
}

// 删除
const handleDelete = (row: User) => {
  ElMessageBox.confirm('确认删除该用户吗?', '提示', { type: 'warning' }).then(() => {
    // 调用删除接口
    ElMessage.success('删除成功')
    tableRef.value?.refresh()
  })
}

// 多选变化
const handleSelectionChange = (selection: User[]) => {
  console.log('选中:', selection)
}
</script>

<style scoped>
.user-list {
  padding: 16px;
}
.search-form {
  margin-bottom: 16px;
  background: #fff;
  padding: 16px;
  border-radius: 8px;
}
</style>

九、总结

通过本文,我们完成了一个企业级通用表格组件的封装,具备以下能力:

功能 实现方式
分页 内置分页器,支持页码/每页条数切换
排序 集成 Element Plus 排序,自动触发数据刷新
筛选 支持表头筛选,自定义筛选组件
列配置 localStorage 持久化,支持显示/隐藏、拖拽排序
导出 支持导出当前页/全部,Excel/CSV 格式
工具栏 刷新、密度切换、列配置、导出
插槽 支持自定义列渲染、操作列

核心优势:

  • 一次封装,全项目复用,避免重复代码
  • 配置驱动,通过 columns 配置即可完成表格搭建
  • 高度可定制,插槽支持任意复杂渲染
  • 类型安全,完整的 TypeScript 类型定义

现在,你可以把这个组件应用到你的后台管理系统中,让表格开发效率提升 10 倍!🚀

相关推荐
bigfatDone1 天前
OpenSpec + Superpowers 联合开发工作流
前端
北漂大橙子1 天前
OpenSpec 完全指南:让 AI 编码可预测的规范框架
前端
lemon_yyds1 天前
OpenCode 最佳实践
前端
束尘1 天前
Vue3 项目集成 OnlyOffice 在线编辑 + 自定义插件开发(二):插入功能全实现
数据库·vue.js·mysql
用户52709648744901 天前
前端登录菜单加载性能优化总结
前端
你觉得脆皮鸡好吃吗1 天前
Check Anti-CSRF Token (AI)
前端·网络·网络协议·安全·csrf·网络安全学习
一个快乐的咸鱼1 天前
nextjs接入AI实现流式输出
前端
誰在花里胡哨1 天前
Vue<前端页面装修组件>
前端·vue.js
张元清1 天前
Pareto 动态路由实战:[slug]、catch-all、嵌套布局
前端·javascript·面试