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

566 lines
13 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.

# 实时贡献显示前端实现计划
> **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行位置添加
```javascript
// 获取活动最新贡献记录(实时轮询用)
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: 提交**
```bash
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 目录**
```bash
mkdir -p frontend/pages/support-activity/composables
```
- [ ] **Step 2: 编写 useContributionPolling.js**
```javascript
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: 提交**
```bash
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**
```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: 提交**
```bash
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行后面添加
```javascript
import ContributionList from './components/ContributionList.vue'
```
- [ ] **Step 2: 在 ThemeBanner 下方、StageArea 上方插入 ContributionList**
在 index.vue 第21行ThemeBanner 结束标签后)插入:
```vue
<!-- 实时贡献列表 -->
<ContributionList
v-if="activityId && !isLoading"
:activity-id="activityId"
class="contribution-list-wrapper"
/>
<!-- 舞台区域 -->
<StageArea
```
- [ ] **Step 3: 在 onShow 中启动贡献轮询**
在 onShow 回调中大约第471行确保页面显示时贡献列表可见
```javascript
onShow(() => {
isPageActive.value = true
if (!isLoading.value && !errorMessage.value && progressManager) {
progressManager.resumePolling()
}
})
```
- [ ] **Step 4: 在 onHide 中停止贡献轮询(页面不可见时)**
在 onHide 回调中大约第479行
```javascript
onHide(() => {
isPageActive.value = false
if (progressManager) {
progressManager.pausePolling()
}
})
```
注意ContributionList 内部已经通过 isPageActive ref 监听页面可见性,当 onHide 时会自动停止轮询。
- [ ] **Step 5: 添加 contribution-list-wrapper 样式**
在 index.vue 的 style 末尾大约第721行之后添加
```css
/* 实时贡献列表样式 */
.contribution-list-wrapper {
width: 100%;
padding: 0 24rpx;
}
```
- [ ] **Step 6: 提交**
```bash
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条记录
- 贡献项格式:头像 + 昵称 + "贡献了" + 道具图标 + 名称 + 数量