在当今互联网时代,随着技术的不断进步,传统的验证码验证方式已经无法满足对安全性和用户体验的需求。为了应对日益狡猾的机器人和恶意攻击,许多网站和应用程序开始引入图形验证码,其中一种备受欢迎的形式就是图片旋转验证功能。这项技术通过利用用户交互、视觉识别和动态效果,为用户提供了一种全新、有趣且高效的验证方式。本文将深入探讨如何实现这一引人注目的图片旋转验证功能,让您轻松保护网站安全,同时提升用户体验
效果展示
功能介绍:
在vue项目中将此验证弹框封装成一个单独的组件,完整代码如下;
此功能中的图是利用canvas技术随机画10个图形拼接而成,然后就是画缺口和缺口的内阴影。
拖动滑轨调整小图移动位置,完成验证功能,验证失败会自动刷新再次验证,点击“刷新”也可以收到刷新图案,这是一个由纯前端实现的验证功能;
完整代码—组件封装
<!-- 滑块拼图验证模块 --><template><div><!--<div @click="changeBtn"class="btn">开始验证</div>--><div></div><!-- 本体部分 --><div v-show="shoWData":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 style="border-radius: 10px" 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":style="`width:${puzzleBaseSize}px;height:${canvasHeight}px;transform:translateX(${styleWidth -
sliderBaseSize -(puzzleBaseSize - sliderBaseSize)*((styleWidth - sliderBaseSize)/(canvasWidth - sliderBaseSize))}px)`
" /><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></div></template><script>import resetSvg from"@/assets/images/pc/login/Vector.png";exportdefault{props:{canvasWidth:{type: Number,default:350},// 主canvas的宽canvasHeight:{type: Number,default:200},// 主canvas的高// 是否出现,由父级控制show:{type: Boolean,default:true},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:"拖动滑块完成拼图验证"},shoWData:{type: Boolean,default:false}},data(){return{verSuccess:false,isShow:false,mouseDown:false,// 鼠标是否在按钮上按下startWidth:50,// 鼠标点下去时父级的widthstartX:0,// 鼠标按下时的XnewX:0,// 鼠标当前的偏移XpinX:0,// 拼图的起始XpinY:0,// 拼图的起始Yloading:false,// 是否正在加在中,主要是等图片onloadisCanSlide:false,// 是否可以拉动滑动条error:false,// 图片加在失败会出现这个,提示用户手动刷新infoBoxShow:false,// 提示信息是否出现infoText:"",// 提示等信息infoBoxFail:false,// 是否验证失败timer1:null,// setTimout1closeDown:false,// 为了解决Mac上的click BUGisSuccess:false,// 验证成功imgIndex:-1,// 用于自定义图片时不会随机到重复的图片isSubmting:false,// 是否正在判定,主要用于判定中不能点击重置按钮
resetSvg,};},/** 生命周期 **/mounted(){// document.body.appendChild(this.$el);
document.addEventListener("mousemove",this.onRangeMouseMove,{passive:false});
document.addEventListener("mouseup",this.onRangeMouseUp,{passive:false});
document.addEventListener("touchmove",this.onRangeMouseMove,{passive:false});
document.addEventListener("touchend",this.onRangeMouseUp,{passive:false});if(this.show){
document.body.classList.add("vue-puzzle-overflow");this.reset();}// if (this.shoWData) {// this.isShow = this.shoWData;// console.log('我收到了验证!');// }},beforeDestroy(){clearTimeout(this.timer1);
document.removeEventListener("mousemove",this.onRangeMouseMove,{passive:false});
document.removeEventListener("mouseup",this.onRangeMouseUp,{passive:false});
document.removeEventListener("touchmove",this.onRangeMouseMove,{passive:false});
document.removeEventListener("touchend",this.onRangeMouseUp,{passive: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~2puzzleBaseSize(){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:{changeBtn(){this.isShow =true;},// 关闭onClose(){if(!this.mouseDown &&!this.isSubmting){clearTimeout(this.timer1);}},onCloseMouseDown(){this.closeDown =true;this.isShow =false;this.init(true);//给父组件传一个状态this.$emit('submit','F')},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",{willReadFrequently:true});const ctx2 = c2.getContext("2d",{willReadFrequently:true});const ctx3 = c3.getContext("2d",{willReadFrequently:true});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 =0;//ctx.globalAlpha = 0.4;
ctx.fill();
ctx.clip();}else{
ctx.clip();
ctx.save();
ctx.shadowOffsetX =0;
ctx.shadowOffsetY =0;
ctx.shadowColor ="#000";
ctx.shadowBlur =0;//ctx.globalAlpha = 0.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();// 将小图赋值给ctx2const imgData = ctx.getImageData(this.pinX -3,// 为了阴影 是从-3px开始截取,判定的时候要+3pxthis.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 =1;
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 ="#ffffff";
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/hmakeImgSize(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",{willReadFrequently:true});
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 =false;// 成功后准备关闭clearTimeout(this.timer1);this.timer1 =setTimeout(()=>{// 成功的回调this.isSubmting =false;this.isShow =false;this.verSuccess =true;this.$emit('submit','F',this.verSuccess);this.reset();},800);}else{// 失败this.infoText =this.failText;this.infoBoxFail =true;this.infoBoxShow =true;this.isCanSlide =false;// 失败的回调// this.$emit("fail", x);// 800ms后重置clearTimeout(this.timer1);this.timer1 =setTimeout(()=>{this.isSubmting =false;this.reset();},800);}},// 重置 - 重新设置初始状态resetState(){this.infoBoxFail =false;this.infoBoxShow =false;this.isCanSlide =false;this.isSuccess =false;this.startWidth =this.sliderBaseSize;// 鼠标点下去时父级的widththis.startX =0;// 鼠标按下时的Xthis.newX =0;// 鼠标当前的偏移X},// 重置reset(){if(this.isSubmting){debuggerreturn;}this.resetState();this.init();}}};</script><style lang="scss" scoped>.btn {cursor: pointer;
background-color: #6aa0ff;width: 80px;height: 30px;
text-align: center;
line-height: 30px;color: #fff;}.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:1;
pointer-events: none;transition: opacity 200ms;&.show_ {opacity:1;
pointer-events: auto;}}.vue-auth-box_ {position: absolute;top:50%;left:50%;transform:translate(-50%,-50%);padding: 20px;background: #fff;
user-select: none;
border-radius: 20px;
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:2;}.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;
box-shadow: inset -2px -2px 4px rgba(50,130,251,0.1), inset 2px 2px 4px rgba(34,73,132,0.2);
border-radius: 43px;.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%;/* 背景颜色线性渐变 *//* linear为线性渐变,也可以用下面的那种写法。left top,right top指的是渐变方向,左上到右上 *//* color-stop函数,第一个表示渐变的位置,0为起点,0.5为中点,1为结束点;第二个表示该点的颜色。所以本次渐变为两边灰色,中间渐白色 */background:-webkit-gradient(linear, left top, right top, color-stop(0, #4d4d4d), color-stop(.4, #4d4d4d), color-stop(.5, white), color-stop(.6, #4d4d4d), color-stop(1, #4d4d4d));/* 设置为text,意思是把文本内容之外的背景给裁剪掉 */-webkit-background-clip: text;/* 设置对象中的文字填充颜色 这里设置为透明 */-webkit-text-fill-color: transparent;/* 每隔2秒调用下面的CSS3动画 infinite属性为循环执行animate */-webkit-animation: animate 1.5s infinite;}/* 兼容写法,要放在@keyframes前面 */
@-webkit-keyframes animate {/* 背景从-100px的水平位置,移动到+100px的水平位置。如果要移动Y轴的,设置第二个数值 */
from {
background-position:-100px;}
to {
background-position: 100px;}}
@keyframes animate {
from {
background-position:-100px;}
to {
background-position: 100px;}}.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;
box-shadow: inset 0px -2px 4px rgba(0,36,90,0.2), inset 0px 2px 4px rgba(194,219,255,0.8);
border-radius:50%;&>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>
版权归原作者 Doraemon* 所有, 如有侵权,请联系我们删除。