Compare commits

...

2 Commits

Author SHA1 Message Date
zerosaturation
128a12fa03 fix(asset-detail): fade in sibling modules during up-pull collapse
The .card-siblings wrapper lives inside <scroll-view v-else>, so when
viewMode flips to 'expanded' the wrapper is destroyed and the
siblingFadeOpacity binding has no element to animate. Up-pull therefore
shows nothing fading in even though the computed correctly returns
0 -> 1.

Add a top-level overlay that mirrors the same four modules
(card-meta-row, info-row, creator-section, chain-section) and binds
its opacity to siblingFadeOpacity. The overlay is rendered only when
viewMode === 'expanded', positioned fixed at the bottom with z-index 60
(above the expanded card at 50, below the header at 100), and uses
pointer-events: none on the wrapper so the up-pull gesture from the
lower viewport can still reach .pull-collapse-surface; interactive
children re-enable pointer-events: auto.

The original .card-siblings inside <scroll-view> is untouched and
still drives the down-pull fade-out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:56:45 +08:00
zerosaturation
3d984f19cd feat(asset-detail): pull-down/up card gestures with smooth follow
Add AssetCardPullExpand component for locked-state display and wire
up/down gesture symmetry on the asset detail page.

Down-pull at top of list:
- card scales 1.0 -> 1.6 with translateY follow, sibling modules fade
  out, hint text '下拉查看大图' -> '松手查看大图' past 80rpx
- on release past threshold, viewMode flips to expanded

Up-pull in expanded state:
- card shrinks 1.6 -> 1.0 with translateY follow, sibling modules fade
  back in, hint text '上拉收起' -> '松手收起' past 80rpx
- on release past threshold, viewMode flips back to normal
- the locked scale(1.6) lives on the wrapper so the release animation
  stays single-jump, not dual-jump

Implementation:
- AssetCardPullExpand renders the card at scale(1) and exposes isExpanded
  for the parent's locked-state toggle (showMask prop gates the legacy
  full-screen tap-to-collapse overlay)
- both gesture surfaces use lazy startY capture in onMove to avoid
  tap-induced jumps; no CSS transition on the wrapper during active pull
- 0.15s transform transition smooths the release interpolation only
- header bar and report/share buttons stay visible in both states;
  v-if/v-else-if/v-else chain (loading -> error -> expanded -> scroll-view)
  preserved; existing 8 card class names and parent CSS untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:13:39 +08:00
4 changed files with 1940 additions and 34 deletions

View File

@ -0,0 +1,718 @@
# 藏品详情页下拉放大卡片实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 `frontend/pages/asset-detail/asset-detail.vue` 实现“下拉放大预览大图”交互:下拉过程中只保留卡片、顶部返回键、分享和举报按钮,其它模块隐藏,松手后保持 1.6 倍放大态,点击背景收起。
**Architecture:** 抽出独立组件 `frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue` 承担触摸手势与放大态;`asset-detail.vue` 沿用顶层 `v-if / v-else-if / scroll-view v-else` 链,新增 `v-else-if="viewMode === 'expanded'"` 分支挂放大态组件,正常态一字不改。
**Tech Stack:** uni-app 3.x + Vue 3 Composition API + `<script setup>` + 移动端原生 touch 事件。
---
## 全局约束
- **Vue 3 组合式 API**`AssetCardPullExpand.vue` 必须使用 `<script setup>`,不写 `this`
- **条件编译**:所有与原生 API / 平台差异相关代码用 `// #ifdef APP-PLUS … // #endif`;禁止 `if (typeof plus !== 'undefined')` 兜底。
- **DOM 一致性硬约束**`AssetCardPullExpand.vue` 内层 `.card-wrapper` 区块的 class 名(`card-wrapper` / `card-wrapper--lenticular` / `card-frame` / `card-image` / `card-badge` / `card-sticker` / `detail-lenticular-slot` / `detail-lenticular-card`)、嵌套顺序、属性名(`@mode-change` / `gyro-source` / `tilt-hint-text` / `:shimmer-mid-opacity` / `:simulate-tilt-from-normalized` / `:layers` / `:transforms`)必须与 `asset-detail.vue` 第 45-63 行**逐字一致**。
- **组件 props-only**:组件内部不调用 `utils/api.js` / `utils/task-api.js` / `utils/assetImageHelper.js`,不读 `uni.getStorageSync` / `getStoredUser()`,不访问 Vuex卡片所有数据由父级以 props 传入。
- **样式不重复声明**:组件 `<style scoped>` 中不允许出现 `.card-wrapper` / `.card-frame` / `.card-image` / `.card-badge` / `.card-sticker` / `.detail-lenticular-slot` / `.detail-lenticular-card` 的样式块;这些继续由 `asset-detail.vue` 现有 `<style scoped>` 提供。**唯一例外**`.card-wrapper` 的尺寸/transform 三行(`width: 352rpx; height: 552rpx; transform-origin: center center;`)写在组件内,三行之外**不得**补任何属性。
- **v-if/v-else 链顺序**`asset-detail.vue` 顶层必须按 `v-if="loading"``v-else-if="loadError && !craftConfirmMode"``v-else-if="viewMode === 'expanded'"``scroll-view v-else` 排列。
- **Header 始终可见**:顶部返回键、`ShareReportButtons`、弹窗、离屏 canvas 都挂在 `<view class="detail-container">` 下,不被 `viewMode` 影响。
- **不在 `unpackage/dist/` 改动**`frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue` 是源码,不影响)。
---
## 文件结构
```
frontend/
├── components/
│ └── AssetCardPullExpand/
│ └── AssetCardPullExpand.vue # 新增:独立放大组件
└── pages/
└── asset-detail/
└── asset-detail.vue # 修改:加 v-else-if 视图分支 + viewMode ref + .pull-expanded-view 容器
```
不修改:
- `frontend/components/lenticular/LenticularCard.vue`
- `frontend/pages/components/ShareReportButtons.vue`
- `frontend/pages/components/ReportModal.vue`
- `frontend/pages/components/LikeUsersModal.vue`
- `frontend/composables/useLenticularCraftTiltPreview.js`
- `frontend/utils/sticker-compositor.js`
- `frontend/utils/castloveMintForm.js`
- `frontend/utils/getStoredUser.js`
- 后端 Go 代码、数据库 schema、API 文档
---
## 任务列表
### Task 1: 新建 `AssetCardPullExpand.vue` 骨架与 props
**Files:**
- Create: `frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue`
**Interfaces:**
- Consumes: 无(独立文件)
- Produces: 一个 Vue 3 SFC 组件,导出 props / events / 内层 DOM 结构(与 `asset-detail.vue` 第 45-63 行 `.card-wrapper` 区块一致)
**Step 1: 创建组件目录与文件骨架**
创建文件 `frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue`,写入以下内容(`<script setup>` + props + template + style 三行尺寸):
```vue
<template>
<view class="pull-expand-root" :class="{ 'is-expanded': isExpanded }">
<view
class="pull-card-touch"
:style="{ transform: cardTransform }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<!-- 内层卡片:与 asset-detail.vue 第 45-63 行原 .card-wrapper 区块逐字一致 -->
<view class="card-wrapper" :class="[
{ 'card-wrapper--lenticular': isLenticular },
cardAnimClass,
]">
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
<view v-if="isLenticular" class="detail-lenticular-slot">
<LenticularCard
class="detail-lenticular-card"
:layers="lenticularLayers"
:transforms="layerTransforms"
gyro-source="simulation"
:tilt-hint-text="tiltHintText"
:shimmer-mid-opacity="shimmerMidOpacity"
: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 stickers"
:key="sticker.id"
class="card-sticker"
:src="sticker.src"
mode="aspectFit"
:style="getStickerStyle(sticker)"
/>
</view>
</view>
<text v-if="isPullActive && hintText" class="pull-hint">{{ hintText }}</text>
<view v-if="isExpanded" class="pull-mask" @tap="onMaskTap" />
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
// === 内部状态 ===
const pullStartY = ref(null);
const pullDistance = ref(0);
const isPullActive = ref(false);
const isExpanded = ref(false);
// === Props(全部由父级注入) ===
const props = defineProps({
coverUrl: { type: String, default: '' },
isLenticular: { type: Boolean, default: false },
lenticularLayers: { type: Array, default: () => [] },
layerTransforms: { type: Object, default: () => ({}) },
simulate: { type: Object, default: null },
tiltHintText: { type: String, default: '倾斜手机查看光栅效果' },
shimmerMidOpacity: { type: Number, default: 0.16 },
gradeBadgeUrl: { type: String, default: '' },
stickers: { type: Array, default: () => [] },
cardAnimClass: { type: String, default: '' },
expandThreshold: { type: Number, default: 80 },
maxScale: { type: Number, default: 1.6 },
maxPullDistance: { type: Number, default: 240 },
disabled: { type: Boolean, default: false },
});
// === Events ===
const emit = defineEmits(['pull-expand-change']);
// === Computed ===
// #ifdef APP-PLUS
const isAppPlus = true;
// #else
const isAppPlus = false;
// #endif
const shouldHandleGesture = computed(() => !props.disabled && isAppPlus);
const cardTransform = computed(() => {
if (isExpanded.value) {
return `scale(${props.maxScale})`;
}
if (isPullActive.value) {
const t = Math.min(props.maxPullDistance, Math.max(0, pullDistance.value)) / props.maxPullDistance;
const s = 1 + t * (props.maxScale - 1);
return `scale(${s.toFixed(3)})`;
}
return 'scale(1)';
});
const hintText = computed(() => {
if (isExpanded.value) return '';
if (pullDistance.value >= props.expandThreshold) return '松手查看大图';
if (pullDistance.value > 20) return '下拉查看大图';
return '';
});
// === 内部方法(本任务先占位,Task 2 完善手势) ===
function onTouchStart() {}
function onTouchMove() {}
function onTouchEnd() {}
function onMaskTap() {
if (!isExpanded.value) return;
isExpanded.value = false;
emit('pull-expand-change', { state: 'normal' });
}
function onCardModeChange() {}
// === 贴纸样式(本任务先占位,Task 3 完善) ===
function getStickerStyle(sticker) {
return {
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
};
}
// === 监听外部 disabled,重置内部状态 ===
watch(() => props.disabled, (v) => {
if (v) {
isPullActive.value = false;
isExpanded.value = false;
pullDistance.value = 0;
pullStartY.value = null;
emit('pull-expand-change', { state: 'normal' });
}
});
</script>
<style scoped>
/* 组件 .card-wrapper 必须显式声明尺寸三行(其它视觉样式由父级 asset-detail.vue 提供) */
.card-wrapper {
width: 352rpx;
height: 552rpx;
transform-origin: center center;
}
/* 手势容器(只负责 transform 与触摸) */
.pull-card-touch {
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.12s linear;
will-change: transform;
}
.pull-card-touch:deep(.card-wrapper),
.pull-expand-root:deep(.card-wrapper) {
/* 深选择:不重复视觉,只确保 transform-origin 生效(已被 .card-wrapper 块覆盖) */
}
/* 放大态根容器 */
.pull-expand-root {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 锁定态过渡 */
.pull-expand-root.is-expanded .pull-card-touch {
transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* 点击背景收起 */
.pull-mask {
position: fixed;
inset: 0;
z-index: 90;
background: transparent;
}
/* 提示文案 */
.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>
```
**Step 2: 校验文件路径与命名**
```bash
ls -la /Users/liulujian/Documents/code/TopFansByGithub/frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue
```
Expected: 文件存在,无错误。
**Step 3: 提交(按 CLAUDE.md 规范需用户明确指示,本计划仅列命令)**
```bash
git add frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue
git commit -m "feat(asset-detail): scaffold AssetCardPullExpand component with props and inner .card-wrapper block"
```
> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
### Task 2: 完善触摸手势实现
**Files:**
- Modify: `frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue`(仅替换 `<script setup>` 内的 `onTouchStart` / `onTouchMove` / `onTouchEnd` 函数体)
**Interfaces:**
- Consumes: `props.expandThreshold` / `props.maxPullDistance` / `props.disabled`、内部 ref `pullStartY` / `pullDistance` / `isPullActive` / `isExpanded`
- Produces: 在 `isPullActive` 过程中根据 `pullDistance` 同步 `cardTransform`;`onTouchEnd` 在达到阈值时设 `isExpanded = true` 并 emit `pull-expand-change({ state: 'expanded' })`,否则 `pullDistance = 0` 回弹
**Step 1: 替换 `onTouchStart` 函数体**
把 Task 1 占位的 `function onTouchStart() {}` 替换为:
```js
function onTouchStart(e) {
if (!shouldHandleGesture.value) return;
// 放大态下不重新捕获(避免误触)
if (isExpanded.value) return;
if (e.touches.length !== 1) return;
pullStartY.value = e.touches[0].pageY;
}
```
**Step 2: 替换 `onTouchMove` 函数体**
把占位的 `function onTouchMove() {}` 替换为:
```js
function onTouchMove(e) {
if (!shouldHandleGesture.value) return;
if (pullStartY.value == null) return;
if (e.touches.length !== 1) return;
const delta = e.touches[0].pageY - pullStartY.value;
if (delta <= 0) {
// 向上滑/未真正下拉:不进入激活态
return;
}
const next = Math.min(delta * 0.6, props.maxPullDistance);
pullDistance.value = next;
if (next > 0) {
isPullActive.value = true;
// 阻止默认滚动(放大态无 scroll-view,正常态父级控制)
if (typeof e.preventDefault === 'function') {
try { e.preventDefault(); } catch (_) {}
}
}
}
```
**Step 3: 替换 `onTouchEnd` 函数体**
把占位的 `function onTouchEnd() {}` 替换为:
```js
function onTouchEnd() {
if (!isPullActive.value) {
pullStartY.value = null;
return;
}
isPullActive.value = false;
if (pullDistance.value >= props.expandThreshold) {
isExpanded.value = true;
emit('pull-expand-change', { state: 'expanded' });
}
pullDistance.value = 0;
pullStartY.value = null;
}
```
**Step 4: 手动语法检查**
```bash
# 仅作语法级别的快速验证(无 linter 工具时)
node -e "const fs=require('fs');const src=fs.readFileSync('/Users/liulujian/Documents/code/TopFansByGithub/frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue','utf8');const m=src.match(/<script setup>([\\s\\S]*?)<\\/script>/);if(!m){console.error('no script');process.exit(1);}try{new Function(m[1].replace(/import [^;]+;/g,'').replace(/defineProps|defineEmits|watch/g,'(function(){})'));console.log('syntax ok');}catch(e){console.error('syntax error',e.message);process.exit(1);}"
```
Expected: `syntax ok`
**Step 5: 提交**
```bash
git add frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue
git commit -m "feat(asset-detail): add pull gesture handlers with APP-PLUS gating"
```
---
### Task 3: 完善 `getStickerStyle` 函数
**Files:**
- Modify: `frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue`(替换 Task 1 占位的 `getStickerStyle` 函数体)
**Interfaces:**
- Consumes: `sticker` 对象(来自 props `stickers`,字段 `id` / `src` / `pos_x` / `pos_y` / `rotation` / `scale_x` / `scale_y` / `opacity`
- Produces: 与 `asset-detail.vue``getStickerStyle` 行为一致的对象,字段与 `image` 的 inline `style` 兼容
**Step 1: 替换 `getStickerStyle` 函数体**
把 Task 1 占位的 `getStickerStyle` 替换为:
```js
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,
};
}
```
**Step 2: 与父级逻辑一致性核对**
打开 `frontend/pages/asset-detail/asset-detail.vue`,搜索 `function getStickerStyle`,把两个函数体逐字对照,确保字段映射一致(`pos_x` / `pos_y` / `rotation` / `scale_x` / `scale_y` / `opacity``transform` 字符串模板)。
Expected: 函数体字段名 / 默认值与父级一致。
**Step 3: 提交**
```bash
git add frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue
git commit -m "feat(asset-detail): mirror getStickerStyle in AssetCardPullExpand"
```
---
### Task 4: 在 `asset-detail.vue` 引入 `viewMode``AssetCardPullExpand`
**Files:**
- Modify: `frontend/pages/asset-detail/asset-detail.vue`
**Interfaces:**
- Consumes: 组件 `<AssetCardPullExpand>` 暴露的 props / events
- Produces:
- 顶层 `v-else-if="viewMode === 'expanded'"` 分支挂组件
- `viewMode: ref('normal')` 状态
- `onPullExpandChange({ state })` 回调
- `.pull-expanded-view` 容器样式
**Step 1: 添加 import 与 ref**
`asset-detail.vue``<script setup>` 顶部 import 区域(现有 `import LenticularCard from '@/components/lenticular/LenticularCard.vue';` 之后)插入:
```js
import AssetCardPullExpand from '@/components/AssetCardPullExpand/AssetCardPullExpand.vue';
```
`// 倒计时` 注释之前的合适位置(refs 区域)添加:
```js
// 视图模式: 'normal' = 列表滚动卡片 / 'expanded' = 全屏放大态
const viewMode = ref('normal');
function onPullExpandChange({ state }) {
viewMode.value = state;
}
```
**Step 2: 在顶层 v-if 链中插入放大态分支**
把现有:
```vue
<view v-if="loading" class="loading-wrapper">
...
</view>
<!-- 错误状态 -->
<view v-else-if="loadError && !craftConfirmMode" class="error-wrapper">
...
</view>
<!-- 详情内容 -->
<scroll-view v-else scroll-y class="content-scroll" :show-scrollbar="false">
...
</scroll-view>
```
调整为(在错误态 `<view>``<scroll-view>` 之间插入新的 `<view v-else-if>`):
```vue
<view v-if="loading" class="loading-wrapper">
...
</view>
<!-- 错误状态 -->
<view v-else-if="loadError && !craftConfirmMode" class="error-wrapper">
...
</view>
<!-- 放大态:完全脱离 scroll-view,父级无滚动,组件手势不被抢占 -->
<view v-else-if="viewMode === 'expanded'" class="pull-expanded-view">
<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"
@pull-expand-change="onPullExpandChange"
/>
</view>
<!-- 详情内容 -->
<scroll-view v-else scroll-y class="content-scroll" :show-scrollbar="false">
...
</scroll-view>
```
**Step 3: 添加 `.pull-expanded-view` 容器样式**
`asset-detail.vue``<style scoped>` 末尾(`.share-canvas` 之前)追加:
```css
/* 放大态容器:全屏居中卡片,完全脱离 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;
/* 背景:复用 detail-container 的 .background-image(不重复声明) */
}
```
**Step 4: 检查 v-if 链顺序**
确认模板中四条分支的顺序为:
1. `<view v-if="loading" class="loading-wrapper">`
2. `<view v-else-if="loadError && !craftConfirmMode" class="error-wrapper">`
3. `<view v-else-if="viewMode === 'expanded'" class="pull-expanded-view">` ← 新增
4. `<scroll-view v-else scroll-y class="content-scroll" :show-scrollbar="false">`
**Step 5: 提交**
```bash
git add frontend/pages/asset-detail/asset-detail.vue
git commit -m "feat(asset-detail): wire viewMode and AssetCardPullExpand via v-else-if branch"
```
---
### Task 5: 验证 `<scroll-view v-else>` 分支未改动
**Files:**
- Modify: 无(纯验证)
- Read: `frontend/pages/asset-detail/asset-detail.vue`
**Interfaces:**
- Consumes: 无
- Produces: 确认正常态分支与 `card-wrapper` 区块未变
**Step 1: 确认 `<scroll-view v-else>` 分支与 `card-wrapper` 段未变**
```bash
git diff --stat HEAD~1 -- frontend/pages/asset-detail/asset-detail.vue
```
Expected: 仅显示新增的 `view v-else-if` / `import` / `viewMode` / `onPullExpandChange` / `.pull-expanded-view` 样式;**不**显示 `<scroll-view>` 内部或 `<view class="card-wrapper">` 区块的改动。
**Step 2: 手动核对 `card-wrapper` 区块**
打开 `frontend/pages/asset-detail/asset-detail.vue`,在 `<scroll-view v-else>` 分支中找到 `<view class="card-wrapper" :class="[…">`,确认其下 `card-frame` / `detail-lenticular-slot` / `LenticularCard` / `card-image` / `card-badge` / `card-sticker` 六个子节点与组件 1.7 模板一致。
Expected: 与 Task 1 的 `AssetCardPullExpand.vue` 模板内层 DOM 字符串完全一致(顺序 / class / 属性名 / 标签名)。
**Step 3: 确认 `asset-detail.vue` 现有卡片样式未删除**
```bash
git diff HEAD~1 -- frontend/pages/asset-detail/asset-detail.vue | grep -E "^-.*\\.card-(frame|image|badge|sticker)|^-.*\\.detail-lenticular-(slot|card)|^-.*\\.card-wrapper"
```
Expected: 无 `-` 开头的删除行(说明 `card-wrapper` 等样式块未被删除)。
**Step 4: 提交(如无改动则跳过)**
如发现非预期改动,立即回滚:
```bash
git checkout -- frontend/pages/asset-detail/asset-detail.vue
```
然后回到 Task 4 重新执行。
---
### Task 6: 手工联调 — 真机 (App-Plus) 验证
**Files:**
- Modify: 无
- Run: `frontend/` 项目 App 构建(命令视项目 README 而定;UniApp 标准命令 `npm run build:app-plus` 或 HBuilderX 运行到真机)
**Interfaces:**
- Consumes: 真机 App 环境
- Produces: 在真机上观察到的行为记录(写到 commit message 或 PR 描述)
**Step 1: 准备运行环境**
- 在 HBuilderX 中打开 `frontend/` 根目录。
- 选择“运行 → 运行到手机或模拟器 → Android App-基座 / iOS App-基座”。
- 打开任意一条藏品详情(`pages/asset-detail/asset-detail.vue`)。
**Step 2: 验证清单**
逐条对照,记录通过/未通过:
- [ ] **正常态加载**:详情页正常加载,卡片、点赞、信息行、创作者、链上数据全部显示,无回归。
- [ ] **顶部下拉**:在列表顶部(`scrollTop === 0`)下拉 → 卡片跟随手指放大,显示“下拉查看大图”提示。
- [ ] **下拉阈值**:继续下拉到约 80rpx → 提示文案切换为“松手查看大图”。
- [ ] **松手放大**:松手后 `viewMode === 'expanded'`,`<scroll-view>` 销毁,卡片保持 1.6 倍放大,其它模块消失。
- [ ] **Header 保留**:返回键、分享按钮、举报按钮在放大态下仍可见可点。
- [ ] **点击背景**:在放大态下点击卡片外空白处 → 卡片回弹到 1.0 倍,`viewMode === 'normal'`,`<scroll-view>` 重新创建,其它模块恢复。
- [ ] **列表中段下拉**:在 `scrollTop > 0` 时下拉 → 列表正常滚动,卡片无放大。
- [ ] **光栅卡片**:进入光栅藏品详情 → 下拉后 `LenticularCard` 状态保留(贴纸/角标同步放大),无重渲染。
- [ ] **加载/错误态**:模拟 `loadError`(在 `loadData` 抛错)→ 错误态显示,组件未挂载,无下拉。
- [ ] **返回键**:在放大态下按 Android 物理返回 / iOS 左滑 → 走 `handleBack()` 既有流程,无 JS 报错。
**Step 3: 记录未通过项**
任何未通过的项都对应到本计划的某个 Task,回到对应 Task 修复后重新跑本验证,直到全部通过。
**Step 4: 提交(如有调整)**
```bash
git add -A
git commit -m "fix(asset-detail): address QA findings from pull-expand manual test"
```
---
### Task 7: H5 端降级验证
**Files:**
- Modify: 无
- Run: H5 端构建/运行(命令: `npm run build:h5` + 浏览器打开)
**Interfaces:**
- Consumes: H5 端运行环境
- Produces: 确认 `// #ifdef APP-PLUS` 路径不会在 H5 启用
**Step 1: 启动 H5**
```bash
cd /Users/liulujian/Documents/code/TopFansByGithub/frontend
npm run dev:h5
```
或 HBuilderX 选择“运行 → 运行到浏览器 → Chrome”。
**Step 2: 验证清单**
- [ ] 详情页加载正常,卡片显示无异常。
- [ ] 鼠标在卡片上按住往下拖 → 卡片无放大(因为 `isAppPlus === false`,`shouldHandleGesture` 始终 false,触摸回调直接 return)。
- [ ] 列表滚动正常,无 JS 报错。
**Step 3: 浏览器控制台无报错**
打开 DevTools Console,确认无以下报错:
- `Cannot read property 'touches' of undefined`
- `shouldHandleGesture is not defined`
- `pullStartY is not a function`
**Step 4: 提交(如有 H5 端调整)**
```bash
git add -A
git commit -m "fix(asset-detail): ensure pull-expand is no-op on H5"
```
---
### Task 8: 全局回归与 spec 自检对照
**Files:**
- Modify: 无
- Read: `docs/superpowers/specs/2026-07-27-asset-detail-pull-expand-design.md`
**Interfaces:**
- Consumes: 已完成的实现 + spec
- Produces: 自检对照表(记录到 PR 描述)
**Step 1: spec §5.1 自检逐条核对**
| # | 自检项 | 任务映射 | 状态 |
|---|---|---|---|
| 1 | 列表顶部下拉 → 卡片跟随手指放大 | Task 6 | ☐ |
| 2 | 下拉到 80rpx 后松手 → viewMode 切到 expanded, scroll-view 销毁 | Task 6 | ☐ |
| 3 | 点击背景 → viewMode 切回 normal, scroll-view 重建 | Task 6 | ☐ |
| 4 | 列表中段下拉 → 不进入激活态 | Task 6 | ☐ |
| 5 | 光栅卡片放大 → LenticularCard 状态保留 | Task 6 | ☐ |
| 6 | Header 两种状态下都可点 | Task 6 | ☐ |
| 7 | 加载/错误态下无下拉 | Task 6 | ☐ |
| 8 | H5 端退化为静态 | Task 7 | ☐ |
| 9 | DOM/class 一致性(8 个 class + 5 个属性) | Task 1 + Task 5 | ☐ |
| 10 | 样式不重复声明(组件 .card-wrapper 仅 3 行) | Task 1 + Task 5 | ☐ |
**Step 2: spec §5.2 回归检查逐条核对**
- [ ] 改动文件只有 `AssetCardPullExpand.vue`(新增)和 `asset-detail.vue`(局部)。
- [ ] 未改动 `LenticularCard.vue` / `ShareReportButtons.vue` / `composables/useLenticularCraftTiltPreview.js` / `utils/sticker-compositor.js` / `utils/castloveMintForm.js`
- [ ] 不修改后端 API,不改数据库 schema。
**Step 3: 风险与回退**
- 风险 1 (`touchmove` 冒泡): 已在 Task 2 `onTouchMove``e.preventDefault()` 兜底。
- 风险 2 (DOM 偷改): Task 5 步骤 1-3 已验证。
- 风险 3 (scroll-view 销毁重建): 父级 `viewMode` 是 ref,所有数据(`coverUrl` / `lenticularLayers` / `activeStickers` 等)在父级维护,不受影响。
**Step 4: 把对照表写入 PR 描述**
(若此计划经 `subagent-driven-development` 执行,最终 PR 描述需包含本表。)
---
## 完成标准
- 所有 Task 1-7 步骤勾选完毕。
- Task 8 自检表全部勾选完毕。
- 真机 (App-Plus) 与 H5 端均无 JS 报错、视觉与功能符合 spec。
- `git log` 显示每步独立 commit,commit message 符合 `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` 规范。
- 不修改 `unpackage/dist/`、不修改后端代码、不修改数据库 schema。

View File

@ -0,0 +1,414 @@
# 藏品详情页下拉放大卡片设计
> ★ **MVP 优先**:本次只引入一个独立放大组件承载下拉手势与放大态,不修改 `LenticularCard` / `ShareReportButtons` 等已有组件,不引入状态管理库。
## 方案概述(必读)
### 要解决的问题
**业务问题**
- 用户在藏品详情页希望“快速预览大图”,但不希望跳转到独立大图页后丢失返回上下文。
- 现有 `asset-detail.vue` 卡片是固定尺寸 352×552rpx无法满足“看清细节”的诉求。
**技术问题**
- `asset-detail.vue` 已承担卡片、光栅、贴纸、点赞、链上数据、举报/分享弹窗等职责,行数接近 1700。
- 直接在同一文件内继续堆触摸手势、放大态、CSS 状态类会让单文件接近 2000 行。
- App 端 `scroll-view` 顶部回弹事件不稳定,负 `scrollTop` 不可靠,无法仅凭 `scrollTop` 识别下拉意图。
- 现有 `LenticularCard` 通过陀螺仪/模拟输入控制光栅切换,不希望被外层 transform 重新渲染打乱。
### 整体实现路径
1. 新建独立组件 `frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue`,承担:
- 触摸手势(`touchstart` / `touchmove` / `touchend` / `touchcancel`)。
- 放大态状态机(`isPullActive` / `isExpanded`)。
- 提示文案与点击背景收起。
2. 在 `asset-detail.vue` 中:
- 沿用顶层 `v-if / v-else-if / scroll-view v-else` 链路,新增 `v-else-if="viewMode === 'expanded'"` 分支挂 `<AssetCardPullExpand>`
- 正常态分支里的 `<view class="card-wrapper">` 一字不改,性能与手感零回归。
- 引入 `viewMode: ref('normal' | 'expanded')`,由 `AssetCardPullExpand` 发出 `pull-expand-change` 事件后切换。
- 顶部 Header返回键、`ShareReportButtons`)始终保留,不受 `viewMode` 影响。
3. H5 端降级:组件检测非 `APP-PLUS` 时整体不进入下拉态,仅做静态卡片展示。
预计工作量1~1.5 小时(新建组件 + asset-detail 集成 + 手工联调)。
### 关键决策
- **抽出独立组件**:避免 `asset-detail.vue` 进一步膨胀;同时让组件可复用(在星册页、铸造预览页等场景后续可直接挂上)。
- **数据全部由父级以 props 传入**:组件内部不调用任何 `utils/api.js` / `utils/task-api.js` / `utils/assetImageHelper.js`,不读 `uni.getStorageSync` / `getStoredUser()`,不直连 Vuex不持有“自己的”藏品数据/光栅贴纸配置。卡片内容、角标、贴纸、`LenticularCard` 所需的 `lenticularLayers` / `layerTransforms` / `simulate` / `tiltHintText` / `shimmerMidOpacity` / `coverUrl` / `gradeBadgeUrl` 等全部从 `asset-detail.vue`(以及后续复用方)以 props 注入。这样保证 `AssetCardPullExpand` 是纯展示 + 手势容器,可在任何页面复用。
- **组件 DOM 与 class 必须与现有 `.card-wrapper` 区块一摸一样**:模板中原 `card-wrapper` 区域的 class 名(`card-wrapper` / `card-wrapper--lenticular` / `card-frame` / `card-image` / `card-badge` / `card-sticker` / `detail-lenticular-slot` / `detail-lenticular-card`、DOM 嵌套顺序、属性名(`:src` / `:layers` / `:transforms` / `gyro-source` / `tilt-hint-text` / `:shimmer-mid-opacity` / `:simulate-tilt-from-normalized` / `@mode-change`)都**保持原样不动**`AssetCardPullExpand.vue` 只是在这个原结构**外面**再包一层“手势容器 + 放大态容器”,内层卡片原封不动。禁止改 class 名前缀(如把 `card-` 改成 `pull-`),禁止删/调 DOM 顺序,禁止重命名 props。这样 `asset-detail.vue` 现有的 `.card-wrapper` / `.card-frame` 等样式无需修改即可继续生效。
- **`.card-wrapper` 的尺寸由组件本地显式声明**:虽然视觉样式不重写,但 `.card-wrapper``width: 352rpx; height: 552rpx; transform-origin: center center;` 这三条尺寸/变换相关属性**必须**出现在 `AssetCardPullExpand.vue``<style scoped>` 里(不带 `scoped` 穿透选择器),因为组件是 `.card-wrapper` 的“拥有者”,不能假设父级一定把这些属性传过来。其它一切样式(背景、边框、阴影、动画 keyframes 等)继续由 `asset-detail.vue` 提供。组件内的 `.card-wrapper` 样式块只能写尺寸/transform 相关三行,不得添加任何其它属性。
- **沿用 `<scroll-view v-else>` 思路切换两种态,性能最稳**`asset-detail.vue` 现在的模板顶层是 `<view v-if="loading">… / <view v-else-if="loadError && !craftConfirmMode">… / <scroll-view v-else>…</scroll-view>`,我们要沿用并扩展这条 v-if/v-else 链。父级新增一个 `viewMode: ref('normal' | 'expanded')`,与 `loading` / `loadError` 形成互斥;在 `expanded` 态下用 `<view v-else-if="viewMode === 'expanded'">…</view>` 替代 `<scroll-view v-else>`,把 `<AssetCardPullExpand>` 放在这个新 view 下,完全脱离 `scroll-view` 渲染树。这样:
- 正常态:沿用现有 `<scroll-view>`,列表滚动 + 卡片展示无任何回归。
- 放大态:不再创建 `scroll-view` 节点,父级没有滚动容器,组件手势不会被父级滚动抢占,卡顿与冲突显著减少。
- 切换走 Vue 的 v-if/v-else diff节点销毁/创建一次性完成,过渡丝滑。
- 不引入 fixed 全屏遮罩层、也不在 `scroll-view` 上做 touch 事件 hack实现最轻、性能最稳。
- **复用 `LenticularCard`**:组件内部直接渲染 `<LenticularCard>` 子组件props 全部由父级透传;放大态完全由外层 `transform: scale()` 驱动,光栅状态不重置。
- **触摸事件而非 `scrollTop`**:仅在 `scrollTop === 0` 时进入激活态,避免与列表滚动冲突。
- **松手后保持放大**:松手后通过 `isExpanded` 锁态保留放大视图,用户点击背景遮罩或返回键退出;返回键走原有 `navigateBack` / `reLaunch` 流程。
- **Header 始终保留**:返回、分享、举报作为放大态下的退出/分享入口。
- **H5 不启用**:避免 H5 端 `scroll-view` 行为差异引入新 bug。
### 核心架构图TL;DR
```text
asset-detail.vue (顶层 v-if / v-else-if / v-else 链)
├── <view v-if="loading"> // 加载态
├── <view v-else-if="loadError && !craft…"> // 错误态
├── <view v-else-if="viewMode === 'expanded'"> // ★ 放大态(无 scroll-view)
│ └── <AssetCardPullExpand> // 新增独立组件
│ ├── touchstart/move/end/cancel
│ ├── 放大态锁 isExpanded
│ └── @pull-expand-change → viewMode
└── <scroll-view v-else> // ★ 正常态(原卡片+其它模块)
└── .card-wrapper / .info-row / .creator-section / .chain-section …
Header(返回 + ShareReportButtons) 在 detail-container 下,两种 viewMode 都渲染
```
## 文档说明
- **适用范围**`frontend/pages/asset-detail/asset-detail.vue` 详情页卡片放大交互。
- **不包含**光栅组件改造、ShareReportButtons 改造、独立大图页、新的状态管理库、H5 端手势支持。
- **前置版本**`LenticularCard` 已支持 `simulate` / `layerTransforms` props`2026-05-22-lenticular-gyro-optimization-design.md`、`2026-06-30-lenticular-android-accel-oneeuro-design.md`)。
- **历史依据**:用户在使用 `asset-detail.vue` 时反馈“卡片太小看不清”,但又不愿离开详情页。
- **目标读者**:前端开发、联调 QA、产品。
## 1. 新增组件 `AssetCardPullExpand`
### 1.1 目录与文件
```
frontend/components/AssetCardPullExpand/
├── AssetCardPullExpand.vue # 主组件
└── useCardPullGesture.js # 可选:抽出下拉手势 hook(优先放进同一文件,避免过度拆分)
```
> 决策:先在一个 `.vue` 文件内完成;如果后续 `asset-detail.vue` / `castlove/success.vue` / 星册页都复用,再拆 hook。
### 1.2 Props全部由父级注入组件内不自行获取
| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `coverUrl` | String | `''` | 非光栅卡片的封面图 URL由父级 `asset-detail.vue` 解析 OSS 签名后传入) |
| `isLenticular` | Boolean | `false` | 是否为光栅卡片(由父级根据 `assetData.tags` 计算后传入) |
| `lenticularLayers` | Array | `[]` | 透传给 `LenticularCard` 的层数据(由父级 `buildLenticularLayers*` 构造) |
| `layerTransforms` | Object | `{}` | 透传给 `LenticularCard` 的变换配置(由父级 `useLenticularCraftTiltPreview` 提供) |
| `simulate` | Object | `null` | 透传给 `LenticularCard` 的模拟输入(同上) |
| `tiltHintText` | String | `'倾斜手机查看光栅效果'` | 透传 `LenticularCard` 提示文案 |
| `shimmerMidOpacity` | Number | `0.16` | 透传 `LenticularCard` |
| `gradeBadgeUrl` | String | `''` | 角标 URL由父级 `gradeBadgeUrl` computed 传入) |
| `stickers` | Array | `[]` | 贴纸数组,字段同父级 `getStickerStyle`(由父级 `activeStickers` 直接传入) |
| `cardAnimClass` | String | `''` | 父级传入的额外动画 class`card-wrapper--auto-flip`),与 `auto`/`manual` 模式联动 |
| `expandThreshold` | Number | `80` | 松手放大的临界下拉值rpx |
| `maxScale` | Number | `1.6` | 最大放大倍数 |
| `maxPullDistance` | Number | `240` | 最大下拉距离rpx |
| `disabled` | Boolean | `false` | 加载中 / 错误态时禁用触摸 |
**禁止行为**(组件内不要写):
- 不要 import `utils/api.js` / `utils/task-api.js` / `utils/assetImageHelper.js`
- 不要调用 `uni.getStorageSync` / `getStoredUser()` / `getPreloadApi()`
- 不要访问 Vuex`uni.$emit` / `uni.$on` 也不要)。
- 不要内部再起一个 `loading` / `loadError` 状态——这些状态由父级控制,通过 `disabled` 透传。
- 不要维护自己的 `coverUrl` / `lenticularLayers` ref所有展示数据来源都必须是 props。
- **不要修改卡片内层 DOM 的 class 名 / 嵌套顺序 / 属性名**;原 `.card-wrapper` 区块的 8 个 class`card-wrapper` / `card-wrapper--lenticular` / `card-frame` / `detail-lenticular-slot` / `detail-lenticular-card` / `card-image` / `card-badge` / `card-sticker`)原封保留,`@mode-change` 事件、`gyro-source` / `tilt-hint-text` / `:shimmer-mid-opacity` / `:simulate-tilt-from-normalized` 等属性原样照写,不要在组件里把它们改写成 `pull-*``asset-card-*` 之类。
- **不要在组件 `<style scoped>` 里覆盖 `.card-wrapper` / `.card-frame` / `.card-image` / `.card-badge` / `.card-sticker` / `.detail-lenticular-slot` / `.detail-lenticular-card` 的样式**;这些样式继续由 `asset-detail.vue` 的现有 `<style scoped>` 提供。组件只负责手势/放大态所需的额外样式(如 `.pull-expand-root` / `.pull-card-touch` / `.pull-mask` / `.pull-hint`),与卡片原样式不冲突。
### 1.3 Events
- `@pull-expand-change(payload: { state: 'normal' | 'expanded' })`:状态变化时通知父级。
- `@collapse()`:父级可主动调起关闭放大态,组件内部 `isExpanded = false` 并 emit 一次 change。
### 1.4 内部状态
- `pullStartY = ref<number | null>(null)`
- `pullDistance = ref(0)`
- `isPullActive = ref(false)`
- `isExpanded = ref(false)`
### 1.5 Computed
- `cardTransform`:根据 `isPullActive``pullDistance` 计算 `transform: scale(s) translateY(d)`
- 激活态:`s = 1 + clamp(pullDistance / maxPullDistance, 0, 1) * (maxScale - 1)`。
- 锁定态:`s = maxScale``d = 0`,过渡曲线 `cubic-bezier(.22,.61,.36,1) .25s`
- `hintText`:根据 `pullDistance` 切换 `''` / `'下拉查看大图'` / `'松手查看大图'`
- `shouldHandleGesture``disabled` 为真或非 `APP-PLUS` 时为 `false``false` 时所有触摸回调直接 return。
### 1.6 事件回调(在组件内定义)
```js
function onTouchStart(e) {
if (!shouldHandleGesture.value) return;
if (scrollTopRef.value !== 0) return; // 仅在顶部进入
pullStartY.value = e.touches[0].pageY;
}
function onTouchMove(e) {
if (pullStartY.value == null) return;
const delta = e.touches[0].pageY - pullStartY.value;
if (delta <= 0) return;
pullDistance.value = Math.min(delta * 0.6, props.maxPullDistance);
isPullActive.value = pullDistance.value > 0;
}
function onTouchEnd() {
if (!isPullActive.value) {
pullStartY.value = null;
return;
}
isPullActive.value = false;
if (pullDistance.value >= props.expandThreshold) {
isExpanded.value = true;
emit('pull-expand-change', { state: 'expanded' });
}
pullDistance.value = 0;
pullStartY.value = null;
}
function onMaskTap() {
if (!isExpanded.value) return;
isExpanded.value = false;
emit('pull-expand-change', { state: 'normal' });
}
```
> `scrollTopRef`:组件 mounted 时通过 `uni.createSelectorQuery` 选中父级 `scroll-view``scrollTop`。若未传 ref使用默认值 0只允许在顶部下拉作为安全默认
### 1.7 模板(内层卡片与原 `.card-wrapper` 完全一致)
```vue
<template>
<view class="pull-expand-root" :class="{ 'is-expanded': isExpanded }">
<!-- 手势 / 放大容器:仅负责 transform 与触摸事件 -->
<view
class="pull-card-touch"
:style="{ transform: cardTransform }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<!-- ↓↓↓ 以下区块与原 .card-wrapper 一摸一样,class / DOM / 属性都不要改 ↓↓↓ -->
<view class="card-wrapper" :class="[
{ 'card-wrapper--lenticular': isLenticular },
cardAnimClass,
]">
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
<view v-if="isLenticular" class="detail-lenticular-slot">
<LenticularCard
class="detail-lenticular-card"
:layers="lenticularLayers"
:transforms="layerTransforms"
gyro-source="simulation"
:tilt-hint-text="tiltHintText"
:shimmer-mid-opacity="shimmerMidOpacity"
: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 stickers"
:key="sticker.id"
class="card-sticker"
:src="sticker.src"
mode="aspectFit"
:style="getStickerStyle(sticker)"
/>
</view>
<!-- ↑↑↑ 区块结束 ↑↑↑ -->
</view>
<text v-if="isPullActive && hintText" class="pull-hint">{{ hintText }}</text>
<view v-if="isExpanded" class="pull-mask" @tap="onMaskTap" />
</view>
</template>
```
> `getStickerStyle`:组件内私有函数,逻辑与父级 `asset-detail.vue` 中的 `getStickerStyle` 一致(本组件允许保留一个本地实现,因为 `sticker` 是 props 传入的纯数据,无副作用)。如果后续要彻底统一,可挪到 `utils/stickerStyle.js`;本次先在组件内复制一份。
### 1.8 样式要点
- `.pull-card-touch``transform-origin: center center; transition: transform .12s linear`(激活) / `.25s cubic-bezier(.22,.61,.36,1)`(锁定)。**不重写 `.card-wrapper` / `.card-frame` / `.card-image` / `.card-badge` / `.card-sticker` / `.detail-lenticular-slot` / `.detail-lenticular-card` 的样式**;这些 class 由父级 `asset-detail.vue` 的现有 `<style scoped>` 继续提供。
- `.pull-expand-root > .card-wrapper`**只允许在组件内显式声明尺寸**`width: 352rpx; height: 552rpx; transform-origin: center center;`。这三行是组件必须写的,不允许超出此范围(不允许补 padding / margin / background / border / box-shadow / animation 等)。光栅卡片的尺寸差异(若未来需要 520×680通过 props 传入,而不是在组件内改。
- `.pull-mask``position: fixed; inset: 0; z-index: 90; background: transparent;` 用于点击空白处收起,不阻挡 HeaderHeader `z-index: 100`)。
- `.pull-hint`:顶部固定位置,放大态隐藏。
- `// #ifdef APP-PLUS` 包裹触摸事件相关样式注释(不影响逻辑,只用于标注)。
## 2. `asset-detail.vue` 集成
### 2.1 import
```js
import AssetCardPullExpand from '@/components/AssetCardPullExpand/AssetCardPullExpand.vue';
```
### 2.2 顶层 v-if/v-else 链(沿用现有写法,新增 `viewMode === 'expanded'` 分支)
原结构:
```vue
<view v-if="loading">...</view>
<view v-else-if="loadError && !craftConfirmMode">...</view>
<scroll-view v-else scroll-y class="content-scroll" :show-scrollbar="false">
<view class="content-wrapper">
<view class="card-section">
<view class="card-wrapper" :class="[...]">...原卡片...</view>
<view class="card-meta-row">...</view>
</view>
<view class="info-row">...</view>
<view v-if="likeCount > 0" class="creator-section">...</view>
<view class="chain-section">...</view>
</view>
</scroll-view>
```
调整为:
```vue
<view v-if="loading">...</view>
<view v-else-if="loadError && !craftConfirmMode">...</view>
<!-- 放大态:完全脱离 scroll-view,父级无滚动,组件手势不被抢占 -->
<view v-else-if="viewMode === 'expanded'" class="pull-expanded-view">
<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"
@pull-expand-change="onPullExpandChange"
/>
</view>
<!-- 正常态:沿用 v-else + scroll-view,卡片+其它模块一切如旧 -->
<scroll-view v-else scroll-y class="content-scroll" :show-scrollbar="false">
<view class="content-wrapper">
<view class="card-section">
<!-- 原 .card-wrapper 一字不改,组件不参与正常态 -->
<view class="card-wrapper" :class="[...]">...原卡片...</view>
<view class="card-meta-row">...</view>
</view>
<view class="info-row">...</view>
<view v-if="likeCount > 0" class="creator-section">...</view>
<view class="chain-section">...</view>
</view>
</scroll-view>
```
要点:
- **正常态分支完全不动**`.card-wrapper` 原段继续写在 `<scroll-view v-else>` 里,与现在一模一样,性能与手感零回归。
- **放大态分支是新增的 `v-else-if` 分支**,内部**不创建** `scroll-view`,只挂 `<AssetCardPullExpand>`,父级无滚动容器,组件内部的 `touchmove` 不会被外层滚动抢占。
- v-if/v-else 切换走 Vue 的 diff正常态与放大态是“节点销毁/创建”级别,一次性完成,过渡丝滑。
- 不引入 fixed 全屏遮罩层、不在 `scroll-view` 上做 touch 事件 hack实现最轻、性能最稳。
### 2.3 状态切换
```js
const viewMode = ref('normal'); // 'normal' | 'expanded'
function onPullExpandChange({ state }) {
viewMode.value = state;
}
```
顶部 Header返回键 + `ShareReportButtons`)、`LikeUsersModal` 弹窗、`ReportModal` 弹窗、离屏 `shareCanvas`、链上哈希遮罩 `txhash-mask` 都**继续挂在 `<view class="detail-container">` 下**,不被 `viewMode` 影响。这样:
- 放大态下用户仍可点分享/举报/返回/弹出 txhash 详情;
- 弹窗本身用 `v-if` 控制,与放大态互不干扰。
### 2.4 `.card-wrapper` 段处理
- 正常态:原 `<view class="card-wrapper" :class="[...]">...</view>` **完全不动**,继续写在 `<scroll-view v-else>` 分支里。
- 放大态:不写第二份 `.card-wrapper`,而是把 `<AssetCardPullExpand>` 放进新增的 `v-else-if` 分支;组件内部再嵌一份与正常态**完全相同**的 `.card-wrapper` 区块(只为手势/放大态服务)。
- 组件不参与正常态渲染;正常态的 `.card-wrapper` 还是 `asset-detail.vue` 自己的那一份。
### 2.5 样式
- **不删除** `asset-detail.vue``.card-wrapper` / `.card-frame` / `.card-image` / `.card-badge` / `.card-sticker` / `.detail-lenticular-slot` / `.detail-lenticular-card` 的样式块——正常态还要用。
- 放大态新增 `.pull-expanded-view` 容器:`position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 50;``asset-detail.vue` 的 `<style scoped>` 内),让卡片居中显示,完全脱离 `scroll-view` 高度。
- `<AssetCardPullExpand>` 内部 `.card-wrapper` 显式声明三行尺寸:`width: 352rpx; height: 552rpx; transform-origin: center center;`(其它样式由父级 `asset-detail.vue` 现有 CSS 提供)。
### 2.6 行为契约
- 顶部 Header返回 + ShareReportButtons`normal` / `expanded` 两种 `viewMode` 下都渲染,且 `z-index` 始终高于 `.pull-expanded-view`,可正常点击。
- 加载中、错误态时,`viewMode === 'normal'`,组件不会被挂载。
- 返回键不强制清除放大态Android/iOS 物理返回 → `handleBack()` 走既有 `navigateBack` / `reLaunch`;若想顺带收起放大态,可在 `handleBack` 顶部加 `if (viewMode.value === 'expanded') { viewMode.value = 'normal'; return; }`,但**默认不强制**。
- 组件内 `disabled` 在加载/错误态被父级设为 `true`,但因为这些态下 `viewMode === 'normal'`、组件根本不被挂载,实际上不会触发该路径——`disabled` 主要用于 `craftConfirmMode` 等其他复用场景。
## 3. 数据流
```text
用户手指向下 → touchstart 记录 startY
→ touchmove 累加 pullDistance
→ touchend:
pullDistance >= threshold → isExpanded = true → emit('expanded')
否则 → pullDistance = 0
父级 onPullExpandChange → viewMode = 'expanded' → 整棵 scroll-view 销毁、放大态节点创建
点击 pull-mask → isExpanded = false → emit('normal') → viewMode = 'normal'
→ scroll-view 重新创建,卡片与其它模块恢复
```
## 4. 错误处理
- **加载中/错误态**`viewMode === 'normal'`,组件不会被挂载,触摸逻辑不会触发。
- **H5 端**`shouldHandleGesture` 通过 `// #ifdef APP-PLUS` 检测,非 App 直接返回 false组件退化为静态卡片。
- **`touchcancel`**:复用 `onTouchEnd` 的回弹分支。
- **页面销毁/切后台**`onUnload` / `onHide` 中清空 `pullStartY`、`pullDistance` 重置为 0、`isExpanded = false`。
- **`scrollTop` 异常**:若未传 `scrollTopRef`,组件假定当前在顶部,只在 `touchstart` 时记录起点;`touchmove` 一旦 `delta <= 0` 直接 return不会与正常列表滚动冲突。
## 5. 验证与回归
### 5.1 自检
- [ ] 详情页加载完成后,在列表顶部下拉 → 卡片跟随手指放大。
- [ ] 下拉到约 80rpx 后松手 → `viewMode` 切到 `expanded``<scroll-view>` 节点销毁,`<AssetCardPullExpand>` 节点创建,卡片保持 1.6 倍放大。
- [ ] 放大态下点击背景 → 卡片恢复原大小,`viewMode` 切回 `normal``<scroll-view>` 节点重新创建,其它模块恢复。
- [ ] 列表中段下拉 → 不进入激活态,列表正常滚动。
- [ ] 光栅卡片放大 → `LenticularCard` 状态保留,贴纸/角标同步放大。
- [ ] Header 在两种状态下都能点击(返回、分享、举报)。
- [ ] 加载中、错误态下,下拉无响应(组件未被挂载)。
- [ ] H5 端打开,组件退化为静态卡片,无下拉手势。
- [ ] **DOM/class 一致性核对**`AssetCardPullExpand.vue` 内层 DOM 节点的 class 名、嵌套顺序、属性与 `asset-detail.vue` 第 45-63 行原 `.card-wrapper` 区块**逐字相同**`card-wrapper` / `card-wrapper--lenticular` / `card-frame` / `card-image` / `card-badge` / `card-sticker` / `detail-lenticular-slot` / `detail-lenticular-card` 八个 class 一个都不能少一个都不能改;`@mode-change` / `gyro-source` / `tilt-hint-text` / `:shimmer-mid-opacity` / `:simulate-tilt-from-normalized` 等属性原样照写。
- [ ] **样式不重复声明**:组件 `<style scoped>` 中没有 `.card-wrapper` / `.card-frame` / `.card-image` / `.card-badge` / `.card-sticker` / `.detail-lenticular-slot` / `.detail-lenticular-card` 的样式块(全靠父级 `asset-detail.vue` 现有样式生效)。
- [ ] **尺寸/transform 显式声明**:组件 `<style scoped>` 中允许出现 `.card-wrapper` 的尺寸/transform 三行(`width: 352rpx; height: 552rpx; transform-origin: center center;`),其它任何属性都不允许写在该块内。
### 5.2 回归检查
- **改动文件**`frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue`(新增);`frontend/pages/asset-detail/asset-detail.vue`(模板/脚本/样式 局部)。
- **未改动文件**`LenticularCard.vue`、`ShareReportButtons.vue`、`composables/useLenticularCraftTiltPreview.js`、`utils/sticker-compositor.js`、`utils/castloveMintForm.js`。
- **相邻模块**`castlove/success.vue` / `castlove/mall.vue` / `pages/square/*` 等其它使用 `LenticularCard` 的页面不受影响(它们没有引入放大态)。
- **样式来源**:内层卡片所有视觉(角标、贴纸、光栅槽位、图片裁切)继续由 `asset-detail.vue` 现有 `<style scoped>` 下的 `.card-wrapper` / `.card-frame` / `.card-image` / `.card-badge` / `.card-sticker` / `.detail-lenticular-slot` / `.detail-lenticular-card` 提供;这些样式**不删除、不修改**,组件不重新声明(仅尺寸三行)。
- **API**:不修改后端。
- **数据库**:不修改 schema。
### 5.3 风险与回退
- 风险:`pullStartY` 监听挂在卡片容器,若 `LenticularCard` 子组件内 `catch``touchmove` 阻止冒泡,可能导致外层拿不到事件。`LenticularCard` 当前未阻止冒泡,如有需要在抽组件时通过 `e.cancelBubble` 兼容。
- 风险:组件内层 DOM 与 `asset-detail.vue` 现有 `.card-wrapper` 区块**完全一致**;若实施时偷懒改了某个 class`card-frame``pull-card-frame`),会出现 `.card-frame``position: absolute; top: 0; left: 0;` 等样式失效、卡片视觉崩坏的回归。spec 自检阶段需明确核对每个 class。
- 风险v-if/v-else 切换会销毁/重建 `<scroll-view>`,对内部的链上数据 / 点赞用户列表组件状态会有影响;如果状态在父级用 ref/computed 维护,则无影响;如挂在 scroll-view 内部,切换会重置。本次状态都在父级 `asset-detail.vue` 用 ref 维护,所以无回归。
- 回退:将 `AssetCardPullExpand` 拆下,父级恢复 `card-wrapper` 原状,即可回到当前行为。
## 6. 实施步骤(实现时按序)
1. 新建 `frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue`,按 §1.x 实现 props / events / 模板 / 样式。
2. 在 `asset-detail.vue` 中 import 新组件。
3. 在顶层 `v-if / v-else-if / scroll-view v-else` 链中插入 `v-else-if="viewMode === 'expanded'"` 分支,引入 `viewMode` ref 与 `onPullExpandChange` 回调。
4. 在 `asset-detail.vue``<style scoped>` 内追加 `.pull-expanded-view` 容器样式。
5. **不要删除** `asset-detail.vue``.card-wrapper` / `.card-frame` / `.card-image` / `.card-badge` / `.card-sticker` / `.detail-lenticular-slot` / `.detail-lenticular-card` 的样式块——正常态还要用。
6. 手动在真机App-Plus验证下拉 / 锁定 / 点击背景收起 / 列表滚动 / 光栅卡片 / Header 按钮。
7. 在 H5 端确认组件退化为静态展示。
## 7. 未做(显式列出)
- H5 / 小程序端的下拉手势支持。
- 大图独立页 / 缩放查看 / 多指捏合。
- 阈值 80rpx、最大 240rpx、放大 1.6 倍的可视化配置(暂走 props 默认值,后续按手感调)。
- 贴纸层在放大态下做单独动画(目前随卡片一起 scale
- 与 `useLenticularCraftTiltPreview` 的进一步解耦(目前直接传 `simulate` / `layerTransforms` props

View File

@ -0,0 +1,245 @@
<template>
<view class="pull-expand-root" :class="{ 'is-expanded': isExpanded }">
<view
class="pull-card-touch"
:style="{ transform: cardTransform }"
>
<!-- 内层卡片: asset-detail.vue 62-80 行原 .card-wrapper 区块逐字一致 -->
<view class="card-wrapper" :class="[
{ 'card-wrapper--lenticular': isLenticular },
cardAnimClass,
]">
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
<view v-if="isLenticular" class="detail-lenticular-slot">
<LenticularCard
class="detail-lenticular-card"
:layers="lenticularLayers"
:transforms="layerTransforms"
gyro-source="simulation"
:tilt-hint-text="tiltHintText"
:shimmer-mid-opacity="shimmerMidOpacity"
:simulate-tilt-from-normalized="simulate"
@mode-change="$emit('mode-change', $event)"
/>
</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 stickers"
:key="sticker.id"
class="card-sticker"
:src="sticker.src"
mode="aspectFit"
:style="getStickerStyle(sticker)"
/>
</view>
</view>
<view v-if="isExpanded && showMask" class="pull-mask" @tap="onMaskTap" />
</view>
</template>
<script setup>
import { computed } from 'vue';
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
// === Props(; asset-detail.vue .card-pull-surface,
// ) ===
const props = defineProps({
coverUrl: { type: String, default: '' },
isLenticular: { type: Boolean, default: false },
lenticularLayers: { type: Array, default: () => [] },
layerTransforms: { type: Object, default: () => ({}) },
// simulate useLenticularCraftTiltPreview gamma (),
// passthrough <LenticularCard :simulate-tilt-from-normalized>,
// prop Function asset-detail.vue LenticularCard,
// ,, Function
simulate: { type: Function, default: () => () => {} },
tiltHintText: { type: String, default: '倾斜手机查看光栅效果' },
shimmerMidOpacity: { type: Number, default: 0.16 },
gradeBadgeUrl: { type: String, default: '' },
stickers: { type: Array, default: () => [] },
cardAnimClass: { type: String, default: '' },
expandThreshold: { type: Number, default: 80 },
maxScale: { type: Number, default: 1.6 },
maxPullDistance: { type: Number, default: 240 },
disabled: { type: Boolean, default: false },
isExpanded: { type: Boolean, default: false },
showMask: { type: Boolean, default: true },
});
// === Events ===
// pull-expand-change: ,, viewMode
// mode-change: <LenticularCard> , cardMode ref
// collapse: , viewMode 'normal'
const emit = defineEmits(['pull-expand-change', 'mode-change', 'collapse']);
// === Computed ===
// 2026-07-27 refactor (Finding B): (isExpanded),
// .card-pull-surface ," scale(maxScale) / scale(1)"
const cardTransform = computed(() => {
if (props.isExpanded) return `scale(${props.maxScale})`;
return 'scale(1)';
});
function onMaskTap() {
if (!props.isExpanded) return;
emit('collapse');
}
// === (, asset-detail.vue getStickerStyle ) ===
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,
};
}
</script>
<style scoped>
/* 组件 .card-wrapper 必须显式声明尺寸三行(其它视觉样式由父级 asset-detail.vue 提供) */
.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));
}
/* 手势容器(只负责 transform;触摸事件已上移到父级的 .card-wrapper,见 asset-detail.vue) */
.pull-card-touch {
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.12s linear;
will-change: transform;
}
/* 放大态根容器 */
.pull-expand-root {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 锁定态过渡 */
.pull-expand-root.is-expanded .pull-card-touch {
transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* 点击背景收起 */
.pull-mask {
position: fixed;
inset: 0;
z-index: 90;
background: transparent;
}
</style>

View File

@ -15,6 +15,12 @@
</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">
<!-- 旋转光环 -->
@ -37,34 +43,84 @@
<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-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">
<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" />
<!-- 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>
<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>
<!-- 贴纸合成导出隐藏画布 -->
<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">
@ -89,6 +145,147 @@
</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 阶段虽然
正确算出 01 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>
<!-- 信息行创作者 + 铸造时间 + 来源 -->
@ -103,8 +300,8 @@
<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 v-if="assetData.owner?.avatar" class="info-avatar"
:src="assetData.owner.avatar" mode="aspectFill">
</image>
<text class="info-value">{{ assetData.owner_nickname || '未知' }}</text>
</view>
@ -139,19 +336,6 @@
</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 class="chain-section">
<image class="chain-logo" src="/static/logo/APPLOGO.png" mode="aspectFit"></image>
@ -195,7 +379,7 @@
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 点赞用户弹窗 -->
<LikeUsersModal :visible="showLikeUsersModal" :tabs="[]" @close="showLikeUsersModal = false">
@ -249,6 +433,7 @@ 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,
@ -292,6 +477,224 @@ function onCardModeChange(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.61.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
@ -890,18 +1293,47 @@ onShow(() => {
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>
@ -1676,4 +2108,101 @@ onUnmounted(() => {
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,
上拉收起过程中 01 渐显,接住原本因 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>