Browse Source

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>
invalid w 6 months ago
parent
commit
000172e482
39 changed files with 1017 additions and 107 deletions
  1. 2 2
      README.ja-JP.md
  2. 2 2
      README.md
  3. 2 2
      README.zh-CN.md
  4. 3 3
      docs/.vitepress/config/shared.mts
  5. 1 1
      docs/src/commercial/community.md
  6. 1 1
      docs/src/commercial/customized.md
  7. 2 2
      docs/src/en/guide/essentials/settings.md
  8. 1 1
      docs/src/en/index.md
  9. 2 2
      docs/src/friend-links/index.md
  10. 2 2
      docs/src/guide/essentials/settings.md
  11. 1 1
      docs/src/index.md
  12. 1 1
      docs/src/sponsor/personal.md
  13. 2 2
      internal/vite-config/src/options.ts
  14. 1 0
      packages/@core/base/icons/src/lucide.ts
  15. 1 1
      packages/@core/base/shared/src/constants/vben.ts
  16. 2 2
      packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap
  17. 2 2
      packages/@core/preferences/src/config.ts
  18. 20 2
      packages/@core/ui-kit/shadcn-ui/src/components/spine-text/spine-text.vue
  19. 2 0
      packages/effects/common-ui/package.json
  20. 5 2
      packages/effects/common-ui/src/components/captcha/index.ts
  21. 10 10
      packages/effects/common-ui/src/components/captcha/point-selection-captcha/index.vue
  22. 12 6
      packages/effects/common-ui/src/components/captcha/point-selection-captcha/point-selection-captcha-card.vue
  23. 241 0
      packages/effects/common-ui/src/components/captcha/slider-captcha/index.vue
  24. 62 0
      packages/effects/common-ui/src/components/captcha/slider-captcha/slider-captcha-action.vue
  25. 38 0
      packages/effects/common-ui/src/components/captcha/slider-captcha/slider-captcha-bar.vue
  26. 52 0
      packages/effects/common-ui/src/components/captcha/slider-captcha/slider-captcha-content.vue
  27. 208 0
      packages/effects/common-ui/src/components/captcha/slider-rotate-captcha/index.vue
  28. 105 21
      packages/effects/common-ui/src/components/captcha/types.ts
  29. 0 7
      packages/effects/common-ui/src/components/captcha/utils.ts
  30. 15 8
      packages/locales/src/langs/en-US.json
  31. 15 8
      packages/locales/src/langs/zh-CN.json
  32. 3 0
      playground/src/locales/langs/en-US.json
  33. 3 0
      playground/src/locales/langs/zh-CN.json
  34. 38 9
      playground/src/router/routes/modules/examples.ts
  35. 0 1
      playground/src/views/examples/captcha/base64.ts
  36. 10 6
      playground/src/views/examples/captcha/point-selection-captcha.vue
  37. 116 0
      playground/src/views/examples/captcha/slider-captcha.vue
  38. 28 0
      playground/src/views/examples/captcha/slider-rotate-captcha.vue
  39. 6 0
      pnpm-lock.yaml

+ 2 - 2
README.ja-JP.md

@@ -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>
 

+ 2 - 2
README.md

@@ -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>
 

+ 2 - 2
README.zh-CN.md

@@ -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>
 

+ 3 - 3
docs/.vitepress/config/shared.mts

@@ -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',
         },
       ],

+ 1 - 1
docs/src/commercial/community.md

@@ -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;"/>

+ 1 - 1
docs/src/commercial/customized.md

@@ -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;"/>
 
 我们会在第一时间回复您,定制费用根据需求而定。

+ 2 - 2
docs/src/en/guide/essentials/settings.md

@@ -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,

+ 1 - 1
docs/src/en/index.md

@@ -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

+ 2 - 2
docs/src/friend-links/index.md

@@ -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 企业级开箱即用的中后台前端解决方案
-  - 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
 
 我们将定期的检查友情链接,如果发现您的网站已经删除了我们的友情链接以及链接地址是否正确。

+ 2 - 2
docs/src/guide/essentials/settings.md

@@ -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,

+ 1 - 1
docs/src/index.md

@@ -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

+ 1 - 1
docs/src/sponsor/personal.md

@@ -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)
 
 您的赞助将帮助我们:
 

+ 2 - 2
internal/vite-config/src/options.ts

@@ -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',
       },
     ],

+ 1 - 0
packages/@core/base/icons/src/lucide.ts

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

+ 1 - 1
packages/@core/base/shared/src/constants/vben.ts

@@ -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 首页地址

+ 2 - 2
packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap

@@ -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,

+ 2 - 2
packages/@core/preferences/src/config.ts

@@ -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,

+ 20 - 2
packages/@core/ui-kit/shadcn-ui/src/components/spine-text/spine-text.vue

@@ -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 {

+ 2 - 0
packages/effects/common-ui/package.json

@@ -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:",

+ 5 - 2
packages/effects/common-ui/src/components/captcha/index.ts

@@ -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';

+ 10 - 10
packages/effects/common-ui/src/components/captcha/point-selection-captcha.vue → packages/effects/common-ui/src/components/captcha/point-selection-captcha/index.vue

@@ -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>

+ 12 - 6
packages/effects/common-ui/src/components/captcha/captcha-card.vue → packages/effects/common-ui/src/components/captcha/point-selection-captcha/point-selection-captcha-card.vue

@@ -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"

+ 241 - 0
packages/effects/common-ui/src/components/captcha/slider-captcha/index.vue

@@ -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>

+ 62 - 0
packages/effects/common-ui/src/components/captcha/slider-captcha/slider-captcha-action.vue

@@ -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>

+ 38 - 0
packages/effects/common-ui/src/components/captcha/slider-captcha/slider-captcha-bar.vue

@@ -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>

+ 52 - 0
packages/effects/common-ui/src/components/captcha/slider-captcha/slider-captcha-content.vue

@@ -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>

+ 208 - 0
packages/effects/common-ui/src/components/captcha/slider-rotate-captcha/index.vue

@@ -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>

+ 105 - 21
packages/effects/common-ui/src/components/captcha/types.ts

@@ -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;
+}

+ 0 - 7
packages/effects/common-ui/src/components/captcha/utils.ts

@@ -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;
-};

+ 15 - 8
packages/locales/src/langs/en-US.json

@@ -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"
+    }
   }
 }

+ 15 - 8
packages/locales/src/langs/zh-CN.json

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

+ 3 - 0
playground/src/locales/langs/en-US.json

@@ -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",

+ 3 - 0
playground/src/locales/langs/zh-CN.json

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

+ 38 - 9
playground/src/router/routes/modules/examples.ts

@@ -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 it is too large
+ 0 - 1
playground/src/views/examples/captcha/base64.ts


+ 10 - 6
playground/src/views/examples/captcha/index.vue → playground/src/views/examples/captcha/point-selection-captcha.vue

@@ -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,
 });

+ 116 - 0
playground/src/views/examples/captcha/slider-captcha.vue

@@ -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>

+ 28 - 0
playground/src/views/examples/captcha/slider-rotate-captcha.vue

@@ -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>

+ 6 - 0
pnpm-lock.yaml

@@ -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))

Some files were not shown because too many files changed in this diff