518 lines
17 KiB
Vue
518 lines
17 KiB
Vue
<template>
|
||
<!-- [boot 守卫] App.vue onShow 检测到 token 后会 reLaunch 到 quickLogin,
|
||
但 reLaunch 是异步的(~700ms),square 作为 pages.json 第 0 项默认 landing
|
||
会抢在 reLaunch 前完成 setup/mount。booting=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=true:quickLogin / 首次注册时由上游页面写入
|
||
// (持久化,只在 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 流程中提前 return,warmIdle 未执行
|
||
// 此处补偿:三个 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 跳转 url:route + 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 / CreationGrid:onShow 会切回 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>
|