From 876b4f8076a3a71a92f9b2a660560e27243eed91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E6=80=9D?= Date: Mon, 25 May 2026 10:27:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + src/components/ChunkedUpload/index.jsx | 848 ++++++++++++++++++++++++ src/components/ChunkedUpload/index.scss | 181 +++++ src/factory/api.js | 110 ++- 4 files changed, 1140 insertions(+), 1 deletion(-) create mode 100644 src/components/ChunkedUpload/index.jsx create mode 100644 src/components/ChunkedUpload/index.scss 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 ( +
+ +
+ {/* 文件选择输入 */} + { + fileInputRef.current = ref; + }} + style={{ display: 'none' }} + accept={accept} + onChange={handleFileSelect} + disabled={uploading || disabled} + /> + + {/* 内容区域 */} +
{ + if (!uploading && !disabled && fileInputRef.current) { + fileInputRef.current.click(); + } + }}> + {uploadStatus === 'idle' && !uploading ? ( + // 未选择文件状态 + children ? ( + children + ) : ( +
+ +
+ 点击或拖拽文件到此处 + 支持分片上传,最大 {formatFileSize(maxSize)} +
+
+ ) + ) : ( + // 上传中/已完成状态 +
+ {file && ( +
+
{file.name}
+
{formatFileSize(file.size)}
+
+ )} + +
+ +
+ {getStatusText()} + {uploadStatus === 'uploading' && uploadSpeed > 0 && ( + {formatSpeed(uploadSpeed)} + )} +
+
+ + {uploadStatus === 'error' && errorMessage && ( +
{errorMessage}
+ )} +
+ )} +
+ + {/* 操作按钮 */} + {(uploadStatus === 'completed' || uploadStatus === 'instant' || uploadStatus === 'error' || uploadStatus === 'cancelled') && ( +
+ {uploadStatus === 'error' && ( + + )} + {(uploadStatus === 'completed' || uploadStatus === 'instant' || uploadStatus === 'cancelled') && ( + + )} +
+ )} +
+
+
+ ); +}); + +ChunkedUpload.displayName = 'ChunkedUpload'; + +export default ChunkedUpload; diff --git a/src/components/ChunkedUpload/index.scss b/src/components/ChunkedUpload/index.scss new file mode 100644 index 00000000..07ee26fc --- /dev/null +++ b/src/components/ChunkedUpload/index.scss @@ -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; + } + } + } + } +} diff --git a/src/factory/api.js b/src/factory/api.js index bcab6cbc..91ef8924 100644 --- a/src/factory/api.js +++ b/src/factory/api.js @@ -87,4 +87,112 @@ export function getMainInfos(deptId, params) { method: 'get', params }); -} \ No newline at end of file +} + +// ==================== 分片上传相关 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 + }); +}