diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index bed8925..7396e24 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -0295a42fd931778d85157816d79d29e5 \ No newline at end of file +d0464f9da25e9356e61652e638c99ffe \ No newline at end of file diff --git a/frontend/src/App.ai-panel-error-boundary.test.ts b/frontend/src/App.ai-panel-error-boundary.test.ts new file mode 100644 index 0000000..cb851b1 --- /dev/null +++ b/frontend/src/App.ai-panel-error-boundary.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const appSource = readFileSync( + fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)), + 'utf8', +); + +describe('AI panel lazy-load guard', () => { + it('keeps AI panel failures scoped to the panel area with retry support', () => { + expect(appSource).toContain('const createLazyAIChatPanel = () => React.lazy(() => import(\'./components/AIChatPanel\'));'); + expect(appSource).toContain('class AIPanelErrorBoundary extends React.Component'); + expect(appSource).toContain(' current + 1)'); + expect(appSource).toContain(' { const afterCase = appSource.slice(start + caseToken.length); const nextCaseIndex = afterCase.search(/\n\s+case '[^']+':/); - const switchEndIndex = afterCase.search(/\n\s+}\n\s+};\n\n\s+window\.addEventListener\('keydown', handleGlobalShortcut\);/); + const switchEndIndex = afterCase.indexOf("window.addEventListener('keydown', handleGlobalShortcut);"); const endIndex = nextCaseIndex >= 0 ? nextCaseIndex : switchEndIndex; expect(endIndex).toBeGreaterThan(-1); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c7d12ad..e1cfa6d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -89,12 +89,12 @@ import { resolveLegacyAIEdgeHandleDockStyle, resolveLegacyAIEdgeHandleStyle, } from './utils/aiEntryLayout'; +import { DEFAULT_AI_PANEL_WIDTH, resolveOverlayAIPanelWidth, shouldOverlayAIPanel } from './utils/aiPanelLayout'; +import { safeWindowRuntimeCall } from './utils/wailsRuntime'; import { ApplyDataRootDirectory, GetDataRootDirectoryInfo, GetSavedConnections, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App'; import './App.css'; import './v2-theme.css'; -const AIChatPanel = React.lazy(() => import('./components/AIChatPanel')); - const { Sider, Content } = Layout; const SIDEBAR_RESIZE_MIN_WIDTH = 200; const SIDEBAR_RESIZE_MAX_WIDTH = 600; @@ -187,6 +187,45 @@ const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogStat confirmLoading: false, }); +const createLazyAIChatPanel = () => React.lazy(() => import('./components/AIChatPanel')); + +interface AIPanelErrorBoundaryProps { + children: React.ReactNode; + fallback: (error: Error | null) => React.ReactNode; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; +} + +interface AIPanelErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class AIPanelErrorBoundary extends React.Component< + AIPanelErrorBoundaryProps, + AIPanelErrorBoundaryState +> { + constructor(props: AIPanelErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): AIPanelErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + this.props.onError?.(error, errorInfo); + } + + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error); + } + + return this.props.children; + } +} + function App() { const [isModalOpen, setIsModalOpen] = useState(false); const [isSyncModalOpen, setIsSyncModalOpen] = useState(false); @@ -244,6 +283,7 @@ function App() { const [isLinuxRuntime, setIsLinuxRuntime] = useState(false); const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); const [hasLoadedSecureConfig, setHasLoadedSecureConfig] = useState(false); + const [viewportWidth, setViewportWidth] = useState(() => (typeof window === 'undefined' ? 1280 : window.innerWidth || 1280)); const [securityUpdateStatus, setSecurityUpdateStatus] = useState(() => createEmptySecurityUpdateStatus()); const [securityUpdateRawPayload, setSecurityUpdateRawPayload] = useState(null); const [securityUpdateHasLegacySensitiveItems, setSecurityUpdateHasLegacySensitiveItems] = useState(false); @@ -258,6 +298,7 @@ function App() { const [focusedAIProviderId, setFocusedAIProviderId] = useState(undefined); const [connectionPackageDialog, setConnectionPackageDialog] = useState(() => createClosedConnectionPackageDialogState()); const [pendingConnectionImportPayload, setPendingConnectionImportPayload] = useState(null); + const [aiPanelRenderNonce, setAiPanelRenderNonce] = useState(0); const sidebarWidth = useStore(state => state.sidebarWidth); const setSidebarWidth = useStore(state => state.setSidebarWidth); const aiPanelVisible = useStore(state => state.aiPanelVisible); @@ -269,6 +310,7 @@ function App() { const windowDiagLastSignatureRef = React.useRef(''); const windowDiagLastAtRef = React.useRef(0); const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig); + const LazyAIChatPanel = useMemo(() => createLazyAIChatPanel(), [aiPanelRenderNonce]); const securityUpdateStatusMeta = useMemo( () => getSecurityUpdateStatusMeta(securityUpdateStatus), [securityUpdateStatus], @@ -279,6 +321,18 @@ function App() { ); const windowCornerRadius = 14; + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + const syncViewportWidth = () => { + setViewportWidth(window.innerWidth || document.documentElement?.clientWidth || 1280); + }; + syncViewportWidth(); + window.addEventListener('resize', syncViewportWidth); + return () => window.removeEventListener('resize', syncViewportWidth); + }, []); + useEffect(()=>{ if (typeof document === 'undefined' || !document.body) { return; @@ -629,8 +683,8 @@ function App() { const saveWindowState = async () => { try { const [isFs, isMax] = await Promise.all([ - WindowIsFullscreen().catch(() => false), - WindowIsMaximised().catch(() => false), + safeWindowRuntimeCall(() => WindowIsFullscreen(), false), + safeWindowRuntimeCall(() => WindowIsMaximised(), false), ]); // 保存窗口状态 @@ -648,8 +702,8 @@ function App() { if (isFs || isMax) return; const [size, pos] = await Promise.all([ - WindowGetSize().catch(() => null), - WindowGetPosition().catch(() => null), + safeWindowRuntimeCall(() => WindowGetSize(), null), + safeWindowRuntimeCall(() => WindowGetPosition(), null), ]); if (!size || !pos) return; const w = Math.trunc(Number(size.w || 0)); @@ -697,8 +751,8 @@ function App() { inFlight = true; try { const [isFullscreen, isMaximised] = await Promise.all([ - WindowIsFullscreen().catch(() => false), - WindowIsMaximised().catch(() => false), + safeWindowRuntimeCall(() => WindowIsFullscreen(), false), + safeWindowRuntimeCall(() => WindowIsMaximised(), false), ]); // 全屏状态下只广播 resize,避免破坏用户的全屏上下文。 @@ -708,7 +762,7 @@ function App() { return; } - const size = await WindowGetSize().catch(() => null); + const size = await safeWindowRuntimeCall(() => WindowGetSize(), null); const width = Math.trunc(Number(size?.w || 0)); const height = Math.trunc(Number(size?.h || 0)); const hasViewportScaleDrift = hasWindowsViewportScaleDrift({ @@ -782,7 +836,7 @@ function App() { const rememberMinimisedState = async (): Promise => { if (cancelled) return false; - const isMinimised = await WindowIsMinimised().catch(() => false); + const isMinimised = await safeWindowRuntimeCall(() => WindowIsMinimised(), false); if (isMinimised) { minimisedSeen = true; } @@ -1336,12 +1390,12 @@ function App() { } try { const [isFullscreen, isMaximised, isMinimised, isNormal, size, position] = await Promise.all([ - WindowIsFullscreen().catch(() => false), - WindowIsMaximised().catch(() => false), - WindowIsMinimised().catch(() => false), - WindowIsNormal().catch(() => false), - WindowGetSize().catch(() => null), - WindowGetPosition().catch(() => null), + safeWindowRuntimeCall(() => WindowIsFullscreen(), false), + safeWindowRuntimeCall(() => WindowIsMaximised(), false), + safeWindowRuntimeCall(() => WindowIsMinimised(), false), + safeWindowRuntimeCall(() => WindowIsNormal(), false), + safeWindowRuntimeCall(() => WindowGetSize(), null), + safeWindowRuntimeCall(() => WindowGetPosition(), null), ]); const payload = { seq: ++windowDiagSequenceRef.current, @@ -2024,6 +2078,19 @@ function App() { const [isAISettingsOpen, setIsAISettingsOpen] = useState(false); const aiEntryPlacement = resolveAIEntryPlacement(); const legacyAiEdgeHandleAttachment = resolveLegacyAIEdgeHandleAttachment(aiPanelVisible); + const aiPanelOverlayActive = aiPanelVisible && shouldOverlayAIPanel({ + isV2Ui, + viewportWidth, + sidebarWidth, + panelWidth: DEFAULT_AI_PANEL_WIDTH, + }); + const aiPanelRenderWidth = aiPanelOverlayActive + ? resolveOverlayAIPanelWidth({ + viewportWidth, + sidebarWidth, + panelWidth: DEFAULT_AI_PANEL_WIDTH, + }) + : DEFAULT_AI_PANEL_WIDTH; const legacyAiEdgeHandleDockStyle = useMemo( () => resolveLegacyAIEdgeHandleDockStyle(legacyAiEdgeHandleAttachment), [legacyAiEdgeHandleAttachment], @@ -2332,6 +2399,14 @@ function App() { setIsAISettingsOpen(true); }, []); + const handleAIPanelRenderError = useCallback((error: Error, errorInfo: React.ErrorInfo) => { + console.error('AIChatPanel render error:', error, errorInfo); + }, []); + + const handleRetryAIPanelRender = useCallback(() => { + setAiPanelRenderNonce((current) => current + 1); + }, []); + const handleCloseAISettings = useCallback(() => { const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource); setIsAISettingsOpen(false); @@ -2346,8 +2421,8 @@ function App() { const syncWindowStateFromRuntime = async () => { try { const [isFullscreen, isMaximised] = await Promise.all([ - WindowIsFullscreen().catch(() => false), - WindowIsMaximised().catch(() => false), + safeWindowRuntimeCall(() => WindowIsFullscreen(), false), + safeWindowRuntimeCall(() => WindowIsMaximised(), false), ]); useStore.getState().setWindowState(isFullscreen ? 'fullscreen' : (isMaximised ? 'maximized' : 'normal')); } catch { @@ -2369,7 +2444,7 @@ function App() { void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen'); return; } - const isMaximised = await WindowIsMaximised().catch(() => false); + const isMaximised = await safeWindowRuntimeCall(() => WindowIsMaximised(), false); if (isMaximised) { WindowUnmaximise(); } else { @@ -2413,19 +2488,19 @@ function App() { console.warn('ResetWebViewZoom backend unavailable, falling back to maximise toggle', e); } try { - const isFullscreen = await WindowIsFullscreen().catch(() => false); + const isFullscreen = await safeWindowRuntimeCall(() => WindowIsFullscreen(), false); if (isFullscreen) { message.info('全屏状态下无法重置缩放,请先退出全屏'); return; } - const isMaximised = await WindowIsMaximised().catch(() => false); + const isMaximised = await safeWindowRuntimeCall(() => WindowIsMaximised(), false); if (isMaximised) { WindowUnmaximise(); await new Promise((resolve) => window.setTimeout(resolve, 96)); WindowMaximise(); await new Promise((resolve) => window.setTimeout(resolve, 96)); } else { - const size = await WindowGetSize().catch(() => null); + const size = await safeWindowRuntimeCall(() => WindowGetSize(), null); const width = Math.trunc(Number(size?.w) || 0); const height = Math.trunc(Number(size?.h) || 0); if (width > 0 && height > 0) { @@ -3157,7 +3232,42 @@ function App() { )} {aiPanelVisible && ( -
+
+ {aiPanelOverlayActive && ( + + +
+
+ + )} + > + }> + setAIPanelVisible(false)} onOpenSettings={() => { + handleOpenAISettings(); + }} overlayTheme={overlayTheme} /> + + + )} diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 2bfee13..040523a 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -246,6 +246,11 @@ export const AIChatPanel: React.FC = ({ const pendingJVMPlanContextRef = useRef(undefined); const pendingJVMDiagnosticPlanContextRef = useRef(undefined); + useEffect(() => { + setPanelWidth(width); + dragWidthRef.current = width; + }, [width]); + const aiChatHistory = useStore(state => state.aiChatHistory); const aiActiveSessionId = useStore(state => state.aiActiveSessionId); const appearance = useStore(state => state.appearance); diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index de07fca..06d29f6 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -91,13 +91,14 @@ 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('data-grid-column-quick-find-action="true"'); expect(markup).toContain('字段显示'); + 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('gn-v2-data-grid-status-main'); + expect(markup).toContain('gn-v2-data-grid-status-right'); 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"'); @@ -367,8 +368,19 @@ describe('DataGrid layout', () => { it('keeps DataGrid scroll synchronization throttled to animation frames', () => { const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8'); + const columnTitleSource = readFileSync(new URL('./DataGridColumnTitle.tsx', import.meta.url), 'utf8'); + const columnQuickFindSource = readFileSync(new URL('./DataGridColumnQuickFind.tsx', import.meta.url), 'utf8'); + const paginationBarSource = readFileSync(new URL('./DataGridPaginationBar.tsx', import.meta.url), 'utf8'); + const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); expect(source).toContain('virtualHorizontalElementsRef'); + expect(source).toContain('const handleSubmitColumnQuickFind = useCallback(() => {'); + expect(source).toContain('resolveDataGridColumnQuickFindScrollLeft({'); + expect(source).toContain('const applied = applyVirtualHorizontalOffset(tableContainer, nextScrollLeft);'); + expect(source).toContain('syncExternalScrollFromTargets();'); + expect(source).toContain("const columnQuickFindContent = isTableSurfaceActive ? ("); + expect(secondaryActionsSource).toContain('data-grid-column-quick-find-action="true"'); expect(source).toContain('type VirtualTableScrollReference = TableReference & {'); expect(source).toContain('const tableRef = useRef(null);'); expect(source).toContain('resolveDataGridHorizontalWheelDelta({'); @@ -406,6 +418,17 @@ describe('DataGrid layout', () => { expect(source).toContain('scrollSnapshotRafRef.current = requestAnimationFrame'); expect(source).toContain("const dataGridBackdropFilter = isV2Ui || isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');"); expect(source).toContain('rowHoverable={!enableVirtual}'); + expect(columnTitleSource).toContain("data-grid-column-highlighted={highlighted ? 'true' : undefined}"); + expect(columnTitleSource).toContain('data-column-name={normalizedName}'); + expect(columnQuickFindSource).toContain('AutoComplete'); + expect(columnQuickFindSource).toContain('placeholder="跳到字段列..."'); + expect(secondaryActionsSource.indexOf('{pageFindContent}')).toBeLessThan(secondaryActionsSource.indexOf('gn-v2-data-grid-status-center')); + expect(css).toContain('width: 66px !important;'); + expect(css).toContain('grid-template-columns: 160px 26px 26px !important;'); + expect(css).toContain('.data-grid-pagination-size-select.ant-select-focused .ant-select-selector'); + expect(css).toContain('overflow-x: auto;'); + expect(paginationBarSource).toContain("label: `${value}/页`"); + expect(css).toContain('background: transparent !important;'); }); it('keeps the DataGrid performance harness aligned with legacy and v2 comparison controls', () => { diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 90ccc5b..0803e68 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -42,6 +42,7 @@ import { calculateExternalHorizontalScrollInnerWidth, calculateTableBodyBottomPadding, calculateVirtualTableScrollX, + resolveDataGridColumnQuickFindScrollLeft, resolveDataGridHorizontalWheelDelta, } from './dataGridLayout'; import { @@ -110,6 +111,7 @@ import { } from './V2TableContextMenu'; import DataGridColumnTitle from './DataGridColumnTitle'; import DataGridColumnInfoPopoverContent from './DataGridColumnInfoPopoverContent'; +import DataGridColumnQuickFind from './DataGridColumnQuickFind'; import DataGridPageFind from './DataGridPageFind'; import DataGridPaginationBar from './DataGridPaginationBar'; import DataGridResultViewSwitcher from './DataGridResultViewSwitcher'; @@ -1559,16 +1561,32 @@ const DataGrid: React.FC = ({ const [displayColumnNames, setDisplayColumnNames] = useState([]); const [localHiddenColumns, setLocalHiddenColumns] = useState([]); const [columnSearchText, setColumnSearchText] = useState(''); + const [columnQuickFindText, setColumnQuickFindText] = useState(''); + const [highlightedColumnName, setHighlightedColumnName] = useState(''); const [pageFindText, setPageFindText] = useState(''); const [activePageFindMatchIndex, setActivePageFindMatchIndex] = useState(-1); + const columnQuickFindHighlightTimerRef = useRef | null>(null); const deferredPageFindText = useDeferredValue(pageFindText); + const deferredColumnQuickFindText = useDeferredValue(columnQuickFindText); const normalizedPageFindText = useMemo(() => normalizeDataGridFindQuery(deferredPageFindText), [deferredPageFindText]); + const normalizedColumnQuickFindText = useMemo( + () => normalizeDataGridFindQuery(deferredColumnQuickFindText), + [deferredColumnQuickFindText], + ); useEffect(() => { + setColumnQuickFindText(''); + setHighlightedColumnName(''); setPageFindText(''); setActivePageFindMatchIndex(-1); }, [connectionId, dbName, tableName]); + useEffect(() => () => { + if (columnQuickFindHighlightTimerRef.current) { + clearTimeout(columnQuickFindHighlightTimerRef.current); + } + }, []); + // Sync hidden columns from store useEffect(() => { if (enableHiddenColumnMemory && connectionId && dbName && tableName) { @@ -2349,10 +2367,11 @@ const DataGrid: React.FC = ({ columnMetaHintColor={columnMetaHintColor} columnMetaTooltipColor={columnMetaTooltipColor} darkMode={darkMode} + highlighted={highlightedColumnName === normalizedName} onOpenForeignKey={foreignKeyTarget ? () => openForeignKeyTarget(foreignKeyTarget) : undefined} /> ); - }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, darkMode, densityParams.metaFontSize, foreignKeyMap, foreignKeyMapByLowerName, openForeignKeyTarget, showColumnComment, showColumnType]); + }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, darkMode, densityParams.metaFontSize, foreignKeyMap, foreignKeyMapByLowerName, highlightedColumnName, openForeignKeyTarget, showColumnComment, showColumnType]); const lockVirtualInlineTableScroll = useCallback((lock: boolean) => { if (lock) { @@ -2875,13 +2894,18 @@ const DataGrid: React.FC = ({ height: 100%; } .${gridId} .data-grid-pagination-size-select { - min-width: 112px; + width: 72px; + min-width: 72px; + max-width: 72px; height: 34px; display: inline-flex; align-items: stretch; } .${gridId} .data-grid-pagination-size-select.ant-select-single, .${gridId} .data-grid-pagination-size-select.ant-select-single.ant-select-sm { + width: 72px; + min-width: 72px; + max-width: 72px; height: 34px; } .${gridId} .data-grid-pagination-size-select .ant-select-selector { @@ -2890,7 +2914,7 @@ const DataGrid: React.FC = ({ border: 1px solid ${paginationChipBorderColor} !important; background: ${paginationChipBg} !important; box-shadow: none !important; - padding: 0 12px !important; + padding: 0 24px 0 10px !important; display: flex !important; align-items: center !important; } @@ -2911,15 +2935,16 @@ const DataGrid: React.FC = ({ line-height: 34px !important; color: ${paginationPrimaryTextColor}; font-weight: 600; + justify-content: flex-start; font-variant-numeric: tabular-nums; } .${gridId} .data-grid-pagination-size-select .ant-select-selection-search { - inset-inline-start: 12px !important; - inset-inline-end: 32px !important; + inset-inline-start: 10px !important; + inset-inline-end: 24px !important; } .${gridId} .data-grid-pagination-size-select .ant-select-arrow { color: ${paginationSecondaryTextColor}; - inset-inline-end: 12px; + inset-inline-end: 10px; top: 50%; transform: translateY(-50%); margin-top: 0; @@ -6246,6 +6271,37 @@ const DataGrid: React.FC = ({ if (match) focusPageFindMatch(match); }, [activePageFindMatchIndex, pageFindMatches, focusPageFindMatch]); + const visibleColumnQuickFindMatches = useMemo(() => { + if (!normalizedColumnQuickFindText) return []; + return displayColumnNames.filter((columnName) => ( + normalizeDataGridFindQuery(columnName).includes(normalizedColumnQuickFindText) + )); + }, [displayColumnNames, normalizedColumnQuickFindText]); + + const columnQuickFindOptions = useMemo( + () => visibleColumnQuickFindMatches.slice(0, 12).map((columnName) => ({ value: columnName, label: columnName })), + [visibleColumnQuickFindMatches], + ); + + const resolveColumnQuickFindTarget = useCallback((): string => { + const exactMatch = displayColumnNames.find((columnName) => ( + normalizeDataGridFindQuery(columnName) === normalizedColumnQuickFindText + )); + if (exactMatch) return exactMatch; + return visibleColumnQuickFindMatches[0] || ''; + }, [displayColumnNames, normalizedColumnQuickFindText, visibleColumnQuickFindMatches]); + + const highlightColumnQuickFindTarget = useCallback((columnName: string) => { + setHighlightedColumnName(columnName); + if (columnQuickFindHighlightTimerRef.current) { + clearTimeout(columnQuickFindHighlightTimerRef.current); + } + columnQuickFindHighlightTimerRef.current = setTimeout(() => { + setHighlightedColumnName((prev) => (prev === columnName ? '' : prev)); + columnQuickFindHighlightTimerRef.current = null; + }, 1600); + }, []); + const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => { const externalScroll = externalHorizontalScrollRef.current; if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') { @@ -6392,6 +6448,107 @@ const DataGrid: React.FC = ({ }); }, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset, syncVirtualHorizontalVisualOffset]); + const focusColumnQuickFindTarget = useCallback((columnName: string): boolean => { + const root = rootRef.current; + const tableContainer = tableContainerRef.current; + if (!(root instanceof HTMLElement) || !(tableContainer instanceof HTMLElement)) return false; + const headerTarget = Array.from(root.querySelectorAll('[data-column-name]')).find((node) => { + const el = node as HTMLElement; + return el.getAttribute('data-column-name') === columnName; + }) as HTMLElement | undefined; + if (!headerTarget) return false; + + const externalScroll = externalHorizontalScrollRef.current; + const tableToExternalTargets = pickTableToExternalSyncTargets(tableContainer); + const referenceScrollTarget = + tableToExternalTargets.find((target) => target.scrollWidth > target.clientWidth + 1) + || tableToExternalTargets[0] + || (tableContainer.querySelector('.ant-table-header') as HTMLElement | null); + if (!(referenceScrollTarget instanceof HTMLElement)) { + return false; + } + + const currentScrollLeft = enableVirtual + ? readVirtualHorizontalOffset(tableContainer) + : referenceScrollTarget.scrollLeft; + const targetRect = headerTarget.getBoundingClientRect(); + const viewportRect = referenceScrollTarget.getBoundingClientRect(); + const nextScrollLeft = resolveDataGridColumnQuickFindScrollLeft({ + currentScrollLeft, + columnLeft: currentScrollLeft + (targetRect.left - viewportRect.left), + columnWidth: targetRect.width, + viewportWidth: referenceScrollTarget.clientWidth, + scrollWidth: referenceScrollTarget.scrollWidth, + }); + + if (enableVirtual) { + const applied = applyVirtualHorizontalOffset(tableContainer, nextScrollLeft); + if (applied) { + lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer); + syncExternalScrollFromTargets(); + requestAnimationFrame(() => { + syncExternalScrollFromTargets(); + }); + } else { + tableToExternalTargets.forEach((target) => { + if (target.scrollWidth <= target.clientWidth + 1) { + return; + } + if (Math.abs(target.scrollLeft - nextScrollLeft) > 1) { + target.scrollLeft = nextScrollLeft; + } + }); + lastTableScrollLeftRef.current = nextScrollLeft; + syncExternalScrollFromTargets(tableToExternalTargets, tableToExternalTargets[0] ?? referenceScrollTarget); + } + } else { + const targets = pickHorizontalScrollTargets(tableContainer); + const liveTargets = targets.length > 0 ? targets : tableToExternalTargets; + liveTargets.forEach((target) => { + if (target.scrollWidth <= target.clientWidth + 1) { + return; + } + if (Math.abs(target.scrollLeft - nextScrollLeft) > 1) { + target.scrollLeft = nextScrollLeft; + } + }); + lastTableScrollLeftRef.current = nextScrollLeft; + scheduleSyncExternalScrollFromTargets(liveTargets[0] ?? referenceScrollTarget); + } + + highlightColumnQuickFindTarget(columnName); + return true; + }, [ + applyVirtualHorizontalOffset, + enableVirtual, + highlightColumnQuickFindTarget, + pickHorizontalScrollTargets, + pickTableToExternalSyncTargets, + readVirtualHorizontalOffset, + scheduleSyncExternalScrollFromTargets, + syncExternalScrollFromTargets, + ]); + + const handleSubmitColumnQuickFind = useCallback(() => { + const targetColumnName = resolveColumnQuickFindTarget(); + if (!targetColumnName) { + if (columnQuickFindText.trim()) { + void message.warning(`未找到字段列:${columnQuickFindText.trim()}`); + } + return; + } + setColumnQuickFindText(targetColumnName); + const tryFocus = () => focusColumnQuickFindTarget(targetColumnName); + if (tryFocus()) return; + requestAnimationFrame(() => { + if (tryFocus()) return; + requestAnimationFrame(() => { + if (tryFocus()) return; + void message.warning(`字段列“${targetColumnName}”当前未渲染,无法定位`); + }); + }); + }, [columnQuickFindText, focusColumnQuickFindTarget, resolveColumnQuickFindTarget]); + // 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效) useEffect(() => { const externalScroll = externalHorizontalScrollRef.current; @@ -6872,6 +7029,18 @@ const DataGrid: React.FC = ({ onNavigateNext={() => handleNavigatePageFind('next')} /> ); + const columnQuickFindContent = isTableSurfaceActive ? ( + } + value={columnQuickFindText} + options={columnQuickFindOptions} + hasTarget={!!resolveColumnQuickFindTarget()} + onChange={setColumnQuickFindText} + onSubmit={handleSubmitColumnQuickFind} + /> + ) : null; const resultViewSwitcher = ( = ({ pendingChangeCount={pendingChangeCount} resultViewSwitcher={resultViewSwitcher} columnInfoSettingContent={columnInfoSettingContent} + columnQuickFindContent={columnQuickFindContent} pageFindContent={pageFindContent} paginationContent={paginationContent} onViewModeChange={handleViewModeChange} diff --git a/frontend/src/components/DataGridColumnQuickFind.tsx b/frontend/src/components/DataGridColumnQuickFind.tsx new file mode 100644 index 0000000..d6509e3 --- /dev/null +++ b/frontend/src/components/DataGridColumnQuickFind.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { AutoComplete, Button, Input, Tooltip } from 'antd'; +import { AimOutlined, SearchOutlined } from '@ant-design/icons'; + +export interface DataGridColumnQuickFindProps { + isV2Ui: boolean; + darkMode: boolean; + inputProps?: Record; + value: string; + options: Array<{ value: string; label?: React.ReactNode }>; + hasTarget: boolean; + onChange: (value: string) => void; + onSubmit: () => void; +} + +const DataGridColumnQuickFind: React.FC = ({ + isV2Ui, + darkMode, + inputProps, + value, + options, + hasTarget, + onChange, + onSubmit, +}) => ( + +
+
+
+ + } + placeholder="跳到字段列..." + value={value} + onChange={(event) => onChange(event.target.value)} + onPressEnter={onSubmit} + style={isV2Ui ? undefined : { width: 220 }} + /> + +
+ +
+ {!isV2Ui && ( + + 定位字段列 + + )} +
+
+); + +export default DataGridColumnQuickFind; diff --git a/frontend/src/components/DataGridColumnTitle.tsx b/frontend/src/components/DataGridColumnTitle.tsx index 57c6d82..d994256 100644 --- a/frontend/src/components/DataGridColumnTitle.tsx +++ b/frontend/src/components/DataGridColumnTitle.tsx @@ -18,6 +18,7 @@ export interface DataGridColumnTitleProps { columnMetaHintColor: string; columnMetaTooltipColor: string; darkMode: boolean; + highlighted?: boolean; onOpenForeignKey?: () => void; } @@ -31,6 +32,7 @@ const DataGridColumnTitle: React.FC = ({ columnMetaHintColor, columnMetaTooltipColor, darkMode, + highlighted = false, onOpenForeignKey, }) => { const normalizedName = String(columnName || ''); @@ -90,6 +92,8 @@ const DataGridColumnTitle: React.FC = ({ const titleNode = (
= ({ minWidth: 0, maxWidth: '100%', lineHeight: 1.2, + borderRadius: highlighted ? 8 : undefined, + background: highlighted ? (darkMode ? 'rgba(250, 173, 20, 0.18)' : 'rgba(250, 173, 20, 0.16)') : undefined, + boxShadow: highlighted ? `inset 0 0 0 1px ${darkMode ? 'rgba(250, 173, 20, 0.5)' : 'rgba(250, 173, 20, 0.55)'}` : undefined, + padding: highlighted ? '4px 6px' : undefined, + transition: 'background 160ms ease, box-shadow 160ms ease', }} > {fieldLabel} diff --git a/frontend/src/components/DataGridPageFind.tsx b/frontend/src/components/DataGridPageFind.tsx index a49ac3a..b7caca1 100644 --- a/frontend/src/components/DataGridPageFind.tsx +++ b/frontend/src/components/DataGridPageFind.tsx @@ -39,34 +39,40 @@ const DataGridPageFind: React.FC = ({ className={isV2Ui ? 'gn-v2-data-grid-page-find' : undefined} style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 6 }} > - } - placeholder="当前页查找..." - value={pageFindText} - onChange={(event) => onPageFindTextChange(event.target.value)} - style={isV2Ui ? undefined : { width: 220 }} - /> - - +
+ } + placeholder="当前页查找..." + value={pageFindText} + onChange={(event) => onPageFindTextChange(event.target.value)} + style={isV2Ui ? undefined : { width: 220 }} + /> + + +
{normalizedPageFindText && ( {hasMatches ? `${activePageFindPosition} / ${matchCount} · ` : ''}匹配 {occurrenceCount} 处 / {matchedCellCount} 个单元格 diff --git a/frontend/src/components/DataGridPaginationBar.tsx b/frontend/src/components/DataGridPaginationBar.tsx index 995d9eb..58e6eea 100644 --- a/frontend/src/components/DataGridPaginationBar.tsx +++ b/frontend/src/components/DataGridPaginationBar.tsx @@ -78,7 +78,7 @@ const DataGridPaginationBar: React.FC = ({ popupMatchSelectWidth={false} value={String(pagination.pageSize)} onChange={onPageSizeChange} - options={paginationPageSizeOptions.map((value) => ({ value, label: `${value} /页` }))} + options={paginationPageSizeOptions.map((value) => ({ value, label: `${value}/页` }))} className="data-grid-pagination-size-select" aria-label="每页条数" /> diff --git a/frontend/src/components/DataGridSecondaryActions.tsx b/frontend/src/components/DataGridSecondaryActions.tsx index f921b5a..5b873ec 100644 --- a/frontend/src/components/DataGridSecondaryActions.tsx +++ b/frontend/src/components/DataGridSecondaryActions.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Button, Popover } from 'antd'; import { + AimOutlined, ConsoleSqlOutlined, EditOutlined, FileTextOutlined, @@ -21,6 +22,7 @@ export interface DataGridSecondaryActionsProps { pendingChangeCount: number; resultViewSwitcher: React.ReactNode; columnInfoSettingContent: React.ReactNode; + columnQuickFindContent: React.ReactNode; pageFindContent: React.ReactNode; paginationContent: React.ReactNode; onViewModeChange: (nextMode: GridViewMode) => void; @@ -41,6 +43,7 @@ const DataGridSecondaryActions: React.FC = ({ pendingChangeCount, resultViewSwitcher, columnInfoSettingContent, + columnQuickFindContent, pageFindContent, paginationContent, onViewModeChange, @@ -59,48 +62,61 @@ const DataGridSecondaryActions: React.FC = ({ return (
-
- {viewTabItems.map((item) => ( +
+
+ {viewTabItems.map((item) => ( + + ))} +
+
+ {resultViewSwitcher} + - ))} + + {columnQuickFindContent}
}> + + + {pageFindContent} +
+ live + {mergedDisplayCount} 行 + 未提交 {pendingChangeCount} +
-
- {resultViewSwitcher} - - - -
- live - {mergedDisplayCount} 行 - 未提交 {pendingChangeCount} +
+ {paginationContent}
- {pageFindContent} - ); } @@ -130,6 +146,7 @@ const DataGridSecondaryActions: React.FC = ({ + {columnQuickFindContent} {canViewDdl && (