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

646 lines
14 KiB
Vue
Raw Permalink 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="detail-container">
<!-- 背景图片 -->
<image class="background-image" src="/static/background/starbook.jpg" mode="aspectFill"></image>
<!-- Header -->
<view class="header-bar">
<view class="back-btn" @tap="handleBack">
<text class="back-icon"></text>
</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-wrapper">
<!-- 旋转光环 -->
<view class="loading-ring-outer">
<view class="loading-ring"></view>
</view>
<!-- 三颗脉冲粒子 -->
<view class="loading-dots">
<view class="loading-dot dot-1"></view>
<view class="loading-dot dot-2"></view>
<view class="loading-dot dot-3"></view>
</view>
<!-- 文字淡入淡出 -->
<text class="loading-text">正在加载藏品...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="loadError" class="error-wrapper">
<text class="error-text">{{ loadError }}</text>
<button class="retry-btn" @tap="loadData">重试</button>
</view>
<!-- 详情内容 -->
<scroll-view v-else scroll-y class="content-scroll">
<view class="content-wrapper">
<!-- 藏品卡片区域 -->
<view class="card-section">
<view class="card-wrapper">
<NftCard
:cover-image="coverUrl"
:width="cardSize"
:height="cardSize"
:custom-style="nftCardStyle"
/>
</view>
<!-- 点赞 + 倒计时行 -->
<view class="card-meta-row">
<!-- 点赞 -->
<view class="like-area" @tap="handleLike">
<image
:src="isLiked ? '/static/icon/like-after.png' : '/static/icon/like-before.png'"
class="like-icon"
mode="aspectFit"
/>
<text class="like-num">{{ likeCount }}</text>
</view>
<!-- 倒计时(如有) -->
<view v-if="showCountdown" class="countdown-area">
<view class="countdown-pill">
<text class="countdown-val">{{ countdownText }}</text>
</view>
</view>
</view>
</view>
<!-- 持有人 + 获取时间 -->
<view class="owner-section">
<view class="owner-row">
<view class="owner-info">
<text class="owner-label">持有人</text>
<text class="owner-name">{{ assetData.owner_nickname || '未知' }}</text>
</view>
</view>
<view class="acquire-row">
<text class="acquire-label">获取时间</text>
<text class="acquire-value">{{ formattedAcquireTime }}</text>
</view>
</view>
<!-- 链上数据 -->
<view class="chain-section">
<view class="chain-row">
<text class="chain-label">数根名称</text>
<text class="chain-value">{{ assetData.name || '未知' }}</text>
</view>
<view class="chain-row">
<text class="chain-label">数根编号</text>
<text class="chain-value">{{ assetData.asset_id || '未知' }}</text>
</view>
<view class="chain-row">
<text class="chain-label">数根发行方</text>
<text class="chain-value">TOPFANS</text>
</view>
<view class="chain-row">
<text class="chain-label">区块链编号</text>
<text class="chain-value">{{ assetData.block_number || '未知' }}</text>
</view>
<view class="chain-row">
<text class="chain-label">交易哈希</text>
<text
class="chain-value chain-hash"
@longpress="copyHash"
>{{ displayTxHash }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import NftCard from '../components/NftCard.vue';
import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi } from '@/utils/api.js';
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
// 页面参数
const assetIdParam = ref('');
const orderIdParam = ref('');
const fromParam = ref('');
// 数据状态
const loading = ref(true);
const loadError = ref('');
const assetData = ref({});
const coverUrl = ref('');
const isLiked = ref(false);
const likeCount = ref(0);
const liking = ref(false);
// 倒计时
const remainSeconds = ref(0);
let countdownTimer = null;
const showCountdown = computed(() => remainSeconds.value > 0);
const countdownText = computed(() => {
const h = String(Math.floor(remainSeconds.value / 3600)).padStart(2, '0');
const m = String(Math.floor((remainSeconds.value % 3600) / 60)).padStart(2, '0');
const s = String(Math.floor(remainSeconds.value % 60)).padStart(2, '0');
return `${h}:${m}:${s}`;
});
// 卡片尺寸(固定 px
const cardSize = 280;
const nftCardStyle = {
position: 'relative',
pointerEvents: 'none',
cursor: 'default',
transition: 'none'
};
// 格式化获取时间(字段名 created_at来自 getAssetDetailApi 响应)
const formattedAcquireTime = computed(() => {
const raw = assetData.value.created_at || '';
if (!raw) return '未知';
const d = new Date(raw);
if (isNaN(d.getTime())) return raw;
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}.${mm}.${dd}`;
});
// 截断交易哈希
const displayTxHash = computed(() => {
const hash = assetData.value.tx_hash;
if (!hash) return '未知';
if (hash.length <= 20) return hash;
return `${hash.substring(0, 10)}...${hash.substring(hash.length - 8)}`;
});
// 复制完整哈希
const copyHash = () => {
const hash = assetData.value.tx_hash;
if (!hash) return;
uni.setClipboardData({
data: hash,
success: () => uni.showToast({ title: '已复制', icon: 'success', duration: 1500 })
});
};
// 加载数据
const loadData = async () => {
loading.value = true;
loadError.value = '';
try {
let assetId = assetIdParam.value;
if (!assetId && orderIdParam.value) {
const mintRes = await getMintOrderDetailApi(orderIdParam.value);
if (mintRes.code === 200 && mintRes.data?.asset?.asset_id) {
assetId = mintRes.data.asset.asset_id;
} else {
throw new Error('获取铸造订单详情失败');
}
}
if (!assetId) throw new Error('藏品信息不完整');
const res = await getAssetDetailApi(assetId);
if (res.code === 200 && res.data?.asset) {
const asset = res.data.asset;
assetData.value = asset;
isLiked.value = res.data.asset.is_liked || res.data.is_liked || false;
likeCount.value = asset.like_count || 0;
if (asset.remain_time > 0) {
remainSeconds.value = asset.remain_time;
startCountdown();
}
coverUrl.value = await getAssetCoverRealUrl(asset.cover_url);
} else {
throw new Error(res.message || '获取藏品详情失败');
}
} catch (err) {
loadError.value = err.message || '加载失败,请重试';
} finally {
loading.value = false;
}
};
// 倒计时
const startCountdown = () => {
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
if (remainSeconds.value > 0) {
remainSeconds.value--;
} else {
clearInterval(countdownTimer);
}
}, 1000);
};
// 点赞
const handleLike = async () => {
if (liking.value || !assetData.value.asset_id) return;
liking.value = true;
try {
if (isLiked.value) {
await unlikeAssetApi(assetData.value.asset_id);
isLiked.value = false;
likeCount.value = Math.max(0, likeCount.value - 1);
} else {
await likeAssetApi(assetData.value.asset_id);
isLiked.value = true;
likeCount.value += 1;
}
// 通知展馆页面更新点赞数
uni.$emit('assetLikeChanged', {
asset_id: assetData.value.asset_id,
like_count: likeCount.value,
is_liked: isLiked.value
});
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none', duration: 2000 });
} finally {
liking.value = false;
}
};
// 返回逻辑
const handleBack = () => {
if (fromParam.value === 'castlove') {
uni.reLaunch({ url: '/pages/square/square?tab=1' });
} else {
uni.navigateBack();
}
};
onLoad((options) => {
assetIdParam.value = options?.asset_id || '';
orderIdParam.value = options?.order_id || '';
fromParam.value = options?.from || '';
loadData();
});
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer);
});
</script>
<style scoped>
.detail-container {
position: relative;
width: 100vw;
min-height: 100vh;
overflow: hidden;
background-color: #0d0820;
}
.background-image {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
}
/* Header */
.header-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 180rpx;
display: flex;
align-items: flex-end;
padding: 0 30rpx 20rpx;
z-index: 100;
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
}
.back-btn:active {
opacity: 0.6;
}
.back-icon {
font-size: 48rpx;
color: #1a1a1a;
line-height: 1;
text-shadow: 0 1rpx 4rpx rgba(255, 255, 255, 0.6);
}
/* 加载/错误 */
.loading-wrapper,
.error-wrapper {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 30rpx;
}
/* 旋转光环 */
.loading-ring-outer {
position: relative;
width: 160rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
}
.loading-ring {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 6rpx solid transparent;
border-top-color: #F0E4B1;
border-right-color: #F08399;
border-bottom-color: #B94E73;
border-left-color: transparent;
box-shadow:
0 0 24rpx rgba(240, 131, 153, 0.5),
inset 0 0 16rpx rgba(183, 78, 115, 0.2);
animation: ring-spin 1.2s linear infinite;
}
@keyframes ring-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 三颗脉冲粒子 */
.loading-dots {
display: flex;
align-items: center;
gap: 24rpx;
}
.loading-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
animation: dot-pulse 1.4s ease-in-out infinite;
}
.dot-1 {
background: #F0E4B1;
box-shadow: 0 0 12rpx rgba(240, 228, 177, 0.8);
animation-delay: 0s;
}
.dot-2 {
background: #F08399;
box-shadow: 0 0 12rpx rgba(240, 131, 153, 0.8);
animation-delay: 0.2s;
}
.dot-3 {
background: #834B9E;
box-shadow: 0 0 12rpx rgba(131, 75, 158, 0.8);
animation-delay: 0.4s;
}
@keyframes dot-pulse {
0%, 100% {
transform: scale(0.6);
opacity: 0.4;
}
50% {
transform: scale(1.3);
opacity: 1;
}
}
/* 加载文字 */
.loading-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
letter-spacing: 4rpx;
animation: text-breathe 2s ease-in-out infinite;
}
@keyframes text-breathe {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.error-text {
font-size: 32rpx;
color: rgba(255, 255, 255, 0.8);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
}
.retry-btn {
padding: 16rpx 48rpx;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
color: #fff;
border-radius: 40rpx;
font-size: 28rpx;
border: none;
}
.retry-btn::after {
border: none;
}
/* 滚动区 */
.content-scroll {
position: relative;
z-index: 1;
height: 100vh;
}
.content-wrapper {
padding: 200rpx 40rpx 80rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
/* 卡片区域 */
.card-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 48rpx;
}
.card-wrapper {
margin-bottom: 28rpx;
}
.card-meta-row {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 24rpx;
}
/* 点赞 */
.like-area {
display: flex;
align-items: center;
gap: 10rpx;
background: rgba(0, 0, 0, 0.22);
border-radius: 999rpx;
padding: 10rpx 28rpx;
backdrop-filter: blur(12rpx);
}
.like-area:active {
opacity: 0.75;
}
.like-icon {
width: 44rpx;
height: 44rpx;
}
.like-num {
font-size: 32rpx;
font-weight: bold;
color: #e6e6e6;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
font-variant-numeric: tabular-nums;
}
/* 倒计时 */
.countdown-area {
display: flex;
align-items: center;
}
.countdown-pill {
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
border-radius: 999rpx;
padding: 10rpx 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
}
.countdown-val {
font-size: 28rpx;
font-weight: bold;
color: #fff;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
font-variant-numeric: tabular-nums;
}
/* 持有人区域 */
.owner-section {
width: 100%;
margin-bottom: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.owner-row {
display: flex;
align-items: center;
justify-content: center;
}
.owner-info {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 4rpx;
}
.owner-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.6);
}
.owner-name {
font-size: 36rpx;
font-weight: bold;
color: #ffffff;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.7);
}
.acquire-row {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 4rpx;
}
.acquire-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 1rpx 6rpx rgba(0, 0, 0, 0.6);
}
.acquire-value {
font-size: 32rpx;
color: #ffffff;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.7);
}
/* 链上数据 */
.chain-section {
width: calc(100% - 80rpx);
background: rgba(0, 0, 0, 0.22);
border-radius: 24rpx;
padding: 30rpx 48rpx;
backdrop-filter: blur(12rpx);
display: flex;
flex-direction: column;
gap: 0;
}
.chain-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 18rpx 0;
}
.chain-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
flex-shrink: 0;
margin-right: 20rpx;
}
.chain-value {
font-size: 28rpx;
color: #ffffff;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-align: right;
flex: 1;
word-break: break-all;
}
.chain-hash {
color: rgba(255, 255, 255, 0.75);
}
</style>