657 lines
16 KiB
Vue
657 lines
16 KiB
Vue
<template>
|
||
<view class="activity-container" :style="containerStyle">
|
||
<!-- 顶部导航 -->
|
||
<Header
|
||
:show-back="true"
|
||
backIconColor="#e6e6e6"
|
||
:showGuideIcon="false" :showTaskIcon="false" :showStarActivityIcon="false"
|
||
/>
|
||
|
||
<!-- 状态栏占位 -->
|
||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||
|
||
<!-- 主题横幅 -->
|
||
<ThemeBanner
|
||
v-if="config"
|
||
:title="config.title"
|
||
:banner-image="config.bannerImage"
|
||
:current="progressData.current"
|
||
:target="progressData.target"
|
||
:is-stale-data="isStaleData"
|
||
/>
|
||
|
||
<!-- 舞台区域 -->
|
||
<StageArea
|
||
v-if="config"
|
||
:activity-type="activityType"
|
||
:config="config"
|
||
:is-completed="isCompleted"
|
||
@animation-complete="onAnimationComplete"
|
||
/>
|
||
|
||
<!-- 悬浮气泡 -->
|
||
<FloatingBubbles
|
||
v-show="config"
|
||
:bubble-texts="config?.bubbleTexts"
|
||
:is-active="isPageActive"
|
||
:current="progressData.current"
|
||
:target="progressData.target"
|
||
/>
|
||
|
||
<!-- 底部操作栏 -->
|
||
<ActionBar
|
||
v-if="activityId && config"
|
||
:activity-id="activityId"
|
||
:action-items="config.actionItems"
|
||
@contribute="handleContribute"
|
||
/>
|
||
|
||
<!-- 底部导航 -->
|
||
<BottomNav
|
||
:activeTab="activeTab"
|
||
:isExpanded="navExpanded"
|
||
@update:activeTab="handleTabChange"
|
||
@update:isExpanded="navExpanded = $event"
|
||
/>
|
||
|
||
<!-- 蒙层 - 导航栏展开时显示 -->
|
||
<view v-if="navExpanded" class="nav-mask" @click="navExpanded = false"></view>
|
||
|
||
<!-- 加载状态 -->
|
||
<view v-if="isLoading" class="loading-overlay">
|
||
<view class="loading-spinner"></view>
|
||
<text class="loading-text">加载中...</text>
|
||
<view v-if="loadingProgress > 0" class="loading-progress">
|
||
<view class="progress-bar-container">
|
||
<view class="progress-bar-fill" :style="{ width: loadingProgress + '%' }"></view>
|
||
</view>
|
||
<text class="progress-text">{{ Math.round(loadingProgress) }}%</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 错误状态 -->
|
||
<view v-if="errorMessage" class="error-overlay">
|
||
<view class="error-content">
|
||
<text class="error-title">加载失败</text>
|
||
<text class="error-message">{{ errorMessage }}</text>
|
||
<button class="retry-button" @click="retryLoad">重试</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
import { onLoad, onUnload, onShow, onHide } from '@dcloudio/uni-app'
|
||
import {
|
||
fetchActivityDetail,
|
||
fetchActivityProgress,
|
||
} from '@/utils/activity-config'
|
||
import { getOssPresignedUrlApi } from '@/utils/api'
|
||
import { getProgressManager, releaseProgressManager, cleanupProgressManager } from '@/utils/progress-manager'
|
||
import screenCache from '@/utils/screen-cache'
|
||
import performanceMonitor from '@/utils/performance-monitor'
|
||
import Header from '../components/Header.vue';
|
||
import BottomNav from '../components/BottomNav.vue';
|
||
import ThemeBanner from './components/ThemeBanner.vue'
|
||
import StageArea from './components/StageArea.vue'
|
||
import FloatingBubbles from './components/FloatingBubbles.vue'
|
||
import ActionBar from './components/ActionBar.vue'
|
||
|
||
const activityType = ref('birthday')
|
||
const activityId = ref('')
|
||
const config = ref(null)
|
||
const progressData = ref({ current: 0, target: 1000 })
|
||
const isLoading = ref(true)
|
||
const isPageActive = ref(true)
|
||
const statusBarHeight = ref(0)
|
||
const errorMessage = ref('')
|
||
const retryCount = ref(0)
|
||
const loadingProgress = ref(0) // 资源加载进度 (0-100)
|
||
const isStaleData = ref(false) // 数据是否过时
|
||
|
||
// 底部导航状态
|
||
const activeTab = ref(0)
|
||
const navExpanded = ref(false)
|
||
|
||
let progressManager = null
|
||
|
||
const isCompleted = computed(() => progressData.value.current >= progressData.value.target)
|
||
const containerStyle = computed(() => {
|
||
if (!config.value) {
|
||
return {
|
||
backgroundImage: 'none',
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
backgroundRepeat: 'no-repeat'
|
||
}
|
||
}
|
||
|
||
// 根据进度完成状态选择背景图
|
||
const bgImage = isCompleted.value ? config.value.mainAssetActive : config.value.bgImage
|
||
|
||
return {
|
||
backgroundImage: `url(${bgImage})`,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
backgroundRepeat: 'no-repeat',
|
||
transition: 'background-image 0.5s ease-in-out' // 添加平滑过渡效果
|
||
}
|
||
})
|
||
|
||
const MAX_RETRY_COUNT = 3
|
||
|
||
// 处理 tab 切换
|
||
function handleTabChange(newTab) {
|
||
activeTab.value = newTab
|
||
navExpanded.value = false
|
||
|
||
// 根据tab索引跳转到对应页面
|
||
const tabRoutes = [
|
||
'/pages/square/square', // 广场
|
||
'/pages/starbook/index', // 星册
|
||
'/pages/castlove/mall', // 铸爱
|
||
'/pages/starcity/index', // 星城
|
||
'/pages/friends/index' // 好友
|
||
]
|
||
|
||
if (newTab >= 0 && newTab < tabRoutes.length) {
|
||
uni.navigateTo({
|
||
url: tabRoutes[newTab],
|
||
fail: (err) => {
|
||
console.error('页面跳转失败:', err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
function initProgressManager() {
|
||
if (!activityId.value) return
|
||
|
||
// 先释放旧实例的引用,引用计数归零时会自动销毁
|
||
if (progressManager) {
|
||
releaseProgressManager(activityId.value)
|
||
}
|
||
|
||
progressManager = getProgressManager(activityId.value)
|
||
|
||
progressManager.addListener((data) => {
|
||
progressData.value = {
|
||
current: data.current || 0,
|
||
target: data.target || 1000
|
||
}
|
||
|
||
// 更新过时数据标志
|
||
isStaleData.value = data.isStale || false
|
||
|
||
if (data.isFromCache) {
|
||
// 如果数据过时,显示一次性提示
|
||
if (data.isStale && !data.hasShownStaleToast) {
|
||
uni.showToast({
|
||
title: '正在使用缓存数据',
|
||
icon: 'none',
|
||
duration: 1500
|
||
})
|
||
data.hasShownStaleToast = true
|
||
}
|
||
} else {
|
||
// 收到新数据,清除过时标志
|
||
isStaleData.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
function startProgressSync() {
|
||
if (progressManager && isPageActive.value) {
|
||
progressManager.startPolling()
|
||
}
|
||
}
|
||
|
||
function stopProgressSync() {
|
||
if (progressManager) {
|
||
progressManager.stopPolling()
|
||
}
|
||
}
|
||
|
||
async function handleContribute(itemType) {
|
||
if (progressManager) {
|
||
await progressManager.refresh()
|
||
}
|
||
}
|
||
|
||
function onAnimationComplete() {
|
||
}
|
||
|
||
// 预签名 URL 缓存,同一路径只请求一次
|
||
const presignedUrlCache = new Map()
|
||
|
||
/**
|
||
* 转换图片路径为OSS预签名URL
|
||
*/
|
||
async function convertImageToPresignedUrl(imagePath) {
|
||
if (!imagePath) return null
|
||
if (imagePath === '') return ''
|
||
|
||
if (presignedUrlCache.has(imagePath)) {
|
||
return presignedUrlCache.get(imagePath)
|
||
}
|
||
|
||
try {
|
||
const response = await getOssPresignedUrlApi(imagePath, 3600, 'avatar')
|
||
const url = response.data?.url || imagePath
|
||
presignedUrlCache.set(imagePath, url)
|
||
return url
|
||
} catch (error) {
|
||
console.error('图片URL转换失败:', imagePath, error)
|
||
return imagePath
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量转换图片路径
|
||
*/
|
||
async function convertActivityImages(activityData) {
|
||
try {
|
||
// 并发转换所有图片
|
||
const [bannerImage, coverImage, stageBackground] = await Promise.all([
|
||
convertImageToPresignedUrl(activityData.banner_image),
|
||
convertImageToPresignedUrl(activityData.cover_image),
|
||
convertImageToPresignedUrl(activityData.current_stage_background)
|
||
])
|
||
|
||
// 转换道具图标
|
||
const actionItems = activityData.items || []
|
||
const convertedItems = await Promise.all(
|
||
actionItems.map(async (item) => ({
|
||
...item,
|
||
icon: await convertImageToPresignedUrl(item.icon)
|
||
}))
|
||
)
|
||
|
||
return {
|
||
bannerImage,
|
||
coverImage,
|
||
stageBackground,
|
||
actionItems: convertedItems
|
||
}
|
||
} catch (error) {
|
||
console.error('批量转换图片失败:', error)
|
||
return {
|
||
bannerImage: activityData.banner_image,
|
||
coverImage: activityData.cover_image,
|
||
stageBackground: activityData.current_stage_background,
|
||
actionItems: activityData.items || []
|
||
}
|
||
}
|
||
}
|
||
|
||
async function initializePage(options = {}) {
|
||
try {
|
||
isLoading.value = true
|
||
errorMessage.value = ''
|
||
|
||
// 启动性能监控
|
||
performanceMonitor.start()
|
||
|
||
// 初始化屏幕缓存
|
||
screenCache.init()
|
||
statusBarHeight.value = screenCache.getStatusBarHeight()
|
||
|
||
// 从URL参数获取活动ID(必需)
|
||
activityId.value = options.id
|
||
|
||
if (!activityId.value) {
|
||
throw new Error('缺少活动ID参数')
|
||
}
|
||
|
||
// 从后端获取活动详情
|
||
const activityData = await fetchActivityDetail(activityId.value)
|
||
|
||
if (!activityData) {
|
||
throw new Error('无法加载活动数据')
|
||
}
|
||
|
||
// 设置活动类型和配置
|
||
activityType.value = activityData.activity_type
|
||
|
||
// 转换图片URL
|
||
const convertedImages = await convertActivityImages(activityData)
|
||
|
||
// 转换后端数据为页面配置格式
|
||
config.value = {
|
||
title: activityData.title,
|
||
bannerImage: convertedImages.bannerImage,
|
||
bgImage: convertedImages.stageBackground || convertedImages.coverImage,
|
||
mainAssetIdle: convertedImages.coverImage,
|
||
mainAssetActive: convertedImages.stageBackground,
|
||
bubbleTexts: activityData.bubble_texts || ['加油!', '一起努力!'],
|
||
actionItems: convertedImages.actionItems
|
||
}
|
||
|
||
// 设置进度数据
|
||
progressData.value = {
|
||
current: activityData.current_progress || 0,
|
||
target: activityData.target_progress || 1000
|
||
}
|
||
|
||
// 优化:只预加载首屏关键资源
|
||
await preloadCriticalAssets()
|
||
|
||
// 记录首屏加载时间
|
||
performanceMonitor.recordFirstScreenLoad()
|
||
|
||
initProgressManager()
|
||
startProgressSync()
|
||
|
||
isLoading.value = false
|
||
|
||
// 在后台继续加载非关键资源
|
||
preloadNonCriticalAssets()
|
||
|
||
} catch (error) {
|
||
console.error('页面初始化失败:', error)
|
||
errorMessage.value = error.message || '页面加载失败'
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 预加载首屏关键资源
|
||
*/
|
||
async function preloadCriticalAssets() {
|
||
if (!config.value) return
|
||
|
||
try {
|
||
// 只加载首屏必需的图片:背景图和横幅图
|
||
const criticalImages = [
|
||
config.value.bgImage,
|
||
config.value.bannerImage
|
||
]
|
||
|
||
const loadPromises = criticalImages.map(src => {
|
||
return new Promise((resolve) => {
|
||
const startTime = Date.now()
|
||
let isResolved = false
|
||
const img = new Image()
|
||
|
||
// 超时定时器
|
||
const timeoutId = setTimeout(() => {
|
||
if (!isResolved) {
|
||
isResolved = true
|
||
performanceMonitor.recordImageLoad(false, 3000)
|
||
resolve()
|
||
}
|
||
}, 3000)
|
||
|
||
img.onload = () => {
|
||
if (!isResolved) {
|
||
isResolved = true
|
||
clearTimeout(timeoutId)
|
||
const loadTime = Date.now() - startTime
|
||
performanceMonitor.recordImageLoad(true, loadTime)
|
||
resolve()
|
||
}
|
||
}
|
||
|
||
img.onerror = () => {
|
||
if (!isResolved) {
|
||
isResolved = true
|
||
clearTimeout(timeoutId)
|
||
const loadTime = Date.now() - startTime
|
||
performanceMonitor.recordImageLoad(false, loadTime)
|
||
resolve() // 即使失败也继续
|
||
}
|
||
}
|
||
|
||
img.src = src
|
||
})
|
||
})
|
||
|
||
await Promise.all(loadPromises)
|
||
|
||
} catch (error) {
|
||
console.error('[页面] 关键资源加载异常:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 预加载非关键资源(后台加载)
|
||
*/
|
||
function preloadNonCriticalAssets() {
|
||
if (!config.value) return
|
||
|
||
setTimeout(() => {
|
||
// 加载其他资源:舞台图标、道具图标等
|
||
const nonCriticalImages = [
|
||
config.value.mainAssetIdle,
|
||
config.value.mainAssetActive,
|
||
...(config.value.actionItems?.map(item => item.icon) || [])
|
||
]
|
||
|
||
nonCriticalImages.forEach(src => {
|
||
if (src) {
|
||
const startTime = Date.now()
|
||
const img = new Image()
|
||
|
||
img.onload = () => {
|
||
const loadTime = Date.now() - startTime
|
||
performanceMonitor.recordImageLoad(true, loadTime)
|
||
}
|
||
|
||
img.onerror = () => {
|
||
const loadTime = Date.now() - startTime
|
||
performanceMonitor.recordImageLoad(false, loadTime)
|
||
}
|
||
|
||
img.src = src
|
||
}
|
||
})
|
||
}, 1000) // 延迟1秒后开始加载
|
||
}
|
||
|
||
async function retryLoad() {
|
||
if (retryCount.value >= MAX_RETRY_COUNT) {
|
||
uni.showToast({
|
||
title: '重试次数已达上限',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
retryCount.value++
|
||
|
||
const pages = getCurrentPages()
|
||
const currentPage = pages[pages.length - 1]
|
||
const options = currentPage.options || {}
|
||
|
||
await initializePage(options)
|
||
}
|
||
|
||
onLoad(async (options) => {
|
||
await initializePage(options)
|
||
})
|
||
|
||
onShow(() => {
|
||
isPageActive.value = true
|
||
|
||
if (!isLoading.value && !errorMessage.value && progressManager) {
|
||
// 使用 resumePolling 而不是 startPolling,避免重复启动
|
||
progressManager.resumePolling()
|
||
}
|
||
})
|
||
|
||
onHide(() => {
|
||
isPageActive.value = false
|
||
|
||
// 使用 pausePolling 暂停轮询,而不是完全停止
|
||
if (progressManager) {
|
||
progressManager.pausePolling()
|
||
}
|
||
})
|
||
|
||
onUnload(() => {
|
||
stopProgressSync()
|
||
|
||
if (activityId.value) {
|
||
releaseProgressManager(activityId.value)
|
||
}
|
||
|
||
// 停止性能监控(会自动打印报告)
|
||
performanceMonitor.stop()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.activity-container {
|
||
width: 100%;
|
||
min-height: 100vh;
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.status-bar {
|
||
width: 100%;
|
||
background: transparent;
|
||
}
|
||
|
||
.loading-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 9999;
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
border: 6rpx solid rgba(255, 255, 255, 0.3);
|
||
border-top-color: #fff;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.loading-text {
|
||
color: #fff;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.loading-progress {
|
||
margin-top: 40rpx;
|
||
width: 400rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.progress-bar-container {
|
||
width: 100%;
|
||
height: 8rpx;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 4rpx;
|
||
overflow: hidden;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.progress-bar-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #ff6b9d, #ffa06b);
|
||
border-radius: 4rpx;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.progress-text {
|
||
color: rgba(255, 255, 255, 0.8);
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.error-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 9999;
|
||
}
|
||
|
||
.error-content {
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
padding: 60rpx 40rpx;
|
||
margin: 40rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
text-align: center;
|
||
}
|
||
|
||
.error-title {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.error-message {
|
||
font-size: 28rpx;
|
||
color: #666;
|
||
margin-bottom: 40rpx;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.retry-button {
|
||
background: linear-gradient(135deg, #ff6b9d, #ffa06b);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 50rpx;
|
||
padding: 20rpx 60rpx;
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.retry-button:active {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* 导航栏展开时的蒙层 */
|
||
.nav-mask {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
z-index: 999;
|
||
animation: fadeIn 0.3s ease-out;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from {
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
</style> |