vue + ant-design-vue + vuedraggable 实现可视化表单设计器

发布于:2025-09-11 ⋅ 阅读:(18) ⋅ 点赞:(0)

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:环境准备

  1. 安装依赖:

    # 安装 Ant Design Vue(UI组件)
    npm install ant-design-vue@1.7.8 --save
    # 安装 vuedraggable(拖拽功能)
    npm install vuedraggable@2.24.3 --save
    
  2. 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. 已实现功能

  • ✅ 拖拽组件生成表单
  • ✅ 自定义字段属性(标签、必填、占位提示等)
  • ✅ 字段排序、编辑、删除
  • ✅ 表单预览与保存校验

网站公告

今日签到

点亮在社区的每一天
去签到