跳到主要内容

React之antd登录图形校验码与短信校验码

about

在前端开发中,用户登录时常见有图形校验码或手机短信校验码。 此类功能可通过后台协助来完成,也可由前端react独立完成。

本例模拟真实用户登录情况来实现

  • 图形校验证即时动态生成,录入时即时校验。
  • 短信校验码即时动态生成,录入时即时校验。

验证成功后,需要及时向后端申请Token。对于重要息,需加密后再传输。

# tree app
app
├── node_modules
├── package.json
├── src
│ ├── index.js //主入口文件
│ └── test
│ ├── countdown.tsx //手机短信倒计时按扭组件
│ ├── captchainput.tsx //图形验证码组件
│ └── test.tsx //示例
├── tsconfig.json
├── typings.d.ts
└── yarn.lock

登录源文件test.tsx

import React,{useRef,useState} from 'react';
import CaptchaInput from './captchainput.tsx' //图形验证码组件
import CountButton from './countdown.tsx' //手机短信验证码组件
//从antd组件中选用的控件
import {
Form,
Input,
Space,
Button,
Row,
Col,
Tabs,
} from 'antd';

import {
UserOutlined,
LockOutlined,
MobileOutlined,
} from '@ant-design/icons';

const App: React.FC = () => {
//创建 Form 实例,用于管理所有数据状态。通过 Form.useForm 对表单数据域进行交互。
const [form] = Form.useForm();

const childRef = useRef() //图形校验码ref
const btdRef = useRef() //手机短信校验码ref

//登录类型切换
type LoginType = 'phone' | 'account';
const [loginType, setLoginType] = useState<LoginType>('account');
const onChange = (activeKey:string) => {
setLoginType(activeKey as LoginType)
console.log(activeKey);
};


//样式
const layout = {
labelCol: { span: 6,offset:2},
wrapperCol: { span:12},
};
//样式
const tailLayout = {
wrapperCol: { offset: 8, span: 16 },
};

//按扭"submit"的单击事件
const onFinish = (values: any) => {
console.log(values);
//console.log(
// form.getFieldValue("username"),
// form.getFieldValue("password")
//);
};
//按扭"reset"的单击事件
const onReset = () => {
form.resetFields();
};


//页面渲染
return(
<>
<Form
style={{ maxWidth: 600,border: "1px dotted red"}} // form的整体样式
{...layout} // form.item的label与控件的样式
colon={true} //表示是否显示 label 后面的冒号 (只有在属性 layout 为 horizontal 时有效)
form={form}
onFinish={onFinish} // form事件,form会把值传递给事件
>
<Tabs
defaultActiveKey="loginType"
activeKey={loginType}
centered={true} //tabs标签是否居中
onChange={onChange} //Tabs变化时的事件
tabPosition="top" //tabs标签的位置,top上面,left侧边,right,bottom
type="card" //页签的基本样式,可选 line(默认方式)、card editable-card 类型
>
<Tabs.TabPane key={'account'} tab={'账号密码登录'} />
<Tabs.TabPane key={'phone'} tab={'手机号登录'} />
</Tabs>
{/*当采用用户密码方式时*/}
{loginType === 'account' && (
<>
<Form.Item
name="username"
label="username"
rules={[ //rule配置校验规则
{
required: true,
message: '此项必须输入',
},
{
type: 'string',
message: '只能输入字串,且不能为空字串(如全部是空格)',
},
]}
>
<Input
size="large"
placeholder="用户名称"
prefix={<UserOutlined />}
/>
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={[
{ required: true,message: '此项必须输入' },
{ min: 5,message: '字串长度不能小于5' },
{ max: 25,message: '字串长度不能大于5' },
]}
hasFeedback={true} //配合 validateStatus 属性使用,展示校验状态图标,建议只配合 Input 组件使用 此外,它还可以通过 Icons 属性获取反馈图标。
validateStatus="success"
>
<Input.Password placeholder="请录入密码" prefix={<LockOutlined />} />
</Form.Item>
<Form.Item label="图形校验码" rules={[{required: true}]} extra="We must make sure that your are a human.">
<Row gutter={8}>
<Col span={6}>
<Form.Item
name="captcha"
noStyle
rules={[
{ required: true, message: 'Please input the captcha you got!' },
{
validator(_, value) {
if (!value) {
return new Promise((reject) => {reject('')})
} else {
const status = childRef.current.validate(value)
return status
? Promise.resolve()
: Promise.reject(new Error('验证码不正确'))
}
}
}
]}
>
<Input />
</Form.Item>
</Col>
<Col >
<CaptchaInput cRef={childRef} />
</Col>
</Row>
</Form.Item>
</>
)}

{/*当采用手机方式时*/}
{loginType === 'phone' && (
<>
<Form.Item
name="mobile"
label="Mobile"
rules={[ //rule配置校验规则
{
required: true,
message: '此项必须输入',
},
{
pattern: /^1\d{10}$/,
message: '手机号格式错误!',
},
]}
>
<Input
size="large"
placeholder="手机号"
prefix={<MobileOutlined />}
/>
</Form.Item>
<Form.Item label="动态校验码" rules={[{required: true}]}>
<Row gutter={8}>
<Col span={11}>
<Form.Item
name="mobileCode"
noStyle
rules={[
{ required: true, message: '请输入验证码' },
{ pattern: /^\d{6}$/, message: '手机短信验证码是由6位数字组成的数字串' },
{
validator(_, value) { //value是当前Form.Item的值。
if (!value) {
return new Promise((reject) => {reject('')})
} else {
//拿当前Form.Item的值到CountButton组件中进行校验(btdRef.current.validate方法),
//若与CountButton中产生的options.code值相同,则通过校验。
const status = btdRef.current.validate(value)
return status
? Promise.resolve()
: Promise.reject(new Error('验证码不正确'))
}
}
},
]}
>
<Input placeholder={'请输入验证码'} prefix={<LockOutlined />}/>
</Form.Item>
</Col>
<Col >
<CountButton form={form} bRef={btdRef} name={"mobile"} />
</Col>
</Row>
</Form.Item>
</>
)}

{/*提交按扭*/}
<Form.Item {...tailLayout} >
<Space align="center">
<Button type="primary" htmlType="submit"> {/* 不指定事件时,默认采用form中定义的事件。此时,form会把值传递给事件 */}
Submit
</Button>
<Button htmlType="button" onClick={onReset}>
Reset
</Button>
</Space>
</Form.Item>
</Form>
</>
)
}

export default App;

图形验证码组件captchainput.tsx

import * as React from 'react'

const size = 4
const verifycode = {
width: '32%',
height: '30px',
marginLeft: '5%',
display: 'inline-block',
top: '0',
right: '0'
}

export default function CaptchaInput ({ cRef }) {
const [options, setOptions] = React.useState({
id: 'verifycode', // 容器Id
canvasId: 'verifyCanvas', // canvas的ID
width: 150, // 默认canvas宽度
height: 36, // 默认canvas高度
type: 'blend', // 图形验证码默认类型blend:数字字母混合类型、number:纯数字、letter:纯字母
code: '', // 图形验证码的值,用户录入的值必须等于此处的code
numArr: '0,1,2,3,4,5,6,7,8,9'.split(','),
letterArr: getAllLetter()
})

React.useImperativeHandle(cRef, () => ({
validate: (value: any) => {
const vcode = value?.toLowerCase()
const v_code = options.code?.toLowerCase()
if (vcode === v_code) {
return true
} else {
return false
}
}
}))

React.useEffect(() => {
_init()
refresh()
})

function _init() {
const con = document.getElementById(options.id)
const canvas: any = document.createElement('canvas')
options.width = con.offsetWidth > 0 ? con.offsetWidth : 150
options.height = con.offsetHeight > 0 ? con.offsetHeight : 47
canvas.id = options.canvasId
canvas.width = options.width
canvas.height = options.height
canvas.style.cursor = 'pointer'
canvas.innerHTML = '您的浏览器版本不支持canvas'
con.appendChild(canvas)
canvas.onclick = function() {
refresh()
}
}

function refresh() {
options.code = ''
const canvas: any = document.getElementById(options.canvasId)
let ctx = null
if (canvas.getContext) {
ctx = canvas.getContext('2d')
} else {
return
}
ctx.clearRect(0, 0, options.width, options.height)
ctx.textBaseline = 'middle'

ctx.fillStyle = randomColor(180, 240)
ctx.fillStyle = 'rgba(206, 244, 196)'// 背景色
ctx.fillRect(0, 0, options.width, options.height)

if (options.type == 'blend') { // 判断验证码类型
var txtArr = options.numArr.concat(options.letterArr)
} else if (options.type == 'number') {
var txtArr = options.numArr
} else {
var txtArr = options.letterArr
}

for (let i = 1; i <= size; i++) {
const txt = txtArr[randomNum(0, txtArr.length)]
options.code += txt
ctx.font = randomNum(options.height / 2, options.height) + 'px SimHei' // 随机生成字体大小
ctx.fillStyle = randomColor(50, 160) // 随机生成字体颜色
// ctx.fillStyle = "rgb(46, 137, 255)";//固定字体颜色
ctx.shadowOffsetX = randomNum(-3, 3)
ctx.shadowOffsetY = randomNum(-3, 3)
ctx.shadowBlur = randomNum(-3, 3)
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
const x = options.width / (size + 1) * i
const y = options.height / 2
const deg = randomNum(-30, 30)
/** 设置旋转角度和坐标原点**/
ctx.translate(x, y)
ctx.rotate(deg * Math.PI / 180)
ctx.fillText(txt, 0, 0)
/** 恢复旋转角度和坐标原点**/
ctx.rotate(-deg * Math.PI / 180)
ctx.translate(-x, -y)
}
/** 绘制干扰线**/
for (let i = 0; i < 4; i++) {
ctx.strokeStyle = randomColor(40, 180)
ctx.beginPath()
ctx.moveTo(randomNum(0, options.width), randomNum(0, options.height))
ctx.lineTo(randomNum(0, options.width), randomNum(0, options.height))
ctx.stroke()
}
// 绘制干扰点
// for (let i = 0; i < 100; i++) {
// ctx.fillStyle = randomColor(0, 255)
// ctx.beginPath()
// ctx.arc(randomNum(0, options.width), randomNum(0, options.height), 1, 0, 2 * Math.PI)
// ctx.fill()
// }
}

/** 生成字母数组**/
function getAllLetter() {
const letterStr = 'a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z'
return letterStr.split(',')
}
/** 生成一个随机数**/
function randomNum(min: number, max: number) {
return Math.floor(Math.random() * (max - min) + min)
}
/** 生成一个随机色**/
function randomColor(min: number, max: number) {
const r = randomNum(min, max)
const g = randomNum(min, max)
const b = randomNum(min, max)
return 'rgb(' + r + ',' + g + ',' + b + ')'
}

console.log("options:",options)

return (
<div id='verifycode' style={verifycode} />
)
}

短信验证码组件countdown.tsx

import React, { useState, useEffect} from 'react'
import { Button,Form } from 'antd'

//随机产生n个数字成的字串(如应用于手机验证码的产生)
function getStringRand(len:any) {
const possibleCharacters = '0123456789';
const stringLength = parseInt(len); // 你想要生成的字符串长度
let randomString = '';

for (let i = 0; i < stringLength; i++) {
const randomIndex = Math.floor(Math.random() * possibleCharacters.length);
randomString += possibleCharacters.charAt(randomIndex);
}
return randomString
}

//参数
// bRef事件引用参数,供外部调用本组件定义的事件
// form表单对像,外部表单对像引用本组件中
// name外部表单某一控件的名称,通常是指手机号码录入控件
export default function CountButton({bRef,form,name},){
//--------短信动态码状态----------------------------
const [options, setOptions] = React.useState({
code: '', // 验证码的值,用户录入的值必须等于此处的code
})

//--------组件定义方法(供外部调用)----------------------------
React.useImperativeHandle(bRef, () => ({
//数据校验
validate: (value: any) => {
const vcode = value?.toLowerCase()
const v_code = options.code?.toLowerCase()
if (vcode === v_code) {
return true
} else {
return false
}
}
}),
)

//--------按扭状态:初始状态配置----------------------------
//按扭状态识别,若手机号不能正确录入,倒计时button不能启用
const [submittable, setSubmittable] = React.useState<boolean>(false);
//const values = Form.useWatch('mobile', form); //只监控某一个Form.Item
//const values = Form.useWatch([],form); //Watch all values
const values = Form.useWatch(name, form); //只监控某一个Form.Item

useEffect(() => {
form
//.validateFields({ validateOnly: true }) //form所有项
.validateFields([name]) //表单的某一项
.then(() => setSubmittable(true))
.catch(() => setSubmittable(false));
}, [form, values]);


//--------按扭状态:单击过程中的状态配置----------------------------
//倒计时button的单击状态识别
const [isClick, setIsClick] = useState(false);
const [count, setCount] = useState(0);
const [btname, setBtname] = useState('发送验证码');
//按扭倒计时进行时
useEffect(() => {
const intervalId = setInterval(() => {
//定时任务区
if (isClick) {
if (count < 60) { //当少于60时
setCount(count + 1)
setBtname("(" + count + "秒后)发送验证码")
setIsClick(true)
} else { //当等于60时,发送按扭复位
setCount(0)
setBtname("发送验证码")
setIsClick(false)
const code = getStringRand(6) //产生6位数字的随机码作为验证码
setOptions({code: code}) //将之前的验证码覆盖掉。
}

}}, 1000); // 每秒更新一次
return () => clearInterval(intervalId);
},
[btname]
);

//--------按扭单击事件----------------------------
function onSendCode(){
if (!isClick) {
setBtname("(60 秒后)发送验证码")
setIsClick(true)
const code = getStringRand(6) //产生6位数字的随机码作为验证码
setOptions({code: code})
//sendCode(code,values) //在真实的业务中,需通过第三方短信通道将此验证码发到用户手机上。
//短信码测试
console.log(code,values) //code是动态码。values是父组件传递而来的手机号码
} else {
setBtname("发送验证码")
setIsClick(false)
}
}

//渲染倒计时按扭
return(
<>
<Button
style={{borderRadius:"0",borderStyle:'none'}}
onClick={onSendCode}
disabled={(isClick||(!submittable))}
>
{btname}
</Button>
</>
)
}

效果

Alt text