You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何为PrimeVue的DataTable树形结构组件添加拖拽功能

解决方案:为PrimeVue树形DataTable添加拖拽功能

问题根源

vuedraggable无效的核心原因是:DataTable会将visibleRows渲染为<table>的<tr>结构,直接在Column模板里套vuedraggable无法正确识别可拖拽的容器和元素;另外你的树形结构依赖扁平化的visibleRows计算属性,拖拽后需要同步更新原始的嵌套elements数组,而不是仅修改扁平化列表。

可行实现步骤

1. 安装SortableJS核心库

直接使用SortableJS而非封装的vuedraggable,对DataTable的tbody进行拖拽绑定,更灵活适配表格结构:

npm install sortablejs

2. 修改组件代码,添加拖拽逻辑

更新<script setup>部分,添加Sortable初始化和拖拽回调:

import Sortable from 'sortablejs';
import { ref, computed, watch, defineProps, nextTick, onMounted, onUnmounted } from "vue";
import Element from "./Element.vue";
import DataTable from "primevue/datatable";
import Column from "primevue/column";

const props = defineProps({
  elements: {
    type: Array as () => any[],
    required: true,
    default: () => [],
  },
  expanded: {
    type: Boolean,
    required: false,
    default: false,
  },
});

const elements = ref([...props.elements]); // 转为本地可修改的ref
const expandedRows = ref({});
let sortableInstance = null;

const hasChildren = (rowData) => {
  return rowData.data.childElements && rowData.data.childElements.length > 0;
};

const toggleRow = (rowData) => {
  const id = rowData.data.id;
  if (expandedRows.value[id]) {
    delete expandedRows.value[id];
  } else {
    expandedRows.value[id] = true;
  }
};

const flattenTree = (treeNodes, level = 0) => {
  let result = [];
  treeNodes.forEach((node) => {
    result.push({
      data: node,
      level: level,
    });
    const isExpanded = expandedRows.value[node.id];
    if (isExpanded && node.childElements && node.childElements.length > 0) {
      const children = flattenTree(node.childElements, level + 1);
      result = result.concat(children);
    }
  });
  return result;
};

const visibleRows = computed(() => {
  return flattenTree(elements.value);
});

const expandAllNodes = (nodes) => {
  nodes.forEach((node) => {
    expandedRows.value[node.id] = true;
    if (node.childElements && node.childElements.length > 0) {
      expandAllNodes(node.childElements);
    }
  });
};

// 初始化Sortable拖拽实例
onMounted(() => {
  nextTick(() => {
    const tbody = document.querySelector('.elements-list .p-datatable-tbody');
    if (tbody) {
      sortableInstance = new Sortable(tbody, {
        animation: 150,
        handle: '.expandable-row', // 指定行容器为拖拽手柄
        onEnd: (evt) => {
          const { oldIndex, newIndex } = evt;
          handleDragEnd(oldIndex, newIndex);
        }
      });
    }
  });
});

onUnmounted(() => {
  if (sortableInstance) {
    sortableInstance.destroy();
  }
});

// 处理拖拽结束后的树形结构更新
const handleDragEnd = (oldIndex, newIndex) => {
  const draggedNode = visibleRows.value[oldIndex];
  const targetNode = visibleRows.value[newIndex];

  // 从原位置移除节点
  removeNodeFromTree(draggedNode.data.id, elements.value);

  // 根据层级判断插入方式
  if (targetNode.level === draggedNode.level) {
    // 同级插入
    insertAsSibling(draggedNode.data, targetNode.data, elements.value);
  } else if (targetNode.level > draggedNode.level) {
    // 作为子节点插入
    insertAsChild(draggedNode.data, targetNode.data);
  } else {
    // 向上层级插入到目标节点的父级同级
    const parentOfTarget = findParentNode(targetNode.data.id, elements.value);
    if (parentOfTarget) {
      insertAsSibling(draggedNode.data, parentOfTarget, elements.value);
    } else {
      elements.value.splice(newIndex, 0, draggedNode.data);
    }
  }

  // 维持拖拽节点的展开状态
  if (draggedNode.data.childElements?.length) {
    expandedRows.value[draggedNode.data.id] = expandedRows.value[draggedNode.data.id] ?? false;
  }
};

// 辅助:从树形结构中移除节点
const removeNodeFromTree = (nodeId, nodes) => {
  for (let i = 0; i < nodes.length; i++) {
    if (nodes[i].id === nodeId) {
      nodes.splice(i, 1);
      return true;
    }
    if (nodes[i].childElements?.length && removeNodeFromTree(nodeId, nodes[i].childElements)) {
      return true;
    }
  }
  return false;
};

// 辅助:查找节点的父节点
const findParentNode = (nodeId, nodes, parent = null) => {
  for (const node of nodes) {
    if (node.id === nodeId) {
      return parent;
    }
    if (node.childElements?.length) {
      const found = findParentNode(nodeId, node.childElements, node);
      if (found) return found;
    }
  }
  return null;
};

// 辅助:作为同级节点插入
const insertAsSibling = (draggedNode, targetNode, nodes) => {
  const parent = findParentNode(targetNode.id, nodes);
  if (parent) {
    const targetIndex = parent.childElements.findIndex(n => n.id === targetNode.id);
    parent.childElements.splice(targetIndex, 0, draggedNode);
  } else {
    const targetIndex = nodes.findIndex(n => n.id === targetNode.id);
    nodes.splice(targetIndex, 0, draggedNode);
  }
};

// 辅助:作为子节点插入
const insertAsChild = (draggedNode, targetNode) => {
  if (!targetNode.childElements) {
    targetNode.childElements = [];
  }
  targetNode.childElements.push(draggedNode);
  expandedRows.value[targetNode.id] = true; // 自动展开父节点
};

// 监听props变化同步本地数据
watch(() => props.elements, (newVal) => {
  elements.value = [...newVal];
}, { deep: true });

watch(
  () => props.expanded,
  (newVal) => {
    if (newVal) {
      expandAllNodes(elements.value);
    } else {
      expandedRows.value = {};
    }
  }
);

// 初始化展开逻辑
if (props.expanded) {
  expandAllNodes(elements.value);
}

3. 调整样式优化拖拽体验

给拖拽手柄添加光标样式,让用户明确可拖拽:

.elements-list {
  .p-datatable-tbody > tr > td {
    border: none;
    padding: 0;
  }
  .expandable-row {
    padding: 16px;
    border: 1px solid #dbdbdb;
    border-radius: 8px;
    margin-bottom: 0.5rem;
    width: 100%;
    cursor: grab;
    &:active {
      cursor: grabbing;
    }
  }
  .expander-icon {
    cursor: pointer;
    margin-right: 0.5rem;
  }
}

关键说明

  • 放弃vuedraggable的原因:它需要直接包裹可拖拽元素,但DataTable的行由组件内部生成,无法直接嵌套绑定;SortableJS可直接操作DOM容器,适配表格结构。
  • 树形结构同步:拖拽后必须修改原始嵌套elements数组,因为visibleRows是依赖它的计算属性,仅改扁平化列表不会持久化。
  • 层级适配:通过目标节点和拖拽节点的层级差,自动判断插入为同级或子节点,保证树形结构逻辑正确。

内容的提问来源于stack exchange,提问作者Yellow rain coat

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.17 18:24:52