chore: 版本 0.1.0 更新补齐内置控件集 textarea/select/number/date/tags/between
Node.js Build / build (push) Successful in 6s
Node.js Build / build (push) Successful in 6s
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+11
-6
@@ -7,9 +7,7 @@ import {
|
|||||||
createFieldState,
|
createFieldState,
|
||||||
groupRows,
|
groupRows,
|
||||||
} 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 },
|
||||||
@@ -39,13 +37,15 @@ function resolveComponent(fieldDef, kind) {
|
|||||||
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 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());
|
||||||
}
|
}
|
||||||
@@ -125,10 +125,15 @@ defineExpose({ getFilters, setFilters, loadFilters, reset });
|
|||||||
<component
|
<component
|
||||||
:is="resolveComponent(fieldDef, 'value')"
|
:is="resolveComponent(fieldDef, 'value')"
|
||||||
v-model="state[fieldDef.field].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"
|
:read-only="state[fieldDef.field]?.readOnly"
|
||||||
:name="typeof fieldDef['value-component'] === 'string' ? fieldDef['value-component'] : 'custom'"
|
:name="typeof fieldDef['value-component'] === 'string' ? fieldDef['value-component'] : 'custom'"
|
||||||
:placeholder="fieldDef['field-name']"
|
:placeholder="fieldDef['field-name']"
|
||||||
|
:field="fieldDef.field"
|
||||||
|
:field-name="fieldDef['field-name']"
|
||||||
|
:query-option="fieldDef['query-option'] || []"
|
||||||
|
: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>
|
||||||
+24
-2
@@ -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 */
|
/** @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> }} */
|
/** @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',
|
||||||
};
|
};
|
||||||
@@ -28,7 +50,7 @@ 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
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 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': '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 = {
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user