566 lines
13 KiB
Markdown
566 lines
13 KiB
Markdown
# 实时贡献显示前端实现计划
|
||
|
||
> **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条记录
|
||
- 贡献项格式:头像 + 昵称 + "贡献了" + 道具图标 + 名称 + 数量 |