diff --git a/web-fe/.umirc.ts b/web-fe/.umirc.ts index e0101c2..89b80b9 100644 --- a/web-fe/.umirc.ts +++ b/web-fe/.umirc.ts @@ -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', + }, }, }); diff --git a/web-fe/mock/images.ts b/web-fe/mock/images.ts index 6698b2e..c9e6f20 100644 --- a/web-fe/mock/images.ts +++ b/web-fe/mock/images.ts @@ -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', }); } }, diff --git a/web-fe/src/constants/images.constants.ts b/web-fe/src/constants/images.constants.ts index 9654a10..c5e99e6 100644 --- a/web-fe/src/constants/images.constants.ts +++ b/web-fe/src/constants/images.constants.ts @@ -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; \ No newline at end of file +} 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; \ No newline at end of file diff --git a/web-fe/src/pages/images/components/modalShow/modalShow.tsx b/web-fe/src/pages/images/components/modalShow/modalShow.tsx index a97fb41..8c148eb 100644 --- a/web-fe/src/pages/images/components/modalShow/modalShow.tsx +++ b/web-fe/src/pages/images/components/modalShow/modalShow.tsx @@ -5,71 +5,56 @@ 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; /**详情弹窗 */ const ModalDetailShow = (props: any) => { const { detailVisible, setDetailVisible, selectedImage, title } = props; - return ( - setDetailVisible(false)} - footer={[ - , - ]} - width={600} - > - {selectedImage && ( -
-
- - {selectedImage.image_name} -
-
- - - {IMAGES_TYPE_MAP[ - selectedImage.image_type as keyof typeof IMAGES_TYPE_MAP - ] || '--'} - -
-
- - {selectedImage.storage_path} -
-
- - {selectedImage.bt_path} -
- {/*
- - {getStatusTag(selectedImage.status)} -
*/} -
- - {selectedImage.create_time} -
- {/*
- -

{selectedImage.description}

-
*/} -
- )} -
- ); + return ( + setDetailVisible(false)} + footer={[ + , + ]} + width={600} + > + {selectedImage && ( +
+ {IMAGE_DETAIL_FIELDS.map((field) => ( +
+ + + {'render' in field && field.render + ? field.render(selectedImage[field.key]) + : selectedImage[field.key] || '--'} + +
+ ))} +
+ )} +
+ ); }; -/**上传文件分片 */ -interface ImportModalProps { - visible: boolean; - onCancel: () => void; - onImportSuccess?: () => void; -} -const ImportModal: React.FC = ({ +const ImportModal: React.FC = ({ visible, onCancel, onImportSuccess, @@ -80,23 +65,10 @@ const ImportModal: React.FC = ({ '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>([]); // 上传队列 const completedChunks = useRef(0); // 已上传的分片数量 const totalChunks = useRef(0); // 总分片数量 @@ -104,12 +76,16 @@ const ImportModal: React.FC = ({ const fileName = useRef(''); // 文件名 const fileSize = useRef(0); // 文件大小 const abortController = useRef(null); // 用于取消上传 + // 上传镜像时间相关 + const [uploadStartTime, setUploadStartTime] = useState(null); + const [elapsedTime, setElapsedTime] = useState(0); + const timerRef = useRef(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 = ({ 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 = ({ } }, [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 => { - 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 => { try { @@ -209,7 +136,50 @@ const ImportModal: React.FC = ({ 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 = ({ // 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 = ({ // 只有在没有错误的情况下才更新进度 if (!hasError) { completedChunks.current += 1; - const progress = Math.round( - (completedChunks.current / totalChunks.current) * 100, - ); + // 修改进度计算方式,只有在真正完成时才显示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 = ({ if (abortController.current) { abortController.current.abort(); } + + // 停止计时器 + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } } return; } @@ -302,30 +291,29 @@ const ImportModal: React.FC = ({ 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 = ({ 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 = ({ } 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 = ({ // } // 检查文件大小 - 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 = ({ 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 = ({

点击选择文件或拖拽文件到此区域上传

{/* 支持文件扩展名:.tar.gz, .iso, .qcow2,大小限制为20G */} - 大小限制为20G + 大小限制为50G

@@ -466,9 +466,12 @@ const ImportModal: React.FC = ({ - {uploadMessage && ( + {(uploadMessage || isUploading) && (
- {uploadMessage} + {uploadMessage || '正在上传...'} +
+ 已用时间: {formatTime(elapsedTime)} +
)} {isUploading && ( diff --git a/web-fe/src/pages/images/index.tsx b/web-fe/src/pages/images/index.tsx index 151d2d7..7885900 100644 --- a/web-fe/src/pages/images/index.tsx +++ b/web-fe/src/pages/images/index.tsx @@ -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([]); const [loading, setLoading] = useState(false); @@ -41,18 +64,9 @@ const ImageList: React.FC = () => { }, }); - // 列显示控制状态 - const [visibleColumns, setVisibleColumns] = useState>( - { - 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 {IMAGES_TYPE_MAP[key] || '--'}; + }, + 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) => {getStatusTag(text)}, + defaultVisible: true, + }, + { + key: 'create_time', + title: '创建时间', + dataIndex: 'create_time', + width: 180, + render: (text: string) => ( + {dayjs(text).format('YYYY-MM-DD HH:mm:ss')} + ), + defaultVisible: true, + }, + { + key: 'action', + title: '操作', + width: 100, + fixed: 'right' as 'right', + render: (_: any, record: IMAGES.ImageItem) => ( + +