编辑个人资料
修改一个已填好当前值的表单并保存。包括选择图片、显示字数,以及保存后的通知。
编辑个人资料
要点
- 1
一开始就填好当前的值
把当前的个人资料传给 defaultValues。在真实的网站中,这里放的是从服务器获取的值。
- 2
什么都没改,就不能保存
根据 isDirty(是否和初始值不同)来禁用保存按钮。保存后调用 reset,把保存的值设为新的初始值。
- 3
保存成功后,用通知告诉用户
在页面右下角显示“已保存”(sonner 的 toast)。页面不跳转的操作,没有通知的话,用户就不知道是否保存成功了。
使用的工具
点击查看第 2 部分的比较
修改代码
修改代码,右边的预览会跟着变化。
import { useState } from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { toast } from "sonner" import { Avatar, AvatarFallback, AvatarImage } from "./components/ui/avatar" import { Button } from "./components/ui/button" import { Input } from "./components/ui/input" import { Label } from "./components/ui/label" import { Toaster } from "./components/ui/sonner" // ① 校验规则(Zod) const schema = z.object({ name: z.string().min(1, "请输入姓名"), username: z.string().regex(/^[a-z0-9_]+$/, "只能使用小写字母、数字和 _"), bio: z.string().max(160, "最多 160 个字符"), }) type Profile = z.infer<typeof schema> // 当前的个人资料(真实项目中从服务器获取) const current: Profile = { name: "张伟", username: "zhangwei", bio: "正在学习 React。", } // 代替真实服务器:稍等片刻 const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) // 把标签、输入框和错误提示组合在一起 function Field({ id, label, error, children }: { id: string; label: string; error?: string; children: React.ReactNode }) { return ( <div className="flex flex-col gap-2"> <Label htmlFor={id}>{label}</Label> {children} {error && <p className="text-sm text-destructive">{error}</p>} </div> ) } export default function App() { // 选中的图片(还没选时为 null) const [avatarUrl, setAvatarUrl] = useState<string | null>(null) // ② defaultValues:一开始就填入当前的值 // isDirty:只要有一个值和初始值不同就是 true(没有改动时不能保存) const { register, handleSubmit, watch, reset, formState: { errors, isDirty, isSubmitting } } = useForm<Profile>({ resolver: zodResolver(schema), defaultValues: current, }) // 输入框里当前的值(每次输入都会变化) const name = watch("name") const bio = watch("bio") async function onSubmit(values: Profile) { await wait(800) // 把保存后的值设为新的“初始值”(isDirty 变回 false) reset(values) // ③ 保存成功后,在右下角弹出通知(sonner) toast.success("已保存") } // 选好图片后显示为头像(只在浏览器里预览,不会上传) function onPickImage(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0] if (file) setAvatarUrl(URL.createObjectURL(file)) } return ( <div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6"> <p className="text-lg font-semibold">个人资料</p> <p className="mt-1 text-sm text-muted-foreground">这些信息对其他人可见</p> <form onSubmit={handleSubmit(onSubmit)} className="mt-6 flex flex-col gap-4"> <div className="flex items-center gap-4"> <Avatar className="size-16"> {avatarUrl && <AvatarImage src={avatarUrl} alt="" />} <AvatarFallback className="bg-linear-to-br from-sky-400 to-indigo-500 text-xl font-semibold text-white">{name.slice(0, 1)}</AvatarFallback> </Avatar> <div> {/* 用按钮样式的 label 来点击隐藏的文件选择框 */} <Button type="button" variant="outline" size="sm" asChild> <label htmlFor="avatar" className="cursor-pointer">更换头像</label> </Button> <input id="avatar" type="file" accept="image/*" className="hidden" onChange={onPickImage} /> <p className="mt-1 text-xs text-muted-foreground">JPG 或 PNG,最大 2MB</p> </div> </div> <Field id="name" label="姓名" error={errors.name?.message}> <Input id="name" {...register("name")} /> </Field> <Field id="username" label="用户名" error={errors.username?.message}> {/* 左侧带“@”的输入框 */} <div className="flex rounded-md border"> <span className="flex items-center border-r bg-muted px-3 text-sm text-muted-foreground">@</span> <Input id="username" className="rounded-l-none border-0 shadow-none" {...register("username")} /> </div> </Field> <Field id="bio" label="个人简介" error={errors.bio?.message}> <textarea id="bio" rows={3} className="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50" {...register("bio")} /> <p className="text-right text-xs text-muted-foreground">{bio.length} / 160</p> </Field> <div className="flex justify-end gap-2"> <Button type="button" variant="outline" disabled={!isDirty} onClick={() => reset()}> 还原 </Button> <Button type="submit" disabled={!isDirty || isSubmitting}> {isSubmitting ? "保存中…" : "保存"} </Button> </div> </form> <Toaster position="bottom-right" /> </div> ) }