feat: 新增图片,修改应援消耗道具
@ -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<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条记录
|
||||||
|
- 贡献项格式:头像 + 昵称 + "贡献了" + 道具图标 + 名称 + 数量
|
||||||
@ -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
|
||||||
|
<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, 'fading-out': record.fading }"
|
||||||
|
>
|
||||||
|
<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.combo_count > 1 ? record.combo_count : record.quantity }}</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**样式要点**
|
||||||
|
- 固定高度(建议 200rpx),超出部分滚动
|
||||||
|
- 半透明背景(rgba(0,0,0,0.3))
|
||||||
|
- 每条记录一行:头像 + 昵称 + "贡献了" + 道具图标 + 名称 + 数量
|
||||||
|
- 新记录(index === 0)有 0.3s 淡入动画
|
||||||
|
- 消失时添加 fading 类,0.5s 淡出动画
|
||||||
|
|
||||||
|
### 5.2 useContributionPolling.js
|
||||||
|
|
||||||
|
**功能**
|
||||||
|
- 管理贡献记录的轮询逻辑
|
||||||
|
- 提供 start / stop 方法
|
||||||
|
- 自动处理增量拉取和列表更新
|
||||||
|
- 每条记录独立计时,超时淡出消失
|
||||||
|
|
||||||
|
**接口**
|
||||||
|
```javascript
|
||||||
|
// 输入
|
||||||
|
activityId: Ref<string>
|
||||||
|
|
||||||
|
// 输出
|
||||||
|
{
|
||||||
|
records, // Ref<ContributionRecord[]> — 当前展示的贡献列表
|
||||||
|
visible, // Ref<boolean> — 列表是否可见(用于 v-if 控制)
|
||||||
|
loading, // Ref<boolean> — 是否正在加载
|
||||||
|
error, // Ref<string | null> — 错误信息
|
||||||
|
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
|
||||||
|
<!-- ThemeBanner 下方 -->
|
||||||
|
<ThemeBanner v-if="config" ... />
|
||||||
|
|
||||||
|
<!-- 贡献列表 -->
|
||||||
|
<ContributionList
|
||||||
|
v-if="activityId && !isLoading"
|
||||||
|
:activity-id="activityId"
|
||||||
|
class="contribution-list-wrapper"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- StageArea -->
|
||||||
|
<StageArea v-if="config" ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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,避免重复拉取
|
||||||
|
- [ ] 列表视觉样式是否有设计稿?
|
||||||
@ -12,8 +12,8 @@ export function useBanner() {
|
|||||||
if (res.code === 200 && res.data?.activities) {
|
if (res.code === 200 && res.data?.activities) {
|
||||||
const activities = res.data.activities
|
const activities = res.data.activities
|
||||||
// 过滤掉已过期的活动
|
// 过滤掉已过期的活动
|
||||||
// bannerActivities.value = activities
|
bannerActivities.value = activities
|
||||||
bannerActivities.value = activities.filter(item => item.status !== 'expired')
|
// bannerActivities.value = activities.filter(item => item.status !== 'expired')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[useBanner] 加载 banner 活动失败', e?.message ?? e)
|
console.error('[useBanner] 加载 banner 活动失败', e?.message ?? e)
|
||||||
|
|||||||
@ -1,39 +1,66 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="action-bar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
|
<view class="action-bar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
|
||||||
<!-- 钻石消耗确认弹窗 -->
|
<!-- 钻石消耗确认弹窗 -->
|
||||||
<DiamondConfirmModal
|
<DiamondConfirmModal :visible="confirmModal.visible" :itemLabel="confirmModal.itemLabel"
|
||||||
:visible="confirmModal.visible"
|
:itemCost="confirmModal.itemCost" @confirm="handleModalConfirm" @cancel="handleModalCancel" />
|
||||||
:itemLabel="confirmModal.itemLabel"
|
|
||||||
:itemCost="confirmModal.itemCost"
|
|
||||||
@confirm="handleModalConfirm"
|
|
||||||
@cancel="handleModalCancel"
|
|
||||||
/>
|
|
||||||
<!-- 主容器 -->
|
<!-- 主容器 -->
|
||||||
<view class="bar-container">
|
<view class="bar-container">
|
||||||
<view
|
<!-- 用户信息区域 -->
|
||||||
v-for="item in items"
|
<view class="user-info">
|
||||||
:key="item.type"
|
<image :src="userInfo?.avatar_url || '/static/icon/avatar-default.png'" class="user-avatar" mode="aspectFill" />
|
||||||
class="action-item"
|
<view class="user-balance">
|
||||||
@click="handleClick(item)"
|
<image src="/static/icon/crystal.png" class="balance-icon" mode="aspectFit" />
|
||||||
>
|
<text class="balance-text">{{ userInfo?.crystal_balance || 0 }}</text>
|
||||||
<!-- 图标区域:添加白色背景和阴影 -->
|
|
||||||
<view class="icon-wrapper">
|
|
||||||
<image :src="item.icon" class="item-icon" mode="aspectFill" lazy-load />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 价格区域:钻石图标 + 数字 -->
|
|
||||||
<view class="cost-wrapper">
|
|
||||||
<image src="/static/icon/crystal.png" class="diamond-icon" mode="aspectFit" lazy-load />
|
|
||||||
<text class="item-cost">{{ item.cost }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 每个道具独立的反馈动画 -->
|
<view class="items-row">
|
||||||
<view v-if="feedbackItem === item.type" class="feedback-layer">
|
<view v-for="item in items" :key="item.type" class="action-item" :class="{ active: selectedItem === item.type }"
|
||||||
<view class="feedback-icon" :style="{ color: itemFeedbackColor(item.type) }">+1</view>
|
@click="handleItemSelect(item)">
|
||||||
|
<!-- 选中状态的背景 -->
|
||||||
|
<image v-if="selectedItem === item.type" src="/static/rank/activity-support-icon/djbeij.jpg" class="item-bg"
|
||||||
|
mode="aspectFill" />
|
||||||
|
|
||||||
|
<!-- 图标区域:添加白色背景和阴影 -->
|
||||||
|
<view class="icon-wrapper">
|
||||||
|
<image :src="item.icon" class="item-icon" mode="aspectFill" lazy-load />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 名称区域 -->
|
||||||
|
<view class="name-wrapper">
|
||||||
|
<text class="item-name">{{ item.name || item.label }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 价格区域:钻石图标 + 数字 -->
|
||||||
|
<view class="cost-wrapper">
|
||||||
|
<image src="/static/icon/crystal.png" class="diamond-icon" mode="aspectFit" lazy-load />
|
||||||
|
<text class="item-cost">{{ item.cost }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 选中状态显示"点击赠送" -->
|
||||||
|
<view v-if="selectedItem === item.type" class="gift-text"
|
||||||
|
@click="(e) => { e.stopPropagation(); handleDirectContribute(item) }">点击赠送</view>
|
||||||
|
|
||||||
|
<!-- 每个道具独立的反馈动画 -->
|
||||||
|
<view v-if="feedbackItem === item.type" class="feedback-layer">
|
||||||
|
<view class="feedback-icon" :style="{ color: itemFeedbackColor(item.type) }">+1</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 数量选择器和确认按钮 -->
|
||||||
|
<view class="quantity-control">
|
||||||
|
<view class="quantity-selector">
|
||||||
|
<view class="quantity-btn minus" @click.stop="decreaseQuantity">-</view>
|
||||||
|
<input type="number" v-model="quantity" class="quantity-input" />
|
||||||
|
<view class="quantity-btn plus" @click.stop="increaseQuantity">+</view>
|
||||||
|
</view>
|
||||||
|
<view class="confirm-btn" @click.stop="handleConfirmContribute">
|
||||||
|
<text>确认赠送</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 重试按钮 (当有失败操作时显示) -->
|
<!-- 重试按钮 (当有失败操作时显示) -->
|
||||||
<view v-if="hasFailedActions" class="retry-banner" @click="retryFailedActions">
|
<view v-if="hasFailedActions" class="retry-banner" @click="retryFailedActions">
|
||||||
<text class="retry-text">有操作失败,点击重试</text>
|
<text class="retry-text">有操作失败,点击重试</text>
|
||||||
@ -74,6 +101,9 @@ const items = computed(() => props.actionItems)
|
|||||||
|
|
||||||
const safeAreaBottom = ref(0)
|
const safeAreaBottom = ref(0)
|
||||||
const feedbackItem = ref(null) // 当前触发反馈动画的道具 type
|
const feedbackItem = ref(null) // 当前触发反馈动画的道具 type
|
||||||
|
const selectedItem = ref(null) // 当前选中的道具
|
||||||
|
const quantity = ref(1) // 赠送数量
|
||||||
|
const isContributing = ref(false) // 正在贡献中,防止状态被清除
|
||||||
|
|
||||||
// 按索引分配颜色,不依赖后端 type 字符串
|
// 按索引分配颜色,不依赖后端 type 字符串
|
||||||
const FEEDBACK_COLORS = ['#FFD700', '#00CFFF', '#FF6B9D', '#A78BFA', '#34D399']
|
const FEEDBACK_COLORS = ['#FFD700', '#00CFFF', '#FF6B9D', '#A78BFA', '#34D399']
|
||||||
@ -118,6 +148,17 @@ function handleModalCancel() {
|
|||||||
|
|
||||||
const userInfo = ref(null);
|
const userInfo = ref(null);
|
||||||
|
|
||||||
|
function loadUserInfo() {
|
||||||
|
try {
|
||||||
|
const userStr = uni.getStorageSync('user')
|
||||||
|
if (userStr) {
|
||||||
|
userInfo.value = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载用户信息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 网络状态监听
|
// 网络状态监听
|
||||||
let networkListener = null
|
let networkListener = null
|
||||||
let syncDebounceTimer = null
|
let syncDebounceTimer = null
|
||||||
@ -135,33 +176,44 @@ onMounted(() => {
|
|||||||
// 获取安全区域信息
|
// 获取安全区域信息
|
||||||
const systemInfo = uni.getSystemInfoSync()
|
const systemInfo = uni.getSystemInfoSync()
|
||||||
safeAreaBottom.value = systemInfo.safeAreaInsets?.bottom || 0
|
safeAreaBottom.value = systemInfo.safeAreaInsets?.bottom || 0
|
||||||
|
|
||||||
|
// 加载用户信息
|
||||||
|
loadUserInfo()
|
||||||
|
|
||||||
// 恢复离线队列
|
// 恢复离线队列
|
||||||
loadPendingActions()
|
loadPendingActions()
|
||||||
|
|
||||||
// 监听网络状态
|
// 监听网络状态
|
||||||
networkListener = uni.onNetworkStatusChange((res) => {
|
networkListener = uni.onNetworkStatusChange((res) => {
|
||||||
const wasOffline = !isOnline.value
|
const wasOffline = !isOnline.value
|
||||||
isOnline.value = res.isConnected
|
isOnline.value = res.isConnected
|
||||||
|
|
||||||
// 如果从离线恢复到在线,自动同步(防抖避免网络抖动重复触发)
|
// 如果从离线恢复到在线,自动同步(防抖避免网络抖动重复触发)
|
||||||
if (wasOffline && isOnline.value && pendingActions.value.length > 0) {
|
if (wasOffline && isOnline.value && pendingActions.value.length > 0) {
|
||||||
debouncedSync()
|
debouncedSync()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检查当前网络状态
|
// 检查当前网络状态
|
||||||
uni.getNetworkType({
|
uni.getNetworkType({
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
isOnline.value = res.networkType !== 'none'
|
isOnline.value = res.networkType !== 'none'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听余额更新事件
|
||||||
|
uni.$on('balanceUpdated', (data) => {
|
||||||
|
if (userInfo.value) {
|
||||||
|
userInfo.value.crystal_balance = data.crystal_balance
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (networkListener) uni.offNetworkStatusChange(networkListener)
|
if (networkListener) uni.offNetworkStatusChange(networkListener)
|
||||||
if (syncDebounceTimer) clearTimeout(syncDebounceTimer)
|
if (syncDebounceTimer) clearTimeout(syncDebounceTimer)
|
||||||
if (resultToastTimer) clearTimeout(resultToastTimer)
|
if (resultToastTimer) clearTimeout(resultToastTimer)
|
||||||
|
uni.$off('balanceUpdated')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 存储键前缀
|
// 存储键前缀
|
||||||
@ -173,7 +225,7 @@ async function loadPendingActions() {
|
|||||||
// 先检查 key 是否存在
|
// 先检查 key 是否存在
|
||||||
const info = uni.getStorageInfoSync()
|
const info = uni.getStorageInfoSync()
|
||||||
const key = `${STORAGE_PREFIX}pending_actions_${props.activityId}`
|
const key = `${STORAGE_PREFIX}pending_actions_${props.activityId}`
|
||||||
|
|
||||||
if (!info.keys.includes(key)) {
|
if (!info.keys.includes(key)) {
|
||||||
return // key 不存在,直接返回
|
return // key 不存在,直接返回
|
||||||
}
|
}
|
||||||
@ -181,11 +233,11 @@ async function loadPendingActions() {
|
|||||||
const cached = await uni.getStorage({
|
const cached = await uni.getStorage({
|
||||||
key: `${STORAGE_PREFIX}pending_actions_${props.activityId}`
|
key: `${STORAGE_PREFIX}pending_actions_${props.activityId}`
|
||||||
})
|
})
|
||||||
|
|
||||||
if (cached && cached[1]?.data && Array.isArray(cached[1].data)) {
|
if (cached && cached[1]?.data && Array.isArray(cached[1].data)) {
|
||||||
pendingActions.value = cached[1].data
|
pendingActions.value = cached[1].data
|
||||||
hasFailedActions.value = cached[1].data.length > 0
|
hasFailedActions.value = cached[1].data.length > 0
|
||||||
|
|
||||||
// 如果在线,尝试同步
|
// 如果在线,尝试同步
|
||||||
if (isOnline.value && cached[1].data.length > 0) {
|
if (isOnline.value && cached[1].data.length > 0) {
|
||||||
syncPendingActions()
|
syncPendingActions()
|
||||||
@ -209,31 +261,86 @@ async function savePendingActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理道具点击
|
// 处理道具选择(第一次点击)
|
||||||
async function handleClick(item) {
|
function handleItemSelect(item) {
|
||||||
|
selectedItem.value = item.type
|
||||||
|
quantity.value = 1
|
||||||
|
isContributing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加数量
|
||||||
|
function increaseQuantity() {
|
||||||
|
quantity.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 减少数量
|
||||||
|
function decreaseQuantity() {
|
||||||
|
if (quantity.value > 1) {
|
||||||
|
quantity.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理直接贡献(点击"点击赠送",不需要确认)
|
||||||
|
async function handleDirectContribute(item) {
|
||||||
|
// 防止同一道具重复点击
|
||||||
|
if (processingItems.has(item.type) || isContributing.value) return
|
||||||
|
processingItems.add(item.type)
|
||||||
|
isContributing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证余额
|
||||||
|
const hasBalance = await validateBalance(item.cost)
|
||||||
|
if (!hasBalance) {
|
||||||
|
showResultToast('', '余额不足')
|
||||||
|
// 余额不足时直接返回,不调用API
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接调用贡献API
|
||||||
|
const success = await contributeItem(item)
|
||||||
|
if (!success) {
|
||||||
|
// 贡献失败时不显示额外提示,由 contributeItem 内部处理
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processingItems.delete(item.type)
|
||||||
|
isContributing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理确认贡献(点击"确认赠送")
|
||||||
|
async function handleConfirmContribute() {
|
||||||
|
const item = items.value.find(i => i.type === selectedItem.value)
|
||||||
|
if (!item || isContributing.value) return
|
||||||
|
|
||||||
// 防止同一道具重复点击
|
// 防止同一道具重复点击
|
||||||
if (processingItems.has(item.type)) return
|
if (processingItems.has(item.type)) return
|
||||||
processingItems.add(item.type)
|
processingItems.add(item.type)
|
||||||
|
isContributing.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 验证余额前给一个轻量反馈
|
// 验证余额(乘以数量)
|
||||||
uni.showLoading({ title: '请稍候...', mask: true })
|
const totalCost = item.cost * quantity.value
|
||||||
const hasBalance = await validateBalance(item.cost)
|
const hasBalance = await validateBalance(totalCost)
|
||||||
uni.hideLoading()
|
|
||||||
|
|
||||||
if (!hasBalance) {
|
if (!hasBalance) {
|
||||||
showResultToast('', '余额不足')
|
showResultToast('', '余额不足')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹出确认框,提示消耗钻石数量
|
// 弹出确认框
|
||||||
const confirmed = await showConfirmModal(item)
|
const confirmed = await showConfirmModal({ ...item, cost: totalCost })
|
||||||
if (!confirmed) return
|
if (!confirmed) {
|
||||||
|
return
|
||||||
// 调用贡献API
|
}
|
||||||
await contributeItem(item)
|
|
||||||
|
// 调用贡献API多次
|
||||||
|
for (let i = 0; i < quantity.value; i++) {
|
||||||
|
await contributeItem(item, false, true)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
processingItems.delete(item.type)
|
processingItems.delete(item.type)
|
||||||
|
isContributing.value = false
|
||||||
|
// 注意:不清除 selectedItem,让用户可以继续赠送或选择其他道具
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,33 +362,33 @@ async function validateBalance(cost) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 贡献道具
|
// 贡献道具
|
||||||
async function contributeItem(item, isRetry = false) {
|
async function contributeItem(item, isRetry = false, silent = false) {
|
||||||
try {
|
try {
|
||||||
// 使用 activity-config.js 中的 purchaseItem 函数
|
// 使用 activity-config.js 中的 purchaseItem 函数
|
||||||
const result = await purchaseItem(props.activityId, item.type, 1)
|
const result = await purchaseItem(props.activityId, item.type, 1)
|
||||||
|
|
||||||
// 检查购买结果
|
// 检查购买结果
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
showResultToast('', result.message || '活动不在进行中,无法购买')
|
if (!silent) showResultToast('', result.message || '活动不在进行中,无法购买')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成功:触发反馈动画(重试时静默,不触发)
|
// 成功:触发反馈动画(重试时静默,不触发)
|
||||||
if (!isRetry) {
|
if (!isRetry && !silent) {
|
||||||
feedbackItem.value = item.type
|
feedbackItem.value = item.type
|
||||||
setTimeout(() => { feedbackItem.value = null }, 800)
|
setTimeout(() => { feedbackItem.value = null }, 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新本地用户余额:非重试时直接用服务端余额;重试时由 syncPendingActions 统一处理
|
// 更新本地用户余额:非重试时直接用服务端余额;重试时由 syncPendingActions 统一处理
|
||||||
if (!isRetry) {
|
if (!isRetry && !silent) {
|
||||||
await updateLocalBalanceFromResult(result.remainingBalance)
|
await updateLocalBalanceFromResult(result.remainingBalance)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知父组件更新进度(使用返回的当前进度)
|
// 通知父组件更新进度(使用返回的当前进度)
|
||||||
emit('contribute', item.type, result.currentProgress)
|
emit('contribute', item.type, result.currentProgress)
|
||||||
|
|
||||||
// 如果是重试成功,不单独弹 toast,由 syncPendingActions 统一汇总提示
|
// 如果是重试成功或静默模式,不单独弹 toast
|
||||||
if (!isRetry) {
|
if (!isRetry && !silent) {
|
||||||
showResultToast('✅', `贡献值 +${result.totalContribution}`)
|
showResultToast('✅', `贡献值 +${result.totalContribution}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,7 +398,7 @@ async function contributeItem(item, isRetry = false) {
|
|||||||
console.error('贡献失败:', error)
|
console.error('贡献失败:', error)
|
||||||
|
|
||||||
// 如果不是重试操作,先乐观扣除本地余额再入队
|
// 如果不是重试操作,先乐观扣除本地余额再入队
|
||||||
if (!isRetry) {
|
if (!isRetry && !silent) {
|
||||||
deductLocalBalance(item.cost)
|
deductLocalBalance(item.cost)
|
||||||
const queued = addToPendingQueue(item)
|
const queued = addToPendingQueue(item)
|
||||||
if (!queued) {
|
if (!queued) {
|
||||||
@ -339,10 +446,10 @@ function deductLocalBalance(cost) {
|
|||||||
} else {
|
} else {
|
||||||
user = { ...userStr }
|
user = { ...userStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扣除余额,确保不为负数
|
// 扣除余额,确保不为负数
|
||||||
user.crystal_balance = Math.max(0, (Number(user.crystal_balance) || 0) - cost)
|
user.crystal_balance = Math.max(0, (Number(user.crystal_balance) || 0) - cost)
|
||||||
|
|
||||||
// 保存回存储
|
// 保存回存储
|
||||||
uni.setStorageSync('user', JSON.stringify(user))
|
uni.setStorageSync('user', JSON.stringify(user))
|
||||||
userInfo.value = user
|
userInfo.value = user
|
||||||
@ -365,10 +472,10 @@ function refundLocalBalance(cost) {
|
|||||||
} else {
|
} else {
|
||||||
user = { ...userStr }
|
user = { ...userStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退还余额
|
// 退还余额
|
||||||
user.crystal_balance = (Number(user.crystal_balance) || 0) + cost
|
user.crystal_balance = (Number(user.crystal_balance) || 0) + cost
|
||||||
|
|
||||||
// 保存回存储
|
// 保存回存储
|
||||||
uni.setStorageSync('user', JSON.stringify(user))
|
uni.setStorageSync('user', JSON.stringify(user))
|
||||||
userInfo.value = user
|
userInfo.value = user
|
||||||
@ -390,10 +497,10 @@ async function updateLocalBalanceFromResult(newBalance) {
|
|||||||
// 如果已经是对象,创建一个新副本避免引用问题
|
// 如果已经是对象,创建一个新副本避免引用问题
|
||||||
user = { ...userStr }
|
user = { ...userStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新余额,确保是数字类型
|
// 更新余额,确保是数字类型
|
||||||
user.crystal_balance = Number(newBalance) || 0
|
user.crystal_balance = Number(newBalance) || 0
|
||||||
|
|
||||||
// 保存回存储,确保是字符串格式
|
// 保存回存储,确保是字符串格式
|
||||||
uni.setStorageSync('user', JSON.stringify(user))
|
uni.setStorageSync('user', JSON.stringify(user))
|
||||||
userInfo.value = user
|
userInfo.value = user
|
||||||
@ -409,12 +516,12 @@ async function syncPendingActions() {
|
|||||||
if (pendingActions.value.length === 0) {
|
if (pendingActions.value.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uni.showLoading({
|
uni.showLoading({
|
||||||
title: '同步中...',
|
title: '同步中...',
|
||||||
mask: true
|
mask: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const actions = [...pendingActions.value]
|
const actions = [...pendingActions.value]
|
||||||
const failedActions = []
|
const failedActions = []
|
||||||
const CONCURRENCY = 3
|
const CONCURRENCY = 3
|
||||||
@ -460,11 +567,11 @@ async function syncPendingActions() {
|
|||||||
await updateLocalBalanceFromResult(minRemainingBalance)
|
await updateLocalBalanceFromResult(minRemainingBalance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新队列
|
// 更新队列
|
||||||
pendingActions.value = failedActions
|
pendingActions.value = failedActions
|
||||||
savePendingActions()
|
savePendingActions()
|
||||||
|
|
||||||
// 显示结果(自定义 toast,完全控制尺寸避免系统截断)
|
// 显示结果(自定义 toast,完全控制尺寸避免系统截断)
|
||||||
const successCount = actions.length - failedActions.length
|
const successCount = actions.length - failedActions.length
|
||||||
if (successCount > 0 && failedActions.length === 0) {
|
if (successCount > 0 && failedActions.length === 0) {
|
||||||
@ -492,84 +599,221 @@ defineExpose({
|
|||||||
/* 整体定位 */
|
/* 整体定位 */
|
||||||
.action-bar {
|
.action-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: fixed;
|
min-height: 13rem;
|
||||||
bottom: 0; /* 通常底部栏贴底,通过 padding 留出安全区 */
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 核心容器:模仿图中的粉色渐变长条 */
|
/* 核心容器:模仿图中的粉色渐变长条 */
|
||||||
.bar-container {
|
.bar-container {
|
||||||
min-width: 50%;
|
width: 100%;
|
||||||
background: linear-gradient(to bottom right,
|
background-image: url('@/static/rank/activity-support-icon/beijingkuang.png');
|
||||||
#F0E4B1 0%, /* 左:浅橙粉 */
|
background-size: 105% 130%;
|
||||||
#F08399 50%,
|
background-position: center;
|
||||||
#B94E73 100% /* 右:柔粉红 */
|
border-radius: 40rpx 40rpx 0 0;
|
||||||
);
|
|
||||||
border-radius: 40rpx;
|
|
||||||
padding: 20rpx 40rpx;
|
padding: 20rpx 40rpx;
|
||||||
box-shadow: 0 10rpx 30rpx rgba(255, 107, 157, 0.4);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
justify-content: space-between;
|
}
|
||||||
|
|
||||||
|
/* 用户信息区域 */
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 128rpx;
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
border-bottom: 1rpx solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3rpx solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-balance {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-icon {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 道具行容器 */
|
||||||
|
.items-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 16rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-item {
|
.action-item {
|
||||||
width:120rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: transform 0.1s;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-item:active {
|
/* 选中状态 */
|
||||||
transform: scale(0.95);
|
.action-item.active .icon-wrapper,
|
||||||
|
.action-item.active .name-wrapper,
|
||||||
|
.action-item.active .cost-wrapper {
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图标包装器:白色圆角背景 */
|
/* 选中背景 */
|
||||||
|
.item-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
|
||||||
|
background: linear-gradient(to bottom right,
|
||||||
|
#F0E4B1 0%,
|
||||||
|
#F08399 50%,
|
||||||
|
#B94E73 100%);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
|
||||||
|
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数量选择器和确认按钮 */
|
||||||
|
.quantity-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
/* justify-content: center; */
|
||||||
|
gap: 24rpx;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
border-top: 1rpx solid rgba(255, 255, 255, 0.3);
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 40rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-btn {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(to bottom right, #F0E4B1 0%, #F08399 50%, #B94E73 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-input {
|
||||||
|
width: 60rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
background-image: url('@/static/rank/activity-support-icon/beijingkuang.png');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
padding: 16rpx 40rpx;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(255, 143, 158, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标包装器 */
|
||||||
.icon-wrapper {
|
.icon-wrapper {
|
||||||
width: 100rpx;
|
width: 120rpx;
|
||||||
height: 100rpx;
|
height: 120rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 8rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-icon {
|
.item-icon {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transform: scale(2);
|
transform: scale(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 名称包装器 */
|
||||||
|
.name-wrapper {
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 价格包装器:横向排列钻石和数字 */
|
/* 价格包装器:横向排列钻石和数字 */
|
||||||
.cost-wrapper {
|
.cost-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8rpx;
|
gap: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 钻石图标样式 */
|
/* 钻石图标样式 */
|
||||||
.diamond-icon {
|
.diamond-icon {
|
||||||
width: 56rpx;
|
width: 40rpx;
|
||||||
height: 56rpx;
|
height: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-cost {
|
.item-cost {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #FFA500; /* 橙色,接近图中 LISA 的颜色 */
|
color: #fff;
|
||||||
text-shadow:
|
/* 橙色,接近图中 LISA 的颜色 */
|
||||||
0 3rpx 6rpx rgba(0, 0, 0, 0.4),
|
text-shadow:
|
||||||
0 1rpx 3rpx rgba(0, 0, 0, 0.3);
|
0 3rpx 6rpx rgba(0, 0, 0, 0.4),
|
||||||
font-family: 'yt', sans-serif;
|
0 1rpx 3rpx rgba(0, 0, 0, 0.3);
|
||||||
|
font-family: 'yt', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 以下保持原有逻辑样式 --- */
|
/* --- 以下保持原有逻辑样式 --- */
|
||||||
@ -592,7 +836,8 @@ defineExpose({
|
|||||||
|
|
||||||
.retry-banner {
|
.retry-banner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -80rpx; /* 调整位置以免遮挡渐变条 */
|
top: -80rpx;
|
||||||
|
/* 调整位置以免遮挡渐变条 */
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: rgba(255, 107, 157, 0.9);
|
background: rgba(255, 107, 157, 0.9);
|
||||||
@ -610,9 +855,20 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes feedback-pop {
|
@keyframes feedback-pop {
|
||||||
0% { opacity: 0; transform: scale(0.5) translateY(0); }
|
0% {
|
||||||
50% { opacity: 1; transform: scale(1.2) translateY(-40rpx); }
|
opacity: 0;
|
||||||
100% { opacity: 0; transform: scale(1) translateY(-80rpx); }
|
transform: scale(0.5) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.2) translateY(-40rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1) translateY(-80rpx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-toast {
|
.result-toast {
|
||||||
|
|||||||
BIN
frontend/static/rank/activity-support-icon/Ndengji.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/static/rank/activity-support-icon/Rdengji.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/static/rank/activity-support-icon/SRdengji.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/static/rank/activity-support-icon/SSRdengji.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/static/rank/activity-support-icon/URengji.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 198 KiB |
BIN
frontend/static/rank/activity-support-icon/beijingkuang.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
frontend/static/rank/activity-support-icon/beijingkuang1.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
frontend/static/rank/activity-support-icon/djbeij.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/static/rank/activity-support-icon/djbeij.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
frontend/static/rank/activity-support-icon/huangguan.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
frontend/static/rank/activity-support-icon/lihe.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 630 KiB |
BIN
frontend/static/rank/activity-support-icon/shuijingzhixin.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
frontend/static/rank/activity-support-icon/tubiao.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
frontend/static/rank/activity-support-icon/xingxing.png
Normal file
|
After Width: | Height: | Size: 96 KiB |