314 lines
9.1 KiB
TypeScript
314 lines
9.1 KiB
TypeScript
"use client"
|
||
|
||
import React, { useCallback, useState, useRef } from 'react'
|
||
import { Button } from './button'
|
||
import { Progress } from './progress'
|
||
import { useToast } from './use-toast'
|
||
import { Upload, X, FileImage, File, Loader2 } from 'lucide-react'
|
||
import { uploadFile, uploadImage, formatFileSize } from '@/lib/api/upload'
|
||
import type { UploadResponse } from '@/lib/api/upload'
|
||
|
||
interface FileUploadProps {
|
||
/**
|
||
* 接受的文件类型
|
||
*/
|
||
accept?: Record<string, string[]>
|
||
/**
|
||
* 是否允许多文件上传
|
||
*/
|
||
multiple?: boolean
|
||
/**
|
||
* 最大文件大小(字节)
|
||
*/
|
||
maxSize?: number
|
||
/**
|
||
* 最大文件数量
|
||
*/
|
||
maxFiles?: number
|
||
/**
|
||
* 是否只允许图片
|
||
*/
|
||
imageOnly?: boolean
|
||
/**
|
||
* 上传成功回调
|
||
*/
|
||
onUploadSuccess?: (files: UploadResponse[]) => void
|
||
/**
|
||
* 上传失败回调
|
||
*/
|
||
onUploadError?: (error: string) => void
|
||
/**
|
||
* 文件移除回调
|
||
*/
|
||
onRemove?: (file: UploadResponse) => void
|
||
/**
|
||
* 预设的文件列表
|
||
*/
|
||
defaultFiles?: UploadResponse[]
|
||
/**
|
||
* 是否禁用
|
||
*/
|
||
disabled?: boolean
|
||
/**
|
||
* 提示文本
|
||
*/
|
||
placeholder?: string
|
||
/**
|
||
* 样式类名
|
||
*/
|
||
className?: string
|
||
}
|
||
|
||
export function FileUpload({
|
||
accept = { 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] },
|
||
multiple = false,
|
||
maxSize = 10 * 1024 * 1024, // 10MB
|
||
maxFiles = 1,
|
||
imageOnly = false,
|
||
onUploadSuccess,
|
||
onUploadError,
|
||
onRemove,
|
||
defaultFiles = [],
|
||
disabled = false,
|
||
placeholder = '点击或拖拽文件到这里上传',
|
||
className = '',
|
||
}: FileUploadProps) {
|
||
const { toast } = useToast()
|
||
const [uploadedFiles, setUploadedFiles] = useState<UploadResponse[]>(defaultFiles)
|
||
const [uploading, setUploading] = useState(false)
|
||
const [progress, setProgress] = useState(0)
|
||
const [isDragActive, setIsDragActive] = useState(false)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const handleFiles = useCallback(async (files: FileList | null) => {
|
||
if (!files || disabled || uploading) return
|
||
|
||
const acceptedFiles = Array.from(files).filter(file => {
|
||
if (imageOnly) {
|
||
return file.type.startsWith('image/')
|
||
}
|
||
return true
|
||
})
|
||
|
||
if (acceptedFiles.length === 0) return
|
||
|
||
// 检查文件数量限制
|
||
if (uploadedFiles.length + acceptedFiles.length > maxFiles) {
|
||
const error = `最多只能上传 ${maxFiles} 个文件`
|
||
onUploadError?.(error)
|
||
toast({
|
||
title: '上传失败',
|
||
description: error,
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
|
||
setUploading(true)
|
||
const uploadPromises: Promise<UploadResponse>[] = []
|
||
|
||
for (const file of acceptedFiles) {
|
||
// 文件大小检查
|
||
if (file.size > maxSize) {
|
||
const error = `文件 ${file.name} 大小超过限制 ${formatFileSize(maxSize)}`
|
||
onUploadError?.(error)
|
||
toast({
|
||
title: '上传失败',
|
||
description: error,
|
||
variant: 'destructive',
|
||
})
|
||
continue
|
||
}
|
||
|
||
// 上传文件
|
||
const uploadPromise = (imageOnly ? uploadImage(file, ({ percentage }) => {
|
||
setProgress(percentage)
|
||
}) : uploadFile(file, ({ percentage }) => {
|
||
setProgress(percentage)
|
||
})).then(response => response.data)
|
||
|
||
uploadPromises.push(uploadPromise)
|
||
}
|
||
|
||
try {
|
||
const results = await Promise.all(uploadPromises)
|
||
const newFiles = [...uploadedFiles, ...results]
|
||
|
||
setUploadedFiles(newFiles)
|
||
onUploadSuccess?.(results)
|
||
|
||
toast({
|
||
title: '上传成功',
|
||
description: `成功上传 ${results.length} 个文件`,
|
||
})
|
||
} catch (error) {
|
||
const errorMsg = error instanceof Error ? error.message : '上传失败'
|
||
onUploadError?.(errorMsg)
|
||
toast({
|
||
title: '上传失败',
|
||
description: errorMsg,
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setUploading(false)
|
||
setProgress(0)
|
||
}
|
||
}, [uploadedFiles, disabled, uploading, maxFiles, maxSize, imageOnly, onUploadSuccess, onUploadError, toast])
|
||
|
||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (!disabled && !uploading) {
|
||
setIsDragActive(true)
|
||
}
|
||
}, [disabled, uploading])
|
||
|
||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setIsDragActive(false)
|
||
}, [])
|
||
|
||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setIsDragActive(false)
|
||
|
||
if (disabled || uploading) return
|
||
|
||
const files = e.dataTransfer.files
|
||
handleFiles(files)
|
||
}, [disabled, uploading, handleFiles])
|
||
|
||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files
|
||
handleFiles(files)
|
||
// 清空input以允许重新选择同一文件
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = ''
|
||
}
|
||
}, [handleFiles])
|
||
|
||
const handleClick = useCallback(() => {
|
||
if (!disabled && !uploading) {
|
||
fileInputRef.current?.click()
|
||
}
|
||
}, [disabled, uploading])
|
||
|
||
const removeFile = (fileToRemove: UploadResponse) => {
|
||
const newFiles = uploadedFiles.filter(file => file.url !== fileToRemove.url)
|
||
setUploadedFiles(newFiles)
|
||
onRemove?.(fileToRemove)
|
||
|
||
toast({
|
||
title: '文件已移除',
|
||
description: fileToRemove.filename,
|
||
})
|
||
}
|
||
|
||
// 生成accept字符串
|
||
const acceptString = Object.keys(accept).join(',')
|
||
|
||
return (
|
||
<div className={`space-y-4 ${className}`}>
|
||
{/* 隐藏的文件input */}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple={multiple}
|
||
accept={acceptString}
|
||
onChange={handleInputChange}
|
||
className="hidden"
|
||
disabled={disabled || uploading}
|
||
/>
|
||
|
||
{/* 上传区域 */}
|
||
<div
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
onClick={handleClick}
|
||
className={`
|
||
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
|
||
${isDragActive ? 'border-primary bg-primary/10' : 'border-gray-300 hover:border-gray-400'}
|
||
${disabled || uploading ? 'opacity-50 cursor-not-allowed' : ''}
|
||
`}
|
||
>
|
||
|
||
{uploading ? (
|
||
<div className="space-y-2">
|
||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||
<p className="text-sm text-gray-500">上传中...</p>
|
||
<Progress value={progress} className="w-full max-w-xs mx-auto" />
|
||
<p className="text-xs text-gray-400">{progress}%</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<Upload className="h-8 w-8 mx-auto text-gray-400" />
|
||
<div>
|
||
<p className="text-sm text-gray-600">
|
||
{isDragActive ? '松开鼠标上传文件' : placeholder}
|
||
</p>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
{imageOnly ? '支持图片格式' : '支持多种文件格式'},
|
||
最大 {formatFileSize(maxSize)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 已上传文件列表 */}
|
||
{uploadedFiles.length > 0 && (
|
||
<div className="space-y-2">
|
||
<h4 className="text-sm font-medium text-gray-700">已上传文件</h4>
|
||
<div className="grid gap-2">
|
||
{uploadedFiles.map((file, index) => (
|
||
<div
|
||
key={`${file.url}-${index}`}
|
||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border"
|
||
>
|
||
<div className="flex items-center space-x-3">
|
||
{file.mimeType && file.mimeType.startsWith('image/') ? (
|
||
<FileImage className="h-5 w-5 text-blue-500" />
|
||
) : (
|
||
<File className="h-5 w-5 text-gray-500" />
|
||
)}
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-900 truncate">
|
||
{file.filename}
|
||
</p>
|
||
<p className="text-xs text-gray-500">
|
||
{formatFileSize(file.size)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-2">
|
||
{file.mimeType && file.mimeType.startsWith('image/') && (
|
||
<img
|
||
src={file.url}
|
||
alt={file.filename}
|
||
className="h-10 w-10 object-cover rounded border"
|
||
/>
|
||
)}
|
||
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => removeFile(file)}
|
||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
} |