topfans/frontend/pages/support-activity/index.vue
2026-04-07 23:08:49 +08:00

657 lines
16 KiB
Vue
Raw Permalink 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.

<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?tab=0', // 广场
'/pages/square/square?tab=1', // 星册
'/pages/square/square?tab=2', // 铸爱
'/pages/square/square?tab=3', // 星城
'/pages/square/square?tab=4' // 好友
]
if (newTab >= 0 && newTab < tabRoutes.length) {
uni.redirectTo({
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>