滑动验证码

一、效果图

在这里插入图片描述

二、封装成一个组件(SlideVerify.vue)

<template>
  <div
    class="slide-verify-wrap"
    :style="{ width: w + 'px' }"
    id="slideVerify"
    onselectstart="return false;"
  >
    <!-- 图片加载遮蔽罩 -->
    <div :class="{ 'slider-verify-loading': loadBlock }"></div>

    <canvas
      :width="w"
      :height="h"
      ref="canvas"
    ></canvas>

    <!-- 刷新icon -->
    <div
      v-if="show"
      class="slide-verify-refresh-icon"
      @click="refresh"
    ></div>

    <canvas
      :width="w"
      :height="h"
      ref="block"
      class="slide-verify-block"
    ></canvas>

    <!-- 滑块区域 -->
    <div
      class="slide-verify-slider"
      :class="{
        'container-active': containerActive,
        'container-success': containerSuccess,
        'container-fail': containerFail
      }"
    >
      <div
        class="slide-verify-slider-mask"
        :style="{ width: sliderMaskWidth }"
      >
        <!-- slider -->
        <div
          @mousedown="sliderDown"
          @touchstart="touchStartEvent"
          @touchmove="handleMoveEvent($event, 'touch')"
          @touchend="handleMoveEndEvent($event, 'touch')"
          class="slide-verify-slider-mask-item"
          :style="{ left: sliderLeft }"
        >
          <div class="slide-verify-slider-mask-item-icon"></div>
        </div>
      </div>
      <span class="slide-verify-slider-text">{{ sliderText }}</span>
    </div>
  </div>
</template>

<script>
// 定义常量
const PI = Math.PI

// 自定义函数
function sum(x, y) {
  return x + y
}

function square(x) {
  return x * x
}

function throttle(fn, interval = 50, options = { leading: true, trailing: true }) {
  const { leading, trailing, resultCallback } = options
  let lastTime = 0
  let timer = null

  const _throttle = function (...args) {
    return new Promise((resolve, reject) => {
      const nowTime = new Date().getTime()
      if (!lastTime && !leading) lastTime = nowTime

      const remainTime = interval - (nowTime - lastTime)
      if (remainTime <= 0) {
        if (timer) {
          clearTimeout(timer)
          timer = null
        }

        const result = fn.apply(this, args)
        if (resultCallback) resultCallback(result)
        resolve(result)
        lastTime = nowTime
        return
      }

      if (trailing && !timer) {
        timer = setTimeout(() => {
          timer = null
          lastTime = !leading ? 0 : new Date().getTime()
          const result = fn.apply(this, args)
          if (resultCallback) resultCallback(result)
          resolve(result)
        }, remainTime)
      }
    })
  }

  _throttle.cancel = function () {
    if (timer) clearTimeout(timer)
    timer = null
    lastTime = 0
  }

  return _throttle
}

export default {
  name: 'SlideVerify',
  props: {
    // block length
    l: {
      type: Number,
      default: 40
    },
    // block radius
    r: {
      type: Number,
      default: 10
    },
    // canvas width
    w: {
      type: Number,
      default: 310
    },
    // canvas height
    h: {
      type: Number,
      default: 155
    },
    // 滑块显示文字
    sliderText: {
      type: String,
      default: 'Slide filled right'
    },
    accuracy: {
      type: Number,
      default: 5 // 若为 -1 则不进行机器判断
    },
    // 是否显示刷新icon
    show: {
      type: Boolean,
      default: true
    },
    imgs: {
      type: Array,
      default: () => []
    }
  },
  data() {
    return {
      loadBlock: true, // Features 图片加载提示,防止图片没加载完就开始验证
      containerActive: false, // 滑块选中样式
      containerSuccess: false, // 滑块成功样式
      containerFail: false, // 滑块失败样式
      sliderMaskWidth: 0, // 滑块拖动宽度
      sliderLeft: 0, // 滑块距左侧的距离
      canvasCtx: null,
      block: null,
      blockCtx: null,
      img: undefined,
      L: this.l + this.r * 2 + 3, // block real length
      block_x: undefined, // container random position
      block_y: undefined,
      isMouseDown: false,
      originX: undefined,
      originY: undefined,
      trail: [],
      timestamp: null,
      success: false // Bug Fixes 修复了验证成功后还能滑动
    }
  },
  mounted() {
    this.init()
  },
  destroyed() {
    document.removeEventListener('mousemove', this.handleMoveEvent)
    document.removeEventListener('mouseup', this.handleMoveEndEvent)
  },
  methods: {
    init() {
      this.initDom()
      this.initImg()
      this.bindEvents()
    },

    initDom() {
      this.block = this.$refs.block
      this.canvasCtx = this.$refs.canvas.getContext('2d')
      this.blockCtx = this.block.getContext('2d')
    },

    initImg() {
      const img = this.createImg(() => {
        // 图片加载完关闭遮蔽罩
        this.loadBlock = false
        this.drawBlock()
        this.canvasCtx.drawImage(img, 0, 0, this.w, this.h)
        this.blockCtx.drawImage(img, 0, 0, this.w, this.h)
        const { block_x: x, block_y: y, r, L } = this
        const _y = y - r * 2 - 1
        const ImageData = this.blockCtx.getImageData(x, _y, L, L)
        this.block.width = L
        this.blockCtx.putImageData(ImageData, 0, _y)
      })
      this.img = img
    },

    createImg(onload) {
      const img = document.createElement('img')
      img.crossOrigin = 'Anonymous'
      img.onload = onload
      img.onerror = () => {
        img.src = this.getRandomImg()
      }
      img.src = this.getRandomImg()
      return img
    },

    // 随机生成img src
    getRandomImg() {
      const len = this.imgs.length
      return len > 0
        ? this.imgs[this.getRandomNumberByRange(0, len - 1)]
        : 'https://source.unsplash.com/300x150/?book,library'
    },

    // 随机数
    getRandomNumberByRange(start, end) {
      return Math.round(Math.random() * (end - start) + start)
    },

    drawBlock() {
      this.block_x = this.getRandomNumberByRange(this.L + 10, this.w - (this.L + 10))
      this.block_y = this.getRandomNumberByRange(10 + this.r * 2, this.h - (this.L + 10))
      this.draw(this.canvasCtx, this.block_x, this.block_y, 'fill')
      this.draw(this.blockCtx, this.block_x, this.block_y, 'clip')
    },

    draw(ctx, x, y, operation) {
      const { l, r } = this
      ctx.beginPath()
      ctx.moveTo(x, y)
      ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI)
      ctx.lineTo(x + l, y)
      ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI)
      ctx.lineTo(x + l, y + l)
      ctx.lineTo(x, y + l)
      ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true)
      ctx.lineTo(x, y)
      ctx.lineWidth = 2
      ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
      ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'
      ctx.stroke()
      ctx[operation]()
      // Bug Fixes 修复了火狐和ie显示问题
      ctx.globalCompositeOperation = 'destination-over'
    },

    bindEvents() {
      document.addEventListener('mousemove', this.handleMoveEvent)
      document.addEventListener('mouseup', this.handleMoveEndEvent)
    },

    // 处理函数抽离
    handleMoveEvent: throttle(function (e, type = 'mouse') {
      if (!this.isMouseDown) return false
      const moveX = type === 'mouse' ? e.clientX - this.originX : e.changedTouches[0].pageX - this.originX
      const moveY = type === 'mouse' ? e.clientY - this.originY : e.changedTouches[0].pageY - this.originY
      if (moveX < 0 || moveX + 38 >= this.w) return false
      this.sliderLeft = moveX + 'px'
      const blockLeft = ((this.w - 40 - 20) / (this.w - 40)) * moveX
      this.block.style.left = blockLeft + 'px'

      this.containerActive = true // add active
      this.sliderMaskWidth = moveX + 2 + 'px'
      this.trail.push(moveY)
    }),

    handleMoveEndEvent(e, type = 'mouse') {
      if (!this.isMouseDown) return false
      this.isMouseDown = false
      if (
        (type === 'mouse' && e.clientX === this.originX) ||
        (type === 'touch' && e.changedTouches[0].pageX === this.originX)
      ) {
        return false
      }
      this.containerActive = false // remove active
      this.timestamp = +new Date() - this.timestamp

      const { spliced, TuringTest } = this.verify()
      if (spliced) {
        if (this.accuracy === -1) {
          this.containerSuccess = true
          this.success = true
          this.$emit('success', this.timestamp)
          return
        }
        if (TuringTest) {
          // succ
          this.containerSuccess = true
          this.success = true
          this.$emit('success', this.timestamp)
        } else {
          this.containerFail = true
          this.$emit('again')
        }
      } else {
        this.containerFail = true
        this.$emit('fail')
        setTimeout(() => {
          this.reset()
        }, 1000)
      }
    },

    verify() {
      const arr = this.trail // drag y move distance
      const average = arr.reduce(sum) / arr.length // average
      const deviations = arr.map(x => x - average) // deviation array
      const stddev = Math.sqrt(deviations.map(square).reduce(sum) / arr.length) // standard deviation
      const left = parseInt(this.block.style.left)
      const accuracy = this.accuracy <= 1 ? 1 : this.accuracy > 10 ? 10 : this.accuracy
      return {
        spliced: Math.abs(left - this.block_x) <= accuracy,
        TuringTest: average !== stddev // equal => not person operate
      }
    },

    // 重置
    reset() {
      this.success = false
      this.containerActive = false
      this.containerSuccess = false
      this.containerFail = false
      this.sliderLeft = 0
      this.block.style.left = 0
      this.sliderMaskWidth = 0
      // canvas
      const { w, h } = this
      this.canvasCtx.clearRect(0, 0, w, h)
      this.blockCtx.clearRect(0, 0, w, h)
      this.block.width = w

      // generate img
      this.img.src = this.getRandomImg()
      this.$emit('fulfilled')
    },

    // 刷新
    refresh() {
      this.reset()
      this.$emit('refresh')
    },

    sliderDown(event) {
      if (this.success) return
      this.originX = event.clientX
      this.originY = event.clientY
      this.isMouseDown = true
      this.timestamp = +new Date()
    },

    touchStartEvent(e) {
      if (this.success) return
      this.originX = e.changedTouches[0].pageX
      this.originY = e.changedTouches[0].pageY
      this.isMouseDown = true
      this.timestamp = +new Date()
    }
  }
}
</script>

<style lang="scss" scoped>
.slide-verify-wrap {
  position: relative;

  // 图片加载遮蔽罩
  .slider-verify-loading {
    position: absolute;
    top: 0;
    right: 0;
    left: 0;
    bottom: 0;
    background: rgba(255, 255, 255, 0.9);
    z-index: 999;
    animation: loading 1.5s infinite;
  }

  @keyframes loading {
    0% {
      opacity: 0.7;
    }
    100% {
      opacity: 9;
    }
  }

  .slide-verify-block {
    position: absolute;
    left: 0;
    top: 0;
  }

  // 刷新icon
  .slide-verify-refresh-icon {
    position: absolute;
    right: 0;
    top: 0;
    width: 34px;
    height: 34px;
    cursor: pointer;
    background: url('../assets/imgs/icon_light.png') 0 -437px;
    background-size: 34px 471px;
  }

  // 滑块区域
  .slide-verify-slider {
    position: relative;
    text-align: center;
    width: 100%;
    height: 40px;
    line-height: 38px;
    margin-top: 15px;
    background: #f7f9fa;
    color: #45494c;
    border: 1px solid #e4e7eb;

    .slide-verify-slider-mask {
      position: absolute;
      left: 0;
      top: 0;
      height: 38px;
      border: 0 solid #1991fa;
      background: #d1e9fe;
    }

    .slide-verify-slider-mask-item {
      position: absolute;
      top: 0;
      left: 0;
      width: 38px;
      height: 38px;
      background: #fff;
      box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
      cursor: pointer;
      transition: background 0.2s linear;
    }
    .slide-verify-slider-mask-item:hover {
      background: #1991fa;
      .slide-verify-slider-mask-item-icon {
        background-position: 0 -13px;
      }
    }

    .slide-verify-slider-mask-item-icon {
      position: absolute;
      top: 14px;
      left: 13px;
      width: 14px;
      height: 12px;
      background: url('../assets/imgs/icon_light.png') 0 -26px;
      background-size: 34px 471px;
    }
  }
  // 滑块选中
  .container-active {
    .slide-verify-slider-mask {
      height: 38px;
      border-width: 1px;
    }

    .slide-verify-slider-mask-item {
      height: 38px;
      top: -1px;
      border: 1px solid #1991fa;
    }

    .slide-verify-slider-text {
      display: none;
    }
  }
  // 滑块成功
  .container-success {
    .slide-verify-slider-mask {
      height: 38px;
      border: 1px solid #52ccba;
      background-color: #d2f4ef;
    }

    .slide-verify-slider-mask-item {
      height: 38px;
      top: -1px;
      border: 1px solid #52ccba;
      background-color: #52ccba !important;
    }

    .slide-verify-slider-mask-item-icon {
      top: 12px;
      background-position: 0 0 !important;
    }

    .slide-verify-slider-text {
      display: none;
    }
  }
  // 滑块失败
  .container-fail {
    .slide-verify-slider-mask {
      height: 38px;
      border: 1px solid #f57a7a;
      background-color: #fce1e1;
    }

    .slide-verify-slider-mask-item {
      height: 38px;
      top: -1px;
      border: 1px solid #f57a7a;
      background-color: #f57a7a !important;
    }

    .slide-verify-slider-mask-item-icon {
      top: 12px;
      background-position: 0 -82px !important;
    }

    .slide-verify-slider-text {
      display: none;
    }
  }
}
</style>

三、在所需界面中使用,代码如下:

<template>
  <div class="slide-verify-code-container">
    <!-- 滑动验证码 -->
    <SlideVerify
      :sliderText="sliderText"
      :imgs="imgs"
      :accuracy="accuracy"
      @success="onSuccess"
      @fail="onFail"
      @again="onAgain"
      @fulfilled="onFulfilled"
      @refresh="onRefresh"
    ></SlideVerify>
  </div>
</template>

<script>
import img0 from '@/assets/imgs/img.jpg'
import img1 from '@/assets/imgs/img1.jpg'
import img2 from '@/assets/imgs/img2.jpg'
import img3 from '@/assets/imgs/img3.jpg'
import img4 from '@/assets/imgs/img4.jpg'
import img5 from '@/assets/imgs/img5.jpg'
import SlideVerify from '@/components/SlideVerify.vue'

export default {
  name: 'SlideVerifyCode',
  components: {
    SlideVerify
  },
  data() {
    return {
      sliderText: '向右滑动滑块填充拼图',
      imgs: [img0, img1, img2, img3, img4, img5],
      accuracy: 1 // 精确度小,可允许的误差范围小;为1时,则表示滑块要与凹槽完全重叠,才能验证成功。默认值为5
    }
  },
  methods: {
    onSuccess(times) {
      const msg = `${(times / 1000).toFixed(1)}s`
      console.log('验证通过,耗时:', msg)
    },

    onFail() {
      console.log('验证不通过')
    },

    onAgain() {
      console.log('检测到非人为操作!')
    },

    onFulfilled() {
      console.log('刷新成功!')
    },

    onRefresh() {
      console.log('点击了刷新小图标')
    }
  }
}
</script>

<style lang="scss" scoped>
.slide-verify-code-container {
  flex: 1;
  height: 0;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

注意:图片自己去处理

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值