377 lines
9.3 KiB
Vue
377 lines
9.3 KiB
Vue
<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.combo_count > 1 ? record.combo_count : record.quantity)"
|
||
>{{
|
||
record.combo_count > 1 ? record.combo_count : 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);
|
||
|
||
// 使用实时 composable(WS 优先,断线降级为轮询)
|
||
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>
|