diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..a540ef0 --- /dev/null +++ b/eslint.config.js @@ -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', + }, + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index 21ab167..634db5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 95a4d0a..2ecac79 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/TransForm.vue b/src/TransForm.vue index a7ac6e4..b7944bd 100644 --- a/src/TransForm.vue +++ b/src/TransForm.vue @@ -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 }); diff --git a/src/components/HeadlessBetween.vue b/src/components/HeadlessBetween.vue new file mode 100644 index 0000000..a623828 --- /dev/null +++ b/src/components/HeadlessBetween.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/components/HeadlessDate.vue b/src/components/HeadlessDate.vue new file mode 100644 index 0000000..61ea926 --- /dev/null +++ b/src/components/HeadlessDate.vue @@ -0,0 +1,26 @@ + + diff --git a/src/components/HeadlessNumber.vue b/src/components/HeadlessNumber.vue new file mode 100644 index 0000000..3f0f850 --- /dev/null +++ b/src/components/HeadlessNumber.vue @@ -0,0 +1,26 @@ + + diff --git a/src/components/HeadlessSelect.vue b/src/components/HeadlessSelect.vue new file mode 100644 index 0000000..6871b80 --- /dev/null +++ b/src/components/HeadlessSelect.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/HeadlessTags.vue b/src/components/HeadlessTags.vue new file mode 100644 index 0000000..09e2392 --- /dev/null +++ b/src/components/HeadlessTags.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/components/HeadlessTextarea.vue b/src/components/HeadlessTextarea.vue new file mode 100644 index 0000000..020f6ad --- /dev/null +++ b/src/components/HeadlessTextarea.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/config.js b/src/config.js index 1da2748..63b406d 100644 --- a/src/config.js +++ b/src/config.js @@ -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, colRatio?: { name?: number, query?: number, value?: number }, changeEmit?: 'immediate'|'debounce'|'manual' }} TransFormConfig */ /** @type {TransFormConfig & { componentMap: Record }} */ 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'; } diff --git a/src/index.js b/src/index.js index b19c39b..3f6065f 100644 --- a/src/index.js +++ b/src/index.js @@ -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'; diff --git a/src/styles/trans-form.css b/src/styles/trans-form.css index 90d0fe7..6ef1586 100644 --- a/src/styles/trans-form.css +++ b/src/styles/trans-form.css @@ -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); diff --git a/stories/TransForm.stories.js b/stories/TransForm.stories.js index d7e519e..8a4a7de 100644 --- a/stories/TransForm.stories.js +++ b/stories/TransForm.stories.js @@ -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 = { diff --git a/test/controls.test.js b/test/controls.test.js new file mode 100644 index 0000000..4c4b41e --- /dev/null +++ b/test/controls.test.js @@ -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']); + }); +});