diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b4a7d40 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +.ace-tool/ diff --git a/frontend/src/App.css b/frontend/src/App.css index 99657b2..913fa7b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 滚动条 */ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4a43c03..de91ad8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, }} > @@ -717,7 +720,7 @@ function App() { /> -
+
{isLogPanelOpen && ( diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 9a85458..ff0fac7 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1056,17 +1056,6 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal - - void; initialVal + {dbType === 'mongodb' && ( + + = ({ tab }) => {
-
+
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 }} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 42cf839..59b461f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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(null); + const [isRenameViewModalOpen, setIsRenameViewModalOpen] = useState(false); + const [renameViewForm] = Form.useForm(); + const [renameViewTarget, setRenameViewTarget] = useState(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> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const query = buildFunctionsMetadataQuery(dialect, dbName); + const rows = await queryMetadataRows(conn, dbName, query); + const seen = new Set(); + 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: , + 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', '表', , tables), buildObjectGroup('views', '视图', , viewNodes), + buildObjectGroup('routines', '函数', , routineNodes), buildObjectGroup('triggers', '触发器', , 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; + 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; + 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) => { 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: , + onClick: () => openCreateView(node) + }, + ]; + } + + // 函数分组节点的右键菜单 + if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') { + return [ + { + key: 'create-function', + label: '新建函数', + icon: , + onClick: () => openCreateRoutine(node, 'FUNCTION') + }, + { + key: 'create-procedure', + label: '新建存储过程', + icon: , + 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: , onClick: () => onDoubleClick(null, node) }, + { + key: 'view-definition', + label: '查看视图定义', + icon: , + onClick: () => openViewDefinition(node) + }, + { type: 'divider' }, + { + key: 'edit-view', + label: '编辑视图', + icon: , + 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: , + onClick: () => { + setRenameViewTarget(node); + renameViewForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.viewName || node.title) }); + setIsRenameViewModalOpen(true); + } + }, + { + key: 'drop-view', + label: '删除视图', + icon: , + 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: , + onClick: () => openRoutineDefinition(node) + }, + { + key: 'edit-routine', + label: '编辑定义', + icon: , + onClick: () => openEditRoutine(node) + }, + { type: 'divider' }, + { + key: 'drop-routine', + label: `删除${typeLabel}`, + icon: , + danger: true, + onClick: () => handleDropRoutine(node) + }, ]; } else if (node.type === 'table') { return [ @@ -1897,6 +2354,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> + { + setIsRenameViewModalOpen(false); + setRenameViewTarget(null); + renameViewForm.resetFields(); + }} + > +
+ + + +
+
+ { @@ -65,6 +66,8 @@ const TabManager: React.FC = () => { content = ; } else if (tab.type === 'trigger') { content = ; + } else if (tab.type === 'view-def' || tab.type === 'routine-def') { + content = ; } const menuItems: MenuProps['items'] = [ diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1671a80..9457771 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index dcb657a..b25c315 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 { diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index f516884..b1471f5 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -40,8 +40,12 @@ export function DownloadUpdate():Promise; export function DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; +export function DropFunction(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; + export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; +export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + export function ExportData(arg1:Array>,arg2:Array,arg3:string,arg4:string):Promise; export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise; @@ -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; +export function RenameView(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; + +export function SetWindowTranslucency(arg1:number,arg2:number):Promise; + export function TestConnection(arg1:connection.ConnectionConfig):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index e7820dc..936bea4 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -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); } diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100644 new mode 100755 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100644 new mode 100755 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100644 new mode 100755 diff --git a/internal/app/app.go b/internal/app/app.go index c6932e5..d54ac2e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -49,6 +49,13 @@ func (a *App) Startup(ctx context.Context) { logger.Infof("应用启动完成") } +// SetWindowTranslucency 动态调整 macOS 窗口透明度。 +// 前端在加载用户外观设置后、以及用户修改外观时调用此方法。 +// opacity=1.0 且 blur=0 时窗口标记为 opaque,GPU 不再持续计算窗口背后的模糊合成。 +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("应用开始关闭,准备释放资源") diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index d66906a..1ba76e0 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -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) diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 90b9d77..bacfc18 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -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 { diff --git a/internal/app/window_translucency_darwin.go b/internal/app/window_translucency_darwin.go index 08ac8bd..851a2e2 100644 --- a/internal/app/window_translucency_darwin.go +++ b/internal/app/window_translucency_darwin.go @@ -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 时窗口标记为 opaque,GPU 不再持续计算窗口背后的模糊效果。 +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 时关闭 NSVisualEffectView(alpha=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)) + } +} diff --git a/internal/app/window_translucency_stub.go b/internal/app/window_translucency_stub.go index 5ffe5ef..0f7f7af 100644 --- a/internal/app/window_translucency_stub.go +++ b/internal/app/window_translucency_stub.go @@ -3,3 +3,5 @@ package app func applyMacWindowTranslucencyFix() {} + +func setMacWindowTranslucency(opacity float64, blur float64) {} diff --git a/internal/db/mongodb_impl.go b/internal/db/mongodb_impl.go index f1b4f35..cdc613f 100644 --- a/internal/db/mongodb_impl.go +++ b/internal/db/mongodb_impl.go @@ -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 {