"use client" import * as React from "react" import { useState, useCallback } from "react" import Cropper from "react-easy-crop" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" import { Slider } from "@/components/ui/slider" import { Crop, RotateCw, X } from "lucide-react" import type { Area, Point } from "react-easy-crop" interface ImageCropperProps { /** 是否打开对话框 */ isOpen: boolean /** 关闭对话框回调 */ onOpenChange: (open: boolean) => void /** 裁剪完成回调 */ onCropComplete: (croppedImage: string, coverType: 'cover' | 'heterotypic') => void /** 封面类型 */ coverType: 'cover' | 'heterotypic' } /** * 创建裁剪后的图片 */ async function getCroppedImg( imageSrc: string, pixelCrop: Area, rotation = 0 ): Promise { const image = await createImage(imageSrc) const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') if (!ctx) { throw new Error('无法创建 canvas context') } const maxSize = Math.max(image.width, image.height) const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2)) canvas.width = safeArea canvas.height = safeArea ctx.translate(safeArea / 2, safeArea / 2) ctx.rotate((rotation * Math.PI) / 180) ctx.translate(-safeArea / 2, -safeArea / 2) ctx.drawImage( image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5 ) const data = ctx.getImageData(0, 0, safeArea, safeArea) canvas.width = pixelCrop.width canvas.height = pixelCrop.height ctx.putImageData( data, Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x), Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y) ) return canvas.toDataURL('image/webp') } /** * 创建图片对象 */ function createImage(url: string): Promise { return new Promise((resolve, reject) => { const image = new Image() image.addEventListener('load', () => resolve(image)) image.addEventListener('error', (error) => reject(error)) image.setAttribute('crossOrigin', 'anonymous') image.src = url }) } /** * 图片裁剪组件 * 使用 react-easy-crop 实现专业的图片裁剪功能 */ export function ImageCropper({ isOpen, onOpenChange, onCropComplete, coverType }: ImageCropperProps) { const [image, setImage] = useState(null) const [crop, setCrop] = useState({ x: 0, y: 0 }) const [zoom, setZoom] = useState(1) const [rotation, setRotation] = useState(0) const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) const fileInputRef = React.useRef(null) const resetCropState = () => { setImage(null) setCrop({ x: 0, y: 0 }) setZoom(1) setRotation(0) setCroppedAreaPixels(null) if (fileInputRef.current) { fileInputRef.current.value = '' } } // 裁剪比例配置 - 2:3 const aspect = 2 / 3 const cropShape = 'rect' const coverLabel = coverType === 'cover' ? '背景封面' : '装饰图片' // 处理文件选择 const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return // 验证文件类型 if (!file.type.startsWith('image/')) { toast.error('请选择图片文件') return } // 验证文件大小 (最大 2MB) if (file.size > 2 * 1024 * 1024) { toast.error('图片大小不能超过 2MB') return } const reader = new FileReader() reader.onload = (e) => { resetCropState() setImage(e.target?.result as string) } reader.readAsDataURL(file) } // 裁剪完成回调 const onCropCompleteHandler = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { setCroppedAreaPixels(croppedAreaPixels) }, []) // 执行裁剪 const handleCrop = async () => { if (!image || !croppedAreaPixels) return try { const croppedImage = await getCroppedImg(image, croppedAreaPixels, rotation) onCropComplete(croppedImage, coverType) handleClose() } catch (error) { console.error('裁剪失败:', error) toast.error('裁剪失败,请重试') } } // 关闭对话框 const handleClose = () => { resetCropState() onOpenChange(false) } return ( {image ? `裁剪${ coverLabel }` : `上传${ coverLabel }`} {image ? '拖拽调整位置,滚轮缩放' : '推荐 2:3 比例,支持 JPG / PNG / WEBP,最大 2MB'} {!image ? ( /* 上传区域 */
) : ( /* 裁剪区域 */
setZoom(value[0])} min={1} max={3} step={0.1} className="flex-1" /> {Math.round(zoom * 100)}%
setRotation(value[0])} min={0} max={360} step={1} className="flex-1" /> {rotation}°
)}
) }