Browse Source

✨ feat(file): 添加文件管理系统和MinIO上传功能

- 新增MinioUploader组件支持拖拽上传和进度显示
- 新增Files页面实现文件列表、搜索、编辑和删除功能
- 集成rc-upload库实现高级上传功能
- 新增MinIO工具类支持分段上传和进度回调
- 支持大文件分段上传和小文件直接上传
yourname 4 months ago
parent
commit
0a3d4cf5c1

+ 1 - 0
package.json

@@ -33,6 +33,7 @@
     "minio": "^8.0.5",
     "mysql2": "^3.14.1",
     "node-fetch": "^3.3.2",
+    "rc-upload": "^4.9.2",
     "react": "^19.1.0",
     "react-dom": "^19.1.0",
     "react-hook-form": "^7.57.0",

+ 3 - 0
pnpm-lock.yaml

@@ -83,6 +83,9 @@ importers:
       node-fetch:
         specifier: ^3.3.2
         version: 3.3.2
+      rc-upload:
+        specifier: ^4.9.2
+        version: 4.9.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       react:
         specifier: ^19.1.0
         version: 19.1.0

+ 260 - 0
src/client/admin/components/MinioUploader.tsx

@@ -0,0 +1,260 @@
+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 { RcFile } from 'rc-upload/lib/interface';
+import type { UploadFileStatus } from 'antd/es/upload/interface';
+import type { UploadRequestOption } from 'rc-upload/lib/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: UploadRequestOption) => {
+    const { file, onSuccess, onError } = options;
+    const rcFile = file as RcFile;
+    const uid = rcFile.uid;
+    
+    // 添加到文件列表
+    setFileList(prev => [
+      ...prev.filter(item => item.uid !== uid),
+      {
+        uid,
+        name: rcFile.name,
+        size: rcFile.size,
+        type: rcFile.type,
+        lastModified: rcFile.lastModified,
+        lastModifiedDate: new Date(rcFile.lastModified),
+        status: 'uploading' as UploadFileStatus,
+        percent: 0,
+      }
+    ]);
+    
+    // 添加到上传中集合
+    setUploadingFiles(prev => new Set(prev).add(uid));
+    
+    try {
+      // 调用minio上传方法
+      const result = await uploadMinIOWithPolicy(
+        uploadPath,
+        options.file as unknown as File,
+        rcFile.name,
+        {
+          onProgress: (event) => handleProgress(uid, event),
+          signal: new AbortController().signal
+        }
+      );
+      
+      handleComplete(uid, result, rcFile as unknown as File);
+      onSuccess?.({}, rcFile);
+    } catch (error) {
+      handleError(uid, error instanceof Error ? error : new Error('未知错误'), rcFile as unknown 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;

+ 348 - 0
src/client/admin/pages/Files.tsx

@@ -0,0 +1,348 @@
+import React, { useState, useEffect } from 'react';
+import { Table, Button, Space, Input, Modal, Form, Select, DatePicker, Upload, Popconfirm } from 'antd';
+import { App } from 'antd';
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, UploadOutlined } from '@ant-design/icons';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { fileClient, clientClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import dayjs from 'dayjs';
+import { uploadMinIOWithPolicy } from '@/client/utils/minio';
+
+// 定义类型
+type FileItem = InferResponseType<typeof fileClient.$get, 200>['data'][0];
+type FileListResponse = InferResponseType<typeof fileClient.$get, 200>;
+type ClientItem = InferResponseType<typeof clientClient.$get, 200>['data'][0];
+type UpdateFileRequest = InferRequestType<typeof fileClient[':id']['$put']>['json'];
+
+const Files: React.FC = () => {
+  const { message } = App.useApp();
+  const [form] = Form.useForm();
+  const [modalVisible, setModalVisible] = useState(false);
+  const [editingKey, setEditingKey] = useState<number | null>(null);
+  const [searchText, setSearchText] = useState('');
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  });
+
+  const queryClient = useQueryClient();
+  
+  
+  // 获取文件列表数据
+  const fetchFiles = async ({ page, pageSize }: { page: number; pageSize: number }): Promise<FileListResponse> => {
+    const response = await fileClient.$get({ query: { page, pageSize, keyword: searchText } });
+    if (!response.ok) throw new Error('Failed to fetch files');
+    return await response.json() as FileListResponse;
+  };
+  
+  const { data, isLoading: loading, error: filesError } = useQuery({
+    queryKey: ['files', pagination.current, pagination.pageSize, searchText],
+    queryFn: () => fetchFiles({ page: pagination.current, pageSize: pagination.pageSize }),
+  });
+
+  // 错误处理
+  if (filesError) {
+    message.error(`获取文件列表失败: ${filesError instanceof Error ? filesError.message : '未知错误'}`);
+  }
+  
+  // 从API响应获取分页数据
+  const tablePagination = data?.pagination || pagination;
+  
+  // 搜索
+  const handleSearch = () => {
+    setPagination({ ...pagination, current: 1 });
+  };
+  
+  // 分页变化
+  const handleTableChange = (newPagination: any) => {
+    setPagination(newPagination);
+  };
+  
+  // 显示编辑弹窗
+  const showModal = (record: FileItem) => {
+    setModalVisible(true);
+    setEditingKey(record.id);
+    form.setFieldsValue({
+      name: record.name,
+      description: record.description,
+      type: record.type,
+      size: record.size,
+    });
+  };
+
+  // 关闭弹窗
+  const handleCancel = () => {
+    setModalVisible(false);
+    form.resetFields();
+  };
+  
+  // 更新文件记录
+  const updateFile = useMutation({
+    mutationFn: ({ id, data }: { id: number; data: UpdateFileRequest }) =>
+      fileClient[':id'].$put({ param: { id }, json: data }),
+    onSuccess: () => {
+      message.success('文件记录更新成功');
+      queryClient.invalidateQueries({ queryKey: ['files'] });
+      setModalVisible(false);
+    },
+    onError: (error: Error) => {
+      message.error(`操作失败: ${error instanceof Error ? error.message : '未知错误'}`);
+    }
+  });
+  
+  // 删除文件记录
+  const deleteFile = useMutation({
+    mutationFn: (id: number) => fileClient[':id'].$delete({ param: { id } }),
+    onSuccess: () => {
+      message.success('文件记录删除成功');
+      queryClient.invalidateQueries({ queryKey: ['files'] });
+    },
+    onError: (error: Error) => {
+      message.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
+    }
+  });
+  
+  // 直接上传文件
+  const handleDirectUpload = async () => {
+    const input = document.createElement('input');
+    input.type = 'file';
+    input.multiple = false;
+    
+    input.onchange = async (e) => {
+      const file = (e.target as HTMLInputElement).files?.[0];
+      if (!file) return;
+      
+      try {
+        message.loading('正在上传文件...');
+        await uploadMinIOWithPolicy('/files', file, file.name);
+        message.success('文件上传成功');
+        queryClient.invalidateQueries({ queryKey: ['files'] });
+      } catch (error) {
+        message.error(`上传失败: ${error instanceof Error ? error.message : '未知错误'}`);
+      }
+    };
+    
+    input.click();
+  };
+  
+  // 提交表单(仅用于编辑已上传文件)
+  const handleSubmit = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      const payload = {
+        name: values.name,
+        description: values.description,
+      };
+      
+      if (editingKey) {
+        await updateFile.mutateAsync({ id: editingKey, data: payload });
+      }
+    } catch (error) {
+      message.error('表单验证失败,请检查输入');
+    }
+  };
+  
+  // 表格列定义
+  const columns = [
+    {
+      title: '文件ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: 80,
+      align: 'center' as const,
+    },
+    {
+      title: '文件名称',
+      dataIndex: 'name',
+      key: 'name',
+      width: 300,
+      ellipsis: true,
+    },
+    {
+      title: '文件类型',
+      dataIndex: 'type',
+      key: 'type',
+      width: 120,
+      render: (type: string) => (
+        <span className="inline-block px-2 py-1 text-xs bg-blue-50 text-blue-700 rounded-full">
+          {type}
+        </span>
+      ),
+    },
+    {
+      title: '文件大小',
+      dataIndex: 'size',
+      key: 'size',
+      width: 120,
+      render: (size: number) => (
+        <span className="text-sm">
+          {size ? `${(size / 1024).toFixed(2)} KB` : '-'}
+        </span>
+      ),
+    },
+    {
+      title: '上传时间',
+      dataIndex: 'uploadTime',
+      key: 'uploadTime',
+      width: 180,
+      render: (time: string) => (
+        <span className="text-sm text-gray-600">
+          {time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'}
+        </span>
+      ),
+    },
+    {
+      title: '上传用户',
+      dataIndex: 'uploadUser',
+      key: 'uploadUser',
+      width: 120,
+      render: (uploadUser?: { username: string; nickname?: string }) => (
+        <span className="text-sm">
+          {uploadUser ? (uploadUser.nickname || uploadUser.username) : '-'}
+        </span>
+      ),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 120,
+      fixed: 'right' as const,
+      render: (_: any, record: FileItem) => (
+        <Space size="small">
+          <Button
+            type="text"
+            icon={<EditOutlined />}
+            onClick={() => showModal(record)}
+            className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
+          >
+            编辑
+          </Button>
+          <Popconfirm
+            title="确认删除"
+            description={`确定要删除文件"${record.name}"吗?此操作不可恢复。`}
+            onConfirm={() => deleteFile.mutate(record.id)}
+            okText="确认"
+            cancelText="取消"
+            okButtonProps={{ danger: true }}
+          >
+            <Button
+              type="text"
+              danger
+              icon={<DeleteOutlined />}
+              className="hover:bg-red-50"
+            >
+              删除
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+  
+  return (
+    <div className="p-6">
+      <div className="mb-6 flex justify-between items-center">
+        <h2 className="text-2xl font-bold text-gray-900">文件管理</h2>
+        <Button
+          type="primary"
+          icon={<UploadOutlined />}
+          onClick={handleDirectUpload}
+          className="h-10 flex items-center"
+        >
+          上传文件
+        </Button>
+      </div>
+      
+      <div className="mb-6">
+        <div className="flex items-center gap-4">
+          <Input
+            placeholder="搜索文件名称或类型"
+            prefix={<SearchOutlined />}
+            value={searchText}
+            onChange={(e) => setSearchText(e.target.value)}
+            onPressEnter={handleSearch}
+            className="w-80 h-10"
+            allowClear
+          />
+          <Button
+            type="default"
+            onClick={handleSearch}
+            className="h-10"
+          >
+            搜索
+          </Button>
+        </div>
+      </div>
+      
+      <div className="bg-white rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
+        <Table
+          columns={columns}
+          dataSource={data?.data || []}
+          rowKey="id"
+          loading={loading}
+          pagination={{
+            ...tablePagination,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            showTotal: (total, range) =>
+              `显示 ${range[0]}-${range[1]} 条,共 ${total} 条`,
+          }}
+          onChange={handleTableChange}
+          bordered={false}
+          scroll={{ x: 'max-content' }}
+          className="[&_.ant-table]:!rounded-lg [&_.ant-table-thead>tr>th]:!bg-gray-50 [&_.ant-table-thead>tr>th]:!font-semibold [&_.ant-table-thead>tr>th]:!text-gray-700 [&_.ant-table-thead>tr>th]:!border-b-2 [&_.ant-table-thead>tr>th]:!border-gray-200"
+          rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+        />
+      </div>
+      
+      <Modal
+        title="编辑文件信息"
+        open={modalVisible}
+        onCancel={handleCancel}
+        footer={[
+          <Button key="cancel" onClick={handleCancel}>
+            取消
+          </Button>,
+          <Button
+            key="submit"
+            type="primary"
+            onClick={handleSubmit}
+            loading={updateFile.isPending}
+          >
+            确定
+          </Button>,
+        ]}
+        width={600}
+        centered
+        destroyOnClose
+        maskClosable={false}
+      >
+        <Form form={form} layout="vertical">
+          <Form.Item name="name" label="文件名称">
+            <Input className="h-10" />
+          </Form.Item>
+          
+          <Form.Item name="description" label="文件描述">
+            <Input.TextArea
+              rows={4}
+              placeholder="请输入文件描述"
+              className="rounded-md"
+            />
+          </Form.Item>
+          
+          <Form.Item name="type" label="文件类型" hidden>
+            <Input />
+          </Form.Item>
+          
+          <Form.Item name="size" label="文件大小" hidden>
+            <Input />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};
+
+export default Files;

+ 376 - 0
src/client/utils/minio.ts

@@ -0,0 +1,376 @@
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { fileClient } from "../api";
+
+export interface MinioProgressEvent {
+  stage: 'uploading' | 'complete' | 'error';
+  message: string;
+  progress: number;
+  details?: {
+      loaded: number;
+      total: number;
+  };
+  timestamp: number;
+}
+
+export interface MinioProgressCallbacks {
+  onProgress?: (event: MinioProgressEvent) => void;
+  onComplete?: () => void;
+  onError?: (error: Error) => void;
+  signal?: AbortSignal;
+}
+
+export interface UploadResult {
+  fileUrl:string;
+  fileKey:string;
+  bucketName:string;
+}
+
+interface UploadPart {
+  ETag: string;
+  PartNumber: number;
+}
+
+interface UploadProgressDetails {
+  partNumber: number;
+  totalParts: number;
+  partSize: number;
+  totalSize: number;
+  partProgress?: number;
+}
+
+type MinioMultipartUploadPolicy = InferResponseType<typeof fileClient["multipart-policy"]['$post'],200>
+type MinioUploadPolicy = InferResponseType<typeof fileClient["upload-policy"]['$post'],200>
+
+
+const PART_SIZE = 5 * 1024 * 1024; // 每部分5MB
+
+
+export class MinIOXHRMultipartUploader {
+  /**
+   * 使用XHR分段上传文件到MinIO
+   */
+  static async upload(
+    policy: MinioMultipartUploadPolicy,
+    file: File | Blob,
+    key: string,
+    callbacks?: MinioProgressCallbacks
+  ): Promise<UploadResult> {
+    const partSize = PART_SIZE;
+    const totalSize = file.size;
+    const totalParts = Math.ceil(totalSize / partSize);
+    const uploadedParts: UploadPart[] = [];
+    
+    callbacks?.onProgress?.({
+      stage: 'uploading',
+      message: '准备上传文件...',
+      progress: 0,
+      details: {
+        loaded: 0,
+        total: totalSize
+      },
+      timestamp: Date.now()
+    });
+    
+    // 分段上传
+    for (let i = 0; i < totalParts; i++) {
+      const start = i * partSize;
+      const end = Math.min(start + partSize, totalSize);
+      const partBlob = file.slice(start, end);
+      const partNumber = i + 1;
+      
+      try {
+        const etag = await this.uploadPart(
+          policy.partUrls[i],
+          partBlob,
+          callbacks,
+          {
+            partNumber,
+            totalParts,
+            partSize: partBlob.size,
+            totalSize
+          }
+        );
+        
+        uploadedParts.push({
+          ETag: etag,
+          PartNumber: partNumber
+        });
+        
+        // 更新进度
+        const progress = Math.round((end / totalSize) * 100);
+        callbacks?.onProgress?.({
+          stage: 'uploading',
+          message: `上传文件片段 ${partNumber}/${totalParts}`,
+          progress,
+          details: {
+            loaded: end,
+            total: totalSize,
+          },
+          timestamp: Date.now()
+        });
+      } catch (error) {
+        callbacks?.onError?.(error instanceof Error ? error : new Error(String(error)));
+        throw error;
+      }
+    }
+    
+    // 完成上传
+    try {
+      await this.completeMultipartUpload(policy, key, uploadedParts);
+      
+      callbacks?.onProgress?.({
+        stage: 'complete',
+        message: '文件上传完成',
+        progress: 100,
+        timestamp: Date.now()
+      });
+      
+      callbacks?.onComplete?.();
+      return {
+        fileUrl: `${policy.host}/${key}`,
+        fileKey: key,
+        bucketName: policy.bucket
+      };
+    } catch (error) {
+      callbacks?.onError?.(error instanceof Error ? error : new Error(String(error)));
+      throw error;
+    }
+  }
+  
+  // 上传单个片段
+  private static uploadPart(
+    uploadUrl: string,
+    partBlob: Blob,
+    callbacks?: MinioProgressCallbacks,
+    progressDetails?: UploadProgressDetails
+  ): Promise<string> {
+    return new Promise((resolve, reject) => {
+      const xhr = new XMLHttpRequest();
+      
+      xhr.upload.onprogress = (event) => {
+        if (event.lengthComputable && callbacks?.onProgress) {
+          const partProgress = Math.round((event.loaded / event.total) * 100);
+          callbacks.onProgress({
+            stage: 'uploading',
+            message: `上传文件片段 ${progressDetails?.partNumber}/${progressDetails?.totalParts} (${partProgress}%)`,
+            progress: Math.round((
+              (progressDetails?.partNumber ? (progressDetails.partNumber - 1) * (progressDetails.partSize || 0) : 0) + event.loaded
+            ) / (progressDetails?.totalSize || 1) * 100),
+            details: {
+              ...progressDetails,
+              loaded: event.loaded,
+              total: event.total
+            },
+            timestamp: Date.now()
+          });
+        }
+      };
+      
+      xhr.onload = () => {
+        if (xhr.status >= 200 && xhr.status < 300) {
+          // 获取ETag(MinIO返回的标识)
+          const etag = xhr.getResponseHeader('ETag')?.replace(/"/g, '') || '';
+          resolve(etag);
+        } else {
+          reject(new Error(`上传片段失败: ${xhr.status} ${xhr.statusText}`));
+        }
+      };
+      
+      xhr.onerror = () => reject(new Error('上传片段失败'));
+      
+      xhr.open('PUT', uploadUrl);
+      xhr.send(partBlob);
+      
+      if (callbacks?.signal) {
+        callbacks.signal.addEventListener('abort', () => {
+          xhr.abort();
+          reject(new Error('上传已取消'));
+        });
+      }
+    });
+  }
+  
+  // 完成分段上传
+  private static async completeMultipartUpload(
+    policy: MinioMultipartUploadPolicy,
+    key: string,
+    uploadedParts: UploadPart[]
+  ): Promise<void> {
+    const response = await fileClient["multipart-complete"].$post({
+        json:{
+            bucket: policy.bucket,
+            key,
+            uploadId: policy.uploadId,
+            parts: uploadedParts.map(part => ({ partNumber: part.PartNumber, etag: part.ETag }))
+        }
+    });
+    
+    if (!response.ok) {
+      throw new Error(`完成分段上传失败: ${response.status} ${response.statusText}`);
+    }
+  }
+}
+
+export class MinIOXHRUploader {
+    /**
+     * 使用XHR上传文件到MinIO
+     */
+    static upload(
+        policy: MinioUploadPolicy,
+        file: File | Blob,
+        key: string,
+        callbacks?: MinioProgressCallbacks
+    ): Promise<UploadResult> {
+        const formData = new FormData();
+
+        // 添加 MinIO 需要的字段
+        Object.entries(policy.uploadPolicy).forEach(([k, value]) => {
+            // 排除 policy 中的 key、host、prefix、ossType 字段
+            if (k !== 'key' && k !== 'host' && k !== 'prefix' && k !== 'ossType' && typeof value === 'string') {
+                formData.append(k, value);
+            }
+        });
+        // 添加 自定义 key 字段
+        formData.append('key', key);
+        formData.append('file', file);
+
+        return new Promise((resolve, reject) => {
+            const xhr = new XMLHttpRequest();
+
+            // 上传进度处理
+            if (callbacks?.onProgress) {
+                xhr.upload.onprogress = (event) => {
+                    if (event.lengthComputable) {
+                        callbacks.onProgress?.({
+                            stage: 'uploading',
+                            message: '正在上传文件...',
+                            progress: Math.round((event.loaded * 100) / event.total),
+                            details: {
+                                loaded: event.loaded,
+                                total: event.total
+                            },
+                            timestamp: Date.now()
+                        });
+                    }
+                };
+            }
+
+            // 完成处理
+            xhr.onload = () => {
+                if (xhr.status >= 200 && xhr.status < 300) {
+                    if (callbacks?.onProgress) {
+                        callbacks.onProgress({
+                            stage: 'complete',
+                            message: '文件上传完成',
+                            progress: 100,
+                            timestamp: Date.now()
+                        });
+                    }
+                    callbacks?.onComplete?.();
+                    resolve({
+                        fileUrl:`${policy.uploadPolicy.host}/${key}`,
+                        fileKey: key,
+                        bucketName: policy.uploadPolicy.bucket
+                    });
+                } else {
+                    const error = new Error(`上传失败: ${xhr.status} ${xhr.statusText}`);
+                    callbacks?.onError?.(error);
+                    reject(error);
+                }
+            };
+
+            // 错误处理
+            xhr.onerror = () => {
+                const error = new Error('上传失败');
+                if (callbacks?.onProgress) {
+                    callbacks.onProgress({
+                        stage: 'error',
+                        message: '文件上传失败',
+                        progress: 0,
+                        timestamp: Date.now()
+                    });
+                }
+                callbacks?.onError?.(error);
+                reject(error);
+            };
+
+            // 根据当前页面协议和 host 配置决定最终的上传地址
+            const currentProtocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
+            const host = policy.uploadPolicy.host?.startsWith('http')
+                ? policy.uploadPolicy.host
+                : `${currentProtocol}//${policy.uploadPolicy.host}`;
+            // 开始上传
+            xhr.open('POST', host);
+            xhr.send(formData);
+
+            // 处理取消
+            if (callbacks?.signal) {
+                callbacks.signal.addEventListener('abort', () => {
+                    xhr.abort();
+                    reject(new Error('上传已取消'));
+                });
+            }
+        });
+    }
+} 
+
+export async function getUploadPolicy(key: string, fileName: string, fileType?: string, fileSize?: number): Promise<MinioUploadPolicy> {
+  const policyResponse = await fileClient["upload-policy"].$post({
+    json: {
+      path: key,
+      name: fileName,
+      type: fileType,
+      size: fileSize
+    }
+  });
+  if (!policyResponse.ok) {
+    throw new Error('获取上传策略失败');
+  }
+  return policyResponse.json();
+}
+
+export async function getMultipartUploadPolicy(totalSize: number, fileKey: string, fileType?: string) {
+  const policyResponse = await fileClient["multipart-policy"].$post({
+    json: {
+      totalSize,
+      partSize: PART_SIZE,
+      fileKey,
+      type: fileType
+    }
+  });
+  if (!policyResponse.ok) {
+    throw new Error('获取分段上传策略失败');
+  }
+  return await policyResponse.json();
+}
+
+export async function uploadMinIOWithPolicy(
+  uploadPath: string,
+  file: File | Blob,
+  fileKey: string,
+  callbacks?: MinioProgressCallbacks
+): Promise<UploadResult> {
+  if(uploadPath === '/') uploadPath = '';
+  else{
+    if(!uploadPath.endsWith('/')) uploadPath = `${uploadPath}/`
+    // 去掉开头的 /
+    if(uploadPath.startsWith('/')) uploadPath = uploadPath.replace(/^\//, '');
+  }
+  
+  
+  if( file.size > PART_SIZE ){
+    const policy = await getMultipartUploadPolicy(file.size, `${uploadPath}${fileKey}`, file instanceof File ? file.type : undefined)
+    return MinIOXHRMultipartUploader.upload(
+      policy,
+      file,
+      policy.key,
+      callbacks
+    );
+  }else{
+    if (!(file instanceof File)) {
+      throw new Error('不支持的文件类型,无法获取文件名');
+    }
+    const policy = await getUploadPolicy(`${uploadPath}${fileKey}`, file.name, file.type, file.size);
+    return MinIOXHRUploader.upload(policy, file, policy.uploadPolicy.key, callbacks);
+  }
+}