本节介绍在应用中实现用户的登录功能,包括通过手机短信和邮箱两种方式登录,风格与注册页一致,需要删除一些字段,改造后如下图
接下来开始我们的代码
一、创建登录页面入口
首先,我们需要创建一个登录页面,这个页面的入口和注册页面类似。在 src/app/login/page.tsx
文件中,我们导入 Form
组件。
ts
// src/app/login/page.tsx
import Form from './form'
export default async function LoginPage() {
return <Form />
}
二、创建登录表单
接下来,我们需要创建一个登录表单,该表单包含两种登录方式:短信登录和邮箱登录。在 src/app/login/form.tsx
文件中,我们使用 Tabs
组件创建两个选项卡,分别对应两种登录方式
ts
// src/app/login/form.tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import EmailLogin from './EmailLogin'
import PhoneLogin from './PhoneLogin'
export default function Form() {
return (
<>
<div className="text-3xl font-bold text-center mt-10">登录</div>
<Tabs defaultValue="phone" className="w-full max-w-lg mx-auto p-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="phone">短信登录</TabsTrigger>
<TabsTrigger value="email">账号登录</TabsTrigger>
</TabsList>
<TabsContent value="phone">
<PhoneLogin />
</TabsContent>
<TabsContent value="email">
<EmailLogin />
</TabsContent>
</Tabs>
</>
)
}
三、创建登录方式组件
为了实现两种登录方式,我们需要创建两个组件:PhoneLogin
和 EmailLogin
。这两个组件的主要任务是收集用户的登录信息。
1. 手机短信登录
在 src/app/login/PhoneLogin.tsx
文件中,我们创建 PhoneLogin
组件,该组件主要用于收集用户的手机号和短信验证码。
ts
// src/app/login/PhoneLogin.tsx
'use client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, 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,
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 PhoneLogin() {
const { toast } = useToast()
const [count, setCount] = useState<number>(60) // 倒计时60秒
const [isActive, setIsActive] = useState<boolean>(false) // 是否开始倒计时
const [logging, setLogging] = 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,
watch,
clearErrors,
} = useForm<TPhoneFormVaildator>({
defaultValues: {},
resolver: zodResolver(PhoneFormVaildator),
mode: 'all',
})
const phone = watch('phone')
const { mutate: startPhoneActive, isLoading: phoneActiveLoading } =
trpc.phoneActive.useMutation({
onSuccess: (info) => {
if (info && info.status === 'frequently') {
// 频繁
setError('phoneCode', {
message: getMessages('10026'),
})
return
}
toast({
title: getMessages('10030'),
description: getMessages('10025'),
variant: 'default',
})
setIsActive(true)
},
onError: (error) => {
toast({
title: getMessages('10030'),
description: error.message,
variant: 'destructive',
})
},
})
const handleActivePhone = useCallback(() => {
clearErrors(['phone', 'phoneCode'])
if (!phoneValidator(phone)) {
setError('phone', {
type: 'manual',
message: getMessages('10019'),
})
return
}
startPhoneActive({ phone, login: true })
}, [clearErrors, phone, setError, startPhoneActive])
const handleSubmitPhone = useCallback(
async ({ phone, phoneCode }: TPhoneFormVaildator) => {
setLogging(true)
console.log(phone, phoneCode)
// todo 登录
setTimeout(() => {
setLogging(false)
}, 2000)
},
[]
)
return (
<Card>
<CardHeader />
<form
id="phoneLogin"
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}
>
{phoneActiveLoading ? (
<>
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
<span className="ml-1">发送中</span>
</>
) : 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={logging}>
{logging && (
<Loader2 className="mr-4 h-4 w-4 animate-spin text-white" />
)}
登录
</Button>
</CardFooter>
</form>
</Card>
)
}
export default PhoneLogin
2. 处理验证码逻辑
这部分逻辑可以复用注册时的 phoneActive
函数,我们只需要多传入一个布尔参数来判断是登录页面的验证码逻辑,如果登录页没有用户抛出错误去注册,有用户不是登录页就抛出错误去登录,其他逻辑不变
ts
phoneActive: publicProcedure
.input(z.object({ phone: z.string(),login: z.boolean().optional() }))
.mutation(async ({ input }) => {
try {
const { phone,login } = input
// 手机号码是否已经注册
const user = await db.user.findFirst({
where: {
phone,
},
})
if (login && !user) {
throw new ManualTRPCError('BAD_REQUEST', getMessages('10028'))
}
if (user && !login) {
throw new ManualTRPCError('BAD_REQUEST', getMessages('10021'))
}
// 验证频繁发送
// ...
} catch (error) {
handleErrorforInitiative(error)
}
}),
3. 邮箱登录
在 src/app/login/EmailLogin.tsx
文件中,我们创建 EmailLogin
组件,该组件主要用于收集用户的邮箱和密码。
ts
// src/app/login/EmailLogin.tsx
'use client'
import { useCallback, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { EmailLoginVaildator, TEmailLoginVaildator } from '@/lib/validator'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { getMessages } from '@/lib/tips'
function EmailLogin() {
const [logging, setLogging] = useState<boolean>(false) // 开始登录
const {
register,
handleSubmit,
formState: { errors },
setValue,
} = useForm<TEmailLoginVaildator>({
defaultValues: {},
resolver: zodResolver(EmailLoginVaildator),
mode: 'all',
})
const handleSubmitEmail = useCallback(
async ({ email, password }: TEmailLoginVaildator) => {
setLogging(true)
console.log(email, password)
// todo 登录
setTimeout(() => {
setLogging(false)
}, 2000)
},
[]
)
return (
<Card>
<CardHeader />
<form
id="emailLogin"
onSubmit={(e) => {
e.preventDefault()
handleSubmit(handleSubmitEmail)()
}}
>
<CardContent className="space-y-2">
<div className="space-y-1">
<Label className="text-zinc-600" htmlFor="email">
邮箱:
</Label>
<Input
{...register('email')}
onBlur={(e) => {
setValue('email', e.target.value)
}}
className={cn(errors.email && 'focus-visible:ring-red-500')}
id="email"
placeholder={getMessages('10008')}
autoComplete="username"
type="email"
/>
<div className="text-destructive text-xs mt-1">
{errors.email ? errors.email.message : null}
</div>
</div>
<div className="space-y-1">
<Label className="text-zinc-600" htmlFor="password">
密码:
</Label>
<Input
{...register('password')}
className={cn(errors.password && 'focus-visible:ring-red-500')}
onBlur={(e) => {
setValue('password', e.target.value)
}}
id="password"
placeholder={getMessages('10009')}
autoComplete="new-password"
type="password"
/>
<div className="text-destructive text-xs mt-1">
{errors.password ? errors.password.message : null}
</div>
</div>
</CardContent>
<CardFooter>
<Button className="w-full" type="submit" disabled={logging}>
{logging && (
<Loader2 className="mr-4 h-4 w-4 animate-spin text-white" />
)}
登录
</Button>
</CardFooter>
</form>
</Card>
)
}
export default EmailLogin
四、验证用户输入
为了保证用户输入密码具有一定的复杂性,需要对用户的输入的密码进行验证。在 src/lib/validator.ts
文件中,我们抽离出了 passwordVaildator
函数,用于验证用户输入的密码是否满足要求。
ts
// src/lib/validator.ts
export const passwordVaildator = (password: string) =>
password.length >= 8 &&
password.length <= 20 &&
/\d/.test(password) && // 包含数字
/[a-z]/.test(password) && // 包含小写字母
/[A-Z]/.test(password) && // 包含大写字母
/\W|_/.test(password)
const passwordSchema = z
.string()
.refine((password) => passwordVaildator(password), {
message: validatorMessages.password,
})
export const EmailLoginVaildator = z.object({
email: z.string().refine((email) => emailValidator.validate(email), {
message: validatorMessages.email,
}),
password: passwordSchema,
})
export type TEmailLoginVaildator = z.infer<typeof EmailLoginVaildator>
五、添加提示信息
添加本节内容用到的提示信息
arduino
'10028': '手机号未注册,前往注册',
'10029': '密码必须包含数字、大写字母、小写字母和符号,长度为8 ~ 20个字符',
'10030': '账号登录',
六、注册与登录互转
增加一个NavigationPrompt
组件,用于在登录和注册页之间跳转,在对应的表单组件使用
ts
// src/components/NavigationPrompt.tsx
import Link from 'next/link'
const NavigationPrompt = ({
isLoginToRegister,
}: {
isLoginToRegister?: boolean
}) => {
return (
<div className="p-6 pt-0 text-center text-sm">
{isLoginToRegister ? (
<>
<span className="text-zinc-600">还没有账号?</span>
<Link href="/register" className="text-primary">
去注册
</Link>
</>
) : (
<>
<span className="text-zinc-600">已经有账号了?</span>
<Link href="/login" className="text-primary">
去登录
</Link>
</>
)}
</div>
)
}
export default NavigationPrompt
同时补充在注册后跳转到登录页
ts
// 跳转
import { useRouter } from 'next/navigation'
const router = useRouter()
router.push('/login')
七、Fix
更新这4个模型的autocomplete
ini
账号登录:
邮箱输入框:autocomplete="email"
密码输入框:autocomplete="current-password"
手机号登录:
手机号输入框:autocomplete="tel"
验证码输入框:autocomplete="one-time-code"
手机号注册:
手机号输入框:autocomplete="tel"
验证码输入框:autocomplete="one-time-code"
账号注册:
邮箱输入框:autocomplete="email"
密码输入框:autocomplete="new-password"
确认密码输入框:autocomplete="new-password"
邮箱激活码:autocomplete="one-time-code"