From 1abb1d5f707189caef312c885c63dc7aa60594b1 Mon Sep 17 00:00:00 2001 From: Zehui Chan Date: Mon, 7 Sep 2026 15:38:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(@vben-core/form-ui):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20useCustomFieldValue=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E6=8E=A7=E4=BB=B6=E5=8F=96=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让插槽 / 复合组件在内部注册取值函数,把值交给所在表单项做校验; 值仍归表单所有,setValues、重置继续通过 modelValue 流回组件。 开启 deep 时表单里存的是值的副本,原地修改同一个对象也能被识别。 close #8381 Co-authored-by: Cursor --- .changeset/custom-field-value.md | 5 + docs/src/components/common-ui/vben-form.md | 56 ++++ docs/src/en/components/common-ui/vben-form.md | 34 ++ .../__tests__/use-custom-field-value.test.ts | 303 ++++++++++++++++++ .../form-ui/src/form-render/form-field.vue | 27 +- packages/@core/ui-kit/form-ui/src/index.ts | 7 + packages/@core/ui-kit/form-ui/src/types.ts | 10 +- .../form-ui/src/use-custom-field-value.ts | 124 +++++++ playground/src/views/examples/form/custom.vue | 15 + .../examples/form/modules/tag-picker.vue | 37 +++ 10 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 .changeset/custom-field-value.md create mode 100644 packages/@core/ui-kit/form-ui/__tests__/use-custom-field-value.test.ts create mode 100644 packages/@core/ui-kit/form-ui/src/use-custom-field-value.ts create mode 100644 playground/src/views/examples/form/modules/tag-picker.vue diff --git a/.changeset/custom-field-value.md b/.changeset/custom-field-value.md new file mode 100644 index 000000000..5258a75d5 --- /dev/null +++ b/.changeset/custom-field-value.md @@ -0,0 +1,5 @@ +--- +'@vben-core/form-ui': minor +--- + +feat: add useCustomFieldValue for custom controls inside form fields diff --git a/docs/src/components/common-ui/vben-form.md b/docs/src/components/common-ui/vben-form.md index 29f616d49..d40237090 100644 --- a/docs/src/components/common-ui/vben-form.md +++ b/docs/src/components/common-ui/vben-form.md @@ -854,3 +854,59 @@ import { z } from '#/adapter/form'; `field`、`componentField`、`modelValue`、`name`、`disabled`、`isInValid`、`values` 和 `formApi` 保留在 slot 根级,供模板逻辑使用,不会自动传入实际控件。 ::: + +## useCustomFieldValue + +组件的值不落在单个控件上时(例如内部用若干控件拼出来的复合组件、第三方组件),表单项拿不到它的值,schema 上的 `rules` 也就无从校验。这类组件可以在自身内部调用 `useCustomFieldValue`,把取值函数交给外层表单项,无需层层透传 props。 + +```vue + + +``` + +```vue +
+ +
+``` + +取值函数的结果变化时,值会写回表单字段、清空该字段的校验状态,并按表单项的 `validateOn` 触发一次校验。值与表单当前值一致时(`setValues`、重置下发的值经组件流回来)不重复写回,也不触发校验;开启 `deep` 后表单里存的是值的副本,组件原地改同一个对象也照样能识别出变化。同一个表单项只接受一个取值函数,重复注册会被忽略并在控制台告警。 + +::: warning 保持组件受控 + +组件的值要继续走 `modelValue`(插槽里就是 `v-bind="slotProps.componentProps"`),这样 `setValues`、重置才能顺着 props 流回组件。schema 里 `component` 写成字符串时,模型属性名由适配器决定(antdv 是 `value`),插槽组件用标准 `modelValue` 的话需要显式声明 `modelPropName: 'modelValue'`,否则组件收不到表单下发的值,点重置就只清空了表单里的值、组件界面上还留着旧的选中态。 + +只有完全自持内部状态、不接受外部值的组件,才需要用返回的 `value` 自行同步。 + +::: + +### 参数 + +| 参数 | 描述 | 类型 | 默认值 | +| --- | --- | --- | --- | +| customValue | 取值函数,返回该字段的值 | `() => T` | - | +| options | 配置项,见下表 | `UseCustomFieldValueOptions` | `{}` | + +| 配置项 | 描述 | 类型 | 默认值 | +| --------- | ------------------------------------ | --------- | ------- | +| deep | 取值为对象/数组且原地修改时开启 | `boolean` | `false` | +| immediate | 挂载时把当前值写入表单(不触发校验) | `boolean` | `false` | + +### 返回值 + +| 名称 | 描述 | 类型 | +| --- | --- | --- | +| value | 表单中该字段的值,可用于响应 `setValues`、`resetForm` | `ComputedRef` | +| error | 该表单项当前的校验错误 | `Ref` | +| disabled | 该表单项的禁用态(含表单级、schema 级、联动计算) | `ComputedRef` | +| fieldName | 所在表单项的字段名 | `string \| undefined` | +| resetValidation | 清除该表单项的校验状态 | `() => void` | diff --git a/docs/src/en/components/common-ui/vben-form.md b/docs/src/en/components/common-ui/vben-form.md index ef9528fbc..159e5b17b 100644 --- a/docs/src/en/components/common-ui/vben-form.md +++ b/docs/src/en/components/common-ui/vben-form.md @@ -321,6 +321,40 @@ formApi.updateSchema([{ fieldName: 'remark', label: 'Description' }]); - A collapsed group expands automatically when one of its fields fails validation. - Groups are single-level: `children` only accepts fields, and array-field `children` cannot contain groups either. +## useCustomFieldValue + +When a component's value does not live on a single control (a composite built from several controls, a third-party component), the enclosing field cannot read it and the schema `rules` have nothing to validate. Such a component can call `useCustomFieldValue` internally to hand its value getter to the enclosing field, without drilling props through the slot: + +```vue + +``` + +```vue +
+ +
+``` + +Every getter change writes the value back to the field, clears its validation state, and revalidates according to the field `validateOn`. A getter value that already equals the current field value (the case when `setValues` or a reset flows through the component) is neither written back nor validated; with `deep` the form stores a copy of the value, so mutating the same object in place is still detected. Options are `deep` (getter returns an object or array mutated in place) and `immediate` (write the current value on mount without validating). A field accepts one getter only; later registrations are ignored with a console warning. + +::: warning Keep the component controlled + +The value must keep flowing in through `modelValue` (in a slot that means `v-bind="slotProps.componentProps"`) so `setValues` and reset reach the component as props. When `component` is a string, the model prop name comes from the adapter (`value` for antdv), so a slot component using the standard `modelValue` needs an explicit `modelPropName: 'modelValue'` — otherwise reset only clears the form value while the component keeps rendering the stale one. + +Only components that fully own their internal state need to sync from the returned `value`. + +::: + ## Form Codec Use the form-level `codec` when component values and the backend payload have different shapes. `encode` converts the complete `TFormValues` object to `TSubmitValues`; `decode` performs the inverse conversion. Multi-field splits and merges are atomic and do not depend on schema order or string-path writes. diff --git a/packages/@core/ui-kit/form-ui/__tests__/use-custom-field-value.test.ts b/packages/@core/ui-kit/form-ui/__tests__/use-custom-field-value.test.ts new file mode 100644 index 000000000..7cb3ccf7f --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/use-custom-field-value.test.ts @@ -0,0 +1,303 @@ +import type { VueWrapper } from '@vue/test-utils'; +import type { PropType } from 'vue'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h, reactive, ref } from 'vue'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { useCustomFieldValue } from '../src/use-custom-field-value'; +import { useVbenForm } from '../src/use-vben-form'; + +const wrappers: VueWrapper[] = []; + +// 字段的组件由插槽接管,schema 上只需要一个占位组件 +const Placeholder = () => h('span'); + +// 内部维护选中项、不接收 modelValue 的复合组件 +const TagPicker = defineComponent({ + props: { + immediate: { default: false, type: Boolean }, + }, + setup(props) { + const tags = ref([]); + const { disabled, error, fieldName, value } = useCustomFieldValue( + () => [...tags.value], + { immediate: props.immediate }, + ); + + return () => + h( + 'button', + { + class: 'tag-picker', + 'data-disabled': String(disabled.value), + 'data-error': error.value ?? '', + 'data-field-name': fieldName ?? '', + 'data-value': JSON.stringify(value.value ?? null), + onClick: () => { + tags.value = [...tags.value, `tag-${tags.value.length}`]; + }, + }, + 'add', + ); + }, +}); + +// 原地修改同一个数组的复合组件,只有 deep 才能感知到变化 +const DeepTagPicker = defineComponent({ + setup() { + const tags = reactive([]); + const { error, value } = useCustomFieldValue(() => tags, { deep: true }); + + return () => + h( + 'button', + { + class: 'tag-picker', + 'data-error': error.value ?? '', + 'data-value': JSON.stringify(value.value ?? null), + onClick: () => { + tags.push(`tag-${tags.length}`); + }, + }, + 'add', + ); + }, +}); + +// 值由表单通过 modelValue 下发的复合组件,取值函数只服务于校验 +const BoundTagPicker = defineComponent({ + props: { + modelValue: { default: () => [], type: Array as PropType }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + const { error, value } = useCustomFieldValue(() => props.modelValue); + + return () => + h( + 'button', + { + class: 'tag-picker', + 'data-error': error.value ?? '', + 'data-value': JSON.stringify(value.value ?? null), + onClick: () => { + emit('update:modelValue', [ + ...props.modelValue, + `tag-${props.modelValue.length}`, + ]); + }, + }, + 'add', + ); + }, +}); + +function mountTagForm( + options: { + formFieldProps?: Record; + immediate?: boolean; + } = {}, +) { + const [Form, formApi] = useVbenForm({ + schema: [ + { + component: Placeholder, + defaultValue: [], + fieldName: 'tags', + formFieldProps: options.formFieldProps, + label: 'Tags', + rules: z.array(z.string()).max(1, 'Too many tags'), + }, + ], + }); + const wrapper = mount(Form, { + slots: { + tags: () => h(TagPicker, { immediate: options.immediate ?? false }), + }, + }); + wrappers.push(wrapper); + return { formApi, wrapper }; +} + +function mountDeepTagForm() { + const [Form, formApi] = useVbenForm({ + schema: [ + { + component: Placeholder, + defaultValue: [], + fieldName: 'tags', + label: 'Tags', + rules: z.array(z.string()).max(1, 'Too many tags'), + }, + ], + }); + const wrapper = mount(Form, { + slots: { + tags: () => h(DeepTagPicker), + }, + }); + wrappers.push(wrapper); + return { formApi, wrapper }; +} + +function mountBoundTagForm() { + const [Form, formApi] = useVbenForm({ + schema: [ + { + component: Placeholder, + defaultValue: [], + fieldName: 'tags', + label: 'Tags', + rules: z.array(z.string()).min(1, 'Pick at least one tag'), + }, + ], + }); + const wrapper = mount(Form, { + slots: { + tags: (slotProps: any) => h(BoundTagPicker, slotProps.componentProps), + }, + }); + wrappers.push(wrapper); + return { formApi, wrapper }; +} + +afterEach(() => { + for (const wrapper of wrappers.splice(0)) { + wrapper.unmount(); + } + vi.restoreAllMocks(); +}); + +describe('useCustomFieldValue', () => { + it('writes the custom value into the form and validates on change', async () => { + const { formApi, wrapper } = mountTagForm(); + await flushPromises(); + + await wrapper.get('.tag-picker').trigger('click'); + await flushPromises(); + + expect(await formApi.getValues()).toEqual({ tags: ['tag-0'] }); + expect(formApi.form.getFieldError('tags')).toBeUndefined(); + + await wrapper.get('.tag-picker').trigger('click'); + await flushPromises(); + + expect(await formApi.getValues()).toEqual({ tags: ['tag-0', 'tag-1'] }); + expect(formApi.form.getFieldError('tags')).toBe('Too many tags'); + }); + + it('validates every in-place mutation when deep is enabled', async () => { + const { formApi, wrapper } = mountDeepTagForm(); + await flushPromises(); + + await wrapper.get('.tag-picker').trigger('click'); + await flushPromises(); + + expect(await formApi.getValues()).toEqual({ tags: ['tag-0'] }); + expect(formApi.form.getFieldError('tags')).toBeUndefined(); + + await wrapper.get('.tag-picker').trigger('click'); + await flushPromises(); + + expect(await formApi.getValues()).toEqual({ tags: ['tag-0', 'tag-1'] }); + expect(formApi.form.getFieldError('tags')).toBe('Too many tags'); + }); + + it('exposes the field state to the custom component', async () => { + const { formApi, wrapper } = mountTagForm(); + await flushPromises(); + + const picker = wrapper.get('.tag-picker'); + expect(picker.attributes('data-field-name')).toBe('tags'); + expect(picker.attributes('data-disabled')).toBe('false'); + + await formApi.setFieldValue('tags', ['from-form']); + await flushPromises(); + + expect(picker.attributes('data-value')).toBe('["from-form"]'); + }); + + it('lets reset flow back into a form-driven component without validating', async () => { + const { formApi, wrapper } = mountBoundTagForm(); + await flushPromises(); + + await wrapper.get('.tag-picker').trigger('click'); + await flushPromises(); + + expect(await formApi.getValues()).toEqual({ tags: ['tag-0'] }); + expect(wrapper.get('.tag-picker').attributes('data-value')).toBe( + '["tag-0"]', + ); + + await formApi.reset(); + await flushPromises(); + + expect(await formApi.getValues()).toEqual({ tags: [] }); + expect(wrapper.get('.tag-picker').attributes('data-value')).toBe('[]'); + expect(formApi.form.getFieldError('tags')).toBeUndefined(); + }); + + it('respects validateOn and skips validation on change', async () => { + const { formApi, wrapper } = mountTagForm({ + formFieldProps: { validateOn: [] }, + }); + await flushPromises(); + + await wrapper.get('.tag-picker').trigger('click'); + await wrapper.get('.tag-picker').trigger('click'); + await flushPromises(); + + expect(formApi.form.getFieldError('tags')).toBeUndefined(); + expect(await formApi.validate()).toEqual({ + errors: { tags: 'Too many tags' }, + valid: false, + }); + }); + + it('writes the initial value on mount when immediate is enabled', async () => { + const { formApi } = mountTagForm({ immediate: true }); + await flushPromises(); + + expect(await formApi.getValues()).toEqual({ tags: [] }); + expect(formApi.form.getFieldError('tags')).toBeUndefined(); + }); + + it('keeps the first getter when a field registers twice', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const [Form] = useVbenForm({ + schema: [ + { + component: Placeholder, + defaultValue: [], + fieldName: 'tags', + }, + ], + }); + const wrapper = mount(Form, { + slots: { + tags: () => [h(TagPicker), h(TagPicker)], + }, + }); + wrappers.push(wrapper); + await flushPromises(); + + expect(warn).toHaveBeenCalledWith( + '表单项 tags 已存在自定义取值函数,本次注册被忽略', + ); + }); + + it('warns when used outside a form field', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const wrapper = mount(TagPicker); + wrappers.push(wrapper); + await flushPromises(); + + expect(warn).toHaveBeenCalledWith( + 'useCustomFieldValue 只能在 VbenForm 的表单项内部使用', + ); + expect(wrapper.get('.tag-picker').attributes('data-field-name')).toBe(''); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue index 4358fb9a2..cd24b2f50 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue +++ b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue @@ -15,6 +15,7 @@ import { nextTick, onUnmounted, ref, + shallowRef, toRaw, useTemplateRef, watch, @@ -35,6 +36,7 @@ import { import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils'; import { getFormRule } from '../rule-registry'; +import { provideFormCustomField } from '../use-custom-field-value'; import { injectComponentRefMap } from '../use-form-context'; import { injectRenderFormProps, useFormContext } from './context'; import useDependencies from './dependencies'; @@ -216,15 +218,18 @@ async function validateFieldValue({ value }: { value: any }) { return result.success ? undefined : result.error.issues[0]?.message; } +const validateTriggers = computed( + () => new Set(formFieldProps?.validateOn ?? ['blur', 'change']), +); + const fieldValidators = computed(() => { const validators: Record = { onSubmitAsync: validateFieldValue, }; - const validateOn = new Set(formFieldProps?.validateOn ?? ['blur', 'change']); - if (validateOn.has('blur')) { + if (validateTriggers.value.has('blur')) { validators.onBlurAsync = validateFieldValue; } - if (validateOn.has('change')) { + if (validateTriggers.value.has('change')) { validators.onChangeAsync = validateFieldValue; } return validators; @@ -268,6 +273,22 @@ const shouldDisabled = computed(() => { return Boolean(isDisabled.value || disabled || computedProps.value?.disabled); }); +// 插槽里的自定义组件既无 componentProps 绑定也无 modelValue 时,靠 useCustomFieldValue 回写值 +provideFormCustomField({ + customValue: shallowRef(), + disabled: shouldDisabled, + error, + fieldName, + resetValidation: () => getFormApi().setFieldError(fieldName), + setValue: (value: any) => getFormApi().setFieldValue(fieldName, value, false), + validateWithTrigger: (trigger) => { + if (validateTriggers.value.has(trigger)) { + void getFormApi().validateField(fieldName); + } + }, + value: fieldValue, +}); + const customContentRender = computed(() => { if (dynamicRenderComponentContentResolved.value) { return dynamicRenderComponentContent.value ?? {}; diff --git a/packages/@core/ui-kit/form-ui/src/index.ts b/packages/@core/ui-kit/form-ui/src/index.ts index e71d15711..62547a9ec 100644 --- a/packages/@core/ui-kit/form-ui/src/index.ts +++ b/packages/@core/ui-kit/form-ui/src/index.ts @@ -27,6 +27,13 @@ export type { VbenFormSlots, } from './types'; +export { useCustomFieldValue } from './use-custom-field-value'; + +export type { + UseCustomFieldValueOptions, + UseCustomFieldValueReturn, +} from './use-custom-field-value'; + export * from './use-vben-form'; // export { default as VbenForm } from './vben-form.vue'; export * as z from 'zod'; diff --git a/packages/@core/ui-kit/form-ui/src/types.ts b/packages/@core/ui-kit/form-ui/src/types.ts index 8bcf2ffe5..8e52dd292 100644 --- a/packages/@core/ui-kit/form-ui/src/types.ts +++ b/packages/@core/ui-kit/form-ui/src/types.ts @@ -269,14 +269,20 @@ export interface VbenFormFieldSlotProps< name: TFieldName; } +/** + * 表单值带索引签名时字段值解析不出具体类型(`unknown`),此处放宽成 `any`: + * 否则 `v-bind="slotProps.componentProps"` 喂不进任何声明了具体 model 类型的组件。 + */ +type LooseFieldValue = unknown extends TValue ? any : TValue; + export type VbenFormResolvedComponentProps< TValue = unknown, TFieldName extends string = string, > = MaybeComponentProps & { disabled: boolean; - modelValue?: TValue; + modelValue?: LooseFieldValue; name: TFieldName; - 'onUpdate:modelValue'?: (value: TValue) => void; + 'onUpdate:modelValue'?: (value: LooseFieldValue) => void; }; type VbenFormFieldSlots< diff --git a/packages/@core/ui-kit/form-ui/src/use-custom-field-value.ts b/packages/@core/ui-kit/form-ui/src/use-custom-field-value.ts new file mode 100644 index 000000000..4e3ffa0be --- /dev/null +++ b/packages/@core/ui-kit/form-ui/src/use-custom-field-value.ts @@ -0,0 +1,124 @@ +import type { ComputedRef, InjectionKey, Ref, ShallowRef } from 'vue'; + +import type { FormValidationTrigger } from './types'; + +import { computed, inject, onUnmounted, provide, toRaw, watch } from 'vue'; + +import { cloneDeep, isEqual } from '@vben-core/shared/utils'; + +export interface FormCustomFieldContext { + /** 已注册的取值函数,同一表单项只接受一个 */ + customValue: ShallowRef<(() => any) | undefined>; + disabled: ComputedRef; + error: Readonly>; + fieldName: string; + resetValidation: () => void; + setValue: (value: any) => void; + validateWithTrigger: (trigger: FormValidationTrigger) => void; + value: Readonly>; +} + +export interface UseCustomFieldValueOptions { + /** 取值为对象/数组且原地修改时开启 */ + deep?: boolean; + /** 挂载时把当前值写入表单(不触发校验) */ + immediate?: boolean; +} + +export interface UseCustomFieldValueReturn { + /** 表单项的禁用态(含表单级、schema 级、依赖计算) */ + disabled: ComputedRef; + /** 表单项当前的校验错误 */ + error: Readonly>; + /** 所在表单项的字段名,不在表单项内时为 undefined */ + fieldName: string | undefined; + /** 清除该表单项的校验状态 */ + resetValidation: () => void; + /** 表单中该字段的值,可用于响应 setValues / resetForm */ + value: ComputedRef; +} + +const CUSTOM_FIELD_INJECTION_KEY: InjectionKey = Symbol( + 'VbenFormCustomField', +); + +export function provideFormCustomField(context: FormCustomFieldContext) { + provide(CUSTOM_FIELD_INJECTION_KEY, context); +} + +/** + * 让 VbenForm 表单项内的自定义组件把自己的值交给表单。 + * + * 组件既没有绑定 `componentProps`(插槽用法),也没有实现 `modelValue` 时, + * 表单拿不到它的值,schema 上的 rules 也就无从校验。调用该函数后,取值函数的 + * 结果会写回表单字段,并按表单项的 `validateOn` 触发校验。 + * + * 值仍归表单所有:组件应继续用 `modelValue` 接收表单下发的值(插槽用法就是 + * `v-bind="slotProps.componentProps"`),`setValues`、重置才能顺着 props 流回组件。 + * 只有完全自持内部状态的组件,才需要用返回的 `value` 自行同步。 + */ +export function useCustomFieldValue( + customValue: () => T, + options: UseCustomFieldValueOptions = {}, +): UseCustomFieldValueReturn { + const field = inject(CUSTOM_FIELD_INJECTION_KEY, null); + + if (!field) { + console.warn('useCustomFieldValue 只能在 VbenForm 的表单项内部使用'); + return { + disabled: computed(() => false), + error: computed(() => undefined), + fieldName: undefined, + resetValidation: () => {}, + value: computed(() => undefined), + }; + } + + // 一个字段只能有一个值来源,后来者会互相覆盖 + if (field.customValue.value) { + console.warn( + `表单项 ${field.fieldName} 已存在自定义取值函数,本次注册被忽略`, + ); + } else { + field.customValue.value = customValue; + + onUnmounted(() => { + if (field.customValue.value === customValue) { + field.customValue.value = undefined; + } + }); + + // deep 时组件原地改的就是这个对象,存一份副本进表单, + // 下次比较才不是拿它跟自己比,原地改动也就不会被当成没变 + const toFormValue = (value: T) => + options.deep ? cloneDeep(toRaw(value)) : value; + + if (options.immediate) { + field.setValue(toFormValue(customValue())); + } + + watch( + customValue, + (value) => { + // 组件由表单驱动(v-model / componentProps)时,setValues、重置下发的新值 + // 会经组件再流回这里,此时值与表单一致:不重复写回,也不触发校验, + // 否则一点重置就立刻冒出必填错误 + if (isEqual(value, toRaw(field.value.value))) { + return; + } + field.setValue(toFormValue(value)); + field.resetValidation(); + field.validateWithTrigger('change'); + }, + { deep: options.deep }, + ); + } + + return { + disabled: field.disabled, + error: field.error, + fieldName: field.fieldName, + resetValidation: field.resetValidation, + value: computed(() => field.value.value as T | undefined), + }; +} diff --git a/playground/src/views/examples/form/custom.vue b/playground/src/views/examples/form/custom.vue index 13453f345..827352cd3 100644 --- a/playground/src/views/examples/form/custom.vue +++ b/playground/src/views/examples/form/custom.vue @@ -7,6 +7,7 @@ import { Button, Card, Input, message, Select } from 'antdv-next'; import { useVbenForm, z } from '#/adapter/form'; +import TagPicker from './modules/tag-picker.vue'; import TwoFields from './modules/two-fields.vue'; interface CustomFormValues extends Record { @@ -16,6 +17,7 @@ interface CustomFormValues extends Record { field3?: string; field4?: [string | undefined, string | undefined]; field5?: string; + field6?: string[]; } function encodeCustomFormValues(values: Readonly) { @@ -118,6 +120,15 @@ const [Form, formApi] = useVbenForm({ label: '动态组件', modelPropName: 'value', }, + { + component: 'Input', + defaultValue: [], + fieldName: 'field6', + label: '自定义取值(slot)', + // 插槽里的 TagPicker 用的是标准 modelValue,不能套用 antdv 的 v-model:value + modelPropName: 'modelValue', + rules: z.array(z.string()).min(1, '请至少选择一个标签'), + }, ], // 中屏一行显示2个,小屏一行显示1个 wrapperClass: 'grid-cols-1 md:grid-cols-2', @@ -179,6 +190,10 @@ function onSubmit(values: CustomSubmitValues) { + + diff --git a/playground/src/views/examples/form/modules/tag-picker.vue b/playground/src/views/examples/form/modules/tag-picker.vue new file mode 100644 index 000000000..e9d45cf5e --- /dev/null +++ b/playground/src/views/examples/form/modules/tag-picker.vue @@ -0,0 +1,37 @@ + + +