优化公司 💩 山代码组件

1,956 阅读7分钟

一、前言

我们公司是搞内网安全,会收集一些电脑数据,通过前期设置的策略对数据进行清洗汇总。

设置策略时,需要针对公司的组织架构进行设置。由此引入今天要说的组件-穿梭框组件

公司内部是有封装了这个组件,但在使用暴露出有很多问题。

今天主要是解决这些问题,并重新优化一下代码。

动画5.gif

动画6.gif

二、问题

背景:原本加载结点时全部加载显示到前端,并且查询函数是使用 el-tree 进行查询。由于前端使用了虚拟结点渲染上没有问题。就是请求时长过多

现在需要修改成懒加载的形式进行处理,由此出现了下述问题:

问题1:搜索功能

由于懒加载,故搜索功能需要通过后端返回,无法使用 el-tree 自身携带的方法进行过滤处理。

后端:返回该结点的上下结点关系,后端这里这样处理没问题。

问题在前端:后端返回结果显示到树上,级联选中会出现一个情况:

比如:开发部下有 张三李四,查询 李四,后端返回的数据结构如下:

const data = {
   id: '',
   label: '开发部',
   children: [
      { id: '2',  label: '李四' }
   ]
}

显示到界面上,勾选李四,由于级联父结点会跟着选中。此时添加到右侧列表是 开发部

这就不符合我们的业务要求:用户搜索李四,并勾选,添加到右侧实际上就是对李四的行为进行监控,而非对开发部进行监控

之前没有暴露出这个问题,是由于后端返回全部数据,搜索方法是使用 el-tree 自带的过滤方法(配置 filter-node-method 属性即可)。

查询后,点击选中时,父节点会根据原有的子节点个数,来控制当前父节点是否为全选半选

临时解决方案:这里只能设置 el-tree 的 check-strictly 属性为 true,不让设置成 false

问题2:搜索结果的显示

由于当前这颗树是懒加载,每点击一层节点都会后端请求数据。

这就出现了一个问题:后端返回的数据显示到树上,点击李四,此时会向后端请求数据。

虽说前端可以拦截不让请求数据,但交互上不是很好。

临时的解决方案:在使用一颗树来显示该查询结果,并考虑到问题一,也设置 check-strictly 属性为 true

问题一和二的解决方案

终上,问题主要出在搜索结果的具体要以那种形式显示在页面上

为什么要显示在一个树上,单纯显示查询节点,不显示上级关系不可以吗?

因为节点可能出现同名,同名情况下在设置策略时怕设置错对象,所以才要设置成树,可以看到上级关系。

但其实后面发现,将结果显示在一棵树上也有一个问题:

  • 如果层级很深的情况下在可视化区域下,是看不到其上级关系的。
  • 查询结果也无法一目了然

针对上述情况,我这边的处理是查询结果直接拍平显示,并且后端增加一个字段 path,记录其上级关系,就不会出现上面的那些问题,并且渲染上少了很多节点。

1.png

问题3:一个页面使用多个该组件时,会出现选中数据的错乱

由于开发初期是使用弹窗的形式,每次点开弹窗,都会直接显示上一次选中的结果,并没有根据当前传入的数据,进行渲染。

这就导致了一个页面,如果使用俩个这样的组件,会出现选中数据的错乱。

解决方案:根据传入的数据进行渲染选中状态

三、所使用的技术

目前该组件是可以支持懒加载非懒加载俩种模式

非懒加载为了避免在渲染出现性能问题,也使用虚拟节点的形式进行渲染。

开发框架:vue@3.2.8

UI框架:element-plus@2.9.3

虚拟树则使用 el-tree-v2

虚拟列表使用 vue-virtual-scroller@2.0.0-beta.8,主要体现在搜索结果以及右侧列表

1.png

对于非懒加载的树,后端可以不用构造成一颗树结构,直接拍平即可,但需要告知每个节点的上级id,方便前端找到其父节点即可。

找到其父节点也无需前端手动处理,这里借助 @aximario/json-tree 即可生成树结构。

下面是后端返回的数据:

1.png

其中 pid 代表其父节点 id

此时做以下处理,即可生成一颗树结构

import { construct } from '@aximario/json-tree'
const data = construct(flattenData)

1.png

四、代码实现

功能操作关键点在于:增加删除清空

并且当切换到查询状态后,上述功能该如何处理。

还有查询状态 =》树状态,如何将结果同步到树上

先看一下整体关键代码

<el-tree-v2
  v-if="!isSearchState && !lazy" 
  ref="treeRef"
  :data="data" 
  show-checkbox 
  :height="324" 
  node-key="id"
  check-strictly
>
  <template #default="{ node }">
    <div class="tree-node">
      <span class="text">{{ nodelabel }}</span>
    </div>
  </template>
</el-tree-v2>

<el-scrollbar height="324px" v-if="!isSearchState && lazy">
   <el-tree 
     ref="treeRef" 
     lazy 
     :load="loadNode" 
     check-on-click-node 
     :expand-on-click-node="false"
     :props="treeProps" 
     :height="324" 
     show-checkbox 
     node-key="id" 
     check-strictly
  >
    <template #default="{ node }">{{ node.label }}</template>
  </el-tree>
</el-scrollbar>

<virtualList 
   ref="treeRef" 
   v-if="isSearchState" 
   :height="324" 
   :list="searchList"
   v-model="checkNodesListBykey" 
/>

关键点:左侧三个组件(查询状态下的列表,懒加载树,虚拟树)的ref 都是绑定同一个值。

好处在于:不用根据当前配置,来切换不同的 ref,来获取组件的实例

1.png

增加功能

const handleAdd = () => {
  const nodesList = treeRef.value.getCheckedNodes()
  // 关键代码 1
  checkNodesList.value = Array.from(new Map([...toRaw(checkNodesList.value), ...nodesList].map(item => [item.id, item])).values())
}

关键代码 1:这里只要是合右侧取并集的,而不是覆盖。

virtualList也需要定义 getCheckedNodes 方法:

const getCheckedNodes = () => {
  const data = props.list.filter(item => checkList.value.includes(item.id))
  return data.map(item => {
    const formItem = JSON.parse(JSON.stringify(item))
    return {
      ...formItem
    }
  })
}

删除功能

const handleDel = () => {
  if (cancelCheckList.value.length !== 0) {
    Array.from(cancelCheckList.value).forEach((key) => {
      treeRef.value.setChecked(key, false)
    })

    checkNodesList.value = checkNodesList.value.filter(item => !cancelCheckList.value.includes(item.id))
    cancelCheckList.value = []
  }
}

同样在 virtualList也需要定义 setChecked 方法:

const setChecked = (key, state) => {
  const isInclude = checkList.value.includes(key)
  if (state && !isInclude) {
    checkList.value.push(key)
  }

  if (!state && isInclude) {
    const index = checkList.value.findIndex(item => item === key)
    checkList.value.splice(index, 1)
  }
}

清空功能

const handleClear = () => {
  treeRef.value.setCheckedKeys([])
  cancelCheckList.value = []
  checkNodesList.value = []
}

同样在 virtualList也需要定义 setCheckedKeys 方法:

const setCheckedKeys = (keys) => {
  checkList.value = keys
}

回显功能

这需要在点击按钮显示弹窗时进行重置,并设置选中值

<template>
  <el-tooltip :visible="visibleTooltip">
    <div class="range-contianer" @mouseenter="taggleTooltip(true)" @mouseleave="taggleTooltip(false)">
      <span class="objLabel">{{ objLabel }}</span>
      <el-input class="range" readonly size="small">
        <template #append>
          <el-button @click="showObjdialog" size="small">...</el-button>
        </template>
      </el-input>
    </div>
    <template #content v-if="objLabel.length != 0">
      <span class="tip">{{ objLabel }}</span>
    </template>
  </el-tooltip>
</template>
<script setup>
const showObjdialog = () => {
  visible.value = true
  nextTick(() => {
    isSearchState.value = false
    searchKey.value = ''
    handleClear()
    nextTick(() => {
      treeRef.value.setCheckedKeys(props.modelValue)
      nextTick(() => handleAdd())
    })
  })
}
</script>

虚拟列表

<template>
  <div>
    <el-checkbox-group :key="rendKey" v-model="checkList" @change="change">
      <RecycleScroller :style="style" class="custom-scrollbar" :items="list" :item-size="26" key-field="id">
        <template v-slot:default="{ item }">
          <el-tooltip placement="left">
            <div class="tree-node">
              <el-checkbox class="custom-checkbox" :value="item.id">
                <template #default>
                  <svg-icon :icon="getIcon(item)" />
                  <span class="text">{{ item.label }}</span>
                </template>
              </el-checkbox>
            </div>
            <template #content>
              <div>{{ item.data.path }}</div>
            </template>
          </el-tooltip>
        </template>
      </RecycleScroller>
    </el-checkbox-group>
  </div>
</template>
<script setup>
import { defineProps, computed, ref, defineEmits, watch, defineExpose } from 'vue'
const checkList = ref([])
const rendKey = ref(0)
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
  list: {
    type: Array,
    default: () => []
  },
  height: {
    type: Number,
    default: 324
  },
  modelValue: {
    type: Array,
    default: () => []
  }
})

const style = computed(() => {
  return {
    height: `${props.height}px`
  }
})

watch(() => props.modelValue, (val) => {
  checkList.value = val
}, { immediate: true })

const getIcon = (node) => {
  const { type } = node.data
  let icon = ''
  switch (type) {
    case 1:
      icon = 'region'
      break
    case 2:
      icon = 'depart'
      break
    case 3:
      icon = 'computer'
      break
    default:
      icon = 'computer'
      break
  }
  return icon
}

const change = (checkList) => {
  emit('update:modelValue', checkList)
}

const getCheckedNodes = () => {
  const data = props.list.filter(item => checkList.value.includes(item.id))
  return data.map(item => {
    const formItem = JSON.parse(JSON.stringify(item))
    return {
      ...formItem
    }
  })
}

const setChecked = (key, state) => {
  const isInclude = checkList.value.includes(key)
  if (state && !isInclude) {
    checkList.value.push(key)
  }

  if (!state && isInclude) {
    const index = checkList.value.findIndex(item => item === key)
    checkList.value.splice(index, 1)
  }
}

const setCheckedKeys = (keys) => {
  checkList.value = keys
}

defineExpose({
  getCheckedNodes,
  setChecked,
  setCheckedKeys
})
</script>
<style lang="scss" scoped>
.tree-node {
  display: flex;
  align-items: center;

  .text {
    display: inline-block;
    margin-left: 6px;
    flex: 1;
    font-size: 14px;
    width: 100%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
}

::v-deep .el-checkbox__label {
  width: 182px;
  display: flex;
  align-items: center;
}

::v-deep .el-checkbox {
  line-height: 26px;
}
</style>

五、如何使用

<template>
  <div class="transfer-page">
    <adminTitle title="组织对象树" />
    <el-form :model="form" ref="formRef" :rules="rules">
      <el-form-item label="对象范围" prop="objRange">
        <SelectObjRange v-model="form.objRange" lazy />
      </el-form-item>
      <el-item-item>
        <el-button @click="sumbit" type="primary">提交</el-button>
      </el-item-item>
    </el-form>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import SelectObjRange from '@/components/SelectObjRange'
import { ElMessage } from 'element-plus'
const formRef = ref(null)
const form = ref({
  objRange: []
})
const isNotEmpty = (rule, value, callback) => {
  if (value.length > 0) return callback()
  return callback(new Error('不能为空'))
}

const rules = ref({
  objRange: [
    { validator: isNotEmpty, trigger: 'change' }
  ]
})
const sumbit = () => {
  formRef.value.validate((valid) => {
    if (valid) return ElMessage.success('校验通过')
    return ElMessage.error('校验失败')
  })
}
</script>
<style lang="scss" scoped></style>

六、源码

将该组件已经应用到了我另外一个项目上,大家可以去下载下来查看。如使用上有什么问题,或出现什么 bug 也希望大家指出来。

1.png

1.png

1.png