基于vue2的搜索栏组件

47 阅读9分钟

QueryBuilder 搜索栏组件

概述

DpQueryBuilder 是一个基于 Vue 2 + Element UI 的通用搜索栏组件,支持多种表单控件类型,封装展开/收起、查询、重置功能,可通过 JSON 配置快速生成复杂的搜索表单满足大部分场景的搜索需求,亦可通过插槽实现自定义搜索项组件的插入。

功能演示

image.png

image.png

基本用法

::: demo

<template>
  <div>
    <DpQueryBuilder
      :config="queryConfig"
      @search="handleSearch"
      @reset="handleReset"
    >
      <!-- 自定义插槽 -->
      <template #customStatus="{ value, setValue }">
        <el-radio-group :value="value" @input="setValue">
          <el-radio label="active">启用</el-radio>
          <el-radio label="inactive">禁用</el-radio>
        </el-radio-group>
      </template>
    </DpQueryBuilder>
  </div>
</template>

<script>
export default {
  data() {
    return {
      queryConfig: [
        {
          label: '关键词查询',
          prop: 'keyword',
          type: 'input',
          placeholder: '请输入关键词,如合同号、客户名称等',
          default: '',
        },
        {
          label: '客户方',
          prop: 'customer',
          type: 'input',
          default: '',
        },
        {
          label: '业务员',
          prop: 'salesperson',
          type: 'select',
          options: [
            { label: '张三', value: 'zhangsan' },
            { label: '李四', value: 'lisi' },
            { label: '王五', value: 'wangwu' },
          ],
          default: '',
        },
        {
          label: '业务员2',
          prop: 'salesperson2',
          type: 'select',
          multiple: true,
          collapseTags: false, // 是否折叠标签
          options: [
            { label: '张三', value: 'zhangsan' },
            { label: '李四', value: 'lisi' },
            { label: '王五', value: 'wangwu' },
          ],
          default: '',
        },
        {
          label: '签订日期',
          prop: 'signDate',
          type: 'dateRange',
          startPlaceholder: '开始日期',
          endPlaceholder: '结束日期',
          default: [new Date(), new Date()],
        },
        {
          label: '合同总额(万元)',
          prop: 'contractAmount',
          type: 'range',
          startPlaceholder: '最小金额',
          endPlaceholder: '最大金额',
          advanced: true,
          default: [1, 11],
        },
        {
          label: '已收保费',
          prop: 'receivedPremium',
          type: 'range',
          default: ['', ''],
        },
        {
          label: '水收保费',
          prop: 'waterPremium',
          type: 'range',
          default: ['', ''],
        },
        {
          label: '已付承保',
          prop: 'paidUnderwriting',
          type: 'range',
          default: ['', ''],
        },
        {
          label: '领证平台日期',
          prop: 'platformDate',
          type: 'dateRange',
          startPlaceholder: '开始日期',
          endPlaceholder: '结束日期',
          default: [],
        },
        {
          label: '状态',
          prop: 'customStatus',
          type: 'slot',
          default: 'inactive',
        },
      ],
    }
  },
  methods: {
    handleSearch(searchData) {
      console.log('搜索参数:', searchData)
    },
    handleReset(searchData) {
      console.log('表单已重置')
      console.log('搜索参数:', searchData)
    }
  }
}
</script>

:::

Props 属性

::: propTable

| 属性名 | 类型 | 默认值 | 说明
| config | Array | [] | 表单配置数组,必填

:::

Events 事件

::: propTable

| 事件名 | 参数 | 说明
| search | searchData | 点击查询按钮时触发,返回格式化后的表单数据
| reset | searchData | 点击重置按钮时触发,返回格式化后的表单数据

:::

配置项说明

每个配置项支持以下属性:

基础属性

::: propTable

| 属性名 | 类型 | 必填 | 说明
| label | String | 是 | 表单项标签
| prop | String | 是 | 表单项属性名,用作 v-model 绑定
| type | String | 是 | 表单项类型,支持:input、select、range、dateRange、date、slot
| default | Any | 否 | 默认值
| placeholder | String | 否 | 占位符文本,不传则使用默认值“请输入XXX”

:::

类型特定属性

input 类型
{
  label: '关键词查询',
  prop: 'keyword',
  type: 'input',
  placeholder: '请输入关键词,例如哈哈哈',
  default: ''
}
select 类型
{
  label: '业务员',
  prop: 'salesperson',
  type: 'select',
  options: [
    { label: '张三', value: 'zhangsan' },
    { label: '李四', value: 'lisi' },
    { label: '王五', value: 'wangwu' }
  ],
  default: ''
}

额外属性:

  • options: Array - 选项数组,每个选项包含 labelvalue
range 类型(数值范围)
{
  label: '合同总额',
  prop: 'contractAmount',
  type: 'range',
  startPlaceholder: '最小金额',
  endPlaceholder: '最大金额',
  default: [11, 11]  // 或 ['', '']
}

额外属性:

  • startPlaceholder: String - 起始输入框占位符,不传则使用默认值“请输入”
  • endPlaceholder: String - 结束输入框占位符,不传则使用默认值“请输入”
  • default: Array - 默认值数组 [起始值, 结束值]
dateRange 类型(日期范围)
{
  label: '签订日期',
  prop: 'signDate',
  type: 'dateRange',
  startPlaceholder: '开始日期',
  endPlaceholder: '结束日期',
  format: 'yyyy-MM-dd',
  valueFormat: 'yyyy-MM-dd',
  default: [new Date(), new Date()]  // 或 []
}

额外属性:

  • startPlaceholder: String - 开始日期占位符
  • endPlaceholder: String - 结束日期占位符
  • format: String - 显示格式,默认 'yyyy-MM-dd'
  • valueFormat: String - 值格式,默认 'yyyy-MM-dd'
date 类型(单个日期)
{
  label: '创建日期',
  prop: 'createDate',
  type: 'date',
  placeholder: '请选择日期',
  format: 'yyyy-MM-dd',
  valueFormat: 'yyyy-MM-dd',
  default: ''
}
slot 类型(自定义插槽)
{
  label: '状态',
  prop: 'customStatus',
  type: 'slot',
  default: 'inactive'
}

使用插槽时需要在模板中定义对应的插槽:

<template #customStatus="{ value, setValue }">
  <el-radio-group :value="value" @input="setValue">
    <el-radio label="active">启用</el-radio>
    <el-radio label="inactive">禁用</el-radio>
  </el-radio-group>
</template>

插槽参数:

  • value: 当前值
  • setValue: 设置值的方法
  • item: 当前配置项对象

布局特性

  1. 5列网格布局:组件采用5列网格布局
  2. 智能展开:当搜索项超过4个时,自动显示"更多"按钮
  3. 动态按钮定位:未展开时:按钮在第一行最后一列;展开后:按钮在最后一行最后一列

数据格式

输入数据格式

  • 普通类型(input、select、date):直接值
  • 范围类型(range、dateRange):数组格式 [起始值, 结束值]

输出数据格式

组件会自动过滤空值,返回的数据格式:

{
  keyword: '搜索关键词',
  salesperson: 'zhangsan',
  contractAmount: [1000, 5000],  // 范围类型返回数组
  signDate: ['2023-01-01', '2023-12-31'],  // 日期范围返回数组
  customStatus: 'active'
}

方法

组件提供以下外部调用方法:

// 获取当前表单数据
const formData = this.$refs.queryBuilder.getFormData()

// 设置表单数据
this.$refs.queryBuilder.setFormData({
  keyword: '新关键词',
  salesperson: 'lisi'
})

注意事项

  1. 默认值类型:确保 default 值的类型与表单项类型匹配
  2. 范围类型:range 和 dateRange 类型的默认值必须是数组格式
  3. 插槽命名:自定义插槽的名称必须与配置项的 prop 值一致
  4. 选项格式:select 类型的 options 数组中每个对象必须包含 labelvalue 属性

DpQueryBuilder源码封装如下

`<!-- 一行5列布局 -->
<template>
  <div class="query-builder" :class="{ expanded: isExpanded }">
    <el-form
      ref="queryForm"
      :model="formData"
      size="small"
      class="query-form"
      label-position="top"
    >
      <div class="form-grid">
        <!-- 统一渲染所有搜索项 -->
        <div
          v-for="(item, index) in config"
          :key="index"
          class="form-item-wrapper"
          :class="getItemClass(index)"
        >
          <el-form-item
            :label="item.label"
            :prop="item.prop"
            class="query-form-item"
          >
            <!-- 普通输入框 -->
            <el-input
              v-if="item.type === 'input'"
              v-model="formData[item.prop]"
              :placeholder="item.placeholder || `请输入${item.label}`"
              clearable
            >
            </el-input>

            <!-- 下拉选择 -->
            <el-select
              v-else-if="item.type === 'select'"
              v-model="formData[item.prop]"
              :placeholder="item.placeholder || `请选择${item.label}`"
              clearable
              style="width: 100%"
            >
              <el-option
                v-for="option in item.options"
                :key="option.value"
                :label="option.label"
                :value="option.value"
              >
              </el-option>
            </el-select>

            <!-- 数值范围 -->
            <div v-else-if="item.type === 'range'" class="range-input">
              <el-input
                v-model.number="formData[item.prop][0]"
                :placeholder="item.startPlaceholder || '请输入'"
                clearable
              >
              </el-input>
              <span class="range-separator">To</span>
              <el-input
                v-model.number="formData[item.prop][1]"
                :placeholder="item.endPlaceholder || '请输入'"
                clearable
              >
              </el-input>
            </div>

            <!-- 日期范围 -->
            <el-date-picker
              v-else-if="item.type === 'dateRange'"
              v-model="formData[item.prop]"
              type="daterange"
              :start-placeholder="item.startPlaceholder || 'Start date'"
              :end-placeholder="item.endPlaceholder || 'End date'"
              :format="item.format || 'yyyy-MM-dd'"
              :value-format="item.valueFormat || 'yyyy-MM-dd'"
              style="width: 100%"
            >
            </el-date-picker>

            <!-- 日期选择 -->
            <el-date-picker
              v-else-if="item.type === 'date'"
              v-model="formData[item.prop]"
              type="date"
              :placeholder="item.placeholder || `请选择${item.label}`"
              :format="item.format || 'yyyy-MM-dd'"
              :value-format="item.valueFormat || 'yyyy-MM-dd'"
              style="width: 100%"
            >
            </el-date-picker>

            <!-- 自定义插槽 -->
            <slot
              v-else-if="item.type === 'slot'"
              :name="item.prop"
              :item="item"
              :value="formData[item.prop]"
              :setValue="(val) => setFormValue(item.prop, val)"
            >
            </slot>
          </el-form-item>
        </div>

        <!-- 操作按钮区域 -->
        <div class="button-wrapper" :class="getButtonClass()">
          <div class="query-buttons">
            <el-button size="mini" type="primary" @click="handleSearch" plain>
              查询
            </el-button>
            <el-button size="mini" type="warning" @click="handleReset" plain
              >重置</el-button
            >
            <!-- 更多/收起按钮 -->
            <el-button
              v-if="hasMoreItems"
              type="text"
              @click="toggleExpanded"
              class="more-btn"
            >
              {{ isExpanded ? '收起' : '更多' }}
              <i
                :class="isExpanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"
              ></i>
            </el-button>
          </div>
        </div>
      </div>
    </el-form>
  </div>
</template>

<script>
export default {
  name: 'DpQueryBuilder',
  props: {
    config: {
      type: Array,
      required: true,
      default: () => [],
    },
  },
  data() {
    return {
      formData: {},
      isExpanded: false,
    }
  },
  computed: {
    // 是否有超过4个搜索项
    hasMoreItems() {
      return this.config.length > 4
    },
  },
  watch: {
    config: {
      handler(newConfig) {
        // 深拷贝配置,避免直接修改原始配置
        // 这里使用 JSON 方法进行深拷贝,适用于简单对象
        // 注意:如果配置中有函数或特殊对象(日期对象会被格式化为字符串),这种方法可能不适用
        // 函数和 Symbol 属性都被忽略;
        // el-date-picker 支持传入字符串值
        const cloneConfig = JSON.parse(JSON.stringify(newConfig))
        this.initFormData(cloneConfig)
      },
      immediate: true,
      deep: true,
    },
  },
  methods: {
    // 获取表单项的CSS类
    getItemClass(index) {
      return {
        'first-row': index < 4,
        'extra-row': index >= 4,
      }
    },

    // 获取按钮区域的CSS类和样式
    getButtonClass() {
      return {
        'button-collapsed': !this.isExpanded,
        'button-expanded': this.isExpanded,
      }
    },

    // 初始化表单数据
    initFormData(config) {
      const formData = {}
      config.forEach((item) => {
        if (item.type === 'range') {
          formData[item.prop] = item.default || ['', '']
        } else if (item.type === 'dateRange') {
          formData[item.prop] = item.default || []
        } else {
          formData[item.prop] = item.default || ''
        }
      })
      this.formData = formData
    },

    // 设置表单值(用于插槽)
    setFormValue(prop, value) {
      this.$set(this.formData, prop, value)
    },

    // 切换展开/收起
    toggleExpanded() {
      this.isExpanded = !this.isExpanded
    },

    // 处理搜索
    handleSearch() {
      const searchData = this.formatSearchData()
      this.$emit('search', searchData)
    },

    // 处理重置
    handleReset() {
      this.initFormData(this.config)
      this.$refs.queryForm.resetFields()
      const searchData = this.formatSearchData()
      this.$emit('reset', searchData)
    },

    // 格式化搜索数据
    formatSearchData() {
      const result = {}
      Object.keys(this.formData).forEach((key) => {
        const value = this.formData[key]
        const configItem = this.config.find((item) => item.prop === key)
        if (configItem && configItem.type === 'range') {
          if (
            Array.isArray(value) &&
            value.filter((v) => v !== '').length > 0
          ) {
            result[key] = value
          }
        } else if (configItem && configItem.type === 'dateRange') {
          if (value && value.length === 2) {
            result[key] = value
          }
        } else {
          if (value !== '' && value !== null && value !== undefined) {
            result[key] = value
          }
        }
      })
      return result
    },

    // 获取表单数据(外部调用)
    getFormData() {
      return this.formatSearchData()
    },

    // 设置表单数据(外部调用)
    setFormData(data) {
      Object.keys(data).forEach((key) => {
        if (Object.prototype.hasOwnProperty.call(this.formData, key)) {
          this.$set(this.formData, key, data[key])
        }
      })
    },
  },
}
</script>

<style scoped>
.query-form {
  width: 100%;
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  row-gap: 16px;
  column-gap: 30px;
  align-items: end;
}

/* 第一行项目样式 */
.form-item-wrapper.first-row {
  display: block;
}

/* 额外行项目样式 - 默认隐藏 */
.form-item-wrapper.extra-row {
  display: none;
}

/* 展开状态下显示额外行 */
.query-builder.expanded .form-item-wrapper.extra-row {
  display: block;
}

/* 按钮区域基础样式 */
.button-wrapper {
  display: flex;
  align-items: end;
  justify-content: flex-end;
}

/* 未展开时:按钮在第一行第5列 */
.button-wrapper.button-collapsed {
  grid-column: 5;
  grid-row: 1;
}

/* 展开时:按钮自然排列到最后位置 */
.button-wrapper.button-expanded {
  /* 让按钮自然流动到最后位置 */
  grid-column: 5;
  order: 999;
}

.query-form-item {
  margin-bottom: 0;
  width: 100%;
}

.query-form-item .el-form-item__label {
  font-size: 12px;
  color: #606266;
  font-weight: normal;
  line-height: 28px;
  padding-bottom: 4px;
}

.query-form-item .el-form-item__content {
  line-height: 28px;
}

.range-input {
  display: flex;
  align-items: center;
  gap: 6px;
}

.range-input .el-input {
  flex: 1;
}

.range-separator {
  font-size: 12px;
  color: #909399;
  white-space: nowrap;
  padding: 0 2px;
}

.query-buttons {
  display: flex;
  align-items: center;
  /* gap: 8px; */
  padding-top: 28px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.more-btn {
  color: #409eff;
  font-size: 12px;
  padding: 0;
  margin-left: 8px;
}

.more-btn:hover {
  color: #66b1ff;
}

.more-btn i {
  margin-left: 2px;
  font-size: 10px;
}

/* Element UI 样式覆盖 */
.el-form--label-top ::v-deep .el-form-item__label {
  padding-bottom: 3px;
}
/* 响应式处理 */
@media (max-width: 1400px) {
  .form-grid {
    gap: 16px;
  }

  .range-input {
    gap: 4px;
  }
}

/* 小屏幕适配 */

@media (max-width: 768px) {
  .form-grid {
    grid-template-columns: 1fr;
    gap: 12px;
  }

  .button-wrapper.button-collapsed,
  .button-wrapper.button-expanded {
    grid-column: 1;
    justify-content: center;
  }

  .query-buttons {
    justify-content: center;
  }
}
</style>
`

提供另外一种样式的版本,功能一样

<template>
  <div class="query-builder" :class="{ expanded: isExpanded }">
    <el-form
      ref="queryForm"
      :model="formData"
      size="small"
      class="query-form"
      label-position="top"
    >
      <div class="form-grid" :class="getGridClass()">
        <!-- 统一渲染所有搜索项 -->
        <div
          v-for="(item, index) in config"
          :key="index"
          class="form-item-wrapper"
          :class="getItemClass(index)"
        >
          <el-form-item
            :label="item.label"
            :prop="item.prop"
            class="query-form-item"
          >
            <!-- 普通输入框 -->
            <el-input
              v-if="item.type === 'input'"
              v-model="formData[item.prop]"
              :placeholder="item.placeholder || `请输入${item.label}`"
              clearable
            >
            </el-input>

            <!-- 下拉选择 -->
            <el-select
              v-else-if="item.type === 'select'"
              v-model="formData[item.prop]"
              :placeholder="item.placeholder || `请选择${item.label}`"
              :multiple="item.multiple || false"
              :collapse-tags="item.multiple && item.collapseTags !== false"
              clearable
              style="width: 100%"
            >
              <el-option
                v-for="option in item.options"
                :key="option.value"
                :label="option.label"
                :value="option.value"
              >
              </el-option>
            </el-select>

            <!-- 数值范围 -->
            <div v-else-if="item.type === 'range'" class="range-input">
              <el-input
                v-model.number="formData[item.prop][0]"
                :placeholder="item.startPlaceholder || '请输入'"
                clearable
              >
              </el-input>
              <span class="range-separator">To</span>
              <el-input
                v-model.number="formData[item.prop][1]"
                :placeholder="item.endPlaceholder || '请输入'"
                clearable
              >
              </el-input>
            </div>

            <!-- 日期范围 -->
            <el-date-picker
              v-else-if="item.type === 'dateRange'"
              v-model="formData[item.prop]"
              type="daterange"
              :start-placeholder="item.startPlaceholder || 'Start date'"
              :end-placeholder="item.endPlaceholder || 'End date'"
              :format="item.format || 'yyyy-MM-dd'"
              :value-format="item.valueFormat || 'yyyy-MM-dd'"
              style="width: 100%"
            >
            </el-date-picker>

            <!-- 日期选择 -->
            <el-date-picker
              v-else-if="item.type === 'date'"
              v-model="formData[item.prop]"
              type="date"
              :placeholder="item.placeholder || `请选择${item.label}`"
              :format="item.format || 'yyyy-MM-dd'"
              :value-format="item.valueFormat || 'yyyy-MM-dd'"
              style="width: 100%"
            >
            </el-date-picker>

            <!-- 自定义插槽 -->
            <slot
              v-else-if="item.type === 'slot'"
              :name="item.prop"
              :item="item"
              :value="formData[item.prop]"
              :setValue="(val) => setFormValue(item.prop, val)"
            >
            </slot>
          </el-form-item>
        </div>

        <!-- 操作按钮区域 -->
        <div class="button-wrapper" :class="getButtonClass()">
          <div class="query-buttons">
            <!-- 更多/收起按钮 -->
            <el-button
              size="small"
              type="primary"
              v-if="hasMoreItems"
              @click="toggleExpanded"
              plain
            >
              {{ isExpanded ? '收起' : '展开' }}
            </el-button>
            <el-button size="small" type="primary" @click="handleSearch" plain>
              查询
            </el-button>
            <el-button size="small" @click="handleReset" plain>
              重置
            </el-button>
            <!-- 更多/收起按钮 ----文字和箭头样式 -->
            <!-- <el-button
              v-if="hasMoreItems"
              type="text"
              @click="toggleExpanded"
              class="more-btn"
            >
              {{ isExpanded ? '收起' : '更多' }}
              <i
                :class="isExpanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"
              ></i>
            </el-button> -->
          </div>
        </div>
      </div>
    </el-form>
  </div>
</template>

<script>
export default {
  name: 'DpQueryBuilder',
  props: {
    config: {
      type: Array,
      required: true,
      default: () => [],
    },
  },
  data() {
    return {
      formData: {},
      isExpanded: false,
    }
  },
  computed: {
    // 是否有超过5个搜索项(5列布局时)
    hasMoreItems() {
      return this.config.length > 5
    },

    // 是否使用单行布局(条件数 <= 5)
    isSingleRowLayout() {
      return this.config.length <= 5
    },

    // 计算按钮应该在的行数(展开状态下)
    buttonRowInExpanded() {
      if (!this.isExpanded || this.isSingleRowLayout) {
        return 1
      }
      // 计算总行数:每行4个搜索项
      return Math.ceil(this.config.length / 4)
    },
  },
  watch: {
    config: {
      handler(newConfig) {
        const cloneConfig = JSON.parse(JSON.stringify(newConfig))
        this.initFormData(cloneConfig)
      },
      immediate: true,
      deep: true,
    },
  },
  methods: {
    // 获取网格布局类
    getGridClass() {
      if (this.isSingleRowLayout) {
        // 单行布局:根据实际项目数量动态分列
        const totalItems = this.config.length + 1 // +1 是按钮
        return `single-row-${totalItems}`
      } else {
        // 多行布局:固定5列,但搜索项只用前4列
        return 'multi-row'
      }
    },

    // 获取表单项的CSS类
    getItemClass(index) {
      if (this.isSingleRowLayout) {
        // 单行布局:所有项目都显示
        return {
          'single-row-item': true,
        }
      } else {
        // 多行布局:前4个为第一行,其余为额外行
        return {
          'first-row': index < 4,
          'extra-row': index >= 4,
          'multi-row-item': true, // 限制只占用前4列
        }
      }
    },

    // 获取按钮区域的CSS类
    getButtonClass() {
      if (this.isSingleRowLayout) {
        return {
          'single-row-button': true,
        }
      } else {
        return {
          'button-collapsed': !this.isExpanded,
          'button-expanded': this.isExpanded,
        }
      }
    },

    // 初始化表单数据
    initFormData(config) {
      const formData = {}
      config.forEach((item) => {
        if (item.type === 'range') {
          formData[item.prop] = item.default || ['', '']
        } else if (item.type === 'dateRange') {
          formData[item.prop] = item.default || []
        } else if (item.type === 'select' && item.multiple) {
          // 多选select默认值为数组
          formData[item.prop] = item.default || []
        } else {
          formData[item.prop] = item.default || ''
        }
      })
      this.formData = formData
    },

    // 设置表单值(用于插槽)
    setFormValue(prop, value) {
      this.$set(this.formData, prop, value)
    },

    // 切换展开/收起
    toggleExpanded() {
      this.isExpanded = !this.isExpanded
    },

    // 处理搜索
    handleSearch() {
      const searchData = this.formatSearchData()
      this.$emit('search', searchData)
    },

    // 处理重置
    handleReset() {
      this.initFormData(this.config)
      this.$refs.queryForm.resetFields()
      const searchData = this.formatSearchData()
      this.$emit('reset', searchData)
    },

    // 格式化搜索数据
    formatSearchData() {
      const result = {}
      Object.keys(this.formData).forEach((key) => {
        const value = this.formData[key]
        const configItem = this.config.find((item) => item.prop === key)
        if (configItem && configItem.type === 'range') {
          if (
            Array.isArray(value) &&
            value.filter((v) => v !== '').length > 0
          ) {
            result[key] = value
          }
        } else if (configItem && configItem.type === 'dateRange') {
          if (value && value.length === 2) {
            result[key] = value
          }
        } else if (
          configItem &&
          configItem.type === 'select' &&
          configItem.multiple
        ) {
          // 处理多选select
          if (Array.isArray(value) && value.length > 0) {
            result[key] = value
          }
        } else {
          if (value !== '' && value !== null && value !== undefined) {
            result[key] = value
          }
        }
      })
      return result
    },

    // 获取表单数据(外部调用)
    getFormData() {
      return this.formatSearchData()
    },

    // 设置表单数据(外部调用)
    setFormData(data) {
      Object.keys(data).forEach((key) => {
        if (Object.prototype.hasOwnProperty.call(this.formData, key)) {
          this.$set(this.formData, key, data[key])
        }
      })
    },
  },
}
</script>

<style scoped>
.query-form {
  width: 100%;
}

.form-grid {
  display: grid;
  row-gap: 16px;
  column-gap: 20px;
  align-items: end;
}

/* 单行布局样式 */
.form-grid.single-row-2 {
  grid-template-columns: 1fr auto;
}

.form-grid.single-row-3 {
  grid-template-columns: 1fr 1fr auto;
}

.form-grid.single-row-4 {
  grid-template-columns: 1fr 1fr 1fr auto;
}

.form-grid.single-row-5 {
  grid-template-columns: 1fr 1fr 1fr 1fr auto;
}

.form-grid.single-row-6 {
  grid-template-columns: 1fr 1fr 1fr 1fr 1fr auto;
}

/* 多行布局样式 - 5列网格 */
.form-grid.multi-row {
  grid-template-columns: 1fr 1fr 1fr 1fr auto;
}

/* 单行布局项目样式 */
.form-item-wrapper.single-row-item {
  display: block;
}

/* 多行布局项目样式 - 限制搜索项只占用前4列 */
.form-item-wrapper.multi-row-item {
  /* 通过CSS确保搜索项不会占用第5列 */
  grid-column: auto / span 1;
}

/* 确保搜索项按4列排列 */
.form-item-wrapper.multi-row-item:nth-child(4n + 1) {
  grid-column: 1;
}

.form-item-wrapper.multi-row-item:nth-child(4n + 2) {
  grid-column: 2;
}

.form-item-wrapper.multi-row-item:nth-child(4n + 3) {
  grid-column: 3;
}

.form-item-wrapper.multi-row-item:nth-child(4n) {
  grid-column: 4;
}

.form-item-wrapper.first-row {
  display: block;
}

.form-item-wrapper.extra-row {
  display: none;
}

/* 展开状态下显示额外行 */
.query-builder.expanded .form-item-wrapper.extra-row {
  display: block;
}

/* 单行布局按钮样式 */
.button-wrapper.single-row-button {
  display: flex;
  align-items: end;
  justify-content: flex-end;
}

/* 多行布局按钮样式 */
.button-wrapper.button-collapsed {
  grid-column: 5;
  grid-row: 1;
  display: flex;
  align-items: end;
  justify-content: flex-end;
}

/* 展开状态下按钮在最后一行第5列 */
.button-wrapper.button-expanded {
  grid-column: 5;
  /* 动态计算按钮应该在的行 */
  order: 999;
  display: flex;
  align-items: end;
  justify-content: flex-end;
}

.query-form-item {
  margin-bottom: 0;
  width: 100%;
}

.range-input {
  display: flex;
  align-items: center;
  gap: 6px;
}

.range-input .el-input {
  flex: 1;
}

.range-separator {
  font-size: 12px;
  color: #909399;
  white-space: nowrap;
  padding: 0 2px;
}

.query-buttons {
  display: flex;
  align-items: center;
  padding-top: 28px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.more-btn {
  color: #409eff;
  font-size: 12px;
  padding: 0;
  margin-left: 8px;
}

.more-btn:hover {
  color: #66b1ff;
}

.more-btn i {
  margin-left: 2px;
  font-size: 10px;
}

/* Element UI 样式覆盖 */
.el-form--label-top ::v-deep .el-form-item__label {
  padding-bottom: 3px;
}

/* 响应式处理 */
@media (max-width: 1400px) {
  .form-grid {
    column-gap: 16px;
  }

  .range-input {
    gap: 4px;
  }
}

@media (max-width: 768px) {
  .form-grid.single-row-2,
  .form-grid.single-row-3,
  .form-grid.single-row-4,
  .form-grid.single-row-5,
  .form-grid.single-row-6,
  .form-grid.multi-row {
    grid-template-columns: 1fr;
    gap: 12px;
  }

  .button-wrapper.single-row-button,
  .button-wrapper.button-collapsed,
  .button-wrapper.button-expanded {
    grid-column: 1;
    justify-content: center;
  }

  /* 移动端取消多行布局的列限制 */
  .form-item-wrapper.multi-row-item:nth-child(4n + 1),
  .form-item-wrapper.multi-row-item:nth-child(4n + 2),
  .form-item-wrapper.multi-row-item:nth-child(4n + 3),
  .form-item-wrapper.multi-row-item:nth-child(4n) {
    grid-column: auto;
  }
}
</style>

另外提供一种数据驱动的封装方法,使用v-model+config

<template>
  <div class="query-builder" :class="{ expanded: isExpanded }">
    <el-form
      ref="queryForm"
      :model="formData"
      size="small"
      class="query-form"
      label-position="top"
    >
      <div class="form-grid" :class="getGridClass()">
        <!-- 统一渲染所有搜索项 -->
        <div
          v-for="(item, index) in config"
          :key="index"
          class="form-item-wrapper"
          :class="getItemClass(index)"
        >
          <el-form-item
            :label="item.label"
            :prop="item.prop"
            class="query-form-item"
          >
            <!-- 普通输入框 -->
            <el-input
              v-if="item.type === 'input'"
              v-model="formData[item.prop]"
              :placeholder="item.placeholder || `请输入${item.label}`"
              clearable
            >
            </el-input>

            <!-- 下拉选择 -->
            <el-select
              v-else-if="item.type === 'select'"
              v-model="formData[item.prop]"
              :placeholder="item.placeholder || `请选择${item.label}`"
              :multiple="item.multiple || false"
              :collapse-tags="item.multiple && item.collapseTags !== false"
              clearable
              style="width: 100%"
            >
              <el-option
                v-for="option in item.options"
                :key="option.value"
                :label="option.label"
                :value="option.value"
              >
              </el-option>
            </el-select>

            <!-- 数值范围 -->
            <div v-else-if="item.type === 'range'" class="range-input">
              <el-input
                v-model.number="formData[item.prop][0]"
                :placeholder="item.startPlaceholder || '请输入'"
                clearable
              >
              </el-input>
              <span class="range-separator">To</span>
              <el-input
                v-model.number="formData[item.prop][1]"
                :placeholder="item.endPlaceholder || '请输入'"
                clearable
              >
              </el-input>
            </div>

            <!-- 日期范围 -->
            <el-date-picker
              v-else-if="item.type === 'dateRange'"
              v-model="formData[item.prop]"
              type="daterange"
              :start-placeholder="item.startPlaceholder || '开始日期'"
              :end-placeholder="item.endPlaceholder || '结束日期'"
              :format="item.format || 'yyyy-MM-dd'"
              :value-format="item.valueFormat || 'yyyy-MM-dd'"
              style="width: 100%"
            >
            </el-date-picker>

            <!-- 开关 -->
            <el-switch
              v-else-if="item.type === 'switch'"
              v-model="formData[item.prop]"
              :active-text="item.activeText || '是'"
              :inactive-text="item.inactiveText || '否'"
            >
            </el-switch>

            <!-- 日期选择 -->
            <el-date-picker
              v-else-if="item.type === 'date'"
              v-model="formData[item.prop]"
              type="date"
              :placeholder="item.placeholder || `请选择${item.label}`"
              :format="item.format || 'yyyy-MM-dd'"
              :value-format="item.valueFormat || 'yyyy-MM-dd'"
              style="width: 100%"
            >
            </el-date-picker>

            <!-- 自定义插槽 -->
            <slot
              v-else-if="item.type === 'slot'"
              :name="item.prop"
              :item="item"
              :value="formData[item.prop]"
              :setValue="(val) => setFormValue(item.prop, val)"
            >
            </slot>
          </el-form-item>
        </div>

        <!-- 操作按钮区域 -->
        <div class="button-wrapper" :class="getButtonClass()">
          <div class="query-buttons">
            <!-- 更多/收起按钮 -->
            <el-button
              size="small"
              type="primary"
              v-if="hasMoreItems"
              @click="toggleExpanded"
              plain
            >
              {{ isExpanded ? '收起' : '展开' }}
            </el-button>
            <el-button size="small" type="primary" @click="handleSearch" plain>
              查询
            </el-button>
            <el-button size="small" @click="handleReset" plain>
              重置
            </el-button>
            <!-- 更多/收起按钮 ----文字和箭头样式 -->
            <!-- <el-button
              v-if="hasMoreItems"
              type="text"
              @click="toggleExpanded"
              class="more-btn"
            >
              {{ isExpanded ? '收起' : '更多' }}
              <i
                :class="isExpanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"
              ></i>
            </el-button> -->
          </div>
        </div>
      </div>
    </el-form>
  </div>
</template>

<script>
export default {
  name: 'QueryBuilder',
  props: {
    value: {
      type: Object,
      required: true,
      default: () => ({}),
    },
    config: {
      type: Array,
      required: true,
      default: () => [],
    },
  },
  data() {
    return {
      isExpanded: false,
      // 保存初始值用于重置
      initialFormData: JSON.parse(JSON.stringify(this.value)),
    }
  },
  computed: {
    // 是否有超过5个搜索项(5列布局时)
    hasMoreItems() {
      return this.config.length > 5
    },

    // 是否使用单行布局(条件数 <= 5)
    isSingleRowLayout() {
      return this.config.length <= 5
    },

    // 计算按钮应该在的行数(展开状态下)
    buttonRowInExpanded() {
      if (!this.isExpanded || this.isSingleRowLayout) {
        return 1
      }
      // 计算总行数:每行4个搜索项
      return Math.ceil(this.config.length / 4)
    },

    formData: {
      get() {
        return this.value
      },
      set(value) {
        this.$emit('input', value)
      },
    },
  },
  watch: {},
  methods: {
    // 获取网格布局类
    getGridClass() {
      if (this.isSingleRowLayout) {
        // 单行布局:根据实际项目数量动态分列
        const totalItems = this.config.length + 1 // +1 是按钮
        return `single-row-${totalItems}`
      } else {
        // 多行布局:固定5列,但搜索项只用前4列
        return 'multi-row'
      }
    },

    // 获取表单项的CSS类
    getItemClass(index) {
      if (this.isSingleRowLayout) {
        // 单行布局:所有项目都显示
        return {
          'single-row-item': true,
        }
      } else {
        // 多行布局:前4个为第一行,其余为额外行
        return {
          'first-row': index < 4,
          'extra-row': index >= 4,
          'multi-row-item': true, // 限制只占用前4列
        }
      }
    },

    // 获取按钮区域的CSS类
    getButtonClass() {
      if (this.isSingleRowLayout) {
        return {
          'single-row-button': true,
        }
      } else {
        return {
          'button-collapsed': !this.isExpanded,
          'button-expanded': this.isExpanded,
        }
      }
    },
    // 设置表单值(用于插槽)
    setFormValue(prop, value) {
      this.$set(this.formData, prop, value)
    },

    // 切换展开/收起
    toggleExpanded() {
      this.isExpanded = !this.isExpanded
    },

    // 处理搜索
    handleSearch() {
      this.$emit('search', this.formData)
    },

    // 处理重置
    handleReset() {
      this.formData = JSON.parse(JSON.stringify(this.initialFormData))
      this.$emit('reset', this.formData)
    },
  },
}
</script>