Files
MyGoNavi/frontend/src/App.tsx
Syngnat cb18bc3067 feat(driver-proxy): 新增ClickHouse数据源并提供全局代理独立入口
- 新增 ClickHouse 可选驱动实现与 optional-driver-agent provider,补齐驱动注册与清单配置
- 补齐 ClickHouse 连接与 SQL 适配:连接默认端口/用户、LIMIT、标识符引用、只读编辑限制
- 新增全局代理后端能力与前端持久化配置,更新检查和驱动网络请求统一走代理客户端
2026-02-27 16:39:13 +08:00

1271 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal';
import LogPanel from './components/LogPanel';
import { useStore } from './store';
import { SavedConnection } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform } from './utils/appearance';
import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/App';
import './App.css';
const { Sider, Content } = Layout;
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
const [isDriverModalOpen, setIsDriverModalOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
const themeMode = useStore(state => state.theme);
const setTheme = useStore(state => state.setTheme);
const appearance = useStore(state => state.appearance);
const setAppearance = useStore(state => state.setAppearance);
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 effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
const blurFilter = blurToFilter(effectiveBlur);
const windowCornerRadius = 14;
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
const globalProxyInvalidHintShownRef = React.useRef(false);
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
// 避免 GPU 持续计算窗口背后的模糊合成
useEffect(() => {
SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => {});
}, [appearance.opacity, appearance.blur]);
useEffect(() => {
let cancelled = false;
Environment()
.then((env) => {
if (cancelled) return;
setIsLinuxRuntime((env?.platform || '').toLowerCase() === 'linux');
})
.catch(() => {
if (cancelled) return;
const platform = typeof navigator !== 'undefined' ? navigator.platform : '';
setIsLinuxRuntime(/linux/i.test(platform));
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (isStoreHydrated) {
return;
}
const unsubscribe = useStore.persist.onFinishHydration(() => {
setIsStoreHydrated(true);
});
return () => {
unsubscribe();
};
}, [isStoreHydrated]);
useEffect(() => {
if (!isStoreHydrated) {
return;
}
const host = String(globalProxy.host || '').trim();
const port = Number(globalProxy.port);
const portValid = Number.isFinite(port) && port > 0 && port <= 65535;
const invalidWhenEnabled = globalProxy.enabled && (!host || !portValid);
if (invalidWhenEnabled) {
if (!globalProxyInvalidHintShownRef.current) {
message.warning({
content: '全局代理已开启,但地址或端口无效,当前按未启用处理',
key: 'global-proxy-invalid',
});
globalProxyInvalidHintShownRef.current = true;
}
} else {
globalProxyInvalidHintShownRef.current = false;
message.destroy('global-proxy-invalid');
}
const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled;
let cancelled = false;
ConfigureGlobalProxy(enabledForBackend, {
type: globalProxy.type,
host,
port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080),
user: String(globalProxy.user || '').trim(),
password: globalProxy.password || '',
})
.then((res) => {
if (cancelled || res?.success) {
return;
}
message.error({
content: '全局代理配置失败: ' + (res?.message || '未知错误'),
key: 'global-proxy-sync-error',
});
})
.catch((err) => {
if (cancelled) {
return;
}
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
message.error({
content: '全局代理配置失败: ' + errMsg,
key: 'global-proxy-sync-error',
});
});
return () => {
cancelled = true;
};
}, [
isStoreHydrated,
globalProxy.enabled,
globalProxy.type,
globalProxy.host,
globalProxy.port,
globalProxy.user,
globalProxy.password,
]);
useEffect(() => {
let cancelled = false;
let startupWindowTimer: number | null = null;
const maxApplyAttempts = 6;
const applyRetryDelayMs = 400;
const settleDelayMs = 160;
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
try {
if (await WindowIsFullscreen()) {
return true;
}
} catch (_) {
// ignore
}
try {
if (await WindowIsMaximised()) {
return true;
}
} catch (_) {
// ignore
}
return false;
};
const applyStartupWindowPreference = (attempt: number) => {
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
startupWindowTimer = window.setTimeout(() => {
if (cancelled) {
return;
}
if (!useStore.getState().startupFullscreen) {
return;
}
Promise.resolve()
.then(async () => {
if (await checkStartupPreferenceApplied()) {
return;
}
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
if (attempt < maxApplyAttempts) {
applyStartupWindowPreference(attempt + 1);
}
});
}, 300);
};
if (useStore.persist.hasHydrated()) {
applyStartupWindowPreference(1);
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
if (cancelled) {
return;
}
applyStartupWindowPreference(1);
});
return () => {
cancelled = true;
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
unsubscribeHydration();
};
}, []);
// Background Helper
const getBg = (darkHex: string, lightHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
// Parse hex to rgb
const hex = darkHex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${effectiveOpacity})`;
};
// Specific colors
const bgMain = getBg('#141414', '#ffffff');
const bgContent = getBg('#1d1d1d', '#ffffff');
const addTab = useStore(state => state.addTab);
const activeContext = useStore(state => state.activeContext);
const connections = useStore(state => state.connections);
const addConnection = useStore(state => state.addConnection);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const updateCheckInFlightRef = React.useRef(false);
const updateDownloadInFlightRef = React.useRef(false);
const updateDownloadedVersionRef = React.useRef<string | null>(null);
const updateDownloadMetaRef = React.useRef<UpdateDownloadResultData | null>(null);
const updateDeferredVersionRef = React.useRef<string | null>(null);
const updateNotifiedVersionRef = React.useRef<string | null>(null);
const updateMutedVersionRef = React.useRef<string | null>(null);
const [isAboutOpen, setIsAboutOpen] = useState(false);
const [aboutLoading, setAboutLoading] = useState(false);
const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string } | null>(null);
const [aboutUpdateStatus, setAboutUpdateStatus] = useState<string>('');
const [lastUpdateInfo, setLastUpdateInfo] = useState<UpdateInfo | null>(null);
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<{
open: boolean;
version: string;
status: 'idle' | 'start' | 'downloading' | 'done' | 'error';
percent: number;
downloaded: number;
total: number;
message: string;
}>({
open: false,
version: '',
status: 'idle',
percent: 0,
downloaded: 0,
total: 0,
message: ''
});
type UpdateInfo = {
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
releaseName?: string;
releaseNotesUrl?: string;
assetName?: string;
assetUrl?: string;
assetSize?: number;
sha256?: string;
downloaded?: boolean;
downloadPath?: string;
};
type UpdateDownloadProgressEvent = {
status?: 'start' | 'downloading' | 'done' | 'error';
percent?: number;
downloaded?: number;
total?: number;
message?: string;
};
type UpdateDownloadResultData = {
info?: UpdateInfo;
downloadPath?: string;
installLogPath?: string;
installTarget?: string;
platform?: string;
autoRelaunch?: boolean;
};
const formatBytes = (bytes?: number) => {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value /= 1024;
idx++;
}
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
};
const promptRestartForUpdate = (info: UpdateInfo, resultData?: UpdateDownloadResultData) => {
const downloadPathHint = resultData?.downloadPath
? `更新包路径:${resultData.downloadPath}`
: '';
const installLogHint = resultData?.installLogPath
? `安装日志:${resultData.installLogPath}`
: '';
Modal.confirm({
title: '更新已下载',
content: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, userSelect: 'text' }}>
<div>{`版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`}</div>
{downloadPathHint ? <div style={{ fontSize: 12, color: '#8c8c8c' }}>{downloadPathHint}</div> : null}
{installLogHint ? <div style={{ fontSize: 12, color: '#8c8c8c' }}>{installLogHint}</div> : null}
</div>
),
okText: '立即重启',
cancelText: '稍后',
onOk: async () => {
updateDeferredVersionRef.current = null;
const res = await (window as any).go.app.App.InstallUpdateAndRestart();
if (!res?.success) {
message.error('更新安装失败: ' + (res?.message || '未知错误'));
}
},
onCancel: () => {
updateDeferredVersionRef.current = info.latestVersion;
}
});
};
const downloadUpdate = React.useCallback(async (info: UpdateInfo, silent: boolean) => {
if (updateDownloadInFlightRef.current) return;
if (updateDownloadedVersionRef.current === info.latestVersion) {
if (!silent) {
const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath;
message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion}`);
}
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
promptRestartForUpdate(info, updateDownloadMetaRef.current || undefined);
}
return;
}
updateDownloadInFlightRef.current = true;
updateDownloadMetaRef.current = null;
const key = 'update-download';
setUpdateDownloadProgress({
open: true,
version: info.latestVersion,
status: 'start',
percent: 0,
downloaded: 0,
total: info.assetSize || 0,
message: ''
});
message.loading({ content: `正在下载更新 ${info.latestVersion}...`, key, duration: 0 });
const res = await (window as any).go.app.App.DownloadUpdate();
updateDownloadInFlightRef.current = false;
if (res?.success) {
const resultData = (res?.data || {}) as UpdateDownloadResultData;
updateDownloadMetaRef.current = resultData;
updateDownloadedVersionRef.current = info.latestVersion;
setUpdateDownloadProgress(prev => ({ ...prev, status: 'done', percent: 100, open: false }));
if (resultData?.downloadPath) {
message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, key, duration: 5 });
} else {
message.success({ content: '更新下载完成', key, duration: 2 });
}
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,待重启安装)`);
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
promptRestartForUpdate(info, resultData);
}
} else {
setUpdateDownloadProgress(prev => ({
...prev,
status: 'error',
message: res?.message || '未知错误'
}));
message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), key, duration: 4 });
}
}, []);
const checkForUpdates = React.useCallback(async (silent: boolean) => {
if (updateCheckInFlightRef.current) return;
updateCheckInFlightRef.current = true;
if (!silent) {
setAboutUpdateStatus('正在检查更新...');
}
const res = await (window as any).go.app.App.CheckForUpdates();
updateCheckInFlightRef.current = false;
if (!res?.success) {
if (!silent) {
message.error('检查更新失败: ' + (res?.message || '未知错误'));
setAboutUpdateStatus('检查更新失败: ' + (res?.message || '未知错误'));
}
return;
}
const info: UpdateInfo = res.data;
if (!info) return;
setLastUpdateInfo(info);
if (info.hasUpdate) {
const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion;
const hasDownloaded = Boolean(info.downloaded) || localDownloaded;
if (hasDownloaded) {
const downloadPath = info.downloadPath || updateDownloadMetaRef.current?.downloadPath || '';
updateDownloadedVersionRef.current = info.latestVersion;
updateDownloadMetaRef.current = {
...(updateDownloadMetaRef.current || {}),
info,
downloadPath: downloadPath || undefined,
};
} else {
if (updateDownloadedVersionRef.current !== info.latestVersion) {
updateDownloadMetaRef.current = null;
}
}
const statusText = hasDownloaded
? `发现新版本 ${info.latestVersion}(已下载,待重启安装)`
: `发现新版本 ${info.latestVersion}(未下载)`;
if (!silent) {
message.info(`发现新版本 ${info.latestVersion}`);
setAboutUpdateStatus(statusText);
}
if (silent && isAboutOpen) {
setAboutUpdateStatus(statusText);
}
if (silent && !isAboutOpen && updateMutedVersionRef.current !== info.latestVersion && updateNotifiedVersionRef.current !== info.latestVersion) {
updateNotifiedVersionRef.current = info.latestVersion;
setIsAboutOpen(true);
}
} else if (!silent) {
const text = `当前已是最新版本(${info.currentVersion || '未知'}`;
message.success(text);
setAboutUpdateStatus(text);
} else if (silent && isAboutOpen) {
const text = `当前已是最新版本(${info.currentVersion || '未知'}`;
setAboutUpdateStatus(text);
}
}, [downloadUpdate]);
const loadAboutInfo = React.useCallback(async () => {
setAboutLoading(true);
const res = await (window as any).go.app.App.GetAppInfo();
if (res?.success) {
setAboutInfo(res.data);
} else {
message.error('获取应用信息失败: ' + (res?.message || '未知错误'));
}
setAboutLoading(false);
}, []);
const handleNewQuery = () => {
let connId = activeContext?.connectionId || '';
let db = activeContext?.dbName || '';
// Priority: Active Tab Context > Sidebar Selection
if (activeTabId) {
const currentTab = tabs.find(t => t.id === activeTabId);
if (currentTab && currentTab.connectionId) {
connId = currentTab.connectionId;
db = currentTab.dbName || '';
}
}
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: connId,
dbName: db,
query: ''
});
};
const handleImportConnections = async () => {
const res = await (window as any).go.app.App.ImportConfigFile();
if (res.success) {
try {
const imported = JSON.parse(res.data);
if (Array.isArray(imported)) {
let count = 0;
imported.forEach((conn: any) => {
if (!connections.some(c => c.id === conn.id)) {
addConnection(conn);
count++;
}
});
message.success(`成功导入 ${count} 个连接`);
} else {
message.error("文件格式错误:需要 JSON 数组");
}
} catch (e) {
message.error("解析 JSON 失败");
}
} else if (res.message !== "Cancelled") {
message.error("导入失败: " + res.message);
}
};
const handleExportConnections = async () => {
if (connections.length === 0) {
message.warning("没有连接可导出");
return;
}
const res = await (window as any).go.app.App.ExportData(connections, [], "connections", "json");
if (res.success) {
message.success("导出成功");
} else if (res.message !== "Cancelled") {
message.error("导出失败: " + res.message);
}
};
const toolsMenu: MenuProps['items'] = [
{
key: 'import',
label: '导入连接配置',
icon: <UploadOutlined />,
onClick: handleImportConnections
},
{
key: 'export',
label: '导出连接配置',
icon: <DownloadOutlined />,
onClick: handleExportConnections
},
{
key: 'sync',
label: '数据同步',
icon: <UploadOutlined rotate={90} />,
onClick: () => setIsSyncModalOpen(true)
},
{
key: 'drivers',
label: '驱动管理',
icon: <SettingOutlined />,
onClick: () => setIsDriverModalOpen(true)
}
];
const themeMenu: MenuProps['items'] = [
{
key: 'light',
label: '亮色主题',
icon: themeMode === 'light' ? <CheckOutlined /> : undefined,
onClick: () => setTheme('light')
},
{
key: 'dark',
label: '暗色主题',
icon: themeMode === 'dark' ? <CheckOutlined /> : undefined,
onClick: () => setTheme('dark')
},
{ type: 'divider' },
{
key: 'settings',
label: '外观设置...',
icon: <SettingOutlined />,
onClick: () => setIsAppearanceModalOpen(true)
}
];
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
const LOG_PANEL_TOOLBAR_HEIGHT = 32;
const LOG_PANEL_SINGLE_ROW_HEIGHT = 39;
const LOG_PANEL_MIN_VISIBLE_ROWS = 1;
const LOG_PANEL_MIN_HEIGHT = LOG_PANEL_TOOLBAR_HEIGHT + (LOG_PANEL_SINGLE_ROW_HEIGHT * LOG_PANEL_MIN_VISIBLE_ROWS);
const LOG_PANEL_MAX_HEIGHT = 800;
const [logPanelHeight, setLogPanelHeight] = useState(Math.max(200, LOG_PANEL_MIN_HEIGHT));
const [isLogPanelOpen, setIsLogPanelOpen] = useState(false);
const logResizeRef = React.useRef<{ startY: number, startHeight: number } | null>(null);
const logGhostRef = React.useRef<HTMLDivElement>(null);
const handleLogResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
logResizeRef.current = { startY: e.clientY, startHeight: logPanelHeight };
if (logGhostRef.current) {
logGhostRef.current.style.top = `${e.clientY}px`;
logGhostRef.current.style.display = 'block';
}
document.addEventListener('mousemove', handleLogResizeMove);
document.addEventListener('mouseup', handleLogResizeUp);
};
const handleLogResizeMove = (e: MouseEvent) => {
if (!logResizeRef.current) return;
// Just update ghost line, no state update
if (logGhostRef.current) {
logGhostRef.current.style.top = `${e.clientY}px`;
}
};
const handleLogResizeUp = (e: MouseEvent) => {
if (logResizeRef.current) {
const delta = logResizeRef.current.startY - e.clientY;
const newHeight = Math.max(
LOG_PANEL_MIN_HEIGHT,
Math.min(LOG_PANEL_MAX_HEIGHT, logResizeRef.current.startHeight + delta)
);
setLogPanelHeight(newHeight);
}
if (logGhostRef.current) {
logGhostRef.current.style.display = 'none';
}
logResizeRef.current = null;
document.removeEventListener('mousemove', handleLogResizeMove);
document.removeEventListener('mouseup', handleLogResizeUp);
};
const handleEditConnection = (conn: SavedConnection) => {
setEditingConnection(conn);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingConnection(null);
};
const handleOpenDriverManagerFromConnection = () => {
setIsModalOpen(false);
setEditingConnection(null);
setIsDriverModalOpen(true);
};
const handleTitleBarDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
return;
}
(window as any).runtime.WindowToggleMaximise();
};
// Sidebar Resizing
const [sidebarWidth, setSidebarWidth] = useState(300);
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
const rafRef = React.useRef<number | null>(null);
const ghostRef = React.useRef<HTMLDivElement>(null);
const latestMouseX = React.useRef<number>(0); // Store latest mouse position
const handleSidebarMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
if (ghostRef.current) {
ghostRef.current.style.left = `${sidebarWidth}px`;
ghostRef.current.style.display = 'block';
}
sidebarDragRef.current = { startX: e.clientX, startWidth: sidebarWidth };
latestMouseX.current = e.clientX; // Init
document.addEventListener('mousemove', handleSidebarMouseMove);
document.addEventListener('mouseup', handleSidebarMouseUp);
};
const handleSidebarMouseMove = (e: MouseEvent) => {
if (!sidebarDragRef.current) return;
latestMouseX.current = e.clientX; // Always update latest pos
if (rafRef.current) return; // Schedule once per frame
rafRef.current = requestAnimationFrame(() => {
if (!sidebarDragRef.current || !ghostRef.current) return;
// Use latestMouseX.current instead of stale closure 'e.clientX'
const delta = latestMouseX.current - sidebarDragRef.current.startX;
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
ghostRef.current.style.left = `${newWidth}px`;
rafRef.current = null;
});
};
const handleSidebarMouseUp = (e: MouseEvent) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (sidebarDragRef.current) {
// Use latest position for final commit too
const delta = e.clientX - sidebarDragRef.current.startX;
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
setSidebarWidth(newWidth);
}
if (ghostRef.current) {
ghostRef.current.style.display = 'none';
}
sidebarDragRef.current = null;
document.removeEventListener('mousemove', handleSidebarMouseMove);
document.removeEventListener('mouseup', handleSidebarMouseUp);
};
useEffect(() => {
document.body.style.backgroundColor = 'transparent';
document.body.style.color = darkMode ? '#ffffff' : '#000000';
document.body.setAttribute('data-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
useEffect(() => {
if (isAboutOpen) {
if (lastUpdateInfo?.hasUpdate) {
setAboutUpdateStatus(`发现新版本 ${lastUpdateInfo.latestVersion}(未下载)`);
} else if (lastUpdateInfo) {
setAboutUpdateStatus(`当前已是最新版本(${lastUpdateInfo.currentVersion || '未知'}`);
} else {
setAboutUpdateStatus('未检查');
}
loadAboutInfo();
}
}, [isAboutOpen, lastUpdateInfo, loadAboutInfo]);
useEffect(() => {
const startupTimer = window.setTimeout(() => {
checkForUpdates(true);
}, 2000);
const interval = window.setInterval(() => {
checkForUpdates(true);
}, 30 * 60 * 1000);
return () => {
window.clearTimeout(startupTimer);
window.clearInterval(interval);
};
}, [checkForUpdates]);
useEffect(() => {
const offDownloadProgress = EventsOn('update:download-progress', (event: UpdateDownloadProgressEvent) => {
if (!event) return;
const status = event.status || 'downloading';
const nextStatus: 'idle' | 'start' | 'downloading' | 'done' | 'error' =
status === 'start' || status === 'downloading' || status === 'done' || status === 'error'
? status
: 'downloading';
const downloaded = typeof event.downloaded === 'number' ? event.downloaded : 0;
const total = typeof event.total === 'number' ? event.total : 0;
const percentRaw = typeof event.percent === 'number'
? event.percent
: (total > 0 ? (downloaded / total) * 100 : 0);
const percent = Math.max(0, Math.min(100, percentRaw));
setUpdateDownloadProgress(prev => ({
open: nextStatus === 'start' || nextStatus === 'downloading' || nextStatus === 'error',
version: prev.version,
status: nextStatus,
percent,
downloaded,
total,
message: String(event.message || '')
}));
});
return () => {
offDownloadProgress();
};
}, []);
const linuxResizeHandleStyleBase = {
position: 'fixed',
zIndex: 12000,
background: 'transparent',
WebkitAppRegion: 'drag',
'--wails-draggable': 'drag',
userSelect: 'none'
} as any;
const showLinuxResizeHandles = isLinuxRuntime;
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorBgLayout: 'transparent',
colorBgContainer: darkMode
? `rgba(29, 29, 29, ${effectiveOpacity})`
: `rgba(255, 255, 255, ${effectiveOpacity})`,
colorBgElevated: darkMode
? '#1f1f1f'
: '#ffffff',
colorFillAlter: darkMode
? `rgba(38, 38, 38, ${effectiveOpacity})`
: `rgba(250, 250, 250, ${effectiveOpacity})`,
},
components: {
Layout: {
colorBgBody: 'transparent',
colorBgHeader: 'transparent',
bodyBg: 'transparent',
headerBg: 'transparent',
siderBg: 'transparent',
triggerBg: 'transparent'
},
Table: {
headerBg: 'transparent',
rowHoverBg: darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)',
},
Tabs: {
cardBg: 'transparent',
itemActiveColor: darkMode ? '#177ddc' : '#1890ff',
}
}
}}
>
<Layout style={{
height: '100vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
borderRadius: showLinuxResizeHandles ? 0 : windowCornerRadius,
clipPath: showLinuxResizeHandles ? 'none' : `inset(0 round ${windowCornerRadius}px)`,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
}}>
{/* Custom Title Bar */}
<div
onDoubleClick={handleTitleBarDoubleClick}
style={{
height: 32,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: bgMain,
borderBottom: 'none',
userSelect: 'none',
WebkitAppRegion: 'drag', // Wails drag region
'--wails-draggable': 'drag',
paddingLeft: 16
} as any}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600 }}>
{/* Logo can be added here if available */}
GoNavi
</div>
<div
data-no-titlebar-toggle="true"
onDoubleClick={(e) => e.stopPropagation()}
style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
>
<Button
type="text"
icon={<MinusOutlined />}
style={{ height: '100%', borderRadius: 0, width: 46 }}
onClick={() => (window as any).runtime.WindowMinimise()}
/>
<Button
type="text"
icon={<BorderOutlined />}
style={{ height: '100%', borderRadius: 0, width: 46 }}
onClick={() => (window as any).runtime.WindowToggleMaximise()}
/>
<Button
type="text"
icon={<CloseOutlined />}
danger
className="titlebar-close-btn"
style={{ height: '100%', borderRadius: 0, width: 46 }}
onClick={() => (window as any).runtime.Quit()}
/>
</div>
</div>
<div
style={{
height: 36,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 4,
padding: '0 8px',
borderBottom: 'none',
background: bgMain,
}}
>
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
<Button type="text" icon={<ToolOutlined />} title="工具"></Button>
</Dropdown>
<Button type="text" icon={<GlobalOutlined />} title="代理" onClick={() => setIsProxyModalOpen(true)}></Button>
<Dropdown menu={{ items: themeMenu }} placement="bottomLeft">
<Button type="text" icon={<SkinOutlined />} title="主题"></Button>
</Dropdown>
<Button type="text" icon={<InfoCircleOutlined />} title="关于" onClick={() => setIsAboutOpen(true)}></Button>
</div>
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
<Sider
width={sidebarWidth}
style={{
borderRight: '1px solid rgba(128,128,128,0.2)',
position: 'relative',
background: bgMain
}}
>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '10px', borderBottom: 'none', display: 'flex', justifyContent: 'flex-end', alignItems: 'center', flexShrink: 0 }}>
<div>
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<Sidebar onEditConnection={handleEditConnection} />
</div>
{/* Sidebar Footer for Log Toggle */}
<div style={{ padding: '8px', borderTop: 'none', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
<Button
type={isLogPanelOpen ? "primary" : "text"}
icon={<BugOutlined />}
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
block
>
SQL
</Button>
</div>
</div>
{/* Sidebar Resize Handle */}
<div
onMouseDown={handleSidebarMouseDown}
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '5px',
cursor: 'col-resize',
zIndex: 100,
// background: 'transparent' // transparent usually, visible on hover if desired
}}
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
<TabManager />
</div>
{isLogPanelOpen && (
<LogPanel
height={logPanelHeight}
onClose={() => setIsLogPanelOpen(false)}
onResizeStart={handleLogResizeStart}
/>
)}
</Content>
</Layout>
<ConnectionModal
open={isModalOpen}
onClose={handleCloseModal}
initialValues={editingConnection}
onOpenDriverManager={handleOpenDriverManagerFromConnection}
/>
<DataSyncModal
open={isSyncModalOpen}
onClose={() => setIsSyncModalOpen(false)}
/>
<DriverManagerModal
open={isDriverModalOpen}
onClose={() => setIsDriverModalOpen(false)}
/>
<Modal
title="关于 GoNavi"
open={isAboutOpen}
onCancel={() => setIsAboutOpen(false)}
footer={[
lastUpdateInfo?.hasUpdate ? (
<Button key="download" icon={<DownloadOutlined />} onClick={() => downloadUpdate(lastUpdateInfo, false)}></Button>
) : null,
lastUpdateInfo?.hasUpdate ? (
<Button key="mute" onClick={() => { updateMutedVersionRef.current = lastUpdateInfo.latestVersion; setIsAboutOpen(false); }}></Button>
) : null,
<Button key="check" icon={<CloudDownloadOutlined />} onClick={() => checkForUpdates(false)}></Button>,
<Button key="close" type="primary" onClick={() => setIsAboutOpen(false)}></Button>
].filter(Boolean)}
>
{aboutLoading ? (
<div style={{ padding: '16px 0', textAlign: 'center' }}>
<Spin />
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div>{aboutInfo?.version || '未知'}</div>
<div>{aboutInfo?.author || '未知'}</div>
<div>{aboutUpdateStatus || '未检查'}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<GithubOutlined />
{aboutInfo?.repoUrl ? (
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>
{aboutInfo.repoUrl}
</a>
) : '未知'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<BugOutlined />
{aboutInfo?.issueUrl ? (
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>
{aboutInfo.issueUrl}
</a>
) : '未知'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<CloudDownloadOutlined />
{aboutInfo?.releaseUrl ? (
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>
{aboutInfo.releaseUrl}
</a>
) : '未知'}
</div>
</div>
)}
</Modal>
<Modal
title="外观设置"
open={isAppearanceModalOpen}
onCancel={() => setIsAppearanceModalOpen(false)}
footer={null}
width={460}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (Opacity)</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={0.1}
max={1.0}
step={0.05}
value={appearance.opacity ?? 1.0}
onChange={(v) => setAppearance({ opacity: v })}
style={{ flex: 1 }}
/>
<span style={{ width: 40 }}>{Math.round((appearance.opacity ?? 1.0) * 100)}%</span>
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (Blur)</div>
{isWindowsPlatform() ? (
<div style={{ fontSize: 12, color: '#888' }}>
Windows 使 Acrylic
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={0}
max={20}
value={appearance.blur ?? 0}
onChange={(v) => setAppearance({ blur: v })}
style={{ flex: 1 }}
/>
<span style={{ width: 40 }}>{appearance.blur}px</span>
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
*
</div>
</>
)}
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span></span>
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
*
</div>
</div>
</div>
</Modal>
<Modal
title="全局代理设置"
open={isProxyModalOpen}
onCancel={() => setIsProxyModalOpen(false)}
footer={null}
width={460}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span></span>
<Switch checked={globalProxy.enabled} onChange={(checked) => setGlobalProxy({ enabled: checked })} />
</div>
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, opacity: globalProxy.enabled ? 1 : 0.7 }}>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}></div>
<Select
value={globalProxy.type}
disabled={!globalProxy.enabled}
options={[
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP' },
]}
onChange={(value) => setGlobalProxy({ type: value as 'socks5' | 'http' })}
/>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}></div>
<InputNumber
min={1}
max={65535}
style={{ width: '100%' }}
value={globalProxy.port}
disabled={!globalProxy.enabled}
onChange={(value) => setGlobalProxy({
port: typeof value === 'number' ? value : (globalProxy.type === 'http' ? 8080 : 1080),
})}
/>
</div>
<div style={{ gridColumn: '1 / span 2' }}>
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}></div>
<Input
placeholder="例如127.0.0.1"
value={globalProxy.host}
disabled={!globalProxy.enabled}
onChange={(e) => setGlobalProxy({ host: e.target.value })}
/>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}></div>
<Input
placeholder="proxy-user"
value={globalProxy.user}
disabled={!globalProxy.enabled}
onChange={(e) => setGlobalProxy({ user: e.target.value })}
/>
</div>
<div>
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}></div>
<Input.Password
placeholder="proxy-password"
value={globalProxy.password}
disabled={!globalProxy.enabled}
onChange={(e) => setGlobalProxy({ password: e.target.value })}
/>
</div>
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 6 }}>
*
</div>
</div>
</div>
</Modal>
<Modal
title={updateDownloadProgress.version ? `下载更新 ${updateDownloadProgress.version}` : '下载更新'}
open={updateDownloadProgress.open}
closable={updateDownloadProgress.status === 'error'}
maskClosable={false}
keyboard={updateDownloadProgress.status === 'error'}
onCancel={() => {
if (updateDownloadProgress.status === 'error') {
setUpdateDownloadProgress({
open: false,
version: '',
status: 'idle',
percent: 0,
downloaded: 0,
total: 0,
message: ''
});
}
}}
footer={updateDownloadProgress.status === 'error' ? [
<Button
key="close"
onClick={() => setUpdateDownloadProgress({
open: false,
version: '',
status: 'idle',
percent: 0,
downloaded: 0,
total: 0,
message: ''
})}
>
</Button>
] : null}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Progress
percent={Math.round(updateDownloadProgress.percent)}
status={updateDownloadProgress.status === 'error' ? 'exception' : (updateDownloadProgress.status === 'done' ? 'success' : 'active')}
/>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
{`${formatBytes(updateDownloadProgress.downloaded)} / ${formatBytes(updateDownloadProgress.total)}`}
</div>
{updateDownloadProgress.message ? (
<div style={{ fontSize: 12, color: '#ff4d4f' }}>{updateDownloadProgress.message}</div>
) : null}
</div>
</Modal>
{showLinuxResizeHandles && (
<>
{/* Linux Mint 下 frameless 仅局部可缩放:补四边四角命中层 */}
<div style={{ ...linuxResizeHandleStyleBase, top: 0, left: 14, right: 14, height: 6, cursor: 'ns-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, left: 14, right: 14, height: 6, cursor: 'ns-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 14, bottom: 14, left: 0, width: 6, cursor: 'ew-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 14, bottom: 14, right: 0, width: 6, cursor: 'ew-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 0, left: 0, width: 14, height: 14, cursor: 'nwse-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 0, right: 0, width: 14, height: 14, cursor: 'nesw-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, left: 0, width: 14, height: 14, cursor: 'nesw-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, right: 0, width: 14, height: 14, cursor: 'nwse-resize' }} />
</>
)}
{/* Ghost Resize Line for Sidebar */}
<div
ref={ghostRef}
style={{
position: 'fixed',
top: 0,
bottom: 0,
left: 0,
width: '4px',
background: 'rgba(24, 144, 255, 0.5)',
zIndex: 9999,
pointerEvents: 'none',
display: 'none'
}}
/>
{/* Ghost Resize Line for Log Panel */}
<div
ref={logGhostRef}
style={{
position: 'fixed',
left: sidebarWidth, // Start from sidebar edge
right: 0,
height: '4px',
background: 'rgba(24, 144, 255, 0.5)',
zIndex: 9999,
pointerEvents: 'none',
display: 'none',
cursor: 'row-resize'
}}
/>
</Layout>
</ConfigProvider>
);
}
export default App;