FrontTools/OverlayPage/OverlayPage.vue
Rain 3ab1f2db24 [入库]初次入库各种工具
- DBManager DB创建管理器
- FormatTimeTool
- ImageViewer工具
- InputArear工具
- OverlayPage工具
- 下拉刷新容器工具
- 视频查看器工具
2026-04-02 16:09:00 +08:00

327 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>