feat(镜像管理): 分片策略+配置
parent
cf036ae0fd
commit
9fb05a44cb
|
@ -48,6 +48,9 @@ export default defineConfig({
|
|||
target: 'http://10.100.51.85:8112',
|
||||
// changeOrigin: true,
|
||||
},
|
||||
'/api/files': {
|
||||
target: 'http://10.100.51.85:8112',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export default {
|
||||
'POST /api/v1/images/queryimagesList': (req: any, res: any) => {
|
||||
'POST /api/files/queryimagesList': (req: any, res: any) => {
|
||||
const { page_size, page_num } = req.body;
|
||||
const data = [];
|
||||
function getRandomFormat() {
|
||||
|
@ -19,17 +19,19 @@ export default {
|
|||
image_name: `周俊星${(page_num - 1) * page_size + i}`,
|
||||
image_type: getRandomFormat(),
|
||||
bt_path: `/serve/logs`,
|
||||
image_version: '1.0.0',
|
||||
os_version: 'Ubuntu 20.04',
|
||||
image_status:Math.random() > 0.5 ? 1 : 2,
|
||||
storage_path: '/mock/images',
|
||||
create_time: +new Date(),
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
error_code: '0000000000',
|
||||
code: '200',
|
||||
message: '操作成功',
|
||||
data: {
|
||||
paging: {
|
||||
total: 520,
|
||||
total_num: 520,
|
||||
page_num: page_num,
|
||||
page_size: page_size,
|
||||
},
|
||||
|
@ -60,34 +62,29 @@ export default {
|
|||
const shardIndex = parseInt(req.body.shard_index);
|
||||
const shardTotal = parseInt(req.body.shard_total);
|
||||
|
||||
console.log(`分片上传进度: ${shardIndex + 1}/${shardTotal}`);
|
||||
console.log(`分片上传进度: ${shardIndex}/${shardTotal}`);
|
||||
|
||||
// 模拟上传完成(最后一个分片)
|
||||
if (shardIndex === shardTotal - 1) {
|
||||
// 修改判断逻辑:当分片索引等于分片总数时,表示上传完成
|
||||
if (shardIndex === shardTotal) {
|
||||
console.log('文件上传完成!');
|
||||
res.send({
|
||||
uploadedChunks: shardIndex,
|
||||
success: true,
|
||||
error_code: '0000000000',
|
||||
message: '操作成功',
|
||||
data: {
|
||||
file_id: req.body.file_id,
|
||||
message: '文件上传成功',
|
||||
},
|
||||
totalChunks: shardTotal,
|
||||
message: '分片上传成功',
|
||||
status: 'completed',
|
||||
});
|
||||
} else {
|
||||
// 模拟上传中
|
||||
const progress = Math.round(((shardIndex + 1) / shardTotal) * 100);
|
||||
const progress = Math.round((shardIndex / shardTotal) * 100);
|
||||
console.log(`上传进度: ${progress}%`);
|
||||
|
||||
res.send({
|
||||
success: false,
|
||||
error_code: '1221000001',
|
||||
message: `上传中... ${progress}%`,
|
||||
data: {
|
||||
uploaded: shardIndex + 1,
|
||||
total: shardTotal,
|
||||
progress: progress,
|
||||
},
|
||||
uploadedChunks: shardIndex,
|
||||
success: true,
|
||||
totalChunks: shardTotal,
|
||||
message: '分片上传成功',
|
||||
status: 'uploading',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,7 +1,83 @@
|
|||
export const ERROR_CODE = '0000000000';
|
||||
export const CODE = "200";
|
||||
|
||||
export const IMAGES_TYPE_MAP = {
|
||||
1: 'VHD',
|
||||
2: 'VHDX',
|
||||
3: 'QCOW2',
|
||||
} as const;
|
||||
|
||||
// ==================== 配置定义 ====================
|
||||
/**
|
||||
* 网络传输层面分片大小配置 (单位: 字节)
|
||||
* ≤10GB : 10MB
|
||||
* 10GB-20GB : 20MB
|
||||
* 20GB-30GB : 30MB
|
||||
* >30GB : 50MB
|
||||
*/
|
||||
export const CHUNK_SIZE_CONFIG = [
|
||||
{ maxSize: 10 * 1024 * 1024 * 1024, chunkSize: 10 * 1024 * 1024 }, // ≤10GB : 10MB
|
||||
{ maxSize: 20 * 1024 * 1024 * 1024, chunkSize: 20 * 1024 * 1024 }, // 10GB-20GB : 20MB
|
||||
{ maxSize: 30 * 1024 * 1024 * 1024, chunkSize: 30 * 1024 * 1024 }, // 20GB-30GB : 30MB
|
||||
{ maxSize: Infinity, chunkSize: 50 * 1024 * 1024 }, // >30GB : 50MB
|
||||
];
|
||||
/**
|
||||
* MD5计算内存分块大小配置 (单位: 字节):根据整个文件大小决定MD5计算时的内存分块大小
|
||||
* 小文件(≤100MB): 512KB块
|
||||
* 中等文件(100MB-1GB): 1MB块
|
||||
* 大文件(1-5GB): 2MB块
|
||||
* 超大文件(5-10GB): 4MB块
|
||||
* 超大文件(10-30GB): 6MB块
|
||||
* 特大文件(>30GB): 8MB块
|
||||
*/
|
||||
export const MD5_CHUNK_SIZE_CONFIG = [
|
||||
{ maxSize: 100 * 1024 * 1024, chunkSize: 512 * 1024 }, // ≤100MB : 512KB
|
||||
{ maxSize: 1024 * 1024 * 1024, chunkSize: 1 * 1024 * 1024 }, // 100MB-1GB : 1MB
|
||||
{ maxSize: 5 * 1024 * 1024 * 1024, chunkSize: 2 * 1024 * 1024 }, // 1GB-5GB : 2MB
|
||||
{ maxSize: 10 * 1024 * 1024 * 1024, chunkSize: 4 * 1024 * 1024 }, // 5GB-10GB : 4MB
|
||||
{ maxSize: 30 * 1024 * 1024 * 1024, chunkSize: 6 * 1024 * 1024 }, // 10GB-30GB : 6MB
|
||||
{ maxSize: Infinity, chunkSize: 8 * 1024 * 1024 }, // >30GB : 8MB
|
||||
];
|
||||
|
||||
/**
|
||||
* 上传配置
|
||||
*/
|
||||
export const UPLOAD_CONFIG = {
|
||||
MAX_CONCURRENT: 4, // 同时上传的分片数量
|
||||
MAX_FILE_SIZE: 50 * 1024 * 1024 * 1024, // 50GB 文件大小限制
|
||||
ALLOWED_EXTENSIONS: ['.tar.gz', '.iso', '.qcow2'], // 允许的文件扩展名
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传状态映射
|
||||
*/
|
||||
export const UPLOAD_STATUS_MAP = {
|
||||
READY: 'ready',
|
||||
UPLOADING: 'uploading',
|
||||
SUCCESS: 'success',
|
||||
ERROR: 'error',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 时间格式化配置
|
||||
*/
|
||||
export const TIME_FORMAT_CONFIG = {
|
||||
HOUR_LABEL: '小时',
|
||||
MINUTE_LABEL: '分钟',
|
||||
SECOND_LABEL: '秒',
|
||||
};
|
||||
|
||||
/**
|
||||
* 镜像详情字段配置
|
||||
*/
|
||||
export const IMAGE_DETAIL_FIELDS: IMAGES.ImageDetailField[] = [
|
||||
{ label: '镜像名称:', key: 'image_name' },
|
||||
{
|
||||
label: '桌面类型:',
|
||||
key: 'image_type',
|
||||
render: (value: number) =>
|
||||
IMAGES_TYPE_MAP[value as keyof typeof IMAGES_TYPE_MAP] || '--',
|
||||
},
|
||||
{ label: '模板存放路径:', key: 'storage_path' },
|
||||
{ label: 'BT路径:', key: 'bt_path' },
|
||||
{ label: '创建时间:', key: 'create_time' },
|
||||
] as const;
|
|
@ -5,6 +5,19 @@ import { UploadProps } from 'antd/lib/upload';
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import SparkMD5 from 'spark-md5';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
IMAGE_DETAIL_FIELDS,
|
||||
UPLOAD_CONFIG,
|
||||
UPLOAD_STATUS_MAP,
|
||||
} from '@/constants/images.constants';
|
||||
import {
|
||||
getChunkSize,
|
||||
calculateMD5InChunks,
|
||||
formatTime,
|
||||
} from '@/utils/images';
|
||||
|
||||
const { MAX_CONCURRENT, MAX_FILE_SIZE, ALLOWED_EXTENSIONS } = UPLOAD_CONFIG;
|
||||
const { READY, UPLOADING, SUCCESS, ERROR } = UPLOAD_STATUS_MAP;
|
||||
|
||||
const { Dragger } = Upload;
|
||||
/**详情弹窗 */
|
||||
|
@ -24,52 +37,24 @@ const ModalDetailShow = (props: any) => {
|
|||
>
|
||||
{selectedImage && (
|
||||
<div className="image-detail">
|
||||
<div className="detail-item">
|
||||
<label>镜像名称:</label>
|
||||
<span>{selectedImage.image_name}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>桌面类型:</label>
|
||||
{IMAGE_DETAIL_FIELDS.map((field) => (
|
||||
<div className="detail-item" key={String(field.key)}>
|
||||
<label>{field.label}</label>
|
||||
<span>
|
||||
{IMAGES_TYPE_MAP[
|
||||
selectedImage.image_type as keyof typeof IMAGES_TYPE_MAP
|
||||
] || '--'}
|
||||
{'render' in field && field.render
|
||||
? field.render(selectedImage[field.key])
|
||||
: selectedImage[field.key] || '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>模板存放路径:</label>
|
||||
<span>{selectedImage.storage_path}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>BT路径:</label>
|
||||
<span>{selectedImage.bt_path}</span>
|
||||
</div>
|
||||
{/* <div className="detail-item">
|
||||
<label>状态:</label>
|
||||
<span>{getStatusTag(selectedImage.status)}</span>
|
||||
</div> */}
|
||||
<div className="detail-item">
|
||||
<label>创建时间:</label>
|
||||
<span>{selectedImage.create_time}</span>
|
||||
</div>
|
||||
{/* <div className="detail-item">
|
||||
<label>描述:</label>
|
||||
<p>{selectedImage.description}</p>
|
||||
</div> */}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
/**上传文件分片 */
|
||||
interface ImportModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onImportSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ImportModal: React.FC<ImportModalProps> = ({
|
||||
const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
|
||||
visible,
|
||||
onCancel,
|
||||
onImportSuccess,
|
||||
|
@ -80,23 +65,10 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
'ready' | 'uploading' | 'success' | 'error'
|
||||
>('ready');
|
||||
const [uploadMessage, setUploadMessage] = useState('');
|
||||
/**
|
||||
* 网络传输层面分片大小策略:
|
||||
* ≤5GB 文件: 10MB 分片
|
||||
* 5GB-10GB 文件: 15MB 分片
|
||||
* 10GB 文件: 20MB 分片
|
||||
*/
|
||||
/**
|
||||
* MD5计算的内存处理层面的分块策略:
|
||||
* 小文件(≤100MB): 1MB块
|
||||
* 中等文件(100MB-1GB): 2MB块
|
||||
* 大文件(1-5GB): 4MB块
|
||||
* 超大文件(>5GB): 8MB块
|
||||
*/
|
||||
|
||||
// 分片上传相关
|
||||
// const CHUNK_SIZE = 10 * 1024 * 1024; // 50MB/每片 网络传输层面的分片
|
||||
const MAX_CONCURRENT = 3; // 同时上传的分片数量
|
||||
const MAX_CONCURRENT = UPLOAD_CONFIG.MAX_CONCURRENT;; // 同时上传的分片数量
|
||||
const uploadQueue = useRef<Array<{ chunk: Blob; index: number }>>([]); // 上传队列
|
||||
const completedChunks = useRef<number>(0); // 已上传的分片数量
|
||||
const totalChunks = useRef<number>(0); // 总分片数量
|
||||
|
@ -104,12 +76,16 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
const fileName = useRef<string>(''); // 文件名
|
||||
const fileSize = useRef<number>(0); // 文件大小
|
||||
const abortController = useRef<AbortController | null>(null); // 用于取消上传
|
||||
// 上传镜像时间相关
|
||||
const [uploadStartTime, setUploadStartTime] = useState<number | null>(null);
|
||||
const [elapsedTime, setElapsedTime] = useState<number>(0);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 添加重置状态函数
|
||||
const resetState = () => {
|
||||
setUploadProgress(0);
|
||||
setIsUploading(false);
|
||||
setUploadStatus('ready');
|
||||
setUploadStatus(READY);
|
||||
setUploadMessage('');
|
||||
completedChunks.current = 0;
|
||||
totalChunks.current = 0;
|
||||
|
@ -118,6 +94,14 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
fileSize.current = 0;
|
||||
uploadQueue.current = [];
|
||||
|
||||
// 重置计时器
|
||||
setUploadStartTime(null);
|
||||
setElapsedTime(0);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// 如果有正在进行的上传,先中止
|
||||
if (abortController.current) {
|
||||
abortController.current.abort();
|
||||
|
@ -132,63 +116,6 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
}
|
||||
}, [visible]);
|
||||
|
||||
// 5. 分块计算文件MD5
|
||||
const calculateMD5InChunks = (
|
||||
file: Blob,
|
||||
fileSize: number,
|
||||
chunkSize: number = (() => {
|
||||
if (fileSize > 5 * 1024 * 1024 * 1024)
|
||||
// >5GB
|
||||
return 8 * 1024 * 1024; // 8MB块
|
||||
if (fileSize > 1024 * 1024 * 1024)
|
||||
// >1GB
|
||||
return 4 * 1024 * 1024; // 4MB块
|
||||
if (fileSize > 100 * 1024 * 1024)
|
||||
// >100MB
|
||||
return 2 * 1024 * 1024; // 2MB块
|
||||
return 1 * 1024 * 1024; // 1MB块
|
||||
})(),
|
||||
// 计算MD5时的内存分块大小 内存处理层面的分块 即整个文件的大小来决定 MD5 计算时的块大小
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const spark = new SparkMD5.ArrayBuffer();
|
||||
const fileReader = new FileReader();
|
||||
let currentChunk = 0;
|
||||
const chunks = Math.ceil(file.size / chunkSize);
|
||||
|
||||
fileReader.onload = (e) => {
|
||||
try {
|
||||
spark.append(e.target?.result as ArrayBuffer);
|
||||
currentChunk++;
|
||||
|
||||
if (currentChunk < chunks) {
|
||||
loadNext();
|
||||
} else {
|
||||
const hash = spark.end();
|
||||
spark.destroy(); // 释放内存
|
||||
resolve(hash);
|
||||
}
|
||||
} catch (error) {
|
||||
spark.destroy();
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
fileReader.onerror = () => {
|
||||
spark.destroy();
|
||||
reject(new Error('计算MD5失败'));
|
||||
};
|
||||
|
||||
const loadNext = () => {
|
||||
const start = currentChunk * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
fileReader.readAsArrayBuffer(file.slice(start, end));
|
||||
};
|
||||
|
||||
loadNext();
|
||||
});
|
||||
};
|
||||
|
||||
// 4. 上传单个分片
|
||||
const uploadChunk = async (chunk: Blob, index: number): Promise<boolean> => {
|
||||
try {
|
||||
|
@ -209,7 +136,50 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
formData.append('shard_index', index.toString());
|
||||
formData.append('shard_total', totalChunks.current.toString());
|
||||
|
||||
await uploadChunkAPI(formData, abortController.current?.signal);
|
||||
const response = await uploadChunkAPI(
|
||||
formData,
|
||||
abortController.current?.signal,
|
||||
);
|
||||
|
||||
// 根据后端返回的状态进行判断
|
||||
if (response.success) {
|
||||
if (response.status === 'completed') {
|
||||
// 文件上传完成,设置进度为100%
|
||||
setUploadProgress(100); // 这里已经正确设置了100%
|
||||
setIsUploading(false);
|
||||
setUploadStatus(SUCCESS);
|
||||
setUploadMessage('文件上传成功!正在处理中,请稍后查看列表。');
|
||||
message.success('文件上传成功!系统正在处理,请稍后查看列表。');
|
||||
onImportSuccess?.();
|
||||
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// 文件上传完成
|
||||
return true;
|
||||
} else if (response.status === 'uploading') {
|
||||
// 分片上传成功,继续上传
|
||||
return true;
|
||||
} else if (response.status === 'error') {
|
||||
// 后端返回错误状态
|
||||
console.error(`上传分片 ${index} 失败:`, response.message);
|
||||
return false;
|
||||
} else {
|
||||
// 未知状态
|
||||
console.error(`上传分片 ${index} 返回未知状态:`, response.status);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// success 为 false
|
||||
console.error(`上传分片 ${index} 失败:`, response.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
// await uploadChunkAPI(formData, abortController.current?.signal);
|
||||
// return true;
|
||||
// 模拟上传过程
|
||||
// await new Promise((resolve) =>
|
||||
// setTimeout(resolve, 300 + Math.random() * 700),
|
||||
|
@ -219,8 +189,6 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
// if (Math.random() < 0.08) {
|
||||
// throw new Error('网络错误');
|
||||
// }
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 检查是否因为中止导致的错误
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
|
@ -258,30 +226,45 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
// 只有在没有错误的情况下才更新进度
|
||||
if (!hasError) {
|
||||
completedChunks.current += 1;
|
||||
const progress = Math.round(
|
||||
// 修改进度计算方式,只有在真正完成时才显示100%
|
||||
let progress;
|
||||
if (completedChunks.current === totalChunks.current) {
|
||||
progress = 100; // 当所有分片都完成时,显示100%
|
||||
} else {
|
||||
progress = Math.min(
|
||||
Math.floor(
|
||||
(completedChunks.current / totalChunks.current) * 100,
|
||||
),
|
||||
99, // 最大显示99%,防止在最后一步完成前就显示100%
|
||||
);
|
||||
}
|
||||
setUploadProgress(progress);
|
||||
|
||||
// 所有分片上传完成
|
||||
if (completedChunks.current === totalChunks.current) {
|
||||
setIsUploading(false);
|
||||
setUploadStatus('success');
|
||||
setUploadMessage(
|
||||
'文件上传成功!正在处理中,请稍后查看列表。',
|
||||
);
|
||||
message.success(
|
||||
'文件上传成功!系统正在处理,请稍后查看列表。',
|
||||
);
|
||||
onImportSuccess?.();
|
||||
}
|
||||
// if (completedChunks.current === totalChunks.current) {
|
||||
// setIsUploading(false);
|
||||
// setUploadStatus(SUCCESS);
|
||||
// setUploadMessage(
|
||||
// '文件上传成功!正在处理中,请稍后查看列表。',
|
||||
// );
|
||||
// message.success(
|
||||
// '文件上传成功!系统正在处理,请稍后查看列表。',
|
||||
// );
|
||||
// onImportSuccess?.();
|
||||
|
||||
// // 停止计时器
|
||||
// if (timerRef.current) {
|
||||
// clearInterval(timerRef.current);
|
||||
// timerRef.current = null;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
} else {
|
||||
// 上传失败处理
|
||||
if (!abortController.current?.signal.aborted && !hasError) {
|
||||
hasError = true; // 设置错误标记
|
||||
setIsUploading(false);
|
||||
setUploadStatus('error');
|
||||
setUploadStatus(ERROR);
|
||||
setUploadMessage('文件上传失败,请重试');
|
||||
message.error('文件上传失败');
|
||||
|
||||
|
@ -289,6 +272,12 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
if (abortController.current) {
|
||||
abortController.current.abort();
|
||||
}
|
||||
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -302,30 +291,29 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
/**
|
||||
* 网络传输层面的分片
|
||||
* ≤5GB 文件: 10MB 分片
|
||||
* 5GB-10GB 文件: 15MB 分片
|
||||
* 10GB 文件: 20MB 分片
|
||||
*/
|
||||
const getChunkSize = (fileSize: number): number => {
|
||||
if (fileSize > 10 * 1024 * 1024 * 1024)
|
||||
// >10GB
|
||||
return 20 * 1024 * 1024; // 20MB
|
||||
if (fileSize > 5 * 1024 * 1024 * 1024)
|
||||
// >5GB
|
||||
return 15 * 1024 * 1024; // 15MB
|
||||
return 10 * 1024 * 1024; // 10MB (默认)
|
||||
};
|
||||
|
||||
// 2. 开始上传
|
||||
const startUpload = async (file: File) => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadStatus('uploading');
|
||||
setUploadStatus(UPLOADING);
|
||||
setUploadMessage('正在准备上传...');
|
||||
setUploadProgress(0);
|
||||
|
||||
// 启动计时器
|
||||
const startTime = Date.now();
|
||||
setUploadStartTime(startTime);
|
||||
setElapsedTime(0);
|
||||
|
||||
// 立即更新一次时间
|
||||
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
|
||||
}, 1000);
|
||||
|
||||
// 初始化状态
|
||||
completedChunks.current = 0;
|
||||
fileSize.current = file.size;
|
||||
|
@ -344,7 +332,7 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
uploadQueue.current.push({ chunk, index: i });
|
||||
uploadQueue.current.push({ chunk, index: i + 1 });
|
||||
}
|
||||
|
||||
setUploadMessage(`开始上传文件... `);
|
||||
|
@ -358,9 +346,15 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
} catch (error) {
|
||||
console.error('上传准备失败:', error);
|
||||
setIsUploading(false);
|
||||
setUploadStatus('error');
|
||||
setUploadStatus(ERROR);
|
||||
setUploadMessage('上传准备失败,请重试');
|
||||
message.error('上传准备失败');
|
||||
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -383,9 +377,9 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
// }
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > 20 * 1024 * 1024 * 1024) {
|
||||
// 20GB
|
||||
message.error('文件大小不能超过20GB');
|
||||
if (file.size > UPLOAD_CONFIG.MAX_FILE_SIZE) {
|
||||
// 50GB
|
||||
message.error('文件大小不能超过50GB');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -400,10 +394,16 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
abortController.current.abort();
|
||||
}
|
||||
setIsUploading(false);
|
||||
setUploadStatus('ready');
|
||||
setUploadStatus(READY);
|
||||
setUploadMessage('上传已取消');
|
||||
setUploadProgress(0);
|
||||
message.info('上传已取消');
|
||||
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 导入弹窗内容
|
||||
|
@ -437,7 +437,7 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
<p className="ant-upload-text">点击选择文件或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
{/* 支持文件扩展名:.tar.gz, .iso, .qcow2,大小限制为20G */}
|
||||
大小限制为20G
|
||||
大小限制为50G
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
|
@ -466,9 +466,12 @@ const ImportModal: React.FC<ImportModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{uploadMessage && (
|
||||
{(uploadMessage || isUploading) && (
|
||||
<div style={{ marginTop: 8, textAlign: 'center' }}>
|
||||
<span>{uploadMessage}</span>
|
||||
<span>{uploadMessage || '正在上传...'}</span>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<span>已用时间: {formatTime(elapsedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isUploading && (
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ERROR_CODE, IMAGES_TYPE_MAP } from '@/constants/images.constants';
|
||||
import { CODE, IMAGES_TYPE_MAP } from '@/constants/images.constants';
|
||||
import { getImagesList } from '@/services/images';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
|
@ -18,6 +18,7 @@ import {
|
|||
Space,
|
||||
Table,
|
||||
Tooltip,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ImportModal, ModalDetailShow } from './components/modalShow/modalShow';
|
||||
|
@ -25,6 +26,28 @@ import './index.less';
|
|||
|
||||
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg';
|
||||
|
||||
// 列配置定义
|
||||
type ColumnConfig = {
|
||||
key: string;
|
||||
title: string;
|
||||
dataIndex?: string;
|
||||
width: number;
|
||||
render?: (text: any, record: any, index: number) => React.ReactNode;
|
||||
fixed?: 'left' | 'right';
|
||||
defaultVisible: boolean; // 默认是否显示
|
||||
alwaysVisible?: boolean; // 始终显示的列
|
||||
};
|
||||
|
||||
type TableColumn = {
|
||||
title: string;
|
||||
dataIndex?: string;
|
||||
key: string;
|
||||
width: number;
|
||||
render?: any;
|
||||
fixed?: 'left' | 'right';
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
const ImageList: React.FC = () => {
|
||||
const [images, setImages] = useState<IMAGES.ImageItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
@ -41,18 +64,9 @@ const ImageList: React.FC = () => {
|
|||
},
|
||||
});
|
||||
|
||||
// 列显示控制状态
|
||||
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
|
||||
{
|
||||
image_type: true,
|
||||
storage_path: true,
|
||||
bt_path: true,
|
||||
create_time: true,
|
||||
action: true,
|
||||
},
|
||||
);
|
||||
const [columnSettingsVisible, setColumnSettingsVisible] = useState(false);
|
||||
|
||||
// 表格参数变化 获取镜像列表
|
||||
useEffect(() => {
|
||||
loadImages();
|
||||
}, [
|
||||
|
@ -61,9 +75,136 @@ const ImageList: React.FC = () => {
|
|||
tableParams?.sortOrder,
|
||||
tableParams?.sortField,
|
||||
JSON.stringify(tableParams.filters),
|
||||
JSON.stringify(tableParams.keywords),
|
||||
JSON.stringify(tableParams.image_name),
|
||||
]);
|
||||
|
||||
// 定义所有列的配置
|
||||
const columnConfigs: ColumnConfig[] = [
|
||||
{
|
||||
key: 'index',
|
||||
title: '序号',
|
||||
width: 120,
|
||||
render: (text: any, row: any, index: number) =>
|
||||
(tableParams.pagination?.current - 1) *
|
||||
tableParams.pagination?.pageSize +
|
||||
index +
|
||||
1,
|
||||
defaultVisible: true,
|
||||
alwaysVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'image_name',
|
||||
title: '镜像名称',
|
||||
dataIndex: 'image_name',
|
||||
width: 120,
|
||||
defaultVisible: true,
|
||||
alwaysVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'image_type',
|
||||
title: '桌面类型',
|
||||
dataIndex: 'image_type',
|
||||
width: 80,
|
||||
render: (text: number) => {
|
||||
const key = text as keyof typeof IMAGES_TYPE_MAP;
|
||||
return <Tooltip>{IMAGES_TYPE_MAP[key] || '--'}</Tooltip>;
|
||||
},
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'storage_path',
|
||||
title: '模板存放路径',
|
||||
dataIndex: 'storage_path',
|
||||
width: 140,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'bt_path',
|
||||
title: 'BT路径',
|
||||
dataIndex: 'bt_path',
|
||||
width: 140,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'image_version',
|
||||
title: '镜像版本',
|
||||
dataIndex: 'image_version',
|
||||
width: 100,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'os_version',
|
||||
title: '操作系统',
|
||||
dataIndex: 'os_version',
|
||||
width: 100,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'image_status',
|
||||
title: '镜像状态',
|
||||
dataIndex: 'image_status',
|
||||
width: 80,
|
||||
render: (text: number) => <Tooltip>{getStatusTag(text)}</Tooltip>,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'create_time',
|
||||
title: '创建时间',
|
||||
dataIndex: 'create_time',
|
||||
width: 180,
|
||||
render: (text: string) => (
|
||||
<Tooltip>{dayjs(text).format('YYYY-MM-DD HH:mm:ss')}</Tooltip>
|
||||
),
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right' as 'right',
|
||||
render: (_: any, record: IMAGES.ImageItem) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewDetail(record)}
|
||||
title="查看详情"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定要删除这个镜像吗?"
|
||||
description="删除后无法恢复,请谨慎操作。"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" icon={<DeleteOutlined />} title="删除" danger />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
defaultVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 初始化 visibleColumns 状态
|
||||
const initialVisibleColumns = columnConfigs.reduce<Record<string, boolean>>(
|
||||
(acc, column) => {
|
||||
if (!column.alwaysVisible) {
|
||||
acc[column.key] = column.defaultVisible;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
|
||||
initialVisibleColumns,
|
||||
);
|
||||
|
||||
// 重置列设置
|
||||
const resetColumns = () => {
|
||||
setVisibleColumns(initialVisibleColumns);
|
||||
};
|
||||
|
||||
/**
|
||||
* 表格参数转换
|
||||
* @param params - 表格参数对象,包含分页、过滤、排序等信息
|
||||
|
@ -75,7 +216,7 @@ const ImageList: React.FC = () => {
|
|||
filters,
|
||||
sortField,
|
||||
sortOrder,
|
||||
keywords,
|
||||
image_name,
|
||||
...restParams
|
||||
} = params;
|
||||
const result: Record<string, any> = {};
|
||||
|
@ -83,8 +224,8 @@ const ImageList: React.FC = () => {
|
|||
result.page_size = pagination?.pageSize;
|
||||
result.page_num = pagination?.current;
|
||||
|
||||
if (keywords) {
|
||||
result.keywords = keywords;
|
||||
if (image_name) {
|
||||
result.image_name = image_name;
|
||||
}
|
||||
|
||||
if (filters) {
|
||||
|
@ -115,7 +256,7 @@ const ImageList: React.FC = () => {
|
|||
setLoading(true);
|
||||
try {
|
||||
const imagesRes = await getImagesList(params);
|
||||
if (imagesRes.error_code === ERROR_CODE) {
|
||||
if (imagesRes.code == CODE) {
|
||||
setImages(imagesRes.data.data || []);
|
||||
setLoading(false);
|
||||
// 直接使用后端返回的分页信息
|
||||
|
@ -137,16 +278,14 @@ const ImageList: React.FC = () => {
|
|||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// const getStatusTag = (status: string) => {
|
||||
// const statusMap = {
|
||||
// active: { color: 'green', text: '可用' },
|
||||
// inactive: { color: 'red', text: '不可用' },
|
||||
// building: { color: 'orange', text: '构建中' },
|
||||
// };
|
||||
// const config = statusMap[status as keyof typeof statusMap];
|
||||
// return <Tag color={config.color}>{config.text}</Tag>;
|
||||
// };
|
||||
const getStatusTag = (status: number) => {
|
||||
const statusMap = {
|
||||
1: { color: 'green', text: '成功' },
|
||||
2: { color: 'red', text: '失败' },
|
||||
};
|
||||
const config = statusMap[status as keyof typeof statusMap];
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const handleViewDetail = (record: IMAGES.ImageItem) => {
|
||||
setSelectedImage(record);
|
||||
|
@ -173,59 +312,21 @@ const ImageList: React.FC = () => {
|
|||
}));
|
||||
};
|
||||
|
||||
const resetColumns = () => {
|
||||
setVisibleColumns({
|
||||
image_type: true,
|
||||
storage_path: true,
|
||||
bt_path: true,
|
||||
create_time: true,
|
||||
action: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 列设置内容
|
||||
const columnSettingsContent = (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{ padding: '4px 12px' }}>
|
||||
{columnConfigs
|
||||
.filter((config) => !config.alwaysVisible) // 只显示可控制的列
|
||||
.map((config) => (
|
||||
<div key={config.key} style={{ padding: '4px 12px' }}>
|
||||
<Checkbox
|
||||
checked={visibleColumns['image_type']}
|
||||
onChange={(e) => handleColumnChange('image_type', e.target.checked)}
|
||||
checked={visibleColumns[config.key]}
|
||||
onChange={(e) => handleColumnChange(config.key, e.target.checked)}
|
||||
>
|
||||
桌面类型
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div style={{ padding: '4px 12px' }}>
|
||||
<Checkbox
|
||||
checked={visibleColumns['storage_path']}
|
||||
onChange={(e) => handleColumnChange('storage_path', e.target.checked)}
|
||||
>
|
||||
模板存放路径
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div style={{ padding: '4px 12px' }}>
|
||||
<Checkbox
|
||||
checked={visibleColumns['bt_path']}
|
||||
onChange={(e) => handleColumnChange('bt_path', e.target.checked)}
|
||||
>
|
||||
BT路径
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div style={{ padding: '4px 12px' }}>
|
||||
<Checkbox
|
||||
checked={visibleColumns['create_time']}
|
||||
onChange={(e) => handleColumnChange('create_time', e.target.checked)}
|
||||
>
|
||||
创建时间
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div style={{ padding: '4px 12px' }}>
|
||||
<Checkbox
|
||||
checked={visibleColumns['action']}
|
||||
onChange={(e) => handleColumnChange('action', e.target.checked)}
|
||||
>
|
||||
操作
|
||||
{config.title}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
|
@ -241,72 +342,33 @@ const ImageList: React.FC = () => {
|
|||
);
|
||||
|
||||
// 根据visibleColumns过滤显示的列
|
||||
const filteredColumns = [
|
||||
{
|
||||
title: '镜像名称',
|
||||
dataIndex: 'image_name',
|
||||
key: 'image_name',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '桌面类型',
|
||||
dataIndex: 'image_type',
|
||||
key: 'image_type',
|
||||
width: 200,
|
||||
render: (text: number) => {
|
||||
const key = text as keyof typeof IMAGES_TYPE_MAP;
|
||||
return <Tooltip>{IMAGES_TYPE_MAP[key] || '--'}</Tooltip>;
|
||||
},
|
||||
...(visibleColumns['image_type'] ? {} : { hidden: true }),
|
||||
},
|
||||
{
|
||||
title: '模板存放路径',
|
||||
dataIndex: 'storage_path',
|
||||
key: 'storage_path',
|
||||
width: 200,
|
||||
...(visibleColumns['storage_path'] ? {} : { hidden: true }),
|
||||
},
|
||||
{
|
||||
title: 'BT路径',
|
||||
dataIndex: 'bt_path',
|
||||
key: 'bt_path',
|
||||
width: 200,
|
||||
...(visibleColumns['bt_path'] ? {} : { hidden: true }),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'create_time',
|
||||
key: 'create_time',
|
||||
width: 180,
|
||||
render:(text:string)=><Tooltip>{dayjs(text).format('YYYY-MM-DD HH:mm:ss')}</Tooltip>,
|
||||
...(visibleColumns['create_time'] ? {} : { hidden: true }),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
render: (_: any, record: IMAGES.ImageItem) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewDetail(record)}
|
||||
title="查看详情"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定要删除这个镜像吗?"
|
||||
description="删除后无法恢复,请谨慎操作。"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" icon={<DeleteOutlined />} title="删除" danger />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
...(visibleColumns['action'] ? {} : { hidden: true }),
|
||||
},
|
||||
].filter((column) => !column.hidden);
|
||||
const filteredColumns = columnConfigs
|
||||
.map((config) => {
|
||||
// 对于始终显示的列
|
||||
if (config.alwaysVisible) {
|
||||
return {
|
||||
title: config.title,
|
||||
dataIndex: config.dataIndex,
|
||||
key: config.key,
|
||||
width: config.width,
|
||||
render: config.render,
|
||||
fixed: config.fixed,
|
||||
hidden: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 对于可控制显示/隐藏的列
|
||||
return {
|
||||
title: config.title,
|
||||
dataIndex: config.dataIndex,
|
||||
key: config.key,
|
||||
width: config.width,
|
||||
render: config.render,
|
||||
fixed: config.fixed,
|
||||
...(visibleColumns[config.key] ? {} : { hidden: true }),
|
||||
};
|
||||
})
|
||||
.filter((column) => !column.hidden) as TableColumn[];
|
||||
|
||||
// 处理表格分页、过滤和排序变化
|
||||
const handleTableChange: TableProps<IMAGES.ImageItem>['onChange'] = (
|
||||
|
@ -368,7 +430,7 @@ const ImageList: React.FC = () => {
|
|||
...prev.pagination,
|
||||
current: 1,
|
||||
},
|
||||
keywords: value,
|
||||
image_name: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { request } from '@umijs/max';
|
||||
|
||||
const BASE_URL = '/api/v1/images';
|
||||
const BASE_URL = '/api/files';
|
||||
|
||||
// 根据终端序列号查询镜像列表
|
||||
export async function getImagesList(params:any) {
|
||||
|
@ -15,7 +15,10 @@ export async function getImagesList(params:any) {
|
|||
}
|
||||
|
||||
export async function uploadChunkAPI(formData: FormData, signal?: AbortSignal) {
|
||||
return request<any>(`${BASE_URL}/file/chunk/upload`, {
|
||||
console.log('formData', formData);
|
||||
|
||||
// return request<any>(`/api/v1/images/file/chunk/upload`, {
|
||||
return request<any>(`${BASE_URL}/upload-chunk`, {
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
signal, // 添加 signal 支持
|
||||
|
|
|
@ -6,6 +6,9 @@ declare namespace IMAGES {
|
|||
image_type: number;
|
||||
storage_path: string;
|
||||
bt_path: string;
|
||||
image_version: string;
|
||||
os_version: string;
|
||||
image_status: number;
|
||||
create_time: string;
|
||||
description?: string;
|
||||
desktopType?: string;
|
||||
|
@ -22,25 +25,40 @@ declare namespace IMAGES {
|
|||
filters?: Record<string, any>;
|
||||
sortOrder?: 'ascend' | 'descend' | null;
|
||||
sortField?: string | number | null;
|
||||
keywords?: string; // 添加关键词字段
|
||||
image_name?: string; // 添加关键词字段
|
||||
image_status?: string;
|
||||
}
|
||||
|
||||
// interface DeviceParams{
|
||||
// device_id: string;
|
||||
// }
|
||||
|
||||
interface Images_ListInfo {
|
||||
error_code: string;
|
||||
code: string;
|
||||
message: string;
|
||||
data: {
|
||||
paging: {
|
||||
total: number;
|
||||
total_num?: number;
|
||||
page_num: number;
|
||||
page_size: number;
|
||||
};
|
||||
data: ImageItem[];
|
||||
};
|
||||
}
|
||||
|
||||
/**上传文件分片 */
|
||||
interface ImportModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onImportSuccess?: () => void;
|
||||
}
|
||||
type ImageDetailFieldWithoutRender = {
|
||||
label: string;
|
||||
key: keyof ImageType; // 假设 ImageType 是 selectedImage 的类型
|
||||
};
|
||||
|
||||
type ImageDetailFieldWithRender = {
|
||||
label: string;
|
||||
key: keyof ImageType;
|
||||
render: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
export type ImageDetailField =
|
||||
| ImageDetailFieldWithoutRender
|
||||
| ImageDetailFieldWithRender;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
import {
|
||||
CHUNK_SIZE_CONFIG,
|
||||
MD5_CHUNK_SIZE_CONFIG,
|
||||
TIME_FORMAT_CONFIG
|
||||
} from '@/constants/images.constants';
|
||||
import SparkMD5 from 'spark-md5';
|
||||
|
||||
/**
|
||||
* 根据文件大小获取网络传输分片大小
|
||||
* @param fileSize 文件大小(字节)
|
||||
* @returns 分片大小(字节)
|
||||
*/
|
||||
export const getChunkSize = (fileSize: number): number => {
|
||||
for (const config of CHUNK_SIZE_CONFIG) {
|
||||
if (fileSize <= config.maxSize) {
|
||||
return config.chunkSize;
|
||||
}
|
||||
}
|
||||
return CHUNK_SIZE_CONFIG[CHUNK_SIZE_CONFIG.length - 1].chunkSize; // 默认返回最大配置
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据文件大小获取MD5计算内存分块大小
|
||||
* @param fileSize 文件大小(字节)
|
||||
* @returns MD5分块大小(字节)
|
||||
*/
|
||||
export const getMD5ChunkSize = (fileSize: number): number => {
|
||||
for (const config of MD5_CHUNK_SIZE_CONFIG) {
|
||||
if (fileSize <= config.maxSize) {
|
||||
return config.chunkSize;
|
||||
}
|
||||
}
|
||||
return MD5_CHUNK_SIZE_CONFIG[MD5_CHUNK_SIZE_CONFIG.length - 1].chunkSize; // 默认返回最大配置
|
||||
};
|
||||
|
||||
/**
|
||||
* 分块计算文件MD5
|
||||
* @param file 文件对象
|
||||
* @param fileSize 文件大小
|
||||
* @param chunkSize 分块大小
|
||||
* @returns Promise<string> MD5值
|
||||
*/
|
||||
export const calculateMD5InChunks = (
|
||||
file: Blob,
|
||||
fileSize: number,
|
||||
chunkSize: number = getMD5ChunkSize(fileSize)
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const spark = new SparkMD5.ArrayBuffer();
|
||||
const fileReader = new FileReader();
|
||||
let currentChunk = 0;
|
||||
const chunks = Math.ceil(file.size / chunkSize);
|
||||
|
||||
fileReader.onload = (e) => {
|
||||
try {
|
||||
spark.append(e.target?.result as ArrayBuffer);
|
||||
currentChunk++;
|
||||
|
||||
if (currentChunk < chunks) {
|
||||
loadNext();
|
||||
} else {
|
||||
const hash = spark.end();
|
||||
spark.destroy(); // 释放内存
|
||||
resolve(hash);
|
||||
}
|
||||
} catch (error) {
|
||||
spark.destroy();
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
fileReader.onerror = () => {
|
||||
spark.destroy();
|
||||
reject(new Error('计算MD5失败'));
|
||||
};
|
||||
|
||||
const loadNext = () => {
|
||||
const start = currentChunk * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
fileReader.readAsArrayBuffer(file.slice(start, end));
|
||||
};
|
||||
|
||||
loadNext();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化时间显示
|
||||
* @param seconds 秒数
|
||||
* @returns 格式化后的时间字符串
|
||||
*/
|
||||
export const formatTime = (seconds: number): string => {
|
||||
const { HOUR_LABEL, MINUTE_LABEL, SECOND_LABEL } = TIME_FORMAT_CONFIG;
|
||||
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}${HOUR_LABEL}${m}${MINUTE_LABEL}${s}${SECOND_LABEL}`;
|
||||
} else if (m > 0) {
|
||||
return `${m}${MINUTE_LABEL}${s}${SECOND_LABEL}`;
|
||||
} else {
|
||||
return `${s}${SECOND_LABEL}`;
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue