Vue递归拖拽与动态展示组件2


为了在现有的嵌套拖拽组件基础上,添加顶层按网格吸附的父布局,并在子组件内部动态载入预先放置的空位供子组件吸附,同时确保数据能够通过 JSON 恢复,您可以按照以下步骤进行优化。这里只提供需要增加或修改的部分代码。

1. 修改数据结构

增加位置属性和预定义槽位

在顶层组件和需要预定义子组件放置位置的组件中,增加 position 属性和 slots 数组,以便管理网格位置和子组件的放置槽位。

// 示例节点结构
{
  id: '1',
  type: 'Container',
  props: { title: '容器 1' },
  position: { x: 0, y: 0 }, // 新增位置属性
  slots: [ // 新增槽位属性
    {
      id: 'slot1',
      accepts: ['Item'], // 可以接受的组件类型
      children: [] // 当前槽位的子组件
    },
    {
      id: 'slot2',
      accepts: ['Item', 'AnotherType'],
      children: []
    }
  ],
  children: []
}

2. 修改 App.vue

确保顶层布局使用网格,并传递 isTopLevel 属性给 DraggableTree 组件。

<template>
  <div id="app">
    <h1>嵌套拖拽组件示例</h1>
    <button @click="saveToJSON">保存为 JSON</button>
    <button @click="loadFromJSON">从 JSON 恢复</button>
    <div class="grid-container">
      <DraggableTree :nodes="treeData" @update="updateTreeData" :isTopLevel="true" />
    </div>
  </div>
</template>

<script>
import DraggableTree from './components/DraggableTree.vue';

export default {
  name: 'App',
  components: { DraggableTree },
  data() {
    return {
      treeData: [
        {
          id: '1',
          type: 'Container',
          props: { title: '默认容器' },
          position: { x: 0, y: 0 },
          slots: [
            { id: 'slot1', accepts: ['Item'], children: [] },
            { id: 'slot2', accepts: ['Item'], children: [] }
          ],
          children: []
        }
      ]
    };
  },
  methods: {
    saveToJSON() {
      const json = JSON.stringify(this.treeData, null, 2);
      localStorage.setItem('treeData', json);
      alert('已保存为 JSON');
    },
    loadFromJSON() {
      const json = localStorage.getItem('treeData');
      if (json) {
        this.treeData = JSON.parse(json);
        alert('已从 JSON 恢复');
      } else {
        alert('没有找到保存的 JSON 数据');
      }
    },
    updateTreeData(newData) {
      this.treeData = newData;
    }
  }
};
</script>

<style>
#app {
  font-family: Arial, sans-serif;
  padding: 20px;
}
.grid-container {
  position: relative;
  width: 800px; /* 根据需要调整 */
  height: 600px;
  background: #f0f0f0;
  border: 1px solid #ccc;
}
</style>

3. 修改 DraggableTree.vue

3.1. 传递 isTopLevel 属性

在递归调用 DraggableTree 组件时,传递 isTopLevel 属性以区分顶层和子层。

<template>
  <draggable
    v-model="localNodes"
    :group="{ name: 'nodes', pull: true, put: true }"
    :grid="[20, 20]" <!-- 设置网格大小 -->
    @end="onDragEnd"
    :sort="false" <!-- 使用绝对定位时禁用排序 -->
    class="grid-container"
  >
    <div
      v-for="node in localNodes"
      :key="node.id"
      class="node"
      :style="getNodeStyle(node)"
    >
      <div class="node-header">
        <span>{{ node.type }} - {{ node.props.title || node.props.content }}</span>
        <div>
          <button @click="editNode(node)">编辑</button>
          <button @click="addChild(node)">添加子组件</button>
          <button @click="removeNode(node)">删除</button>
        </div>
      </div>

      <!-- 渲染预定义槽位 -->
      <div class="slots" v-if="node.slots && node.slots.length">
        <div
          v-for="slot in node.slots"
          :key="slot.id"
          class="slot"
        >
          <draggable
            v-model="slot.children"
            :group="{ name: 'slots', pull: false, put: slot.accepts }"
            :disabled="slot.children.length > 0" <!-- 已占用则禁用 -->
            @add="onSlotAdd(slot, $event)"
          >
            <div class="slot-placeholder" v-if="slot.children.length === 0">
              放置组件
            </div>
            <div v-else>
              <component
                :is="getComponentType(slot.children[0].type)"
                :props="slot.children[0].props"
              />
            </div>
          </draggable>
        </div>
      </div>

      <!-- 递归渲染子节点 -->
      <div class="node-children" v-if="node.children && node.children.length">
        <DraggableTree
          :nodes="node.children"
          @update="updateChildren(node, $event)"
          :isTopLevel="false"
        />
      </div>
    </div>
  </draggable>
</template>

<script>
import draggable from 'vuedraggable';
import { v4 as uuidv4 } from 'uuid';

export default {
  name: 'DraggableTree',
  components: {
    draggable,
    DraggableTree: () => import('./DraggableTree.vue')
  },
  props: {
    nodes: {
      type: Array,
      required: true
    },
    isTopLevel: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      localNodes: JSON.parse(JSON.stringify(this.nodes)) // 深拷贝
    };
  },
  watch: {
    localNodes: {
      handler(newVal) {
        this.$emit('update', newVal);
      },
      deep: true
    },
    nodes: {
      handler(newVal) {
        this.localNodes = JSON.parse(JSON.stringify(newVal));
      },
      deep: true
    }
  },
  methods: {
    getNodeStyle(node) {
      if (this.isTopLevel) {
        return {
          position: 'absolute',
          left: node.position.x + 'px',
          top: node.position.y + 'px'
        };
      }
      return {};
    },
    onDragEnd(evt) {
      if (this.isTopLevel) {
        const gridSize = 20;
        const x = Math.round(evt.event.x / gridSize) * gridSize;
        const y = Math.round(evt.event.y / gridSize) * gridSize;
        this.localNodes[evt.newIndex].position = { x, y };
      }
      this.$emit('update', this.localNodes);
    },
    addChild(node) {
      if (!node.slots) {
        // 如果节点没有预定义槽位,则不允许添加子组件
        return;
      }
      const emptySlot = node.slots.find(slot => slot.children.length === 0);
      if (emptySlot) {
        const newNode = {
          id: uuidv4(),
          type: 'Item', // 或其他类型
          props: { content: '新项目' },
          children: []
        };
        emptySlot.children.push(newNode);
      } else {
        alert('所有槽位均已被占用');
      }
    },
    removeNode(node) {
      const removeRecursively = (nodes, target) => {
        const index = nodes.findIndex(n => n.id === target.id);
        if (index !== -1) {
          nodes.splice(index, 1);
          return true;
        }
        for (let n of nodes) {
          if (n.children && n.children.length) {
            if (removeRecursively(n.children, target)) {
              return true;
            }
          }
          // 检查槽位
          if (n.slots && n.slots.length) {
            for (let slot of n.slots) {
              if (slot.children && slot.children.length) {
                if (removeRecursively(slot.children, target)) {
                  return true;
                }
              }
            }
          }
        }
        return false;
      };
      removeRecursively(this.localNodes, node);
    },
    updateChildren(parentNode, updatedChildren) {
      parentNode.children = updatedChildren;
    },
    editNode(node) {
      if (node.type === 'Container') {
        const newTitle = prompt('修改标题', node.props.title);
        if (newTitle !== null) {
          node.props.title = newTitle;
        }
      } else if (node.type === 'Item') {
        const newContent = prompt('修改内容', node.props.content);
        if (newContent !== null) {
          node.props.content = newContent;
        }
      }
    },
    onSlotAdd(slot, event) {
      // 确保槽位只能接受特定类型的组件
      const draggedItem = event.item.__vue__.node;
      if (!slot.accepts.includes(draggedItem.type)) {
        // 如果类型不匹配,移除该组件
        slot.children.pop();
        alert('该槽位不接受此类型的组件');
      }
    },
    getComponentType(type) {
      const map = {
        Container: 'ContainerComponent',
        Item: 'ItemComponent'
        // 添加其他类型映射
      };
      return map[type] || 'div';
    }
  }
};
</script>

<style>
.grid-container {
  position: relative; /* 绝对定位的基础 */
  width: 800px; /* 根据需要调整 */
  height: 600px;
  background: #f0f0f0;
  border: 1px solid #ccc;
}
.node {
  border: 1px solid #ddd;
  padding: 10px;
  margin: 5px;
  background-color: #fff;
}
.node-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.slots {
  margin-top: 10px;
  display: flex;
  gap: 10px;
}
.slot {
  width: 150px;
  height: 100px;
  border: 1px dashed #aaa;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}
.slot-placeholder {
  color: #aaa;
}
.slot div {
  width: 100%;
  height: 100%;
}
.node-children {
  margin-left: 20px;
}
button {
  margin-left: 5px;
}
</style>

3.2. 动态载入子组件占位符

确保每个预定义槽位只能接受特定类型的组件,并且在槽位被占用后禁用放置。上述代码中的 slots 部分已经实现了这一功能。

4. 序列化和反序列化 JSON

确保在保存和加载 JSON 时包含位置和槽位信息。

修改保存和加载方法

App.vue 中,位置和槽位信息已经包含在 treeData 中,因此无需额外修改。确保保存和加载时完整保存 treeData

methods: {
  saveToJSON() {
    const json = JSON.stringify(this.treeData, null, 2);
    localStorage.setItem('treeData', json);
    alert('已保存为 JSON');
  },
  loadFromJSON() {
    const json = localStorage.getItem('treeData');
    if (json) {
      this.treeData = JSON.parse(json);
      alert('已从 JSON 恢复');
    } else {
      alert('没有找到保存的 JSON 数据');
    }
  },
  updateTreeData(newData) {
    this.treeData = newData;
  }
}

5. 优化拖拽逻辑以支持网格吸附

设置 Vue.Draggable 的网格选项

DraggableTree.vue 中,已经通过 :grid="[20, 20]" 实现了网格吸附。您可以根据需要调整网格大小。

确保组件在拖拽结束时对齐到网格

onDragEnd 方法中,已经根据拖拽事件的位置对组件进行了网格对齐。

6. 防止在占用槽位后继续放置

上述 DraggableTree.vue 中,通过 :disabled="slot.children.length > 0" 禁用了已被占用的槽位,从而防止了在槽位被占用后继续放置其他组件。

7. 动态载入组件类型

确保根据 type 动态渲染不同类型的组件。上述代码中的 getComponentType 方法已经实现了这一功能。确保您有相应的组件(如 ContainerComponent.vueItemComponent.vue)并在父组件中注册。

methods: {
  getComponentType(type) {
    const map = {
      Container: 'ContainerComponent',
      Item: 'ItemComponent'
      // 添加其他类型映射
    };
    return map[type] || 'div';
  }
}

8. 示例槽位组件

如果需要,可以创建预定义的槽位组件来进一步增强功能。例如,SlotComponent.vue

<template>
  <div class="slot">
    <draggable
      v-model="children"
      :group="{ name: 'slots', pull: false, put: accepts }"
      :disabled="children.length > 0"
      @add="onAdd"
    >
      <div class="slot-placeholder" v-if="children.length === 0">
        放置组件
      </div>
      <div v-else>
        <component :is="getComponentType(children[0].type)" :props="children[0].props" />
      </div>
    </draggable>
  </div>
</template>

<script>
export default {
  props: {
    slot: {
      type: Object,
      required: true
    }
  },
  computed: {
    children: {
      get() {
        return this.slot.children;
      },
      set(value) {
        this.$emit('update:children', value);
      }
    },
    accepts() {
      return this.slot.accepts;
    }
  },
  methods: {
    onAdd(event) {
      const draggedItem = event.item.__vue__.node;
      if (!this.accepts.includes(draggedItem.type)) {
        // 如果类型不匹配,移除该组件
        this.children.pop();
        alert('该槽位不接受此类型的组件');
      }
    },
    getComponentType(type) {
      const map = {
        Container: 'ContainerComponent',
        Item: 'ItemComponent'
        // 添加其他类型映射
      };
      return map[type] || 'div';
    }
  }
};
</script>

<style>
.slot {
  width: 150px;
  height: 100px;
  border: 1px dashed #aaa;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}
.slot-placeholder {
  color: #aaa;
}
</style>

然后在 DraggableTree.vue 中使用 SlotComponent

<!-- 替换现有槽位部分 -->
<div class="slots" v-if="node.slots && node.slots.length">
  <SlotComponent
    v-for="slot in node.slots"
    :key="slot.id"
    :slot="slot"
    @update:children="(newChildren) => updateSlotChildren(node, slot, newChildren)"
  />
</div>

并在 methods 中添加 updateSlotChildren 方法:

methods: {
  // 其他方法...
  updateSlotChildren(parentNode, slot, newChildren) {
    const targetSlot = parentNode.slots.find(s => s.id === slot.id);
    if (targetSlot) {
      targetSlot.children = newChildren;
    }
  }
}

结论

通过以上增加和修改的代码,您可以实现顶层组件的网格吸附布局,同时在子组件内部动态加载预定义的槽位,并确保槽位的唯一性和类型限制。这样的优化不仅提升了用户体验,还保证了数据的完整性和一致性。确保在实际应用中根据需求调整网格大小、组件类型映射以及样式等细节。

声明:HEUE NOTE|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA 4.0协议进行授权

转载:转载请注明原文链接 - Vue递归拖拽与动态展示组件2