MinioUploader.tsx 16 KB

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