Browse Source
* 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
committed by
GitHub
10 changed files with 314 additions and 0 deletions
@ -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> |
||||
|
After Width: | Height: | Size: 980 B |
File diff suppressed because one or more lines are too long
@ -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> |
||||
Loading…
Reference in new issue