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/48/MERGE
parent
ad89ea7a75
commit
b1636405fc
|
@ -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>
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from './captcha';
|
||||||
export * from './ellipsis-text';
|
export * from './ellipsis-text';
|
||||||
export * from './page';
|
export * from './page';
|
||||||
export * from '@vben-core/popup-ui';
|
export * from '@vben-core/popup-ui';
|
||||||
|
|
|
@ -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>
|
After Width: | Height: | Size: 980 B |
|
@ -10,6 +10,7 @@ const SvgDownloadIcon = createIconifyIcon('svg:download');
|
||||||
const SvgCardIcon = createIconifyIcon('svg:card');
|
const SvgCardIcon = createIconifyIcon('svg:card');
|
||||||
const SvgBellIcon = createIconifyIcon('svg:bell');
|
const SvgBellIcon = createIconifyIcon('svg:bell');
|
||||||
const SvgCakeIcon = createIconifyIcon('svg:cake');
|
const SvgCakeIcon = createIconifyIcon('svg:cake');
|
||||||
|
const SvgRefreshIcon = createIconifyIcon('svg:refresh');
|
||||||
|
|
||||||
export {
|
export {
|
||||||
SvgAvatar1Icon,
|
SvgAvatar1Icon,
|
||||||
|
@ -20,4 +21,5 @@ export {
|
||||||
SvgCakeIcon,
|
SvgCakeIcon,
|
||||||
SvgCardIcon,
|
SvgCardIcon,
|
||||||
SvgDownloadIcon,
|
SvgDownloadIcon,
|
||||||
|
SvgRefreshIcon,
|
||||||
};
|
};
|
||||||
|
|
|
@ -71,6 +71,9 @@
|
||||||
},
|
},
|
||||||
"ellipsis": {
|
"ellipsis": {
|
||||||
"title": "EllipsisText"
|
"title": "EllipsisText"
|
||||||
|
},
|
||||||
|
"captcha": {
|
||||||
|
"title": "Captcha"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,6 +71,9 @@
|
||||||
},
|
},
|
||||||
"ellipsis": {
|
"ellipsis": {
|
||||||
"title": "文本省略"
|
"title": "文本省略"
|
||||||
|
},
|
||||||
|
"captcha": {
|
||||||
|
"title": "验证码"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,6 +39,14 @@ const routes: RouteRecordRaw[] = [
|
||||||
title: $t('page.examples.ellipsis.title'),
|
title: $t('page.examples.ellipsis.title'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'CaptchaExample',
|
||||||
|
path: '/examples/captcha',
|
||||||
|
component: () => import('#/views/examples/captcha/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: $t('page.examples.captcha.title'),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
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