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

18 KiB
Raw Blame History

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: 创建基础模板和样式结构

<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 部分
<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: 添加样式
<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-36template 部分)

  • Step 1: 在 banner-content 上添加点击事件

<view class="banner-content"> 上添加 @tap="handleBannerClick"

  • Step 2: 添加 emit 定义

在 script 部分添加:

const emit = defineEmits(['tap'])

// 添加点击处理函数
const handleBannerClick = () => {
  emit('tap')
}

Task 3: 修改 support-activity/index.vue 引入和使用组件

文件:

  • 修改: frontend/pages/support-activity/index.vue:14-21template

  • 修改: frontend/pages/support-activity/index.vue:105-122script import

  • Step 1: 引入 ActivityRankingModal

import ActivityRankingModal from './components/ActivityRankingModal.vue'
  • Step 2: 添加弹窗状态和引用
const rankingModalVisible = ref(false)
const currentActivityTitle = ref('')
  • Step 3: 在 template 中添加组件
<!-- 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: 添加打开弹窗方法
const openRankingModal = () => {
  currentActivityTitle.value = config.value?.title || '活动排名'
  rankingModalVisible.value = true
}

Task 4: 提交代码

  • Step 1: 提交所有更改
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