第十二章 更新个人信息功能的设计与实现

140 阅读3分钟

一、个人信息页面

// src/app/(page)/(protected)/account/settings/page.tsx
'use client'

import { trpc } from '@/app/_trpc/client'
import Settings from '@/components/Settings'
import UploadAvatar from '@/components/UploadAvatar'
import { reactQueryRetry } from '@/lib/utils'

function SettingsPage() {
  const { data, isLoading } = trpc.getUserInfo.useQuery(undefined, {
    retry: reactQueryRetry,
  })
  return (
    <div className="flex-1 justify-between flex flex-col h-[calc(100vh-3.5rem)]">
      <div className="mx-auto w-full max-w-8xl grow lg:flex lg:flex-row-reverse xl:px-2">
        <div className="shrink-0 flex-[0.5] lg:w-96">
          <UploadAvatar userInfo={data} isLoading={isLoading} />
        </div>
        <div className="flex-1 xl:flex">
          <div className="px-4 py-6 sm:px-6 lg:pl-8 xl:flex-1 xl:pl-6">
            <Settings userInfo={data} isLoading={isLoading} />
          </div>
        </div>
      </div>
    </div>
  )
}

export default SettingsPage

更新头像组件UploadAvatar暂时使用以下来占位

// src/components/UploadAvatar.tsx
'use client'
import { Prisma } from '@prisma/client'

interface UploadAvatarProps {
  userInfo: Prisma.UserCreateInput | null | undefined
  isLoading: boolean
}
function UploadAvatar(props: UploadAvatarProps) {
  const { userInfo, isLoading } = props
  console.log(userInfo, isLoading)
  return <div>UploadAvatar</div>
}

export default UploadAvatar

二、设置个人信息组件

// src/components/Settings.tsx
'use client'

import { useCallback, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { trpc } from '@/app/_trpc/client'
import { toast } from '@/components/ui/use-toast'
import { getMessages } from '@/lib/tips'
import { Loader2 } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { UpdateProfile, TUpdateProfile } from '@/lib/validator'
import { Label } from './ui/label'
import { Input } from './ui/input'
import { Prisma } from '@prisma/client'
interface SettingsProps {
  userInfo: Prisma.UserCreateInput | null | undefined
  isLoading: boolean
}
function Settings(props: SettingsProps) {
  const { userInfo, isLoading } = props
  const {
    register,
    handleSubmit,
    formState: { errors },
    setValue,
  } = useForm<TUpdateProfile>({
    defaultValues: {},
    resolver: zodResolver(UpdateProfile),
  })

  useEffect(() => {
    if (userInfo && !isLoading) {
      setValue('bio', userInfo.bio ?? undefined)
      setValue('name', userInfo.name ?? undefined)
    }
  }, [userInfo, isLoading, setValue])

  const { mutate: startUpdateProfile, isLoading: updateProfileLoading } =
    trpc.updateProfile.useMutation({
      onSuccess: () => {
        toast({
          title: getMessages('10036'),
          description: getMessages('10037'),
          variant: 'default',
        })
      },
      onError: (error) => {
        toast({
          title: getMessages('10036'),
          description: error.message,
          variant: 'destructive',
        })
      },
    })
  const handlePageSubmit = useCallback(
    async ({ bio, name }: TUpdateProfile) => {
      startUpdateProfile({ bio, name })
    },
    [startUpdateProfile]
  )

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        handleSubmit(handlePageSubmit)()
      }}
      className="grid w-full max-w-2xl items-center gap-1.5 mx-auto py-6 px-12"
    >
      <div>
        <Label className="text-zinc-600" htmlFor="name">
          昵称:
        </Label>
        <Input
          placeholder={getMessages('10039')}
          {...register('name')}
          className={cn(
            'resize-none mt-4',
            errors.name && 'focus-visible:ring-red-500'
          )}
          onBlur={(e) => {
            setValue('name', e.target.value)
          }}
          id="name"
        />
        <div className="text-destructive text-xs mt-1">
          {errors.name ? errors.name.message : null}
        </div>
      </div>
      <div>
        <Label className="text-zinc-600" htmlFor="bio">
          个人简介:
        </Label>
        <Textarea
          placeholder={getMessages('10038')}
          {...register('bio')}
          className={cn(
            'resize-none mt-4',
            errors.bio && 'focus-visible:ring-red-500'
          )}
          onBlur={(e) => {
            setValue('bio', e.target.value)
          }}
          id="bio"
        />
        <div className="text-destructive text-xs mt-1">
          {errors.bio ? errors.bio.message : null}
        </div>
      </div>
      <Button
        disabled={updateProfileLoading}
        className="mt-4 w-24"
        type="submit"
      >
        {updateProfileLoading && (
          <Loader2 className="mr-4 h-4 w-4 animate-spin text-white" />
        )}{' '}
        修改
      </Button>
    </form>
  )
}

export default Settings

三、提交格式校验

// src/lib/validator.ts
// 修改个人信息
export const UpdateProfile = z.object({
  bio: z.string().max(120, { message: validatorMessages.bio }).optional(),
  name: z.string().max(30, { message: validatorMessages.name }).optional(),
})
export type TUpdateProfile = z.infer<typeof UpdateProfile>

四、更新方法

// src/trpc/index.ts
import { UpdateProfile } from '@/lib/validator'
updateProfile: privateProcedure
    .input(UpdateProfile)
    .mutation(async ({ input, ctx }) => {
      try {
        const { userId } = ctx
        const { bio, name } = input
        const userInfo = await db.user.update({
          where: {
            id: userId,
          },
          data: {
            bio,
            name,
          },
        })
        // 不能给出全部的值
        return {
          id: userInfo.id,
        }
      } catch (error) {
        handleErrorforInitiative(error)
      }
    }),

五、补充文案

const messages = {
  // ...
  '10036': '修改个人信息',
  '10037': '修改成功',
  '10038': '请输入个人简介',
  '10039': '请输入昵称',
  '10040': '个人简介不能超过120个字符',
  '10041': '昵称不能超过30个字符',
}

export const validatorMessages = {
  // ...
  bio: messages['10040'],
  name: messages['10041'],
}

六、头像组件

完成头像展示和移除逻辑,头像组件如下:

// src/components/UploadAvatar.tsx
'use client'
import { Prisma } from '@prisma/client'
import { Label } from './ui/label'
import { trpc } from '@/app/_trpc/client'
import { useEffect, useState } from 'react'
import { useToast } from './ui/use-toast'
import { getMessages } from '@/lib/tips'
import Image from 'next/image'
import { Eraser, Loader2 } from 'lucide-react'

interface UploadAvatarProps {
  userInfo: Prisma.UserCreateInput | null | undefined
  isLoading: boolean
}
function UploadAvatar(props: UploadAvatarProps) {
  const { userInfo, isLoading } = props
  const [image, setImage] = useState<string | undefined>()
  const [isLoaded, setIsLoaded] = useState(false)
  const { toast } = useToast()
  useEffect(() => {
    setImage(userInfo?.image ?? undefined)
  }, [userInfo?.image])

  const { mutate: startRemoveImage, isLoading: removeImageLoading } =
    trpc.removeImage.useMutation({
      onSuccess: () => {
        setImage(undefined)
        toast({
          title: getMessages('10042'),
          description: getMessages('10043'),
          variant: 'default',
        })
      },
      onError: (error) => {
        toast({
          title: getMessages('10042'),
          description: error.message,
          variant: 'destructive',
        })
      },
    })
  return (
    <div className="px-4 py-6 sm:px-6 lg:pl-8 xl:pl-6">
      <Label className="text-zinc-600" htmlFor="Profile picture">
        个人头像:
      </Label>
      <div className="flex flex-col items-center md:block">
        <div className="w-64 h-64 relative">
          <div className="rounded-full w-full h-full flex items-center justify-center border border-zinc-200 relative bg-zinc-50 overflow-hidden cursor-pointer">
            <Image
              sizes="100%"
              fill={true}
              src={image ? image : '/images/user.png'}
              alt="avatar"
              priority={true}
              onLoad={() => setIsLoaded(true)}
            />
            {(!isLoaded || isLoading) && (
              <div className="absolute w-64 h-64 animate-pulse rounded-full bg-zinc-300 opacity-25"></div>
            )}
            {isLoaded && (
              <div className="absolute w-64 h-16 top-3/4 bg-zinc-600 opacity-90 text-center pt-3 text-sm text-white">
                <div>拖拽图片到此处</div>
                <div>或点击</div>
              </div>
            )}
          </div>
          {image && (
            <div
              className="absolute top-6 right-6 text-destructive cursor-pointer"
              onClick={() => {
                startRemoveImage()
              }}
            >
              {removeImageLoading ? (
                <Loader2 className="h-4 w-4 animate-spin" />
              ) : (
                <Eraser className="w-4 h-4" strokeWidth={3} />
              )}
            </div>
          )}
        </div>
      </div>
    </div>
  )
}

export default UploadAvatar

删除逻辑

removeImage: privateProcedure.mutation(async ({ ctx, input }) => {
  try {
    const { userId } = ctx
    // 先查询获取原始数据
    const originalUserInfo = await db.user.findUnique({
      where: {
        id: userId,
      },
    })

    // 获取原始的image值
    const originalImage = originalUserInfo?.image
    // todo: 删除oss图片
    const userInfo = await db.user.update({
      where: {
        id: userId,
      },
      data: {
        image: null,
      },
    })
    return {
      id: userInfo.id,
    }
  } catch (error) {
    handleErrorforInitiative(error)
  }
}),

提示文案

'10042': '更新头像',
'10043': '头像更新成功',

接下来使用阿里云的oss上传修改头像。