一、Swiper组件 vant 轮播图
<template>
<div class="swiper" ref="containerRef">
<div
class="swiper-track"
:style="trackStyle"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<slot></slot>
</div>
<div v-if="showIndicators && count > 1" class="swiper-pagination">
<div
v-for="index in count"
:key="index"
class="dot-wrapper"
@click.stop="swipeTo(index - 1)"
>
<span :class="['dot-core', { active: currentIndex === index - 1 }]"></span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
count: { type: Number, required: true },
duration: { type: Number, default: 300 },
showIndicators: { type: Boolean, default: true }
});
// 1. 核心变量定义
const containerRef = ref(null);
const currentIndex = ref(0);
const offset = ref(0); // 手指滑动时的实时偏移量
const isTransitioning = ref(true);
const swiperWidth = ref(0);
// 2. 触摸状态对象
const touch = reactive({
startX: 0,
deltaX: 0,
startTime: 0
});
// 计算容器宽度
const updateWidth = () => {
if (containerRef.value) {
swiperWidth.value = containerRef.value.offsetWidth;
}
};
onMounted(() => {
updateWidth();
window.addEventListener('resize', updateWidth);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateWidth);
});
// 3. 核心样式计算
const trackStyle = computed(() => {
// 总位移 = -(当前索引 * 容器宽度) + 手指拖动的偏移
const x = -(currentIndex.value * swiperWidth.value) + offset.value;
return {
transform: `translateX(${x}px)`,
transition: isTransitioning.value ? `transform ${props.duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)` : 'none',
display: 'flex',
width: '100%'
};
});
// 4. 点击圆点跳转方法
const swipeTo = (index) => {
isTransitioning.value = true;
currentIndex.value = index;
offset.value = 0; // 跳转时确保偏移归零
};
// 5. 手势逻辑
const handleTouchStart = (e) => {
touch.startX = e.touches[0].clientX;
touch.startTime = Date.now();
isTransitioning.value = false; // 拖动时关闭过渡动画,实现跟手
};
const handleTouchMove = (e) => {
const currentX = e.touches[0].clientX;
touch.deltaX = currentX - touch.startX;
// 增加边缘阻力(在第一页向右划或最后一页向左划时)
if ((currentIndex.value === 0 && touch.deltaX > 0) ||
(currentIndex.value === props.count - 1 && touch.deltaX < 0)) {
offset.value = touch.deltaX * 0.3;
} else {
offset.value = touch.deltaX;
}
};
const handleTouchEnd = () => {
isTransitioning.value = true;
const timeDiff = Date.now() - touch.startTime;
const threshold = swiperWidth.value / 4; // 滑动超过 1/4 宽度则翻页
// 判定是否需要翻页
if (Math.abs(touch.deltaX) > threshold || (timeDiff < 250 && Math.abs(touch.deltaX) > 20)) {
if (touch.deltaX < 0 && currentIndex.value < props.count - 1) {
currentIndex.value++;
} else if (touch.deltaX > 0 && currentIndex.value > 0) {
currentIndex.value--;
}
}
offset.value = 0; // 动画吸附回正
};
// 暴露 API
defineExpose({ swipeTo, currentIndex });
</script>
<style scoped>
.swiper {
position: relative;
width: 100%;
overflow: hidden;
touch-action: pan-y; /* 拦截横向滑动,保留纵向滚动 */
}
.swiper-track {
will-change: transform;
}
/* 分页器样式 */
.swiper-pagination {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
z-index: 10;
}
/* 增加点击热区 */
.dot-wrapper {
padding: 8px 6px;
cursor: pointer;
}
.dot-core {
display: block;
width: 6px;
height: 6px;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 50%;
transition: all 0.3s ease;
}
.dot-core.active {
background-color: #1989fa;
width: 12px;
border-radius: 3px;
}
/* 强制 slot 内部的第一级子元素(page)撑满宽度 */
:deep(.swiper-track > *) {
flex-shrink: 0;
width: 100%;
box-sizing: border-box;
}
</style>
二、Notify组件 vant 消息通知
notify.vue
<template>
<transition class="notify-clide">
<div v-if="visible" class="notify" :class="[`notify`--${type}]" :style="{zIndex: zIndex}">
<slot> {{ message }}</slot>
</div>
</transition>
</template>
<script lang="ts" setup name="Notify">
import { ref, onMounted, onUnmounted } from 'vue';
const props = defineProps({
message: String,
type: {
type: String,
default: 'primary', //primary success warning danger
},
duration: {
type: Number,
default: 2000
},
zIndex: {
type: Number,
default: 3000
}
})
const visible = ref(false)
let timer = null
const show = () =>{
visible.value = true;
if(props.duration > 0){
timer = setTimeout(() => {
visible.value = false
}, props.duration)
}
}
defineExpose({show})
onMounted(() => {
show()
})
onUnmounted(() =>{
clearTimeout(timer)
})
</script>
<style lang="less" scoped>
.notify{
position:fixed;
top: 0;
left: 0;
width: 100%;
padding: 8px 16px;
color: #fff;
font-size: 14px;
line-height:20p;
text-align: center;
word-wrap: break-word;
box-sizing: border-box;
}
.notify--primary{
background-color: #1989fa;
}
.notify--success{
background-color: #07c160;
}
.notify--warning{
background-color: #ff976a;
}
.notify--danger{
background-color: #ee0a24;
}
.notify-slide-enter-active,.notify-slide-leave-active{
transition: transform 0.3s ease-out;
}
.notify-slide-enter-from, .notify-slide-leave-to{
transform:translateY(-100%);
}
.notify-slide-enter-to,.notify-slide-leave-from{
transform: translateY(0);
}
</style>
notify.ts
import { render, h} from 'vue';
import NotifyComponent from '@/components/Notify/notify.vue';
interface NotifyOptions {
message: string,
type?: 'primary' | 'success' | 'warning' | 'danger',
zIndex?: number,
[key: string]: any
}
let container: HTMLElement | null = null;
export function Notify(options: NotifyOptions){
if(container){
render(null, container)
document.body.removeChild(container)
}
container = document.createElement('div)
const vnode = h(NotifyComponent,{
...options, onUnmounted: ()=>{
container = null;
})
render(vnode, container);
document.body.appendChild(container);
}
三、action-sheet组件 vant 动作面板
<template>
<teleport to="body">
<transition name="fade">
<div v-if="modelValue" class="actionsheet-mask" @click="$emit('update:modelValue')",false></div>
</transition>
<transition name="slide">
<div v-if="modelValue" class="actionsheet-panel">
<slot name="title">
<div v-if="title" class="actionsheet-title">{{title}}</div>
</slot>
<slot>
<div class="actionsheet-menu">
<div v-for="(item,i) in actions" :key="i" class="actionsheet-item" @click="handleSelect(item)">{{item.name}}</div>
</div>
</slot>
<div class="actionsheet-gap"></div>
</div>
</transition>
</teleport>
</template>
<script setup lang="ts" name="ActionSheet">
defineProps({
modelValue: Boolean,
title: String,
actions: {
typr: Array,
default: () => []
})
copnst emit = defineEmits(['update:modelValue'],'select');
const handleSelect = (item) =>{
emit('select', item)
emit('update:modelValue',false)
}
</script>
<style lang="less" scoped>
.actionsheet-mask{
position:fixed;
top:0;
left:0;
width:100%;
height:100%;
background: rgba(0,0,0,0.5);
}
.actionsheet-panel{
position:fixed;
max-height: 80%;
left: 0;
bottom:0;
width:100%;
padding-bottom:12px;
background:#f7f8fa;
border-radius: 12px 12px 0 0 ;
z-index: 667;
overflow: hidden;
padding-bottom: env(safe-area-inset-bottom);
}
.actionsheet-title{
padding-inline-start:16px;
text-align:center;
color: #969799;
font-size:14px;
background: #fff;
}
.actionsheet-item, .actionseet-cancel{
padding:16px;
text-align: center;
background: #fff;
font-size: 16px;
}
.actionsheet-item:active, .actionsheet-cancel:active{
background: #f2f3f5;
}
.actionsheet-item{
border-top: 0.5px solid #ebedf0;
}
.actionsheet-gap{
height: 8px;
background: #f7f8fa;
}
.fade-enter-active, .fade-leave-active{
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to{
opacity:0;
}
.slide-enter-active, .slide-leave-active{
transition: transform 0.3s ease-out;
}
.slide-enter-form, .slide-leave-to{
transform: translateY(100%)
}
</style>