1197 lines
32 KiB
Vue
1197 lines
32 KiB
Vue
<template>
|
||
<!-- 触摸事件绑定在最外层,确保 cabin 图标不会拦截滑动手势 -->
|
||
<view class="square-container" @touchstart="onBgTouchStart" @touchmove="onBgTouchMove" @touchend="onBgTouchEnd"
|
||
@touchcancel="onBgTouchCancel">
|
||
<!-- 横向无限滚动背景条 -->
|
||
<view class="background-strip" :style="backgroundStripStyle">
|
||
<image v-for="i in 3" :key="i" class="background-tile" :style="{ width: tileWidth + 'px', height: '100%', }"
|
||
src="/static/background/mainbg.png" :ref="'img' + i" />
|
||
</view>
|
||
|
||
<!-- Cabin 图标层(与背景同步移动) -->
|
||
<view class="cabin-layer" :style="cabinLayerStyle">
|
||
<view v-for="cabin in visibleCabins" :key="cabin.key" :id="'cabin-' + cabin.key" class="cabin-wrapper"
|
||
:class="{ 'cabin-nickname-mine': cabin.isMine || cabin.nickname === currentUserNickname, 'cabin-slots-zero': cabin.sharedBoothSlotsRemaining === 0 }"
|
||
:style="{ left: cabin.x + 'px', top: cabin.y + 'px', width: cabin.w + 'px' }"
|
||
@click="handleCabinClick(cabin)">
|
||
<image class="cabin-icon" :src="cabin.src" :style="{ width: cabin.w + 'px', height: cabin.h + 'px' }"
|
||
mode="scaleToFill" />
|
||
|
||
<text v-if="cabin.showNickname && cabin.nickname" class="cabin-nickname">{{ cabin.nickname ===
|
||
currentUserNickname ? '我的小屋' : cabin.nickname }}</text>
|
||
<text v-else class="cabin-nickname cabin-nickname--empty">小屋暂无人居住</text>
|
||
<view v-if="cabin.showDialog" class="cabin-slots-dialog">
|
||
<text class="cabin-slots-text text-white">剩余 <text class="text-orange"> {{
|
||
cabin.sharedBoothSlotsRemaining }} </text> 个展位</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 翻页箭头按钮 -->
|
||
<view class="nav-arrows">
|
||
<view class="arrow-btn arrow-left" @click="scrollPage(-1)">
|
||
<text class="arrow-text">‹</text>
|
||
</view>
|
||
<view class="arrow-btn arrow-right" @click="scrollPage(1)">
|
||
<text class="arrow-text">›</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Header组件 -->
|
||
<Header :showGuideIcon="true" :showTaskIcon="true" :showStarActivityIcon="true" backIconColor="#e6e6e6" />
|
||
|
||
<!-- 轮播图 + 应援活动列表 -->
|
||
<view class="banner-carousel" @click.stop>
|
||
<swiper class="banner-swiper" :autoplay="true" :interval="4000" :duration="400" :circular="true"
|
||
:indicator-dots="false">
|
||
<swiper-item @click.stop="showRankingModal = true">
|
||
<BannerTop3 />
|
||
</swiper-item>
|
||
<swiper-item v-for="item in bannerActivities" :key="item.id" @click.stop="handleActivityClick(item)">
|
||
<image class="banner-activity-img" :src="item.cover_image || '/static/avatar/1.jpeg'"
|
||
mode="aspectFill" />
|
||
</swiper-item>
|
||
</swiper>
|
||
</view>
|
||
|
||
|
||
|
||
<!-- 蒙层 - 导航栏展开时显示 -->
|
||
<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="0" :isExpanded="navExpanded" @update:activeTab="handleTabChange"
|
||
@update:isExpanded="navExpanded = $event" />
|
||
|
||
<!-- 全局引导遮罩 -->
|
||
|
||
<!-- 新手引导开始弹窗 -->
|
||
<GuideStartModal :visible="showGuideStartModal" @start="handleGuideStart" @close="handleGuideStartModalClose" />
|
||
|
||
<!-- 全局引导遮罩 -->
|
||
<GuideOverlay />
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import {
|
||
ref,
|
||
reactive,
|
||
computed,
|
||
shallowRef,
|
||
watch,
|
||
onMounted,
|
||
onUnmounted
|
||
} from "vue";
|
||
import {
|
||
onLoad,
|
||
onShow,
|
||
} from "@dcloudio/uni-app";
|
||
import {
|
||
useStore
|
||
} from "vuex";
|
||
import Header from "../components/Header.vue";
|
||
import BottomNav from "../components/BottomNav.vue";
|
||
import GuideStartModal from "@/components/GuideStartModal.vue";
|
||
import GuideOverlay from "@/components/GuideOverlay.vue";
|
||
import {
|
||
getRandomUsersApi,
|
||
getActivityListApi,
|
||
getOssPresignedUrlApi,
|
||
} from "@/utils/api.js";
|
||
import BannerTop3 from "../components/BannerTop3.vue";
|
||
import RankingModal from "../components/RankingModal.vue";
|
||
import {
|
||
shouldShowGuideStartModal
|
||
} from "@/utils/guideConfig.js";
|
||
|
||
const activeTab = ref(0);
|
||
|
||
const navExpanded = ref(false);
|
||
|
||
// 排行榜弹窗
|
||
const showRankingModal = ref(false);
|
||
const currentStarId = ref(uni.getStorageSync("star_id") || null);
|
||
|
||
// 新手引导弹窗
|
||
const showGuideStartModal = ref(false);
|
||
|
||
// --- Banner 活动图片(取前3条) ---
|
||
const bannerActivities = ref([]);
|
||
|
||
const loadBannerActivities = async () => {
|
||
try {
|
||
const starId = uni.getStorageSync("star_id") || null;
|
||
const res = await getActivityListApi(starId, "active", 1, 3);
|
||
if (res.code === 200 && res.data?.activities) {
|
||
bannerActivities.value = await Promise.all(
|
||
res.data.activities.map(async (item) => {
|
||
if (!item.cover_image) return item;
|
||
try {
|
||
const r = await getOssPresignedUrlApi(
|
||
item.cover_image,
|
||
3600,
|
||
"avatar",
|
||
);
|
||
if (r?.code === 200 && r.data?.url)
|
||
return {
|
||
...item,
|
||
cover_image: r.data.url,
|
||
};
|
||
} catch (e) { }
|
||
return item;
|
||
}),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.error("[Square] 加载 banner 活动失败", e?.message ?? e);
|
||
}
|
||
};
|
||
|
||
const handleActivityClick = (item) => {
|
||
uni.navigateTo({
|
||
url: `/pages/support-activity/index?id=${item.id}`,
|
||
});
|
||
};
|
||
|
||
// 处理排行榜弹窗中的拜访小屋点击
|
||
const handleRankingVisit = (userId) => {
|
||
showRankingModal.value = false;
|
||
uni.navigateTo({
|
||
url: `/pages/exhibition/exhibition?target_uid=${userId}`,
|
||
});
|
||
};
|
||
|
||
// 处理排行榜弹窗关闭(引导中使用)
|
||
const handleRankingModalClose = (visible) => {
|
||
showRankingModal.value = visible;
|
||
if (!visible && store.state.guide.isActive) {
|
||
// 引导中关闭组件,触发下一步
|
||
uni.$emit('guide:closeComponent')
|
||
}
|
||
};
|
||
|
||
// --- 背景横向无限滚动 ---
|
||
// 背景图原始尺寸,用于按屏幕高度等比计算 tileWidth(一个 tile 的宽度)
|
||
const IMAGE_W = 2012;
|
||
const IMAGE_H = 1918;
|
||
|
||
const tileWidth = ref(375);
|
||
const screenWidth = ref(375);
|
||
const screenHeight = ref(812);
|
||
// bgOffsetX:当前背景的横向偏移量,始终被 clampOffset 限制在 [-tileWidth, 0) 范围内。
|
||
// 背景由 3 张图片并排(strip),初始 translateX = -tileWidth,使中间那张居中显示。
|
||
// 滑动时只改变 bgOffsetX,CSS transform 实时跟随,视觉上形成无限滚动效果。
|
||
const bgOffsetX = ref(0);
|
||
let touchStartX = 0;
|
||
let lastMoveX = 0;
|
||
let lastMoveTime = 0;
|
||
let velocity = 0;
|
||
let inertiaRaf = null;
|
||
|
||
// --- Cabin 坐标 ---
|
||
// 每个元素 [x, y] 是背景图片的小平台中心点坐标,也是 cabin 在背景原图(2012×1918)中的像素坐标。
|
||
// 运行时会按 windowHeight/IMAGE_H 等比缩放为屏幕坐标(见 onMounted 中的 scaledCoords)。
|
||
// 如需调整某个 cabin 的显示位置,修改对应的 [x, y] 值即可;
|
||
// 坐标顺序即为每页用户数据的排列顺序(第 i 个坐标对应第 i 个用户)。
|
||
// 原图 2293×4281 → 新图 2012×1918,按比例 x*0.8775 y*0.4481 换算
|
||
const CABIN_COORDS = [
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[0, 0],
|
||
[-260, -20],
|
||
[235, -20],
|
||
[750, -20],
|
||
[1265, -20],
|
||
[0, 150],
|
||
[510, 150],
|
||
[1010, 150],
|
||
[1505, 150],
|
||
[-260, 350],
|
||
[235, 350],
|
||
[750, 350],
|
||
[1265, 350],
|
||
[0, 530],
|
||
[510, 530],
|
||
[1010, 530],
|
||
[1505, 520],
|
||
[-260, 730],
|
||
[235, 730],
|
||
[750, 730],
|
||
[1265, 730],
|
||
[0, 910],
|
||
[510, 910],
|
||
[1010, 910],
|
||
[1505, 910],
|
||
[-260, 1115],
|
||
[235, 1115],
|
||
[750, 1115],
|
||
[1265, 1115],
|
||
[0, 1300],
|
||
[510, 1300],
|
||
[1010, 1300],
|
||
[1505, 1300],
|
||
[-260, 1510],
|
||
[235, 1510],
|
||
[750, 1510],
|
||
[1265, 1510],
|
||
[0, 1690],
|
||
[510, 1690],
|
||
[1010, 1690],
|
||
[1505, 1690],
|
||
[-260, 1910],
|
||
[235, 1910],
|
||
[750, 1910],
|
||
[1265, 1910],
|
||
];
|
||
|
||
// --- 无限翻页:数据分页与缓存 ---
|
||
// 实现原理:
|
||
// 背景图片循环平铺(3 张并排 + clampOffset 回绕),视觉上可无限横向滚动。
|
||
// 每滑过一个 tile 宽度,currentPage 递增/递减,通过 wrapPage 在 [1, maxPage] 环形回绕,
|
||
// 实现"最后一页 → 第一页"的无缝循环。
|
||
// ensurePages 始终保证当前可见的 5 个 tile 对应的页数据已缓存,
|
||
// 超出范围的页缓存会被自动清除以节省内存。
|
||
const PAGE_SIZE = CABIN_COORDS.length;
|
||
const currentPage = ref(1);
|
||
const totalUsers = ref(0);
|
||
const pageCache = shallowRef(new Map());
|
||
// pageCache 内容变化时递增,用于驱动 watch(shallowRef 的 triggerRef 不触发普通 watch)
|
||
const pageCacheVersion = ref(0);
|
||
// 正在进行中的请求页码集合,清除缓存时跳过这些页,防止"刚加载的数据被立即删除"
|
||
const pendingPages = new Set();
|
||
|
||
const maxPage = computed(() =>
|
||
Math.max(1, Math.ceil(totalUsers.value / PAGE_SIZE)),
|
||
);
|
||
|
||
// 将页码环形回绕到 [1, maxPage] 范围,使翻页可无限循环
|
||
const wrapPage = (p) => {
|
||
const max = maxPage.value;
|
||
return ((p - 1 + max) % max) + 1;
|
||
};
|
||
|
||
const fetchPage = async (page) => {
|
||
if (pageCache.value.has(page)) {
|
||
return;
|
||
}
|
||
if (pendingPages.has(page)) {
|
||
return;
|
||
}
|
||
const t0 = Date.now();
|
||
pendingPages.add(page);
|
||
try {
|
||
const res = await getRandomUsersApi(page, PAGE_SIZE);
|
||
if (res.code === 200 && res.data) {
|
||
if (totalUsers.value === 0) totalUsers.value = res.data.total || 0;
|
||
pageCache.value.set(page, (() => {
|
||
const users = res.data.users || [];
|
||
if (users.length >= 3) [users[0], users[2]] = [users[2], users[0]];
|
||
return users;
|
||
})());
|
||
pageCacheVersion.value++;
|
||
} else { }
|
||
} catch (e) {
|
||
console.error(
|
||
`[Square] fetchPage: page=${page} 请求失败 耗时=${Date.now() - t0}ms`,
|
||
e?.message ?? e,
|
||
);
|
||
} finally {
|
||
pendingPages.delete(page);
|
||
}
|
||
};
|
||
|
||
const ensurePages = (center) => {
|
||
// 渲染范围 n=[0,4] 对应页码 center-1 ~ center+3,需全部预加载
|
||
const keep = new Set();
|
||
for (let n = 0; n <= 4; n++) keep.add(wrapPage(center + n - 1));
|
||
|
||
const pages = [...keep];
|
||
pages.forEach(fetchPage);
|
||
|
||
// 清除不在渲染范围内的页缓存,跳过正在请求中的页
|
||
const evicted = [];
|
||
for (const key of pageCache.value.keys()) {
|
||
if (!keep.has(key) && !pendingPages.has(key)) {
|
||
evicted.push(key);
|
||
pageCache.value.delete(key);
|
||
}
|
||
}
|
||
if (evicted.length) {
|
||
pageCacheVersion.value++;
|
||
}
|
||
};
|
||
|
||
// 每种 cabin 的图片路径、原图尺寸、锚点(需要cabin底座中心点对准背景小平台中心点坐标的像素位置)
|
||
const CABIN_DEFS = [{
|
||
src: "/static/components/cabin1.png",
|
||
imgW: 1000,
|
||
imgH: 1000,
|
||
anchorX: 500,
|
||
anchorY: 680,
|
||
},
|
||
{
|
||
src: "/static/components/cabin2.png",
|
||
imgW: 1000,
|
||
imgH: 1000,
|
||
anchorX: 500,
|
||
anchorY: 680,
|
||
},
|
||
{
|
||
src: "/static/components/cabin3.png",
|
||
imgW: 1000,
|
||
imgH: 1351,
|
||
anchorX: 500,
|
||
anchorY: 965,
|
||
},
|
||
{
|
||
src: "/static/components/cabin4.png",
|
||
imgW: 1000,
|
||
imgH: 1223,
|
||
anchorX: 500,
|
||
anchorY: 875,
|
||
},
|
||
];
|
||
|
||
// 根据用户等级返回 cabin 类型索引(0=cabin1, 1=cabin2, 2=cabin3, 3=cabin4)
|
||
const cabinTypeByLevel = (level) => {
|
||
if (level >= 7) return 3;
|
||
if (level >= 5) return 2;
|
||
if (level >= 3) return 1;
|
||
return 0;
|
||
};
|
||
|
||
// 缩放后的坐标(ref,onMounted 赋值后触发 visibleCabins 重新计算)
|
||
const scaledCoords = ref([]);
|
||
// 每种 cabin 渲染后的宽高和锚点偏移(px),onMounted 中计算
|
||
let cabinRenderDefs = [];
|
||
|
||
// uni-app 环境下使用 uni.requestAnimationFrame 替代 requestAnimationFrame
|
||
const rafFn = (cb) =>
|
||
uni.requestAnimationFrame ?
|
||
uni.requestAnimationFrame(cb) :
|
||
setTimeout(cb, 16);
|
||
const cafFn = (id) =>
|
||
uni.cancelAnimationFrame ? uni.cancelAnimationFrame(id) : clearTimeout(id);
|
||
|
||
// 累计未归一化的总偏移量(px),用于检测跨越了多少个 tile
|
||
let rawOffsetX = 0;
|
||
// 是否处于惯性滚动阶段(touchEnd 后),用于决定是否防抖
|
||
let isInertiaPhase = false;
|
||
// 防止 ensurePages 在惯性滚动中被高频调用(仅惯性阶段使用)
|
||
let ensureTimer = null;
|
||
const debouncedEnsurePages = (center) => {
|
||
if (ensureTimer) clearTimeout(ensureTimer);
|
||
ensureTimer = setTimeout(() => {
|
||
ensureTimer = null;
|
||
ensurePages(center);
|
||
}, 100);
|
||
};
|
||
|
||
// 将 offsetX 归一化到 [-tileWidth, 0) 范围,实现无缝回绕
|
||
const clampOffset = (offset) => {
|
||
const w = tileWidth.value;
|
||
return (((offset % w) + w) % w) - w;
|
||
};
|
||
|
||
// 滑动时使用:归一化 + 通过累计原始偏移量检测跨越 tile 边界,更新 currentPage
|
||
const normalizeOffset = (offset) => {
|
||
const w = tileWidth.value;
|
||
const normalized = clampOffset(offset);
|
||
const prevTileN = Math.floor(-rawOffsetX / w);
|
||
rawOffsetX += offset - bgOffsetX.value;
|
||
const nextTileN = Math.floor(-rawOffsetX / w);
|
||
const delta = nextTileN - prevTileN;
|
||
if (delta !== 0) {
|
||
const newPage = wrapPage(currentPage.value + delta);
|
||
currentPage.value = newPage;
|
||
// 手动触摸阶段立即触发,惯性阶段防抖避免高频调用
|
||
if (isInertiaPhase) {
|
||
debouncedEnsurePages(currentPage.value);
|
||
} else {
|
||
ensurePages(currentPage.value);
|
||
}
|
||
}
|
||
return normalized;
|
||
};
|
||
|
||
const backgroundStripStyle = computed(() => ({
|
||
width: `${tileWidth.value * 3}px`,
|
||
transform: `translateX(${-tileWidth.value + (screenWidth.value - tileWidth.value) / 2 + bgOffsetX.value}px)`
|
||
// transform: `translateX(${-tileWidth.value + bgOffsetX.value}px)`,
|
||
// transform: `translateX(${-tileWidth.value + (screenWidth.value - tileWidth.value) / 2 + bgOffsetX.value}px) rotateX(45deg) translateY(${(-tileWidth.value + (screenWidth.value - tileWidth.value) / 2 + bgOffsetX.value) / 4}px)`,
|
||
}));
|
||
|
||
// --- Cabin 层 ---
|
||
const cabinLayerStyle = computed(() => ({
|
||
// transform: `translateX(${-tileWidth.value + bgOffsetX.value}px)`,
|
||
transform: `translateX(${-tileWidth.value + (screenWidth.value - tileWidth.value) / 2 + bgOffsetX.value}px) `
|
||
}));
|
||
|
||
// visibleCabins 是稳定的对象数组,DOM 节点始终复用,避免图片重载闪烁。
|
||
// 更新分两条路径:
|
||
// - 翻页 / 坐标初始化(scaledCoords 变化):重建整个数组(位置 + 数据全量更新)
|
||
// - 数据加载完成(pageCacheVersion 变化):只原地更新数据字段,不替换数组引用
|
||
// cabin 的屏幕位置通过 cabinLayerStyle 的 translateX 整体平移,无需每帧重建列表。
|
||
const visibleCabins = ref([]);
|
||
|
||
// cabin 层整体有 -tileWidth 的初始偏移(cabinLayerStyle),
|
||
// 因此 n=1 的 tile 才是视觉上的"当前页"(屏幕中央)。
|
||
// 页码映射:tile n → wrapPage(page + n - 1),使 n=1 对应 currentPage。
|
||
// 渲染范围 n=[0,4],覆盖左右各 1 个 tile 的过渡区域。
|
||
const buildVisibleCabins = (page, cache, coords) => {
|
||
if (!coords.length || !cabinRenderDefs.length) return [];
|
||
const w = tileWidth.value;
|
||
const result = [];
|
||
|
||
// 轮播图底部边界:top(184rpx) + height(312rpx) = 496rpx,转换为 px
|
||
// 额外加一个 cabin 高度的缓冲,确保被 banner 部分遮挡的小屋也不显示昵称
|
||
const rpxToPx = screenWidth.value / 750;
|
||
const cabinH = cabinRenderDefs.length ? cabinRenderDefs[0].renderedH : 0;
|
||
const bannerBottom = 496 * rpxToPx + cabinH;
|
||
|
||
// 第一遍:收集上方有用户数据的 cabin(待移位),以及下方空位坐标(无用户数据)
|
||
// 结构:{ i, n, user, typeIdx, def, render }
|
||
const toRelocate = []; // 上方有数据需要移位的
|
||
const emptySlots = []; // 下方无数据的空位 { sx, sy, n }
|
||
|
||
for (let i = 0; i < coords.length; i++) {
|
||
const {
|
||
sx,
|
||
sy
|
||
} = coords[i];
|
||
for (let n = 0; n <= 4; n++) {
|
||
const p = wrapPage(page + n - 1);
|
||
const users = cache.get(p) || [];
|
||
const user = users[i] || null;
|
||
const isAbove = sy < bannerBottom;
|
||
if (isAbove && user) {
|
||
const typeIdx = cabinTypeByLevel(user.level);
|
||
toRelocate.push({
|
||
i,
|
||
n,
|
||
user,
|
||
typeIdx,
|
||
sx,
|
||
sy
|
||
});
|
||
} else if (!isAbove && !user) {
|
||
emptySlots.push({
|
||
sx,
|
||
sy,
|
||
n
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 第二遍:正常渲染,跳过"被移位占用的空位"和"已移位的上方 cabin"
|
||
// 记录哪些空位被占用(按 index)
|
||
const occupiedSlots = new Set();
|
||
for (let k = 0; k < toRelocate.length && k < emptySlots.length; k++) {
|
||
occupiedSlots.add(k);
|
||
}
|
||
|
||
for (let i = 0; i < coords.length; i++) {
|
||
const {
|
||
sx,
|
||
sy
|
||
} = coords[i];
|
||
for (let n = 0; n <= 4; n++) {
|
||
const p = wrapPage(page + n - 1);
|
||
const users = cache.get(p) || [];
|
||
const user = users[i] || null;
|
||
const isAbove = sy < bannerBottom;
|
||
|
||
// 跳过上方有数据的(会被移位渲染)
|
||
if (isAbove && user) continue;
|
||
|
||
// 检查这个空位是否被移位 cabin 占用
|
||
const slotIdx = emptySlots.findIndex(
|
||
(s) => s.sx === sx && s.sy === sy && s.n === n,
|
||
);
|
||
if (slotIdx !== -1 && occupiedSlots.has(slotIdx)) continue;
|
||
|
||
const typeIdx = cabinTypeByLevel(user ? user.level : 0);
|
||
const def = CABIN_DEFS[typeIdx];
|
||
const render = cabinRenderDefs[typeIdx];
|
||
const cabinY = sy - render.offsetY;
|
||
result.push(
|
||
reactive({
|
||
key: `${i}-${n}`,
|
||
x: sx + n * w - render.offsetX,
|
||
y: cabinY,
|
||
w: render.renderedW,
|
||
h: render.renderedH,
|
||
src: def.src,
|
||
userId: user ? user.user_id : null,
|
||
galleryOwnerId: user ? user.gallery_owner_id : null,
|
||
nickname: user ? user.nickname : null,
|
||
sharedBoothSlotsRemaining: user ? user.shared_booth_slots_remaining : null,
|
||
showNickname: !isAbove,
|
||
isMine: user ? user.nickname === currentUserNickname.value : false,
|
||
showDialog: false,
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
// 第三遍:把移位的 cabin 放到对应空位坐标上
|
||
for (let k = 0; k < toRelocate.length; k++) {
|
||
const {
|
||
n,
|
||
user,
|
||
typeIdx
|
||
} = toRelocate[k];
|
||
const def = CABIN_DEFS[typeIdx];
|
||
const render = cabinRenderDefs[typeIdx];
|
||
|
||
let targetSx, targetSy;
|
||
if (k < emptySlots.length) {
|
||
// 放到空位坐标
|
||
targetSx = emptySlots[k].sx;
|
||
targetSy = emptySlots[k].sy;
|
||
} else {
|
||
// 空位不够,跳过不渲染
|
||
continue;
|
||
}
|
||
|
||
result.push(
|
||
reactive({
|
||
key: `reloc-${k}-${n}`,
|
||
x: targetSx + n * w - render.offsetX,
|
||
y: targetSy - render.offsetY,
|
||
w: render.renderedW,
|
||
h: render.renderedH,
|
||
src: def.src,
|
||
userId: user.user_id,
|
||
galleryOwnerId: user.gallery_owner_id,
|
||
nickname: user.nickname,
|
||
sharedBoothSlotsRemaining: user.shared_booth_slots_remaining,
|
||
showNickname: true,
|
||
isMine: user.nickname === currentUserNickname.value,
|
||
showDialog: false,
|
||
}),
|
||
);
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
// 仅更新数据字段(src / userId / galleryOwnerId / nickname),
|
||
// 保持数组引用和 DOM 节点稳定,防止图片重载闪烁。
|
||
const patchVisibleCabinsData = (page, cache) => {
|
||
const list = visibleCabins.value;
|
||
if (!list.length) return;
|
||
let idx = 0;
|
||
for (let i = 0; i < CABIN_COORDS.length; i++) {
|
||
for (let n = 0; n <= 4; n++) {
|
||
const p = wrapPage(page + n - 1);
|
||
const users = cache.get(p) || [];
|
||
const user = users[i] || null;
|
||
const typeIdx = cabinTypeByLevel(user ? user.level : 0);
|
||
const item = list[idx++];
|
||
item.src = CABIN_DEFS[typeIdx].src;
|
||
item.userId = user ? user.user_id : null;
|
||
item.galleryOwnerId = user ? user.gallery_owner_id : null;
|
||
item.nickname = user ? user.nickname : null;
|
||
item.sharedBoothSlotsRemaining = user ? user.shared_booth_slots_remaining : null;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 翻页或坐标初始化时全量重建(位置变了,必须重建)
|
||
watch(
|
||
[currentPage, scaledCoords],
|
||
() => {
|
||
visibleCabins.value = buildVisibleCabins(
|
||
currentPage.value,
|
||
pageCache.value,
|
||
scaledCoords.value,
|
||
);
|
||
}, {
|
||
immediate: true,
|
||
},
|
||
);
|
||
|
||
// 数据加载完成时重建(因为移位逻辑依赖用户数据,需要全量重建)
|
||
watch(pageCacheVersion, () => {
|
||
visibleCabins.value = buildVisibleCabins(
|
||
currentPage.value,
|
||
pageCache.value,
|
||
scaledCoords.value,
|
||
);
|
||
// cabin 数据加载完成后,如果引导处于激活状态,重新计算位置
|
||
if (store.state.guide.isActive) {
|
||
const guide = store.state.guide.currentGuide
|
||
const currentStep = store.state.guide.currentStep
|
||
const stepConfig = guide?.steps?.[currentStep]
|
||
// 检查当前步骤是否在当前页面
|
||
if (stepConfig?.page === '/pages/square/square') {
|
||
setTimeout(() => {
|
||
uni.$emit('guide:recalculatePosition')
|
||
}, 100)
|
||
}
|
||
}
|
||
});
|
||
|
||
// 展位提示框随机轮换定时器
|
||
let dialogRotationTimer = null;
|
||
|
||
// 计算 cabin 是否在可视区域内(通过 DOM boundingClientRect 判断,漏出区域大于70%)
|
||
const isCabinInViewport = (cabin) => {
|
||
return new Promise((resolve) => {
|
||
const query = uni.createSelectorQuery();
|
||
query.select(`#cabin-${cabin.key}`).boundingClientRect((rect) => {
|
||
if (!rect) {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
// 计算漏出区域百分比
|
||
const visibleTop = Math.max(0, rect.top);
|
||
const visibleBottom = Math.min(screenHeight.value, rect.bottom);
|
||
const visibleLeft = Math.max(0, rect.left);
|
||
const visibleRight = Math.min(screenWidth.value, rect.right);
|
||
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
|
||
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
|
||
const visibleArea = visibleWidth * visibleHeight;
|
||
const totalArea = rect.width * rect.height;
|
||
const visiblePercent = totalArea > 0 ? visibleArea / totalArea : 0;
|
||
const inViewport = visiblePercent > 0.7;
|
||
resolve(inViewport);
|
||
}).exec();
|
||
});
|
||
};
|
||
|
||
// 随机轮换展位提示框可见性
|
||
const rotateDialogVisibility = async () => {
|
||
const cabins = visibleCabins.value;
|
||
if (!cabins.length) {
|
||
return;
|
||
}
|
||
|
||
// 先全部重置为 false
|
||
cabins.forEach(c => c.showDialog = false);
|
||
|
||
// 筛选符合条件的 cabin:有昵称、有展位剩余
|
||
const hasData = cabins.filter(c => c.nickname && c.sharedBoothSlotsRemaining !== null);
|
||
|
||
// 批量检查所有 cabin 是否在可视区域内
|
||
const viewportChecks = await Promise.all(hasData.map(c => isCabinInViewport(c)));
|
||
const eligible = hasData.filter((c, i) => viewportChecks[i]);
|
||
|
||
if (eligible.length === 0) return;
|
||
|
||
// 随机选择 2-3 个
|
||
const count = Math.min(Math.floor(Math.random() * 2) + 2, eligible.length, 3);
|
||
const shuffled = eligible.sort(() => Math.random() - 0.5);
|
||
for (let i = 0; i < count; i++) {
|
||
shuffled[i].showDialog = true;
|
||
}
|
||
};
|
||
|
||
// 启动定时器(2-3秒随机间隔)
|
||
const startDialogRotation = () => {
|
||
stopDialogRotation();
|
||
const interval = Math.floor(Math.random() * 1000) + 2000; // 2000-3000ms
|
||
dialogRotationTimer = setTimeout(() => {
|
||
rotateDialogVisibility();
|
||
startDialogRotation(); // 继续下一轮
|
||
}, interval);
|
||
};
|
||
|
||
const stopDialogRotation = () => {
|
||
if (dialogRotationTimer) {
|
||
clearTimeout(dialogRotationTimer);
|
||
dialogRotationTimer = null;
|
||
}
|
||
};
|
||
|
||
// visibleCabins 更新时重启轮换
|
||
watch(visibleCabins, () => {
|
||
console.log('[Square] visibleCabins watch triggered, cabins:', visibleCabins.value.length);
|
||
if (visibleCabins.value.some(c => c.nickname && c.sharedBoothSlotsRemaining !== null)) {
|
||
console.log('[Square] starting dialog rotation');
|
||
startDialogRotation();
|
||
} else {
|
||
stopDialogRotation();
|
||
}
|
||
}, { immediate: true });
|
||
|
||
// 翻页动画:direction = -1 向左,1 向右,精确移动 1 个 tileWidth
|
||
const scrollPage = (direction) => {
|
||
// 立即更新 currentPage,触发数据加载,不等动画结束
|
||
const newPage = wrapPage(currentPage.value + direction);
|
||
currentPage.value = newPage;
|
||
ensurePages(newPage);
|
||
|
||
stopInertia();
|
||
const DURATION = 300;
|
||
const FRAME = 16;
|
||
const totalFrames = Math.round(DURATION / FRAME);
|
||
// 精确移动 1 个 tile 宽度,确保视觉上跨越一个 tile
|
||
const totalDelta = tileWidth.value * direction * -1;
|
||
let frame = 0;
|
||
|
||
const step = () => {
|
||
frame++;
|
||
const progress = frame / totalFrames;
|
||
const eased = 1 - Math.pow(1 - progress, 3);
|
||
const prevEased =
|
||
frame === 1 ? 0 : 1 - Math.pow(1 - (frame - 1) / totalFrames, 3);
|
||
const delta = totalDelta * (eased - prevEased);
|
||
// 箭头翻页使用 clampOffset,不触发 normalizeOffset 里的 tile 边界检测
|
||
bgOffsetX.value = clampOffset(bgOffsetX.value + delta);
|
||
rawOffsetX += delta;
|
||
if (frame < totalFrames) {
|
||
inertiaRaf = rafFn(step);
|
||
} else { }
|
||
};
|
||
|
||
inertiaRaf = rafFn(step);
|
||
};
|
||
|
||
const stopInertia = () => {
|
||
if (inertiaRaf) {
|
||
cafFn(inertiaRaf);
|
||
inertiaRaf = null;
|
||
}
|
||
};
|
||
|
||
// 重置广场到初始状态(位置、数据全部清空并重新加载第 1 页)
|
||
const resetSquare = async () => {
|
||
stopInertia();
|
||
bgOffsetX.value = 0;
|
||
rawOffsetX = 0;
|
||
velocity = 0;
|
||
currentPage.value = 1;
|
||
totalUsers.value = 0;
|
||
pageCache.value = new Map();
|
||
pageCacheVersion.value++;
|
||
await fetchPage(1);
|
||
ensurePages(1);
|
||
};
|
||
|
||
onMounted(async () => {
|
||
const info = uni.getSystemInfoSync();
|
||
screenWidth.value = info.windowWidth;
|
||
screenHeight.value = info.windowHeight;
|
||
tileWidth.value = Math.round(info.windowHeight * (IMAGE_W / IMAGE_H));
|
||
|
||
const baseH = Math.round(info.windowWidth * 0.2 * 1.3);
|
||
cabinRenderDefs = CABIN_DEFS.map(({
|
||
imgW,
|
||
imgH,
|
||
anchorX,
|
||
anchorY
|
||
}) => {
|
||
const renderedH = baseH;
|
||
const renderedW = Math.round(renderedH * (imgW / imgH));
|
||
return {
|
||
renderedW,
|
||
renderedH,
|
||
offsetX: Math.round(renderedW * (anchorX / imgW)),
|
||
offsetY: Math.round(renderedH * (anchorY / imgH)),
|
||
};
|
||
});
|
||
|
||
const scale = info.windowHeight / IMAGE_H;
|
||
scaledCoords.value = CABIN_COORDS.map(([x, y]) => ({
|
||
sx: x * scale,
|
||
sy: y * scale,
|
||
}));
|
||
await fetchPage(1);
|
||
ensurePages(1);
|
||
loadBannerActivities();
|
||
});
|
||
|
||
// 从其他路由页面返回时重置
|
||
onShow(() => {
|
||
resetSquare();
|
||
});
|
||
|
||
// 页面加载完成后初始化引导
|
||
const store = useStore();
|
||
const currentUserNickname = computed(() => store.state.user?.userInfo?.nickname || '');
|
||
|
||
// 监听引导打开组件事件
|
||
uni.$on('guide:openComponent', (componentName) => {
|
||
if (componentName === 'RankingModal') {
|
||
showRankingModal.value = true;
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
uni.$off('guide:openComponent');
|
||
stopDialogRotation();
|
||
});
|
||
|
||
onShow(() => {
|
||
// 检查是否需要显示引导开始弹窗
|
||
if (shouldShowGuideStartModal()) {
|
||
showGuideStartModal.value = true;
|
||
console.log("[Square] 显示引导开始弹窗");
|
||
}
|
||
// 不再自动触发引导,由用户从引导列表选择后触发
|
||
});
|
||
|
||
// 处理引导开始
|
||
const handleGuideStart = () => {
|
||
showGuideStartModal.value = false;
|
||
};
|
||
|
||
// 关闭引导开始弹窗
|
||
const handleGuideStartModalClose = () => {
|
||
showGuideStartModal.value = false;
|
||
};
|
||
|
||
// 领取奖励成功
|
||
const handleClaimSuccess = (reward) => {
|
||
// 可以在这里刷新用户信息
|
||
console.log("[Square] 领取奖励成功:", reward);
|
||
};
|
||
|
||
// banner 区域底部边界(px),触摸起点在此范围内时跳过背景滑动处理
|
||
const getBannerBottom = () => (screenWidth.value / 750) * 496;
|
||
let touchInBanner = false;
|
||
|
||
const onBgTouchStart = (e) => {
|
||
const touchY = e.touches[0].clientY;
|
||
touchInBanner = touchY < getBannerBottom();
|
||
if (touchInBanner) return;
|
||
stopInertia();
|
||
isInertiaPhase = false;
|
||
touchStartX = e.touches[0].clientX;
|
||
lastMoveX = touchStartX;
|
||
lastMoveTime = Date.now();
|
||
velocity = 0;
|
||
};
|
||
|
||
const onBgTouchMove = (e) => {
|
||
// 如果触摸在 banner 区域也不处理,让 banner 自己处理
|
||
if (touchInBanner) return;
|
||
// 不在 banner 区域时,阻止默认行为以实现横向背景滑动
|
||
e.preventDefault();
|
||
|
||
const currentX = e.touches[0].clientX;
|
||
const now = Date.now();
|
||
const dt = now - lastMoveTime || 1;
|
||
velocity = (currentX - lastMoveX) / dt;
|
||
lastMoveX = currentX;
|
||
lastMoveTime = now;
|
||
bgOffsetX.value = normalizeOffset(bgOffsetX.value + (currentX - touchStartX));
|
||
touchStartX = currentX;
|
||
};
|
||
|
||
const onBgTouchEnd = () => {
|
||
if (touchInBanner) {
|
||
touchInBanner = false;
|
||
return;
|
||
}
|
||
touchInBanner = false;
|
||
isInertiaPhase = true;
|
||
const FRICTION = 0.8; // 惯性滚动的速度衰减系数
|
||
const MIN_VELOCITY = 0.2; // 惯性滚动速度小于此值时停止滚动
|
||
|
||
const step = () => {
|
||
velocity *= FRICTION;
|
||
if (Math.abs(velocity) < MIN_VELOCITY) {
|
||
isInertiaPhase = false;
|
||
return;
|
||
}
|
||
bgOffsetX.value = normalizeOffset(bgOffsetX.value + velocity * 16);
|
||
inertiaRaf = rafFn(step);
|
||
};
|
||
|
||
inertiaRaf = rafFn(step);
|
||
};
|
||
|
||
const onBgTouchCancel = () => {
|
||
touchInBanner = false;
|
||
stopInertia();
|
||
isInertiaPhase = false;
|
||
velocity = 0;
|
||
};
|
||
|
||
// --- 小屋点击跳转 ---
|
||
const handleCabinClick = (cabin) => {
|
||
// 惯性速度残留时说明是滑动结束,不是真实点击
|
||
if (Math.abs(velocity) > 0.5) return;
|
||
// 无用户、或被 banner 遮住(showNickname=false)的小屋不响应点击
|
||
if (!cabin.userId || !cabin.showNickname) return;
|
||
// 如果是自己的小屋,不需要 target_uid 参数
|
||
uni.navigateTo({
|
||
url: cabin.isMine ? '/pages/exhibition/exhibition' : `/pages/exhibition/exhibition?target_uid=${cabin.userId}`,
|
||
});
|
||
};
|
||
|
||
// --- Tab 切换 ---
|
||
const handleTabChange = (newTab) => {
|
||
if (newTab === 0) {
|
||
// 已经在广场页面,不需要跳转
|
||
navExpanded.value = false;
|
||
return;
|
||
}
|
||
|
||
const routes = [
|
||
'/pages/square/square',
|
||
'/pages/starbook/index',
|
||
'/pages/castlove/mall',
|
||
'/pages/starcity/index',
|
||
'/pages/friends/index'
|
||
];
|
||
|
||
if (newTab >= 0 && newTab < routes.length) {
|
||
uni.redirectTo({
|
||
url: routes[newTab]
|
||
});
|
||
}
|
||
};
|
||
|
||
onLoad((options) => {
|
||
// 调试模式:读取 guide_debug 参数并设置存储
|
||
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] 调试模式已关闭')
|
||
}
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.square-container {
|
||
position: relative;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
min-height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 3 张图并排,translateX 由 JS 控制(含初始 -tileWidth 偏移) */
|
||
.background-strip {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
height: 150%;
|
||
display: flex;
|
||
z-index: 0;
|
||
will-change: transform;
|
||
}
|
||
|
||
.background-tile {
|
||
flex-shrink: 0;
|
||
height: 100%;
|
||
}
|
||
|
||
.cabin-layer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.cabin-wrapper {
|
||
position: absolute;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.cabin-icon {
|
||
display: block;
|
||
}
|
||
|
||
.cabin-slots-zero .cabin-icon {
|
||
filter: grayscale(100%);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.cabin-nickname {
|
||
font-size: 20rpx;
|
||
color: #ffffff;
|
||
text-align: center;
|
||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.6);
|
||
margin-top: 4rpx;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.cabin-nickname--empty {
|
||
color: rgba(255, 255, 255, 0.75);
|
||
font-style: italic;
|
||
}
|
||
|
||
.cabin-slots-dialog {
|
||
position: absolute;
|
||
top: -50rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background-image: url('/static/icon/tips-bg.png');
|
||
background-size: 100% 100%;
|
||
background-repeat: no-repeat;
|
||
padding: 8rpx 24rpx 18rpx;
|
||
width: max-content;
|
||
z-index: 10;
|
||
}
|
||
|
||
.cabin-slots-text {
|
||
font-size: 18rpx;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
text-align: center;
|
||
}
|
||
|
||
.text-white {
|
||
color: #ffffff;
|
||
}
|
||
|
||
.text-orange {
|
||
color: #FFB800;
|
||
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
|
||
text-shadow:
|
||
0 0 10rpx rgba(255, 184, 0, 0.8),
|
||
0 2rpx 4rpx rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.nav-arrows {
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 24rpx;
|
||
right: 24rpx;
|
||
transform: translateY(-50%);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
pointer-events: none;
|
||
z-index: 10;
|
||
}
|
||
|
||
.arrow-btn {
|
||
width: 56rpx;
|
||
height: 96rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(165deg,
|
||
#f0e4b1 0%,
|
||
#f08399 50%,
|
||
#b94e73 90%,
|
||
#834b9e 100%);
|
||
border-radius: 12rpx;
|
||
pointer-events: auto;
|
||
cursor: pointer;
|
||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
.arrow-left {
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
.arrow-right {
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
.arrow-btn:active {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.arrow-text {
|
||
font-size: 56rpx;
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
margin-top: -6rpx;
|
||
}
|
||
|
||
/* 轮播图 */
|
||
.banner-carousel {
|
||
position: fixed;
|
||
top: 216rpx;
|
||
left: 0;
|
||
right: 0;
|
||
width: 100%;
|
||
z-index: 100;
|
||
padding: 0 8rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.banner-swiper {
|
||
width: 100%;
|
||
height: 312rpx;
|
||
border-radius: 24rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 防止 swiper-item 内容溢出露边 */
|
||
:deep(.uni-swiper-slide) {
|
||
overflow: hidden;
|
||
}
|
||
|
||
.banner-activity-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
|
||
.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> |