Files
NextEdu/src/modules/settings/components/avatar-upload.tsx
SpecialX f3c223d914 feat(settings,questions,school,textbooks): add brand config, question components, school dialogs, textbooks hooks
settings:

- Add actions-brand, brand-config, data-access-brand for brand management

- Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card

- Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card

- Add security-recent-logins-section, security-two-factor-section

- Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils

questions:

- Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor

- Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils

school:

- Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards

- Add use-grade-data hook

textbooks:

- Add textbook-form-fields component

- Add use-kp-create, use-kp-delete, use-kp-update hooks
2026-07-03 10:26:00 +08:00

188 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string | null>(currentImage)
const inputRef = React.useRef<HTMLInputElement>(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<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0]
if (!file) return
const error = validateFile(file)
if (error) {
toast.error(error)
return
}
setUploading(true)
try {
// 上传文件到 /api/uploadtargetType="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<void> => {
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 (
<div className="flex items-center gap-4">
<div className="relative group">
<Avatar className="h-20 w-20">
{previewUrl ? <AvatarImage src={previewUrl} alt={name ?? email} /> : null}
<AvatarFallback className="text-xl font-semibold">{fallbackText}</AvatarFallback>
</Avatar>
{uploading ? (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
<Loader2 className="h-5 w-5 animate-spin text-white" />
</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading || removing}
onClick={() => inputRef.current?.click()}
className="gap-2"
>
<Upload className="h-4 w-4" />
{t("upload")}
</Button>
{previewUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={uploading || removing}
onClick={handleRemove}
className="gap-2 text-destructive hover:text-destructive"
>
{removing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
{t("remove")}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">{t("hint")}</p>
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES.join(",")}
onChange={handleFileChange}
className="sr-only"
aria-label={t("upload")}
/>
</div>
)
}