Skip to content

Commit

Permalink
feat: captcha example (#4330)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
Squall2017 and vince292007 committed Sep 7, 2024
1 parent ad89ea7 commit b163640
Show file tree
Hide file tree
Showing 10 changed files with 314 additions and 0 deletions.
8 changes: 8 additions & 0 deletions packages/effects/common-ui/src/components/captcha/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/effects/common-ui/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './captcha';
export * from './ellipsis-text';
export * from './page';
export * from '@vben-core/popup-ui';
Expand Down
1 change: 1 addition & 0 deletions packages/icons/src/svg/icons/refresh.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/icons/src/svg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const SvgDownloadIcon = createIconifyIcon('svg:download');
const SvgCardIcon = createIconifyIcon('svg:card');
const SvgBellIcon = createIconifyIcon('svg:bell');
const SvgCakeIcon = createIconifyIcon('svg:cake');
const SvgRefreshIcon = createIconifyIcon('svg:refresh');

export {
SvgAvatar1Icon,
Expand All @@ -20,4 +21,5 @@ export {
SvgCakeIcon,
SvgCardIcon,
SvgDownloadIcon,
SvgRefreshIcon,
};
3 changes: 3 additions & 0 deletions playground/src/locales/langs/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@
},
"ellipsis": {
"title": "EllipsisText"
},
"captcha": {
"title": "Captcha"
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions playground/src/locales/langs/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@
},
"ellipsis": {
"title": "文本省略"
},
"captcha": {
"title": "验证码"
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions playground/src/router/routes/modules/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ const routes: RouteRecordRaw[] = [
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'),
},
},
],
},
];
Expand Down
4 changes: 4 additions & 0 deletions playground/src/views/examples/captcha/base64.ts

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions playground/src/views/examples/captcha/index.vue
Original file line number Diff line number Diff line change
@@ -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>

0 comments on commit b163640

Please sign in to comment.