有点小九九
简单的事情认真做
import { useState } from "react";
import { useModel } from "@umijs/max";
import {
  getUploadFileId,
  uploadFileChunk,
  uploadFileFinish,
} from "@/services/ant-design-pro/material";
import { getVideoMD5 } from "@/utils/md5Utils";

// 默认配置
const defaultConfig = {
  chunkSize: 5 * 1024 * 1024, // 每个分片大小,默认 5MB
  retryLimit: 3,               // 每个分片失败后的重试次数
  autoUpload: false,            // 是否自动上传
  multiple: true,               // 是否多选
  concurrent: false,           // 是否开启多文件并发上传
  maxConcurrent: 3,            // 最大并发数
  extraParams: {},             // 额外参数(可扩展传给后端)
  allowedTypes: [],             // 允许上传的文件类型,例如 ['video/mp4', 'video/mov']
};

export default function MultiFileChunkUploader({ config = {} }) {
  const options = { ...defaultConfig, ...config };
  const [files, setFiles] = useState([]); // 保存多文件的状态

  const { initialState } = useModel("@@initialState");
  const { currentUser } = initialState || {};
  const advertiser_id = currentUser?.advertiser_id;
  const accessToken = localStorage.getItem("accessToken");
  const headers = {
    "Access-Token": accessToken,
    "Content-Type": "application/json",
  }
  /**
   * 🔥 第一步:请求后端获取  upload_id
   * 每个文件上传前都要先拿到 upload_id
   */
  const getFileId = async (file) => {
    const params = {
      advertiser_id,
      size: file.size,
      name: file.name,
      content_type: "video",
    };
    const res = await getUploadFileId(params, headers);
    if (res.code === 0) {
      return res.data; // { upload_id, ... }
    } else {
      console.error("获取文件ID失败", res.message);
      return null;
    }
  };

  /**
   * 🔥 第二步:上传单个分片
   * 支持重试机制,失败会重新请求,直到超过 retryLimit
   */
  const uploadChunk = async (file, fileRef, chunk, index, retries = 0) => {
    try {
      console.log(index * chunk.size, "index * chunk.size");

      const signature = await getVideoMD5(chunk);
      const params = {
        advertiser_id,
        upload_id: fileRef.upload_id,
        signature,
        start_offset: index * options.chunkSize, // 当前分片在文件中的偏移量
        file: chunk,
        ...options.extraParams,
      };
      let chunkHeaders = {}
      // ⚠️ 你需要替换这里:分片上传的接口调用
      Object.assign(chunkHeaders, headers, { "Content-Type": "multipart/form-data" })
      const res = await uploadFileChunk(params, chunkHeaders);
      console.log(res, "uploadFileChunk-res");

      // 🔥 后端返回 code 校验
      if (res.code !== 0) {
        // 如果重试次数未到上限,继续重试
        if (retries < options.retryLimit) {
          console.warn(
            `Chunk ${index} 返回 code=${res.code},重试中...(${retries + 1})`
          );
          return uploadChunk(file, fileRef, chunk, index, retries + 1);
        } else {
          // 超过重试次数,抛出错误
          throw new Error(`Chunk ${index} 上传失败,code=${res.code}, message=${res.message}`);
        }
      }
      return res.data; // ⚡ 后端需要返回 { start_offset, end_offset }
    } catch (err) {
      if (retries < options.retryLimit) {
        console.warn(`分片索引: ${index} 异常重试第几次: (${retries + 1})`);
        return uploadChunk(file, fileRef, chunk, index, retries + 1);
      } else {
        throw err;
      }
    }
  };

  /**
   * 🔥 第三步:通知后端文件已上传完成
   */
  const notifyComplete = async (fileRef) => {
    const params = {
      advertiser_id,
      upload_id: fileRef.upload_id,
    };
    await uploadFileFinish(params, headers);
  };

  /**
   * 🔥 主流程:上传单个文件
   */
  const handleFileUpload = async (fileIndex) => {
    const fileObj = files[fileIndex];
    const { file } = fileObj;

    // 修改文件状态为 uploading
    setFiles((prev) =>
      prev.map((f, i) =>
        i === fileIndex ? { ...f, status: "uploading", progress: 0 } : f
      )
    );

    // 先请求 upload_id
    const fileRef = await getFileId(file);
    if (!fileRef) {
      setFiles((prev) =>
        prev.map((f, i) =>
          i === fileIndex ? { ...f, status: "error" } : f
        )
      );
      return;
    }

    // 分片上传
    const chunkSize = options.chunkSize;
    const chunks = Math.ceil(file.size / chunkSize);
    let uploaded = 0;

    for (let i = 0; i < chunks; i++) {
      const start = i * chunkSize;
      const end = Math.min(file.size, start + chunkSize);
      const chunk = file.slice(start, end);

      // 上传分片
      const res = await uploadChunk(file, fileRef, chunk, i);
      uploaded++;
      const progress = Math.round((uploaded / chunks) * 100);

      // 更新文件进度
      setFiles((prev) =>
        prev.map((f, j) =>
          j === fileIndex ? { ...f, progress, status: "uploading" } : f
        )
      );

      // 判断是否所有分片已完成
      if (res?.start_offset === res?.end_offset) {
        await notifyComplete(fileRef);
        break;
      }
    }

    // 更新状态为 done
    setFiles((prev) =>
      prev.map((f, i) =>
        i === fileIndex ? { ...f, progress: 100, status: "done" } : f
      )
    );
  };

  /**
   * 🔥 上传所有文件
   * 根据 concurrent 决定是串行上传还是并发上传
   */
  const handleAllUpload = async () => {
    if (!options.concurrent) {
      // 串行上传
      for (let i = 0; i < files.length; i++) {
        await handleFileUpload(i);
      }
    } else {
      // 并发上传,限制最大同时上传数
      const queue = [...files.keys()]; // 文件索引队列
      let activeCount = 0;

      return new Promise((resolve) => {
        const next = async () => {
          if (queue.length === 0 && activeCount === 0) {
            resolve();
            return;
          }
          while (activeCount < options.maxConcurrent && queue.length > 0) {
            const fileIndex = queue.shift();
            activeCount++;
            handleFileUpload(fileIndex)
              .finally(() => {
                activeCount--;
                next();
              });
          }
        };
        next();
      });
    }
  };

  /**
   * 🔥 选择文件
   * 支持多选文件
   */
  const handleFileChange = (e) => {
    // const selectedFiles = Array.from(e.target.files).map((f) => ({
    //   file: f,
    //   progress: 0,
    //   status: "waiting", // waiting | uploading | done | error
    // }));
    const selectedFiles = Array.from(e.target.files)
      .filter(f => {
        // 如果 allowedTypes 配置了,过滤不允许的类型
        if (options.allowedTypes.length > 0 && !options.allowedTypes.includes(f.type)) {
          console.warn(`文件类型不允许: ${f.name}`);
          return false;
        }
        return true;
      })
      .map(f => ({
        file: f,
        progress: 0,
        status: "waiting",
      }));

    setFiles((prev) => [...prev, ...selectedFiles]);

    if (options.autoUpload) {
      setTimeout(() => handleAllUpload(), 0);
    }
  };

  // 限制文件选择对话框中可选文件类型
  const acceptAttr = options.allowedTypes.length > 0
    ? options.allowedTypes.join(",")
    : "*/*";

  return (
    <div className="p-4 border rounded-xl shadow-md w-[500px]">
      <input type="file" accept={acceptAttr} multiple={options.multiple} onChange={handleFileChange} />

      {/* 手动上传按钮 */}
      {!options.autoUpload && files.length > 0 && (
        <button
          onClick={handleAllUpload}
          className="mt-2 px-4 py-2 bg-blue-500 text-white rounded-lg"
        >
          {options.concurrent ? `并发上传(最多 ${options.maxConcurrent})` : "串行上传"}
        </button>
      )}

      {/* 文件列表 + 进度条 */}
      <div className="mt-4 space-y-3">
        {files.map((f, index) => (
          <div key={index} className="border rounded p-2 shadow-sm">
            <div className="flex justify-between text-sm">
              <span>{f.file.name}</span>
              <span>{f.progress}%</span>
            </div>
            <div className="w-full bg-gray-200 h-3 rounded mt-1">
              <div
                className="bg-green-500 h-3 rounded"
                style={{ width: `${f.progress}%` }}
              ></div>
            </div>
            {f.status === "done" && (
              <p className="text-xs text-green-600 mt-1">上传完成</p>
            )}
            {f.status === "error" && (
              <p className="text-xs text-red-600 mt-1">上传失败</p>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

 

posted on 2025-09-08 16:51  有点小九九  阅读(13)  评论(0)    收藏  举报