419 lines
10 KiB
Vue
419 lines
10 KiB
Vue
<template>
|
||
<view v-if="visible && current" class="global-popup-mask" @tap="handleMaskTap">
|
||
<view class="global-popup-wrap" @tap.stop>
|
||
<view v-if="showAutoCloseProgress" class="global-popup__progress">
|
||
<view class="global-popup__progress-inner" :style="progressStyle"></view>
|
||
</view>
|
||
<view class="global-popup">
|
||
<!-- 关闭按钮 -->
|
||
<view v-if="current.is_closable !== 0" class="global-popup__close" @tap="handleClose">
|
||
<text>×</text>
|
||
</view>
|
||
|
||
<!-- 主体内容 -->
|
||
<view class="global-popup__body" @tap="handleClick">
|
||
<image v-if="current.image" :src="current.image" class="global-popup__image" mode="widthFix" />
|
||
|
||
<view v-if="current.title || current.content" class="global-popup__text">
|
||
<text v-if="current.title" class="global-popup__title">{{ current.title }}</text>
|
||
<rich-text v-if="current.content" class="global-popup__content"
|
||
:nodes="current.content"></rich-text>
|
||
</view>
|
||
|
||
<view v-if="current.link_type !== 0" class="global-popup__btn">
|
||
<text>查看详情</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, onMounted, computed } from 'vue'
|
||
import { getPopupList, reportPopupAction, type PagePopupItem } from '@/api/popup'
|
||
import { getOrCreateDeviceId, getPlatform } from '@/utils/device'
|
||
import { useAppStore } from '@/stores/app'
|
||
|
||
const props = defineProps<{
|
||
pagePath?: string
|
||
triggerTick?: number
|
||
}>()
|
||
|
||
const appStore = useAppStore()
|
||
|
||
function getPagePathFromPages(): string {
|
||
const pages = getCurrentPages()
|
||
if (!pages.length) return ''
|
||
const page = pages[pages.length - 1] as any
|
||
let route = page.route || ''
|
||
// #ifdef H5
|
||
route = route.replace(/^\//, '')
|
||
if (route.startsWith('mobile/')) {
|
||
route = route.replace('mobile/', '')
|
||
}
|
||
// #endif
|
||
return '/' + route
|
||
}
|
||
|
||
const effectivePath = computed(() => props.pagePath || appStore.popupPagePath || getPagePathFromPages())
|
||
const effectiveTick = computed(() => props.triggerTick ?? appStore.popupTick)
|
||
|
||
const visible = ref(false)
|
||
const current = ref<PagePopupItem | null>(null)
|
||
const showAutoCloseProgress = ref(false)
|
||
const progressPct = ref(100)
|
||
let autoCloseInterval: ReturnType<typeof setInterval> | null = null
|
||
|
||
function progressColor(pct: number): string {
|
||
if (pct > 60) return '#4cd964'
|
||
if (pct > 30) return '#ff9500'
|
||
return '#ff3b30'
|
||
}
|
||
|
||
const progressStyle = computed(() => ({
|
||
width: progressPct.value + '%',
|
||
backgroundColor: progressColor(progressPct.value),
|
||
transitionDuration: '200ms',
|
||
}))
|
||
const platform = getPlatform()
|
||
const deviceId = getOrCreateDeviceId()
|
||
let autoCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
// 频率控制存储 key
|
||
const FREQ_KEY_PREFIX = 'popup_shown_'
|
||
|
||
/** 同一 App 启动周期内已展示的弹窗 ID 集合(每会话一次) */
|
||
const sessionShown = new Set<number>()
|
||
|
||
/**
|
||
* 检查频率是否允许展示
|
||
* @returns true 表示可以展示
|
||
*/
|
||
function checkFrequency(item: PagePopupItem): boolean {
|
||
const key = FREQ_KEY_PREFIX + item.id
|
||
const now = Date.now()
|
||
|
||
switch (item.frequency) {
|
||
case 1: // 每次
|
||
return true
|
||
case 2: // 每会话一次
|
||
return !sessionShown.has(item.id)
|
||
case 3: {
|
||
// 每天一次
|
||
try {
|
||
const last = uni.getStorageSync(key)
|
||
if (!last) return true
|
||
return now - Number(last) >= 24 * 60 * 60 * 1000
|
||
} catch (e) {
|
||
return true
|
||
}
|
||
}
|
||
case 4: {
|
||
// 仅一次
|
||
try {
|
||
return !uni.getStorageSync(key)
|
||
} catch (e) {
|
||
return true
|
||
}
|
||
}
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
|
||
/** 标记已展示 */
|
||
function markShown(item: PagePopupItem) {
|
||
sessionShown.add(item.id)
|
||
if (item.frequency === 3 || item.frequency === 4) {
|
||
try {
|
||
uni.setStorageSync(FREQ_KEY_PREFIX + item.id, Date.now())
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 拉取弹窗并展示首个匹配项 */
|
||
async function checkAndShow() {
|
||
if (!effectivePath.value) return
|
||
try {
|
||
const res: any = await getPopupList({
|
||
page_path: effectivePath.value,
|
||
platform,
|
||
})
|
||
const lists: PagePopupItem[] = res?.lists || []
|
||
if (!lists.length) return
|
||
|
||
// 取第一个频率允许展示的弹窗
|
||
const target = lists.find((it) => checkFrequency(it))
|
||
if (!target) return
|
||
|
||
const showFn = () => {
|
||
current.value = target
|
||
visible.value = true
|
||
markShown(target)
|
||
startAutoClose(target)
|
||
// 上报展示
|
||
reportPopupAction({
|
||
popup_id: target.id,
|
||
action: 1,
|
||
page_path: effectivePath.value,
|
||
device_id: deviceId,
|
||
platform,
|
||
}).catch(() => { /* ignore */ })
|
||
}
|
||
|
||
if (target.delay_seconds && target.delay_seconds > 0) {
|
||
setTimeout(showFn, target.delay_seconds * 1000)
|
||
} else {
|
||
showFn()
|
||
}
|
||
} catch (e) {
|
||
console.warn('[GlobalPopup] 拉取弹窗失败:', e)
|
||
}
|
||
}
|
||
|
||
function handleClick() {
|
||
if (!current.value) return
|
||
const item = current.value
|
||
// 上报点击
|
||
reportPopupAction({
|
||
popup_id: item.id,
|
||
action: 2,
|
||
page_path: effectivePath.value,
|
||
device_id: deviceId,
|
||
platform,
|
||
}).catch(() => { /* ignore */ })
|
||
|
||
// 跳转
|
||
if (item.link_type === 1 && item.link_url) {
|
||
// 站内
|
||
uni.navigateTo({
|
||
url: item.link_url,
|
||
fail: () => {
|
||
uni.switchTab({
|
||
url: item.link_url,
|
||
fail: () => uni.reLaunch({ url: item.link_url }),
|
||
})
|
||
},
|
||
})
|
||
closeWithoutReport()
|
||
} else if (item.link_type === 2 && item.link_url) {
|
||
// 外部 H5
|
||
// #ifdef APP-PLUS
|
||
plus.runtime.openURL(item.link_url)
|
||
// #endif
|
||
// #ifdef H5
|
||
window.open(item.link_url, '_blank')
|
||
// #endif
|
||
// #ifdef MP
|
||
uni.setClipboardData({ data: item.link_url })
|
||
uni.showToast({ title: '链接已复制', icon: 'none' })
|
||
// #endif
|
||
closeWithoutReport()
|
||
} else if (item.link_type === 3 && item.link_url) {
|
||
// 拨打电话
|
||
uni.makePhoneCall({ phoneNumber: item.link_url })
|
||
closeWithoutReport()
|
||
}
|
||
}
|
||
|
||
function handleClose() {
|
||
if (!current.value) return
|
||
clearAutoClose()
|
||
reportPopupAction({
|
||
popup_id: current.value.id,
|
||
action: 3,
|
||
page_path: effectivePath.value,
|
||
device_id: deviceId,
|
||
platform,
|
||
}).catch(() => { /* ignore */ })
|
||
closeWithoutReport()
|
||
}
|
||
|
||
function handleMaskTap() {
|
||
if (current.value && current.value.is_closable === 0) return
|
||
handleClose()
|
||
}
|
||
|
||
function closeWithoutReport() {
|
||
clearAutoClose()
|
||
visible.value = false
|
||
current.value = null
|
||
}
|
||
|
||
function closeWithReport() {
|
||
if (!current.value) return
|
||
reportPopupAction({
|
||
popup_id: current.value.id,
|
||
action: 3,
|
||
page_path: effectivePath.value,
|
||
device_id: deviceId,
|
||
platform,
|
||
}).catch(() => { /* ignore */ })
|
||
closeWithoutReport()
|
||
}
|
||
|
||
function startAutoClose(item: PagePopupItem) {
|
||
clearAutoClose()
|
||
const seconds = Number(item.auto_close_seconds || 0)
|
||
if (seconds <= 0) return
|
||
const duration = seconds * 1000
|
||
const step = 200
|
||
const decrement = (100 * step) / duration
|
||
progressPct.value = 100
|
||
showAutoCloseProgress.value = true
|
||
autoCloseInterval = setInterval(() => {
|
||
progressPct.value = Math.max(0, progressPct.value - decrement)
|
||
}, step)
|
||
autoCloseTimer = setTimeout(() => {
|
||
closeWithReport()
|
||
}, duration)
|
||
}
|
||
|
||
function clearAutoClose() {
|
||
if (autoCloseTimer) {
|
||
clearTimeout(autoCloseTimer)
|
||
autoCloseTimer = null
|
||
}
|
||
if (autoCloseInterval) {
|
||
clearInterval(autoCloseInterval)
|
||
autoCloseInterval = null
|
||
}
|
||
showAutoCloseProgress.value = false
|
||
progressPct.value = 100
|
||
}
|
||
|
||
onMounted(() => {
|
||
checkAndShow()
|
||
})
|
||
|
||
watch(effectiveTick, () => {
|
||
if (!visible.value) checkAndShow()
|
||
})
|
||
|
||
watch(effectivePath, () => {
|
||
if (!visible.value) checkAndShow()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.global-popup-mask {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 9998;
|
||
}
|
||
|
||
.global-popup-wrap {
|
||
position: relative;
|
||
width: 580rpx;
|
||
}
|
||
|
||
.global-popup {
|
||
position: relative;
|
||
width: 100%;
|
||
background: #fff;
|
||
border-radius: 24rpx;
|
||
overflow: hidden;
|
||
animation: globalPopupIn 0.25s ease-out;
|
||
}
|
||
|
||
@keyframes globalPopupIn {
|
||
0% {
|
||
transform: scale(0.85);
|
||
opacity: 0;
|
||
}
|
||
|
||
100% {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.global-popup__close {
|
||
position: absolute;
|
||
top: 20rpx;
|
||
right: 20rpx;
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 5;
|
||
|
||
text {
|
||
font-size: 48rpx;
|
||
color: #fff;
|
||
line-height: 1;
|
||
}
|
||
}
|
||
|
||
.global-popup__progress {
|
||
width: 100%;
|
||
height: 6rpx;
|
||
margin-bottom: 16rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(255, 255, 255, 0.3);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.global-popup__progress-inner {
|
||
height: 100%;
|
||
border-radius: 999rpx;
|
||
transition-property: width, background-color;
|
||
transition-timing-function: linear;
|
||
}
|
||
|
||
.global-popup__body {
|
||
width: 100%;
|
||
}
|
||
|
||
.global-popup__image {
|
||
width: 100%;
|
||
display: block;
|
||
}
|
||
|
||
.global-popup__text {
|
||
padding: 32rpx 40rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.global-popup__title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #222;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.global-popup__content {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.global-popup__btn {
|
||
margin: 32rpx 40rpx 32rpx;
|
||
background: #0b55eb;
|
||
border-radius: 40rpx;
|
||
padding: 24rpx 0;
|
||
text-align: center;
|
||
|
||
text {
|
||
font-size: 28rpx;
|
||
color: #fff;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
</style>
|