一、前言
我们是公司是搞内网安全的,会收集日常电脑操作行为数据。基于前期设置的预警策略,对收集到的数据进行汇总处理分析形成报表。
因此经常要对数据进行分析,会涉及到对多数据字段进行过滤处理。前期界面上基本都是多个单一字段做过滤处理,并没有考虑多字段做关系运算后在做过滤处理。
由此产品经理设计了下面的组件,我也不知道这个组件叫啥名🤣,暂且称它为 条件过滤树
由于涉及到产品设计,原型图不方便贴出,但该组件是参考 神策数据中的某个组件,大家可以去那里看看。
一开始拿到手的时候,想着看看有没有写好的轮子,有找到但技术栈不适合,没办法只能硬写了🤣。
公司用的技术栈是 vue2,写完感觉就是在堆屎🤣,开发时间不够,一天硬搓出来,很多细节也没有细究,也是一个小 demo。
效果图:
后面比较闲,想着这个组件还是值去研究,去写好的。所以决定重新用 vue3(script setup)去重新封装(平时私下写 vue3 居多)。
顺便总结一下遇到问题,如有不对的地方或有更好的解决方案也请掘友们指出😁。
vue3 实现的效果图:
二、主要功能
条件过滤树 目前实现的功能点如下:
- 实现 增加节点、删除节点、编辑节点
- 实现 表单校验
- 实现 自定义节点内容
三、设计思路
3.1、数据结构
一开始在公司自己设计的数据结构是这样的:
const form = {
relation: 1, // 关系
cond_child: [
{
relation: 2,
cond_child: [
{
field: 'fileType', // 操作字段
oper: 1, // 操作符
value: 1 // 值
},
{
field: 'fileType', // 操作字段
oper: 1, // 操作符
value: 1 // 值
}
]
},
{
field: 'operType', // 操作字段
oper: 2, // 操作符
value: 2 // 值
}
]
}
出现了几个问题😒:
- 字段值
value
: 只能定义一个值,如果出现多个值该如何处理 - 没有字段标记该字段
field
是支持什么形式的输入(input
、select
)
前面俩个问题还好,主要是下面这个问题:
- 没有字段可以判断该条件是否为 叶子节点,只能通过判断
cond_child
是否为空。这就引发了下面的问题
如果当前 form
表单的数据如下:
const form = {
field: 'operType', // 操作字段
oper: 2, // 操作符
value: 2 // 值
}
在此条件下添加一个且的条件,数据结构则变成了:
const form = {
relation: 1,
cond_children: [
{
field: 'operType', // 操作字段
oper: 2, // 操作符
value: 2 // 值
},
{
field: 'fileType', // 操作字段
oper: 2, // 操作符
value: 2 // 值
}
]
}
这一切看上去也没什么问题,监听addRule 事件
,修改 this.form
。但界面没有重新渲染,this.form
数据是正确的。
大概代码如下:
const ruleItem = {
field: this.form.field,
....
}
// 添加 cond_child 属性
this.form.cond_child = [ruleItem, { /* 新的规则 */ }]
delete this.form.field; // 删除没有的属性
原因:vue2 监听的对象是 this.form
的引用,而我直接操作 this.form
修改,添加 cond_children
属性,导致监听不到。
解决方法:使用 vue.$set()
和 vue.$delete()
。但觉得复杂了,我改掉 this.form
引用不就解决了。
后面改成了如下代码:
// 拷贝一份数据
const data = cloneDeep(this.form);
// 对 data 进行修改
....
// 重新赋值给 form
this.form = data
问题解决👌。
由于出现了上面的问题, vue3 重新封装,重新定义一下数据结构。
✍️ 具体结构如下:
const formData = ref({
is_leaf: false, // 是否为叶子节点
relation: 1, // 关系
children: [
{
is_leaf: false,
relation: 0,
children: [
{
is_leaf: false,
relation: 1,
children: [
{
is_leaf: true,
data: {
filter_type: 'userBehavior', // 字段
oper_type: 1,
content: [''], // 内容
}
},
{
is_leaf: true, // 叶子节点
data: {
filter_type: 'fileType', // 字段
oper_type: 1,
content: [''], // 内容
}
}
]
},
{
is_leaf: true,
data: {
filter_type: 'diskType', // 字段
oper_type: 1,
content: [''], // 内容
}
}
]
}
]
})
3.2、组件设计
⚡目前设计是: RuleTree (树)
、RuleItem(结点)
、index.vue(入口)
✍️ vue3 的调用使用方法:
<template>
<RuleTree :rules="rules" v-model:form="data" :dataMap="dataMap" />
</template>
<script setup>
import { reactive, ref } from 'vue'
import RuleTree from '@/components/RuleTree/index'
const rules = ref({}) // 表单规则
const data = ref({}) // 规则数据
const dataMap = reactive({
operList: [], // 操作符
dataList: [] // 字段字典
})
</script>
3.3、表单校验
一开始使用 vue2 为了快😂,直接往 data 里面注入了错误信息,通过 el-form-item
的 error
进行显示
<!-- ruleItem.vue -->
<el-form-item :error="rule.error" :prop="rule.filter_type">
<!-- 内容的显示 -->
</el-form-item>
但后面发现了几个问题😒:
- 必须手动去触发校验,没法通过
trigger
的形式使内部表单元素自动触发,这就使得跟其他表单组件有点出入 - 污染数据,需要在
data
注入error
变量(用于标记校验信息)
到了 vue3 这里,就没有使用上面的那种方式,而是配合 el-form
、el-form-item
进行校验。
这里最大的问题:该组件是属于递归组件,如何通知到最底层的组件进行表单校验也是试了好多方法,一开始走了好多弯路。
后面接着细说。。。。
3.4、对节点的增加和删除
这里最大的问题:除了上面提到的事件的传递外,还有如何确定节点的位置。
后面接着细说。。。。
四、开发过程
4.1、数据驱动页面显示
该部分主要解决的是能够根据数据在页面上能够展现出来。
✍️ 代码实现
// RuleTree.vue
<template>
<div class="rule-tree">
<template v-if="formData && !formData.is_leaf && formData.next.length != 0">
<div class="rule-item-container">
<!-- 修改关系 -->
<div class="relation" v-if="formData.next.length >= 2">
<el-button type="text" size="small">且</el-button>
<el-button type="text" size="small">或</el-button>
</div>
<!-- 非叶子节点,往下接着递归 -->
<RuleTree v-for="(cond, i) in formData.next" :formData="cond" :key="i" />
</div>
</template>
<template v-else>
<!-- 叶子节点 -->
<RuleItem :config="formData.data"/>
</template>
</div>
</template>
<script setup>
import { defineProps, inject } from 'vue'
import RuleItem from './RuleItem'
const props = defineProps({
formData: {
type: Object,
default: () => ({})
}
})
</script>
// RuleItem.vue
<template>
<div>{{ JSON.stringify(config) }}</div>
</template>
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
config: { type: Object, default: () => ({}) }
})
</script>
这部分没有遇到什么问题,顺利通过👌...
4.2、如何确定 ruleNode 在 ruleTree 的位置
目前该组件设计之初并没有考虑到可以套多层节点,仅设计了套俩层。(后续完善成通过配置进行处理)
这里通过三个参数来确定 runleNode 在 ruleTree 的位置:
curDepth
: 当前层级index
: 下标(当前层级下的next
中数据config
所在的位置)branch
: 分支(只有 0, 1)仅设置了俩层
✍️ 代码实现:
// RuleTree.vue
<template>
<div class="rule-tree">
<template v-if="formData && !formData.is_leaf && formData.next.length != 0">
<div class="rule-item-container">
<!-- 修改关系 -->
<div class="relation" v-if="formData.next.length >= 2">
<el-button type="text" size="small">且</el-button>
<el-button type="text" size="small">或</el-button>
</div>
<!-- 非叶子节点,往下接着递归 -->
<RuleTree v-for="(cond, i) in formData.next" :formData="cond" :key="i" :index="i" :curDepth="curDepth + 1"
:branch="curDepth === 0 ? i : branch"/>
</div>
</template>
<template v-else>
<!-- 叶子节点 -->
<RuleItem :config="formData.data" :index="index" :showAddBtn="showAddBtn" :branch="branch" :curDepth="curDepth"/>
</template>
</div>
</template>
<script setup>
import { defineProps, inject } from 'vue'
import RuleItem from './RuleItem'
const props = defineProps({
formData: {
type: Object,
default: () => ({})
},
curDepth: { // 层级
type: Number,
default: 0
},
branch: { // 分支
type: Number,
default: 0
},
index: { // 下标
type: Number,
default: 0
},
})
</script>
// RuleItem.vue
<template>
<div>{{ JSON.stringify(config) }}</div>
</template>
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
config: { type: Object, default: () => ({}) },
index: { type: Number, default: 0 },
branch: { type: Number, default: 0 },
curDepth: { type: Number, default: 0 }
})
</script>
4.3、如何进行增删
上面已经确定好了三个参数,这里需要在 ruleItem 层级将事件进行抛出。现在问题是要抛到那一层,一开始是抛到了 ruleTree
层。
后面发现在 ruleTree
修改数据结构时(增加、删除节点),由于 上层 index
(组件的入口文件)并没有进行双向数据绑定,导致数据没有同步到该层级。
<template>
<RuleTree :formData="form" />
<el-button size="small" @click="addRuleByTree" icon="Plus"></el-button>
</template>
因此,就设计成了将事件抛到组件入口 index
层级。 ruleTree
和 ruleItem
仅做数据层面的渲染以及事件的抛出。
这又有一个问题,由于上面事件抛出又多了加了一层:
-
原本:
孙(ruleItem)
抛给子(ruleTree)
即可 -
现在:
孙(ruleItem)
经过子(ruleTree)
,在抛给父(index)
vue2:使用的是事件总线的方式在 ruleItem 直接将参数(curDeth
、index
、depth
)抛给了index
之所以采用事件总线,一开始是使用 emit 一层层往外抛事件的,但在开发过程中发现,对于递归组件偶尔事件在 index 层级没有接收到,后面因为时间问题,也没有去细究就使用了事件总线的方式。
缺点:组件挂载后需要监听事件,销毁后需要注销事件
vue3:这里则使用的是依赖注入( provide, inject )的方式,在 ruleItem 通过 emits 事件抛给ruleTree,拿到参数后,在调用 inject 方法触发事件。
事件抛出也可以绕过 RuleTree,直接抛到 index。
这里只不过说修改
relation
是在RuleTree
抛给了index
。为了统一所以增加删除节点才通过 RuleTree 在抛出。
代码实现
1、✍️ 添加节点
// 添加节点
const addRule = (params) => {
const { depth, index, branch } = params
let data = cloneDeep(props.form)
const isAddLayer = depth === 0
let customRule = ruleNode.value
if (typeof props.addRules === 'function') {
const res = props.addRules()
if (res) customRule = res
}
const addRuleNode = (obj, depth, i, branch) => {
if (depth === 0) {
// 到达对应层级
if (isAddLayer) {
const clickRule = cloneDeep(obj.next[i].data)
obj.next[i].relation = 1
obj.next[i].is_leaf = false
obj.next[i].next = [{
is_leaf: true,
data: {
...clickRule
}
}, customRule]
} else {
i++
obj.next.splice(i, 0, customRule)
}
return
}
if (Array.isArray(obj.next)) {
if (addRuleNode(obj.next[branch], depth - 1, i)) {
return true
}
}
}
addRuleNode(data, depth, index, branch)
emits('update:form', data)
}
2、✍️ 删除节点
// 删除节点
const delRule = (params) => {
const { depth, index, branch } = params
let data = cloneDeep(props.form)
const delRuleNode = (obj, depth, i, branch) => {
// 如果已经到达目标层级
if (depth === 0) {
if (Array.isArray(obj.next) && obj.next.length > i) {
// 删除指定下标的对象
obj.next.splice(i, 1)
if (obj.next.length === 1 && obj.next[0].is_leaf) { // 变成叶子结点
obj.is_leaf = true
obj.data = obj.next[0].data
obj.next = []
}
return true // 删除成功
}
return false // 删除失败
}
// 如果还没到达目标层级,继续递归
if (Array.isArray(obj.next)) {
if (delRuleNode(obj.next[branch], depth - 1, i)) {
return true
}
}
}
data.is_leaf ? data = {} : delRuleNode(data, depth, index, branch)
data = formatForm(data) // 调整数据结构,后面【注意】会细讲
emits('update:form', data)
}
注意 删除节点后可能会出现下面这种情况:
原本:
删除节点后:
删除后数据结构:
{
"is_leaf": false,
"data": {},
"next": [
{
"is_leaf": false,
"data": {
"filter_type": "operType",
"opr_type": 1,
"content": []
},
"relation": 1,
"next": [
{
"is_leaf": true,
"data": {
"filter_type": "operType",
"opr_type": 1,
"content": []
}
},
{
"is_leaf": true,
"data": {
"filter_type": "operType",
"opr_type": 1,
"content": []
}
}
]
}
],
"relation": 1
}
查看数据结构,组件的渲染是没有问题,这部分在渲染且或
操作符时,需要判断当前的 next
长度是否为大于 1
<template v-if="formData && !formData.is_leaf && formData.next.length != 0">
<div class="rule-item-container">
<div class="relation" v-if="formData.next.length >= 2">
<el-button type="text" size="small" :class="+formData.relation === 1 ? 'active' : ''"
@click="changeRelation(1)">且</el-button>
<el-button type="text" size="small" :class="+formData.relation === 2 ? 'active' : ''"
@click="changeRelation(2)">或</el-button>
</div>
<RuleTree v-for="(cond, i) in formData.next" :formData="cond" :key="i" :index="i" :curDepth="curDepth + 1"
:branch="curDepth === 0 ? i : branch" :showAddBtn="showAddBtn">
<template #default="slotProps">
<slot v-bind="slotProps" />
</template>
</RuleTree>
</div>
</template>
界面显示是小问题,问题是该数据结构是要发送给到后端的,relation
标记了同级目录下的 next
中所有条件的关系,但 next
只有一个。
这里觉得不是很好,因此这里删除节点后,又对数据结构的层级做了格式化的操作
const formatForm = (data) => {
if (!data.is_leaf) {
if (data.next && data.next.length === 1) {
data = data.next[0]
return data
}
return data
}
return data
}
因为这里的组件设计只能套俩层,故只要处理上面这种情况即可。
3、✍️ provide inject 事件的注入
// index.vue
<template>
<div class="tree">
<RuleTree :formData="form"></RuleTree>
<el-button size="small" @click="addRuleByTree" icon="Plus"></el-button>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, defineExpose, provide, watch, computed } from 'vue'
import RuleTree from './RuleTree'
import { cloneDeep } from 'lodash'
const emits = defineEmits(['update:form'])
const props = defineProps({
form: { type: Object, default: () => ({}) },
rules: { type: Object, default: () => ({}) },
addRules: { type: Function, default: () => { } },
depth: { type: Number, default: 2 }
})
// 添加节点
const addRule = (params) => {}
// 删除节点
const delRule = (params) => {}
// 修改关系 (跟只有俩层,比较简单,不做更多描述)
const changeRelation = (params) => {
const { value, depth, branch } = params
const data = cloneDeep(props.form)
if (depth === 0) {
data.relation = value
} else {
data.next[branch].relation = value
}
emits('update:form', data)
}
// 调整数据结构
const formatForm = (data) => {
if (!data.is_leaf) {
if (data.next && data.next.length === 1) {
data = data.next[0]
return data
}
return data
}
return data
}
// 注入
provide('addRule', addRule)
provide('delRule', delRule)
provide('changeRelation', changeRelation)
</script>
// ruleTree.vue
<template>
<div class="rule-tree" :style="curDepth > 0 ? { paddingLeft: '60px' } : {}">
<template v-if="formData && !formData.is_leaf && formData.next.length != 0">
<div class="rule-item-container">
<div class="relation" v-if="formData.next.length >= 2">
<el-button type="text" size="small" :class="+formData.relation === 1 ? 'active' : ''"
@click="changeRelation(1)">且</el-button>
<el-button type="text" size="small" :class="+formData.relation === 2 ? 'active' : ''"
@click="changeRelation(2)">或</el-button>
</div>
<RuleTree v-for="(cond, i) in formData.next" :formData="cond" :key="i" :index="i" :curDepth="curDepth + 1"
:branch="curDepth === 0 ? i : branch" :showAddBtn="showAddBtn">
</RuleTree>
</div>
</template>
<template v-else>
<RuleItem :config="formData.data" :index="index" :showAddBtn="showAddBtn" :branch="branch" :curDepth="curDepth"
@addRule="addRule" @delRule="delRule">
</RuleItem>
</template>
</div>
</template>
<script setup>
import { defineProps, inject } from 'vue'
import RuleItem from './RuleItem'
const changeRelationFuncInject = inject('changeRelation')
const addRuleFuncInject = inject('addRule')
const delRuleFuncInject = inject('delRule')
const props = defineProps({
formData: {
type: Object,
default: () => ({})
},
curDepth: { // 层级
type: Number,
default: 0
},
branch: { // 分支
type: Number,
default: 0
},
index: { // 下标
type: Number,
default: 0
},
showAddBtn: {
type: Boolean,
default: false
}
})
const changeRelation = (value) => {
changeRelationFuncInject({
depth: props.curDepth,
branch: props.branch,
value
})
}
const addRule = () => {
addRuleFuncInject({
branch: props.branch,
depth: props.curDepth - 1,
index: props.index
})
}
const delRule = () => {
delRuleFuncInject({
branch: props.branch,
depth: props.curDepth - 1,
index: props.index
})
}
</script>
// ruleItem.vue
<template>
<div class="rule-item">
<div class="rule-node">
<div>{{ JSON.stringify(config) }}</div>
</div>
<div class="oper-btn">
<el-button size="small" v-if="showAddBtn" @click="addRule">添加</el-button>
<el-button size="small" @click="delRule">删除</el-button>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, watch, ref, inject, onMounted, computed } from 'vue'
const emits = defineEmits(['addRule', 'delRule'])
const props = defineProps({
config: { type: Object, default: () => ({}) },
index: { type: Number, default: 0 },
showAddBtn: { type: Boolean, default: false },
branch: { type: Number, default: 0 },
curDepth: { type: Number, default: 0 }
})
const addRule = () => emits('addRule')
const delRule = () => emits('delRule')
</script>
4.4、如何自定义内容
该部分使用的是插槽的形式进行实现的,这里考虑到需要 增加删除节点
,如果由上层去定位节点位置可能会比较麻烦。
- 上层通过 id 去定位可能就不会很复杂
故:内部使用插槽时除了将数据抛出外,还好会将增加节点
、删除节点
事件方法抛出,由上层自行决定是否使用。
✍️ 代码实现
// RuleTree.vue
<template>
<div class="rule-tree" :style="curDepth > 0 ? { paddingLeft: '60px' } : {}">
<template v-if="formData && !formData.is_leaf && formData.next.length != 0">
<div class="rule-item-container">
<div class="relation" v-if="formData.next.length >= 2">
<el-button type="text" size="small" :class="+formData.relation === 1 ? 'active' : ''"
@click="changeRelation(1)">且</el-button>
<el-button type="text" size="small" :class="+formData.relation === 2 ? 'active' : ''"
@click="changeRelation(2)">或</el-button>
</div>
<RuleTree v-for="(cond, i) in formData.next" :formData="cond" :key="i" :index="i" :curDepth="curDepth + 1"
:branch="curDepth === 0 ? i : branch" :showAddBtn="showAddBtn">
<!-- 插槽 -->
<template #default="slotProps">
<slot v-bind="slotProps" />
</template>
</RuleTree>
</div>
</template>
<template v-else>
<RuleItem :config="formData.data" :index="index" :showAddBtn="showAddBtn" :branch="branch" :curDepth="curDepth"
@addRule="addRule" @delRule="delRule">
<!-- 插槽 -->
<template #default="slotProps">
<slot v-bind="slotProps" />
</template>
</RuleItem>
</template>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
import RuleItem from './RuleItem'
const props = defineProps({
formData: {
type: Object,
default: () => ({})
},
curDepth: { // 层级
type: Number,
default: 0
},
branch: { // 分支
type: Number,
default: 0
},
index: { // 下标
type: Number,
default: 0
},
showAddBtn: {
type: Boolean,
default: false
}
})
</script>
// ruleItem.vue
<template>
<slot :data="config" :addRule="addRule" :delRule="delRule">
<div class="rule-item">
<div class="rule-node">
<div>{{ JSON.stringify(config) }}</div>
</div>
<div class="oper-btn">
<el-button size="small" v-if="showAddBtn" @click="addRule">添加</el-button>
<el-button size="small" @click="delRule">删除</el-button>
</div>
</div>
</slot>
</template>
<script setup>
import { defineProps, defineEmits, watch, ref, inject, onMounted, computed } from 'vue'
const emits = defineEmits(['addRule', 'delRule'])
const props = defineProps({
config: { type: Object, default: () => ({}) },
index: { type: Number, default: 0 },
showAddBtn: { type: Boolean, default: false },
branch: { type: Number, default: 0 },
curDepth: { type: Number, default: 0 }
})
const addRule = () => emits('addRule')
const delRule = () => emits('delRule')
</script>
4.5、如何进行表单校验
由于是配合el-form
和 el-form-item
进行使用的,这里的第一个问题是:
- 这个两个组件是要套在哪个位置上最为合适,一开始我是套在
RuleTree
// RuleTree
<template>
<div class="rule-tree" :style="curDepth > 0 ? { paddingLeft: '60px' } : {}">
<template v-if="formData && !formData.is_leaf && formData.next.length != 0">
<div class="rule-item-container">
<el-form>
<el-form-item>
<RuleTree v-for="(cond, i) in formData.next" :formData="cond" :key="i"/>
</el-form-item>
</el-form>
</div>
</template>
<template v-else>
<RuleItem :config="formData.data" />
</template>
</div>
</template>
之所以放到这里是想着一个子树 对应一个表单,但这里就发现了一个问题,又出现了递归现象(el-form-item
套 el-form
)一直循环套下去,如果后续层级修改成可配置则递归的层级会更深。
而且如果出现递归现象,上层要通知底层组件进行校验也很麻烦(el-form
校验的方式是通过 ref
)
经过考虑后:将 el-form
和 el-form-item
放到 RuleItem。
放到这里的好处:
- 收集表单
ref
比较好收集 - 表单需要绑定数据,在这一层拿到的这个节点的数据比较简单
// RuleItem.vue
<template>
<el-form size="small" ref="formRef" :model="config">
<el-form-item prop="content" :rules="rules[config.filter_type]">
<div class="rule-item">
<div class="rule-node">
<div>{{ JSON.stringify(config) }}</div>
</div>
<div class="oper-btn">
<el-button size="small" v-if="showAddBtn" @click="addRule">添加</el-button>
<el-button size="small" @click="delRule">删除</el-button>
</div>
</div>
</el-form-item>
</el-form>
</template>
到这里,就能实现通过配置 trigger
实现表单组件自动触发校验。
接下来:上层需要通过 ref
去触发校验,代码如下:
// 调用方
<template>
<RuleTree ref="ruleTreeRef" :rules="rules" v-model:form="formData" :dataMap="dataMap" />
<el-button type="primary" @click="validate">校验</el-button>
</template>
<script setup>
import { reactive, ref } from 'vue'
import RuleTree from '@/components/RuleTree/index'
import { ElMessage } from 'element-plus'
const isNotEmpty = (rule, value, callback) => {
if (value.length > 0) return callback()
return callback(new Error('不能为空'))
}
const dataMap = reactive({}) // 不重要
const formData = ref({})
// 表单规则(el-form 一致)
const rules = ref({
userBehavior: [
{ validator: isNotEmpty, trigger: 'change' }
],
fileSize: [
{ validator: isNotEmpty, trigger: 'blur' }
],
operType: [
{
validator: (rule, value, callback) => {
if (value.length <= 1) callback(new Error('长度需要大于 1'))
return callback()
},
trigger: 'blur'
}
]
})
// 校验
const validate = async () => {
ruleTreeRef.value.validate((valid) => {
if (valid) return ElMessage.success('校验通过')
return ElMessage.error('校验失败')
})
}
</script>
这里很明显在 组件入口文件 index 中需要提供 validate
方法供上层触发校验。
// index.vue
<script setup>
import { defineExpose } from 'vue'
const validate = (callback) => {
// TODO 这里需要通知 RuleItem 触发校验,并把结果返回
// TODO 汇总所有结果,将最终的校验结果给到上层
}
defineExpose({ validate })
</script>
一开始想怎么通知 ruleItem
触发校验也是想了很久,这里是有组件递归的现象。
后面想到可以通过 provide、inject
先收集到所有 el-form
对象保存到数组中,手动触发校验时遍历该数组进行校验即可。
✍️ 代码实现
// index.vue
<script setup>
import { defineExpose, provide, ref } from 'vue'
const ruleNodeList = ref([])
const collectRuleNode = (ruleNode) => {
ruleNodeList.value.push(ruleNode)
}
const validate = (callback) => {
return new Promise((resolve) => {
Promise.all(ruleNodeList.value.filter((item) => item.value).map(ruleNode => ruleNode.value.validate())).then(res => {
typeof callback === 'function' ? callback(true) : resolve(true)
}).catch(() => {
typeof callback === 'function' ? callback(false) : resolve(false)
})
})
}
provide('collectRuleNode', collectRuleNode)
defineExpose({ validate })
</script>
<template>
<el-form ref="elFormRef" size="small" ref="formRef" :model="config">
<el-form-item prop="content" :rules="rules[config.filter_type]">
<div class="rule-item">
<div class="rule-node">
<div>{{ JSON.stringify(config) }}</div>
</div>
<div class="oper-btn">
<el-button size="small" v-if="showAddBtn" @click="addRule">添加</el-button>
<el-button size="small" @click="delRule">删除</el-button>
</div>
</div>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, inject, onMounted } from 'vue'
const elFormRef = ref(null)
const collectRuleNodeFuncInject = inject('collectRuleNode')
onMounted(() => {
collectRuleNodeFuncInject(elFormRef)
})
</script>
五、使用说明
5.1、基本使用
<template>
<RuleTree ref="ruleTreeRef" v-model:form="formData" :dataMap="dataMap" />
</template>
<script setup>
import { reactive, ref } from 'vue'
import RuleTree from '@/components/RuleTree/index'
const dataMap = reactive({
operList: [
{ label: '大于', value: 1 },
{ label: '等于', value: 2 },
{ label: '小于', value: 3 },
{ label: '介于', value: 4 }
],
dataList: [
{
label: '文件类型',
field: 'fileType',
type: 'select',
valueList: [
{ label: '文件类型-1', value: 1 },
{ label: '文件类型-2', value: 2 },
{ label: '文件类型-3', value: 3 },
{ label: '文件类型-4', value: 4 },
{ label: '文件类型-5', value: 5 }
]
},
{
label: '磁盘类型',
field: 'diskType',
type: 'select',
valueList: [
{ label: '磁盘类型-1', value: 1 },
{ label: '磁盘类型-2', value: 2 },
{ label: '磁盘类型-3', value: 3 },
{ label: '磁盘类型-4', value: 4 },
{ label: '磁盘类型-5', value: 5 }
]
}
]
})
const formData = ref({})
</script>
5.2、使用自定义内容
<template>
<RuleTree ref="ruleTreeRef" v-model:form="formData">
<template #default="{ data, addRule, delRule }">
<div>
数据:{{ JSON.stringify(data) }}
<el-button type="primary" @click="addRule">增加</el-button>
<el-button type="danger" @click="delRule">删除</el-button>
</div>
</template>
</RuleTree>
</template>
<script setup>
import { reactive, ref } from 'vue'
import RuleTree from '@/components/RuleTree/index'
const formData = ref({})
</script>
5.3、表单校验
<template>
<RuleTree ref="ruleTreeRef" :rules="rules" v-model:form="formData" :dataMap="dataMap" />
<el-button type="primary" @click="validate">校验</el-button>
</template>
<script setup>
import { reactive, ref } from 'vue'
import RuleTree from '@/components/RuleTree/index'
import { ElMessage } from 'element-plus'
const isNotEmpty = (rule, value, callback) => {
if (value.length > 0) return callback()
return callback(new Error('不能为空'))
}
const dataMap = reactive({
operList: [
{ label: '大于', value: 1 },
{ label: '等于', value: 2 },
{ label: '小于', value: 3 },
{ label: '介于', value: 4 }
],
dataList: [
{
label: '文件类型',
field: 'fileType',
type: 'select',
valueList: [
{ label: '文件类型-1', value: 1 },
{ label: '文件类型-2', value: 2 },
{ label: '文件类型-3', value: 3 },
{ label: '文件类型-4', value: 4 },
{ label: '文件类型-5', value: 5 }
]
},
{
label: '磁盘类型',
field: 'diskType',
type: 'select',
valueList: [
{ label: '磁盘类型-1', value: 1 },
{ label: '磁盘类型-2', value: 2 },
{ label: '磁盘类型-3', value: 3 },
{ label: '磁盘类型-4', value: 4 },
{ label: '磁盘类型-5', value: 5 }
]
}
]
})
const formData = ref({})
const rules = ref({
userBehavior: [
{ validator: isNotEmpty, trigger: 'change' }
],
fileSize: [
{ validator: isNotEmpty, trigger: 'blur' }
],
operType: [
{
validator: (rule, value, callback) => {
if (value.length <= 1) callback(new Error('长度需要大于 1'))
return callback()
},
trigger: 'blur'
}
]
})
const validate = async () => {
ruleTreeRef.value.validate((valid) => {
if (valid) return ElMessage.success('校验通过')
return ElMessage.error('校验失败')
})
}
</script>
六、不足及后续开发
目前存在的不足:
- 目前组件层级只能套俩层,后续会调整成可配置的
- 没法实现鼠标拖拽节点(el-tree 有点类似)修改其相对位置(后续看看可行性)
针对第二点:这里请教一下掘友有没有好用的拖拽插件可以使用。
七、组件源码
vue2 写得比较简陋也有些问题,这里就不贴出来如果有需要的话,后续我修改后会贴出来。
注:想着每个业务场景使用该组件时,除非很通用(对于操作符介于,要显示一个还是俩个输入框,每个场景可能有所不同)才会使用到ruleItem
设置好的组件进行渲染,不然会使用自定义内容的形式进行渲染。故ruleItem 渲染处理的比较简单。
7.1、入口文件 index.vue
<template>
<div class="tree">
<RuleTree v-if="JSON.stringify(form) !== '{}'" :formData="form" :showAddBtn="showAddBtn">
<template #default="slotProps">
<slot v-bind="slotProps" />
</template>
</RuleTree>
<el-button size="small" @click="addRuleByTree" icon="Plus"></el-button>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, defineExpose, provide, watch, computed } from 'vue'
import RuleTree from './RuleTree'
import { cloneDeep } from 'lodash'
const emits = defineEmits(['update:form'])
const props = defineProps({
form: { type: Object, default: () => ({}) },
rules: { type: Object, default: () => ({}) },
addRules: { type: Function, default: () => { } },
depth: { type: Number, default: 2 },
dataMap: { type: Array, default: () => ([]) }
})
const showAddBtn = ref(false)
const ruleNodeList = ref([])
const ruleNode = computed(() => {
const rule = {
is_leaf: true,
data: {}
}
const { dataList = [], operList = [] } = props.dataMap
const { valueList = [] } = dataList
if (dataList.length > 0 && operList.length > 0) {
rule.data.filter_type = dataList[0].field
rule.data.opr_type = operList[0].value
rule.data.content = valueList.map(item => item.value)
}
return rule
})
watch(() => props.form, (data) => {
showAddBtn.value = data.next && data.next.length > 1
}, { immediate: true })
// 往树上添加结点
const addRuleByTree = () => {
let data = cloneDeep(props.form)
const isInit = JSON.stringify(data) === '{}'
let rule = ruleNode.value
if (typeof props.addRules === 'function') {
const res = props.addRules()
if (res) rule = res
}
if (isInit) { // 未初始化
const form = {}
form.is_leaf = true
form.data = rule.data
form.next = []
data = form
} else {
const len = data.next.length
data.is_leaf = false
if (len === 0) {
data.relation = 1
data.next = [{
is_leaf: true,
data: data.data
}, rule]
data.data = {}
} else {
data.next.push(rule)
}
}
emits('update:form', data)
}
// 添加节点
const addRule = (params) => {
const { depth, index, branch } = params
let data = cloneDeep(props.form)
const isAddLayer = depth === 0
let customRule = ruleNode.value
if (typeof props.addRules === 'function') {
const res = props.addRules()
if (res) customRule = res
}
const addRuleNode = (obj, depth, i, branch) => {
if (depth === 0) {
// 到达对应层级
if (isAddLayer) {
const clickRule = cloneDeep(obj.next[i].data)
obj.next[i].relation = 1
obj.next[i].is_leaf = false
obj.next[i].next = [{
is_leaf: true,
data: {
...clickRule
}
}, customRule]
} else {
i++
obj.next.splice(i, 0, customRule)
}
return
}
if (Array.isArray(obj.next)) {
if (addRuleNode(obj.next[branch], depth - 1, i)) {
return true
}
}
}
addRuleNode(data, depth, index, branch)
data = formatForm(data)
emits('update:form', data)
}
// 删除节点
const delRule = (params) => {
const { depth, index, branch } = params
let data = cloneDeep(props.form)
const delRuleNode = (obj, depth, i, branch) => {
// 如果已经到达目标层级
if (depth === 0) {
if (Array.isArray(obj.next) && obj.next.length > i) {
// 删除指定下标的对象
obj.next.splice(i, 1)
if (obj.next.length === 1 && obj.next[0].is_leaf) { // 变成叶子结点
obj.is_leaf = true
obj.data = obj.next[0].data
obj.next = []
}
return true // 删除成功
}
return false // 删除失败
}
// 如果还没到达目标层级,继续递归
if (Array.isArray(obj.next)) {
if (delRuleNode(obj.next[branch], depth - 1, i)) {
return true
}
}
}
data.is_leaf ? data = {} : delRuleNode(data, depth, index, branch)
data = formatForm(data)
emits('update:form', data)
}
// 修改关系
const changeRelation = (params) => {
const { value, depth, branch } = params
const data = cloneDeep(props.form)
if (depth === 0) {
data.relation = value
} else {
data.next[branch].relation = value
}
emits('update:form', data)
}
// 调整数据结构
const formatForm = (data) => {
if (!data.is_leaf) {
if (data.next && data.next.length === 1) {
data = data.next[0]
return data
}
return data
}
return data
}
const collectRuleNode = (ruleNode) => {
ruleNodeList.value.push(ruleNode)
}
const validate = (callback) => {
return new Promise((resolve) => {
Promise.all(ruleNodeList.value.filter((item) => item.value).map(ruleNode => ruleNode.value.validate())).then(res => {
typeof callback === 'function' ? callback(true) : resolve(true)
}).catch(() => {
typeof callback === 'function' ? callback(false) : resolve(false)
})
})
}
provide('addRule', addRule)
provide('delRule', delRule)
provide('changeRelation', changeRelation)
provide('collectRuleNode', collectRuleNode)
provide('rules', props.rules)
provide('dataMap', props.dataMap)
provide('addRuleByTree', addRuleByTree)
defineExpose({
validate
})
</script>
7.2、RuleTree.vue
<template>
<div class="rule-tree" :style="curDepth > 0 ? { paddingLeft: '60px' } : {}">
<template v-if="formData && !formData.is_leaf && formData.next.length != 0">
<div class="rule-item-container">
<div class="relation" v-if="formData.next.length >= 2">
<el-button type="text" size="small" :class="+formData.relation === 1 ? 'active' : ''"
@click="changeRelation(1)">且</el-button>
<el-button type="text" size="small" :class="+formData.relation === 2 ? 'active' : ''"
@click="changeRelation(2)">或</el-button>
</div>
<RuleTree v-for="(cond, i) in formData.next" :formData="cond" :key="i" :index="i" :curDepth="curDepth + 1"
:branch="curDepth === 0 ? i : branch" :showAddBtn="showAddBtn">
<template #default="slotProps">
<slot v-bind="slotProps" />
</template>
</RuleTree>
</div>
</template>
<template v-else>
<RuleItem :config="formData.data" :index="index" :showAddBtn="showAddBtn" :branch="branch" :curDepth="curDepth"
@addRule="addRule" @delRule="delRule">
<template #default="slotProps">
<slot v-bind="slotProps" />
</template>
</RuleItem>
</template>
</div>
</template>
<script setup>
import { defineProps, inject } from 'vue'
import RuleItem from './RuleItem'
const changeRelationFuncInject = inject('changeRelation')
const addRuleFuncInject = inject('addRule')
const delRuleFuncInject = inject('delRule')
const props = defineProps({
formData: {
type: Object,
default: () => ({})
},
curDepth: { // 层级
type: Number,
default: 0
},
branch: { // 分支
type: Number,
default: 0
},
index: { // 下标
type: Number,
default: 0
},
showAddBtn: {
type: Boolean,
default: false
}
})
const changeRelation = (value) => {
changeRelationFuncInject({
depth: props.curDepth,
branch: props.branch,
value
})
}
const addRule = () => {
addRuleFuncInject({
branch: props.branch,
depth: props.curDepth - 1,
index: props.index
})
}
const delRule = () => {
delRuleFuncInject({
branch: props.branch,
depth: props.curDepth - 1,
index: props.index
})
}
</script>
<style lang="scss" scoped>
.rule-tree {
margin-bottom: 20px;
.rule-item-container {
position: relative;
.relation {
position: absolute;
top: 0px;
bottom: 0px;
left: 12px;
display: flex;
flex-direction: column;
justify-content: center;
width: 40px;
border-right: 1px solid var(--el-color-primary);
.el-button {
color: #c0c4cc;
&.active {
color: var(--el-color-primary);
}
}
}
}
.el-button {
margin: 0px;
}
}
</style>
7.3、ruleItem.vue
<template>
<el-form size="small" ref="formRef" v-if="show" :model="config">
<el-form-item prop="content" :rules="rules[config.filter_type]">
<slot :data="config" :addRule="addRule" :delRule="delRule">
<div class="rule-item">
<div class="rule-node">
<el-select v-model="config.filter_type">
<el-option v-for="item in map.labelList" :key="item.field" :value="item.field" :label="item.label" />
</el-select>
<el-select v-model="config.opr_type">
<el-option v-for="item in map.operList" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
<el-select class="value-select" v-model="config.content" multiple collapse-tags collapse-tags-tooltip
:max-collapse-tags="2">
<el-option v-for="(item) in map.dataList[config.filter_type].valueList" :key="item.value"
:value="item.value" :label="item.label" />
</el-select>
</div>
<div class="oper-btn">
<el-button size="small" v-if="showAddBtn" @click="addRule">添加</el-button>
<el-button size="small" @click="delRule">删除</el-button>
</div>
</div>
</slot>
</el-form-item>
</el-form>
</template>
<script setup>
import { defineProps, defineEmits, watch, ref, inject, onMounted, computed } from 'vue'
const emits = defineEmits(['addRule', 'delRule'])
const props = defineProps({
config: { type: Object, default: () => ({}) },
index: { type: Number, default: 0 },
showAddBtn: { type: Boolean, default: false },
branch: { type: Number, default: 0 },
curDepth: { type: Number, default: 0 }
})
const collectRuleNodeFuncInject = inject('collectRuleNode')
const rules = inject('rules')
const dataMap = inject('dataMap')
const addRuleByTreeFunc = inject('addRuleByTree')
const formRef = ref(null)
const show = ref(false)
const map = computed(() => {
const operList = dataMap.operList
const labelList = dataMap.dataList.map(item => {
return {
label: item.label,
field: item.field
}
})
const dataList = dataMap.dataList.reduce((acc, cur) => {
acc[cur.field] = cur
return acc
}, {})
return {
operList,
labelList,
dataList
}
})
watch(() => props.config, (data) => {
show.value = data.filter_type && data.opr_type && data.content
}, { immediate: true, deep: 2 })
const addRule = () => {
if (!props.showAddBtn) {
addRuleByTreeFunc()
} else {
emits('addRule')
}
}
const delRule = () => emits('delRule')
onMounted(() => {
collectRuleNodeFuncInject(formRef)
})
</script>
<style lang="scss" scoped>
.rule-item {
display: flex;
gap: 6px;
.rule-node {
.el-select {
width: 120px;
&.value-select {
width: 300px;
}
}
display: flex;
gap: 6px;
}
}
</style>
7.4、使用(完整代码)
<template>
<div>
<el-form size="small">
<el-form-item label="满足条件">
<!-- 不使用插槽 -->
<RuleTree ref="ruleTreeRef" :rules="rules" v-model:form="formData" :dataMap="dataMap" />
<!-- 使用插槽 -->
<RuleTree ref="ruleTreeRef" :rules="rules" v-model:form="formData" :dataMap="dataMap">
<template #default="{ data, addRule, delRule }">
<div>
数据:{{ JSON.stringify(data) }}
<el-button type="primary" @click="addRule">增加</el-button>
<el-button type="danger" @click="delRule">删除</el-button>
</div>
</template>
</RuleTree>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="validate">校验</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import RuleTree from '@/components/RuleTree/index'
import { ElMessage } from 'element-plus'
const isNotEmpty = (rule, value, callback) => {
if (value.length > 0) return callback()
return callback(new Error('不能为空'))
}
const dataMap = reactive({
operList: [
{ label: '大于', value: 1 },
{ label: '等于', value: 2 },
{ label: '小于', value: 3 },
{ label: '介于', value: 4 }
],
dataList: [
{
label: '操作类型',
field: 'operType',
type: 'select',
valueList: [
{ label: '操作类型-1', value: 1 },
{ label: '操作类型-2', value: 2 },
{ label: '操作类型-3', value: 3 },
{ label: '操作类型-4', value: 4 },
{ label: '操作类型-5', value: 5 }
]
},
{
label: '文件类型',
field: 'fileType',
type: 'select',
valueList: [
{ label: '文件类型-1', value: 1 },
{ label: '文件类型-2', value: 2 },
{ label: '文件类型-3', value: 3 },
{ label: '文件类型-4', value: 4 },
{ label: '文件类型-5', value: 5 }
]
},
{
label: '磁盘类型',
field: 'diskType',
type: 'select',
valueList: [
{ label: '磁盘类型-1', value: 1 },
{ label: '磁盘类型-2', value: 2 },
{ label: '磁盘类型-3', value: 3 },
{ label: '磁盘类型-4', value: 4 },
{ label: '磁盘类型-5', value: 5 }
]
},
{
label: '用户行为',
field: 'userBehavior',
type: 'select',
valueList: [
{ label: '用户行为-1', value: 1 },
{ label: '用户行为-2', value: 2 },
{ label: '用户行为-3', value: 3 },
{ label: '用户行为-4', value: 4 },
{ label: '用户行为-5', value: 5 }
]
},
{
label: '文件大小',
field: 'fileSize',
type: 'input'
}
]
})
const ruleTreeRef = ref(null)
const rules = ref({
userBehavior: [
{ validator: isNotEmpty, trigger: 'change' }
],
fileSize: [
{ validator: isNotEmpty, trigger: 'blur' }
],
operType: [
{
validator: (rule, value, callback) => {
if (value.length <= 1) callback(new Error('长度需要大于 1'))
return callback()
},
trigger: 'blur'
}
]
})
const formData = ref({})
const validate = async () => {
ruleTreeRef.value.validate((valid) => {
if (valid) return ElMessage.success('校验通过')
return ElMessage.error('校验失败')
})
}
</script>
<style lang="scss" scoped></style>