topfans/docs/superpowers/specs/2026-07-27-asset-detail-pull-expand-design.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

415 lines
28 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.

# 藏品详情页下拉放大卡片设计
> ★ **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