版本发布

This commit is contained in:
icssoa
2025-07-21 16:47:04 +08:00
parent 1abed7a2e1
commit 6d8193880a
307 changed files with 41718 additions and 0 deletions

View File

@@ -0,0 +1,414 @@
<template>
<view
class="cl-banner"
:class="[pt.className]"
:style="{
height: parseRpx(height)
}"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view
class="cl-banner__list"
:class="{
'is-transition': isAnimating
}"
:style="{
transform: `translateX(${slideOffset}px)`
}"
>
<view
class="cl-banner__item"
v-for="(item, index) in list"
:key="index"
:class="[
pt.item?.className,
`${item.isActive ? `${pt.itemActive?.className}` : ''}`
]"
:style="{
width: `${getSlideWidth(index)}px`
}"
@tap="handleSlideClick(index)"
>
<slot :item="item" :index="index">
<image
:src="item.url"
mode="aspectFill"
class="cl-banner__item-image"
:class="[pt.image?.className]"
></image>
</slot>
</view>
</view>
<view class="cl-banner__dots" :class="[pt.dots?.className]" v-if="showDots">
<view
class="cl-banner__dots-item"
v-for="(item, index) in list"
:key="index"
:class="[
{
'is-active': item.isActive
},
pt.dot?.className,
`${item.isActive ? `${pt.dotActive?.className}` : ''}`
]"
></view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, watch, getCurrentInstance, type PropType } from "vue";
import { parsePt, parseRpx } from "@/cool";
import type { PassThroughProps } from "../../types";
type Item = {
url: string;
isActive: boolean;
};
defineOptions({
name: "cl-banner"
});
defineSlots<{
item(props: { item: Item; index: number }): any;
}>();
const props = defineProps({
// 透传属性
pt: {
type: Object,
default: () => ({})
},
// 轮播项列表
list: {
type: Array as PropType<string[]>,
default: () => []
},
// 上一个轮播项的左边距
previousMargin: {
type: Number,
default: 0
},
// 下一个轮播项的右边距
nextMargin: {
type: Number,
default: 0
},
// 是否自动轮播
autoplay: {
type: Boolean,
default: true
},
// 自动轮播间隔时间(ms)
interval: {
type: Number,
default: 5000
},
// 是否显示指示器
showDots: {
type: Boolean,
default: true
},
// 是否禁用触摸
disableTouch: {
type: Boolean,
default: false
},
// 高度
height: {
type: [Number, String],
default: 300
}
});
const emit = defineEmits(["change", "item-tap"]);
const { proxy } = getCurrentInstance()!;
// 透传属性类型定义
type PassThrough = {
className?: string;
item?: PassThroughProps;
itemActive?: PassThroughProps;
image?: PassThroughProps;
dots?: PassThroughProps;
dot?: PassThroughProps;
dotActive?: PassThroughProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
/** 当前激活的轮播项索引 */
const activeIndex = ref(0);
/** 轮播项列表 */
const list = computed<Item[]>(() => {
return props.list.map((e, i) => {
return {
url: e,
isActive: i == activeIndex.value
} as Item;
});
});
/** 轮播容器的水平偏移量(px) */
const slideOffset = ref(0);
/** 是否正在执行动画过渡 */
const isAnimating = ref(false);
/** 轮播容器的总宽度(px) */
const bannerWidth = ref(0);
/** 单个轮播项的宽度(px) - 用于缓存计算结果 */
const slideWidth = ref(0);
/** 触摸开始时的X坐标 */
const touchStartPoint = ref(0);
/** 触摸开始时的时间戳 */
const touchStartTimestamp = ref(0);
/** 触摸开始时的初始偏移量 */
const initialOffset = ref(0);
/**
* 更新轮播容器的位置
* 根据当前激活索引计算并设置容器的偏移量
*/
function updateSlidePosition() {
if (bannerWidth.value == 0) return;
// 计算累积偏移量,考虑每个位置的动态边距
let totalOffset = 0;
// 遍历当前索引之前的所有项,累加它们的宽度
for (let i = 0; i < activeIndex.value; i++) {
const itemPreviousMargin = i == 0 ? 0 : props.previousMargin;
const itemNextMargin = i == props.list.length - 1 ? 0 : props.nextMargin;
const itemWidthAtIndex = bannerWidth.value - itemPreviousMargin - itemNextMargin;
totalOffset += itemWidthAtIndex;
}
// 当前项的左边距
const currentPreviousMargin = activeIndex.value == 0 ? 0 : props.previousMargin;
// 设置最终的偏移量:负方向移动累积宽度,然后加上当前项的左边距
slideOffset.value = -totalOffset + currentPreviousMargin;
}
/**
* 获取指定索引轮播项的宽度
* @param index 轮播项索引
* @returns 轮播项宽度(px)
*/
function getSlideWidth(index: number): number {
// 动态计算每个项的宽度,考虑边距
const itemPreviousMargin = index == 0 ? 0 : props.previousMargin;
const itemNextMargin = index == props.list.length - 1 ? 0 : props.nextMargin;
return bannerWidth.value - itemPreviousMargin - itemNextMargin;
}
/**
* 计算并缓存轮播项宽度
* 使用固定的基础宽度计算,避免动态变化导致的性能问题
*/
function calculateSlideWidth() {
const baseWidth = bannerWidth.value - props.previousMargin - props.nextMargin;
slideWidth.value = baseWidth;
}
/**
* 测量轮播容器的尺寸信息
* 获取容器宽度并初始化相关计算
*/
function getRect() {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-banner")
.boundingClientRect((node) => {
bannerWidth.value = (node as NodeInfo).width ?? 0;
// 重新计算宽度和位置
calculateSlideWidth();
updateSlidePosition();
})
.exec();
}
/** 自动轮播定时器 */
let autoplayTimer: number = 0;
/**
* 清除自动轮播定时器
*/
function clearAutoplay() {
if (autoplayTimer != 0) {
clearInterval(autoplayTimer);
autoplayTimer = 0;
}
}
/**
* 启动自动轮播
*/
function startAutoplay() {
if (props.list.length <= 1) return;
if (props.autoplay) {
clearAutoplay();
}
isAnimating.value = true;
// @ts-ignore
autoplayTimer = setInterval(() => {
if (activeIndex.value >= props.list.length - 1) {
activeIndex.value = 0;
} else {
activeIndex.value++;
}
}, props.interval);
}
/**
* 处理触摸开始事件
* 记录触摸起始状态,准备手势识别
* @param e 触摸事件对象
*/
function onTouchStart(e: UniTouchEvent) {
// 如果禁用触摸,则不进行任何操作
if (props.disableTouch) return;
// 单项或空列表不支持滑动
if (props.list.length <= 1) return;
// 清除自动轮播
clearAutoplay();
// 禁用动画,开始手势跟踪
isAnimating.value = false;
touchStartPoint.value = e.touches[0].clientX;
touchStartTimestamp.value = Date.now();
initialOffset.value = slideOffset.value;
}
/**
* 处理触摸移动事件
* 实时更新容器位置,实现跟手效果
* @param e 触摸事件对象
*/
function onTouchMove(e: UniTouchEvent) {
if (props.list.length <= 1 || props.disableTouch) return;
// 计算手指移动距离,实时更新偏移量
const deltaX = e.touches[0].clientX - touchStartPoint.value;
slideOffset.value = initialOffset.value + deltaX;
}
/**
* 处理触摸结束事件
* 根据滑动距离和速度判断是否切换轮播项
*/
function onTouchEnd() {
if (props.list.length <= 1) return;
// 恢复动画效果
isAnimating.value = true;
// 计算滑动距离、时间和速度
const deltaX = slideOffset.value - initialOffset.value;
const deltaTime = Date.now() - touchStartTimestamp.value;
const velocity = Math.abs(deltaX) / deltaTime; // px/ms
let newIndex = activeIndex.value;
// 使用当前项的实际宽度进行滑动判断
const currentSlideWidth = getSlideWidth(activeIndex.value);
// 判断是否需要切换滑动距离超过30%或速度够快
if (Math.abs(deltaX) > currentSlideWidth * 0.3 || velocity > 0.3) {
// 向右滑动且不是第一项 -> 上一项
if (deltaX > 0 && activeIndex.value > 0) {
newIndex = activeIndex.value - 1;
}
// 向左滑动且不是最后一项 -> 下一项
else if (deltaX < 0 && activeIndex.value < props.list.length - 1) {
newIndex = activeIndex.value + 1;
}
}
// 更新索引
activeIndex.value = newIndex;
updateSlidePosition();
// 恢复自动轮播
setTimeout(() => {
startAutoplay();
}, 300);
}
/**
* 处理轮播项点击事件
* @param index 被点击的轮播项索引
*/
function handleSlideClick(index: number) {
emit("item-tap", index);
}
/** 监听激活索引变化 */
watch(activeIndex, (val: number) => {
updateSlidePosition();
emit("change", val);
});
onMounted(() => {
getRect();
startAutoplay();
});
</script>
<style lang="scss" scoped>
.cl-banner {
@apply relative z-10 rounded-xl;
&__list {
@apply flex flex-row h-full w-full overflow-visible;
&.is-transition {
transition-property: transform;
transition-duration: 0.3s;
transition-timing-function: ease;
}
}
&__item {
@apply relative transition-transform;
&-image {
@apply w-full h-full rounded-xl;
}
}
&__dots {
@apply flex flex-row items-center justify-center;
@apply absolute bottom-3 left-0 w-full;
&-item {
@apply w-2 h-2 rounded-full mx-1;
background-color: rgba(255, 255, 255, 0.3);
transition-property: width, background-color;
transition-duration: 0.3s;
&.is-active {
@apply bg-white w-5;
}
}
}
}
</style>

View File

@@ -0,0 +1,24 @@
import type { PassThroughProps } from "../../types";
export type ClBannerPassThrough = {
className?: string;
item?: PassThroughProps;
itemActive?: PassThroughProps;
image?: PassThroughProps;
dots?: PassThroughProps;
dot?: PassThroughProps;
dotActive?: PassThroughProps;
};
export type ClBannerProps = {
className?: string;
pt?: ClBannerPassThrough;
list?: string[];
previousMargin?: number;
nextMargin?: number;
autoplay?: boolean;
interval?: number;
showDots?: boolean;
disableTouch?: boolean;
height?: any;
};