refactor: 修改form-field-array支持dependencies

This commit is contained in:
layhuts
2026-07-01 11:08:35 +08:00
parent 9d36b34c4c
commit fdf2abb702
11 changed files with 1315 additions and 196 deletions

View File

@@ -101,6 +101,47 @@ describe('formApi', () => {
expect(formActions.values).toEqual(originalValuesSnapshot);
});
it('should format child schema values inside array fields', async () => {
formApi.setState({
schema: [
{
children: [
{
component: 'text',
fieldName: 'name',
valueFormat: (
value: any,
setValue: any,
_values: any,
ctx: any,
) => {
setValue('normalizedName', value?.trim());
setValue('$root.firstRow', ctx?.rowIndex);
},
},
],
fieldName: 'contacts',
type: 'array',
} as any,
],
});
const formActions: any = {
meta: {},
values: {
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
},
};
await formApi.mount(formActions, new Map());
const values = await formApi.getValues();
expect(values).toEqual({
contacts: [{ normalizedName: 'Ada' }, { normalizedName: 'Grace' }],
firstRow: 1,
});
});
it('should set field value', async () => {
const setFieldValueMock = vi.fn();
const formActions: any = {
@@ -229,6 +270,35 @@ describe('updateSchema', () => {
expect(instance.state?.schema?.[1]?.label).toBe('Age');
});
it('should update child schema by parent path', () => {
instance.state = {
schema: [
{
children: [
{ component: 'text', fieldName: 'name', label: 'Name' },
{ component: 'text', fieldName: 'phone', label: 'Phone' },
],
fieldName: 'contacts',
type: 'array',
} as any,
],
};
instance.updateSchema([
{
fieldName: 'contacts.name',
label: 'Full Name',
},
]);
expect((instance.state?.schema?.[0] as any)?.children?.[0]?.label).toBe(
'Full Name',
);
expect((instance.state?.schema?.[0] as any)?.children?.[1]?.label).toBe(
'Phone',
);
});
it('should log an error if fieldName is missing in some items', () => {
const newSchema: any[] = [
{ component: 'textarea', fieldName: 'name' },

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { FormSchema } from '../types';
import type { FormCommonConfig, FormSchema } from '../types';
import { computed } from 'vue';
@@ -9,11 +9,12 @@ import {
VbenIconButton,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
import { cn, set } from '@vben-core/shared/utils';
import { useFieldArray } from 'vee-validate';
import FormField from '../form-render/form-field.vue';
import { createArrayChildSchema } from '../form-render/schema';
defineOptions({ name: 'VbenFormFieldArray', inheritAttrs: false });
@@ -23,6 +24,8 @@ const props = withDefaults(
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig;
/**
* 新增一行时生成的默认数据;缺省时按 schema 的 fieldName 生成空对象
*/
@@ -30,6 +33,8 @@ const props = withDefaults(
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
/** 最多行数 */
max?: number;
/** 最少行数 */
@@ -51,6 +56,8 @@ const props = withDefaults(
createRow: undefined,
disabled: false,
emptyText: '暂无数据',
commonConfig: () => ({}),
globalCommonConfig: () => ({}),
max: Number.POSITIVE_INFINITY,
min: 0,
name: '',
@@ -67,12 +74,33 @@ const { fields, push, remove } = useFieldArray<Record<string, any>>(
const canAdd = computed(() => fields.value.length < props.max);
const canRemove = computed(() => fields.value.length > props.min);
const gridStyle = computed(() => {
const columns = [
...(props.showIndex ? ['3rem'] : []),
...props.schema.map(() => 'minmax(0, 1fr)'),
'4rem',
];
return {
gridTemplateColumns: columns.join(' '),
};
});
function buildDefaultRow(): Record<string, any> {
if (props.createRow) {
return props.createRow();
}
return Object.fromEntries(props.schema.map((col) => [col.fieldName, null]));
const row: Record<string, any> = {};
props.schema.forEach((col) => {
const value =
Reflect.has(col, 'defaultValue') && col.defaultValue !== undefined
? col.defaultValue
: 'type' in col && col.type === 'array'
? []
: null;
set(row, col.fieldName, value);
});
return row;
}
function addRow() {
@@ -89,83 +117,101 @@ function removeRow(index: number) {
remove(index);
}
/**
* 把列定义转换为子单元格 FormField 所需的 props。
* - fieldName 替换为嵌套路径 `name[index].fieldName`,让校验与取值落在数组元素上
* - hideLabel表头已展示列名单元格不重复显示
*/
function cellProps(col: FormSchema, index: number) {
return {
...col,
commonComponentProps: {},
disabled: props.disabled,
fieldName: `${arrayPath.value}[${index}].${col.fieldName}`,
formFieldProps: {},
hideLabel: true,
};
function rowSchemas(index: number) {
return props.schema.map((col) =>
createArrayChildSchema(col as never, {
arrayField: arrayPath.value,
commonConfig: props.commonConfig,
disabled: props.disabled,
globalCommonConfig: props.globalCommonConfig,
index,
}),
);
}
</script>
<template>
<div :class="cn('w-full', $attrs.class as string)">
<table class="w-full border-collapse">
<thead>
<tr class="border-border border-b">
<th
v-if="showIndex"
class="text-muted-foreground w-12 px-2 py-2 text-left text-sm font-normal"
>
#
</th>
<th
v-for="col in schema"
:key="col.fieldName"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<VbenRenderContent :content="col.label" />
</th>
<th
class="text-muted-foreground w-16 px-2 py-2 text-left text-sm font-normal"
>
{{ actionText }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(entry, index) in fields"
:key="entry.key"
class="border-border/60 border-b align-top"
<div class="border-border/70 overflow-hidden rounded-md border">
<div
class="bg-muted/30 border-border hidden border-b px-2 sm:grid"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<td v-if="showIndex" class="text-muted-foreground px-2 py-3 text-sm">
{{ index + 1 }}
</td>
<td v-for="col in schema" :key="col.fieldName" class="px-2 py-2">
<FormField v-bind="cellProps(col, index)" />
</td>
<td class="px-2 py-3">
<VbenIconButton
:disabled="disabled || !canRemove"
:on-click="() => removeRow(index)"
class="text-muted-foreground hover:text-destructive"
>
<X class="size-4" />
</VbenIconButton>
</td>
</tr>
</tbody>
</table>
#
</div>
<div
v-for="col in schema"
:key="col.fieldName"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<VbenRenderContent :content="col.label" />
</div>
<div
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
{{ actionText }}
</div>
</div>
<div
v-if="fields.length === 0"
class="text-muted-foreground border-border/60 border-b py-6 text-center text-sm"
>
{{ emptyText }}
<div
v-for="(entry, index) in fields"
:key="entry.key"
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground mb-2 text-sm sm:mb-0 sm:px-4 sm:py-3"
>
<span class="sm:hidden">#</span>
{{ index + 1 }}
</div>
<template
v-for="(childSchema, childIndex) in rowSchemas(index)"
:key="childSchema.fieldName"
>
<div class="min-w-0 py-2 sm:px-2">
<div
class="text-muted-foreground mb-1 text-xs font-medium sm:hidden"
>
<VbenRenderContent :content="schema?.[childIndex]?.label" />
</div>
<FormField
v-bind="childSchema"
:class="childSchema.formItemClass"
/>
</div>
</template>
<div class="flex justify-end pt-1 sm:block sm:px-2 sm:py-3">
<VbenIconButton
type="button"
:disabled="disabled || !canRemove"
:on-click="() => removeRow(index)"
class="text-muted-foreground hover:text-destructive"
>
<X class="size-4" />
</VbenIconButton>
</div>
</div>
<div
v-if="fields.length === 0"
class="text-muted-foreground py-6 text-center text-sm"
>
{{ emptyText }}
</div>
</div>
<VbenButton
variant="outline"
size="sm"
type="button"
:disabled="disabled || !canAdd"
class="mt-3 w-full border-dashed"
@click="addRow"

View File

@@ -30,6 +30,10 @@ import {
} from '@vben-core/shared/utils';
import { resolveFieldNamePath } from './field-name';
import {
getFormArraySchemaChildren,
resolveArrayChildFieldName,
} from './form-render/schema';
function getDefaultState(): VbenFormProps {
return {
@@ -393,25 +397,10 @@ export class FormApi {
);
return;
}
const currentSchema = [...(this.state?.schema ?? [])];
const updatedMap: Record<string, any> = {};
updated.forEach((item) => {
if (item.fieldName) {
updatedMap[item.fieldName] = item;
}
});
currentSchema.forEach((schema, index) => {
const updatedData = updatedMap[schema.fieldName];
if (updatedData) {
currentSchema[index] = mergeWithArrayOverride(
updatedData,
schema,
) as FormSchema;
}
});
const currentSchema = this.updateSchemaList(
[...(this.state?.schema ?? [])],
updated,
);
this.setState({ schema: currentSchema });
}
@@ -456,6 +445,79 @@ export class FormApi {
return validateResult;
}
private applyValueFormatBySchemas(
schemas: FormSchema[],
values: Record<string, any>,
parentPath?: string,
parentContext?: {
arrayField?: string;
row?: Record<string, any>;
rowIndex?: number;
rowPath?: string;
},
) {
schemas.forEach((schema) => {
const fieldName = parentPath
? resolveArrayChildFieldName(parentPath, schema.fieldName)
: schema.fieldName;
const row =
parentPath && parentContext?.rowPath
? this.resolveValueByFieldName(values, parentContext.rowPath)
: parentContext?.row;
const schemaContext = {
...parentContext,
fieldName,
originalFieldName: schema.fieldName,
rootValues: values,
row,
};
const children = getFormArraySchemaChildren(schema);
if (children.length > 0) {
const arrayValue = this.resolveValueByFieldName(values, fieldName);
if (Array.isArray(arrayValue)) {
arrayValue.forEach((rowValue, index) => {
const rowPath = `${fieldName}[${index}]`;
this.applyValueFormatBySchemas(
children as FormSchema[],
values,
rowPath,
{
arrayField: fieldName,
row: rowValue,
rowIndex: index,
rowPath,
},
);
});
}
}
if (schema.valueFormat) {
const value = this.resolveValueByFieldName(values, fieldName);
this.deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
this.setValueByFieldName(
values,
this.resolveValueFormatFieldName(key, parentPath),
nextValue,
);
},
values,
schemaContext,
);
if (formattedValue !== undefined) {
this.setValueByFieldName(values, fieldName, formattedValue);
}
}
});
}
private deleteValueByFieldName(
values: Record<string, any>,
fieldName: string,
@@ -611,30 +673,7 @@ export class FormApi {
private handleValueFormat = (originValues: Record<string, any>) => {
const values = { ...originValues };
const currentSchema = this.state?.schema ?? [];
currentSchema.forEach((schema) => {
if (!schema.valueFormat) {
return;
}
const fieldName = schema.fieldName;
const value = this.resolveValueByFieldName(values, fieldName);
this.deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
this.setValueByFieldName(values, key, nextValue);
},
values,
);
if (formattedValue !== undefined) {
this.setValueByFieldName(values, fieldName, formattedValue);
}
});
this.applyValueFormatBySchemas(this.state?.schema ?? [], values);
return values;
};
@@ -654,6 +693,27 @@ export class FormApi {
});
};
private resolveChildUpdateFieldName(
parentFieldName: string,
fieldName: string,
) {
if (fieldName.startsWith(`${parentFieldName}.`)) {
return fieldName.slice(parentFieldName.length + 1);
}
const indexedPrefix = `${parentFieldName}[`;
if (!fieldName.startsWith(indexedPrefix)) {
return;
}
const closeIndex = fieldName.indexOf(']', indexedPrefix.length);
if (closeIndex === -1 || fieldName[closeIndex + 1] !== '.') {
return;
}
return fieldName.slice(closeIndex + 2);
}
private resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
@@ -666,6 +726,51 @@ export class FormApi {
return get(values, fieldName);
}
private resolveValueFormatFieldName(fieldName: string, parentPath?: string) {
if (!parentPath) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${parentPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === parentPath || fieldName.startsWith(`${parentPath}.`)) {
return fieldName;
}
return `${parentPath}.${fieldName}`;
}
private setSchemaChildren(schema: FormSchema, children: FormSchema[]) {
if ('children' in schema && Array.isArray(schema.children)) {
return {
...schema,
children,
} as FormSchema;
}
if (
!isFunction(schema.componentProps) &&
schema.componentProps &&
Array.isArray((schema.componentProps as Record<string, any>).schema)
) {
return {
...schema,
componentProps: {
...(schema.componentProps as Record<string, any>),
schema: children,
},
} as FormSchema;
}
return schema;
}
private setValueByFieldName(
values: Record<string, any>,
fieldName: string,
@@ -680,6 +785,48 @@ export class FormApi {
set(values, fieldName, value);
}
private updateSchemaList(
currentSchema: FormSchema[],
updated: Partial<FormSchema>[],
): FormSchema[] {
return currentSchema.map((schema): FormSchema => {
const exactUpdatedData = updated.find(
(item) => item.fieldName === schema.fieldName,
);
if (exactUpdatedData) {
return mergeWithArrayOverride(exactUpdatedData, schema) as FormSchema;
}
const children = getFormArraySchemaChildren(schema);
if (children.length === 0) {
return schema;
}
const childUpdates = updated
.map((item) => {
const fieldName = item.fieldName
? this.resolveChildUpdateFieldName(schema.fieldName, item.fieldName)
: undefined;
return fieldName
? ({
...item,
fieldName,
} as Partial<FormSchema>)
: undefined;
})
.filter(Boolean) as Partial<FormSchema>[];
if (childUpdates.length === 0) {
return schema;
}
return this.setSchemaChildren(
schema,
this.updateSchemaList(children as FormSchema[], childUpdates),
);
});
}
private updateState() {
const currentSchema = this.state?.schema ?? [];
const prevSchema = this.prevState?.schema ?? [];

View File

@@ -78,6 +78,9 @@ const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
const formApi = formRenderProps.form;
const compact = computed(() => formRenderProps.compact);
const isInValid = computed(() => errors.value?.length > 0);
const shouldApplyInvalidStyle = computed(() => {
return isInValid.value && component !== 'VbenFormFieldArray';
});
const collapseOpen = ref(!defaultCollapsed);
function getFormApi(): FormActions {
@@ -338,7 +341,7 @@ onUnmounted(() => {
<FormItem
v-show="isShow"
:class="{
'form-valid-error': isInValid,
'form-valid-error': shouldApplyInvalidStyle,
'form-is-required': shouldRequired,
'flex-col': isVertical,
'flex-row items-center': !isVertical,
@@ -406,7 +409,7 @@ onUnmounted(() => {
ref="fieldComponentRef"
:class="{
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
isInValid,
shouldApplyInvalidStyle,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"

View File

@@ -2,27 +2,19 @@
import type { GenericObject } from 'vee-validate';
import type { ZodTypeAny } from 'zod';
import type {
FormCommonConfig,
FormRenderProps,
FormSchema,
FormShape,
} from '../types';
import type { FormCommonConfig, FormRenderProps, FormShape } from '../types';
import type { NormalizedFormFieldSchema } from './schema';
import { computed } from 'vue';
import { Form } from '@vben-core/shadcn-ui';
import {
cn,
isFunction,
isString,
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
import { cn, isString } from '@vben-core/shared/utils';
import { provideFormRenderProps } from './context';
import { useExpandable } from './expandable';
import FormField from './form-field.vue';
import { getBaseRules, getDefaultValueInZodStack } from './helper';
import { createFormFieldSchema } from './schema';
interface Props extends FormRenderProps {}
@@ -95,78 +87,23 @@ const formCollapsed = computed(() => {
return props.collapsed && isCalculated.value;
});
const computedSchema = computed(
(): (Omit<FormSchema, 'formFieldProps'> & {
commonComponentProps: Record<string, any>;
formFieldProps: Record<string, any>;
})[] => {
const {
colon = false,
componentProps = {},
controlClass = '',
disabled,
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
formFieldProps = {},
formItemClass = '',
hideLabel = false,
hideRequiredMark = false,
labelClass = '',
labelWidth = 100,
modelPropName = '',
wrapperClass = '',
} = mergeWithArrayOverride(props.commonConfig, props.globalCommonConfig);
return (props.schema || []).map((schema, index) => {
const keepIndex = keepFormItemIndex.value;
const computedSchema = computed((): NormalizedFormFieldSchema[] => {
return (props.schema || []).map((schema, index) => {
const keepIndex = keepFormItemIndex.value;
const hidden =
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
props.showCollapseButton && !!formCollapsed.value && keepIndex
? keepIndex <= index
: false;
const hidden =
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
props.showCollapseButton && !!formCollapsed.value && keepIndex
? keepIndex <= index
: false;
// 处理函数形式的formItemClass
let resolvedSchemaFormItemClass = schema.formItemClass;
if (isFunction(schema.formItemClass)) {
try {
resolvedSchemaFormItemClass = schema.formItemClass();
} catch (error) {
console.error('Error calling formItemClass function:', error);
resolvedSchemaFormItemClass = '';
}
}
return {
colon,
disabled,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
hideLabel,
hideRequiredMark,
labelWidth,
modelPropName,
wrapperClass,
...schema,
commonComponentProps: componentProps,
componentProps: schema.componentProps,
controlClass: cn(controlClass, schema.controlClass),
formFieldProps: {
...formFieldProps,
...schema.formFieldProps,
},
formItemClass: cn(
'shrink-0',
{ hidden },
formItemClass,
resolvedSchemaFormItemClass,
),
labelClass: cn(labelClass, schema.labelClass),
};
return createFormFieldSchema(schema as never, {
commonConfig: props.commonConfig,
globalCommonConfig: props.globalCommonConfig,
hidden,
});
},
);
});
});
</script>
<template>

View File

@@ -0,0 +1,341 @@
import type {
BaseFormComponentType,
FormActions,
FormCommonConfig,
FormFieldProps,
FormItemDependencies,
FormSchema,
FormSchemaContext,
MaybeComponentProps,
} from '../types';
import {
get,
isFunction,
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
type AnyFormSchema = FormSchema<BaseFormComponentType, Record<string, any>>;
export type NormalizedFormFieldSchema = FormFieldProps & {
commonComponentProps: MaybeComponentProps;
formFieldProps: Record<string, any>;
formItemClass: string;
};
interface CreateFormFieldSchemaOptions {
commonConfig?: FormCommonConfig;
disabled?: boolean;
forceHideLabel?: boolean;
globalCommonConfig?: FormCommonConfig;
hidden?: boolean;
}
interface CreateArrayChildSchemaOptions extends CreateFormFieldSchemaOptions {
arrayField: string;
index: number;
}
function createSchemaContext(
baseContext: FormSchemaContext,
values?: Partial<Record<string, any>>,
): FormSchemaContext {
const rootValues = values as Record<string, any> | undefined;
return {
...baseContext,
rootValues,
row:
baseContext.rowPath && rootValues
? get(rootValues, baseContext.rowPath)
: undefined,
};
}
function scopeRowFieldName(rowPath: string, fieldName: string) {
if (!fieldName) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${rowPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === rowPath || fieldName.startsWith(`${rowPath}.`)) {
return fieldName;
}
return `${rowPath}.${fieldName}`;
}
function wrapComponentProps(
componentProps: AnyFormSchema['componentProps'],
baseContext: FormSchemaContext,
) {
if (!isFunction(componentProps)) {
return componentProps;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
componentProps(values, actions, createSchemaContext(baseContext, values));
}
function wrapCustomParamsRender(
render: AnyFormSchema['help'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
}
function wrapRenderComponentContent(
render: AnyFormSchema['renderComponentContent'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
}
function wrapDependencyFn<T>(handler: T, baseContext: FormSchemaContext): T {
if (!isFunction(handler)) {
return handler;
}
return ((
values: Partial<Record<string, any>>,
actions: FormActions,
controller: any,
) =>
handler(
values,
actions,
controller,
createSchemaContext(baseContext, values),
)) as T;
}
function scopeDependencies(
dependencies: FormItemDependencies | undefined,
baseContext: FormSchemaContext,
) {
if (!dependencies) {
return dependencies;
}
const rowPath = baseContext.rowPath;
if (!rowPath) {
return dependencies;
}
return {
...dependencies,
componentProps: wrapDependencyFn(dependencies.componentProps, baseContext),
disabled: wrapDependencyFn(dependencies.disabled, baseContext),
if: wrapDependencyFn(dependencies.if, baseContext),
required: wrapDependencyFn(dependencies.required, baseContext),
rules: wrapDependencyFn(dependencies.rules, baseContext),
show: wrapDependencyFn(dependencies.show, baseContext),
trigger: wrapDependencyFn(dependencies.trigger, baseContext),
triggerFields:
dependencies.triggerFields?.map((fieldName) =>
scopeRowFieldName(rowPath, fieldName),
) ?? [],
};
}
function createArrayComponentProps(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions,
) {
const componentProps = schema.componentProps;
const arrayProps = 'arrayProps' in schema ? schema.arrayProps : undefined;
const children = getFormArraySchemaChildren(schema);
const commonConfig = options.commonConfig;
const globalCommonConfig = options.globalCommonConfig;
const schemaProps = children.length > 0 ? { schema: children } : {};
if (isFunction(componentProps)) {
return (values: Partial<Record<string, any>>, actions: FormActions) => ({
...arrayProps,
...componentProps(values, actions),
commonConfig,
globalCommonConfig,
...schemaProps,
});
}
return {
...arrayProps,
...componentProps,
commonConfig,
globalCommonConfig,
...schemaProps,
};
}
function createArrayFieldSchema(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions,
) {
const restSchema = { ...(schema as AnyFormSchema & Record<string, any>) };
Reflect.deleteProperty(restSchema, 'arrayProps');
Reflect.deleteProperty(restSchema, 'children');
Reflect.deleteProperty(restSchema, 'type');
return {
...restSchema,
component: 'VbenFormFieldArray',
componentProps: createArrayComponentProps(schema, options),
};
}
export function getFormArraySchemaChildren(schema: Partial<AnyFormSchema>) {
if ('children' in schema && Array.isArray(schema.children)) {
return schema.children;
}
const componentProps = schema.componentProps;
if (
!isFunction(componentProps) &&
componentProps &&
Array.isArray((componentProps as Record<string, any>).schema)
) {
return (componentProps as Record<string, any>).schema;
}
return [];
}
export function isFormArraySchema(schema: Partial<AnyFormSchema>) {
return (
('type' in schema && schema.type === 'array') ||
schema.component === 'VbenFormFieldArray' ||
getFormArraySchemaChildren(schema).length > 0
);
}
export function resolveArrayChildFieldName(rowPath: string, fieldName: string) {
return scopeRowFieldName(rowPath, fieldName);
}
export function createFormFieldSchema(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions = {},
): NormalizedFormFieldSchema {
const commonConfig = mergeWithArrayOverride(
options.commonConfig ?? {},
options.globalCommonConfig ?? {},
);
const {
colon = false,
componentProps = {},
controlClass = '',
disabled,
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
formFieldProps = {},
formItemClass = '',
hideLabel = false,
hideRequiredMark = false,
labelClass = '',
labelWidth = 100,
modelPropName = '',
wrapperClass = '',
} = commonConfig;
const normalizedSchema = isFormArraySchema(schema)
? createArrayFieldSchema(schema, options)
: schema;
let resolvedSchemaFormItemClass = normalizedSchema.formItemClass;
if (isFunction(normalizedSchema.formItemClass)) {
try {
resolvedSchemaFormItemClass = normalizedSchema.formItemClass();
} catch (error) {
console.error('Error calling formItemClass function:', error);
resolvedSchemaFormItemClass = '';
}
}
return {
colon,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
hideRequiredMark,
labelWidth,
modelPropName,
wrapperClass,
...normalizedSchema,
commonComponentProps: componentProps as MaybeComponentProps,
componentProps: normalizedSchema.componentProps,
controlClass: [controlClass, normalizedSchema.controlClass]
.filter(Boolean)
.join(' '),
formFieldProps: {
...formFieldProps,
...normalizedSchema.formFieldProps,
},
formItemClass: [
'shrink-0',
options.hidden ? 'hidden' : '',
formItemClass,
resolvedSchemaFormItemClass,
]
.filter(Boolean)
.join(' '),
labelClass: [labelClass, normalizedSchema.labelClass]
.filter(Boolean)
.join(' '),
disabled: options.disabled ?? normalizedSchema.disabled ?? disabled,
hideLabel:
options.forceHideLabel ?? normalizedSchema.hideLabel ?? hideLabel,
} as NormalizedFormFieldSchema;
}
export function createArrayChildSchema(
schema: AnyFormSchema,
options: CreateArrayChildSchemaOptions,
): NormalizedFormFieldSchema {
const rowPath = `${options.arrayField}[${options.index}]`;
const fieldName = resolveArrayChildFieldName(rowPath, schema.fieldName);
const baseContext: FormSchemaContext = {
arrayField: options.arrayField,
fieldName,
originalFieldName: schema.fieldName,
rowIndex: options.index,
rowPath,
};
return createFormFieldSchema(
{
...schema,
componentProps: wrapComponentProps(schema.componentProps, baseContext),
dependencies: scopeDependencies(schema.dependencies, baseContext),
fieldName,
help: wrapCustomParamsRender(schema.help, baseContext),
renderComponentContent: wrapRenderComponentContent(
schema.renderComponentContent,
baseContext,
),
},
{
commonConfig: options.commonConfig,
disabled: options.disabled || schema.disabled,
forceHideLabel: true,
globalCommonConfig: options.globalCommonConfig,
},
);
}

View File

@@ -4,6 +4,7 @@ export type {
BaseFormComponentType,
ExtendedFormApi,
FormLayout,
FormSchemaContext,
VbenFormFieldArrayProps,
VbenFormProps,
FormSchema as VbenFormSchema,

View File

@@ -66,6 +66,23 @@ export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
export type FormActions = FormContext<GenericObject>;
export interface FormSchemaContext {
/** 数组字段名,例如 contacts */
arrayField?: string;
/** 当前真实字段名,例如 contacts[0].name */
fieldName?: string;
/** 原始 schema 字段名,例如 name */
originalFieldName?: string;
/** 表单完整值 */
rootValues?: Record<string, any>;
/** 当前行数据 */
row?: Record<string, any>;
/** 当前行索引 */
rowIndex?: number;
/** 当前行路径,例如 contacts[0] */
rowPath?: string;
}
export type CustomRenderType = (() => Component | string) | string;
// 动态渲染参数
@@ -73,6 +90,7 @@ export type CustomParamsRenderType =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => Component | string)
| string;
@@ -87,18 +105,21 @@ type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => T;
type FormItemDependenciesConditionWithRules = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
type FormItemDependenciesConditionWithProps = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
export interface FormItemDependencies {
@@ -145,6 +166,7 @@ type ComponentProps =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => MaybeComponentProps)
| MaybeComponentProps;
@@ -233,12 +255,14 @@ export interface FormCommonConfig {
type RenderComponentContentType = (
value: Partial<Record<string, any>>,
api: FormActions,
ctx?: FormSchemaContext,
) => Record<string, any>;
type MappedComponentProps<P> =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => P & Record<string, any>)
| (P & Record<string, any>);
@@ -253,6 +277,7 @@ export type FormValueFormat = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
ctx?: FormSchemaContext,
) => any;
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
@@ -303,10 +328,32 @@ type FormSchemaFallback<T extends BaseFormComponentType> = {
componentProps?: ComponentProps;
} & FormSchemaBody;
type FormArraySchema<
T extends BaseFormComponentType,
P extends Record<string, any>,
> = {
/** 内置数组编辑器参数 */
arrayProps?: Omit<
VbenFormFieldArrayProps<T, P>,
'disabled' | 'globalCommonConfig' | 'name' | 'schema'
>;
/** 数组子字段定义 */
children: FormSchema<T, P>[];
/** 兼容显式指定内置数组编辑器 */
component?: Component | T;
/** 兼容通过 componentProps 传递数组编辑器参数 */
componentProps?: ComponentProps;
/** 数组字段标记 */
type: 'array';
} & FormSchemaBody;
export type FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = FormSchemaDiscriminated<T, P> | FormSchemaFallback<T>;
> =
| FormArraySchema<T, P>
| FormSchemaDiscriminated<T, P>
| FormSchemaFallback<T>;
/**
* 数组编辑器VbenFormFieldArray的组件参数
@@ -319,17 +366,23 @@ export interface VbenFormFieldArrayProps<
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig;
/** 新增一行时生成的默认数据;缺省时按列定义的 fieldName 生成空对象 */
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/** 数组字段路径,由外层 FormField 透传 */
name?: string;
/** 列定义,每一列是一个子字段(复用 FormSchema */
schema: FormSchema<T, P>[];
schema?: FormSchema<T, P>[];
/** 是否显示序号列 */
showIndex?: boolean;
}

View File

@@ -110,6 +110,15 @@ const routes: RouteRecordRaw[] = [
title: $t('examples.form.collapsible'),
},
},
{
name: 'FormArrayDemo',
path: '/form-array-demo',
component: () => import('#/views/demos/form-array/index.vue'),
meta: {
icon: 'lucide:list-plus',
title: '表单数组 Demo',
},
},
],
},
{

View File

@@ -0,0 +1,325 @@
# Form Array 数组字段用法
这个 demo 展示 `form-ui` 的数组字段 schema 写法。业务侧推荐使用 `type: 'array' + children` 描述数组结构,不需要直接指定 `component: 'VbenFormFieldArray'`
示例页面:
- [index.vue](/Users/lht/workspace/webstorm_workspace/hotline-ui/apps/hotline-web/src/views/demos/form-array/index.vue)
核心实现:
- [schema.ts](/Users/lht/workspace/webstorm_workspace/hotline-ui/packages/@core/ui-kit/form-ui/src/form-render/schema.ts)
- [form-field-array.vue](/Users/lht/workspace/webstorm_workspace/hotline-ui/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue)
- [form-api.ts](/Users/lht/workspace/webstorm_workspace/hotline-ui/packages/@core/ui-kit/form-ui/src/form-api.ts)
## 快速开始
```ts
const schema: VbenFormSchema[] = [
{
type: 'array',
fieldName: 'contacts',
label: '联系人',
formItemClass: 'col-span-1 md:col-span-2',
defaultValue: [
{
enabled: true,
name: '张三',
phone: '10086',
role: 'owner',
},
],
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
arrayProps: {
addButtonText: '添加联系人',
min: 1,
max: 5,
createRow: () => ({
enabled: true,
name: '',
phone: '',
role: 'member',
}),
},
children: [
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
},
{
component: 'Select',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
componentProps: {
options: [
{ label: '负责人', value: 'owner' },
{ label: '成员', value: 'member' },
],
},
},
],
},
];
```
## 字段职责
### `type: 'array'`
声明这是一个数组字段。渲染前会被内部转换为 `VbenFormFieldArray`,所以业务 schema 不需要写具体组件名。
### `fieldName`
数组字段名。假设为 `contacts`,第 1 行子字段 `name` 会被转换成:
```ts
contacts[0].name
```
### `children`
数组每一行的子字段定义。每个 child 都是完整的 `FormSchema`,可以继续使用:
- `component`
- `componentProps`
- `rules`
- `dependencies`
- `defaultValue`
- `help`
- `suffix`
- `renderComponentContent`
- `valueFormat`
- `formFieldProps`
- `disabled`
- `hide`
- `labelClass`
- `controlClass`
### `arrayProps`
传给数组编辑器的配置:
| 字段 | 说明 |
| --- | --- |
| `addButtonText` | 新增按钮文案 |
| `actionText` | 操作列表头文案 |
| `emptyText` | 空数据文案 |
| `min` | 最少行数,达到后禁用删除 |
| `max` | 最多行数,达到后禁用新增 |
| `showIndex` | 是否显示序号 |
| `createRow` | 新增行时生成默认数据 |
## 校验建议
数组父级 `rules` 建议只写数组级规则,例如至少一行:
```ts
rules: z.array(z.any()).min(1, '请至少添加一个联系人')
```
每个子字段的必填、长度、格式校验写在 children 自己的 `rules`
```ts
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
}
```
不要在父级数组规则里重复写 `z.object({ name: ... })`,否则某一行子字段失败时,父级数组也会失败,容易出现重复错误提示。
## dependencies 用法
children 里的 `dependencies.triggerFields` 默认是“当前行相对路径”。例如:
```ts
{
component: 'Input',
fieldName: 'phone',
label: '电话',
dependencies: {
triggerFields: ['role'],
componentProps: (_values, _form, _api, ctx) => ({
disabled: ctx?.row?.role === 'viewer',
placeholder:
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
}),
},
}
```
在第 1 行中,`triggerFields: ['role']` 会被转换成:
```ts
contacts[0].role
```
回调多了一个可选 `ctx` 参数:
| 字段 | 说明 |
| --- | --- |
| `ctx.row` | 当前行数据 |
| `ctx.rowIndex` | 当前行索引 |
| `ctx.rowPath` | 当前行路径,例如 `contacts[0]` |
| `ctx.arrayField` | 数组字段名,例如 `contacts` |
| `ctx.fieldName` | 当前真实字段名,例如 `contacts[0].phone` |
| `ctx.originalFieldName` | 原始 child 字段名,例如 `phone` |
| `ctx.rootValues` | 表单完整值 |
如果 child 需要依赖表单根字段,可以使用 `$root.` 前缀:
```ts
dependencies: {
triggerFields: ['$root.planName'],
componentProps: (values) => ({
disabled: !values.planName,
}),
}
```
如果想显式写当前行字段,也可以使用 `$row.` 前缀:
```ts
triggerFields: ['$row.role']
```
## valueFormat 用法
children 里的 `valueFormat` 也会按行执行:
```ts
{
component: 'Input',
fieldName: 'phone',
label: '电话',
valueFormat: (value, setValue) => {
const nextValue = value?.trim();
if (!nextValue) {
return;
}
setValue('phone', nextValue);
},
}
```
`contacts[0]` 中,`setValue('phone', nextValue)` 会自动写到:
```ts
contacts[0].phone
```
如果要写根字段,用 `$root.`
```ts
setValue('$root.firstContactPhone', value)
```
如果要显式写当前行字段,用 `$row.`
```ts
setValue('$row.phone', value)
```
## updateSchema 用法
可以用父级路径更新 child schema
```ts
formApi.updateSchema([
{
fieldName: 'contacts.phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
```
如果传入带索引路径,也会更新对应 child 定义:
```ts
formApi.updateSchema([
{
fieldName: 'contacts[0].phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
```
注意:`updateSchema` 更新的是 schema 定义,不是单行实例。因此 `contacts[0].phone` 这种写法目前会解析到 child `phone`,实际影响所有行的该列。
## 新增行默认值
优先使用 `arrayProps.createRow`
```ts
arrayProps: {
createRow: () => ({
name: '',
role: 'member',
phone: '',
enabled: true,
}),
}
```
没有 `createRow` 时,会根据 children 的 `defaultValue` 生成行数据;没有 defaultValue 的字段会给 `null`
## 内部流程
数组字段从 schema 到渲染大致是这条链路:
```mermaid
flowchart TD
A["业务 schema: type='array' + children"] --> B["form.vue computedSchema"]
B --> C["createFormFieldSchema"]
C --> D["识别数组 schema"]
D --> E["转换为 component='VbenFormFieldArray'"]
E --> F["children 放入 componentProps.schema"]
F --> G["form-field.vue 渲染外层 FormField"]
G --> H["form-field-array.vue useFieldArray 管理行"]
H --> I["createArrayChildSchema"]
I --> J["child.fieldName 转为 contacts[index].xxx"]
I --> K["scope dependencies triggerFields"]
I --> L["包装 componentProps/help/render/valueFormat ctx"]
J --> M["继续复用 FormField 渲染 child"]
```
几个关键点:
- 外层 `type: 'array'` 只是语义声明。
- 具体展示仍复用内部 `VbenFormFieldArray`
- child 最终仍然走 `FormField`,所以现有 FormSchema 能力不会丢。
- `dependencies` 不改核心调用链,而是在 `createArrayChildSchema` 里做路径和 ctx 适配。
- `valueFormat``updateSchema``FormApi` 里递归处理 children。
## 小屏幕展示
`form-field-array` 在大屏下按表格式 grid 展示,在小屏下每行转为纵向堆叠,并显示每个 child 的 label。业务侧通常不需要额外处理移动端布局。
## 兼容旧写法
旧写法仍可用:
```ts
{
component: 'VbenFormFieldArray',
fieldName: 'contacts',
componentProps: {
schema: [...],
},
}
```
新代码推荐:
```ts
{
type: 'array',
fieldName: 'contacts',
children: [...],
}
```

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, Space } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
const submitValues = ref<Record<string, any>>({});
const schema: VbenFormSchema[] = [
{
component: 'Input',
componentProps: {
placeholder: '请输入方案名称',
},
defaultValue: '值班联络人配置',
fieldName: 'planName',
label: '方案名称',
rules: z.string().min(1, '请输入方案名称'),
},
{
component: 'Textarea',
dependencies: {
componentProps: (values) => {
const planName = values.planName as string | undefined;
return {
disabled: !planName,
placeholder: planName ? `${planName} 的补充说明` : '请先填写方案名称',
rows: 2,
};
},
required: (values) => {
return String(values.planName ?? '').includes('值班');
},
rules: (values) => {
return String(values.planName ?? '').includes('值班')
? z.string().min(2, '请输入至少 2 个字')
: z.string().optional();
},
triggerFields: ['planName'],
},
fieldName: 'description',
formItemClass: 'col-span-1 md:col-span-2',
label: '方案说明',
},
{
arrayProps: {
addButtonText: '添加联系人',
createRow: () => ({
enabled: true,
name: '',
phone: '',
role: 'member',
}),
max: 5,
min: 1,
},
children: [
{
component: 'Input',
componentProps: (_values, _form, ctx) => ({
placeholder: `${(ctx?.rowIndex ?? 0) + 1} 行姓名`,
}),
defaultValue: '',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
valueFormat: (value) => value?.trim(),
},
{
component: 'Select',
componentProps: {
options: [
{ label: '负责人', value: 'owner' },
{ label: '成员', value: 'member' },
{ label: '观察员', value: 'viewer' },
],
},
defaultValue: 'member',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
},
{
component: 'Input',
dependencies: {
componentProps: (_values, _form, _api, ctx) => ({
disabled: ctx?.row?.role === 'viewer',
placeholder:
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
}),
triggerFields: ['role'],
},
fieldName: 'phone',
label: '电话',
rules: z.string().optional(),
valueFormat: (value, setValue) => {
const nextValue = value?.trim();
if (!nextValue) {
return;
}
setValue('phone', nextValue);
},
},
{
component: 'Switch',
componentProps: {
checkedChildren: '启用',
unCheckedChildren: '停用',
},
defaultValue: true,
fieldName: 'enabled',
label: '状态',
},
],
defaultValue: [
{
enabled: true,
name: '张三',
phone: ' 10086 ',
role: 'owner',
},
],
fieldName: 'contacts',
formItemClass: 'col-span-1 md:col-span-2',
label: '联系人',
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
type: 'array',
},
];
const [Form, formApi] = useVbenForm({
commonConfig: {
labelWidth: 90,
},
handleSubmit: (values) => {
submitValues.value = values;
message.success('已通过校验');
},
schema,
showDefaultActions: false,
wrapperClass: 'grid-cols-1 gap-x-4 md:grid-cols-2',
});
async function handleSubmit() {
await formApi.validateAndSubmitForm();
}
async function handleGetValues() {
submitValues.value = await formApi.getValues();
}
function handlePatchChildRule() {
formApi.updateSchema([
{
fieldName: 'contacts.phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
message.success('已动态更新子字段规则');
}
</script>
<template>
<Page title="Form Array Demo">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card title="数组字段">
<Form />
<Space class="mt-4 flex flex-wrap">
<Button type="primary" @click="handleSubmit">提交</Button>
<Button @click="handleGetValues">获取值</Button>
<Button @click="handlePatchChildRule">更新电话规则</Button>
</Space>
</Card>
<Card title="输出">
<pre
class="bg-muted text-muted-foreground max-h-[420px] overflow-auto rounded-md p-3 text-xs"
>{{ JSON.stringify(submitValues, null, 2) }}</pre>
</Card>
</div>
</Page>
</template>