feat: 重构广场页面

This commit is contained in:
zerosaturation 2026-04-28 16:05:55 +08:00
parent 4538725884
commit ef257261f3
9 changed files with 1518 additions and 450 deletions

View File

@ -92,6 +92,15 @@
} }
} }
}, },
{
"path": "pages/profile/myWorks",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
{ {
"path": "pages/exhibition/exhibition", "path": "pages/exhibition/exhibition",
"style": { "style": {

View File

@ -1,47 +1,7 @@
<template> <template>
<view class="banner-top3"> <!-- 只渲染背景图卡片由父组件 BannerCarousel 渲染在 swiper 外层 -->
<image class="banner-bg" src="/static/rank/rank-bg.png" mode="aspectFill" /> <view class="banner-top3-bg" @tap="onBannerTap">
<image class="banner-bg banner-mask" src="/static/rank/bg-text.png" mode="aspectFill" /> <image class="banner-bg" src="/static/square/paihangbang.png" mode="aspectFill" />
<view
v-for="(item, index) in top3"
:key="item.asset_id || index"
class="top3-card"
>
<view class="artwork-container">
<image class="artwork-image" :src="item.cover_url || '/static/avatar/1.jpeg'" mode="aspectFill" />
<!-- 人气值 -->
<!--<view class="popularity-overlay">
<image class="fire-icon" src="/static/rank/spark.png" mode="aspectFit" />
<text class="popularity-score">{{ formatScore(item.like_count) }}</text>
</view>-->
<!-- 排名徽章 -->
<view class="rank-badge-bottom">
<image class="rank-icon" :src="`/static/rank/charm-rank-icon${index + 1}.png`" mode="aspectFit" />
</view>
<view class="rank-badge-bottom rank-badge-bottom2">
<image class="rank-icon" :src="`/static/rank/rank-icon${index + 1}.png`" mode="aspectFit" />
</view>
</view>
<!-- 用户信息 -->
<view class="user-info-with-artwork">
<image
class="user-avatar-small"
:src="item.owner_avatar || '/static/avatar/1.jpeg'"
mode="aspectFit"
/>
<text class="user-nickname">用户 :
<text class="user-nickname-name">{{ item.owner_nickname || '未知用户' }}</text>
</text>
</view>
</view>
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card" />
</view>
</view> </view>
</template> </template>
@ -49,17 +9,8 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { getHotRankingApi, getOssPresignedUrlApi } from '@/utils/api.js'; import { getHotRankingApi, getOssPresignedUrlApi } from '@/utils/api.js';
const top3 = ref([]); const emit = defineEmits(['dataLoaded']);
const loading = ref(true);
const formatScore = (score) => {
if (typeof score !== 'number' || isNaN(score) || score < 0) return '0';
if (score >= 1000000) return (score / 1000000).toFixed(1) + 'M';
if (score >= 1000) return (score / 1000).toFixed(1) + 'K';
return score.toString();
};
// OSS URL
const resolveOssUrl = async (fileName, type) => { const resolveOssUrl = async (fileName, type) => {
if (!fileName) return ''; if (!fileName) return '';
try { try {
@ -72,199 +23,45 @@ const resolveOssUrl = async (fileName, type) => {
}; };
const loadTop3 = async () => { const loadTop3 = async () => {
loading.value = true;
try { try {
const res = await getHotRankingApi('total', null, 1, 3); const res = await getHotRankingApi('total', null, 1, 3);
if (res.code === 200 && res.data?.items) { if (res.code === 200 && res.data?.items) {
const items = res.data.items.slice(0, 3); const items = res.data.items.slice(0, 3);
// OSS URL const resolved = await Promise.all(items.map(async (item) => {
top3.value = await Promise.all(items.map(async (item) => {
const [coverUrl, avatarUrl] = await Promise.all([ const [coverUrl, avatarUrl] = await Promise.all([
resolveOssUrl(item.cover_url || '', 'asset'), resolveOssUrl(item.cover_url || '', 'asset'),
resolveOssUrl(item.avatar_url || '', 'avatar'), resolveOssUrl(item.avatar_url || '', 'avatar'),
]); ]);
return { ...item, cover_url: coverUrl, avatar_url: avatarUrl }; return { ...item, cover_url: coverUrl, avatar_url: avatarUrl };
})); }));
emit('dataLoaded', resolved);
} }
} catch (e) { } catch (e) {
console.error('[BannerTop3] 加载失败', e?.message ?? e); console.error('[BannerTop3] 加载失败', e?.message ?? e);
} finally {
loading.value = false;
} }
}; };
const onBannerTap = () => {
uni.navigateTo({ url: '/pages/rank/rank' });
};
onMounted(loadTop3); onMounted(loadTop3);
defineExpose({ reload: loadTop3 }); defineExpose({ reload: loadTop3 });
</script> </script>
<style scoped> <style scoped>
.banner-top3 { .banner-top3-bg {
width: 100%; width: 100%;
display: flex; height: 360rpx;
flex-direction: row;
align-items: flex-end;
justify-content: space-around;
padding: 0 16rpx;
box-sizing: border-box;
min-height: 200rpx;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: 24rpx; border-radius: 24rpx;
} }
.banner-bg { .banner-bg {
position: absolute;
top: 0;
width: 105%;
height: 100%;
z-index: 0;
}
/* ===== 复用 TOP3Card 样式 ===== */
.top3-card {
width: 25%;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
/* gap: 32rpx; */
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 16rpx;
}
.rank-icon {
width: 100%;
height: 100%;
filter: drop-shadow(0 4rpx 8rpx rgba(0, 0, 0, 0.3));
}
/* 有作品布局 */
.artwork-container {
width: 90%;
height: 208rpx;
margin-top: 16rpx;
margin-bottom: 32rpx;
background-image: url('/static/rank/frames/frame1.png');
background-size: 130% 115%;
background-repeat: no-repeat;
background-position: center;
position: relative;
}
.artwork-image {
width: calc(100% - 16rpx);
height: calc(100% - 24rpx);
top:16rpx;
left:8rpx;
border-radius: 12rpx;
}
.popularity-overlay {
position: absolute;
top: 24rpx;
left: 0;
display: flex;
gap: 4rpx;
align-items: center;
padding: 6rpx 12rpx;
border-radius: 12rpx;
z-index: 2;
}
.popularity-overlay .fire-icon {
width: 32rpx;
height: 40rpx;
}
.popularity-overlay .popularity-score {
font-size: 24rpx;
font-weight: bold;
color: #FFFFFF;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5), 0 1rpx 2rpx rgba(0, 0, 0, 0.3);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
}
.rank-badge-bottom {
position: absolute;
bottom: -40rpx;
left: 50%;
transform: translateX(-50%) scale(1.5);
z-index: 10;
width: 80rpx;
height: 80rpx;
pointer-events: none;
}
.rank-badge-bottom2 {
bottom: -50rpx;
transform: translateX(-50%) scale(2);
}
.user-info-with-artwork {
display: flex;
flex-direction: row;
align-items: center;
/* gap: 8rpx; */
width: 100%;
justify-content: flex-start;
padding: 0 12rpx;
}
.user-avatar-small {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 3rpx solid rgba(255, 255, 255, 0.7);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
flex-shrink: 0;
}
.user-nickname {
font-size: 14rpx;
margin-left: 8rpx;
color: #FFFFFF;
text-align: center;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 3rpx 6rpx rgba(0, 0, 0, 0.4), 0 1rpx 3rpx rgba(0, 0, 0, 0.3);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
}
.user-nickname-name {
font-size: 14rpx;
color: #FFA500;
text-shadow: 0 3rpx 6rpx rgba(0, 0, 0, 0.4), 0 1rpx 3rpx rgba(0, 0, 0, 0.3);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
}
/* 骨架屏 */
.skeleton-wrap {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; width: 100%;
align-items: center; height: 100%;
justify-content: space-around;
padding: 0 16rpx;
box-sizing: border-box;
}
.skeleton-card {
width: 25%;
height: 180rpx;
border-radius: 16rpx;
background: linear-gradient(90deg,
rgba(255,255,255,0.06) 25%,
rgba(255,255,255,0.14) 50%,
rgba(255,255,255,0.06) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
} }
</style> </style>

View File

@ -12,7 +12,7 @@
<!-- 1. 上层任务图标悬浮在上面 --> <!-- 1. 上层任务图标悬浮在上面 -->
<view class="task-icon-box"> <view class="task-icon-box">
<image class="task-icon-img" src="/static/icon/task.png" mode="aspectFit"></image> <image class="task-icon-img" src="/static/icon/task.png" mode="aspectFit"></image>
<view class="task-red-dot"></view> <image class="task-red-dot" src="/static/square/tishi.png" mode="aspectFit"></image>
</view> </view>
<!-- 2. 下层文字背景块 --> <!-- 2. 下层文字背景块 -->
@ -26,7 +26,7 @@
<!-- 1. 上层新手引导悬浮在上面 --> <!-- 1. 上层新手引导悬浮在上面 -->
<view class="task-icon-box"> <view class="task-icon-box">
<image class="task-icon-img" src="/static/icon/onboarding-bg.png" mode="aspectFit"></image> <image class="task-icon-img" src="/static/icon/onboarding-bg.png" mode="aspectFit"></image>
<view class="task-red-dot"></view> <image class="task-red-dot" src="/static/square/tishi.png" mode="aspectFit"></image>
</view> </view>
<!-- 2. 下层文字背景块 --> <!-- 2. 下层文字背景块 -->
@ -40,7 +40,7 @@
<!-- 1. 上层星援活动悬浮在上面 --> <!-- 1. 上层星援活动悬浮在上面 -->
<view class="task-icon-box"> <view class="task-icon-box">
<image class="task-icon-img" src="/static/icon/bus-icon.png" mode="aspectFit"></image> <image class="task-icon-img" src="/static/icon/bus-icon.png" mode="aspectFit"></image>
<view class="task-red-dot"></view> <image class="task-red-dot" src="/static/square/tishi.png" mode="aspectFit"></image>
</view> </view>
<!-- 2. 下层文字背景块 --> <!-- 2. 下层文字背景块 -->
@ -58,13 +58,14 @@
<!-- 2. 右侧容器用于包裹背景和文字 --> <!-- 2. 右侧容器用于包裹背景和文字 -->
<view class="crystal-info-container"> <view class="crystal-info-container">
<image class="crystal-bg-img" src="/static/square/shuijingzhanshikuang.png" mode="aspectFill"></image>
<!-- 上层文字内容层级中间盖住背景 --> <!-- 上层文字内容 -->
<view class="crystal-text-layer"> <view class="crystal-text-layer">
<text class="balance-number">{{ crystalBalance }}</text> <text class="balance-number">{{ crystalBalance }}</text>
</view> </view>
<!-- 下层渐变背景层级最低被文字盖住但盖住容器底色 --> <!-- 收益文字 -->
<view class="crystal-bg-layer"> <view class="crystal-bg-layer">
<text class="balance-income">收益 27.1/H</text> <text class="balance-income">收益 27.1/H</text>
</view> </view>
@ -454,14 +455,14 @@ defineExpose({
/* --- 上层图标样式 --- */ /* --- 上层图标样式 --- */
.task-icon-box { .task-icon-box {
position: absolute; position: absolute;
top: 0; top: 8rpx;
/* 固定在顶部 */ /* 固定在顶部 */
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
/* 水平居中 */ /* 水平居中 */
width: 90rpx; width: 88rpx;
/* 图标宽度 */ /* 图标宽度 */
height: 90rpx; height: 88rpx;
/* 图标高度 */ /* 图标高度 */
z-index: 10; z-index: 10;
/* 确保图标在文字块上面 */ /* 确保图标在文字块上面 */
@ -475,13 +476,10 @@ defineExpose({
/* 红点样式 */ /* 红点样式 */
.task-red-dot { .task-red-dot {
position: absolute; position: absolute;
top: 6rpx; top: 8rpx;
right: 6rpx; right: 12rpx;
width: 16rpx; width: 16rpx;
height: 16rpx; height: 16rpx;
background-color: red;
border-radius: 50%;
border: 2rpx solid #fff;
} }
/* --- 下层文字背景块 --- */ /* --- 下层文字背景块 --- */
@ -493,42 +491,22 @@ defineExpose({
transform: translateX(-50%); transform: translateX(-50%);
width: 88rpx; width: 88rpx;
height: 80rpx; height: 80rpx;
background: linear-gradient(to bottom right, background-image: url('/static/square/gerenzhongxincangpinkuang.png');
#F0E4B1 0%, background-size: 100% 100%;
/* 左:浅橙粉 */ background-repeat: no-repeat;
#F08399 50%,
#B94E73 100%
/* 右:柔粉红 */
);
/* 立体感核心:多层阴影 + 内阴影模拟凸起 */
box-shadow:
/* 外层投影 - 让按钮浮起 */
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
0 2rpx 6rpx rgba(255, 143, 158, 0.15),
/* 内阴影 - 模拟顶部受光 + 底部凹陷 */
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4),
/* 顶部高光 */
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.05);
/* 底部暗部 */
/* 粉色渐变背景,可微调 */
border-radius: 20rpx; border-radius: 20rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 5; z-index: 5;
/* 必须比图标小,才能被覆盖 */ /* 必须比图标小,才能被覆盖 */
box-shadow: 0 4rpx 10rpx rgba(255, 107, 187, 0.3);
/* 可选:增加一点阴影层次感 */
} }
.task-text-label { .task-text-label {
font-size: 18rpx; font-size: 16rpx;
color: #fff; color: #fff;
text-shadow: 1rpx 1rpx 2rpx rgba(255, 255, 255, 0.5); text-shadow: 1rpx 1rpx 2rpx rgba(255, 255, 255, 0.5);
margin-top: 24rpx; margin-top: 32rpx;
} }
@ -564,17 +542,23 @@ defineExpose({
/* --- 右侧容器 --- */ /* --- 右侧容器 --- */
.crystal-info-container { .crystal-info-container {
position: relative; position: relative;
/* 自动撑开宽度 */
height: 56rpx; height: 56rpx;
width: 80rpx; width: 80rpx;
/* 容器高度 */
border-radius: 16rpx; border-radius: 16rpx;
background: rgba(0, 0, 0, 0.5);
bottom: 4rpx; bottom: 4rpx;
padding-right: 28rpx; padding-right: 28rpx;
padding-left: 56rpx; padding-left: 56rpx;
} }
.crystal-bg-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
/* --- 上层:文字内容 --- */ /* --- 上层:文字内容 --- */
.crystal-text-layer { .crystal-text-layer {
position: relative; position: relative;
@ -618,30 +602,19 @@ defineExpose({
width: 100%; width: 100%;
height: 65%; height: 65%;
z-index: 1; z-index: 1;
/* 关键:层级低于文字,高于透明底色 */
background: linear-gradient(to bottom right, background: linear-gradient(to bottom right,
#F0E4B1 0%, #F0E4B1 0%,
/* 左:浅橙粉 */
#F08399 50%, #F08399 50%,
#B94E7399 100% #B94E7399 100%
/* 右:柔粉红 */
); );
/* 立体感核心:多层阴影 + 内阴影模拟凸起 */
box-shadow: box-shadow:
/* 外层投影 - 让按钮浮起 */
0 4rpx 12rpx rgba(255, 143, 158, 0.2), 0 4rpx 12rpx rgba(255, 143, 158, 0.2),
0 2rpx 6rpx rgba(255, 143, 158, 0.15), 0 2rpx 6rpx rgba(255, 143, 158, 0.15),
/* 内阴影 - 模拟顶部受光 + 底部凹陷 */
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4),
/* 顶部高光 */
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.05); inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.05);
/* 底部暗部 */
border-radius: 10rpx; border-radius: 10rpx;
} }
.icon-item { .icon-item {

View File

@ -0,0 +1,589 @@
<template>
<view class="page-container">
<!-- 背景图片 -->
<image class="bg-image" src="/static/square/beijingban.png" mode="aspectFill"></image>
<!-- 顶部导航 -->
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<image class="nav-back-icon" src="/static/icon/back.png" mode="aspectFit"></image>
</view>
<!-- <text class="nav-title">我的作品</text> -->
<view class="nav-placeholder"></view>
</view>
<view class="scroll-content">
<!-- 我的在展作品 -->
<view class="section-block section-1">
<view class="section-label">
<image class="section-label-bg" src="/static/nft/dingbutubiao_liang.png" mode="aspectFill"></image>
<text class="section-label-text">我的在展作品</text>
</view>
<view class="exhibition-grid">
<view
v-for="(item, index) in exhibitionWorks"
:key="item.id"
class="exhibition-card"
:class="index % 2 === 0 ? 'card-tilt-left' : 'card-tilt-right'"
@tap="goToAssetDetail(item.id)"
>
<image class="card-image" :src="item.cover_url || '/static/nft/placeholder.png'" mode="aspectFill"></image>
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
<!-- 点赞数 -->
<view class="card-rate-badge">
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
<view class="card-rate-text-wrap">
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
</view>
</view>
<!-- 图片下方收益 -->
<view class="card-income-row" :class="index % 2 === 0 ? 'income-tilt-right' : 'income-tilt-left'">
<image class="topfans-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
<view class="card-income-text-wrap">
<text class="card-income-text">{{ item.rate || '0' }}/H</text>
</view>
</view>
</view>
<!-- 空状态占位 -->
<view v-if="exhibitionWorks.length === 0" class="empty-exhibition">
<text class="empty-text">暂无在展作品</text>
</view>
</view>
</view>
<!-- 今日点赞作品 -->
<view class="section-block">
<view class="section-label">
<image class="section-label-bg" src="/static/nft/dingbutubiao_liang.png" mode="aspectFill"></image>
<text class="section-label-text">今日点赞作品</text>
</view>
<scroll-view class="liked-list" scroll-y="true" :show-scrollbar="false">
<view
v-for="(item, index) in likedWorks"
:key="item.id"
class="liked-row"
@tap="goToAssetDetail(item.id)"
>
<!-- 排名图标绝对定位在卡片左侧 -->
<image
v-if="index < 3"
:src="rankIcons[index]"
class="rank-icon-img"
mode="aspectFit"
></image>
<!-- 卡片主体 -->
<view class="liked-item" :class="index === 0 ? 'liked-item-first' : ''">
<!-- 作品封面 -->
<view class="liked-cover-wrap" :class="index === 0 ? 'liked-cover-wrap-first' : ''">
<image class="liked-cover" :src="item.cover_url || '/static/nft/placeholder.png'" mode="aspectFill"></image>
<image class="liked-cover-frame" src="/static/square/cangpinkuang1.png" mode="aspectFill"></image>
</view>
<!-- 作品信息 -->
<view class="liked-info">
<text class="liked-status">{{ item.status_text }}</text>
<view class="liked-score-row">
<text class="liked-score">{{ formatScore(item.score) }}</text>
<image class="fire-icon" src="/static/square/rementubiao.png" mode="aspectFit"></image>
</view>
</view>
<!-- 右侧奖励 -->
<view class="liked-reward">
<image class="reward-token-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
<text class="reward-amount">+{{ item.reward }}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="likedWorks.length === 0" class="empty-liked">
<text class="empty-text">今日暂无点赞作品</text>
</view>
</scroll-view>
</view>
<!-- <view style="height: 60rpx;"></view> -->
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const goBack = () => {
uni.navigateBack();
};
const goToAssetDetail = (id) => {
if (!id) return;
uni.navigateTo({ url: `/pages/asset-detail/asset-detail?id=${id}` });
};
const rankIcons = [
'/static/rank/rank-icon1.png',
'/static/rank/rank-icon2.png',
'/static/rank/rank-icon3.png',
];
const formatScore = (score) => {
if (!score && score !== 0) return '0';
return Number(score).toLocaleString();
};
//
const exhibitionWorks = ref([]);
//
const likedWorks = ref([]);
// API
const loadMockData = () => {
exhibitionWorks.value = [
{ id: '1', cover_url: '/static/sucai/image-08.png', owner_name: 'u585', like_count: 1234, rate: '0.7' },
{ id: '2', cover_url: '/static/sucai/image-11.png', owner_name: 'u585', like_count: 856, rate: '0.6' },
];
likedWorks.value = [
{ id: '1', cover_url: '/static/sucai/image-03.png', status_text: '排名破100', score: 1354321, reward: 20 },
{ id: '2', cover_url: '/static/sucai/image-04.png', status_text: '排名破300', score: 354321, reward: 17 },
{ id: '3', cover_url: '/static/sucai/image-05.png', status_text: '热度飙升中', score: 14321, reward: 15 },
{ id: '4', cover_url: '/static/sucai/image-06.png', status_text: '潜力待挖中', score: 321, reward: 8 },
{ id: '5', cover_url: '/static/sucai/image-07.png', status_text: '潜力待挖中', score: 89, reward: 3 },
];
};
onMounted(() => {
loadMockData();
});
</script>
<style scoped>
.page-container {
min-height: 100vh;
position: relative;
}
/* 背景图片 */
.bg-image {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
/* 导航栏 */
.nav-bar {
position: relative;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 80rpx 32rpx 16rpx;
}
.nav-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
/* background: rgba(255,255,255,0.5);
border-radius: 50%; */
}
.nav-back-icon {
width: 80rpx;
height: 80rpx;
}
.nav-title {
font-size: 36rpx;
font-weight: 700;
color: #5a2d82;
letter-spacing: 2rpx;
}
.nav-placeholder {
width: 64rpx;
}
/* 内容区域 */
.scroll-content {
position: relative;
z-index: 1;
/* padding: 0 32rpx; */
}
/* 区块 */
.section-1 {
margin-bottom: 8rpx;
background: rgb(249 159 192 / 45%);
border-radius: 48rpx;
padding: 16rpx;
}
/* 区块标签 */
.section-label {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
height: 80rpx;
min-width: 232rpx;
}
.section-label-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.section-label-text {
position: relative;
z-index: 1;
font-size: 26rpx;
color: #fff;
font-weight: 600;
padding: 0 28rpx;
}
/* 在展作品网格 */
.exhibition-grid {
display: flex;
flex-direction: row;
/* gap: 24rpx; */
padding: 10rpx 10rpx 80rpx;
justify-content: center;
}
.exhibition-card {
width: 248rpx;
height: 380rpx;
border-radius: 20rpx;
overflow: visible;
position: relative;
}
.card-tilt-left {
transform: rotate(-4deg) translateY(10rpx);
margin-right: 32rpx;
}
.card-tilt-right {
transform: rotate(4deg) translateY(10rpx);
}
.card-income-row.income-tilt-right {
transform: translateX(-50%) rotate(4deg);
}
.card-income-row.income-tilt-left {
transform: translateX(-50%) rotate(-4deg);
}
.card-image {
width: 88%;
height: 92%;
border-radius: 80rpx;
transform-origin: center center;
position: relative;
z-index: 3;
padding: 16rpx;
overflow: hidden;
}
.card-frame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
}
.card-user-tag {
position: absolute;
bottom: 56rpx;
left: 0;
right: 0;
display: flex;
justify-content: center;
}
.card-user-text {
font-size: 20rpx;
color: #fff;
background: rgba(0,0,0,0.45);
padding: 4rpx 14rpx;
border-radius: 20rpx;
}
.card-rate-badge {
position: absolute;
bottom: 16rpx;
left: 40%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6rpx;
padding: 6rpx 16rpx;
z-index: 9;
}
/* 图片下方收益 */
.card-income-row {
position: absolute;
bottom: -52rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
white-space: nowrap;
overflow: visible;
}
.topfans-icon {
width: 52rpx;
height: 52rpx;
position: relative;
z-index: 2;
margin-right: -16rpx;
left: 20rpx;
top: 8rpx
}
.card-income-text-wrap {
background: linear-gradient(to bottom right,
#F0E4B1 0%,
#F08399 50%,
#B94E73 100%
);
border-radius: 999rpx;
padding: 8rpx 20rpx 8rpx 40rpx;
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.card-income-text {
font-size: 16rpx;
color: #fff;
font-weight: 700;
text-align: center;
}
.heart-icon {
width: 28rpx;
height: 28rpx;
}
.card-rate-text-wrap {
background: linear-gradient(to bottom right,
#F0E4B1 0%,
#F08399 50%,
#B94E73 100%
);
border-radius: 999rpx;
padding: 2rpx 12rpx;
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
}
.card-rate-text {
font-size: 22rpx;
color: #fff;
font-weight: 700;
}
/* 空状态 */
.empty-exhibition {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 60rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #b09cc0;
}
/* 今日点赞列表 */
.liked-list {
max-height: 732rpx;
}
.liked-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 16rpx;
}
.liked-item {
display: flex;
align-items: center;
background: #ffffff50;
border-radius: 32rpx;
padding: 16rpx 20rpx;
gap: 16rpx;
overflow: hidden;
box-sizing: border-box;
width: 80%;
padding-left: 10%;
}
.liked-item-first {
padding: 28rpx 20rpx;
width: 90%;
padding-left: 20%;
}
/* 排名徽章 */
.rank-icon-img {
width: 56rpx;
height: 68rpx;
flex-shrink: 0;
margin-right: 8rpx;
}
.rank-number-badge {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba(180,140,220,0.3);
display: flex;
align-items: center;
justify-content: center;
}
.rank-number-text {
font-size: 24rpx;
color: #fff;
font-weight: 700;
}
/* 作品封面 */
.liked-cover-wrap {
width: 88rpx;
height: 88rpx;
flex-shrink: 0;
margin-left: -18rpx;
margin-right: 48rpx;
position: relative;
}
/* .liked-cover-wrap-first {
width: 88rpx;
height: 110rpx;
} */
.liked-cover {
width: 90%;
height: 90%;
border-radius: 24rpx;
transform: rotate(-22deg);
transform-origin: center center;
position: relative;
z-index: 3;
padding: 0.25rem;
}
.liked-cover-frame {
position: absolute;
top: 0;
left: 0;
width: 110%;
height: 110%;
z-index: 2;
transform: rotate(-22deg);
transform-origin: center center;
}
/* 作品信息 */
.liked-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
/* gap: 8rpx; */
overflow: hidden;
}
.liked-status {
font-size: 28rpx;
color: #fff;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 16rpx;
}
.liked-score-row {
display: flex;
align-items: center;
/* gap: 6rpx; */
}
.liked-score {
font-size: 26rpx;
color: #fff;
font-weight: 700;
margin-right: 8rpx;
}
.fire-icon {
width: 32rpx;
height: 32rpx;
align-self: flex-end;
margin-top: 4rpx;
}
/* 右侧奖励 */
.liked-reward {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
flex-shrink: 0;
}
.reward-token-icon {
width: 56rpx;
height: 56rpx;
}
.reward-amount {
font-size: 28rpx;
color: #c060e0;
font-weight: 700;
}
/* 空状态 */
.empty-liked {
padding: 60rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -1,15 +1,16 @@
<template> <template>
<view class="banner-carousel" @click.stop> <view class="banner-carousel" @click.stop>
<swiper <swiper
class="banner-swiper" class="banner-swiper"
:autoplay="true" :autoplay="true"
:interval="4000" :interval="4000"
:duration="400" :duration="400"
:circular="true" :circular="true"
:indicator-dots="false" :indicator-dots="false"
@change="onSwiperChange"
> >
<swiper-item @click.stop="$emit('top3Click')"> <swiper-item @click.stop="$emit('top3Click')">
<BannerTop3 /> <BannerTop3 @dataLoaded="onTop3DataLoaded" />
</swiper-item> </swiper-item>
<swiper-item <swiper-item
v-for="item in bannerActivities" v-for="item in bannerActivities"
@ -23,28 +24,82 @@
/> />
</swiper-item> </swiper-item>
</swiper> </swiper>
<!-- 卡片层跟随第一个 swiper-item切走时隐藏 -->
<view
class="cards-overlay"
:style="{ opacity: currentIndex === 0 ? 1 : 0, pointerEvents: currentIndex === 0 ? 'auto' : 'none' }"
@click.stop="$emit('top3Click')"
>
<view
v-for="(item, index) in top3Items"
:key="item.asset_id || index"
class="card-wrapper"
:class="`card-pos-${index}`"
>
<view class="card-frame">
<view class="card-image-wrap">
<image
class="card-image"
:src="item.cover_url || '/static/avatar/1.jpeg'"
mode="aspectFill"
/>
<!-- 点赞数叠在图片底部 -->
<view class="card-footer">
<image class="card-heart" src="/static/icon/heart-icon.png" mode="aspectFit" />
<view class="card-likes-wrap">
<text class="card-likes">{{ formatLikes(item.like_count) }}</text>
</view>
</view>
</view>
<!-- 边框叠在整张卡片上 -->
<image class="card-frame-border" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill" />
</view>
</view>
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card" :class="`skeleton-pos-${i - 1}`" />
</view>
</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'
import BannerTop3 from '../../components/BannerTop3.vue' import BannerTop3 from '../../components/BannerTop3.vue'
defineProps({ defineProps({
bannerActivities: { bannerActivities: { type: Array, default: () => [] }
type: Array,
default: () => []
}
}) })
defineEmits(['activityClick', 'top3Click']) defineEmits(['activityClick', 'top3Click'])
const top3Items = ref([])
const loading = ref(true)
const currentIndex = ref(0)
const onSwiperChange = (e) => {
currentIndex.value = e.detail.current
}
const onTop3DataLoaded = (items) => {
const list = items.slice(0, 3)
while (list.length < 3) list.push({ asset_id: `placeholder-${list.length}` })
top3Items.value = list
loading.value = false
}
const formatLikes = (n) => {
if (!n || isNaN(n)) return '0'
if (n >= 10000) return (n / 10000).toFixed(1) + 'w'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(n)
}
</script> </script>
<style scoped> <style scoped>
.banner-carousel { .banner-carousel {
position: fixed; position: relative;
top: 216rpx;
left: 0;
right: 0;
width: 100%; width: 100%;
z-index: 100; z-index: 100;
padding: 0 8rpx; padding: 0 8rpx;
@ -53,9 +108,8 @@ defineEmits(['activityClick', 'top3Click'])
.banner-swiper { .banner-swiper {
width: 100%; width: 100%;
height: 312rpx; height: 360rpx;
border-radius: 24rpx; border-radius: 24rpx;
overflow: hidden;
} }
.banner-activity-img { .banner-activity-img {
@ -64,7 +118,151 @@ defineEmits(['activityClick', 'top3Click'])
display: block; display: block;
} }
:deep(.uni-swiper-slide) { /* 卡片层:绝对定位叠在 swiper 上,可自由溢出 */
.cards-overlay {
position: absolute;
top: 0;
right: 8rpx;
width: 420rpx;
height: 360rpx;
pointer-events: auto;
z-index: 10;
}
.card-wrapper {
position: absolute;
width: 148rpx;
height: 220rpx;
}
.card-pos-0 {
left: 50rpx;
top: 40rpx;
transform: rotate(-6deg);
z-index: 3;
}
.card-pos-1 {
left: 140rpx;
top: -8rpx;
transform: rotate(6deg);
z-index: 4;
}
.card-pos-2 {
left: 240rpx;
top: 106rpx;
transform: rotate(16deg);
z-index: 5;
}
.card-frame {
width: 100%;
height: 100%;
border-radius: 16rpx;
position: relative;
overflow: visible;
}
.card-image-wrap {
width: 90%;
height: 92%;
position: relative;
border-radius: 16rpx;
overflow: hidden; overflow: hidden;
z-index: 5;
padding: 8rpx;
}
.card-image {
width: 100%;
height: 100%;
border-radius: 16rpx;
}
.card-frame-border {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
}
.card-footer {
position: absolute;
bottom: 8rpx;
left: 0;
right: 64rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4rpx;
z-index: 3;
}
.card-bg-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
.card-heart {
width: 22rpx;
height: 22rpx;
flex-shrink: 0;
}
.card-likes-wrap {
background: linear-gradient(to bottom right,
#F0E4B1 0%,
#F08399 50%,
#B94E73 100%
);
border-radius: 8rpx;
padding: 2rpx 8rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
}
.card-likes {
font-size: 12rpx;
color: #ffffff;
font-weight: 700;
}
/* 骨架屏 */
.skeleton-wrap {
position: absolute;
inset: 0;
z-index: 5;
}
.skeleton-card {
position: absolute;
width: 148rpx;
height: 220rpx;
border-radius: 16rpx;
background: linear-gradient(90deg,
rgba(255,255,255,0.06) 25%,
rgba(255,255,255,0.14) 50%,
rgba(255,255,255,0.06) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-pos-0 { left: 50rpx; top: 40rpx; transform: rotate(-6deg); }
.skeleton-pos-1 { left: 140rpx; top: -40rpx; transform: rotate(6deg); }
.skeleton-pos-2 { left: 240rpx; top: 10rpx; transform: rotate(16deg); }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
} }
</style> </style>

View File

@ -0,0 +1,146 @@
<template>
<view class="content-tabs" @click.stop>
<view
v-for="(tab, index) in tabs"
:key="tab.key"
class="tab-item"
:class="{ active: modelValue === tab.key }"
@click="$emit('update:modelValue', tab.key)"
>
<!-- 背景图片 -->
<image
class="tab-bg"
:class="{ 'tab-bg-inactive': modelValue !== tab.key }"
src="/static/nft/dingbutubiao_liang.png"
mode="scaleToFill"
/>
<!-- 左侧图标绝对浮动覆盖背景左侧色块不影响文字布局 -->
<view class="tab-left" :class="{ 'tab-left-first': index === 0 }">
<text v-if="tab.emoji" class="tab-emoji">{{ tab.emoji }}</text>
<image
v-else
class="tab-icon"
:src="tab.icon"
mode="aspectFill"
:style="{ width: tab.iconWidth + 'rpx', height: tab.iconHeight + 'rpx' }"
/>
</view>
<!-- 右侧文字 -->
<text class="tab-label">{{ tab.label }}</text>
</view>
</view>
</template>
<script setup>
defineProps({
modelValue: { type: String, default: 'hot' }
})
defineEmits(['update:modelValue'])
const tabs = [
{ key: 'hot', label: '热门作品', emoji: null, icon: '/static/square/rementubiao.png', iconWidth: 104, iconHeight: 104 },
{ key: 'xingka', label: '星卡', emoji: null, icon: '/static/square/xingka.png', iconWidth: 80, iconHeight: 80 },
{ key: 'baji', label: '把爱', emoji: null, icon: '/static/square/baji.png', iconWidth: 80, iconHeight: 80 },
{ key: 'haibao', label: '海报', emoji: null, icon: '/static/square/haibao.png', iconWidth: 96, iconHeight: 96 },
]
</script>
<style scoped>
.content-tabs {
position: relative;
top: unset;
left: unset;
right: unset;
z-index: 100;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin: 8rpx 16rpx 0;
padding: 0 4rpx;
height: 56rpx;
background: transparent;
overflow: visible;
}
.tab-item {
flex: 1;
max-width: 160rpx;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 56rpx;
margin: 0 4rpx;
overflow: visible;
}
/* 背景图片铺满整个 tab */
.tab-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
opacity: 1;
}
/* 未选中时加透明度 */
.tab-bg-inactive {
opacity: 0.4;
}
/* 左侧图标,绝对浮动覆盖背景左侧色块,不影响文字布局 */
.tab-left {
position: absolute;
z-index: 1;
left: -10rpx;
top: 50%;
transform: translateY(-50%);
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s ease;
}
/* 第一个 tab 图标单独样式 */
.tab-left-first {
top: 40%;
transform: translateY(-70%);
}
/* 选中时图标上移 */
/* .tab-item.active .tab-left {
transform: translateY(-70%);
} */
.tab-emoji {
font-size: 52rpx;
line-height: 1;
margin-top: -4rpx;
}
.tab-icon {
display: block;
}
.tab-label {
position: relative;
z-index: 1;
font-size: 20rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.65);
white-space: nowrap;
margin-left: 60rpx;
}
.tab-item.active .tab-label {
color: #ffffff;
}
</style>

View File

@ -0,0 +1,457 @@
<template>
<scroll-view
class="waterfall-scroll"
:style="scrollStyle"
scroll-x
:show-scrollbar="false"
:scroll-left="scrollLeft"
@scroll="onScroll"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view class="waterfall-inner" :style="{ width: totalWidth + 'px', height: '100%' }">
<view
v-for="card in cards"
:key="card.id"
class="wf-card"
:style="cardStyle(card)"
@click="handleCardClick(card)"
>
<!-- 渐变边框光效 -->
<view class="wf-card-border" :style="borderStyle(card)" />
<!-- 封面图 -->
<image
v-if="card.coverUrl"
class="wf-card-img"
:src="card.coverUrl"
mode="aspectFill"
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
/>
<!-- 底部点赞数 -->
<view class="wf-card-footer">
<image class="wf-heart" src="/static/icon/heart-icon.png" mode="aspectFit" />
<view class="wf-likes-wrap">
<text class="wf-likes">{{ formatLikes(card.likes) }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { getRandomUsersApi, getOssPresignedUrlApi } from '@/utils/api.js'
const props = defineProps({
screenWidth: { type: Number, default: 375 },
screenHeight: { type: Number, default: 812 },
bannerBottom: { type: Number, default: 200 },
})
const emit = defineEmits(['cardClick'])
// ========== ==========
const rpx2px = (rpx) => Math.round(uni.getSystemInfoSync().windowWidth / 750 * rpx)
const GAP = rpx2px(16)
const BORDER_W = rpx2px(2)
const SCALE = 0.9
const ROWS = 4
const AUTO_SCROLL_SPEED = 1.2
const AUTO_RESUME_DELAY = 2500
const PRELOAD_THRESHOLD = rpx2px(1200)
// ========== ==========
const cards = ref([])
const allUsers = ref([])
const totalWidth = ref(0)
const scrollLeft = ref(0)
let currentScrollLeft = 0
let idCounter = 0
// ========== RAF ==========
const rafFn = (cb) => {
if (typeof requestAnimationFrame !== 'undefined') return requestAnimationFrame(cb)
if (uni.requestAnimationFrame) return uni.requestAnimationFrame(cb)
return setTimeout(cb, 16)
}
const cafFn = (id) => {
if (typeof cancelAnimationFrame !== 'undefined') return cancelAnimationFrame(id)
if (uni.cancelAnimationFrame) return uni.cancelAnimationFrame(id)
clearTimeout(id)
}
// ========== ==========
let rafId = null
let userInteracting = false
let resumeTimer = null
const startAutoScroll = () => {
if (rafId) return
const step = () => {
if (!userInteracting) {
currentScrollLeft += AUTO_SCROLL_SPEED
scrollLeft.value = currentScrollLeft
if (totalWidth.value - currentScrollLeft - props.screenWidth < PRELOAD_THRESHOLD) {
appendMore()
}
}
rafId = rafFn(step)
}
rafId = rafFn(step)
}
const stopAutoScroll = () => {
if (rafId) { cafFn(rafId); rafId = null }
}
const pauseForUser = () => {
userInteracting = true
clearTimeout(resumeTimer)
resumeTimer = setTimeout(() => { userInteracting = false }, AUTO_RESUME_DELAY)
}
// ========== / ==========
const onTouchStart = () => pauseForUser()
const onTouchEnd = () => {}
const onScroll = (e) => {
currentScrollLeft = e.detail.scrollLeft
if (totalWidth.value - currentScrollLeft - props.screenWidth < PRELOAD_THRESHOLD) {
appendMore()
}
}
// ========== ==========
const scrollStyle = computed(() => ({
position: 'absolute',
top: props.bannerBottom + 'px',
left: 0,
width: props.screenWidth + 'px',
height: (props.screenHeight - props.bannerBottom) + 'px',
zIndex: 2,
overflow: 'hidden',
}))
// ========== ==========
// span = ROWS
// = span=ROWS span
//
// 1. span span
// 2. span
class WaterfallLayout {
constructor(containerH, gap = GAP) {
this.containerH = containerH
this.gap = gap
this.rowH = Math.floor((containerH - gap * (ROWS - 1)) / ROWS)
// = rowH × 9/16 9:16span
this.colW = Math.round(this.rowH * 9 / 16)
this.curX = 0
}
_cardSize(span) {
const h = Math.round((span * this.rowH + (span - 1) * this.gap) * SCALE)
const w = Math.round(h * 9 / 16)
return { w, h }
}
// span
_span(likes) {
if (likes < 500) return 1
if (likes < 2000) return 2
if (likes < 8000) return 2
if (likes < 50000) return 3
return 4
}
// span span = ROWS
_groupIntoColumns(users) {
const columns = []
let i = 0
while (i < users.length) {
const col = []
let sum = 0
while (i < users.length && sum < ROWS) {
// span
const rawSpan = users[i].span != null ? users[i].span : this._span(users[i].likes || 0)
const span = Math.min(rawSpan, ROWS - sum)
col.push({ ...users[i], span })
sum += span
i++
}
// _pad
while (sum < ROWS) {
col.push({ id: idCounter++, span: 1, _pad: true, likes: 0 })
sum++
}
columns.push(col)
}
return columns
}
_placeColumn(colUsers) {
const result = []
let curY = 0
const colX = this.curX
const maxSpan = Math.max(...colUsers.map(u => u.span || 1))
const colW = this._cardSize(maxSpan).w
for (const u of colUsers) {
const span = u.span || 1
const { w, h } = this._cardSize(span)
if (!u._pad) {
result.push({ ...u, left: colX, top: curY, w, h, radius: 8 })
}
curY += h + this.gap
}
this.curX += colW + this.gap
return result
}
//
compute(users) {
this.curX = 0
const columns = this._groupIntoColumns(users)
const result = []
for (const col of columns) {
result.push(...this._placeColumn(col))
}
return result
}
// X
addCards(users) {
const columns = this._groupIntoColumns(users)
const result = []
for (const col of columns) {
result.push(...this._placeColumn(col))
}
return result
}
getTotalWidth() {
return this.curX
}
}
let layout = null
// ========== ==========
const PALETTES = [
'linear-gradient(135deg, #2d1b69, #5b21b6)',
'linear-gradient(135deg, #0c4a6e, #0284c7)',
'linear-gradient(135deg, #450a0a, #b91c1c)',
'linear-gradient(135deg, #064e3b, #059669)',
'linear-gradient(135deg, #78350f, #d97706)',
'linear-gradient(135deg, #4a1942, #be185d)',
]
const BORDER_COLORS = [
'linear-gradient(135deg, #a78bfa, #60a5fa)',
'linear-gradient(135deg, #38bdf8, #818cf8)',
'linear-gradient(135deg, #f472b6, #fb923c)',
'linear-gradient(135deg, #4ade80, #22d3ee)',
'linear-gradient(135deg, #fbbf24, #f87171)',
'linear-gradient(135deg, #c084fc, #f472b6)',
]
// ========== ==========
const cardStyle = (card) => ({
position: 'absolute',
left: card.left + 'px',
top: card.top + 'px',
width: card.w + 'px',
height: card.h + 'px',
borderRadius: card.radius + 'px',
overflow: 'hidden',
background: card.coverUrl ? 'transparent' : PALETTES[Math.abs(card.id) % PALETTES.length],
})
const borderStyle = (card) => ({
position: 'absolute',
inset: `-${BORDER_W}px`,
borderRadius: (card.radius + BORDER_W) + 'px',
background: BORDER_COLORS[Math.abs(card.id) % BORDER_COLORS.length],
zIndex: 0,
opacity: 0.85,
})
const formatLikes = (n) => {
if (n >= 10000) return (n / 10000).toFixed(1) + 'w'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(n)
}
// ========== ==========
let isLoadingMore = false
// likes
const randomLikes = () => {
const r = Math.random()
if (r < 0.20) return Math.floor(Math.random() * 100)
if (r < 0.40) return Math.floor(100 + Math.random() * 400)
if (r < 0.60) return Math.floor(500 + Math.random() * 1500)
if (r < 0.75) return Math.floor(2000 + Math.random() * 6000)
if (r < 0.90) return Math.floor(8000 + Math.random() * 42000)
return Math.floor(50000 + Math.random() * 950000)
}
// 使
const MOCK_IMAGES = Array.from({ length: 16 }, (_, i) => `/static/sucai/image-${String(i + 1).padStart(2, '0')}.png`)
const mapUser = async (u) => {
//
let coverUrl = MOCK_IMAGES[idCounter % MOCK_IMAGES.length]
if (u.cover_url) {
try {
const r = await getOssPresignedUrlApi(u.cover_url, 3600, 'asset')
if (r?.code === 200 && r.data?.url) coverUrl = r.data.url
} catch (_) {}
}
// span 1~4
return {
id: idCounter++,
userId: u.user_id,
nickname: u.nickname,
coverUrl,
likes: u.likes ?? randomLikes(),
span: u.span ?? null, // null =
}
}
const loadUsers = async () => {
try {
const res = await getRandomUsersApi(1, 40)
if (res.code === 200 && res.data?.users) {
const withData = await Promise.all(res.data.users.map(mapUser))
allUsers.value = withData
cards.value = layout.compute(withData)
totalWidth.value = layout.getTotalWidth()
}
} catch (e) {
console.error('[WaterfallGrid] 加载用户失败', e?.message ?? e)
}
}
const appendMore = async () => {
if (isLoadingMore) return
isLoadingMore = true
try {
const res = await getRandomUsersApi(1, 20)
if (res.code === 200 && res.data?.users) {
const withData = await Promise.all(res.data.users.map(mapUser))
const placed = layout.addCards(withData)
cards.value = [...cards.value, ...placed]
totalWidth.value = layout.getTotalWidth()
}
} catch (e) {
console.error('[WaterfallGrid] 追加用户失败', e?.message ?? e)
} finally {
isLoadingMore = false
}
}
// ========== ==========
const handleCardClick = (card) => {
if (userInteracting) return
emit('cardClick', card)
}
// ========== ==========
onMounted(() => {
const containerH = props.screenHeight - props.bannerBottom
layout = new WaterfallLayout(containerH)
loadUsers().then(() => startAutoScroll())
})
onUnmounted(() => {
stopAutoScroll()
clearTimeout(resumeTimer)
})
watch(() => [props.screenHeight, props.bannerBottom], () => {
const containerH = props.screenHeight - props.bannerBottom
layout = new WaterfallLayout(containerH)
if (allUsers.value.length) {
cards.value = layout.compute(allUsers.value)
totalWidth.value = layout.getTotalWidth()
}
})
</script>
<style scoped>
.waterfall-scroll {
white-space: nowrap;
}
.waterfall-inner {
position: relative;
height: 100%;
display: inline-block;
}
.wf-card {
position: absolute;
cursor: pointer;
will-change: transform;
transform-origin: top center;
}
.wf-card:active {
transform: scale(0.96);
}
.wf-card-border {
position: absolute;
pointer-events: none;
}
.wf-card-img {
position: absolute;
top: 0;
left: 0;
z-index: 1;
display: block;
}
.wf-card-footer {
position: absolute;
bottom: 8rpx;
left: 10rpx;
z-index: 2;
display: flex;
align-items: center;
gap: 4rpx;
}
.wf-heart {
width: 28rpx;
height: 28rpx;
}
.wf-likes-wrap {
background: linear-gradient(to bottom right,
#F0E4B1 0%,
#F08399 50%,
#B94E73 100%
);
border-radius: 999rpx;
padding: 2rpx 10rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
}
.wf-likes {
font-size: 18rpx;
font-weight: 700;
color: #ffffff;
}
</style>

View File

@ -88,7 +88,7 @@ export function useSwipe() {
inertiaRaf = rafFn(step) inertiaRaf = rafFn(step)
} }
const getBannerBottom = () => (screenWidth.value / 750) * 496 const getBannerBottom = () => (screenWidth.value / 750) * 632
const onBgTouchStart = (e) => { const onBgTouchStart = (e) => {
const touchY = e.touches[0].clientY const touchY = e.touches[0].clientY

View File

@ -1,40 +1,16 @@
<template> <template>
<view <view class="square-container">
class="square-container" <!-- 固定背景 -->
@touchstart="onBgTouchStart" <image class="background-fixed" src="/static/square/squearbj.png" mode="aspectFill" />
@touchmove="onBgTouchMove"
@touchend="onBgTouchEnd"
@touchcancel="onBgTouchCancel"
>
<!-- 横向无限滚动背景条 -->
<view class="background-strip" :style="backgroundStripStyle">
<image
v-for="i in 3"
:key="i"
class="background-tile"
:style="{ width: tileWidth + 'px', height: '100%' }"
src="/static/background/mainbg.png"
/>
</view>
<!-- Cabin 图标层与背景同步移动 --> <!-- 横向瀑布流卡片层内部自带横向滚动 -->
<view class="cabin-layer" :style="cabinLayerStyle"> <WaterfallGrid
<CabinItem :screenWidth="screenWidth"
v-for="cabin in visibleCabins" :screenHeight="screenHeight"
:key="cabin.key" :bannerBottom="bannerBottomPx"
:cabin="cabin" @cardClick="handleCardClick"
:currentUserNickname="currentUserNickname" class="fall-bg"
@click="handleCabinClick" />
/>
</view>
<!-- 翻页箭头按钮 -->
<NavArrows @scroll="scrollPage" />
<!-- 调试按钮开发环境 -->
<view v-if="isDev" class="debug-btn" @click="openDebugGrid">
<text class="debug-text">调试</text>
</view>
<!-- Header组件 --> <!-- Header组件 -->
<Header <Header
@ -44,12 +20,15 @@
backIconColor="#e6e6e6" backIconColor="#e6e6e6"
/> />
<!-- 轮播图 + 应援活动列表 --> <!-- 轮播图 + 内容分类 Tab -->
<BannerCarousel <view class="banner-tabs-wrapper">
:bannerActivities="bannerActivities" <BannerCarousel
@activityClick="handleActivityClick" :bannerActivities="bannerActivities"
@top3Click="showRankingModal = true" @activityClick="handleActivityClick"
/> @top3Click="showRankingModal = true"
/>
<ContentTabs class="tabs" v-model="activeContentTab" />
</view>
<!-- 蒙层 - 导航栏展开时显示 --> <!-- 蒙层 - 导航栏展开时显示 -->
<view v-if="navExpanded" class="nav-mask" @click="navExpanded = false"></view> <view v-if="navExpanded" class="nav-mask" @click="navExpanded = false"></view>
@ -77,22 +56,18 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app' import { onLoad, onShow } from '@dcloudio/uni-app'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import Header from '../components/Header.vue' import Header from '../components/Header.vue'
import BottomNav from '../components/BottomNav.vue' import BottomNav from '../components/BottomNav.vue'
import GuideOverlay from '@/components/GuideOverlay.vue' import GuideOverlay from '@/components/GuideOverlay.vue'
import RankingModal from '../components/RankingModal.vue' import RankingModal from '../components/RankingModal.vue'
import CabinItem from './components/CabinItem.vue'
import BannerCarousel from './components/BannerCarousel.vue' import BannerCarousel from './components/BannerCarousel.vue'
import NavArrows from './components/NavArrows.vue' import WaterfallGrid from './components/WaterfallGrid.vue'
import { clearSubStepProgress, shouldShowGuideStartModal, resetGuide } from '@/utils/guideConfig.js' import ContentTabs from './components/ContentTabs.vue'
import { IMAGE_W, IMAGE_H } from './config/cabin.js' import { clearSubStepProgress, shouldShowGuideStartModal } from '@/utils/guideConfig.js'
import { useSwipe } from './composables/useSwipe.js'
import { useCabin } from './composables/useCabin.js'
import { useBanner } from './composables/useBanner.js' import { useBanner } from './composables/useBanner.js'
import { useDialogRotation } from './composables/useDialogRotation.js'
// ========== Store & User Info ========== // ========== Store & User Info ==========
const store = useStore() const store = useStore()
@ -100,51 +75,32 @@ const currentUserNickname = computed(() => store.state.user?.userInfo?.nickname
const currentStarId = ref(uni.getStorageSync('star_id') || null) const currentStarId = ref(uni.getStorageSync('star_id') || null)
// ========== UI State ========== // ========== UI State ==========
const activeContentTab = ref('hot')
const navExpanded = ref(false) const navExpanded = ref(false)
const showRankingModal = ref(false) const showRankingModal = ref(false)
const isDev = ref(false) // const isDev = ref(false) //
// ========== Screen Info ========== // ========== Screen Info ==========
const tileWidth = ref(375)
const screenWidth = ref(375) const screenWidth = ref(375)
const screenHeight = ref(812) const screenHeight = ref(812)
// ========== Composables ========== // ========== Composables ==========
const {
cabinLayerStyle,
backgroundStripStyle,
scrollPage,
initSwipe,
reset: resetSwipe,
onBgTouchStart,
onBgTouchMove,
onBgTouchEnd,
onBgTouchCancel,
velocity,
} = useSwipe()
const {
visibleCabins,
currentPage,
ensurePages,
initCabin,
updateCurrentUserNickname,
resetSquare: resetCabinSquare,
wrapPage,
} = useCabin()
const { const {
bannerActivities, bannerActivities,
loadBannerActivities, loadBannerActivities,
} = useBanner() } = useBanner()
const { // banner(216+360rpx) + tab(16+80rpx) + (8rpx) 680rpx
initDialogRotation, const bannerBottomPx = computed(() => Math.round(screenWidth.value / 750 * 716))
startDialogRotation,
stopDialogRotation,
} = useDialogRotation()
// ========== Handlers ========== // ========== Handlers ==========
const handleCardClick = (card) => {
if (!card.userId) return
uni.navigateTo({
url: `/pages/exhibition/exhibition?target_uid=${card.userId}`,
})
}
const handleActivityClick = (item) => { const handleActivityClick = (item) => {
uni.navigateTo({ uni.navigateTo({
url: `/pages/support-activity/index?id=${item.id}`, url: `/pages/support-activity/index?id=${item.id}`,
@ -166,19 +122,6 @@ const handleRankingModalClose = (visible) => {
} }
} }
const handleCabinClick = (cabin) => {
//
if (Math.abs(velocity) > 0.5) return
// banner showNickname=false
if (!cabin.userId || !cabin.showNickname) return
uni.navigateTo({
url: (cabin.isMine || cabin.nickname === currentUserNickname.value)
? '/pages/exhibition/exhibition'
: `/pages/exhibition/exhibition?target_uid=${cabin.userId}`,
})
}
const handleTabChange = (newTab) => { const handleTabChange = (newTab) => {
if (newTab === 0) { if (newTab === 0) {
navExpanded.value = false navExpanded.value = false
@ -210,71 +153,19 @@ const openDebugGrid = () => {
} }
// ========== Tile Change Callback ========== // ========== Tile Change Callback ==========
const handleTileChange = (delta, isInertia) => { const handleTileChange = () => {}
const newPage = wrapPage(currentPage.value + delta)
currentPage.value = newPage
if (isInertia) {
//
let ensureTimer = null
if (ensureTimer) clearTimeout(ensureTimer)
ensureTimer = setTimeout(() => {
ensurePages(newPage)
ensureTimer = null
}, 100)
} else {
//
ensurePages(newPage)
}
}
// ========== Reset Square ========== // ========== Reset Square ==========
const resetSquare = async () => { const resetSquare = async () => {}
resetSwipe()
await resetCabinSquare()
}
// ========== Watch visibleCabins for Dialog Rotation ==========
watch(visibleCabins, () => {
if (visibleCabins.value.some(c => c.nickname && c.sharedBoothSlotsRemaining !== null)) {
startDialogRotation(visibleCabins.value)
} else {
stopDialogRotation()
}
}, { immediate: true })
// ========== Watch currentUserNickname ========== // ========== Watch currentUserNickname ==========
watch(currentUserNickname, (nickname) => { // (no-op, kept for future use)
if (nickname) {
updateCurrentUserNickname(nickname)
}
}, { immediate: true })
// ========== Lifecycle ========== // ========== Lifecycle ==========
onMounted(() => { onMounted(() => {
const info = uni.getSystemInfoSync() const info = uni.getSystemInfoSync()
screenWidth.value = info.windowWidth screenWidth.value = info.windowWidth
screenHeight.value = info.windowHeight screenHeight.value = info.windowHeight
tileWidth.value = Math.round(info.windowHeight * (IMAGE_W / IMAGE_H))
//
initSwipe({
screenW: screenWidth.value,
tileW: tileWidth.value,
onTileChangeCallback: handleTileChange,
})
initCabin({
screenW: screenWidth.value,
tileW: tileWidth.value,
imageH: screenHeight.value,
currentUserNickname: currentUserNickname.value,
})
initDialogRotation({
screenW: screenWidth.value,
screenH: screenHeight.value,
})
// //
resetSquare() resetSquare()
@ -327,7 +218,6 @@ uni.$on('guide:openComponent', (componentName) => {
onUnmounted(() => { onUnmounted(() => {
uni.$off('guide:openComponent') uni.$off('guide:openComponent')
stopDialogRotation()
}) })
</script> </script>
@ -340,29 +230,38 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
} }
.background-strip { /* .fall-bg{
position: absolute; background: rgba(255, 180, 180, 0.25);
top: 0; border-radius: 48rpx;
left: 50%; } */
height: 150%;
.banner-tabs-wrapper {
position: fixed;
top: 216rpx;
left: 0;
right: 0;
z-index: 100;
display: flex; display: flex;
z-index: 0; flex-direction: column;
will-change: transform; min-height: 448rpx;
justify-content: space-between;
background: rgb(249 159 192 / 45%);;
/* background: rgba(212, 127, 127, 0.8); */
border-radius: 48rpx;
overflow: visible;
} }
.background-tile { /* .tabs{
flex-shrink: 0; margin-bottom: 8rpx;
height: 100%; } */
}
.cabin-layer { .background-fixed {
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 1; z-index: 0;
pointer-events: none;
} }
.nav-mask { .nav-mask {