验证码输入组件功能概述
本验证码输入组件实现了现代网站中常见的高效交互体验,主要功能如下:
- 自动跳转与退格回退
用户在某一输入框输入一个有效字符(数字或字母)后,光标会自动跳转至下一个输入框;当删除内容时,光标则自动退至上一输入框,显著提升输入效率。 - 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">×</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>
适用场景
手机验证码校验登录,注册,验证等场景;

323

被折叠的 条评论
为什么被折叠?



