| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357 |
- import React, { useState, useCallback } from 'react';
- import { Button } from '@/client/components/ui/button';
- import { Card, CardContent } from '@/client/components/ui/card';
- import { Progress } from '@/client/components/ui/progress';
- import { Badge } from '@/client/components/ui/badge';
- import { toast } from 'sonner';
- import { Upload, X, CheckCircle, Loader2, FileText } from 'lucide-react';
- import { uploadMinIOWithPolicy, MinioProgressEvent } from '@/client/utils/minio';
- import type { UploadRequestOption } from 'rc-upload/lib/interface';
- import type { RcFile } from 'rc-upload/lib/interface';
- interface MinioUploaderProps {
- /** 上传路径 */
- uploadPath: string;
- /** 允许的文件类型,如['image/*', '.pdf'] */
- accept?: string;
- /** 最大文件大小(MB) */
- maxSize?: number;
- /** 是否允许多文件上传 */
- multiple?: boolean;
- /** 上传成功回调 */
- onUploadSuccess?: (fileKey: string, fileUrl: string, file: File) => void;
- /** 上传失败回调 */
- onUploadError?: (error: Error, file: File) => void;
- /** 自定义上传按钮文本 */
- buttonText?: string;
- /** 自定义提示文本 */
- tipText?: string;
- }
- // 定义上传文件状态
- interface UploadFile {
- uid: string;
- name: string;
- size: number;
- type?: string;
- status: 'uploading' | 'success' | 'error';
- percent: number;
- error?: string;
- url?: string;
- }
- const MinioUploader: React.FC<MinioUploaderProps> = ({
- uploadPath = '/',
- accept,
- maxSize = 500, // 默认最大500MB
- multiple = false,
- onUploadSuccess,
- onUploadError,
- buttonText = '点击或拖拽上传文件',
- tipText = '支持单文件或多文件上传,单个文件大小不超过500MB'
- }) => {
- const [fileList, setFileList] = useState<UploadFile[]>([]);
- const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
- const [dragActive, setDragActive] = useState(false);
- // 处理上传进度
- const handleProgress = useCallback((uid: string, event: MinioProgressEvent) => {
- setFileList(prev =>
- prev.map(item => {
- if (item.uid === uid) {
- return {
- ...item,
- status: event.stage === 'error' ? 'error' : 'uploading',
- percent: event.progress,
- error: event.stage === 'error' ? event.message : undefined
- };
- }
- return item;
- })
- );
- }, []);
- // 处理上传成功
- const handleComplete = useCallback((uid: string, result: { fileKey: string; fileUrl: string }, file: File) => {
- setFileList(prev =>
- prev.map(item => {
- if (item.uid === uid) {
- return {
- ...item,
- status: 'success',
- percent: 100,
- url: result.fileUrl,
- };
- }
- return item;
- })
- );
-
- setUploadingFiles(prev => {
- const newSet = new Set(prev);
- newSet.delete(uid);
- return newSet;
- });
-
- toast.success(`文件 "${file.name}" 上传成功`);
- onUploadSuccess?.(result.fileKey, result.fileUrl, file);
- }, [onUploadSuccess]);
- // 处理上传失败
- const handleError = useCallback((uid: string, error: Error, file: File) => {
- setFileList(prev =>
- prev.map(item => {
- if (item.uid === uid) {
- return {
- ...item,
- status: 'error',
- percent: 0,
- error: error.message || '上传失败'
- };
- }
- return item;
- })
- );
-
- setUploadingFiles(prev => {
- const newSet = new Set(prev);
- newSet.delete(uid);
- return newSet;
- });
-
- toast.error(`文件 "${file.name}" 上传失败: ${error.message}`);
- onUploadError?.(error, file);
- }, [onUploadError]);
- // 自定义上传逻辑
- const handleUpload = async (file: File) => {
- const uid = Date.now().toString() + Math.random().toString(36).substr(2, 9);
-
- // 添加到文件列表
- setFileList(prev => [
- ...prev,
- {
- uid,
- name: file.name,
- size: file.size,
- type: file.type,
- status: 'uploading',
- percent: 0,
- }
- ]);
-
- // 添加到上传中集合
- setUploadingFiles(prev => new Set(prev).add(uid));
-
- try {
- // 验证文件大小
- const fileSizeMB = file.size / (1024 * 1024);
- if (fileSizeMB > maxSize) {
- throw new Error(`文件大小超过 ${maxSize}MB 限制`);
- }
-
- // 调用minio上传方法
- const result = await uploadMinIOWithPolicy(
- uploadPath,
- file,
- file.name,
- {
- onProgress: (event) => handleProgress(uid, event),
- signal: new AbortController().signal
- }
- );
-
- handleComplete(uid, result, file);
- } catch (error) {
- handleError(uid, error instanceof Error ? error : new Error('未知错误'), file);
- }
- };
- // 处理文件选择
- const handleFileSelect = (files: FileList) => {
- if (!files || files.length === 0) return;
- const fileArray = Array.from(files);
-
- if (!multiple && fileArray.length > 1) {
- toast.error('不支持多文件上传');
- return;
- }
- fileArray.forEach(file => handleUpload(file));
- };
- // 处理拖拽
- const handleDrag = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- if (e.type === 'dragenter' || e.type === 'dragover') {
- setDragActive(true);
- } else if (e.type === 'dragleave') {
- setDragActive(false);
- }
- };
- const handleDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setDragActive(false);
-
- const files = e.dataTransfer.files;
- handleFileSelect(files);
- };
- // 处理文件移除
- const handleRemove = (uid: string) => {
- setFileList(prev => prev.filter(item => item.uid !== uid));
- };
- // 渲染上传状态
- const renderUploadStatus = (item: UploadFile) => {
- switch (item.status) {
- case 'uploading':
- return (
- <div className="flex items-center gap-2">
- <Loader2 className="h-4 w-4 animate-spin" />
- <span className="text-sm">{Math.round(item.percent)}%</span>
- </div>
- );
- case 'success':
- return (
- <div className="flex items-center gap-2">
- <CheckCircle className="h-4 w-4 text-green-500" />
- <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
- 上传成功
- </Badge>
- </div>
- );
- case 'error':
- return (
- <div className="flex items-center gap-2">
- <div className="h-4 w-4 text-red-500">×</div>
- <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
- {item.error || '上传失败'}
- </Badge>
- </div>
- );
- default:
- return null;
- }
- };
- // 渲染文件图标
- const renderFileIcon = (type?: string) => {
- if (type?.startsWith('image/')) {
- return <FileText className="h-8 w-8 text-blue-500" />;
- } else if (type?.startsWith('video/')) {
- return <FileText className="h-8 w-8 text-red-500" />;
- } else if (type?.startsWith('audio/')) {
- return <FileText className="h-8 w-8 text-purple-500" />;
- } else if (type?.includes('pdf')) {
- return <FileText className="h-8 w-8 text-red-500" />;
- } else if (type?.includes('word')) {
- return <FileText className="h-8 w-8 text-blue-600" />;
- } else if (type?.includes('excel') || type?.includes('sheet')) {
- return <FileText className="h-8 w-8 text-green-500" />;
- } else {
- return <FileText className="h-8 w-8 text-gray-500" />;
- }
- };
- return (
- <div className="space-y-4">
- {/* 拖拽上传区域 */}
- <div
- className={`relative border-2 border-dashed rounded-lg p-6 transition-all ${
- dragActive
- ? 'border-primary bg-primary/5'
- : 'border-gray-300 hover:border-primary/50'
- }`}
- onDragEnter={handleDrag}
- onDragLeave={handleDrag}
- onDragOver={handleDrag}
- onDrop={handleDrop}
- >
- <div className="flex flex-col items-center justify-center space-y-4">
- <Upload className={`h-12 w-12 ${dragActive ? 'text-primary' : 'text-gray-400'}`} />
- <div className="text-center">
- <p className="text-lg font-medium">{buttonText}</p>
- <p className="text-sm text-gray-500 mt-1">{tipText}</p>
- </div>
- <Button
- type="button"
- variant="outline"
- onClick={() => {
- const input = document.createElement('input');
- input.type = 'file';
- input.accept = accept || '';
- input.multiple = multiple;
- input.onchange = (e) => {
- const files = (e.target as HTMLInputElement).files;
- if (files) handleFileSelect(files);
- };
- input.click();
- }}
- >
- <Upload className="h-4 w-4 mr-2" />
- 选择文件
- </Button>
- </div>
- </div>
- {/* 上传进度列表 */}
- {fileList.length > 0 && (
- <Card>
- <CardContent className="pt-6">
- <h3 className="text-lg font-semibold mb-4">上传进度</h3>
- <div className="space-y-4">
- {fileList.map(item => (
- <div key={item.uid} className="flex items-center space-x-4 p-4 border rounded-lg">
- <div className="flex-shrink-0">
- {renderFileIcon(item.type)}
- </div>
- <div className="flex-1 min-w-0">
- <div className="flex justify-between items-center mb-2">
- <p className="text-sm font-medium truncate">{item.name}</p>
- <div className="flex items-center space-x-2">
- {renderUploadStatus(item)}
- <Button
- variant="ghost"
- size="sm"
- onClick={() => handleRemove(item.uid)}
- disabled={item.status === 'uploading'}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- </div>
- {item.status === 'uploading' && (
- <div className="space-y-2">
- <Progress value={item.percent} className="h-2" />
- <p className="text-xs text-gray-500">
- {Math.round(item.percent)}% - {formatFileSize(item.size * (item.percent / 100))} / {formatFileSize(item.size)}
- </p>
- </div>
- )}
- </div>
- </div>
- ))}
- </div>
- </CardContent>
- </Card>
- )}
- </div>
- );
- };
- // 辅助函数:格式化文件大小
- const formatFileSize = (bytes: number): string => {
- if (bytes === 0) return '0 Bytes';
- const k = 1024;
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
- };
- export default MinioUploader;
|