topfans/frontend/pages/square/components/HotCategoryBlock.vue
2026-06-11 00:52:27 +08:00

736 lines
18 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>
<!-- 骨架屏 -->
<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"
/>
<!-- 前 3 名专属:包裹整个卡片的边框图 -->
<image
v-if="index < 3"
class="frame-image"
:src="TOP_FRAME_MAP[index]"
mode="scaleToFill"
/>
<!-- 前 3 名专属:左上角奖牌装饰 -->
<image
v-if="index < 3"
class="card-medal"
:src="MEDAL_MAP[index]"
mode="aspectFit"
/>
</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>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { getHotRankingApi } from "@/utils/api.js";
import { getAssetCoverRealUrl } 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("");
// Tab 配置(直接写死在组件内)
// 新增 tab 在这里 push 一项即可:{ key, label, icon, iconWidth, iconHeight, fetch }
const tabs = [
{
key: "hot",
label: "热度榜",
icon: "/static/square/galaxy/dianzanbang.png",
iconWidth: 64,
iconHeight: 72,
fetch: () => getHotRankingApi("displaying", null, 1, 11),
},
{
key: "new",
label: "活跃榜",
icon: "/static/square/galaxy/huoyuebang.png",
iconWidth: 64,
iconHeight: 72,
fetch: () => getHotRankingApi("displaying", null, 1, 11),
},
{
key: "trending",
label: "曝光榜",
icon: "/static/square/galaxy/baoguangbang.png",
iconWidth: 64,
iconHeight: 72,
fetch: () => getHotRankingApi("displaying", null, 1, 11),
},
{
key: "tongcheng",
label: "同城榜",
icon: "/static/square/galaxy/tongchengbang.png",
iconWidth: 64,
iconHeight: 72,
fetch: () => getHotRankingApi("displaying", null, 1, 11),
},
];
// 前 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, () => {
loadData();
});
// 格式化数量
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 loadData = async () => {
const tab = activeTab.value;
if (!tab || typeof tab.fetch !== "function") {
console.warn("[HotCategoryBlock] 当前 tab 未配置 fetch:", tab);
items.value = [];
return;
}
loading.value = true;
try {
const res = await tab.fetch();
if (res && res.code === 200 && res.data?.items) {
// 逐个把 cover_url 转换成真实可访问 URLOSS 预签名前端实现)
items.value = await Promise.all(
res.data.items.map(async (item) => {
return await resolveItemUrls({
...item,
id: item.id || item.asset_id,
});
}),
);
} else {
items.value = [];
}
} catch (e) {
console.error("[HotCategoryBlock] 加载数据失败", e?.message ?? e);
items.value = [];
} finally {
loading.value = false;
}
};
onMounted(() => {
uni.$on("assetLiked", onAssetLiked);
loadData();
});
onShow(() => {
// loadData();
});
onUnmounted(() => {
uni.$off("assetLiked", onAssetLiked);
});
</script>
<style scoped lang="scss">
.hot-category-block {
padding: 0 9.5rpx;
border-radius: 24rpx;
position: relative;
}
/* Tab Figma Rectangle 90 (83:268) 一一对应 */
.ranking-tabs {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16rpx;
position: relative;
/* 关键去掉 left:50% + transform:translate(-50%) 这套组合
transform 会创建独立 stacking context把子元素active tab锁在父级层级里
导致即使给 .active z-index:999 也浮不到 .items-grid 之上
改用 margin:0 auto 水平居中父级就不再创建 stacking context */
top: 16rpx;
margin: 0 auto;
/* 父级不设 z-index也不设 transform/opacity inactive tab 默认层级
低于 .items-gridgrid 2 这样 grid 就能遮住 top 栏被覆盖的部分 */
width: 480rpx;
height: 96rpx;
&::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(184deg, #ff5a5d -36.55%, #c2ebff 121.2%);
opacity: 0.8;
backdrop-filter: blur(5.85px);
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: 88rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
flex-direction: column;
transition: all 0.25s ease;
/* 保持 position: relative .ranking-tab-icon position: absolute 能以本元素为定位基准
注意不能写 position: absolute否则 4 tab 会从 flex 流中抽离并全部堆在 left:0 重叠 */
position: relative;
}
.ranking-tab-item.active {
/* Figma active tab 尺寸 48x85px 96x170rpx */
width: 96rpx;
height: 170rpx;
top: 40rpx;
/* 显式加 z-index:3 + position:relative让红色渐变能浮到 .items-gridz-index:2之上
前提是 .ranking-tabs 不能创建 stacking context已通过去掉 transform 保证 */
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);
}
}
.ranking-tab-item.active .ranking-tab-icon {
top: -24rpx;
}
.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; /* items-grid 保持一致纵向 */
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; /* top 栏高top 栏无 z-index所以 grid 会盖住 inactive tab
但比 .ranking-tab-item.active z-index:3 所以选中的红色渐变仍能浮出来 */
/* 背景与 Figma node 80-259 一致 */
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%
);
backdrop-filter: blur(4.65px); /* Figma 4.65px不是 9.3px */
border-top-left-radius: 13px; /* Figma: 左上 13px其余 12px */
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
border-bottom-left-radius: 12px;
opacity: 0.8; /* Figma 一致 */
padding: 40rpx 20rpx 0;
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: 48rpx;
}
/* 单行布局藏品图片 + 头像 + 点赞信息 + 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;
}
.grid-card-top-2 {
background: url("/static/square/galaxy/TOP2.png") no-repeat center;
}
.grid-card-top-3 {
background: url("/static/square/galaxy/TOP3.png") no-repeat center;
}
.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;
// [方案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-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; /* 推到右侧 */
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-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);
}
}
</style>