topfans/frontend/pages/square/components/HotCategoryBlock.vue

953 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="hot-category-block">
<!-- Tab -->
<view class="ranking-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="ranking-tab-item"
:class="{ active: activeTabKey === tab.key }"
:data-key="tab.key"
@click="handleTabClick"
>
<image
v-if="tab.icon"
class="ranking-tab-icon"
:src="tab.icon"
mode="aspectFit"
:style="{
width: (tab.iconWidth || 32) + 'rpx',
height: (tab.iconHeight || 40) + 'rpx',
}"
/>
<!-- <text class="ranking-tab-label">{{ tab.label }}</text> -->
</view>
</view>
<!-- 内容区域: 内部 scroll -->
<scroll-view
class="content-scroll"
scroll-y
:show-scrollbar="false"
:bounce="false"
:lower-threshold="80"
:scroll-into-view="scrollIntoView"
:scroll-with-animation="false"
:refresher-enabled="true"
:refresher-triggered="refreshing"
refresher-default-style="white"
refresher-background="transparent"
@refresherrefresh="handleRefresh"
@scrolltolower="handleScrollToLower"
>
<!-- 顶部哨兵:切换 tab 时通过 scroll-into-view 把列表强制滚回顶部 -->
<view id="grid-top" class="scroll-anchor"></view>
<!-- 骨架屏 -->
<view v-if="loading" class="grid-skeleton">
<view v-for="i in 11" :key="i" class="skeleton-card">
<view class="skeleton-image"></view>
<view class="skeleton-info">
<view class="skeleton-avatar"></view>
<view class="skeleton-name"></view>
</view>
</view>
</view>
<!-- 内容网格 -->
<view v-else class="items-grid">
<view
v-for="(item, index) in items"
:key="item.id || index"
class="grid-card"
:class="{
[`grid-card-top-${index + 1}`]: index < 5,
'grid-card-top-other': index >= 5,
}"
@click="handleCardClick(item)"
>
<!-- 点赞动效波纹 -->
<view
class="wf-like-wave wf-like-wave-outer"
:class="{ 'wf-like-wave-active': likingMap[item.id] }"
/>
<view
class="wf-like-wave wf-like-wave-inner"
:class="{ 'wf-like-wave-active': likingMap[item.id] }"
/>
<!-- 单行布局:藏品图片 + 头像 + 点赞数 + TOP 标签 -->
<view class="card-row">
<view class="card-image-wrap">
<image
class="card-image"
:src="item.cover_url || item.cover_image || ''"
mode="aspectFill"
lazy-load
/>
<!-- 前 3 名专属:包裹整个卡片的边框图 -->
<image
v-if="index < 3"
class="frame-image"
:src="TOP_FRAME_MAP[index]"
mode="scaleToFill"
lazy-load
/>
<!-- 前 3 名专属:左上角奖牌装饰 -->
<!-- <image
v-if="index < 3"
class="card-medal"
:src="MEDAL_MAP[index]"
mode="aspectFit"
lazy-load
/> -->
</view>
<view
class="like-info"
:class="{ [`like-info-top-${index + 1}`]: index < 3 }"
>
<view class="like-row">
<image
class="like-icon"
:src="
item.is_liked
? '/static/icon/heart-icon.png'
: '/static/icon/heart-icon-false.png'
"
mode="aspectFit"
/>
<text class="like-count">{{
formatCount(item.like_count)
}}</text>
</view>
<text class="user-name">{{
item.owner_nickname || item.creator_name || item.name || ""
}}</text>
<text class="user-number">No.{{ item.owner_uid || "" }}</text>
</view>
<view v-if="index >= 3" class="top-badge">
<view class="badge-rank">
<image
class="badge-rank-icon"
src="/static/square/top/top.png"
mode="aspectFit"
/>
<text class="badge-rank-number">{{ index + 1 }}</text>
</view>
</view>
</view>
</view>
<!-- 分页底部状态:加载中 / 没有更多了 / 暂无数据 -->
<view v-if="loadingMore" class="load-more-tip load-more-tip-loading">
<text class="load-more-text">加载中...</text>
</view>
<view v-else-if="!hasMore && items.length > 0" class="load-more-tip">
<text class="load-more-text">— 没有更多了 —</text>
</view>
<view v-else-if="!loading && items.length === 0" class="load-more-tip">
<text class="load-more-text">暂无数据</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { getHotRankingApi } from "@/utils/api.js";
import {
getAssetCoverRealUrl,
getInstantAssetCoverUrl,
} from "@/utils/assetImageHelper.js";
import { usePreload } from "@/composables/usePreload.js";
// ★ 首屏数据走 usePreload自动缓存 + 切换 tab 回来自动命中)
const { data: firstPageData, loading: firstPageLoading } = usePreload("ranking.xingbang");
// 把后端返回的 cover_url / cover_image 转成真实可访问的 URL
async function resolveItemUrls(item) {
if (!item) return item;
const cover = item.cover_url || item.cover_image || "";
if (cover) {
item.cover_url = await getAssetCoverRealUrl(cover);
}
return item;
}
const emit = defineEmits(["cardClick"]);
const items = ref([]);
const loading = ref(true);
const likingMap = ref({});
const activeTabKey = ref("");
const refreshing = ref(false);
// ===== 分页状态 =====
const currentPage = ref(1);
const hasMore = ref(true);
const loadingMore = ref(false);
const scrollIntoView = ref("");
const PAGE_SIZE = 10;
// Tab 配置
const tabs = [
{
key: "hot",
label: "点赞榜",
icon: "/static/square/galaxy/dianzanbang.png",
iconWidth: 96,
iconHeight: 88,
fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE),
},
{
key: "new",
label: "活跃榜",
icon: "/static/square/galaxy/huoyuebang.png",
iconWidth: 96,
iconHeight: 88,
fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE),
},
{
key: "trending",
label: "曝光榜",
icon: "/static/square/galaxy/baoguangbang.png",
iconWidth: 96,
iconHeight: 88,
fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE),
},
{
key: "tongcheng",
label: "同城榜",
icon: "/static/square/galaxy/tongchengbang.png",
iconWidth: 96,
iconHeight: 88,
fetch: (page) => getHotRankingApi("displaying", null, page, PAGE_SIZE),
},
];
// 前 3 名奖牌图标
const MEDAL_MAP = {
0: "/static/square/top/TOP1icon.png",
1: "/static/square/top/TOP2icon.png",
2: "/static/square/top/TOP3icon.png",
};
// 前 3 名对应的边框图
const TOP_FRAME_MAP = {
0: "/static/square/top/TOP1biankuang1.png",
1: "/static/square/top/TOP2biankuang2.png",
2: "/static/square/top/TOP3biankuangpng3.png",
};
const activeTab = computed(
() => tabs.find((t) => t.key === activeTabKey.value) || tabs[0],
);
// 初始化 activeTabKey
watch(
() => tabs,
(newTabs) => {
if (
newTabs.length > 0 &&
!newTabs.some((t) => t.key === activeTabKey.value)
) {
activeTabKey.value = newTabs[0].key;
}
},
{ immediate: true },
);
// ★ 首屏数据watch usePreload 结果,自动填充 items
let firstPageConsumed = false;
watch(firstPageLoading, (val) => { loading.value = val }); // 同步骨架屏
watch(firstPageData, (newData) => {
if (!newData || newData.code !== 0 || firstPageConsumed) return;
firstPageConsumed = true;
loading.value = false;
processRawItems(newData.data?.items || [], false);
}, { immediate: true });
// fallback1.5s 后如果 usePreload 还没数据,走手动 fetch
let fallbackTimer = null;
onMounted(() => {
fallbackTimer = setTimeout(() => {
if (firstPageConsumed) return;
loadData({ append: false });
}, 1500);
});
onUnmounted(() => {
if (fallbackTimer) clearTimeout(fallbackTimer);
});
// 处理原始 items转成渲染需要的格式
function processRawItems(rawItems, append) {
const instant = rawItems.map((it) => {
const id = it.id || it.asset_id;
const rawCover = it.cover_url || it.cover_image || "";
return {
...it,
id,
_rawCover: rawCover,
cover_url: getInstantAssetCoverUrl(rawCover),
};
});
const baseOffset = append ? items.value.length : 0;
if (append) {
items.value = [...items.value, ...instant];
} else {
items.value = instant;
}
// 后台异步解析预签名 URL
instant.forEach((it, idx) => {
const raw = it._rawCover;
if (!raw) return;
getAssetCoverRealUrl(raw)
.then((realUrl) => {
const targetIdx = baseOffset + idx;
const target = items.value[targetIdx];
if (target && target.id === it.id && realUrl) {
target.cover_url = realUrl;
}
})
.catch(() => {});
});
}
// 切换 tab
const handleTabClick = (e) => {
const key = e.currentTarget.dataset.key;
if (key && key !== activeTabKey.value) {
activeTabKey.value = key;
}
};
// activeTab 变化时重新加载(重置分页,走手动 fetch
watch(activeTab, () => {
resetAndLoad();
});
const formatCount = (count) => {
if (!count) return "0";
if (count >= 10000) return (count / 10000).toFixed(1) + "w";
if (count >= 1000) return (count / 1000).toFixed(1) + "k";
return count.toString();
};
const handleCardClick = (item) => {
emit("cardClick", item);
};
// 监听全局点赞事件
const onAssetLiked = ({ asset_id, data }) => {
const index = items.value.findIndex(
(item) => (item.asset_id || item.id) === asset_id,
);
if (index !== -1) {
const updatedItems = [...items.value];
updatedItems[index] = {
...updatedItems[index],
is_liked: data?.is_liked ?? true,
like_count:
data?.new_like_count ?? (updatedItems[index].like_count || 0) + 1,
};
items.value = updatedItems;
likingMap.value = { ...likingMap.value, [asset_id]: true };
setTimeout(() => {
likingMap.value = { ...likingMap.value, [asset_id]: false };
}, 600);
}
};
const resetAndLoad = () => {
currentPage.value = 1;
hasMore.value = true;
loadingMore.value = false;
items.value = [];
scrollIntoView.value = "";
nextTick(() => {
scrollIntoView.value = "grid-top";
});
loadData({ append: false });
};
const PLACEHOLDER_IMAGE = "/static/nft/collection.png";
// 手动 fetch切换 tab / 分页 / 下拉刷新时使用)
const loadData = async ({ append = false, silent = false } = {}) => {
const tab = activeTab.value;
if (!tab || typeof tab.fetch !== "function") return false;
if (append) {
loadingMore.value = true;
} else if (!silent) {
loading.value = true;
}
try {
const res = await tab.fetch(currentPage.value);
if (res && res.code === 0 && res.data?.items) {
processRawItems(res.data.items, append);
const total = Number(res.data.total ?? 0);
if (total > 0) {
hasMore.value = items.value.length < total;
} else {
hasMore.value = res.data.items.length >= PAGE_SIZE;
}
return true;
} else if (!append) {
if (!silent) items.value = [];
hasMore.value = false;
return false;
}
return false;
} catch (e) {
console.error("[HotCategoryBlock] 加载数据失败", e?.message ?? e);
if (!append && !silent) items.value = [];
return false;
} finally {
loading.value = false;
loadingMore.value = false;
}
};
const handleScrollToLower = () => {
if (loading.value || loadingMore.value || !hasMore.value) return;
currentPage.value += 1;
loadData({ append: true });
};
const handleRefresh = async () => {
if (refreshing.value || loading.value) return;
refreshing.value = true;
try {
currentPage.value = 1;
hasMore.value = true;
loadingMore.value = false;
const success = await loadData({ append: false, silent: true });
if (success) {
uni.showToast({ title: "刷新成功", icon: "success", duration: 1200 });
} else {
uni.showToast({ title: "刷新失败", icon: "none", duration: 1500 });
}
} catch (e) {
console.error("[HotCategoryBlock] 刷新失败", e?.message ?? e);
uni.showToast({ title: "刷新失败", icon: "none", duration: 1500 });
} finally {
setTimeout(() => {
refreshing.value = false;
}, 500);
}
};
onMounted(() => {
uni.$on("assetLiked", onAssetLiked);
// 首屏由 usePreload 提供,不再手动 loadData()
// fallback timer 在 1.5s 后触发
});
onShow(() => {
// loadData();
});
onUnmounted(() => {
uni.$off("assetLiked", onAssetLiked);
if (fallbackTimer) clearTimeout(fallbackTimer);
});
</script>
<style scoped lang="scss">
.hot-category-block {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
// padding: 0 9.5rpx;
border-radius: 24rpx;
position: relative;
background: url("/static/square/galaxy/xbbj.png") center no-repeat;
background-size: 100% 100%;
}
.content-scroll {
flex: 1;
min-height: 0;
border-radius: 12px;
overflow: hidden;
position: relative;
z-index: 2;
margin-top: 16rpx;
}
.ranking-tabs {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 64rpx;
position: relative;
top: 16rpx;
margin: 0 24rpx;
// width: 480rpx;
height: 96rpx;
// clip-path: inset(-200rpx 0 16rpx 0);
&::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
184deg,
rgba(255, 90, 93, 0.47) -36.55%,
rgba(194, 235, 255, 0.47) 121.2%
);
filter: blur(2.5px);
// opacity: 0.8;
// Figma 用的就是 filter: blur(图形模糊),不是 backdrop-filter(背景模糊)
// filter: blur(3.7px);
-webkit-filter: blur(2.5px);
border-top-left-radius: 14px;
border-top-right-radius: 13px;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 7px;
}
}
.ranking-tab-item {
height: 80rpx;
width: 99.2rpx;
display: flex;
align-items: center;
flex-direction: column;
transition: all 0.25s ease;
position: relative;
}
.ranking-tab-item.active {
width: 99.2rpx;
height: 160rpx;
top: 40rpx;
z-index: 1;
&::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
190.37deg,
rgba(255, 90, 140, 0.98) 5.5378%,
rgba(194, 235, 255, 0.98) 184.09%
);
opacity: 0.5;
// backdrop-filter: blur(0.9px);
border-radius: 20rpx;
filter: blur(1.3px);
}
}
.ranking-tab-item.active .ranking-tab-icon {
top: -8rpx;
height: 96rpx !important;
}
.ranking-tab-label {
font-size: 22rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.4);
line-height: 1;
white-space: nowrap;
}
.ranking-tab-icon {
display: block;
position: absolute;
}
.ranking-tab-item.active .ranking-tab-label {
color: #ffffff;
}
/* 骨架屏 */
.grid-skeleton {
display: flex;
flex-direction: column;
position: relative;
z-index: 2;
border-radius: 12px;
background: linear-gradient(
161.28deg,
rgba(255, 90, 93, 0.2) 16.63%,
rgba(76, 237, 255, 0.2) 48.19%,
rgba(255, 122, 124, 0.2) 83.71%
);
// backdrop-filter: blur(9.300000190734863px);
overflow: hidden;
}
.skeleton-card {
width: 100%;
height: 120rpx; /* 与新卡片 row 高度一致 */
background: rgba(255, 255, 255, 0.1);
border-radius: 16rpx;
display: flex;
align-items: center;
padding: 12rpx 16rpx;
box-sizing: border-box;
}
.skeleton-avatar {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #3a3a4a;
flex-shrink: 0;
}
.skeleton-name {
flex: 1;
height: 28rpx;
background: #3a3a4a;
border-radius: 8rpx;
margin-left: 16rpx;
}
.skeleton-image,
.skeleton-info {
display: none; /* 新布局下不再使用 */
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* 内容网格 */
.items-grid {
display: flex;
flex-direction: column;
position: relative;
z-index: 2;
// background: linear-gradient(
// 145.83deg,
// rgba(255, 90, 93, 0.2) 16.63%,
// rgba(76, 237, 255, 0.2) 48.19%,
// rgba(255, 122, 124, 0.2) 83.71%
// );
// pointer-events: none;
// backdrop-filter: blur(4.65px);
border-top-left-radius: 13px;
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
border-bottom-left-radius: 12px;
// opacity: 0.8;
padding: 24rpx 20rpx 32rpx;
overflow: hidden;
}
.grid-card {
width: 100%;
border-radius: 16rpx;
position: relative;
/* background: rgba(255, 255, 255, 0.15); */
/* box-shadow: 2px 2px 4.5px 0px #f04b4b40; */
box-shadow: 2px 4px 4px 0px #c92f2f5c;
margin-bottom: 36.8rpx;
z-index: 3;
}
/* 单行布局藏品图片 + 头像 + 点赞信息 + TOP 标签 */
.card-row {
display: flex;
align-items: center;
padding: 0 16rpx;
width: 100%;
height: 104rpx;
box-sizing: border-box;
position: relative;
}
.card-image-wrap {
position: relative;
width: 90rpx;
height: 120rpx;
}
.card-image {
width: 100%;
height: 100%;
border-radius: 12rpx;
display: block;
position: absolute;
top: -16rpx;
left: 32rpx;
}
/* 3 名专属左上角奖牌装饰 */
.card-medal {
position: absolute;
top: -32rpx;
left: 96rpx;
width: 48rpx;
height: 48rpx;
z-index: 5;
pointer-events: none;
transform: rotate(45deg);
}
.grid-card-top-1 {
background: url("/static/square/galaxy/TOP.png") no-repeat center;
background-size: 100% 100%;
}
.grid-card-top-2 {
background: url("/static/square/galaxy/TOP2.png") no-repeat center;
background-size: 100% 100%;
}
.grid-card-top-3 {
background: url("/static/square/galaxy/TOP3.png") no-repeat center;
background-size: 100% 100%;
}
.grid-card-top-4 {
position: relative;
// overflow: hidden;
// [方案3] 伪元素承载 bj.png对图片单独设 opacity
&::before {
content: "";
position: absolute;
inset: 0;
background: url("/static/square/galaxy/TOP4.png") center no-repeat;
background-size: 100% 100%;
opacity: 0.8; // 调这个数控制图片透明度0=完全透明1=完全不透明
pointer-events: none;
z-index: 0;
}
}
.grid-card-top-5 {
position: relative;
// overflow: hidden;
&::before {
content: "";
position: absolute;
inset: 0;
background: url("/static/square/galaxy/TOP4.png") center no-repeat;
background-size: 100% 100%;
opacity: 0.8;
pointer-events: none;
z-index: 0;
}
}
.grid-card-top-other {
position: relative;
// overflow: hidden;
&::before {
content: "";
position: absolute;
inset: 0;
background: url("/static/square/galaxy/TOP6.png") center no-repeat;
background-size: 100% 100%;
opacity: 0.8;
}
}
/* 3 名专属包裹藏品图的边框图叠加在 card-image 之上 */
.frame-image {
position: absolute;
top: -16rpx;
left: 32rpx;
width: 100%;
height: 100%;
z-index: 4;
pointer-events: none;
display: block;
}
.like-info {
display: flex;
flex-direction: column; /* 从上往下用户名 编号 点赞 */
justify-content: center;
margin-left: 64rpx; /* 距头像 10rpx */
flex: 1;
min-width: 0;
}
.user-name {
font-size: 20rpx;
font-weight: 800;
color: #fffabd;
text-shadow: -1px 1px 4px #ce0909d6;
white-space: nowrap;
white-space: nowrap;
text-overflow: ellipsis;
}
.user-number {
font-size: 20rpx;
font-weight: 800;
line-height: 1.3;
color: #fffabd;
text-shadow: -1px 1px 4px #ce0909d6;
white-space: nowrap;
}
.like-row {
display: flex;
align-items: center;
margin-top: 6rpx;
}
.like-info .like-icon {
width: 24rpx;
height: 24rpx;
margin-right: 4rpx;
}
.like-info .like-count {
font-size: 28rpx;
font-weight: 400;
color: #fffabd;
text-shadow: -1px 1px 4px #ce0909d6;
white-space: nowrap;
}
/* Top 排名标签顶到右边 */
.top-badge {
margin-left: auto; /* 推到右侧 */
margin-right: 16rpx;
min-width: 100rpx;
height: 36rpx;
border-radius: 18rpx;
padding: 0 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.badge-rank {
display: flex;
align-items: baseline;
justify-content: center;
}
.badge-rank-icon {
width: 90.24rpx;
height: 40rpx;
margin-right: 5px;
}
.badge-rank-number {
font-family: "Abyssinica SIL";
font-size: 84rpx;
font-weight: 600;
/* 渐变填充到文字 */
background: linear-gradient(
181.98deg,
#fcfcf8 23.78%,
#ffb3eb 66.88%,
#ffeded 94.93%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
white-space: nowrap;
}
/* 点赞动效波纹 */
.wf-like-wave {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0;
z-index: 1;
}
.wf-like-wave-outer {
background: radial-gradient(
circle,
rgba(255, 107, 107, 0.8) 0%,
transparent 70%
);
}
.wf-like-wave-inner {
background: radial-gradient(
circle,
rgba(255, 184, 0, 0.6) 0%,
transparent 70%
);
}
.wf-like-wave-active {
animation: likeWave 0.6s ease-out forwards;
}
@keyframes likeWave {
0% {
opacity: 0.9;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(1.5);
}
}
/* 分页底部加载提示 */
.load-more-tip {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 16rpx 0 8rpx;
position: relative;
z-index: 3;
}
/* 滚动哨兵仅作为 scroll-into-view 的锚点不占据视觉空间 */
.scroll-anchor {
width: 100%;
height: 0;
overflow: hidden;
}
.load-more-text {
font-size: 22rpx;
color: #fffabd;
text-shadow: -1px 1px 4px #ce0909d6;
opacity: 0.85;
letter-spacing: 1rpx;
}
.load-more-tip-loading .load-more-text {
animation: loadMorePulse 1s ease-in-out infinite;
}
@keyframes loadMorePulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
</style>