我很好奇客户会用得懂这个组件吗?
前言
我们是公司是搞内网安全的,会收集日常电脑操作行为数据。基于前期设置的预警策略,对收集到的数据进行汇总处理分析形成报表。
因此经常要对数据进行分析,会涉及到对多数据字段进行过滤处理。前期界面上基本都是多个单一字段做过滤处理,并没有考虑多字段做「关系运算」后在做过滤处理。
由此产品经理设计了下面的组件,我也不知道这个组件叫啥名🤣,暂且称它为 「条件过滤树」
由于涉及到产品设计,原型图不方便贴出,但该组件是参考 神策数据中的某个组件,大家可以去那里看看。
一开始拿到手的时候,想着看看有没有写好的轮子,有找到但技术栈不适合,没办法只能硬写了🤣。
公司用的技术栈是 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 进行修改....// 重新赋值给 formthis.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 } = paramslet data = cloneDeep(props.form)const isAddLayer = depth === 0let customRule = ruleNode.valueif (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 = 1obj.next[i].is_leaf = falseobj.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 } = paramslet 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 = trueobj.data = obj.next[0].dataobj.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 } = paramsconst 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.dataMapconst { valueList = [] } = dataListif (dataList.length > 0 && operList.length > 0) {rule.data.filter_type = dataList[0].fieldrule.data.opr_type = operList[0].valuerule.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.valueif (typeof props.addRules === 'function') {const res = props.addRules()if (res) rule = res}if (isInit) { // 未初始化const form = {}form.is_leaf = trueform.data = rule.dataform.next = []data = form} else {const len = data.next.lengthdata.is_leaf = falseif (len === 0) {data.relation = 1data.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 } = paramslet data = cloneDeep(props.form)const isAddLayer = depth === 0let customRule = ruleNode.valueif (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 = 1obj.next[i].is_leaf = falseobj.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 } = paramslet 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 = trueobj.data = obj.next[0].dataobj.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 } = paramsconst 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.operListconst labelList = dataMap.dataList.map(item => {return {label: item.label,field: item.field}})const dataList = dataMap.dataList.reduce((acc, cur) => {acc[cur.field] = curreturn 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>