refactor: 修改form-field-array支持dependencies
This commit is contained in:
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
325
playground/src/views/demos/form-array/README.md
Normal file
325
playground/src/views/demos/form-array/README.md
Normal 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: [...],
|
||||
}
|
||||
```
|
||||
187
playground/src/views/demos/form-array/index.vue
Normal file
187
playground/src/views/demos/form-array/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user