el-tree:实现父节点自动关联选中、子节点自动关联取消选中功能

element plus的Tree 树形控件提供了一种显示层级关系记录的可视化方案,比如权限记录、组织架构记录等,大大优化了数据显示效果、提升客户感知度。但原生的Tree控件却存在一些不足,如关联操作。比如现在要达到这样的效果:

  1. 某个节点被选中时,上层各级父节点要关联选中;
  2. 某个节点被取消选中时,如果存在子节点且有子节点处于选中状态,则下级各层子节点要关联取消选中;
  3. 某个节点被取消选中时,如果存在父节点且有父节点处于选中状态,则保持原状态不变,不会因为下级子节点全部处于取消选中状态而被系统自动取消选中。

这种需求并不是凭空捏造出来的,而是实实在在的实际需求。比如在前后端分离的项目中,利用动态路由机制为每一位登录用户动态创建导航目录和菜单信息,这些都是权限控制的对象。

我们知道,在动态路由加载机制的加持下,目录、菜单和按钮都是由后台权限机制控制的。能够显示那些目录、每个目录下能够显示哪些菜单访问相应的页面,以及每个页面中能够执行怎样的操作,全都由后台维护,前端不再进行硬编码,有后台提供的权限树列表进行数据渲染。

此时就会出现一个问题,当给角色授权时,如果子节点被选中,而上层各级父节点没有自动关联选中,保存之后就会出现得到了操作权限却看不见操作页面的尴尬。于是还得手动逐级向上一个一个的将父节点全都给手动选择上,这显然太不智能了。反过来说,当节点被取消选中后,各级子节点一并同步取消选中的同时,上层各级父节点不应因为下级子节点全部处于取消选中状态而被系统自动取消选中。这个操作的实际意义就是,允许你访问页面,但不允许进行任何操作。比如,有些场景下,普通用户可以访问用户管理页面,但只能看到自己的用户记录,因此不需要给用户授权类似查询、新建、删除等操作权限。此时,页面访问权限作为父节点,在页面内的增、删、改、查操作权限全部被置空的情况下,不应被取消。

可能有人会说,Tree控件提供了一个属性:check-strictly,默认设置false,就会是上面所说的情况,可以置将其为false,就自动执行父子关联了。大家可以尝试一下,一旦置为fals,当节点存在子节点且所有子节点全部置空的情况下,父节点也自动取消选中了。这显然没有达到我们要的效果。这个逻辑看起来很简单,实现起来还是有些技巧的,所以说光靠控件提供的方法和属性,显然已经达不到想要的效果,只能动手自己干。

首先要明确目标:

  1. 进行角色权限配置时,显示权限树,并将角色绑定权限对应的复选框置为选中状态;
  2. 选中节点时,该节点上层各级父节点同步关联选中;
  3. 取消节点选中时,该节点下层各级子节点同步关联取消选中;
  4. 取消节点选中时,该节点上层各级父节点保持原状态不变。

目标1和2实现起来不难,check-strictly置为false,不要进行父子关联,再将选中项的id添加到default-checked-keys中,然后按照正常的步骤就可以实现。但这里我们打算自己写个方法,在check-strictly置为true,也就是关闭父子节点关联的情况下,一次性实现所有目标。接下来看具体的做法。

在Tree控件中,绑定check事件,从事件中可以得到两个参数:当前被操作节点对象和当前处于选中状态的对象的集合(包括全选中和半选中状态的对象)。而第二个参数又包含checkedNodes、checkedKeys、halfCheckedNodes和halfCheckedKeys四个属性,分别对应了全选节点对象集合、全选节点Key属性集合、半选节点对象集合和半选节点Key属性集合。当check-strictly参数设置成true时,后两个属性没有意义。于是就得到了两个关键信息,当前被操作节点对象和全选节点Key属性集合,后者也就是当前所有被选中节点的ID集合。

为什么要获取当前所有被选中节点的ID集合,前面说过check-strictly参数设置成true,将不会进行父子节点间的选择关联,也就意味着当子节点被选中,父节点不会关联同步选中。这样一来,如果当前选中的不是顶层目录节点,而是菜单或按钮节点,当提交表单时就会出现前面说过的尴尬场景,拥有页面下的操作权限,却看不到页面,因为没有关联上级菜单权限,导致动态路由加载时不会出现相关的菜单项,也就无法访问相关页面。因此,如果赋予了按钮的操作权,就必须同时赋予它查看当前页面的权限和访问相关目录的权限,也就是动态路由在创建目录和菜单时,必须将操作按钮对应的目录和菜单显示出来,才能进入到按钮所在的操作页面完成操作,权限的层级关联关系必须完整的体现出来,否则父级权限的缺失就会导致子权限无法操作。在取消了自动选择关联的前提下,就必须手动查询出被选中节点的各级父节点id集合,然后将集合中id对应的节点全部置于选中状态。这样一来,在角色/权限关联映射表中,就能够形成一条条的完整的权限层级关系链,从顶层的目录节点,到下一级的菜单节点,再到最底层的按钮节点。最终,权限架构才能和动态路由机制完美配合。因此,需要得到当前所有被选中节点ID集合,来遍历寻找每个元素的各级父节点,并将它们全都至于选中状态,实现节点选中后的向上关联操作。

再来说第一个参数,前面说过,最终做出来的效果应该是子节点被选中,则向上的各层级父节点自动被选中;父节点取消选中时,下层所有子节点全部取消选中。check事件提供的第二个参数中的checkedKeys属性,实现了第一步。要实现第二步,需要利用第一个参数获得当前被操作节点对象,先判断当前节点是被选中了还是被取消选中。如果是被取消选中,则以当前节点作为根节点,顺藤摸瓜遍历子树,将各层级所有子节点全部遍历出来,然后全部置于取消选中状态即可。

这就是实现权限配置操作的基本逻辑,有点绕,接下来结合JS代码再对这个流程进行梳理和介绍。

el-tree:实现父节点自动关联选中、子节点自动关联取消选中功能_第1张图片

权限树的数据加载过程这里就不展示了,都是常规操作,这里主要介绍的是tree组件的check事件如何实现上述功能,以下是selectedParents方法的实现逻辑,每一行都有注释:

// 将被选中节点的所有父节点自动设置为选中状态
// 同时当取消选中时,关联下级各层子节点也全部取消选中
let selectedParents = (curPerNode, status) => {
  // 获取被选择节点ID数组
  let selectedNodeKeyArr = status.checkedKeys;
  // 初始化被选择节点的各层级父节点ID集合
  // 这里要使用set集合,而不能用数组,因为权限树分为三层,会出现二层和三层节点拥有相同顶层节点的情况,需要去重
  let selectedNodeParentKeySet = new Set;
  // 遍历被选择节点ID数组,寻找每个节点的各层级父节点
  selectedNodeKeyArr.forEach((nodeKey, index) => {
    // 获取当前节点的ID
    let curNodeKey = nodeKey;
    // 循环遍历当前节点各层级父节点,直到顶层节点为止,顶层节点的父节点ID为0
    while (0 != curNodeKey) {
      // 根据当前节点ID,获得当前节点对象
      let curNode = perTreeRef.value.getNode(curNodeKey);
      // 从当前节点对象中获取父节点ID
      // 不能使用node.parent.data.id方法获取父节点id,原因是当遍历到顶层节点时,parent方法会报错,因为顶层节点没有父节点
      let parentID = curNode.data.permissionParentId;
      // console.info(Object.keys(curNode.data));
      // 判断当前节点的父节点ID是否为0,若为零说明已经遍历到顶层节点,不需要将ID添加到父节点ID集合
      // if (0 != parentID) {
        selectedNodeParentKeySet.add(parentID);
      // }
      // 将当前节点的父节点ID赋值给当前节点ID参数,以便下一次循环遍历时作为获取目标节点对象的参数
      curNodeKey = parentID;
    }
  })

  // 将被选中节点的各层级父节点也置于选中状态
  selectedNodeParentKeySet.forEach((parentKey, index) => {
    perTreeRef.value.setChecked(parentKey, true, false);
  })

  // 初始化取消选中节点id数组,该数组记录的是被取消选中节点以及下级各层子节点的ID
  let unSelectedNodeKeyArr = [];
  // 根据当前节点id是否包含在被选择节点id集合中判断,当前被操作节点是被选中,还是被取消选中
  // 当取消选择时才进行子节点状态变更
  if (!selectedNodeKeyArr.includes(curPerNode.id)) {
    // 将当前取消选中的节点对象id增加到取消选中节点数组中
    unSelectedNodeKeyArr.push(curPerNode.id);
    // 初始化节点类型参数,c代表目录,m代表菜单,b代表按钮
    let perType = 'c';
    // 循环判断当前节点类型参数是否为m,若是则停止循环
    // 之所以停止条件设置为菜单,是因为当以菜单节点作为父节点进行判断时,符合要求的节点全部都是叶子结点,不需要再进行一轮叶子结点的判断,因为叶子结点不会再有子节点
    while ("m" != perType) {
      // 初始化取消选中节点id数组长度
      let length = unSelectedNodeKeyArr.length;
      // 初始化被取消选中的节点对象
      let unSelectedNode = ref([]);
      // 遍历取消选中节点ID数组
      unSelectedNodeKeyArr.forEach((unSelectedNodeKey, index) => {
        // 根据被取消的选中节点的ID获得对应的节点对象
        unSelectedNode = perTreeRef.value.getNode(unSelectedNodeKey);
        // 遍历被选中节点id数组
        selectedNodeKeyArr.forEach((nodeKey, index) => {
          // 根据被选中节点的ID获得对应的节点对象
          let node = perTreeRef.value.getNode(nodeKey);
          // 获得节点对象的父节点ID,permissionParentId是后台orm对象属性
          let parentID = node.data.permissionParentId;
          // 比较被选中节点的父节点ID是否与被取消选中节点ID相同,如果相同,说明当前被选中节点的父节点已被取消选中,则当前节点也应被取消选中
          if (parentID == unSelectedNodeKey) {
            // 将当前节点ID增加到取消选中节点数组中
            unSelectedNodeKeyArr.push(node.data.id);
          }
        })
      })
      /*
      根据数组长度变化判断是否有元素被添加到取消选中节点数组中。
      如果没有,说明被取消节点没有子节点,或者其子节点都处于未被选中状态,循环可以结束;
      反之,则说明被取消节点作为父节点,还有被选中的子节点存在,则随便找一个子节点记录它的类型即可。
      permissionType是后台orm对象属性。
      */
      perType = length == unSelectedNodeKeyArr.length ? 'm' : (unSelectedNode.data.permissionType);
    }

    // 遍历取消选中节点ID数组,将数组中还处于选中状态的节点全部取消选中
    // 也就是将被操作的取消选中节点本身,以及还处于选中状态的子节点全部关联取消选中
    unSelectedNodeKeyArr.forEach((nodeKey) => {
      perTreeRef.value.setChecked(nodeKey, false, false);
    })
  }
}

先创建check事件绑定的方法selectedParents,从第二个参数中获取被选择节点ID数组selectedNodeKeyArr。创建一个set集合,用来记录父节点的id。之所以要使用set集合,是因为权限树是分层结构,当同一父节点下有多个子节点被选中时,只需要记录一次父节点id即可,为了免去判断id重复的代码逻辑,直接使用set自动去重。遍历selectedNodeKeyArr,将每个元素的各层级父节点都找出来。同样因为权限的多层结构,利用while循环进行遍历,直到发现节点id为0,说明已经到了顶层目录节点,就停止循环。这样一来,一个元素的父节点就算找完了,开始下一个元素的循环遍历。

循环体内,先根据当前节点id从权限树引用对象中得到该节点对象,通过permissionParentId属性得到其父节点id,permissionParentId属性是从后台orm对象获取的。这里需要注意,没有使用node.parent.data.id方法,原因是当遍历到顶层节点时,parent方法会报错,因为顶层节点没有父节点。如果要使用parent方法,就必须增加判断逻辑,以免报错。接下来将父节点放入selectedNodeParentKeySet集合中,并将父节点id赋值给当前节点id,进入下一轮循环,如此往复,直到当前节点为0。

当所有的遍历和循环都结束之后,通过权限树引用对象perTreeRef的setChecked方法,将selectedNodeParentKeySet集合中id元素对应的节点全部置于选中状态。setChecked方法有三个参数:要选中节点的id、是否选中标识和是否递归选中子节点标识。这里分别对应了从selectedNodeParentKeySet遍历出来的id、选中标识true和不关联子节点标识false。

到此为止,完成了选中某个节点,自动关联选中各级父节点的操作。接下来处理如何在取消选中节点情况下,自动关联取消子树中各级子节点的操作。

初始化取消选中节点id数组unSelectedNodeKeyArr,该数组记录的是被取消选中节点及其下级各层子节点的ID。通过includes方法判断当前被操作节点的id是否包含在被选中节点id数组selectedNodeKeyArr中,如果包含在内,说明当前是被选中状态,反之则是被取消选中了。只有当前节点被取消选中,才需要对其各级子节点进行取消选中操作。先将当前被操作节点id放入unSelectedNodeKeyArr,定义初始化权限类型perType为目录-c,开始循环遍历,直到perType类型变为菜单-m。

来看循环体内的代码,初始化当前取消选中节点id数组长度参数length,以便记录循环开始时,取消选中节点id数组的长度。接下来初始化当前被操作的、取消了选中状态的节点对象unSelectedNode。遍历unSelectedNodeKeyArr数组中的每个元素,生成node节点对象,并将其赋值给unSelectedNode。再通过遍历当前处于选中状态的节点id数组selectedNodeKeyArr,查询每个元素对象的父节点id,并与外层unSelectedNodeKeyArr数组遍历的元素进行比较,确定包含在selectedNodeKeyArr数组中元素节点的父节点是否也包含在unSelectedNodeKeyArr数组中,也就是说当前被选中的节点中,有没有父节点或者父节点的父节点刚刚被取消选中的情况。如果有,就要将这个节点从selectedNodeKeyArr数组中剔除,而剔除的方式就是将当前这个节点的id存放到unSelectedNodeKeyArr数组中,将来统一再做取消选中处理。

其实到这一步,节点取消选中同步关联下级各层子节点也取消选中的核心逻辑已经完成了,但是还有两个问题没有解决:一是循环终止条件如何得到;二是如果当前被选中节点的下级各层子节点都没有被选中,该怎么处理。也就是说假设当前有一个目录节点被选中,它是顶层节点,它的下级各层子节点都没有被选中,而此时要取消选中该节点,结果是selectedNodeKeyArr数组中不存在以该节点为父节点的元素,换句话说就是以该节点为跟节点的子树分支上,不存在被选中的节点,那么循环就应该立即结束。推广开来,如果是目录节点也是如此,因为目录节点的下一级就是叶子结点,叶子结点不会再有子节点,前面介绍过,循环终止条件就是遍历到目录节点即可。最底层的按钮节点更是如此,因为它本身就是叶子结点。这一连串的判断都需要用最简单、最直观的方式表述出来,得到一个能够进行循环判断的条件。

先说第一个问题,循环终止条件如何得到。既然循环条件判断的是当前节点的类型,那肯定要以它为基础,构建终止条件。因此首先获取到遍历的节点类型。这个类型不是指定某个节点的类型,而是最后一次遍历时任一节点的类型即可。因为unSelectedNodeKeyArr数组中的元素,是按照权限层级关系自动分组依次向后分布的,因此遍历unSelectedNodeKeyArr数组的过程是按照权限层级关系一层一层向下遍历,嵌套在内部的selectedNodeKeyArr遍历过程得到的结果也必定是同一级权限节点的id,不必担心同一次遍历出来的节点类型不同。因此,获取节点类型就可以为循环判断提供重要依据。

光有节点类型还不够,第二个问题就出现了,如果当前节点没有子节点,或者子节点都没处于选中状态,此时取消选中该节点,如何停止循环。在循环一开始,曾经对于循环逻辑开始前的unSelectedNodeKeyArr数组长度进行了一次记录,赋值给了参数length。经过循环逻辑处理后,如果说当前节点有子节点处于选中状态话,那么子节点的id一定会被记录到unSelectedNodeKeyArr中去,此时unSelectedNodeKeyArr的数组长度就一定与循环逻辑开始前的长度length不相等。由此就可以解决第二个问题,对于像被操作节点没有子节点,也就是叶子节点,或者说有子节点但都没被选中的情况下,如何进行判断。

结合这两个问题综合考量,最终得到了一个三目判断表达式:

/*
  根据数组长度变化判断是否有元素被添加到取消选中节点数组中。
  如果没有,说明被取消节点没有子节点,或者其子节点都处于未被选中状态,循环可以结束;
  反之,则说明被取消节点作为父节点,还有被选中的子节点存在,则随便找一个子节点记录它的类型即可。
  permissionType是后台orm对象属性。
*/
perType = length == unSelectedNodeKeyArr.length ? 'm' : (unSelectedNode.data.permissionType);

根据unSelectedNodeKeyArr数组循环逻辑处理前后的长度对比,判断当前循环是否可以结束。如果相等,就可以结束,就将节点类型参数perType设置成目录-m,下一次循环判断时就直接结束,此时真实的节点类型可能是子节点都没有被选中的目录节点-c,也可能是子节点都没有被选中的菜单节点-m,还可能是按钮节点-b。反之,就将节点实际类型赋值给perType,此时,如果perType不是目录-m,就是目录节点-c,会继续循环遍历,绝对不可能是按钮节点-b。也就是说这部分处理的其实是存在子节点被选中的情况。

到此为止,循环逻辑判断就全部结束了,得到的结果就是一个unSelectedNodeKeyArr数组。前面曾经通过树引用对象的setChecked方法将selectedNodeParentKeySet集合中id元素对应的选项设置成选中状态,此时因为又有节点被取消选中,再次调用setChecked方法,将unSelectedNodeKeyArr数组中包含的被操作节点及其各级子节点id对应的节点取消选中即可。

以上就是el-tree控件实现父节点自动关联选中、子节点自动关联取消选中功能的前端代码,希望对大家有所帮助。当然,后期还可以将其封装成JS组件,以便在类似场景重复使用。

你可能感兴趣的:(node.js,vue.js,前端框架)