|
| 1 | +<script setup lang="ts"> |
| 2 | +import type { |
| 3 | + CaptchaVerifyPassingData, |
| 4 | + SliderCaptchaActionType, |
| 5 | + SliderRotateVerifyPassingData, |
| 6 | + SliderTranslateCaptchaProps, |
| 7 | +} from '../types'; |
| 8 | +
|
| 9 | +import { |
| 10 | + computed, |
| 11 | + onMounted, |
| 12 | + reactive, |
| 13 | + ref, |
| 14 | + unref, |
| 15 | + useTemplateRef, |
| 16 | + watch, |
| 17 | +} from 'vue'; |
| 18 | +
|
| 19 | +import { $t } from '@vben/locales'; |
| 20 | +
|
| 21 | +import SliderCaptcha from '../slider-captcha/index.vue'; |
| 22 | +
|
| 23 | +const props = withDefaults(defineProps<SliderTranslateCaptchaProps>(), { |
| 24 | + defaultTip: '', |
| 25 | + canvasWidth: 420, |
| 26 | + canvasHeight: 280, |
| 27 | + squareLength: 42, |
| 28 | + circleRadius: 10, |
| 29 | + src: '', |
| 30 | + diffDistance: 3, |
| 31 | +}); |
| 32 | +
|
| 33 | +const emit = defineEmits<{ |
| 34 | + success: [CaptchaVerifyPassingData]; |
| 35 | +}>(); |
| 36 | +
|
| 37 | +const PI: number = Math.PI; |
| 38 | +enum CanvasOpr { |
| 39 | + // eslint-disable-next-line no-unused-vars |
| 40 | + Clip = 'clip', |
| 41 | + // eslint-disable-next-line no-unused-vars |
| 42 | + Fill = 'fill', |
| 43 | +} |
| 44 | +
|
| 45 | +const modalValue = defineModel<boolean>({ default: false }); |
| 46 | +
|
| 47 | +const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef'); |
| 48 | +const puzzleCanvasRef = useTemplateRef<HTMLCanvasElement>('puzzleCanvasRef'); |
| 49 | +const pieceCanvasRef = useTemplateRef<HTMLCanvasElement>('pieceCanvasRef'); |
| 50 | +
|
| 51 | +const state = reactive({ |
| 52 | + dragging: false, |
| 53 | + startTime: 0, |
| 54 | + endTime: 0, |
| 55 | + pieceX: 0, |
| 56 | + pieceY: 0, |
| 57 | + moveDistance: 0, |
| 58 | + isPassing: false, |
| 59 | + showTip: false, |
| 60 | +}); |
| 61 | +
|
| 62 | +const left = ref('0'); |
| 63 | +
|
| 64 | +const pieceStyle = computed(() => { |
| 65 | + return { |
| 66 | + left: left.value, |
| 67 | + }; |
| 68 | +}); |
| 69 | +
|
| 70 | +function setLeft(val: string) { |
| 71 | + left.value = val; |
| 72 | +} |
| 73 | +
|
| 74 | +const verifyTip = computed(() => { |
| 75 | + return state.isPassing |
| 76 | + ? $t('ui.captcha.sliderTranslateSuccessTip', [ |
| 77 | + ((state.endTime - state.startTime) / 1000).toFixed(1), |
| 78 | + ]) |
| 79 | + : $t('ui.captcha.sliderTranslateFailTip'); |
| 80 | +}); |
| 81 | +function handleStart() { |
| 82 | + state.startTime = Date.now(); |
| 83 | +} |
| 84 | +
|
| 85 | +function handleDragBarMove(data: SliderRotateVerifyPassingData) { |
| 86 | + state.dragging = true; |
| 87 | + const { moveX } = data; |
| 88 | + state.moveDistance = moveX; |
| 89 | + setLeft(`${moveX}px`); |
| 90 | +} |
| 91 | +
|
| 92 | +function handleDragEnd() { |
| 93 | + const { pieceX } = state; |
| 94 | + const { diffDistance } = props; |
| 95 | +
|
| 96 | + if (Math.abs(pieceX - state.moveDistance) >= (diffDistance || 3)) { |
| 97 | + setLeft('0'); |
| 98 | + state.moveDistance = 0; |
| 99 | + } else { |
| 100 | + checkPass(); |
| 101 | + } |
| 102 | + state.showTip = true; |
| 103 | + state.dragging = false; |
| 104 | +} |
| 105 | +
|
| 106 | +function checkPass() { |
| 107 | + state.isPassing = true; |
| 108 | + state.endTime = Date.now(); |
| 109 | +} |
| 110 | +
|
| 111 | +watch( |
| 112 | + () => state.isPassing, |
| 113 | + (isPassing) => { |
| 114 | + if (isPassing) { |
| 115 | + const { endTime, startTime } = state; |
| 116 | + const time = (endTime - startTime) / 1000; |
| 117 | + emit('success', { isPassing, time: time.toFixed(1) }); |
| 118 | + } |
| 119 | + modalValue.value = isPassing; |
| 120 | + }, |
| 121 | +); |
| 122 | +
|
| 123 | +function resetCanvas() { |
| 124 | + const { canvasWidth, canvasHeight } = props; |
| 125 | + const puzzleCanvas = unref(puzzleCanvasRef); |
| 126 | + const pieceCanvas = unref(pieceCanvasRef); |
| 127 | + if (!puzzleCanvas || !pieceCanvas) return; |
| 128 | + pieceCanvas.width = canvasWidth; |
| 129 | + const puzzleCanvasCtx = puzzleCanvas.getContext('2d'); |
| 130 | + // Canvas2D: Multiple readback operations using getImageData |
| 131 | + // are faster with the willReadFrequently attribute set to true. |
| 132 | + // See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous) |
| 133 | + const pieceCanvasCtx = pieceCanvas.getContext('2d', { |
| 134 | + willReadFrequently: true, |
| 135 | + }); |
| 136 | + if (!puzzleCanvasCtx || !pieceCanvasCtx) return; |
| 137 | + puzzleCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight); |
| 138 | + pieceCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight); |
| 139 | +} |
| 140 | +
|
| 141 | +function initCanvas() { |
| 142 | + const { canvasWidth, canvasHeight, squareLength, circleRadius, src } = props; |
| 143 | + const puzzleCanvas = unref(puzzleCanvasRef); |
| 144 | + const pieceCanvas = unref(pieceCanvasRef); |
| 145 | + if (!puzzleCanvas || !pieceCanvas) return; |
| 146 | + const puzzleCanvasCtx = puzzleCanvas.getContext('2d'); |
| 147 | + // Canvas2D: Multiple readback operations using getImageData |
| 148 | + // are faster with the willReadFrequently attribute set to true. |
| 149 | + // See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous) |
| 150 | + const pieceCanvasCtx = pieceCanvas.getContext('2d', { |
| 151 | + willReadFrequently: true, |
| 152 | + }); |
| 153 | + if (!puzzleCanvasCtx || !pieceCanvasCtx) return; |
| 154 | + const img = new Image(); |
| 155 | + // 解决跨域 |
| 156 | + img.crossOrigin = 'Anonymous'; |
| 157 | + img.src = src; |
| 158 | + img.addEventListener('load', () => { |
| 159 | + draw(puzzleCanvasCtx, pieceCanvasCtx); |
| 160 | + puzzleCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight); |
| 161 | + pieceCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight); |
| 162 | + const pieceLength = squareLength + 2 * circleRadius + 3; |
| 163 | + const sx = state.pieceX; |
| 164 | + const sy = state.pieceY - 2 * circleRadius - 1; |
| 165 | + const imageData = pieceCanvasCtx.getImageData( |
| 166 | + sx, |
| 167 | + sy, |
| 168 | + pieceLength, |
| 169 | + pieceLength, |
| 170 | + ); |
| 171 | + pieceCanvas.width = pieceLength; |
| 172 | + pieceCanvasCtx.putImageData(imageData, 0, sy); |
| 173 | + setLeft('0'); |
| 174 | + }); |
| 175 | +} |
| 176 | +
|
| 177 | +function getRandomNumberByRange(start: number, end: number) { |
| 178 | + return Math.round(Math.random() * (end - start) + start); |
| 179 | +} |
| 180 | +
|
| 181 | +// 绘制拼图 |
| 182 | +function draw(ctx1: CanvasRenderingContext2D, ctx2: CanvasRenderingContext2D) { |
| 183 | + const { canvasWidth, canvasHeight, squareLength, circleRadius } = props; |
| 184 | + state.pieceX = getRandomNumberByRange( |
| 185 | + squareLength + 2 * circleRadius, |
| 186 | + canvasWidth - (squareLength + 2 * circleRadius), |
| 187 | + ); |
| 188 | + state.pieceY = getRandomNumberByRange( |
| 189 | + 3 * circleRadius, |
| 190 | + canvasHeight - (squareLength + 2 * circleRadius), |
| 191 | + ); |
| 192 | + drawPiece(ctx1, state.pieceX, state.pieceY, CanvasOpr.Fill); |
| 193 | + drawPiece(ctx2, state.pieceX, state.pieceY, CanvasOpr.Clip); |
| 194 | +} |
| 195 | +
|
| 196 | +// 绘制拼图切块 |
| 197 | +function drawPiece( |
| 198 | + ctx: CanvasRenderingContext2D, |
| 199 | + x: number, |
| 200 | + y: number, |
| 201 | + opr: CanvasOpr, |
| 202 | +) { |
| 203 | + const { squareLength, circleRadius } = props; |
| 204 | + ctx.beginPath(); |
| 205 | + ctx.moveTo(x, y); |
| 206 | + ctx.arc( |
| 207 | + x + squareLength / 2, |
| 208 | + y - circleRadius + 2, |
| 209 | + circleRadius, |
| 210 | + 0.72 * PI, |
| 211 | + 2.26 * PI, |
| 212 | + ); |
| 213 | + ctx.lineTo(x + squareLength, y); |
| 214 | + ctx.arc( |
| 215 | + x + squareLength + circleRadius - 2, |
| 216 | + y + squareLength / 2, |
| 217 | + circleRadius, |
| 218 | + 1.21 * PI, |
| 219 | + 2.78 * PI, |
| 220 | + ); |
| 221 | + ctx.lineTo(x + squareLength, y + squareLength); |
| 222 | + ctx.lineTo(x, y + squareLength); |
| 223 | + ctx.arc( |
| 224 | + x + circleRadius - 2, |
| 225 | + y + squareLength / 2, |
| 226 | + circleRadius + 0.4, |
| 227 | + 2.76 * PI, |
| 228 | + 1.24 * PI, |
| 229 | + true, |
| 230 | + ); |
| 231 | + ctx.lineTo(x, y); |
| 232 | + ctx.lineWidth = 2; |
| 233 | + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; |
| 234 | + ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; |
| 235 | + ctx.stroke(); |
| 236 | + opr === CanvasOpr.Clip ? ctx.clip() : ctx.fill(); |
| 237 | + ctx.globalCompositeOperation = 'destination-over'; |
| 238 | +} |
| 239 | +
|
| 240 | +function resume() { |
| 241 | + state.showTip = false; |
| 242 | + const basicEl = unref(slideBarRef); |
| 243 | + if (!basicEl) { |
| 244 | + return; |
| 245 | + } |
| 246 | + state.dragging = false; |
| 247 | + state.isPassing = false; |
| 248 | + state.pieceX = 0; |
| 249 | + state.pieceY = 0; |
| 250 | +
|
| 251 | + basicEl.resume(); |
| 252 | + resetCanvas(); |
| 253 | + initCanvas(); |
| 254 | +} |
| 255 | +
|
| 256 | +onMounted(() => { |
| 257 | + initCanvas(); |
| 258 | +}); |
| 259 | +</script> |
| 260 | + |
| 261 | +<template> |
| 262 | + <div class="relative flex flex-col items-center"> |
| 263 | + <div |
| 264 | + class="border-border relative flex cursor-pointer overflow-hidden border shadow-md" |
| 265 | + > |
| 266 | + <canvas |
| 267 | + ref="puzzleCanvasRef" |
| 268 | + :width="canvasWidth" |
| 269 | + :height="canvasHeight" |
| 270 | + @click="resume" |
| 271 | + ></canvas> |
| 272 | + <canvas |
| 273 | + ref="pieceCanvasRef" |
| 274 | + :width="canvasWidth" |
| 275 | + :height="canvasHeight" |
| 276 | + :style="pieceStyle" |
| 277 | + class="absolute" |
| 278 | + @click="resume" |
| 279 | + ></canvas> |
| 280 | + <div |
| 281 | + class="h-15 absolute bottom-3 left-0 z-10 block w-full text-center text-xs leading-[30px] text-white" |
| 282 | + > |
| 283 | + <div |
| 284 | + v-if="state.showTip" |
| 285 | + :class="{ |
| 286 | + 'bg-success/80': state.isPassing, |
| 287 | + 'bg-destructive/80': !state.isPassing, |
| 288 | + }" |
| 289 | + > |
| 290 | + {{ verifyTip }} |
| 291 | + </div> |
| 292 | + <div v-if="!state.dragging" class="bg-black/30"> |
| 293 | + {{ defaultTip || $t('ui.captcha.sliderTranslateDefaultTip') }} |
| 294 | + </div> |
| 295 | + </div> |
| 296 | + </div> |
| 297 | + <SliderCaptcha |
| 298 | + ref="slideBarRef" |
| 299 | + v-model="modalValue" |
| 300 | + class="mt-5" |
| 301 | + is-slot |
| 302 | + @end="handleDragEnd" |
| 303 | + @move="handleDragBarMove" |
| 304 | + @start="handleStart" |
| 305 | + > |
| 306 | + <template v-for="(_, key) in $slots" :key="key" #[key]="slotProps"> |
| 307 | + <slot :name="key" v-bind="slotProps"></slot> |
| 308 | + </template> |
| 309 | + </SliderCaptcha> |
| 310 | + </div> |
| 311 | +</template> |
0 commit comments