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


为了在现有的嵌套拖拽组件基础上,实现拖拽结束时组件自动缩放以撑满网格或槽位,您需要对以下部分进行修改和增加:

  1. 定义网格单元格尺寸
  2. 调整 getNodeStyle 方法
  3. 修改 CSS 以确保组件撑满容器
  4. 确保槽位内的组件自动填充

以下是具体的实现步骤和代码片段,仅包含需要增加或修改的部分。

1. 定义网格单元格尺寸

首先,明确网格单元格的宽度和高度,以便在拖拽结束时调整组件的尺寸和位置。

// 在 DraggableTree.vue 中定义网格尺寸
const GRID_SIZE = 100; // 每个网格单元格的宽度和高度,例如 100px

2. 调整 getNodeStyle 方法

修改 getNodeStyle 方法,使顶层组件在拖拽结束后自动缩放并填满网格单元格,子组件在槽位中自动填满其父容器。

// DraggableTree.vue
<script>
import draggable from 'vuedraggable';
import { v4 as uuidv4 } from 'uuid';

const GRID_SIZE = 100; // 定义网格大小

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',
          width: `${GRID_SIZE}px`, // 设置宽度为网格大小
          height: `${GRID_SIZE}px` // 设置高度为网格大小
        };
      }
      // 对于非顶层组件,填满其父容器
      return {
        width: '100%',
        height: '100%'
      };
    },
    onDragEnd(evt) {
      if (this.isTopLevel) {
        const gridSize = GRID_SIZE;
        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.localNodes[evt.newIndex].width = gridSize;
        this.localNodes[evt.newIndex].height = gridSize;
      }
      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>

3. 修改 CSS 以确保组件撑满容器

更新 CSS 样式,使顶层组件和槽位内的子组件能够自动填满其容器。

/* DraggableTree.vue 的样式部分 */

<style>
.grid-container {
  position: relative; /* 绝对定位的基础 */
  width: 800px; /* 根据需要调整 */
  height: 600px;
  background: #f0f0f0;
  border: 1px solid #ccc;
}

.node {
  border: 1px solid #ddd;
  padding: 10px;
  margin: 0; /* 去除 margin 以确保填满网格 */
  background-color: #fff;
  box-sizing: border-box; /* 包含 padding 和 border 在内 */
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.node-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-grow: 1;
}

.slots {
  margin-top: 10px;
  display: flex;
  gap: 10px;
  flex-grow: 1;
}

.slot {
  width: 100%; /* 填满父容器 */
  height: 100%; /* 填满父容器 */
  border: 1px dashed #aaa;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

.slot-placeholder {
  color: #aaa;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.slot div {
  width: 100%;
  height: 100%;
}

.node-children {
  margin-left: 20px;
  flex-grow: 1;
}

button {
  margin-left: 5px;
}

.slot > div > .node {
  width: 100%; /* 确保子组件填满槽位 */
  height: 100%;
}
</style>

4. 确保槽位内的组件自动填充

确保槽位内的子组件在渲染时自动填充其父容器。可以通过在子组件中设置 widthheight100% 来实现。

<!-- 例如,ItemComponent.vue -->
<template>
  <div class="item-component">
    <!-- 组件内容 -->
    {{ props.content }}
  </div>
</template>

<script>
export default {
  name: 'ItemComponent',
  props: {
    props: {
      type: Object,
      required: true
    }
  }
};
</script>

<style scoped>
.item-component {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #e0f7fa;
  border: 1px solid #00796b;
}
</style>

同样地,确保其他类型的组件(如 ContainerComponent.vue)也具有相似的样式以填满其父容器。

5. 更新槽位组件(可选)

为了更好地管理槽位内的组件,可以创建一个独立的槽位组件,如 SlotComponent.vue,并在其中处理自动填充和尺寸调整。

<!-- 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"
          class="filler-component"
        />
      </div>
    </draggable>
  </div>
</template>

<script>
export default {
  name: 'SlotComponent',
  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 scoped>
.slot {
  width: 100%;
  height: 100%;
  border: 1px dashed #aaa;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

.slot-placeholder {
  color: #aaa;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.filler-component {
  width: 100%;
  height: 100%;
}
</style>

然后在 DraggableTree.vue 中使用 SlotComponent

<!-- DraggableTree.vue 中的槽位部分 -->
<template>
  <draggable
    v-model="localNodes"
    :group="{ name: 'nodes', pull: true, put: true }"
    :grid="[GRID_SIZE, GRID_SIZE]" <!-- 设置网格大小 -->
    @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>

      <!-- 使用 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>

      <!-- 递归渲染子节点 -->
      <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';
import SlotComponent from './SlotComponent.vue'; // 引入 SlotComponent

const GRID_SIZE = 100; // 定义网格大小

export default {
  name: 'DraggableTree',
  components: {
    draggable,
    DraggableTree: () => import('./DraggableTree.vue'),
    SlotComponent
  },
  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',
          width: `${GRID_SIZE}px`, // 设置宽度为网格大小
          height: `${GRID_SIZE}px` // 设置高度为网格大小
        };
      }
      // 对于非顶层组件,填满其父容器
      return {
        width: '100%',
        height: '100%'
      };
    },
    onDragEnd(evt) {
      if (this.isTopLevel) {
        const gridSize = GRID_SIZE;
        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.localNodes[evt.newIndex].width = gridSize;
        this.localNodes[evt.newIndex].height = gridSize;
      }
      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;
        }
      }
    },
    updateSlotChildren(parentNode, slot, newChildren) {
      const targetSlot = parentNode.slots.find(s => s.id === slot.id);
      if (targetSlot) {
        targetSlot.children = newChildren;
      }
    }
  }
};
</script>

6. 确保 JSON 数据包含尺寸信息

为了在从 JSON 恢复组件时保持尺寸信息,确保在保存和加载时包含 widthheight 属性。

// App.vue 中的保存和加载方法
<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 },
          width: 100, // 初始宽度
          height: 100, // 初始高度
          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>

7. 测试和调整

完成上述修改后,确保测试以下场景:

  1. 顶层组件拖拽到网格:组件应自动调整大小并填满拖拽到的网格单元格。
  2. 子组件拖拽到槽位:子组件应自动填满槽位,并且槽位在被占用后不允许再次放置组件。
  3. 从 JSON 恢复:加载的 JSON 数据应正确恢复组件的层级、位置和尺寸。

8. 进一步优化(可选)

使用动态计算尺寸

如果需要更灵活的网格布局,可以根据容器大小动态计算网格单元格的尺寸。

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

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