Browse Source

feat(架构): 完善登录验证码

shizhongming 2 năm trước cách đây
mục cha
commit
962095b5a5

+ 1 - 2
src/api/sys/model/userModel.ts

@@ -4,8 +4,7 @@
 export interface LoginParams {
   username: string;
   password: string;
-  key: string;
-  code: string;
+  code?: string;
 }
 
 export interface RoleInfo {

+ 3 - 2
src/components/Verify/index.ts

@@ -1,10 +1,11 @@
 import { withInstall } from '@/utils';
 import basicDragVerify from './src/DragVerify.vue';
 import rotateDragVerify from './src/ImgRotate.vue';
-import textCaptcha from './src/TextCaptcha.vue';
 
 export const BasicDragVerify = withInstall(basicDragVerify);
 export const RotateDragVerify = withInstall(rotateDragVerify);
 export * from './src/typing';
 
-export const TextCaptcha = withInstall(textCaptcha);
+export { default as TextCaptcha } from './src/TextCaptcha.vue';
+export { default as ImageCaptcha } from './src/ImageCaptcha.vue';
+export { default as ImageCaptchaModal } from './src/ImageCaptchaModal.vue';

+ 33 - 0
src/components/Verify/src/ImageCaptcha.vue

@@ -0,0 +1,33 @@
+<template>
+  <div :class="prefixCls">
+    <ImageSliderCaptcha ref="sliderRef" v-if="type === 'SLIDER'" :type="type" v-bind="$attrs" />
+  </div>
+</template>
+
+<script setup lang="ts">
+  import type { ImageCaptchaType } from '@/components/Verify';
+  import ImageSliderCaptcha from './components/ImageSliderCaptcha.vue';
+  import { ref } from 'vue';
+  import { useDesign } from '@/hooks/web/useDesign';
+
+  const { prefixCls } = useDesign('smart-component-imageCaptcha');
+
+  /**
+   * 图片验证码
+   */
+  defineProps({
+    type: {
+      type: String as PropType<ImageCaptchaType>,
+      required: true,
+    },
+  });
+
+  const sliderRef = ref();
+
+  const refresh = () => {
+    sliderRef.value.refresh();
+  };
+  defineExpose({ refresh });
+</script>
+
+<style lang="less"></style>

+ 91 - 0
src/components/Verify/src/ImageCaptchaModal.vue

@@ -0,0 +1,91 @@
+<template>
+  <BasicModal
+    centered
+    v-bind="$attrs"
+    :width="computedModalWidth"
+    @open-change="handleVisibleChange"
+    :wrapClassName="prefixCls"
+    :footer="null"
+    @register="registerModal"
+  >
+    <ImageCaptcha ref="dragRef" v-bind="$attrs" :type="type" />
+    <div class="bottom">
+      <Icon
+        @click="refresh"
+        class="icon"
+        style="margin-right: 5px"
+        size="22px"
+        icon="ant-design:reload-outlined"
+      />
+      <Icon @click="closeModal" class="icon" size="22px" icon="ant-design:close-circle-outlined" />
+    </div>
+  </BasicModal>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref, unref } from 'vue';
+  import { propTypes } from '@/utils/propTypes';
+  import { BasicModal, useModalInner } from '@/components/Modal';
+
+  import { ImageCaptchaType } from '@/components/Verify';
+  import ImageCaptcha from './ImageCaptcha.vue';
+  import { Icon } from '@/components/Icon';
+  import { useDesign } from '@/hooks/web/useDesign';
+
+  const { prefixCls } = useDesign('smart-component-imageCaptchaModal');
+
+  const props = defineProps({
+    type: {
+      type: String as PropType<ImageCaptchaType>,
+      required: true,
+    },
+    // 图片宽度
+    width: propTypes.number.def(320),
+  });
+
+  const dragRef = ref();
+
+  const [registerModal, { closeModal }] = useModalInner();
+
+  const computedModalWidth = computed(() => {
+    return props.width + 28;
+  });
+
+  const handleVisibleChange = (visible: boolean) => {
+    if (visible) {
+      unref(dragRef)?.refresh();
+    }
+  };
+  const refresh = () => {
+    unref(dragRef)?.refresh();
+  };
+  defineExpose({
+    refresh,
+  });
+</script>
+
+<style lang="less">
+  @prefix-cls: ~'@{namespace}-smart-component-imageCaptchaModal';
+  .@{prefix-cls} {
+    .ant-modal-header {
+      display: none;
+    }
+
+    .ant-modal-close {
+      display: none;
+    }
+
+    .scrollbar__wrap {
+      margin-bottom: 0 !important;
+    }
+
+    .bottom {
+      margin-top: 5px;
+      text-align: right;
+
+      .icon {
+        cursor: pointer;
+      }
+    }
+  }
+</style>

+ 16 - 0
src/components/Verify/src/Verify.api.ts

@@ -0,0 +1,16 @@
+import type { ImageCaptchaType } from './typing';
+import { ApiServiceEnum, defHttp } from '@/utils/http/axios';
+
+enum Api {
+  loadCaptcha = 'public/auth/generateCaptcha',
+}
+
+export const loadCaptchaApi = (type: ImageCaptchaType) => {
+  return defHttp.post({
+    service: ApiServiceEnum.SMART_AUTH,
+    url: Api.loadCaptcha,
+    data: {
+      type,
+    },
+  });
+};

+ 224 - 0
src/components/Verify/src/components/ImageSliderCaptcha.vue

@@ -0,0 +1,224 @@
+<template>
+  <div :class="prefixCls" class="slider" :style="computedSliderStyle">
+    <div class="content" :style="computedContentStyle">
+      <div class="bg-img-div">
+        <img ref="bgImageRef" :src="captchaDataRef.image?.backgroundImage" alt="" />
+      </div>
+      <div class="slider-img-div" :style="computedSliderTemplateStyle">
+        <img ref="sliderImageRef" :src="captchaDataRef.image?.templateImage" alt="" />
+      </div>
+    </div>
+    <div class="slider-move">
+      <DragVerify
+        ref="basicDragVerifyRef"
+        :width="width"
+        is-slot
+        @start="handleStart"
+        @end="handleEnd"
+        @move="handleMove"
+        :value="verifySuccessRef"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { type ImageCaptchaType, type MoveData } from '@/components/Verify';
+  import { useDesign } from '@/hooks/web/useDesign';
+  import { propTypes } from '@/utils/propTypes';
+  import { computed, nextTick, onMounted, ref, unref } from 'vue';
+
+  import DragVerify from '../DragVerify.vue';
+  import { useImageSliderCaptcha } from '../hooks/useImageSliderCaptcha';
+  import { loadCaptchaApi } from '../Verify.api';
+
+  const { prefixCls } = useDesign('smart-component-imageSliderCaptcha');
+
+  const props = defineProps({
+    type: {
+      type: String as PropType<ImageCaptchaType>,
+      required: true,
+      default: 'SLIDER',
+    },
+    // 图片宽度
+    width: propTypes.number.def(320),
+    api: {
+      type: Function as PropType<(params: any) => Promise<Recordable>>,
+      default: loadCaptchaApi,
+    },
+  });
+
+  const emit = defineEmits(['end']);
+
+  const basicDragVerifyRef = ref();
+  const bgImageRef = ref();
+  const sliderImageRef = ref();
+
+  const verifySuccessRef = ref(true);
+
+  /**
+   * 验证码数据
+   */
+  const captchaDataRef = ref<Recordable>({});
+  /**
+   * X轴移动距离
+   */
+  const moveXRef = ref(0);
+
+  const { initConfig, start, move, end, createCaptchaParameter } = useImageSliderCaptcha(
+    props.type,
+  );
+
+  /**
+   * 开始移动事件
+   * @param event
+   */
+  const handleStart = (event: MouseEvent | TouchEvent) => {
+    start(event);
+  };
+
+  /**
+   * 停止移动事件
+   * @param e
+   */
+  const handleEnd = async (e: MouseEvent | TouchEvent) => {
+    end(e);
+    const parameter = createCaptchaParameter();
+    emit('end', parameter);
+  };
+
+  const handleMove = (data: MoveData) => {
+    moveXRef.value = data.moveX;
+    move(data.event);
+  };
+
+  /**
+   * 刷新验证码
+   */
+  const refresh = async () => {
+    captchaDataRef.value = await props.api(props.type);
+    const bgImage = unref(bgImageRef);
+    const sliderImage = unref(sliderImageRef);
+    moveXRef.value = 0;
+    basicDragVerifyRef.value?.resume();
+    await nextTick(() => {
+      initConfig({
+        key: captchaDataRef.value.key,
+        bgImageWidth: bgImage.width,
+        bgImageHeight: bgImage.height,
+        sliderImageWidth: sliderImage.width,
+        sliderImageHeight: sliderImage.height,
+      });
+    });
+  };
+  onMounted(refresh);
+
+  const computedContentHeight = computed(() => {
+    return props.width / 1.64;
+  });
+
+  const computedSliderStyle = computed(() => {
+    return {
+      width: `${props.width}px`,
+      height: `${unref(computedContentHeight) + 40}px`,
+    };
+  });
+  const computedContentStyle = computed(() => {
+    return {
+      height: `${unref(computedContentHeight)}px`,
+    };
+  });
+  /**
+   * 滑块样式计算属性
+   */
+  const computedSliderTemplateStyle = computed(() => {
+    const movePx = moveXRef.value;
+    return {
+      transform: `translate(${movePx}px, 0px)`,
+    };
+  });
+
+  defineExpose({ refresh });
+</script>
+
+<style lang="less">
+  @prefix-cls: ~'@{namespace}-smart-component-imageSliderCaptcha';
+
+  .@{prefix-cls} {
+    &.slider {
+      z-index: 999;
+      box-sizing: border-box;
+      height: 260px;
+      background-color: #fff;
+      user-select: none;
+
+      .bottom {
+        width: 100%;
+        height: 19px;
+      }
+
+      .content {
+        position: relative;
+        width: 100%;
+      }
+
+      .slider-move {
+        position: relative;
+        width: 100%;
+      }
+    }
+
+    .bg-img-div {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      transform: translate(0, 0);
+    }
+
+    .slider-img-div {
+      position: absolute;
+      height: 100%;
+      transform: translate(0, 0);
+    }
+
+    .bg-img-div img {
+      width: 100%;
+    }
+
+    .slider-img-div img {
+      height: 100%;
+    }
+
+    .refresh-btn,
+    .close-btn {
+      display: inline-block;
+    }
+
+    .slider-move {
+      .slider-move-track {
+        color: #88949d;
+        font-size: 14px;
+        line-height: 38px;
+        text-align: center;
+        white-space: nowrap;
+        user-select: none;
+      }
+
+      .slider-move-btn {
+        position: absolute;
+        top: -12px;
+        left: 0;
+        width: 66px;
+        height: 66px;
+        transform: translate(0, 0);
+        background-position: -5px 11.7%;
+      }
+    }
+
+    .slider-move-btn:hover,
+    .close-btn:hover,
+    .refresh-btn:hover {
+      cursor: pointer;
+    }
+  }
+</style>

+ 128 - 0
src/components/Verify/src/hooks/useImageSliderCaptcha.ts

@@ -0,0 +1,128 @@
+import type { CaptchaConfig, ImageCaptchaType } from '../typing';
+
+import { ref, unref } from 'vue';
+import { formatToDateTime } from '@/utils/dateUtil';
+
+/**
+ * 验证码hook
+ */
+export const useImageSliderCaptcha = (type: ImageCaptchaType) => {
+  // 验证码配置信息
+  const captchaConfigRef = ref<CaptchaConfig>({
+    type,
+    key: '',
+  });
+
+  /**
+   * 初始化配置
+
+   */
+  const initConfig = (config: CaptchaConfig) => {
+    captchaConfigRef.value = {
+      ...config,
+      startTime: new Date(),
+      trackList: [],
+      type,
+    };
+  };
+
+  /**
+   * 开始滑动
+   * @param event
+   */
+  const start = (event: MouseEvent | TouchEvent) => {
+    let startX;
+    let startY;
+    if (event instanceof MouseEvent) {
+      startX = event.pageX;
+      startY = event.pageY;
+    } else {
+      const targetTouches = event.targetTouches;
+      startX = Math.round(targetTouches[0].pageX);
+      startY = Math.round(targetTouches[0].pageY);
+    }
+    captchaConfigRef.value.startX = startX;
+    captchaConfigRef.value.startY = startY;
+
+    captchaConfigRef.value.trackList?.push({
+      x: 0,
+      y: 0,
+      type: 'DOWN',
+      t: new Date().getTime() - unref(captchaConfigRef).startTime!.getTime(),
+    });
+  };
+
+  /**
+   * 移动事件
+   * @param e
+   */
+  const move = (e: MouseEvent | TouchEvent) => {
+    let event: MouseEvent | Touch;
+    if (e instanceof TouchEvent) {
+      event = e.touches[0];
+    } else {
+      event = e;
+    }
+    const pageX = Math.round(event.pageX);
+    const pageY = Math.round(event.pageY);
+    const captchaConfig = unref(captchaConfigRef);
+    captchaConfigRef.value.trackList?.push({
+      x: pageX - captchaConfig.startX!,
+      y: pageY - captchaConfig.startY!,
+      type: 'MOVE',
+      t: new Date().getTime() - captchaConfig.startTime!.getTime(),
+    });
+  };
+
+  /**
+   * 移动结束事件
+   * @param e
+   */
+  const end = (e: MouseEvent | TouchEvent) => {
+    let event: MouseEvent | Touch;
+    if (e instanceof TouchEvent) {
+      event = e.touches[0];
+    } else {
+      event = e;
+    }
+    const pageX = Math.round(event.pageX);
+    const pageY = Math.round(event.pageY);
+    captchaConfigRef.value.stopTime = new Date();
+    const captchaConfig = unref(captchaConfigRef);
+    captchaConfigRef.value.trackList?.push({
+      x: pageX - captchaConfig.startX!,
+      y: pageY - captchaConfig.startY!,
+      type: 'UP',
+      t: new Date().getTime() - captchaConfig.startTime!.getTime(),
+    });
+  };
+
+  /**
+   * 构建验证码参数
+   */
+  const createCaptchaParameter = () => {
+    const captchaConfig = unref(captchaConfigRef);
+
+    return {
+      key: captchaConfig.key,
+      type,
+      image: {
+        bgImageWidth: captchaConfig.bgImageWidth,
+        bgImageHeight: captchaConfig.bgImageHeight,
+        sliderImageWidth: captchaConfig.sliderImageWidth,
+        sliderImageHeight: captchaConfig.sliderImageHeight,
+        startSlidingTime: formatToDateTime(captchaConfig.startTime),
+        endSlidingTime: formatToDateTime(captchaConfig.stopTime),
+        trackList: captchaConfig.trackList,
+      },
+    };
+  };
+
+  return {
+    initConfig,
+    start,
+    move,
+    end,
+    createCaptchaParameter,
+  };
+};

+ 51 - 0
src/components/Verify/src/typing.ts

@@ -12,3 +12,54 @@ export interface MoveData {
   moveDistance: number;
   moveX: number;
 }
+
+/**
+ * 图片支持的验证类型
+ */
+export type ImageCaptchaType = 'SLIDER' | 'ROTATE' | 'CONCAT' | 'WORD_IMAGE_CLICK';
+
+/**
+ * 文本验证码类型
+ */
+export type TextCaptchaType =
+  | 'TEXT_PNG'
+  | 'TEXT_GIF'
+  | 'TEXT_CHINESE'
+  | 'TEXT_CHINESE_GIF'
+  | 'TEXT_ARITHMETIC';
+
+export type CaptchaType = ImageCaptchaType & TextCaptchaType;
+
+export type CaptchaTrackType = 'DOWN' | 'MOVE' | 'UP';
+
+export interface CaptchaTrackData {
+  x: number;
+  y: number;
+  type: CaptchaTrackType;
+  t: number;
+}
+
+/**
+ * 验证码配置信息
+ */
+export interface CaptchaConfig {
+  type?: ImageCaptchaType;
+  key: string;
+  startTime?: Date;
+
+  bgImageWidth?: number;
+
+  bgImageHeight?: number;
+
+  sliderImageWidth?: number;
+
+  sliderImageHeight?: number;
+
+  stopTime?: Date;
+
+  trackList?: CaptchaTrackData[];
+
+  startX?: number;
+
+  startY?: number;
+}

+ 41 - 14
src/views/sys/login/LoginForm.vue

@@ -26,7 +26,7 @@
     </FormItem>
 
     <!--  文本验证码  -->
-    <ARow v-if="computedUseCaptcha == 'TEXT'" :gutter="16">
+    <ARow v-if="computedCaptchaIdent == 'TEXT'" :gutter="16">
       <ACol :span="16">
         <FormItem name="captcha">
           <Input
@@ -38,12 +38,7 @@
         </FormItem>
       </ACol>
       <ACol :span="8">
-        <TextCaptcha
-          ref="captchaRef"
-          @after-refresh="({ key }) => (formData.key = key)"
-          height="40px"
-          :api="getCaptchaApi"
-        />
+        <TextCaptcha ref="captchaRef" height="40px" :api="getCaptchaApi" />
       </ACol>
     </ARow>
 
@@ -101,6 +96,14 @@
       <GoogleCircleFilled />
       <TwitterCircleFilled />
     </div>
+
+    <ImageCaptchaModal
+      ref="captchaRef"
+      @register="registerImageCaptchaModal"
+      v-if="computedCaptchaIdent == 'IMAGE'"
+      @end="handleImageCaptchaEnd"
+      :type="computedCaptchaType"
+    />
   </Form>
 </template>
 <script lang="ts" setup>
@@ -125,7 +128,8 @@
   import { ApiServiceEnum, defHttp } from '@/utils/http/axios';
   import { createPassword } from '@/utils/auth';
   import { useAppStore } from '@/store/modules/app';
-  import { TextCaptcha } from '@/components/Verify';
+  import { TextCaptcha, ImageCaptchaModal, ImageCaptchaType } from '@/components/Verify';
+  import { useModal } from '@/components/Modal';
   //import { onKeyStroke } from '@vueuse/core';
 
   const captchaRef = ref();
@@ -150,15 +154,20 @@
   /**
    * 是否使用验证码
    */
-  const computedUseCaptcha = computed(() => {
+  const computedCaptchaIdent = computed(() => {
     return appStore.systemProperties.captchaIdent;
   });
 
+  const computedCaptchaType = computed<ImageCaptchaType>(() => {
+    return appStore.systemProperties.captchaType as ImageCaptchaType;
+  });
+
+  const [registerImageCaptchaModal, { openModal: openImageCaptchaModal }] = useModal();
+
   const formData = reactive({
     account: 'admin',
     password: '123456',
     captcha: '',
-    key: '',
   });
 
   const { validForm } = useFormValid(formRef);
@@ -168,6 +177,25 @@
   const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN);
 
   async function handleLogin() {
+    if (unref(computedCaptchaIdent) === 'IMAGE') {
+      const data = await validForm();
+      if (!data) return;
+      openImageCaptchaModal();
+      return;
+    }
+    let code = '';
+    if (unref(computedCaptchaIdent) === 'TEXT') {
+      code = JSON.stringify(unref(captchaRef).createValidateParameter(formData.captcha));
+    }
+    doLogin(code);
+  }
+
+  const handleImageCaptchaEnd = (parameter) => {
+    const code = JSON.stringify(parameter);
+    doLogin(code);
+  };
+
+  const doLogin = async (code: string) => {
     const data = await validForm();
     if (!data) return;
     try {
@@ -176,8 +204,7 @@
         password: createPassword(data.account, data.password),
         username: data.account,
         mode: 'none', //不要默认的错误提示
-        key: formData.key,
-        code: JSON.stringify(unref(captchaRef).createValidateParameter(formData.captcha)),
+        code,
       });
       if (userInfo) {
         notification.success({
@@ -190,13 +217,13 @@
       createErrorModal({
         title: t('sys.api.errorTip'),
         content: (error as unknown as Error).message || t('sys.api.networkExceptionMsg'),
-        getContainer: () => document.body.querySelector(`.${prefixCls}`) || document.body,
+        // getContainer: () => document.body.querySelector(`.${prefixCls}`) || document.body,
       });
       unref(captchaRef).refresh();
     } finally {
       loading.value = false;
     }
-  }
+  };
 
   const getCaptchaApi = () => {
     return defHttp.post({