chore: 版本 0.1.0 更新 between-ui/in-ui 可配项与 query-component 完整支持
Node.js Build / build (push) Successful in 6s

This commit is contained in:
2026-08-10 22:10:57 +08:00
parent e938118594
commit 701cc5026e
4 changed files with 302 additions and 8 deletions
+57 -6
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { computed, markRaw, reactive, toRaw, watch } from 'vue';
import { getConfig } from './config.js';
import {
applyFilters,
@@ -16,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));
@@ -31,10 +30,36 @@ 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;
@@ -42,6 +67,16 @@ function resolveComponent(fieldDef, kind) {
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';
@@ -110,6 +145,7 @@ 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"
@@ -123,12 +159,27 @@ defineExpose({ getFilters, setFilters, loadFilters, reset });
</option>
</select>
<component
v-else
:is="resolveComponent(fieldDef, 'query')"
v-model="state[fieldDef.field].query"
:disabled="state[fieldDef.field]?.disabled"
:read-only="state[fieldDef.field]?.readOnly"
: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="typeof fieldDef['value-component'] === 'string' ? fieldDef['value-component'] : 'custom'"
:name="resolveComponentName(fieldDef, 'value')"
:placeholder="fieldDef['field-name']"
:field="fieldDef.field"
:field-name="fieldDef['field-name']"
+2
View File
@@ -4,6 +4,8 @@
* @property {'AND'|'OR'} [logic]
* @property {string|import('vue').Component} [value-component]
* @property {string|import('vue').Component} [query-component]
* @property {string|import('vue').Component} [between-ui]
* @property {string|import('vue').Component} [in-ui]
* @property {string} field
* @property {string} [field-name]
* @property {Array<{value: string, label?: string}>} [query-option]
+1 -1
View File
@@ -48,7 +48,7 @@ const schema = [
'value-component': 'between',
field: 'created',
'field-name': '创建时间',
'between-ui': 'dual-input',
'between-ui': 'between',
'query-option': [{ value: 'BETWEEN', label: '↔', 'desc-cn': '区间' }],
},
{
+242 -1
View File
@@ -1,6 +1,11 @@
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 污染其它用例
@@ -55,6 +60,242 @@ describe('TransForm 操作符下拉(默认 query-option)', () => {
schema: [{ row: 12, field: 'custom', 'query-component': 'CustomQuery' }],
},
});
expect(wrapper.find('select').findAll('option')).toHaveLength(0);
// 配置了 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');
});
});