業務需求,需要在系統登陸的時候,使用“滑動圖片驗證碼”,來驗證操作的不是機器人。
效果圖
使用方式
在一般的頁面組件引用即可。onReload
這個函數一般是用來請求後臺圖片的。
class App extends Component {
state = {
url: ""
}
componentDidMount() {
this.setState({ url: getImage() })
}
onReload = () => {
this.setState({ url: getImage() })
}
render() {
return (
<div>
<ImageCode
imageUrl={this.state.url}
onReload={this.onReload}
onMatch={() => {
console.log("code is match")
}}
/>
</div>
)
}
}
上代碼
// index.js
/**
* @name ImageCode
* @desc 滑動拼圖驗證
* @author darcrand
* @version 2019-02-26
*
* @param {String} imageUrl 圖片的路徑
* @param {Number} imageWidth 展示圖片的寬帶
* @param {Number} imageHeight 展示圖片的高帶
* @param {Number} fragmentSize 滑動圖片的尺寸
* @param {Function} onReload 當點擊'重新驗證'時執行的函數
* @param {Function} onMath 匹配成功時執行的函數
* @param {Function} onError 匹配失敗時執行的函數
*/
import React from "react"
import "./styles.css"
const icoSuccess = require("./icons/success.png")
const icoError = require("./icons/error.png")
const icoReload = require("./icons/reload.png")
const icoSlider = require("./icons/slider.png")
const STATUS_LOADING = 0 // 還沒有圖片
const STATUS_READY = 1 // 圖片渲染完成,可以開始滑動
const STATUS_MATCH = 2 // 圖片位置匹配成功
const STATUS_ERROR = 3 // 圖片位置匹配失敗
const arrTips = [{ ico: icoSuccess, text: "匹配成功" }, { ico: icoError, text: "匹配失敗" }]
// 生成裁剪路徑
function createClipPath(ctx, size = 100, styleIndex = 0) {
const styles = [
[0, 0, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0],
[0, 0, 1, 1],
[0, 1, 0, 0],
[0, 1, 0, 1],
[0, 1, 1, 0],
[0, 1, 1, 1],
[1, 0, 0, 0],
[1, 0, 0, 1],
[1, 0, 1, 0],
[1, 0, 1, 1],
[1, 1, 0, 0],
[1, 1, 0, 1],
[1, 1, 1, 0],
[1, 1, 1, 1]
]
const style = styles[styleIndex]
const r = 0.1 * size
ctx.save()
ctx.beginPath()
// left
ctx.moveTo(r, r)
ctx.lineTo(r, 0.5 * size - r)
ctx.arc(r, 0.5 * size, r, 1.5 * Math.PI, 0.5 * Math.PI, style[0])
ctx.lineTo(r, size - r)
// bottom
ctx.lineTo(0.5 * size - r, size - r)
ctx.arc(0.5 * size, size - r, r, Math.PI, 0, style[1])
ctx.lineTo(size - r, size - r)
// right
ctx.lineTo(size - r, 0.5 * size + r)
ctx.arc(size - r, 0.5 * size, r, 0.5 * Math.PI, 1.5 * Math.PI, style[2])
ctx.lineTo(size - r, r)
// top
ctx.lineTo(0.5 * size + r, r)
ctx.arc(0.5 * size, r, r, 0, Math.PI, style[3])
ctx.lineTo(r, r)
ctx.clip()
ctx.closePath()
}
class ImageCode extends React.Component {
static defaultProps = {
imageUrl: "",
imageWidth: 500,
imageHeight: 300,
fragmentSize: 80,
onReload: () => {},
onMatch: () => {},
onError: () => {}
}
state = {
isMovable: false,
offsetX: 0, //圖片截取的x
offsetY: 0, //圖片截取的y
startX: 0, // 開始滑動的 x
oldX: 0,
currX: 0, // 滑塊當前 x,
status: STATUS_LOADING,
showTips: false,
tipsIndex: 0
}
componentDidUpdate(prevProps) {
// 當父組件傳入新的圖片後,開始渲染
if (!!this.props.imageUrl && prevProps.imageUrl !== this.props.imageUrl) {
this.renderImage()
}
}
renderImage = () => {
// 初始化狀態
this.setState({ status: STATUS_LOADING })
// 創建一個圖片對象,主要用於canvas.context.drawImage()
const objImage = new Image()
objImage.addEventListener("load", () => {
const { imageWidth, imageHeight, fragmentSize } = this.props
// 先獲取兩個ctx
const ctxShadow = this.refs.shadowCanvas.getContext("2d")
const ctxFragment = this.refs.fragmentCanvas.getContext("2d")
// 讓兩個ctx擁有同樣的裁剪路徑(可滑動小塊的輪廓)
const styleIndex = Math.floor(Math.random() * 16)
createClipPath(ctxShadow, fragmentSize, styleIndex)
createClipPath(ctxFragment, fragmentSize, styleIndex)
// 隨機生成裁剪圖片的開始座標
const clipX = Math.floor(fragmentSize + (imageWidth - 2 * fragmentSize) * Math.random())
const clipY = Math.floor((imageHeight - fragmentSize) * Math.random())
// 讓小塊繪製出被裁剪的部分
ctxFragment.drawImage(objImage, clipX, clipY, fragmentSize, fragmentSize, 0, 0, fragmentSize, fragmentSize)
// 讓陰影canvas帶上陰影效果
ctxShadow.fillStyle = "rgba(0, 0, 0, 0.5)"
ctxShadow.fill()
// 恢復畫布狀態
ctxShadow.restore()
ctxFragment.restore()
// 設置裁剪小塊的位置
this.setState({ offsetX: clipX, offsetY: clipY })
// 修改狀態
this.setState({ status: STATUS_READY })
})
objImage.src = this.props.imageUrl
}
onMoveStart = e => {
if (this.state.status !== STATUS_READY) {
return
}
// 記錄滑動開始時的絕對座標x
this.setState({ isMovable: true, startX: e.clientX })
}
onMoving = e => {
if (this.state.status !== STATUS_READY || !this.state.isMovable) {
return
}
const distance = e.clientX - this.state.startX
let currX = this.state.oldX + distance
const minX = 0
const maxX = this.props.imageWidth - this.props.fragmentSize
currX = currX < minX ? 0 : currX > maxX ? maxX : currX
this.setState({ currX })
}
onMoveEnd = () => {
if (this.state.status !== STATUS_READY || !this.state.isMovable) {
return
}
// 將舊的固定座標x更新
this.setState(pre => ({ isMovable: false, oldX: pre.currX }))
const isMatch = Math.abs(this.state.currX - this.state.offsetX) < 5
if (isMatch) {
this.setState(pre => ({ status: STATUS_MATCH, currX: pre.offsetX }), this.onShowTips)
this.props.onMatch()
} else {
this.setState({ status: STATUS_ERROR }, () => {
this.onReset()
this.onShowTips()
})
this.props.onError()
}
}
onReset = () => {
const timer = setTimeout(() => {
this.setState({ oldX: 0, currX: 0, status: STATUS_READY })
clearTimeout(timer)
}, 1000)
}
onReload = () => {
if (this.state.status !== STATUS_READY && this.state.status !== STATUS_MATCH) {
return
}
const ctxShadow = this.refs.shadowCanvas.getContext("2d")
const ctxFragment = this.refs.fragmentCanvas.getContext("2d")
// 清空畫布
ctxShadow.restore()
ctxFragment.restore()
ctxShadow.clearRect(0, 0, this.props.fragmentSize, this.props.fragmentSize)
ctxFragment.clearRect(0, 0, this.props.fragmentSize, this.props.fragmentSize)
this.setState(
{
isMovable: false,
offsetX: 0, //圖片截取的x
offsetY: 0, //圖片截取的y
startX: 0, // 開始滑動的 x
oldX: 0,
currX: 0, // 滑塊當前 x,
status: STATUS_LOADING
},
this.props.onReload
)
}
onShowTips = () => {
if (this.state.showTips) {
return
}
const tipsIndex = this.state.status === STATUS_MATCH ? 0 : 1
this.setState({ showTips: true, tipsIndex })
const timer = setTimeout(() => {
this.setState({ showTips: false })
clearTimeout(timer)
}, 2000)
}
render() {
const { imageUrl, imageWidth, imageHeight, fragmentSize } = this.props
const { offsetX, offsetY, currX, showTips, tipsIndex } = this.state
const tips = arrTips[tipsIndex]
return (
<div className="image-code" style={{ width: imageWidth }}>
<div className="image-container" style={{ height: imageHeight, backgroundImage: `url("${imageUrl}")` }}>
<canvas
ref="shadowCanvas"
className="canvas"
width={fragmentSize}
height={fragmentSize}
style={{ left: offsetX + "px", top: offsetY + "px" }}
/>
<canvas
ref="fragmentCanvas"
className="canvas"
width={fragmentSize}
height={fragmentSize}
style={{ top: offsetY + "px", left: currX + "px" }}
/>
<div className={showTips ? "tips-container--active" : "tips-container"}>
<i className="tips-ico" style={{ backgroundImage: `url("${tips.ico}")` }} />
<span className="tips-text">{tips.text}</span>
</div>
</div>
<div className="reload-container">
<div className="reload-wrapper" onClick={this.onReload}>
<i className="reload-ico" style={{ backgroundImage: `url("${icoReload}")` }} />
<span className="reload-tips">刷新驗證</span>
</div>
</div>
<div className="slider-wrpper" onMouseMove={this.onMoving} onMouseLeave={this.onMoveEnd}>
<div className="slider-bar">按住滑塊,拖動完成拼圖</div>
<div
className="slider-button"
onMouseDown={this.onMoveStart}
onMouseUp={this.onMoveEnd}
style={{ left: currX + "px", backgroundImage: `url("${icoSlider}")` }}
/>
</div>
</div>
)
}
}
export default ImageCode
// styles.css
.image-code {
padding: 10px;
user-select: none;
}
.image-container {
position: relative;
background-color: #ddd;
}
.canvas {
position: absolute;
top: 0;
left: 0;
}
.reload-container {
margin: 20px 0;
}
.reload-wrapper {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.reload-ico {
width: 20px;
height: 20px;
margin-right: 10px;
background: center/cover no-repeat;
}
.reload-tips {
font-size: 14px;
color: #666;
}
.slider-wrpper {
position: relative;
margin: 10px 0;
}
.slider-bar {
padding: 10px;
font-size: 14px;
text-align: center;
color: #999;
background-color: #ddd;
}
.slider-button {
position: absolute;
top: 50%;
left: 0;
width: 50px;
height: 50px;
border-radius: 25px;
transform: translateY(-50%);
cursor: pointer;
background: #fff center/80% 80% no-repeat;
box-shadow: 0 2px 10px 0 #333;
}
/* 提示信息 */
.tips-container,
.tips-container--active {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
padding: 10px;
transform: translate(-50%, -50%);
transition: all 0.25s;
background: #fff;
border-radius: 5px;
visibility: hidden;
opacity: 0;
}
.tips-container--active {
visibility: visible;
opacity: 1;
}
.tips-ico {
width: 20px;
height: 20px;
margin-right: 10px;
background: center/cover no-repeat;
}
.tips-text {
color: #666;
}