"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, Crop, } from "lucide-react"; import { apiMyMedia, apiUploadBrand, type MediaAttachment, } from "@/lib/api"; import { getCroppedBrandWebp } from "@/lib/cropImage"; import Modal from "@/components/Modal"; import { brandFileOk, type BrandSlot, type BrandLogoSize } from "@/lib/brand"; // 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准) const SOURCE_MAX_BYTES = 15 * 1024 * 1024; // 品牌图槽位到默认裁剪比例的映射 const SLOT_ASPECT: Record = { logo_light: 1, // 方标 1:1 logo_dark: 1, // 方标 1:1 favicon: 1, // Favicon 1:1 }; // 品牌图比例选项(与 BRAND_LOGO_SIZES 对应) const ASPECT_OPTIONS: { id: BrandLogoSize; label: string; aspect: number }[] = [ { id: "sq", label: "1:1", aspect: 1 }, { id: "2x1", label: "2:1", aspect: 2 }, { id: "3x1", label: "3:1", aspect: 3 }, { id: "4x1", label: "4:1", aspect: 4 }, { id: "16x9", label: "16:9", aspect: 16 / 9 }, ]; interface BrandCropModalProps { open: boolean; slot: BrandSlot; onClose: () => void; /** 裁剪完成后返回新 URL */ onCropped: (url: string) => void; /** 外部传入的文件:打开后直接进入裁剪 */ seedFile?: File | null; /** 外部传入的 URL(如媒体库图片):打开后直接进入裁剪 */ seedUrl?: string; /** 已消费 seedFile / seedUrl */ onSeedConsumed?: () => void; } export default function BrandCropModal({ open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed, }: BrandCropModalProps) { const [media, setMedia] = useState([]); const [imageSrc, setImageSrc] = useState(null); const [srcIsBlob, setSrcIsBlob] = useState(false); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [pixelCrop, setPixelCrop] = useState(null); const [aspect, setAspect] = useState(SLOT_ASPECT[slot] || 1); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); // 打开时拉取媒体库 useEffect(() => { if (!open) return; setError(""); apiMyMedia() .then((res) => setMedia(res.attachments || [])) .catch(() => setMedia([])); }, [open]); // 关闭时复位并释放本地 blob URL 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); setAspect(SLOT_ASPECT[slot] || 1); setError(""); setBusy(false); setDragOver(false); } }, [open, slot]); 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 => { const err = brandFileOk(slot, file); if (err) return err; if (file.size > SOURCE_MAX_BYTES) { return "原图不能超过 15MB,裁剪后的品牌图需在 2MB 以内"; } enterCrop(URL.createObjectURL(file), true); return ""; }, [enterCrop, slot]); // 外部传入:弹窗打开后立刻进入裁剪 useEffect(() => { if (!open) return; if (seedFile) { const errMsg = acceptImageFile(seedFile); if (errMsg) setError(errMsg); onSeedConsumed?.(); return; } if (seedUrl) { enterCrop(seedUrl, false); onSeedConsumed?.(); } }, [open, seedFile, seedUrl, acceptImageFile, enterCrop, onSeedConsumed]); /** * 点击媒体库图片:直接进入裁剪界面 * 品牌图不做"直接选用",统一走裁剪后上传,保证比例一致 */ const handlePick = (item: MediaAttachment) => { if (busy) return; enterCrop(item.url, false); }; 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 getCroppedBrandWebp(imageSrc, pixelCrop, aspect); const file = new File([blob], "brand.webp", { type: "image/webp" }); const url = await apiUploadBrand(slot, file); onCropped(url); onClose(); } catch (err) { setError(err instanceof Error ? err.message : "上传失败,请重试"); } finally { setBusy(false); } }; const slotLabel = slot === "logo_light" ? "浅色 Logo" : slot === "logo_dark" ? "深色 Logo" : "Favicon"; // 裁剪区底色与槽位主题相反:浅色主题 Logo(通常为深色图)用浅底,深色主题 Logo 用深底 const cropBg = slot === "logo_dark" ? "#0c0d12" : "#f2f3f5"; return ( {error &&
{error}
} {!imageSrc ? ( <> {/* 选择新图片:点击或拖放 */} {/* 媒体库 */} {media.length > 0 && (

我的图片(点击后进入裁剪)

{media.map((item) => ( ))}
)} ) : ( <> {/* 比例选择 */}
比例 {ASPECT_OPTIONS.map((opt) => ( ))}
{/* 裁剪区 */}
{/* 缩放 */}
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="缩放" />
{/* 输出说明 + 操作 */}
输出 WebP,高度 ≤ 2048px
)}
); }