Replace SCSS variable usages with explicit pixel/hex values for consistent styling across all components Fix broken template syntax including missing class spaces and incorrect closing tags Migrate constant and API imports to centralized @/constants and @/api modules Add new utility classes: IdUtils, CallbackUtils, and TimerUtils Add new chat conversation API endpoints for recent conversations and message lists Add new Discovery page components (FindTabs, QuickQuestions, CardSwiper) and their styles Update app store config to use environment variables for base API and WebSocket URLs Add new selected tab icon assets
225 lines
6.3 KiB
Vue
225 lines
6.3 KiB
Vue
<template>
|
||
<div class="card border-box pb-12 relative mt-12">
|
||
<div 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)">
|
||
<div class="inner-card bg-white">
|
||
<!-- 商品大图部分:自适应剩余空间 -->
|
||
<div class="goods-image-wrapper relative">
|
||
<img class="w-full h-full" :src="card.commodityPhoto" mode="aspectFill" />
|
||
</div>
|
||
|
||
<!-- 底部:价格 + 购买按钮 -->
|
||
<div class="card-footer border-box p-12 flex flex-justify-between flex-items-center">
|
||
<div class="border-box">
|
||
<div class="font-size-14 font-500 color-333 ellipsis-1">
|
||
{{ card.commodityName }}
|
||
</div>
|
||
<div class="card-price-row color-333">
|
||
<span class="font-size-11">¥</span>
|
||
<span class="font-size-24 font-bold">
|
||
{{ card.specificationPrice }}
|
||
</span>
|
||
<span class="font-size-11 ml-2" v-if="card.stockUnitLabel">/{{ card.stockUnitLabel }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="buy-btn" @click.stop="placeOrderHandle(card)">购买</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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 predivScales = [1, 0.94, 0.86];
|
||
const predivOffsets = [0, 18, 39];
|
||
const scale = predivScales[index] ?? 0.94;
|
||
const y = predivOffsets[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>
|