分片上传组件
This commit is contained in:
parent
deed967a44
commit
876b4f8076
|
|
@ -83,6 +83,7 @@
|
|||
"numeral": "^2.0.6",
|
||||
"nvm": "0.0.4",
|
||||
"object-assign": "4.1.1",
|
||||
"p-queue": "^9.2.0",
|
||||
"papaparse": "^5.3.2",
|
||||
"pinyin": "^4.0.0-alpha.0",
|
||||
"postcss-flexbugs-fixes": "3.2.0",
|
||||
|
|
@ -138,6 +139,7 @@
|
|||
"showdown": "^1.9.1",
|
||||
"showdown-katex": "^0.8.0",
|
||||
"slick-carousel": "^1.8.1",
|
||||
"spark-md5": "^3.0.2",
|
||||
"store": "^2.0.12",
|
||||
"styled-components": "^4.4.1",
|
||||
"webpack-node-externals": "^3.0.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,848 @@
|
|||
import React, { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react';
|
||||
import { Button, Progress, message, Spin } from 'antd';
|
||||
import SparkMD5 from 'spark-md5';
|
||||
import './index.scss';
|
||||
|
||||
/**
|
||||
* 分片上传组件
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Function} props.onUploadComplete - 上传完成回调 (fileInfo) => void
|
||||
* @param {Function} props.onUploadProgress - 上传进度回调 (progress, uploaded, total) => void
|
||||
* @param {Function} props.onUploadError - 上传错误回调 (error) => void
|
||||
* @param {Function} props.onCancel - 取消上传回调 () => void
|
||||
* @param {string} props.fileType - 文件类型 (cms/dms/resource/pms等)
|
||||
* @param {string} props.hierarchy - 层级结构
|
||||
* @param {number} props.chunkSize - 分片大小(字节)
|
||||
* @param {boolean} props.disabled - 是否禁用
|
||||
* @param {string} props.accept - 接受的文件类型
|
||||
* @param {number} props.maxSize - 最大文件大小(字节)
|
||||
* @param {Object} props.defaultFile - 默认文件信息(已上传的文件),用于回显
|
||||
* - {string} defaultFile.fileId - 文件ID
|
||||
* - {string} defaultFile.fileOriginName - 文件名
|
||||
* - {number} defaultFile.fileSize - 文件大小(字节)
|
||||
* - {string} defaultFile.url - 文件下载地址
|
||||
*/
|
||||
|
||||
/**
|
||||
* 暴露给父组件的方法(通过 ref)
|
||||
*
|
||||
* @method cancelUpload - 移除上传的文件(重置为待上传状态)
|
||||
*/
|
||||
const ChunkedUpload = forwardRef(({
|
||||
onUploadComplete,
|
||||
onUploadProgress,
|
||||
onUploadError,
|
||||
onCancel,
|
||||
fileType = 'resource',
|
||||
hierarchy = '',
|
||||
chunkSize = 10 * 1024 * 1024, // 默认 10MB
|
||||
disabled = false,
|
||||
accept = '*',
|
||||
maxSize = 1024 * 1024 * 1024, // 默认 1GB
|
||||
children, // 支持自定义未选择文件状态的内容
|
||||
defaultFile, // 默认文件信息(已上传的文件),用于回显
|
||||
}, ref) => {
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
/**
|
||||
* 移除上传的文件(重置为待上传状态)
|
||||
*/
|
||||
cancelUpload: () => {
|
||||
handleCancel();
|
||||
},
|
||||
}));
|
||||
const DEFAULT_SIZE = 10 * 1024 * 1024; // 10MB,与 ruoyi-react 保持一致
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [file, setFile] = useState(null);
|
||||
const [uploadStatus, setUploadStatus] = useState('idle'); // idle, checking, initializing, uploading, merging, completed, error, cancelled, instant
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [uploadedChunks, setUploadedChunks] = useState(0);
|
||||
const [totalChunks, setTotalChunks] = useState(0);
|
||||
const [uploadId, setUploadId] = useState(null);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [uploadSpeed, setUploadSpeed] = useState(0);
|
||||
const [instantFileInfo, setInstantFileInfo] = useState(null); // 秒传成功时的文件信息
|
||||
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// 初始化默认文件(已上传的文件回显)
|
||||
useEffect(() => {
|
||||
if (defaultFile && Object.keys(defaultFile).length > 0) {
|
||||
// 设置已完成状态
|
||||
const fileInfo = {
|
||||
name: defaultFile.fileOriginName || 'unknown',
|
||||
size: defaultFile.fileSize || 0,
|
||||
};
|
||||
setFile(fileInfo);
|
||||
setUploadStatus('completed');
|
||||
setProgress(100);
|
||||
setUploadedChunks(1);
|
||||
setTotalChunks(1);
|
||||
}
|
||||
}, [defaultFile]);
|
||||
|
||||
const uploadRef = useRef({
|
||||
isCancelled: false,
|
||||
uploadId: null,
|
||||
abortController: null,
|
||||
currentFile: null,
|
||||
fileSignature: null, // 文件签名
|
||||
});
|
||||
|
||||
// 清理上传状态
|
||||
const cleanupUpload = useCallback(() => {
|
||||
uploadRef.current.isCancelled = true;
|
||||
|
||||
// 取消进行中的请求
|
||||
if (uploadRef.current.abortController) {
|
||||
uploadRef.current.abortController.abort();
|
||||
}
|
||||
|
||||
setUploadStatus('cancelled');
|
||||
setUploading(false);
|
||||
}, []);
|
||||
|
||||
// 移除上传(仅前端移除,重置为待上传样式)
|
||||
const handleCancel = useCallback(() => {
|
||||
uploadRef.current.isCancelled = true;
|
||||
|
||||
// 取消进行中的请求
|
||||
if (uploadRef.current.abortController) {
|
||||
uploadRef.current.abortController.abort();
|
||||
}
|
||||
|
||||
setUploadStatus('idle');
|
||||
setUploading(false);
|
||||
setUploadId(null);
|
||||
setFile(null);
|
||||
setProgress(0);
|
||||
setUploadedChunks(0);
|
||||
setTotalChunks(0);
|
||||
setErrorMessage('');
|
||||
onCancel?.();
|
||||
}, [onCancel]);
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = useCallback((bytes) => {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
if(typeof bytes === "string") return bytes
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}, []);
|
||||
|
||||
// 格式化速度
|
||||
const formatSpeed = useCallback((bytesPerSecond) => {
|
||||
return formatFileSize(bytesPerSecond) + '/s';
|
||||
}, [formatFileSize]);
|
||||
|
||||
// 更新进度
|
||||
const updateProgress = useCallback((uploaded, total, fileSize, elapsedMs) => {
|
||||
const percent = Math.round((uploaded / total) * 100);
|
||||
setProgress(percent);
|
||||
setUploadedChunks(uploaded);
|
||||
setTotalChunks(total);
|
||||
|
||||
// 计算速度
|
||||
const speed = elapsedMs > 0 ? Math.round((uploaded * chunkSize) / (elapsedMs / 1000)) : 0;
|
||||
setUploadSpeed(speed);
|
||||
|
||||
onUploadProgress?.(percent, uploaded, total);
|
||||
}, [chunkSize, onUploadProgress]);
|
||||
|
||||
// 上传单个分片(带重试)
|
||||
const uploadSingleChunk = useCallback(async (chunkNumber, chunkFile, onChunkProgress) => {
|
||||
const { uploadChunk: uploadChunkApi } = await import('../../factory/api');
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
uploadRef.current.abortController = abortController;
|
||||
|
||||
await uploadChunkApi(
|
||||
{
|
||||
uploadId: uploadRef.current.uploadId,
|
||||
chunkNumber,
|
||||
chunk: chunkFile,
|
||||
},
|
||||
onChunkProgress
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return false;
|
||||
}
|
||||
|
||||
retryCount++;
|
||||
if (retryCount >= maxRetries) {
|
||||
console.error(`分片 ${chunkNumber} 上传失败,已达最大重试次数:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 等待后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// 并发上传分片
|
||||
const uploadChunksConcurrently = useCallback(async (chunks, fileToUpload) => {
|
||||
const uploaded = new Set();
|
||||
const chunksToUpload = chunks.filter(c => !uploaded.has(c.index));
|
||||
|
||||
if (chunksToUpload.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let uploadedCount = 0;
|
||||
const maxConcurrent = 4;
|
||||
|
||||
// 并发控制
|
||||
const processChunk = async (chunk) => {
|
||||
if (uploadRef.current.isCancelled) return;
|
||||
|
||||
try {
|
||||
const start = chunk.start;
|
||||
const end = chunk.end;
|
||||
const blob = fileToUpload.slice(start, end);
|
||||
// 将 Blob 包装为 File 对象,与 ruoyi-react 保持一致
|
||||
const chunkFile = new File([blob], `chunk-${chunk.index}-${Date.now()}`);
|
||||
|
||||
await uploadSingleChunk(chunk.index, chunkFile, (loaded, total) => {
|
||||
// 分片级别进度
|
||||
});
|
||||
|
||||
uploaded.add(chunk.index);
|
||||
uploadedCount++;
|
||||
|
||||
updateProgress(uploadedCount, chunks.length, fileToUpload.size, Date.now() - startTime);
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
setErrorMessage(`分片 ${chunk.index} 上传失败: ${error.message}`);
|
||||
setUploadStatus('error');
|
||||
onUploadError?.(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 分批并发上传
|
||||
for (let i = 0; i < chunksToUpload.length; i += maxConcurrent) {
|
||||
if (uploadRef.current.isCancelled) break;
|
||||
|
||||
const batch = chunksToUpload.slice(i, i + maxConcurrent);
|
||||
await Promise.all(batch.map(processChunk));
|
||||
}
|
||||
}, [uploadSingleChunk, updateProgress, onUploadError]);
|
||||
|
||||
// 初始化上传(参照 ruoyi-react 的流程)
|
||||
const initUpload = useCallback(async (fileToUpload) => {
|
||||
setUploadStatus('initializing');
|
||||
|
||||
try {
|
||||
const { initChunkedUpload, checkInstantUpload } = await import('../../factory/api');
|
||||
|
||||
// 1. 初始化分片上传
|
||||
const initResponse = await initChunkedUpload({
|
||||
fileName: fileToUpload.name,
|
||||
fileSize: fileToUpload.size,
|
||||
type: fileType,
|
||||
hierarchy: hierarchy || undefined,
|
||||
});
|
||||
|
||||
const { data: initData } = initResponse.data || initResponse;
|
||||
const { uploadId: newUploadId, totalChunks, chunkSize: serverChunkSize, hashType, sampleChunks } = initData;
|
||||
|
||||
uploadRef.current.uploadId = newUploadId;
|
||||
setUploadId(newUploadId);
|
||||
|
||||
// 2. 根据 hashType 和 sampleChunks 计算文件签名(参照 ruoyi-react)
|
||||
setUploadStatus('calculating');
|
||||
const fileSignature = await computeFileSignature(fileToUpload, hashType, sampleChunks, serverChunkSize || chunkSize);
|
||||
uploadRef.current.fileSignature = fileSignature;
|
||||
|
||||
// 3. 检查秒传/断点续传(参照 ruoyi-react 的参数格式)
|
||||
setUploadStatus('checking');
|
||||
const checkResponse = await checkInstantUpload({
|
||||
uploadId: String(newUploadId),
|
||||
fileSignature: fileSignature,
|
||||
});
|
||||
|
||||
const { data: checkData } = checkResponse.data || checkResponse;
|
||||
const { canInstantUpload, canResumingUpload, completedUploadChunkedNumberList, existingFileId, existingFileName, existingFileSize, existingFileUrl } = checkData || {};
|
||||
|
||||
// 秒传成功(参照 ruoyi-react 的实现)
|
||||
if (canInstantUpload) {
|
||||
setUploadStatus('instant');
|
||||
const fileInfo = {
|
||||
fileId: existingFileId,
|
||||
fileOriginName: existingFileName,
|
||||
fileSize: existingFileSize,
|
||||
url: existingFileUrl,
|
||||
};
|
||||
setInstantFileInfo(fileInfo);
|
||||
message.success('秒传成功,文件已存在');
|
||||
onUploadComplete?.(fileInfo);
|
||||
setUploading(false); // 重置 uploading 状态,隐藏 Spin
|
||||
return null; // 返回 null 表示不需要上传
|
||||
}
|
||||
|
||||
// 断点续传(参照 ruoyi-react 的实现)
|
||||
let uploadedChunksSet = new Set();
|
||||
if (canResumingUpload && completedUploadChunkedNumberList && completedUploadChunkedNumberList.length > 0) {
|
||||
const resumingUploadId = checkData.resumingUploadId || newUploadId;
|
||||
uploadRef.current.uploadId = resumingUploadId;
|
||||
setUploadId(resumingUploadId);
|
||||
|
||||
// 处理会话状态为合并中的情况(参照 ruoyi-react)
|
||||
if (checkData.resumingSessionStatus === 'MERGING' || checkData.resumingSessionStatus === '合并中') {
|
||||
// message.info('文件正在合并中,请稍候...');
|
||||
// 直接轮询进度
|
||||
const mergeResult = await pollMergeStatus(newUploadId, (mergeProgress) => {
|
||||
setProgress(mergeProgress);
|
||||
});
|
||||
setUploadStatus('completed');
|
||||
setProgress(100);
|
||||
setUploading(false);
|
||||
message.success('文件上传完成');
|
||||
|
||||
// 确保传递 fileId,从轮询结果中获取
|
||||
onUploadComplete?.({
|
||||
fileId: mergeResult?.fileId || mergeResult?.data?.fileId,
|
||||
fileOriginName: mergeResult?.fileOriginName || mergeResult?.data?.fileOriginName,
|
||||
fileSize: mergeResult?.fileSize || mergeResult?.data?.fileSize,
|
||||
url: mergeResult?.url || mergeResult?.data?.url,
|
||||
uploadId: newUploadId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将已上传的分片索引存入 Set,方便后续过滤
|
||||
uploadedChunksSet = new Set(completedUploadChunkedNumberList);
|
||||
const uploadedCount = uploadedChunksSet.size;
|
||||
setUploadedChunks(uploadedCount);
|
||||
setTotalChunks(totalChunks);
|
||||
|
||||
// message.info(`断点续传,已上传 ${uploadedCount} 个分片,将从第 ${uploadedCount + 1} 个分片继续上传`);
|
||||
|
||||
// 如果所有分片都已上传,直接合并
|
||||
if (uploadedCount >= totalChunks) {
|
||||
const mergeResult = await mergeUpload();
|
||||
setUploadStatus('completed');
|
||||
setProgress(100);
|
||||
setUploading(false);
|
||||
message.success('上传完成');
|
||||
// 确保传递 fileId,从合并结果中获取
|
||||
onUploadComplete?.({
|
||||
fileId: mergeResult?.fileId || mergeResult?.data?.fileId,
|
||||
fileOriginName: mergeResult?.fileOriginName || mergeResult?.data?.fileOriginName,
|
||||
fileSize: mergeResult?.fileSize || mergeResult?.data?.fileSize,
|
||||
url: mergeResult?.url || mergeResult?.data?.url,
|
||||
uploadId: uploadRef.current.uploadId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
setTotalChunks(totalChunks);
|
||||
|
||||
// 如果服务端返回的分片大小不同,重新计算分片
|
||||
const finalChunkSize = serverChunkSize || chunkSize;
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
// 跳过已上传的分片
|
||||
if (uploadedChunksSet.has(i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.push({
|
||||
index: i, // 分片从 0 开始
|
||||
start: i * finalChunkSize,
|
||||
end: Math.min((i + 1) * finalChunkSize, fileToUpload.size),
|
||||
size: Math.min(fileToUpload.size - (i * finalChunkSize), finalChunkSize),
|
||||
});
|
||||
}
|
||||
|
||||
// message.info(`实际需要上传 ${chunks.length} 个分片`);
|
||||
|
||||
return chunks;
|
||||
} catch (error) {
|
||||
setErrorMessage(`初始化上传失败: ${error.message}`);
|
||||
setUploadStatus('error');
|
||||
onUploadError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fileType, hierarchy, chunkSize, onUploadComplete, onUploadError]);
|
||||
|
||||
// 轮询检查合并状态(递归方式)
|
||||
const pollMergeStatus = useCallback(async (uploadId, onMergeProgress, maxRetries = 60, interval = 3000) => {
|
||||
const { getProgress: getProgressApi } = await import('../../factory/api');
|
||||
|
||||
let retryCount = 0;
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const response = await getProgressApi(uploadId);
|
||||
const { data: progressData } = response.data || response;
|
||||
|
||||
// 检查合并状态
|
||||
if (progressData.status === 'COMPLETED' || progressData.status === 'SUCCESS') {
|
||||
// 合并成功
|
||||
onMergeProgress?.(100);
|
||||
return progressData;
|
||||
} else if (progressData.status === 'FAILED' || progressData.status === 'ERROR') {
|
||||
// 合并失败
|
||||
throw new Error(progressData.message || '合并失败');
|
||||
}
|
||||
|
||||
// 继续轮询(合并中或其他中间状态)
|
||||
retryCount++;
|
||||
if (retryCount >= maxRetries) {
|
||||
throw new Error('合并超时,请稍后查看结果');
|
||||
}
|
||||
|
||||
// 等待指定间隔后继续轮询
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
return await checkStatus();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 开始轮询
|
||||
return checkStatus();
|
||||
}, []);
|
||||
|
||||
// 合并分片
|
||||
const mergeUpload = useCallback(async () => {
|
||||
setUploadStatus('merging');
|
||||
|
||||
try {
|
||||
const { mergeChunks: mergeChunksApi } = await import('../../factory/api');
|
||||
|
||||
// 1. 提交合并任务
|
||||
const response = await mergeChunksApi(uploadRef.current.uploadId);
|
||||
const { data: mergeData } = response.data || response;
|
||||
|
||||
// 如果返回的消息提示需要轮询进度
|
||||
if (mergeData?.message && mergeData.message.includes('请通过进度接口查询结果')) {
|
||||
// message.info('合并任务已提交,正在处理中...');
|
||||
|
||||
// 2. 轮询检查合并状态
|
||||
const result = await pollMergeStatus(uploadRef.current.uploadId, (mergeProgress) => {
|
||||
setProgress(mergeProgress);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果直接返回成功(不需要轮询)
|
||||
return mergeData;
|
||||
} catch (error) {
|
||||
setErrorMessage(`合并分片失败: ${error.message}`);
|
||||
setUploadStatus('error');
|
||||
onUploadError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [onUploadError, pollMergeStatus]);
|
||||
|
||||
// 主上传流程
|
||||
const startUpload = useCallback(async (selectedFile) => {
|
||||
if (disabled) return;
|
||||
|
||||
// 验证文件大小
|
||||
if (selectedFile.size > maxSize) {
|
||||
message.error(`文件大小不能超过 ${formatFileSize(maxSize)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
uploadRef.current.currentFile = selectedFile;
|
||||
setUploading(true);
|
||||
setUploadStatus('checking');
|
||||
setProgress(0);
|
||||
setUploadedChunks(0);
|
||||
setErrorMessage('');
|
||||
setInstantFileInfo(null);
|
||||
|
||||
uploadRef.current.isCancelled = false;
|
||||
|
||||
try {
|
||||
// 1. 初始化分片上传(内部会计算文件签名并检查秒传)
|
||||
const chunks = await initUpload(selectedFile);
|
||||
|
||||
// 如果 chunks 为 null,说明秒传成功或无需上传
|
||||
if (chunks === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 上传分片
|
||||
setUploadStatus('uploading');
|
||||
await uploadChunksConcurrently(chunks, selectedFile);
|
||||
|
||||
if (uploadRef.current.isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 合并分片
|
||||
const mergeResult = await mergeUpload();
|
||||
|
||||
setUploadStatus('completed');
|
||||
setProgress(100);
|
||||
setUploading(false); // 重置 uploading 状态,隐藏 Spin
|
||||
|
||||
message.success('上传成功');
|
||||
// 确保传递 fileId,从合并结果中获取
|
||||
onUploadComplete?.({
|
||||
fileId: mergeResult?.fileId || mergeResult?.data?.fileId,
|
||||
fileOriginName: mergeResult?.fileOriginName || mergeResult?.data?.fileOriginName,
|
||||
fileSize: mergeResult?.fileSize || mergeResult?.data?.fileSize,
|
||||
url: mergeResult?.url || mergeResult?.data?.url,
|
||||
uploadId: uploadRef.current.uploadId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!uploadRef.current.isCancelled) {
|
||||
setUploadStatus('error');
|
||||
setUploading(false); // 重置 uploading 状态,隐藏 Spin
|
||||
onUploadError?.(error);
|
||||
}
|
||||
}
|
||||
}, [disabled, maxSize, formatFileSize, onUploadComplete, onUploadError, initUpload, uploadChunksConcurrently, mergeUpload]);
|
||||
|
||||
// 计算文件签名(参照 ruoyi-react 的实现)
|
||||
const computeFileSignature = useCallback((fileToUpload, hashType = 'FULL', sampleChunks = [], chunkSize = DEFAULT_SIZE) => {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
if (hashType === 'FULL') {
|
||||
// 计算完整文件MD5
|
||||
computeFullMD5(fileToUpload).then(resolve);
|
||||
} else if (hashType === 'SAMPLE' && sampleChunks.length > 0) {
|
||||
// 计算采样点哈希
|
||||
computeSampleHash(fileToUpload, sampleChunks, chunkSize).then(resolve);
|
||||
} else {
|
||||
// 默认使用UUID
|
||||
resolve(generateUUID());
|
||||
}
|
||||
} catch (error) {
|
||||
resolve(generateUUID());
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 计算完整文件MD5
|
||||
const computeFullMD5 = useCallback((fileToUpload) => {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const fileReader = new FileReader();
|
||||
const spark = new SparkMD5.ArrayBuffer();
|
||||
|
||||
fileReader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
spark.append(e.target.result);
|
||||
const md5 = spark.end();
|
||||
resolve(md5);
|
||||
} else {
|
||||
resolve(generateUUID());
|
||||
}
|
||||
};
|
||||
|
||||
fileReader.onerror = function () {
|
||||
resolve(generateUUID());
|
||||
};
|
||||
|
||||
fileReader.readAsArrayBuffer(fileToUpload);
|
||||
} catch (error) {
|
||||
resolve(generateUUID());
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 计算采样点哈希(参照 ruoyi-react 的实现)
|
||||
const computeSampleHash = useCallback((fileToUpload, sampleChunks, chunkSize = DEFAULT_SIZE) => {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// 对采样分片排序(与后端保持一致)
|
||||
const sortedChunks = [...sampleChunks].sort((a, b) => a - b);
|
||||
const totalChunks = Math.ceil(fileToUpload.size / chunkSize);
|
||||
|
||||
if (sortedChunks.length === 0) {
|
||||
resolve(generateUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建固定长度的数组,按sortedChunks顺序存储MD5
|
||||
const chunkMd5s = new Array(sortedChunks.length);
|
||||
let processedCount = 0;
|
||||
|
||||
sortedChunks.forEach((chunkIndex, idx) => {
|
||||
if (chunkIndex >= totalChunks) {
|
||||
// 分片索引超出范围,填充占位符
|
||||
chunkMd5s[idx] = 'error';
|
||||
processedCount++;
|
||||
if (processedCount === sortedChunks.length) {
|
||||
calculateFinalSignature(chunkMd5s, resolve);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const start = chunkIndex * chunkSize;
|
||||
const end = Math.min(start + chunkSize, fileToUpload.size);
|
||||
const blob = fileToUpload.slice(start, end);
|
||||
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = (e) => {
|
||||
try {
|
||||
if (e.target?.result) {
|
||||
// 计算单个分片的MD5
|
||||
const spark = new SparkMD5.ArrayBuffer();
|
||||
spark.append(e.target.result);
|
||||
const chunkMd5 = spark.end();
|
||||
chunkMd5s[idx] = chunkMd5;
|
||||
}
|
||||
} catch (error) {
|
||||
chunkMd5s[idx] = 'error';
|
||||
}
|
||||
processedCount++;
|
||||
if (processedCount === sortedChunks.length) {
|
||||
// 所有分片处理完成,计算最终签名
|
||||
calculateFinalSignature(chunkMd5s, resolve);
|
||||
}
|
||||
};
|
||||
|
||||
fileReader.onerror = function () {
|
||||
chunkMd5s[idx] = 'error';
|
||||
processedCount++;
|
||||
if (processedCount === sortedChunks.length) {
|
||||
calculateFinalSignature(chunkMd5s, resolve);
|
||||
}
|
||||
};
|
||||
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
});
|
||||
|
||||
// 辅助函数:计算最终签名
|
||||
function calculateFinalSignature(md5List, resolveCallback) {
|
||||
try {
|
||||
const spark = new SparkMD5.ArrayBuffer();
|
||||
const md5String = md5List.join('');
|
||||
const encoder = new TextEncoder();
|
||||
spark.append(encoder.encode(md5String));
|
||||
const finalSignature = spark.end();
|
||||
resolveCallback(finalSignature);
|
||||
} catch (error) {
|
||||
resolveCallback(generateUUID());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
resolve(generateUUID());
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 生成 UUID
|
||||
const generateUUID = useCallback(() => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 文件选择处理
|
||||
const handleFileSelect = useCallback((e) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
startUpload(selectedFile);
|
||||
}
|
||||
}, [startUpload]);
|
||||
|
||||
// 拖拽上传处理
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const droppedFile = e.dataTransfer?.files?.[0];
|
||||
if (droppedFile) {
|
||||
startUpload(droppedFile);
|
||||
}
|
||||
}, [startUpload]);
|
||||
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
// 重新上传
|
||||
const handleRetry = useCallback(() => {
|
||||
setUploadStatus('checking');
|
||||
setProgress(0);
|
||||
setUploadedChunks(0);
|
||||
setErrorMessage('');
|
||||
|
||||
const fileToRetry = file || uploadRef.current.currentFile;
|
||||
if (fileToRetry) {
|
||||
startUpload(fileToRetry);
|
||||
}
|
||||
}, [file, startUpload]);
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupUpload();
|
||||
};
|
||||
}, [cleanupUpload]);
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = () => {
|
||||
switch (uploadStatus) {
|
||||
case 'idle':
|
||||
return '等待上传';
|
||||
case 'checking':
|
||||
return '检查中...';
|
||||
case 'initializing':
|
||||
return '初始化中...';
|
||||
case 'calculating':
|
||||
return '计算文件签名中...';
|
||||
case 'uploading':
|
||||
return `上传中... (${uploadedChunks}/${totalChunks})`;
|
||||
case 'merging':
|
||||
return '合并中...';
|
||||
case 'completed':
|
||||
return '上传完成';
|
||||
case 'instant':
|
||||
return '秒传成功';
|
||||
case 'error':
|
||||
return '上传失败';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
default:
|
||||
return '未知状态';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = () => {
|
||||
switch (uploadStatus) {
|
||||
case 'completed':
|
||||
return '#52c41a';
|
||||
case 'instant':
|
||||
return '#52c41a';
|
||||
case 'error':
|
||||
return '#ff4d4f';
|
||||
case 'cancelled':
|
||||
return '#d9d9d9';
|
||||
default:
|
||||
return '#1890ff';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chunked-upload-wrapper">
|
||||
<Spin spinning={uploading}>
|
||||
<div
|
||||
className={`chunked-upload-area ${uploading ? 'uploading' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* 文件选择输入 */}
|
||||
<input
|
||||
type="file"
|
||||
ref={(ref) => {
|
||||
fileInputRef.current = ref;
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
accept={accept}
|
||||
onChange={handleFileSelect}
|
||||
disabled={uploading || disabled}
|
||||
/>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="chunked-upload-content" onClick={() => {
|
||||
if (!uploading && !disabled && fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}}>
|
||||
{uploadStatus === 'idle' && !uploading ? (
|
||||
// 未选择文件状态
|
||||
children ? (
|
||||
children
|
||||
) : (
|
||||
<div className="chunked-upload-placeholder">
|
||||
<i className="iconfont icon-shangchuanicon chunked-upload-icon"></i>
|
||||
<div className="chunked-upload-text">
|
||||
<span className="chunked-upload-main">点击或拖拽文件到此处</span>
|
||||
<span className="chunked-upload-hint">支持分片上传,最大 {formatFileSize(maxSize)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// 上传中/已完成状态
|
||||
<div className="chunked-upload-info">
|
||||
{file && (
|
||||
<div className="chunked-upload-file-info">
|
||||
<div className="chunked-upload-file-name">{file.name}</div>
|
||||
<div className="chunked-upload-file-size">{formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chunked-upload-progress">
|
||||
<Progress
|
||||
percent={progress}
|
||||
status={uploadStatus === 'error' ? 'exception' : uploadStatus === 'cancelled' ? 'normal' : 'success'}
|
||||
strokeColor={getStatusColor()}
|
||||
size="small"
|
||||
/>
|
||||
<div className="chunked-upload-status">
|
||||
<span className="chunked-upload-status-text">{getStatusText()}</span>
|
||||
{uploadStatus === 'uploading' && uploadSpeed > 0 && (
|
||||
<span className="chunked-upload-speed">{formatSpeed(uploadSpeed)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadStatus === 'error' && errorMessage && (
|
||||
<div className="chunked-upload-error">{errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{(uploadStatus === 'completed' || uploadStatus === 'instant' || uploadStatus === 'error' || uploadStatus === 'cancelled') && (
|
||||
<div className="chunked-upload-actions">
|
||||
{uploadStatus === 'error' && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRetry}
|
||||
>
|
||||
<i className="iconfont icon-refresh mr5"></i>重试
|
||||
</Button>
|
||||
)}
|
||||
{(uploadStatus === 'completed' || uploadStatus === 'instant' || uploadStatus === 'cancelled') && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<i className="iconfont icon-delete mr5"></i>移除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChunkedUpload.displayName = 'ChunkedUpload';
|
||||
|
||||
export default ChunkedUpload;
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* 分片上传组件样式
|
||||
*/
|
||||
|
||||
.chunked-upload-wrapper {
|
||||
width: 100%;
|
||||
|
||||
.chunked-upload-area {
|
||||
border: 2px dashed rgba(22, 76, 228, 0.17);
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
border-color: #164ce4;
|
||||
|
||||
.chunked-upload-placeholder {
|
||||
.chunked-upload-icon {
|
||||
color: #164ce4;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
cursor: default;
|
||||
border-color: #52c41a;
|
||||
background-color: #f6ffed;
|
||||
|
||||
&:hover {
|
||||
border-color: #52c41a;
|
||||
background-color: #f6ffed;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
border-color: #d9d9d9;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
.chunked-upload-content {
|
||||
padding: 40px 20px;
|
||||
|
||||
.chunked-upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.chunked-upload-icon {
|
||||
font-size: 48px;
|
||||
color: #8c8c8c;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chunked-upload-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.chunked-upload-main {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chunked-upload-hint {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chunked-upload-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.chunked-upload-file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.chunked-upload-file-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chunked-upload-file-size {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
.chunked-upload-progress {
|
||||
.chunked-upload-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
|
||||
.chunked-upload-status-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chunked-upload-speed {
|
||||
font-size: 12px;
|
||||
color: #164ce4;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chunked-upload-error {
|
||||
padding: 8px 12px;
|
||||
background-color: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chunked-upload-actions {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖 Ant Design Progress 组件样式
|
||||
:global {
|
||||
.ant-progress-status-normal {
|
||||
.ant-progress-bg {
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-progress-exception {
|
||||
.ant-progress-text {
|
||||
color: #ff4d4f !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-progress-success-circle {
|
||||
.ant-progress-circle-trail {
|
||||
stroke: #f7f7f7 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮样式优化
|
||||
.ant-btn {
|
||||
font-size: 13px;
|
||||
|
||||
&.ant-btn-dangerous {
|
||||
&:hover {
|
||||
background-color: #ff4d4f !important;
|
||||
border-color: #ff4d4f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,4 +87,112 @@ export function getMainInfos(deptId, params) {
|
|||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 分片上传相关 API ====================
|
||||
|
||||
/**
|
||||
* 初始化分片上传
|
||||
* @param {Object} data - 初始化请求参数
|
||||
* @param {string} data.fileName - 文件名
|
||||
* @param {number} data.fileSize - 文件大小(字节)
|
||||
* @param {string} [data.type] - 文件类型(cms/dms/resource/pms等)
|
||||
* @param {string} [data.hierarchy] - 层级结构
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function initChunkedUpload(data) {
|
||||
return fetch({
|
||||
url: '/file/chunked/init',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传分片
|
||||
* @param {Object} data - 分片上传参数
|
||||
* @param {string} data.uploadId - 上传ID
|
||||
* @param {number} data.chunkNumber - 分片序号
|
||||
* @param {File} data.chunk - 分片文件
|
||||
* @param {Function} [onUploadProgress] - 上传进度回调
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function uploadChunk(data, onUploadProgress) {
|
||||
// 使用 FormData 包装数据,让浏览器自动设置 boundary
|
||||
const formData = new FormData();
|
||||
formData.append('chunk', data.chunk);
|
||||
formData.append('uploadId', data.uploadId);
|
||||
formData.append('chunkNumber', data.chunkNumber);
|
||||
|
||||
return fetch({
|
||||
url: `/file/chunked/upload/${data.uploadId}/${data.chunkNumber}`,
|
||||
method: 'post',
|
||||
data: formData,
|
||||
timeout: 600000,
|
||||
onUploadProgress
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并分片
|
||||
* @param {string} uploadId - 上传ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function mergeChunks(uploadId) {
|
||||
return fetch({
|
||||
url: `/file/chunked/merge/${uploadId}`,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消上传
|
||||
* @param {string} uploadId - 上传ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function cancelUpload(uploadId) {
|
||||
return fetch({
|
||||
url: `/file/chunked/cancel/${uploadId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查分片是否已上传
|
||||
* @param {string} uploadId - 上传ID
|
||||
* @param {number} chunkNumber - 分片序号
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function checkChunk(uploadId, chunkNumber) {
|
||||
return fetch({
|
||||
url: `/file/chunked/check/${uploadId}/${chunkNumber}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上传进度
|
||||
* @param {string} uploadId - 上传ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getProgress(uploadId) {
|
||||
return fetch({
|
||||
url: `/file/chunked/progress/${uploadId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查秒传/断点续传
|
||||
* @param {Object} data - 检查参数
|
||||
* @param {string} data.uploadId - 上传ID
|
||||
* @param {string} data.fileSignature - 文件签名
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function checkInstantUpload(data) {
|
||||
return fetch({
|
||||
url: '/file/chunked/checkInstantUpload',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue