feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray

pull/355/head^2
Saleri 2026-05-31 15:03:55 +08:00
parent 108d7ff335
commit e5f9106caa
8 changed files with 341 additions and 0 deletions

View File

@ -3,6 +3,7 @@
"naive": "Naive UI",
"table": "Table",
"form": "Form",
"arrayForm": "Array Editor",
"vben": {
"title": "Project",
"about": "About",

View File

@ -3,6 +3,7 @@
"naive": "Naive UI",
"table": "Table",
"form": "表单",
"arrayForm": "数组编辑器",
"vben": {
"title": "项目",
"about": "关于",

View File

@ -37,6 +37,14 @@ const routes: RouteRecordRaw[] = [
path: '/demos/form',
component: () => import('#/views/demos/form/basic.vue'),
},
{
meta: {
title: $t('demos.arrayForm'),
},
name: 'ArrayForm',
path: '/demos/array-form',
component: () => import('#/views/demos/naive/array-form/index.vue'),
},
],
},
];

View File

@ -0,0 +1,123 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { NButton, NCard, useMessage } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
const message = useMessage();
const [Form, formApi] = useVbenForm({
layout: 'vertical',
wrapperClass: 'grid-cols-1',
handleSubmit: (values) => {
message.success(`提交成功:${JSON.stringify(values)}`);
},
schema: [
{
component: 'Input',
fieldName: 'projectName',
label: '项目名称',
rules: 'required',
},
{
component: 'VbenFormFieldArray',
fieldName: 'members',
label: '项目成员',
// useFieldArray 使
defaultValue: [],
componentProps: {
min: 1,
max: 5,
createRow: () => ({
name: null,
age: null,
role: null,
joinDate: null,
active: true,
}),
// vbenForm
schema: [
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: 'required',
componentProps: { placeholder: '请输入姓名' },
},
{
component: 'InputNumber',
fieldName: 'age',
label: '年龄',
componentProps: { min: 0, max: 150 },
},
{
component: 'Select',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
componentProps: {
placeholder: '请选择',
options: [
{ label: '前端', value: 'fe' },
{ label: '后端', value: 'be' },
{ label: '测试', value: 'qa' },
{ label: '产品', value: 'pm' },
],
},
},
{
component: 'DatePicker',
fieldName: 'joinDate',
label: '入职日期',
},
{
component: 'Switch',
fieldName: 'active',
label: '在职',
},
],
},
},
],
});
function setFormValues() {
formApi.setValues({
projectName: 'Vben Admin',
members: [
{ name: '张三', age: 28, role: 'fe', joinDate: Date.now(), active: true },
{
name: '李四',
age: 32,
role: 'be',
joinDate: Date.now(),
active: false,
},
],
});
}
async function getFormValues() {
const values = await formApi.getValues();
message.info(JSON.stringify(values));
}
</script>
<template>
<Page
description="基于 useVbenForm 的数组编辑器VbenFormFieldArray可增删行每个单元格复用 vbenForm 注册的编辑组件,并享受逐格校验。"
title="数组编辑器表单"
>
<NCard title="数组编辑器">
<template #header-extra>
<NButton class="mr-2" @click="setFormValues"></NButton>
<NButton class="mr-2" @click="getFormValues"></NButton>
<NButton type="primary" @click="formApi.submitForm()">
提交校验
</NButton>
</template>
<Form />
</NCard>
</Page>
</template>

View File

@ -0,0 +1,177 @@
<script setup lang="ts">
import type { FormSchema } from '../types';
import { computed } from 'vue';
import { Plus, X } from '@vben-core/icons';
import {
VbenButton,
VbenIconButton,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
import { useFieldArray } from 'vee-validate';
import FormField from '../form-render/form-field.vue';
defineOptions({ name: 'VbenFormFieldArray', inheritAttrs: false });
const props = withDefaults(
defineProps<{
/** 操作列表头文案 */
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/**
* 新增一行时生成的默认数据缺省时按 schema fieldName 生成空对象
*/
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/**
* 字段路径由外层 FormField 通过 componentField 透传vee-validate name
*/
name?: string;
/**
* 列定义每一列就是一个子字段复用 FormSchema
*/
schema?: FormSchema[];
/** 是否显示序号列 */
showIndex?: boolean;
}>(),
{
actionText: '操作',
addButtonText: '添加一行',
createRow: undefined,
disabled: false,
emptyText: '暂无数据',
max: Number.POSITIVE_INFINITY,
min: 0,
name: '',
schema: () => [],
showIndex: true,
},
);
const arrayPath = computed(() => props.name);
const { fields, push, remove } = useFieldArray<Record<string, any>>(
() => arrayPath.value,
);
const canAdd = computed(() => fields.value.length < props.max);
const canRemove = computed(() => fields.value.length > props.min);
function buildDefaultRow(): Record<string, any> {
if (props.createRow) {
return props.createRow();
}
return Object.fromEntries(props.schema.map((col) => [col.fieldName, null]));
}
function addRow() {
if (props.disabled || !canAdd.value) {
return;
}
push(buildDefaultRow());
}
function removeRow(index: number) {
if (props.disabled || !canRemove.value) {
return;
}
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,
};
}
</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"
>
<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
v-if="fields.length === 0"
class="text-muted-foreground border-border/60 border-b py-6 text-center text-sm"
>
{{ emptyText }}
</div>
<VbenButton
variant="outline"
size="sm"
:disabled="disabled || !canAdd"
class="mt-3 w-full border-dashed"
@click="addRow"
>
<Plus class="mr-1 size-4" />
{{ addButtonText }}
</VbenButton>
</div>
</template>

View File

@ -20,6 +20,8 @@ import { globalShareState } from '@vben-core/shared/global-state';
import { defineRule } from 'vee-validate';
import VbenFormFieldArray from './components/form-field-array.vue';
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
export const DEFAULT_FORM_COMMON_CONFIG: FormCommonConfig = {};
@ -28,6 +30,7 @@ export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
VbenCheckbox,
VbenFormFieldArray,
VbenInput,
VbenInputPassword,
VbenPinInput,

View File

@ -4,6 +4,7 @@ export type {
BaseFormComponentType,
ExtendedFormApi,
FormLayout,
VbenFormFieldArrayProps,
VbenFormProps,
FormSchema as VbenFormSchema,
} from './types';

View File

@ -14,6 +14,7 @@ export type BaseFormComponentType =
| 'DefaultButton'
| 'PrimaryButton'
| 'VbenCheckbox'
| 'VbenFormFieldArray'
| 'VbenInput'
| 'VbenInputPassword'
| 'VbenPinInput'
@ -307,6 +308,32 @@ export type FormSchema<
P extends Record<string, any> = Record<never, never>,
> = FormSchemaDiscriminated<T, P> | FormSchemaFallback<T>;
/**
* VbenFormFieldArray
*/
export interface VbenFormFieldArrayProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> {
/** 操作列表头文案 */
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 新增一行时生成的默认数据;缺省时按列定义的 fieldName 生成空对象 */
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/** 列定义,每一列是一个子字段(复用 FormSchema */
schema: FormSchema<T, P>[];
/** 是否显示序号列 */
showIndex?: boolean;
}
export type HandleSubmitFn = (
values: Record<string, any>,
) => Promise<void> | void;