近期写了一个demo,基于element组件库与draggable-next实现在目标区域进行添加组件、容器。

vuedraggable
关于vuedraggable的配置文档就不一一说明了,大家可以参考官网文档或者中文文档。
安装
bash
npm i -S vuedraggable@next
关于element-plus,大家可以去官网自行安装:element-plus.org/zh-CN/guide...
使用
- 引入vuedraggable
js
import draggable from 'vuedraggable'
- 定义一个源数据列表
html
<div class="lowcode-builder">
<div class="component-library">
<div class="title">源列表</div>
<draggable class="source-list"
:list="sourceList"
:group="{ name: 'items', pull: 'clone', put: false }"
itemKey="id"
:sort="false"
:clone="cloneComponent"
>
<template #item="{ element }">
<div class="component-item">
<el-button siz="large">
{{ element.name }}
</el-button>
</div>
</template>
</draggable>
</div>
js
const sourceList = ref([
{
name: '选择器',
id: '',
type: 'vSelect', // 类型对应element-plus组件库
value: '',
options: [
],
label: '选择器',
placeholder: '请选择',
},
{
name: '输入框',
id: '',
type: 'vInput',
value: '',
label: '输入框',
placeholder: '请输入',
},
{
name: '容器',
id: '',
type: 'vContainer',
value: '',
label: '容器',
children: [
]
}
])
// 生成唯一ID
const generateId = () => {
const randomPart = Math.random().toString(36).substring(2, 10) // 生成8位随机字符串
return randomPart
}
// 克隆组件时添加唯一ID
const cloneComponent = (original) => {
const cloned = JSON.parse(JSON.stringify(original))
// 容器盒子处理
if(cloned.type === 'vContainer' && !cloned.children){
cloned.children = []
}
cloned.id = generateId()
return cloned
}
注意:设置group的pull属性为clone以及事件clone="cloneComponent"非常重要
- 设置一个目标区域实现组件的拖入
html
<div class="canvas-container">
<h2>目标列表</h2>
<draggable
class="drag-list"
:list="targetList"
:group="{ name: 'items', pull: true, put: true }"
itemKey="id"
@start="isDragging = true"
@end="onDragEnd"
@change="onListChange"
>
<template #item="{ element }">
<!-- 区分容器还是普通组件 -->
<div class="container" v-if="element.type === 'vContainer'" @click="onSelectItem(element)">
<draggable
class="container-drag-area"
:list="element.children"
:group="{ name: 'items', pull: true, put: true }"
itemKey="id"
@start="isDragging = true"
@end="onDragEnd"
@change="(event) => handleContainerChange(element, event)"
>
<template #item="{ element: childElement }">
<div
class="drag-item"
@click.stop="onSelectItem(childElement)"
>
<component :is="childElement.type" :itemData="childElement" ></component>
</div>
</template>
</draggable>
</div>
<div v-else class="drag-item" @click="onSelectItem(element)">
<component :is="element.type" :itemData="element"></component>
</div>
</template>
</draggable>
</div>
js
const targetList = ref([])
// 拖拽状态
let isDragging = ref(false)
// 当前选中的组件
let selectedItem = ref(null)
// 拖拽结束事件
const onDragEnd = (e) => {
isDragging.value = false
}
// 列表变化事件
const onListChange = (event) => {
// 处理添加操作
if (event.added) {
const element = event.added.element
// 确保新添加的元素有唯一ID
if (!element.id) {
element.id = generateId()
}
onSelectItem(element)
}
}
// 处理容器内部变化
const handleContainerChange = (container, event) => {
// 处理添加到容器中的组件
if (event.added) {
const element = event.added.element
if (!element.id) {
element.id = generateId()
}
// 标记组件属于哪个容器
element.containerId = container.id
onSelectItem(element)
}
if(event.removed){
const element = event.removed.element
delete element.containerId
}
}
// 选择当前组件
const onSelectItem = (item) => {
selectedItem.value = item
}
至此已经实现组件的拖拽效果

- 完善配置可以在最右侧添加一个组件配置列
html
<div class="property-panel">
<setWidger
:item="selectedItem"
@delete="onDeleteItem"
@save="onSaveItem"
/>
</div>
js
import setWidger from './components/setWidger.vue';
const onDeleteItem = (item) => {
// 如果删除的是容器内的组件
if (item?.containerId) {
const container = targetList.value.find(el => el.id === item.containerId)
if (container) {
const index = container.children.findIndex(i => i.id === item.id)
if (index !== -1) {
container.children.splice(index, 1)
}
}
} else {
// 删除画布上的组件
const index = targetList.value.findIndex(i => i.id === item.id)
if (index !== -1) {
targetList.value.splice(index, 1)
}
}
selectedItem.value = null
}
// 保存选中的组件
const onSaveItem = (item) => {
const index = targetList.value.findIndex((i) => i.id === item.id)
if (index !== -1) {
targetList.value.splice(index, 1, item)
// selectedItem.value = item
}
}
目前保存功能还有一些bug....(当保存后,点击其他组件会覆盖当前组件)
完整代码
index组件
xml
<template>
<div class="lowcode-builder">
<div class="component-library">
<div class="title">源列表</div>
<draggable class="source-list"
:list="sourceList"
:group="{ name: 'items', pull: 'clone', put: false }"
itemKey="id"
:sort="false"
:clone="cloneComponent"
>
<template #item="{ element }">
<div class="component-item">
<el-button siz="large">
{{ element.name }}
</el-button>
</div>
</template>
</draggable>
</div>
<div class="canvas-container">
<h2>目标列表</h2>
<draggable
class="drag-list"
:list="targetList"
:group="{ name: 'items', pull: true, put: true }"
itemKey="id"
@start="isDragging = true"
@end="onDragEnd"
@change="onListChange"
>
<template #item="{ element }">
<div class="container" v-if="element.type === 'vContainer'" @click="onSelectItem(element)">
<draggable
class="container-drag-area"
:list="element.children"
:group="{ name: 'items', pull: true, put: true }"
itemKey="id"
@start="isDragging = true"
@end="onDragEnd"
@change="(event) => handleContainerChange(element, event)"
>
<template #item="{ element: childElement }">
<div
class="drag-item"
@click.stop="onSelectItem(childElement)"
>
<component :is="childElement.type" :itemData="childElement" ></component>
</div>
</template>
</draggable>
</div>
<div v-else class="drag-item" @click="onSelectItem(element)">
<component :is="element.type" :itemData="element"></component>
</div>
</template>
</draggable>
</div>
<div class="property-panel">
<setWidger
:item="selectedItem"
@delete="onDeleteItem"
@save="onSaveItem"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import draggable from 'vuedraggable';
import setWidger from './components/setWidger.vue';
const sourceList = ref([
{
name: '选择器',
id: '',
type: 'vSelect',
value: '',
options: [
],
label: '选择器',
placeholder: '请选择',
},
{
name: '输入框',
id: '',
type: 'vInput',
value: '',
label: '输入框',
placeholder: '请输入',
},
{
name: '容器',
id: '',
type: 'vContainer',
value: '',
label: '容器',
children: [
]
}
])
const targetList = ref([])
// 拖拽状态
let isDragging = ref(false)
// 当前选中的组件
let selectedItem = ref(null)
// 生成唯一ID
const generateId = () => {
const randomPart = Math.random().toString(36).substring(2, 10) // 生成8位随机字符串
return randomPart
}
// 克隆组件时添加唯一ID
const cloneComponent = (original) => {
const cloned = JSON.parse(JSON.stringify(original))
if(cloned.type === 'vContainer' && !cloned.children){
cloned.children = []
}
cloned.id = generateId()
return cloned
}
// 拖拽结束事件
const onDragEnd = (e) => {
isDragging.value = false
}
// 列表变化事件
const onListChange = (event) => {
// 处理添加操作
if (event.added) {
const element = event.added.element
// 确保新添加的元素有唯一ID
if (!element.id) {
element.id = generateId()
}
onSelectItem(element)
}
}
// 处理容器内部变化
const handleContainerChange = (container, event) => {
// 处理添加到容器中的组件
if (event.added) {
const element = event.added.element
if (!element.id) {
element.id = generateId()
}
// 标记组件属于哪个容器
element.containerId = container.id
onSelectItem(element)
}
if(event.removed){
const element = event.removed.element
delete element.containerId
}
}
// 选择当前组件
const onSelectItem = (item) => {
console.log(item, targetList.value);
selectedItem.value = item
}
// 删除选中的组件
const onDeleteItem = (item) => {
// 如果删除的是容器内的组件
if (item?.containerId) {
const container = targetList.value.find(el => el.id === item.containerId)
if (container) {
const index = container.children.findIndex(i => i.id === item.id)
if (index !== -1) {
container.children.splice(index, 1)
}
}
} else {
// 删除画布上的组件
const index = targetList.value.findIndex(i => i.id === item.id)
if (index !== -1) {
targetList.value.splice(index, 1)
}
}
selectedItem.value = null
}
// 保存选中的组件
const onSaveItem = (item) => {
const index = targetList.value.findIndex((i) => i.id === item.id)
if (index !== -1) {
targetList.value.splice(index, 1, item)
// selectedItem.value = item
}
// console.log(targetList.value, item);
}
</script>
<style scoped lang="scss">
.lowcode-builder {
display: flex;
height: 100vh;
overflow: hidden;
/* 左侧组件库 */
.component-library {
width: 220px;
padding: 10px;
border-right: 1px solid #ebeef5;
display: flex;
flex-direction: column;
.title{
width: 100%;
font-size: 20px;
font-weight: bold;
margin-bottom: 10px;
text-align: center;
}
.source-list{
width: 100%;
display: flex;
flex-wrap: wrap;
}
.component-item{
width: 48%;
margin: 5px 0;
}
}
/* 中间画布 */
.canvas-container {
flex: 1;
padding: 10px;
overflow: hidden;
.drag-list{
width: 100%;
height: 100%;
/* overflow: hidden; */
display: flex;
flex-wrap: wrap;
.drag-item{
height: 45px;
margin: 5px;
}
.container{
width: 500px;
height: 500px;
border: 1px solid #ccc;
.container-drag-area{
min-height: 150px;
padding: 10px;
}
}
}
}
/* 右侧配置面板 */
.property-panel {
width: 320px;
padding: 10px;
border-left: 1px solid #ebeef5;
overflow: auto;
}
}
</style>
setWidger组件
xml
<template>
<div class="setWidger-box" v-if="configData.type">
<div class="btn-box">
<vButton
message="删除"
@click="ondelete"
buttonType="danger"
/>
<vButton
message="保存"
@click="onSave"
buttonType="success"
/>
</div>
<div class="config-box">
<div>
</div>
<el-form :model="configData">
<!-- 根据组件类型显示不同配置项 -->
<template v-if="configData.type === 'vInput'">
<el-form-item label="占位文本">
<el-input v-model="configData.placeholder"></el-input>
</el-form-item>
<el-form-item label="是否必填">
<el-switch v-model="configData.required"></el-switch>
</el-form-item>
<el-form-item label="最大长度">
<el-input-number v-model="configData.maxLength" :min="1"></el-input-number>
</el-form-item>
</template>
<template v-if="configData.type === 'vSelect'">
<el-form-item label="选项">
<el-table :data="configData.options" stripe border>
<el-table-column prop="label" label="显示文本"></el-table-column>
<el-table-column prop="value" label="值"></el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button type="danger" size="small" :icon="Delete" @click="removeOption(scope.$index)"/>
</template>
</el-table-column>
</el-table>
<el-form-item>
<el-input v-model="newOption.label" placeholder="显示文本" style="width: 120px; margin-right: 8px"></el-input>
<el-input v-model="newOption.value" placeholder="值" style="width: 120px; margin-right: 8px"></el-input>
<el-button type="primary" size="small" @click="addOption">添加选项</el-button>
</el-form-item>
</el-form-item>
</template>
</el-form>
</div>
</div>
</template>
<script setup>
import { defineProps, computed, reactive, watch, ref } from 'vue'
import { Delete } from '@element-plus/icons-vue'
import { cloneDeep } from 'lodash'
const props = defineProps({
item: {
type: Object,
default: () => ({})
}
})
// 修改配置数据
let configData = reactive({})
watch(() => props.item, (newVal, oldVal) => {
if(newVal?.id !== oldVal?.id){
onEmpty()
Object.assign(configData, cloneDeep(newVal))
}
}, { immediate: true })
const emit = defineEmits(['delete', 'save'])
const ondelete = () => {
emit('delete', props.item)
onEmpty()
}
const onSave = () => {
emit('save', configData)
}
// 制空
const onEmpty = () => {
Object.keys(configData).forEach(key => delete configData[key])
}
// 添加选项相关数据
const newOption = reactive({
label: '',
value: ''
})
// 下拉框选项管理
const addOption = () => {
if (!newOption.label || !newOption.value) return
if (configData.type === 'vSelect') {
configData.options.push({
label: newOption.label,
value: newOption.value
})
newOption.label = ''
newOption.value = ''
}
}
// 删除选项
const removeOption = (index) => {
if (configData && configData.type === 'vSelect') {
configData.options.splice(index, 1)
}
}
</script>
<style lang="scss" scoped>
.setWidger-box{
.btn-box{
display: flex;
margin-bottom: 10px;
}
}
</style>
其他
关于组件类型vInput、vSelect等,博主使用的vue3.5+,所以可以直接使用defineOptions宏定义组件名称。
结语
本篇文章到此就结束了,欢迎在评论区交流,最后保存问题,如果大家有方法解决,也欢迎大家指出。