249 lines
7.5 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|