245 lines
6.9 KiB
Vue
245 lines
6.9 KiB
Vue
<template>
|
||
<view class="card border-box pb-12 relative mt-12">
|
||
<view
|
||
class="card-item absolute overflow-hidden"
|
||
v-for="(card, index) in list"
|
||
:key="card.__uid"
|
||
:style="[itemStyle(index, card), transformStyle(index, card)]"
|
||
@touchstart.stop="touchStart($event, index)"
|
||
@touchmove.stop.prevent="touchMove($event, index)"
|
||
@touchend.stop="touchEnd(index)"
|
||
@touchcancel.stop="touchCancel(index)"
|
||
@transitionend="onTransitionEnd(index)"
|
||
>
|
||
<view class="inner-card bg-white">
|
||
<!-- 商品大图部分:自适应剩余空间 -->
|
||
<view class="goods-image-wrapper relative">
|
||
<image class="w-full h-full" :src="card.commodityPhoto" mode="aspectFill"/>
|
||
<view
|
||
class="goods-title absolute left-0 right-0 bottom-0 border-box p-12"
|
||
>
|
||
<view class="font-size-14 font-500 color-white ellipsis-1">
|
||
{{ card.commodityName }}
|
||
</view>
|
||
<view class="card-price-row color-white">
|
||
<text class="font-size-11">¥</text>
|
||
<text class="font-size-14 font-bold">
|
||
{{ card.specificationPrice }}
|
||
</text>
|
||
<text class="font-size-11 ml-2" v-if="card.stockUnitLabel"
|
||
>/{{ card.stockUnitLabel }}</text
|
||
>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 底部相册部分:固定比例或高度 -->
|
||
<view class="border-box p-12 flex flex-justify-between">
|
||
<view
|
||
v-for="(item, index) in card.commodityPhotoList"
|
||
:key="index"
|
||
class="album-item relative overflow-hidden bg-f5 rounded-10"
|
||
>
|
||
<image :src="item.photoUrl" mode="aspectFill" />
|
||
<view
|
||
class="album-title absolute left-0 right-0 bottom-0 color-white font-size-11 font-500 ellipsis-1 flex flex-items-center flex-justify-center"
|
||
>
|
||
{{ item.photoName }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch } from "vue";
|
||
import { checkToken } from "@/hooks/useGoLogin";
|
||
|
||
const props = defineProps({
|
||
cardsData: { type: Array, default: () => [] },
|
||
});
|
||
|
||
const DURATION = 300;
|
||
const CLICK_THRESHOLD = 8; // 点击判定的最大偏移阈值(像素)
|
||
const swipering = ref(false);
|
||
const animatingOut = ref(false);
|
||
let reorderTimer = null;
|
||
|
||
const { windowWidth } = uni.getWindowInfo();
|
||
|
||
let uidCounter = 0;
|
||
// 始终生成全局唯一的 __uid,避免因重复 key 导致后续卡片无法正确重渲染与绑定事件
|
||
const genUid = (item) =>
|
||
`swipe_${item?.commodityId ?? "unknown"}_${uidCounter++}_${Date.now()}`;
|
||
const normalize = (item) => ({
|
||
...item,
|
||
__uid: genUid(item),
|
||
x: 0,
|
||
y: 0,
|
||
opacity: 1,
|
||
});
|
||
|
||
// 循环队列(全量),堆栈(仅前3张)
|
||
const queue = ref((props.cardsData || []).map(normalize));
|
||
const list = ref(queue.value.slice(0, 3));
|
||
const updateStack = () => {
|
||
list.value = queue.value.slice(0, 3);
|
||
};
|
||
|
||
watch(
|
||
() => props.cardsData,
|
||
(val) => {
|
||
queue.value = (val || []).map(normalize);
|
||
updateStack();
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// 触摸状态
|
||
const touchState = ref({
|
||
startX: 0,
|
||
startY: 0,
|
||
moving: false,
|
||
isClickCandidate: false,
|
||
});
|
||
|
||
const touchStart = (e, index) => {
|
||
if (index !== 0 || animatingOut.value) return;
|
||
const t = e.changedTouches?.[0];
|
||
if (!t) return;
|
||
touchState.value.startX = t.clientX;
|
||
touchState.value.startY = t.clientY;
|
||
touchState.value.moving = true;
|
||
// 初始认为可能是点击,移动过程中如果超过阈值则取消点击
|
||
touchState.value.isClickCandidate = true;
|
||
swipering.value = true;
|
||
};
|
||
|
||
const touchMove = (e, index) => {
|
||
if (index !== 0 || !touchState.value.moving || animatingOut.value) return;
|
||
const t = e.changedTouches?.[0];
|
||
if (!t) return;
|
||
const dx = t.clientX - touchState.value.startX;
|
||
const dy = t.clientY - touchState.value.startY;
|
||
// 超过点击阈值则标记为不是点击
|
||
if (Math.abs(dx) > CLICK_THRESHOLD || Math.abs(dy) > CLICK_THRESHOLD) {
|
||
touchState.value.isClickCandidate = false;
|
||
}
|
||
const top = list.value[0];
|
||
if (!top) return;
|
||
top.x = dx;
|
||
top.y = dy;
|
||
};
|
||
|
||
const finalizeReorder = () => {
|
||
const top = queue.value[0];
|
||
if (!top) return;
|
||
const moved = { ...top, x: 0, y: 0, opacity: 1 };
|
||
queue.value = [...queue.value.slice(1), moved];
|
||
updateStack();
|
||
animatingOut.value = false;
|
||
if (reorderTimer) {
|
||
clearTimeout(reorderTimer);
|
||
reorderTimer = null;
|
||
}
|
||
};
|
||
|
||
const touchEnd = (index) => {
|
||
if (index !== 0 || !touchState.value.moving) return;
|
||
touchState.value.moving = false;
|
||
swipering.value = false;
|
||
const top = list.value[0];
|
||
if (!top) return;
|
||
// 若在有效点击范围内,则触发跳转,不进行滑动逻辑
|
||
if (touchState.value.isClickCandidate) {
|
||
top.x = 0;
|
||
top.y = 0;
|
||
top.opacity = 1;
|
||
uni.navigateTo({
|
||
url: `/pages/goods/index?commodityId=${top.commodityId}`,
|
||
});
|
||
return;
|
||
}
|
||
const threshold = windowWidth / 4;
|
||
if (Math.abs(top.x) > threshold) {
|
||
const direction = top.x > 0 ? 1 : -1;
|
||
animatingOut.value = true;
|
||
top.x = direction * (windowWidth + 100);
|
||
top.opacity = 0;
|
||
if (reorderTimer) {
|
||
clearTimeout(reorderTimer);
|
||
reorderTimer = null;
|
||
}
|
||
reorderTimer = setTimeout(() => {
|
||
if (animatingOut.value) finalizeReorder();
|
||
}, DURATION + 40);
|
||
} else {
|
||
// 回弹复位
|
||
top.x = 0;
|
||
top.y = 0;
|
||
top.opacity = 1;
|
||
}
|
||
};
|
||
|
||
const touchCancel = (index) => {
|
||
if (index !== 0) return;
|
||
const top = list.value[0];
|
||
if (!top) return;
|
||
touchState.value.moving = false;
|
||
swipering.value = false;
|
||
top.x = 0;
|
||
top.y = 0;
|
||
top.opacity = 1;
|
||
};
|
||
|
||
const onTransitionEnd = (index) => {
|
||
if (index !== 0) return;
|
||
if (!animatingOut.value) return;
|
||
finalizeReorder();
|
||
};
|
||
|
||
// 栈样式:层级、基础位移缩放、过渡时长
|
||
const itemStyle = (index, card) => {
|
||
const zIndex = list.value.length - index;
|
||
const duration = swipering.value ? "0ms" : `${DURATION}ms`;
|
||
const opacity = card.opacity;
|
||
return {
|
||
zIndex,
|
||
opacity,
|
||
transition: `transform ${duration} ease, opacity ${duration} ease`,
|
||
};
|
||
};
|
||
|
||
// 变换样式:顶部卡动态位移/旋转,后续卡预览层级
|
||
const transformStyle = (index, card) => {
|
||
if (index === 0) {
|
||
const deg = card.x / 20;
|
||
return {
|
||
transform: `translate3d(${card.x}px, ${card.y}px, 0) rotate(${deg}deg)`,
|
||
};
|
||
}
|
||
// 预览层:轻微位移与缩放,确保连贯顶上
|
||
const previewScales = [1, 0.94, 0.86];
|
||
const previewOffsets = [0, 18, 39];
|
||
const scale = previewScales[index] ?? 0.94;
|
||
const y = previewOffsets[index] ?? 24;
|
||
return {
|
||
transform: `translate3d(0, ${y}px, 0) scale(${scale})`,
|
||
};
|
||
};
|
||
|
||
// 去下单
|
||
const placeOrderHandle = (item) => {
|
||
console.log("去下单", item);
|
||
|
||
uni.navigateTo({
|
||
url: `/pages/goods/index?commodityId=${item.commodityId}`,
|
||
});
|
||
};
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
@import "./styles/index.scss";
|
||
</style> |