From 08ab06c038160a43dfcc90e155abae9bc8724d75 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 1 Apr 2026 15:29:42 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sidebar/table-overview):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BE=A7=E8=BE=B9=E6=A0=8F=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E8=A1=A8=E6=A6=82=E8=A7=88=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复连接刷新后数据库节点无法再次展开的问题,刷新时清除子节点 expandedKeys/loadedKeys/loadingRef - 表概览由双击改为单击"表(N)"分组节点打开,双击仅触发展开/折叠 - 使用 clickTimerRef 延时防抖区分单击与双击事件,避免双击同时打开表概览 - 表概览新增列表视图模式,展示表名、注释、行数、数据大小、索引大小、引擎等列 - 工具栏新增卡片/列表视图切换按钮,两种视图共享搜索、排序和右键菜单 - refs #296 - refs #324 --- frontend/src/components/Sidebar.tsx | 59 ++++++--- frontend/src/components/TableOverview.tsx | 146 +++++++++++++++++++++- 2 files changed, 187 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 58658f3..b50d09f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -175,6 +175,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const [selectedKeys, setSelectedKeys] = useState([]); const selectedNodesRef = useRef([]); const loadingNodesRef = useRef>(new Set()); + const clickTimerRef = useRef | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null); // Virtual Scroll State @@ -1456,6 +1457,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false); else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false); else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false); + else if (type === 'object-group' && dataRef?.groupKey === 'tables') { + // 单击延迟打开表概览,双击时会取消此定时器 + if (clickTimerRef.current) clearTimeout(clickTimerRef.current); + const { id, dbName: gDbName, schemaName } = dataRef; + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = null; + addTab({ + id: `table-overview-${id}-${gDbName}${schemaName ? `-${schemaName}` : ''}`, + title: `表概览 - ${gDbName}${schemaName ? ` (${schemaName})` : ''}`, + type: 'table-overview' as any, + connectionId: id, + dbName: gDbName, + schemaName, + } as any); + }, 250); + } }; const onExpand = (newExpandedKeys: React.Key[]) => { @@ -1464,7 +1481,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; const onDoubleClick = (e: any, node: any) => { - // 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态 + // 双击时取消单击延迟动作(如表概览打开),让双击只触发展开/折叠 + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } const { type, dataRef, key: nodeKey } = node; if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' }); else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); @@ -1472,18 +1493,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` }); - 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, - schemaName, - } as any); - return; - } if (node.type === 'table') { const { tableName, dbName, id } = node.dataRef; // 记录表访问 @@ -3090,7 +3099,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> key: 'refresh', label: '刷新', icon: , - onClick: () => loadDatabases(node) + onClick: () => { + const connKey = String(node.key); + // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData + setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); + setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); + // 清除 loadingNodesRef 中残留的子节点加载标记 + Array.from(loadingNodesRef.current).forEach(lk => { + if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); + }); + loadDatabases(node); + } }, { type: 'divider' }, { @@ -3207,7 +3226,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> key: 'refresh', label: '刷新', icon: , - onClick: () => loadDatabases(node) + onClick: () => { + const connKey = String(node.key); + // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData + setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); + setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); + // 清除 loadingNodesRef 中残留的子节点加载标记 + Array.from(loadingNodesRef.current).forEach(lk => { + if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); + }); + loadDatabases(node); + } }, { type: 'divider' }, { diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index f1d641d..da611e3 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -1,6 +1,6 @@ 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 { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App'; import type { TabData } from '../types'; @@ -22,6 +22,7 @@ interface TableStatRow { type SortField = 'name' | 'rows' | 'dataSize'; type SortOrder = 'asc' | 'desc'; +type ViewMode = 'card' | 'list'; const formatSize = (bytes: number): string => { if (!bytes || bytes <= 0) return '—'; @@ -146,6 +147,7 @@ const TableOverview: React.FC = ({ tab }) => { const [searchText, setSearchText] = useState(''); const [sortField, setSortField] = useState('name'); const [sortOrder, setSortOrder] = useState('asc'); + const [viewMode, setViewMode] = useState('card'); const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]); @@ -366,14 +368,43 @@ const TableOverview: React.FC = ({ tab }) => { +
+ +
setViewMode('card')} + style={{ + padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s', + background: viewMode === 'card' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent', + boxShadow: viewMode === 'card' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', + color: viewMode === 'card' ? accentColor : textMuted, + }} + > + +
+
+ +
setViewMode('list')} + style={{ + padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s', + background: viewMode === 'list' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent', + boxShadow: viewMode === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', + color: viewMode === 'list' ? accentColor : textMuted, + }} + > + +
+
+
- {/* Cards Grid */} + {/* Content Area */}
{sortedFiltered.length === 0 ? ( - ) : ( + ) : viewMode === 'card' ? ( + /* ========== 卡片视图 ========== */
= ({ tab }) => { ))}
+ ) : ( + /* ========== 列表/表格视图 ========== */ +
+ + + + {[ + { field: 'name' as SortField, label: '表名', width: undefined }, + { field: null, label: '注释', width: undefined }, + { field: 'rows' as SortField, label: '行数', width: 100 }, + { field: 'dataSize' as SortField, label: '数据大小', width: 110 }, + { field: null, label: '索引大小', width: 110 }, + { field: null, label: '引擎', width: 90 }, + ].map((col, idx) => ( + + ))} + + + + {sortedFiltered.map((t, rowIdx) => ( + , onClick: () => { + setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' }); + 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={{ + cursor: 'pointer', + transition: 'background 0.12s', + borderBottom: rowIdx < sortedFiltered.length - 1 ? `1px solid ${cardBorder}` : 'none', + }} + onMouseEnter={e => { (e.currentTarget as HTMLTableRowElement).style.background = cardHoverBg; }} + onMouseLeave={e => { (e.currentTarget as HTMLTableRowElement).style.background = 'transparent'; }} + > + + + + + + + + + ))} + +
toggleSort(col.field!) : undefined} + style={{ + padding: '10px 14px', + textAlign: idx >= 2 ? 'right' : 'left', + fontWeight: 600, + color: textSecondary, + borderBottom: `1px solid ${cardBorder}`, + cursor: col.field ? 'pointer' : 'default', + userSelect: 'none', + whiteSpace: 'nowrap', + width: col.width, + }} + > + {col.label} + {col.field && sortField === col.field && ( + + {sortOrder === 'asc' ? '↑' : '↓'} + + )} +
+
+ + + {t.name} + +
+
+ {t.comment ? ( + {t.comment} + ) : ( + + )} + {formatRows(t.rows)}{formatSize(t.dataSize)}{formatSize(t.indexSize)}{t.engine || '—'}
+
)}