稀土掘金技术社区

我很好奇客户会用得懂这个组件吗?

前言

我们是公司是搞内网安全的,会收集日常电脑操作行为数据。基于前期设置的预警策略,对收集到的数据进行汇总处理分析形成报表。

因此经常要对数据进行分析,会涉及到对多数据字段进行过滤处理。前期界面上基本都是多个单一字段做过滤处理,并没有考虑多字段做「关系运算」后在做过滤处理。

由此产品经理设计了下面的组件,我也不知道这个组件叫啥名🤣,暂且称它为 「条件过滤树」

由于涉及到产品设计,原型图不方便贴出,但该组件是参考 神策数据中的某个组件,大家可以去那里看看。

Image

一开始拿到手的时候,想着看看有没有写好的轮子,有找到但技术栈不适合,没办法只能硬写了🤣。

公司用的技术栈是 vue2,写完感觉就是在堆屎🤣,开发时间不够,一天硬搓出来,很多细节也没有细究,也是一个小 demo。

效果图:

Image

后面比较闲,想着这个组件还是值去研究,去写好的。所以决定重新用 vue3(script setup)去重新封装(平时私下写 vue3 居多)。

顺便总结一下遇到问题,如有不对的地方或有更好的解决方案也请掘友们指出😁。

Image

vue3 实现的效果图:

Image

主要功能

「条件过滤树」 目前实现的功能点如下:

  • 实现 「增加节点」、「删除节点」、「编辑节点」
  • 实现 「表单校验」
  • 实现 「自定义节点内容」

设计思路

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、数据驱动页面显示

该部分主要解决的是能够根据数据在页面上能够展现出来。

Image

✍️ 「代码实现」

// 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」 的位置

目前该组件设计之初并没有考虑到可以套多层节点,仅设计了套俩层。(后续完善成通过配置进行处理)

Image

这里通过三个参数来确定 「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>

Image

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 方法触发事件。

Image

❝

事件抛出也可以绕过 「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)}

「注意」 删除节点后可能会出现下面这种情况:

原本:

Image

删除节点后:

Image

删除后数据结构:

{    "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 只有一个。

Image

这里觉得不是很好,因此这里删除节点后,又对数据结构的层级做了格式化的操作

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 有点类似)修改其相对位置(后续看看可行性)

针对第二点:这里请教一下掘友有没有好用的拖拽插件可以使用。

Image

组件源码

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>