MinioUploader.tsx 13 KB

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