一、前言
我们公司是搞内网安全,会收集一些电脑数据,通过前期设置的策略对数据进行清洗汇总。
设置策略时,需要针对公司的组织架构进行设置。由此引入今天要说的组件-穿梭框组件
公司内部是有封装了这个组件,但在使用暴露出有很多问题。
今天主要是解决这些问题,并重新优化一下代码。
二、问题
背景:原本加载结点时全部加载显示到前端,并且查询函数是使用 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,记录其上级关系,就不会出现上面的那些问题,并且渲染上少了很多节点。
问题3:一个页面使用多个该组件时,会出现选中数据的错乱
由于开发初期是使用弹窗的形式,每次点开弹窗,都会直接显示上一次选中的结果,并没有根据当前传入的数据,进行渲染。
这就导致了一个页面,如果使用俩个这样的组件,会出现选中数据的错乱。
解决方案:根据传入的数据进行渲染选中状态
三、所使用的技术
目前该组件是可以支持懒加载和非懒加载俩种模式
非懒加载为了避免在渲染出现性能问题,也使用虚拟节点的形式进行渲染。
开发框架:vue@3.2.8
UI框架:element-plus@2.9.3
虚拟树则使用 el-tree-v2
虚拟列表使用 vue-virtual-scroller@2.0.0-beta.8,主要体现在搜索结果以及右侧列表
对于非懒加载的树,后端可以不用构造成一颗树结构,直接拍平即可,但需要告知每个节点的上级id,方便前端找到其父节点即可。
找到其父节点也无需前端手动处理,这里借助 @aximario/json-tree 即可生成树结构。
下面是后端返回的数据:
其中 pid 代表其父节点 id
此时做以下处理,即可生成一颗树结构
import { construct } from '@aximario/json-tree'
const data = construct(flattenData)
四、代码实现
功能操作关键点在于:增加、删除、清空
并且当切换到查询状态后,上述功能该如何处理。
还有查询状态 =》树状态,如何将结果同步到树上
先看一下整体关键代码
<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,来获取组件的实例
增加功能
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 也希望大家指出来。