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宏定义组件名称。

结语

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

相关推荐
步行cgn13 分钟前
Vue 中的数据代理机制
前端·javascript·vue.js
GH小杨18 分钟前
JS之Dom模型和Bom模型
前端·javascript·html
星月心城1 小时前
JS深入之从原型到原型链
前端·javascript
你的人类朋友2 小时前
🤔Token 存储方案有哪些
前端·javascript·后端
烛阴2 小时前
从零开始:使用Node.js和Cheerio进行轻量级网页数据提取
前端·javascript·后端
liuyang___2 小时前
日期的数据格式转换
前端·后端·学习·node.js·node
贩卖纯净水.3 小时前
webpack其余配置
前端·webpack·node.js
码上奶茶3 小时前
HTML 列表、表格、表单
前端·html·表格·标签·列表·文本·表单
抹茶san4 小时前
和 Trae 一起开发可视化拖拽编辑项目(1) :迈出第一步
前端·trae