Browse Source

Merge 51757106fd into d7768e8a46

pull/8382/merge
Zehui Chan 3 weeks ago
committed by GitHub
parent
commit
6595b41a98
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/custom-field-value.md
  2. 56
      docs/src/components/common-ui/vben-form.md
  3. 34
      docs/src/en/components/common-ui/vben-form.md
  4. 303
      packages/@core/ui-kit/form-ui/__tests__/use-custom-field-value.test.ts
  5. 27
      packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
  6. 7
      packages/@core/ui-kit/form-ui/src/index.ts
  7. 10
      packages/@core/ui-kit/form-ui/src/types.ts
  8. 124
      packages/@core/ui-kit/form-ui/src/use-custom-field-value.ts
  9. 17
      playground/src/adapter/vxe-table.ts
  10. 15
      playground/src/views/examples/form/custom.vue
  11. 37
      playground/src/views/examples/form/modules/tag-picker.vue

5
.changeset/custom-field-value.md

@ -0,0 +1,5 @@
---
'@vben-core/form-ui': minor
---
feat: add useCustomFieldValue for custom controls inside form fields

56
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
<!-- tag-picker.vue -->
<script lang="ts" setup>
import { useCustomFieldValue } from '@vben/common-ui';
// 值仍归表单所有:表单通过 modelValue 下发,组件只负责 emit 出去
const modelValue = defineModel<string[]>({ default: () => [] });
const { disabled, error } = useCustomFieldValue(() => modelValue.value);
</script>
```
```vue
<Form>
<template #tags="slotProps">
<TagPicker v-bind="slotProps.componentProps" />
</template>
</Form>
```
取值函数的结果变化时,值会写回表单字段、清空该字段的校验状态,并按表单项的 `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<T \| undefined>` |
| error | 该表单项当前的校验错误 | `Ref<string \| undefined>` |
| disabled | 该表单项的禁用态(含表单级、schema 级、联动计算) | `ComputedRef<boolean>` |
| fieldName | 所在表单项的字段名 | `string \| undefined` |
| resetValidation | 清除该表单项的校验状态 | `() => void` |

34
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
<script lang="ts" setup>
import { useCustomFieldValue } from '@vben/common-ui';
// The form still owns the value: it comes down through modelValue and the
// component only emits changes back
const modelValue = defineModel<string[]>({ default: () => [] });
const { disabled, error } = useCustomFieldValue(() => modelValue.value);
</script>
```
```vue
<Form>
<template #tags="slotProps">
<TagPicker v-bind="slotProps.componentProps" />
</template>
</Form>
```
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.

303
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<string[]>([]);
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<string[]>([]);
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<string[]> },
},
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<string, any>;
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('');
});
});

27
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<string, typeof validateFieldValue> = {
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 ?? {};

7
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';

10
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<TValue> = unknown extends TValue ? any : TValue;
export type VbenFormResolvedComponentProps<
TValue = unknown,
TFieldName extends string = string,
> = MaybeComponentProps & {
disabled: boolean;
modelValue?: TValue;
modelValue?: LooseFieldValue<TValue>;
name: TFieldName;
'onUpdate:modelValue'?: (value: TValue) => void;
'onUpdate:modelValue'?: (value: LooseFieldValue<TValue>) => void;
};
type VbenFormFieldSlots<

124
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<boolean>;
error: Readonly<Ref<string | undefined>>;
fieldName: string;
resetValidation: () => void;
setValue: (value: any) => void;
validateWithTrigger: (trigger: FormValidationTrigger) => void;
value: Readonly<Ref<any>>;
}
export interface UseCustomFieldValueOptions {
/** 取值为对象/数组且原地修改时开启 */
deep?: boolean;
/** 挂载时把当前值写入表单(不触发校验) */
immediate?: boolean;
}
export interface UseCustomFieldValueReturn<T> {
/** 表单项的禁用态(含表单级、schema 级、依赖计算) */
disabled: ComputedRef<boolean>;
/** 表单项当前的校验错误 */
error: Readonly<Ref<string | undefined>>;
/** 所在表单项的字段名,不在表单项内时为 undefined */
fieldName: string | undefined;
/** 清除该表单项的校验状态 */
resetValidation: () => void;
/** 表单中该字段的值,可用于响应 setValues / resetForm */
value: ComputedRef<T | undefined>;
}
const CUSTOM_FIELD_INJECTION_KEY: InjectionKey<FormCustomFieldContext> = 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<T = any>(
customValue: () => T,
options: UseCustomFieldValueOptions = {},
): UseCustomFieldValueReturn<T> {
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),
};
}

17
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',

15
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<string, unknown> {
@ -16,6 +17,7 @@ interface CustomFormValues extends Record<string, unknown> {
field3?: string;
field4?: [string | undefined, string | undefined];
field5?: string;
field6?: string[];
}
function encodeCustomFormValues(values: Readonly<CustomFormValues>) {
@ -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) {
<template #field3="slotProps">
<Input placeholder="请输入" v-bind="slotProps.componentProps" />
</template>
<!-- 值走 componentProps 的 modelValue,校验值由组件内 useCustomFieldValue 提供 -->
<template #field6="slotProps">
<TagPicker v-bind="slotProps.componentProps" />
</template>
</Form>
</Card>
</Page>

37
playground/src/views/examples/form/modules/tag-picker.vue

@ -0,0 +1,37 @@
<script lang="ts" setup>
import { useCustomFieldValue } from '@vben/common-ui';
import { CheckableTag } from 'antdv-next';
const options = ['前端', '后端', '运维', '测试'];
// 值归表单所有:表单通过 componentProps 的 modelValue 下发,组件只负责 emit 回去,
// 所以 setValues、重置能顺着 props 流回来,组件里不必再存一份选中项
const modelValue = defineModel<string[]>({ default: () => [] });
// 选中项不落在单个原生控件上,靠 useCustomFieldValue 把它交给表单项做校验
const { disabled } = useCustomFieldValue(() => modelValue.value);
function toggle(option: string, checked: boolean) {
if (disabled.value) {
return;
}
modelValue.value = checked
? [...modelValue.value, option]
: modelValue.value.filter((item) => item !== option);
}
</script>
<template>
<div class="flex w-full items-center gap-1">
<CheckableTag
v-for="option in options"
:key="option"
:checked="modelValue.includes(option)"
:disabled="disabled"
@update:checked="(checked: boolean) => toggle(option, checked)"
>
{{ option }}
</CheckableTag>
</div>
</template>
Loading…
Cancel
Save