Vue 可视化表单设计器:从0到1实现拖拽式表单配置
本文将基于 Vue + Ant Design Vue + vuedraggable,手把手教你实现一个可视化表单设计器,支持组件拖拽、属性配置、表单预览与保存。
一、项目介绍
1. 核心用途
通过拖拽组件快速生成表单,无需编写代码即可配置:
- 单行文本、多行文本、数字等基础字段
- 下拉选择、单选/复选框组等选择类字段
- 日期选择、文件上传、开关等特殊字段
- 自定义字段标签、必填规则、占位提示等属性
2. 技术栈
技术/框架 | 用途 |
---|---|
Vue 2.x | 前端框架(核心逻辑承载) |
Ant Design Vue | UI组件库(提供表单组件、按钮、模态框等) |
vuedraggable | 拖拽插件(实现组件拖拽、字段排序) |
JavaScript | 逻辑处理(拖拽事件、属性更新、表单保存) |
二、核心功能与视觉效果
先通过3张截图直观了解设计器的功能模块,后续将逐一实现这些效果:
1. 初始设计界面(截图1)
- 顶部工具栏:保存表单、预览表单、输入表单标题
- 左侧组件栏:提供9类可拖拽组件(单行文本、多行文本、数字等,剩余可自定义)
- 中间画布:拖拽组件的目标区域,初始显示“从左侧拖拽组件到此处”
2. 组件属性配置(截图2)
- 拖拽组件到画布后,右侧属性面板自动激活
- 可配置字段标签(如“单行文本1”)、字段名称(如“checkbox_6”)
- 选择类组件(如复选框组)支持添加/删除选项(如“选项1”“选项2”)
- 支持配置“是否必填”“占位提示”等基础规则
3. 表单预览效果(截图3)
- 点击“预览”按钮,打开模态框展示最终表单样式
- 预览界面与实际填写界面一致,支持查看字段布局和交互效果
- 提供“取消”“确定”按钮,模拟表单提交流程
三、分步实现教程
步骤1:环境准备
安装依赖:
# 安装 Ant Design Vue(UI组件) npm install ant-design-vue@1.7.8 --save # 安装 vuedraggable(拖拽功能) npm install vuedraggable@2.24.3 --save
在
main.js
全局引入 Ant Design Vue:import Vue from 'vue'; import Antd from 'ant-design-vue'; import 'ant-design-vue/dist/antd.css'; Vue.use(Antd);
步骤2:搭建入口页面(FormDesignView.vue)
入口页面负责承载表单设计器,处理路由参数(编辑/新建表单)和页面导航,对应文档中的 FormDesignView.vue
。
代码实现
<template>
<div class="form-design-page">
<!-- 页面头部:标题、返回按钮、副标题 -->
<a-page-header
title="表单设计器"
sub-title="自定义督导表单,支持拖拽配置字段"
@back="handleGoBack"
/>
<!-- 表单设计器容器(白色背景+阴影,提升视觉体验) -->
<div class="designer-container">
<form-designer
:form-id="formId"
@save-success="handleSaveSuccess"
@cancel="handleGoBack"
/>
</div>
</div>
</template>
<script>
// 引入核心表单设计器组件
import FormDesigner from './components/form-design/FormDesigner';
export default {
name: 'FormDesignView',
components: { FormDesigner },
data() {
return {
// 从路由参数获取formId(编辑场景),新建时为null
formId: this.$route.query.formId || null
}
},
methods: {
// 返回上一页
handleGoBack() {
this.$router.go(-1);
},
// 表单保存成功后的回调(接收设计器传递的formId和表单名称)
handleSaveSuccess(formId, formName) {
// 提示保存成功
this.$message.success(`表单【${formName}】保存成功`);
// 新建表单时,更新formId并同步到路由(避免刷新丢失)
if (!this.formId) {
this.formId = formId;
this.$router.push({
path: '/form-design',
query: { formId } // 路由携带formId,支持后续编辑
});
}
}
}
}
</script>
<style scoped>
.form-design-page {
padding: 16px;
background-color: #f5f7fa; /* 页面背景色,区分内容区域 */
min-height: 100vh;
}
.designer-container {
margin-top: 20px;
background-color: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); /* 轻微阴影,提升层次感 */
padding: 20px;
}
</style>
步骤3:实现核心设计器(FormDesigner.vue)
这是整个项目的核心,包含“左侧组件拖拽”“中间画布渲染”“右侧属性配置”“预览/保存”四大模块,对应文档中的 FormDesigner.vue
。
3.1 模板结构(Template)
先搭建页面骨架,分为工具栏、左侧组件栏、中间画布、右侧属性面板、预览模态框:
<template>
<div class="form-designer">
<a-card title="表单设计器">
<!-- 1. 顶部工具栏:保存、预览、表单标题输入 -->
<div class="designer-toolbar">
<a-button type="primary" @click="saveForm">保存表单</a-button>
<a-button style="margin-left: 10px" @click="previewForm">预览</a-button>
<a-input
v-model="formTitle"
placeholder="请输入表单标题"
style="width: 300px; margin-left: 20px"
/>
</div>
<!-- 2. 核心容器:左侧组件栏 + 中间画布 + 右侧属性面板 -->
<div class="designer-container">
<!-- 左侧:可拖拽组件列表 -->
<div class="designer-components">
<h3>表单组件</h3>
<div
class="component-item"
v-for="item in componentList"
:key="item.type"
draggable
@dragstart="handleDragStart(item)"
>
<a-icon :type="item.icon" />
<span>{{ item.name }}</span>
</div>
</div>
<!-- 中间:画布(拖拽目标区域 + 已添加字段) -->
<div
class="designer-canvas"
@dragover.prevent
@drop="handleDrop"
>
<!-- 表单标题(为空时显示“未命名表单”) -->
<div class="canvas-title">{{ formTitle || '未命名表单' }}</div>
<!-- 已添加的字段(支持拖拽排序) -->
<draggable
v-model="formFields"
:options="{
animation: 200,
handle: '.form-item-header',
ghostClass: 'form-item-ghost'
}"
@end="handleDragEnd"
class="no-select"
>
<!-- 循环渲染已添加的字段 -->
<div class="form-item" v-for="(field, index) in formFields" :key="field.id">
<!-- 字段头部(可拖拽、编辑、删除、上下移动) -->
<div class="form-item-header no-select">
<a-icon type="menu" style="cursor: move; margin-right: 8px;" />
<span>{{ field.label }}</span>
<div class="form-item-actions">
<a-icon type="up" @click="moveField(index, 'up')" /> <!-- 上移 -->
<a-icon type="down" @click="moveField(index, 'down')" /> <!-- 下移 -->
<a-icon type="edit" @click="editField(index)" /> <!-- 编辑属性 -->
<a-icon type="delete" @click="deleteField(index)" /> <!-- 删除字段 -->
</div>
</div>
<!-- 字段预览(画布中显示禁用状态,避免编辑干扰) -->
<div class="form-item-preview">
<!-- 单行文本 -->
<template v-if="field.type === 'text'">
<a-input disabled v-model="field.value" :placeholder="field.placeholder" />
</template>
<!-- 多行文本 -->
<template v-if="field.type === 'textarea'">
<a-textarea disabled v-model="field.value" rows="3" :placeholder="field.placeholder" />
</template>
<!-- 其他组件(数字、下拉、单选等)按此格式添加,参考文档完整代码 -->
</div>
</div>
</draggable>
<!-- 画布为空时的提示 -->
<div v-if="formFields.length === 0" class="empty-canvas">
<a-icon type="plus-circle" />
<p>从左侧拖拽组件到此处</p>
</div>
</div>
<!-- 右侧:组件属性配置面板(仅选中字段时显示) -->
<div class="designer-properties" v-if="currentField">
<h3>组件属性</h3>
<a-form layout="vertical">
<!-- 字段标签 -->
<a-form-item label="字段标签">
<a-input v-model="currentField.label" />
</a-form-item>
<!-- 字段名称(用于表单提交的key) -->
<a-form-item label="字段名称">
<a-input v-model="currentField.name" />
</a-form-item>
<!-- 是否必填 -->
<a-form-item label="是否必填">
<a-switch v-model="currentField.required" />
</a-form-item>
<!-- 占位提示 -->
<a-form-item label="占位提示">
<a-input v-model="currentField.placeholder" />
</a-form-item>
<!-- 选择类组件(下拉/单选/复选)的选项配置 -->
<template v-if="['select', 'radio', 'checkbox'].includes(currentField.type)">
<a-form-item label="选项配置">
<a-button type="dashed" style="width: 100%" @click="addOption">
<a-icon type="plus" /> 添加选项
</a-button>
<!-- 循环渲染选项 -->
<div v-for="(option, i) in currentField.options" :key="i" class="option-item">
<a-input v-model="option.label" placeholder="选项文本" style="width: 45%; margin-right: 10px" />
<a-input v-model="option.value" placeholder="选项值" style="width: 45%" />
<a-icon type="close" @click="removeOption(i)" style="margin-left: 10px; cursor: pointer" />
</div>
</a-form-item>
</template>
<!-- 其他属性(如文本最大长度、上传类型)按此格式添加,参考文档完整代码 -->
</a-form>
</div>
</div>
</a-card>
<!-- 3. 预览模态框(点击“预览”时打开) -->
<a-modal
title="表单预览"
:visible="previewVisible"
@cancel="previewVisible = false"
width="600px"
:footer="[{ text: '取消', onClick: () => (previewVisible = false) }, { text: '确定', onClick: () => (previewVisible = false) }]"
>
<a-form :model="previewFormData">
<!-- 循环渲染预览字段(与画布逻辑类似,但不禁用) -->
<a-form-item
v-for="(field, index) in formFields"
:key="field.id"
:label="field.label"
:required="field.required"
>
<!-- 单行文本(预览时可编辑) -->
<template v-if="field.type === 'text'">
<a-input v-model="field.value" :placeholder="field.placeholder" />
</template>
<!-- 其他组件预览逻辑,参考文档完整代码 -->
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
3.2 逻辑处理(Script)
实现拖拽事件、属性更新、保存预览等核心逻辑:
<script>
// 引入拖拽组件
import draggable from 'vuedraggable';
export default {
components: { draggable },
data() {
return {
formTitle: '', // 表单标题
formFields: [], // 已添加的字段列表
componentList: [
{ type: 'text', name: '单行文本', icon: 'edit' },
{ type: 'textarea', name: '多行文本', icon: 'align-left' },
{ type: 'number', name: '数字', icon: 'calculator' },
{ type: 'select', name: '下拉选择', icon: 'down' },
{ type: 'radio', name: '单选框组', icon: 'check-circle' },
{ type: 'checkbox', name: '复选框组', icon: 'check-square' },
{ type: 'date', name: '日期选择', icon: 'calendar' },
{ type: 'upload', name: '文件上传', icon: 'upload' },
{ type: 'switch', name: '开关', icon: 'swap' }
],
currentField: null, // 当前选中的字段(用于属性配置)
previewVisible: false, // 预览模态框显示状态
previewFormData: {} // 预览表单数据
}
},
methods: {
// 1. 拖拽开始:生成新字段的默认配置
handleDragStart(component) {
// 生成唯一字段ID(避免重复)
const fieldId = `field_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
// 新字段的默认配置
const newField = {
id: fieldId,
type: component.type, // 组件类型(如text、radio)
label: `${component.name} ${this.formFields.length + 1}`, // 默认标签(如“单行文本1”)
name: `${component.type}_${this.formFields.length + 1}`, // 默认字段名(如“text_1”)
required: false, // 默认非必填
placeholder: `请输入${component.name}`, // 默认占位提示
...this.getDefaultFieldProps(component.type) // 组件专属默认属性(如选项、最大长度)
};
// 存储拖拽的字段数据(用于拖拽释放时获取)
event.dataTransfer.setData('field', JSON.stringify(newField));
},
// 2. 拖拽释放:将新字段添加到画布
handleDrop(event) {
const fieldData = event.dataTransfer.getData('field');
if (fieldData) {
const newField = JSON.parse(fieldData);
this.formFields.push(newField); // 添加到字段列表
this.currentField = { ...newField }; // 自动选中新字段,方便配置属性
}
},
// 3. 获取组件专属默认属性(如单选框默认2个选项)
getDefaultFieldProps(type) {
const props = {};
switch (type) {
case 'text':
case 'textarea':
props.value = ''; // 文本类默认值为空
props.maxLength = 100; // 默认最大长度100
break;
case 'select':
case 'radio':
case 'checkbox':
props.value = undefined; // 选择类默认未选中
props.options = [{ label: '选项1', value: '1' }, { label: '选项2', value: '2' }]; // 默认2个选项
break;
case 'upload':
props.fileList = []; // 上传类默认无文件
props.accept = 'image/*'; // 默认支持图片上传
props.maxCount = 1; // 默认最多上传1个文件
break;
default:
break;
}
return props;
},
// 4. 编辑字段:选中字段并加载属性
editField(index) {
this.currentField = { ...this.formFields[index] }; // 深拷贝,避免直接修改原数据
},
// 5. 删除字段:弹窗确认后删除
deleteField(index) {
this.$confirm({
title: '确认删除',
content: '确定要删除这个字段吗?',
onOk: () => {
this.formFields.splice(index, 1); // 从列表中删除
// 若删除的是当前选中字段,清空属性面板
if (this.currentField && this.formFields.findIndex(f => f.id === this.currentField.id) === -1) {
this.currentField = null;
}
}
});
},
// 6. 字段上下移动
moveField(index, direction) {
if (direction === 'up' && index > 0) {
// 上移:与前一个字段交换位置
[this.formFields[index], this.formFields[index - 1]] = [this.formFields[index - 1], this.formFields[index]];
} else if (direction === 'down' && index < this.formFields.length - 1) {
// 下移:与后一个字段交换位置
[this.formFields[index], this.formFields[index + 1]] = [this.formFields[index + 1], this.formFields[index]];
}
this.formFields = [...this.formFields]; // 触发数组更新
},
// 7. 为选择类组件添加选项
addOption() {
if (!this.currentField.options) this.currentField.options = [];
this.currentField.options.push({
label: `新选项${this.currentField.options.length + 1}`,
value: (this.currentField.options.length + 1).toString()
});
},
// 8. 删除选择类组件的选项
removeOption(index) {
this.currentField.options.splice(index, 1);
},
// 9. 保存表单:校验 + 生成表单数据
saveForm() {
// 校验:表单标题不能为空
if (!this.formTitle) {
this.$message.error('请输入表单标题');
return;
}
// 校验:至少添加一个字段
if (this.formFields.length === 0) {
this.$message.error('请添加表单字段');
return;
}
// 生成最终表单数据(可提交到后端存储)
const formData = {
title: this.formTitle,
fields: this.formFields
};
console.log('保存的表单数据:', formData);
// 触发父组件的保存成功回调(传递表单ID和名称,实际项目需后端返回formId)
this.$emit('save-success', 'form_' + Date.now(), this.formTitle);
},
// 10. 预览表单:初始化预览数据并打开模态框
previewForm() {
if (this.formFields.length === 0) {
this.$message.error('请添加表单字段');
return;
}
this.previewFormData = {};
// 初始化预览数据(为空)
this.formFields.forEach(field => {
this.previewFormData[field.name] = '';
});
this.previewVisible = true; // 打开预览模态框
},
// 11. 字段排序结束:打印日志(可扩展后端同步排序)
handleDragEnd() {
console.log('字段排序已更新:', this.formFields.map(field => field.id));
}
},
// 监听currentField变化:实时更新画布中的字段属性
watch: {
currentField: {
handler(newVal) {
if (newVal) {
// 找到当前字段在列表中的索引
const index = this.formFields.findIndex(f => f.id === newVal.id);
if (index !== -1) {
this.formFields.splice(index, 1, { ...newVal }); // 更新字段属性
}
}
},
deep: true // 深度监听(监听对象内部属性变化)
}
}
}
</script>
3.3 样式美化(Style)
通过CSS优化布局和交互体验,确保与截图效果一致:
<style scoped>
/* 设计器整体容器 */
.form-designer {
padding: 20px;
}
/* 顶部工具栏 */
.designer-toolbar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
/* 核心容器(三栏布局) */
.designer-container {
display: flex;
gap: 20px;
height: calc(100vh - 180px); /* 固定高度,超出滚动 */
}
/* 左侧组件栏 */
.designer-components {
width: 200px;
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 10px;
overflow-y: auto; /* 组件过多时滚动 */
}
/* 单个组件项 */
.component-item {
padding: 10px;
margin-bottom: 10px;
border: 1px solid #e8e8e8;
border-radius: 4px;
cursor: move;
display: flex;
align-items: center;
gap: 8px;
background: #fff;
}
/* 组件项 hover 效果 */
.component-item:hover {
border-color: #1890ff; /* AntD主题色 */
background: #f0f7ff;
}
/* 中间画布 */
.designer-canvas {
flex: 1; /* 占满剩余宽度 */
border: 1px dashed #e8e8e8;
border-radius: 4px;
padding: 20px;
overflow-y: auto;
background: #fafafa;
}
/* 画布标题 */
.canvas-title {
text-align: center;
font-size: 18px;
font-weight: bold;
margin-bottom: 30px;
}
/* 单个字段容器 */
.form-item {
background: #fff;
padding: 15px;
margin-bottom: 15px;
border-radius: 4px;
border: 1px solid #e8e8e8;
}
/* 字段拖拽占位样式 */
.form-item-ghost {
border: 1px dashed #1890ff !important;
background-color: #e6f7ff !important;
opacity: 0.8;
}
/* 字段头部(可拖拽区域) */
.form-item-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
color: #1890ff;
align-items: center;
cursor: move;
}
/* 字段操作按钮组(上下移、编辑、删除) */
.form-item-actions {
display: flex;
gap: 5px;
}
.form-item-actions .anticon {
cursor: pointer;
font-size: 14px;
}
/* 右侧属性面板 */
.designer-properties {
width: 300px;
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 10px;
overflow-y: auto;
}
/* 画布为空时的提示 */
.empty-canvas {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
}
.empty-canvas .anticon {
font-size: 48px;
margin-bottom: 10px;
}
/* 选项配置项(如单选框的选项) */
.option-item {
display: flex;
align-items: center;
margin-top: 10px;
}
/* 禁止文本选中(避免拖拽时选中文本) */
.no-select {
-webkit-user-select: none; /* Chrome/Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE/Edge */
user-select: none; /* 标准属性 */
}
</style>
四、总结与优化方向
1. 已实现功能
- ✅ 拖拽组件生成表单
- ✅ 自定义字段属性(标签、必填、占位提示等)
- ✅ 字段排序、编辑、删除
- ✅ 表单预览与保存校验