1769 lines
48 KiB
Vue
1769 lines
48 KiB
Vue
<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 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) => {
|
||
// 如果当前是查看自己的展馆,强制使用 currentUserUid
|
||
// 防止 API 返回的 gallery_owner_id 与当前用户 UID 不一致
|
||
if (!isViewingOthers.value && currentUserUid.value) {
|
||
visitingGalleryOwnerUid.value = currentUserUid.value;
|
||
galleryOwnerNickname.value = '';
|
||
} else {
|
||
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 () => {
|
||
try {
|
||
let response;
|
||
// 使用 isViewingOthers 判断,因为它在 onLoad 中同步设置,比 isMyGallery 更可靠
|
||
if (!isViewingOthers.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;
|
||
}
|
||
});
|
||
|
||
// 处理点赞数实时更新
|
||
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();
|
||
|
||
// 启动倒计时定时器(每秒更新一次)
|
||
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>
|