Просмотр исходного кода

✨ feat(admin): add aliyun OSS file upload component

- implement AliyunOSSUploader component with drag-and-drop functionality
- support file size validation and type restriction
- add upload progress display and status feedback
- implement retry and cancel upload functions
- support both single and multiple file upload modes
- add custom button text and tip text props for flexibility
- integrate with backend upload policy API for secure file uploads
yourname 8 месяцев назад
Родитель
Сommit
7f4d4dcb66
1 измененных файлов с 358 добавлено и 0 удалено
  1. 358 0
      src/client/admin/components/AliyunOSSUploader.tsx

+ 358 - 0
src/client/admin/components/AliyunOSSUploader.tsx

@@ -0,0 +1,358 @@
+import React, { useState, useCallback } from 'react';
+import { Upload, Progress, message, Tag, Space, Typography, Button } from 'antd';
+import { UploadOutlined, CloseOutlined, CheckCircleOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons';
+import { App } from 'antd';
+import type { UploadFile, UploadProps } from 'antd';
+import type { UploadFileStatus } from 'antd/es/upload/interface';
+import { fileClient } from '@/client/api';
+import type { InferRequestType, InferResponseType } from 'hono/client';
+
+// 定义上传策略响应类型
+type UploadPolicyResponse = InferResponseType<typeof fileClient['upload-policy']['$post'], 200>;
+// 定义创建文件请求类型
+type CreateFileRequest = InferRequestType<typeof fileClient['upload-policy']['$post']>['json'];
+
+interface AliyunOSSUploaderProps {
+  /** 上传路径 */
+  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 AliyunOSSUploader: React.FC<AliyunOSSUploaderProps> = ({
+  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, percent: number) => {
+    setFileList(prev => 
+      prev.map(item => {
+        if (item.uid === uid) {
+          return {
+            ...item,
+            status: 'uploading' as UploadFileStatus,
+            percent
+          };
+        }
+        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 getUploadPolicy = async (file: File): Promise<UploadPolicyResponse> => {
+    const fileName = file.name;
+    const fileSize = file.size;
+    const fileType = file.type;
+    
+    // 构造请求数据
+    const requestData: CreateFileRequest = {
+      name: fileName,
+      size: fileSize,
+      type: fileType,
+      path: uploadPath,
+      // 可以根据需要添加更多元数据
+    };
+    
+    try {
+      // 调用后端接口获取上传策略
+      const response = await fileClient['upload-policy'].$post({
+        json: requestData
+      });
+      
+      if (!response.ok) {
+        const errorData = await response.json().catch(() => null);
+        throw new Error(errorData?.message || `获取上传策略失败,状态码: ${response.status}`);
+      }
+      
+      return await response.json() as UploadPolicyResponse;
+    } catch (error) {
+      throw error instanceof Error ? error : new Error('获取上传策略失败');
+    }
+  };
+
+  // 重试上传
+  const handleRetry = (file: UploadFile) => {
+    if (file.originFileObj) {
+      customRequest({
+        file,
+        onSuccess: () => {},
+        onError: () => {}
+      });
+    }
+  };
+
+  // 自定义上传逻辑
+  const customRequest = async (options: {
+    file: UploadFile,
+    onSuccess: () => void,
+    onError: (error: Error) => void
+  }) => {
+    const { file, onSuccess, onError } = options;
+    const uid = file.uid;
+    const originFile = file.originFileObj as File;
+    
+    // 添加到文件列表
+    setFileList(prev => [
+      ...prev.filter(item => item.uid !== uid),
+      {
+        uid,
+        name: file.name,
+        size: file.size,
+        type: file.type,
+        status: 'uploading' as UploadFileStatus,
+        percent: 0,
+      }
+    ]);
+    
+    // 添加到上传中集合
+    setUploadingFiles(prev => new Set(prev).add(uid));
+    
+    try {
+      // 获取上传策略
+      handleProgress(uid, 5); // 标记为获取策略中
+      const policyResponse = await getUploadPolicy(originFile);
+      const { uploadPolicy } = policyResponse;
+      
+      // 创建表单数据
+      const formData = new FormData();
+      formData.append('key', uploadPolicy.key);
+      formData.append('x-amz-algorithm', uploadPolicy['x-amz-algorithm']);
+      formData.append('x-amz-credential', uploadPolicy['x-amz-credential']);
+      formData.append('x-amz-date', uploadPolicy['x-amz-date']);
+      formData.append('policy', uploadPolicy.policy);
+      formData.append('x-amz-signature', uploadPolicy['x-amz-signature']);
+      if (uploadPolicy['x-amz-security-token']) {
+        formData.append('x-amz-security-token', uploadPolicy['x-amz-security-token']);
+      }
+      formData.append('file', originFile);
+      
+      // 创建XMLHttpRequest对象上传文件
+      const xhr = new XMLHttpRequest();
+      xhr.open('POST', uploadPolicy.host, true);
+      
+      // 监听进度事件
+      xhr.upload.addEventListener('progress', (e) => {
+        if (e.lengthComputable) {
+          const percent = Math.round((e.loaded / e.total) * 100);
+          // 加上之前的5%作为获取策略的进度
+          const adjustedPercent = 5 + percent * 0.95;
+          handleProgress(uid, adjustedPercent);
+        }
+      });
+      
+      // 监听完成事件
+      xhr.addEventListener('load', () => {
+        if (xhr.status >= 200 && xhr.status < 300) {
+          handleComplete(uid, {
+            fileKey: uploadPolicy.key,
+            fileUrl: `${uploadPolicy.host}/${uploadPolicy.key}`
+          }, originFile);
+          onSuccess();
+        } else {
+          throw new Error(`上传失败,状态码: ${xhr.status}`);
+        }
+      });
+      
+      // 监听错误事件
+      xhr.addEventListener('error', () => {
+        throw new Error('网络错误,上传失败');
+      });
+      
+      // 监听中断事件
+      xhr.addEventListener('abort', () => {
+        throw new Error('上传已取消');
+      });
+      
+      // 发送请求
+      xhr.send(formData);
+      
+    } catch (error) {
+      handleError(uid, error instanceof Error ? error : new Error('未知错误'), originFile);
+      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>{Math.round(item.percent || 0)}%</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>
+            <Button
+              type="text"
+              size="small"
+              icon={<ReloadOutlined size={12} />}
+              onClick={() => handleRetry(item)}
+            />
+          </Space>
+        );
+      default:
+        return null;
+    }
+  };
+
+  return (
+    <div className="aliyun-oss-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 AliyunOSSUploader;