MinioUploader.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import React, { useState, useCallback } from 'react';
  2. import { Upload, Progress, message, Tag, Space, Typography, Button } from 'antd';
  3. import { UploadOutlined, CloseOutlined, CheckCircleOutlined, SyncOutlined } from '@ant-design/icons';
  4. import { App } from 'antd';
  5. import type { UploadFile, UploadProps } from 'antd';
  6. import type { RcFile } from 'rc-upload/lib/interface';
  7. import type { UploadFileStatus } from 'antd/es/upload/interface';
  8. import type { UploadRequestOption } from 'rc-upload/lib/interface';
  9. import { uploadMinIOWithPolicy, MinioProgressEvent } from '@/client/utils/minio';
  10. interface MinioUploaderProps {
  11. /** 上传路径 */
  12. uploadPath: string;
  13. /** 允许的文件类型,如['image/*', '.pdf'] */
  14. accept?: string;
  15. /** 最大文件大小(MB) */
  16. maxSize?: number;
  17. /** 是否允许多文件上传 */
  18. multiple?: boolean;
  19. /** 上传成功回调 */
  20. onUploadSuccess?: (fileKey: string, fileUrl: string, file: File) => void;
  21. /** 上传失败回调 */
  22. onUploadError?: (error: Error, file: File) => void;
  23. /** 自定义上传按钮文本 */
  24. buttonText?: string;
  25. /** 自定义提示文本 */
  26. tipText?: string;
  27. }
  28. const MinioUploader: React.FC<MinioUploaderProps> = ({
  29. uploadPath = '/',
  30. accept,
  31. maxSize = 500, // 默认最大500MB
  32. multiple = false,
  33. onUploadSuccess,
  34. onUploadError,
  35. buttonText = '点击或拖拽上传文件',
  36. tipText = '支持单文件或多文件上传,单个文件大小不超过500MB'
  37. }) => {
  38. const { message: antdMessage } = App.useApp();
  39. const [fileList, setFileList] = useState<UploadFile[]>([]);
  40. const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
  41. // 处理上传进度
  42. const handleProgress = useCallback((uid: string, event: MinioProgressEvent) => {
  43. setFileList(prev =>
  44. prev.map(item => {
  45. if (item.uid === uid) {
  46. return {
  47. ...item,
  48. status: event.stage === 'error' ? ('error' as UploadFileStatus) : ('uploading' as UploadFileStatus),
  49. percent: event.progress,
  50. error: event.stage === 'error' ? event.message : undefined
  51. };
  52. }
  53. return item;
  54. })
  55. );
  56. }, []);
  57. // 处理上传成功
  58. const handleComplete = useCallback((uid: string, result: { fileKey: string; fileUrl: string }, file: File) => {
  59. setFileList(prev =>
  60. prev.map(item => {
  61. if (item.uid === uid) {
  62. return {
  63. ...item,
  64. status: 'success' as UploadFileStatus,
  65. percent: 100,
  66. response: { fileKey: result.fileKey },
  67. url: result.fileUrl,
  68. };
  69. }
  70. return item;
  71. })
  72. );
  73. setUploadingFiles(prev => {
  74. const newSet = new Set(prev);
  75. newSet.delete(uid);
  76. return newSet;
  77. });
  78. antdMessage.success(`文件 "${file.name}" 上传成功`);
  79. onUploadSuccess?.(result.fileKey, result.fileUrl, file);
  80. }, [antdMessage, onUploadSuccess]);
  81. // 处理上传失败
  82. const handleError = useCallback((uid: string, error: Error, file: File) => {
  83. setFileList(prev =>
  84. prev.map(item => {
  85. if (item.uid === uid) {
  86. return {
  87. ...item,
  88. status: 'error' as UploadFileStatus,
  89. percent: 0,
  90. error: error.message || '上传失败'
  91. };
  92. }
  93. return item;
  94. })
  95. );
  96. setUploadingFiles(prev => {
  97. const newSet = new Set(prev);
  98. newSet.delete(uid);
  99. return newSet;
  100. });
  101. antdMessage.error(`文件 "${file.name}" 上传失败: ${error.message}`);
  102. onUploadError?.(error, file);
  103. }, [antdMessage, onUploadError]);
  104. // 自定义上传逻辑
  105. const customRequest = async (options: UploadRequestOption) => {
  106. const { file, onSuccess, onError } = options;
  107. const rcFile = file as RcFile;
  108. const uid = rcFile.uid;
  109. // 添加到文件列表
  110. setFileList(prev => [
  111. ...prev.filter(item => item.uid !== uid),
  112. {
  113. uid,
  114. name: rcFile.name,
  115. size: rcFile.size,
  116. type: rcFile.type,
  117. lastModified: rcFile.lastModified,
  118. lastModifiedDate: new Date(rcFile.lastModified),
  119. status: 'uploading' as UploadFileStatus,
  120. percent: 0,
  121. }
  122. ]);
  123. // 添加到上传中集合
  124. setUploadingFiles(prev => new Set(prev).add(uid));
  125. try {
  126. // 调用minio上传方法
  127. const result = await uploadMinIOWithPolicy(
  128. uploadPath,
  129. options.file as unknown as File,
  130. rcFile.name,
  131. {
  132. onProgress: (event) => handleProgress(uid, event),
  133. signal: new AbortController().signal
  134. }
  135. );
  136. handleComplete(uid, result, rcFile as unknown as File);
  137. onSuccess?.({}, rcFile);
  138. } catch (error) {
  139. handleError(uid, error instanceof Error ? error : new Error('未知错误'), rcFile as unknown as File);
  140. onError?.(error instanceof Error ? error : new Error('未知错误'));
  141. }
  142. };
  143. // 处理文件移除
  144. const handleRemove = (uid: string) => {
  145. setFileList(prev => prev.filter(item => item.uid !== uid));
  146. };
  147. // 验证文件大小
  148. const beforeUpload = (file: File) => {
  149. const fileSizeMB = file.size / (1024 * 1024);
  150. if (fileSizeMB > maxSize!) {
  151. message.error(`文件 "${file.name}" 大小超过 ${maxSize}MB 限制`);
  152. return false;
  153. }
  154. return true;
  155. };
  156. // 渲染上传状态
  157. const renderUploadStatus = (item: UploadFile) => {
  158. switch (item.status) {
  159. case 'uploading':
  160. return (
  161. <Space>
  162. <SyncOutlined spin size={12} />
  163. <span>{item.percent}%</span>
  164. </Space>
  165. );
  166. case 'done':
  167. return (
  168. <Space>
  169. <CheckCircleOutlined style={{ color: '#52c41a' }} size={12} />
  170. <Tag color="success">上传成功</Tag>
  171. </Space>
  172. );
  173. case 'error':
  174. return (
  175. <Space>
  176. <CloseOutlined style={{ color: '#ff4d4f' }} size={12} />
  177. <Tag color="error">{item.error || '上传失败'}</Tag>
  178. </Space>
  179. );
  180. default:
  181. return null;
  182. }
  183. };
  184. return (
  185. <div className="minio-uploader">
  186. <Upload.Dragger
  187. name="files"
  188. accept={accept}
  189. multiple={multiple}
  190. customRequest={customRequest}
  191. beforeUpload={beforeUpload}
  192. showUploadList={false}
  193. disabled={uploadingFiles.size > 0 && !multiple}
  194. >
  195. <div className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-gray-300 rounded-md transition-all hover:border-primary">
  196. <UploadOutlined style={{ fontSize: 24, color: '#1890ff' }} />
  197. <Typography.Text className="mt-2">{buttonText}</Typography.Text>
  198. <Typography.Text type="secondary" className="mt-1">
  199. {tipText}
  200. </Typography.Text>
  201. </div>
  202. </Upload.Dragger>
  203. {/* 上传进度列表 */}
  204. {fileList.length > 0 && (
  205. <div className="mt-4 space-y-3">
  206. {fileList.map(item => (
  207. <div key={item.uid} className="flex items-center p-3 border rounded-md">
  208. <div className="flex-1 min-w-0">
  209. <div className="flex justify-between items-center mb-1">
  210. <Typography.Text ellipsis className="max-w-xs">
  211. {item.name}
  212. </Typography.Text>
  213. <div className="flex items-center space-x-2">
  214. {renderUploadStatus(item)}
  215. <Button
  216. type="text"
  217. size="small"
  218. icon={<CloseOutlined />}
  219. onClick={() => handleRemove(item.uid)}
  220. disabled={item.status === 'uploading'}
  221. />
  222. </div>
  223. </div>
  224. {item.status === 'uploading' && (
  225. <Progress
  226. percent={item.percent}
  227. size="small"
  228. status={item.percent === 100 ? 'success' : undefined}
  229. />
  230. )}
  231. </div>
  232. </div>
  233. ))}
  234. </div>
  235. )}
  236. </div>
  237. );
  238. };
  239. export default MinioUploader;