feat(editor/appearance): 跨库SQL智能提示与全局透明度模糊效果

跨库SQL智能提示:
  - 扩展 tablesRef/allColumnsRef 支持跨库元数据存储
  - 根据 includeDatabases 配置过滤可见数据库
  - 支持三段式(db.table.column)和两段式(db.table)补全格式
  - 优化补全权重:FROM表字段优先于其他表和关键字
  - 移除数据库类型限制,PostgreSQL等均支持列信息获取

  全局透明度与高斯模糊:
  - 新增 appearance 状态管理(opacity/blur)并持久化
  - App/Sidebar/LogPanel/DataGrid/TabManager 适配透明背景
  - 使用 backdropFilter 实现高斯模糊效果
  - 右键菜单使用 Portal 渲染避免 fixed 定位失效

  单元格右键菜单增强:
  - 合并复制(INSERT/JSON/CSV/Markdown)和导出功能
  - 添加 stopPropagation 防止菜单事件冒泡
This commit is contained in:
杨国锋
2026-02-05 21:26:03 +08:00
parent f75e04f091
commit d1ce9cefb8
10 changed files with 545 additions and 108 deletions

View File

@@ -1 +1 @@
5b8157374dae5f9340e31b2d0bd2c00e
d0f9366af59a6367ad3c7e2d4185ead4

View File

@@ -3,6 +3,7 @@ html, body, #root {
margin: 0;
padding: 0;
overflow: hidden; /* Disable global scrollbar */
background-color: transparent !important; /* CRITICAL: Allow Wails window transparency */
}
/* 侧边栏 Tree 样式优化 */
@@ -52,13 +53,18 @@ body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* Ensure body background matches theme to avoid white flashes */
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
body {
transition: background-color 0.3s, color 0.3s;
transition: color 0.3s;
}
body[data-theme='dark'] {
/* Improve contrast on transparent backgrounds */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
/* Custom Title Bar Close Button Hover */
.titlebar-close-btn:hover {
background-color: #ff4d4f !important;
color: #fff !important;
}
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin } from 'antd';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Popover } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined } from '@ant-design/icons';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
@@ -19,7 +19,25 @@ function App() {
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 darkMode = themeMode === 'dark';
// Background Helper
const getBg = (darkHex: string, lightHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${appearance.opacity ?? 0.95})`; // 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}, ${appearance.opacity ?? 0.95})`;
};
// 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);
@@ -241,9 +259,18 @@ function App() {
label: '暗色主题',
icon: themeMode === 'dark' ? <CheckOutlined /> : undefined,
onClick: () => setTheme('dark')
},
{ type: 'divider' },
{
key: 'settings',
label: '外观设置...',
icon: <SettingOutlined />,
onClick: () => setIsAppearanceModalOpen(true)
}
];
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
// Log Panel
const [logPanelHeight, setLogPanelHeight] = useState(200);
@@ -399,9 +426,46 @@ function App() {
locale={zhCN}
theme={{
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorBgLayout: 'transparent',
colorBgContainer: darkMode
? `rgba(29, 29, 29, ${appearance.opacity ?? 0.95})`
: `rgba(255, 255, 255, ${appearance.opacity ?? 0.95})`,
colorBgElevated: darkMode
? '#1f1f1f'
: '#ffffff',
colorFillAlter: darkMode
? `rgba(38, 38, 38, ${appearance.opacity ?? 0.95})`
: `rgba(250, 250, 250, ${appearance.opacity ?? 0.95})`,
},
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' }}>
<Layout style={{
height: '100vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
backdropFilter: `blur(${appearance.blur ?? 0}px)`
}}>
{/* Custom Title Bar */}
<div
style={{
@@ -410,10 +474,12 @@ function App() {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: darkMode ? '#141414' : '#fff',
borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
background: bgMain,
backdropFilter: `blur(${appearance.blur ?? 0}px)`,
borderBottom: 'none',
userSelect: 'none',
WebkitAppRegion: 'drag', // Wails drag region
'--wails-draggable': 'drag',
paddingLeft: 16
} as any}
>
@@ -421,7 +487,7 @@ function App() {
{/* Logo can be added here if available */}
GoNavi
</div>
<div style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag' } as any}>
<div style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}>
<Button
type="text"
icon={<MinusOutlined />}
@@ -454,8 +520,9 @@ function App() {
justifyContent: 'flex-start',
gap: 4,
padding: '0 8px',
borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
background: darkMode ? '#141414' : '#fff'
borderBottom: 'none',
background: bgMain,
backdropFilter: `blur(${appearance.blur ?? 0}px)`
}}
>
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
@@ -470,13 +537,13 @@ function App() {
<Sider
width={sidebarWidth}
style={{
borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
borderRight: 'none',
position: 'relative',
background: darkMode ? '#141414' : '#fff'
background: bgMain
}}
>
<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: 'flex-end', alignItems: 'center', flexShrink: 0, background: darkMode ? '#141414' : '#fff' }}>
<div style={{ padding: '10px', borderBottom: 'none', display: 'flex', justifyContent: 'flex-end', alignItems: 'center', flexShrink: 0 }}>
<div>
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
@@ -484,12 +551,12 @@ function App() {
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden', background: darkMode ? '#141414' : '#fff' }}>
<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, background: darkMode ? '#141414' : '#fff' }}>
<div style={{ padding: '8px', borderTop: 'none', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
<Button
type={isLogPanelOpen ? "primary" : "text"}
icon={<BugOutlined />}
@@ -517,8 +584,8 @@ function App() {
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: darkMode ? '#1d1d1d' : '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, backdropFilter: `blur(${appearance.blur ?? 0}px)` }}>
<TabManager />
</div>
{isLogPanelOpen && (
@@ -590,6 +657,47 @@ function App() {
</div>
)}
</Modal>
<Modal
title="外观设置"
open={isAppearanceModalOpen}
onCancel={() => setIsAppearanceModalOpen(false)}
footer={null}
width={400}
>
<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 ?? 0.95}
onChange={(v) => setAppearance({ opacity: v })}
style={{ flex: 1 }}
/>
<span style={{ width: 40 }}>{Math.round((appearance.opacity ?? 0.95) * 100)}%</span>
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}> (Blur)</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>
</Modal>
{/* Ghost Resize Line for Sidebar */}
<div

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd';
import type { SortOrder } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined } from '@ant-design/icons';
@@ -209,6 +210,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
const handleContextMenu = (e: React.MouseEvent) => {
if (!editable) return;
e.preventDefault();
e.stopPropagation(); // 阻止冒泡到行级菜单
if (cellContextMenuContext) {
cellContextMenuContext.showMenu(e, record, dataIndex, title);
}
@@ -355,8 +357,32 @@ const DataGrid: React.FC<DataGridProps> = ({
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const opacity = appearance.opacity ?? 0.95;
const selectionColumnWidth = 46;
// Background Helper
const getBg = (darkHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${opacity})`;
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}, ${opacity})`;
};
const blur = appearance.blur ?? 0;
const bgContent = getBg('#1d1d1d');
const bgFilter = getBg('#262626');
const bgContextMenu = getBg('#1f1f1f');
// Row Colors with Opacity
const getRowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`;
const rowAddedBg = darkMode ? getRowBg(22, 43, 22) : getRowBg(246, 255, 237);
const rowModBg = darkMode ? getRowBg(22, 34, 56) : getRowBg(230, 247, 255);
const rowAddedHover = darkMode ? getRowBg(31, 61, 31) : getRowBg(217, 247, 190);
const rowModHover = darkMode ? getRowBg(29, 53, 94) : getRowBg(186, 231, 255);
const [form] = Form.useForm();
const [modal, contextHolder] = Modal.useModal();
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
@@ -1288,7 +1314,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const enableVirtual = mergedDisplayData.length >= 200;
return (
<div className={gridId} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: darkMode ? '#1d1d1d' : '#fff' }}>
<div className={gridId} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent, backdropFilter: blur > 0 ? `blur(${blur}px)` : undefined }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
@@ -1337,7 +1363,12 @@ const DataGrid: React.FC<DataGridProps> = ({
{/* Filter Panel */}
{showFilter && (
<div style={{ padding: '8px', background: darkMode ? '#141414' : '#f5f5f5', borderBottom: darkMode ? '1px solid #303030' : '1px solid #eee' }}>
<div style={{
padding: '8px',
margin: '4px 8px 0 8px',
borderRadius: '8px',
background: bgFilter,
}}>
{filterConditions.map(cond => (
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start' }}>
<Select
@@ -1528,19 +1559,20 @@ const DataGrid: React.FC<DataGridProps> = ({
</DataContext.Provider>
</Form>
{/* Cell Context Menu */}
{cellContextMenu.visible && (
{/* Cell Context Menu - 使用 Portal 渲染到 body避免 backdropFilter 影响 fixed 定位 */}
{cellContextMenu.visible && createPortal(
<div
style={{
position: 'fixed',
left: cellContextMenu.x,
top: cellContextMenu.y,
zIndex: 10000,
background: darkMode ? '#1f1f1f' : '#fff',
background: bgContextMenu,
backdropFilter: blur > 0 ? `blur(${blur}px)` : undefined,
border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
minWidth: 120,
minWidth: 160,
color: darkMode ? '#fff' : 'rgba(0, 0, 0, 0.88)'
}}
onClick={(e) => e.stopPropagation()}
@@ -1557,12 +1589,127 @@ const DataGrid: React.FC<DataGridProps> = ({
>
NULL
</div>
</div>
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
INSERT
</div>
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleCopyJson(cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
JSON
</div>
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleCopyCsv(cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
CSV
</div>
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) {
const records = getTargets(cellContextMenu.record);
const lines = records.map((r: any) => {
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r;
return `| ${Object.values(vals).join(' | ')} |`;
});
copyToClipboard(lines.join('\n'));
}
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
Markdown
</div>
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleExportSelected('csv', cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
CSV
</div>
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleExportSelected('xlsx', cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
Excel
</div>
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleExportSelected('json', cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
JSON
</div>
</div>,
document.body
)}
</div>
{pagination && (
<div style={{ padding: '8px', borderTop: darkMode ? '1px solid #303030' : '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: darkMode ? '#1d1d1d' : '#fff' }}>
<div style={{ padding: '8px', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
@@ -1581,10 +1728,16 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
<style>{`
.${gridId} .row-added td { background-color: ${darkMode ? '#162b16' : '#f6ffed'} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; }
.${gridId} .row-modified td { background-color: ${darkMode ? '#162238' : '#e6f7ff'} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; }
.${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${darkMode ? '#1f3d1f' : '#d9f7be'} !important; }
.${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${darkMode ? '#1d355e' : '#bae7ff'} !important; }
.${gridId} .ant-table { background: transparent !important; }
.${gridId} .ant-table-container { background: transparent !important; border: none !important; }
.${gridId} .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
.${gridId} .ant-table-thead > tr > th::before { display: none !important; }
.${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; }
.${gridId} .row-added td { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; }
.${gridId} .row-modified td { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; }
.${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${rowAddedHover} !important; }
.${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${rowModHover} !important; }
`}</style>
{/* Ghost Resize Line for Columns */}

View File

@@ -13,8 +13,21 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
const sqlLogs = useStore(state => state.sqlLogs);
const clearSqlLogs = useStore(state => state.clearSqlLogs);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
// Background Helper
const getBg = (darkHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${appearance.opacity ?? 0.95})`;
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}, ${appearance.opacity ?? 0.95})`;
};
const bgMain = getBg('#1f1f1f');
const bgToolbar = getBg('#2a2a2a');
const columns = [
{
title: 'Time',
@@ -54,8 +67,9 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
return (
<div style={{
height,
borderTop: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
background: darkMode ? '#1f1f1f' : '#fff',
borderTop: 'none',
background: bgMain,
backdropFilter: `blur(${appearance.blur ?? 0}px)`,
display: 'flex',
flexDirection: 'column',
position: 'relative',
@@ -78,11 +92,10 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
{/* Toolbar */}
<div style={{
padding: '4px 8px',
borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
borderBottom: 'none',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: darkMode ? '#2a2a2a' : '#fafafa',
height: 32
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>

View File

@@ -42,8 +42,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
const tablesRef = useRef<string[]>([]); // Store tables for autocomplete
const allColumnsRef = useRef<{tableName: string, name: string, type: string}[]>([]); // Store all columns
const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db)
const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db)
const visibleDbsRef = useRef<string[]>([]); // Store visible databases for cross-db intellisense
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
@@ -81,9 +82,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const fetchDbs = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) return;
const config = {
...conn.config,
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
@@ -93,27 +94,41 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const res = await DBGetDatabases(config as any);
if (res.success && Array.isArray(res.data)) {
const dbs = res.data.map((row: any) => row.Database || row.database);
let dbs = res.data.map((row: any) => row.Database || row.database);
// 过滤只显示 includeDatabases 中配置的数据库
const includeDbs = conn.includeDatabases;
if (includeDbs && includeDbs.length > 0) {
dbs = dbs.filter((db: string) => includeDbs.includes(db));
}
// 存储可见数据库列表用于跨库智能提示
visibleDbsRef.current = dbs;
setDbList(dbs);
if (!currentDbRef.current) {
if (conn.config.database) setCurrentDb(conn.config.database);
if (conn.config.database && dbs.includes(conn.config.database)) setCurrentDb(conn.config.database);
else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
}
} else {
visibleDbsRef.current = [];
setDbList([]);
}
};
fetchDbs();
}, [currentConnectionId, connections]);
// Fetch Metadata for Autocomplete
// Fetch Metadata for Autocomplete (Cross-database)
useEffect(() => {
const fetchMetadata = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn || !currentDb) return;
if (!conn) return;
const config = {
...conn.config,
const visibleDbs = visibleDbsRef.current;
if (!visibleDbs || visibleDbs.length === 0) return;
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
@@ -121,25 +136,39 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const resTables = await DBGetTables(config as any, currentDb);
if (resTables.success && Array.isArray(resTables.data)) {
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
tablesRef.current = tableNames;
} else {
tablesRef.current = [];
}
// 加载所有可见数据库的表
const allTables: {dbName: string, tableName: string}[] = [];
const allColumns: {dbName: string, tableName: string, name: string, type: string}[] = [];
if (config.type === 'mysql' || !config.type) {
const resCols = await DBGetAllColumns(config as any, currentDb);
for (const dbName of visibleDbs) {
// 获取表
const resTables = await DBGetTables(config as any, dbName);
if (resTables.success && Array.isArray(resTables.data)) {
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
tableNames.forEach((tableName: string) => {
allTables.push({ dbName, tableName });
});
}
// 获取列 (所有数据库类型都支持 DBGetAllColumns)
const resCols = await DBGetAllColumns(config as any, dbName);
if (resCols.success && Array.isArray(resCols.data)) {
allColumnsRef.current = resCols.data;
} else {
allColumnsRef.current = [];
resCols.data.forEach((col: any) => {
allColumns.push({
dbName,
tableName: col.tableName,
name: col.name,
type: col.type
});
});
}
}
tablesRef.current = allTables;
allColumnsRef.current = allColumns;
};
fetchMetadata();
}, [currentConnectionId, currentDb, connections]);
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
// Handle Resizing
const handleMouseDown = (e: React.MouseEvent) => {
@@ -242,61 +271,125 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const fullText = model.getValue();
// 1) alias.field completion: when cursor is after "<alias>.<prefix>"
// 获取当前行光标前的内容
const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
// 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列
const threePartMatch = linePrefix.match(/([`"]?[\w]+[`"]?)\.([`"]?[\w]+[`"]?)\.(\w*)$/);
if (threePartMatch) {
const dbPart = stripQuotes(threePartMatch[1]);
const tablePart = stripQuotes(threePartMatch[2]);
const colPrefix = (threePartMatch[3] || '').toLowerCase();
// 在 allColumnsRef 中查找匹配的列
const cols = allColumnsRef.current.filter(c =>
(c.dbName || '').toLowerCase() === dbPart.toLowerCase() &&
(c.tableName || '').toLowerCase() === tablePart.toLowerCase()
);
const filtered = colPrefix
? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix))
: cols;
const suggestions = filtered.map(c => ({
label: c.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: c.name,
detail: `${c.type} (${c.dbName}.${c.tableName})`,
range,
sortText: '0' + c.name
}));
return { suggestions };
}
// 1) 两段式 qualifier.xxx 格式
const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_][\w]*[`"]?)\.(\w*)$/);
if (qualifierMatch) {
const alias = stripQuotes(qualifierMatch[1]);
const colPrefix = (qualifierMatch[2] || '').toLowerCase();
const qualifier = stripQuotes(qualifierMatch[1]);
const prefix = (qualifierMatch[2] || '').toLowerCase();
// 首先检查 qualifier 是否是数据库名(跨库表提示)
const visibleDbs = visibleDbsRef.current;
if (visibleDbs.some(db => db.toLowerCase() === qualifier.toLowerCase())) {
// qualifier 是数据库名,提示该库的表
const tables = tablesRef.current.filter(t =>
(t.dbName || '').toLowerCase() === qualifier.toLowerCase()
);
const filtered = prefix
? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix))
: tables;
const suggestions = filtered.map(t => ({
label: t.tableName,
kind: monaco.languages.CompletionItemKind.Class,
insertText: t.tableName,
detail: `Table (${t.dbName})`,
range,
sortText: '0' + t.tableName
}));
return { suggestions };
}
// 否则检查是否是表别名或表名,提示列
const reserved = new Set([
'where', 'on', 'group', 'order', 'limit', 'having',
'left', 'right', 'inner', 'outer', 'full', 'cross', 'join',
'union', 'except', 'intersect', 'as', 'set', 'values', 'returning',
]);
const aliasMap: Record<string, string> = {};
// Capture table and optional alias, support schema.table
const aliasMap: Record<string, {dbName: string, tableName: string}> = {};
// Capture table and optional alias, support db.table format
const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?[\w]+[`"]?))?/gi;
let m;
while ((m = aliasRegex.exec(fullText)) !== null) {
const tableIdent = normalizeQualifiedName(m[1] || '');
if (!tableIdent) continue;
// 解析 db.table 或 table 格式
const parts = tableIdent.split('.');
let dbName = currentDbRef.current || '';
let tableName = tableIdent;
if (parts.length === 2) {
dbName = parts[0];
tableName = parts[1];
}
const shortTable = getLastPart(tableIdent);
// allow "table." as qualifier too
if (shortTable) aliasMap[shortTable.toLowerCase()] = tableIdent;
// 用表名作为 qualifier
if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName };
const a = stripQuotes(m[2] || '').trim();
if (!a) continue;
const al = a.toLowerCase();
if (reserved.has(al)) continue;
aliasMap[al] = tableIdent;
aliasMap[al] = { dbName, tableName };
}
const tableIdent = aliasMap[alias.toLowerCase()];
if (tableIdent) {
const shortTable = getLastPart(tableIdent);
const tableInfo = aliasMap[qualifier.toLowerCase()];
if (tableInfo) {
// Prefer preloaded MySQL all-columns cache
let cols: { name: string, type?: string, tableName?: string }[] = [];
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[] = [];
if (allColumnsRef.current.length > 0) {
cols = allColumnsRef.current
.filter(c => (c.tableName || '').toLowerCase() === (shortTable || '').toLowerCase())
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName }));
.filter(c =>
(c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() &&
(c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase()
)
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName }));
} else {
const dbCols = await getColumnsByDB(tableIdent);
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: shortTable }));
const dbCols = await getColumnsByDB(tableInfo.tableName);
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName }));
}
const filtered = colPrefix
? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix))
const filtered = prefix
? cols.filter(c => (c.name || '').toLowerCase().startsWith(prefix))
: cols;
const suggestions = filtered.map(c => ({
label: c.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: c.name,
detail: c.type ? `${c.type}${c.tableName ? ` (${c.tableName})` : ''}` : (c.tableName ? `(${c.tableName})` : ''),
detail: c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${c.tableName})` : (c.tableName ? `(${c.tableName})` : ''),
range,
sortText: '0' + c.name
}));
@@ -311,35 +404,72 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
while ((match = tableRegex.exec(fullText)) !== null) {
const t = normalizeQualifiedName(match[1] || '');
if (!t) continue;
foundTables.add(getLastPart(t).toLowerCase());
// 存储完整标识 db.table 或 table
foundTables.add(t.toLowerCase());
}
const currentDatabase = currentDbRef.current || '';
// 相关列提示:匹配 SQL 中引用的表FROM/JOIN 等)
// 权重最高,输入 WHERE 条件时优先显示
const relevantColumns = allColumnsRef.current
.filter(c => foundTables.has((c.tableName || '').toLowerCase()))
.map(c => ({
label: c.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: c.name,
detail: `${c.type} (${c.tableName})`,
.filter(c => {
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
const shortIdent = (c.tableName || '').toLowerCase();
return foundTables.has(fullIdent) || foundTables.has(shortIdent);
})
.map(c => {
// 当前库的表字段优先级更高
const isCurrentDb = (c.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
return {
label: c.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: c.name,
detail: `${c.type} (${c.dbName}.${c.tableName})`,
range,
sortText: isCurrentDb ? '00' + c.name : '01' + c.name // FROM 表字段最优先
};
});
// 表提示:当前库显示表名,其他库显示 db.table 格式
const tableSuggestions = tablesRef.current.map(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText,
detail: `Table (${t.dbName})`,
range,
sortText: '0' + c.name
}));
sortText: isCurrentDb ? '10' + t.tableName : '11' + t.tableName // 表次优先
};
});
// 数据库提示
const dbSuggestions = visibleDbsRef.current.map(db => ({
label: db,
kind: monaco.languages.CompletionItemKind.Module,
insertText: db,
detail: 'Database',
range,
sortText: '20' + db // 数据库最后
}));
// 关键字提示
const keywordSuggestions = ['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
label: k,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: k,
range,
sortText: '30' + k // 关键字权重最低
}));
const suggestions = [
...['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
label: k,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: k,
range
})),
...tablesRef.current.map(t => ({
label: t,
kind: monaco.languages.CompletionItemKind.Class,
insertText: t,
detail: 'Table',
range
})),
...relevantColumns
...relevantColumns, // FROM 表的列最优先
...tableSuggestions, // 表次之
...dbSuggestions, // 数据库
...keywordSuggestions // 关键字最后
];
return { suggestions };
}

View File

@@ -49,8 +49,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const setActiveContext = useStore(state => state.setActiveContext);
const removeConnection = useStore(state => state.removeConnection);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const [treeData, setTreeData] = useState<TreeNode[]>([]);
// Background Helper (Duplicate logic for now, ideally shared)
const getBg = (darkHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${appearance.opacity ?? 0.95})`;
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}, ${appearance.opacity ?? 0.95})`;
};
const bgMain = getBg('#141414');
const [searchValue, setSearchValue] = useState('');
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [autoExpandParent, setAutoExpandParent] = useState(true);
@@ -1217,7 +1229,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</div>
{/* Toolbar for batch operations - always visible */}
<div style={{ padding: '4px 8px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', gap: 4, background: darkMode ? '#141414' : '#fff' }}>
<div style={{ padding: '4px 8px', borderBottom: 'none', display: 'flex', gap: 4 }}>
<Button
size="small"
icon={<CheckSquareOutlined />}

View File

@@ -122,6 +122,9 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
.main-tabs .ant-tabs-nav::before {
border-bottom: none !important;
}
`}</style>
<Tabs
className="main-tabs"

View File

@@ -20,6 +20,7 @@ interface AppState {
activeContext: { connectionId: string; dbName: string } | null;
savedQueries: SavedQuery[];
theme: 'light' | 'dark';
appearance: { opacity: number; blur: number };
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
queryOptions: { maxRows: number };
sqlLogs: SqlLog[];
@@ -41,6 +42,7 @@ interface AppState {
deleteQuery: (id: string) => void;
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
@@ -57,6 +59,7 @@ export const useStore = create<AppState>()(
activeContext: null,
savedQueries: [],
theme: 'light',
appearance: { opacity: 0.95, blur: 0 },
sqlFormatOptions: { keywordCase: 'upper' },
queryOptions: { maxRows: 5000 },
sqlLogs: [],
@@ -126,6 +129,7 @@ export const useStore = create<AppState>()(
deleteQuery: (id) => set((state) => ({ savedQueries: state.savedQueries.filter(q => q.id !== id) })),
setTheme: (theme) => set({ theme }),
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
@@ -134,7 +138,7 @@ export const useStore = create<AppState>()(
}),
{
name: 'lite-db-storage', // name of the item in the storage (must be unique)
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, theme: state.theme, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs
}
)
);

16
main.go
View File

@@ -9,6 +9,7 @@ import (
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/windows"
)
//go:embed all:frontend/dist
@@ -20,19 +21,26 @@ func main() {
// Create application with options
err := wails.Run(&options.App{
Title: "GoNavi",
Width: 1024,
Height: 768,
Title: "GoNavi",
Width: 1024,
Height: 768,
Frameless: true,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0},
OnStartup: application.Startup,
OnShutdown: application.Shutdown,
Bind: []interface{}{
application,
},
Windows: &windows.Options{
WebviewIsTransparent: true,
WindowIsTranslucent: true,
BackdropType: windows.Acrylic,
DisableWindowIcon: false,
DisableFramelessWindowDecorations: false,
},
})
if err != nil {