Appearance
Form Actions实战案例
学习目标
通过本章学习,你将掌握:
- 完整的表单应用实现
- 用户认证系统
- 内容管理系统
- 电商结账流程
- 文件上传系统
- 评论系统
- 搜索功能
- 实际项目架构
第一部分:用户认证系统
1.1 注册表单
javascript
// app/actions/auth.js
'use server';
import { hash } from 'bcrypt';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { db } from '@/lib/database';
import { createSession } from '@/lib/auth';
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email('邮箱格式不正确'),
password: z.string()
.min(8, '密码至少8个字符')
.regex(/[A-Z]/, '密码必须包含大写字母')
.regex(/[0-9]/, '密码必须包含数字'),
name: z.string().min(2, '姓名至少2个字符')
});
export async function register(prevState, formData) {
const rawData = {
email: formData.get('email'),
password: formData.get('password'),
name: formData.get('name')
};
const result = registerSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
};
}
const { email, password, name } = result.data;
const existingUser = await db.users.findUnique({
where: { email }
});
if (existingUser) {
return {
success: false,
errors: { email: '邮箱已被注册' }
};
}
const passwordHash = await hash(password, 10);
const user = await db.users.create({
data: {
email,
password: passwordHash,
name
}
});
const session = await createSession(user.id);
cookies().set('session', session.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7,
path: '/'
});
redirect('/dashboard');
}
// app/register/page.jsx
'use client';
import { useActionState } from 'react';
import { register } from './actions';
export default function RegisterPage() {
const [state, formAction, isPending] = useActionState(register, {
success: false,
errors: {}
});
return (
<div className="register-page">
<h1>注册账号</h1>
<form action={formAction}>
<div>
<label htmlFor="name">姓名</label>
<input
id="name"
name="name"
required
/>
{state.errors?.name && (
<span className="error">{state.errors.name[0]}</span>
)}
</div>
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
name="email"
type="email"
required
/>
{state.errors?.email && (
<span className="error">{state.errors.email[0]}</span>
)}
</div>
<div>
<label htmlFor="password">密码</label>
<input
id="password"
name="password"
type="password"
required
/>
{state.errors?.password && (
<span className="error">{state.errors.password[0]}</span>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? '注册中...' : '注册'}
</button>
</form>
<p>
已有账号?<a href="/login">登录</a>
</p>
</div>
);
}1.2 登录表单
javascript
// app/actions/auth.js
'use server';
import { compare } from 'bcrypt';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function login(prevState, formData) {
const email = formData.get('email');
const password = formData.get('password');
if (!email || !password) {
return {
success: false,
error: '请填写完整信息'
};
}
const user = await db.users.findUnique({
where: { email }
});
if (!user) {
return {
success: false,
error: '邮箱或密码错误'
};
}
const passwordValid = await compare(password, user.password);
if (!passwordValid) {
return {
success: false,
error: '邮箱或密码错误'
};
}
const session = await createSession(user.id);
cookies().set('session', session.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7,
path: '/'
});
redirect('/dashboard');
}
// app/login/page.jsx
'use client';
import { useActionState } from 'react';
import { login } from './actions';
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(login, {
success: false
});
return (
<div className="login-page">
<h1>登录</h1>
{state.error && (
<div className="error">{state.error}</div>
)}
<form action={formAction}>
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
name="email"
type="email"
required
/>
</div>
<div>
<label htmlFor="password">密码</label>
<input
id="password"
name="password"
type="password"
required
/>
</div>
<div>
<label>
<input type="checkbox" name="remember" />
记住我
</label>
</div>
<button type="submit" disabled={isPending}>
{isPending ? '登录中...' : '登录'}
</button>
</form>
<p>
还没账号?<a href="/register">注册</a>
</p>
</div>
);
}1.3 密码重置
javascript
// app/actions/auth.js
'use server';
import { randomBytes } from 'crypto';
import { sendEmail } from '@/lib/email';
export async function requestPasswordReset(prevState, formData) {
const email = formData.get('email');
if (!email) {
return {
success: false,
error: '请输入邮箱'
};
}
const user = await db.users.findUnique({
where: { email }
});
if (!user) {
return {
success: true,
message: '如果邮箱存在,重置链接已发送'
};
}
const token = randomBytes(32).toString('hex');
await db.passwordResetTokens.create({
data: {
userId: user.id,
token,
expiresAt: new Date(Date.now() + 3600000)
}
});
await sendEmail({
to: email,
subject: '密码重置',
html: `
<p>点击以下链接重置密码:</p>
<a href="${process.env.APP_URL}/reset-password?token=${token}">
重置密码
</a>
`
});
return {
success: true,
message: '重置链接已发送到您的邮箱'
};
}
export async function resetPassword(prevState, formData) {
const token = formData.get('token');
const password = formData.get('password');
const resetToken = await db.passwordResetTokens.findUnique({
where: { token },
include: { user: true }
});
if (!resetToken || resetToken.expiresAt < new Date()) {
return {
success: false,
error: '重置链接无效或已过期'
};
}
const passwordHash = await hash(password, 10);
await db.users.update({
where: { id: resetToken.userId },
data: { password: passwordHash }
});
await db.passwordResetTokens.delete({
where: { token }
});
return {
success: true,
message: '密码已重置,请登录'
};
}第二部分:博客管理系统
2.1 创建文章
javascript
// app/actions/posts.js
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth';
import { uploadToS3 } from '@/lib/storage';
export async function createPost(prevState, formData) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const title = formData.get('title');
const content = formData.get('content');
const excerpt = formData.get('excerpt');
const coverImage = formData.get('coverImage');
const tags = formData.get('tags')?.split(',').map(t => t.trim()) || [];
const errors = {};
if (!title || title.length < 3) {
errors.title = '标题至少3个字符';
}
if (!content || content.length < 100) {
errors.content = '内容至少100个字符';
}
if (Object.keys(errors).length > 0) {
return {
success: false,
errors
};
}
let coverImageUrl = null;
if (coverImage && coverImage.size > 0) {
if (coverImage.size > 2 * 1024 * 1024) {
return {
success: false,
errors: { coverImage: '图片大小不能超过2MB' }
};
}
const uploaded = await uploadToS3(coverImage, 'posts');
coverImageUrl = uploaded.url;
}
const post = await db.posts.create({
data: {
title,
content,
excerpt: excerpt || content.slice(0, 200),
coverImage: coverImageUrl,
tags,
authorId: user.id,
published: false
}
});
revalidatePath('/dashboard/posts');
redirect(`/dashboard/posts/${post.id}`);
}
// app/dashboard/posts/new/page.jsx
'use client';
import { useActionState } from 'react';
import { createPost } from './actions';
import MarkdownEditor from '@/components/MarkdownEditor';
export default function NewPostPage() {
const [state, formAction, isPending] = useActionState(createPost, {
success: false,
errors: {}
});
return (
<div className="new-post-page">
<h1>新建文章</h1>
<form action={formAction}>
<div>
<label htmlFor="title">标题</label>
<input
id="title"
name="title"
required
/>
{state.errors?.title && (
<span className="error">{state.errors.title}</span>
)}
</div>
<div>
<label htmlFor="excerpt">摘要</label>
<textarea
id="excerpt"
name="excerpt"
rows={3}
/>
</div>
<div>
<label htmlFor="coverImage">封面图</label>
<input
id="coverImage"
name="coverImage"
type="file"
accept="image/*"
/>
{state.errors?.coverImage && (
<span className="error">{state.errors.coverImage}</span>
)}
</div>
<div>
<label htmlFor="content">内容</label>
<MarkdownEditor name="content" />
{state.errors?.content && (
<span className="error">{state.errors.content}</span>
)}
</div>
<div>
<label htmlFor="tags">标签</label>
<input
id="tags"
name="tags"
placeholder="用逗号分隔,如: React, TypeScript"
/>
</div>
<div className="actions">
<button
type="submit"
name="action"
value="draft"
disabled={isPending}
>
保存草稿
</button>
<button
type="submit"
name="action"
value="publish"
disabled={isPending}
>
发布
</button>
</div>
</form>
</div>
);
}2.2 评论系统
javascript
// app/actions/comments.js
'use server';
import { revalidatePath } from 'next/cache';
import { getCurrentUser } from '@/lib/auth';
export async function createComment(prevState, formData) {
const user = await getCurrentUser();
if (!user) {
return {
success: false,
error: '请先登录'
};
}
const postId = formData.get('postId');
const content = formData.get('content');
const parentId = formData.get('parentId');
if (!content || content.length < 5) {
return {
success: false,
error: '评论至少5个字符'
};
}
const comment = await db.comments.create({
data: {
content,
postId,
authorId: user.id,
parentId: parentId || null
},
include: {
author: {
select: {
id: true,
name: true,
avatar: true
}
}
}
});
revalidatePath(`/posts/${postId}`);
return {
success: true,
comment
};
}
// app/posts/[id]/CommentForm.jsx
'use client';
import { useActionState, useRef, useEffect } from 'react';
import { createComment } from './actions';
export default function CommentForm({ postId, parentId = null, onSuccess }) {
const formRef = useRef(null);
const [state, formAction, isPending] = useActionState(
createComment,
{ success: false }
);
useEffect(() => {
if (state.success) {
formRef.current?.reset();
onSuccess?.();
}
}, [state.success, onSuccess]);
return (
<form ref={formRef} action={formAction}>
<input type="hidden" name="postId" value={postId} />
{parentId && (
<input type="hidden" name="parentId" value={parentId} />
)}
<textarea
name="content"
placeholder={parentId ? '回复...' : '写下你的评论...'}
required
minLength={5}
/>
{state.error && (
<div className="error">{state.error}</div>
)}
{state.success && (
<div className="success">评论已发布!</div>
)}
<button type="submit" disabled={isPending}>
{isPending ? '发布中...' : '发布评论'}
</button>
</form>
);
}第三部分:电商结账流程
3.1 购物车
javascript
// app/actions/cart.js
'use server';
import { revalidatePath } from 'next/cache';
import { getCurrentUser } from '@/lib/auth';
export async function addToCart(prevState, formData) {
const user = await getCurrentUser();
if (!user) {
return {
success: false,
error: '请先登录'
};
}
const productId = formData.get('productId');
const quantity = Number(formData.get('quantity'));
if (!productId || quantity < 1) {
return {
success: false,
error: '无效的商品或数量'
};
}
const product = await db.products.findUnique({
where: { id: productId }
});
if (!product || product.stock < quantity) {
return {
success: false,
error: '商品库存不足'
};
}
await db.cartItems.upsert({
where: {
userId_productId: {
userId: user.id,
productId
}
},
create: {
userId: user.id,
productId,
quantity
},
update: {
quantity: {
increment: quantity
}
}
});
revalidatePath('/cart');
return {
success: true,
message: '已添加到购物车'
};
}
export async function updateCartItem(prevState, formData) {
const user = await getCurrentUser();
if (!user) {
return {
success: false,
error: '请先登录'
};
}
const itemId = formData.get('itemId');
const quantity = Number(formData.get('quantity'));
if (quantity < 1) {
await db.cartItems.delete({
where: { id: itemId, userId: user.id }
});
} else {
await db.cartItems.update({
where: { id: itemId, userId: user.id },
data: { quantity }
});
}
revalidatePath('/cart');
return { success: true };
}3.2 结账表单
javascript
// app/actions/checkout.js
'use server';
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth';
import { processPayment } from '@/lib/payment';
export async function checkout(prevState, formData) {
const user = await getCurrentUser();
if (!user) {
redirect('/login?redirect=/checkout');
}
const cartItems = await db.cartItems.findMany({
where: { userId: user.id },
include: { product: true }
});
if (cartItems.length === 0) {
return {
success: false,
error: '购物车为空'
};
}
const shippingAddress = {
name: formData.get('name'),
phone: formData.get('phone'),
address: formData.get('address'),
city: formData.get('city'),
zipCode: formData.get('zipCode')
};
const errors = {};
if (!shippingAddress.name) errors.name = '请输入收件人姓名';
if (!shippingAddress.phone) errors.phone = '请输入联系电话';
if (!shippingAddress.address) errors.address = '请输入详细地址';
if (Object.keys(errors).length > 0) {
return {
success: false,
errors
};
}
const total = cartItems.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
);
const order = await db.orders.create({
data: {
userId: user.id,
total,
shippingAddress: JSON.stringify(shippingAddress),
status: 'pending',
items: {
create: cartItems.map(item => ({
productId: item.productId,
quantity: item.quantity,
price: item.product.price
}))
}
}
});
try {
await processPayment({
orderId: order.id,
amount: total,
paymentMethod: formData.get('paymentMethod')
});
await db.orders.update({
where: { id: order.id },
data: { status: 'paid' }
});
await db.cartItems.deleteMany({
where: { userId: user.id }
});
redirect(`/orders/${order.id}/success`);
} catch (error) {
return {
success: false,
error: '支付失败,请重试'
};
}
}第四部分:搜索和过滤
4.1 搜索表单
javascript
// app/search/page.jsx
import { db } from '@/lib/database';
import SearchForm from './SearchForm';
import SearchResults from './SearchResults';
async function searchProducts(query, filters = {}) {
const where = {};
if (query) {
where.OR = [
{ name: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } }
];
}
if (filters.category) {
where.categoryId = filters.category;
}
if (filters.minPrice || filters.maxPrice) {
where.price = {};
if (filters.minPrice) where.price.gte = Number(filters.minPrice);
if (filters.maxPrice) where.price.lte = Number(filters.maxPrice);
}
const products = await db.products.findMany({
where,
take: 50
});
return products;
}
export default async function SearchPage({ searchParams }) {
const query = searchParams.q || '';
const filters = {
category: searchParams.category,
minPrice: searchParams.minPrice,
maxPrice: searchParams.maxPrice
};
const products = await searchProducts(query, filters);
return (
<div className="search-page">
<SearchForm initialQuery={query} initialFilters={filters} />
<SearchResults products={products} query={query} />
</div>
);
}
// app/search/SearchForm.jsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
export default function SearchForm({ initialQuery, initialFilters }) {
const router = useRouter();
const searchParams = useSearchParams();
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value) {
params.set(key, value);
}
}
router.push(`/search?${params.toString()}`);
};
return (
<form onSubmit={handleSubmit}>
<input
name="q"
defaultValue={initialQuery}
placeholder="搜索商品..."
/>
<select name="category" defaultValue={initialFilters.category}>
<option value="">所有分类</option>
<option value="electronics">电子产品</option>
<option value="clothing">服装</option>
<option value="books">图书</option>
</select>
<input
name="minPrice"
type="number"
placeholder="最低价格"
defaultValue={initialFilters.minPrice}
/>
<input
name="maxPrice"
type="number"
placeholder="最高价格"
defaultValue={initialFilters.maxPrice}
/>
<button type="submit">搜索</button>
</form>
);
}注意事项
1. 安全性
javascript
// ✅ 始终验证权限
const user = await getCurrentUser();
if (!user) {
throw new Error('未登录');
}
// ✅ 验证输入
if (!title || title.length < 3) {
return { error: '标题至少3个字符' };
}
// ✅ 防止SQL注入(使用ORM)
await db.posts.findMany({
where: {
title: { contains: query }
}
});2. 用户体验
javascript
// ✅ 提供即时反馈
if (state.success) {
return <div className="success">操作成功!</div>;
}
// ✅ 显示加载状态
<button disabled={isPending}>
{isPending ? '处理中...' : '提交'}
</button>
// ✅ 保留用户输入
<input defaultValue={state.fields?.name} />3. 性能优化
javascript
// ✅ 使用revalidatePath更新缓存
revalidatePath('/posts');
// ✅ 只查询需要的字段
await db.users.findUnique({
select: {
id: true,
name: true,
avatar: true
}
});常见问题
Q1: 如何处理大文件上传?
A: 使用云存储服务(S3、OSS等),分块上传,显示进度。
Q2: 如何实现实时搜索?
A: 客户端防抖 + Server Action,或使用API路由。
Q3: 表单提交后如何保持滚动位置?
A: 使用URL hash或保存滚动位置到sessionStorage。
总结
实战要点
✅ 完善的认证系统
✅ 数据验证
✅ 错误处理
✅ 文件上传
✅ 权限控制
✅ 缓存管理
✅ 用户体验优化
✅ 性能优化开发流程
1. 设计数据模型
2. 创建Server Actions
3. 实现表单UI
4. 添加验证
5. 处理错误
6. 优化体验
7. 测试功能
8. 部署上线通过这些实战案例,你应该能够构建完整的Form Actions应用了!