Browse Source

feat: 实现滑块验证中拼图块也能拖动

zhouyuhao 5 tháng trước cách đây
mục cha
commit
1b269d3e11

+ 113 - 83
src/components/VSliderVerification/src/VSliderVerification.vue

@@ -6,7 +6,8 @@ import Img03 from './image/verification-img-3.png'
 import Img04 from './image/verification-img-4.png'
 import { ref } from 'vue'
 //引入'vue3-puzzle-vcode'插件
-import Vcode from 'vue3-puzzle-vcode'
+import Vcode from './components/SliderVerification.vue'
+// import Vcode from 'vue3-puzzle-vcode'
 
 const openDialog = () => {
   isShow.value = true
@@ -31,9 +32,16 @@ const updateRefreshImg = () => {
   }
 }
 const addTipsNode = () => {
+  if (document.querySelector('.v-slider-verification-tips')) {
+    return
+  }
   const childNode = document.createElement('div')
-  childNode.className = 'tips'
+  childNode.className = 'v-slider-verification-tips'
   childNode.innerHTML = `
+    <div style="margin-bottom: 5px; text-align: right;">
+      <span class="font_family icon-icon_reject_b close-icon" style="margin-right: -20px;">
+      </span>
+    </div>
     <p>Please drag the slider below to complete the</p>
     <p>verification to ensure normal access</p>
   `
@@ -43,6 +51,15 @@ const addTipsNode = () => {
   } else {
     parentNode.appendChild(childNode)
   }
+  nextTick(() => {
+    const targetNode: any = document.querySelector('.font_family.icon-icon_reject_b.close-icon')
+    targetNode.onclick = closeDialog
+  })
+}
+
+const closeDialog = () => {
+  isShow.value = false
+  emit('close')
 }
 
 const styleMap = {
@@ -88,6 +105,7 @@ const updateSliderBackground = (state: string) => {
 
 const emit = defineEmits<{
   close: []
+  success: []
 }>()
 // 监听验证成功事件,因为这里库中的成功事件有0.8秒的延迟,所以这里手动监听验证成功事件
 const onSuccess = () => {
@@ -101,7 +119,7 @@ const onSuccess = () => {
             updateSliderBackground('success')
             addSliderBtnNode('success')
             setTimeout(() => {
-              emit('close')
+              emit('success')
               isShow.value = false
             }, 500)
           }
@@ -118,6 +136,7 @@ const onSuccess = () => {
 }
 const close = () => {
   isShow.value = true
+  emit('close')
 }
 const fail = () => {
   updateSliderBackground('error')
@@ -133,24 +152,26 @@ defineExpose({
 })
 </script>
 <template>
-  <Vcode
-    :show="isShow"
-    @close="close"
-    @fail="fail"
-    :canvasWidth="320"
-    :canvasHeight="180"
-    sliderText="Swipe to verify"
-    :sliderSize="38"
-    successText="Verification successful"
-    failText="Verification failed"
-    :range="5"
-    :imgs="[Img01, Img02, Img03, Img04]"
-  ></Vcode>
+  <div>
+    <Vcode
+      :show="isShow"
+      @close="close"
+      @fail="fail"
+      :canvasWidth="320"
+      :canvasHeight="180"
+      sliderText="Swipe to verify"
+      :sliderSize="38"
+      successText="Verification successful"
+      failText="Verification failed"
+      :range="5"
+      :imgs="[Img01, Img02, Img03, Img04]"
+    ></Vcode>
+  </div>
 </template>
 <style lang="scss" scoped>
 .slider-verification {
   width: 400px;
-  height: 373px;
+  height: 365px;
   padding: 40px;
   .tips {
     text-align: center;
@@ -158,81 +179,90 @@ defineExpose({
 }
 </style>
 <style lang="scss">
-// 整体框架
-.vue-auth-box_ {
-  width: 400px;
-  height: 373px;
-  padding: 40px;
-  background-color: var(--color-slider-bg);
-  border-radius: 16px;
-  box-shadow: -2px 2px 12px 0 rgba(0, 0, 0, 0.5);
-  .tips {
-    margin-bottom: 16px;
-    text-align: center;
-  }
-  .icon-border {
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    width: 16px !important;
-    height: 16px !important;
-    border-radius: 50%;
-    border: none !important;
-    span {
-      font-size: 14px !important;
+.vue-puzzle-vcode {
+  // 整体框架
+  div.vue-auth-box_ {
+    width: 400px;
+    height: 365px;
+    padding: 40px;
+    padding-top: 16px;
+    background-color: var(--color-slider-bg);
+    border-radius: 16px;
+    box-shadow: -2px 2px 12px 0 rgba(0, 0, 0, 0.5);
+    .v-slider-verification-tips {
+      margin-bottom: 16px;
+      text-align: center;
+      .close-icon {
+        cursor: pointer;
+        &:hover {
+          color: var(--color-theme);
+        }
+      }
+    }
+    .icon-border {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      width: 16px !important;
+      height: 16px !important;
+      border-radius: 50%;
+      border: none !important;
+      span {
+        font-size: 14px !important;
+      }
     }
   }
-}
 
-div.vue-puzzle-vcode {
-  background-color: rgba(43, 47, 54, 0.7);
-}
+  div.vue-puzzle-vcode {
+    background-color: rgba(43, 47, 54, 0.7);
+  }
 
-.vue-auth-box_ .auth-control_ div.range-box {
-  background-color: #87909e;
-  box-shadow: none;
-}
-.vue-auth-box_ div.auth-body_ {
-  border-radius: 6px;
-}
-// 已滑动区域的样式
-.vue-auth-box_ .auth-control_ .range-box div.range-slider {
-  background-color: var(--color-success);
-}
+  .vue-auth-box_ .auth-control_ div.range-box {
+    background-color: #87909e;
+    box-shadow: none;
+  }
+  .vue-auth-box_ div.auth-body_ {
+    border-radius: 6px;
+  }
+  // 已滑动区域的样式
+  .vue-auth-box_ .auth-control_ .range-box div.range-slider {
+    background-color: var(--color-success);
+  }
 
-.vue-auth-box_ .auth-body_ div.loading-box_.hide_ {
-  display: none;
-}
+  .vue-auth-box_ .auth-body_ div.loading-box_.hide_ {
+    display: none;
+  }
 
-// 成功的提示
-.vue-auth-box_ .auth-body_ div.info-box_ {
-  background: var(--color-success);
-}
-// 失败的提示
-.vue-auth-box_ .auth-body_ div.info-box_.fail {
-  background: #c7353f;
-}
+  // 成功的提示
+  .vue-auth-box_ .auth-body_ div.info-box_ {
+    background: var(--color-success);
+  }
+  // 失败的提示
+  .vue-auth-box_ .auth-body_ div.info-box_.fail {
+    background: #c7353f;
+  }
 
-.vue-auth-box_ .auth-control_ .range-box .range-slider {
-  border-radius: 6px;
-}
+  .vue-auth-box_ .auth-control_ .range-box .range-slider {
+    border-radius: 6px;
+  }
 
-.vue-auth-box_ .auth-control_ div.range-box {
-  border-radius: 6px;
-}
-// 滑块
-.vue-auth-box_ .auth-control_ .range-box .range-slider .range-btn {
-  border-radius: 6px;
-}
+  .vue-auth-box_ .auth-control_ div.range-box {
+    border-radius: 6px;
+  }
+  // 滑块
+  .vue-auth-box_ .auth-control_ .range-box .range-slider .range-btn {
+    border-radius: 6px;
+  }
 
-.vue-auth-box_ .auth-body_ .reset_ {
-  width: 20px;
-  height: 20px;
-  top: 10px;
-  right: 10px;
-}
+  .vue-auth-box_ .auth-body_ .reset_ {
+    width: 20px;
+    height: 20px;
+    top: 10px;
+    right: 10px;
+  }
 
-.vue-auth-box_ .auth-control_ .range-box .range-text {
-  color: #fff;
+  .vue-auth-box_ .auth-control_ .range-box .range-text {
+    color: #fff;
+  }
 }
 </style>

+ 817 - 0
src/components/VSliderVerification/src/components/SliderVerification.vue

@@ -0,0 +1,817 @@
+<template>
+  <!-- 本体部分 -->
+  <div
+    :class="['vue-puzzle-vcode', { show_: show }]"
+    @mousedown="onCloseMouseDown"
+    @mouseup="onCloseMouseUp"
+    @touchstart="onCloseMouseDown"
+    @touchend="onCloseMouseUp"
+  >
+    <div class="vue-auth-box_" @mousedown.stop @touchstart.stop>
+      <div class="auth-body_" :style="`height: ${canvasHeight}px`">
+        <!-- 主图,有缺口 -->
+        <canvas
+          ref="canvas1"
+          :width="canvasWidth"
+          :height="canvasHeight"
+          :style="`width:${canvasWidth}px;height:${canvasHeight}px`"
+        />
+        <!-- 成功后显示的完整图 -->
+        <canvas
+          ref="canvas3"
+          :class="['auth-canvas3_', { show: isSuccess }]"
+          :width="canvasWidth"
+          :height="canvasHeight"
+          :style="`width:${canvasWidth}px;height:${canvasHeight}px`"
+        />
+        <!-- 小图 -->
+        <canvas
+          :width="puzzleBaseSize"
+          class="auth-canvas2_"
+          :height="canvasHeight"
+          ref="canvas2"
+          @mousedown="onRangeMouseDown($event)"
+          @touchstart="onRangeMouseDown($event)"
+          :style="`width:${puzzleBaseSize}px;height:${canvasHeight}px;transform:translateX(${
+            styleWidth -
+            sliderBaseSize -
+            (puzzleBaseSize - sliderBaseSize) *
+              ((styleWidth - sliderBaseSize) / (canvasWidth - sliderBaseSize))
+          }px)`"
+        />
+        <div :class="['loading-box_', { hide_: !loading }]">
+          <div class="loading-gif_">
+            <span></span>
+            <span></span>
+            <span></span>
+            <span></span>
+            <span></span>
+          </div>
+        </div>
+        <div :class="['info-box_', { show: infoBoxShow }, { fail: infoBoxFail }]">
+          {{ infoText }}
+        </div>
+        <div
+          :class="['flash_', { show: isSuccess }]"
+          :style="`transform: translateX(${
+            isSuccess ? `${canvasWidth + canvasHeight * 0.578}px` : `-${canvasHeight * 0.578}px`
+          }) skew(-30deg, 0);`"
+        ></div>
+        <img class="reset_" @click="reset" :src="resetSvg" />
+      </div>
+      <div class="auth-control_">
+        <div class="range-box" :style="`height:${sliderBaseSize}px`">
+          <div class="range-text">{{ sliderText }}</div>
+          <div class="range-slider" ref="range-slider" :style="`width:${styleWidth}px`">
+            <div
+              :class="['range-btn', { isDown: mouseDown }]"
+              :style="`width:${sliderBaseSize}px`"
+              @mousedown="onRangeMouseDown($event)"
+              @touchstart="onRangeMouseDown($event)"
+            >
+              <div></div>
+              <div></div>
+              <div></div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script>
+import resetSvg from '../image/reset.png'
+export default {
+  props: {
+    canvasWidth: { type: Number, default: 310 }, // 主canvas的宽
+    canvasHeight: { type: Number, default: 160 }, // 主canvas的高
+    // 是否出现,由父级控制
+    show: { type: Boolean, default: false },
+    puzzleScale: { type: Number, default: 1 }, // 拼图块的大小缩放比例
+    sliderSize: { type: Number, default: 50 }, // 滑块的大小
+    range: { type: Number, default: 10 }, // 允许的偏差值
+    // 所有的背景图片
+    imgs: {
+      type: Array
+    },
+    successText: {
+      type: String,
+      default: '验证通过!'
+    },
+    failText: {
+      type: String,
+      default: '验证失败,请重试'
+    },
+    sliderText: {
+      type: String,
+      default: '拖动滑块完成拼图'
+    }
+  },
+
+  data() {
+    return {
+      mouseDown: false, // 鼠标是否在按钮上按下
+      startWidth: 50, // 鼠标点下去时父级的width
+      startX: 0, // 鼠标按下时的X
+      newX: 0, // 鼠标当前的偏移X
+      pinX: 0, // 拼图的起始X
+      pinY: 0, // 拼图的起始Y
+      loading: false, // 是否正在加在中,主要是等图片onload
+      isCanSlide: false, // 是否可以拉动滑动条
+      error: false, // 图片加在失败会出现这个,提示用户手动刷新
+      infoBoxShow: false, // 提示信息是否出现
+      infoText: '', // 提示等信息
+      infoBoxFail: false, // 是否验证失败
+      timer1: null, // setTimout1
+      closeDown: false, // 为了解决Mac上的click BUG
+      isSuccess: false, // 验证成功
+      imgIndex: -1, // 用于自定义图片时不会随机到重复的图片
+      isSubmting: false, // 是否正在判定,主要用于判定中不能点击重置按钮
+      resetSvg
+    }
+  },
+
+  /** 生命周期 **/
+  mounted() {
+    document.body.appendChild(this.$el)
+    document.addEventListener('mousemove', this.onRangeMouseMove, false)
+    document.addEventListener('mouseup', this.onRangeMouseUp, false)
+
+    document.addEventListener('touchmove', this.onRangeMouseMove, {
+      passive: false
+    })
+    document.addEventListener('touchend', this.onRangeMouseUp, false)
+    if (this.show) {
+      document.body.classList.add('vue-puzzle-overflow')
+      this.reset()
+    }
+  },
+  beforeUnmount() {
+    clearTimeout(this.timer1)
+    document.body.removeChild(this.$el)
+    document.removeEventListener('mousemove', this.onRangeMouseMove, false)
+    document.removeEventListener('mouseup', this.onRangeMouseUp, false)
+
+    document.removeEventListener('touchmove', this.onRangeMouseMove, {
+      passive: false
+    })
+    document.removeEventListener('touchend', this.onRangeMouseUp, false)
+  },
+
+  /** 监听 **/
+  watch: {
+    show(newV) {
+      // 每次出现都应该重新初始化
+      if (newV) {
+        document.body.classList.add('vue-puzzle-overflow')
+        this.reset()
+      } else {
+        this.isSubmting = false
+        this.isSuccess = false
+        this.infoBoxShow = false
+        document.body.classList.remove('vue-puzzle-overflow')
+      }
+    }
+  },
+
+  /** 计算属性 **/
+  computed: {
+    // styleWidth是底部用户操作的滑块的父级,就是轨道在鼠标的作用下应该具有的宽度
+    styleWidth() {
+      const w = this.startWidth + this.newX - this.startX
+      return w < this.sliderBaseSize
+        ? this.sliderBaseSize
+        : w > this.canvasWidth
+          ? this.canvasWidth
+          : w
+    },
+    // 图中拼图块的60 * 用户设定的缩放比例计算之后的值 0.2~2
+    puzzleBaseSize() {
+      return Math.round(Math.max(Math.min(this.puzzleScale, 2), 0.2) * 52.5 + 6)
+    },
+    // 处理一下sliderSize,弄成整数,以免计算有偏差
+    sliderBaseSize() {
+      return Math.max(Math.min(Math.round(this.sliderSize), Math.round(this.canvasWidth * 0.5)), 10)
+    }
+  },
+
+  /** 方法 **/
+  methods: {
+    // 关闭
+    onClose() {
+      if (!this.mouseDown && !this.isSubmting) {
+        clearTimeout(this.timer1)
+        this.$emit('close')
+      }
+    },
+    onCloseMouseDown() {
+      this.closeDown = true
+    },
+    onCloseMouseUp() {
+      if (this.closeDown) {
+        this.onClose()
+      }
+      this.closeDown = false
+    },
+    // 鼠标按下准备拖动
+    onRangeMouseDown(e) {
+      if (this.isCanSlide) {
+        this.mouseDown = true
+        this.startWidth = this.$refs['range-slider'].clientWidth
+        this.newX = e.clientX || e.changedTouches[0].clientX
+        this.startX = e.clientX || e.changedTouches[0].clientX
+      }
+    },
+    // 鼠标移动
+    onRangeMouseMove(e) {
+      if (this.mouseDown) {
+        e.preventDefault()
+        this.newX = e.clientX || e.changedTouches[0].clientX
+      }
+    },
+    // 鼠标抬起
+    onRangeMouseUp() {
+      if (this.mouseDown) {
+        this.mouseDown = false
+        this.submit()
+      }
+    },
+    /**
+     * 开始进行
+     * @param withCanvas 是否强制使用canvas随机作图
+     */
+    init(withCanvas) {
+      // 防止重复加载导致的渲染错误
+      if (this.loading && !withCanvas) {
+        return
+      }
+      this.loading = true
+      this.isCanSlide = false
+      const c = this.$refs.canvas1
+      const c2 = this.$refs.canvas2
+      const c3 = this.$refs.canvas3
+      const ctx = c.getContext('2d')
+      const ctx2 = c2.getContext('2d')
+      const ctx3 = c3.getContext('2d')
+      const isFirefox =
+        navigator.userAgent.indexOf('Firefox') >= 0 && navigator.userAgent.indexOf('Windows') >= 0 // 是windows版火狐
+      const img = document.createElement('img')
+      ctx.fillStyle = 'rgba(255,255,255,1)'
+      ctx3.fillStyle = 'rgba(255,255,255,1)'
+      ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
+      ctx2.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
+
+      // 取一个随机坐标,作为拼图块的位置
+      this.pinX = this.getRandom(this.puzzleBaseSize, this.canvasWidth - this.puzzleBaseSize - 20) // 留20的边距
+      this.pinY = this.getRandom(20, this.canvasHeight - this.puzzleBaseSize - 20) // 主图高度 - 拼图块自身高度 - 20边距
+      img.crossOrigin = 'anonymous' // 匿名,想要获取跨域的图片
+      img.onload = () => {
+        const [x, y, w, h] = this.makeImgSize(img)
+        ctx.save()
+        // 先画小图
+        this.paintBrick(ctx)
+        ctx.closePath()
+        if (!isFirefox) {
+          ctx.shadowOffsetX = 0
+          ctx.shadowOffsetY = 0
+          ctx.shadowColor = '#000'
+          ctx.shadowBlur = 3
+          ctx.fill()
+          ctx.clip()
+        } else {
+          ctx.clip()
+          ctx.save()
+          ctx.shadowOffsetX = 0
+          ctx.shadowOffsetY = 0
+          ctx.shadowColor = '#000'
+          ctx.shadowBlur = 3
+          ctx.fill()
+          ctx.restore()
+        }
+
+        ctx.drawImage(img, x, y, w, h)
+        ctx3.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
+        ctx3.drawImage(img, x, y, w, h)
+
+        // 设置小图的内阴影
+        ctx.globalCompositeOperation = 'source-atop'
+
+        this.paintBrick(ctx)
+
+        ctx.arc(
+          this.pinX + Math.ceil(this.puzzleBaseSize / 2),
+          this.pinY + Math.ceil(this.puzzleBaseSize / 2),
+          this.puzzleBaseSize * 1.2,
+          0,
+          Math.PI * 2,
+          true
+        )
+        ctx.closePath()
+        ctx.shadowColor = 'rgba(255, 255, 255, .8)'
+        ctx.shadowOffsetX = -1
+        ctx.shadowOffsetY = -1
+        ctx.shadowBlur = Math.min(Math.ceil(8 * this.puzzleScale), 12)
+        ctx.fillStyle = '#ffffaa'
+        ctx.fill()
+
+        // 将小图赋值给ctx2
+        const imgData = ctx.getImageData(
+          this.pinX - 3, // 为了阴影 是从-3px开始截取,判定的时候要+3px
+          this.pinY - 20,
+          this.pinX + this.puzzleBaseSize + 5,
+          this.pinY + this.puzzleBaseSize + 5
+        )
+        ctx2.putImageData(imgData, 0, this.pinY - 20)
+
+        // ctx2.drawImage(c, this.pinX - 3,this.pinY - 20,this.pinX + this.puzzleBaseSize + 5,this.pinY + this.puzzleBaseSize + 5,
+        // 0, this.pinY - 20, this.pinX + this.puzzleBaseSize + 5, this.pinY + this.puzzleBaseSize + 5);
+
+        // 清理
+        ctx.restore()
+        ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
+
+        // 画缺口
+        ctx.save()
+        this.paintBrick(ctx)
+        ctx.globalAlpha = 0.8
+        ctx.fillStyle = '#ffffff'
+        ctx.fill()
+        ctx.restore()
+
+        // 画缺口的内阴影
+        ctx.save()
+        ctx.globalCompositeOperation = 'source-atop'
+        this.paintBrick(ctx)
+        ctx.arc(
+          this.pinX + Math.ceil(this.puzzleBaseSize / 2),
+          this.pinY + Math.ceil(this.puzzleBaseSize / 2),
+          this.puzzleBaseSize * 1.2,
+          0,
+          Math.PI * 2,
+          true
+        )
+        ctx.shadowColor = '#000'
+        ctx.shadowOffsetX = 2
+        ctx.shadowOffsetY = 2
+        ctx.shadowBlur = 16
+        ctx.fill()
+        ctx.restore()
+
+        // 画整体背景图
+        ctx.save()
+        ctx.globalCompositeOperation = 'destination-over'
+        ctx.drawImage(img, x, y, w, h)
+        ctx.restore()
+
+        this.loading = false
+        this.isCanSlide = true
+      }
+      img.onerror = () => {
+        this.init(true) // 如果图片加载错误就重新来,并强制用canvas随机作图
+      }
+
+      if (!withCanvas && this.imgs && this.imgs.length) {
+        let randomNum = this.getRandom(0, this.imgs.length - 1)
+        if (randomNum === this.imgIndex) {
+          if (randomNum === this.imgs.length - 1) {
+            randomNum = 0
+          } else {
+            randomNum++
+          }
+        }
+        this.imgIndex = randomNum
+        img.src = this.imgs[randomNum]
+      } else {
+        img.src = this.makeImgWithCanvas()
+      }
+    },
+    // 工具 - 范围随机数
+    getRandom(min, max) {
+      return Math.ceil(Math.random() * (max - min) + min)
+    },
+    // 工具 - 设置图片尺寸cover方式贴合canvas尺寸 w/h
+    makeImgSize(img) {
+      const imgScale = img.width / img.height
+      const canvasScale = this.canvasWidth / this.canvasHeight
+      let x = 0,
+        y = 0,
+        w = 0,
+        h = 0
+      if (imgScale > canvasScale) {
+        h = this.canvasHeight
+        w = imgScale * h
+        y = 0
+        x = (this.canvasWidth - w) / 2
+      } else {
+        w = this.canvasWidth
+        h = w / imgScale
+        x = 0
+        y = (this.canvasHeight - h) / 2
+      }
+      return [x, y, w, h]
+    },
+    // 绘制拼图块的路径
+    paintBrick(ctx) {
+      const moveL = Math.ceil(15 * this.puzzleScale) // 直线移动的基础距离
+      ctx.beginPath()
+      ctx.moveTo(this.pinX, this.pinY)
+      ctx.lineTo(this.pinX + moveL, this.pinY)
+      ctx.arcTo(
+        this.pinX + moveL,
+        this.pinY - moveL / 2,
+        this.pinX + moveL + moveL / 2,
+        this.pinY - moveL / 2,
+        moveL / 2
+      )
+      ctx.arcTo(
+        this.pinX + moveL + moveL,
+        this.pinY - moveL / 2,
+        this.pinX + moveL + moveL,
+        this.pinY,
+        moveL / 2
+      )
+      ctx.lineTo(this.pinX + moveL + moveL + moveL, this.pinY)
+      ctx.lineTo(this.pinX + moveL + moveL + moveL, this.pinY + moveL)
+      ctx.arcTo(
+        this.pinX + moveL + moveL + moveL + moveL / 2,
+        this.pinY + moveL,
+        this.pinX + moveL + moveL + moveL + moveL / 2,
+        this.pinY + moveL + moveL / 2,
+        moveL / 2
+      )
+      ctx.arcTo(
+        this.pinX + moveL + moveL + moveL + moveL / 2,
+        this.pinY + moveL + moveL,
+        this.pinX + moveL + moveL + moveL,
+        this.pinY + moveL + moveL,
+        moveL / 2
+      )
+      ctx.lineTo(this.pinX + moveL + moveL + moveL, this.pinY + moveL + moveL + moveL)
+      ctx.lineTo(this.pinX, this.pinY + moveL + moveL + moveL)
+      ctx.lineTo(this.pinX, this.pinY + moveL + moveL)
+
+      ctx.arcTo(
+        this.pinX + moveL / 2,
+        this.pinY + moveL + moveL,
+        this.pinX + moveL / 2,
+        this.pinY + moveL + moveL / 2,
+        moveL / 2
+      )
+      ctx.arcTo(this.pinX + moveL / 2, this.pinY + moveL, this.pinX, this.pinY + moveL, moveL / 2)
+      ctx.lineTo(this.pinX, this.pinY)
+    },
+    // 用canvas随机生成图片
+    makeImgWithCanvas() {
+      const canvas = document.createElement('canvas')
+      const ctx = canvas.getContext('2d')
+      canvas.width = this.canvasWidth
+      canvas.height = this.canvasHeight
+      ctx.fillStyle = `rgb(${this.getRandom(100, 255)},${this.getRandom(100, 255)},${this.getRandom(
+        100,
+        255
+      )})`
+      ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
+      // 随机画10个图形
+      for (let i = 0; i < 12; i++) {
+        ctx.fillStyle = `rgb(${this.getRandom(100, 255)},${this.getRandom(
+          100,
+          255
+        )},${this.getRandom(100, 255)})`
+        ctx.strokeStyle = `rgb(${this.getRandom(100, 255)},${this.getRandom(
+          100,
+          255
+        )},${this.getRandom(100, 255)})`
+
+        if (this.getRandom(0, 2) > 1) {
+          // 矩形
+          ctx.save()
+          ctx.rotate((this.getRandom(-90, 90) * Math.PI) / 180)
+          ctx.fillRect(
+            this.getRandom(-20, canvas.width - 20),
+            this.getRandom(-20, canvas.height - 20),
+            this.getRandom(10, canvas.width / 2 + 10),
+            this.getRandom(10, canvas.height / 2 + 10)
+          )
+          ctx.restore()
+        } else {
+          // 圆
+          ctx.beginPath()
+          const ran = this.getRandom(-Math.PI, Math.PI)
+          ctx.arc(
+            this.getRandom(0, canvas.width),
+            this.getRandom(0, canvas.height),
+            this.getRandom(10, canvas.height / 2 + 10),
+            ran,
+            ran + Math.PI * 1.5
+          )
+          ctx.closePath()
+          ctx.fill()
+        }
+      }
+      return canvas.toDataURL('image/png')
+    },
+    // 开始判定
+    submit() {
+      this.isSubmting = true
+      // 偏差 x = puzzle的起始X - (用户真滑动的距离) + (puzzle的宽度 - 滑块的宽度) * (用户真滑动的距离/canvas总宽度)
+      // 最后+ 的是补上slider和滑块宽度不一致造成的缝隙
+      const x = Math.abs(
+        this.pinX -
+          (this.styleWidth - this.sliderBaseSize) +
+          (this.puzzleBaseSize - this.sliderBaseSize) *
+            ((this.styleWidth - this.sliderBaseSize) / (this.canvasWidth - this.sliderBaseSize)) -
+          3
+      )
+      if (x < this.range) {
+        // 成功
+        this.infoText = this.successText
+        this.infoBoxFail = false
+        this.infoBoxShow = true
+        this.isCanSlide = false
+        this.isSuccess = true
+        // 成功后准备关闭
+        clearTimeout(this.timer1)
+        this.timer1 = setTimeout(() => {
+          // 成功的回调
+          this.isSubmting = false
+          this.$emit('success', x)
+        }, 400)
+      } else {
+        // 失败
+        this.infoText = this.failText
+        this.infoBoxFail = true
+        this.infoBoxShow = true
+        this.isCanSlide = false
+        // 失败的回调
+        this.$emit('fail', x)
+        // 400ms后重置
+        clearTimeout(this.timer1)
+        this.timer1 = setTimeout(() => {
+          this.isSubmting = false
+          this.reset()
+        }, 400)
+      }
+    },
+    // 重置 - 重新设置初始状态
+    resetState() {
+      this.infoBoxFail = false
+      this.infoBoxShow = false
+      this.isCanSlide = false
+      this.isSuccess = false
+      this.startWidth = this.sliderBaseSize // 鼠标点下去时父级的width
+      this.startX = 0 // 鼠标按下时的X
+      this.newX = 0 // 鼠标当前的偏移X
+    },
+
+    // 重置
+    reset() {
+      if (this.isSubmting) {
+        return
+      }
+      this.resetState()
+      this.init()
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.vue-puzzle-vcode {
+  position: fixed;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  right: 0;
+  background-color: rgba(0, 0, 0, 0.3);
+  z-index: 999;
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 200ms;
+  &.show_ {
+    opacity: 1;
+    pointer-events: auto;
+  }
+}
+.vue-auth-box_ {
+  position: absolute;
+  top: 40%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  padding: 20px;
+  background: #fff;
+  user-select: none;
+  border-radius: 3px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+  .auth-body_ {
+    position: relative;
+    overflow: hidden;
+    border-radius: 3px;
+    .loading-box_ {
+      position: absolute;
+      top: 0;
+      left: 0;
+      bottom: 0;
+      right: 0;
+      background-color: rgba(0, 0, 0, 0.8);
+      z-index: 20;
+      opacity: 1;
+      transition: opacity 200ms;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      &.hide_ {
+        opacity: 0;
+        pointer-events: none;
+        .loading-gif_ {
+          span {
+            animation-play-state: paused;
+          }
+        }
+      }
+      .loading-gif_ {
+        flex: none;
+        height: 5px;
+        line-height: 0;
+        @keyframes load {
+          0% {
+            opacity: 1;
+            transform: scale(1.3);
+          }
+          100% {
+            opacity: 0.2;
+            transform: scale(0.3);
+          }
+        }
+        span {
+          display: inline-block;
+          width: 5px;
+          height: 100%;
+          margin-left: 2px;
+          border-radius: 50%;
+          background-color: #888;
+          animation: load 1.04s ease infinite;
+          &:nth-child(1) {
+            margin-left: 0;
+          }
+          &:nth-child(2) {
+            animation-delay: 0.13s;
+          }
+          &:nth-child(3) {
+            animation-delay: 0.26s;
+          }
+          &:nth-child(4) {
+            animation-delay: 0.39s;
+          }
+          &:nth-child(5) {
+            animation-delay: 0.52s;
+          }
+        }
+      }
+    }
+    .info-box_ {
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      width: 100%;
+      height: 24px;
+      line-height: 24px;
+      text-align: center;
+      overflow: hidden;
+      font-size: 13px;
+      background-color: #83ce3f;
+      opacity: 0;
+      transform: translateY(24px);
+      transition: all 200ms;
+      color: #fff;
+      z-index: 10;
+      &.show {
+        opacity: 0.95;
+        transform: translateY(0);
+      }
+      &.fail {
+        background-color: #ce594b;
+      }
+    }
+    .auth-canvas2_ {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 60px;
+      height: 100%;
+      z-index: 4;
+    }
+    .auth-canvas3_ {
+      position: absolute;
+      top: 0;
+      left: 0;
+      opacity: 0;
+      transition: opacity 600ms;
+      z-index: 3;
+      &.show {
+        opacity: 1;
+      }
+    }
+    .flash_ {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 30px;
+      height: 100%;
+      background-color: rgba(255, 255, 255, 0.1);
+      z-index: 3;
+      &.show {
+        transition: transform 600ms;
+      }
+    }
+    .reset_ {
+      position: absolute;
+      top: 2px;
+      right: 2px;
+      width: 35px;
+      height: auto;
+      z-index: 12;
+      cursor: pointer;
+      transition: transform 200ms;
+      transform: rotate(0deg);
+      &:hover {
+        transform: rotate(-90deg);
+      }
+    }
+  }
+  .auth-control_ {
+    .range-box {
+      position: relative;
+      width: 100%;
+      background-color: #eef1f8;
+      margin-top: 20px;
+      border-radius: 3px;
+      box-shadow: 0 0 8px rgba(240, 240, 240, 0.6) inset;
+      .range-text {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        font-size: 14px;
+        color: #b7bcd1;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        text-align: center;
+        width: 100%;
+      }
+      .range-slider {
+        position: absolute;
+        height: 100%;
+        width: 50px;
+        background-color: rgba(106, 160, 255, 0.8);
+        border-radius: 3px;
+        .range-btn {
+          position: absolute;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          right: 0;
+          width: 50px;
+          height: 100%;
+          background-color: #fff;
+          border-radius: 3px;
+          box-shadow: 0 0 4px #ccc;
+          cursor: pointer;
+          & > div {
+            width: 0;
+            height: 40%;
+
+            transition: all 200ms;
+            &:nth-child(2) {
+              margin: 0 4px;
+            }
+            border: solid 1px #6aa0ff;
+          }
+          &:hover,
+          &.isDown {
+            & > div:first-child {
+              border: solid 4px transparent;
+              height: 0;
+              border-right-color: #6aa0ff;
+            }
+            & > div:nth-child(2) {
+              border-width: 3px;
+              height: 0;
+              border-radius: 3px;
+              margin: 0 6px;
+              border-right-color: #6aa0ff;
+            }
+            & > div:nth-child(3) {
+              border: solid 4px transparent;
+              height: 0;
+              border-left-color: #6aa0ff;
+            }
+          }
+        }
+      }
+    }
+  }
+}
+.vue-puzzle-overflow {
+  overflow: hidden !important;
+}
+</style>

BIN
src/components/VSliderVerification/src/image/reset.png


+ 5 - 0
src/styles/elementui.scss

@@ -745,6 +745,11 @@ div .el-space {
   flex-wrap: wrap;
   margin: 3px 0 0 0;
 }
+div.element-loading {
+  .el-loading-text {
+    color: var(--color-theme);
+  }
+}
 div .el-loading-spinner .path {
   stroke: var(--color-theme);
 }

+ 20 - 3
src/views/Login/src/loginView.vue

@@ -24,6 +24,7 @@ const getQueryParams = (url: string) => {
   }
   return query
 }
+// 从旧系统使用Token登录
 const handleTokenRedirect = () => {
   if (route.query.token && route.query.uname) {
     const obj: any = getQueryParams(route.fullPath)
@@ -172,13 +173,14 @@ const handleVerification = () => {
   // 这里是登录逻辑
   // handleLoginAfterVerify()
 }
+const loginLoading = ref(false)
 // 验证结束后通过status值判断调登录还是忘记密码接口
 const confirmVerification = () => {
   // 生成验证成功的密文
   const pwd = dayjs().unix()
   confirmVerifyStatus.value = encryptVerificationPwd(pwd)
   isShowSliderVerification.value = false
-
+  loginLoading.value = true
   if (status.value === 'login') {
     handleLoginAfterVerify()
   } else {
@@ -256,6 +258,9 @@ const handleLoginAfterVerify = () => {
       }
       handleResult(res)
     })
+    .finally(() => {
+      loginLoading.value = false
+    })
 }
 
 // 从忘记密码返回登录
@@ -282,6 +287,7 @@ const handleForgot = () => {
 }
 const handleSendPassword = () => {
   if (!isUserNameExit.value || !loginForm.value.username) {
+    loginLoading.value = false
     return
   }
   // 这里是发送密码逻辑
@@ -296,6 +302,9 @@ const handleSendPassword = () => {
         backLogin(true)
       }
     })
+    .finally(() => {
+      loginLoading.value = false
+    })
 }
 
 const isEmailTips = ref(false)
@@ -324,7 +333,14 @@ const firstLoginTipsRef = ref()
 </script>
 
 <template>
-  <div class="login" :class="{ 'dark-bg': themeStore.theme === 'dark' }">
+  <div
+    class="login"
+    :class="{ 'dark-bg': themeStore.theme === 'dark' }"
+    v-loading.fullscreen.lock="loginLoading"
+    element-loading-text="Loading..."
+    element-loading-custom-class="element-loading"
+    element-loading-background="rgb(43, 47, 54, 0.7)"
+  >
     <ScoringSystem class="scoring-system"></ScoringSystem>
     <el-card class="login-card" v-if="status === 'login'">
       <div class="card-title" :class="{ 'is-dark': themeStore.theme === 'dark' }">
@@ -452,7 +468,8 @@ const firstLoginTipsRef = ref()
     </el-card>
     <VSliderVerification
       v-if="isShowSliderVerification"
-      @close="confirmVerification"
+      @success="confirmVerification"
+      @close="isShowSliderVerification = false"
       ref="sliderVerificationRef"
     ></VSliderVerification>
     <ErrorTips ref="errorTipsRef" @forget-password="status = 'reset'"></ErrorTips>

+ 2 - 1
src/views/Tracking/src/components/PublicTracking/src/PublicTrackingSearch.vue

@@ -138,7 +138,8 @@ const encryptPassword = (password) => {
     </div>
     <VSliderVerification
       v-if="isShowSliderVerification"
-      @close="confirmVerification"
+      @success="confirmVerification"
+      @close="isShowSliderVerification = false"
       ref="sliderVerificationRef"
     ></VSliderVerification>
   </div>