├── .dumirc.ts ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── babel.config.js ├── commitlint.config.js ├── docs └── index.md ├── package.json ├── rollup.config.mjs ├── src ├── ControlBar │ └── index.tsx ├── Jigsaw │ ├── LoadingBox.tsx │ └── index.tsx ├── SliderButton │ └── index.tsx ├── SliderIcon │ ├── IconArrowRight.tsx │ ├── IconCheck.tsx │ ├── IconImageFill.tsx │ ├── IconLoading.tsx │ ├── IconRefresh.tsx │ ├── IconX.tsx │ ├── SliderIconBase.tsx │ └── index.tsx ├── demos │ ├── actionRef.tsx │ ├── assets │ │ ├── 1bg@2x.jpg │ │ ├── 1puzzle@2x.png │ │ ├── 2bg.png │ │ ├── 2puzzle.png │ │ ├── 3bg.png │ │ ├── 3puzzle.png │ │ └── sunflower.jpg │ ├── basic.tsx │ ├── create-puzzle.tsx │ ├── custom-content.module.less │ ├── custom-content.tsx │ ├── custom-dark.tsx │ ├── custom-height.tsx │ ├── custom-intl.tsx │ ├── custom-style.tsx │ ├── custom-styles.tsx │ ├── dev-button.tsx │ ├── dev-control-bar.tsx │ ├── dev-icon.tsx │ ├── dev-jigsaw.tsx │ ├── error.tsx │ ├── errors.tsx │ ├── float.tsx │ ├── modal.tsx │ ├── request-failed.tsx │ ├── service1.ts │ ├── service2.ts │ ├── service3.ts │ ├── service4.ts │ ├── size.tsx │ ├── size2.tsx │ ├── slider-full-width.tsx │ └── slider.tsx ├── index.tsx ├── interface.ts ├── style │ ├── ControlBar.less │ ├── Jigsaw.less │ ├── SliderButton.less │ ├── SliderIcon.less │ ├── config.less │ ├── index.less │ └── index.ts └── utils.ts ├── stylelint.config.mjs ├── tests ├── __snapshots__ │ └── index.test.tsx.snap ├── fixtures │ └── service1.ts └── index.test.tsx ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.types.json └── typings.d.ts /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | 3 | const isDev = process.env.NODE_ENV === 'development'; 4 | const publicPath = isDev ? '/' : '/rc-slider-captcha/'; 5 | 6 | export default defineConfig({ 7 | themeConfig: { 8 | name: 'rc-slider-captcha', 9 | logo: 'https://www.caijinfeng.com/logo.png', 10 | nav: [], 11 | prefersColor: { 12 | default: 'light', 13 | switch: false 14 | }, 15 | footer: `
16 |
caijf | Copyright © 2022-present
17 |
Powered by dumi
18 |
` 19 | }, 20 | base: publicPath, 21 | publicPath, 22 | favicons: ['https://www.caijinfeng.com/favicon.ico'], 23 | outputPath: 'docs-dist', 24 | analytics: { 25 | ga_v2: 'G-9R6Q9PDGBK' 26 | }, 27 | // headScripts: [ 28 | // { 29 | // src: 'https://cdn.bootcdn.net/ajax/libs/vConsole/3.13.0/vconsole.min.js' 30 | // }, 31 | // { 32 | // content: 'var vConsole = new window.VConsole();' 33 | // } 34 | // ], 35 | styles: [ 36 | `body .dumi-default-doc-layout { 37 | background: white; 38 | } 39 | body .dumi-default-doc-layout > main{ 40 | padding-top: 24px; 41 | } 42 | body .dumi-default-header{ 43 | display: none; 44 | } 45 | body .dumi-default-header-left { 46 | width: auto; 47 | } 48 | body .dumi-default-header-menu-btn{ 49 | display: none; 50 | } 51 | body .dumi-default-doc-layout > main > .dumi-default-doc-layout-toc-wrapper { 52 | top: 52px; 53 | } 54 | @media screen and (max-width: 400px){ 55 | body .dumi-default-previewer-demo{ 56 | padding: 40px 12px; 57 | } 58 | body .dumi-default-doc-layout > main { 59 | padding: 0 12px; 60 | } 61 | }` 62 | ] 63 | }); 64 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /npm-debug.log* 4 | /yarn-error.log 5 | /yarn.lock 6 | /package-lock.json 7 | 8 | # production 9 | /es 10 | /dist 11 | /docs-dist 12 | 13 | # misc 14 | .DS_Store 15 | /coverage 16 | 17 | # umi 18 | .umi 19 | .umi-production 20 | .umi-test 21 | .env.local 22 | 23 | # ide 24 | /.vscode 25 | /.idea 26 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | jest: true 7 | }, 8 | settings: { 9 | react: { 10 | version: 'detect' 11 | } 12 | }, 13 | extends: [ 14 | 'eslint:recommended', 15 | 'plugin:react/recommended', 16 | 'plugin:@typescript-eslint/recommended' 17 | ], 18 | parser: '@typescript-eslint/parser', 19 | parserOptions: { 20 | ecmaFeatures: { 21 | jsx: true 22 | }, 23 | ecmaVersion: 12, 24 | sourceType: 'module' 25 | }, 26 | plugins: ['react', 'react-hooks', '@typescript-eslint'], 27 | rules: { 28 | 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks 29 | 'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies 30 | '@typescript-eslint/no-explicit-any': 0, 31 | '@typescript-eslint/ban-ts-comment': 0 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - docs/** 8 | - src/** 9 | - .dumirc.ts 10 | - .github/** 11 | - package.json 12 | - README.md 13 | 14 | jobs: 15 | build-and-deploy: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | - uses: pnpm/action-setup@v2 21 | with: 22 | version: 8 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version: 16 26 | - name: Install 27 | run: pnpm install 28 | - name: Build 29 | run: pnpm build 30 | - name: Test 31 | run: pnpm test 32 | - name: Build docs 33 | run: pnpm run docs:build 34 | - name: Deploy docs 35 | uses: peaceiris/actions-gh-pages@v3 36 | with: 37 | github_token: ${{ secrets.ACCESS_TOKEN_WORKFLOW }} 38 | publish_dir: docs-dist 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | pnpm-lock.yaml 10 | 11 | # production 12 | /es 13 | /dist 14 | /dist-bak 15 | /docs-dist 16 | /types 17 | 18 | # misc 19 | .DS_Store 20 | /coverage 21 | 22 | # umi 23 | .dumi/tmp 24 | .dumi/tmp-test 25 | .dumi/tmp-production 26 | .env.local 27 | 28 | # ide 29 | /.vscode 30 | /.idea 31 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | CNAME 4 | LICENSE 5 | package.json 6 | package-lock.json 7 | 8 | *.lock 9 | yarn-error.log 10 | *debug.log 11 | 12 | .gitignore 13 | .prettierignore 14 | .eslintignore 15 | .eslintcache 16 | .history 17 | 18 | *.svg 19 | *.png 20 | *.jpg 21 | *.gif 22 | *.bmp 23 | 24 | dist 25 | docs-dist 26 | .umi 27 | .umi-production 28 | .umi-test 29 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | tabWidth: 2, 4 | trailingComma: 'none', 5 | printWidth: 100, 6 | useTabs: false, 7 | semi: true, 8 | bracketSpacing: true, 9 | arrowParens: 'always', 10 | proseWrap: 'never', 11 | plugins: ['prettier-plugin-two-style-order'] 12 | }; 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 caijf 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-slider-captcha 2 | 3 | [![npm][npm]][npm-url] ![GitHub](https://img.shields.io/github/license/caijf/rc-slider-captcha.svg) 4 | 5 | React 滑块验证码组件。 6 | 7 | [查看文档和示例][site] 8 | 9 | [![example](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2d2d8d7dc28d4ad2aa114449ddced512~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp)][site] 10 | 11 | [掘金文章介绍地址](https://juejin.cn/post/7160519128950767652) 12 | 13 | ## 特性 14 | 15 | - 简单易用 16 | - 适配 PC 和移动端 17 | - 兼容 IE11 和现代浏览器 18 | - 使用 TypeScript 开发,提供完整的类型定义文件 19 | 20 | ## 安装 21 | 22 | ```shell 23 | npm install rc-slider-captcha 24 | ``` 25 | 26 | ```shell 27 | yarn add rc-slider-captcha 28 | ``` 29 | 30 | ```shell 31 | pnpm add rc-slider-captcha 32 | ``` 33 | 34 | ## 使用 35 | 36 | ```typescript 37 | import SliderCaptcha from 'rc-slider-captcha'; 38 | 39 | const Demo = () => { 40 | return ( 41 | { 43 | return { 44 | bgUrl: 'background image url', 45 | puzzleUrl: 'puzzle image url' 46 | }; 47 | }} 48 | onVerify={async (data) => { 49 | console.log(data); 50 | // verify data 51 | return Promise.resolve(); 52 | }} 53 | /> 54 | ); 55 | }; 56 | ``` 57 | 58 | ## API 59 | 60 | ```typescript 61 | import SliderCaptcha, { 62 | SliderCaptchaProps, 63 | VerifyParam, 64 | Status, 65 | ActionType 66 | } from 'rc-slider-captcha'; 67 | ``` 68 | 69 | ### SliderCaptcha 70 | 71 | | 参数 | 说明 | 类型 | 默认值 | 72 | | --- | --- | --- | --- | 73 | | request | 请求背景图和拼图 | `() => Promise<{ bgUrl:string; puzzleUrl:string;}>` | - | 74 | | onVerify | 用户操作滑块完成后触发,主要用于验证,返回 `resolve` 表示验证成功,`reject` 表示验证失败。 | `(data: VerifyParam) => Promise` | - | 75 | | mode | 显示模式。`embed` - 嵌入式, `float` - 触发式, `slider` - 只有滑块无拼图。 | `'embed' \| 'float' \| 'slider'` | `'embed'` | 76 | | bgSize | 背景图尺寸 | `{ width: number; height: number; }` | `{ width: 320, height: 160 }` | 77 | | puzzleSize | 拼图尺寸和偏移调整,默认宽度 `60`,高度为背景图高度。 | `{ width: number; height: number; left: number; top: number; }` | `{ width: 60 }` | 78 | | tipText | 提示文本配置 | `{ default: ReactNode; loading: ReactNode; moving: ReactNode; verifying: ReactNode; success: ReactNode; error: ReactNode; errors: ReactNode; loadFailed: ReactNode; }` | - | 79 | | tipIcon | 提示图标配置 | `{ default: ReactNode; loading: ReactNode; error: ReactNode; success: ReactNode; refresh: ReactNode; loadFailed: ReactNode; }` | - | 80 | | actionRef | 常用操作,比如`刷新`。 | `React.MutableRefObject;` | - | 81 | | showRefreshIcon | 显示右上角刷新图标 | `boolean` | `true` | 82 | | limitErrorCount | 限制连续错误次数。当连续错误次数达到限制时,不允许操作滑块和刷新图标,必须手动点击操作条刷新。`0` 表示不限制错误次数。 | `number` | `0` | 83 | | jigsawContent | 拼图区域自定义内容,需要自己定义绝对定位和 zIndex 。 | `ReactNode` | - | 84 | | loadingBoxProps | 拼图区域加载配置,支持 div 属性。 | `{ icon: ReactNode; text: ReactNode }` | - | 85 | | autoRequest | 自动发起请求 | `boolean` | `true` | 86 | | autoRefreshOnError | 验证失败后自动刷新 | `boolean` | `true` | 87 | | errorHoldDuration | 错误停留多少毫秒后自动刷新,仅在 `autoRefreshOnError=true` 时生效。 | `number` | `500` | 88 | | loadingDelay | 设置 `loading` 状态延迟的时间,避免闪烁,单位为毫秒。 | `number` | `0` | 89 | | placement | 浮层位置。仅在 `mode=float` 时生效。 | `'top' \| 'bottom'` | `'top'` | 90 | | precision | 数字精度。为避免内部计算产生精度问题,只对 `onVerify` 方法参数 `x` `y` `sliderOffsetX` 生效。 | `number \| false` | `7` | 91 | | className | 容器类名 | `string` | - | 92 | | style | 容器样式 | `CSSProperties` | - | 93 | | styles | 配置内置模块样式 | `{ panel?: CSSProperties; jigsaw?: CSSProperties; bgImg?: CSSProperties; puzzleImg?: CSSProperties; control?: CSSProperties; indicator?: CSSProperties; }` | - | 94 | 95 | > 连续错误次数说明:当用户操作滑块验证成功后,将重置连续错误次数为 0 。当用户点击限制错误次数操作条刷新时也将错误次数重置为 0 。 96 | 97 | ### VerifyParam 98 | 99 | ```typescript 100 | type VerifyParam = { 101 | x: number; // 拼图 x 轴移动值。(拼图和滑块按钮移动距离不一样,这里指的是计算后的拼图移动距离。) 102 | y: number; // 用户操作按钮或拼图 y 轴移动值。(按下鼠标到释放鼠标 y 轴的差值。) 103 | sliderOffsetX: number; // 滑块 x 轴偏移值。(暂时没有什么场景会用到。) 104 | duration: number; // 操作持续时长,单位毫秒。 105 | trail: [number, number][]; // 移动轨迹 106 | targetType: 'puzzle' | 'button'; // 操作 dom 目标。 puzzle-拼图 button-滑块按钮。 107 | errorCount: number; // 连续错误次数 108 | }; 109 | ``` 110 | 111 | 如果对安全比较重视的,可以通过 `y` `duration` `trail` 等结合算法判断是否人为操作,防止一些非人为操作破解滑块验证码。 112 | 113 | 大部分情况下,只需要将 `x` 传给后端即可(如果背景图和滑块有比例缩放,可能需要自己计算 `x` 乘以缩放比例)。 114 | 115 | ### actionRef 116 | 117 | 提供给外部的操作,便于一些特殊场景自定义。 118 | 119 | ```typescript 120 | export type ActionType = { 121 | refresh: (resetErrorCount?: boolean) => void; // 刷新,参数为是否重置连续错误次数为0 122 | status: Status; // 每次获取返回当前的状态,注意它不是引用值,而是一个静态值。部分场景下配合自定义刷新操作使用。 123 | }; 124 | 125 | export enum Status { 126 | Default = 1, // 默认 127 | Loading, // 加载中 128 | Verify, // 验证中 129 | Success, // 验证成功 130 | Error, // 验证失败 131 | LoadFailed // 加载失败 132 | } 133 | ``` 134 | 135 | ### CSS 变量 136 | 137 | | 变量名 | 说明 | 默认值 | 138 | | --- | --- | --- | 139 | | --rcsc-primary | 主色 | `#1991fa` | 140 | | --rcsc-primary-light | 主色-浅 | `#d1e9fe` | 141 | | --rcsc-error | 错误色 | `#f57a7a` | 142 | | --rcsc-error-light | 错误色-浅 | `#fce1e1` | 143 | | --rcsc-success | 成功色 | `#52ccba` | 144 | | --rcsc-success-light | 成功色-浅 | `#d2f4ef` | 145 | | --rcsc-border-color | 边框色 | `#e4e7eb` | 146 | | --rcsc-bg-color | 背景色 | `#f7f9fa` | 147 | | --rcsc-text-color | 文本色 | `#45494c` | 148 | | --rcsc-button-color | 按钮颜色 | `#676d73` | 149 | | --rcsc-button-hover-color | 鼠标移入时,按钮颜色 | `#ffffff` | 150 | | --rcsc-button-bg-color | 按钮背景颜色 | `#ffffff` | 151 | | --rcsc-panel-border-radius | 图片容器边框圆角 | `2px` | 152 | | --rcsc-control-height | 滑轨高度 | `42px` | 153 | 154 | > \*注意 IE11 不支持 css 变量,如果你的项目需要兼容 IE11,尽量不使用 css 变量改变样式。 155 | 156 | [site]: https://caijf.github.io/rc-slider-captcha/ 157 | [npm]: https://img.shields.io/npm/v/rc-slider-captcha.svg 158 | [npm-url]: https://npmjs.com/package/rc-slider-captcha 159 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | ['@babel/preset-react', { runtime: 'automatic' }], 5 | '@babel/preset-typescript' 6 | ] 7 | }; 8 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | toc: content 3 | --- 4 | 5 | # rc-slider-captcha 6 | 7 | [![npm][npm]][npm-url] ![GitHub](https://img.shields.io/github/license/caijf/rc-slider-captcha.svg) [![GitHub Star][github-star]][github-url] 8 | 9 | React 滑块验证码组件。 10 | 11 | ## 特性 12 | 13 | - 简单易用 14 | - 适配 PC 和移动端 15 | - 兼容 IE11 和现代浏览器 16 | - 使用 TypeScript 开发,提供完整的类型定义文件 17 | 18 | ## 代码演示 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ### 基础用法 37 | 38 | 只需传入 `request` 和 `onVerify` 。 39 | 40 | `request` 异步返回背景图和滑块图。 41 | 42 | `onVerify` 用户滑动停止时触发,用于验证。参数 [VerifyParam](#verifyparam) 一般情况下只需要用到拼图 `x` 轴偏移值。 43 | 44 | 45 | 46 | ### 触发式 47 | 48 | 设置 `mode="float"`。 49 | 50 | 51 | 52 | > **触发式交互说明:** 53 | > 54 | > - PC 端:鼠标移入时显示拼图,移出隐藏拼图。 55 | > - 移动端:触摸滑块显示拼图,停止触摸后,如果有向右滑动过则验证后隐藏拼图,否则隐藏拼图。 56 | > 57 | > **注意,验证成功后,PC 端和移动端都会隐藏拼图,且不再显示。假如后面提交时验证码失效,可以通过手动触发刷新。** 58 | 59 | ### 纯滑块,无拼图 60 | 61 | 设置 `mode="slider"` 无需 `request` 。你也可以自定义宽度、结合移动轨迹,做人机校验识别。 62 | 63 | 64 | 65 | 宽度自适应 66 | 67 | 68 | 69 | ### 手动刷新 70 | 71 | 假如滑动验证成功之后,等一段时间再去提交表单,服务返回验证码失效。这时候可以通过主动刷新滑块验证码,并提示用户重新验证。 72 | 73 | 74 | 75 | ### 自定义尺寸 76 | 77 | **什么情况下需要自定义尺寸?** 78 | 79 | 1. 背景图`宽度`不等于 `320` 或`高度`不等于 `160` 80 | 2. 拼图`宽度`不等于 `60` 或高度不等于背景图高度,需要调整 `left` 、 `top` 81 | 82 | 83 | 84 | 85 | 86 | ### 自定义样式 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 暗色主题 99 | 100 | 101 | 102 | ### 自定义文本 103 | 104 | 105 | 106 | ### 请求失败 107 | 108 | 当图片接口请求失败时,友好显示。 109 | 110 | 111 | 112 | ### 验证失败处理 113 | 114 | 一、验证失败不自动刷新,需要手动点击刷新图标 或 手动调用刷新方法 115 | 116 | 设置 `autoRefreshOnError={false}` 。如果验证失败需要外部手动刷新 或 用户点击刷新图标。 117 | 118 | 119 | 120 | 二、连续验证失败超过限制次数,需要手动点击刷新 121 | 122 | 当连续失败3次后,需要点击滑块控制条才能刷新。 123 | 124 | 125 | 126 | ### 验证成功提示 127 | 128 | 自定义拼图内容,验证成功后显示“多少秒完成,打败了多少用户”。 129 | 130 | 131 | 132 | ### 结合弹窗 133 | 134 | 点击登录按钮显示滑块验证码弹窗,你可以在 `onVerify` 成功之后进行页面跳转或其他操作。 135 | 136 | 137 | 138 | ### 客户端生成拼图 139 | 140 | > 使用 [create-puzzle](https://caijf.github.io/create-puzzle/) 生成背景图和拼图。如果你使用的是 Node.js 做服务端,推荐使用 [node-puzzle](https://github.com/caijf/node-puzzle) 。 141 | 142 | 143 | 144 | ## API 145 | 146 | 147 | 148 | [npm]: https://img.shields.io/npm/v/rc-slider-captcha.svg 149 | [npm-url]: https://npmjs.com/package/rc-slider-captcha 150 | [github-star]: https://img.shields.io/github/stars/caijf/rc-slider-captcha?style=social 151 | [github-url]: https://github.com/caijf/rc-slider-captcha 152 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rc-slider-captcha", 3 | "version": "1.7.2", 4 | "description": "React slider captcha component.", 5 | "keywords": [ 6 | "react", 7 | "component", 8 | "slider-captcha" 9 | ], 10 | "main": "dist/index.cjs.js", 11 | "module": "dist/index.esm.js", 12 | "types": "types/index.d.ts", 13 | "files": [ 14 | "dist", 15 | "types" 16 | ], 17 | "scripts": { 18 | "test": "jest", 19 | "start": "dumi dev", 20 | "docs:build": "dumi build", 21 | "build": "rm -rf dist && rollup -c && npm run build:types", 22 | "build:types": "rm -rf types && tsc -p tsconfig.types.json", 23 | "lint": "npm run lint:js && npm run lint:style", 24 | "lint:js": "eslint --ext .js,.jsx,.ts,.tsx src", 25 | "lint-fix:js": "npm run lint:js -- --fix", 26 | "lint:style": "stylelint src/**/*.less", 27 | "lint-fix:style": "npm run lint:stylelint -- --fix", 28 | "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"", 29 | "prepublishOnly": "npm run build && npm test", 30 | "commit": "cz", 31 | "tsc": "tsc --noEmit", 32 | "prepare": "husky && dumi setup" 33 | }, 34 | "config": { 35 | "commitizen": { 36 | "path": "@commitlint/cz-commitlint" 37 | } 38 | }, 39 | "lint-staged": { 40 | "**/*.{css,less}": "stylelint --fix", 41 | "**/*.{js,jsx,ts,tsx}": "eslint", 42 | "*.{js,jsx,ts,tsx,less,md,json}": "prettier -w" 43 | }, 44 | "repository": { 45 | "type": "git", 46 | "url": "git+https://github.com/caijf/rc-slider-captcha.git" 47 | }, 48 | "author": "caijf", 49 | "license": "MIT", 50 | "bugs": { 51 | "url": "https://github.com/caijf/rc-slider-captcha/issues" 52 | }, 53 | "homepage": "https://github.com/caijf/rc-slider-captcha#readme", 54 | "dependencies": { 55 | "classnames": "^2.5.1", 56 | "rc-hooks": "^3.0.15", 57 | "tslib": "^2.8.1" 58 | }, 59 | "devDependencies": { 60 | "@babel/core": "^7.26.9", 61 | "@babel/preset-env": "^7.26.9", 62 | "@babel/preset-react": "^7.26.3", 63 | "@babel/preset-typescript": "^7.26.0", 64 | "@commitlint/cli": "^19.8.0", 65 | "@commitlint/config-conventional": "^19.8.0", 66 | "@commitlint/cz-commitlint": "^19.8.0", 67 | "@rollup/plugin-commonjs": "^28.0.3", 68 | "@rollup/plugin-node-resolve": "^16.0.0", 69 | "@rollup/plugin-typescript": "^12.1.2", 70 | "@testing-library/jest-dom": "^6.6.3", 71 | "@testing-library/react": "^16.2.0", 72 | "@types/jest": "^29.5.14", 73 | "@types/react": "^18.3.18", 74 | "@types/react-dom": "^18.3.5", 75 | "@typescript-eslint/eslint-plugin": "^7.18.0", 76 | "@typescript-eslint/parser": "^7.18.0", 77 | "antd": "^5.24.3", 78 | "autoprefixer": "^10.4.21", 79 | "babel-jest": "^29.7.0", 80 | "commitizen": "^4.3.1", 81 | "create-puzzle": "^3.0.2", 82 | "doly-icons": "^1.6.0", 83 | "dumi": "^2.4.18", 84 | "eslint": "^8.57.1", 85 | "eslint-plugin-react": "^7.37.4", 86 | "eslint-plugin-react-hooks": "^4.6.2", 87 | "husky": "^9.1.7", 88 | "jest": "^29.7.0", 89 | "jest-environment-jsdom": "^29.7.0", 90 | "less": "^4.2.2", 91 | "lint-staged": "^15.4.3", 92 | "postcss": "^8.5.3", 93 | "postcss-css-variables": "^0.19.0", 94 | "prettier": "^3.5.3", 95 | "prettier-plugin-two-style-order": "^1.0.1", 96 | "react": "^18.3.1", 97 | "react-dom": "^18.3.1", 98 | "rollup": "^4.35.0", 99 | "rollup-plugin-postcss": "^4.0.2", 100 | "stylelint": "^16.15.0", 101 | "stylelint-config-css-modules": "^4.4.0", 102 | "stylelint-config-standard": "^37.0.0", 103 | "stylelint-declaration-block-no-ignored-properties": "^2.8.0", 104 | "stylelint-no-unsupported-browser-features": "^8.0.4", 105 | "typescript": "^5.8.2", 106 | "ut2": "^1.16.0" 107 | }, 108 | "peerDependencies": { 109 | "react": ">=16.9.0" 110 | }, 111 | "browserslist": { 112 | "development": [ 113 | "last 1 version" 114 | ], 115 | "production": [ 116 | "last 1 version", 117 | "> 1%", 118 | "ie 11" 119 | ] 120 | }, 121 | "publishConfig": { 122 | "registry": "https://registry.npmjs.org/" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | import autoprefixer from 'autoprefixer'; 5 | import cssvariables from 'postcss-css-variables'; 6 | import postcss from 'rollup-plugin-postcss'; 7 | 8 | export default { 9 | input: './src/index.tsx', 10 | output: [ 11 | { 12 | file: 'dist/index.cjs.js', 13 | exports: 'named', 14 | format: 'cjs' 15 | }, 16 | { 17 | file: 'dist/index.esm.js', 18 | format: 'es' 19 | } 20 | ], 21 | external: ['react', 'rc-hooks', 'classnames', 'tslib'], 22 | plugins: [ 23 | nodeResolve(), 24 | commonjs(), 25 | typescript({ 26 | tsconfig: './tsconfig.build.json' 27 | }), 28 | postcss({ 29 | inject: true, 30 | extensions: ['.less'], 31 | plugins: [autoprefixer, cssvariables({ preserve: true })] 32 | }) 33 | ] 34 | }; 35 | -------------------------------------------------------------------------------- /src/ControlBar/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { 3 | FC, 4 | Ref, 5 | HTMLAttributes, 6 | ReactNode, 7 | useImperativeHandle, 8 | useMemo, 9 | useRef 10 | } from 'react'; 11 | import SliderButton, { SliderButtonProps } from '../SliderButton'; 12 | import { prefixCls, setStyle } from '../utils'; 13 | import '../style'; 14 | import SliderIcon from '../SliderIcon'; 15 | import { Status } from '../interface'; 16 | 17 | const controlPrefixCls = `${prefixCls}-control`; 18 | const SliderButtonDefaultWidth = 40; 19 | const IndicatorBorderWidth = 2; 20 | 21 | export type TipTextType = { 22 | /** 23 | * @description 默认状态显示内容。 24 | * @default '向右拖动滑块填充拼图' 25 | */ 26 | default: ReactNode; 27 | 28 | /** 29 | * @description 加载中显示内容。 30 | * @default '加载中...' 31 | */ 32 | loading: ReactNode; 33 | 34 | /** 35 | * @description 用户操作滑块移动中显示内容。 36 | * @default null 37 | */ 38 | moving: ReactNode; 39 | 40 | /** 41 | * @description 用户停止操作验证中显示内容。 42 | * @default null 43 | */ 44 | verifying: ReactNode; 45 | 46 | /** 47 | * @description 验证成功显示内容。 48 | * @default null 49 | */ 50 | success: ReactNode; 51 | 52 | /** 53 | * @description 验证失败显示内容。 54 | * @default null 55 | */ 56 | error: ReactNode; 57 | 58 | /** 59 | * @description 验证失败且连续错误次数超出限制显示内容。 60 | * @default 失败过多,点击重试 61 | */ 62 | errors: ReactNode; 63 | 64 | /** 65 | * @description `request` 请求返回失败显示内容。 66 | * @default '加载失败,点击重试' 67 | */ 68 | loadFailed: ReactNode; 69 | }; 70 | 71 | export type TipIconType = { 72 | /** 73 | * @description 默认状态滑轨按钮图标。 74 | * @default 75 | */ 76 | default: ReactNode; 77 | 78 | /** 79 | * @description 加载中滑轨按钮图标。 80 | * @default 81 | */ 82 | loading: ReactNode; 83 | 84 | /** 85 | * @description 验证失败滑轨提示图标。 86 | * @default 87 | */ 88 | error: ReactNode; 89 | 90 | /** 91 | * @description 验证成功滑轨提示图标。 92 | * @default 93 | */ 94 | success: ReactNode; 95 | }; 96 | 97 | export type ControlBarRefType = { 98 | getSliderButtonWidth(force?: boolean): number; 99 | getIndicatorBorderWidth(force?: boolean): number; 100 | getRect(force?: boolean): DOMRect; 101 | updateLeft(left: number): void; 102 | }; 103 | 104 | interface ControlBarProps extends HTMLAttributes { 105 | status?: Status; 106 | isLimitErrors?: boolean; 107 | tipText?: Partial; // 提示文本 108 | tipIcon?: Partial; // 提示图标 109 | sliderButtonProps?: SliderButtonProps; 110 | indicatorProps?: HTMLAttributes; 111 | controlRef?: Ref; 112 | } 113 | 114 | const ControlBar: FC = ({ 115 | status = Status.Default, 116 | isLimitErrors, 117 | tipText: customTipText, 118 | tipIcon: customTipIcon, 119 | sliderButtonProps, 120 | indicatorProps, 121 | controlRef, 122 | ...restProps 123 | }) => { 124 | const wrapperRef = useRef(null); 125 | const sliderButtonRef = useRef(null); 126 | const indicatorRef = useRef(null); 127 | const rectRef = useRef<{ 128 | sliderButtonWidth?: number; 129 | indicatorBorderWidth?: number; 130 | rect?: DOMRect; 131 | }>({}); 132 | 133 | const tipText = useMemo( 134 | () => ({ 135 | default: '向右拖动滑块填充拼图', 136 | loading: '加载中...', 137 | moving: null, 138 | verifying: null, 139 | success: null, 140 | error: null, 141 | errors: ( 142 | <> 143 | 失败过多,点击重试 144 | 145 | ), 146 | loadFailed: '加载失败,点击重试', 147 | ...customTipText 148 | }), 149 | [customTipText] 150 | ); 151 | const tipIcon = useMemo( 152 | () => ({ 153 | default: , 154 | loading: , 155 | error: , 156 | success: , 157 | ...customTipIcon 158 | }), 159 | [customTipIcon] 160 | ); 161 | const statusViewMap = useMemo( 162 | () => ({ 163 | [Status.Default]: [tipText.default, tipIcon.default], 164 | [Status.Loading]: [tipText.loading, tipIcon.default], 165 | [Status.Moving]: [tipText.moving, tipIcon.default], 166 | [Status.Verify]: [tipText.verifying, tipIcon.loading], 167 | [Status.Error]: [tipText.error, tipIcon.error], 168 | [Status.Success]: [tipText.success, tipIcon.success], 169 | [Status.LoadFailed]: [tipText.loadFailed, tipIcon.default] 170 | }), 171 | [tipText, tipIcon] 172 | ); 173 | 174 | const getSliderButtonWidth = (force?: boolean) => { 175 | if (force || typeof rectRef.current.sliderButtonWidth !== 'number') { 176 | rectRef.current.sliderButtonWidth = 177 | sliderButtonRef.current?.clientWidth || SliderButtonDefaultWidth; 178 | } 179 | return rectRef.current.sliderButtonWidth!; 180 | }; 181 | 182 | const getIndicatorBorderWidth = (force?: boolean) => { 183 | if (force || typeof rectRef.current.indicatorBorderWidth !== 'number') { 184 | if (indicatorRef.current) { 185 | const indicatorStyles = window.getComputedStyle(indicatorRef.current); 186 | rectRef.current.indicatorBorderWidth = 187 | parseInt(indicatorStyles.borderLeftWidth) + parseInt(indicatorStyles.borderRightWidth); 188 | } else { 189 | rectRef.current.indicatorBorderWidth = IndicatorBorderWidth; 190 | } 191 | } 192 | return rectRef.current.indicatorBorderWidth!; 193 | }; 194 | 195 | const getRect = (force?: boolean) => { 196 | if (force || !rectRef.current.rect) { 197 | if (wrapperRef.current) { 198 | rectRef.current.rect = wrapperRef.current?.getBoundingClientRect(); 199 | } 200 | } 201 | return rectRef.current.rect!; 202 | }; 203 | 204 | useImperativeHandle( 205 | controlRef, 206 | () => ({ 207 | getSliderButtonWidth, 208 | getIndicatorBorderWidth, 209 | getRect, 210 | updateLeft(left) { 211 | const sliderButtonWidth = getSliderButtonWidth(); 212 | const indicatorBorderWidth = getIndicatorBorderWidth(); 213 | setStyle(sliderButtonRef.current, { left: left + 'px' }); 214 | setStyle(indicatorRef.current, { 215 | width: left + sliderButtonWidth + indicatorBorderWidth + 'px' 216 | }); 217 | } 218 | }), 219 | [] 220 | ); 221 | 222 | const isLoading = status === Status.Loading; 223 | const isMoving = status === Status.Moving; 224 | const isVerify = status === Status.Verify; 225 | const isSuccess = status === Status.Success; 226 | const isError = status === Status.Error; 227 | const isLoadFailed = status === Status.LoadFailed; 228 | 229 | const currentTipText = isLimitErrors ? tipText.errors : statusViewMap[status][0]; 230 | 231 | return ( 232 |
249 |
254 | 264 | {statusViewMap[status][1]} 265 | 266 |
270 | {currentTipText} 271 |
272 |
273 | ); 274 | }; 275 | 276 | export default ControlBar; 277 | -------------------------------------------------------------------------------- /src/Jigsaw/LoadingBox.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | import SliderIcon from '../SliderIcon'; 4 | import { prefixCls } from '../utils'; 5 | import '../style'; 6 | 7 | const currentPrefixCls = `${prefixCls}-loading`; 8 | 9 | export interface LoadingBoxProps extends React.HTMLAttributes { 10 | icon?: React.ReactNode; 11 | text?: React.ReactNode; 12 | } 13 | 14 | const LoadingBox: React.FC = ({ 15 | icon = , 16 | text = '加载中...', 17 | className, 18 | ...restProps 19 | }) => { 20 | return ( 21 |
22 |
{icon}
23 |
{text}
24 |
25 | ); 26 | }; 27 | 28 | export default LoadingBox; 29 | -------------------------------------------------------------------------------- /src/Jigsaw/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HTMLAttributes, 3 | ImgHTMLAttributes, 4 | ReactNode, 5 | Ref, 6 | useImperativeHandle, 7 | useRef 8 | } from 'react'; 9 | import classnames from 'classnames'; 10 | import { Status } from '../interface'; 11 | import LoadingBox, { LoadingBoxProps } from './LoadingBox'; 12 | import { prefixCls, setStyle } from '../utils'; 13 | import '../style'; 14 | import React from 'react'; 15 | import SliderIcon from '../SliderIcon'; 16 | 17 | const jigsawPrefixCls = `${prefixCls}-jigsaw`; 18 | 19 | type SizeType = { 20 | width: number; 21 | height: number; 22 | top: number; 23 | left: number; 24 | }; 25 | 26 | export type JigsawRefType = { 27 | /** 28 | * 更新拼图 `left` 值。 29 | * @param left 样式 `left` 值。 30 | */ 31 | updateLeft(left: number): void; 32 | }; 33 | 34 | // 默认配置 35 | export const defaultConfig = { 36 | bgSize: { 37 | width: 320, 38 | height: 160 39 | }, 40 | puzzleSize: { 41 | width: 60, 42 | left: 0 43 | }, 44 | loadFailedIcon: , 45 | refreshIcon: 46 | }; 47 | 48 | export interface JigsawProps extends HTMLAttributes { 49 | /** 50 | * @description 状态。 51 | */ 52 | status?: Status; 53 | 54 | /** 55 | * @description 背景图片尺寸。 56 | * @default { width: 320, height: 160 } 57 | */ 58 | bgSize?: Partial>; 59 | 60 | /** 61 | * @description 拼图尺寸和偏移调整。 62 | * @default { width: 60, left: 0 } 63 | */ 64 | puzzleSize?: Partial; 65 | 66 | /** 67 | * @description 背景图 url 。 68 | */ 69 | bgUrl?: string; 70 | 71 | /** 72 | * @description 拼图 url 。 73 | */ 74 | puzzleUrl?: string; 75 | 76 | /** 77 | * @description 背景图元素属性。 78 | */ 79 | bgImgProps?: ImgHTMLAttributes; 80 | 81 | /** 82 | * @description 拼图元素属性。 83 | */ 84 | puzzleImgProps?: ImgHTMLAttributes; 85 | 86 | /** 87 | * @description 拼图操作。 88 | */ 89 | jigsawRef?: Ref; 90 | 91 | /** 92 | * @description 拼图区域加载配置,支持 div 属性。 93 | */ 94 | loadingBoxProps?: LoadingBoxProps; 95 | 96 | /** 97 | * @description 拼图区域加载失败图标。 98 | * @default 99 | */ 100 | loadFailedIcon?: ReactNode; 101 | showRefreshIcon?: boolean; // 显示右上角刷新图标 102 | 103 | /** 104 | * @description 拼图区域刷新图标。 105 | * @default 106 | */ 107 | refreshIcon?: ReactNode; 108 | 109 | /** 110 | * @description 禁止刷新。 111 | */ 112 | disabledRefresh?: boolean; 113 | 114 | /** 115 | * @description 点击刷新时触发。 116 | * @returns 117 | */ 118 | onRefresh?: () => void; 119 | } 120 | 121 | const Jigsaw: React.FC = ({ 122 | status, 123 | bgSize = defaultConfig.bgSize, 124 | puzzleSize = defaultConfig.puzzleSize, 125 | bgUrl, 126 | puzzleUrl, 127 | bgImgProps, 128 | puzzleImgProps, 129 | jigsawRef, 130 | 131 | loadingBoxProps, 132 | loadFailedIcon = defaultConfig.loadFailedIcon, 133 | showRefreshIcon = true, 134 | refreshIcon = defaultConfig.refreshIcon, 135 | disabledRefresh, 136 | onRefresh, 137 | 138 | style, 139 | className, 140 | children, 141 | ...restProps 142 | }) => { 143 | const puzzleRef = useRef(null); 144 | 145 | useImperativeHandle(jigsawRef, () => ({ 146 | updateLeft(left) { 147 | setStyle(puzzleRef.current, { left: left + 'px' }); 148 | } 149 | })); 150 | 151 | if (status === Status.Loading) { 152 | return ( 153 | 160 | ); 161 | } 162 | 163 | if (status === Status.LoadFailed || !bgUrl || !puzzleUrl) { 164 | return ( 165 |
166 | {loadFailedIcon} 167 |
168 | ); 169 | } 170 | 171 | const isStop = status === Status.Verify || status === Status.Error || status === Status.Success; // 是否停止滑动 172 | 173 | return ( 174 |
182 | 189 | 197 | {showRefreshIcon && status !== Status.Success && refreshIcon && ( 198 |
{ 203 | if (status !== Status.Verify && !disabledRefresh) { 204 | onRefresh?.(); 205 | } 206 | }} 207 | > 208 | {refreshIcon} 209 |
210 | )} 211 | {children} 212 |
213 | ); 214 | }; 215 | 216 | export default Jigsaw; 217 | -------------------------------------------------------------------------------- /src/SliderButton/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | import { isSupportTouch, prefixCls } from '../utils'; 4 | import '../style'; 5 | 6 | const currentPrefixCls = `${prefixCls}-button`; 7 | 8 | export interface SliderButtonProps extends React.HTMLAttributes { 9 | disabled?: boolean; 10 | active?: boolean; 11 | success?: boolean; 12 | error?: boolean; 13 | verify?: boolean; 14 | buttonRef?: React.RefObject; 15 | } 16 | 17 | const SliderButton: React.FC = ({ 18 | className, 19 | disabled, 20 | active, 21 | success, 22 | error, 23 | verify, 24 | buttonRef, 25 | ...restProps 26 | }) => ( 27 | 39 | ); 40 | 41 | SliderButton.displayName = 'SliderButton'; 42 | 43 | export default SliderButton; 44 | -------------------------------------------------------------------------------- /src/SliderIcon/IconArrowRight.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconArrowRight: React.FC> = (props) => { 4 | return ( 5 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default IconArrowRight; 21 | -------------------------------------------------------------------------------- /src/SliderIcon/IconCheck.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconCheck: React.FC> = (props) => { 4 | return ( 5 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default IconCheck; 19 | -------------------------------------------------------------------------------- /src/SliderIcon/IconImageFill.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconImageFill: React.FC> = (props) => { 4 | return ( 5 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default IconImageFill; 19 | -------------------------------------------------------------------------------- /src/SliderIcon/IconLoading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconLoading: React.FC> = (props) => { 4 | return ( 5 | 6 | 7 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default IconLoading; 37 | -------------------------------------------------------------------------------- /src/SliderIcon/IconRefresh.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconRefresh: React.FC> = (props) => { 4 | return ( 5 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default IconRefresh; 19 | -------------------------------------------------------------------------------- /src/SliderIcon/IconX.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconX: React.FC> = (props) => { 4 | return ( 5 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default IconX; 19 | -------------------------------------------------------------------------------- /src/SliderIcon/SliderIconBase.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | import { prefixCls } from '../utils'; 4 | 5 | const currentPrefixCls = `${prefixCls}-icon`; 6 | 7 | export interface SliderIconBaseProps extends React.HTMLAttributes { 8 | spin?: boolean; 9 | } 10 | 11 | const SliderIconBase: React.FC = ({ className, spin, ...restProps }) => { 12 | return ( 13 | 17 | ); 18 | }; 19 | 20 | export default SliderIconBase; 21 | -------------------------------------------------------------------------------- /src/SliderIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconArrayRight from './IconArrowRight'; 3 | import IconCheck from './IconCheck'; 4 | import IconLoading from './IconLoading'; 5 | import IconRefresh from './IconRefresh'; 6 | import IconX from './IconX'; 7 | import IconImageFill from './IconImageFill'; 8 | import '../style'; 9 | import BaseIcon, { SliderIconBaseProps } from './SliderIconBase'; 10 | 11 | const iconMap = { 12 | arrowRight: , 13 | check: , 14 | loading: , 15 | refresh: , 16 | x: , 17 | imageFill: 18 | }; 19 | 20 | export interface SliderIconProps extends SliderIconBaseProps { 21 | type: keyof typeof iconMap; 22 | } 23 | 24 | const SliderIcon: React.FC = ({ type, ...restProps }) => { 25 | return {iconMap[type]}; 26 | }; 27 | 28 | export default SliderIcon; 29 | -------------------------------------------------------------------------------- /src/demos/actionRef.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha'; 2 | import React, { useRef } from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service1'; 4 | 5 | function Demo() { 6 | const actionRef = useRef(); 7 | 8 | return ( 9 |
10 | { 14 | console.log(data); 15 | return verifyCaptcha(data); 16 | }} 17 | actionRef={actionRef} 18 | /> 19 |
20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | export default Demo; 27 | -------------------------------------------------------------------------------- /src/demos/assets/1bg@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/1bg@2x.jpg -------------------------------------------------------------------------------- /src/demos/assets/1puzzle@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/1puzzle@2x.png -------------------------------------------------------------------------------- /src/demos/assets/2bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/2bg.png -------------------------------------------------------------------------------- /src/demos/assets/2puzzle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/2puzzle.png -------------------------------------------------------------------------------- /src/demos/assets/3bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/3bg.png -------------------------------------------------------------------------------- /src/demos/assets/3puzzle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/3puzzle.png -------------------------------------------------------------------------------- /src/demos/assets/sunflower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caijf/rc-slider-captcha/89d8946d4493d12de5e3fab30bffbee45108cc60/src/demos/assets/sunflower.jpg -------------------------------------------------------------------------------- /src/demos/basic.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha from 'rc-slider-captcha'; 2 | import React from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service1'; 4 | 5 | function Demo() { 6 | return ( 7 | { 10 | console.log(data); 11 | return verifyCaptcha(data); 12 | }} 13 | /> 14 | ); 15 | } 16 | 17 | export default Demo; 18 | -------------------------------------------------------------------------------- /src/demos/create-puzzle.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha'; 2 | import React, { useRef, useState } from 'react'; 3 | import { Options, createPuzzle } from 'create-puzzle'; 4 | import DemoImage from './assets/sunflower.jpg'; 5 | import { Radio } from 'antd'; 6 | import { useUpdateEffect } from 'rc-hooks'; 7 | 8 | function Demo() { 9 | const [format, setFormat] = useState('blob'); 10 | const actionRef = useRef(); 11 | const offsetXRef = useRef(0); // x 轴偏移值 12 | 13 | useUpdateEffect(() => { 14 | actionRef.current?.refresh(); 15 | }, [format]); 16 | 17 | return ( 18 |
19 |
20 | 图片格式: 21 | setFormat(e.target.value)} 26 | > 27 | Blob 28 | Base64 29 | 30 |
31 | 33 | createPuzzle(DemoImage, { 34 | format 35 | }).then((res) => { 36 | offsetXRef.current = res.x; 37 | 38 | return { 39 | bgUrl: res.bgUrl, 40 | puzzleUrl: res.puzzleUrl 41 | }; 42 | }) 43 | } 44 | onVerify={(data) => { 45 | console.log(data); 46 | if (data.x >= offsetXRef.current - 5 && data.x < offsetXRef.current + 5) { 47 | return Promise.resolve(); 48 | } 49 | return Promise.reject(); 50 | }} 51 | bgSize={{ 52 | width: 360 53 | }} 54 | loadingDelay={300} 55 | actionRef={actionRef} 56 | /> 57 |
58 | ); 59 | } 60 | 61 | export default Demo; 62 | -------------------------------------------------------------------------------- /src/demos/custom-content.module.less: -------------------------------------------------------------------------------- 1 | .custom { 2 | .successTip { 3 | position: absolute; 4 | bottom: 0; 5 | left: 0; 6 | z-index: 2; 7 | box-sizing: border-box; 8 | width: 100%; 9 | padding: 2px 5px; 10 | color: #fff; 11 | font-size: 14px; 12 | text-align: center; 13 | background-color: #43a047; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/demos/custom-content.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha from 'rc-slider-captcha'; 2 | import React, { useState } from 'react'; 3 | import styles from './custom-content.module.less'; 4 | import { getCaptcha, verifyCaptcha } from './service1'; 5 | 6 | function Demo() { 7 | const [visible, setVisible] = useState(false); 8 | const [duration, setDuration] = useState(0); 9 | 10 | return ( 11 | { 14 | console.log(data); 15 | return verifyCaptcha(data).then(() => { 16 | setDuration(data.duration); 17 | setVisible(true); 18 | }); 19 | }} 20 | className={styles.custom} 21 | jigsawContent={ 22 | visible && ( 23 |
24 | {Number((duration / 1000).toFixed(2))}秒内完成,打败了xx%用户 25 |
26 | ) 27 | } 28 | /> 29 | ); 30 | } 31 | 32 | export default Demo; 33 | -------------------------------------------------------------------------------- /src/demos/custom-dark.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha from 'rc-slider-captcha'; 2 | import React from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service1'; 4 | 5 | function Demo() { 6 | return ( 7 | { 10 | console.log(data); 11 | return verifyCaptcha(data); 12 | }} 13 | style={{ 14 | '--rcsc-bg-color': '#141414', 15 | '--rcsc-text-color': 'rgba(255, 255, 255, 0.85)', 16 | '--rcsc-border-color': '#424242', 17 | '--rcsc-button-color': 'rgba(255, 255, 255, 0.65)', 18 | '--rcsc-button-bg-color': '#333' 19 | }} 20 | /> 21 | ); 22 | } 23 | 24 | export default Demo; 25 | -------------------------------------------------------------------------------- /src/demos/custom-height.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * debug: true 3 | */ 4 | import SliderCaptcha from 'rc-slider-captcha'; 5 | import React from 'react'; 6 | import { getCaptcha, verifyCaptcha } from './service1'; 7 | 8 | function Demo() { 9 | return ( 10 | <> 11 | { 15 | console.log(data); 16 | return verifyCaptcha(data); 17 | }} 18 | style={{ 19 | '--rcsc-control-height': '30px' 20 | }} 21 | /> 22 |
23 |

底部显示浮层

24 | { 30 | console.log(data); 31 | return verifyCaptcha(data); 32 | }} 33 | /> 34 | 35 | ); 36 | } 37 | 38 | export default Demo; 39 | -------------------------------------------------------------------------------- /src/demos/custom-intl.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha from 'rc-slider-captcha'; 2 | import React from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service1'; 4 | 5 | function Demo() { 6 | return ( 7 | { 10 | console.log(data); 11 | return verifyCaptcha(data); 12 | }} 13 | tipText={{ 14 | default: 'Drag to complete the puzzle', 15 | loading: 'Loading...', 16 | moving: 'Drag right to the puzzle', 17 | verifying: 'Verifying', 18 | error: 'Failed' 19 | }} 20 | loadingBoxProps={{ 21 | text: 'loading' 22 | }} 23 | /> 24 | ); 25 | } 26 | 27 | export default Demo; 28 | -------------------------------------------------------------------------------- /src/demos/custom-style.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRepeat, EmojiFrownFill, EmojiSmileFill, Gem, Heart } from 'doly-icons'; 2 | import SliderCaptcha from 'rc-slider-captcha'; 3 | import React from 'react'; 4 | import { getCaptcha, verifyCaptcha } from './service1'; 5 | 6 | function Demo() { 7 | return ( 8 | { 11 | console.log(data); 12 | return verifyCaptcha(data); 13 | }} 14 | tipIcon={{ 15 | default: , 16 | loading: , 17 | success: , 18 | error: , 19 | refresh: 20 | }} 21 | tipText={{ 22 | default: '向右👉拖动完成拼图', 23 | loading: '👩🏻‍💻🧑‍💻努力中...', 24 | moving: '向右拖动至拼图位置', 25 | verifying: '验证中...', 26 | error: '验证失败' 27 | }} 28 | loadingBoxProps={{ 29 | icon: , 30 | text: "I'm loading" 31 | }} 32 | style={{ 33 | '--rcsc-primary': '#e91e63', 34 | '--rcsc-primary-light': '#f8bbd0', 35 | '--rcsc-panel-border-radius': '10px', 36 | '--rcsc-control-border-radius': '20px' 37 | }} 38 | /> 39 | ); 40 | } 41 | 42 | export default Demo; 43 | -------------------------------------------------------------------------------- /src/demos/custom-styles.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * debug: true 3 | */ 4 | import { ArrowRepeat, EmojiFrownFill, EmojiSmileFill, Gem, Heart } from 'doly-icons'; 5 | import SliderCaptcha from 'rc-slider-captcha'; 6 | import React from 'react'; 7 | import { getCaptcha, verifyCaptcha } from './service1'; 8 | 9 | function Demo() { 10 | return ( 11 | { 14 | console.log(data); 15 | return verifyCaptcha(data); 16 | }} 17 | tipIcon={{ 18 | default: , 19 | loading: , 20 | success: , 21 | error: , 22 | refresh: 23 | }} 24 | tipText={{ 25 | default: '向右👉拖动完成拼图', 26 | loading: '👩🏻‍💻🧑‍💻努力中...', 27 | moving: '向右拖动至拼图位置', 28 | verifying: '验证中...', 29 | error: '验证失败' 30 | }} 31 | loadingBoxProps={{ 32 | icon: , 33 | text: "I'm loading" 34 | }} 35 | style={{ 36 | '--rcsc-primary': '#e91e63', 37 | '--rcsc-primary-light': '#f8bbd0', 38 | '--rcsc-text-color': 'gray', 39 | '--rcsc-panel-border-radius': '10px', 40 | '--rcsc-control-border-radius': '20px' 41 | }} 42 | styles={{ 43 | panel: { fontSize: 14 }, 44 | jigsaw: { fontSize: 16 }, 45 | bgImg: { fontSize: 18 }, 46 | puzzleImg: { fontSize: 20 }, 47 | control: { fontSize: 22 }, 48 | indicator: { fontSize: 24 } 49 | }} 50 | /> 51 | ); 52 | } 53 | 54 | export default Demo; 55 | -------------------------------------------------------------------------------- /src/demos/dev-button.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * debug: true 3 | */ 4 | import * as React from 'react'; 5 | import SliderButton from '../SliderButton'; 6 | import SliderIcon from '../SliderIcon'; 7 | 8 | function Demo() { 9 | return ( 10 | <> 11 | 默认:{' '} 12 | 13 | 14 | 15 |
16 | 禁用:{' '} 17 | 18 | 19 | 20 |
21 | 激活:{' '} 22 | 23 | 24 | 25 |
26 | 验证:{' '} 27 | 28 | 29 | 30 |
31 | 成功:{' '} 32 | 33 | 34 | 35 |
36 | 失败:{' '} 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | export default Demo; 45 | -------------------------------------------------------------------------------- /src/demos/dev-control-bar.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * debug: true 3 | */ 4 | import * as React from 'react'; 5 | import { Status } from '../interface'; 6 | import ControlBar, { ControlBarRefType } from '../ControlBar'; 7 | import { Space } from 'antd'; 8 | 9 | const Demo = () => { 10 | const controlBarRef1 = React.useRef(null); 11 | const controlBarRef2 = React.useRef(null); 12 | const controlBarRef3 = React.useRef(null); 13 | const controlBarRef4 = React.useRef(null); 14 | const controlBarRef5 = React.useRef(null); 15 | const controlBarRef6 = React.useRef(null); 16 | const controlBarRef7 = React.useRef(null); 17 | const controlBarRef8 = React.useRef(null); 18 | 19 | React.useEffect(() => { 20 | controlBarRef4.current?.updateLeft(48); 21 | controlBarRef5.current?.updateLeft(158); 22 | controlBarRef6.current?.updateLeft(158); 23 | controlBarRef7.current?.updateLeft(48); 24 | controlBarRef8.current?.updateLeft(48); 25 | }, []); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default Demo; 42 | -------------------------------------------------------------------------------- /src/demos/dev-icon.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * debug: true 3 | */ 4 | import * as React from 'react'; 5 | import SliderIcon from '../SliderIcon'; 6 | 7 | function Demo() { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | export default Demo; 21 | -------------------------------------------------------------------------------- /src/demos/dev-jigsaw.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * debug: true 3 | */ 4 | import React, { useEffect, useRef } from 'react'; 5 | import Jigsaw, { JigsawRefType } from '../Jigsaw'; 6 | import { Space } from 'antd'; 7 | import ImageBg from './assets/1bg@2x.jpg'; 8 | import ImagePuzzle from './assets/1puzzle@2x.png'; 9 | import { Status } from '../interface'; 10 | 11 | function Demo() { 12 | const jigsawRef1 = useRef(null); 13 | const jigsawRef2 = useRef(null); 14 | const jigsawRef3 = useRef(null); 15 | const jigsawRef4 = useRef(null); 16 | const jigsawRef5 = useRef(null); 17 | const jigsawRef6 = useRef(null); 18 | const jigsawRef7 = useRef(null); 19 | const jigsawRef8 = useRef(null); 20 | 21 | useEffect(() => { 22 | jigsawRef4.current?.updateLeft(90); 23 | jigsawRef5.current?.updateLeft(90); 24 | jigsawRef6.current?.updateLeft(90); 25 | jigsawRef7.current?.updateLeft(60); 26 | jigsawRef8.current?.updateLeft(60); 27 | }, []); 28 | 29 | return ( 30 | 31 |
32 |

加载中:

33 | 39 |
40 |
41 |

加载失败:

42 | 48 |
49 |
50 |

加载成功:

51 | 57 |
58 |
59 |

移动中:

60 | 66 |
67 |
68 |

验证中:

69 | 75 |
76 |
77 |

验证成功:

78 | 84 |
85 |
86 |

验证失败:

87 | 93 |
94 |
95 |

多次验证失败:

96 | 103 |
104 |
105 | ); 106 | } 107 | 108 | export default Demo; 109 | -------------------------------------------------------------------------------- /src/demos/error.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha'; 2 | import React, { useRef } from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service1'; 4 | 5 | function Demo() { 6 | const actionRef = useRef(); 7 | 8 | return ( 9 |
10 | { 13 | console.log(data); 14 | return verifyCaptcha(data); 15 | }} 16 | autoRefreshOnError={false} 17 | actionRef={actionRef} 18 | /> 19 |
20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | export default Demo; 27 | -------------------------------------------------------------------------------- /src/demos/errors.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha'; 2 | import React, { useRef } from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service1'; 4 | 5 | function Demo() { 6 | const actionRef = useRef(); 7 | 8 | return ( 9 |
10 | { 13 | console.log(data); 14 | return verifyCaptcha(data); 15 | }} 16 | limitErrorCount={3} 17 | actionRef={actionRef} 18 | /> 19 |
20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | export default Demo; 27 | -------------------------------------------------------------------------------- /src/demos/float.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha from 'rc-slider-captcha'; 2 | import React from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service1'; 4 | 5 | function Demo() { 6 | return ( 7 | <> 8 | { 12 | console.log(data); 13 | return verifyCaptcha(data); 14 | }} 15 | /> 16 |
17 |

底部显示浮层

18 | { 24 | console.log(data); 25 | return verifyCaptcha(data); 26 | }} 27 | /> 28 | 29 | ); 30 | } 31 | 32 | export default Demo; 33 | -------------------------------------------------------------------------------- /src/demos/modal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal } from 'antd'; 2 | import SliderCaptcha from 'rc-slider-captcha'; 3 | import React, { useState } from 'react'; 4 | import { getCaptcha, verifyCaptcha } from './service1'; 5 | 6 | function Demo() { 7 | const [open, setOpen] = useState(false); 8 | return ( 9 | <> 10 |
11 |
用户名: xxx
12 |
密码: xxx
13 | 16 |
17 | setOpen(false)} 20 | title="安全验证" 21 | footer={false} 22 | centered 23 | width={368} 24 | style={{ maxWidth: '100%' }} 25 | > 26 | { 29 | console.log(data); 30 | return verifyCaptcha(data); 31 | }} 32 | /> 33 | 34 | 35 | ); 36 | } 37 | 38 | export default Demo; 39 | -------------------------------------------------------------------------------- /src/demos/request-failed.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha from 'rc-slider-captcha'; 2 | import React from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service4'; 4 | 5 | function Demo() { 6 | return ( 7 | { 10 | console.log(data); 11 | return verifyCaptcha(data); 12 | }} 13 | // tipText={{ 14 | // loadFailed: '🧑‍💻加载失败,点击重新加载' 15 | // }} 16 | /> 17 | ); 18 | } 19 | 20 | export default Demo; 21 | -------------------------------------------------------------------------------- /src/demos/service1.ts: -------------------------------------------------------------------------------- 1 | import { inRange, sleep } from 'ut2'; 2 | import ImageBg from './assets/1bg@2x.jpg'; 3 | import ImagePuzzle from './assets/1puzzle@2x.png'; 4 | 5 | export const getCaptcha = async () => { 6 | await sleep(); 7 | return { 8 | bgUrl: ImageBg, 9 | puzzleUrl: ImagePuzzle 10 | }; 11 | }; 12 | 13 | export const verifyCaptcha = async (data: { x: number }) => { 14 | await sleep(); 15 | // value is 90±5 16 | if (data && inRange(data.x, 85, 95)) { 17 | return Promise.resolve(); 18 | } 19 | return Promise.reject(); 20 | }; 21 | -------------------------------------------------------------------------------- /src/demos/service2.ts: -------------------------------------------------------------------------------- 1 | import { inRange, sleep } from 'ut2'; 2 | import ImageBg from './assets/2bg.png'; 3 | import ImagePuzzle from './assets/2puzzle.png'; 4 | 5 | export const getCaptcha = async () => { 6 | await sleep(); 7 | return { 8 | bgUrl: ImageBg, 9 | puzzleUrl: ImagePuzzle, 10 | y: 31 11 | }; 12 | }; 13 | 14 | export const verifyCaptcha = async (data: { x: number }) => { 15 | await sleep(); 16 | // value is 190±5 17 | if (data && inRange(data.x, 185, 195)) { 18 | return Promise.resolve(); 19 | } 20 | return Promise.reject(); 21 | }; 22 | -------------------------------------------------------------------------------- /src/demos/service3.ts: -------------------------------------------------------------------------------- 1 | import { inRange, sleep } from 'ut2'; 2 | import ImageBg from './assets/3bg.png'; 3 | import ImagePuzzle from './assets/3puzzle.png'; 4 | 5 | export const getCaptcha = async () => { 6 | await sleep(); 7 | return { 8 | bgUrl: ImageBg, 9 | puzzleUrl: ImagePuzzle 10 | }; 11 | }; 12 | 13 | export const verifyCaptcha = async (data: { x: number }) => { 14 | await sleep(); 15 | // value is 254±5 16 | if (data && inRange(data.x, 249, 259)) { 17 | return Promise.resolve(); 18 | } 19 | return Promise.reject(); 20 | }; 21 | -------------------------------------------------------------------------------- /src/demos/service4.ts: -------------------------------------------------------------------------------- 1 | import { inRange, sleep } from 'ut2'; 2 | import ImageBg from './assets/1bg@2x.jpg'; 3 | import ImagePuzzle from './assets/1puzzle@2x.png'; 4 | 5 | let count = 0; 6 | 7 | export const getCaptcha = async () => { 8 | await sleep(); 9 | 10 | if (++count % 2 !== 0) { 11 | return Promise.reject('request failed'); 12 | } 13 | 14 | return { 15 | bgUrl: ImageBg, 16 | puzzleUrl: ImagePuzzle 17 | }; 18 | }; 19 | 20 | export const verifyCaptcha = async (data: { x: number }) => { 21 | await sleep(); 22 | // value is 90±5 23 | if (data && inRange(data.x, 85, 95)) { 24 | return Promise.resolve(); 25 | } 26 | return Promise.reject(); 27 | }; 28 | -------------------------------------------------------------------------------- /src/demos/size.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha from 'rc-slider-captcha'; 2 | import React, { useState } from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service2'; 4 | 5 | function Demo() { 6 | const [top, setTop] = useState(0); 7 | 8 | return ( 9 | { 11 | return getCaptcha().then((res) => { 12 | setTop(res.y); 13 | return res; 14 | }); 15 | }} 16 | onVerify={(data) => { 17 | console.log(data); 18 | return verifyCaptcha(data); 19 | }} 20 | bgSize={{ 21 | width: 310, 22 | height: 110 23 | }} 24 | puzzleSize={{ 25 | width: 55, 26 | height: 45, 27 | top 28 | }} 29 | /> 30 | ); 31 | } 32 | 33 | export default Demo; 34 | -------------------------------------------------------------------------------- /src/demos/size2.tsx: -------------------------------------------------------------------------------- 1 | import SliderCaptcha from 'rc-slider-captcha'; 2 | import React from 'react'; 3 | import { getCaptcha, verifyCaptcha } from './service3'; 4 | 5 | function Demo() { 6 | return ( 7 | { 11 | console.log(data); 12 | return verifyCaptcha(data); 13 | }} 14 | bgSize={{ 15 | width: 348, 16 | height: 110 17 | }} 18 | puzzleSize={{ 19 | width: 62 20 | }} 21 | /> 22 | ); 23 | } 24 | 25 | export default Demo; 26 | -------------------------------------------------------------------------------- /src/demos/slider-full-width.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import SliderCaptcha, { ActionType, Status } from 'rc-slider-captcha'; 3 | import { useSize } from 'rc-hooks'; 4 | 5 | function Demo() { 6 | const wrapperRef = useRef(null); 7 | const { width } = useSize(wrapperRef); 8 | const actionRef = useRef(); 9 | 10 | const finalWidth = width || 320; 11 | const controlButtonWidth = 40; 12 | const indicatorBorderWidth = 2; 13 | 14 | useEffect(() => { 15 | if (actionRef.current && actionRef.current.status === Status.Success) { 16 | actionRef.current.refresh(); 17 | // reset your slider captcha flag 18 | // ... 19 | } 20 | }, [width]); 21 | 22 | return ( 23 | <> 24 |
25 | { 43 | console.log(data); 44 | if (data.x === finalWidth - controlButtonWidth - indicatorBorderWidth) { 45 | return Promise.resolve(); 46 | } 47 | return Promise.reject(); 48 | }} 49 | actionRef={actionRef} 50 | /> 51 |
52 |
53 | 54 |
55 | 56 | ); 57 | } 58 | 59 | export default Demo; 60 | -------------------------------------------------------------------------------- /src/demos/slider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import SliderCaptcha, { ActionType } from 'rc-slider-captcha'; 3 | 4 | function Demo() { 5 | const actionRef = useRef(); 6 | 7 | const controlBarWidth = 320; 8 | const controlButtonWidth = 40; 9 | const indicatorBorderWidth = 2; 10 | 11 | return ( 12 |
13 | { 28 | console.log(data); 29 | if (data.x === controlBarWidth - controlButtonWidth - indicatorBorderWidth) { 30 | return Promise.resolve(); 31 | } 32 | return Promise.reject(); 33 | }} 34 | actionRef={actionRef} 35 | /> 36 |
37 | 38 |
39 |
40 | ); 41 | } 42 | 43 | export default Demo; 44 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React, { ReactNode, useImperativeHandle, useMemo, useRef } from 'react'; 3 | import { useSafeState, useLatest, useMount } from 'rc-hooks'; 4 | import './style'; 5 | import { SliderButtonProps } from './SliderButton'; 6 | import { 7 | getClient, 8 | isSupportPointer, 9 | isSupportTouch, 10 | normalizeNumber, 11 | prefixCls, 12 | reflow, 13 | setStyle 14 | } from './utils'; 15 | import ControlBar, { ControlBarRefType, TipIconType, TipTextType } from './ControlBar'; 16 | import { Status } from './interface'; 17 | import Jigsaw, { defaultConfig as jigsawDefaultConfig, JigsawProps, JigsawRefType } from './Jigsaw'; 18 | 19 | const events = isSupportPointer 20 | ? { 21 | start: 'pointerdown', 22 | move: 'pointermove', 23 | end: 'pointerup', 24 | cancel: 'pointercancel' 25 | } 26 | : isSupportTouch 27 | ? { 28 | start: 'touchstart', 29 | move: 'touchmove', 30 | end: 'touchend', 31 | cancel: 'touchcancel' 32 | } 33 | : { 34 | start: 'mousedown', 35 | move: 'mousemove', 36 | end: 'mouseup', 37 | cancel: 'touchcancel' 38 | }; 39 | const startEventName = isSupportPointer 40 | ? 'onPointerDown' 41 | : isSupportTouch 42 | ? 'onTouchStart' 43 | : 'onMouseDown'; 44 | 45 | type StyleWithVariable = React.CSSProperties & Partial>; 46 | type StyleProp = StyleWithVariable< 47 | | '--rcsc-primary' 48 | | '--rcsc-primary-light' 49 | | '--rcsc-error' 50 | | '--rcsc-error-light' 51 | | '--rcsc-success' 52 | | '--rcsc-success-light' 53 | | '--rcsc-border-color' 54 | | '--rcsc-bg-color' 55 | | '--rcsc-text-color' 56 | | '--rcsc-button-color' 57 | | '--rcsc-button-hover-color' 58 | | '--rcsc-button-bg-color' 59 | | '--rcsc-panel-border-radius' 60 | | '--rcsc-control-border-radius' 61 | | '--rcsc-control-height' 62 | >; 63 | 64 | type JigsawImages = { 65 | /** 66 | * 背景图 67 | */ 68 | bgUrl: string; 69 | 70 | /** 71 | * 拼图 72 | */ 73 | puzzleUrl: string; 74 | }; 75 | 76 | export enum CurrentTargetType { 77 | Puzzle = 'puzzle', 78 | Button = 'button' 79 | } 80 | 81 | export type VerifyParam = { 82 | /** 83 | * 拼图 x 轴移动值。(这里指的是计算后的拼图移动距离。) 84 | * 85 | * 如果背景图和滑块有比例缩放,可能需要自己计算 x 乘以缩放比例 86 | */ 87 | x: number; 88 | 89 | /** 90 | * 用户操作按钮或拼图 y 轴移动值。(按下鼠标到释放鼠标 y 轴的差值。) 91 | */ 92 | y: number; 93 | 94 | /** 95 | * 滑块 x 轴偏移值。(暂时没有什么场景会用到) 96 | */ 97 | sliderOffsetX: number; 98 | 99 | /** 100 | * 操作持续时长,单位毫秒。 101 | */ 102 | duration: number; 103 | 104 | /** 105 | * 移动轨迹。 106 | */ 107 | trail: [number, number][]; 108 | 109 | /** 110 | * 操作 dom 目标。 `puzzle`-拼图 `button`-滑块按钮。 111 | */ 112 | targetType: CurrentTargetType; 113 | 114 | /** 115 | * 连续错误次数。 116 | */ 117 | errorCount: number; 118 | }; 119 | 120 | export { Status }; 121 | 122 | // 常用操作 123 | export type ActionType = { 124 | /** 125 | * @description 主动刷新。 126 | * @param resetErrorCount 是否重置连续错误次数为 `0`。默认为 `false`。 127 | * @returns 128 | */ 129 | refresh: (resetErrorCount?: boolean) => void; 130 | 131 | /** 132 | * @description 每次获取返回当前的状态,注意它不是引用值,而是一个静态值。部分场景下配合自定义刷新操作使用。 133 | */ 134 | status: Status; 135 | }; 136 | 137 | export type SliderCaptchaProps = Pick< 138 | JigsawProps, 139 | 'bgSize' | 'puzzleSize' | 'showRefreshIcon' | 'loadingBoxProps' 140 | > & { 141 | /** 142 | * @description 限制连续错误次数。当连续错误次数达到限制时,不允许操作滑块和刷新图标,必须手动点击操作条刷新。`0` 表示不限制错误次数。 143 | * @default 0 144 | */ 145 | limitErrorCount?: number; 146 | 147 | /** 148 | * @description 用户操作滑块完成后触发,主要用于验证,返回 `resolve` 表示验证成功,`reject` 表示验证失败。 149 | * @param data 验证参数。 150 | * @returns 151 | */ 152 | onVerify: (data: VerifyParam) => Promise; 153 | 154 | /** 155 | * @description 提示文本配置。 156 | * @default { default: '向右拖动滑块填充拼图', loading: '加载中...', moving: null, verifying: null, success: null, error: null, errors: (<> 失败过多,点击重试), loadFailed: '加载失败,点击重试' } 157 | */ 158 | tipText?: Partial; 159 | 160 | /** 161 | * @description 提示图标配置。 162 | */ 163 | tipIcon?: Partial< 164 | TipIconType & { 165 | /** 166 | * @description 拼图区域刷新图标。 167 | * @default 168 | */ 169 | refresh: JigsawProps['refreshIcon']; 170 | 171 | /** 172 | * @description 拼图区域加载失败图标。 173 | * @default 174 | */ 175 | loadFailed: ReactNode; 176 | } 177 | >; 178 | 179 | /** 180 | * @description 拼图区域刷新图标。 181 | * @deprecated 即将废弃,请使用 `tipIcon.refresh`。 182 | */ 183 | refreshIcon?: ReactNode; 184 | 185 | /** 186 | * @description 自动发起请求。 187 | * @default true 188 | */ 189 | autoRequest?: boolean; 190 | 191 | /** 192 | * @description 验证失败后自动刷新。 193 | * @default true 194 | */ 195 | autoRefreshOnError?: boolean; 196 | 197 | /** 198 | * @description 常用操作,比如`刷新`。 199 | */ 200 | actionRef?: React.MutableRefObject; 201 | 202 | /** 203 | * @description 拼图区域自定义内容,需要自己定义绝对定位和 `zIndex`。如“xx秒完成超过多少用户” 或隐藏刷新图标,自定义右上角内容。 204 | */ 205 | jigsawContent?: React.ReactNode; 206 | 207 | /** 208 | * @description 错误停留多少毫秒后自动刷新。仅在 `autoRefreshOnError=true` 时生效。 209 | * @default 500 210 | */ 211 | errorHoldDuration?: number; 212 | 213 | /** 214 | * @description 设置 `loading` 状态延迟的时间,避免闪烁,单位为毫秒。 215 | * @default 0 216 | */ 217 | loadingDelay?: number; 218 | 219 | /** 220 | * @description 浮层位置。仅在 `mode=float` 时生效。 221 | * @default 'top' 222 | */ 223 | placement?: 'top' | 'bottom'; 224 | 225 | /** 226 | * @description 滑轨按钮属性。 227 | */ 228 | sliderButtonProps?: SliderButtonProps; 229 | 230 | /** 231 | * @description 数字精度。为避免内部计算产生精度问题,只对 `onVerify` 方法参数 `x` `y` `sliderOffsetX` 生效。 232 | * @default 7 233 | */ 234 | precision?: number | false; 235 | 236 | /** 237 | * @description 类名。 238 | */ 239 | className?: string; 240 | 241 | /** 242 | * @description 样式。 243 | */ 244 | style?: StyleProp; 245 | 246 | /** 247 | * @description 配置内置模块样式。 248 | */ 249 | styles?: { 250 | panel?: StyleProp; 251 | jigsaw?: StyleProp; 252 | bgImg?: StyleProp; 253 | puzzleImg?: StyleProp; 254 | control?: StyleProp; 255 | indicator?: StyleProp; 256 | }; 257 | } & ( 258 | | { 259 | /** 260 | * @description 模式。`embed`-嵌入式 `float`-触发式 `slider`-只有滑块无拼图,默认为 `embed`。 261 | */ 262 | mode?: 'embed' | 'float'; 263 | 264 | /** 265 | * @description 请求背景图和拼图。 266 | * @returns 267 | */ 268 | request: () => Promise; 269 | } 270 | | { 271 | /** 272 | * @description 纯滑块不需要传入 `request`。 273 | */ 274 | mode: 'slider'; 275 | 276 | /** 277 | * @description 不要传。 278 | * @returns 279 | */ 280 | request?: () => Promise; 281 | } 282 | ); 283 | const SliderCaptcha: React.FC = ({ 284 | mode: outMode = 'embed', 285 | limitErrorCount = 0, 286 | tipText, 287 | tipIcon, 288 | refreshIcon: customRefreshIcon, 289 | bgSize: outBgSize, 290 | puzzleSize: outPuzzleSize, 291 | request, 292 | autoRequest = true, 293 | onVerify, 294 | autoRefreshOnError = true, 295 | actionRef, 296 | showRefreshIcon = true, 297 | jigsawContent, 298 | errorHoldDuration = 500, 299 | loadingDelay = 0, 300 | placement = 'top', 301 | loadingBoxProps, 302 | sliderButtonProps, 303 | precision = 7, 304 | className, 305 | style, 306 | styles 307 | }) => { 308 | const [jigsawImgs, setJigsawImgs] = useSafeState(); 309 | const [status, setStatus] = useSafeState(Status.Default); 310 | const latestStatus = useLatest(status); // 同步status值,提供给事件方法使用 311 | const controlRef = useRef(null); 312 | const jigsawRef = useRef(null); 313 | 314 | // dom ref 315 | const panelRef = useRef(null); 316 | 317 | // config 318 | const mode = useMemo( 319 | () => (outMode === 'float' || outMode === 'slider' ? outMode : 'embed'), 320 | [outMode] 321 | ); 322 | const refreshIcon = useMemo(() => { 323 | if (customRefreshIcon !== undefined) { 324 | return customRefreshIcon; 325 | } 326 | if (tipIcon?.refresh !== undefined) { 327 | return tipIcon.refresh; 328 | } 329 | }, [customRefreshIcon, tipIcon]); 330 | const bgSize = useMemo(() => ({ ...jigsawDefaultConfig.bgSize, ...outBgSize }), [outBgSize]); 331 | const puzzleSize = useMemo( 332 | () => ({ ...jigsawDefaultConfig.puzzleSize, ...outPuzzleSize }), 333 | [outPuzzleSize] 334 | ); 335 | const placementPos = useMemo(() => (placement === 'bottom' ? 'top' : 'bottom'), [placement]); 336 | 337 | const internalRef = useRef({ 338 | isPressed: false, // 标识是否按下 339 | trail: [] as VerifyParam['trail'], // 移动轨迹 340 | errorCount: 0, // 连续错误次数 341 | startInfo: { x: 0, y: 0, timestamp: 0 }, // 鼠标按下或触摸开始信息 342 | currentTargetType: CurrentTargetType.Button, // 当前触发事件的节点,拼图或按钮 343 | 344 | floatTransitionTimer: null as any, // 触发式渐变过渡效果定时器 345 | floatDelayShowTimer: null as any, // 触发式鼠标移入定时器 346 | floatDelayHideTimer: null as any, // 触发式鼠标移出定时器 347 | refreshTimer: null as any, // 自动刷新的定时器 348 | loadingTimer: null as any, // 延迟加载状态定时器 349 | 350 | sliderButtonWidth: 40, // 滑块按钮宽度 351 | indicatorBorderWidth: 2, // 滑轨边框宽度 352 | ratio: 1, // 当滑块或拼图为触发事件的焦点时,两者的变换比例 353 | buttonMaxDistance: 0, // 按钮最大可移动距离 354 | puzzleMaxDistance: 0 // 拼图最大可移动距离 355 | }); 356 | 357 | const modeIsSlider = mode === 'slider'; // 单滑轨,无图片 358 | const hasLoadingDelay = typeof loadingDelay === 'number' && loadingDelay > 0; // 延迟加载状态 359 | 360 | const isLimitErrors = 361 | status === Status.Error && 362 | limitErrorCount > 0 && 363 | internalRef.current.errorCount >= limitErrorCount; // 是否超过限制错误次数 364 | 365 | // 更新最大可移动距离 366 | const updateMaxDistance = () => { 367 | internalRef.current.buttonMaxDistance = 368 | bgSize.width - 369 | internalRef.current.sliderButtonWidth - 370 | internalRef.current.indicatorBorderWidth; 371 | internalRef.current.puzzleMaxDistance = bgSize.width - puzzleSize.width - puzzleSize.left; 372 | }; 373 | 374 | const getControlHeight = () => { 375 | return controlRef.current?.getRect(true).height || 42; 376 | }; 377 | 378 | // 获取背景图和拼图 379 | const getJigsawImages = async () => { 380 | if (modeIsSlider) return; 381 | if (request) { 382 | if (hasLoadingDelay) { 383 | internalRef.current.loadingTimer = setTimeout(() => { 384 | setStatus(Status.Loading); 385 | }, loadingDelay); 386 | } else { 387 | setStatus(Status.Loading); 388 | } 389 | 390 | try { 391 | const result = await request(); 392 | 393 | if (hasLoadingDelay) { 394 | clearTimeout(internalRef.current.loadingTimer); 395 | } 396 | 397 | setJigsawImgs(result); 398 | setStatus(Status.Default); 399 | } catch (err) { 400 | // console.error(err); 401 | if (hasLoadingDelay) { 402 | clearTimeout(internalRef.current.loadingTimer); 403 | } 404 | setStatus(Status.LoadFailed); 405 | } 406 | } 407 | }; 408 | 409 | // 触发式下,显示面板 410 | const showPanel = (delay = 300) => { 411 | if (mode !== 'float' || latestStatus.current === Status.Success) { 412 | return; 413 | } 414 | 415 | clearTimeout(internalRef.current.floatTransitionTimer); 416 | clearTimeout(internalRef.current.floatDelayHideTimer); 417 | clearTimeout(internalRef.current.floatDelayShowTimer); 418 | 419 | internalRef.current.floatDelayShowTimer = setTimeout(() => { 420 | setStyle(panelRef.current, { display: 'block' }); 421 | reflow(panelRef.current); 422 | const controlBarHeight = getControlHeight() + 'px'; 423 | setStyle(panelRef.current, { [placementPos]: controlBarHeight, opacity: '1' }); 424 | }, delay); 425 | }; 426 | 427 | // 触发式下,隐藏面板 428 | const hidePanel = (delay = 300) => { 429 | if (mode !== 'float') { 430 | return; 431 | } 432 | 433 | clearTimeout(internalRef.current.floatTransitionTimer); 434 | clearTimeout(internalRef.current.floatDelayHideTimer); 435 | clearTimeout(internalRef.current.floatDelayShowTimer); 436 | 437 | internalRef.current.floatDelayHideTimer = setTimeout(() => { 438 | const controlBarHalfHeight = getControlHeight() / 2 + 'px'; 439 | setStyle(panelRef.current, { [placementPos]: controlBarHalfHeight, opacity: '0' }); 440 | internalRef.current.floatTransitionTimer = setTimeout(() => { 441 | setStyle(panelRef.current, { display: 'none' }); 442 | }, 300); 443 | }, delay); 444 | }; 445 | 446 | // 更新拼图位置 447 | const updatePuzzleLeft = (left: number) => { 448 | if (!modeIsSlider) { 449 | jigsawRef.current?.updateLeft(left); 450 | } 451 | }; 452 | 453 | // 重置状态和元素位置 454 | const reset = () => { 455 | internalRef.current.isPressed = false; 456 | setStatus(Status.Default); 457 | 458 | controlRef.current?.updateLeft(0); 459 | updatePuzzleLeft(puzzleSize.left); 460 | }; 461 | 462 | // 刷新 463 | const refresh = (resetErrorCount = false) => { 464 | // 重置连续错误次数记录 465 | if (resetErrorCount) { 466 | internalRef.current.errorCount = 0; 467 | } 468 | 469 | // 清除延迟调用刷新方法的定时器 470 | clearTimeout(internalRef.current.refreshTimer); 471 | 472 | // 防止连续调用刷新方法,会触发多次请求的问题 473 | if (latestStatus.current === Status.Loading) { 474 | return; 475 | } 476 | 477 | reset(); 478 | getJigsawImages(); 479 | }; 480 | 481 | // 点击滑块操作条,如果连续超过错误次数或请求失败则刷新 482 | const handleClickControl = () => { 483 | if (isLimitErrors || status === Status.LoadFailed) { 484 | refresh(isLimitErrors); 485 | } 486 | }; 487 | 488 | // 鼠标移入显示面板,如果支持touch事件不处理 489 | const handleMouseEnter = () => { 490 | if (isSupportTouch) { 491 | return; 492 | } 493 | showPanel(); 494 | }; 495 | 496 | // 鼠标移出隐藏面板,如果支持touch事件不处理 497 | const handleMouseLeave = () => { 498 | if (isSupportTouch) { 499 | return; 500 | } 501 | hidePanel(); 502 | }; 503 | 504 | const touchstartPuzzle = (e: any) => { 505 | internalRef.current.currentTargetType = CurrentTargetType.Puzzle; 506 | touchstart(e); 507 | }; 508 | const touchstartSliderButton = (e: any) => { 509 | internalRef.current.currentTargetType = CurrentTargetType.Button; 510 | touchstart(e); 511 | }; 512 | 513 | // 鼠标按下或触摸开始 514 | const touchstart = (e: any) => { 515 | if (latestStatus.current !== Status.Default) { 516 | return; 517 | } 518 | 519 | e.preventDefault(); // 防止移动端按下后会选择文本或图片 520 | 521 | const { clientX, clientY } = getClient(e); 522 | 523 | internalRef.current.startInfo = { 524 | x: clientX, 525 | y: clientY, 526 | timestamp: new Date().getTime() 527 | }; 528 | internalRef.current.trail = [[clientX, clientY]]; 529 | 530 | if (controlRef.current) { 531 | internalRef.current.sliderButtonWidth = controlRef.current.getSliderButtonWidth(true); 532 | internalRef.current.indicatorBorderWidth = controlRef.current.getIndicatorBorderWidth(true); 533 | } 534 | updateMaxDistance(); 535 | 536 | // TODO 改动比例,等大版本更新在调整。 537 | // if (modeIsSlider) { 538 | // internalRef.current.ratio = 1; 539 | // } else { 540 | // 最大可移动区间值比例 541 | internalRef.current.ratio = 542 | internalRef.current.puzzleMaxDistance / internalRef.current.buttonMaxDistance; 543 | if (internalRef.current.currentTargetType === CurrentTargetType.Puzzle) { 544 | internalRef.current.ratio = 1 / internalRef.current.ratio; 545 | } 546 | // } 547 | 548 | // 处理移动端-触发式兼容 549 | // 可触屏电脑不支持触摸事件,但是 pointerType 可能为 'touch' 或 'pen' 550 | if (isSupportTouch || e.pointerType === 'pen' || e.pointerType === 'touch') { 551 | showPanel(0); 552 | } 553 | 554 | internalRef.current.isPressed = true; 555 | 556 | document.addEventListener(events.move, touchmove); 557 | document.addEventListener(events.end, touchend); 558 | document.addEventListener(events.cancel, touchend); 559 | }; 560 | 561 | // 鼠标移动 或 触摸移动 562 | const touchmove = (e: any) => { 563 | if (!internalRef.current.isPressed) { 564 | return; 565 | } 566 | 567 | e.preventDefault(); 568 | const { clientX, clientY } = getClient(e); 569 | 570 | let diffX = clientX - internalRef.current.startInfo.x; // 移动距离 571 | internalRef.current.trail.push([clientX, clientY]); // 记录移动轨迹 572 | 573 | if (latestStatus.current !== Status.Moving && diffX > 0) { 574 | setStatus(Status.Moving); 575 | } 576 | 577 | let puzzleLeft = diffX; // 拼图左偏移值 578 | let sliderButtonLeft = diffX; // 滑块按钮左偏移值 579 | 580 | if (internalRef.current.currentTargetType === CurrentTargetType.Puzzle) { 581 | diffX = Math.max(0, Math.min(diffX, internalRef.current.puzzleMaxDistance)); 582 | puzzleLeft = diffX + puzzleSize.left; 583 | sliderButtonLeft = diffX * internalRef.current.ratio; 584 | } else { 585 | diffX = Math.max(0, Math.min(diffX, internalRef.current.buttonMaxDistance)); 586 | sliderButtonLeft = diffX; 587 | puzzleLeft = diffX * internalRef.current.ratio + puzzleSize.left; 588 | } 589 | 590 | controlRef.current?.updateLeft(sliderButtonLeft); 591 | updatePuzzleLeft(puzzleLeft); 592 | }; 593 | 594 | // 鼠标弹起 或 停止触摸 595 | const touchend = (e: any) => { 596 | document.removeEventListener(events.move, touchmove); 597 | document.removeEventListener(events.end, touchend); 598 | document.removeEventListener(events.cancel, touchend); 599 | 600 | if (!internalRef.current.isPressed) { 601 | return; 602 | } 603 | 604 | if (latestStatus.current !== Status.Moving) { 605 | internalRef.current.isPressed = false; 606 | 607 | // 如果是移动端事件,并且是触发式,隐藏浮层 608 | if (isSupportTouch) { 609 | hidePanel(); 610 | } 611 | return; 612 | } 613 | 614 | if (onVerify) { 615 | internalRef.current.isPressed = false; 616 | setStatus(Status.Verify); 617 | 618 | const endTimestamp = new Date().getTime(); 619 | const { clientX, clientY } = getClient(e); 620 | 621 | const diffY = clientY - internalRef.current.startInfo.y; 622 | let diffX = clientX - internalRef.current.startInfo.x; // 拼图移动距离 623 | let sliderOffsetX = diffX; // 滑块偏移值 624 | 625 | if (internalRef.current.currentTargetType === CurrentTargetType.Puzzle) { 626 | diffX = Math.max(0, Math.min(diffX, internalRef.current.puzzleMaxDistance)); 627 | sliderOffsetX = diffX * internalRef.current.ratio; 628 | } else { 629 | diffX = Math.max(0, Math.min(diffX, internalRef.current.buttonMaxDistance)); 630 | sliderOffsetX = diffX; 631 | diffX *= internalRef.current.ratio; 632 | } 633 | 634 | onVerify({ 635 | x: normalizeNumber(diffX, precision), 636 | y: normalizeNumber(diffY, precision), 637 | sliderOffsetX: normalizeNumber(sliderOffsetX, precision), 638 | duration: endTimestamp - internalRef.current.startInfo.timestamp, 639 | trail: internalRef.current.trail, 640 | targetType: internalRef.current.currentTargetType, 641 | errorCount: internalRef.current.errorCount 642 | }) 643 | .then(() => { 644 | internalRef.current.errorCount = 0; 645 | setStatus(Status.Success); 646 | hidePanel(); 647 | }) 648 | .catch(() => { 649 | internalRef.current.errorCount += 1; 650 | setStatus(Status.Error); 651 | 652 | if (isSupportTouch || e.pointerType === 'pen' || e.pointerType === 'touch') { 653 | hidePanel(); 654 | } 655 | 656 | if ( 657 | (limitErrorCount <= 0 || internalRef.current.errorCount < limitErrorCount) && 658 | autoRefreshOnError 659 | ) { 660 | internalRef.current.refreshTimer = setTimeout(() => { 661 | refresh(); 662 | }, errorHoldDuration); 663 | } 664 | }); 665 | } else { 666 | reset(); 667 | } 668 | }; 669 | 670 | useMount(() => { 671 | if (autoRequest) { 672 | getJigsawImages(); 673 | } 674 | }); 675 | 676 | // 提供给外部 677 | useImperativeHandle(actionRef, () => ({ 678 | refresh, 679 | get status() { 680 | return latestStatus.current; 681 | } 682 | })); 683 | 684 | return ( 685 |
693 | {!modeIsSlider && ( 694 |
695 |
699 | 719 | {jigsawContent} 720 | 721 |
722 |
723 | )} 724 | 738 |
739 | ); 740 | }; 741 | 742 | export default SliderCaptcha; 743 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | // 内部状态 2 | export enum Status { 3 | Default = 1, 4 | Loading, 5 | Moving, 6 | Verify, 7 | Success, 8 | Error, 9 | LoadFailed 10 | } 11 | -------------------------------------------------------------------------------- /src/style/ControlBar.less: -------------------------------------------------------------------------------- 1 | @import './config.less'; 2 | 3 | .@{prefixCls}-control { 4 | position: relative; 5 | box-sizing: border-box; 6 | width: 100%; 7 | height: @control-height; 8 | background-color: @bg-color; 9 | /* stylelint-disable-next-line declaration-property-value-no-unknown */ 10 | border: 1px solid @border-color; 11 | border-radius: @control-border-radius; 12 | 13 | &-button { 14 | position: absolute; 15 | top: 0; 16 | left: 0; 17 | z-index: 2; 18 | border-radius: @control-border-radius; 19 | } 20 | 21 | &-indicator { 22 | position: absolute; 23 | top: -1px; 24 | bottom: -1px; 25 | left: -1px; 26 | display: none; 27 | box-sizing: border-box; 28 | width: 0; 29 | background-color: @primary-light; 30 | /* stylelint-disable-next-line declaration-property-value-no-unknown */ 31 | border: 1px solid @primary; 32 | border-radius: @control-border-radius; 33 | } 34 | 35 | &-tips { 36 | position: relative; 37 | z-index: 1; 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | width: 100%; 42 | height: 100%; 43 | color: @text-color; 44 | font-size: 14px; 45 | line-height: 20px; 46 | text-align: center; 47 | user-select: none; 48 | } 49 | 50 | // 其他状态 51 | &-moving &-indicator, 52 | &-verify &-indicator, 53 | &-error &-indicator, 54 | &-success &-indicator { 55 | display: block; 56 | } 57 | 58 | &-error &-indicator { 59 | background-color: @error-light; 60 | border-color: @error; 61 | } 62 | 63 | &-success &-indicator { 64 | background-color: @success-light; 65 | border-color: @success; 66 | } 67 | 68 | // 失败多次 69 | &-errors, 70 | &-load-failed { 71 | padding-left: 0; 72 | background-color: @error-light; 73 | border-color: @error; 74 | } 75 | &-errors &-button, 76 | &-errors &-indicator, 77 | &-load-failed &-button, 78 | &-load-failed &-indicator { 79 | display: none; 80 | } 81 | &-errors &-tips, 82 | &-load-failed &-tips { 83 | color: @error; 84 | cursor: pointer; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/style/Jigsaw.less: -------------------------------------------------------------------------------- 1 | @import './config.less'; 2 | 3 | .@{prefixCls} { 4 | &-jigsaw { 5 | position: relative; 6 | background-color: #f7f9fa; 7 | user-select: none; 8 | 9 | img { 10 | position: absolute; 11 | } 12 | &-bg { 13 | width: 100%; 14 | height: 100%; 15 | pointer-events: none; 16 | } 17 | &-puzzle { 18 | width: 60px; 19 | height: 100%; 20 | touch-action: none; 21 | 22 | &:hover { 23 | cursor: grab; 24 | } 25 | &:active { 26 | cursor: grabbing; 27 | } 28 | } 29 | 30 | &-refresh { 31 | position: absolute; 32 | top: 0; 33 | right: 0; 34 | z-index: 2; 35 | padding: 5px; 36 | color: #fff; 37 | font-size: 22px; 38 | line-height: 0; 39 | cursor: pointer; 40 | opacity: 0.75; 41 | transition: opacity 0.2s linear; 42 | 43 | &:hover { 44 | opacity: 1; 45 | } 46 | 47 | &-disabled { 48 | cursor: not-allowed; 49 | 50 | &:hover { 51 | opacity: 0.75; 52 | } 53 | } 54 | } 55 | 56 | &-stop &-puzzle { 57 | pointer-events: none; 58 | 59 | &:hover, 60 | &:active { 61 | cursor: default; 62 | } 63 | } 64 | } 65 | 66 | &-loading { 67 | display: flex; 68 | flex-direction: column; 69 | align-items: center; 70 | justify-content: center; 71 | box-sizing: border-box; 72 | padding: 15px; 73 | color: @text-color; 74 | font-size: 14px; 75 | text-align: center; 76 | background-color: @bg-color; 77 | 78 | &-icon { 79 | font-size: 30px; 80 | } 81 | 82 | &-text { 83 | margin-top: 5px; 84 | } 85 | } 86 | 87 | &-load-failed { 88 | display: flex; 89 | flex-direction: column; 90 | align-items: center; 91 | justify-content: center; 92 | box-sizing: border-box; 93 | width: 100%; 94 | height: 100%; 95 | color: #ccc; 96 | font-size: 85px; 97 | background-color: @bg-color; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/style/SliderButton.less: -------------------------------------------------------------------------------- 1 | @import './config.less'; 2 | 3 | .@{prefixCls}-button { 4 | display: inline-block; 5 | display: inline-flex; 6 | align-items: center; 7 | justify-content: center; 8 | box-sizing: border-box; 9 | width: 40px; 10 | height: 100%; 11 | padding: 5px 0; 12 | color: @button-color; 13 | font-size: 22px; 14 | line-height: 1; 15 | background-color: @button-bg-color; 16 | box-shadow: 0 0 3px rgba(0, 0, 0, 0.3); 17 | cursor: grab; 18 | transition-timing-function: linear; 19 | transition-duration: 0.2s; 20 | transition-property: background-color, color; 21 | touch-action: none; 22 | 23 | // -pc 用于区分PC端和移动端,移动端不需要移入样式 24 | &-active, 25 | &-verify, 26 | &-pc:hover, 27 | &:active { 28 | color: @button-hover-color; 29 | background-color: @primary; 30 | } 31 | &-active, 32 | &:active { 33 | cursor: grabbing; 34 | } 35 | 36 | &-verify, 37 | &-verify:active { 38 | cursor: wait; 39 | } 40 | 41 | &-error, 42 | &-error:hover, 43 | &-error:active { 44 | color: @button-hover-color; 45 | background-color: @error; 46 | cursor: default; 47 | } 48 | 49 | &-success, 50 | &-success:hover, 51 | &-success:active { 52 | color: @button-hover-color; 53 | background-color: @success; 54 | cursor: default; 55 | } 56 | 57 | &-disabled, 58 | &-disabled:hover, 59 | &-disabled:active { 60 | color: @button-color; 61 | background-color: @button-bg-color; 62 | cursor: no-drop; 63 | opacity: 0.7; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/style/SliderIcon.less: -------------------------------------------------------------------------------- 1 | @import './config.less'; 2 | 3 | @keyframes slider-icon-animate_spin { 4 | 100% { 5 | transform: rotate(360deg); 6 | } 7 | } 8 | 9 | .@{prefixCls}-icon { 10 | display: inline-block; 11 | display: inline-flex; 12 | align-items: center; 13 | justify-content: center; 14 | font-style: normal; 15 | line-height: 0; 16 | text-align: center; 17 | text-transform: none; 18 | vertical-align: -0.125em; 19 | text-rendering: optimizeLegibility; 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | 23 | & > svg { 24 | line-height: 1; 25 | } 26 | 27 | &-spin { 28 | animation: slider-icon-animate_spin 1s infinite linear; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/style/config.less: -------------------------------------------------------------------------------- 1 | @prefixCls: rc-slider-captcha; 2 | 3 | // theme 4 | // TODO 下个大版本不再兼容ie11,使用颜色计算的css变量值 5 | @primary: var(--rcsc-primary, #1991fa); 6 | @primary-light: var(--rcsc-primary-light, #d1e9fe); 7 | @error: var(--rcsc-error, #f57a7a); 8 | @error-light: var(--rcsc-error-light, #fce1e1); 9 | @success: var(--rcsc-success, #52ccba); 10 | @success-light: var(--rcsc-success-light, #d2f4ef); 11 | @border-color: var(--rcsc-border-color, #e4e7eb); 12 | @bg-color: var(--rcsc-bg-color, #f7f9fa); 13 | @text-color: var(--rcsc-text-color, #45494c); 14 | @button-color: var(--rcsc-button-color, #676d73); 15 | @button-bg-color: var(--rcsc-button-bg-color, #fff); 16 | @button-hover-color: var(--rcsc-button-hover-color, #fff); 17 | @panel-border-radius: var(--rcsc-panel-border-radius, 2px); 18 | @control-border-radius: var(--rcsc-control-border-radius, 2px); 19 | @control-height: var(--rcsc-control-height, 42px); 20 | -------------------------------------------------------------------------------- /src/style/index.less: -------------------------------------------------------------------------------- 1 | @import './config.less'; 2 | @import './SliderIcon.less'; 3 | @import './SliderButton.less'; 4 | @import './ControlBar.less'; 5 | @import './Jigsaw.less'; 6 | 7 | .@{prefixCls} { 8 | position: relative; 9 | 10 | & > * { 11 | // iOS中通过Javascript定义的可点击元素,长按就会出现一个灰色背景 12 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 13 | } 14 | 15 | &-panel { 16 | padding-bottom: 15px; 17 | 18 | &-inner { 19 | overflow: hidden; 20 | border-radius: @panel-border-radius; 21 | } 22 | } 23 | 24 | &-float &-panel { 25 | position: absolute; 26 | left: 0; 27 | display: none; 28 | opacity: 0; 29 | transition-timing-function: ease-out; 30 | transition-duration: 0.3s; 31 | transition-property: top, bottom, opacity; 32 | } 33 | 34 | &-float-top &-panel { 35 | bottom: 22px; 36 | } 37 | 38 | &-float-bottom &-panel { 39 | top: 22px; 40 | padding: 15px 0 0; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/style/index.ts: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | // className 前缀 2 | export const prefixCls = 'rc-slider-captcha'; 3 | 4 | // 获取事件触发客户端坐标 5 | export function getClient(e: any) { 6 | let x = 0, 7 | y = 0; 8 | if (typeof e.clientX === 'number' && typeof e.clientY === 'number') { 9 | x = e.clientX; 10 | y = e.clientY; 11 | } else if (e.touches && e.touches[0]) { 12 | x = e.touches[0].clientX; 13 | y = e.touches[0].clientY; 14 | } else if (e.changedTouches && e.changedTouches[0]) { 15 | x = e.changedTouches[0].clientX; 16 | y = e.changedTouches[0].clientY; 17 | } 18 | return { 19 | clientX: x, 20 | clientY: y 21 | }; 22 | } 23 | 24 | // 设置样式 25 | export function setStyle(el: HTMLElement | null, styleObj: Record = {}) { 26 | if (el) { 27 | for (const prop in styleObj) { 28 | el.style[prop as any] = styleObj[prop]; 29 | } 30 | } 31 | } 32 | 33 | // 当前运行环境是否可以使用 dom 34 | export const isBrowser = 35 | typeof window === 'object' && 36 | window && 37 | typeof document === 'object' && 38 | document && 39 | window.document === document && 40 | !!document.addEventListener; 41 | 42 | // 是否支持指针事件 43 | export const isSupportPointer = isBrowser && 'onpointerdown' in window; 44 | 45 | // 是否支持Touch事件 46 | // 区分移动端和PC端的事件绑定,移动端也会触发 mouseup mousedown 事件 47 | export const isSupportTouch = isBrowser && 'ontouchstart' in window; 48 | 49 | // 触发重绘 50 | export const reflow = (node: HTMLElement | null) => node?.scrollTop; 51 | 52 | // 规整化数字精度 53 | export function normalizeNumber(num: number, precision?: number | false) { 54 | if ( 55 | typeof num === 'number' && 56 | !Number.isNaN(num) && 57 | typeof precision === 'number' && 58 | precision > 0 59 | ) { 60 | return Number(num.toFixed(precision)); 61 | } 62 | return num; 63 | } 64 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: [ 3 | 'stylelint-config-standard', 4 | 'stylelint-config-css-modules', 5 | 'stylelint-no-unsupported-browser-features' 6 | ], 7 | plugins: ['stylelint-declaration-block-no-ignored-properties'], 8 | customSyntax: 'postcss-less', 9 | rules: { 10 | 'plugin/declaration-block-no-ignored-properties': true, 11 | 'no-descending-specificity': null, 12 | 'no-invalid-position-at-import-rule': null, 13 | 'declaration-empty-line-before': null, 14 | 'keyframes-name-pattern': null, 15 | 'custom-property-pattern': null, 16 | 'number-max-precision': 8, 17 | 'alpha-value-notation': 'number', 18 | 'color-function-notation': 'legacy', 19 | 'selector-class-pattern': null, 20 | 'selector-id-pattern': null, 21 | 'font-family-no-missing-generic-family-keyword': null, 22 | 'rule-empty-line-before': null, 23 | 'import-notation': ['string'], 24 | 'value-keyword-case': ['lower', { ignoreKeywords: ['optimizeLegibility'] }], 25 | 'selector-no-vendor-prefix': [ 26 | true, 27 | { ignoreSelectors: ['::-webkit-input-placeholder', '/-moz-.*/'] } 28 | ] 29 | }, 30 | ignoreFiles: ['**/*.js', '**/*.jsx', '**/*.tsx', '**/*.ts'] 31 | }; 32 | -------------------------------------------------------------------------------- /tests/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`snapshot custom config 1`] = ` 4 | 5 |
9 |
12 |
16 |
20 |
23 | define icon 24 |
25 |
28 | define text 29 |
30 |
31 |
32 |
33 |
37 |
41 | 44 | define icon default 45 | 46 |
49 | define loading 50 |
51 |
52 |
53 | 54 | `; 55 | 56 | exports[`snapshot custom config 2`] = ` 57 | 58 |
62 |
65 |
69 |
73 | 79 | 85 |
88 | define icon refresh 89 |
90 |
91 |
92 |
93 |
97 |
101 | 104 | define icon default 105 | 106 |
109 | define default 110 |
111 |
112 |
113 | 114 | `; 115 | 116 | exports[`snapshot default mode 1`] = ` 117 | 118 |
122 |
125 |
129 |
133 |
136 | 139 | 144 | 145 | 155 | 156 | 157 | 161 | 166 | 171 | 176 | 181 | 186 | 191 | 196 | 201 | 206 | 211 | 216 | 217 | 218 | 219 |
220 |
223 | 加载中... 224 |
225 |
226 |
227 |
228 |
231 |
234 | 237 | 240 | 248 | 251 | 254 | 255 | 256 | 257 |
260 | 加载中... 261 |
262 |
263 |
264 | 265 | `; 266 | 267 | exports[`snapshot default mode 2`] = ` 268 | 269 |
273 |
276 |
280 |
284 | 290 | 296 |
299 | 302 | 309 | 312 | 313 | 314 |
315 |
316 |
317 |
318 |
321 |
324 | 327 | 330 | 338 | 341 | 344 | 345 | 346 | 347 |
350 | 向右拖动滑块填充拼图 351 |
352 |
353 |
354 | 355 | `; 356 | 357 | exports[`snapshot default mode 3`] = ` 358 | 359 |
363 |
366 |
370 |
374 | 380 | 386 |
387 |
388 |
389 |
392 |
395 | 398 | 401 | 409 | 412 | 415 | 416 | 417 | 418 |
421 | 向右拖动滑块填充拼图 422 |
423 |
424 |
425 | 426 | `; 427 | 428 | exports[`snapshot float mode 1`] = ` 429 | 430 |
434 |
437 |
441 |
445 |
448 | 451 | 456 | 457 | 467 | 468 | 469 | 473 | 478 | 483 | 488 | 493 | 498 | 503 | 508 | 513 | 518 | 523 | 528 | 529 | 530 | 531 |
532 |
535 | 加载中... 536 |
537 |
538 |
539 |
540 |
543 |
546 | 549 | 552 | 560 | 563 | 566 | 567 | 568 | 569 |
572 | 加载中... 573 |
574 |
575 |
576 | 577 | `; 578 | 579 | exports[`snapshot float mode 2`] = ` 580 | 581 |
585 |
588 |
592 |
596 | 602 | 608 |
611 | 614 | 621 | 624 | 625 | 626 |
627 |
628 |
629 |
630 |
633 |
636 | 639 | 642 | 650 | 653 | 656 | 657 | 658 | 659 |
662 | 向右拖动滑块填充拼图 663 |
664 |
665 |
666 | 667 | `; 668 | 669 | exports[`snapshot float mode 3`] = ` 670 | 671 |
675 |
678 |
682 |
686 | 692 | 698 |
701 | 704 | 711 | 714 | 715 | 716 |
717 |
718 |
719 |
720 |
723 |
726 | 729 | 732 | 740 | 743 | 746 | 747 | 748 | 749 |
752 | 向右拖动滑块填充拼图 753 |
754 |
755 |
756 | 757 | `; 758 | 759 | exports[`snapshot slider mode 1`] = ` 760 | 761 |
765 |
768 |
771 | 774 | 777 | 785 | 788 | 791 | 792 | 793 | 794 |
797 | 向右拖动滑块填充拼图 798 |
799 |
800 |
801 | 802 | `; 803 | -------------------------------------------------------------------------------- /tests/fixtures/service1.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from 'ut2'; 2 | 3 | export const getCaptcha = async () => { 4 | await sleep(); 5 | return { 6 | bgUrl: 'image1', 7 | puzzleUrl: 'image2' 8 | }; 9 | }; 10 | 11 | export const verifyCaptcha = async (data: { x: number }) => { 12 | await sleep(); 13 | if (data.x && data.x > 87 && data.x < 93) { 14 | return Promise.resolve(); 15 | } 16 | return Promise.reject(); 17 | }; 18 | -------------------------------------------------------------------------------- /tests/index.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import '@testing-library/jest-dom'; 5 | import { render } from '@testing-library/react'; 6 | import React, { act } from 'react'; 7 | import { getCaptcha, verifyCaptcha } from './fixtures/service1'; 8 | import SliderCaptcha from '..'; 9 | 10 | describe('snapshot', () => { 11 | beforeAll(() => { 12 | jest.useFakeTimers(); 13 | }); 14 | afterAll(() => { 15 | jest.useRealTimers(); 16 | }); 17 | 18 | test('default mode', async () => { 19 | const component = render(); 20 | 21 | expect(component.asFragment()).toMatchSnapshot(); 22 | 23 | await act(async () => { 24 | jest.runAllTimers(); 25 | }); 26 | 27 | expect(component.asFragment()).toMatchSnapshot(); 28 | 29 | await act(async () => { 30 | component.rerender( 31 | 32 | ); 33 | }); 34 | 35 | expect(component.asFragment()).toMatchSnapshot(); 36 | }); 37 | 38 | test('float mode', async () => { 39 | const component = render( 40 | 41 | ); 42 | expect(component.asFragment()).toMatchSnapshot(); 43 | 44 | await act(async () => { 45 | jest.runAllTimers(); 46 | }); 47 | 48 | expect(component.asFragment()).toMatchSnapshot(); 49 | 50 | await act(async () => { 51 | component.rerender( 52 | 58 | ); 59 | }); 60 | 61 | expect(component.asFragment()).toMatchSnapshot(); 62 | }); 63 | 64 | test('slider mode', () => { 65 | const component = render(); 66 | expect(component.asFragment()).toMatchSnapshot(); 67 | }); 68 | 69 | test('custom config', async () => { 70 | const component = render( 71 | 111 | ); 112 | 113 | expect(component.asFragment()).toMatchSnapshot(); 114 | 115 | await act(async () => { 116 | jest.runAllTimers(); 117 | }); 118 | 119 | expect(component.asFragment()).toMatchSnapshot(); 120 | }); 121 | }); 122 | 123 | describe('render', () => { 124 | beforeAll(() => { 125 | jest.useFakeTimers(); 126 | }); 127 | afterAll(() => { 128 | jest.useRealTimers(); 129 | }); 130 | 131 | it('render default', async () => { 132 | const { container } = render(); 133 | 134 | // console.log(container.innerHTML); 135 | // console.log(container.querySelector('.rc-slider-captcha-loading')?.getAttribute('style')); 136 | 137 | const wrapperEl = container.querySelector('.rc-slider-captcha') as HTMLElement; 138 | const panelEl = container.querySelector('.rc-slider-captcha-panel') as HTMLElement; 139 | const controlEl = container.querySelector('.rc-slider-captcha-control') as HTMLElement; 140 | 141 | expect(container).toContainElement(wrapperEl); 142 | expect(wrapperEl).toContainElement(panelEl); 143 | expect(wrapperEl).toContainElement(controlEl); 144 | 145 | expect(container.querySelector('.rc-slider-captcha-loading')).toBeInTheDocument(); 146 | expect(container.querySelector('.rc-slider-captcha-jigsaw')).not.toBeInTheDocument(); 147 | expect(controlEl.querySelector('.rc-slider-captcha-control-tips')).toHaveTextContent( 148 | '加载中...' 149 | ); 150 | 151 | await act(async () => { 152 | jest.runAllTimers(); 153 | }); 154 | 155 | expect(container.querySelector('.rc-slider-captcha-loading')).not.toBeInTheDocument(); 156 | expect(container.querySelector('.rc-slider-captcha-jigsaw')).toBeInTheDocument(); 157 | expect(controlEl.querySelector('.rc-slider-captcha-control-tips')).toHaveTextContent( 158 | '向右拖动滑块填充拼图' 159 | ); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["src/demos/**/*", "src/.umi/**/*", "src/.umi*/**/*"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "ES2015", 5 | "moduleResolution": "node", 6 | "jsx": "react", 7 | "esModuleInterop": true, 8 | "types": ["jest", "@testing-library/jest-dom"], 9 | "lib": ["DOM", "ESNext"], 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "baseUrl": "./", 13 | "paths": { 14 | "@@/*": [".dumi/tmp/*"], 15 | "rc-slider-captcha": ["src"] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true, 6 | "declarationDir": "types" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | declare module '*.png'; 4 | declare module '*.jpg'; 5 | declare module '*.jpeg'; 6 | --------------------------------------------------------------------------------