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.
|
||||
|
||||
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",
|
||||
"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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 {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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user