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" :showTaskIcon="false"
:showStarActivityIcon="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> <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) { function isEnded(item) {
return item.status === "expired" || item.status === "completed"; 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 { .status-bar {
width: 100%; width: 100%;
background: transparent; background: transparent;

View File

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

View File

@ -1,8 +1,7 @@
<template> <template>
<view class="contribution-list" v-if="visible"> <view class="contribution-list" v-if="visible">
<!-- <view class="list-header"> <!-- 渐变模糊背景层 -->
<text class="header-title">实时贡献</text> <view class="list-bg"></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"
@ -10,14 +9,34 @@
class="contribution-item" class="contribution-item"
:class="{ 'new-item': index === 0, 'fading-out': record.fading }" :class="{ 'new-item': index === 0, 'fading-out': record.fading }"
> >
<!-- 左侧用户信息头像 + 昵称 + 赠送动作 -->
<view class="user-info"> <view class="user-info">
<image class="user-avatar" :src="record.avatar_url" mode="aspectFill" /> <image
class="user-avatar"
:src="record.avatar_url"
mode="aspectFill"
/>
<view class="user-text">
<text class="user-nickname">{{ record.nickname }}</text> <text class="user-nickname">{{ record.nickname }}</text>
<text class="gift-text" v-if="record.item_name"
>{{ record.item_name }}</text
>
</view> </view>
</view>
<!-- 右侧道具图标外层模块带 11° 旋转 + 红色阴影 -->
<view class="item-icon-wrap">
<image class="item-icon" :src="record.item_icon" mode="aspectFill" /> <image class="item-icon" :src="record.item_icon" mode="aspectFill" />
<view class="quantity-info"> <view class="quantity-info">
<text class="item-quantity">x</text> <text class="item-x">X</text>
<text class="item-count">{{ record.combo_count > 1 ? record.combo_count : record.quantity }}</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>
</view> </view>
</scroll-view> </scroll-view>
@ -25,37 +44,83 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from "vue";
import { useContributionPolling } from '../composables/useContributionPolling.js' import { useContributionPolling } from "../composables/useContributionPolling.js";
const props = defineProps({ const props = defineProps({
activityId: { activityId: {
type: String, type: String,
required: true required: true,
} },
}) });
const isPageActive = ref(true) const isPageActive = ref(true);
// 使 composable // 使 composable
const { records, visible, loading, error, start, stop, reset } = useContributionPolling( const { records, visible, loading, error, start, stop, reset } =
useContributionPolling(
computed(() => props.activityId), computed(() => props.activityId),
isPageActive isPageActive,
) );
onMounted(() => { onMounted(() => {
isPageActive.value = true isPageActive.value = true;
}) });
// //
onUnmounted(() => { onUnmounted(() => {
stop() stop();
}) });
// 使 // 使
defineExpose({ 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> </script>
<style scoped> <style scoped>
@ -63,44 +128,54 @@ defineExpose({
width: 100%; width: 100%;
padding: 0 24rpx; padding: 0 24rpx;
margin-top: 32rpx; margin-top: 32rpx;
position: relative;
} }
.list-header { /* 渐变模糊背景层Figma 中的 4 色径向 + 50px 模糊 */
display: flex; .list-bg {
align-items: center; position: absolute;
justify-content: center; top: 0;
margin-bottom: 16rpx; left: 24rpx;
} right: 24rpx;
height: 512rpx;
.header-title { border-radius: 48rpx;
font-size: 28rpx; background: linear-gradient(
font-weight: bold; 131.84deg,
color: #fff; rgba(154, 146, 255, 0.47) 9.69%,
/* text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5); */ 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 { .list-content {
height: 280rpx; height: 512rpx;
border-radius: 16rpx; border-radius: 24rpx;
padding: 16rpx; padding: 24rpx 16rpx;
position: relative;
z-index: 1;
} }
/* 单条贡献 item胶囊形状 + 红色阴影 + 宽度跟随昵称 */
.contribution-item { .contribution-item {
width: 384rpx; /* 宽度由 :style 注入的 --item-width 控制跨端最稳CSS 仅兜底 min/max */
height: 8rpx; width: max-content;
min-width: 200rpx;
max-width: 420rpx;
height: 88rpx;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 16rpx 0;
background: rgba(0, 0, 0, 0.1); margin-bottom: 24rpx;
animation: fadeIn 0.3s ease-out; animation: fadeIn 0.3s ease-out;
position: relative; position: relative;
top: 32rpx;
border-radius: 999rpx;
margin-bottom: 48rpx;
} }
.contribution-item:last-child { .contribution-item:last-child {
border-bottom: none; margin-bottom: 0;
} }
.new-item { .new-item {
@ -111,65 +186,118 @@ defineExpose({
animation: fadeOut 0.5s forwards; animation: fadeOut 0.5s forwards;
} }
/* 左侧:用户信息容器(头像 + 文字) */
.user-info { .user-info {
display: flex; display: flex;
align-items: center; align-items: center;
background-image: url('@/static/rank/activity-support-icon/beijingkuang.png'); /* 不再 flex:1 撑满,宽度跟随内容 */
background-size: 105% 130%; flex: 0 0 auto;
background-position: center; min-width: 0;
/* padding: 0 32rpx 0 40rpx; */
border-radius: 999rpx; 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 { .user-avatar {
width: 40rpx; width: 78rpx;
height: 40rpx; height: 78rpx;
border-radius: 50%; border-radius: 50%;
margin-right: 16rpx; margin-right: 16rpx;
box-shadow: 2rpx 2rpx 8rpx rgba(181, 7, 7, 0.54);
flex-shrink: 0;
} }
.user-nickname { /* 文字容器(昵称 + 赠送动作) */
font-size: 16rpx; .user-text {
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;
display: flex; 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; .user-nickname {
color: #ffcc00; font-size: 24rpx;
font-weight: bold; font-weight: bold;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5); color: #fff9a1;
margin-right: 8rpx; 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 { .item-count {
font-size: 38rpx; font-size: 56rpx;
color: #ffcc00; color: #f7e600;
font-weight: bold; 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 { @keyframes fadeIn {
@ -195,7 +323,11 @@ defineExpose({
} }
@keyframes fadeOut { @keyframes fadeOut {
from { opacity: 1; } from {
to { opacity: 0; } 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 * 贡献轮询逻辑 composable
* @param {Ref<string>} activityId - 活动ID * @param {Ref<string>} activityId - 活动ID
* @param {boolean} isPageActive - 页面是否可见 * @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 * @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 MAX_RECORDS = 5
const POLL_INTERVAL = 1000 // 每秒拉取 const POLL_INTERVAL = 1000 // 每秒拉取
const RECORD_TTL = 5000 // 每条记录 5 秒后消失
const records = ref([]) const records = ref([])
const visible = ref(true) const visible = ref(true)
@ -25,6 +28,9 @@ export function useContributionPolling(activityId, isPageActive) {
// 重置记录计时器 // 重置记录计时器
function resetRecordTimer(record) { function resetRecordTimer(record) {
// TTL 功能未开启时:不做任何定时器与淡出处理,记录会一直保留
if (!enableTTL) return
// 清除已有定时器 // 清除已有定时器
if (recordTimers.has(record.id)) { if (recordTimers.has(record.id)) {
clearTimeout(recordTimers.get(record.id)) clearTimeout(recordTimers.get(record.id))
@ -49,7 +55,7 @@ export function useContributionPolling(activityId, isPageActive) {
} else { } else {
recordTimers.delete(record.id) recordTimers.delete(record.id)
} }
}, RECORD_TTL) }, ttlMs)
recordTimers.set(record.id, timer) recordTimers.set(record.id, timer)
} }
@ -58,7 +64,7 @@ export function useContributionPolling(activityId, isPageActive) {
if (!activityId.value) return if (!activityId.value) return
try { try {
const res = await getActivityContributionsLatestApi(activityId.value, latestTimestamp, latestId, 1) const res = await getActivityContributionsLatestApi(activityId.value, latestTimestamp, latestId, 3)
// 处理 code 不为 200 的情况(静默忽略) // 处理 code 不为 200 的情况(静默忽略)
if (res.code !== 0) return if (res.code !== 0) return
@ -66,22 +72,22 @@ export function useContributionPolling(activityId, isPageActive) {
const newRecords = res.data?.records || [] const newRecords = res.data?.records || []
if (newRecords.length === 0) return if (newRecords.length === 0) return
const newRecord = newRecords[0] // API 返回的 records 按 created_at DESC, id DESC 排序。
// 只需比对第一条;后面的记录时间戳/ID 必然不更小。
// 检测到新记录(时间戳更新,或时间戳相同但 ID 更新) const firstRecord = newRecords[0]
const isNew = newRecord.created_at > latestTimestamp || const isNew = firstRecord.created_at > latestTimestamp ||
(newRecord.created_at === latestTimestamp && newRecord.id > latestId) (firstRecord.created_at === latestTimestamp && firstRecord.id > latestId)
if (isNew) { if (isNew) {
// 重置所有现有记录的计时器(新数据到来,刷新列表) // 重置所有现有记录的计时器(新数据到来,刷新列表)
records.value.forEach(resetRecordTimer) records.value.forEach(resetRecordTimer)
// 新记录插入到列表头部 // 一次性插入本轮拉取到的全部新记录API 已按"新→旧"排序,新的在前)
records.value = [newRecord, ...records.value].slice(0, MAX_RECORDS) records.value = [...newRecords, ...records.value].slice(0, MAX_RECORDS)
// 为新记录启动消失计时器 // 为每条新记录启动消失计时器
resetRecordTimer(newRecord) newRecords.forEach(resetRecordTimer)
// 更新时间戳和 ID // 更新游标到最新一条
latestTimestamp = newRecord.created_at latestTimestamp = firstRecord.created_at
latestId = newRecord.id latestId = firstRecord.id
} }
} catch (e) { } 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