fix(前端): 镜像列表修改
parent
218150f8a0
commit
e656e20450
|
@ -19,13 +19,15 @@ export default {
|
|||
image_name: `Win版 PR 2024 【支持win10、win11】.zip${
|
||||
(page_num - 1) * page_size + i
|
||||
}`,
|
||||
image_type: getRandomFormat(),
|
||||
image_file_name: `Win版 PR 2024 【支持win10、win11】.zip`,
|
||||
// image_type: getRandomFormat(),
|
||||
bt_path: `https://releases.ubuntu.com/20.04.6/ubuntu-20.04.6-desktop-amd64.iso.torrent`,
|
||||
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(),
|
||||
description: `这是一个测试镜像文件,ID: ${i}`,
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export const CODE = "200";
|
||||
export const CODE = '200';
|
||||
|
||||
export const IMAGES_TYPE_MAP = {
|
||||
1: 'VHD',
|
||||
|
@ -38,6 +38,51 @@ export const MD5_CHUNK_SIZE_CONFIG = [
|
|||
{ maxSize: Infinity, chunkSize: 8 * 1024 * 1024 }, // >30GB : 8MB
|
||||
];
|
||||
|
||||
// 表单字段配置
|
||||
export const FORM_FIELDS_CONFIG: IMAGES.FormFieldConfig[] = [
|
||||
{
|
||||
name: 'image_name',
|
||||
label: '镜像名称',
|
||||
type: 'input',
|
||||
rules: [{ required: true, message: '请输入镜像名称' }],
|
||||
props: {
|
||||
placeholder: '请输入镜像名称',
|
||||
maxLength: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image_version',
|
||||
label: '版本',
|
||||
type: 'input',
|
||||
rules: [{ required: true, message: '请输入版本号' }],
|
||||
props: {
|
||||
placeholder: '请输入版本号',
|
||||
maxLength: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'os_version',
|
||||
label: '操作系统',
|
||||
type: 'select',
|
||||
rules: [{ required: true, message: '请选择操作系统' }],
|
||||
props: {
|
||||
placeholder: '请选择操作系统',
|
||||
options: [
|
||||
{ value: 'windows', label: 'Windows' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: '描述',
|
||||
type: 'textarea',
|
||||
props: {
|
||||
placeholder: '请输入描述',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 上传配置
|
||||
*/
|
||||
|
@ -57,6 +102,14 @@ export const UPLOAD_STATUS_MAP = {
|
|||
ERROR: 'error',
|
||||
} as const;
|
||||
|
||||
// 上传状态message信息显示样式映射
|
||||
export const MESSAGE_STYLE_MAP = {
|
||||
ready: { color: '#666' },
|
||||
uploading: { color: '#1890ff' },
|
||||
success: { color: '#52c41a' },
|
||||
error: { color: '#f5222d' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 时间格式化配置
|
||||
*/
|
||||
|
@ -66,18 +119,29 @@ export const TIME_FORMAT_CONFIG = {
|
|||
SECOND_LABEL: '秒',
|
||||
};
|
||||
|
||||
// 镜像状态映射
|
||||
export const STATUS_MAP = {
|
||||
1: { color: 'green', text: '成功' },
|
||||
2: { color: 'red', text: '失败' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 镜像详情字段配置
|
||||
*/
|
||||
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: 'image_file_name' },
|
||||
// {
|
||||
// label: '桌面类型:',
|
||||
// key: 'image_type',
|
||||
// render: (value: number) =>
|
||||
// IMAGES_TYPE_MAP[value as keyof typeof IMAGES_TYPE_MAP] || '--',
|
||||
// },
|
||||
{ label: '镜像版本:', key: 'image_version' },
|
||||
{ label: '操作系统:', key: 'os_version' },
|
||||
{ label: '镜像状态:', key: 'image_status' },
|
||||
{ label: '上传时间:', key: 'create_time' },
|
||||
{ label: '模板存放路径:', key: 'storage_path' },
|
||||
{ label: 'BT路径:', key: 'bt_path' },
|
||||
{ label: '创建时间:', key: 'create_time' },
|
||||
{ label: '描述:', key: 'description' },
|
||||
] as const;
|
|
@ -1,19 +1,20 @@
|
|||
import { uploadChunkAPI, cancelUploadImagesAPI } from '@/services/images';
|
||||
import { Alert, Button, message, Modal, Progress, Upload } from 'antd';
|
||||
import { UploadProps } from 'antd/lib/upload';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
CODE,
|
||||
IMAGE_DETAIL_FIELDS,
|
||||
STATUS_MAP,
|
||||
UPLOAD_CONFIG,
|
||||
UPLOAD_STATUS_MAP,
|
||||
} from '@/constants/images.constants';
|
||||
import {
|
||||
getChunkSize,
|
||||
calculateMD5InChunks,
|
||||
formatTime,
|
||||
getChunkSize,
|
||||
} from '@/pages/images/utils/images';
|
||||
import { cancelUploadImagesAPI, uploadChunkAPI } from '@/services/images';
|
||||
import { Alert, Button, message, Modal, Progress, Tag, Upload } from 'antd';
|
||||
import { UploadProps } from 'antd/lib/upload';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const { READY, UPLOADING, SUCCESS, ERROR } = UPLOAD_STATUS_MAP;
|
||||
|
||||
|
@ -21,37 +22,78 @@ const { Dragger } = Upload;
|
|||
/**详情弹窗 */
|
||||
const ModalDetailShow = (props: any) => {
|
||||
const { detailVisible, setDetailVisible, selectedImage, title } = props;
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDetailVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={600}
|
||||
>
|
||||
{selectedImage && (
|
||||
<div className="image-detail">
|
||||
{IMAGE_DETAIL_FIELDS.map((field) => (
|
||||
<div className="detail-item" key={String(field.key)}>
|
||||
<label>{field.label}</label>
|
||||
<span>
|
||||
{'render' in field && field.render
|
||||
? field.render(selectedImage[field.key])
|
||||
: selectedImage[field.key] || '--'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
const longTextFields = ['bt_path', 'description', 'storage_path'];
|
||||
const getStatusTag = (status: number) => {
|
||||
const config = STATUS_MAP[status as keyof typeof STATUS_MAP];
|
||||
return <Tag color={config?.color}>{config.text}</Tag>;
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDetailVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={800} // 增加宽度以容纳两列布局
|
||||
>
|
||||
{selectedImage && (
|
||||
<div
|
||||
className="image-detail"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)', // 两列布局
|
||||
gap: '16px', // 间距
|
||||
}}
|
||||
>
|
||||
{IMAGE_DETAIL_FIELDS.map((field) => (
|
||||
<div
|
||||
key={String(field.key)}
|
||||
style={{
|
||||
gridColumn: longTextFields.includes(String(field.key))
|
||||
? '1 / -1'
|
||||
: 'auto', // 长文本字段占整行
|
||||
display: 'flex',
|
||||
minWidth: 0, // 防止内容溢出
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: 'rgba(0, 0, 0, 0.85)',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: longTextFields.includes(String(field.key))
|
||||
? 1
|
||||
: 2, // 长文本字段只显示一行
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{'render' in field && field.render
|
||||
? field.render(selectedImage[field.key])
|
||||
: field.key === 'image_status'
|
||||
? getStatusTag(selectedImage[field.key])
|
||||
: selectedImage[field.key] || '--'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
|
||||
visible,
|
||||
onCancel,
|
||||
|
@ -88,7 +130,6 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
|
|||
e.preventDefault();
|
||||
e.returnValue = '镜像正在上传中,确定要离开吗?';
|
||||
return e.returnValue;
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -142,7 +183,7 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
|
|||
try {
|
||||
// 检查是否已中止
|
||||
if (abortController.current?.signal.aborted) {
|
||||
return {success:false};
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// 更新状态,提示正在计算MD5
|
||||
|
@ -210,7 +251,7 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
|
|||
// 检查是否因为中止导致的错误
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
// console.log(`上传分片 ${index} 被用户取消`);
|
||||
return {success:false};
|
||||
return { success: false };
|
||||
}
|
||||
// console.error(`上传分片 ${index} 失败:`, error);
|
||||
return { success: false };
|
||||
|
@ -295,7 +336,7 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
|
|||
// 2. 开始上传
|
||||
const startUpload = async (file: File) => {
|
||||
try {
|
||||
isManualCancel.current=false;
|
||||
isManualCancel.current = false;
|
||||
uploadCompletedRef.current = false;
|
||||
setIsUploading(true);
|
||||
setUploadStatus(UPLOADING);
|
||||
|
@ -313,7 +354,7 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
|
|||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
// 重新计算开始时间,基于当前时间和已用时间
|
||||
setElapsedTime((prev) => prev + 1);
|
||||
setElapsedTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
// 初始化状态
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.ant-modal {
|
||||
.ant-modal-header {
|
||||
margin-bottom: 20px !important;
|
||||
.ant-modal-title {
|
||||
font-weight: 500 !important;
|
||||
color: #17233d !important;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,748 @@
|
|||
import {
|
||||
FORM_FIELDS_CONFIG,
|
||||
MESSAGE_STYLE_MAP,
|
||||
UPLOAD_CONFIG,
|
||||
UPLOAD_STATUS_MAP,
|
||||
} from '@/constants/images.constants';
|
||||
import {
|
||||
calculateMD5InChunks,
|
||||
formatTime,
|
||||
getChunkSize,
|
||||
} from '@/pages/images/utils/images';
|
||||
import { cancelUploadImagesAPI, uploadChunkAPI } from '@/services/images';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CloseOutlined,
|
||||
FileOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Progress,
|
||||
Select,
|
||||
Space,
|
||||
Steps,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import { UploadProps } from 'antd/lib/upload';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import './uploadFileModal.less';
|
||||
|
||||
const { Step } = Steps;
|
||||
const { READY, UPLOADING, SUCCESS, ERROR } = UPLOAD_STATUS_MAP;
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
|
||||
visible,
|
||||
onCancel,
|
||||
onImportSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
// 上传相关状态
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<
|
||||
'ready' | 'uploading' | 'success' | 'error'
|
||||
>('ready');
|
||||
const [uploadMessage, setUploadMessage] = useState('');
|
||||
|
||||
// 分片上传相关
|
||||
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); // 总分片数量
|
||||
const fileId = useRef<string>(''); // 文件id
|
||||
const fileName = useRef<string>(''); // 文件名
|
||||
const fileSize = useRef<number>(0); // 文件大小
|
||||
const abortController = useRef<AbortController | null>(null); // 用于取消上传
|
||||
// 上传镜像时间相关
|
||||
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
||||
const [elapsedTime, setElapsedTime] = useState<number>(0); // 上传时间
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// 上传完成状态
|
||||
const uploadCompletedRef = useRef(false);
|
||||
// 手动取消状态
|
||||
const isManualCancel = useRef(false); // 是否手动取消上传
|
||||
// 是否已开始上传
|
||||
const [hasStartedUpload, setHasStartedUpload] = useState(false);
|
||||
|
||||
// 处理页面刷新/关闭
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isUploading && !uploadCompletedRef.current) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '镜像正在上传中,确定要离开吗?';
|
||||
return e.returnValue;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [isUploading]);
|
||||
|
||||
// 计时器清理(仅组件卸载时执行)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
resetOtherStates(); // 每次打开弹窗时重置状态
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const resetOtherStates = () => {
|
||||
setCurrentFile(null);
|
||||
setCurrentStep(0);
|
||||
form.resetFields();
|
||||
}
|
||||
|
||||
// 添加重置状态函数
|
||||
const resetState = () => {
|
||||
setUploadProgress(0);
|
||||
setIsUploading(false);
|
||||
setUploadStatus(READY);
|
||||
setUploadMessage('');
|
||||
setElapsedTime(0);
|
||||
setHasStartedUpload(false);
|
||||
|
||||
uploadCompletedRef.current = false;
|
||||
isManualCancel.current = false;
|
||||
completedChunks.current = 0;
|
||||
totalChunks.current = 0;
|
||||
fileId.current = '';
|
||||
fileName.current = '';
|
||||
fileSize.current = 0;
|
||||
uploadQueue.current = [];
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (abortController.current) {
|
||||
abortController.current.abort();
|
||||
abortController.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const validateStepOne = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
return FORM_FIELDS_CONFIG.every((field) => {
|
||||
if (field.rules?.some((rule) => rule.required)) {
|
||||
return values[field.name];
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 修改下一步按钮处理
|
||||
const handleNext = async () => {
|
||||
const isValid = await validateStepOne();
|
||||
if (isValid) {
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
message.error('请填写完整信息');
|
||||
}
|
||||
};
|
||||
|
||||
// 修改上一步按钮处理
|
||||
const handlePrev = () => {
|
||||
if (!isUploading) {
|
||||
setCurrentStep(0);
|
||||
}
|
||||
};
|
||||
// 根据配置渲染表单字段
|
||||
const renderFormFields = () => {
|
||||
return FORM_FIELDS_CONFIG.map((field) => {
|
||||
switch (field.type) {
|
||||
case 'input':
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
rules={field.rules}
|
||||
>
|
||||
<Input {...field.props} />
|
||||
</Form.Item>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
rules={field.rules}
|
||||
>
|
||||
<Select
|
||||
placeholder={field.props.placeholder || '请选择'}
|
||||
// 添加options存在性检查
|
||||
options={field.props.options || []}
|
||||
{...field.props}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case 'textarea':
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
rules={field.rules}
|
||||
>
|
||||
<Input.TextArea {...field.props} />
|
||||
</Form.Item>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 检查表单是否完整
|
||||
const isFormValid = () => {
|
||||
const values = form.getFieldsValue();
|
||||
return (
|
||||
FORM_FIELDS_CONFIG.every((field) => {
|
||||
// 检查必填字段
|
||||
if (field.rules?.some((rule) => rule.required)) {
|
||||
return values[field.name];
|
||||
}
|
||||
return true;
|
||||
}) && currentFile
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!hasStartedUpload && currentFile) {
|
||||
// 如果还没开始上传且有文件,则开始上传
|
||||
startUpload(currentFile);
|
||||
setHasStartedUpload(true);
|
||||
} else if (uploadStatus === 'success') {
|
||||
// 如果上传已完成,则提交表单
|
||||
form.validateFields().then((values) => {
|
||||
const formData = {
|
||||
...values,
|
||||
file: {
|
||||
name: fileName.current,
|
||||
size: fileSize.current,
|
||||
id: fileId.current,
|
||||
},
|
||||
};
|
||||
console.log('提交数据:', formData);
|
||||
message.success('镜像提交成功');
|
||||
onCancel();
|
||||
});
|
||||
} else if (uploadStatus === 'error') {
|
||||
// 如果是上传失败状态,点击"重新上传"
|
||||
if (currentFile) {
|
||||
resetState(); // 重置上传状态但不重置文件
|
||||
startUpload(currentFile);
|
||||
setHasStartedUpload(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 上传单个分片
|
||||
const uploadChunk = async (
|
||||
chunk: Blob,
|
||||
index: number,
|
||||
): Promise<IMAGES.UploadChunkResult> => {
|
||||
try {
|
||||
if (abortController.current?.signal.aborted) {
|
||||
return { success: false };
|
||||
}
|
||||
// 获取表单中的镜像信息
|
||||
const formValues = form.getFieldsValue([
|
||||
'image_name',
|
||||
'image_version',
|
||||
'os_version',
|
||||
]);
|
||||
|
||||
const chunkMD5 = await calculateMD5InChunks(chunk, fileSize.current);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file_id', fileId.current);
|
||||
formData.append('file_name', fileName.current);
|
||||
formData.append('file_size', fileSize.current.toString());
|
||||
formData.append('chunk', chunk);
|
||||
formData.append('chunk_md5', chunkMD5);
|
||||
formData.append('chunk_size', chunk.size.toString());
|
||||
formData.append('shard_index', index.toString());
|
||||
formData.append('shard_total', totalChunks.current.toString());
|
||||
|
||||
// 添加镜像信息参数
|
||||
formData.append('image_name', formValues.image_name);
|
||||
formData.append('image_version', formValues.image_version);
|
||||
formData.append('os_version', formValues.os_version);
|
||||
|
||||
const response = await uploadChunkAPI(
|
||||
formData,
|
||||
abortController.current?.signal,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
if (response.status === 'completed') {
|
||||
uploadCompletedRef.current = true; // 设置上传完成状态
|
||||
isManualCancel.current = false; // 重置手动取消状态
|
||||
// 文件上传完成,设置进度为100%
|
||||
setUploadProgress(100); // 这里已经正确设置了100%
|
||||
setIsUploading(false);
|
||||
setUploadStatus(SUCCESS);
|
||||
setUploadMessage('文件上传成功!请稍后查看列表。');
|
||||
message.success('文件上传成功!系统正在处理,请稍后查看列表。');
|
||||
onImportSuccess?.();
|
||||
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} else {
|
||||
console.error(`上传分片 ${index} 失败:`, response.message);
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return { success: false };
|
||||
}
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 处理上传队列:每次处理最多 MAX_CONCURRENT 个分片
|
||||
const processUploadQueue = async () => {
|
||||
const promises: Promise<void>[] = [];
|
||||
let hasError = false;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < Math.min(MAX_CONCURRENT, uploadQueue.current.length);
|
||||
i++
|
||||
) {
|
||||
const processChunk = async () => {
|
||||
while (
|
||||
uploadQueue.current.length > 0 &&
|
||||
!abortController.current?.signal.aborted &&
|
||||
!hasError
|
||||
) {
|
||||
const chunkItem = uploadQueue.current.shift();
|
||||
if (chunkItem) {
|
||||
const { chunk, index } = chunkItem;
|
||||
const result = await uploadChunk(chunk, index);
|
||||
|
||||
if (result.success) {
|
||||
// 只有在没有错误的情况下才更新进度
|
||||
if (!hasError) {
|
||||
completedChunks.current += 1;
|
||||
// 修改进度计算方式,只有在真正完成时才显示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);
|
||||
}
|
||||
} else {
|
||||
if (!abortController.current?.signal.aborted && !hasError) {
|
||||
hasError = true;
|
||||
setIsUploading(false);
|
||||
setUploadStatus(ERROR);
|
||||
if (!isManualCancel.current) {
|
||||
setUploadMessage(result.message || '文件上传失败');
|
||||
message.error(result.message || '文件上传失败');
|
||||
}
|
||||
if (abortController.current) {
|
||||
abortController.current.abort();
|
||||
}
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
promises.push(processChunk());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
// 2. 开始上传
|
||||
const startUpload = async (file: File) => {
|
||||
try {
|
||||
isManualCancel.current = false;
|
||||
uploadCompletedRef.current = false;
|
||||
setIsUploading(true);
|
||||
setUploadStatus(UPLOADING);
|
||||
setUploadProgress(0);
|
||||
setUploadMessage('正在准备上传...');
|
||||
setCurrentFile(file);
|
||||
|
||||
// 启动计时器
|
||||
// 启动计时器
|
||||
setElapsedTime(0);
|
||||
|
||||
// 立即更新一次时间
|
||||
setElapsedTime(Math.floor((Date.now() - Date.now()) / 1000));
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
// 重新计算开始时间,基于当前时间和已用时间
|
||||
setElapsedTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
// 初始化分片状态
|
||||
completedChunks.current = 0;
|
||||
fileSize.current = file.size;
|
||||
fileName.current = file.name;
|
||||
fileId.current = uuidv4();
|
||||
const chunkSize = getChunkSize(file.size);
|
||||
totalChunks.current = Math.ceil(file.size / chunkSize);
|
||||
uploadQueue.current = [];
|
||||
setUploadMessage(`正在分析文件... `);
|
||||
|
||||
// 切割文件为分片
|
||||
for (let i = 0; i < totalChunks.current; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
uploadQueue.current.push({ chunk, index: i + 1 });
|
||||
}
|
||||
|
||||
setUploadMessage(`开始上传文件... `);
|
||||
// 创建新的AbortController
|
||||
abortController.current = new AbortController();
|
||||
|
||||
// 开始处理上传队列
|
||||
await processUploadQueue();
|
||||
} catch (error) {
|
||||
console.error('上传准备失败:', error);
|
||||
setIsUploading(false);
|
||||
setUploadStatus(ERROR);
|
||||
setUploadMessage('上传准备失败,请重试');
|
||||
message.error('上传准备失败');
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 1. 处理文件上传
|
||||
const handleFileUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
// 检查文件扩展名
|
||||
// const allowedExtensions = ['.tar.gz', '.iso', '.qcow2'];
|
||||
// const fileExtension = file.name
|
||||
// .substring(file.name.lastIndexOf('.'))
|
||||
// .toLowerCase();
|
||||
// const isAllowedExtension = allowedExtensions.some((ext) =>
|
||||
// file.name.toLowerCase().endsWith(ext),
|
||||
// );
|
||||
|
||||
// if (!isAllowedExtension) {
|
||||
// message.error(
|
||||
// '不支持的文件类型,请上传 .tar.gz, .iso, .qcow2 格式的文件',
|
||||
// );
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > UPLOAD_CONFIG.MAX_FILE_SIZE) {
|
||||
// 50GB
|
||||
message.error('文件大小不能超过50GB');
|
||||
return false;
|
||||
}
|
||||
// 设置文件但不立即上传
|
||||
setCurrentFile(file);
|
||||
setHasStartedUpload(false);
|
||||
return false;
|
||||
// // 开始上传流程
|
||||
// startUpload(file);
|
||||
// return false; // 阻止默认上传行为
|
||||
};
|
||||
|
||||
// 取消上传
|
||||
const cancelUpload = async () => {
|
||||
isManualCancel.current = true;
|
||||
uploadCompletedRef.current = true;
|
||||
uploadQueue.current = [];
|
||||
|
||||
const oldController = abortController.current;
|
||||
abortController.current = new AbortController();
|
||||
if (oldController) {
|
||||
oldController.abort();
|
||||
}
|
||||
|
||||
if (fileId.current) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('file_id', fileId.current);
|
||||
await cancelUploadImagesAPI(params);
|
||||
message.success('上传已取消');
|
||||
} catch (error) {
|
||||
console.error('取消上传API调用失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setIsUploading(false);
|
||||
setUploadStatus(READY);
|
||||
setUploadProgress(0);
|
||||
setHasStartedUpload(false);
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFileSize = (file: File | null) => {
|
||||
if (!file) return '0 Bytes';
|
||||
|
||||
const bytes = file.size;
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const renderUploadInfo = () => (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<FileOutlined style={{ fontSize: 24 }} />
|
||||
<div>
|
||||
<div>{currentFile?.name}</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>
|
||||
{renderFileSize(currentFile)}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
{hasStartedUpload ? (
|
||||
<Space size="large" align="center">
|
||||
<span style={{ ...MESSAGE_STYLE_MAP[uploadStatus], minWidth: 120 }}>
|
||||
{uploadMessage}
|
||||
</span>
|
||||
{/* 修改Progress组件,添加format显示百分比 */}
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
status={uploadStatus === 'error' ? 'exception' : 'normal'}
|
||||
size="small"
|
||||
style={{ width: 120 }}
|
||||
format={(percent) => `${percent}%`}
|
||||
/>
|
||||
<span style={{ position: 'relative', top: 2 }}>
|
||||
{formatTime(elapsedTime)}
|
||||
</span>
|
||||
{isUploading ? (
|
||||
<Button
|
||||
style={{ position: 'relative', top: 2 }}
|
||||
type="text"
|
||||
title="取消上传"
|
||||
onClick={cancelUpload}
|
||||
>
|
||||
取消上传
|
||||
</Button>
|
||||
) : uploadStatus === 'success' ? null : (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
setCurrentFile(null);
|
||||
setHasStartedUpload(false);
|
||||
resetState();
|
||||
}}
|
||||
>
|
||||
移除文件
|
||||
</Button>
|
||||
)}
|
||||
{/* <Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
title="删除文件"
|
||||
// onClick={cancelUpload}
|
||||
disabled={isUploading && uploadStatus !== ERROR}
|
||||
/> */}
|
||||
</Space>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
setCurrentFile(null);
|
||||
setHasStartedUpload(false);
|
||||
}}
|
||||
>
|
||||
移除文件
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 导入弹窗内容
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return (
|
||||
<Form form={form} labelCol={{ span: 3 }} wrapperCol={{ span: 20 }}>
|
||||
{renderFormFields()}
|
||||
</Form>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<Form.Item label="上传系统镜像" required>
|
||||
{!currentFile ? (
|
||||
<Dragger
|
||||
beforeUpload={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
multiple={false}
|
||||
showUploadList={false}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined style={{ fontSize: 48 }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击上传或拖拽文件到此处</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持单个文件上传,大小限制为50G
|
||||
</p>
|
||||
</Dragger>
|
||||
) : (
|
||||
renderUploadInfo()
|
||||
)}
|
||||
</Form.Item>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
if (currentStep === 0) {
|
||||
return [
|
||||
// <Button key="cancel" onClick={handleModalCancel}>
|
||||
// 关闭
|
||||
// </Button>,
|
||||
<Button key="next" type="primary" onClick={handleNext}>
|
||||
下一步
|
||||
</Button>,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
<Button
|
||||
key="back"
|
||||
onClick={handlePrev}
|
||||
disabled={
|
||||
isUploading ||
|
||||
uploadStatus === 'uploading' ||
|
||||
uploadStatus === 'success'
|
||||
}
|
||||
icon={<ArrowLeftOutlined />}
|
||||
>
|
||||
上一步
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
!currentFile || // 没有文件时禁用
|
||||
(hasStartedUpload &&
|
||||
(isUploading ||
|
||||
(uploadStatus === 'error' && uploadProgress > 0))) ||
|
||||
uploadStatus === 'success' // 上传失败且已有进度时不禁用
|
||||
}
|
||||
loading={isUploading}
|
||||
>
|
||||
{hasStartedUpload
|
||||
? uploadStatus === 'success'
|
||||
? '上传成功' // 上传成功时显示"上传成功"
|
||||
: uploadStatus === 'error'
|
||||
? '重新上传' // 上传失败时显示"重新上传"
|
||||
: '上传中...'
|
||||
: '开始上传'}
|
||||
</Button>,
|
||||
<Button key="cancel" onClick={handleModalCancel}>
|
||||
关闭
|
||||
</Button>,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
if (isUploading) {
|
||||
cancelUpload().finally(() => {
|
||||
resetState(); // 确保取消完成后再重置
|
||||
onCancel();
|
||||
});
|
||||
} else {
|
||||
resetState();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新建系统镜像"
|
||||
open={visible}
|
||||
onCancel={handleModalCancel}
|
||||
footer={renderFooter()}
|
||||
width={1000}
|
||||
wrapClassName="import-modal"
|
||||
maskClosable={false}
|
||||
closable={!isUploading}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}
|
||||
>
|
||||
<Steps current={currentStep} style={{ marginBottom: 24, width: '50%' }}>
|
||||
<Step title="基本信息" />
|
||||
<Step title="上传镜像" />
|
||||
</Steps>
|
||||
</div>
|
||||
{renderStepContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export { ImportModal };
|
|
@ -1,4 +1,8 @@
|
|||
import { CODE, IMAGES_TYPE_MAP } from '@/constants/images.constants';
|
||||
import {
|
||||
CODE,
|
||||
IMAGES_TYPE_MAP,
|
||||
STATUS_MAP,
|
||||
} from '@/constants/images.constants';
|
||||
import { delImagesAPI, getImagesList } from '@/services/images';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
|
@ -21,7 +25,8 @@ import {
|
|||
} from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ImportModal, ModalDetailShow } from './components/modalShow/modalShow';
|
||||
import { ModalDetailShow } from './components/modalShow/modalShow';
|
||||
import { ImportModal } from './components/uploadFileModal/uploadFileModal';
|
||||
import useTableParams from './hook/hook';
|
||||
import './index.less';
|
||||
|
||||
|
@ -140,7 +145,7 @@ const ImageList: React.FC = () => {
|
|||
key: 'image_name',
|
||||
title: '镜像名称',
|
||||
dataIndex: 'image_name',
|
||||
width: 200,
|
||||
width: 150,
|
||||
defaultVisible: true,
|
||||
alwaysVisible: true,
|
||||
ellipsis: true,
|
||||
|
@ -154,34 +159,51 @@ const ImageList: React.FC = () => {
|
|||
),
|
||||
},
|
||||
{
|
||||
key: 'image_type',
|
||||
title: '桌面类型',
|
||||
dataIndex: 'image_type',
|
||||
width: 120,
|
||||
render: (text: number) => {
|
||||
const key = text as keyof typeof IMAGES_TYPE_MAP;
|
||||
return text ? IMAGES_TYPE_MAP[key] : '--';
|
||||
},
|
||||
key: 'image_file_name',
|
||||
title: '镜像文件',
|
||||
dataIndex: 'image_file_name',
|
||||
width: 150,
|
||||
defaultVisible: true,
|
||||
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm }) => (
|
||||
<Menu
|
||||
selectedKeys={selectedKeys.length > 0 ? selectedKeys : ['全部']}
|
||||
onClick={({ key }) => {
|
||||
setSelectedKeys(key === '全部' ? [] : [key]);
|
||||
confirm({ closeDropdown: true }); // 立即触发筛选并关闭下拉菜单
|
||||
}}
|
||||
items={[
|
||||
{ key: '全部', label: '全部' },
|
||||
...Object.entries(IMAGES_TYPE_MAP).map(([key, value]) => ({
|
||||
key,
|
||||
label: value,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
filterMultiple: false,
|
||||
defaultFilteredValue: ['全部'],
|
||||
alwaysVisible: true,
|
||||
ellipsis: true,
|
||||
render: (text: string) =>
|
||||
text ? (
|
||||
<Tooltip title={text} placement="topLeft">
|
||||
{text}
|
||||
</Tooltip>
|
||||
) : (
|
||||
'--'
|
||||
),
|
||||
},
|
||||
// {
|
||||
// key: 'image_type',
|
||||
// title: '桌面类型',
|
||||
// dataIndex: 'image_type',
|
||||
// width: 120,
|
||||
// render: (text: number) => {
|
||||
// const key = text as keyof typeof IMAGES_TYPE_MAP;
|
||||
// return text ? IMAGES_TYPE_MAP[key] : '--';
|
||||
// },
|
||||
// defaultVisible: true,
|
||||
// filterDropdown: ({ setSelectedKeys, selectedKeys, confirm }) => (
|
||||
// <Menu
|
||||
// selectedKeys={selectedKeys.length > 0 ? selectedKeys : ['全部']}
|
||||
// onClick={({ key }) => {
|
||||
// setSelectedKeys(key === '全部' ? [] : [key]);
|
||||
// confirm({ closeDropdown: true }); // 立即触发筛选并关闭下拉菜单
|
||||
// }}
|
||||
// items={[
|
||||
// { key: '全部', label: '全部' },
|
||||
// ...Object.entries(IMAGES_TYPE_MAP).map(([key, value]) => ({
|
||||
// key,
|
||||
// label: value,
|
||||
// })),
|
||||
// ]}
|
||||
// />
|
||||
// ),
|
||||
// filterMultiple: false,
|
||||
// defaultFilteredValue: ['全部'],
|
||||
// },
|
||||
{
|
||||
key: 'storage_path',
|
||||
title: '模板存放路径',
|
||||
|
@ -189,16 +211,30 @@ const ImageList: React.FC = () => {
|
|||
width: 140,
|
||||
defaultVisible: true,
|
||||
ellipsis: true,
|
||||
render: (text: string) => text ? <Tooltip title={text} placement="topLeft">{text}</Tooltip>: '--'
|
||||
render: (text: string) =>
|
||||
text ? (
|
||||
<Tooltip title={text} placement="topLeft">
|
||||
{text}
|
||||
</Tooltip>
|
||||
) : (
|
||||
'--'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bt_path',
|
||||
title: 'BT路径',
|
||||
dataIndex: 'bt_path',
|
||||
width: 180,
|
||||
width: 140,
|
||||
defaultVisible: true,
|
||||
ellipsis: true,
|
||||
render: (text: string) => text ? <Tooltip title={text} placement="topLeft">{text}</Tooltip>:'--'
|
||||
render: (text: string) =>
|
||||
text ? (
|
||||
<Tooltip title={text} placement="topLeft">
|
||||
{text}
|
||||
</Tooltip>
|
||||
) : (
|
||||
'--'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'image_version',
|
||||
|
@ -230,7 +266,7 @@ const ImageList: React.FC = () => {
|
|||
},
|
||||
{
|
||||
key: 'create_time',
|
||||
title: '创建时间',
|
||||
title: '上传时间',
|
||||
dataIndex: 'create_time',
|
||||
width: 160,
|
||||
render: (text: string) =>
|
||||
|
@ -324,11 +360,7 @@ const ImageList: React.FC = () => {
|
|||
}
|
||||
};
|
||||
const getStatusTag = (status: number) => {
|
||||
const statusMap = {
|
||||
1: { color: 'green', text: '成功' },
|
||||
2: { color: 'red', text: '失败' },
|
||||
};
|
||||
const config = statusMap[status as keyof typeof statusMap];
|
||||
const config = STATUS_MAP[status as keyof typeof STATUS_MAP];
|
||||
return <Tag color={config?.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
|
@ -481,7 +513,7 @@ const ImageList: React.FC = () => {
|
|||
return (
|
||||
<div className="image-list">
|
||||
<div className="search-box">
|
||||
<Button onClick={() => setImportModalVisible(true)}>导入</Button>
|
||||
<Button onClick={() => setImportModalVisible(true)}>新建</Button>
|
||||
<div className="search-input">
|
||||
<Input.Search
|
||||
placeholder="镜像名称"
|
||||
|
|
|
@ -65,6 +65,20 @@ declare namespace IMAGES {
|
|||
status?: boolean; // 是否应该停止上传
|
||||
};
|
||||
|
||||
// 镜像新增表单字段配置
|
||||
type FormFieldConfig = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'input' | 'select' | 'textarea';
|
||||
rules?: any[];
|
||||
props: {
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
options?: { value: string; label: string }[]; // 明确options类型
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
export type ImageDetailField =
|
||||
| ImageDetailFieldWithoutRender
|
||||
| ImageDetailFieldWithRender;
|
||||
|
|
Loading…
Reference in New Issue