feat: 新增图片,修改应援消耗道具

This commit is contained in:
zheng020 2026-05-14 12:17:50 +08:00
parent 7e1da61397
commit 1af0a0527a
20 changed files with 1615 additions and 102 deletions

View File

@ -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条记录
- 贡献项格式:头像 + 昵称 + "贡献了" + 道具图标 + 名称 + 数量

View File

@ -0,0 +1,691 @@
# 用户贡献实时显示方案
## 1. 概述
### 1.1 需求描述
在活动页面实时展示所有用户的贡献动态,包括:
- 贡献者头像 / 昵称
- 道具图标 / 名称
- 贡献数量
### 1.2 展示形式
- **列表形式**:最新贡献显示在顶部
- **生命周期**:页面可见时显示,不可见时暂停
- **消失逻辑**:每条记录 5 秒无新数据则逐条淡出消失
### 1.3 技术方案
- 后端Gateway 层新增 HTTP APIActivityService 处理业务逻辑
- 前端:轮询 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 + GinGateway+ Dubbo-go微服务+ GORM
- [x] 数据库MySQL
- [x] ORMGORM
- [x] 冗余字段user_nickname、user_avatar、item_name、item_icon 直接存在表中
- [x] 不需要"几秒前"等相对时间显示,只显示实时贡献
- [x] 轮询使用时间戳since_timestamp而非 ID避免重复拉取
- [ ] 列表视觉样式是否有设计稿?

View File

@ -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)

View File

@ -1,35 +1,62 @@
<template>
<view class="action-bar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
<!-- 钻石消耗确认弹窗 -->
<DiamondConfirmModal
:visible="confirmModal.visible"
:itemLabel="confirmModal.itemLabel"
:itemCost="confirmModal.itemCost"
@confirm="handleModalConfirm"
@cancel="handleModalCancel"
/>
<DiamondConfirmModal :visible="confirmModal.visible" :itemLabel="confirmModal.itemLabel"
:itemCost="confirmModal.itemCost" @confirm="handleModalConfirm" @cancel="handleModalCancel" />
<!-- 主容器 -->
<view class="bar-container">
<view
v-for="item in items"
:key="item.type"
class="action-item"
@click="handleClick(item)"
>
<!-- 图标区域添加白色背景和阴影 -->
<view class="icon-wrapper">
<image :src="item.icon" class="item-icon" mode="aspectFill" lazy-load />
<!-- 用户信息区域 -->
<view class="user-info">
<image :src="userInfo?.avatar_url || '/static/icon/avatar-default.png'" class="user-avatar" mode="aspectFill" />
<view class="user-balance">
<image src="/static/icon/crystal.png" class="balance-icon" mode="aspectFit" />
<text class="balance-text">{{ userInfo?.crystal_balance || 0 }}</text>
</view>
</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 class="items-row">
<view v-for="item in items" :key="item.type" class="action-item" :class="{ active: selectedItem === item.type }"
@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 v-if="feedbackItem === item.type" class="feedback-layer">
<view class="feedback-icon" :style="{ color: itemFeedbackColor(item.type) }">+1</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>
@ -74,6 +101,9 @@ const items = computed(() => props.actionItems)
const safeAreaBottom = ref(0)
const feedbackItem = ref(null) // type
const selectedItem = ref(null) //
const quantity = ref(1) //
const isContributing = ref(false) //
// type
const FEEDBACK_COLORS = ['#FFD700', '#00CFFF', '#FF6B9D', '#A78BFA', '#34D399']
@ -118,6 +148,17 @@ function handleModalCancel() {
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 syncDebounceTimer = null
@ -136,6 +177,9 @@ onMounted(() => {
const systemInfo = uni.getSystemInfoSync()
safeAreaBottom.value = systemInfo.safeAreaInsets?.bottom || 0
//
loadUserInfo()
// 线
loadPendingActions()
@ -156,12 +200,20 @@ onMounted(() => {
isOnline.value = res.networkType !== 'none'
}
})
//
uni.$on('balanceUpdated', (data) => {
if (userInfo.value) {
userInfo.value.crystal_balance = data.crystal_balance
}
})
})
onUnmounted(() => {
if (networkListener) uni.offNetworkStatusChange(networkListener)
if (syncDebounceTimer) clearTimeout(syncDebounceTimer)
if (resultToastTimer) clearTimeout(resultToastTimer)
uni.$off('balanceUpdated')
})
//
@ -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
processingItems.add(item.type)
isContributing.value = true
try {
//
uni.showLoading({ title: '请稍候...', mask: true })
const hasBalance = await validateBalance(item.cost)
uni.hideLoading()
//
const totalCost = item.cost * quantity.value
const hasBalance = await validateBalance(totalCost)
if (!hasBalance) {
showResultToast('', '余额不足')
return
}
//
const confirmed = await showConfirmModal(item)
if (!confirmed) return
//
const confirmed = await showConfirmModal({ ...item, cost: totalCost })
if (!confirmed) {
return
}
// API
await contributeItem(item)
// API
for (let i = 0; i < quantity.value; i++) {
await contributeItem(item, false, true)
}
} finally {
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 {
// 使 activity-config.js purchaseItem
const result = await purchaseItem(props.activityId, item.type, 1)
//
if (!result.success) {
showResultToast('', result.message || '活动不在进行中,无法购买')
if (!silent) showResultToast('', result.message || '活动不在进行中,无法购买')
return false
}
//
if (!isRetry) {
if (!isRetry && !silent) {
feedbackItem.value = item.type
setTimeout(() => { feedbackItem.value = null }, 800)
}
// syncPendingActions
if (!isRetry) {
if (!isRetry && !silent) {
await updateLocalBalanceFromResult(result.remainingBalance)
}
// 使
emit('contribute', item.type, result.currentProgress)
// toast syncPendingActions
if (!isRetry) {
// toast
if (!isRetry && !silent) {
showResultToast('✅', `贡献值 +${result.totalContribution}`)
}
@ -291,7 +398,7 @@ async function contributeItem(item, isRetry = false) {
console.error('贡献失败:', error)
//
if (!isRetry) {
if (!isRetry && !silent) {
deductLocalBalance(item.cost)
const queued = addToPendingQueue(item)
if (!queued) {
@ -492,84 +599,221 @@ defineExpose({
/* 整体定位 */
.action-bar {
width: 100%;
position: fixed;
bottom: 0; /* 通常底部栏贴底,通过 padding 留出安全区 */
left: 0;
right: 0;
z-index: 100;
min-height: 13rem;
display: flex;
justify-content: center;
}
/* 核心容器:模仿图中的粉色渐变长条 */
.bar-container {
min-width: 50%;
background: linear-gradient(to bottom right,
#F0E4B1 0%, /* 左:浅橙粉 */
#F08399 50%,
#B94E73 100% /* 右:柔粉红 */
);
border-radius: 40rpx;
width: 100%;
background-image: url('@/static/rank/activity-support-icon/beijingkuang.png');
background-size: 105% 130%;
background-position: center;
border-radius: 40rpx 40rpx 0 0;
padding: 20rpx 40rpx;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 157, 0.4);
display: flex;
flex-direction: column;
gap: 24rpx;
justify-content: space-between;
}
/* 用户信息区域 */
.user-info {
display: flex;
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 {
width:120rpx;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
transition: transform 0.1s;
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 {
width: 100rpx;
height: 100rpx;
width: 120rpx;
height: 120rpx;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 16rpx;
margin-bottom: 8rpx;
overflow: hidden;
}
.item-icon {
width: 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 {
display: flex;
align-items: center;
gap: 8rpx;
gap: 4rpx;
}
/* 钻石图标样式 */
.diamond-icon {
width: 56rpx;
height: 56rpx;
width: 40rpx;
height: 40rpx;
}
.item-cost {
font-size: 24rpx;
color: #FFA500; /* 橙色,接近图中 LISA 的颜色 */
text-shadow:
0 3rpx 6rpx rgba(0, 0, 0, 0.4),
0 1rpx 3rpx rgba(0, 0, 0, 0.3);
font-family: 'yt', sans-serif;
color: #fff;
/* 橙色,接近图中 LISA 的颜色 */
text-shadow:
0 3rpx 6rpx rgba(0, 0, 0, 0.4),
0 1rpx 3rpx rgba(0, 0, 0, 0.3);
font-family: 'yt', sans-serif;
}
/* --- 以下保持原有逻辑样式 --- */
@ -592,7 +836,8 @@ defineExpose({
.retry-banner {
position: absolute;
top: -80rpx; /* 调整位置以免遮挡渐变条 */
top: -80rpx;
/* 调整位置以免遮挡渐变条 */
left: 50%;
transform: translateX(-50%);
background: rgba(255, 107, 157, 0.9);
@ -610,9 +855,20 @@ defineExpose({
}
@keyframes feedback-pop {
0% { opacity: 0; transform: scale(0.5) translateY(0); }
50% { opacity: 1; transform: scale(1.2) translateY(-40rpx); }
100% { opacity: 0; transform: scale(1) translateY(-80rpx); }
0% {
opacity: 0;
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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB