chore: 版本 0.1.0 更新补齐内置控件集 textarea/select/number/date/tags/between
Node.js Build / build (push) Successful in 6s

This commit is contained in:
2026-08-07 11:11:40 +08:00
parent fd5f7a0752
commit 95acff6a1a
15 changed files with 568 additions and 9 deletions
+18
View File
@@ -0,0 +1,18 @@
import js from '@eslint/js';
export default [
{ ignores: ['**/node_modules/**', '**/dist/**', '**/storybook-static/**', '.storybook/**'] },
js.configs.recommended,
{
files: ['**/*.{js,mjs}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
window: 'readonly',
document: 'readonly',
console: 'readonly',
},
},
},
];
+22
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/vue3": "^8.6.14",
"@storybook/vue3-vite": "^8.6.14",
@@ -618,6 +619,27 @@
"node": ">=18"
}
},
"node_modules/@eslint/js": {
"version": "10.0.1",
"resolved": "http://verdaccio:4873/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+1
View File
@@ -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",
+11 -6
View File
@@ -7,9 +7,7 @@ import {
createFieldState,
groupRows,
} 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 },
@@ -39,13 +37,15 @@ function resolveComponent(fieldDef, kind) {
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 isNullQuery(fieldDef) {
const q = state[fieldDef.field]?.query;
return q === 'IS_NULL' || q === 'IS_NOT_NULL';
}
function emitChange() {
emit('change', getFilters());
}
@@ -125,10 +125,15 @@ defineExpose({ getFilters, setFilters, loadFilters, reset });
<component
:is="resolveComponent(fieldDef, 'value')"
v-model="state[fieldDef.field].value"
:disabled="state[fieldDef.field]?.disabled"
:disabled="state[fieldDef.field]?.disabled || isNullQuery(fieldDef)"
:read-only="state[fieldDef.field]?.readOnly"
:name="typeof fieldDef['value-component'] === 'string' ? fieldDef['value-component'] : 'custom'"
:placeholder="fieldDef['field-name']"
:field="fieldDef.field"
:field-name="fieldDef['field-name']"
:query-option="fieldDef['query-option'] || []"
:schema="fieldDef"
:options="fieldDef.options || []"
/>
</div>
</div>
+57
View File
@@ -0,0 +1,57 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue']);
const start = computed({
get() {
return String(props.modelValue ?? '').split(',')[0] ?? '';
},
set(value) {
const endVal = String(props.modelValue ?? '').split(',')[1] ?? '';
emit('update:modelValue', `${value ?? ''},${endVal}`);
},
});
const end = computed({
get() {
return String(props.modelValue ?? '').split(',')[1] ?? '';
},
set(value) {
const startVal = String(props.modelValue ?? '').split(',')[0] ?? '';
emit('update:modelValue', `${startVal},${value ?? ''}`);
},
});
</script>
<template>
<input
class="tf-control tf-between-input"
type="text"
v-model="start"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
/>
<input
class="tf-control tf-between-input"
type="text"
v-model="end"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
/>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
defineEmits(['update:modelValue']);
</script>
<template>
<input
class="tf-control tf-date"
type="date"
:value="modelValue"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target).value)"
/>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
defineEmits(['update:modelValue']);
</script>
<template>
<input
class="tf-control tf-number"
type="number"
:value="modelValue"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target).value)"
/>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
defineEmits(['update:modelValue']);
const list = computed(() => (props.options?.length ? props.options : props.queryOption));
</script>
<template>
<select
class="tf-control tf-select"
:value="modelValue"
:disabled="disabled || readOnly"
@change="$emit('update:modelValue', ($event.target).value)"
>
<option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
<option v-for="opt in list" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</template>
+97
View File
@@ -0,0 +1,97 @@
<script setup>
import { computed, ref } from 'vue';
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue']);
const draft = ref('');
const tags = computed(() => {
if (props.modelValue == null || props.modelValue === '') return [];
return String(props.modelValue)
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
});
function emitTags(next) {
emit('update:modelValue', next.join(','));
}
function commitDraft() {
if (props.disabled || props.readOnly) return;
const parts = draft.value
.split(',')
.map((part) => part.trim())
.filter((part) => part.length > 0);
draft.value = '';
if (!parts.length) return;
emitTags([...tags.value, ...parts]);
}
function removeTag(index) {
if (props.disabled || props.readOnly) return;
const next = [...tags.value];
next.splice(index, 1);
emitTags(next);
}
function onKeydown(event) {
if (props.disabled || props.readOnly) return;
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
commitDraft();
}
}
function onInput(event) {
if (props.disabled || props.readOnly) return;
const value = event.target.value;
draft.value = value;
if (value.includes(',')) {
commitDraft();
}
}
</script>
<template>
<div class="tf-control tf-tags" :class="{ 'is-disabled': disabled }">
<span
v-for="(tag, index) in tags"
:key="`${tag}-${index}`"
class="tf-tag"
>
{{ tag }}
<button
type="button"
class="tf-tag-x"
:disabled="disabled || readOnly"
@click.stop="removeTag(index)"
>
×
</button>
</span>
<input
class="tf-tag-input"
type="text"
:value="draft"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
@input="onInput"
@keydown="onKeydown"
/>
</div>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
defineProps({
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
readOnly: { type: Boolean, default: false },
placeholder: { type: String, default: '' },
name: { type: String, default: '' },
field: { type: String, default: '' },
fieldName: { type: String, default: '' },
queryOption: { type: Array, default: () => [] },
schema: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
});
defineEmits(['update:modelValue']);
</script>
<template>
<textarea
class="tf-control tf-textarea"
:value="modelValue"
:disabled="disabled"
:readonly="readOnly"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target).value)"
></textarea>
</template>
+24 -2
View File
@@ -1,8 +1,30 @@
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,
};
/** @typedef {{ componentMap?: Record<string, import('vue').Component>, colRatio?: { name?: number, query?: number, value?: number }, changeEmit?: 'immediate'|'debounce'|'manual' }} 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',
};
@@ -28,7 +50,7 @@ export function getConfig() {
}
export function resetConfig() {
globalConfig.componentMap = {};
globalConfig.componentMap = { ...BUILTIN_COMPONENT_MAP };
globalConfig.colRatio = { name: 3, query: 2, value: 7 };
globalConfig.changeEmit = 'immediate';
}
+9
View File
@@ -1,3 +1,12 @@
import './styles/trans-form.css';
export { default as TransFormPlugin, TransForm, setConfig, getConfig } from './plugin.js';
export { default as HeadlessInput } from './components/HeadlessInput.vue';
export { default as HeadlessTextarea } from './components/HeadlessTextarea.vue';
export { default as HeadlessSelect } from './components/HeadlessSelect.vue';
export { default as HeadlessNumber } from './components/HeadlessNumber.vue';
export { default as HeadlessDate } from './components/HeadlessDate.vue';
export { default as HeadlessTags } from './components/HeadlessTags.vue';
export { default as HeadlessBetween } from './components/HeadlessBetween.vue';
export { default as UnknownPlaceholder } from './components/UnknownPlaceholder.vue';
+61
View File
@@ -63,6 +63,67 @@
font-size: 13px;
}
/* ---- tags(IN/NOT_IN 多值标签) ---- */
.tf-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
min-height: 32px;
cursor: text;
}
.tf-tags.is-disabled {
opacity: 0.65;
}
.tf-tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: #eef1f6;
border: 1px solid var(--tf-border);
border-radius: 4px;
padding: 2px 6px;
font-size: 13px;
line-height: 1.4;
}
.tf-tag-x {
border: none;
background: transparent;
color: var(--tf-label-color);
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 0 2px;
}
.tf-tag-x:hover {
color: #cf222e;
}
.tf-tag-input {
border: none;
outline: none;
background: transparent;
flex: 1;
min-width: 80px;
font-size: 13px;
padding: 2px;
}
/* ---- between(区间双输入) ---- */
.tf-between {
display: flex;
align-items: center;
gap: var(--tf-gap);
}
.tf-between-input {
flex: 1;
}
.tf-actions {
display: flex;
gap: var(--tf-gap);
+53 -1
View File
@@ -21,11 +21,63 @@ const schema = [
{
row: 6,
logic: 'OR',
'value-component': 'input',
'value-component': 'select',
field: 'status',
'field-name': '状态',
options: [
{ value: 'active', label: '启用' },
{ value: 'inactive', label: '停用' },
],
'query-option': [
{ value: 'EQ', label: '=', 'desc-cn': '等于' },
{ value: 'NE', label: '≠', 'desc-cn': '不等于' },
],
},
{
row: 4,
logic: 'AND',
'value-component': 'tags',
field: 'tags',
'field-name': '标签',
'in-ui': 'tags',
'query-option': [{ value: 'IN', label: '∈', 'desc-cn': '包含任一' }],
},
{
row: 4,
logic: 'AND',
'value-component': 'between',
field: 'created',
'field-name': '创建时间',
'between-ui': 'dual-input',
'query-option': [{ value: 'BETWEEN', label: '↔', 'desc-cn': '区间' }],
},
{
row: 4,
logic: 'AND',
'value-component': 'number',
field: 'age',
'field-name': '年龄',
'query-option': [
{ value: 'GT', label: '>', 'desc-cn': '大于' },
{ value: 'LT', label: '<', 'desc-cn': '小于' },
],
},
{
row: 6,
logic: 'AND',
'value-component': 'date',
field: 'birthday',
'field-name': '生日',
'query-option': [{ value: 'EQ', label: '=', 'desc-cn': '等于' }],
},
{
row: 6,
logic: 'AND',
'value-component': 'textarea',
field: 'remark',
'field-name': '备注',
'query-option': [{ value: 'LIKE', label: '~', 'desc-cn': '包含' }],
},
];
export const Default = {
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import HeadlessTextarea from '../src/components/HeadlessTextarea.vue';
import HeadlessSelect from '../src/components/HeadlessSelect.vue';
import HeadlessNumber from '../src/components/HeadlessNumber.vue';
import HeadlessDate from '../src/components/HeadlessDate.vue';
import HeadlessTags from '../src/components/HeadlessTags.vue';
import HeadlessBetween from '../src/components/HeadlessBetween.vue';
describe('HeadlessTextarea', () => {
it('renders textarea and emits update:modelValue', async () => {
const wrapper = mount(HeadlessTextarea, { props: { modelValue: 'hi' } });
expect(wrapper.find('textarea').element.value).toBe('hi');
await wrapper.find('textarea').setValue('hello');
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['hello']);
});
});
describe('HeadlessSelect', () => {
const options = [
{ value: 'EQ', label: '=' },
{ value: 'GT', label: '>' },
];
it('renders options from props.options', () => {
const wrapper = mount(HeadlessSelect, { props: { modelValue: 'EQ', options } });
const opts = wrapper.findAll('option');
expect(opts).toHaveLength(2);
expect(opts[0].text()).toBe('=');
});
it('falls back to queryOption when options empty', () => {
const wrapper = mount(HeadlessSelect, {
props: { modelValue: 'EQ', queryOption: options, options: [] },
});
expect(wrapper.findAll('option')).toHaveLength(2);
});
it('emits on change', async () => {
const wrapper = mount(HeadlessSelect, { props: { modelValue: 'EQ', options } });
await wrapper.find('select').setValue('GT');
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['GT']);
});
});
describe('HeadlessNumber', () => {
it('emits number input', async () => {
const wrapper = mount(HeadlessNumber, { props: { modelValue: 3 } });
expect(wrapper.find('input').attributes('type')).toBe('number');
await wrapper.find('input').setValue('7');
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['7']);
});
});
describe('HeadlessDate', () => {
it('renders date input', () => {
const wrapper = mount(HeadlessDate, { props: { modelValue: '2026-08-07' } });
expect(wrapper.find('input').attributes('type')).toBe('date');
expect(wrapper.find('input').element.value).toBe('2026-08-07');
});
});
describe('HeadlessTags', () => {
it('renders tags from comma string', () => {
const wrapper = mount(HeadlessTags, { props: { modelValue: 'a,b,c' } });
expect(wrapper.findAll('.tf-tag')).toHaveLength(3);
});
it('adds tag on Enter', async () => {
const wrapper = mount(HeadlessTags, { props: { modelValue: 'a' } });
const input = wrapper.find('input');
await input.setValue('b');
await input.trigger('keydown', { key: 'Enter' });
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual(['a,b']);
});
it('removes tag on x click', async () => {
const wrapper = mount(HeadlessTags, { props: { modelValue: 'a,b' } });
await wrapper.findAll('.tf-tag-x')[0].trigger('click');
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual(['b']);
});
it('respects disabled', async () => {
const wrapper = mount(HeadlessTags, { props: { modelValue: 'a', disabled: true } });
await wrapper.find('input').trigger('keydown', { key: 'Enter' });
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
});
});
describe('HeadlessBetween', () => {
it('splits modelValue into two inputs', () => {
const wrapper = mount(HeadlessBetween, { props: { modelValue: '1,10' } });
const inputs = wrapper.findAll('input');
expect(inputs).toHaveLength(2);
expect(inputs[0].element.value).toBe('1');
expect(inputs[1].element.value).toBe('10');
});
it('emits combined string on start change', async () => {
const wrapper = mount(HeadlessBetween, { props: { modelValue: '1,10' } });
await wrapper.findAll('input')[0].setValue('5');
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual(['5,10']);
});
});