一、个人信息页面
// 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上传修改头像。