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:
@@ -10,6 +10,7 @@ export {
|
||||
ArrowUpToLine,
|
||||
Bell,
|
||||
BookOpenText,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
|
@@ -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 首页地址
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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 {
|
||||
|
@@ -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:",
|
||||
|
@@ -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';
|
||||
|
@@ -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>
|
@@ -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"
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
};
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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": "请依次点击"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user