Skip to content

Commit d4786f3

Browse files
authored
Merge branch 'main' into feature/scroll_to_the_error_field
2 parents f1051c8 + b333fd6 commit d4786f3

File tree

10 files changed

+396
-4
lines changed

10 files changed

+396
-4
lines changed

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ outline: deep
2222

2323
## 基础用法
2424

25-
使用 `useVbenDrawer` 创建最基础的模态框
25+
使用 `useVbenDrawer` 创建最基础的抽屉
2626

2727
<DemoPreview dir="demos/vben-drawer/basic" />
2828

@@ -52,7 +52,7 @@ Drawer 内的内容一般业务中,会比较复杂,所以我们可以将 dra
5252

5353
::: info 注意
5454

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export { default as PointSelectionCaptchaCard } from './point-selection-captcha/
33

44
export { default as SliderCaptcha } from './slider-captcha/index.vue';
55
export { default as SliderRotateCaptcha } from './slider-rotate-captcha/index.vue';
6+
export { default as SliderTranslateCaptcha } from './slider-translate-captcha/index.vue';
67
export type * from './types';
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
<script setup lang="ts">
2+
import type {
3+
CaptchaVerifyPassingData,
4+
SliderCaptchaActionType,
5+
SliderRotateVerifyPassingData,
6+
SliderTranslateCaptchaProps,
7+
} from '../types';
8+
9+
import {
10+
computed,
11+
onMounted,
12+
reactive,
13+
ref,
14+
unref,
15+
useTemplateRef,
16+
watch,
17+
} from 'vue';
18+
19+
import { $t } from '@vben/locales';
20+
21+
import SliderCaptcha from '../slider-captcha/index.vue';
22+
23+
const props = withDefaults(defineProps<SliderTranslateCaptchaProps>(), {
24+
defaultTip: '',
25+
canvasWidth: 420,
26+
canvasHeight: 280,
27+
squareLength: 42,
28+
circleRadius: 10,
29+
src: '',
30+
diffDistance: 3,
31+
});
32+
33+
const emit = defineEmits<{
34+
success: [CaptchaVerifyPassingData];
35+
}>();
36+
37+
const PI: number = Math.PI;
38+
enum CanvasOpr {
39+
// eslint-disable-next-line no-unused-vars
40+
Clip = 'clip',
41+
// eslint-disable-next-line no-unused-vars
42+
Fill = 'fill',
43+
}
44+
45+
const modalValue = defineModel<boolean>({ default: false });
46+
47+
const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
48+
const puzzleCanvasRef = useTemplateRef<HTMLCanvasElement>('puzzleCanvasRef');
49+
const pieceCanvasRef = useTemplateRef<HTMLCanvasElement>('pieceCanvasRef');
50+
51+
const state = reactive({
52+
dragging: false,
53+
startTime: 0,
54+
endTime: 0,
55+
pieceX: 0,
56+
pieceY: 0,
57+
moveDistance: 0,
58+
isPassing: false,
59+
showTip: false,
60+
});
61+
62+
const left = ref('0');
63+
64+
const pieceStyle = computed(() => {
65+
return {
66+
left: left.value,
67+
};
68+
});
69+
70+
function setLeft(val: string) {
71+
left.value = val;
72+
}
73+
74+
const verifyTip = computed(() => {
75+
return state.isPassing
76+
? $t('ui.captcha.sliderTranslateSuccessTip', [
77+
((state.endTime - state.startTime) / 1000).toFixed(1),
78+
])
79+
: $t('ui.captcha.sliderTranslateFailTip');
80+
});
81+
function handleStart() {
82+
state.startTime = Date.now();
83+
}
84+
85+
function handleDragBarMove(data: SliderRotateVerifyPassingData) {
86+
state.dragging = true;
87+
const { moveX } = data;
88+
state.moveDistance = moveX;
89+
setLeft(`${moveX}px`);
90+
}
91+
92+
function handleDragEnd() {
93+
const { pieceX } = state;
94+
const { diffDistance } = props;
95+
96+
if (Math.abs(pieceX - state.moveDistance) >= (diffDistance || 3)) {
97+
setLeft('0');
98+
state.moveDistance = 0;
99+
} else {
100+
checkPass();
101+
}
102+
state.showTip = true;
103+
state.dragging = false;
104+
}
105+
106+
function checkPass() {
107+
state.isPassing = true;
108+
state.endTime = Date.now();
109+
}
110+
111+
watch(
112+
() => state.isPassing,
113+
(isPassing) => {
114+
if (isPassing) {
115+
const { endTime, startTime } = state;
116+
const time = (endTime - startTime) / 1000;
117+
emit('success', { isPassing, time: time.toFixed(1) });
118+
}
119+
modalValue.value = isPassing;
120+
},
121+
);
122+
123+
function resetCanvas() {
124+
const { canvasWidth, canvasHeight } = props;
125+
const puzzleCanvas = unref(puzzleCanvasRef);
126+
const pieceCanvas = unref(pieceCanvasRef);
127+
if (!puzzleCanvas || !pieceCanvas) return;
128+
pieceCanvas.width = canvasWidth;
129+
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
130+
// Canvas2D: Multiple readback operations using getImageData
131+
// are faster with the willReadFrequently attribute set to true.
132+
// See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
133+
const pieceCanvasCtx = pieceCanvas.getContext('2d', {
134+
willReadFrequently: true,
135+
});
136+
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
137+
puzzleCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
138+
pieceCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
139+
}
140+
141+
function initCanvas() {
142+
const { canvasWidth, canvasHeight, squareLength, circleRadius, src } = props;
143+
const puzzleCanvas = unref(puzzleCanvasRef);
144+
const pieceCanvas = unref(pieceCanvasRef);
145+
if (!puzzleCanvas || !pieceCanvas) return;
146+
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
147+
// Canvas2D: Multiple readback operations using getImageData
148+
// are faster with the willReadFrequently attribute set to true.
149+
// See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
150+
const pieceCanvasCtx = pieceCanvas.getContext('2d', {
151+
willReadFrequently: true,
152+
});
153+
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
154+
const img = new Image();
155+
// 解决跨域
156+
img.crossOrigin = 'Anonymous';
157+
img.src = src;
158+
img.addEventListener('load', () => {
159+
draw(puzzleCanvasCtx, pieceCanvasCtx);
160+
puzzleCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
161+
pieceCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
162+
const pieceLength = squareLength + 2 * circleRadius + 3;
163+
const sx = state.pieceX;
164+
const sy = state.pieceY - 2 * circleRadius - 1;
165+
const imageData = pieceCanvasCtx.getImageData(
166+
sx,
167+
sy,
168+
pieceLength,
169+
pieceLength,
170+
);
171+
pieceCanvas.width = pieceLength;
172+
pieceCanvasCtx.putImageData(imageData, 0, sy);
173+
setLeft('0');
174+
});
175+
}
176+
177+
function getRandomNumberByRange(start: number, end: number) {
178+
return Math.round(Math.random() * (end - start) + start);
179+
}
180+
181+
// 绘制拼图
182+
function draw(ctx1: CanvasRenderingContext2D, ctx2: CanvasRenderingContext2D) {
183+
const { canvasWidth, canvasHeight, squareLength, circleRadius } = props;
184+
state.pieceX = getRandomNumberByRange(
185+
squareLength + 2 * circleRadius,
186+
canvasWidth - (squareLength + 2 * circleRadius),
187+
);
188+
state.pieceY = getRandomNumberByRange(
189+
3 * circleRadius,
190+
canvasHeight - (squareLength + 2 * circleRadius),
191+
);
192+
drawPiece(ctx1, state.pieceX, state.pieceY, CanvasOpr.Fill);
193+
drawPiece(ctx2, state.pieceX, state.pieceY, CanvasOpr.Clip);
194+
}
195+
196+
// 绘制拼图切块
197+
function drawPiece(
198+
ctx: CanvasRenderingContext2D,
199+
x: number,
200+
y: number,
201+
opr: CanvasOpr,
202+
) {
203+
const { squareLength, circleRadius } = props;
204+
ctx.beginPath();
205+
ctx.moveTo(x, y);
206+
ctx.arc(
207+
x + squareLength / 2,
208+
y - circleRadius + 2,
209+
circleRadius,
210+
0.72 * PI,
211+
2.26 * PI,
212+
);
213+
ctx.lineTo(x + squareLength, y);
214+
ctx.arc(
215+
x + squareLength + circleRadius - 2,
216+
y + squareLength / 2,
217+
circleRadius,
218+
1.21 * PI,
219+
2.78 * PI,
220+
);
221+
ctx.lineTo(x + squareLength, y + squareLength);
222+
ctx.lineTo(x, y + squareLength);
223+
ctx.arc(
224+
x + circleRadius - 2,
225+
y + squareLength / 2,
226+
circleRadius + 0.4,
227+
2.76 * PI,
228+
1.24 * PI,
229+
true,
230+
);
231+
ctx.lineTo(x, y);
232+
ctx.lineWidth = 2;
233+
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
234+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
235+
ctx.stroke();
236+
opr === CanvasOpr.Clip ? ctx.clip() : ctx.fill();
237+
ctx.globalCompositeOperation = 'destination-over';
238+
}
239+
240+
function resume() {
241+
state.showTip = false;
242+
const basicEl = unref(slideBarRef);
243+
if (!basicEl) {
244+
return;
245+
}
246+
state.dragging = false;
247+
state.isPassing = false;
248+
state.pieceX = 0;
249+
state.pieceY = 0;
250+
251+
basicEl.resume();
252+
resetCanvas();
253+
initCanvas();
254+
}
255+
256+
onMounted(() => {
257+
initCanvas();
258+
});
259+
</script>
260+
261+
<template>
262+
<div class="relative flex flex-col items-center">
263+
<div
264+
class="border-border relative flex cursor-pointer overflow-hidden border shadow-md"
265+
>
266+
<canvas
267+
ref="puzzleCanvasRef"
268+
:width="canvasWidth"
269+
:height="canvasHeight"
270+
@click="resume"
271+
></canvas>
272+
<canvas
273+
ref="pieceCanvasRef"
274+
:width="canvasWidth"
275+
:height="canvasHeight"
276+
:style="pieceStyle"
277+
class="absolute"
278+
@click="resume"
279+
></canvas>
280+
<div
281+
class="h-15 absolute bottom-3 left-0 z-10 block w-full text-center text-xs leading-[30px] text-white"
282+
>
283+
<div
284+
v-if="state.showTip"
285+
:class="{
286+
'bg-success/80': state.isPassing,
287+
'bg-destructive/80': !state.isPassing,
288+
}"
289+
>
290+
{{ verifyTip }}
291+
</div>
292+
<div v-if="!state.dragging" class="bg-black/30">
293+
{{ defaultTip || $t('ui.captcha.sliderTranslateDefaultTip') }}
294+
</div>
295+
</div>
296+
</div>
297+
<SliderCaptcha
298+
ref="slideBarRef"
299+
v-model="modalValue"
300+
class="mt-5"
301+
is-slot
302+
@end="handleDragEnd"
303+
@move="handleDragBarMove"
304+
@start="handleStart"
305+
>
306+
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
307+
<slot :name="key" v-bind="slotProps"></slot>
308+
</template>
309+
</SliderCaptcha>
310+
</div>
311+
</template>

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,42 @@ export interface SliderRotateCaptchaProps {
159159
defaultTip?: string;
160160
}
161161

162+
export interface SliderTranslateCaptchaProps {
163+
/**
164+
* @description 拼图的宽度
165+
* @default 420
166+
*/
167+
canvasWidth?: number;
168+
/**
169+
* @description 拼图的高度
170+
* @default 280
171+
*/
172+
canvasHeight?: number;
173+
/**
174+
* @description 切块上正方形的长度
175+
* @default 42
176+
*/
177+
squareLength?: number;
178+
/**
179+
* @description 切块上圆形的半径
180+
* @default 10
181+
*/
182+
circleRadius?: number;
183+
/**
184+
* @description 图片的地址
185+
*/
186+
src?: string;
187+
/**
188+
* @description 允许的最大差距
189+
* @default 3
190+
*/
191+
diffDistance?: number;
192+
/**
193+
* @description 默认提示文本
194+
*/
195+
defaultTip?: string;
196+
}
197+
162198
export interface CaptchaVerifyPassingData {
163199
isPassing: boolean;
164200
time: number | string;

0 commit comments

Comments
 (0)