Browse Source

feat(@vben-core/form-ui): support grouped schema for collapsible sections

Add `type: 'group'` to `FormSchema`, parallel to `type: 'array'`, so a set
of fields can be organised into a collapsible section without turning the
group itself into a field.

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/8379/head
Zehui Chan 3 weeks ago
parent
commit
483fc15765
  1. 5
      .changeset/grouped-form-sections.md
  2. 63
      docs/src/components/common-ui/vben-form.md
  3. 30
      docs/src/en/components/common-ui/vben-form.md
  4. 93
      packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts
  5. 189
      packages/@core/ui-kit/form-ui/__tests__/form-group.test.ts
  6. 78
      packages/@core/ui-kit/form-ui/__tests__/form-schema.test.ts
  7. 54
      packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts
  8. 23
      packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts
  9. 6
      packages/@core/ui-kit/form-ui/src/components/form-field-array.vue
  10. 32
      packages/@core/ui-kit/form-ui/src/form-api.ts
  11. 107
      packages/@core/ui-kit/form-ui/src/form-render/form-group.vue
  12. 122
      packages/@core/ui-kit/form-ui/src/form-render/form.vue
  13. 107
      packages/@core/ui-kit/form-ui/src/form-render/schema.ts
  14. 17
      packages/@core/ui-kit/form-ui/src/form-value-transform.ts
  15. 3
      packages/@core/ui-kit/form-ui/src/index.ts
  16. 61
      packages/@core/ui-kit/form-ui/src/types.ts
  17. 3
      packages/@core/ui-kit/form-ui/src/use-form-context.ts
  18. 5
      packages/@core/ui-kit/form-ui/src/vben-use-form.vue
  19. 132
      playground/src/views/examples/form/collapsible.vue

5
.changeset/grouped-form-sections.md

@ -0,0 +1,5 @@
---
'@vben-core/form-ui': minor
---
feat: support grouped schema for collapsible sections

63
docs/src/components/common-ui/vben-form.md

@ -264,6 +264,35 @@ export { initComponentAdapter };
<DemoPreview dir="demos/vben-form/query" />
## 表单分组
在 `schema` 中加入 `type: 'group'` 项,可以把若干字段组织成一个可折叠的区块。分组本身不是字段:没有 `fieldName`,不参与取值与校验;`children` 内的字段与顶层字段完全等价,`setValues`、`updateSchema`、`removeSchemaByFields` 以及字段插槽都按 `fieldName` 直接作用于组内字段。
```ts
const [Form, formApi] = useVbenForm({
schema: [
{ component: 'Input', fieldName: 'name', label: '名称' },
{
type: 'group',
title: '高级选项',
defaultCollapsed: true,
children: [
{ component: 'Input', fieldName: 'remark', label: '备注' },
{ component: 'Switch', fieldName: 'enabled', label: '启用' },
],
},
],
});
// 组内字段照常按 fieldName 更新
formApi.updateSchema([{ fieldName: 'remark', label: '说明' }]);
```
- `collapsible: false` 时分组不可折叠,仅作为带标题的区块。
- 分组默认占满一行,可通过 `formItemClass` 调整;`wrapperClass` 控制分组内部的栅格,缺省继承表单的 `wrapperClass`。
- 分组内任一字段校验失败时会自动展开,避免错误提示被折叠区域遮住。
- 分组只支持一层,`children` 只能是字段,不能再嵌套分组;数组字段的 `children` 同样只能是字段。
## 表单值编解码
当组件值与后端 payload 不一致时,使用表单级 `codec` 统一定义双向转换。`encode` 接收完整 `TFormValues` 并返回完整 `TSubmitValues`;`decode` 执行反向转换。多字段拆分、合并和删除都在一个纯函数边界完成,不依赖 schema 顺序或字符串路径写入。
@ -651,6 +680,40 @@ export interface FormSchema<
:::
::: details FormGroupSchema
`schema` 数组中的每一项要么是字段(`FormFieldSchema`,即上面的 `FormSchema`),要么是分组(`FormGroupSchema`),以 `type: 'group'` 区分。
```ts
export interface FormGroupSchema<
T extends BaseFormComponentType = BaseFormComponentType,
TValues extends FormValues = FormValues,
> {
/** 分组内的字段定义,只能是字段,不能再嵌套分组 */
children: FormFieldSchema<T, TValues>[];
/** 是否允许折叠,默认 true */
collapsible?: boolean;
/** 是否默认折叠,默认 false */
defaultCollapsed?: boolean;
/** 标题右侧的附加内容 */
extra?: CustomRenderType;
/** 分组容器在表单栅格中的样式,默认占满一行 */
formItemClass?: FormItemClassType;
/** 是否隐藏分组 */
hide?: boolean;
/** 分组标识,用于渲染时的稳定 key,缺省按索引 */
name?: string;
/** 分组标题 */
title?: CustomRenderType;
/** 分组标记 */
type: 'group';
/** 分组内部的栅格布局,缺省继承表单的 wrapperClass */
wrapperClass?: WrapperClassType;
}
```
:::
::: details FormValueFormat
`FormValueFormat` 是兼容类型,已标记为 `@deprecated`。新代码应使用 `FormCodec<TFormValues, TSubmitValues>`。

30
docs/src/en/components/common-ui/vben-form.md

@ -292,6 +292,35 @@ Control bindings are grouped under `componentProps`. It contains the model value
Root metadata remains available for template logic through `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`; it is not forwarded automatically to the rendered control.
## Field Groups
Add a `type: 'group'` item to `schema` to organize fields into a collapsible section. A group is not a field: it has no `fieldName` and takes no part in values or validation. Fields in `children` behave exactly like top-level fields, so `setValues`, `updateSchema`, `removeSchemaByFields`, and named field slots address them by `fieldName`.
```ts
const [Form, formApi] = useVbenForm({
schema: [
{ component: 'Input', fieldName: 'name', label: 'Name' },
{
type: 'group',
title: 'Advanced',
defaultCollapsed: true,
children: [
{ component: 'Input', fieldName: 'remark', label: 'Remark' },
{ component: 'Switch', fieldName: 'enabled', label: 'Enabled' },
],
},
],
});
// grouped fields are still updated by fieldName
formApi.updateSchema([{ fieldName: 'remark', label: 'Description' }]);
```
- `collapsible: false` renders a titled section that cannot be collapsed.
- A group spans the full row by default; adjust it with `formItemClass`. `wrapperClass` controls the grid inside the group and inherits the form `wrapperClass` by default.
- 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.
## 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.
@ -363,6 +392,7 @@ Use benchmark results to compare relative changes on the same machine and runtim
- top-level `componentProps`, `help`, and `renderComponentContent` functions receive `FormSchemaContext`; value-dependent rendering belongs in `dependencies.resolve`
- use `formFieldProps.validateOn` with `blur` and/or `change`; submit always validates, and `asyncDebounceMs` debounces async validators
- use `changeEventFallback: true` only for components that emit `change` without an `update:*` event
- `type: 'group'` schema items render collapsible sections; `FormSchema` is `FormFieldSchema | FormGroupSchema`, and `updateSchema` only accepts field schemas
## Reference

93
packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts

@ -374,6 +374,71 @@ describe('formApi', () => {
);
});
it('should treat fields inside groups as known fields when filtering', async () => {
const setValuesMock = vi.fn();
formApi.setState({
schema: [
{ component: 'text', fieldName: 'name' },
{
children: [{ component: 'text', fieldName: 'email' }],
title: 'Contact',
type: 'group',
},
],
});
const formActions: any = {
meta: {},
setValues: setValuesMock,
values: {},
};
await formApi.mount(formActions, new Map());
await formApi.setValues({
email: 'ada@example.com',
name: 'Ada',
unknown: 'ignored',
});
expect(setValuesMock).toHaveBeenCalledWith(
{ email: 'ada@example.com', name: 'Ada' },
false,
);
});
it('should clear values of fields removed from a group', async () => {
const setFieldValueMock = vi.fn();
formApi.setState({
schema: [
{
children: [
{ component: 'text', fieldName: 'email' },
{ component: 'text', fieldName: 'phone' },
],
title: 'Contact',
type: 'group',
},
],
});
const formActions: any = {
meta: {},
setFieldValue: setFieldValueMock,
values: { email: 'ada@example.com', phone: '123' },
};
await formApi.mount(formActions, new Map());
formApi.removeSchemaByFields(['phone']);
expect(formApi.state?.schema).toEqual([
{
children: [{ component: 'text', fieldName: 'email' }],
title: 'Contact',
type: 'group',
},
]);
expect(setFieldValueMock).toHaveBeenCalledWith('phone', undefined);
expect(setFieldValueMock).not.toHaveBeenCalledWith('email', undefined);
});
it('should preserve nested schema siblings in touched branches', async () => {
const setValuesMock = vi.fn();
formApi.setState({
@ -676,7 +741,7 @@ describe('updateSchema', () => {
instance.updateSchema(newSchema);
expect(instance.state?.schema?.[0]?.component).toBe('text');
expect(instance.state?.schema?.[1]?.label).toBe('Age');
expect(instance.state?.schema?.[1]).toMatchObject({ label: 'Age' });
});
it('should update child schema by parent path', () => {
@ -708,6 +773,32 @@ describe('updateSchema', () => {
);
});
it('should update fields nested inside groups', () => {
instance.state = {
schema: [
{ component: 'text', fieldName: 'name' },
{
children: [
{ component: 'text', fieldName: 'email', label: 'Email' },
{ component: 'text', fieldName: 'phone', label: 'Phone' },
],
title: 'Contact',
type: 'group',
},
],
};
instance.updateSchema([{ fieldName: 'phone', label: 'Mobile' }]);
expect(instance.state?.schema?.[1]).toMatchObject({
children: [
{ fieldName: 'email', label: 'Email' },
{ fieldName: 'phone', label: 'Mobile' },
],
type: 'group',
});
});
it('should log an error if fieldName is missing in some items', () => {
const newSchema: any[] = [
{ component: 'textarea', fieldName: 'name' },

189
packages/@core/ui-kit/form-ui/__tests__/form-group.test.ts

@ -0,0 +1,189 @@
import type { VueWrapper } from '@vue/test-utils';
import type { FormSchema } from '../src/types';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { z } from 'zod';
import { setupVbenForm } from '../src/config';
import { useVbenForm } from '../src/use-vben-form';
const wrappers: VueWrapper[] = [];
const TestInput = defineComponent({
inheritAttrs: false,
emits: ['update:modelValue'],
setup(_props, { attrs, emit }) {
return () =>
h('input', {
...attrs,
onInput: (event: Event) => {
emit('update:modelValue', (event.target as HTMLInputElement).value);
},
value: attrs.modelValue ?? '',
});
},
});
beforeAll(() => {
setupVbenForm({ config: {} });
});
afterEach(() => {
for (const wrapper of wrappers.splice(0)) {
wrapper.unmount();
}
});
function createContactGroup(
overrides: Partial<Extract<FormSchema, { type: 'group' }>> = {},
): FormSchema {
return {
children: [
{
component: TestInput,
defaultValue: 'ada@example.com',
fieldName: 'email',
label: 'Email',
},
{ component: TestInput, fieldName: 'phone', label: 'Phone' },
],
name: 'contact',
title: 'Contact',
type: 'group',
...overrides,
};
}
function getGroupState(wrapper: VueWrapper) {
return wrapper.get('.form-group [data-state]').attributes('data-state');
}
describe('form group rendering', () => {
it('renders grouped fields as regular form fields', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{ component: TestInput, fieldName: 'name', label: 'Name' },
createContactGroup({ extra: 'Optional' }),
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(wrapper.get('.form-group-title').text()).toBe('Contact');
expect(wrapper.text()).toContain('Optional');
expect(wrapper.findAll('input')).toHaveLength(3);
expect(wrapper.get('.form-group').findAll('input')).toHaveLength(2);
expect(getGroupState(wrapper)).toBe('open');
expect(await formApi.getValues()).toEqual({ email: 'ada@example.com' });
});
it('toggles the group from its header and honors defaultCollapsed', async () => {
const [Form] = useVbenForm({
schema: [createContactGroup({ defaultCollapsed: true })],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(getGroupState(wrapper)).toBe('closed');
await wrapper.get('.form-group-header').trigger('click');
expect(getGroupState(wrapper)).toBe('open');
await wrapper.get('.form-group-header').trigger('click');
expect(getGroupState(wrapper)).toBe('closed');
});
it('keeps a non-collapsible group open when the header is clicked', async () => {
const [Form] = useVbenForm({
schema: [createContactGroup({ collapsible: false })],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await wrapper.get('.form-group-header').trigger('click');
expect(getGroupState(wrapper)).toBe('open');
});
it('expands a collapsed group when one of its fields fails validation', async () => {
const [Form, formApi] = useVbenForm({
schema: [
createContactGroup({
children: [
{
component: TestInput,
fieldName: 'email',
label: 'Email',
rules: z.string().min(1, 'Email is required'),
},
],
defaultCollapsed: true,
}),
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(getGroupState(wrapper)).toBe('closed');
expect(await formApi.validate()).toEqual({
errors: { email: 'Email is required' },
valid: false,
});
await flushPromises();
expect(getGroupState(wrapper)).toBe('open');
expect(wrapper.text()).toContain('Email is required');
});
it('skips hidden groups and forwards field slots into groups', async () => {
const [Form] = useVbenForm({
schema: [
createContactGroup(),
{
children: [{ component: TestInput, fieldName: 'secret' }],
hide: true,
name: 'hidden',
type: 'group',
},
],
});
const wrapper = mount(Form, {
slots: {
phone: (slotProps: Record<string, any>) =>
h(TestInput, {
...slotProps.componentProps,
class: 'slot-phone',
}),
},
});
wrappers.push(wrapper);
await flushPromises();
expect(wrapper.findAll('.form-group')).toHaveLength(1);
expect(wrapper.findAll('input')).toHaveLength(2);
expect(wrapper.get('.form-group').find('.slot-phone').exists()).toBe(true);
});
it('re-renders grouped fields after updateSchema', async () => {
const [Form, formApi] = useVbenForm({
schema: [createContactGroup()],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
formApi.updateSchema([{ fieldName: 'phone', label: 'Mobile' }]);
await flushPromises();
expect(wrapper.text()).toContain('Mobile');
expect(wrapper.text()).not.toContain('Phone');
});
});

78
packages/@core/ui-kit/form-ui/__tests__/form-schema.test.ts

@ -1,8 +1,14 @@
import type { FormSchema } from '../src/types';
import { describe, expect, it, vi } from 'vitest';
import {
createArrayChildSchema,
createFormFieldSchema,
getFormFieldSchemas,
isFormGroupSchema,
removeFormSchemaByFields,
updateFormSchemaList,
} from '../src/form-render/schema';
describe('form schema normalization', () => {
@ -71,3 +77,75 @@ describe('form schema normalization', () => {
});
});
});
describe('form group schema', () => {
const nameSchema: FormSchema = { component: 'VbenInput', fieldName: 'name' };
const contactGroup: FormSchema = {
children: [
{ component: 'VbenInput', fieldName: 'email' },
{ component: 'VbenInput', fieldName: 'phone' },
],
name: 'contact',
title: 'Contact',
type: 'group',
};
const tagsArray: FormSchema = {
children: [{ component: 'VbenInput', fieldName: 'label' }],
fieldName: 'tags',
type: 'array',
};
const schema: FormSchema[] = [nameSchema, contactGroup, tagsArray];
it('distinguishes groups from fields and arrays', () => {
expect(isFormGroupSchema(nameSchema)).toBe(false);
expect(isFormGroupSchema(contactGroup)).toBe(true);
expect(isFormGroupSchema(tagsArray)).toBe(false);
});
it('flattens groups into field schemas while keeping arrays intact', () => {
const fields = getFormFieldSchemas(schema);
expect(fields.map((item) => item.fieldName)).toEqual([
'name',
'email',
'phone',
'tags',
]);
expect(fields[3]).toBe(schema[2]);
});
it('updates fields nested inside groups', () => {
const updated = updateFormSchemaList(schema, [
{ fieldName: 'phone', label: 'Phone' },
{ fieldName: 'tags.label', label: 'Tag' },
]);
expect(updated[0]).toBe(schema[0]);
expect(updated[1]).toMatchObject({
children: [
{ fieldName: 'email' },
{ fieldName: 'phone', label: 'Phone' },
],
name: 'contact',
type: 'group',
});
expect(updated[2]).toMatchObject({
children: [{ fieldName: 'label', label: 'Tag' }],
fieldName: 'tags',
});
// 原 schema 不应被修改
expect(schema[1]).not.toHaveProperty('children.1.label');
});
it('removes fields nested inside groups without dropping the group', () => {
const result = removeFormSchemaByFields(schema, ['name', 'email']);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
children: [{ fieldName: 'phone' }],
name: 'contact',
type: 'group',
});
expect(result[1]).toBe(schema[2]);
});
});

54
packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts

@ -4,7 +4,10 @@ import type {
FormActions,
FormContextApi,
FormFieldOptions,
FormFieldSchema,
FormGroupSchema,
FormItemDependencies,
FormSchema,
FormValidationResult,
FormValuePatch,
FormValueSnapshot,
@ -271,6 +274,57 @@ describe('form public types', () => {
).resolves.toEqualTypeOf<AccountFormValues>();
});
it('discriminates group schemas from field schemas by type', () => {
const [, formApi] = useVbenForm<AccountFormValues>({
schema: [
{ component: 'VbenInput', fieldName: 'email' },
{
children: [{ component: 'VbenInput', fieldName: 'profile.nickname' }],
defaultCollapsed: true,
title: 'Profile',
type: 'group',
},
],
});
expectTypeOf<FormSchema>().toEqualTypeOf<
FormFieldSchema | FormGroupSchema
>();
expectTypeOf<FormGroupSchema['fieldName']>().toEqualTypeOf<undefined>();
expectTypeOf<FormGroupSchema['component']>().toEqualTypeOf<undefined>();
expectTypeOf<FormGroupSchema['children']>().toEqualTypeOf<
FormFieldSchema[]
>();
// updateSchema 只接受字段更新,分组本身不可被更新
expectTypeOf(formApi.updateSchema)
.parameter(0)
.toEqualTypeOf<
Partial<
FormFieldSchema<
BaseFormComponentType,
Record<never, never>,
AccountFormValues
>
>[]
>();
// @ts-expect-error 分组不能声明 fieldName
const invalidGroup: FormSchema = {
children: [],
fieldName: 'group',
type: 'group',
};
// @ts-expect-error 数组子字段不能是分组
const invalidArrayChildren: FormSchema = {
children: [{ children: [], type: 'group' }],
fieldName: 'contacts',
type: 'array',
};
expectTypeOf(invalidGroup).toMatchTypeOf<FormSchema>();
expectTypeOf(invalidArrayChildren).toMatchTypeOf<FormSchema>();
});
it('exposes canonical names alongside deprecated aliases', () => {
expectTypeOf<FormContextApi['reset']>().toEqualTypeOf<
FormContextApi['resetForm']

23
packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts

@ -62,6 +62,29 @@ describe('form value transforms', () => {
});
});
it('formats fields nested inside groups', () => {
const schema = [
{
children: [
{
component: 'text',
fieldName: 'email',
valueFormat: (value: string) => value.trim().toLowerCase(),
},
],
title: 'Contact',
type: 'group',
},
] as any;
const result = applyFormValueFormats(
{ email: ' Ada@Example.com ' },
schema,
);
expect(result).toEqual({ email: 'ada@example.com' });
});
it('runs the unified formatting pipeline in a stable order', () => {
const result = formatFormValues(
{

6
packages/@core/ui-kit/form-ui/src/components/form-field-array.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
// oxlint-disable unicorn/no-nested-ternary
import type { FormCommonConfig, FormSchema } from '../types';
import type { FormCommonConfig, FormFieldSchema } from '../types';
import { computed } from 'vue';
@ -42,9 +42,9 @@ const props = withDefaults(
/** 字段路径,由外层 FormField 通过 componentField 透传 */
name?: string;
/**
* 列定义,每一列就是一个子字段(复用 FormSchema)
* 列定义,每一列就是一个子字段(复用 FormFieldSchema)
*/
schema?: FormSchema[];
schema?: FormFieldSchema[];
/** 是否显示序号列 */
showIndex?: boolean;
}>(),

32
packages/@core/ui-kit/form-ui/src/form-api.ts

@ -4,6 +4,7 @@ import type {
BaseFormComponentType,
FormActions,
FormFieldName,
FormFieldSchema,
FormFieldValue,
FormResetOptions,
FormResetState,
@ -28,7 +29,11 @@ import {
import { warnDeprecatedOnce } from './deprecation';
import { resolveFieldNamePath } from './field-name';
import { decodeFormValues, encodeFormValues } from './form-codec';
import { updateFormSchemaList } from './form-render/schema';
import {
getFormFieldSchemas,
removeFormSchemaByFields,
updateFormSchemaList,
} from './form-render/schema';
import { formatFormValues } from './form-value-transform';
type FormApiProps<
@ -44,6 +49,12 @@ type FormApiSchema<
P extends Record<string, any>,
> = FormSchema<T, P, TValues>;
type FormApiFieldSchema<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = FormFieldSchema<T, P, TValues>;
function isPlainFormObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object') {
return false;
@ -359,13 +370,10 @@ export class FormApi<
* @param fields
*/
async removeSchemaByFields(fields: string[]) {
const fieldSet = new Set(fields);
const schema = this.state?.schema ?? [];
const filterSchema = schema.filter((item) => !fieldSet.has(item.fieldName));
this.setState({
schema: filterSchema,
schema: removeFormSchemaByFields(schema, fields),
});
}
@ -512,7 +520,7 @@ export class FormApi<
]),
);
const schemaFieldPaths = (this.state?.schema ?? []).map(
const schemaFieldPaths = getFormFieldSchemas(this.state?.schema ?? []).map(
(schema) => resolveFieldNamePath(schema.fieldName).pathSegments,
);
const filterValue = (
@ -575,7 +583,7 @@ export class FormApi<
this.stateHandler.reset();
}
updateSchema(schema: Partial<FormApiSchema<TFormValues, T, P>>[]) {
updateSchema(schema: Partial<FormApiFieldSchema<TFormValues, T, P>>[]) {
const updated: Partial<FormApiSchema<TFormValues, T, P>>[] = [...schema];
const hasField = updated.every(
(item) => Reflect.has(item, 'fieldName') && item.fieldName,
@ -655,8 +663,8 @@ export class FormApi<
}
private updateState() {
const currentSchema = this.state?.schema ?? [];
const prevSchema = this.prevState?.schema ?? [];
const currentSchema = getFormFieldSchemas(this.state?.schema ?? []);
const prevSchema = getFormFieldSchemas(this.prevState?.schema ?? []);
// 进行了删除schema操作
if (currentSchema.length < prevSchema.length) {
const currentFields = new Set(
@ -694,7 +702,7 @@ export class FormApi<
this.legacyTransformWarningState = warningState;
const hasValueFormat = (
items: FormApiSchema<TFormValues, T, P>[],
items: FormApiFieldSchema<TFormValues, T, P>[],
): boolean => {
return items.some((schema) => {
if (schema.valueFormat) {
@ -704,7 +712,9 @@ export class FormApi<
return Array.isArray(children) && hasValueFormat(children);
});
};
const usesValueFormat = hasValueFormat(warningState.schema);
const usesValueFormat = hasValueFormat(
getFormFieldSchemas(warningState.schema),
);
const usesFieldMappingTime =
(warningState.fieldMappingTime?.length ?? 0) > 0;
const usesArrayToStringFields =

107
packages/@core/ui-kit/form-ui/src/form-render/form-group.vue

@ -0,0 +1,107 @@
<script setup lang="ts">
import type { FormGroupSchema } from '../types';
import { computed, ref, watch } from 'vue';
import { ChevronsDown } from '@vben-core/icons';
import { VbenCollapsible, VbenRenderContent } from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
import { injectRenderFormProps } from './context';
interface Props {
/** 分组内容区(字段栅格)的样式 */
contentClass?: string;
/** 表单折叠按钮收起时是否隐藏当前分组 */
hidden?: boolean;
schema: FormGroupSchema;
}
const props = withDefaults(defineProps<Props>(), {
contentClass: '',
hidden: false,
});
const formRenderProps = injectRenderFormProps();
const collapseOpen = ref(!props.schema.defaultCollapsed);
const shouldCollapsible = computed(() => {
return props.schema.collapsible !== false;
});
// 组内任一字段校验失败时自动展开,避免错误提示被折叠区域遮住
const hasInvalidField = computed(() => {
const errors = formRenderProps.form?.errors ?? {};
return props.schema.children.some((field) =>
Boolean(errors[field.fieldName]),
);
});
watch(hasInvalidField, (invalid) => {
if (invalid && !collapseOpen.value) {
collapseOpen.value = true;
}
});
function toggleCollapsed() {
if (!shouldCollapsible.value) {
return;
}
collapseOpen.value = !collapseOpen.value;
}
</script>
<template>
<div
:class="
cn(
'form-group col-span-full flex w-full flex-col',
{ hidden: props.hidden },
props.schema.formItemClass,
)
"
>
<VbenCollapsible v-model:open="collapseOpen" :show-trigger="false">
<template #label>
<div
:class="
cn(
'form-group-header mb-2 flex min-h-7 flex-1 items-center gap-2',
{
'cursor-pointer select-none': shouldCollapsible,
},
)
"
@click="toggleCollapsed"
>
<span class="bg-primary h-3.5 w-[3px] flex-none rounded-full"></span>
<span
v-if="props.schema.title"
class="form-group-title text-sm font-medium leading-6"
>
<VbenRenderContent :content="props.schema.title" />
</span>
<div v-if="props.schema.extra" class="ml-auto" @click.stop>
<VbenRenderContent :content="props.schema.extra" />
</div>
<ChevronsDown
v-if="shouldCollapsible"
:size="16"
:class="
cn('text-muted-foreground transition-transform', {
'ml-auto': !props.schema.extra,
'rotate-180': collapseOpen,
})
"
/>
</div>
</template>
<template #collapsibleContent>
<div :class="props.contentClass">
<slot></slot>
</div>
</template>
</VbenCollapsible>
</div>
</template>

122
packages/@core/ui-kit/form-ui/src/form-render/form.vue

@ -1,7 +1,13 @@
<script setup lang="ts">
import type { ZodType } from 'zod';
import type { FormCommonConfig, FormRenderProps, FormShape } from '../types';
import type {
FormCommonConfig,
FormFieldSchema,
FormGroupSchema,
FormRenderProps,
FormShape,
} from '../types';
import type { NormalizedFormFieldSchema } from './schema';
import { computed, reactive, toRaw, toRefs } from 'vue';
@ -11,10 +17,21 @@ import { cn, isString } from '@vben-core/shared/utils';
import { provideFormRenderProps } from './context';
import { useExpandable } from './expandable';
import FormField from './form-field.vue';
import FormGroup from './form-group.vue';
import { getBaseRules, getDefaultValueInZodStack } from './helper';
import { createFormFieldSchema } from './schema';
import {
createFormFieldSchema,
getFormFieldSchemas,
isFormGroupSchema,
} from './schema';
import { useFormLabelWidth } from './utils';
interface NormalizedFormGroupSchema extends FormGroupSchema {
fields: NormalizedFormFieldSchema[];
hidden: boolean;
key: string;
}
interface Props extends FormRenderProps {}
const props = withDefaults(
@ -32,24 +49,30 @@ const emits = defineEmits<{
submit: [event: any];
}>();
const wrapperClass = computed(() => {
function getWrapperClass(gridClass?: string) {
const cls = ['flex'];
if (props.layout === 'inline') {
cls.push('flex-wrap gap-x-2');
} else {
cls.push(props.compact ? 'gap-x-2' : 'gap-x-4', 'flex-col grid');
}
return cn(...cls, props.wrapperClass);
});
return cn(...cls, gridClass);
}
const wrapperClass = computed(() => getWrapperClass(props.wrapperClass));
provideFormRenderProps(reactive({ ...toRefs(props), ...useFormLabelWidth() }));
// @ts-expect-error unused
const { isCalculated, keepFormItemIndex, wrapperRef } = useExpandable(props);
const formFieldSchemas = computed(() => {
return getFormFieldSchemas(props.schema ?? []);
});
const shapes = computed(() => {
const resultShapes: FormShape[] = [];
props.schema?.forEach((schema) => {
formFieldSchemas.value.forEach((schema) => {
const { fieldName } = schema;
const rules = toRaw(schema.rules) as ZodType;
@ -84,33 +107,90 @@ const formCollapsed = computed(() => {
return props.collapsed && isCalculated.value;
});
const computedSchema = computed((): NormalizedFormFieldSchema[] => {
return (props.schema || []).map((schema, index) => {
const keepIndex = keepFormItemIndex.value;
function normalizeFieldSchema(schema: FormFieldSchema, hidden = false) {
return createFormFieldSchema(schema as never, {
commonConfig: props.commonConfig,
globalCommonConfig: props.globalCommonConfig,
hidden,
});
}
const hidden =
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
props.showCollapseButton && !!formCollapsed.value && keepIndex
? keepIndex <= index
: false;
const computedSchema = computed(
(): Array<NormalizedFormFieldSchema | NormalizedFormGroupSchema> => {
const keepIndex = keepFormItemIndex.value;
const result: Array<NormalizedFormFieldSchema | NormalizedFormGroupSchema> =
[];
(props.schema ?? []).forEach((schema, index) => {
const hidden =
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引(分组按一个顶层项计算)
props.showCollapseButton && !!formCollapsed.value && keepIndex
? keepIndex <= index
: false;
if (isFormGroupSchema(schema)) {
if (schema.hide || schema.children.length === 0) {
return;
}
result.push({
...schema,
fields: schema.children.map((field) => normalizeFieldSchema(field)),
hidden,
key: schema.name ?? `group-${index}`,
});
return;
}
return createFormFieldSchema(schema as never, {
commonConfig: props.commonConfig,
globalCommonConfig: props.globalCommonConfig,
hidden,
result.push(normalizeFieldSchema(schema, hidden));
});
});
});
return result;
},
);
function isNormalizedFormGroupSchema(
schema: NormalizedFormFieldSchema | NormalizedFormGroupSchema,
): schema is NormalizedFormGroupSchema {
return isFormGroupSchema(schema);
}
function getGroupWrapperClass(schema: NormalizedFormGroupSchema) {
return getWrapperClass(schema.wrapperClass ?? props.wrapperClass);
}
</script>
<template>
<component :is="formComponent" v-bind="formComponentProps">
<div ref="wrapperRef" :class="wrapperClass">
<template v-for="cSchema in computedSchema" :key="cSchema.fieldName">
<template
v-for="cSchema in computedSchema"
:key="
isNormalizedFormGroupSchema(cSchema) ? cSchema.key : cSchema.fieldName
"
>
<FormGroup
v-if="isNormalizedFormGroupSchema(cSchema)"
:content-class="getGroupWrapperClass(cSchema)"
:hidden="cSchema.hidden"
:schema="cSchema"
>
<FormField
v-for="fieldSchema in cSchema.fields"
:key="fieldSchema.fieldName"
v-bind="fieldSchema"
:class="fieldSchema.formItemClass"
:rules="fieldSchema.rules"
>
<template #default="slotProps">
<slot v-bind="slotProps" :name="fieldSchema.fieldName"></slot>
</template>
</FormField>
</FormGroup>
<!-- <div v-if="$slots[cSchema.fieldName]" :class="cSchema.formItemClass">
<slot :definition="cSchema" :name="cSchema.fieldName"> </slot>
</div> -->
<FormField
v-else
v-bind="cSchema"
:class="cSchema.formItemClass"
:rules="cSchema.rules"

107
packages/@core/ui-kit/form-ui/src/form-render/schema.ts

@ -4,10 +4,13 @@ import type {
FormCommonConfig,
FormDependenciesResolveContext,
FormFieldProps,
FormFieldSchema,
FormGroupSchema,
FormItemDependencies,
FormItemDependenciesLegacy,
FormSchema,
FormSchemaContext,
FormValues,
MaybeComponentProps,
} from '../types';
@ -19,7 +22,10 @@ import {
import { resolveChildUpdateFieldName } from '../field-name';
type AnyFormSchema = FormSchema<BaseFormComponentType, Record<string, any>>;
type AnyFormFieldSchema = FormFieldSchema<
BaseFormComponentType,
Record<string, any>
>;
export type NormalizedFormFieldSchema = FormFieldProps & {
commonComponentProps: MaybeComponentProps;
@ -76,7 +82,7 @@ function scopeRowFieldName(rowPath: string, fieldName: string) {
}
function wrapComponentProps(
componentProps: AnyFormSchema['componentProps'],
componentProps: AnyFormFieldSchema['componentProps'],
baseContext: FormSchemaContext,
) {
if (!isFunction(componentProps)) {
@ -104,7 +110,7 @@ function wrapCommonConfig(
}
function wrapCustomParamsRender(
render: AnyFormSchema['help'],
render: AnyFormFieldSchema['help'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
@ -115,7 +121,7 @@ function wrapCustomParamsRender(
}
function wrapRenderComponentContent(
render: AnyFormSchema['renderComponentContent'],
render: AnyFormFieldSchema['renderComponentContent'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
@ -195,7 +201,7 @@ function scopeDependencies(
}
function createArrayComponentProps(
schema: AnyFormSchema,
schema: AnyFormFieldSchema,
options: CreateFormFieldSchemaOptions,
) {
const componentProps = schema.componentProps;
@ -225,10 +231,12 @@ function createArrayComponentProps(
}
function createArrayFieldSchema(
schema: AnyFormSchema,
schema: AnyFormFieldSchema,
options: CreateFormFieldSchemaOptions,
) {
const restSchema = { ...(schema as AnyFormSchema & Record<string, any>) };
const restSchema = {
...(schema as AnyFormFieldSchema & Record<string, any>),
};
Reflect.deleteProperty(restSchema, 'arrayProps');
Reflect.deleteProperty(restSchema, 'children');
Reflect.deleteProperty(restSchema, 'type');
@ -246,7 +254,34 @@ interface FormArraySchemaLike {
}
interface UpdatableFormSchemaLike extends FormArraySchemaLike {
fieldName: string;
/** 分组没有 fieldName,因此这里是可选的 */
fieldName?: string;
type?: string;
}
export function isFormGroupSchema<
T extends BaseFormComponentType,
P extends Record<string, any>,
TValues extends FormValues,
>(schema: FormSchema<T, P, TValues>): schema is FormGroupSchema<T, P, TValues>;
export function isFormGroupSchema<TSchema extends object>(
schema: TSchema,
): schema is Extract<TSchema, { type: 'group' }>;
export function isFormGroupSchema(schema: object) {
return 'type' in schema && schema.type === 'group';
}
/**
* 展开分组,得到表单中全部字段 schema
*/
export function getFormFieldSchemas<
T extends BaseFormComponentType,
P extends Record<string, any>,
TValues extends FormValues,
>(schemas: FormSchema<T, P, TValues>[]): FormFieldSchema<T, P, TValues>[] {
return schemas.flatMap((schema) =>
isFormGroupSchema(schema) ? schema.children : [schema],
);
}
function setSchemaChildren<TSchema extends UpdatableFormSchemaLike>(
@ -276,7 +311,7 @@ function setSchemaChildren<TSchema extends UpdatableFormSchemaLike>(
return schema;
}
export function getFormArraySchemaChildren<TSchema = FormSchema>(
export function getFormArraySchemaChildren<TSchema = FormFieldSchema>(
schema: FormArraySchemaLike,
): TSchema[] {
if ('children' in schema && Array.isArray(schema.children)) {
@ -295,7 +330,7 @@ export function getFormArraySchemaChildren<TSchema = FormSchema>(
return [];
}
export function isFormArraySchema(schema: Partial<AnyFormSchema>) {
export function isFormArraySchema(schema: Partial<AnyFormFieldSchema>) {
return (
('type' in schema && schema.type === 'array') ||
schema.component === 'VbenFormFieldArray' ||
@ -312,8 +347,21 @@ export function updateFormSchemaList<TSchema extends UpdatableFormSchemaLike>(
updated: Partial<TSchema>[],
): TSchema[] {
return currentSchema.map((schema) => {
// 分组本身不是字段,直接把更新下发给组内字段
if (isFormGroupSchema(schema)) {
return setSchemaChildren(
schema,
updateFormSchemaList((schema.children ?? []) as TSchema[], updated),
);
}
const { fieldName: schemaFieldName } = schema;
if (!schemaFieldName) {
return schema;
}
const exactUpdatedData = updated.find(
(item) => item.fieldName === schema.fieldName,
(item) => item.fieldName === schemaFieldName,
);
if (exactUpdatedData) {
return mergeWithArrayOverride(exactUpdatedData, schema) as TSchema;
@ -325,7 +373,7 @@ export function updateFormSchemaList<TSchema extends UpdatableFormSchemaLike>(
}
const childUpdates = updated.flatMap((item) => {
const fieldName = item.fieldName
? resolveChildUpdateFieldName(schema.fieldName, item.fieldName)
? resolveChildUpdateFieldName(schemaFieldName, item.fieldName)
: undefined;
return fieldName ? [{ ...item, fieldName } as Partial<TSchema>] : [];
});
@ -339,8 +387,39 @@ export function updateFormSchemaList<TSchema extends UpdatableFormSchemaLike>(
});
}
/**
* 按字段名移除 schema,分组内的字段一并处理,分组壳子保留
*/
export function removeFormSchemaByFields<
TSchema extends UpdatableFormSchemaLike,
>(currentSchema: TSchema[], fields: string[]): TSchema[] {
const fieldSet = new Set(fields);
const result: TSchema[] = [];
for (const schema of currentSchema) {
if (isFormGroupSchema(schema)) {
result.push(
setSchemaChildren(
schema,
removeFormSchemaByFields(
(schema.children ?? []) as TSchema[],
fields,
),
),
);
continue;
}
if (!schema.fieldName || !fieldSet.has(schema.fieldName)) {
result.push(schema);
}
}
return result;
}
export function createFormFieldSchema(
schema: AnyFormSchema,
schema: AnyFormFieldSchema,
options: CreateFormFieldSchemaOptions = {},
): NormalizedFormFieldSchema {
const commonConfig = mergeWithArrayOverride(
@ -417,7 +496,7 @@ export function createFormFieldSchema(
}
export function createArrayChildSchema(
schema: AnyFormSchema,
schema: AnyFormFieldSchema,
options: CreateArrayChildSchemaOptions,
): NormalizedFormFieldSchema {
const rowPath = `${options.arrayField}[${options.index}]`;

17
packages/@core/ui-kit/form-ui/src/form-value-transform.ts

@ -2,6 +2,7 @@ import type {
ArrayToStringFields,
BaseFormComponentType,
FieldMappingTime,
FormFieldSchema,
FormSchema,
FormSchemaContext,
FormValues,
@ -17,6 +18,7 @@ import {
} from './field-name';
import {
getFormArraySchemaChildren,
getFormFieldSchemas,
resolveArrayChildFieldName,
} from './form-render/schema';
@ -26,6 +28,12 @@ type AnyFormSchema<TValues extends FormValues> = FormSchema<
TValues
>;
type AnyFormFieldSchema<TValues extends FormValues> = FormFieldSchema<
BaseFormComponentType,
Record<string, any>,
TValues
>;
function processFields(
fields: string[],
separator: string,
@ -132,7 +140,7 @@ function applyRangeTimeFields(
}
function applyValueFormatBySchemas<TValues extends FormValues>(
schemas: AnyFormSchema<TValues>[],
schemas: AnyFormFieldSchema<TValues>[],
values: Record<string, any>,
parentPath?: string,
parentContext?: FormSchemaContext<TValues>,
@ -153,7 +161,8 @@ function applyValueFormatBySchemas<TValues extends FormValues>(
row,
};
const children = getFormArraySchemaChildren<AnyFormSchema<TValues>>(schema);
const children =
getFormArraySchemaChildren<AnyFormFieldSchema<TValues>>(schema);
if (children.length > 0) {
const arrayValue = getValueByFieldName(values, fieldName);
if (Array.isArray(arrayValue)) {
@ -197,7 +206,7 @@ export function applyFormValueFormats<TValues extends FormValues>(
schemas: AnyFormSchema<TValues>[],
) {
const values = cloneDeep(originValues);
applyValueFormatBySchemas(schemas, values);
applyValueFormatBySchemas(getFormFieldSchemas(schemas), values);
return values;
}
@ -210,7 +219,7 @@ export function formatFormValues<TValues extends FormValues>(
const values = cloneDeep(originValues);
applyArrayToStringFields(values, arrayToStringFields);
applyRangeTimeFields(values, fieldMappingTime);
applyValueFormatBySchemas(schemas, values);
applyValueFormatBySchemas(getFormFieldSchemas(schemas), values);
return values;
}

3
packages/@core/ui-kit/form-ui/src/index.ts

@ -9,6 +9,7 @@ export type {
FormActions,
FormCodec,
FormContextApi,
FormGroupSchema,
FormLayout,
FormSchemaContext,
FormValues,
@ -17,7 +18,9 @@ export type {
VbenFormComponent,
VbenFormDefaultSlotProps,
VbenFormFieldArrayProps,
FormFieldSchema as VbenFormFieldSchema,
VbenFormFieldSlotProps,
FormGroupSchema as VbenFormGroupSchema,
VbenFormProps,
VbenFormResolvedComponentProps,
FormSchema as VbenFormSchema,

61
packages/@core/ui-kit/form-ui/src/types.ts

@ -667,7 +667,7 @@ type FormArraySchema<
'disabled' | 'globalCommonConfig' | 'name' | 'schema'
>;
/** 数组子字段定义 */
children: FormSchema<T, P, TValues>[];
children: FormFieldSchema<T, P, TValues>[];
/** 兼容显式指定内置数组编辑器 */
component?: Component | T;
/** 兼容通过 componentProps 传递数组编辑器参数 */
@ -676,7 +676,51 @@ type FormArraySchema<
type: 'array';
} & FormSchemaBody<TValues>;
export type FormSchema<
/**
* 表单分组,用于把若干字段组织成一个可折叠的区块。
* 分组本身不是字段,不参与取值与校验。
*/
export interface FormGroupSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> {
/** 分组内的字段定义 */
children: FormFieldSchema<T, P, TValues>[];
/**
* 是否允许折叠
* @default true
*/
collapsible?: boolean;
/** 分组不是字段,禁止指定组件 */
component?: never;
/**
* 是否默认折叠
* @default false
*/
defaultCollapsed?: boolean;
/** 标题右侧的附加内容 */
extra?: CustomRenderType;
/** 分组不是字段,禁止指定字段名 */
fieldName?: never;
/** 分组容器在表单栅格中的样式,默认占满一行 */
formItemClass?: FormItemClassType;
/** 是否隐藏分组 */
hide?: boolean;
/** 分组标识,用于渲染时的稳定 key,缺省按索引 */
name?: string;
/** 分组标题 */
title?: CustomRenderType;
/** 分组标记 */
type: 'group';
/** 分组内部的栅格布局,缺省继承表单的 wrapperClass */
wrapperClass?: WrapperClassType;
}
/**
* 单个表单字段的 schema(普通字段 / 数组字段)
*/
export type FormFieldSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
@ -685,6 +729,15 @@ export type FormSchema<
| FormSchemaDiscriminated<T, P, TValues>
| FormSchemaFallback<T, TValues>;
/**
* 表单 schema 项:字段或分组,以 `type` 区分
*/
export type FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> = FormFieldSchema<T, P, TValues> | FormGroupSchema<T, P, TValues>;
/**
* 数组编辑器(VbenFormFieldArray)的组件参数
*/
@ -712,8 +765,8 @@ export interface VbenFormFieldArrayProps<
min?: number;
/** 数组字段路径,由外层 FormField 透传 */
name?: string;
/** 列定义,每一列是一个子字段(复用 FormSchema) */
schema?: FormSchema<T, P, TValues>[];
/** 列定义,每一列是一个子字段(复用 FormFieldSchema) */
schema?: FormFieldSchema<T, P, TValues>[];
/** 是否显示序号列 */
showIndex?: boolean;
}

3
packages/@core/ui-kit/form-ui/src/use-form-context.ts

@ -12,6 +12,7 @@ import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
import { getDefaultsForSchema } from 'zod-defaults';
import { getFormFieldSchemas } from './form-render/schema';
import { useFormRuntime } from './form-runtime';
type ExtendFormProps = VbenFormProps & {
@ -49,7 +50,7 @@ export function useFormInitial(
const initialValues: Record<string, any> = {};
const zodObject: Record<string, ZodType> = {};
(unref(props).schema || []).forEach((item) => {
getFormFieldSchemas(unref(props).schema ?? []).forEach((item) => {
if (Reflect.has(item, 'defaultValue')) {
set(initialValues, item.fieldName, item.defaultValue);
} else if (item.rules && !isString(item.rules)) {

5
packages/@core/ui-kit/form-ui/src/vben-use-form.vue

@ -15,6 +15,7 @@ import {
DEFAULT_FORM_COMMON_CONFIG,
} from './config';
import { Form } from './form-render';
import { getFormFieldSchemas } from './form-render/schema';
import {
provideComponentRefMap,
provideFormProps,
@ -92,7 +93,9 @@ watch(values, (currentValues, previousValues) => {
if (!handleValuesChange && !submitOnChange) {
return;
}
const fields = state?.value.schema?.map((item) => item.fieldName) ?? [];
const fields = getFormFieldSchemas(state?.value.schema ?? []).map(
(item) => item.fieldName,
);
if (handleValuesChange && fields.length > 0) {
const changedFields = fields.filter((field) => {
return !isEqual(

132
playground/src/views/examples/form/collapsible.vue

@ -232,12 +232,122 @@ const [BaseForm, baseFormApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
});
// 通过 schema 的 type: 'group' 把字段组织成可折叠的分组
const [GroupForm, groupFormApi] = useVbenForm({
showDefaultActions: false,
commonConfig: {
componentProps: {
class: 'w-full',
},
},
handleSubmit: onSubmit,
schema: [
{
component: 'Input',
fieldName: 'name',
label: '任务名称',
rules: 'required',
},
{
component: 'Select',
componentProps: {
options: [
{ label: 'SFT', value: 'sft' },
{ label: 'DPO', value: 'dpo' },
],
},
defaultValue: 'sft',
fieldName: 'method',
label: '训练方式',
},
{
type: 'group',
name: 'training',
title: '训练参数',
children: [
{
component: 'InputNumber',
defaultValue: 32,
fieldName: 'batchSize',
label: '批次大小',
},
{
component: 'InputNumber',
defaultValue: 1e-5,
fieldName: 'learningRate',
label: '学习率',
},
{
component: 'InputNumber',
defaultValue: 3,
fieldName: 'epochs',
label: '循环次数',
},
{
component: 'InputNumber',
defaultValue: 32_768,
fieldName: 'maxLength',
label: '序列长度',
},
],
},
{
type: 'group',
name: 'advanced',
title: '高级选项',
extra: () =>
h(
'span',
{ class: 'text-muted-foreground text-xs' },
'默认折叠,校验失败时自动展开',
),
defaultCollapsed: true,
children: [
{
component: 'Input',
fieldName: 'checkpoint',
label: 'Checkpoint',
rules: 'required',
},
{
component: 'Switch',
componentProps: {
class: 'w-auto',
},
defaultValue: false,
fieldName: 'enableEval',
label: '定期评估',
},
{
component: 'Textarea',
fieldName: 'remark',
formItemClass: 'col-span-2',
label: '备注',
},
],
},
],
wrapperClass: 'grid-cols-2',
});
function onSubmit(values: Record<string, any>) {
message.info({
content: `form values: ${JSON.stringify(values)}`,
});
}
async function handleSubmitGroupForm() {
const { valid } = await groupFormApi.validate();
if (valid) {
groupFormApi.submit();
}
}
function handleResetGroupForm() {
groupFormApi.reset(undefined, { force: true });
}
function onLayoutChange() {
baseFormApi.setState({
layout: layout.value,
@ -277,7 +387,7 @@ async function handleSubmitFormValue() {
>
<template #description>
<div class="text-muted-foreground">
<p>可折叠表单项、以及可折叠参数配置组件示例</p>
<p>可折叠表单项、可折叠参数配置组件,以及 schema 分组折叠示例</p>
</div>
</template>
<template #extra>
@ -307,5 +417,25 @@ async function handleSubmitFormValue() {
<BaseForm />
</div>
</Card>
<Card title="分组折叠">
<template #extra>
<div class="inline-flex items-center gap-4!">
<Button type="primary" @click="handleSubmitGroupForm">
提交表单
</Button>
<Button type="primary" @click="handleResetGroupForm">
重置表单
</Button>
</div>
</template>
<p class="text-muted-foreground mb-4 text-sm">
在 schema 中使用 <code>type: 'group'</code>
把字段组织成可折叠区块。分组本身不是字段,组内字段与顶层字段完全等价;
「高级选项」默认折叠,直接提交时会因校验失败自动展开。
</p>
<div class="w-full overflow-hidden">
<GroupForm />
</div>
</Card>
</Page>
</template>

Loading…
Cancel
Save