topfans/frontend/pages/square/square.vue
2026-07-21 16:41:42 +08:00

518 lines
17 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>
<!-- [boot 守卫] App.vue onShow 检测到 token 后会 reLaunch quickLogin,
reLaunch 是异步的(~700ms),square 作为 pages.json 0 项默认 landing
会抢在 reLaunch 前完成 setup/mountbooting=true 时整个页面渲染空白,
reLaunch 真正执行后本页被销毁,booting 分支自然消失
防止出现"先 square-container 后 TopfansWelcome"的视觉闪烁 -->
<template v-if="booting">
<!-- 故意留空:boot 重定向窗口期不渲染任何业务内容,避免一闪而过的 square-container -->
</template>
<template v-else>
<!-- TOPFANS 欢迎页确认后才显示主内容 -->
<TopfansWelcome v-if="showWelcome" @enter="handleEnterTopfans" />
<view v-else class="square-container">
<!-- 背景图片 -->
<!-- <image
class="bg-wrapper"
src="/static/square/squearbj11.png"
mode="aspectFill"
></image> -->
<!-- Header组件 -->
<Header
:showGuideIcon="true"
:showTaskIcon="true"
:showStarActivityIcon="true"
:showScanIcon="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"
:scroll-y="activeContentTab === 'guangchang'"
:scroll-top="outerScrollTop"
:show-scrollbar="false"
:bounce="false"
@scroll="onScroll"
@scrolltolower="handleScrollToLower"
>
<!-- 区域一:顶部运营轮播图 -->
<view class="banner-section">
<BannerCarousel
:bannerActivities="bannerActivities"
:banners="banners"
:loading="bannerLoading"
@activityClick="handleActivityClick"
@top3Click="showRankingModal = true"
@bannerClick="handleBannerClick"
/>
</view>
<ContentTabs
class="tabs"
:modelValue="activeContentTab"
@update:modelValue="activeContentTab = $event"
/>
<!-- 星河区块 - 仅在 星河 时显示 -->
<view v-if="activeContentTab === 'xinghe'" class="star-galaxy-wrapper">
<StarGalaxy @cardClick="handleCardClick" />
</view>
<!-- 在线榜单区块 - 仅在 星榜 时显示 -->
<view v-if="activeContentTab === 'xingbang'" class="hot-category-wrapper">
<HotCategoryBlock @cardClick="handleCardClick" />
</view>
<!-- CreationGrid 组件主Tab + 分类标签 + 网格列表 - 仅在 广场 时显示 -->
<CreationGrid
v-if="activeContentTab === 'guangchang'"
@cardClick="handleCardClick"
ref="creationGridRef"
/>
</scroll-view>
</view>
</template>
<!-- /boot 守卫 v-else 闭合 -->
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from "vue";
import { onLoad, onShow, onHide, onReady } 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 StarGalaxy from "./components/StarGalaxy/index.vue";
import TopfansWelcome from "@/components/TopfansWelcome.vue";
// import { clearSubStepProgress, shouldShowGuideStartModal } from '@/utils/guideConfig.js'
import { useBanner } from "./composables/useBanner.js";
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh.js";
import { doubleTapLike } from "@/utils/likeHelper.js";
import { getPreloadApi } from "@/utils/preloadApi/index";
import checkUpdate from "@/uni_modules/uni-upgrade-center-app/utils/check-update";
// ========== 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("xinghe");
// TOPFANS 欢迎页:满足两个条件才显示
// 1) needs_welcome=truequickLogin / 首次注册时由上游页面写入
// (持久化,只在 app 销毁/卸载时才丢)
// 2) !globalData.welcomeShownThisSession本次 app 会话内尚未展示过
// App.vue 的 onLaunch 触发时重置为 false即"后台关闭后重新打开"或"首次启动"两种冷启动场景)
// onShow 不重置,避免按 home 键前后台切换时误触发)
const showWelcome = ref(
!!uni.getStorageSync("needs_welcome") &&
!getApp().globalData.welcomeShownThisSession,
);
// [boot 守卫] App.vue onShow 检测到 token 后会 uni.reLaunch 到 quickLogin,
// 但 reLaunch 是异步的(~700ms 才真正执行),而 square 作为 pages.json 第 0 项
// 默认 landing 页面会抢在 reLaunch 完成前完成 setup/mount,渲染出一帧 square-container。
// 修复:App.vue 改成在 reLaunch 的 success 回调里才清 shouldShowQuickLogin,
// 这里读到的还是 true,就标记 booting=true,模板渲染空白,不渲染 TopfansWelcome / square-container。
// 等 reLaunch 真正生效后,本页会被销毁,booting 这个分支自然消失。
const booting = ref(!!getApp().globalData.shouldShowQuickLogin);
const handleEnterTopfans = () => {
showWelcome.value = false;
getApp().globalData.welcomeShownThisSession = true;
// ★ 用户已经看过欢迎页,把持久化的 needs_welcome 立刻从 storage 清掉。
// 解决:App.vue onLaunch 在 recent restart(5 分钟内杀进程重开)时
// 会无条件 removeStorageSync("needs_welcome"),但此时若用户刚登录完就被
// 后台杀掉,登录时写入的 needs_welcome 会被一并清掉,导致 welcome 不弹。
// 把清理时机下移到"用户已确认看过欢迎页",recent restart 清理就只是兜底,
// 不会再误伤"刚登录还没看过"的场景。
uni.removeStorageSync("needs_welcome");
// ★ 欢迎页关闭后立即触发 idle 预拉
// App.vue onShow 在 quickLogin 流程中提前 returnwarmIdle 未执行
// 此处补偿:三个 tab + banner 的预热请求,在组件挂载前就开始跑
try {
const api = getPreloadApi();
if (api) api.warmIdle();
} catch (e) {}
// ★ onReady 在 welcome 渲染时已经 fire 过,那时 showWelcome=true 跳过了 checkUpdate。
// 用户点 enter 进入 square 主内容后,这里补一次触发,保证更新检查不漏。
// 注意:正常无 welcome 路径(无 token / 无 needs_welcome)由 onReady 那条路径触发,
// 这里是兜底,两条路径互斥,checkUpdate 只会跑一次。
checkUpdate();
// ★ P2 修复(2026-07-21):deep link 唤起冷启动后,App.vue onShow + login.vue/quickLogin.vue
// 都只落盘 pending_scan_url,真正跳转验真页推迟到"用户进入 square 时"。
// 这里就是那个时机 —— welcome 点击等同于"用户进入 square 主页"。
// 有 pending_scan_url 就 reLaunch 到验真页,没有就走正常 square 流程(留在 square)。
const pendingScanUrl = uni.getStorageSync("pending_scan_url");
if (pendingScanUrl) {
uni.removeStorageSync("pending_scan_url");
console.log("[handleEnterTopfans] 🔗 消费 pending_scan_url →", pendingScanUrl);
uni.reLaunch({ url: pendingScanUrl });
}
};
const navExpanded = ref(false);
const showRankingModal = ref(false);
const creationGridRef = ref(null);
const cardTapTimers = {};
const likingMap = ref({});
// 外层 scroll-view 的 scrollTop 受控值:通过 ref 绑定实现程序化重置
// uni.pageScrollTo 操作的是 page 滚动条,不会作用于 <scroll-view>
const outerScrollTop = ref(0);
// 切 tab 时重置外层 scroll-view 的 scrollTop,避免残留滚动位置
watch(activeContentTab, () => {
// 星河/星榜 时 scroll-y=false,但 scrollTop 残留可能导致显示异常
outerScrollTop.value = 0;
});
// 统一 scroll 处理:驱动 CreationGrid 切 fixed
const onScroll = (e) => {
const scrollTop = (e && e.detail && e.detail.scrollTop) || 0;
// 通知 CreationGrid根据 scrollTop 决定分类标签是否切到 fixed
creationGridRef.value?.updateScroll?.(scrollTop);
};
// ========== Composables ==========
const { bannerActivities, banners, loading: bannerLoading, loadBannerActivities, loadBanners } =
useBanner();
// ========== 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];
const navApi = getPreloadApi(); (navApi || uni).navigateTo({
url: `/pages/asset-detail/asset-detail?asset_id=${card.id}`,
});
}, 300);
}
}
};
const handleScrollToLower = () => {
if (creationGridRef.value) {
creationGridRef.value.loadMore();
}
};
const handleActivityClick = (item) => {
const navApi = getPreloadApi(); (navApi || uni).navigateTo({
url: `/pages/support-activity/index?id=${item.id}`,
});
};
// 运营 banner 点击(铸造活动)
const handleBannerClick = (banner) => {
console.log("[square] banner click", banner);
// 优先使用自定义 route若匹配已配置的页面映射自动触发预拉
if (banner.route) {
const api = getPreloadApi();
return (api || uni).navigateTo({ url: buildBannerUrl(banner) });
}
};
// 拼接 banner 跳转 urlroute + params(JSON 字符串解析后作为 query)
const buildBannerUrl = (banner) => {
const base = banner.route || "";
if (!banner.params) return base;
try {
const obj = JSON.parse(banner.params);
const entries = Object.entries(obj)
.filter(([, v]) => v !== undefined && v !== null)
.map(
([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`,
);
if (entries.length === 0) return base;
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}${entries.join("&")}`;
} catch (e) {
// params 解析失败时只打印日志,不影响 route 跳转
console.warn("[square] banner.params 解析失败, 忽略", banner.params, e);
return base;
}
};
const handleRankingVisit = ({ userId, nickname }) => {
showRankingModal.value = false;
const navApi = getPreloadApi(); (navApi || 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 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/profile",
];
if (newTab >= 0 && newTab < routes.length) {
const navApi = getPreloadApi(); (navApi || uni).navigateTo({
url: routes[newTab],
});
}
};
// 主Tab点击 / 分类切换 已在 CreationGrid 内部处理,不再冒泡
// ========== Tile Change Callback ==========
const handleTileChange = () => {};
// ========== Reset Square ==========
const resetSquare = async () => {};
// ========== Lifecycle ==========
onMounted(() => {
resetSquare();
loadBannerActivities();
loadBanners();
});
// 离开页面时记住当前 tab,返回时恢复;首次进入默认 xinghe
const lastActiveTab = ref(null);
onHide(() => {
lastActiveTab.value = activeContentTab.value;
});
onShow(() => {
// 有记录说明是从其他页面返回,恢复之前停留的 tab
// 没有记录说明是首次进入(或从后台切回),保持 xinghe
if (lastActiveTab.value) {
activeContentTab.value = lastActiveTab.value;
lastActiveTab.value = null;
} else {
activeContentTab.value = "xinghe";
}
// uni.showToast({
// title: "页面加载完成",
// icon: "success",
// duration: 2000,
// });
});
// ========== 后台切回自动刷新 ==========
// App 从后台切回前台时,刷新 banner 数据。
// 三个 Tab 内容区中:
// - StarGalaxy默认 Tab 且 v-if 命中后会保持挂载,需要在 StarGalaxy 内部独立接入 useBackgroundRefresh
// - HotCategoryBlock / CreationGridonShow 会切回 xinghe自身被 v-if 卸载后再切回时重新挂载即重新拉数据
useBackgroundRefresh(() => {
loadBannerActivities();
loadBanners();
});
// 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(() => {
uni.$off("guide:openComponent");
});
onReady(() => {
// 三重守卫,避免在不该弹的时机弹出更新提示:
// 1) booting=true:App.vue 正在异步 reLaunch 到 quickLogin,本页即将被销毁,弹更新无意义
// 2) showWelcome.value=true:用户在欢迎页,弹更新会干扰欢迎页动画
// 3) 都不命中:正常进 square,弹更新
// 历史 bug:之前只判断 !showWelcome.value,boot 窗口期第 1 次 mount 时
// booting=true 但 showWelcome=false(storage 里 needs_welcome 是空),
// 条件命中触发 checkUpdate → quickLogin 跑完 → 用户点一键登录后看到更新弹窗,
// 跟"刚登录完"的体验冲突。
if (!booting.value && !showWelcome.value) {
checkUpdate();
}
});
</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 0 0;
box-sizing: border-box;
}
/* 区域一:轮播图 */
.banner-section {
width: 100%;
height: 360rpx;
/* margin-bottom: 32rpx; */
}
/* 区域二主Tab+ 区域三(分类标签)+ 占位 + 回弹动效
已迁入 CreationGrid 组件内部(创建于 components/CreationGrid.vue。 */
/* 热门分类区块 */
.hot-category-wrapper {
position: relative;
/* 100vh - header(208rpx) - banner(360rpx) - tabs(88rpx) = 可用 body 高度 */
height: calc(100vh - 208rpx - 360rpx - 88rpx);
top: 8rpx;
padding: 0 24rpx;
}
/* 蒙层 */
.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;
}
}
</style>