Merge branch 'main' into feature/scroll_to_the_error_field
						commit
						d4786f3f75
					
				|  | @ -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` | - | | ||||
|  |  | |||
|  | @ -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'; | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -31,8 +31,11 @@ | |||
|     "sliderSuccessText": "验证通过", | ||||
|     "sliderDefaultText": "请按住滑块拖动", | ||||
|     "sliderRotateDefaultTip": "点击图片可刷新", | ||||
|     "sliderTranslateDefaultTip": "点击图片可刷新", | ||||
|     "sliderRotateFailTip": "验证失败", | ||||
|     "sliderRotateSuccessTip": "验证成功,耗时{0}秒", | ||||
|     "sliderTranslateFailTip": "验证失败", | ||||
|     "sliderTranslateSuccessTip": "验证成功,耗时{0}秒", | ||||
|     "alt": "支持img标签src属性值", | ||||
|     "refreshAriaLabel": "刷新验证码", | ||||
|     "confirmAriaLabel": "确认选择", | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -45,6 +45,7 @@ | |||
|     "pointSelection": "点选验证", | ||||
|     "sliderCaptcha": "滑块验证", | ||||
|     "sliderRotateCaptcha": "旋转验证", | ||||
|     "sliderTranslateCaptcha": "拼图滑块验证", | ||||
|     "captchaCardTitle": "请完成安全验证", | ||||
|     "pageDescription": "通过点击图片中的特定位置来验证用户身份。", | ||||
|     "pageTitle": "验证码组件示例", | ||||
|  |  | |||
|  | @ -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', | ||||
|  |  | |||
|  | @ -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> | ||||
		Loading…
	
		Reference in New Issue
	
	 panda7
						panda7