feat:修改活动页面

This commit is contained in:
zheng020 2026-06-17 23:12:59 +08:00
parent f06623339d
commit 6f29925e6a
13 changed files with 1492 additions and 352 deletions

View File

@ -8,7 +8,9 @@
:showTaskIcon="false"
:showStarActivityIcon="false"
/> -->
<view class="nav-back" @tap="goBack">
<text class="nav-back-icon"></text>
</view>
<!-- 状态栏占位 -->
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
@ -224,6 +226,20 @@ const filteredActivities = computed(() => {
);
});
const goBack = () => {
//
const pages = getCurrentPages();
if (pages.length > 1) {
//
uni.navigateBack();
} else {
// square
uni.reLaunch({
url: "/pages/square/square",
});
}
};
function isEnded(item) {
return item.status === "expired" || item.status === "completed";
}
@ -395,6 +411,24 @@ onShow(() => {
}
}
.nav-back {
display: flex;
align-items: center;
justify-content: center;
/* background: rgba(255,255,255,0.5);
border-radius: 50%; */
position: fixed;
top: 88rpx;
left: 32rpx;
z-index: 4;
}
.nav-back-icon {
font-size: 48rpx;
font-weight: bold;
color: #fff;
}
.status-bar {
width: 100%;
background: transparent;

View File

@ -502,8 +502,8 @@ async function contributeItem(item, isRetry = false, silent = false, qty = 1) {
await updateLocalBalanceFromResult(result.remainingBalance);
}
// 使
emit("contribute", item.type, result.currentProgress);
// 使
emit("contribute", item.type, result.remainingBalance);
// toast
if (!isRetry && !silent) {

View File

@ -1,8 +1,7 @@
<template>
<view class="contribution-list" v-if="visible">
<!-- <view class="list-header">
<text class="header-title">实时贡献</text>
</view> -->
<!-- 渐变模糊背景层 -->
<view class="list-bg"></view>
<scroll-view class="list-content" scroll-y>
<view
v-for="(record, index) in records"
@ -10,14 +9,34 @@
class="contribution-item"
:class="{ 'new-item': index === 0, 'fading-out': record.fading }"
>
<!-- 左侧用户信息头像 + 昵称 + 赠送动作 -->
<view class="user-info">
<image class="user-avatar" :src="record.avatar_url" mode="aspectFill" />
<text class="user-nickname">{{ record.nickname }}</text>
<image
class="user-avatar"
:src="record.avatar_url"
mode="aspectFill"
/>
<view class="user-text">
<text class="user-nickname">{{ record.nickname }}</text>
<text class="gift-text" v-if="record.item_name"
>{{ record.item_name }}</text
>
</view>
</view>
<image class="item-icon" :src="record.item_icon" mode="aspectFill" />
<view class="quantity-info">
<text class="item-quantity">x</text>
<text class="item-count">{{ record.combo_count > 1 ? record.combo_count : record.quantity }}</text>
<!-- 右侧道具图标外层模块带 11° 旋转 + 红色阴影 -->
<view class="item-icon-wrap">
<image class="item-icon" :src="record.item_icon" mode="aspectFill" />
<view class="quantity-info">
<text class="item-x">X</text>
<text
class="item-count"
:class="`count-${(record.combo_count > 1 ? record.combo_count : record.quantity).toString().length}-digit`"
>{{
record.combo_count > 1 ? record.combo_count : record.quantity
}}</text
>
</view>
</view>
</view>
</scroll-view>
@ -25,37 +44,83 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useContributionPolling } from '../composables/useContributionPolling.js'
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useContributionPolling } from "../composables/useContributionPolling.js";
const props = defineProps({
activityId: {
type: String,
required: true
}
})
required: true,
},
});
const isPageActive = ref(true)
const isPageActive = ref(true);
// 使 composable
const { records, visible, loading, error, start, stop, reset } = useContributionPolling(
computed(() => props.activityId),
isPageActive
)
const { records, visible, loading, error, start, stop, reset } =
useContributionPolling(
computed(() => props.activityId),
isPageActive,
);
onMounted(() => {
isPageActive.value = true
})
isPageActive.value = true;
});
//
onUnmounted(() => {
stop()
})
stop();
});
// 使
defineExpose({
reset
})
reset,
});
/**
* 估算单行文本宽度rpx
* - CJK 字符按 1.0 * fontSize 计算方形字宽 fontSize
* - 其它字符按 0.55 * fontSize 计算拉丁/数字字宽 半个 fontSize
*/
const estimateTextWidth = (text, fontSize) => {
if (!text) return 0;
let width = 0;
for (const ch of String(text)) {
// CJK Unified Ideographs + +
if (/[ -鿿＀-￯]/.test(ch)) {
width += fontSize * 1.0;
} else {
width += fontSize * 0.55;
}
}
return Math.round(width);
};
/**
* 根据昵称+赠送道具名动态计算胶囊宽度
* 组成左右内边距(32) + 头像(78) + 头像间距(16) + 文字宽度 + 文字右侧间距(16)
* + 数量区宽度X + 数字数字按 1 位约 56rpx 兜底
*/
const getItemWidth = (record) => {
const nickname = record?.nickname || "";
const giftName = record?.item_name || "";
const nicknameWidth = estimateTextWidth(nickname, 24);
const giftWidth = estimateTextWidth("送" + giftName, 20);
//
const textWidth = Math.max(nicknameWidth, giftWidth);
// item-x (32) + (6) + item-count 1 56rpx font-size
// const quantityWidth = 32 + 6 + 56;
// 16 + 78 + 16 + 16 + 16
const FIXED = 16 + 78 + 16 + 16 + 16;
const total = FIXED + textWidth;
// min/max CSS
return Math.min(420, Math.max(200, total));
};
</script>
<style scoped>
@ -63,44 +128,54 @@ defineExpose({
width: 100%;
padding: 0 24rpx;
margin-top: 32rpx;
position: relative;
}
.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); */
/* 渐变模糊背景层Figma 中的 4 色径向 + 50px 模糊 */
.list-bg {
position: absolute;
top: 0;
left: 24rpx;
right: 24rpx;
height: 512rpx;
border-radius: 48rpx;
background: linear-gradient(
131.84deg,
rgba(154, 146, 255, 0.47) 9.69%,
rgba(255, 202, 229, 0.47) 43.91%,
rgba(255, 250, 253, 0.465) 76.13%,
rgba(63, 63, 76, 0.47) 91.61%
);
filter: blur(50rpx);
z-index: 0;
pointer-events: none;
}
.list-content {
height: 280rpx;
border-radius: 16rpx;
padding: 16rpx;
height: 512rpx;
border-radius: 24rpx;
padding: 24rpx 16rpx;
position: relative;
z-index: 1;
}
/* 单条贡献 item胶囊形状 + 红色阴影 + 宽度跟随昵称 */
.contribution-item {
width: 384rpx;
height: 8rpx;
/* 宽度由 :style 注入的 --item-width 控制跨端最稳CSS 仅兜底 min/max */
width: max-content;
min-width: 200rpx;
max-width: 420rpx;
height: 88rpx;
display: flex;
align-items: center;
padding: 16rpx 0;
background: rgba(0, 0, 0, 0.1);
margin-bottom: 24rpx;
animation: fadeIn 0.3s ease-out;
position: relative;
top: 32rpx;
border-radius: 999rpx;
margin-bottom: 48rpx;
}
.contribution-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
.new-item {
@ -111,65 +186,118 @@ defineExpose({
animation: fadeOut 0.5s forwards;
}
/* 左侧:用户信息容器(头像 + 文字) */
.user-info {
display: flex;
align-items: center;
background-image: url('@/static/rank/activity-support-icon/beijingkuang.png');
background-size: 105% 130%;
background-position: center;
/* padding: 0 32rpx 0 40rpx; */
/* 不再 flex:1 撑满,宽度跟随内容 */
flex: 0 0 auto;
min-width: 0;
border-radius: 999rpx;
margin-right: 32rpx;
padding: 8rpx 16rpx;
padding-right: 64rpx;
box-shadow: 0 4rpx 8rpx rgba(102, 7, 7, 0.25);
background-image: url("@/static/rank/activity-support-icon/beijingkuang.png");
background-size: 100% 130%;
background-position: center;
}
/* 头像:圆形 + 红色阴影 */
.user-avatar {
width: 40rpx;
height: 40rpx;
width: 78rpx;
height: 78rpx;
border-radius: 50%;
margin-right: 16rpx;
box-shadow: 2rpx 2rpx 8rpx rgba(181, 7, 7, 0.54);
flex-shrink: 0;
}
.user-nickname {
font-size: 16rpx;
color: #fff;
padding: 0 32rpx 0 16rpx;
max-width: 120rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
}
.item-icon {
width: 72rpx;
height: 72rpx;
position: relative;
bottom: 8rpx;
margin-right: 24rpx;
}
.quantity-info {
position: relative;
right: 0;
bottom: 8rpx;
/* 文字容器(昵称 + 赠送动作) */
.user-text {
display: flex;
align-items: baseline;
flex-direction: column;
justify-content: center;
/* 不再 flex:1让昵称真实宽度决定容器宽度 */
flex: 0 0 auto;
white-space: nowrap;
}
.item-quantity {
font-size: 20rpx;
color: #ffcc00;
/* 昵称:黄色 + 红色文字阴影 */
.user-nickname {
font-size: 24rpx;
font-weight: bold;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
margin-right: 8rpx;
color: #fff9a1;
text-shadow: -2rpx 2rpx 8rpx rgba(206, 9, 9, 0.45);
white-space: nowrap;
line-height: 1.2;
font-family: "Abhaya Libre ExtraBold", sans-serif;
}
/* 赠送动作文本 */
.gift-text {
font-size: 20rpx;
color: #ffffff;
text-shadow: -2rpx 2rpx 8rpx rgba(206, 9, 9, 0.45);
white-space: nowrap;
line-height: 1.2;
margin-top: 2rpx;
font-family: "Abhaya Libre ExtraBold", sans-serif;
}
/* 道具图标外层:带 11° 旋转 + 红色阴影 + 绝对定位伸出版心 */
.item-icon-wrap {
display: flex;
/* position: absolute;
right: -64rpx; */
border-radius: 8rpx;
margin-left: -40rpx;
}
/* 道具图标本体:填满外层模块 */
.item-icon {
width: 96rpx;
height: 96rpx;
display: block;
transform: rotate(11deg);
transform-origin: center center;
}
/* 数量区域X 白色 + 数字黄色 */
.quantity-info {
display: flex;
align-items: flex-end;
text-shadow: -0.0625rem 0.0625rem 0.25rem rgba(206, 9, 9, 0.45);
margin-left: 16rpx;
}
.item-x {
font-size: 32rpx;
color: #ffffff;
font-style: italic;
margin-right: 6rpx;
}
.item-count {
font-size: 38rpx;
color: #ffcc00;
font-size: 56rpx;
color: #f7e600;
font-weight: bold;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
line-height: 1;
font-style: italic;
}
/* 数字位数不同对应不同尺寸,对照 Figma 设计3位 76rpx / 2位 52rpx / 1位 48rpx */
.count-1-digit {
font-size: 48rpx;
}
.count-2-digit {
font-size: 52rpx;
}
.count-3-digit {
font-size: 76rpx;
}
.count-4-digit,
.count-5-digit {
font-size: 64rpx;
}
@keyframes fadeIn {
@ -195,7 +323,11 @@ defineExpose({
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>
</style>

View File

@ -0,0 +1,108 @@
<template>
<view class="message-board">
<scroll-view class="message-board-scroll" scroll-y :scroll-top="scrollTop">
<view v-for="msg in messages" :key="msg.id" class="message-bubble">
<text class="msg-user">{{ msg.user }}</text>
<text class="msg-content">{{ msg.content }}</text>
</view>
<view v-if="messages.length === 0" class="empty-tip">
<text>暂无留言,快来抢沙发吧~</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, watch, nextTick } from "vue";
const props = defineProps({
messages: {
type: Array,
default: () => [],
},
});
const scrollTop = ref(0);
// ,
watch(
() => props.messages.length,
async () => {
await nextTick();
scrollTop.value = 99999;
},
);
</script>
<style scoped>
.message-board {
width: 100%;
position: fixed;
bottom: 0;
padding: 0 34rpx; /* 对应 Figma 中 left: 17px */
}
.message-board-scroll {
height: 480rpx;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start; /* 气泡左对齐,宽度适应内容 */
gap: 6rpx; /* 对应 Figma 中 3px 间距,rpx 自动换算 */
}
/* 留言气泡:对应 Figma 留言板 的圆角矩形 */
.message-bubble {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
max-width: 100%;
padding: 6rpx 16rpx; /* 适当内边距,让文字与气泡边距舒适 */
background: rgba(42, 17, 17, 0.3);
border-radius: 16rpx; /* 8px = 16rpx */
/* Figma 中的微妙旋转与倾斜,营造手写/手帐感 */
transform: rotate(-0.44deg) skewX(-0.1deg);
animation: bubbleFadeIn 0.3s ease-out;
box-sizing: border-box;
}
.msg-user {
font-size: 24rpx; /* 12px = 24rpx */
color: #acf0c3;
font-weight: bold;
text-shadow: -1rpx 1rpx 4rpx rgba(206, 9, 9, 0.45);
font-family: "Abhaya Libre ExtraBold", sans-serif;
line-height: 1.5;
}
.msg-content {
font-size: 24rpx; /* 12px = 24rpx */
color: #ffffff;
font-weight: bold;
text-shadow: -1rpx 1rpx 4rpx rgba(206, 9, 9, 0.45);
font-family: "Abhaya Libre ExtraBold", sans-serif;
line-height: 1.5;
word-break: break-all;
}
.empty-tip {
display: flex;
align-items: center;
justify-content: center;
height: 200rpx;
width: 100%;
color: rgba(255, 255, 255, 0.5);
font-size: 24rpx;
}
@keyframes bubbleFadeIn {
from {
opacity: 0;
transform: translateY(20rpx) rotate(-0.44deg) skewX(-0.1deg);
}
to {
opacity: 1;
transform: translateY(0) rotate(-0.44deg) skewX(-0.1deg);
}
}
</style>

View File

@ -0,0 +1,206 @@
<template>
<!--
Figma 147:325 (Group 50, 296x42)
展示型留言条:左侧圆形头像 + 中间居中文本 + 右侧表情 + 发送箭头
点击整条 / 点击发送箭头会触发 send 事件,父组件可弹出真实输入面板
-->
<view class="message-row" @click="handleTap">
<!-- 外层 pill 容器 (296x42, radius 20) -->
<view class="message-row__pill">
<!-- 内嵌凹槽 (238x30, radius 16) -->
<view class="message-row__groove">
<text class="message-row__text">{{ displayText }}</text>
</view>
</view>
<!-- 左侧圆形头像 (35x35) -->
<view class="message-row__avatar">
<image
class="message-row__avatar-img"
:src="avatar || defaultAvatar"
mode="aspectFill"
/>
</view>
<!-- 骰子表情 (17x17) -->
<image
class="message-row__emoji"
src="/static/rank/activity-support-icon/message-row/emoji.png"
mode="aspectFit"
/>
<!-- 发送箭头 (21x21) -->
<view class="message-row__send" @click.stop="handleSend">
<image
class="message-row__send-img"
src="/static/rank/activity-support-icon/message-row/send.png"
mode="aspectFit"
/>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
/**
* 展示的文案:父组件可传入最新一条留言,默认使用 Figma 设计稿文案
*/
text: {
type: String,
default: "今天第一天姐妹们大家冲鸭!!!",
},
/**
* 头像 URL,未传时回退到默认头像
*/
avatar: {
type: String,
default: "",
},
/**
* 兼容旧用法:外部如果还在传 placeholder,作为 text 的兜底
*/
placeholder: {
type: String,
default: "",
},
});
const emit = defineEmits(["send", "tap"]);
const defaultAvatar =
"/static/rank/activity-support-icon/message-row/avatar.png";
const displayText = computed(() => {
if (props.text && props.text.trim().length > 0) return props.text;
if (props.placeholder && props.placeholder.trim().length > 0)
return props.placeholder;
return "今天第一天姐妹们大家冲鸭!!!";
});
function handleTap() {
emit("tap");
}
function handleSend() {
// :, /
emit("send", displayText.value);
}
</script>
<style scoped>
/*
* 设计稿原始尺寸 (px) rpx ( 750 / 375 = 2 倍换算)
* 容器 296 x 42 592rpx x 84rpx
* 凹槽 238 x 30 476rpx x 60rpx (left 12px=24rpx,top 6px=12rpx)
* 头像 35 x 35 70rpx x 70rpx (left 6px=12rpx,top 3px=6rpx)
* 文本 11px 22rpx
* emoji 17 x 17 34rpx x 34rpx
* send 21 x 21 42rpx x 42rpx
*/
.message-row {
position: fixed;
left: 22rpx; /* 11px */
bottom: 32rpx;
width: 592rpx;
height: 84rpx;
z-index: 50;
}
/* 外层 pill */
.message-row__pill {
position: absolute;
inset: 0;
background: rgba(217, 217, 217, 0.4);
border-radius: 40rpx; /* 20px */
box-shadow: 2rpx 4rpx 8rpx 0 rgba(0, 0, 0, 0.25);
}
/* 内嵌凹槽 */
.message-row__groove {
position: absolute;
left: 24rpx; /* 12px */
top: 12rpx; /* 6px */
width: 476rpx; /* 238px */
height: 60rpx; /* 30px */
background: rgba(54, 51, 51, 0.1);
border-radius: 32rpx; /* 16px */
display: flex;
align-items: center;
/* 头像突出在 pill 左侧,文字从凹槽右半段开始;末尾留出表情 + 发送图标空间 */
padding-left: 80rpx;
padding-right: 120rpx;
overflow: hidden;
}
.message-row__text {
display: block;
width: 100%;
font-size: 22rpx; /* 11px */
line-height: 1.3;
color: #ffffff;
font-weight: bold;
font-family: "Abhaya Libre ExtraBold", "PingFang SC", sans-serif;
text-shadow: -2rpx 2rpx 8rpx rgba(206, 9, 9, 0.45);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* 设计稿的轻微倾斜 */
transform: rotate(-0.44deg) skewX(-0.1deg);
transform-origin: center;
}
/* 左侧头像 (突出 pill 左缘) */
.message-row__avatar {
position: absolute;
left: 12rpx; /* 6px */
top: 6rpx; /* 3px */
width: 70rpx; /* 35px */
height: 70rpx;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 14rpx 0 rgba(203, 24, 24, 0.84);
background: #2a1111;
}
.message-row__avatar-img {
width: 100%;
height: 100%;
display: block;
}
/* 骰子表情 */
.message-row__emoji {
position: absolute;
right: 92rpx; /* ≈ 46px from right edge */
top: 24rpx; /* (84-34)/2 ≈ 25 */
width: 34rpx; /* 17px */
height: 34rpx;
filter: drop-shadow(0 4rpx 8rpx rgba(138, 6, 6, 0.74));
}
/* 右侧发送箭头 (可点击) */
.message-row__send {
position: absolute;
right: 28rpx; /* ≈ 14px */
top: 20rpx; /* 21 */
width: 42rpx; /* 21px */
height: 42rpx;
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 6rpx 8rpx rgba(0, 0, 0, 0.47));
}
.message-row__send-img {
width: 100%;
height: 100%;
}
.message-row__send:active {
transform: scale(0.92);
transition: transform 0.12s ease;
}
</style>

View File

@ -0,0 +1,240 @@
<template>
<view class="top-ranking" @tap="handleOpenRanking">
<!-- TOP3 头像组对应 Figma 110-671 -->
<view v-if="top3List.length > 0" class="top3-card">
<view v-for="item in top3List" :key="item.userId" class="top3-item">
<image
class="top3-avatar"
:src="item.avatar || '/static/avatar/1.jpeg'"
mode="aspectFill"
@error="handleAvatarError"
/>
<image
class="top3-medal"
:src="`/static/rank/rank-icon${item.rank}.png`"
mode="aspectFit"
/>
</view>
</view>
<!-- 我的排名条对应 Figma 110-662 -->
<view v-if="myInfo.rank" class="my-rank-card">
<view class="my-rank-row">
<image
class="my-avatar"
:src="myInfo.avatar || '/static/avatar/1.jpeg'"
mode="aspectFill"
@error="handleAvatarError"
/>
<text class="my-rank-label">当前排名</text>
<text class="my-rank-number">{{ myInfo.rank }}</text>
<image
class="my-rank-icon"
src="/static/rank/lsph.png"
mode="aspectFit"
/>
</view>
<view class="my-rank-row">
<text class="my-rank-label">距离上一名贡献值</text>
<text class="my-rank-number">{{ myInfo.gapToPrev }}</text>
<image
class="my-rank-icon"
src="/static/icon/crystal.png"
mode="aspectFit"
/>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { getActivityRankingApi } from "@/utils/api.js";
const props = defineProps({
activityId: {
type: [String, Number],
required: true,
},
starId: {
type: [String, Number],
default: null,
},
});
const emit = defineEmits(["open-ranking"]);
// TOP3
const top3List = ref([]);
//
const myInfo = ref({
rank: null,
avatar: "",
gapToPrev: 0,
});
//
function handleAvatarError(e) {
e.target.src = "/static/avatar/1.jpeg";
}
//
function handleOpenRanking() {
emit("open-ranking");
}
// ( N-1 total_contribution - total_contribution)
function calcGapToPrev(myContribution, allItems) {
if (!myContribution || !Array.isArray(allItems)) return 0;
const myRank = myContribution.rank;
if (!myRank || myRank <= 1) return 0; // 1
const prev = allItems.find((u) => u.rank === myRank - 1);
if (!prev) return 0;
const gap =
(prev.total_contribution || 0) - (myContribution.total_contribution || 0);
return gap > 0 ? gap : 0;
}
//
async function loadRanking() {
if (!props.activityId) return;
try {
const sid = props.starId || uni.getStorageSync("star_id");
const res = await getActivityRankingApi(props.activityId, sid, 1, 3);
if (res && res.code === 0 && res.data) {
const items = Array.isArray(res.data.items) ? res.data.items : [];
// TOP3
top3List.value = items
.filter((u) => u.rank >= 1 && u.rank <= 3)
.sort((a, b) => a.rank - b.rank)
.map((u) => ({
rank: u.rank,
userId: String(u.user_id),
avatar: u.avatar_url || "/static/avatar/1.jpeg",
}));
//
const my = res.data.my_contribution;
if (my && my.rank) {
myInfo.value = {
rank: my.rank,
avatar: my.avatar_url || "/static/avatar/1.jpeg",
gapToPrev: calcGapToPrev(my, items),
};
} else {
myInfo.value = { rank: null, avatar: "", gapToPrev: 0 };
}
}
} catch (err) {
console.error("[TopRanking] 加载排行失败", err);
}
}
onMounted(() => {
loadRanking();
});
defineExpose({
refresh: loadRanking,
});
</script>
<style scoped>
.top-ranking {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12rpx;
}
/* TOP3 卡片(对应 110-671圆角深红半透明 + 3 个头像 + 奖牌) */
.top3-card {
display: flex;
align-items: center;
gap: 8rpx;
padding: 6rpx 12rpx;
background: rgba(42, 17, 17, 0.3);
border-radius: 22rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.15);
}
.top3-item {
position: relative;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.top3-avatar {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 2rpx solid rgba(255, 255, 255, 0.6);
background: #fff;
}
.top3-medal {
position: absolute;
bottom: -10rpx;
left: 50%;
transform: translateX(-50%);
width: 32rpx;
height: 32rpx;
}
/* 我的排名卡片(对应 110-662圆角深红半透明 + 头像 + 文案 + 数字 + 图标) */
.my-rank-card {
display: flex;
flex-direction: column;
gap: 4rpx;
padding: 8rpx 14rpx;
background: rgba(42, 17, 17, 0.3);
border-radius: 22rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.15);
min-width: 208rpx;
}
.my-rank-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.my-avatar {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 2rpx solid rgba(255, 255, 255, 0.6);
box-shadow: 1px 1px 4px 0 rgba(181, 7, 7, 0.54);
background: #fff;
margin-right: 4rpx;
}
.my-rank-label {
font-size: 20rpx;
color: #fff;
font-weight: bold;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.45);
flex: 1;
white-space: nowrap;
text-align: right;
}
.my-rank-number {
font-size: 28rpx;
color: #fffabd;
font-weight: bold;
font-family: "yt", "Baloo Bhai", sans-serif;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.45);
margin: 0 8rpx;
}
.my-rank-icon {
width: 40rpx;
height: 32rpx;
transform: rotate(-10deg);
}
</style>

View File

@ -5,12 +5,15 @@ import { getActivityContributionsLatestApi } from '@/utils/api.js'
* 贡献轮询逻辑 composable
* @param {Ref<string>} activityId - 活动ID
* @param {boolean} isPageActive - 页面是否可见
* @param {Object} [options] - 配置项
* @param {boolean} [options.enableTTL=false] - 是否开启"每条记录 5 秒后自动消失"的开关默认关闭记录一直保留直到被新数据挤掉
* @param {number} [options.ttlMs=5000] - 自动消失的延时毫秒仅在 enableTTL=true 时生效
* @returns {Object} records, visible, loading, error, start, stop, reset
*/
export function useContributionPolling(activityId, isPageActive) {
export function useContributionPolling(activityId, isPageActive, options = {}) {
const { enableTTL = false, ttlMs = 5000 } = options
const MAX_RECORDS = 5
const POLL_INTERVAL = 1000 // 每秒拉取
const RECORD_TTL = 5000 // 每条记录 5 秒后消失
const records = ref([])
const visible = ref(true)
@ -25,6 +28,9 @@ export function useContributionPolling(activityId, isPageActive) {
// 重置记录计时器
function resetRecordTimer(record) {
// TTL 功能未开启时:不做任何定时器与淡出处理,记录会一直保留
if (!enableTTL) return
// 清除已有定时器
if (recordTimers.has(record.id)) {
clearTimeout(recordTimers.get(record.id))
@ -49,7 +55,7 @@ export function useContributionPolling(activityId, isPageActive) {
} else {
recordTimers.delete(record.id)
}
}, RECORD_TTL)
}, ttlMs)
recordTimers.set(record.id, timer)
}
@ -58,7 +64,7 @@ export function useContributionPolling(activityId, isPageActive) {
if (!activityId.value) return
try {
const res = await getActivityContributionsLatestApi(activityId.value, latestTimestamp, latestId, 1)
const res = await getActivityContributionsLatestApi(activityId.value, latestTimestamp, latestId, 3)
// 处理 code 不为 200 的情况(静默忽略)
if (res.code !== 0) return
@ -66,22 +72,22 @@ export function useContributionPolling(activityId, isPageActive) {
const newRecords = res.data?.records || []
if (newRecords.length === 0) return
const newRecord = newRecords[0]
// 检测到新记录(时间戳更新,或时间戳相同但 ID 更新)
const isNew = newRecord.created_at > latestTimestamp ||
(newRecord.created_at === latestTimestamp && newRecord.id > latestId)
// API 返回的 records 按 created_at DESC, id DESC 排序。
// 只需比对第一条;后面的记录时间戳/ID 必然不更小。
const firstRecord = newRecords[0]
const isNew = firstRecord.created_at > latestTimestamp ||
(firstRecord.created_at === latestTimestamp && firstRecord.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
// 一次性插入本轮拉取到的全部新记录API 已按"新→旧"排序,新的在前)
records.value = [...newRecords, ...records.value].slice(0, MAX_RECORDS)
// 为每条新记录启动消失计时器
newRecords.forEach(resetRecordTimer)
// 更新游标到最新一条
latestTimestamp = firstRecord.created_at
latestId = firstRecord.id
}
} catch (e) {
// 网络错误等,静默忽略,继续等待下一次轮询

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB