topfans/frontend/pages/support-activity/center.vue
2026-06-22 12:37:59 +08:00

922 lines
21 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="center-container">
<!-- 顶部导航 -->
<!-- <Header
:show-back="true"
backIconColor="#3a2540"
:showGuideIcon="false"
: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>
<!-- 顶部毛绒装饰区直接用项目里现成的 centerhbj.png -->
<view class="hero-section">
<image
class="hero-bg-img"
src="/static/rank/centerhbj.png"
mode="aspectFill"
/>
</view>
<view class="main-box">
<!-- 统计条3 个独立渐变模块背景对应 Figma 108:567 / 108:579 / 108:581 -->
<view class="stats-strip">
<view class="stat-card stat-card-1">
<image
class="image-1"
src="/static/rank/hd.png"
mode="aspectFit"
></image>
<text class="stat-label">已参与活动</text>
<text class="stat-value">{{ stats.participated }}</text>
</view>
<view class="stat-card stat-card-2">
<image
class="image-2"
src="/static/icon/crystal.png"
mode="aspectFit"
></image>
<text class="stat-label">水晶贡献数</text>
<text class="stat-value">{{ formattedContributions }}</text>
</view>
<view class="stat-card stat-card-3">
<image
class="image-3"
src="/static/rank/lsph.png"
mode="aspectFit"
></image>
<text class="stat-label">历史最高排名</text>
<text class="stat-value">{{ formattedBestRank }}</text>
</view>
</view>
<!-- 标签切换 -->
<view class="tab-bar">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ 'tab-active': activeTab === tab.key }"
@click="activeTab = tab.key"
>
<text
class="tab-text"
:class="{ 'tab-text-active': activeTab === tab.key }"
>{{ tab.label }}</text
>
</view>
</view>
<!-- 活动列表 -->
<scroll-view
class="activity-scroll"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="onScrollToLower"
>
<view class="activity-list">
<view
v-for="item in filteredActivities"
:key="item.id"
class="activity-card"
@click="handleCardTap(item)"
>
<!-- 左侧封面 -->
<view class="card-cover">
<image
v-if="item.banner_image"
:src="item.banner_image"
class="cover-img"
mode="aspectFill"
:lazy-load="true"
/>
<view class="date-row">
<text class="date-text">{{ formatDateRange(item) }}</text>
<text v-if="!isEnded(item)" class="countdown-text">
距离活动结束还有{{ getDaysLeft(item) }}天
</text>
</view>
<!-- <view v-else class="cover-fallback"
>{{ item.title || "活动" }}
</view> -->
<view
v-if="item.status === 'expired' || item.status === 'completed'"
class="cover-ended"
>
<text class="ended-text">活动已结束</text>
</view>
</view>
<!-- 右侧信息 -->
<view class="card-info">
<view class="info-row">
<view class="year-pill">
<text class="year-text">{{ getYear(item) }}</text>
<text class="name-text">{{ getShortName(item) }}</text>
</view>
</view>
<view class="stat-row">
<view class="stat-block">
<text class="block-value">{{
formatStatValue(item.my_contribution)
}}</text>
<text class="block-label">水晶贡献数</text>
<image
class="stat-image"
src="/static/icon/crystal.png"
mode="aspectFit"
></image>
</view>
<view class="stat-block">
<text class="block-value">{{
formatRank(item.my_rank)
}}</text>
<text class="block-label">排名</text>
<image
class="block-image"
src="/static/rank/ph.png"
mode="aspectFit"
></image>
</view>
</view>
</view>
</view>
<view
v-if="filteredActivities.length === 0 && !isLoading"
class="empty-block"
>
<text class="empty-text">暂无活动</text>
</view>
<view v-if="filteredActivities.length > 0" class="footer-end">
<text class="footer-text">已经到底啦~</text>
</view>
</view>
</scroll-view>
<!-- 加载/错误遮罩 -->
<view v-if="isLoading" class="loading-mask">
<view class="loading-spinner"></view>
<text class="loading-text">加载中…</text>
</view>
<view v-if="errorMessage" class="error-mask">
<text class="error-title">加载失败</text>
<text class="error-msg">{{ errorMessage }}</text>
<view class="retry-btn" @click="loadData">
<text class="retry-text">重试</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
import Header from "../components/Header.vue";
import { getActivityListApi, getActivityRankingApi } from "@/utils/api.js";
import screenCache from "@/utils/screen-cache";
const statusBarHeight = ref(0);
const isLoading = ref(true);
const refreshing = ref(false);
const errorMessage = ref("");
const activities = ref([]);
const activeTab = ref("all");
const tabs = [
{ key: "all", label: "全部" },
{ key: "active", label: "进行中" },
{ key: "ended", label: "已结束" },
];
// 顶部 3 项统计(聚合自活动列表)
const stats = ref({
participated: 0,
contributions: 0,
bestRank: 0,
});
const formattedContributions = computed(() =>
formatStatValue(stats.value.contributions),
);
const formattedBestRank = computed(() => {
const r = stats.value.bestRank;
if (!r) return "—";
if (r >= 10000) return `${Math.floor(r / 1000)}W+`;
return String(r);
});
const filteredActivities = computed(() => {
if (activeTab.value === "all") return activities.value;
if (activeTab.value === "active") {
return activities.value.filter((a) => a.status === "active");
}
return activities.value.filter(
(a) => a.status === "expired" || a.status === "completed",
);
});
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";
}
function getYear(item) {
const t = item.start_time || "";
return t.slice(0, 4) || "—";
}
function getShortName(item) {
// Figma 显示 "巴士星援会/生日星援会/星演会",对应 activity.theme
return item.theme || item.title || "活动";
}
function formatDateRange(item) {
const s = (item.start_time || "").slice(0, 10).replace(/-/g, ".");
const e = (item.end_time || "").slice(0, 10).replace(/-/g, ".");
if (s && e) return `${s}~${e}`;
if (s) return s;
return "—";
}
function getDaysLeft(item) {
if (!item.end_time) return 0;
const end = new Date(item.end_time.replace(/-/g, "/")).getTime();
const now = Date.now();
if (Number.isNaN(end) || end < now) return 0;
return Math.max(0, Math.ceil((end - now) / 86400000));
}
function formatStatValue(v) {
if (v === null || v === undefined || v === 0) return "0";
return String(v);
}
function formatRank(rank) {
if (rank === null || rank === undefined) return "—";
if (rank === 0) return "未上榜";
if (rank >= 10000) return "1W+";
return String(rank);
}
async function fetchWithRanking(list) {
// 并发拉取每个活动的「我的贡献/排名」
const starId = uni.getStorageSync("star_id") || null;
const tasks = list.map(async (a) => {
try {
const res = await getActivityRankingApi(a.id, starId, 1, 10);
if (res?.code === 0 && res.data) {
const mine = res.data.my_contribution;
return {
...a,
my_contribution: mine?.total_contribution ?? 0,
my_rank: mine?.rank ?? null,
};
}
} catch (e) {
console.error("[center] 拉取活动贡献失败", a.id, e?.message ?? e);
}
return { ...a, my_contribution: 0, my_rank: null };
});
return Promise.all(tasks);
}
function aggregate(merged) {
const participated = merged.length;
const contributions = merged.reduce(
(sum, a) => sum + (a.my_contribution || 0),
0,
);
const ranks = merged.map((a) => a.my_rank).filter((r) => r && r > 0);
const bestRank = ranks.length ? Math.min(...ranks) : 0;
stats.value = { participated, contributions, bestRank };
}
async function loadData() {
try {
isLoading.value = true;
errorMessage.value = "";
const starId = uni.getStorageSync("star_id");
if (!starId) {
throw new Error("缺少粉丝身份,请先选择明星");
}
// 1. 拉活动列表
const res = await getActivityListApi(starId, 1, 50);
if (res?.code !== 0) {
throw new Error(res?.message || "获取活动列表失败");
}
const list = res.data?.activities || [];
// 2. 并发拉每个活动的我的贡献/排名
const merged = await fetchWithRanking(list);
// 3. 按开始时间倒序(新→旧)
merged.sort((a, b) => {
const ta = a.start_time ? new Date(a.start_time).getTime() : 0;
const tb = b.start_time ? new Date(b.start_time).getTime() : 0;
return tb - ta;
});
activities.value = merged;
aggregate(merged);
} catch (e) {
console.error("[center] loadData failed", e);
errorMessage.value = e?.message || "加载失败";
} finally {
isLoading.value = false;
refreshing.value = false;
}
}
function onRefresh() {
refreshing.value = true;
loadData();
}
function onScrollToLower() {
// 已是全量加载,无更多分页
}
function handleCardTap(item) {
// 跳转到对应活动详情页
uni.navigateTo({
url: `/pages/support-activity/index?id=${item.id}`,
fail: (err) => {
console.error("[center] 跳转活动详情失败:", err);
},
});
}
onMounted(() => {
screenCache.init();
statusBarHeight.value = screenCache.getStatusBarHeight();
loadData();
});
onShow(() => {
// 每次回到页面刷新一次(贡献数可能有变化)
if (activities.value.length > 0) {
loadData();
}
});
</script>
<style scoped lang="scss">
.center-container {
position: relative;
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(
180deg,
rgba(255, 229, 229, 0.25) -32.49%,
rgba(243, 160, 161, 0.25) -32.49%,
rgba(255, 156, 156, 0.25) 86.46%,
rgba(255, 32, 36, 0.25) 180.79%
);
// filter: blur(2px);
&::before {
content: "";
position: absolute;
inset: 0;
background: url("/static/rank/centerbj.png") center no-repeat;
background-size: 100% 100%;
}
}
.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;
}
/* ============ 顶部毛绒装饰区 ============ */
.hero-section {
position: absolute;
top: 0;
width: 100%;
height: 360rpx;
overflow: hidden;
}
.main-box {
position: relative;
display: flex;
flex-direction: column;
margin: 328rpx 10rpx 0;
padding: 0 0 40rpx;
min-height: 1324rpx;
border-radius: 28rpx;
background: linear-gradient(
180deg,
#ffd8dc 0%,
rgba(255, 90, 93, 0.2) 38%,
transparent 81.31%,
transparent 128.86%
);
// box-shadow: 0 12rpx 24rpx rgba(179, 50, 50, 0.15);
box-sizing: border-box;
overflow: hidden;
}
.hero-bg-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* ============ 统计条 ============ */
.stats-strip {
position: relative;
margin: -80rpx 24rpx 0;
z-index: 5;
display: flex;
align-items: flex-end;
justify-content: space-between;
// gap: 18rpx;
height: 200rpx;
padding: 0 16rpx;
}
.stat-card {
position: relative;
width: 200rpx;
height: 81.92rpx;
// padding-bottom: 12rpx;
border-radius: 38rpx 20rpx 14rpx 14rpx;
background: linear-gradient(
274deg,
rgba(168, 166, 237, 0.49) -9.28%,
rgba(136, 200, 216, 0.49) 61.89%,
rgba(243, 128, 239, 0.49) 106.57%
);
box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25);
z-index: 0;
box-sizing: border-box;
overflow: hidden;
}
/* 3 个模块各自的 ::before 背景图按需替换路径 */
.image-1 {
width: 100%;
height: 100%;
position: absolute;
left: -32rpx;
bottom: -8rpx;
}
.image-2 {
width: 100%;
height: 100%;
position: absolute;
left: -32rpx;
bottom: -16rpx;
transform: rotate(-10deg);
opacity: 0.6;
}
.image-3 {
width: 100%;
height: 100%;
position: absolute;
left: -32rpx;
bottom: 0;
transform: rotate(-2deg);
}
.stat-label {
position: absolute;
top: 0;
left: 8rpx;
z-index: 2;
font-size: 16rpx;
font-weight: bold;
color: #fffabd;
text-shadow: -2rpx 2rpx 8rpx rgba(206, 9, 9, 0.84);
letter-spacing: 0.5rpx;
margin-top: 4rpx;
}
.stat-value {
position: absolute;
bottom: 4rpx;
right: 12rpx;
z-index: 2;
font-size: 32rpx;
font-weight: bold;
color: #fffabd;
text-shadow: -2rpx 2rpx 8rpx rgba(206, 9, 9, 0.84);
line-height: 1.1;
margin-top: 2rpx;
}
/* ============ 标签切换对应 Figma 108:589 title组 ============ */
.tab-bar {
position: relative;
z-index: 5;
margin: 30rpx 128rpx 18rpx;
height: 64rpx;
border-radius: 32rpx;
background: rgba(217, 217, 217, 0.11);
box-shadow: 0 8rpx 8rpx 0 rgba(179, 50, 50, 0.25);
display: flex;
align-items: center;
justify-content: space-around;
padding: 0 8rpx;
}
.tab-item {
flex: 1;
height: 46rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 23rpx;
transition: all 0.2s ease;
}
.tab-active {
background: linear-gradient(
90deg,
rgba(255, 222, 8, 0.55) 0%,
rgba(252, 100, 102, 0.85) 64%,
rgba(244, 88, 104, 0.85) 100%
);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
}
.tab-text {
font-size: 26rpx;
font-weight: bold;
color: #fff;
text-shadow: 0 4rpx 6rpx rgba(0, 0, 0, 0.55);
opacity: 0.85;
}
.tab-text-active {
opacity: 1;
}
/* ============ 活动列表 ============ */
.activity-scroll {
flex: 1;
width: 100%;
padding: 0 24rpx;
box-sizing: border-box;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 36rpx;
padding-bottom: 60rpx;
}
.activity-card {
position: relative;
display: flex;
align-items: stretch;
gap: 24rpx;
min-height: 168rpx;
}
.card-cover {
position: relative;
width: 384rpx;
height: 168rpx;
border-radius: 24rpx;
overflow: hidden;
background: linear-gradient(135deg, #ffd6e0 0%, #ff9eb5 100%);
box-shadow: 0 8rpx 16rpx rgba(179, 50, 50, 0.25);
flex-shrink: 0;
z-index: 2;
}
.cover-img {
width: 100%;
height: 100%;
}
.cover-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28rpx;
font-weight: bold;
text-align: center;
padding: 0 16rpx;
box-sizing: border-box;
}
.cover-ended {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
border-radius: 24rpx;
}
.ended-text {
color: #ffffff;
font-size: 24rpx;
font-weight: bold;
text-shadow: -2rpx 2rpx 8rpx rgba(206, 9, 9, 0.84);
letter-spacing: 1rpx;
}
.card-info {
width: 320rpx;
height: 150.4rpx;
display: flex;
margin: 16rpx 0;
flex-direction: column;
justify-content: space-between;
background: url("static/rank/infobj.png") center no-repeat;
background-size: 100% 100%;
position: absolute;
right: 0;
}
.info-row {
display: flex;
align-items: center;
justify-content: center;
position: relative;
top: -16rpx;
}
.year-pill {
min-width: 192rpx;
height: 40rpx;
padding: 0 20rpx;
border-radius: 12rpx;
// background: linear-gradient(
// 176deg,
// rgba(255, 90, 93, 0.6) 16.6%,
// rgba(76, 237, 255, 0.6) 48%,
// rgba(255, 122, 124, 0.6) 84%
// );
// box-shadow: 0 4rpx 6rpx rgba(201, 60, 159, 0.5);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: url("static/rank/textbj.png") center no-repeat;
background-size: 100%;
}
.year-text {
display: block;
color: #fffabd;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
font-family: "Baloo Bhai";
font-size: 28rpx;
font-style: normal;
font-weight: 400;
line-height: normal;
margin-right: 8rpx;
}
.name-text {
display: block;
color: #fffabd;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
font-family: "Abhaya Libre ExtraBold";
font-size: 20rpx;
font-style: normal;
font-weight: 800;
line-height: normal;
margin-left: 8rpx;
}
.date-row {
height: 72rpx;
position: absolute;
z-index: 2;
inset: 0;
top: 88rpx;
}
.date-text {
color: #fffabd;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
font-family: "Baloo Bhai";
font-size: 20rpx;
font-style: normal;
font-weight: 400;
line-height: normal;
position: absolute;
top: 0;
left: 24rpx;
}
.countdown-text {
color: #fffabd;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
font-family: "Abhaya Libre ExtraBold";
font-size: 14rpx;
font-style: normal;
font-weight: 800;
line-height: normal;
position: absolute;
right: 24rpx;
bottom: 0;
}
.stat-row {
display: flex;
align-items: center;
margin-left: 64rpx;
overflow: hidden;
}
.stat-block {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rpx;
min-width: 0;
overflow: hidden;
}
.block-label {
font-size: 14rpx;
font-weight: bold;
color: #fffabd;
text-shadow: -2rpx 2rpx 8rpx rgba(206, 9, 9, 0.84);
display: block;
}
.block-image {
width: 96rpx;
height: 64rpx;
position: relative;
}
.block-value {
font-size: 30rpx;
font-weight: bold;
color: #fffabd;
text-shadow: -2rpx 2rpx 8rpx rgba(206, 9, 9, 0.84);
line-height: 1.1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.stat-image {
width: 96rpx;
height: 64rpx;
position: relative;
bottom: -16rpx;
transform: scale(1.3) rotate(-15deg);
}
.empty-block {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
color: rgba(255, 250, 189, 0.6);
font-size: 24rpx;
}
.footer-end {
padding: 40rpx 0 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.footer-text {
color: rgba(95, 95, 95, 0.66);
font-size: 24rpx;
font-weight: bold;
}
/* ============ 加载/错误遮罩 ============ */
.loading-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 200;
}
.loading-spinner {
width: 80rpx;
height: 80rpx;
border: 6rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20rpx;
}
.loading-text {
color: #fff;
font-size: 28rpx;
}
.error-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 200;
padding: 0 60rpx;
}
.error-title {
color: #fff;
font-size: 36rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.error-msg {
color: rgba(255, 255, 255, 0.85);
font-size: 26rpx;
margin-bottom: 40rpx;
text-align: center;
line-height: 1.5;
}
.retry-btn {
padding: 16rpx 60rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #ff5a5d 0%, #fc6466 50%, #f45868 100%);
box-shadow: 0 4rpx 8rpx rgba(242, 21, 21, 0.5);
}
.retry-text {
color: #fff;
font-size: 28rpx;
font-weight: bold;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>