"use client" import * as React from "react" import { useTranslations } from "next-intl" import { Loader2, Trash2, Upload } from "lucide-react" import { toast } from "sonner" import { removeUserAvatarAction, updateUserAvatarAction } from "@/modules/settings/actions-avatar" import type { FileUploadResult } from "@/modules/files/types" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" import { Button } from "@/shared/components/ui/button" interface AvatarUploadProps { /** 当前头像 URL */ currentImage: string | null /** 用户显示名(用于 fallback) */ name: string | null /** 用户邮箱(用于 fallback) */ email: string /** 头像更新后的回调 */ onUpdated?: (imageUrl: string | null) => void } const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"] as const const MAX_AVATAR_SIZE = 2 * 1024 * 1024 // 2MB const MAX_FILENAME_LENGTH = 255 /** * 头像上传组件 * * 支持上传新头像、预览、删除。 * 文件通过 /api/upload 上传(targetType="user_avatar" 已注册到 FileTargetType 枚举), * 成功后调用 Server Action 更新 users.image。 */ export function AvatarUpload({ currentImage, name, email, onUpdated, }: AvatarUploadProps): React.ReactElement { const t = useTranslations("settings.profile.avatar") const [uploading, setUploading] = React.useState(false) const [removing, setRemoving] = React.useState(false) const [previewUrl, setPreviewUrl] = React.useState(currentImage) const inputRef = React.useRef(null) React.useEffect(() => { setPreviewUrl(currentImage) }, [currentImage]) const validateFile = (file: File): string | null => { if (file.name.length > MAX_FILENAME_LENGTH) { return t("tooLongName") } if (!ACCEPTED_IMAGE_TYPES.includes(file.type as (typeof ACCEPTED_IMAGE_TYPES)[number])) { return t("invalidType") } if (file.size > MAX_AVATAR_SIZE) { return t("tooLarge") } return null } const handleFileChange = async (e: React.ChangeEvent): Promise => { const file = e.target.files?.[0] if (!file) return const error = validateFile(file) if (error) { toast.error(error) return } setUploading(true) try { // 上传文件到 /api/upload,targetType="user_avatar" 已注册到 FileTargetType 枚举 const formData = new FormData() formData.append("file", file) formData.append("targetType", "user_avatar") const response = await fetch("/api/upload", { method: "POST", body: formData, }) if (!response.ok) { const body = (await response.json().catch(() => ({}))) as { message?: string } throw new Error(body.message || "Upload failed") } const result = (await response.json()) as FileUploadResult // 调用 Server Action 更新用户头像 const updateResult = await updateUserAvatarAction(result.url) if (!updateResult.success) { throw new Error(updateResult.message || "Failed to update avatar") } setPreviewUrl(result.url) onUpdated?.(result.url) toast.success(t("uploadSuccess")) } catch (error) { const message = error instanceof Error ? error.message : t("uploadFailure") toast.error(message) } finally { setUploading(false) if (inputRef.current) { inputRef.current.value = "" } } } const handleRemove = async (): Promise => { setRemoving(true) try { const result = await removeUserAvatarAction() if (!result.success) { throw new Error(result.message || "Failed to remove avatar") } setPreviewUrl(null) onUpdated?.(null) toast.success(t("removeSuccess")) } catch (error) { const message = error instanceof Error ? error.message : t("removeFailure") toast.error(message) } finally { setRemoving(false) } } const fallbackText = (name ?? email).slice(0, 2).toUpperCase() return (
{previewUrl ? : null} {fallbackText} {uploading ? (
) : null}
{previewUrl ? ( ) : null}

{t("hint")}

) }