Vue3 手机验证码校验弹窗组件使用

该文章已生成可运行项目,

验证码输入组件功能概述

本验证码输入组件实现了现代网站中常见的高效交互体验,主要功能如下:

  • 自动跳转与退格回退
    用户在某一输入框输入一个有效字符(数字或字母)后,光标会自动跳转至下一个输入框;当删除内容时,光标则自动退至上一输入框,显著提升输入效率。
  • v-model 双向数据绑定
    每个输入框的内容通过 v-model 绑定到 codes 数组,实现统一管理与响应式更新,便于获取完整验证码并进行后续处理。
  • 实时错误提示机制
    当用户输入非法字符时,系统将实时检测并在输入框下方显示红色错误提示信息,同时自动清空该输入框中的非法内容,确保输入的合法性。
  • 按钮状态控制逻辑
    “确认提交”按钮默认禁用,仅当所有输入框均填写了有效字符时才变为可点击状态,防止用户在信息不完整的情况下误操作提交。

功能截图:

代码实现

组件代码:

<template>
    <div v-if="visibleVerify" class="popup-mask" @click.self="closePopup">
        <div class="popup-content">
            <div class="popup-header">
                <h1 class="title">请填写验证码</h1>
                <span class="close-btn" @click="closePopup">&times;</span>
            </div>
            <div class="popup-body">
                <div class="info-text">{{ tabbarTitle }}设备确认</div>
                <div class="phone-text">短信已发送至 {{ phone }}</div>


                <!-- 验证码输入框 -->
                <div class="code-container">
                    <input v-for="(code, index) in codes" :key="index" type="text" inputmode="numeric" maxlength="1"
                        :value="code" @input="handleInput(index, $event)" @keydown="handleKeyDown(index, $event)"
                        :ref="(el) => setInputRef(el, index)" class="code-input" />
                </div>
                <div style="height: 20px;margin-bottom: 10px;"> <div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div></div>
            </div>



            <!-- 操作按钮 -->
            <div class="popup-footer">
                <button class="confirm-btn" :disabled="!isAllFilled" @click="handleConfirm">
                    确认{{ tabbarTitle }}
                </button>

                <div class="resend-text">
                    没有收到短信,
                    <span class="resend-link" :class="{ 'disabled': isCounting }" @click="sendMessage">
                        重新发送
                        <span v-if="isCounting">({{ countdown }}s)</span>
                    </span>
                </div>
            </div>
        </div>
    </div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'


// ========== Props & Emits ==========
const props = defineProps({
    visibleVerify: Boolean,
    phone: String,
    tabbarTitle: String,
    deviceId: String,
})
const emit = defineEmits(['update:visibleVerify', 'confirm'])

// ========== 状态管理 ==========
const inputRefs = ref([]);
const codeLength = 4;
const codes = ref(Array(codeLength).fill(''));
const countdown = ref(60);
const isCounting = ref(false);
let timer = null;
const errorMsg = ref('')

// 是否全部填写
const isAllFilled = computed(() => {
    return codes.value.every(code => code !== '');
});

// 弹窗控制
const closePopup = () => {
    emit('update:visibleVerify', false)
}

// 收集每个 nut-input 实例
const setInputRef = (el, index) => {
    if (el) {
        inputRefs.value[index] = el;
    }
}

// 处理输入
const handleInput = (index, event) => {

    // 直接通过原生事件获取最新值
    const value = event.target.value.slice(-1); // 确保只取最后一位
    console.log('value:', value, /^[0-9]*$/.test(value));

    if (!/^[0-9]*$/.test(value)) {
        errorMsg.value = '请输入数字或字母'
        codes.value[index] = ''
        return
    }

    // 手动更新响应式数据
    codes.value[index] = value;

    // 立即执行跳转逻辑
    if (value && index < codes.value.length - 1) {
        inputRefs.value[index + 1]?.focus();
        inputRefs.value[index + 1]?.select();
    }
};

const handleKeyDown = (index, event) => {

    if (event.key === 'Backspace') {
        if (codes.value[index] === '' && index > 0) {
            nextTick(() => {
                inputRefs.value[index - 1]?.focus();
            });
        }
    } else if (event.key === 'Enter') {
        // 阻止默认行为(可选)
        event.preventDefault();
        if (isAllFilled.value) {
            handleConfirm();
        }
    }
};


// 启动倒计时
const startCountdown = (seconds = 60) => {
    let targetTime = Date.now() + seconds * 1000 // 计算目标时间戳
    sessionStorage.setItem('countdownTarget', targetTime); // 保存目标时间戳
    countdown.value = seconds
    isCounting.value = true

    const update = () => {
        const remaining = Math.round((targetTime - Date.now()) / 1000)

        if (remaining <= 0) {
            stopCountdown()
            return
        }

        if (remaining !== countdown.value) {
            countdown.value = remaining
        }

        // 动态调整延迟时间
        const delay = Math.max(100, (targetTime - Date.now()) % 1000 || 1000)
        timer = setTimeout(update, delay)
    }

    timer = setTimeout(update, 1000)
};

// 停止倒计时
const stopCountdown = () => {
    clearInterval(timer);
    sessionStorage.removeItem('countdownTarget'); // 清除目标时间戳
    isCounting.value = false;
    countdown.value = 60;
};

// 发送短信
const sendMessage = async () => {
    if (isCounting.value) return;
    //加载 title: '发送短信中',
    try {
        // 异步请求
        let form = {
            deviceId: props.deviceId,
        }
        // const res = await deviceGenerateCode(form);
        const res = {
            success: true,
            code: 0,
            data: {},
        }
        if (res.success && res.code == 0) {
            console.log('res.code');

            nextTick(() => {
                inputRefs.value[0]?.focus();
            });
            // 开始倒计时
            startCountdown();
            //加载 title: res.data,

        } else {
            //加载   title: res.msg || '获取短信失败请重新尝试',
        }
    } catch (error) {
        console.error('发送失败:', error);
    }
}

const isHandleConfirm = ref(false)
// 确认关闭
const handleConfirm = async () => {
    // 防止重复点击
    if (isHandleConfirm.value) return;
    isHandleConfirm.value = true;
    const code = codes.value.join('');
    emit('confirm', code) // 把验证码传给父组件
    isHandleConfirm.value = false;
};

onMounted(() => {
    const savedTargetTime = sessionStorage.getItem('countdownTarget');
    if (savedTargetTime && Date.now() < savedTargetTime) {
        startCountdown(Math.round((savedTargetTime - Date.now()) / 1000));
    }
});

// 弹窗打开时自动聚焦第一个输入框
watch(
    () => props.visibleVerify,
    async (newVal) => {
        if (newVal) {
            await nextTick();
            console.log('newVal:', newVal, 'inputRefs.value.length:', inputRefs.value.length);
            sendMessage();
            if (inputRefs.value.length > 0) {
                inputRefs.value[0]?.focus();
            }
        } else if (!newVal) {
            console.log('弹窗关闭,正在停止倒计时...')
            stopCountdown()
            codes.value = Array(codeLength).fill('');
            isHandleConfirm.value = false;
        }

    },
    { immediate: true } // 确保初始渲染也触发一次
);

// 组件卸载时清除定时器
onUnmounted(stopCountdown);
</script>

<style scoped>
.popup-mask {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: rgba(0, 0, 0, 0.5);
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 9999;
}

.popup-content {
    background-color: #fff;
    border-radius: 12px;
    width: 80%;
    max-width: 400px;
    padding: 24px;
    position: relative;
    text-align: center;
}

.popup-header {
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
    margin-bottom: 20px;
    height: 36px;
}

.title {
    font-size: 24px;
    margin: 0;
}

.close-btn {
    font-size: 24px;
    cursor: pointer;
    color: #888;
    position: absolute;
    right: 0;
    top: 0;
}

.info-text {
    font-size: 16px;
    color: #333;
    margin-bottom: 10px;
}

.phone-text {
    font-size: 20px;
    color: #898d92;
    margin-bottom: 20px;
}

.code-container {
    display: flex;
    justify-content: space-between;
    margin-bottom: 5px;
}

.error-msg {
    font-size: 14px;
    color: red;
}

.code-input {
    width: 48px;
    height: 60px;
    text-align: center;
    font-size: 32px;
    border: none;
    border-radius: 5px;
    background-color: #e9e9e9;
    outline: none;
}

.confirm-btn {
    width: 60%;
    height: 50px;
    background-color: #4b8ef3;
    color: white;
    border: none;
    border-radius: 5px;
    font-size: 16px;
    cursor: pointer;
}

.confirm-btn:disabled {
    background-color: #ccc;
    cursor: not-allowed;
}

.resend-text {
    margin-top: 10px;
    font-size: 16px;
    color: #898d92;
    text-align: center;
}

.resend-link {
    color: #4b8ef3;
    cursor: pointer;
}

.resend-link.disabled {
    color: #ccc;
    cursor: not-allowed;
}
</style>

具体调用:

<template>
  <div>
    <button @click="showVerify = true">打开验证码弹窗</button>
    <VerifyPopup v-model:visible-verify="showVerify" phone="138****1234" tabbarTitle="登录" device-status="false"
      deviceId="123456" @confirm="handleConfirm" />
  </div>
</template>

<script setup>
import { useRouter } from 'vue-router';
import { ref, computed } from 'vue'

import VerifyPopup from './components/message.vue'

const router = useRouter();

// 当前设备状态
const showVerify = ref(false)

const handleConfirm = async (code) => {
  console.log('输入的验证码:', code)
  // 模拟调用接口
  try {
    const res = await submitCodeToServer(code)

    if (res.success) {
      console.log('验证成功')
      // 通知子组件可以关闭弹窗并停止倒计时
      showVerify.value = false
    } else {
      alert('验证码错误')
    }
  } catch (err) {
    alert('网络异常,请重试')
  }
}

// 模拟接口请求
const submitCodeToServer = (code) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      if (code === '1234') {
        resolve({ success: true })
      } else {
        resolve({ success: false })
      }
    }, 1000)
  })
}
</script>

适用场景

手机验证码校验登录,注册,验证等场景;

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值