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

25 KiB
Raw Blame History

藏品详情页下拉放大卡片实现计划

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 组合式 APIAssetCardPullExpand.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 三行尺寸):

<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: 校验文件路径与命名

ls -la /Users/liulujian/Documents/code/TopFansByGithub/frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue

Expected: 文件存在,无错误。

Step 3: 提交(按 CLAUDE.md 规范需用户明确指示,本计划仅列命令)

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() {} 替换为:

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() {} 替换为:

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() {} 替换为:

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: 手动语法检查

# 仅作语法级别的快速验证(无 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: 提交

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.vuegetStickerStyle 行为一致的对象,字段与 image 的 inline style 兼容

Step 1: 替换 getStickerStyle 函数体

把 Task 1 占位的 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,
  };
}

Step 2: 与父级逻辑一致性核对

打开 frontend/pages/asset-detail/asset-detail.vue,搜索 function getStickerStyle,把两个函数体逐字对照,确保字段映射一致(pos_x / pos_y / rotation / scale_x / scale_y / opacitytransform 字符串模板)。

Expected: 函数体字段名 / 默认值与父级一致。

Step 3: 提交

git add frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue
git commit -m "feat(asset-detail): mirror getStickerStyle in AssetCardPullExpand"

Task 4: 在 asset-detail.vue 引入 viewModeAssetCardPullExpand

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'; 之后)插入:

import AssetCardPullExpand from '@/components/AssetCardPullExpand/AssetCardPullExpand.vue';

// 倒计时 注释之前的合适位置(refs 区域)添加:

// 视图模式: 'normal' = 列表滚动卡片 / 'expanded' = 全屏放大态
const viewMode = ref('normal');

function onPullExpandChange({ state }) {
  viewMode.value = state;
}

Step 2: 在顶层 v-if 链中插入放大态分支

把现有:

<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>):

<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 之前)追加:

/* 放大态容器:全屏居中卡片,完全脱离 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: 提交

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 段未变

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 现有卡片样式未删除

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: 提交(如无改动则跳过)

如发现非预期改动,立即回滚:

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: 提交(如有调整)

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

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 端调整)

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 onTouchMovee.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。