Vue 3 + Element Plus 表格操作封装:useTableOperations 组合式函数详解
在日常的中后台管理系统开发中,表格的 增删改查、导入导出、批量操作 等功能几乎是标配。为了提高代码复用性和开发效率,我们可以将这些通用逻辑封装成一个可复用的 组合式函数。本文将介绍一个基于 Vue 3 和 Element Plus 的
useTableOperations函数,帮助你快速实现表格页面的各类交互功能。
🧩 功能介绍
useTableOperations 是一个高度可配置的 Vue 3 组合式函数,支持以下功能:
✅ 表格数据查询与分页
✅ 新增、修改、删除(单条/批量)
✅ 表单校验与提交
✅ 导入/导出 Excel
✅ 动态表格高度适配
✅ 搜索栏展开/收起
✅ 自定义导入导出模板
🛠 核心设计
类型定义与配置项
export interface TableOperationsOptions<T, Q, F> {listApi: (params: Q) => Promise<any> // 查询接口addApi?: (data: F) => Promise<any> // 新增接口updateApi?: (data: F) => Promise<any> // 更新接口deleteApi?: (id: string | number) => Promise<any> // 删除接口getApi?: (id: string | number) => Promise<any> // 获取单条数据initFormData: F // 表单初始数据createQueryParams: () => Q // 查询参数构造器entityName?: string // 实体名称(用于提示语)exportUrl?: string // 导出地址importUrl?: string // 导入地址importTemplateUrl?: string // 导入模板地址enableDynamicHeight?: boolean // 是否启用动态高度dynamicHeightOffset?: number // 动态高度偏移量
}响应式状态管理
函数内部维护了多个 ref 和 reactive 状态,包括:
dataList:表格数据loading:加载状态queryParams:查询参数form:表单数据dialog:弹窗控制upload:导入配置
🧭 使用示例
1. 引入并使用
import { useTableOperations } from '@/composables/useTableOperations'const {dataList,loading,handleQuery,handleAdd,handleUpdate,handleDelete,handleBatchDelete,handleExport,handleImport,// ... 其他返回值
} = useTableOperations({listApi: api.getList,addApi: api.add,updateApi: api.update,deleteApi: api.delete,getApi: api.getDetail,initFormData: {name: '',status: '',},createQueryParams: () => ({pageNum: 1,pageSize: 10,name: '',status: '',}),entityName: '用户',exportUrl: '/export/user',importUrl: '/import/user',importTemplateUrl: '/template/user.xlsx',enableDynamicHeight: true,
})2. 模板中绑定
<template><div><!-- 搜索区域 --><el-form ref="searchFormRef" :model="queryParams" inline><el-form-item label="名称" prop="name"><el-input v-model="queryParams.name" /></el-form-item><el-button @click="handleQuery">搜索</el-button><el-button @click="resetQuery">重置</el-button></el-form><!-- 操作按钮 --><el-button @click="handleAdd">新增</el-button><el-button @click="handleBatchDelete" :disabled="multiple">批量删除</el-button><el-button @click="handleExport">导出</el-button><el-button @click="handleImport">导入</el-button><!-- 表格 --><el-table:data="dataList":max-height="tableMaxHeight"@selection-change="handleSelectionChange"><el-table-column type="selection" width="55" /><el-table-column prop="name" label="名称" /><el-table-column label="操作"><template #default="{ row }"><el-button link @click="handleUpdate(row)">编辑</el-button><el-button link @click="handleDelete(row)">删除</el-button></template></el-table-column></el-table><!-- 分页 --><el-paginationref="paginationRef"v-model:current-page="queryParams.pageNum"v-model:page-size="queryParams.pageSize":total="total"@current-change="getList"/><!-- 表单弹窗 --><el-dialog v-model="dialog.visible" :title="dialog.title"><el-form ref="formRef" :model="form" :rules="rules"><el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item></el-form><template #footer><el-button @click="cancel">取消</el-button><el-button type="primary" @click="submitForm" :loading="buttonLoading">确认</el-button></template></el-dialog><!-- 导入弹窗 --><el-dialog v-model="upload.open" :title="upload.title"><el-uploadref="uploadRef":action="upload.url":headers="upload.headers":on-progress="handleFileUploadProgress":on-success="handleFileSuccess"><el-button>选择文件</el-button><template #tip><el-button link @click="importTemplate">下载模板</el-button></template></el-upload><template #footer><el-button @click="upload.open = false">取消</el-button><el-button type="primary" @click="submitFileForm">提交</el-button></template></el-dialog></div>
</template>🧩 动态表格高度
通过 enableDynamicHeight 开启后,表格会自动根据窗口大小和页面元素动态计算最大高度,适用于数据量大时需要固定表头并支持滚动的场景。
const tableMaxHeight = computed(() => {if (!enableDynamicHeight) return undefinedconst documentHeight = document.documentElement.clientHeightconst usedHeight = searchFormHeight.value + tableHeaderHeight.value + paginationHeight.value + dynamicHeightOffsetreturn Math.max(200, documentHeight - usedHeight)
})🧠 扩展方法
除了基础的导入导出,还支持自定义导出和模板下载:
// 自定义导出
handleCustomExportWithParams('/custom/export','自定义报表.xlsx',{ type: 'monthly' },true // 是否包含分页参数
)// 自定义导入模板下载
handleCustomImportTemplateDownload('/custom/template','自定义模板'
)✅ 总结
useTableOperations 通过组合式 API 将表格页面的通用逻辑进行封装,大大提升了开发效率,同时保持了良好的类型支持和可扩展性。你可以根据项目需求进一步扩展或定制该函数,例如加入权限控制、操作日志、更多导入导出格式等。
如果大家有更好的建议或实现方式,欢迎在评论区交流讨论!
完整代码
import type { FormInstance, UploadInstance } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, Ref } from 'vue'// 类型定义
export interface DialogOption {visible: booleantitle: string
}export interface ImportOption {open: booleantitle: stringisUploading: booleanupdateSupport: numberheaders: anyurl: stringtemplateUrl: stringtemplateFileName: string
}export interface TableOperationsOptions<T, Q, F> {// API 方法listApi: (params: Q) => Promise<any>addApi?: (data: F) => Promise<any>updateApi?: (data: F) => Promise<any>deleteApi?: (id: string | number) => Promise<any>getApi?: (id: string | number) => Promise<any>// 初始数据initFormData: FcreateQueryParams: () => Q// 配置entityName?: stringexportUrl?: stringimportUrl?: stringimportTemplateUrl?: string// 动态高度配置enableDynamicHeight?: booleandynamicHeightOffset?: number
}export function useTableOperations<T, Q, F>(options: TableOperationsOptions<T, Q, F>,
) {const {listApi,addApi,updateApi,deleteApi,getApi,initFormData,createQueryParams,entityName = '数据',exportUrl,importUrl,importTemplateUrl,enableDynamicHeight = false,dynamicHeightOffset = 260,} = options// 响应式数据const dataList = ref<T[]>([])const loading = ref(true)const buttonLoading = ref(false)const showSearch = ref(true)const total = ref(0)const ids = ref<Array<string | number>>([])const single = ref(true)const multiple = ref(true)const queryFormRef = ref<FormInstance>()const formRef = ref<FormInstance>()const uploadRef = ref<UploadInstance>()const queryParams = ref<Q>(createQueryParams())const form = ref<F>({ ...initFormData })const dialog = reactive<DialogOption>({visible: false,title: '',})const upload = reactive<ImportOption>({open: false,title: '',isUploading: false,updateSupport: 0,headers: globalHeaders(),url: importUrl || '',templateUrl: importTemplateUrl || '',templateFileName: '导入模板.xlsx',})// 动态高度相关const searchFormRef = ref()const paginationRef = ref()const tableHeaderRef = ref()const searchFormHeight = ref(0)const tableHeaderHeight = ref(0)const paginationHeight = ref(0)// 计算表格最大高度const tableMaxHeight = computed(() => {if (!enableDynamicHeight) {return undefined}const documentHeight = document.documentElement.clientHeightconst usedHeight = searchFormHeight.value + tableHeaderHeight.value + paginationHeight.value + dynamicHeightOffsetreturn Math.max(200, documentHeight - usedHeight) // 确保最小高度为200px})// ResizeObserver 实例let resizeObserver: ResizeObserver | null = null/** 设置尺寸监听器 */const setupResizeObserver = () => {if (!enableDynamicHeight)returnresizeObserver = new ResizeObserver((entries) => {entries.forEach((entry) => {const target = entry.target as HTMLElementconst height = target.offsetHeightif (target === searchFormRef.value?.$el || target === searchFormRef.value) {searchFormHeight.value = showSearch.value ? height : 0} else if (target === tableHeaderRef.value?.$el) {tableHeaderHeight.value = height} else if (target === paginationRef.value?.$el) {paginationHeight.value = total.value > 0 ? height : 0}})})// 观察相关元素if (searchFormRef.value) {const searchFormEl = searchFormRef.value.$el || searchFormRef.valueresizeObserver.observe(searchFormEl)}if (tableHeaderRef.value) {resizeObserver.observe(tableHeaderRef.value.$el)}if (paginationRef.value) {resizeObserver.observe(paginationRef.value.$el)}}/** 监听窗口大小变化 */const handleResize = () => {// 触发重新计算if (enableDynamicHeight) {// eslint-disable-next-line ts/no-unused-expressionstableMaxHeight.valueconsole.warn(document.documentElement.clientHeight, tableMaxHeight.value, '005')}}/** 初始化动态高度监听 */const initDynamicHeight = () => {if (!enableDynamicHeight)returnwindow.addEventListener('resize', handleResize)nextTick(() => {setupResizeObserver()})}/** 清理动态高度监听 */const cleanupDynamicHeight = () => {if (!enableDynamicHeight)returnwindow.removeEventListener('resize', handleResize)if (resizeObserver) {resizeObserver.disconnect()}}/** 查询列表 */const getList = async () => {loading.value = truetry {const res = await listApi(queryParams.value)dataList.value = res.rowstotal.value = res.total} catch (error) {console.error('查询失败:', error)ElMessage.error('查询失败')} finally {loading.value = false}}/** 表单重置 */const reset = () => {form.value = { ...initFormData } as FformRef.value?.resetFields()}/** 取消操作 */const cancel = () => {reset()dialog.visible = false}/** 搜索操作 */const handleQuery = () => {queryParams.value.pageNum = 1getList()}/** 重置查询 */const resetQuery = () => {queryFormRef.value?.resetFields()queryParams.value = createQueryParams()handleQuery()}/** 新增操作 */const handleAdd = () => {reset()dialog.visible = truedialog.title = `添加${entityName}`}/** 修改操作 */const handleUpdate = async (row: any) => {if (!getApi) {console.warn('getApi 未提供,无法执行修改操作')return}reset()const id = row?.idtry {const res = await getApi(id)Object.assign(form.value, res.data)dialog.visible = truedialog.title = `修改${entityName}`} catch (error) {console.error('获取详情失败:', error)ElMessage.error('获取详情失败')}}/** 提交表单 */const submitForm = async () => {if (!formRef.value)returnconst isValid = await formRef.value.validate()if (!isValid) {ElMessage.warning('请完善表单信息')return}buttonLoading.value = truetry {if ((form.value as any).id) {if (updateApi) {await updateApi(form.value)}} else {if (addApi) {await addApi(form.value)}}ElMessage.success('操作成功')dialog.visible = falseawait getList()} catch (error) {console.error('操作失败:', error)ElMessage.error('操作失败')} finally {buttonLoading.value = false}}/** 删除操作 */const handleDelete = async (row: any) => {if (!deleteApi) {console.warn('deleteApi 未提供,无法执行删除操作')return}const id = row?.idtry {await ElMessageBox.confirm(`是否确认删除${entityName}编号为"${id}"的数据项?`,'警告',{confirmButtonText: '确定',cancelButtonText: '取消',type: 'warning',},)await deleteApi(id)ElMessage.success('删除成功')await getList()} catch (error) {if (error !== 'cancel') {console.error('删除失败:', error)ElMessage.error('删除失败')}}}/** 批量删除操作 */const handleBatchDelete = async () => {if (!deleteApi) {console.warn('deleteApi 未提供,无法执行批量删除操作')return}if (ids.value.length === 0) {ElMessage.warning('请选择要删除的数据项')return}try {await ElMessageBox.confirm(`是否确认删除选中的${ids.value.length}个${entityName}数据项?`,'警告',{confirmButtonText: '确定',cancelButtonText: '取消',type: 'warning',},)await Promise.all(ids.value.map(id => deleteApi(id)))ElMessage.success('删除成功')await getList()// 清空选中项ids.value = []single.value = truemultiple.value = true} catch (error) {if (error !== 'cancel') {console.error('删除失败:', error)ElMessage.error('删除失败')}}}/** 多选框选中数据 */const handleSelectionChange = (selection: T[]) => {ids.value = selection.map(item => (item as any).id)single.value = selection.length !== 1multiple.value = !selection.length}/** 导出操作 */const handleExport = () => {if (!exportUrl) {console.warn('exportUrl 未提供,无法执行导出操作')return}generalDownload(exportUrl,{...queryParams.value,},`${entityName}_${getTimestamp()}.xlsx`,)}/** 自定义导出操作 */const handleCustomExportWithParams = (customUrl: string, customFileName?: string, customParams?: any, includePagination: boolean = false) => {if (!customUrl) {console.warn('导出URL未提供')return}let exportParams = { ...queryParams.value }if (!includePagination) {const { pageNum, pageSize, ...rest } = exportParamsexportParams = rest}exportParams = {...exportParams,...customParams,}generalDownload(customUrl,exportParams,customFileName || `台帐_${getTimestamp()}.xlsx`,)}/** 下载模板 */const importTemplate = () => {if (!importTemplateUrl) {console.warn('importTemplateUrl 未提供,无法下载模板')return}generalDownload(importTemplateUrl,{},`${entityName}模板_${getTimestamp()}.xlsx`,)}/** 导入操作 */const handleImport = () => {if (!importUrl) {console.warn('importUrl 未提供,无法执行导入操作')return}upload.title = `${entityName}导入`upload.open = true}/** 自定义导入操作模板下载 */const handleCustomImportTemplateDownload = (customUrl: string, customFileName?: string) => {if (!customUrl) {console.warn('customUrl 未提供,无法下载模板')return}generalDownload(customUrl,{},`${customFileName}模板_${getTimestamp()}.xlsx`,)}/** 文件上传进度处理 */const handleFileUploadProgress = () => {upload.isUploading = true}/** 文件上传成功处理 */const handleFileSuccess = (response: any, file: any) => {upload.open = falseupload.isUploading = falseuploadRef.value?.handleRemove(file)ElMessage.info(response.msg)getList()}/** 提交上传文件 */const submitFileForm = () => {uploadRef.value?.submit()}// 初始化onMounted(() => {getList()initDynamicHeight()})onUnmounted(() => {cleanupDynamicHeight()})return {// 响应式数据dataList,loading,buttonLoading,showSearch,total,ids,single,multiple,queryFormRef,formRef,uploadRef,queryParams,form,dialog,upload,// 动态高度相关tableMaxHeight,searchFormRef,paginationRef,tableHeaderRef,// 方法getList,cancel,reset,handleQuery,resetQuery,handleAdd,handleUpdate,submitForm,handleDelete,handleBatchDelete,handleSelectionChange,handleExport,importTemplate,handleImport,handleFileUploadProgress,handleFileSuccess,submitFileForm,handleCustomExportWithParams,handleCustomImportTemplateDownload,}
}
希望这篇博客对你有帮助,如果有需要调整或补充的地方,我可以继续优化。
