committed by
GitHub
10 changed files with 396 additions and 4 deletions
@ -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> |
||||
@ -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