diff --git a/package.json b/package.json index 03cc0825..18e641d4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ChunkedUpload/index.jsx b/src/components/ChunkedUpload/index.jsx new file mode 100644 index 00000000..37c5a09e --- /dev/null +++ b/src/components/ChunkedUpload/index.jsx @@ -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 ( +