11 changed files with 1316 additions and 197 deletions
@ -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, |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
@ -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: [...], |
||||
|
} |
||||
|
``` |
||||
@ -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> |
||||
Loading…
Reference in new issue