import { useCallback, useEffect, useState } from 'react'; import Cropper, { type Area } from 'react-easy-crop'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { notify } from '@/lib/notify'; import { getCroppedAvatarFile, validateAvatarOutput } from '../utils/avatarCrop'; interface Props { open: boolean; imageSrc: string | null; fileName?: string; /** 裁剪后文件体积上限(MB) */ maxMb: number; onOpenChange: (open: boolean) => void; onConfirm: (file: File) => void; } export default function AvatarCropDialog({ open, imageSrc, fileName, maxMb, onOpenChange, onConfirm, }: Props) { const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); const [confirming, setConfirming] = useState(false); useEffect(() => { if (open) { setCrop({ x: 0, y: 0 }); setZoom(1); setCroppedAreaPixels(null); } }, [open, imageSrc]); const onCropComplete = useCallback((_: Area, pixels: Area) => { setCroppedAreaPixels(pixels); }, []); const handleConfirm = async () => { if (!imageSrc || !croppedAreaPixels) return; setConfirming(true); try { const file = await getCroppedAvatarFile(imageSrc, croppedAreaPixels, fileName); const sizeErr = validateAvatarOutput(file, maxMb); if (sizeErr) { notify.error(sizeErr); return; } onConfirm(file); onOpenChange(false); } catch { notify.error('裁剪失败,请重试'); } finally { setConfirming(false); } }; return ( 裁剪头像 拖动图片调整位置,滚轮或滑块缩放。裁剪结果按原图格式保存(JPG→JPEG,PNG 保留透明);GIF 裁剪后变为静态 JPG。服务端会额外生成 WebP。 {imageSrc ? ( ) : ( )} 缩放 setZoom(Number(e.target.value))} aria-label="缩放" /> onOpenChange(false)}> 取消 确认裁剪 ); }