React&Ant Design实现简易图形滑块验证
背景
自己网页登录时、发送验证码时没有人机验证方式,于是自己开发前后端,写了一个十分简单的图形滑块验证码组件。
注意:移动端由于触发方式的不同,需要在后端校验时做一定的容差处理。
效果
如有侵权,请联系我删除,谢谢!
代码
错误提示使用Ant Design中message,使用Modal承载弹窗。裁剪图大小为后端返回,蓝色操作按钮的宽度会随裁剪图宽度变化。
@/services/captcha(queryCaptcha) 后端接口请求
import { request } from 'umi';
export async function queryCaptcha(options) {
return request('接口地址', {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=UTF-8',
},
body: JSON.stringify(options),
});
}
接口返回示例结构
{
"status": 0,
"message": "成功",
"data": {
"randomKey": "EVG4aeYP4UgCgYf99PAHeu3VHRj9cSXg",
"cutImageWidth": 50,
"oriImage": "背景图base64编码",
"y": 13,
"cutImageHeight": 60,
"cutImage": "裁剪图base64编码"
}
}
index.less
.image-captcha-box {
width: 100%;
height: 175px;
position: relative;
.image-captcha-box-bg {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.image-captcha-box-cut {
position: absolute;
}
}
.slider-area {
position: relative;
width: 100%;
height: 10px;
margin-top: 20px;
border-radius: 50px;
box-sizing: border-box;
background-color: #f0f0f0;
.slider-box {
position: absolute;
top: 50%;
height: 24px;
color: #fff;
font-size: 16px;
line-height: 24px;
transform: translate(0, -50%);
background-color: #007bff;
border-radius: 25px;
cursor: pointer;
text-align: center;
}
}
.button-box {
width: 100%;
display: flex;
justify-content: space-between;
}
index.tsx(ImageCaptcha)
import React, { useEffect, useRef, useState } from 'react';
import './index.less';
import { queryCaptcha } from '@/services/captcha';
import { Button, Modal, Skeleton, Spin, message } from 'antd';
import { ReloadOutlined, CloseOutlined, ArrowRightOutlined } from '@ant-design/icons';
// 定义滑动验证所需变量
// document监听的事件,无法监听React状态变更,也就无法使用useState定义的变量
let movable = false;
let movePosition = { x: 0, y: 0 };
let mousePosition = { x: 0, y: 0 };
// 占位背景图,可不用,代码中有骨架屏
const emptyCaptcha = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAV4AAACvCAYAAAC1krYSAAAAAXNSR0IArs4c6QAABv5JREFUeF7t1jFuHEEQA0Dd/3+sRAbsxIkN6bTHWVLlWLfdXRwQfry/v3+8+UeAAAECMYGH4o1ZG0SAAIHfAorXQyBAgEBYQPGGwY0jQICA4vUGCBAgEBZQvGFw4wgQIKB4vQECBAiEBRRvGNw4AgQIKF5vgAABAmEBxRsGN44AAQKK1xsgQIBAWEDxhsGNI0CAgOL1BggQIBAWULxhcOMIECCgeL0BAgQIhAUUbxjcOAIECCheb4AAAQJhAcUbBjeOAAECitcbIECAQFhA8YbBjSNAgIDi9QYIECAQFlC8YXDjCBAgoHi9AQIECIQFFG8Y3DgCBAgoXm+AAAECYQHFGwY3jgABAorXGyBAgEBYQPGGwY0jQICA4vUGCBAgEBZQvGFw4wgQIKB4vQECBAiEBRRvGNw4AgQIKF5vgAABAmEBxRsGN44AAQKK1xsgQIBAWEDxhsGNI0CAgOL1BggQIBAWULxhcOMIECCgeL0BAgQIhAUUbxjcOAIECCheb4AAAQJhAcUbBjeOAAECitcbIECAQFhA8YbBjSNAgIDi9QYIECAQFlC8YXDjCBAgoHi9AQIECIQFFG8Y3DgCBAgoXm+AAAECYQHFGwY3jgABAorXGyBAgEBYQPGGwY0jQICA4vUGCBAgEBZQvGFw4wgQIKB4vQECBAiEBRRvGNw4AgQIKF5vgAABAmEBxRsGN44AAQKK1xsgQIBAWEDxhsGNI0CAgOL1BggQIBAWULxhcOMIECCgeL0BAgQIhAUUbxjcOAIECCheb4AAAQJhAcUbBjeOAAECitcbIECAQFhA8YbBjSNAgIDi9QYIECAQFlC8YXDjCBAgoHi9AQIECIQFFG8Y3DgCBAgoXm+AAAECYQHFGwY3rlfg4+Pj7fF49B5g89sIKN7bRGGRuwso3rsn1LOf4u3JyqaHBRTv4QCGxiveoTCd8loBxfta35/0dcU7nPbfRaE0hoN2Wp2A4q2LrHNhxd+Zm61fI6B4X+PqqwQIEPingOL1OOYEvvK/66/87RyUg44JKN5j9AbfQUDx3iGFn7eD4v15md/q4tPFd3r+rcKwTExA8caozwy6e7Gc3u/0/DOvwtTTAor3dAIH59+hdE7vcHr+wfiNPiigeA/iN4++qrCu+s6zlqfn/2/vO+/2rLff/RFQvF7CUYHT5XJ6/lF8w48JKN5j9AbfQeDK4r3yW3ewscPrBBTv62x9uUBAWRaENLii4h0M1UmfF1C8n7fyl9cJKN7rLCu+dFXRXPWd02h3vuPOu53OrX2+4m1P0P7fElBu3+Lz4ycFFO+TcH62IaB4N3Jsu0LxtiVm30sFni3eZ3936fI+ViugeGujs/gVAgr0CkXf+KqA4v2qmL+fElC8U3HWHKN4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwK/APJhIeCPK54wAAAAAElFTkSuQmCC';
interface Props {
/**
* 是否打开
*/
captchOpen: boolean;
/**
* 校验函数
* @param randomKey 滑块随机key
* @param x 滑动x坐标
* @returns Func
*/
endMove: (randomKey: string, x: number) => void;
/**
* 改变父组件中状态
* @param open 是否打开
* @returns Func
*/
changeModalOpen: (open: boolean) => void;
/**
* 校验错误
*/
checkError: boolean;
/**
* 改变校验错误
* @param error 是否校验错误
* @returns Func
*
*/
changeCheckError: (error: boolean) => void;
/**
* 校验中
*/
checkLoading: boolean;
/**
* 背景图与裁剪图的padding
*/
backPadding: number;
}
const ImageCaptcha: React.FC<Props> = (props) => {
const { captchOpen, endMove, changeModalOpen, checkError, changeCheckError, checkLoading, backPadding } = props;
const [backImage, setBackImage] = useState('');
const [cutImage, setCutImage] = useState('');
const [position, setPosition] = useState({ x: backPadding, y: 0 });
const [modalLoading, setModalLoading] = useState(false);
const [randomKey, setRandomKey] = useState('');
const [cutWidth, setCutWidth] = useState(0);
const [cutHeight, setCutHeight] = useState(0);
const sliderAreaRef = useRef(null);
const sliderBoxRef = useRef(null);
/**
* 打开弹窗时获取验证码
*/
useEffect(() => {
getCaptcha();
}, [captchOpen]);
/**
* 校验错误时重新获取验证码
*/
useEffect(() => {
if (checkError) {
getCaptcha();
changeCheckError(false);
}
}, [checkError]);
/**
* 获取验证码
*/
const getCaptcha = async () => {
setModalLoading(true);
const res = await queryCaptcha();
if (res) {
if (res.status === 0) {
setBackImage('data:image/png;base64,' + res.data.oriImage);
setCutImage('data:image/png;base64,' + res.data.cutImage);
setPosition({ x: backPadding, y: res.data.y });
movePosition = { x: backPadding, y: res.data.y };
movable = false;
setCutHeight(res.data.cutImageHeight);
setCutWidth(res.data.cutImageWidth);
setRandomKey(res.data.randomKey);
setModalLoading(false);
mousePosition = { x: 0, y: 0 }
} else {
setModalLoading(false);
message.warning(res.message);
}
}
};
/**
* 处理移动开始事件
* @param e 移动事件
*/
const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
if (!isPassiveEventSupported()) {
e.preventDefault();
}
if (backImage === '' || cutImage === '' || movable) return;
movable = true;
mousePosition = {
x: ('touches' in e) ? e.touches[0].clientX : e.clientX,
y: ('touches' in e) ? e.touches[0].clientY : e.clientY
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleMouseMove);
document.addEventListener('touchend', handleMouseUp);
};
/**
* 处理移动事件
* 移动距离<背景图左边距(backPadding) 设置移动距离为backPadding
* 移动距离>背景图宽度-滑块图宽度-背景图左边距(backPadding) 设置移动距离为最大
* 其余时候直接设置滑动距离
* @param e 移动事件
*/
const handleMouseMove = (e: MouseEvent | TouchEvent) => {
if (!isPassiveEventSupported()) {
e.preventDefault();
}
if (!movable) return;
const enventX = ('touches' in e) ? e.touches[0].clientX : e.clientX;
const moveX = enventX - mousePosition.x;
const rect = sliderAreaRef.current.getBoundingClientRect();
if (moveX < backPadding) {
setPosition({ ...position, x: backPadding });
movePosition = { ...movePosition, x: backPadding };
} else if (moveX > rect.width - sliderBoxRef.current.offsetWidth - backPadding) {
setPosition({ ...position, x: rect.width - sliderBoxRef.current.offsetWidth - backPadding });
movePosition = { ...movePosition, x: rect.width - sliderBoxRef.current.offsetWidth - backPadding };
} else {
setPosition({ ...position, x: moveX });
movePosition = { ...movePosition, x: moveX };
}
};
/**
* 处理移动结束事件
* @returns 校验函数
*/
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleMouseMove);
document.removeEventListener('touchend', handleMouseUp);
if (!movable) return;
if (backImage === '' || cutImage === '') return;
movable = false;
endMove(randomKey, movePosition.x);
};
/**
* 检查当前浏览器是否支持被动事件监听器
* @returns 如果支持返回 true,否则返回 false
*/
const isPassiveEventSupported = () => {
let supportsPassive = false;
try {
const opts = Object.defineProperty({}, 'passive', {
get: () => {
supportsPassive = true;
}
});
window.addEventListener('test', null, opts);
} catch (e) { }
return supportsPassive;
}
/**
* 关闭弹窗
*/
const closeModal = () => {
changeModalOpen(false);
setBackImage('');
setCutImage('');
setPosition({ x: backPadding, y: 0 });
mousePosition = { x: 0, y: 0 }
movePosition = { x: backPadding, y: 0 };
movable = false;
setModalLoading(false);
setCutWidth(0);
setCutHeight(0);
setRandomKey('');
}
return <Modal
destroyOnClose
open={captchOpen}
title='请进行验证'
footer={null}
width={398}
centered
onCancel={() => closeModal()}
>
<Spin spinning={checkLoading || modalLoading}>
{(!checkLoading || !modalLoading) ? <>
<div className='image-captcha-box'>
<img src={backImage || emptyCaptcha} alt='背景图' className='image-captcha-box-bg' />
<img src={cutImage} alt='裁剪图' className='image-captcha-box-cut' style={{ top: `${position.y}px`, left: `${position.x}px`, height: `${cutHeight}px`, width: `${cutWidth}px` }} />
</div>
<div className="slider-area" ref={sliderAreaRef}>
<div
className="slider-box"
ref={sliderBoxRef}
onMouseDown={(e) => handleMouseDown(e)}
onTouchStart={(e) => handleMouseDown(e)}
style={{ left: `${position.x}px`, width: `${cutWidth || 50}px` }}
>
<ArrowRightOutlined />
</div>
</div>
<div style={{ width: '100%', height: 40 }}></div>
</> : <Skeleton active style={{ width: '100%', height: '100%' }}
paragraph={{
rows: 6,
}} />}
</Spin>
<div className="button-box">
<Button disabled={checkLoading || modalLoading} onClick={() => closeModal()}><CloseOutlined />关闭</Button>
<Button disabled={checkLoading || modalLoading} type='primary' onClick={() => getCaptcha()}><ReloadOutlined />刷新</Button>
</div>
</Modal>
}
ImageCaptcha.defaultProps = {
captchOpen: false,
endMove: () => { },
changeModalOpen: () => { },
checkError: false,
changeCheckError: () => { },
checkLoading: false,
backPadding: 4,
};
export default ImageCaptcha;
使用,在调用文件中定义变量即可完成调用
<ImageCaptcha captchOpen={captchOpen}
endMove={(randomKey, x) => { checkCaptcha(randomKey, x) }}
changeModalOpen={() => setCaptchOpen(false)}
checkError={captchaError}
checkLoading={captchaLoading}
changeCheckError={() => setCaptchaError(false)}
/>