Files
YGChatCS/src/components/SwipeCards/index.vue
2025-12-15 18:38:48 +08:00

245 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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