简介:面向 Vue.js 前端开发者的一份组合查询条件示例源码,适合正在实现多字段筛选、动态查询参数拼装与结果联动更新的初中级开发者参考。代码围绕组件化查询表单展开:输入框、下拉选择、日期等字段各自独立封装,借助计算属性与侦听器组合查询参数,并用 Vuex 集中管理查询条件与结果状态,配合 axios 发起请求、Vue Router 管理结果页路由,较完整地呈现了从表单绑定到数据回显的响应式链路,对理解查询条件如何驱动视图更新、请求参数如何分层组织尤其有帮助。压缩包共 32 个文件,约 139KB,以 14 个 js 脚本、4 个 vue 单文件组件和 5 个 xml 配置为主,另含 json、html、png 等工程与说明文件,目录涵盖 src、router、store、build、config 等模块,结构清晰便于按层阅读。目前已有 1278 人学习下载,可作为搭建后台列表筛选功能时的结构参照与排错对照。
1. 从一张越堆越长的筛选面板说起:vue 组合查询条件源码值不值得拆
运营后台的筛选区大概是最容易被低估的地方。一开始只是「用户名 + 状态下拉」,三个月后变成「注册时间区间、来源渠道多选、订单数大于、标签包含任意一个、手机号模糊匹配」,模板里塞满了v-if,每加一个字段就要改一次页面、发一次版。把这份 vue 组合查询条件源码拆完,最直接的感受是:筛选不该写成模板,应该写成数据结构——字段是一份配置,条件是用户点出来的一棵树,页面只负责把这棵树画出来。这份资源给的就是一整套条件树组件、操作符字典、序列化与校验逻辑,适合正在做中后台、低代码表单、报表查询页的开发者,尤其适合那种「筛选条件由产品随时改」的项目。
2. 目录与技术选型:这份 vue 组合查询条件源码的骨架长什么样
先看骨架再谈实现。整套东西是 Vue 3 +<script setup>写的,构建工具用 Vite,样式基本裸写、不绑定组件库,输入控件那层留了插槽和映射表,所以你可以接 Element Plus、Ant Design Vue,也可以纯原生<input>。这个选择挺务实:查询构造器这种东西一旦和某个 UI 库绑死,换库就得重写,而它真正有价值的部分是数据模型和序列化,不是按钮长什么样。
2.1 目录结构与每个文件的职责
src/ ├── components/ │ ├── QueryBuilder.vue # 对外入口:布局、操作按钮、校验结果汇总 │ ├── ConditionGroup.vue # 递归节点:渲染一组 AND/OR 子条件(自己嵌套自己) │ ├── ConditionRow.vue # 单条条件:字段选择 / 操作符选择 / 值输入 │ └── ValueInput.vue # 按字段类型动态切换输入控件(文本框、下拉、日期区间) ├── composables/ │ ├── useConditionTree.js # 树的增删改、路径查找、节点 id 生成 │ └── useOperator.js # 操作符与字段类型的匹配、操作符元信息查询 ├── core/ │ ├── operators.js # 操作符字典(eq/like/between/in…) │ ├── serialize.js # 条件树 → JSON DSL / SQL 片段双出口 │ └── validate.js # 递归校验,返回错误路径 ├── presets/ │ └── fields.js # 字段元数据示例(用户名、注册时间、渠道、订单数) └── App.vue # 演示页,接一份假的列表数据这个划分的关键点是core目录不 import 任何 Vue 的东西。serialize.js和validate.js是纯函数,输入一个普通对象,输出一个普通对象。这个约束看着不起眼,实际很救命——后端同学要对照 DSL 结构时,你可以直接把core丢给他跑单测,不用起一个 Vue 环境;将来要做「查询条件保存为常用视图」,序列化逻辑也能直接搬到 Node 侧复用。
| 文件 | 是否依赖 Vue | 主要职责 | 改造频率 |
|---|---|---|---|
| QueryBuilder.vue | 是 | 入口、按钮、校验汇总 | 低 |
| ConditionGroup.vue | 是 | 递归渲染、组级操作 | 中 |
| ConditionRow.vue | 是 | 字段/操作符切换、值重置 | 中 |
| ValueInput.vue | 是 | 控件映射 | 高(换 UI 库时) |
| core/serialize.js | 否 | 树 → DSL / SQL | 高(对接后端时) |
| core/validate.js | 否 | 递归校验 | 低 |
| presets/fields.js | 否 | 业务字段配置 | 最高(每个项目都改) |
2.2 数据模型:为什么是树,不是扁平数组
扁平数组([{field, op, value}, ...])只能表达「全部 AND」,一旦产品说「渠道是 A 或 B,并且订单数大于 3」,扁平结构就塌了,只能在字段值里塞数组做特殊处理,越写越玄学。树形结构把「组」和「条件」区分开,逻辑关系挂在组上,表达能力立刻够了:
{ "id": "g_root", "type": "group", "logic": "AND", "children": [ { "id": "c_1", "type": "condition", "field": "channel", "operator": "in", "value": ["a", "b"] }, { "id": "g_2", "type": "group", "logic": "OR", "children": [ { "id": "c_2", "type": "condition", "field": "orderCount", "operator": "gt", "value": 3 }, { "id": "c_3", "type": "condition", "field": "orderCount", "operator": "between", "value": [10, 20] } ] } ] }几个字段的含义:type只有group和condition两种,递归时靠它分流;logic只出现在组节点上,取值为AND/OR;id用于v-for的 key 和路径定位,必须全局唯一,别用索引——索引做 key,在中间插入或删除节点时会触发复用错乱,输入框里的值会「跳到」另一行去,这个坑我在别的列表组件里踩过不止一次。
value的形状跟着操作符走:in和between是数组,其余是标量。这套约定要提前和后端对齐,别一边传[1,2]一边传"1,2",联调时能吵半天。
2.3 依赖与启动:三条命令把 demo 跑起来
依赖极简,Vue 3 是硬依赖,其余都是可选的。核心依赖大致是这样:
{ "name": "vue-query-builder", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "vue": "^3.4.0" }, "devDependencies": { "vite": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0" } }如果你打算用现成的下拉和日期控件,再加一行element-plus或ant-design-vue,然后在ValueInput.vue的映射表里换掉原生标签就行,组件逻辑不用动。启动流程没什么花样:
# 装依赖 npm install # 起开发服务器,默认 http://localhost:5173 npm run dev # 生产构建,产物在 dist/ npm run build跑起来之后页面上是一个默认带两三条条件的面板:可以点「+ 条件」加一行,点「+ 条件组」加一个嵌套组,组头部的下拉能在「且 / 或」之间切。打开控制台,点提交时能看到serialize打印出的 DSL 结构——先看这个结构,比读代码更快建立对整棵树的直觉。
提示:如果你的宿主项目还是 Vue 2,别急着照搬
<script setup>。Vue 2.7 已经支持 Composition API,把setup语法换成setup()函数返回即可,递归组件的name选项必须显式写上,这个后面会细说。
3. 递归渲染链路:条件树是怎么一层层变成可操作面板的
组件里唯一有难度的地方就是递归。ConditionGroup渲染自己,ConditionRow渲染叶子,ValueInput负责最终那个输入框。三层各管一段,边界清楚,改起来才不会一改全崩。这里我把每个文件的核心逻辑拆开讲,你可以直接对着抄。
3.1 ConditionGroup:递归节点与组级操作
组节点要做三件事:渲染组头(逻辑切换 + 增删按钮)、渲染本组内的条件行、渲染子组并把自己再调一次。
<!-- ConditionGroup.vue --> <script setup> import { computed } from 'vue' import ConditionRow from './ConditionRow.vue' const props = defineProps({ node: { type: Object, required: true }, // 当前组 { id, type:'group', logic, children } fields: { type: Array, default: () => [] }, // 字段元数据,原样往下传 depth: { type: Number, default: 0 } // 嵌套层级,控制缩进与最大深度 }) const emit = defineEmits(['add-row', 'add-group', 'remove-node', 'update-logic', 'change-row']) // 硬限制三层,"且"嵌套"或"再嵌套"且"之上基本没人看得懂,也没人维护得了 const canNest = computed(() => props.depth < 3) const rows = computed(() => props.node.children.filter(c => c.type === 'condition')) const groups = computed(() => props.node.children.filter(c => c.type === 'group')) </script> <template> <div class="qb-group" :class="{ 'qb-group--nested': depth > 0 }"> <div class="qb-group__head"> <select :value="node.logic" @change="emit('update-logic', node.id, $event.target.value)"> <option value="AND">且</option> <option value="OR">或</option> </select> <button type="button" @click="emit('add-row', node.id)">+ 条件</button> <button v-if="canNest" type="button" @click="emit('add-group', node.id)">+ 条件组</button> <button v-if="depth > 0" type="button" @click="emit('remove-node', node.id)">删除组</button> </div> <!-- 叶子条件 --> <ConditionRow v-for="child in rows" :key="child.id" :node="child" :fields="fields" @change="(patch) => emit('change-row', child.id, patch)" @remove="emit('remove-node', child.id)" /> <!-- 递归自己:子组继续走同一套渲染 --> <ConditionGroup v-for="child in groups" :key="child.id" :node="child" :fields="fields" :depth="depth + 1" @add-row="emit('add-row', $event)" @add-group="emit('add-group', $event)" @remove-node="emit('remove-node', $event)" @update-logic="(id, logic) => emit('update-logic', id, logic)" @change-row="(id, patch) => emit('change-row', id, patch)" /> </div> </template>参数说明:node是当前子树,组件不持有任何副本,纯靠 props 往下传;depth从 0 开始,每递归一层加 1,同时用于缩进和canNest判断;fields是字段元数据,一路透传,保证每一个叶子节点都能拿到完整的字段清单。事件全部往上抛,组件自身不修改props.node,这是 Vue 单向数据流的硬要求,也是后面「删了没反应」那个坑的根源。
为什么用事件冒泡而不是provide/inject直接操作?因为树的写操作集中到最顶层一个 composable 里,做不可变更新更好排查:任何一次改动都会产生一棵新树,把前后两棵树打印出来对比,问题基本就锁定了。provide/inject少写几行,但出问题时改动的来源满天飞。
3.2 ConditionRow:字段一换,值和操作符都得重置
这一行看着简单,实际是 bug 高发区。用户先选了「注册时间」配了日期区间,又改成「用户名」,如果只更新field而不管value,输入框会拿着一个数组去渲染文本框,页面上出现[object Object],甚至直接报错。
<!-- ConditionRow.vue --> <script setup> import { computed } from 'vue' import ValueInput from './ValueInput.vue' const props = defineProps({ node: { type: Object, required: true }, // { id, type:'condition', field, operator, value } fields: { type: Array, default: () => [] } }) const emit = defineEmits(['change', 'remove']) const currentField = computed(() => props.fields.find(f => f.key === props.node.field)) const operators = computed(() => currentField.value?.operators || ['eq']) // 切字段:操作符回落到该字段合法的第一个,值按新字段的形态重置 function onFieldChange(key) { const field = props.fields.find(f => f.key === key) emit('change', { field: key, operator: field?.operators?.[0] || 'eq', value: field?.valueType === 'array' ? [] : '' }) } // 切操作符:只在形态变化时重置,比如 eq -> between 要从标量变数组 function onOperatorChange(op) { const needArray = op === 'between' || op === 'in' const isArray = Array.isArray(props.node.value) emit('change', { operator: op, value: needArray === isArray ? props.node.value : (needArray ? [] : '') }) } </script>valueType是字段元数据里自己定的约定,取值string/number/array/date,它决定了新条件初始值的形态。onOperatorChange里那个needArray === isArray判断是个小技巧:形态没变就别动值,用户从gt切到gte时输过的数字应该留着,别一刀切清空,那种体验很烦人。
注意:
emit('change', patch)传的是补丁对象而不是完整节点,由顶层合并。这样快速连续切换时不会因为拿到的props.node是旧快照而互相覆盖。
3.3 ValueInput:一张映射表搞定所有控件类型
值输入这一层最容易写成一堆v-if。写在模板里,加一种类型就多一个分支,半年后没人敢动。改成映射表的思路是:字段元数据里用widget声明该用哪种控件,组件只负责把widget映射到具体组件。
<!-- ValueInput.vue --> <script setup> import { computed } from 'vue' const props = defineProps({ field: { type: Object, default: () => ({}) }, modelValue: { type: [String, Number, Array, Date, null], default: '' } }) const emit = defineEmits(['update:modelValue']) const widget = computed(() => props.field?.widget || 'input') const options = computed(() => props.field?.options || []) function update(v) { emit('update:modelValue', v) } </script> <template> <!-- 文本框:eq / like / ne 这类单值操作符默认走这里 --> <input v-if="widget === 'input'" :value="modelValue" @input="update($event.target.value)" /> <!-- 数字框:注意转 Number,后端拿到字符串会报类型错 --> <input v-else-if="widget === 'number'" type="number" :value="modelValue" @input="update($event.target.value === '' ? '' : Number($event.target.value))" /> <!-- 下拉:单选或多选由字段的 multiple 决定 --> <select v-else-if="widget === 'select'" :multiple="field.multiple" :value="modelValue" @change="update( field.multiple ? Array.from($event.target.selectedOptions).map(o => o.value) : $event.target.value )" > <option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option> </select> <!-- 日期区间:输出固定为 [start, end] 两元素数组 --> <span v-else-if="widget === 'daterange'"> <input type="date" :value="modelValue?.[0] || ''" @change="update([$event.target.value, modelValue?.[1] || ''])" /> <input type="date" :value="modelValue?.[1] || ''" @change="update([modelValue?.[0] || '', $event.target.value])" /> </span> </template>widget支持input/number/select/daterange四种,够覆盖中后台八成场景,剩下的用插槽扩展。这里特意把数字框做了Number()转换——原生input的value永远是字符串,orderCount > 3传给后端变成"3",很多 ORM 会做隐式转换,但一旦碰上严格类型校验就直接 400,属于典型的联调翻车点。同理,日期控件输出的"2024-05-01"这种字符串要不要转时间戳,最好在序列化层统一处理,而不是散落在各个控件里。
4. 序列化与校验:把条件树翻译成后端接口认的参数
树画出来了,接下来是把它变成后端能吃的东西。这一步决定了整套组件是「好看但没用」还是「真能落地」。平时我会准备两个出口:一个结构化 JSON DSL,交给后端自己解析;一个带占位符的 SQL 片段,给那些只接受原生条件的查询接口用。前者是主流,后者是兜底。
4.1 serialize:空节点剪枝与单子节点扁平化
序列化最容易忽略的不是拼接,是「清理」。用户点了「+ 条件组」但没填任何东西,直接提交,后端收到一个空 children 的组,解析出来就是AND (),语法错误。所以serialize的第一职责是丢垃圾。
// core/serialize.js // 单条条件是否"有效":字段必须有,值不能是空字符串 / null / 空数组 function isValidCondition(node) { if (!node.field) return false const v = node.value if (v === '' || v === null || v === undefined) return false if (Array.isArray(v) && (v.length === 0 || v.some(x => x === '' || x === null))) return false return true } /** * 条件树 -> JSON DSL * @param {Object} node 组节点或条件节点 * @returns {Object|null} null 表示该分支整枝无效,调用方需要过滤掉 */ export function serialize(node) { if (!node) return null if (node.type === 'condition') { return isValidCondition(node) ? { field: node.field, op: node.operator, value: node.value } : null } const children = (node.children || []) .map(serialize) .filter(Boolean) // 剪掉无效分支 if (children.length === 0) return null if (children.length === 1) return children[0] // 单子节点不用包一层,后端少解析一层 return { logic: node.logic || 'AND', children } }逐段看:isValidCondition把「值是否为空」的判断集中在一个地方,区间字段只要有一端没填就整条判无效,避免出现BETWEEN 3 AND NULL;filter(Boolean)是剪枝的核心,一行代码把空组、空条件全部剔除;最后的单子节点扁平化是有意为之,{logic:'AND', children:[x]}和x语义等价,但前者会让后端多写一层递归,DSL 也变难看。前端做掉这层简化,联调时对方会舒服很多。
如果后端只要 SQL 片段,加一个出口就行,重点是永远别把值直接拼进字符串:
// core/serialize.js(续) const OP_MAP = { eq: '=', ne: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=', like: 'LIKE', in: 'IN', between: 'BETWEEN' } export function toSql(node, params = []) { const dsl = serialize(node) if (!dsl) return { sql: '', params: [] } function walk(n) { if (n.children) { const parts = n.children.map(walk) return `(${parts.join(` ${n.logic || 'AND'} `)})` } const op = OP_MAP[n.op] || '=' if (n.op === 'between') { params.push(n.value[0], n.value[1]) return `${n.field} BETWEEN ? AND ?` } if (n.op === 'in') { params.push(...n.value) return `${n.field} IN (${n.value.map(() => '?').join(',')})` } if (n.op === 'like') { params.push(`%${n.value}%`) // 通配符在参数里拼,不拼进 SQL return `${n.field} LIKE ?` } params.push(n.value) return `${n.field} ${op} ?` } return { sql: walk(dsl), params } }params数组收集占位符对应的值,顺序和 SQL 里的?一一对应。字段名(field)这里是直接拼的,前提是字段名来自fields.js白名单——serialize前应该用配置里的 key 校验一遍,绝不能让用户输入的字符串变成列名,这是最基本的一条底线。
4.2 validate:递归校验要能定位到具体哪一行
校验和序列化的区别在于,序列化可以「静默丢弃」,校验必须「明着告警」。用户点了提交,你得告诉他第几条条件的哪个字段没填,而不是简单弹一句「请完善查询条件」。
// core/validate.js export function validate(root, fields) { const fieldMap = new Map(fields.map(f => [f.key, f])) const errors = [] // path 记录从根到当前节点的索引链路,用来拼出"第 2 组第 1 条"这种可读位置 function walk(node, path) { if (node.type === 'group') { (node.children || []).forEach((child, i) => walk(child, [...path, i])) return } const field = fieldMap.get(node.field) if (!field) { errors.push({ path, nodeId: node.id, message: '请选择字段' }) return } if (!field.operators.includes(node.operator)) { errors.push({ path, nodeId: node.id, message: `字段「${field.label}」不支持该操作符` }) } const v = node.value const empty = v === '' || v === null || v === undefined || (Array.isArray(v) && v.length === 0) if (empty) { errors.push({ path, nodeId: node.id, message: `请填写「${field.label}」的查询值` }) } } walk(root, []) return errors }path是这条链路的关键,它把嵌套位置编码成数组,界面上可以用nodeId直接滚动定位并高亮那一行。返回结构里带nodeId而不是让调用方自己去找,是因为树可能被重新生成过,索引会变,但 id 不会。实际项目里我还会给每个错误配一条人话消息,别让用户看到VALIDATION_FAILED这种。
4.3 接进真实请求:DSL 放哪儿、怎么防 URL 超长
拿到 DSL 之后就是发请求。枚举查询条件传 JSON 结构,GET时塞进params会被序列化成[object Object],必须自己JSON.stringify;条件一多,URL 长度轻松突破浏览器和网关的限制,这时候换POST更稳。
import { serialize } from '@/core/serialize' import { validate } from '@/core/validate' async function handleSearch(tree, fields, page) { // 1. 先校验,有错直接拦下,别把半成品发给后端 const errors = validate(tree, fields) if (errors.length) { highlightErrors(errors) // 界面上按 nodeId 高亮 return } // 2. 序列化,空分支已经被剪掉 const dsl = serialize(tree) // 3. 结构复杂或条件多时走 POST,避免 URL 超长 const res = await fetch('/api/users/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ condition: dsl, // 后端按 logic/children 递归解析 page: page.page, size: page.size }) }) return res.json() }这里有个容易被忽略的点:dsl为null时代表「没有任何有效条件」,此时应该查全量还是拦下来提示「至少填一个条件」,取决于业务。我一般会在入口加个开关requireCondition,默认true——后台列表页动辄百万行,一个空条件查询打下去,数据库那边脸色会很难看。
5. 踩坑排查:条件树组件最容易翻车的五个现场
这套东西的复杂度不在写出来,在改的时候不崩。下面五条基本都是我和周围人真踩过的,按「现象 → 原因 → 解决」记下来,出问题时对着查能省不少时间。
第一条:控制台报Failed to resolve component: ConditionGroup,页面一片空白。
现象是递归的地方完全没有渲染,也不报错中断。原因是递归自引用失效。Vue 3 的<script setup>支持用文件名隐式自引用,但前提是文件名和标签名严格一致,而且组件必须是通过 SFC 编译的。一旦你把它包进defineComponent、改了文件名、或者用index.js统一导出,隐式引用就断了;Vue 2 更直接,必须显式写name: 'ConditionGroup'。解决办法:Vue 3 里在<script setup>中import ConditionGroup from './ConditionGroup.vue'后虽然会有自引用告警,但最稳的做法是保留文件名一致;Vue 2 老老实实加name选项。
第二条:点「删除条件」,数据变了但界面纹丝不动,或者删错了行。
现象是控制台打印树结构发现节点已经没了,视图却还是旧的。原因多半是在子组件里直接props.node.children.splice(index, 1)。这既违反单向数据流,也因为修改的是 prop 内部的引用,父组件的响应式追踪不到。解决:所有写操作统一往上 emit,在顶层useConditionTree里做不可变更新——返回一棵新树,用tree.value = nextTree整体替换。宁可多拷贝一次,也别在子组件里动手。
第三条:从「等于」切到「区间」,页面直接报value.map is not a function。
现象是切换操作符后输入控件崩了。原因是value还是字符串"abc",而daterange/ 多选控件期望数组,内部调.map就炸。解决:在ConditionRow里监听operator变化,按操作符的形态(标量 / 数组)重置值,也就是 3.2 里onOperatorChange那段。补一句,切换字段同理,两处都要处理,只处理一处照样翻车。
第四条:后端返回 400,说条件解析失败,一看请求体里有{"logic":"AND","children":[]}。
现象是明明没填任何条件,提交后后端报错。原因就是空组没剪枝,serialize直接把空 children 原样输出。解决:在serialize里对children.length === 0返回null,外层filter(Boolean)过滤掉。顺带一提,单个子节点也建议扁平化,减少后端递归层数,两边都省事。
第五条:复制一个条件组做「常用查询」,日期条件全变成了字符串,回填时报错。
现象是「另存为模板」再加载回来,日期区间不好使了。原因是用了JSON.parse(JSON.stringify(tree))做深拷贝,Date对象被序列化成 ISO 字符串,undefined直接丢失。解决:优先用structuredClone,它原生支持Date、Map、Set;环境不支持就手写递归拷贝,或者在数据模型层面就约定所有值只用基本类型和数组,日期统一存时间戳数字,从源头避开这个问题。
提示:这五条里有四条都指向同一个设计原则——不要在子组件里改 props,不要相信用户会填完整。把写操作收敛到一个地方,把校验和剪枝放在序列化之前,能省掉大半联调时间。
6. 二次开发:把自己的业务字段接进这套查询构造器
真正落地时,改得最多的不是组件,是presets/fields.js。这份配置决定了用户能选哪些字段、每种字段能用哪些操作符、值用什么控件渲染。加一个新字段,基本就是往数组里塞一个对象,加上后端对应的列名映射。
| 配置项 | 类型 | 说明 | 常见取值 |
|---|---|---|---|
| key | string | 字段唯一标识,也是传给后端的列名 | username、createdAt |
| label | string | 界面上显示的中文名 | 用户名、注册时间 |
| widget | string | 用哪种输入控件 | input/number/select/daterange |
| valueType | string | 值的形态,决定重置逻辑 | string/number/array |
| operators | string[] | 该字段允许的操作符,第一个是默认值 | ['eq','like'] |
| multiple | boolean | 下拉是否多选 | true/false |
| options | array | 下拉选项,{label, value} | 由字典接口拉取 |
// presets/fields.js —— 新增一个"订单数"字段 export const fields = [ { key: 'username', label: '用户名', widget: 'input', valueType: 'string', operators: ['like', 'eq', 'ne'] // 默认 like,模糊搜索最常用 }, { key: 'createdAt', label: '注册时间', widget: 'daterange', valueType: 'array', operators: ['between'] // 日期字段只给区间,别开放 eq }, { key: 'orderCount', label: '订单数', widget: 'number', valueType: 'number', operators: ['gt', 'gte', 'lt', 'lte', 'between'] }, { key: 'channel', label: '来源渠道', widget: 'select', valueType: 'array', multiple: true, operators: ['in', 'notIn'], options: [] // 运行时从字典接口填充 } ]加完之后有两处必须同步改:一是core/operators.js里补上notIn这类新操作符的定义(显示名、是否需要数组值、对应的 SQL 写法);二是后端解析 DSL 的分支里认这个操作符。这两边不同步,前端能选、后端不认,症状是提交后静默少一个条件,比报错还难查。
验证方法我一般用三步走:先在演示页里把新字段的每种操作符都点一遍,看控制台打印的 DSL 结构对不对;再写一个纯函数的单测,直接对serialize喂一棵构造好的树,断言输出结构,这层不依赖 Vue,跑得飞快;最后接真实接口联调,重点看两个边界——空值被剪掉没有、数组值和标量值有没有混淆。遇到字段多、选项来自接口的情况,记得把options做成异步填充,别在fields.js里写死,不然字典一改就得重新发版。
这套组件的改造上限其实取决于你的 DSL 设计。想做得更深,可以往「条件模板」「跨表关联字段」「权限控制哪些字段可见」几个方向走,但每一步都要先改数据结构再改 UI,顺序反了就会陷入到处打补丁的局面。
说个教训:早些年我在一个后台项目里图省事,让子组件直接改父级传下来的数组,测试环境一路通畅,上线后运营反馈「删条件删不干净、偶尔还删错行」,查了大半天才定位到是引用被就地修改、视图没跟上。从那以后我每次写这类树形/列表组件,写操作一律从顶层走一遍不可变替换,删完立刻打印前后两棵树对比。看着啰嗦,但比半夜回滚强。希望帮到你。
本文还有配套的精品资源,点击获取