Files
MyGoNavi/frontend/src/App.tsx
杨国锋 4099796c88 feat(connection): 增强连接管理与交互体验
- 新增测试连接功能,修复底层驱动假成功问题,确保密码/端口验证准确
- 支持导入/导出连接配置(JSON),便于迁移与备份
- 优化侧边栏:实现虚拟滚动解决卡顿,增加数据库筛选与断开连接重连机制
- 优化交互:改进右键菜单体验(全行触发/禁用选文),完善新建查询的上下文自动关联
- 界面调整:精简连接弹窗,移除冗余的默认数据库输入
2026-02-02 16:33:11 +08:00

332 lines
12 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message } from 'antd';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, BugOutlined, SettingOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
import LogPanel from './components/LogPanel';
import { useStore } from './store';
import { SavedConnection } from './types';
import './App.css';
const { Sider, Content } = Layout;
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
const { darkMode, toggleDarkMode, addTab, activeContext, connections, addConnection, tabs, activeTabId } = useStore();
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
});
};
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 settingsMenu: MenuProps['items'] = [
{
key: 'import',
label: '导入连接配置',
icon: <UploadOutlined />,
onClick: handleImportConnections
},
{
key: 'export',
label: '导出连接配置',
icon: <DownloadOutlined />,
onClick: handleExportConnections
}
];
// Log Panel
const [logPanelHeight, setLogPanelHeight] = useState(200);
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(100, Math.min(800, 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);
};
// 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(() => {
if (darkMode) {
document.body.style.backgroundColor = '#141414';
document.body.style.color = '#ffffff';
} else {
document.body.style.backgroundColor = '#ffffff';
document.body.style.color = '#000000';
}
}, [darkMode]);
return (
<ConfigProvider
theme={{
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Sider
theme={darkMode ? "dark" : "light"}
width={sidebarWidth}
style={{
borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
position: 'relative'
}}
>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
<div>
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
<Dropdown menu={{ items: settingsMenu }} placement="bottomRight">
<Button type="text" icon={<SettingOutlined />} title="更多设置" />
</Dropdown>
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<Sidebar onEditConnection={handleEditConnection} />
</div>
{/* Sidebar Footer for Log Toggle */}
<div style={{ padding: '8px', borderTop: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', 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: darkMode ? '#141414' : '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'hidden' }}>
<TabManager />
</div>
{isLogPanelOpen && (
<LogPanel
height={logPanelHeight}
onClose={() => setIsLogPanelOpen(false)}
onResizeStart={handleLogResizeStart}
/>
)}
</Content>
<ConnectionModal
open={isModalOpen}
onClose={handleCloseModal}
initialValues={editingConnection}
/>
{/* 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;