Vue实现滑动拼图验证码功能

664 阅读3分钟

效果如下




核心就是用canvas绘制两个小方块。并且第一个小方块的背景是截取另一个小方块所缺失的部分。理解ctx.drawImage() 这个api

滑块滑动的时候获取clientX(即光标位置距离当前body可视区域的轴坐标),并相应的使滑块和有背景的方块移动相应的距离。

当鼠标抬起的时候,判断有背景的小方块和背景阴影的小方块之间的x轴距离。

若小于5px。则拼图成功。

子组件代码如下

<template>

<div class="image-all-container" :style="{ width: imageWidth + 'px' }">
<div
class="image-container"
:style="{
height: imageHeight + 'px',
backgroundImage: 'url(' + imageUrl + ')'
}"
>
 <canvas
ref="shadowCanvas"
class="canvas"
:width="fragmentSize"
:height="fragmentSize"
:style="{ left: offsetX + 'px', top: offsetY + 'px' }"
></canvas>

<canvas

ref="fragmentCanvas"
class="canvas"
:width="fragmentSize"
:height="fragmentSize"
:style="{ top: offsetY + 'px', left: currX + 'px' }"
></canvas>

<div
class="tips-container"
:class="{ 'tips-container--active': isShowTips == true }"
>
<i class="tips-ico" :class="tips.ico"></i>
<span class="tips-text">{{ tips.text }}</span>
</div>
</div>

<div class="reload-container">
<div class="reload-wrapper" @click="reload">
<i class="el-icon-refresh-right reload-ico"></i>
<span class="reload-tips">刷新验证</span>
</div>
</div>

<div class="slider-wrapper" @mousemove="onMoving" @mouseleave="onMoveEnd">
<div class="slider-bar">按钮滑块,拖动完成拼图</div>
<div
class="slider-button"
:style="{ left: currX + 'px' }"
@mousedown="onMoveStart"
@mouseup="onMoveEnd"
>
<i class="el-icon-video-pause"></i>
</div>
</div>
</div>
</template>
<script>
const STATUS_LOADING = 0; // 还没有图片
const STATUS_READY = 1; // 图片渲染完成,可以开始滑动
const STATUS_MATCH = 2; // 图片位置匹配成功
const STATUS_ERROR = 3; // 图片位置匹配失败

const arrTips = [
{ ico: "el-icon-check", text: "匹配成功" },
{ ico: "el-icon-close", text: "匹配失败" }
];

// 生成裁剪路径
function createClipPath(ctx, size = 100, styleIndex = 0) {
const styles = [
[0, 0, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0],
[0, 0, 1, 1],
[0, 1, 0, 0],
[0, 1, 0, 1],
[0, 1, 1, 0],
[0, 1, 1, 1],
[1, 0, 0, 0],
[1, 0, 0, 1],
[1, 0, 1, 0],
[1, 0, 1, 1],
[1, 1, 0, 0],
[1, 1, 0, 1],
[1, 1, 1, 0],
[1, 1, 1, 1]
];
const style = styles[styleIndex];

const r = 0.1 * size;
ctx.save();
ctx.beginPath();
// left
ctx.moveTo(r, r);
ctx.lineTo(r, 0.5 * size - r);
ctx.arc(r, 0.5 * size, r, 1.5 * Math.PI, 0.5 * Math.PI, style[0]);
ctx.lineTo(r, size - r);
// bottom
ctx.lineTo(0.5 * size - r, size - r);
ctx.arc(0.5 * size, size - r, r, Math.PI, 0, style[1]);
ctx.lineTo(size - r, size - r);
// right
ctx.lineTo(size - r, 0.5 * size + r);
ctx.arc(size - r, 0.5 * size, r, 0.5 * Math.PI, 1.5 * Math.PI, style[2]);
ctx.lineTo(size - r, r);
// top
ctx.lineTo(0.5 * size + r, r);
ctx.arc(0.5 * size, r, r, 0, Math.PI, style[3]);
ctx.lineTo(r, r);

ctx.clip();
ctx.closePath();
}

export default {
props: {
imageUrl: {
type: String,
default: ""
},
imageWidth: {
type: Number,
default: 500
},
imageHeight: {
type: Number,
default: 300
},
fragmentSize: {
type: Number,
default: 80
},
onReload: {
type: Function,
default: () => {}
},
onMatch: {
type: Function,
default: () => {}
},
onError: {
type: Function,
default: () => {}
}
},
data() {
return {
isMovable: false,
offsetX: 0, // 图片截取的x
offsetY: 0, // 图片截取的y
startX: 0, // 开始滑动的 x
oldX: 0,
currX: 0, // 滑块当前 x,
status: STATUS_LOADING,
isShowTips: false,
tipsIndex: 0
};
},
computed: {
tips() {
return arrTips[this.tipsIndex];
}
},
mounted() {
if (this.imageUrl) {
this.renderImage();
}
},
beforeUpdate() {},
methods: {
renderImage() {
this.status = STATUS_LOADING;
const objImage = new Image();
objImage.addEventListener("load", () => {
const { imageWidth, imageHeight, fragmentSize } = this.$props;

// 先获取两个ctx
const ctxShadow = this.$refs.shadowCanvas.getContext("2d");
const ctxFragment = this.$refs.fragmentCanvas.getContext("2d");

// 让两个ctx拥有同样的裁剪路径(可滑动小块的轮廓)
const styleIndex = Math.floor(Math.random() * 16);
createClipPath(ctxShadow, fragmentSize, styleIndex);
createClipPath(ctxFragment, fragmentSize, styleIndex);

// 随机生成裁剪图片的开始坐标
const clipX = Math.floor(
fragmentSize + (imageWidth - 2 * fragmentSize) * Math.random()
);
const clipY = Math.floor((imageHeight - fragmentSize) * Math.random());

// 让小块绘制出被裁剪的部分
ctxFragment.drawImage(
objImage,
clipX,
clipY,
fragmentSize,
fragmentSize,
0,
0,
fragmentSize,
fragmentSize
);

// 让阴影canvas带上阴影效果
ctxShadow.fillStyle = "rgba(0, 0, 0, 0.5)";
ctxShadow.fill();

// 恢复画布状态
ctxShadow.restore();
ctxFragment.restore();

// 设置裁剪小块的位置
this.offsetX = clipX;
this.offsetY = clipY;

// 修改状态
this.status = STATUS_READY;
});

objImage.src = this.imageUrl;
},

onMoveStart(e) {
if (this.status !== STATUS_READY) {
return;
}
// 记录滑动开始时的绝对坐标x
this.isMovable = true;
this.startX = e.clientX;
},
onMoving(e) {
if (this.status !== STATUS_READY || !this.isMovable) return;
const distance = e.clientX - this.startX;
const currX = this.oldX + distance;

const minX = 0;
const maxX = this.imageWidth - this.fragmentSize;

this.currX = currX < minX ? 0 : currX > maxX ? maxX : currX;
},

onMoveEnd(e) {
if (this.status !== STATUS_READY || !this.isMovable) return;
this.isMovable = false;
this.oldX = this.currX;

const isMatch = Math.abs(this.currX - this.offsetX) < 5;

if (isMatch) {
this.status = STATUS_MATCH;
this.currX = this.offsetX;
this.onShowTips();
this.onMatch();
} else {
this.status = STATUS_ERROR;
this.onReset();
this.onShowTips();
this.onError();
}
},

onReset() {
const timer = setTimeout(() => {
this.oldX = 0;
this.currX = 0;
this.status = STATUS_READY;
clearTimeout(timer);
}, 1000);
},

reload() {
// 还没有图片或者匹配失败时 则不触发此事件
if (this.status !== STATUS_READY && this.status !== STATUS_MATCH) {
return;
}
// canvas对象
const ctxShadow = this.$refs.shadowCanvas.getContext("2d");
const ctxFragment = this.$refs.fragmentCanvas.getContext("2d");

// 清空画布
ctxShadow.clearRect(0, 0, this.fragmentSize, this.fragmentSize);
ctxFragment.clearRect(0, 0, this.fragmentSize, this.fragmentSize);

this.isMovable = false;
this.offsetX = 0;
this.offsetY = 0;
this.startX = 0;
this.oldX = 0;
this.currX = 0;
this.status = STATUS_LOADING;

this.renderImage();
this.onReload();
},

onShowTips() {
if (this.isShowTips) return;
const tipsIndex = this.status === STATUS_MATCH ? 0 : 1;
this.isShowTips = true;
this.tipsIndex = tipsIndex;
const timer = setTimeout(() => {
this.isShowTips = false;
clearTimeout(timer);
}, 2000);
}
}
};
</script>

<style lang="scss" scoped>
.image-all-container {
padding: 10px;
user-select: none;
}

.image-container {
position: relative;
background-color: #ddd;
}

.canvas {
position: absolute;
top: 0;
left: 0;
}

.reload-container {
margin: 20px 0;
}

.reload-wrapper {
display: inline-flex;
align-items: center;
cursor: pointer;
}

.reload-ico {
width: 20px;
height: 20px;
font-size: 20px;
margin-right: 10px;
background: center/cover no-repeat;
}

.reload-tips {
font-size: 14px;
color: #666;
}

.slider-wrpper {
position: relative;
margin: 10px 0;
}

.slider-bar {
padding: 10px;
font-size: 14px;
text-align: center;
color: #999;
background-color: #ddd;
}

.slider-button {
position: absolute;
top: 54%;
left: 0;
width: 50px;
height: 50px;
border-radius: 25px;
transform: translateY(-50%);
cursor: pointer;
background: #fff center/80% 80% no-repeat;
box-shadow: 0 2px 10px 0 #333;
}
.el-icon-video-pause {
font-size: 50px;
}

/* 提示信息 */
.tips-container {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
padding: 10px;
transform: translate(-50%, -50%);
transition: all 0.25s;
background: #fff;
border-radius: 5px;
visibility: hidden;
opacity: 0;
}

.tips-container--active {
visibility: visible;
opacity: 1;
}

.tips-ico {
width: 20px;

background: center/cover no-repeat;
}

.tips-text {
color: #666;
}
</style>

父组件调用

<template>

<div>

<ImageCaptcha
image-url="https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=336492172,12372647&fm=26&gp=0.jpg"
:on-reload="onReload"
:on-match="onMatch"
/>
</div>
</template>
<script>


import ImageCaptcha from "@/components/imageCaptcha";
export default {
components: { ImageCaptcha },
data() {
return {};
},
created() {},
methods: {
onReload() {
// console.log("reload");
},
onMatch() {
// console.log("code is match");
}
}
};
</script>
<style lang="scss" scoped>
</style>