表单与验证
技术栈:react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui Form 组件(已在 KODRA 脚手架中安装)。一个 zod schema 是类型、客户端验证和服务端验证的唯一数据源。
规则
- Schema 优先。 定义 zod schema;通过
z.infer派生 TypeScript 类型。将共享 schema 放在src/lib/schemas/<name>.ts中,以便服务端路由可以导入同一个文件。 - 服务端也要验证。 客户端验证提升用户体验;服务端路由、边缘函数或数据库约束才是安全边界。使用同一个 schema 重新解析请求体并返回字段错误。
- 每个字段都有可见的标签,当格式不明确时有帮助性描述,以及字段下方的内联错误。
- 验证时机:
mode: "onTouched"(首次失焦后验证,之后每次输入变化时验证)。不要在用户首次输入时就弹出错误。 - 提交按钮保持可用,直到提交开始,然后显示待处理状态并禁用,以防止重复提交。
- 成功时 显示操作结果(提示条或内联消息)并重置或导航。失败时 保留用户输入,在表单附近显示错误,并将焦点移到第一个无效字段(react-hook-form 默认支持)。
- 使用正确的输入类型:
type="email"、inputMode="numeric"、autoComplete标记(name、email、tel、street-address、postal-code、one-time-code、new-password),以便浏览器和手机提供帮助。
规范表单
// src/lib/schemas/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().trim().min(2, "请输入您的全名"),
email: z.string().trim().email("请输入有效的邮箱地址,例如 [email protected]"),
phone: z
.string()
.trim()
.regex(
/^0\d{8,9}$/,
"请输入以 0 开头的泰国电话号码(9-10 位数字)",
)
.optional()
.or(z.literal("")),
topic: z.enum(["booking", "billing", "other"], {
required_error: "请选择主题",
}),
message: z
.string()
.trim()
.min(10, "请提供更多信息(至少 10 个字符)")
.max(2000),
consent: z.boolean().refine((v) => v, "请接受以继续"),
});
export type ContactInput = z.infer<typeof contactSchema>;
// src/components/ContactForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { contactSchema, type ContactInput } from "@/lib/schemas/contact";
export function ContactForm() {
const form = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
mode: "onTouched",
defaultValues: {
name: "",
email: "",
phone: "",
message: "",
consent: false,
},
});
async function onSubmit(values: ContactInput) {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (res.status === 422) {
const { fieldErrors } = (await res.json()) as {
fieldErrors: Partial<Record<keyof ContactInput, string>>;
};
for (const [field, message] of Object.entries(fieldErrors)) {
form.setError(field as keyof ContactInput, { type: "server", message });
}
return;
}
if (!res.ok) {
form.setError("root", {
message:
"无法发送您的消息,请检查网络连接后重试。",
});
return;
}
toast.success("消息已发送,我们将在一个工作日内回复。");
form.reset();
}
const { isSubmitting, errors } = form.formState;
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
noValidate
className="grid gap-5 sm:grid-cols-2"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>全名</FormLabel>
<FormControl>
<Input autoComplete="name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>主题</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择一个主题" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="booking">预订</SelectItem>
<SelectItem value="billing">账单</SelectItem>
<SelectItem value="other">其他</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>电话(可选)</FormLabel>
<FormControl>
<Input
type="tel"
inputMode="tel"
autoComplete="tel"
{...field}
/>
</FormControl>
<FormDescription>仅当您希望我们回电时提供。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>消息</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="consent"
render={({ field }) => (
<FormItem className="flex items-start gap-3 sm:col-span-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
我同意就此次请求与我联系
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
{errors.root && (
<p role="alert" className="text-sm text-destructive sm:col-span-2">
{errors.root.message}
</p>
)}
<div className="sm:col-span-2">
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting && (
<Loader2
className="mr-2 h-4 w-4 animate-spin"
aria-hidden="true"
/>
)}
{isSubmitting ? "发送中..." : "发送消息"}
</Button>
</div>
</form>
</Form>
);
}
注意事项:
noValidate关闭浏览器原生验证气泡,确保只显示 zod 的消息。- 始终传递
defaultValues(空字符串,而不是undefined),以便输入从首次渲染起就是受控的。 - 在 Select/RadioGroup 上使用
value={field.value}(而非defaultValue),以便form.reset()能清除它们。 - 当表单通过 TanStack Query 的 mutation 提交时,使用
mutation.isPending替代isSubmitting,并在onSubmit中调用mutateAsync,以便 react-hook-form 等待它。
服务端验证
使用同一个 schema 解析并返回 422 以及字段错误:
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ fieldErrors: parsed.error.flatten().fieldErrors },
{ status: 422 },
);
}
flatten().fieldErrors 返回每个字段的 string[];发送第一条消息(fieldErrors.email?.[0])或适配客户端循环。数据库约束(唯一邮箱、外键)也要映射为字段错误:捕获唯一约束冲突并返回 { fieldErrors: { email: "此邮箱已被注册" } }。在以 RLS 为主的 Supabase 应用中,通过数据库约束或边缘函数再次验证;绝不依赖客户端验证。
错误消息风格
- 告知用户该怎么做,而非出了什么问题:请输入今天之后的日期,而不是无效日期。
- 使用用户的语言,并在内联中显示预期格式(例如 DD/MM/YYYY)。
- 每次每个字段只显示一条消息;不要重复标签。
- 使用应用的语言编写消息;zod 消息是纯字符串,因此需传递翻译后的文本(参见 thai-localization 技能)。
状态检查清单
- 空状态:标签、帮助性占位文本(示例,非指令)。
- 输入中:首次失焦前不显示错误。
- 无效:红色边框 + 字段下方的消息(
FormMessage),提交时焦点移到第一个错误。 - 提交中:按钮禁用并带有旋转图标和进度标签;输入保持可读。
- 服务端错误:提交按钮上方显示带有
role="alert"的消息;保留输入。 - 成功:提示条或内联确认;重置表单或重定向。
- 长表单的未保存更改:离开前警告(
formState.isDirty)。