企业级官网全栈(React·Next.js·Tailwind·Axios·Headless UI·RHF·i18n)实战教程-第四篇:登录与注册系统(核心篇)

第四篇:登录与注册系统(核心篇)

技术关键词:
Next.js App Router · RHF · Zod · Headless UI · Axios · next-intl

本篇目标:

构建一个 可扩展、可国际化、可维护 的企业级认证系统,而不是"一堆 useForm"。


一、整体注册 / 登录流程设计

1.注册流程(Register)

bash 复制代码
填写邮箱 + 手机号
        ↓
图形验证码校验
        ↓
发送邮箱验证码 / 短信验证码
        ↓
填写验证码 + 密码
        ↓
注册成功

2.登录流程(Login)

复制代码
邮箱 / 手机号 + 密码
        ↓
(可选)图形验证码
        ↓
登录成功

3.找回密码(Reset)

复制代码
邮箱 / 手机号
        ↓
验证码校验
        ↓
设置新密码

二、核心设计原则(非常重要)

错误做法(99% 项目)

bash 复制代码
useForm()
useForm()
useForm()
useForm()
  • 表单逻辑分散

  • 校验重复

  • 国际化混乱

  • 无法复用


正确做法(企业级)

表单 = Schema + UI + Controller

bash 复制代码
src/
├─ components/
│ ├─ ui/ # shadcn / 纯 UI
│ ├─ form/ # 表单原子控件(Headless UI)
│ │ ├─ TextInput.tsx
│ │ ├─ PasswordInput.tsx
│ │ ├─ AgreeSwitch.tsx
│ │ └─ CaptchaCanvas.tsx
│
├─ forms/
│ ├─ schemas/ # Zod 校验模型(核心)
│ │ ├─ login.schema.ts
│ │ └─ register.schema.ts
│ ├─ components/ # RHF 绑定组件
│ │ ├─ FormInput.tsx
│ │ └─ FormAgree.tsx
│ └─ hooks/
│   ├─ useRegisterForm.ts
│   └─ useLoginForm.ts
│
├─ services/ # API 请求
│ └─ auth.service.ts # Axios API
│
└─ app/[locale]/(auth)/
                ├─ login/page.tsx
                └─ register/page.tsx

三、基础准备:Axios 请求封装

添加axios依赖

bash 复制代码
pnpm add axios

环境变量配置

项目根目录创建文件
bash 复制代码
.env.local        ← 本地开发(local)环境(git ignore)
.env.development  ← 开发(dev)环境(可选)
.env.test         ← 测试(alpha)环境(可选)
.env.staging      ← 准生产(beta)环境(可选)
.env.production   ← 生产(real)环境
.env.local(开发环境)
javascript 复制代码
NEXT_PUBLIC_API_BASE=http://localhost:4000/api
NEXT_PUBLIC_APP_NAME=Enterprise Web
.env.production(生产环境)
javascript 复制代码
NEXT_PUBLIC_API_BASE=https://api.enterprise.com
NEXT_PUBLIC_APP_NAME=Enterprise Web
Next.js 中正确的环境变量分层(非常重要)
1.客户端可用(必须带 NEXT_PUBLIC_
javascript 复制代码
NEXT_PUBLIC_API_BASE=https://api.enterprise.com

用于:

  • Axios

  • fetch

  • 浏览器请求


2.服务端专用(不加前缀)
javascript 复制代码
API_SECRET=xxxx

用于:

  • Server Actions

  • Route Handlers

  • 第三方密钥

类型安全(推荐,但不是必须)
src/env.d.ts
javascript 复制代码
namespace NodeJS {
  interface ProcessEnv {
    NEXT_PUBLIC_API_BASE: string
    NEXT_PUBLIC_APP_NAME: string
  }
}

src/lib/http/client.ts

javascript 复制代码
import axios from 'axios'

if (!process.env.NEXT_PUBLIC_API_BASE) {
  throw new Error('NEXT_PUBLIC_API_BASE is not defined')
}

export const http = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE,
  timeout: 10000,
  withCredentials: true, // 为将来登录态做准备
})

http.interceptors.response.use(
    response => {
        if (response.data && response.data.header && typeof response.data.header.isSuccess === 'boolean') {
            const apiResponse = response.data;
            if (!apiResponse.header.isSuccess) {
                alert(apiResponse.header.message || 'API Error');
                throw new Error(apiResponse.header.message || 'API Error');
            }
        }
        return response;
    },
    error => {
        console.error(error)
        const message =
            error.response?.data?.message ||
            error.message ||
            'Network Error'
        return Promise.reject(new Error(message))
    }
)

response的类型封装

javascript 复制代码
// src/types/api-response.ts

// Base response structure with header and result
export interface ApiResponseHeader {
    code: number;
    message: string;
    isSuccess: boolean;
}

export interface ApiSingleResult<T> {
    data: T;
}

export interface ApiListResult<T> {
    data: T[];
}

export interface Pagination {
    page: number;
    size: number;
    total: number;
    totalPage: number;
}

export interface ApiPagedListResult<T> {
    data: T[];
    pagination: Pagination;
}

export interface ApiResponse<T> {
    header: ApiResponseHeader;
    result: ApiSingleResult<T> | ApiListResult<T> | ApiPagedListResult<T>;
}

export interface ApiSingleResponse<T> {
    header: ApiResponseHeader;
    result: ApiSingleResult<T>;
}

export interface ApiListResponse<T> {
    header: ApiResponseHeader;
    result: ApiListResult<T>;
}

export interface ApiPagedListResponse<T> {
    header: ApiResponseHeader;
    result: ApiPagedListResult<T>;
}

export interface LegacyApiResponse<T> {
    code: number;
    message?: string;
    data: T;
}
javascript 复制代码
// src/types/index.ts
export * from './api-response';

四、Zod Schema 设计(多语言友好)

1.添加zod依赖

javascript 复制代码
pnpm add zod

2.注册 Schema

src/forms/schemas/register.schema.ts
javascript 复制代码
import { z } from 'zod'

export const registerSchema = z.object({
    email: z.email('form.email.invalid'),
    phone: z
        .string()
        .regex(/^1\d{10}$/, 'form.phone.invalid'),
    password: z
        .string()
        .min(8, 'form.password.min')
        .regex(/[A-Z]/, 'form.password.uppercase')
        .regex(/[0-9]/, 'form.password.number'),
    captcha: z.string().min(4, 'form.captcha.invalid'),
    agree: z.boolean().refine(val => val === true, {
        message: 'form.agree.required',
    }),
    code: z.string().min(4, 'form.code.invalid'),
})

export type RegisterFormValues = z.infer<typeof registerSchema>

注意:
不要在 schema 里写死中文错误!


五、Headless UI 表单组件封装(重点)

1.添加react-hook-form依赖

bash 复制代码
pnpm add react-hook-form

2.初始化过 shadcn/ui

bash 复制代码
pnpm dlx shadcn@latest init

2.Input 组件

生成input组件
bash 复制代码
pnpm dlx shadcn@latest add input
src/forms/components/FormInput.tsx
javascript 复制代码
'use client'

import { Input } from '@/components/ui/input'
import { useTranslations } from 'next-intl'
import { FieldError } from 'react-hook-form'

interface Props {
  label: string
  error?: FieldError
  [key: string]: unknown
}

export function FormInput({ label, error, ...props }: Props) {
  const t = useTranslations()

  return (
    <div className="space-y-1">
      <label className="text-sm font-medium">{label}</label>
      <Input {...props} />
      {error && <p className="text-xs text-destructive">{t(error.message || '')}</p>}
    </div>
  )
}

2.Headless UI Switch(同意协议)

src\components\form\AgreeSwitch.tsx
javascript 复制代码
'use client'

import { Switch } from '@headlessui/react'

interface AgreeSwitchProps {
  value: boolean
  onChange: (value: boolean) => void
}

export function AgreeSwitch({ value, onChange }: AgreeSwitchProps) {
  return (
    <Switch
      checked={value}
      onChange={onChange}
      className={`${
        value ? 'bg-primary' : 'bg-muted'
      } relative inline-flex h-6 w-11 items-center rounded-full transition`}
    >
      <span
        className={`${
          value ? 'translate-x-6' : 'translate-x-1'
        } inline-block h-4 w-4 transform rounded-full bg-white transition`}
      />
    </Switch>
  )
}
src\forms\components\FormAgree.tsx
javascript 复制代码
'use client'

import { AgreeSwitch } from '@/components/form/AgreeSwitch'
import { useTranslations } from 'next-intl'
import { Control, Controller, FieldError, Path } from 'react-hook-form'

interface FormAgreeProps<TFieldValues extends Record<string, unknown> = Record<string, unknown>> {
  name: Path<TFieldValues>
  control: Control<TFieldValues>
  label: string
  error?: FieldError
}

export function FormAgree<TFieldValues extends Record<string, unknown> = Record<string, unknown>>({
  name,
  control,
  label,
  error,
}: FormAgreeProps<TFieldValues>) {
  const t = useTranslations()

  return (
    <div className="space-y-1">
      <Controller
        name={name}
        control={control}
        render={({ field }) => (
          <div className="flex items-center gap-3">
            <AgreeSwitch value={field.value as boolean} onChange={field.onChange} />
            <span className="text-sm">{label}</span>
          </div>
        )}
      />
      {error && <p className="text-xs text-destructive">{t(error.message || '')}</p>}
    </div>
  )
}

六、注册表单 Hook(不是堆 useForm)

添加@hookform/resolvers依赖

bash 复制代码
pnpm add @hookform/resolvers

src/forms/hooks/useRegisterForm.ts

javascript 复制代码
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { RegisterFormValues, registerSchema } from '../schemas/register.schema'


export function useRegisterForm() {
    return useForm<RegisterFormValues>({
        resolver: zodResolver(registerSchema),
        defaultValues: {
            email: '',
            phone: '',
            password: '',
            captcha: '',
            agree: false,
        },
    })
}

七、注册页面(完整示例)

src/app/[locale]/register/page.tsx

javascript 复制代码
import Image from "next/image";

export default function Home() {
  return (
    <div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
      <main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
        <Image
          className="dark:invert"
          src="/next.svg"
          alt="Next.js logo"
          width={100}
          height={20}
          priority
        />
        <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
          <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
            To get started, edit the page.tsx file.
          </h1>
          <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
            Looking for a starting point or more instructions? Head over to{" "}
            <a
              href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
              className="font-medium text-zinc-950 dark:text-zinc-50"
            >
              Templates
            </a>{" "}
            or the{" "}
            <a
              href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
              className="font-medium text-zinc-950 dark:text-zinc-50"
            >
              Learning
            </a>{" "}
            center.
          </p>
        </div>
        <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
          <a
            className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
            href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
            target="_blank"
            rel="noopener noreferrer"
          >
            <Image
              className="dark:invert"
              src="/vercel.svg"
              alt="Vercel logomark"
              width={16}
              height={16}
            />
            Deploy Now
          </a>
          <a
            className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
            href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
            target="_blank"
            rel="noopener noreferrer"
          >
            Documentation
          </a>
        </div>
      </main>
    </div>
  );
}

八、登录实现

1.schema

bash 复制代码
// src/forms/schemas/login.schema.ts
import { z } from 'zod'

export const loginSchema = z.object({
    email: z.email('form.email.invalid'),
    password: z.string().min(1, 'form.password.required'),
    captcha: z.string().min(4, 'form.captcha.invalid'),
})

export type LoginFormValues = z.infer<typeof loginSchema>

2.相关组件

TextInput.tsx
javascript 复制代码
'use client'

interface TextInputProps {
  value: string
  onChange: (value: string) => void
  placeholder?: string
  type?: 'text' | 'email' | 'tel'
}

export function TextInput({ value, onChange, placeholder, type = 'text' }: TextInputProps) {
  return (
    <input
      type={type}
      value={value}
      onChange={(e) => onChange(e.target.value)}
      placeholder={placeholder}
      className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
    />
  )
}
PasswordInput.tsx

添加lucide-react依赖

bash 复制代码
pnpm add lucide-react

https://lucide.dev/icons/

javascript 复制代码
// src/components/form/PasswordInput.tsx
'use client'

import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'

interface PasswordInputProps {
  value: string
  onChange: (value: string) => void
  placeholder?: string
}

export function PasswordInput({ value, onChange, placeholder }: PasswordInputProps) {
  const [visible, setVisible] = useState(false)

  return (
    <div className="relative">
      <input
        type={visible ? 'text' : 'password'}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        className="w-full rounded-md border px-3 py-2 pr-10 text-sm outline-none focus:ring-2 focus:ring-primary"
      />
      <button
        type="button"
        onClick={() => setVisible((v) => !v)}
        className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
        aria-label={visible ? 'Hide password' : 'Show password'}
      >
        {visible ? <EyeOff size={16} /> : <Eye size={16} />}
      </button>
    </div>
  )
}
CaptchaCanvas.tsx
javascript 复制代码
// src/components/form/CaptchaCanvas.tsx
'use client'

import { useTranslations } from 'next-intl'
import { useEffect, useRef } from 'react'

interface CaptchaCanvasProps {
  value: string
  onChange: (value: string) => void
  placeholder?: string
}

export function CaptchaCanvas({ value, onChange, placeholder }: CaptchaCanvasProps) {
  const t = useTranslations()
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const captchaRef = useRef<string>('')

  // Generate random captcha text
  const generateCaptcha = () => {
    const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
    let newCaptcha = ''
    for (let i = 0; i < 4; i++) {
      newCaptcha += chars.charAt(Math.floor(Math.random() * chars.length))
    }
    return newCaptcha
  }

  // Draw captcha on canvas
  const drawCaptcha = (text: string) => {
    const canvas = canvasRef.current
    if (!canvas) return

    const ctx = canvas.getContext('2d')
    if (!ctx) return

    // Set canvas dimensions
    canvas.width = 120
    canvas.height = 40

    // Clear canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height)

    // Draw background
    ctx.fillStyle = '#f8f9fa'
    ctx.fillRect(0, 0, canvas.width, canvas.height)

    // Draw random lines
    for (let i = 0; i < 5; i++) {
      ctx.strokeStyle = `hsl(${Math.random() * 360}, 50%, 50%)`
      ctx.beginPath()
      ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height)
      ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height)
      ctx.stroke()
    }

    // Draw captcha text
    ctx.font = 'bold 20px Arial'
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'

    // Draw each character with different color and rotation
    for (let i = 0; i < text.length; i++) {
      ctx.fillStyle = `hsl(${Math.random() * 360}, 50%, 30%)`
      ctx.save()
      ctx.translate(20 + i * 20, 20)
      ctx.rotate((Math.random() - 0.5) * 0.5)
      ctx.fillText(text[i], 0, 0)
      ctx.restore()
    }
  }

  // Initialize captcha
  useEffect(() => {
    const newCaptcha = generateCaptcha()
    captchaRef.current = newCaptcha
    drawCaptcha(newCaptcha)
  }, [])

  const refreshCaptcha = () => {
    const newCaptcha = generateCaptcha()
    captchaRef.current = newCaptcha
    drawCaptcha(newCaptcha)
  }

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onChange(e.target.value)
  }

  const validateCaptcha = () => {
    // This would typically validate the user input against the actual captcha
    // For now, we'll just check if the input matches the current captcha
    return value.toLowerCase() === captchaRef.current.toLowerCase()
  }

  return (
    <div className="flex items-center gap-2">
      <div className="relative">
        <input
          type="text"
          value={value}
          onChange={handleInputChange}
          placeholder={placeholder || 'Enter captcha'}
          className="w-full rounded-md border px-3 py-2 pr-10 text-sm outline-none focus:ring-2 focus:ring-primary"
        />
      </div>
      <div className="flex flex-row gap-1">
        <canvas
          ref={canvasRef}
          onClick={refreshCaptcha}
          className="cursor-pointer rounded border bg-muted"
          aria-label="Captcha image"
        />
        <button
          type="button"
          onClick={refreshCaptcha}
          className="mt-1 text-xs text-muted-foreground hover:underline"
        >
          {t('register.refresh')}
        </button>
      </div>
    </div>
  )
}

3.表单 Hook

javascript 复制代码
// src/forms/hooks/useLoginForm.ts
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { LoginFormValues, loginSchema } from '../schemas/login.schema'

export function useLoginForm() {
    return useForm<LoginFormValues>({
        resolver: zodResolver(loginSchema),
        defaultValues: {
            email: '',
            password: '',
            captcha: '',
        },
    })
}

4.页面

javascript 复制代码
// src/app/[locale]/(auth)/login/page.tsx
'use client'

import { CaptchaCanvas } from '@/components/form/CaptchaCanvas'
import { PasswordInput } from '@/components/form/PasswordInput'
import { TextInput } from '@/components/form/TextInput'
import { Button } from '@/components/ui/button'
import { useLoginForm } from '@/forms/hooks/useLoginForm'
import { useTranslations } from 'next-intl'
import { Controller } from 'react-hook-form'

export default function LoginPage() {
  const form = useLoginForm()
  const t = useTranslations()

  const onSubmit = form.handleSubmit((data) => {
    console.log('login submit', data)
  })

  return (
    <div className="mx-auto max-w-md py-16">
      <h1 className="mb-6 text-2xl font-bold">{t('login.title')}</h1>
      <form onSubmit={onSubmit} className="mx-auto mt-10 max-w-md space-y-4">
        <Controller
          control={form.control}
          name="email"
          render={({ field, fieldState }) => (
            <div>
              <TextInput
                type="email"
                value={field.value}
                onChange={field.onChange}
                placeholder={t('register.email')}
              />
              {fieldState.error && (
                <p className="text-xs text-destructive">{t(fieldState.error?.message || '')}</p>
              )}
            </div>
          )}
        />

        <Controller
          control={form.control}
          name="password"
          render={({ field, fieldState }) => (
            <div>
              <PasswordInput
                value={field.value}
                onChange={field.onChange}
                placeholder={t('register.password')}
              />
              {fieldState.error && (
                <p className="text-xs text-destructive">{t(fieldState.error?.message || '')}</p>
              )}
            </div>
          )}
        />

        <Controller
          control={form.control}
          name="captcha"
          render={({ field, fieldState }) => (
            <div>
              <CaptchaCanvas
                value={field.value}
                onChange={field.onChange}
                placeholder={t('register.captcha')}
              />
              {fieldState.error && (
                <p className="text-xs text-destructive">{t(fieldState.error?.message || '')}</p>
              )}
            </div>
          )}
        />

        <Button type="submit" className="w-full">
          {t('login.submit')}
        </Button>
      </form>
    </div>
  )
}

八、国际化内容

src/i18n/messages/zh.json
javascript 复制代码
{
  "nav": {
    "products": "产品",
    "news": "新闻",
    "careers": "招聘",
    "docs": "文档"
  },
  "user_menu": {
    "profile": "个人资料",
    "user_center": "用户中心",
    "login_setting": "登录设置",
    "payment_method": "支付方式",
    "guest": "访客"
  },
  "auth": {
    "login": "登录",
    "logout": "退出登录",
    "register": "注册"
  },
  "footer": {
    "address": "北京市朝阳区某某科技园",
    "phone": "电话",
    "copyright": "版权所有"
  },
  "register": {
    "title": "注册",
    "email": "邮箱",
    "phone": "手机号",
    "password": "密码",
    "code": "手机验证码",
    "captcha": "验证码",
    "confirm_password": "确认密码",
    "agree_terms": "同意用户协议",
    "submit": "注册",
    "send_code": "发送验证码",
    "sending": "发送中..."
  },
  "login": {
    "title": "登录",
    "email": "邮箱",
    "password": "密码",
    "submit": "登录"
  },
  "form": {
    "email": {
      "invalid": "邮箱格式无效"
    },
    "phone": {
      "required": "请输入手机号",
      "invalid": "手机号格式无效"
    },
    "password": {
      "min": "密码长度不能小于8位",
      "uppercase": "密码必须包含大写字母",
      "invalid": "密码格式无效",
      "required": "请输入密码"
    },
    "captcha": {
      "invalid": "验证码格式无效"
    },
    "code": {
      "invalid": "手机验证码格式无效"
    },
    "agree": {
      "required": "必须同意用户协议"
    }
  }
}
src/i18n/messages/en.json
javascript 复制代码
{
  "nav": {
    "products": "产品",
    "news": "新闻",
    "careers": "招聘",
    "docs": "文档"
  },
  "user_menu": {
    "profile": "个人资料",
    "user_center": "用户中心",
    "login_setting": "登录设置",
    "payment_method": "支付方式",
    "guest": "访客"
  },
  "auth": {
    "login": "登录",
    "logout": "退出登录",
    "register": "注册"
  },
  "footer": {
    "address": "北京市朝阳区某某科技园",
    "phone": "电话",
    "copyright": "版权所有"
  },
  "register": {
    "title": "注册",
    "email": "邮箱",
    "phone": "手机号",
    "password": "密码",
    "code": "手机验证码",
    "captcha": "验证码",
    "confirm_password": "确认密码",
    "agree_terms": "同意用户协议",
    "submit": "注册",
    "send_code": "发送验证码",
    "sending": "发送中...",
    "refresh": "刷新"
  },
  "login": {
    "title": "登录",
    "email": "邮箱",
    "password": "密码",
    "submit": "登录"
  },
  "form": {
    "email": {
      "invalid": "邮箱格式无效"
    },
    "phone": {
      "required": "请输入手机号",
      "invalid": "手机号格式无效"
    },
    "password": {
      "min": "密码长度不能小于8位",
      "uppercase": "密码必须包含大写字母",
      "invalid": "密码格式无效",
      "required": "请输入密码"
    },
    "captcha": {
      "invalid": "验证码格式无效"
    },
    "code": {
      "invalid": "手机验证码格式无效"
    },
    "agree": {
      "required": "必须同意用户协议"
    }
  }
}

九、运行效果

修改src/components/layout/UserMenu.tsx

javascript 复制代码
import { maskEmail } from '@/lib/utils/mask-email'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'

interface UserMenuProps {
  email?: string
}

export function UserMenu({ email }: UserMenuProps) {
  const t = useTranslations()

  return (
    <Menu as="div" className="relative">
      <MenuButton className="flex items-center space-x-2 text-sm">
        <span>{email ? maskEmail(email) : t('user_menu.guest')}</span>
        <span>▼</span>
      </MenuButton>

      <MenuItems className="absolute right-0 mt-2 w-40 rounded-md border bg-background shadow">
        {email ? (
          <>
            <MenuItem>
              {({ focus }) => (
                <Link
                  href="/user"
                  className={`block px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
                >
                  {t('user_menu.user_center')}
                </Link>
              )}
            </MenuItem>

            <MenuItem>
              {({ focus }) => (
                <Link
                  href="/user/settings"
                  className={`block px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
                >
                  {t('user_menu.login_setting')}
                </Link>
              )}
            </MenuItem>

            <MenuItem>
              {({ focus }) => (
                <Link
                  href="/user/payment"
                  className={`block px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
                >
                  {t('user_menu.payment_method')}
                </Link>
              )}
            </MenuItem>

            <MenuItem>
              {({ focus }) => (
                <button
                  className={`w-full text-left px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
                  onClick={() => console.log('Logout functionality would go here')}
                >
                  {t('auth.logout')}
                </button>
              )}
            </MenuItem>
          </>
        ) : (
          <>
            <MenuItem>
              {({ focus }) => (
                <Link
                  href={`/register`}
                  className={`block px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
                >
                  {t('auth.register')}
                </Link>
              )}
            </MenuItem>
            <MenuItem>
              {({ focus }) => (
                <Link
                  href={`/login`}
                  className={`block px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
                >
                  {t('auth.login')}
                </Link>
              )}
            </MenuItem>
          </>
        )}
      </MenuItems>
    </Menu>
  )
}

修改src/components/layout/Header.tsx

javascript 复制代码
// 临时模拟登录态(后续会替换为真实 auth)
const mockUser = {
  // email: 'user@example.com',
  email: '',
}

运行

javascript 复制代码
pnpm dev

点击注册

点击注册按钮

英语

登录


十、services

javascript 复制代码
// src/types/user.ts
export interface User {
    id: string
    email: string
    name: string
    role: 'admin' | 'user'
}

auth.service.ts

javascript 复制代码
// src/services/auth.service.ts
import { LoginFormValues } from '@/forms/schemas/login.schema'
import { RegisterFormValues } from '@/forms/schemas/register.schema'
import { http } from '@/lib/http/client'
import { ApiSingleResponse } from '@/types'
import { User } from '@/types/user'

interface LoginResponse {
    token: string
    user: User
}

export const authService = {
    login(data: LoginFormValues) {
        return http.post<ApiSingleResponse<LoginResponse>>('/auth/login', data)
    },

    register(data: RegisterFormValues) {
        return http.post<ApiSingleResponse<LoginResponse>>('/auth/register', data)
    },
}

十一、图形验证码设计思路(不写死实现)

推荐方案

方案 说明
Canvas 前端生成,防脚本
后端生成 Redis + token
第三方 GeeTest / reCAPTCHA

企业常用:

复制代码
GET /captcha
→ { image, captchaId }

POST /register
→ captchaId + captchaCode

十二、你现在拥有的能力

你已经完成了:

  • 企业级表单架构

  • RHF + Zod 解耦设计

  • Headless UI 表单封装

  • 完整 i18n 校验体系

  • 登录 / 注册 / 找回密码通用能力


下一篇预告

第五篇:认证系统进阶

  • 登录态管理(JWT / Cookie)

  • 用户信息 Context

  • Header 登录态联动

  • 路由权限保护(middleware)

相关推荐
Tigger4 分钟前
用 Vue 3 做了一套年会抽奖工具,顺便踩了些坑
前端·javascript·vue.js
天天扭码9 分钟前
一文搞懂——React 19到底更新了什么
前端·react.js·前端框架
OpenTiny社区23 分钟前
OpenTiny 2025年度贡献者榜单正式公布~
前端·javascript·vue.js
OEC小胖胖27 分钟前
08|Commit 阶段:副作用如何被组织、执行与约束
前端·react.js·前端框架·react·开源库
biubiubiu070631 分钟前
Vue脚手架创建项目记录
javascript·vue.js·ecmascript
前端付豪1 小时前
必知Node应用性能提升及API test 接口测试
前端·react.js·node.js
boooooooom1 小时前
手写简易Vue响应式:基于Proxy + effect的核心实现
javascript·vue.js
guangzan1 小时前
AI → JSON → UI
ui·ai·zod
bug总结1 小时前
uniapp+动态设置顶部导航栏使用详解
java·前端·javascript
晴殇i1 小时前
深入理解MessageChannel:JS双向通信的高效解决方案
前端·javascript·程序员