简介:一份基于Vue CLI构建的组合查询条件功能源码,面向前端开发者和Vue初学者,用于解决多字段筛选、条件联动与查询参数动态生成等常见业务需求。压缩包共32个文件,以14个js脚本和4个vue单文件组件为主体,辅以5个xml、2个json、1个html及babelrc、editorconfig等项目配置,整体仅139KB,结构紧凑便于快速阅读与复用。
源码覆盖Vue项目开发的多个核心环节:组件化拆分查询字段(输入框、下拉、日期选择)、通过v-model收集表单状态,借助计算属性与侦听器实时组合查询参数,并用Vuex集中管理过滤条件和结果;同时涉及axios请求封装、Vue Router页面导航以及CLI环境下的构建与调试。内容预览显示项目包含完整的src目录、router、store、build配置及静态资源,可直接作为功能模块迁移到后台管理或数据筛选页面。
资源已有1277人浏览学习,适合需要掌握组合查询实现思路、或希望基于现成模板快速搭建过滤表单的开发者参考。
1. 组合查询条件源码:把后台筛选从「写死表单」变成「配置驱动」
做后台管理的同学应该都有体会:每个页面的查询区长得差不多,但每换一个页面就得重新写一遍表单,改一个条件要动模板、调逻辑、对接口。这套 Vue 组合查询条件源码解决的就是这个重复劳动——把筛选条件抽象成配置数据,页面只维护一个条件数组,组件自动渲染出对应的输入控件,并在提交时统一输出结构化查询参数。适合订单管理、用户筛选、日志检索这类字段多、组合方式多变的场景。对一线开发来说,最大的收益不是少写几行代码,而是需求变动时只改配置不动页面结构,回归成本低一个量级。
2. 技术选型与结构拆解:Vue 3 组合式 API 与三个关键目录
2.1 为什么选 Vue 3 + Composition API
这套源码基于 Vue 3 实现,关键原因是组合查询的状态结构太适合用 Composition API 来组织。条件列表的增删、关系切换、值变更、结果序列化,如果用 Options API 写,data 里堆一堆状态、methods 里堆一堆方法、watch 里再挂几个监听,改一个逻辑要在三个区块之间来回跳。Composition API 可以把「条件列表管理」「运算符映射」「结果序列化」各自收敛成独立的函数,模块边界清晰,换到新项目里复用也方便。
第二个原因是 Vue 3 的响应式系统。组合查询的数据结构是「数组里套对象、对象里套数组」的多层嵌套,Vue 2 里改深层字段常常要 $set,写起来很别扭。Vue 3 基于 Proxy 的响应式代理能自动追踪深层属性,conditions.value[0].value = 'abc'这种操作天然响应。这在条件数量多、嵌套深的场景里省掉了很多心智负担。
组件本身用 script setup 语法,defineProps 和 defineEmits 声明接口后,TypeScript 的推导是完整的。这点对组合查询组件很关键,因为字段类型、运算符、值类型三者之间有约束关系,编译期就能挡住不少低级的类型错误。
2.2 目录结构拆解:components、types、utils 各司其职
先看整体目录,这套源码的结构不复杂,但分层清楚:
src/ ├── components/ │ └── query-builder/ │ ├── QueryBuilder.vue # 组件入口,负责条件列表渲染和对外通信 │ ├── ConditionRow.vue # 单行条件:字段选择、运算符、值输入 │ ├── conditions-map.ts # 条件类型到渲染组件和运算符的映射表 │ └── widgets/ │ ├── InputWidget.vue # 文本输入控件 │ ├── SelectWidget.vue # 下拉选择控件 │ ├── DateRangeWidget.vue # 日期范围控件 │ └── NumberRangeWidget.vue # 数字区间控件 ├── types/ │ └── query.ts # 字段、运算符、查询参数的 TypeScript 类型 ├── utils/ │ └── query-serialize.ts # 条件数组到查询参数的序列化工具 └── demo/ └── App.vue # 演示页面,跑起来直接看效果各目录的职责边界可以概括为一张表:
| 目录 | 职责 | 关键约束 |
|---|---|---|
| components/query-builder | 组件渲染与交互 | 不直接接触后端接口 |
| types/query.ts | 类型协议定义 | 前后端字段约定的唯一来源 |
| utils/query-serialize.ts | 纯函数序列化 | 不做 DOM 操作,可直接单测 |
这套结构里最需要理解的是 conditions-map.ts 这个映射表。它把「字段类型」翻译成「可用的运算符集合」和「对应的值控件」,是整个渲染引擎的数据中枢。widgets 目录里每个控件只做一件事:展示当前值、在用户操作后 emit 新值。控件之间不互相依赖,新增一个控件类型不会影响已有逻辑。
2.3 条件类型定义:先定义协议再写组件
组合查询的核心难点不在 UI,而在数据结构。我一般先把类型定义写清楚,再回头写组件,因为后面序列化和后端对接都是顺着类型走的事。看一下 types/query.ts 的核心部分:
// 字段元信息:页面告诉组件「有哪些字段可以查」 export interface QueryField { field: string // 字段名,对应后端接口的参数名 label: string // 显示名称,比如「订单号」「创建时间」 type: 'input' | 'select' | 'date-range' | 'number-range' options?: { label: string; value: string | number }[] // select 类型的选项 } // 一条具体的查询条件,组件内部用 export interface QueryCondition { id: string // 唯一 id,组件内部生成,用来定位和删除 field: string // 选中的字段 operator: string // 运算符:eq、gt、between、like 等 value: unknown // 条件值,类型由字段 type 决定 } // 提交给后端的最终查询参数 export interface QueryParams { logic: 'AND' | 'OR' // 条件关系 conditions: { field: string; operator: string; value: unknown }[] }这里有个容易忽视的设计:QueryCondition 带 id 而 QueryParams 不带。id 是组件内部管理用的——增删条件、做 diff 都要靠它;但提交给后端时要剥掉,不然后端会收到一堆业务无关的字段。剥壳动作放在序列化工具里做,组件层不接触这个逻辑。
运算符的映射关系也在这层定义。input 类型支持 eq 和 like,select 支持 eq 和 in,日期区间和数字区间支持 between,数字还能用 gt、lt。这是前后端约定的起点:后端 SQL 解析层按 operator 分发逻辑,新增运算符只需要两边同步加分支。
3. 核心模块拆解:一条查询条件从配置到出参的完整链路
3.1 条件渲染引擎:字段类型到控件的动态映射
查询区最核心的是渲染引擎。它的输入是 QueryField 配置列表和当前条件,输出是字段选择器、运算符选择器和值输入控件。映射表在 conditions-map.ts 里维护:
// conditions-map.ts import InputWidget from './widgets/InputWidget.vue' import SelectWidget from './widgets/SelectWidget.vue' import DateRangeWidget from './widgets/DateRangeWidget.vue' import NumberRangeWidget from './widgets/NumberRangeWidget.vue' // 每种字段类型支持哪些运算符 export const operatorMap: Record<string, string[]> = { input: ['eq', 'like'], select: ['eq', 'in'], 'date-range': ['between'], 'number-range': ['between', 'gt', 'lt'], } // 每种字段类型渲染哪个值控件 export const widgetMap = { input: InputWidget, select: SelectWidget, 'date-range': DateRangeWidget, 'number-range': NumberRangeWidget, }模板里用动态组件按当前字段类型渲染控件:
<!-- ConditionRow.vue(核心片段) --> <template> <div class="condition-row"> <select v-model="localCondition.field" @change="onFieldChange"> <option v-for="f in fields" :key="f.field" :value="f.field"> {{ f.label }} </option> </select> <select v-model="localCondition.operator"> <option v-for="op in currentOperators" :key="op" :value="op"> {{ operatorLabel(op) }} </option> </select> <component :is="currentWidget" :field="currentFieldMeta" :modelValue="localCondition.value" @update:modelValue="onValueChange" /> </div> </template>这段模板解决了一个很实际的交互问题:当用户把字段从「订单号」切到「创建时间」,运算符列表要跟着变(eq/like 变成 between),值控件也要替换(文本框变成日期范围选择器)。onFieldChange 里必须做两件事:把 operator 重置为当前字段的第一个合法运算符,把 value 清空。如果不清空,残留的旧字段值会被误带进查询参数,这是一类很隐蔽的 bug。
operatorLabel 可以是「等于」「包含」「区间」这类中文展示名,运算符的英文枚举和展示文案的映射也放在 conditions-map.ts 里,方便产品改文案时只动一处。
3.2 关系逻辑与条件管理:AND / OR 下的增删改查
条件之间的关系是组合查询里最容易被低估的部分。很多项目把条件简单拼成 AND,但用户会问「查状态为已发货 或 金额大于 1000 的订单」,没有 OR 支持就满足不了。组件的做法是把逻辑关系放在顶层,统一作用于所有条件:
// QueryBuilder.vue 中的条件管理逻辑 import { ref } from 'vue' import type { QueryCondition, QueryField, QueryParams } from '../../types/query' const conditions = ref<QueryCondition[]>([]) const logic = ref<'AND' | 'OR'>('AND') // 新增一条空条件,id 用自增计数器加时间戳保证唯一 let seq = 0 function addCondition() { conditions.value.push({ id: `cond_${++seq}_${Date.now()}`, field: fields[0].field, operator: getDefaultOperator(fields[0]), value: getDefaultValue(fields[0]), }) } function removeCondition(id: string) { const index = conditions.value.findIndex(c => c.id === id) if (index > -1) { conditions.value.splice(index, 1) } } function emitQuery() { const params: QueryParams = serialize(conditions.value, logic.value) emit('query', params) }注意 removeCondition 用 splice 而不是 filter 重新赋值。splice 是响应式数组的变更方法,能精确触发依赖项的更新;filter 产生新数组会让整块条件列表重新 diff,条件超过 10 条时会有可感知的卡顿。新增条件时,字段默认取配置列表第一个,运算符和值跟随默认逻辑生成,用户只需要改值就能完成第一条查询。
条件的局部状态通过 ConditionRow 的 v-model 链路同步回父组件。每行条件维护自己的 field、operator、value,变更事件向上抛,父组件只关心条件列表的增删和整体出参。这样 QueryBuilder 的开销始终和条件数量成正比,不会因为某个控件内部的复杂操作拖慢整棵组件树。
3.3 值序列化:日期范围、多选、数字区间统一出参
条件和值在组件内部是「人看」的格式,提交给后端要变「机器看」的格式。序列化这一步在 utils/query-serialize.ts 里完成:
// utils/query-serialize.ts import type { QueryCondition, QueryParams } from '../types/query' export function serialize( conditions: QueryCondition[], logic: 'AND' | 'OR', ): QueryParams { // 过滤空值条件:空字符串、null、undefined 一律不参与查询 const valid = conditions.filter(c => c.value !== '' && c.value != null) return { logic, conditions: valid.map(({ id, ...rest }) => rest), } }序列化的关键在两点。一是空值过滤:文本框清空后是空字符串,下拉框未选是 undefined,日期范围清空是 null,三种空值形态统一在入口过滤掉,后端不用处理「空条件也要拼 SQL」的脏逻辑。二是剥 id:条件内部的标识不是业务字段,出参前必须去掉。
日期范围控件是序列化的重灾区,单独看它的值处理:
// DateRangeWidget.vue(内部处理片段) function onDateChange(dates: [Date, Date] | null) { if (!dates) { emit('update:modelValue', null) return } // 转成本地时间字符串,避免 toISOString 的 UTC 偏移 const format = (d: Date) => { const pad = (n: number) => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } emit('update:modelValue', [format(dates[0]), format(dates[1])]) }这里刻意不用 Date.toISOString(),因为 toISOString 返回的是 UTC 时间,东八区项目会整体偏移 8 小时,这是日期查询最常见的事故来源。格式化为本地时间字符串再交给后端,后端按字符串解析不会出错。数字区间控件同理,在控件内部把两个输入框的值整理成[min, max]数组,组件边界上永远是统一的类型。
4. 组合查询避坑记录:索引错位、时区偏移与空值三座大山
4.1 删除中间条件后索引错位,条件串了行
现象:页面有 5 条条件,删掉第 2 条后,第 3 条的值显示到了第 2 条的位置,提交参数和界面不一致。
原因:条件列表用数组下标作为渲染 key,删除中间项后 Vue 按 key 复用 DOM,输入控件里保留的是旧值,导致数据串位。
解决:每条条件用唯一 id(如cond_3_1620000000)作为 key,删除按 id 查找后 splice,不要依赖 index。我在一次排障时花了半个下午定位,最后发现是同事图省事用 index 当 key,后续所有条件的值都对不上号。这个坑在条件超过三条后几乎必现,建议在自定义组件开发的初期就把 key 设计成 id。
4.2 日期范围查询总是差 8 小时
现象:选「2024-06-01 到 2024-06-30」,后端查出来的数据却从 2024-05-31 16:00 开始。
原因:前端用 toISOString() 把本地时间转成 UTC 字符串,后端按 UTC 解析后少了 8 小时时差。
解决:序列化时手动格式化为本地时间字符串(见 3.3 的 format 函数),接口文档明确约定传输格式为本地时间字符串;后端若是时间戳字段,用 Date.parse 解析本地字符串再转成对应时间戳查询。
提示:顺手把「结束日期是 23:59:59」还是「次日 00:00:00」也约定清楚,不然查 6 月 30 日的数据时容易漏掉当天最后一秒。
4.3 空值处理不一致:null、空字符串、undefined 混着传
现象:有的条件没填值提交了 null,有的传了空字符串,有的字段直接缺失。后端写 SQL 被迫WHERE (? IS NULL OR col = ?)写三份判断。
原因:不同控件 emit 空值的方式不同——文本框清空是空字符串,下拉框未选是 undefined,日期范围清空是 null。
解决:在 serialize 阶段统一过滤规则,null、undefined、空字符串一律视为「该条件不参与查询」。类型定义里把值类型明确为string | number | string[] | [string, string] | null,约定值为 null 代表不传。后端收到 null 时直接跳过该条件的 SQL 拼接。
4.4 深层数组对象改了值界面不刷新
现象:条件列表是多层嵌套,修改内层输入框的值后界面不响应,打印数据却是新的。
原因:值在赋值时绕过了响应式代理,比如先把对象取出来赋给临时变量,改完临时变量再回写数组,中间断了响应链路;或者是把 Vue 2 时代的 $set 思维带进来,把响应式对象当普通对象操作。
解决:确保所有条件对象创建后都在 ref 包的响应式代理里,所有修改通过conditions.value[index].value = newVal这类路径操作。如果发现改了不刷新,第一步先检查赋值路径是否经过响应式数组本身,而不是某个抛出来的浅拷贝。实在定位不到,用 Vue Devtools 看一下组件树里该条件的响应式标记是否还在。
5. 运行与接入实践:从 demo 跑到对接后端接口
5.1 环境准备与最小启动步骤
源码用的是 Vite + Vue 3 标准工程,跑起来很常规:
# 安装依赖 npm install # 启动开发服务 npm run dev启动后打开 demo 页面,能看到一个可交互的组合查询区:第一列选字段、第二列选运算符、第三列填值,底部有「新增条件」和「查询」按钮。加几条条件后点查询,浏览器控制台会打印出完整的 QueryParams 结构,这是验证组件行为最快的路径。
如果 npm install 遇到 peer 依赖冲突,常见原因是本机 Node 版本过新或过旧,检查一下 package.json 里声明的 Vite 版本对应要求,按需切换 Node 版本即可。启动后端口被占用会报 EADDRINUSE,直接改 vite.config.ts 里的 server.port。
如果想单独跑组件不跑整个 demo,删掉 demo 目录,把 query-builder 作为独立组件引入就行。组件没有外部运行时依赖,不需要引入额外的 UI 库,这对集成进已有项目很友好。
5.2 以组件方式接入:三个方向打通接口
组件对外的接口很收敛,接入一个页面只需要三步:传 fields 配置、监听 query 事件、回填初始条件。看一个最小接入例子:
<!-- 你的页面 --> <template> <QueryBuilder :fields="orderFields" :initial-conditions="savedQuery" @query="onQuery" /> </template> <script setup> import { ref } from 'vue' import QueryBuilder from '@/components/query-builder/QueryBuilder.vue' // 定义可查询字段:加字段 = 改配置,不用动组件 const orderFields = ref([ { field: 'orderNo', label: '订单号', type: 'input' }, { field: 'status', label: '状态', type: 'select', options: [ { label: '待支付', value: 0 }, { label: '已支付', value: 1 }, { label: '已发货', value: 2 }, ] }, { field: 'createdAt', label: '创建时间', type: 'date-range' }, { field: 'amount', label: '金额', type: 'number-range' }, ]) // 初始条件,通常来自「保存的查询方案」或路由参数 const savedQuery = ref([]) function onQuery(params) { // params = { logic: 'AND', conditions: [...] } fetchOrders(params) // 对接你的后端请求 } </script>props 和事件的含义如下:
| 名称 | 方向 | 作用 |
|---|---|---|
| fields | 入参 | 声明可查询字段及其类型 |
| initial-conditions | 入参 | 回填已有查询条件 |
| query 事件 | 出参 | 提交时携带结构化查询参数 |
接入的核心思想是让页面不关心条件区内部怎么渲染,只关心「哪些字段能查」和「用户查了什么」。fields 是入参方向,query 事件是出参方向,initial-conditions 是回显方向,三个方向打通,组件就是完整黑盒,内部怎么折腾都影响不到业务代码。
5.3 与后端接口对接:参数结构约定与 SQL 映射
序列化产出的 QueryParams 建议直接作为 POST body 的一部分传给后端。后端最终收到的是:
{ "logic": "AND", "conditions": [ { "field": "status", "operator": "eq", "value": 1 }, { "field": "createdAt", "operator": "between", "value": ["2024-06-01 00:00:00", "2024-06-30 23:59:59"] }, { "field": "amount", "operator": "gt", "value": 1000 } ] }后端解析时按 operator 分发 SQL 片段:
// Java 后端的简易映射(示意) switch (cond.getOperator()) { case "eq" -> sql.append(cond.getField()).append(" = ?"); case "like" -> sql.append(cond.getField()).append(" LIKE CONCAT('%', ?, '%')"); case "between" -> sql.append(cond.getField()).append(" BETWEEN ? AND ?"); case "in" -> sql.append(cond.getField()).append(" IN (?, ?)"); case "gt" -> sql.append(cond.getField()).append(" > ?"); case "lt" -> sql.append(cond.getField()).append(" < ?"); }logic 字段决定条件之间用 AND 还是 OR 拼接。这个约定的好处是后续前端新增字段类型不需要后端改接口结构,只需要在 SQL 解析层加一个 operator 分支。建议在接口文档里把 operators 的枚举值列为公共约定,前后端各存一份,避免出现「前端传 between 后端只认识 range」这种对不上的状况。
6. 二次开发:自定义条件类型与查询方案持久化
拿到源码后最常做的两件事,一是加自定义条件类型,二是做查询方案的保存和回填。
加自定义类型分四步。第一步在 types/query.ts 的 QueryField.type 联合类型里加'user-select';第二步在 widgets 目录下新建 UserSelectWidget.vue,只需要实现接收 modelValue 和 field 元信息、emit update:modelValue 输出新值;第三步在 conditions-map.ts 的 widgetMap 里注册映射;第四步在页面 fields 配置里把新类型用起来。四步走完,新条件类型就能出现在查询区。最容易遗漏的是第一步——类型定义没更新,TypeScript 会在编译期直接报错,反而成了保护网。
查询方案持久化,核心就一句话:把 QueryParams 存起来,下次渲染时映射回 QueryCondition 再传给组件。我踩过一个很实在的坑:直接把 QueryParams 当 initial-conditions 传回组件,结果渲染后每行条件的 id 都是 undefined,删除按钮全部失效,报错信息还指向不明确。回填前必须补 id:
// 从存储中还原条件列表,id 必须重新生成 function restoreConditions(params: QueryParams): QueryCondition[] { return params.conditions.map((c, i) => ({ ...c, id: `restored_${i}_${Date.now()}`, })) }从那以后我每次写「保存查询方案」这类功能,都强制走一遍完整链路:序列化出参、存储、回填还原、再序列化对比,确认两次出参结构一致才敢提交。这套组件的设计核心就是「配置驱动一切」,改业务需求前先想配置结构怎么变,而不是急着改模板。希望这篇解析能帮到你,把组合查询做成页面里最省心的部分。
本文还有配套的精品资源,点击获取