GridForm:基于 Vue 3 + Ant Design Vue 的表格样式通用表单组件
背景
在企业级后台系统开发中,表单是使用频率最高的 UI 元素之一。传统的 a-form + a-row/col 栅格布局虽然灵活,但在需要精确对齐多列字段、实现单元格合并等复杂布局时,往往需要大量手动调整,代码也不够直观。
参考 FineUI 等企业级 UI 框架的表格样式表单设计,笔者基于 Vue 3 + Ant Design Vue 4 封装了一个通用表格样式表单组件 GridForm,以 <table> 为底层结构,通过配置式 fields 数组驱动渲染,一行代码完成复杂表单布局。
效果预览
单列布局
两列布局 + span 横向合并
三列布局 + 多种控件类型
rowSpan 纵向合并 + noLabel 无标签字段
校验错误提示
后端错误注入(setErrors)
技术栈
| 技术 | 版本 | 说明 |
| Vue 3 | ^3.5 | Composition API + <script setup> |
| Ant Design Vue | ^4.x | UI 组件库 |
| Vite | ^5.x | 构建工具 |
| JavaScript | ES2020+ | 无 TypeScript 依赖 |
核心设计
布局模型
组件内部使用"逻辑列"概念:
物理总列数 = columns * 2 每个字段默认占 2 个物理列(1 标签列 + 1 内容列)
通过 border-collapse: separate; border-spacing: 0 模式,既保持单元格紧贴,又支持 box-shadow 焦点高亮效果。
边框采用"每个单元格只绘制右边框和下边框,table 负责上/左边框"的方案,避免相邻单元格边框叠加导致颜色深浅不一。
布局属性
| 属性 | 说明 |
span |
横向合并:字段占几个逻辑列宽 |
rowSpan |
纵向合并:内容列跨几行 |
noLabel |
不渲染标签列,内容自动补满 |
layoutRows 计算逻辑
这是组件的核心计算属性,负责将 fields 数组按行分组:
const layoutRows = computed(() => { const rows = [] // 占用表:记录被 rowSpan 占用的行列位置 const occupied = new Map() let currentRow = [], currentColPos = 0, rowIndex = 0 for (const field of props.fields) { const span = field.span || 1 // 跳过被 rowSpan 占用的列位置 while (isOccupied(rowIndex, currentColPos)) currentColPos++ // 放不下则换行 if (currentColPos + span > props.columns && currentRow.length > 0) { rows.push(currentRow) currentRow = [] rowIndex++ currentColPos = 0 } // 标记后续行占用 if (field.rowSpan > 1) markOccupied(rowIndex, currentColPos, field.rowSpan) currentRow.push(field) currentColPos += span if (currentColPos >= props.columns) { rows.push(currentRow) currentRow = [] rowIndex++ currentColPos = 0 } } if (currentRow.length > 0) rows.push(currentRow) return rows })
内容列 colspan 计算
function calcContentColSpan(field) { if (field.contentColSpan) return field.contentColSpan const span = field.span || 1 if (field.noLabel) return span * 2 // 无标签:吞掉标签列 return span === 1 ? 1 : span * 2 - 1 // 多列合并:跨越中间的标签列 }
快速上手
安装依赖
npm install ant-design-vue@4
注册组件
// main.js import Antd from 'ant-design-vue' import 'ant-design-vue/dist/reset.css' createApp(App).use(Antd).mount('#app')
将 GridForm.vue 复制到项目中即可,无需额外配置。
示例一:单列布局
<template> <GridForm ref="formRef" title="个人简介" :columns="1" :fields="fields" v-model="formData" label-width="90px" /> <a-button type="primary" @click="formRef.validate()">提交</a-button> </template> <script setup> import { ref } from 'vue' import GridForm from '@/components/GridForm.vue' const formRef = ref(null) const fields = [ { name: 'nickname', label: '昵称', required: true }, { name: 'age', label: '年龄', type: 'number', min: 1, max: 120 }, { name: 'email', label: '邮箱', required: true, validator: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : '邮箱格式不正确' }, { name: 'bio', label: '简介', type: 'textarea', rows: 3 }, ] let formData = ref({}) </script>
示例二:两列布局 + span 横向合并
<template> <GridForm title="联系我们" :columns="2" :fields="fields" v-model="formData" /> </template> <script setup> import { ref } from 'vue' import GridForm from '@/components/GridForm.vue' const fields = [ { name: 'firstName', label: '姓', required: true, span: 1 }, { name: 'lastName', label: '名', required: true, span: 1 }, // span=2:横跨两列,占满整行 { name: 'email', label: '电子邮箱', required: true, span: 2 }, { name: 'subject', label: '主题', required: true, span: 2 }, { name: 'message', label: '消息正文', type: 'textarea', rows: 5, span: 2 }, ] let formData = ref({}) </script>
示例三:rowSpan 纵向合并 + noLabel 无标签
<template> <GridForm title="商品信息" :columns="2" :fields="fields" v-model="formData" /> </template> <script setup> import { ref } from 'vue' import GridForm from '@/components/GridForm.vue' /** * 布局示意: * ┌────────┬──────────┬────────┬──────────────┐ * │ 商品名 │ [input] │ 备注 │ │ * ├────────┼──────────┤ │ [textarea] │ * │ 价格 │ [number] │ row │ rowSpan=3 │ * ├────────┼──────────┤ Span=3 │ │ * │ 品牌 │ [input] │ │ │ * ├────────┴──────────┴────────┴──────────────┤ * │ [无标签,noLabel=true,span=2,跨满整行] │ * └───────────────────────────────────────────┘ */ const fields = [ { name: 'goodsName', label: '商品名', required: true }, { name: 'remark', label: '备注', type: 'textarea', rows: 6, rowSpan: 3 }, { name: 'price', label: '价格', type: 'number' }, { name: 'brand', label: '品牌' }, { name: 'desc', noLabel: true, type: 'textarea', rows: 2, span: 2, placeholder: '请输入商品详细描述' }, ] let formData = ref({}) </script>
示例四:后端校验错误注入
// 模拟后端返回字段级错误 formRef.value.setErrors({ username: '用户名已被占用', email: '该邮箱已注册', })
支持的控件类型
| type | 控件 |
input(默认) |
单行文本 |
textarea |
多行文本 |
number |
数字输入框 |
select |
下拉选择框 |
date |
日期选择器 |
date-range |
日期范围选择器 |
radio |
单选按钮组 |
checkbox |
多选框组 |
switch |
开关 |
text |
纯文本展示 |
暴露的方法
| 方法 | 说明 |
validate() |
触发校验,返回 Boolean |
clearValidate() |
清除所有错误 |
resetFields() |
重置为默认值 |
setErrors(map) |
注入外部错误 |
formData |
当前表单数据(Ref) |
焦点高亮实现细节
组件去掉了 Ant Design Vue 控件自身的边框和阴影,统一由 <td> 单元格管理视觉状态:
/* 焦点态:蓝色边框 + 光晕 */ .grid-form-content.is-focused { border-color: #1677ff; box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2); position: relative; z-index: 1; } /* 校验错误:浅红底色 */ .grid-form-content.is-error { background: #fff2f0; }
通过在 <td> 上监听 @focusin/@focusout 事件跟踪焦点字段:
const focusedField = ref(null) // <td @focusin="focusedField = field.name" @focusout="focusedField = null">
总结
GridForm 通过 <table> 底层结构 + fields 配置数组,实现了:
- ✅ 任意列数布局(columns 属性)
- ✅ 横向合并(span)
- ✅ 纵向合并(rowSpan)
- ✅ 无标签字段(noLabel)
- ✅ 10 种内置控件类型
- ✅ 内置校验 + 后端错误注入
- ✅ 焦点单元格高亮
- ✅ 插槽自定义渲染
适合在企业级后台系统中需要密集信息录入的场景,与传统栅格表单相比布局更加精确,视觉上更接近 Excel 风格的信息填报界面。
开源地址: https://gitee.com/wanghenan/grid-from
欢迎 Star ⭐ 和提 Issue。