实现一个拖动旋转图片的安全验证器

3,314 阅读5分钟

安全验证

在很多网站,都会在发送短信验证码的时候,让用户输入图形验证码,或者拖动图片拼图,或者从几张图片中选择自来水龙头,或者在图片中点击文字,或者选择图片中的红绿灯(我老是选不对),也或者是把一张歪掉的图片旋转至正确的角度。这篇文章就来说说如何实现图片旋转验证。说是安全验证,其实也就是验证这个用户是真实的人而不是机器人。

效果一览

动画4.gif

图片和旋转角度

一般来说,应该由后端返回一张旋转过的图片,然后前端把图片顺时针转正,再返回给后端一个前端旋转的角度,后端把自己旋转的角度和前端旋转的角度加起来,差不多360度的时候,就表示用户旋转的角度是正确的。这里解释一下为什么要用差不多,因为如果强行要和360相等,那么用户将很难完成验证,所以需要理由一定的误差范围。

因为文章条件的原因,不好演示后端,这里直接用一张旋转了90度的照片,然后角度校验这事,前端来做。

验证图片

构建基础布局

因为状态蛮多的,所以用vue来写。

html

<div id="app">
  <div class="check">
    <p>拖动滑块使图片角度为正</p>
    <div class="img-con">
      <img src="https://z3.ax1x.com/2021/08/06/fn7X4S.png" />
    </div>
    <div ref="sliderCon" class="slider-con">
      <div ref="slider" id="slider" class="slider" :class="slider">
      </div>
    </div>
  </div>
</div>

css

#app {
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.check {
  --slider-size: 50px;
  margin-top: 20px;
  width: 300px;
  background: white;
  box-shadow: 0 0 5px grey;
  border-radius: 5px;
  padding: 20px 0;

  display: flex;
  flex-direction: column;
  align-items: center;
}

.check .img-con {
  position: relative;
  overflow: hidden;
  width: 120px;
  height: 120px;
  border-radius: 50%;
}

.check .img-con img {
  width: 100%;
  height: 100%;
  user-select: none;
}

.check .slider-con {
  width: 80%;
  height: var(--slider-size);
  border-radius: 50px;
  margin-top: 1rem;
  position: relative;
  background: #f5f5f5;
  box-shadow: 0 0 5px rgba(0,0,0,0.1) inset;
}

.slider-con .slider {
  background: white;
  width: var(--slider-size);
  height: var(--slider-size);
  border-radius: 50%;
  box-shadow: 0 0 5px rgba(0,0,0,0.2);
  cursor: move;
}

body {
  padding: 0;
  margin: 0;
  background: #fef5e0;
}

js

Vue.createApp({
  data() {
    return {}
  },
  methods: {}
}).mount('#app')

这就是基础布局了,代码量比较大,你忍一下。

image.png

动起来!

按下滑块

我们先让下面的滑块动起来,更新 vue 的 data ,设置需要的状态数据

  data() {
    return {
      showError: false, // 显示错误提示
      showSuccess: false, // 显示成功提示
      checking: false, // 显示检查中提示
      sliding: false, // 当前是否正在拖动滑块
      slidMove: 0 // 滑块移动的距离(px)
    };
  },

在 methods 中新增三个函数,处理鼠标按下的事件和抬起事件以及重置滑块的函数

// 鼠标按下
onMouseDown(event) {
  // 当用户鼠标按下时,目标不是滑块,则不处理
  if (event.target.id !== "slider") {
    return;
  }
  if (this.checking) return;
  // 设置状态为滑动中
  this.sliding = true;
  // 下面三个变量不需要监听变化,因此不放到 data 中
  
  // clientX 事件属性返回当事件被触发时鼠标指针相对于浏览器页面(或客户区)的水平坐标。
  // 记录鼠标按下时的x位置
  this.sliderLeft = event.clientX;
  this.sliderConWidth = this.$refs.sliderCon.clientWidth; // 记录滑槽的宽度
  this.sliderWidth = this.$refs.slider.clientWidth; // 记录滑块的宽度
}
// 鼠标抬起
onMouseUp(event) {
  if (this.sliding) {
    this.resetSlider()
  }
}
// 重置滑块
resetSlider() {
  this.sliding = false;
  this.slidMove = 0;
  this.checking = false;
  this.showSuccess = false;
  this.showError = false;
}

给验证卡片绑定上事件和数据。

div.check 绑定鼠标事件:

<div class="check" @mousedown="onMouseDown" @mouseup="onMouseUp">

#slider 动态绑定class :

<div ref="slider" class="slider" id="slider" :class="{sliding}">
</div>

在 css 中新增一个 css 选择器

.slider-con .slider.sliding {
  background: #4ed3ff;
}

这样在按下滑块时,就会记录滑动状态为 true ,然后滑块会变为蓝色,松开时,滑块的滑动状态为 false ,失去 .sliding 类,颜色恢复白色。

初始状态有了后,来专心处理滑动。

修改 css ,找到 .slider-con .slider 这个选择器,在里面新增两个 css 属性

.slider-con .slider {
  /* ...其他属性... */
  
  --move: 0px;
  transform: translateX(var(--move));
}

这样在鼠标移动时,只需要改变 --move 这个 css 变量即可。接下来绑定这个变量即可,修改 id="slider" 的 div。也就是滑块 div。

<div ref="slider" class="slider" id="slider" :class="{sliding}" :style="{'--move': `${slidMove}px`}">
</div>

这里通过 :style 的方式动态绑定了 --move css变量和 slidMove变量

新增一个 onMouseMove 函数,用于处理鼠标移动。

// 处理鼠标移动
onMouseMove(event) {
  if (this.sliding && this.checking === false) {
    // 滑块向右的平移距离等于鼠标移动事件的X坐标减去鼠标按下时的初始坐标。
    let m = event.clientX - this.sliderLeft;
    if (m < 0) {
      // 如果m小于0表示用户鼠标向左移动超出了初始位置,也就是0
      // 所以直接等于 0,以防止越界
      m = 0;
    } else if (m > this.sliderConWidth - this.sliderWidth) {
      // 滑块向右移动的最大距离是滑槽的宽度减去滑块的宽度。
      // 因为css的 translateX 函数是以元素的左上角坐标计算的
      // 所以要减去滑块的宽度,这样滑块在最右边时,才不会左上角和滑槽右上角重合。
      m = this.sliderConWidth - this.sliderWidth;
    }
    this.slidMove = m;
  }
}

好了,这样就可以拖动滑块了。

play.gif

图片旋转

先增加两个 vue 的计算属性,需要根据滑块移动相对滑槽可移动空间的移动比例来计算旋转的角度。

  computed: {
    angle() {
      let sliderConWidth = this.sliderConWidth ?? 0;
      let sliderWidth = this.sliderWidth ?? 0;
      let ratio = this.slidMove / (sliderConWidth - sliderWidth);
      // 360度乘以滑块的移动比例,就是旋转的角度
      return 360 * ratio;
    },
    imgAngle() {
      return `rotate(${this.angle}deg)`;
    }
  }

随后绑定到 dom 上去即可。找到 img 元素,修改它。

<img src="https://z3.ax1x.com/2021/08/06/fn7X4S.png" :style="{transform: imgAngle}" />

动画.gif

验证旋转角度是否正确

修改 html 部分,显示各种状态。

<div class="img-con">
  <img src="https://z3.ax1x.com/2021/08/06/fn7X4S.png" :style="{transform: imgAngle}" />
  <div v-if="showError" class="check-state">
    错误
  </div>
  <div v-else-if="showSuccess" class="check-state">
    正确
  </div>
  <div v-else-if="checking" class="check-state">
    验证中
  </div>
</div>

新增 .check-state 样式

.check-state {
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  color: white;
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  justify-content:center;
  align-items:center;
}

再新增一个 validApi 函数到 methods 中,用于模拟后端 api 验证角度是否正确。

// 验证角度是否正确
validApi(angle) {
  return new Promise((resolve, reject) => {
    // 模拟网络请求
    setTimeout(() => {
      // 图片已旋转的角度
      const imgAngle = 90;
      // 图片已旋转角度和用户旋转角度之和
      let sum = imgAngle + angle;
      // 误差范围
      const errorRang = 10;
      // 当用户旋转角度和已旋转角度之和为360度时,表示旋转了一整圈,也就是转正了
      // 但是不能指望用户刚好转到那么多,所以需要留有一定的误差
      let isOk = Math.abs(360 - sum) <= errorRang;

      resolve(isOk);
    }, 1000);
  });
}

接下来修改 onMouseUp 函数,来验证和处理旋转角度及状态。

onMouseUp(event) {
  if (this.sliding && this.checking === false) {
    this.checking = true;
    this.validApi(this.angle)
      .then((isok) => {
        if (isok) {
          this.showSuccess = true;
        } else {
          this.showError = true;
        }
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            if (isok) {
              resolve(Math.round(this.angle));
            } else {
              reject();
            }
            this.resetSlider();
          }, 1000);
        });
      })
      .then((angle) => {
        // 处理业务,或者通知调用者验证成功
        alert("旋转正确: " + angle + "度");
      })
      .catche((e) => {
        alert("旋转错误");
      });
  }
}

来看看效果

动画2.gif

丰富样式

现在基础功能做好了,滑块部分的样式还是不够好,只有一个按下状态,现在可以给他加一个错误的时候抖动的动画。

首先新增一个 css 动画

@keyframes jitter {
  20% {
    transform: translateX(-5px);
  }
  40% {
    transform: translateX(10px);
  }
  60% {
    transform: translateX(-5px);
  }
  80% {
    transform: translateX(10px);
  }
  100% {
    transform: translateX(0);
  }
}

.slider-con.err-anim {
  animation: jitter 0.5s;
}
/** 错误动画下的滑块颜色为红色 **/
.slider-con.err-anim .slider {
  background: #ff4e4e;
}

然后绑定到滑槽上面去。

<div ref="sliderCon" class="slider-con" :class="{'err-anim':showError}">
    ...
</div>

查看效果

动画3.gif

完整代码

代码都是在 codepen 编写的,因此可直接前往 codepen 查看完整代码

SafeCheck (codepen.io)