feat(镜像管理): 待联调

master
rdpnr_chenyantong 2025-08-06 17:43:46 +08:00
parent 9efc422b8a
commit 65b71d61ac
10 changed files with 963 additions and 195 deletions

View File

@ -0,0 +1,94 @@
export default {
'POST /api/v1/images/queryimagesList': (req: any, res: any) => {
const { page_size, page_num } = req.body;
const data = [];
function getRandomFormat() {
const random = Math.random(); // 生成 0 ~ 1 的随机数
if (random < 0.33) {
return 1;
} else if (random < 0.66) {
return 2;
} else {
return 3;
}
}
for (let i = 1; i <= page_size; i++) {
data.push({
image_id: i,
image_name: `周俊星${(page_num - 1) * page_size + i}`,
image_type: getRandomFormat(),
bt_path: `/serve/logs`,
storage_path: '/mock/images',
create_time: +new Date(),
});
}
const result = {
error_code: '0000000000',
message: '操作成功',
data: {
paging: {
total: 520,
total_num: 520,
page_num: page_num,
page_size: page_size,
},
data: data,
},
};
setTimeout(() => {
res.send(result);
}, 500);
},
'POST /api/v1/images/file/chunk/upload': (req: any, res: any) => {
// 打印所有接收到的字段
console.log('=== 分片上传信息 ===');
console.log('文件ID:', req.body.file_id);
console.log('文件名:', req.body.file_name);
console.log('文件大小:', req.body.file_size);
console.log('分片索引:', req.body.shard_index);
console.log('分片总数:', req.body.shard_total);
console.log('分片大小:', req.body.chunk_size);
console.log('分片MD5:', req.body.chunk_md5);
// 如果有文件上传,打印文件信息
if (req.files && req.files.chunk) {
console.log('上传的分片文件:', req.files.chunk);
}
// 模拟上传进度
const shardIndex = parseInt(req.body.shard_index);
const shardTotal = parseInt(req.body.shard_total);
console.log(`分片上传进度: ${shardIndex + 1}/${shardTotal}`);
// 模拟上传完成(最后一个分片)
if (shardIndex === shardTotal - 1) {
console.log('文件上传完成!');
res.send({
success: true,
error_code: '0000000000',
message: '操作成功',
data: {
file_id: req.body.file_id,
message: '文件上传成功',
},
});
} else {
// 模拟上传中
const progress = Math.round(((shardIndex + 1) / shardTotal) * 100);
console.log(`上传进度: ${progress}%`);
res.send({
success: false,
error_code: '1221000001',
message: `上传中... ${progress}%`,
data: {
uploaded: shardIndex + 1,
total: shardTotal,
progress: progress,
},
});
}
},
};

View File

@ -13,11 +13,14 @@
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-components": "^2.4.4",
"@umijs/max": "^4.4.11",
"antd": "^5.4.0"
"antd": "^5.4.0",
"spark-md5": "^3.0.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"@types/spark-md5": "^3.0.5",
"lint-staged": "^13.2.0",
"prettier": "^2.8.7",
"prettier-plugin-organize-imports": "^3.2.2",

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1754450820809" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5341" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M981.448462 133.180788a35.367512 35.367512 0 0 0-35.367512 35.367512v85.103076a505.092283 505.092283 0 0 0-939.449541 221.046951 35.367512 35.367512 0 0 0 32.604425 38.130599 35.367512 35.367512 0 0 0 35.367512-32.604425 434.357258 434.357258 0 0 1 819.53157-165.785213h-93.944954a35.367512 35.367512 0 1 0 0 71.287641h181.2585a35.367512 35.367512 0 0 0 35.367512-35.367512V168.5483a35.367512 35.367512 0 0 0-35.367512-35.367512z m0 379.095521a35.367512 35.367512 0 0 0-38.130599 32.604425 434.357258 434.357258 0 0 1-819.531571 165.785213h100.023746a35.367512 35.367512 0 1 0 0-71.287642H42.551538a35.367512 35.367512 0 0 0-35.367512 35.367513v181.258499a35.367512 35.367512 0 1 0 71.287642 0v-85.655693a505.092283 505.092283 0 0 0 939.449541-221.046951 35.367512 35.367512 0 0 0-34.814895-37.025364z" fill="#5E5C5C" p-id="5342"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,7 @@
export const ERROR_CODE = '0000000000';
export const IMAGES_TYPE_MAP = {
1: 'VHD',
2: 'VHDX',
3: 'QCOW2',
} as const;

View File

@ -0,0 +1,425 @@
import { IMAGES_TYPE_MAP } from '@/constants/images.constants';
import { uploadChunkAPI } from '@/services/images';
import { Alert, Button, message, Modal, Progress, Upload } from 'antd';
import { UploadProps } from 'antd/lib/upload';
import React, { useRef, useState } from 'react';
import SparkMD5 from 'spark-md5';
import { v4 as uuidv4 } from 'uuid';
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">
<div className="detail-item">
<label></label>
<span>{selectedImage.image_name}</span>
</div>
<div className="detail-item">
<label></label>
<span>
{IMAGES_TYPE_MAP[
selectedImage.image_type as keyof typeof IMAGES_TYPE_MAP
] || '--'}
</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> = ({
visible,
onCancel,
onImportSuccess,
}) => {
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<
'ready' | 'uploading' | 'success' | 'error'
>('ready');
const [uploadMessage, setUploadMessage] = useState('');
// 分片上传相关
const CHUNK_SIZE = 10 * 1024 * 1024; // 50MB/每片 网络传输层面的分片
const MAX_CONCURRENT = 5; // 同时上传的分片数量
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); // 用于取消上传
// 5. 分块计算文件MD5
const calculateMD5InChunks = (
file: Blob,
chunkSize: number = file.size > 50 * 1024 * 1024
? 2 * 1024 * 1024 // 大文件用2MB块
: 1 * 1024 * 1024, // 小文件用1MB块, // 计算MD5时的内存分块大小 内存处理层面的分块 即在计算每个10MB分片的MD5值时为了避免占用过多内存将10MB的分片再进一步分成2MB的小块逐步读取计算
): 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 {
// 检查是否已中止
if (abortController.current?.signal.aborted) {
return false;
}
// 使用 spark-md5 计算当前分片的MD5
const chunkMD5 = await calculateMD5InChunks(chunk);
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());
// 这里应该调用实际的上传API
// 示例: await uploadChunkAPI(formData);
await uploadChunkAPI(formData, abortController.current?.signal);
// 模拟上传过程
// await new Promise((resolve) =>
// setTimeout(resolve, 300 + Math.random() * 700),
// );
// 模拟8%的失败率用于测试
// if (Math.random() < 0.08) {
// throw new Error('网络错误');
// }
return true;
} catch (error) {
// 检查是否因为中止导致的错误
if (error instanceof Error && error.name === 'AbortError') {
// console.log(`上传分片 ${index} 被用户取消`);
return false;
}
// console.error(`上传分片 ${index} 失败:`, error);
return false;
}
};
// 3. 处理上传队列:每次处理最多 MAX_CONCURRENT 个分片
const processUploadQueue = async () => {
const promises: Promise<void>[] = [];
// 同时处理最多 MAX_CONCURRENT 个分片
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
) {
const chunkItem = uploadQueue.current.shift();
if (chunkItem) {
const { chunk, index } = chunkItem;
const success = await uploadChunk(chunk, index);
if (success) {
completedChunks.current += 1;
const progress = Math.round(
(completedChunks.current / totalChunks.current) * 100,
);
setUploadProgress(progress);
// 所有分片上传完成
if (completedChunks.current === totalChunks.current) {
setIsUploading(false);
setUploadStatus('success');
setUploadMessage('文件上传成功!正在处理中,请稍后查看列表。');
message.success('文件上传成功!系统正在处理,请稍后查看列表。');
onImportSuccess?.();
}
} else {
// 上传失败处理
if (!abortController.current?.signal.aborted) {
setIsUploading(false);
setUploadStatus('error');
setUploadMessage('文件上传失败,请重试');
message.error('文件上传失败');
}
return;
}
}
}
};
promises.push(processChunk());
}
await Promise.all(promises);
};
// 2. 开始上传
const startUpload = async (file: File) => {
try {
setIsUploading(true);
setUploadStatus('uploading');
setUploadMessage('正在准备上传...');
setUploadProgress(0);
// 初始化状态
completedChunks.current = 0;
fileSize.current = file.size;
fileName.current = file.name;
fileId.current = uuidv4(); // 生成唯一文件ID
totalChunks.current = Math.ceil(file.size / CHUNK_SIZE);
uploadQueue.current = [];
setUploadMessage(`正在分析文件... 共 ${totalChunks.current} 个分片`);
// 创建分片并添加到队列
for (let i = 0; i < totalChunks.current; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
uploadQueue.current.push({ chunk, index: i });
}
setUploadMessage(`开始上传文件,共 ${totalChunks.current} 个分片`);
// 创建新的AbortController
abortController.current = new AbortController();
// 开始处理上传队列
await processUploadQueue();
} catch (error) {
console.error('上传准备失败:', error);
setIsUploading(false);
setUploadStatus('error');
setUploadMessage('上传准备失败,请重试');
message.error('上传准备失败');
}
};
// 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 > 20 * 1024 * 1024 * 1024) {
// 20GB
message.error('文件大小不能超过20GB');
return false;
}
// 开始上传流程
startUpload(file);
return false; // 阻止默认上传行为
};
// 取消上传
const cancelUpload = () => {
if (abortController.current) {
abortController.current.abort();
}
setIsUploading(false);
setUploadStatus('ready');
setUploadMessage('上传已取消');
setUploadProgress(0);
message.info('上传已取消');
};
// 导入弹窗内容
const importModalContent = (
<div>
<Alert
message="重要提示"
description={
<div>
<div>
1.
</div>
<div>
2.
</div>
</div>
}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
<Dragger
beforeUpload={handleFileUpload}
disabled={isUploading}
multiple={false}
showUploadList={false}
// accept=".tar.gz,.iso,.qcow2"
>
<p className="ant-upload-drag-icon">
<div style={{ fontSize: 48, lineHeight: 1 }}></div>
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
{/* 支持文件扩展名:.tar.gz, .iso, .qcow2大小限制为20G */}
20G
</p>
</Dragger>
{(isUploading || uploadStatus !== 'ready') && (
<div style={{ marginTop: 20 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<span></span>
<span>{uploadProgress}%</span>
</div>
<Progress
percent={uploadProgress}
status={uploadStatus === 'error' ? 'exception' : 'normal'}
/>
{uploadMessage && (
<div style={{ marginTop: 8, textAlign: 'center' }}>
<span>{uploadMessage}</span>
</div>
)}
{/* {isUploading && (
<div style={{ marginTop: 12, textAlign: 'center' }}>
<Button onClick={cancelUpload}></Button>
</div>
)} */}
</div>
)}
</div>
);
return (
<Modal
title="导入镜像"
open={visible}
onCancel={() => {
if (isUploading) {
cancelUpload();
}
onCancel();
}}
footer={[
<Button
key="close"
onClick={() => {
if (isUploading) {
cancelUpload();
}
onCancel();
}}
>
</Button>,
]}
width={600}
maskClosable={false}
closable={!isUploading}
>
{importModalContent}
</Modal>
);
};
export { ImportModal, ModalDetailShow };

View File

@ -15,6 +15,7 @@
// 镜像列表样式
.image-list {
padding: 10px;
.image-detail {
.detail-item {
margin-bottom: 16px;

View File

@ -1,151 +1,280 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Modal, message, Popconfirm } from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined
import { ERROR_CODE, IMAGES_TYPE_MAP } from '@/constants/images.constants';
import { getImagesList } from '@/services/images';
import {
DeleteOutlined,
EyeOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { TableProps } from 'antd';
import {
Button,
Checkbox,
Input,
message,
Modal,
Popconfirm,
Popover,
Space,
Table,
Tooltip,
} from 'antd';
import React, { useEffect, useState } from 'react';
import { ModalDetailShow, ImportModal } from './components/modalShow/modalShow';
import './index.less';
interface ImageItem {
id: string;
name: string;
version: string;
size: string;
status: 'active' | 'inactive' | 'building';
createTime: string;
description: string;
}
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg';
const ImageList: React.FC = () => {
const [images, setImages] = useState<ImageItem[]>([]);
const [images, setImages] = useState<IMAGES.ImageItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
const [selectedImage, setSelectedImage] = useState<IMAGES.ImageItem | null>(
null,
);
const [searchText, setSearchText] = useState<string>('');
const [detailVisible, setDetailVisible] = useState(false);
const [importModalVisible, setImportModalVisible] = useState(false);
const [tableParams, setTableParams] = useState<IMAGES.TableParams>({
pagination: {
current: 1,
pageSize: 10,
},
});
// 列显示控制状态
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();
console.log('cyt==>测试push');
}, []);
}, [
tableParams.pagination?.current,
tableParams.pagination?.pageSize,
tableParams?.sortOrder,
tableParams?.sortField,
JSON.stringify(tableParams.filters),
JSON.stringify(tableParams.keywords),
]);
const getRandomuserParams = (params: IMAGES.TableParams) => {
const {
pagination,
filters,
sortField,
sortOrder,
keywords,
...restParams
} = params;
const result: Record<string, any> = {};
const loadImages = () => {
setLoading(true);
// 模拟数据加载
setTimeout(() => {
const mockData: ImageItem[] = [
{
id: '1',
name: 'Windows 10 专业版',
version: 'v1.0.0',
size: '15.2 GB',
status: 'active',
createTime: '2024-01-15 10:30:00',
description: 'Windows 10 专业版镜像,包含常用办公软件'
},
{
id: '2',
name: 'Ubuntu 22.04 LTS',
version: 'v2.1.0',
size: '8.5 GB',
status: 'active',
createTime: '2024-01-10 14:20:00',
description: 'Ubuntu 22.04 LTS 服务器版本,适用于开发环境'
},
{
id: '3',
name: 'CentOS 8',
version: 'v1.5.0',
size: '12.1 GB',
status: 'building',
createTime: '2024-01-20 09:15:00',
description: 'CentOS 8 企业级服务器操作系统'
},
{
id: '4',
name: 'macOS Monterey',
version: 'v1.2.0',
size: '18.7 GB',
status: 'inactive',
createTime: '2024-01-05 16:45:00',
description: 'macOS Monterey 开发环境镜像'
result.page_size = pagination?.pageSize;
result.page_num = pagination?.current;
if (keywords) {
result.keywords = keywords;
}
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
result[key] = value;
}
];
setImages(mockData);
});
}
if (sortField) {
result.orderby = sortField;
result.order = sortOrder === 'ascend' ? 'asc' : 'desc';
}
// 处理其他参数
Object.entries(restParams).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
result[key] = value;
}
});
return result;
};
const params = getRandomuserParams(tableParams);
const loadImages = async () => {
setLoading(true);
try {
const imagesRes = await getImagesList(params);
if (imagesRes.error_code === ERROR_CODE) {
setImages(imagesRes.data.data || []);
setLoading(false);
setTableParams((prev) => ({
...prev,
pagination: {
...prev.pagination,
total: imagesRes.data.paging.total || 0,
},
}));
} else {
message.error(imagesRes.message || '获取镜像列表失败');
setLoading(false);
}
} catch (err) {
message.error('获取镜像列表失败');
setLoading(false);
}, 1000);
}
};
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: 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 handleViewDetail = (record: ImageItem) => {
const handleViewDetail = (record: IMAGES.ImageItem) => {
setSelectedImage(record);
setDetailVisible(true);
};
const handleEdit = (record: ImageItem) => {
message.info(`编辑镜像:${record.name}`);
};
const handleDelete = (record: ImageItem) => {
const handleDelete = (record: IMAGES.ImageItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除镜像 "${record.name}" 吗?`,
content: `确定要删除镜像 "${record.image_name}" 吗?`,
onOk: () => {
setImages(images.filter(img => img.id !== record.id));
// TODO: 调用删除接口
setImages(images.filter((img) => img.image_id !== record.image_id));
message.success('删除成功');
}
},
});
};
const columns = [
// 列设置相关函数
const handleColumnChange = (columnKey: string, checked: boolean) => {
setVisibleColumns((prev) => ({
...prev,
[columnKey]: checked,
}));
};
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' }}>
<Checkbox
checked={visibleColumns['image_type']}
onChange={(e) => handleColumnChange('image_type', 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)}
>
</Checkbox>
</div>
<div
style={{
padding: '8px 12px',
borderTop: '1px solid #f0f0f0',
marginTop: 8,
}}
>
<Button type="link" onClick={resetColumns} style={{ padding: 0 }}>
</Button>
</div>
</div>
);
// 根据visibleColumns过滤显示的列
const filteredColumns = [
{
title: '镜像名称',
dataIndex: 'name',
key: 'name',
dataIndex: 'image_name',
key: 'image_name',
width: 200,
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
width: 100,
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: 'size',
key: 'size',
width: 100,
title: '模板存放路径',
dataIndex: 'storage_path',
key: 'storage_path',
width: 200,
...(visibleColumns['storage_path'] ? {} : { hidden: true }),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => getStatusTag(status),
title: 'BT路径',
dataIndex: 'bt_path',
key: 'bt_path',
width: 200,
...(visibleColumns['bt_path'] ? {} : { hidden: true }),
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
dataIndex: 'create_time',
key: 'create_time',
width: 180,
...(visibleColumns['create_time'] ? {} : { hidden: true }),
},
{
title: '操作',
key: 'action',
width: 200,
render: (_: any, record: ImageItem) => (
render: (_: any, record: IMAGES.ImageItem) => (
<Space size="small">
<Button
type="text"
@ -160,78 +289,127 @@ const ImageList: React.FC = () => {
okText="确定"
cancelText="取消"
>
<Button
type="text"
icon={<DeleteOutlined />}
title="删除"
danger
/>
<Button type="text" icon={<DeleteOutlined />} title="删除" danger />
</Popconfirm>
</Space>
),
...(visibleColumns['action'] ? {} : { hidden: true }),
},
];
].filter((column) => !column.hidden);
const handleTableChange: TableProps<IMAGES.ImageItem>['onChange'] = (
pagination,
filters,
sorter,
) => {
setTableParams({
pagination: {
current: pagination.current || 1,
pageSize: pagination.pageSize || 10,
},
filters,
sortOrder: Array.isArray(sorter) ? undefined : sorter.order,
sortField: Array.isArray(sorter)
? undefined
: (sorter.field as string | number | undefined),
});
if (pagination.pageSize !== tableParams.pagination?.pageSize) {
setImages([]);
}
};
const handleRefresh = () => {
loadImages();
};
// 导入成功后的回调
const handleImportSuccess = () => {
// 可以在这里添加导入成功后的处理逻辑
// 例如:刷新列表或显示提示信息
setTimeout(() => {
loadImages(); // 一段时间后刷新列表查看是否有新导入的镜像
}, 5000);
};
return (
<div className="image-list">
<Card>
<Table
columns={columns}
dataSource={images}
rowKey="id"
loading={loading}
pagination={{
total: images.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
</Card>
<Modal
title="镜像详情"
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailVisible(false)}>
</Button>
]}
width={600}
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
}}
>
{selectedImage && (
<div className="image-detail">
<div className="detail-item">
<label></label>
<span>{selectedImage.name}</span>
</div>
<div className="detail-item">
<label></label>
<span>{selectedImage.version}</span>
</div>
<div className="detail-item">
<label></label>
<span>{selectedImage.size}</span>
</div>
<div className="detail-item">
<label></label>
<span>{getStatusTag(selectedImage.status)}</span>
</div>
<div className="detail-item">
<label></label>
<span>{selectedImage.createTime}</span>
</div>
<div className="detail-item">
<label></label>
<p>{selectedImage.description}</p>
</div>
</div>
)}
</Modal>
<div>
<Button onClick={() => setImportModalVisible(true)}></Button>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input.Search
placeholder="镜像名称"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
onSearch={(value) => {
setTableParams((prev) => ({
...prev,
pagination: {
...prev.pagination,
current: 1,
},
keywords: value,
}));
}}
/>
<Button
onClick={handleRefresh}
loading={loading}
icon={<RefreshIcon style={{ width: 13, height: 13 }} />}
></Button>
<Popover
content={columnSettingsContent}
title="列设置"
trigger="click"
open={columnSettingsVisible}
onOpenChange={setColumnSettingsVisible}
placement="bottomRight"
>
<Button icon={<SettingOutlined />}></Button>
</Popover>
</div>
</div>
<Table
columns={filteredColumns}
dataSource={images}
rowKey="image_id"
loading={loading}
pagination={tableParams.pagination}
onChange={handleTableChange}
// pagination={{
// total: images.length,
// pageSize: 10,
// showSizeChanger: true,
// showQuickJumper: true,
// showTotal: (total) => `共 ${total} 条记录`,
// }}
/>
{detailVisible ? (
<ModalDetailShow
title="镜像详情"
detailVisible={detailVisible}
setDetailVisible={setDetailVisible}
selectedImage={selectedImage}
/>
) : null}
{/* 导入弹窗 */}
<ImportModal
visible={importModalVisible}
onCancel={() => setImportModalVisible(false)}
onImportSuccess={handleImportSuccess}
/>
</div>
);
};
export default ImageList;
export default ImageList;

View File

@ -0,0 +1,23 @@
import { request } from '@umijs/max';
const BASE_URL = '/api/v1/images';
// 根据终端序列号查询镜像列表
export async function getImagesList(params:any) {
// console.log('镜像列表 params', params);
return request<IMAGES.Images_ListInfo>(`${BASE_URL}/queryimagesList`, {
method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
data: params,
});
}
export async function uploadChunkAPI(formData: FormData, signal?: AbortSignal) {
return request<any>(`${BASE_URL}/file/chunk/upload`, {
method: 'POST',
data: formData,
signal, // 添加 signal 支持
});
}

46
web-fe/src/types/images.d.ts vendored 100644
View File

@ -0,0 +1,46 @@
declare namespace IMAGES {
interface ImageItem {
image_id: number;
image_name: string;
image_type: number;
storage_path: string;
bt_path: string;
create_time: string;
description?: string;
desktopType?: string;
version?: string;
size?: string;
status?: 'active' | 'inactive' | 'building';
}
interface TableParams {
pagination: {
current: number;
pageSize: number;
};
filters?: Record<string, any>;
sortOrder?: 'ascend' | 'descend' | null;
sortField?: string | number | null;
keywords?: string; // 添加关键词字段
}
// interface DeviceParams{
// device_id: string;
// }
interface Images_ListInfo {
error_code: string;
message: string;
data: {
paging: {
total: number;
total_num?: number;
page_num: number;
page_size: number;
};
data: ImageItem[];
};
}
}

View File

@ -1896,6 +1896,11 @@
resolved "https://registry.npmmirror.com/@types/semver/-/semver-7.7.0.tgz#64c441bdae033b378b6eef7d0c3d77c329b9378e"
integrity sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==
"@types/spark-md5@^3.0.5":
version "3.0.5"
resolved "http://10.208.10.36:8080/repository/npm/@types/spark-md5/-/spark-md5-3.0.5.tgz#eddec8639217e518c26e9e221ff56bf5f5f5c900"
integrity sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==
"@types/stylis@^4.0.2":
version "4.2.7"
resolved "https://registry.npmmirror.com/@types/stylis/-/stylis-4.2.7.tgz#1813190525da9d2a2b6976583bdd4af5301d9fd4"
@ -9271,6 +9276,11 @@ source-map@^0.7.3, source-map@^0.7.4:
resolved "https://registry.npmmirror.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02"
integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==
spark-md5@^3.0.2:
version "3.0.2"
resolved "http://10.208.10.36:8080/repository/npm/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc"
integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==
spdx-correct@^3.0.0:
version "3.2.0"
resolved "https://registry.npmmirror.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c"
@ -9402,16 +9412,7 @@ string-convert@^0.2.0:
resolved "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97"
integrity sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -9501,14 +9502,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -10106,6 +10100,11 @@ utils-merge@1.0.1:
resolved "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid@^11.1.0:
version "11.1.0"
resolved "http://10.208.10.36:8080/repository/npm/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912"
integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==
v8-compile-cache@^2.3.0:
version "2.4.0"
resolved "https://registry.npmmirror.com/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz#cdada8bec61e15865f05d097c5f4fd30e94dc128"
@ -10261,16 +10260,7 @@ word-wrap@^1.2.5:
resolved "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==