QueryBuilder 搜索栏组件
概述
DpQueryBuilder 是一个基于 Vue 2 + Element UI 的通用搜索栏组件,支持多种表单控件类型,封装展开/收起、查询、重置功能,可通过 JSON 配置快速生成复杂的搜索表单满足大部分场景的搜索需求,亦可通过插槽实现自定义搜索项组件的插入。
功能演示
基本用法
::: 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 - 选项数组,每个选项包含label
和value
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
: 当前配置项对象
布局特性
- 5列网格布局:组件采用5列网格布局
- 智能展开:当搜索项超过4个时,自动显示"更多"按钮
- 动态按钮定位:未展开时:按钮在第一行最后一列;展开后:按钮在最后一行最后一列
数据格式
输入数据格式
- 普通类型(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'
})
注意事项
- 默认值类型:确保
default
值的类型与表单项类型匹配 - 范围类型:range 和 dateRange 类型的默认值必须是数组格式
- 插槽命名:自定义插槽的名称必须与配置项的
prop
值一致 - 选项格式:select 类型的 options 数组中每个对象必须包含
label
和value
属性
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>