topfans/docs/superpowers/plans/2026-07-27-asset-detail-pull-expand-implementation-plan.md
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

719 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 藏品详情页下拉放大卡片实现计划
> **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。