修改
This commit is contained in:
Vendored
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user