第八章 创建用户登录界面

本节介绍在应用中实现用户的登录功能,包括通过手机短信和邮箱两种方式登录,风格与注册页一致,需要删除一些字段,改造后如下图

接下来开始我们的代码

一、创建登录页面入口

首先,我们需要创建一个登录页面,这个页面的入口和注册页面类似。在 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>
    </>
  )
}

三、创建登录方式组件

为了实现两种登录方式,我们需要创建两个组件:PhoneLoginEmailLogin。这两个组件的主要任务是收集用户的登录信息。

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"
相关推荐
etsuyou2 分钟前
Koa学习
服务器·前端·学习
Easonmax17 分钟前
【CSS3】css开篇基础(1)
前端·css
大鱼前端36 分钟前
未来前端发展方向:深度探索与技术前瞻
前端
昨天;明天。今天。41 分钟前
案例-博客页面简单实现
前端·javascript·css
天上掉下来个程小白42 分钟前
请求响应-08.响应-案例
java·服务器·前端·springboot
周太密1 小时前
使用 Vue 3 和 Element Plus 构建动态酒店日历组件
前端
时清云2 小时前
【算法】合并两个有序链表
前端·算法·面试
小爱丨同学2 小时前
宏队列和微队列
前端·javascript
weixin_446260852 小时前
前端框架选择指南
前端框架
持久的棒棒君2 小时前
ElementUI 2.x 输入框回车后在调用接口进行远程搜索功能
前端·javascript·elementui