diff --git a/apps/web-antd/src/router/routes/modules/dashboard.ts b/apps/web-antd/src/router/routes/modules/dashboard.ts index f3a952b0..5254dc65 100644 --- a/apps/web-antd/src/router/routes/modules/dashboard.ts +++ b/apps/web-antd/src/router/routes/modules/dashboard.ts @@ -10,7 +10,7 @@ const routes: RouteRecordRaw[] = [ title: $t('page.dashboard.title'), }, name: 'Dashboard', - path: '/', + path: '/dashboard', children: [ { name: 'Analytics', diff --git a/apps/web-ele/src/router/routes/modules/dashboard.ts b/apps/web-ele/src/router/routes/modules/dashboard.ts index f3a952b0..5254dc65 100644 --- a/apps/web-ele/src/router/routes/modules/dashboard.ts +++ b/apps/web-ele/src/router/routes/modules/dashboard.ts @@ -10,7 +10,7 @@ const routes: RouteRecordRaw[] = [ title: $t('page.dashboard.title'), }, name: 'Dashboard', - path: '/', + path: '/dashboard', children: [ { name: 'Analytics', diff --git a/apps/web-naive/src/router/routes/modules/dashboard.ts b/apps/web-naive/src/router/routes/modules/dashboard.ts index f3a952b0..5254dc65 100644 --- a/apps/web-naive/src/router/routes/modules/dashboard.ts +++ b/apps/web-naive/src/router/routes/modules/dashboard.ts @@ -10,7 +10,7 @@ const routes: RouteRecordRaw[] = [ title: $t('page.dashboard.title'), }, name: 'Dashboard', - path: '/', + path: '/dashboard', children: [ { name: 'Analytics', diff --git a/docs/src/components/common-ui/vben-api-component.md b/docs/src/components/common-ui/vben-api-component.md index f275adfc..caa9facf 100644 --- a/docs/src/components/common-ui/vben-api-component.md +++ b/docs/src/components/common-ui/vben-api-component.md @@ -129,7 +129,8 @@ function fetchApi(): Promise> { | 属性名 | 描述 | 类型 | 默认值 | | --- | --- | --- | --- | -| component | 欲包装的组件 | `Component` | - | +| modelValue(v-model) | 当前值 | `any` | - | +| component | 欲包装的组件(以下称为目标组件) | `Component` | - | | numberToString | 是否将value从数字转为string | `boolean` | `false` | | api | 获取数据的函数 | `(arg?: any) => Promise>` | - | | params | 传递给api的参数 | `Record` | - | @@ -137,16 +138,12 @@ function fetchApi(): Promise> { | labelField | label字段名 | `string` | `label` | | childrenField | 子级数据字段名,需要层级数据的组件可用 | `string` | `` | | valueField | value字段名 | `string` | `value` | -| optionsPropName | 组件接收options数据的属性名称 | `string` | `options` | -| modelPropName | 组件的双向绑定属性名,默认为modelValue。部分组件可能为value | `string` | `modelValue` | +| optionsPropName | 目标组件接收options数据的属性名称 | `string` | `options` | +| modelPropName | 目标组件的双向绑定属性名,默认为modelValue。部分组件可能为value | `string` | `modelValue` | | immediate | 是否立即调用api | `boolean` | `true` | | alwaysLoad | 每次`visibleEvent`事件发生时都重新请求数据 | `boolean` | `false` | | beforeFetch | 在api请求之前的回调函数 | `AnyPromiseFunction` | - | | afterFetch | 在api请求之后的回调函数 | `AnyPromiseFunction` | - | | options | 直接传入选项数据,也作为api返回空数据时的后备数据 | `OptionsItem[]` | - | | visibleEvent | 触发重新请求数据的事件名 | `string` | - | -| loadingSlot | 组件的插槽名称,用来显示一个"加载中"的图标 | `string` | - | - -``` - -``` +| loadingSlot | 目标组件的插槽名称,用来显示一个"加载中"的图标 | `string` | - | diff --git a/docs/src/components/common-ui/vben-modal.md b/docs/src/components/common-ui/vben-modal.md index 8d0b4284..e49196ee 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` | >5.5.2支持Promise | | onCancel | 点击取消按钮触发 | `()=>void` | | | onClosed | 关闭动画播放完毕时触发 | `()=>void` | >5.4.3 | | onConfirm | 点击确认按钮触发 | `()=>void` | | @@ -145,11 +146,18 @@ const [Modal, modalApi] = useVbenModal({ ### modalApi -| 方法 | 描述 | 类型 | -| --- | --- | --- | -| setState | 动态设置弹窗状态属性 | `(((prev: ModalState) => Partial)\| Partial)=>modalApi` | -| open | 打开弹窗 | `()=>void` | -| close | 关闭弹窗 | `()=>void` | -| setData | 设置共享数据 | `(data:T)=>modalApi` | -| getData | 获取共享数据 | `()=>T` | -| useStore | 获取可响应式状态 | - | +| 方法 | 描述 | 类型 | 版本 | +| --- | --- | --- | --- | +| setState | 动态设置弹窗状态属性 | `(((prev: ModalState) => Partial)\| Partial)=>modalApi` | - | +| open | 打开弹窗 | `()=>void` | - | +| close | 关闭弹窗 | `()=>void` | - | +| setData | 设置共享数据 | `(data:T)=>modalApi` | - | +| getData | 获取共享数据 | `()=>T` | - | +| useStore | 获取可响应式状态 | - | - | +| lock | 将弹窗标记为提交中,锁定当前状态 | `(isLock:boolean)=>modalApi` | >5.5.2 | + +::: 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 af8775ee..96d4f37b 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/form-ui/src/form-api.ts b/packages/@core/ui-kit/form-ui/src/form-api.ts index 2d1e971a..c3d7b2bb 100644 --- a/packages/@core/ui-kit/form-ui/src/form-api.ts +++ b/packages/@core/ui-kit/form-ui/src/form-api.ts @@ -404,9 +404,8 @@ export class FormApi { const deletedSchema = prevSchema.filter( (item) => !currentFields.has(item.fieldName), ); - for (const schema of deletedSchema) { - this.form?.setFieldValue(schema.fieldName, undefined); + this.form?.setFieldValue?.(schema.fieldName, undefined); } } } 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 c401793c..3bff3a0e 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 }); + } + modalLoading(loading: boolean) { this.store.setState((prev) => ({ ...prev, 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 14225e8c..9f86ab8c 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 ae02a245..35858f69 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, + '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/effects/common-ui/src/components/api-component/api-component.vue b/packages/effects/common-ui/src/components/api-component/api-component.vue index 51435316..c871fe20 100644 --- a/packages/effects/common-ui/src/components/api-component/api-component.vue +++ b/packages/effects/common-ui/src/components/api-component/api-component.vue @@ -1,12 +1,16 @@ diff --git a/packages/styles/src/antd/index.css b/packages/styles/src/antd/index.css index 8638a9b8..f04d343d 100644 --- a/packages/styles/src/antd/index.css +++ b/packages/styles/src/antd/index.css @@ -112,7 +112,3 @@ button[disabled].btn-success { color: rgb(50 54 57 / 25%) !important; border-color: hsl(240deg 5.9% 90%) !important; } - -.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 5179ffeb..6d58aa15 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); +}