topfans/frontend/pages/square/square.vue
2026-06-09 11:21:04 +08:00

547 lines
15 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="square-container">
<!-- 背景图片 -->
<!-- <image
class="bg-wrapper"
src="/static/square/squearbj11.png"
mode="aspectFill"
></image> -->
<!-- Header组件 -->
<Header :showGuideIcon="true" :showTaskIcon="true" :showStarActivityIcon="true" backIconColor="#e6e6e6" />
<!-- 蒙层 - 导航栏展开时显示 -->
<view v-if="navExpanded" class="nav-mask" @click="navExpanded = false"></view>
<!-- 排行榜弹窗 -->
<RankingModal :visible="showRankingModal" :parent-active="true" :star-id="currentStarId"
@update:visible="handleRankingModalClose" @visit="handleRankingVisit" />
<!-- 底部导航栏 -->
<BottomNav :activeTab="4" :isExpanded="navExpanded" @update:activeTab="handleTabChange"
@update:isExpanded="navExpanded = $event" />
<!-- 全局引导遮罩 -->
<GuideOverlay />
<!-- 内容区域 -->
<scroll-view class="content-wrapper" :class="{ 'spotlight-ready': isH5 }" scroll-y :show-scrollbar="false"
:bounce="false" @scroll="onScroll" @touchstart="onTouchStart" @touchmove="onTouchMove"
@scrolltolower="handleScrollToLower">
<!-- 区域一:顶部运营轮播图 -->
<view ref="bannerSectionRef" class="banner-section">
<BannerCarousel :bannerActivities="bannerActivities" :banners="banners"
@activityClick="handleActivityClick" @top3Click="showRankingModal = true"
@bannerClick="handleBannerClick" />
</view>
<ContentTabs ref="contentTabsRef" class="tabs" :modelValue="activeContentTab"
@update:modelValue="activeContentTab = $event" />
<!-- 在线榜单区块 -->
<view ref="hotCategoryRef" class="hot-category-wrapper">
<HotCategoryBlock :dimension="activeContentTab" @cardClick="handleCardClick" />
<view class="hot-more-btn" @click="goToHotCategoryMore">
<text class="hot-more-text">查看更多</text>
</view>
</view>
<!-- 区域二//四已合并到 CreationGrid 组件主Tab + 分类标签 + 网格列表 -->
<CreationGrid :screenWidth="screenWidth" :screenHeight="screenHeight" :bannerBottom="bannerBottomPx"
:category="activeCategoryTab" :isActive="isActive"
@cardClick="handleCardClick" @loaded="onGridLoaded"
@mainTabClick="handleMainTabClick" @categoryChange="handleCategoryChange"
ref="creationGridRef" />
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { onLoad, onShow, onHide } 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 BannerCarousel from "./components/BannerCarousel.vue";
import ContentTabs from "./components/ContentTabs.vue";
import HotCategoryBlock from "./components/HotCategoryBlock.vue";
import CreationGrid from "./components/CreationGrid.vue";
// import { clearSubStepProgress, shouldShowGuideStartModal } from '@/utils/guideConfig.js'
import { useBanner } from "./composables/useBanner.js";
import { useSpotlight } from "./composables/useSpotlight.js";
import { doubleTapLike } from "@/utils/likeHelper.js";
// ========== Store & User Info ==========
const store = useStore();
// const currentUserNickname = computed(() => store.state.user?.userInfo?.nickname || '')
const currentStarId = ref(uni.getStorageSync("star_id") || null);
// ========== UI State ==========
const activeContentTab = ref("displaying");
const activeCategoryTab = ref("hot");
const navExpanded = ref(false);
const showRankingModal = ref(false);
const isActive = ref(true);
const creationGridRef = ref(null);
const cardTapTimers = {};
const likingMap = ref({});
// ========== 各区块的 template ref ==========
const bannerSectionRef = ref(null);
const contentTabsRef = ref(null);
const hotCategoryRef = ref(null);
// mainTabsRef / categoryRef 已迁入 CreationGrid 内部(通过 creationGridRef.value.mainTabsRef / categoryRef 拿)
// - 4 个区块
// - CreationGrid 里的所有卡片(通过 defineExpose 暴露的 getCardRefs 拿到)
const allSpotlightRefs = () => {
const sections = [
bannerSectionRef.value,
contentTabsRef.value?.$el || contentTabsRef.value,
hotCategoryRef.value,
creationGridRef.value?.mainTabsRef?.value,
creationGridRef.value?.categoryRef?.value,
].filter(Boolean)
const cards = creationGridRef.value?.getCardRefs?.() || []
return [...sections, ...cards]
}
const { update, bindScroll, start: startSpotlight, stop: stopSpotlight, isH5 } = useSpotlight({
getElements: allSpotlightRefs,
})
// 统一 scroll 处理spotlight + 驱动 CreationGrid 切 fixed
const onScroll = (e) => {
bindScroll() // 兼容路径scroll 事件触发时也跑一次(主驱动是 rAF 轮询)
const scrollTop = (e && e.detail && e.detail.scrollTop) || 0
// 通知 CreationGrid根据 scrollTop 决定分类标签是否切到 fixed
creationGridRef.value?.updateScroll?.(scrollTop)
}
// 移动端 rAF 在用户滚动时会被节流opacity 跟不上。
// 用 touchstart / touchmove 同步触发 update(),绕开 rAF 节流。
const onTouchStart = () => {
update()
}
const onTouchMove = () => {
update()
}
// tab / 分类变化 → 新内容可能进入视口,重做一次 spotlight 计算
watch(activeContentTab, () => {
nextTick(() => setTimeout(update, 50));
});
watch(activeCategoryTab, () => {
nextTick(() => setTimeout(update, 50));
});
// 卡片加载到位 → 重新算一次 spotlight卡片异步加载不在初始 200ms update 范围内)
const onGridLoaded = (count) => {
if (count > 0) {
nextTick(() => update());
}
};
// ========== Screen Info ==========
const screenWidth = ref(375);
const screenHeight = ref(812);
// ========== Composables ==========
const { bannerActivities, banners, loadBannerActivities, loadBanners } = useBanner();
// banner(216+360rpx) + tab栏(16+80rpx) + 间距(8rpx) ≈ 680rpx
const bannerBottomPx = computed(() =>
Math.round((screenWidth.value / 750) * 715),
);
// ========== Handlers ==========
const handleCardClick = (card) => {
if (cardTapTimers[card.id]) {
// 第二次点击,双击点赞
clearTimeout(cardTapTimers[card.id]);
delete cardTapTimers[card.id];
// 触发动画
likingMap.value = { ...likingMap.value, [card.id]: true };
setTimeout(() => {
likingMap.value = { ...likingMap.value, [card.id]: false };
}, 600);
doubleTapLike(card.id, card.exhibition_id || 0, (success) => {
if (success) {
uni.showToast({ title: "点赞成功", icon: "success" });
}
});
} else {
// 第一次点击,单击跳转
if (card.id) {
cardTapTimers[card.id] = setTimeout(() => {
delete cardTapTimers[card.id];
uni.navigateTo({
url: `/pages/asset-detail/asset-detail?asset_id=${card.id}`,
});
}, 300);
}
}
};
const handleScrollToLower = () => {
if (creationGridRef.value) {
creationGridRef.value.loadMore();
}
};
const handleActivityClick = (item) => {
uni.navigateTo({
url: `/pages/support-activity/index?id=${item.id}`,
});
};
// 运营 banner 点击(铸造活动)
const handleBannerClick = (banner) => {
console.log('[square] banner click', banner);
// 优先使用自定义 route
if (banner.route) {
return uni.navigateTo({ url: banner.route });
}
if (banner.link_type === 'activity') {
return uni.navigateTo({
url: `/pages/castlove/detail?id=${banner.link_value}`,
});
}
if (banner.link_type === 'topic') {
return uni.navigateTo({
url: `/pages/topic/detail?id=${banner.link_value}`,
});
}
};
const handleRankingVisit = ({ userId, nickname }) => {
showRankingModal.value = false;
uni.navigateTo({
url: `/pages/profile/hisWorks?userId=${userId}&nickname=${encodeURIComponent(nickname)}`,
});
};
const handleRankingModalClose = (visible) => {
showRankingModal.value = visible;
if (!visible && store.state.guide.componentMode) {
uni.$emit("guide:closeComponent");
}
};
const goToHotCategoryMore = () => {
const title =
activeContentTab.value === "displaying"
? "日榜"
: activeContentTab.value === "week"
? "周榜"
: "月榜";
uni.navigateTo({
url: `/pages/square/hot-category-more?dimension=${activeContentTab.value}&title=${encodeURIComponent(title)}`,
});
};
const handleTabChange = (newTab) => {
// if (newTab === 4) {
// navExpanded.value = false;
// return;
// }
const routes = [
"/pages/ai-dazi/index",
"/pages/starbook/index",
"/pages/castlove/mall",
"/pages/starcity/index",
"/pages/profile/myWorks",
];
if (newTab >= 0 && newTab < routes.length) {
uni.navigateTo({
url: routes[newTab],
});
}
};
// 主Tab点击 - 进入铸造页面
const handleMainTabClick = (tab) => {
// 跳转到 mall 页(mall 内嵌 craft-select 组件,带菜单),并带上 type
uni.navigateTo({
url: `/pages/castlove/mall?type=${encodeURIComponent(tab.type)}`,
});
};
const handleCategoryChange = (value) => {
if (activeCategoryTab.value === value) return;
activeCategoryTab.value = value;
};
// ========== Tile Change Callback ==========
const handleTileChange = () => { };
// ========== Reset Square ==========
const resetSquare = async () => { };
// ========== Lifecycle ==========
onMounted(() => {
const info = uni.getSystemInfoSync();
screenWidth.value = info.windowWidth;
screenHeight.value = info.windowHeight;
resetSquare();
loadBannerActivities();
loadBanners();
// 等首屏 DOM 稳定后,启动 spotlight 轮询
// 分类标签的位置/高度测量已迁入 CreationGrid 内部onMounted 自动测)
nextTick(() => {
startSpotlight();
});
});
onShow(() => {
isActive.value = true;
activeContentTab.value = "displaying";
activeCategoryTab.value = "hot";
nextTick(() => {
setTimeout(update, 80);
});
});
onHide(() => {
isActive.value = false;
});
// onLoad((options) => {
// if (options && 'guide_debug' in options) {
// const debugValue = options.guide_debug
// const isDebug = debugValue === '1' || debugValue === 'true'
// if (isDebug) {
// uni.setStorageSync('guide_debug_mode', true)
// uni.setStorageSync('is_new_user', true)
// console.log('[Guide] 调试模式已开启')
// } else {
// uni.setStorageSync('guide_debug_mode', false)
// uni.removeStorageSync('is_new_user')
// console.log('[Guide] 调试模式已关闭')
// }
// }
// if (options && options.guide_key) {
// console.log('[Guide] 收到引导跳转参数, guide_key:', options.guide_key, 'guide_step:', options.guide_step)
// store.dispatch('guide/resumeGuide', options.guide_key).then(res => {
// console.log('[Guide] resumeGuide 结果:', res)
// }).catch(err => {
// console.error('[Guide] resumeGuide 失败:', err)
// })
// }
// })
// 监听引导打开组件事件
uni.$on("guide:openComponent", (componentName) => {
if (componentName === "RankingModal") {
showRankingModal.value = true;
}
});
onUnmounted(() => {
stopSpotlight();
uni.$off("guide:openComponent");
});
</script>
<style lang="scss" scoped>
.square-container {
position: relative;
background: linear-gradient(179.98deg, rgba(255, 229, 229, 0.25) -32.49%, rgba(243, 160, 161, 0.25) -32.49%, rgba(255, 156, 156, 0.25) 86.46%, rgba(255, 32, 36, 0.25) 180.79%);
backdrop-filter: blur(4px);
width: 100vw;
height: 100vh;
min-height: 100vh;
overflow: hidden;
&::before {
content: "";
position: absolute;
inset: 0;
background: url("/static/dashboard/bj.png") center / cover no-repeat;
opacity: 0.2; // ⬅ 调这个数控制图片透明度0=完全透明1=完全不透明)
pointer-events: none;
z-index: 0;
}
}
/* 背景图片 */
.bg-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
/* 内容区域 */
.content-wrapper {
position: relative;
z-index: 1;
width: 100%;
height: 100vh;
/* margin-top: 160rpx; */
padding: 208rpx 16rpx 0;
box-sizing: border-box;
}
/* 区域一:轮播图 */
.banner-section {
width: 100%;
height: 360rpx;
/* margin-bottom: 32rpx; */
}
/* 区域二主Tab+ 区域三(分类标签)+ 占位 + 回弹动效
已迁入 CreationGrid 组件内部(创建于 components/CreationGrid.vue
这里的 .main-tab-section / .category-section / .tab-item / 等选择器被保留
是因为 .spotlight-ready 选择器需要它们CSS specificity 才能匹配(容器外层应用 spotlight-ready。 */
/* 热门分类区块 */
.hot-category-wrapper {
position: relative;
padding-bottom: 80rpx;
}
.hot-more-btn {
position: absolute;
bottom: 0;
right: 16rpx;
z-index: 10;
width: 104rpx;
height: 40rpx;
border-radius: 20rpx;
opacity: 0.66;
padding: 8rpx 20rpx;
background: linear-gradient(90deg,
rgba(255, 222, 8, 0.61) -17.54%,
rgba(255, 0, 25, 0.61) 116.67%);
box-shadow: 2px 2px 4px 0px #f2151578;
display: flex;
justify-content: center;
align-items: center;
}
.hot-more-text {
font-size: 22rpx;
color: #fff;
font-weight: 600;
}
/* 蒙层 */
.nav-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* ========== 滚动 spotlight仅 H5 ==========
* JS 把 --scroll-opacity 写到每个 .spotlight 元素上;
* 边缘渐隐由 CSS transition 平滑。
* 非 H5 不写变量,默认 1元素完全可见。 */
</style>
<style>
.spotlight,
.spotlight-ready .banner-section,
.spotlight-ready .tabs,
.spotlight-ready .hot-category-wrapper,
.spotlight-ready .main-tab-section,
.spotlight-ready .category-section,
.spotlight-ready .creation-card {
/* 默认 opacity 1JS 通过 node.style.opacity 行内样式直接覆盖 */
opacity: 1;
transition: opacity 0.22s ease-out;
will-change: opacity;
}
/* 主Tab 三个图标首次进入视野时 pop 一下first-seen 由 JS 写) */
.spotlight-ready .main-tab-section.first-seen .tab-item .tab-icon-wrap {
animation: tabIconPop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation-delay: var(--tab-delay, 0s);
}
@keyframes tabIconPop {
0% {
transform: scale(0.5) rotate(-90deg);
opacity: 0;
}
60% {
transform: scale(1.08) rotate(0deg);
opacity: 1;
}
100% {
transform: scale(1) rotate(0);
opacity: 1;
}
}
/* 分类标签 fixed 时的回弹just-fixed 短暂挂载) */
.spotlight-ready .category-section.just-fixed {
animation: categoryBounce 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes categoryBounce {
0% {
transform: scale(1);
}
50% {
transform: scale(0.96);
}
100% {
transform: scale(1);
}
}
/* 动效减弱的可访问性兜底 */
@media (prefers-reduced-motion: reduce) {
.spotlight,
.spotlight-ready .banner-section,
.spotlight-ready .tabs,
.spotlight-ready .hot-category-wrapper,
.spotlight-ready .main-tab-section,
.spotlight-ready .category-section,
.spotlight-ready .creation-card,
.spotlight-ready .main-tab-section.first-seen .tab-item .tab-icon-wrap,
.spotlight-ready .category-section.just-fixed {
transition: none !important;
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
</style>