Files
cunkebao_v3/Cunkebao/app/components/common/FileUploader.tsx
笔记本里的永平 5ff15472f5 feat: 本次提交更新内容如下
场景获客列表搞定
2025-07-07 17:08:27 +08:00

374 lines
12 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 type React from "react"
import { useState, useRef, useCallback } from "react"
import { Button } from "@/app/components/ui/button"
import { Card, CardContent } from "@/app/components/ui/card"
import { Progress } from "@/app/components/ui/progress"
import { Badge } from "@/app/components/ui/badge"
import { Upload, X, File, ImageIcon, Video, FileText, Download, Eye } from "lucide-react"
import { cn } from "@/app/lib/utils"
export interface UploadedFile {
id: string
name: string
size: number
type: string
url?: string
progress?: number
status: "uploading" | "success" | "error"
error?: string
}
export interface FileUploaderProps {
/** 允许的文件类型 */
accept?: string
/** 是否支持多文件上传 */
multiple?: boolean
/** 最大文件大小(字节) */
maxSize?: number
/** 最大文件数量 */
maxFiles?: number
/** 已上传的文件列表 */
files?: UploadedFile[]
/** 文件变更回调 */
onFilesChange?: (files: UploadedFile[]) => void
/** 文件上传处理函数 */
onUpload?: (file: File) => Promise<{ url: string; id: string }>
/** 文件删除回调 */
onDelete?: (fileId: string) => void
/** 是否禁用 */
disabled?: boolean
/** 自定义类名 */
className?: string
/** 上传区域提示文本 */
placeholder?: string
/** 是否显示预览 */
showPreview?: boolean
}
/**
* 统一的文件上传组件
* 支持拖拽上传、多文件上传、进度显示、预览等功能
*/
export function FileUploader({
accept,
multiple = false,
maxSize = 10 * 1024 * 1024, // 10MB
maxFiles = 10,
files = [],
onFilesChange,
onUpload,
onDelete,
disabled = false,
className,
placeholder = "点击或拖拽文件到此处上传",
showPreview = true,
}: FileUploaderProps) {
const [isDragging, setIsDragging] = useState(false)
const [uploadingFiles, setUploadingFiles] = useState<UploadedFile[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// 获取文件图标
const getFileIcon = (type: string) => {
if (type.startsWith("image/")) return <ImageIcon className="h-8 w-8 text-blue-500" />
if (type.startsWith("video/")) return <Video className="h-8 w-8 text-purple-500" />
if (type.includes("pdf") || type.includes("document")) return <FileText className="h-8 w-8 text-red-500" />
return <File className="h-8 w-8 text-gray-500" />
}
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
}
// 验证文件
const validateFile = (file: File): string | null => {
if (maxSize && file.size > maxSize) {
return `文件大小不能超过 ${formatFileSize(maxSize)}`
}
if (accept) {
const acceptedTypes = accept.split(",").map((type) => type.trim())
const isAccepted = acceptedTypes.some((type) => {
if (type.startsWith(".")) {
return file.name.toLowerCase().endsWith(type.toLowerCase())
}
return file.type.match(type.replace("*", ".*"))
})
if (!isAccepted) {
return `不支持的文件类型: ${file.type}`
}
}
if (files.length + uploadingFiles.length >= maxFiles) {
return `最多只能上传 ${maxFiles} 个文件`
}
return null
}
// 处理文件上传
const handleFileUpload = useCallback(
async (fileList: FileList) => {
if (disabled || !onUpload) return
const filesToUpload = Array.from(fileList)
const newUploadingFiles: UploadedFile[] = []
for (const file of filesToUpload) {
const error = validateFile(file)
if (error) {
// 显示错误通知
console.error(error)
continue
}
const uploadFile: UploadedFile = {
id: Math.random().toString(36).substr(2, 9),
name: file.name,
size: file.size,
type: file.type,
progress: 0,
status: "uploading",
}
newUploadingFiles.push(uploadFile)
}
setUploadingFiles((prev) => [...prev, ...newUploadingFiles])
// 逐个上传文件
for (let i = 0; i < newUploadingFiles.length; i++) {
const uploadFile = newUploadingFiles[i]
const file = filesToUpload[i]
try {
// 模拟上传进度
const progressInterval = setInterval(() => {
setUploadingFiles((prev) =>
prev.map((f) => (f.id === uploadFile.id ? { ...f, progress: Math.min((f.progress || 0) + 10, 90) } : f)),
)
}, 200)
const result = await onUpload(file)
clearInterval(progressInterval)
// 上传成功
const successFile: UploadedFile = {
...uploadFile,
url: result.url,
id: result.id,
progress: 100,
status: "success",
}
setUploadingFiles((prev) => prev.filter((f) => f.id !== uploadFile.id))
if (onFilesChange) {
onFilesChange([...files, successFile])
}
} catch (error) {
// 上传失败
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === uploadFile.id
? { ...f, status: "error", error: error instanceof Error ? error.message : "上传失败" }
: f,
),
)
}
}
},
[disabled, onUpload, files, onFilesChange, maxSize, maxFiles, accept, uploadingFiles.length],
)
// 处理拖拽
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
if (!disabled) {
setIsDragging(true)
}
},
[disabled],
)
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (disabled) return
const droppedFiles = e.dataTransfer.files
if (droppedFiles.length > 0) {
handleFileUpload(droppedFiles)
}
},
[disabled, handleFileUpload],
)
// 处理文件选择
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files
if (selectedFiles && selectedFiles.length > 0) {
handleFileUpload(selectedFiles)
}
// 清空input值允许重复选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
},
[handleFileUpload],
)
// 删除文件
const handleDeleteFile = (fileId: string) => {
if (onDelete) {
onDelete(fileId)
}
if (onFilesChange) {
onFilesChange(files.filter((file) => file.id !== fileId))
}
}
// 删除上传中的文件
const handleDeleteUploadingFile = (fileId: string) => {
setUploadingFiles((prev) => prev.filter((file) => file.id !== fileId))
}
// 重试上传
const handleRetryUpload = (fileId: string) => {
const failedFile = uploadingFiles.find((f) => f.id === fileId)
if (failedFile) {
// 这里需要重新获取原始文件,实际实现中可能需要保存原始文件引用
console.log("重试上传:", failedFile.name)
}
}
const allFiles = [...files, ...uploadingFiles]
return (
<div className={cn("space-y-4", className)}>
{/* 上传区域 */}
<Card
className={cn(
"border-2 border-dashed transition-colors cursor-pointer",
isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300",
disabled && "opacity-50 cursor-not-allowed",
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !disabled && fileInputRef.current?.click()}
>
<CardContent className="p-8 text-center">
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg font-medium mb-2">{placeholder}</p>
<p className="text-sm text-gray-500 mb-4">
{accept && `支持格式: ${accept}`}
{maxSize && ` • 最大 ${formatFileSize(maxSize)}`}
{multiple && ` • 最多 ${maxFiles} 个文件`}
</p>
<Button variant="outline" disabled={disabled}>
</Button>
</CardContent>
</Card>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
/>
{/* 文件列表 */}
{allFiles.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium"> ({allFiles.length})</h4>
<div className="space-y-2">
{allFiles.map((file) => (
<Card key={file.id} className="p-3">
<div className="flex items-center space-x-3">
{showPreview && getFileIcon(file.type)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="font-medium truncate">{file.name}</p>
<div className="flex items-center space-x-2">
<Badge
variant={
file.status === "success"
? "success"
: file.status === "error"
? "destructive"
: "secondary"
}
>
{file.status === "success" ? "已上传" : file.status === "error" ? "失败" : "上传中"}
</Badge>
<span className="text-sm text-gray-500">{formatFileSize(file.size)}</span>
</div>
</div>
{file.status === "uploading" && <Progress value={file.progress || 0} className="mt-2" />}
{file.status === "error" && file.error && <p className="text-sm text-red-500 mt-1">{file.error}</p>}
</div>
<div className="flex items-center space-x-1">
{file.status === "success" && file.url && (
<>
<Button variant="ghost" size="icon" onClick={() => window.open(file.url, "_blank")}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => window.open(file.url, "_blank")}>
<Download className="h-4 w-4" />
</Button>
</>
)}
{file.status === "error" && (
<Button variant="ghost" size="sm" onClick={() => handleRetryUpload(file.id)}>
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() =>
file.status === "success" ? handleDeleteFile(file.id) : handleDeleteUploadingFile(file.id)
}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
)}
</div>
)
}