topfans/frontend/pages/square/square.vue
2026-04-07 23:08:49 +08:00

1203 lines
33 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>
<!-- 触摸事件绑定在最外层确保 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" v-show="activeTab === 0">
<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>
<!-- 翻页箭头按钮(仅广场 Tab 显示) -->
<view v-show="activeTab === 0" 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 :showBack="activeTab !== 0" :showGuideIcon="activeTab === 0" :showTaskIcon="activeTab === 0" :showStarActivityIcon="activeTab === 0" backIconColor="#e6e6e6" />
<!-- 轮播图 + 应援活动列表(仅广场 Tab 显示swiper 切换) -->
<view v-show="activeTab === 0" 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>
<!-- 页面内容组件 - 使用 v-show 切换 -->
<StarbookContent v-show="activeTab === 1" :isActive="activeTab === 1" />
<CastloveContent v-show="activeTab === 2" @back="handleCastloveBack" />
<StarCityContent v-show="activeTab === 3" />
<FriendsContent v-show="activeTab === 4" />
<!-- 蒙层 - 导航栏展开时显示 -->
<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="activeTab" :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 CastloveContent from "../components/CastloveContent.vue";
import StarbookContent from "../components/StarbookContent.vue";
import StarCityContent from "../components/StarCityContent.vue";
import FriendsContent from "../components/FriendsContent.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使中间那张居中显示。
// 滑动时只改变 bgOffsetXCSS 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 内容变化时递增,用于驱动 watchshallowRef 的 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;
};
// 缩放后的坐标refonMounted 赋值后触发 visibleCabins 重新计算)
const scaledCoords = ref([]);
// 每种 cabin 渲染后的宽高和锚点偏移pxonMounted 中计算
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(() => {
if (activeTab.value === 0) 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) => {
if (activeTab.value !== 0) return;
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) => {
// 非 tab 0 时不阻止,让 scroll-view 正常滚动
if (activeTab.value !== 0) return;
// tab 0 时如果触摸在 banner 区域也不处理,让 banner 自己处理
if (touchInBanner) return;
// tab 0 且不在 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 (activeTab.value !== 0 || 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;
uni.navigateTo({
url: `/pages/exhibition/exhibition?target_uid=${cabin.userId}`,
});
};
// --- Tab 切换 ---
const handleTabChange = (newTab) => {
const prevTab = activeTab.value;
activeTab.value = newTab;
navExpanded.value = false;
if (prevTab !== 0 && newTab === 0) resetSquare();
};
const handleCastloveBack = () => {
handleTabChange(0);
};
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] 调试模式已关闭')
}
}
if (options && options.tab) {
const tabIndex = parseInt(options.tab);
if (!isNaN(tabIndex) && tabIndex >= 0 && tabIndex <= 4) {
activeTab.value = tabIndex;
}
}
});
</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>