uniapp 滑块验证组件

slider-verify.vue
<template>
    <view class="slider-verify-container" :style="{ width: width + 'rpx', height: height + 'rpx' }" ref="containerRef">
        <!-- 背景轨道 -->
        <view class="slider-track" :class="{ verified: isVerified }" :style="{ height: height + 'rpx', borderRadius: borderRadius + 'rpx' }">
            <!-- 验证通过文字 -->
            <text v-if="isVerified" class="verified-text" :style="{ fontSize: fontSize + 'rpx' }">验证通过</text>
<!-- 默认文字 -->
<text v-else class="slider-text" :style="{ fontSize: fontSize + 'rpx' }">{{ sliderText }}</text>

<!-- 进度条 -->
<view
class="slider-progress"
:class="{ dragging: isDragging }"
:style="{
width: progressWidth + 'rpx',
    height: height + 'rpx',
        borderRadius: borderRadius + 'rpx',
            background: !showFailAnimation ? progressColor : 'rgba(255, 107, 107, 0.5)'
}"
></view>
</view>

<!-- 滑块按钮 -->
<view 
class="slider-button"
:class="{ verified: isVerified, dragging: isDragging, failed: showFailAnimation }"
:style="{
left: buttonLeft + 'rpx',
    width: buttonSize + 'rpx',
        height: buttonSize + 'rpx'
}"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
    <wd-icon v-if="!isVerified && !showFailAnimation" name="arrow-right" size="20" color="#ffffff"></wd-icon>
<wd-icon v-if="isVerified" name="check" size="20" color="#ffffff"></wd-icon>
<wd-icon v-if="showFailAnimation" name="close" size="20" color="#ffffff"></wd-icon>
</view>
</view>
</template>

<script setup>
    import { ref, computed, watch } from 'vue';

// v-model 绑定验证状态:0-初始化,1-验证成功
const modelValue = defineModel({
    type: Number,
    default: 0
});

const props = defineProps({
    // 组件宽度
    width: {
        type: Number,
        default: 600
    },
    // 组件高度
    height: {
        type: Number,
        default: 100
    },
    // 圆角大小
    borderRadius: {
        type: Number,
        default: 16
    },
    // 按钮大小
    buttonSize: {
        type: Number,
        default: 100
    },
    // 文字大小
    fontSize: {
        type: Number,
        default: 32
    },
    // 滑块文字
    sliderText: {
        type: String,
        default: '向右滑动验证'
    },
    // 进度条颜色
    progressColor: {
        type: String,
        default: 'rgba(69, 225, 129, 0.5)'
    },
    // 最大滑动距离
    maxDistance: {
        type: Number,
        default: 0
    },
    // 是否开启验证
    enable: {
        type: Boolean,
        default: true
    },
    // 自定义验证函数,返回 Promise<boolean>
    validator: {
        type: Function,
        default: null
    }
});

const emit = defineEmits(['success', 'fail', 'change', 'reset', 'update:modelValue']);

// 响应式状态
const isDragging = ref(false);
const isVerified = ref(false);
const showFailAnimation = ref(false);
const startX = ref(0);
const currentX = ref(0);
const buttonLeft = ref(0);
const progressWidth = ref(0);
const containerLeft = ref(0);
const hasGotContainerLeft = ref(false);

// 获取容器左边距
const getContainerLeft = () => {
    return new Promise((resolve) => {
        const query = uni.createSelectorQuery();
        query.select('.slider-verify-container').boundingClientRect();
        query.exec((res) => {
            if (res && res[0]) {
                containerLeft.value = res[0].left;
                hasGotContainerLeft.value = true;
                resolve(res[0].left);
            } else {
                resolve(0);
            }
        });
    });
};

// 计算最大滑动距离
const computedMaxDistance = computed(() => {
    if (props.maxDistance > 0) {
        return props.maxDistance;
    }
    return props.width - props.buttonSize;
});

// 触摸开始
const onTouchStart = async (e) => {
    if (!props.enable || isVerified.value) return;

    // 获取容器左边距
    if (!hasGotContainerLeft.value) {
        await getContainerLeft();
    }

    isDragging.value = true;

    // 使用 clientX(视窗坐标)减去容器左边距,得到相对于容器的坐标(px)
    const relativeClientX = e.touches[0].clientX - containerLeft.value;
    // 转换为 rpx:2rpx = 2rpx
    startX.value = relativeClientX * 2;
    currentX.value = relativeClientX * 2;

    emit('change', {
        status: 'dragging',
        progress: progressWidth.value / computedMaxDistance.value
    });
};

// 触摸移动
const onTouchMove = (e) => {
    if (!isDragging.value || !props.enable || isVerified.value) return;

    // 使用 clientX(视窗坐标)减去容器左边距,得到相对于容器的坐标(px)
    const relativeClientX = e.touches[0].clientX - containerLeft.value;
    // 转换为 rpx:2rpx = 2rpx
    currentX.value = relativeClientX * 2;
    const diff = currentX.value - startX.value;

    // 限制滑动范围
    const distance = Math.max(0, Math.min(diff, computedMaxDistance.value));
    buttonLeft.value = distance;
    progressWidth.value = distance + 20;

    emit('change', {
        status: 'dragging',
        progress: progressWidth.value / computedMaxDistance.value
    });
};

// 触摸结束
const onTouchEnd = async () => {
    if (!isDragging.value) return;

    isDragging.value = false;

    // 检查是否滑动到最大距离
    const isAtMaxDistance = progressWidth.value >= computedMaxDistance.value;

    if (isAtMaxDistance) {
        // 执行验证
        let verifyResult = true;

        // 如果有自定义验证器,优先使用
        if (props.validator) {
            try {
                verifyResult = await props.validator();
            } catch (error) {
                console.error('验证失败:', error);
                verifyResult = false;
            }
        }

        if (verifyResult) {
            // 验证成功
            isVerified.value = true;
            buttonLeft.value = computedMaxDistance.value;
            progressWidth.value = computedMaxDistance.value;
            modelValue.value = 1; // 更新 v-model 状态为验证成功

            emit('success');
            emit('change', {
                status: 'success',
                progress: 1
            });
        } else {
            // 验证失败,触发失败动画和重置
            triggerFailAnimation();
        }
    } else {
        // 未滑动到最大距离,重置
        triggerFailAnimation();
        setTimeout(() => {
            reset();
        }, 300);
    }
};

// 触发失败动画
const triggerFailAnimation = () => {
    showFailAnimation.value = true;
    emit('fail');
    emit('change', {
        status: 'fail',
        progress: progressWidth.value / computedMaxDistance.value
    });

    // 失败动画持续300ms后重置
    setTimeout(() => {
        reset();
    }, 1000);
};

// 重置组件
const reset = () => {
    isDragging.value = false;
    isVerified.value = false;
    showFailAnimation.value = false;
    startX.value = 0;
    currentX.value = 0;
    buttonLeft.value = 0;
    progressWidth.value = 0;
    modelValue.value = 0; // 重置 v-model 状态为初始化

    emit('reset');
    emit('change', {
        status: 'reset',
        progress: 0
    });
};

// 监听 enable 属性变化
watch(
    () => props.enable,
    (newVal) => {
        if (!newVal) {
            reset();
        }
    }
);

// 监听 v-model 值变化,同步组件状态
watch(
    () => modelValue.value,
    (newVal) => {
        if (newVal === 1) {
            // v-model 设置为 1,表示验证成功
            isVerified.value = true;
            buttonLeft.value = computedMaxDistance.value;
            progressWidth.value = computedMaxDistance.value;
        } else if (newVal === 0) {
            // v-model 设置为 0,表示重置
            isVerified.value = false;
            buttonLeft.value = 0;
            progressWidth.value = 0;
        }
    }
);

// 对外暴露的重置方法
defineExpose({
    reset,
    isVerified: () => isVerified.value
});
</script>

<style lang="scss" scoped>
    .slider-verify-container {
        position: relative;
        overflow: hidden;
    }

.slider-track {
    position: relative;
    width: 100%;
    height: 100%;
    background-color: #e0e0e0;
    overflow: hidden;
    transition: background-color 0.3s;

    &.verified {
        background-color: #4cd964;
    }

    &.failed {
        background-color: #f56c6c !important;
    }
}

.slider-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: #999999;
    font-weight: 500;
    z-index: 3;
    pointer-events: none;
    white-space: nowrap;
}

.verified-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: #ffffff;
    font-weight: 500;
    z-index: 3;
    pointer-events: none;
    white-space: nowrap;
}

.slider-progress {
    position: absolute;
    left: 0;
    top: 0;
    z-index: 2;
    border-radius: unset !important;
}

.slider-button {
    position: absolute;
    top: 0;
    left: 0;
    background: linear-gradient(135deg, #42d77d 0%, #26c26a 100%);
    box-shadow: 0 4rpx 15rpx rgba(66, 215, 125, 0.5), 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
    touch-action: none;
    z-index: 10;
    border-radius: 10rpx;

    &.dragging {
        box-shadow: 0 6rpx 25rpx rgba(66, 215, 125, 0.6), 0 4rpx 12rpx rgba(0, 0, 0, 0.2);
        transform: scale(1.02);
    }

    &.verified {
        background: linear-gradient(135deg, #42d77d 0%, #26c26a 100%);
        box-shadow: 0 4rpx 15rpx rgba(66, 215, 125, 0.5), 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
    }

    &.failed {
        background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
        box-shadow: 0 4rpx 15rpx rgba(255, 107, 107, 0.5), 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
        animation: shake 0.3s ease-in-out;
    }
}

@keyframes shake {
    0%,
        100% {
            transform: translateX(0);
        }

    25% {
        transform: translateX(-10rpx);
    }

    75% {
        transform: translateX(10rpx);
    }
}
</style>

组件使用

<SliderVerify v-model="sliderStatus"></SliderVerify>