From 1af0a0527aa27b9270c153f4d3ad2b078e276bb4 Mon Sep 17 00:00:00 2001 From: zheng020 Date: Thu, 14 May 2026 12:17:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=9B=BE=E7=89=87?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=94=B9=E5=BA=94=E6=8F=B4=E6=B6=88=E8=80=97?= =?UTF-8?q?=E9=81=93=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ribution-realtime-display-frontend-plan.md | 566 ++++++++++++++ ...13-contribution-realtime-display-design.md | 691 ++++++++++++++++++ .../pages/square/composables/useBanner.js | 4 +- .../support-activity/components/ActionBar.vue | 456 +++++++++--- .../rank/activity-support-icon/Ndengji.png | Bin 0 -> 14413 bytes .../rank/activity-support-icon/Rdengji.png | Bin 0 -> 17528 bytes .../rank/activity-support-icon/SRdengji.png | Bin 0 -> 19494 bytes .../rank/activity-support-icon/SSRdengji.png | Bin 0 -> 15012 bytes .../rank/activity-support-icon/URengji.png | Bin 0 -> 18038 bytes .../bc722ea5ef1b9c0c9774f63d508d49bb.png | Bin 0 -> 203068 bytes .../activity-support-icon/beijingkuang.png | Bin 0 -> 159603 bytes .../activity-support-icon/beijingkuang1.png | Bin 0 -> 219805 bytes .../rank/activity-support-icon/djbeij.jpg | Bin 0 -> 30908 bytes .../rank/activity-support-icon/djbeij.png | Bin 0 -> 133069 bytes .../rank/activity-support-icon/huangguan.png | Bin 0 -> 104270 bytes .../rank/activity-support-icon/lihe.png | Bin 0 -> 135481 bytes .../shengrihuipaihangbang.png | Bin 0 -> 645135 bytes .../activity-support-icon/shuijingzhixin.png | Bin 0 -> 134369 bytes .../rank/activity-support-icon/tubiao.png | Bin 0 -> 264678 bytes .../rank/activity-support-icon/xingxing.png | Bin 0 -> 98003 bytes 20 files changed, 1615 insertions(+), 102 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-14-contribution-realtime-display-frontend-plan.md create mode 100644 docs/superpowers/specs/2026-05-13-contribution-realtime-display-design.md create mode 100644 frontend/static/rank/activity-support-icon/Ndengji.png create mode 100644 frontend/static/rank/activity-support-icon/Rdengji.png create mode 100644 frontend/static/rank/activity-support-icon/SRdengji.png create mode 100644 frontend/static/rank/activity-support-icon/SSRdengji.png create mode 100644 frontend/static/rank/activity-support-icon/URengji.png create mode 100644 frontend/static/rank/activity-support-icon/bc722ea5ef1b9c0c9774f63d508d49bb.png create mode 100644 frontend/static/rank/activity-support-icon/beijingkuang.png create mode 100644 frontend/static/rank/activity-support-icon/beijingkuang1.png create mode 100644 frontend/static/rank/activity-support-icon/djbeij.jpg create mode 100644 frontend/static/rank/activity-support-icon/djbeij.png create mode 100644 frontend/static/rank/activity-support-icon/huangguan.png create mode 100644 frontend/static/rank/activity-support-icon/lihe.png create mode 100644 frontend/static/rank/activity-support-icon/shengrihuipaihangbang.png create mode 100644 frontend/static/rank/activity-support-icon/shuijingzhixin.png create mode 100644 frontend/static/rank/activity-support-icon/tubiao.png create mode 100644 frontend/static/rank/activity-support-icon/xingxing.png diff --git a/docs/superpowers/plans/2026-05-14-contribution-realtime-display-frontend-plan.md b/docs/superpowers/plans/2026-05-14-contribution-realtime-display-frontend-plan.md new file mode 100644 index 0000000..8b6a7d2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-contribution-realtime-display-frontend-plan.md @@ -0,0 +1,566 @@ +# 实时贡献显示前端实现计划 + +> **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} 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 + + + + + +``` + +- [ ] **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 + + + + + { + 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条记录 + - 贡献项格式:头像 + 昵称 + "贡献了" + 道具图标 + 名称 + 数量 \ No newline at end of file diff --git a/docs/superpowers/specs/2026-05-13-contribution-realtime-display-design.md b/docs/superpowers/specs/2026-05-13-contribution-realtime-display-design.md new file mode 100644 index 0000000..705e9d7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-contribution-realtime-display-design.md @@ -0,0 +1,691 @@ +# 用户贡献实时显示方案 + +## 1. 概述 + +### 1.1 需求描述 +在活动页面实时展示所有用户的贡献动态,包括: +- 贡献者头像 / 昵称 +- 道具图标 / 名称 +- 贡献数量 + +### 1.2 展示形式 +- **列表形式**:最新贡献显示在顶部 +- **生命周期**:页面可见时显示,不可见时暂停 +- **消失逻辑**:每条记录 5 秒无新数据则逐条淡出消失 + +### 1.3 技术方案 +- 后端:Gateway 层新增 HTTP API,ActivityService 处理业务逻辑 +- 前端:轮询 composable 与列表组件 +- 数据库:`activity_contributions` 表已存在 + +--- + +## 2. 数据库设计 + +### 2.1 ER 关系 + +``` +┌─────────────────┐ ┌──────────────────────────┐ +│ activities │ │ activity_contributions │ +│─────────────────│ │───────────────────────────│ +│ id │──────< │ activity_id │ +│ star_id │ │ user_id │ +│ ... │ │ star_id │ +└─────────────────┘ │ item_id │ + │ item_type │ + │ quantity │ + │ contribution_points │ + │ created_at(毫秒时间戳) │ + └──────────────────────────┘ + │ + │ LEFT JOIN + ▼ + ┌─────────────────┐ + │ users │ + │─────────────────│ + │ id │ + │ nickname │ + │ avatar_url │ + └─────────────────┘ + │ + │ LEFT JOIN + ▼ + ┌─────────────────┐ + │ activity_items │ + │─────────────────│ + │ id │ + │ item_name │ + │ icon_url │ + └─────────────────┘ +``` + +### 2.2 表结构 + +**activity_contributions(活动贡献记录表)** + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| id | bigint | PK, AUTO_INCREMENT | 贡献记录 ID | +| activity_id | bigint | NOT NULL, INDEX | 关联活动 ID | +| user_id | bigint | NOT NULL | 贡献者用户 ID | +| star_id | bigint | NOT NULL | 明星 ID | +| item_id | bigint | NOT NULL | 道具 ID | +| item_type | varchar(50) | NOT NULL | 道具类型,如 `firework`、`megaphone` | +| quantity | int | DEFAULT 1 | 本次贡献数量 | +| crystal_spent | bigint | NOT NULL | 花费的水晶数量 | +| contribution_points | bigint | NOT NULL | 贡献值 | +| created_at | bigint | NOT NULL, INDEX | 创建时间(毫秒时间戳) | + +**索引设计** + +| 索引名 | 索引字段 | 类型 | 用途 | +|--------|----------|------|------| +| idx_activity_id | (activity_id) | BTREE | 按活动 ID 筛选 | +| idx_activity_created | (activity_id, created_at DESC, id DESC) | BTREE | 按活动时间降序+ID降序查询,支持 since_timestamp + since_id 轮询 | + +> `created_at DESC` 索引可加速 `WHERE activity_id = ? AND created_at > sinceTimestamp` 查询。 + +### 2.3 写入时机 + +`purchaseItem` 成功后,**同步写入**此表。 + +**连击计数器(Redis)**: + +用户每次点击购买道具时,更新 Redis 连击计数器: +- **Key**:`combo:{user_id}:{item_type}` +- **Value**:当前连击数 +- **TTL**:3 秒(无新点击则过期) + +``` +用户点击购买 → Redis INCR combo:{user_id}:{item_type} + EXPIRE 3秒 +``` + +### 2.4 查询时获取连击数 + +前端轮询时,后端从 Redis 获取当前连击数,合并到返回结果中: + +``` +GET combo:{user_id}:{item_type} → 返回当前计数 +``` + +**连击逻辑**: +- 同一用户、同一道具:3 秒内的多次点击累加显示 +- 不同道具:独立计数,互不影响 + +--- + +## 3. 后端 API 设计 + +### 3.1 接口概述 + +| 接口 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 获取最新贡献记录 | GET | `/api/v1/activity/:activityId/contributions/latest` | 实时轮询用 | + +### 3.2 获取最新贡献记录接口 + +**请求** +``` +GET /api/v1/activity/:activityId/contributions/latest +``` + +**Query 参数** +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| since_timestamp | int64 | 否 | 0 | 起始时间戳(毫秒),获取该时间之后的最新记录 | +| since_id | int64 | 否 | 0 | 起始记录 ID(用于同时间戳内的二次筛选) | +| limit | int32 | 否 | 1 | 每次拉取条数,最大 5 | + +**响应(成功)** +```json +{ + "code": 0, + "message": "success", + "data": { + "records": [ + { + "id": 12345, + "activity_id": 1001, + "user_id": 888, + "user_nickname": "小明", + "user_avatar": "https://example.com/avatar.jpg", + "item_type": "firework", + "item_name": "烟花", + "item_icon": "https://example.com/firework.png", + "quantity": 3, + "contribution_points": 3, + "combo_count": 2, + "created_at": 1747133400000 + } + ], + "latest_timestamp": 1747133400000, + "latest_id": 12345 + } +} +``` + +**字段说明**: +| 字段 | 说明 | +|------|------| +| quantity | 本次 contribution 的实际贡献数量 | +| combo_count | 从 Redis 获取的当前连击数(3秒内同用户同道具的累计点击) | + +**响应(失败)** +```json +{ + "code": 40401, + "message": "活动不存在", + "data": null +} +``` + +### 3.3 接口流程 + +``` +请求到达 + │ + ▼ +参数校验(activityId 格式、limit 范围) + │ + ▼ +Gateway 调用 ActivityService(直接查 Repository) + │ + ▼ +查询 activity_contributions 表,并联表查询用户信息和道具信息 + SELECT c.id, c.activity_id, c.user_id, + u.nickname as user_nickname, u.avatar_url as user_avatar, + i.item_type, i.item_name, i.icon_url as item_icon, + c.quantity, c.contribution_points, c.created_at + FROM activity_contributions c + LEFT JOIN users u ON c.user_id = u.id + LEFT JOIN activity_items i ON c.item_id = i.id + WHERE c.activity_id = ? + AND (c.created_at > ? OR (c.created_at = ? AND c.id > ?)) + ORDER BY c.created_at DESC, c.id DESC + LIMIT limit + │ + ▼ +组装响应 + latest_timestamp = 本次返回的最新记录时间戳 + latest_id = 本次返回的最大 ID + combo_count = 从 Redis 获取连击数 + │ + ▼ +返回 code: 0 + records + latest_timestamp + latest_id +``` + +> 使用时间戳 + ID 双重条件查询,好处是: +> 1. 用户退出页面再进来时,用列表中最新记录的时间戳和 ID 作为起点 +> 2. 同一毫秒内多条记录时,用 ID 做二次筛选,不会漏数据 +> 3. `ORDER BY created_at DESC, id DESC` 保证同时间戳内 ID 大的在前 + +### 3.4 后端实现 + +#### Gateway 层(HTTP API) + +新增 Controller:`gateway/controller/contribution_controller.go` + +```go +package controller + +type ContributionController struct { + db *gorm.DB +} + +func NewContributionController(db *gorm.DB) *ContributionController + +// GetLatestContributions 获取最新贡献记录 +// @Summary 获取活动最新贡献记录(实时轮询用) +// @Tags contributions +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param activityId path int64 true "活动ID" +// @Param since_timestamp query int64 false "起始时间戳(毫秒)" +// @Param since_id query int64 false "起始记录ID(用于同时间戳内的二次筛选)" +// @Param limit query int32 false "拉取条数,默认1,最大5" +// @Success 200 {object} response.Response +// @Router /api/v1/activity/{activityId}/contributions/latest [get] +func (ctrl *ContributionController) GetLatestContributions(c *gin.Context) +``` + +#### Repository 层 + +```go +// services/activityService/repository/activity_repository.go + +// GetLatestContributions 获取最新的贡献记录(用于实时轮询) +// sinceTimestamp: 起始时间戳,sinceId: 起始记录ID(用于同时间戳内的二次筛选) +func (r *activityRepository) GetLatestContributions(activityID int64, sinceTimestamp int64, sinceId int64, limit int) ([]*models.ActivityContribution, int64, int64, error) { + query := r.db.Model(&models.ActivityContribution{}). + Where("activity_id = ?", activityID) + + if sinceTimestamp > 0 && sinceId > 0 { + // 同一毫秒内用 ID 做二次筛选 + query = query.Where("(created_at > ? OR (created_at = ? AND id > ?))", sinceTimestamp, sinceTimestamp, sinceId) + } else if sinceTimestamp > 0 { + query = query.Where("created_at > ?", sinceTimestamp) + } + + var contributions []*models.ActivityContribution + if err := query.Order("created_at DESC, id DESC").Limit(limit).Find(&contributions).Error; err != nil { + return nil, 0, 0, err + } + + // 返回本次返回的最新时间戳和最大 ID + var latestTimestamp, latestId int64 + if len(contributions) > 0 { + latestTimestamp = contributions[0].CreatedAt + latestId = contributions[0].Id + } + + return contributions, latestTimestamp, latestId, nil +} +``` + +**获取连击数(Service 层)**: + +```go +func (s *ActivityService) GetComboCount(userID int64, itemType string) (int64, error) { + key := fmt.Sprintf("combo:%d:%s", userID, itemType) + count, err := s.redis.Get(key).Int64() + if err != nil || count == 0 { + return 1, nil // 无连击时返回 1 + } + return count, nil +} +``` + +#### 写入时机(purchaseItem 改造) + +当前 `PurchaseItem` 成功后已调用 `CreateContribution`,需确保冗余字段(user_nickname、user_avatar、item_name、item_icon)一并写入。 + +**连击计数**: +```go +// Redis INCR + EXPIRE +rdb.Incr(ctx, fmt.Sprintf("combo:%d:%s", userID, itemType)) +rdb.Expire(ctx, fmt.Sprintf("combo:%d:%s", userID, itemType), 3*time.Second) +``` + +#### 查询时获取连击数 + +```go +// 从 Redis 获取 combo_count +comboCount, _ := rdb.Get(ctx, fmt.Sprintf("combo:%d:%s", userID, itemType)).Int64() +``` + +--- + +## 4. 前端架构设计 + +### 4.1 目录结构 + +``` +frontend/pages/support-activity/ +├── components/ +│ └── ContributionList.vue # 新增:贡献列表组件 +├── composables/ +│ └── useContributionPolling.js # 新增:贡献轮询逻辑 +└── index.vue +``` + +### 4.2 数据流 + +``` +页面可见 + │ + ▼ +useContributionPolling.start() + │ + ▼ +首次全量拉取:GET /api/v1/activity/{id}/contributions/latest?since_timestamp=0&since_id=0&limit=5 + │ + ▼ +records = 返回的最新记录(最多 5 条) +latestTimestamp = 本次返回的最新时间戳 +latestId = 本次返回的最大 ID + │ + ▼ +定时轮询(每秒) + │ + ▼ + GET /api/v1/activity/{id}/contributions/latest?since_timestamp={latestTimestamp}&since_id={latestId}&limit=1 + │ + ▼ + 比对 records: + - 有新记录(时间戳更新 或 同时间戳内 ID 更新)→ 插入到列表头部,移除超出的记录,重置所有记录计时器 + - 无新记录 → 不更新,计时器继续倒计时 + │ + ▼ + 更新 latestTimestamp 和 latestId + +页面不可见 + │ + ▼ +useContributionPolling.stop() — 停止轮询,保留 latestTimestamp 和 records + +页面重新可见 + │ + ▼ +useContributionPolling.start() — 直接用现有的 latestTimestamp 继续轮询 +``` + +### 4.3 轮询策略 + +| 参数 | 值 | 说明 | +|------|-----|------| +| 轮询间隔 | 1000ms | 每秒拉取一次,确保实时感 | +| 首次请求 | 全量拉取 5 条最新记录 | 获取最近 5 条实时贡献 | +| 轮询请求 | limit=1 | 每秒拉取 1 条,保持实时感 | +| 列表上限 | 5 条 | 超出后移除最旧记录 | +| 消失计时 | 每条记录 5 秒无更新则逐条淡出消失 | 无新数据时,最早那条记录 5 秒后先消失,后续记录依次前移并同样计时 | +| 暂停条件 | onHide | 页面隐藏时停止轮询,保留 latestTimestamp、latestId 和 records | +| 恢复条件 | onShow | 页面显示时继续轮询,使用现有的 latestTimestamp 和 latestId | +| 切换活动 | - | activityId 变化时,调用 reset() 重置所有状态 | + +--- + +## 5. 组件设计 + +### 5.1 ContributionList.vue + +**功能** +- 展示贡献记录滚动列表,最新记录在顶部 +- 页面不可见时整体隐藏(v-if) +- 新记录有淡入动画,记录超时淡出消失 + +**Props** +| 属性 | 类型 | 必填 | 说明 | +|------|------|------|------| +| activityId | string | 是 | 活动 ID | + +**模板结构** +```vue + +``` + +**样式要点** +- 固定高度(建议 200rpx),超出部分滚动 +- 半透明背景(rgba(0,0,0,0.3)) +- 每条记录一行:头像 + 昵称 + "贡献了" + 道具图标 + 名称 + 数量 +- 新记录(index === 0)有 0.3s 淡入动画 +- 消失时添加 fading 类,0.5s 淡出动画 + +### 5.2 useContributionPolling.js + +**功能** +- 管理贡献记录的轮询逻辑 +- 提供 start / stop 方法 +- 自动处理增量拉取和列表更新 +- 每条记录独立计时,超时淡出消失 + +**接口** +```javascript +// 输入 +activityId: Ref + +// 输出 +{ + records, // Ref — 当前展示的贡献列表 + visible, // Ref — 列表是否可见(用于 v-if 控制) + loading, // Ref — 是否正在加载 + error, // Ref — 错误信息 + start, // () => void — 开始轮询 + stop, // () => void — 停止轮询(保留状态) + reset // () => void — 重置所有状态(切换活动时用) +} + +// 内部状态 +{ + latestTimestamp, // number — 当前列表中最新记录的时间戳(继续轮询的起点) + latestId, // number — 当前列表中最新记录的 ID(同时间戳内二次筛选用) + pollingTimer, // number — setInterval 返回的 timer ID + isPolling, // boolean — 是否正在轮询 + recordTimers, // Map — 记录 ID -> 定时器 +} +``` + +**核心逻辑** +```javascript +const MAX_RECORDS = 5 +const POLL_INTERVAL = 1000 // 每秒拉取 +const RECORD_TTL = 5000 // 每条记录 5 秒后消失 + +let latestTimestamp = 0 // 初始为 0,获取所有最新记录 +let latestId = 0 // 初始为 0 +const recordTimers = new Map() // 记录 ID -> 定时器 + +function resetRecordTimer(record) { + // 清除已有定时器 + if (recordTimers.has(record.id)) { + clearTimeout(recordTimers.get(record.id)) + recordTimers.delete(record.id) + } + // 清除淡出状态(新数据到来时重置) + const target = records.value.find(r => r.id === record.id) + if (target) { + target.fading = false + } + // 设置新的消失定时器 + const timer = setTimeout(() => { + // 标记为淡出状态 + const target = records.value.find(r => r.id === record.id) + if (target) { + target.fading = true + // 等待动画完成后移除 + setTimeout(() => { + records.value = records.value.filter(r => r.id !== record.id) + recordTimers.delete(record.id) + }, 500) + } else { + recordTimers.delete(record.id) + } + }, RECORD_TTL) + recordTimers.set(record.id, timer) +} + +async function fetchLatest() { + // 拉取 since_timestamp 和 since_id 之后的最新记录 + const res = await fetchActivityContributionsLatest(activityId.value, latestTimestamp, latestId, 1) + + if (res.records.length === 0) return + + const newRecord = res.records[0] + + // 检测到新记录(时间戳更新,或时间戳相同但 ID 更新) + const isNew = newRecord.created_at > latestTimestamp || + (newRecord.created_at === latestTimestamp && newRecord.id > latestId) + + if (isNew) { + // 重置所有现有记录的计时器(新数据到来,刷新列表) + records.value.forEach(resetRecordTimer) + // 新记录插入到列表头部 + records.value = [newRecord, ...records.value].slice(0, MAX_RECORDS) + // 为新记录启动消失计时器 + resetRecordTimer(newRecord) + // 更新时间戳和 ID + latestTimestamp = newRecord.created_at + latestId = newRecord.id + } +} + +function start() { + if (pollingTimer) return + isPolling.value = true + // 如果 latestTimestamp 为 0,说明是首次或切换活动,清空列表 + if (latestTimestamp === 0) { + records.value = [] + } + fetchLatest() + pollingTimer = setInterval(fetchLatest, POLL_INTERVAL) +} + +function stop() { + if (pollingTimer) { + clearInterval(pollingTimer) + pollingTimer = null + } + // 停止所有记录的计时器 + recordTimers.forEach(timer => clearTimeout(timer)) + recordTimers.clear() + isPolling.value = false + // 不清空 records、latestTimestamp、latestId,保留状态以便恢复 +} + +function reset() { + // 切换活动时调用,重置所有状态 + stop() + latestTimestamp = 0 + latestId = 0 + records.value = [] +} + +// 监听页面显示/隐藏 +onShow(() => { + visible.value = true + start() +}) + +onHide(() => { + visible.value = false + stop() +}) +``` + +> `fetchActivityContributionsLatest` 调用 `GET /api/v1/activity/{id}/contributions/latest?since_timestamp={sinceTimestamp}&since_id={sinceId}&limit={limit}` +> +> 新增 `reset()` 方法,切换活动时调用,重置所有状态。 + +--- + +## 6. 页面集成 + +### 6.1 组件引入 + +在 `ThemeBanner` 下方、`StageArea` 上方引入: + +```vue + + + + + + + + +``` + +### 6.2 样式控制 + +```css +.contribution-list-wrapper { + width: 100%; + padding: 0 24rpx; +} + +.contribution-item.fading-out { + animation: fadeOut 0.5s forwards; +} + +@keyframes fadeOut { + from { opacity: 1; } + to { opacity: 0; } +} +``` + +--- + +## 7. 性能与优化 + +| 优化点 | 方案 | +|--------|------| +| 列表更新 | 新记录插入到头部,使用展开运算符 | +| 去重 | 以 `record.id` 作为 v-for 的 :key + 时间戳+ID 双重比对 | +| 列表上限 | 超过 5 条时移除最旧记录 | +| 记录消失 | 每条记录 5 秒无更新则淡出消失 | +| 内存占用 | 页面隐藏时停止计时器,保留 records | +| 轮询策略 | 每秒拉取 1 条,保持实时感 | +| 同毫秒去重 | 使用 `since_timestamp + since_id` 双重条件,同一毫秒多条不漏 | + +--- + +## 8. 错误处理 + +| 场景 | 处理方式 | +|------|----------| +| 接口返回 404(活动不存在) | 组件隐藏,不影响页面其他功能 | +| 接口返回 500(服务器错误) | 静默忽略,继续等待下一次 | +| 网络断开 | 页面 onHide 会停止轮询;恢复后 onShow 继续轮询 | +| 返回数据为空 | 不更新列表,不更新 latestTimestamp 和 latestId | +| 切换活动 | 调用 reset() 重置所有状态 | +| 同毫秒多条记录 | 使用 since_timestamp + since_id 双重筛选,不会漏数据 | +| 连击显示 | combo_count 从 Redis 获取,3秒内同用户同道具累加 | + +--- + +## 9. 实施步骤 + +### 9.1 后端实现 +1. [x] 在 `activity_contributions` 表联表查询用户信息和道具信息(LEFT JOIN users, LEFT JOIN activity_items) +2. [ ] 在 Redis 中实现连击计数器 `combo:{user_id}:{item_type}`,TTL 3秒 +3. [ ] 在 `activity_repository.go` 添加 `GetLatestContributions` 方法 +4. [ ] 在 Gateway 层添加 `contribution_controller.go` +5. [ ] 在路由注册新接口 `/api/v1/activity/:activityId/contributions/latest` +6. [ ] 验证 `purchaseItem` 后 `CreateContribution` 正常工作,并更新 Redis 连击计数器 + +### 9.2 前端 API 层 +1. [ ] 在 `frontend/utils/activity-config.js` 中新增 `fetchActivityContributionsLatest` 方法 + +### 9.3 前端组件 +1. [ ] 创建 `useContributionPolling.js` +2. [ ] 创建 `ContributionList.vue` + +### 9.4 页面集成 +1. [ ] 在 `index.vue` 中引入 `ContributionList` + +### 9.5 测试验证 +1. [ ] 轮询正常:每秒间隔拉取数据 +2. [ ] 增量正确:新贡献出现时列表头部插入 +3. [ ] 消失逻辑:无新数据 5 秒后记录淡出消失 +4. [ ] 页面切换:onHide 停止,onShow 继续(latestTimestamp 保持) +5. [ ] 列表上限:超过 5 条时移除最旧记录 + +--- + +## 10. 待确认 + +- [x] 后端接口由我方实现 +- [x] 道具图标由后端提供(冗余字段直接存储) +- [x] 仅显示当前页面实时数据(页面切换时暂停) +- [x] 后端框架:Go + Gin(Gateway)+ Dubbo-go(微服务)+ GORM +- [x] 数据库:MySQL +- [x] ORM:GORM +- [x] 冗余字段:user_nickname、user_avatar、item_name、item_icon 直接存在表中 +- [x] 不需要"几秒前"等相对时间显示,只显示实时贡献 +- [x] 轮询使用时间戳(since_timestamp)而非 ID,避免重复拉取 +- [ ] 列表视觉样式是否有设计稿? \ No newline at end of file diff --git a/frontend/pages/square/composables/useBanner.js b/frontend/pages/square/composables/useBanner.js index 1822c65..56b8ad5 100644 --- a/frontend/pages/square/composables/useBanner.js +++ b/frontend/pages/square/composables/useBanner.js @@ -12,8 +12,8 @@ export function useBanner() { if (res.code === 200 && res.data?.activities) { const activities = res.data.activities // 过滤掉已过期的活动 - // bannerActivities.value = activities - bannerActivities.value = activities.filter(item => item.status !== 'expired') + bannerActivities.value = activities + // bannerActivities.value = activities.filter(item => item.status !== 'expired') } } catch (e) { console.error('[useBanner] 加载 banner 活动失败', e?.message ?? e) diff --git a/frontend/pages/support-activity/components/ActionBar.vue b/frontend/pages/support-activity/components/ActionBar.vue index c4e2e08..679de4a 100644 --- a/frontend/pages/support-activity/components/ActionBar.vue +++ b/frontend/pages/support-activity/components/ActionBar.vue @@ -1,39 +1,66 @@