|
@@ -0,0 +1,311 @@
|
|
|
|
+<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.sliderTranslateSuccessTip', [
|
|
|
|
+ ((state.endTime - state.startTime) / 1000).toFixed(1),
|
|
|
|
+ ])
|
|
|
|
+ : $t('ui.captcha.sliderTranslateFailTip');
|
|
|
|
+});
|
|
|
|
+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 || 3)) {
|
|
|
|
+ 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');
|
|
|
|
+ // Canvas2D: Multiple readback operations using getImageData
|
|
|
|
+ // are faster with the willReadFrequently attribute set to true.
|
|
|
|
+ // See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
|
|
|
|
+ const pieceCanvasCtx = pieceCanvas.getContext('2d', {
|
|
|
|
+ willReadFrequently: true,
|
|
|
|
+ });
|
|
|
|
+ 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');
|
|
|
|
+ // Canvas2D: Multiple readback operations using getImageData
|
|
|
|
+ // are faster with the willReadFrequently attribute set to true.
|
|
|
|
+ // See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
|
|
|
|
+ const pieceCanvasCtx = pieceCanvas.getContext('2d', {
|
|
|
|
+ willReadFrequently: true,
|
|
|
|
+ });
|
|
|
|
+ if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
|
|
|
|
+ const img = new Image();
|
|
|
|
+ // 解决跨域
|
|
|
|
+ img.crossOrigin = 'Anonymous';
|
|
|
|
+ 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);
|
|
|
|
+ setLeft('0');
|
|
|
|
+ });
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+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="h-15 absolute bottom-3 left-0 z-10 block 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.sliderTranslateDefaultTip') }}
|
|
|
|
+ </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>
|