liveh5-nuxt/app/components/YourPuzzleComponent.vue
xingyy e9896d86d6 feat(login): 实现滑动验证码功能
- 新增滑动验证码
2025-03-12 10:18:30 +08:00

368 lines
8.0 KiB
Vue

<template>
<div class="puzzle-container">
<div class="puzzle-box" :style="{ height: boxHeight + 'px' }">
<!-- 背景图 -->
<img :src="bgImageUrl" style="width: 320px;height: 191px;" ref="bgImage" @load="onImageLoad" @error="handleImageError">
<!-- 滑块 -->
<img
class="slider-block"
:src="sliderImageUrl"
:style="{
top: `${blockY}px`,
left: `${moveX}px`,
visibility: loaded ? 'visible' : 'hidden',
width: '50px',
height: '50px'
}"
></img>
<div v-if="verifySuccess || verifyError" :class="`text-#fff ${verifySuccess?'bg-#52C41A':'bg-#FF4D4F'} h-24px w-100% text-14px absolute left-0 bottom-0 text-center leading-24px`">{{ verifyTip }}</div>
</div>
<!-- 滑动条 -->
<div class="slider-container">
<div class="slider-track">
<div
class="slider-bar"
:style="{ width: `${moveX}px` }"
></div>
<div
class="slider-button"
:style="{ left: `${moveX}px` }"
@mousedown="handleMouseDown"
@touchstart="handleTouchStart"
>
<div class="slider-icon"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
show: {
type: Boolean,
default: false
},
blockY: {
type: Number,
required: true
},
bgImageUrl: {
type: String,
required: true
},
sliderImageUrl: {
type: String,
required: true
}
})
const emit = defineEmits(['leave'])
// 响应式状态
const moveX = ref(0)
const startX = ref(0)
const oldMoveX = ref(0)
const maxMoveX = ref(0)
const boxHeight = ref(0)
const loaded = ref(false)
const isDragging = ref(false)
const verifySuccess = ref(false)
const verifyError = ref(false)
const verifyTip = ref('')
// 重置方法
const reset = () => {
moveX.value = 0
verifySuccess.value = false
verifyError.value = false
verifyTip.value = ''
isDragging.value = false
}
// DOM引用
const bgImage = ref(null)
// 方法
const onImageLoad = () => {
if (bgImage.value) {
try {
const img = bgImage.value
// 确保图片已经完全加载
if (!img.complete) {
return
}
const scale = img.width / img.naturalWidth // 计算图片缩放比例
boxHeight.value = img.height
// 根据图片实际显示大小调整滑块大小
const blockSize = Math.round(50 * scale)
document.documentElement.style.setProperty('--block-size', blockSize + 'px')
maxMoveX.value = img.width - blockSize // 使用实际显示的滑块大小计算最大移动距离
loaded.value = true
} catch (error) {
console.error('Image load error:', error)
// 设置默认值以确保组件仍然可用
const defaultBlockSize = 50
document.documentElement.style.setProperty('--block-size', defaultBlockSize + 'px')
maxMoveX.value = 320 - defaultBlockSize // 使用默认容器宽度
loaded.value = true
}
}
}
// 添加图片错误处理
const handleImageError = () => {
console.error('Image failed to load')
// 设置默认值以确保组件仍然可用
const defaultBlockSize = 50
document.documentElement.style.setProperty('--block-size', defaultBlockSize + 'px')
maxMoveX.value = 320 - defaultBlockSize
loaded.value = true
}
const handleMouseDown = (event) => {
event.preventDefault()
startX.value = event.clientX
oldMoveX.value = moveX.value
isDragging.value = true
}
const handleTouchStart = (event) => {
event.preventDefault()
const touch = event.touches[0]
startX.value = touch.clientX
oldMoveX.value = moveX.value
isDragging.value = true
}
const move = (clientX) => {
let diff = clientX - startX.value
let newMoveX = oldMoveX.value + diff
// 限制移动范围
if (newMoveX < 0) newMoveX = 0
if (newMoveX > maxMoveX.value) newMoveX = maxMoveX.value
moveX.value = Math.round(newMoveX) // 取整数避免小数点导致的模糊
}
const handleMouseMove = (event) => {
if (!isDragging.value) return
move(event.clientX)
}
const handleTouchMove = (event) => {
if (!isDragging.value) return
event.preventDefault() // 防止页面滚动
move(event.touches[0].clientX)
}
const handleMouseUp = async () => {
if (!isDragging.value) return
isDragging.value = false
try {
emit('leave', moveX.value, (success) => {
if (success) {
verifySuccess.value = true
verifyError.value = false
verifyTip.value = '验证成功'
} else {
verifySuccess.value = false
verifyError.value = true
verifyTip.value = '验证失败'
}
})
} catch (error) {
verifySuccess.value = false
verifyError.value = true
verifyTip.value = '验证失败'
}finally{
setTimeout(() => {
reset()
}, 2000)
}
}
const handleTouchEnd = () => {
handleMouseUp()
}
// 预加载图片
const preloadImages = () => {
const bgImg = new Image()
const sliderImg = new Image()
bgImg.onload = () => {
if (bgImage.value) {
onImageLoad()
}
}
bgImg.src = props.bgImageUrl
sliderImg.src = props.sliderImageUrl
}
// 生命周期钩子
onMounted(() => {
preloadImages()
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
window.addEventListener('touchmove', handleTouchMove)
window.addEventListener('touchend', handleTouchEnd)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
})
</script>
<style scoped>
:root {
--block-size: 50px;
}
.puzzle-container {
position: relative;
margin: 0 auto;
background: #fff;
padding: 15px;
border-radius: 10px;
touch-action: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.puzzle-box {
position: relative;
width: 100%;
overflow: hidden;
background: #f8f8f8;
border-radius: 8px;
touch-action: none;
}
.bg-image {
display: block;
width: 100%;
height: auto;
-webkit-user-select: none;
user-select: none;
pointer-events: none;
object-fit: contain;
max-width: 100%;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.slider-block {
position: absolute;
width: var(--block-size);
height: var(--block-size);
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
touch-action: none;
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.slider-container {
position: relative;
margin-top: 15px;
height: 40px;
touch-action: none;
}
.slider-track {
position: relative;
height: 40px;
background: #f5f5f5;
border-radius: 20px;
touch-action: none;
}
.slider-bar {
position: absolute;
width: 100%;
height: 100%;
background: #91d5ff;
border-radius: 20px;
will-change: transform;
transform-origin: left;
}
.slider-button {
position: absolute;
top: 0;
width: 40px;
height: 40px;
background: #fff;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
cursor: pointer;
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.slider-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
background: #1890ff;
border-radius: 50%;
}
.verify-result-bar {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 40px;
line-height: 40px;
text-align: center;
font-size: 14px;
border-radius: 20px;
transition: all 0.3s;
z-index: 1;
}
.verify-result-bar.success {
background: #52c41a;
color: #fff;
}
.verify-result-bar.error {
background: #ff4d4f;
color: #fff;
}
/* 移除旧的提示样式 */
.verify-tip {
display: none;
}
</style>