Vue 3 表单设计器实现

Vue 3 表单设计器实现:从拖拽到 Schema 生成的全流程解析

1. 项目背景与功能概述

在现代前端开发中,表单是用户与系统交互的重要桥梁。为了提高开发效率和用户体验,我们开发了一个基于 Vue 3 的可视化表单设计器,支持组件拖拽、属性配置和 Schema 生成等核心功能。

核心功能

  • 组件拖拽:从左侧组件库拖拽组件到中间设计区域
  • 实时预览:在设计区域实时预览表单效果
  • 属性配置:通过右侧面板配置组件和表单属性
  • Schema 生成:自动生成符合 JSON Schema 规范的表单配置
  • Schema 导入:支持通过导入 Schema 快速创建表单
  • 组件排序:支持在设计区域拖动组件调整顺序

2. 技术栈与架构设计

技术栈

  • 前端框架:Vue 3 + Composition API
  • UI 库:Element Plus
  • 拖拽实现:HTML5 原生拖拽 API
  • 文件上传:自定义文件管理器组件
  • 样式方案:SCSS + 自定义主题

架构设计

表单设计器采用三栏布局:

  • 左侧:组件库,包含各种表单组件
  • 中间:设计区域,用于预览和调整表单
  • 右侧:属性配置面板,用于配置组件和表单属性

3. 核心功能实现

3.1 组件拖拽功能

表单设计器的核心功能之一是组件拖拽,我们使用 HTML5 原生拖拽 API 实现:

TypeScript 复制代码
// 拖拽开始
const onDragStart = (event, component) => {
  event.dataTransfer.setData('text/plain', JSON.stringify(component));
  event.dataTransfer.effectAllowed = 'copy';
  // 添加拖拽时的视觉效果
  event.target.style.opacity = '0.5';
};

// 拖拽结束
const onDragEnd = (event) => {
  event.target.style.opacity = '1';
};

// 拖拽悬停
const onDragOver = (event) => {
  event.preventDefault();
  event.dataTransfer.dropEffect = 'copy';
  // 添加悬停效果
  event.target.classList.add('drop-area-active');
};

// 放置处理
const onDrop = (event) => {
  event.preventDefault();
  event.target.classList.remove('drop-area-active');
  const componentData = event.dataTransfer.getData('text/plain');
  
  if (componentData) {
    try {
      const component = JSON.parse(componentData);
      // 创建新字段并添加到表单
      const newField = {
        type: component.type,
        key: generateId(),
        label: component.name,
        placeholder: `请输入`,
        required: false,
        isTextarea: component.isTextarea || false
      };
      
      // 计算插入位置
      const formItems = document.querySelectorAll('.field-item');
      let insertIndex = formFields.value.length;
      
      // 找到最接近鼠标位置的表单项目
      let minDistance = Infinity;
      for (let i = 0; i < formItems.length; i++) {
        const rect = formItems[i].getBoundingClientRect();
        const centerY = rect.top + rect.height / 2;
        const distance = Math.abs(event.clientY - centerY);
        if (distance < minDistance) {
          minDistance = distance;
          insertIndex = i;
        }
      }
      
      // 插入到合适的位置
      formFields.value.splice(insertIndex, 0, newField);
      
      // 根据组件类型初始化默认值
      // ...
      
      // 自动选中新创建的组件
      selectField(newField);
    } catch (error) {
      console.error('Error parsing component data:', error);
    }
  }
};

3.2 组件排序功能

为了提高用户体验,我们实现了组件排序功能,允许用户通过拖拽调整组件顺序:

TypeScript 复制代码
// 表单组件拖动功能
const draggedIndex = ref(-1);

// 开始拖动
const startDrag = (index) => {
  draggedIndex.value = index;
};

// 结束拖动
const endDrag = () => {
  draggedIndex.value = -1;
  // 移除所有视觉反馈类
  document.querySelectorAll('.field-item').forEach(item => {
    item.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
  });
};

// 允许放置
const allowDrop = (event) => {
  event.preventDefault();
  // 移除所有其他元素的视觉反馈
  document.querySelectorAll('.field-item').forEach(item => {
    item.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
  });
  
  // 添加视觉反馈
  const target = event.currentTarget;
  target.classList.add('drag-over');
  
  // 计算鼠标位置,判断是在组件上方还是下方
  const rect = target.getBoundingClientRect();
  const y = event.clientY - rect.top;
  if (y < rect.height / 2) {
    target.classList.add('drag-over-top');
  } else {
    target.classList.add('drag-over-bottom');
  }
};

// 放置处理
const drop = (event, targetIndex) => {
  event.preventDefault();
  // 移除视觉反馈
  const target = event.currentTarget;
  target.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
  
  if (draggedIndex.value !== -1) {
    // 计算实际插入位置
    const rect = target.getBoundingClientRect();
    const y = event.clientY - rect.top;
    let insertIndex = targetIndex;
    
    // 如果鼠标在组件下方,插入到组件后面
    if (y >= rect.height / 2) {
      insertIndex += 1;
    }
    
    // 确保插入位置有效
    insertIndex = Math.max(0, Math.min(insertIndex, formFields.value.length));
    
    // 避免自己拖到自己的位置
    if (draggedIndex.value !== insertIndex) {
      // 交换位置
      const [movedField] = formFields.value.splice(draggedIndex.value, 1);
      formFields.value.splice(insertIndex, 0, movedField);
    }
    
    draggedIndex.value = -1;
  }
};

3.3 Schema 生成与导入

表单设计器支持自动生成 JSON Schema,并支持通过导入 Schema 快速创建表单:

TypeScript 复制代码
// 生成Schema
const generateSchema = () => {
  const schema = {
    type: 'object',
    properties: {},
    formConfig: {
      name: formConfig.name,
      labelPosition: formConfig.labelPosition,
      size: formConfig.size,
      labelSuffix: formConfig.labelSuffix,
      labelWidth: formConfig.labelWidth,
      itemMarginBottom: formConfig.itemMarginBottom
    }
  };

  formFields.value.forEach(field => {
    const fieldSchema = {
      type: getFieldType(field.type),
      title: field.label,
      required: field.required
    };

    // 添加默认值
    if (formData[field.key] !== undefined && formData[field.key] !== null) {
      fieldSchema.default = formData[field.key];
    }

    // 添加选项配置
    if (field.options) {
      fieldSchema.options = field.options;
    }

    // 添加文件信息
    if (field.files) {
      fieldSchema.files = field.files;
    }

    schema.properties[field.key] = fieldSchema;
  });

  generatedSchema.value = JSON.stringify(schema, null, 2);

  saveSchema();
};

// 导入Schema
const importSchema = () => {
  try {
    const schema = JSON.parse(importedSchema.value);

    // 清空现有表单
    formFields.value = [];
    Object.keys(formData).forEach(key => {
      delete formData[key];
    });
    selectedField.value = null;

    // 导入表单配置
    if (schema.formConfig) {
      formConfig.name = schema.formConfig.name || '';
      formConfig.labelPosition = schema.formConfig.labelPosition || 'right';
      formConfig.size = schema.formConfig.size || 'default';
      formConfig.labelSuffix = schema.formConfig.labelSuffix || '';
      formConfig.labelWidth = schema.formConfig.labelWidth || 125;
      formConfig.itemMarginBottom = schema.formConfig.itemMarginBottom || 20;
    }

    // 导入字段
    if (schema.properties) {
      Object.keys(schema.properties).forEach(key => {
        const fieldSchema = schema.properties[key];
        let componentType = getComponentType(fieldSchema.type);

        // 根据选项和标题判断组件类型
        if (fieldSchema.options) {
          const label = fieldSchema.title || key;
          if (label.includes('复选框')) {
            componentType = 'el-checkbox-group';
          } else if (label.includes('单选框')) {
            componentType = 'el-radio-group';
          } else if (label.includes('下拉')) {
            componentType = 'el-select';
          } else {
            // 默认使用下拉选择框
            componentType = 'el-select';
          }
        } else if (fieldSchema.title && fieldSchema.title.includes('上传文件')) {
          // 根据标题判断上传文件组件
          componentType = 'el-upload';
        }

        const field = {
          type: componentType,
          key: key,
          label: fieldSchema.title || key,
          placeholder: `请输入`,
          required: fieldSchema.required || false,
          isTextarea: fieldSchema.type === 'textarea'
        };

        // 添加选项配置
        if (fieldSchema.options) {
          field.options = fieldSchema.options;
        }

        formFields.value.push(field);

        // 初始化默认值
        if (fieldSchema.default !== undefined && fieldSchema.default !== null) {
          formData[key] = fieldSchema.default;
        } else {
          switch (field.type) {
            case 'el-checkbox-group':
              formData[key] = [];
              break;
            case 'el-switch':
              formData[key] = false;
              break;
            case 'el-input-number':
            case 'el-slider':
              formData[key] = 0;
              break;
            default:
              formData[key] = '';
          }
        }
      });
    }

    importDialogVisible.value = false;
  } catch (error) {
    console.error('Error parsing Schema:', error);
    alert('Schema格式错误,请检查输入');
  }
};

3.4 文件上传处理

表单设计器集成了自定义文件管理器组件,支持文件上传和预览:

TypeScript 复制代码
const fileCallback = (file, fieldKey) => {
  console.log('Uploaded file:', file);
  if (file && Array.isArray(file) && file.length > 0) {
    // 保存所有文件的文件名到formData
    formData[fieldKey] = file.map(f => f.name || '未知文件').join(',');
    
    // 在右侧组件配置中显示所有文件名
    if (selectedField.value && selectedField.value.key === fieldKey) {
      selectedField.value.fileName = file.map(f => f.name || '未知文件').join(',');
      // 保存所有文件信息
      selectedField.value.files = file;
    }
  }
};

4. 代码优化与用户体验

4.1 性能优化

  1. 响应式数据管理 :使用 refreactive 合理管理响应式数据
  2. 计算属性缓存 :使用 computed 缓存计算结果
  3. 事件委托:合理使用事件委托减少事件监听器数量
  4. DOM 操作优化:批量操作 DOM,减少重排和重绘

4.2 用户体验优化

  1. 拖拽视觉反馈:添加拖拽时的视觉效果,提高用户体验
  2. 实时预览:在设计区域实时预览表单效果
  3. 自动选中:新添加组件后自动选中,方便用户配置
  4. 错误处理:添加表单验证和错误提示
  5. 滚动条样式:自定义滚动条样式,提升界面美观度
css 复制代码
/* 自定义滚动条样式 */
::-webkit-scrollbar {
  width: 2px;
  height: 8px;
}

::-webkit-scrollbar-track {
  background: #f1f1f1;
  border-radius: 4px;
}

::-webkit-scrollbar-thumb {
  background: #0b6837;
  border-radius: 4px;
  opacity: 0;
  transition: opacity 0.3s ease;
}

/* 鼠标悬停时显示滚动条 */
::-webkit-scrollbar-thumb:hover {
  opacity: 1;
}

5.完整项目代码

html 复制代码
<template>
  <div class="ticket-text-container">
    <el-card shadow="never" class="form-designer-card">
      <template #header>
        <div class="wqdCard">
          <div class="flex">
            <div class="rightArrow"></div>
            <span class="cardTitle">表单渲染</span>
          </div>
          <div class="designer-footer">
            <el-button class="generateBtn" type="primary" @click="generateSchema">保存表单</el-button>
            <!-- <el-button class="importBtn" type="info" @click="openImportDialog">导入表单</el-button> -->
            <el-button class="clearBtn" @click="clearForm">清空表单</el-button>
          </div>
        </div>
      </template>
      <div class="form-designer-container">
        <div class="designer-content">
          <el-row :gutter="20">
            <!-- 左侧组件库 -->
            <el-col :span="6">
              <div class="component-library">
                <h4>组件库</h4>
                <div class="components-list">
                  <div v-for="(component, index) in components" :key="index" class="component-item" draggable="true"
                    @mousedown="onMouseDown" @dragstart="onDragStart($event, component)" @dragend="onDragEnd">
                    <div class="component-icon">
                      <el-icon v-if="component.icon === 'Edit'">
                        <Edit />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'DataAnalysis'">
                        <DataAnalysis />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'Operation'">
                        <Operation />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'View'">
                        <View />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'Check'">
                        <Check />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'Calendar'">
                        <Calendar />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'AlarmClock'">
                        <AlarmClock />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'Switch'">
                        <Switch />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'Position'">
                        <Position />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'Document'">
                        <Document />
                      </el-icon>
                      <el-icon v-else-if="component.icon === 'Upload'">
                        <Upload />
                      </el-icon>
                    </div>
                    <div class="component-name">{{ component.name }}</div>
                  </div>
                </div>
              </div>
            </el-col>
            <!-- 中间设计区域 -->
            <el-col :span="12">
              <div class="design-area">
                <h4>设计区域</h4>
                <div class="drop-area" @drop="onDrop" @dragover="onDragOver" @dragleave="onDragLeave">
                  <el-form ref="formRef" :model="formData" :label-width="formConfig.labelWidth + 'px'"
                    :label-position="formConfig.labelPosition" :size="formConfig.size"
                    :label-suffix="formConfig.labelSuffix" class="demo-form"
                    :style="{ marginBottom: formConfig.itemMarginBottom + 'px' }">
                    <el-form-item v-for="(field, index) in formFields" :key="field.key" :label="field.label"
                      :required="field.required" @click="selectField(field)" class="field-item"
                      :style="{ marginBottom: formConfig.itemMarginBottom + 'px' }" draggable="true"
                      @dragstart="startDrag(index)" @dragend="endDrag" @dragover="allowDrop"
                      @drop="drop($event, index)">
                      <template v-if="field.type === 'el-checkbox-group'">
                        <el-checkbox-group v-model="formData[field.key]">
                          <el-checkbox v-for="option in field.options || getComponentConfig(field).options"
                            :key="option.value" :label="option.value">
                            {{ option.label }}
                          </el-checkbox>
                        </el-checkbox-group>
                      </template>
                      <template v-else-if="field.type === 'el-radio-group'">
                        <el-radio-group v-model="formData[field.key]">
                          <el-radio v-for="option in field.options || getComponentConfig(field).options"
                            :key="option.value" :label="option.value">
                            {{ option.label }}
                          </el-radio>
                        </el-radio-group>
                      </template>
                      <template v-else-if="field.type === 'el-select'">
                        <el-select v-model="formData[field.key]" :placeholder="field.placeholder">
                          <el-option v-for="option in field.options || getComponentConfig(field).options"
                            :key="option.value" :label="option.label" :value="option.value" />
                        </el-select>
                      </template>
                      <template v-else-if="field.type === 'el-date-picker'">
                        <el-date-picker v-model="formData[field.key]" type="date" :placeholder="field.placeholder"
                          style="width: 100%" />
                      </template>
                      <template v-else-if="field.type === 'el-time-picker'">
                        <el-time-picker v-model="formData[field.key]" :placeholder="field.placeholder"
                          style="width: 100%" />
                      </template>
                      <template v-else-if="field.type === 'el-upload'">
                        <WqdFile v-model:modelValue="formData[field.key]" buttonText="上传文件" :multiple="true"
                          fileSize="104857600" @change="(file) => fileCallback(file, field.key)" />
                      </template>
                      <template v-else-if="field.isTextarea">
                        <el-input v-model="formData[field.key]" type="textarea" :placeholder="field.placeholder"
                          :rows="3" />
                      </template>
                      <template v-else>
                        <component :is="getComponent(field.type)" v-model="formData[field.key]"
                          :placeholder="field.placeholder" />
                      </template>
                    </el-form-item>
                  </el-form>
                </div>
              </div>
            </el-col>
            <!-- 右侧属性配置 -->
            <el-col :span="6">
              <div class="property-panel">
                <el-tabs v-model="activeTab">
                  <el-tab-pane label="组件配置" name="component">
                    <div v-if="selectedField">
                      <el-form :model="selectedField" label-width="80px">
                        <el-form-item label="字段名">
                          <el-input v-model="selectedField.key"></el-input>
                        </el-form-item>
                        <el-form-item label="标签">
                          <el-input v-model="selectedField.label"></el-input>
                        </el-form-item>
                        <el-form-item label="占位符">
                          <el-input v-model="selectedField.placeholder"></el-input>
                        </el-form-item>
                        <el-form-item label="必填">
                          <el-switch v-model="selectedField.required"></el-switch>
                        </el-form-item>

                        <!-- 文本类组件特有属性 -->
                        <template v-if="selectedField.type === 'el-input'">
                          <el-form-item label="默认值">
                            <el-input v-model="formData[selectedField.key]"></el-input>
                          </el-form-item>
                          <el-form-item v-if="selectedField.isTextarea" label="行数">
                            <el-input-number v-model="selectedField.rows" :min="1" :max="10"></el-input-number>
                          </el-form-item>
                        </template>

                        <!-- 数字类组件特有属性 -->
                        <template v-else-if="selectedField.type === 'el-input-number'">
                          <el-form-item label="默认值">
                            <el-input-number v-model="formData[selectedField.key]"></el-input-number>
                          </el-form-item>
                        </template>

                        <!-- 开关组件特有属性 -->
                        <template v-else-if="selectedField.type === 'el-switch'">
                          <el-form-item label="默认值">
                            <el-switch v-model="formData[selectedField.key]"></el-switch>
                          </el-form-item>
                        </template>

                        <!-- 滑块组件特有属性 -->
                        <template v-else-if="selectedField.type === 'el-slider'">
                          <el-form-item label="默认值">
                            <el-slider v-model="formData[selectedField.key]" :min="0" :max="100"></el-slider>
                          </el-form-item>
                        </template>

                        <!-- 日期选择器特有属性 -->
                        <template v-else-if="selectedField.type === 'el-date-picker'">
                          <el-form-item label="默认值">
                            <el-date-picker v-model="formData[selectedField.key]" type="date" style="width: 100%" />
                          </el-form-item>
                        </template>

                        <!-- 时间选择器特有属性 -->
                        <template v-else-if="selectedField.type === 'el-time-picker'">
                          <el-form-item label="默认值">
                            <el-time-picker v-model="formData[selectedField.key]" style="width: 100%" />
                          </el-form-item>
                        </template>

                        <!-- 上传文件组件特有属性 -->
                        <template v-else-if="selectedField.type === 'el-upload'">
                          <!-- <el-form-item label="默认值">
                            <el-input v-model="formData[selectedField.key]" placeholder="文件路径" readonly></el-input>
                          </el-form-item> -->
                          <el-form-item label="文件名" v-if="formData[selectedField.key]">
                            <span>{{ formData[selectedField.key] }}</span>
                          </el-form-item>
                        </template>

                        <!-- 选择类组件特有属性 -->
                        <template
                          v-else-if="['el-checkbox-group', 'el-radio-group', 'el-select'].includes(selectedField.type)">
                          <el-form-item label="默认值">
                            <template v-if="selectedField.type === 'el-checkbox-group'">
                              <el-checkbox-group v-model="formData[selectedField.key]">
                                <el-checkbox v-for="option in getOptions(selectedField)" :key="option.value"
                                  :label="option.value">
                                  {{ option.label }}
                                </el-checkbox>
                              </el-checkbox-group>
                            </template>
                            <template v-else-if="selectedField.type === 'el-radio-group'">
                              <el-radio-group v-model="formData[selectedField.key]">
                                <el-radio v-for="option in getOptions(selectedField)" :key="option.value"
                                  :label="option.value">
                                  {{ option.label }}
                                </el-radio>
                              </el-radio-group>
                            </template>
                            <template v-else-if="selectedField.type === 'el-select'">
                              <el-select v-model="formData[selectedField.key]" style="width: 100%">
                                <el-option v-for="option in getOptions(selectedField)" :key="option.value"
                                  :label="option.label" :value="option.value" />
                              </el-select>
                            </template>
                          </el-form-item>
                          <el-form-item label="选项配置">
                            <div v-for="(option, index) in getOptions(selectedField)" :key="index" class="option-item">
                              <el-input v-model="option.label" placeholder="选项标签"
                                style="width: 100px; margin-right: 10px"></el-input>
                              <el-input v-model="option.value" placeholder="选项值" style="width: 100px"></el-input>
                              <el-button class="deleteBtn" type="danger" size="small"
                                @click="removeOption(selectedField, index)">删除</el-button>
                            </div>
                            <el-button class="addBtn" type="primary" size="small"
                              @click="addOption(selectedField)">添加选项</el-button>
                          </el-form-item>
                        </template>

                        <el-form-item>
                          <el-button class="deleteBtn" type="primary"
                            @click="removeField(selectedField)">删除组件</el-button>
                        </el-form-item>
                      </el-form>
                    </div>
                    <div v-else>
                      <p>请选择一个组件进行配置</p>
                    </div>
                  </el-tab-pane>
                  <el-tab-pane label="表单配置" name="form">
                    <el-form :model="formConfig" label-width="110px">
                      <!-- <el-form-item label="表单名称">
                        <el-input v-model="formConfig.name"></el-input>
                      </el-form-item> -->
                      <el-form-item label="标题" required>
                        <el-input v-model="formConfig.title"></el-input>
                      </el-form-item>
                      <el-form-item label="备注">
                        <el-input v-model="formConfig.remark" type="textarea" :rows="3"></el-input>
                      </el-form-item>
                      <el-form-item label="标签的位置">
                        <el-select v-model="formConfig.labelPosition" placeholder="请选择">
                          <el-option label="右对齐" value="right"></el-option>
                          <el-option label="左对齐" value="left"></el-option>
                          <el-option label="顶部" value="top"></el-option>
                        </el-select>
                      </el-form-item>
                      <el-form-item label="表单的尺寸">
                        <el-select v-model="formConfig.size" placeholder="请选择">
                          <el-option label="默认" value="default"></el-option>
                          <el-option label="中等" value="medium"></el-option>
                          <el-option label="小型" value="small"></el-option>
                          <el-option label="迷你" value="mini"></el-option>
                        </el-select>
                      </el-form-item>
                      <el-form-item label="标签的后缀">
                        <el-input v-model="formConfig.labelSuffix"></el-input>
                      </el-form-item>
                      <el-form-item label="标签的宽度">
                        <el-input-number v-model="formConfig.labelWidth" :min="0" :max="300"></el-input-number>
                        <span style="margin-left: 8px">px</span>
                      </el-form-item>
                      <el-form-item label="表单项的下边距">
                        <el-input-number v-model="formConfig.itemMarginBottom" :min="0" :max="50"></el-input-number>
                        <span style="margin-left: 8px">px</span>
                      </el-form-item>
                    </el-form>
                  </el-tab-pane>
                </el-tabs>
              </div>
            </el-col>
          </el-row>
        </div>


        <!-- 导入Schema对话框 -->
        <el-dialog v-model="importDialogVisible" title="导入Schema" width="600px">
          <el-form>
            <el-form-item>
              <el-input v-model="importedSchema" type="textarea" :rows="10" placeholder="请输入Schema"></el-input>
            </el-form-item>
          </el-form>
          <template #footer>
            <span class="dialog-footer">
              <el-button @click="importDialogVisible = false">取消</el-button>
              <el-button class="importBtn" type="primary" @click="importSchema">导入</el-button>
            </span>
          </template>
        </el-dialog>

      </div>
    </el-card>
  </div>
</template>
TypeScript 复制代码
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import WqdFile from "@wqd/wqd-file-manager/src/components/WqdFile";
import {
  Edit,
  DataAnalysis,
  Operation,
  View,
  Check,
  Calendar,
  AlarmClock,
  Switch,
  Position,
  Document,
  Upload,
  Download
} from '@element-plus/icons-vue';
import DocumentCopyIcon from "~icons/ep/document-copy";
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import { apiInspectionTemplate } from "@/api/admin/dict/inspection";
import { useRoute } from 'vue-router';

const route = useRoute();
const id = ref(route.params.id as string);

// 组件库
const components = [
  { type: 'el-input', name: '文本输入框', icon: 'Edit' },
  { type: 'el-input-number', name: '数字输入框', icon: 'DataAnalysis' },
  { type: 'el-select', name: '下拉选择框', icon: 'Operation' },
  { type: 'el-radio-group', name: '单选框', icon: 'View' },
  { type: 'el-checkbox-group', name: '复选框', icon: 'Check' },
  { type: 'el-date-picker', name: '日期选择器', icon: 'Calendar' },
  { type: 'el-time-picker', name: '时间选择器', icon: 'AlarmClock' },
  { type: 'el-switch', name: '开关', icon: 'Switch' },
  { type: 'el-slider', name: '滑块', icon: 'Position' },
  { type: 'el-input', name: '多行文本', icon: 'Document', isTextarea: true },
  { type: 'el-upload', name: '上传文件', icon: 'Upload' }
];

// 生成唯一ID的函数
const generateId = () => {
  return 'field_' + Date.now() + '_' + Math.floor(Math.random() * 1000);
};

// 表单数据
const formData = reactive({});

// 表单字段
const formFields = ref([
  {
    type: 'el-input',
    key: 'name',
    label: '姓名',
    placeholder: '请输入姓名',
    required: true
  },
  {
    type: 'el-input-number',
    key: 'age',
    label: '年龄',
    placeholder: '请输入年龄',
    required: false
  }
]);

// 选中的字段
const selectedField = ref(null);

// 生成的Schema
const generatedSchema = ref('');

// 当前激活的标签页
const activeTab = ref('component');

// 表单配置
const formConfig = reactive({
  name: '',
  title: '',
  remark: '',
  labelPosition: 'right',
  size: 'default',
  labelSuffix: '',
  labelWidth: 125,
  itemMarginBottom: 20
});

// 导入Schema相关
const importDialogVisible = ref(false);
const importedSchema = ref('');

// 路由实例
const router = useRouter();

// 打开导入对话框
const openImportDialog = () => {
  importDialogVisible.value = true;
  importedSchema.value = '';
};

// 导入Schema
const importSchema = () => {
  try {
    const schema = JSON.parse(importedSchema.value);

    // 清空现有表单
    formFields.value = [];
    Object.keys(formData).forEach(key => {
      delete formData[key];
    });
    selectedField.value = null;

    // 导入表单配置
    if (schema.formConfig) {
      formConfig.name = schema.formConfig.name || '';
      formConfig.labelPosition = schema.formConfig.labelPosition || 'right';
      formConfig.size = schema.formConfig.size || 'default';
      formConfig.labelSuffix = schema.formConfig.labelSuffix || '';
      formConfig.labelWidth = schema.formConfig.labelWidth || 125;
      formConfig.itemMarginBottom = schema.formConfig.itemMarginBottom || 20;
    }

    console.log(schema);

    // 导入字段
    if (schema.properties) {
      Object.keys(schema.properties).forEach(key => {
        const fieldSchema = schema.properties[key];
        let componentType = getComponentType(fieldSchema.type);

        // 根据选项和标题判断组件类型
        if (fieldSchema.options) {
          const label = fieldSchema.title || key;
          if (label.includes('复选框')) {
            componentType = 'el-checkbox-group';
          } else if (label.includes('单选框')) {
            componentType = 'el-radio-group';
          } else if (label.includes('下拉')) {
            componentType = 'el-select';
          } else {
            // 默认使用下拉选择框
            componentType = 'el-select';
          }
        } else if (fieldSchema.title && fieldSchema.title.includes('上传文件')) {
          // 根据标题判断上传文件组件
          componentType = 'el-upload';
        }

        const field = {
          type: componentType,
          key: key,
          label: fieldSchema.title || key,
          placeholder: `请输入`,
          required: fieldSchema.required || false,
          isTextarea: fieldSchema.type === 'textarea'
        };

        // 添加选项配置
        if (fieldSchema.options) {
          field.options = fieldSchema.options;
        }

        formFields.value.push(field);

        // 初始化默认值
        if (fieldSchema.default !== undefined && fieldSchema.default !== null) {
          formData[key] = fieldSchema.default;
        } else {
          switch (field.type) {
            case 'el-checkbox-group':
              formData[key] = [];
              break;
            case 'el-switch':
              formData[key] = false;
              break;
            case 'el-input-number':
            case 'el-slider':
              formData[key] = 0;
              break;
            default:
              formData[key] = '';
          }
        }
      });
    }

    importDialogVisible.value = false;
  } catch (error) {
    console.error('Error parsing Schema:', error);
    alert('Schema格式错误,请检查输入');
  }
};

const fileCallback = (file, fieldKey) => {
  console.log('Uploaded file:', file);
  if (file && Array.isArray(file) && file.length > 0) {
    // 保存所有文件的文件名到formData
    formData[fieldKey] = file.map(f => f.name || '未知文件').join(',');

    // 在右侧组件配置中显示所有文件名
    if (selectedField.value && selectedField.value.key === fieldKey) {
      selectedField.value.fileName = file.map(f => f.name || '未知文件').join(',');
      // 保存所有文件信息
      selectedField.value.files = file;
    }
  }
};

// 根据字段类型获取组件类型
const getComponentType = (fieldType) => {
  switch (fieldType) {
    case 'string':
      return 'el-input';
    case 'number':
      return 'el-input-number';
    case 'boolean':
      return 'el-switch';
    default:
      return 'el-input';
  }
};

// 鼠标按下
const onMouseDown = (event) => {
  console.log('Mouse down on component');
};

// 拖拽开始
const onDragStart = (event, component) => {
  console.log('Drag start:', component);
  event.dataTransfer.setData('text/plain', JSON.stringify(component));
  event.dataTransfer.effectAllowed = 'copy';
  // 添加拖拽时的视觉效果
  event.target.style.opacity = '0.5';
};

// 拖拽结束
const onDragEnd = (event) => {
  console.log('Drag end');
  event.target.style.opacity = '1';
};

// 表单组件拖动功能
const draggedIndex = ref(-1);

// 开始拖动
const startDrag = (index) => {
  draggedIndex.value = index;
};

// 结束拖动
const endDrag = () => {
  draggedIndex.value = -1;
  // 移除所有视觉反馈类
  document.querySelectorAll('.field-item').forEach(item => {
    item.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
  });
};

// 允许放置
const allowDrop = (event) => {
  event.preventDefault();
  // 移除所有其他元素的视觉反馈
  document.querySelectorAll('.field-item').forEach(item => {
    item.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
  });

  // 添加视觉反馈
  const target = event.currentTarget;
  target.classList.add('drag-over');

  // 计算鼠标位置,判断是在组件上方还是下方
  const rect = target.getBoundingClientRect();
  const y = event.clientY - rect.top;
  if (y < rect.height / 2) {
    target.classList.add('drag-over-top');
  } else {
    target.classList.add('drag-over-bottom');
  }
};

// 放置处理
const drop = (event, targetIndex) => {
  event.preventDefault();
  // 移除视觉反馈
  const target = event.currentTarget;
  target.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');

  if (draggedIndex.value !== -1) {
    // 计算实际插入位置
    const rect = target.getBoundingClientRect();
    const y = event.clientY - rect.top;
    let insertIndex = targetIndex;

    // 如果鼠标在组件下方,插入到组件后面
    if (y >= rect.height / 2) {
      insertIndex += 1;
    }

    // 确保插入位置有效
    insertIndex = Math.max(0, Math.min(insertIndex, formFields.value.length));

    // 避免自己拖到自己的位置
    if (draggedIndex.value !== insertIndex) {
      // 交换位置
      const [movedField] = formFields.value.splice(draggedIndex.value, 1);
      formFields.value.splice(insertIndex, 0, movedField);
    }

    draggedIndex.value = -1;
  }
};

// 拖拽悬停
const onDragOver = (event) => {
  console.log('Drag over');
  event.preventDefault();
  event.dataTransfer.dropEffect = 'copy';
  // 添加悬停效果
  event.target.classList.add('drop-area-active');
};

// 拖拽离开
const onDragLeave = (event) => {
  console.log('Drag leave');
  event.target.classList.remove('drop-area-active');
};

// 拖拽结束
const onDrop = (event) => {
  console.log('Drop event');
  event.preventDefault();
  event.target.classList.remove('drop-area-active');
  const componentData = event.dataTransfer.getData('text/plain');
  console.log('Dropped component data:', componentData);

  if (componentData) {
    try {
      const component = JSON.parse(componentData);
      console.log('Parsed component:', component);

      const newField = {
        type: component.type,
        key: generateId(),
        label: component.name,
        placeholder: `请输入`,
        required: false,
        isTextarea: component.isTextarea || false
      };

      // 计算插入位置
      const formItems = document.querySelectorAll('.field-item');
      let insertIndex = formFields.value.length;

      // 找到最接近鼠标位置的表单项目
      let minDistance = Infinity;
      for (let i = 0; i < formItems.length; i++) {
        const rect = formItems[i].getBoundingClientRect();
        const centerY = rect.top + rect.height / 2;
        const distance = Math.abs(event.clientY - centerY);
        if (distance < minDistance) {
          minDistance = distance;
          insertIndex = i;
        }
      }

      // 插入到合适的位置
      formFields.value.splice(insertIndex, 0, newField);

      // 根据组件类型初始化默认值
      switch (component.type) {
        case 'el-checkbox-group':
          formData[newField.key] = [];
          break;
        case 'el-switch':
          formData[newField.key] = false;
          break;
        case 'el-input-number':
        case 'el-slider':
          formData[newField.key] = 0;
          break;
        default:
          formData[newField.key] = '';
      }

      console.log('Added new field:', newField);
      // 自动选中新创建的组件
      selectField(newField);
    } catch (error) {
      console.error('Error parsing component data:', error);
    }
  }
};

// 获取组件
const getComponent = (type) => {
  return type;
};

// 获取组件配置
const getComponentConfig = (field) => {
  switch (field.type) {
    case 'el-checkbox-group':
      return {
        options: [
          { label: '选项1', value: 'option1' },
          { label: '选项2', value: 'option2' },
          { label: '选项3', value: 'option3' }
        ]
      };
    case 'el-radio-group':
      return {
        options: [
          { label: '选项1', value: 'option1' },
          { label: '选项2', value: 'option2' },
          { label: '选项3', value: 'option3' }
        ]
      };
    case 'el-select':
      return {
        options: [
          { label: '选项1', value: 'option1' },
          { label: '选项2', value: 'option2' },
          { label: '选项3', value: 'option3' }
        ]
      };
    default:
      return {};
  }
};

// 获取选项(确保字段有自己的options属性)
const getOptions = (field) => {
  if (!field.options) {
    // 从组件配置中获取默认选项并复制到字段的options属性
    field.options = JSON.parse(JSON.stringify(getComponentConfig(field).options));
  }
  return field.options;
};

// 选择字段
const selectField = (field) => {
  selectedField.value = field;
  activeTab.value = 'component';
};

// 删除字段
const removeField = (field) => {
  const index = formFields.value.findIndex(f => f.key === field.key);
  if (index > -1) {
    formFields.value.splice(index, 1);
    delete formData[field.key];
    selectedField.value = null;
  }
};

// 添加选项
const addOption = (field) => {
  if (!field.options) {
    field.options = [];
  }
  field.options.push({ label: `选项${field.options.length + 1}`, value: `option${field.options.length + 1}` });
};

// 删除选项
const removeOption = (field, index) => {
  if (field.options && field.options.length > 1) {
    field.options.splice(index, 1);
  }
};

// 处理文件上传
const handleFileChange = (file, fieldKey) => {
  console.log('File changed:', file);
  formData[fieldKey] = file.raw;
};

// 检查并导入sessionStorage中的模板数据
const checkAndImportTemplateData = async () => {
  if (!id) {
    return;
  }
  const res = await apiInspectionTemplate().get(Number(id.value));
  if (res.data) {
    try {
      if (res.data.jsonSchema) {
        importedSchema.value = res.data.jsonSchema;
        importSchema();
      }
      // 导入标题和备注
      if (res.data.title) {
        formConfig.title = res.data.title;
      }
      if (res.data.remark) {
        formConfig.remark = res.data.remark;
      }
    } catch (error) {
      console.error('Error parsing inspection template data:', error);
    }
  }
};

// 生成Schema
const generateSchema = () => {
  const schema = {
    type: 'object',
    properties: {},
    formConfig: {
      name: formConfig.name,
      labelPosition: formConfig.labelPosition,
      size: formConfig.size,
      labelSuffix: formConfig.labelSuffix,
      labelWidth: formConfig.labelWidth,
      itemMarginBottom: formConfig.itemMarginBottom
    }
  };

  formFields.value.forEach(field => {
    const fieldSchema = {
      type: getFieldType(field.type),
      title: field.label,
      required: field.required
    };

    // 添加默认值
    if (formData[field.key] !== undefined && formData[field.key] !== null) {
      fieldSchema.default = formData[field.key];
    }

    // 添加选项配置
    if (field.options) {
      fieldSchema.options = field.options;
    }

    // 添加文件信息
    if (field.files) {
      fieldSchema.files = field.files;
    }

    schema.properties[field.key] = fieldSchema;
  });

  generatedSchema.value = JSON.stringify(schema, null, 2);

  saveSchema();
};

// 获取字段类型
const getFieldType = (componentType) => {
  switch (componentType) {
    case 'el-input':
    case 'el-textarea':
    case 'el-select':
    case 'el-upload':
      return 'string';
    case 'el-input-number':
      return 'number';
    case 'el-date-picker':
    case 'el-time-picker':
      return 'string';
    case 'el-switch':
      return 'boolean';
    case 'el-slider':
      return 'number';
    default:
      return 'string';
  }
};

// 清空表单
const clearForm = () => {
  formFields.value = [];
  Object.keys(formData).forEach(key => {
    delete formData[key];
  });
  selectedField.value = null;
  generatedSchema.value = '';
};

// 复制Schema
const copySchema = () => {
  if (generatedSchema.value) {
    navigator.clipboard.writeText(generatedSchema.value)
      .then(() => {
        ElMessage.success('Schema已复制到剪贴板');
      })
      .catch(err => {
        console.error('复制失败:', err);
        ElMessage.error('复制失败,请手动复制');
      });
  }
};

// 页面加载时检查并导入模板数据
onMounted(() => {
  checkAndImportTemplateData();
});

// 保存Schema
const saveSchema = async () => {
  if (!formConfig.title) {
    ElMessage.warning('请输入标题');
    activeTab.value = 'form';
    return;
  }
  if (generatedSchema.value) {
    try {
      const formData = {
        id: Number(id.value) || 0,
        title: formConfig.title,
        jsonSchema: generatedSchema.value,
        remark: formConfig.remark || ''
      };

      const res = formData.id ? await apiInspectionTemplate().update(formData) : await apiInspectionTemplate().create(formData);

      if (res.status == 0) {
        ElMessage.success('表单保存成功,正在返回...');
        setTimeout(() => {
          router.push('/config/inspection');
        }, 1000);
      } else {
        ElMessage.error(res.msg);
      }
    } catch (error) {
      console.error('保存Schema失败:', error);
      ElMessage.error('保存Schema失败,请检查Schema格式');
    }
  } else {
    ElMessage.warning('请先生成Schema');
  }
};
</script>
css 复制代码
<style scoped>
.ticket-text-container {
  padding: 20px;
  user-select: none;
}

.form-designer-card {
  height: 80vh;
  display: flex;
  flex-direction: column;
}

:deep(.el-card__body) {
  height: 100%;
  overflow: auto;

  /* 自定义滚动条样式 */
  &::-webkit-scrollbar {
    width: 2px;
    height: 8px;
  }

  &::-webkit-scrollbar-track {
    background: #f1f1f1;
    border-radius: 4px;
  }

  &::-webkit-scrollbar-thumb {
    background: #0b6837;
    border-radius: 4px;
    opacity: 0;
    transition: opacity 0.3s ease;
  }

  &::-webkit-scrollbar-thumb:hover {
    opacity: 1;
  }

  &:hover::-webkit-scrollbar-thumb {
    opacity: 1;
  }
}

:deep(.el-row) {
  height: 100%;
}

.form-designer-container {
  margin-top: 20px;
  display: flex;
  flex-direction: column;
  height: 97%;
}

.designer-content {
  flex: 1;
  margin-bottom: 20px;
  height: 97%;
}

.component-library,
.design-area,
.property-panel {
  background-color: #f9f9f9;
  border-radius: 8px;
  padding: 16px;
  height: 97%;
  overflow-y: auto;
}

.property-panel {
  overflow: auto;
  padding-top: 0;

  :deep(.el-tabs__item) {
    width: 100px;
  }

  :deep(.el-tabs__content) {
    overflow: auto;
  }

  :deep(.el-form-item--label-right .el-form-item__label) {
    justify-content: start;
  }

  :deep(.el-form-item) {
    flex-direction: column;

    :deep(.el-form-item__content) {
      margin-left: 0 !important;
    }
  }
}

.rightArrow {
  width: 20px;
  height: 19px;
  background: url("@/assets/rightArrow.png") no-repeat center center / cover;
}

.cardTitle {
  line-height: 20px;
  margin-left: 5px;
  font-weight: bold;
  font-size: 18px;
}

.component-library h4,
.design-area h4,
.property-panel h4 {
  font-size: 16px;
  color: #333;
  margin-bottom: 15px;
  border-left: 4px solid #0b6837;
  padding-left: 10px;
}

.components-list {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

.component-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 16px 8px;
  background-color: #fff;
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  cursor: move;
  transition: all 0.2s ease;
  height: 100px;
  text-align: center;
}

.component-item:hover {
  border-color: #0b6837;
  box-shadow: 0 4px 8px rgba(11, 104, 55, 0.15);
  transform: translateY(-2px);
}

.component-icon {
  font-size: 24px;
  color: #0b6837;
  margin-bottom: 8px;
}

.component-name {
  font-size: 12px;
  color: #333;
  line-height: 1.2;
}

.drop-area {
  height: 56.6vh;
  padding: 20px;
  background-color: #fff;
  border: 2px dashed #dcdfe6;
  border-radius: 4px;
  transition: all 0.3s ease;
  overflow-y: auto;
}

.drop-area:hover {
  border-color: #0b6837;
  background-color: #f0f9eb;
}

.drop-area-active {
  border-color: #0b6837;
  background-color: #f0f9eb;
  box-shadow: 0 0 0 2px rgba(11, 104, 55, 0.2);
}

.field-item {
  cursor: pointer;
  transition: all 0.3s ease;
  padding: 8px;
  border-radius: 4px;
  border: 2px solid transparent;
  position: relative;
}

.field-item:hover {
  background-color: #f0f9eb;
}

/* 拖动时的视觉反馈 */
.field-item.drag-over {
  background-color: rgba(11, 104, 55, 0.1);
}

.field-item.drag-over-top {
  border-top-color: #0b6837;
}

.field-item.drag-over-bottom {
  border-bottom-color: #0b6837;
}

.option-item {
  display: flex;
  align-items: center;
  margin-bottom: 10px;
  padding: 10px;
  background-color: #f9f9f9;
  border-radius: 4px;
}

.option-item .el-button {
  margin-left: 10px;
}

.demo-form {
  background-color: #fff;
  padding: 20px;
  border-radius: 8px;
}

.designer-footer {
  display: flex;
  gap: 10px;
}

.schema-result {
  margin-top: 20px;
  padding: 15px;
  background-color: #f0f9eb;
  border: 1px solid #c2e7b0;
  border-radius: 4px;
}

.schema-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
}

.button-group {
  display: flex;
  gap: 10px;
}

.copyBtn,
.saveBtn {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.saveBtn {
  background-color: #67c23a;
  border: none;
}

.saveBtn:hover {
  background-color: #85ce61;
}

.schema-result h4 {
  margin: 0;
  color: #67c23a;
}

.copyBtn {
  background-color: #67c23a;
  border: none;
}

.copyBtn:hover {
  background-color: #85ce61;
}

.schema-result pre {
  margin: 0;
  padding: 10px;
  background-color: #fff;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  overflow-x: auto;
  font-size: 14px;
  line-height: 1.5;
}

.deleteBtn {
  background-color: #ff5918;
  border: none;
}

.addBtn {
  color: #0b6837;
  background-color: #f1f6f3;
  border-color: #81b198;
}

.addBtn:hover {
  background-color: #0b6837;
  color: #fff;
}

.clearBtn {
  color: #ff5918;
  border-color: #ff5918;
}

.clearBtn:hover {
  background-color: #ff5918;
  color: #fff;
}

.importBtn {
  background-color: #67c23a;
  border: none;
}

.generateBtn {
  background-color: #0b6837;
  border: none;
}

:deep(.el-card__header) {
  border-bottom: 0;
  padding-bottom: 0;
}

:deep(.el-tabs__item) {

  &.is-active {
    color: #0b6837;
    font-weight: bold;
  }

  &:hover {
    color: #0b6837;
  }
}

:deep(.el-tabs__active-bar) {
  background-color: #0b6837;
}

/* 自定义滚动条样式 */
::-webkit-scrollbar {
  width: 2px;
  height: 8px;
}

::-webkit-scrollbar-track {
  background: #f1f1f1;
  border-radius: 4px;
}

::-webkit-scrollbar-thumb {
  background: #0b6837;
  border-radius: 4px;
  opacity: 0;
  transition: opacity 0.3s ease;
}

/* 鼠标悬停时显示滚动条 */
::-webkit-scrollbar-thumb:hover {
  opacity: 1;
}

/* 为特定容器添加滚动条样式 */
.component-library::-webkit-scrollbar-thumb,
.design-area::-webkit-scrollbar-thumb,
.property-panel::-webkit-scrollbar-thumb,
.drop-area::-webkit-scrollbar-thumb {
  background: #0b6837;
  opacity: 0;
  transition: opacity 0.3s ease;
}

.component-library:hover::-webkit-scrollbar-thumb,
.design-area:hover::-webkit-scrollbar-thumb,
.property-panel:hover::-webkit-scrollbar-thumb,
.drop-area:hover::-webkit-scrollbar-thumb {
  opacity: 1;
}

:deep(.el-dialog__header) {
  font-size: 16px;
  color: #333;
  border-left: 4px solid #0b6837;
  padding-left: 10px;
  padding-bottom: 0;
  margin-bottom: 16px;
}
</style>
相关推荐
吴佳浩 Alben2 小时前
Vibe Coding 时代:Vue 消失了还是 React 太强?
前端·vue.js·人工智能·react.js·语言模型·自然语言处理
梓贤Vigo2 小时前
【Axure原型分享】能上下拖动和滚动查看内容的中继器表格
交互·产品经理·axure·原型·中继器
前端大波2 小时前
Vue 项目中让 AI 更稳:AGENTS.md + Prompt 模板实践
vue.js·人工智能·prompt
紫_龙2 小时前
最新版vue3+TypeScript开发入门到实战教程之组件通信之一
前端·vue.js·typescript
fxshy2 小时前
前端直连模型 vs 完整 MCP:大模型驱动地图的原理与实践(技术栈Vue + Cesium + Node.js + WebSocket + MCP)
前端·vue.js·node.js·cesium·mcp
destinying2 小时前
性能优化之项目实战:从构建到部署的完整优化方案
前端·javascript·vue.js
英俊潇洒美少年2 小时前
数据驱动视图 vue和react对比
javascript·vue.js·react.js
永远的个初学者3 小时前
一个同时支持 React、Vue、Node、CLI、Vite、Webpack 的图片优化库:rv-image-optimize
vue.js·react.js·webpack
xy34533 小时前
Axure 9.0 原生组件:让折线图实现动态交互(文本标签)
ui·交互·axure·原型·折线图