LuoSong
LuoSong
Published on 2024-11-25 / 11 Visits
0
0

React&Ant Design实现简易图形滑块验证

React&Ant Design实现简易图形滑块验证

背景

自己网页登录时、发送验证码时没有人机验证方式,于是自己开发前后端,写了一个十分简单的图形滑块验证码组件。
注意:移动端由于触发方式的不同,需要在后端校验时做一定的容差处理。

效果

如有侵权,请联系我删除,谢谢!

captcha-eg.png

代码

错误提示使用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)}
/>

Comment