feat(镜像管理): 分片策略+配置

master
chenyt 2025-08-08 18:23:23 +08:00
parent cf036ae0fd
commit 9fb05a44cb
8 changed files with 616 additions and 348 deletions

View File

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

View File

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

View File

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

View File

@ -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 && (

View File

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

View File

@ -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 支持

View File

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

View File

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