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>
);
}