组件
javascript"><template><canvas :class="cvsClass" :width="props.width" :height="props.height" ref="cvs"></canvas>
</template><script setup>
import { ref, reactive, watch, nextTick } from "vue";
const props = defineProps({// 是否开启服务端验证servertest: {type: Boolean,default: false},width: {type: Number,default: 220},height: {type: Number,default: 45},strokeWidth: {type: Number,default: 3},// 滑块宽度dropWidth: {type: Number,default: 50},// 已激活验证背景色 activeBgColor | 验证中激活的背景色 testIngBgColor| 验证成功激活的背景色 successBgColor// 验证成功文本色 tipSucColor| 验证失败文本色 tipTailColor | 验证中的文本色 tipTestIngColor | 待验证文本色 tipNoneColor// 移动滑块背景色 dropColor// 滑块原始背景色 slideColor// 滑块颜色dropColor: {type: String,default: "#fff"},// 待验证文本色tipNoneColor: {type: String,default: "#000"},// 验证成功文本色tipSucColor: {type: String,default: "#fff"},// 验证中的文本色tipTestIngColor: {type: String,default: "#fff"},// 验证失败文本色tipTailColor: {type: String,default: "#ee0a24"},// 验证中提示testTip: {type: String,default: "正在验证..."},// 滑块背景色颜色slideColor: {type: String,default: "#fff"},// 滑块背景色颜色tipTxt: {type: String,default: "拖动滑块验证"},// 验证通过背景色successBgColor: {type: String,default: "#07c160"},// 验证失败背景色tailBgColor: {type: String,default: "#ee0a24"},// 已激活的背景色activeBgColor: {type: String,default: "#888"},// 验证中激活的背景色testIngBgColor: {type: String,default: "#ff976a"},// 验证成功文字提示successTip: {type: String,default: "恭喜你验证通过!"},// 验证失败文字提示failTip: {type: String,default: "验证失败,请重试"},// 文本大小fontSize: {type: Number,default: 14}
});
const emit = defineEmits(["statu"]);
let vfcx = null;
const cvs = ref();
const cvsClass = ref("cur-none");
let vfcres = {startX: 0, //开始拖动滑块位置endX: 0, //结束拖动滑块位置timed: 0, //拖动所用时间 || 低于30毫秒认定为机器guiji: [], //拖动轨迹 | 连续2个2数之差相同判定为机器width: props.width
};
const vfcStatu = reactive({statu: "none"
});
// 监听数据,并发给父级
watch(vfcStatu, res => {emit("statu", res, vfcres);// 验证成功if (res.statu === "success") {vfcx.anmateOff = false;vfcx.activeBgColor = props.successBgColor;vfcx.tipTxt = props.successTip;vfcx.colors.slideColor = props.successBgColor;vfcx.evNone();} else if (res.statu === "tail") {vfcx.reset();vfcx.tipTxt = props.failTip;vfcx.fontColor = props.tipTailColor;vfcx.draw();}
});
/*** 验证器* @param {Element} cvsEl canvas元素* @param {String, default:'cur-none'} cvsClass canvas的class* @param {Boolear, default:fasle} vfcres 验证结果* @param {Number, default:5} strokeWidth 滑块内边距* @param {Number,default:50} dropWidth 滑块宽度* @param {color,default:'#fff'} dropColor 移动滑块背景色* @param {color,default:'#e8e8e8'} slideColor 滑块背景色颜色* @param {color,default:'skyblue'} activeBgColor 已激活验证背景色* @param {color,default:'#ff976a'} testIngBgColor 验证中激活的背景色* @param {color,default:'#07c160'} successBgColor 验证成功激活的背景色* @param {color,default:'#07c160'} tipSucColor 验证成功文本色* @param {color,default:'#ee0a24'} tipTailColor 验证失败文本色* @param {color,default:'#fff'} tipTestIngColor 验证中的文本色* @param {color,default:'#000'} tipNoneColor 待验证文本色* @param {String,default:'向右滑动验证'} tipTxt 文字提示* @param {String,default:'太棒了,恭喜你验证通过!'} successTip 验证成功文字提示* @param {String,default:'验证失败,请重试...'} failTip 验证失败文字提示* @param {Bool} servertest 是否开启前端验证模式* @param {String} testTip 验证提示*/
class Vfcs {constructor(cvsEl, cvsClass, vfcres, vfcStatu, strokeWidth, dropWidth, fontSize, servertest, colors, tipTxt) {this.cvsEl = cvsEl;this.vfcres = vfcres;this.cvsClass = cvsClass;this.strokeWidth = strokeWidth;this.dropWidth = dropWidth;this.vfcStatu = vfcStatu;this.colors = colors;this.fontSize = fontSize;this.dwonIsPath = false; //是否按下验证滑块this.ctx = null;this.allTipTxts = tipTxt;this.tipTxt = this.allTipTxts.tipTxt;this.fontColor = this.colors.tipNoneColor;this.activeBgColor = this.colors.activeBgColor;this.servertest = servertest;this.guiji = [];this.startTime = 0;this.endTime = 0;this.startX = 0;this.startY = 0;this.moveX = 0;this.moveY = 0;this.fontOp = 1; //文本透明度this.met = false;this.offX = 0; //x轴的位移this.minX = this.strokeWidth / 2;this.maxX = this.cvsEl.width - this.dropWidth - this.strokeWidth;// this.dropX最大值 -》 cW - this.dropWidth - this.strokeWidth / 2// this.dropX最小 -》 this.strokeWidth / 2this.dropX = this.minX + this.offX; // 滑块位置this.toTouchEnd = false;//是否按下滑块this.isDown = false;this.testAm = null; //验证中动画的idthis.anmateOff = true; //动画开关this.evsName = []; //事件名this.evsFun = [this.down.bind(this), this.move.bind(this), this.up.bind(this)]; //事件方法this.init();}init() {this.ctx = this.cvsEl.getContext("2d");this.draw();this.evsName = this.evType();// 给canvas添加事件this.evsName.forEach((evName, i) => (i === 0 ? this.cvsEl.addEventListener(evName, this.evsFun[i]) : document.addEventListener(evName, this.evsFun[i])));}// 绘制draw() {let cW = this.cvsEl.width,cH = this.cvsEl.height,c = this.ctx;c.clearRect(0, 0, cW, cH);c.globalAlpha = this.fontOp; // 设置图像透明度c.fillRect(0, 0, cW, cH);c.fillStyle = this.colors.slideColor;c.strokeStyle = this.colors.slideColor;c.lineWidth = this.strokeWidth;c.fillRect(0, 0, cW, cH);c.strokeRect(0, 0, cW, cH);// 激活背景色c.fillStyle = this.activeBgColor;c.strokeStyle = this.activeBgColor;c.fillRect(this.minX + 2, this.minX, this.offX, cH - this.strokeWidth);// 文本提示c.textAlign = "center";c.textBaseline = "middle";c.fillStyle = this.fontColor;c.font = `${this.fontSize}px 黑体`;c.fillText(this.tipTxt, cW / 2, cH / 2);// 验证失败// 待验证 | 验证中if (this.vfcStatu.statu === "none" || this.vfcStatu.statu === "testing" || this.vfcStatu.statu === "servertest" || this.vfcStatu.statu === "tail") {// 滑块c.beginPath();c.fillStyle = this.colors.dropColor;c.rect(this.dropX, this.minX, this.dropWidth, cH - this.strokeWidth);c.fill();// 箭头c.lineWidth = 2;// 右边箭头c.moveTo(this.dropX + this.dropWidth / 1.7 - 5, this.strokeWidth + 10);c.lineTo(this.dropX + this.dropWidth / 1.7 + 5, cH / 2);c.lineTo(this.dropX + this.dropWidth / 1.7 - 5, cH - this.strokeWidth - 10);// 左边箭头c.moveTo(this.dropX + this.dropWidth / 1.7 - 15, this.strokeWidth + 10);c.lineTo(this.dropX + this.dropWidth / 1.7 - 5, cH / 2);c.lineTo(this.dropX + this.dropWidth / 1.7 - 15, cH - this.strokeWidth - 10);c.stroke();c.closePath();// 验证成功} else if (this.vfcStatu.statu === "success") {// 滑块c.beginPath();c.fillStyle = this.colors.dropColor;c.rect(this.dropX, this.minX, this.dropWidth, cH - this.strokeWidth);c.fill();c.closePath();// 圈c.beginPath();c.fillStyle = this.colors.successBgColor;c.arc(this.dropWidth / 2 + this.dropX, cH / 2, cH / 3, 0, 2 * Math.PI);c.fill();c.closePath();// 勾c.beginPath();c.lineWidth = 3;c.lineJoin = "bevel";c.lineCap = "round";c.strokeStyle = this.colors.dropColor;c.moveTo(this.dropX + this.dropWidth / 2 - 8, cH / 2 + 1);c.lineTo(this.dropX + this.dropWidth / 2.1, cH / 1.6);c.lineTo(this.dropX + this.dropWidth / 2 + 8, cH / 2 - 5);c.stroke();c.closePath();}}// 滑块按下down(ev) {if (this.vfcStatu.statu === "testing" || this.vfcStatu.statu === "servertest") return;this.setXY(ev);//按下滑块this.isDown = true;this.startTime = new Date().getTime();// 若按下滑块const isPath = this.ctx.isPointInPath(this.startX, this.startY);this.dwonIsPath = isPath;}// 滑块移动move(ev) {if (this.vfcStatu.statu === "testing" || this.vfcStatu.statu === "servertest") return;this.setXY(ev);const isPath = this.ctx.isPointInPath(this.moveX, this.moveX);// pc 鼠标变手势if (ev.x) isPath === true ? (this.cvsClass.value = "pointer") : (this.cvsClass.value = "cur-none");const x = Number(this.moveX.toFixed(2));const y = Number(this.moveY.toFixed(2));const moveTime = new Date().getTime();this.guiji.push({ x, y, moveTime });if (this.dwonIsPath === false || this.moveX <= 0) return;if (this.isDown === true) {// 若滑到尾部this.toTouchEnd = this.touchDrosToEnd();if (this.toTouchEnd === true) this.up();this.draw();}}// 滑块抬起up() {if (this.vfcStatu.statu === "testing" || this.vfcStatu.statu === "servertest" || this.offX === 0 || this.dwonIsPath === false || this.moveX <= 0) return;this.endTime = new Date().getTime();this.vfcres.startX = this.startX; //鼠标/手指按下位置this.vfcres.endX = this.dropX + this.dropWidth + this.minX; //鼠标/手指抬起位置this.vfcres.timed = this.endTime - this.startTime; //耗时this.vfcres.guiji = this.guiji; //滑动轨迹this.vfcres.width = this.cvsEl.width;this.dwonIsPath = false;this.isDown = false;// 未滑动到尾部if (this.toTouchEnd === false) {this.dropX = this.minX; // 滑块位置this.offX = 0;this.tipTxt = this.allTipTxts.failTip;this.fontColor = this.colors.tipTailColor;// 滑动到尾部} else {this.vfcStatu.statu = "testing";this.testAdmate(); //开启动画// 验证中this.fontColor = this.colors.tipTestIngColor;this.tipTxt = this.allTipTxts.testTip;this.activeBgColor = this.colors.testIngBgColor;this.dropX = this.maxX + this.minX; // 滑块位置const test = this.testVer();setTimeout(() => {// 前端验证通过if (test === "success") {// 已开启前端验证模式if (this.servertest === true) {this.vfcStatu.statu = "servertest";} else {this.vfcStatu.statu = "success";}// 前端验证不通过} else {this.vfcStatu.statu = "tail";}}, 1000);}this.draw();this.guiji = [];}// 重置滑块reset() {this.dropX = this.minX; // 滑块位置this.anmateOff = false;this.activeBgColor = this.colors.activeBgColor;this.fontColor = this.colors.tipNoneColor;this.tipTxt = this.allTipTxts.tipTxt;this.offX = 0;this.toTouchEnd = false;this.guiji = [];this.draw();}// 解绑事件evNone() {this.evsName.forEach((evName, i) => (i === 0 ? this.cvsEl.removeEventListener(evName, this.evsFun[i]) : document.removeEventListener(evName, this.evsFun[i])));}// 验证中动画testAdmate() {// 文本透明度if (this.met === false && this.fontOp >= 1) {this.met = true;} else if (this.met === true && this.fontOp <= 0.5) {this.met = false;}this.met === false ? (this.fontOp += 0.015) : (this.fontOp -= 0.015);this.draw();cancelAnimationFrame(this.testAm);this.testAm = window.requestAnimationFrame(this.testAdmate.bind(this));if (this.anmateOff === false) {cancelAnimationFrame(this.testAm);this.fontOp = 1;this.testAm = null;this.met = false;this.anmateOff = true;}this.draw();}/*** 验证是否滑动到尾部* @return {Number} return true 到尾部,false 没到尾部*/touchDrosToEnd() {const x = this.offX + this.dropWidth + this.strokeWidth;const isSuccess = x >= this.cvsEl.width;return isSuccess;}// 设置xy坐标setXY(ev) {if (ev.type === "touchstart") {this.startX = ev.touches[0].clientX - this.cvsEl.getBoundingClientRect().left;this.startY = ev.touches[0].clientY - this.cvsEl.getBoundingClientRect().top;}if (ev.type === "touchmove") {this.moveX = ev.touches[0].clientX - this.cvsEl.getBoundingClientRect().left;this.moveY = ev.touches[0].clientY - this.cvsEl.getBoundingClientRect().top;}// ///pc事件 //if (ev.type === "mousedown") {this.startX = ev.x - this.cvsEl.getBoundingClientRect().left;this.startY = ev.y - this.cvsEl.getBoundingClientRect().top;}if (ev.type === "mousemove") {this.moveX = ev.x - this.cvsEl.getBoundingClientRect().left;this.moveY = ev.y - this.cvsEl.getBoundingClientRect().top;}// 防止滑块溢出指定范围if (ev.type === "mousemove" || ev.type === "touchmove") {this.offX = this.moveX - this.startX;if (this.offX > this.maxX) this.offX = this.maxX;if (this.offX < this.minX) this.offX = this.minX;this.dropX = this.minX + this.offX; // 滑块位置}}// 事件类型evType() {const isMobile = navigator.userAgent.match(/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i) !== null;const events = isMobile ? ["touchstart", "touchmove", "touchend"] : ["mousedown", "mousemove", "mouseup"];return events;}/*** 滑动轨迹信息 | 计算滑动轨迹每2数之间的差值 | 出现次数等* @return {Object(chaArr,repeatX,repeatY,repeatMaxXCount,repeatMaxYCount,allCount)} chaArr → 每2数之间的插值 | repeatX → x轴每2数之间的差值与重复数 | repeatY → y轴每2数之间的差值与重复数 | repeatMaxXCount → x轴每重复数最多的次数 | repeatMaxYCount → y轴每重复数最多的次数*/arrCmp() {// 重复的数量const repeatX = [];const repeatY = [];const timed = [];const chaArr = this.guiji.reduce((prev, itm, i, arr) => {if (i === arr.length - 1) return prev;const nv = arr[i + 1];const chaX = Number((nv.x - itm.x).toFixed(2));const chaY = Number((nv.y - itm.y).toFixed(2));const timeCha = nv.moveTime - itm.moveTime;timed.push(timeCha); //时间差// 是否有重复的数组const repeatXIndex = repeatX.findIndex(item => item.num === chaX);const repeatYIndex = repeatY.findIndex(item => item.num === chaY);// xy轴每2数差数据if (repeatXIndex === -1) {const obj = {num: chaX,count: 1};repeatX.push(obj);} else {repeatX[repeatXIndex].count++;}if (repeatYIndex === -1) {const obj = {num: chaY,count: 1};repeatY.push(obj);} else {repeatY[repeatYIndex].count++;}prev.push({ x: chaX, y: chaY });return prev;}, []);// 所有重复次数const findXCount = [];const findYCount = [];repeatX.forEach(it => findXCount.push(it.count));repeatY.forEach(it => findYCount.push(it.count));const repeatMaxXCount = Math.max(...findXCount); //x重复最多的次数const repeatMaxYCount = Math.max(...findYCount); //y重复最多的次数const repeatMaxTimed = Math.max(...timed); //滑动时间重复最多的次数return {chaArr,repeatX,repeatY,repeatMaxXCount,repeatMaxYCount,repeatMaxTimed};}// 前端验证// x轴最大波动大于数等于所有波动长度则为人机 | y轴最大波动数等于所有波动长度则为人机 | 滑动时间低于50毫秒不通过 | 时间波动最大次数大于滑动轨迹长度的3/1为人机testVer() {// return 'tail'// 滑动所用时间低于50毫秒 是人机if (this.vfcres.timed < 50) return "tail";const sliderInfo = this.arrCmp(); //处理滑动轨迹信息// 时间波动最大次数等于sliderInfo.chaArr.length滑动轨迹长度为人机const timeTest = sliderInfo.repeatMaxTimed === sliderInfo.chaArr.length;if (timeTest === true) return "tail";// x轴最大波动大于数等于所有波动长度则为人机if (sliderInfo.repeatMaxXCount === sliderInfo.repeatX) return "tail";// y轴最大波动数等于所有波动长度则为人机if (sliderInfo.repeatMaxYCount === sliderInfo.chaArr.length) return "tail";// 是真人return "success";}
}
nextTick(() => {const colors = {activeBgColor: props.activeBgColor,testIngBgColor: props.testIngBgColor,successBgColor: props.successBgColor,tipSucColor: props.tipSucColor,tipTailColor: props.tipTailColor,tipTestIngColor: props.tipTestIngColor,tipNoneColor: props.tipNoneColor,dropColor: props.dropColor,slideColor: props.slideColor};const tipTxt = {testTip: props.testTip,tipTxt: props.tipTxt,successTip: props.successTip,failTip: props.failTip};vfcx = new Vfcs(cvs.value, cvsClass, vfcres, vfcStatu, props.strokeWidth, props.dropWidth, props.fontSize, props.servertest, colors, tipTxt);
});
</script>
页面调用
javascript"><slider-vfc @statu="slideVerify" />
// 滑块验证
const slideVerify = vfcStatu => {props.form.verify = vfcStatu.statu == "success";
};