index.vue 6.27 KB
<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 ('pageX' in e) {
    return e.pageX;
  } else if ('touches' in e && 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}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;

  contentEl.getEl().style.width = '100%';
  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>