topfans/frontend/pages/support-activity/index.vue
zheng020 0a09048a85 feat: 添加 ActivityRankingModal 活动榜单弹窗组件
- 新建 ActivityRankingModal.vue 组件,实现单活动排名展示
- TOP3 展示使用 TOP3Card 组件
- 排名列表使用 RankingListItem 组件
- 支持下拉刷新和滚动加载更多
- 当前用户栏固定底部显示
- ThemeBanner 添加 @tap 事件触发弹窗
- index.vue 集成 ActivityRankingModal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:06:19 +08:00

773 lines
18 KiB
Vue
Raw 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"
@tap="openRankingModal"
/>
<!-- 实时贡献列表 -->
<ContributionList
v-if="activityId && !isLoading"
:activity-id="activityId"
class="contribution-list-wrapper"
/>
<!-- 舞台区域 -->
<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"
/> -->
<!-- 底部操作栏触发按钮 -->
<view class="action-bar-trigger" @click="toggleActionBar">
<view class="trigger-btn">
<image src="/static/rank/activity-support-icon/lihe.png" class="trigger-icon" mode="aspectFit" />
</view>
<text class="trigger-text">我要贡献</text>
</view>
<!-- 底部操作栏弹出框 -->
<view v-if="actionBarVisible" class="action-bar-popup">
<!-- 遮罩层 -->
<view class="action-bar-mask" @click="toggleActionBar"></view>
<!-- 弹出内容 -->
<view class="action-bar-content">
<ActionBar
v-if="activityId && config"
:activity-id="activityId"
:action-items="config.actionItems"
@contribute="handleContribute"
/>
</view>
</view>
<!-- 底部导航 -->
<!-- <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>
<!-- 排行榜弹窗 -->
<ActivityRankingModal
v-model:visible="rankingModalVisible"
:activity-id="activityId"
:activity-title="currentActivityTitle"
@visit="handleVisitUser"
@view-profile="handleViewUserProfile"
@view-artwork="handleViewArtwork"
/>
</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 { 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 ContributionList from './components/ContributionList.vue'
import StageArea from './components/StageArea.vue'
import FloatingBubbles from './components/FloatingBubbles.vue'
import ActivityRankingModal from './components/ActivityRankingModal.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)
// ActionBar 弹出框状态
const actionBarVisible = ref(false)
// 排行榜弹窗状态
const rankingModalVisible = ref(false)
const currentActivityTitle = ref('')
function toggleActionBar() {
actionBarVisible.value = !actionBarVisible.value
}
// 打开排行榜弹窗
function openRankingModal() {
currentActivityTitle.value = config.value?.title || '活动排名'
rankingModalVisible.value = true
}
// 处理拜访用户
function handleVisitUser(data) {
console.log('拜访用户:', data)
}
// 处理查看用户资料
function handleViewUserProfile(data) {
console.log('查看用户资料:', data)
}
// 处理查看作品
function handleViewArtwork(data) {
console.log('查看作品:', data)
}
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/ai-dazi/index', // 搭子
'/pages/starbook/index', // 星册
'/pages/castlove/mall', // 铸爱
'/pages/starcity/index', // 星城
'/pages/square/square' // 广场
]
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
async function convertActivityImages(activityData) {
try {
// 并发转换所有图片
const [bannerImage, coverImage, stageBackground] = await Promise.all([
Promise.resolve(activityData.banner_image),
Promise.resolve(activityData.cover_image),
Promise.resolve(activityData.current_stage_background)
])
// 转换道具图标
const actionItems = activityData.items || []
const convertedItems = actionItems.map(item => ({
...item,
icon: 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);
}
}
/* ActionBar 弹出框样式 */
.action-bar-trigger {
width: 180rpx;
height: 180rpx;
position: fixed;
bottom: 180rpx;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
background-image: url('@/static/rank/activity-support-icon/beijingkuang1.png');
background-size: cover;
background-position: center;
}
.trigger-btn {
width: 120rpx;
height: 120rpx;
border-radius: 24rpx;
display: flex;
justify-content: center;
align-items: center;
}
.trigger-icon {
width: 80rpx;
height: 80rpx;
}
.trigger-text {
font-size: 24rpx;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
.action-bar-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 998;
}
.action-bar-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.action-bar-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* 实时贡献列表样式 */
.contribution-list-wrapper {
width: 100%;
padding: 0 24rpx;
}
</style>