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>
pull/48/MERGE
invalid w 2024-09-21 20:52:36 +08:00 committed by GitHub
parent dbe5b33db6
commit 000172e482
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1017 additions and 110 deletions

View File

@ -1,4 +1,4 @@
<div align="center"> <a href="https://github.com/anncwb/vue-vben-admin"> <img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp"> </a> <br> <br>
<div align="center"> <a href="https://github.com/anncwb/vue-vben-admin"> <img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp"> </a> <br> <br>
[![license](https://img.shields.io/github/license/anncwb/vue-vben-admin.svg)](LICENSE)
@ -129,7 +129,7 @@ pnpm build
このプロジェクトが役に立つと思われた場合、作者にコーヒーを一杯おごってサポートを示すことができます!
![donate](https://unpkg.com/@vbenjs/static-source@0.1.6/source/sponsor.png)
![donate](https://unpkg.com/@vbenjs/static-source@0.1.7/source/sponsor.png)
<a style="display: block;width: 100px;height: 50px;line-height: 50px; color: #fff;text-align: center; background: #408aed;border-radius: 4px;" href="https://www.paypal.com/paypalme/cvvben">Paypal Me</a>

View File

@ -1,4 +1,4 @@
<div align="center"> <a href="https://github.com/anncwb/vue-vben-admin"> <img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp"> </a> <br> <br>
<div align="center"> <a href="https://github.com/anncwb/vue-vben-admin"> <img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp"> </a> <br> <br>
[![license](https://img.shields.io/github/license/anncwb/vue-vben-admin.svg)](LICENSE)
@ -128,7 +128,7 @@ Support modern browsers, not IE
If you think this project is helpful to you, you can help the author buy a cup of coffee to show your support!
![donate](https://unpkg.com/@vbenjs/static-source@0.1.6/source/sponsor.png)
![donate](https://unpkg.com/@vbenjs/static-source@0.1.7/source/sponsor.png)
<a style="display: block;width: 100px;height: 50px;line-height: 50px; color: #fff;text-align: center; background: #408aed;border-radius: 4px;" href="https://www.paypal.com/paypalme/cvvben">Paypal Me</a>

View File

@ -1,4 +1,4 @@
<div align="center"> <a href="https://github.com/anncwb/vue-vben-admin"> <img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp"> </a> <br> <br>
<div align="center"> <a href="https://github.com/anncwb/vue-vben-admin"> <img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp"> </a> <br> <br>
[![license](https://img.shields.io/github/license/anncwb/vue-vben-admin.svg)](LICENSE)
@ -124,7 +124,7 @@ pnpm build
如果你觉得这个项目对你有帮助,你可以帮作者买一杯咖啡表示支持!
![donate](https://unpkg.com/@vbenjs/static-source@0.1.6/source/sponsor.png)
![donate](https://unpkg.com/@vbenjs/static-source@0.1.7/source/sponsor.png)
<a style="display: block;width: 100px;height: 50px;line-height: 50px; color: #fff;text-align: center; background: #408aed;border-radius: 4px;" href="https://www.paypal.com/paypalme/cvvben">Paypal Me</a>

View File

@ -32,7 +32,7 @@ export const shared = defineConfig({
srcDir: 'src',
themeConfig: {
i18nRouting: true,
logo: 'https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp',
logo: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
search: {
options: {
locales: {
@ -138,12 +138,12 @@ function pwa(): PwaOptions {
icons: [
{
sizes: '192x192',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.6/source/pwa-icon-192.png',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/pwa-icon-192.png',
type: 'image/png',
},
{
sizes: '512x512',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.6/source/pwa-icon-512.png',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/pwa-icon-512.png',
type: 'image/png',
},
],

View File

@ -24,4 +24,4 @@
:::
<img src="https://unpkg.com/@vbenjs/static-source@0.1.6/source/wechat.jpg" style="width: 300px;"/>
<img src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/wechat.jpg" style="width: 300px;"/>

View File

@ -7,6 +7,6 @@
- 通过邮箱联系开发者: [ann.vben@gmail.com](mailto:ann.vben@gmail.com)
- 通过微信联系开发者:
<img src="https://unpkg.com/@vbenjs/static-source@0.1.6/source/wechat.jpg" style="width: 300px;"/>
<img src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/wechat.jpg" style="width: 300px;"/>
我们会在第一时间回复您,定制费用根据需求而定。

View File

@ -163,7 +163,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,
@ -202,7 +202,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

@ -8,7 +8,7 @@ hero:
text: Enterprise-Level Management System Framework
tagline: Fully Upgraded, Ready to Use, Simple and Efficient
image:
src: https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp
src: https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp
alt: Vben Admin
actions:
- theme: brand

View File

@ -9,7 +9,7 @@
- 通过邮箱联系作者: [ann.vben@gmail.com](mailto:ann.vben@gmail.com)
- 通过微信联系作者:
<img src="https://unpkg.com/@vbenjs/static-source@0.1.6/source/wechat.jpg" style="width: 300px;"/>
<img src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/wechat.jpg" style="width: 300px;"/>
### 提供资料
@ -22,6 +22,6 @@
- 名称Vben Admin
- 链接https://www.vben.pro
- 描述Vben Admin 企业级开箱即用的中后台前端解决方案
- Logohttps://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp
- Logohttps://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp
我们将定期的检查友情链接,如果发现您的网站已经删除了我们的友情链接以及链接地址是否正确。

View File

@ -185,7 +185,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,
@ -224,7 +224,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

@ -8,7 +8,7 @@ hero:
text: 企业级管理系统框架
tagline: 全新升级,开箱即用,简单高效
image:
src: https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp
src: https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp
alt: Vben Admin
actions:
- theme: brand

View File

@ -2,7 +2,7 @@
如果你觉得这个项目对你有帮助,你可以帮作者买一杯咖啡表示支持!
![](https://unpkg.com/@vbenjs/static-source@0.1.6/source/sponsor.png)
![](https://unpkg.com/@vbenjs/static-source@0.1.7/source/sponsor.png)
您的赞助将帮助我们:

View File

@ -11,12 +11,12 @@ const getDefaultPwaOptions = (name: string): Partial<PwaPluginOptions> => ({
icons: [
{
sizes: '192x192',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.6/source/pwa-icon-192.png',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/pwa-icon-192.png',
type: 'image/png',
},
{
sizes: '512x512',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.6/source/pwa-icon-512.png',
src: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/pwa-icon-512.png',
type: 'image/png',
},
],

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": "请依次点击"
}
}
}

View File

@ -83,6 +83,9 @@
},
"captcha": {
"title": "Captcha",
"pointSelection": "Point Selection Captcha",
"sliderCaptcha": "Slider Captcha",
"sliderRotateCaptcha": "Rotate Captcha",
"captchaCardTitle": "Please complete the security verification",
"pageDescription": "Verify user identity by clicking on specific locations in the image.",
"pageTitle": "Captcha Component Example",

View File

@ -83,6 +83,9 @@
},
"captcha": {
"title": "验证码",
"pointSelection": "点选验证",
"sliderCaptcha": "滑块验证",
"sliderRotateCaptcha": "旋转验证",
"captchaCardTitle": "请完成安全验证",
"pageDescription": "通过点击图片中的特定位置来验证用户身份。",
"pageTitle": "验证码组件示例",

View File

@ -42,15 +42,7 @@ const routes: RouteRecordRaw[] = [
title: $t('page.examples.ellipsis.title'),
},
},
{
name: 'CaptchaExample',
path: '/examples/captcha',
component: () => import('#/views/examples/captcha/index.vue'),
meta: {
icon: 'logos:recaptcha',
title: $t('page.examples.captcha.title'),
},
},
{
name: 'FormExample',
path: '/examples/form',
@ -109,6 +101,43 @@ const routes: RouteRecordRaw[] = [
},
],
},
{
name: 'CaptchaExample',
path: '/examples/captcha',
meta: {
icon: 'logos:recaptcha',
title: $t('page.examples.captcha.title'),
},
children: [
{
name: 'DragVerifyExample',
path: '/examples/captcha/slider',
component: () =>
import('#/views/examples/captcha/slider-captcha.vue'),
meta: {
title: $t('page.examples.captcha.sliderCaptcha'),
},
},
{
name: 'RotateVerifyExample',
path: '/examples/captcha/slider-rotate',
component: () =>
import('#/views/examples/captcha/slider-rotate-captcha.vue'),
meta: {
title: $t('page.examples.captcha.sliderRotateCaptcha'),
},
},
{
name: 'CaptchaPointSelectionExample',
path: '/examples/captcha/point-selection',
component: () =>
import('#/views/examples/captcha/point-selection-captcha.vue'),
meta: {
title: $t('page.examples.captcha.pointSelection'),
},
},
],
},
],
},
];

File diff suppressed because one or more lines are too long

View File

@ -9,20 +9,24 @@ import { Card, Input, InputNumber, message, Switch } from 'ant-design-vue';
import { $t } from '#/locales';
import { captchaImage, hintImage } from './base64';
const DEFAULT_CAPTCHA_IMAGE =
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/default-captcha-image.jpeg';
const DEFAULT_HINT_IMAGE =
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/default-hint-image.png';
const selectedPoints = ref<CaptchaPoint[]>([]);
const params = reactive({
captchaImage,
captchaImageUrl: '',
captchaImage: '',
captchaImageUrl: DEFAULT_CAPTCHA_IMAGE,
height: undefined,
hintImage,
hintImageUrl: '',
hintImage: '',
hintImageUrl: DEFAULT_HINT_IMAGE,
hintText: '唇,燕,碴,找',
paddingX: undefined,
paddingY: undefined,
showConfirm: true,
showHintImage: true,
showHintImage: false,
title: '',
width: undefined,
});

View File

@ -0,0 +1,116 @@
<script lang="ts" setup>
import type {
CaptchaVerifyPassingData,
SliderCaptchaActionType,
} from '@vben/common-ui';
import { ref } from 'vue';
import { Page, SliderCaptcha } from '@vben/common-ui';
import { Bell, Sun } from '@vben/icons';
import { Button, Card, message } from 'ant-design-vue';
function handleSuccess(data: CaptchaVerifyPassingData) {
const { time } = data;
message.success(`校验成功,耗时${time}`);
}
function handleBtnClick(elRef?: SliderCaptchaActionType) {
if (!elRef) {
return;
}
elRef.resume();
}
const el1 = ref<SliderCaptchaActionType>();
const el2 = ref<SliderCaptchaActionType>();
const el3 = ref<SliderCaptchaActionType>();
const el4 = ref<SliderCaptchaActionType>();
const el5 = ref<SliderCaptchaActionType>();
</script>
<template>
<Page description="用于前端简单的拖动校验场景" title="滑块校验">
<Card class="mb-5" title="基础示例">
<div class="flex items-center justify-center p-4 px-[30%]">
<SliderCaptcha ref="el1" @success="handleSuccess" />
<Button class="ml-2" type="primary" @click="handleBtnClick(el1)">
还原
</Button>
</div>
</Card>
<Card class="mb-5" title="自定义圆角">
<div class="flex items-center justify-center p-4 px-[30%]">
<SliderCaptcha
ref="el2"
class="rounded-full"
@success="handleSuccess"
/>
<Button class="ml-2" type="primary" @click="handleBtnClick(el2)">
还原
</Button>
</div>
</Card>
<Card class="mb-5" title="自定义背景色">
<div class="flex items-center justify-center p-4 px-[30%]">
<SliderCaptcha
ref="el3"
:bar-style="{
backgroundColor: '#018ffb',
}"
success-text="校验成功"
text="拖动以进行校验"
@success="handleSuccess"
/>
<Button class="ml-2" type="primary" @click="handleBtnClick(el3)">
还原
</Button>
</div>
</Card>
<Card class="mb-5" title="自定义拖拽图标">
<div class="flex items-center justify-center p-4 px-[30%]">
<SliderCaptcha ref="el4" @success="handleSuccess">
<template #actionIcon="{ isPassing }">
<Bell v-if="isPassing" />
<Sun v-else />
</template>
</SliderCaptcha>
<Button class="ml-2" type="primary" @click="handleBtnClick(el4)">
还原
</Button>
</div>
</Card>
<Card class="mb-5" title="自定义文本">
<div class="flex items-center justify-center p-4 px-[30%]">
<SliderCaptcha
ref="el5"
success-text="成功"
text="拖动"
@success="handleSuccess"
/>
<Button class="ml-2" type="primary" @click="handleBtnClick(el5)">
还原
</Button>
</div>
</Card>
<Card class="mb-5" title="自定义内容(slot)">
<div class="flex items-center justify-center p-4 px-[30%]">
<SliderCaptcha ref="el5" @success="handleSuccess">
<template #text="{ isPassing }">
<template v-if="isPassing">
<Bell class="mr-2 size-4" />
成功
</template>
<template v-else>
拖动
<Sun class="ml-2 size-4" />
</template>
</template>
</SliderCaptcha>
<Button class="ml-2" type="primary" @click="handleBtnClick(el5)">
还原
</Button>
</div>
</Card>
</Page>
</template>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Page, SliderRotateCaptcha } from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { Card, message } from 'ant-design-vue';
const userStore = useUserStore();
function handleSuccess() {
message.success('success!');
}
const avatar = computed(() => {
return userStore.userInfo?.avatar || preferences.app.defaultAvatar;
});
</script>
<template>
<Page description="用于前端简单的拖动校验场景" title="滑块旋转校验">
<Card class="mb-5" title="基本示例">
<div class="flex items-center justify-center p-4">
<SliderRotateCaptcha :src="avatar" @success="handleSuccess" />
</div>
</Card>
</Page>
</template>

View File

@ -1448,6 +1448,9 @@ importers:
'@vben-core/shadcn-ui':
specifier: workspace:*
version: link:../../@core/ui-kit/shadcn-ui
'@vben-core/shared':
specifier: workspace:*
version: link:../../@core/base/shared
'@vben/constants':
specifier: workspace:*
version: link:../../constants
@ -1460,6 +1463,9 @@ importers:
'@vben/types':
specifier: workspace:*
version: link:../../types
'@vueuse/core':
specifier: 'catalog:'
version: 11.1.0(vue@3.5.7(typescript@5.6.2))
'@vueuse/integrations':
specifier: 'catalog:'
version: 11.1.0(async-validator@4.2.5)(axios@1.7.7)(focus-trap@7.6.0)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.3)(vue@3.5.7(typescript@5.6.2))