Compare commits
2 Commits
27313f414a
...
128a12fa03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
128a12fa03 | ||
|
|
3d984f19cd |
@ -0,0 +1,718 @@
|
||||
# 藏品详情页下拉放大卡片实现计划
|
||||
|
||||
> **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。
|
||||
@ -0,0 +1,414 @@
|
||||
# 藏品详情页下拉放大卡片设计
|
||||
|
||||
> ★ **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)。
|
||||
245
frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue
Normal file
245
frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue
Normal file
@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<view class="pull-expand-root" :class="{ 'is-expanded': isExpanded }">
|
||||
<view
|
||||
class="pull-card-touch"
|
||||
:style="{ transform: cardTransform }"
|
||||
>
|
||||
<!-- 内层卡片:与 asset-detail.vue 第 62-80 行原 .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="$emit('mode-change', $event)"
|
||||
/>
|
||||
</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>
|
||||
<view v-if="isExpanded && showMask" class="pull-mask" @tap="onMaskTap" />
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
|
||||
|
||||
// === Props(全部由父级注入;激活态相关状态已上移到 asset-detail.vue 的 .card-pull-surface,
|
||||
// 组件仅负责锁定态展示) ===
|
||||
const props = defineProps({
|
||||
coverUrl: { type: String, default: '' },
|
||||
isLenticular: { type: Boolean, default: false },
|
||||
lenticularLayers: { type: Array, default: () => [] },
|
||||
layerTransforms: { type: Object, default: () => ({}) },
|
||||
// simulate 是 useLenticularCraftTiltPreview 返回的 gamma 写入函数(连续叠化引擎入口),
|
||||
// 本组件作为 passthrough 直接转发给 <LenticularCard :simulate-tilt-from-normalized>,
|
||||
// 因此 prop 类型必须是 Function。父级 asset-detail.vue 既在正常态直接绑给 LenticularCard,
|
||||
// 又在放大态绑到这里,两条路径期望的值类型一致,这里只能声明 Function。
|
||||
simulate: { type: Function, default: () => () => {} },
|
||||
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 },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
showMask: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
// === Events ===
|
||||
// pull-expand-change: 兼容保留,但本组件已不再主动驱动状态切换,父级直接持有 viewMode
|
||||
// mode-change: 将内部 <LenticularCard> 冒泡到父级,父级写入 cardMode ref
|
||||
// collapse: 父级在放大态下点击背景遮罩时接收,自行把 viewMode 切回 'normal'
|
||||
const emit = defineEmits(['pull-expand-change', 'mode-change', 'collapse']);
|
||||
|
||||
// === Computed ===
|
||||
// 2026-07-27 refactor (Finding B): 组件只在放大态(isExpanded)挂载,激活态由父级的
|
||||
// .card-pull-surface 接管,组件内只需表达"锁定态 scale(maxScale) / 默认态 scale(1)"
|
||||
const cardTransform = computed(() => {
|
||||
if (props.isExpanded) return `scale(${props.maxScale})`;
|
||||
return 'scale(1)';
|
||||
});
|
||||
|
||||
function onMaskTap() {
|
||||
if (!props.isExpanded) return;
|
||||
emit('collapse');
|
||||
}
|
||||
|
||||
// === 贴纸样式(本地实现,与父级 asset-detail.vue 中的 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,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
/* 组件 .card-wrapper 必须显式声明尺寸三行(其它视觉样式由父级 asset-detail.vue 提供) */
|
||||
|
||||
.card-wrapper {
|
||||
position: relative;
|
||||
width: 352rpx;
|
||||
height: 552rpx;
|
||||
margin-bottom: 32rpx;
|
||||
transform-origin: center center;
|
||||
perspective: 800rpx;
|
||||
transform-style: preserve-3d;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 仅 auto 模式(光栅自动摆动时)启用翻转动画;用户手动控制时通过移除 class 关闭 */
|
||||
.card-wrapper--auto-flip {
|
||||
animation: card-3d-flip 15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes card-3d-flip {
|
||||
0% {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: rotateY(-30deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotateY(30deg);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* .card-wrapper--lenticular {
|
||||
width: 520rpx;
|
||||
height: 680rpx;
|
||||
} */
|
||||
|
||||
.detail-lenticular-slot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 88%;
|
||||
height: 96%;
|
||||
border-radius: 48rpx;
|
||||
/* transform: translate(-50%, -50%) rotate(-10deg); */
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
/* overflow: hidden; */
|
||||
}
|
||||
|
||||
.detail-lenticular-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 88%;
|
||||
height: 96%;
|
||||
left: 6%;
|
||||
top: 1.5%;
|
||||
border-radius: 48rpx;
|
||||
transform-origin: center center;
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
/* overflow: hidden; */
|
||||
/* transform: rotate(-10deg); */
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
left: -56rpx;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
z-index: 4;
|
||||
/* transform: rotate(-10deg); */
|
||||
}
|
||||
|
||||
.card-frame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
/* transform: rotate(-10deg); */
|
||||
|
||||
}
|
||||
|
||||
.card-sticker {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
/* 手势容器(只负责 transform;触摸事件已上移到父级的 .card-wrapper,见 asset-detail.vue) */
|
||||
.pull-card-touch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.12s linear;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 放大态根容器 */
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@ -15,6 +15,12 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 2026-07-28: 提示文案上移到 detail-container 顶层,fixed 定位,
|
||||
渲染在所有 v-if/v-else-if 之外 → expanded 与 normal 两态全程可见。
|
||||
配合 hintText computed 同时识别 isPullActive 与 isCollapsePullActive,
|
||||
同一段文案同时服务下拉放大与上拉收起两套手势 -->
|
||||
<text v-if="(isPullActive || isCollapsePullActive) && hintText" class="pull-hint">{{ hintText }}</text>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading" class="loading-wrapper">
|
||||
<!-- 旋转光环 -->
|
||||
@ -37,11 +43,54 @@
|
||||
<button class="retry-btn" @tap="loadData">重试</button>
|
||||
</view>
|
||||
|
||||
<!-- 放大态:完全脱离 scroll-view,父级无滚动,组件手势不被抢占 -->
|
||||
<view v-else-if="viewMode === 'expanded'" class="pull-expanded-view">
|
||||
<!-- 2026-07-28: 上拉收起手势 surface + transform 镜像容器
|
||||
(symmetric mirror of .card-pull-surface in normal branch)。
|
||||
仅负责触摸事件 + 镜像 transform,内层 AssetCardPullExpand 渲染不变。
|
||||
:is-expanded 固定为 false:让 wrapper 的 collapseCardTransform 提供完整视觉
|
||||
scale(1.6 at rest → 1.0 at max pull),内层始终 scale(1)。原设计
|
||||
(isExpanded && !isCollapsePullActive) 在释放瞬间会让内层从 scale(1) 跳到
|
||||
scale(1.6),叠加 wrapper 的 scale(1) → 出现"先放大再塌缩"双重跳变。
|
||||
新方案下,内层固定 scale(1),wrapper 平滑过渡 scale(1.6) ↔ scale(s) translateY(-pull) -->
|
||||
<view class="pull-collapse-surface" :style="{ transform: collapseCardTransform }"
|
||||
@touchstart="onCollapseTouchStart" @touchmove="onCollapseTouchMove"
|
||||
@touchend="onCollapseTouchEnd" @touchcancel="onCollapseTouchEnd">
|
||||
<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"
|
||||
:is-expanded="false"
|
||||
:show-mask="false"
|
||||
@mode-change="onCardModeChange"
|
||||
@pull-expand-change="onPullExpandChange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 详情内容 -->
|
||||
<scroll-view v-else scroll-y class="content-scroll" :show-scrollbar="false">
|
||||
<scroll-view v-else scroll-y class="content-scroll" :show-scrollbar="false" @scroll="onScroll">
|
||||
<!-- 2026-07-28 fix: 提示文案必须放在 .card-pull-surface(带 pull transform)之外,
|
||||
否则会跟随卡片一起 scale/translateY,被缩放到屏幕外或藏到卡片后面。
|
||||
2026-07-28 refactor (continued): 此处 <text> 已上移到 detail-container 顶层
|
||||
(位于 header-bar 之后、v-if loading 之前),渲染在所有 v-if/v-else-if 之外,
|
||||
同时服务 normal 态下拉与 expanded 态上拉两套手势。下方保留注释占位以记录迁移。 -->
|
||||
<view class="content-wrapper">
|
||||
<!-- 藏品卡片区域 -->
|
||||
<view class="card-section">
|
||||
<!-- 2026-07-27 refactor (Finding A): 触摸事件 + 跟手 transform 改挂在 .card-pull-surface
|
||||
上,避免 .card-wrapper 上 card-3d-flip 动画的 transform 覆盖父级 :style,
|
||||
使下拉跟手反馈可见 -->
|
||||
<view class="card-pull-surface" :style="{ transform: cardTransform }"
|
||||
@touchstart="onTouchStart" @touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd" @touchcancel="onTouchEnd">
|
||||
<view class="card-wrapper" :class="[
|
||||
{ 'card-wrapper--lenticular': isLenticularAsset },
|
||||
cardWrapperAnimClass,
|
||||
@ -61,10 +110,17 @@
|
||||
<image v-for="sticker in activeStickers" :key="sticker.id" class="card-sticker"
|
||||
:src="sticker.src" mode="aspectFit" :style="getStickerStyle(sticker)" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 贴纸合成导出隐藏画布 -->
|
||||
<canvas v-if="activeStickers.length" canvas-id="stickerCompositCanvas" class="export-canvas"
|
||||
:style="{ position: 'fixed', left: '-9999px', top: '-9999px', width: '450px', height: '600px' }" />
|
||||
</view>
|
||||
|
||||
<!-- 2026-07-27: 卡片下方的四个 sibling 模块统一包在 .card-siblings 里,
|
||||
通过 opacity 同步 pullDistance 做淡出,接住放大态切换时的视觉接缝 -->
|
||||
<view class="card-siblings"
|
||||
:style="{ opacity: siblingFadeOpacity, transition: 'opacity .12s linear' }">
|
||||
|
||||
<!-- 点赞 + 收益 + 倒计时行 -->
|
||||
<view class="card-meta-row">
|
||||
@ -89,7 +145,6 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 信息行(创作者 + 铸造时间 + 来源) -->
|
||||
<view class="info-row">
|
||||
@ -139,6 +194,50 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 链上数据 -->
|
||||
<view class="chain-section">
|
||||
<image class="chain-logo" src="/static/logo/APPLOGO.png" mode="aspectFit"></image>
|
||||
<view class="chain-left">
|
||||
<view class="chain-row">
|
||||
<text class="chain-label">数根名称</text>
|
||||
<view class="chain-value-wrap">
|
||||
<text class="chain-value">{{ assetData.name || '未知' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chain-row">
|
||||
<text class="chain-label">数根发行方</text>
|
||||
<view class="chain-value-wrap">
|
||||
<text class="chain-value">TOPFANS</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chain-row">
|
||||
<text class="chain-label">区块链编号</text>
|
||||
<view class="chain-value-wrap">
|
||||
<text class="chain-value">{{ showBlockNumber ? (assetData.block_number || '未知') :
|
||||
hiddenBlockNumber }}</text>
|
||||
<view class="toggle-btn" @tap="showBlockNumber = !showBlockNumber">
|
||||
<image class="toggle-icon"
|
||||
:src="showBlockNumber ? '/static/icon/show.png' : '/static/icon/hide.png'"
|
||||
mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chain-row">
|
||||
<text class="chain-label">链上哈希</text>
|
||||
<view class="chain-value-wrap">
|
||||
<text class="chain-value chain-hash" @longpress="copyHash">{{ showTxHash ? displayTxHash
|
||||
: hiddenTxHash }}</text>
|
||||
<view class="toggle-btn" @tap="showTxHash = !showTxHash">
|
||||
<image class="toggle-icon"
|
||||
:src="showTxHash ? '/static/icon/show.png' : '/static/icon/hide.png'"
|
||||
mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 完整链上哈希显示遮罩层 -->
|
||||
<view v-if="showTxHash" class="txhash-mask" @tap="showTxHash = false">
|
||||
<view class="txhash-popup" @tap.stop>
|
||||
@ -151,6 +250,91 @@
|
||||
<text class="txhash-popup-content" selectable @longpress="copyHash">{{ displayTxHash }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 2026-07-28 fix (sibling fade-in): 在 detail-container 顶层再渲染一份 .card-siblings,
|
||||
仅在 expanded 态挂载。原因:v-if/v-else-if 切到 expanded 时 <scroll-view> 被销毁,
|
||||
原 .card-siblings 节点随父级一起消失,导致 siblingFadeOpacity 在 up-pull 阶段虽然
|
||||
正确算出 0→1 但 DOM 上无元素可绑,上拉收起过程中 sibling modules 始终不可见。
|
||||
这份 overlay 复用父级作用域的 isLiked/likeCount/assetData 等所有响应式数据(无需 props),
|
||||
opacity 绑 siblingFadeOpacity 即可接住上拉渐显;正常态下 scroll-view 里仍有一份原版。 -->
|
||||
<view v-if="viewMode === 'expanded'" class="card-siblings-expanded-overlay">
|
||||
<view class="card-siblings"
|
||||
:style="{ opacity: siblingFadeOpacity, transition: 'opacity .12s linear' }">
|
||||
|
||||
<!-- 点赞 + 收益 + 倒计时行 -->
|
||||
<view class="card-meta-row">
|
||||
<!-- 点赞 -->
|
||||
<view class="like-area" @tap="handleLike">
|
||||
<image :src="isLiked ? '/static/icon/like-after.png' : '/static/icon/like-before.png'"
|
||||
class="like-icon" mode="aspectFit"></image>
|
||||
<text class="like-num">{{ likeCount || 0 }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 收益 -->
|
||||
<view class="earnings-area">
|
||||
<image class="crystal-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||||
<text class="earnings-text">{{ assetData.hourly_earnings || 5 }}/时</text>
|
||||
</view>
|
||||
|
||||
<!-- 倒计时(如有) -->
|
||||
<view v-if="assetData.display_status === 1 && countdownText" class="countdown-area">
|
||||
<view class="countdown-pill">
|
||||
<image class="crystal-icon" src="/static/assetDetail/time.png" mode="aspectFit">
|
||||
</image>
|
||||
<text class="countdown-val">{{ countdownText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 信息行(创作者 + 铸造时间 + 来源) -->
|
||||
<view class="info-row">
|
||||
<view class="info-col">
|
||||
<view class="info-item">
|
||||
<text class="info-label">来源</text>
|
||||
<text class="info-value">{{ assetData.source || '铸造' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-col">
|
||||
<view class="info-item">
|
||||
<text class="info-label">创作者</text>
|
||||
<view class="info-nickname">
|
||||
<image v-if="assetData.owner?.avatar" class="info-avatar"
|
||||
:src="assetData.owner.avatar" mode="aspectFill">
|
||||
</image>
|
||||
<text class="info-value">{{ assetData.owner_nickname || '未知' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-col">
|
||||
<view class="info-item">
|
||||
<text class="info-label">铸造时间</text>
|
||||
<text class="info-value">{{ formattedAcquireTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 创作者信息模块 -->
|
||||
<view v-if="likeCount > 0" class="creator-section" @tap="showLikeUsersModal = true">
|
||||
<!-- 点赞用户头像区域 -->
|
||||
<view class="liked-users-area">
|
||||
<view v-for="(user, index) in likedUsers" :key="index" class="liked-user-avatar" :style="{
|
||||
transform: `translate(${user.ellipseX || 0}rpx, ${user.ellipseY || 0}rpx) scale(${user.size || 1})`,
|
||||
zIndex: index < 2 ? index + 1 : (index < 4 ? index + 3 : index + 1)
|
||||
}">
|
||||
<image class="liked-user-image" :src="user.avatar" mode="aspectFill"></image>
|
||||
</view>
|
||||
</view>
|
||||
<view class="creator-info">
|
||||
<text class="creator-title">TA们最近为你点过赞</text>
|
||||
<view class="creator-card">
|
||||
<text class="creator-tip">点击查看</text>
|
||||
<image class="creator-preview" src="/static/rank/activity-support-icon/tubiao.png"
|
||||
mode="aspectFill"></image>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 链上数据 -->
|
||||
<view class="chain-section">
|
||||
@ -195,7 +379,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 点赞用户弹窗 -->
|
||||
<LikeUsersModal :visible="showLikeUsersModal" :tabs="[]" @close="showLikeUsersModal = false">
|
||||
@ -249,6 +433,7 @@ import LikeUsersModal from '@/pages/components/LikeUsersModal.vue';
|
||||
import ShareReportButtons from '@/pages/components/ShareReportButtons.vue';
|
||||
import ReportModal from '@/pages/components/ReportModal.vue';
|
||||
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
|
||||
import AssetCardPullExpand from '@/components/AssetCardPullExpand/AssetCardPullExpand.vue';
|
||||
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
|
||||
import {
|
||||
buildLenticularLayersTwo,
|
||||
@ -292,6 +477,224 @@ function onCardModeChange(mode) {
|
||||
}
|
||||
const cardWrapperAnimClass = computed(() => cardMode.value === 'auto' ? 'card-wrapper--auto-flip' : '')
|
||||
|
||||
// 视图模式: 'normal' = 列表滚动卡片 / 'expanded' = 全屏放大态
|
||||
const viewMode = ref('normal');
|
||||
|
||||
function onPullExpandChange({ state }) {
|
||||
viewMode.value = state;
|
||||
}
|
||||
|
||||
// 2026-07-27 refactor: 下拉手势相关状态从 AssetCardPullExpand 组件上移到本页面,
|
||||
// 关键原因见 Block 1:normal 态下 .card-wrapper 必须能接收触摸才能触发放大态,
|
||||
// 因此手势 state 必须住在手势表面所在的父级上,而不能藏在组件内部。
|
||||
|
||||
// === 手势状态(由父级持有,作为 props 注入 AssetCardPullExpand) ===
|
||||
const pullStartY = ref(null);
|
||||
const pullDistance = ref(0);
|
||||
const isPullActive = ref(false);
|
||||
const isExpanded = ref(false);
|
||||
// scrollTop 跟踪(避免与列表滚动冲突,仅在顶部进入激活态)
|
||||
const scrollTopRef = ref(0);
|
||||
|
||||
// 2026-07-28: 放大态下的上拉收起手势 state(symmetric mirror of down-pull above)。
|
||||
// 与 down-pull 解耦的独立 ref 集合:direction / startY / distance / active。
|
||||
// 设计动机:上拉只可能在 expanded 分支挂载时触发,而 down-pull 只可能在 normal 分支
|
||||
// 触发,二者 v-if/v-else-if 互斥,自然隔离;共用 ref 反而要写额外判断。
|
||||
const collapseStartY = ref(null);
|
||||
const collapsePullDistance = ref(0);
|
||||
const isCollapsePullActive = ref(false);
|
||||
|
||||
// === 手势常量(与组件 props 默认值对齐) ===
|
||||
const PULL_EXPAND_THRESHOLD = 80;
|
||||
const PULL_MAX_DISTANCE = 240;
|
||||
const PULL_MAX_SCALE = 1.6;
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
const isAppPlus = true;
|
||||
// #else
|
||||
const isAppPlus = false;
|
||||
// #endif
|
||||
|
||||
const shouldHandleGesture = computed(() => {
|
||||
return isAppPlus && !craftConfirmMode.value && !loading.value && !loadError.value;
|
||||
});
|
||||
|
||||
const hintText = computed(() => {
|
||||
// 2026-07-28: 上拉收起手势 hint(在 expanded 状态下激活)
|
||||
if (isCollapsePullActive.value) {
|
||||
if (collapsePullDistance.value >= PULL_EXPAND_THRESHOLD) return '松手收起';
|
||||
if (collapsePullDistance.value > 20) return '上拉收起';
|
||||
return '';
|
||||
}
|
||||
if (pullDistance.value >= PULL_EXPAND_THRESHOLD) return '松手查看大图';
|
||||
if (pullDistance.value > 20) return '下拉查看大图';
|
||||
return '';
|
||||
});
|
||||
|
||||
// 父级持有 cardTransform 公式(对齐 spec §1.5):正常态卡片实时跟手放大;切换到放大态时
|
||||
// AssetCardPullExpand 自己用同一公式再算一遍(pullDistance=0 / isExpanded=true → scale(maxScale))
|
||||
const cardTransform = computed(() => {
|
||||
if (isExpanded.value) {
|
||||
return `scale(${PULL_MAX_SCALE})`;
|
||||
}
|
||||
if (isPullActive.value) {
|
||||
const t = Math.min(1, Math.max(0, pullDistance.value) / PULL_MAX_DISTANCE);
|
||||
const s = 1 + t * (PULL_MAX_SCALE - 1);
|
||||
return `scale(${s.toFixed(3)}) translateY(${pullDistance.value}px)`;
|
||||
}
|
||||
return 'scale(1)';
|
||||
});
|
||||
|
||||
// 2026-07-27: sibling content 同步淡出。pullDistance 从 0 → threshold*0.6 (默认 48rpx),
|
||||
// opacity 从 1.0 → 0.0;超过 48rpx 仍维持 0,直到 onTouchEnd 切到 expanded(viewMode 切换
|
||||
// 会销毁整个 <scroll-view>,siblings 随节点一起消失,所以接缝处无缝)
|
||||
const siblingFadeOpacity = computed(() => {
|
||||
// 2026-07-28: 上拉收起过程中 sibling 从 0 → 1 渐显,正好与下拉淡出对称。
|
||||
// limit 与下拉共用(48rpx),即拉到 48rpx 时 siblings 已完全可见,松开 < 80rpx
|
||||
// 时回弹放大态不会留下"残影"
|
||||
if (isCollapsePullActive.value) {
|
||||
const limit = PULL_EXPAND_THRESHOLD * 0.6;
|
||||
const t = Math.min(1, Math.max(0, collapsePullDistance.value / limit));
|
||||
return t;
|
||||
}
|
||||
if (!isPullActive.value) return 1;
|
||||
const limit = PULL_EXPAND_THRESHOLD * 0.6; // 48rpx with default 80rpx threshold
|
||||
const t = Math.min(1, Math.max(0, pullDistance.value / limit));
|
||||
return 1 - t;
|
||||
});
|
||||
|
||||
// === 手势回调 ===
|
||||
// 2026-07-28 fix: touchstart 只清理残留状态,不 eager 记录 startY。
|
||||
// 纯点击(无 move)时 touchend 会因 isPullActive=false 提前 return,
|
||||
// eager 写入的 startY 会残留到下一次手势,导致首帧 transform 跳变
|
||||
// ("点一下卡片先缩小再弹回")。startY 改为在首个 touchmove 里懒记录。
|
||||
function onTouchStart(e) {
|
||||
if (!shouldHandleGesture.value) return;
|
||||
if (isExpanded.value) return;
|
||||
if (scrollTopRef.value !== 0) return; // 仅在列表顶部进入
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
pullStartY.value = null;
|
||||
pullDistance.value = 0;
|
||||
isPullActive.value = false;
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!shouldHandleGesture.value) return;
|
||||
if (isExpanded.value) return;
|
||||
if (scrollTopRef.value !== 0) return; // 仅在列表顶部进入
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
const currentY = e.touches[0].pageY;
|
||||
if (pullStartY.value == null) {
|
||||
// 手势真正开始移动时才记录起点
|
||||
pullStartY.value = currentY;
|
||||
}
|
||||
const delta = currentY - pullStartY.value;
|
||||
if (delta <= 0) return; // 向上/未真正下拉,不进入激活态
|
||||
const next = Math.min(delta * 0.6, PULL_MAX_DISTANCE);
|
||||
pullDistance.value = next;
|
||||
if (next > 0) {
|
||||
isPullActive.value = true;
|
||||
// 阻止默认滚动(避免与列表滚动手势冲突)
|
||||
if (typeof e.preventDefault === 'function') {
|
||||
try { e.preventDefault(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!isPullActive.value) {
|
||||
pullStartY.value = null;
|
||||
return;
|
||||
}
|
||||
// 2026-07-28 fix(revert): 去掉延迟切换定时器。transition 已移除,
|
||||
// 释放后立即翻 viewMode,分支销毁重建即完成视觉切换;延迟 260ms 反而
|
||||
// 让释放动作有"等一下才响应"的迟滞感。
|
||||
isPullActive.value = false;
|
||||
if (pullDistance.value >= PULL_EXPAND_THRESHOLD) {
|
||||
isExpanded.value = true;
|
||||
viewMode.value = 'expanded'; // 同时通知 v-else-if 切到放大分支
|
||||
}
|
||||
pullDistance.value = 0;
|
||||
pullStartY.value = null;
|
||||
}
|
||||
|
||||
// 2026-07-28: 上拉收起的 wrapper transform(scale 1.6 → 1.0,translateY 0 → -pullDistance)。
|
||||
// 2026-07-28 fix(jump): 三段语义 ——
|
||||
// 1) viewMode !== 'expanded':wrapper 整体回到 scale(1)(no-op),
|
||||
// 此时 scroll-view 的卡片接管视觉(本身 scale(1) transform),无冲突
|
||||
// 2) viewMode === 'expanded' && !isCollapsePullActive:wrapper 提供 scale(1.6)(锁定态),
|
||||
// 内层 AssetCardPullExpand :is-expanded="false" 永远 scale(1),总视觉 = 1.6 × 1 = 1.6 ✓
|
||||
// 3) viewMode === 'expanded' && isCollapsePullActive:wrapper 实时跟手 scale(s) translateY(-pull),
|
||||
// s 从 1.6 线性降到 1.0,内层 scale(1),总视觉 = s × 1 = s(同 1.6→1.0 平滑收缩) ✓
|
||||
// 关键差异:旧实现 !isCollapsePullActive → 'scale(1)' 会让视觉从 s 跳到 1.6(因为内层此时是 scale(1.6)),
|
||||
// 释放瞬间 v-else-if 销毁又跳到 scale(1),双重跳变即用户报的"上拉跳一下"。
|
||||
// 新实现把锁定态 scale(1.6) 显式交给 wrapper,内层永远 scale(1),释放时 wrapper 平滑回 scale(1.6) translateY(0),
|
||||
// 配合 .pull-collapse-surface 的 CSS transition,跨分支销毁前视觉已经"回到锁定态"接缝更小。
|
||||
const collapseCardTransform = computed(() => {
|
||||
if (viewMode.value !== 'expanded') return 'scale(1)';
|
||||
if (!isCollapsePullActive.value) return 'scale(1.6)';
|
||||
const t = Math.min(1, Math.max(0, collapsePullDistance.value / PULL_MAX_DISTANCE));
|
||||
const s = PULL_MAX_SCALE - t * (PULL_MAX_SCALE - 1); // 1.6 → 1.0
|
||||
return `scale(${s.toFixed(3)}) translateY(${-collapsePullDistance.value}px)`;
|
||||
});
|
||||
|
||||
// 2026-07-28: 上拉收起手势回调(对称镜像 onTouchStart/Move/End)。
|
||||
// 仅在 expanded 分支挂载时触发(viewMode.value === 'expanded'),
|
||||
// 节点销毁/创建的天然隔离使 down-pull 与 up-pull 不会同时处于激活态
|
||||
// 2026-07-28 fix: 同 onTouchStart —— 不 eager 记录 startY,避免纯点击时
|
||||
// 残留的 startY 在下一次 touchstart/touchmove 之间让 collapseCardTransform
|
||||
// 重算,出现"点一下先缩小再弹回"。startY 在首个 touchmove 里懒记录。
|
||||
function onCollapseTouchStart(e) {
|
||||
if (!shouldHandleGesture.value) return;
|
||||
if (viewMode.value !== 'expanded') return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
collapseStartY.value = null;
|
||||
collapsePullDistance.value = 0;
|
||||
isCollapsePullActive.value = false;
|
||||
}
|
||||
|
||||
function onCollapseTouchMove(e) {
|
||||
if (!shouldHandleGesture.value) return;
|
||||
if (viewMode.value !== 'expanded') return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
const currentY = e.touches[0].pageY;
|
||||
if (collapseStartY.value == null) {
|
||||
collapseStartY.value = currentY;
|
||||
}
|
||||
const delta = currentY - collapseStartY.value;
|
||||
if (delta >= 0) return; // 未真正上拉(向下/静止),不进入激活态
|
||||
const next = Math.min(-delta * 0.6, PULL_MAX_DISTANCE);
|
||||
collapsePullDistance.value = next;
|
||||
if (next > 0) {
|
||||
isCollapsePullActive.value = true;
|
||||
// 阻止默认行为,避免与父级 mask/其他手势冲突
|
||||
if (typeof e.preventDefault === 'function') {
|
||||
try { e.preventDefault(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onCollapseTouchEnd() {
|
||||
if (!isCollapsePullActive.value) {
|
||||
collapseStartY.value = null;
|
||||
return;
|
||||
}
|
||||
// 2026-07-28 fix(revert): 去掉延迟切换定时器,释放后立即切分支。
|
||||
isCollapsePullActive.value = false;
|
||||
if (collapsePullDistance.value >= PULL_EXPAND_THRESHOLD) {
|
||||
isExpanded.value = false;
|
||||
viewMode.value = 'normal'; // 切回 normal 分支,scroll-view 重新挂载
|
||||
}
|
||||
collapsePullDistance.value = 0;
|
||||
collapseStartY.value = null;
|
||||
}
|
||||
|
||||
function onScroll(e) {
|
||||
scrollTopRef.value = (e && e.detail && typeof e.detail.scrollTop === 'number')
|
||||
? e.detail.scrollTop
|
||||
: 0;
|
||||
}
|
||||
|
||||
const isLenticularAsset = computed(() => {
|
||||
if (craftConfirmMode.value) return false
|
||||
const tags = assetData.value?.tags
|
||||
@ -890,18 +1293,47 @@ onShow(() => {
|
||||
|
||||
onHide(() => {
|
||||
stopLikePolling();
|
||||
// 2026-07-27 refactor: 页面切到后台时清掉手势中间态,避免回到前台时还残留 pullDistance / isPullActive
|
||||
pullStartY.value = null;
|
||||
pullDistance.value = 0;
|
||||
isPullActive.value = false;
|
||||
// 2026-07-28: 同步清掉上拉收起的中间态
|
||||
collapseStartY.value = null;
|
||||
collapsePullDistance.value = 0;
|
||||
isCollapsePullActive.value = false;
|
||||
isExpanded.value = false;
|
||||
viewMode.value = 'normal';
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
// 页面被关闭/销毁:清理轮询和事件订阅,避免泄漏到下一页或后台
|
||||
stopLikePolling();
|
||||
uni.$off('assetLikeChanged', handleAssetLikeChangedExternal);
|
||||
// 重置手势状态,避免下一次 mount 后出现"上次残留"假象
|
||||
pullStartY.value = null;
|
||||
pullDistance.value = 0;
|
||||
isPullActive.value = false;
|
||||
// 2026-07-28: 同步重置上拉收起手势状态
|
||||
collapseStartY.value = null;
|
||||
collapsePullDistance.value = 0;
|
||||
isCollapsePullActive.value = false;
|
||||
isExpanded.value = false;
|
||||
viewMode.value = 'normal';
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
stopLikePolling();
|
||||
uni.$off('assetLikeChanged', handleAssetLikeChangedExternal);
|
||||
// Vue 层卸载兜底
|
||||
pullStartY.value = null;
|
||||
pullDistance.value = 0;
|
||||
isPullActive.value = false;
|
||||
// 2026-07-28: 同步重置上拉收起手势状态
|
||||
collapseStartY.value = null;
|
||||
collapsePullDistance.value = 0;
|
||||
isCollapsePullActive.value = false;
|
||||
isExpanded.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1676,4 +2108,101 @@ onUnmounted(() => {
|
||||
height: 1334px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 放大态容器:全屏居中卡片,完全脱离 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;
|
||||
}
|
||||
|
||||
/* 2026-07-27 refactor (Finding A): 手势/transform 表面,与 .card-wrapper 分离,
|
||||
避免 card-3d-flip 动画的 transform 覆盖父级 :style.transform;仅在正常态存在 */
|
||||
.card-pull-surface {
|
||||
position: relative;
|
||||
display: block;
|
||||
transform-origin: center top;
|
||||
/* 2026-07-28 fix(revert): 不加 transition —— 拉动过程必须严格跟手,
|
||||
任何 transition 都会让 transform 插值 250ms,手感变成"橡皮筋/回弹"。
|
||||
释放后的视觉状态由 v-if/v-else-if 分支切换决定(节点销毁重建),
|
||||
不需要 CSS 过渡跨越这个边界。 */
|
||||
}
|
||||
|
||||
/* 2026-07-28: 上拉收起手势 surface,镜像 .card-pull-surface 在 normal 分支的角色
|
||||
(触摸事件 + 跟手 transform)。仅在 expanded 分支挂载。
|
||||
2026-07-28 fix(jump): 加 0.15s transition(旧版本 0.25s 让跟手变橡皮筋,这次砍到 0.15s):
|
||||
- 拉动过程 touchmove 每 16ms 一帧,0.15s ≈ 9 帧,跟手几乎无感(浏览器从当前插值位置起步,
|
||||
新 transition 覆盖旧 transition,视觉上仍紧贴手指)
|
||||
- 释放瞬间 collapseCardTransform 从 scale(s) translateY(-pull) → scale(1.6) translateY(0),
|
||||
transition 把这个"回弹到锁定态"的过程从瞬时跳变变成 150ms 平滑插值,吃掉用户报的"跳一下"
|
||||
- 跨阈值(viewMode=normal)路径下,wrapper 销毁,新分支卡片接管,过渡发生在销毁前的最后
|
||||
一帧,残余的 transition 不影响新节点 */
|
||||
.pull-collapse-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
transition: transform 0.15s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
/* 2026-07-27: 卡片下方四个 sibling 模块(card-meta-row / info-row / creator-section / chain-section)
|
||||
的统一容器,在父级 asset-detail.vue 的 <script setup> 里通过 siblingFadeOpacity 实时绑 opacity,
|
||||
与 pullDistance 同步淡出。transition 已经在 inline :style 声明,这里仅补 will-change 提示 GPU 合成 */
|
||||
.card-siblings {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* 2026-07-28 fix (sibling fade-in): expanded 态下的 .card-siblings overlay,
|
||||
父级 detail-container 顶层渲染,仅 expanded 态挂载。opacity 绑 siblingFadeOpacity,
|
||||
上拉收起过程中 0→1 渐显,接住原本因 scroll-view 销毁而丢失的渐显视觉。
|
||||
position: fixed + bottom: 0 让 overlay 始终贴底;跟手过程 wrapper surface 整体
|
||||
translateY(-pullDistance) 把卡片往上推,overlay 自然落在腾出的下方空间。
|
||||
pointer-events: none 让上拉手势从 overlay 范围也能穿透到 .pull-collapse-surface;
|
||||
单独给 .like-area/.creator-section/.toggle-btn/.chain-hash 开 auto,让交互元素仍可点 */
|
||||
.card-siblings-expanded-overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 60;
|
||||
padding: 40rpx;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-siblings-expanded-overlay .like-area,
|
||||
.card-siblings-expanded-overlay .creator-section,
|
||||
.card-siblings-expanded-overlay .toggle-btn,
|
||||
.card-siblings-expanded-overlay .chain-hash {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 2026-07-27 refactor (Finding B): 提示文案由父级渲染,组件不再持有
|
||||
2026-07-28 fix: 改 fixed 定位脱离滚动容器与 pull transform,固定在 header(top:96rpx, z-index:100)
|
||||
下方;z-index 95 保证盖住卡片和滚动内容,但仍在 header 之下 */
|
||||
.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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user