mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 07:29:40 +08:00
Merge remote-tracking branch 'origin/main' into dev
# Conflicts: # .github/workflows/test-build-all-platforms.yml # frontend/src/components/ConnectionModal.tsx # internal/db/query_value.go # internal/db/query_value_test.go
This commit is contained in:
@@ -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/**
|
||||
|
||||
@@ -1 +1 @@
|
||||
5b8157374dae5f9340e31b2d0bd2c00e
|
||||
d0f9366af59a6367ad3c7e2d4185ead4
|
||||
@@ -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<void>((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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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<DataGridProps> = ({
|
||||
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);
|
||||
@@ -778,6 +781,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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);
|
||||
@@ -2795,6 +2800,13 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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') {
|
||||
@@ -2877,12 +2889,162 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
};
|
||||
}, [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;
|
||||
|
||||
@@ -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<string, ViewerFilterSnapshot>();
|
||||
@@ -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<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||
@@ -204,10 +222,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const latestDbNameRef = useRef<string>('');
|
||||
const latestCountSqlRef = useRef<string>('');
|
||||
const latestCountKeyRef = useRef<string>('');
|
||||
const scrollSnapshotRef = useRef<ViewerScrollSnapshot>({
|
||||
top: initialViewerSnapshot.scrollTop,
|
||||
left: initialViewerSnapshot.scrollLeft,
|
||||
});
|
||||
const initialLoadRef = useRef(false);
|
||||
|
||||
const [pagination, setPagination] = useState<ViewerPaginationState>({
|
||||
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<boolean>(() => getViewerFilterSnapshot(tab.id).showFilter);
|
||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(() => getViewerFilterSnapshot(tab.id).conditions);
|
||||
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
|
||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
|
||||
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||
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 (
|
||||
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -792,6 +842,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
readOnly={forceReadOnly}
|
||||
sortInfoExternal={sortInfo}
|
||||
exportSqlWithFilter={exportSqlWithFilter || undefined}
|
||||
scrollSnapshot={scrollSnapshotRef.current}
|
||||
onScrollSnapshotChange={handleTableScrollSnapshotChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user