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_SERVER_BASEURL=https://howhowfresh.com/prod-api
VITE_SERVER_BASEURL=http://192.168.5.11:8080
VITE_SERVER_BASEURL=https://howhowfresh.com/prod-api
#VITE_SERVER_BASEURL=http://192.168.5.11:8080
#VITE_SERVER_BASEURL=http://192.168.0.148:8888
#VITE_SERVER_BASEURL=http://liuyao.nat100.top/meiguowaimai
@@ -1,57 +1,54 @@
<template>
<view class="class-bullet-container">
<!-- 第一行自动缓慢滚动 + 手动 -->
<view class="scroll-row first-row">
<scroll-view
class="ab-scroll mb-10rpx"
scroll-x
show-scrollbar="false"
:scroll-left="scrollLeft1"
@touchstart="onTouchStart1"
@touchend="onTouchEnd1"
@touchcancel="onTouchEnd1"
@scroll="onScroll1"
>
<view class="sv-inner" id="sv1-inner">
<!-- 一份内容 -->
<view
v-for="(item, idx) in categories"
:key="item.id + '-row1-a-' + idx"
class="category-item"
@click="handleItemClick(item)"
>
<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
class="scroll-row"
@touchstart="onTouchStart1"
@touchmove.stop.prevent="onTouchMove1"
@touchend="onTouchEnd1"
@touchcancel="onTouchEnd1"
>
<view class="marquee-viewport">
<view class="marquee-track" :style="trackStyle1">
<view class="track-group row1-group-a">
<view
v-for="(item, idx) in categories"
:key="item.id + '-row1-a-' + idx"
class="category-item"
@click="handleItemClick(item)"
>
<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
v-for="(item, idx) in categories"
:key="item.id + '-row1-b-' + idx"
class="category-item"
@click="handleItemClick(item)"
>
<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 class="track-group">
<view
v-for="(item, idx) in categories"
:key="item.id + '-row1-b-' + idx"
class="category-item"
@click="handleItemClick(item)"
>
<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>
</scroll-view>
</view>
</view>
<!-- 第二行反向数据自动滚动速度略不同 -->
<view class="scroll-row">
<scroll-view
class="ab-scroll"
scroll-x
show-scrollbar="false"
:scroll-left="scrollLeft2"
@touchstart="onTouchStart2"
@touchend="onTouchEnd2"
@touchcancel="onTouchEnd2"
@scroll="onScroll2"
>
<view class="sv-inner" id="sv2-inner">
<!-- 第二行同向跑马灯 + 手动拖动 -->
<view
class="scroll-row"
@touchstart="onTouchStart2"
@touchmove.stop.prevent="onTouchMove2"
@touchend="onTouchEnd2"
@touchcancel="onTouchEnd2"
>
<view class="marquee-viewport">
<view class="marquee-track" :style="trackStyle2">
<view class="track-group row2-group-a">
<view
v-for="(item, idx) in categoriesReversed"
v-for="(item, idx) in categories"
:key="item.id + '-row2-a-' + idx"
class="category-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" />
<text class="category-text">{{ item.categoryName || item.name }}</text>
</view>
</view>
<view
v-for="(item, idx) in categoriesReversed"
v-for="(item, idx) in categories"
:key="item.id + '-row2-b-' + idx"
class="category-item"
@click="handleItemClick(item)"
@@ -69,14 +67,13 @@
<text class="category-text">{{ item.categoryName || item.name }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<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'
// 定义分类项接口(与模板字段一致)
@@ -121,127 +118,113 @@ const mockCategories: CategoryItem[] = [
const categories = computed(() => {
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 isTouching2 = ref(false)
const touchStartX1 = ref(0)
const touchStartX2 = ref(0)
const touchStartOffset1 = ref(0)
const touchStartOffset2 = ref(0)
let autoTimer1: any = null
let autoTimer2: any = null
let resumeTimer1: any = null
let resumeTimer2: any = null
// 速度和间隔可以根据效果微调
const AUTO_INTERVAL = 30
const AUTO_STEP_1 = 0.6
const AUTO_STEP_2 = 0.4
let resumeTimer1: ReturnType<typeof setTimeout> | null = null
let resumeTimer2: ReturnType<typeof setTimeout> | null = null
let raf1 = 0
let raf2 = 0
let lastTs1 = 0
let lastTs2 = 0
const RESUME_DELAY = 800
const RESET_THRESHOLD = 2 // 在距离末尾一定范围内就重置
const PX_PER_SEC_1 = 22
const PX_PER_SEC_2 = 18
function startAuto1() {
if (autoTimer1) return
autoTimer1 = setInterval(() => {
if (isTouching1.value) return
let next = scrollLeft1.value + AUTO_STEP_1
const max = maxScroll1.value
if (max > 0 && next >= max - RESET_THRESHOLD) {
// 距离最右侧很近时,从头开始,实现循环
next = 0
}
scrollLeft1.value = next
}, AUTO_INTERVAL)
const trackStyle1 = computed(() => ({
transform: `translate3d(${-offset1.value}px, 0, 0)`,
}))
const trackStyle2 = computed(() => ({
transform: `translate3d(${-offset2.value}px, 0, 0)`,
}))
function normalizeOffset(value: number, loopWidth: number) {
if (loopWidth <= 0) return value
let next = value % loopWidth
if (next < 0) next += loopWidth
return next
}
function startAuto2() {
if (autoTimer2) return
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() {
function onTouchStart1(e: any) {
const touch = e?.touches?.[0]
isTouching1.value = true
if (resumeTimer1) clearTimeout(resumeTimer1)
stopAuto1()
touchStartX1.value = Number(touch?.clientX || 0)
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() {
resumeTimer1 = setTimeout(() => {
isTouching1.value = false
startAuto1()
resumeTimer1 = null
}, RESUME_DELAY)
}
function onScroll1(e: any) {
const detail = e?.detail
if (detail && typeof detail.scrollLeft === 'number') {
scrollLeft1.value = detail.scrollLeft
// scrollWidth - viewportWidth 为最大可滚动距离
if (typeof detail.scrollWidth === 'number' && viewportWidth > 0) {
const max = detail.scrollWidth - viewportWidth
maxScroll1.value = max > 0 ? max : 0
}
function onTouchStart2(e: any) {
const touch = e?.touches?.[0]
isTouching2.value = true
touchStartX2.value = Number(touch?.clientX || 0)
touchStartOffset2.value = offset2.value
if (resumeTimer2) {
clearTimeout(resumeTimer2)
resumeTimer2 = null
}
}
function onTouchStart2() {
isTouching2.value = true
if (resumeTimer2) clearTimeout(resumeTimer2)
stopAuto2()
function onTouchMove2(e: any) {
if (!isTouching2.value) return
const touch = e?.touches?.[0]
const x = Number(touch?.clientX || 0)
const delta = x - touchStartX2.value
offset2.value = normalizeOffset(touchStartOffset2.value - delta, loopWidth2.value)
}
function onTouchEnd2() {
resumeTimer2 = setTimeout(() => {
isTouching2.value = false
startAuto2()
resumeTimer2 = null
}, RESUME_DELAY)
}
function onScroll2(e: any) {
const detail = e?.detail
if (detail && typeof detail.scrollLeft === 'number') {
scrollLeft2.value = detail.scrollLeft
if (typeof detail.scrollWidth === 'number' && viewportWidth > 0) {
const max = detail.scrollWidth - viewportWidth
maxScroll2.value = max > 0 ? max : 0
}
function tickRow1(ts: number) {
if (!lastTs1) lastTs1 = ts
const dt = ts - lastTs1
lastTs1 = ts
if (!isTouching1.value) {
offset1.value = normalizeOffset(offset1.value + (PX_PER_SEC_1 * dt) / 1000, loopWidth1.value)
}
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
@@ -255,21 +238,57 @@ function navigateTo(url: string) {
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(() => {
nextTick(() => {
// 启动自动滚动,maxScroll 值会在第一次 scroll 事件时更新
setTimeout(() => {
startAuto1()
startAuto2()
}, 300)
ensureMeasuredWidths()
})
raf1 = requestAnimationFrame(tickRow1)
raf2 = requestAnimationFrame(tickRow2)
})
watch(
() => categories.value.length,
() => {
nextTick(() => {
ensureMeasuredWidths()
})
}
)
onUnmounted(() => {
stopAuto1()
stopAuto2()
if (resumeTimer1) clearTimeout(resumeTimer1)
if (resumeTimer2) clearTimeout(resumeTimer2)
if (raf1) cancelAnimationFrame(raf1)
if (raf2) cancelAnimationFrame(raf2)
if (resumeTimer1) {
clearTimeout(resumeTimer1)
resumeTimer1 = null
}
if (resumeTimer2) {
clearTimeout(resumeTimer2)
resumeTimer2 = null
}
})
</script>
@@ -279,7 +298,7 @@ onUnmounted(() => {
overflow: hidden;
.scroll-row {
margin-bottom: 20rpx;
margin-bottom: 12rpx;
overflow: hidden;
&:last-child {
@@ -287,15 +306,21 @@ onUnmounted(() => {
}
}
.ab-scroll {
.marquee-viewport {
width: 100%;
overflow: hidden;
}
.sv-inner {
.marquee-track {
display: flex;
flex-direction: row;
gap: 20rpx;
flex-wrap: nowrap;
width: max-content;
will-change: transform;
}
.track-group {
display: flex;
flex-wrap: nowrap;
}
.category-item {
@@ -304,6 +329,7 @@ onUnmounted(() => {
justify-content: center;
min-width: 120rpx;
height: 60rpx;
margin-right: 20rpx;
padding: 0 20rpx;
background: #fff;
border: none;
@@ -331,6 +357,4 @@ onUnmounted(() => {
}
}
}
// 取消 CSS 动画,由 JS 控制的 scroll-left 实现无缝滚动,避免与手势滚动冲突导致的抖动
</style>