|
|
@@ -0,0 +1,262 @@
|
|
|
+import React, { useState, useCallback } from 'react';
|
|
|
+import { Upload, Progress, message, Tag, Space, Typography, Button } from 'antd';
|
|
|
+import { UploadOutlined, CloseOutlined, CheckCircleOutlined, SyncOutlined } from '@ant-design/icons';
|
|
|
+import { App } from 'antd';
|
|
|
+import type { UploadFile , UploadProps} from 'antd';
|
|
|
+import type { UploadFileStatus } from 'antd/es/upload/interface';
|
|
|
+import { uploadMinIOWithPolicy, MinioProgressEvent } from '@/client/utils/minio';
|
|
|
+
|
|
|
+interface MinioUploaderProps {
|
|
|
+ /** 上传路径 */
|
|
|
+ uploadPath: string;
|
|
|
+ /** 允许的文件类型,如['image/*', '.pdf'] */
|
|
|
+ accept?: string;
|
|
|
+ /** 最大文件大小(MB) */
|
|
|
+ maxSize?: number;
|
|
|
+ /** 是否允许多文件上传 */
|
|
|
+ multiple?: boolean;
|
|
|
+ /** 上传成功回调 */
|
|
|
+ onUploadSuccess?: (fileKey: string, fileUrl: string, file: File) => void;
|
|
|
+ /** 上传失败回调 */
|
|
|
+ onUploadError?: (error: Error, file: File) => void;
|
|
|
+ /** 自定义上传按钮文本 */
|
|
|
+ buttonText?: string;
|
|
|
+ /** 自定义提示文本 */
|
|
|
+ tipText?: string;
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+const MinioUploader: React.FC<MinioUploaderProps> = ({
|
|
|
+ uploadPath = '/',
|
|
|
+ accept,
|
|
|
+ maxSize = 500, // 默认最大500MB
|
|
|
+ multiple = false,
|
|
|
+ onUploadSuccess,
|
|
|
+ onUploadError,
|
|
|
+ buttonText = '点击或拖拽上传文件',
|
|
|
+ tipText = '支持单文件或多文件上传,单个文件大小不超过500MB'
|
|
|
+}) => {
|
|
|
+ const { message: antdMessage } = App.useApp();
|
|
|
+ const [fileList, setFileList] = useState<UploadFile[]>([]);
|
|
|
+ const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
|
|
|
+
|
|
|
+ // 处理上传进度
|
|
|
+ const handleProgress = useCallback((uid: string, event: MinioProgressEvent) => {
|
|
|
+ setFileList(prev =>
|
|
|
+ prev.map(item => {
|
|
|
+ if (item.uid === uid) {
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ status: event.stage === 'error' ? ('error' as UploadFileStatus) : ('uploading' as UploadFileStatus),
|
|
|
+ percent: event.progress,
|
|
|
+ error: event.stage === 'error' ? event.message : undefined
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return item;
|
|
|
+ })
|
|
|
+ );
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 处理上传成功
|
|
|
+ const handleComplete = useCallback((uid: string, result: { fileKey: string; fileUrl: string }, file: File) => {
|
|
|
+ setFileList(prev =>
|
|
|
+ prev.map(item => {
|
|
|
+ if (item.uid === uid) {
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ status: 'success' as UploadFileStatus,
|
|
|
+ percent: 100,
|
|
|
+ response: { fileKey: result.fileKey },
|
|
|
+ url: result.fileUrl,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return item;
|
|
|
+ })
|
|
|
+ );
|
|
|
+
|
|
|
+ setUploadingFiles(prev => {
|
|
|
+ const newSet = new Set(prev);
|
|
|
+ newSet.delete(uid);
|
|
|
+ return newSet;
|
|
|
+ });
|
|
|
+
|
|
|
+ antdMessage.success(`文件 "${file.name}" 上传成功`);
|
|
|
+ onUploadSuccess?.(result.fileKey, result.fileUrl, file);
|
|
|
+ }, [antdMessage, onUploadSuccess]);
|
|
|
+
|
|
|
+ // 处理上传失败
|
|
|
+ const handleError = useCallback((uid: string, error: Error, file: File) => {
|
|
|
+ setFileList(prev =>
|
|
|
+ prev.map(item => {
|
|
|
+ if (item.uid === uid) {
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ status: 'error' as UploadFileStatus,
|
|
|
+ percent: 0,
|
|
|
+ error: error.message || '上传失败'
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return item;
|
|
|
+ })
|
|
|
+ );
|
|
|
+
|
|
|
+ setUploadingFiles(prev => {
|
|
|
+ const newSet = new Set(prev);
|
|
|
+ newSet.delete(uid);
|
|
|
+ return newSet;
|
|
|
+ });
|
|
|
+
|
|
|
+ antdMessage.error(`文件 "${file.name}" 上传失败: ${error.message}`);
|
|
|
+ onUploadError?.(error, file);
|
|
|
+ }, [antdMessage, onUploadError]);
|
|
|
+
|
|
|
+ // 自定义上传逻辑
|
|
|
+ const customRequest = async (options: {
|
|
|
+ file: UploadFile,
|
|
|
+ onSuccess: () => void,
|
|
|
+ onError: (error: Error) => void
|
|
|
+ }) => {
|
|
|
+ const { file, onSuccess, onError } = options;
|
|
|
+ const uid = file.uid;
|
|
|
+
|
|
|
+ // 添加到文件列表
|
|
|
+ setFileList(prev => [
|
|
|
+ ...prev.filter(item => item.uid !== uid),
|
|
|
+ {
|
|
|
+ uid,
|
|
|
+ name: file.name,
|
|
|
+ size: file.size,
|
|
|
+ type: file.type,
|
|
|
+ lastModified: file.lastModified,
|
|
|
+ lastModifiedDate: file.lastModifiedDate,
|
|
|
+ originFileObj: file.originFileObj,
|
|
|
+ status: 'uploading' as UploadFileStatus,
|
|
|
+ percent: 0,
|
|
|
+ }
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // 添加到上传中集合
|
|
|
+ setUploadingFiles(prev => new Set(prev).add(uid));
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 调用minio上传方法
|
|
|
+ const result = await uploadMinIOWithPolicy(
|
|
|
+ uploadPath,
|
|
|
+ file.originFileObj as File,
|
|
|
+ file.name,
|
|
|
+ {
|
|
|
+ onProgress: (event) => handleProgress(uid, event),
|
|
|
+ signal: new AbortController().signal
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ handleComplete(uid, result, file.originFileObj as File);
|
|
|
+ onSuccess();
|
|
|
+ } catch (error) {
|
|
|
+ handleError(uid, error instanceof Error ? error : new Error('未知错误'), file.originFileObj as File);
|
|
|
+ onError(error instanceof Error ? error : new Error('未知错误'));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理文件移除
|
|
|
+ const handleRemove = (uid: string) => {
|
|
|
+ setFileList(prev => prev.filter(item => item.uid !== uid));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 验证文件大小
|
|
|
+ const beforeUpload = (file: File) => {
|
|
|
+ const fileSizeMB = file.size / (1024 * 1024);
|
|
|
+ if (fileSizeMB > maxSize!) {
|
|
|
+ message.error(`文件 "${file.name}" 大小超过 ${maxSize}MB 限制`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 渲染上传状态
|
|
|
+ const renderUploadStatus = (item: UploadFile) => {
|
|
|
+ switch (item.status) {
|
|
|
+ case 'uploading':
|
|
|
+ return (
|
|
|
+ <Space>
|
|
|
+ <SyncOutlined spin size={12} />
|
|
|
+ <span>{item.percent}%</span>
|
|
|
+ </Space>
|
|
|
+ );
|
|
|
+ case 'done':
|
|
|
+ return (
|
|
|
+ <Space>
|
|
|
+ <CheckCircleOutlined style={{ color: '#52c41a' }} size={12} />
|
|
|
+ <Tag color="success">上传成功</Tag>
|
|
|
+ </Space>
|
|
|
+ );
|
|
|
+ case 'error':
|
|
|
+ return (
|
|
|
+ <Space>
|
|
|
+ <CloseOutlined style={{ color: '#ff4d4f' }} size={12} />
|
|
|
+ <Tag color="error">{item.error || '上传失败'}</Tag>
|
|
|
+ </Space>
|
|
|
+ );
|
|
|
+ default:
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="minio-uploader">
|
|
|
+ <Upload.Dragger
|
|
|
+ name="files"
|
|
|
+ accept={accept}
|
|
|
+ multiple={multiple}
|
|
|
+ customRequest={customRequest}
|
|
|
+ beforeUpload={beforeUpload}
|
|
|
+ showUploadList={false}
|
|
|
+ disabled={uploadingFiles.size > 0 && !multiple}
|
|
|
+ >
|
|
|
+ <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">
|
|
|
+ <UploadOutlined style={{ fontSize: 24, color: '#1890ff' }} />
|
|
|
+ <Typography.Text className="mt-2">{buttonText}</Typography.Text>
|
|
|
+ <Typography.Text type="secondary" className="mt-1">
|
|
|
+ {tipText}
|
|
|
+ </Typography.Text>
|
|
|
+ </div>
|
|
|
+ </Upload.Dragger>
|
|
|
+
|
|
|
+ {/* 上传进度列表 */}
|
|
|
+ {fileList.length > 0 && (
|
|
|
+ <div className="mt-4 space-y-3">
|
|
|
+ {fileList.map(item => (
|
|
|
+ <div key={item.uid} className="flex items-center p-3 border rounded-md">
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <div className="flex justify-between items-center mb-1">
|
|
|
+ <Typography.Text ellipsis className="max-w-xs">
|
|
|
+ {item.name}
|
|
|
+ </Typography.Text>
|
|
|
+ <div className="flex items-center space-x-2">
|
|
|
+ {renderUploadStatus(item)}
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<CloseOutlined />}
|
|
|
+ onClick={() => handleRemove(item.uid)}
|
|
|
+ disabled={item.status === 'uploading'}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {item.status === 'uploading' && (
|
|
|
+ <Progress
|
|
|
+ percent={item.percent}
|
|
|
+ size="small"
|
|
|
+ status={item.percent === 100 ? 'success' : undefined}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default MinioUploader;
|