Browse Source
feat: captcha example (#4330)
feat: captcha example (#4330)
* feat: captcha example * fix: fix lint errors * chore: event handling and methods * chore: add accessibility features ARIA labels and roles --------- Co-authored-by: vince <vince292007@gmail.com>pull/4338/head
Squall2017
2 weeks ago
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 314 additions and 0 deletions
-
8packages/effects/common-ui/src/components/captcha/index.ts
-
241packages/effects/common-ui/src/components/captcha/point-selection-captcha.vue
-
1packages/effects/common-ui/src/components/index.ts
-
1packages/icons/src/svg/icons/refresh.svg
-
2packages/icons/src/svg/index.ts
-
3playground/src/locales/langs/en-US.json
-
3playground/src/locales/langs/zh-CN.json
-
8playground/src/router/routes/modules/examples.ts
-
4playground/src/views/examples/captcha/base64.ts
-
43playground/src/views/examples/captcha/index.vue
@ -0,0 +1,8 @@ |
|||
export { default as PointSelectionCaptcha } from './point-selection-captcha.vue'; |
|||
export interface Point { |
|||
i: number; |
|||
x: number; |
|||
y: number; |
|||
t: number; |
|||
} |
|||
export type ClearFunction = () => void; |
@ -0,0 +1,241 @@ |
|||
<script setup lang="ts"> |
|||
import { computed, ref } from 'vue'; |
|||
|
|||
import { VbenButton } from '@vben/common-ui'; |
|||
import { SvgRefreshIcon } from '@vben/icons'; |
|||
import { |
|||
Card, |
|||
CardContent, |
|||
CardFooter, |
|||
CardHeader, |
|||
CardTitle, |
|||
VbenIconButton, |
|||
} from '@vben-core/shadcn-ui'; |
|||
|
|||
import { type Point } from '.'; |
|||
|
|||
interface Props { |
|||
/** |
|||
* 点选的图片 |
|||
* @default '12px' |
|||
*/ |
|||
captchaImage: string; |
|||
/** |
|||
* 验证码图片高度 |
|||
* @default '220px' |
|||
*/ |
|||
height?: number | string; |
|||
/** |
|||
* 提示图片高度 |
|||
* @default '40px' |
|||
*/ |
|||
hintHeight?: number | string; |
|||
/** |
|||
* 提示图片宽度 |
|||
* @default '150px' |
|||
*/ |
|||
hintWidth?: number | string; |
|||
/** |
|||
* 提示图片 |
|||
* @default '12px' |
|||
*/ |
|||
hintImage: string; |
|||
/** |
|||
* 水平内边距 |
|||
* @default '12px' |
|||
*/ |
|||
paddingX?: number | string; |
|||
/** |
|||
* 垂直内边距 |
|||
* @default '16px' |
|||
*/ |
|||
paddingY?: number | string; |
|||
/** |
|||
* 标题 |
|||
* @default '请按图依次点击' |
|||
*/ |
|||
title?: string; |
|||
/** |
|||
* 验证码图片宽度 |
|||
* @default '300px' |
|||
*/ |
|||
width?: number | string; |
|||
} |
|||
|
|||
const props = withDefaults(defineProps<Props>(), { |
|||
height: '220px', |
|||
hintHeight: '40px', |
|||
hintWidth: '150px', |
|||
paddingX: '12px', |
|||
paddingY: '16px', |
|||
title: '请按图依次点击', |
|||
width: '300px', |
|||
}); |
|||
|
|||
const emit = defineEmits<{ |
|||
click: [number, number]; |
|||
confirm: [Array<Point>, clear: () => void]; |
|||
refresh: []; |
|||
}>(); |
|||
|
|||
const parseValue = (value: number | string) => { |
|||
if (typeof value === 'number') { |
|||
return value; |
|||
} |
|||
const parsed = Number.parseFloat(value); |
|||
return Number.isNaN(parsed) ? 0 : parsed; |
|||
}; |
|||
|
|||
const rootStyles = computed(() => ({ |
|||
padding: `${parseValue(props.paddingY)}px ${parseValue(props.paddingX)}px`, |
|||
width: `${parseValue(props.width) - parseValue(props.paddingX) * 2}px`, |
|||
})); |
|||
|
|||
const hintStyles = computed(() => ({ |
|||
height: `${parseValue(props.hintHeight)}px`, |
|||
width: `${parseValue(props.hintWidth)}px`, |
|||
})); |
|||
|
|||
const captchaStyles = computed(() => { |
|||
return { |
|||
height: `${parseValue(props.height)}px`, |
|||
width: `${parseValue(props.width)}px`, |
|||
}; |
|||
}); |
|||
|
|||
function getElementPosition(element: HTMLElement) { |
|||
let posX = 0; |
|||
let posY = 0; |
|||
if (element.getBoundingClientRect) { |
|||
const rect = element.getBoundingClientRect(); |
|||
const doc = document.documentElement; |
|||
posX = |
|||
rect.left + |
|||
Math.max(doc.scrollLeft, document.body.scrollLeft) - |
|||
doc.clientLeft; |
|||
posY = |
|||
rect.top + |
|||
Math.max(doc.scrollTop, document.body.scrollTop) - |
|||
doc.clientTop; |
|||
} else { |
|||
while (element !== document.body) { |
|||
posX += element.offsetLeft; |
|||
posY += element.offsetTop; |
|||
element = element.offsetParent as HTMLElement; |
|||
} |
|||
} |
|||
return { |
|||
x: posX, |
|||
y: posY, |
|||
}; |
|||
} |
|||
const points = ref<Point[]>([]); |
|||
const POINT_OFFSET = 11; |
|||
|
|||
function handleClick(e: any | Event) { |
|||
try { |
|||
const dom = e.currentTarget as HTMLElement; |
|||
if (!dom) throw new Error('Element not found'); |
|||
|
|||
const { x: domX, y: domY } = getElementPosition(dom); |
|||
|
|||
const mouseX = e.pageX || e.clientX; |
|||
const mouseY = e.pageY || e.clientY; |
|||
|
|||
if (mouseX === undefined || mouseY === undefined) |
|||
throw new Error('Mouse coordinates not found'); |
|||
|
|||
const xPos = mouseX - domX; |
|||
const yPos = mouseY - domY; |
|||
|
|||
const x = Math.ceil(xPos); |
|||
const y = Math.ceil(yPos); |
|||
|
|||
points.value.push({ |
|||
i: points.value.length, |
|||
t: Date.now(), |
|||
x, |
|||
y, |
|||
}); |
|||
|
|||
emit('click', x, y); |
|||
e.cancelBubble = true; |
|||
e.preventDefault(); |
|||
} catch (error) { |
|||
console.error('Error in handleClick:', error); |
|||
} |
|||
} |
|||
|
|||
function clear() { |
|||
try { |
|||
points.value = []; |
|||
} catch (error) { |
|||
console.error('Error in clear:', error); |
|||
} |
|||
} |
|||
|
|||
function handleRefresh() { |
|||
try { |
|||
clear(); |
|||
emit('refresh'); |
|||
} catch (error) { |
|||
console.error('Error in handleRefresh:', error); |
|||
} |
|||
} |
|||
|
|||
function handleConfirm() { |
|||
try { |
|||
emit('confirm', points.value, clear); |
|||
} catch (error) { |
|||
console.error('Error in handleConfirm:', error); |
|||
} |
|||
} |
|||
</script> |
|||
<template> |
|||
<Card :style="rootStyles" aria-labelledby="captcha-title" role="region"> |
|||
<CardHeader class="p-0"> |
|||
<CardTitle id="captcha-title" class="flex items-center justify-between"> |
|||
<span>{{ title }}</span> |
|||
<img |
|||
v-show="hintImage" |
|||
:src="hintImage" |
|||
:style="hintStyles" |
|||
alt="提示图片" |
|||
/> |
|||
</CardTitle> |
|||
</CardHeader> |
|||
<CardContent class="relative mt-2 flex w-full overflow-hidden rounded p-0"> |
|||
<img |
|||
v-show="captchaImage" |
|||
:src="captchaImage" |
|||
:style="captchaStyles" |
|||
alt="验证码图片" |
|||
class="relative z-10" |
|||
@click="handleClick" |
|||
/> |
|||
<div class="absolute inset-0"> |
|||
<div |
|||
v-for="(point, index) in points" |
|||
:key="index" |
|||
:style="{ |
|||
top: `${point.y - POINT_OFFSET}px`, |
|||
left: `${point.x - POINT_OFFSET}px`, |
|||
}" |
|||
aria-label="点击点 {{ index + 1 }}" |
|||
class="bg-primary text-primary-50 border-primary-50 absolute z-20 flex h-5 w-5 cursor-default items-center justify-center rounded-full border-2" |
|||
role="button" |
|||
> |
|||
{{ index + 1 }} |
|||
</div> |
|||
</div> |
|||
</CardContent> |
|||
<CardFooter class="mt-2 flex justify-between p-0"> |
|||
<VbenIconButton aria-label="刷新验证码" @click="handleRefresh"> |
|||
<SvgRefreshIcon class="size-6" /> |
|||
</VbenIconButton> |
|||
<VbenButton aria-label="确认选择" @click="handleConfirm"> |
|||
确认 |
|||
</VbenButton> |
|||
</CardFooter> |
|||
</Card> |
|||
</template> |
@ -0,0 +1 @@ |
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M3.68 11.333h-.75zm0 1.667l-.528.532a.75.75 0 0 0 1.056 0zm2.208-1.134A.75.75 0 1 0 4.83 10.8zM2.528 10.8a.75.75 0 0 0-1.056 1.065zm16.088-3.408a.75.75 0 1 0 1.277-.786zM12.079 2.25c-5.047 0-9.15 4.061-9.15 9.083h1.5c0-4.182 3.42-7.583 7.65-7.583zm-9.15 9.083V13h1.5v-1.667zm1.28 2.2l1.679-1.667L4.83 10.8l-1.68 1.667zm0-1.065L2.528 10.8l-1.057 1.065l1.68 1.666zm15.684-5.86A9.16 9.16 0 0 0 12.08 2.25v1.5a7.66 7.66 0 0 1 6.537 3.643zM20.314 11l.527-.533a.75.75 0 0 0-1.054 0zM18.1 12.133a.75.75 0 0 0 1.055 1.067zm3.373 1.067a.75.75 0 1 0 1.054-1.067zM5.318 16.606a.75.75 0 1 0-1.277.788zm6.565 5.144c5.062 0 9.18-4.058 9.18-9.083h-1.5c0 4.18-3.43 7.583-7.68 7.583zm9.18-9.083V11h-1.5v1.667zm-1.276-2.2L18.1 12.133l1.055 1.067l1.686-1.667zm0 1.066l1.686 1.667l1.054-1.067l-1.686-1.666zM4.04 17.393a9.2 9.2 0 0 0 7.842 4.357v-1.5a7.7 7.7 0 0 1-6.565-3.644z"/></svg> |
4
playground/src/views/examples/captcha/base64.ts
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,43 @@ |
|||
<script lang="ts" setup> |
|||
import { ref } from 'vue'; |
|||
|
|||
import { Page, type Point, PointSelectionCaptcha } from '@vben/common-ui'; |
|||
|
|||
import { Card } from 'ant-design-vue'; |
|||
|
|||
import { captchaImage, hintImage } from './base64'; |
|||
|
|||
const selectedPoints = ref<Point[]>([]); |
|||
const handleConfirm = (points: Point[], clear: () => void) => { |
|||
selectedPoints.value = points; |
|||
clear(); |
|||
}; |
|||
const handleRefresh = () => { |
|||
selectedPoints.value = []; |
|||
}; |
|||
</script> |
|||
|
|||
<template> |
|||
<Page |
|||
description="通过点击图片中的特定位置来验证用户身份。" |
|||
title="验证码组件示例" |
|||
> |
|||
<Card class="mb-4" title="基本使用"> |
|||
<PointSelectionCaptcha |
|||
:captcha-image="captchaImage" |
|||
:hint-image="hintImage" |
|||
class="float-left" |
|||
@confirm="handleConfirm" |
|||
@refresh="handleRefresh" |
|||
/> |
|||
<div class="float-left p-5"> |
|||
<div v-for="point in selectedPoints" :key="point.i" class="flex"> |
|||
<span class="mr-3 w-16">索引:{{ point.i }}</span> |
|||
<span class="mr-3 w-44">时间戳:{{ point.t }}</span> |
|||
<span class="mr-3 w-16">x:{{ point.x }}</span> |
|||
<span class="mr-3 w-16">y:{{ point.y }}</span> |
|||
</div> |
|||
</div> |
|||
</Card> |
|||
</Page> |
|||
</template> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue