feat: 重构广场页面

This commit is contained in:
zerosaturation 2026-04-28 16:05:55 +08:00
parent 77ca675bd0
commit 4211108bd6
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",
"style": {

View File

@ -1,47 +1,7 @@
<template>
<view class="banner-top3">
<image class="banner-bg" src="/static/rank/rank-bg.png" mode="aspectFill" />
<image class="banner-bg banner-mask" src="/static/rank/bg-text.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>
<!-- 只渲染背景图卡片由父组件 BannerCarousel 渲染在 swiper 外层 -->
<view class="banner-top3-bg" @tap="onBannerTap">
<image class="banner-bg" src="/static/square/paihangbang.png" mode="aspectFill" />
</view>
</template>
@ -49,17 +9,8 @@
import { ref, onMounted } from 'vue';
import { getHotRankingApi, getOssPresignedUrlApi } from '@/utils/api.js';
const top3 = ref([]);
const loading = ref(true);
const emit = defineEmits(['dataLoaded']);
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) => {
if (!fileName) return '';
try {
@ -72,199 +23,45 @@ const resolveOssUrl = async (fileName, type) => {
};
const loadTop3 = async () => {
loading.value = true;
try {
const res = await getHotRankingApi('total', null, 1, 3);
if (res.code === 200 && res.data?.items) {
const items = res.data.items.slice(0, 3);
// OSS URL
top3.value = await Promise.all(items.map(async (item) => {
const resolved = await Promise.all(items.map(async (item) => {
const [coverUrl, avatarUrl] = await Promise.all([
resolveOssUrl(item.cover_url || '', 'asset'),
resolveOssUrl(item.avatar_url || '', 'avatar'),
]);
return { ...item, cover_url: coverUrl, avatar_url: avatarUrl };
}));
emit('dataLoaded', resolved);
}
} catch (e) {
console.error('[BannerTop3] 加载失败', e?.message ?? e);
} finally {
loading.value = false;
}
};
const onBannerTap = () => {
uni.navigateTo({ url: '/pages/rank/rank' });
};
onMounted(loadTop3);
defineExpose({ reload: loadTop3 });
</script>
<style scoped>
.banner-top3 {
.banner-top3-bg {
width: 100%;
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-around;
padding: 0 16rpx;
box-sizing: border-box;
min-height: 200rpx;
height: 360rpx;
position: relative;
overflow: hidden;
border-radius: 24rpx;
}
.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;
inset: 0;
display: flex;
align-items: center;
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; }
width: 100%;
height: 100%;
}
</style>

View File

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

@ -7,9 +7,10 @@
:duration="400"
:circular="true"
:indicator-dots="false"
@change="onSwiperChange"
>
<swiper-item @click.stop="$emit('top3Click')">
<BannerTop3 />
<BannerTop3 @dataLoaded="onTop3DataLoaded" />
</swiper-item>
<swiper-item
v-for="item in bannerActivities"
@ -23,28 +24,82 @@
/>
</swiper-item>
</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>
</template>
<script setup>
import { ref } from 'vue'
import BannerTop3 from '../../components/BannerTop3.vue'
defineProps({
bannerActivities: {
type: Array,
default: () => []
}
bannerActivities: { type: Array, default: () => [] }
})
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>
<style scoped>
.banner-carousel {
position: fixed;
top: 216rpx;
left: 0;
right: 0;
position: relative;
width: 100%;
z-index: 100;
padding: 0 8rpx;
@ -53,9 +108,8 @@ defineEmits(['activityClick', 'top3Click'])
.banner-swiper {
width: 100%;
height: 312rpx;
height: 360rpx;
border-radius: 24rpx;
overflow: hidden;
}
.banner-activity-img {
@ -64,7 +118,151 @@ defineEmits(['activityClick', 'top3Click'])
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;
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>

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)
}
const getBannerBottom = () => (screenWidth.value / 750) * 496
const getBannerBottom = () => (screenWidth.value / 750) * 632
const onBgTouchStart = (e) => {
const touchY = e.touches[0].clientY

View File

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