refactor: refactor code structure and improve demo page (#4389)
* feat: captcha example * fix: fix lint errors * chore: event handling and methods * chore: add accessibility features ARIA labels and roles * refactor: refactor code structure and improve captcha demo page * feat: add captcha internationalization * chore: 适配时间戳国际化展示 --------- Co-authored-by: vince <vince292007@gmail.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import type { CaptchaCardProps } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
import { parseValue } from './utils';
|
||||
|
||||
const props = withDefaults(defineProps<CaptchaCardProps>(), {
|
||||
height: '220px',
|
||||
paddingX: '12px',
|
||||
paddingY: '16px',
|
||||
title: '',
|
||||
width: '300px',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [MouseEvent];
|
||||
}>();
|
||||
|
||||
const rootStyles = computed(() => ({
|
||||
padding: `${parseValue(props.paddingY)}px ${parseValue(props.paddingX)}px`,
|
||||
width: `${parseValue(props.width) + parseValue(props.paddingX) * 2}px`,
|
||||
}));
|
||||
|
||||
const captchaStyles = computed(() => {
|
||||
return {
|
||||
height: `${parseValue(props.height)}px`,
|
||||
width: `${parseValue(props.width)}px`,
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
emit('click', e);
|
||||
}
|
||||
</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">
|
||||
<template v-if="$slots.title">
|
||||
<slot name="title">{{ $t('captcha.title') }}</slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ title }}</span>
|
||||
</template>
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="relative mt-2 flex w-full overflow-hidden rounded p-0">
|
||||
<img
|
||||
v-show="captchaImage"
|
||||
:alt="$t('captcha.alt')"
|
||||
:src="captchaImage"
|
||||
:style="captchaStyles"
|
||||
class="relative z-10"
|
||||
@click="handleClick"
|
||||
/>
|
||||
<div class="absolute inset-0">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="mt-2 flex justify-between p-0">
|
||||
<slot name="footer"></slot>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
@@ -1,2 +1,3 @@
|
||||
export { default as CaptchaCard } from './captcha-card.vue';
|
||||
export { default as PointSelectionCaptcha } from './point-selection-captcha.vue';
|
||||
export type * from './types';
|
||||
|
@@ -1,107 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { CaptchaPoint } from './types';
|
||||
import type { CaptchaPoint, PointSelectionCaptchaProps } from './types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { RotateCw } from '@vben/icons';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
VbenButton,
|
||||
VbenIconButton,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
import { VbenButton, VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
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;
|
||||
}
|
||||
import { CaptchaCard } from '.';
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<PointSelectionCaptchaProps>(), {
|
||||
height: '220px',
|
||||
hintHeight: '40px',
|
||||
hintWidth: '150px',
|
||||
hintImage: '',
|
||||
hintText: '',
|
||||
paddingX: '12px',
|
||||
paddingY: '16px',
|
||||
title: '请按图依次点击',
|
||||
showConfirm: false,
|
||||
title: '',
|
||||
width: '300px',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [number, number];
|
||||
click: [CaptchaPoint];
|
||||
confirm: [Array<CaptchaPoint>, 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;
|
||||
};
|
||||
if (!props.hintImage && !props.hintText) {
|
||||
throw new Error('At least one of hint image or hint text must be provided');
|
||||
}
|
||||
|
||||
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`,
|
||||
};
|
||||
});
|
||||
const points = ref<CaptchaPoint[]>([]);
|
||||
const POINT_OFFSET = 11;
|
||||
|
||||
function getElementPosition(element: HTMLElement) {
|
||||
let posX = 0;
|
||||
@@ -129,8 +59,6 @@ function getElementPosition(element: HTMLElement) {
|
||||
y: posY,
|
||||
};
|
||||
}
|
||||
const points = ref<CaptchaPoint[]>([]);
|
||||
const POINT_OFFSET = 11;
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
try {
|
||||
@@ -151,15 +79,16 @@ function handleClick(e: MouseEvent) {
|
||||
const x = Math.ceil(xPos);
|
||||
const y = Math.ceil(yPos);
|
||||
|
||||
points.value.push({
|
||||
const point = {
|
||||
i: points.value.length,
|
||||
t: Date.now(),
|
||||
x,
|
||||
y,
|
||||
});
|
||||
};
|
||||
points.value.push(point);
|
||||
|
||||
emit('click', x, y);
|
||||
e.cancelBubble = true;
|
||||
emit('click', point);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
} catch (error) {
|
||||
console.error('Error in handleClick:', error);
|
||||
@@ -184,6 +113,7 @@ function handleRefresh() {
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!props.showConfirm) return;
|
||||
try {
|
||||
emit('confirm', points.value, clear);
|
||||
} catch (error) {
|
||||
@@ -192,50 +122,64 @@ function handleConfirm() {
|
||||
}
|
||||
</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">
|
||||
<CaptchaCard
|
||||
:captcha-image="captchaImage"
|
||||
:height="height"
|
||||
:padding-x="paddingX"
|
||||
:padding-y="paddingY"
|
||||
:title="title"
|
||||
:width="width"
|
||||
@click="handleClick"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title">{{ $t('captcha.title') }}</slot>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<VbenIconButton
|
||||
:aria-label="$t('captcha.refreshAriaLabel')"
|
||||
class="ml-1"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<RotateCw class="size-5" />
|
||||
</VbenIconButton>
|
||||
<VbenButton aria-label="确认选择" @click="handleConfirm">
|
||||
确认
|
||||
<VbenButton
|
||||
v-if="showConfirm"
|
||||
:aria-label="$t('captcha.confirmAriaLabel')"
|
||||
class="ml-2"
|
||||
size="sm"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ $t('captcha.confirm') }}
|
||||
</VbenButton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-for="(point, index) in points"
|
||||
:key="index"
|
||||
:aria-label="$t('captcha.pointAriaLabel') + (index + 1)"
|
||||
:style="{
|
||||
top: `${point.y - POINT_OFFSET}px`,
|
||||
left: `${point.x - POINT_OFFSET}px`,
|
||||
}"
|
||||
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>
|
||||
<template #footer>
|
||||
<img
|
||||
v-if="hintImage"
|
||||
:alt="$t('captcha.alt')"
|
||||
:src="hintImage"
|
||||
class="h-10 w-full rounded border border-solid border-slate-200"
|
||||
/>
|
||||
<div
|
||||
v-else-if="hintText"
|
||||
class="flex h-10 w-full items-center justify-center rounded border border-solid border-slate-200"
|
||||
>
|
||||
{{ `${$t('captcha.clickInOrder')}` + `【${hintText}】` }}
|
||||
</div>
|
||||
</template>
|
||||
</CaptchaCard>
|
||||
</template>
|
||||
|
@@ -1,6 +1,89 @@
|
||||
export interface CaptchaPoint {
|
||||
i: number;
|
||||
export interface CaptchaData {
|
||||
/**
|
||||
* x
|
||||
*/
|
||||
x: number;
|
||||
/**
|
||||
* y
|
||||
*/
|
||||
y: number;
|
||||
/**
|
||||
* 时间戳
|
||||
*/
|
||||
t: number;
|
||||
}
|
||||
export interface CaptchaPoint extends CaptchaData {
|
||||
/**
|
||||
* 数据索引
|
||||
*/
|
||||
i: number;
|
||||
}
|
||||
export interface CaptchaCardProps {
|
||||
/**
|
||||
* 验证码图片
|
||||
*/
|
||||
captchaImage: string;
|
||||
/**
|
||||
* 验证码图片高度
|
||||
* @default '220px'
|
||||
*/
|
||||
height?: number | string;
|
||||
/**
|
||||
* 水平内边距
|
||||
* @default '12px'
|
||||
*/
|
||||
paddingX?: number | string;
|
||||
/**
|
||||
* 垂直内边距
|
||||
* @default '16px'
|
||||
*/
|
||||
paddingY?: number | string;
|
||||
/**
|
||||
* 标题
|
||||
* @default '请按图依次点击'
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 验证码图片宽度
|
||||
* @default '300px'
|
||||
*/
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export interface PointSelectionCaptchaProps extends CaptchaCardProps {
|
||||
/**
|
||||
* 是否展示确定按钮
|
||||
* @default false
|
||||
*/
|
||||
showConfirm?: boolean;
|
||||
/**
|
||||
* 提示图片
|
||||
* @default ''
|
||||
*/
|
||||
hintImage?: string;
|
||||
/**
|
||||
* 提示文本
|
||||
* @default ''
|
||||
*/
|
||||
hintText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: 滑动验证码
|
||||
*/
|
||||
// export interface SlideCaptchaProps extends CaptchaCardProps {
|
||||
// /**
|
||||
// * 瓦片图片高度
|
||||
// * @default '40px'
|
||||
// */
|
||||
// tileHeight?: number | string;
|
||||
// /**
|
||||
// * 瓦片图片宽度
|
||||
// * @default '150px'
|
||||
// */
|
||||
// tileWidth?: number | string;
|
||||
// /**
|
||||
// * 瓦片图片
|
||||
// */
|
||||
// tileImage: string;
|
||||
// }
|
||||
|
@@ -0,0 +1,7 @@
|
||||
export const parseValue = (value: number | string) => {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
@@ -311,5 +311,14 @@
|
||||
"sidebarToggle": "Enable Sidebar Toggle",
|
||||
"lockScreen": "Enable Lock Screen"
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
"alt": "Supports img tag src attribute value",
|
||||
"title": "Please complete the security verification",
|
||||
"refreshAriaLabel": "Refresh captcha",
|
||||
"confirmAriaLabel": "Confirm selection",
|
||||
"confirm": "Confirm",
|
||||
"pointAriaLabel": "Click point",
|
||||
"clickInOrder": "Please click in order"
|
||||
}
|
||||
}
|
||||
|
@@ -311,5 +311,14 @@
|
||||
"sidebarToggle": "启用侧边栏切换",
|
||||
"lockScreen": "启用锁屏"
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
"alt": "支持img标签src属性值",
|
||||
"title": "请完成安全验证",
|
||||
"refreshAriaLabel": "刷新验证码",
|
||||
"confirmAriaLabel": "确认选择",
|
||||
"confirm": "确认",
|
||||
"pointAriaLabel": "点击点",
|
||||
"clickInOrder": "请依次点击"
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user