Files
cma-management/frontend/src/views/SliderCaptcha.vue
T

336 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>