feat(每日充值活动): 根据手机系统类型使用不同的展示方案

This commit is contained in:
hzj 2026-05-07 14:26:56 +08:00
parent e67b130afc
commit f236293dfb
3 changed files with 308 additions and 39 deletions

View File

@ -10,13 +10,34 @@ import {
// ==================== 工具函数 ====================
/**
* 检测是否为设备Androidios系统
* 检测是否为移动设备AndroidiOS系统
* @returns {boolean}
*/
export function android() {
return /Android|iPad|iPhone|iPod/.test(navigator.userAgent)
}
/**
* 检测是否为 Android 设备
* @returns {boolean}
*/
export function isAndroidDevice() {
if (typeof navigator === 'undefined') return false
return /Android/i.test(navigator.userAgent || '')
}
/**
* 检测是否为 iOS 设备
* @returns {boolean}
*/
export function isIosDevice() {
if (typeof navigator === 'undefined') return false
const userAgent = navigator.userAgent || ''
const isIosUserAgent = /iPad|iPhone|iPod/i.test(userAgent)
const isIPadOS = /Macintosh/i.test(userAgent) && navigator.maxTouchPoints > 1
return isIosUserAgent || isIPadOS
}
/**
* 检测是否在APP环境中
* @returns {boolean}
@ -289,6 +310,21 @@ export function gotoAppPage(pageName) {
}
}
/**
* 通知 APP 调用 VAP 播放
*/
export function notifyAppPlayVap(sourceUrl) {
if (!sourceUrl) return
try {
const message = `play_vap:${sourceUrl}`
infoLog(message)
// 直接使用Flutter通道
window.FlutterPageControl.postMessage(message)
} catch (error) {
errorLog('通知APP调用VAP失败:', error)
}
}
/**
* 打开app组件
*/

View File

@ -0,0 +1,119 @@
<template>
<div class="active-game-page">
<video
v-if="!useVapPlayer"
ref="nativeVideoRef"
class="native-background-video"
:src="resolvedSrc"
autoplay
muted
loop
playsinline
webkit-playsinline
x5-playsinline
preload="auto"
></video>
<VapMp4Player
v-else
v-model="backgroundVisible"
class="vap-background"
:src="src"
mode="inline"
fit="cover"
:loop="true"
:mute="true"
:auto-close="false"
:show-status="false"
@error="handleVapError"
/>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { showError } from '@/utils/toast.js'
import VapMp4Player from '@/components/VapMp4Player.vue'
import { preloadVapConfig } from '@/utils/vapMp4Config.js'
import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
const props = defineProps({
src: {
type: String,
default: '',
},
})
const backgroundVisible = ref(true)
const nativeVideoRef = ref(null)
const useVapPlayer = ref(false)
const resolvedSrc = computed(() => resolveProtectedAssetUrl(props.src))
async function ensureNativeVideoPlayback() {
await nextTick()
const video = nativeVideoRef.value
if (!video) {
return
}
video.muted = true
video.defaultMuted = true
video.playsInline = true
video.setAttribute('playsinline', '')
video.setAttribute('webkit-playsinline', '')
video.setAttribute('x5-playsinline', '')
try {
await video.play()
} catch (error) {
console.warn('Native background video autoplay failed:', error)
}
}
async function resolvePlaybackMode() {
useVapPlayer.value = false
if (!resolvedSrc.value) {
return
}
ensureNativeVideoPlayback()
const vapConfig = await preloadVapConfig(resolvedSrc.value)
if (vapConfig) {
useVapPlayer.value = true
}
}
function handleVapError(error) {
console.error('VAP playback failed:', error)
useVapPlayer.value = false
ensureNativeVideoPlayback()
if (!error?.message?.includes('VAP config')) {
showError(error?.message || 'VAP playback failed')
}
}
onMounted(() => {
resolvePlaybackMode()
})
watch(resolvedSrc, () => {
resolvePlaybackMode()
})
</script>
<style lang="scss" scoped>
.active-game-page {
overflow: hidden;
}
.native-background-video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
</style>

View File

@ -15,12 +15,14 @@
<div class="active-game-shell scroll">
<div class="active-game-stage">
<!-- 头部背景图 -->
<img
v-smart-img.noFade
:src="imageUrl('bg2')"
alt=""
class="vap-background-layer"
<VapBg
v-if="isAndroidDevice"
class="vap-background-layer vap-background-vap-layer"
:src="bgVapUrl"
/>
<img v-else v-smart-img.noFade :src="imageUrl('bg2')" alt="" class="vap-background-layer" />
<!-- 背景层 -->
<div class="page-background-layer">
<BackgroundLayer
:useCanvas="true"
@ -325,36 +327,35 @@ justify-content: flex-start;
<div
class="reward-showcase-vap-wrap"
:key="mainRewardRenderKey"
:class="{ 'reward-showcase-vap-wrap-playable': mainReward?.sourceUrl }"
@click="playMainRewardPreview"
:class="{
'reward-showcase-vap-wrap-playable': isIosDevice && mainReward?.sourceUrl,
}"
@click="handleMainRewardPreviewClick"
>
<VapMp4Player
v-if="mainReward.sourceUrl && rewardPreviewPlaying"
:model-value="rewardPreviewPlaying"
v-if="isAndroidDevice && mainReward.sourceUrl"
:model-value="true"
:src="mainReward.sourceUrl"
mode="overlay"
mode="inline"
fit="contain"
:loop="true"
:mute="true"
:auto-close="false"
:show-close="true"
:show-status="false"
@update:modelValue="rewardPreviewPlaying = $event"
@close="rewardPreviewPlaying = false"
@error="handleRewardVapError"
/>
<img
v-if="!mainReward.sourceUrl || !rewardPreviewPlaying"
v-else
:src="mainReward.cover || ''"
alt=""
style="display: block; width: 90%; object-fit: contain"
@error="(e) => handleRewardImageError(e, mainReward.type)"
/>
<button
v-if="mainReward.sourceUrl && !rewardPreviewPlaying"
v-if="isIosDevice && mainReward.sourceUrl"
type="button"
class="reward-showcase-play-button"
@click.stop="playMainRewardPreview"
@click.stop="handleMainRewardPreviewClick"
>
<span class="reward-showcase-play-icon"></span>
</button>
@ -425,11 +426,7 @@ justify-content: flex-start;
class="reward-extra-scroll-wrapper"
:class="{ 'reward-extra-scroll-wrapper-rtl': isRtlLayout }"
>
<div
v-for="value in 2"
:key="value"
class="reward-extra-scroll-content"
>
<div v-for="value in 2" :key="value" class="reward-extra-scroll-content">
{{ getRewardDisplayText(reward) }}
</div>
</div>
@ -511,11 +508,16 @@ justify-content: flex-start;
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { setDocumentDirection } from '@/locales/i18n'
import { connectToApp } from '@/utils/appConnector.js'
import { getPngUrl } from '@/config/imagePaths.js'
import {
isAndroidDevice as detectAndroidDevice,
isIosDevice as detectIosDevice,
notifyAppPlayVap,
} from '@/utils/appBridge.js'
import { getMp4Url, getPngUrl } from '@/config/imagePaths.js'
import { preloadImages } from '@/utils/image/imagePreloader.js'
import { useLangStore } from '@/stores/lang'
import { showError } from '@/utils/toast.js'
@ -534,7 +536,9 @@ import { useThrottle } from '@/utils/useDebounce.js'
import BackgroundLayer from '@/components/BackgroundLayer.vue'
import itemCenter from '@/components/itemCenter.vue'
import maskLayer from '@/components/MaskLayer.vue'
import VapMp4Player from '@/components/VapMp4Player.vue'
const VapMp4Player = defineAsyncComponent(() => import('@/components/VapMp4Player.vue'))
const VapBg = defineAsyncComponent(() => import('./components/vapBg.vue'))
const isLoading = ref(true)
@ -547,10 +551,74 @@ const currentLangType = computed(() => {
const isRtlLayout = computed(() => {
return currentLangType.value === 'ar'
})
const DEVICE_SIMULATION_STORAGE_KEY = 'daily-recharge-device-simulation'
const DEVICE_SIMULATION_MODES = ['auto', 'android', 'ios']
const normalizeDeviceSimulationMode = (mode) => {
return DEVICE_SIMULATION_MODES.includes(mode) ? mode : 'auto'
}
const getDeviceSimulationQueryMode = () => {
if (typeof window === 'undefined') {
return ''
}
const searchMode = new URLSearchParams(window.location.search).get('dailyRechargeDevice')
if (searchMode) {
return searchMode
}
const hashQueryIndex = window.location.hash.indexOf('?')
if (hashQueryIndex === -1) {
return ''
}
return new URLSearchParams(window.location.hash.slice(hashQueryIndex + 1)).get(
'dailyRechargeDevice',
)
}
const getInitialDeviceSimulationMode = () => {
if (!import.meta.env.DEV || typeof window === 'undefined') {
return 'auto'
}
const queryMode = getDeviceSimulationQueryMode()
const storageMode = window.localStorage.getItem(DEVICE_SIMULATION_STORAGE_KEY)
return normalizeDeviceSimulationMode(queryMode || storageMode || 'auto')
}
const simulatedDeviceMode = ref(getInitialDeviceSimulationMode())
const isAndroidDevice = computed(() => {
if (import.meta.env.DEV && simulatedDeviceMode.value === 'android') {
return true
}
if (import.meta.env.DEV && simulatedDeviceMode.value === 'ios') {
return false
}
return detectAndroidDevice()
})
const isIosDevice = computed(() => {
if (import.meta.env.DEV && simulatedDeviceMode.value === 'ios') {
return true
}
if (import.meta.env.DEV && simulatedDeviceMode.value === 'android') {
return false
}
return detectIosDevice()
})
const setSimulatedDeviceMode = (mode = 'auto') => {
if (!import.meta.env.DEV) {
return
}
simulatedDeviceMode.value = normalizeDeviceSimulationMode(mode)
if (typeof window !== 'undefined') {
window.localStorage.setItem(DEVICE_SIMULATION_STORAGE_KEY, simulatedDeviceMode.value)
}
}
locale.value && setDocumentDirection(locale.value)
const imageUrl = (filename) => getPngUrl('Activities/DailyRecharge/', filename)
const mp4Url = (filename) => getMp4Url('Activities/DailyRecharge/', filename)
const bgVapUrl = mp4Url('bg')
const CHEST_PROGRESS_GAP_VW = 2
const CHEST_PROGRESS_ITEM_WIDTH_VW = 14
const CHEST_PROGRESS_FIRST_CENTER_VW = CHEST_PROGRESS_GAP_VW + CHEST_PROGRESS_ITEM_WIDTH_VW / 2
@ -713,8 +781,8 @@ const rewardGridList = computed(() => {
const mainRewardRenderKey = computed(() => {
return mainReward.value ? getRewardRenderKey(mainReward.value, 0) : 'empty-main-reward'
})
const rewardPreviewPlaying = ref(false)
const rewardVapPreloadElements = new Map()
const criticalVapPreloadElements = new Map()
let rewardVapPreloadTimer = null
let rewardVapPreloadIdleCallbackId = null
let rewardVapPreloadStarted = false
@ -749,7 +817,7 @@ const getRewardVapUrls = (rewardTopList) => {
}
const preloadRewardVap = (sourceUrl) => {
if (!sourceUrl || rewardVapPreloadElements.has(sourceUrl)) {
if (!isAndroidDevice.value || !sourceUrl || rewardVapPreloadElements.has(sourceUrl)) {
return
}
@ -785,8 +853,39 @@ const preloadRewardVaps = (rewardTopList) => {
getRewardVapUrls(rewardTopList).forEach(preloadRewardVap)
}
const preloadCriticalVap = (sourceUrl) => {
if (!isAndroidDevice.value || !sourceUrl) {
return Promise.resolve()
}
const resolvedSourceUrl = resolveProtectedAssetUrl(sourceUrl)
const configPromise = preloadVapConfig(resolvedSourceUrl)
if (criticalVapPreloadElements.has(resolvedSourceUrl) || typeof document === 'undefined') {
return configPromise
}
const preloadVideo = document.createElement('video')
preloadVideo.preload = 'auto'
preloadVideo.muted = true
preloadVideo.playsInline = true
preloadVideo.src = resolvedSourceUrl
preloadVideo.style.position = 'fixed'
preloadVideo.style.left = '-9999px'
preloadVideo.style.top = '-9999px'
preloadVideo.style.width = '1px'
preloadVideo.style.height = '1px'
preloadVideo.style.opacity = '0'
preloadVideo.style.pointerEvents = 'none'
document.body.appendChild(preloadVideo)
preloadVideo.load()
criticalVapPreloadElements.set(resolvedSourceUrl, preloadVideo)
return configPromise
}
const startRewardVapPreload = () => {
if (rewardVapPreloadStarted || !activityRewards.value.length) {
if (!isAndroidDevice.value || rewardVapPreloadStarted || !activityRewards.value.length) {
return
}
@ -795,7 +894,7 @@ const startRewardVapPreload = () => {
}
const queueRewardVapPreload = () => {
if (rewardVapPreloadStarted || typeof window === 'undefined') {
if (!isAndroidDevice.value || rewardVapPreloadStarted || typeof window === 'undefined') {
return
}
@ -912,22 +1011,19 @@ const openPopup = (type) => {
const handleRewardVapError = (error) => {
console.error('Reward VAP playback failed:', error)
rewardPreviewPlaying.value = false
showError(error?.message || t('active_game_vap_playback_failed'))
}
const playMainRewardPreview = () => {
const handleMainRewardPreviewClick = () => {
if (!mainReward.value?.sourceUrl) {
return
}
rewardPreviewPlaying.value = true
if (isIosDevice.value) {
notifyAppPlayVap(mainReward.value.sourceUrl)
}
}
watch(mainRewardRenderKey, () => {
rewardPreviewPlaying.value = false
})
const claimTask1Reward = useThrottle(async () => {
if (!canClaimTask1Reward.value) {
return
@ -1017,9 +1113,7 @@ const preloadCriticalImages = async () => {
imageUrl(getImgName('ruleTitle')),
]
await Promise.all([
preloadImages(criticalImages),
])
await Promise.all([preloadImages(criticalImages), preloadCriticalVap(bgVapUrl)])
}
const completePreloading = async () => {
@ -1068,10 +1162,18 @@ const connectToAppHandler = async () => {
}
onMounted(() => {
if (import.meta.env.DEV && typeof window !== 'undefined') {
window.__setDailyRechargeDevice = setSimulatedDeviceMode
window.__dailyRechargeDeviceMode = simulatedDeviceMode
}
connectToAppHandler()
})
onBeforeUnmount(() => {
if (import.meta.env.DEV && typeof window !== 'undefined') {
delete window.__setDailyRechargeDevice
delete window.__dailyRechargeDeviceMode
}
if (connectToAppFallbackTimer !== null && typeof window !== 'undefined') {
window.clearTimeout(connectToAppFallbackTimer)
connectToAppFallbackTimer = null
@ -1095,6 +1197,13 @@ onBeforeUnmount(() => {
video.parentNode && video.parentNode.removeChild(video)
})
rewardVapPreloadElements.clear()
criticalVapPreloadElements.forEach((video) => {
video.pause()
video.removeAttribute('src')
video.load()
video.parentNode && video.parentNode.removeChild(video)
})
criticalVapPreloadElements.clear()
})
</script>
@ -1144,9 +1253,14 @@ onBeforeUnmount(() => {
top: 0;
left: 0;
z-index: 1;
object-fit: cover;
pointer-events: none;
}
.vap-background-vap-layer {
height: 150vw;
}
.page-background-layer {
position: absolute;
top: 120vw;