1. 效果预览

2. 使用场景
账号登录,比如验证码发送,防止无限调用发送接口,所以在发送之前,需要行为验证!
3. 插件选择
为什么要选用【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. 文字背景图和点击文字获取
- 由于后端接口是将【AJ-Captcha行为验证码】的java代码拉到项目中,接口名进行了修改,所以这个更具自己的实际开发来;
- 传入参数captchaType,验证码类型:1)滑动拼图 blockPuzzle 2)文字点选 clickWord;
- 使用 setPointBackImgBase 保存返回文字背景图片;
- 使用 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. 代码分析
- 由于后端接口是将【AJ-Captcha行为验证码】的java代码拉到项目中,接口名进行了修改,所以这个更具自己的实际开发来;
- 验证参数进行了加密后传给后端校验;
- 校验成功,修改点选文字、边框和颜色,同时回调 props.onSuccess,将返回参数返回,用于获取短信验证码接口校验;
- 校验失败,修改点选文字、边框和颜色提示失败,刷新新的行为验证码,同时如果对校验失败有监听,可以使用 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. 刷新文字点选界面
- bindingClick 逻辑上允许进行文字点选;
- tempPoints 清空点选的文字坐标列表;
- checkPosArr 清空提交后端的校验坐标列表;
- num 记录选择第几个文字了,初始化第一个;
- setStyleInfo 设置界面初始化样式;
- getCommonImageCode 获取文字点选的图片和文字信息;
- 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. 界面布局
- 整个行为验证一个盒子;
- 上边行为验证码的盒子,里边放刷新按钮,行为验证码的图片,以及点击坐标标识【showCurrentNumHtml】;
- 最下边放需要点击的提示文本,此处在验证成功和验证失败后会进行样式修改。
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. 代码分析
- 统一微信小程序端和H5端点击事件获取坐标点的数据结构;
- 通过 boundingClientRect 获取图片的信息;
- 通过 getMousePos 计算当前的点击坐标点;
- page.current.num < page.current.checkNum 说明还没有满足后端验证选择的坐标;
- 更新 num , page.current.num = createPoint(getMousePos(imgRef, e));
- page.current.num == page.current.checkNum 说明满足验证条件;
- 更新 num , page.current.num = createPoint(getMousePos(imgRef, e));
- 按比例转换坐标值,转换为后端校验的坐标;
- 根据是否存在加密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. 变量保存
- imgRef 保存图像对象;
- pointBackImgBase 保存点选文字的背景图地址;
- info 最后提交后端验证的信息保存;
- page 只参与逻辑,不参与渲染的变量;
- styleInfo 界面样式修改的控制变量;
- 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. 完整代码
- useAsyncState 是对 useState 的二次封装,防止组件卸载后设置报错;
- axios 是封装的发起网络请求对象;
- aesCaptchaEncrypt 加密方法封装;
- licon 界面中使用的图标存放位置;
- api 一些提示等公用方法封装;
- 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. 最终效果
