lty/qy-lty-admin/components/ui/file-upload.tsx
2026-03-17 13:17:02 +08:00

314 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}