From 4c322818bcc6d9b2ca17250bdf26ec4f4f56269e Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:22:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(data-viewer):=20=E4=BF=9D=E6=8C=81=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=A0=87=E7=AD=BE=E5=90=8E=E7=9A=84=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/DataGrid.tsx | 104 ++++++++++++++++++++++++- frontend/src/components/DataViewer.tsx | 74 +++++++++++++++--- 2 files changed, 166 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 10c8b87..60d8e5c 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -610,6 +610,8 @@ interface DataGridProps { exportSqlWithFilter?: string; onApplyFilter?: (conditions: GridFilterCondition[]) => void; appliedFilterConditions?: FilterCondition[]; + scrollSnapshot?: { top: number; left: number }; + onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void; } type GridFilterCondition = FilterCondition & { @@ -629,7 +631,8 @@ type ColumnMeta = { const DataGrid: React.FC = ({ data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false, - onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions + onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, + scrollSnapshot, onScrollSnapshotChange }) => { const connections = useStore(state => state.connections); const addSqlLog = useStore(state => state.addSqlLog); @@ -750,6 +753,8 @@ const DataGrid: React.FC = ({ const lastTableScrollLeftRef = useRef(0); const lastExternalScrollLeftRef = useRef(0); const pendingScrollToBottomRef = useRef(false); + const lastReportedScrollRef = useRef<{ top: number; left: number }>({ top: 0, left: 0 }); + const didRestoreScrollRef = useRef(false); // 批量编辑模式状态 const [cellEditMode, setCellEditMode] = useState(false); @@ -2767,6 +2772,13 @@ const DataGrid: React.FC = ({ return active ? [active] : []; }, []); + const pickVerticalScrollTarget = useCallback((tableContainer: HTMLElement): HTMLElement | null => { + const virtualHolder = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; + const rcVirtualHolder = tableContainer.querySelector('.rc-virtual-list-holder') as HTMLElement | null; + const body = tableContainer.querySelector('.ant-table-body') as HTMLElement | null; + return virtualHolder || rcVirtualHolder || body; + }, []); + const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => { const externalScroll = externalHScrollRef.current; if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') { @@ -2855,6 +2867,96 @@ const DataGrid: React.FC = ({ return () => cancelAnimationFrame(rafId); }, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]); + useEffect(() => { + if (viewMode !== 'table' || !onScrollSnapshotChange) return; + const tableContainer = tableContainerRef.current; + if (!(tableContainer instanceof HTMLElement)) return; + + let rafId: number | null = null; + let boundVerticalTarget: HTMLElement | null = null; + let boundHorizontalTargets: HTMLElement[] = []; + const externalScroll = externalHScrollRef.current; + const hasStoredScroll = !!scrollSnapshot && (Math.abs(scrollSnapshot.top) > 0.5 || Math.abs(scrollSnapshot.left) > 0.5); + + const emitSnapshot = () => { + if (!didRestoreScrollRef.current && hasStoredScroll) { + return; + } + const verticalTarget = boundVerticalTarget || pickVerticalScrollTarget(tableContainer); + const horizontalTargets = boundHorizontalTargets.length > 0 ? boundHorizontalTargets : pickHorizontalScrollTargets(tableContainer); + const top = verticalTarget ? verticalTarget.scrollTop : 0; + const left = horizontalTargets[0]?.scrollLeft ?? externalScroll?.scrollLeft ?? 0; + if (Math.abs(lastReportedScrollRef.current.top - top) < 1 && Math.abs(lastReportedScrollRef.current.left - left) < 1) { + return; + } + lastReportedScrollRef.current = { top, left }; + onScrollSnapshotChange({ top, left }); + }; + + const bindTargets = () => { + if (boundVerticalTarget) { + boundVerticalTarget.removeEventListener('scroll', emitSnapshot); + } + boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot)); + externalScroll?.removeEventListener('scroll', emitSnapshot); + + boundVerticalTarget = pickVerticalScrollTarget(tableContainer); + boundHorizontalTargets = pickHorizontalScrollTargets(tableContainer); + + boundVerticalTarget?.addEventListener('scroll', emitSnapshot, { passive: true }); + boundHorizontalTargets.forEach(target => target.addEventListener('scroll', emitSnapshot, { passive: true })); + externalScroll?.addEventListener('scroll', emitSnapshot, { passive: true }); + emitSnapshot(); + }; + + rafId = requestAnimationFrame(bindTargets); + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + if (boundVerticalTarget) { + boundVerticalTarget.removeEventListener('scroll', emitSnapshot); + } + boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot)); + externalScroll?.removeEventListener('scroll', emitSnapshot); + }; + }, [viewMode, mergedDisplayData.length, onScrollSnapshotChange, pickHorizontalScrollTargets, pickVerticalScrollTarget, scrollSnapshot]); + + useEffect(() => { + if (viewMode !== 'table') return; + if (!scrollSnapshot) return; + if (didRestoreScrollRef.current) return; + const tableContainer = tableContainerRef.current; + if (!(tableContainer instanceof HTMLElement)) return; + if (mergedDisplayData.length === 0) return; + + let rafId = requestAnimationFrame(() => { + const verticalTarget = pickVerticalScrollTarget(tableContainer); + const horizontalTargets = pickHorizontalScrollTargets(tableContainer); + const nextTop = Math.max(0, scrollSnapshot.top); + const nextLeft = Math.max(0, scrollSnapshot.left); + if (verticalTarget && Math.abs(verticalTarget.scrollTop - scrollSnapshot.top) > 1) { + verticalTarget.scrollTop = nextTop; + } + if (Math.abs(nextLeft) > 0.5) { + horizontalTargets.forEach(target => { + if (Math.abs(target.scrollLeft - nextLeft) > 1) { + target.scrollLeft = nextLeft; + } + }); + const externalScroll = externalHScrollRef.current; + if (externalScroll && Math.abs(externalScroll.scrollLeft - nextLeft) > 1) { + externalScroll.scrollLeft = nextLeft; + } + lastTableScrollLeftRef.current = nextLeft; + lastExternalScrollLeftRef.current = nextLeft; + } + lastReportedScrollRef.current = { top: nextTop, left: nextLeft }; + didRestoreScrollRef.current = true; + onScrollSnapshotChange?.({ top: nextTop, left: nextLeft }); + }); + + return () => cancelAnimationFrame(rafId); + }, [viewMode, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]); + // 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动 useEffect(() => { if (viewMode !== 'table' || !enableVirtual) return; diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index adc2240..597523e 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -155,6 +155,16 @@ const reverseOrderBySQL = (orderBySQL: string): string => { type ViewerFilterSnapshot = { showFilter: boolean; conditions: FilterCondition[]; + currentPage: number; + pageSize: number; + sortInfo: { columnKey: string, order: string } | null; + scrollTop: number; + scrollLeft: number; +}; + +type ViewerScrollSnapshot = { + top: number; + left: number; }; const viewerFilterSnapshotsByTab = new Map(); @@ -175,15 +185,23 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => { const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim()); if (!cached) { - return { showFilter: false, conditions: [] }; + return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 }; } return { showFilter: cached.showFilter === true, conditions: normalizeViewerFilterConditions(cached.conditions), + currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1, + pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100, + sortInfo: cached.sortInfo && cached.sortInfo.columnKey && (cached.sortInfo.order === 'ascend' || cached.sortInfo.order === 'descend') + ? { columnKey: String(cached.sortInfo.columnKey), order: cached.sortInfo.order } + : null, + scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0, + scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0, }; }; const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { + const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]); const [data, setData] = useState([]); const [columnNames, setColumnNames] = useState([]); const [pkColumns, setPkColumns] = useState([]); @@ -204,10 +222,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const latestDbNameRef = useRef(''); const latestCountSqlRef = useRef(''); const latestCountKeyRef = useRef(''); + const scrollSnapshotRef = useRef({ + top: initialViewerSnapshot.scrollTop, + left: initialViewerSnapshot.scrollLeft, + }); + const initialLoadRef = useRef(false); const [pagination, setPagination] = useState({ - current: 1, - pageSize: 100, + current: initialViewerSnapshot.currentPage, + pageSize: initialViewerSnapshot.pageSize, total: 0, totalKnown: false, totalApprox: false, @@ -215,10 +238,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { totalCountCancelled: false, }); - const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null); + const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(initialViewerSnapshot.sortInfo); - const [showFilter, setShowFilter] = useState(() => getViewerFilterSnapshot(tab.id).showFilter); - const [filterConditions, setFilterConditions] = useState(() => getViewerFilterSnapshot(tab.id).conditions); + const [showFilter, setShowFilter] = useState(initialViewerSnapshot.showFilter); + const [filterConditions, setFilterConditions] = useState(initialViewerSnapshot.conditions); const duckdbSafeSelectCacheRef = useRef>({}); const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; const currentConnCaps = getDataSourceCapabilities(currentConnConfig); @@ -229,16 +252,25 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const snapshot = getViewerFilterSnapshot(tab.id); setShowFilter(snapshot.showFilter); setFilterConditions(snapshot.conditions); + setSortInfo(snapshot.sortInfo); + scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft }; + initialLoadRef.current = false; }, [tab.id]); useEffect(() => { viewerFilterSnapshotsByTab.set(tab.id, { showFilter, conditions: normalizeViewerFilterConditions(filterConditions), + currentPage: pagination.current, + pageSize: pagination.pageSize, + sortInfo, + scrollTop: scrollSnapshotRef.current.top, + scrollLeft: scrollSnapshotRef.current.left, }); - }, [tab.id, showFilter, filterConditions]); + }, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]); useEffect(() => { + const snapshot = getViewerFilterSnapshot(tab.id); setPkColumns([]); pkKeyRef.current = ''; countKeyRef.current = ''; @@ -250,16 +282,29 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { latestDbNameRef.current = ''; latestCountSqlRef.current = ''; latestCountKeyRef.current = ''; + scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft }; + initialLoadRef.current = false; setPagination(prev => ({ ...prev, - current: 1, + current: snapshot.currentPage, + pageSize: snapshot.pageSize, total: 0, totalKnown: false, totalApprox: false, totalCountLoading: false, totalCountCancelled: false, })); - }, [tab.connectionId, tab.dbName, tab.tableName]); + }, [tab.id, tab.connectionId, tab.dbName, tab.tableName]); + + const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => { + scrollSnapshotRef.current = snapshot; + const cached = getViewerFilterSnapshot(tab.id); + viewerFilterSnapshotsByTab.set(tab.id, { + ...cached, + scrollTop: snapshot.top, + scrollLeft: snapshot.left, + }); + }, [tab.id]); const handleDuckDBManualCount = useCallback(async () => { if (latestDbTypeRef.current !== 'duckdb') { @@ -765,8 +810,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]); useEffect(() => { - fetchData(1, pagination.pageSize); - }, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter + if (!initialLoadRef.current) { + initialLoadRef.current = true; + fetchData(pagination.current, pagination.pageSize); + return; + } + fetchData(1, pagination.pageSize); + }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter return (
@@ -792,6 +842,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { readOnly={forceReadOnly} sortInfoExternal={sortInfo} exportSqlWithFilter={exportSqlWithFilter || undefined} + scrollSnapshot={scrollSnapshotRef.current} + onScrollSnapshotChange={handleTableScrollSnapshotChange} />
);