topfans/frontend/components/AssetCardPullExpand/AssetCardPullExpand.vue
zerosaturation 3d984f19cd feat(asset-detail): pull-down/up card gestures with smooth follow
Add AssetCardPullExpand component for locked-state display and wire
up/down gesture symmetry on the asset detail page.

Down-pull at top of list:
- card scales 1.0 -> 1.6 with translateY follow, sibling modules fade
  out, hint text '下拉查看大图' -> '松手查看大图' past 80rpx
- on release past threshold, viewMode flips to expanded

Up-pull in expanded state:
- card shrinks 1.6 -> 1.0 with translateY follow, sibling modules fade
  back in, hint text '上拉收起' -> '松手收起' past 80rpx
- on release past threshold, viewMode flips back to normal
- the locked scale(1.6) lives on the wrapper so the release animation
  stays single-jump, not dual-jump

Implementation:
- AssetCardPullExpand renders the card at scale(1) and exposes isExpanded
  for the parent's locked-state toggle (showMask prop gates the legacy
  full-screen tap-to-collapse overlay)
- both gesture surfaces use lazy startY capture in onMove to avoid
  tap-induced jumps; no CSS transition on the wrapper during active pull
- 0.15s transform transition smooths the release interpolation only
- header bar and report/share buttons stay visible in both states;
  v-if/v-else-if/v-else chain (loading -> error -> expanded -> scroll-view)
  preserved; existing 8 card class names and parent CSS untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:13:39 +08:00

246 lines
7.0 KiB
Vue

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