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

This commit is contained in:
Saleri
2026-05-31 15:03:55 +08:00
parent 108d7ff335
commit e5f9106caa
8 changed files with 341 additions and 0 deletions

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;