"use client"; import { useCallback, useEffect, useRef, useState, type CSSProperties } 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, getFullBrandWebp } from "@/lib/cropImage"; import Modal from "@/components/Modal"; import BrandLockup from "@/components/BrandLockup"; import { AdminSegmented } from "@/components/admin"; import { BRAND_LOGO_FITS, BRAND_LOGO_SIZES, BRAND_LOGO_SIZE_ASPECT, brandFileOk, brandLogoBox, suggestLogoSize, type BrandLogoFit, type BrandLogoSize, type BrandSlot, } from "@/lib/brand"; // 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准) const SOURCE_MAX_BYTES = 15 * 1024 * 1024; interface BrandCropModalProps { open: boolean; slot: BrandSlot; onClose: () => void; /** 裁剪完成后返回新 URL */ onCropped: (url: string) => void; /** 外部传入的文件:打开后直接进入裁剪 */ seedFile?: File | null; /** 外部传入的 URL(如媒体库图片):打开后直接进入裁剪 */ seedUrl?: string; /** 已消费 seedFile / seedUrl */ onSeedConsumed?: () => void; /** 当前显示画幅(与工具栏同源,受控) */ logoSize?: BrandLogoSize; /** 当前铺法(与工具栏同源,受控) */ logoFit?: BrandLogoFit; /** 画幅变更回调(同步到父组件工具栏) */ onSizeChange?: (size: BrandLogoSize) => void; /** 铺法变更回调(同步到父组件工具栏) */ onFitChange?: (fit: BrandLogoFit) => void; } export default function BrandCropModal({ open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed, logoSize = "sq", logoFit = "contain", onSizeChange, onFitChange, }: 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 [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); // 已识别过比例的图片(每张图进入裁剪时只自动推荐一次画幅) const detectedSrcRef = useRef(null); // Favicon 固定方形,不参与画幅/铺法;auto=原图比例自由裁剪 const isFavicon = slot === "favicon"; const effectiveSize: BrandLogoSize = isFavicon ? "sq" : logoSize; const isAuto = effectiveSize === "auto"; const cropAspect: number | undefined = isFavicon ? 1 : BRAND_LOGO_SIZE_ASPECT[effectiveSize]; // 打开时拉取媒体库 useEffect(() => { if (!open) return; setError(""); apiMyMedia() .then((res) => setMedia(res.attachments || [])) .catch(() => setMedia([])); }, [open]); // 图片进入裁剪后,按原始比例自动推荐画幅(差距大→原图自适应),每张图仅一次 useEffect(() => { if (!imageSrc || isFavicon || detectedSrcRef.current === imageSrc) return; detectedSrcRef.current = imageSrc; const img = new Image(); img.onload = () => { onSizeChange?.(suggestLogoSize(img.naturalWidth, img.naturalHeight)); }; img.src = imageSrc; }, [imageSrc, isFavicon, onSizeChange]); // 画幅变化时重置构图位置(换画幅=重新构图;缩放保留) useEffect(() => { if (!open) return; setCrop({ x: 0, y: 0 }); }, [effectiveSize, 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); setError(""); setBusy(false); setDragOver(false); detectedSrcRef.current = null; } }, [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 || busy) return; if (!isAuto && !pixelCrop) return; setBusy(true); setError(""); try { // 原图模式:整图直传不裁剪;固定画幅:按裁剪区域导出 const blob = isAuto ? await getFullBrandWebp(imageSrc) : await getCroppedBrandWebp(imageSrc, pixelCrop!, cropAspect!); 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) => ( ))}
)} ) : ( <> {/* 画幅:唯一的比例控件(同时决定裁剪比例与页眉图框);favicon 固定方形不展示 */} {!isFavicon && (
画幅 已按图片比例推荐,可手动调整
{BRAND_LOGO_SIZES.map((item) => { const on = effectiveSize === item.id; return ( ); })}
)} {/* 原图模式:整图直传,不裁剪;固定画幅:裁剪 */} {isAuto ? (
{/* eslint-disable-next-line @next/next/no-img-element */} 整图预览 整图使用,不裁剪
) : ( <>
{/* 缩放 + 重置 */}
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="缩放" />
)} {/* 铺法:仅固定画幅需要;原图模式图框随图,无需铺法 */} {!isFavicon && ( isAuto ? (

原图模式:整图使用不裁剪,页眉图框按图片原始比例自动给宽,图片完整不变形。

) : (
铺法 onFitChange?.(v)} options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))} />

{logoFit === "contain" ? "适应:完整显示图片,比例不匹配时留空(推荐)" : logoFit === "cover" ? "填充:裁切填满画幅" : logoFit === "repeat" ? "平铺:重复铺满画幅" : "拉伸:变形填满画幅"}

) )} {/* 页眉实时预览:深色 Logo 用深色衬底并局部反转文字变量 */}
{/* 输出说明 + 操作 */}
{isAuto ? "输出 WebP · 高度 40px,宽度随原图比例" : `输出 WebP · 图框 ${brandLogoBox(effectiveSize).w}×${brandLogoBox(effectiveSize).h}px`}
)}
); }