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

12,836 阅读19分钟

一、前言

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

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

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

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

2.png

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

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

效果图:

1.jpg

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

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

vue3 实现的效果图:

动画.gif

二、主要功能

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

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

三、设计思路

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 是支持什么形式的输入(inputselect

前面俩个问题还好,主要是下面这个问题:

  • 没有字段可以判断该条件是否为 叶子节点,只能通过判断 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-itemerror 进行显示

<!-- ruleItem.vue -->
<el-form-item :error="rule.error" :prop="rule.filter_type">
<!-- 内容的显示 -->
</el-form-item>

但后面发现了几个问题😒:

  • 必须手动去触发校验,没法通过 trigger 的形式使内部表单元素自动触发,这就使得跟其他表单组件有点出入
  • 污染数据,需要在 data 注入 error 变量(用于标记校验信息)

到了 vue3 这里,就没有使用上面的那种方式,而是配合 el-formel-form-item 进行校验。

这里最大的问题:该组件是属于递归组件,如何通知到最底层的组件进行表单校验也是试了好多方法,一开始走了好多弯路。

后面接着细说。。。。

3.4、对节点的增加和删除

这里最大的问题:除了上面提到的事件的传递外,还有如何确定节点的位置。

后面接着细说。。。。

四、开发过程

4.1、数据驱动页面显示

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

1.png

✍️ 代码实现

// 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、如何确定 ruleNoderuleTree 的位置

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

1.png

这里通过三个参数来确定 runleNoderuleTree 的位置:

  • 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>

1.png

4.3、如何进行增删

上面已经确定好了三个参数,这里需要在 ruleItem 层级将事件进行抛出。现在问题是要抛到那一层,一开始是抛到了 ruleTree 层。

后面发现在 ruleTree 修改数据结构时(增加、删除节点),由于 上层 index(组件的入口文件)并没有进行双向数据绑定,导致数据没有同步到该层级。

<template>
    <RuleTree :formData="form" />
    <el-button size="small" @click="addRuleByTree" icon="Plus"></el-button>
</template>

因此,就设计成了将事件抛到组件入口 index 层级。 ruleTreeruleItem 仅做数据层面的渲染以及事件的抛出。

这又有一个问题,由于上面事件抛出又多了加了一层:

  • 原本:孙(ruleItem) 抛给 子(ruleTree) 即可

  • 现在:孙(ruleItem) 经过 子(ruleTree),在抛给父(index)

vue2:使用的是事件总线的方式在 ruleItem 直接将参数(curDethindexdepth)抛给了index

之所以采用事件总线,一开始是使用 emit 一层层往外抛事件的,但在开发过程中发现,对于递归组件偶尔事件在 index 层级没有接收到,后面因为时间问题,也没有去细究就使用了事件总线的方式。

缺点:组件挂载后需要监听事件,销毁后需要注销事件

vue3:这里则使用的是依赖注入( provide, inject )的方式,在 ruleItem 通过 emits 事件抛给ruleTree,拿到参数后,在调用 inject 方法触发事件。

1.png

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

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

原本:

2.png

删除节点后:

2.png

删除后数据结构:

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

2.png

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

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

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

七、组件源码

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>