refactor(@vben-core/form-ui): migrate to Zod 4 and TanStack Form (#8176)

* build(@vben-core/form-ui): update form validation dependencies

* refactor(@vben-core/form-ui): replace vee-validate with TanStack Form

* test(@vben-core/form-ui): cover TanStack Form migration

* docs(@vben/docs): document Zod 4 form migration

* refactor(project): update form adapters

* refactor(project): migrate application form consumers

* refactor(@vben/playground): migrate form examples

* docs(@vben/docs): fix validate return type and required rule empty check in form docs

* fix(@vben-core/form-ui): resolve oxlint and eslint errors

- rewrite nested ternary expressions as if/else in form-field-array.vue and form-runtime.ts
- sort @tanstack/vue-form before @vben-core/composables in package.json

* chore: fix merge artifacts and formatting

- move dependencies before devDependencies in root package.json
- update preferences snapshot for widget positioning fields
- format form-array demo README

* refactor(@vben-core/form-ui): optimize form runtime and value flow

- add fine-grained field selectors and atomic dependency resolution
- expose raw and formatted value snapshots with focused regression coverage

* fix(@vben/layouts): dispose sortable instance on unmount

* docs(@vben/docs): document form runtime API changes

- list added, changed, removed, and deprecated form APIs
- document atomic dependencies and raw versus formatted values
This commit is contained in:
dream-weave
2026-07-22 19:47:07 +08:00
committed by GitHub
parent 6b6708bcf2
commit 28757fb9c8
81 changed files with 4683 additions and 1435 deletions

View File

@@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@@ -21,7 +22,7 @@ async function initSetupVbenForm() {
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@@ -40,9 +41,18 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;

View File

@@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

View File

@@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
@@ -10,6 +10,18 @@ import { Button, Card, message, Space } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
const submitValues = ref<Record<string, any>>({});
const formattedSubmitValues = computed(() =>
JSON.stringify(submitValues.value, null, 2),
);
const outputClass = [
'bg-muted',
'text-muted-foreground',
'max-h-[420px]',
'overflow-auto',
'rounded-md',
'p-3',
'text-xs',
];
const schema: VbenFormSchema[] = [
{
@@ -62,8 +74,8 @@ const schema: VbenFormSchema[] = [
children: [
{
component: 'Input',
componentProps: (_values, _form, ctx) => ({
placeholder: `${(ctx?.rowIndex ?? 0) + 1} 行姓名`,
componentProps: (ctx) => ({
placeholder: `${(ctx.rowIndex ?? 0) + 1} 行姓名`,
}),
defaultValue: '',
fieldName: 'name',
@@ -147,7 +159,7 @@ const [Form, formApi] = useVbenForm({
});
async function handleSubmit() {
await formApi.validateAndSubmitForm();
await formApi.validateAndSubmit();
}
async function handleGetValues() {
@@ -178,9 +190,7 @@ function handlePatchChildRule() {
</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>
<pre :class="outputClass" v-text="formattedSubmitValues"></pre>
</Card>
</div>
</Page>

View File

@@ -35,7 +35,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
drawerApi.close();
},
onConfirm: async () => {
await formApi.submitForm();
await formApi.submit();
drawerApi.close();
},
onOpenChange(isOpen: boolean) {

View File

@@ -285,11 +285,16 @@ const [BaseForm, baseFormApi] = useVbenForm({
},
{
component: 'DatePicker',
dependencies: {
resolve: ({ values }) => ({
help: () =>
[`这是一个可输出其他字段值的帮助信息${values.rate}`].map((value) =>
h('p', value),
),
}),
triggerFields: ['rate'],
},
fieldName: 'datePicker',
help: (values) =>
[`这是一个可输出其他字段值的帮助信息${values?.rate}`].map((v) =>
h('p', v),
),
label: '日期选择框',
},
{

View File

@@ -22,8 +22,8 @@ const layout = ref<FormLayout>('vertical');
function getNumberValidator(key: string, limit?: [number?, number?]) {
let validator = z.number({
required_error: `${key} 值不能为空`,
invalid_type_error: `${key} 值只能为数字`,
error: (issue) =>
issue.input === undefined ? `${key} 值不能为空` : `${key} 值只能为数字`,
});
if (limit) {
@@ -257,14 +257,14 @@ function handleSetFormValue() {
}
function handleResetFormValue() {
baseFormApi.resetForm(undefined, { force: true });
baseFormApi.reset(undefined, { force: true });
}
async function handleSubmitFormValue() {
const { valid } = await baseFormApi.validate();
if (valid) {
baseFormApi.submitForm();
baseFormApi.submit();
}
}
</script>

View File

@@ -57,7 +57,7 @@ const [Form] = useVbenForm({
{
component: markRaw(TwoFields),
defaultValue: [undefined, ''],
disabledOnChangeListener: false,
changeEventFallback: true,
fieldName: 'field4',
formItemClass: 'col-span-1',
label: '组合字段',

View File

@@ -70,7 +70,7 @@ const [Form, formApi] = useVbenForm({
fieldName: 'field4',
// 界面显示的label
label: '邮箱',
rules: z.string().email('请输入正确的邮箱'),
rules: z.email('请输入正确的邮箱'),
},
{
component: 'InputNumber',
@@ -184,8 +184,7 @@ const [Form, formApi] = useVbenForm({
},
fieldName: 'input-blur',
formFieldProps: {
validateOnChange: false,
validateOnModelUpdate: false,
validateOn: ['blur'],
},
help: 'blur时才会触发校验',
label: 'blur触发',
@@ -235,7 +234,7 @@ function onSubmit(values: Record<string, any>) {
<Card title="基础组件校验示例">
<template #extra>
<Button @click="() => formApi.validate()">校验表单</Button>
<Button class="mx-2" @click="() => formApi.resetValidate()">
<Button class="mx-2" @click="() => formApi.clearValidation()">
清空校验信息
</Button>
</template>

View File

@@ -98,7 +98,7 @@ const [Form, formApi] = useVbenForm({
// 测试 validateAndSubmitForm验证并提交
async function testValidateAndSubmit() {
await formApi.validateAndSubmitForm();
await formApi.validateAndSubmit();
}
// 测试 validate手动验证整个表单
@@ -112,13 +112,13 @@ async function testValidateField() {
}
// 切换滚动功能
function toggleScrollToError() {
formApi.setState({ scrollToFirstError: scrollEnabled.value });
function toggleScrollToError(checked: boolean) {
formApi.setState({ scrollToFirstError: checked });
}
// 填充部分数据测试
async function fillPartialData() {
await formApi.resetForm();
await formApi.reset();
await formApi.setFieldValue('username', '测试用户');
await formApi.setFieldValue('email', 'test@example.com');
}
@@ -153,13 +153,13 @@ async function fillPartialData() {
<h4 class="mb-3 font-medium">验证方法测试</h4>
<div class="flex flex-wrap gap-2">
<Button type="primary" @click="testValidateAndSubmit">
测试 validateAndSubmitForm()
测试 validateAndSubmit()
</Button>
<Button @click="testValidate"> 测试 validate() </Button>
<Button @click="testValidateField"> 测试 validateField() </Button>
</div>
<div class="mt-2 text-xs text-gray-500">
<p> validateAndSubmitForm(): 验证表单并提交</p>
<p> validateAndSubmit(): 验证表单并提交</p>
<p> validate(): 手动验证整个表单</p>
<p> validateField(): 验证单个字段这里测试用户名字段</p>
</div>
@@ -169,7 +169,7 @@ async function fillPartialData() {
<h4 class="mb-3 font-medium">数据填充测试</h4>
<div class="flex flex-wrap gap-2">
<Button @click="fillPartialData"> 填充部分数据 </Button>
<Button @click="() => formApi.resetForm()"> 清空表单 </Button>
<Button @click="() => formApi.reset()"> 清空表单 </Button>
</div>
<div class="mt-2 text-xs text-gray-500">
<p> 填充部分数据后验证会滚动到第一个错误字段</p>

View File

@@ -54,7 +54,7 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
await formApi.validateAndSubmitForm();
await formApi.validateAndSubmit();
// modalApi.close();
},
onOpenChange(isOpen: boolean) {

View File

@@ -28,7 +28,7 @@ const [Form, formApi] = useVbenForm({
});
function resetForm() {
formApi.resetForm();
formApi.reset();
formApi.setValues(formData.value || {});
}

View File

@@ -56,12 +56,13 @@ const schema: VbenFormSchema[] = [
async (value: string) => {
return !(await isMenuNameExists(value, formData.value?.id));
},
(value) => ({
message: $t('ui.formRules.alreadyExists', [
$t('system.menu.menuName'),
value,
]),
}),
{
error: (issue) =>
$t('ui.formRules.alreadyExists', [
$t('system.menu.menuName'),
issue.input,
]),
},
),
},
{
@@ -139,12 +140,13 @@ const schema: VbenFormSchema[] = [
async (value: string) => {
return !(await isMenuPathExists(value, formData.value?.id));
},
(value) => ({
message: $t('ui.formRules.alreadyExists', [
$t('system.menu.path'),
value,
]),
}),
{
error: (issue) =>
$t('ui.formRules.alreadyExists', [
$t('system.menu.path'),
issue.input,
]),
},
),
},
{
@@ -227,7 +229,7 @@ const schema: VbenFormSchema[] = [
},
fieldName: 'linkSrc',
label: $t('system.menu.linkSrc'),
rules: z.string().url($t('ui.formRules.invalidURL')),
rules: z.url($t('ui.formRules.invalidURL')),
},
{
component: 'Input',
@@ -278,18 +280,18 @@ const schema: VbenFormSchema[] = [
},
{
component: 'Input',
componentProps: (values) => {
return {
allowClear: true,
class: 'w-full',
disabled: values.meta?.badgeType !== 'normal',
};
},
dependencies: {
show: (values) => {
return values.type !== 'button';
resolve: ({ values }) => {
return {
componentProps: {
allowClear: true,
class: 'w-full',
disabled: values.meta?.badgeType !== 'normal',
},
show: values.type !== 'button',
};
},
triggerFields: ['type'],
triggerFields: ['meta.badgeType', 'type'],
},
fieldName: 'meta.badge',
label: $t('system.menu.badge'),
@@ -451,7 +453,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
? $t(formData.value.meta.title)
: '';
} else {
formApi.resetForm();
formApi.reset();
titleSuffix.value = '';
}
}

View File

@@ -51,7 +51,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
async onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemRoleApi.SystemRole>();
formApi.resetForm();
formApi.reset();
if (data) {
formData.value = data;

View File

@@ -50,7 +50,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
async onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemUserApi.SystemUser>();
formApi.resetForm();
formApi.reset();
if (data) {
formData.value = data;