2208 lines
66 KiB
Vue
2208 lines
66 KiB
Vue
<template>
|
||
<view class="detail-container">
|
||
<!-- 背景图片 -->
|
||
<image class="background-image" src="/static/square/squearbj.png" mode="aspectFill"></image>
|
||
|
||
<!-- Header -->
|
||
<view class="header-bar">
|
||
<view class="back-btn" @tap="handleBack">
|
||
<text class="back-icon">←</text>
|
||
</view>
|
||
<view class="header-actions">
|
||
<ShareReportButtons :ownerNickname="assetData.owner_nickname || ''" :assetId="assetIdParam"
|
||
:coverUrl="coverUrl" :coverKey="assetData.cover_url || assetData.image_url || ''"
|
||
:displayTxHash="displayTxHash" externalCanvasId="shareCanvas" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 2026-07-28: 提示文案上移到 detail-container 顶层,fixed 定位,
|
||
渲染在所有 v-if/v-else-if 之外 → expanded 与 normal 两态全程可见。
|
||
配合 hintText computed 同时识别 isPullActive 与 isCollapsePullActive,
|
||
同一段文案同时服务下拉放大与上拉收起两套手势 -->
|
||
<text v-if="(isPullActive || isCollapsePullActive) && hintText" class="pull-hint">{{ hintText }}</text>
|
||
|
||
<!-- 加载中 -->
|
||
<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 && !craftConfirmMode" class="error-wrapper">
|
||
<text class="error-text">{{ loadError }}</text>
|
||
<button class="retry-btn" @tap="loadData">重试</button>
|
||
</view>
|
||
|
||
<!-- 放大态:完全脱离 scroll-view,父级无滚动,组件手势不被抢占 -->
|
||
<view v-else-if="viewMode === 'expanded'" class="pull-expanded-view">
|
||
<!-- 2026-07-28: 上拉收起手势 surface + transform 镜像容器
|
||
(symmetric mirror of .card-pull-surface in normal branch)。
|
||
仅负责触摸事件 + 镜像 transform,内层 AssetCardPullExpand 渲染不变。
|
||
:is-expanded 固定为 false:让 wrapper 的 collapseCardTransform 提供完整视觉
|
||
scale(1.6 at rest → 1.0 at max pull),内层始终 scale(1)。原设计
|
||
(isExpanded && !isCollapsePullActive) 在释放瞬间会让内层从 scale(1) 跳到
|
||
scale(1.6),叠加 wrapper 的 scale(1) → 出现"先放大再塌缩"双重跳变。
|
||
新方案下,内层固定 scale(1),wrapper 平滑过渡 scale(1.6) ↔ scale(s) translateY(-pull) -->
|
||
<view class="pull-collapse-surface" :style="{ transform: collapseCardTransform }"
|
||
@touchstart="onCollapseTouchStart" @touchmove="onCollapseTouchMove"
|
||
@touchend="onCollapseTouchEnd" @touchcancel="onCollapseTouchEnd">
|
||
<AssetCardPullExpand
|
||
:cover-url="coverUrl"
|
||
:is-lenticular="isLenticularAsset"
|
||
:lenticular-layers="lenticularLayers"
|
||
:layer-transforms="layerTransforms"
|
||
:simulate="simulate"
|
||
:grade-badge-url="gradeBadgeUrl"
|
||
:stickers="activeStickers"
|
||
:tilt-hint-text="'倾斜手机查看光栅效果'"
|
||
:shimmer-mid-opacity="0.16"
|
||
:card-anim-class="cardWrapperAnimClass"
|
||
:is-expanded="false"
|
||
:show-mask="false"
|
||
@mode-change="onCardModeChange"
|
||
@pull-expand-change="onPullExpandChange"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 详情内容 -->
|
||
<scroll-view v-else scroll-y class="content-scroll" :show-scrollbar="false" @scroll="onScroll">
|
||
<!-- 2026-07-28 fix: 提示文案必须放在 .card-pull-surface(带 pull transform)之外,
|
||
否则会跟随卡片一起 scale/translateY,被缩放到屏幕外或藏到卡片后面。
|
||
2026-07-28 refactor (continued): 此处 <text> 已上移到 detail-container 顶层
|
||
(位于 header-bar 之后、v-if loading 之前),渲染在所有 v-if/v-else-if 之外,
|
||
同时服务 normal 态下拉与 expanded 态上拉两套手势。下方保留注释占位以记录迁移。 -->
|
||
<view class="content-wrapper">
|
||
<!-- 藏品卡片区域 -->
|
||
<view class="card-section">
|
||
<!-- 2026-07-27 refactor (Finding A): 触摸事件 + 跟手 transform 改挂在 .card-pull-surface
|
||
上,避免 .card-wrapper 上 card-3d-flip 动画的 transform 覆盖父级 :style,
|
||
使下拉跟手反馈可见 -->
|
||
<view class="card-pull-surface" :style="{ transform: cardTransform }"
|
||
@touchstart="onTouchStart" @touchmove="onTouchMove"
|
||
@touchend="onTouchEnd" @touchcancel="onTouchEnd">
|
||
<view class="card-wrapper" :class="[
|
||
{ 'card-wrapper--lenticular': isLenticularAsset },
|
||
cardWrapperAnimClass,
|
||
]">
|
||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill">
|
||
</image>
|
||
<view v-if="isLenticularAsset" class="detail-lenticular-slot">
|
||
<LenticularCard class="detail-lenticular-card" :layers="lenticularLayers"
|
||
:transforms="layerTransforms" gyro-source="simulation"
|
||
tilt-hint-text="倾斜手机查看光栅效果" :shimmer-mid-opacity="0.16"
|
||
:simulate-tilt-from-normalized="simulate"
|
||
@mode-change="onCardModeChange" />
|
||
</view>
|
||
<image v-else class="card-image" :src="coverUrl" mode="aspectFill"></image>
|
||
<image class="card-badge" :src="gradeBadgeUrl" mode="aspectFit"></image>
|
||
<!-- 贴纸叠加层 -->
|
||
<image v-for="sticker in activeStickers" :key="sticker.id" class="card-sticker"
|
||
:src="sticker.src" mode="aspectFit" :style="getStickerStyle(sticker)" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 贴纸合成导出隐藏画布 -->
|
||
<canvas v-if="activeStickers.length" canvas-id="stickerCompositCanvas" class="export-canvas"
|
||
:style="{ position: 'fixed', left: '-9999px', top: '-9999px', width: '450px', height: '600px' }" />
|
||
</view>
|
||
|
||
<!-- 2026-07-27: 卡片下方的四个 sibling 模块统一包在 .card-siblings 里,
|
||
通过 opacity 同步 pullDistance 做淡出,接住放大态切换时的视觉接缝 -->
|
||
<view class="card-siblings"
|
||
:style="{ opacity: siblingFadeOpacity, transition: 'opacity .12s linear' }">
|
||
|
||
<!-- 点赞 + 收益 + 倒计时行 -->
|
||
<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"></image>
|
||
<text class="like-num">{{ likeCount || 0 }}</text>
|
||
</view>
|
||
|
||
<!-- 收益 -->
|
||
<view class="earnings-area">
|
||
<image class="crystal-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||
<text class="earnings-text">{{ assetData.hourly_earnings || 5 }}/时</text>
|
||
</view>
|
||
|
||
<!-- 倒计时(如有) -->
|
||
<view v-if="assetData.display_status === 1 && countdownText" class="countdown-area">
|
||
<view class="countdown-pill">
|
||
<image class="crystal-icon" src="/static/assetDetail/time.png" mode="aspectFit"></image>
|
||
<text class="countdown-val">{{ countdownText }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 信息行(创作者 + 铸造时间 + 来源) -->
|
||
<view class="info-row">
|
||
<view class="info-col">
|
||
<view class="info-item">
|
||
<text class="info-label">来源</text>
|
||
<text class="info-value">{{ assetData.source || '铸造' }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="info-col">
|
||
<view class="info-item">
|
||
<text class="info-label">创作者</text>
|
||
<view class="info-nickname">
|
||
<image v-if="assetData.owner?.avatar" class="info-avatar" :src="assetData.owner.avatar"
|
||
mode="aspectFill">
|
||
</image>
|
||
<text class="info-value">{{ assetData.owner_nickname || '未知' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="info-col">
|
||
<view class="info-item">
|
||
<text class="info-label">铸造时间</text>
|
||
<text class="info-value">{{ formattedAcquireTime }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 创作者信息模块 -->
|
||
<view v-if="likeCount > 0" class="creator-section" @tap="showLikeUsersModal = true">
|
||
<!-- 点赞用户头像区域 -->
|
||
<view class="liked-users-area">
|
||
<view v-for="(user, index) in likedUsers" :key="index" class="liked-user-avatar" :style="{
|
||
transform: `translate(${user.ellipseX || 0}rpx, ${user.ellipseY || 0}rpx) scale(${user.size || 1})`,
|
||
zIndex: index < 2 ? index + 1 : (index < 4 ? index + 3 : index + 1)
|
||
}">
|
||
<image class="liked-user-image" :src="user.avatar" mode="aspectFill"></image>
|
||
</view>
|
||
</view>
|
||
<view class="creator-info">
|
||
<text class="creator-title">TA们最近为你点过赞</text>
|
||
<view class="creator-card">
|
||
<text class="creator-tip">点击查看</text>
|
||
<image class="creator-preview" src="/static/rank/activity-support-icon/tubiao.png"
|
||
mode="aspectFill"></image>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 链上数据 -->
|
||
<view class="chain-section">
|
||
<image class="chain-logo" src="/static/logo/APPLOGO.png" mode="aspectFit"></image>
|
||
<view class="chain-left">
|
||
<view class="chain-row">
|
||
<text class="chain-label">数根名称</text>
|
||
<view class="chain-value-wrap">
|
||
<text class="chain-value">{{ assetData.name || '未知' }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="chain-row">
|
||
<text class="chain-label">数根发行方</text>
|
||
<view class="chain-value-wrap">
|
||
<text class="chain-value">TOPFANS</text>
|
||
</view>
|
||
</view>
|
||
<view class="chain-row">
|
||
<text class="chain-label">区块链编号</text>
|
||
<view class="chain-value-wrap">
|
||
<text class="chain-value">{{ showBlockNumber ? (assetData.block_number || '未知') :
|
||
hiddenBlockNumber }}</text>
|
||
<view class="toggle-btn" @tap="showBlockNumber = !showBlockNumber">
|
||
<image class="toggle-icon"
|
||
:src="showBlockNumber ? '/static/icon/show.png' : '/static/icon/hide.png'"
|
||
mode="aspectFit"></image>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="chain-row">
|
||
<text class="chain-label">链上哈希</text>
|
||
<view class="chain-value-wrap">
|
||
<text class="chain-value chain-hash" @longpress="copyHash">{{ showTxHash ? displayTxHash
|
||
: hiddenTxHash }}</text>
|
||
<view class="toggle-btn" @tap="showTxHash = !showTxHash">
|
||
<image class="toggle-icon"
|
||
:src="showTxHash ? '/static/icon/show.png' : '/static/icon/hide.png'"
|
||
mode="aspectFit"></image>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 完整链上哈希显示遮罩层 -->
|
||
<view v-if="showTxHash" class="txhash-mask" @tap="showTxHash = false">
|
||
<view class="txhash-popup" @tap.stop>
|
||
<view class="txhash-popup-header">
|
||
<text class="txhash-popup-title">链上哈希</text>
|
||
<view class="txhash-popup-close" @tap="showTxHash = false">
|
||
<image class="close-icon" src="/static/icon/hide.png" mode="aspectFit"></image>
|
||
</view>
|
||
</view>
|
||
<text class="txhash-popup-content" selectable @longpress="copyHash">{{ displayTxHash }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 2026-07-28 fix (sibling fade-in): 在 detail-container 顶层再渲染一份 .card-siblings,
|
||
仅在 expanded 态挂载。原因:v-if/v-else-if 切到 expanded 时 <scroll-view> 被销毁,
|
||
原 .card-siblings 节点随父级一起消失,导致 siblingFadeOpacity 在 up-pull 阶段虽然
|
||
正确算出 0→1 但 DOM 上无元素可绑,上拉收起过程中 sibling modules 始终不可见。
|
||
这份 overlay 复用父级作用域的 isLiked/likeCount/assetData 等所有响应式数据(无需 props),
|
||
opacity 绑 siblingFadeOpacity 即可接住上拉渐显;正常态下 scroll-view 里仍有一份原版。 -->
|
||
<view v-if="viewMode === 'expanded'" class="card-siblings-expanded-overlay">
|
||
<view class="card-siblings"
|
||
:style="{ opacity: siblingFadeOpacity, transition: 'opacity .12s linear' }">
|
||
|
||
<!-- 点赞 + 收益 + 倒计时行 -->
|
||
<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"></image>
|
||
<text class="like-num">{{ likeCount || 0 }}</text>
|
||
</view>
|
||
|
||
<!-- 收益 -->
|
||
<view class="earnings-area">
|
||
<image class="crystal-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||
<text class="earnings-text">{{ assetData.hourly_earnings || 5 }}/时</text>
|
||
</view>
|
||
|
||
<!-- 倒计时(如有) -->
|
||
<view v-if="assetData.display_status === 1 && countdownText" class="countdown-area">
|
||
<view class="countdown-pill">
|
||
<image class="crystal-icon" src="/static/assetDetail/time.png" mode="aspectFit">
|
||
</image>
|
||
<text class="countdown-val">{{ countdownText }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 信息行(创作者 + 铸造时间 + 来源) -->
|
||
<view class="info-row">
|
||
<view class="info-col">
|
||
<view class="info-item">
|
||
<text class="info-label">来源</text>
|
||
<text class="info-value">{{ assetData.source || '铸造' }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="info-col">
|
||
<view class="info-item">
|
||
<text class="info-label">创作者</text>
|
||
<view class="info-nickname">
|
||
<image v-if="assetData.owner?.avatar" class="info-avatar"
|
||
:src="assetData.owner.avatar" mode="aspectFill">
|
||
</image>
|
||
<text class="info-value">{{ assetData.owner_nickname || '未知' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="info-col">
|
||
<view class="info-item">
|
||
<text class="info-label">铸造时间</text>
|
||
<text class="info-value">{{ formattedAcquireTime }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 创作者信息模块 -->
|
||
<view v-if="likeCount > 0" class="creator-section" @tap="showLikeUsersModal = true">
|
||
<!-- 点赞用户头像区域 -->
|
||
<view class="liked-users-area">
|
||
<view v-for="(user, index) in likedUsers" :key="index" class="liked-user-avatar" :style="{
|
||
transform: `translate(${user.ellipseX || 0}rpx, ${user.ellipseY || 0}rpx) scale(${user.size || 1})`,
|
||
zIndex: index < 2 ? index + 1 : (index < 4 ? index + 3 : index + 1)
|
||
}">
|
||
<image class="liked-user-image" :src="user.avatar" mode="aspectFill"></image>
|
||
</view>
|
||
</view>
|
||
<view class="creator-info">
|
||
<text class="creator-title">TA们最近为你点过赞</text>
|
||
<view class="creator-card">
|
||
<text class="creator-tip">点击查看</text>
|
||
<image class="creator-preview" src="/static/rank/activity-support-icon/tubiao.png"
|
||
mode="aspectFill"></image>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 链上数据 -->
|
||
<view class="chain-section">
|
||
<image class="chain-logo" src="/static/logo/APPLOGO.png" mode="aspectFit"></image>
|
||
<view class="chain-left">
|
||
<view class="chain-row">
|
||
<text class="chain-label">数根名称</text>
|
||
<view class="chain-value-wrap">
|
||
<text class="chain-value">{{ assetData.name || '未知' }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="chain-row">
|
||
<text class="chain-label">数根发行方</text>
|
||
<view class="chain-value-wrap">
|
||
<text class="chain-value">TOPFANS</text>
|
||
</view>
|
||
</view>
|
||
<view class="chain-row">
|
||
<text class="chain-label">区块链编号</text>
|
||
<view class="chain-value-wrap">
|
||
<text class="chain-value">{{ showBlockNumber ? (assetData.block_number || '未知') :
|
||
hiddenBlockNumber }}</text>
|
||
<view class="toggle-btn" @tap="showBlockNumber = !showBlockNumber">
|
||
<image class="toggle-icon"
|
||
:src="showBlockNumber ? '/static/icon/show.png' : '/static/icon/hide.png'"
|
||
mode="aspectFit"></image>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="chain-row">
|
||
<text class="chain-label">链上哈希</text>
|
||
<view class="chain-value-wrap">
|
||
<text class="chain-value chain-hash" @longpress="copyHash">{{ showTxHash ? displayTxHash
|
||
: hiddenTxHash }}</text>
|
||
<view class="toggle-btn" @tap="showTxHash = !showTxHash">
|
||
<image class="toggle-icon"
|
||
:src="showTxHash ? '/static/icon/show.png' : '/static/icon/hide.png'"
|
||
mode="aspectFit"></image>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 点赞用户弹窗 -->
|
||
<LikeUsersModal :visible="showLikeUsersModal" :tabs="[]" @close="showLikeUsersModal = false">
|
||
<template #content>
|
||
<view class="like-users-list">
|
||
<view v-for="(user, index) in allLikedUsers" :key="index" class="like-user-item">
|
||
<image class="like-user-avatar" :src="user.avatar" mode="aspectFill"></image>
|
||
<view class="like-user-info">
|
||
<text class="like-user-name">{{ user.nickname }}</text>
|
||
</view>
|
||
<!-- 拜访按钮 -->
|
||
<view class="visit-button" @tap="handleVisit(user)">
|
||
<image class="visit-icon" src="/static/square/dianjibaifang.png" mode="aspectFit" lazy-load>
|
||
</image>
|
||
<text class="visit-text">点击拜访</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</LikeUsersModal>
|
||
|
||
<!-- 举报弹窗(也由 ShareReportButtons 触发,asset-detail 内显式声明以便直接控制) -->
|
||
<ReportModal
|
||
:visible="showReportModal"
|
||
:assetId="assetIdParam"
|
||
@close="showReportModal = false"
|
||
@submit="handleReportSubmit"
|
||
/>
|
||
|
||
<!-- 离屏 canvas(放 page 级,uni-app 组件内 canvas 不被 createCanvasContext 识别)
|
||
⚠️ width/height 必须设 HTML 属性(决定 native canvas surface 尺寸),
|
||
CSS display:none + rpx 单位会让 surface = 0 → drawImage 报 'width or height of 0' -->
|
||
<canvas
|
||
canvas-id="shareCanvas"
|
||
id="shareCanvas"
|
||
:width="750"
|
||
:height="1334"
|
||
class="share-canvas"
|
||
/>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onUnmounted } from 'vue';
|
||
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app';
|
||
import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi, getAssetMaterialsApi, getAssetLikersApi } from '@/utils/api.js';
|
||
import { reportEvent } from '@/utils/task-api.js';
|
||
import { getPreloadApi } from '@/utils/preloadApi/index';
|
||
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
|
||
import LikeUsersModal from '@/pages/components/LikeUsersModal.vue';
|
||
import ShareReportButtons from '@/pages/components/ShareReportButtons.vue';
|
||
import ReportModal from '@/pages/components/ReportModal.vue';
|
||
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
|
||
import AssetCardPullExpand from '@/components/AssetCardPullExpand/AssetCardPullExpand.vue';
|
||
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
|
||
import {
|
||
buildLenticularLayersTwo,
|
||
buildLenticularLayers,
|
||
LENTICULAR_STUDIO_STORAGE_KEY,
|
||
CRAFT_LENTICULAR_CN,
|
||
CRAFT_LASER_CARD_CN,
|
||
CRAFT_TAG_LENTICULAR,
|
||
} from '@/utils/castloveMintForm.js';
|
||
import {
|
||
STUDIO_LENTICULAR,
|
||
STUDIO_LASER,
|
||
} from '@/utils/castloveGenerationFlow.js';
|
||
import { readDraft } from '@/utils/draftStorage';
|
||
import { submitCraftMintFromPath } from '@/utils/craftMintSubmit.js';
|
||
import { composeStickers, relationsToStickers } from '@/utils/sticker-compositor.js';
|
||
import { getStoredUser } from '@/utils/getStoredUser.js';
|
||
// 页面参数
|
||
const assetIdParam = ref('');
|
||
const orderIdParam = ref('');
|
||
const fromParam = ref('');
|
||
const studioKindParam = ref('');
|
||
const lastLoadKey = ref('');
|
||
|
||
const craftConfirmMode = ref(false);
|
||
const craftFormData = ref({});
|
||
const craftCoverUrl = ref('');
|
||
const craftMinting = ref(false);
|
||
const lenticularLayers = ref([]);
|
||
const {
|
||
layerTransforms,
|
||
simulate,
|
||
} = useLenticularCraftTiltPreview(lenticularLayers);
|
||
|
||
// 卡片当前控制模式:'auto' = 自动摆动中;'manual' = 用户接管中
|
||
// 用来决定外层 card-wrapper 是否启用 card-3d-flip 动画(manual 时关闭避免和手动控制冲突)
|
||
const cardMode = ref('auto')
|
||
function onCardModeChange(mode) {
|
||
cardMode.value = mode
|
||
}
|
||
const cardWrapperAnimClass = computed(() => cardMode.value === 'auto' ? 'card-wrapper--auto-flip' : '')
|
||
|
||
// 视图模式: 'normal' = 列表滚动卡片 / 'expanded' = 全屏放大态
|
||
const viewMode = ref('normal');
|
||
|
||
function onPullExpandChange({ state }) {
|
||
viewMode.value = state;
|
||
}
|
||
|
||
// 2026-07-27 refactor: 下拉手势相关状态从 AssetCardPullExpand 组件上移到本页面,
|
||
// 关键原因见 Block 1:normal 态下 .card-wrapper 必须能接收触摸才能触发放大态,
|
||
// 因此手势 state 必须住在手势表面所在的父级上,而不能藏在组件内部。
|
||
|
||
// === 手势状态(由父级持有,作为 props 注入 AssetCardPullExpand) ===
|
||
const pullStartY = ref(null);
|
||
const pullDistance = ref(0);
|
||
const isPullActive = ref(false);
|
||
const isExpanded = ref(false);
|
||
// scrollTop 跟踪(避免与列表滚动冲突,仅在顶部进入激活态)
|
||
const scrollTopRef = ref(0);
|
||
|
||
// 2026-07-28: 放大态下的上拉收起手势 state(symmetric mirror of down-pull above)。
|
||
// 与 down-pull 解耦的独立 ref 集合:direction / startY / distance / active。
|
||
// 设计动机:上拉只可能在 expanded 分支挂载时触发,而 down-pull 只可能在 normal 分支
|
||
// 触发,二者 v-if/v-else-if 互斥,自然隔离;共用 ref 反而要写额外判断。
|
||
const collapseStartY = ref(null);
|
||
const collapsePullDistance = ref(0);
|
||
const isCollapsePullActive = ref(false);
|
||
|
||
// === 手势常量(与组件 props 默认值对齐) ===
|
||
const PULL_EXPAND_THRESHOLD = 80;
|
||
const PULL_MAX_DISTANCE = 240;
|
||
const PULL_MAX_SCALE = 1.6;
|
||
|
||
// #ifdef APP-PLUS
|
||
const isAppPlus = true;
|
||
// #else
|
||
const isAppPlus = false;
|
||
// #endif
|
||
|
||
const shouldHandleGesture = computed(() => {
|
||
return isAppPlus && !craftConfirmMode.value && !loading.value && !loadError.value;
|
||
});
|
||
|
||
const hintText = computed(() => {
|
||
// 2026-07-28: 上拉收起手势 hint(在 expanded 状态下激活)
|
||
if (isCollapsePullActive.value) {
|
||
if (collapsePullDistance.value >= PULL_EXPAND_THRESHOLD) return '松手收起';
|
||
if (collapsePullDistance.value > 20) return '上拉收起';
|
||
return '';
|
||
}
|
||
if (pullDistance.value >= PULL_EXPAND_THRESHOLD) return '松手查看大图';
|
||
if (pullDistance.value > 20) return '下拉查看大图';
|
||
return '';
|
||
});
|
||
|
||
// 父级持有 cardTransform 公式(对齐 spec §1.5):正常态卡片实时跟手放大;切换到放大态时
|
||
// AssetCardPullExpand 自己用同一公式再算一遍(pullDistance=0 / isExpanded=true → scale(maxScale))
|
||
const cardTransform = computed(() => {
|
||
if (isExpanded.value) {
|
||
return `scale(${PULL_MAX_SCALE})`;
|
||
}
|
||
if (isPullActive.value) {
|
||
const t = Math.min(1, Math.max(0, pullDistance.value) / PULL_MAX_DISTANCE);
|
||
const s = 1 + t * (PULL_MAX_SCALE - 1);
|
||
return `scale(${s.toFixed(3)}) translateY(${pullDistance.value}px)`;
|
||
}
|
||
return 'scale(1)';
|
||
});
|
||
|
||
// 2026-07-27: sibling content 同步淡出。pullDistance 从 0 → threshold*0.6 (默认 48rpx),
|
||
// opacity 从 1.0 → 0.0;超过 48rpx 仍维持 0,直到 onTouchEnd 切到 expanded(viewMode 切换
|
||
// 会销毁整个 <scroll-view>,siblings 随节点一起消失,所以接缝处无缝)
|
||
const siblingFadeOpacity = computed(() => {
|
||
// 2026-07-28: 上拉收起过程中 sibling 从 0 → 1 渐显,正好与下拉淡出对称。
|
||
// limit 与下拉共用(48rpx),即拉到 48rpx 时 siblings 已完全可见,松开 < 80rpx
|
||
// 时回弹放大态不会留下"残影"
|
||
if (isCollapsePullActive.value) {
|
||
const limit = PULL_EXPAND_THRESHOLD * 0.6;
|
||
const t = Math.min(1, Math.max(0, collapsePullDistance.value / limit));
|
||
return t;
|
||
}
|
||
if (!isPullActive.value) return 1;
|
||
const limit = PULL_EXPAND_THRESHOLD * 0.6; // 48rpx with default 80rpx threshold
|
||
const t = Math.min(1, Math.max(0, pullDistance.value / limit));
|
||
return 1 - t;
|
||
});
|
||
|
||
// === 手势回调 ===
|
||
// 2026-07-28 fix: touchstart 只清理残留状态,不 eager 记录 startY。
|
||
// 纯点击(无 move)时 touchend 会因 isPullActive=false 提前 return,
|
||
// eager 写入的 startY 会残留到下一次手势,导致首帧 transform 跳变
|
||
// ("点一下卡片先缩小再弹回")。startY 改为在首个 touchmove 里懒记录。
|
||
function onTouchStart(e) {
|
||
if (!shouldHandleGesture.value) return;
|
||
if (isExpanded.value) return;
|
||
if (scrollTopRef.value !== 0) return; // 仅在列表顶部进入
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
pullStartY.value = null;
|
||
pullDistance.value = 0;
|
||
isPullActive.value = false;
|
||
}
|
||
|
||
function onTouchMove(e) {
|
||
if (!shouldHandleGesture.value) return;
|
||
if (isExpanded.value) return;
|
||
if (scrollTopRef.value !== 0) return; // 仅在列表顶部进入
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
const currentY = e.touches[0].pageY;
|
||
if (pullStartY.value == null) {
|
||
// 手势真正开始移动时才记录起点
|
||
pullStartY.value = currentY;
|
||
}
|
||
const delta = currentY - pullStartY.value;
|
||
if (delta <= 0) return; // 向上/未真正下拉,不进入激活态
|
||
const next = Math.min(delta * 0.6, PULL_MAX_DISTANCE);
|
||
pullDistance.value = next;
|
||
if (next > 0) {
|
||
isPullActive.value = true;
|
||
// 阻止默认滚动(避免与列表滚动手势冲突)
|
||
if (typeof e.preventDefault === 'function') {
|
||
try { e.preventDefault(); } catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
function onTouchEnd() {
|
||
if (!isPullActive.value) {
|
||
pullStartY.value = null;
|
||
return;
|
||
}
|
||
// 2026-07-28 fix(revert): 去掉延迟切换定时器。transition 已移除,
|
||
// 释放后立即翻 viewMode,分支销毁重建即完成视觉切换;延迟 260ms 反而
|
||
// 让释放动作有"等一下才响应"的迟滞感。
|
||
isPullActive.value = false;
|
||
if (pullDistance.value >= PULL_EXPAND_THRESHOLD) {
|
||
isExpanded.value = true;
|
||
viewMode.value = 'expanded'; // 同时通知 v-else-if 切到放大分支
|
||
}
|
||
pullDistance.value = 0;
|
||
pullStartY.value = null;
|
||
}
|
||
|
||
// 2026-07-28: 上拉收起的 wrapper transform(scale 1.6 → 1.0,translateY 0 → -pullDistance)。
|
||
// 2026-07-28 fix(jump): 三段语义 ——
|
||
// 1) viewMode !== 'expanded':wrapper 整体回到 scale(1)(no-op),
|
||
// 此时 scroll-view 的卡片接管视觉(本身 scale(1) transform),无冲突
|
||
// 2) viewMode === 'expanded' && !isCollapsePullActive:wrapper 提供 scale(1.6)(锁定态),
|
||
// 内层 AssetCardPullExpand :is-expanded="false" 永远 scale(1),总视觉 = 1.6 × 1 = 1.6 ✓
|
||
// 3) viewMode === 'expanded' && isCollapsePullActive:wrapper 实时跟手 scale(s) translateY(-pull),
|
||
// s 从 1.6 线性降到 1.0,内层 scale(1),总视觉 = s × 1 = s(同 1.6→1.0 平滑收缩) ✓
|
||
// 关键差异:旧实现 !isCollapsePullActive → 'scale(1)' 会让视觉从 s 跳到 1.6(因为内层此时是 scale(1.6)),
|
||
// 释放瞬间 v-else-if 销毁又跳到 scale(1),双重跳变即用户报的"上拉跳一下"。
|
||
// 新实现把锁定态 scale(1.6) 显式交给 wrapper,内层永远 scale(1),释放时 wrapper 平滑回 scale(1.6) translateY(0),
|
||
// 配合 .pull-collapse-surface 的 CSS transition,跨分支销毁前视觉已经"回到锁定态"接缝更小。
|
||
const collapseCardTransform = computed(() => {
|
||
if (viewMode.value !== 'expanded') return 'scale(1)';
|
||
if (!isCollapsePullActive.value) return 'scale(1.6)';
|
||
const t = Math.min(1, Math.max(0, collapsePullDistance.value / PULL_MAX_DISTANCE));
|
||
const s = PULL_MAX_SCALE - t * (PULL_MAX_SCALE - 1); // 1.6 → 1.0
|
||
return `scale(${s.toFixed(3)}) translateY(${-collapsePullDistance.value}px)`;
|
||
});
|
||
|
||
// 2026-07-28: 上拉收起手势回调(对称镜像 onTouchStart/Move/End)。
|
||
// 仅在 expanded 分支挂载时触发(viewMode.value === 'expanded'),
|
||
// 节点销毁/创建的天然隔离使 down-pull 与 up-pull 不会同时处于激活态
|
||
// 2026-07-28 fix: 同 onTouchStart —— 不 eager 记录 startY,避免纯点击时
|
||
// 残留的 startY 在下一次 touchstart/touchmove 之间让 collapseCardTransform
|
||
// 重算,出现"点一下先缩小再弹回"。startY 在首个 touchmove 里懒记录。
|
||
function onCollapseTouchStart(e) {
|
||
if (!shouldHandleGesture.value) return;
|
||
if (viewMode.value !== 'expanded') return;
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
collapseStartY.value = null;
|
||
collapsePullDistance.value = 0;
|
||
isCollapsePullActive.value = false;
|
||
}
|
||
|
||
function onCollapseTouchMove(e) {
|
||
if (!shouldHandleGesture.value) return;
|
||
if (viewMode.value !== 'expanded') return;
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
const currentY = e.touches[0].pageY;
|
||
if (collapseStartY.value == null) {
|
||
collapseStartY.value = currentY;
|
||
}
|
||
const delta = currentY - collapseStartY.value;
|
||
if (delta >= 0) return; // 未真正上拉(向下/静止),不进入激活态
|
||
const next = Math.min(-delta * 0.6, PULL_MAX_DISTANCE);
|
||
collapsePullDistance.value = next;
|
||
if (next > 0) {
|
||
isCollapsePullActive.value = true;
|
||
// 阻止默认行为,避免与父级 mask/其他手势冲突
|
||
if (typeof e.preventDefault === 'function') {
|
||
try { e.preventDefault(); } catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
function onCollapseTouchEnd() {
|
||
if (!isCollapsePullActive.value) {
|
||
collapseStartY.value = null;
|
||
return;
|
||
}
|
||
// 2026-07-28 fix(revert): 去掉延迟切换定时器,释放后立即切分支。
|
||
isCollapsePullActive.value = false;
|
||
if (collapsePullDistance.value >= PULL_EXPAND_THRESHOLD) {
|
||
isExpanded.value = false;
|
||
viewMode.value = 'normal'; // 切回 normal 分支,scroll-view 重新挂载
|
||
}
|
||
collapsePullDistance.value = 0;
|
||
collapseStartY.value = null;
|
||
}
|
||
|
||
function onScroll(e) {
|
||
scrollTopRef.value = (e && e.detail && typeof e.detail.scrollTop === 'number')
|
||
? e.detail.scrollTop
|
||
: 0;
|
||
}
|
||
|
||
const isLenticularAsset = computed(() => {
|
||
if (craftConfirmMode.value) return false
|
||
const tags = assetData.value?.tags
|
||
if (Array.isArray(tags) && tags.includes(CRAFT_TAG_LENTICULAR)) return true
|
||
return false
|
||
})
|
||
|
||
const isLenticularDetail = ref(false)
|
||
|
||
const isCraftLenticular = computed(() => studioKindParam.value === STUDIO_LENTICULAR);
|
||
const craftCategoryLabel = computed(() => {
|
||
if (isCraftLenticular.value) return CRAFT_LENTICULAR_CN;
|
||
if (studioKindParam.value === STUDIO_LASER) return CRAFT_LASER_CARD_CN;
|
||
return craftFormData.value?.typeName || '星卡';
|
||
});
|
||
const craftAssetName = computed(() => craftFormData.value?.name || craftCategoryLabel.value);
|
||
const craftCreatorName = computed(() => '我');
|
||
const craftEarningsHint = computed(() => '21/时');
|
||
const craftMintDate = computed(() => {
|
||
const d = new Date();
|
||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||
const dd = String(d.getDate()).padStart(2, '0');
|
||
return `${d.getFullYear()}.${mm}.${dd}`;
|
||
});
|
||
const craftBlockPlaceholder = computed(() => '铸造后生成');
|
||
const craftHashPlaceholder = computed(() => '铸造后生成');
|
||
|
||
// 数据状态
|
||
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 userAvatarUrl = ref('');
|
||
const currentUserId = ref('');
|
||
const currentUserNickname = ref('');
|
||
|
||
// 加载当前用户信息
|
||
const loadCurrentUser = () => {
|
||
try {
|
||
// ★ 2026-07-27 refactor:委托 @/utils/getStoredUser,统一 JSON.parse 守卫
|
||
const userInfo = getStoredUser() || {};
|
||
userAvatarUrl.value = userInfo.avatar_url || '';
|
||
currentUserId.value = String(userInfo.uid || userInfo.user_id || '');
|
||
currentUserNickname.value = userInfo.nickname || '';
|
||
} catch (e) {
|
||
console.error('解析用户信息失败:', e);
|
||
}
|
||
};
|
||
|
||
// 点赞用户头像数据(最多6个,按椭圆排列,每个头像有独立的偏移量配置)
|
||
// 椭圆排列配置:从真实API加载后,按此配置映射
|
||
const ellipseConfig = [
|
||
{ ellipseX: -40, ellipseY: -40, size: 0.85 },
|
||
{ ellipseX: 40, ellipseY: -40, size: 0.85 },
|
||
{ ellipseX: -96, ellipseY: 0, size: 0.9 },
|
||
{ ellipseX: 120, ellipseY: -40, size: 0.9 },
|
||
{ ellipseX: 64, ellipseY: 32, size: 1 },
|
||
{ ellipseX: -16, ellipseY: 32, size: 1.15 }
|
||
];
|
||
|
||
// 点赞用户数据(从API加载)
|
||
const likedUsers = ref([]);
|
||
|
||
// 把 API 返回的原始点赞用户映射成展示用的头像数组(最多 6 个,按椭圆排列)
|
||
const mapLikersToDisplay = (users) => {
|
||
if (!Array.isArray(users) || !users.length) return [];
|
||
return users.slice(0, 6).map((user, index) => {
|
||
const config = ellipseConfig[index] || { ellipseX: 0, ellipseY: 0, size: 1 };
|
||
return {
|
||
avatar: user.avatar || '/static/sucai/default-avatar.png',
|
||
ellipseX: config.ellipseX,
|
||
ellipseY: config.ellipseY,
|
||
size: config.size
|
||
};
|
||
});
|
||
};
|
||
|
||
// 把 API 返回的原始点赞用户映射成弹窗用的完整列表
|
||
const mapLikersToModal = (users) => {
|
||
if (!Array.isArray(users)) return [];
|
||
return users.map(user => ({
|
||
userId: user.user_id,
|
||
nickname: user.nickname || '匿名用户',
|
||
avatar: user.avatar || '/static/sucai/default-avatar.png',
|
||
liked_at: user.liked_at || 0
|
||
}));
|
||
};
|
||
|
||
// 加载点赞用户头像
|
||
const loadLikedUsers = async (assetId) => {
|
||
if (!assetId) return;
|
||
try {
|
||
likedUsersLoading.value = true;
|
||
// 加载点赞用户
|
||
const res = await getAssetLikersApi(assetId, 20, 0);
|
||
if (res.code === 0 && res.data?.users) {
|
||
likedUsers.value = mapLikersToDisplay(res.data.users);
|
||
allLikedUsers.value = mapLikersToModal(res.data.users);
|
||
}
|
||
} catch (e) {
|
||
console.error('加载点赞用户失败:', e);
|
||
// 失败时使用空数组,让 v-if="likeCount > 0" 条件隐藏整个模块
|
||
likedUsers.value = [];
|
||
allLikedUsers.value = [];
|
||
} finally {
|
||
likedUsersLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// ---- 贴纸相关 ----
|
||
const activeStickers = ref([]);
|
||
const compositingStickers = ref(false);
|
||
|
||
function getStickerStyle(sticker) {
|
||
const ox = (sticker.pos_x != null ? sticker.pos_x : 0.5) * 100
|
||
const oy = (sticker.pos_y != null ? sticker.pos_y : 0.5) * 100
|
||
const rot = sticker.rotation != null ? sticker.rotation : 0
|
||
const scX = sticker.scale_x != null ? sticker.scale_x : 1
|
||
const scY = sticker.scale_y != null ? sticker.scale_y : 1
|
||
const op = sticker.opacity != null ? sticker.opacity : 1
|
||
return {
|
||
left: `${ox}%`,
|
||
top: `${oy}%`,
|
||
transform: `translate(-50%, -50%) translateZ(0) rotate(${rot}deg) scale(${scX}, ${scY})`,
|
||
opacity: op,
|
||
}
|
||
}
|
||
|
||
async function loadStickersForAsset(materialRelations) {
|
||
if (!materialRelations || !materialRelations.length) {
|
||
activeStickers.value = []
|
||
return
|
||
}
|
||
const stickers = relationsToStickers(materialRelations)
|
||
const withIds = stickers.map((s, i) => ({ ...s, id: `sticker_${i}` }))
|
||
activeStickers.value = withIds
|
||
}
|
||
|
||
async function exportCompositeImage() {
|
||
if (!coverUrl.value || !activeStickers.value.length) return null
|
||
compositingStickers.value = true
|
||
try {
|
||
const result = await composeStickers({
|
||
baseImageSrc: coverUrl.value,
|
||
stickers: activeStickers.value,
|
||
exportW: 450,
|
||
exportH: 600,
|
||
canvasId: 'stickerCompositCanvas',
|
||
})
|
||
return result
|
||
} catch (e) {
|
||
console.error('[asset-detail] sticker compose failed:', e)
|
||
return null
|
||
} finally {
|
||
compositingStickers.value = false
|
||
}
|
||
}
|
||
|
||
// 弹窗相关
|
||
const showLikeUsersModal = ref(false);
|
||
const likeUsersActiveTab = ref(0);
|
||
// 举报弹窗(asset-detail 内显式持有,与 ShareReportButtons 内的 modal 互不影响,可由本页面其他位置触发)
|
||
const showReportModal = ref(false);
|
||
|
||
// 举报提交回调(埋点/统计/事件总线用)
|
||
const handleReportSubmit = (payload) => {
|
||
console.log('[asset-detail] report submitted', payload);
|
||
};
|
||
|
||
// 全部点赞用户数据(从API加载)
|
||
const allLikedUsers = ref([]);
|
||
const likedUsersLoading = ref(false);
|
||
|
||
const handleLikeUsersTabChange = ({ tabIndex }) => {
|
||
likeUsersActiveTab.value = tabIndex;
|
||
};
|
||
|
||
// 拜访按钮(预拉他人作品数据)
|
||
const handleVisit = (user) => {
|
||
const api = getPreloadApi();
|
||
(api || uni).navigateTo({
|
||
url: `/pages/profile/hisWorks?userId=${user.userId}&nickname=${encodeURIComponent(user.nickname)}`
|
||
});
|
||
};
|
||
|
||
// 区块链编号和交易哈希的显示切换
|
||
const showBlockNumber = ref(false);
|
||
const showTxHash = ref(false);
|
||
|
||
// 倒计时
|
||
let countdownTimer = null;
|
||
|
||
// 倒计时状态
|
||
const countdowns = ref({});
|
||
|
||
// 计算剩余时间
|
||
const calculateRemainingTime = (item) => {
|
||
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.exhibition_expire_at) {
|
||
const now = Date.now();
|
||
const expireTime = new Date(item.exhibition_expire_at).getTime();
|
||
const remaining = expireTime - now;
|
||
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 = () => {
|
||
countdowns.value['currentAsset'] = calculateRemainingTime({ exhibition_expire_at: assetData.value.exhibition_expire_at });
|
||
};
|
||
|
||
const countdownText = computed(() => {
|
||
const countdown = countdowns.value['currentAsset'];
|
||
if (!countdown || countdown.expired) return '';
|
||
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}`;
|
||
});
|
||
|
||
// 格式化获取时间(字段名 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(() => {
|
||
return assetData.value.tx_hash || '未知';
|
||
});
|
||
|
||
// 隐藏交易哈希(保留前6位 + 6个*)
|
||
const hiddenTxHash = computed(() => {
|
||
const hash = assetData.value.tx_hash;
|
||
if (!hash) return '******';
|
||
if (hash.length <= 6) return hash + '******';
|
||
return `${hash.substring(0, 6)}******`;
|
||
});
|
||
|
||
// 隐藏区块链编号(保留前6位 + 6个*)
|
||
const hiddenBlockNumber = computed(() => {
|
||
const num = assetData.value.block_number;
|
||
if (!num) return '******';
|
||
const str = String(num);
|
||
if (str.length <= 6) return str + '******';
|
||
return `${str.substring(0, 6)}******`;
|
||
});
|
||
|
||
// 等级徽章图片
|
||
const gradeBadgeUrl = computed(() => {
|
||
const grade = assetData.value.grade;
|
||
const gradeMap = {
|
||
1: '/static/starbookcontent/grade/Ndengji.png',
|
||
2: '/static/starbookcontent/grade/Rdengji.png',
|
||
3: '/static/starbookcontent/grade/SRdengji.png',
|
||
4: '/static/starbookcontent/grade/SSRdengji.png',
|
||
5: '/static/starbookcontent/grade/URengji.png',
|
||
};
|
||
return gradeMap[grade] || '/static/starbookcontent/grade/Ndengji.png';
|
||
});
|
||
|
||
// 复制完整哈希
|
||
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 === 0 && 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 === 0 && res.data?.asset) {
|
||
const asset = res.data.asset;
|
||
assetData.value = asset;
|
||
// console.log('[asset-detail] loaded asset:', JSON.stringify({
|
||
// id: asset.id,
|
||
// cover_url: asset.cover_url,
|
||
// image_url: asset.image_url,
|
||
// asset_keys: Object.keys(asset).slice(0, 20)
|
||
// }));
|
||
isLiked.value = res.data.asset.is_liked || res.data.is_liked || false;
|
||
likeCount.value = asset.like_count || 0;
|
||
|
||
// 加载贴纸素材(从 material_relations 加载)
|
||
if (asset.material_relations || asset.materials) {
|
||
loadStickersForAsset(asset.material_relations || asset.materials)
|
||
}
|
||
|
||
if (asset.exhibition_expire_at) {
|
||
startCountdown();
|
||
}
|
||
|
||
// 异步加载封面图片,不阻塞页面渲染
|
||
getAssetCoverRealUrl(asset.cover_url).then(async (url) => {
|
||
coverUrl.value = url;
|
||
if (isLenticularAsset.value) {
|
||
// 参考 success.vue 逻辑:通过 bindAssetMaterialsApi 获取素材列表
|
||
let subjectUrl = url
|
||
let bgUrl = ''
|
||
// 调用素材接口获取完整的素材列表
|
||
try {
|
||
const materialsRes = await getAssetMaterialsApi(assetId)
|
||
if (materialsRes.code === 0 && materialsRes.data) {
|
||
const materialsList = Array.isArray(materialsRes.data) ? materialsRes.data : materialsRes.data.materials || []
|
||
// 找到 bg 和 main 素材
|
||
for (const mat of materialsList) {
|
||
if (mat.material_type === 'main' && mat.material_url_signed) {
|
||
subjectUrl = mat.material_url_signed
|
||
}
|
||
if (mat.material_type === 'bg' && mat.material_url_signed) {
|
||
bgUrl = mat.material_url_signed
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[asset-detail] 获取素材列表失败:', e)
|
||
}
|
||
lenticularLayers.value = bgUrl
|
||
? buildLenticularLayersTwo(bgUrl, subjectUrl)
|
||
: buildLenticularLayers(subjectUrl)
|
||
isLenticularDetail.value = true
|
||
}
|
||
}).catch(err => {
|
||
console.error('加载封面图片失败:', err);
|
||
coverUrl.value = ''; // 失败时不显示默认图片
|
||
});
|
||
// 异步加载点赞用户头像
|
||
loadLikedUsers(assetId);
|
||
} else {
|
||
throw new Error(res.message || '获取藏品详情失败');
|
||
}
|
||
} catch (err) {
|
||
console.error('loadData 出错:', err);
|
||
loadError.value = err.message || '加载失败,请重试';
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
// 倒计时
|
||
const startCountdown = () => {
|
||
updateCountdowns(); // 立即显示,不等待1秒
|
||
if (countdownTimer) clearInterval(countdownTimer);
|
||
countdownTimer = setInterval(() => {
|
||
updateCountdowns();
|
||
}, 1000);
|
||
};
|
||
|
||
// 把当前用户乐观插入到点赞用户列表(让「创作者信息模块」立刻反映本次点赞)
|
||
const prependCurrentUserToLikers = () => {
|
||
if (!userAvatarUrl.value) return;
|
||
const meAvatar = userAvatarUrl.value;
|
||
// 1) 顶部头像展示列表:去重后插到最前,重新应用椭圆配置
|
||
const nextDisplay = [
|
||
{ avatar: meAvatar },
|
||
...likedUsers.value.filter(u => u.avatar !== meAvatar)
|
||
];
|
||
likedUsers.value = mapLikersToDisplay(nextDisplay);
|
||
// 2) 弹窗用完整列表:去重后插到最前
|
||
const meRow = {
|
||
userId: currentUserId.value,
|
||
nickname: currentUserNickname.value || '我',
|
||
avatar: meAvatar,
|
||
liked_at: Date.now()
|
||
};
|
||
allLikedUsers.value = [
|
||
meRow,
|
||
...allLikedUsers.value.filter(u => u.avatar !== meAvatar)
|
||
];
|
||
};
|
||
|
||
// 点赞
|
||
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 {
|
||
const res = await likeAssetApi(assetData.value.asset_id);
|
||
isLiked.value = true;
|
||
// 优先用后端返回的最新 like_count;没有则本地 +1
|
||
const serverCount = res?.data?.like_count;
|
||
likeCount.value = typeof serverCount === 'number' ? serverCount : likeCount.value + 1;
|
||
// 乐观更新「创作者信息模块」——立刻把当前用户头像插到第一位
|
||
prependCurrentUserToLikers();
|
||
// 后台静默拉取一次真实列表,保证后续顺序、昵称、avatar URL 与服务端一致
|
||
loadLikedUsers(assetData.value.asset_id);
|
||
// }
|
||
// 通知展馆页面更新点赞数
|
||
uni.$emit('assetLikeChanged', {
|
||
asset_id: assetData.value.asset_id,
|
||
like_count: likeCount.value,
|
||
is_liked: isLiked.value
|
||
});
|
||
} catch (err) {
|
||
const errMsg = err?.message || '';
|
||
const errData = err?.data?.message || '';
|
||
// 显示后端返回的实际错误信息
|
||
const showMsg = errData || errMsg || '点赞失败';
|
||
uni.showToast({ title: showMsg, icon: 'none', duration: 2000 });
|
||
console.error('点赞失败:', err);
|
||
} finally {
|
||
liking.value = false;
|
||
}
|
||
};
|
||
|
||
const loadCraftConfirm = () => {
|
||
loading.value = false;
|
||
loadError.value = '';
|
||
try {
|
||
const formStr = readDraft('castlove_form_data');
|
||
if (formStr) {
|
||
craftFormData.value = JSON.parse(formStr);
|
||
}
|
||
const selected = readDraft('craft_selected_image') || '';
|
||
craftCoverUrl.value = selected;
|
||
if (isCraftLenticular.value) {
|
||
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY);
|
||
if (raw) {
|
||
const p = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||
lenticularLayers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[asset-detail] craft confirm', e);
|
||
loadError.value = '加载确认页失败';
|
||
}
|
||
};
|
||
|
||
const handleCraftMint = async () => {
|
||
if (craftMinting.value) return;
|
||
const imagePath =
|
||
isCraftLenticular.value
|
||
? lenticularLayers.value.find((l) => l.id === 'mid')?.src || craftCoverUrl.value
|
||
: craftCoverUrl.value;
|
||
if (!imagePath) {
|
||
uni.showToast({ title: '缺少作品图', icon: 'none' });
|
||
return;
|
||
}
|
||
const bgImagePath = isCraftLenticular.value
|
||
? lenticularLayers.value.find((l) => l.id === 'base')?.src || ''
|
||
: undefined;
|
||
craftMinting.value = true;
|
||
uni.showLoading({ title: '铸造中…', mask: true });
|
||
try {
|
||
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
|
||
uni.hideLoading();
|
||
uni.navigateTo({ url: '/pages/castlove/success' });
|
||
} catch (e) {
|
||
uni.hideLoading();
|
||
uni.showToast({ title: e.message || '铸造失败', icon: 'none' });
|
||
} finally {
|
||
craftMinting.value = false;
|
||
}
|
||
};
|
||
|
||
// 返回逻辑
|
||
const handleBack = () => {
|
||
// 获取页面栈
|
||
const pages = getCurrentPages();
|
||
if (fromParam.value === 'castlove') {
|
||
uni.reLaunch({ url: '/pages/castlove/mall' });
|
||
} else if (pages.length > 1) {
|
||
// 有上一页,执行返回
|
||
uni.navigateBack();
|
||
} else {
|
||
// 没有上一页,跳转到square页面
|
||
uni.reLaunch({
|
||
url: "/pages/square/square",
|
||
});
|
||
}
|
||
};
|
||
|
||
// 点赞轮询定时器:覆盖跨用户/跨设备「实时」更新(后端暂无点赞 WS)
|
||
let likePollingTimer = null;
|
||
const LIKE_POLLING_INTERVAL = 30000; // 20s
|
||
|
||
// 启动点赞轮询
|
||
const startLikePolling = () => {
|
||
stopLikePolling();
|
||
if (!assetData.value?.asset_id) return;
|
||
likePollingTimer = setInterval(() => {
|
||
// 页面被切到后台时轮询其实也会跑,但 uni-app 在 H5/App 下 setInterval 仍触发;
|
||
// 定时器在 onHide 清理,onShow 重新启动,天然只在可见时拉取
|
||
if (assetData.value?.asset_id) {
|
||
loadLikedUsers(assetData.value.asset_id);
|
||
}
|
||
}, LIKE_POLLING_INTERVAL);
|
||
};
|
||
|
||
const stopLikePolling = () => {
|
||
if (likePollingTimer) {
|
||
clearInterval(likePollingTimer);
|
||
likePollingTimer = null;
|
||
}
|
||
};
|
||
|
||
// 其他页面触发的点赞变更事件:保持本页面与全局一致
|
||
const handleAssetLikeChangedExternal = (data) => {
|
||
if (!data || String(data.asset_id) !== String(assetData.value?.asset_id)) return;
|
||
if (typeof data.like_count === 'number') likeCount.value = data.like_count;
|
||
if (typeof data.is_liked === 'boolean') isLiked.value = data.is_liked;
|
||
// 拉取最新点赞用户列表,更新「创作者信息模块」
|
||
if (assetData.value?.asset_id) {
|
||
loadLikedUsers(assetData.value.asset_id);
|
||
}
|
||
};
|
||
|
||
onLoad((options) => {
|
||
assetIdParam.value = options?.asset_id || '';
|
||
orderIdParam.value = options?.order_id || '';
|
||
fromParam.value = options?.from || '';
|
||
studioKindParam.value = options?.studio_kind || '';
|
||
loadCurrentUser();
|
||
// 订阅跨页面点赞事件
|
||
uni.$on('assetLikeChanged', handleAssetLikeChangedExternal);
|
||
|
||
// 触发每日首次浏览藏品任务(spec §3 / Phase F.2)
|
||
// 后端幂等:completed/claimed 状态时 skip;本地不缓存避免跨天去重逻辑复杂
|
||
const starIdBrowse = uni.getStorageSync('star_id') || 1;
|
||
reportEvent('daily_browse_asset', starIdBrowse).catch(err => {
|
||
console.error('上报浏览事件失败:', err);
|
||
});
|
||
|
||
// craft_confirm 模式:不需要调用 loadData,而是加载本地表单数据
|
||
if (fromParam.value === 'craft_confirm') {
|
||
craftConfirmMode.value = true;
|
||
loadCraftConfirm();
|
||
}
|
||
});
|
||
|
||
onShow(() => {
|
||
const currentKey = `${assetIdParam.value}_${orderIdParam.value}`;
|
||
|
||
// 如果参数变化了,或者是首次加载,则重新加载数据
|
||
if ((assetIdParam.value || orderIdParam.value) && currentKey !== lastLoadKey.value) {
|
||
lastLoadKey.value = currentKey;
|
||
loadData();
|
||
}
|
||
|
||
// 页面每次回到前台都重新拉一次点赞列表 —— 兜底「创作者信息模块」时效
|
||
if (assetData.value?.asset_id && !craftConfirmMode.value) {
|
||
loadLikedUsers(assetData.value.asset_id);
|
||
// startLikePolling();
|
||
}
|
||
});
|
||
|
||
onHide(() => {
|
||
stopLikePolling();
|
||
// 2026-07-27 refactor: 页面切到后台时清掉手势中间态,避免回到前台时还残留 pullDistance / isPullActive
|
||
pullStartY.value = null;
|
||
pullDistance.value = 0;
|
||
isPullActive.value = false;
|
||
// 2026-07-28: 同步清掉上拉收起的中间态
|
||
collapseStartY.value = null;
|
||
collapsePullDistance.value = 0;
|
||
isCollapsePullActive.value = false;
|
||
isExpanded.value = false;
|
||
viewMode.value = 'normal';
|
||
});
|
||
|
||
onUnload(() => {
|
||
// 页面被关闭/销毁:清理轮询和事件订阅,避免泄漏到下一页或后台
|
||
stopLikePolling();
|
||
uni.$off('assetLikeChanged', handleAssetLikeChangedExternal);
|
||
// 重置手势状态,避免下一次 mount 后出现"上次残留"假象
|
||
pullStartY.value = null;
|
||
pullDistance.value = 0;
|
||
isPullActive.value = false;
|
||
// 2026-07-28: 同步重置上拉收起手势状态
|
||
collapseStartY.value = null;
|
||
collapsePullDistance.value = 0;
|
||
isCollapsePullActive.value = false;
|
||
isExpanded.value = false;
|
||
viewMode.value = 'normal';
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
if (countdownTimer) clearInterval(countdownTimer);
|
||
stopLikePolling();
|
||
uni.$off('assetLikeChanged', handleAssetLikeChangedExternal);
|
||
// Vue 层卸载兜底
|
||
pullStartY.value = null;
|
||
pullDistance.value = 0;
|
||
isPullActive.value = false;
|
||
// 2026-07-28: 同步重置上拉收起手势状态
|
||
collapseStartY.value = null;
|
||
collapsePullDistance.value = 0;
|
||
isCollapsePullActive.value = false;
|
||
isExpanded.value = false;
|
||
});
|
||
</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: 80rpx; */
|
||
/* #ifdef APP-PLUS */
|
||
/* App 通用(兜底) */
|
||
top: 96rpx;
|
||
/* #endif */
|
||
/* #ifdef H5 */
|
||
/* H5 使用普通高度 */
|
||
top: 16rpx;
|
||
/* #endif */
|
||
left: 32rpx;
|
||
right: 32rpx;
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: self-start;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.back-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.back-btn:active {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.back-icon {
|
||
font-size: 48rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
/* 加载/错误 */
|
||
.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: 'yt', 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: 'yt', 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 {
|
||
min-height: 90%;
|
||
padding: 200rpx 40rpx 80rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0;
|
||
}
|
||
|
||
/* 卡片区域 */
|
||
.card-section {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
overflow: visible;
|
||
}
|
||
|
||
.card-wrapper {
|
||
position: relative;
|
||
width: 352rpx;
|
||
height: 552rpx;
|
||
margin-bottom: 32rpx;
|
||
transform-origin: center center;
|
||
perspective: 800rpx;
|
||
transform-style: preserve-3d;
|
||
overflow: visible;
|
||
}
|
||
|
||
/* 仅 auto 模式(光栅自动摆动时)启用翻转动画;用户手动控制时通过移除 class 关闭 */
|
||
.card-wrapper--auto-flip {
|
||
animation: card-3d-flip 15s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes card-3d-flip {
|
||
0% {
|
||
transform: rotateY(0deg);
|
||
}
|
||
|
||
20% {
|
||
transform: rotateY(-30deg);
|
||
}
|
||
|
||
50% {
|
||
transform: rotateY(30deg);
|
||
}
|
||
|
||
80% {
|
||
transform: rotateY(0deg);
|
||
}
|
||
|
||
100% {
|
||
transform: rotateY(0deg);
|
||
}
|
||
}
|
||
|
||
/* .card-wrapper--lenticular {
|
||
width: 520rpx;
|
||
height: 680rpx;
|
||
} */
|
||
|
||
.detail-lenticular-slot {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
width: 88%;
|
||
height: 96%;
|
||
border-radius: 48rpx;
|
||
/* transform: translate(-50%, -50%) rotate(-10deg); */
|
||
transform: translate(-50%, -50%);
|
||
z-index: 2;
|
||
/* overflow: hidden; */
|
||
}
|
||
|
||
.detail-lenticular-card {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.card-image {
|
||
width: 88%;
|
||
height: 96%;
|
||
left: 6%;
|
||
top: 1.5%;
|
||
border-radius: 48rpx;
|
||
transform-origin: center center;
|
||
position: absolute;
|
||
z-index: 3;
|
||
/* overflow: hidden; */
|
||
/* transform: rotate(-10deg); */
|
||
}
|
||
|
||
.card-badge {
|
||
position: absolute;
|
||
top: -8rpx;
|
||
left: -56rpx;
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
z-index: 4;
|
||
/* transform: rotate(-10deg); */
|
||
}
|
||
|
||
.card-frame {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
z-index: 2;
|
||
/* transform: rotate(-10deg); */
|
||
|
||
}
|
||
|
||
.card-sticker {
|
||
position: absolute;
|
||
z-index: 5;
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
pointer-events: none;
|
||
transform-origin: center center;
|
||
transform-style: preserve-3d;
|
||
will-change: transform;
|
||
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.35));
|
||
}
|
||
|
||
.export-canvas {
|
||
position: fixed;
|
||
left: -9999px;
|
||
top: -9999px;
|
||
}
|
||
|
||
.card-meta-row {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 24rpx;
|
||
}
|
||
|
||
/* 点赞 */
|
||
.like-area {
|
||
display: flex;
|
||
align-items: center;
|
||
background: linear-gradient(to bottom right,
|
||
#F0E4B1 0%,
|
||
#F08399 50%,
|
||
#B94E73 100%);
|
||
border-radius: 999rpx;
|
||
box-shadow:
|
||
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
|
||
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
|
||
padding: 10rpx 28rpx;
|
||
}
|
||
|
||
.like-area:active {
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.like-icon,
|
||
.heart-icon,
|
||
.crystal-icon {
|
||
width: 44rpx;
|
||
height: 44rpx;
|
||
margin-right: 10rpx;
|
||
}
|
||
|
||
.like-num {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
font-family: 'yt', sans-serif;
|
||
font-variant-numeric: tabular-nums;
|
||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||
}
|
||
|
||
/* 收益 */
|
||
.earnings-area {
|
||
display: flex;
|
||
align-items: center;
|
||
background: linear-gradient(to bottom right,
|
||
#F0E4B1 0%,
|
||
#F08399 50%,
|
||
#B94E73 100%);
|
||
border-radius: 999rpx;
|
||
box-shadow:
|
||
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
|
||
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
|
||
padding: 10rpx 28rpx;
|
||
}
|
||
|
||
.earnings-text {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
font-family: 'yt', sans-serif;
|
||
font-variant-numeric: tabular-nums;
|
||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||
}
|
||
|
||
/* 倒计时 */
|
||
.countdown-area {
|
||
display: flex;
|
||
align-items: center;
|
||
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
|
||
border-radius: 999rpx;
|
||
padding: 10rpx 28rpx;
|
||
}
|
||
|
||
.countdown-pill {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
font-family: 'yt', sans-serif;
|
||
font-variant-numeric: tabular-nums;
|
||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.countdown-val {
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
font-family: 'yt', sans-serif;
|
||
font-variant-numeric: tabular-nums;
|
||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||
}
|
||
|
||
/* 信息行(持有人 + 铸造时间) */
|
||
.info-row {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: stretch;
|
||
justify-content: center;
|
||
gap: 20rpx;
|
||
padding: 24rpx 32rpx;
|
||
border-radius: 24rpx;
|
||
margin-bottom: 40rpx;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.info-col {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.info-item {
|
||
display: flex;
|
||
gap: 8rpx;
|
||
width: 416rpx;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.info-label {
|
||
font-size: 32rpx;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-family: 'yt', sans-serif;
|
||
}
|
||
|
||
.info-value {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #ffffff;
|
||
font-family: 'yt', sans-serif;
|
||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.7);
|
||
}
|
||
|
||
.info-avatar {
|
||
width: 48rpx;
|
||
height: 48rpx;
|
||
border-radius: 50%;
|
||
margin-left: 8rpx;
|
||
}
|
||
|
||
.info-nickname {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
border-radius: 24rpx;
|
||
}
|
||
|
||
/* 点赞用户头像区域 */
|
||
.liked-users-area {
|
||
position: relative;
|
||
flex: 1;
|
||
height: 184rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.liked-user-avatar {
|
||
position: absolute;
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
border-radius: 50%;
|
||
border: 3rpx solid #F0E4B1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.liked-user-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 创作者信息模块 */
|
||
.creator-section {
|
||
width: calc(100% - 80rpx);
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 24rpx;
|
||
padding: 16rpx;
|
||
background: url('/static/rank/activity-support-icon/beijingkuang.png') no-repeat center center;
|
||
background-size: 105% 120%;
|
||
border-radius: 24rpx;
|
||
margin-bottom: 64rpx;
|
||
}
|
||
|
||
.creator-avatar {
|
||
width: 100rpx;
|
||
height: 100rpx;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.creator-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.creator-title {
|
||
font-size: 28rpx;
|
||
color: #fff;
|
||
font-family: 'yt', sans-serif;
|
||
}
|
||
|
||
.creator-card {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
border-radius: 16rpx;
|
||
}
|
||
|
||
.creator-tip {
|
||
font-size: 32rpx;
|
||
color: #ffcc00;
|
||
font-family: 'yt', sans-serif;
|
||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.7);
|
||
}
|
||
|
||
.creator-preview {
|
||
width: 128rpx;
|
||
height: 80rpx;
|
||
/* border-radius: 12rpx; */
|
||
}
|
||
|
||
/* 链上数据 */
|
||
.chain-section {
|
||
width: calc(100% - 80rpx);
|
||
background: url('/static/rank/activity-support-icon/beijingkuang.png') no-repeat center center;
|
||
background-size: 105% 120%;
|
||
border-radius: 24rpx;
|
||
padding: 30rpx 16rpx;
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 24rpx;
|
||
}
|
||
|
||
.chain-left {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.chain-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.chain-logo {
|
||
width: 156rpx;
|
||
height: 156rpx;
|
||
}
|
||
|
||
.chain-label {
|
||
font-size: 28rpx;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-family: 'yt', sans-serif;
|
||
flex-shrink: 0;
|
||
margin-right: 20rpx;
|
||
}
|
||
|
||
.chain-value {
|
||
font-size: 28rpx;
|
||
color: #ffffff;
|
||
font-family: 'yt', sans-serif;
|
||
flex: 1;
|
||
word-break: break-all;
|
||
display: flex
|
||
}
|
||
|
||
.chain-value-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
width: 240rpx;
|
||
}
|
||
|
||
.toggle-btn {
|
||
padding: 4rpx 8rpx;
|
||
}
|
||
|
||
.toggle-icon {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
}
|
||
|
||
.chain-hash {
|
||
color: rgba(255, 255, 255, 0.75);
|
||
}
|
||
|
||
/* 链上哈希遮罩层 */
|
||
.txhash-mask {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 24rpx;
|
||
z-index: 999;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 64rpx;
|
||
|
||
}
|
||
|
||
.txhash-popup {
|
||
border-radius: 24rpx;
|
||
padding: 32rpx;
|
||
background: url('/static/rank/activity-support-icon/beijingkuang.png') no-repeat center center;
|
||
background-size: 105% 120%;
|
||
}
|
||
|
||
.txhash-popup-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.txhash-popup-title {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
font-family: 'yt', sans-serif;
|
||
}
|
||
|
||
.txhash-popup-close {
|
||
padding: 8rpx;
|
||
}
|
||
|
||
.close-icon {
|
||
width: 40rpx;
|
||
height: 40rpx;
|
||
}
|
||
|
||
.txhash-popup-content {
|
||
display: block;
|
||
font-size: 26rpx;
|
||
color: #fff;
|
||
font-family: 'yt', sans-serif;
|
||
word-break: break-all;
|
||
line-height: 1.6;
|
||
background-color: rgba(0, 0, 0, 0.2);
|
||
padding: 20rpx;
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
/* 点赞用户列表 */
|
||
.like-users-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24rpx;
|
||
padding: 16rpx;
|
||
}
|
||
|
||
.like-user-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
padding: 0 16rpx;
|
||
background: linear-gradient(to bottom right,
|
||
#F0E4B1 0%,
|
||
#F08399 50%,
|
||
#B94E73 100%);
|
||
border-radius: 999rpx;
|
||
box-shadow:
|
||
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
|
||
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
.like-user-avatar {
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.like-user-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin: 0 24rpx;
|
||
}
|
||
|
||
.like-user-name {
|
||
font-size: 28rpx;
|
||
color: #fff;
|
||
font-family: 'yt', sans-serif;
|
||
}
|
||
|
||
.like-user-time {
|
||
font-size: 24rpx;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
font-family: 'yt', sans-serif;
|
||
}
|
||
|
||
/* 拜访按钮 - 只显示图标 */
|
||
.visit-button {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
padding: 8rpx;
|
||
flex-direction: column;
|
||
}
|
||
|
||
|
||
.visit-icon {
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
margin-bottom: 8rpx;
|
||
/* transform: scale(1.2); */
|
||
}
|
||
|
||
.visit-text {
|
||
color: #FFFFFF;
|
||
font-size: 16rpx;
|
||
}
|
||
|
||
/* 离屏 canvas(放 page 级,uni-app 组件内 canvas 不被 createCanvasContext 识别会触发 'reading ready')。
|
||
⚠️ 不能 display:none(让 layout=0,native canvas surface=0),改用负坐标离屏 */
|
||
.share-canvas {
|
||
position: fixed;
|
||
left: -9999px;
|
||
top: -9999px;
|
||
width: 750px;
|
||
height: 1334px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 放大态容器:全屏居中卡片,完全脱离 scroll-view */
|
||
.pull-expanded-view {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 50;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 2026-07-27 refactor (Finding A): 手势/transform 表面,与 .card-wrapper 分离,
|
||
避免 card-3d-flip 动画的 transform 覆盖父级 :style.transform;仅在正常态存在 */
|
||
.card-pull-surface {
|
||
position: relative;
|
||
display: block;
|
||
transform-origin: center top;
|
||
/* 2026-07-28 fix(revert): 不加 transition —— 拉动过程必须严格跟手,
|
||
任何 transition 都会让 transform 插值 250ms,手感变成"橡皮筋/回弹"。
|
||
释放后的视觉状态由 v-if/v-else-if 分支切换决定(节点销毁重建),
|
||
不需要 CSS 过渡跨越这个边界。 */
|
||
}
|
||
|
||
/* 2026-07-28: 上拉收起手势 surface,镜像 .card-pull-surface 在 normal 分支的角色
|
||
(触摸事件 + 跟手 transform)。仅在 expanded 分支挂载。
|
||
2026-07-28 fix(jump): 加 0.15s transition(旧版本 0.25s 让跟手变橡皮筋,这次砍到 0.15s):
|
||
- 拉动过程 touchmove 每 16ms 一帧,0.15s ≈ 9 帧,跟手几乎无感(浏览器从当前插值位置起步,
|
||
新 transition 覆盖旧 transition,视觉上仍紧贴手指)
|
||
- 释放瞬间 collapseCardTransform 从 scale(s) translateY(-pull) → scale(1.6) translateY(0),
|
||
transition 把这个"回弹到锁定态"的过程从瞬时跳变变成 150ms 平滑插值,吃掉用户报的"跳一下"
|
||
- 跨阈值(viewMode=normal)路径下,wrapper 销毁,新分支卡片接管,过渡发生在销毁前的最后
|
||
一帧,残余的 transition 不影响新节点 */
|
||
.pull-collapse-surface {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transform-origin: center center;
|
||
will-change: transform;
|
||
transition: transform 0.15s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||
}
|
||
|
||
/* 2026-07-27: 卡片下方四个 sibling 模块(card-meta-row / info-row / creator-section / chain-section)
|
||
的统一容器,在父级 asset-detail.vue 的 <script setup> 里通过 siblingFadeOpacity 实时绑 opacity,
|
||
与 pullDistance 同步淡出。transition 已经在 inline :style 声明,这里仅补 will-change 提示 GPU 合成 */
|
||
.card-siblings {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
will-change: opacity;
|
||
}
|
||
|
||
/* 2026-07-28 fix (sibling fade-in): expanded 态下的 .card-siblings overlay,
|
||
父级 detail-container 顶层渲染,仅 expanded 态挂载。opacity 绑 siblingFadeOpacity,
|
||
上拉收起过程中 0→1 渐显,接住原本因 scroll-view 销毁而丢失的渐显视觉。
|
||
position: fixed + bottom: 0 让 overlay 始终贴底;跟手过程 wrapper surface 整体
|
||
translateY(-pullDistance) 把卡片往上推,overlay 自然落在腾出的下方空间。
|
||
pointer-events: none 让上拉手势从 overlay 范围也能穿透到 .pull-collapse-surface;
|
||
单独给 .like-area/.creator-section/.toggle-btn/.chain-hash 开 auto,让交互元素仍可点 */
|
||
.card-siblings-expanded-overlay {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 60;
|
||
padding: 40rpx;
|
||
background: transparent;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.card-siblings-expanded-overlay .like-area,
|
||
.card-siblings-expanded-overlay .creator-section,
|
||
.card-siblings-expanded-overlay .toggle-btn,
|
||
.card-siblings-expanded-overlay .chain-hash {
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* 2026-07-27 refactor (Finding B): 提示文案由父级渲染,组件不再持有
|
||
2026-07-28 fix: 改 fixed 定位脱离滚动容器与 pull transform,固定在 header(top:96rpx, z-index:100)
|
||
下方;z-index 95 保证盖住卡片和滚动内容,但仍在 header 之下 */
|
||
.pull-hint {
|
||
position: fixed;
|
||
top: 220rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 95;
|
||
font-size: 28rpx;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
font-family: 'yt', sans-serif;
|
||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.6);
|
||
pointer-events: none;
|
||
}
|
||
</style>
|