소스 검색

feat: 忘记密码页面获取手机验证码增加人机校验

周玉环 1 주 전
부모
커밋
96753170cd

BIN
xinkeaboard-seller/src/components/SlideVerify/icons/refresh.png


BIN
xinkeaboard-seller/src/components/SlideVerify/icons/right.png


BIN
xinkeaboard-seller/src/components/SlideVerify/icons/success.png


+ 396 - 0
xinkeaboard-seller/src/components/SlideVerify/index.js

@@ -0,0 +1,396 @@
+import React from "react";
+import { Spin } from "antd";
+import { sldComLanguage } from "@/utils/utils";
+import styles from "./index.less";
+
+const PI = Math.PI;
+
+function sum(x, y) {
+  return x + y;
+}
+function square(x) {
+  return x * x;
+}
+
+class SlideVerify extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      success: false,
+      containerActive: false,
+      containerSuccess: false,
+      containerFail: false,
+      sliderLeft: 0,
+      sliderWidth: 0,
+      loadBlock: true,
+      blockX: 0,
+      blockY: 0,
+    };
+
+    this.origin = { x: 0, y: 0 };
+    this.isMouseDown = false;
+    this.timestamp = 0;
+    this.trail = [];
+
+    this.blockRef = React.createRef();
+    this.canvasRef = React.createRef();
+
+    this.img = null;
+
+    this.handleStart = this.handleStart.bind(this);
+    this.handleMove = this.handleMove.bind(this);
+    this.handleEnd = this.handleEnd.bind(this);
+    this.reset = this.reset.bind(this);
+    this.refresh = this.refresh.bind(this);
+  }
+
+  componentDidMount() {
+    this.initCanvas();
+
+    document.addEventListener("mousemove", this.handleMove);
+    document.addEventListener("mouseup", this.handleEnd);
+
+    // 如果需要支持移动端,也加对应touch事件监听
+    document.addEventListener("touchmove", this.handleMove, { passive: false });
+    document.addEventListener("touchend", this.handleEnd);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener("mousemove", this.handleMove);
+    document.removeEventListener("mouseup", this.handleEnd);
+
+    document.removeEventListener("touchmove", this.handleMove);
+    document.removeEventListener("touchend", this.handleEnd);
+  }
+
+  draw = (ctx, x, y, l, r, operation) => {
+    ctx.beginPath();
+    ctx.moveTo(x, y);
+    ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI);
+    ctx.lineTo(x + l, y);
+    ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI);
+    ctx.lineTo(x + l, y + l);
+    ctx.lineTo(x, y + l);
+    ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true);
+    ctx.lineTo(x, y);
+    ctx.lineWidth = 2;
+    ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
+    ctx.strokeStyle = "rgba(255, 255, 255, 0.7)";
+    ctx.stroke();
+    ctx[operation]();
+    ctx.globalCompositeOperation = "destination-over";
+  };
+
+  getRandomNumberByRange = (start, end) => {
+    return Math.round(Math.random() * (end - start) + start);
+  };
+
+  getRandomImg = (imgs, w, h) => {
+    if (!imgs || imgs.length === 0) {
+      return `https://picsum.photos/300/150?random=${Date.now()}`;
+    }
+    const idx = getRandomNumberByRange(0, imgs.length - 1);
+    return imgs[idx];
+  };
+
+  createImg = (imgs, onload) => {
+    const img = document.createElement("img");
+    img.crossOrigin = "Anonymous";
+    img.onload = onload;
+    img.onerror = () => {
+      img.src = this.getRandomImg(imgs);
+    };
+    img.src = this.getRandomImg(imgs);
+    return img;
+  };
+
+  initCanvas() {
+    const { w, h, l, r, imgs } = this.props;
+    const canvas = this.canvasRef.current;
+    const block = this.blockRef.current;
+    if (!canvas || !block) return;
+
+    this.canvasCtx = canvas.getContext("2d");
+    this.blockCtx = block.getContext("2d", { willReadFrequently: true });
+
+    this.img = this.createImg(imgs, () => {
+      this.setState({ loadBlock: false });
+
+      const L = l + r * 2 + 3;
+      const blockX = this.getRandomNumberByRange(L + 10, w - (L + 10));
+      const blockY = this.getRandomNumberByRange(10 + r * 2, h - (L + 10));
+
+      this.setState({ blockX, blockY });
+
+      if (this.canvasCtx && this.blockCtx) {
+        this.draw(this.canvasCtx, blockX, blockY, l, r, "fill");
+        this.draw(this.blockCtx, blockX, blockY, l, r, "clip");
+
+        this.canvasCtx.drawImage(this.img, 0, 0, w, h);
+        this.blockCtx.drawImage(this.img, 0, 0, w, h);
+
+        const _y = blockY - r * 2 - 1;
+        const imgData = this.blockCtx.getImageData(blockX, _y, L, L);
+        block.width = L;
+        this.blockCtx.putImageData(imgData, 0, _y);
+      }
+
+      if (this.props.onImageLoad) this.props.onImageLoad();
+    });
+  }
+
+  handleStart(e) {
+    if (this.state.success) return;
+
+    const nativeEvent = e.nativeEvent || e;
+
+    if (nativeEvent.touches && nativeEvent.touches.length > 0) {
+      this.origin.x = nativeEvent.touches[0].pageX;
+      this.origin.y = nativeEvent.touches[0].pageY;
+    } else if ("clientX" in nativeEvent && "clientY" in nativeEvent) {
+      this.origin.x = nativeEvent.clientX;
+      this.origin.y = nativeEvent.clientY;
+    }
+    this.isMouseDown = true;
+    this.trail = [];
+    this.timestamp = Date.now();
+  }
+
+  handleMove(e) {
+    if (!this.isMouseDown) return false;
+
+    // 阻止滚动等默认事件
+    e.preventDefault();
+
+    const nativeEvent = e.nativeEvent || e;
+
+    let moveX = 0;
+    let moveY = 0;
+
+    if (nativeEvent.touches && nativeEvent.touches.length > 0) {
+      moveX = nativeEvent.touches[0].pageX - this.origin.x;
+      moveY = nativeEvent.touches[0].pageY - this.origin.y;
+    } else if ("clientX" in nativeEvent && "clientY" in nativeEvent) {
+      moveX = nativeEvent.clientX - this.origin.x;
+      moveY = nativeEvent.clientY - this.origin.y;
+    }
+
+    const { w } = this.props;
+
+    if (moveX < 0 || moveX + 38 >= w) return false;
+
+    this.setState({
+      sliderLeft: moveX,
+      sliderWidth: moveX,
+      containerActive: true,
+      containerFail: false,
+      containerSuccess: false,
+    });
+
+    this.trail.push(moveY);
+  }
+
+  handleEnd(e) {
+    if (!this.isMouseDown) return false;
+    this.isMouseDown = false;
+
+    const nativeEvent = e.nativeEvent || e;
+
+    let moveX;
+
+    if (nativeEvent.changedTouches && nativeEvent.changedTouches.length > 0) {
+      moveX = nativeEvent.changedTouches[0].pageX;
+    } else if ("clientX" in nativeEvent) {
+      moveX = nativeEvent.clientX;
+    } else {
+      moveX = this.origin.x;
+    }
+
+    if (moveX === this.origin.x) return false;
+
+    const timestamp = Date.now() - this.timestamp;
+
+    const { spliced, TuringTest } = this.verify(
+      this.state.sliderLeft,
+      this.state.blockX,
+      this.props.accuracy
+    );
+
+    if (spliced) {
+      if (TuringTest) {
+        this.setState({
+          success: true,
+          containerSuccess: true,
+          containerFail: false,
+          sliderLeft: this.state.blockX,
+          sliderWidth: this.state.blockX,
+        });
+        this.props.onSuccess && this.props.onSuccess(timestamp);
+      } else {
+        this.setState({
+          containerFail: true,
+          containerSuccess: false,
+          sliderLeft: 0,
+          sliderWidth: 0,
+        });
+        this.props.onAgain && this.props.onAgain();
+      }
+    } else {
+      this.setState({
+        containerFail: true,
+        containerSuccess: false,
+        sliderLeft: 0,
+        sliderWidth: 0,
+      });
+      this.refresh();
+    }
+  }
+
+  verify(left, blockX, accuracy) {
+    const arr = this.trail;
+    const sum = (x, y) => x + y;
+    const square = (x) => x * x;
+
+    const average = arr.reduce(sum, 0) / arr.length || 0;
+    const deviations = arr.map((x) => x - average);
+    const stddev = Math.sqrt(
+      deviations.map(square).reduce(sum, 0) / arr.length || 0
+    );
+
+    const leftNum = parseInt(left);
+    accuracy = accuracy <= 1 ? 1 : accuracy > 10 ? 10 : accuracy;
+
+    return {
+      spliced: Math.abs(leftNum - blockX) <= accuracy,
+      TuringTest: average !== stddev,
+    };
+  }
+
+  reset() {
+    const { w, h, l, r, imgs } = this.props;
+    this.setState({
+      success: false,
+      containerActive: false,
+      containerSuccess: false,
+      containerFail: false,
+      sliderLeft: 0,
+      sliderWidth: 0,
+      loadBlock: true,
+    });
+
+    const block = this.blockRef.current;
+    if (block) block.style.left = "0px";
+    if (this.canvasCtx) this.canvasCtx.clearRect(0, 0, w, h);
+    if (this.blockCtx) this.blockCtx.clearRect(0, 0, w, h);
+    if (block) block.width = w;
+
+    this.img.src = this.getRandomImg(imgs);
+  }
+
+  refresh() {
+    this.reset();
+    this.props.onRefresh && this.props.onRefresh();
+  }
+
+  render() {
+    const { w, h, sliderText, show } = this.props;
+    const {
+      loadBlock,
+      containerActive,
+      containerSuccess,
+      containerFail,
+      sliderLeft,
+      sliderWidth,
+      success,
+    } = this.state;
+
+    // 容器className 组合
+    const containerCls = [
+      styles["slide-verify-slider"],
+      containerActive ? styles["container-active"] : "",
+      containerSuccess ? styles["container-success"] : "",
+      containerFail ? styles["container-fail"] : "",
+    ].join(" ");
+
+    return (
+      <div
+        id="slideVerify"
+        className={styles["slide-verify"]}
+        style={{ width: w }}
+        onSelect={(e) => e.preventDefault()}
+      >
+        <Spin spinning={loadBlock}>
+          <canvas
+            ref={this.canvasRef}
+            width={w}
+            height={h}
+            className={styles["slide-verify-canvas"]}
+          />
+
+          {show && (
+            <div
+              className={styles["slide-verify-refresh-icon"]}
+              onClick={this.refresh}
+            >
+              <i className={`iconfont ${styles["icon-refresh"]}`} />
+            </div>
+          )}
+
+          <canvas
+            ref={this.blockRef}
+            width={w}
+            height={h}
+            className={styles["slide-verify-block"]}
+            style={{ left: `${sliderLeft}px`, position: "absolute", top: 0 }}
+          />
+
+          <div className={containerCls}>
+            <div
+              className={styles["slide-verify-slider-mask"]}
+              style={{ width: sliderWidth }}
+            >
+              <div
+                className={styles["slide-verify-slider-mask-item"]}
+                style={{ left: sliderLeft }}
+                onMouseDown={this.handleStart}
+                onTouchStart={this.handleStart}
+                onTouchMove={this.handleMove}
+                onTouchEnd={this.handleEnd}
+              >
+                <img
+                  draggable={false}
+                  src={
+                    success
+                      ? require("./icons/success.png")
+                      : require("./icons/right.png")
+                  }
+                />
+              </div>
+            </div>
+            <span className={styles["slide-verify-slider-text"]}>
+              {sliderText}
+            </span>
+          </div>
+          <div className={styles["bottom-action"]} onClick={this.refresh}>
+            <img src={require("./icons/refresh.png")} />
+            <span>{sldComLanguage('刷新')}</span>
+          </div>
+        </Spin>
+      </div>
+    );
+  }
+}
+
+SlideVerify.defaultProps = {
+  l: 42,
+  r: 10,
+  w: 310,
+  h: 155,
+  sliderText: sldComLanguage("拖动滑块来填充拼图"),
+  accuracy: 5,
+  show: true,
+  imgs: [],
+  interval: 50,
+};
+
+export default SlideVerify;

+ 137 - 0
xinkeaboard-seller/src/components/SlideVerify/index.less

@@ -0,0 +1,137 @@
+@import "../../../src/themeColor.less";
+
+.slide-verify {
+  width: 100%;
+  position: relative;
+
+  canvas {
+    border-radius: 20px;
+  }
+}
+.slide-verify-loading {
+  position: absolute;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(255, 255, 255, 0.9);
+  z-index: 999;
+  animation: loading-3f647794 1.5s infinite;
+}
+.slide-verify-block {
+  position: absolute;
+  left: 0;
+  top: 0;
+}
+.slide-verify-refresh-icon {
+  position: absolute;
+  right: 0;
+  top: 0;
+  width: 34px;
+  height: 34px;
+  cursor: pointer;
+}
+.slide-verify-refresh-icon .iconfont {
+  font-size: 34px;
+  color: #fff;
+}
+.slide-verify-slider {
+  position: relative;
+  text-align: center;
+  width: 100%;
+  height: 50px;
+  line-height: 50px;
+  margin-top: 15px;
+  background: #f7f9fa;
+  color: #45494c;
+  border: 1px solid #e4e7eb;
+  border-radius: 25px;
+}
+.slide-verify-slider-mask {
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 50px;
+  border-radius: 25px;
+  background: @theme-color;
+}
+.slide-verify-slider-mask-item {
+  position: absolute;
+  left: 0;
+  top: 0;
+  background: #fff;
+  box-shadow: 0 0 3px #0000004d;
+  cursor: pointer;
+  transition: background 0.2s linear;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 50px;
+  height: 50px;
+  border-radius: 100%;
+
+  img {
+    width: 28px;
+  }
+}
+.slide-verify-slider-mask-item:hover {
+  background: #fff;
+}
+
+.container-active .slide-verify-slider-mask {
+  border-width: 1px;
+}
+.container-active .slide-verify-slider-mask-item {
+  top: -1px;
+  border: 1px solid @theme-color;
+}
+.container-success .slide-verify-slider-mask {
+  border: 1px solid @theme-color;
+  background-color: @theme-color;
+}
+.container-success .slide-verify-slider-mask-item {
+  top: -1px;
+  border: 1px solid @theme-color;
+  background-color: @theme-color !important;
+}
+
+.container-fail .slide-verify-slider-mask {
+  border: 1px solid #f57a7a;
+  background-color: #fce1e1;
+}
+.container-fail .slide-verify-slider-mask-item {
+  top: -1px;
+  border: 1px solid #f57a7a;
+  background-color: #f57a7a !important;
+}
+
+.container-active .slide-verify-slider-text,
+.container-success .slide-verify-slider-text,
+.container-fail .slide-verify-slider-text {
+  display: none;
+}
+@keyframes loading {
+  0% {
+    opacity: 0.7;
+  }
+  to {
+    opacity: 9;
+  }
+}
+
+.bottom-action {
+  margin: 10px 0;
+  cursor: pointer;
+  :global {
+    img {
+      width: 18px;
+    }
+
+    span {
+      font-size: 12px;
+      color: rgba(0, 0, 0, 0.85);
+      font-weight: bold;
+      margin-left: 5px;
+    }
+  }
+}

+ 60 - 26
xinkeaboard-seller/src/pages/User/Login.js

@@ -1,6 +1,7 @@
 import React, { Component } from 'react';
 import { connect } from 'dva';
-import { Form, Input, Modal, Button } from 'antd';
+import { Form, Input, Modal, Button, message } from 'antd';
+import SlideVerify from "@/components/SlideVerify"
 import styles from './Login.less';
 import global from '@/global.less';
 import {
@@ -46,6 +47,7 @@ export default class LoginPage extends Component {
     register_captcha: '',//注册——图形验证码
     login_captcha: '',//登录——图形验证码
     visibleModal: false,
+    visibleModalVerify: false,// 人机验证弹窗
     modal_type: 'register',  //区分忘记密码和立即注册弹框
     visibleModalSuccess: false,
     //商家账号
@@ -654,31 +656,8 @@ export default class LoginPage extends Component {
 
   };
 
-  //获取短信验证码
-  getSmsCode(type) {
-    if (this.state.countDownC) {
-      return;
-    } else if (this.state.countDownM) {
-      return;
-    }
-    let mobile = this.props.form.getFieldValue('vendorMobile');
-    let captcha = this.props.form.getFieldValue('verifyCode');//图形验证码
-    if (mobile == undefined || (mobile != undefined && !mobile)) {
-      this.setState({
-        is_show_phone_err: true,
-        phone_error_info: `${sldComLanguage('请输入手机号')}`,
-      });
-    } else if (!sldCheckMobile(mobile)) {
-      this.setState({
-        is_show_phone_err: true,
-        phone_error_info: `${sldComLanguage('请输入正确的手机号')}`,
-      });
-    } else if (type == 'register' && (captcha == undefined || (captcha != undefined && !captcha))) {
-      this.setState({
-        is_show_code_err: true,
-        code_error_info: `${sldComLanguage('请输入图形验证码')}`,
-      });
-    } else {
+  // 获取验证码操作
+  getSmsCodeAction = ({ mobile, type, captcha }) => {
       const { dispatch } = this.props;
       let _this = this;
       let param = {};
@@ -713,7 +692,49 @@ export default class LoginPage extends Component {
           }
         },
       });
+  }
+
+  //获取短信验证码前的校验
+  getSmsCode(type) {
+    if (this.state.countDownC) {
+      return;
+    } else if (this.state.countDownM) {
+      return;
     }
+    let mobile = this.props.form.getFieldValue('vendorMobile');
+    let captcha = this.props.form.getFieldValue('verifyCode');//图形验证码
+    if (mobile == undefined || (mobile != undefined && !mobile)) {
+      this.setState({
+        is_show_phone_err: true,
+        phone_error_info: `${sldComLanguage('请输入手机号')}`,
+      });
+    } else if (!sldCheckMobile(mobile)) {
+      this.setState({
+        is_show_phone_err: true,
+        phone_error_info: `${sldComLanguage('请输入正确的手机号')}`,
+      });
+    } else if (type == 'register' && (captcha == undefined || (captcha != undefined && !captcha))) {
+      this.setState({
+        is_show_code_err: true,
+        code_error_info: `${sldComLanguage('请输入图形验证码')}`,
+      });
+    } else if (type === 'retrieve'){
+      this.setState({visibleModalVerify: true})
+    } else {
+      this.getSmsCodeAction({ mobile, type, captcha })
+    }
+  }
+
+  handleCancelVeriyModal = () => {
+    this.setState({visibleModalVerify: false})
+  }
+
+  verifySuccess = () => {
+    message.success(sldComLanguage('验证成功'));
+    const mobile = this.props.form.getFieldValue('vendorMobile');
+    const captcha = this.props.form.getFieldValue('verifyCode');
+    this.setState({visibleModalVerify: false})
+    this.getSmsCodeAction({ mobile, type: 'retrieve', captcha })
   }
 
   render() {
@@ -728,6 +749,7 @@ export default class LoginPage extends Component {
       title, 
       visibleModal, 
       visibleModalSuccess, 
+      visibleModalVerify,
       register_error_info, 
       is_show_registe_err, 
       countDownM,
@@ -1111,6 +1133,18 @@ export default class LoginPage extends Component {
                     onClick={this.handleSuccessBtn}>{sldComLanguage('点击入驻')}</Button>
           </div>
         </Modal>
+
+        { /* 人机验证 */ }
+        <Modal
+          className={styles.verifyModal}
+          title={'滑动验证'}
+          visible={visibleModalVerify}
+          footer={false}
+          onCancel={this.handleCancelVeriyModal}
+          width={500}
+        >
+          {visibleModalVerify &&  <SlideVerify w={450} h={220} onSuccess={this.verifySuccess}/> }
+        </Modal>
       </div>
     );
   }

+ 24 - 0
xinkeaboard-seller/src/pages/User/Login.less

@@ -322,3 +322,27 @@
     color: #fff;
   }
 }
+
+.verifyModal {
+  :global {
+    .ant-modal-header {
+      height: 50px;
+      border-bottom: none;
+      background-color: transparent !important;
+
+      .ant-modal-title {
+        text-align: center;
+        color: rgba(0, 0, 0, 0.85) !important;
+      }
+    }
+
+    .ant-modal-body {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+    }
+    .ant-modal-close-x svg{
+      color: rgba(0, 0, 0, 0.85) !important;
+    }
+  }
+}