topfans/frontend/pages/exhibition/exhibition.vue
2026-04-17 17:17:32 +08:00

1794 lines
49 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="exhibition-container">
<!-- 背景图片 -->
<image class="background-image" src="/static/background/ExhibitionBackground.png" mode="aspectFill"></image>
<!-- Header组件 -->
<Header :showBack="true" :showGuideIcon="false" :showTaskIcon="false" :showStarActivityIcon="false" backIconColor="#e6e6e6" />
<!-- 展馆标题 -->
<view class="gallery-owner-title">
<text class="gallery-owner-title-text">
{{ isViewingOthers ? (galleryOwnerNickname ? galleryOwnerNickname + '的展馆' : 'TA的展馆') : '我的展馆' }}
</text>
</view>
<!-- 展览板容器 -->
<view class="exhibition-boards" :style="{ paddingTop: topPadding + 'rpx' }">
<!-- 展览板1 -->
<view class="exhibition-board">
<image class="board-image" src="/static/components/frame1.png" mode="widthFix"></image>
<view class="nft-cards-container">
<template v-for="index in 3" :key="`board1-${index}`">
<!-- 获取对应位置的藏品 -->
<template v-if="friendExhibitions[index - 1] && friendExhibitions[index - 1].image">
<!-- 有藏品:显示藏品卡片 -->
<NftCard
:cover-image="friendExhibitions[index - 1].image"
:width="getNftCardSize()"
:height="getNftCardSize()"
:custom-style="getNftCardStyle(index, 1)"
:visibility="friendExhibitions[index - 1].visibility || 'public'"
:operation="friendExhibitions[index - 1].operation || 'none'"
@click="handleNftClick(friendExhibitions[index - 1])"
@longpress="handleLongPress($event, friendExhibitions[index - 1])"
/>
<!-- 倒计时背景 -->
<view class="countdown-background" :style="getCountdownBackgroundStyle(index, 1)"></view>
<!-- 倒计时文字 -->
<text class="countdown-text" :style="getCountdownStyle(index, 1)">
{{ formatCountdown(friendExhibitions[index - 1].id) }}
</text>
<!-- 点赞数(左下角) -->
<view class="like-badge" :style="getLikeBadgeStyle(index, 1)">
<image class="like-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
<view class="like-count-background">
<text class="like-count-text">{{ formatLikeCount(friendExhibitions[index - 1].like_count) }}</text>
</view>
</view>
</template>
<template v-else>
<!-- 无藏品根据can_operate判断是否显示添加按钮 -->
<NftCard
:cover-image="''"
:width="getNftCardSize()"
:height="getNftCardSize()"
:custom-style="getNftCardStyle(index, 1)"
:show-add-button="getSlotCanOperate(1, index).operation === 'place'"
:visibility="'public'"
:operation="getSlotCanOperate(1, index).operation"
@add="handleAddAssetClick(index, 1)"
/>
</template>
</template>
</view>
<text class="board-label">共享展位</text>
</view>
<!-- 展览板2 -->
<view class="exhibition-board nft-cards-container-2">
<image class="board-image" src="/static/components/frame2.png" mode="widthFix"></image>
<view class="nft-cards-container">
<template v-for="index in 3" :key="`board2-${index}`">
<!-- 获取对应位置的藏品 -->
<template v-if="myExhibitions[index - 1]">
<!-- 有藏品:显示藏品卡片 -->
<NftCard
:cover-image="myExhibitions[index - 1].image"
:width="getNftCardSize()"
:height="getNftCardSize()"
:custom-style="getNftCardStyle(index, 2)"
:visibility="myExhibitions[index - 1].visibility || 'private'"
:operation="myExhibitions[index - 1].operation || 'none'"
@click="handleNftClick(myExhibitions[index - 1])"
@longpress="handleLongPress($event, myExhibitions[index - 1])"
/>
<!-- 倒计时背景 -->
<view class="countdown-background" :style="getCountdownBackgroundStyle(index, 2)"></view>
<!-- 倒计时文字 -->
<text class="countdown-text" :style="getCountdownStyle(index, 2)">
{{ formatCountdown(myExhibitions[index - 1].id) }}
</text>
<!-- 点赞数(左下角) -->
<view class="like-badge" :style="getLikeBadgeStyle(index, 2)">
<image class="like-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
<view class="like-count-background">
<text class="like-count-text">{{ formatLikeCount(myExhibitions[index - 1].like_count) }}</text>
</view>
</view>
</template>
<template v-else>
<!-- 无藏品根据can_operate判断是否显示添加按钮 -->
<NftCard
:cover-image="''"
:width="getNftCardSize()"
:height="getNftCardSize()"
:custom-style="getNftCardStyle(index, 2)"
:show-add-button="getSlotCanOperate(2, index).operation === 'place'"
:visibility="'private'"
:operation="getSlotCanOperate(2, index).operation"
@add="handleAddAssetClick(index, 2)"
/>
</template>
</template>
</view>
<text class="board-label board-label-right">{{ isMyGallery ? '我的展位' : 'TA的展位' }}</text>
</view>
</view>
<!-- 底部按钮:动态显示 -->
<button class="castlove-btn" :style="{ bottom: bottomPadding + 'rpx' }" @click="handleBottomButtonClick">
{{ isMyGallery ? '铸爱' : '返回我的展馆' }}
</button>
<!-- 随机展馆左右箭头 -->
<view class="random-gallery-arrows">
<view class="arrow-btn arrow-left" @click="loadRandomGallery">
<text class="arrow-text"></text>
</view>
<view class="arrow-btn arrow-right" @click="loadRandomGallery">
<text class="arrow-text"></text>
</view>
</view>
<!-- 左下角角色图片 -->
<image class="character-image" src="/static/icon/exhibition-char.png" mode="widthFix"></image>
<!-- 藏品选择弹窗 -->
<view v-if="showAssetSelectModal" class="asset-select-modal-mask" @tap="closeAssetSelectModal">
<view
class="asset-select-modal"
@tap.stop
:class="{ 'show': assetSelectModalAnimated }"
>
<!-- 背景图片 -->
<image class="modal-background-image" src="/static/background/starbook.jpg" mode="aspectFill"></image>
<!-- 内容包装器 -->
<view class="modal-content-wrapper">
<!-- 顶部拖动区域 -->
<view
class="modal-top-drag-area"
@touchstart="handleModalTouchStart"
@touchmove="handleModalTouchMove"
@touchend="handleModalTouchEnd"
>
<!-- 顶部拖动条 -->
<view class="modal-handle"></view>
<!-- 标题 -->
<text class="modal-title">选择要展出的藏品</text>
</view>
<!-- 藏品网格 -->
<view class="modal-nft-grid-container">
<view
v-for="(item, index) in myAssetsList"
:key="item.asset_id || index"
class="modal-nft-grid-item"
>
<NftCard
:cover-image="item.image || '/static/nft/collection.png'"
:width="cardSize"
:height="cardSize"
:custom-style="cardCustomStyle"
@click="handleAssetSelect(item)"
/>
</view>
</view>
</view>
</view>
</view>
<!-- 上架确认弹窗 -->
<view v-if="showPlaceConfirmModal" class="place-confirm-modal-mask" @tap="closePlaceConfirmModal">
<view class="place-confirm-modal" @tap.stop>
<text class="confirm-title">{{ isReplaceMode ? '确认替换藏品?' : '确认展出藏品?' }}</text>
<view class="confirm-content">
<view class="confirm-row">
<text class="confirm-label">藏品ID:</text>
<text class="confirm-value">{{ selectedAssetForPlace?.asset_id }}</text>
</view>
<view class="confirm-row">
<text class="confirm-label">藏品名称:</text>
<text class="confirm-value">{{ selectedAssetForPlace?.name || '未知' }}</text>
</view>
<view class="confirm-row">
<text class="confirm-label">展出位置:</text>
<text class="confirm-value">slotID: {{ selectedSlotId }}</text>
</view>
</view>
<view class="confirm-buttons">
<button class="confirm-btn cancel-btn" @tap="closePlaceConfirmModal">取消</button>
<button class="confirm-btn submit-btn" @tap="confirmPlaceAsset">确认</button>
</view>
</view>
</view>
<!-- 下架确认弹窗 -->
<view v-if="showRemoveConfirmModal" class="place-confirm-modal-mask" @tap="closeRemoveConfirmModal">
<view class="place-confirm-modal" @tap.stop>
<text class="confirm-title">确认下架藏品?</text>
<view class="confirm-content">
<view class="confirm-row">
<text class="confirm-label">藏品ID:</text>
<text class="confirm-value">{{ selectedAssetForRemove?.asset_id }}</text>
</view>
<view class="confirm-row">
<text class="confirm-label">藏品名称:</text>
<text class="confirm-value">{{ selectedAssetForRemove?.name || '未知' }}</text>
</view>
<view class="confirm-row">
<text class="confirm-label">展出位置:</text>
<text class="confirm-value">slotID: {{ selectedAssetForRemove?.slot_id }}</text>
</view>
</view>
<view class="confirm-buttons">
<button class="confirm-btn cancel-btn" @tap="closeRemoveConfirmModal">取消</button>
<button class="confirm-btn submit-btn" @tap="confirmRemoveAsset">确认下架</button>
</view>
</view>
</view>
<!-- 全局引导遮罩 -->
<GuideOverlay />
</view>
</template>
<script>
export default {
onBackPress(options) {
// 获取当前页面实例
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
// 如果藏品选择弹窗打开,关闭弹窗而不是返回上一页
if (currentPage.$vm && currentPage.$vm.showAssetSelectModal && currentPage.$vm.showAssetSelectModal.value) {
currentPage.$vm.closeAssetSelectModal();
return true; // 返回 true 表示已处理,阻止默认返回行为
}
return false; // 返回 false 表示未处理,执行默认返回行为
}
};
</script>
<script setup>
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { useStore } from 'vuex';
import Header from '../components/Header.vue';
import NftCard from '../components/NftCard.vue';
import { getMyGalleriesApi, placeAssetToGalleryApi, getMyAssetsApi, removeAssetFromGalleryApi, getUserGalleriesApi, getRandomGalleryApi } from '@/utils/api.js';
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
import { reportEvent } from '@/utils/task-api.js';
import GuideOverlay from "@/components/GuideOverlay.vue";
const store = useStore();
// 屏幕宽度和高度
const screenWidth = ref(0);
const screenHeight = ref(0);
// 顶部距离屏幕高度的14%
const topPadding = ref(0);
// 底部距离屏幕高度的5%
const bottomPadding = ref(0);
// 共享展位藏品数据展览板1
const friendExhibitions = ref([
{
id: 'friend_1',
asset_id: 'NFT001',
cover_url: '/static/nft/collection.png',
owner_uid: '10001',
owner_nickname: '好友昵称',
name: '友谊之光',
tx_hash: '0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890',
block_number: '12345678',
like_count: 128,
is_liked: false,
startTime: Date.now(), // 展出开始时间戳
duration: 8 * 60 * 60 * 1000 // 8小时毫秒
}
]);
// 我的展位藏品数据展览板2
const myExhibitions = ref([
{
}
]);
// 倒计时状态
const countdowns = ref({});
// 展馆相关状态
const galleryOwnerId = ref(null);
const gallerySlots = ref([]);
// 获取指定槽位的can_operate信息用于空槽位
const getSlotCanOperate = (boardType, index) => {
// 按 slot_index 排序后再按位置选择
const publicSlots = gallerySlots.value
.filter(slot => slot.visibility === 'public')
.sort((a, b) => a.slot_index - b.slot_index);
const privateSlots = gallerySlots.value
.filter(slot => slot.visibility === 'private')
.sort((a, b) => a.slot_index - b.slot_index);
const slots = boardType === 1 ? publicSlots : privateSlots;
const slot = slots[index - 1];
if (!slot) return { can_operate: false, operation: 'none' };
return {
can_operate: slot.can_operate || false,
operation: slot.operation || 'none'
};
};
const showAssetSelectModal = ref(false);
const assetSelectModalAnimated = ref(false); // 控制弹窗动画
const selectedSlotId = ref(null);
const selectedSlotIndex = ref(null);
const showPlaceConfirmModal = ref(false);
const selectedAssetForPlace = ref(null);
const myAssetsList = ref([]);
// 下架藏品相关状态
const showRemoveConfirmModal = ref(false);
const selectedAssetForRemove = ref(null);
// 替换藏品相关状态
const isReplaceMode = ref(false);
// 访问其他用户展馆相关状态
// 当前访问的展馆所有者UID
const visitingGalleryOwnerUid = ref(null);
// 展馆主人昵称(仅访问他人展馆时有值)
const galleryOwnerNickname = ref('');
// 是否是通过 target_uid 进入他人展馆onLoad 时确定,不随 parseGalleryResponse 变化)
const isViewingOthers = ref(false);
// 当前用户UID
const currentUserUid = ref(null);
// 是否是访问自己的展馆
const isMyGallery = computed(() => {
if (!currentUserUid.value || !visitingGalleryOwnerUid.value) {
return true; // 默认为true避免闪烁
}
return currentUserUid.value === visitingGalleryOwnerUid.value;
});
// 弹窗滑动关闭相关状态
const modalTouchStartY = ref(0);
const modalTouchStartTime = ref(0);
const modalScrollTop = ref(0);
// 获取NFT卡片尺寸
const getNftCardSize = () => {
// 根据屏幕宽度计算,放大槽位尺寸
return screenWidth.value * 0.33;
};
// 计算藏品选择弹窗中的卡片尺寸(与星册页面保持一致)
const cardSize = computed(() => {
if (screenWidth.value === 0) return 200;
const rpxToPx = screenWidth.value / 750;
const padding = 40 * rpxToPx;
const gap = 15 * rpxToPx;
const availableWidth = screenWidth.value - (padding * 2) - (gap * 2);
return Math.floor(availableWidth / 3);
});
// 卡片自定义样式(与星册页面保持一致)
const cardCustomStyle = {
position: 'absolute',
top: '0',
left: '0'
};
// 计算顶部和底部距离
const calculateTopPadding = () => {
if (screenHeight.value === 0 || screenWidth.value === 0) return;
// rpx转换比例750rpx = 屏幕宽度
const pxToRpx = 750 / screenWidth.value;
topPadding.value = screenHeight.value * 0.14 * pxToRpx;
bottomPadding.value = screenHeight.value * 0.05 * pxToRpx;
};
// 获取NFT卡片样式位置
const getNftCardStyle = (index, boardNumber) => {
const cardSize = getNftCardSize();
const containerWidth = screenWidth.value;
// 计算三个卡片的位置,水平均匀分布
// 第一个卡片在左侧,第二个在中间,第三个在右侧
let leftPercent = 0;
if (index === 1) {
leftPercent = 25; // 左侧
} else if (index === 2) {
leftPercent = 50; // 中间
} else {
leftPercent = 75; // 右侧
}
// 垂直位置,根据展览板调整
let topPercent = 50; // 默认居中
if (boardNumber === 1) {
topPercent = 42; // 第一个展览板下移
} else {
topPercent = 54; // 第二个展览板下移
}
return {
position: 'absolute',
left: `${leftPercent}%`,
top: `${topPercent}%`,
transform: 'translate(-50%, -50%)'
};
};
// 过滤已过期的藏品
const filterExpiredExhibitions = async () => {
let hasExpired = false;
// 过滤展览板1共享展位/公开展位)
// 关键:使用 map 而不是 filter保持数组长度和槽位对应关系
friendExhibitions.value = friendExhibitions.value.map(item => {
if (!item) return null;
// 如果有 remainSeconds 字段,检查是否过期
if (item.remainSeconds !== undefined) {
const isExpired = item.remainSeconds <= 0;
if (isExpired) hasExpired = true;
return isExpired ? null : item;
}
// 兼容旧的时间戳方式
if (item.startTime && item.duration) {
const now = Date.now();
const isExpired = now - item.startTime >= item.duration;
if (isExpired) hasExpired = true;
return isExpired ? null : item;
}
return null;
});
// 过滤展览板2我的展位/私密展位)
// 关键:使用 map 而不是 filter保持数组长度和槽位对应关系
myExhibitions.value = myExhibitions.value.map(item => {
if (!item) return null;
// 如果有 remainSeconds 字段,检查是否过期
if (item.remainSeconds !== undefined) {
const isExpired = item.remainSeconds <= 0;
if (isExpired) hasExpired = true;
return isExpired ? null : item;
}
// 兼容旧的时间戳方式
if (item.startTime && item.duration) {
const now = Date.now();
const isExpired = now - item.startTime >= item.duration;
if (isExpired) hasExpired = true;
return isExpired ? null : item;
}
return null;
});
// 如果有藏品过期,刷新 gallerySlots 数据以更新槽位状态
if (hasExpired) {
await loadGallerySlots();
}
};
// 计算剩余时间
const calculateRemainingTime = (item) => {
// 如果有 remainSeconds 字段,直接使用(单位:秒)
if (item.remainSeconds !== undefined) {
if (item.remainSeconds <= 0) {
return { hours: 0, minutes: 0, seconds: 0, expired: true };
}
const hours = Math.floor(item.remainSeconds / 3600);
const minutes = Math.floor((item.remainSeconds % 3600) / 60);
const seconds = Math.floor(item.remainSeconds % 60);
return { hours, minutes, seconds, expired: false };
}
// 兼容旧的时间戳方式(用于共享展位)
if (item.startTime && item.duration) {
const now = Date.now();
const elapsed = now - item.startTime;
const remaining = item.duration - elapsed;
if (remaining <= 0) {
return { hours: 0, minutes: 0, seconds: 0, expired: true };
}
const hours = Math.floor(remaining / (60 * 60 * 1000));
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
const seconds = Math.floor((remaining % (60 * 1000)) / 1000);
return { hours, minutes, seconds, expired: false };
}
// 默认返回过期状态
return { hours: 0, minutes: 0, seconds: 0, expired: true };
};
// 更新所有倒计时
const updateCountdowns = () => {
// 更新共享展位倒计时,并减少 remainSeconds
[...friendExhibitions.value].forEach(item => {
// 跳过空槽位null
if (item && item.id) {
// 如果有 remainSeconds每秒减1
if (item.remainSeconds !== undefined && item.remainSeconds > 0) {
item.remainSeconds--;
}
countdowns.value[item.id] = calculateRemainingTime(item);
}
});
// 更新我的展位倒计时,并减少 remainSeconds
[...myExhibitions.value].forEach(item => {
if (item && item.id) {
// 如果有 remainSeconds每秒减1
if (item.remainSeconds !== undefined && item.remainSeconds > 0) {
item.remainSeconds--;
}
countdowns.value[item.id] = calculateRemainingTime(item);
}
});
// 移除过期藏品
filterExpiredExhibitions();
};
// 获取倒计时样式(位于卡片左上角)
const getCountdownStyle = (index, boardNumber) => {
const cardStyle = getNftCardStyle(index, boardNumber);
const cardSize = getNftCardSize();
const backgroundWidth = cardSize * 0.58;
const backgroundHeight = cardSize * 0.16;
const insetY = cardSize * 0.1;
const rightOffset = cardSize * 0.08;
// 让倒计时左边缘与卡片左边缘严格对齐
const translateX = -cardSize / 2 + backgroundWidth / 2 + rightOffset;
const translateY = -cardSize / 2 + insetY;
return {
position: 'absolute',
left: cardStyle.left,
top: cardStyle.top,
width: `${backgroundWidth}px`,
height: `${backgroundHeight}px`,
transform: `translate(calc(-50% + ${translateX}px), calc(-50% + ${translateY}px))`,
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
};
};
// 获取倒计时背景样式(位于卡片左上角)
const getCountdownBackgroundStyle = (index, boardNumber) => {
const cardStyle = getNftCardStyle(index, boardNumber);
const cardSize = getNftCardSize();
const backgroundWidth = cardSize * 0.58;
const backgroundHeight = cardSize * 0.16;
const insetY = cardSize * 0.1;
const rightOffset = cardSize * 0.08;
// 让倒计时左边缘与卡片左边缘严格对齐
const translateX = -cardSize / 2 + backgroundWidth / 2 + rightOffset;
const translateY = -cardSize / 2 + insetY;
return {
position: 'absolute',
left: cardStyle.left,
top: cardStyle.top,
width: `${backgroundWidth}px`,
height: `${backgroundHeight}px`,
transform: `translate(calc(-50% + ${translateX}px), calc(-50% + ${translateY}px))`,
pointerEvents: 'none'
};
};
// 格式化倒计时显示
const formatCountdown = (itemId) => {
const countdown = countdowns.value[itemId];
if (!countdown || countdown.expired) return '00:00:00';
const h = String(countdown.hours).padStart(2, '0');
const m = String(countdown.minutes).padStart(2, '0');
const s = String(countdown.seconds).padStart(2, '0');
return `${h}:${m}:${s}`;
};
// 获取点赞徽标样式(位于卡片左下角)
const getLikeBadgeStyle = (index, boardNumber) => {
const cardStyle = getNftCardStyle(index, boardNumber);
const cardSize = getNftCardSize();
const iconSize = cardSize * 0.23;
const insetX = iconSize * 1.55;
const insetY = iconSize * 0.55;
const translateX = -cardSize / 2 + insetX;
const translateY = cardSize / 2 - insetY;
return {
position: 'absolute',
left: cardStyle.left,
top: cardStyle.top,
transform: `translate(calc(-50% + ${translateX}px), calc(-50% + ${translateY}px))`,
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
gap: `${Math.max(4, cardSize * 0.02)}px`
};
};
// 格式化点赞数显示
const formatLikeCount = (likeCount) => {
const numericLikeCount = Number(likeCount);
if (!Number.isFinite(numericLikeCount) || numericLikeCount < 0) {
return '0';
}
return `${Math.floor(numericLikeCount)}`;
};
// 点击藏品卡片,跳转到详情页
const handleNftClick = (nft) => {
if (!nft || !nft.asset_id) return;
uni.navigateTo({
url: `/pages/asset-detail/asset-detail?asset_id=${nft.asset_id}`
});
};
// 跳转到铸爱页面
const goToCastlove = () => {
uni.navigateTo({
url: '/pages/castlove/mall'
});
};
// 处理底部按钮点击
const handleBottomButtonClick = async () => {
if (isMyGallery.value) {
// 访问自己的展馆,跳转到铸爱页面
goToCastlove();
} else {
// 访问他人展馆,返回自己的展馆
// 先确保 currentUserUid 有值
if (!currentUserUid.value) {
const userStr = uni.getStorageSync('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
currentUserUid.value = user.uid;
} catch (e) {
console.error('解析用户信息失败:', e);
}
}
}
// 先清空状态,确保 UI 先更新
isViewingOthers.value = false;
visitingGalleryOwnerUid.value = currentUserUid.value;
galleryOwnerNickname.value = '';
// 强制更新 DOM
await new Promise(resolve => setTimeout(resolve, 0));
// 再加载数据
loadGallerySlots();
}
};
// 加载展馆槽位信息
// 将 API 响应解析并填充到展馆数据中(供 loadGallerySlots 和 loadRandomGallery 共用)
const parseGalleryResponse = async (data) => {
console.log('parseGalleryResponse called', {
isViewingOthers: isViewingOthers.value,
dataGalleryOwnerId: data.gallery_owner_id,
currentUserUid: currentUserUid.value
});
// 只更新展馆所有者信息,不要修改 isViewingOthers
// isViewingOthers 只在 onLoad 时设置,避免状态混乱
visitingGalleryOwnerUid.value = data.gallery_owner_id;
galleryOwnerNickname.value = data.nickname || '';
galleryOwnerId.value = data.gallery_owner_id;
uni.setStorageSync('gallery_owner_id', data.gallery_owner_id);
gallerySlots.value = data.slots || [];
// 按 slot_index 排序,确保展位按顺序处理
const publicSlots = gallerySlots.value
.filter(slot => slot.visibility === 'public')
.sort((a, b) => a.slot_index - b.slot_index);
const privateSlots = gallerySlots.value
.filter(slot => slot.visibility === 'private')
.sort((a, b) => a.slot_index - b.slot_index);
const buildExhibitionItem = async (slot, index) => {
if (index >= 3 || slot.status !== 'OCCUPIED' || !slot.asset) return null;
const realCoverUrl = await getAssetCoverRealUrl(slot.asset.cover_url);
return {
id: `slot_${slot.slot_id}_${slot.asset.asset_id}`,
asset_id: slot.asset.asset_id,
name: slot.asset.name,
image: realCoverUrl,
cover_url: slot.asset.cover_url || '/static/nft/collection.png',
like_count: slot.asset.like_count || 0,
slot_id: slot.slot_id,
slot_index: slot.slot_index,
visibility: slot.visibility,
can_operate: slot.can_operate || false,
operation: slot.operation || 'none',
remainSeconds: slot.asset.remain_time || 0,
occupied_at: slot.occupied_at,
expire_at: slot.expire_at,
occupier_uid: slot.occupier_uid
};
};
friendExhibitions.value = new Array(3).fill(null);
const publicResults = await Promise.all(publicSlots.map(buildExhibitionItem));
publicResults.forEach((item, i) => { if (item) friendExhibitions.value[i] = item; });
myExhibitions.value = new Array(3).fill(null);
const privateResults = await Promise.all(privateSlots.map(buildExhibitionItem));
privateResults.forEach((item, i) => { if (item) myExhibitions.value[i] = item; });
};
const loadGallerySlots = async () => {
console.log('loadGallerySlots called', {
isViewingOthers: isViewingOthers.value,
visitingGalleryOwnerUid: visitingGalleryOwnerUid.value,
currentUserUid: currentUserUid.value
});
try {
let response;
// 根据 visitingGalleryOwnerUid 是否等于 currentUserUid 来判断是否调用自己的展馆 API
// 这样可以避免 isViewingOthers 状态错误导致的 API 调用错误
if (visitingGalleryOwnerUid.value === currentUserUid.value) {
response = await getMyGalleriesApi();
} else {
response = await getUserGalleriesApi(visitingGalleryOwnerUid.value);
}
if (response.code === 200 && response.data) {
await parseGalleryResponse(response.data);
}
} catch (error) {
console.error('获取展馆槽位信息失败:', error);
uni.showToast({
title: error.message || '获取展馆信息失败',
icon: 'none',
duration: 2000
});
}
};
// 加载随机用户展馆(点击左右箭头触发)
const loadRandomGallery = async () => {
try {
const response = await getRandomGalleryApi();
console.log(response)
if (response.code === 200 && response.data) {
// 加载随机展馆时,需要设置 isViewingOthers 为 true
isViewingOthers.value = response.data.gallery_owner_id !== currentUserUid.value;
await parseGalleryResponse(response.data);
}
} catch (error) {
console.error('获取随机展馆失败:', error);
uni.showToast({
title: error.message || '获取随机展馆失败',
icon: 'none',
duration: 2000
});
}
};
// 处理添加藏品点击(展览板的加号按钮)
// slotIndex: 页面上的位置索引1-based即1、2、3
// boardNumber: 展览板编号1或2
const handleAddAssetClick = async (slotIndex, boardNumber = 2) => {
// 根据展览板编号确定 visibility
const targetVisibility = boardNumber === 1 ? 'public' : 'private';
// 找到对应 visibility 的槽位,并根据页面位置索引匹配
// 按 slot_index 排序后再按位置选择
const slotsWithTargetVisibility = gallerySlots.value
.filter(s => s.visibility === targetVisibility)
.sort((a, b) => a.slot_index - b.slot_index);
// slotIndex 是页面位置1-based对应数组索引为 slotIndex - 1
const slot = slotsWithTargetVisibility[slotIndex - 1];
if (!slot) {
uni.showToast({
title: '槽位信息不存在',
icon: 'none',
duration: 2000
});
return;
}
selectedSlotId.value = slot.slot_id;
selectedSlotIndex.value = slot.slot_index;
// 获取用户藏品列表
try {
const response = await getMyAssetsApi(1, 20);
if (response.code === 200 && response.data && response.data.items) {
const assetsPromises = response.data.items.map(async item => {
const realCoverUrl = await getAssetCoverRealUrl(item.cover_url);
return {
asset_id: item.asset_id,
name: item.name,
image: realCoverUrl,
cover_url: item.cover_url || '/static/nft/collection.png'
};
});
myAssetsList.value = await Promise.all(assetsPromises);
// 显示藏品选择弹窗
showAssetSelectModal.value = true;
// 延迟触发动画
setTimeout(() => {
assetSelectModalAnimated.value = true;
}, 50);
}
} catch (error) {
console.error('获取藏品列表失败:', error);
uni.showToast({
title: error.message || '获取藏品列表失败',
icon: 'none',
duration: 2000
});
}
};
// 关闭藏品选择弹窗
const closeAssetSelectModal = () => {
// 先触发关闭动画
assetSelectModalAnimated.value = false;
// 等待动画结束后再隐藏弹窗
setTimeout(() => {
showAssetSelectModal.value = false;
myAssetsList.value = [];
// 重置替换模式状态
if (isReplaceMode.value) {
isReplaceMode.value = false;
selectedAssetForRemove.value = null;
}
}, 300);
};
// 处理弹窗触摸开始(仅在顶部拖动区域触发)
const handleModalTouchStart = (e) => {
modalTouchStartY.value = e.touches[0].clientY;
modalTouchStartTime.value = Date.now();
};
// 处理弹窗触摸移动(仅在顶部拖动区域触发)
const handleModalTouchMove = (e) => {
const currentY = e.touches[0].clientY;
const deltaY = currentY - modalTouchStartY.value;
// 向下滑动时阻止默认行为,准备关闭弹窗
if (deltaY > 0) {
e.preventDefault();
}
};
// 处理弹窗触摸结束(仅在顶部拖动区域触发)
const handleModalTouchEnd = (e) => {
const currentY = e.changedTouches[0].clientY;
const deltaY = currentY - modalTouchStartY.value;
const deltaTime = Date.now() - modalTouchStartTime.value;
// 判断是否应该关闭弹窗
// 条件向下滑动距离超过100px 或 快速向下滑动(速度>0.5px/ms
if (deltaY > 0) {
const velocity = deltaY / deltaTime;
if (deltaY > 100 || velocity > 0.5) {
closeAssetSelectModal();
}
}
};
// 选择藏品
const handleAssetSelect = (asset) => {
selectedAssetForPlace.value = asset;
showPlaceConfirmModal.value = true;
};
// 关闭上架确认弹窗
const closePlaceConfirmModal = () => {
showPlaceConfirmModal.value = false;
selectedAssetForPlace.value = null;
// 重置替换模式状态
if (isReplaceMode.value) {
isReplaceMode.value = false;
selectedAssetForRemove.value = null;
}
};
// 确认上架藏品
const confirmPlaceAsset = async () => {
if (!selectedAssetForPlace.value || !selectedSlotId.value || !galleryOwnerId.value) {
uni.showToast({
title: '信息不完整',
icon: 'none',
duration: 2000
});
return;
}
try {
// 如果是替换模式,先移除旧藏品
if (isReplaceMode.value && selectedAssetForRemove.value && selectedAssetForRemove.value.slot_id) {
const removeResponse = await removeAssetFromGalleryApi(selectedAssetForRemove.value.slot_id);
if (removeResponse.code !== 200) {
uni.showToast({
title: removeResponse.message || '移除旧藏品失败',
icon: 'none',
duration: 2000
});
return;
}
}
const response = await placeAssetToGalleryApi(
selectedAssetForPlace.value.asset_id,
galleryOwnerId.value,
selectedSlotId.value
);
if (response.code === 200) {
uni.showToast({
title: isReplaceMode.value ? '替换成功' : '上架成功',
icon: 'success',
duration: 2000
});
// 关闭确认弹窗
showPlaceConfirmModal.value = false;
// 关闭藏品选择弹窗(带动画)
assetSelectModalAnimated.value = false;
setTimeout(() => {
showAssetSelectModal.value = false;
myAssetsList.value = [];
}, 300);
// 清空选中状态
selectedAssetForPlace.value = null;
selectedSlotId.value = null;
selectedSlotIndex.value = null;
// 重置替换模式状态
if (isReplaceMode.value) {
isReplaceMode.value = false;
selectedAssetForRemove.value = null;
}
// 刷新展馆信息
await loadGallerySlots();
}
} catch (error) {
console.error('上架藏品失败:', error);
uni.showToast({
title: error.message || '上架失败,请重试',
icon: 'none',
duration: 2000
});
}
};
// 处理长按藏品
const handleLongPress = (e, asset) => {
// 阻止默认事件,避免触发点击
if (e && e.preventDefault) {
e.preventDefault();
}
// 检查是否为用户自己的藏品
// 需要从本地存储获取用户uid
const userStr = uni.getStorageSync('user');
if (!userStr) {
uni.showToast({
title: '请先登录',
icon: 'none',
duration: 2000
});
return;
}
let currentUserUid;
try {
const user = JSON.parse(userStr);
currentUserUid = user.uid;
} catch (e) {
console.error('解析用户信息失败:', e);
return;
}
// 权限检查根据can_operate字段判断是否可以操作
// operation 为 'remove' 时可以替换和移除
if (asset.operation !== 'remove') {
uni.showToast({
title: '该槽位不可操作',
icon: 'none',
duration: 2000
});
return;
}
// 检查藏品是否有occupier_uid字段
// 如果没有,说明是用户自己的藏品(在"我的展位"中)
// 如果有需要比较occupier_uid和用户uid
// 注意:在"我的展馆"中可以下架他人放置的藏品
if (asset.occupier_uid && asset.occupier_uid !== currentUserUid && !isMyGallery.value) {
uni.showToast({
title: '只能操作自己的藏品',
icon: 'none',
duration: 2000
});
return;
}
// 检查是否有slot_id
if (!asset.slot_id) {
uni.showToast({
title: '槽位信息不完整',
icon: 'none',
duration: 2000
});
return;
}
// 弹出操作菜单
// 判断是否为"我的展馆"中他人放置的藏品
const isOthersAssetInMyGallery = asset.occupier_uid && asset.occupier_uid !== currentUserUid && isMyGallery.value;
const itemList = isOthersAssetInMyGallery
? ['查看详情', '移除藏品']
: ['查看详情', '替换藏品', '移除藏品'];
uni.showActionSheet({
itemList,
success: (res) => {
switch (res.tapIndex) {
case 0: // 查看详情
handleNftClick(asset);
break;
case 1: // 替换藏品 or 移除藏品
if (isOthersAssetInMyGallery) {
// 移除藏品
selectedAssetForRemove.value = asset;
showRemoveConfirmModal.value = true;
} else {
// 替换藏品
handleReplaceAsset(asset);
}
break;
case 2: // 移除藏品(仅自己的藏品有)
if (!isOthersAssetInMyGallery) {
selectedAssetForRemove.value = asset;
showRemoveConfirmModal.value = true;
}
break;
}
}
});
};
// 处理替换藏品
const handleReplaceAsset = async (asset) => {
// 设置为替换模式
isReplaceMode.value = true;
// 保存被替换的藏品信息(用于区分空槽位上架)
selectedAssetForRemove.value = asset;
// 设置槽位信息
selectedSlotId.value = asset.slot_id;
// 获取用户藏品列表
try {
const response = await getMyAssetsApi(1, 20);
if (response.code === 200 && response.data && response.data.items) {
const assetsPromises = response.data.items.map(async item => {
const realCoverUrl = await getAssetCoverRealUrl(item.cover_url);
return {
asset_id: item.asset_id,
name: item.name,
image: realCoverUrl,
cover_url: item.cover_url || '/static/nft/collection.png'
};
});
myAssetsList.value = await Promise.all(assetsPromises);
// 显示藏品选择弹窗
showAssetSelectModal.value = true;
// 延迟触发动画
setTimeout(() => {
assetSelectModalAnimated.value = true;
}, 50);
}
} catch (error) {
console.error('获取藏品列表失败:', error);
uni.showToast({
title: error.message || '获取藏品列表失败',
icon: 'none',
duration: 2000
});
// 重置状态
isReplaceMode.value = false;
selectedAssetForRemove.value = null;
}
};
// 关闭下架确认弹窗
const closeRemoveConfirmModal = () => {
showRemoveConfirmModal.value = false;
selectedAssetForRemove.value = null;
};
// 确认下架藏品
const confirmRemoveAsset = async () => {
if (!selectedAssetForRemove.value || !selectedAssetForRemove.value.slot_id) {
uni.showToast({
title: '槽位信息不完整',
icon: 'none',
duration: 2000
});
return;
}
try {
uni.showLoading({
title: '下架中...',
mask: true
});
const response = await removeAssetFromGalleryApi(selectedAssetForRemove.value.slot_id);
uni.hideLoading();
if (response.code === 200) {
uni.showToast({
title: '下架成功',
icon: 'success',
duration: 1500
});
// 关闭弹窗
closeRemoveConfirmModal();
// 重新加载展馆槽位信息
await loadGallerySlots();
} else {
uni.showToast({
title: response.message || '下架失败',
icon: 'none',
duration: 2000
});
}
} catch (error) {
uni.hideLoading();
console.error('下架藏品失败:', error);
uni.showToast({
title: error.message || '下架失败,请重试',
icon: 'none',
duration: 2000
});
}
};
// 将状态和方法暴露给页面实例,供 onBackPress 使用
const instance = getCurrentInstance();
if (instance) {
instance.proxy.showAssetSelectModal = showAssetSelectModal;
instance.proxy.closeAssetSelectModal = closeAssetSelectModal;
}
// 启动定时器
let countdownTimer = null;
// 使用 onLoad 接收页面参数
onLoad((options) => {
// 获取当前用户UID
const userStr = uni.getStorageSync('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
currentUserUid.value = user.uid;
} catch (e) {
console.error('解析用户信息失败:', e);
}
}
// 如果传入了target_uid参数则访问指定用户的展馆
if (options && options.target_uid) {
visitingGalleryOwnerUid.value = parseInt(options.target_uid);
isViewingOthers.value = true;
} else {
// 默认访问自己的展馆
visitingGalleryOwnerUid.value = currentUserUid.value;
isViewingOthers.value = false;
}
// 处理引导跳转参数:如果传递了 guide_key则继续该引导
if (options && options.guide_key) {
console.log('[Guide] exhibition 收到引导跳转参数, guide_key:', options.guide_key, 'guide_step:', options.guide_step)
// 使用 resumeGuide 继续引导(不会检查 shouldShowGuide
store.dispatch('guide/resumeGuide', options.guide_key).then(res => {
console.log('[Guide] exhibition resumeGuide 结果:', res)
}).catch(err => {
console.error('[Guide] exhibition resumeGuide 失败:', err)
})
}
});
// 处理点赞数实时更新
const handleAssetLikeChanged = ({ asset_id, like_count }) => {
[friendExhibitions, myExhibitions].forEach(list => {
list.value.forEach(item => {
if (item && item.asset_id === asset_id) {
item.like_count = like_count;
}
});
});
};
onMounted(() => {
const systemInfo = uni.getSystemInfoSync();
screenWidth.value = systemInfo.windowWidth;
screenHeight.value = systemInfo.windowHeight;
// 计算顶部距离
calculateTopPadding();
// 确保 currentUserUid 有值
if (!currentUserUid.value) {
const userStr = uni.getStorageSync('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
currentUserUid.value = user.uid;
} catch (e) {
console.error('解析用户信息失败:', e);
}
}
}
// 如果 visitingGalleryOwnerUid 没有值,设置为当前用户
if (!visitingGalleryOwnerUid.value) {
visitingGalleryOwnerUid.value = currentUserUid.value;
isViewingOthers.value = false;
}
// 加载展馆槽位信息
loadGallerySlots();
// 上报每日首次预览事件
const starId = uni.getStorageSync('star_id') || 1
reportEvent('daily_browse_asset', starId).catch(err => {
console.error('上报预览事件失败:', err)
})
// 启动倒计时定时器(每秒更新一次)
countdownTimer = setInterval(() => {
updateCountdowns();
}, 1000);
// 监听点赞变化事件
uni.$on('assetLikeChanged', handleAssetLikeChanged);
// 延迟检查并恢复引导位置(确保 DOM 已渲染)
setTimeout(() => {
if (store.state.guide.isActive) {
console.log('[Exhibition] 检测到引导激活,重新计算位置')
store.dispatch('guide/relocateOnPageReady')
}
}, 500);
});
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer);
}
uni.$off('assetLikeChanged', handleAssetLikeChanged);
});
</script>
<style scoped>
.exhibition-container {
position: relative;
width: 100vw;
height: 100vh;
min-height: 100vh;
/* overflow-y: auto; */
overflow: hidden;
}
.background-image {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
object-fit: cover;
min-width: 100%;
min-height: 100%;
}
.background-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
background: rgba(0, 0, 0, 0.3);
}
.content-wrapper {
position: relative;
width: 100%;
height: 100%;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
padding-top: 120rpx;
box-sizing: border-box;
}
.page-title {
font-size: 48rpx;
color: #e6e6e6;
font-weight: bold;
}
/* 他人展馆标题 */
.gallery-owner-title {
position: fixed;
top: 192rpx;
left: 0;
width: 100%;
z-index: 99;
text-align: center;
pointer-events: none;
}
.gallery-owner-title-text {
font-size: 36rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
color: #e6e6e6;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.6);
letter-spacing: 4rpx;
}
/* 展览板容器 */
.exhibition-boards {
position: relative;
width: 100%;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 200rpx;
box-sizing: border-box;
}
/* 展览板 */
.exhibition-board {
position: relative;
width: 105%;
display: flex;
justify-content: center;
}
.exhibition-board:last-child {
margin-bottom: 0;
}
/* 展览板图片 */
.board-image {
width: 100%;
display: block;
position: relative;
z-index: 1;
}
/* NFT卡片容器 */
.nft-cards-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
}
/* 展览板标签文字 */
.board-label {
position: absolute;
left: 80rpx;
bottom: 45rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
font-size: 32rpx;
color: #e6e6e6;
z-index: 3;
pointer-events: none;
}
/* 展览板2右上角标签 */
.board-label-right {
left: auto;
right: 120rpx;
bottom: auto;
top: 33rpx;
rotate: -4deg;
}
/* 藏品选择弹窗遮罩 */
.asset-select-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9998;
display: flex;
align-items: flex-end;
}
/* 藏品选择弹窗 */
.asset-select-modal {
position: relative;
width: 100%;
height: 80vh;
border-top-left-radius: 40rpx;
border-top-right-radius: 40rpx;
overflow: hidden;
transform: translateY(100%);
transition: transform 0.3s ease-out;
background: #0d0820;
}
.asset-select-modal.show {
transform: translateY(0);
}
/* 弹窗背景图片 */
.modal-background-image {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
object-fit: cover;
}
/* 弹窗内容包装器 */
.modal-content-wrapper {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 20rpx 30rpx 30rpx;
box-sizing: border-box;
}
/* 顶部拖动区域 */
.modal-top-drag-area {
flex-shrink: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 0;
cursor: grab;
}
.modal-top-drag-area:active {
cursor: grabbing;
}
/* 拖动条 */
.modal-handle {
width: 80rpx;
height: 8rpx;
background: rgba(255, 255, 255, 0.5);
border-radius: 4rpx;
margin: 0 auto 20rpx;
flex-shrink: 0;
}
/* 弹窗标题 */
.modal-title {
font-size: 36rpx;
font-weight: bold;
color: #e6e6e6;
text-align: center;
margin-bottom: 30rpx;
margin-top: 30rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
flex-shrink: 0;
}
/* 藏品网格容器 */
.modal-nft-grid-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 15rpx;
row-gap: 10rpx;
align-items: start;
align-content: start;
width: 100%;
max-width: 100%;
}
.modal-nft-grid-item {
position: relative;
width: 100%;
padding-top: 133.33%;
display: block;
}
/* 上架确认弹窗遮罩 */
.place-confirm-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
/* 上架确认弹窗 */
.place-confirm-modal {
width: 600rpx;
background: #e6e6e6;
border-radius: 20rpx;
padding: 40rpx;
z-index: 10001;
}
.confirm-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
text-align: center;
display: block;
margin-bottom: 30rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
}
.confirm-content {
margin-bottom: 30rpx;
}
.confirm-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.confirm-label {
font-size: 28rpx;
color: #666;
}
.confirm-value {
font-size: 28rpx;
color: #333;
font-weight: bold;
}
.confirm-buttons {
display: flex;
justify-content: space-between;
gap: 20rpx;
}
.confirm-btn {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.cancel-btn {
background: #f0f0f0;
color: #666;
}
.submit-btn {
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
color: #e6e6e6;
}
/* 底部铸爱按钮 */
.castlove-btn {
position: fixed;
left: 50%;
transform: translateX(-50%);
/* margin-top: 30rpx; */
/* margin-bottom: 48rpx; */
/* margin-left: auto; */
margin-right: auto;
min-width: 300rpx;
max-width: 500rpx;
width: auto;
padding: 0 40rpx;
height: 100rpx;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
border-radius: 50rpx;
border: 4rpx solid #e6e6e6;
font-size: 48rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
color: #e6e6e6;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
z-index: 100;
box-shadow:
0 0 20rpx rgba(255, 107, 157, 0.6),
0 0 40rpx rgba(255, 140, 66, 0.4),
0 4rpx 20rpx rgba(0, 0, 0, 0.3);
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
.castlove-btn::after {
border: none;
}
/* 倒计时背景 */
.countdown-background {
position: absolute;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
border-radius: 999rpx;
z-index: 9;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
opacity: 0.95;
}
/* 倒计时文字 */
.countdown-text {
position: absolute;
font-size: 22rpx;
font-weight: bold;
color: #e6e6e6;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.8);
z-index: 10;
/* 使用等宽数字,防止数字宽度变化导致跳动 */
font-variant-numeric: tabular-nums;
/* 或者使用等宽字体作为备选 */
font-feature-settings: "tnum";
}
/* 点赞徽标(左下角) */
.like-badge {
position: absolute;
z-index: 11;
}
.like-icon {
width: 36rpx;
height: 36rpx;
flex-shrink: 0;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.45));
}
.like-count-text {
color: #ffffff;
font-size: 24rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
font-weight: bold;
line-height: 1;
text-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.85);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
.like-count-background {
min-width: 24rpx;
height: 28rpx;
padding: 0 8rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
border-radius: 999rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
opacity: 0.95;
}
/* 左下角角色图片 */
.character-image {
position: fixed;
left: 30rpx;
bottom: 30rpx;
width: 35%;
max-width: 300rpx;
z-index: 10;
pointer-events: none;
filter: drop-shadow(0 10rpx 20rpx rgba(0, 0, 0, 0.4));
}
/* 随机展馆左右箭头容器 */
.random-gallery-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;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.25);
}
.arrow-btn:active {
opacity: 0.8;
}
.arrow-text {
font-size: 56rpx;
color: #FFFFFF;
font-weight: 600;
line-height: 1;
margin-top: -6rpx;
}
</style>