fix(镜像列表): 联调+参数管理

master
chenyt 2025-08-12 09:44:46 +08:00
parent 725932dfc2
commit 60e6deaa17
8 changed files with 390 additions and 221 deletions

View File

@ -1,5 +1,5 @@
export default {
'POST /api/files/queryimagesList': (req: any, res: any) => {
'POST /api/nex/v1/queryimagesList': (req: any, res: any) => {
const { page_size, page_num } = req.body;
const data = [];
function getRandomFormat() {
@ -15,8 +15,10 @@ export default {
}
for (let i = 1; i <= page_size; i++) {
data.push({
image_id: i,
image_name: `周俊星${(page_num - 1) * page_size + i}`,
id: i,
image_name: `Win版 PR 2024 【支持win10、win11】.zip${
(page_num - 1) * page_size + i
}`,
image_type: getRandomFormat(),
bt_path: `/serve/logs`,
image_version: '1.0.0',
@ -30,11 +32,9 @@ export default {
code: '200',
message: '操作成功',
data: {
paging: {
total: 520,
page_num: page_num,
page_size: page_size,
},
data: data,
},
};

View File

@ -1,9 +1,7 @@
import { IMAGES_TYPE_MAP } from '@/constants/images.constants';
import { uploadChunkAPI } from '@/services/images';
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 SparkMD5 from 'spark-md5';
import { v4 as uuidv4 } from 'uuid';
import {
IMAGE_DETAIL_FIELDS,
@ -13,10 +11,10 @@ import {
import {
getChunkSize,
calculateMD5InChunks,
calculateMD5InChunksWorkers,
formatTime,
} from '@/utils/images';
} from '@/pages/images/utils/images';
const { MAX_CONCURRENT, MAX_FILE_SIZE, ALLOWED_EXTENSIONS } = UPLOAD_CONFIG;
const { READY, UPLOADING, SUCCESS, ERROR } = UPLOAD_STATUS_MAP;
const { Dragger } = Upload;
@ -77,8 +75,7 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
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 [elapsedTime, setElapsedTime] = useState<number>(0); // 上传时间
const timerRef = useRef<NodeJS.Timeout | null>(null);
// 添加重置状态函数
@ -95,7 +92,6 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
uploadQueue.current = [];
// 重置计时器
setUploadStartTime(null);
setElapsedTime(0);
if (timerRef.current) {
clearInterval(timerRef.current);
@ -123,8 +119,17 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
if (abortController.current?.signal.aborted) {
return false;
}
// 更新状态提示正在计算MD5
// setUploadMessage(`正在计算第${index}个分片的MD5...`);
// 使用 spark-md5 计算当前分片的MD5
const chunkMD5 = await calculateMD5InChunks(chunk, fileSize.current);
const chunkMD5 = await calculateMD5InChunks(
chunk,
fileSize.current,
);
// 更新状态,提示正在上传
// setUploadMessage(`正在上传第${index}个分片...`);
const formData = new FormData();
formData.append('file_id', fileId.current);
@ -300,18 +305,17 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
setUploadProgress(0);
// 启动计时器
const startTime = Date.now();
setUploadStartTime(startTime);
setElapsedTime(0);
// 立即更新一次时间
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
setElapsedTime(Math.floor((Date.now() - Date.now()) / 1000));
if (timerRef.current) {
clearInterval(timerRef.current);
}
timerRef.current = setInterval(() => {
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
// 重新计算开始时间,基于当前时间和已用时间
setElapsedTime((prev) => prev + 1);
}, 1000);
// 初始化状态
@ -389,10 +393,18 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
};
// 取消上传
const cancelUpload = () => {
const cancelUpload = async() => {
if (abortController.current) {
abortController.current.abort();
}
// 如果有正在上传的文件调用后端取消上传API
if (fileId.current) {
try {
await cancelUploadImagesAPI({ file_id: fileId.current });
} catch (error) {
console.error('取消上传API调用失败:', error);
}
}
setIsUploading(false);
setUploadStatus(READY);
setUploadMessage('上传已取消');
@ -432,7 +444,7 @@ const ImportModal: React.FC<IMAGES.ImportModalProps> = ({
// accept=".tar.gz,.iso,.qcow2"
>
<p className="ant-upload-drag-icon">
<div style={{ fontSize: 48, lineHeight: 1 }}></div>
<span style={{ fontSize: 48, lineHeight: 1 }}></span>
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">

View File

@ -0,0 +1,112 @@
import type { TableProps } from 'antd';
import { useCallback, useState } from 'react';
const useTableParams = (
initialParams: IMAGES.TableParams = {
pagination: { current: 1, pageSize: 10 },
filters: {},
sort: {},
},
) => {
const [tableParams, setTableParams] =
useState<IMAGES.TableParams>(initialParams);
const getApiParams = useCallback(() => {
console.log('getApiParams', tableParams);
const { pagination, filters, sort, ...rest } = tableParams;
const apiParams: Record<string, any> = {
page_size: pagination?.pageSize,
page_num: pagination?.current,
...rest,
};
if (sort?.field) {
apiParams.orderby = sort.field;
apiParams.order = sort.order === 'ascend' ? 'asc' : 'desc';
}
Object.entries(filters || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
apiParams[key] = value;
}
});
console.log('getApiParams apiParams', apiParams);
return apiParams;
}, [tableParams]);
const updateParams = useCallback((newParams: Partial<IMAGES.TableParams>) => {
console.log('updateParams', newParams);
setTableParams((prev) => ({
...prev,
...newParams,
pagination: {
...prev.pagination,
...newParams.pagination,
},
}));
}, []);
/**
*
* @param pagination
* @param filters filters
* @param sorter
* @param extra
* @returns void
* */
const handleTableChange = useCallback<
NonNullable<TableProps<IMAGES.ImageItem>['onChange']>
>(
(pagination, filters, sorter, extra) => {
// 过滤掉空值的filters
const filteredFilters: Record<string, any> = {};
Object.entries(filters || {}).forEach(([key, value]) => {
if (key === 'image_type') {
if (Array.isArray(value) && value.length > 0 && value[0] !== '全部') {
filteredFilters[key] = Number(value[0]);
}
} else {
if (Array.isArray(value) && value.length > 0) {
filteredFilters[key] = value[0];
} else if (value !== undefined && value !== null) {
if (!Array.isArray(value) && value !== '') {
filteredFilters[key] = value;
}
}
}
});
const newParams: Partial<IMAGES.TableParams> = {
pagination: {
current: pagination.current || 1,
pageSize: pagination.pageSize || 10,
},
filters: filteredFilters,
};
if (!Array.isArray(sorter)) {
newParams.sort = {
field: sorter.field as string,
order:
sorter.order === 'ascend' || sorter.order === 'descend'
? sorter.order
: undefined,
};
}
console.log('handleTableChange', newParams);
updateParams(newParams);
},
[updateParams],
);
return {
tableParams,
getApiParams,
updateParams,
handleTableChange,
};
};
export default useTableParams;

View File

@ -36,17 +36,16 @@
flex-direction: column;
overflow: hidden;
.images-list-table {
display: flex;
flex: 1;
overflow: hidden;
}
}
// 表格适应样式
.ant-table-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
// 表格适应样式
.ant-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-spin-nested-loading {
flex: 1;
@ -61,23 +60,31 @@
overflow: hidden;
.ant-table {
flex: 1;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-container {
flex: 1;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.ant-table-header {
flex-shrink: 0;
}
.ant-table-body {
flex: 1;
overflow-y: auto !important;
overflow: auto !important;
table {
height: 100%;
}
}
// 确保分页器在底部正确显示
.ant-table-pagination {
flex-shrink: 0;
// 确保分页器始终可见
position: relative;
z-index: 1;
}
}
}
@ -85,6 +92,7 @@
}
}
}
.image-detail {
.detail-item {
margin-bottom: 16px;

View File

@ -1,12 +1,10 @@
import { CODE, IMAGES_TYPE_MAP } from '@/constants/images.constants';
import { getImagesList } from '@/services/images';
import { delImagesAPI, getImagesList } from '@/services/images';
import {
DeleteOutlined,
EyeOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { TableProps } from 'antd';
import dayjs from 'dayjs';
import {
Button,
Checkbox,
@ -17,11 +15,13 @@ import {
Popover,
Space,
Table,
Tooltip,
Tag,
Tooltip,
} from 'antd';
import React, { useEffect, useState } from 'react';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ImportModal, ModalDetailShow } from './components/modalShow/modalShow';
import useTableParams from './hook/hook';
import './index.less';
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg';
@ -36,6 +36,10 @@ type ColumnConfig = {
fixed?: 'left' | 'right';
defaultVisible: boolean; // 默认是否显示
alwaysVisible?: boolean; // 始终显示的列
filters?: { text: string; value: string }[];
filterMultiple?: boolean; // 是否多选过滤
defaultFilteredValue?: string[]; // 默认过滤值
onFilter?: (value: string, record: any) => boolean;
};
type TableColumn = {
@ -48,22 +52,38 @@ type TableColumn = {
hidden?: boolean;
};
// 在组件顶部添加防抖函数
const debounce = (func: Function, delay: number) => {
let timer: NodeJS.Timeout;
return (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => func(...args), delay);
};
};
const ImageList: React.FC = () => {
const [images, setImages] = useState<IMAGES.ImageItem[]>([]);
const [loading, setLoading] = useState(false);
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>({
const [searchText, setSearchText] = useState<string>(''); // 添加本地搜索状态
const searchInputRef = useRef<string>(''); // 保存已发送请求的搜索文本
const { tableParams, getApiParams, updateParams, handleTableChange } =
useTableParams({
pagination: {
current: 1,
pageSize: 10,
},
});
// 在组件顶部添加一个 ref 来保存最新的 tableParams
const tableParamsRef = useRef(tableParams);
tableParamsRef.current = tableParams; // 每次渲染时更新 ref 的值
const [columnSettingsVisible, setColumnSettingsVisible] = useState(false);
// 表格参数变化 获取镜像列表
@ -75,7 +95,6 @@ const ImageList: React.FC = () => {
tableParams?.sortOrder,
tableParams?.sortField,
JSON.stringify(tableParams.filters),
JSON.stringify(tableParams.image_name),
]);
// 定义所有列的配置
@ -83,7 +102,7 @@ const ImageList: React.FC = () => {
{
key: 'index',
title: '序号',
width: 120,
width: 60,
render: (text: any, row: any, index: number) =>
(tableParams.pagination?.current - 1) *
tableParams.pagination?.pageSize +
@ -96,20 +115,52 @@ const ImageList: React.FC = () => {
key: 'image_name',
title: '镜像名称',
dataIndex: 'image_name',
width: 120,
width: 200,
defaultVisible: true,
alwaysVisible: true,
render: (text: string) => (
<Tooltip title={text}>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'inline-block',
width: '100%',
}}
>
{text || '--'}
</span>
</Tooltip>
),
},
{
key: 'image_type',
title: '桌面类型',
dataIndex: 'image_type',
width: 80,
width: 100,
render: (text: number) => {
const key = text as keyof typeof IMAGES_TYPE_MAP;
return <Tooltip>{IMAGES_TYPE_MAP[key] || '--'}</Tooltip>;
},
defaultVisible: true,
filters: [
{ text: '全部', value: '全部' },
...Object.entries(IMAGES_TYPE_MAP).map(([key, value]) => ({
text: value,
value: key, // 保持 key 为字符串
})),
],
filterMultiple: false,
defaultFilteredValue: ['全部'],
// onFilter: (value, record) => {
// // 当选择"全部"时显示所有记录
// if (value === '全部') {
// return true;
// }
// // 比较时将字符串 value 转换为数字
// return record.image_type === Number(value);
// },
},
{
key: 'storage_path',
@ -205,70 +256,31 @@ const ImageList: React.FC = () => {
setVisibleColumns(initialVisibleColumns);
};
/**
*
* @param params -
* @returns
*/
const getRandomuserParams = (params: IMAGES.TableParams) => {
const {
pagination,
filters,
sortField,
sortOrder,
image_name,
...restParams
} = params;
const result: Record<string, any> = {};
result.page_size = pagination?.pageSize;
result.page_num = pagination?.current;
if (image_name) {
result.image_name = image_name;
}
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
result[key] = value;
}
});
}
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);
// 将搜索文本合并到API参数中
const apiParams = {
...getApiParams(),
};
if (searchInputRef.current) {
apiParams.image_name = searchInputRef.current;
}
const imagesRes = await getImagesList(apiParams);
if (imagesRes.code == CODE) {
setImages(imagesRes.data.data || []);
setImages(imagesRes.data?.data || []);
setLoading(false);
// 直接使用后端返回的分页信息
setTableParams((prev) => ({
...prev,
console.log('imagesRes', imagesRes);
// 正确处理后端返回的分页信息
updateParams({
pagination: {
...prev.pagination,
current: imagesRes.data.paging.page_num || 1,
total: imagesRes.data.paging.total || 0,
pageSize: imagesRes.data.paging.page_size || 10,
...tableParams.pagination,
current: imagesRes.data?.page_num || 1,
total: imagesRes.data?.total || 0,
pageSize: tableParams.pagination.pageSize|| 10,
},
}));
});
} else {
message.error(imagesRes.message || '获取镜像列表失败');
setLoading(false);
@ -297,9 +309,14 @@ const ImageList: React.FC = () => {
title: '确认删除',
content: `确定要删除镜像 "${record.image_name}" 吗?`,
onOk: () => {
// TODO: 调用删除接口
setImages(images.filter((img) => img.image_id !== record.image_id));
delImagesAPI(record.id).then((res) => {
if (res.code == CODE) {
message.success('删除成功');
loadImages();
} else {
message.error(res.message || '删除失败');
}
});
},
});
};
@ -347,52 +364,19 @@ const ImageList: React.FC = () => {
// 对于始终显示的列
if (config.alwaysVisible) {
return {
title: config.title,
dataIndex: config.dataIndex,
key: config.key,
width: config.width,
render: config.render,
fixed: config.fixed,
...config,
hidden: undefined,
};
}
// 对于可控制显示/隐藏的列
return {
title: config.title,
dataIndex: config.dataIndex,
key: config.key,
width: config.width,
render: config.render,
fixed: config.fixed,
...config,
...(visibleColumns[config.key] ? {} : { hidden: true }),
};
})
.filter((column) => !column.hidden) as TableColumn[];
// 处理表格分页、过滤和排序变化
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();
};
@ -413,6 +397,33 @@ const ImageList: React.FC = () => {
pageSizeOptions: ['10', '20', '50', '100'],
};
const handleSearch = useCallback(
(searchValue: string) => {
console.log('handleSearch', searchValue, tableParams);
// 使用 ref 中的最新值
const currentTableParams = tableParamsRef.current;
// 只有当搜索值变化时才更新参数和触发请求
if (searchInputRef.current !== searchValue) {
searchInputRef.current = searchValue;
updateParams({
pagination: {
current: 1,
pageSize: currentTableParams.pagination?.pageSize || 10,
},
filters: {
...currentTableParams.filters,
image_name: searchValue ? searchValue : undefined,
},
});
}
},
[updateParams],
);
const debouncedSearch = useRef(debounce(handleSearch, 500)).current;
return (
<div className="image-list">
<div className="search-box">
@ -421,18 +432,18 @@ const ImageList: React.FC = () => {
<Input.Search
placeholder="镜像名称"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
onSearch={(value) => {
setTableParams((prev) => ({
...prev,
pagination: {
...prev.pagination,
current: 1,
},
image_name: value,
}));
onChange={(e) => {
const value = e.target.value;
setSearchText(value);
// 只有当输入为空时立即触发搜索,否则使用防抖
if (value === '') {
handleSearch('');
} else {
debouncedSearch(value);
}
}}
style={{ width: 300 }}
onSearch={handleSearch}
/>
<Button
onClick={handleRefresh}
@ -456,11 +467,18 @@ const ImageList: React.FC = () => {
<Table
columns={filteredColumns}
dataSource={images}
rowKey="image_id"
rowKey="id"
loading={loading}
pagination={paginationConfig}
onChange={handleTableChange}
scroll={{ y: '100%' }}
scroll={{
y: 'max-content', // 关键:允许内容决定高度
}}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
/>
</div>
</div>

View File

@ -43,21 +43,28 @@ export const getMD5ChunkSize = (fileSize: number): number => {
export const calculateMD5InChunks = (
file: Blob,
fileSize: number,
chunkSize: number = getMD5ChunkSize(fileSize)
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) => {
const loadNext = () => {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const reader = new FileReader();
reader.onload = (e) => {
try {
spark.append(e.target?.result as ArrayBuffer);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
// 使用 setTimeout 让出主线程,避免长时间阻塞
setTimeout(loadNext, 1);
} else {
const hash = spark.end();
spark.destroy(); // 释放内存
@ -69,15 +76,12 @@ export const calculateMD5InChunks = (
}
};
fileReader.onerror = () => {
reader.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));
reader.readAsArrayBuffer(chunk);
};
loadNext();

View File

@ -1,26 +1,38 @@
import { request } from '@umijs/max';
const BASE_URL = '/api/files';
const BASE_URL_FILE = '/api/files';
const BASE_URL = '/api/nex/v1';
// 根据终端序列号查询镜像列表
// 查询镜像列表
export async function getImagesList(params:any) {
// console.log('镜像列表 params', params);
return request<IMAGES.Images_ListInfo>(`${BASE_URL}/queryimagesList`, {
// return request<IMAGES.Images_ListInfo>(`${BASE_URL}/queryimagesList`, {
return request<IMAGES.Images_ListInfo>(`${BASE_URL}/image/select/page`, {
method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
data: params,
});
}
export async function uploadChunkAPI(formData: FormData, signal?: AbortSignal) {
console.log('formData', formData);
// 删除镜像
export async function delImagesAPI(params:any) {
return request<any>(`${BASE_URL}/image/delete`, {
method: 'POST',
data: params,
});
}
// return request<any>(`/api/v1/images/file/chunk/upload`, {
return request<any>(`${BASE_URL}/upload-chunk`, {
// 上传镜像
export async function uploadChunkAPI(formData: FormData, signal?: AbortSignal) {
return request<any>(`${BASE_URL_FILE}/upload-chunk`, {
method: 'POST',
data: formData,
signal, // 添加 signal 支持
});
}
// 取消上传:调用后端,删除已上传的分片
export async function cancelUploadImagesAPI(params:any) {
return request<any>(`${BASE_URL_FILE}/cancel/upload`, {
method: 'POST',
data: params,
});
}

View File

@ -1,7 +1,6 @@
declare namespace IMAGES {
interface ImageItem {
image_id: number;
id: number;
image_name: string;
image_type: number;
storage_path: string;
@ -21,11 +20,17 @@ declare namespace IMAGES {
pagination: {
current: number;
pageSize: number;
total?: number;
};
image_name?: string;
image_type?: number | string; // 修改为联合类型
filters?: Record<string, any>;
sort?: {
field?: string;
order?: 'ascend' | 'descend';
};
sortOrder?: 'ascend' | 'descend' | null;
sortField?: string | number | null;
image_name?: string; // 添加关键词字段
image_status?: string;
}
@ -33,11 +38,9 @@ declare namespace IMAGES {
code: string;
message: string;
data: {
paging: {
total: number;
page_num: number;
page_size: number;
};
data: ImageItem[];
};
}