feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray
parent
108d7ff335
commit
e5f9106caa
|
|
@ -3,6 +3,7 @@
|
||||||
"naive": "Naive UI",
|
"naive": "Naive UI",
|
||||||
"table": "Table",
|
"table": "Table",
|
||||||
"form": "Form",
|
"form": "Form",
|
||||||
|
"arrayForm": "Array Editor",
|
||||||
"vben": {
|
"vben": {
|
||||||
"title": "Project",
|
"title": "Project",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"naive": "Naive UI",
|
"naive": "Naive UI",
|
||||||
"table": "Table",
|
"table": "Table",
|
||||||
"form": "表单",
|
"form": "表单",
|
||||||
|
"arrayForm": "数组编辑器",
|
||||||
"vben": {
|
"vben": {
|
||||||
"title": "项目",
|
"title": "项目",
|
||||||
"about": "关于",
|
"about": "关于",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,14 @@ const routes: RouteRecordRaw[] = [
|
||||||
path: '/demos/form',
|
path: '/demos/form',
|
||||||
component: () => import('#/views/demos/form/basic.vue'),
|
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'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -20,6 +20,8 @@ import { globalShareState } from '@vben-core/shared/global-state';
|
||||||
|
|
||||||
import { defineRule } from 'vee-validate';
|
import { defineRule } from 'vee-validate';
|
||||||
|
|
||||||
|
import VbenFormFieldArray from './components/form-field-array.vue';
|
||||||
|
|
||||||
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
|
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
|
||||||
|
|
||||||
export const DEFAULT_FORM_COMMON_CONFIG: FormCommonConfig = {};
|
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' }),
|
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
|
||||||
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
|
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
|
||||||
VbenCheckbox,
|
VbenCheckbox,
|
||||||
|
VbenFormFieldArray,
|
||||||
VbenInput,
|
VbenInput,
|
||||||
VbenInputPassword,
|
VbenInputPassword,
|
||||||
VbenPinInput,
|
VbenPinInput,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export type {
|
||||||
BaseFormComponentType,
|
BaseFormComponentType,
|
||||||
ExtendedFormApi,
|
ExtendedFormApi,
|
||||||
FormLayout,
|
FormLayout,
|
||||||
|
VbenFormFieldArrayProps,
|
||||||
VbenFormProps,
|
VbenFormProps,
|
||||||
FormSchema as VbenFormSchema,
|
FormSchema as VbenFormSchema,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export type BaseFormComponentType =
|
||||||
| 'DefaultButton'
|
| 'DefaultButton'
|
||||||
| 'PrimaryButton'
|
| 'PrimaryButton'
|
||||||
| 'VbenCheckbox'
|
| 'VbenCheckbox'
|
||||||
|
| 'VbenFormFieldArray'
|
||||||
| 'VbenInput'
|
| 'VbenInput'
|
||||||
| 'VbenInputPassword'
|
| 'VbenInputPassword'
|
||||||
| 'VbenPinInput'
|
| 'VbenPinInput'
|
||||||
|
|
@ -307,6 +308,32 @@ export type FormSchema<
|
||||||
P extends Record<string, any> = Record<never, never>,
|
P extends Record<string, any> = Record<never, never>,
|
||||||
> = FormSchemaDiscriminated<T, P> | FormSchemaFallback<T>;
|
> = 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 = (
|
export type HandleSubmitFn = (
|
||||||
values: Record<string, any>,
|
values: Record<string, any>,
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue