mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-06 14:39:35 +08:00
✨ feat(sidebar/table-overview): 优化侧边栏交互并新增表概览列表视图
- 修复连接刷新后数据库节点无法再次展开的问题,刷新时清除子节点 expandedKeys/loadedKeys/loadingRef - 表概览由双击改为单击"表(N)"分组节点打开,双击仅触发展开/折叠 - 使用 clickTimerRef 延时防抖区分单击与双击事件,避免双击同时打开表概览 - 表概览新增列表视图模式,展示表名、注释、行数、数据大小、索引大小、引擎等列 - 工具栏新增卡片/列表视图切换按钮,两种视图共享搜索、排序和右键菜单 - refs #296 - refs #324
This commit is contained in:
@@ -175,6 +175,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const selectedNodesRef = useRef<any[]>([]);
|
||||
const loadingNodesRef = useRef<Set<string>>(new Set());
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | 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: <ReloadOutlined />,
|
||||
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: <ReloadOutlined />,
|
||||
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' },
|
||||
{
|
||||
|
||||
@@ -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<TableOverviewProps> = ({ tab }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('card');
|
||||
|
||||
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
|
||||
|
||||
@@ -366,14 +368,43 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
|
||||
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||
</Dropdown>
|
||||
<div style={{ display: 'flex', gap: 2, padding: 2, borderRadius: 6, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
|
||||
<Tooltip title="卡片视图">
|
||||
<div
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<AppstoreOutlined style={{ fontSize: 14 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="列表视图">
|
||||
<div
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<UnorderedListOutlined style={{ fontSize: 14 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
{/* Content Area */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
|
||||
{sortedFiltered.length === 0 ? (
|
||||
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
|
||||
) : (
|
||||
) : viewMode === 'card' ? (
|
||||
/* ========== 卡片视图 ========== */
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
@@ -451,6 +482,115 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</Dropdown>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ========== 列表/表格视图 ========== */
|
||||
<div style={{ borderRadius: 8, border: `1px solid ${cardBorder}`, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)' }}>
|
||||
{[
|
||||
{ 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) => (
|
||||
<th
|
||||
key={idx}
|
||||
onClick={col.field ? () => 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 && (
|
||||
<span style={{ marginLeft: 4, fontSize: 11 }}>
|
||||
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedFiltered.map((t, rowIdx) => (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, 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: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, 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') },
|
||||
]},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<tr
|
||||
onDoubleClick={() => 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'; }}
|
||||
>
|
||||
<td style={{ padding: '10px 14px', color: textPrimary, fontWeight: 500 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
|
||||
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 14px', color: textSecondary, maxWidth: 260, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.comment ? (
|
||||
<Tooltip title={t.comment} mouseEnterDelay={0.4}><span>{t.comment}</span></Tooltip>
|
||||
) : (
|
||||
<span style={{ color: textMuted }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</td>
|
||||
<td style={{ padding: '10px 14px', textAlign: 'right', color: textMuted }}>{t.engine || '—'}</td>
|
||||
</tr>
|
||||
</Dropdown>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user