ソースを参照

refactor: refactor code structure and improve demo page (#4389)

* feat: captcha example

* fix: fix lint errors

* chore: event handling and methods

* chore: add accessibility features ARIA labels and roles

* refactor: refactor code structure and improve captcha demo page

* feat: add captcha internationalization

* chore: 适配时间戳国际化展示

---------

Co-authored-by: vince <vince292007@gmail.com>
Squall2017 1 年間 前
コミット
5ba3a9dec2

+ 77 - 0
packages/effects/common-ui/src/components/captcha/captcha-card.vue

@@ -0,0 +1,77 @@
+<script setup lang="ts">
+import type { CaptchaCardProps } from './types';
+
+import { computed } from 'vue';
+
+import { $t } from '@vben/locales';
+import {
+  Card,
+  CardContent,
+  CardFooter,
+  CardHeader,
+  CardTitle,
+} from '@vben-core/shadcn-ui';
+
+import { parseValue } from './utils';
+
+const props = withDefaults(defineProps<CaptchaCardProps>(), {
+  height: '220px',
+  paddingX: '12px',
+  paddingY: '16px',
+  title: '',
+  width: '300px',
+});
+
+const emit = defineEmits<{
+  click: [MouseEvent];
+}>();
+
+const rootStyles = computed(() => ({
+  padding: `${parseValue(props.paddingY)}px ${parseValue(props.paddingX)}px`,
+  width: `${parseValue(props.width) + parseValue(props.paddingX) * 2}px`,
+}));
+
+const captchaStyles = computed(() => {
+  return {
+    height: `${parseValue(props.height)}px`,
+    width: `${parseValue(props.width)}px`,
+  };
+});
+
+function handleClick(e: MouseEvent) {
+  emit('click', e);
+}
+</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">
+        <template v-if="$slots.title">
+          <slot name="title">{{ $t('captcha.title') }}</slot>
+        </template>
+        <template v-else>
+          <span>{{ title }}</span>
+        </template>
+        <div class="flex items-center justify-end">
+          <slot name="extra"></slot>
+        </div>
+      </CardTitle>
+    </CardHeader>
+    <CardContent class="relative mt-2 flex w-full overflow-hidden rounded p-0">
+      <img
+        v-show="captchaImage"
+        :alt="$t('captcha.alt')"
+        :src="captchaImage"
+        :style="captchaStyles"
+        class="relative z-10"
+        @click="handleClick"
+      />
+      <div class="absolute inset-0">
+        <slot></slot>
+      </div>
+    </CardContent>
+    <CardFooter class="mt-2 flex justify-between p-0">
+      <slot name="footer"></slot>
+    </CardFooter>
+  </Card>
+</template>

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

@@ -1,2 +1,3 @@
+export { default as CaptchaCard } from './captcha-card.vue';
 export { default as PointSelectionCaptcha } from './point-selection-captcha.vue';
 export type * from './types';

+ 80 - 136
packages/effects/common-ui/src/components/captcha/point-selection-captcha.vue

@@ -1,107 +1,37 @@
 <script setup lang="ts">
-import type { CaptchaPoint } from './types';
+import type { CaptchaPoint, PointSelectionCaptchaProps } from './types';
 
-import { computed, ref } from 'vue';
+import { ref } from 'vue';
 
 import { RotateCw } from '@vben/icons';
-import {
-  Card,
-  CardContent,
-  CardFooter,
-  CardHeader,
-  CardTitle,
-  VbenButton,
-  VbenIconButton,
-} from '@vben-core/shadcn-ui';
-
-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;
-}
+import { $t } from '@vben/locales';
+import { VbenButton, VbenIconButton } from '@vben-core/shadcn-ui';
+
+import { CaptchaCard } from '.';
 
-const props = withDefaults(defineProps<Props>(), {
+const props = withDefaults(defineProps<PointSelectionCaptchaProps>(), {
   height: '220px',
-  hintHeight: '40px',
-  hintWidth: '150px',
+  hintImage: '',
+  hintText: '',
   paddingX: '12px',
   paddingY: '16px',
-  title: '请按图依次点击',
+  showConfirm: false,
+  title: '',
   width: '300px',
 });
 
 const emit = defineEmits<{
-  click: [number, number];
+  click: [CaptchaPoint];
   confirm: [Array<CaptchaPoint>, 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`,
-}));
+if (!props.hintImage && !props.hintText) {
+  throw new Error('At least one of hint image or hint text must be provided');
+}
 
-const captchaStyles = computed(() => {
-  return {
-    height: `${parseValue(props.height)}px`,
-    width: `${parseValue(props.width)}px`,
-  };
-});
+const points = ref<CaptchaPoint[]>([]);
+const POINT_OFFSET = 11;
 
 function getElementPosition(element: HTMLElement) {
   let posX = 0;
@@ -129,8 +59,6 @@ function getElementPosition(element: HTMLElement) {
     y: posY,
   };
 }
-const points = ref<CaptchaPoint[]>([]);
-const POINT_OFFSET = 11;
 
 function handleClick(e: MouseEvent) {
   try {
@@ -151,15 +79,16 @@ function handleClick(e: MouseEvent) {
     const x = Math.ceil(xPos);
     const y = Math.ceil(yPos);
 
-    points.value.push({
+    const point = {
       i: points.value.length,
       t: Date.now(),
       x,
       y,
-    });
+    };
+    points.value.push(point);
 
-    emit('click', x, y);
-    e.cancelBubble = true;
+    emit('click', point);
+    e.stopPropagation();
     e.preventDefault();
   } catch (error) {
     console.error('Error in handleClick:', error);
@@ -184,6 +113,7 @@ function handleRefresh() {
 }
 
 function handleConfirm() {
+  if (!props.showConfirm) return;
   try {
     emit('confirm', points.value, clear);
   } catch (error) {
@@ -192,50 +122,64 @@ function handleConfirm() {
 }
 </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">
+  <CaptchaCard
+    :captcha-image="captchaImage"
+    :height="height"
+    :padding-x="paddingX"
+    :padding-y="paddingY"
+    :title="title"
+    :width="width"
+    @click="handleClick"
+  >
+    <template #title>
+      <slot name="title">{{ $t('captcha.title') }}</slot>
+    </template>
+
+    <template #extra>
+      <VbenIconButton
+        :aria-label="$t('captcha.refreshAriaLabel')"
+        class="ml-1"
+        @click="handleRefresh"
+      >
         <RotateCw class="size-5" />
       </VbenIconButton>
-      <VbenButton aria-label="确认选择" @click="handleConfirm">
-        确认
+      <VbenButton
+        v-if="showConfirm"
+        :aria-label="$t('captcha.confirmAriaLabel')"
+        class="ml-2"
+        size="sm"
+        @click="handleConfirm"
+      >
+        {{ $t('captcha.confirm') }}
       </VbenButton>
-    </CardFooter>
-  </Card>
+    </template>
+
+    <div
+      v-for="(point, index) in points"
+      :key="index"
+      :aria-label="$t('captcha.pointAriaLabel') + (index + 1)"
+      :style="{
+        top: `${point.y - POINT_OFFSET}px`,
+        left: `${point.x - POINT_OFFSET}px`,
+      }"
+      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>
+    <template #footer>
+      <img
+        v-if="hintImage"
+        :alt="$t('captcha.alt')"
+        :src="hintImage"
+        class="h-10 w-full rounded border border-solid border-slate-200"
+      />
+      <div
+        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}】` }}
+      </div>
+    </template>
+  </CaptchaCard>
 </template>

+ 85 - 2
packages/effects/common-ui/src/components/captcha/types.ts

@@ -1,6 +1,89 @@
-export interface CaptchaPoint {
-  i: number;
+export interface CaptchaData {
+  /**
+   * x
+   */
   x: number;
+  /**
+   * y
+   */
   y: number;
+  /**
+   * 时间戳
+   */
   t: number;
 }
+export interface CaptchaPoint extends CaptchaData {
+  /**
+   * 数据索引
+   */
+  i: number;
+}
+export interface CaptchaCardProps {
+  /**
+   * 验证码图片
+   */
+  captchaImage: string;
+  /**
+   * 验证码图片高度
+   * @default '220px'
+   */
+  height?: number | string;
+  /**
+   * 水平内边距
+   * @default '12px'
+   */
+  paddingX?: number | string;
+  /**
+   * 垂直内边距
+   * @default '16px'
+   */
+  paddingY?: number | string;
+  /**
+   * 标题
+   * @default '请按图依次点击'
+   */
+  title?: string;
+  /**
+   * 验证码图片宽度
+   * @default '300px'
+   */
+  width?: number | string;
+}
+
+export interface PointSelectionCaptchaProps extends CaptchaCardProps {
+  /**
+   * 是否展示确定按钮
+   * @default false
+   */
+  showConfirm?: boolean;
+  /**
+   * 提示图片
+   * @default ''
+   */
+  hintImage?: string;
+  /**
+   * 提示文本
+   * @default ''
+   */
+  hintText?: string;
+}
+
+/**
+ * TODO: 滑动验证码
+ */
+// export interface SlideCaptchaProps extends CaptchaCardProps {
+//   /**
+//    * 瓦片图片高度
+//    * @default '40px'
+//    */
+//   tileHeight?: number | string;
+//   /**
+//    * 瓦片图片宽度
+//    * @default '150px'
+//    */
+//   tileWidth?: number | string;
+//   /**
+//    * 瓦片图片
+//    */
+//   tileImage: string;
+// }

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

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

+ 9 - 0
packages/locales/src/langs/en-US.json

@@ -311,5 +311,14 @@
       "sidebarToggle": "Enable Sidebar Toggle",
       "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"
   }
 }

+ 9 - 0
packages/locales/src/langs/zh-CN.json

@@ -311,5 +311,14 @@
       "sidebarToggle": "启用侧边栏切换",
       "lockScreen": "启用锁屏"
     }
+  },
+  "captcha": {
+    "alt": "支持img标签src属性值",
+    "title": "请完成安全验证",
+    "refreshAriaLabel": "刷新验证码",
+    "confirmAriaLabel": "确认选择",
+    "confirm": "确认",
+    "pointAriaLabel": "点击点",
+    "clickInOrder": "请依次点击"
   }
 }

+ 21 - 1
playground/src/locales/langs/en-US.json

@@ -81,7 +81,27 @@
         "custom": "Custom Component"
       },
       "captcha": {
-        "title": "Captcha"
+        "title": "Captcha",
+        "captchaCardTitle": "Please complete the security verification",
+        "pageDescription": "Verify user identity by clicking on specific locations in the image.",
+        "pageTitle": "Captcha Component Example",
+        "basic": "Basic Usage",
+        "titlePlaceholder": "Captcha Title Text",
+        "captchaImageUrlPlaceholder": "Captcha Image (supports img tag src attribute value)",
+        "hintImage": "Hint Image",
+        "hintText": "Hint Text",
+        "hintImagePlaceholder": "Hint Image (supports img tag src attribute value)",
+        "hintTextPlaceholder": "Hint Text",
+        "showConfirm": "Show Confirm",
+        "hideConfirm": "Hide Confirm",
+        "widthPlaceholder": "Captcha Image Width Default 300px",
+        "heightPlaceholder": "Captcha Image Height Default 220px",
+        "paddingXPlaceholder": "Horizontal Padding Default 12px",
+        "paddingYPlaceholder": "Vertical Padding Default 16px",
+        "index": "Index:",
+        "timestamp": "Timestamp:",
+        "x": "x:",
+        "y": "y:"
       }
     }
   }

+ 21 - 1
playground/src/locales/langs/zh-CN.json

@@ -81,7 +81,27 @@
         "custom": "自定义组件"
       },
       "captcha": {
-        "title": "验证码"
+        "title": "验证码",
+        "captchaCardTitle": "请完成安全验证",
+        "pageDescription": "通过点击图片中的特定位置来验证用户身份。",
+        "pageTitle": "验证码组件示例",
+        "basic": "基本使用",
+        "titlePlaceholder": "验证码标题文案",
+        "captchaImageUrlPlaceholder": "验证码图片(支持img标签src属性值)",
+        "hintImage": "提示图片",
+        "hintText": "提示文本",
+        "hintImagePlaceholder": "提示图片(支持img标签src属性值)",
+        "hintTextPlaceholder": "提示文本",
+        "showConfirm": "展示确认",
+        "hideConfirm": "隐藏确认",
+        "widthPlaceholder": "验证码图片宽度 默认300px",
+        "heightPlaceholder": "验证码图片高度 默认220px",
+        "paddingXPlaceholder": "水平内边距 默认12px",
+        "paddingYPlaceholder": "垂直内边距 默认16px",
+        "index": "索引:",
+        "timestamp": "时间戳:",
+        "x": "x:",
+        "y": "y:"
       }
     }
   }

+ 149 - 17
playground/src/views/examples/captcha/index.vue

@@ -1,45 +1,177 @@
 <script lang="ts" setup>
 import type { CaptchaPoint } from '@vben/common-ui';
 
-import { ref } from 'vue';
+import { reactive, ref } from 'vue';
 
 import { Page, PointSelectionCaptcha } from '@vben/common-ui';
 
-import { Card } from 'ant-design-vue';
+import { Card, Input, InputNumber, message, Switch } from 'ant-design-vue';
+
+import { $t } from '#/locales';
 
 import { captchaImage, hintImage } from './base64';
 
 const selectedPoints = ref<CaptchaPoint[]>([]);
+const params = reactive({
+  captchaImage,
+  captchaImageUrl: '',
+  height: undefined,
+  hintImage,
+  hintImageUrl: '',
+  hintText: '唇,燕,碴,找',
+  paddingX: undefined,
+  paddingY: undefined,
+  showConfirm: true,
+  showHintImage: true,
+  title: '',
+  width: undefined,
+});
 const handleConfirm = (points: CaptchaPoint[], clear: () => void) => {
-  selectedPoints.value = points;
+  message.success({
+    content: `captcha points: ${JSON.stringify(points)}`,
+  });
   clear();
+  selectedPoints.value = [];
 };
 const handleRefresh = () => {
   selectedPoints.value = [];
 };
+const handleClick = (point: CaptchaPoint) => {
+  selectedPoints.value.push(point);
+};
 </script>
 
 <template>
   <Page
-    description="通过点击图片中的特定位置来验证用户身份。"
-    title="验证码组件示例"
+    :description="$t('page.examples.captcha.pageDescription')"
+    :title="$t('page.examples.captcha.pageTitle')"
   >
-    <Card class="mb-4" title="基本使用">
+    <Card :title="$t('page.examples.captcha.basic')" class="mb-4">
+      <div class="mb-3 flex items-center justify-start">
+        <Input
+          v-model:value="params.title"
+          :placeholder="$t('page.examples.captcha.titlePlaceholder')"
+          class="w-64"
+        />
+        <Input
+          v-model:value="params.captchaImageUrl"
+          :placeholder="$t('page.examples.captcha.captchaImageUrlPlaceholder')"
+          class="ml-8 w-64"
+        />
+        <div class="ml-8 flex w-96 items-center">
+          <Switch
+            v-model:checked="params.showHintImage"
+            :checked-children="$t('page.examples.captcha.hintImage')"
+            :un-checked-children="$t('page.examples.captcha.hintText')"
+            class="mr-4 w-40"
+          />
+          <Input
+            v-show="params.showHintImage"
+            v-model:value="params.hintImageUrl"
+            :placeholder="$t('page.examples.captcha.hintImagePlaceholder')"
+          />
+          <Input
+            v-show="!params.showHintImage"
+            v-model:value="params.hintText"
+            :placeholder="$t('page.examples.captcha.hintTextPlaceholder')"
+          />
+        </div>
+
+        <Switch
+          v-model:checked="params.showConfirm"
+          :checked-children="$t('page.examples.captcha.showConfirm')"
+          :un-checked-children="$t('page.examples.captcha.hideConfirm')"
+          class="ml-8 w-28"
+        />
+      </div>
+      <div class="mb-3 flex items-center justify-start">
+        <div>
+          <InputNumber
+            v-model:value="params.width"
+            :min="1"
+            :placeholder="$t('page.examples.captcha.widthPlaceholder')"
+            :precision="0"
+            :step="1"
+            class="w-64"
+          >
+            <template #addonAfter>px</template>
+          </InputNumber>
+        </div>
+        <div class="ml-8">
+          <InputNumber
+            v-model:value="params.height"
+            :min="1"
+            :placeholder="$t('page.examples.captcha.heightPlaceholder')"
+            :precision="0"
+            :step="1"
+            class="w-64"
+          >
+            <template #addonAfter>px</template>
+          </InputNumber>
+        </div>
+        <div class="ml-8">
+          <InputNumber
+            v-model:value="params.paddingX"
+            :min="1"
+            :placeholder="$t('page.examples.captcha.paddingXPlaceholder')"
+            :precision="0"
+            :step="1"
+            class="w-64"
+          >
+            <template #addonAfter>px</template>
+          </InputNumber>
+        </div>
+        <div class="ml-8">
+          <InputNumber
+            v-model:value="params.paddingY"
+            :min="1"
+            :placeholder="$t('page.examples.captcha.paddingYPlaceholder')"
+            :precision="0"
+            :step="1"
+            class="w-64"
+          >
+            <template #addonAfter>px</template>
+          </InputNumber>
+        </div>
+      </div>
+
       <PointSelectionCaptcha
-        :captcha-image="captchaImage"
-        :hint-image="hintImage"
+        :captcha-image="params.captchaImageUrl || params.captchaImage"
+        :height="params.height || 220"
+        :hint-image="
+          params.showHintImage ? params.hintImageUrl || params.hintImage : ''
+        "
+        :hint-text="params.hintText"
+        :padding-x="params.paddingX"
+        :padding-y="params.paddingY"
+        :show-confirm="params.showConfirm"
+        :width="params.width || 300"
         class="float-left"
+        @click="handleClick"
         @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>
+      >
+        <template #title>
+          {{ params.title || $t('page.examples.captcha.captchaCardTitle') }}
+        </template>
+      </PointSelectionCaptcha>
+
+      <ol class="float-left p-5">
+        <li v-for="point in selectedPoints" :key="point.i" class="flex">
+          <span class="mr-3 w-16">{{
+            $t('page.examples.captcha.index') + point.i
+          }}</span>
+          <span class="mr-3 w-52">{{
+            $t('page.examples.captcha.timestamp') + point.t
+          }}</span>
+          <span class="mr-3 w-16">{{
+            $t('page.examples.captcha.x') + point.x
+          }}</span>
+          <span class="mr-3 w-16">{{
+            $t('page.examples.captcha.y') + point.y
+          }}</span>
+        </li>
+      </ol>
     </Card>
   </Page>
 </template>