mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-09 22:42:55 +08:00
🐛 fix(ui): 修复 v2 数据视图交互回归
- 筛选优化:隔离 WHERE 输入剪贴板事件并让字号跟随全局设置 - 表视图优化:补齐表头和单元格新版右键菜单及行列复制能力 - 置顶同步:卡片视图、列表视图和左侧对象树统一展示置顶分组 - 数据视图优化:调整分页、字段显示、DDL 侧栏和横向滚动同步体验 - 测试覆盖:补充 DataGrid、Sidebar 和表概览置顶分组回归测试
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user