MinioUploader.tsx 15 KB

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