336 lines
8.4 KiB
Vue
336 lines
8.4 KiB
Vue
<template>
|
||
<div class="slider-captcha">
|
||
<!-- 触发按钮(未验证时显示) -->
|
||
<el-button
|
||
v-if="!verified"
|
||
type="warning"
|
||
size="large"
|
||
plain
|
||
style="width:100%; margin-bottom:12px"
|
||
:loading="loading"
|
||
@click="openDialog"
|
||
>
|
||
⚠ 请完成安全验证
|
||
</el-button>
|
||
|
||
<!-- 已验证 -->
|
||
<div v-else class="verified-badge">
|
||
✅ 安全验证通过
|
||
</div>
|
||
|
||
<!-- 验证对话框 -->
|
||
<el-dialog
|
||
v-model="dialogVisible"
|
||
title="安全验证"
|
||
width="380px"
|
||
:close-on-click-modal="false"
|
||
append-to-body
|
||
top="20vh"
|
||
>
|
||
<div class="captcha-body">
|
||
<!-- 图形验证码模式 -->
|
||
<template v-if="captchaType === 'image'">
|
||
<div class="image-captcha-wrapper">
|
||
<img :src="captchaImage" class="captcha-img" alt="验证码" />
|
||
<el-input
|
||
v-model="imageAnswer"
|
||
placeholder="输入验证码"
|
||
size="large"
|
||
maxlength="4"
|
||
style="width:120px"
|
||
/>
|
||
</div>
|
||
<div class="captcha-actions">
|
||
<el-button size="small" @click="fetchCaptcha">换一张</el-button>
|
||
<el-button type="primary" size="small" @click="verifyImageCaptcha">确认</el-button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 滑块拼图模式 -->
|
||
<template v-else>
|
||
<div class="slider-wrapper">
|
||
<div class="slider-bg">
|
||
<img :src="sliderBg" alt="滑块背景" />
|
||
<div
|
||
class="slider-gap-marker"
|
||
:style="{ left: sliderGapX + 'px' }"
|
||
></div>
|
||
</div>
|
||
<div class="slider-piece-track">
|
||
<div
|
||
class="slider-piece"
|
||
:style="{ left: sliderPos + 'px', background: dragging ? '#409eff' : '#67c23a' }"
|
||
></div>
|
||
<div
|
||
class="slider-rail"
|
||
ref="railRef"
|
||
@mousedown.prevent="startDrag"
|
||
@touchstart.prevent="startDrag"
|
||
>
|
||
<div class="slider-thumb" :style="{ left: sliderPos + 'px' }">
|
||
{{ dragging ? '→' : '→' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="slider-hint">{{ dragging ? '松开完成验证' : '拖动滑块完成拼图' }}</p>
|
||
</div>
|
||
<div class="captcha-actions">
|
||
<el-button size="small" @click="fetchCaptcha">换一张</el-button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { securityApi } from '../api/index'
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'verified', token: string): void
|
||
}>()
|
||
|
||
const loading = ref(false)
|
||
const dialogVisible = ref(false)
|
||
const verified = ref(false)
|
||
const captchaToken = ref('')
|
||
const captchaType = ref<'image' | 'slider'>('slider')
|
||
|
||
// 图形验证码
|
||
const captchaImage = ref('')
|
||
const imageAnswer = ref('')
|
||
const captchaId = ref('')
|
||
|
||
// 滑块拼图
|
||
const sliderBg = ref('')
|
||
const sliderGapX = ref(0)
|
||
const sliderPos = ref(0)
|
||
const dragging = ref(false)
|
||
const railRef = ref<HTMLElement | null>(null)
|
||
const dragStartX = ref(0)
|
||
const dragStartPos = ref(0)
|
||
|
||
// 容差像素
|
||
const TOLERANCE = 6
|
||
|
||
async function fetchCaptcha() {
|
||
loading.value = true
|
||
try {
|
||
const res: any = await securityApi.requestCaptcha(captchaType.value)
|
||
captchaId.value = res.captcha_id
|
||
if (captchaType.value === 'image') {
|
||
captchaImage.value = res.image
|
||
imageAnswer.value = ''
|
||
} else {
|
||
sliderBg.value = res.bg
|
||
sliderGapX.value = res.gap_x
|
||
sliderPos.value = 0
|
||
}
|
||
} catch {
|
||
ElMessage.error('获取验证码失败')
|
||
}
|
||
loading.value = false
|
||
}
|
||
|
||
async function openDialog() {
|
||
captchaType.value = Math.random() > 0.3 ? 'slider' : 'image'
|
||
dialogVisible.value = true
|
||
await fetchCaptcha()
|
||
}
|
||
|
||
async function verifyImageCaptcha() {
|
||
if (!imageAnswer.value || imageAnswer.value.length !== 4) {
|
||
ElMessage.warning('请输入4位验证码')
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
const res: any = await securityApi.verifyCaptcha({
|
||
captcha_id: captchaId.value,
|
||
answer: imageAnswer.value.toUpperCase(),
|
||
captcha_type: 'image',
|
||
})
|
||
captchaToken.value = res.token
|
||
verified.value = true
|
||
dialogVisible.value = false
|
||
emit('verified', res.token)
|
||
ElMessage.success('验证通过')
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.detail || '验证失败')
|
||
await fetchCaptcha()
|
||
}
|
||
loading.value = false
|
||
}
|
||
|
||
function startDrag(e: MouseEvent | TouchEvent) {
|
||
dragging.value = true
|
||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||
dragStartX.value = clientX
|
||
dragStartPos.value = sliderPos.value
|
||
|
||
document.addEventListener('mousemove', onDrag)
|
||
document.addEventListener('mouseup', endDrag)
|
||
document.addEventListener('touchmove', onDrag, { passive: false })
|
||
document.addEventListener('touchend', endDrag)
|
||
}
|
||
|
||
function onDrag(e: MouseEvent | TouchEvent) {
|
||
if (!dragging.value) return
|
||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||
const rail = railRef.value
|
||
if (!rail) return
|
||
const railRect = rail.getBoundingClientRect()
|
||
const max = railRect.width - 40
|
||
let newPos = dragStartPos.value + (clientX - dragStartX.value)
|
||
newPos = Math.max(0, Math.min(newPos, max))
|
||
sliderPos.value = newPos
|
||
}
|
||
|
||
function endDrag() {
|
||
if (!dragging.value) return
|
||
dragging.value = false
|
||
document.removeEventListener('mousemove', onDrag)
|
||
document.removeEventListener('mouseup', endDrag)
|
||
document.removeEventListener('touchmove', onDrag)
|
||
document.removeEventListener('touchend', endDrag)
|
||
|
||
// 验证滑块位置是否匹配缺口位置
|
||
const rail = railRef.value
|
||
if (!rail) return
|
||
const railWidth = rail.getBoundingClientRect().width - 40
|
||
const piecePos = (sliderPos.value / railWidth) * 280 // 映射到280px背景图宽度
|
||
const gapX = sliderGapX.value
|
||
if (Math.abs(piecePos - gapX) <= TOLERANCE) {
|
||
// 验证通过 → 调用后端确认
|
||
confirmSlider()
|
||
} else {
|
||
ElMessage.warning('位置不匹配,请再试一次')
|
||
sliderPos.value = 0
|
||
}
|
||
}
|
||
|
||
async function confirmSlider() {
|
||
loading.value = true
|
||
try {
|
||
const res: any = await securityApi.verifyCaptcha({
|
||
captcha_id: captchaId.value,
|
||
answer: 'verified',
|
||
captcha_type: 'slider',
|
||
})
|
||
captchaToken.value = res.token
|
||
verified.value = true
|
||
dialogVisible.value = false
|
||
emit('verified', res.token)
|
||
ElMessage.success('验证通过')
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.detail || '验证失败')
|
||
sliderPos.value = 0
|
||
await fetchCaptcha()
|
||
}
|
||
loading.value = false
|
||
}
|
||
|
||
// 对外暴露重置方法
|
||
function reset() {
|
||
verified.value = false
|
||
captchaToken.value = ''
|
||
}
|
||
|
||
defineExpose({ reset })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.captcha-body {
|
||
text-align: center;
|
||
}
|
||
.image-captcha-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.captcha-img {
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 4px;
|
||
height: 50px;
|
||
}
|
||
.captcha-actions {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
.slider-wrapper {
|
||
margin-bottom: 8px;
|
||
}
|
||
.slider-bg {
|
||
position: relative;
|
||
margin: 0 auto 12px;
|
||
width: 280px;
|
||
height: 160px;
|
||
overflow: hidden;
|
||
border-radius: 4px;
|
||
}
|
||
.slider-bg img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.slider-gap-marker {
|
||
position: absolute;
|
||
top: 0;
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 2px solid rgba(255, 0, 0, 0.5);
|
||
background: rgba(255, 0, 0, 0.1);
|
||
pointer-events: none;
|
||
}
|
||
.slider-piece-track {
|
||
position: relative;
|
||
margin: 0 auto 8px;
|
||
width: 280px;
|
||
}
|
||
.slider-piece {
|
||
position: absolute;
|
||
top: -40px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 4px;
|
||
z-index: 2;
|
||
transition: none;
|
||
}
|
||
.slider-rail {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 36px;
|
||
background: #ebeef5;
|
||
border-radius: 18px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.slider-thumb {
|
||
position: absolute;
|
||
top: -2px;
|
||
width: 40px;
|
||
height: 40px;
|
||
line-height: 40px;
|
||
text-align: center;
|
||
background: #409eff;
|
||
color: #fff;
|
||
border-radius: 50%;
|
||
cursor: grab;
|
||
font-size: 16px;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||
}
|
||
.slider-thumb:active { cursor: grabbing; }
|
||
.slider-hint { color: #909399; font-size: 12px; margin: 0; }
|
||
.verified-badge {
|
||
color: #67c23a;
|
||
font-size: 14px;
|
||
margin-bottom: 12px;
|
||
}
|
||
</style>
|