diff --git a/docs/src/components/common-ui/vben-form.md b/docs/src/components/common-ui/vben-form.md index 7abb3051e..a6d455253 100644 --- a/docs/src/components/common-ui/vben-form.md +++ b/docs/src/components/common-ui/vben-form.md @@ -324,6 +324,7 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单 | submitOnEnter | 按下回车健时提交表单 | `boolean` | false | | submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | `boolean` | false | | compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false | +| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | `boolean` | false | ::: tip handleValuesChange diff --git a/docs/src/demos/vben-form/rules/index.vue b/docs/src/demos/vben-form/rules/index.vue index 7abcc6f6c..78e598133 100644 --- a/docs/src/demos/vben-form/rules/index.vue +++ b/docs/src/demos/vben-form/rules/index.vue @@ -15,6 +15,7 @@ const [Form] = useVbenForm({ handleSubmit: onSubmit, // 垂直布局,label和input在不同行,值为vertical // 水平布局,label和input在同一行 + scrollToFirstError: true, layout: 'horizontal', schema: [ { diff --git a/packages/@core/ui-kit/form-ui/src/components/form-actions.vue b/packages/@core/ui-kit/form-ui/src/components/form-actions.vue index c5a3f9c2f..189755960 100644 --- a/packages/@core/ui-kit/form-ui/src/components/form-actions.vue +++ b/packages/@core/ui-kit/form-ui/src/components/form-actions.vue @@ -48,13 +48,18 @@ const queryFormStyle = computed(() => { async function handleSubmit(e: Event) { e?.preventDefault(); e?.stopPropagation(); - const { valid } = await form.validate(); + const props = unref(rootProps); + if (!props.formApi) { + return; + } + + const { valid } = await props.formApi.validate(); if (!valid) { return; } - const values = toRaw(await unref(rootProps).formApi?.getValues()); - await unref(rootProps).handleSubmit?.(values); + const values = toRaw(await props.formApi.getValues()); + await props.handleSubmit?.(values); } async function handleReset(e: Event) { diff --git a/packages/@core/ui-kit/form-ui/src/form-api.ts b/packages/@core/ui-kit/form-ui/src/form-api.ts index e097a4654..bdc44de78 100644 --- a/packages/@core/ui-kit/form-ui/src/form-api.ts +++ b/packages/@core/ui-kit/form-ui/src/form-api.ts @@ -39,6 +39,7 @@ function getDefaultState(): VbenFormProps { layout: 'horizontal', resetButtonOptions: {}, schema: [], + scrollToFirstError: false, showCollapseButton: false, showDefaultActions: true, submitButtonOptions: {}, @@ -253,6 +254,41 @@ export class FormApi { }); } + /** + * 滚动到第一个错误字段 + * @param errors 验证错误对象 + */ + scrollToFirstError(errors: Record | string) { + // https://github.com/logaretm/vee-validate/discussions/3835 + const firstErrorFieldName = + typeof errors === 'string' ? errors : Object.keys(errors)[0]; + + if (!firstErrorFieldName) { + return; + } + + let el = document.querySelector( + `[name="${firstErrorFieldName}"]`, + ) as HTMLElement; + + // 如果通过 name 属性找不到,尝试通过组件引用查找, 正常情况下不会走到这,怕哪天 vee-validate 改了 name 属性有个兜底的 + if (!el) { + const componentRef = this.getFieldComponentRef(firstErrorFieldName); + if (componentRef && componentRef.$el instanceof HTMLElement) { + el = componentRef.$el; + } + } + + if (el) { + // 滚动到错误字段,添加一些偏移量以确保字段完全可见 + el.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + } + } + async setFieldValue(field: string, value: any, shouldValidate?: boolean) { const form = await this.getForm(); form.setFieldValue(field, value, shouldValidate); @@ -377,14 +413,21 @@ export class FormApi { if (Object.keys(validateResult?.errors ?? {}).length > 0) { console.error('validate error', validateResult?.errors); + + if (this.state?.scrollToFirstError) { + this.scrollToFirstError(validateResult.errors); + } } return validateResult; } async validateAndSubmitForm() { const form = await this.getForm(); - const { valid } = await form.validate(); + const { valid, errors } = await form.validate(); if (!valid) { + if (this.state?.scrollToFirstError) { + this.scrollToFirstError(errors); + } return; } return await this.submitForm(); @@ -396,6 +439,10 @@ export class FormApi { if (Object.keys(validateResult?.errors ?? {}).length > 0) { console.error('validate error', validateResult?.errors); + + if (this.state?.scrollToFirstError) { + this.scrollToFirstError(fieldName); + } } return validateResult; } diff --git a/packages/@core/ui-kit/form-ui/src/types.ts b/packages/@core/ui-kit/form-ui/src/types.ts index 34312ae78..aa4f2dc6f 100644 --- a/packages/@core/ui-kit/form-ui/src/types.ts +++ b/packages/@core/ui-kit/form-ui/src/types.ts @@ -387,6 +387,12 @@ export interface VbenFormProps< */ resetButtonOptions?: ActionButtonOptions; + /** + * 验证失败时是否自动滚动到第一个错误字段 + * @default true + */ + scrollToFirstError?: boolean; + /** * 是否显示默认操作按钮 * @default true diff --git a/playground/src/locales/langs/en-US/examples.json b/playground/src/locales/langs/en-US/examples.json index 9335b28b7..c8d2b8c4b 100644 --- a/playground/src/locales/langs/en-US/examples.json +++ b/playground/src/locales/langs/en-US/examples.json @@ -19,6 +19,7 @@ "custom": "Custom Component", "api": "Api", "merge": "Merge Form", + "scrollToError": "Scroll to Error Field", "upload-error": "Partial file upload failed", "upload-urls": "Urls after file upload", "file": "file", diff --git a/playground/src/locales/langs/zh-CN/examples.json b/playground/src/locales/langs/zh-CN/examples.json index ff11d7fd2..035fbe244 100644 --- a/playground/src/locales/langs/zh-CN/examples.json +++ b/playground/src/locales/langs/zh-CN/examples.json @@ -22,6 +22,7 @@ "custom": "自定义组件", "api": "Api", "merge": "合并表单", + "scrollToError": "滚动到错误字段", "upload-error": "部分文件上传失败", "upload-urls": "文件上传后的网址", "file": "文件", diff --git a/playground/src/router/routes/modules/examples.ts b/playground/src/router/routes/modules/examples.ts index c91303c77..7bdbe5d00 100644 --- a/playground/src/router/routes/modules/examples.ts +++ b/playground/src/router/routes/modules/examples.ts @@ -85,6 +85,15 @@ const routes: RouteRecordRaw[] = [ title: $t('examples.form.merge'), }, }, + { + name: 'FormScrollToErrorExample', + path: '/examples/form/scroll-to-error-test', + component: () => + import('#/views/examples/form/scroll-to-error-test.vue'), + meta: { + title: $t('examples.form.scrollToError'), + }, + }, ], }, { diff --git a/playground/src/views/examples/form/scroll-to-error-test.vue b/playground/src/views/examples/form/scroll-to-error-test.vue new file mode 100644 index 000000000..61e8815c3 --- /dev/null +++ b/playground/src/views/examples/form/scroll-to-error-test.vue @@ -0,0 +1,183 @@ + + +