MinioUploader.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import React, { useState, useCallback } from 'react';
  2. import { Button } from '@/client/components/ui/button';
  3. import { Card, CardContent } from '@/client/components/ui/card';
  4. import { Progress } from '@/client/components/ui/progress';
  5. import { Badge } from '@/client/components/ui/badge';
  6. import { toast } from 'sonner';
  7. import { Upload, X, CheckCircle, Loader2, FileText } from 'lucide-react';
  8. import { uploadMinIOWithPolicy, MinioProgressEvent } from '@/client/utils/minio';
  9. import type { UploadRequestOption } from 'rc-upload/lib/interface';
  10. import type { RcFile } from 'rc-upload/lib/interface';
  11. interface MinioUploaderProps {
  12. /** 上传路径 */
  13. uploadPath: string;
  14. /** 允许的文件类型,如['image/*', '.pdf'] */
  15. accept?: string;
  16. /** 最大文件大小(MB) */
  17. maxSize?: number;
  18. /** 是否允许多文件上传 */
  19. multiple?: boolean;
  20. /** 上传成功回调 */
  21. onUploadSuccess?: (fileKey: string, fileUrl: string, file: File) => void;
  22. /** 上传失败回调 */
  23. onUploadError?: (error: Error, file: File) => void;
  24. /** 自定义上传按钮文本 */
  25. buttonText?: string;
  26. /** 自定义提示文本 */
  27. tipText?: string;
  28. }
  29. // 定义上传文件状态
  30. interface UploadFile {
  31. uid: string;
  32. name: string;
  33. size: number;
  34. type?: string;
  35. status: 'uploading' | 'success' | 'error';
  36. percent: number;
  37. error?: string;
  38. url?: string;
  39. }
  40. const MinioUploader: React.FC<MinioUploaderProps> = ({
  41. uploadPath = '/',
  42. accept,
  43. maxSize = 500, // 默认最大500MB
  44. multiple = false,
  45. onUploadSuccess,
  46. onUploadError,
  47. buttonText = '点击或拖拽上传文件',
  48. tipText = '支持单文件或多文件上传,单个文件大小不超过500MB'
  49. }) => {
  50. const [fileList, setFileList] = useState<UploadFile[]>([]);
  51. const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
  52. const [dragActive, setDragActive] = useState(false);
  53. // 处理上传进度
  54. const handleProgress = useCallback((uid: string, event: MinioProgressEvent) => {
  55. setFileList(prev =>
  56. prev.map(item => {
  57. if (item.uid === uid) {
  58. return {
  59. ...item,
  60. status: event.stage === 'error' ? 'error' : 'uploading',
  61. percent: event.progress,
  62. error: event.stage === 'error' ? event.message : undefined
  63. };
  64. }
  65. return item;
  66. })
  67. );
  68. }, []);
  69. // 处理上传成功
  70. const handleComplete = useCallback((uid: string, result: { fileKey: string; fileUrl: string }, file: File) => {
  71. setFileList(prev =>
  72. prev.map(item => {
  73. if (item.uid === uid) {
  74. return {
  75. ...item,
  76. status: 'success',
  77. percent: 100,
  78. url: result.fileUrl,
  79. };
  80. }
  81. return item;
  82. })
  83. );
  84. setUploadingFiles(prev => {
  85. const newSet = new Set(prev);
  86. newSet.delete(uid);
  87. return newSet;
  88. });
  89. toast.success(`文件 "${file.name}" 上传成功`);
  90. onUploadSuccess?.(result.fileKey, result.fileUrl, file);
  91. }, [onUploadSuccess]);
  92. // 处理上传失败
  93. const handleError = useCallback((uid: string, error: Error, file: File) => {
  94. setFileList(prev =>
  95. prev.map(item => {
  96. if (item.uid === uid) {
  97. return {
  98. ...item,
  99. status: 'error',
  100. percent: 0,
  101. error: error.message || '上传失败'
  102. };
  103. }
  104. return item;
  105. })
  106. );
  107. setUploadingFiles(prev => {
  108. const newSet = new Set(prev);
  109. newSet.delete(uid);
  110. return newSet;
  111. });
  112. toast.error(`文件 "${file.name}" 上传失败: ${error.message}`);
  113. onUploadError?.(error, file);
  114. }, [onUploadError]);
  115. // 自定义上传逻辑
  116. const handleUpload = async (file: File) => {
  117. const uid = Date.now().toString() + Math.random().toString(36).substr(2, 9);
  118. // 添加到文件列表
  119. setFileList(prev => [
  120. ...prev,
  121. {
  122. uid,
  123. name: file.name,
  124. size: file.size,
  125. type: file.type,
  126. status: 'uploading',
  127. percent: 0,
  128. }
  129. ]);
  130. // 添加到上传中集合
  131. setUploadingFiles(prev => new Set(prev).add(uid));
  132. try {
  133. // 验证文件大小
  134. const fileSizeMB = file.size / (1024 * 1024);
  135. if (fileSizeMB > maxSize) {
  136. throw new Error(`文件大小超过 ${maxSize}MB 限制`);
  137. }
  138. // 调用minio上传方法
  139. const result = await uploadMinIOWithPolicy(
  140. uploadPath,
  141. file,
  142. file.name,
  143. {
  144. onProgress: (event) => handleProgress(uid, event),
  145. signal: new AbortController().signal
  146. }
  147. );
  148. handleComplete(uid, result, file);
  149. } catch (error) {
  150. handleError(uid, error instanceof Error ? error : new Error('未知错误'), file);
  151. }
  152. };
  153. // 处理文件选择
  154. const handleFileSelect = (files: FileList) => {
  155. if (!files || files.length === 0) return;
  156. const fileArray = Array.from(files);
  157. if (!multiple && fileArray.length > 1) {
  158. toast.error('不支持多文件上传');
  159. return;
  160. }
  161. fileArray.forEach(file => handleUpload(file));
  162. };
  163. // 处理拖拽
  164. const handleDrag = (e: React.DragEvent) => {
  165. e.preventDefault();
  166. e.stopPropagation();
  167. if (e.type === 'dragenter' || e.type === 'dragover') {
  168. setDragActive(true);
  169. } else if (e.type === 'dragleave') {
  170. setDragActive(false);
  171. }
  172. };
  173. const handleDrop = (e: React.DragEvent) => {
  174. e.preventDefault();
  175. e.stopPropagation();
  176. setDragActive(false);
  177. const files = e.dataTransfer.files;
  178. handleFileSelect(files);
  179. };
  180. // 处理文件移除
  181. const handleRemove = (uid: string) => {
  182. setFileList(prev => prev.filter(item => item.uid !== uid));
  183. };
  184. // 渲染上传状态
  185. const renderUploadStatus = (item: UploadFile) => {
  186. switch (item.status) {
  187. case 'uploading':
  188. return (
  189. <div className="flex items-center gap-2">
  190. <Loader2 className="h-4 w-4 animate-spin" />
  191. <span className="text-sm">{Math.round(item.percent)}%</span>
  192. </div>
  193. );
  194. case 'success':
  195. return (
  196. <div className="flex items-center gap-2">
  197. <CheckCircle className="h-4 w-4 text-green-500" />
  198. <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
  199. 上传成功
  200. </Badge>
  201. </div>
  202. );
  203. case 'error':
  204. return (
  205. <div className="flex items-center gap-2">
  206. <div className="h-4 w-4 text-red-500">×</div>
  207. <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
  208. {item.error || '上传失败'}
  209. </Badge>
  210. </div>
  211. );
  212. default:
  213. return null;
  214. }
  215. };
  216. // 渲染文件图标
  217. const renderFileIcon = (type?: string) => {
  218. if (type?.startsWith('image/')) {
  219. return <FileText className="h-8 w-8 text-blue-500" />;
  220. } else if (type?.startsWith('video/')) {
  221. return <FileText className="h-8 w-8 text-red-500" />;
  222. } else if (type?.startsWith('audio/')) {
  223. return <FileText className="h-8 w-8 text-purple-500" />;
  224. } else if (type?.includes('pdf')) {
  225. return <FileText className="h-8 w-8 text-red-500" />;
  226. } else if (type?.includes('word')) {
  227. return <FileText className="h-8 w-8 text-blue-600" />;
  228. } else if (type?.includes('excel') || type?.includes('sheet')) {
  229. return <FileText className="h-8 w-8 text-green-500" />;
  230. } else {
  231. return <FileText className="h-8 w-8 text-gray-500" />;
  232. }
  233. };
  234. return (
  235. <div className="space-y-4">
  236. {/* 拖拽上传区域 */}
  237. <div
  238. className={`relative border-2 border-dashed rounded-lg p-6 transition-all ${
  239. dragActive
  240. ? 'border-primary bg-primary/5'
  241. : 'border-gray-300 hover:border-primary/50'
  242. }`}
  243. onDragEnter={handleDrag}
  244. onDragLeave={handleDrag}
  245. onDragOver={handleDrag}
  246. onDrop={handleDrop}
  247. >
  248. <div className="flex flex-col items-center justify-center space-y-4">
  249. <Upload className={`h-12 w-12 ${dragActive ? 'text-primary' : 'text-gray-400'}`} />
  250. <div className="text-center">
  251. <p className="text-lg font-medium">{buttonText}</p>
  252. <p className="text-sm text-gray-500 mt-1">{tipText}</p>
  253. </div>
  254. <Button
  255. type="button"
  256. variant="outline"
  257. onClick={() => {
  258. const input = document.createElement('input');
  259. input.type = 'file';
  260. input.accept = accept || '';
  261. input.multiple = multiple;
  262. input.onchange = (e) => {
  263. const files = (e.target as HTMLInputElement).files;
  264. if (files) handleFileSelect(files);
  265. };
  266. input.click();
  267. }}
  268. >
  269. <Upload className="h-4 w-4 mr-2" />
  270. 选择文件
  271. </Button>
  272. </div>
  273. </div>
  274. {/* 上传进度列表 */}
  275. {fileList.length > 0 && (
  276. <Card>
  277. <CardContent className="pt-6">
  278. <h3 className="text-lg font-semibold mb-4">上传进度</h3>
  279. <div className="space-y-4">
  280. {fileList.map(item => (
  281. <div key={item.uid} className="flex items-center space-x-4 p-4 border rounded-lg">
  282. <div className="flex-shrink-0">
  283. {renderFileIcon(item.type)}
  284. </div>
  285. <div className="flex-1 min-w-0">
  286. <div className="flex justify-between items-center mb-2">
  287. <p className="text-sm font-medium truncate">{item.name}</p>
  288. <div className="flex items-center space-x-2">
  289. {renderUploadStatus(item)}
  290. <Button
  291. variant="ghost"
  292. size="sm"
  293. onClick={() => handleRemove(item.uid)}
  294. disabled={item.status === 'uploading'}
  295. >
  296. <X className="h-4 w-4" />
  297. </Button>
  298. </div>
  299. </div>
  300. {item.status === 'uploading' && (
  301. <div className="space-y-2">
  302. <Progress value={item.percent} className="h-2" />
  303. <p className="text-xs text-gray-500">
  304. {Math.round(item.percent)}% - {formatFileSize(item.size * (item.percent / 100))} / {formatFileSize(item.size)}
  305. </p>
  306. </div>
  307. )}
  308. </div>
  309. </div>
  310. ))}
  311. </div>
  312. </CardContent>
  313. </Card>
  314. )}
  315. </div>
  316. );
  317. };
  318. // 辅助函数:格式化文件大小
  319. const formatFileSize = (bytes: number): string => {
  320. if (bytes === 0) return '0 Bytes';
  321. const k = 1024;
  322. const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
  323. const i = Math.floor(Math.log(bytes) / Math.log(k));
  324. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  325. };
  326. export default MinioUploader;