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, // 每个分片大小,默认 5MBretryLimit: 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;// 修改文件状态为 uploadingsetFiles((prev) =>prev.map((f, i) =>i === fileIndex ? { ...f, status: "uploading", progress: 0 } : f));// 先请求 upload_idconst 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;}}// 更新状态为 donesetFiles((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 && (<buttononClick={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"><divclassName="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> ); }