编辑个人资料

修改一个已填好当前值的表单并保存。包括选择图片、显示字数,以及保存后的通知。

编辑个人资料

要点

  1. 1

    一开始就填好当前的值

    把当前的个人资料传给 defaultValues。在真实的网站中,这里放的是从服务器获取的值。

  2. 2

    什么都没改,就不能保存

    根据 isDirty(是否和初始值不同)来禁用保存按钮。保存后调用 reset,把保存的值设为新的初始值。

  3. 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>
  )
}