diff --git a/docs/src/components/common-ui/vben-modal.md b/docs/src/components/common-ui/vben-modal.md index 8d0b42841..e9334c58a 100644 --- a/docs/src/components/common-ui/vben-modal.md +++ b/docs/src/components/common-ui/vben-modal.md @@ -113,6 +113,7 @@ const [Modal, modalApi] = useVbenModal({ | bordered | 是否显示border | `boolean` | `false` | | zIndex | 弹窗的ZIndex层级 | `number` | `1000` | | overlayBlur | 遮罩模糊度 | `number` | - | +| submitting | 标记为提交中,锁定弹窗当前状态 | `boolean` | `false` | ::: info appendToMain @@ -126,7 +127,7 @@ const [Modal, modalApi] = useVbenModal({ | 事件名 | 描述 | 类型 | 版本号 | | --- | --- | --- | --- | -| onBeforeClose | 关闭前触发,返回 `false`则禁止关闭 | `()=>boolean` | | +| onBeforeClose | 关闭前触发,返回 `false`或者被`reject`则禁止关闭 | `()=>Promise\|boolean` | | | onCancel | 点击取消按钮触发 | `()=>void` | | | onClosed | 关闭动画播放完毕时触发 | `()=>void` | >5.4.3 | | onConfirm | 点击确认按钮触发 | `()=>void` | | @@ -153,3 +154,10 @@ const [Modal, modalApi] = useVbenModal({ | setData | 设置共享数据 | `(data:T)=>modalApi` | | getData | 获取共享数据 | `()=>T` | | useStore | 获取可响应式状态 | - | +| lock | 将弹窗标记为提交中,锁定当前状态 | `(isLock:boolean)=>modalApi` | + +::: info lock + +`lock`方法用于锁定当前弹窗的状态,一般用于提交数据的过程中防止用户重复提交或者弹窗被意外关闭、表单数据被改变等等。当处于锁定状态时,弹窗的确认按钮会变为loading状态,同时禁用确认按钮、隐藏关闭按钮、禁止ESC或者点击遮罩等方式关闭弹窗、开启弹窗的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的弹窗时,会自动解锁。 + +::: diff --git a/packages/@core/base/typings/src/helper.d.ts b/packages/@core/base/typings/src/helper.d.ts index af8775ee4..96d4f37ba 100644 --- a/packages/@core/base/typings/src/helper.d.ts +++ b/packages/@core/base/typings/src/helper.d.ts @@ -109,6 +109,8 @@ type MergeAll< type EmitType = (name: Name, ...args: any[]) => void; +type MaybePromise = Promise | T; + export type { AnyFunction, AnyNormalFunction, @@ -118,6 +120,7 @@ export type { EmitType, IntervalHandle, MaybeComputedRef, + MaybePromise, MaybeReadonlyRef, Merge, MergeAll, diff --git a/packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts b/packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts index cc08260d5..d1fc63caa 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts +++ b/packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts @@ -95,13 +95,18 @@ export class ModalApi { /** * 关闭弹窗 + * @description 关闭弹窗时会调用 onBeforeClose 钩子函数,如果 onBeforeClose 返回 false,则不关闭弹窗 */ - close() { + async close() { // 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗 // 如果 onBeforeClose 返回 false,则不关闭弹窗 - const allowClose = this.api.onBeforeClose?.() ?? true; + const allowClose = (await this.api.onBeforeClose?.()) ?? true; if (allowClose) { - this.store.setState((prev) => ({ ...prev, isOpen: false })); + this.store.setState((prev) => ({ + ...prev, + isOpen: false, + submitting: false, + })); } } @@ -109,6 +114,15 @@ export class ModalApi { return (this.sharedData?.payload ?? {}) as T; } + /** + * 锁定弹窗状态(用于提交过程中的等待状态) + * @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖弹窗内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态 + * @param isLocked 是否锁定 + */ + lock(isLocked = true) { + return this.setState({ submitting: isLocked }); + } + /** * 取消操作 */ diff --git a/packages/@core/ui-kit/popup-ui/src/modal/modal.ts b/packages/@core/ui-kit/popup-ui/src/modal/modal.ts index 14225e8c7..9f86ab8c3 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/modal.ts +++ b/packages/@core/ui-kit/popup-ui/src/modal/modal.ts @@ -1,5 +1,7 @@ import type { Component, Ref } from 'vue'; +import type { MaybePromise } from '@vben-core/typings'; + import type { ModalApi } from './modal-api'; export interface ModalProps { @@ -113,6 +115,10 @@ export interface ModalProps { * @default true */ showConfirmButton?: boolean; + /** + * 提交中(锁定弹窗状态) + */ + submitting?: boolean; /** * 弹窗标题 */ @@ -155,7 +161,7 @@ export interface ModalApiOptions extends ModalState { * 关闭前的回调,返回 false 可以阻止关闭 * @returns */ - onBeforeClose?: () => void; + onBeforeClose?: () => MaybePromise; /** * 点击取消按钮的回调 */ diff --git a/packages/@core/ui-kit/popup-ui/src/modal/modal.vue b/packages/@core/ui-kit/popup-ui/src/modal/modal.vue index ae02a245a..7e834faaa 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/modal.vue +++ b/packages/@core/ui-kit/popup-ui/src/modal/modal.vue @@ -80,6 +80,7 @@ const { overlayBlur, showCancelButton, showConfirmButton, + submitting, title, titleTooltip, zIndex, @@ -115,9 +116,9 @@ watch( ); watch( - () => showLoading.value, - (v) => { - if (v && wrapperRef.value) { + () => [showLoading.value, submitting.value], + ([l, s]) => { + if ((s || l) && wrapperRef.value) { wrapperRef.value.scrollTo({ // behavior: 'smooth', top: 0, @@ -135,13 +136,13 @@ function handleFullscreen() { }); } function interactOutside(e: Event) { - if (!closeOnClickModal.value) { + if (!closeOnClickModal.value || submitting.value) { e.preventDefault(); e.stopPropagation(); } } function escapeKeyDown(e: KeyboardEvent) { - if (!closeOnPressEscape.value) { + if (!closeOnPressEscape.value || submitting.value) { e.preventDefault(); } } @@ -156,7 +157,11 @@ function handerOpenAutoFocus(e: Event) { function pointerDownOutside(e: Event) { const target = e.target as HTMLElement; const isDismissableModal = target?.dataset.dismissableModal; - if (!closeOnClickModal.value || isDismissableModal !== id) { + if ( + !closeOnClickModal.value || + isDismissableModal !== id || + submitting.value + ) { e.preventDefault(); e.stopPropagation(); } @@ -174,7 +179,7 @@ const getAppendTo = computed(() => { { " :modal="modal" :open="state?.isOpen" - :show-close="closable" + :show-close="submitting ? false : closable" :z-index="zIndex" :overlay-blur="overlayBlur" close-class="top-3" @@ -247,12 +252,12 @@ const getAppendTo = computed(() => { ref="wrapperRef" :class=" cn('relative min-h-40 flex-1 overflow-y-auto p-3', contentClass, { - 'pointer-events-none overflow-hidden': showLoading, + 'pointer-events-none overflow-hidden': showLoading || submitting, }) " > @@ -287,6 +292,7 @@ const getAppendTo = computed(() => { :is="components.DefaultButton || VbenButton" v-if="showCancelButton" variant="ghost" + :disabled="submitting" @click="() => modalApi?.onCancel()" > @@ -298,7 +304,7 @@ const getAppendTo = computed(() => { :is="components.PrimaryButton || VbenButton" v-if="showConfirmButton" :disabled="confirmDisabled" - :loading="confirmLoading" + :loading="confirmLoading || submitting" @click="() => modalApi?.onConfirm()" > diff --git a/packages/styles/src/antd/index.css b/packages/styles/src/antd/index.css index 4784e5283..ed822fdca 100644 --- a/packages/styles/src/antd/index.css +++ b/packages/styles/src/antd/index.css @@ -54,7 +54,3 @@ .ant-app .form-valid-error .ant-picker-focused { box-shadow: 0 0 0 2px rgb(255 38 5 / 6%); } - -.ant-message { - z-index: var(--popup-z-index); -} diff --git a/playground/src/views/examples/modal/form-modal-demo.vue b/playground/src/views/examples/modal/form-modal-demo.vue index 5179ffebf..6d58aa15c 100644 --- a/playground/src/views/examples/modal/form-modal-demo.vue +++ b/playground/src/views/examples/modal/form-modal-demo.vue @@ -9,10 +9,6 @@ defineOptions({ name: 'FormModelDemo', }); -function onSubmit(values: Record) { - message.info(JSON.stringify(values)); // 只会执行一次 -} - const [Form, formApi] = useVbenForm({ handleSubmit: onSubmit, schema: [ @@ -70,6 +66,23 @@ const [Modal, modalApi] = useVbenModal({ }, title: '内嵌表单示例', }); + +function onSubmit(values: Record) { + message.loading({ + content: '正在提交中...', + duration: 0, + key: 'is-form-submitting', + }); + modalApi.lock(); + setTimeout(() => { + modalApi.close(); + message.success({ + content: `提交成功:${JSON.stringify(values)}`, + duration: 2, + key: 'is-form-submitting', + }); + }, 3000); +}