topfans/frontend/pages/asset-detail/asset-detail.vue
2026-07-29 16:03:33 +08:00

2208 lines
66 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/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>