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