topfans/docs/superpowers/plans/2026-05-14-contribution-realtime-display-frontend-plan.md

13 KiB
Raw Blame History

实时贡献显示前端实现计划

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: 在活动页面实现实时贡献列表展示,从下往上堆叠,最新贡献显示在顶部

Architecture: 轮询模式页面可见时每秒拉取一次最新贡献记录增量更新列表最多5条页面不可见时停止并清空列表

Tech Stack: Vue 3 Composition API, uni-app, 原生小程序轮询


文件结构

frontend/
├── utils/
│   └── api.js                           # 新增 getActivityContributionsLatestApi
├── pages/
│   └── support-activity/
│       ├── composables/
│       │   └── useContributionPolling.js # 新增:贡献轮询逻辑
│       ├── components/
│       │   └── ContributionList.vue      # 新增:贡献列表组件
│       └── index.vue                      # 修改:引入 ContributionList

Task 1: 添加 API 方法到 api.js

Files:

  • Modify: frontend/utils/api.js:550 (在 purchaseActivityItemApi 之后)

  • Step 1: 添加获取活动贡献记录 API

purchaseActivityItemApi 函数后面第551行位置添加

// 获取活动最新贡献记录(实时轮询用)
export function getActivityContributionsLatestApi(activityId, sinceId = 0, limit = 5) {
	return request({
		url: `/api/v1/activity/${activityId}/contributions/latest?since_id=${sinceId}&limit=${limit}`,
		method: 'GET'
	})
}
  • Step 2: 提交
git add frontend/utils/api.js
git commit -m "feat: add getActivityContributionsLatestApi for realtime contributions"

Task 2: 创建 useContributionPolling.js Composable

Files:

  • Create: frontend/pages/support-activity/composables/useContributionPolling.js

  • Step 1: 创建 composable 目录

mkdir -p frontend/pages/support-activity/composables
  • Step 2: 编写 useContributionPolling.js
import { ref, watch, onUnmounted } from 'vue'
import { getActivityContributionsLatestApi } from '@/utils/api.js'

/**
 * 贡献轮询逻辑 composable
 * @param {Ref<string>} activityId - 活动ID
 * @param {boolean} isPageActive - 页面是否可见
 * @returns {Object} records, loading, error, start, stop, refresh
 */
export function useContributionPolling(activityId, isPageActive) {
  const MAX_RECORDS = 5
  const POLL_INTERVAL = 1000 // 每秒拉取
  const RECORD_TTL = 5000 // 每条记录 5 秒后消失

  const records = ref([])
  const loading = ref(false)
  const error = ref(null)

  let latestId = 0
  let pollingTimer = null
  let isPolling = false
  const recordTimers = new Map()

  // 重置记录计时器
  function resetRecordTimer(record) {
    if (recordTimers.has(record.id)) {
      clearTimeout(recordTimers.get(record.id))
    }
    const timer = setTimeout(() => {
      records.value = records.value.filter(r => r.id !== record.id)
      recordTimers.delete(record.id)
    }, RECORD_TTL)
    recordTimers.set(record.id, timer)
  }

  // 获取最新贡献记录
  async function fetchLatest() {
    if (!activityId.value) return

    try {
      const res = await getActivityContributionsLatestApi(activityId.value, latestId, 1)

      // 处理 code 不为 200 的情况(静默忽略)
      if (res.code !== 200) return

      const newRecords = res.data?.records || []
      if (newRecords.length === 0) return

      const newRecord = newRecords[0]

      // 检测到新记录id > latestId
      if (newRecord.id > latestId) {
        // 重置所有现有记录的计时器
        records.value.forEach(resetRecordTimer)

        // 新记录插入到列表头部
        records.value = [newRecord, ...records.value].slice(0, MAX_RECORDS)

        // 为新记录启动消失计时器
        resetRecordTimer(newRecord)

        // 更新 latestId
        latestId = newRecord.id
      }
    } catch (e) {
      // 网络错误等,静默忽略,继续等待下一次轮询
      console.error('[useContributionPolling] fetchLatest error:', e)
    }
  }

  // 全量拉取(首次或重新开始)
  async function fetchAll() {
    if (!activityId.value) return

    loading.value = true
    error.value = null

    try {
      const res = await getActivityContributionsLatestApi(activityId.value, 0, MAX_RECORDS)

      if (res.code !== 200) {
        throw new Error(res.message || '获取贡献记录失败')
      }

      const newRecords = res.data?.records || []

      // 清除所有计时器
      recordTimers.forEach(timer => clearTimeout(timer))
      recordTimers.clear()

      // 重置列表
      records.value = newRecords

      // 为每条记录启动计时器
      newRecords.forEach(record => resetRecordTimer(record))

      // 更新 latestId
      latestId = newRecords.length > 0 ? newRecords[newRecords.length - 1].id : 0
    } catch (e) {
      error.value = e.message || '获取贡献记录失败'
      console.error('[useContributionPolling] fetchAll error:', e)
    } finally {
      loading.value = false
    }
  }

  // 开始轮询
  function start() {
    if (pollingTimer) return
    if (!activityId.value) return

    isPolling = true
    latestId = 0

    // 全量拉取首次
    fetchAll()

    // 启动定时轮询
    pollingTimer = setInterval(fetchLatest, POLL_INTERVAL)
  }

  // 停止轮询
  function stop() {
    if (pollingTimer) {
      clearInterval(pollingTimer)
      pollingTimer = null
    }

    // 清除所有计时器
    recordTimers.forEach(timer => clearTimeout(timer))
    recordTimers.clear()

    isPolling = false
    records.value = []
    latestId = 0
  }

  // 强制刷新(全量拉取)
  function refresh() {
    stop()
    start()
  }

  // 监听页面可见性
  watch(isPageActive, (active) => {
    if (active) {
      start()
    } else {
      stop()
    }
  }, { immediate: true })

  // 监听 activityId 变化
  watch(activityId, (newId, oldId) => {
    if (newId !== oldId) {
      stop()
      if (isPageActive.value && newId) {
        start()
      }
    }
  })

  // 组件卸载时清理
  onUnmounted(() => {
    stop()
  })

  return {
    records,
    loading,
    error,
    start,
    stop,
    refresh
  }
}
  • Step 3: 提交
git add frontend/pages/support-activity/composables/useContributionPolling.js
git commit -m "feat: add useContributionPolling composable for realtime contributions"

Task 3: 创建 ContributionList.vue 组件

Files:

  • Create: frontend/pages/support-activity/components/ContributionList.vue

  • Step 1: 编写 ContributionList.vue

<template>
  <view class="contribution-list" v-if="visible">
    <view class="list-header">
      <text class="header-title">实时贡献</text>
    </view>
    <scroll-view class="list-content" scroll-y>
      <view
        v-for="(record, index) in records"
        :key="record.id"
        class="contribution-item"
        :class="{ 'new-item': index === 0 && isNewRecord(record.id) }"
      >
        <image class="user-avatar" :src="record.user_avatar" mode="aspectFill" />
        <text class="user-nickname">{{ record.user_nickname }}</text>
        <text class="contribute-text">贡献了</text>
        <image class="item-icon" :src="record.item_icon" mode="aspectFill" />
        <text class="item-name">{{ record.item_name }}</text>
        <text class="item-quantity">x{{ record.quantity }}</text>
      </view>
    </scroll-view>
  </view>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useContributionPolling } from '../composables/useContributionPolling.js'

const props = defineProps({
  activityId: {
    type: String,
    required: true
  }
})

const visible = ref(true)
const isPageActive = ref(true)
const newRecordIds = ref(new Set())

// 使用轮询 composable
const { records, loading, error, start, stop, refresh } = useContributionPolling(
  computed(() => props.activityId),
  isPageActive
)

// 判断记录是否为新记录(刚加入的)
function isNewRecord(id) {
  return newRecordIds.value.has(id)
}

// 监听 records 变化,标记新记录
watch(records, (newRecords, oldRecords) => {
  if (newRecords.length > 0 && oldRecords) {
    const newIds = newRecords.map(r => r.id)
    const oldIds = oldRecords.map(r => r.id)

    // 找出新增的记录ID
    for (const id of newIds) {
      if (!oldIds.includes(id)) {
        newRecordIds.value.add(id)
        // 2秒后移除新记录标记
        setTimeout(() => {
          newRecordIds.value.delete(id)
        }, 2000)
      }
    }
  }
}, { deep: true })

onMounted(() => {
  // 检查页面可见性
  const pages = getCurrentPages()
  const currentPage = pages[pages.length - 1]
  const data = currentPage.options || {}
  // 如果URL参数中有 _page_visible=false 认为页面不可见
  // 但实际上 uni-app 会通过 onShow/onHide 生命周期来管理
  isPageActive.value = true
})

// 页面生命周期集成
onUnmounted(() => {
  stop()
})

// 暴露给父组件使用
defineExpose({
  refresh
})
</script>

<style scoped>
.contribution-list {
  width: 100%;
  padding: 0 24rpx;
  margin-top: 20rpx;
}

.list-header {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 16rpx;
}

.header-title {
  font-size: 28rpx;
  font-weight: bold;
  color: #fff;
  text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
}

.list-content {
  height: 200rpx;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 16rpx;
  padding: 16rpx;
}

.contribution-item {
  display: flex;
  align-items: center;
  padding: 12rpx 0;
  border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
  animation: fadeIn 0.3s ease-out;
}

.contribution-item:last-child {
  border-bottom: none;
}

.new-item {
  animation: slideIn 0.3s ease-out;
}

.user-avatar {
  width: 48rpx;
  height: 48rpx;
  border-radius: 50%;
  margin-right: 12rpx;
}

.user-nickname {
  font-size: 24rpx;
  color: #fff;
  max-width: 120rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.contribute-text {
  font-size: 24rpx;
  color: rgba(255, 255, 255, 0.7);
  margin: 0 12rpx;
}

.item-icon {
  width: 36rpx;
  height: 36rpx;
  margin-right: 8rpx;
}

.item-name {
  font-size: 24rpx;
  color: #fff;
  margin-right: 8rpx;
}

.item-quantity {
  font-size: 24rpx;
  color: #ffcc00;
  font-weight: bold;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(-10rpx);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateX(-20rpx);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}
</style>
  • Step 2: 提交
git add frontend/pages/support-activity/components/ContributionList.vue
git commit -m "feat: add ContributionList component for realtime display"

Task 4: 在 index.vue 中集成 ContributionList

Files:

  • Modify: frontend/pages/support-activity/index.vue:103-113 (imports)

  • Modify: frontend/pages/support-activity/index.vue:14-21 (ThemeBanner 下方插入 ContributionList)

  • Modify: frontend/pages/support-activity/index.vue:476-482 (onHide 逻辑,停止贡献轮询)

  • Step 1: 修改 imports添加 ContributionList 引入

在 import StageArea 那行第111行后面添加

import ContributionList from './components/ContributionList.vue'
  • Step 2: 在 ThemeBanner 下方、StageArea 上方插入 ContributionList

在 index.vue 第21行ThemeBanner 结束标签后)插入:

    <!-- 实时贡献列表 -->
    <ContributionList
      v-if="activityId && !isLoading"
      :activity-id="activityId"
      class="contribution-list-wrapper"
    />

    <!-- 舞台区域 -->
    <StageArea
  • Step 3: 在 onShow 中启动贡献轮询

在 onShow 回调中大约第471行确保页面显示时贡献列表可见

onShow(() => {
  isPageActive.value = true

  if (!isLoading.value && !errorMessage.value && progressManager) {
    progressManager.resumePolling()
  }
})
  • Step 4: 在 onHide 中停止贡献轮询(页面不可见时)

在 onHide 回调中大约第479行

onHide(() => {
  isPageActive.value = false

  if (progressManager) {
    progressManager.pausePolling()
  }
})

注意ContributionList 内部已经通过 isPageActive ref 监听页面可见性,当 onHide 时会自动停止轮询。

  • Step 5: 添加 contribution-list-wrapper 样式

在 index.vue 的 style 末尾大约第721行之后添加

/* 实时贡献列表样式 */
.contribution-list-wrapper {
  width: 100%;
  padding: 0 24rpx;
}
  • Step 6: 提交
git add frontend/pages/support-activity/index.vue
git commit -m "feat: integrate ContributionList in support-activity page"

验证步骤

  1. 编译检查:确保 npm run dev 无报错
  2. 功能检查
    • 页面可见时贡献列表每1秒轮询一次
    • 页面不可见时(切换到后台),贡献列表停止轮询并清空
    • 新贡献出现时,列表头部插入,最旧记录被移除
    • 每条记录5秒无新数据后淡出消失
  3. 页面集成检查
    • ThemeBanner 下方显示"实时贡献"标题
    • 列表最多显示5条记录
    • 贡献项格式:头像 + 昵称 + "贡献了" + 道具图标 + 名称 + 数量