"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Cropper, { type Area } from "react-easy-crop"; import "react-easy-crop/react-easy-crop.css"; import { UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Trash2, AlertTriangle, Crop, } from "lucide-react"; import { apiMyMedia, apiUploadAvatar, apiUseAvatar, apiDeleteAttachment, type MediaAttachment, } from "@/lib/api"; import { getCroppedAvatarWebp } from "@/lib/cropImage"; import Modal from "@/components/Modal"; interface AvatarCropModalProps { open: boolean; onClose: () => void; currentAvatar: string; /** 头像变更(新 URL)或被删除(空串)时触发 */ onChanged: (avatar: string) => void; /** 外部拖入的图片:打开后直接进入裁剪 */ seedFile?: File | null; /** 已消费 seedFile(成功或校验失败后均调用,便于父级清空) */ onSeedConsumed?: () => void; } // 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准) const SOURCE_MAX_BYTES = 15 * 1024 * 1024; export default function AvatarCropModal({ open, onClose, currentAvatar, onChanged, seedFile, onSeedConsumed, }: AvatarCropModalProps) { const [media, setMedia] = useState([]); const [imageSrc, setImageSrc] = useState(null); // blob: 或同源 /uploads URL const [srcIsBlob, setSrcIsBlob] = useState(false); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [pixelCrop, setPixelCrop] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [confirmDeleteId, setConfirmDeleteId] = useState(null); // 正在"直接选用"的历史头像 id(区别于裁剪上传,走 PUT /avatar/use) const [usingId, setUsingId] = useState(null); const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); const confirmTimer = useRef | null>(null); // 打开时拉取媒体库 useEffect(() => { if (!open) return; setError(""); apiMyMedia() .then((res) => setMedia(res.attachments || [])) .catch(() => setMedia([])); }, [open]); // 关闭时复位并释放本地 blob URL(媒体库的同源 URL 不能 revoke) useEffect(() => { if (!open) { setImageSrc((prev) => { if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev); return null; }); setSrcIsBlob(false); setCrop({ x: 0, y: 0 }); setZoom(1); setPixelCrop(null); setError(""); setBusy(false); setConfirmDeleteId(null); setUsingId(null); setDragOver(false); } }, [open]); useEffect(() => () => { if (confirmTimer.current) clearTimeout(confirmTimer.current); }, []); const onCropComplete = useCallback((_: Area, areaPixels: Area) => { setPixelCrop(areaPixels); }, []); const enterCrop = useCallback((src: string, isBlob: boolean) => { setError(""); setImageSrc((prev) => { if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev); return src; }); setSrcIsBlob(isBlob); setCrop({ x: 0, y: 0 }); setZoom(1); }, []); /** 校验并进入裁剪;返回错误文案,成功返回空串 */ const acceptImageFile = useCallback((file: File): string => { if (!file.type.startsWith("image/")) { return "请选择图片文件(JPG / PNG / GIF / WebP)"; } if (file.size > SOURCE_MAX_BYTES) { return "原图不能超过 15MB,裁剪后的头像需在 2MB 以内"; } enterCrop(URL.createObjectURL(file), true); return ""; }, [enterCrop]); // 外部拖入:弹窗打开后立刻进入裁剪 useEffect(() => { if (!open || !seedFile) return; const errMsg = acceptImageFile(seedFile); if (errMsg) setError(errMsg); onSeedConsumed?.(); }, [open, seedFile, acceptImageFile, onSeedConsumed]); /** * 点击媒体库图片: * - 成品头像(kind=avatar,本就是合规正方形 WebP):直接选用已有文件, * 走 PUT /avatar/use,绝不重新裁剪上传产生重复文件; * - 其他图片(kind=image,如帖子插图,可能非正方形):进入裁剪界面, * 裁剪后才生成新的头像文件上传。 */ const handlePick = async (item: MediaAttachment) => { if (busy || item.url === currentAvatar) return; if (item.kind === "image") { enterCrop(item.url, false); return; } setBusy(true); setUsingId(item.id); setError(""); try { const res = await apiUseAvatar(item.url); if (!res.url) { setError(res.error || "设置失败,请重试"); return; } onChanged(res.url); } catch { setError("网络错误,请重试"); } finally { setBusy(false); setUsingId(null); } }; const onFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; const errMsg = acceptImageFile(file); if (errMsg) setError(errMsg); }; const onDropZoneDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!busy) setDragOver(true); }; const onDropZoneDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragOver(false); }; const onDropZoneDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragOver(false); if (busy) return; const file = e.dataTransfer.files?.[0]; if (!file) return; const errMsg = acceptImageFile(file); if (errMsg) setError(errMsg); }; const backToLibrary = () => { if (imageSrc && srcIsBlob) URL.revokeObjectURL(imageSrc); setImageSrc(null); setSrcIsBlob(false); setError(""); }; const confirmUpload = async () => { if (!imageSrc || !pixelCrop || busy) return; setBusy(true); setError(""); try { // 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB) const blob = await getCroppedAvatarWebp(imageSrc, pixelCrop); const res = await apiUploadAvatar(blob); if (!res.url) { setError(res.error || "上传失败,请重试"); return; } onChanged(res.url); // 父组件负责关窗、广播与刷新 } catch (err) { setError(err instanceof Error ? err.message : "上传失败,请重试"); } finally { setBusy(false); } }; const handleDelete = async (item: MediaAttachment) => { if (busy) return; // 两阶段确认:第一次点击进入红色确认态,3.5 秒内再点才执行 if (confirmDeleteId !== item.id) { setConfirmDeleteId(item.id); if (confirmTimer.current) clearTimeout(confirmTimer.current); confirmTimer.current = setTimeout(() => setConfirmDeleteId(null), 3500); return; } if (confirmTimer.current) clearTimeout(confirmTimer.current); setConfirmDeleteId(null); setBusy(true); setError(""); try { const res = await apiDeleteAttachment(item.id); if (res.ok === undefined && res.error) { setError(res.error); return; } setMedia((prev) => prev.filter((m) => m.id !== item.id)); // 删的正是当前头像:清空(回到字母头像)并关闭弹窗 if (item.url === currentAvatar) { onChanged(""); } } catch { setError("网络错误,请重试"); } finally { setBusy(false); } }; return ( {error &&
{error}
} {!imageSrc ? ( <> {/* 选择新图片:点击或拖放 */} {/* 媒体库:本人上传过的全部图片(头像 + 帖子插图) */} {media.length > 0 && (

我的图片(历史头像点击直接使用,其他图片点击后裁剪;右上角可删除)

{media.map((item) => { const active = item.url === currentAvatar; const confirming = confirmDeleteId === item.id; return (
{/* hover 指示:成品头像=直接使用,其他图片=裁剪;选用中显示 spinner */} {!active && ( {usingId === item.id ? ( ) : item.kind === "image" ? ( ) : ( )} )} {/* 当前头像勾选标记 */} {active && ( )} {/* 删除按钮(两阶段确认) */} {!active && ( )}
); })}
)} ) : ( <> {/* 裁剪区 */}
{/* 缩放 */}
setZoom(Number(e.target.value))} className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer" style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }} aria-label="缩放" />
{/* 输出说明 + 操作 */}
输出 320×320 WebP
)}
); }