feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147
This commit is contained in:
Syngnat
2026-03-03 13:49:31 +08:00
parent b904c0b107
commit e76e174bfe
5 changed files with 844 additions and 196 deletions

View File

@@ -57,6 +57,29 @@ body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* Scrollbar styling for light mode (transparent-friendly) */
body[data-theme='light'] ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
body[data-theme='light'] ::-webkit-scrollbar-track {
background: transparent;
}
body[data-theme='light'] ::-webkit-scrollbar-corner {
background: transparent;
}
body[data-theme='light'] ::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.30);
border: 2px solid transparent;
background-clip: content-box;
}
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
body {
transition: color 0.3s;
@@ -102,11 +125,13 @@ body[data-theme='dark'] .ant-switch.ant-switch-checked {
background: #d8a93b !important;
}
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td {
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td,
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell {
background: rgba(246, 196, 83, 0.18) !important;
}
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td {
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td,
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell {
background: rgba(246, 196, 83, 0.26) !important;
}

View File

@@ -16,6 +16,12 @@ import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/A
import './App.css';
const { Sider, Content } = Layout;
const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 20;
const DEFAULT_UI_SCALE = 1.0;
const DEFAULT_FONT_SIZE = 14;
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -26,11 +32,28 @@ function App() {
const setTheme = useStore(state => state.setTheme);
const appearance = useStore(state => state.appearance);
const setAppearance = useStore(state => state.setAppearance);
const uiScale = useStore(state => state.uiScale);
const setUiScale = useStore(state => state.setUiScale);
const fontSize = useStore(state => state.fontSize);
const setFontSize = useStore(state => state.setFontSize);
const startupFullscreen = useStore(state => state.startupFullscreen);
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
const globalProxy = useStore(state => state.globalProxy);
const setGlobalProxy = useStore(state => state.setGlobalProxy);
const darkMode = themeMode === 'dark';
const effectiveUiScale = Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Number(uiScale) || DEFAULT_UI_SCALE));
const effectiveFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(Number(fontSize) || DEFAULT_FONT_SIZE)));
const tokenFontSize = Math.round(effectiveFontSize * effectiveUiScale);
const tokenFontSizeSM = Math.max(10, Math.round(tokenFontSize * 0.86));
const tokenFontSizeLG = Math.max(tokenFontSize + 1, Math.round(tokenFontSize * 1.14));
const tokenControlHeight = Math.max(24, Math.round(32 * effectiveUiScale));
const tokenControlHeightSM = Math.max(20, Math.round(24 * effectiveUiScale));
const tokenControlHeightLG = Math.max(30, Math.round(40 * effectiveUiScale));
const appComponentSize: 'small' | 'middle' | 'large' = effectiveUiScale <= 0.92 ? 'small' : (effectiveUiScale >= 1.12 ? 'large' : 'middle');
const titleBarHeight = Math.max(28, Math.round(32 * effectiveUiScale));
const toolbarHeight = Math.max(32, Math.round(36 * effectiveUiScale));
const titleBarButtonWidth = Math.max(40, Math.round(46 * effectiveUiScale));
const floatingLogButtonHeight = Math.max(30, Math.round(34 * effectiveUiScale));
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
const blurFilter = blurToFilter(effectiveBlur);
@@ -834,7 +857,9 @@ function App() {
document.body.style.backgroundColor = 'transparent';
document.body.style.color = darkMode ? '#ffffff' : '#000000';
document.body.setAttribute('data-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
document.body.style.fontSize = `${effectiveFontSize}px`;
document.documentElement.style.setProperty('--gonavi-font-size', `${effectiveFontSize}px`);
}, [darkMode, effectiveFontSize]);
useEffect(() => {
isAboutOpenRef.current = isAboutOpen;
@@ -916,9 +941,16 @@ function App() {
return (
<ConfigProvider
locale={zhCN}
componentSize={appComponentSize}
theme={{
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
fontSize: tokenFontSize,
fontSizeSM: tokenFontSizeSM,
fontSizeLG: tokenFontSizeLG,
controlHeight: tokenControlHeight,
controlHeightSM: tokenControlHeightSM,
controlHeightLG: tokenControlHeightLG,
colorBgLayout: 'transparent',
colorBgContainer: darkMode
? `rgba(29, 29, 29, ${effectiveOpacity})`
@@ -982,7 +1014,7 @@ function App() {
<div
onDoubleClick={handleTitleBarDoubleClick}
style={{
height: 32,
height: titleBarHeight,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
@@ -992,10 +1024,11 @@ function App() {
userSelect: 'none',
WebkitAppRegion: 'drag', // Wails drag region
'--wails-draggable': 'drag',
paddingLeft: 16
paddingLeft: Math.max(12, Math.round(16 * effectiveUiScale)),
fontSize: tokenFontSize
} as any}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: Math.max(6, Math.round(8 * effectiveUiScale)), fontWeight: 600 }}>
{/* Logo can be added here if available */}
GoNavi
</div>
@@ -1007,13 +1040,13 @@ function App() {
<Button
type="text"
icon={<MinusOutlined />}
style={{ height: '100%', borderRadius: 0, width: 46 }}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={() => (window as any).runtime.WindowMinimise()}
/>
<Button
type="text"
icon={<BorderOutlined />}
style={{ height: '100%', borderRadius: 0, width: 46 }}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={() => (window as any).runtime.WindowToggleMaximise()}
/>
<Button
@@ -1021,7 +1054,7 @@ function App() {
icon={<CloseOutlined />}
danger
className="titlebar-close-btn"
style={{ height: '100%', borderRadius: 0, width: 46 }}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={() => (window as any).runtime.Quit()}
/>
</div>
@@ -1029,13 +1062,13 @@ function App() {
<div
style={{
height: 36,
height: toolbarHeight,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 4,
padding: '0 8px',
gap: Math.max(2, Math.round(4 * effectiveUiScale)),
padding: `0 ${Math.max(6, Math.round(8 * effectiveUiScale))}px`,
borderBottom: 'none',
background: bgMain,
}}
@@ -1088,13 +1121,13 @@ function App() {
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
style={isLogPanelOpen ? {
width: '100%',
height: 34,
height: floatingLogButtonHeight,
borderRadius: 999,
boxShadow: floatingLogButtonShadow,
pointerEvents: 'auto'
} : {
width: '100%',
height: 34,
height: floatingLogButtonHeight,
borderRadius: 999,
border: `1px solid ${floatingLogButtonBorderColor}`,
color: floatingLogButtonTextColor,
@@ -1216,6 +1249,37 @@ function App() {
width={460}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (UI Scale)</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={MIN_UI_SCALE}
max={MAX_UI_SCALE}
step={0.05}
value={effectiveUiScale}
onChange={(v) => setUiScale(Number(v))}
style={{ flex: 1 }}
/>
<span style={{ width: 56 }}>{Math.round(effectiveUiScale * 100)}%</span>
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
* 85%-95%
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (Font Size)</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step={1}
value={effectiveFontSize}
onChange={(v) => setFontSize(Number(v))}
style={{ flex: 1 }}
/>
<span style={{ width: 56 }}>{effectiveFontSize}px</span>
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (Opacity)</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
@@ -1264,6 +1328,17 @@ function App() {
*
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
onClick={() => {
setUiScale(DEFAULT_UI_SCALE);
setFontSize(DEFAULT_FONT_SIZE);
setAppearance({ opacity: 1.0, blur: 0 });
}}
>
</Button>
</div>
</div>
</Modal>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useRef, useState } from 'react';
import { Tabs, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import type { MenuProps, TabsProps } from 'antd';
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
@@ -35,44 +35,18 @@ const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined):
};
type SortableTabLabelProps = {
tabId: string;
displayTitle: string;
menuItems: MenuProps['items'];
draggingTabId: string | null;
onSelect: (tabId: string) => void;
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
tabId,
displayTitle,
menuItems,
draggingTabId,
onSelect,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
opacity: isDragging ? 0.88 : 1,
cursor: isDragging ? 'grabbing' : 'grab',
display: 'inline-flex',
alignItems: 'center',
maxWidth: '100%',
touchAction: 'none',
};
const isDragBlocked = !!draggingTabId && draggingTabId !== tabId;
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span
ref={setNodeRef}
style={style}
className={`tab-dnd-label ${isDragging ? 'is-dragging' : ''}`}
{...attributes}
{...listeners}
onClick={() => {
if (!isDragBlocked) onSelect(tabId);
}}
className="tab-dnd-label"
onContextMenu={(e) => e.preventDefault()}
title="拖拽调整标签顺序"
>
@@ -82,9 +56,36 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
);
};
type DraggableTabNodeProps = {
node: React.ReactElement;
};
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
const tabId = String(node.key || '').trim();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
const style: React.CSSProperties = {
...(node.props.style || {}),
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
opacity: isDragging ? 0.88 : 1,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
zIndex: isDragging ? 2 : node.props.style?.zIndex,
};
return React.cloneElement(node, {
ref: setNodeRef,
style,
...attributes,
...listeners,
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
});
};
const TabManager: React.FC = () => {
const tabs = useStore(state => state.tabs);
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const closeTab = useStore(state => state.closeTab);
@@ -93,6 +94,7 @@ const TabManager: React.FC = () => {
const closeTabsToRight = useStore(state => state.closeTabsToRight);
const closeAllTabs = useStore(state => state.closeAllTabs);
const moveTab = useStore(state => state.moveTab);
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const suppressClickUntilRef = useRef<number>(0);
const sensors = useSensors(
@@ -111,11 +113,6 @@ const TabManager: React.FC = () => {
}
};
const handleTabSelect = (tabId: string) => {
if (Date.now() < suppressClickUntilRef.current) return;
setActiveTab(tabId);
};
const handleDragStart = (event: DragStartEvent) => {
const sourceId = String(event.active.id || '').trim();
setDraggingTabId(sourceId || null);
@@ -138,11 +135,21 @@ const TabManager: React.FC = () => {
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
<DefaultTabBar {...tabBarProps}>
{(node) => <DraggableTabNode key={node.key} node={node} />}
</DefaultTabBar>
);
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command';
const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive;
let content;
if (tab.type === 'query') {
if (!shouldRenderContent) {
content = null;
} else if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
} else if (tab.type === 'table') {
content = <DataViewer tab={tab} />;
@@ -189,17 +196,14 @@ const TabManager: React.FC = () => {
return {
label: (
<SortableTabLabel
tabId={tab.id}
displayTitle={displayTitle}
menuItems={menuItems}
draggingTabId={draggingTabId}
onSelect={handleTabSelect}
/>
),
key: tab.id,
children: content,
};
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, draggingTabId]);
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>
@@ -248,7 +252,7 @@ const TabManager: React.FC = () => {
display: none !important;
}
.main-tabs .ant-tabs-nav::before {
border-bottom: none !important;
border-bottom: 1px solid ${tabsNavBorderColor} !important;
}
.main-tabs .ant-tabs-tab {
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease;
@@ -256,8 +260,12 @@ const TabManager: React.FC = () => {
.main-tabs .tab-dnd-label {
user-select: none;
-webkit-user-select: none;
display: inline-flex;
align-items: center;
max-width: 100%;
}
.main-tabs .tab-dnd-label.is-dragging {
.main-tabs .tab-dnd-node.is-dragging,
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
cursor: grabbing !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible {
@@ -289,11 +297,15 @@ const TabManager: React.FC = () => {
<Tabs
className="main-tabs"
type="editable-card"
onChange={onChange}
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);
}}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
hideAdd
renderTabBar={renderTabBar}
/>
</SortableContext>
</DndContext>

View File

@@ -3,6 +3,12 @@ import { persist } from 'zustand/middleware';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery } from './types';
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
const DEFAULT_UI_SCALE = 1.0;
const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
const DEFAULT_FONT_SIZE = 14;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 20;
const DEFAULT_STARTUP_FULLSCREEN = false;
const LEGACY_DEFAULT_OPACITY = 0.95;
const OPACITY_EPSILON = 1e-6;
@@ -107,6 +113,13 @@ const normalizeIntegerInRange = (value: unknown, fallbackValue: number, min: num
return normalized;
};
const normalizeFloatInRange = (value: unknown, fallbackValue: number, min: number, max: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallbackValue;
if (parsed < min || parsed > max) return fallbackValue;
return parsed;
};
const isValidHostEntry = (entry: string): boolean => {
if (!entry) return false;
if (entry.length > MAX_HOST_ENTRY_LENGTH) return false;
@@ -318,6 +331,8 @@ interface AppState {
savedQueries: SavedQuery[];
theme: 'light' | 'dark';
appearance: { opacity: number; blur: number };
uiScale: number;
fontSize: number;
startupFullscreen: boolean;
globalProxy: GlobalProxyConfig;
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
@@ -347,6 +362,8 @@ interface AppState {
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
setUiScale: (scale: number) => void;
setFontSize: (size: number) => void;
setStartupFullscreen: (enabled: boolean) => void;
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
@@ -441,6 +458,14 @@ const sanitizeStartupFullscreen = (value: unknown): boolean => {
return value === true;
};
const sanitizeUiScale = (value: unknown): number => {
return normalizeFloatInRange(value, DEFAULT_UI_SCALE, MIN_UI_SCALE, MAX_UI_SCALE);
};
const sanitizeFontSize = (value: unknown): number => {
return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE);
};
const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase();
@@ -477,6 +502,8 @@ export const useStore = create<AppState>()(
savedQueries: [],
theme: 'light',
appearance: { ...DEFAULT_APPEARANCE },
uiScale: DEFAULT_UI_SCALE,
fontSize: DEFAULT_FONT_SIZE,
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
globalProxy: { ...DEFAULT_GLOBAL_PROXY },
sqlFormatOptions: { keywordCase: 'upper' },
@@ -607,6 +634,8 @@ export const useStore = create<AppState>()(
setTheme: (theme) => set({ theme }),
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
@@ -646,6 +675,8 @@ export const useStore = create<AppState>()(
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
nextState.theme = sanitizeTheme(state.theme);
nextState.appearance = sanitizeAppearance(state.appearance, version);
nextState.uiScale = sanitizeUiScale(state.uiScale);
nextState.fontSize = sanitizeFontSize(state.fontSize);
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
@@ -663,6 +694,8 @@ export const useStore = create<AppState>()(
savedQueries: sanitizeSavedQueries(state.savedQueries),
theme: sanitizeTheme(state.theme),
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
uiScale: sanitizeUiScale(state.uiScale),
fontSize: sanitizeFontSize(state.fontSize),
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
globalProxy: sanitizeGlobalProxy(state.globalProxy),
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
@@ -676,6 +709,8 @@ export const useStore = create<AppState>()(
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
uiScale: state.uiScale,
fontSize: state.fontSize,
startupFullscreen: state.startupFullscreen,
globalProxy: state.globalProxy,
sqlFormatOptions: state.sqlFormatOptions,