9 Commits

Author SHA1 Message Date
windychen 859a7ecc94 chore: 版本 0.2.0 更新 between-ui/in-ui 可配项与 query-component 完整支持
Node.js Build / build (push) Successful in 8s
2026-08-10 22:13:43 +08:00
windychen 701cc5026e chore: 版本 0.1.0 更新 between-ui/in-ui 可配项与 query-component 完整支持
Node.js Build / build (push) Successful in 6s
2026-08-10 22:10:57 +08:00
windychen e938118594 chore: 版本 0.1.0 更新 默认 query-option 深拷贝导出与 schema 缺省回退
Node.js Build / build (push) Successful in 6s
2026-08-07 16:42:01 +08:00
windychen 23a7ebf668 chore: 版本 0.1.0 更新 component DESIGN 输出协议与 CSS 变量说明
Node.js Build / build (push) Successful in 5s
2026-08-07 16:18:12 +08:00
windychen 7c08a9f34c docs: 修正 DESIGN.md 中 CSS 变量数量(5→11)
Node.js Build / build (push) Has been skipped
2026-08-07 16:17:19 +08:00
windychen ec99dee803 chore: 版本 0.1.0 更新 README 状态标记说明(数据库修复后重触发)
Node.js Build / build (push) Successful in 6s
2026-08-07 16:11:21 +08:00
windychen d0a9eab9d9 chore: 版本 0.1.0 更新 README 状态标记说明 2026-08-07 16:06:20 +08:00
windychen 95acff6a1a chore: 版本 0.1.0 更新补齐内置控件集 textarea/select/number/date/tags/between
Node.js Build / build (push) Successful in 6s
2026-08-07 11:11:40 +08:00
windychen fd5f7a0752 docs: 补全 Q1-Q61 完整设计决策文档
Node.js Build / build (push) Has been skipped
2026-08-07 10:25:52 +08:00
20 changed files with 1219 additions and 46 deletions
+136 -24
View File
@@ -1,32 +1,144 @@
# trans-form-component — Design (Q1–Q61)
# @windychen/trans-form-component — Design (Q1–Q61)
Status: **implementation scaffold** (v0.1.0)
> 设计基线:grill-me 61 题访谈结论(2026-08-06)。配套包:`@windychen/trans-form-middleware`。
Companion package: `@windychen/trans-form-middleware`.
## 角色定位
## Role
Schema 驱动的 Vue 3 动态查询表单:接收 schema 数组,输出扁平 filter 数组(含可选 `logic`、状态标记、`empty` 标记)。
Schema-driven Vue 3 query form → flat filter array with optional `logic`, state flags, and `empty` markers.
```jsonc
// 输入 schema 示例
[
{
"row": 6,
"value-component": "input",
"field": "name",
"field-name": "名称",
"query-option": [
{ "value": "EQ", "label": "=", "desc-cn": "等于" },
{ "value": "GT", "label": ">", "desc-cn": "大于" }
]
}
]
## Key decisions (selected)
// 输出 filters 示例
[
{ "field": "name", "value": "xx", "query": "EQ", "op": "=" }
]
```
| # | Decision |
|---|----------|
| 4–5 | No UI framework; headless native controls for string `value-component` |
| 7 | Flat array + `logic` between adjacent conditions |
| 11 | `@change` + `getFilters()` ref API |
| 15 | 12-column grid via `row` |
| 17–18 | Query dropdown; optional `query-component` (defaults to value renderer) |
| 19 | Global singleton `setConfig` / `componentMap` |
| 20 | ESM only (Vite library build) |
| 30, 35 | Inline layout 3:2:7 (name/query/value), globally configurable |
| 47 | Storybook included |
| 48 | `app.use(TransFormPlugin)` |
| 51 | Built-in reset only (`showReset`, `reset()`) |
| 54 | First filter item has no `logic` |
| 55–59 | Reactive schema; preserve matching fields; AND/OR editable; v1 types include tags |
| 60 | Output includes state flags for middleware |
---
## CI
## 一、架构与发布
Same as windychen-utils: commit must contain `chore` + `版本` + `更新`.
| # | 决策 |
|---|------|
| 1 | middleware:Node.js 纯 JS |
| 2 | middleware 输出:结构化过滤器,不碰 SQL |
| 3 | **两个独立 Gitea 仓库**,各自 CI、各自发版 |
| 4 | component:**无 UI 库依赖**,slot/渲染器由宿主提供 |
| 5 | 字符串 `value-component`:**内置 headless 原生控件** |
| 10 | component 源码:**纯 JavaScript**(与 middleware 一致) |
| 20 | component 发布:**ESM only** |
| 21 | CI:**复制 windychen-utils 模式**(三关键词 → Verdaccio + Gitea Packages + Release + 钉钉) |
| 22 | 包名:`@windychen/trans-form-component`、`@windychen/trans-form-middleware` |
| 23 | `peerDependencies: vue@^3.4`(Vue 3 only) |
| 26 | component 构建:**vue-tsc + Vite**(library 模式) |
| 28 | 测试:**Vitest**,CI 里 `npm test` 通过才发布 |
| 29 | 版本号:**完全独立**,兼容性写 README |
| 47 | component 仓库带 **Storybook** |
| 48 | 宿主通过 `app.use(TransFormPlugin)` 全局注册 |
---
## 二、Schema / 布局 / 渲染
### 布局
| # | 决策 |
|---|------|
| 15 | `row`:**12 栅格**,一行总宽 12,累加超 12 自动换行 |
| 30 | field 布局:同一 row 内 **name 3 / query 2 / value 7**(inline) |
| 35 | 栅格比例:**全局固定**,`setConfig` 一次设定 |
### 条件逻辑
| # | 决策 |
|---|------|
| 16 | 输入 schema 每条可带 **`logic: 'AND'\|'OR'`** |
| 17 | 操作符:**下拉可改**;支持 **`query-component`**(规则同 `value-component`) |
| 18 | 未指定 `query-component` 时:**继承 value renderer**,绑定 query 字段 |
| 19 | `setConfig` / `component-map`:**全局单例** |
| 34 | 未写 `default-query`:默认 **`query-option` 第一项** |
| 57 | logic:**UI 可改** AND/OR,输出以 UI 为准 |
### 组件解析
| # | 决策 |
|---|------|
| 36 | 未注册 `value-component`:**占位 + console warn**,不阻断其它 field |
| 37 | 未注册 `query-component`:同上,默认继承 value renderer |
| 43 | 自定义组件 props:`{ field, fieldName, queryOption, schema }` 等上下文 |
| 45 | `options`:**组件自主维护**,schema 不强制固定字段 |
### 样式
| # | 决策 |
|---|------|
| 42 | 样式:**内置最小样式 + `--tf-*` CSS 变量**(11 个:`--tf-bg` `--tf-border` `--tf-radius` `--tf-gap` `--tf-label-color` `--tf-name-cols` `--tf-query-cols` `--tf-value-cols` `--tf-span` `--tf-unknown-bg` `--tf-unknown-color`) |
### 内置控件(v1)
| # | 决策 |
|---|------|
| 58 | 内置类型:`input`、`textarea`、`select`、`number`、`date` + **`tags`** |
| 32 | BETWEEN UI:可配 `between-ui`,**默认双 input** |
| 33 | IN/NOT_IN UI:可配 `in-ui`,**默认 tags** |
### 字段状态
| # | 决策 |
|---|------|
| 59 | field 状态:支持 **`hidden` / `disabled` / `readOnly`** |
| 31 | IS_NULL/IS_NOT_NULL:value **禁用但占位** |
| 55 | `schema`:**响应式 prop**,变化时保留同名 field 状态 |
| 56 | 匹配 key:按 **`field` 名** |
---
## 三、输出协议
| # | 决策 |
|---|------|
| 6 | 多条件:**schema 可配 and/or,支持嵌套分组** |
| 7 | 输出形态:**扁平数组 + `logic` 字段** |
| 9 | 空 value:**保留并标记 `empty: true`** |
| 11 | 对外 API:**emit `@change` + ref `getFilters()` / `setFilters()` / `loadFilters()` / `reset()`** |
| 12 | v1 操作符:比较 + LIKE/IN/NOT_IN + BETWEEN/IS_NULL/IS_NOT_NULL |
| 13 | 多值 `value`:**逗号分隔字符串**(如 `"10,20"`、`"a,b,c"`) |
| 51 | 只内置 **重置**(`reset()` + 可选按钮),查询由宿主触发 |
| 52 | 重置:回到 **schema 初始态** |
| 53 | `@change`:`changeEmit: 'immediate' \| 'debounce' \| 'manual'`,默认 immediate |
| 54 | **第一条不带 `logic`**,从第二条起表示与前一条的连接 |
| 60 | filters **全部输出带状态标记**(`disabled` / `hidden` / `readOnly`) |
---
## 四、与 middleware 的协议对齐
| 项 | 约定 |
|----|------|
| 输出字段 | `field`、`value`、`query`(原始码)、`op`(标准符号)、`logic?`、`values?`、`empty?` |
| 状态标记 | `disabled: true` / `hidden: true` → middleware 默认跳过;`readOnly` → 正常解析 |
| 空值 | `empty: true` → middleware 默认保留,`skipEmpty` 可配 |
| 操作符码表 | `EQ NE GT GTE LT LTE LIKE IN NOT_IN BETWEEN IS_NULL IS_NOT_NULL` |
---
## 五、CI 触发约定
commit message 必须同时包含 `chore` + `版本` + `更新` 才触发构建发布;否则 job 显示 skipped(设计行为,防误触)。
```bash
git commit -m "chore: 版本 0.1.0 更新 ..."
```
+2
View File
@@ -59,6 +59,8 @@ function onChange(filters) {
]
```
Output items carry state flags: `disabled: true` / `hidden: true` are skipped by middleware by default, `readOnly: true` is parsed normally, and `empty: true` marks an empty value (kept unless `skipEmpty`).
Pair with `@windychen/trans-form-middleware` for server-side parsing.
See `DESIGN.md` for full grill-me decisions (Q1–Q61).
+18
View File
@@ -0,0 +1,18 @@
import js from '@eslint/js';
export default [
{ ignores: ['**/node_modules/**', '**/dist/**', '**/storybook-static/**', '.storybook/**'] },
js.configs.recommended,
{
files: ['**/*.{js,mjs}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
window: 'readonly',
document: 'readonly',
console: 'readonly',
},
},
},
];
+22
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/vue3": "^8.6.14",
"@storybook/vue3-vite": "^8.6.14",
@@ -618,6 +619,27 @@
"node": ">=18"
}
},
"node_modules/@eslint/js": {
"version": "10.0.1",
"resolved": "http://verdaccio:4873/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windychen/trans-form-component",
"version": "0.1.0",
"version": "0.2.0",
"description": "Vue 3 dynamic query form: schema in, filter array out",
"type": "module",
"exports": {
@@ -24,6 +24,7 @@
"vue": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/vue3": "^8.6.14",
"@storybook/vue3-vite": "^8.6.14",
+71 -14
View File
@@ -1,15 +1,14 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { computed, markRaw, reactive, toRaw, watch } from 'vue';
import { getConfig } from './config.js';
import {
applyFilters,
buildFilters,
createFieldState,
groupRows,
resolveQueryOption,
} from './composables/useFilters.js';
import HeadlessInput from './components/HeadlessInput.vue';
import UnknownPlaceholder from './components/UnknownPlaceholder.vue';
const props = defineProps({
schema: { type: Array, default: () => [] },
showReset: { type: Boolean, default: true },
@@ -17,7 +16,6 @@ const props = defineProps({
const emit = defineEmits(['change']);
const initialState = createFieldState(props.schema);
const state = reactive(createFieldState(props.schema));
const rowGroups = computed(() => groupRows(props.schema));
@@ -32,20 +30,58 @@ const colStyle = computed(() => {
};
});
/**
* 解析控件:
* - kind === 'query':读 query-component(未配置时由模板走默认 select)
* - kind === 'value':按当前 query 优先 between-ui(BETWEEN)/ in-ui(IN|NOT_IN),
* 未配置时默认 between=双 input、in=tags;其它 query 走 value-component
*/
function resolveRaw(fieldDef, kind) {
if (kind === 'query') {
return { key: 'query-component', raw: fieldDef['query-component'] };
}
const query = state[fieldDef.field]?.query;
if (query === 'BETWEEN') {
return {
key: 'between-ui',
raw: fieldDef['between-ui'] ?? 'between',
};
}
if (query === 'IN' || query === 'NOT_IN') {
return {
key: 'in-ui',
raw: fieldDef['in-ui'] ?? 'tags',
};
}
return { key: 'value-component', raw: fieldDef['value-component'] };
}
function resolveComponent(fieldDef, kind) {
const key = kind === 'query' ? 'query-component' : 'value-component';
const raw = fieldDef[key] ?? fieldDef['value-component'];
if (raw && typeof raw === 'object') return raw;
const { key, raw } = resolveRaw(fieldDef, kind);
if (raw && typeof raw === 'object') return markRaw(toRaw(raw));
const name = typeof raw === 'string' ? raw : 'input';
const mapped = config.value.componentMap[name];
if (mapped) return mapped;
if (name === 'input' || name === 'textarea' || name === 'number' || name === 'date') {
return HeadlessInput;
}
console.warn(`[TransForm] unknown ${key}: "${name}"`);
return UnknownPlaceholder;
}
function resolveComponentName(fieldDef, kind) {
const { raw } = resolveRaw(fieldDef, kind);
if (raw && typeof raw === 'object') return 'custom';
return typeof raw === 'string' ? raw : 'input';
}
function hasQueryComponent(fieldDef) {
return fieldDef['query-component'] != null;
}
function isNullQuery(fieldDef) {
const q = state[fieldDef.field]?.query;
return q === 'IS_NULL' || q === 'IS_NOT_NULL';
}
function emitChange() {
emit('change', getFilters());
}
@@ -109,12 +145,13 @@ defineExpose({ getFilters, setFilters, loadFilters, reset });
<label class="tf-label">{{ fieldDef['field-name'] || fieldDef.field }}</label>
<select
v-if="!hasQueryComponent(fieldDef)"
class="tf-control"
v-model="state[fieldDef.field].query"
:disabled="state[fieldDef.field]?.disabled"
>
<option
v-for="opt in fieldDef['query-option'] || []"
v-for="opt in resolveQueryOption(fieldDef)"
:key="opt.value"
:value="opt.value"
>
@@ -123,12 +160,32 @@ defineExpose({ getFilters, setFilters, loadFilters, reset });
</select>
<component
:is="resolveComponent(fieldDef, 'value')"
v-model="state[fieldDef.field].value"
v-else
:is="resolveComponent(fieldDef, 'query')"
v-model="state[fieldDef.field].query"
:disabled="state[fieldDef.field]?.disabled"
:read-only="state[fieldDef.field]?.readOnly"
:name="typeof fieldDef['value-component'] === 'string' ? fieldDef['value-component'] : 'custom'"
:name="resolveComponentName(fieldDef, 'query')"
:placeholder="fieldDef['field-name']"
:field="fieldDef.field"
:field-name="fieldDef['field-name']"
:query-option="resolveQueryOption(fieldDef)"
:schema="fieldDef"
:options="fieldDef.options || []"
/>
<component
:is="resolveComponent(fieldDef, 'value')"
v-model="state[fieldDef.field].value"
:disabled="state[fieldDef.field]?.disabled || isNullQuery(fieldDef)"
:read-only="state[fieldDef.field]?.readOnly"
:name="resolveComponentName(fieldDef, 'value')"
:placeholder="fieldDef['field-name']"
:field="fieldDef.field"
:field-name="fieldDef['field-name']"
:query-option="resolveQueryOption(fieldDef)"
:schema="fieldDef"
:options="fieldDef.options || []"
/>
</div>
</div>
+57
View File
@@ -0,0 +1,57 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue']);
const start = computed({
get() {
return String(props.modelValue ?? '').split(',')[0] ?? '';
},
set(value) {
const endVal = String(props.modelValue ?? '').split(',')[1] ?? '';
emit('update:modelValue', `${value ?? ''},${endVal}`);
},
});
const end = computed({
get() {
return String(props.modelValue ?? '').split(',')[1] ?? '';
},
set(value) {
const startVal = String(props.modelValue ?? '').split(',')[0] ?? '';
emit('update:modelValue', `${startVal},${value ?? ''}`);
},
});
</script>
<template>
<input
class="tf-control tf-between-input"
type="text"
v-model="start"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
/>
<input
class="tf-control tf-between-input"
type="text"
v-model="end"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
/>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
defineEmits(['update:modelValue']);
</script>
<template>
<input
class="tf-control tf-date"
type="date"
:value="modelValue"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target).value)"
/>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
defineEmits(['update:modelValue']);
</script>
<template>
<input
class="tf-control tf-number"
type="number"
:value="modelValue"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target).value)"
/>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
defineEmits(['update:modelValue']);
const list = computed(() => (props.options?.length ? props.options : props.queryOption));
</script>
<template>
<select
class="tf-control tf-select"
:value="modelValue"
:disabled="disabled || readOnly"
@change="$emit('update:modelValue', ($event.target).value)"
>
<option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
<option v-for="opt in list" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</template>
+97
View File
@@ -0,0 +1,97 @@
<script setup>
import { computed, ref } from 'vue';
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue']);
const draft = ref('');
const tags = computed(() => {
if (props.modelValue == null || props.modelValue === '') return [];
return String(props.modelValue)
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
});
function emitTags(next) {
emit('update:modelValue', next.join(','));
}
function commitDraft() {
if (props.disabled || props.readOnly) return;
const parts = draft.value
.split(',')
.map((part) => part.trim())
.filter((part) => part.length > 0);
draft.value = '';
if (!parts.length) return;
emitTags([...tags.value, ...parts]);
}
function removeTag(index) {
if (props.disabled || props.readOnly) return;
const next = [...tags.value];
next.splice(index, 1);
emitTags(next);
}
function onKeydown(event) {
if (props.disabled || props.readOnly) return;
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
commitDraft();
}
}
function onInput(event) {
if (props.disabled || props.readOnly) return;
const value = event.target.value;
draft.value = value;
if (value.includes(',')) {
commitDraft();
}
}
</script>
<template>
<div class="tf-control tf-tags" :class="{ 'is-disabled': disabled }">
<span
v-for="(tag, index) in tags"
:key="`${tag}-${index}`"
class="tf-tag"
>
{{ tag }}
<button
type="button"
class="tf-tag-x"
:disabled="disabled || readOnly"
@click.stop="removeTag(index)"
>
×
</button>
</span>
<input
class="tf-tag-input"
type="text"
:value="draft"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
@input="onInput"
@keydown="onKeydown"
/>
</div>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
defineEmits(['update:modelValue']);
</script>
<template>
<textarea
class="tf-control tf-textarea"
:value="modelValue"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target).value)"
></textarea>
</template>
+20 -2
View File
@@ -4,6 +4,8 @@
* @property {'AND'|'OR'} [logic]
* @property {string|import('vue').Component} [value-component]
* @property {string|import('vue').Component} [query-component]
* @property {string|import('vue').Component} [between-ui]
* @property {string|import('vue').Component} [in-ui]
* @property {string} field
* @property {string} [field-name]
* @property {Array<{value: string, label?: string}>} [query-option]
@@ -15,6 +17,22 @@
* @property {string} [query]
*/
import { getDefaultQueryOption } from '../config.js';
/**
* 解析字段的查询操作符列表:
* - schema 显式配置了 query-option 时,优先使用字段自己的;
* - 否则若配置了自定义 query-component(操作符由组件自己管理),返回空数组、不套默认;
* - 否则返回默认操作符列表(getDefaultQueryOption 深拷贝,调用方可安全修改)。
* @param {SchemaField} [fieldDef]
* @returns {Array<{value: string, label?: string}>}
*/
export function resolveQueryOption(fieldDef) {
if (fieldDef?.['query-option']) return fieldDef['query-option'];
if (fieldDef?.['query-component']) return [];
return getDefaultQueryOption();
}
/**
* @param {SchemaField[]} schema
*/
@@ -26,7 +44,7 @@ export function createFieldState(schema) {
const defaultQuery =
item['default-query'] ??
item.query ??
item['query-option']?.[0]?.value ??
resolveQueryOption(item)[0]?.value ??
'EQ';
state[item.field] = {
value: item.value ?? '',
@@ -60,7 +78,7 @@ export function buildFilters(schema, state) {
const row = {
field: key,
value: empty ? null : value,
query: current.query ?? fieldDef['query-option']?.[0]?.value ?? 'EQ',
query: current.query ?? resolveQueryOption(fieldDef)[0]?.value ?? 'EQ',
hidden: current.hidden === true,
disabled: current.disabled === true,
readOnly: current.readOnly === true,
+60 -3
View File
@@ -1,10 +1,54 @@
/** @typedef {{ componentMap?: Record<string, import('vue').Component>, colRatio?: { name?: number, query?: number, value?: number }, changeEmit?: 'immediate'|'debounce'|'manual' }} TransFormConfig */
import HeadlessInput from './components/HeadlessInput.vue';
import HeadlessTextarea from './components/HeadlessTextarea.vue';
import HeadlessSelect from './components/HeadlessSelect.vue';
import HeadlessNumber from './components/HeadlessNumber.vue';
import HeadlessDate from './components/HeadlessDate.vue';
import HeadlessTags from './components/HeadlessTags.vue';
import HeadlessBetween from './components/HeadlessBetween.vue';
/**
* 内置控件映射(Q5/Q58):字符串 value-component 先查这里,
* 用户通过 setConfig({ componentMap }) 可覆盖或新增。
*/
export const BUILTIN_COMPONENT_MAP = {
input: HeadlessInput,
textarea: HeadlessTextarea,
select: HeadlessSelect,
number: HeadlessNumber,
date: HeadlessDate,
tags: HeadlessTags,
between: HeadlessBetween,
};
/**
* 默认查询操作符全集(对应 DESIGN.md Q12/Q61 操作符码表)。
* 注意:这是共享常量,请通过 getDefaultQueryOption() 获取深拷贝后再修改,
* 不要直接改动本常量,以免污染全局配置。
*/
export const DEFAULT_QUERY_OPTION = [
{ value: 'EQ', label: '=' },
{ value: 'NE', label: '≠' },
{ value: 'GT', label: '>' },
{ value: 'GTE', label: '≥' },
{ value: 'LT', label: '<' },
{ value: 'LTE', label: '≤' },
{ value: 'LIKE', label: 'LIKE' },
{ value: 'IN', label: 'IN' },
{ value: 'NOT_IN', label: 'NOT IN' },
{ value: 'BETWEEN', label: 'BETWEEN' },
{ value: 'IS_NULL', label: 'IS NULL' },
{ value: 'IS_NOT_NULL', label: 'IS NOT NULL' },
];
/** @typedef {{ componentMap?: Record<string, import('vue').Component>, colRatio?: { name?: number, query?: number, value?: number }, changeEmit?: 'immediate'|'debounce'|'manual', queryOption?: Array<{value: string, label?: string}> }} TransFormConfig */
/** @type {TransFormConfig & { componentMap: Record<string, import('vue').Component> }} */
const globalConfig = {
componentMap: {},
componentMap: { ...BUILTIN_COMPONENT_MAP },
colRatio: { name: 3, query: 2, value: 7 },
changeEmit: 'immediate',
// 默认操作符列表(深拷贝一份,避免外部修改 DEFAULT_QUERY_OPTION 常量污染配置)
queryOption: structuredClone(DEFAULT_QUERY_OPTION),
};
/**
@@ -21,6 +65,18 @@ export function setConfig(patch) {
globalConfig.colRatio = { ...globalConfig.colRatio, ...patch.colRatio };
}
if (patch.changeEmit) globalConfig.changeEmit = patch.changeEmit;
// queryOption:整体替换整个数组(深拷贝存储,防止外部后续修改污染配置)
if (patch.queryOption) {
globalConfig.queryOption = structuredClone(patch.queryOption);
}
}
/**
* 获取默认查询操作符列表(始终返回深拷贝,调用方可安全修改,不影响全局配置)。
* @returns {Array<{value: string, label?: string}>}
*/
export function getDefaultQueryOption() {
return structuredClone(globalConfig.queryOption);
}
export function getConfig() {
@@ -28,7 +84,8 @@ export function getConfig() {
}
export function resetConfig() {
globalConfig.componentMap = {};
globalConfig.componentMap = { ...BUILTIN_COMPONENT_MAP };
globalConfig.colRatio = { name: 3, query: 2, value: 7 };
globalConfig.changeEmit = 'immediate';
globalConfig.queryOption = structuredClone(DEFAULT_QUERY_OPTION);
}
+12
View File
@@ -1,3 +1,15 @@
import './styles/trans-form.css';
export { default as TransFormPlugin, TransForm, setConfig, getConfig } from './plugin.js';
export { DEFAULT_QUERY_OPTION, getDefaultQueryOption, resetConfig } from './config.js';
export { resolveQueryOption } from './composables/useFilters.js';
export { default as HeadlessInput } from './components/HeadlessInput.vue';
export { default as HeadlessTextarea } from './components/HeadlessTextarea.vue';
export { default as HeadlessSelect } from './components/HeadlessSelect.vue';
export { default as HeadlessNumber } from './components/HeadlessNumber.vue';
export { default as HeadlessDate } from './components/HeadlessDate.vue';
export { default as HeadlessTags } from './components/HeadlessTags.vue';
export { default as HeadlessBetween } from './components/HeadlessBetween.vue';
export { default as UnknownPlaceholder } from './components/UnknownPlaceholder.vue';
+61
View File
@@ -63,6 +63,67 @@
font-size: 13px;
}
/* ---- tags(IN/NOT_IN 多值标签) ---- */
.tf-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
min-height: 32px;
cursor: text;
}
.tf-tags.is-disabled {
opacity: 0.65;
}
.tf-tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: #eef1f6;
border: 1px solid var(--tf-border);
border-radius: 4px;
padding: 2px 6px;
font-size: 13px;
line-height: 1.4;
}
.tf-tag-x {
border: none;
background: transparent;
color: var(--tf-label-color);
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 0 2px;
}
.tf-tag-x:hover {
color: #cf222e;
}
.tf-tag-input {
border: none;
outline: none;
background: transparent;
flex: 1;
min-width: 80px;
font-size: 13px;
padding: 2px;
}
/* ---- between(区间双输入) ---- */
.tf-between {
display: flex;
align-items: center;
gap: var(--tf-gap);
}
.tf-between-input {
flex: 1;
}
.tf-actions {
display: flex;
gap: var(--tf-gap);
+53 -1
View File
@@ -21,11 +21,63 @@ const schema = [
{
row: 6,
logic: 'OR',
'value-component': 'input',
'value-component': 'select',
field: 'status',
'field-name': '状态',
options: [
{ value: 'active', label: '启用' },
{ value: 'inactive', label: '停用' },
],
'query-option': [
{ value: 'EQ', label: '=', 'desc-cn': '等于' },
{ value: 'NE', label: '≠', 'desc-cn': '不等于' },
],
},
{
row: 4,
logic: 'AND',
'value-component': 'tags',
field: 'tags',
'field-name': '标签',
'in-ui': 'tags',
'query-option': [{ value: 'IN', label: '∈', 'desc-cn': '包含任一' }],
},
{
row: 4,
logic: 'AND',
'value-component': 'between',
field: 'created',
'field-name': '创建时间',
'between-ui': 'between',
'query-option': [{ value: 'BETWEEN', label: '↔', 'desc-cn': '区间' }],
},
{
row: 4,
logic: 'AND',
'value-component': 'number',
field: 'age',
'field-name': '年龄',
'query-option': [
{ value: 'GT', label: '>', 'desc-cn': '大于' },
{ value: 'LT', label: '<', 'desc-cn': '小于' },
],
},
{
row: 6,
logic: 'AND',
'value-component': 'date',
field: 'birthday',
'field-name': '生日',
'query-option': [{ value: 'EQ', label: '=', 'desc-cn': '等于' }],
},
{
row: 6,
logic: 'AND',
'value-component': 'textarea',
field: 'remark',
'field-name': '备注',
'query-option': [{ value: 'LIKE', label: '~', 'desc-cn': '包含' }],
},
];
export const Default = {
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import HeadlessTextarea from '../src/components/HeadlessTextarea.vue';
import HeadlessSelect from '../src/components/HeadlessSelect.vue';
import HeadlessNumber from '../src/components/HeadlessNumber.vue';
import HeadlessDate from '../src/components/HeadlessDate.vue';
import HeadlessTags from '../src/components/HeadlessTags.vue';
import HeadlessBetween from '../src/components/HeadlessBetween.vue';
describe('HeadlessTextarea', () => {
it('renders textarea and emits update:modelValue', async () => {
const wrapper = mount(HeadlessTextarea, { props: { modelValue: 'hi' } });
expect(wrapper.find('textarea').element.value).toBe('hi');
await wrapper.find('textarea').setValue('hello');
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['hello']);
});
});
describe('HeadlessSelect', () => {
const options = [
{ value: 'EQ', label: '=' },
{ value: 'GT', label: '>' },
];
it('renders options from props.options', () => {
const wrapper = mount(HeadlessSelect, { props: { modelValue: 'EQ', options } });
const opts = wrapper.findAll('option');
expect(opts).toHaveLength(2);
expect(opts[0].text()).toBe('=');
});
it('falls back to queryOption when options empty', () => {
const wrapper = mount(HeadlessSelect, {
props: { modelValue: 'EQ', queryOption: options, options: [] },
});
expect(wrapper.findAll('option')).toHaveLength(2);
});
it('emits on change', async () => {
const wrapper = mount(HeadlessSelect, { props: { modelValue: 'EQ', options } });
await wrapper.find('select').setValue('GT');
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['GT']);
});
});
describe('HeadlessNumber', () => {
it('emits number input', async () => {
const wrapper = mount(HeadlessNumber, { props: { modelValue: 3 } });
expect(wrapper.find('input').attributes('type')).toBe('number');
await wrapper.find('input').setValue('7');
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['7']);
});
});
describe('HeadlessDate', () => {
it('renders date input', () => {
const wrapper = mount(HeadlessDate, { props: { modelValue: '2026-08-07' } });
expect(wrapper.find('input').attributes('type')).toBe('date');
expect(wrapper.find('input').element.value).toBe('2026-08-07');
});
});
describe('HeadlessTags', () => {
it('renders tags from comma string', () => {
const wrapper = mount(HeadlessTags, { props: { modelValue: 'a,b,c' } });
expect(wrapper.findAll('.tf-tag')).toHaveLength(3);
});
it('adds tag on Enter', async () => {
const wrapper = mount(HeadlessTags, { props: { modelValue: 'a' } });
const input = wrapper.find('input');
await input.setValue('b');
await input.trigger('keydown', { key: 'Enter' });
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual(['a,b']);
});
it('removes tag on x click', async () => {
const wrapper = mount(HeadlessTags, { props: { modelValue: 'a,b' } });
await wrapper.findAll('.tf-tag-x')[0].trigger('click');
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual(['b']);
});
it('respects disabled', async () => {
const wrapper = mount(HeadlessTags, { props: { modelValue: 'a', disabled: true } });
await wrapper.find('input').trigger('keydown', { key: 'Enter' });
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
});
});
describe('HeadlessBetween', () => {
it('splits modelValue into two inputs', () => {
const wrapper = mount(HeadlessBetween, { props: { modelValue: '1,10' } });
const inputs = wrapper.findAll('input');
expect(inputs).toHaveLength(2);
expect(inputs[0].element.value).toBe('1');
expect(inputs[1].element.value).toBe('10');
});
it('emits combined string on start change', async () => {
const wrapper = mount(HeadlessBetween, { props: { modelValue: '1,10' } });
await wrapper.findAll('input')[0].setValue('5');
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual(['5,10']);
});
});
+301
View File
@@ -0,0 +1,301 @@
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import TransForm from '../src/TransForm.vue';
import HeadlessBetween from '../src/components/HeadlessBetween.vue';
import HeadlessDate from '../src/components/HeadlessDate.vue';
import HeadlessSelect from '../src/components/HeadlessSelect.vue';
import HeadlessTags from '../src/components/HeadlessTags.vue';
import { resetConfig, setConfig } from '../src/config.js';
// 每个用例后恢复全局配置,避免 setConfig 污染其它用例
afterEach(() => {
resetConfig();
});
describe('TransForm 操作符下拉(默认 query-option)', () => {
it('无 query-option 的字段渲染默认操作符列表(12 项,第一项 EQ)', () => {
const wrapper = mount(TransForm, {
props: { schema: [{ row: 12, field: 'name', 'field-name': '名称' }] },
});
const opts = wrapper.find('select').findAll('option');
expect(opts).toHaveLength(12);
expect(opts[0].text()).toBe('=');
expect(opts[0].attributes('value')).toBe('EQ');
expect(opts[11].text()).toBe('IS NOT NULL');
});
it('有 query-option 的字段仍渲染 schema 自己的选项(不被默认覆盖)', () => {
const wrapper = mount(TransForm, {
props: {
schema: [
{
row: 12,
field: 'status',
'field-name': '状态',
'query-option': [{ value: 'NE', label: '≠' }],
},
],
},
});
const opts = wrapper.find('select').findAll('option');
expect(opts).toHaveLength(1);
expect(opts[0].text()).toBe('≠');
expect(opts[0].attributes('value')).toBe('NE');
});
it('setConfig 自定义 queryOption 后,无 query-option 字段的下拉随之更新', () => {
setConfig({ queryOption: [{ value: 'GT', label: '>' }] });
const wrapper = mount(TransForm, {
props: { schema: [{ row: 12, field: 'age', 'field-name': '年龄' }] },
});
const opts = wrapper.find('select').findAll('option');
expect(opts).toHaveLength(1);
expect(opts[0].text()).toBe('>');
});
it('有 query-component 但无 query-option 的字段不套默认下拉(操作符由组件自己管理)', () => {
const wrapper = mount(TransForm, {
props: {
schema: [{ row: 12, field: 'custom', 'query-component': 'CustomQuery' }],
},
});
// 配置了 query-component 后不再渲染默认 select,未知组件走占位
expect(wrapper.find('select').exists()).toBe(false);
expect(wrapper.find('.tf-unknown').exists()).toBe(true);
});
});
describe('between-ui / in-ui(DESIGN Q32/Q33)', () => {
it('未配置 between-ui 且 query=BETWEEN 时默认渲染双 input(HeadlessBetween)', () => {
const wrapper = mount(TransForm, {
props: {
schema: [
{
row: 12,
field: 'age',
'field-name': '年龄',
'value-component': 'number',
'default-query': 'BETWEEN',
'query-option': [{ value: 'BETWEEN', label: 'BETWEEN' }],
},
],
},
});
expect(wrapper.findComponent(HeadlessBetween).exists()).toBe(true);
expect(wrapper.findAll('.tf-between-input')).toHaveLength(2);
});
it('未配置 in-ui 且 query=IN 时默认渲染 tags(HeadlessTags)', () => {
const wrapper = mount(TransForm, {
props: {
schema: [
{
row: 12,
field: 'tags',
'field-name': '标签',
'value-component': 'input',
'default-query': 'IN',
'query-option': [{ value: 'IN', label: 'IN' }],
},
],
},
});
expect(wrapper.findComponent(HeadlessTags).exists()).toBe(true);
});
it('between-ui 指定 date 时渲染 HeadlessDate', () => {
const wrapper = mount(TransForm, {
props: {
schema: [
{
row: 12,
field: 'age',
'field-name': '年龄',
'value-component': 'between',
'between-ui': 'date',
'default-query': 'BETWEEN',
'query-option': [{ value: 'BETWEEN', label: 'BETWEEN' }],
},
],
},
});
expect(wrapper.findComponent(HeadlessDate).exists()).toBe(true);
expect(wrapper.findComponent(HeadlessBetween).exists()).toBe(false);
expect(wrapper.find('input[type="date"]').exists()).toBe(true);
});
it('in-ui 指定 select 时渲染 HeadlessSelect', () => {
const wrapper = mount(TransForm, {
props: {
schema: [
{
row: 12,
field: 'status',
'field-name': '状态',
'value-component': 'tags',
'in-ui': 'select',
'default-query': 'IN',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
],
'query-option': [{ value: 'IN', label: 'IN' }],
},
],
},
});
expect(wrapper.findComponent(HeadlessSelect).exists()).toBe(true);
expect(wrapper.findComponent(HeadlessTags).exists()).toBe(false);
});
it('非 BETWEEN/IN 时仍走 value-component,忽略 between-ui/in-ui', () => {
const wrapper = mount(TransForm, {
props: {
schema: [
{
row: 12,
field: 'age',
'value-component': 'number',
'between-ui': 'date',
'in-ui': 'select',
'default-query': 'EQ',
'query-option': [
{ value: 'EQ', label: '=' },
{ value: 'BETWEEN', label: 'BETWEEN' },
{ value: 'IN', label: 'IN' },
],
},
],
},
});
expect(wrapper.find('input[type="number"]').exists()).toBe(true);
expect(wrapper.findComponent(HeadlessDate).exists()).toBe(false);
expect(wrapper.findComponent(HeadlessSelect).exists()).toBe(false);
});
});
describe('query-component(DESIGN Q17/Q18)', () => {
it('无 query-component 时保持默认操作符下拉', () => {
const wrapper = mount(TransForm, {
props: {
schema: [
{
row: 12,
field: 'name',
'field-name': '名称',
'query-option': [
{ value: 'EQ', label: '=' },
{ value: 'LIKE', label: 'LIKE' },
],
},
],
},
});
const select = wrapper.find('select');
expect(select.exists()).toBe(true);
expect(select.findAll('option')).toHaveLength(2);
expect(wrapper.vm.getFilters()[0].query).toBe('EQ');
});
it('配置 query-component 时渲染自定义组件且 v-model 绑定 query', async () => {
const CustomQuery = defineComponent({
name: 'CustomQuery',
props: {
modelValue: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () =>
h('button', {
class: 'custom-query-btn',
type: 'button',
disabled: props.disabled,
onClick: () => emit('update:modelValue', 'LIKE'),
}, `query:${props.modelValue}`);
},
});
const wrapper = mount(TransForm, {
props: {
schema: [
{
row: 12,
field: 'name',
'field-name': '名称',
'value-component': 'input',
'query-component': CustomQuery,
'default-query': 'EQ',
'query-option': [
{ value: 'EQ', label: '=' },
{ value: 'LIKE', label: 'LIKE' },
],
},
],
},
});
expect(wrapper.find('select').exists()).toBe(false);
const btn = wrapper.find('.custom-query-btn');
expect(btn.exists()).toBe(true);
expect(btn.text()).toBe('query:EQ');
expect(btn.attributes('disabled')).toBeUndefined();
await btn.trigger('click');
expect(wrapper.vm.getFilters()[0].query).toBe('LIKE');
expect(wrapper.find('.custom-query-btn').text()).toBe('query:LIKE');
});
it('query-component 字符串名可通过 componentMap 注册', async () => {
const CustomQuery = defineComponent({
name: 'MappedQuery',
inheritAttrs: false,
props: {
modelValue: { type: String, default: '' },
options: { type: Array, default: () => [] },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () =>
h('select', {
class: 'mapped-query',
value: props.modelValue,
onChange: (e) => emit('update:modelValue', e.target.value),
}, [
h('option', { value: 'EQ' }, '='),
h('option', { value: 'GT' }, '>'),
]);
},
});
setConfig({ componentMap: { MappedQuery: CustomQuery } });
const wrapper = mount(TransForm, {
props: {
schema: [
{
row: 12,
field: 'age',
'query-component': 'MappedQuery',
'default-query': 'EQ',
'query-option': [
{ value: 'EQ', label: '=' },
{ value: 'GT', label: '>' },
],
},
],
},
});
const mapped = wrapper.find('.mapped-query');
expect(mapped.exists()).toBe(true);
await mapped.setValue('GT');
expect(wrapper.vm.getFilters()[0].query).toBe('GT');
});
});
+92 -1
View File
@@ -1,11 +1,18 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import { getDefaultQueryOption, resetConfig, setConfig } from '../src/config.js';
import {
applyFilters,
buildFilters,
createFieldState,
groupRows,
resolveQueryOption,
} from '../src/composables/useFilters.js';
// 每个用例后恢复全局配置,避免 setConfig 污染其它用例
afterEach(() => {
resetConfig();
});
const schema = [
{
row: 6,
@@ -68,3 +75,87 @@ describe('groupRows', () => {
expect(groups[1]).toHaveLength(1);
});
});
describe('resolveQueryOption(默认 query-option 解析)', () => {
it('无 query-option 且无 query-component 时返回默认操作符列表', () => {
const opts = resolveQueryOption({ field: 'name' });
expect(opts).toHaveLength(12);
expect(opts[0]).toEqual({ value: 'EQ', label: '=' });
expect(opts.map((o) => o.value)).toEqual([
'EQ', 'NE', 'GT', 'GTE', 'LT', 'LTE',
'LIKE', 'IN', 'NOT_IN', 'BETWEEN', 'IS_NULL', 'IS_NOT_NULL',
]);
});
it('有 query-option 的字段仍用 schema 自己的(不被默认覆盖)', () => {
const fieldDef = { field: 'name', 'query-option': [{ value: 'NE', label: '≠' }] };
expect(resolveQueryOption(fieldDef)).toEqual([{ value: 'NE', label: '≠' }]);
});
it('有 query-component 但无 query-option 时返回空数组(组件自己管理)', () => {
const fieldDef = { field: 'name', 'query-component': 'CustomQuery' };
expect(resolveQueryOption(fieldDef)).toEqual([]);
});
it('getDefaultQueryOption() 返回深拷贝:修改返回值不影响后续调用', () => {
const first = getDefaultQueryOption();
first.push({ value: 'X', label: 'x' });
first[0].label = '被改坏了';
first.splice(1, 1);
const second = getDefaultQueryOption();
expect(second).toHaveLength(12);
expect(second[0]).toEqual({ value: 'EQ', label: '=' });
expect(second[1]).toEqual({ value: 'NE', label: '≠' });
});
it('setConfig({ queryOption }) 后 getDefaultQueryOption() 返回新列表', () => {
setConfig({
queryOption: [
{ value: 'EQ', label: '=' },
{ value: 'GT', label: '>' },
],
});
expect(getDefaultQueryOption()).toEqual([
{ value: 'EQ', label: '=' },
{ value: 'GT', label: '>' },
]);
});
it('resetConfig() 恢复默认操作符列表', () => {
setConfig({ queryOption: [{ value: 'ONLY_EQ', label: '=' }] });
resetConfig();
expect(getDefaultQueryOption()).toHaveLength(12);
expect(getDefaultQueryOption()[0]).toEqual({ value: 'EQ', label: '=' });
});
});
describe('默认 query fallback(无 query-option 时取默认列表第一项)', () => {
it('createFieldState:无 query-option 且无 query-component 默认取默认列表第一项(EQ)', () => {
const state = createFieldState([{ field: 'name' }]);
expect(state.name.query).toBe('EQ');
});
it('buildFilters:无 query-option 且无 query-component 默认取默认列表第一项(EQ)', () => {
const state = createFieldState([{ field: 'name' }]);
const filters = buildFilters([{ field: 'name' }], state);
expect(filters[0].query).toBe('EQ');
});
it('setConfig 自定义 queryOption 后,默认 query 取新列表第一项', () => {
setConfig({ queryOption: [{ value: 'GT', label: '>' }] });
const state = createFieldState([{ field: 'age' }]);
expect(state.age.query).toBe('GT');
expect(buildFilters([{ field: 'age' }], state)[0].query).toBe('GT');
});
it('有 query-component 但无 query-option 时,默认 query 仍为 EQ', () => {
const state = createFieldState([{ field: 'custom', 'query-component': 'CustomQuery' }]);
expect(state.custom.query).toBe('EQ');
});
it('显式 default-query 优先级最高,覆盖默认列表第一项', () => {
const state = createFieldState([{ field: 'name', 'default-query': 'LIKE' }]);
expect(state.name.query).toBe('LIKE');
});
});