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
188 lines
5.7 KiB
TypeScript
188 lines
5.7 KiB
TypeScript
"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/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<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>
|
||
)
|
||
}
|