topfans/docs/superpowers/plans/2026-05-14-activity-ranking-modal-implementation-plan.md
2026-05-14 15:59:56 +08:00

716 lines
18 KiB
Markdown
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.

# ActivityRankingModal 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 创建 `ActivityRankingModal.vue` 弹窗组件,从 `ThemeBanner` 点击触发,显示单活动的排名数据
**Architecture:** 复用 `RankingModal.vue` 的现有样式主题和数据转换逻辑,创建专用于活动排名的简化弹窗组件
**Tech Stack:** Vue 3 Composition API, uni-app, 复用现有 RankingModal/TOP3Card/RankingListItem 组件
---
## 文件结构
```
frontend/pages/support-activity/
├── components/
│ ├── ActivityRankingModal.vue ← 新建
│ ├── ThemeBanner.vue ← 修改:添加点击事件触发弹窗
│ ├── TOP3Card.vue ← 复用(来自 pages/components/
│ └── RankingListItem.vue ← 复用(来自 pages/components/
└── index.vue ← 修改:引入并使用 ActivityRankingModal
frontend/utils/api.js ← 无需修改getActivityRankingApi 已存在)
docs/superpowers/specs/2026-05-14-activity-ranking-modal-design.md ← 已存在
```
---
## 任务分解
### Task 1: 创建 ActivityRankingModal.vue 组件
**文件:**
- 创建: `frontend/pages/support-activity/components/ActivityRankingModal.vue`
- [ ] **Step 1: 创建基础模板和样式结构**
```vue
<template>
<view v-if="visible" class="modal-wrapper">
<transition name="fade">
<view v-if="visible" class="modal-mask" @tap="handleClose"></view>
</transition>
<transition name="slide-up">
<view v-if="visible" class="modal-container" @tap.stop>
<!-- 背景 -->
<view class="modal-background"></view>
<!-- 内容区域 -->
<view class="modal-content">
<!-- 头部 -->
<view class="header-section">
<text class="activity-title">{{ activityTitle }}</text>
<view class="close-btn" @tap="handleClose">
<text class="close-icon">×</text>
</view>
</view>
<!-- 滚动内容区 -->
<scroll-view
class="scrollable-content"
scroll-y="true"
:show-scrollbar="false"
:refresher-enabled="true"
:refresher-triggered="isRefreshing"
@refresherrefresh="handleRefresh"
@scrolltolower="handleScrollToLower"
:lower-threshold="100"
>
<!-- 加载中 -->
<view v-if="isLoading && !isRefreshing" class="loading-container">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="errorMessage" class="error-container">
<text class="error-text">{{ errorMessage }}</text>
<button class="retry-button" @tap="loadRankingData">重试</button>
</view>
<!-- TOP3 区域 -->
<view v-else-if="top3Users.length > 0" class="top3-section">
<TOP3Card
v-for="user in top3Users"
:key="user.userId"
:rank="user.rank"
:avatar="user.avatar"
:nickname="user.nickname"
:popularityScore="user.popularityScore"
:artworkImage="user.artworkImage"
:userId="user.userId"
:isCurrentUser="isCurrentUser(user.userId)"
@visit="handleVisit"
@view-profile="handleViewProfile"
/>
</view>
<!-- 空状态 -->
<view v-if="!isLoading && !errorMessage && rankingData.length === 0" class="empty-data">
<text class="empty-text">暂无排名数据</text>
</view>
<!-- 排名列表 -->
<view v-if="listUsers.length > 0" class="ranking-list-section">
<RankingListItem
v-for="item in listUsers"
:key="item.userId"
:rank="item.rank"
:userId="item.userId"
:avatar="item.avatar"
:nickname="item.nickname"
:popularityScore="item.popularityScore"
:artworkImage="item.artworkImage"
:artworkId="item.artworkId"
:showVisitButton="!isCurrentUser(item.userId)"
:isCurrentUser="isCurrentUser(item.userId)"
@visit="handleVisit"
@view-profile="handleViewProfile"
@artwork-click="handleArtworkClick"
/>
</view>
<!-- 加载更多 -->
<view v-if="isLoadingMore" class="loading-more-container">
<view class="loading-spinner-small"></view>
<text class="loading-more-text">加载中...</text>
</view>
<!-- 底部占位 -->
<view class="bottom-spacer"></view>
</scroll-view>
<!-- 当前用户栏 -->
<view v-if="!isLoading" class="current-user-bar">
<view class="current-user-content">
<image
class="current-user-avatar"
:src="currentUserInfo.avatar"
mode="aspectFill"
@error="handleCurrentUserAvatarError"
/>
<view class="current-user-info">
<view class="current-user-score">
<image class="flame-icon" src="/static/rank/spark.png" mode="aspectFit"></image>
<text class="score-text">{{ formatPopularityScore(currentUserInfo.popularityScore) }}</text>
</view>
</view>
<view class="current-user-rank">
<text class="rank-text">{{ formatCurrentUserRank(currentUserInfo.rank) }}</text>
</view>
</view>
</view>
</view>
</view>
</transition>
</view>
</template>
```
- [ ] **Step 2: 实现 script 部分**
```vue
<script setup>
import { ref, computed, watch } from 'vue'
import { getActivityRankingApi } from '@/utils/api.js'
import TOP3Card from '../../components/TOP3Card.vue'
import RankingListItem from '../../components/RankingListItem.vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
activityId: {
type: [String, Number],
required: true
},
starId: {
type: [String, Number],
default: null
},
activityTitle: {
type: String,
default: '活动排名'
},
currentUser: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:visible', 'visit', 'view-profile', 'view-artwork'])
// 状态管理
const rankingData = ref([])
const isLoading = ref(false)
const isRefreshing = ref(false)
const isLoadingMore = ref(false)
const hasNoMoreData = ref(false)
const errorMessage = ref(null)
const currentUserInfo = ref({
userId: 'currentUser',
avatar: '/static/avatar/1.jpeg',
nickname: '我',
popularityScore: 0,
rank: null
})
// 分页
const pageInfo = ref({ currentPage: 1, hasMore: true })
const PAGE_SIZE = 10
// 计算属性TOP3 用户
const top3Users = computed(() => {
return rankingData.value
.filter(user => user.rank >= 1 && user.rank <= 3)
.sort((a, b) => a.rank - b.rank)
})
// 计算属性列表用户第4名及以后
const listUsers = computed(() => {
return rankingData.value
.filter(user => user.rank >= 4)
.sort((a, b) => a.rank - b.rank)
})
// 数据转换函数(复用 RankingModal 逻辑)
const transformActivityRankingData = async (apiResponse) => {
if (!apiResponse || !apiResponse.data) return []
const { items } = apiResponse.data
if (!Array.isArray(items)) return []
return items.map(item => ({
rank: item.rank,
userId: String(item.user_id),
avatar: item.avatar_url || '/static/avatar/1.jpeg',
nickname: item.nickname || '未知用户',
popularityScore: item.total_contribution || 0,
artworkImage: '',
artworkId: null
}))
}
// 转换当前用户数据
const transformMyActivityContribution = async (myContribution) => {
return {
userId: 'currentUser',
avatar: myContribution?.avatar_url || '/static/avatar/1.jpeg',
popularityScore: myContribution?.total_contribution || 0,
rank: myContribution?.rank > 0 ? myContribution.rank : null
}
}
// 加载排名数据
const loadRankingData = async (page = 1, isRefreshAction = false) => {
try {
if (page === 1 && !isRefreshAction) {
isLoading.value = true
} else if (page > 1) {
isLoadingMore.value = true
}
errorMessage.value = null
const starId = props.starId || uni.getStorageSync('star_id')
const apiResponse = await getActivityRankingApi(props.activityId, starId, page, PAGE_SIZE)
if (apiResponse && apiResponse.code === 200 && apiResponse.data) {
const transformedData = await transformActivityRankingData(apiResponse)
if (page === 1) {
rankingData.value = transformedData
pageInfo.value = { currentPage: 1, hasMore: transformedData.length >= PAGE_SIZE }
} else {
rankingData.value.push(...transformedData)
pageInfo.value.currentPage = page
pageInfo.value.hasMore = transformedData.length >= PAGE_SIZE
}
hasNoMoreData.value = !pageInfo.value.hasMore
// 处理当前用户数据
if (page === 1 && apiResponse.data.my_contribution) {
currentUserInfo.value = await transformMyActivityContribution(apiResponse.data.my_contribution)
}
return true
} else {
throw new Error(apiResponse?.message || '获取排名失败')
}
} catch (error) {
console.error('Failed to load ranking:', error)
errorMessage.value = error.message || '加载失败'
return false
} finally {
if (page === 1 && !isRefreshAction) {
isLoading.value = false
} else if (page > 1) {
isLoadingMore.value = false
}
}
}
// 刷新
const handleRefresh = async () => {
if (isRefreshing.value) return
isRefreshing.value = true
const success = await loadRankingData(1, true)
if (success) {
uni.showToast({ title: '刷新成功', icon: 'success', duration: 1500 })
} else {
uni.showToast({ title: '刷新失败', icon: 'none', duration: 2000 })
}
setTimeout(() => { isRefreshing.value = false }, 500)
}
// 滚动加载更多
const handleScrollToLower = async () => {
if (isLoadingMore.value || hasNoMoreData.value) return
isLoadingMore.value = true
await loadRankingData(pageInfo.value.currentPage + 1)
isLoadingMore.value = false
}
// 工具函数
const formatPopularityScore = (score) => {
if (typeof score !== 'number' || isNaN(score) || score < 0) return '0'
if (score >= 1000000) return (score / 1000000).toFixed(1) + 'M'
if (score >= 1000) return (score / 1000).toFixed(1) + 'K'
return score.toString()
}
const formatCurrentUserRank = (rank) => {
if (!rank || rank <= 0) return '未上榜'
return `第${rank}名`
}
const isCurrentUser = (userId) => userId === currentUserInfo.value.userId
const handleCurrentUserAvatarError = (e) => {
e.target.src = '/static/avatar/1.jpeg'
}
// 事件处理
const handleClose = () => emit('update:visible', false)
const handleVisit = (userId, nickname) => emit('visit', { userId, nickname })
const handleViewProfile = (userId) => emit('view-profile', userId)
const handleArtworkClick = (data) => emit('view-artwork', data)
// 监听 visible 变化
watch(() => props.visible, async (newVisible) => {
if (newVisible && props.activityId) {
rankingData.value = []
pageInfo.value = { currentPage: 1, hasMore: true }
hasNoMoreData.value = false
await loadRankingData(1)
}
})
</script>
```
- [ ] **Step 3: 添加样式**
```vue
<style scoped>
/* 动画 */
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-up-enter-active, .slide-up-leave-active { transition: transform 0.3s ease; }
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%); }
.modal-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: flex-end;
justify-content: center;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
}
.modal-container {
position: relative;
width: 100%;
height: 80vh;
border-radius: 48rpx 48rpx 0 0;
overflow: hidden;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
}
.modal-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: url('/static/rank/paihangbang.png');
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
}
.modal-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
padding: 40rpx 20rpx 20rpx;
}
.header-section {
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 0 20rpx 30rpx;
}
.activity-title {
font-size: 36rpx;
font-weight: bold;
color: #fff;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
}
.close-btn {
position: absolute;
right: 20rpx;
top: 50%;
transform: translateY(-50%);
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.close-icon {
font-size: 40rpx;
color: #fff;
}
.scrollable-content {
flex: 1;
overflow-y: auto;
padding-bottom: 200rpx;
}
/* 复用 RankingModal 样式 */
.loading-container, .error-container, .empty-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400rpx;
padding: 40rpx;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text, .error-text, .empty-text {
margin-top: 20rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
.retry-button {
margin-top: 30rpx;
padding: 16rpx 40rpx;
background: linear-gradient(135deg, #ff6b9d, #ffa06b);
border-radius: 40rpx;
color: #fff;
font-size: 28rpx;
border: none;
}
.top3-section {
display: flex;
justify-content: space-between;
gap: 40rpx;
padding: 0 48rpx;
margin-bottom: 60rpx;
}
.ranking-list-section {
padding: 0 48rpx;
}
.loading-more-container {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx;
gap: 12rpx;
}
.loading-spinner-small {
width: 40rpx;
height: 40rpx;
border: 3rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-more-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
}
.bottom-spacer {
height: 20rpx;
}
.current-user-bar {
position: absolute;
bottom: 120rpx;
left: 40rpx;
right: 40rpx;
padding: 24rpx;
background: linear-gradient(135deg, rgba(255, 107, 157, 0.9), rgba(255, 177, 153, 0.9));
border-radius: 32rpx;
box-shadow: 0 -4rpx 16rpx rgba(255, 107, 157, 0.35);
border: 2rpx solid rgba(255, 255, 255, 0.3);
}
.current-user-content {
display: flex;
align-items: center;
z-index: 1;
}
.current-user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.9);
}
.current-user-info {
flex: 1;
margin-left: 34rpx;
}
.current-user-score {
display: flex;
align-items: center;
}
.flame-icon {
width: 44rpx;
height: 44rpx;
}
.score-text {
font-size: 26rpx;
margin-left: 8rpx;
color: #fff;
}
.current-user-rank {
padding: 16rpx 28rpx;
}
.rank-text {
font-size: 30rpx;
color: #fff;
font-weight: bold;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
```
---
### Task 2: 修改 ThemeBanner.vue 添加点击事件
**文件:**
- 修改: `frontend/pages/support-activity/components/ThemeBanner.vue:1-36`template 部分)
- [ ] **Step 1: 在 banner-content 上添加点击事件**
`<view class="banner-content">` 上添加 `@tap="handleBannerClick"`
- [ ] **Step 2: 添加 emit 定义**
在 script 部分添加:
```javascript
const emit = defineEmits(['tap'])
// 添加点击处理函数
const handleBannerClick = () => {
emit('tap')
}
```
---
### Task 3: 修改 support-activity/index.vue 引入和使用组件
**文件:**
- 修改: `frontend/pages/support-activity/index.vue:14-21`template
- 修改: `frontend/pages/support-activity/index.vue:105-122`script import
- [ ] **Step 1: 引入 ActivityRankingModal**
```javascript
import ActivityRankingModal from './components/ActivityRankingModal.vue'
```
- [ ] **Step 2: 添加弹窗状态和引用**
```javascript
const rankingModalVisible = ref(false)
const currentActivityTitle = ref('')
```
- [ ] **Step 3: 在 template 中添加组件**
```vue
<!-- ThemeBanner 添加 @tap 打开弹窗 -->
<ThemeBanner
v-if="config"
:title="config.title"
:banner-image="config.bannerImage"
:current="progressData.current"
:target="progressData.target"
:is-stale-data="isStaleData"
@tap="openRankingModal"
/>
<!-- 添加 ActivityRankingModal -->
<ActivityRankingModal
v-model:visible="rankingModalVisible"
:activity-id="activityId"
:activity-title="currentActivityTitle"
@visit="handleVisitUser"
@view-profile="handleViewUserProfile"
@view-artwork="handleViewArtwork"
/>
```
- [ ] **Step 4: 添加打开弹窗方法**
```javascript
const openRankingModal = () => {
currentActivityTitle.value = config.value?.title || '活动排名'
rankingModalVisible.value = true
}
```
---
### Task 4: 提交代码
- [ ] **Step 1: 提交所有更改**
```bash
git add frontend/pages/support-activity/components/ActivityRankingModal.vue \
frontend/pages/support-activity/components/ThemeBanner.vue \
frontend/pages/support-activity/index.vue
git commit -m "feat: 添加 ActivityRankingModal 活动榜单弹窗组件"
```
---
## 验证步骤
1. 启动开发服务器:`cd frontend && npm run dev`
2. 进入活动页面,点击 ThemeBanner 区域
3. 验证弹窗正常显示TOP3和排名列表
4. 验证下拉刷新和滚动加载更多功能
5. 验证当前用户栏正确显示
---
## 依赖项
- `TOP3Card.vue` - 来自 `frontend/pages/components/TOP3Card.vue`
- `RankingListItem.vue` - 来自 `frontend/pages/components/RankingListItem.vue`
- `getActivityRankingApi` - 来自 `frontend/utils/api.js`