基于vue3实现一个多条件复合查询组件

3,238 阅读8分钟

前言:在写后台管理系统的时候,查询功能是一个十分常见的功能,像文本输入查询,下拉选项框查询,日期选项查询等。但如果查询条件一旦多了起来,那么界面会变臃肿且难看。所以就需要一个复合查询框。

问题

开发中有时候查询条件过多,类似于下图

image.png

我们会发现,单单是查询区域就几乎占据了一半的界面,一般这样数据展示界面,我们需要给表格更多的区域,让用户一次查看到更多数据,而且因为查询的组件不同,例如input属性里面的texttextarea所占据的区域又会不一样,导致查询区域的样式变动复杂。

解决办法

  1. 最简单粗暴就是给查询区域增加控制显隐按钮,在表格上方的操作栏增加多一个按钮(是否展示查询区域)。默认隐藏,这样页面一开始就会给表格足够高的高度,展示更多的数据。不过这种方法意义不大。
  2. 在侧边展示查询区域。并附带显隐功能,这样无论如何表格数据都是固定的高度,能够展示较多数据,需要查询的时候就显示查询区域。这种是比较常用的方法之一。

image.png image.png

3.今天主要说明的就是将查询条件符合在一个输入框。仿某通后台管理系统的查询功能

1674887194685.png

组件编写

界面编写

首先需要编写一个简单的下拉框组件,这里我就不一一细说,主要就是样式代码。

<template>
  <div class="vue-amazing-selector">
    <el-row :gutter="20">
      <el-col :span="24">
        <div
          :class="['m-select-wrap', { 'hover focus': !props.disabled, disabled: props.disabled }]"
          :style="`width: ${props.width}px;`"
          tabindex="0"
          @click="openSelect()"
        >
          <div
            :class="['u-select-input', { placeholder: !selectedName }]"
            :style="`line-height: ${props.height - 10}px;width: ${props.width - 37}px;min-height:${props.height}px`"
          >
            <span v-for="(item, index) in selectedName" :key="index">
              <el-tag
                v-if="item[1]"
                type="info"
                closable
                @close="tagClose(item, index)"
                style="padding: 0 10px; margin: 5px 5px"
              >
                {{ item[1] ? `${item[0]}:` : '' }}<span style="color: black">{{ item[1] ? item[1] : '' }}</span>
              </el-tag></span
            >
            <span v-if="!selectedName.length" class="u-select-input">{{ props.placeholder }}</span>
          </div>
          <svg
            @click="openSelect"
            :class="['triangle', { rotate: showOptions }]"
            viewBox="64 64 896 896"
            data-icon="down"
            aria-hidden="true"
            focusable="false"
            class=""
          >
            <path
              d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
            ></path>
          </svg>
        </div>
        <transition name="fade">
          <div
            v-show="showOptions"
            class="m-options-panel"
            @mouseleave="onLeave"
            :style="`line-height: ${props.height - 12}px; max-height: ${props.num * (props.height - 3)}px; width: ${props.width}px;`"
            style="min-height: 50px;"
          >
          </div>
        </transition>
      </el-col>
    </el-row>
  </div>
</template>

<script  setup>
import { ref, defineProps } from 'vue'

const props = defineProps({
  search: {
    //下拉框配置
    type: Array,
    default: () => []
  },
  searchOption: {
    //下拉框字典值
    type: Object,
    default: () => {}
  },
  placeholder: {
    // 下拉框默认文字
    type: String,
    default: '请选择'
  },
  disabled: {
    // 是否禁用下拉
    type: Boolean,
    default: false
  },
  width: {
    // 下拉框宽度
    type: Number,
    default: 400
  },
  height: {
    // 下拉框高度
    type: Number,
    default: 36
  },
  num: {
    // 下拉面板最多能展示的下拉项数,超过后滚动显示,修改样式之后值减半
    type: Number,
    default: 6
  }
})

const showOptions = ref(false)
const selectedName=ref([])

const openSelect = () => {
  showOptions.value = !showOptions.value
}
</script>

<style lang="scss" scoped>
input:focus {
  outline: none;
}
input,
p {
  margin: 0;
  padding: 0;
}
.searchLabel {
  // position: relative;
  // bottom: 10px;
  width: 100px;
  display: inline-block;
  line-height: 40px;
}
.vue-amazing-selector {
  position: relative;
  display: inline-block;
  font-size: 14px;
  font-weight: 400;
  color: rgba(0, 0, 0, 0.65);
  line-height: 0px;
}
// 渐变过渡效果
.fade-enter-active,
.fade-leave-active {
  transition: all 0.5s;
}
.fade-enter,
.fade-leave-to {
  opacity: 0;
  // transform: translateY(-6px); // 滑动变换
}
.m-select-wrap {
  position: relative;
  display: inline-block;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  .u-select-input {
    display: block;
    text-align: left;
    margin-left: 11px;
    margin-right: 24px;
    // overflow: hidden;
    // text-overflow: ellipsis;
    // white-space: nowrap;
  }
  .placeholder {
    color: #bfbfbf;
  }
  .triangle {
    position: absolute;
    top: 50%;
    right: 12px;
    width: 12px;
    height: 12px;
    fill: rgba(0, 0, 0, 0.25);
    transform: translateY(-50%);
    -webkit-transform: translateY(-50%);
    transition: all 0.3s ease-in-out;
    pointer-events: none;
  }
  .rotate {
    transform: translateY(-50%) rotate(180deg);
    -webkit-transform: translateY(-50%) rotate(180deg);
  }
}
.hover {
  // 悬浮时样式
  &:hover {
    border-color: #1890ff;
  }
}
.focus {
  // 激活时样式
  &:focus {
    // 需设置元素的tabindex属性
    border-color: #1890ff;
    box-shadow: 0 0 0 2px rgba(24, 144, 255, 20%);
  }
}
.disabled {
  // 下拉禁用样式
  color: rgba(0, 0, 0, 0.25);
  background: #f5f5f5;
  user-select: none;
  cursor: not-allowed;
}
.m-options-panel {
  position: absolute;
  z-index: 999;
  overflow: auto;
  background: #fff;
  padding: 4px 0;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 15%);
  width: 1000px;
  // margin-left: 100px;
  margin: 0;
  .u-option {
    // 下拉项默认样式
    text-align: left;
    position: relative;
    display: block;
    padding: 5px 12px;
    font-weight: 400;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    cursor: pointer;
    transition: background 0.3s ease;
  }
  .option-selected {
    // 被选中的下拉项样式
    font-weight: 600;
    background: #fafafa;
  }
  .option-hover {
    // 悬浮时的下拉项样式
    background: #e6f7ff;
  }
  .option-disabled {
    // 禁用某个下拉选项时的样式
    color: rgba(0, 0, 0, 0.25);
    user-select: none;
    cursor: not-allowed;
  }
}
</style>

image.png

大概界面就这样,有了基本下拉框的动画和hover颜色改变。接下来就是对下拉区域里面进行编写。

从上面要实现的图片看出组件是基本是两列来排布,有些是占据一整列,那么我们就可以通过用el-form来进行编写。

数据格式

编写的时候我们要思考,首先,el-form里面可能有select input date等,位置不明,组件类型不明,组件占据列数不明,所以我们需要进行动态配置。那么传递给下拉区域的参数格式可以是这样:

    /*
     label:展示的标签
     value:传递的值
     type:组件类型(input,textarea,select,date)
     multiple:当type=select时,true为多选,默认为false
     props:当type=select时,字典的属性配置,即options的props配置
     dateType:当type=date时,该属性决定日期选择框的类型(类型参考饿了么官网)
     format:当type=format时,决定日期时间的格式
    */
      search: [
        {
          label: "商家名称",
          value: "callingImGroupName",
          type: "input",
        },
        {
          label: "运单编号",
          value: "orderCode",
          type: "textarea",
        },
        {
          label: "转人工原因",
          value: "transferReason",
          type: "select",
          multiple:true
        },
        {
          label: "处置状态",
          value: "disposalStatus",
          type: "select",
          props:{
            label:'dictValue',
            value:'dictKey'
          }
        },
      ],

当我们决定好数据格式之后,其实组件编写就很清晰了。首先通过循环整个数据,根据类型来判断这个区域需要展示的组件是什么,然后根据其他属性配置是否有无,有则按照特定数据配置,无则依据组件默认值配置。

//在transition标签里面增加如下代码
          <div
            v-show="showOptions"
            class="m-options-panel"
            @mouseleave="onLeave"
            :style="`line-height: ${height - 12}px;width: ${width}px;`"
          >
            <el-form :model="formInline" inline label-width="120px">
              <template v-for="(item, index) in search">
                <el-form-item :key="index" :label="item.label">
                  <el-input
                    v-if="item.type === 'input'"
                    style="width: 350px"
                    size="mini"
                    clearable
                    v-model="formInline[item.value]"
                    :placeholder="`请输入${item.label}`"
                    //因为没有预先在data定义变量,需要利用$set来进行响应式
                    @input="inputChange($event, item.value)"
                  ></el-input>
                  <el-select
                    v-else-if="item.type === 'select'"
                    style="width: 350px"
                    size="mini"
                    v-model="formInline[item.value]"
                    :placeholder="`请选择${item.label}`"
                    filterable
                    clearable
                    :multiple="item.multiple"
                    :collapse-tags="item.multiple"
                    @change="
                      handleChange($event, item.value, 'select', item.props)
                    "
                  >
                    <el-option
                      v-for="op in searchOption[item.value]"
                      :key="op.key"
                      :label="item.props ? op[item.props.label] : op.label"
                      :value="item.props ? op[item.props.value] : op.value"
                    >
                    </el-option>
                  </el-select>
                  <el-date-picker
                    v-else-if="item.type === 'date'"
                    size="mini"
                    style="width: 350px"
                    v-model="formInline[item.value]"
                    :type="item.dateType || 'datetimerange'"
                    :value-format="item.format || 'yyyy-MM-dd HH:mm:ss'"
                    :picker-options="pickerOptions"
                    range-separator="至"
                    start-placeholder="开始时间"
                    end-placeholder="结束结束"
                    :default-time="['00:00:00', '23:59:59']"
                    align="right"
                    @change="handleChange($event, item.value, 'date')"
                  >
                  </el-date-picker>
                </el-form-item>
              </template>
              <el-form-item label="" style="width: 600px; padding-left: 120px">
                <el-button
                  type="primary"
                  size="mini"
                  icon="el-icon-search"
                  @click="handleSearch"
                  >查询</el-button
                >
                <el-button
                  icon="el-icon-refresh"
                  type="primary"
                  size="mini"
                  @click="handleRest"
                  >重置</el-button
                >
              </el-form-item>
            </el-form>
          </div>
//父组件
<template>
  <searchVue :search="search" :searchOption="searchOption" :width="1000" :height="30"></searchVue>
</template>

<script setup>
import searchVue from './search.vue'

import { ref } from 'vue'

//下拉框字典值
const searchOption = ref({})
const search = ref([
  {
    label: '商家名称',
    value: 'callingImGroupName',
    type: 'input'
  },
  {
    label: '运单编号',
    value: 'orderCode',
    type: 'input'
  },
  {
    label: '转人工原因',
    value: 'transferReason',
    type: 'select',
    multiple: true,
    props: {
      label: 'dictValue',
      value: 'dictKey'
    }
  },
  {
    label: '处置状态',
    value: 'disposalStatus',
    type: 'select',
    props: {
      label: 'dictValue',
      value: 'dictKey'
    }
  },
  {
    label: '高价值',
    value: 'ifValuable',
    type: 'select'
  },
  {
    label: '问题类型',
    value: 'processType',
    type: 'select',
    props: {
      label: 'dictValue',
      value: 'dictKey'
    }
  },
  {
    label: '创建人',
    value: 'createUser',
    type: 'select',
    props: {
      label: 'name',
      value: 'roleId'
    }
  },
  {
    label: '分配',
    value: 'assignedUser',
    type: 'select'
  },
  {
    label: '受理时间',
    value: 'accept',
    type: 'date'
  },
  {
    label: '修改时间',
    value: 'modification',
    type: 'date'
  },
  {
    label: '综合搜索',
    value: 'all',
    type: 'input'
  }
])
</script>

image.png 我们可以看到界面基本完成,然后接下来需要做的就是数据的显示,在显示的input区域里,我们需要展示的类似于标签的样式来进行分组。首先我们把选项的数据模拟出来再进行展示。

//下拉框字典值
const searchOption = ref({
    "processType": [
        {
            "id": "17",
            "dictKey": "ChangeAdd",
            "dictValue": "改地址",
        },
        {
            "id": "18",
            "dictKey": "ChangeNum",
            "dictValue": "改电话",
        },
        {
            "id": "19",
            "dictKey": "Push",
            "dictValue": "催件",
        },
        {
            "id": "20",
            "dictKey": "CheckWgt",
            "dictValue": "查重量",
        },
        {
            "id": "21",
            "dictKey": "ReturnNwk",
            "dictValue": "退回网点",
        },
        {
            "id": "22",
            "dictKey": "ReturnAdder",
            "dictValue": "退回寄件人",
        },
        {
            "id": "23",
            "dictKey": "NotRev",
            "dictValue": "签收未收到",
        },
        {
            "id": "51",
            "dictKey": "receiveException",
            "dictValue": "收件异常",
        }
    ],
    "disposalStatus": [
        {
            "id": "305036671949238273",
            "dictKey": "undo",
            "dictValue": "未处理",
        },
        {
            "id": "1605165908435333122",
            "dictKey": "autoFollow",
            "dictValue": "自动化跟进中",
        },
        {
            "id": "1585911111626715137",
            "dictKey": "autoDone",
            "dictValue": "自动化已完成",
        },
        {
            "id": "1585911043695767554",
            "dictKey": "toAgent",
            "dictValue": "转人工",
        },
        {
            "id": "305036671949238274",
            "dictKey": "doing",
            "dictValue": "处理中",
        },
        {
            "id": "305036671949238275",
            "dictKey": "done",
            "dictValue": "处理完成",
        },
        {
            "id": "1585911151329996801",
            "dictKey": "toBeVerify",
            "dictValue": "待核实",
        },
        {
            "id": "305036671949238276",
            "dictKey": "vitiation",
            "dictValue": "无效",
        }
    ],
    "createUser": [
        {
            "id": "1493460106072633346",
            "name": "admin",
            "roleId": "1510142099646943254",
        },
        {
            "id": "1511520904500318209",
            "name": "测试管理者",
            "roleId": "1510142099646943234",
        },
        {
            "id": "1512041404334268418",
            "name": "dizzy",
            "roleId": "1123598816738675201",
        },
        {
            "id": "1512324249559080962",
            "name": "read",
            "roleId": "1493465905587105794",
        }
    ],
    "transferReason": [
        {
            "id": "303903700819144705",
            "dictKey": "SystemProcessingFailed",
            "dictValue": "系统处理失败",
        },
        {
            "id": "1585904155021209601",
            "dictKey": "ManualTransfer",
            "dictValue": "座席手动转人工",
        },
        {
            "id": "1585904596824027137",
            "dictKey": "QuestionTypeChanged",
            "dictValue": "二次咨询类型变更转人工",
        },
        {
            "id": "1585907252586016769",
            "dictKey": "LoginFailed",
            "dictValue": "账号登录失败转人工",
        },
    ]
})

逻辑处理

在展示之前我们需要知道查询条件的数据格式是如何的,所以现在查询按钮增加一个打印事件。

/**
 * 搜索框查询事件
 */
const handleSearch=()=>{
    console.log(formInline.value)
}

image.png

点击查询之后得到数据格式可知,如果是传递给后端当做查询条件是没有问题的,但是我们需要展示在界面上,key的值需要转换为中文才可。所以我们需要处理以下有关选项框的值。

/**
 * 下拉框选项change事件
 * @param value 源事件,当前选中的值
 * @param key 用于查找该下拉框所用是哪个字典对象
 * @param type 下拉框的类型
 * @param config 下拉框props的配置,有些不是默认的label和value,在label和value替换时用到
 */
const handleChange = (value, key, type, config) => {
  //不可总体拷贝,否则修改完的值会在下一次change事件触发又变为key值
  JSON.stringify(showForm.value) === '{}'
    ? (showForm.value = JSON.parse(JSON.stringify(formInline.value)))
    : (showForm.value[key] = JSON.parse(JSON.stringify(formInline.value[key])))
  //如果是下拉选择框,将单选和多选都转换为数组形式进行处理
  if (type === 'select') {
    Array.isArray(showForm.value[key]) ? null : (showForm.value[key] = showForm.value[key].split())
    showForm.value[key].map((item, index) => {
      props.searchOption[key].map((x) => {
        if (x[config ? config.value : 'value'] == item) {
          // showForm.value[key][index] = x.dictValue;
          showForm.value[key].splice(index, 1, x[config ? config.label : 'label'])
        }
      })
    })
  }
  //如果是日期时间框,进行处理,有些接口参数需要处理可在下面函数里面进行处理
  if (type === 'date') {
  }
  console.log('showForm',showForm.value)
}

image.png

由上图可知,我们将value值转换为了中文,那么剩下就是将对应的key值转换为中文,然后就进行显示即可。因为每次选择或者输入我们都要进行处理,所以我们需要使用watch监听showForm这个对象,这个对象是用于展示,showForm改变时进行key的替换操作。

watch(
  () => showForm,
  () => {
    let objArr = Object.entries(showForm.value)
    objArr.map((x) => {
      props.search.map((item) => {
        if (item.value == x[0]) {
          x[0] = item.label
        }
      })
      if (Array.isArray(x[1])) {
        // console.log('x[1]',x[1])
        x[1] = x[1].join(',')
      }
    })
    selectedName.value = objArr
    console.log('selectedName', selectedName.value)
  },
//   需要深度监听
  { deep: true }
)

image.png

这时候界面展示成功了一半,接下来就是文本输入框的处理,这里就是简单赋值操作。

const inputChange = (e, key) => {
  showForm.value[key] = e
}

image.png 现在展示效果就完成一大半了,接下来要注意到,在下拉区域里面删除的话,展示区域会跟着不见,但是展示区域的标签关闭却没效果,所以我们需要让二者联动起来。在el-tag标签的close事件中编写。

/**
 * 标签关闭事件
 */
const tagClose = (item, index) => {
  selectedName.value.splice(index, 1)
  let key = Object.keys(formInline.value)[index]
  delete showForm.value[key]
  delete formInline.value[key]
}

最后我们需要考虑当鼠标焦点不在查询区域的时候,查询区域应该收起来,这里一开始考虑到了在body添加事件进行监听,然后阻止冒泡,最后发现不太行。后来想着利用ref来通过父组件控制子组件属性来操作,但是感觉过于麻烦。最后在全局添加监听事件通过判断class类名来进行控制显隐。

  mounted() {
    document.addEventListener("click", (e) => {
      let arr = ['u-select-input','el-input__inner','el-form el-form--inline','el-form-item']
      if (arr.indexOf(e.target.className)===-1) {
        this.showOptions = false;
      }
    });
  },

最终效果如下图,基本这样算满足要求,如果有更多的业务需求可以以此为基础再修改。

20230128-164750.gif

最后附带上完整script代码

//search.vue
<script setup>
import { ref, defineProps, watch } from 'vue'

const props = defineProps({
  search: {
    //下拉框配置
    type: Array,
    default: () => []
  },
  searchOption: {
    //下拉框字典值
    type: Object,
    default: () => {}
  },
  placeholder: {
    // 下拉框默认文字
    type: String,
    default: '请选择'
  },
  disabled: {
    // 是否禁用下拉
    type: Boolean,
    default: false
  },
  width: {
    // 下拉框宽度
    type: Number,
    default: 400
  },
  height: {
    // 下拉框高度
    type: Number,
    default: 36
  },
  num: {
    // 下拉面板最多能展示的下拉项数,超过后滚动显示,修改样式之后值减半
    type: Number,
    default: 6
  }
})

const showOptions = ref(false)
const selectedName = ref([])
const formInline = ref({})
const showForm = ref({})

watch(
  () => showForm,
  () => {
    let objArr = Object.entries(showForm.value)
    objArr.map((x) => {
      props.search.map((item) => {
        if (item.value == x[0]) {
          x[0] = item.label
        }
      })
      if (Array.isArray(x[1])) {
        // console.log('x[1]',x[1])
        x[1] = x[1].join(',')
      }
    })
    selectedName.value = objArr
    console.log('selectedName', selectedName.value)
  },
  //   需要深度监听
  { deep: true }
)

const openSelect = () => {
  showOptions.value = !showOptions.value
}

/**
 * 搜索框查询事件
 */
const handleSearch = () => {
  console.log(formInline.value)
}

/**
 * 下拉框选项change事件
 * @param value 源事件,当前选中的值
 * @param key 用于查找该下拉框所用是哪个字典对象
 * @param type 下拉框的类型
 * @param config 下拉框props的配置,有些不是默认的label和value,在label和value替换时用到
 */
const handleChange = (value, key, type, config) => {
  //不可总体拷贝,否则修改完的值会在下一次change事件触发又变为key值
  //vue2需要使用到$set,如果是空对象则深拷贝formInline,如果不是,则判断showForm是否有这个属性值,如果有则重新赋值,如果没有就用$set进行动态赋值
  //   JSON.stringify(showForm.value) === '{}'
  //     ? (showForm.value = JSON.parse(JSON.stringify(formInline.value)))
  //     : showForm.value.hasOwnProperty(key)
  //     ? (showForm.value[key] = JSON.parse(JSON.stringify(formInline.value[key])))
  //     : this.$set(showForm.value, key, formInline.value[key])
  JSON.stringify(showForm.value) === '{}'
    ? (showForm.value = JSON.parse(JSON.stringify(formInline.value)))
    : (showForm.value[key] = JSON.parse(JSON.stringify(formInline.value[key])))
  //如果是下拉选择框,将单选和多选都转换为数组形式进行处理
  if (type === 'select') {
    Array.isArray(showForm.value[key]) ? null : (showForm.value[key] = showForm.value[key].split())
    showForm.value[key].map((item, index) => {
      props.searchOption[key].map((x) => {
        if (x[config ? config.value : 'value'] == item) {
          // showForm.value[key][index] = x.dictValue;
          showForm.value[key].splice(index, 1, x[config ? config.label : 'label'])
        }
      })
    })
  }
  //如果是日期时间框,进行处理,有些接口参数需要处理可在下面函数里面进行处理
  if (type === 'date') {
  }
  console.log('showForm', showForm.value)
}

const inputChange = (e, key) => {
  showForm.value[key] = e
}

/**
 * 标签关闭事件
 */
const tagClose = (item, index) => {
  selectedName.value.splice(index, 1)
  let key = Object.keys(formInline.value)[index]
  delete showForm.value[key]
  delete formInline.value[key]
}
</script>

总结:关于多种条件的查询组件编写就到这了,因为是小白,所以请勿要求我的代码质量,希望各位大佬多多给点意见。如果这篇文章能帮助到你,请给小弟点个赞(* ̄︶ ̄)。