From 65b71d61acae81b835147b482e12577abad3ea69 Mon Sep 17 00:00:00 2001 From: rdpnr_chenyantong Date: Wed, 6 Aug 2025 17:43:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=95=9C=E5=83=8F=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E5=BE=85=E8=81=94=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-fe/mock/images.ts | 94 ++++ web-fe/package.json | 5 +- web-fe/src/assets/icons/refresh.svg | 1 + web-fe/src/constants/images.constants.ts | 7 + .../images/components/modalShow/modalShow.tsx | 425 +++++++++++++++ web-fe/src/pages/images/index.less | 1 + web-fe/src/pages/images/index.tsx | 510 ++++++++++++------ web-fe/src/services/images.ts | 23 + web-fe/src/types/images.d.ts | 46 ++ web-fe/yarn.lock | 46 +- 10 files changed, 963 insertions(+), 195 deletions(-) create mode 100644 web-fe/mock/images.ts create mode 100644 web-fe/src/assets/icons/refresh.svg create mode 100644 web-fe/src/constants/images.constants.ts create mode 100644 web-fe/src/pages/images/components/modalShow/modalShow.tsx create mode 100644 web-fe/src/services/images.ts create mode 100644 web-fe/src/types/images.d.ts diff --git a/web-fe/mock/images.ts b/web-fe/mock/images.ts new file mode 100644 index 0000000..6698b2e --- /dev/null +++ b/web-fe/mock/images.ts @@ -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, + }, + }); + } + }, +}; diff --git a/web-fe/package.json b/web-fe/package.json index 51ce9f5..6829372 100644 --- a/web-fe/package.json +++ b/web-fe/package.json @@ -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", diff --git a/web-fe/src/assets/icons/refresh.svg b/web-fe/src/assets/icons/refresh.svg new file mode 100644 index 0000000..ffbfbc8 --- /dev/null +++ b/web-fe/src/assets/icons/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web-fe/src/constants/images.constants.ts b/web-fe/src/constants/images.constants.ts new file mode 100644 index 0000000..9654a10 --- /dev/null +++ b/web-fe/src/constants/images.constants.ts @@ -0,0 +1,7 @@ +export const ERROR_CODE = '0000000000'; + +export const IMAGES_TYPE_MAP = { + 1: 'VHD', + 2: 'VHDX', + 3: 'QCOW2', +} as const; \ No newline at end of file diff --git a/web-fe/src/pages/images/components/modalShow/modalShow.tsx b/web-fe/src/pages/images/components/modalShow/modalShow.tsx new file mode 100644 index 0000000..0ddbb3f --- /dev/null +++ b/web-fe/src/pages/images/components/modalShow/modalShow.tsx @@ -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 ( + setDetailVisible(false)} + footer={[ + , + ]} + width={600} + > + {selectedImage && ( +
+
+ + {selectedImage.image_name} +
+
+ + + {IMAGES_TYPE_MAP[ + selectedImage.image_type as keyof typeof IMAGES_TYPE_MAP + ] || '--'} + +
+
+ + {selectedImage.storage_path} +
+
+ + {selectedImage.bt_path} +
+ {/*
+ + {getStatusTag(selectedImage.status)} +
*/} +
+ + {selectedImage.create_time} +
+ {/*
+ +

{selectedImage.description}

+
*/} +
+ )} +
+ ); +}; + +/**上传文件分片 */ +interface ImportModalProps { + visible: boolean; + onCancel: () => void; + onImportSuccess?: () => void; +} + +const ImportModal: React.FC = ({ + 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>([]); // 上传队列 + const completedChunks = useRef(0); // 已上传的分片数量 + const totalChunks = useRef(0); // 总分片数量 + const fileId = useRef(''); // 文件id + const fileName = useRef(''); // 文件名 + const fileSize = useRef(0); // 文件大小 + const abortController = useRef(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 => { + return new Promise((resolve, reject) => { + const spark = new SparkMD5.ArrayBuffer(); + const fileReader = new FileReader(); + let currentChunk = 0; + const chunks = Math.ceil(file.size / chunkSize); + + fileReader.onload = (e) => { + try { + spark.append(e.target?.result as ArrayBuffer); + currentChunk++; + + if (currentChunk < chunks) { + loadNext(); + } else { + const hash = spark.end(); + spark.destroy(); // 释放内存 + resolve(hash); + } + } catch (error) { + spark.destroy(); + reject(error); + } + }; + + fileReader.onerror = () => { + spark.destroy(); + reject(new Error('计算MD5失败')); + }; + + const loadNext = () => { + const start = currentChunk * chunkSize; + const end = Math.min(start + chunkSize, file.size); + fileReader.readAsArrayBuffer(file.slice(start, end)); + }; + + loadNext(); + }); + }; + + // 4. 上传单个分片 + const uploadChunk = async (chunk: Blob, index: number): Promise => { + try { + // 检查是否已中止 + 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[] = []; + + // 同时处理最多 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 = ( +
+ +
+ 1. + 文件上传需要时间,请耐心等待。 +
+
+ 2. 文件上传中请勿刷新或者离开页面,否则会导致文件上传失败。 +
+
+ } + type="warning" + showIcon + style={{ marginBottom: 16 }} + /> + + +

+

+

+

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

+

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

+
+ + {(isUploading || uploadStatus !== 'ready') && ( +
+
+ 上传进度 + {uploadProgress}% +
+ + {uploadMessage && ( +
+ {uploadMessage} +
+ )} + {/* {isUploading && ( +
+ +
+ )} */} +
+ )} + + ); + + return ( + { + if (isUploading) { + cancelUpload(); + } + onCancel(); + }} + footer={[ + , + ]} + width={600} + maskClosable={false} + closable={!isUploading} + > + {importModalContent} + + ); +}; + +export { ImportModal, ModalDetailShow }; diff --git a/web-fe/src/pages/images/index.less b/web-fe/src/pages/images/index.less index 5ef9d15..54cdc1b 100644 --- a/web-fe/src/pages/images/index.less +++ b/web-fe/src/pages/images/index.less @@ -15,6 +15,7 @@ // 镜像列表样式 .image-list { + padding: 10px; .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 d7cbc43..0c81cba 100644 --- a/web-fe/src/pages/images/index.tsx +++ b/web-fe/src/pages/images/index.tsx @@ -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([]); + const [images, setImages] = useState([]); const [loading, setLoading] = useState(false); - const [selectedImage, setSelectedImage] = useState(null); + 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 [visibleColumns, setVisibleColumns] = useState>( + { + 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 = {}; - 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 {config.text}; - }; + // 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 {config.text}; + // }; - 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 = ( +
+
+ handleColumnChange('image_type', e.target.checked)} + > + 桌面类型 + +
+
+ handleColumnChange('storage_path', e.target.checked)} + > + 模板存放路径 + +
+
+ handleColumnChange('bt_path', e.target.checked)} + > + BT路径 + +
+
+ handleColumnChange('create_time', e.target.checked)} + > + 创建时间 + +
+
+ handleColumnChange('action', e.target.checked)} + > + 操作 + +
+
+ +
+
+ ); + + // 根据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 {IMAGES_TYPE_MAP[key] || '--'}; + }, + ...(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) => (