1. 前言
之前使用【AJ-Captcha 行为验证码】实现过一个【Taro React组件开发 —— RuiVerifySlider 行为验证码之滑动拼图】,但是现在 AI 很火,所以就出现需要检测滑动验证码的是不是人机。其实检测条件也很简单,就是人不会匀速画直线。之前的滑动验证码基本都是检验一下终点坐标,现在需要检测人机,就需要检测滑动的时间和 Y 轴的坐标了。滑动验证码本来开源的项目也比较多,所以后端最后选择了【tianaiCAPTCHA - 天爱验证码(TAC)】。
2. 天爱验证码 tianai-captcha
- 天爱验证码 tianai-captcha 文档;
- 天爱验证码 tianai-captcha 体验;
- 天爱验证码 tianai-captcha 仓库;
- 天爱验证码 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}]}}
- id 是滑动图片生成接口返回的,因此前端直接根据接口返回传递就好;
- bgImageWidth 和 bgImageHeight 是背景图片的大小,这个用于校验滑动的最终坐标是否校验通过使用;
- startTime 和 stopTime 滑动的开始和结束时间;
- trackList 记录滑动的坐标点和时间,同时记录在此坐标点的类型,比如按下,移动,放开。
5. 添加提交数据
const checkInfo = useRef({
id: "",
data: {
bgImageWidth: parseInt(imgSize.width),
bgImageHeight: parseInt(imgSize.height),
startTime: "",
stopTime: "",
trackList: [],
},
});
6. 对获取滑动背景图和滑动图片函数改造
- 由于我这里是按照当前项目的封装请求来改造的,因此直接将请求改成你自己的请求就可以。
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(),
});
}
}
- 记录开始时间 【checkInfo.current.data.startTime = startTime;】;
- 获取坐标点并记录;
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();
}
- 计算 Y 轴坐标的偏移量;
- 根据开始时间计算当前点所用的时间;
- 记录滑动到此点的数据记录。
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;
}
}
- 结束时间记录;
- 结束点时间计算;
- 结束点类型修改。
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 改造说明
- 不管校验成功还是失败,都需要将校验数据清空。
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. 总结
- 只是对原有组件进行升级改造,并没有重新开发,所以其实我并不想写这篇博客,但是由于天爱验证码不提供 Taro React 版本,因此想着能够解决有需要开发者的时间,就进行了改造记录。
- 感觉最后搞得东西都差不多,没啥可写的,所以不怎么想更新了!