no message

This commit is contained in:
hajimi
2026-06-11 12:15:29 +08:00
parent 10ebe39c30
commit 96efa1d905
5859 changed files with 815501 additions and 5 deletions
@@ -0,0 +1,418 @@
<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: 0 40rpx 32rpx;
background: #0b55eb;
border-radius: 40rpx;
padding: 24rpx 0;
text-align: center;
text {
font-size: 28rpx;
color: #fff;
font-weight: 500;
}
}
</style>