style:修改样式
This commit is contained in:
parent
b269fbd075
commit
3f8f27166c
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Skill(superpowers:subagent-driven-development)",
|
||||||
|
"Skill(superpowers:subagent-driven-development:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,716 @@
|
|||||||
|
# ActivityRankingModal 实现计划
|
||||||
|
|
||||||
|
> **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:** 创建 `ActivityRankingModal.vue` 弹窗组件,从 `ThemeBanner` 点击触发,显示单活动的排名数据
|
||||||
|
|
||||||
|
**Architecture:** 复用 `RankingModal.vue` 的现有样式主题和数据转换逻辑,创建专用于活动排名的简化弹窗组件
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3 Composition API, uni-app, 复用现有 RankingModal/TOP3Card/RankingListItem 组件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/pages/support-activity/
|
||||||
|
├── components/
|
||||||
|
│ ├── ActivityRankingModal.vue ← 新建
|
||||||
|
│ ├── ThemeBanner.vue ← 修改:添加点击事件触发弹窗
|
||||||
|
│ ├── TOP3Card.vue ← 复用(来自 pages/components/)
|
||||||
|
│ └── RankingListItem.vue ← 复用(来自 pages/components/)
|
||||||
|
└── index.vue ← 修改:引入并使用 ActivityRankingModal
|
||||||
|
|
||||||
|
frontend/utils/api.js ← 无需修改(getActivityRankingApi 已存在)
|
||||||
|
docs/superpowers/specs/2026-05-14-activity-ranking-modal-design.md ← 已存在
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务分解
|
||||||
|
|
||||||
|
### Task 1: 创建 ActivityRankingModal.vue 组件
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
- 创建: `frontend/pages/support-activity/components/ActivityRankingModal.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建基础模板和样式结构**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<view v-if="visible" class="modal-wrapper">
|
||||||
|
<transition name="fade">
|
||||||
|
<view v-if="visible" class="modal-mask" @tap="handleClose"></view>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<transition name="slide-up">
|
||||||
|
<view v-if="visible" class="modal-container" @tap.stop>
|
||||||
|
<!-- 背景 -->
|
||||||
|
<view class="modal-background"></view>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<view class="modal-content">
|
||||||
|
<!-- 头部 -->
|
||||||
|
<view class="header-section">
|
||||||
|
<text class="activity-title">{{ activityTitle }}</text>
|
||||||
|
<view class="close-btn" @tap="handleClose">
|
||||||
|
<text class="close-icon">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 滚动内容区 -->
|
||||||
|
<scroll-view
|
||||||
|
class="scrollable-content"
|
||||||
|
scroll-y="true"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
:refresher-enabled="true"
|
||||||
|
:refresher-triggered="isRefreshing"
|
||||||
|
@refresherrefresh="handleRefresh"
|
||||||
|
@scrolltolower="handleScrollToLower"
|
||||||
|
:lower-threshold="100"
|
||||||
|
>
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<view v-if="isLoading && !isRefreshing" class="loading-container">
|
||||||
|
<view class="loading-spinner"></view>
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<view v-else-if="errorMessage" class="error-container">
|
||||||
|
<text class="error-text">{{ errorMessage }}</text>
|
||||||
|
<button class="retry-button" @tap="loadRankingData">重试</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- TOP3 区域 -->
|
||||||
|
<view v-else-if="top3Users.length > 0" class="top3-section">
|
||||||
|
<TOP3Card
|
||||||
|
v-for="user in top3Users"
|
||||||
|
:key="user.userId"
|
||||||
|
:rank="user.rank"
|
||||||
|
:avatar="user.avatar"
|
||||||
|
:nickname="user.nickname"
|
||||||
|
:popularityScore="user.popularityScore"
|
||||||
|
:artworkImage="user.artworkImage"
|
||||||
|
:userId="user.userId"
|
||||||
|
:isCurrentUser="isCurrentUser(user.userId)"
|
||||||
|
@visit="handleVisit"
|
||||||
|
@view-profile="handleViewProfile"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-if="!isLoading && !errorMessage && rankingData.length === 0" class="empty-data">
|
||||||
|
<text class="empty-text">暂无排名数据</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 排名列表 -->
|
||||||
|
<view v-if="listUsers.length > 0" class="ranking-list-section">
|
||||||
|
<RankingListItem
|
||||||
|
v-for="item in listUsers"
|
||||||
|
:key="item.userId"
|
||||||
|
:rank="item.rank"
|
||||||
|
:userId="item.userId"
|
||||||
|
:avatar="item.avatar"
|
||||||
|
:nickname="item.nickname"
|
||||||
|
:popularityScore="item.popularityScore"
|
||||||
|
:artworkImage="item.artworkImage"
|
||||||
|
:artworkId="item.artworkId"
|
||||||
|
:showVisitButton="!isCurrentUser(item.userId)"
|
||||||
|
:isCurrentUser="isCurrentUser(item.userId)"
|
||||||
|
@visit="handleVisit"
|
||||||
|
@view-profile="handleViewProfile"
|
||||||
|
@artwork-click="handleArtworkClick"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载更多 -->
|
||||||
|
<view v-if="isLoadingMore" class="loading-more-container">
|
||||||
|
<view class="loading-spinner-small"></view>
|
||||||
|
<text class="loading-more-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部占位 -->
|
||||||
|
<view class="bottom-spacer"></view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 当前用户栏 -->
|
||||||
|
<view v-if="!isLoading" class="current-user-bar">
|
||||||
|
<view class="current-user-content">
|
||||||
|
<image
|
||||||
|
class="current-user-avatar"
|
||||||
|
:src="currentUserInfo.avatar"
|
||||||
|
mode="aspectFill"
|
||||||
|
@error="handleCurrentUserAvatarError"
|
||||||
|
/>
|
||||||
|
<view class="current-user-info">
|
||||||
|
<view class="current-user-score">
|
||||||
|
<image class="flame-icon" src="/static/rank/spark.png" mode="aspectFit"></image>
|
||||||
|
<text class="score-text">{{ formatPopularityScore(currentUserInfo.popularityScore) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="current-user-rank">
|
||||||
|
<text class="rank-text">{{ formatCurrentUserRank(currentUserInfo.rank) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</transition>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 实现 script 部分**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { getActivityRankingApi } from '@/utils/api.js'
|
||||||
|
import TOP3Card from '../../components/TOP3Card.vue'
|
||||||
|
import RankingListItem from '../../components/RankingListItem.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
activityId: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
starId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
activityTitle: {
|
||||||
|
type: String,
|
||||||
|
default: '活动排名'
|
||||||
|
},
|
||||||
|
currentUser: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'visit', 'view-profile', 'view-artwork'])
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const rankingData = ref([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isRefreshing = ref(false)
|
||||||
|
const isLoadingMore = ref(false)
|
||||||
|
const hasNoMoreData = ref(false)
|
||||||
|
const errorMessage = ref(null)
|
||||||
|
const currentUserInfo = ref({
|
||||||
|
userId: 'currentUser',
|
||||||
|
avatar: '/static/avatar/1.jpeg',
|
||||||
|
nickname: '我',
|
||||||
|
popularityScore: 0,
|
||||||
|
rank: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const pageInfo = ref({ currentPage: 1, hasMore: true })
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
// 计算属性:TOP3 用户
|
||||||
|
const top3Users = computed(() => {
|
||||||
|
return rankingData.value
|
||||||
|
.filter(user => user.rank >= 1 && user.rank <= 3)
|
||||||
|
.sort((a, b) => a.rank - b.rank)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算属性:列表用户(第4名及以后)
|
||||||
|
const listUsers = computed(() => {
|
||||||
|
return rankingData.value
|
||||||
|
.filter(user => user.rank >= 4)
|
||||||
|
.sort((a, b) => a.rank - b.rank)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 数据转换函数(复用 RankingModal 逻辑)
|
||||||
|
const transformActivityRankingData = async (apiResponse) => {
|
||||||
|
if (!apiResponse || !apiResponse.data) return []
|
||||||
|
|
||||||
|
const { items } = apiResponse.data
|
||||||
|
if (!Array.isArray(items)) return []
|
||||||
|
|
||||||
|
return items.map(item => ({
|
||||||
|
rank: item.rank,
|
||||||
|
userId: String(item.user_id),
|
||||||
|
avatar: item.avatar_url || '/static/avatar/1.jpeg',
|
||||||
|
nickname: item.nickname || '未知用户',
|
||||||
|
popularityScore: item.total_contribution || 0,
|
||||||
|
artworkImage: '',
|
||||||
|
artworkId: null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换当前用户数据
|
||||||
|
const transformMyActivityContribution = async (myContribution) => {
|
||||||
|
return {
|
||||||
|
userId: 'currentUser',
|
||||||
|
avatar: myContribution?.avatar_url || '/static/avatar/1.jpeg',
|
||||||
|
popularityScore: myContribution?.total_contribution || 0,
|
||||||
|
rank: myContribution?.rank > 0 ? myContribution.rank : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载排名数据
|
||||||
|
const loadRankingData = async (page = 1, isRefreshAction = false) => {
|
||||||
|
try {
|
||||||
|
if (page === 1 && !isRefreshAction) {
|
||||||
|
isLoading.value = true
|
||||||
|
} else if (page > 1) {
|
||||||
|
isLoadingMore.value = true
|
||||||
|
}
|
||||||
|
errorMessage.value = null
|
||||||
|
|
||||||
|
const starId = props.starId || uni.getStorageSync('star_id')
|
||||||
|
const apiResponse = await getActivityRankingApi(props.activityId, starId, page, PAGE_SIZE)
|
||||||
|
|
||||||
|
if (apiResponse && apiResponse.code === 200 && apiResponse.data) {
|
||||||
|
const transformedData = await transformActivityRankingData(apiResponse)
|
||||||
|
|
||||||
|
if (page === 1) {
|
||||||
|
rankingData.value = transformedData
|
||||||
|
pageInfo.value = { currentPage: 1, hasMore: transformedData.length >= PAGE_SIZE }
|
||||||
|
} else {
|
||||||
|
rankingData.value.push(...transformedData)
|
||||||
|
pageInfo.value.currentPage = page
|
||||||
|
pageInfo.value.hasMore = transformedData.length >= PAGE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNoMoreData.value = !pageInfo.value.hasMore
|
||||||
|
|
||||||
|
// 处理当前用户数据
|
||||||
|
if (page === 1 && apiResponse.data.my_contribution) {
|
||||||
|
currentUserInfo.value = await transformMyActivityContribution(apiResponse.data.my_contribution)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
throw new Error(apiResponse?.message || '获取排名失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load ranking:', error)
|
||||||
|
errorMessage.value = error.message || '加载失败'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
if (page === 1 && !isRefreshAction) {
|
||||||
|
isLoading.value = false
|
||||||
|
} else if (page > 1) {
|
||||||
|
isLoadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (isRefreshing.value) return
|
||||||
|
|
||||||
|
isRefreshing.value = true
|
||||||
|
const success = await loadRankingData(1, true)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
uni.showToast({ title: '刷新成功', icon: 'success', duration: 1500 })
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '刷新失败', icon: 'none', duration: 2000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => { isRefreshing.value = false }, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动加载更多
|
||||||
|
const handleScrollToLower = async () => {
|
||||||
|
if (isLoadingMore.value || hasNoMoreData.value) return
|
||||||
|
|
||||||
|
isLoadingMore.value = true
|
||||||
|
await loadRankingData(pageInfo.value.currentPage + 1)
|
||||||
|
isLoadingMore.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
const formatPopularityScore = (score) => {
|
||||||
|
if (typeof score !== 'number' || isNaN(score) || score < 0) return '0'
|
||||||
|
if (score >= 1000000) return (score / 1000000).toFixed(1) + 'M'
|
||||||
|
if (score >= 1000) return (score / 1000).toFixed(1) + 'K'
|
||||||
|
return score.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrentUserRank = (rank) => {
|
||||||
|
if (!rank || rank <= 0) return '未上榜'
|
||||||
|
return `第${rank}名`
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentUser = (userId) => userId === currentUserInfo.value.userId
|
||||||
|
|
||||||
|
const handleCurrentUserAvatarError = (e) => {
|
||||||
|
e.target.src = '/static/avatar/1.jpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件处理
|
||||||
|
const handleClose = () => emit('update:visible', false)
|
||||||
|
|
||||||
|
const handleVisit = (userId, nickname) => emit('visit', { userId, nickname })
|
||||||
|
|
||||||
|
const handleViewProfile = (userId) => emit('view-profile', userId)
|
||||||
|
|
||||||
|
const handleArtworkClick = (data) => emit('view-artwork', data)
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(() => props.visible, async (newVisible) => {
|
||||||
|
if (newVisible && props.activityId) {
|
||||||
|
rankingData.value = []
|
||||||
|
pageInfo.value = { currentPage: 1, hasMore: true }
|
||||||
|
hasNoMoreData.value = false
|
||||||
|
await loadRankingData(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 添加样式**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<style scoped>
|
||||||
|
/* 动画 */
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
|
||||||
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
.slide-up-enter-active, .slide-up-leave-active { transition: transform 0.3s ease; }
|
||||||
|
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%); }
|
||||||
|
|
||||||
|
.modal-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 80vh;
|
||||||
|
border-radius: 48rpx 48rpx 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-background {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image: url('/static/rank/paihangbang.png');
|
||||||
|
background-size: 100% 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 40rpx 20rpx 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
padding: 0 20rpx 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 20rpx;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollable-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 复用 RankingModal 样式 */
|
||||||
|
.loading-container, .error-container, .empty-data {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400rpx;
|
||||||
|
padding: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text, .error-text, .empty-text {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
padding: 16rpx 40rpx;
|
||||||
|
background: linear-gradient(135deg, #ff6b9d, #ffa06b);
|
||||||
|
border-radius: 40rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top3-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 40rpx;
|
||||||
|
padding: 0 48rpx;
|
||||||
|
margin-bottom: 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-list-section {
|
||||||
|
padding: 0 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner-small {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border: 3rpx solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-spacer {
|
||||||
|
height: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 120rpx;
|
||||||
|
left: 40rpx;
|
||||||
|
right: 40rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 107, 157, 0.9), rgba(255, 177, 153, 0.9));
|
||||||
|
border-radius: 32rpx;
|
||||||
|
box-shadow: 0 -4rpx 16rpx rgba(255, 107, 157, 0.35);
|
||||||
|
border: 2rpx solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4rpx solid rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-score {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flame-icon {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
margin-left: 8rpx;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-rank {
|
||||||
|
padding: 16rpx 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 修改 ThemeBanner.vue 添加点击事件
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
- 修改: `frontend/pages/support-activity/components/ThemeBanner.vue:1-36`(template 部分)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 banner-content 上添加点击事件**
|
||||||
|
|
||||||
|
在 `<view class="banner-content">` 上添加 `@tap="handleBannerClick"`
|
||||||
|
|
||||||
|
- [ ] **Step 2: 添加 emit 定义**
|
||||||
|
|
||||||
|
在 script 部分添加:
|
||||||
|
```javascript
|
||||||
|
const emit = defineEmits(['tap'])
|
||||||
|
|
||||||
|
// 添加点击处理函数
|
||||||
|
const handleBannerClick = () => {
|
||||||
|
emit('tap')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 修改 support-activity/index.vue 引入和使用组件
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
- 修改: `frontend/pages/support-activity/index.vue:14-21`(template)
|
||||||
|
- 修改: `frontend/pages/support-activity/index.vue:105-122`(script import)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 引入 ActivityRankingModal**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import ActivityRankingModal from './components/ActivityRankingModal.vue'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 添加弹窗状态和引用**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const rankingModalVisible = ref(false)
|
||||||
|
const currentActivityTitle = ref('')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 在 template 中添加组件**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ThemeBanner 添加 @tap 打开弹窗 -->
|
||||||
|
<ThemeBanner
|
||||||
|
v-if="config"
|
||||||
|
:title="config.title"
|
||||||
|
:banner-image="config.bannerImage"
|
||||||
|
:current="progressData.current"
|
||||||
|
:target="progressData.target"
|
||||||
|
:is-stale-data="isStaleData"
|
||||||
|
@tap="openRankingModal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 添加 ActivityRankingModal -->
|
||||||
|
<ActivityRankingModal
|
||||||
|
v-model:visible="rankingModalVisible"
|
||||||
|
:activity-id="activityId"
|
||||||
|
:activity-title="currentActivityTitle"
|
||||||
|
@visit="handleVisitUser"
|
||||||
|
@view-profile="handleViewUserProfile"
|
||||||
|
@view-artwork="handleViewArtwork"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 添加打开弹窗方法**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const openRankingModal = () => {
|
||||||
|
currentActivityTitle.value = config.value?.title || '活动排名'
|
||||||
|
rankingModalVisible.value = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 提交代码
|
||||||
|
|
||||||
|
- [ ] **Step 1: 提交所有更改**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/pages/support-activity/components/ActivityRankingModal.vue \
|
||||||
|
frontend/pages/support-activity/components/ThemeBanner.vue \
|
||||||
|
frontend/pages/support-activity/index.vue
|
||||||
|
git commit -m "feat: 添加 ActivityRankingModal 活动榜单弹窗组件"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证步骤
|
||||||
|
|
||||||
|
1. 启动开发服务器:`cd frontend && npm run dev`
|
||||||
|
2. 进入活动页面,点击 ThemeBanner 区域
|
||||||
|
3. 验证弹窗正常显示TOP3和排名列表
|
||||||
|
4. 验证下拉刷新和滚动加载更多功能
|
||||||
|
5. 验证当前用户栏正确显示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖项
|
||||||
|
|
||||||
|
- `TOP3Card.vue` - 来自 `frontend/pages/components/TOP3Card.vue`
|
||||||
|
- `RankingListItem.vue` - 来自 `frontend/pages/components/RankingListItem.vue`
|
||||||
|
- `getActivityRankingApi` - 来自 `frontend/utils/api.js`
|
||||||
@ -677,15 +677,284 @@ onHide(() => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 待确认
|
---
|
||||||
|
|
||||||
- [x] 后端接口由我方实现
|
## 11. Redis 缓存 + 时间窗口合并 + 分布式锁优化
|
||||||
- [x] 道具图标由后端提供(冗余字段直接存储)
|
|
||||||
- [x] 仅显示当前页面实时数据(页面切换时暂停)
|
### 11.1 背景与目标
|
||||||
- [x] 后端框架:Go + Gin(Gateway)+ Dubbo-go(微服务)+ GORM
|
|
||||||
- [x] 数据库:MySQL
|
#### 背景
|
||||||
- [x] ORM:GORM
|
- 前端已实现每秒轮询 `GET /api/v1/activity/:activityId/contributions/latest`
|
||||||
- [x] 冗余字段:user_nickname、user_avatar、item_name、item_icon 直接存在表中
|
- 如果 10,000 用户同时轮询,每秒 10,000 次 DB 查询,存在性能瓶颈
|
||||||
- [x] 不需要"几秒前"等相对时间显示,只显示实时贡献
|
|
||||||
- [x] 轮询使用时间戳(since_timestamp)而非 ID,避免重复拉取
|
#### 目标
|
||||||
- [ ] 列表视觉样式是否有设计稿?
|
- **秒级合并**:10s/12s/13s 的查询请求,统一在 10s 窗口执行一次 DB 查询
|
||||||
|
- **有新数据立即返回**:如果缓存中检测到新记录,直接返回最新数据
|
||||||
|
- **无新数据合并查询**:窗口内无写入时,后续请求复用缓存结果
|
||||||
|
|
||||||
|
#### 性能指标
|
||||||
|
- 10k 并发轮询 → 实际 DB 查询频率 ≤ 1次/秒(正常状态)
|
||||||
|
- 缓存未命中时首个请求有 100-300ms 延迟,后续请求 < 5ms
|
||||||
|
- 缓存 TTL 5秒,滚动窗口
|
||||||
|
|
||||||
|
### 11.2 整体流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户轮询请求 (10k 并发)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Gateway API
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
查询 Redis 缓存
|
||||||
|
activity:{id}:contributions:latest
|
||||||
|
│
|
||||||
|
├── 有缓存 + 窗口有效(now_ms - updated_at < 1000ms)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ 检查 sinceTimestamp:
|
||||||
|
│ - sinceTimestamp <= updated_at → 缓存数据够新,直接返回
|
||||||
|
│ - sinceTimestamp > updated_at → 继续查 DB(数据可能不够新)
|
||||||
|
│
|
||||||
|
└── 缓存不存在 / 过期
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SETNX 加分布式锁
|
||||||
|
lock: activity:{id}:contributions:lock
|
||||||
|
(5秒自动释放,防止死锁)
|
||||||
|
│
|
||||||
|
├── 获取锁成功
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ 查询 PostgreSQL
|
||||||
|
│ 回填 Redis (TTL=5秒)
|
||||||
|
│ 释放锁
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ 返回数据
|
||||||
|
│
|
||||||
|
└── 获取锁失败
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
等待 100ms 重试
|
||||||
|
(最多 3 次,避免长时间等待)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 Redis Key 设计
|
||||||
|
|
||||||
|
| Key | 类型 | 说明 | TTL |
|
||||||
|
|-----|------|------|-----|
|
||||||
|
| `activity:{activityId}:contributions:latest` | Hash | 最新贡献记录缓存 | 5秒 |
|
||||||
|
| `activity:{activityId}:contributions:lock` | String | 分布式锁 | 5秒 |
|
||||||
|
|
||||||
|
### 11.4 缓存数据结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"user_id": 1001,
|
||||||
|
"user_nickname": "用户昵称",
|
||||||
|
"user_avatar": "https://...",
|
||||||
|
"item_id": 1,
|
||||||
|
"item_type": "gift_flower",
|
||||||
|
"item_name": "玫瑰花",
|
||||||
|
"item_icon": "https://...",
|
||||||
|
"quantity": 10,
|
||||||
|
"crystal_spent": 100,
|
||||||
|
"contribution_points": 50,
|
||||||
|
"combo_count": 2,
|
||||||
|
"created_at": 1747133400000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated_at": 1747133400000,
|
||||||
|
"latest_id": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
- `records`: 最新贡献记录数组(最多 5 条),**每条记录的 combo_count 已合并**
|
||||||
|
- `updated_at`: 窗口时间戳(毫秒级 Unix 时间戳),用于判断是否在有效窗口内
|
||||||
|
- `latest_id`: 最新记录的 ID,用于增量检测
|
||||||
|
|
||||||
|
**时间戳单位**:统一使用**毫秒**,与前端 `sinceTimestamp` 参数单位一致
|
||||||
|
|
||||||
|
### 11.5 查询流程(伪代码)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func GetContributionsLatest(activityId int64, sinceTimestamp int64, sinceId int64, limit int) ([]*ContributionRecord, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cacheKey := fmt.Sprintf("activity:%d:contributions:latest", activityId)
|
||||||
|
lockKey := fmt.Sprintf("activity:%d:contributions:lock", activityId)
|
||||||
|
nowMs := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
// 1. 尝试获取缓存
|
||||||
|
cached := redis.Get(ctx, cacheKey)
|
||||||
|
if cached != nil {
|
||||||
|
cache := parseCache(cached)
|
||||||
|
// 缓存有效(1秒窗口内)→ 检查 sinceTimestamp 是否在窗口内
|
||||||
|
if cache != nil && nowMs-cache.UpdatedAt < 1000 {
|
||||||
|
// sinceTimestamp <= updated_at,说明请求的数据在缓存窗口内,直接返回
|
||||||
|
if sinceTimestamp <= cache.UpdatedAt {
|
||||||
|
return cache.Records, nil
|
||||||
|
}
|
||||||
|
// sinceTimestamp > updated_at,缓存数据可能不够新,继续查 DB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 缓存不存在或过期或数据不够新,尝试加锁
|
||||||
|
locked := redis.SetNX(ctx, lockKey, "1", 5*time.Second)
|
||||||
|
if !locked {
|
||||||
|
// 3. 获取锁失败,等待重试
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
cached := redis.Get(ctx, cacheKey)
|
||||||
|
if cached != nil {
|
||||||
|
cache := parseCache(cached)
|
||||||
|
if cache != nil && nowMs-cache.UpdatedAt < 1000 && sinceTimestamp <= cache.UpdatedAt {
|
||||||
|
return cache.Records, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 重试 3 次后仍失败,返回错误或旧缓存
|
||||||
|
return nil, errors.New("cache unavailable after retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取锁成功,查 DB
|
||||||
|
defer redis.Del(ctx, lockKey)
|
||||||
|
|
||||||
|
records, err := db.QueryContributions(activityId, sinceTimestamp, sinceId, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 回填缓存(TTL 5秒)
|
||||||
|
if len(records) > 0 {
|
||||||
|
// 为每条记录获取最新的 combo_count
|
||||||
|
for _, record := range records {
|
||||||
|
comboCount, _ := redis.Get(ctx, fmt.Sprintf("combo:%d:%s", record.UserId, record.ItemType)).Int64()
|
||||||
|
if comboCount > 0 {
|
||||||
|
record.ComboCount = comboCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &ContributionCache{
|
||||||
|
Records: records,
|
||||||
|
UpdatedAt: nowMs,
|
||||||
|
LatestId: records[0].Id,
|
||||||
|
}
|
||||||
|
redis.Set(ctx, cacheKey, cache, 5*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.6 写入流程(使缓存失效)
|
||||||
|
|
||||||
|
在 `PurchaseItem` 成功写入 `activity_contributions` 后:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *activityService) PurchaseItem(...) error {
|
||||||
|
// ... 原有的购买逻辑 ...
|
||||||
|
|
||||||
|
// 1. 写数据库
|
||||||
|
err := s.repo.CreateContribution(contribution)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 使缓存失效(不是更新,是删除)
|
||||||
|
cacheKey := fmt.Sprintf("activity:%d:contributions:latest", activityId)
|
||||||
|
redis.Del(ctx, cacheKey)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么不更新缓存而是删除?**
|
||||||
|
- 如果更新缓存,需要处理并发写入的 race condition
|
||||||
|
- 删除缓存让下次查询触发重建,逻辑更简单且正确
|
||||||
|
|
||||||
|
### 11.7 窗口合并策略
|
||||||
|
|
||||||
|
#### 时间窗口对齐
|
||||||
|
- 窗口粒度:**1 秒**(1000 毫秒,可调整)
|
||||||
|
- 查询时:如果 `now_ms - cache.updated_at < 1000ms`,认为是同一窗口
|
||||||
|
- 超过 1 秒:缓存过期,下次查询触发重建
|
||||||
|
|
||||||
|
#### sinceTimestamp 过滤逻辑
|
||||||
|
- 前端传 `sinceTimestamp`(毫秒)进行增量查询
|
||||||
|
- 缓存命中时,判断 `sinceTimestamp <= cache.updated_at`:
|
||||||
|
- `true` → 请求的数据在缓存窗口内,直接返回缓存
|
||||||
|
- `false` → 缓存数据不够新,继续查 DB
|
||||||
|
|
||||||
|
#### 有新数据时的处理
|
||||||
|
- 后端对比 `sinceId`:
|
||||||
|
- `sinceId > cache.latest_id` → 有新数据,返回最新记录
|
||||||
|
- `sinceId <= cache.latest_id` → 无新数据,返回缓存数据
|
||||||
|
|
||||||
|
#### combo_count 合并
|
||||||
|
- 缓存回填时,从 Redis 获取每条记录的 `combo:{user_id}:{item_type}` 值
|
||||||
|
- 合并到 `record.combo_count` 字段
|
||||||
|
- 如果 Redis 无值,视为 1
|
||||||
|
|
||||||
|
### 11.8 分布式锁设计
|
||||||
|
|
||||||
|
#### 锁 Key
|
||||||
|
```
|
||||||
|
lock: activity:{activityId}:contributions:lock
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 锁参数
|
||||||
|
- **TTL**: 5 秒(防止进程崩溃导致死锁)
|
||||||
|
- **重试**: 获取失败后等待 100ms 重试,最多 3 次
|
||||||
|
- **释放**: 使用后立即删除(`DEL` 命令)
|
||||||
|
|
||||||
|
#### 可靠性说明
|
||||||
|
- 锁仅用于防止**缓存击穿**(cache stampede)
|
||||||
|
- 锁持有时间极短(一次 DB 查询,约 10-50ms)
|
||||||
|
- 单实例 Redis 足够,无需 Redlock
|
||||||
|
|
||||||
|
### 11.9 错误处理
|
||||||
|
|
||||||
|
| 场景 | 处理方式 |
|
||||||
|
|------|----------|
|
||||||
|
| Redis 不可用 | 回退到直接查 DB(降级) |
|
||||||
|
| 获取锁失败 + 重试 3 次后仍失败 | 返回 503 Service Unavailable 或返回旧缓存 |
|
||||||
|
| DB 查询失败 | 返回错误,前端显示重试 |
|
||||||
|
| 缓存为空(无数据) | 返回空数组,不缓存 |
|
||||||
|
|
||||||
|
### 11.10 影响范围
|
||||||
|
|
||||||
|
#### 需要修改的文件
|
||||||
|
1. **Gateway 层**
|
||||||
|
- `gateway/controller/activity_controller.go` — 添加 `GetContributionsLatest` 方法(如果尚未添加)
|
||||||
|
|
||||||
|
2. **Service 层**
|
||||||
|
- `services/activityService/provider/activity_provider.go` — 实现缓存逻辑 + 锁逻辑
|
||||||
|
|
||||||
|
3. **Repository 层**
|
||||||
|
- `services/activityService/repository/activity_repository.go` — `GetLatestContributions` 查询方法(如果尚未添加)
|
||||||
|
|
||||||
|
4. **Proto 定义**(如使用 Dubbo RPC)
|
||||||
|
- `pkg/proto/activity/activity.proto` — 添加 `GetContributionsLatestRequest/Response`
|
||||||
|
- 重新生成 `activity.pb.go`
|
||||||
|
|
||||||
|
#### 不需要修改的文件
|
||||||
|
- 前端 `ContributionList.vue` 和 `useContributionPolling.js` 无需改动(接口兼容)
|
||||||
|
|
||||||
|
### 11.11 测试要点
|
||||||
|
|
||||||
|
1. **并发测试**:10k 请求同时发起,验证 DB 只查询 1 次
|
||||||
|
2. **缓存失效测试**:Purchase 后,验证缓存被正确删除
|
||||||
|
3. **锁竞争测试**:缓存失效瞬间,多个请求抢锁,验证只有一个请求查 DB
|
||||||
|
4. **降级测试**:Redis 不可用时,验证服务能回退到直连 DB
|
||||||
|
5. **增量查询测试**:传入 `sinceId`,验证只返回增量数据
|
||||||
|
|
||||||
|
### 11.12 后续优化(可选)
|
||||||
|
|
||||||
|
1. **多级缓存**:引入本地内存缓存(如 Go 的 `sync.Map`),减少 Redis 请求
|
||||||
|
2. **窗口动态调整**:根据并发量动态调整窗口大小
|
||||||
|
3. **监控告警**:监控缓存命中率、锁等待时间、DB 查询 QPS
|
||||||
@ -78,8 +78,6 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 用户昵称(在头像下方) -->
|
<!-- 用户昵称(在头像下方) -->
|
||||||
<view class="nickname-container">
|
<view class="nickname-container">
|
||||||
<text class="user-nickname">{{'用户 :'}}
|
<text class="user-nickname">{{'用户 :'}}
|
||||||
|
|||||||
@ -638,8 +638,16 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
/* background: rgba(0, 0, 0, 0.2); */
|
||||||
padding: 8rpx 20rpx;
|
padding: 8rpx 20rpx;
|
||||||
|
background: linear-gradient(to bottom right,
|
||||||
|
#F0E4B1 0%,
|
||||||
|
#F08399 50%,
|
||||||
|
#B94E73 100%);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
|
||||||
|
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
|
||||||
border-radius: 30rpx;
|
border-radius: 30rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -715,7 +723,6 @@ defineExpose({
|
|||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
padding-top: 24rpx;
|
padding-top: 24rpx;
|
||||||
border-top: 1rpx solid rgba(255, 255, 255, 0.3);
|
border-top: 1rpx solid rgba(255, 255, 255, 0.3);
|
||||||
margin-top: 16rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quantity-selector {
|
.quantity-selector {
|
||||||
|
|||||||
@ -6,21 +6,21 @@
|
|||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<transition name="slide-up">
|
<transition name="slide-up">
|
||||||
<view v-if="visible" class="modal-container" :class="{ 'dragging': isDragging }"
|
<view v-if="visible" class="modal-container" :class="{ 'dragging': isDragging }" @tap.stop>
|
||||||
:style="{ transform: `translateY(${dragOffset}px)` }" @tap.stop>
|
|
||||||
<!-- 背景图 -->
|
<!-- 背景图 -->
|
||||||
<view class="modal-background"></view>
|
<view class="modal-background"></view>
|
||||||
|
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
<view class="modal-content">
|
<view class="modal-content">
|
||||||
<!-- 头部区域 - 活动名称 + 关闭按钮 -->
|
<!-- 头部区域 - 活动名称 + 关闭按钮 -->
|
||||||
<view class="header-section" @touchstart.stop="handleTouchStart" @touchmove.stop="handleTouchMove"
|
<view class="header-section" @touchstart.stop="handleTouchStart" @touchmove.stop="handleTouchMove"
|
||||||
@touchend.stop="handleTouchEnd">
|
@touchend.stop="handleTouchEnd">
|
||||||
<text class="activity-title">{{ activityTitle }}</text>
|
|
||||||
<view class="close-button" @tap="handleClose">
|
<view class="close-button" @tap="handleClose">
|
||||||
<text class="close-icon">✕</text>
|
<image class="close-icon-img" src="/static/starbookcontent/tuichu.png" mode="aspectFit">
|
||||||
|
</image>
|
||||||
</view>
|
</view>
|
||||||
|
<image class="activity-title-img" src="/static/rank/activity-rank-badge.png" mode="aspectFit">
|
||||||
|
</image>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 滚动内容区域 -->
|
<!-- 滚动内容区域 -->
|
||||||
@ -42,11 +42,39 @@
|
|||||||
|
|
||||||
<!-- TOP3 展示区域 -->
|
<!-- TOP3 展示区域 -->
|
||||||
<view v-else-if="top3Users.length > 0" class="top3-section">
|
<view v-else-if="top3Users.length > 0" class="top3-section">
|
||||||
<TOP3Card v-for="user in top3Users" :key="user.userId" :rank="user.rank" :avatar="user.avatar"
|
<view v-for="user in top3Users" :key="user.userId" class="top3-card-item">
|
||||||
:nickname="user.nickname" :popularityScore="user.popularityScore"
|
<!-- 头像区域(居中显示,大尺寸) -->
|
||||||
:artworkImage="user.artworkImage" :userId="user.userId"
|
<view class="avatar-container">
|
||||||
:isCurrentUser="isCurrentUser(user.userId)" @visit="handleVisit(user.userId, user.nickname)"
|
<image class="user-avatar" :src="user.avatar || '/static/avatar/1.jpeg'"
|
||||||
@view-profile="handleViewProfile" @avatar-click="(payload) => emit('view-profile', { userId: payload.userId })" />
|
mode="aspectFill" @error="handleAvatarError"
|
||||||
|
@tap="handleViewProfile(user.userId)">
|
||||||
|
</image>
|
||||||
|
<!-- 排名图标在头像下方 -->
|
||||||
|
<view class="rank-badge-bottom">
|
||||||
|
<image class="rank-icon" :src="`/static/rank/charm-rank-icon${user.rank}.png`"
|
||||||
|
mode="aspectFit" @error="handleRankIconError"></image>
|
||||||
|
</view>
|
||||||
|
<view class="rank-badge-bottom rank-badge-bottom2">
|
||||||
|
<image class="rank-icon" :src="`/static/rank/rank-icon${user.rank}.png`"
|
||||||
|
mode="aspectFit" @error="handleRankIconError"></image>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 昵称(在头像下方) -->
|
||||||
|
<view class="nickname-container">
|
||||||
|
<text class="user-nickname-name">{{ user.nickname || '未知用户' }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 消耗水晶值(在头像上方) -->
|
||||||
|
<view class="popularity-container-top">
|
||||||
|
<image class="fire-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||||||
|
<text class="popularity-score-top">{{ formatPopularityScore(user.popularityScore)
|
||||||
|
}}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 拜访按钮 -->
|
||||||
|
<view v-if="!isCurrentUser(user.userId) && user.rank >= 4" class="visit-btn"
|
||||||
|
@tap="handleVisit(user.userId, user.nickname)">
|
||||||
|
<text class="visit-text">拜访</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 空数据提示 -->
|
<!-- 空数据提示 -->
|
||||||
@ -58,12 +86,33 @@
|
|||||||
<!-- 排名列表区域 -->
|
<!-- 排名列表区域 -->
|
||||||
<view v-if="!isLoadingData && !dataLoadError && listUsers.length > 0"
|
<view v-if="!isLoadingData && !dataLoadError && listUsers.length > 0"
|
||||||
class="ranking-list-section">
|
class="ranking-list-section">
|
||||||
<RankingListItem v-for="item in listUsers" :key="item.userId" :rank="item.rank"
|
<view v-for="item in listUsers" :key="item.userId" class="ranking-list-item">
|
||||||
:userId="item.userId" :avatar="item.avatar" :nickname="item.nickname"
|
<!-- 排名编号 -->
|
||||||
:popularityScore="item.popularityScore" :artworkImage="item.artworkImage"
|
<view class="rank-number">
|
||||||
:artworkId="item.artworkId" :showVisitButton="!isCurrentUser(item.userId)"
|
<text class="rank-text">{{ item.rank }}</text>
|
||||||
:isCurrentUser="isCurrentUser(item.userId)" @visit="handleVisit(item.userId, item.nickname)"
|
</view>
|
||||||
@view-profile="handleViewProfile" @artwork-click="handleArtworkClick" />
|
<!-- 左侧:头像 + 昵称 -->
|
||||||
|
<view class="left-section">
|
||||||
|
<image class="user-avatar-small" :src="item.avatar || '/static/avatar/1.jpeg'"
|
||||||
|
mode="aspectFill" @error="handleItemAvatarError"
|
||||||
|
@tap="handleViewProfile(item.userId)"></image>
|
||||||
|
<text class="item-nickname">{{ item.nickname || '未知用户' }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 右侧:人气值和拜访按钮 -->
|
||||||
|
<view class="right-section">
|
||||||
|
<view class="popularity-container-top">
|
||||||
|
<image class="fire-icon" src="/static/icon/crystal.png" mode="aspectFit">
|
||||||
|
</image>
|
||||||
|
<text class="popularity-score-top">{{
|
||||||
|
formatPopularityScore(item.popularityScore)
|
||||||
|
}}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="!isCurrentUser(item.userId)" class="visit-button"
|
||||||
|
@tap="handleVisit(item.userId, item.nickname)">
|
||||||
|
<text class="visit-btn-text">拜访</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 加载更多提示 -->
|
<!-- 加载更多提示 -->
|
||||||
@ -91,9 +140,9 @@
|
|||||||
<!-- 用户信息 -->
|
<!-- 用户信息 -->
|
||||||
<view class="current-user-info">
|
<view class="current-user-info">
|
||||||
<view class="current-user-score">
|
<view class="current-user-score">
|
||||||
<image class="flame-icon" src="/static/rank/spark.png" mode="aspectFit"></image>
|
<image class="flame-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||||||
<text class="score-text">{{ formatPopularityScore(currentUserInfo.popularityScore)
|
<text class="score-text">{{ formatPopularityScore(currentUserInfo.popularityScore)
|
||||||
}}</text>
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -110,10 +159,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||||
import { getActivityRankingApi } from '@/utils/api.js';
|
import { getActivityRankingApi } from '@/utils/api.js';
|
||||||
import TOP3Card from '@/pages/components/TOP3Card.vue';
|
|
||||||
import RankingListItem from '@/pages/components/RankingListItem.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
@ -185,14 +232,8 @@ const isCurrentUser = (userId) => {
|
|||||||
return userId === currentUserInfo.value.userId;
|
return userId === currentUserInfo.value.userId;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取OSS图片URL - 直接使用后端返回的URL
|
|
||||||
const getOssImageUrl = async (fileName, type = 'avatar') => {
|
|
||||||
if (!fileName || fileName === '') return;
|
|
||||||
return fileName;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 转换活动排名数据
|
// 转换活动排名数据
|
||||||
const transformActivityRankingData = async (apiResponse) => {
|
const transformActivityRankingData = (apiResponse) => {
|
||||||
if (!apiResponse || !apiResponse.data) {
|
if (!apiResponse || !apiResponse.data) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -202,30 +243,22 @@ const transformActivityRankingData = async (apiResponse) => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformedItems = await Promise.all(items.map(async (item) => {
|
return items.map(item => ({
|
||||||
const avatarUrl = await getOssImageUrl(item.avatar_url || '', 'avatar');
|
rank: item.rank,
|
||||||
|
userId: String(item.user_id),
|
||||||
return {
|
avatar: item.avatar_url || '/static/avatar/1.jpeg',
|
||||||
rank: item.rank,
|
nickname: item.nickname || '未知用户',
|
||||||
userId: String(item.user_id),
|
popularityScore: item.total_contribution || 0,
|
||||||
avatar: avatarUrl || '/static/avatar/1.jpeg',
|
artworkImage: '',
|
||||||
nickname: item.nickname || '未知用户',
|
artworkId: ''
|
||||||
popularityScore: item.total_contribution || 0,
|
|
||||||
artworkImage: '',
|
|
||||||
artworkId: ''
|
|
||||||
};
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return transformedItems;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 转换当前用户数据
|
// 转换当前用户数据
|
||||||
const transformMyActivityContribution = async (myContribution) => {
|
const transformMyActivityContribution = (myContribution) => {
|
||||||
const avatarUrl = await getOssImageUrl(myContribution?.avatar_url, 'avatar');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: 'currentUser',
|
userId: 'currentUser',
|
||||||
avatar: avatarUrl || '/static/avatar/1.jpeg',
|
avatar: myContribution?.avatar_url || '/static/avatar/1.jpeg',
|
||||||
popularityScore: myContribution?.total_contribution || 0,
|
popularityScore: myContribution?.total_contribution || 0,
|
||||||
rank: (myContribution?.rank > 0) ? myContribution.rank : null
|
rank: (myContribution?.rank > 0) ? myContribution.rank : null
|
||||||
};
|
};
|
||||||
@ -250,8 +283,7 @@ const loadRankingData = async (page = 1, isRefreshAction = false) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (apiResponse && apiResponse.code === 200 && apiResponse.data) {
|
if (apiResponse && apiResponse.code === 200 && apiResponse.data) {
|
||||||
// 转换数据
|
const transformedData = transformActivityRankingData(apiResponse);
|
||||||
const transformedData = await transformActivityRankingData(apiResponse);
|
|
||||||
|
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
rankingData.value = transformedData;
|
rankingData.value = transformedData;
|
||||||
@ -259,12 +291,10 @@ const loadRankingData = async (page = 1, isRefreshAction = false) => {
|
|||||||
rankingData.value.push(...transformedData);
|
rankingData.value.push(...transformedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新分页状态
|
|
||||||
hasNoMoreData.value = transformedData.length < PAGE_SIZE;
|
hasNoMoreData.value = transformedData.length < PAGE_SIZE;
|
||||||
|
|
||||||
// 保存当前用户数据(仅第一页)
|
|
||||||
if (page === 1 && apiResponse.data.my_contribution) {
|
if (page === 1 && apiResponse.data.my_contribution) {
|
||||||
const myContributionData = await transformMyActivityContribution(apiResponse.data.my_contribution);
|
const myContributionData = transformMyActivityContribution(apiResponse.data.my_contribution);
|
||||||
if (!props.currentUser) {
|
if (!props.currentUser) {
|
||||||
currentUserInfo.value = myContributionData;
|
currentUserInfo.value = myContributionData;
|
||||||
}
|
}
|
||||||
@ -316,11 +346,9 @@ const handleRefresh = async () => {
|
|||||||
isRefreshing.value = true;
|
isRefreshing.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 重置分页
|
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
hasNoMoreData.value = false;
|
hasNoMoreData.value = false;
|
||||||
|
|
||||||
// 重新加载
|
|
||||||
const success = await loadRankingData(1, true);
|
const success = await loadRankingData(1, true);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@ -338,11 +366,6 @@ const handleRefresh = async () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Refresh failed:', error);
|
console.error('Refresh failed:', error);
|
||||||
uni.showToast({
|
|
||||||
title: '刷新失败',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2000
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing.value = false;
|
isRefreshing.value = false;
|
||||||
}
|
}
|
||||||
@ -381,11 +404,8 @@ const handleViewProfile = (userId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 处理作品点击
|
// 处理作品点击
|
||||||
const handleArtworkClick = (data) => {
|
const handleArtworkClick = () => {
|
||||||
emit('view-artwork', {
|
// 预留,后续可扩展查看作品详情
|
||||||
artworkId: data.artworkId,
|
|
||||||
userId: data.userId
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理关闭
|
// 处理关闭
|
||||||
@ -439,17 +459,30 @@ const handleCurrentUserAvatarError = (e) => {
|
|||||||
e.target.src = '/static/avatar/1.jpeg';
|
e.target.src = '/static/avatar/1.jpeg';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理排名图标加载失败
|
||||||
|
const handleRankIconError = (e) => {
|
||||||
|
e.target.src = '/static/rank/charm-rank-icon1.png';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理头像加载失败
|
||||||
|
const handleAvatarError = (e) => {
|
||||||
|
e.target.src = '/static/avatar/1.jpeg';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理列表项头像加载失败
|
||||||
|
const handleItemAvatarError = (e) => {
|
||||||
|
e.target.src = '/static/avatar/1.jpeg';
|
||||||
|
};
|
||||||
|
|
||||||
// 监听可见性变化
|
// 监听可见性变化
|
||||||
watch(() => props.visible, async (newVisible, oldVisible) => {
|
watch(() => props.visible, async (newVisible, oldVisible) => {
|
||||||
if (newVisible && !oldVisible) {
|
if (newVisible && !oldVisible) {
|
||||||
// 重置状态
|
|
||||||
rankingData.value = [];
|
rankingData.value = [];
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
hasNoMoreData.value = false;
|
hasNoMoreData.value = false;
|
||||||
isLoadingData.value = true;
|
isLoadingData.value = true;
|
||||||
dataLoadError.value = null;
|
dataLoadError.value = null;
|
||||||
|
|
||||||
// 使用传入的 currentUser 或重置
|
|
||||||
if (props.currentUser) {
|
if (props.currentUser) {
|
||||||
currentUserInfo.value = { ...props.currentUser };
|
currentUserInfo.value = { ...props.currentUser };
|
||||||
} else {
|
} else {
|
||||||
@ -462,7 +495,6 @@ watch(() => props.visible, async (newVisible, oldVisible) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
await loadRankingData(1);
|
await loadRankingData(1);
|
||||||
}
|
}
|
||||||
}, { immediate: false });
|
}, { immediate: false });
|
||||||
@ -535,8 +567,9 @@ onUnmounted(() => {
|
|||||||
.modal-container {
|
.modal-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 80vh;
|
height: 80%;
|
||||||
bottom: 0;
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
border-radius: 48rpx 48rpx 0 0;
|
border-radius: 48rpx 48rpx 0 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@ -553,7 +586,7 @@ onUnmounted(() => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-image: url('/static/rank/paihangbang.png');
|
background-image: url('/static/rank/activity-support-icon/shengrihuipaihangbang.png');
|
||||||
background-size: 100% 100%;
|
background-size: 100% 100%;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
@ -586,34 +619,39 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 40rpx;
|
margin-bottom: 40rpx;
|
||||||
padding: 0 32rpx;
|
padding: 0 14rpx;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
top: 48rpx;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-title {
|
.activity-title-img {
|
||||||
font-size: 36rpx;
|
flex: 1;
|
||||||
font-weight: bold;
|
max-width: 400rpx;
|
||||||
color: #FFFFFF;
|
height: 60rpx;
|
||||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.4);
|
object-fit: contain;
|
||||||
font-family: 'yt', sans-serif;
|
filter: drop-shadow(0 4rpx 8rpx rgba(0, 0, 0, 0.4));
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
will-change: opacity, transform;
|
||||||
|
transform: scale(2.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button {
|
.close-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 32rpx;
|
left: 64rpx;
|
||||||
width: 56rpx;
|
width: 56rpx;
|
||||||
height: 56rpx;
|
height: 56rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-icon {
|
.close-icon-img {
|
||||||
font-size: 32rpx;
|
width: 80rpx;
|
||||||
color: #FFFFFF;
|
height: 80rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动内容区域 */
|
/* 滚动内容区域 */
|
||||||
@ -694,6 +732,136 @@ onUnmounted(() => {
|
|||||||
padding: 0 48rpx;
|
padding: 0 48rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top3-card-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 25%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 人气值容器(在头像上方) */
|
||||||
|
.popularity-container-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rpx 20rpx 4rpx 64rpx;
|
||||||
|
background: linear-gradient(to bottom right,
|
||||||
|
#F0E4B1 0%,
|
||||||
|
#F08399 50%,
|
||||||
|
#B94E73 100%);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
|
||||||
|
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popularity-container-top .fire-icon {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
position: absolute;
|
||||||
|
left: -8rpx;
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popularity-container-top .popularity-score-top {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #FFFFFF;
|
||||||
|
text-shadow:
|
||||||
|
0 2rpx 4rpx rgba(0, 0, 0, 0.5),
|
||||||
|
0 1rpx 2rpx rgba(0, 0, 0, 0.3);
|
||||||
|
font-family: 'yt', sans-serif;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头像容器(居中显示,大尺寸) */
|
||||||
|
.avatar-container {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 180rpx;
|
||||||
|
margin-bottom: 64rpx;
|
||||||
|
margin-top: 64rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 180rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 5rpx solid rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 排名图标在头像下方 */
|
||||||
|
.rank-badge-bottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -40rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) scale(2.5);
|
||||||
|
z-index: 10;
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge-bottom2 {
|
||||||
|
bottom: -50rpx;
|
||||||
|
transform: translateX(-50%) scale(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: drop-shadow(0 4rpx 8rpx rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 昵称容器(在头像下方) */
|
||||||
|
.nickname-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-nickname {
|
||||||
|
font-size: 12rpx;
|
||||||
|
margin-left: 10rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-nickname-name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
color: #FFA500;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-btn {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 107, 157, 0.9), rgba(255, 177, 153, 0.9));
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-family: 'yt', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
/* 空数据提示 */
|
/* 空数据提示 */
|
||||||
.empty-data {
|
.empty-data {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -719,6 +887,92 @@ onUnmounted(() => {
|
|||||||
padding: 0 48rpx;
|
padding: 0 48rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ranking-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-number {
|
||||||
|
min-width: 64rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #FFFFFF;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
|
||||||
|
font-family: 'yt', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-small {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3rpx solid rgba(255, 255, 255, 0.7);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-nickname {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
|
||||||
|
font-family: 'yt', sans-serif;
|
||||||
|
max-width: 200rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popularity-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fire-icon-small {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-score {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
|
||||||
|
font-family: 'yt', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-button {
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 107, 157, 0.9), rgba(255, 177, 153, 0.9));
|
||||||
|
border-radius: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-family: 'yt', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
/* 加载更多容器 */
|
/* 加载更多容器 */
|
||||||
.loading-more-container {
|
.loading-more-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -766,7 +1020,7 @@ onUnmounted(() => {
|
|||||||
/* 当前用户栏 - 固定在底部 */
|
/* 当前用户栏 - 固定在底部 */
|
||||||
.current-user-bar {
|
.current-user-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 120rpx;
|
bottom: 152rpx;
|
||||||
left: 84rpx;
|
left: 84rpx;
|
||||||
right: 84rpx;
|
right: 84rpx;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
@ -816,18 +1070,20 @@ onUnmounted(() => {
|
|||||||
.current-user-score {
|
.current-user-score {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flame-icon {
|
.flame-icon {
|
||||||
width: 44rpx;
|
width: 80rpx;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.3));
|
left: -8rpx;
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
/* filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.3)); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-text {
|
.score-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
margin-left: 8rpx;
|
color: #fff;
|
||||||
color: rgba(255, 255, 255, 0.95);
|
|
||||||
font-family: 'yt', sans-serif;
|
font-family: 'yt', sans-serif;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
|
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
|
||||||
@ -837,6 +1093,11 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 28rpx;
|
padding: 16rpx 28rpx;
|
||||||
|
background-image: url('@/static/rank/activity-support-icon/beijingkuang.png');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-text {
|
.rank-text {
|
||||||
@ -860,8 +1121,9 @@ onUnmounted(() => {
|
|||||||
margin-bottom: 32rpx;
|
margin-bottom: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-title {
|
.activity-title-img {
|
||||||
font-size: 32rpx;
|
width: 280rpx;
|
||||||
|
height: 75rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollable-content {
|
.scrollable-content {
|
||||||
@ -877,6 +1139,23 @@ onUnmounted(() => {
|
|||||||
padding: 0 8rpx;
|
padding: 0 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TOP3 头像区域 */
|
||||||
|
.avatar-container {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge-bottom {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
bottom: -18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.current-user-bar {
|
.current-user-bar {
|
||||||
bottom: 100rpx;
|
bottom: 100rpx;
|
||||||
left: 15rpx;
|
left: 15rpx;
|
||||||
@ -901,8 +1180,9 @@ onUnmounted(() => {
|
|||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-title {
|
.activity-title-img {
|
||||||
font-size: 28rpx;
|
width: 260rpx;
|
||||||
|
height: 70rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollable-content {
|
.scrollable-content {
|
||||||
@ -915,6 +1195,30 @@ onUnmounted(() => {
|
|||||||
padding: 0 4rpx;
|
padding: 0 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TOP3 头像区域 */
|
||||||
|
.avatar-container {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge-bottom {
|
||||||
|
width: 68rpx;
|
||||||
|
height: 68rpx;
|
||||||
|
bottom: -16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popularity-container-top {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.ranking-list-section {
|
.ranking-list-section {
|
||||||
padding: 0 4rpx;
|
padding: 0 4rpx;
|
||||||
}
|
}
|
||||||
@ -969,13 +1273,19 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-button {
|
.close-button {
|
||||||
right: 16rpx;
|
left: 16rpx;
|
||||||
width: 48rpx;
|
width: 48rpx;
|
||||||
height: 48rpx;
|
height: 48rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-icon {
|
.close-icon-img {
|
||||||
font-size: 28rpx;
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-title-img {
|
||||||
|
width: 240rpx;
|
||||||
|
height: 65rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollable-content {
|
.scrollable-content {
|
||||||
@ -988,6 +1298,44 @@ onUnmounted(() => {
|
|||||||
padding: 0 2rpx;
|
padding: 0 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TOP3 头像区域 */
|
||||||
|
.avatar-container {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-width: 3rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge-bottom {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
bottom: -14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popularity-container-top {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
gap: 5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-nickname {
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fire-icon {
|
||||||
|
width: 24rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popularity-score-top {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.ranking-list-section {
|
.ranking-list-section {
|
||||||
padding: 0 2rpx;
|
padding: 0 2rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="contribution-list" v-if="visible">
|
<view class="contribution-list" v-if="visible">
|
||||||
<view class="list-header">
|
<!-- <view class="list-header">
|
||||||
<text class="header-title">实时贡献</text>
|
<text class="header-title">实时贡献</text>
|
||||||
</view>
|
</view> -->
|
||||||
<scroll-view class="list-content" scroll-y>
|
<scroll-view class="list-content" scroll-y>
|
||||||
<view
|
<view
|
||||||
v-for="(record, index) in records"
|
v-for="(record, index) in records"
|
||||||
@ -73,12 +73,12 @@ defineExpose({
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
|
/* text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-content {
|
.list-content {
|
||||||
height: 200rpx;
|
height: 200rpx;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
/* background: rgba(0, 0, 0, 0.3); */
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
padding: 16rpx;
|
padding: 16rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -769,5 +769,7 @@ onUnload(() => {
|
|||||||
.contribution-list-wrapper {
|
.contribution-list-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
|
position: relative;
|
||||||
|
top: 192rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 630 KiB After Width: | Height: | Size: 496 KiB |
Loading…
Reference in New Issue
Block a user