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>
246 lines
7.0 KiB
Vue
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>
|