React 发送短信验证码和验证码校验功能组件

该代码实现了一个带键盘跟随功能的验证码输入组件,主要特性包括: 响应式设计,通过useVisualViewport钩子检测键盘高度,自动调整输入框位置避免遮挡 验证码输入:支持6位数字输入,包含复制粘贴功能和样式化输入框显示 交互功能: 自动发送验证码 60秒重发倒计时 键盘弹出时自动聚焦输入框 UI组件:包含关闭按钮、手机号显示、验证码输入框和确认按钮 动画效果:淡入/上滑动画,输入框激活状态视觉效果 组件适用于移动端验证场景,提供良好的键盘交互体验。

效果预览:

1.获取键盘高度组件

html 复制代码
import { useState, useEffect } from "react";

export default function UseVisualViewport() {
  const [viewport, setViewport] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
    visualWidth: window.visualViewport ? window.visualViewport.width : window.innerWidth,
    visualHeight: window.visualViewport ? window.visualViewport.height : window.innerHeight,
    keyboardHeight: 0,
    isKeyboardVisible: false
  });

  useEffect(() => {
    const handler = () => {
      if (!window.visualViewport) {
        setViewport(prev => ({
          ...prev,
          width: window.innerWidth,
          height: window.innerHeight
        }));
        return;
      }

      const keyboardHeight = Math.max(0, window.innerHeight - window.visualViewport.height);
      const isKeyboardVisible = keyboardHeight > 50; // 键盘高度大于50px认为键盘可见

      setViewport({
        width: window.innerWidth,
        height: window.innerHeight,
        visualWidth: window.visualViewport.width,
        visualHeight: window.visualViewport.height,
        keyboardHeight,
        isKeyboardVisible
      });
    };

    // 添加事件监听
    if (window.visualViewport) {
      window.visualViewport.addEventListener("resize", handler);
      window.visualViewport.addEventListener("scroll", handler);
    } else {
      // 如果不支持visualViewport,使用resize事件作为fallback
      window.addEventListener("resize", handler);
    }

    // 初始调用一次
    handler();

    return () => {
      if (window.visualViewport) {
        window.visualViewport.removeEventListener("resize", handler);
        window.visualViewport.removeEventListener("scroll", handler);
      } else {
        window.removeEventListener("resize", handler);
      }
    };
  }, []);

  return viewport;
}

2.代码主要内容

html 复制代码
import React, { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import UseVisualViewport from "@/components/UseVisualViewport";
import {maskPhoneNumber} from "util/util";

const CodeInputDrawer = (props) => {
  const {
    phoneNumber,
    visible = false,
    onClose,
    onSendCode,
    onConfirm,
    codeLength = 6,
    countdownTime = 60,
  } = props;

  const [code, setCode] = useState("");
  const [countdown, setCountdown] = useState(0);
  const [isInputFocused, setIsInputFocused] = useState(false);
  const inputRef = useRef(null);
  const containerRef = useRef(null);
  const viewport = UseVisualViewport();

  // 处理输入框聚焦
  const handleInputFocus = () => {
    if (inputRef.current) {
      inputRef.current.focus();
      setIsInputFocused(true);

      // 确保输入框在键盘上方可见
      if (viewport.isKeyboardVisible && containerRef.current) {
        const containerRect = containerRef.current.getBoundingClientRect();
        const viewportHeight = window.innerHeight;
        // 如果容器底部在键盘区域内,滚动到可见位置
        if (containerRect.bottom > viewport.visualHeight) {
          const scrollOffset = containerRect.bottom - viewport.visualHeight + 20;
          window.scrollBy({
            top: scrollOffset,
            behavior: 'smooth'
          });
        }
      }
    }
  };

  // 处理输入框失焦
  const handleInputBlur = () => {
    setIsInputFocused(false);
  };

  // 处理输入变化
  const handleInputChange = (e) => {
    const value = e.target.value.replace(/[^0-9]/g, "").slice(0, codeLength);
    setCode(value);

    if (value.length === codeLength && props.onComplete) {
      props.onComplete(value);
    }
  };

  // 发送验证码
  const handleSendCode = async () => {
    if (!phoneNumber || phoneNumber.length !== 11) {
      alert("请输入正确的手机号码");
      return;
    }

    if (countdown > 0) {
      return;
    }

    try {
      if (onSendCode) {
        await onSendCode(phoneNumber);
      }
      setCountdown(countdownTime);
    } catch (error) {
      console.error("发送验证码失败:", error);
      alert("发送验证码失败,请重试");
    }
  };

  // 确认输入的验证码
  const handleConfirm = () => {
    if (code.length !== codeLength) {
      alert("请输入完整的验证码");
      return;
    }

    if (onConfirm) {
      onConfirm(code);
    }
  };

  // 关闭抽屉
  const handleClose = () => {
    if (onClose) {
      onClose();
    }
  };

  // 倒计时效果
  useEffect(() => {
    if (countdown > 0) {
      const timer = setTimeout(() => {
        setCountdown(countdown - 1);
      }, 1000);
      return () => clearTimeout(timer);
    }
  }, [countdown]);

  // 当键盘显示/隐藏时调整容器位置
  useEffect(() => {
    if (!visible) return;

    // 键盘显示时确保内容可见
    if (viewport.isKeyboardVisible && containerRef.current) {
      const containerRect = containerRef.current.getBoundingClientRect();

      // 如果容器被键盘遮挡,滚动到可见位置
      if (containerRect.bottom > viewport.visualHeight) {
        const scrollOffset = containerRect.bottom - viewport.visualHeight + 20;
        window.scrollBy({
          top: scrollOffset,
          behavior: 'smooth'
        });
      }
    }
  }, [viewport.isKeyboardVisible, visible]);

  // 点击容器外部时失焦输入框
  useEffect(() => {
    if (!visible) return;
    const handleClickOutside = (e) => {
      if (containerRef.current && !containerRef.current.contains(e.target)) {
        handleInputBlur();
      }
    };

    document.addEventListener("mousedown", handleClickOutside);
    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [visible]);

  // 当抽屉可见时自动聚焦输入框
  useEffect(() => {
    if (visible) {
      setTimeout(() => {
        handleInputFocus();
      }, 300);
    } else {
      setCode("");
      setCountdown(0);
      setIsInputFocused(false);
    }
  }, [visible]);

  // 首次加载自动发送验证码
  useEffect(() => {
    if (visible) {
      handleSendCode().catch(() => {
        alert("发送验证码失败,请重试");
      });
    }
  }, [visible]);

  // 根据键盘高度获取容器样式
  const getContainerStyle = () => {
    if (viewport.isKeyboardVisible) {
      return {
        bottom: `${viewport.keyboardHeight + 20}px`, // 距离键盘20px边距
        transition: "bottom 0.3s ease",
        position: 'fixed',
        zIndex: 1000
      };
    }
    return {
      bottom: "0",
      transition: "bottom 0.3s ease",
      position: 'fixed',
      zIndex: 1000
    };
  };

  // 渲染单个验证码框
  const renderCodeBox = (index) => {
    const digit = code[index] || "";
    const isActive = index === code.length && isInputFocused;
    const isFilled = digit !== "";

    return (
      <div
        key={index}
        className={`code-box ${isActive ? 'active' : ''} ${isFilled ? 'filled' : ''}`}
      >
        {digit}
      </div>
    );
  };

  if (!visible) {
    return null;
  }

  return (
    <div className="code-input-mask" onClick={handleClose}>
      <div
        ref={containerRef}
        className={`code-input-container ${visible ? 'visible' : ''}`}
        style={getContainerStyle()}
        onClick={(e) => e.stopPropagation()}
      >
        {/* Close button */}
        <button className="close-btn" onClick={handleClose}>×</button>

        {/* Phone number display */}
        <div className="phone-display">
          <div className='title-tip'>请输入验证码</div>
          <div className='tip-content'>
            我们已给手机号码{phoneNumber &&<span className="phone-number">{maskPhoneNumber(phoneNumber)}</span>}发送了一个6位数的验证码,输入验证码完成补款!
          </div>
        </div>

        {/* Code boxes area */}
        <div className="code-boxes-container" onClick={handleInputFocus}>
          {Array.from({ length: codeLength }).map((_, index) => renderCodeBox(index))}
        </div>

        {/* Hidden input field */}
        <input
          ref={inputRef}
          type="tel"
          value={code}
          onChange={handleInputChange}
          onFocus={() => setIsInputFocused(true)}
          onBlur={handleInputBlur}
          className="hidden-input"
          maxLength={codeLength}
          inputMode="numeric"
          pattern="[0-9]*"
        />

        {/* Send code button */}
         <div className="send-code-btn-container">
           <div
             className="send-code-btn"
             onClick={handleSendCode}
             disabled={countdown > 0 || !phoneNumber}
           >
             {countdown > 0 ? `重新发送(${countdown}s)` : "重新发送验证码"}
           </div>
         </div>

        {/* Confirm button */}
        <button
          className="confirm-btn"
          onClick={handleConfirm}
          disabled={code.length !== codeLength}
        >
          确定
        </button>
      </div>
    </div>
  );
};

// Define prop types
CodeInputDrawer.propTypes = {
  phoneNumber: PropTypes.string,
  visible: PropTypes.bool,
  onClose: PropTypes.func,
  onSendCode: PropTypes.func,
  onConfirm: PropTypes.func,
  onComplete: PropTypes.func, // Add this if you have an onComplete callback
  codeLength: PropTypes.number,
  countdownTime: PropTypes.number,
};

// Default props
CodeInputDrawer.defaultProps = {
  visible: false,
  codeLength: 6,
  countdownTime: 60,
};

export default CodeInputDrawer;

3.代码样式

html 复制代码
.code-input-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: flex-end;
  justify-content: center;
  z-index: 9999;
  animation: maskFadeIn 0.2s ease;
}

.code-input-container {
  width: 100%;
  max-width: 100%;
  background-color: white;
  border-radius: 16px 16px 0 0;
  padding: 24px 20px 40px;
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.15);
  animation: drawerSlideUp 0.2s ease;
  position: relative; /* 从absolute改为relative */
  transform: translateY(100%);
  transition: transform 0.3s ease;
  bottom: 0;

  /* 确保容器保持在键盘上方 */
  &.visible {
    transform: translateY(0);
  }

  /* 响应式设计 */
  @media (min-width: 768px) {
    max-width: 400px;
    border-radius: 16px;
    margin-bottom: 20px;
  }
}

.phone-display {
  font-size: 14px;
  color: #666;
  margin-bottom: 16px;
  .title-tip{
    font-size: 20px;
    font-weight: 500;
    color: #131523;
  }
  .tip-content{
    font-size: 14px;
    font-weight: 400;
    color: rgba(19, 21, 35, 0.58);
    margin-top: 10px;
  }
  .phone-number {
    color: #3484FD;
  }
}

.code-boxes-container {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;

  .code-box {
    width: 44px;
    height: 52px;
    border: 1px solid #D9D9D9;
    border-radius: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 20px;
    font-weight: bold;
    background-color: #F6F6F6; // 设置背景颜色为#F6F6F6
    color: #000000; // 设置字体颜色为黑色
    transition: all 0.2s ease;
    caret-color: #1890ff; // 设置光标颜色为蓝色

    &.active {
      border-color: #1890ff;
      background-color: #F6F6F6; // 保持背景颜色一致
      box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
      // 添加蓝色竖线光标效果
      position: relative;
      &::after {
        content: '';
        position: absolute;
        top: 10px;
        bottom: 10px;
        left: 50%;
        width: 2px;
        background-color: #1890ff;
        animation: blink 1s infinite;
      }
    }

    &.filled {
      border-color: #1890ff;
      background-color: #F6F6F6; // 保持背景颜色一致
      color: #000000; // 设置字体颜色为黑色
    }
  }
}

// 添加光标闪烁动画
@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

.send-code-btn-container{
  width: 100%;
  display: flex;
  justify-content: flex-end;
  align-items: center;
  .send-code-btn {
    border: none;
    border-radius: 8px;
    font-size: 14px;
    font-weight: 500;
    margin-bottom: 16px;
    cursor: pointer;
    transition: all 0.2s ease;

    &:not(:disabled) {
      color: #999;
    }

    &:disabled {
      color: #999;
      cursor: not-allowed;
    }
  }
}


.confirm-btn {
  width: 100%;
  height: 44px;
  border: none;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;

  &:not(:disabled) {
    background-color: #52c41a;
    color: white;

    &:hover {
      background-color: #73d13d;
    }

    &:active {
      background-color: #389e0d;
    }
  }

  &:disabled {
    background-color: #f5f5f5;
    color: #999;
    cursor: not-allowed;
  }
}

.close-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  width: 24px;
  height: 24px;
  border: none;
  background: none;
  font-size: 18px;
  color: #999;
  cursor: pointer;

  &:hover {
    color: #666;
  }
}

.hidden-input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
  width: 1px;
  height: 1px;
}

/* 动画效果 */
@keyframes maskFadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes drawerSlideUp {
  from {
    transform: translateY(100%);
  }
  to {
    transform: translateY(0);
  }
}

4.使用组件

html 复制代码
    <CodeInputDrawer
            phoneNumber="19948765606"
            visible={showCodeInput}
            onClose={() => this.setState({showCodeInput: false})}
            onSendCode={(phone) => {
              console.log(phone)
            }}
            onConfirm={(code) => {
              // 验证验证码
              console.log("验证码:", code);
            }}
          />
相关推荐
devincob2 小时前
js原生、vue导出、react导出、axios ( post请求方式)跨平台导出下载四种方式的demo
javascript·vue.js·react.js
葡萄城技术团队2 小时前
迎接下一代 React 框架:Next.js 16 核心能力解读
javascript·spring·react.js
全马必破三2 小时前
React“组件即函数”
前端·javascript·react.js
三思而后行,慎承诺2 小时前
React 底层原理
前端·react.js·前端框架
座山雕~2 小时前
html 和css基础常用的标签和样式
前端·css·html
課代表2 小时前
JavaScript 中获取二维数组最大值
javascript·max·数组·递归·array·最大值·二维
灰小猿3 小时前
Spring前后端分离项目时间格式转换问题全局配置解决
java·前端·后端·spring·spring cloud
im_AMBER3 小时前
React 16
前端·笔记·学习·react.js·前端框架
02苏_3 小时前
ES6模板字符串
前端·ecmascript·es6