MinioUploader.tsx 15 KB

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