topfans/frontend/pages/square/components/HotCategoryBlock.vue
2026-06-15 12:07:56 +08:00

925 lines
23 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"
@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";
// 把后端返回的 cover_url / cover_image 转成真实可访问的 URL
// 处理 3 种形态:/static/... (本地)、相对路径 (需 presign)、完整 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(false);
const likingMap = ref({});
const activeTabKey = ref("");
// ===== 分页状态 =====
// currentPage : 已加载到的页码(从 1 开始)
// hasMore : 是否还有下一页(接口返回数量 < PAGE_SIZE 或累计已 >= total 时置 false
// loadingMore : 是否正在加载下一页(防止 scrolltolower 重复触发)
const currentPage = ref(1);
const hasMore = ref(true);
const loadingMore = ref(false);
// scroll-into-view 目标元素 id —— 切换 tab 时设为 'grid-top' 把 scroll-view 滚回顶部
// 通过先置空 → nextTick 设回目标 id 来强制触发(即使上一次已经是同一个 id 也能生效)
const scrollIntoView = ref("");
// 每页数量
const PAGE_SIZE = 10;
// Tab 配置(直接写死在组件内)
// 新增 tab 在这里 push 一项即可:{ key, label, icon, iconWidth, iconHeight, fetch }
// fetch 接受 page 参数,由组件内部分页逻辑统一传入。
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默认选第一个 tab
watch(
() => tabs,
(newTabs) => {
if (
newTabs.length > 0 &&
!newTabs.some((t) => t.key === activeTabKey.value)
) {
activeTabKey.value = newTabs[0].key;
}
},
{ immediate: true },
);
// 切换 tab
const handleTabClick = (e) => {
const key = e.currentTarget.dataset.key;
if (key && key !== activeTabKey.value) {
activeTabKey.value = key;
}
};
// activeTab 变化时重新加载数据(重置分页)
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);
}
};
// 重置分页并重新加载第 1 页
// 切换 tab 时调用:清空 items、把 currentPage 拉回 1、hasMore 设回 true、滚动到顶。
const resetAndLoad = () => {
currentPage.value = 1;
hasMore.value = true;
loadingMore.value = false;
items.value = [];
// 强制滚回顶部:先清空再设回 id避免上一次值就是 'grid-top' 时不触发
scrollIntoView.value = "";
nextTick(() => {
scrollIntoView.value = "grid-top";
});
loadData({ append: false });
};
// 加载数据
// append=false首页加载覆盖 itemsappend=true分页追加拼接到 items 末尾。
//
// 性能关键:不再 await 预签名!流程:
// 1) 接口返回后立即用 getInstantAssetCoverUrl 同步拿到「能马上渲染的 URL」命中缓存/未过期URL/占位图)
// 2) 立刻 set items.value → 列表瞬时出现,骨架屏立即下线
// 3) 后台并行跑 getAssetCoverRealUrl 拿到精确的预签名 URL逐个 patch 回 items.value[i].cover_url
// Vue 3 ref 数组里的对象是 reactive 代理,单个属性变更会触发该卡片重渲染)
const loadData = async ({ append = false } = {}) => {
const tab = activeTab.value;
if (!tab || typeof tab.fetch !== "function") {
console.warn("[HotCategoryBlock] 当前 tab 未配置 fetch:", tab);
if (!append) items.value = [];
return;
}
// 首页用 loading 整屏骨架;分页用 loadingMore 底部小指示器
if (append) {
loadingMore.value = true;
} else {
loading.value = true;
}
try {
const res = await tab.fetch(currentPage.value);
if (res && res.code === 200 && res.data?.items) {
// ① 立刻准备好「能马上渲染的」items —— 不等任何网络
const rawItems = res.data.items.map((it) => {
const id = it.id || it.asset_id;
const rawCover = it.cover_url || it.cover_image || "";
return {
...it,
id,
_rawCover: rawCover, // 留一份原始 URL后台异步任务会用它去预签名
cover_url: getInstantAssetCoverUrl(rawCover),
};
});
// ② 立即上屏
const baseOffset = append ? items.value.length : 0;
if (append) {
items.value = [...items.value, ...rawItems];
} else {
items.value = rawItems;
}
// ③ 后台并行解析预签名 URL单张图回来就 patch 一张
// 不 await —— 不阻塞任何渲染
rawItems.forEach((it, idx) => {
const raw = it._rawCover;
if (!raw) return;
// 同步路径下已经有精确 URL命中缓存 / 完整未过期)则无需再请求
const instant = it.cover_url;
if (
instant &&
instant !== PLACEHOLDER_IMAGE &&
instant === getInstantAssetCoverUrl(raw)
) {
// 已是精确 URL仍然异步校准一次以处理过期getAssetCoverRealUrl 内部命中缓存就是同步快速返回)
}
getAssetCoverRealUrl(raw)
.then((realUrl) => {
const targetIdx = baseOffset + idx;
const target = items.value[targetIdx];
// 防御:用户已切 tab / 列表被清空时,跳过 patch
if (target && target.id === it.id && realUrl) {
target.cover_url = realUrl;
}
})
.catch(() => {
/* 单张失败不影响其它图 */
});
});
// 判定是否还有下一页
const total = Number(res.data.total ?? 0);
if (total > 0) {
hasMore.value = items.value.length < total;
} else {
hasMore.value = rawItems.length >= PAGE_SIZE;
}
} else if (!append) {
items.value = [];
hasMore.value = false;
}
} catch (e) {
console.error("[HotCategoryBlock] 加载数据失败", e?.message ?? e);
if (!append) items.value = [];
} finally {
loading.value = false;
loadingMore.value = false;
}
};
// 占位图常量(与 helper 内部 DEFAULT_IMAGE 一致)
const PLACEHOLDER_IMAGE = "/static/nft/collection.png";
// 滚动到底部触发加载下一页
const handleScrollToLower = () => {
if (loading.value || loadingMore.value || !hasMore.value) return;
currentPage.value += 1;
loadData({ append: true });
};
onMounted(() => {
uni.$on("assetLiked", onAssetLiked);
loadData();
});
onShow(() => {
// loadData();
});
onUnmounted(() => {
uni.$off("assetLiked", onAssetLiked);
});
</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;
}
.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: 40rpx 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;
}
/* 单行布局藏品图片 + 头像 + 点赞信息 + 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>