Skip to content

Commit b163640

Browse files
feat: captcha example (vbenjs#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 <[email protected]>
1 parent ad89ea7 commit b163640

File tree

10 files changed

+314
-0
lines changed

10 files changed

+314
-0
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export { default as PointSelectionCaptcha } from './point-selection-captcha.vue';
2+
export interface Point {
3+
i: number;
4+
x: number;
5+
y: number;
6+
t: number;
7+
}
8+
export type ClearFunction = () => void;
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
<script setup lang="ts">
2+
import { computed, ref } from 'vue';
3+
4+
import { VbenButton } from '@vben/common-ui';
5+
import { SvgRefreshIcon } from '@vben/icons';
6+
import {
7+
Card,
8+
CardContent,
9+
CardFooter,
10+
CardHeader,
11+
CardTitle,
12+
VbenIconButton,
13+
} from '@vben-core/shadcn-ui';
14+
15+
import { type Point } from '.';
16+
17+
interface Props {
18+
/**
19+
* 点选的图片
20+
* @default '12px'
21+
*/
22+
captchaImage: string;
23+
/**
24+
* 验证码图片高度
25+
* @default '220px'
26+
*/
27+
height?: number | string;
28+
/**
29+
* 提示图片高度
30+
* @default '40px'
31+
*/
32+
hintHeight?: number | string;
33+
/**
34+
* 提示图片宽度
35+
* @default '150px'
36+
*/
37+
hintWidth?: number | string;
38+
/**
39+
* 提示图片
40+
* @default '12px'
41+
*/
42+
hintImage: string;
43+
/**
44+
* 水平内边距
45+
* @default '12px'
46+
*/
47+
paddingX?: number | string;
48+
/**
49+
* 垂直内边距
50+
* @default '16px'
51+
*/
52+
paddingY?: number | string;
53+
/**
54+
* 标题
55+
* @default '请按图依次点击'
56+
*/
57+
title?: string;
58+
/**
59+
* 验证码图片宽度
60+
* @default '300px'
61+
*/
62+
width?: number | string;
63+
}
64+
65+
const props = withDefaults(defineProps<Props>(), {
66+
height: '220px',
67+
hintHeight: '40px',
68+
hintWidth: '150px',
69+
paddingX: '12px',
70+
paddingY: '16px',
71+
title: '请按图依次点击',
72+
width: '300px',
73+
});
74+
75+
const emit = defineEmits<{
76+
click: [number, number];
77+
confirm: [Array<Point>, clear: () => void];
78+
refresh: [];
79+
}>();
80+
81+
const parseValue = (value: number | string) => {
82+
if (typeof value === 'number') {
83+
return value;
84+
}
85+
const parsed = Number.parseFloat(value);
86+
return Number.isNaN(parsed) ? 0 : parsed;
87+
};
88+
89+
const rootStyles = computed(() => ({
90+
padding: `${parseValue(props.paddingY)}px ${parseValue(props.paddingX)}px`,
91+
width: `${parseValue(props.width) - parseValue(props.paddingX) * 2}px`,
92+
}));
93+
94+
const hintStyles = computed(() => ({
95+
height: `${parseValue(props.hintHeight)}px`,
96+
width: `${parseValue(props.hintWidth)}px`,
97+
}));
98+
99+
const captchaStyles = computed(() => {
100+
return {
101+
height: `${parseValue(props.height)}px`,
102+
width: `${parseValue(props.width)}px`,
103+
};
104+
});
105+
106+
function getElementPosition(element: HTMLElement) {
107+
let posX = 0;
108+
let posY = 0;
109+
if (element.getBoundingClientRect) {
110+
const rect = element.getBoundingClientRect();
111+
const doc = document.documentElement;
112+
posX =
113+
rect.left +
114+
Math.max(doc.scrollLeft, document.body.scrollLeft) -
115+
doc.clientLeft;
116+
posY =
117+
rect.top +
118+
Math.max(doc.scrollTop, document.body.scrollTop) -
119+
doc.clientTop;
120+
} else {
121+
while (element !== document.body) {
122+
posX += element.offsetLeft;
123+
posY += element.offsetTop;
124+
element = element.offsetParent as HTMLElement;
125+
}
126+
}
127+
return {
128+
x: posX,
129+
y: posY,
130+
};
131+
}
132+
const points = ref<Point[]>([]);
133+
const POINT_OFFSET = 11;
134+
135+
function handleClick(e: any | Event) {
136+
try {
137+
const dom = e.currentTarget as HTMLElement;
138+
if (!dom) throw new Error('Element not found');
139+
140+
const { x: domX, y: domY } = getElementPosition(dom);
141+
142+
const mouseX = e.pageX || e.clientX;
143+
const mouseY = e.pageY || e.clientY;
144+
145+
if (mouseX === undefined || mouseY === undefined)
146+
throw new Error('Mouse coordinates not found');
147+
148+
const xPos = mouseX - domX;
149+
const yPos = mouseY - domY;
150+
151+
const x = Math.ceil(xPos);
152+
const y = Math.ceil(yPos);
153+
154+
points.value.push({
155+
i: points.value.length,
156+
t: Date.now(),
157+
x,
158+
y,
159+
});
160+
161+
emit('click', x, y);
162+
e.cancelBubble = true;
163+
e.preventDefault();
164+
} catch (error) {
165+
console.error('Error in handleClick:', error);
166+
}
167+
}
168+
169+
function clear() {
170+
try {
171+
points.value = [];
172+
} catch (error) {
173+
console.error('Error in clear:', error);
174+
}
175+
}
176+
177+
function handleRefresh() {
178+
try {
179+
clear();
180+
emit('refresh');
181+
} catch (error) {
182+
console.error('Error in handleRefresh:', error);
183+
}
184+
}
185+
186+
function handleConfirm() {
187+
try {
188+
emit('confirm', points.value, clear);
189+
} catch (error) {
190+
console.error('Error in handleConfirm:', error);
191+
}
192+
}
193+
</script>
194+
<template>
195+
<Card :style="rootStyles" aria-labelledby="captcha-title" role="region">
196+
<CardHeader class="p-0">
197+
<CardTitle id="captcha-title" class="flex items-center justify-between">
198+
<span>{{ title }}</span>
199+
<img
200+
v-show="hintImage"
201+
:src="hintImage"
202+
:style="hintStyles"
203+
alt="提示图片"
204+
/>
205+
</CardTitle>
206+
</CardHeader>
207+
<CardContent class="relative mt-2 flex w-full overflow-hidden rounded p-0">
208+
<img
209+
v-show="captchaImage"
210+
:src="captchaImage"
211+
:style="captchaStyles"
212+
alt="验证码图片"
213+
class="relative z-10"
214+
@click="handleClick"
215+
/>
216+
<div class="absolute inset-0">
217+
<div
218+
v-for="(point, index) in points"
219+
:key="index"
220+
:style="{
221+
top: `${point.y - POINT_OFFSET}px`,
222+
left: `${point.x - POINT_OFFSET}px`,
223+
}"
224+
aria-label="点击点 {{ index + 1 }}"
225+
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"
226+
role="button"
227+
>
228+
{{ index + 1 }}
229+
</div>
230+
</div>
231+
</CardContent>
232+
<CardFooter class="mt-2 flex justify-between p-0">
233+
<VbenIconButton aria-label="刷新验证码" @click="handleRefresh">
234+
<SvgRefreshIcon class="size-6" />
235+
</VbenIconButton>
236+
<VbenButton aria-label="确认选择" @click="handleConfirm">
237+
确认
238+
</VbenButton>
239+
</CardFooter>
240+
</Card>
241+
</template>

packages/effects/common-ui/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './captcha';
12
export * from './ellipsis-text';
23
export * from './page';
34
export * from '@vben-core/popup-ui';
Lines changed: 1 addition & 0 deletions
Loading

packages/icons/src/svg/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const SvgDownloadIcon = createIconifyIcon('svg:download');
1010
const SvgCardIcon = createIconifyIcon('svg:card');
1111
const SvgBellIcon = createIconifyIcon('svg:bell');
1212
const SvgCakeIcon = createIconifyIcon('svg:cake');
13+
const SvgRefreshIcon = createIconifyIcon('svg:refresh');
1314

1415
export {
1516
SvgAvatar1Icon,
@@ -20,4 +21,5 @@ export {
2021
SvgCakeIcon,
2122
SvgCardIcon,
2223
SvgDownloadIcon,
24+
SvgRefreshIcon,
2325
};

playground/src/locales/langs/en-US.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
},
7272
"ellipsis": {
7373
"title": "EllipsisText"
74+
},
75+
"captcha": {
76+
"title": "Captcha"
7477
}
7578
}
7679
}

playground/src/locales/langs/zh-CN.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
},
7272
"ellipsis": {
7373
"title": "文本省略"
74+
},
75+
"captcha": {
76+
"title": "验证码"
7477
}
7578
}
7679
}

playground/src/router/routes/modules/examples.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ const routes: RouteRecordRaw[] = [
3939
title: $t('page.examples.ellipsis.title'),
4040
},
4141
},
42+
{
43+
name: 'CaptchaExample',
44+
path: '/examples/captcha',
45+
component: () => import('#/views/examples/captcha/index.vue'),
46+
meta: {
47+
title: $t('page.examples.captcha.title'),
48+
},
49+
},
4250
],
4351
},
4452
];

playground/src/views/examples/captcha/base64.ts

Lines changed: 4 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script lang="ts" setup>
2+
import { ref } from 'vue';
3+
4+
import { Page, type Point, PointSelectionCaptcha } from '@vben/common-ui';
5+
6+
import { Card } from 'ant-design-vue';
7+
8+
import { captchaImage, hintImage } from './base64';
9+
10+
const selectedPoints = ref<Point[]>([]);
11+
const handleConfirm = (points: Point[], clear: () => void) => {
12+
selectedPoints.value = points;
13+
clear();
14+
};
15+
const handleRefresh = () => {
16+
selectedPoints.value = [];
17+
};
18+
</script>
19+
20+
<template>
21+
<Page
22+
description="通过点击图片中的特定位置来验证用户身份。"
23+
title="验证码组件示例"
24+
>
25+
<Card class="mb-4" title="基本使用">
26+
<PointSelectionCaptcha
27+
:captcha-image="captchaImage"
28+
:hint-image="hintImage"
29+
class="float-left"
30+
@confirm="handleConfirm"
31+
@refresh="handleRefresh"
32+
/>
33+
<div class="float-left p-5">
34+
<div v-for="point in selectedPoints" :key="point.i" class="flex">
35+
<span class="mr-3 w-16">索引:{{ point.i }}</span>
36+
<span class="mr-3 w-44">时间戳:{{ point.t }}</span>
37+
<span class="mr-3 w-16">x:{{ point.x }}</span>
38+
<span class="mr-3 w-16">y:{{ point.y }}</span>
39+
</div>
40+
</div>
41+
</Card>
42+
</Page>
43+
</template>

0 commit comments

Comments
 (0)