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>
415 lines
28 KiB
Markdown
415 lines
28 KiB
Markdown
# 藏品详情页下拉放大卡片设计
|
||
|
||
> ★ **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;` 用于点击空白处收起,不阻挡 Header(Header `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)。
|