Taro React 之行为验证码之滑动拼图使用【天爱验证码 tianai-captcha 】实现

96 阅读7分钟

1. 前言

之前使用【AJ-Captcha 行为验证码】实现过一个【Taro React组件开发 —— RuiVerifySlider 行为验证码之滑动拼图】,但是现在 AI 很火,所以就出现需要检测滑动验证码的是不是人机。其实检测条件也很简单,就是人不会匀速画直线。之前的滑动验证码基本都是检验一下终点坐标,现在需要检测人机,就需要检测滑动的时间和 Y 轴的坐标了。滑动验证码本来开源的项目也比较多,所以后端最后选择了【tianaiCAPTCHA - 天爱验证码(TAC)】。

2. 天爱验证码 tianai-captcha

  1. 天爱验证码 tianai-captcha 文档
  2. 天爱验证码 tianai-captcha 体验
  3. 天爱验证码 tianai-captcha 仓库
  4. 天爱验证码 tianai-captcha 私人定制

3. 需求分析

输入图片说明 由于项目是使用 Taro React 开发的,但是可以看到【天爱验证码 tianai-captcha】并不支持,而且私人定制应该是需要小钱钱的,我之前又使用【AJ-Captcha 行为验证码】实现过一个【Taro React组件开发 —— RuiVerifySlider 行为验证码之滑动拼图】。因此我决定直接在【Taro React组件开发 —— RuiVerifySlider 行为验证码之滑动拼图】的基础上进行改造,适配天爱验证码 tianai-captcha就好。

4. 提交数据分析

{"id":"SLIDER_91c64ab8c2eb433abad8891c9b99b88d","data":{"bgImageWidth":300,"bgImageHeight":180,"startTime":"2025-06-17T06:07:16.852Z","stopTime":"2025-06-17T06:07:18.794Z","trackList":[{"x":0,"y":0,"type":"down","t":806},{"x":11,"y":0,"type":"move","t":1022},{"x":25,"y":-1,"type":"move","t":1027},{"x":39,"y":-1,"type":"move","t":1037},{"x":72,"y":-1,"type":"move","t":1049},{"x":86,"y":-1,"type":"move","t":1058},{"x":100,"y":-1,"type":"move","t":1064},{"x":111,"y":-1,"type":"move","t":1075},{"x":120,"y":-1,"type":"move","t":1081},{"x":128,"y":-1,"type":"move","t":1087},{"x":138,"y":-1,"type":"move","t":1093},{"x":145,"y":-2,"type":"move","t":1100},{"x":153,"y":-2,"type":"move","t":1110},{"x":159,"y":-3,"type":"move","t":1117},{"x":165,"y":-4,"type":"move","t":1131},{"x":168,"y":-4,"type":"move","t":1138},{"x":170,"y":-4,"type":"move","t":1148},{"x":172,"y":-5,"type":"move","t":1153},{"x":174,"y":-6,"type":"move","t":1166},{"x":176,"y":-6,"type":"move","t":1177},{"x":178,"y":-6,"type":"move","t":1183},{"x":180,"y":-6,"type":"move","t":1191},{"x":181,"y":-6,"type":"move","t":1200},{"x":182,"y":-6,"type":"move","t":1208},{"x":184,"y":-6,"type":"move","t":1220},{"x":185,"y":-6,"type":"move","t":1228},{"x":186,"y":-6,"type":"move","t":1249},{"x":187,"y":-6,"type":"move","t":1275},{"x":188,"y":-6,"type":"move","t":1280},{"x":191,"y":-6,"type":"move","t":1309},{"x":192,"y":-6,"type":"move","t":1333},{"x":193,"y":-6,"type":"move","t":1339},{"x":194,"y":-6,"type":"move","t":1375},{"x":195,"y":-6,"type":"move","t":1390},{"x":196,"y":-6,"type":"move","t":1399},{"x":197,"y":-6,"type":"move","t":1417},{"x":200,"y":-6,"type":"move","t":1433},{"x":201,"y":-6,"type":"move","t":1442},{"x":202,"y":-6,"type":"move","t":1451},{"x":203,"y":-6,"type":"move","t":1455},{"x":204,"y":-6,"type":"move","t":1464},{"x":205,"y":-6,"type":"move","t":1471},{"x":206,"y":-6,"type":"move","t":1477},{"x":207,"y":-6,"type":"move","t":1493},{"x":208,"y":-6,"type":"move","t":1501},{"x":210,"y":-6,"type":"move","t":1516},{"x":211,"y":-6,"type":"move","t":1522},{"x":212,"y":-6,"type":"move","t":1530},{"x":213,"y":-6,"type":"move","t":1538},{"x":214,"y":-6,"type":"move","t":1560},{"x":215,"y":-6,"type":"move","t":1576},{"x":216,"y":-6,"type":"move","t":1599},{"x":217,"y":-6,"type":"move","t":1641},{"x":218,"y":-6,"type":"move","t":1666},{"x":220,"y":-6,"type":"move","t":1692},{"x":220,"y":-6,"type":"up","t":1942}]}}
  1. id 是滑动图片生成接口返回的,因此前端直接根据接口返回传递就好;
  2. bgImageWidth 和 bgImageHeight 是背景图片的大小,这个用于校验滑动的最终坐标是否校验通过使用;
  3. startTime 和 stopTime 滑动的开始和结束时间;
  4. trackList 记录滑动的坐标点和时间,同时记录在此坐标点的类型,比如按下,移动,放开。

5. 添加提交数据

const checkInfo = useRef({
    id: "",
    data: {
      bgImageWidth: parseInt(imgSize.width),
      bgImageHeight: parseInt(imgSize.height),
      startTime: "",
      stopTime: "",
      trackList: [],
    },
});

6. 对获取滑动背景图和滑动图片函数改造

  1. 由于我这里是按照当前项目的封装请求来改造的,因此直接将请求改成你自己的请求就可以。
function getCommonImageCode() {
    axios
      .getCaptchaSolide()
      .then((res) => {
        setBackImgBase(res?.captcha?.backgroundImage);
        setBlockBackImgBase(res?.captcha?.templateImage);
        checkInfo.current.id = res?.id;
        checkInfo.current.data.trackList = [];
      })
      .catch(console.log);
}

7. 构造校验数据

7.1 开始监听改造

function start(e) {
    page.current.startMoveTime = new Date().getTime(); //开始滑动的时间
    const startTime = new Date();
    checkInfo.current.data.startTime = startTime;
    if (page.current.isEnd == false) {
      info.text = "";
      info.moveBlockBackgroundColor = "#337ab7";
      info.leftBarBorderColor = "#337AB7";
      info.iconColor = "#fff";
      e.stopPropagation();
      page.current.status = true;
      setInfo({ ...info });
      let { x, y } = getMouseXY(e);
      checkInfo.current.startY = y;
      const startY = checkInfo.current.startY;
      y = startY - y;
      checkInfo.current.data.trackList.push({
        x,
        y,
        type: "down",
        t: +new Date() - startTime.getTime(),
      });
    }
}
  1. 记录开始时间 【checkInfo.current.data.startTime = startTime;】;
  2. 获取坐标点并记录;
      let { x, y } = getMouseXY(e);
      checkInfo.current.startY = y;
      const startY = checkInfo.current.startY;
      y = startY - y;
      checkInfo.current.data.trackList.push({
        x,
        y,
        type: "down",
        t: +new Date() - startTime.getTime(),
      });

3. 记录开始的 Y 轴坐标点,用于计算后边点 Y 轴是否发生的偏移【checkInfo.current.startY = y;】; 4. 记录开始的坐标点和时间。

7.2 移动函数改造

function move(e) {
    const query = createSelectorQuery();
    page.current.barArea = query.select(".verify-bar-area");
    let bar_area_left, barArea_offsetWidth;
    page.current.barArea
      .boundingClientRect((data) => {
        bar_area_left = Math.ceil(data.left);
        barArea_offsetWidth = Math.ceil(data.width);

        if (page.current.status && page.current.isEnd == false) {
          let { x, y } = getMouseXY(e);
          const startTime = checkInfo.current.data.startTime;
          const startY = checkInfo.current.startY;
          y = startY - y;
          checkInfo.current.data.trackList.push({
            x,
            y,
            type: "move",
            t: +new Date() - startTime.getTime(),
          });
          var move_block_left = x - bar_area_left; //小方块相对于父元素的left值
          if (
            move_block_left >=
            barArea_offsetWidth - parseInt(parseInt(blockSize.width) / 2) - 2
          ) {
            move_block_left =
              barArea_offsetWidth - parseInt(parseInt(blockSize.width) / 2) - 2;
          }
          if (move_block_left <= 0) {
            move_block_left = parseInt(parseInt(blockSize.width) / 2);
          }

          //拖动后小方块的left值
          info.moveBlockLeft =
            move_block_left - parseInt(parseInt(blockSize.width) / 2) + "px";
          info.leftBarWidth =
            move_block_left -
            parseInt(parseInt(blockSize.width) / 2) +
            1 +
            "px";
          setInfo({ ...info });
        }
      })
      .exec();
}
  1. 计算 Y 轴坐标的偏移量;
  2. 根据开始时间计算当前点所用的时间;
  3. 记录滑动到此点的数据记录。

7.3 滑动结束函数改造

function end() {
    page.current.endMovetime = new Date().getTime();
    const stopTime = new Date();
    checkInfo.current.data.stopTime = stopTime;
    // 判断是否重合
    if (page.current.status && page.current.isEnd == false) {
      const trackList = checkInfo.current.data.trackList;
      const startTime = checkInfo.current.data.startTime;
      trackList.at(-1).type = "up";
      trackList.at(-1).t = +new Date() - startTime.getTime();
      var moveLeftDistance = parseInt(
        (info.moveBlockLeft || "").replace("px", "")
      );
      moveLeftDistance = (moveLeftDistance * 310) / parseInt(imgSize.width);

      verifyPointsByRquest(checkInfo.current);
      page.current.status = false;
    }
}
  1. 结束时间记录;
  2. 结束点时间计算;
  3. 结束点类型修改。

7.4 说明

由于是对【Taro React组件开发 —— RuiVerifySlider 行为验证码之滑动拼图】进行改造,因此我只是对提交数据和请求进行了修改,原来涉及状态和 UI 界面状态展示的变量,我没有改变,因此代码看着比较不是那么好看,请理解一下,理解这个逻辑,你自己写一个都可以,写的会更加优雅。

8. 坐标点获取函数

function getMouseXY(e) {
    let x = 0;
    let y = 0;
    if (!e.touches) {
      //兼容移动端
      x = Math.ceil(e.clientX);
      y = Math.ceil(e.clientY);
    } else {
      //兼容PC端
      x = Math.ceil(e.touches[0].pageX);
      y = Math.ceil(e.touches[0].pageY);
    }
    return { x, y };
}

9. 提交函数改造

function verifyPointsByRquest(data) {
    axios
      .checkCaptchaSolideOniceVerify({ ...data, isToast: true })
      .then((res) => {
        const id = checkInfo.current.id;
        checkInfo.current = {
          id: "",
          data: {
            bgImageWidth: parseInt(imgSize.width),
            bgImageHeight: parseInt(imgSize.height),
            startTime: "",
            stopTime: "",
            trackList: [],
          },
        };
        info.moveBlockBackgroundColor = "#5cb85c";
        info.leftBarBorderColor = "#5cb85c";
        info.iconColor = "#fff";
        info.iconClass = "icon-check";
        page.current.showRefresh = true;
        page.current.isEnd = true;
        let timer = setTimeout(() => {
          clearTimeout(timer);
          refresh();
        }, 1500);
        info.passFalg = true;
        info.tipWords = `${(
          (page.current.endMovetime - page.current.startMoveTime) /
          1000
        ).toFixed(2)}s验证成功`;
        setInfo({ ...info });
        let timerSuccess = setTimeout(() => {
          info.tipWords = "";
          setInfo({ ...info });
          const result = {
            ...res,
            id,
          };
          props.onSuccess && props.onSuccess(result);
          clearTimeout(timerSuccess);
        }, 1000);
      })
      .catch((err) => {
        checkInfo.current = {
          id: "",
          data: {
            bgImageWidth: parseInt(imgSize.width),
            bgImageHeight: parseInt(imgSize.height),
            startTime: "",
            stopTime: "",
            trackList: [],
          },
        };
        info.moveBlockBackgroundColor = "#d9534f";
        info.leftBarBorderColor = "#d9534f";
        info.iconColor = "#fff";
        info.iconClass = "icon-close";
        info.passFalg = false;
        let timer = setTimeout(() => {
          clearTimeout(timer);
          refresh();
        }, 1000);
        props.onFail && props.onFail(err);
        info.tipWords = "验证失败";
        setInfo({ ...info });
        let timerFail = setTimeout(() => {
          info.tipWords = "";
          clearTimeout(timerFail);
          setInfo({ ...info });
        }, 1000);
      });
}

9.1 改造说明

  1. 不管校验成功还是失败,都需要将校验数据清空。
checkInfo.current = {
          id: "",
          data: {
            bgImageWidth: parseInt(imgSize.width),
            bgImageHeight: parseInt(imgSize.height),
            startTime: "",
            stopTime: "",
            trackList: [],
          },
};

9.2 成功数据构造

const result = {
            ...res,
            id,
          };
props.onSuccess && props.onSuccess(result);

由于我们后端发送短信的接口需要校验成功的token和生成id,所以我这里对成功返回数据进行重新构造,根据自己后端接口需求修改。

10. 组件使用

import { View, Image } from "@tarojs/components";
import { getSystemInfoSync } from "@tarojs/taro";
import { useState, useImperativeHandle, forwardRef, useMemo } from "react";
import RuiVerifySlider from "./RuiVerifySlider";
import icon from "@utils/icon/icon";
import "./verify.scss";

const RuiVerifyMask = (props, ref) => {
  const [isShow, setIsShow] = useState(false);
  const screenPercent = getSystemInfoSync().screenWidth / 750;
  let imgSize = useMemo(
    () => ({
      width: `${620 * screenPercent}px`,
      height: `${372 * screenPercent}px`,
      ...props?.imgSize,
    }),
    [props?.imgSize]
  );
  const close = () => {
    setIsShow(false);
  };
  const open = () => {
    setIsShow(true);
  };
  useImperativeHandle(ref, () => ({
    close,
    open,
  }));
  const getSuccess = (res) => {
    close();
    props?.onSuccess?.(res);
  };
  const verifyHtml = useMemo(() => {
    if (isShow) {
      return (
        <View className="rui-mask-layer">
          <View className="rui-mask-content rui-center">
            <View className="rui-captch-container">
              <View className="rui-flex-ac rui-mb20 rui-verify-title">
                <View className="rui-fg rui-fs30">请完成安全验证</View>
                <Image
                  src={icon.close1Icon}
                  onClick={close}
                  className="rui-close-icon"
                ></Image>
              </View>
              <RuiVerifySlider
                {...props}
                imgSize={imgSize}
                onSuccess={getSuccess}
              />
            </View>
          </View>
        </View>
      );
    }
    return <></>;
  }, [isShow]);
  return <>{verifyHtml}</>;
};
export default forwardRef(RuiVerifyMask);

10.1 计算组件的宽高

const screenPercent = getSystemInfoSync().screenWidth / 750;
  let imgSize = useMemo(
    () => ({
      width: `${620 * screenPercent}px`,
      height: `${372 * screenPercent}px`,
      ...props?.imgSize,
    }),
    [props?.imgSize]
  );

11. 最终效果

输入图片说明

12. 总结

  1. 只是对原有组件进行升级改造,并没有重新开发,所以其实我并不想写这篇博客,但是由于天爱验证码不提供 Taro React 版本,因此想着能够解决有需要开发者的时间,就进行了改造记录。
  2. 感觉最后搞得东西都差不多,没啥可写的,所以不怎么想更新了!