修改
This commit is contained in:
Vendored
+2
-2
@@ -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,19 +1,16 @@
|
|||||||
<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"
|
|
||||||
scroll-x
|
|
||||||
show-scrollbar="false"
|
|
||||||
:scroll-left="scrollLeft1"
|
|
||||||
@touchstart="onTouchStart1"
|
@touchstart="onTouchStart1"
|
||||||
|
@touchmove.stop.prevent="onTouchMove1"
|
||||||
@touchend="onTouchEnd1"
|
@touchend="onTouchEnd1"
|
||||||
@touchcancel="onTouchEnd1"
|
@touchcancel="onTouchEnd1"
|
||||||
@scroll="onScroll1"
|
|
||||||
>
|
>
|
||||||
<view class="sv-inner" id="sv1-inner">
|
<view class="marquee-viewport">
|
||||||
<!-- 一份内容 -->
|
<view class="marquee-track" :style="trackStyle1">
|
||||||
|
<view class="track-group row1-group-a">
|
||||||
<view
|
<view
|
||||||
v-for="(item, idx) in categories"
|
v-for="(item, idx) in categories"
|
||||||
:key="item.id + '-row1-a-' + idx"
|
:key="item.id + '-row1-a-' + idx"
|
||||||
@@ -23,7 +20,8 @@
|
|||||||
<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 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"
|
||||||
@@ -34,24 +32,23 @@
|
|||||||
<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 class="scroll-row">
|
<view
|
||||||
<scroll-view
|
class="scroll-row"
|
||||||
class="ab-scroll"
|
|
||||||
scroll-x
|
|
||||||
show-scrollbar="false"
|
|
||||||
:scroll-left="scrollLeft2"
|
|
||||||
@touchstart="onTouchStart2"
|
@touchstart="onTouchStart2"
|
||||||
|
@touchmove.stop.prevent="onTouchMove2"
|
||||||
@touchend="onTouchEnd2"
|
@touchend="onTouchEnd2"
|
||||||
@touchcancel="onTouchEnd2"
|
@touchcancel="onTouchEnd2"
|
||||||
@scroll="onScroll2"
|
|
||||||
>
|
>
|
||||||
<view class="sv-inner" id="sv2-inner">
|
<view class="marquee-viewport">
|
||||||
|
<view class="marquee-track" :style="trackStyle2">
|
||||||
|
<view class="track-group row2-group-a">
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user