309 lines
6.3 KiB
Vue
309 lines
6.3 KiB
Vue
|
|
<template>
|
|||
|
|
<Teleport to="body">
|
|||
|
|
<Transition name="popup-fade">
|
|||
|
|
<div
|
|||
|
|
v-if="visible"
|
|||
|
|
class="stretchable-modal__overlay"
|
|||
|
|
@click="handleOverlayClick"
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
class="stretchable-modal__container"
|
|||
|
|
:style="containerStyle"
|
|||
|
|
@click.stop
|
|||
|
|
>
|
|||
|
|
<!-- 头部:大弹窗top切图.png -->
|
|||
|
|
<div class="stretchable-modal__header">
|
|||
|
|
<slot name="header">
|
|||
|
|
<h3 class="stretchable-modal__title">{{ title }}</h3>
|
|||
|
|
</slot>
|
|||
|
|
<button
|
|||
|
|
v-if="showClose"
|
|||
|
|
class="stretchable-modal__close"
|
|||
|
|
@click="handleClose"
|
|||
|
|
aria-label="关闭弹窗"
|
|||
|
|
>
|
|||
|
|
<img
|
|||
|
|
src="../../assets/images/modal/closeIcon.png"
|
|||
|
|
alt="关闭"
|
|||
|
|
class="close-icon"
|
|||
|
|
/>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 中间:大弹窗mid切图.png -->
|
|||
|
|
<div class="stretchable-modal__body">
|
|||
|
|
<slot></slot>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 底部:大弹窗bottom切图.png -->
|
|||
|
|
<div v-if="$slots.footer" class="stretchable-modal__footer">
|
|||
|
|
<slot name="footer"></slot>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</Transition>
|
|||
|
|
</Teleport>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { computed, watch, onMounted, onUnmounted } from 'vue';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* StretchableModal - 三段式可拉伸弹窗组件
|
|||
|
|
*
|
|||
|
|
* 特性:
|
|||
|
|
* - 使用三张切图实现可拉伸的弹窗背景(头部、中间、底部)
|
|||
|
|
* - 头部和底部仅支持横向拉伸,中间部分支持全方向拉伸
|
|||
|
|
* - 标题居中显示
|
|||
|
|
* - 支持多种交互方式关闭(点击遮罩、ESC键、关闭按钮)
|
|||
|
|
* - 支持插槽自定义内容
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const props = defineProps({
|
|||
|
|
/**
|
|||
|
|
* 控制弹窗显示/隐藏
|
|||
|
|
*/
|
|||
|
|
visible: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: false
|
|||
|
|
},
|
|||
|
|
/**
|
|||
|
|
* 弹窗标题
|
|||
|
|
*/
|
|||
|
|
title: {
|
|||
|
|
type: String,
|
|||
|
|
default: ''
|
|||
|
|
},
|
|||
|
|
/**
|
|||
|
|
* 弹窗宽度,支持 CSS 单位或响应式值
|
|||
|
|
* @example '800px' | 'clamp(778px, 90vw, 1200px)'
|
|||
|
|
*/
|
|||
|
|
width: {
|
|||
|
|
type: String,
|
|||
|
|
default: 'clamp(900px, 85vw, 1400px)'
|
|||
|
|
},
|
|||
|
|
/**
|
|||
|
|
* 弹窗高度,支持 CSS 单位或响应式值
|
|||
|
|
* @example '600px' | 'clamp(500px, 80vh, 800px)'
|
|||
|
|
*/
|
|||
|
|
height: {
|
|||
|
|
type: String,
|
|||
|
|
default: 'clamp(600px, 85vh, 900px)'
|
|||
|
|
},
|
|||
|
|
/**
|
|||
|
|
* 点击遮罩层是否关闭弹窗
|
|||
|
|
*/
|
|||
|
|
closeOnClickModal: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: true
|
|||
|
|
},
|
|||
|
|
/**
|
|||
|
|
* 按 ESC 键是否关闭弹窗
|
|||
|
|
*/
|
|||
|
|
closeOnPressEscape: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: true
|
|||
|
|
},
|
|||
|
|
/**
|
|||
|
|
* 是否显示关闭按钮
|
|||
|
|
*/
|
|||
|
|
showClose: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: true
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const emit = defineEmits(['update:visible', 'close', 'open']);
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 计算弹窗容器的样式
|
|||
|
|
*/
|
|||
|
|
const containerStyle = computed(() => ({
|
|||
|
|
width: props.width,
|
|||
|
|
height: props.height
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理遮罩层点击事件
|
|||
|
|
*/
|
|||
|
|
const handleOverlayClick = () => {
|
|||
|
|
if (props.closeOnClickModal) {
|
|||
|
|
handleClose();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 关闭弹窗
|
|||
|
|
*/
|
|||
|
|
const handleClose = () => {
|
|||
|
|
emit('update:visible', false);
|
|||
|
|
emit('close');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理 ESC 键按下事件
|
|||
|
|
*/
|
|||
|
|
const handleKeydown = (event) => {
|
|||
|
|
if (props.visible && props.closeOnPressEscape && event.key === 'Escape') {
|
|||
|
|
handleClose();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 监听 visible 变化,触发相应事件
|
|||
|
|
watch(() => props.visible, (newVal) => {
|
|||
|
|
if (newVal) {
|
|||
|
|
emit('open');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 挂载和卸载时添加/移除键盘事件监听
|
|||
|
|
onMounted(() => {
|
|||
|
|
document.addEventListener('keydown', handleKeydown);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
document.removeEventListener('keydown', handleKeydown);
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped lang="scss">
|
|||
|
|
@use "@/styles/mixins.scss" as *;
|
|||
|
|
|
|||
|
|
.stretchable-modal {
|
|||
|
|
&__overlay {
|
|||
|
|
position: fixed;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
width: 100vw;
|
|||
|
|
height: 100vh;
|
|||
|
|
background: rgba(0, 0, 0, 0.6);
|
|||
|
|
backdrop-filter: blur(vw(5));
|
|||
|
|
z-index: 1000;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: vw(20);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&__container {
|
|||
|
|
position: relative;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&__header {
|
|||
|
|
position: relative;
|
|||
|
|
height: vh(66);
|
|||
|
|
min-height: vh(66);
|
|||
|
|
background: url(../../assets/images/modal/大弹窗top切图.png) repeat-x;
|
|||
|
|
background-size: 100% vh(66);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: 0 vw(60);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&__title {
|
|||
|
|
color: var(--text-white);
|
|||
|
|
font-size: fs(20);
|
|||
|
|
font-family: SourceHanSansCN-Bold, sans-serif;
|
|||
|
|
font-weight: bold;
|
|||
|
|
text-align: center;
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&__close {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 34px;
|
|||
|
|
right: 34px;
|
|||
|
|
transform: translateY(-50%);
|
|||
|
|
width: vw(32);
|
|||
|
|
height: vw(32);
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
z-index: 10;
|
|||
|
|
padding: 0;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
opacity: 0.8;
|
|||
|
|
transform: translateY(-50%) scale(1.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&:active {
|
|||
|
|
transform: translateY(-50%) scale(0.95);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.close-icon {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: contain;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&__body {
|
|||
|
|
flex: 1;
|
|||
|
|
background: url(../../assets/images/modal/大弹窗mid切图.png) repeat;
|
|||
|
|
background-size: 100% vh(66);
|
|||
|
|
overflow-y: auto;
|
|||
|
|
overflow-x: hidden;
|
|||
|
|
padding: vw(20) vw(30);
|
|||
|
|
|
|||
|
|
// 自定义滚动条样式
|
|||
|
|
&::-webkit-scrollbar {
|
|||
|
|
width: vw(6);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&::-webkit-scrollbar-track {
|
|||
|
|
background: rgba(255, 255, 255, 0.1);
|
|||
|
|
border-radius: vw(3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&::-webkit-scrollbar-thumb {
|
|||
|
|
background: var(--primary-color);
|
|||
|
|
border-radius: vw(3);
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
background: var(--primary-light);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&__footer {
|
|||
|
|
height: vh(66);
|
|||
|
|
min-height: vh(66);
|
|||
|
|
background: url(../../assets/images/modal/大弹窗bottom切图.png) repeat-x;
|
|||
|
|
background-size: 100% vh(66);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: 0 vw(30);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 弹窗淡入淡出动画
|
|||
|
|
.popup-fade-enter-active,
|
|||
|
|
.popup-fade-leave-active {
|
|||
|
|
transition: opacity 0.3s ease;
|
|||
|
|
|
|||
|
|
.stretchable-modal__container {
|
|||
|
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.popup-fade-enter-from,
|
|||
|
|
.popup-fade-leave-to {
|
|||
|
|
opacity: 0;
|
|||
|
|
|
|||
|
|
.stretchable-modal__container {
|
|||
|
|
transform: scale(0.9);
|
|||
|
|
opacity: 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|