feat(settings): add security center, 2FA/TOTP, avatar upload, system settings
- Add TOTP implementation and two-factor data-access for 2FA enrollment - Add security center card with password policy and session management - Add avatar upload action and component - Add system settings actions and data-access (actions-system-settings, data-access-system-settings) - Add notification preferences and service actions - Add security-utils and student-overview-data with tests - Update existing settings views, data-access, and types for new features
This commit is contained in:
186
src/modules/settings/components/avatar-upload.tsx
Normal file
186
src/modules/settings/components/avatar-upload.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"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 上传,成功后调用 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
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user