327 lines
8.0 KiB
Vue
327 lines
8.0 KiB
Vue
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, watch, inject, onUnmounted } from 'vue'
|
|||
|
|
|
|||
|
|
// ══════ 全局状态管理 ══════
|
|||
|
|
let _zSeed = 1000
|
|||
|
|
let _activeCount = 0
|
|||
|
|
// 记录所有活动的 overlay 的 z-index,用于判断最顶层
|
|||
|
|
const _activeZIndices = new Set<number>()
|
|||
|
|
|
|||
|
|
const props = withDefaults(defineProps<{
|
|||
|
|
visible: boolean
|
|||
|
|
title?: string
|
|||
|
|
noHeader?: boolean
|
|||
|
|
/** 点击标题时的回调,传入后标题可点击 */
|
|||
|
|
titleClick?: () => void
|
|||
|
|
/** 为 true 时:默认槽在首次打开后才挂载,关闭后保留 DOM,减少未打开 overlay 的重内容渲染 */
|
|||
|
|
lazy?: boolean
|
|||
|
|
}>(), {
|
|||
|
|
title: '',
|
|||
|
|
noHeader: false,
|
|||
|
|
lazy: false
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
/** 打开时触发(当 visible 变为 true 时) */
|
|||
|
|
open: []
|
|||
|
|
/** 关闭时触发(用户主动关闭时,如点击关闭按钮、返回键等) */
|
|||
|
|
close: []
|
|||
|
|
/** 关闭完成时触发(当 visible 变为 false 时,用于生命周期监听) */
|
|||
|
|
closed: []
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
// ══════ 层级 Z-Index(新打开的永远在上层) ══════
|
|||
|
|
const zIndex = ref(_zSeed)
|
|||
|
|
|
|||
|
|
// ══════ 状态栏高度 ══════
|
|||
|
|
const statusBarHeight = ref(0)
|
|||
|
|
|
|||
|
|
// ══════ 返回键栈管理 ══════
|
|||
|
|
const tabRootBackPress = inject<{
|
|||
|
|
register: (h: { check: () => boolean; close: () => void }) => () => void
|
|||
|
|
}>('tabRootBackPress', undefined as any)
|
|||
|
|
|
|||
|
|
let unreg: (() => void) | null = null
|
|||
|
|
|
|||
|
|
// ══════ 内部状态 ══════
|
|||
|
|
const isTracked = ref(false)
|
|||
|
|
/** lazy 模式下,首次打开后为 true,用于保持 slot 挂载避免重复创建 */
|
|||
|
|
const hasOpenedOnce = ref(false)
|
|||
|
|
/** 关闭动画定时器,用于延迟触发 closed 事件 */
|
|||
|
|
let closeTimer: ReturnType<typeof setTimeout> | null = null
|
|||
|
|
|
|||
|
|
// ══════ 工具函数 ══════
|
|||
|
|
/**
|
|||
|
|
* 获取状态栏高度
|
|||
|
|
*/
|
|||
|
|
const getStatusBarHeight = (): number => {
|
|||
|
|
try {
|
|||
|
|
return uni.getSystemInfoSync().statusBarHeight ?? 0
|
|||
|
|
} catch {
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 注册到动态页面计数
|
|||
|
|
*/
|
|||
|
|
const registerActive = () => {
|
|||
|
|
if (!isTracked.value) {
|
|||
|
|
isTracked.value = true
|
|||
|
|
_activeCount++
|
|||
|
|
_activeZIndices.add(zIndex.value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从动态页面计数中注销
|
|||
|
|
*/
|
|||
|
|
const unregisterActive = () => {
|
|||
|
|
if (isTracked.value) {
|
|||
|
|
isTracked.value = false
|
|||
|
|
_activeZIndices.delete(zIndex.value)
|
|||
|
|
if (--_activeCount <= 0) {
|
|||
|
|
_activeCount = 0
|
|||
|
|
_zSeed = 1000
|
|||
|
|
_activeZIndices.clear()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取当前最大的 z-index
|
|||
|
|
*/
|
|||
|
|
const getMaxZIndex = (): number => {
|
|||
|
|
if (_activeZIndices.size === 0) return 0
|
|||
|
|
return Math.max(...Array.from(_activeZIndices))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 注册返回键处理
|
|||
|
|
* 只有当当前 overlay 可见且是最顶层(z-index 最高)时才响应返回键
|
|||
|
|
*/
|
|||
|
|
const registerBackPress = () => {
|
|||
|
|
if (tabRootBackPress) {
|
|||
|
|
unreg = tabRootBackPress.register({
|
|||
|
|
check: () => {
|
|||
|
|
// 只有当前 overlay 可见且是最顶层时才返回 true
|
|||
|
|
if (!props.visible) return false;
|
|||
|
|
// 检查是否是最顶层:当前 z-index 应该等于所有活动 overlay 中的最大 z-index
|
|||
|
|
const maxZ = getMaxZIndex()
|
|||
|
|
return zIndex.value === maxZ && maxZ > 0
|
|||
|
|
},
|
|||
|
|
close: handleClose
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 注销返回键处理
|
|||
|
|
*/
|
|||
|
|
const unregisterBackPress = () => {
|
|||
|
|
if (unreg) {
|
|||
|
|
unreg()
|
|||
|
|
unreg = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理打开逻辑
|
|||
|
|
*/
|
|||
|
|
const handleOpen = () => {
|
|||
|
|
// 分配新层级
|
|||
|
|
zIndex.value = ++_zSeed
|
|||
|
|
// 注册层级
|
|||
|
|
registerActive()
|
|||
|
|
// 获取状态栏高度
|
|||
|
|
statusBarHeight.value = getStatusBarHeight()
|
|||
|
|
// 注册返回键
|
|||
|
|
registerBackPress()
|
|||
|
|
// 触发生命周期事件
|
|||
|
|
emit('open')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理关闭逻辑
|
|||
|
|
*/
|
|||
|
|
const handleClose = () => {
|
|||
|
|
emit('close')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理关闭后的清理
|
|||
|
|
* 延迟触发 closed 事件,等待退出动画完成
|
|||
|
|
*/
|
|||
|
|
const handleCloseCleanup = () => {
|
|||
|
|
// 注销返回键
|
|||
|
|
unregisterBackPress()
|
|||
|
|
// 注销活动计数
|
|||
|
|
unregisterActive()
|
|||
|
|
// 清除之前的定时器(如果存在)
|
|||
|
|
if (closeTimer) {
|
|||
|
|
clearTimeout(closeTimer)
|
|||
|
|
closeTimer = null
|
|||
|
|
}
|
|||
|
|
// 延迟触发 closed 事件,等待动画完成
|
|||
|
|
closeTimer = setTimeout(() => {
|
|||
|
|
closeTimer = null
|
|||
|
|
emit('closed')
|
|||
|
|
}, 600)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ══════ 监听 visible 变化,管理生命周期 ══════
|
|||
|
|
watch(() => props.visible, (newVal, oldVal) => {
|
|||
|
|
if (newVal === oldVal) return
|
|||
|
|
|
|||
|
|
if (newVal) {
|
|||
|
|
// 如果正在关闭动画中,取消关闭定时器
|
|||
|
|
if (closeTimer) {
|
|||
|
|
clearTimeout(closeTimer)
|
|||
|
|
closeTimer = null
|
|||
|
|
}
|
|||
|
|
if (props.lazy) hasOpenedOnce.value = true
|
|||
|
|
handleOpen()
|
|||
|
|
} else {
|
|||
|
|
handleCloseCleanup()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// ══════ 组件卸载时的清理 ══════
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
unregisterBackPress()
|
|||
|
|
unregisterActive()
|
|||
|
|
// 清除关闭动画定时器
|
|||
|
|
if (closeTimer) {
|
|||
|
|
clearTimeout(closeTimer)
|
|||
|
|
closeTimer = null
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// ══════ 暴露给模板的方法 ══════
|
|||
|
|
const close = handleClose
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<view class="ov-page" :class="{ 'ov-page--visible': visible }" :style="{ zIndex }">
|
|||
|
|
<!-- 遮罩: 屏蔽下层触摸 -->
|
|||
|
|
<view
|
|||
|
|
v-show="visible"
|
|||
|
|
class="ov-page__backdrop"
|
|||
|
|
:class="{ 'ov-page__backdrop--visible': visible }"
|
|||
|
|
@touchmove.stop.prevent
|
|||
|
|
@touchstart.stop
|
|||
|
|
@touchend.stop
|
|||
|
|
/>
|
|||
|
|
<!-- 滑入面板 -->
|
|||
|
|
<view class="ov-page__panel" :class="{ 'ov-page__panel--in': visible }">
|
|||
|
|
<!-- 顶栏 (noHeader 关闭 / #header 完全替换 / #header-right 自定义右侧) -->
|
|||
|
|
<template v-if="!noHeader">
|
|||
|
|
<slot name="header" :close="close" :statusBarHeight="statusBarHeight">
|
|||
|
|
<view class="ov-header" :style="{ paddingTop: statusBarHeight + 'px' }">
|
|||
|
|
<view class="ov-header__inner">
|
|||
|
|
<view class="ov-header__back" @click="close">
|
|||
|
|
<wd-icon name="thin-arrow-left" size="32rpx" color="var(--dv-text)" />
|
|||
|
|
</view>
|
|||
|
|
<view
|
|||
|
|
v-if="titleClick"
|
|||
|
|
class="ov-header__title ov-header__title--clickable"
|
|||
|
|
@click="titleClick"
|
|||
|
|
>{{ title }}</view>
|
|||
|
|
<text v-else class="ov-header__title">{{ title }}</text>
|
|||
|
|
<view class="ov-header__right">
|
|||
|
|
<slot name="header-right" />
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</slot>
|
|||
|
|
</template>
|
|||
|
|
<!-- 主内容(lazy 时首次打开后才挂载,减少未打开 overlay 的重内容) -->
|
|||
|
|
<template v-if="!lazy || visible || hasOpenedOnce">
|
|||
|
|
<slot />
|
|||
|
|
</template>
|
|||
|
|
</view>
|
|||
|
|
<!-- 额外内容插槽(用于放置嵌套 overlay,避免 transform 影响 fixed 定位) -->
|
|||
|
|
<slot name="extra" />
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style lang="scss" scoped>
|
|||
|
|
.ov-page {
|
|||
|
|
position: fixed;
|
|||
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
.ov-page--visible { pointer-events: auto; }
|
|||
|
|
|
|||
|
|
.ov-page__backdrop {
|
|||
|
|
position: absolute;
|
|||
|
|
inset: 0;
|
|||
|
|
z-index: 0;
|
|||
|
|
opacity: 0;
|
|||
|
|
transition: opacity .1s cubic-bezier(.22, .61, .36, 1);
|
|||
|
|
}
|
|||
|
|
.ov-page__backdrop--visible {
|
|||
|
|
opacity: 1;
|
|||
|
|
transition: opacity .1s cubic-bezier(.22, .61, .36, 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ov-page__panel {
|
|||
|
|
position: absolute;
|
|||
|
|
inset: 0;
|
|||
|
|
z-index: 1;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
background: var(--dv-bg);
|
|||
|
|
transform: translateX(100%);
|
|||
|
|
opacity: 0;
|
|||
|
|
filter: blur(20rpx);
|
|||
|
|
box-shadow: -8rpx 0 24rpx rgba(0, 0, 0, 0.15);
|
|||
|
|
transition: transform .3s cubic-bezier(.22, .61, .36, 1), opacity .6s cubic-bezier(.22, .61, .36, 1), filter .6s cubic-bezier(.22, .61, .36, 1);
|
|||
|
|
will-change: transform, opacity, filter;
|
|||
|
|
}
|
|||
|
|
.ov-page__panel--in {
|
|||
|
|
transform: translateX(0);
|
|||
|
|
opacity: 1;
|
|||
|
|
filter: blur(0);
|
|||
|
|
transition: transform .3s cubic-bezier(.22, .61, .36, 1), opacity .25s cubic-bezier(.22, .61, .36, 1), filter .25s cubic-bezier(.22, .61, .36, 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ov-header {
|
|||
|
|
position: sticky;
|
|||
|
|
top: 0;
|
|||
|
|
z-index: 100;
|
|||
|
|
background: var(--dv-bg);
|
|||
|
|
padding-left: env(safe-area-inset-left);
|
|||
|
|
padding-right: env(safe-area-inset-right);
|
|||
|
|
}
|
|||
|
|
.ov-header__inner {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
height: 88rpx;
|
|||
|
|
padding: 0 24rpx;
|
|||
|
|
}
|
|||
|
|
.ov-header__back {
|
|||
|
|
width: 72rpx;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 8rpx;
|
|||
|
|
}
|
|||
|
|
.ov-header__title {
|
|||
|
|
flex: 1;
|
|||
|
|
text-align: center;
|
|||
|
|
font-size: var(--dv-font-title);
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--dv-text);
|
|||
|
|
}
|
|||
|
|
.ov-header__title--clickable {
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
.ov-header__right {
|
|||
|
|
width: 88rpx;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
</style>
|