topfans/frontend/pages/support-activity/components/ContributionList.vue
2026-06-23 18:57:50 +08:00

375 lines
9.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="contribution-list" v-if="visible">
<!-- 渐变模糊背景层 -->
<view class="list-bg"></view>
<scroll-view
class="list-content"
scroll-y
:scroll-into-view="latestRecordId"
:scroll-with-animation="true"
:show-scrollbar="false"
:enhanced="true"
:bounces="false"
>
<view
v-for="(record, index) in records"
:key="record.id"
:id="`contribution-record-${record.id}`"
class="contribution-item"
:class="{ 'new-item': index === records.length - 1, 'fading-out': record.fading }"
>
<!-- 左侧:用户信息(头像 + 昵称 + 赠送动作) -->
<view class="user-info">
<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>
<!-- 右侧:道具图标(外层模块带 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="getCountSizeClass(record.quantity)"
>{{ record.quantity }}</text
>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useContributionRealtime } from "../composables/useContributionRealtime.js";
const props = defineProps({
activityId: {
type: String,
required: true,
},
});
const isPageActive = ref(true);
// 使用实时 composableWS 优先,断线降级为轮询)
const { records, visible, loading, error, isUsingWS } = useContributionRealtime(
computed(() => props.activityId),
isPageActive,
);
onMounted(() => {
isPageActive.value = true;
});
// 实时 composable 内部已在 onMounted/onUnmounted 中处理 start/stop/reset
// 此处无需重复调用 stop()。
onUnmounted(() => {
// 仅将页面标记为非激活;实际资源释放由 useContributionRealtime 内部完成。
isPageActive.value = false;
});
// 暴露给父组件使用
defineExpose({
// useContributionRealtime 内部管理生命周期,无需对外暴露 reset。
});
/**
* 根据数量值映射数字字号档位
* - >= 999 → count-largest最大
* - 520 ~ 998 → count-mid次大
* - < 520 → count-small最小
*/
const getCountSizeClass = (value) => {
const n = Number(value) || 0;
if (n >= 999) return "count-largest";
if (n >= 520) return "count-mid";
return "count-small";
};
// scroll-into-view 直接滚到指定 id 的元素,比 scroll-top 更可靠(不用猜位置)。
// 通过"先清空、再设值"的方式,确保每次都能触发 scroll-view 滚动
// (因为 scroll-into-view 值未变化时不会重新滚动)。
//
// 关键设计:不能依赖 records[0] 或 records[N-1] 来定位"最新记录"——
// WS 路径是 append(最新在尾),polling 路径是 prepend(最新在头),方向相反。
// 由于 id 单调递增(后端保证 + useContributionRealtime 也用 highestIdRef 判断),
// 所以直接找 id 最大的那条记录,无论它在头还是在尾,都能正确滚过去。
const latestRecordId = ref("");
let scrollTimerId = null;
function getMaxIdRecord() {
const list = records.value;
if (list.length === 0) return null;
let max = list[0];
for (let i = 1; i < list.length; i++) {
if (list[i].id > max.id) max = list[i];
}
return max;
}
function scrollToLatest() {
// 防抖:快速连续触发时只滚最后一次
if (scrollTimerId) clearTimeout(scrollTimerId);
scrollTimerId = setTimeout(() => {
const target = getMaxIdRecord();
if (!target) return;
const targetId = `contribution-record-${target.id}`;
// 关键:清空再赋值,确保 scroll-into-view 一定触发
latestRecordId.value = "";
// 用 setTimeout 把赋值推到下一个宏任务,避开 Vue 的批处理
setTimeout(() => {
latestRecordId.value = targetId;
}, 0);
scrollTimerId = null;
}, 50);
}
// 监听"id 最大的那条记录的 id"——这是真正能感知"新记录到达"的信号。
// 注意:不能只 watch length —— 当列表已满 MAX_RECORDS=5 时,
// WS 是 [...records, record].slice(-5),polling 也是 [...newRecords, ...records].slice(0, 5),
// 两种情况下 length 都不变,只会替换现有元素,length watch 不触发。
watch(
() => {
const target = getMaxIdRecord();
return target ? target.id : null;
},
() => scrollToLatest(),
);
// 监听 visible 变化:scroll-view 通过 v-if="visible" 渲染,visible 由 false→true
// 时 scroll-view 才挂载,需要在挂载完成后立即滚动一次
watch(
() => visible.value,
(val) => {
if (val) {
scrollToLatest();
}
},
);
// 组件挂载后,如果已有历史记录,也立即滚动到 id 最大的那条
onMounted(() => {
if (records.value.length > 0) {
scrollToLatest();
}
});
</script>
<style scoped>
.contribution-list {
width: 100%;
padding: 0 24rpx;
margin-top: 32rpx;
position: relative;
}
/* 渐变模糊背景层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: 512rpx;
border-radius: 24rpx;
padding: 24rpx 16rpx;
position: relative;
z-index: 1;
}
/* 单条贡献 item胶囊形状 + 红色阴影 + 宽度跟随昵称 */
.contribution-item {
/* 宽度由 :style 注入的 --item-width 控制跨端最稳CSS 仅兜底 min/max */
width: max-content;
min-width: 200rpx;
max-width: 420rpx;
height: 88rpx;
display: flex;
align-items: center;
margin-bottom: 24rpx;
animation: fadeIn 0.3s ease-out;
position: relative;
}
.contribution-item:last-child {
margin-bottom: 0;
}
.new-item {
animation: slideIn 0.3s ease-out;
}
.fading-out {
animation: fadeOut 0.5s forwards;
}
/* 左侧:用户信息容器(头像 + 文字) */
.user-info {
display: flex;
align-items: center;
/* 不再 flex:1 撑满,宽度跟随内容 */
flex: 0 0 auto;
min-width: 0;
border-radius: 999rpx;
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: 78rpx;
height: 78rpx;
border-radius: 50%;
margin-right: 16rpx;
box-shadow: 2rpx 2rpx 8rpx rgba(181, 7, 7, 0.54);
flex-shrink: 0;
}
/* 文字容器(昵称 + 赠送动作) */
.user-text {
display: flex;
flex-direction: column;
justify-content: center;
/* 不再 flex:1让昵称真实宽度决定容器宽度 */
flex: 0 0 auto;
white-space: nowrap;
}
/* 昵称:黄色 + 红色文字阴影 */
.user-nickname {
font-size: 24rpx;
font-weight: bold;
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(10deg);
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: 56rpx;
color: #f7e600;
font-weight: bold;
line-height: 1;
font-style: italic;
}
/* 数量档位样式999+ 顶格、520~998 中档、其他常规 */
.count-largest {
font-size: 76rpx;
}
.count-mid {
font-size: 52rpx;
}
.count-small {
font-size: 48rpx;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>