feat: 增加基于图片拼图切片平移的验证码
parent
b78bc65ce7
commit
8554924cb9
|
|
@ -0,0 +1,298 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
CaptchaVerifyPassingData,
|
||||||
|
SliderCaptchaActionType,
|
||||||
|
SliderRotateVerifyPassingData,
|
||||||
|
SliderTranslateCaptchaProps,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
reactive,
|
||||||
|
ref,
|
||||||
|
unref,
|
||||||
|
useTemplateRef,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import SliderCaptcha from '../slider-captcha/index.vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SliderTranslateCaptchaProps>(), {
|
||||||
|
defaultTip: '',
|
||||||
|
canvasWidth: 420,
|
||||||
|
canvasHeight: 280,
|
||||||
|
squareLength: 42,
|
||||||
|
circleRadius: 10,
|
||||||
|
src: '',
|
||||||
|
diffDistance: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
success: [CaptchaVerifyPassingData];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const PI: number = Math.PI;
|
||||||
|
enum CanvasOpr {
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
Clip = 'clip',
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
Fill = 'fill',
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalValue = defineModel<boolean>({ default: false });
|
||||||
|
|
||||||
|
const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
|
||||||
|
const puzzleCanvasRef = useTemplateRef<HTMLCanvasElement>('puzzleCanvasRef');
|
||||||
|
const pieceCanvasRef = useTemplateRef<HTMLCanvasElement>('pieceCanvasRef');
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
dragging: false,
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
pieceX: 0,
|
||||||
|
PieceY: 0,
|
||||||
|
moveDistance: 0,
|
||||||
|
isPassing: false,
|
||||||
|
showTip: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const left = ref('0');
|
||||||
|
|
||||||
|
const pieceStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
left: left.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function setLeft(val: string) {
|
||||||
|
left.value = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyTip = computed(() => {
|
||||||
|
return state.isPassing
|
||||||
|
? $t('ui.captcha.sliderRotateSuccessTip', [
|
||||||
|
((state.endTime - state.startTime) / 1000).toFixed(1),
|
||||||
|
])
|
||||||
|
: $t('ui.captcha.sliderRotateFailTip');
|
||||||
|
});
|
||||||
|
function handleStart() {
|
||||||
|
state.startTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragBarMove(data: SliderRotateVerifyPassingData) {
|
||||||
|
state.dragging = true;
|
||||||
|
const { moveX } = data;
|
||||||
|
state.moveDistance = moveX;
|
||||||
|
setLeft(`${moveX}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
const { pieceX } = state;
|
||||||
|
const { diffDistance } = props;
|
||||||
|
|
||||||
|
if (Math.abs(pieceX - state.moveDistance) >= (diffDistance || 10)) {
|
||||||
|
setLeft('0');
|
||||||
|
state.moveDistance = 0;
|
||||||
|
} else {
|
||||||
|
checkPass();
|
||||||
|
}
|
||||||
|
state.showTip = true;
|
||||||
|
state.dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPass() {
|
||||||
|
state.isPassing = true;
|
||||||
|
state.endTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => state.isPassing,
|
||||||
|
(isPassing) => {
|
||||||
|
if (isPassing) {
|
||||||
|
const { endTime, startTime } = state;
|
||||||
|
const time = (endTime - startTime) / 1000;
|
||||||
|
emit('success', { isPassing, time: time.toFixed(1) });
|
||||||
|
}
|
||||||
|
modalValue.value = isPassing;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function resetCanvas() {
|
||||||
|
const { canvasWidth, canvasHeight } = props;
|
||||||
|
const puzzleCanvas = unref(puzzleCanvasRef);
|
||||||
|
const pieceCanvas = unref(pieceCanvasRef);
|
||||||
|
if (!puzzleCanvas || !pieceCanvas) return;
|
||||||
|
pieceCanvas.width = canvasWidth;
|
||||||
|
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
|
||||||
|
const pieceCanvasCtx = pieceCanvas.getContext('2d');
|
||||||
|
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
|
||||||
|
puzzleCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
pieceCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCanvas() {
|
||||||
|
const { canvasWidth, canvasHeight, squareLength, circleRadius, src } = props;
|
||||||
|
const puzzleCanvas = unref(puzzleCanvasRef);
|
||||||
|
const pieceCanvas = unref(pieceCanvasRef);
|
||||||
|
if (!puzzleCanvas || !pieceCanvas) return;
|
||||||
|
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
|
||||||
|
const pieceCanvasCtx = pieceCanvas.getContext('2d');
|
||||||
|
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
|
||||||
|
const img = new Image();
|
||||||
|
img.src = src;
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
draw(puzzleCanvasCtx, pieceCanvasCtx);
|
||||||
|
puzzleCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
|
||||||
|
pieceCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
|
||||||
|
const pieceLength = squareLength + 2 * circleRadius + 3;
|
||||||
|
const sx = state.pieceX;
|
||||||
|
const sy = state.PieceY - 2 * circleRadius - 1;
|
||||||
|
const imageData = pieceCanvasCtx.getImageData(
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
pieceLength,
|
||||||
|
pieceLength,
|
||||||
|
);
|
||||||
|
pieceCanvas.width = pieceLength;
|
||||||
|
pieceCanvasCtx.putImageData(imageData, 0, sy);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomNumberByRange(start: number, end: number) {
|
||||||
|
return Math.round(Math.random() * (end - start) + start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制拼图
|
||||||
|
function draw(ctx1: CanvasRenderingContext2D, ctx2: CanvasRenderingContext2D) {
|
||||||
|
const { canvasWidth, canvasHeight, squareLength, circleRadius } = props;
|
||||||
|
state.pieceX = getRandomNumberByRange(
|
||||||
|
squareLength + 2 * circleRadius,
|
||||||
|
canvasWidth - (squareLength + 2 * circleRadius),
|
||||||
|
);
|
||||||
|
state.PieceY = getRandomNumberByRange(
|
||||||
|
3 * circleRadius,
|
||||||
|
canvasHeight - (squareLength + 2 * circleRadius),
|
||||||
|
);
|
||||||
|
drawPiece(ctx1, state.pieceX, state.PieceY, CanvasOpr.Fill);
|
||||||
|
drawPiece(ctx2, state.pieceX, state.PieceY, CanvasOpr.Clip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制拼图切块
|
||||||
|
function drawPiece(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
opr: CanvasOpr,
|
||||||
|
) {
|
||||||
|
const { squareLength, circleRadius } = props;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.arc(
|
||||||
|
x + squareLength / 2,
|
||||||
|
y - circleRadius + 2,
|
||||||
|
circleRadius,
|
||||||
|
0.72 * PI,
|
||||||
|
2.26 * PI,
|
||||||
|
);
|
||||||
|
ctx.lineTo(x + squareLength, y);
|
||||||
|
ctx.arc(
|
||||||
|
x + squareLength + circleRadius - 2,
|
||||||
|
y + squareLength / 2,
|
||||||
|
circleRadius,
|
||||||
|
1.21 * PI,
|
||||||
|
2.78 * PI,
|
||||||
|
);
|
||||||
|
ctx.lineTo(x + squareLength, y + squareLength);
|
||||||
|
ctx.lineTo(x, y + squareLength);
|
||||||
|
ctx.arc(
|
||||||
|
x + circleRadius - 2,
|
||||||
|
y + squareLength / 2,
|
||||||
|
circleRadius + 0.4,
|
||||||
|
2.76 * PI,
|
||||||
|
1.24 * PI,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||||
|
ctx.stroke();
|
||||||
|
opr === CanvasOpr.Clip ? ctx.clip() : ctx.fill();
|
||||||
|
ctx.globalCompositeOperation = 'destination-over';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resume() {
|
||||||
|
state.showTip = false;
|
||||||
|
const basicEl = unref(slideBarRef);
|
||||||
|
if (!basicEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.dragging = false;
|
||||||
|
state.isPassing = false;
|
||||||
|
state.pieceX = 0;
|
||||||
|
state.PieceY = 0;
|
||||||
|
|
||||||
|
basicEl.resume();
|
||||||
|
resetCanvas();
|
||||||
|
initCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initCanvas();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
class="border-border relative flex cursor-pointer overflow-hidden border shadow-md"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref="puzzleCanvasRef"
|
||||||
|
:width="canvasWidth"
|
||||||
|
:height="canvasHeight"
|
||||||
|
@click="resume"
|
||||||
|
></canvas>
|
||||||
|
<canvas
|
||||||
|
ref="pieceCanvasRef"
|
||||||
|
:width="canvasWidth"
|
||||||
|
:height="canvasHeight"
|
||||||
|
:style="pieceStyle"
|
||||||
|
class="absolute"
|
||||||
|
@click="resume"
|
||||||
|
></canvas>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-3 left-0 z-10 block h-7 w-full text-center text-xs leading-[30px] text-white"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="state.showTip"
|
||||||
|
:class="{
|
||||||
|
'bg-success/80': state.isPassing,
|
||||||
|
'bg-destructive/80': !state.isPassing,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ verifyTip }}
|
||||||
|
</div>
|
||||||
|
<div v-if="!state.dragging" class="bg-black/30">
|
||||||
|
{{ defaultTip || $t('ui.captcha.sliderRotateDefaultTip') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SliderCaptcha
|
||||||
|
ref="slideBarRef"
|
||||||
|
v-model="modalValue"
|
||||||
|
class="mt-5"
|
||||||
|
is-slot
|
||||||
|
@end="handleDragEnd"
|
||||||
|
@move="handleDragBarMove"
|
||||||
|
@start="handleStart"
|
||||||
|
>
|
||||||
|
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
|
||||||
|
<slot :name="key" v-bind="slotProps"></slot>
|
||||||
|
</template>
|
||||||
|
</SliderCaptcha>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Page, SliderTranslateCaptcha } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Card, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
function handleSuccess() {
|
||||||
|
message.success('success!');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page
|
||||||
|
description="用于前端简单的拼图滑块水平拖动校验场景"
|
||||||
|
title="拼图滑块校验"
|
||||||
|
>
|
||||||
|
<Card class="mb-5" title="基本示例">
|
||||||
|
<div class="flex items-center justify-center p-4">
|
||||||
|
<SliderTranslateCaptcha
|
||||||
|
src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/pro-avatar.webp"
|
||||||
|
:canvas-width="420"
|
||||||
|
:canvas-height="420"
|
||||||
|
@success="handleSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
Loading…
Reference in New Issue