├── demo └── src │ ├── favicon.ico │ ├── t01b6eac4d21e9d24d4.jpg │ ├── index.html │ ├── index.scss │ ├── index.jsx │ ├── components │ ├── ActionBtn.tsx │ └── Wrapper.tsx │ ├── page │ ├── index.tsx │ ├── OutSide.tsx │ └── Statusoutput.tsx │ └── server │ ├── canvas.ts │ └── index.ts ├── types └── decs.d.ts ├── babel.config.json ├── .babelrc ├── .editorconfig ├── src ├── components │ ├── useCaptchaInstance.ts │ ├── CaptchaMask.tsx │ ├── CaptchaClose.tsx │ ├── lang.ts │ ├── CaptchaWrap.tsx │ ├── CaptchaTooltips.tsx │ ├── RotateCaptcha.tsx │ ├── CaptchaControl.tsx │ ├── CaptchaImage.tsx │ └── Captcha.tsx ├── assets │ ├── btn-icon.svg │ ├── close.svg │ ├── success.svg │ ├── fail.svg │ ├── tips │ │ ├── shuaxin.svg │ │ ├── zhengque.svg │ │ ├── gantanhao.svg │ │ └── shibai.svg │ ├── loading.svg │ └── index.scss └── index.tsx ├── .gitignore ├── docs ├── changelog.md └── test.md ├── .prettierrc ├── tsconfig.json ├── config ├── webpack.base.js ├── webpack.prod.config.js └── webpack.dev.config.js ├── LICENSE ├── package.json └── README.md /demo/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgfeel/react-rotate-captcha/HEAD/demo/src/favicon.ico -------------------------------------------------------------------------------- /demo/src/t01b6eac4d21e9d24d4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgfeel/react-rotate-captcha/HEAD/demo/src/t01b6eac4d21e9d24d4.jpg -------------------------------------------------------------------------------- /types/decs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: React.FunctionComponent>; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["@babel/plugin-proposal-optional-chaining-assign", { 4 | "version": "2023-07" 5 | }] 6 | ] 7 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | ["@babel/preset-react", {"runtime": "automatic"}], 5 | "@babel/preset-typescript" 6 | ] 7 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /src/components/useCaptchaInstance.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { CaptchaContext } from "./Captcha"; 3 | 4 | export default function useCaptchaInstance() { 5 | const { captcha } = useContext(CaptchaContext); 6 | return captcha; 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 指定发布 npm 的时候需要忽略的文件和文件夹 2 | # npm 默认不会把 node_modules 发上去 3 | # dependencies 4 | /lib 5 | /node_modules 6 | /.vscode 7 | 8 | # misc 9 | .DS_Store 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | .idea -------------------------------------------------------------------------------- /src/assets/btn-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | ## 1.0.0 - 1.0.25 4 | 5 | 基于[[isszz/rotate-captcha](https://github.com/ahsankhatri/wordpress-auth-driver-laravel/tree/master)]做的二次开发,做了如下优化: 6 | 7 | - 增加安全策略,适应不同的应用场景 [[查看](https://github.com/cgfeel/react-rotate-captcha#-设计思路-design)] 8 | - 根据客户端情况提供了接口,可提供不同尺寸、格式的图片 9 | - 简化组件绑定的数据对象,而选择通过`useRef`的方式进行交互,尽可能减少因数据变化产生重复渲染 10 | - 添加支持多语言 11 | - 添加支持多主题 12 | - 去掉了原有通过配置文件来配置主题,统一修改为通过CSS变量改变主题 13 | - 支持多种方式唤起验证 14 | - 提供物料 15 | -------------------------------------------------------------------------------- /demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | text-width-auto-label 7 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/CaptchaMask.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext } from "react"; 2 | import "../assets/index.scss"; 3 | import { CaptchaContext } from "./Captcha"; 4 | import { CaptchaCloseProps } from "./CaptchaClose"; 5 | 6 | const CaptchaMask: FC = ({ close }) => { 7 | const { type: [code,,force] } = useContext(CaptchaContext); 8 | return ( 9 |
(code == 3 || force === true) && close()} 12 | >
13 | ); 14 | }; 15 | 16 | export interface CaptchaMaskProps extends CaptchaCloseProps {} 17 | 18 | export default CaptchaMask; -------------------------------------------------------------------------------- /src/assets/success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "[css]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[html]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[javascript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "arrowParens": "always", 12 | "bracketSpacing": true, 13 | "endOfLine": "auto", 14 | "htmlWhitespaceSensitivity": "ignore", 15 | "jsxBracketSameLine": true, 16 | "jsxSingleQuote": false, 17 | "printWidth": 120, 18 | "proseWrap": "preserve", 19 | "semi": true, 20 | "singleQuote": false, 21 | "tabWidth": 4, 22 | "trailingComma": "es5", 23 | "useTabs": false 24 | } 25 | -------------------------------------------------------------------------------- /src/assets/fail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo/src/index.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | grid-gap: 10px 20px; 4 | grid-template-columns: repeat(1, 1fr); 5 | grid-template-rows: repeat(2, auto); 6 | margin: 50px; 7 | .text { 8 | span:nth-child(2) { 9 | color: #f00; 10 | } 11 | } 12 | } 13 | .wrapper { 14 | display: grid; 15 | grid-gap: 10px 20px; 16 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 17 | .item { 18 | background-color: #eaf2ed; 19 | border-radius: 10px; 20 | padding: 20px; 21 | button + button { 22 | margin-left: 10px; 23 | } 24 | pre { 25 | background-color: #fff; 26 | border-radius: 10px; 27 | padding: 20px; 28 | overflow-x: auto; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/components/CaptchaClose.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext } from "react"; 2 | import Close from "../assets/close.svg"; 3 | import "../assets/index.scss"; 4 | import { CaptchaContext } from "./Captcha"; 5 | 6 | const CaptchaClose: FC = ({ close }) => { 7 | const { type: [code,,force] } = useContext(CaptchaContext); 8 | const disabled = code !== 3 && force !== true; 9 | 10 | return ( 11 | 18 | ); 19 | }; 20 | 21 | export interface CaptchaCloseProps { 22 | close: () => void; 23 | } 24 | 25 | export default CaptchaClose; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": false, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "sourceMap": true, 16 | "declaration": true, 17 | "outDir": "lib", 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ], 23 | "paths": { 24 | "@/*": ["./src/*"] 25 | }, 26 | "typeRoots": ["./node_modules/@types", "./types"] 27 | }, 28 | "exclude": ["config", "demo", "node_modules", "**/*.svg"] 29 | } 30 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import Captcha, { CaptchaContext, CaptchaInstance, CaptchaProps, resultType, ticketType, tokenType } from "./components/Captcha"; 2 | import { LangType } from "./components/lang"; 3 | import useCaptchaInstance from "./components/useCaptchaInstance"; 4 | 5 | type InternalCaptchaType = typeof Captcha; 6 | 7 | type CompoundedComponent = InternalCaptchaType & { 8 | useCaptchaInstance: typeof useCaptchaInstance; 9 | } 10 | 11 | const RotateCaptcha = Captcha as CompoundedComponent; 12 | 13 | RotateCaptcha.useCaptchaInstance = useCaptchaInstance; 14 | 15 | type TicketInfoType = resultType; 16 | type TokenInfoType = resultType; 17 | 18 | export type { 19 | CaptchaInstance, 20 | CaptchaProps, 21 | LangType, 22 | TicketInfoType, 23 | TokenInfoType, 24 | }; 25 | 26 | export { RotateCaptcha, CaptchaContext }; 27 | 28 | export default RotateCaptcha; -------------------------------------------------------------------------------- /src/assets/tips/shuaxin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/index.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode, useRef } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import RotateCaptcha from "react-rotate-captcha"; 4 | import PageDemo from "./page"; 5 | import OutSide from "./page/OutSide"; 6 | import Statusoutput from "./page/Statusoutput"; 7 | import { get, load, verify } from "./server"; 8 | 9 | const Root = () => { 10 | const demoRef = useRef(null); 11 | return ( 12 | 13 | demoRef.current.setTicket(info)} 18 | verify={verify} 19 | > 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | createRoot(document.getElementById('root')).render(); -------------------------------------------------------------------------------- /src/components/lang.ts: -------------------------------------------------------------------------------- 1 | export const zh_CN = { 2 | desc: '拖动滑块,使图片角度为正', 3 | getFaild: '信息获取失败', 4 | loadFaild: '图片加载失败', 5 | loadImg: '获取验证图片...', 6 | loadTips: '获取信息中...', 7 | lostProps: '缺少props', 8 | refresh: '换图', 9 | sendError: '提交失败', 10 | success: '完成检测', 11 | title: '安全验证', 12 | verifying: '检测中...', 13 | wait: '待滑动和验证', 14 | }; 15 | 16 | export const en = { 17 | desc: 'Drag the slider to make the picture angle positive', 18 | getFaild: 'Load Fail.', 19 | loadFaild: 'Image failed to load.', 20 | loadImg: 'Obtain verification image.', 21 | loadTips: 'Access to information.', 22 | lostProps: 'Missing props', 23 | refresh: 'Refresh', 24 | sendError: 'Submission failed.', 25 | success: 'success', 26 | title: 'Safety certification', 27 | verifying: 'Verifying.', 28 | wait: 'Sliding and verify.', 29 | }; 30 | 31 | export function choose(lang: string): LangType { 32 | return lang === 'zh_CN' ? zh_CN : en; 33 | } 34 | 35 | export type LangType = typeof en; -------------------------------------------------------------------------------- /src/assets/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/assets/tips/zhengque.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/components/ActionBtn.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useCallback, useContext } from "react"; 2 | import { TicketInfoType } from "react-rotate-captcha"; 3 | import { checkTicket } from "../server"; 4 | import { WrapperContext } from "./Wrapper"; 5 | 6 | const ActionBtn: FC = ({ ticket }) => { 7 | const { setInfo } = useContext(WrapperContext); 8 | const { data } = ticket||{}; 9 | 10 | const getInfo = useCallback(async (ticket?: TicketInfoType) => { 11 | const info = await checkTicket(ticket); 12 | setInfo(info); 13 | }, [setInfo]); 14 | 15 | return [ 16 | , 23 | , 29 | ]; 30 | }; 31 | 32 | export interface ActionBtnProps { 33 | ticket?: TicketInfoType; 34 | } 35 | 36 | export default ActionBtn; -------------------------------------------------------------------------------- /config/webpack.base.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | resolve: { 3 | // 定义 import 引用时可省略的文件后缀名 4 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 5 | }, 6 | module: { 7 | rules: [ 8 | { 9 | exclude: /node_modules/, 10 | test: /\.js(x?)$/, 11 | use: [ 12 | { 13 | loader: 'babel-loader' 14 | } 15 | ] 16 | }, 17 | { 18 | exclude: /node_modules/, 19 | test: /\.ts(x?)$/, 20 | use: [ 21 | { loader: 'babel-loader' }, 22 | { loader: 'ts-loader' } 23 | ] 24 | }, 25 | { 26 | test: /\.svg$/, 27 | resourceQuery: /abc/, 28 | type: 'asset', 29 | }, 30 | { 31 | test: /\.svg$/, 32 | resourceQuery: { not: [/abc/] }, 33 | use: ['@svgr/webpack'], 34 | } 35 | ] 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/assets/tips/gantanhao.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 快乐的小萌新 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/assets/tips/shibai.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/components/Wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren, ReactNode, createContext, useState } from "react"; 2 | import { TicketInfoType } from "react-rotate-captcha"; 3 | import '../index.scss'; 4 | import { ActionType } from "../server"; 5 | 6 | const Wrapper: FC> = ({ captcha, children, ticket }) => { 7 | const [info, setInfo] = useState(undefined); 8 | return ( 9 | 12 |
15 |
18 | {captcha} 19 |
{JSON.stringify(ticket, null, 2)}
20 |
21 |
22 | {children} 23 |
{JSON.stringify(info, null, 2)}
24 |
25 |
26 |
27 | ); 28 | }; 29 | 30 | interface WrapperContextInstance { 31 | setInfo: (info: ActionType) => void; 32 | } 33 | 34 | export const WrapperContext = createContext({} as WrapperContextInstance); 35 | 36 | export interface WrapperProps { 37 | captcha?: ReactNode; 38 | ticket?: TicketInfoType; 39 | } 40 | 41 | export default Wrapper; -------------------------------------------------------------------------------- /src/components/CaptchaWrap.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren, useContext } from "react"; 2 | import { CaptchaContext } from "./Captcha"; 3 | import { LangType } from "./lang"; 4 | 5 | const statusType = ['captcha-success', 'captcha-fail' ]; 6 | 7 | const CaptchaHeader: FC<{ lang: LangType }> = ({ lang }) => { 8 | const { desc, title } = lang; 9 | return ( 10 |
11 |
{title}
12 | {desc} 13 |
14 | ); 15 | }; 16 | 17 | const CaptchaWrap: FC> = ({ children }) => { 18 | const { lang, type: [code] } = useContext(CaptchaContext); 19 | return ( 20 |
23 |
26 | 27 |
30 | {children} 31 |
32 |
35 |
36 |
37 |
38 |
39 | ); 40 | }; 41 | 42 | export interface CaptchaWrapProps {} 43 | 44 | export default CaptchaWrap; -------------------------------------------------------------------------------- /src/components/CaptchaTooltips.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext } from "react"; 2 | import Gantanhao from "../assets/tips/gantanhao.svg"; 3 | import Shibai from "../assets/tips/shibai.svg"; 4 | import Shuaxin from "../assets/tips/shuaxin.svg"; 5 | import Zhengque from "../assets/tips/zhengque.svg"; 6 | import { CaptchaContext } from "./Captcha"; 7 | 8 | const icon = [, ]; 9 | const color = ['tips-success', 'tips-fail']; 10 | 11 | const CaptchaTooltips: FC = ({ reLoad }) => { 12 | const { lang: { refresh }, type: [code, msg] } = useContext(CaptchaContext); 13 | const colorTips = color[code]||''; 14 | 15 | return ( 16 |
19 |
22 | {icon[code]||} 23 | 26 | {msg} 27 | 28 |
29 |
32 | 38 |
39 |
40 | ); 41 | }; 42 | 43 | export interface TooltipsProps { 44 | reLoad: () => void; 45 | } 46 | 47 | export default CaptchaTooltips; -------------------------------------------------------------------------------- /demo/src/page/index.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useImperativeHandle, useState } from "react"; 2 | import RotateCaptcha, { TicketInfoType } from "react-rotate-captcha"; 3 | import ActionBtn from "../components/ActionBtn"; 4 | import Wrapper from "../components/Wrapper"; 5 | import "../index.scss"; 6 | 7 | const PageDemo = forwardRef((_, ref) => { 8 | const captcha = RotateCaptcha.useCaptchaInstance(); 9 | const [ticket, setTicket] = useState(); 10 | 11 | useImperativeHandle(ref, () => ({ 12 | setTicket: info => setTicket(info), 13 | })); 14 | 15 | return [ 16 |
20 |
23 |
26 | 示例: 27 | Context上下文中触发 28 |
29 |
30 |
33 | captcha.open()} 39 | > 40 | 开始验证 41 | , 42 | 48 | ]} 49 | > 50 | 51 | 52 |
53 |
54 | ]; 55 | }); 56 | 57 | export interface DemoInstance { 58 | setTicket: (info: TicketInfoType) => void; 59 | } 60 | 61 | export default PageDemo; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-rotate-captcha", 3 | "version": "1.0.26", 4 | "description": "Rotate image captcha,旋转图片验证码", 5 | "main": "./lib/index_bundle.js", 6 | "types": "./lib/index.d.ts", 7 | "scripts": { 8 | "build": "npm run clean && webpack --config config/webpack.prod.config.js", 9 | "clean": "rimraf lib", 10 | "dev": "webpack-dev-server --config config/webpack.dev.config.js", 11 | "pub": "pub", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "keywords": [ 15 | "react", 16 | "captcha", 17 | "rotate-captcha" 18 | ], 19 | "author": "levi", 20 | "license": "MIT", 21 | "homepage": "https://github.com/cgfeel/react-rotate-captcha", 22 | "bugs": { 23 | "url": "https://github.com/cgfeel/react-rotate-captcha/issues" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/cgfeel/react-rotate-captcha" 28 | }, 29 | "files": [ 30 | "lib" 31 | ], 32 | "devDependencies": { 33 | "@babel/cli": "^7.23.0", 34 | "@babel/core": "^7.23.3", 35 | "@babel/plugin-proposal-optional-chaining-assign": "^7.23.0", 36 | "@babel/preset-env": "^7.23.3", 37 | "@babel/preset-react": "^7.23.3", 38 | "@babel/preset-typescript": "^7.23.3", 39 | "@pureadmin/release": "^1.0.0", 40 | "@svgr/webpack": "^8.1.0", 41 | "@types/react": "^18.2.37", 42 | "@types/react-dom": "^18.2.15", 43 | "babel-loader": "^9.1.3", 44 | "css-loader": "^6.8.1", 45 | "html-webpack-plugin": "^5.5.3", 46 | "mini-css-extract-plugin": "^2.7.6", 47 | "node-sass": "^9.0.0", 48 | "postcss": "^8.4.31", 49 | "postcss-loader": "^7.3.3", 50 | "postcss-preset-env": "^9.3.0", 51 | "react": "^18.2.0", 52 | "react-dom": "^18.2.0", 53 | "rimraf": "^5.0.5", 54 | "sass-loader": "^13.3.2", 55 | "style-loader": "^3.3.3", 56 | "ts-loader": "^9.5.1", 57 | "webpack": "^5.89.0", 58 | "webpack-cli": "^5.1.4", 59 | "webpack-dev-server": "^4.15.1", 60 | "webpack-merge": "^5.10.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /demo/src/page/OutSide.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useRef, useState } from "react"; 2 | import RotateCaptcha, { CaptchaInstance, TicketInfoType } from "react-rotate-captcha"; 3 | import ActionBtn from "../components/ActionBtn"; 4 | import Wrapper from "../components/Wrapper"; 5 | import { get, load, verify } from "../server"; 6 | 7 | const OutSide: FC = () => { 8 | const ref = useRef(null); 9 | const [ticket, setTicket] = useState(); 10 | 11 | return ( 12 |
15 | setTicket(info)} 20 | verify={verify} 21 | > 22 |
25 |
28 | 示例: 29 | 通过Ref触发 30 |
31 |
32 |
33 |
36 | ref.current!.open()} 42 | > 43 | 开始验证 44 | , 45 | 51 | ]} 52 | > 53 | 54 | 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default OutSide; -------------------------------------------------------------------------------- /config/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require('webpack-merge'); 3 | const baseConfig = require('./webpack.base'); // 引用公共的配置 4 | 5 | const prodConfig = { 6 | mode: 'production', // 生产模式 7 | entry: path.join(__dirname, '../src/index.tsx'), 8 | output: { 9 | path: path.join(__dirname, '../lib/'), 10 | filename: "./index_bundle.js", 11 | libraryTarget: 'umd', // 采用通用模块定义 12 | libraryExport: 'default', // 兼容 ES6 Module、CommonJS 和 AMD 模块规范 13 | }, 14 | devtool: 'source-map', 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.(sa|sc|c)ss$/, 19 | exclude: /.min.css$/, 20 | use: [ 21 | { loader: 'style-loader' }, 22 | { 23 | loader: 'css-loader', 24 | options: { 25 | modules: { 26 | mode: 'global' 27 | } 28 | } 29 | }, 30 | { 31 | loader: 'postcss-loader', 32 | options: { 33 | postcssOptions: { 34 | plugins: [ 35 | 'postcss-preset-env' 36 | ] 37 | } 38 | }, 39 | }, 40 | { loader: 'sass-loader' } 41 | ] 42 | } 43 | ] 44 | }, 45 | // 定义外部依赖,避免把react和react-dom打包进去 46 | externals: { 47 | react: { 48 | root: 'React', 49 | commonjs2: 'react', 50 | commonjs: 'react', 51 | amd: 'react' 52 | }, 53 | "react-dom": { 54 | root: 'ReactDOM', 55 | commonjs2: 'react-dom', 56 | commonjs: 'react-dom', 57 | amd: 'react-dom' 58 | } 59 | } 60 | }; 61 | module.exports = merge(prodConfig, baseConfig); // 合并配置 -------------------------------------------------------------------------------- /demo/src/page/Statusoutput.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import RotateCaptcha, { TicketInfoType } from "react-rotate-captcha"; 3 | import ActionBtn from "../components/ActionBtn"; 4 | import Wrapper from "../components/Wrapper"; 5 | import { get, load, verify } from "../server"; 6 | 7 | const Statusoutput: FC = () => { 8 | const [ticket, setTicket] = useState(); 9 | const [open, setOpen] = useState(false); 10 | 11 | return ( 12 |
15 | setOpen(false)} 20 | result={info => setTicket(info)} 21 | verify={verify} 22 | > 23 |
26 |
29 | 示例: 30 | 通过Ref触发 31 |
32 |
33 |
34 |
37 | setOpen(true)} 43 | > 44 | 开始验证 45 | , 46 | 52 | ]} 53 | > 54 | 55 | 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default Statusoutput; -------------------------------------------------------------------------------- /config/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require('webpack-merge'); 3 | const baseConfig = require('./webpack.base'); 4 | 5 | const devConfig = { 6 | mode: "development", // 开发模式 7 | entry: path.join(__dirname, "../demo/src/index.jsx"), // 入口,处理资源文件的依赖关系 8 | output: { 9 | path: path.join(__dirname, "../demo/src"), 10 | filename: "./index_bundle.js", 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.(sa|sc|c)ss$/, 16 | exclude: /.min.css$/, 17 | use: [ 18 | { loader: "style-loader" }, 19 | { 20 | loader: "css-loader", 21 | options: { 22 | modules: { 23 | mode: "global" 24 | } 25 | } 26 | }, 27 | { 28 | loader: 'postcss-loader', 29 | options: { 30 | postcssOptions: { 31 | plugins: [ 32 | [ 33 | 'postcss-preset-env', 34 | { 35 | // 其他选项 36 | }, 37 | ] 38 | ] 39 | } 40 | } 41 | }, 42 | { loader: 'sass-loader' } 43 | ] 44 | }, 45 | { 46 | test: /.min.css$/, 47 | use: [ 48 | { loader: 'style-loader' }, 49 | { loader: 'css-loader' } 50 | ] 51 | } 52 | ] 53 | }, 54 | devServer: { 55 | static: path.join(__dirname, "../demo/src/"), 56 | compress: true, 57 | host: '0.0.0.0', 58 | hot: true, 59 | port: 8686, // 启动窗口 60 | open: false // 打开浏览器 61 | }, 62 | }; 63 | module.exports = merge(devConfig, baseConfig); -------------------------------------------------------------------------------- /demo/src/server/canvas.ts: -------------------------------------------------------------------------------- 1 | const calcSize = (img: HTMLImageElement, size: number) => { 2 | const src_src = Math.max(Math.min(img.width, img.height, size), 160); 3 | const dst_w = src_src; 4 | const dst_h = src_src; 5 | 6 | const dst_scale = dst_h / dst_w; // Target image ratio 7 | const src_scale = img.height / img.width; // Original image aspect ratio 8 | 9 | const info = src_scale >= dst_scale ? [ 10 | Math.round(img.height * (src_src / img.width)), 11 | 0, 12 | Math.round((img.height - img.width) / 2) 13 | ] : [ 14 | Math.round(img.width * (src_src / img.height)), 15 | Math.round((img.width - img.height) / 2), 16 | 0 17 | ]; 18 | 19 | return [img.width, img.height, src_src, ...info]; 20 | }; 21 | 22 | const build = (img: HTMLImageElement, sizes: number[]): [number, string] => { 23 | const [src_w, src_h, size, tar_size, x, y] = sizes; 24 | const canvas = document.createElement("canvas"); 25 | canvas.width = size; 26 | canvas.height = size; 27 | 28 | const max = 275 - 50; 29 | const min = 0; 30 | 31 | const ave = Math.round((360 / max) * 100) / 100; 32 | const ctx = canvas.getContext("2d"); 33 | 34 | const coordinate = size / 2; 35 | const moveX = Math.floor(Math.random() * (max - min + 1)) + 0; 36 | 37 | ctx?.beginPath(); 38 | ctx?.translate(coordinate, coordinate); 39 | ctx?.rotate((moveX * -1 * ave * Math.PI) / 180); 40 | ctx?.translate(-coordinate, -coordinate); 41 | ctx?.drawImage(img, x, y, src_w, src_h, 0, 0, tar_size, size); 42 | ctx!.globalCompositeOperation = "destination-in"; 43 | ctx?.arc(size / 2, size / 2, size / 2, 0, (360 * Math.PI) / 180, false); 44 | ctx?.fill(); 45 | ctx?.restore(); 46 | 47 | return [(360 / (max - min)) * moveX, canvas.toDataURL("image/png")]; 48 | }; 49 | 50 | export const handle = (url: string, size: number = 350) => new Promise<[number, string]>(resovle => { 51 | const img = new Image(); 52 | img.onerror = function () { 53 | console.log("image load error"); 54 | }; 55 | 56 | img.onload = function () { 57 | const sizes = calcSize(img, size); 58 | const arc_img = build(img, sizes); 59 | 60 | resovle(arc_img); 61 | }; 62 | 63 | img.src = url; 64 | }); -------------------------------------------------------------------------------- /demo/src/server/index.ts: -------------------------------------------------------------------------------- 1 | import type { TicketInfoType, TokenInfoType } from "react-rotate-captcha"; 2 | import { handle } from "./canvas"; 3 | 4 | export type ActionType = { 5 | code: 0|1; 6 | msg: string; 7 | }; 8 | 9 | const tokenRaw = "Nvuv8LdXUNRAVW022Gm7HkGc7RTDoEmU"; 10 | const info = { 11 | angle: -1, 12 | sid: '', 13 | ticket: '', 14 | }; 15 | 16 | export async function checkTicket(ticket?: TicketInfoType) { 17 | const { sid, ticket: ticketRaw } = info; 18 | const { data } = ticket||{}; 19 | 20 | const isWait = sid !== '' && ticketRaw !== ''; 21 | const success = sid === data?.sid && ticketRaw === data.ticket; 22 | 23 | const result = isWait && success ? { 24 | code: 0, 25 | msg: 'Successful', 26 | } : { 27 | code: 1, 28 | msg: 'Failed', 29 | }; 30 | 31 | return result as ActionType; 32 | } 33 | 34 | export async function get(): Promise { 35 | info.angle = -1; 36 | info.sid = ''; 37 | info.ticket = ''; 38 | return { 39 | code: 0, 40 | data: { 41 | str: "t01b6eac4d21e9d24d4", 42 | token: tokenRaw, 43 | }, 44 | msg: "success", 45 | }; 46 | } 47 | 48 | export function isSupportWebp() { 49 | try { 50 | return document.createElement('canvas').toDataURL('image/webp', 0.5).indexOf('data:image/webp') === 0; 51 | } catch(err) { 52 | return false; 53 | } 54 | } 55 | 56 | export async function load(path: string) { 57 | const [degree, src] = await handle(`${location.origin}/${path}.jpg`); 58 | info.angle = degree; 59 | 60 | return src; 61 | } 62 | 63 | export function sleep(time: number) { 64 | return new Promise(resolve => { 65 | setTimeout(() => resolve(true), time); 66 | }); 67 | } 68 | 69 | export async function verify(token: string, deg: number): Promise { 70 | const { angle } = info; 71 | const success = token === tokenRaw && Math.abs(deg - angle) <= 5; 72 | 73 | info.sid = Math.random().toString(36).slice(-8); 74 | info.ticket = crypto.randomUUID(); 75 | 76 | return angle >= 0 && success ? { 77 | code: 0, 78 | data: { 79 | sid: info.sid, 80 | ticket: info.ticket 81 | }, 82 | msg: 'Success', 83 | } : { 84 | code: 1, 85 | msg: 'Fail verify', 86 | }; 87 | } -------------------------------------------------------------------------------- /docs/test.md: -------------------------------------------------------------------------------- 1 | ``` 2 | import { TicketInfoType, TokenInfoType } from "../../App"; 3 | import { handle } from "./canvas"; 4 | 5 | const tokenRaw = "Nvuv8LdXUNRAVW022Gm7HkGc7RTDoEmU"; 6 | let angle = 0; 7 | 8 | export async function get(): Promise { 9 | /*const request = await fetch(`http://${location.hostname}:8000/rotate.captcha`); 10 | const headers = request.headers; 11 | 12 | const info = await request.json(); 13 | info.data.token = headers.get('X-Captchatoken'); 14 | 15 | return info;*/ 16 | return { 17 | code: 0, 18 | data: { 19 | str: "t01b6eac4d21e9d24d4", 20 | token: tokenRaw, 21 | }, 22 | msg: "success", 23 | }; 24 | } 25 | 26 | export function isSupportWebp() { 27 | try { 28 | return document.createElement('canvas').toDataURL('image/webp', 0.5).indexOf('data:image/webp') === 0; 29 | } catch(err) { 30 | return false; 31 | } 32 | } 33 | 34 | export async function load(path: string) { 35 | const [degree, src] = await handle(`${location.origin}/${path}.jpg`); 36 | angle = degree; 37 | 38 | return src; 39 | return `http://${location.hostname}:8000/rotate.captcha/${path}`; 40 | } 41 | 42 | export function sleep(time: number) { 43 | return new Promise(resolve => { 44 | setTimeout(() => resolve(true), time); 45 | }); 46 | } 47 | 48 | export async function verify(token: string, deg: number): Promise { 49 | console.log(angle, deg); 50 | const success = token === tokenRaw && Math.abs(deg - angle) <= 5; 51 | return success ? { 52 | code: 0, 53 | data: { 54 | sid: Math.random().toString(36).slice(-8), 55 | ticket: crypto.randomUUID() 56 | }, 57 | msg: 'Success', 58 | } : { 59 | code: 1, 60 | msg: 'Fail verify', 61 | }; 62 | /*const request = await fetch(`http://${location.hostname}:8000/rotate.captcha/verify/${deg}`, { 63 | method: "GET", 64 | headers: { 65 | 'X-Captchatoken': token, 66 | } 67 | }); 68 | const info = await request.json(); 69 | return info;*/ 70 | } 71 | 72 | export async function ticket(ticket?: TicketInfoType) { 73 | const { data } = ticket||{}; 74 | const response = await fetch(`http://${location.hostname}:8000/rotate.captcha/test/action`, data === undefined ? {} : { 75 | headers: { 76 | 'X-Captchasid': data.sid, 77 | 'X-Captchaticket': data.ticket, 78 | }, 79 | }); 80 | 81 | const info: ActionType = await response.json(); 82 | return info; 83 | } 84 | ``` 85 | -------------------------------------------------------------------------------- /src/components/RotateCaptcha.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, PropsWithChildren, ReactNode, SetStateAction, forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import "../assets/index.scss"; 4 | import { CaptchaContext, CaptchaProps, CurrentlyType } from "./Captcha"; 5 | 6 | const RotateCaptcha = forwardRef>(( 7 | { 8 | children, 9 | className, 10 | close, 11 | mask, 12 | visible, 13 | get, 14 | loadImg, 15 | show, 16 | verify, 17 | theme = 'light', 18 | zIndex = 999, 19 | }, 20 | ref 21 | ) => { 22 | const { lang } = useContext(CaptchaContext); 23 | 24 | const rotate = useRef(null); 25 | const [open, setOpen] = useState<0|1|2>(visible); 26 | 27 | const getToken = useCallback(async () => { 28 | try { 29 | rotate.current!.token = ''; 30 | show([2, lang.loadTips]); 31 | 32 | if (!get) throw new Error(lang.lostProps); 33 | const { code, data, msg } = await get(); 34 | 35 | if (data === undefined || code !== 0) { 36 | throw new Error(msg||lang.getFaild); 37 | } 38 | 39 | const { str, token } = data; 40 | rotate.current!.token = token; 41 | 42 | show([2, lang.loadImg]); 43 | loadImg(str, token); 44 | } catch (e) { 45 | show([1, lang.getFaild, true]); 46 | } 47 | }, [lang, rotate, get, loadImg, show]); 48 | 49 | useEffect(() => { 50 | setOpen(open => open === visible ? open : (visible === 0 ? 2 : 1)); 51 | }, [visible, setOpen]); 52 | 53 | useEffect(() => { 54 | if (open === 0) show([2, lang.loadTips]); 55 | }, [open, show]); 56 | 57 | useImperativeHandle(ref, () => ({ 58 | load: (num = 1) => setOpen(num), 59 | start: () => getToken(), 60 | toVerify: async (deg) => { 61 | const token = rotate.current?.token; 62 | if (!token || !verify) { 63 | return { 64 | code: 2, 65 | msg: lang.getFaild 66 | }; 67 | } 68 | 69 | show([4, lang.verifying]); 70 | return await verify(token, deg).then(info => { 71 | // 提交验证立即关闭窗口-重新打开验证,这个时候token就发生改变了 72 | return token === rotate.current?.token ? info : { 73 | code: -1, 74 | msg: 'is over', 75 | }; 76 | }); 77 | }, 78 | })); 79 | 80 | return open === 0 ? null : createPortal( 81 |
87 |
{ 90 | switch (open) { 91 | case 1: getToken(); break; 92 | case 2: setOpen(0); break; 93 | } 94 | }} 95 | > 96 | {close}{children} 97 |
98 | {mask} 99 |
, 100 | document.body 101 | ); 102 | }); 103 | 104 | export interface RotateCaptchaInstance { 105 | load: (num?: 0|1|2) => void; // 0.强制销毁,1.打开,2.关闭 106 | start: () => Promise; 107 | toVerify: (deg: number) => ReturnType['verify']>; 108 | } 109 | 110 | export interface RotateCaptchaProps extends Pick { 111 | visible: 0|1; 112 | loadImg: (path: string, token: string) => void; 113 | show: Dispatch>; 114 | close?: ReactNode; 115 | mask?: ReactNode; 116 | } 117 | 118 | export default RotateCaptcha; -------------------------------------------------------------------------------- /src/components/CaptchaControl.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef } from "react"; 2 | import Icon from "../assets/btn-icon.svg"; 3 | import "../assets/index.scss"; 4 | import { CaptchaContext, CaptchaProps } from "./Captcha"; 5 | import { RollType } from "./CaptchaImage"; 6 | 7 | const CaptchaControl = forwardRef( 8 | ({ onRoll, send, show, limit = 2 }, ref) => { 9 | const barRef = useRef(null); 10 | const { 11 | type: [code, , forceStop], 12 | } = useContext(CaptchaContext); 13 | 14 | const startAction = useCallback( 15 | (xnum: number) => { 16 | if (code !== 3) return; 17 | barRef.current!.startX = xnum; 18 | onRoll("down"); 19 | }, 20 | [barRef, code, onRoll] 21 | ); 22 | 23 | useEffect(() => { 24 | const isTouch = "ontouchmove" in window; 25 | const event = { 26 | mousemove: function (e: MouseEvent) { 27 | e.preventDefault(); 28 | const start_x = barRef.current?.startX || -1; 29 | if (start_x < 0) return; 30 | 31 | const x = onRoll("move", e.pageX - start_x); 32 | barRef.current!.setAttribute("style", `--captcha-control-x:translateX(${x}px)`); 33 | }, 34 | touchmove: function (e: TouchEvent) { 35 | const start_x = barRef.current?.startX || -1; 36 | if (start_x < 0) return; 37 | 38 | const x = onRoll("move", e.targetTouches[0].pageX - start_x); 39 | barRef.current!.setAttribute("style", `--captcha-control-x:translateX(${x}px)`); 40 | }, 41 | upend: function () { 42 | const start_x = barRef.current?.startX || -1; 43 | if (start_x < 0) return; 44 | 45 | const deg = onRoll("up"); 46 | barRef.current!.startX = -1; 47 | send(deg); 48 | }, 49 | }; 50 | 51 | const up = isTouch ? "touchend" : "mouseup"; 52 | if (isTouch) { 53 | document.addEventListener("touchmove", event.touchmove, false); 54 | } else { 55 | document.addEventListener("mousemove", event.mousemove, false); 56 | } 57 | 58 | document.addEventListener(up, event.upend, false); 59 | return () => { 60 | if (isTouch) { 61 | document.removeEventListener("touchmove", event.touchmove); 62 | } else { 63 | document.removeEventListener("mousemove", event.mousemove); 64 | } 65 | document.removeEventListener(up, event.upend); 66 | }; 67 | }, [barRef, onRoll]); 68 | 69 | useImperativeHandle(ref, () => ({ 70 | reback: () => { 71 | barRef.current!.style.animation = "reback .4s"; 72 | }, 73 | restart: (num = -1) => { 74 | barRef.current!.total = num < 0 ? limit : num; 75 | }, 76 | })); 77 | 78 | return ( 79 |
80 |
81 |
{ 85 | const total = barRef.current!.total || 0; 86 | 87 | barRef.current!.total = total + 1; 88 | barRef.current?.removeAttribute("style"); 89 | 90 | if (limit > barRef.current!.total || forceStop) { 91 | // 当错误次数未上限时,阻止冒泡发起请求,并返回代操作状态 92 | e.stopPropagation(); 93 | !forceStop && show(); 94 | } else { 95 | // 否则通过冒泡发起重新获取的请求,并归零 96 | barRef.current!.total = 0; 97 | } 98 | }} 99 | onMouseDown={(e) => { 100 | if (!("ontouchstart" in window)) startAction(e.pageX); 101 | }} 102 | onTouchStart={(e) => startAction(e.targetTouches[0].pageX)}> 103 | 104 |
105 |
106 | ); 107 | } 108 | ); 109 | 110 | type BarInfoType = { 111 | total?: number; 112 | startX?: number; 113 | }; 114 | 115 | export interface CaptchaControlInstance { 116 | reback: () => void; 117 | restart: (num?: number) => void; 118 | } 119 | 120 | export interface CaptchaControlProps extends Pick { 121 | onRoll: (name: RollType, x?: number) => number; 122 | send: (deg: number) => void; 123 | show: () => void; 124 | } 125 | 126 | export default CaptchaControl; 127 | -------------------------------------------------------------------------------- /src/components/CaptchaImage.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, forwardRef, useCallback, useContext, useImperativeHandle, useRef } from "react"; 2 | import Fail from "../assets/fail.svg"; 3 | import Loading from "../assets/loading.svg"; 4 | import Success from "../assets/success.svg"; 5 | import "../assets/index.scss"; 6 | import { CaptchaContext, CaptchaProps, CurrentlyType } from "./Captcha"; 7 | 8 | const def_coor = { ave: 0, btn: 0, cost: 0, deg: 0, moveX: 0, size: 0, x: 0, y: 0 }; 9 | const stateIcon = [, , null, null, ]; 10 | 11 | const defaultAction = async () => ''; 12 | 13 | const CaptchaImage = forwardRef(( 14 | { show, reLoad, load = defaultAction }, ref 15 | ) => { 16 | const { lang, type: [code] } = useContext(CaptchaContext); 17 | 18 | const canvasRef = useRef(null); 19 | const pointRef = useRef(null); 20 | 21 | const drawCanvas = useCallback(() => { 22 | const { 23 | img, ave = 0, moveX = 0, size = 0, x = 0, y = 0 24 | } = canvasRef.current?.info||{}; 25 | 26 | const canvas = canvasRef.current; 27 | if (size === 0 || img === undefined || canvas === null) return; 28 | 29 | canvas.width = size; 30 | canvas.height = size; 31 | 32 | const ctx = canvas.getContext('2d'); 33 | ctx?.beginPath(); 34 | ctx?.arc(100, 100, size, 0, 360 * Math.PI / 180, false) 35 | ctx?.closePath(); 36 | ctx?.clip(); 37 | ctx?.save(); 38 | ctx?.clearRect(0, 0, size, size); 39 | ctx?.translate(x, y); 40 | ctx?.rotate(moveX * ave * Math.PI / 180); 41 | ctx?.translate(-x, -y); 42 | ctx?.drawImage(img, 0, 0, size, size); 43 | ctx?.restore(); 44 | }, [canvasRef]); 45 | 46 | const initCoordinate = useCallback((img: HTMLImageElement) => { 47 | const container = document.querySelector('.captcha-img'); 48 | const control = document.querySelector('.captcha-control'); 49 | const button = document.querySelector('.captcha-control-button'); 50 | 51 | const size = container?.clientWidth||0; 52 | const cost = control?.clientWidth||0; 53 | const btn = button?.clientWidth||0; 54 | 55 | canvasRef.current!.info = { 56 | ave: Math.round((360 / (cost - btn)) * 100) / 100, // 进度条百分比:360°的圆 / (控制器 - 按钮) 57 | deg: 0, // 滑动的角度 58 | moveX: 0, // 滑动距离 59 | x: size / 2, // 验证图中心坐标x 60 | y: size / 2, // 验证图中心坐标y 61 | btn, // 按钮宽度 62 | cost, // 进度条宽度 63 | img, // 验证图 64 | size, // 验证图尺寸 65 | }; 66 | drawCanvas(); 67 | }, [canvasRef]); 68 | 69 | const moveAction = useCallback((x: number) => { 70 | const info = canvasRef.current?.info; 71 | if (info === undefined) return 0; 72 | 73 | const { btn, cost } = info; 74 | const width = cost - btn; 75 | 76 | const moveX = Math.max(0, Math.min(x, width)); 77 | const deg = (360 / width) * moveX; 78 | 79 | canvasRef.current!.info = { 80 | ...info, 81 | deg, 82 | moveX, 83 | }; 84 | 85 | drawCanvas(); 86 | return moveX; 87 | }, [canvasRef]); 88 | 89 | useImperativeHandle(ref, () => ({ 90 | loadImg: (path, token) => { 91 | if (!canvasRef.current) return; 92 | canvasRef.current.token = token; 93 | 94 | load(path).then(url => { 95 | if (token !== canvasRef.current?.token) return; 96 | if (!url) { 97 | show([1, lang.loadFaild, true]); 98 | return; 99 | } 100 | 101 | const image = new Image(); 102 | image.onerror = function(e) { 103 | show([1, lang.loadFaild, true]); 104 | } 105 | image.onload = function() { 106 | initCoordinate(image); 107 | show([3, lang.wait]); 108 | }; 109 | image.src = url; 110 | }).catch( 111 | () => show([1, lang.loadFaild, true]) 112 | ); 113 | }, 114 | reStart: () => { 115 | const { img } = canvasRef.current?.info||{}; 116 | if (img !== undefined) initCoordinate(img); 117 | }, 118 | toRoll: (name, x) => { 119 | if (name === 'move') return x !== undefined ? moveAction(x) : 0; 120 | 121 | const show = name === 'down' ? 'block' : 'none'; 122 | pointRef.current!.style.display = `${show}`; 123 | 124 | const { deg } = canvasRef.current?.info||{}; 125 | return deg||0; 126 | }, 127 | })); 128 | 129 | return ( 130 |
133 |
136 |
139 | 140 |
143 | {code === 2 && } 144 |
145 |
146 |
150 |
{ 153 | e.stopPropagation(); 154 | switch (code) { 155 | case 0: setTimeout(() => reLoad(code), 300); break; 156 | case 1: reLoad(code); break; 157 | } 158 | }} 159 | > 160 |
163 | {stateIcon[code]} 164 |
165 |
166 |
167 |
168 | ); 169 | }); 170 | 171 | type CoordinateType = { 172 | info?: (typeof def_coor) & { img?: HTMLImageElement } 173 | token?: string; 174 | }; 175 | 176 | export type RollType = 'down'|'move'|'up'; 177 | 178 | export interface CaptchaImageInstance { 179 | loadImg: (path: string, token: string) => void; 180 | reStart: () => void; 181 | toRoll: (name: RollType, x?: number) => number; 182 | } 183 | 184 | export interface CaptchaImageProps extends Pick { 185 | show: Dispatch>; 186 | reLoad: (code: 0|1) => void; 187 | } 188 | 189 | export default CaptchaImage; -------------------------------------------------------------------------------- /src/components/Captcha.tsx: -------------------------------------------------------------------------------- 1 | import { ForwardRefRenderFunction, PropsWithChildren, ReactElement, ReactNode, Ref, createContext, forwardRef, useImperativeHandle, useRef, useState } from "react"; 2 | import CaptchaClose from "./CaptchaClose"; 3 | import CaptchaControl, { CaptchaControlInstance } from "./CaptchaControl"; 4 | import CaptchaImage, { CaptchaImageInstance } from "./CaptchaImage"; 5 | import CaptchaMask from "./CaptchaMask"; 6 | import CaptchaTooltips from "./CaptchaTooltips"; 7 | import CaptchaWrap from "./CaptchaWrap"; 8 | import RotateCaptcha, { RotateCaptchaInstance } from "./RotateCaptcha"; 9 | import { choose, LangType } from "./lang"; 10 | 11 | const resultDefault: Required['result'] = _ => {}; 12 | 13 | const InternalCaptcha: ForwardRefRenderFunction> = ( 14 | { 15 | className, 16 | close, 17 | children, 18 | limit, 19 | mask, 20 | open, 21 | tips, 22 | theme, 23 | zIndex, 24 | get, 25 | load, 26 | onClose, 27 | verify, 28 | lang: langRaw = 'zh_CN', 29 | result = resultDefault, 30 | }, 31 | ref 32 | ) => { 33 | const lang = (typeof langRaw === 'string' ? choose(langRaw) : langRaw) as LangType; 34 | const [currently, setCurrently] = useState([2, lang.loadTips]); 35 | 36 | const control = useRef(null); 37 | const image = useRef(null); 38 | const modal = useRef(null); 39 | 40 | const captchaInstance = { 41 | close: (force = false) => { 42 | if (open !== true) { 43 | modal.current?.load(force ? 0 : 2); 44 | } 45 | if (onClose !== undefined) onClose(); 46 | }, 47 | open: () => modal.current?.load(), 48 | reload: () => { 49 | if (currently[0] === 3) { 50 | modal.current?.start(); 51 | control.current?.restart(0); 52 | } 53 | }, 54 | }; 55 | 56 | // Pass ref with captcha instance 57 | useImperativeHandle(ref, () => captchaInstance); 58 | 59 | return ( 60 | 67 | 71 | )} 72 | mask={mask !== null && ( 73 | mask|| 74 | )} 75 | ref={modal} 76 | theme={theme} 77 | visible={open === true ? 1 : 0} 78 | zIndex={zIndex} 79 | get={get} 80 | loadImg={(path, token) => image.current?.loadImg(path, token)} 81 | show={setCurrently} 82 | verify={verify} 83 | > 84 | 85 | { 89 | code ? control.current?.reback() : captchaInstance.close(); 90 | }} 91 | show={setCurrently} 92 | /> 93 | image.current!.toRoll(name, x)} 97 | send={deg => modal.current?.toVerify(deg).then(info => { 98 | const { code, msg } = info; 99 | if (code === -1) return; 100 | 101 | const tips = code === 0 ? lang.success : msg; 102 | if (code === 2) { 103 | control.current!.restart(-1); 104 | } 105 | 106 | setCurrently([code === 0 ? 0 : 1, tips]); 107 | result(info); 108 | }).catch((e: Error) => { 109 | const tips = e.message||lang.sendError; 110 | 111 | setCurrently([1, tips, true]); 112 | result({ code: 1, msg: lang.sendError }); 113 | })} 114 | show={() => { 115 | image.current?.reStart(); 116 | setCurrently([3, lang.wait]); 117 | }} 118 | /> 119 | {tips||} 122 | 123 | 124 | {children} 125 | 126 | ); 127 | }; 128 | 129 | export const CaptchaContext = createContext({} as CaptchaContextInstance); 130 | 131 | export interface CaptchaContextInstance { 132 | captcha: CaptchaInstance; 133 | lang: LangType; 134 | type: CurrentlyType; 135 | } 136 | 137 | export interface CaptchaInstance { 138 | close: (force?: boolean) => void; 139 | open: () => void; 140 | reload: () => void; 141 | } 142 | 143 | export interface CaptchaProps { 144 | className?: string; 145 | close?: null|ReactNode; 146 | lang?: LangType|string; 147 | limit?: number; 148 | mask?: null|ReactNode; 149 | open?: boolean; 150 | tips?: ReactNode; 151 | theme?: 'dark'|'light'; 152 | zIndex?: number; 153 | get?: () => Promise>; 154 | load?: (path: string) => Promise; 155 | onClose?: () => void; 156 | result?: (info: resultType) => void; 157 | verify?: (token: string, deg: number) => Promise>; 158 | } 159 | 160 | /** 161 | * - 0.正确; 1.错误; 2.待获取; 3.待提交; 4.提交中 162 | * - msg 163 | * - forceStop 164 | */ 165 | export type CurrentlyType = [0|1|2|3|4, string, boolean?]; 166 | 167 | /** 168 | * code: 0.正常; 1.错误可继续; 2.错误重新开始; -1.过期操作(非验证行为) 169 | */ 170 | export type resultType = { 171 | code: -1|0|1|2; 172 | msg: string; 173 | data?: T; 174 | }; 175 | 176 | export type ticketType = { 177 | sid: string; 178 | ticket: string; 179 | }; 180 | 181 | export type tokenType = { 182 | str: string; 183 | token: string; 184 | }; 185 | 186 | const Captcha = forwardRef(InternalCaptcha) as (( 187 | props: PropsWithChildren & { ref?: Ref }, 188 | ) => ReactElement) & { displayName?: string }; 189 | 190 | if (process.env.NODE_ENV !== 'production') { 191 | Captcha.displayName = 'RotateCaptch'; 192 | } 193 | 194 | export default Captcha; -------------------------------------------------------------------------------- /src/assets/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --captcha-canvas-opacity: .4; 3 | --captcha-control-x: translateX(0px); 4 | --captcha-mask-bar-color: rgba(0, 0, 0, 0.6); 5 | --captcha-modal-background: rgba(255, 255, 255, 1); 6 | --captcha-modal-color: rgba(31, 31, 31, 1); 7 | --captcha-modal-title-color: rgba(184, 184, 184, 1); 8 | --captcha-progress-bar-color: rgba(0, 0, 0, 0.2); 9 | --captcha-size-control: 275px; 10 | --captcha-size-img: 152px; 11 | --captcha-size-img-background: rgba(245, 245, 245, 1); 12 | --captcha-size-img-margin: 28px; 13 | --captcha-size-width: 305px; 14 | --captcha-theme: rgba(0, 119, 255, 1); 15 | --captcha-theme-active: rgba(0, 105, 224, 1); 16 | --captcha-theme-color: rgba(255, 255, 255, 1); 17 | --captcha-theme-disabled: rgba(166, 180, 196, 1); 18 | --captcha-theme-disabled-color: rgba(222, 222, 222, 1); 19 | --captcha-theme-hover: rgba(0, 109, 235, 1); 20 | } 21 | .rotate-captcha[theme-mode="dark"] { 22 | --captcha-canvas-opacity: .6; 23 | --captcha-mask-bar-color: rgba(0, 0, 0, 0.8); 24 | --captcha-modal-background: rgba(22, 27, 34, 1); 25 | --captcha-modal-color: rgba(237, 245, 252, 1); 26 | --captcha-modal-title-color: rgba(117, 129, 141, 1); 27 | --captcha-size-img-background: rgba(0, 0, 0, 1); 28 | --captcha-theme: rgba(21, 107, 205, 1); 29 | --captcha-theme-disabled: rgba(116, 136, 159, 1); 30 | --captcha-theme-disabled-color: rgb(204, 204, 204, 1); 31 | } 32 | body:has(.rotate-captcha) { 33 | overflow: hidden; 34 | } 35 | .rotate-captcha { 36 | bottom: 0; 37 | font-size: 16px; 38 | left: 0; 39 | position: fixed; 40 | right: 0; 41 | text-align: center; 42 | top: 0; 43 | user-select: none; 44 | &::before { 45 | content: '\200B'; 46 | display: inline-block; 47 | height: 100%; 48 | vertical-align: middle; 49 | } 50 | .captcha { 51 | padding: 20px 15px 25px; 52 | position: relative; 53 | text-align: center; 54 | width: var(--captcha-size-width); 55 | } 56 | .captcha-control { 57 | height: 50px; 58 | margin: 0 auto; 59 | position: relative; 60 | width: var(--captcha-size-control); 61 | } 62 | .captcha-control-wrap, 63 | .captcha-control-button { 64 | border-radius: 100px; 65 | box-sizing: border-box; 66 | height: 100%; 67 | position: absolute; 68 | top: 0; 69 | } 70 | .captcha-control-button { 71 | background-color: var(--captcha-theme); 72 | box-shadow: 0 2px 6px 0 rgba(0, 0, 0, .4); 73 | cursor: pointer; 74 | position: absolute; 75 | transform: var(--captcha-control-x); 76 | width: 50px; 77 | color: var(--captcha-theme-color); 78 | &:hover { 79 | background-color: var(--captcha-theme-hover); 80 | } 81 | &:active { 82 | background-color: var(--captcha-theme-active); 83 | } 84 | &.disabled { 85 | background-color: var(--captcha-theme-disabled); 86 | color: var(--captcha-theme-disabled-color); 87 | cursor: not-allowed; 88 | } 89 | } 90 | .captcha-control-button svg { 91 | fill: currentColor; 92 | background-repeat: no-repeat; 93 | background-size: 100% 100%; 94 | height: 28px; 95 | left: 50%; 96 | margin-left: -14px; 97 | margin-top: -14px; 98 | position: absolute; 99 | top: 50%; 100 | width: 28px; 101 | } 102 | .captcha-control-wrap { 103 | background: var(--captcha-size-img-background); 104 | left: 0; 105 | overflow: hidden; 106 | width: 100%; 107 | } 108 | .captcha-coordinate { 109 | display: none; 110 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAcoAAAHLBAMAAAC67sVKAAAAG1BMVEUAAAD////////////////////////////////rTT7CAAAACXRSTlMAzGcNwPRatBpQE/jnAAACVElEQVR42u3asU3DUBSG0ShZgBsxAO9NkMILBCagSI+yAg0lo1PYCZZoiW3de05DiT7Br2sHdjsA8viuEHn4rFC5P1aoHJ5PBSpbvBSY5TneCswyosAwh4gCw2wR+Yd5OEfkH+Y+osAwh4gCw2wR+Yd5OEfkH+Y+osAwh7Ey+TDHWSYf5jTL5MOcZpl8mMOtMvUwb7NMPcz7LFMP8z7L1MMcfisTD/N3lomHOZtl4mHOZpl4mMO8Mu0w57PMO8xr7733iGPvvfeP1K8lEU+7/GpUAoB7qVKlSpUqAcC9VKlSpUqV3kkAwL1UqVKlSpUA4F6qVKlSpUrvJADgXqpUqVKlSgBwL1WqVKlSpXcSAHAvVapUqVIlALiXKlWqVKkSAPBUoFKlSpUqAcC9VKlSpUqVAICnApUqVapUCQDupUqVKlWqBAA8FahUWbTyeqpQ2V5PFSpj/czHa1Ehs0WFzBYVMltUyBwrs2dOlckzb5UrZi5yL1fPXLTynhnjt13sy+Xy/vhv9CczeeWUmb0yvvwsc+0y871M/WDQKkRu4dnHc6x3Eu+X2/usYKGn9ZV/kstUrv3rWuPzWJ+t/5MafyfZAP+7BQDupUqVKlWqBAD3UqVKlSpVAgCeClSqVKlSJQC4lypVqlSpEgDwVKBSpUqVKgHAvVSpUqVKlQCApwKVKlWqVAkA7qVKlSpVqgQA7yQqVapUqVIlALiXKlWqVKkSALyTqFSpUqVKlQDgXqpUqVKlSgDwTqJSpUqVKlUCgHupUqVKlSoBwL1UqVKlSpUqN+YHG9iGwKYOVR0AAAAASUVORK5CYII='); 111 | background-repeat: no-repeat; 112 | background-size: 100% 100%; 113 | height: 100%; 114 | left: 0; 115 | position: absolute; 116 | top: 0; 117 | width: 100%; 118 | } 119 | .captcha-desc { 120 | font-size: 18px; 121 | line-height: 24px; 122 | } 123 | .captcha-image { 124 | margin: var(--captcha-size-img-margin) auto; 125 | overflow: hidden; 126 | position: relative; 127 | } 128 | .captcha-img { 129 | background-color: var(--captcha-size-img-background); 130 | border-radius: 50%; 131 | height: var(--captcha-size-img); 132 | position: relative; 133 | width: var(--captcha-size-img); 134 | canvas { 135 | height: var(--captcha-size-img); 136 | width: var(--captcha-size-img); 137 | } 138 | img { 139 | width: 60px; 140 | height: 60px; 141 | display: block; 142 | } 143 | .captcha-loader { 144 | align-items: center; 145 | display: none; 146 | justify-content: center; 147 | height: 100%; 148 | width: 100%; 149 | color: var(--captcha-theme); 150 | & > svg { 151 | fill: currentColor; 152 | } 153 | } 154 | &.captcha-loading { 155 | canvas { 156 | display: none; 157 | } 158 | .captcha-loader { 159 | display: flex; 160 | } 161 | } 162 | } 163 | .captcha-mask { 164 | background-color: var(--captcha-mask-bar-color); 165 | bottom: 0; 166 | left: 0; 167 | position: fixed; 168 | right: 0; 169 | top: 0; 170 | } 171 | &.on .captcha-mask { 172 | animation: vanishIn .3s; 173 | } 174 | &.off .captcha-mask { 175 | animation: vanishOut .3s; 176 | background-color: rgba(0, 0, 0, 0); 177 | } 178 | .captcha-modal { 179 | backface-visibility: hidden; 180 | background-color: var(--captcha-modal-background); 181 | border-radius: 10px; 182 | color: var(--captcha-modal-color); 183 | display: inline-block; 184 | margin-left: auto; 185 | margin-right: auto; 186 | max-width: 100%; 187 | perspective: 2000px; 188 | position: relative; 189 | vertical-align: middle; 190 | z-index: 1; 191 | &-close, &-close svg { 192 | height: 15px; 193 | width: 15px; 194 | color: var(--captcha-modal-title-color); 195 | fill: currentColor; 196 | } 197 | &-close { 198 | background-color: transparent; 199 | border: none; 200 | cursor: pointer; 201 | padding: 0; 202 | position: absolute; 203 | right: 14px; 204 | top: 10px; 205 | z-index: 1; 206 | } 207 | &-close-disabled { 208 | cursor: not-allowed; 209 | opacity: .3; 210 | } 211 | } 212 | &.on .captcha-modal { 213 | animation: modalIn .3s; 214 | } 215 | &.off .captcha-modal { 216 | animation: modalOut .3s; 217 | opacity: 0; 218 | } 219 | .captcha-reset { 220 | min-width: 76px; 221 | text-align: right; 222 | button { 223 | align-items: center; 224 | background-color: transparent; 225 | border: none; 226 | cursor: pointer; 227 | display: inline-flex; 228 | padding: 0; 229 | } 230 | svg { 231 | display: inline-block; 232 | margin-left: 3px; 233 | &.loading { 234 | animation: rotating 1.1s linear infinite; 235 | } 236 | } 237 | &.disabled button { 238 | cursor: not-allowed; 239 | } 240 | } 241 | .captcha-tips { 242 | align-items: center; 243 | display: flex; 244 | min-width: 0; 245 | position: relative; 246 | svg { 247 | display: inline-block; 248 | margin-right: 3px; 249 | } 250 | .captcha-tooltip-shot { 251 | display: block; 252 | max-width: 100%; 253 | overflow: hidden; 254 | text-overflow: ellipsis; 255 | white-space: nowrap; 256 | } 257 | } 258 | .captcha-title { 259 | color: var(--captcha-modal-title-color); 260 | font-size: 16px; 261 | font-weight: bold; 262 | line-height: 16px; 263 | padding-bottom: 10px; 264 | } 265 | .captcha-tooltips { 266 | align-items: center; 267 | display: flex; 268 | font-size: 14px; 269 | justify-content: space-between; 270 | margin: 15px auto 0; 271 | width: var(--captcha-size-control); 272 | svg { 273 | width: 16px; 274 | height: 16px; 275 | fill: currentColor; 276 | } 277 | } 278 | .captcha-wrap { 279 | align-items: center; 280 | display: flex; 281 | justify-content: center; 282 | } 283 | .tips-success { 284 | color: #082; 285 | } 286 | .tips-fail { 287 | color: #f00; 288 | } 289 | } 290 | /* state */ 291 | .rotate-captcha { 292 | .captcha-status-show { 293 | canvas { 294 | opacity: var(--captcha-canvas-opacity); 295 | } 296 | .captcha-img { 297 | background-color: rgba(0, 0, 0, 1); 298 | } 299 | } 300 | } 301 | .captcha-image { 302 | .captcha-state { 303 | animation: fadeIn 0.3s forwards; 304 | border-radius: 50%; 305 | display: none; 306 | height: 100%; 307 | left: 0; 308 | position: absolute; 309 | transform: translate(0, 0); 310 | top: 0; 311 | width: 100%; 312 | .captcha-state-icon { 313 | height: 100%; 314 | width: 100%; 315 | & > svg { 316 | height: 100%; 317 | width: 100%; 318 | } 319 | } 320 | .captcha-state-icon { 321 | display: none; 322 | } 323 | } 324 | } 325 | .captcha-status-show { 326 | .captcha-image { 327 | .captcha-state, .captcha-state-icon { 328 | display: block; 329 | } 330 | } 331 | } 332 | .captcha-status-4 .captcha-image { 333 | .captcha-state { 334 | animation: none; 335 | transform: none; 336 | .captcha-state-icon { 337 | display: flex; 338 | align-items: center; 339 | justify-content: center; 340 | & > svg { 341 | height: auto; 342 | width: auto; 343 | } 344 | } 345 | } 346 | } 347 | .loading_svg__captcha-spin { 348 | stop-color: var(--captcha-theme); 349 | } 350 | .captcha-status-4 { 351 | .loading_svg__captcha-spin { 352 | stop-color: rgba(255, 255, 255, 1); 353 | } 354 | } 355 | /* success timer progress - @deprecated */ 356 | .captcha { 357 | .captcha-timer-progress-bar { 358 | background-color: var(--captcha-progress-bar-color); 359 | height: 4px; 360 | width: 100%; 361 | display: flex; 362 | position: absolute; 363 | bottom: 0; 364 | left: 0; 365 | cursor: pointer; 366 | &:hover { 367 | transition: width 1.1s linear; 368 | width: 0%; 369 | } 370 | } 371 | .captcha-timer-progress-bar-wrap { 372 | border-radius: 10px; 373 | bottom: 0; 374 | display: none; 375 | height: 100%; 376 | left: 0; 377 | overflow: hidden; 378 | position: absolute; 379 | right: 0; 380 | width: 100%; 381 | } 382 | } 383 | /* animation */ 384 | @keyframes modalIn { 385 | 0% { 386 | -ms-transform: scale(1.185); 387 | transform: scale(1.185); 388 | opacity: 0; 389 | } 390 | 100% { 391 | -ms-transform: scale(1); 392 | transform: scale(1); 393 | opacity: 1; 394 | } 395 | } 396 | @keyframes modalOut { 397 | 0% { 398 | -ms-transform: scale(1); 399 | transform: scale(1); 400 | opacity: 1; 401 | } 402 | 100% { 403 | -ms-transform: scale(1.185); 404 | transform: scale(1.185); 405 | opacity: 0; 406 | } 407 | } 408 | @keyframes tiping { 409 | 0% { 410 | bottom: 100%; 411 | opacity: 0; 412 | } 413 | 100% { 414 | bottom: 150%; 415 | opacity: 1; 416 | } 417 | } 418 | @keyframes reback { 419 | 0% { 420 | transform: var(--captcha-control-x); 421 | } 422 | 100% { 423 | transform: translateX(0); 424 | } 425 | } 426 | @keyframes rotating { 427 | 0% { 428 | transform: rotate(0); 429 | } 430 | 100% { 431 | transform: rotate(360deg); 432 | } 433 | } 434 | @keyframes vanishIn { 435 | 0% { 436 | opacity: 0; 437 | } 438 | 100% { 439 | opacity: 1; 440 | } 441 | } 442 | @keyframes vanishOut { 443 | 0% { 444 | opacity: 1; 445 | } 446 | 100% { 447 | opacity: 0; 448 | } 449 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo-react](https://github.com/cgfeel/react-rotate-captcha/assets/578141/0510ddac-2c95-47f5-a6f4-e0ee8335da3c) 2 | 3 | [![NPM version](https://img.shields.io/npm/v/react-rotate-captcha.svg?style=flat)](https://www.npmjs.com/package/react-rotate-captcha) [![NPM downloads](https://img.shields.io/npm/dm/react-rotate-captcha.svg?style=flat)](https://www.npmjs.com/package/react-rotate-captcha) [![React library](https://img.shields.io/badge/react-libaray-blue)](https://www.npmjs.com/package/react-rotate-captcha) [![React component](https://img.shields.io/badge/react-component-green)](https://www.npmjs.com/package/react-rotate-captcha) [![Typescript](https://img.shields.io/badge/typescript-8A2BE2)](https://www.npmjs.com/package/react-rotate-captcha) [![License](https://img.shields.io/npm/l/react-rotate-captcha)](https://github.com/cgfeel/react-rotate-captcha/blob/main/LICENSE) [![npm bundle size (minzip)](https://img.shields.io/bundlephobia/minzip/react-rotate-captcha)](https://www.npmjs.com/package/react-rotate-captcha) [![npm bundle size (min)](https://img.shields.io/bundlephobia/min/react-rotate-captcha)](https://www.npmjs.com/package/react-rotate-captcha) 4 | 5 | 一个开箱即用的滑动验证码React组件,基于[[isszz/rotate-captcha](https://github.com/isszz/rotate-captcha)]做的二次开发;结合了腾讯防水墙,增加安全策略,查看:[设计思路](#-设计思路-design) 6 | 7 | 后端提供`Laravel`扩展:`levi/laravel-rotate-captcha` [[查看](https://github.com/cgfeel/laravel-rotate-captcha)],可直接使用或参考下方Api接口定制 8 | 9 | image 10 | 11 | ## 🎙️ 演示 (Demo) 12 | 13 | 以下演示全部都一样,分别展示了多主题,多语言、多个唤起方式;在不同的环境下的演示,可根据自己的情况选择一个环境查看演示和演示的代码 14 | 15 | - CodeSondbox:Webpack+TS+React [[查看](https://codesandbox.io/p/devbox/tske-yong-v5-d5kgjr?layout=%257B%2522sidebarPanel%2522%253A%2522EXPLORER%2522%252C%2522rootPanelGroup%2522%253A%257B%2522direction%2522%253A%2522horizontal%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522id%2522%253A%2522ROOT_LAYOUT%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522clpqci2l100073b6lcf1o65qk%2522%252C%2522sizes%2522%253A%255B70%252C30%255D%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522EDITOR%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522id%2522%253A%2522clpqci2l000023b6lh9c8vv90%2522%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522SHELLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522id%2522%253A%2522clpqci2l000043b6ld2blf0sx%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522DEVTOOLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522id%2522%253A%2522clpqci2l000063b6lewuwosa5%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%252C%2522sizes%2522%253A%255B60%252C40%255D%257D%252C%2522tabbedPanels%2522%253A%257B%2522clpqci2l000023b6lh9c8vv90%2522%253A%257B%2522id%2522%253A%2522clpqci2l000023b6lh9c8vv90%2522%252C%2522tabs%2522%253A%255B%255D%257D%252C%2522clpqci2l000063b6lewuwosa5%2522%253A%257B%2522id%2522%253A%2522clpqci2l000063b6lewuwosa5%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpqci2l000053b6lwhnjyn5s%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522TASK_PORT%2522%252C%2522taskId%2522%253A%2522start%2522%252C%2522port%2522%253A3000%252C%2522path%2522%253A%2522%252Fzh-CN%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clpqci2l000053b6lwhnjyn5s%2522%257D%252C%2522clpqci2l000043b6ld2blf0sx%2522%253A%257B%2522id%2522%253A%2522clpqci2l000043b6ld2blf0sx%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpqci2l000033b6l2vrwme3j%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522TASK_LOG%2522%252C%2522taskId%2522%253A%2522start%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clpqci2l000033b6l2vrwme3j%2522%257D%257D%252C%2522showDevtools%2522%253Atrue%252C%2522showShells%2522%253Atrue%252C%2522showSidebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A15%257D)] 16 | - CodeSondbox:Webpack+JS+React [[查看](https://codesandbox.io/p/devbox/react-rotate-captcha-js-react-webpack-ngm77w?layout=%257B%2522sidebarPanel%2522%253A%2522EXPLORER%2522%252C%2522rootPanelGroup%2522%253A%257B%2522direction%2522%253A%2522horizontal%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522id%2522%253A%2522ROOT_LAYOUT%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522clpp92lgn00083b6lcztfa1s7%2522%252C%2522sizes%2522%253A%255B70%252C30%255D%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522EDITOR%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522id%2522%253A%2522clpp92lgm00023b6lox4wiual%2522%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522SHELLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522id%2522%253A%2522clpp92lgm00053b6lk2sv6sks%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522DEVTOOLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522id%2522%253A%2522clpp92lgn00073b6lcb020nkl%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%252C%2522sizes%2522%253A%255B50%252C50%255D%257D%252C%2522tabbedPanels%2522%253A%257B%2522clpp92lgm00023b6lox4wiual%2522%253A%257B%2522id%2522%253A%2522clpp92lgm00023b6lox4wiual%2522%252C%2522tabs%2522%253A%255B%255D%257D%252C%2522clpp92lgn00073b6lcb020nkl%2522%253A%257B%2522id%2522%253A%2522clpp92lgn00073b6lcb020nkl%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpp92lgn00063b6lt7dh3gg9%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522TASK_PORT%2522%252C%2522taskId%2522%253A%2522start%2522%252C%2522port%2522%253A3000%252C%2522path%2522%253A%2522%252Fen-US%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clpp92lgn00063b6lt7dh3gg9%2522%257D%252C%2522clpp92lgm00053b6lk2sv6sks%2522%253A%257B%2522id%2522%253A%2522clpp92lgm00053b6lk2sv6sks%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpp92lgm00033b6l4gn4biw7%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522TASK_LOG%2522%252C%2522taskId%2522%253A%2522start%2522%257D%252C%257B%2522id%2522%253A%2522clpp92lgm00043b6lprj2oc6z%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522TASK_LOG%2522%252C%2522taskId%2522%253A%2522build%2522%257D%252C%257B%2522type%2522%253A%2522TASK_LOG%2522%252C%2522taskId%2522%253A%2522yarn%2520add%2520react-rotate-captcha%2540latest%2522%252C%2522id%2522%253A%2522clpt7p4yv005j3b6ld1k4bd3z%2522%252C%2522mode%2522%253A%2522permanent%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clpp92lgm00033b6l4gn4biw7%2522%257D%257D%252C%2522showDevtools%2522%253Atrue%252C%2522showShells%2522%253Atrue%252C%2522showSidebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A15%257D)] 17 | - CodeSondbox:Vite+TS+React [[查看](https://codesandbox.io/p/devbox/react-rotate-captcha-ts-react-vite-t23lcq?layout=%257B%2522sidebarPanel%2522%253A%2522EXPLORER%2522%252C%2522rootPanelGroup%2522%253A%257B%2522direction%2522%253A%2522horizontal%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522id%2522%253A%2522ROOT_LAYOUT%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522clpqf4taw00073b6lf9ixqjs6%2522%252C%2522sizes%2522%253A%255B70%252C30%255D%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522EDITOR%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522id%2522%253A%2522clpqf4tav00023b6l8tmq733p%2522%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522SHELLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522id%2522%253A%2522clpqf4taw00043b6lpn1xeejf%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522DEVTOOLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522id%2522%253A%2522clpqf4taw00063b6lzext9al2%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%252C%2522sizes%2522%253A%255B50%252C50%255D%257D%252C%2522tabbedPanels%2522%253A%257B%2522clpqf4tav00023b6l8tmq733p%2522%253A%257B%2522id%2522%253A%2522clpqf4tav00023b6l8tmq733p%2522%252C%2522tabs%2522%253A%255B%255D%257D%252C%2522clpqf4taw00063b6lzext9al2%2522%253A%257B%2522id%2522%253A%2522clpqf4taw00063b6lzext9al2%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpqf4taw00053b6lvewjnad1%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522TASK_PORT%2522%252C%2522taskId%2522%253A%2522dev%2522%252C%2522port%2522%253A5173%252C%2522path%2522%253A%2522%252Fzh-CN%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clpqf4taw00053b6lvewjnad1%2522%257D%252C%2522clpqf4taw00043b6lpn1xeejf%2522%253A%257B%2522id%2522%253A%2522clpqf4taw00043b6lpn1xeejf%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpqf4taw00033b6lpioiumrk%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522TASK_LOG%2522%252C%2522taskId%2522%253A%2522dev%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clpqf4taw00033b6lpioiumrk%2522%257D%257D%252C%2522showDevtools%2522%253Atrue%252C%2522showShells%2522%253Atrue%252C%2522showSidebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A15%257D)] 18 | - Stackblitz:ts+react [[查看](https://stackblitz.com/edit/stackblitz-starters-paesfm?file=src%2FApp.tsx)] 19 | 20 | 视频演示: 21 | 22 | https://github.com/cgfeel/laravel-rotate-captcha/assets/578141/afa169d1-05c3-43d6-b7e7-cabaa8c5dbc5 23 | 24 | ## 📦 安装 (Installing) 25 | 26 | Using NPM 27 | 28 | ``` 29 | npm install react-rotate-captcha 30 | ``` 31 | 32 | Using Yarn 33 | 34 | ``` 35 | yarn add react-rotate-captcha 36 | ``` 37 | 38 | Using PNPM 39 | 40 | ``` 41 | pnpm add react-rotate-captcha 42 | ``` 43 | 44 | ## 🔨 使用 (Usage) 45 | 46 | 通过`status`唤起 47 | 48 | ```tsx 49 | import { useState } from 'react'; 50 | import RotateCaptcha from "react-rotate-captcha"; 51 | 52 | function App() { 53 | const [open, setOpen] = useState(true); 54 | return setOpen(false)} /> 55 | } 56 | ``` 57 | 58 | 通过`Instance`唤起 59 | 60 | ```tsx 61 | import RotateCaptcha from "react-rotate-captcha"; 62 | 63 | function Page() { 64 | const captcha = RotateCaptcha.useCaptchaInstance(); 65 | return ( 66 | 67 | ); 68 | } 69 | 70 | function App() { 71 | return ( 72 | 73 | 74 | 75 | ); 76 | } 77 | ``` 78 | 79 | 通过`ref`唤起 80 | 81 | ```tsx 82 | import { useRef } from 'react'; 83 | import RotateCaptcha, { CaptchaInstance } from "react-rotate-captcha"; 84 | 85 | function App() { 86 | const ref = useRef(null); 87 | return ( 88 | 89 | 90 | 91 | ); 92 | } 93 | ``` 94 | 95 | 更多请见下方Api 96 | 97 | ## 📍 参数 (Props) 98 | 99 | ### Captcha Props 100 | 101 | | 参数 | 说明 | 类型 | 默认值 | 102 | | ----- | ----- | ----- | ----- | 103 | | className | 验证浮窗顶层样式名,用于覆盖默认主题样式 | `string` | - | 104 | | close | 自定义关闭按钮,设置`null`屏蔽按钮 | `ReactNode` │ `null` | - | 105 | | lang | 语言,默认提供`en`和`zh_CN`,接受传入`LangType`对象自定义语言 | `LangType` │ `string` | `zh_CN` | 106 | | limit | 试错次数,需要和服务端设置一致 | `number` | 2 | 107 | | mask | 自定义背景层,设置`null`屏蔽背景层 | `ReactNode` │ `null` | - | 108 | | open | `true`打开验证码,否则关闭 | `boolean` | - | 109 | | tips | 自定义底部提示 | `ReactNode` | - | 110 | | theme | 提供两个主体`dark`和`light`,自定义主题通过自定义样式变量 | `string` | `light` | 111 | | zIndex | 浮窗样式层级 | `number` | 999 | 112 | 113 | - 主题样式变量请参考样式源文件:[[查看](https://github.com/cgfeel/react-rotate-captcha/blob/main/src/assets/index.scss)] 114 | - `LangType`类型:[[查看](https://github.com/cgfeel/react-rotate-captcha/blob/main/lib/components/lang.d.ts)] 115 | 116 | ### Captcha Event Props 117 | 118 | 接受5个方法,只有`onClose`和`result`是同步函数,其余全部为异步函数 119 | 120 | | 参数 | 说明 | 参数 | 返回值 | 121 | | ----- | ----- | ----- | ----- | 122 | | get | 初始获取验证码信息 | - | `Promise>` | 123 | | load | 提取`tokenType`中的`str`去换图片,返回图片`URL`路径或图片`base64`字符 | `path: string` | `Promise` | 124 | | onClose | 关闭浮窗触发,以`status`唤起的验证,必须提供`onClose`来关闭 | - | `void` | 125 | | result | 提取正确或错误的票据结果,可选,也可以通过`verify`直接获取结果 | `info: resultType` | `void` | 126 | | verify | 滚动验证,返回票据信息 | `token: string`,`deg: number` | `Promise>` | 127 | 128 | - Captcha数据类型:[[查看](https://github.com/cgfeel/react-rotate-captcha/blob/main/lib/components/Captcha.d.ts)] 129 | - 请通过下方提供的`Api`接口,自行获取验证数据,组件内部不提供数据获取 130 | 131 | ### Captcha Instance 132 | 133 | 通过`ref`或`useCaptchaInstance`返回的`Captcha`实例,接受3个方法,所有方法都返回`void`,具体如下: 134 | 135 | | 参数 | 说明 | 参数 | 136 | | ----- | ----- | ----- | 137 | | close | 关闭浮窗,参数`force`默认值`false`,设为`true`将强制销毁验证浮窗 | `force?: boolean` | 138 | | open | 打开浮窗 | - | 139 | | reload | 重新获取验证码图片 | - | 140 | 141 | - 通过`status`唤起的验证,不支持强制销毁 142 | 143 | ### CaptchaContext 144 | 145 | 通过`useContent`提供上下文`CaptchaContext`,方便自定义提示栏: 146 | 147 | `CurrentlyType: [0|1|2|3|4, string, boolean?]`,验证状态: 148 | 149 | - 状态:0.正确; 1.错误; 2.待获取; 3.待提交; 4.提交中 150 | - 提示信息 151 | - 是否强制停止流程,例如初始信息加载失败 152 | 153 | `LangType`使用的语言:[[查看](https://github.com/cgfeel/react-rotate-captcha/blob/main/lib/components/lang.d.ts)] 154 | 155 | `captcha`,Captcha实例,如果只获取实例建议通过`ref`或`useCaptchaInstance` 156 | 157 | ## 🎯 接口 (Api) 158 | 159 | 这里以开源的`levi/laravel-rotate-captcha`([查看](https://github.com/cgfeel/laravel-rotate-captcha))举例,提供了5个接口([查看](https://github.com/cgfeel/laravel-rotate-captcha#-使用-usage)),请求参数和返回数据如下: 160 | 161 | ### 获取验证码信息 162 | 163 | - URL: `/rotate.captcha` 164 | - method: `GET` 165 | - response: `{ code: 0|1; msg: string; data: { ${str}: string } }` 166 | - res header: `X-Captchatoken: ${token}` 167 | 168 | ### `str`换image 169 | 170 | - URL: `/rotate.captcha/${str}` 171 | - method: `GET` 172 | - response: image url or base64 173 | 174 | ### 验证信息,`token`换`ticket` 175 | 176 | - URL: `/rotate.captcha/verify/${angle}/${token?}` 177 | - method: `GET` 178 | - response: `{ code: 0|1|2; msg: string; data?: { ${sid}: string; ${ticket}: string; } }` 179 | - req header: `X-Captchatoken: ${token}` 180 | 181 | `URL`中或`req header`中的,至少有一处提供`${token?}` 182 | 183 | ### 鉴权测试,请根据实际情况替换鉴权接口 184 | 185 | - URL: `/rotate.captcha/test/action` 186 | - method: `GET|POST` 187 | - response: `{ code: 0|1|2; msg: string; }` 188 | - req header: `X-Captchasid: ${sid}; X-Captchaticket: ${ticket}; X-Captchapolicie?: ${police}` 189 | 190 | 请求可以通过`header`或`POST formData`或`POST raw`提交;安全策略`police`根据服务器配置决定是否提交 191 | 192 | ### 自定义获取验证信息 193 | 194 | 当需要匹配不同尺寸的设备时,或者一些老的设备不支持`webp`的情况下,通过这个接口获取定制的验证图片 195 | 196 | - URL: `/rotate.captcha` 197 | - method: `POST` 198 | - formData: `{ size?: number; output?: 'jpg'|'png'|'webp' }` 199 | - response: `{ code: 0|1; msg: string; data: { ${str}: string } }` 200 | - res header: `X-Captchatoken: ${token}` 201 | 202 | > `code`的状态:0.正常; 1.错误可继续; 2.错误重新开始; 注意:小于0的值为内部保留状态,请勿使用 203 | 204 | ## 📜 组件类型引导 (TypeScript) 205 | 206 | 如果安装后获取不到组件类型,请在`tsconfig.json`的`compilerOptions`添加如下引导: 207 | 208 | ``` 209 | "typeRoots": [ 210 | "./node_modules/@types" 211 | ] 212 | ``` 213 | 214 | ## 🛟 设计思路 (Design) 215 | 216 | 安全策略详细见`laravel-rotate-captcha` [[查看](https://github.com/cgfeel/laravel-rotate-captcha#-策略-policie)],在提供允许的情况下,前端发送`X-Captchapolici`这个`header`,申请对应的权限。 217 | 218 | 高级用法: 219 | 220 | - 验证流程中`ua`实际并不局限使用`User-Agent`,可以通过自定义头部加密拼接增加安全系数 221 | - 除了头部,包括图片路径,都可以在本地通过二次加密`encryption`的方式增加安全系数 222 | 223 | ![New Board](https://github.com/cgfeel/laravel-rotate-captcha/assets/578141/27e82f87-0937-4e23-9e08-395fd9f0adda) 224 | 225 | ## ✂️ 物料 (Material) 226 | 227 | 即时设计的向量稿件,包含组件设计规范:[查看](https://js.design/community?category=detail&type=resource&id=6561674f12aadf8dee1b33c2) 228 | 229 | ![911700882740_ pic](https://github.com/cgfeel/laravel-rotate-captcha/assets/578141/ea1532fa-17e1-4d08-b005-5089f705388c) 230 | 231 | ## 🗓️ 更新日志 (Changelog) 232 | 233 | 具体请查看文档:[更新日志](https://github.com/cgfeel/react-rotate-captcha/blob/main/docs/changelog.md) 234 | 235 | ## 🔗 相关产品 (Product) 236 | 237 | ### `Laravel`扩展 238 | 239 | 后端提供`Laravel`扩展:`levi/laravel-rotate-captcha` [[查看](https://github.com/cgfeel/laravel-rotate-captcha)],可直接使用或参考上方Api接口定制 240 | 241 | ### isszz/rotate-captcha 242 | 243 | 第三方仓库,提供了react和laravel之外的生态扩展 [[点击打开](https://github.com/isszz/rotate-captcha)] 244 | 245 | 包含: 246 | 247 | - 前端:vue、uni-app 248 | - 后端:php、ThinkPHP 249 | 250 | ### MrXujiang/react-slider-vertify 251 | 252 | 基于react实现的滑动验证码组件 [[点击打开](https://github.com/MrXujiang/react-slider-vertify)] 253 | 254 | --------------------------------------------------------------------------------