From 60e6deaa17de25e22799465b60c1c86bca61df70 Mon Sep 17 00:00:00 2001 From: chenyt Date: Tue, 12 Aug 2025 09:44:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E9=95=9C=E5=83=8F=E5=88=97=E8=A1=A8):=20?= =?UTF-8?q?=E8=81=94=E8=B0=83+=E5=8F=82=E6=95=B0=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-fe/mock/images.ts | 18 +- .../images/components/modalShow/modalShow.tsx | 42 ++- web-fe/src/pages/images/hook/hook.ts | 112 ++++++++ web-fe/src/pages/images/index.less | 58 ++-- web-fe/src/pages/images/index.tsx | 266 ++++++++++-------- web-fe/src/{ => pages/images}/utils/images.ts | 56 ++-- web-fe/src/services/images.ts | 40 ++- web-fe/src/types/images.d.ts | 19 +- 8 files changed, 390 insertions(+), 221 deletions(-) create mode 100644 web-fe/src/pages/images/hook/hook.ts rename web-fe/src/{ => pages/images}/utils/images.ts (74%) diff --git a/web-fe/mock/images.ts b/web-fe/mock/images.ts index c9e6f20..8a9b056 100644 --- a/web-fe/mock/images.ts +++ b/web-fe/mock/images.ts @@ -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,13 +15,15 @@ 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', os_version: 'Ubuntu 20.04', - image_status:Math.random() > 0.5 ? 1 : 2, + image_status: Math.random() > 0.5 ? 1 : 2, storage_path: '/mock/images', create_time: +new Date(), }); @@ -30,11 +32,9 @@ export default { code: '200', message: '操作成功', data: { - paging: { - total: 520, - page_num: page_num, - page_size: page_size, - }, + total: 520, + page_num: page_num, + page_size: page_size, data: data, }, }; diff --git a/web-fe/src/pages/images/components/modalShow/modalShow.tsx b/web-fe/src/pages/images/components/modalShow/modalShow.tsx index 8c148eb..d77b773 100644 --- a/web-fe/src/pages/images/components/modalShow/modalShow.tsx +++ b/web-fe/src/pages/images/components/modalShow/modalShow.tsx @@ -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 = ({ const fileSize = useRef(0); // 文件大小 const abortController = useRef(null); // 用于取消上传 // 上传镜像时间相关 - const [uploadStartTime, setUploadStartTime] = useState(null); - const [elapsedTime, setElapsedTime] = useState(0); + const [elapsedTime, setElapsedTime] = useState(0); // 上传时间 const timerRef = useRef(null); // 添加重置状态函数 @@ -95,7 +92,6 @@ const ImportModal: React.FC = ({ uploadQueue.current = []; // 重置计时器 - setUploadStartTime(null); setElapsedTime(0); if (timerRef.current) { clearInterval(timerRef.current); @@ -123,8 +119,17 @@ const ImportModal: React.FC = ({ 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 = ({ 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 = ({ }; // 取消上传 - 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 = ({ // accept=".tar.gz,.iso,.qcow2" >

-

+

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

diff --git a/web-fe/src/pages/images/hook/hook.ts b/web-fe/src/pages/images/hook/hook.ts new file mode 100644 index 0000000..0842756 --- /dev/null +++ b/web-fe/src/pages/images/hook/hook.ts @@ -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(initialParams); + + const getApiParams = useCallback(() => { + console.log('getApiParams', tableParams); + + const { pagination, filters, sort, ...rest } = tableParams; + const apiParams: Record = { + 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) => { + 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['onChange']> + >( + (pagination, filters, sorter, extra) => { + // 过滤掉空值的filters + const filteredFilters: Record = {}; + 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 = { + 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; diff --git a/web-fe/src/pages/images/index.less b/web-fe/src/pages/images/index.less index 9194601..4a0140e 100644 --- a/web-fe/src/pages/images/index.less +++ b/web-fe/src/pages/images/index.less @@ -36,48 +36,55 @@ 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-spin-nested-loading { flex: 1; display: flex; flex-direction: column; overflow: hidden; - - .ant-spin-container { - flex: 1; + // 表格适应样式 + .ant-table-wrapper { display: flex; flex-direction: column; - overflow: hidden; + flex: 1; + overflow: hidden; - .ant-table { + .ant-spin-nested-loading { flex: 1; display: flex; flex-direction: column; - overflow: hidden; + overflow: hidden; - .ant-table-container { + .ant-spin-container { flex: 1; display: flex; flex-direction: column; - overflow: hidden; + overflow: hidden; - .ant-table-body { + .ant-table { + display: flex; + flex-direction: column; flex: 1; - overflow-y: auto !important; + overflow: hidden; - table { - height: 100%; + .ant-table-container { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + .ant-table-header { + flex-shrink: 0; + } + .ant-table-body { + flex: 1; + overflow: auto !important; + + } + } + // 确保分页器在底部正确显示 + .ant-table-pagination { + flex-shrink: 0; + // 确保分页器始终可见 + position: relative; + z-index: 1; } } } @@ -85,6 +92,7 @@ } } } + .image-detail { .detail-item { margin-bottom: 16px; diff --git a/web-fe/src/pages/images/index.tsx b/web-fe/src/pages/images/index.tsx index 7885900..a3a55f2 100644 --- a/web-fe/src/pages/images/index.tsx +++ b/web-fe/src/pages/images/index.tsx @@ -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,21 +52,37 @@ 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([]); const [loading, setLoading] = useState(false); const [selectedImage, setSelectedImage] = useState( null, ); - const [searchText, setSearchText] = useState(''); const [detailVisible, setDetailVisible] = useState(false); const [importModalVisible, setImportModalVisible] = useState(false); - const [tableParams, setTableParams] = useState({ - pagination: { - current: 1, - pageSize: 10, - }, - }); + const [searchText, setSearchText] = useState(''); // 添加本地搜索状态 + const searchInputRef = useRef(''); // 保存已发送请求的搜索文本 + + 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) => ( + + + {text || '--'} + + + ), }, { key: 'image_type', title: '桌面类型', dataIndex: 'image_type', - width: 80, + width: 100, render: (text: number) => { const key = text as keyof typeof IMAGES_TYPE_MAP; return {IMAGES_TYPE_MAP[key] || '--'}; }, 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 = {}; - - 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)); - message.success('删除成功'); + 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['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 (

@@ -421,18 +432,18 @@ const ImageList: React.FC = () => { 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} />