第四篇:登录与注册系统(核心篇)
技术关键词:
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

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)