This commit is contained in:
2026-03-25 14:54:15 +08:00
commit ab4646b43a
827 changed files with 123812 additions and 0 deletions
@@ -0,0 +1,48 @@
/**
* u-modal 函数式调用事件名(全平台)
* @description
* - useModal() 通过 uni.$emit 派发事件
* - <u-modal /> 通过 uni.$on 监听事件并转调自身 show/hide
*/
import type { ModalProps } from './types';
// 普通(页面级)modal 事件
export const U_MODAL_EVENT_SHOW = 'uview-pro:u-modal:show:';
export const U_MODAL_EVENT_HIDE = 'uview-pro:u-modal:hide:';
export const U_MODAL_EVENT_CLEAR_LOADING = 'uview-pro:u-modal:clear-loading:';
// 全局(App 根部)modal 事件,供 useModal() 使用
export const U_MODAL_GLOBAL_EVENT_SHOW = 'uview-pro:u-modal:global:show';
export const U_MODAL_GLOBAL_EVENT_HIDE = 'uview-pro:u-modal:global:hide';
export const U_MODAL_GLOBAL_EVENT_CLEAR_LOADING = 'uview-pro:u-modal:global:clear-loading';
// 根据当前页面获取事件名
export function getEventWithCurrentPage(event: string, page?: string | boolean) {
if (page && typeof page === 'string' && page !== '') {
return event + page;
}
return event + getCurrentPage();
}
// 获取当前页面路由
function getCurrentPage() {
try {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1].route as string;
return currentPage || '';
} catch (error) {
return '';
}
}
/**
* u-modal 函数式调用载荷类型
* @description 完整覆盖 u-modal 的所有 props(除 modelValue 外)
*/
export type ModalPayload = Partial<Omit<ModalProps, 'modelValue'>> & {
/** 确认回调 */
onConfirm?: () => void;
/** 取消回调 */
onCancel?: () => void;
};
@@ -0,0 +1,133 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { getColor, useLocale } from '../../';
import { baseProps } from '../common/props';
const { t } = useLocale();
/**
* u-modal 组件 props 类型定义
* @description 弹窗模态框,支持多种样式和交互
*/
export const ModalProps = {
...baseProps,
/** 是否显示模态框 */
modelValue: {
type: Boolean,
default: false
},
/** 层级z-index */
zIndex: {
type: [Number, String] as PropType<number | string>,
default: ''
},
/** 标题 */
title: {
type: String,
default: () => t('uModal.title')
},
/** 弹窗宽度 */
width: {
type: [Number, String] as PropType<number | string>,
default: 600
},
/** 弹窗内容 */
content: {
type: String,
default: ''
},
/** 是否显示标题 */
showTitle: {
type: Boolean,
default: true
},
/** 是否显示确认按钮 */
showConfirmButton: {
type: Boolean,
default: true
},
/** 是否显示取消按钮 */
showCancelButton: {
type: Boolean,
default: false
},
/** 确认文案 */
confirmText: {
type: String,
default: () => t('uModal.confirmText')
},
/** 取消文案 */
cancelText: {
type: String,
default: () => t('uModal.cancelText')
},
/** 确认按钮颜色 */
confirmColor: {
type: String,
default: () => getColor('primary')
},
/** 取消文字颜色 */
cancelColor: {
type: String,
default: () => getColor('contentColor')
},
/** 圆角值 */
borderRadius: {
type: [Number, String] as PropType<number | string>,
default: 16
},
/** 标题的样式 */
titleStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 内容的样式 */
contentStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 取消按钮的样式 */
cancelStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 确定按钮的样式 */
confirmStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 是否开启缩放效果 */
zoom: {
type: Boolean,
default: true
},
/** 是否异步关闭,只对确定按钮有效 */
asyncClose: {
type: Boolean,
default: false
},
/** 是否允许点击遮罩关闭modal */
maskCloseAble: {
type: Boolean,
default: false
},
/** 给一个负的margin-top,往上偏移,避免和键盘重合的情况 */
negativeTop: {
type: [String, Number] as PropType<number | string>,
default: 0
},
/** 是否作为全局根部 modal(通常放在 App.vue 中,给 useModal() 使用) */
global: {
type: Boolean,
default: false
},
/** 是否作为页面级 modal(通常放在页面中,给 useModal({ page: true }) 使用) */
page: {
type: [Boolean, String] as PropType<boolean | string>,
default: false
}
};
/**
* u-modal 组件 props 类型
*/
export type ModalProps = ExtractPropTypes<typeof ModalProps>;
@@ -0,0 +1,432 @@
<template>
<view>
<u-popup
v-model="popupValue"
mode="center"
:zoom="effectiveConfig.zoom"
:popup="false"
:z-index="uZIndex"
:length="effectiveConfig.width"
:mask-close-able="effectiveConfig.maskCloseAble"
:border-radius="effectiveConfig.borderRadius"
:negative-top="effectiveConfig.negativeTop"
:custom-class="effectiveConfig.customClass"
@close="popupClose"
>
<view class="u-model" :style="$u.toStyle(effectiveConfig.customStyle)">
<view
v-if="effectiveConfig.showTitle"
class="u-model__title u-line-1"
:style="$u.toStyle(effectiveConfig.titleStyle)"
>
{{ effectiveConfig.title }}
</view>
<view class="u-model__content">
<view v-if="slots.default" :style="$u.toStyle(effectiveConfig.contentStyle)">
<slot />
</view>
<view v-else class="u-model__content__message" :style="$u.toStyle(effectiveConfig.contentStyle)">
<text>{{ effectiveConfig.content }}</text>
</view>
</view>
<view
v-if="effectiveConfig.showCancelButton || effectiveConfig.showConfirmButton"
class="u-model__footer u-border-top"
>
<view
v-if="effectiveConfig.showCancelButton"
:hover-stay-time="100"
hover-class="u-model__btn--hover"
class="u-model__footer__button"
:style="$u.toStyle(cancelBtnStyle)"
@tap="cancel"
>
{{ effectiveConfig.cancelText }}
</view>
<view
v-if="effectiveConfig.showConfirmButton || slots['confirm-button']"
class="u-model__footer__button hairline-left"
:hover-stay-time="100"
:hover-class="effectiveConfig.asyncClose ? 'none' : 'u-model__btn--hover'"
:style="[confirmBtnStyle]"
@tap="confirm"
>
<slot v-if="slots['confirm-button']" name="confirm-button"></slot>
<template v-else>
<u-loading mode="circle" :color="effectiveConfig.confirmColor" v-if="loading"></u-loading>
<template v-else>
{{ effectiveConfig.confirmText }}
</template>
</template>
</view>
</view>
</view>
</u-popup>
</view>
</template>
<script lang="ts">
export default {
name: 'u-modal',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, useSlots } from 'vue';
import { $u } from '../..';
import { ModalProps } from './types';
import {
U_MODAL_EVENT_CLEAR_LOADING,
U_MODAL_EVENT_HIDE,
U_MODAL_EVENT_SHOW,
U_MODAL_GLOBAL_EVENT_CLEAR_LOADING,
U_MODAL_GLOBAL_EVENT_HIDE,
U_MODAL_GLOBAL_EVENT_SHOW,
getEventWithCurrentPage,
type ModalPayload
} from './service';
/**
* modal 模态框
* @description 弹出模态框,常用于消息提示、消息确认、在当前页面内完成特定的交互操作
* @tutorial https://uviewpro.cn/zh/components/modal.html
* @property {Boolean} value 是否显示模态框
* @property {String | Number} z-index 层级
* @property {String} title 模态框标题(默认"提示")
* @property {String | Number} width 模态框宽度(默认600
* @property {String} content 模态框内容(默认"内容")
* @property {Boolean} show-title 是否显示标题(默认true
* @property {Boolean} async-close 是否异步关闭,只对确定按钮有效(默认false)
* @property {Boolean} show-confirm-button 是否显示确认按钮(默认true)
* @property {String | Number} negative-top modal往上偏移的值
* @property {Boolean} show-cancel-button 是否显示取消按钮(默认false)
* @property {Boolean} mask-close-able 是否允许点击遮罩关闭modal(默认false)
* @property {String} confirm-text 确认按钮的文字内容(默认"确认")
* @property {String} cancel-text 取消按钮的文字内容(默认"取消")
* @property {String} cancel-color 取消按钮的颜色(默认"var(--u-content-color)"
* @property {String} confirm-color 确认按钮的文字内容(默认主题色primary)
* @property {String | Number} border-radius 模态框圆角值,单位rpx(默认16)
* @property {Object} title-style 自定义标题样式,对象形式
* @property {Object} content-style 自定义内容样式,对象形式
* @property {Object} cancel-style 自定义取消按钮样式,对象形式
* @property {Object} confirm-style 自定义确认按钮样式,对象形式
* @property {Boolean} zoom 是否开启缩放模式(默认true)
* @event {Function} confirm 确认按钮被点击
* @event {Function} cancel 取消按钮被点击
* @example <u-modal :src="title" :content="content"></u-modal>
*/
const props = defineProps(ModalProps);
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
const slots = useSlots();
// 确认按钮是否正在加载中
const loading = ref(false);
const isGlobal = computed(() => !!props.global);
const isPage = computed(() => !!props.page);
const showEvent = computed(() =>
isGlobal.value
? U_MODAL_GLOBAL_EVENT_SHOW
: isPage.value
? getEventWithCurrentPage(U_MODAL_EVENT_SHOW, props.page)
: ''
);
const hideEvent = computed(() =>
isGlobal.value
? U_MODAL_GLOBAL_EVENT_HIDE
: isPage.value
? getEventWithCurrentPage(U_MODAL_EVENT_HIDE, props.page)
: ''
);
const clearLoadingEvent = computed(() =>
isGlobal.value
? U_MODAL_GLOBAL_EVENT_CLEAR_LOADING
: isPage.value
? getEventWithCurrentPage(U_MODAL_EVENT_CLEAR_LOADING, props.page)
: ''
);
// 存储用户传入的回调函数
let userOnConfirm: (() => void) | null = null;
let userOnCancel: (() => void) | null = null;
// 需要与 effectiveConfig 合并的 props 键名列表(用于函数式调用)
const MERGE_PROPS_KEYS = [
'title',
'content',
'showTitle',
'showConfirmButton',
'showCancelButton',
'confirmText',
'cancelText',
'confirmColor',
'cancelColor',
'confirmStyle',
'cancelStyle',
'titleStyle',
'contentStyle',
'asyncClose',
'borderRadius',
'width',
'zoom',
'maskCloseAble',
'negativeTop',
'zIndex',
'customStyle',
'customClass'
] as const;
// 函数式调用时的临时配置
const tempConfig = ref<Partial<ModalPayload>>({});
// 函数式调用时的内部显示状态(用于 global 模式)
const internalShow = ref(false);
// 有效的配置(函数式调用时合并 tempConfig 和 propsv-model 时使用 props
const effectiveConfig = computed(() => {
// 如果有临时配置(函数式调用),合并用户配置与 props 默认值
if (Object.keys(tempConfig.value).length > 0) {
const result: Record<string, any> = {};
for (const key of MERGE_PROPS_KEYS) {
// 用户配置优先,否则使用 props 默认值
result[key] = (tempConfig.value as Record<string, any>)[key] ?? (props as Record<string, any>)[key];
}
result.zIndex = tempConfig.value.zIndex ?? props.zIndex ?? $u.zIndex.popup;
return result;
}
// v-model 直接控制时使用 props
return props;
});
// 取消按钮样式
const cancelBtnStyle = computed(() => {
return Object.assign({ color: effectiveConfig.value.cancelColor }, effectiveConfig.value.cancelStyle);
});
// 确认按钮样式
const confirmBtnStyle = computed(() => {
return Object.assign({ color: effectiveConfig.value.confirmColor }, effectiveConfig.value.confirmStyle);
});
// 弹窗的样式
const uZIndex = computed(() => effectiveConfig.value.zIndex ?? $u.zIndex.popup);
// 是否使用内部状态控制显示(global 模式)
const useInternalShow = computed(() => isGlobal.value || isPage.value);
// 最终显示状态
const finalShow = computed(() => {
if (useInternalShow.value) {
return internalShow.value;
}
return props.modelValue;
});
// u-popup 绑定的值
const popupValue = computed({
get: () => finalShow.value,
set: (val: boolean) => {
if (useInternalShow.value) {
internalShow.value = val;
} else {
emit('update:modelValue', val);
}
}
});
// 如果是异步关闭时,外部修改v-model的值为false时,重置内部的loading状态,避免下次打开的时候,状态混乱
watch(
() => popupValue.value,
n => {
if (n === true) loading.value = false;
}
);
/**
* 确认按钮点击事件
*/
function confirm() {
// 先调用回调,再重置配置
const onConfirm = userOnConfirm;
// 异步关闭
if (effectiveConfig.value.asyncClose) {
loading.value = true;
} else {
popupValue.value = false;
// 延迟重置配置,避免闪屏
setTimeout(() => resetTempConfig(), 300);
}
emit('confirm');
// 调用函数式调用时用户传入的回调
onConfirm?.();
}
/**
* 取消按钮点击事件
*/
function cancel() {
// 先调用回调,再重置配置
const onCancel = userOnCancel;
// 延迟重置配置,避免闪屏
setTimeout(() => resetTempConfig(), 300);
emit('cancel');
popupValue.value = false;
// 调用函数式调用时用户传入的回调
onCancel?.();
// 目前popup弹窗关闭有一个延时操作,此处做一个延时
// 避免确认按钮文字变成了"确定"字样,modal还没消失,造成视觉不好的效果
setTimeout(() => {
loading.value = false;
}, 300);
}
/**
* 点击遮罩关闭modal,设置v-model的值为false,否则无法第二次弹起modal
*/
function popupClose() {
popupValue.value = false;
resetTempConfig();
}
/**
* 清除加载中的状态
*/
function clearLoading() {
loading.value = false;
}
/**
* 函数式调用显示 modal
*/
function onServiceShow(payload: ModalPayload) {
// 保存回调函数
userOnConfirm = payload.onConfirm ?? null;
userOnCancel = payload.onCancel ?? null;
// 只保存用户传入的配置(过滤掉回调)
const { onConfirm, onCancel, ...rest } = payload;
tempConfig.value = rest;
// 使用内部状态控制显示
internalShow.value = true;
}
/**
* 函数式调用关闭 modal
*/
function onServiceHide() {
internalShow.value = false;
resetTempConfig();
}
/**
* 重置临时配置
*/
function resetTempConfig() {
tempConfig.value = {};
userOnConfirm = null;
userOnCancel = null;
}
// 开始监听事件
function startListeners() {
// 如果为全局 toast,则先移除所有事件监听,再重新监听
if (isGlobal.value) {
removeAllListeners();
}
if (showEvent.value) {
uni?.$on && uni.$on(showEvent.value, onServiceShow);
}
if (hideEvent.value) {
uni?.$on && uni.$on(hideEvent.value, onServiceHide);
}
if (clearLoadingEvent.value) {
uni?.$on && uni.$on(clearLoadingEvent.value, clearLoading);
}
}
// 停止监听事件
function stopListeners() {
if (showEvent.value) {
uni?.$off && uni.$off(showEvent.value, onServiceShow);
}
if (hideEvent.value) {
uni?.$off && uni.$off(hideEvent.value, onServiceHide);
}
if (clearLoadingEvent.value) {
uni?.$off && uni.$off(clearLoadingEvent.value, clearLoading);
}
}
// 移除所有事件监听
function removeAllListeners() {
if (showEvent.value) {
uni?.$off && uni.$off(showEvent.value);
}
if (hideEvent.value) {
uni?.$off && uni.$off(hideEvent.value);
}
if (clearLoadingEvent.value) {
uni?.$off && uni.$off(clearLoadingEvent.value);
}
}
onMounted(() => {
startListeners();
});
onBeforeUnmount(() => {
stopListeners();
});
defineExpose({
clearLoading
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-model {
height: auto;
overflow: hidden;
font-size: 32rpx;
background-color: var(--u-bg-white);
&__btn--hover {
background-color: rgb(230, 230, 230);
}
&__title {
padding-top: 48rpx;
font-weight: 500;
text-align: center;
color: $u-main-color;
}
&__content {
&__message {
padding: 48rpx;
font-size: 30rpx;
text-align: center;
color: $u-content-color;
}
}
&__footer {
@include vue-flex;
&__button {
flex: 1;
height: 100rpx;
line-height: 100rpx;
font-size: 32rpx;
box-sizing: border-box;
cursor: pointer;
text-align: center;
border-radius: 4rpx;
}
}
}
</style>