数据查询可以查看《SpringBoot:查询Tree形数据》,这里不另外展开,主要讲拖拽的逻辑。

数据库存储结构如下:
在这里插入图片描述

  • project_id、create_time、update_time是业务逻辑字段(查询条件),可忽略。
SpringBoot
  • MODEL
public class TestCaseNodeDO {

    /**
     * 节点id
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    /**
     * 项目id
     */
    private Integer projectId;

    /**
     * 节点名称
     */
    private String name;

    /**
     * 父节点id
     */
    private Integer parentId;

    /**
     * 节点层级
     */
    private Integer level;

    /**
     * 创建时间
     */
    @JsonFormat(locale="zh", timezone="GMT+8", pattern="yyyy-MM-dd HH:mm:ss")
    private Date createTime;

    /**
     * 更新时间
     */
    @JsonFormat(locale="zh", timezone="GMT+8", pattern="yyyy-MM-dd HH:mm:ss")
    private Date updateTime;

    /**
     * 排序
     */
    private Integer pos;
    
}
public class TreeBO {

    private Integer id;

    private String name;

    private Integer level;

    private Integer pos;

    private List<TreeBO> children;
}
  • Controller
    提供两个接口,一个是节点的拖拽/drag,一个是拖拽后节点的排序/position
@PostMapping("/create")
public CreatedVO create(@RequestBody TestCaseNodeDO testCaseNodeDO) {
    testCaseNodeService.addNode(testCaseNodeDO);
    return new CreatedVO();
}

// 这里的ids代表要删除的节点id于其所有子节点id
@PostMapping("/delete")
public DeletedVO delete(@RequestBody List<Integer> ids) {
    testCaseNodeService.deleteNodes(ids);
    return new DeletedVO();
}

@PostMapping("/drag")
public UpdatedVO drag(@RequestBody TreeBO treeBO) {
    testCaseNodeService.nodeDrag(treeBO);
    return new UpdatedVO();
}

@PostMapping("/position")
public UpdatedVO position(@RequestBody List<Integer> ids) {
    testCaseNodeService.nodePosition(ids);
    return new UpdatedVO();
}
  • service
    拖拽节点,并遍历子节点(所有子节点只修改了level),所有数据库操作使用了mybatis-plus,语法进自行百度
public void nodeDrag(TreeBO nodeTree) {

    List<TestCaseNodeDO> updateNodes = new ArrayList<>();
    buildUpdateTestCase(nodeTree, updateNodes, 0, 1);

    updateNodes.forEach( node -> {
        testCaseNodeMapper.updateById(node);
    });
}

private void buildUpdateTestCase(TreeBO rootNode, List<TestCaseNodeDO> updateNodes, Integer parentId, int level) {

    TestCaseNodeDO testCaseNode = new TestCaseNodeDO();
    testCaseNode.setId(rootNode.getId());
    testCaseNode.setLevel(level);
    testCaseNode.setParentId(parentId);
    testCaseNode.setPos(rootNode.getPos());
    updateNodes.add(testCaseNode);

    List<TreeBO> children = rootNode.getChildren();
    if (children != null && children.size() > 0) {
        for (TreeBO child : children) {
            buildUpdateTestCase(child, updateNodes, rootNode.getId(), level + 1);
        }
    }
}

设置节点pos:获取相邻节点,然后将该节点pos设置为相邻两节点的中间值

public boolean nodePosition(List<Integer> ids) {
    // 获取相邻节点 id
    Integer before = ids.get(0);
    Integer current = ids.get(1);
    Integer after = ids.get(2);

    TestCaseNodeDO beforeCase = null;
    TestCaseNodeDO afterCase = null;

    TestCaseNodeDO currentNode = getCaseNode(current);
    // 获取相邻节点
    if (before != 0) {
        beforeCase = getCaseNode(before);
        beforeCase = beforeCase.getLevel().equals(currentNode.getLevel()) ? beforeCase : null;
    }

    if (after != 0) {
        afterCase = getCaseNode(after);
        afterCase = afterCase.getLevel().equals(currentNode.getLevel()) ? afterCase : null;
    }

    int pos;

    if (beforeCase == null) {
        pos = afterCase != null ? afterCase.getPos() / 2 : 1024;
    } else {
        pos = afterCase != null ? (beforeCase.getPos() + afterCase.getPos()) / 2 : beforeCase.getPos() + 1024;
    }
    currentNode.setPos(pos);
    return testCaseNodeMapper.updateById(currentNode) > 0;
}

// 通过节点id获取节点
private TestCaseNodeDO getCaseNode(Integer id) {
    return testCaseNodeMapper.selectById(id);
}

随便补充一下添加节点的逻辑,方便理解pos字段定义逻辑。在添加节点时,如果在同一父节点下已经存在同级节点,则获取所有节点中pos最大的值,再+1024,否则直接设为1024

public boolean addNode(TestCaseNodeDO node) {
    node.setCreateTime(new Date());
    node.setUpdateTime(new Date());
    Integer pos = getNextLevelPos(node.getProjectId(), node.getLevel(), node.getParentId());
    node.setPos(pos);
    return testCaseNodeMapper.insert(node) > 0;
}

/**
 * 获得同级模块下一个 pos 值
 * @param projectId project id
 * @param level node level
 * @param parentId node parent id
 * @return 同级模块下一个 pos 值
 */
private Integer getNextLevelPos(Integer projectId, Integer level, Integer parentId) {
    List<TestCaseNodeDO> list = new LambdaQueryChainWrapper<>(testCaseNodeMapper)
            .eq(TestCaseNodeDO::getProjectId, projectId)
            .eq(TestCaseNodeDO::getLevel, level)
            .eq(parentId != null, TestCaseNodeDO::getParentId, parentId)
            .orderByDesc(TestCaseNodeDO::getPos)
            .list();
    if (!CollectionUtils.isEmpty(list)) {
        return list.get(0).getPos() + 1024;
    } else {
        return 1024;
    }
}

删除比较简单

public boolean deleteNodes(List<Integer> ids) {
    return testCaseNodeMapper.deleteBatchIds(ids) > 0;
}
Vue

在这里插入图片描述
前端展示效果如图所示:每个节点鼠标停留时,会出现编辑、添加和删除的按钮

<el-tree
  class="filter-tree node-tree"
  :data="treeNodes"
  :default-expanded-keys="expandedNode"
  node-key="id"
  @node-drag-end="handleDragEnd"
  @node-expand="nodeExpand"
  @node-collapse="nodeCollapse"
  :filter-node-method="filterNode"
  :expand-on-click-node="false"
  highlight-current
  :draggable="draggable"
  :props="defaultProps"
  ref="tree">
  <template v-slot:default="{node,data}">
    <span class="custom-tree-node father" @click="handleNodeSelect(node)">
      <span class="node-icon">
        <i class="el-icon-folder"></i>
      </span>
		<!--如果没修改过字段,这里对应后端应该使用node.name-->
      <span class="node-title">{{ node.label }}</span>

      <span v-if="type === 'edit' && !disabled" class="node-operate child">
        <el-tooltip
          class="item"
          effect="dark"
          :open-delay="200"
          content="重命名"
          placement="top">
          <i @click.stop="openEditNodeDialog('edit', data)" class="el-icon-edit"></i>
        </el-tooltip>
        <el-tooltip
          class="item"
          effect="dark"
          :open-delay="200"
          content="添加子模块"
          placement="top">
          <i @click.stop="openEditNodeDialog('add', data)" class="el-icon-circle-plus-outline"></i>
        </el-tooltip>
        <el-tooltip class="item" effect="dark"
                    :open-delay="200" content="删除" placement="top">
          <i @click.stop="remove(node, data)" class="el-icon-delete"></i>
        </el-tooltip>
      </span>
    </span>
  </template>
</el-tree>

因为是封装过的tree,有一些不需要的元素或字段大家可自行删除,icon的@click事件请自行添加

async handleDragEnd(draggingNode, dropNode, dropType) {
  if (dropType === 'none' || dropType === undefined) {
    return
  }
  const param = this.buildParam(draggingNode, dropNode, dropType)

  this.list = []
  this.getNodeTree(this.treeNodes, draggingNode.data.id, this.list)
  // 这里进行drag的接口请求,如果拖拽成功,再调用position接口
  const res = await Node.dragNode(param)
  if (res.code < window.MAX_SUCCESS_CODE) {
    await Node.nodePosition(this.list)
    ...
  } else {
    ...
  }
},
buildParam(draggingNode, dropNode, dropType) {
  let param
  if (dropNode.level === 1 && dropType !== 'inner') {
    param = draggingNode.data
  } else {
    this.treeNodes.some(node => {
      param = this.findTreeByNodeId(node, dropNode.data.id)
      return param
    })
  }
  return param
},
findTreeByNodeId(rootNode, nodeId) {
  if (rootNode.id === nodeId) {
    return rootNode
  }
  if (rootNode.children) {
    for (let i = 0; i < rootNode.children.length; i++) {
      if (this.findTreeByNodeId(rootNode.children[i], nodeId)) {
        return rootNode
      }
    }
  }
},
getNodeTree(nodes, id, list) {
  if (!nodes) {
    return
  }
  for (let i = 0; i < nodes.length; i++) {
    if (nodes[i].id === id) {
      list[0] = i - 1 >= 0 ? nodes[i - 1].id : 0
      list[1] = nodes[i].id
      list[2] = i + 1 < nodes.length ? nodes[i + 1].id : 0
      return
    }
    if (nodes[i].children) {
      this.getNodeTree(nodes[i].children, id, list)
    }
  }
},
// 这里点击删除按钮时会调用remove弹出弹出层,二次确认删除才会调用confirmRemove进行删除
remove(node) {
  const nodeIds = []
  this.getChildNodeId(node.data, nodeIds)
  this.removeNodeIds = nodeIds
  this.confirmDeleteDialog = true
},
async confirmRemove() {
  const res = await Node.deleteNodes(this.removeNodeIds)
  if (res.code < window.MAX_SUCCESS_CODE) {
    ...
  } else {
    ...
  }
},
getChildNodeId(rootNode, nodeIds) {
  // 递归获取所有子节点ID
  nodeIds.push(rootNode.id)
  if (rootNode.children) {
    for (let i = 0; i < rootNode.children.length; i++) {
      this.getChildNodeId(rootNode.children[i], nodeIds)
    }
  }
},

补上css

.custom-tree-node {
  flex: 11 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 14px;
  padding-right: 8px;
  width: 100%;
}

.node-tree {
  margin-top: 15px;
}

.father .child {
  display: none;
}

.father:hover .child {
  display: block;
}

.node-title {
  width: 0;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex: 1 1 auto;
  padding: 0 5px;
  overflow: hidden;
}

.node-operate > i {
  color: #409eff;
  margin: 0 5px;
}

到这里大概功能就已经OK了,写文章时删除了部分我觉得不需要po出的代码,如有遗漏请在文章下方留言,我会再不断更新文章内容~

Logo

前往低代码交流专区

更多推荐