فهرست منبع

Merge branch 'main' into feature/scroll_to_the_error_field

panda7 2 ماه پیش
والد
کامیت
d4786f3f75

+ 4 - 4
docs/src/components/common-ui/vben-drawer.md

@@ -22,7 +22,7 @@ outline: deep
 
 ## 基础用法
 
-使用 `useVbenDrawer` 创建最基础的模态框
+使用 `useVbenDrawer` 创建最基础的抽屉
 
 <DemoPreview dir="demos/vben-drawer/basic" />
 
@@ -52,7 +52,7 @@ Drawer 内的内容一般业务中,会比较复杂,所以我们可以将 dra
 
 ::: info 注意
 
-- `VbenDrawer` 组件对参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenDrawer参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
+- `VbenDrawer` 组件对参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenDrawer参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
 - 如果你使用到了 `connectedComponent` 参数,那么会存在 2 个`useVbenDrawer`, 此时,如果同时设置了相同的参数,那么以内部为准(也就是没有设置 connectedComponent 的代码)。比如 同时设置了 `onConfirm`,那么以内部的 `onConfirm` 为准。`onOpenChange`事件除外,内外都会触发。
 - 使用了`connectedComponent`参数时,可以配置`destroyOnClose`属性来决定当关闭弹窗时,是否要销毁`connectedComponent`组件(重新创建`connectedComponent`组件,这将会把其内部所有的变量、状态、数据等恢复到初始状态。)。
 - 如果抽屉的默认行为不符合你的预期,可以在`src\bootstrap.ts`中修改`setDefaultDrawerProps`的参数来设置默认的属性,如默认隐藏全屏按钮,修改默认ZIndex等。
@@ -77,7 +77,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
 | 属性名 | 描述 | 类型 | 默认值 |
 | --- | --- | --- | --- |
 | appendToMain | 是否挂载到内容区域(默认挂载到body) | `boolean` | `false` |
-| connectedComponent | 连接另一个Modal组件 | `Component` | - |
+| connectedComponent | 连接另一个Drawer组件 | `Component` | - |
 | destroyOnClose | 关闭时销毁 | `boolean` | `false` |
 | title | 标题 | `string\|slot` | - |
 | titleTooltip | 标题提示信息 | `string\|slot` | - |
@@ -96,7 +96,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
 | cancelText | 取消按钮文本 | `string\|slot` | `取消` |
 | placement | 抽屉弹出位置 | `'left'\|'right'\|'top'\|'bottom'` | `right` |
 | showCancelButton | 显示取消按钮 | `boolean` | `true` |
-| showConfirmButton | 显示确认按钮文本 | `boolean` | `true` |
+| showConfirmButton | 显示确认按钮 | `boolean` | `true` |
 | class | modal的class,宽度通过这个配置 | `string` | - |
 | contentClass | modal内容区域的class | `string` | - |
 | footerClass | modal底部区域的class | `string` | - |

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

@@ -3,4 +3,5 @@ export { default as PointSelectionCaptchaCard } from './point-selection-captcha/
 
 export { default as SliderCaptcha } from './slider-captcha/index.vue';
 export { default as SliderRotateCaptcha } from './slider-rotate-captcha/index.vue';
+export { default as SliderTranslateCaptcha } from './slider-translate-captcha/index.vue';
 export type * from './types';

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

@@ -0,0 +1,311 @@
+<script setup lang="ts">
+import type {
+  CaptchaVerifyPassingData,
+  SliderCaptchaActionType,
+  SliderRotateVerifyPassingData,
+  SliderTranslateCaptchaProps,
+} from '../types';
+
+import {
+  computed,
+  onMounted,
+  reactive,
+  ref,
+  unref,
+  useTemplateRef,
+  watch,
+} from 'vue';
+
+import { $t } from '@vben/locales';
+
+import SliderCaptcha from '../slider-captcha/index.vue';
+
+const props = withDefaults(defineProps<SliderTranslateCaptchaProps>(), {
+  defaultTip: '',
+  canvasWidth: 420,
+  canvasHeight: 280,
+  squareLength: 42,
+  circleRadius: 10,
+  src: '',
+  diffDistance: 3,
+});
+
+const emit = defineEmits<{
+  success: [CaptchaVerifyPassingData];
+}>();
+
+const PI: number = Math.PI;
+enum CanvasOpr {
+  // eslint-disable-next-line no-unused-vars
+  Clip = 'clip',
+  // eslint-disable-next-line no-unused-vars
+  Fill = 'fill',
+}
+
+const modalValue = defineModel<boolean>({ default: false });
+
+const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
+const puzzleCanvasRef = useTemplateRef<HTMLCanvasElement>('puzzleCanvasRef');
+const pieceCanvasRef = useTemplateRef<HTMLCanvasElement>('pieceCanvasRef');
+
+const state = reactive({
+  dragging: false,
+  startTime: 0,
+  endTime: 0,
+  pieceX: 0,
+  pieceY: 0,
+  moveDistance: 0,
+  isPassing: false,
+  showTip: false,
+});
+
+const left = ref('0');
+
+const pieceStyle = computed(() => {
+  return {
+    left: left.value,
+  };
+});
+
+function setLeft(val: string) {
+  left.value = val;
+}
+
+const verifyTip = computed(() => {
+  return state.isPassing
+    ? $t('ui.captcha.sliderTranslateSuccessTip', [
+        ((state.endTime - state.startTime) / 1000).toFixed(1),
+      ])
+    : $t('ui.captcha.sliderTranslateFailTip');
+});
+function handleStart() {
+  state.startTime = Date.now();
+}
+
+function handleDragBarMove(data: SliderRotateVerifyPassingData) {
+  state.dragging = true;
+  const { moveX } = data;
+  state.moveDistance = moveX;
+  setLeft(`${moveX}px`);
+}
+
+function handleDragEnd() {
+  const { pieceX } = state;
+  const { diffDistance } = props;
+
+  if (Math.abs(pieceX - state.moveDistance) >= (diffDistance || 3)) {
+    setLeft('0');
+    state.moveDistance = 0;
+  } else {
+    checkPass();
+  }
+  state.showTip = true;
+  state.dragging = false;
+}
+
+function checkPass() {
+  state.isPassing = true;
+  state.endTime = Date.now();
+}
+
+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;
+  },
+);
+
+function resetCanvas() {
+  const { canvasWidth, canvasHeight } = props;
+  const puzzleCanvas = unref(puzzleCanvasRef);
+  const pieceCanvas = unref(pieceCanvasRef);
+  if (!puzzleCanvas || !pieceCanvas) return;
+  pieceCanvas.width = canvasWidth;
+  const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
+  // Canvas2D: Multiple readback operations using getImageData
+  // are faster with the willReadFrequently attribute set to true.
+  // See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
+  const pieceCanvasCtx = pieceCanvas.getContext('2d', {
+    willReadFrequently: true,
+  });
+  if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
+  puzzleCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
+  pieceCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
+}
+
+function initCanvas() {
+  const { canvasWidth, canvasHeight, squareLength, circleRadius, src } = props;
+  const puzzleCanvas = unref(puzzleCanvasRef);
+  const pieceCanvas = unref(pieceCanvasRef);
+  if (!puzzleCanvas || !pieceCanvas) return;
+  const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
+  // Canvas2D: Multiple readback operations using getImageData
+  // are faster with the willReadFrequently attribute set to true.
+  // See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
+  const pieceCanvasCtx = pieceCanvas.getContext('2d', {
+    willReadFrequently: true,
+  });
+  if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
+  const img = new Image();
+  // 解决跨域
+  img.crossOrigin = 'Anonymous';
+  img.src = src;
+  img.addEventListener('load', () => {
+    draw(puzzleCanvasCtx, pieceCanvasCtx);
+    puzzleCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
+    pieceCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
+    const pieceLength = squareLength + 2 * circleRadius + 3;
+    const sx = state.pieceX;
+    const sy = state.pieceY - 2 * circleRadius - 1;
+    const imageData = pieceCanvasCtx.getImageData(
+      sx,
+      sy,
+      pieceLength,
+      pieceLength,
+    );
+    pieceCanvas.width = pieceLength;
+    pieceCanvasCtx.putImageData(imageData, 0, sy);
+    setLeft('0');
+  });
+}
+
+function getRandomNumberByRange(start: number, end: number) {
+  return Math.round(Math.random() * (end - start) + start);
+}
+
+// 绘制拼图
+function draw(ctx1: CanvasRenderingContext2D, ctx2: CanvasRenderingContext2D) {
+  const { canvasWidth, canvasHeight, squareLength, circleRadius } = props;
+  state.pieceX = getRandomNumberByRange(
+    squareLength + 2 * circleRadius,
+    canvasWidth - (squareLength + 2 * circleRadius),
+  );
+  state.pieceY = getRandomNumberByRange(
+    3 * circleRadius,
+    canvasHeight - (squareLength + 2 * circleRadius),
+  );
+  drawPiece(ctx1, state.pieceX, state.pieceY, CanvasOpr.Fill);
+  drawPiece(ctx2, state.pieceX, state.pieceY, CanvasOpr.Clip);
+}
+
+// 绘制拼图切块
+function drawPiece(
+  ctx: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  opr: CanvasOpr,
+) {
+  const { squareLength, circleRadius } = props;
+  ctx.beginPath();
+  ctx.moveTo(x, y);
+  ctx.arc(
+    x + squareLength / 2,
+    y - circleRadius + 2,
+    circleRadius,
+    0.72 * PI,
+    2.26 * PI,
+  );
+  ctx.lineTo(x + squareLength, y);
+  ctx.arc(
+    x + squareLength + circleRadius - 2,
+    y + squareLength / 2,
+    circleRadius,
+    1.21 * PI,
+    2.78 * PI,
+  );
+  ctx.lineTo(x + squareLength, y + squareLength);
+  ctx.lineTo(x, y + squareLength);
+  ctx.arc(
+    x + circleRadius - 2,
+    y + squareLength / 2,
+    circleRadius + 0.4,
+    2.76 * PI,
+    1.24 * PI,
+    true,
+  );
+  ctx.lineTo(x, y);
+  ctx.lineWidth = 2;
+  ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
+  ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
+  ctx.stroke();
+  opr === CanvasOpr.Clip ? ctx.clip() : ctx.fill();
+  ctx.globalCompositeOperation = 'destination-over';
+}
+
+function resume() {
+  state.showTip = false;
+  const basicEl = unref(slideBarRef);
+  if (!basicEl) {
+    return;
+  }
+  state.dragging = false;
+  state.isPassing = false;
+  state.pieceX = 0;
+  state.pieceY = 0;
+
+  basicEl.resume();
+  resetCanvas();
+  initCanvas();
+}
+
+onMounted(() => {
+  initCanvas();
+});
+</script>
+
+<template>
+  <div class="relative flex flex-col items-center">
+    <div
+      class="border-border relative flex cursor-pointer overflow-hidden border shadow-md"
+    >
+      <canvas
+        ref="puzzleCanvasRef"
+        :width="canvasWidth"
+        :height="canvasHeight"
+        @click="resume"
+      ></canvas>
+      <canvas
+        ref="pieceCanvasRef"
+        :width="canvasWidth"
+        :height="canvasHeight"
+        :style="pieceStyle"
+        class="absolute"
+        @click="resume"
+      ></canvas>
+      <div
+        class="h-15 absolute bottom-3 left-0 z-10 block 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.dragging" class="bg-black/30">
+          {{ defaultTip || $t('ui.captcha.sliderTranslateDefaultTip') }}
+        </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>

+ 36 - 0
packages/effects/common-ui/src/components/captcha/types.ts

@@ -159,6 +159,42 @@ export interface SliderRotateCaptchaProps {
   defaultTip?: string;
 }
 
+export interface SliderTranslateCaptchaProps {
+  /**
+   * @description 拼图的宽度
+   * @default 420
+   */
+  canvasWidth?: number;
+  /**
+   * @description 拼图的高度
+   * @default 280
+   */
+  canvasHeight?: number;
+  /**
+   * @description 切块上正方形的长度
+   * @default 42
+   */
+  squareLength?: number;
+  /**
+   * @description 切块上圆形的半径
+   * @default 10
+   */
+  circleRadius?: number;
+  /**
+   * @description 图片的地址
+   */
+  src?: string;
+  /**
+   * @description 允许的最大差距
+   * @default 3
+   */
+  diffDistance?: number;
+  /**
+   * @description 默认提示文本
+   */
+  defaultTip?: string;
+}
+
 export interface CaptchaVerifyPassingData {
   isPassing: boolean;
   time: number | string;

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

@@ -32,8 +32,11 @@
     "sliderDefaultText": "Slider and drag",
     "alt": "Supports img tag src attribute value",
     "sliderRotateDefaultTip": "Click picture to refresh",
+    "sliderTranslateDefaultTip": "Click picture to refresh",
     "sliderRotateFailTip": "Validation failed",
     "sliderRotateSuccessTip": "Validation successful, time {0} seconds",
+    "sliderTranslateFailTip": "Validation failed",
+    "sliderTranslateSuccessTip": "Validation successful, time {0} seconds",
     "refreshAriaLabel": "Refresh captcha",
     "confirmAriaLabel": "Confirm selection",
     "confirm": "Confirm",

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

@@ -31,8 +31,11 @@
     "sliderSuccessText": "验证通过",
     "sliderDefaultText": "请按住滑块拖动",
     "sliderRotateDefaultTip": "点击图片可刷新",
+    "sliderTranslateDefaultTip": "点击图片可刷新",
     "sliderRotateFailTip": "验证失败",
     "sliderRotateSuccessTip": "验证成功,耗时{0}秒",
+    "sliderTranslateFailTip": "验证失败",
+    "sliderTranslateSuccessTip": "验证成功,耗时{0}秒",
     "alt": "支持img标签src属性值",
     "refreshAriaLabel": "刷新验证码",
     "confirmAriaLabel": "确认选择",

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

@@ -42,6 +42,7 @@
     "pointSelection": "Point Selection Captcha",
     "sliderCaptcha": "Slider Captcha",
     "sliderRotateCaptcha": "Rotate Captcha",
+    "sliderTranslateCaptcha": "Translate Captcha",
     "captchaCardTitle": "Please complete the security verification",
     "pageDescription": "Verify user identity by clicking on specific locations in the image.",
     "pageTitle": "Captcha Component Example",

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

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

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

@@ -205,6 +205,15 @@ const routes: RouteRecordRaw[] = [
               title: $t('examples.captcha.sliderRotateCaptcha'),
             },
           },
+          {
+            name: 'TranslateVerifyExample',
+            path: '/examples/captcha/slider-translate',
+            component: () =>
+              import('#/views/examples/captcha/slider-translate-captcha.vue'),
+            meta: {
+              title: $t('examples.captcha.sliderTranslateCaptcha'),
+            },
+          },
           {
             name: 'CaptchaPointSelectionExample',
             path: '/examples/captcha/point-selection',

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

@@ -0,0 +1,27 @@
+<script setup lang="ts">
+import { Page, SliderTranslateCaptcha } from '@vben/common-ui';
+
+import { Card, message } from 'ant-design-vue';
+
+function handleSuccess() {
+  message.success('success!');
+}
+</script>
+
+<template>
+  <Page
+    description="用于前端简单的拼图滑块水平拖动校验场景"
+    title="拼图滑块校验"
+  >
+    <Card class="mb-5" title="基本示例">
+      <div class="flex items-center justify-center p-4">
+        <SliderTranslateCaptcha
+          src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/pro-avatar.webp"
+          :canvas-width="420"
+          :canvas-height="420"
+          @success="handleSuccess"
+        />
+      </div>
+    </Card>
+  </Page>
+</template>