34 changed files with 2189 additions and 332 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,240 @@ |
|||
import type { RouteRecordRaw } from '@vben/types'; |
|||
|
|||
import { describe, expect, it } from 'vitest'; |
|||
|
|||
import { generateAccessible } from '../accessible'; |
|||
|
|||
// generateAccessible 会操作传入的 router 实例。这里用最小 stub 覆盖它实际调用的方法:
|
|||
// - getRoutes(): 返回 [] -> 不存在根路由 '/', 走 router.addRoute 分支
|
|||
// - addRoute/removeRoute: 空实现
|
|||
// 我们只断言返回的 accessibleRoutes 上自动生成的 redirect。
|
|||
function createRouterStub() { |
|||
return { |
|||
addRoute: () => {}, |
|||
getRoutes: () => [], |
|||
removeRoute: () => {}, |
|||
} as any; |
|||
} |
|||
|
|||
async function generate(routes: RouteRecordRaw[]) { |
|||
const { accessibleRoutes } = await generateAccessible('frontend', { |
|||
router: createRouterStub(), |
|||
routes, |
|||
}); |
|||
return accessibleRoutes; |
|||
} |
|||
|
|||
function findByName( |
|||
routes: RouteRecordRaw[], |
|||
name: string, |
|||
): RouteRecordRaw | undefined { |
|||
for (const route of routes) { |
|||
if (route.name === name) { |
|||
return route; |
|||
} |
|||
if (route.children) { |
|||
const found = findByName(route.children as RouteRecordRaw[], name); |
|||
if (found) { |
|||
return found; |
|||
} |
|||
} |
|||
} |
|||
return undefined; |
|||
} |
|||
|
|||
describe('generateAccessible - redirect normalization', () => { |
|||
it('不为动态参数(:id)首子路由的父级生成 redirect', async () => { |
|||
const routes = [ |
|||
{ |
|||
name: 'DyeSets', |
|||
path: 'dye-sets', |
|||
children: [ |
|||
{ |
|||
name: 'DyeSetDetail', |
|||
path: ':id', |
|||
meta: { hideInMenu: true, title: 'detail' }, |
|||
}, |
|||
], |
|||
meta: { title: 'dye-sets' }, |
|||
}, |
|||
] as unknown as RouteRecordRaw[]; |
|||
|
|||
const result = await generate(routes); |
|||
expect(findByName(result, 'DyeSets')?.redirect).toBeUndefined(); |
|||
}); |
|||
|
|||
it('父级为对象 redirect({name}) 且含 :id 子路由时不抛异常且不生成 redirect', async () => { |
|||
const routes = [ |
|||
{ |
|||
name: 'Production', |
|||
path: '/production', |
|||
redirect: { name: 'ProductionTasks' }, |
|||
children: [ |
|||
{ |
|||
name: 'ProductionTasks', |
|||
path: 'production-tasks', |
|||
children: [ |
|||
{ |
|||
name: 'ProductionTaskDetail', |
|||
path: ':id', |
|||
meta: { hideInMenu: true, title: 'detail' }, |
|||
}, |
|||
{ |
|||
name: 'ProductionTaskMatch', |
|||
path: ':id/match', |
|||
meta: { hideInMenu: true, title: 'match' }, |
|||
}, |
|||
], |
|||
meta: { title: 'tasks' }, |
|||
}, |
|||
], |
|||
meta: { title: 'production' }, |
|||
}, |
|||
] as unknown as RouteRecordRaw[]; |
|||
|
|||
const result = await generate(routes); |
|||
// 顶级对象 redirect 保持不变
|
|||
expect(findByName(result, 'Production')?.redirect).toEqual({ |
|||
name: 'ProductionTasks', |
|||
}); |
|||
// :id 首子路由的父级不生成 redirect
|
|||
expect(findByName(result, 'ProductionTasks')?.redirect).toBeUndefined(); |
|||
}); |
|||
|
|||
it('父级为对象 redirect 时,普通相对首子路由回退用 parent.path 拼接', async () => { |
|||
const routes = [ |
|||
{ |
|||
name: 'Setting', |
|||
path: '/setting', |
|||
redirect: { name: 'SettingService' }, |
|||
children: [ |
|||
{ |
|||
name: 'SettingGroup', |
|||
path: 'group', |
|||
children: [ |
|||
{ |
|||
name: 'SettingService', |
|||
path: 'service', |
|||
meta: { title: 'service' }, |
|||
}, |
|||
], |
|||
meta: { title: 'group' }, |
|||
}, |
|||
], |
|||
meta: { title: 'setting' }, |
|||
}, |
|||
] as unknown as RouteRecordRaw[]; |
|||
|
|||
const result = await generate(routes); |
|||
expect(findByName(result, 'SettingGroup')?.redirect).toBe( |
|||
'/setting/group/service', |
|||
); |
|||
}); |
|||
|
|||
it('深层嵌套(上游风格)相对路径逐级生成正确的累计绝对 redirect', async () => { |
|||
const routes = [ |
|||
{ |
|||
name: 'Demos', |
|||
path: '/demos', |
|||
children: [ |
|||
{ |
|||
name: 'NestedDemos', |
|||
path: 'nested', |
|||
children: [ |
|||
{ |
|||
name: 'Menu1Demo', |
|||
path: 'menu1', |
|||
meta: { title: 'menu1' }, |
|||
}, |
|||
{ |
|||
name: 'Menu2Demo', |
|||
path: 'menu2', |
|||
children: [ |
|||
{ |
|||
name: 'Menu21Demo', |
|||
path: 'menu2-1', |
|||
meta: { title: 'menu2-1' }, |
|||
}, |
|||
], |
|||
meta: { title: 'menu2' }, |
|||
}, |
|||
], |
|||
meta: { title: 'nested' }, |
|||
}, |
|||
], |
|||
meta: { title: 'demos' }, |
|||
}, |
|||
] as unknown as RouteRecordRaw[]; |
|||
|
|||
const result = await generate(routes); |
|||
// Demos 重定向到第一级子路由,子路由继续级联到叶子
|
|||
expect(findByName(result, 'Demos')?.redirect).toBe('/demos/nested'); |
|||
expect(findByName(result, 'NestedDemos')?.redirect).toBe( |
|||
'/demos/nested/menu1', |
|||
); |
|||
expect(findByName(result, 'Menu2Demo')?.redirect).toBe( |
|||
'/demos/nested/menu2/menu2-1', |
|||
); |
|||
}); |
|||
|
|||
it('首子路由为绝对路径(/foo)时不生成 redirect', async () => { |
|||
const routes = [ |
|||
{ |
|||
name: 'Dashboard', |
|||
path: '/dashboard', |
|||
children: [ |
|||
{ |
|||
name: 'Analytics', |
|||
path: '/analytics', |
|||
meta: { title: 'analytics' }, |
|||
}, |
|||
], |
|||
meta: { title: 'dashboard' }, |
|||
}, |
|||
] as unknown as RouteRecordRaw[]; |
|||
|
|||
const result = await generate(routes); |
|||
expect(findByName(result, 'Dashboard')?.redirect).toBeUndefined(); |
|||
}); |
|||
|
|||
it('首子路由为空 path 时不生成 redirect', async () => { |
|||
const routes = [ |
|||
{ |
|||
name: 'HideChildrenParent', |
|||
path: 'hide-menu-children', |
|||
children: [ |
|||
{ |
|||
name: 'HideChildren', |
|||
path: '', |
|||
meta: { title: 'hide' }, |
|||
}, |
|||
], |
|||
meta: { title: 'parent' }, |
|||
}, |
|||
] as unknown as RouteRecordRaw[]; |
|||
|
|||
const result = await generate(routes); |
|||
expect(findByName(result, 'HideChildrenParent')?.redirect).toBeUndefined(); |
|||
}); |
|||
|
|||
it('已存在的 redirect 保持不变', async () => { |
|||
const routes = [ |
|||
{ |
|||
name: 'Custom', |
|||
path: '/custom', |
|||
redirect: '/custom/keep', |
|||
children: [ |
|||
{ |
|||
name: 'CustomChild', |
|||
path: 'child', |
|||
meta: { title: 'child' }, |
|||
}, |
|||
], |
|||
meta: { title: 'custom' }, |
|||
}, |
|||
] as unknown as RouteRecordRaw[]; |
|||
|
|||
const result = await generate(routes); |
|||
expect(findByName(result, 'Custom')?.redirect).toBe('/custom/keep'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,325 @@ |
|||
# Form Array 数组字段用法 |
|||
|
|||
这个 demo 展示 `form-ui` 的数组字段 schema 写法。业务侧推荐使用 `type: 'array' + children` 描述数组结构,不需要直接指定 `component: 'VbenFormFieldArray'`。 |
|||
|
|||
示例页面: |
|||
|
|||
- `/vue-vben-admin/playground/src/views/demos/form-array/index.vue` |
|||
|
|||
核心实现: |
|||
|
|||
- `/packages/@core/ui-kit/form-ui/src/form-render/schema.ts` |
|||
- `/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue` |
|||
- `/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