Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 859a7ecc94 | |||
| 701cc5026e | |||
| e938118594 | |||
| 23a7ebf668 | |||
| 7c08a9f34c | |||
| ec99dee803 | |||
| d0a9eab9d9 | |||
| 95acff6a1a | |||
| fd5f7a0752 |
@@ -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 更新 ..."
|
||||||
|
```
|
||||||
|
|||||||
@@ -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.
|
Pair with `@windychen/trans-form-middleware` for server-side parsing.
|
||||||
|
|
||||||
See `DESIGN.md` for full grill-me decisions (Q1–Q61).
|
See `DESIGN.md` for full grill-me decisions (Q1–Q61).
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
Generated
+22
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@storybook/addon-essentials": "^8.6.14",
|
"@storybook/addon-essentials": "^8.6.14",
|
||||||
"@storybook/vue3": "^8.6.14",
|
"@storybook/vue3": "^8.6.14",
|
||||||
"@storybook/vue3-vite": "^8.6.14",
|
"@storybook/vue3-vite": "^8.6.14",
|
||||||
@@ -618,6 +619,27 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@windychen/trans-form-component",
|
"name": "@windychen/trans-form-component",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Vue 3 dynamic query form: schema in, filter array out",
|
"description": "Vue 3 dynamic query form: schema in, filter array out",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@storybook/addon-essentials": "^8.6.14",
|
"@storybook/addon-essentials": "^8.6.14",
|
||||||
"@storybook/vue3": "^8.6.14",
|
"@storybook/vue3": "^8.6.14",
|
||||||
"@storybook/vue3-vite": "^8.6.14",
|
"@storybook/vue3-vite": "^8.6.14",
|
||||||
|
|||||||
+71
-14
@@ -1,15 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, watch } from 'vue';
|
import { computed, markRaw, reactive, toRaw, watch } from 'vue';
|
||||||
import { getConfig } from './config.js';
|
import { getConfig } from './config.js';
|
||||||
import {
|
import {
|
||||||
applyFilters,
|
applyFilters,
|
||||||
buildFilters,
|
buildFilters,
|
||||||
createFieldState,
|
createFieldState,
|
||||||
groupRows,
|
groupRows,
|
||||||
|
resolveQueryOption,
|
||||||
} from './composables/useFilters.js';
|
} from './composables/useFilters.js';
|
||||||
import HeadlessInput from './components/HeadlessInput.vue';
|
|
||||||
import UnknownPlaceholder from './components/UnknownPlaceholder.vue';
|
import UnknownPlaceholder from './components/UnknownPlaceholder.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
schema: { type: Array, default: () => [] },
|
schema: { type: Array, default: () => [] },
|
||||||
showReset: { type: Boolean, default: true },
|
showReset: { type: Boolean, default: true },
|
||||||
@@ -17,7 +16,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
const initialState = createFieldState(props.schema);
|
|
||||||
const state = reactive(createFieldState(props.schema));
|
const state = reactive(createFieldState(props.schema));
|
||||||
|
|
||||||
const rowGroups = computed(() => groupRows(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) {
|
function resolveComponent(fieldDef, kind) {
|
||||||
const key = kind === 'query' ? 'query-component' : 'value-component';
|
const { key, raw } = resolveRaw(fieldDef, kind);
|
||||||
const raw = fieldDef[key] ?? fieldDef['value-component'];
|
if (raw && typeof raw === 'object') return markRaw(toRaw(raw));
|
||||||
if (raw && typeof raw === 'object') return raw;
|
|
||||||
const name = typeof raw === 'string' ? raw : 'input';
|
const name = typeof raw === 'string' ? raw : 'input';
|
||||||
const mapped = config.value.componentMap[name];
|
const mapped = config.value.componentMap[name];
|
||||||
if (mapped) return mapped;
|
if (mapped) return mapped;
|
||||||
if (name === 'input' || name === 'textarea' || name === 'number' || name === 'date') {
|
|
||||||
return HeadlessInput;
|
|
||||||
}
|
|
||||||
console.warn(`[TransForm] unknown ${key}: "${name}"`);
|
console.warn(`[TransForm] unknown ${key}: "${name}"`);
|
||||||
return UnknownPlaceholder;
|
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() {
|
function emitChange() {
|
||||||
emit('change', getFilters());
|
emit('change', getFilters());
|
||||||
}
|
}
|
||||||
@@ -109,12 +145,13 @@ defineExpose({ getFilters, setFilters, loadFilters, reset });
|
|||||||
<label class="tf-label">{{ fieldDef['field-name'] || fieldDef.field }}</label>
|
<label class="tf-label">{{ fieldDef['field-name'] || fieldDef.field }}</label>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
|
v-if="!hasQueryComponent(fieldDef)"
|
||||||
class="tf-control"
|
class="tf-control"
|
||||||
v-model="state[fieldDef.field].query"
|
v-model="state[fieldDef.field].query"
|
||||||
:disabled="state[fieldDef.field]?.disabled"
|
:disabled="state[fieldDef.field]?.disabled"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="opt in fieldDef['query-option'] || []"
|
v-for="opt in resolveQueryOption(fieldDef)"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
:value="opt.value"
|
:value="opt.value"
|
||||||
>
|
>
|
||||||
@@ -123,12 +160,32 @@ defineExpose({ getFilters, setFilters, loadFilters, reset });
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<component
|
<component
|
||||||
:is="resolveComponent(fieldDef, 'value')"
|
v-else
|
||||||
v-model="state[fieldDef.field].value"
|
:is="resolveComponent(fieldDef, 'query')"
|
||||||
|
v-model="state[fieldDef.field].query"
|
||||||
:disabled="state[fieldDef.field]?.disabled"
|
:disabled="state[fieldDef.field]?.disabled"
|
||||||
:read-only="state[fieldDef.field]?.readOnly"
|
:read-only="state[fieldDef.field]?.readOnly"
|
||||||
:name="typeof fieldDef['value-component'] === 'string' ? fieldDef['value-component'] : 'custom'"
|
:name="resolveComponentName(fieldDef, 'query')"
|
||||||
:placeholder="fieldDef['field-name']"
|
: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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
* @property {'AND'|'OR'} [logic]
|
* @property {'AND'|'OR'} [logic]
|
||||||
* @property {string|import('vue').Component} [value-component]
|
* @property {string|import('vue').Component} [value-component]
|
||||||
* @property {string|import('vue').Component} [query-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
|
||||||
* @property {string} [field-name]
|
* @property {string} [field-name]
|
||||||
* @property {Array<{value: string, label?: string}>} [query-option]
|
* @property {Array<{value: string, label?: string}>} [query-option]
|
||||||
@@ -15,6 +17,22 @@
|
|||||||
* @property {string} [query]
|
* @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
|
* @param {SchemaField[]} schema
|
||||||
*/
|
*/
|
||||||
@@ -26,7 +44,7 @@ export function createFieldState(schema) {
|
|||||||
const defaultQuery =
|
const defaultQuery =
|
||||||
item['default-query'] ??
|
item['default-query'] ??
|
||||||
item.query ??
|
item.query ??
|
||||||
item['query-option']?.[0]?.value ??
|
resolveQueryOption(item)[0]?.value ??
|
||||||
'EQ';
|
'EQ';
|
||||||
state[item.field] = {
|
state[item.field] = {
|
||||||
value: item.value ?? '',
|
value: item.value ?? '',
|
||||||
@@ -60,7 +78,7 @@ export function buildFilters(schema, state) {
|
|||||||
const row = {
|
const row = {
|
||||||
field: key,
|
field: key,
|
||||||
value: empty ? null : value,
|
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,
|
hidden: current.hidden === true,
|
||||||
disabled: current.disabled === true,
|
disabled: current.disabled === true,
|
||||||
readOnly: current.readOnly === true,
|
readOnly: current.readOnly === true,
|
||||||
|
|||||||
+60
-3
@@ -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> }} */
|
/** @type {TransFormConfig & { componentMap: Record<string, import('vue').Component> }} */
|
||||||
const globalConfig = {
|
const globalConfig = {
|
||||||
componentMap: {},
|
componentMap: { ...BUILTIN_COMPONENT_MAP },
|
||||||
colRatio: { name: 3, query: 2, value: 7 },
|
colRatio: { name: 3, query: 2, value: 7 },
|
||||||
changeEmit: 'immediate',
|
changeEmit: 'immediate',
|
||||||
|
// 默认操作符列表(深拷贝一份,避免外部修改 DEFAULT_QUERY_OPTION 常量污染配置)
|
||||||
|
queryOption: structuredClone(DEFAULT_QUERY_OPTION),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +65,18 @@ export function setConfig(patch) {
|
|||||||
globalConfig.colRatio = { ...globalConfig.colRatio, ...patch.colRatio };
|
globalConfig.colRatio = { ...globalConfig.colRatio, ...patch.colRatio };
|
||||||
}
|
}
|
||||||
if (patch.changeEmit) globalConfig.changeEmit = patch.changeEmit;
|
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() {
|
export function getConfig() {
|
||||||
@@ -28,7 +84,8 @@ export function getConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resetConfig() {
|
export function resetConfig() {
|
||||||
globalConfig.componentMap = {};
|
globalConfig.componentMap = { ...BUILTIN_COMPONENT_MAP };
|
||||||
globalConfig.colRatio = { name: 3, query: 2, value: 7 };
|
globalConfig.colRatio = { name: 3, query: 2, value: 7 };
|
||||||
globalConfig.changeEmit = 'immediate';
|
globalConfig.changeEmit = 'immediate';
|
||||||
|
globalConfig.queryOption = structuredClone(DEFAULT_QUERY_OPTION);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
import './styles/trans-form.css';
|
import './styles/trans-form.css';
|
||||||
|
|
||||||
export { default as TransFormPlugin, TransForm, setConfig, getConfig } from './plugin.js';
|
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';
|
||||||
|
|||||||
@@ -63,6 +63,67 @@
|
|||||||
font-size: 13px;
|
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 {
|
.tf-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--tf-gap);
|
gap: var(--tf-gap);
|
||||||
|
|||||||
@@ -21,11 +21,63 @@ const schema = [
|
|||||||
{
|
{
|
||||||
row: 6,
|
row: 6,
|
||||||
logic: 'OR',
|
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: 'tags',
|
||||||
'field-name': '标签',
|
'field-name': '标签',
|
||||||
|
'in-ui': 'tags',
|
||||||
'query-option': [{ value: 'IN', label: '∈', 'desc-cn': '包含任一' }],
|
'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 = {
|
export const Default = {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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 {
|
import {
|
||||||
applyFilters,
|
applyFilters,
|
||||||
buildFilters,
|
buildFilters,
|
||||||
createFieldState,
|
createFieldState,
|
||||||
groupRows,
|
groupRows,
|
||||||
|
resolveQueryOption,
|
||||||
} from '../src/composables/useFilters.js';
|
} from '../src/composables/useFilters.js';
|
||||||
|
|
||||||
|
// 每个用例后恢复全局配置,避免 setConfig 污染其它用例
|
||||||
|
afterEach(() => {
|
||||||
|
resetConfig();
|
||||||
|
});
|
||||||
|
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
row: 6,
|
row: 6,
|
||||||
@@ -68,3 +75,87 @@ describe('groupRows', () => {
|
|||||||
expect(groups[1]).toHaveLength(1);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user