分片上传组件

This commit is contained in:
谢思 2026-05-25 10:27:46 +08:00
parent deed967a44
commit 876b4f8076
4 changed files with 1140 additions and 1 deletions

View File

@ -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",

View File

@ -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;
}
// sortedChunksMD5
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;

View File

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

View File

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