This commit is contained in:
2026-05-08 09:20:05 +08:00
parent 51d016655c
commit 2a2af01097
2 changed files with 184 additions and 160 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ NODE_ENV=development
VITE_DELETE_CONSOLE=false VITE_DELETE_CONSOLE=false
#本地环境 #本地环境
#VITE_SERVER_BASEURL=https://howhowfresh.com/prod-api VITE_SERVER_BASEURL=https://howhowfresh.com/prod-api
VITE_SERVER_BASEURL=http://192.168.5.11:8080 #VITE_SERVER_BASEURL=http://192.168.5.11:8080
#VITE_SERVER_BASEURL=http://192.168.0.148:8888 #VITE_SERVER_BASEURL=http://192.168.0.148:8888
#VITE_SERVER_BASEURL=http://liuyao.nat100.top/meiguowaimai #VITE_SERVER_BASEURL=http://liuyao.nat100.top/meiguowaimai
@@ -1,57 +1,54 @@
<template> <template>
<view class="class-bullet-container"> <view class="class-bullet-container">
<!-- 第一行自动缓慢滚动 + 手动 --> <!-- 第一行跑马灯 + 手动 -->
<view class="scroll-row first-row"> <view
<scroll-view class="scroll-row"
class="ab-scroll mb-10rpx" @touchstart="onTouchStart1"
scroll-x @touchmove.stop.prevent="onTouchMove1"
show-scrollbar="false" @touchend="onTouchEnd1"
:scroll-left="scrollLeft1" @touchcancel="onTouchEnd1"
@touchstart="onTouchStart1" >
@touchend="onTouchEnd1" <view class="marquee-viewport">
@touchcancel="onTouchEnd1" <view class="marquee-track" :style="trackStyle1">
@scroll="onScroll1" <view class="track-group row1-group-a">
> <view
<view class="sv-inner" id="sv1-inner"> v-for="(item, idx) in categories"
<!-- 一份内容 --> :key="item.id + '-row1-a-' + idx"
<view class="category-item"
v-for="(item, idx) in categories" @click="handleItemClick(item)"
:key="item.id + '-row1-a-' + idx" >
class="category-item" <image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
@click="handleItemClick(item)" <text class="category-text">{{ item.categoryName || item.name }}</text>
> </view>
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
<text class="category-text">{{ item.categoryName || item.name }}</text>
</view> </view>
<!-- 第二份重复内容用于无缝滚动 --> <view class="track-group">
<view <view
v-for="(item, idx) in categories" v-for="(item, idx) in categories"
:key="item.id + '-row1-b-' + idx" :key="item.id + '-row1-b-' + idx"
class="category-item" class="category-item"
@click="handleItemClick(item)" @click="handleItemClick(item)"
> >
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" /> <image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
<text class="category-text">{{ item.categoryName || item.name }}</text> <text class="category-text">{{ item.categoryName || item.name }}</text>
</view>
</view> </view>
</view> </view>
</scroll-view> </view>
</view> </view>
<!-- 第二行反向数据自动滚动速度略不同 --> <!-- 第二行同向跑马灯 + 手动拖动 -->
<view class="scroll-row"> <view
<scroll-view class="scroll-row"
class="ab-scroll" @touchstart="onTouchStart2"
scroll-x @touchmove.stop.prevent="onTouchMove2"
show-scrollbar="false" @touchend="onTouchEnd2"
:scroll-left="scrollLeft2" @touchcancel="onTouchEnd2"
@touchstart="onTouchStart2" >
@touchend="onTouchEnd2" <view class="marquee-viewport">
@touchcancel="onTouchEnd2" <view class="marquee-track" :style="trackStyle2">
@scroll="onScroll2" <view class="track-group row2-group-a">
>
<view class="sv-inner" id="sv2-inner">
<view <view
v-for="(item, idx) in categoriesReversed" v-for="(item, idx) in categories"
:key="item.id + '-row2-a-' + idx" :key="item.id + '-row2-a-' + idx"
class="category-item" class="category-item"
@click="handleItemClick(item)" @click="handleItemClick(item)"
@@ -59,8 +56,9 @@
<image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" /> <image v-if="item.categoryImage || item.logoUrl" :src="item.categoryImage || item.logoUrl" class="category-icon" mode="aspectFit" />
<text class="category-text">{{ item.categoryName || item.name }}</text> <text class="category-text">{{ item.categoryName || item.name }}</text>
</view> </view>
</view>
<view <view
v-for="(item, idx) in categoriesReversed" v-for="(item, idx) in categories"
:key="item.id + '-row2-b-' + idx" :key="item.id + '-row2-b-' + idx"
class="category-item" class="category-item"
@click="handleItemClick(item)" @click="handleItemClick(item)"
@@ -69,14 +67,13 @@
<text class="category-text">{{ item.categoryName || item.name }}</text> <text class="category-text">{{ item.categoryName || item.name }}</text>
</view> </view>
</view> </view>
</scroll-view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue' import { computed, ref, onMounted, onUnmounted, nextTick, getCurrentInstance, watch } from 'vue'
import { useCategoryNavStore } from '@/store' import { useCategoryNavStore } from '@/store'
// 定义分类项接口(与模板字段一致) // 定义分类项接口(与模板字段一致)
@@ -121,127 +118,113 @@ const mockCategories: CategoryItem[] = [
const categories = computed(() => { const categories = computed(() => {
return props.categories.length > 0 ? props.categories : mockCategories return props.categories.length > 0 ? props.categories : mockCategories
}) })
// 第二行使用反向顺序以形成与第一行相反的视觉效果
const categoriesReversed = computed(() => {
const list = categories.value || []
return list.slice().reverse()
})
// ===== 自动滚动相关(电商标签推荐风格) =====
// 视口宽度,用于计算最大可滚动距离(scrollWidth - viewportWidth
const systemInfo = uni.getSystemInfoSync()
const viewportWidth = systemInfo.windowWidth || 0
const scrollLeft1 = ref(0)
const scrollLeft2 = ref(0)
// 最大可滚动距离(由 scroll 事件提供,避免自己算)
const maxScroll1 = ref(0)
const maxScroll2 = ref(0)
const offset1 = ref(0)
const offset2 = ref(0)
const loopWidth1 = ref(0)
const loopWidth2 = ref(0)
const isTouching1 = ref(false) const isTouching1 = ref(false)
const isTouching2 = ref(false) const isTouching2 = ref(false)
const touchStartX1 = ref(0)
const touchStartX2 = ref(0)
const touchStartOffset1 = ref(0)
const touchStartOffset2 = ref(0)
let autoTimer1: any = null let resumeTimer1: ReturnType<typeof setTimeout> | null = null
let autoTimer2: any = null let resumeTimer2: ReturnType<typeof setTimeout> | null = null
let resumeTimer1: any = null let raf1 = 0
let resumeTimer2: any = null let raf2 = 0
let lastTs1 = 0
// 速度和间隔可以根据效果微调 let lastTs2 = 0
const AUTO_INTERVAL = 30
const AUTO_STEP_1 = 0.6
const AUTO_STEP_2 = 0.4
const RESUME_DELAY = 800 const RESUME_DELAY = 800
const RESET_THRESHOLD = 2 // 在距离末尾一定范围内就重置 const PX_PER_SEC_1 = 22
const PX_PER_SEC_2 = 18
function startAuto1() { const trackStyle1 = computed(() => ({
if (autoTimer1) return transform: `translate3d(${-offset1.value}px, 0, 0)`,
autoTimer1 = setInterval(() => { }))
if (isTouching1.value) return
let next = scrollLeft1.value + AUTO_STEP_1 const trackStyle2 = computed(() => ({
const max = maxScroll1.value transform: `translate3d(${-offset2.value}px, 0, 0)`,
if (max > 0 && next >= max - RESET_THRESHOLD) { }))
// 距离最右侧很近时,从头开始,实现循环
next = 0 function normalizeOffset(value: number, loopWidth: number) {
} if (loopWidth <= 0) return value
scrollLeft1.value = next let next = value % loopWidth
}, AUTO_INTERVAL) if (next < 0) next += loopWidth
return next
} }
function startAuto2() { function onTouchStart1(e: any) {
if (autoTimer2) return const touch = e?.touches?.[0]
autoTimer2 = setInterval(() => {
if (isTouching2.value) return
let next = scrollLeft2.value + AUTO_STEP_2
const max = maxScroll2.value
if (max > 0 && next >= max - RESET_THRESHOLD) {
next = 0
}
scrollLeft2.value = next
}, AUTO_INTERVAL)
}
function stopAuto1() {
if (autoTimer1) {
clearInterval(autoTimer1)
autoTimer1 = null
}
}
function stopAuto2() {
if (autoTimer2) {
clearInterval(autoTimer2)
autoTimer2 = null
}
}
function onTouchStart1() {
isTouching1.value = true isTouching1.value = true
if (resumeTimer1) clearTimeout(resumeTimer1) touchStartX1.value = Number(touch?.clientX || 0)
stopAuto1() touchStartOffset1.value = offset1.value
if (resumeTimer1) {
clearTimeout(resumeTimer1)
resumeTimer1 = null
}
}
function onTouchMove1(e: any) {
if (!isTouching1.value) return
const touch = e?.touches?.[0]
const x = Number(touch?.clientX || 0)
const delta = x - touchStartX1.value
offset1.value = normalizeOffset(touchStartOffset1.value - delta, loopWidth1.value)
} }
function onTouchEnd1() { function onTouchEnd1() {
resumeTimer1 = setTimeout(() => { resumeTimer1 = setTimeout(() => {
isTouching1.value = false isTouching1.value = false
startAuto1() resumeTimer1 = null
}, RESUME_DELAY) }, RESUME_DELAY)
} }
function onScroll1(e: any) { function onTouchStart2(e: any) {
const detail = e?.detail const touch = e?.touches?.[0]
if (detail && typeof detail.scrollLeft === 'number') { isTouching2.value = true
scrollLeft1.value = detail.scrollLeft touchStartX2.value = Number(touch?.clientX || 0)
// scrollWidth - viewportWidth 为最大可滚动距离 touchStartOffset2.value = offset2.value
if (typeof detail.scrollWidth === 'number' && viewportWidth > 0) { if (resumeTimer2) {
const max = detail.scrollWidth - viewportWidth clearTimeout(resumeTimer2)
maxScroll1.value = max > 0 ? max : 0 resumeTimer2 = null
}
} }
} }
function onTouchStart2() { function onTouchMove2(e: any) {
isTouching2.value = true if (!isTouching2.value) return
if (resumeTimer2) clearTimeout(resumeTimer2) const touch = e?.touches?.[0]
stopAuto2() const x = Number(touch?.clientX || 0)
const delta = x - touchStartX2.value
offset2.value = normalizeOffset(touchStartOffset2.value - delta, loopWidth2.value)
} }
function onTouchEnd2() { function onTouchEnd2() {
resumeTimer2 = setTimeout(() => { resumeTimer2 = setTimeout(() => {
isTouching2.value = false isTouching2.value = false
startAuto2() resumeTimer2 = null
}, RESUME_DELAY) }, RESUME_DELAY)
} }
function onScroll2(e: any) { function tickRow1(ts: number) {
const detail = e?.detail if (!lastTs1) lastTs1 = ts
if (detail && typeof detail.scrollLeft === 'number') { const dt = ts - lastTs1
scrollLeft2.value = detail.scrollLeft lastTs1 = ts
if (typeof detail.scrollWidth === 'number' && viewportWidth > 0) { if (!isTouching1.value) {
const max = detail.scrollWidth - viewportWidth offset1.value = normalizeOffset(offset1.value + (PX_PER_SEC_1 * dt) / 1000, loopWidth1.value)
maxScroll2.value = max > 0 ? max : 0
}
} }
raf1 = requestAnimationFrame(tickRow1)
}
function tickRow2(ts: number) {
if (!lastTs2) lastTs2 = ts
const dt = ts - lastTs2
lastTs2 = ts
if (!isTouching2.value) {
offset2.value = normalizeOffset(offset2.value + (PX_PER_SEC_2 * dt) / 1000, loopWidth2.value)
}
raf2 = requestAnimationFrame(tickRow2)
} }
// 点击处理(列表页菜谱 id 来自 store,不放在 URL query // 点击处理(列表页菜谱 id 来自 store,不放在 URL query
@@ -255,21 +238,57 @@ function navigateTo(url: string) {
uni.navigateTo({ url }) uni.navigateTo({ url })
} }
function measureLoopWidths() {
const instance = getCurrentInstance()
if (!instance) return
const query = uni.createSelectorQuery().in(instance.proxy)
query.select('.row1-group-a').boundingClientRect()
query.select('.row2-group-a').boundingClientRect()
query.exec((res: Array<{ width?: number } | null>) => {
const w1 = Number(res?.[0]?.width || 0)
const w2 = Number(res?.[1]?.width || 0)
loopWidth1.value = w1 > 0 ? w1 : 0
loopWidth2.value = w2 > 0 ? w2 : 0
})
}
function ensureMeasuredWidths(retry = 0) {
measureLoopWidths()
if (retry >= 8) return
setTimeout(() => {
if (loopWidth1.value > 0 && loopWidth2.value > 0) return
ensureMeasuredWidths(retry + 1)
}, 220)
}
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
// 启动自动滚动,maxScroll 值会在第一次 scroll 事件时更新 ensureMeasuredWidths()
setTimeout(() => {
startAuto1()
startAuto2()
}, 300)
}) })
raf1 = requestAnimationFrame(tickRow1)
raf2 = requestAnimationFrame(tickRow2)
}) })
watch(
() => categories.value.length,
() => {
nextTick(() => {
ensureMeasuredWidths()
})
}
)
onUnmounted(() => { onUnmounted(() => {
stopAuto1() if (raf1) cancelAnimationFrame(raf1)
stopAuto2() if (raf2) cancelAnimationFrame(raf2)
if (resumeTimer1) clearTimeout(resumeTimer1) if (resumeTimer1) {
if (resumeTimer2) clearTimeout(resumeTimer2) clearTimeout(resumeTimer1)
resumeTimer1 = null
}
if (resumeTimer2) {
clearTimeout(resumeTimer2)
resumeTimer2 = null
}
}) })
</script> </script>
@@ -279,7 +298,7 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
.scroll-row { .scroll-row {
margin-bottom: 20rpx; margin-bottom: 12rpx;
overflow: hidden; overflow: hidden;
&:last-child { &:last-child {
@@ -287,15 +306,21 @@ onUnmounted(() => {
} }
} }
.ab-scroll { .marquee-viewport {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
} }
.sv-inner { .marquee-track {
display: flex; display: flex;
flex-direction: row; flex-wrap: nowrap;
gap: 20rpx; width: max-content;
will-change: transform;
}
.track-group {
display: flex;
flex-wrap: nowrap;
} }
.category-item { .category-item {
@@ -304,6 +329,7 @@ onUnmounted(() => {
justify-content: center; justify-content: center;
min-width: 120rpx; min-width: 120rpx;
height: 60rpx; height: 60rpx;
margin-right: 20rpx;
padding: 0 20rpx; padding: 0 20rpx;
background: #fff; background: #fff;
border: none; border: none;
@@ -331,6 +357,4 @@ onUnmounted(() => {
} }
} }
} }
// 取消 CSS 动画,由 JS 控制的 scroll-left 实现无缝滚动,避免与手势滚动冲突导致的抖动
</style> </style>