import React, { useState, useRef, useEffect } from "react"; import { motion } from "framer-motion"; import { Upload, X } from "lucide-react"; import { useTranslations } from "@/i18n/compat/client"; import { toast } from "sonner"; import { compressImage, estimateBase64Size } from "@/utils/imageUtils"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerFooter, DrawerClose, DrawerDescription, } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PhotoConfig, DEFAULT_CONFIG, getRatioMultiplier, getBorderRadiusValue, } from "@/types/resume"; import { Textarea } from "@/components/ui/textarea"; import { useResumeStore } from "@/store/useResumeStore"; import { cn } from "@/lib/utils"; const DEFAULT_AVATAR = "/avatar.png"; interface Props { isOpen: boolean; onClose: () => void; photo?: string; config?: PhotoConfig; onPhotoChange: (photo: string | undefined, config?: PhotoConfig) => void; onConfigChange: (config: PhotoConfig) => void; } const PhotoConfigDrawer: React.FC = ({ isOpen, onClose, photo, config: initialConfig, onPhotoChange, onConfigChange, ...props }) => { const t = useTranslations("photoConfig"); const { updateBasicInfo } = useResumeStore(); const inputRef = useRef(null); const [previewUrl, setPreviewUrl] = useState(photo); const [isDragging, setIsDragging] = useState(false); const [imageUrl, setImageUrl] = useState(photo || ""); const drawerContentRef = useRef(null); const [config, setConfig] = useState( initialConfig || DEFAULT_CONFIG ); const [isMobile, setIsMobile] = useState(false); useEffect(() => { const handleResize = () => { setIsMobile(window.innerWidth <= 768); }; window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, []); useEffect(() => { if (isOpen) { setConfig(initialConfig || DEFAULT_CONFIG); setPreviewUrl(photo === "" ? "" : photo || DEFAULT_AVATAR); setImageUrl(photo === DEFAULT_AVATAR ? "" : photo || ""); } const handleClick = (e: MouseEvent) => { if (!drawerContentRef.current?.contains(e.target as Node)) { onClose(); } }; document.addEventListener("mousedown", handleClick); return () => { document.removeEventListener("mousedown", handleClick); }; }, [isOpen, initialConfig, photo]); const handleFile = async (file: File) => { if (!file.type.startsWith("image/")) { toast.error(t("upload.typeLimit")); return; } try { let imageData: string; if (file.size > 2 * 1024 * 1024) { try { imageData = await compressImage(file, 800, 800, 0.7); let compressedSize = estimateBase64Size(imageData); if (compressedSize > 2 * 1024 * 1024) { imageData = await compressImage(file, 600, 600, 0.5); compressedSize = estimateBase64Size(imageData); if (compressedSize > 2 * 1024 * 1024) { imageData = await compressImage(file, 400, 400, 0.4); } } console.log( `原始图片大小: ${(file.size / 1024).toFixed(2)}KB, 压缩后大小: ${( estimateBase64Size(imageData) / 1024 ).toFixed(2)}KB` ); } catch (error) { toast.error(t("upload.sizeLimit")); return; } } else { // 如果图片小于2MB,但仍然进行轻度压缩以优化性能 imageData = await compressImage(file, 1200, 1200, 0.8); } setPreviewUrl(imageData); setImageUrl(imageData); updateBasicInfo({ photo: imageData, }); } catch (error) { toast.error(t("upload.error")); } }; const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { handleFile(file); } }; const handleUrlChange = async (e: string) => { const url = e.trim(); setImageUrl(url); if (!url) { handleRemovePhoto(); return; } try { const proxyUrl = `/api/proxy/image?url=${encodeURIComponent(url)}`; const img = new Image(); img.crossOrigin = "anonymous"; // 检查图片大小 const checkImageSize = () => { return new Promise((resolve, reject) => { fetch(proxyUrl, { method: "HEAD" }) .then((response) => { const contentLength = response.headers.get("content-length"); if (contentLength) { const size = parseInt(contentLength, 10); if (size > 2 * 1024 * 1024) { reject(new Error(t("upload.sizeLimit"))); } } resolve(); }) .catch(() => { // 如果无法获取大小,则继续尝试加载图片 resolve(); }); }); }; // 先检查图片大小 await checkImageSize(); await new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(t("upload.timeout"))); }, 10000); img.onload = () => { clearTimeout(timer); resolve(undefined); }; img.onerror = () => { clearTimeout(timer); reject(new Error(t("upload.loadError"))); }; img.src = proxyUrl; }); setPreviewUrl(proxyUrl); updateBasicInfo({ photo: url, }); onPhotoChange(url, config); } catch (error) { toast.error( t("upload.invalidUrl", { defaultMessage: "图片链接无效或无法访问,请尝试使用其他图片链接", }) ); handleRemovePhoto(); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (file) { handleFile(file); } }; const handleRemovePhoto = () => { setPreviewUrl(""); setImageUrl(""); if (inputRef.current) { inputRef.current.value = ""; } updateBasicInfo({ photo: "", }); onPhotoChange("", config); setTimeout(() => { setPreviewUrl(""); }, 0); }; const handleConfigChange = (updates: Partial) => { const newConfig = { ...config, ...updates }; if (config.aspectRatio !== "custom") { if ("width" in updates) { const ratio = getRatioMultiplier(config.aspectRatio); newConfig.height = Math.round(updates.width! * ratio) > 200 ? 200 : Math.round(updates.width! * ratio); } if ("height" in updates) { const ratio = 1 / getRatioMultiplier(config.aspectRatio); newConfig.width = Math.round(updates.height! * ratio) > 200 ? 200 : Math.round(updates.height! * ratio); } } setConfig(newConfig); onConfigChange(newConfig); }; const handleInputChange = ( e: React.ChangeEvent, key: "width" | "height" | "customBorderRadius" ) => { const value = Number(e.target.value) > 200 ? 200 : e.target.value; if (value === "") { setConfig((prev) => ({ ...prev, [key]: "" })); return; } const numValue = Number(value); if (!isNaN(numValue)) { setConfig((prev) => ({ ...prev, [key]: numValue })); } }; const handleInputBlur = ( e: React.FocusEvent, key: "width" | "height" | "customBorderRadius" ) => { const value = e.target.value; const numValue = value === "" ? 0 : Number(value); if (key === "customBorderRadius") { const maxRadius = Math.min(config.width, config.height) / 2; const validValue = Math.max(0, Math.min(numValue, maxRadius)); handleConfigChange({ customBorderRadius: validValue }); } else { const validValue = Math.max(24, Math.min(numValue, 200)); handleConfigChange({ [key]: validValue }); } }; const handleSave = () => { onPhotoChange(previewUrl, config); onClose(); }; return ( !open && onClose()} >
{t("title")}
{previewUrl && previewUrl !== "" ? (
Profile
) : ( )}
{t("upload.dragHint")} ({t("upload.sizeLimit")})

{t("upload.title")}