feat:修改活动页面
This commit is contained in:
parent
f06623339d
commit
6f29925e6a
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
108
frontend/pages/support-activity/components/MessageBoard.vue
Normal file
108
frontend/pages/support-activity/components/MessageBoard.vue
Normal 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>
|
||||
206
frontend/pages/support-activity/components/MessageInput.vue
Normal file
206
frontend/pages/support-activity/components/MessageInput.vue
Normal 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>
|
||||
240
frontend/pages/support-activity/components/TopRanking.vue
Normal file
240
frontend/pages/support-activity/components/TopRanking.vue
Normal 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>
|
||||
@ -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 |
BIN
frontend/static/rank/activity-support-icon/message-row/emoji.png
Normal file
BIN
frontend/static/rank/activity-support-icon/message-row/emoji.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
frontend/static/rank/activity-support-icon/message-row/send.png
Normal file
BIN
frontend/static/rank/activity-support-icon/message-row/send.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
BIN
frontend/static/rank/phtb.png
Normal file
BIN
frontend/static/rank/phtb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/static/rank/sjbj.png
Normal file
BIN
frontend/static/rank/sjbj.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Loading…
Reference in New Issue
Block a user