From aa2177d35a469316f8326102678b1f86b064f2ab Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 26 May 2026 08:26:52 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(ui):=20=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?v2=20=E6=95=B0=E6=8D=AE=E8=A7=86=E5=9B=BE=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 筛选优化:隔离 WHERE 输入剪贴板事件并让字号跟随全局设置 - 表视图优化:补齐表头和单元格新版右键菜单及行列复制能力 - 置顶同步:卡片视图、列表视图和左侧对象树统一展示置顶分组 - 数据视图优化:调整分页、字段显示、DDL 侧栏和横向滚动同步体验 - 测试覆盖:补充 DataGrid、Sidebar 和表概览置顶分组回归测试 --- frontend/src/components/DataGrid.ddl.test.tsx | 237 ++- .../src/components/DataGrid.layout.test.tsx | 94 + frontend/src/components/DataGrid.tsx | 1587 +++++++++++++---- .../Sidebar.locate-toolbar.test.tsx | 67 + frontend/src/components/Sidebar.tsx | 161 +- frontend/src/components/TableOverview.tsx | 465 +++-- .../src/components/V2TableContextMenu.tsx | 93 + .../src/utils/tableOverviewFilter.test.ts | 28 + frontend/src/utils/tableOverviewFilter.ts | 22 + frontend/src/v2-theme.css | 166 +- 10 files changed, 2351 insertions(+), 569 deletions(-) diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index cfd88597..95c167ad 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -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('react-dom'); + return { + ...actual, + createPortal: (children: React.ReactNode) => children, + }; +}); + vi.mock('@monaco-editor/react', () => ({ default: (props: { value?: string; language?: string; theme?: string; options?: Record }) => (
{ 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 ; }, 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( + , + ); + }); + 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( + , + ); + }); + 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( + , + ); + }); + 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( + , + ); + }); + 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( + , + ); + }); + 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'; diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index e0755fb9..629736b2 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -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( + {}} + 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'); + }); }); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index cbfaa38c..17e2c59f 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'; import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker, AutoComplete } from 'antd'; import dayjs from 'dayjs'; import type { SortOrder, ColumnType } from 'antd/es/table/interface'; -import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined, LinkOutlined } from '@ant-design/icons'; +import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined, LinkOutlined, TableOutlined } from '@ant-design/icons'; import Editor from './MonacoEditor'; import { DndContext, @@ -63,6 +63,7 @@ import { pickRowsForClipboard, } from './dataGridClipboardExport'; import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap'; +import { DEFAULT_SHORTCUT_OPTIONS, getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; import { TEMPORAL_FORMATS, formatFromDayjs, @@ -98,6 +99,12 @@ import { resolveRowLocatorValues, type EditRowLocator, } from '../utils/rowLocator'; +import { + V2CellContextMenuView, + V2ColumnHeaderContextMenuView, + type V2CellContextMenuActionKey, + type V2ColumnHeaderContextMenuActionKey, +} from './V2TableContextMenu'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -275,6 +282,45 @@ export const formatCellDisplayText = (val: any, columnType?: string): string => } }; +const formatClipboardCellText = (val: any, columnType?: string): string => { + try { + if (val === null || val === undefined) return 'NULL'; + const bitText = normalizeBitHexDisplayText(val, columnType); + if (bitText !== null) return bitText; + if (typeof val === 'string') return normalizeDateTimeString(val); + if (typeof val === 'object') { + try { + return JSON.stringify(val); + } catch { + return String(val); + } + } + return String(val); + } catch (e) { + console.error('formatClipboardCellText error:', e); + return '[Error]'; + } +}; + +const normalizeClipboardTsvCell = (text: string): string => text.replace(/\t/g, ' ').replace(/\r?\n/g, ' '); + +const buildClipboardTsv = ( + rows: Array>, + columnNames: string[], + getColumnType?: (columnName: string) => string | undefined, +): string => { + if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) { + return ''; + } + const header = columnNames.map(normalizeClipboardTsvCell).join('\t'); + const lines = rows.map((row) => ( + columnNames + .map((columnName) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[columnName], getColumnType?.(columnName)))) + .join('\t') + )); + return [header, ...lines].join('\n'); +}; + const renderHighlightedCellText = (text: string, query: string): React.ReactNode => { const ranges = findDataGridTextRanges(text, query); if (ranges.length === 0) return text; @@ -1033,7 +1079,8 @@ type GridFilterCondition = FilterCondition & { value2?: string; }; -type GridViewMode = 'table' | 'json' | 'text'; +type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; +type DdlViewLayoutMode = 'bottom' | 'side'; type ColumnMeta = { type: string; @@ -1270,8 +1317,11 @@ const DataGrid: React.FC = ({ const setTableHiddenColumns = useStore(state => state.setTableHiddenColumns); const setEnableHiddenColumnMemory = useStore(state => state.setEnableHiddenColumnMemory); const clearTableHiddenColumns = useStore(state => state.clearTableHiddenColumns); + const shortcutOptions = useStore(state => state.shortcutOptions); const isMacLike = useMemo(() => isMacLikePlatform(), []); + const isV2Ui = appearance?.uiVersion === 'v2'; + const activeShortcutPlatform = useMemo(() => getShortcutPlatform(isMacLike), [isMacLike]); const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); @@ -1566,7 +1616,18 @@ const DataGrid: React.FC = ({ const [ddlModalOpen, setDdlModalOpen] = useState(false); const [ddlLoading, setDdlLoading] = useState(false); const [ddlText, setDdlText] = useState(''); + const [ddlViewLayout, setDdlViewLayout] = useState('bottom'); + const [ddlSidebarWidth, setDdlSidebarWidth] = useState(420); + const [ddlSidebarResizePreviewX, setDdlSidebarResizePreviewX] = useState(null); + const ddlSidebarResizeRef = useRef<{ + startX: number; + startWidth: number; + previewWidth: number; + moveHandler?: (event: MouseEvent) => void; + upHandler?: () => void; + } | null>(null); const ddlRequestSeqRef = useRef(0); + const isTableSurfaceActive = viewMode === 'table' || (isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side'); // --- Data Preview Panel State --- const [dataPanelOpen, setDataPanelOpen] = useState(false); @@ -1593,6 +1654,7 @@ const DataGrid: React.FC = ({ visible: boolean; x: number; y: number; + kind: 'cell' | 'column'; record: Item | null; dataIndex: string; title: string; @@ -1600,6 +1662,7 @@ const DataGrid: React.FC = ({ visible: false, x: 0, y: 0, + kind: 'cell', record: null, dataIndex: '', title: '', @@ -1609,9 +1672,19 @@ const DataGrid: React.FC = ({ const tableContainerRef = useRef(null); const tableScrollTargetsRef = useRef([]); const externalHorizontalScrollRef = useRef(null); + const virtualHorizontalElementsRef = useRef<{ + tableContainer: HTMLElement | null; + holderEl: HTMLElement | null; + innerEl: HTMLElement | null; + headerEl: HTMLElement | null; + }>({ tableContainer: null, holderEl: null, innerEl: null, headerEl: null }); const horizontalSyncSourceRef = useRef<'table' | 'external' | ''>(''); const lastTableScrollLeftRef = useRef(0); const lastExternalScrollLeftRef = useRef(0); + const externalSyncRafRef = useRef(null); + const tableHorizontalWheelRafRef = useRef(null); + const pendingTableHorizontalDeltaRef = useRef(0); + const scrollSnapshotRafRef = useRef(null); const pendingScrollToBottomRef = useRef(false); const pastedRowSequenceRef = useRef(0); const lastReportedScrollRef = useRef<{ top: number; left: number }>({ top: 0, left: 0 }); @@ -1651,6 +1724,22 @@ const DataGrid: React.FC = ({ body.scrollTop = body.scrollHeight; }, []); + useEffect(() => () => { + if (externalSyncRafRef.current !== null) { + cancelAnimationFrame(externalSyncRafRef.current); + externalSyncRafRef.current = null; + } + if (tableHorizontalWheelRafRef.current !== null) { + cancelAnimationFrame(tableHorizontalWheelRafRef.current); + tableHorizontalWheelRafRef.current = null; + } + if (scrollSnapshotRafRef.current !== null) { + cancelAnimationFrame(scrollSnapshotRafRef.current); + scrollSnapshotRafRef.current = null; + } + pendingTableHorizontalDeltaRef.current = 0; + }, []); + // Close cell context menu when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -1695,12 +1784,39 @@ const DataGrid: React.FC = ({ visible: true, x: menuX, y: menuY, + kind: 'cell', record, dataIndex, title: titleText, }); }, []); + const showColumnHeaderContextMenu = useCallback((e: React.MouseEvent, columnName: string) => { + e.preventDefault(); + e.stopPropagation(); + const estimatedMenuHeight = 292; + const estimatedMenuWidth = 264; + const viewportH = window.innerHeight; + const viewportW = window.innerWidth; + let menuY = e.clientY; + let menuX = e.clientX; + if (menuY + estimatedMenuHeight > viewportH) { + menuY = Math.max(4, viewportH - estimatedMenuHeight); + } + if (menuX + estimatedMenuWidth > viewportW) { + menuX = Math.max(4, viewportW - estimatedMenuWidth); + } + setCellContextMenu({ + visible: true, + x: menuX, + y: menuY, + kind: 'column', + record: null, + dataIndex: columnName, + title: columnName, + }); + }, []); + // Helper to export specific data const exportData = async (rows: any[], format: string) => { const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0); @@ -1994,6 +2110,9 @@ const DataGrid: React.FC = ({ const normalizedName = String(name || ''); const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; const foreignKeyTarget = foreignKeyMap[normalizedName] || foreignKeyMapByLowerName[normalizedName.toLowerCase()]; + const shouldShowColumnType = showColumnType && !!meta?.type; + const shouldShowColumnComment = showColumnComment && !!meta?.comment; + const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment; const hoverLines: string[] = []; if (meta?.type) hoverLines.push(`类型:${meta.type}`); if (meta?.comment) hoverLines.push(`备注:${meta.comment}`); @@ -2038,10 +2157,15 @@ const DataGrid: React.FC = ({ ); const titleNode = ( -
+
{fieldLabel} - {showColumnType && meta?.type && ( + {shouldShowColumnType && ( = ({ {meta.type} )} - {showColumnComment && meta?.comment && ( + {shouldShowColumnComment && ( = ({ {titleNode} ); - }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, foreignKeyMap, foreignKeyMapByLowerName, showColumnComment, showColumnType, densityParams, openForeignKeyTarget]); + }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, foreignKeyMap, foreignKeyMapByLowerName, showColumnComment, showColumnType, densityParams, isV2Ui, openForeignKeyTarget]); const closeCellEditor = useCallback(() => { setCellEditorOpen(false); @@ -2819,6 +2944,31 @@ const DataGrid: React.FC = ({ })); }, [allTableColumnNames, displayColumnNames, quickWhereDraft, dbType, darkMode]); + const handleQuickWherePaste = useCallback((event: React.ClipboardEvent) => { + const pastedText = event.clipboardData.getData('text/plain') || event.clipboardData.getData('text'); + if (!pastedText) return; + + event.preventDefault(); + event.stopPropagation(); + + const input = event.currentTarget; + const currentValue = input.value ?? quickWhereDraft; + const start = input.selectionStart ?? currentValue.length; + const end = input.selectionEnd ?? start; + const nextValue = `${currentValue.slice(0, start)}${pastedText}${currentValue.slice(end)}`; + const nextCursor = start + pastedText.length; + + setQuickWhereDraft(nextValue); + requestAnimationFrame(() => { + input.focus(); + input.setSelectionRange(nextCursor, nextCursor); + }); + }, [quickWhereDraft]); + + const stopQuickWhereClipboardPropagation = useCallback((event: React.ClipboardEvent) => { + event.stopPropagation(); + }, []); + const gridFieldSelectOptions = useMemo( () => buildGridFieldSelectOptions(displayColumnNames), [displayColumnNames], @@ -2881,6 +3031,8 @@ const DataGrid: React.FC = ({ setDdlModalOpen(false); setDdlLoading(false); setDdlText(''); + setDdlViewLayout('bottom'); + setDdlSidebarResizePreviewX(null); rowEditorForm.resetFields(); closeCellEditor(); form.resetFields(); @@ -3054,7 +3206,7 @@ const DataGrid: React.FC = ({ // 事件委托:在容器级别处理单元格拖选;未开启模式时,拖拽超过阈值会自动进入单元格编辑模式。 useEffect(() => { const container = containerRef.current; - if (!canModifyData || viewMode !== 'table') return; + if (!canModifyData || !isTableSurfaceActive) return; if (!container) return; const EDGE_THRESHOLD_PX = 28; const MIN_SCROLL_STEP = 8; @@ -3341,7 +3493,7 @@ const DataGrid: React.FC = ({ cellSelectionPointerRef.current = null; isDraggingRef.current = false; }; - }, [canModifyData, viewMode, displayColumnNames, columnIndexMap, effectiveEditLocator, updateCellSelection]); + }, [canModifyData, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, updateCellSelection]); const handleCopySelectedColumnsFromRow = useCallback(() => { const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; @@ -3577,7 +3729,8 @@ const DataGrid: React.FC = ({ useEffect(() => { displayDataRef.current = displayData; }, [displayData]); - const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0; + const pendingChangeCount = addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size; + const hasChanges = pendingChangeCount > 0; const allSelectedAreDeleted = useMemo(() => { if (selectedRowKeys.length === 0) return false; @@ -3612,6 +3765,21 @@ const DataGrid: React.FC = ({ if (onSort) onSort(JSON.stringify(next), ''); }, [onSort]); + const applySortInfo = useCallback((next: Array<{ columnKey: string, order: string, enabled?: boolean }>) => { + setSortInfo(next); + if (onSort) onSort(JSON.stringify(next), ''); + }, [onSort]); + + const applyColumnSort = useCallback((columnName: string, order: 'ascend' | 'descend' | null) => { + const normalizedName = String(columnName || '').trim(); + if (!normalizedName) return; + const next = sortInfo.filter((item) => item.columnKey !== normalizedName); + if (order) { + next.push({ columnKey: normalizedName, order, enabled: true }); + } + applySortInfo(next); + }, [applySortInfo, sortInfo]); + // Native Drag State const draggingRef = useRef<{ startX: number, @@ -3735,47 +3903,53 @@ const DataGrid: React.FC = ({ setColumnWidths((prev) => ({ ...newWidths, ...prev })); }, [displayColumnNames, displayData, densityParams, measureTextWidth]); - const handleResizeAutoFit = useCallback((key: string) => (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const handleEl = e.currentTarget as HTMLElement | null; - const headerEl = handleEl?.closest('th') as HTMLElement | null; + const autoFitColumnWidth = useCallback((key: string, headerEl?: HTMLElement | null) => { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey) return; const sampleCell = Array.from( containerRef.current?.querySelectorAll('.ant-table-cell[data-col-name]') || [] - ).find((node) => (node as HTMLElement).getAttribute('data-col-name') === key) as HTMLElement | undefined; + ).find((node) => (node as HTMLElement).getAttribute('data-col-name') === normalizedKey) as HTMLElement | undefined; - const meta = columnMetaMap[key] || columnMetaMapByLowerName[key.toLowerCase()]; - const headerTexts = [key]; + const meta = columnMetaMap[normalizedKey] || columnMetaMapByLowerName[normalizedKey.toLowerCase()]; + const headerTexts = [normalizedKey]; if (showColumnType && meta?.type) headerTexts.push(meta.type); if (showColumnComment && meta?.comment) headerTexts.push(meta.comment); const defaultWidth = resolveDataTableColumnWidth({ - manualWidth: columnWidths[key], + manualWidth: columnWidths[normalizedKey], density: dataTableDensity, }); const containerWidth = containerRef.current?.clientWidth ?? 0; const nextWidth = calculateAutoFitColumnWidth({ headerTexts, - valueTexts: displayDataRef.current.slice(0, 200).map((row) => row?.[key]), - measureHeaderText: buildAutoFitMeasurer(headerEl, `600 ${densityParams.dataFontSize}px -apple-system, sans-serif`), + valueTexts: displayDataRef.current.slice(0, 200).map((row) => row?.[normalizedKey]), + measureHeaderText: buildAutoFitMeasurer(headerEl ?? null, `600 ${densityParams.dataFontSize}px -apple-system, sans-serif`), measureCellText: buildAutoFitMeasurer(sampleCell ?? null, `400 ${densityParams.dataFontSize}px -apple-system, sans-serif`), defaultWidth, minWidth: 80, maxWidth: Math.max(720, Math.floor(containerWidth * 0.85)), }); - setColumnWidths((prev) => ({ ...prev, [key]: nextWidth })); + setColumnWidths((prev) => ({ ...prev, [normalizedKey]: nextWidth })); }, [ buildAutoFitMeasurer, columnMetaMap, columnMetaMapByLowerName, columnWidths, dataTableDensity, + densityParams.dataFontSize, showColumnComment, showColumnType, ]); + const handleResizeAutoFit = useCallback((key: string) => (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const handleEl = e.currentTarget as HTMLElement | null; + const headerEl = handleEl?.closest('th') as HTMLElement | null; + autoFitColumnWidth(key, headerEl); + }, [autoFitColumnWidth]); + // 2. Drag Move (Global) const handleResizeMove = useCallback((e: MouseEvent) => { if (!draggingRef.current) return; @@ -3929,6 +4103,12 @@ const DataGrid: React.FC = ({ openCellEditor(record, dataIndex, title); }, [canModifyData, openCellEditor]); + const handleVirtualCellContextMenu = useCallback((e: React.MouseEvent, record: Item, dataIndex: string) => { + e.preventDefault(); + e.stopPropagation(); + showCellContextMenu(e, record, dataIndex, dataIndex); + }, [showCellContextMenu]); + // Merge Data for Display // 'displayData' already merges addedRows. // We need to merge modifiedRows into it for rendering. @@ -4104,7 +4284,54 @@ const DataGrid: React.FC = ({ setJsonEditorOpen(true); }, [canModifyData, jsonViewText]); + const handleOpenTableDdl = useCallback(async (options?: { asView?: boolean }) => { + if (!canViewDdl || !currentConnConfig || !tableName) { + void message.error('当前表缺少连接或表名,无法查看 DDL'); + return; + } + const asView = options?.asView === true && isV2Ui; + const requestSeq = ++ddlRequestSeqRef.current; + if (asView) { + setViewMode('ddl'); + setDdlModalOpen(false); + } else { + setDdlModalOpen(true); + } + setDdlLoading(true); + setDdlText(''); + try { + const res = await DBShowCreateTable(buildRpcConnectionConfig(currentConnConfig) as any, dbName || '', tableName); + if (requestSeq !== ddlRequestSeqRef.current) return; + if (res.success) { + setDdlText(String(res.data ?? '')); + return; + } + void message.error(res.message || '获取 DDL 失败'); + } catch (error: any) { + if (requestSeq !== ddlRequestSeqRef.current) return; + void message.error(error?.message || '获取 DDL 失败'); + } finally { + if (requestSeq === ddlRequestSeqRef.current) { + setDdlLoading(false); + } + } + }, [canViewDdl, currentConnConfig, dbName, isV2Ui, tableName]); + + useEffect(() => { + if (isV2Ui || (viewMode !== 'fields' && viewMode !== 'ddl' && viewMode !== 'er')) return; + setViewMode('table'); + }, [isV2Ui, viewMode]); + const handleViewModeChange = useCallback((nextMode: GridViewMode) => { + if ((nextMode === 'fields' || nextMode === 'ddl' || nextMode === 'er') && !isV2Ui) { + setViewMode('table'); + return; + } + if (nextMode === 'ddl') { + void handleOpenTableDdl({ asView: true }); + setViewMode('ddl'); + return; + } if (nextMode === 'json' && cellEditMode) { closeCellEditMode(); } @@ -4120,7 +4347,7 @@ const DataGrid: React.FC = ({ } setViewMode(nextMode); - }, [cellEditMode, mergedDisplayData, selectedRowKeys, rowKeyStr, closeCellEditMode]); + }, [cellEditMode, handleOpenTableDdl, isV2Ui, mergedDisplayData, selectedRowKeys, rowKeyStr, closeCellEditMode]); const handleOpenContextMenuRowEditor = useCallback(() => { if (!canModifyData) return; @@ -4306,7 +4533,7 @@ const DataGrid: React.FC = ({ }, [rowEditorRowKey, rowEditorForm, addedRows, visibleColumnNames, rowKeyStr, closeRowEditor, effectiveEditLocator, columnMetaMap, columnMetaMapByLowerName]); - const enableVirtual = viewMode === 'table'; + const enableVirtual = isTableSurfaceActive; const enableInlineEditableCell = canModifyData; const columns: (ColumnType & { editable?: boolean })[] = useMemo(() => { @@ -4336,9 +4563,13 @@ const DataGrid: React.FC = ({ onHeaderCell: (column: any) => ({ id: key, width: column.width, - className: 'gonavi-sortable-header-cell', + className: `gonavi-sortable-header-cell${showColumnComment || showColumnType ? '' : ' is-single-line-title'}`, onResizeStart: handleResizeStart(key), // Only need start onResizeAutoFit: handleResizeAutoFit(key), + onContextMenu: (event: React.MouseEvent) => { + if (!isV2Ui) return; + showColumnHeaderContextMenu(event, key); + }, onClickCapture: (event: React.MouseEvent) => { if (!onSort) return; const eventTarget = event.target as HTMLElement | null; @@ -4363,7 +4594,7 @@ const DataGrid: React.FC = ({ }, }), })); - }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableDensity, normalizedPageFindText, columnMetaMap, columnMetaMapByLowerName]); + }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, isV2Ui, showColumnHeaderContextMenu, canModifyData, onSort, renderColumnTitle, dataTableDensity, normalizedPageFindText, columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]); const mergedColumns = useMemo(() => columns.map((col): ColumnType => { const dataIndex = String(col.dataIndex); @@ -4450,11 +4681,7 @@ const DataGrid: React.FC = ({ return (
{ - e.preventDefault(); - e.stopPropagation(); - showCellContextMenu(e, record, dataIndex, dataIndex); - }} + onContextMenu={(e) => handleVirtualCellContextMenu(e, record, dataIndex)} > {originalRenderContent}
@@ -4463,7 +4690,7 @@ const DataGrid: React.FC = ({ return originalRenderContent; } }; - }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, showCellContextMenu, columnMetaMap, columnMetaMapByLowerName, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode]); + }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleVirtualCellContextMenu, columnMetaMap, columnMetaMapByLowerName, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode]); const handleAddRow = () => { const newKey = `new-${Date.now()}`; @@ -4702,6 +4929,86 @@ const DataGrid: React.FC = ({ setCellContextMenu(prev => ({ ...prev, visible: false })); }, [cellContextMenu.dataIndex, cellContextMenu.title, copyToClipboard]); + const handleCopyColumnData = useCallback((columnName: string) => { + const normalizedColumnName = String(columnName || '').trim(); + if (!normalizedColumnName || !displayOutputColumnNames.includes(normalizedColumnName)) { + void message.info('未识别到可复制的列'); + return; + } + if (mergedDisplayData.length === 0) { + void message.info('当前结果集没有可复制内容'); + return; + } + + const columnType = (columnMetaMap[normalizedColumnName] || columnMetaMapByLowerName[normalizedColumnName.toLowerCase()])?.type; + const text = mergedDisplayData + .map((row) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[normalizedColumnName], columnType))) + .join('\n'); + copyToClipboard(text); + }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, displayOutputColumnNames, mergedDisplayData]); + + const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeaderContextMenuActionKey) => { + const columnName = resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title); + if (!columnName) { + void message.info('未识别到字段名称'); + setCellContextMenu(prev => ({ ...prev, visible: false })); + return; + } + + switch (action) { + case 'copy-field-name': + copyToClipboard(columnName); + break; + case 'copy-column-data': + handleCopyColumnData(columnName); + break; + case 'sort-asc': + applyColumnSort(columnName, 'ascend'); + break; + case 'sort-desc': + applyColumnSort(columnName, 'descend'); + break; + case 'clear-sort': + applyColumnSort(columnName, null); + break; + case 'auto-fit-column': + autoFitColumnWidth(columnName); + break; + case 'hide-column': + if (displayColumnNames.length <= 1) { + void message.info('至少保留一个可见字段'); + break; + } + toggleColumnVisibility(columnName, false); + break; + case 'show-column-type': + setQueryOptions({ showColumnType: true }); + break; + case 'hide-column-type': + setQueryOptions({ showColumnType: false }); + break; + case 'show-column-comment': + setQueryOptions({ showColumnComment: true }); + break; + case 'hide-column-comment': + setQueryOptions({ showColumnComment: false }); + break; + default: + break; + } + setCellContextMenu(prev => ({ ...prev, visible: false })); + }, [ + applyColumnSort, + autoFitColumnWidth, + cellContextMenu.dataIndex, + cellContextMenu.title, + copyToClipboard, + displayColumnNames.length, + handleCopyColumnData, + setQueryOptions, + toggleColumnVisibility, + ]); + const getClipboardRows = useCallback(() => ( pickRowsForClipboard({ rows: mergedDisplayData as Array>, @@ -4749,33 +5056,6 @@ const DataGrid: React.FC = ({ copyToClipboard(text); }, [copyToClipboard, getClipboardColumnNames, getClipboardRows]); - const handleOpenTableDdl = useCallback(async () => { - if (!canViewDdl || !currentConnConfig || !tableName) { - void message.error('当前表缺少连接或表名,无法查看 DDL'); - return; - } - const requestSeq = ++ddlRequestSeqRef.current; - setDdlModalOpen(true); - setDdlLoading(true); - setDdlText(''); - try { - const res = await DBShowCreateTable(buildRpcConnectionConfig(currentConnConfig) as any, dbName || '', tableName); - if (requestSeq !== ddlRequestSeqRef.current) return; - if (res.success) { - setDdlText(String(res.data ?? '')); - return; - } - void message.error(res.message || '获取 DDL 失败'); - } catch (error: any) { - if (requestSeq !== ddlRequestSeqRef.current) return; - void message.error(error?.message || '获取 DDL 失败'); - } finally { - if (requestSeq === ddlRequestSeqRef.current) { - setDdlLoading(false); - } - } - }, [canViewDdl, currentConnConfig, dbName, tableName]); - const handleCopyDdl = useCallback(() => { if (!ddlText.trim()) { void message.info('暂无可复制的 DDL'); @@ -4867,6 +5147,21 @@ const DataGrid: React.FC = ({ return [clickedRecord]; }, []); + const getContextMenuTargetRows = useCallback((clickedRecord: any) => { + if (!clickedRecord) return []; + const selKeys = selectedRowKeysRef.current; + const clickedKey = clickedRecord?.[GONAVI_ROW_KEY]; + const clickedKeyStr = clickedKey === undefined || clickedKey === null ? '' : rowKeyStr(clickedKey); + const selectedKeyStrSet = new Set(selKeys.map(rowKeyStr)); + if (clickedKeyStr && selectedKeyStrSet.has(clickedKeyStr)) { + return mergedDisplayData.filter((row) => { + const rowKey = row?.[GONAVI_ROW_KEY]; + return rowKey !== undefined && rowKey !== null && selectedKeyStrSet.has(rowKeyStr(rowKey)); + }); + } + return [clickedRecord]; + }, [mergedDisplayData, rowKeyStr]); + const buildCopySqlBatchText = useCallback((mode: 'insert' | 'update' | 'delete', record: any): string | null => { if (!supportsCopyInsert) { void message.warning("当前数据源不支持复制 SQL,请使用 JSON/CSV/Markdown 复制。"); @@ -4972,6 +5267,21 @@ const DataGrid: React.FC = ({ copyToClipboard([header, ...lines].join('\n')); }, [getTargets, displayOutputColumnNames, copyToClipboard]); + const handleCopyRowData = useCallback((record: any) => { + const rows = getContextMenuTargetRows(record); + const columns = displayOutputColumnNames; + const text = buildClipboardTsv( + rows, + columns, + (columnName) => (columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type, + ); + if (!text) { + void message.info('当前行没有可复制内容'); + return; + } + copyToClipboard(text); + }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, displayOutputColumnNames, getContextMenuTargetRows]); + const buildConnConfig = useCallback(() => { if (!connectionId) return null; const conn = connections.find(c => c.id === connectionId); @@ -5117,6 +5427,104 @@ const DataGrid: React.FC = ({ await exportByQuery(sql, format, tableName || 'export'); }, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery, displayOutputColumnNames]); + const handleV2CellContextMenuAction = useCallback((action: V2CellContextMenuActionKey) => { + const record = cellContextMenu.record; + const closeMenu = () => setCellContextMenu(prev => ({ ...prev, visible: false })); + + switch (action) { + case 'copy-field-name': + handleCopyContextMenuFieldName(); + return; + case 'copy-row-data': + if (record) handleCopyRowData(record); + closeMenu(); + return; + case 'copy-column-data': + handleCopyColumnData(cellContextMenu.dataIndex); + closeMenu(); + return; + case 'set-null': + handleCellSetNull(); + return; + case 'edit-row': + handleOpenContextMenuRowEditor(); + return; + case 'fill-selected': + if (selectedRowKeys.length > 0 && record) { + handleBatchFillToSelected(record, cellContextMenu.dataIndex); + } + closeMenu(); + return; + case 'paste-copied-columns': + if (copiedCellPatch) { + handlePasteCopiedColumnsToSelectedRows(record?.[GONAVI_ROW_KEY]); + } + closeMenu(); + return; + case 'copy-insert': + if (record) handleCopyInsert(record); + closeMenu(); + return; + case 'copy-update': + if (record) handleCopyUpdate(record); + closeMenu(); + return; + case 'copy-delete': + if (record) handleCopyDelete(record); + closeMenu(); + return; + case 'copy-json': + if (record) handleCopyJson(record); + closeMenu(); + return; + case 'copy-csv': + if (record) handleCopyCsv(record); + closeMenu(); + return; + case 'copy-markdown': + if (record) { + const records = getTargets(record); + const columns = getClipboardColumnNames(records); + copyToClipboard(buildClipboardMarkdown(records, columns)); + } + closeMenu(); + return; + case 'export-csv': + case 'export-xlsx': + case 'export-json': + case 'export-html': + if (record) { + const format = action.replace('export-', ''); + handleExportSelected(format, record).catch(console.error); + } + closeMenu(); + return; + default: + closeMenu(); + } + }, [ + cellContextMenu.record, + cellContextMenu.dataIndex, + copiedCellPatch, + copyToClipboard, + getClipboardColumnNames, + getTargets, + handleBatchFillToSelected, + handleCellSetNull, + handleCopyContextMenuFieldName, + handleCopyCsv, + handleCopyDelete, + handleCopyInsert, + handleCopyJson, + handleCopyColumnData, + handleCopyRowData, + handleCopyUpdate, + handleExportSelected, + handleOpenContextMenuRowEditor, + handlePasteCopiedColumnsToSelectedRows, + selectedRowKeys.length, + ]); + // Export const handleExport = async (format: string) => { if (!connectionId) return; @@ -5509,7 +5917,7 @@ const DataGrid: React.FC = ({ isMacLike, }); }, [totalWidth, isMacLike, tableViewportWidth]); - const horizontalScrollVisible = viewMode === 'table' && tableScrollX > tableViewportWidth + 1; + const horizontalScrollVisible = isTableSurfaceActive && tableScrollX > tableViewportWidth + 1; const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX); const tableScrollConfig = useMemo(() => ({ x: tableScrollX, y: tableHeight }), [tableScrollX, tableHeight]); const tableComponents = useMemo(() => { @@ -5527,10 +5935,22 @@ const DataGrid: React.FC = ({ const tableOnRow = useMemo(() => (useContextMenuRow ? rowPropsFactory : undefined), [useContextMenuRow, rowPropsFactory]); const resolveVirtualHorizontalElements = useCallback((tableContainer: HTMLElement) => { + const cached = virtualHorizontalElementsRef.current; + if ( + cached.tableContainer === tableContainer + && cached.holderEl?.isConnected + && cached.innerEl?.isConnected + && cached.headerEl?.isConnected + ) { + return cached; + } + const holderEl = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; const innerEl = holderEl?.querySelector('.ant-table-tbody-virtual-holder-inner') as HTMLElement | null; const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null; - return { holderEl, innerEl, headerEl }; + const nextElements = { tableContainer, holderEl, innerEl, headerEl }; + virtualHorizontalElementsRef.current = nextElements; + return nextElements; }, []); const readVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement): number => { @@ -5565,6 +5985,37 @@ const DataGrid: React.FC = ({ return true; }, [resolveVirtualHorizontalElements, tableScrollX]); + const flushVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement) => { + tableHorizontalWheelRafRef.current = null; + const delta = pendingTableHorizontalDeltaRef.current; + pendingTableHorizontalDeltaRef.current = 0; + if (!Number.isFinite(delta) || Math.abs(delta) < 0.5) { + horizontalSyncSourceRef.current = ''; + return; + } + + const currentOffset = readVirtualHorizontalOffset(tableContainer); + applyVirtualHorizontalOffset(tableContainer, currentOffset + delta); + requestAnimationFrame(() => { + const nextScrollLeft = readVirtualHorizontalOffset(tableContainer); + lastTableScrollLeftRef.current = nextScrollLeft; + const externalScroll = externalHorizontalScrollRef.current; + if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) { + externalScroll.scrollLeft = nextScrollLeft; + lastExternalScrollLeftRef.current = nextScrollLeft; + } + if (pendingTableHorizontalDeltaRef.current === 0 && tableHorizontalWheelRafRef.current === null) { + horizontalSyncSourceRef.current = ''; + } + }); + }, [applyVirtualHorizontalOffset, readVirtualHorizontalOffset]); + + const scheduleVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement, delta: number) => { + pendingTableHorizontalDeltaRef.current += delta; + if (tableHorizontalWheelRafRef.current !== null) return; + tableHorizontalWheelRafRef.current = requestAnimationFrame(() => flushVirtualHorizontalWheel(tableContainer)); + }, [flushVirtualHorizontalWheel]); + const pickHorizontalScrollTargets = useCallback((tableContainer: HTMLElement): HTMLElement[] => { const virtualBody = tableContainer.querySelector('.ant-table-tbody-virtual-holder'); const body = tableContainer.querySelector('.ant-table-body'); @@ -5696,52 +6147,64 @@ const DataGrid: React.FC = ({ return; } lastExternalScrollLeftRef.current = externalScroll.scrollLeft; + if (externalSyncRafRef.current !== null) { + return; + } horizontalSyncSourceRef.current = 'external'; - const tableContainer = tableContainerRef.current; - // 虚拟表格路径:通过合成 WheelEvent 驱动 rc-virtual-list 内部状态, - // rc-table 自动同步 header scrollLeft。 - if (enableVirtual && tableContainer instanceof HTMLElement) { - const applied = applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft); - if (applied) { - // WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref - requestAnimationFrame(() => { - lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer); - horizontalSyncSourceRef.current = ''; - }); - return; - } - // 空数据回退:virtual-holder 不存在时,直接滚动表头 - const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null; - const contentEl = tableContainer.querySelector('.ant-table-content') as HTMLElement | null; - const fallbackTargets = [headerEl, contentEl].filter((el): el is HTMLElement => el instanceof HTMLElement && el.scrollWidth > el.clientWidth + 1); - if (fallbackTargets.length > 0) { - fallbackTargets.forEach((target) => { - target.scrollLeft = externalScroll.scrollLeft; - }); - lastTableScrollLeftRef.current = externalScroll.scrollLeft; + externalSyncRafRef.current = requestAnimationFrame(() => { + externalSyncRafRef.current = null; + const latestExternalScroll = externalHorizontalScrollRef.current; + if (!(latestExternalScroll instanceof HTMLDivElement)) { horizontalSyncSourceRef.current = ''; return; } - horizontalSyncSourceRef.current = ''; - return; - } - // 非虚拟表格路径:依赖 liveTargets 进行 scrollLeft 同步 - const liveTargets = tableScrollTargetsRef.current; - if (liveTargets.length === 0) { - horizontalSyncSourceRef.current = ''; - return; - } - liveTargets.forEach((target) => { - if (target.scrollWidth <= target.clientWidth + 1) { + + const tableContainer = tableContainerRef.current; + // 虚拟表格路径:通过合成 WheelEvent 驱动 rc-virtual-list 内部状态, + // rc-table 自动同步 header scrollLeft。 + if (enableVirtual && tableContainer instanceof HTMLElement) { + const applied = applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft); + if (applied) { + // WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref + requestAnimationFrame(() => { + lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer); + horizontalSyncSourceRef.current = ''; + }); + return; + } + // 空数据回退:virtual-holder 不存在时,直接滚动表头 + const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null; + const contentEl = tableContainer.querySelector('.ant-table-content') as HTMLElement | null; + const fallbackTargets = [headerEl, contentEl].filter((el): el is HTMLElement => el instanceof HTMLElement && el.scrollWidth > el.clientWidth + 1); + if (fallbackTargets.length > 0) { + fallbackTargets.forEach((target) => { + target.scrollLeft = latestExternalScroll.scrollLeft; + }); + lastTableScrollLeftRef.current = latestExternalScroll.scrollLeft; + horizontalSyncSourceRef.current = ''; + return; + } + horizontalSyncSourceRef.current = ''; return; } - if (Math.abs(target.scrollLeft - externalScroll.scrollLeft) > 1) { - target.scrollLeft = externalScroll.scrollLeft; + // 非虚拟表格路径:依赖 liveTargets 进行 scrollLeft 同步 + const liveTargets = tableScrollTargetsRef.current; + if (liveTargets.length === 0) { + horizontalSyncSourceRef.current = ''; + return; } + liveTargets.forEach((target) => { + if (target.scrollWidth <= target.clientWidth + 1) { + return; + } + if (Math.abs(target.scrollLeft - latestExternalScroll.scrollLeft) > 1) { + target.scrollLeft = latestExternalScroll.scrollLeft; + } + }); + lastTableScrollLeftRef.current = latestExternalScroll.scrollLeft; + horizontalSyncSourceRef.current = ''; }); - lastTableScrollLeftRef.current = externalScroll.scrollLeft; - horizontalSyncSourceRef.current = ''; }, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset]); // 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效) @@ -5766,13 +6229,17 @@ const DataGrid: React.FC = ({ externalScroll.addEventListener('wheel', handleExternalWheel, { passive: false, capture: true }); return () => { externalScroll.removeEventListener('wheel', handleExternalWheel, { capture: true } as EventListenerOptions); + if (externalSyncRafRef.current !== null) { + cancelAnimationFrame(externalSyncRafRef.current); + externalSyncRafRef.current = null; + } }; }, [horizontalScrollVisible]); // 支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。 // 虚拟表格与普通表格统一走外部横向滚动条,避免内部轨道覆盖最后一行。 useEffect(() => { - if (viewMode !== 'table') return; + if (!isTableSurfaceActive) return; const container = tableContainerRef.current; if (!(container instanceof HTMLElement)) return; @@ -5831,19 +6298,8 @@ const DataGrid: React.FC = ({ return; } - // 有数据:通过 applyVirtualHorizontalOffset 合成 WheelEvent 驱动 rc-virtual-list - const currentOffset = readVirtualHorizontalOffset(container); - applyVirtualHorizontalOffset(container, currentOffset + horizontalDelta); - requestAnimationFrame(() => { - const nextScrollLeft = readVirtualHorizontalOffset(container); - lastTableScrollLeftRef.current = nextScrollLeft; - const externalScroll = externalHorizontalScrollRef.current; - if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) { - externalScroll.scrollLeft = nextScrollLeft; - lastExternalScrollLeftRef.current = nextScrollLeft; - } - horizontalSyncSourceRef.current = ''; - }); + // 有数据:合并同一帧内的横向滚轮增量,再驱动 rc-virtual-list。 + scheduleVirtualHorizontalWheel(container, horizontalDelta); return; } @@ -5882,21 +6338,26 @@ const DataGrid: React.FC = ({ container.addEventListener('wheel', handleContainerHorizontalWheel, { passive: false, capture: true }); return () => { container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions); + if (tableHorizontalWheelRafRef.current !== null) { + cancelAnimationFrame(tableHorizontalWheelRafRef.current); + tableHorizontalWheelRafRef.current = null; + } + pendingTableHorizontalDeltaRef.current = 0; }; - }, [applyVirtualHorizontalOffset, enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, viewMode]); + }, [enableVirtual, isTableSurfaceActive, pickHorizontalScrollTargets, scheduleVirtualHorizontalWheel]); useEffect(() => { - if (viewMode !== 'table') return; + if (!isTableSurfaceActive) return; const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current)); return () => cancelAnimationFrame(rafId); - }, [viewMode, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]); + }, [isTableSurfaceActive, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]); // 虚拟表列对齐:antd 虚拟表 body 使用
+
(非 ), // 不会自动拉伸列宽到视口。而 header
会被 antd 的 CSS 或 JS // 设置为 width:100% 自动拉伸。强制 header table 宽度等于 scroll.x, // 使 header 列宽与 body 单元格宽度精确一致。 useEffect(() => { - if (viewMode !== 'table') return; + if (!isTableSurfaceActive) return; const container = tableContainerRef.current; if (!container) return; const syncHeaderWidth = () => { @@ -5914,10 +6375,10 @@ const DataGrid: React.FC = ({ const headerEl = container.querySelector('.ant-table-header'); if (headerEl) observer.observe(headerEl, { attributes: true, childList: true, subtree: true, attributeFilter: ['style'] }); return () => { cancelAnimationFrame(rafId); observer.disconnect(); }; - }, [viewMode, tableScrollX, mergedDisplayData.length]); + }, [isTableSurfaceActive, tableScrollX, mergedDisplayData.length]); useEffect(() => { - if (viewMode !== 'table' || !onScrollSnapshotChange) return; + if (!isTableSurfaceActive || !onScrollSnapshotChange) return; const tableContainer = tableContainerRef.current; if (!(tableContainer instanceof HTMLElement)) return; @@ -5927,7 +6388,8 @@ const DataGrid: React.FC = ({ const externalScroll = externalHorizontalScrollRef.current; const hasStoredScroll = !!scrollSnapshot && (Math.abs(scrollSnapshot.top) > 0.5 || Math.abs(scrollSnapshot.left) > 0.5); - const emitSnapshot = () => { + const emitSnapshotNow = () => { + scrollSnapshotRafRef.current = null; if (!didRestoreScrollRef.current && hasStoredScroll) { return; } @@ -5941,6 +6403,10 @@ const DataGrid: React.FC = ({ lastReportedScrollRef.current = { top, left }; onScrollSnapshotChange({ top, left }); }; + const emitSnapshot = () => { + if (scrollSnapshotRafRef.current !== null) return; + scrollSnapshotRafRef.current = requestAnimationFrame(emitSnapshotNow); + }; const bindTargets = () => { if (boundVerticalTarget) { @@ -5961,16 +6427,20 @@ const DataGrid: React.FC = ({ rafId = requestAnimationFrame(bindTargets); return () => { if (rafId !== null) cancelAnimationFrame(rafId); + if (scrollSnapshotRafRef.current !== null) { + cancelAnimationFrame(scrollSnapshotRafRef.current); + scrollSnapshotRafRef.current = null; + } if (boundVerticalTarget) { boundVerticalTarget.removeEventListener('scroll', emitSnapshot); } boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot)); externalScroll?.removeEventListener('scroll', emitSnapshot); }; - }, [viewMode, mergedDisplayData.length, onScrollSnapshotChange, pickHorizontalScrollTargets, pickVerticalScrollTarget, scrollSnapshot]); + }, [isTableSurfaceActive, mergedDisplayData.length, onScrollSnapshotChange, pickHorizontalScrollTargets, pickVerticalScrollTarget, scrollSnapshot]); useEffect(() => { - if (viewMode !== 'table') return; + if (!isTableSurfaceActive) return; if (!scrollSnapshot) return; if (didRestoreScrollRef.current) return; const tableContainer = tableContainerRef.current; @@ -6004,10 +6474,10 @@ const DataGrid: React.FC = ({ }); return () => cancelAnimationFrame(rafId); - }, [viewMode, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]); + }, [isTableSurfaceActive, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]); useEffect(() => { - if (viewMode !== 'table') return; + if (!isTableSurfaceActive) return; const tableContainer = tableContainerRef.current; const externalScroll = externalHorizontalScrollRef.current; if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) return; @@ -6054,7 +6524,7 @@ const DataGrid: React.FC = ({ cancelAnimationFrame(rafId); } }; - }, [viewMode, tableScrollX, mergedDisplayData.length, syncExternalScrollFromTargets, pickHorizontalScrollTargets]); + }, [isTableSurfaceActive, tableScrollX, mergedDisplayData.length, syncExternalScrollFromTargets, pickHorizontalScrollTargets]); const paginationSummaryText = useMemo(() => { if (!pagination) return ''; @@ -6073,6 +6543,45 @@ const DataGrid: React.FC = ({ }); }, [pagination, supportsApproximateTotalPages]); + const paginationControlTotal = useMemo(() => { + if (!pagination) return 0; + return resolvePaginationTotalForControl({ + pagination, + supportsApproximateTotalPages, + }); + }, [pagination, supportsApproximateTotalPages]); + + const paginationTotalPages = useMemo(() => { + if (!pagination) return 1; + if (!Number.isFinite(paginationControlTotal) || paginationControlTotal <= 0) { + return Math.max(1, pagination.current); + } + return Math.max(1, Math.ceil(paginationControlTotal / Math.max(1, pagination.pageSize))); + }, [pagination, paginationControlTotal]); + + const paginationV2SummaryText = useMemo(() => { + if (!pagination) return ''; + if (pagination.totalKnown === false) { + if (prefersManualTotalCount) { + if (pagination.totalCountLoading) return `${mergedDisplayData.length} 条 · 正在统计`; + if (supportsApproximateTotalPages && paginationControlTotal > 0) { + return `约 ${paginationControlTotal} 条 · 共 ${paginationTotalPages} 页`; + } + if (pagination.totalCountCancelled) return `${mergedDisplayData.length} 条 · 已取消统计`; + return `${mergedDisplayData.length} 条 · 总数未统计`; + } + return `${mergedDisplayData.length} 条 · 正在统计`; + } + return `${Math.max(0, paginationControlTotal)} 条 · 共 ${paginationTotalPages} 页`; + }, [ + mergedDisplayData.length, + pagination, + paginationControlTotal, + paginationTotalPages, + prefersManualTotalCount, + supportsApproximateTotalPages, + ]); + const handlePageSizeChange = useCallback((value: string) => { if (!pagination || !onPageChange) return; const nextSize = Number(value); @@ -6082,11 +6591,523 @@ const DataGrid: React.FC = ({ onPageChange(nextPage, nextSize); }, [pagination, onPageChange]); + const handleV2PageStep = useCallback((direction: 'previous' | 'next') => { + if (!pagination || !onPageChange) return; + const nextPage = direction === 'previous' + ? Math.max(1, pagination.current - 1) + : Math.min(paginationTotalPages, pagination.current + 1); + if (nextPage === pagination.current) return; + onPageChange(nextPage, pagination.pageSize); + }, [onPageChange, pagination, paginationTotalPages]); + + const renderToolbarDivider = () => ( +
+ ); + const aiShortcutLabel = resolveShortcutDisplay(shortcutOptions ?? DEFAULT_SHORTCUT_OPTIONS, 'toggleAIPanel', activeShortcutPlatform); + const legacyAiButtonStyle: React.CSSProperties | undefined = isV2Ui ? undefined : { + background: darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))' : 'linear-gradient(135deg, rgba(16,185,129,0.1), rgba(16,185,129,0.02))', + borderColor: darkMode ? 'rgba(16,185,129,0.3)' : 'rgba(16,185,129,0.4)', + color: '#10b981', + fontWeight: 500, + boxShadow: darkMode ? '0 2px 8px rgba(16,185,129,0.1)' : '0 2px 6px rgba(16,185,129,0.05)', + }; + const renderDataTableView = () => ( +
+
+ + + + + +
+ + + + + + +
+
+
+
+ ); + const renderV2FieldsView = () => ( +
+
+
+ FIELDS + {tableName || '查询结果'} +
+
+ {displayOutputColumnNames.length} 个字段 +
+
+
+
+ # + 名称 + 类型 + NN + PK + 默认值 + 注释 +
+ {displayOutputColumnNames.map((columnName, index) => { + const meta = columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()]; + const isPk = pkColumns.includes(columnName) || effectiveEditLocator?.columns?.includes(columnName); + return ( +
+ {index + 1} + {columnName} + {meta?.type || '-'} + - + {isPk ? PK : '-'} + - + {meta?.comment || '-'} +
+ ); + })} +
+
+ ); + const renderV2DdlView = (layout: DdlViewLayoutMode) => ( +
+
+
+ DDL + {tableName ? `DDL - ${tableName}` : 'DDL'} +
+
+ setDdlViewLayout(String(value) as DdlViewLayoutMode)} + /> + + + {layout === 'side' && ( + + )} +
+
+
+ +
+
+ ); + const handleDdlSidebarResizeStart = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const startX = event.clientX; + const startWidth = ddlSidebarWidth; + const moveHandler = (moveEvent: MouseEvent) => { + const nextWidth = Math.max(320, Math.min(760, startWidth + (startX - moveEvent.clientX))); + if (ddlSidebarResizeRef.current) { + ddlSidebarResizeRef.current.previewWidth = nextWidth; + } + setDdlSidebarResizePreviewX(moveEvent.clientX); + }; + const upHandler = () => { + const nextWidth = ddlSidebarResizeRef.current?.previewWidth ?? startWidth; + setDdlSidebarWidth(nextWidth); + setDdlSidebarResizePreviewX(null); + document.removeEventListener('mousemove', moveHandler); + document.removeEventListener('mouseup', upHandler); + ddlSidebarResizeRef.current = null; + }; + + ddlSidebarResizeRef.current = { startX, startWidth, previewWidth: startWidth, moveHandler, upHandler }; + document.addEventListener('mousemove', moveHandler); + document.addEventListener('mouseup', upHandler); + }, [ddlSidebarWidth]); + const renderV2DdlSideWorkspace = () => ( +
+
+ {renderDataTableView()} +
+
+ +
+
+ ); + const renderV2ErView = () => ( +
+
+ TABLE + {tableName || '查询结果'} + {displayOutputColumnNames.length} fields +
+
+ + +
+
+ {displayOutputColumnNames.slice(0, 6).map((columnName) => ( +
+ FIELD + {columnName} + {(columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type || '-'} +
+ ))} +
+
+ ); + const toggleDataPanel = () => { + const next = !dataPanelOpen; + setDataPanelOpen(next); + if (!next) { + setFocusedCellInfo(null); + setDataPanelValue(''); + setDataPanelIsJson(false); + dataPanelDirtyRef.current = false; + } + }; + const pageFindContent = ( + +
+ } + placeholder="当前页查找..." + value={pageFindText} + onChange={(event) => setPageFindText(event.target.value)} + style={isV2Ui ? undefined : { width: 220 }} + /> + + + {normalizedPageFindText && ( + + {pageFindMatches.length > 0 ? `${activePageFindPosition} / ${pageFindMatches.length} · ` : ''}匹配 {pageFindSummary.occurrenceCount} 处 / {pageFindSummary.matchedCellCount} 个单元格 + + )} +
+
+ ); + const resultViewSwitcher = ( +
+ 结果视图 + handleViewModeChange(String(val) as GridViewMode)} + /> +
+ ); + const paginationContent = pagination ? ( +
+ {isV2Ui ? ( +
+
+ {paginationV2SummaryText} +
+
+ )} +
+ ) : null; + const renderSecondaryActions = () => { + if (isV2Ui) { + const viewTabItems: Array<{ key: GridViewMode; label: string; icon: React.ReactNode; disabled?: boolean }> = [ + { key: 'table', label: '数据预览', icon: }, + { key: 'fields', label: '字段信息', icon: }, + { key: 'ddl', label: '查看 DDL', icon: , disabled: !canViewDdl }, + { key: 'er', label: 'ER 图', icon: }, + ]; + return ( +
+
+ {viewTabItems.map((item) => ( + + ))} +
+
+ {resultViewSwitcher} + + + +
+ live + {mergedDisplayData.length} 行 + 未提交 {pendingChangeCount} +
+ {pageFindContent} + + ); + } + return ( + <> +
+
+ + + + + {canViewDdl && ( + + )} + {pageFindContent} +
+ {resultViewSwitcher} +
+ {paginationContent} + + ); + }; + return ( -
+
{/* Toolbar + Filter Panel */} -
+
+ {isV2Ui && ( + <> +
+ + {tableName || '查询结果'} + {dbName && · {dbName}} +
+ {renderToolbarDivider()} + + )} {onReload && )} {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}} -
+ {renderToolbarDivider()} + + )} + {renderToolbarDivider()} + {hasChanges && ( = ({ {(canImport || canExport) && ( <> -
+ {renderToolbarDivider()} {canImport && } {canExport && } @@ -6223,7 +7257,7 @@ const DataGrid: React.FC = ({ {isQueryResultExport && ( <> -
+ {renderToolbarDivider()} {prefersManualTotalCount && onRequestTotalCount && ( <> -
+ {renderToolbarDivider()}
{showFilter && ( -
= ({ onChange={setQuickWhereDraft} onOpenChange={setQuickWhereSuggestionsOpen} onInputKeyDown={(event) => { + const isClipboardShortcut = (event.metaKey || event.ctrlKey) && !event.altKey && ['c', 'v', 'x'].includes(String(event.key || '').toLowerCase()); + if (isClipboardShortcut) { + event.stopPropagation(); + return; + } if (!shouldApplyQuickWhereOnEnter({ key: event.key, shiftKey: event.shiftKey, @@ -6372,6 +7409,10 @@ const DataGrid: React.FC = ({ @@ -6776,64 +7817,15 @@ const DataGrid: React.FC = ({ {viewMode === 'table' ? ( -
-
- - - - - -
- - - - - - -
-
-
-
+ renderDataTableView() + ) : isV2Ui && viewMode === 'fields' ? ( + renderV2FieldsView() + ) : isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side' ? ( + renderV2DdlSideWorkspace() + ) : isV2Ui && viewMode === 'ddl' ? ( + renderV2DdlView('bottom') + ) : isV2Ui && viewMode === 'er' ? ( + renderV2ErView() ) : viewMode === 'json' ? (
@@ -6903,7 +7895,7 @@ const DataGrid: React.FC = ({ )} {/* Data Preview Panel */} - {dataPanelOpen && viewMode === 'table' && ( + {dataPanelOpen && isTableSurfaceActive && (
= ({
)} + {isTableSurfaceActive && isV2Ui && cellContextMenu.visible && createPortal( +
e.stopPropagation()} + > + {cellContextMenu.kind === 'column' ? (() => { + const fieldName = resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title); + const meta = columnMetaMap[fieldName] || columnMetaMapByLowerName[fieldName.toLowerCase()]; + const activeSort = sortInfo.find((item) => item.columnKey === fieldName && item.enabled !== false); + return ( + + ); + })() : ( + + )} +
, + document.body + )} + {/* Cell Context Menu - 使用 Portal 渲染到 body,避免 backdropFilter 影响 fixed 定位 */} - {viewMode === 'table' && cellContextMenu.visible && createPortal( + {isTableSurfaceActive && !isV2Ui && cellContextMenu.visible && createPortal(
= ({ )}
-
-
- - - - - {canViewDdl && ( - - )} - -
- } - placeholder="当前页查找..." - value={pageFindText} - onChange={(event) => setPageFindText(event.target.value)} - style={{ width: 220 }} - /> - - - {normalizedPageFindText && ( - - {pageFindMatches.length > 0 ? `${activePageFindPosition} / ${pageFindMatches.length} · ` : ''}匹配 {pageFindSummary.occurrenceCount} 处 / {pageFindSummary.matchedCellCount} 个单元格 - - )} -
-
-
-
- 结果视图 - handleViewModeChange(String(val) as GridViewMode)} - /> -
-
- - {pagination && ( -
-
-
- 结果集 - {paginationSummaryText} -
-
{paginationPageText}
- { - if (type === 'prev') { - return ; - } - if (type === 'next') { - return ; - } - return originalElement; - }} - /> -