vue3结合拖拽组件draggable-next与element-plus实现组件拖拽效果

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

vuedraggable

关于vuedraggable的配置文档就不一一说明了,大家可以参考官网文档或者中文文档。

安装

bash 复制代码
npm i -S vuedraggable@next

关于element-plus,大家可以去官网自行安装:element-plus.org/zh-CN/guide...

使用

  1. 引入vuedraggable
js 复制代码
import draggable from 'vuedraggable'
  1. 定义一个源数据列表
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"非常重要

  1. 设置一个目标区域实现组件的拖入
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  
}

至此已经实现组件的拖拽效果

  1. 完善配置可以在最右侧添加一个组件配置列
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宏定义组件名称。

结语

本篇文章到此就结束了,欢迎在评论区交流,最后保存问题,如果大家有方法解决,也欢迎大家指出。

相关推荐
Larcher26 分钟前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
徐子颐38 分钟前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭1 小时前
如何理解HTML语义化
前端·html
jump6801 小时前
url输入到网页展示会发生什么?
前端
诸葛韩信1 小时前
我们需要了解的Web Workers
前端
brzhang1 小时前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
yivifu2 小时前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花2 小时前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js
十二春秋2 小时前
场景模拟:基础路由配置
前端
六月的可乐2 小时前
实战干货-Vue实现AI聊天助手全流程解析
前端·vue.js·ai编程