chore(@vben/common-ui): add verify component (#4390)

* chore(@vben/common-ui): 增加拖拽校验组件

* chore: 增加样式

* Merge branch 'main' into wangjue-verify-comp

* chore: 封装action组件

* chore: 拆分完成拖拽功能

* chore: 样式调整为tailwindcss语法

* chore: 导出check图标

* chore: 拖动的图标变为@vben/icons的

* chore: 完成插槽功能迁移

* fix: ci error

* chore: 适配暗黑主题

* chore: 国际化

* chore: resolve conflict

* chore: 迁移v2的图片旋转校验组件

* chore: 完善选择校验demo

* chore: 转换为tailwindcss

* chore: 替换为系统的颜色变量

* chore: 使用interface代替组件的props声明

* chore: 调整props

* chore: 优化demo背景

* chore: follow suggest

* chore: rm unnecessary style tag

* chore: update demo

* perf: improve the experience of Captcha components

---------

Co-authored-by: vince <vince292007@gmail.com>
Co-authored-by: Vben <ann.vben@gmail.com>
This commit is contained in:
invalid w
2024-09-21 20:52:36 +08:00
committed by GitHub
parent dbe5b33db6
commit 000172e482
39 changed files with 1017 additions and 110 deletions

View File

@@ -10,6 +10,7 @@ export {
ArrowUpToLine,
Bell,
BookOpenText,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,

View File

@@ -12,7 +12,7 @@ export const VBEN_DOC_URL = 'https://doc.vben.pro';
* @zh_CN Vben Logo
*/
export const VBEN_LOGO_URL =
'https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp';
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp';
/**
* @zh_CN Vben Admin 首页地址

View File

@@ -10,7 +10,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"colorWeakMode": false,
"compact": false,
"contentCompact": "wide",
"defaultAvatar": "https://unpkg.com/@vbenjs/static-source@0.1.6/source/avatar-v1.webp",
"defaultAvatar": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp",
"dynamicTitle": true,
"enableCheckUpdates": true,
"enablePreferences": true,
@@ -49,7 +49,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
},
"logo": {
"enable": true,
"source": "https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp",
"source": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp",
},
"navigation": {
"accordion": true,

View File

@@ -10,7 +10,7 @@ const defaultPreferences: Preferences = {
compact: false,
contentCompact: 'wide',
defaultAvatar:
'https://unpkg.com/@vbenjs/static-source@0.1.6/source/avatar-v1.webp',
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
dynamicTitle: true,
enableCheckUpdates: true,
enablePreferences: true,
@@ -49,7 +49,7 @@ const defaultPreferences: Preferences = {
},
logo: {
enable: true,
source: 'https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp',
source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
},
navigation: {
accordion: true,

View File

@@ -1,5 +1,22 @@
<script lang="ts" setup>
import { computed } from 'vue';
const { animationDuration = 2, animationIterationCount = 'infinite' } =
defineProps<{
// 动画持续时间,单位秒
animationDuration?: number;
// 动画是否只执行一次
animationIterationCount?: 'infinite' | number;
}>();
const style = computed(() => {
return {
animation: `shine ${animationDuration}s linear ${animationIterationCount}`,
};
});
</script>
<template>
<div class="vben-spine-text !bg-clip-text text-transparent">
<div :style="style" class="vben-spine-text !bg-clip-text text-transparent">
<slot></slot>
</div>
</template>
@@ -9,7 +26,8 @@
radial-gradient(circle at center, rgb(255 255 255 / 80%), #f000) -200% 50% /
200% 100% no-repeat,
#000;
animation: shine 3s linear infinite;
/* animation: shine 3s linear infinite; */
}
.dark .vben-spine-text {

View File

@@ -23,10 +23,12 @@
"@vben-core/form-ui": "workspace:*",
"@vben-core/popup-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben/constants": "workspace:*",
"@vben/icons": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/types": "workspace:*",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"qrcode": "catalog:",
"vue": "catalog:",

View File

@@ -1,3 +1,6 @@
export { default as CaptchaCard } from './captcha-card.vue';
export { default as PointSelectionCaptcha } from './point-selection-captcha.vue';
export { default as PointSelectionCaptcha } from './point-selection-captcha/index.vue';
export { default as PointSelectionCaptchaCard } from './point-selection-captcha/index.vue';
export { default as SliderCaptcha } from './slider-captcha/index.vue';
export { default as SliderRotateCaptcha } from './slider-rotate-captcha/index.vue';
export type * from './types';

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { CaptchaPoint, PointSelectionCaptchaProps } from './types';
import type { CaptchaPoint, PointSelectionCaptchaProps } from '../types';
import { RotateCw } from '@vben/icons';
import { $t } from '@vben/locales';
import { VbenButton, VbenIconButton } from '@vben-core/shadcn-ui';
import CaptchaCard from './captcha-card.vue';
import { useCaptchaPoints } from './hooks/useCaptchaPoints';
import { useCaptchaPoints } from '../hooks/useCaptchaPoints';
import CaptchaCard from './point-selection-captcha-card.vue';
const props = withDefaults(defineProps<PointSelectionCaptchaProps>(), {
height: '220px',
@@ -121,12 +121,12 @@ function handleConfirm() {
@click="handleClick"
>
<template #title>
<slot name="title">{{ $t('captcha.title') }}</slot>
<slot name="title">{{ $t('ui.captcha.title') }}</slot>
</template>
<template #extra>
<VbenIconButton
:aria-label="$t('captcha.refreshAriaLabel')"
:aria-label="$t('ui.captcha.refreshAriaLabel')"
class="ml-1"
@click="handleRefresh"
>
@@ -134,19 +134,19 @@ function handleConfirm() {
</VbenIconButton>
<VbenButton
v-if="showConfirm"
:aria-label="$t('captcha.confirmAriaLabel')"
:aria-label="$t('ui.captcha.confirmAriaLabel')"
class="ml-2"
size="sm"
@click="handleConfirm"
>
{{ $t('captcha.confirm') }}
{{ $t('ui.captcha.confirm') }}
</VbenButton>
</template>
<div
v-for="(point, index) in points"
:key="index"
:aria-label="$t('captcha.pointAriaLabel') + (index + 1)"
:aria-label="$t('ui.captcha.pointAriaLabel') + (index + 1)"
:style="{
top: `${point.y - POINT_OFFSET}px`,
left: `${point.x - POINT_OFFSET}px`,
@@ -160,7 +160,7 @@ function handleConfirm() {
<template #footer>
<img
v-if="hintImage"
:alt="$t('captcha.alt')"
:alt="$t('ui.captcha.alt')"
:src="hintImage"
class="h-10 w-full rounded border border-solid border-slate-200"
/>
@@ -168,7 +168,7 @@ function handleConfirm() {
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}` }}
{{ `${$t('ui.captcha.clickInOrder')}` + `${hintText}` }}
</div>
</template>
</CaptchaCard>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { CaptchaCardProps } from './types';
import type { PointSelectionCaptchaCardProps } from '../types';
import { computed } from 'vue';
@@ -12,9 +12,7 @@ import {
CardTitle,
} from '@vben-core/shadcn-ui';
import { parseValue } from './utils';
const props = withDefaults(defineProps<CaptchaCardProps>(), {
const props = withDefaults(defineProps<PointSelectionCaptchaCardProps>(), {
height: '220px',
paddingX: '12px',
paddingY: '16px',
@@ -26,6 +24,14 @@ const emit = defineEmits<{
click: [MouseEvent];
}>();
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`,
@@ -47,7 +53,7 @@ function handleClick(e: MouseEvent) {
<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>
<slot name="title">{{ $t('ui.captcha.title') }}</slot>
</template>
<template v-else>
<span>{{ title }}</span>
@@ -60,7 +66,7 @@ function handleClick(e: MouseEvent) {
<CardContent class="relative mt-2 flex w-full overflow-hidden rounded p-0">
<img
v-show="captchaImage"
:alt="$t('captcha.alt')"
:alt="$t('ui.captcha.alt')"
:src="captchaImage"
:style="captchaStyles"
class="relative z-10"

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import type {
CaptchaVerifyPassingData,
SliderCaptchaProps,
SliderRotateVerifyPassingData,
} from '../types';
import { reactive, unref, useTemplateRef, watch, watchEffect } from 'vue';
import { $t } from '@vben/locales';
import { cn } from '@vben-core/shared/utils';
import { useTimeoutFn } from '@vueuse/core';
import SliderCaptchaAction from './slider-captcha-action.vue';
import SliderCaptchaBar from './slider-captcha-bar.vue';
import SliderCaptchaContent from './slider-captcha-content.vue';
const props = withDefaults(defineProps<SliderCaptchaProps>(), {
actionStyle: () => ({}),
barStyle: () => ({}),
contentStyle: () => ({}),
isSlot: false,
successText: '',
text: '',
wrapperStyle: () => ({}),
});
const emit = defineEmits<{
end: [MouseEvent | TouchEvent];
move: [SliderRotateVerifyPassingData];
start: [MouseEvent | TouchEvent];
success: [CaptchaVerifyPassingData];
}>();
const modelValue = defineModel<boolean>({ default: false });
const state = reactive({
endTime: 0,
isMoving: false,
isPassing: false,
moveDistance: 0,
startTime: 0,
toLeft: false,
});
defineExpose({
resume,
});
const wrapperRef = useTemplateRef<HTMLDivElement>('wrapperRef');
const barRef = useTemplateRef<typeof SliderCaptchaBar>('barRef');
const contentRef = useTemplateRef<typeof SliderCaptchaContent>('contentRef');
const actionRef = useTemplateRef<typeof SliderCaptchaAction>('actionRef');
watch(
() => state.isPassing,
(isPassing) => {
if (isPassing) {
const { endTime, startTime } = state;
const time = (endTime - startTime) / 1000;
emit('success', { isPassing, time: time.toFixed(1) });
modelValue.value = isPassing;
}
},
);
watchEffect(() => {
state.isPassing = !!modelValue.value;
});
function getEventPageX(e: MouseEvent | TouchEvent): number {
if (e instanceof MouseEvent) {
return e.pageX;
} else if (e instanceof TouchEvent && e.touches[0]) {
return e.touches[0].pageX;
}
return 0;
}
function handleDragStart(e: MouseEvent | TouchEvent) {
if (state.isPassing) {
return;
}
if (!actionRef.value) return;
emit('start', e);
state.moveDistance =
getEventPageX(e) -
Number.parseInt(
actionRef.value.getStyle().left.replace('px', '') || '0',
10,
);
state.startTime = Date.now();
state.isMoving = true;
}
function getOffset(actionEl: HTMLDivElement) {
const wrapperWidth = wrapperRef.value?.offsetWidth ?? 220;
const actionWidth = actionEl?.offsetWidth ?? 40;
const offset = wrapperWidth - actionWidth - 6;
return { actionWidth, offset, wrapperWidth };
}
function handleDragMoving(e: MouseEvent | TouchEvent) {
const { isMoving, moveDistance } = state;
if (isMoving) {
const actionEl = unref(actionRef);
const barEl = unref(barRef);
if (!actionEl || !barEl) return;
const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
const moveX = getEventPageX(e) - moveDistance;
emit('move', {
event: e,
moveDistance,
moveX,
});
if (moveX > 0 && moveX <= offset) {
actionEl.setLeft(`${moveX}px`);
barEl.setWidth(`${moveX + actionWidth / 2}px`);
} else if (moveX > offset) {
actionEl.setLeft(`${wrapperWidth - actionWidth}px`);
barEl.setWidth(`${wrapperWidth - actionWidth / 2}px`);
if (!props.isSlot) {
checkPass();
}
}
}
}
function handleDragOver(e: MouseEvent | TouchEvent) {
const { isMoving, isPassing, moveDistance } = state;
if (isMoving && !isPassing) {
emit('end', e);
const actionEl = actionRef.value;
const barEl = unref(barRef);
if (!actionEl || !barEl) return;
const moveX = getEventPageX(e) - moveDistance;
const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
if (moveX < offset) {
if (props.isSlot) {
setTimeout(() => {
if (modelValue.value) {
const contentEl = unref(contentRef);
if (contentEl) {
contentEl.getEl().style.width = `${Number.parseInt(barEl.getEl().style.width)}px`;
}
} else {
resume();
}
}, 0);
} else {
resume();
}
} else {
actionEl.setLeft(`${wrapperWidth - actionWidth + 10}px`);
barEl.setWidth(`${wrapperWidth - actionWidth / 2}px`);
checkPass();
}
state.isMoving = false;
}
}
function checkPass() {
if (props.isSlot) {
resume();
return;
}
state.endTime = Date.now();
state.isPassing = true;
state.isMoving = false;
}
function resume() {
state.isMoving = false;
state.isPassing = false;
state.moveDistance = 0;
state.toLeft = false;
state.startTime = 0;
state.endTime = 0;
const actionEl = unref(actionRef);
const barEl = unref(barRef);
const contentEl = unref(contentRef);
if (!actionEl || !barEl || !contentEl) return;
state.toLeft = true;
useTimeoutFn(() => {
state.toLeft = false;
actionEl.setLeft('0');
barEl.setWidth('0');
}, 300);
}
</script>
<template>
<div
ref="wrapperRef"
:class="
cn(
'border-border bg-background-deep relative flex h-10 w-full items-center overflow-hidden rounded-md border text-center',
props.class,
)
"
:style="wrapperStyle"
@mouseleave="handleDragOver"
@mousemove="handleDragMoving"
@mouseup="handleDragOver"
@touchend="handleDragOver"
@touchmove="handleDragMoving"
>
<SliderCaptchaBar
ref="barRef"
:bar-style="barStyle"
:to-left="state.toLeft"
/>
<SliderCaptchaContent
ref="contentRef"
:content-style="contentStyle"
:is-passing="state.isPassing"
:success-text="successText || $t('ui.captcha.sliderSuccessText')"
:text="text || $t('ui.captcha.sliderDefaultText')"
>
<template v-if="$slots.text" #text>
<slot :is-passing="state.isPassing" name="text"></slot>
</template>
</SliderCaptchaContent>
<SliderCaptchaAction
ref="actionRef"
:action-style="actionStyle"
:is-passing="state.isPassing"
:to-left="state.toLeft"
@mousedown="handleDragStart"
@touchstart="handleDragStart"
>
<template v-if="$slots.actionIcon" #icon>
<slot :is-passing="state.isPassing" name="actionIcon"></slot>
</template>
</SliderCaptchaAction>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import { Check, ChevronsRight } from '@vben/icons';
import { Slot } from '@vben-core/shadcn-ui';
const props = defineProps<{
actionStyle: CSSProperties;
isPassing: boolean;
toLeft: boolean;
}>();
const actionRef = useTemplateRef<HTMLDivElement>('actionRef');
const left = ref('0');
const style = computed(() => {
const { actionStyle } = props;
return {
...actionStyle,
left: left.value,
};
});
const isDragging = computed(() => {
const currentLeft = Number.parseInt(left.value as string);
return currentLeft > 10 && !props.isPassing;
});
defineExpose({
getEl: () => {
return actionRef.value;
},
getStyle: () => {
return actionRef?.value?.style;
},
setLeft: (val: string) => {
left.value = val;
},
});
</script>
<template>
<div
ref="actionRef"
:class="{
'transition-width !left-0 duration-300': toLeft,
'rounded-md': isDragging,
}"
:style="style"
class="bg-background dark:bg-accent absolute left-0 top-0 flex h-full cursor-move items-center justify-center px-3.5 shadow-md"
>
<Slot :is-passing="isPassing" class="text-foreground/60 size-4">
<slot name="icon">
<ChevronsRight v-if="!isPassing" />
<Check v-else />
</slot>
</Slot>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, type CSSProperties, ref, useTemplateRef } from 'vue';
const props = defineProps<{
barStyle: CSSProperties;
toLeft: boolean;
}>();
const barRef = useTemplateRef<HTMLDivElement>('barRef');
const width = ref('0');
const style = computed(() => {
const { barStyle } = props;
return {
...barStyle,
width: width.value,
};
});
defineExpose({
getEl: () => {
return barRef.value;
},
setWidth: (val: string) => {
width.value = val;
},
});
</script>
<template>
<div
ref="barRef"
:class="toLeft && 'transition-width !w-0 duration-300'"
:style="style"
class="bg-success absolute h-full"
></div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { VbenSpineText } from '@vben-core/shadcn-ui';
const props = defineProps<{
contentStyle: CSSProperties;
isPassing: boolean;
successText: string;
text: string;
}>();
const contentRef = useTemplateRef<HTMLDivElement>('contentRef');
const style = computed(() => {
const { contentStyle } = props;
return {
...contentStyle,
};
});
defineExpose({
getEl: () => {
return contentRef.value;
},
});
</script>
<template>
<div
ref="contentRef"
:class="{
[$style.success]: isPassing,
}"
:style="style"
class="absolute top-0 flex size-full select-none items-center justify-center text-xs"
>
<slot name="text">
<VbenSpineText class="flex h-full items-center">
{{ isPassing ? successText : text }}
</VbenSpineText>
</slot>
</div>
</template>
<style module>
.success {
-webkit-text-fill-color: hsl(0deg 0% 98%);
}
</style>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import type {
CaptchaVerifyPassingData,
SliderCaptchaActionType,
SliderRotateCaptchaProps,
SliderRotateVerifyPassingData,
} from '../types';
import { computed, reactive, unref, useTemplateRef, watch } from 'vue';
import { $t } from '@vben/locales';
import { useTimeoutFn } from '@vueuse/core';
import SliderCaptcha from '../slider-captcha/index.vue';
const props = withDefaults(defineProps<SliderRotateCaptchaProps>(), {
defaultTip: '',
diffDegree: 20,
imageSize: 260,
maxDegree: 300,
minDegree: 120,
src: '',
});
const emit = defineEmits<{
success: [CaptchaVerifyPassingData];
}>();
const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
const state = reactive({
currentRotate: 0,
dragging: false,
endTime: 0,
imgStyle: {},
isPassing: false,
randomRotate: 0,
showTip: false,
startTime: 0,
toOrigin: false,
});
const modalValue = defineModel<boolean>({ default: false });
watch(
() => state.isPassing,
(isPassing) => {
if (isPassing) {
const { endTime, startTime } = state;
const time = (endTime - startTime) / 1000;
emit('success', { isPassing, time: time.toFixed(1) });
}
modalValue.value = isPassing;
},
);
const getImgWrapStyleRef = computed(() => {
const { imageSize, imageWrapperStyle } = props;
return {
height: `${imageSize}px`,
width: `${imageSize}px`,
...imageWrapperStyle,
};
});
const getFactorRef = computed(() => {
const { maxDegree, minDegree } = props;
if (minDegree === maxDegree) {
return Math.floor(1 + Math.random() * 1) / 10 + 1;
}
return 1;
});
function handleStart() {
state.startTime = Date.now();
}
function handleDragBarMove(data: SliderRotateVerifyPassingData) {
state.dragging = true;
const { imageSize, maxDegree } = props;
const { moveX } = data;
const denominator = imageSize!;
if (denominator === 0) {
return;
}
const currentRotate = Math.ceil(
(moveX / denominator) * 1.5 * maxDegree! * unref(getFactorRef),
);
state.currentRotate = currentRotate;
setImgRotate(state.randomRotate - currentRotate);
}
function handleImgOnLoad() {
const { maxDegree, minDegree } = props;
const ranRotate = Math.floor(
minDegree! + Math.random() * (maxDegree! - minDegree!),
); // 生成随机角度
state.randomRotate = ranRotate;
setImgRotate(ranRotate);
}
function handleDragEnd() {
const { currentRotate, randomRotate } = state;
const { diffDegree } = props;
if (Math.abs(randomRotate - currentRotate) >= (diffDegree || 20)) {
setImgRotate(randomRotate);
state.toOrigin = true;
useTimeoutFn(() => {
state.toOrigin = false;
state.showTip = true;
// 时间与动画时间保持一致
}, 300);
} else {
checkPass();
}
state.showTip = true;
}
function setImgRotate(deg: number) {
state.imgStyle = {
transform: `rotateZ(${deg}deg)`,
};
}
function checkPass() {
state.isPassing = true;
state.endTime = Date.now();
}
function resume() {
state.showTip = false;
const basicEl = unref(slideBarRef);
if (!basicEl) {
return;
}
state.isPassing = false;
basicEl.resume();
handleImgOnLoad();
}
const imgCls = computed(() => {
return state.toOrigin ? ['transition-transform duration-300'] : [];
});
const verifyTip = computed(() => {
return state.isPassing
? $t('ui.captcha.sliderRotateSuccessTip', [
((state.endTime - state.startTime) / 1000).toFixed(1),
])
: $t('ui.captcha.sliderRotateFailTip');
});
defineExpose({
resume,
});
</script>
<template>
<div class="relative flex flex-col items-center">
<div
:style="getImgWrapStyleRef"
class="border-border relative overflow-hidden rounded-full border shadow-md"
>
<img
:class="imgCls"
:src="src"
:style="state.imgStyle"
alt="verify"
class="w-full rounded-full"
@click="resume"
@load="handleImgOnLoad"
/>
<div
class="absolute bottom-3 left-0 z-10 block h-7 w-full text-center text-xs leading-[30px] text-white"
>
<div
v-if="state.showTip"
:class="{
'bg-success/80': state.isPassing,
'bg-destructive/80': !state.isPassing,
}"
>
{{ verifyTip }}
</div>
<div v-if="!state.showTip && !state.dragging" class="bg-black/30">
{{ defaultTip || $t('ui.captcha.sliderRotateDefaultTip') }}
</div>
</div>
</div>
<SliderCaptcha
ref="slideBarRef"
v-model="modalValue"
class="mt-5"
is-slot
@end="handleDragEnd"
@move="handleDragBarMove"
@start="handleStart"
>
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
<slot :name="key" v-bind="slotProps"></slot>
</template>
</SliderCaptcha>
</div>
</template>

View File

@@ -1,3 +1,5 @@
import type { CSSProperties } from 'vue';
export interface CaptchaData {
/**
* x
@@ -18,7 +20,7 @@ export interface CaptchaPoint extends CaptchaData {
*/
i: number;
}
export interface CaptchaCardProps {
export interface PointSelectionCaptchaCardProps {
/**
* 验证码图片
*/
@@ -50,7 +52,8 @@ export interface CaptchaCardProps {
width?: number | string;
}
export interface PointSelectionCaptchaProps extends CaptchaCardProps {
export interface PointSelectionCaptchaProps
extends PointSelectionCaptchaCardProps {
/**
* 是否展示确定按钮
* @default false
@@ -68,22 +71,103 @@ export interface PointSelectionCaptchaProps extends CaptchaCardProps {
hintText?: string;
}
/**
* TODO: 滑动验证码
*/
// export interface SlideCaptchaProps extends CaptchaCardProps {
// /**
// * 瓦片图片高度
// * @default '40px'
// */
// tileHeight?: number | string;
// /**
// * 瓦片图片宽度
// * @default '150px'
// */
// tileWidth?: number | string;
// /**
// * 瓦片图片
// */
// tileImage: string;
// }
export interface SliderCaptchaProps {
class?: any;
/**
* @description 滑块的样式
* @default {}
*/
actionStyle?: CSSProperties;
/**
* @description 滑块条的样式
* @default {}
*/
barStyle?: CSSProperties;
/**
* @description 内容的样式
* @default {}
*/
contentStyle?: CSSProperties;
/**
* @description 组件的样式
* @default {}
*/
wrapperStyle?: CSSProperties;
/**
* @description 是否作为插槽使用,用于联动组件,可参考旋转校验组件
* @default false
*/
isSlot?: boolean;
/**
* @description 验证成功的提示
* @default '验证通过'
*/
successText?: string;
/**
* @description 提示文字
* @default '请按住滑块拖动'
*/
text?: string;
}
export interface SliderRotateCaptchaProps {
/**
* @description 旋转的角度
* @default 20
*/
diffDegree?: number;
/**
* @description 图片的宽度
* @default 260
*/
imageSize?: number;
/**
* @description 图片的样式
* @default {}
*/
imageWrapperStyle?: CSSProperties;
/**
* @description 最大旋转角度
* @default 270
*/
maxDegree?: number;
/**
* @description 最小旋转角度
* @default 90
*/
minDegree?: number;
/**
* @description 图片的地址
*/
src?: string;
/**
* @description 默认提示文本
*/
defaultTip?: string;
}
export interface CaptchaVerifyPassingData {
isPassing: boolean;
time: number | string;
}
export interface SliderCaptchaActionType {
resume: () => void;
}
export interface SliderRotateVerifyPassingData {
event: MouseEvent | TouchEvent;
moveDistance: number;
moveX: number;
}

View File

@@ -1,7 +0,0 @@
export const parseValue = (value: number | string) => {
if (typeof value === 'number') {
return value;
}
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? 0 : parsed;
};

View File

@@ -313,13 +313,20 @@
"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"
"ui": {
"captcha": {
"title": "Please complete the security verification",
"sliderSuccessText": "Passed",
"sliderDefaultText": "Slider and drag",
"alt": "Supports img tag src attribute value",
"sliderRotateDefaultTip": "Click picture to refresh",
"sliderRotateFailTip": "Validation failed",
"sliderRotateSuccessTip": "Validation successful, time {0} seconds",
"refreshAriaLabel": "Refresh captcha",
"confirmAriaLabel": "Confirm selection",
"confirm": "Confirm",
"pointAriaLabel": "Click point",
"clickInOrder": "Please click in order"
}
}
}

View File

@@ -313,13 +313,20 @@
"lockScreen": "启用锁屏"
}
},
"captcha": {
"alt": "支持img标签src属性值",
"title": "请完成安全验证",
"refreshAriaLabel": "刷新验证码",
"confirmAriaLabel": "确认选择",
"confirm": "确认",
"pointAriaLabel": "点击点",
"clickInOrder": "请依次点击"
"ui": {
"captcha": {
"title": "请完成安全验证",
"sliderSuccessText": "验证通过",
"sliderDefaultText": "请按住滑块拖动",
"sliderRotateDefaultTip": "点击图片可刷新",
"sliderRotateFailTip": "验证失败",
"sliderRotateSuccessTip": "验证成功,耗时{0}秒",
"alt": "支持img标签src属性值",
"refreshAriaLabel": "刷新验证码",
"confirmAriaLabel": "确认选择",
"confirm": "确认",
"pointAriaLabel": "点击点",
"clickInOrder": "请依次点击"
}
}
}