Browse Source

feat: captcha example (#4330)

* feat: captcha example

* fix: fix lint errors

* chore: event handling and methods

* chore: add accessibility features ARIA labels and roles

---------

Co-authored-by: vince <vince292007@gmail.com>
Squall2017 6 months ago
parent
commit
b1636405fc

+ 8 - 0
packages/effects/common-ui/src/components/captcha/index.ts

@@ -0,0 +1,8 @@
+export { default as PointSelectionCaptcha } from './point-selection-captcha.vue';
+export interface Point {
+  i: number;
+  x: number;
+  y: number;
+  t: number;
+}
+export type ClearFunction = () => void;

+ 241 - 0
packages/effects/common-ui/src/components/captcha/point-selection-captcha.vue

@@ -0,0 +1,241 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+
+import { VbenButton } from '@vben/common-ui';
+import { SvgRefreshIcon } from '@vben/icons';
+import {
+  Card,
+  CardContent,
+  CardFooter,
+  CardHeader,
+  CardTitle,
+  VbenIconButton,
+} from '@vben-core/shadcn-ui';
+
+import { type Point } from '.';
+
+interface Props {
+  /**
+   * 点选的图片
+   * @default '12px'
+   */
+  captchaImage: string;
+  /**
+   * 验证码图片高度
+   * @default '220px'
+   */
+  height?: number | string;
+  /**
+   * 提示图片高度
+   * @default '40px'
+   */
+  hintHeight?: number | string;
+  /**
+   * 提示图片宽度
+   * @default '150px'
+   */
+  hintWidth?: number | string;
+  /**
+   * 提示图片
+   * @default '12px'
+   */
+  hintImage: string;
+  /**
+   * 水平内边距
+   * @default '12px'
+   */
+  paddingX?: number | string;
+  /**
+   * 垂直内边距
+   * @default '16px'
+   */
+  paddingY?: number | string;
+  /**
+   * 标题
+   * @default '请按图依次点击'
+   */
+  title?: string;
+  /**
+   * 验证码图片宽度
+   * @default '300px'
+   */
+  width?: number | string;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  height: '220px',
+  hintHeight: '40px',
+  hintWidth: '150px',
+  paddingX: '12px',
+  paddingY: '16px',
+  title: '请按图依次点击',
+  width: '300px',
+});
+
+const emit = defineEmits<{
+  click: [number, number];
+  confirm: [Array<Point>, clear: () => void];
+  refresh: [];
+}>();
+
+const parseValue = (value: number | string) => {
+  if (typeof value === 'number') {
+    return value;
+  }
+  const parsed = Number.parseFloat(value);
+  return Number.isNaN(parsed) ? 0 : parsed;
+};
+
+const rootStyles = computed(() => ({
+  padding: `${parseValue(props.paddingY)}px ${parseValue(props.paddingX)}px`,
+  width: `${parseValue(props.width) - parseValue(props.paddingX) * 2}px`,
+}));
+
+const hintStyles = computed(() => ({
+  height: `${parseValue(props.hintHeight)}px`,
+  width: `${parseValue(props.hintWidth)}px`,
+}));
+
+const captchaStyles = computed(() => {
+  return {
+    height: `${parseValue(props.height)}px`,
+    width: `${parseValue(props.width)}px`,
+  };
+});
+
+function getElementPosition(element: HTMLElement) {
+  let posX = 0;
+  let posY = 0;
+  if (element.getBoundingClientRect) {
+    const rect = element.getBoundingClientRect();
+    const doc = document.documentElement;
+    posX =
+      rect.left +
+      Math.max(doc.scrollLeft, document.body.scrollLeft) -
+      doc.clientLeft;
+    posY =
+      rect.top +
+      Math.max(doc.scrollTop, document.body.scrollTop) -
+      doc.clientTop;
+  } else {
+    while (element !== document.body) {
+      posX += element.offsetLeft;
+      posY += element.offsetTop;
+      element = element.offsetParent as HTMLElement;
+    }
+  }
+  return {
+    x: posX,
+    y: posY,
+  };
+}
+const points = ref<Point[]>([]);
+const POINT_OFFSET = 11;
+
+function handleClick(e: any | Event) {
+  try {
+    const dom = e.currentTarget as HTMLElement;
+    if (!dom) throw new Error('Element not found');
+
+    const { x: domX, y: domY } = getElementPosition(dom);
+
+    const mouseX = e.pageX || e.clientX;
+    const mouseY = e.pageY || e.clientY;
+
+    if (mouseX === undefined || mouseY === undefined)
+      throw new Error('Mouse coordinates not found');
+
+    const xPos = mouseX - domX;
+    const yPos = mouseY - domY;
+
+    const x = Math.ceil(xPos);
+    const y = Math.ceil(yPos);
+
+    points.value.push({
+      i: points.value.length,
+      t: Date.now(),
+      x,
+      y,
+    });
+
+    emit('click', x, y);
+    e.cancelBubble = true;
+    e.preventDefault();
+  } catch (error) {
+    console.error('Error in handleClick:', error);
+  }
+}
+
+function clear() {
+  try {
+    points.value = [];
+  } catch (error) {
+    console.error('Error in clear:', error);
+  }
+}
+
+function handleRefresh() {
+  try {
+    clear();
+    emit('refresh');
+  } catch (error) {
+    console.error('Error in handleRefresh:', error);
+  }
+}
+
+function handleConfirm() {
+  try {
+    emit('confirm', points.value, clear);
+  } catch (error) {
+    console.error('Error in handleConfirm:', error);
+  }
+}
+</script>
+<template>
+  <Card :style="rootStyles" aria-labelledby="captcha-title" role="region">
+    <CardHeader class="p-0">
+      <CardTitle id="captcha-title" class="flex items-center justify-between">
+        <span>{{ title }}</span>
+        <img
+          v-show="hintImage"
+          :src="hintImage"
+          :style="hintStyles"
+          alt="提示图片"
+        />
+      </CardTitle>
+    </CardHeader>
+    <CardContent class="relative mt-2 flex w-full overflow-hidden rounded p-0">
+      <img
+        v-show="captchaImage"
+        :src="captchaImage"
+        :style="captchaStyles"
+        alt="验证码图片"
+        class="relative z-10"
+        @click="handleClick"
+      />
+      <div class="absolute inset-0">
+        <div
+          v-for="(point, index) in points"
+          :key="index"
+          :style="{
+            top: `${point.y - POINT_OFFSET}px`,
+            left: `${point.x - POINT_OFFSET}px`,
+          }"
+          aria-label="点击点 {{ index + 1 }}"
+          class="bg-primary text-primary-50 border-primary-50 absolute z-20 flex h-5 w-5 cursor-default items-center justify-center rounded-full border-2"
+          role="button"
+        >
+          {{ index + 1 }}
+        </div>
+      </div>
+    </CardContent>
+    <CardFooter class="mt-2 flex justify-between p-0">
+      <VbenIconButton aria-label="刷新验证码" @click="handleRefresh">
+        <SvgRefreshIcon class="size-6" />
+      </VbenIconButton>
+      <VbenButton aria-label="确认选择" @click="handleConfirm">
+        确认
+      </VbenButton>
+    </CardFooter>
+  </Card>
+</template>

+ 1 - 0
packages/effects/common-ui/src/components/index.ts

@@ -1,3 +1,4 @@
+export * from './captcha';
 export * from './ellipsis-text';
 export * from './page';
 export * from '@vben-core/popup-ui';

+ 1 - 0
packages/icons/src/svg/icons/refresh.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M3.68 11.333h-.75zm0 1.667l-.528.532a.75.75 0 0 0 1.056 0zm2.208-1.134A.75.75 0 1 0 4.83 10.8zM2.528 10.8a.75.75 0 0 0-1.056 1.065zm16.088-3.408a.75.75 0 1 0 1.277-.786zM12.079 2.25c-5.047 0-9.15 4.061-9.15 9.083h1.5c0-4.182 3.42-7.583 7.65-7.583zm-9.15 9.083V13h1.5v-1.667zm1.28 2.2l1.679-1.667L4.83 10.8l-1.68 1.667zm0-1.065L2.528 10.8l-1.057 1.065l1.68 1.666zm15.684-5.86A9.16 9.16 0 0 0 12.08 2.25v1.5a7.66 7.66 0 0 1 6.537 3.643zM20.314 11l.527-.533a.75.75 0 0 0-1.054 0zM18.1 12.133a.75.75 0 0 0 1.055 1.067zm3.373 1.067a.75.75 0 1 0 1.054-1.067zM5.318 16.606a.75.75 0 1 0-1.277.788zm6.565 5.144c5.062 0 9.18-4.058 9.18-9.083h-1.5c0 4.18-3.43 7.583-7.68 7.583zm9.18-9.083V11h-1.5v1.667zm-1.276-2.2L18.1 12.133l1.055 1.067l1.686-1.667zm0 1.066l1.686 1.667l1.054-1.067l-1.686-1.666zM4.04 17.393a9.2 9.2 0 0 0 7.842 4.357v-1.5a7.7 7.7 0 0 1-6.565-3.644z"/></svg>

+ 2 - 0
packages/icons/src/svg/index.ts

@@ -10,6 +10,7 @@ const SvgDownloadIcon = createIconifyIcon('svg:download');
 const SvgCardIcon = createIconifyIcon('svg:card');
 const SvgBellIcon = createIconifyIcon('svg:bell');
 const SvgCakeIcon = createIconifyIcon('svg:cake');
+const SvgRefreshIcon = createIconifyIcon('svg:refresh');
 
 export {
   SvgAvatar1Icon,
@@ -20,4 +21,5 @@ export {
   SvgCakeIcon,
   SvgCardIcon,
   SvgDownloadIcon,
+  SvgRefreshIcon,
 };

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

@@ -71,6 +71,9 @@
       },
       "ellipsis": {
         "title": "EllipsisText"
+      },
+      "captcha": {
+        "title": "Captcha"
       }
     }
   }

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

@@ -71,6 +71,9 @@
       },
       "ellipsis": {
         "title": "文本省略"
+      },
+      "captcha": {
+        "title": "验证码"
       }
     }
   }

+ 8 - 0
playground/src/router/routes/modules/examples.ts

@@ -39,6 +39,14 @@ const routes: RouteRecordRaw[] = [
           title: $t('page.examples.ellipsis.title'),
         },
       },
+      {
+        name: 'CaptchaExample',
+        path: '/examples/captcha',
+        component: () => import('#/views/examples/captcha/index.vue'),
+        meta: {
+          title: $t('page.examples.captcha.title'),
+        },
+      },
     ],
   },
 ];

File diff suppressed because it is too large
+ 1 - 0
playground/src/views/examples/captcha/base64.ts


+ 43 - 0
playground/src/views/examples/captcha/index.vue

@@ -0,0 +1,43 @@
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+import { Page, type Point, PointSelectionCaptcha } from '@vben/common-ui';
+
+import { Card } from 'ant-design-vue';
+
+import { captchaImage, hintImage } from './base64';
+
+const selectedPoints = ref<Point[]>([]);
+const handleConfirm = (points: Point[], clear: () => void) => {
+  selectedPoints.value = points;
+  clear();
+};
+const handleRefresh = () => {
+  selectedPoints.value = [];
+};
+</script>
+
+<template>
+  <Page
+    description="通过点击图片中的特定位置来验证用户身份。"
+    title="验证码组件示例"
+  >
+    <Card class="mb-4" title="基本使用">
+      <PointSelectionCaptcha
+        :captcha-image="captchaImage"
+        :hint-image="hintImage"
+        class="float-left"
+        @confirm="handleConfirm"
+        @refresh="handleRefresh"
+      />
+      <div class="float-left p-5">
+        <div v-for="point in selectedPoints" :key="point.i" class="flex">
+          <span class="mr-3 w-16">索引:{{ point.i }}</span>
+          <span class="mr-3 w-44">时间戳:{{ point.t }}</span>
+          <span class="mr-3 w-16">x:{{ point.x }}</span>
+          <span class="mr-3 w-16">y:{{ point.y }}</span>
+        </div>
+      </div>
+    </Card>
+  </Page>
+</template>

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