Taro React 之行为验证码之文字点选

1. 效果预览

2. 使用场景

账号登录,比如验证码发送,防止无限调用发送接口,所以在发送之前,需要行为验证!

3. 插件选择

  1. AJ-Captcha行为验证码文档
  2. AJ-Captcha行为验证码代码仓库

为什么要选用【AJ-Captcha行为验证码】呢?因为我们管理后台使用的是 pigx ,它在后端采用的是【AJ-Captcha行为验证码】,因此移动端也采用了【AJ-Captcha行为验证码】!

4. 为什么要开发

因为【AJ-Captcha行为验证码】前端提供了Android、iOS、Futter、Uni-App、ReactNative、Vue、Angular、Html、Php等多端示例,发现并没有 taro react 的示例,因此决定根据他的 uni-app 示例开发一个 taro react 的示例!

5. 文字背景图和点击文字获取

  1. 由于后端接口是将【AJ-Captcha行为验证码】的java代码拉到项目中,接口名进行了修改,所以这个更具自己的实际开发来;
  2. 传入参数captchaType,验证码类型:1)滑动拼图 blockPuzzle 2)文字点选 clickWord;
  3. 使用 setPointBackImgBase 保存返回文字背景图片;
  4. 使用 setInfo 保存最后验证需要的 token,加密 secretKey,以及点选的文字 poinTextList。
ini 复制代码
  // 获取点击文字的图片信息
  function getCommonImageCode(){
    axios.getCommonImageCode({
      captchaType: page.current.captchaType,  //验证码类型 clickWord, blockPuzzle
    }).then(res => {
      // console.log(res)
      if (res.repCode == "0000") {
        setPointBackImgBase(`data:image/png;base64,${res.repData.originalImageBase64}`)
        info.backToken = res.repData.token
        info.secretKey = res.repData.disturbStr
        info.poinTextList = res.repData.wordList
        info.text = `请依次点击【${info.poinTextList.join(",")}】`
        setInfo({...info})
      }
      // 判断接口请求次数是否失效
      if(res.repCode == '6201') {
        setPointBackImgBase(null)
      }
    }).catch(console.log)
  }

6. 将点选的坐标验证

1. 代码分析

  1. 由于后端接口是将【AJ-Captcha行为验证码】的java代码拉到项目中,接口名进行了修改,所以这个更具自己的实际开发来;
  2. 验证参数进行了加密后传给后端校验;
  3. 校验成功,修改点选文字、边框和颜色,同时回调 props.onSuccess,将返回参数返回,用于获取短信验证码接口校验
  4. 校验失败,修改点选文字、边框和颜色提示失败,刷新新的行为验证码,同时如果对校验失败有监听,可以使用 props.onFail,监听校验失败。

2. 代码实现

scss 复制代码
	// 验证选择的点
  function verifyPointsByRquest(data, captchaVerification){
    axios.getCommonImageCheck({...data}).then(res => {
      // let res = result.data
      // console.log(res)
      if (res.repCode == "0000") {
        setStyleInfo({
          ...styleInfo,
          barAreaColor: '#4cae4c',
          barAreaBorderColor: '#5cb85c',
          bindingClick: false
        })
        setInfo({
          ...info,
          text: '验证成功'
        })
        setTimeout(() => {
          refresh();
        },1500)
        props.onSuccess && props.onSuccess(res.repData)
      }else{
        setStyleInfo({
          ...styleInfo,
          barAreaColor: '#d9534f',
          barAreaBorderColor: '#d9534f',
          bindingClick: false
        })
        setInfo({
          ...info,
          text: '验证失败'
        })
        setTimeout(() => {
          refresh();
        }, 700);
        props.onFail && props.onFail(res.repData)
      }
    }).catch(console.log)
  }

3. 验证成功

4. 验证失败

7. 刷新文字点选界面

  1. bindingClick 逻辑上允许进行文字点选;
  2. tempPoints 清空点选的文字坐标列表;
  3. checkPosArr 清空提交后端的校验坐标列表;
  4. num 记录选择第几个文字了,初始化第一个;
  5. setStyleInfo 设置界面初始化样式;
  6. getCommonImageCode 获取文字点选的图片和文字信息;
  7. showRefresh 是否切换刷新状态。
ini 复制代码
  // 刷新
  function refresh() {
    page.current.bindingClick = true
    page.current.tempPoints.splice(0, page.current.tempPoints.length)
    page.current.fontPos.splice(0, page.current.fontPos.length)
    page.current.checkPosArr.splice(0, page.current.checkPosArr.length)
    page.current.num = 1
    setStyleInfo({
      barAreaColor: '#000',
      barAreaBorderColor: '#ddd',
      bindingClick: true,
      tempPoints: page.current.tempPoints
    })
    // 获取点击文字的图片信息
    getCommonImageCode();

    // this.text = '验证失败'
    page.current.showRefresh = true
  }

8. 界面布局

  1. 整个行为验证一个盒子;
  2. 上边行为验证码的盒子,里边放刷新按钮,行为验证码的图片,以及点击坐标标识【showCurrentNumHtml】;
  3. 最下边放需要点击的提示文本,此处在验证成功和验证失败后会进行样式修改。
ini 复制代码
  return <View className='rui-verify-slider-components-content rui-pr'>
    <View className='verify-image-out'>
      <View 
        style={{
          'width': imgSize.width,
          'height': imgSize.height,
          'marginBottom': vSpace + 'px'
        }}
        className='verify-image-panel rui-flex-cc'>
        <Image src={licon.refreshIcon} className="verify-refresh" onClick={refresh}></Image>
        {
          pointBackImgBase ? 
            <Image 
              src={pointBackImgBase} 
              id='image'
              ref={imgRef}
              onClick={styleInfo.bindingClick ? canvasClick : undefined}
              className="rui-verify-point-image"></Image>
            : <View className='rui-fs60'>图片加载中</View>
        }
        
      </View>
      {/* 当前点击位置 */}
      { showCurrentNumHtml() }
    </View>
    <View 
      style={{
        'width': imgSize.width,
        'color': styleInfo.barAreaColor,
        'borderColor': styleInfo.barAreaBorderColor
      }}
      className='verify-bar-area'>
      <Text className='verify-msg'>{info.text}</Text>
    </View>
  </View>

9. 文字点选标识

1. 标识代码

arduino 复制代码
  // 点击数字展示
  function showCurrentNumHtml(){
    return styleInfo.tempPoints.map((tempPoint,idx) => <View 
      key={`point-area-${idx}`}
      style={{
        'backgroundColor':'#1abd6c',
        'color':'#fff',
        'zIndex': 9999,
        'width':'20px',
        'height':'20px',
        'fontSize': '14px',
        'textAlign':'center',
        'lineHeight':'20px',
        'borderRadius': '50%',
        'position':'absolute',
        'top': parseInt(tempPoint.y - 10) + 'px',
        'left': parseInt(tempPoint.x - 10) + 'px'
      }}
      className='point-area'>
        {idx + 1}
      </View>)
  }

2. 标识效果

10. 文字点选

1. 代码分析

  1. 统一微信小程序端和H5端点击事件获取坐标点的数据结构;
  2. 通过 boundingClientRect 获取图片的信息;
  3. 通过 getMousePos 计算当前的点击坐标点;
  4. page.current.num < page.current.checkNum 说明还没有满足后端验证选择的坐标;
  5. 更新 num , page.current.num = createPoint(getMousePos(imgRef, e));
  6. page.current.num == page.current.checkNum 说明满足验证条件;
  7. 更新 num , page.current.num = createPoint(getMousePos(imgRef, e));
  8. 按比例转换坐标值,转换为后端校验的坐标;
  9. 根据是否存在加密key来判断验证条件是否加密,调用 verifyPointsByRquest 进行验证。

2. 代码实现

ini 复制代码
	// 图片点击事件
  function canvasClick(e){
    // 如果是H5,需要对e的数据解构进行重构
    if(api.hasWeb()){
      e = {
        detail: {
          x: e.x,
          y: e.y
        }
      }
    }
    const query = createSelectorQuery();
    query.select('#image').boundingClientRect(data => {
      // console.log(data)
      page.current.imgLeft = Math.ceil(data.left)
      page.current.imgTop = Math.ceil(data.top) 
      page.current.checkPosArr.push(getMousePos(imgRef, e));
      if(page.current.num == page.current.checkNum) {
        page.current.num = createPoint(getMousePos(imgRef, e));
        //按比例转换坐标值
        page.current.checkPosArr = pointTransfrom(page.current.checkPosArr,imgSize);
        //等创建坐标执行完
        setTimeout(() => {
          //发送后端请求
          let checkPosArrJsonStr = JSON.stringify(page.current.checkPosArr)
          let word = `${info.backToken}---${checkPosArrJsonStr}`
          let pointJson = info.secretKey ? aesCaptchaEncrypt(checkPosArrJsonStr, info.secretKey) : checkPosArrJsonStr;
          var captchaVerification = info.secretKey ? aesCaptchaEncrypt(word,info.secretKey) : word;
          let data = {
            "captchaType": page.current.captchaType,
            "pointJson": pointJson,
            "token": info.backToken
          }
          verifyPointsByRquest(data, captchaVerification)
        }, 400);
      }

      if (page.current.num < page.current.checkNum) {
        page.current.num = createPoint(getMousePos(imgRef, e));
      }
    }).exec();
  }

11. 计算点选坐标

arduino 复制代码
	//获取坐标
  function getMousePos(obj, e) {
    let position = {
        x:Math.ceil(e.detail.x) - page.current.imgLeft,
        y:Math.ceil(e.detail.y) - page.current.imgTop,
    }
    return position
  }

12. 创建点选坐标

scss 复制代码
  //创建坐标点
  function createPoint(pos) {
    page.current.tempPoints.push(Object.assign({}, pos))
    setStyleInfo({
      ...styleInfo,
      tempPoints: page.current.tempPoints
    })
    return ++page.current.num;
  }

13. 转换坐标

javascript 复制代码
	//坐标转换函数
  function pointTransfrom(pointArr,imgSize){
    var newPointArr = pointArr.map(p=>{
      let x = Math.round(310 * p.x / parseInt(imgSize.width)) 
      let y = Math.round(155 * p.y / parseInt(imgSize.height)) 
      return {x,y}
    })
    return newPointArr
  }

14. 变量保存

  1. imgRef 保存图像对象;
  2. pointBackImgBase 保存点选文字的背景图地址;
  3. info 最后提交后端验证的信息保存;
  4. page 只参与逻辑,不参与渲染的变量;
  5. styleInfo 界面样式修改的控制变量;
  6. props 传入组件的部分变量。
php 复制代码
	let imgRef = useRef(null)
  let [pointBackImgBase, setPointBackImgBase] = useAsyncState(null)
  let [info, setInfo] = useAsyncState({
    backToken: '',
    secretKey: '',
    poinTextList: [],
    text: ''
  })
  let page = useRef({
    captchaType: "clickWord",       //后端返回的加密秘钥 字段
    checkNum:3,                 //
    fontPos: [],                // 选中的坐标信息
    checkPosArr: [],            //用户点击的坐标
    num: 1,  
    tempPoints: [],
    showRefresh: true,
    imgLeft:'' ,
    imgTop:'',
  })
  let [styleInfo, setStyleInfo] = useAsyncState({
    barAreaColor: '#fff',
    barAreaBorderColor: "#fff",
    bindingClick: true,
    tempPoints: [],
  })
  let {
    imgSize = {
      width: '310px',
      height: '155px'
    },
    vSpace = 5
  } = props

15. 完整代码

  1. useAsyncState 是对 useState 的二次封装,防止组件卸载后设置报错;
  2. axios 是封装的发起网络请求对象;
  3. aesCaptchaEncrypt 加密方法封装;
  4. licon 界面中使用的图标存放位置;
  5. api 一些提示等公用方法封装;
  6. index.scss 使用的是【AJ-Captcha行为验证码】的uni-app的样式进行微调。
javascript 复制代码
import { View, Text, Image } from '@tarojs/components';
import { createSelectorQuery } from '@tarojs/taro';
import { useAsyncState } from '@utils/event';
import { useEffect, useRef } from 'react';
import { axios } from '@utils/axios/axios';
import api from '@utils/api';
import { aesCaptchaEncrypt } from '@utils/encryption';
import licon from '@utils/icon/licon';
import './index.scss';

const RuiVerifyPoint = (props) => {
  let imgRef = useRef(null)
  let [pointBackImgBase, setPointBackImgBase] = useAsyncState(null)
  let [info, setInfo] = useAsyncState({
    backToken: '',
    secretKey: '',
    poinTextList: [],
    text: ''
  })
  let page = useRef({
    captchaType: "clickWord",       //后端返回的加密秘钥 字段
    checkNum:3,                 //
    fontPos: [],                // 选中的坐标信息
    checkPosArr: [],            //用户点击的坐标
    num: 1,  
    tempPoints: [],
    showRefresh: true,
    imgLeft:'' ,
    imgTop:'',
  })
  let [styleInfo, setStyleInfo] = useAsyncState({
    barAreaColor: '#fff',
    barAreaBorderColor: "#fff",
    bindingClick: true,
    tempPoints: [],
  })
  let {
    imgSize = {
      width: '310px',
      height: '155px'
    },
    vSpace = 5
  } = props
  useEffect(() => {
    // 初始化
    refresh()
  },[])
  // 刷新
  function refresh() {
    setStyleInfo({
      barAreaColor: '#000',
      barAreaBorderColor: '#ddd',
      bindingClick: true,
      tempPoints: page.current.tempPoints
    })
    page.current.bindingClick = true
    page.current.tempPoints.splice(0, page.current.tempPoints.length)
    page.current.fontPos.splice(0, page.current.fontPos.length)
    page.current.checkPosArr.splice(0, page.current.checkPosArr.length)
    page.current.num = 1
    // 获取点击文字的图片信息
    getCommonImageCode();

    // this.text = '验证失败'
    page.current.showRefresh = true
  } 
  // 获取点击文字的图片信息
  function getCommonImageCode(){
    axios.getCommonImageCode({
      captchaType: page.current.captchaType,  //验证码类型 clickWord, blockPuzzle
    }).then(res => {
      // console.log(res)
      if (res.repCode == "0000") {
        setPointBackImgBase(`data:image/png;base64,${res.repData.originalImageBase64}`)
        info.backToken = res.repData.token
        info.secretKey = res.repData.disturbStr
        info.poinTextList = res.repData.wordList
        info.text = `请依次点击【${info.poinTextList.join(",")}】`
        setInfo({...info})
      }
      // 判断接口请求次数是否失效
      if(res.repCode == '6201') {
        setPointBackImgBase(null)
      }
    }).catch(console.log)
  }
  //获取坐标
  function getMousePos(obj, e) {
    let position = {
        x:Math.ceil(e.detail.x) - page.current.imgLeft,
        y:Math.ceil(e.detail.y) - page.current.imgTop,
    }
    return position
  }
  //创建坐标点
  function createPoint(pos) {
    page.current.tempPoints.push(Object.assign({}, pos))
    setStyleInfo({
      ...styleInfo,
      tempPoints: page.current.tempPoints
    })
    return ++page.current.num;
  }
  //坐标转换函数
  function pointTransfrom(pointArr,imgSize){
    var newPointArr = pointArr.map(p=>{
      let x = Math.round(310 * p.x / parseInt(imgSize.width)) 
      let y = Math.round(155 * p.y / parseInt(imgSize.height)) 
      return {x,y}
    })
    // console.log(newPointArr,"newPointArr");
    return newPointArr
  }
  // 验证选择的点
  function verifyPointsByRquest(data, captchaVerification){
    axios.getCommonImageCheck({...data}).then(res => {
      // let res = result.data
      // console.log(res)
      if (res.repCode == "0000") {
        setStyleInfo({
          ...styleInfo,
          barAreaColor: '#4cae4c',
          barAreaBorderColor: '#5cb85c',
          bindingClick: false
        })
        setInfo({
          ...info,
          text: '验证成功'
        })
        setTimeout(() => {
          refresh();
        },1500)
        props.onSuccess && props.onSuccess(res.repData)
      }else{
        setStyleInfo({
          ...styleInfo,
          barAreaColor: '#d9534f',
          barAreaBorderColor: '#d9534f',
          bindingClick: false
        })
        setInfo({
          ...info,
          text: '验证失败'
        })
        setTimeout(() => {
          refresh();
        }, 700);
        props.onFail && props.onFail(res.repData)
      }
    }).catch(console.log)
  }
  // 图片点击事件
  function canvasClick(e){
    // 如果是H5,需要对e的数据解构进行重构
    if(api.hasWeb()){
      e = {
        detail: {
          x: e.x,
          y: e.y
        }
      }
    }
    const query = createSelectorQuery();
    query.select('#image').boundingClientRect(data => {
      // console.log(data)
      page.current.imgLeft = Math.ceil(data.left)
      page.current.imgTop = Math.ceil(data.top) 
      page.current.checkPosArr.push(getMousePos(imgRef, e));
      if(page.current.num == page.current.checkNum) {
        page.current.num = createPoint(getMousePos(imgRef, e));
        //按比例转换坐标值
        page.current.checkPosArr = pointTransfrom(page.current.checkPosArr,imgSize);
        //等创建坐标执行完
        setTimeout(() => {
          //发送后端请求
          let checkPosArrJsonStr = JSON.stringify(page.current.checkPosArr)
          let word = `${info.backToken}---${checkPosArrJsonStr}`
          let pointJson = info.secretKey ? aesCaptchaEncrypt(checkPosArrJsonStr, info.secretKey) : checkPosArrJsonStr;
          var captchaVerification = info.secretKey ? aesCaptchaEncrypt(word,info.secretKey) : word;
          let data = {
            "captchaType": page.current.captchaType,
            "pointJson": pointJson,
            "token": info.backToken
          }
          verifyPointsByRquest(data, captchaVerification)
        }, 400);
      }

      if (page.current.num < page.current.checkNum) {
        page.current.num = createPoint(getMousePos(imgRef, e));
      }
    }).exec();
  }
  // 点击数字展示
  function showCurrentNumHtml(){
    return styleInfo.tempPoints.map((tempPoint,idx) => <View 
      key={`point-area-${idx}`}
      style={{
        'backgroundColor':'#1abd6c',
        'color':'#fff',
        'zIndex': 9999,
        'width':'20px',
        'height':'20px',
        'fontSize': '14px',
        'textAlign':'center',
        'lineHeight':'20px',
        'borderRadius': '50%',
        'position':'absolute',
        'top': parseInt(tempPoint.y - 10) + 'px',
        'left': parseInt(tempPoint.x - 10) + 'px'
      }}
      className='point-area'>
        {idx + 1}
      </View>)
  }
  return <View className='rui-verify-slider-components-content rui-pr'>
    <View className='verify-image-out'>
      <View 
        style={{
          'width': imgSize.width,
          'height': imgSize.height,
          'marginBottom': vSpace + 'px'
        }}
        className='verify-image-panel rui-flex-cc'>
        <Image src={licon.refreshIcon} className="verify-refresh" onClick={refresh}></Image>
        {
          pointBackImgBase ? 
            <Image 
              src={pointBackImgBase} 
              id='image'
              ref={imgRef}
              onClick={styleInfo.bindingClick ? canvasClick : undefined}
              className="rui-verify-point-image"></Image>
            : <View className='rui-fs60'>图片加载中</View>
        }
        
      </View>
      {/* 当前点击位置 */}
      { showCurrentNumHtml() }
    </View>
    <View 
      style={{
        'width': imgSize.width,
        'color': styleInfo.barAreaColor,
        'borderColor': styleInfo.barAreaBorderColor
      }}
      className='verify-bar-area'>
      <Text className='verify-msg'>{info.text}</Text>
    </View>
  </View>
}
export default RuiVerifyPoint;

16. 加密方法

php 复制代码
import CryptoJS from 'crypto-js'
/**
 * @word 要加密的内容
 * @keyWord String  服务器随机返回的关键字
 *  */
export function aesCaptchaEncrypt(word,keyWord="XwKsGlMcdPMEhR1B"){
  var key = CryptoJS.enc.Utf8.parse(keyWord);
  var srcs = CryptoJS.enc.Utf8.parse(word);
  var encrypted = CryptoJS.AES.encrypt(srcs, key, {mode:CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7});
  return encrypted.toString();
}

17. 最终效果

相关推荐
小妖66618 分钟前
react-router 怎么设置 basepath 设置网站基础路径
前端·react.js·前端框架
GISer_Jing7 小时前
React手撕组件和Hooks总结
前端·react.js·前端框架
布兰妮甜20 小时前
CSS Houdini 与 React 19 调度器:打造极致流畅的网页体验
前端·css·react.js·houdini
快起来别睡了1 天前
React Hook 核心指南:从实战到源码,彻底掌握状态与副作用
react.js
FSHOW2 天前
记一次开源_大量SVG的高性能渲染
前端·react.js
萌萌哒草头将军2 天前
🔥🔥🔥 原来在字节写代码就是这么朴实无华!🔥🔥🔥
前端·javascript·react.js
托尔呢2 天前
从0到1实现react(二):函数组件、类组件和空标签(Fragment)的初次渲染流程
前端·react.js
Ratten2 天前
【taro react】 ---- 实现 RuiPaging 滚动到底部加载更多数据
react.js
艾小码2 天前
React Hooks时代:抛弃Class,拥抱函数式组件与状态管理
前端·javascript·react.js