Files
freedakgmail 5c8c70097c 0.8.0.0
2025-12-21 17:32:33 +08:00

249 lines
7.5 KiB
TypeScript

"use client"
import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Upload, X, Loader2, Crop } from "lucide-react"
import ReactCrop, { type Crop as CropType } from "react-image-crop"
import "react-image-crop/dist/ReactCrop.css"
import imageCompression from "browser-image-compression"
import { useDialog } from "@/components/ui/alert-dialog-custom"
interface ImageUploadProps {
value?: string // The image URL
onChange: (imageUrl: string | undefined) => void
className?: string
}
export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined)
const [isLoading, setIsLoading] = useState(false)
const [showCropDialog, setShowCropDialog] = useState(false)
const [imageToCrop, setImageToCrop] = useState<string | null>(null)
const [crop, setCrop] = useState<CropType>()
const [completedCrop, setCompletedCrop] = useState<CropType>()
const imgRef = useRef<HTMLImageElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { showAlert } = useDialog()
// Load preview if value (URL) exists
useEffect(() => {
if (value) {
setPreviewUrl(value)
} else {
setPreviewUrl(undefined)
}
}, [value])
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
// Basic validation
if (!file.type.startsWith("image/")) {
await showAlert("请选择图片文件", "提示")
return
}
// 压缩图片
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 1024,
useWebWorker: true,
}
const compressedFile = await imageCompression(file, options)
// 显示裁剪对话框
const reader = new FileReader()
reader.onload = () => {
setImageToCrop(reader.result as string)
setShowCropDialog(true)
}
reader.readAsDataURL(compressedFile)
} catch (error) {
console.error("Failed to process image:", error)
await showAlert("图片处理失败", "错误")
} finally {
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
const handleCropComplete = async () => {
if (!completedCrop || !imgRef.current) {
// 如果没有裁剪,直接保存原图
await saveImage(imageToCrop!)
return
}
try {
setIsLoading(true)
const canvas = document.createElement('canvas')
const scaleX = imgRef.current.naturalWidth / imgRef.current.width
const scaleY = imgRef.current.naturalHeight / imgRef.current.height
canvas.width = completedCrop.width
canvas.height = completedCrop.height
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(
imgRef.current,
completedCrop.x * scaleX,
completedCrop.y * scaleY,
completedCrop.width * scaleX,
completedCrop.height * scaleY,
0,
0,
completedCrop.width,
completedCrop.height
)
}
canvas.toBlob(async (blob) => {
if (blob) {
await saveImage(URL.createObjectURL(blob), blob)
}
}, 'image/jpeg', 0.9)
} catch (error) {
console.error("Failed to crop image:", error)
await showAlert("图片裁剪失败", "错误")
} finally {
setIsLoading(false)
setShowCropDialog(false)
setImageToCrop(null)
}
}
const saveImage = async (dataUrl: string, blob?: Blob) => {
try {
setIsLoading(true)
let imageBlob = blob
if (!imageBlob) {
const response = await fetch(dataUrl)
imageBlob = await response.blob()
}
// 上传到服务器
const formData = new FormData()
formData.append('file', imageBlob, 'avatar.jpg')
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || '上传失败')
}
const { url } = await response.json()
onChange(url)
setShowCropDialog(false)
setImageToCrop(null)
} catch (error) {
console.error("Failed to save image:", error)
await showAlert("图片保存失败: " + (error instanceof Error ? error.message : '未知错误'), "错误")
} finally {
setIsLoading(false)
}
}
const handleRemove = () => {
onChange(undefined)
}
return (
<>
<div className={`flex items-center gap-4 ${className}`}>
<Avatar className="h-20 w-20 border-2 border-dashed border-muted-foreground/50">
<AvatarImage src={previewUrl} className="object-cover" />
<AvatarFallback className="bg-transparent">
{isLoading ? <Loader2 className="h-6 w-6 animate-spin" /> : <Upload className="h-6 w-6 text-muted-foreground" />}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileSelect}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
>
{value ? "更换头像" : "上传头像"}
</Button>
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={handleRemove}
>
<X className="mr-2 h-3 w-3" />
</Button>
)}
</div>
</div>
{/* 裁剪对话框 */}
<Dialog open={showCropDialog} onOpenChange={setShowCropDialog}>
<DialogContent className="max-w-3xl" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Crop className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto">
{imageToCrop && (
<ReactCrop
crop={crop}
onChange={(c) => setCrop(c)}
onComplete={(c) => setCompletedCrop(c)}
aspect={1}
circularCrop
>
<img
ref={imgRef}
src={imageToCrop}
alt="待裁剪图片"
className="max-w-full"
/>
</ReactCrop>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowCropDialog(false)
setImageToCrop(null)
}}
>
</Button>
<Button onClick={handleCropComplete} disabled={isLoading}>
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{completedCrop ? "确认裁剪" : "跳过裁剪"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}