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