我们首先完成短信注册的逻辑,前面我们预留了一个空的手机注册组件,现在来完成,基本表单代码逻辑与账号注册一致,增加了一些细节,比如获取短信时的计时,重新定义了一个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 }
注意:以上短信方法下面要接入阿里云的短信服务来完成