2025-06-03 11:47:06 +08:00
|
|
|
|
<template>
|
2025-06-03 16:40:49 +08:00
|
|
|
|
<view class="simple-vertical-swiper" :style="{ height: height + 'rpx' }">
|
2025-06-03 11:47:06 +08:00
|
|
|
|
<view
|
|
|
|
|
class="swiper-wrapper"
|
|
|
|
|
@touchstart.stop="onTouchStart"
|
|
|
|
|
@touchmove.stop.prevent="onTouchMove"
|
|
|
|
|
@touchend.stop="onTouchEnd"
|
|
|
|
|
>
|
|
|
|
|
<view
|
2025-06-03 16:40:49 +08:00
|
|
|
|
v-for="(item, index) in displayItems"
|
2025-06-03 11:47:06 +08:00
|
|
|
|
:key="index"
|
|
|
|
|
class="swiper-slide"
|
2025-06-03 16:40:49 +08:00
|
|
|
|
:class="{ 'swiper-slide-active': index === virtualCurrentIndex }"
|
2025-06-03 11:47:06 +08:00
|
|
|
|
:style="getSlideStyle(index)"
|
|
|
|
|
>
|
|
|
|
|
<!-- 公告类型 -->
|
|
|
|
|
<view v-if="type === 'notice'" class="notice-item">
|
|
|
|
|
<text class="notice-icon">📢</text>
|
|
|
|
|
<text class="notice-text">{{ item.content || item }}</text>
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
<!-- 商品类型 -->
|
|
|
|
|
<view v-else-if="type === 'product'" class="product-item">
|
|
|
|
|
<image
|
|
|
|
|
class="product-image"
|
|
|
|
|
:src="item.image"
|
|
|
|
|
mode="aspectFill"
|
|
|
|
|
></image>
|
|
|
|
|
<view class="product-info">
|
|
|
|
|
<text class="product-name">{{ item.name }}</text>
|
|
|
|
|
<text class="product-price">¥{{ item.price }}</text>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
<!-- 内容类型 -->
|
|
|
|
|
<view v-else-if="type === 'content'" class="content-item">
|
|
|
|
|
<text class="content-title">{{ item.title }}</text>
|
|
|
|
|
<text class="content-desc">{{ item.description }}</text>
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
<!-- 文本类型 -->
|
|
|
|
|
<view v-else-if="type === 'text'" class="text-item">
|
|
|
|
|
<text class="text-content">{{ item.text || item }}</text>
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
<!-- 自定义插槽 -->
|
2025-06-03 16:40:49 +08:00
|
|
|
|
<slot v-else :item="item" :index="getOriginalIndex(index)">
|
2025-06-03 11:47:06 +08:00
|
|
|
|
<view class="default-item">
|
|
|
|
|
<text>{{ item.text || item.content || item }}</text>
|
|
|
|
|
</view>
|
|
|
|
|
</slot>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
<!-- 指示器 -->
|
|
|
|
|
<view v-if="showIndicators && items.length > 1" class="indicators">
|
|
|
|
|
<view
|
2025-06-03 16:40:49 +08:00
|
|
|
|
v-for="(_item, originalItemIndex) in items"
|
|
|
|
|
:key="originalItemIndex"
|
2025-06-03 11:47:06 +08:00
|
|
|
|
class="indicator"
|
2025-06-03 16:40:49 +08:00
|
|
|
|
:class="{ active: originalItemIndex === currentIndex }"
|
2025-06-03 11:47:06 +08:00
|
|
|
|
></view>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
export default {
|
|
|
|
|
name: 'SimpleVerticalSwiper',
|
|
|
|
|
props: {
|
|
|
|
|
// 轮播数据
|
|
|
|
|
items: {
|
|
|
|
|
type: Array,
|
|
|
|
|
default: () => [],
|
|
|
|
|
},
|
|
|
|
|
// 数据类型:notice(公告), product(商品), content(内容), text(文本), custom(自定义)
|
|
|
|
|
type: {
|
|
|
|
|
type: String,
|
|
|
|
|
default: 'text',
|
|
|
|
|
},
|
|
|
|
|
// 组件高度
|
|
|
|
|
height: {
|
|
|
|
|
type: Number,
|
|
|
|
|
default: 60,
|
|
|
|
|
},
|
|
|
|
|
// 是否自动轮播
|
|
|
|
|
autoplay: {
|
|
|
|
|
type: Boolean,
|
|
|
|
|
default: true,
|
|
|
|
|
},
|
|
|
|
|
// 自动轮播间隔时间(ms)
|
|
|
|
|
interval: {
|
|
|
|
|
type: Number,
|
|
|
|
|
default: 3000,
|
|
|
|
|
},
|
|
|
|
|
// 动画持续时间(ms)
|
|
|
|
|
duration: {
|
|
|
|
|
type: Number,
|
|
|
|
|
default: 300,
|
|
|
|
|
},
|
|
|
|
|
// 是否显示指示器
|
|
|
|
|
showIndicators: {
|
|
|
|
|
type: Boolean,
|
|
|
|
|
default: false,
|
|
|
|
|
},
|
|
|
|
|
// 是否循环轮播
|
|
|
|
|
circular: {
|
|
|
|
|
type: Boolean,
|
|
|
|
|
default: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
data() {
|
|
|
|
|
return {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
currentIndex: 0, // Index in original items array
|
|
|
|
|
virtualCurrentIndex: 0, // Index in displayItems array
|
2025-06-03 11:47:06 +08:00
|
|
|
|
timer: null,
|
|
|
|
|
isTransitioning: false,
|
2025-06-03 16:40:49 +08:00
|
|
|
|
isJumping: false, // Flag for instant jumps, to disable CSS transition
|
2025-06-03 11:47:06 +08:00
|
|
|
|
touchStartY: 0,
|
|
|
|
|
touchStartTime: 0,
|
|
|
|
|
isTouching: false,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
computed: {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
originalItemsLength() {
|
2025-06-03 11:47:06 +08:00
|
|
|
|
return this.items.length
|
|
|
|
|
},
|
2025-06-03 16:40:49 +08:00
|
|
|
|
displayItems() {
|
|
|
|
|
if (!this.items || this.originalItemsLength === 0) {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
if (!this.circular) {
|
|
|
|
|
return this.items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.originalItemsLength === 1) {
|
|
|
|
|
return [this.items[0], this.items[0], this.items[0]]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lastItem = this.items[this.originalItemsLength - 1]
|
|
|
|
|
const firstItem = this.items[0]
|
|
|
|
|
return [lastItem, ...this.items, firstItem]
|
|
|
|
|
},
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
|
|
|
|
watch: {
|
|
|
|
|
items: {
|
|
|
|
|
handler() {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
this.initSwiperState()
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
|
|
|
|
immediate: true,
|
|
|
|
|
},
|
|
|
|
|
autoplay(newVal) {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
if (this.originalItemsLength === 0) return
|
2025-06-03 11:47:06 +08:00
|
|
|
|
if (newVal) {
|
|
|
|
|
this.startAutoplay()
|
|
|
|
|
} else {
|
|
|
|
|
this.clearTimer()
|
|
|
|
|
}
|
|
|
|
|
},
|
2025-06-03 16:40:49 +08:00
|
|
|
|
circular() {
|
|
|
|
|
this.initSwiperState()
|
|
|
|
|
},
|
|
|
|
|
virtualCurrentIndex() {
|
|
|
|
|
// This watcher can be used for debugging or complex state reactions if needed
|
|
|
|
|
},
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
|
|
|
|
mounted() {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
// Initialization is handled by immediate watch on items
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
|
|
|
|
beforeDestroy() {
|
|
|
|
|
this.clearTimer()
|
|
|
|
|
},
|
|
|
|
|
// uniapp 生命周期
|
|
|
|
|
onReady() {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
this.initSwiperState()
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
|
|
|
|
onUnload() {
|
|
|
|
|
this.clearTimer()
|
|
|
|
|
},
|
|
|
|
|
methods: {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
getOriginalIndex(displayIndex) {
|
|
|
|
|
if (this.originalItemsLength === 0) return 0
|
|
|
|
|
if (!this.circular) {
|
|
|
|
|
return displayIndex
|
|
|
|
|
}
|
|
|
|
|
if (this.originalItemsLength === 1) {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
if (displayIndex === 0) return this.originalItemsLength - 1
|
|
|
|
|
if (displayIndex === this.displayItems.length - 1) return 0
|
|
|
|
|
if (displayIndex > 0 && displayIndex <= this.originalItemsLength) {
|
|
|
|
|
return displayIndex - 1
|
|
|
|
|
}
|
|
|
|
|
return 0 // Fallback, should ideally not be reached with correct logic
|
|
|
|
|
},
|
|
|
|
|
initSwiperState() {
|
|
|
|
|
this.clearTimer()
|
|
|
|
|
if (this.originalItemsLength === 0) {
|
|
|
|
|
this.currentIndex = 0
|
|
|
|
|
this.virtualCurrentIndex = 0
|
|
|
|
|
if (this.autoplay) this.startAutoplay() // Try to start autoplay even if no items initially
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.circular) {
|
|
|
|
|
if (this.originalItemsLength === 1) {
|
|
|
|
|
this.virtualCurrentIndex = 1
|
|
|
|
|
this.currentIndex = 0
|
|
|
|
|
} else {
|
|
|
|
|
this.virtualCurrentIndex = 1
|
|
|
|
|
this.currentIndex = 0
|
2025-06-03 11:47:06 +08:00
|
|
|
|
}
|
2025-06-03 16:40:49 +08:00
|
|
|
|
} else {
|
|
|
|
|
this.virtualCurrentIndex = 0
|
|
|
|
|
this.currentIndex = 0
|
2025-06-03 11:47:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
// Ensure DOM is updated if items change, then start autoplay
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
if (this.autoplay) {
|
|
|
|
|
this.startAutoplay()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
getSlideStyle(indexInDisplayItems) {
|
|
|
|
|
const offset = indexInDisplayItems - this.virtualCurrentIndex
|
|
|
|
|
const translateY = offset * this.height
|
|
|
|
|
|
2025-06-03 11:47:06 +08:00
|
|
|
|
let opacity = 0.6
|
|
|
|
|
let zIndex = 1
|
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
if (offset === 0) {
|
|
|
|
|
// Active slide
|
2025-06-03 11:47:06 +08:00
|
|
|
|
opacity = 1
|
|
|
|
|
zIndex = 10
|
2025-06-03 16:40:49 +08:00
|
|
|
|
} else if (Math.abs(offset) === 1) {
|
|
|
|
|
// Adjacent slides
|
|
|
|
|
opacity = 0.3
|
|
|
|
|
zIndex = 5
|
2025-06-03 11:47:06 +08:00
|
|
|
|
} else {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
// Far away slides
|
|
|
|
|
opacity = 0
|
|
|
|
|
zIndex = 0
|
2025-06-03 11:47:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const transition =
|
2025-06-03 16:40:49 +08:00
|
|
|
|
(this.isTransitioning || this.isJumping) &&
|
|
|
|
|
!this.isTouching &&
|
|
|
|
|
!(this.isJumping && !this.isTransitioning) // Allow transition if only isTransitioning is true
|
|
|
|
|
? this.isJumping
|
|
|
|
|
? 'none'
|
|
|
|
|
: `all ${this.duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`
|
2025-06-03 11:47:06 +08:00
|
|
|
|
: 'none'
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
transform: `translateY(${translateY}px)`,
|
|
|
|
|
opacity: opacity,
|
|
|
|
|
zIndex: zIndex,
|
|
|
|
|
transition: transition,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
startAutoplay() {
|
|
|
|
|
this.clearTimer()
|
2025-06-03 16:40:49 +08:00
|
|
|
|
// Condition for autoplay: must have items, and if not circular, must have more than 1 item.
|
|
|
|
|
const canAutoplay =
|
|
|
|
|
this.originalItemsLength > 0 &&
|
|
|
|
|
(this.circular || this.originalItemsLength > 1)
|
|
|
|
|
if (this.autoplay && canAutoplay) {
|
2025-06-03 11:47:06 +08:00
|
|
|
|
this.timer = setInterval(() => {
|
|
|
|
|
this.next()
|
|
|
|
|
}, this.interval)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
clearTimer() {
|
|
|
|
|
if (this.timer) {
|
|
|
|
|
clearInterval(this.timer)
|
|
|
|
|
this.timer = null
|
|
|
|
|
}
|
|
|
|
|
},
|
2025-06-03 16:40:49 +08:00
|
|
|
|
updateCurrentIndex(currentVirtualIdx, isMidJump = false) {
|
|
|
|
|
const oldOriginalIndex = this.currentIndex
|
|
|
|
|
let newOriginalIndex = 0
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
if (this.originalItemsLength === 0) {
|
|
|
|
|
this.currentIndex = 0
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
if (!this.circular) {
|
|
|
|
|
newOriginalIndex = currentVirtualIdx
|
|
|
|
|
} else if (this.originalItemsLength === 1) {
|
|
|
|
|
newOriginalIndex = 0
|
|
|
|
|
} else {
|
|
|
|
|
if (currentVirtualIdx === 0) {
|
|
|
|
|
newOriginalIndex = this.originalItemsLength - 1
|
|
|
|
|
} else if (currentVirtualIdx === this.displayItems.length - 1) {
|
|
|
|
|
newOriginalIndex = 0
|
|
|
|
|
} else {
|
|
|
|
|
newOriginalIndex = currentVirtualIdx - 1
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
this.currentIndex = newOriginalIndex
|
|
|
|
|
// Emit change only if it's not a mid-jump correction that results in the same original index visually
|
|
|
|
|
// Or if the original index actually changed.
|
|
|
|
|
if (oldOriginalIndex !== newOriginalIndex || !isMidJump) {
|
2025-06-03 11:47:06 +08:00
|
|
|
|
this.$emit('change', {
|
|
|
|
|
current: this.currentIndex,
|
|
|
|
|
currentItem: this.items[this.currentIndex],
|
|
|
|
|
})
|
2025-06-03 16:40:49 +08:00
|
|
|
|
}
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
2025-06-03 16:40:49 +08:00
|
|
|
|
moveTo(targetVirtualIndex, direction) {
|
|
|
|
|
// direction can be 'next' or 'prev', used to emit correct original index during transition
|
|
|
|
|
if (this.isTransitioning && !this.isJumping) return
|
|
|
|
|
if (this.originalItemsLength === 0) return
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
|
|
|
|
this.isTransitioning = true
|
2025-06-03 16:40:49 +08:00
|
|
|
|
this.virtualCurrentIndex = targetVirtualIndex
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
// Emit change based on where we are going (even if it's a clone)
|
|
|
|
|
this.updateCurrentIndex(targetVirtualIndex)
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.isTransitioning = false
|
2025-06-03 16:40:49 +08:00
|
|
|
|
if (this.circular) {
|
|
|
|
|
this.checkAndCorrectLoopBoundary()
|
|
|
|
|
}
|
2025-06-03 11:47:06 +08:00
|
|
|
|
}, this.duration)
|
|
|
|
|
},
|
2025-06-03 16:40:49 +08:00
|
|
|
|
checkAndCorrectLoopBoundary() {
|
|
|
|
|
if (this.originalItemsLength <= 1 && this.circular) {
|
|
|
|
|
// Special handling for 1 item circular
|
|
|
|
|
// displayItems = [item, item, item], virtualCurrentIndex can be 0, 1, 2
|
|
|
|
|
if (this.virtualCurrentIndex !== 1) {
|
|
|
|
|
this.isJumping = true
|
|
|
|
|
this.virtualCurrentIndex = 1 // Jump to the middle "real" one
|
|
|
|
|
this.updateCurrentIndex(1, true)
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
this.isJumping = false
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (this.originalItemsLength < 2 || !this.circular) return // Only for 2+ items circular
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
const firstRealItemVirtualIdx = 1
|
|
|
|
|
const lastRealItemVirtualIdx = this.originalItemsLength
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
let jumpToVirtualIndex = -1
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
if (this.virtualCurrentIndex === 0) {
|
|
|
|
|
jumpToVirtualIndex = lastRealItemVirtualIdx
|
|
|
|
|
} else if (this.virtualCurrentIndex === this.displayItems.length - 1) {
|
|
|
|
|
jumpToVirtualIndex = firstRealItemVirtualIdx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (jumpToVirtualIndex !== -1) {
|
|
|
|
|
this.isJumping = true
|
|
|
|
|
this.virtualCurrentIndex = jumpToVirtualIndex
|
|
|
|
|
this.updateCurrentIndex(jumpToVirtualIndex, true) // Update original index reflecting the jump destination
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
this.isJumping = false
|
2025-06-03 11:47:06 +08:00
|
|
|
|
})
|
2025-06-03 16:40:49 +08:00
|
|
|
|
}
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
2025-06-03 16:40:49 +08:00
|
|
|
|
next() {
|
|
|
|
|
if (this.originalItemsLength === 0) return
|
|
|
|
|
if (
|
|
|
|
|
!this.circular &&
|
|
|
|
|
this.virtualCurrentIndex >= this.originalItemsLength - 1
|
|
|
|
|
)
|
|
|
|
|
return
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
let targetVirtualIndex = this.virtualCurrentIndex + 1
|
|
|
|
|
this.moveTo(targetVirtualIndex, 'next')
|
|
|
|
|
},
|
|
|
|
|
prev() {
|
|
|
|
|
if (this.originalItemsLength === 0) return
|
|
|
|
|
if (!this.circular && this.virtualCurrentIndex <= 0) return
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
let targetVirtualIndex = this.virtualCurrentIndex - 1
|
|
|
|
|
this.moveTo(targetVirtualIndex, 'prev')
|
|
|
|
|
},
|
|
|
|
|
goToSlide(originalIndex) {
|
|
|
|
|
if (originalIndex < 0 || originalIndex >= this.originalItemsLength) return
|
|
|
|
|
// if (originalIndex === this.currentIndex && !this.isTransitioning && !this.isJumping) return;
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
let targetVirtualIndex
|
|
|
|
|
if (!this.circular) {
|
|
|
|
|
targetVirtualIndex = originalIndex
|
|
|
|
|
} else {
|
|
|
|
|
if (this.originalItemsLength === 1) targetVirtualIndex = 1
|
|
|
|
|
else targetVirtualIndex = originalIndex + 1
|
|
|
|
|
}
|
|
|
|
|
this.clearTimer()
|
|
|
|
|
this.moveTo(targetVirtualIndex)
|
|
|
|
|
if (this.autoplay) {
|
|
|
|
|
setTimeout(() => this.startAutoplay(), this.duration + 100)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onTouchStart(e) {
|
|
|
|
|
if (this.originalItemsLength === 0) return
|
|
|
|
|
if (!this.circular && this.originalItemsLength <= 1) return // No swipe for single non-circular item
|
|
|
|
|
|
|
|
|
|
this.clearTimer()
|
2025-06-03 11:47:06 +08:00
|
|
|
|
this.touchStartY = e.touches[0].clientY
|
|
|
|
|
this.touchStartTime = Date.now()
|
|
|
|
|
this.isTouching = true
|
2025-06-03 16:40:49 +08:00
|
|
|
|
this.isJumping = false
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
|
|
|
|
onTouchMove(e) {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
if (!this.isTouching) return
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
|
|
|
|
onTouchEnd(e) {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
if (!this.isTouching || this.originalItemsLength === 0) {
|
|
|
|
|
this.isTouching = false // Ensure isTouching is reset
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-06-03 11:47:06 +08:00
|
|
|
|
this.isTouching = false
|
|
|
|
|
|
|
|
|
|
const currentY = e.changedTouches[0].clientY
|
|
|
|
|
const deltaY = currentY - this.touchStartY
|
|
|
|
|
const deltaTime = Date.now() - this.touchStartTime
|
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
const threshold = this.height * 0.2
|
2025-06-03 11:47:06 +08:00
|
|
|
|
const velocityThreshold = 0.3
|
2025-06-03 16:40:49 +08:00
|
|
|
|
const velocity = deltaTime > 0 ? Math.abs(deltaY) / deltaTime : 0
|
2025-06-03 11:47:06 +08:00
|
|
|
|
|
2025-06-03 16:40:49 +08:00
|
|
|
|
if (Math.abs(deltaY) > threshold || velocity > velocityThreshold) {
|
|
|
|
|
if (deltaY < 0) {
|
2025-06-03 11:47:06 +08:00
|
|
|
|
this.next()
|
2025-06-03 16:40:49 +08:00
|
|
|
|
} else if (deltaY > 0) {
|
|
|
|
|
this.prev()
|
2025-06-03 11:47:06 +08:00
|
|
|
|
}
|
|
|
|
|
} else {
|
2025-06-03 16:40:49 +08:00
|
|
|
|
// No swipe, or too small. Potentially restart autoplay if it was interrupted.
|
2025-06-03 11:47:06 +08:00
|
|
|
|
if (this.autoplay) {
|
|
|
|
|
this.startAutoplay()
|
|
|
|
|
}
|
2025-06-03 16:40:49 +08:00
|
|
|
|
}
|
|
|
|
|
// Autoplay restart is handled within next/prev through moveTo or if no swipe occurs
|
|
|
|
|
// If a swipe occurs, next/prev call moveTo, which stops autoplay via clearTimer. Then this onTouchEnd may restart.
|
|
|
|
|
// If no swipe, this onTouchEnd directly restarts.
|
|
|
|
|
// Consider a more unified autoplay restart logic after touch interaction concludes.
|
|
|
|
|
// For now, let's consolidate: if autoplay was on, restart it after a delay.
|
|
|
|
|
if (this.autoplay) {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (!this.timer) this.startAutoplay() // Restart only if not already restarted by a quick succession
|
|
|
|
|
}, this.duration + 200) // Delay after potential transition
|
|
|
|
|
}
|
2025-06-03 11:47:06 +08:00
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
.simple-vertical-swiper {
|
|
|
|
|
position: relative;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
// 防止触摸时选中文本
|
|
|
|
|
user-select: none;
|
|
|
|
|
-webkit-user-select: none;
|
|
|
|
|
// App端需要的设置
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
|
|
|
|
|
.swiper-wrapper {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
// uniapp App端触摸控制
|
2025-06-03 16:40:49 +08:00
|
|
|
|
touch-action: pan-y; /* More specific touch action */
|
2025-06-03 11:47:06 +08:00
|
|
|
|
// 防止触摸时的默认行为
|
|
|
|
|
-webkit-touch-callout: none;
|
|
|
|
|
-webkit-tap-highlight-color: transparent;
|
|
|
|
|
// App端滚动穿透防护
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.swiper-slide {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
// 防止子元素被选中
|
|
|
|
|
user-select: none;
|
|
|
|
|
-webkit-user-select: none;
|
|
|
|
|
|
|
|
|
|
&.swiper-slide-active {
|
|
|
|
|
opacity: 1 !important;
|
|
|
|
|
z-index: 10 !important;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 公告样式
|
|
|
|
|
.notice-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 0 15px;
|
|
|
|
|
width: calc(100% - 30px);
|
|
|
|
|
height: 80%;
|
|
|
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
|
|
|
|
.notice-icon {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
color: #fff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.notice-text {
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
flex: 1;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 商品样式
|
|
|
|
|
.product-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 10px 15px;
|
|
|
|
|
width: calc(100% - 30px);
|
|
|
|
|
height: calc(100% - 20px);
|
|
|
|
|
background-color: #fff;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
|
|
|
|
|
|
|
.product-image {
|
|
|
|
|
width: 60px;
|
|
|
|
|
height: 60px;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
margin-right: 12px;
|
|
|
|
|
background-color: #f0f0f0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.product-info {
|
|
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
|
|
.product-name {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: #333;
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.product-price {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
color: #ff6b6b;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 内容样式
|
|
|
|
|
.content-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 15px;
|
|
|
|
|
width: calc(100% - 30px);
|
|
|
|
|
height: calc(100% - 30px);
|
|
|
|
|
text-align: center;
|
|
|
|
|
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
|
|
.content-title {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.content-desc {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: rgba(255, 255, 255, 0.9);
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 文本样式
|
|
|
|
|
.text-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
padding: 0 15px;
|
|
|
|
|
width: calc(100% - 30px);
|
|
|
|
|
height: 80%;
|
|
|
|
|
background: linear-gradient(135deg, #fd79a8 0%, #e84393 100%);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
|
|
|
|
.text-content {
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 默认样式
|
|
|
|
|
.default-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
padding: 0 15px;
|
|
|
|
|
width: calc(100% - 30px);
|
|
|
|
|
height: 80%;
|
|
|
|
|
background-color: #f8f9fa;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
border: 1px solid #e9ecef;
|
|
|
|
|
|
|
|
|
|
text {
|
|
|
|
|
color: #333;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 指示器
|
|
|
|
|
.indicators {
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: 8px;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 3px;
|
|
|
|
|
z-index: 20;
|
|
|
|
|
|
|
|
|
|
.indicator {
|
|
|
|
|
width: 3px;
|
|
|
|
|
height: 12px;
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.4);
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
|
|
|
|
&.active {
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.9);
|
|
|
|
|
height: 16px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|