Browse Source

feat: modal state locked on submitting (#5401)

* feat: modal state locked on submitting

* docs: 更新modal文档
Netfan 2 months ago
parent
commit
8cc903c0e1

+ 9 - 1
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>\|boolean` |  |
 | onCancel | 点击取消按钮触发 | `()=>void` |  |
 | onClosed | 关闭动画播放完毕时触发 | `()=>void` | >5.4.3 |
 | onConfirm | 点击确认按钮触发 | `()=>void` |  |
@@ -153,3 +154,10 @@ const [Modal, modalApi] = useVbenModal({
 | setData | 设置共享数据 | `<T>(data:T)=>modalApi` |
 | getData | 获取共享数据 | `<T>()=>T` |
 | useStore | 获取可响应式状态 | - |
+| lock | 将弹窗标记为提交中,锁定当前状态 | `(isLock:boolean)=>modalApi` |
+
+::: info lock
+
+`lock`方法用于锁定当前弹窗的状态,一般用于提交数据的过程中防止用户重复提交或者弹窗被意外关闭、表单数据被改变等等。当处于锁定状态时,弹窗的确认按钮会变为loading状态,同时禁用确认按钮、隐藏关闭按钮、禁止ESC或者点击遮罩等方式关闭弹窗、开启弹窗的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的弹窗时,会自动解锁。
+
+:::

+ 3 - 0
packages/@core/base/typings/src/helper.d.ts

@@ -109,6 +109,8 @@ type MergeAll<
 
 type EmitType = (name: Name, ...args: any[]) => void;
 
+type MaybePromise<T> = Promise<T> | T;
+
 export type {
   AnyFunction,
   AnyNormalFunction,
@@ -118,6 +120,7 @@ export type {
   EmitType,
   IntervalHandle,
   MaybeComputedRef,
+  MaybePromise,
   MaybeReadonlyRef,
   Merge,
   MergeAll,

+ 17 - 3
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 });
+  }
+
   /**
    * 取消操作
    */

+ 7 - 1
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<boolean | undefined>;
   /**
    * 点击取消按钮的回调
    */

+ 17 - 11
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(() => {
   <Dialog
     :modal="false"
     :open="state?.isOpen"
-    @update:open="() => modalApi?.close()"
+    @update:open="() => (!submitting ? modalApi?.close() : undefined)"
   >
     <DialogContent
       ref="contentRef"
@@ -195,7 +200,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,
           })
         "
       >
         <VbenLoading
-          v-if="showLoading"
+          v-if="showLoading || submitting"
           class="size-full h-auto min-h-full"
           spinning
         />
@@ -287,6 +292,7 @@ const getAppendTo = computed(() => {
             :is="components.DefaultButton || VbenButton"
             v-if="showCancelButton"
             variant="ghost"
+            :disabled="submitting"
             @click="() => modalApi?.onCancel()"
           >
             <slot name="cancelText">
@@ -298,7 +304,7 @@ const getAppendTo = computed(() => {
             :is="components.PrimaryButton || VbenButton"
             v-if="showConfirmButton"
             :disabled="confirmDisabled"
-            :loading="confirmLoading"
+            :loading="confirmLoading || submitting"
             @click="() => modalApi?.onConfirm()"
           >
             <slot name="confirmText">

+ 0 - 4
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);
-}

+ 17 - 4
playground/src/views/examples/modal/form-modal-demo.vue

@@ -9,10 +9,6 @@ defineOptions({
   name: 'FormModelDemo',
 });
 
-function onSubmit(values: Record<string, any>) {
-  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<string, any>) {
+  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);
+}
 </script>
 <template>
   <Modal>

+ 1 - 1
playground/src/views/examples/modal/index.vue

@@ -97,7 +97,7 @@ function openFormModal() {
   formModalApi
     .setData({
       // 表单值
-      values: { field1: 'abc' },
+      values: { field1: 'abc', field2: '123' },
     })
     .open();
 }