🐛 fix(ui): 修复 v2 数据视图交互回归

- 筛选优化:隔离 WHERE 输入剪贴板事件并让字号跟随全局设置

- 表视图优化:补齐表头和单元格新版右键菜单及行列复制能力

- 置顶同步:卡片视图、列表视图和左侧对象树统一展示置顶分组

- 数据视图优化:调整分页、字段显示、DDL 侧栏和横向滚动同步体验

- 测试覆盖:补充 DataGrid、Sidebar 和表概览置顶分组回归测试
This commit is contained in:
Syngnat
2026-05-26 08:26:52 +08:00
parent 9118406de3
commit aa2177d35a
10 changed files with 2351 additions and 569 deletions

View File

@@ -65,6 +65,7 @@ const backendApp = vi.hoisted(() => ({
const testRenderState = vi.hoisted(() => ({
latestColumns: [] as any[],
latestTableProps: null as any,
}));
const messageApi = vi.hoisted(() => ({
@@ -81,6 +82,14 @@ vi.mock('../store', () => ({
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('react-dom', async () => {
const actual = await vi.importActual<any>('react-dom');
return {
...actual,
createPortal: (children: React.ReactNode) => children,
};
});
vi.mock('@monaco-editor/react', () => ({
default: (props: { value?: string; language?: string; theme?: string; options?: Record<string, unknown> }) => (
<div
@@ -118,12 +127,16 @@ vi.mock('@ant-design/icons', () => {
ClearOutlined: Icon,
EditOutlined: Icon,
VerticalAlignBottomOutlined: Icon,
ColumnWidthOutlined: Icon,
EyeInvisibleOutlined: Icon,
LeftOutlined: Icon,
RightOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
LinkOutlined: Icon,
TableOutlined: Icon,
SortAscendingOutlined: Icon,
SortDescendingOutlined: Icon,
DatabaseOutlined: Icon,
NodeIndexOutlined: Icon,
ThunderboltOutlined: Icon,
@@ -220,8 +233,10 @@ vi.mock('antd', () => {
);
return {
Table: ({ columns }: any) => {
Table: (props: any) => {
const { columns } = props;
testRenderState.latestColumns = Array.isArray(columns) ? columns : [];
testRenderState.latestTableProps = props;
return <table />;
},
message: messageApi,
@@ -475,6 +490,7 @@ describe('DataGrid DDL interactions', () => {
storeState.addTab.mockReset();
storeState.setActiveContext.mockReset();
testRenderState.latestColumns = [];
testRenderState.latestTableProps = null;
vi.stubGlobal('document', {
addEventListener: vi.fn(),
@@ -617,6 +633,225 @@ describe('DataGrid DDL interactions', () => {
},
);
it('marks v2 table headers as single-line when column type and comment rows are hidden', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.queryOptions.showColumnComment = false;
storeState.queryOptions.showColumnType = false;
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
const idColumn = testRenderState.latestColumns.find((column) => column.key === 'id');
expect(idColumn).toBeTruthy();
expect(idColumn.onHeaderCell(idColumn).className).toContain('is-single-line-title');
const headerRenderer = create(<>{idColumn.title}</>);
expect(headerRenderer.root.findByProps({ 'data-grid-column-title-single-line': 'true' })).toBeTruthy();
expect(headerRenderer.root.findAllByProps({ className: 'gn-v2-column-title-type' })).toHaveLength(0);
expect(headerRenderer.root.findAllByProps({ className: 'gn-v2-column-title-comment' })).toHaveLength(0);
renderer!.unmount();
});
it('opens the v2 column header context menu from table headers', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.queryOptions.showColumnComment = true;
storeState.queryOptions.showColumnType = true;
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'id', type: 'bigint', comment: '主键 ID' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
const idColumn = testRenderState.latestColumns.find((column) => column.key === 'id');
expect(idColumn).toBeTruthy();
const headerProps = idColumn.onHeaderCell(idColumn);
await act(async () => {
headerProps.onContextMenu({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 120,
clientY: 88,
});
});
expect(renderer!.root.findByProps({ 'data-v2-column-context-menu': 'true' })).toBeTruthy();
expect(textContent(renderer!.root)).toContain('复制字段名称');
expect(textContent(renderer!.root)).toContain('复制列数据');
expect(textContent(renderer!.root)).toContain('升序排序');
expect(textContent(renderer!.root)).toContain('隐藏此字段');
expect(textContent(renderer!.root)).toContain('隐藏字段类型');
expect(textContent(renderer!.root)).toContain('隐藏字段备注');
renderer!.unmount();
});
it('opens the v2 cell context menu for table cells instead of the legacy inline menu', async () => {
storeState.appearance.uiVersion = 'v2';
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
const idColumn = testRenderState.latestColumns.find((column) => column.key === 'id');
const cellProps = idColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1 });
await act(async () => {
cellProps.onContextMenu({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 160,
clientY: 120,
});
});
expect(renderer!.root.findByProps({ 'data-v2-cell-context-menu': 'true' })).toBeTruthy();
expect(textContent(renderer!.root)).toContain('复制字段名称');
expect(textContent(renderer!.root)).toContain('复制行数据');
expect(textContent(renderer!.root)).toContain('复制列数据');
expect(textContent(renderer!.root)).toContain('复制为 INSERT');
expect(textContent(renderer!.root)).toContain('导出');
renderer!.unmount();
});
it('copies loaded column data from the v2 column header context menu', async () => {
storeState.appearance.uiVersion = 'v2';
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[
{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' },
{ __gonavi_row_key__: 'row-2', id: 2, name: 'beta' },
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
const idColumn = testRenderState.latestColumns.find((column) => column.key === 'id');
const headerProps = idColumn.onHeaderCell(idColumn);
await act(async () => {
headerProps.onContextMenu({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 120,
clientY: 88,
});
});
await act(async () => {
findButton(renderer!, '复制列数据').props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('1\n2');
renderer!.unmount();
});
it('copies row and column data from the v2 cell context menu', async () => {
storeState.appearance.uiVersion = 'v2';
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[
{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' },
{ __gonavi_row_key__: 'row-2', id: 2, name: 'beta' },
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name');
const cellProps = nameColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' });
await act(async () => {
cellProps.onContextMenu({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 160,
clientY: 120,
});
});
await act(async () => {
findButton(renderer!, '复制行数据').props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(navigator.clipboard.writeText).toHaveBeenLastCalledWith('id\tname\n1\talpha');
await act(async () => {
cellProps.onContextMenu({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 160,
clientY: 120,
});
});
await act(async () => {
findButton(renderer!, '复制列数据').props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(navigator.clipboard.writeText).toHaveBeenLastCalledWith('alpha\nbeta');
renderer!.unmount();
});
it('switches the v2 footer field tab into the main fields view', async () => {
storeState.appearance.uiVersion = 'v2';

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { readFileSync } from 'node:fs';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
@@ -9,6 +10,7 @@ import DataGrid, {
resolveDefaultGridFilterOperator,
resolveNextGridFilterOperatorForColumnChange,
} from './DataGrid';
import { cloneShortcutOptions, DEFAULT_SHORTCUT_OPTIONS } from '../utils/shortcuts';
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
@@ -40,6 +42,7 @@ vi.mock('../store', () => ({
setTableHiddenColumns: vi.fn(),
setEnableHiddenColumnMemory: vi.fn(),
clearTableHiddenColumns: vi.fn(),
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}),
@@ -87,12 +90,70 @@ describe('DataGrid layout', () => {
expect(markup).toContain('data-grid-secondary-actions="true"');
expect(markup).toContain('data-grid-view-switcher="true"');
expect(markup).toContain('data-grid-column-display-action="true"');
expect(markup).toContain('字段显示');
expect(markup).toContain('data-grid-page-find="true"');
expect(markup).toContain('data-grid-page-find-prev="true"');
expect(markup).toContain('data-grid-page-find-next="true"');
expect(markup).not.toContain('gn-v2-data-grid-status-right');
expect(markup).not.toContain('gn-v2-data-grid-status-spacer');
expect(markup).toContain('gn-v2-data-grid-pagination-spacer');
expect(markup).toContain('data-grid-v2-pagination="true"');
expect(markup).toContain('data-grid-v2-page-chip="true"');
expect(markup).toContain('data-grid-v2-pagination-prev="true"');
expect(markup).toContain('data-grid-v2-pagination-next="true"');
expect(markup).not.toContain('class="ant-pagination');
expect(markup).not.toContain('class="data-grid-pagination-kicker"');
expect(markup).toContain('当前页查找...');
});
it('renders the v2 DataGrid toolbar using the redesigned topbar hooks', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
editLocator={{
strategy: 'primary-key',
columns: ['id'],
valueColumns: ['id'],
readOnly: false,
}}
onReload={() => {}}
showFilter
onToggleFilter={() => {}}
pagination={{
current: 1,
pageSize: 100,
total: 1,
}}
onPageChange={() => {}}
/>,
);
expect(markup).toContain('gn-v2-data-grid');
expect(markup).toContain('gn-v2-data-grid-toolbar-frame');
expect(markup).toContain('gn-v2-data-grid-toolbar-title');
expect(markup).toContain('gn-v2-toolbar-divider');
expect(markup).toContain('gn-v2-commit-button');
expect(markup).toContain('gn-v2-ai-insight-button');
expect(markup).toContain('gn-v2-smart-filter-panel');
expect(markup).toContain('gn-v2-data-grid-table-shell');
expect(markup).toContain('gn-v2-data-grid-table-wrap');
expect(markup).toContain('· main');
expect(markup).toContain('提交事务');
expect(markup).toContain('AI 洞察');
});
it('preserves fractional seconds when rendering datetime values', () => {
expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456');
});
@@ -278,7 +339,40 @@ describe('DataGrid layout', () => {
);
expect(markup).toContain('data-grid-quick-where="true"');
expect(markup).toContain('data-grid-quick-where-input="true"');
expect(markup).toContain('WHERE');
expect(markup).toContain('输入 WHERE 后面的条件');
});
it('keeps quick WHERE input clipboard editing isolated from grid shortcuts', () => {
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
expect(source).toContain('const handleQuickWherePaste = useCallback');
expect(source).toContain("event.clipboardData.getData('text/plain')");
expect(source).toContain('const currentValue = input.value ?? quickWhereDraft;');
expect(source).toContain('event.stopPropagation();');
expect(source).toContain('data-grid-quick-where-input="true"');
expect(source).toContain('{...noAutoCapInputProps}');
expect(source).toContain('onCopy={stopQuickWhereClipboardPropagation}');
expect(source).toContain('onCut={stopQuickWhereClipboardPropagation}');
expect(source).toContain('onPaste={handleQuickWherePaste}');
expect(source).toContain("['c', 'v', 'x'].includes");
expect(css).toContain('[data-grid-quick-where-input="true"]');
expect(css).toContain('font-size: var(--gn-font-size, 14px) !important;');
expect(css).toContain('user-select: text !important;');
});
it('keeps DataGrid scroll synchronization throttled to animation frames', () => {
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
expect(source).toContain('virtualHorizontalElementsRef');
expect(source).toContain('const scheduleVirtualHorizontalWheel = useCallback');
expect(source).toContain('pendingTableHorizontalDeltaRef.current += delta;');
expect(source).toContain('tableHorizontalWheelRafRef.current = requestAnimationFrame');
expect(source).toContain('if (externalSyncRafRef.current !== null)');
expect(source).toContain('externalSyncRafRef.current = requestAnimationFrame');
expect(source).toContain('if (scrollSnapshotRafRef.current !== null) return;');
expect(source).toContain('scrollSnapshotRafRef.current = requestAnimationFrame');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Sidebar, {
buildV2SidebarTableSectionedChildren,
buildV2RailConnectionGroups,
filterV2ExplorerTreeByKind,
getV2RailConnectionGroupBadgeText,
@@ -628,6 +629,63 @@ describe('Sidebar locate toolbar', () => {
}).map((entry) => entry.tableName)).toEqual(['orders', 'users', 'audit']);
});
it('keeps the v2 table pin action on sidebar table rows', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
expect(source).toContain('data-v2-sidebar-table-pin-action="true"');
expect(source).toContain('node?.dataRef?.pinnedSidebarTable ? <StarFilled /> : <StarOutlined />');
expect(source).toContain('toggleSidebarTablePinned(node);');
expect(source).toContain("message.success(shouldPin ? '已置顶表' : '已取消置顶');");
expect(css).toMatch(/\.gn-v2-table-pin-action \{[^}]*opacity: 0;/s);
expect(css).toMatch(/\.gn-v2-table-pin-action\.is-pinned \{[^}]*color: #f59e0b;[^}]*opacity: 1;/s);
expect(css).toMatch(/\.ant-tree-node-content-wrapper:hover \.gn-v2-table-pin-action,/s);
});
it('splits v2 sidebar pinned tables into a dedicated table section', () => {
const children = buildV2SidebarTableSectionedChildren('conn-main-tables', [
{ title: 'orders', key: 'orders', type: 'table', dataRef: { pinnedSidebarTable: true } },
{ title: 'users', key: 'users', type: 'table', dataRef: { pinnedSidebarTable: false } },
{ title: 'audit', key: 'audit', type: 'table', dataRef: {} },
]);
expect(children.map((node) => node.title)).toEqual(['置顶', 'orders', '全部', 'users', 'audit']);
expect(children.map((node) => node.type)).toEqual(['v2-table-section', 'table', 'v2-table-section', 'table', 'table']);
expect(children[0]).toMatchObject({
key: 'conn-main-tables-v2-pinned-tables-section',
isLeaf: true,
selectable: false,
dataRef: { sectionKind: 'pinned' },
});
expect(children[2]).toMatchObject({
key: 'conn-main-tables-v2-all-tables-section',
isLeaf: true,
selectable: false,
dataRef: { sectionKind: 'all' },
});
});
it('keeps v2 table sections out of regular table lists when nothing is pinned', () => {
const tableNodes = [
{ title: 'users', key: 'users', type: 'table' as const, dataRef: { pinnedSidebarTable: false } },
];
expect(buildV2SidebarTableSectionedChildren('conn-main-tables', tableNodes)).toBe(tableNodes);
});
it('renders v2 table section labels as tree children instead of group header badges', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
expect(source).toContain("node.type === 'v2-table-section'");
expect(source).toContain('className="gn-v2-tree-section-title"');
expect(source).not.toContain('gn-v2-tree-section-label');
expect(source).toContain("if (isV2Ui && node?.type === 'v2-table-section')");
expect(source).toContain("if (isV2Ui && info?.node?.type === 'v2-table-section')");
expect(css).toContain('.gn-v2-tree-section-title');
expect(css).toContain('.ant-tree-treenode:has(.gn-v2-tree-section-title)');
});
it('formats v2 table context menu stats like the prototype header', () => {
expect(formatV2TableContextMenuRows(2)).toBe('2 行');
expect(formatV2TableContextMenuSize(16 * 1024)).toBe('16 KB');
@@ -723,4 +781,13 @@ describe('Sidebar locate toolbar', () => {
expect(markup).toContain('按使用频率排序');
expect(markup).toContain('当前');
});
it('listens for table overview pin changes to refresh the matching sidebar database node', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain("window.addEventListener('gonavi:sidebar-table-pin-changed'");
expect(source).toContain('findTreeNodeByKeyRef.current(treeDataRef.current, `${connectionId}-${dbName}`)');
expect(source).toContain('void loadTables(dbNode);');
expect(source).toContain("window.removeEventListener('gonavi:sidebar-table-pin-changed'");
});
});

View File

@@ -41,7 +41,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
ToolOutlined,
SettingOutlined,
BarsOutlined,
PushpinOutlined
StarFilled,
StarOutlined
} from '@ant-design/icons';
import { buildSidebarTablePinKey, useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
@@ -103,10 +104,11 @@ interface TreeNode {
title: string;
key: string;
isLeaf?: boolean;
selectable?: boolean;
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
}
const isV2SidebarObjectNode = (node: Pick<TreeNode, 'type'> | null | undefined): boolean => {
@@ -192,6 +194,33 @@ export const sortSidebarTableEntries = <T extends SidebarTableEntryForSort>(
});
};
export const buildV2SidebarTableSectionedChildren = (
parentKey: string,
tableNodes: TreeNode[],
): TreeNode[] => {
const pinnedTables = tableNodes.filter((node) => node?.dataRef?.pinnedSidebarTable);
if (pinnedTables.length === 0) return tableNodes;
const regularTables = tableNodes.filter((node) => !node?.dataRef?.pinnedSidebarTable);
const buildSectionNode = (kind: 'pinned' | 'all', title: string): TreeNode => ({
title,
key: `${parentKey}-v2-${kind}-tables-section`,
type: 'v2-table-section',
isLeaf: true,
selectable: false,
dataRef: {
sectionKind: kind,
},
});
return [
buildSectionNode('pinned', '置顶'),
...pinnedTables,
buildSectionNode('all', '全部'),
...regularTables,
];
};
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
type BatchObjectType = 'table' | 'view';
type BatchObjectFilterType = 'all' | BatchObjectType;
@@ -2151,7 +2180,7 @@ const Sidebar: React.FC<{
return {
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-${entry.tableName}`,
icon: isPinned ? <PushpinOutlined /> : <TableOutlined />,
icon: <TableOutlined />,
type: 'table',
dataRef: { ...conn, tableName: entry.tableName, schemaName: entry.schemaName, pinnedSidebarTable: isPinned },
isLeaf: false,
@@ -2210,15 +2239,21 @@ const Sidebar: React.FC<{
groupIcon: React.ReactNode,
children: TreeNode[],
extraData: Record<string, any> = {}
): TreeNode => ({
title: groupTitle,
key: `${parentKey}-${groupKey}`,
icon: groupIcon,
type: 'object-group',
isLeaf: children.length === 0,
children: children.length > 0 ? children : undefined,
dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData }
});
): TreeNode => {
const groupNodeKey = `${parentKey}-${groupKey}`;
const groupedChildren = groupKey === 'tables'
? buildV2SidebarTableSectionedChildren(groupNodeKey, children)
: children;
return {
title: groupTitle,
key: groupNodeKey,
icon: groupIcon,
type: 'object-group',
isLeaf: children.length === 0,
children: groupedChildren.length > 0 ? groupedChildren : undefined,
dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData }
};
};
const shouldGroupBySchema = shouldHideSchemaPrefix(conn as SavedConnection);
if (shouldGroupBySchema) {
@@ -2419,6 +2454,23 @@ const Sidebar: React.FC<{
};
}, []);
useEffect(() => {
const handleSidebarTablePinChanged = (event: Event) => {
const detail = (event as CustomEvent).detail || {};
const connectionId = String(detail.connectionId || '').trim();
const dbName = String(detail.dbName || '').trim();
if (!connectionId || !dbName) return;
const dbNode = findTreeNodeByKeyRef.current(treeDataRef.current, `${connectionId}-${dbName}`);
if (dbNode) {
void loadTables(dbNode);
}
};
window.addEventListener('gonavi:sidebar-table-pin-changed', handleSidebarTablePinChanged as EventListener);
return () => {
window.removeEventListener('gonavi:sidebar-table-pin-changed', handleSidebarTablePinChanged as EventListener);
};
}, []);
const onLoadData = async ({ key, children, dataRef, type }: any) => {
if (type === 'tag') return;
if (hasSidebarLazyChildren(children)) return;
@@ -2503,6 +2555,9 @@ const Sidebar: React.FC<{
const isV2Ui = (uiVersion ?? appearance.uiVersion) === 'v2';
const onSelect = (keys: React.Key[], info: any) => {
if (isV2Ui && info?.node?.type === 'v2-table-section') {
return;
}
setSelectedKeys(keys);
selectedNodesRef.current = info.selectedNodes || [];
@@ -2573,6 +2628,9 @@ const Sidebar: React.FC<{
clickTimerRef.current = null;
}
const { type, dataRef, key: nodeKey } = node;
if (isV2Ui && type === 'v2-table-section') {
return;
}
const nodeConnectionId = resolveSidebarNodeConnectionId(node, connectionIds);
if (type === 'connection') {
setSelectedKeys([nodeKey]);
@@ -4280,14 +4338,7 @@ const Sidebar: React.FC<{
switch (action) {
case 'pin-table':
case 'unpin-table': {
const conn = node.dataRef || {};
const tableName = String(conn.tableName || '').trim();
const dbName = String(conn.dbName || '').trim();
if (!conn.id || !dbName || !tableName) return;
const shouldPin = action === 'pin-table';
setSidebarTablePinned(conn.id, dbName, tableName, conn.schemaName || '', shouldPin);
void loadTables(getDatabaseNodeRef(conn, dbName));
message.success(shouldPin ? '已置顶表' : '已取消置顶');
toggleSidebarTablePinned(node, action === 'pin-table');
return;
}
case 'open-data':
@@ -4365,6 +4416,24 @@ const Sidebar: React.FC<{
}
};
const toggleSidebarTablePinned = (node: any, pinned?: boolean) => {
const conn = node?.dataRef || {};
const tableName = String(conn.tableName || node?.title || '').trim();
const dbName = String(conn.dbName || '').trim();
if (!conn.id || !dbName || !tableName) return;
const currentlyPinned = isSidebarTablePinned(
pinnedSidebarTables,
String(conn.id || ''),
dbName,
tableName,
String(conn.schemaName || ''),
);
const shouldPin = pinned ?? !currentlyPinned;
setSidebarTablePinned(conn.id, dbName, tableName, conn.schemaName || '', shouldPin);
void loadTables(getDatabaseNodeRef(conn, dbName));
message.success(shouldPin ? '已置顶表' : '已取消置顶');
};
const handleTableGroupSortAction = (node: any, sortBy: 'name' | 'frequency') => {
const groupData = node.dataRef;
setTableSortPreference(groupData.id, groupData.dbName, sortBy);
@@ -5115,7 +5184,7 @@ const Sidebar: React.FC<{
if (node.type === 'database') {
databaseObjectCounts.set(node.key, childCount);
} else if (node.type === 'object-group') {
objectGroupCounts.set(node.key, Array.isArray(node.children) ? node.children.length : 0);
objectGroupCounts.set(node.key, childCount);
}
return totalCount;
};
@@ -5459,6 +5528,17 @@ const Sidebar: React.FC<{
const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => {
const rawTitle = String(node.title ?? '');
const groupKey = String(node?.dataRef?.groupKey || '');
if (node.type === 'v2-table-section') {
return (
<span
className="gn-v2-tree-section-title"
data-section-kind={node?.dataRef?.sectionKind || undefined}
title={rawTitle}
>
{rawTitle}
</span>
);
}
const displayTitle = (() => {
if (node.type === 'queries-folder') return '已存查询 · saved';
if (node.type === 'external-sql-root') return '外部 SQL 目录';
@@ -5481,14 +5561,40 @@ const Sidebar: React.FC<{
|| node.type === 'routine'
|| node.type === 'saved-query'
|| node.type === 'external-sql-file';
const sectionLabel = node.type === 'object-group' && groupKey === 'tables' && metaText ? (
<span className="gn-v2-tree-section-label"></span>
) : null;
const titleClassName = [
'gn-v2-tree-title',
isMono ? 'is-mono' : '',
node.type === 'object-group' ? 'is-group' : '',
node.type === 'table' && node?.dataRef?.pinnedSidebarTable ? 'is-pinned-table' : '',
].filter(Boolean).join(' ');
const tablePinAction = node.type === 'table' ? (
<button
type="button"
className={[
'gn-v2-table-pin-action',
node?.dataRef?.pinnedSidebarTable ? 'is-pinned' : '',
].filter(Boolean).join(' ')}
title={node?.dataRef?.pinnedSidebarTable ? '取消置顶表' : '置顶表'}
aria-label={node?.dataRef?.pinnedSidebarTable ? '取消置顶表' : '置顶表'}
aria-pressed={node?.dataRef?.pinnedSidebarTable ? true : false}
data-v2-sidebar-table-pin-action="true"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleSidebarTablePinned(node);
}}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
{node?.dataRef?.pinnedSidebarTable ? <StarFilled /> : <StarOutlined />}
</button>
) : null;
return (
<>
<span
@@ -5501,7 +5607,7 @@ const Sidebar: React.FC<{
<span className="gn-v2-tree-label">{displayTitle}</span>
{metaText && <span className="gn-v2-tree-count">{metaText}</span>}
</span>
{sectionLabel}
{tablePinAction}
</>
);
};
@@ -6717,6 +6823,11 @@ const Sidebar: React.FC<{
};
const onRightClick = ({ event, node }: any) => {
if (isV2Ui && node?.type === 'v2-table-section') {
event.preventDefault();
event.stopPropagation();
return;
}
if (isV2Ui && node?.type === 'connection') {
openV2ConnectionContextMenu(event, node);
return;

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } fr
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd';
import type { MenuProps } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { buildSidebarTablePinKey, useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
@@ -14,6 +14,7 @@ import {
TABLE_OVERVIEW_RENDER_BATCH_SIZE,
buildTableOverviewSearchIndex,
filterAndSortTableOverviewRows,
prioritizePinnedTableOverviewRows,
resolveTableOverviewVisibleRows,
type TableOverviewSortField,
type TableOverviewSortOrder,
@@ -39,6 +40,12 @@ interface TableStatRow {
type SortField = TableOverviewSortField;
type SortOrder = TableOverviewSortOrder;
type ViewMode = 'card' | 'list';
type OverviewTableSection = {
key: string;
title: string;
kind: 'pinned' | 'all';
rows: TableStatRow[];
};
const formatSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '—';
@@ -55,6 +62,17 @@ const formatRows = (count: number): string => {
return String(count);
};
const isOverviewTablePinned = (
pinnedKeys: string[],
connectionId: string | undefined,
dbName: string | undefined,
schemaName: string | undefined,
tableName: string,
): boolean => {
const key = buildSidebarTablePinKey(connectionId || '', dbName || '', tableName, schemaName || '');
return !!key && pinnedKeys.includes(key);
};
const getMetadataDialect = (connType: string, driver?: string, oceanBaseProtocol?: string): string => {
const type = (connType || '').trim().toLowerCase();
if (type === 'custom') {
@@ -177,6 +195,8 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const setActiveContext = useStore(state => state.setActiveContext);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const addAIContext = useStore(state => state.addAIContext);
const pinnedSidebarTables = useStore(state => state.pinnedSidebarTables);
const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned);
const darkMode = theme === 'dark';
const isV2Ui = appearance.uiVersion === 'v2';
@@ -196,6 +216,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver, connection?.config?.oceanBaseProtocol),
[connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type]
);
const schemaName = String((tab as any).schemaName || '').trim();
const autoFetchVisible = useAutoFetchVisibility();
const loadData = useCallback(async () => {
@@ -210,7 +231,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', (tab as any).schemaName);
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', schemaName);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(metadataDialect, res.data));
@@ -222,7 +243,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
} finally {
setLoading(false);
}
}, [connection, metadataDialect, tab.dbName]);
}, [connection, metadataDialect, schemaName, tab.dbName]);
useEffect(() => {
if (!autoFetchVisible) {
@@ -237,16 +258,40 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
filterAndSortTableOverviewRows(tableSearchIndex, deferredSearchText, sortField, sortOrder)
), [deferredSearchText, sortField, sortOrder, tableSearchIndex]);
const pinnedOverview = useMemo(() => (
prioritizePinnedTableOverviewRows(
sortedFiltered,
(table) => isOverviewTablePinned(pinnedSidebarTables, connection?.id, tab.dbName, schemaName, table.name),
)
), [connection?.id, pinnedSidebarTables, schemaName, sortedFiltered, tab.dbName]);
useEffect(() => {
setVisibleTableLimit(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
}, [deferredSearchText, sortField, sortOrder, viewMode, tables]);
}, [deferredSearchText, sortField, sortOrder, viewMode, tables, pinnedSidebarTables]);
const visibleOverview = useMemo(() => (
resolveTableOverviewVisibleRows(sortedFiltered, visibleTableLimit)
), [sortedFiltered, visibleTableLimit]);
resolveTableOverviewVisibleRows(pinnedOverview.orderedRows, visibleTableLimit)
), [pinnedOverview.orderedRows, visibleTableLimit]);
const visibleTables = visibleOverview.visibleRows;
const visibleTableSections = useMemo<OverviewTableSection[]>(() => {
if (pinnedOverview.pinnedRows.length === 0) {
return [{ key: 'all', title: '全部', kind: 'all', rows: visibleTables }];
}
const visiblePinnedNames = new Set(
visibleTables
.filter((table) => isOverviewTablePinned(pinnedSidebarTables, connection?.id, tab.dbName, schemaName, table.name))
.map((table) => table.name),
);
const pinnedRows = pinnedOverview.pinnedRows.filter((table) => visiblePinnedNames.has(table.name));
const regularRows = visibleTables.filter((table) => !visiblePinnedNames.has(table.name));
return [
...(pinnedRows.length > 0 ? [{ key: 'pinned', title: '置顶', kind: 'pinned' as const, rows: pinnedRows }] : []),
...(regularRows.length > 0 ? [{ key: 'all', title: '全部', kind: 'all' as const, rows: regularRows }] : []),
];
}, [connection?.id, pinnedOverview.pinnedRows, pinnedSidebarTables, schemaName, tab.dbName, visibleTables]);
const openTable = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
@@ -426,6 +471,26 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
});
}, [buildConfig, tab.dbName, loadData]);
const toggleOverviewTablePinned = useCallback((tableName: string, pinned?: boolean) => {
if (!connection?.id || !tab.dbName || !tableName) return;
const currentlyPinned = isOverviewTablePinned(
pinnedSidebarTables,
connection.id,
tab.dbName,
schemaName,
tableName,
);
const shouldPin = pinned ?? !currentlyPinned;
setSidebarTablePinned(connection.id, tab.dbName, tableName, schemaName, shouldPin);
window.dispatchEvent(new CustomEvent('gonavi:sidebar-table-pin-changed', {
detail: {
connectionId: connection.id,
dbName: tab.dbName,
},
}));
message.success(shouldPin ? '已置顶表' : '已取消置顶');
}, [connection?.id, pinnedSidebarTables, schemaName, setSidebarTablePinned, tab.dbName]);
const handleRenameTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
@@ -550,6 +615,12 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
case 'open-new-tab':
openTable(tableName);
return;
case 'pin-table':
toggleOverviewTablePinned(tableName, true);
return;
case 'unpin-table':
toggleOverviewTablePinned(tableName, false);
return;
case 'design-table':
openDesign(tableName);
return;
@@ -623,6 +694,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
openTable,
openTableDdl,
openTableInER,
toggleOverviewTablePinned,
]);
const renderV2OverviewTableContextMenu = useCallback((table: TableStatRow) => (
@@ -634,6 +706,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
indexLength: table.indexSize,
engine: table.engine,
}}
isPinned={isOverviewTablePinned(pinnedSidebarTables, connection?.id, tab.dbName, schemaName, table.name)}
supportsTruncate={allowTruncate}
supportsStarRocksRollup={metadataDialect === 'starrocks'}
onAction={(action) => {
@@ -641,7 +714,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
handleV2TableContextMenuAction(table, action);
}}
/>
), [allowTruncate, handleV2TableContextMenuAction, metadataDialect]);
), [allowTruncate, connection?.id, handleV2TableContextMenuAction, metadataDialect, pinnedSidebarTables, schemaName, tab.dbName]);
const buildLegacyTableContextMenuItems = useCallback((table: TableStatRow): MenuProps['items'] => [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => openQueryForTable(table.name) },
@@ -676,6 +749,192 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
openQueryForTable,
]);
const renderOverviewSectionTitle = (section: OverviewTableSection) => (
<div
className={isV2Ui ? 'gn-v2-table-overview-section-title' : undefined}
data-overview-table-section={section.kind}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
margin: section.kind === 'pinned' ? '0 0 8px' : '14px 0 8px',
color: textMuted,
fontSize: 12,
fontWeight: 600,
}}
>
<span>{section.title}</span>
<span>{section.rows.length}</span>
</div>
);
const renderCardTable = (t: TableStatRow) => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
open={isV2Ui ? openContextMenuTable === t.name : undefined}
onOpenChange={isV2Ui ? (open) => setOpenContextMenuTable(open ? t.name : null) : undefined}
popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined}
rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined}
overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined}
>
<div
className={isV2Ui ? 'gn-v2-table-card' : undefined}
onDoubleClick={() => openTable(t.name)}
style={{
background: cardBg,
border: `1px solid ${cardBorder}`,
borderRadius: 10,
padding: '14px 16px',
cursor: 'pointer',
transition: isV2Ui ? undefined : 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div className={isV2Ui ? 'gn-v2-table-card-name' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
{t.name}
</span>
</Tooltip>
</div>
{t.comment && (
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.comment}
</div>
</Tooltip>
)}
<div className={isV2Ui ? 'gn-v2-table-card-meta' : undefined} style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
</div>
{isV2Ui && (
<div className="gn-v2-table-size-bar">
<span style={{ width: `${Math.min(100, Math.max(4, maxCombinedSize > 0 ? Math.round(((t.dataSize + t.indexSize) / maxCombinedSize) * 100) : 4))}%` }} />
</div>
)}
</div>
</Dropdown>
);
const renderListTable = (t: TableStatRow) => {
const combinedSize = t.dataSize + t.indexSize;
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)';
const rowSecondary = t.comment || (t.engine ? `${t.engine}` : '双击打开数据,右键查看更多操作');
return (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
open={isV2Ui ? openContextMenuTable === t.name : undefined}
onOpenChange={isV2Ui ? (open) => setOpenContextMenuTable(open ? t.name : null) : undefined}
popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined}
rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined}
overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined}
>
<div
className={isV2Ui ? 'gn-v2-table-row' : undefined}
onDoubleClick={() => openTable(t.name)}
style={{
position: 'relative',
overflow: 'hidden',
borderRadius: 10,
border: `1px solid ${cardBorder}`,
background: cardBg,
cursor: 'pointer',
transition: isV2Ui ? undefined : 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: fillWidth,
background: fillColor,
pointerEvents: 'none',
transition: 'width 0.2s ease',
}}
/>
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '14px 16px',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0, flex: '1 1 320px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ color: textPrimary, fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.name}
</span>
</Tooltip>
{t.engine && (
<span
style={{
flexShrink: 0,
padding: '1px 6px',
borderRadius: 999,
fontSize: 11,
color: textMuted,
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
}}
>
{t.engine}
</span>
)}
</div>
<Tooltip title={rowSecondary} mouseEnterDelay={0.4}>
<div style={{ marginTop: 6, color: textSecondary, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{rowSecondary}
</div>
</Tooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap', fontSize: 12 }}>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</div>
</div>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
</div>
</div>
</div>
</div>
</div>
</Dropdown>
);
};
if (loading) {
return (
<div className={isV2Ui ? 'gn-v2-table-overview gn-v2-table-overview-loading' : undefined} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
@@ -769,182 +1028,26 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
)}
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : viewMode === 'card' ? (
/* ========== 卡片视图 ========== */
<div className={isV2Ui ? 'gn-v2-table-card-grid' : undefined} style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{visibleTables.map(t => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
open={isV2Ui ? openContextMenuTable === t.name : undefined}
onOpenChange={isV2Ui ? (open) => setOpenContextMenuTable(open ? t.name : null) : undefined}
popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined}
rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined}
overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined}
>
<div
className={isV2Ui ? 'gn-v2-table-card' : undefined}
onDoubleClick={() => openTable(t.name)}
style={{
background: cardBg,
border: `1px solid ${cardBorder}`,
borderRadius: 10,
padding: '14px 16px',
cursor: 'pointer',
transition: isV2Ui ? undefined : 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div className={isV2Ui ? 'gn-v2-table-card-name' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
{t.name}
</span>
</Tooltip>
</div>
{t.comment && (
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.comment}
</div>
</Tooltip>
)}
<div className={isV2Ui ? 'gn-v2-table-card-meta' : undefined} style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
</div>
{isV2Ui && (
<div className="gn-v2-table-size-bar">
<span style={{ width: `${Math.min(100, Math.max(4, maxCombinedSize > 0 ? Math.round(((t.dataSize + t.indexSize) / maxCombinedSize) * 100) : 4))}%` }} />
</div>
)}
</div>
</Dropdown>
))}
</div>
) : (
/* ========== 行视图 ========== */
<div className={isV2Ui ? 'gn-v2-table-row-list' : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{visibleTables.map(t => {
const combinedSize = t.dataSize + t.indexSize;
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)';
const rowSecondary = t.comment || (t.engine ? `${t.engine}` : '双击打开数据,右键查看更多操作');
return (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
open={isV2Ui ? openContextMenuTable === t.name : undefined}
onOpenChange={isV2Ui ? (open) => setOpenContextMenuTable(open ? t.name : null) : undefined}
popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined}
rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined}
overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined}
>
<div
className={isV2Ui ? 'gn-v2-table-row' : undefined}
onDoubleClick={() => openTable(t.name)}
style={{
position: 'relative',
overflow: 'hidden',
borderRadius: 10,
border: `1px solid ${cardBorder}`,
background: cardBg,
cursor: 'pointer',
transition: isV2Ui ? undefined : 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: fillWidth,
background: fillColor,
pointerEvents: 'none',
transition: 'width 0.2s ease',
}}
/>
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '14px 16px',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0, flex: '1 1 320px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ color: textPrimary, fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.name}
</span>
</Tooltip>
{t.engine && (
<span
style={{
flexShrink: 0,
padding: '1px 6px',
borderRadius: 999,
fontSize: 11,
color: textMuted,
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
}}
>
{t.engine}
</span>
)}
</div>
<Tooltip title={rowSecondary} mouseEnterDelay={0.4}>
<div style={{ marginTop: 6, color: textSecondary, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{rowSecondary}
</div>
</Tooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap', fontSize: 12 }}>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</div>
</div>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
</div>
</div>
</div>
</div>
<div className={isV2Ui ? 'gn-v2-table-overview-sections' : undefined}>
{visibleTableSections.map((section) => (
<section key={section.key} className={isV2Ui ? 'gn-v2-table-overview-section' : undefined}>
{pinnedOverview.pinnedRows.length > 0 && renderOverviewSectionTitle(section)}
{viewMode === 'card' ? (
<div className={isV2Ui ? 'gn-v2-table-card-grid' : undefined} style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{section.rows.map(renderCardTable)}
</div>
</Dropdown>
);
})}
) : (
<div className={isV2Ui ? 'gn-v2-table-row-list' : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{section.rows.map(renderListTable)}
</div>
)}
</section>
))}
</div>
)}
{sortedFiltered.length > 0 && visibleOverview.hiddenCount > 0 && (

View File

@@ -19,11 +19,15 @@ import {
CheckSquareOutlined,
CloudOutlined,
ClearOutlined,
ColumnWidthOutlined,
DashboardOutlined,
EyeInvisibleOutlined,
FileTextOutlined,
FolderAddOutlined,
HddOutlined,
PushpinOutlined,
SortAscendingOutlined,
SortDescendingOutlined,
VerticalAlignBottomOutlined,
} from '@ant-design/icons';
@@ -508,6 +512,8 @@ export const V2ConnectionContextMenuView: React.FC<{
export type V2CellContextMenuActionKey =
| 'copy-field-name'
| 'copy-row-data'
| 'copy-column-data'
| 'set-null'
| 'edit-row'
| 'fill-selected'
@@ -523,6 +529,91 @@ export type V2CellContextMenuActionKey =
| 'export-json'
| 'export-html';
export type V2ColumnHeaderContextMenuActionKey =
| 'copy-field-name'
| 'copy-column-data'
| 'sort-asc'
| 'sort-desc'
| 'clear-sort'
| 'auto-fit-column'
| 'hide-column'
| 'show-column-type'
| 'hide-column-type'
| 'show-column-comment'
| 'hide-column-comment';
export const V2ColumnHeaderContextMenuView: React.FC<{
fieldName: string;
columnType?: string;
columnComment?: string;
sortOrder?: 'ascend' | 'descend' | null;
showColumnType?: boolean;
showColumnComment?: boolean;
onAction?: (action: V2ColumnHeaderContextMenuActionKey) => void;
}> = ({
fieldName,
columnType,
columnComment,
sortOrder,
showColumnType = true,
showColumnComment = true,
onAction,
}) => {
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
items,
onAction as (action: string) => void,
);
const normalizedType = String(columnType || '').trim();
const normalizedComment = String(columnComment || '').trim();
const meta = [
normalizedType || '未知类型',
normalizedComment || '暂无备注',
].join(' · ');
return (
<div className="gn-v2-table-context-menu gn-v2-column-context-menu" data-v2-column-context-menu="true" role="menu">
<V2ContextMenuHeader
icon={<FileTextOutlined />}
title={fieldName || '未命名字段'}
meta={meta}
pill="FIELD"
/>
<div className="gn-v2-context-menu-body">
{renderItems([
{ action: 'copy-field-name', icon: <CopyOutlined />, title: '复制字段名称', kbd: '⌘C', featured: true },
{ action: 'copy-column-data', icon: <CopyOutlined />, title: '复制列数据' },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'sort-asc', icon: <SortAscendingOutlined />, title: '升序排序', selected: sortOrder === 'ascend', kbd: sortOrder === 'ascend' ? '当前' : undefined },
{ action: 'sort-desc', icon: <SortDescendingOutlined />, title: '降序排序', selected: sortOrder === 'descend', kbd: sortOrder === 'descend' ? '当前' : undefined },
{ action: 'clear-sort', icon: <ClearOutlined />, title: '取消此字段排序', disabled: !sortOrder },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'auto-fit-column', icon: <ColumnWidthOutlined />, title: '按内容自适应列宽' },
{ action: 'hide-column', icon: <EyeInvisibleOutlined />, title: '隐藏此字段' },
{
action: showColumnType ? 'hide-column-type' : 'show-column-type',
icon: <FileTextOutlined />,
title: showColumnType ? '隐藏字段类型' : '显示字段类型',
selected: showColumnType,
},
{
action: showColumnComment ? 'hide-column-comment' : 'show-column-comment',
icon: <FileTextOutlined />,
title: showColumnComment ? '隐藏字段备注' : '显示字段备注',
selected: showColumnComment,
},
])}
</div>
</div>
);
};
export const V2CellContextMenuView: React.FC<{
fieldName: string;
tableName?: string;
@@ -588,6 +679,8 @@ export const V2CellContextMenuView: React.FC<{
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'copy-row-data', icon: <CopyOutlined />, title: '复制行数据' },
{ action: 'copy-column-data', icon: <CopyOutlined />, title: '复制列数据' },
...(supportsCopyInsert ? [
{ action: 'copy-insert' as const, icon: <ConsoleSqlOutlined />, title: '复制为 INSERT', kbd: 'SQL' },
{ action: 'copy-update' as const, icon: <ConsoleSqlOutlined />, title: '复制为 UPDATE' },