Files
zacfrontuser_v2/src/components/ClickCaptcha.vue
2026-01-15 18:03:13 +08:00

522 lines
12 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 v-if="visible" class="captcha-overlay">
<div class="captcha-modal">
<div class="captcha-header">
<h3 class="captcha-title">安全验证</h3>
<button class="close-btn" @click="handleClose">×</button>
</div>
<div class="captcha-content">
<canvas ref="canvasRef" :width="canvasWidth" :height="canvasHeight" class="captcha-canvas"
@click="handleCanvasClick"></canvas>
<div class="captcha-instruction">
<p>
请依次点击 <span class="target-list">{{ targetChars.join('、') }}</span>
</p>
</div>
<div class="captcha-status">
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
<p v-else-if="successMessage" class="success-message">{{ successMessage }}</p>
<p v-else class="status-text">点击图片中的目标文字</p>
</div>
</div>
<div class="captcha-footer">
<button class="refresh-btn" @click="refreshCaptcha" :disabled="isRefreshing">
{{ isRefreshing ? '刷新中...' : '刷新验证' }}
</button>
<button class="confirm-btn" :disabled="clickedList.length < 3 || !!successMessage" @click="handleConfirm">
确认
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['success', 'close'])
const canvasRef = ref(null)
const canvasWidth = 300
const canvasHeight = 180
const bgImgUrl = '/image/clickCaptcha.jpg' // 可替换为任意背景图
const allChars = ['大', '数', '据', '一', '查', '风', '险', '报', '告']
const targetChars = ref(['一', '查', '查']) // 目标点击顺序固定
const charPositions = ref([]) // [{char, x, y, w, h}]
const clickedIndex = ref(0)
const errorMessage = ref('')
const successMessage = ref('')
const isRefreshing = ref(false)
const clickedList = ref([]) // [{char, idx, ...}]
let currentChars = [] // 当前乱序后的字顺序
function randomChars(count, except = []) {
const pool = allChars.filter(c => !except.includes(c))
const arr = []
while (arr.length < count) {
const c = pool[Math.floor(Math.random() * pool.length)]
if (!arr.includes(c)) arr.push(c)
}
return arr
}
function randomColor() {
return (
'#' +
Math.floor(Math.random() * 0xffffff)
.toString(16)
.padStart(6, '0')
)
}
function drawCaptcha() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvasWidth, canvasHeight)
// 绘制背景图
const bg = new window.Image()
bg.src = bgImgUrl
bg.onload = () => {
ctx.drawImage(bg, 0, 0, canvasWidth, canvasHeight)
// 绘制乱序文字
charPositions.value.forEach(pos => {
ctx.save()
ctx.translate(pos.x + pos.w / 2, pos.y + pos.h / 2)
ctx.rotate(pos.angle)
ctx.font = 'bold 28px sans-serif'
ctx.fillStyle = pos.color
ctx.shadowColor = '#333'
ctx.shadowBlur = 4
ctx.fillText(pos.char, -pos.w / 2, pos.h / 2 - 8)
ctx.restore()
})
// 绘制点击顺序标签
clickedList.value.forEach((item, i) => {
const pos = charPositions.value[item.idx]
if (!pos) return
ctx.save()
ctx.beginPath()
ctx.arc(pos.x + pos.w / 2, pos.y + 6, 12, 0, 2 * Math.PI)
ctx.fillStyle = '#8CC6F7'
ctx.fill()
ctx.font = 'bold 16px sans-serif'
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText((i + 1).toString(), pos.x + pos.w / 2, pos.y + 6)
ctx.restore()
})
}
}
function refreshCaptcha() {
isRefreshing.value = true
setTimeout(() => {
generateCaptcha()
isRefreshing.value = false
}, 500)
}
function generateCaptcha() {
// 乱序排列7个字
const chars = [...allChars]
for (let i = chars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[chars[i], chars[j]] = [chars[j], chars[i]]
}
currentChars = chars
targetChars.value = ['一', '查', '查']
clickedIndex.value = 0
errorMessage.value = ''
successMessage.value = ''
clickedList.value = []
// 生成每个字的坐标、角度、颜色
charPositions.value = []
const used = []
chars.forEach((char, idx) => {
let x,
y,
w = 36,
h = 36,
tryCount = 0
do {
x = Math.random() * (canvasWidth - w - 10) + 5
y = Math.random() * (canvasHeight - h - 10) + 5
tryCount++
} while (used.some(pos => Math.abs(pos.x - x) < w && Math.abs(pos.y - y) < h) && tryCount < 20)
used.push({ x, y })
const angle = (Math.random() - 0.5) * 0.7
const color = randomColor()
charPositions.value.push({ char, x, y, w, h, idx, angle, color })
})
nextTick(drawCaptcha)
}
function handleCanvasClick(e) {
if (successMessage.value) return
// 适配缩放
const rect = canvasRef.value.getBoundingClientRect()
const scaleX = canvasWidth / rect.width
const scaleY = canvasHeight / rect.height
const x = (e.clientX - rect.left) * scaleX
const y = (e.clientY - rect.top) * scaleY
// 找到被点中的字
const posIdx = charPositions.value.findIndex(
pos => x >= pos.x && x <= pos.x + pos.w && y >= pos.y && y <= pos.y + pos.h
)
if (posIdx === -1) {
errorMessage.value = '请点击目标文字'
setTimeout(() => (errorMessage.value = ''), 1200)
return
}
// 已经点过不能重复点
if (clickedList.value.some(item => item.idx === posIdx)) return
if (clickedList.value.length >= charPositions.value.length) return
clickedList.value.push({ char: charPositions.value[posIdx].char, idx: posIdx })
drawCaptcha()
}
function handleConfirm() {
if (clickedList.value.length < 3) {
errorMessage.value = '请依次点击3个字'
setTimeout(() => (errorMessage.value = ''), 1200)
return
}
const userSeq = clickedList.value
.slice(0, 3)
.map(item => item.char)
.join('')
if (userSeq === '真爱查') {
successMessage.value = '验证成功!'
setTimeout(() => emit('success'), 600)
} else {
errorMessage.value = '校验错误,请重试'
setTimeout(() => {
errorMessage.value = ''
clickedList.value = []
// 失败时重新打乱
generateCaptcha()
}, 1200)
}
}
function handleClose() {
emit('close')
}
onMounted(() => {
if (props.visible) generateCaptcha()
})
watch(
() => props.visible,
v => {
if (v) generateCaptcha()
}
)
</script>
<style scoped>
.captcha-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 1rem;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.captcha-modal {
background: #fff;
border-radius: 1rem;
width: 100%;
max-width: 360px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.captcha-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border-primary, #ebedf0);
background: linear-gradient(135deg, var(--color-primary-light, rgba(140, 198, 247, 0.1)), #ffffff);
}
.captcha-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary, #323233);
margin: 0;
background: linear-gradient(135deg, var(--color-primary, #8CC6F7), var(--color-primary-600, #709ec6));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.close-btn {
background: rgba(0, 0, 0, 0.05);
border: none;
font-size: 1.5rem;
color: var(--color-text-secondary, #646566);
cursor: pointer;
padding: 0.375rem;
width: 32px;
height: 32px;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
line-height: 1;
}
.close-btn:hover {
color: var(--color-text-primary, #323233);
background: rgba(0, 0, 0, 0.1);
transform: rotate(90deg);
}
.captcha-content {
padding: 1.5rem 1.5rem 1rem 1.5rem;
background: #ffffff;
}
.captcha-canvas {
width: 100%;
border-radius: 0.75rem;
background: var(--color-bg-tertiary, #f8f8f8);
display: block;
margin: 0 auto;
border: 2px solid var(--color-border-primary, #ebedf0);
transition: all 0.2s ease;
cursor: pointer;
}
.captcha-canvas:hover {
border-color: var(--color-primary, #8CC6F7);
box-shadow: 0 0 0 3px var(--color-primary-light, rgba(140, 198, 247, 0.1));
}
.captcha-instruction {
margin: 1.25rem 0 0.75rem 0;
text-align: center;
}
.captcha-instruction p {
font-size: 0.95rem;
color: var(--color-text-secondary, #646566);
margin: 0;
line-height: 1.5;
}
.target-list {
color: var(--color-primary, #8CC6F7);
font-weight: 600;
font-size: 1.05rem;
padding: 0.25rem 0.5rem;
background: var(--color-primary-light, rgba(140, 198, 247, 0.1));
border-radius: 0.375rem;
display: inline-block;
}
.captcha-status {
text-align: center;
min-height: 1.75rem;
margin-top: 0.5rem;
}
.error-message {
color: var(--color-danger, #ee0a24);
font-size: 0.875rem;
margin: 0;
padding: 0.5rem;
background: rgba(238, 10, 36, 0.1);
border-radius: 0.5rem;
animation: shake 0.3s ease-in-out;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}
.success-message {
color: var(--color-success, #07c160);
font-size: 0.875rem;
margin: 0;
padding: 0.5rem;
background: rgba(7, 193, 96, 0.1);
border-radius: 0.5rem;
animation: bounce 0.4s ease-out;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
.status-text {
color: var(--color-text-tertiary, #969799);
font-size: 0.875rem;
margin: 0;
}
.captcha-footer {
padding: 1.25rem 1.5rem;
border-top: 1px solid var(--color-border-primary, #ebedf0);
background: var(--color-bg-secondary, #fafafa);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.refresh-btn {
width: 100%;
padding: 0.875rem;
background: var(--color-primary, #8CC6F7);
color: white;
border: none;
border-radius: 0.625rem;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(140, 198, 247, 0.3);
}
.refresh-btn:hover:not(:disabled) {
background: var(--color-primary-dark, rgba(140, 198, 247, 0.8));
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(140, 198, 247, 0.4);
}
.refresh-btn:active:not(:disabled) {
transform: translateY(0);
}
.refresh-btn:disabled {
background: var(--color-text-disabled, #c8c9cc);
cursor: not-allowed;
box-shadow: none;
}
.confirm-btn {
width: 100%;
padding: 0.875rem;
background: var(--color-success, #07c160);
color: white;
border: none;
border-radius: 0.625rem;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(7, 193, 96, 0.3);
}
.confirm-btn:hover:not(:disabled) {
background: rgba(7, 193, 96, 0.9);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.4);
}
.confirm-btn:active:not(:disabled) {
transform: translateY(0);
}
.confirm-btn:disabled {
background: var(--color-text-disabled, #c8c9cc);
cursor: not-allowed;
box-shadow: none;
}
@media (max-width: 480px) {
.captcha-overlay {
padding: 0.75rem;
}
.captcha-modal {
max-width: 100%;
border-radius: 0.875rem;
}
.captcha-header {
padding: 1rem 1.25rem;
}
.captcha-content {
padding: 1.25rem 1.25rem 0.875rem 1.25rem;
}
.captcha-canvas {
min-height: 140px;
}
.captcha-footer {
padding: 1rem 1.25rem;
}
.captcha-instruction p {
font-size: 0.875rem;
}
}
</style>