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 性能优化
- 响应式数据管理 :使用
ref和reactive合理管理响应式数据 - 计算属性缓存 :使用
computed缓存计算结果 - 事件委托:合理使用事件委托减少事件监听器数量
- DOM 操作优化:批量操作 DOM,减少重排和重绘
4.2 用户体验优化
- 拖拽视觉反馈:添加拖拽时的视觉效果,提高用户体验
- 实时预览:在设计区域实时预览表单效果
- 自动选中:新添加组件后自动选中,方便用户配置
- 错误处理:添加表单验证和错误提示
- 滚动条样式:自定义滚动条样式,提升界面美观度
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>