如何实现一个滑块验证码

最近改造公司的登陆功能,手机验证码登录和密码登录需要使用滑块验证码。

基于前端实现的滑块验证,由于是vue2项目,因此先建一个滑块验证码的组件,方便在两个不同的页面使用。

<template>
  <div class="slide-verify" :style="{width: canvasWidth + 'px'}" onselectstart="return false;">
    <!-- 图片加载遮蔽罩 -->
    <div :class="{'img-loading': isLoading}" :style="{height: canvasHeight + 'px'}" v-if="isLoading"/>
    <!-- 认证成功后的文字提示 -->
    <div class="success-hint" :style="{height: canvasHeight + 'px'}" v-if="verifySuccess">{{ successHint }}</div>
    <div class="fail-hint"  v-if="verifyFail">{{ failHint }}</div>
    <!--刷新按钮-->
    <div class="refresh-icon" @click="refresh" />
    <!--前端生成-->
    <div class="load">
      <template v-if="isFrontCheck" v-loading="fullscreenLoading">
        <!--验证图片-->
        <canvas ref="canvas" class="slide-canvas" :width="canvasWidth" :height="canvasHeight"/>
        <!--阻塞块-->
        <canvas ref="block" class="slide-block" :width="canvasWidth" :height="canvasHeight"/>
      </template>
      <!--后端生成-->
      <template v-else >
        <!--验证图片-->
        <img ref="canvas" class="slide-canvas" :width="canvasWidth" :height="canvasHeight"/>
        <!--阻塞块-->
        <img ref="block" :class="['slide-block', {'verify-fail': verifyFail}]"/>
      </template>
    </div>

    <!-- 滑动条 -->
    <div class="slider" :class="{'verify-active': verifyActive, 'verify-success': verifySuccess, 'verify-fail': verifyFail}" >
      <!--滑块-->
      <div class="slider-box" :style="{width: sliderBoxWidth}">
        <!-- 按钮 -->
        <div class="slider-button" id="slider-button" :style="{left: sliderButtonLeft}">
          <!-- 按钮图标 -->
          <div class="slider-button-icon"/>
        </div>
      </div>
      <!--滑动条提示文字-->
      <span class="slider-hint">{{ sliderHint }}</span>
    </div>
  </div>
</template>

<script>
import loading, {showFullScreenLoading,hideFullScreenLoading} from "~/assets/utils/loading";
function sum(x, y) {
  return x + y;
}

function square(x) {
  return x * x;
}

import api from '~/api/login';
export default {
  name: 'sliderVerify',
  props: {
    // 阻塞块长度
    blockLength: {
      type: Number,
      default: 42,
    },
    // 阻塞块弧度
    blockRadius: {
      type: Number,
      default: 10,
    },
    // 画布宽度
    canvasWidth: {
      type: Number,
      default: 320,
    },
    // 画布高度
    canvasHeight: {
      type: Number,
      default: 155,
    },
    // 滑块操作提示
    sliderHint: {
      type: String,
      default: '向右滑动',
    },
    // 可允许的误差范围小;为1时,则表示滑块要与凹槽完全重叠,才能验证成功。默认值为5,若为 -1 则不进行机器判断
    accuracy: {
      type: Number,
      default: 3,
    },
    // 图片资源数组
    imageList: {
      type: Array,
      default: () => [],
    },
    isFrontCheck:{
      type: Boolean,
      default: false,
    }
  },
  data() {
    return {
      // 校验进行状态
      verifyActive: false,
      // 校验成功状态
      verifySuccess: false,
      // 校验失败状态
      verifyFail: false,
      // 阻塞块对象
      blockObj: null,
      // 图片画布对象
      canvasCtx: null,
      // 阻塞块画布对象
      blockCtx: null,
      // 阻塞块宽度
      blockWidth: this.blockLength * 2,
      // 阻塞块的横轴坐标
      blockX: undefined,
      // 阻塞块的纵轴坐标
      blockY: undefined,
      // 图片对象
      image: undefined,
      // 移动的X轴坐标
      originX: undefined,
      // 移动的Y轴做坐标
      originY: undefined,
      // 拖动距离数组
      dragDistanceList: [],
      // 滑块箱拖动宽度
      sliderBoxWidth: 0,
      // 滑块按钮距离左侧起点位置
      sliderButtonLeft: 0,
      // 鼠标按下状态
      isMouseDown: false,
      // 图片加载提示,防止图片没加载完就开始验证
      isLoading: true,
      // 时间戳,计算滑动时长
      timestamp: null,
      // 成功提示
      successHint: '',
      failHint:'',
      // 随机字符串
      nonceStr: undefined,
    };
  },
  mounted() {
    this.init();
  },
  methods: {
    /* 初始化*/
    init() {
      this.initDom();
      this.bindEvents();
    },
    /* 初始化DOM对象*/
    initDom() {
      this.blockObj = this.$refs.block;
      if (this.isFrontCheck) {
        this.canvasCtx = this.$refs.canvas.getContext('2d');
        this.blockCtx = this.blockObj.getContext('2d');
        this.initImage();
      } else {
        this.getCaptcha();
      }
    },
    /* 后台获取验证码*/
    getCaptcha() {
      let self = this;
      //取后端默认值
      const data = {};
      api(this).getCaptcha(data).then((response) => {
        const data = response.data;
        self.nonceStr = data.nonceStr;
        self.$refs.block.src = data.blockSrc;
        self.$refs.block.style.top = data.blockY + 'px';
        self.$refs.canvas.src = data.canvasSrc;
      }).finally(() => {
        self.isLoading = false;
      });
    },
    /* 前端获取验证码*/
    initImage() {
      const image = this.createImage(() => {
        this.drawBlock();
        let {canvasWidth, canvasHeight, blockX, blockY, blockRadius, blockWidth} = this;
        this.canvasCtx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
        this.blockCtx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
        // 将抠图防止最左边位置
        let yAxle = blockY - blockRadius * 2;
        let ImageData = this.blockCtx.getImageData(blockX, yAxle, blockWidth, blockWidth);
        this.blockObj.width = blockWidth;
        this.blockCtx.putImageData(ImageData, 0, yAxle);
        // 图片加载完关闭遮蔽罩
        this.isLoading = false;
        // 前端校验设置特殊值
        this.nonceStr = 'loyer';
      });
      this.image = image;
    },
    /* 创建image对象*/
    createImage(onload) {
      const image = document.createElement('img');
      image.crossOrigin = 'Anonymous';
      image.onload = onload;
      // image.onerror = () => {
      //   image.src = require('../../assets/images/bgImg.jpg');
      // };
      image.src = this.getImageSrc();
      return image;
    },
    /* 获取imgSrc*/
    getImageSrc() {
      const len = this.imageList.length;
      return len > 0 ? this.imageList[this.getNonceByRange(0, len)] : `https://loyer.wang/view/ftp/wallpaper/${this.getNonceByRange(1, 1000)}.jpg`;
    },
    /* 根据指定范围获取随机数*/
    getNonceByRange(start, end) {
      return Math.round(Math.random() * (end - start) + start);
    },
    /* 绘制阻塞块*/
    drawBlock() {
      this.blockX = this.getNonceByRange(this.blockWidth + 10, this.canvasWidth - (this.blockWidth + 10));
      this.blockY = this.getNonceByRange(10 + this.blockRadius * 2, this.canvasHeight - (this.blockWidth + 10));
      this.draw(this.canvasCtx, 'fill');
      this.draw(this.blockCtx, 'clip');
    },
    /* 绘制事件*/
    draw(ctx, operation) {
      const PI = Math.PI;
      let {blockX: x, blockY: y, blockLength: l, blockRadius: 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.9)';
      ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
      ctx.stroke();
      ctx[operation]();
      ctx.globalCompositeOperation = 'destination-over';
    },
    /* 事件绑定*/
    bindEvents() {
      // 监听鼠标按下事件
      document.getElementById('slider-button').addEventListener('mousedown', (event) => {
        this.startEvent(event.clientX, event.clientY);
      });
      // 监听鼠标移动事件
      document.addEventListener('mousemove', (event) => {
        this.moveEvent(event.clientX, event.clientY);
      });
      // 监听鼠标离开事件
      document.addEventListener('mouseup', (event) => {
        this.endEvent(event.clientX);
      });
      // 监听触摸开始事件
      document.getElementById('slider-button').addEventListener('touchstart', (event) => {
        this.startEvent(event.changedTouches[0].pageX, event.changedTouches[0].pageY);
      });
      // 监听触摸滑动事件
      document.addEventListener('touchmove', (event) => {
        this.moveEvent(event.changedTouches[0].pageX, event.changedTouches[0].pageY);
      });
      // 监听触摸离开事件
      document.addEventListener('touchend', (event) => {
        this.endEvent(event.changedTouches[0].pageX);
      });
    },
    /* 校验图片是否存在*/
    checkImgSrc() {
      if (this.isFrontCheck) {
        return true;
      }
      return !!this.$refs.canvas.src;
    },
    /* 滑动开始事件*/
    startEvent(originX, originY) {
      if (!this.checkImgSrc() || this.isLoading || this.verifySuccess) {
        return;
      }
      this.originX = originX;
      this.originY = originY;
      this.isMouseDown = true;
      this.timestamp = +new Date();
    },
    /* 滑动事件*/
    moveEvent(originX, originY) {
      if (!this.isMouseDown) {
        return false;
      }
      const moveX = originX - this.originX;
      const moveY = originY - this.originY;
      if (moveX < 0 || moveX + 40 >= this.canvasWidth) {
        return false;
      }
      this.sliderButtonLeft = moveX + 'px';
      let blockLeft = (this.canvasWidth - 40 - 20) / (this.canvasWidth - 40) * moveX;
      this.blockObj.style.left = blockLeft + 'px';
      this.verifyActive = true;
      this.sliderBoxWidth = moveX + 'px';
      this.dragDistanceList.push(moveY);
    },
    /* 滑动结束事件*/
    endEvent(originX) {
      if (!this.isMouseDown) {
        return false;
      }
      this.isMouseDown = false;
      if (originX === this.originX) {
        return false;
      }
      // 开始校验
      this.isLoading = true;
      // 校验结束
      this.verifyActive = false;
      // 滑动时长
      this.timestamp = +new Date() - this.timestamp;
      // 移动距离
      const moveLength = parseInt(this.blockObj.style.left);
      // 限制操作时长10S,超出判断失败
      if (this.timestamp > 10000) {
        this.verifyFailEvent();
      }
      // else
      //   // 人为操作判定
      // if (!this.turingTest()) {
      //   this.verifyFail = true;
      //   this.$emit('again');
      // }
      else
        // 是否前端校验
      if (this.isFrontCheck) {
        const accuracy = this.accuracy <= 1 ? 1 : this.accuracy > 10 ? 10 : this.accuracy; // 容错精度值
        const spliced = Math.abs(moveLength - this.blockX) <= accuracy; // 判断是否重合
        if (!spliced) {
          this.verifyFailEvent();
        } else {
          this.verifySuccessEvent()
          // 设置特殊值,后台特殊处理,直接验证通过
          this.$emit('success', {nonceStr: this.nonceStr, value: moveLength});
        }
      } else {
        this.$emit('success', {nonceStr: this.nonceStr, value: moveLength});
      }
    },
    /* 图灵测试*/
    turingTest() {
      const arr = this.dragDistanceList; // 拖动距离数组
      const average = arr.reduce(sum) / arr.length; // 平均值
      const deviations = arr.map((x) => x - average); // 偏离值
      const stdDev = Math.sqrt(deviations.map(square).reduce(sum) / arr.length); // 标准偏差
      return average !== stdDev; // 判断是否人为操作
    },
    /* 校验成功*/
    verifySuccessEvent() {
      this.isLoading = false;
      this.verifySuccess = true;
      const elapsedTime = (this.timestamp / 1000).toFixed(1);
      if (elapsedTime < 1) {
        this.successHint = `仅仅${elapsedTime}S,你的速度快如闪电`;
      } else if (elapsedTime < 2) {
        this.successHint = `只用了${elapsedTime}S,这速度简直完美`;
      } else {
        this.successHint = `耗时${elapsedTime}S,争取下次再快一点`;
      }
    },
    /* 校验失败*/
    verifyFailEvent(msg) {
      this.verifyFail = true;
      // this.$emit('fail', msg);
      this.failHint = "验证失败!请重新尝试"
      this.refresh();
    },
    /* 刷新图片验证码*/
    refresh() {
      showFullScreenLoading('.load')
      // 延迟class的删除,等待动画结束
      setTimeout(() => {
        this.verifyFail = false;
        this.isLoading = true;
        this.verifyActive = false;
        this.verifySuccess = false;
        this.blockObj.style.left = 0;
        this.sliderBoxWidth = 0;
        this.sliderButtonLeft = 0;
        if (this.isFrontCheck) {
          // 刷新画布
          let {canvasWidth, canvasHeight} = this;
          this.canvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
          this.blockCtx.clearRect(0, 0, canvasWidth, canvasHeight);
          this.blockObj.width = canvasWidth;
          // 刷新图片
          this.image.src = this.getImageSrc();
        } else {
          this.getCaptcha();
        }
        hideFullScreenLoading()
      }, 500);
    },
  },
};
</script>

<style scoped>
.slide-verify {
  position: relative;
}

/*图片加载样式*/
.img-loading {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  bottom: 0;
  z-index: 999;
  animation: loading 3s infinite;
  background-repeat: no-repeat;
  background-position: center center;
  //background-image: url(../assets/img/captcha/加载动画.svg);
  background-size: 100px;
  //background-color: #d5d2d2;
  border-radius: 5px;
}

@keyframes loading {
  0% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.5;
    transform: scale(1.1);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}
.fail-hint{
  position: absolute;
  bottom: 55px;
  right: 0;
  left: 0;
  text-align: center;
  z-index: 999;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(243, 82, 23, 0.8);
  color: #fff;
  font-size: 15px;
}
/*认证成功后的文字提示*/
.success-hint {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  z-index: 999;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.8);
  color: #2CD000;
  font-size: 18px;
}

/*刷新按钮*/
.refresh-icon {
  position: absolute;
  right: 0;
  top: 0;
  width: 35px;
  height: 35px;
  cursor: pointer;
  background: url("../assets/img/captcha/刷新.png") no-repeat;
  background-size: 30px 30px ;
}

/*验证图片*/
.slide-canvas {
  border-radius: 5px;
}

/*阻塞块*/
.slide-block {
  position: absolute;
  left: 0;
  top: 0;
}

/*校验失败时的阻塞块样式*/
.slide-block.verify-fail {
  transition: left 0.5s linear;
}



/*滑动条*/
.slider {
  position: relative;
  text-align: center;
  width: 100%;
  height: 40px;
  line-height: 40px;
  margin-top: 15px;
  background: #f7f9fa;
  color: #45494c;
  border: 1px solid #e4e7eb;
  border-radius: 5px;
}

/*滑动盒子*/
.slider-box {
  position: absolute;
  left: 0;
  top: 0;
  height: 40px;
  border: 0 solid #409eff;
  background: #D1E9FE;
  border-radius: 5px;
}

/*滑动按钮*/
.slider-button {
  position: absolute;
  top: 0;
  left: 0;
  width: 40px;
  height: 40px;
  background: #fff;
  box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
  cursor: pointer;
  transition: background .2s linear;
  border-radius: 5px;
}

/*鼠标悬浮时的按钮样式*/
.slider-button:hover {
  background: #409eff;
}
.verify-success .slider-button:hover {
  background: rgba(0,0,0,0);
}
.verify-success .slider-button:hover {
  background: rgba(0,0,0,0);
}
/*鼠标悬浮时的按钮图标样式*/
.slider-button:hover .slider-button-icon {
  background: url("../assets/img/captcha/滑块-白色.png")  no-repeat;
  background-size: cover ;
}

/*滑动按钮图标*/
.slider-button-icon {
  position: absolute;
  top: 15px;
  left: 13px;
  width: 15px;
  height: 13px;
  background: url("../assets/img/captcha/滑块灰色.png")  no-repeat;
  background-size: cover ;
}

/*校验时的按钮样式*/
.verify-active .slider-button {
  height: 38px;
  top: -1px;
  border: 1px solid #409eff;
}

/*校验时的滑动箱样式*/
.verify-active .slider-box {
  height: 39px;
  border: 1px;
}

/*校验成功时的滑动箱样式*/
.verify-success .slider-box {
  height: 38px;
  border: 1px solid #16e01e;
  background-color: #16e01e;
}

/*校验成功时的按钮样式*/
.verify-success .slider-button {
  height: 38px;
  top: -1px;
  border: 1px solid #16e01e ;
  //background-color: #98f69c !important;
}
.verify-success:hover .slider-button-icon {
  background: url("../assets/img/captcha/成功.png")  no-repeat;
  background-size: cover ;
  bottom: 5px;
}
/*校验成功时的按钮图标样式*/
.verify-success .slider-button-icon {
  background: url("../assets/img/captcha/成功.png")  no-repeat;
  background-size: cover ;
  bottom: 5px;
}

/*校验失败时的滑动箱样式*/
.verify-fail .slider-box {
  height: 38px;
  border: 1px solid #f57a7a;
  background-color: #fce1e1;
  transition: width 0.5s linear;
}

/*校验失败时的按钮样式*/
.verify-fail .slider-button {
  height: 38px;
  top: -1px;
  border: 1px solid #f57a7a;
  background-color: #f57a7a !important;
  transition: left 0.5s linear;
}

/*校验失败时的按钮图标样式*/
.verify-fail .slider-button-icon {
  background: url("../assets/img/captcha/滑块-白色.png")  no-repeat;
  background-size: cover ;
}

/*校验状态下的提示文字隐藏*/
.verify-active .slider-hint,
.verify-success .slider-hint,
.verify-fail .slider-hint {
  display: none;
}
</style>

其中使用到的一个局部加载样式的组件js代码如下:

loading.js,在更新滑块验证码图片时,可以有一个加载中的样式

import Vue from 'vue'

// loading框设置局部刷新,且所有请求完成后关闭loading框
let loading
let needLoadingRequestCount = 0 // 声明一个对象用于存储请求个数
function startLoading (targetdq) {
  loading = Vue.prototype.$loading({
    lock: true,
    text: '加载中...',
    background: 'rgba(255,255,255,.4)',
    target: document.querySelector(targetdq) // 设置加载动画区域
  })
}

function endLoading () {
  loading.close()
}
export function showFullScreenLoading (targetdq) {
  if (needLoadingRequestCount === 0) {
    startLoading(targetdq)
  }
  needLoadingRequestCount++
}
export function hideFullScreenLoading () {
  if (needLoadingRequestCount <= 0) return
  needLoadingRequestCount--
  if (needLoadingRequestCount === 0) {
    endLoading()
  }
}

export default {
  showFullScreenLoading,
  hideFullScreenLoading
}

另外其他的一些使用到的按钮图片刷新图片等如下

刷新按钮

滑块滑动成功按钮

滑块滑动hover样式图片

滑块按钮图片

最后效果图

组件使用方式是在需要的页面引入此组件,如下:

      <template>
  <div class="login-container">
    <el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form" auto-complete="on" label-position="left">
      ...省略无关代码
      
      <!--滑块验证-->
      <el-dialog title="请拖动滑块完成拼图" width="360px" :visible.sync="isShowSliderVerify" :close-on-click-modal="false" @closed="refresh" append-to-body>
        <slider-verify ref="sliderVerify" @success="onSuccess" @fail="onFail" @again="onAgain"/>
      </el-dialog>

      <el-button :loading="loading" round style="width:100%;margin-bottom:30px;" @click.native.prevent="handleLogin">登录</el-button>

    </el-form>
  </div>
</template>
<script>
  import sliderVerify from './sliderVerify';
export default {
  name: 'Login',
  components: {
    sliderVerify,
  },
  data() {
    return {
      loginForm: {
        userName: '',
        passWord: '',
        // 随机字符串
        nonceStr: '',
        // 验证值
        value: '',
      },
     
      loading: false,
      // 是否显示滑块验证
      isShowSliderVerify: false,
    }
  },
  methods: {
    /* 提交*/
    handleLogin() {
      let self = this;
      self.$refs.loginForm.validate((flag) => {
        self.isShowSliderVerify = flag;
      });
    },
    /* 登录*/
    login() {
      let self = this;
      self.loading = true;
      self.$store.dispatch('user/login', self.loginForm).then(() => {
          self.$refs.sliderVerify.verifySuccessEvent();
          setTimeout(() => {
            self.isShowSliderVerify = false;
            self.message('success', '登录成功');
          }, 500);
          this.$router.push({ path: this.redirect || '/' })
      }).catch(() => {
        self.$refs.sliderVerify.verifyFailEvent();
      });
    },
    /* 滑动验证成功*/
    onSuccess(captcha) {
      Object.assign(this.loginForm, captcha);
      this.login();
    },
    /* 滑动验证失败*/
    onFail(msg) {
      //this.message('error', msg || '验证失败,请控制拼图对齐缺口');
    },
    /* 滑动验证异常*/
    onAgain() {
      this.message('error', '滑动操作异常,请重试');
    },
    /* 刷新验证码*/
    refresh() {
      this.$refs.sliderVerify.refresh();
    },
    /* 提示弹框*/
    message(type, message) {
      this.$message({
        showClose: true,
        type: type,
        message: message,
        duration: 1500,
      });
    },

  },

}
</script>

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值