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/adapter/vxe-table.ts b/playground/src/adapter/vxe-table.ts
index 9131d1a88..2703d984c 100644
--- a/playground/src/adapter/vxe-table.ts
+++ b/playground/src/adapter/vxe-table.ts
@@ -310,7 +310,7 @@ setupVbenVxeTable({
);
}
- const operationRenderStrategies: Recordable<() => any[]> = {
+ const operationRenderStrategies = {
button: () => btns,
menu: () => [
h(
@@ -318,9 +318,7 @@ setupVbenVxeTable({
{
getPopupContainer: () => document.body,
placement:
- column.align === 'left'
- ? 'bottomLeft'
- : 'bottomRight',
+ column.align === 'left' ? 'bottomLeft' : 'bottomRight',
trigger: ['click'],
...dropdownProps,
},
@@ -338,11 +336,9 @@ setupVbenVxeTable({
h(
'div',
{
- class:
- 'ant-dropdown-menu flex flex-col gap-1 p-1',
+ class: 'ant-dropdown-menu flex flex-col gap-1 p-1',
- onClick: (event: MouseEvent) =>
- event.stopPropagation(),
+ onClick: (event: MouseEvent) => event.stopPropagation(),
style: {
minWidth: '80px',
@@ -355,8 +351,9 @@ setupVbenVxeTable({
],
};
const renderOperations =
- operationRenderStrategies[renderMode ?? mode] ??
- operationRenderStrategies.button!;
+ (renderMode ?? mode) === 'menu'
+ ? operationRenderStrategies.menu
+ : operationRenderStrategies.button;
return h(
'div',
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 @@
+
+
+
+ ]
+ toggle(option, checked)"
+ >
+ {{ option }}
+
+
+