From 2b190e564f622d73293f0c70a08b6843078b4cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=A3=E6=9D=A1?= <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:40:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(multi-db,query,ci):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=A4=9A=E6=95=B0=E6=8D=AE=E6=BA=90=E5=85=BC=E5=AE=B9=E6=80=A7?= =?UTF-8?q?=E3=80=81=E6=9F=A5=E8=AF=A2=E4=BD=93=E9=AA=8C=E4=B8=8E=E5=85=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=B5=8B=E8=AF=95=E6=9E=84=E5=BB=BA=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源 refs #168 * fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销 refs #178 * fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误 refs #176 * fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败 refs #177 * chore(ci): 新增手动触发的 macOS 测试构建工作流 * chore(ci): 允许测试工作流在当前分支自动触发 * fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185 * feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174 * fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181 * fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155 * fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154 * fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157 * fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178 * fix(query-execution): 支持带前置注释的读查询结果识别 * chore(ci): 新增全平台测试包手动构建工作流 * fix(ci): 修复全平台测试包 artifact 命名冲突 * fix(data-viewer): 保持切换标签后的表格滚动位置 * fix(datetime-display): 修复零日期显示被错误转换 refs #189 * fix(window-scale): 修复任务栏切换后字体异常放大 refs #193 * fix(data-grid-scroll): 修复数据区触摸板横向滚动失效 refs #175 * fix(db-query-value): 清理 query_value 合并冲突并保持零日期处理 * chore(ci): 删除旧的 macOS 单平台测试工作流 --- .../workflows/test-build-all-platforms.yml | 2 +- .github/workflows/test-macos-build.yml | 91 ---------- frontend/src/App.tsx | 47 ++++- frontend/src/components/DataGrid.tsx | 164 +++++++++++++++++- frontend/src/components/DataViewer.tsx | 74 ++++++-- internal/db/query_value.go | 36 +++- internal/db/query_value_test.go | 30 ++++ 7 files changed, 334 insertions(+), 110 deletions(-) delete mode 100644 .github/workflows/test-macos-build.yml diff --git a/.github/workflows/test-build-all-platforms.yml b/.github/workflows/test-build-all-platforms.yml index 3fccb8d..29ffe9d 100644 --- a/.github/workflows/test-build-all-platforms.yml +++ b/.github/workflows/test-build-all-platforms.yml @@ -334,7 +334,7 @@ jobs: - name: Upload Artifact uses: actions/upload-artifact@v4 with: - name: test-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${{ github.run_number }} + name: test-build-${{ matrix.build_name }}-run${{ github.run_number }} path: | artifacts/* drivers/** diff --git a/.github/workflows/test-macos-build.yml b/.github/workflows/test-macos-build.yml deleted file mode 100644 index 1dd01af..0000000 --- a/.github/workflows/test-macos-build.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Test Build macOS (Manual) - -on: - workflow_dispatch: - inputs: - build_label: - description: "测试包标识(仅用于文件名)" - required: false - default: "test" - push: - branches: - - feature/kingbase_opt - paths: - - ".github/workflows/test-macos-build.yml" - -permissions: - contents: read - -jobs: - build-macos: - name: Build macOS ${{ matrix.arch }} - runs-on: macos-latest - strategy: - fail-fast: false - matrix: - include: - - platform: darwin/amd64 - arch: amd64 - - platform: darwin/arm64 - arch: arm64 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: "1.24.3" - check-latest: true - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - - - name: Build App - run: | - set -euo pipefail - OUTPUT_NAME="gonavi-test-${{ matrix.arch }}" - BUILD_LABEL="${{ inputs.build_label }}" - if [ -z "$BUILD_LABEL" ]; then - BUILD_LABEL="test" - fi - APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}" - wails build \ - -platform "${{ matrix.platform }}" \ - -clean \ - -o "$OUTPUT_NAME" \ - -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" - - - name: Package Zip - run: | - set -euo pipefail - APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app" - if [ ! -d "$APP_PATH" ]; then - APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true) - fi - if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then - echo "未找到 .app 产物" - ls -la build/bin || true - exit 1 - fi - LABEL="${{ inputs.build_label }}" - if [ -z "$LABEL" ]; then - LABEL="test" - fi - ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip" - mkdir -p artifacts - ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME" - shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256" - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }} - path: artifacts/* - if-no-files-found: error diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be49c41..f1cd093 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -283,6 +283,7 @@ function App() { let inFlight = false; let lastRatio = Number(window.devicePixelRatio) || 1; let lastFixAt = 0; + let activationTimer: number | null = null; const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); @@ -334,17 +335,55 @@ function App() { void fixWindowScaleIfNeeded(); }; + const scheduleActivationFix = () => { + if (cancelled) return; + if (activationTimer !== null) { + window.clearTimeout(activationTimer); + } + activationTimer = window.setTimeout(() => { + activationTimer = null; + if (cancelled) return; + void fixWindowScaleIfNeeded(); + }, 80); + }; + + const handleWindowFocus = () => { + if (cancelled) return; + checkDevicePixelRatio(); + scheduleActivationFix(); + }; + + const handleVisibilityChange = () => { + if (cancelled) return; + if (document.visibilityState !== 'visible') { + return; + } + checkDevicePixelRatio(); + scheduleActivationFix(); + }; + + const handlePageShow = () => { + if (cancelled) return; + checkDevicePixelRatio(); + scheduleActivationFix(); + }; + const pollTimer = window.setInterval(checkDevicePixelRatio, 900); window.addEventListener('resize', checkDevicePixelRatio); - window.addEventListener('focus', checkDevicePixelRatio); - document.addEventListener('visibilitychange', checkDevicePixelRatio); + window.addEventListener('focus', handleWindowFocus); + window.addEventListener('pageshow', handlePageShow); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { cancelled = true; + if (activationTimer !== null) { + window.clearTimeout(activationTimer); + } window.clearInterval(pollTimer); window.removeEventListener('resize', checkDevicePixelRatio); - window.removeEventListener('focus', checkDevicePixelRatio); - document.removeEventListener('visibilitychange', checkDevicePixelRatio); + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener('pageshow', handlePageShow); + document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 10c8b87..088bc14 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') { @@ -2849,12 +2861,162 @@ const DataGrid: React.FC = ({ }; }, [horizontalScrollVisible]); + // 非虚拟模式:支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。 + // 某些平台在表格内容未铺满一页时,不会把水平手势正确路由到表格 body,导致只能在表头/底部滚动条区域滚动。 + useEffect(() => { + if (viewMode !== 'table' || enableVirtual) return; + const container = tableContainerRef.current; + if (!(container instanceof HTMLElement)) return; + + const resolveHorizontalDelta = (event: WheelEvent) => { + if (Math.abs(event.deltaX) > 0.5) { + return event.deltaX; + } + if (event.shiftKey && Math.abs(event.deltaY) > 0.5) { + return event.deltaY; + } + return 0; + }; + + const isTableDataAreaTarget = (target: EventTarget | null) => { + const element = target instanceof HTMLElement ? target : null; + if (!element) return false; + if (element.closest('.data-grid-external-hscroll')) return false; + return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody'); + }; + + const handleContainerHorizontalWheel = (event: WheelEvent) => { + const horizontalDelta = resolveHorizontalDelta(event); + if (!Number.isFinite(horizontalDelta) || Math.abs(horizontalDelta) < 0.5) return; + if (!isTableDataAreaTarget(event.target)) return; + + const targets = pickHorizontalScrollTargets(container); + const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0]; + if (!(activeTarget instanceof HTMLElement)) return; + + const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth); + if (maxScrollLeft <= 0) return; + + const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta)); + if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) return; + + event.preventDefault(); + event.stopPropagation(); + + horizontalSyncSourceRef.current = 'table'; + activeTarget.scrollLeft = nextScrollLeft; + lastTableScrollLeftRef.current = nextScrollLeft; + + const externalScroll = externalHScrollRef.current; + if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) { + externalScroll.scrollLeft = nextScrollLeft; + lastExternalScrollLeftRef.current = nextScrollLeft; + } + horizontalSyncSourceRef.current = ''; + }; + + container.addEventListener('wheel', handleContainerHorizontalWheel, { passive: false, capture: true }); + return () => { + container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions); + }; + }, [viewMode, enableVirtual, pickHorizontalScrollTargets]); + useEffect(() => { if (viewMode !== 'table') return; const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current)); 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} />
); diff --git a/internal/db/query_value.go b/internal/db/query_value.go index fa28bd7..24388e5 100644 --- a/internal/db/query_value.go +++ b/internal/db/query_value.go @@ -1,4 +1,4 @@ -package db +package db import ( "encoding/hex" @@ -31,12 +31,44 @@ func normalizeQueryValue(v interface{}) interface{} { } func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) interface{} { + if tm, ok := v.(time.Time); ok { + return normalizeTemporalValueForDisplay(tm, databaseTypeName) + } if b, ok := v.([]byte); ok { return bytesToDisplayValue(b, databaseTypeName) } return normalizeCompositeQueryValue(v) } +func normalizeTemporalValueForDisplay(value time.Time, databaseTypeName string) interface{} { + if value.IsZero() { + if zeroValue, ok := zeroTemporalDisplayValue(databaseTypeName); ok { + return zeroValue + } + } + return value.Format(time.RFC3339Nano) +} + +func zeroTemporalDisplayValue(databaseTypeName string) (string, bool) { + typeName := strings.ToUpper(strings.TrimSpace(databaseTypeName)) + if typeName == "" { + return "0000-00-00 00:00:00", true + } + + switch { + case strings.Contains(typeName, "TIMESTAMP") || strings.Contains(typeName, "DATETIME"): + return "0000-00-00 00:00:00", true + case typeName == "DATE" || typeName == "NEWDATE": + return "0000-00-00", true + case strings.Contains(typeName, "TIME"): + return "00:00:00", true + case strings.Contains(typeName, "YEAR"): + return "0000", true + default: + return "", false + } +} + func normalizeCompositeQueryValue(v interface{}) interface{} { if v == nil { return nil @@ -91,7 +123,7 @@ func normalizeCompositeQueryValue(v interface{}) interface{} { // 部分驱动(如 Kingbase)会返回复杂结构体值,直接透传会导致前端渲染和比较开销激增。 // 统一降级为可读字符串,避免对象深层序列化触发 UI 卡顿。 if tm, ok := v.(time.Time); ok { - return tm.Format(time.RFC3339Nano) + return normalizeTemporalValueForDisplay(tm, "") } if stringer, ok := v.(fmt.Stringer); ok { return stringer.String() diff --git a/internal/db/query_value_test.go b/internal/db/query_value_test.go index 285344e..a66faf4 100644 --- a/internal/db/query_value_test.go +++ b/internal/db/query_value_test.go @@ -195,3 +195,33 @@ func TestNormalizeQueryValueWithDBType_TimeStructToRFC3339(t *testing.T) { t.Fatalf("time.Time 规整值异常,实际=%s", text) } } + +func TestNormalizeQueryValueWithDBType_ZeroTemporalValues(t *testing.T) { + zero := time.Time{} + cases := []struct { + name string + dbType string + wantText string + }{ + {name: "date", dbType: "DATE", wantText: "0000-00-00"}, + {name: "newdate", dbType: "NEWDATE", wantText: "0000-00-00"}, + {name: "datetime", dbType: "DATETIME", wantText: "0000-00-00 00:00:00"}, + {name: "timestamp", dbType: "TIMESTAMP", wantText: "0000-00-00 00:00:00"}, + {name: "time", dbType: "TIME", wantText: "00:00:00"}, + {name: "year", dbType: "YEAR", wantText: "0000"}, + {name: "unknown", dbType: "", wantText: "0000-00-00 00:00:00"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeQueryValueWithDBType(zero, tc.dbType) + text, ok := got.(string) + if !ok { + t.Fatalf("期望 string,实际=%v(%T)", got, got) + } + if text != tc.wantText { + t.Fatalf("dbType=%s 期望=%s,实际=%s", tc.dbType, tc.wantText, text) + } + }) + } +}