🔧fix(mongodb): 修复MongoDB查询仅返回一条数据的问题

- queryWithContext 中 find/count 命令改用原生 Collection.Find()和 CountDocuments() API,替代RunCommand 的 firstBatch 模式
- 新增 convertBsonValue 将 ObjectID/bson.M/bson.D/bson.A 转为JSON 友好类型,_id 列自动置首
- DBQuery 增加 MongoDB JSON 命令识别,避免 find 命令误走 Exec 分支

️perf(macos): 动态控制 NSVisualEffectView 降低 MacOS GPU 持续消耗,Windows不受影响

- NSVisualEffectView 启动默认 alpha 由 0.72 改为 0,窗口默认 opaque
- 新增 gonaviSetEffectViewAlpha ObjC 函数支持运行时动态切换
- 新增 SetWindowTranslucency Wails 绑定方法供前端调用
- 启动重试次数由 24 次缩减至 8 次
- opacity=1.0 且 blur=0 时窗口标记 opaque,GPU 不再持续计算模糊合成
- App.tsx 仅保留最外层 Layout 的 backdropFilter,移除 TitleBar/MenuBar/Content/DataGrid/LogPanel 冗余嵌套
- App.css 移除暗色模式全局 text-shadow 减少 compositing 开销
This commit is contained in:
Syngnat
2026-02-10 12:25:34 +08:00
parent 78e35a5be8
commit fa318a9f0e
23 changed files with 1380 additions and 83 deletions

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.ace-tool/

View File

@@ -63,8 +63,8 @@ body {
}
body[data-theme='dark'] {
/* Improve contrast on transparent backgrounds */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
/* 移除全局 text-shadow对每个文本元素增加 GPU compositing 成本,
在透明窗口环境下会显著加剧 GPU 负载 */
}
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */

View File

@@ -11,6 +11,7 @@ import LogPanel from './components/LogPanel';
import { useStore } from './store';
import { SavedConnection } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform } from './utils/appearance';
import { SetWindowTranslucency } from '../wailsjs/go/app/App';
import './App.css';
const { Sider, Content } = Layout;
@@ -29,6 +30,12 @@ function App() {
const blurFilter = blurToFilter(effectiveBlur);
const windowCornerRadius = 14;
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
// 避免 GPU 持续计算窗口背后的模糊合成
useEffect(() => {
SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => {});
}, [appearance.opacity, appearance.blur]);
// Background Helper
const getBg = (darkHex: string, lightHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
@@ -601,8 +608,6 @@ function App() {
alignItems: 'center',
justifyContent: 'space-between',
background: bgMain,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
borderBottom: 'none',
userSelect: 'none',
WebkitAppRegion: 'drag', // Wails drag region
@@ -653,8 +658,6 @@ function App() {
padding: '0 8px',
borderBottom: 'none',
background: bgMain,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
}}
>
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
@@ -717,7 +720,7 @@ function App() {
/>
</Sider>
<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: blurFilter, WebkitBackdropFilter: blurFilter }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
<TabManager />
</div>
{isLogPanelOpen && (

View File

@@ -1056,17 +1056,6 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
<Form.Item name="mongoAuthSource" label="认证库 (authSource)">
<Input placeholder="默认使用 database 或 admin" />
</Form.Item>
<Form.Item name="mongoAuthMechanism" label="验证方式 (authMechanism)">
<Select
allowClear
placeholder="默认由 MongoDB 驱动协商"
options={[
{ value: 'SCRAM-SHA-1', label: 'SCRAM-SHA-1' },
{ value: 'SCRAM-SHA-256', label: 'SCRAM-SHA-256' },
{ value: 'MONGODB-AWS', label: 'MONGODB-AWS' },
]}
/>
</Form.Item>
<Form.Item name="mongoReadPreference" label="读偏好 (readPreference)">
<Select
options={[
@@ -1109,6 +1098,19 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
<Form.Item name="password" label="密码" style={{ flex: 1 }}>
<Input.Password />
</Form.Item>
{dbType === 'mongodb' && (
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ width: 160 }}>
<Select
allowClear
placeholder="自动协商"
options={[
{ value: 'SCRAM-SHA-1', label: 'SCRAM-SHA-1' },
{ value: 'SCRAM-SHA-256', label: 'SCRAM-SHA-256' },
{ value: 'MONGODB-AWS', label: 'MONGODB-AWS' },
]}
/>
</Form.Item>
)}
</div>
)}

View File

@@ -9,7 +9,7 @@ import { useStore } from '../store';
import { v4 as uuidv4 } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, type FilterCondition } from '../utils/sql';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance';
import { normalizeOpacityForPlatform } from '../utils/appearance';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -135,7 +135,10 @@ const INLINE_EDIT_MAX_CHARS = 2000;
const shouldOpenModalEditor = (val: any): boolean => {
if (val === null || val === undefined) return false;
if (typeof val === 'string') {
return val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n');
if (val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n')) return true;
const trimmed = val.trimStart();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return true;
return false;
}
if (typeof val === 'object') {
return true;
@@ -451,8 +454,6 @@ const DataGrid: React.FC<DataGridProps> = ({
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const blur = normalizeBlurForPlatform(appearance.blur);
const blurFilter = blurToFilter(blur);
const selectionColumnWidth = 46;
// Background Helper
@@ -1732,7 +1733,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const enableVirtual = mergedDisplayData.length >= 200;
return (
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''}`} ref={containerRef} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent, backdropFilter: blurFilter, WebkitBackdropFilter: blurFilter }}>
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''}`} ref={containerRef} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
@@ -1960,7 +1961,6 @@ const DataGrid: React.FC<DataGridProps> = ({
open={cellEditorOpen}
onCancel={closeCellEditor}
width={960}
destroyOnHidden
maskClosable={false}
footer={[
<Button key="format" onClick={handleFormatJsonInEditor} disabled={!cellEditorIsJson}>
@@ -1973,23 +1973,21 @@ const DataGrid: React.FC<DataGridProps> = ({
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
{cellEditorMeta ? `${tableName || ''}${tableName ? '.' : ''}${cellEditorMeta.dataIndex}` : ''}
</div>
{cellEditorOpen && (
<Editor
height="56vh"
language={cellEditorIsJson ? "json" : "plaintext"}
theme={darkMode ? "vs-dark" : "light"}
value={cellEditorValue}
onChange={(val) => setCellEditorValue(val || '')}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
fontSize: 14,
tabSize: 2,
automaticLayout: true,
}}
/>
)}
<Editor
height="56vh"
language={cellEditorIsJson ? "json" : "plaintext"}
theme={darkMode ? "transparent-dark" : "transparent-light"}
value={cellEditorValue}
onChange={(val) => setCellEditorValue(val || '')}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
fontSize: 14,
tabSize: 2,
automaticLayout: true,
}}
/>
</Modal>
{/* 批量编辑弹窗 */}
@@ -2063,8 +2061,6 @@ const DataGrid: React.FC<DataGridProps> = ({
top: cellContextMenu.y,
zIndex: 10000,
background: bgContextMenu,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',

View File

@@ -0,0 +1,282 @@
import React, { useState, useEffect } from 'react';
import Editor from '@monaco-editor/react';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
interface DefinitionViewerProps {
tab: TabData;
}
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [definition, setDefinition] = useState<string>('');
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const getMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
return String(conn?.config?.driver || '').trim().toLowerCase();
}
if (type === 'mariadb') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const parseSchemaAndName = (fullName: string): { schema: string; name: string } => {
const raw = String(fullName || '').trim();
const idx = raw.lastIndexOf('.');
if (idx > 0 && idx < raw.length - 1) {
return { schema: raw.substring(0, idx), name: raw.substring(idx + 1) };
}
return { schema: '', name: raw };
};
const buildShowViewQuery = (dialect: string, viewName: string, dbName: string): string => {
const { schema, name } = parseSchemaAndName(viewName);
const safeName = escapeSQLLiteral(name);
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
return `SHOW CREATE VIEW \`${name.replace(/`/g, '``')}\``;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
const schemaRef = schema || 'public';
return `SELECT pg_get_viewdef('${escapeSQLLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`;
}
case 'sqlserver':
return `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(viewName)}')) AS view_definition`;
case 'oracle':
case 'dm':
if (schema) {
return `SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${escapeSQLLiteral(schema).toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`;
}
if (safeDbName) {
return `SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`;
}
return `SELECT TEXT AS view_definition FROM USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`;
case 'sqlite':
return `SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${safeName}'`;
default:
return `-- 暂不支持该数据库类型的视图定义查看`;
}
};
const buildShowRoutineQuery = (dialect: string, routineName: string, routineType: string, dbName: string): string => {
const { schema, name } = parseSchemaAndName(routineName);
const safeName = escapeSQLLiteral(name);
const safeDbName = escapeSQLLiteral(dbName);
const upperType = (routineType || 'FUNCTION').toUpperCase();
switch (dialect) {
case 'mysql':
return `SHOW CREATE ${upperType} \`${name.replace(/`/g, '``')}\``;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
const schemaRef = schema || 'public';
return `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${safeName}' LIMIT 1`;
}
case 'sqlserver':
return `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(routineName)}')) AS routine_definition`;
case 'oracle':
case 'dm': {
const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : '');
if (owner) {
return `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`;
}
return `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`;
}
case 'sqlite':
return `-- SQLite 不支持存储函数/存储过程`;
default:
return `-- 暂不支持该数据库类型的函数/存储过程定义查看`;
}
};
const extractViewDefinition = (dialect: string, data: any[]): string => {
if (!data || data.length === 0) return '-- 未找到视图定义';
const row = data[0];
switch (dialect) {
case 'mysql': {
const keys = Object.keys(row);
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
if (sqlKey) return row[sqlKey];
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE') && val.toUpperCase().includes('VIEW')) {
return val;
}
}
return JSON.stringify(row, null, 2);
}
case 'oracle':
case 'dm':
return row.view_definition || row.VIEW_DEFINITION || row.text || row.TEXT || Object.values(row)[0] || '';
default:
return row.view_definition || row.VIEW_DEFINITION || row.sql || row.SQL || Object.values(row)[0] || '';
}
};
const extractRoutineDefinition = (dialect: string, data: any[]): string => {
if (!data || data.length === 0) return '-- 未找到函数/存储过程定义';
switch (dialect) {
case 'mysql': {
const row = data[0];
const keys = Object.keys(row);
const sqlKey = keys.find(k => k.toLowerCase().includes('create function') || k.toLowerCase().includes('create procedure'));
if (sqlKey) return row[sqlKey];
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('FUNCTION') || val.toUpperCase().includes('PROCEDURE'))) {
return val;
}
}
return JSON.stringify(row, null, 2);
}
case 'oracle':
case 'dm': {
// Oracle/DM ALL_SOURCE returns multiple rows, one per line
return data.map(row => row.text || row.TEXT || Object.values(row)[0] || '').join('');
}
default: {
const row = data[0];
return row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row)[0] || '';
}
}
};
useEffect(() => {
const loadDefinition = async () => {
setLoading(true);
setError(null);
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
setError('未找到数据库连接');
setLoading(false);
return;
}
const dbName = tab.dbName || '';
const dialect = getMetadataDialect(conn);
let query: string;
let extractFn: (dialect: string, data: any[]) => string;
if (tab.type === 'view-def') {
const viewName = tab.viewName || '';
if (!viewName) {
setError('视图名称为空');
setLoading(false);
return;
}
query = buildShowViewQuery(dialect, viewName, dbName);
extractFn = extractViewDefinition;
} else {
const routineName = tab.routineName || '';
const routineType = tab.routineType || 'FUNCTION';
if (!routineName) {
setError('函数/存储过程名称为空');
setLoading(false);
return;
}
query = buildShowRoutineQuery(dialect, routineName, routineType, dbName);
extractFn = extractRoutineDefinition;
}
if (query.startsWith('--')) {
setDefinition(query);
setLoading(false);
return;
}
try {
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || '',
database: conn.config.database || '',
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
};
const result = await DBQuery(config as any, dbName, query);
if (result.success && Array.isArray(result.data)) {
const def = extractFn(dialect, result.data);
setDefinition(def);
} else {
setError(result.message || '查询定义失败');
}
} catch (e: any) {
setError('查询定义失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
};
loadDefinition();
}, [tab.connectionId, tab.dbName, tab.viewName, tab.routineName, tab.routineType, tab.type, connections]);
const objectLabel = tab.type === 'view-def' ? '视图' : '函数/存储过程';
const objectName = tab.type === 'view-def' ? tab.viewName : tab.routineName;
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin tip={`加载${objectLabel}定义...`} />
</div>
);
}
if (error) {
return (
<div style={{ padding: 16 }}>
<Alert type="error" message="加载失败" description={error} showIcon />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 16px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0' }}>
<strong>{objectLabel}: </strong>{objectName}
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>: {tab.dbName}</span>}
{tab.routineType && <span style={{ marginLeft: 16, color: '#888' }}>: {tab.routineType}</span>}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<Editor
height="100%"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={definition}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
</div>
);
};
export default DefinitionViewer;

View File

@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
import { Table, Tag, Button, Tooltip } from 'antd';
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance';
import { normalizeOpacityForPlatform } from '../utils/appearance';
interface LogPanelProps {
height: number;
@@ -17,7 +17,6 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const blur = normalizeBlurForPlatform(appearance.blur);
// Background Helper
const getBg = (darkHex: string) => {
@@ -30,7 +29,6 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
};
const bgMain = getBg('#1f1f1f');
const bgToolbar = getBg('#2a2a2a');
const blurFilter = blurToFilter(blur);
const columns = [
{
@@ -73,8 +71,6 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
height,
borderTop: 'none',
background: bgMain,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
display: 'flex',
flexDirection: 'column',
position: 'relative',

View File

@@ -196,6 +196,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
editorRef.current = editor;
monacoRef.current = monaco;
// 应用透明主题(主题已在 main.tsx 全局注册)
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.'],
provideCompletionItems: async (model: any, position: any) => {
@@ -1211,7 +1214,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
transition: none !important;
}
`}</style>
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<div style={{ padding: '8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<Select
style={{ width: 150 }}
placeholder="选择连接"
@@ -1265,11 +1268,11 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
</Button.Group>
</div>
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
<div style={{ height: editorHeight, minHeight: '100px' }}>
<Editor
height="100%"
defaultLanguage="sql"
theme={darkMode ? "vs-dark" : "light"}
theme={darkMode ? "transparent-dark" : "transparent-light"}
value={query}
onChange={(val) => setQuery(val || '')}
onMount={handleEditorDidMount}
@@ -1287,7 +1290,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
style={{
height: '5px',
cursor: 'row-resize',
background: darkMode ? '#333' : '#f0f0f0',
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
flexShrink: 0,
zIndex: 10
}}

View File

@@ -25,11 +25,12 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
DeleteOutlined,
DisconnectOutlined,
CloudOutlined,
CheckSquareOutlined
CheckSquareOutlined,
CodeOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable } from '../../wailsjs/go/app/App';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
import { normalizeOpacityForPlatform } from '../utils/appearance';
const { Search } = Input;
@@ -41,7 +42,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
@@ -109,6 +110,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false);
const [renameTableForm] = Form.useForm();
const [renameTableTarget, setRenameTableTarget] = useState<any>(null);
const [isRenameViewModalOpen, setIsRenameViewModalOpen] = useState(false);
const [renameViewForm] = Form.useForm();
const [renameViewTarget, setRenameViewTarget] = useState<any>(null);
// Batch Operations Modal
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
@@ -374,6 +378,54 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return triggers;
};
const buildFunctionsMetadataQuery = (dialect: string, dbName: string): string => {
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
if (!safeDbName) return '';
return `SELECT ROUTINE_NAME AS routine_name, ROUTINE_TYPE AS routine_type FROM information_schema.routines WHERE routine_schema = '${safeDbName}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME`;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%' ORDER BY n.nspname, routine_type, p.proname`;
case 'sqlserver': {
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
return `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name`;
}
case 'oracle':
case 'dm': {
if (!safeDbName) {
return `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME`;
}
return `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME`;
}
default:
return '';
}
};
const loadFunctions = async (conn: any, dbName: string): Promise<Array<{ displayName: string; routineName: string; routineType: string }>> => {
const dialect = getMetadataDialect(conn as SavedConnection);
const query = buildFunctionsMetadataQuery(dialect, dbName);
const rows = await queryMetadataRows(conn, dbName, query);
const seen = new Set<string>();
const routines: Array<{ displayName: string; routineName: string; routineType: string }> = [];
rows.forEach((row) => {
const routineName = getCaseInsensitiveValue(row, ['routine_name', 'object_name', 'proname', 'name']);
if (!routineName) return;
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'owner']);
const routineType = getCaseInsensitiveValue(row, ['routine_type', 'object_type']) || 'FUNCTION';
const fullName = buildQualifiedName(schemaName, routineName);
if (!fullName || seen.has(fullName)) return;
seen.add(fullName);
const typeLabel = routineType.toUpperCase() === 'PROCEDURE' ? 'P' : 'F';
routines.push({ displayName: `${fullName} [${typeLabel}]`, routineName: fullName, routineType: routineType.toUpperCase() });
});
return routines;
};
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
const loadKey = `dbs-${conn.id}`;
@@ -500,9 +552,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
});
const [views, triggers] = await Promise.all([
const [views, triggers, routines] = await Promise.all([
loadViews(conn, conn.dbName),
loadDatabaseTriggers(conn, conn.dbName),
loadFunctions(conn, conn.dbName),
]);
// 获取当前数据库的排序偏好
@@ -534,6 +587,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
// Sort triggers by display name (case-insensitive)
triggers.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
// Sort routines by display name (case-insensitive)
routines.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
const viewNodes: TreeNode[] = views.map((viewName) => ({
title: getSidebarTableDisplayName(conn, viewName),
key: `${conn.id}-${conn.dbName}-view-${viewName}`,
@@ -552,6 +608,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
isLeaf: true,
}));
const routineNodes: TreeNode[] = routines.map((r) => ({
title: r.displayName,
key: `${conn.id}-${conn.dbName}-routine-${r.routineName}`,
icon: <CodeOutlined />,
type: 'routine',
dataRef: { ...conn, routineName: r.routineName, routineType: r.routineType },
isLeaf: true,
}));
const buildObjectGroup = (groupKey: string, groupTitle: string, groupIcon: React.ReactNode, children: TreeNode[]): TreeNode => ({
title: `${groupTitle} (${children.length})`,
key: `${key}-${groupKey}`,
@@ -565,6 +630,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const groupedNodes: TreeNode[] = [
buildObjectGroup('tables', '表', <TableOutlined />, tables),
buildObjectGroup('views', '视图', <EyeOutlined />, viewNodes),
buildObjectGroup('routines', '函数', <CodeOutlined />, routineNodes),
buildObjectGroup('triggers', '触发器', <FunctionOutlined />, triggerNodes),
];
@@ -675,7 +741,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: title });
} else if (type === 'table') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'view' || type === 'db-trigger') {
} else if (type === 'view' || type === 'db-trigger' || type === 'routine') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
@@ -751,6 +817,19 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
triggerName
});
return;
} else if (node.type === 'routine') {
const { routineName, routineType, dbName, id } = node.dataRef;
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
addTab({
id: `routine-def-${node.key}`,
title: `${typeLabel}: ${routineName}`,
type: 'routine-def',
connectionId: id,
dbName,
routineName,
routineType
});
return;
}
const key = node.key;
@@ -1326,6 +1405,298 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
// --- 视图操作 ---
const openViewDefinition = (node: any) => {
const { viewName, dbName, id } = node.dataRef;
addTab({
id: `view-def-${id}-${dbName}-${viewName}`,
title: `视图: ${viewName}`,
type: 'view-def',
connectionId: id,
dbName,
viewName,
});
};
const openEditView = async (node: any) => {
const conn = node.dataRef;
const { viewName, dbName, id } = conn;
// 获取视图定义后打开查询编辑器
const dialect = getMetadataDialect(conn as SavedConnection);
let template = `-- 编辑视图 ${viewName}\n-- 请修改后执行\nCREATE OR REPLACE VIEW ${viewName} AS\nSELECT * FROM your_table;`;
try {
const config = buildRuntimeConfig(conn, dbName);
let query = '';
switch (dialect) {
case 'mysql':
query = `SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': {
const parts = viewName.split('.');
const schema = parts.length > 1 ? parts[0] : 'public';
const name = parts.length > 1 ? parts[1] : viewName;
query = `SELECT pg_get_viewdef('${escapeSQLLiteral(schema)}.${escapeSQLLiteral(name)}'::regclass, true) AS view_definition`;
break;
}
case 'sqlserver':
query = `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(viewName)}')) AS view_definition`;
break;
case 'sqlite':
query = `SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${escapeSQLLiteral(viewName)}'`;
break;
}
if (query) {
const result = await DBQuery(config as any, dbName, query);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
const row = result.data[0] as Record<string, any>;
const def = row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
if (def) {
template = `-- 编辑视图 ${viewName}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
}
}
}
} catch { /* 降级使用模板 */ }
addTab({
id: `query-edit-view-${Date.now()}`,
title: `编辑视图: ${viewName}`,
type: 'query',
connectionId: id,
dbName,
query: template
});
};
const openCreateView = (node: any) => {
const conn = node.dataRef;
const { dbName, id } = conn;
const dialect = getMetadataDialect(conn as SavedConnection);
let template: string;
switch (dialect) {
case 'mysql':
template = `CREATE VIEW \`view_name\` AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase':
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'sqlserver':
template = `CREATE VIEW dbo.view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'oracle': case 'dm':
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'sqlite':
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
default:
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
}
addTab({
id: `query-create-view-${Date.now()}`,
title: `新建视图`,
type: 'query',
connectionId: id,
dbName,
query: template
});
};
const handleDropView = (node: any) => {
const conn = node.dataRef;
const viewName = String(conn.viewName || '').trim();
if (!viewName) return;
Modal.confirm({
title: '确认删除视图',
content: `确定删除视图 "${viewName}" 吗?该操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropView(config as any, conn.dbName, viewName);
if (res.success) {
message.success("视图删除成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
} else {
message.error("删除失败: " + res.message);
}
}
});
};
const handleRenameView = async () => {
if (!renameViewTarget) return;
try {
const values = await renameViewForm.validateFields();
const conn = renameViewTarget.dataRef;
const oldViewName = String(conn.viewName || '').trim();
const newViewName = String(values.newName || '').trim();
if (!oldViewName || !newViewName) {
message.error("视图名称不能为空");
return;
}
if (extractObjectName(oldViewName) === newViewName || oldViewName === newViewName) {
message.warning("新旧视图名相同,无需修改");
return;
}
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await RenameView(config as any, conn.dbName, oldViewName, newViewName);
if (res.success) {
message.success("视图重命名成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
setIsRenameViewModalOpen(false);
setRenameViewTarget(null);
renameViewForm.resetFields();
} else {
message.error("重命名失败: " + res.message);
}
} catch (e) {
// Validate failed
}
};
// --- 函数/存储过程操作 ---
const openRoutineDefinition = (node: any) => {
const { routineName, routineType, dbName, id } = node.dataRef;
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
addTab({
id: `routine-def-${id}-${dbName}-${routineName}`,
title: `${typeLabel}: ${routineName}`,
type: 'routine-def',
connectionId: id,
dbName,
routineName,
routineType
});
};
const openEditRoutine = async (node: any) => {
const conn = node.dataRef;
const { routineName, routineType, dbName, id } = conn;
const dialect = getMetadataDialect(conn as SavedConnection);
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
let template = `-- 编辑${typeLabel} ${routineName}`;
try {
const config = buildRuntimeConfig(conn, dbName);
let query = '';
const parts = routineName.split('.');
const name = parts.length > 1 ? parts[1] : routineName;
const schema = parts.length > 1 ? parts[0] : '';
switch (dialect) {
case 'mysql':
query = `SHOW CREATE ${routineType} \`${name.replace(/`/g, '``')}\``;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': {
const schemaRef = schema || 'public';
query = `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${escapeSQLLiteral(name)}' LIMIT 1`;
break;
}
case 'sqlserver':
query = `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(routineName)}')) AS routine_definition`;
break;
case 'oracle': case 'dm': {
const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : '';
if (owner) {
query = `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`;
} else {
query = `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`;
}
break;
}
}
if (query) {
const result = await DBQuery(config as any, dbName, query);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
if (dialect === 'oracle' || dialect === 'dm') {
const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join('');
if (lines) template = `-- 编辑${typeLabel} ${routineName}\nCREATE OR REPLACE ${lines}`;
} else {
const row = result.data[0] as Record<string, any>;
const def = row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
if (def) template = `-- 编辑${typeLabel} ${routineName}\n${def}`;
}
}
}
} catch { /* 降级使用模板 */ }
addTab({
id: `query-edit-routine-${Date.now()}`,
title: `编辑${typeLabel}: ${routineName}`,
type: 'query',
connectionId: id,
dbName,
query: template
});
};
const openCreateRoutine = (node: any, type: 'FUNCTION' | 'PROCEDURE') => {
const conn = node.dataRef;
const { dbName, id } = conn;
const dialect = getMetadataDialect(conn as SavedConnection);
const isProc = type === 'PROCEDURE';
let template: string;
switch (dialect) {
case 'mysql':
template = isProc
? `DELIMITER $$\nCREATE PROCEDURE proc_name(IN param1 INT)\nBEGIN\n SELECT * FROM table_name WHERE id = param1;\nEND$$\nDELIMITER ;`
: `DELIMITER $$\nCREATE FUNCTION func_name(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN param1 * 2;\nEND$$\nDELIMITER ;`;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase':
template = isProc
? `CREATE OR REPLACE PROCEDURE proc_name(param1 integer)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- procedure body\nEND;\n$$;`
: `CREATE OR REPLACE FUNCTION func_name(param1 integer)\nRETURNS integer\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RETURN param1 * 2;\nEND;\n$$;`;
break;
case 'sqlserver':
template = isProc
? `CREATE PROCEDURE dbo.proc_name\n @param1 INT\nAS\nBEGIN\n SELECT * FROM table_name WHERE id = @param1;\nEND;`
: `CREATE FUNCTION dbo.func_name(@param1 INT)\nRETURNS INT\nAS\nBEGIN\n RETURN @param1 * 2;\nEND;`;
break;
case 'oracle': case 'dm':
template = isProc
? `CREATE OR REPLACE PROCEDURE proc_name(param1 IN NUMBER)\nIS\nBEGIN\n -- procedure body\n NULL;\nEND;`
: `CREATE OR REPLACE FUNCTION func_name(param1 IN NUMBER)\nRETURN NUMBER\nIS\nBEGIN\n RETURN param1 * 2;\nEND;`;
break;
default:
template = isProc
? `CREATE PROCEDURE proc_name()\nBEGIN\n -- procedure body\nEND;`
: `CREATE FUNCTION func_name()\nRETURNS INTEGER\nBEGIN\n RETURN 0;\nEND;`;
}
addTab({
id: `query-create-routine-${Date.now()}`,
title: isProc ? '新建存储过程' : '新建函数',
type: 'query',
connectionId: id,
dbName,
query: template
});
};
const handleDropRoutine = (node: any) => {
const conn = node.dataRef;
const routineName = String(conn.routineName || '').trim();
const routineType = String(conn.routineType || 'FUNCTION').trim();
if (!routineName) return;
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
Modal.confirm({
title: `确认删除${typeLabel}`,
content: `确定删除${typeLabel} "${routineName}" 吗?该操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropFunction(config as any, conn.dbName, routineName, routineType);
if (res.success) {
message.success(`${typeLabel}删除成功`);
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
} else {
message.error("删除失败: " + res.message);
}
}
});
};
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
setSearchValue(value);
@@ -1394,6 +1765,36 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
];
}
// 视图分组节点的右键菜单
if (node.type === 'object-group' && node.dataRef?.groupKey === 'views') {
return [
{
key: 'create-view',
label: '新建视图',
icon: <PlusOutlined />,
onClick: () => openCreateView(node)
},
];
}
// 函数分组节点的右键菜单
if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') {
return [
{
key: 'create-function',
label: '新建函数',
icon: <PlusOutlined />,
onClick: () => openCreateRoutine(node, 'FUNCTION')
},
{
key: 'create-procedure',
label: '新建存储过程',
icon: <PlusOutlined />,
onClick: () => openCreateRoutine(node, 'PROCEDURE')
},
];
}
if (node.type === 'connection') {
// Redis connection menu
if (isRedis) {
@@ -1666,6 +2067,19 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
icon: <EyeOutlined />,
onClick: () => onDoubleClick(null, node)
},
{
key: 'view-definition',
label: '查看视图定义',
icon: <CodeOutlined />,
onClick: () => openViewDefinition(node)
},
{ type: 'divider' },
{
key: 'edit-view',
label: '编辑视图',
icon: <EditOutlined />,
onClick: () => openEditView(node)
},
{
key: 'new-query',
label: '新建查询',
@@ -1680,7 +2094,50 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
query: ''
});
}
}
},
{ type: 'divider' },
{
key: 'rename-view',
label: '重命名视图',
icon: <EditOutlined />,
onClick: () => {
setRenameViewTarget(node);
renameViewForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.viewName || node.title) });
setIsRenameViewModalOpen(true);
}
},
{
key: 'drop-view',
label: '删除视图',
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDropView(node)
},
];
} else if (node.type === 'routine') {
const routineType = node.dataRef?.routineType || 'FUNCTION';
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
return [
{
key: 'view-routine-def',
label: '查看定义',
icon: <CodeOutlined />,
onClick: () => openRoutineDefinition(node)
},
{
key: 'edit-routine',
label: '编辑定义',
icon: <EditOutlined />,
onClick: () => openEditRoutine(node)
},
{ type: 'divider' },
{
key: 'drop-routine',
label: `删除${typeLabel}`,
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDropRoutine(node)
},
];
} else if (node.type === 'table') {
return [
@@ -1897,6 +2354,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</Form>
</Modal>
<Modal
title={`重命名视图${renameViewTarget?.dataRef?.viewName ? ` (${renameViewTarget.dataRef.viewName})` : ''}`}
open={isRenameViewModalOpen}
onOk={handleRenameView}
onCancel={() => {
setIsRenameViewModalOpen(false);
setRenameViewTarget(null);
renameViewForm.resetFields();
}}
>
<Form form={renameViewForm} layout="vertical">
<Form.Item name="newName" label="新视图名" rules={[{ required: true, message: '请输入新视图名' }]}>
<Input />
</Form.Item>
</Form>
</Modal>
<Modal
title="批量操作表"
open={isBatchModalOpen}

View File

@@ -8,6 +8,7 @@ import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import type { TabData } from '../types';
const detectConnectionEnvLabel = (connectionName: string): string | null => {
@@ -65,6 +66,8 @@ const TabManager: React.FC = () => {
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'trigger') {
content = <TriggerViewer tab={tab} />;
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
content = <DefinitionViewer tab={tab} />;
}
const menuItems: MenuProps['items'] = [

View File

@@ -3,6 +3,22 @@ import ReactDOM from 'react-dom/client'
import App from './App'
// import './index.css' // Optional global styles
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'
loader.config({ monaco })
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000' }
})
monaco.editor.defineTheme('transparent-light', {
base: 'vs', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000' }
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

@@ -84,7 +84,7 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def';
connectionId: string;
dbName?: string;
tableName?: string;
@@ -93,6 +93,9 @@ export interface TabData {
readOnly?: boolean;
redisDB?: number; // Redis database index for redis tabs
triggerName?: string; // Trigger name for trigger tabs
viewName?: string; // View name for view definition tabs
routineName?: string; // Routine name for function/procedure definition tabs
routineType?: string; // 'FUNCTION' or 'PROCEDURE'
}
export interface DatabaseNode {

View File

@@ -40,8 +40,12 @@ export function DownloadUpdate():Promise<connection.QueryResult>;
export function DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function DropFunction(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
@@ -122,4 +126,8 @@ export function RenameDatabase(arg1:connection.ConnectionConfig,arg2:string,arg3
export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function RenameView(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;

View File

@@ -74,10 +74,18 @@ export function DropDatabase(arg1, arg2) {
return window['go']['app']['App']['DropDatabase'](arg1, arg2);
}
export function DropFunction(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DropFunction'](arg1, arg2, arg3, arg4);
}
export function DropTable(arg1, arg2, arg3) {
return window['go']['app']['App']['DropTable'](arg1, arg2, arg3);
}
export function DropView(arg1, arg2, arg3) {
return window['go']['app']['App']['DropView'](arg1, arg2, arg3);
}
export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}
@@ -238,6 +246,14 @@ export function RenameTable(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RenameTable'](arg1, arg2, arg3, arg4);
}
export function RenameView(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RenameView'](arg1, arg2, arg3, arg4);
}
export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}

0
frontend/wailsjs/runtime/package.json Normal file → Executable file
View File

0
frontend/wailsjs/runtime/runtime.d.ts vendored Normal file → Executable file
View File

0
frontend/wailsjs/runtime/runtime.js Normal file → Executable file
View File

View File

@@ -49,6 +49,13 @@ func (a *App) Startup(ctx context.Context) {
logger.Infof("应用启动完成")
}
// SetWindowTranslucency 动态调整 macOS 窗口透明度。
// 前端在加载用户外观设置后、以及用户修改外观时调用此方法。
// opacity=1.0 且 blur=0 时窗口标记为 opaqueGPU 不再持续计算窗口背后的模糊合成。
func (a *App) SetWindowTranslucency(opacity float64, blur float64) {
setMacWindowTranslucency(opacity, blur)
}
// Shutdown is called when the app terminates
func (a *App) Shutdown(ctx context.Context) {
logger.Infof("应用开始关闭,准备释放资源")

View File

@@ -367,7 +367,12 @@ func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query s
defer cancel()
lowerQuery := strings.TrimSpace(strings.ToLower(query))
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") {
isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain")
// MongoDB JSON 命令中的 find/count/aggregate 也属于读查询
if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
isReadQuery = true
}
if isReadQuery {
var data []map[string]interface{}
var columns []string
if q, ok := dbInst.(interface {
@@ -539,6 +544,125 @@ func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, t
return connection.QueryResult{Success: true, Data: triggers}
}
func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewName string) connection.QueryResult {
viewName = strings.TrimSpace(viewName)
if viewName == "" {
return connection.QueryResult{Success: false, Message: "视图名称不能为空"}
}
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除视图", dbType)}
}
schemaName, pureViewName := normalizeSchemaAndTableByType(dbType, dbName, viewName)
if pureViewName == "" {
return connection.QueryResult{Success: false, Message: "视图名称不能为空"}
}
qualifiedView := quoteTableIdentByType(dbType, schemaName, pureViewName)
sql := fmt.Sprintf("DROP VIEW %s", qualifiedView)
runConfig := buildRunConfigForDDL(config, dbType, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if _, err := dbInst.Exec(sql); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "视图删除成功"}
}
func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, routineName string, routineType string) connection.QueryResult {
routineName = strings.TrimSpace(routineName)
routineType = strings.TrimSpace(strings.ToUpper(routineType))
if routineName == "" {
return connection.QueryResult{Success: false, Message: "函数/存储过程名称不能为空"}
}
if routineType != "FUNCTION" && routineType != "PROCEDURE" {
routineType = "FUNCTION"
}
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "postgres", "kingbase", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除函数/存储过程", dbType)}
}
schemaName, pureName := normalizeSchemaAndTableByType(dbType, dbName, routineName)
if pureName == "" {
return connection.QueryResult{Success: false, Message: "函数/存储过程名称不能为空"}
}
qualifiedName := quoteTableIdentByType(dbType, schemaName, pureName)
sql := fmt.Sprintf("DROP %s %s", routineType, qualifiedName)
runConfig := buildRunConfigForDDL(config, dbType, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if _, err := dbInst.Exec(sql); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
label := "函数"
if routineType == "PROCEDURE" {
label = "存储过程"
}
return connection.QueryResult{Success: true, Message: fmt.Sprintf("%s删除成功", label)}
}
func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldName string, newName string) connection.QueryResult {
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return connection.QueryResult{Success: false, Message: "视图名称不能为空"}
}
if strings.EqualFold(oldName, newName) {
return connection.QueryResult{Success: false, Message: "新旧视图名称不能相同"}
}
if strings.Contains(newName, ".") {
return connection.QueryResult{Success: false, Message: "新视图名不能包含 schema 或数据库前缀"}
}
dbType := resolveDDLDBType(config)
schemaName, pureOldName := normalizeSchemaAndTableByType(dbType, dbName, oldName)
if pureOldName == "" {
return connection.QueryResult{Success: false, Message: "旧视图名不能为空"}
}
oldQualified := quoteTableIdentByType(dbType, schemaName, pureOldName)
newQuoted := quoteIdentByType(dbType, newName)
var sql string
switch dbType {
case "mysql", "mariadb":
newQualified := quoteTableIdentByType(dbType, schemaName, newName)
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualified, newQualified)
case "postgres", "kingbase", "highgo", "vastbase":
sql = fmt.Sprintf("ALTER VIEW %s RENAME TO %s", oldQualified, newQuoted)
case "sqlserver":
oldFullName := schemaName + "." + pureOldName
escapedOld := strings.ReplaceAll(oldFullName, "'", "''")
escapedNew := strings.ReplaceAll(newName, "'", "''")
sql = fmt.Sprintf("EXEC sp_rename '%s', '%s'", escapedOld, escapedNew)
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名视图", dbType)}
}
runConfig := buildRunConfigForDDL(config, dbType, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if _, err := dbInst.Exec(sql); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "视图重命名成功"}
}
func (a *App) DBGetAllColumns(config connection.ConnectionConfig, dbName string) connection.QueryResult {
runConfig := normalizeRunConfig(config, dbName)

View File

@@ -224,7 +224,19 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
// 使用版本号命名的工作目录,便于识别和调试
stagedDir := filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, info.LatestVersion))
// 清理可能残留的旧目录(上次下载失败后未清理)
_ = os.RemoveAll(stagedDir)
// Windows 上文件可能被杀毒软件/索引服务占用,需要重试
for retry := 0; retry < 5; retry++ {
err := os.RemoveAll(stagedDir)
if err == nil {
break
}
if retry < 4 {
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
} else {
// 最后一次仍然失败,换一个带时间戳的目录名避免冲突
stagedDir = filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s-%d", stdRuntime.GOOS, info.LatestVersion, time.Now().UnixNano()))
}
}
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", stagedDir)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
@@ -490,11 +502,21 @@ func downloadFileWithHash(url, filePath string, onProgress func(downloaded, tota
return "", fmt.Errorf("下载更新包失败HTTP %d", resp.StatusCode)
}
out, err := os.Create(filePath)
if err != nil {
return "", err
// Windows 上旧文件可能被杀毒软件/索引服务占用,先尝试删除并重试
_ = os.Remove(filePath)
var out *os.File
for retry := 0; retry < 5; retry++ {
out, err = os.Create(filePath)
if err == nil {
break
}
if retry < 4 {
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
}
}
if err != nil {
return "", fmt.Errorf("更新下载失败,文件被占用:%w", err)
}
defer out.Close()
hasher := sha256.New()
total := resp.ContentLength
@@ -508,12 +530,22 @@ func downloadFileWithHash(url, filePath string, onProgress func(downloaded, tota
onProgress(0, total)
}
if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil {
out.Close()
return "", err
}
if onProgress != nil {
onProgress(progressWriter.written, total)
}
// 显式 Sync + Close确保数据落盘且文件句柄释放
if err := out.Sync(); err != nil {
out.Close()
return "", err
}
if err := out.Close(); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
@@ -544,18 +576,13 @@ func buildUpdateInstallLogPath(baseDir string) string {
}
func resolveUpdateWorkspaceDir() string {
exePath, err := os.Executable()
if err != nil {
return ""
}
exePath, _ = filepath.EvalSymlinks(exePath)
if stdRuntime.GOOS == "darwin" {
appPath := detectMacAppPath(exePath)
if appPath != "" {
return filepath.Dir(appPath)
}
}
return filepath.Dir(exePath)
// 使用系统临时目录作为更新工作区,避免以下问题:
// 1. Windows: exe 所在目录可能被杀毒软件/索引服务锁定,或缺少写权限(如 Program Files
// 2. macOS: /Applications 需要管理员权限才能写入
// 3. 运行中的 exe 文件锁与 staging 文件冲突
dir := filepath.Join(os.TempDir(), "gonavi-updates")
_ = os.MkdirAll(dir, 0o755)
return dir
}
func resolveUpdateInstallTarget() string {

View File

@@ -47,14 +47,16 @@ static void gonaviTuneWindowTranslucency(NSWindow *window) {
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
[effectView setBlendingMode:NSVisualEffectBlendingModeBehindWindow];
[effectView setState:NSVisualEffectStateActive];
[effectView setAlphaValue:0.72];
// 默认 alpha=0不可见由前端根据用户外观设置动态启用
[effectView setAlphaValue:0.0];
[effectView setWantsLayer:YES];
[[effectView layer] setCornerRadius:cornerRadius];
[[effectView layer] setMasksToBounds:YES];
}
static void gonaviApplyWindowTranslucencyFix() {
for (int i = 0; i < 24; i++) {
// 启动时应用窗口透明度修复,减少重试次数以降低启动期 GPU 负载
for (int i = 0; i < 8; i++) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(i * 250 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
for (NSWindow *window in [NSApp windows]) {
gonaviTuneWindowTranslucency(window);
@@ -62,9 +64,56 @@ static void gonaviApplyWindowTranslucencyFix() {
});
}
}
// 动态设置 NSVisualEffectView 的透明度和窗口不透明标志。
// alpha <= 0 时窗口标记为 opaqueGPU 不再持续计算窗口背后的模糊效果。
static void gonaviSetEffectViewAlpha(double alpha) {
dispatch_async(dispatch_get_main_queue(), ^{
for (NSWindow *window in [NSApp windows]) {
NSView *contentView = [window contentView];
if (contentView == nil) {
continue;
}
for (NSView *subview in [contentView subviews]) {
if ([subview isKindOfClass:[NSVisualEffectView class]]) {
NSVisualEffectView *effectView = (NSVisualEffectView *)subview;
[effectView setAlphaValue:alpha];
break;
}
}
if (alpha <= 0.01) {
[window setOpaque:YES];
} else {
[window setOpaque:NO];
[window setBackgroundColor:[NSColor clearColor]];
}
}
});
}
*/
import "C"
func applyMacWindowTranslucencyFix() {
C.gonaviApplyWindowTranslucencyFix()
}
// setMacWindowTranslucency 根据用户外观设置动态调整 macOS 窗口透明度。
// opacity=1.0 且 blur=0 时关闭 NSVisualEffectViewalpha=0窗口标记为 opaque
// GPU 不再持续计算窗口背后的模糊合成,显著降低 CPU/GPU 温度。
func setMacWindowTranslucency(opacity float64, blur float64) {
if opacity >= 0.999 && blur <= 0 {
C.gonaviSetEffectViewAlpha(C.double(0.0))
} else {
// 半透明模式NSVisualEffectView alpha 根据透明度动态映射
alpha := (1.0 - opacity) * 1.2
if alpha < 0.3 {
alpha = 0.3
}
if alpha > 0.85 {
alpha = 0.85
}
C.gonaviSetEffectViewAlpha(C.double(alpha))
}
}

View File

@@ -3,3 +3,5 @@
package app
func applyMacWindowTranslucencyFix() {}
func setMacWindowTranslucency(opacity float64, blur float64) {}

View File

@@ -200,13 +200,13 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
uri := fmt.Sprintf("%s://%s", scheme, hostText)
if config.User != "" {
encodedUser := url.PathEscape(config.User)
var userinfo *url.Userinfo
if config.Password != "" {
encodedPass := url.PathEscape(config.Password)
uri = fmt.Sprintf("%s://%s:%s@%s", scheme, encodedUser, encodedPass, hostText)
userinfo = url.UserPassword(config.User, config.Password)
} else {
uri = fmt.Sprintf("%s://%s@%s", scheme, encodedUser, hostText)
userinfo = url.User(config.User)
}
uri = fmt.Sprintf("%s://%s@%s", scheme, userinfo.String(), hostText)
}
path := "/"
@@ -441,6 +441,23 @@ func asMongoBool(raw interface{}) bool {
}
}
func asMongoInt64(raw interface{}) int64 {
switch value := raw.(type) {
case int:
return int64(value)
case int32:
return int64(value)
case int64:
return value
case float32:
return int64(value)
case float64:
return int64(value)
default:
return 0
}
}
func mongoStateByCode(code int) string {
switch code {
case 1:
@@ -613,6 +630,98 @@ func (m *MongoDB) QueryContext(ctx context.Context, query string) ([]map[string]
return m.queryWithContext(ctx, query)
}
// sqlToMongoFind 将前端生成的简单 SQL 转换为 MongoDB find 命令 JSON。
// 支持SELECT * FROM "coll" LIMIT n OFFSET m / SELECT COUNT(*) as total FROM "coll"
func sqlToMongoFind(sql string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(sql))
// SELECT COUNT(*) as total FROM "coll" ...
if strings.HasPrefix(lower, "select count(") {
coll := extractCollectionFromSQL(sql)
if coll == "" {
return "", false
}
return fmt.Sprintf(`{"count":"%s","query":{}}`, coll), true
}
// SELECT * FROM "coll" ... LIMIT n OFFSET m
if !strings.HasPrefix(lower, "select") {
return "", false
}
coll := extractCollectionFromSQL(sql)
if coll == "" {
return "", false
}
limit := int64(0)
skip := int64(0)
// 提取 LIMIT
if idx := strings.Index(lower, "limit "); idx >= 0 {
after := strings.TrimSpace(lower[idx+6:])
parts := strings.Fields(after)
if len(parts) > 0 {
if n, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
limit = n
}
}
}
// 提取 OFFSET
if idx := strings.Index(lower, "offset "); idx >= 0 {
after := strings.TrimSpace(lower[idx+7:])
parts := strings.Fields(after)
if len(parts) > 0 {
if n, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
skip = n
}
}
}
cmd := fmt.Sprintf(`{"find":"%s","filter":{}`, coll)
if limit > 0 {
cmd += fmt.Sprintf(`,"limit":%d`, limit)
}
if skip > 0 {
cmd += fmt.Sprintf(`,"skip":%d`, skip)
}
cmd += "}"
return cmd, true
}
// extractCollectionFromSQL 从 SQL 中提取 FROM 后的 collection 名称。
func extractCollectionFromSQL(sql string) string {
lower := strings.ToLower(sql)
idx := strings.Index(lower, "from ")
if idx < 0 {
return ""
}
after := strings.TrimSpace(sql[idx+5:])
// 去掉引号包裹
var coll string
if len(after) > 0 && after[0] == '"' {
end := strings.Index(after[1:], "\"")
if end < 0 {
return ""
}
coll = after[1 : end+1]
} else if len(after) > 0 && after[0] == '`' {
end := strings.Index(after[1:], "`")
if end < 0 {
return ""
}
coll = after[1 : end+1]
} else {
parts := strings.Fields(after)
if len(parts) == 0 {
return ""
}
coll = parts[0]
}
return strings.TrimSpace(coll)
}
func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if m.client == nil {
return nil, nil, fmt.Errorf("connection not open")
@@ -623,18 +732,44 @@ func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[str
return nil, nil, fmt.Errorf("empty query")
}
// 如果输入是 SQL 语句(前端 DataViewer 统一生成),自动转换为 MongoDB JSON 命令
lowerQuery := strings.ToLower(query)
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") {
if converted, ok := sqlToMongoFind(query); ok {
query = converted
}
}
// Parse JSON command
var cmd bson.D
if err := bson.UnmarshalExtJSON([]byte(query), true, &cmd); err != nil {
return nil, nil, fmt.Errorf("invalid JSON command: %w", err)
}
// 对 find 和 count 命令使用原生 driver API避免 RunCommand 的 firstBatch 限制
if len(cmd) > 0 {
switch cmd[0].Key {
case "find":
return m.execFind(ctx, cmd)
case "count":
return m.execCount(ctx, cmd)
}
}
// 其他命令走 RunCommand
db := m.client.Database(m.database)
var result bson.M
if err := db.RunCommand(ctx, cmd).Decode(&result); err != nil {
return nil, nil, err
}
// Handle COUNT result (e.g. delete/update returns "n")
if n, ok := result["n"]; ok {
if _, hasCursor := result["cursor"]; !hasCursor {
return []map[string]interface{}{{"total": n}}, []string{"total"}, nil
}
}
// Convert result to standard format
data := []map[string]interface{}{{"result": result}}
columns := []string{"result"}
@@ -664,6 +799,156 @@ func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[str
return data, columns, nil
}
// execFind 使用原生 Collection.Find() 执行查询,正确处理游标迭代
func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
var collName string
var filter interface{}
var limit int64
var skip int64
var sortDoc interface{}
var projection interface{}
for _, elem := range cmd {
switch elem.Key {
case "find":
collName = fmt.Sprintf("%v", elem.Value)
case "filter":
filter = elem.Value
case "limit":
limit = asMongoInt64(elem.Value)
case "skip":
skip = asMongoInt64(elem.Value)
case "sort":
sortDoc = elem.Value
case "projection":
projection = elem.Value
}
}
if collName == "" {
return nil, nil, fmt.Errorf("find command missing collection name")
}
if filter == nil {
filter = bson.D{}
}
collection := m.client.Database(m.database).Collection(collName)
opts := options.Find()
if limit > 0 {
opts.SetLimit(limit)
}
if skip > 0 {
opts.SetSkip(skip)
}
if sortDoc != nil {
opts.SetSort(sortDoc)
}
if projection != nil {
opts.SetProjection(projection)
}
cursor, err := collection.Find(ctx, filter, opts)
if err != nil {
return nil, nil, err
}
defer cursor.Close(ctx)
var data []map[string]interface{}
columnSet := make(map[string]bool)
for cursor.Next(ctx) {
var doc bson.M
if err := cursor.Decode(&doc); err != nil {
continue
}
row := make(map[string]interface{})
for k, v := range doc {
row[k] = convertBsonValue(v)
columnSet[k] = true
}
data = append(data, row)
}
if err := cursor.Err(); err != nil {
return nil, nil, err
}
columns := make([]string, 0, len(columnSet))
for k := range columnSet {
columns = append(columns, k)
}
sort.Strings(columns)
// 将 _id 列置首
for i, col := range columns {
if col == "_id" && i > 0 {
columns = append(columns[:i], columns[i+1:]...)
columns = append([]string{"_id"}, columns...)
break
}
}
return data, columns, nil
}
// execCount 使用原生 Collection.CountDocuments() 执行计数
func (m *MongoDB) execCount(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
var collName string
var filter interface{}
for _, elem := range cmd {
switch elem.Key {
case "count":
collName = fmt.Sprintf("%v", elem.Value)
case "query":
filter = elem.Value
}
}
if collName == "" {
return nil, nil, fmt.Errorf("count command missing collection name")
}
if filter == nil {
filter = bson.D{}
}
collection := m.client.Database(m.database).Collection(collName)
n, err := collection.CountDocuments(ctx, filter)
if err != nil {
return nil, nil, err
}
return []map[string]interface{}{{"total": n}}, []string{"total"}, nil
}
// convertBsonValue 将 BSON 特殊类型转换为前端可读的 JSON 友好值
func convertBsonValue(v interface{}) interface{} {
switch val := v.(type) {
case bson.ObjectID:
return val.Hex()
case bson.M:
result := make(map[string]interface{}, len(val))
for k, v2 := range val {
result[k] = convertBsonValue(v2)
}
return result
case bson.D:
result := make(map[string]interface{}, len(val))
for _, elem := range val {
result[elem.Key] = convertBsonValue(elem.Value)
}
return result
case bson.A:
result := make([]interface{}, len(val))
for i, v2 := range val {
result[i] = convertBsonValue(v2)
}
return result
default:
return v
}
}
func (m *MongoDB) Exec(query string) (int64, error) {
_, _, err := m.Query(query)
if err != nil {