|
@@ -0,0 +1,241 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import type {
|
|
|
+ CaptchaVerifyPassingData,
|
|
|
+ SliderCaptchaProps,
|
|
|
+ SliderRotateVerifyPassingData,
|
|
|
+} from '../types';
|
|
|
+
|
|
|
+import { reactive, unref, useTemplateRef, watch, watchEffect } from 'vue';
|
|
|
+
|
|
|
+import { $t } from '@vben/locales';
|
|
|
+import { cn } from '@vben-core/shared/utils';
|
|
|
+
|
|
|
+import { useTimeoutFn } from '@vueuse/core';
|
|
|
+
|
|
|
+import SliderCaptchaAction from './slider-captcha-action.vue';
|
|
|
+import SliderCaptchaBar from './slider-captcha-bar.vue';
|
|
|
+import SliderCaptchaContent from './slider-captcha-content.vue';
|
|
|
+
|
|
|
+const props = withDefaults(defineProps<SliderCaptchaProps>(), {
|
|
|
+ actionStyle: () => ({}),
|
|
|
+ barStyle: () => ({}),
|
|
|
+ contentStyle: () => ({}),
|
|
|
+ isSlot: false,
|
|
|
+ successText: '',
|
|
|
+ text: '',
|
|
|
+ wrapperStyle: () => ({}),
|
|
|
+});
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ end: [MouseEvent | TouchEvent];
|
|
|
+ move: [SliderRotateVerifyPassingData];
|
|
|
+ start: [MouseEvent | TouchEvent];
|
|
|
+ success: [CaptchaVerifyPassingData];
|
|
|
+}>();
|
|
|
+
|
|
|
+const modelValue = defineModel<boolean>({ default: false });
|
|
|
+
|
|
|
+const state = reactive({
|
|
|
+ endTime: 0,
|
|
|
+ isMoving: false,
|
|
|
+ isPassing: false,
|
|
|
+ moveDistance: 0,
|
|
|
+ startTime: 0,
|
|
|
+ toLeft: false,
|
|
|
+});
|
|
|
+
|
|
|
+defineExpose({
|
|
|
+ resume,
|
|
|
+});
|
|
|
+
|
|
|
+const wrapperRef = useTemplateRef<HTMLDivElement>('wrapperRef');
|
|
|
+const barRef = useTemplateRef<typeof SliderCaptchaBar>('barRef');
|
|
|
+const contentRef = useTemplateRef<typeof SliderCaptchaContent>('contentRef');
|
|
|
+const actionRef = useTemplateRef<typeof SliderCaptchaAction>('actionRef');
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => state.isPassing,
|
|
|
+ (isPassing) => {
|
|
|
+ if (isPassing) {
|
|
|
+ const { endTime, startTime } = state;
|
|
|
+ const time = (endTime - startTime) / 1000;
|
|
|
+ emit('success', { isPassing, time: time.toFixed(1) });
|
|
|
+ modelValue.value = isPassing;
|
|
|
+ }
|
|
|
+ },
|
|
|
+);
|
|
|
+
|
|
|
+watchEffect(() => {
|
|
|
+ state.isPassing = !!modelValue.value;
|
|
|
+});
|
|
|
+
|
|
|
+function getEventPageX(e: MouseEvent | TouchEvent): number {
|
|
|
+ if (e instanceof MouseEvent) {
|
|
|
+ return e.pageX;
|
|
|
+ } else if (e instanceof TouchEvent && e.touches[0]) {
|
|
|
+ return e.touches[0].pageX;
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+}
|
|
|
+
|
|
|
+function handleDragStart(e: MouseEvent | TouchEvent) {
|
|
|
+ if (state.isPassing) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!actionRef.value) return;
|
|
|
+ emit('start', e);
|
|
|
+
|
|
|
+ state.moveDistance =
|
|
|
+ getEventPageX(e) -
|
|
|
+ Number.parseInt(
|
|
|
+ actionRef.value.getStyle().left.replace('px', '') || '0',
|
|
|
+ 10,
|
|
|
+ );
|
|
|
+ state.startTime = Date.now();
|
|
|
+ state.isMoving = true;
|
|
|
+}
|
|
|
+
|
|
|
+function getOffset(actionEl: HTMLDivElement) {
|
|
|
+ const wrapperWidth = wrapperRef.value?.offsetWidth ?? 220;
|
|
|
+ const actionWidth = actionEl?.offsetWidth ?? 40;
|
|
|
+ const offset = wrapperWidth - actionWidth - 6;
|
|
|
+ return { actionWidth, offset, wrapperWidth };
|
|
|
+}
|
|
|
+
|
|
|
+function handleDragMoving(e: MouseEvent | TouchEvent) {
|
|
|
+ const { isMoving, moveDistance } = state;
|
|
|
+ if (isMoving) {
|
|
|
+ const actionEl = unref(actionRef);
|
|
|
+ const barEl = unref(barRef);
|
|
|
+ if (!actionEl || !barEl) return;
|
|
|
+ const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
|
|
|
+ const moveX = getEventPageX(e) - moveDistance;
|
|
|
+
|
|
|
+ emit('move', {
|
|
|
+ event: e,
|
|
|
+ moveDistance,
|
|
|
+ moveX,
|
|
|
+ });
|
|
|
+ if (moveX > 0 && moveX <= offset) {
|
|
|
+ actionEl.setLeft(`${moveX}px`);
|
|
|
+ barEl.setWidth(`${moveX + actionWidth / 2}px`);
|
|
|
+ } else if (moveX > offset) {
|
|
|
+ actionEl.setLeft(`${wrapperWidth - actionWidth}px`);
|
|
|
+ barEl.setWidth(`${wrapperWidth - actionWidth / 2}px`);
|
|
|
+ if (!props.isSlot) {
|
|
|
+ checkPass();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleDragOver(e: MouseEvent | TouchEvent) {
|
|
|
+ const { isMoving, isPassing, moveDistance } = state;
|
|
|
+ if (isMoving && !isPassing) {
|
|
|
+ emit('end', e);
|
|
|
+ const actionEl = actionRef.value;
|
|
|
+ const barEl = unref(barRef);
|
|
|
+ if (!actionEl || !barEl) return;
|
|
|
+ const moveX = getEventPageX(e) - moveDistance;
|
|
|
+ const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
|
|
|
+ if (moveX < offset) {
|
|
|
+ if (props.isSlot) {
|
|
|
+ setTimeout(() => {
|
|
|
+ if (modelValue.value) {
|
|
|
+ const contentEl = unref(contentRef);
|
|
|
+ if (contentEl) {
|
|
|
+ contentEl.getEl().style.width = `${Number.parseInt(barEl.getEl().style.width)}px`;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ resume();
|
|
|
+ }
|
|
|
+ }, 0);
|
|
|
+ } else {
|
|
|
+ resume();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ actionEl.setLeft(`${wrapperWidth - actionWidth + 10}px`);
|
|
|
+ barEl.setWidth(`${wrapperWidth - actionWidth / 2}px`);
|
|
|
+ checkPass();
|
|
|
+ }
|
|
|
+ state.isMoving = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function checkPass() {
|
|
|
+ if (props.isSlot) {
|
|
|
+ resume();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ state.endTime = Date.now();
|
|
|
+ state.isPassing = true;
|
|
|
+ state.isMoving = false;
|
|
|
+}
|
|
|
+
|
|
|
+function resume() {
|
|
|
+ state.isMoving = false;
|
|
|
+ state.isPassing = false;
|
|
|
+ state.moveDistance = 0;
|
|
|
+ state.toLeft = false;
|
|
|
+ state.startTime = 0;
|
|
|
+ state.endTime = 0;
|
|
|
+ const actionEl = unref(actionRef);
|
|
|
+ const barEl = unref(barRef);
|
|
|
+ const contentEl = unref(contentRef);
|
|
|
+ if (!actionEl || !barEl || !contentEl) return;
|
|
|
+ state.toLeft = true;
|
|
|
+ useTimeoutFn(() => {
|
|
|
+ state.toLeft = false;
|
|
|
+ actionEl.setLeft('0');
|
|
|
+ barEl.setWidth('0');
|
|
|
+ }, 300);
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ ref="wrapperRef"
|
|
|
+ :class="
|
|
|
+ cn(
|
|
|
+ 'border-border bg-background-deep relative flex h-10 w-full items-center overflow-hidden rounded-md border text-center',
|
|
|
+ props.class,
|
|
|
+ )
|
|
|
+ "
|
|
|
+ :style="wrapperStyle"
|
|
|
+ @mouseleave="handleDragOver"
|
|
|
+ @mousemove="handleDragMoving"
|
|
|
+ @mouseup="handleDragOver"
|
|
|
+ @touchend="handleDragOver"
|
|
|
+ @touchmove="handleDragMoving"
|
|
|
+ >
|
|
|
+ <SliderCaptchaBar
|
|
|
+ ref="barRef"
|
|
|
+ :bar-style="barStyle"
|
|
|
+ :to-left="state.toLeft"
|
|
|
+ />
|
|
|
+ <SliderCaptchaContent
|
|
|
+ ref="contentRef"
|
|
|
+ :content-style="contentStyle"
|
|
|
+ :is-passing="state.isPassing"
|
|
|
+ :success-text="successText || $t('ui.captcha.sliderSuccessText')"
|
|
|
+ :text="text || $t('ui.captcha.sliderDefaultText')"
|
|
|
+ >
|
|
|
+ <template v-if="$slots.text" #text>
|
|
|
+ <slot :is-passing="state.isPassing" name="text"></slot>
|
|
|
+ </template>
|
|
|
+ </SliderCaptchaContent>
|
|
|
+
|
|
|
+ <SliderCaptchaAction
|
|
|
+ ref="actionRef"
|
|
|
+ :action-style="actionStyle"
|
|
|
+ :is-passing="state.isPassing"
|
|
|
+ :to-left="state.toLeft"
|
|
|
+ @mousedown="handleDragStart"
|
|
|
+ @touchstart="handleDragStart"
|
|
|
+ >
|
|
|
+ <template v-if="$slots.actionIcon" #icon>
|
|
|
+ <slot :is-passing="state.isPassing" name="actionIcon"></slot>
|
|
|
+ </template>
|
|
|
+ </SliderCaptchaAction>
|
|
|
+ </div>
|
|
|
+</template>
|