短信注册逻辑与二次定义组件

我们首先完成短信注册的逻辑,前面我们预留了一个空的手机注册组件,现在来完成,基本表单代码逻辑与账号注册一致,增加了一些细节,比如获取短信时的计时,重新定义了一个Input组件,完成后页面样式如下

一、注册组件

下面是该短信注册组件代码:

ts 复制代码
'use client'
import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { getMessages } from '@/lib/tips'
import { cn } from '@/lib/utils'
import {
  PhoneFormVaildator,
  TPhoneFormVaildator,
  phoneCodeValidator,
  phoneValidator,
} from '@/lib/validator'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { trpc } from '@/app/_trpc/client'
import { Input2 } from '@/components/ui/input2'
function PhoneRegister() {
  const { toast } = useToast()
  const [count, setCount] = useState<number>(60) // 倒计时60秒
  const [isActive, setIsActive] = useState<boolean>(false) // 是否开始倒计时

  useEffect(() => {
    let timer: NodeJS.Timeout

    if (isActive && count > 0) {
      timer = setInterval(() => {
        setCount((prevCount) => prevCount - 1)
        if (count === 1) {
          setIsActive(false)
        }
      }, 1000)
    } else if (!isActive && count !== 60) {
      setCount(60)
    }

    return () => {
      clearInterval(timer) // 组件卸载时清除定时器
    }
  }, [isActive, count])
  const {
    register,
    handleSubmit,
    formState: { errors },
    setValue,
    setError,
    clearErrors,
    getValues,
    watch,
  } = useForm<TPhoneFormVaildator>({
    defaultValues: {},
    resolver: zodResolver(PhoneFormVaildator),
  })

  const phone = watch('phone')
  const phoneCode = watch('phoneCode')
  useEffect(() => {
    if (phone && !phoneValidator(phone)) {
      setError('phone', {
        type: 'manual',
        message: getMessages('10019'),
      })
    } else {
      clearErrors('phone')
    }

    if (phoneCode && !phoneCodeValidator(phoneCode)) {
      setError('phoneCode', {
        type: 'manual',
        message: getMessages('10020'),
      })
    } else {
      clearErrors('phoneCode')
    }
  }, [clearErrors, phone, phoneCode, setError])

  const { mutate: startPhoneRegister, isLoading: phoneRegisterLoading } =
    trpc.phoneRegister.useMutation({
      onSuccess: (user) => {
        if (user && user.id) {
          toast({
            title: getMessages('10003'),
            description: getMessages('10004', user.phone),
            variant: 'default',
          })
        }
      },
      onError: (error) => {
        toast({
          title: getMessages('10003'),
          description: error.message,
          variant: 'destructive',
        })
      },
    })
  const { mutate: startPhoneActive, isLoading: phoneActiveLoading } =
    trpc.phoneActive.useMutation({
      onSuccess: (info) => {
        if (info && info.status === 'frequently') {
          // 频繁
          setError('phoneCode', {
            message: getMessages('10026'),
          })
          return
        }
        toast({
          title: getMessages('10003'),
          description: getMessages('10025'),
          variant: 'default',
        })
        setIsActive(true)
      },
      onError: (error) => {
        toast({
          title: getMessages('10003'),
          description: error.message,
          variant: 'destructive',
        })
      },
    })
  const handleActivePhone = useCallback(() => {
    clearErrors(['phone', 'phoneCode'])
    if (!phoneValidator(phone)) {
      setError('phone', {
        type: 'manual',
        message: getMessages('10019'),
      })
      return
    }
    startPhoneActive({ phone })
  }, [clearErrors, phone, setError, startPhoneActive])

  const handleSubmitPhone = useCallback(
    ({ phone, phoneCode }: TPhoneFormVaildator) => {
      startPhoneRegister({ phone, phoneCode })
    },
    [startPhoneRegister]
  )
  return (
    <Card>
      <CardHeader>
        <CardDescription>未注册手机号验证通过后将自动注册</CardDescription>
      </CardHeader>
      <form
        id="phoneRegister"
        onSubmit={(e) => {
          e.preventDefault()
          handleSubmit(handleSubmitPhone)()
        }}
      >
        <CardContent className="space-y-2">
          <div className="space-y-1">
            <Label className="text-zinc-600" htmlFor="phone">
              手机号:
            </Label>
            <Input2
              {...register('phone')}
              onBlur={(e) => {
                setValue('phone', e.target.value)
              }}
              className={cn(
                errors.phone && 'focus-visible:ring-red-500',
                'pl-12'
              )}
              id="phone"
              placeholder={getMessages('10018')}
              autoComplete="username"
              type="text"
              customprefix={
                <span className="text-zinc-500 text-xs pl-4">+86</span>
              }
            />
            <div className="text-destructive text-xs mt-1">
              {errors.phone ? errors.phone.message : null}
            </div>
          </div>
          <div className="space-y-1">
            <Label className="text-zinc-600" htmlFor="phoneCode">
              验证码:
            </Label>
            <div className="flex space-x-2">
              <Input
                {...register('phoneCode')}
                onBlur={(e) => {
                  setValue('phoneCode', e.target.value)
                }}
                className={cn(errors.phoneCode && 'focus-visible:ring-red-500')}
                id="phoneCode"
                placeholder={getMessages('10027')}
                autoComplete="off"
                type="text"
              />
              <Button
                onClick={handleActivePhone}
                className="min-w-max text-zinc-500"
                variant={'outline'}
                type="button"
                disabled={isActive || phoneActiveLoading}
              >
                {isActive ? `${count}秒后重发` : '获取验证码'}
              </Button>
            </div>
            <div className="text-destructive text-xs mt-1">
              {errors.phoneCode ? errors.phoneCode.message : null}
            </div>
          </div>
        </CardContent>
        <CardFooter>
          <Button
            className="w-full"
            type="submit"
            disabled={phoneRegisterLoading}
          >
            {phoneRegisterLoading && (
              <Loader2 className="mr-4 h-4 w-4 animate-spin text-white" />
            )}
            注册
          </Button>
        </CardFooter>
      </form>
    </Card>
  )
}

export default PhoneRegister

二、校验规则

使用Zod库来定义手机号和验证码的验证规则代码如下:

ts 复制代码
// src/lib/validator.ts
export const phoneValidator = (phone: string) => {
  return phone.length === 11 && /^1[3-9]\d{9}$/.test(phone)
}

export const phoneCodeValidator = (code: string) => {
  return code.length === 6 && /^\d{6}$/.test(code)
}

export const PhoneFormVaildator = z.object({
  phone: z.string().refine((phone) => phoneValidator(phone), {
    message: validatorMessages.phone,
  }),
  phoneCode: z.string().refine((code) => phoneCodeValidator(code), {
    message: validatorMessages.phoneCode,
  }),
})
export type TPhoneFormVaildator = z.infer<typeof PhoneFormVaildator>

三、提示文案

用到的提示文案如下:

ts 复制代码
const messages = {
    '9993': '手机号码格式不正确',
    '9994': '手机验证码格式不正确',
    //...
    '10018': '输入手机号码',
    '10019': '输入正确的手机号格式',
    '10020': '输入6位数字验证码',
    '10021': '手机号已经注册,请直接登录',
    '10022': '请先获取验证码',
    '10023': '验证码已过期,请重新发送获取',
    '10024': '验证码不正确,请重新输入',
    '10025': '验证码已发送',
    '10026': '获取验证码操作太频繁,请稍后再试',
    '10027': '输入验证码'
}

export const validatorMessages = {
  // ...
  phone: messages['9993'],
  phoneCode: messages['9994'],
}

四、注册逻辑

TRPC的手机注册和发送验证码API如下,注册过程与账号注册一致,校验短信除了前端控制外后端结合过期时间给定了一个1分钟的频繁操作限制,注册了的手机号也不需要再次发生注册短信

ts 复制代码
// src/trpc/index.ts
import { db } from '@/db'
import { publicProcedure, router } from './trpc'
import { z } from 'zod'
import { hash } from 'bcryptjs'
import {
  ManualTRPCError,
  getEmailTemplate,
  handleErrorforInitiative,
} from '@/lib/utils'
import { emailMessages, getMessages } from '@/lib/tips'
import { sendEmail } from '@/lib/sendEmail'
export const appRouter = router({
  // ...
  phoneRegister: publicProcedure
    .input(
      z.object({
        phone: z.string(),
        phoneCode: z.string(),
      })
    )
    .mutation(async ({ input }) => {
      const { phone, phoneCode } = input
      try {
        // 验证参数
        if (!phone || !phoneCode) {
          throw new ManualTRPCError('BAD_REQUEST', getMessages('10012'))
        }
        // 验证手机号码是否已经注册
        const user = await db.user.findFirst({
          where: {
            phone,
          },
        })
        if (user) {
          throw new ManualTRPCError('BAD_REQUEST', getMessages('10021'))
        }
        // 验证手机验证码是否存在
        const phoneCodeRecord = await db.activateToken.findFirst({
          where: {
            account: phone,
          },
        })
        if (!phoneCodeRecord) {
          throw new ManualTRPCError('BAD_REQUEST', getMessages('10022'))
        }
        // 验证手机验证码是否过期
        if (phoneCodeRecord.expiredAt.getTime() < Date.now()) {
          throw new ManualTRPCError('BAD_REQUEST', getMessages('10023'))
        }
        // 验证手机验证码是否正确
        if (phoneCodeRecord.code !== phoneCode) {
          throw new ManualTRPCError('BAD_REQUEST', getMessages('10024'))
        }
        // 创建用户
        const newUser = await db.user.create({
          data: {
            phone,
          },
        })
        return { id: newUser.id, phone }
      } catch (error) {
        handleErrorforInitiative(error)
      }
    }),
  phoneActive: publicProcedure
    .input(z.object({ phone: z.string() }))
    .mutation(async ({ input }) => {
      try {
        const { phone } = input
        // 手机号码是否已经注册
        const user = await db.user.findFirst({
          where: {
            phone,
          },
        })
        if (user) {
          throw new ManualTRPCError('BAD_REQUEST', getMessages('10021'))
        }
        // 验证频繁发送
        const phoneCodeRecord = await db.activateToken.findFirst({
          where: {
            account: phone,
          },
        })
        if (phoneCodeRecord) {
          if (
            phoneCodeRecord.expiredAt.getTime() >
            Date.now() + 1000 * 60 * 4
          ) {
            return { status: 'frequently' }
          }
        }
        // 模拟生成要发送给用户的Code
        const smsCode = String(Math.floor(Math.random() * 900000) + 100000)
        // todo 发送短信
        // 保存验证码=》不存在phone就创建,存在就更新
        if (phoneCodeRecord) {
          // 更新
          await db.activateToken.update({
            where: {
              account: phone,
            },
            data: {
              code: smsCode,
              expiredAt: new Date(Date.now() + 1000 * 60 * 5), // 5分钟过期
            },
          })
        } else {
          // 创建
          await db.activateToken.create({
            data: {
              account: phone,
              code: smsCode,
              expiredAt: new Date(Date.now() + 1000 * 60 * 5), // 5分钟过期
            },
          })
        }
        // 返回状态
        return { status: 'success' }
      } catch (error) {
        handleErrorforInitiative(error)
      }
    }),
})

// export type definition of API
export type AppRouter = typeof appRouter

五、自定义Input

最后是自定义的Input组件,可以设置自定义的前缀包括文字和图标,但是要注册外部给出适当的样式

ts 复制代码
// src/components/ui/input2.tsx
import * as React from 'react'

import { cn } from '@/lib/utils'

export interface InputProps
  extends React.InputHTMLAttributes<HTMLInputElement> {
  customprefix?: React.ReactNode
}

const Input2 = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, type, ...props }, ref) => {
    return (
      <div className="relative">
        <input
          type={type}
          className={cn(
            'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
            className
          )}
          ref={ref}
          {...props}
        />
        <div className="absolute top-1/2 transform -translate-y-1/2 flex items-center">
          {props.customprefix}
        </div>
      </div>
    )
  }
)
Input2.displayName = 'Input'

export { Input2 }

注意:以上短信方法下面要接入阿里云的短信服务来完成

相关推荐
Мартин.1 分钟前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss
昨天;明天。今天。1 小时前
案例-表白墙简单实现
前端·javascript·css
数云界1 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd2 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常2 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
ChinaDragonDreamer2 小时前
Vite:为什么选 Vite
前端
笑非不退2 小时前
前端框架对比和选择
前端框架
小御姐@stella2 小时前
Vue 之组件插槽Slot用法(组件间通信一种方式)
前端·javascript·vue.js
GISer_Jing2 小时前
【React】增量传输与渲染
前端·javascript·面试
eHackyd2 小时前
前端知识汇总(持续更新)
前端