From 0adc8411fa4d8d0571d944e83ea89946bbf4fe0e Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 08:59:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid/table-designer?= =?UTF-8?q?/about):=20=E4=BF=AE=E5=A4=8D=E7=A9=BA=E8=A1=A8=E6=A8=AA?= =?UTF-8?q?=E5=90=91=E6=BB=9A=E5=8A=A8=E3=80=81=E7=B4=A2=E5=BC=95=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=9B=9E=E6=98=BE=E5=8F=8A=E5=85=B3=E4=BA=8E=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E6=8C=89=E9=92=AE=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 空表滚动:虚拟模式下空数据表缺少 virtual-holder 元素时,回退到直接滚动表头实现横向滚动 - 索引回显:修复修改索引后再次编辑时被删除的字段仍然显示的问题,selectedIndex 随 groupedIndexes 同步更新 - 按钮间距:关于弹窗 footer 增加 flex-wrap 和 gap,解决关闭按钮与上方操作按钮行重叠 - refs #258 --- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 2 +- frontend/src/components/DataGrid.tsx | 26 ++++++++++++++++++----- frontend/src/components/TableDesigner.tsx | 8 ++++++- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index a7661c0..0f8f4fe 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file +5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2c38879..9ebf4e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1641,7 +1641,7 @@ function App() { title={renderUtilityModalTitle(, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')} open={isAboutOpen} onCancel={() => setIsAboutOpen(false)} - styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} + styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10, justifyContent: 'flex-end' } }} footer={[ canShowProgressEntry ? ( diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index b4b10bb..49c66cf 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -3900,12 +3900,28 @@ const DataGrid: React.FC = ({ // 虚拟表格路径:通过合成 WheelEvent 驱动 rc-virtual-list 内部状态, // rc-table 自动同步 header scrollLeft。 if (enableVirtual && tableContainer instanceof HTMLElement) { - applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft); - // WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref - requestAnimationFrame(() => { - lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer); + const applied = applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft); + if (applied) { + // WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref + requestAnimationFrame(() => { + lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer); + horizontalSyncSourceRef.current = ''; + }); + return; + } + // 空数据回退:virtual-holder 不存在时,直接滚动表头 + const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null; + const contentEl = tableContainer.querySelector('.ant-table-content') as HTMLElement | null; + const fallbackTargets = [headerEl, contentEl].filter((el): el is HTMLElement => el instanceof HTMLElement && el.scrollWidth > el.clientWidth + 1); + if (fallbackTargets.length > 0) { + fallbackTargets.forEach((target) => { + target.scrollLeft = externalScroll.scrollLeft; + }); + lastTableScrollLeftRef.current = externalScroll.scrollLeft; horizontalSyncSourceRef.current = ''; - }); + return; + } + horizontalSyncSourceRef.current = ''; return; } // 非虚拟表格路径:依赖 liveTargets 进行 scrollLeft 同步 diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 04b98fc..00e963b 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1162,8 +1162,14 @@ ${selectedTrigger.statement}`; useEffect(() => { if (!selectedIndex) return; - if (!groupedIndexes.some(idx => idx.key === selectedIndex.key)) { + const freshIndex = groupedIndexes.find(idx => idx.key === selectedIndex.key); + if (!freshIndex) { setSelectedIndex(null); + return; + } + // 索引仍存在但内容可能已变(如字段列表),同步为最新对象 + if (freshIndex !== selectedIndex) { + setSelectedIndex(freshIndex); } }, [groupedIndexes, selectedIndex]); From 72de16995a76610143f075894393ca32d0908439 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 10:25:06 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat(TableDesigner):=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E8=A1=A8=E6=94=AF=E6=8C=81=E5=88=97=E5=AE=BD?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E5=92=8CCheckbox=E5=A4=9A=E9=80=89=E5=85=A8?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拖拽调整:索引表header支持拖拽调整列宽,带三角形角标与DataGrid一致 - 多选重构:索引选择从Radio单选改为Checkbox多选,支持全选/取消全选/半选指示 - 选择列固定:Checkbox列固定48px宽度,不参与拖拽resize,header和body对齐一致 - 按钮逻辑:编辑按钮要求恰好选中1个索引,删除按钮要求选中≥1个索引 - 样式优化:索引表header禁用文字选中和光标效果,保持干净交互体验 --- frontend/src/components/TableDesigner.tsx | 279 ++++++++++++++-------- 1 file changed, 176 insertions(+), 103 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 00e963b..0df2840 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react'; -import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag } from 'antd'; +import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd'; import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; @@ -225,7 +225,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const [tableCommentDraft, setTableCommentDraft] = useState(''); const [isTableCommentModalOpen, setIsTableCommentModalOpen] = useState(false); const [tableCommentSaving, setTableCommentSaving] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(null); + const [selectedIndexKeys, setSelectedIndexKeys] = useState([]); const [isIndexModalOpen, setIsIndexModalOpen] = useState(false); const [indexModalMode, setIndexModalMode] = useState<'create' | 'edit'>('create'); const [indexSaving, setIndexSaving] = useState(false); @@ -270,6 +270,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const [tableHeight, setTableHeight] = useState(500); const containerRef = useRef(null); + const shellRef = useRef(null); const pendingFocusColumnKeyRef = useRef(null); const focusHighlightTimerRef = useRef(null); const [focusColumnKey, setFocusColumnKey] = useState(''); @@ -329,7 +330,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { // --- Resizable Columns State --- const [tableColumns, setTableColumns] = useState([]); - const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number } | null>(null); + const [indexColumns, setIndexColumns] = useState([]); + const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number; setter: React.Dispatch> } | null>(null); const resizeRafRef = useRef(null); const latestResizeXRef = useRef(null); const ghostRef = useRef(null); @@ -548,17 +550,17 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { document.body.style.userSelect = ''; }, []); - const handleResizeStart = useCallback((index: number) => (e: React.MouseEvent) => { + const createResizeStartHandler = useCallback((columns: any[], setter: React.Dispatch>) => (index: number) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; - const currentWidth = Number(tableColumns[index]?.width || 200); - const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0; - resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft }; + const currentWidth = Number(columns[index]?.width || 200); + const containerLeft = shellRef.current?.getBoundingClientRect().left ?? 0; + resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft, setter }; latestResizeXRef.current = startX; - if (ghostRef.current && containerRef.current) { + if (ghostRef.current && shellRef.current) { const relativeLeft = startX - containerLeft; ghostRef.current.style.transform = `translateX(${relativeLeft}px)`; ghostRef.current.style.display = 'block'; @@ -575,10 +577,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const onUp = (event: MouseEvent) => { if (resizeDragRef.current) { - const { startX: dragStartX, startWidth, index: dragIndex } = resizeDragRef.current; + const { startX: dragStartX, startWidth, index: dragIndex, setter: dragSetter } = resizeDragRef.current; const deltaX = event.clientX - dragStartX; const newWidth = Math.max(50, startWidth + deltaX); - setTableColumns((prevColumns) => { + dragSetter((prevColumns) => { if (!prevColumns[dragIndex]) return prevColumns; const nextColumns = [...prevColumns]; nextColumns[dragIndex] = { @@ -598,7 +600,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { document.addEventListener('mouseup', onUp); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; - }, [cleanupResizeState, detachResizeListeners, flushResizeGhost, tableColumns]); + }, [cleanupResizeState, detachResizeListeners, flushResizeGhost]); + + const handleResizeStart = useMemo(() => createResizeStartHandler(tableColumns, setTableColumns), [createResizeStartHandler, tableColumns]); + const handleIndexResizeStart = useMemo(() => createResizeStartHandler(indexColumns, setIndexColumns), [createResizeStartHandler, indexColumns]); useEffect(() => { return () => { @@ -1083,6 +1088,11 @@ ${selectedTrigger.statement}`; }); }, [indexes]); + const selectedIndex = useMemo(() => { + if (selectedIndexKeys.length === 0) return null; + return groupedIndexes.find(idx => selectedIndexKeys.includes(idx.key)) || null; + }, [selectedIndexKeys, groupedIndexes]); + const groupedIndexFieldCount = useMemo( () => groupedIndexes.reduce((total, row) => total + row.columnNames.length, 0), [groupedIndexes] @@ -1161,17 +1171,12 @@ ${selectedTrigger.statement}`; ); useEffect(() => { - if (!selectedIndex) return; - const freshIndex = groupedIndexes.find(idx => idx.key === selectedIndex.key); - if (!freshIndex) { - setSelectedIndex(null); - return; + if (selectedIndexKeys.length === 0) return; + const validKeys = selectedIndexKeys.filter(key => groupedIndexes.some(idx => idx.key === key)); + if (validKeys.length !== selectedIndexKeys.length) { + setSelectedIndexKeys(validKeys); } - // 索引仍存在但内容可能已变(如字段列表),同步为最新对象 - if (freshIndex !== selectedIndex) { - setSelectedIndex(freshIndex); - } - }, [groupedIndexes, selectedIndex]); + }, [groupedIndexes, selectedIndexKeys]); useEffect(() => { if (!selectedForeignKey) return; @@ -2023,6 +2028,109 @@ END;`; }), })); + // --- Index Columns Init --- + useEffect(() => { + setIndexColumns([ + { + title: '索引名', + dataIndex: 'name', + key: 'name', + width: 240, + render: (text: string) => ( + + + {text} + + + ), + }, + { + title: '字段', + dataIndex: 'columnNames', + key: 'columnNames', + width: 320, + render: (columnNames: string[]) => { + if (!columnNames || columnNames.length === 0) { + return '-'; + } + return ( +
+ {columnNames.map((columnName: string, idx: number) => ( + + {columnName} + + ))} +
+ ); + } + }, + { + title: '索引类型', + dataIndex: 'indexType', + key: 'indexType', + width: 140, + render: (text: string) => text || '-', + }, + { + title: '唯一性', + dataIndex: 'nonUnique', + key: 'nonUnique', + width: 110, + render: (v: number) => ( + + {v === 0 ? '唯一' : '普通'} + + ), + }, + ]); + }, []); + + // Checkbox 选择列(不参与 resize,支持全选) + const allIndexKeys = groupedIndexes.map(idx => idx.key); + const isAllSelected = allIndexKeys.length > 0 && selectedIndexKeys.length === allIndexKeys.length; + const isIndeterminate = selectedIndexKeys.length > 0 && selectedIndexKeys.length < allIndexKeys.length; + + const selectColumn = { + title: () => ( + { + setSelectedIndexKeys(e.target.checked ? allIndexKeys : []); + }} + style={{ margin: 0 }} + /> + ), + dataIndex: '_select', + key: '_select', + width: 48, + render: (_: any, record: any) => ( + { + e.stopPropagation(); + setSelectedIndexKeys(prev => + e.target.checked + ? [...prev, record.key] + : prev.filter(k => k !== record.key) + ); + }} + style={{ margin: 0 }} + /> + ), + }; + + const resizableIndexColumns = [ + selectColumn, + ...indexColumns.map((col, index) => ({ + ...col, + onHeaderCell: (column: any) => ({ + width: column.width, + onResizeStart: handleIndexResizeStart(index), + }), + })), + ]; + const columnsTabContent = (
)} -
); return ( -
+
+
+
{!readOnly && (
- - + + {!supportsIndexSchemaOps() && ( 当前数据库暂不支持索引编辑,仅支持查看 )} - {supportsIndexSchemaOps() && selectedIndex && ( + {supportsIndexSchemaOps() && selectedIndexKeys.length > 0 && ( - 已选择:{selectedIndex.name} + 已选择:{selectedIndexKeys.length} 个索引 )}
@@ -2263,75 +2389,22 @@ END;`;
( - - - {text} - - - ), - }, - { - title: '字段', - dataIndex: 'columnNames', - key: 'columnNames', - render: (columnNames: string[]) => { - if (!columnNames || columnNames.length === 0) { - return '-'; - } - return ( -
- {columnNames.map((columnName, idx) => ( - - {columnName} - - ))} -
- ); - } - }, - { - title: '索引类型', - dataIndex: 'indexType', - key: 'indexType', - width: 140, - render: (text: string) => text || '-', - }, - { - title: '唯一性', - dataIndex: 'nonUnique', - key: 'nonUnique', - width: 110, - render: (v: number) => ( - - {v === 0 ? '唯一' : '普通'} - - ), - }, - ]} + columns={resizableIndexColumns} rowKey="key" size="small" pagination={false} loading={loading} scroll={{ x: 960, y: tableHeight }} - rowSelection={{ - type: 'radio', - selectedRowKeys: selectedIndex ? [selectedIndex.key] : [], - onChange: (_, selectedRows) => setSelectedIndex((selectedRows[0] as IndexDisplayRow) || null), + components={{ + header: { cell: ResizableTitle }, }} onRow={(record) => ({ onClick: () => { - if (selectedIndex?.key === record.key) { - setSelectedIndex(null); - } else { - setSelectedIndex(record); - } + setSelectedIndexKeys(prev => + prev.includes(record.key) + ? prev.filter(k => k !== record.key) + : [...prev, record.key] + ); }, style: { cursor: 'pointer' } })} From 2c3f4a103274df6f8ffb872813506f337ec90670 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 11:33:30 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(TableDesigner?= =?UTF-8?q?):=20=E9=87=8D=E6=9E=84=E9=80=89=E6=8B=A9=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=B8=BA=E6=89=8B=E5=8A=A8Checkbox=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 交互重构:移除rowSelection依赖,改用手动Checkbox列避免Ant Design内部对齐差异 - 列隔离:Checkbox和Sort列脱离resizableColumns,不经过ResizableTitle处理 - 对齐修复:.ant-input padding-left归零,消除borderless Input导致的th/td文字偏移 - 性能优化:resizableColumns/sortColumn等用useMemo稳定引用,Tab切换startTransition降级 - 动画加速:ink-bar添加will-change:transform独立合成层,过渡缩短至0.15s --- frontend/src/components/TableDesigner.tsx | 88 ++++++++++++++++++----- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 0df2840..2697949 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -121,7 +121,7 @@ const ResizableTitle = (props: any) => { nextStyle.width = width; } - if (!width) { + if (!onResizeStart) { return
; } @@ -415,11 +415,6 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { // Initial Columns Definition useEffect(() => { const initialCols = [ - ...(readOnly ? [] : [{ - key: 'sort', - width: 40, - render: () => , - }]), { title: '名', dataIndex: 'name', @@ -2020,13 +2015,60 @@ END;`; }; // Merge columns with resize handler - const resizableColumns = tableColumns.map((col, index) => ({ + const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({ ...col, onHeaderCell: (column: any) => ({ width: column.width, onResizeStart: handleResizeStart(index), }), - })); + })), [tableColumns]); + + // 字段表 Checkbox 选择列(不参与 resize,支持全选) + const allColumnKeys = useMemo(() => columns.map(c => c._key), [columns]); + const isAllColumnsSelected = allColumnKeys.length > 0 && selectedColumnRowKeys.length === allColumnKeys.length; + const isColumnsIndeterminate = selectedColumnRowKeys.length > 0 && selectedColumnRowKeys.length < allColumnKeys.length; + + const columnSelectCol = useMemo(() => ({ + title: () => ( + setSelectedColumnRowKeys(e.target.checked ? allColumnKeys : [])} + style={{ margin: 0 }} + /> + ), + dataIndex: '_select', + key: '_select', + width: 48, + render: (_: any, record: any) => ( + { + e.stopPropagation(); + setSelectedColumnRowKeys((prev: string[]) => + e.target.checked + ? [...prev, record._key] + : prev.filter((k: string) => k !== record._key) + ); + }} + style={{ margin: 0 }} + /> + ), + }), [selectedColumnRowKeys, allColumnKeys, isAllColumnsSelected, isColumnsIndeterminate]); + + // sort 拖拽列(不参与 resize) + const sortColumn = useMemo(() => ({ + key: 'sort', + width: 40, + render: () => , + }), []); + + const columnsWithSelect = useMemo(() => + readOnly + ? resizableColumns + : [columnSelectCol, sortColumn, ...resizableColumns], + [readOnly, columnSelectCol, sortColumn, resizableColumns] + ); // --- Index Columns Init --- useEffect(() => { @@ -2153,7 +2195,7 @@ END;`; {readOnly ? ( record._key === focusColumnKey ? 'table-designer-focus-row' : ''} size="small" @@ -2172,11 +2214,7 @@ END;`; c._key)} strategy={verticalListSortingStrategy}>
setSelectedColumnRowKeys(nextSelectedRowKeys as string[]), - }} + columns={columnsWithSelect} rowKey="_key" rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''} size="small" @@ -2203,11 +2241,13 @@ END;`; .table-designer-shell .ant-table-container { background: transparent !important; } - .table-designer-shell .ant-table-wrapper, - .table-designer-shell .ant-table-container { + .table-designer-shell .ant-table-wrapper { border: none !important; overflow: hidden !important; } + .table-designer-shell .ant-table-container { + border: none !important; + } .table-designer-shell .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'} !important; @@ -2219,6 +2259,13 @@ END;`; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } + .table-designer-shell .ant-table-tbody td .ant-input { + padding-left: 0 !important; + padding-right: 0 !important; + } + .table-designer-shell .ant-table-tbody td .ant-select .ant-select-selector { + padding-left: 0 !important; + } .table-designer-shell .ant-table-thead > tr > th::before { display: none !important; } @@ -2237,6 +2284,13 @@ END;`; .table-designer-shell .ant-tabs-nav::before { border-bottom-color: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'} !important; } + .table-designer-shell .ant-tabs-ink-bar { + will-change: transform; + transition: width 0.15s ease, left 0.15s ease, transform 0.15s ease !important; + } + .table-designer-shell .ant-tabs-tab { + transition: color 0.15s ease !important; + } .table-designer-shell .ant-tabs-content-holder, .table-designer-shell .ant-tabs-content, .table-designer-shell .ant-tabs-tabpane { @@ -2343,7 +2397,7 @@ END;`; React.startTransition(() => setActiveKey(key))} style={{ flex: 1, minHeight: 0, From c99f857d0ac425ed735eb2c90a743d98bf02fa0e Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 11:58:12 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20feat(TableOverview):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=A1=A8=E5=B9=B3=E9=93=BA=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=A6=82=E8=A7=88=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 TableOverview 组件:卡片网格展示表名、注释、行数、数据大小、引擎 - 数据获取:通过 DBQuery 发 SHOW TABLE STATUS 等 SQL 适配多数据库方言 - 交互功能:搜索过滤、按名称/行数/大小排序、双击打开DataGrid、Tooltip悬浮全名 - 右键菜单:与 Sidebar 完全一致(新建查询/设计表/复制结构/备份/重命名/删除/导出) - 入口集成:双击侧边栏"表(N)"分组节点打开概览Tab,注册table-overview类型 - UI细节:统计指标固定列宽对齐,卡片hover高亮边框 --- frontend/src/components/Sidebar.tsx | 11 + frontend/src/components/TabManager.tsx | 5 +- frontend/src/components/TableOverview.tsx | 454 ++++++++++++++++++++++ frontend/src/types.ts | 2 +- 4 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/TableOverview.tsx diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 29b91d6..0e3b694 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1454,6 +1454,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; const onDoubleClick = (e: any, node: any) => { + if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') { + const { id, dbName, schemaName } = node.dataRef; + addTab({ + id: `table-overview-${id}-${dbName}${schemaName ? `-${schemaName}` : ''}`, + title: `表概览 - ${dbName}${schemaName ? ` (${schemaName})` : ''}`, + type: 'table-overview' as any, + connectionId: id, + dbName, + }); + return; + } if (node.type === 'table') { const { tableName, dbName, id } = node.dataRef; // 记录表访问 diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index dd60446..8784f4d 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -14,6 +14,7 @@ import RedisViewer from './RedisViewer'; import RedisCommandEditor from './RedisCommandEditor'; import TriggerViewer from './TriggerViewer'; import DefinitionViewer from './DefinitionViewer'; +import TableOverview from './TableOverview'; import type { TabData } from '../types'; const detectConnectionEnvLabel = (connectionName: string): string | null => { @@ -28,7 +29,7 @@ const detectConnectionEnvLabel = (connectionName: string): string | null => { }; const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => { - if (tab.type !== 'table' && tab.type !== 'design') return tab.title; + if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title; if (!connectionName) return tab.title; const prefix = detectConnectionEnvLabel(connectionName) || connectionName; return `[${prefix}] ${tab.title}`; @@ -159,6 +160,8 @@ const TabManager: React.FC = () => { content = ; } else if (tab.type === 'view-def' || tab.type === 'routine-def') { content = ; + } else if (tab.type === 'table-overview') { + content = ; } const menuItems: MenuProps['items'] = [ diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx new file mode 100644 index 0000000..013ebd8 --- /dev/null +++ b/frontend/src/components/TableOverview.tsx @@ -0,0 +1,454 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd'; +import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons'; +import { useStore } from '../store'; +import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App'; +import type { TabData } from '../types'; + +interface TableOverviewProps { + tab: TabData; +} + +interface TableStatRow { + name: string; + comment: string; + rows: number; + dataSize: number; + indexSize: number; + engine: string; + createTime: string; + updateTime: string; +} + +type SortField = 'name' | 'rows' | 'dataSize'; +type SortOrder = 'asc' | 'desc'; + +const formatSize = (bytes: number): string => { + if (!bytes || bytes <= 0) return '—'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +}; + +const formatRows = (count: number): string => { + if (count === undefined || count === null || count < 0) return '—'; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(count); +}; + +const getMetadataDialect = (connType: string, driver?: string): string => { + const type = (connType || '').trim().toLowerCase(); + if (type === 'custom') { + const d = (driver || '').trim().toLowerCase(); + if (d === 'diros' || d === 'doris') return 'mysql'; + return d; + } + if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql'; + if (type === 'dameng') return 'dm'; + return type; +}; + +const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => { + const escapeLiteral = (s: string) => s.replace(/'/g, "''"); + switch (dialect) { + case 'mysql': + return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``; + case 'postgres': + case 'kingbase': + case 'vastbase': { + const schema = schemaName || 'public'; + return ` +SELECT + c.relname AS table_name, + obj_description(c.oid, 'pg_class') AS table_comment, + c.reltuples::bigint AS table_rows, + pg_total_relation_size(c.oid) AS data_length, + pg_indexes_size(c.oid) AS index_length +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE c.relkind = 'r' + AND n.nspname = '${escapeLiteral(schema)}' +ORDER BY c.relname`; + } + case 'sqlserver': { + const safeDB = `[${dbName.replace(/]/g, ']]')}]`; + return ` +SELECT + t.name AS table_name, + ep.value AS table_comment, + SUM(p.rows) AS table_rows, + SUM(a.total_pages) * 8 * 1024 AS data_length, + SUM(a.used_pages) * 8 * 1024 AS index_length +FROM ${safeDB}.sys.tables t +LEFT JOIN ${safeDB}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' +LEFT JOIN ${safeDB}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1) +LEFT JOIN ${safeDB}.sys.allocation_units a ON p.partition_id = a.container_id +WHERE t.type = 'U' +GROUP BY t.name, ep.value +ORDER BY t.name`; + } + case 'clickhouse': + return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`; + case 'dm': + case 'oracle': { + const owner = (schemaName || dbName).toUpperCase(); + return `SELECT table_name, comments AS table_comment, num_rows AS table_rows, 0 AS data_length, 0 AS index_length FROM all_tab_comments JOIN all_tables USING (table_name, owner) WHERE owner = '${escapeLiteral(owner)}' ORDER BY table_name`; + } + default: + return `SELECT table_name, '' AS table_comment, 0 AS table_rows, 0 AS data_length, 0 AS index_length FROM information_schema.tables WHERE table_schema = '${escapeLiteral(dbName)}' AND table_type = 'BASE TABLE' ORDER BY table_name`; + } +}; + +const parseTableStats = (dialect: string, rows: Record[]): TableStatRow[] => { + return rows.map((row) => { + const get = (keys: string[]): any => { + for (const k of keys) { + for (const rk of Object.keys(row)) { + if (rk.toLowerCase() === k.toLowerCase() && row[rk] !== null && row[rk] !== undefined) return row[rk]; + } + } + return undefined; + }; + const strVal = (keys: string[]) => String(get(keys) ?? '').trim(); + const numVal = (keys: string[]) => { + const v = get(keys); + if (v === null || v === undefined || v === '') return 0; + const n = Number(v); + return isNaN(n) ? 0 : Math.max(0, Math.round(n)); + }; + + return { + name: strVal(['Name', 'table_name', 'tablename', 'TABLE_NAME']), + comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']), + rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']), + dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']), + indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH']), + engine: strVal(['Engine', 'engine']), + createTime: strVal(['Create_time', 'create_time']), + updateTime: strVal(['Update_time', 'update_time']), + }; + }).filter(t => t.name); +}; + +const TableOverview: React.FC = ({ tab }) => { + const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + const addTab = useStore(state => state.addTab); + const darkMode = theme === 'dark'; + + const [tables, setTables] = useState([]); + const [loading, setLoading] = useState(true); + const [searchText, setSearchText] = useState(''); + const [sortField, setSortField] = useState('name'); + const [sortOrder, setSortOrder] = useState('asc'); + + const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]); + + const loadData = useCallback(async () => { + if (!connection) return; + setLoading(true); + try { + const config = { + ...connection.config, + port: Number(connection.config.port), + password: connection.config.password || '', + database: connection.config.database || '', + useSSH: connection.config.useSSH || false, + ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, + }; + const dialect = getMetadataDialect(connection.config.type, (connection.config as any)?.driver); + const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName); + const res = await DBQuery(config as any, tab.dbName || '', sql); + if (res.success && Array.isArray(res.data)) { + setTables(parseTableStats(dialect, res.data)); + } else { + message.error('获取表信息失败: ' + (res.message || '未知错误')); + } + } catch (e: any) { + message.error('获取表信息失败: ' + (e?.message || String(e))); + } finally { + setLoading(false); + } + }, [connection, tab.dbName]); + + useEffect(() => { loadData(); }, [loadData]); + + const sortedFiltered = useMemo(() => { + let list = [...tables]; + if (searchText.trim()) { + const kw = searchText.trim().toLowerCase(); + list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw)); + } + list.sort((a, b) => { + let cmp = 0; + if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + else if (sortField === 'rows') cmp = a.rows - b.rows; + else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize; + return sortOrder === 'asc' ? cmp : -cmp; + }); + return list; + }, [tables, searchText, sortField, sortOrder]); + + const openTable = useCallback((tableName: string) => { + if (!connection) return; + addTab({ + id: `${connection.id}-${tab.dbName}-${tableName}`, + title: tableName, + type: 'table', + connectionId: connection.id, + dbName: tab.dbName, + tableName, + }); + }, [connection, tab.dbName, addTab]); + + const openDesign = useCallback((tableName: string) => { + if (!connection) return; + addTab({ + id: `design-${connection.id}-${tab.dbName}-${tableName}`, + title: `设计表 (${tableName})`, + type: 'design', + connectionId: connection.id, + dbName: tab.dbName, + tableName, + initialTab: 'columns', + readOnly: false, + }); + }, [connection, tab.dbName, addTab]); + + const buildConfig = useCallback(() => { + if (!connection) return null; + return { + ...connection.config, + port: Number(connection.config.port), + password: connection.config.password || '', + database: connection.config.database || '', + useSSH: connection.config.useSSH || false, + ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, + }; + }, [connection]); + + const handleCopyStructure = useCallback(async (tableName: string) => { + const config = buildConfig(); + if (!config) return; + const res = await DBShowCreateTable(config as any, tab.dbName || '', tableName); + if (res.success) { + navigator.clipboard.writeText(res.data as string); + message.success('表结构已复制到剪贴板'); + } else { + message.error(res.message); + } + }, [buildConfig, tab.dbName]); + + const handleExport = useCallback(async (tableName: string, format: string) => { + const config = buildConfig(); + if (!config) return; + const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0); + const res = await ExportTable(config as any, tab.dbName || '', tableName, format); + hide(); + if (res.success) { + message.success('导出成功'); + } else if (res.message !== '已取消') { + message.error('导出失败: ' + res.message); + } + }, [buildConfig, tab.dbName]); + + const handleDeleteTable = useCallback((tableName: string) => { + const config = buildConfig(); + if (!config) return; + Modal.confirm({ + title: '确认删除表', + content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`, + okButtonProps: { danger: true }, + onOk: async () => { + const res = await DropTable(config as any, tab.dbName || '', tableName); + if (res.success) { + message.success('表删除成功'); + loadData(); + } else { + message.error('删除失败: ' + res.message); + } + }, + }); + }, [buildConfig, tab.dbName, loadData]); + + const handleRenameTable = useCallback((tableName: string) => { + const config = buildConfig(); + if (!config) return; + let newName = tableName; + Modal.confirm({ + title: '重命名表', + content: ( + { newName = e.target.value; }} + placeholder="输入新表名" + autoFocus + style={{ marginTop: 8 }} + /> + ), + onOk: async () => { + const trimmed = newName.trim(); + if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); } + if (trimmed === tableName) { message.warning('新旧表名相同'); return; } + const res = await RenameTable(config as any, tab.dbName || '', tableName, trimmed); + if (res.success) { + message.success('表重命名成功'); + loadData(); + } else { + message.error('重命名失败: ' + res.message); + } + }, + }); + }, [buildConfig, tab.dbName, loadData]); + + // --- Theme --- + const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'; + const cardHoverBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'; + const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'; + const textPrimary = darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)'; + const textSecondary = darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)'; + const textMuted = darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)'; + const accentColor = '#1677ff'; + const containerBg = darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.01)'; + + const toggleSort = (field: SortField) => { + if (sortField === field) { + setSortOrder(o => o === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortOrder(field === 'name' ? 'asc' : 'desc'); + } + }; + + const sortMenuItems = [ + { key: 'name', label: `按名称${sortField === 'name' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('name') }, + { key: 'rows', label: `按行数${sortField === 'rows' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('rows') }, + { key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') }, + ]; + + const totalRows = tables.reduce((s, t) => s + t.rows, 0); + const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+ + {tab.dbName} + + {tables.length} 张表 · {formatRows(totalRows)} 行 · {formatSize(totalSize)} + +
+ } + value={searchText} + onChange={e => setSearchText(e.target.value)} + allowClear + style={{ width: 240 }} + size="small" + /> + + + + +
+ + {/* Cards Grid */} +
+ {sortedFiltered.length === 0 ? ( + + ) : ( +
+ {sortedFiltered.map(t => ( + , onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: '新建查询', + type: 'query', + connectionId: tab.connectionId, + dbName: tab.dbName, + query: `SELECT * FROM ${t.name};`, + }); + }}, + { type: 'divider' }, + { key: 'design-table', label: '设计表', icon: , onClick: () => openDesign(t.name) }, + { key: 'copy-structure', label: '复制表结构', icon: , onClick: () => handleCopyStructure(t.name) }, + { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(t.name, 'sql') }, + { key: 'rename-table', label: '重命名表', icon: , onClick: () => handleRenameTable(t.name) }, + { key: 'drop-table', label: '删除表', icon: , danger: true, onClick: () => handleDeleteTable(t.name) }, + { type: 'divider' }, + { key: 'export', label: '导出表数据', icon: , children: [ + { key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') }, + { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') }, + { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') }, + { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') }, + { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') }, + ]}, + ], + }} + > +
openTable(t.name)} + style={{ + background: cardBg, + border: `1px solid ${cardBorder}`, + borderRadius: 10, + padding: '14px 16px', + cursor: 'pointer', + transition: 'all 0.15s ease', + userSelect: 'none', + }} + onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }} + onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }} + > +
+ + + + {t.name} + + +
+ {t.comment && ( + +
+ {t.comment} +
+
+ )} +
+ 📊 {formatRows(t.rows)} + 💾 {formatSize(t.dataSize)} + {t.engine && {t.engine}} +
+
+
+ ))} +
+ )} +
+
+ ); +}; + +export default TableOverview; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 662a02c..d173baf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -116,7 +116,7 @@ export interface TriggerDefinition { export interface TabData { id: string; title: string; - type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def'; + type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview'; connectionId: string; dbName?: string; tableName?: string;