diff --git a/components/captcha/captcha.tsx b/components/captcha/captcha.tsx index 6e86d485f..d299043bc 100644 --- a/components/captcha/captcha.tsx +++ b/components/captcha/captcha.tsx @@ -39,6 +39,7 @@ import { randomHardColor } from '@vexip-ui/utils' import { captchaProps } from './props' +import { heartPath, puzzlePath, shieldPath, squarePath } from './hollow-paths' import type { CaptchaSliderExposed } from '@/components/captcha-slider' import type { SuccessEvent } from './symbol' @@ -98,6 +99,10 @@ export default defineComponent({ hideDelay: { default: 3000, validator: value => value >= 0 + }, + hollowShape: { + default: squarePath, + isFunc: true } }) @@ -138,6 +143,7 @@ export default defineComponent({ let imageLoaded = false let image: HTMLImageElement | undefined + let memoryCanvas: HTMLCanvasElement | undefined const isLoading = computed(() => props.loading || imageLoading.value || testLoading.value) const failLocked = computed(() => props.failLimit > 0 && failedCount.value >= props.failLimit) @@ -178,9 +184,15 @@ export default defineComponent({ await (imagePromise.value = loadImage()) drawImageNextFrame() }) - watch([currentTarget, () => props.canvasSize[0], () => props.canvasSize[1]], () => { - drawImageNextFrame() - }) + watch( + [ + currentTarget, + () => props.canvasSize[0], + () => props.canvasSize[1], + () => props.hollowShape + ], + drawImageNextFrame + ) watch( [() => props.type, () => props.remotePoint], () => { @@ -338,6 +350,21 @@ export default defineComponent({ } } + function getHollowProcess() { + if (typeof props.hollowShape === 'function') return props.hollowShape + + switch (props.hollowShape) { + case 'puzzle': + return puzzlePath + case 'shield': + return shieldPath + case 'heart': + return heartPath + default: + return squarePath + } + } + function drawImage() { const canvasEl = canvas.value const ctx = canvasEl?.getContext?.('2d') @@ -351,43 +378,89 @@ export default defineComponent({ return } - if (!subCanvasEl || !subCtx) return + if (!subCanvasEl || !subCtx || !track.value) return + + if (!memoryCanvas) { + if (!isClient) return + + memoryCanvas = document.createElement('canvas') + } + + memoryCanvas.width = canvasEl.width + memoryCanvas.height = canvasEl.height + + const pathCtx = memoryCanvas.getContext('2d') + + if (!pathCtx) return + + ctx.clearRect(0, 0, canvasEl.width, canvasEl.height) + subCtx.clearRect(0, 0, subCanvasEl.width, subCanvasEl.height) + pathCtx.clearRect(0, 0, memoryCanvas.width, memoryCanvas.height) const canvasRect = canvasEl.getBoundingClientRect() - const subImageRect = track.value!.getBoundingClientRect() - const widthFix = ((canvasRect.width - subImageRect.width) / canvasRect.width) * canvasEl.width + const trackRect = track.value.getBoundingClientRect() + // 滑动时以轨道为准,所以需要补正 canvas 宽度和 track 宽度的差值 + const widthFix = ((canvasRect.width - trackRect.width) / canvasRect.width) * canvasEl.width const targetX = widthFix / 2 + currentTarget.value[0] * (canvasEl.width - widthFix) * 0.01 const targetY = currentTarget.value[1] * canvasEl.height * 0.01 - const sideLength = Math.min(canvasEl.width, canvasEl.height) * 0.25 - const halfSideLength = sideLength * 0.5 - subCanvasEl.width = sideLength + 4 + const hollowProcess = getHollowProcess() - ctx.drawImage(image, 0, 0, canvasEl.width, canvasEl.height) + pathCtx.beginPath() + pathCtx.strokeStyle = 'rgba(255, 255, 255, 0.5)' + pathCtx.lineWidth = 4 + + const [clipX, clipY, clipWidth, clipHeight] = hollowProcess({ + ctx: pathCtx, + x: targetX, + y: targetY, + width: props.canvasSize[0], + height: props.canvasSize[1] + }) + + pathCtx.stroke() + pathCtx.clip() + pathCtx.drawImage(image, 0, 0, canvasEl.width, canvasEl.height) + + // 中心点偏移修正 + const xLeftWidth = targetX - clipX + const translateFix = ((clipWidth * 0.5 - xLeftWidth) / clipWidth) * 100 + + subCanvasEl.style.transform = `translate3d(${translateFix - 50}%, 0, 0)` + subCanvasEl.width = clipWidth subCtx.drawImage( - canvasEl, - targetX - halfSideLength, - targetY - halfSideLength, - sideLength, - sideLength, - 2, - targetY - halfSideLength, - sideLength, - sideLength + memoryCanvas, + clipX, + clipY, + clipWidth, + clipHeight, + 0, + clipY, + clipWidth, + clipHeight ) - subCtx.lineWidth = 2 - subCtx.strokeStyle = 'rgba(255, 255, 255, 0.5)' - subCtx.strokeRect(1, targetY - halfSideLength, sideLength + 2, sideLength) + ctx.save() + ctx.beginPath() ctx.fillStyle = 'rgba(255, 255, 255, 0.75)' - ctx.fillRect( - targetX - halfSideLength - 1, - targetY - halfSideLength - 1, - sideLength + 2, - sideLength + 2 - ) + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)' + ctx.lineWidth = 10 + + hollowProcess({ + ctx, + x: targetX, + y: targetY, + width: props.canvasSize[0], + height: props.canvasSize[1] + }) + + ctx.stroke() + ctx.fill() + ctx.restore() + ctx.globalCompositeOperation = 'destination-over' + ctx.drawImage(image, 0, 0, canvasEl.width, canvasEl.height) } function drawImageNextFrame() { diff --git a/components/captcha/hollow-paths.ts b/components/captcha/hollow-paths.ts new file mode 100644 index 000000000..bc887c8d8 --- /dev/null +++ b/components/captcha/hollow-paths.ts @@ -0,0 +1,143 @@ +export interface CaptchaHollowOptions { + ctx: CanvasRenderingContext2D, + /** + * The x coordinate of slide target center + */ + x: number, + /** + * The y coordinate of slide target center + */ + y: number, + /** + * Current canvas width + */ + width: number, + /** + * Current canvas height + */ + height: number +} + +/** + * Specify the react of the hollow's shape + */ +export type CaptchaHollowResult = [x: number, y: number, width: number, height: number] +export type CaptchaHollowProcess = (options: CaptchaHollowOptions) => CaptchaHollowResult + +export type CaptchaHollowType = 'square' | 'puzzle' | 'shield' | 'heart' + +export const squarePath: CaptchaHollowProcess = ({ ctx, x, y, width, height }) => { + const side = Math.min(width, height) * 0.25 + const halfSide = side * 0.5 + + ctx.moveTo(x - halfSide, y - halfSide) + ctx.lineTo(x + halfSide, y - halfSide) + ctx.lineTo(x + halfSide, y + halfSide) + ctx.lineTo(x - halfSide, y + halfSide) + ctx.closePath() + + return [x - halfSide - 2, y - halfSide - 2, side + 4, side + 4] +} + +export const puzzlePath: CaptchaHollowProcess = ({ ctx, x, y, width, height }) => { + const side = Math.min(width, height) * 0.2 + const halfSide = side * 0.5 + const left = x - halfSide + const top = y - halfSide + const radius = side * 0.2 + + ctx.moveTo(left, top) + ctx.arc(left + halfSide, top - radius + 2, radius, 0.72 * Math.PI, 2.26 * Math.PI) + ctx.lineTo(left + side, top) + ctx.arc(left + side + radius - 2, top + halfSide, radius, 1.21 * Math.PI, 2.78 * Math.PI) + ctx.lineTo(left + side, top + side) + ctx.lineTo(left, top + side) + ctx.arc(left + radius - 2, top + halfSide, radius + 0.4, 2.76 * Math.PI, 1.24 * Math.PI, true) + ctx.lineTo(left, top) + + return [x - halfSide - 2, y - side * 0.9 - 2, side * 1.4 + 4, side * 1.4 + 4] +} + +export const shieldPath: CaptchaHollowProcess = ({ ctx, x, y, width, height }) => { + const side = Math.min(width, height) * 0.25 + const halfSide = side * 0.5 + + ctx.moveTo(x, y - halfSide) + ctx.bezierCurveTo( + x, + y - halfSide + side * 0.05, + x - halfSide + side * 0.3, + y - halfSide * 0.5 + side * 0.1, + x - halfSide, + y - halfSide * 0.7 + ) + ctx.bezierCurveTo(x - halfSide, y + side * 0.3, x - side * 0.1, y + halfSide, x, y + halfSide) + ctx.bezierCurveTo( + x + side * 0.1, + y + halfSide, + x + halfSide, + y + side * 0.3, + x + halfSide, + y - halfSide * 0.7 + ) + ctx.bezierCurveTo( + x + halfSide - side * 0.3, + y - halfSide * 0.5 + side * 0.1, + x, + y - halfSide + side * 0.05, + x, + y - halfSide + ) + + return [x - halfSide - 2, y - halfSide - 2, side + 4, side + 4] +} + +export const heartPath: CaptchaHollowProcess = ({ ctx, x, y, width, height }) => { + const side = Math.min(width, height) * 0.25 + const halfSide = side * 0.5 + + ctx.moveTo(x, y - side * 0.25) + ctx.bezierCurveTo( + x, + y - side * 0.4, + x - side * 0.1, + y - halfSide, + x - halfSide * 0.5, + y - halfSide + ) + ctx.bezierCurveTo( + x - halfSide * 0.5 - side * 0.1, + y - halfSide, + x - halfSide, + y - side * 0.4, + x - halfSide, + y - side * 0.2 + ) + ctx.bezierCurveTo( + x - halfSide, + y + side * 0.2, + x - side * 0.05, + y + halfSide * 0.8, + x, + y + halfSide * 0.8 + ) + ctx.bezierCurveTo( + x + side * 0.05, + y + halfSide * 0.8, + x + halfSide, + y + side * 0.2, + x + halfSide, + y - side * 0.2 + ) + ctx.bezierCurveTo( + x + halfSide, + y - side * 0.4, + x + halfSide * 0.5 + side * 0.1, + y - halfSide, + x + halfSide * 0.5, + y - halfSide + ) + ctx.bezierCurveTo(x + side * 0.1, y - halfSide, x, y - side * 0.4, x, y - side * 0.25) + + return [x - halfSide - 2, y - halfSide - 2, side + 4, side * 0.9 + 4] +} diff --git a/components/captcha/index.ts b/components/captcha/index.ts index f2c946757..e87309edd 100644 --- a/components/captcha/index.ts +++ b/components/captcha/index.ts @@ -1,4 +1,5 @@ export { default as Captcha } from './captcha' export type { CaptchaProps, CaptchaCProps } from './props' +export type * from './hollow-paths' export type { CaptchaType, CaptchaBeforeTest, CaptchaExposed } from './symbol' diff --git a/components/captcha/props.ts b/components/captcha/props.ts index 8bf4761f3..d8a630d95 100644 --- a/components/captcha/props.ts +++ b/components/captcha/props.ts @@ -10,6 +10,7 @@ import { import type { ExtractPropTypes, PropType } from 'vue' import type { IconEffect } from '@/components/icon' import type { ConfigurableProps, EventListener } from '@vexip-ui/config' +import type { CaptchaHollowProcess, CaptchaHollowType } from './hollow-paths' import type { CaptchaBeforeTest, CaptchaType, SuccessEvent } from './symbol' export const captchaProps = buildProps({ @@ -36,6 +37,7 @@ export const captchaProps = buildProps({ triggerText: String, transfer: booleanStringProp, hideDelay: Number, + hollowShape: [String, Function] as PropType, onSuccess: eventProp>(), onFail: eventProp(), onDragStart: eventProp<(percent: number) => void>(), diff --git a/docs/demos/captcha/hollow-shape/demo.en-US.vue b/docs/demos/captcha/hollow-shape/demo.en-US.vue new file mode 100644 index 000000000..4d558d117 --- /dev/null +++ b/docs/demos/captcha/hollow-shape/demo.en-US.vue @@ -0,0 +1,36 @@ + + + diff --git a/docs/demos/captcha/hollow-shape/demo.zh-CN.vue b/docs/demos/captcha/hollow-shape/demo.zh-CN.vue new file mode 100644 index 000000000..f15f9c27f --- /dev/null +++ b/docs/demos/captcha/hollow-shape/demo.zh-CN.vue @@ -0,0 +1,36 @@ + + + diff --git a/docs/en-US/component/captcha.md b/docs/en-US/component/captcha.md index 7a91b862a..9a71bbe22 100644 --- a/docs/en-US/component/captcha.md +++ b/docs/en-US/component/captcha.md @@ -1,4 +1,4 @@ -# Captcha ^[Since v2.3.0](!s) +# Captcha ==!s|2.3.0== In some cases, it's necessary to prevent script and robot behaviors as much as possible, and captcha will be used. @@ -32,6 +32,16 @@ By listening to the `refresh` event, we can change the image from the remote whe ::: +:::demo captcha/hollow-shape + +### Hollow Shape + +The built-in hollow shape name can be specified via the `hollow-shape` prop. + +You can also pass in a custom process method to draw the hollow shape. + +::: + :::demo captcha/point ### Pointe Type @@ -84,34 +94,48 @@ type CaptchaType = 'slide' | 'point' type CaptchaBeforeTest = | ((percent: number, matched: boolean) => unknown) | ((positions: number[]) => unknown) + +interface CaptchaHollowOptions { + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number +} + +type CaptchaHollowResult = [x: number, y: number, width: number, height: number] +type CaptchaHollowProcess = (options: CaptchaHollowOptions) => CaptchaHollowResult + +type CaptchaHollowType = 'square' | 'puzzle' | 'shield' | 'heart' ``` ### Captcha Props -| Name | Type | Description | Default | Since | -| -------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ----- | -| type | `CaptchaType` | Set the interaction type of captcha | `'slide'` | - | -| slide-target | `number \| number[]` | Set the slide target position, the second item is the vertical position when passing in the array | `null` | - | -| title | `string` | Set the title of captcha | `null` | - | -| tip | `string` | Set the tip of captcha | `null` | - | -| success-tip | `string` | Set the tip when the captcha is successful | `null` | - | -| image | `string` | Set an image for captcha | `null` | - | -| tolerance | `number` | Set the error tolerance allowed for the captcha target position | `null` | - | -| canvas-size | `number[]` | Set canvas size | `[1000, 600]` | - | -| refresh-icon | `VueComponent` | Set refresh icon | `null` | - | -| disabled | `boolean` | Set whether to disable the captcha | `false` | - | -| loading | `boolean` | Set whether the captcha is loading | `false` | - | -| loading-icon | `VueComponent` | Set loading icon | `null` | - | -| loading-effect | `string` | Set the effect animation for the loading icon | `null` | - | -| on-before-test | `CaptchaBeforeTest` | Set the callback before test, support async function and Promise, when returning a boolean value, it will be directly used as the result | `null` | - | -| texts | `string[]` | Set the letters to be pointed in sequence | `[]` | - | -| fail-limit | `number` | Set the limit count of captcha failures, which needs to be refreshed after reaching or exceeding | `0` | - | -| remote-point | `boolean` | Whether to use remote point captcha | `false` | - | -| use-trigger | `boolean` | Whether to use trigger | `false` | - | -| trigger-size | `'small' \| 'default' \| 'large'` | Set the size of the trigger | `'default'` | - | -| trigger-text | `string` | Set the content in the trigger | `null` | - | -| transfer | `boolean \| string` | Set the rendering place of panel. When set to `true`, it will render to `` by default | `false` | - | -| hide-delay | `number` | When using trigger, set the number of milliseconds to delay hiding the panel after successful captcha | `3000` | - | +| Name | Type | Description | Default | Since | +| -------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ----- | +| type | `CaptchaType` | Set the interaction type of captcha | `'slide'` | - | +| slide-target | `number \| number[]` | Set the slide target position, the second item is the vertical position when passing in the array | `null` | - | +| title | `string` | Set the title of captcha | `null` | - | +| tip | `string` | Set the tip of captcha | `null` | - | +| success-tip | `string` | Set the tip when the captcha is successful | `null` | - | +| image | `string` | Set an image for captcha | `null` | - | +| tolerance | `number` | Set the error tolerance allowed for the captcha target position | `null` | - | +| canvas-size | `number[]` | Set canvas size | `[1000, 600]` | - | +| refresh-icon | `VueComponent` | Set refresh icon | `null` | - | +| disabled | `boolean` | Set whether to disable the captcha | `false` | - | +| loading | `boolean` | Set whether the captcha is loading | `false` | - | +| loading-icon | `VueComponent` | Set loading icon | `null` | - | +| loading-effect | `string` | Set the effect animation for the loading icon | `null` | - | +| on-before-test | `CaptchaBeforeTest` | Set the callback before test, support async function and Promise, when returning a boolean value, it will be directly used as the result | `null` | - | +| texts | `string[]` | Set the letters to be pointed in sequence | `[]` | - | +| fail-limit | `number` | Set the limit count of captcha failures, which needs to be refreshed after reaching or exceeding | `0` | - | +| remote-point | `boolean` | Whether to use remote point captcha | `false` | - | +| use-trigger | `boolean` | Whether to use trigger | `false` | - | +| trigger-size | `'small' \| 'default' \| 'large'` | Set the size of the trigger | `'default'` | - | +| trigger-text | `string` | Set the content in the trigger | `null` | - | +| transfer | `boolean \| string` | Set the rendering place of panel. When set to `true`, it will render to `` by default | `false` | - | +| hide-delay | `number` | When using trigger, set the number of milliseconds to delay hiding the panel after successful captcha | `3000` | - | +| hollow-shape | `CaptchaHollowType \| CaptchaHollowProcess` | Set the shape of hollow | `'square'` | - | ### Captcha Events diff --git a/docs/zh-CN/component/captcha.md b/docs/zh-CN/component/captcha.md index f98ee67d6..8d5ed34b1 100644 --- a/docs/zh-CN/component/captcha.md +++ b/docs/zh-CN/component/captcha.md @@ -1,4 +1,4 @@ -# Captcha ^[Since v2.3.0](!s) +# Captcha ==!s|2.3.0== 在某些场合,要尽可能的防止脚本和机器人行为,此时便会用到人机验证。 @@ -30,6 +30,16 @@ ::: +:::demo captcha/hollow-shape + +### 镂空形状 + +通过 `hollow-shape` 属性可以指定内置的镂空形状名称。 + +你也可以传入一个自定义的处理方法来画镂空的形状。 + +::: + :::demo captcha/point ### 点击验证 @@ -82,34 +92,48 @@ type CaptchaType = 'slide' | 'point' type CaptchaBeforeTest = | ((percent: number, matched: boolean) => unknown) | ((positions: number[]) => unknown) + +interface CaptchaHollowOptions { + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number +} + +type CaptchaHollowResult = [x: number, y: number, width: number, height: number] +type CaptchaHollowProcess = (options: CaptchaHollowOptions) => CaptchaHollowResult + +type CaptchaHollowType = 'square' | 'puzzle' | 'shield' | 'heart' ``` ### Captcha 属性 -| 名称 | 类型 | 说明 | 默认值 | 始于 | -| -------------- | --------------------------------- | ------------------------------------------------------------------------ | ------------- | ---- | -| type | `CaptchaType` | 设置验证的交互类型 | `'slide'` | - | -| slide-target | `number \| number[]` | 设置滑动目标位置,传入数组时第二位为纵向位置 | `null` | - | -| title | `string` | 设置验证的标题 | `null` | - | -| tip | `string` | 设置验证的提示语 | `null` | - | -| success-tip | `string` | 设置验证成功时的提示语 | `null` | - | -| image | `string` | 设置验证用的图片 | `null` | - | -| tolerance | `number` | 设置验证目标位置允许的误差范围 | `1` | - | -| canvas-size | `number[]` | 设置画布大小 | `[1000, 600]` | - | -| refresh-icon | `VueComponent` | 设置验证的刷新图标 | `null` | - | -| disabled | `boolean` | 设置是否禁用验证 | `false` | - | -| loading | `boolean` | 设置是否为加载中 | `false` | - | -| loading-icon | `VueComponent` | 设置加载中的图标 | `null` | - | -| loading-effect | `string` | 设置加载中图标的效果动画 | `null` | - | -| on-before-test | `CaptchaBeforeTest` | 设置验证前的回调,支持异步函数和 Promise,返回布尔值时将直接作为验证结果 | `null` | - | -| texts | `string[]` | 设置要依次点击的单字 | `[]` | - | -| fail-limit | `number` | 设置验证失败的限制次数,达到或超出后需刷新 | `0` | - | -| remote-point | `boolean` | 是否使用远程点击验证 | `false` | - | -| use-trigger | `boolean` | 是否使用触发器 | `false` | - | -| trigger-size | `'small' \| 'default' \| 'large'` | 设置触发器的大小 | `'default'` | - | -| trigger-text | `string` | 设置触发器中的提示语 | `null` | - | -| transfer | `boolean \| string` | 设置验证面板的渲染位置,设置为 `true` 时默认渲染至 `` | `false` | - | -| hide-delay | `number` | 使用触发器时,设置验证成功后隐藏面板的延迟毫秒数 | `3000` | - | +| 名称 | 类型 | 说明 | 默认值 | 始于 | +| -------------- | ------------------------------------------- | ------------------------------------------------------------------------ | ------------- | ---- | +| type | `CaptchaType` | 设置验证的交互类型 | `'slide'` | - | +| slide-target | `number \| number[]` | 设置滑动目标位置,传入数组时第二位为纵向位置 | `null` | - | +| title | `string` | 设置验证的标题 | `null` | - | +| tip | `string` | 设置验证的提示语 | `null` | - | +| success-tip | `string` | 设置验证成功时的提示语 | `null` | - | +| image | `string` | 设置验证用的图片 | `null` | - | +| tolerance | `number` | 设置验证目标位置允许的误差范围 | `1` | - | +| canvas-size | `number[]` | 设置画布大小 | `[1000, 600]` | - | +| refresh-icon | `VueComponent` | 设置验证的刷新图标 | `null` | - | +| disabled | `boolean` | 设置是否禁用验证 | `false` | - | +| loading | `boolean` | 设置是否为加载中 | `false` | - | +| loading-icon | `VueComponent` | 设置加载中的图标 | `null` | - | +| loading-effect | `string` | 设置加载中图标的效果动画 | `null` | - | +| on-before-test | `CaptchaBeforeTest` | 设置验证前的回调,支持异步函数和 Promise,返回布尔值时将直接作为验证结果 | `null` | - | +| texts | `string[]` | 设置要依次点击的单字 | `[]` | - | +| fail-limit | `number` | 设置验证失败的限制次数,达到或超出后需刷新 | `0` | - | +| remote-point | `boolean` | 是否使用远程点击验证 | `false` | - | +| use-trigger | `boolean` | 是否使用触发器 | `false` | - | +| trigger-size | `'small' \| 'default' \| 'large'` | 设置触发器的大小 | `'default'` | - | +| trigger-text | `string` | 设置触发器中的提示语 | `null` | - | +| transfer | `boolean \| string` | 设置验证面板的渲染位置,设置为 `true` 时默认渲染至 `` | `false` | - | +| hide-delay | `number` | 使用触发器时,设置验证成功后隐藏面板的延迟毫秒数 | `3000` | - | +| hollow-shape | `CaptchaHollowType \| CaptchaHollowProcess` | 设置镂空的形状 | `'square'` | - | ### Captcha 事件