diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 27eebdb..d813456 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, import { DatabaseOutlined, TableOutlined, + EyeOutlined, ConsoleSqlOutlined, HddOutlined, FolderOpenOutlined, @@ -28,7 +29,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, } from '@ant-design/icons'; import { useStore } from '../store'; import { SavedConnection } from '../types'; - import { DBGetDatabases, DBGetTables, 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 } from '../../wailsjs/go/app/App'; import { normalizeOpacityForPlatform } from '../utils/appearance'; const { Search } = Input; @@ -40,7 +41,7 @@ interface TreeNode { children?: TreeNode[]; icon?: React.ReactNode; dataRef?: any; - type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db'; + type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db'; } type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; @@ -203,6 +204,161 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return rawName.substring(lastDotIndex + 1); }; + const getMetadataDialect = (conn: SavedConnection | undefined): string => { + const type = String(conn?.config?.type || '').trim().toLowerCase(); + if (type === 'custom') { + return String((conn?.config as any)?.driver || '').trim().toLowerCase(); + } + if (type === 'mariadb') return 'mysql'; + if (type === 'dameng') return 'dm'; + return type; + }; + + const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); + const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; + + const getCaseInsensitiveValue = (row: Record, candidateKeys: string[]): string => { + const keyMap = new Map(); + Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); + for (const key of candidateKeys) { + const value = keyMap.get(key.toLowerCase()); + if (value !== undefined && value !== null) { + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + } + return ''; + }; + + const getFirstRowValue = (row: Record): string => { + for (const value of Object.values(row || {})) { + if (value !== undefined && value !== null) { + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + } + return ''; + }; + + const buildQualifiedName = (schemaName: string, objectName: string): string => { + const schema = String(schemaName || '').trim(); + const name = String(objectName || '').trim(); + if (!name) return ''; + if (!schema) return name; + if (name.includes('.')) return name; + return `${schema}.${name}`; + }; + + const buildViewsMetadataQuery = (dialect: string, dbName: string): string => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + if (!safeDbName) return ''; + return `SELECT TABLE_NAME AS view_name FROM information_schema.views WHERE table_schema = '${safeDbName}' ORDER BY TABLE_NAME`; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg_%' ORDER BY schemaname, viewname`; + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name`; + } + case 'oracle': + case 'dm': { + if (!safeDbName) { + return `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME`; + } + return `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME`; + } + case 'sqlite': + return `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name`; + default: + return ''; + } + }; + + const buildTriggersMetadataQuery = (dialect: string, dbName: string): string => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + if (!safeDbName) return ''; + return `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME`; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg_%' ORDER BY event_object_schema, event_object_table, trigger_name`; + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name`; + } + case 'oracle': + case 'dm': { + if (!safeDbName) { + return `SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME`; + } + return `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME`; + } + case 'sqlite': + return `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name`; + default: + return ''; + } + }; + + const queryMetadataRows = async (conn: any, dbName: string, query: string): Promise[]> => { + if (!query) return []; + try { + const config = buildRuntimeConfig(conn, dbName); + const result = await DBQuery(config as any, dbName, query); + if (!result.success || !Array.isArray(result.data)) return []; + return result.data as Record[]; + } catch { + return []; + } + }; + + const loadViews = async (conn: any, dbName: string): Promise => { + const dialect = getMetadataDialect(conn as SavedConnection); + const query = buildViewsMetadataQuery(dialect, dbName); + const rows = await queryMetadataRows(conn, dbName, query); + const seen = new Set(); + const views: string[] = []; + + rows.forEach((row) => { + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema']); + const viewName = getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) || getFirstRowValue(row); + const fullName = buildQualifiedName(schemaName, viewName); + if (!fullName || seen.has(fullName)) return; + seen.add(fullName); + views.push(fullName); + }); + return views; + }; + + const loadDatabaseTriggers = async (conn: any, dbName: string): Promise> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const query = buildTriggersMetadataQuery(dialect, dbName); + const rows = await queryMetadataRows(conn, dbName, query); + const seen = new Set(); + const triggers: Array<{ displayName: string; triggerName: string; tableName: string }> = []; + + rows.forEach((row) => { + const triggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'name']) || getFirstRowValue(row); + if (!triggerName) return; + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema']); + const tableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name']); + const fullTableName = buildQualifiedName(schemaName, tableName); + const uniqueKey = `${triggerName}@@${fullTableName}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + const displayName = fullTableName ? `${triggerName} (${fullTableName})` : triggerName; + triggers.push({ displayName, triggerName, tableName: fullTableName }); + }); + return triggers; + }; + const loadDatabases = async (node: any) => { const conn = node.dataRef as SavedConnection; const loadKey = `dbs-${conn.id}`; @@ -328,8 +484,56 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> isLeaf: false, }; }); - - setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...tables])); + + const [views, triggers] = await Promise.all([ + loadViews(conn, conn.dbName), + loadDatabaseTriggers(conn, conn.dbName), + ]); + + // Sort tables by display name (case-insensitive) + tables.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); + + // Sort views by name (case-insensitive) + views.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + // Sort triggers by display name (case-insensitive) + triggers.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}`, + icon: , + type: 'view', + dataRef: { ...conn, viewName, tableName: viewName }, + isLeaf: true, + })); + + const triggerNodes: TreeNode[] = triggers.map((trigger) => ({ + title: trigger.displayName, + key: `${conn.id}-${conn.dbName}-trigger-${trigger.triggerName}-${trigger.tableName}`, + icon: , + type: 'db-trigger', + dataRef: { ...conn, triggerName: trigger.triggerName, triggerTableName: trigger.tableName }, + isLeaf: true, + })); + + const buildObjectGroup = (groupKey: string, groupTitle: string, groupIcon: React.ReactNode, children: TreeNode[]): TreeNode => ({ + title: `${groupTitle} (${children.length})`, + key: `${key}-${groupKey}`, + icon: groupIcon, + type: 'object-group', + isLeaf: children.length === 0, + children: children.length > 0 ? children : undefined, + dataRef: { ...conn, dbName: conn.dbName, groupKey } + }); + + const groupedNodes: TreeNode[] = [ + buildObjectGroup('tables', '表', , tables), + buildObjectGroup('views', '视图', , viewNodes), + buildObjectGroup('triggers', '触发器', , triggerNodes), + ]; + + setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes])); } else { setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); message.error({ content: res.message, key: `db-${key}-tables` }); @@ -348,7 +552,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> await loadTables({ key, dataRef }); } else if (type === 'table') { // Expand table to show object categories - const { tableName, dbName, id } = dataRef; const conn = dataRef; const folders: TreeNode[] = [ @@ -437,6 +640,8 @@ 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') { + setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); } else if (type === 'saved-query') { setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); } else if (type === 'redis-db') { @@ -466,6 +671,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> tableName, }); return; + } else if (node.type === 'view') { + const { viewName, dbName, id } = node.dataRef; + addTab({ + id: node.key, + title: viewName, + type: 'table', + connectionId: id, + dbName, + tableName: viewName, + }); + return; } else if (node.type === 'saved-query') { const q = node.dataRef; addTab({ @@ -487,6 +703,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> redisDB: redisDB }); return; + } else if (node.type === 'db-trigger') { + const { triggerName, dbName, id } = node.dataRef; + addTab({ + id: `trigger-${node.key}`, + title: `触发器: ${triggerName}`, + type: 'trigger', + connectionId: id, + dbName, + triggerName + }); + return; } const key = node.key; @@ -1358,6 +1585,30 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> onClick: () => handleRunSQLFile(node) } ]; + } else if (node.type === 'view') { + return [ + { + key: 'open-view', + label: '浏览视图数据', + icon: , + onClick: () => onDoubleClick(null, node) + }, + { + key: 'new-query', + label: '新建查询', + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: `新建查询`, + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: '' + }); + } + } + ]; } else if (node.type === 'table') { return [ { @@ -1443,8 +1694,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const displayTitle = String(node.title ?? ''); let hoverTitle = displayTitle; - if (node.type === 'table') { - const rawTableName = String(node?.dataRef?.tableName || '').trim(); + if (node.type === 'table' || node.type === 'view') { + const rawTableName = String(node?.dataRef?.tableName || node?.dataRef?.viewName || '').trim(); const conn = node?.dataRef as SavedConnection | undefined; if (rawTableName && shouldHideSchemaPrefix(conn)) { const lastDotIndex = rawTableName.lastIndexOf('.'); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 01d1e4d..c10a610 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -7,6 +7,7 @@ import QueryEditor from './QueryEditor'; import TableDesigner from './TableDesigner'; import RedisViewer from './RedisViewer'; import RedisCommandEditor from './RedisCommandEditor'; +import TriggerViewer from './TriggerViewer'; const TabManager: React.FC = () => { const tabs = useStore(state => state.tabs); @@ -40,6 +41,8 @@ const TabManager: React.FC = () => { content = ; } else if (tab.type === 'redis-command') { content = ; + } else if (tab.type === 'trigger') { + content = ; } const menuItems: MenuProps['items'] = [ diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 21caff1..ff0748f 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useState, useContext, useMemo, useRef } from 'react'; -import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select } from 'antd'; -import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined } from '@ant-design/icons'; +import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space } from 'antd'; +import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Resizable } from 'react-resizable'; +import Editor, { loader } from '@monaco-editor/react'; import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types'; import { useStore } from '../store'; import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App'; @@ -162,13 +163,47 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const [previewSql, setPreviewSql] = useState(''); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [activeKey, setActiveKey] = useState(tab.initialTab || "columns"); + const [selectedTrigger, setSelectedTrigger] = useState(null); + const [isTriggerModalOpen, setIsTriggerModalOpen] = useState(false); + const [isTriggerEditModalOpen, setIsTriggerEditModalOpen] = useState(false); + const [triggerEditMode, setTriggerEditMode] = useState<'create' | 'edit'>('create'); + const [triggerEditSql, setTriggerEditSql] = useState(''); + const [triggerExecuting, setTriggerExecuting] = useState(false); const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + const darkMode = theme === 'dark'; const readOnly = !!tab.readOnly; const [tableHeight, setTableHeight] = useState(500); const containerRef = useRef(null); + // 初始化透明 Monaco Editor 主题 + useEffect(() => { + loader.init().then(monaco => { + 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', + } + }); + }); + }, []); + useEffect(() => { if (!containerRef.current) return; const resizeObserver = new ResizeObserver(entries => { @@ -365,6 +400,215 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { fetchData(); }, [tab]); + // --- Trigger Handlers --- + + const getDbType = (): string => { + const conn = connections.find(c => c.id === tab.connectionId); + const type = String(conn?.config?.type || '').toLowerCase(); + if (type === 'mariadb') return 'mysql'; + if (type === 'dameng') return 'dm'; + return type; + }; + + const generateTriggerTemplate = (): string => { + const dbType = getDbType(); + const tblName = tab.tableName || 'table_name'; + + switch (dbType) { + case 'mysql': + return `CREATE TRIGGER trigger_name +BEFORE INSERT ON \`${tblName}\` +FOR EACH ROW +BEGIN + -- 触发器逻辑 +END;`; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `CREATE OR REPLACE FUNCTION trigger_function_name() +RETURNS TRIGGER AS $$ +BEGIN + -- 触发器逻辑 + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_name +BEFORE INSERT ON "${tblName}" +FOR EACH ROW +EXECUTE FUNCTION trigger_function_name();`; + case 'sqlserver': + return `CREATE TRIGGER trigger_name +ON [${tblName}] +AFTER INSERT +AS +BEGIN + SET NOCOUNT ON; + -- 触发器逻辑 +END;`; + case 'oracle': + case 'dm': + return `CREATE OR REPLACE TRIGGER trigger_name +BEFORE INSERT ON "${tblName}" +FOR EACH ROW +BEGIN + -- 触发器逻辑 + NULL; +END;`; + case 'sqlite': + return `CREATE TRIGGER trigger_name +AFTER INSERT ON "${tblName}" +BEGIN + -- 触发器逻辑 +END;`; + default: + return `-- 请输入 CREATE TRIGGER 语句`; + } + }; + + const buildDropTriggerSql = (triggerName: string): string => { + const dbType = getDbType(); + const tblName = tab.tableName || ''; + + switch (dbType) { + case 'mysql': + return `DROP TRIGGER IF EXISTS \`${triggerName}\``; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`; + case 'sqlserver': + return `DROP TRIGGER IF EXISTS [${triggerName}]`; + case 'oracle': + case 'dm': + return `DROP TRIGGER "${triggerName}"`; + case 'sqlite': + return `DROP TRIGGER IF EXISTS "${triggerName}"`; + default: + return `DROP TRIGGER ${triggerName}`; + } + }; + + const handleCreateTrigger = () => { + setTriggerEditMode('create'); + setTriggerEditSql(generateTriggerTemplate()); + setIsTriggerEditModalOpen(true); + }; + + const handleEditTrigger = () => { + if (!selectedTrigger) return; + setTriggerEditMode('edit'); + // 构建完整的 CREATE TRIGGER 语句 + const dbType = getDbType(); + const tblName = tab.tableName || ''; + let createSql = ''; + + if (dbType === 'mysql') { + createSql = `CREATE TRIGGER \`${selectedTrigger.name}\` +${selectedTrigger.timing} ${selectedTrigger.event} ON \`${tblName}\` +FOR EACH ROW +${selectedTrigger.statement}`; + } else { + createSql = selectedTrigger.statement || '-- 无法获取完整的触发器定义'; + } + + setTriggerEditSql(createSql); + setIsTriggerEditModalOpen(true); + }; + + const handleDeleteTrigger = () => { + if (!selectedTrigger) return; + + Modal.confirm({ + title: '确认删除触发器', + icon: , + content: `确定要删除触发器 "${selectedTrigger.name}" 吗?此操作不可撤销。`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + const conn = connections.find(c => c.id === tab.connectionId); + if (!conn) { + message.error('未找到连接'); + return; + } + + 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 dropSql = buildDropTriggerSql(selectedTrigger.name); + + try { + const res = await DBQuery(config as any, tab.dbName || '', dropSql); + if (res.success) { + message.success('触发器删除成功'); + setSelectedTrigger(null); + fetchData(); // 刷新列表 + } else { + message.error('删除失败: ' + res.message); + } + } catch (e: any) { + message.error('删除失败: ' + (e?.message || String(e))); + } + } + }); + }; + + const handleExecuteTriggerSql = async () => { + const conn = connections.find(c => c.id === tab.connectionId); + if (!conn) { + message.error('未找到连接'); + return; + } + + 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: "" } + }; + + setTriggerExecuting(true); + + try { + // 如果是编辑模式,先删除旧触发器 + if (triggerEditMode === 'edit' && selectedTrigger) { + const dropSql = buildDropTriggerSql(selectedTrigger.name); + const dropRes = await DBQuery(config as any, tab.dbName || '', dropSql); + if (!dropRes.success) { + message.error('删除旧触发器失败: ' + dropRes.message); + setTriggerExecuting(false); + return; + } + } + + // 执行创建语句 + const res = await DBQuery(config as any, tab.dbName || '', triggerEditSql); + if (res.success) { + message.success(triggerEditMode === 'create' ? '触发器创建成功' : '触发器修改成功'); + setIsTriggerEditModalOpen(false); + setSelectedTrigger(null); + fetchData(); // 刷新列表 + } else { + message.error('执行失败: ' + res.message); + } + } catch (e: any) { + message.error('执行失败: ' + (e?.message || String(e))); + } finally { + setTriggerExecuting(false); + } + }; + // --- Handlers --- const handleColumnChange = (key: string, field: keyof EditableColumn, value: any) => { @@ -680,19 +924,61 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { key: 'triggers', label: '触发器', children: ( - +
+
+ + + + + + {selectedTrigger ? `已选择: ${selectedTrigger.name}` : '请点击选择触发器'} + +
+
}} + rowSelection={{ + type: 'radio', + selectedRowKeys: selectedTrigger ? [selectedTrigger.name] : [], + onChange: (_, selectedRows) => setSelectedTrigger(selectedRows[0] || null), + onSelect: (record, selected) => { + // 点击单选按钮时,如果已选中则取消 + if (selectedTrigger?.name === record.name) { + setSelectedTrigger(null); + } else { + setSelectedTrigger(record); + } + }, + }} + onRow={(record) => ({ + onClick: () => { + // 点击已选中的行时取消选择 + if (selectedTrigger?.name === record.name) { + setSelectedTrigger(null); + } else { + setSelectedTrigger(record); + } + }, + style: { cursor: 'pointer' } + })} + /> + ) } ] : []), @@ -701,8 +987,22 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { label: 'DDL', icon: , children: ( -
-
{ddl}
+
+
) }] : []) @@ -725,6 +1025,75 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {

请仔细检查 SQL,执行后不可撤销。

+ + setIsTriggerModalOpen(false)} + footer={null} + width={700} + > + {selectedTrigger && ( +
+
+ 时机: {selectedTrigger.timing} + 事件: {selectedTrigger.event} +
+
+ +
+
+ )} +
+ + setIsTriggerEditModalOpen(false)} + width={800} + okText={triggerEditMode === 'create' ? '创建' : '保存'} + cancelText="取消" + confirmLoading={triggerExecuting} + onOk={handleExecuteTriggerSql} + > +
+ {triggerEditMode === 'edit' && selectedTrigger && ( + 修改触发器时会先删除原触发器,再创建新触发器。 + )} +
+
+ setTriggerEditSql(val || '')} + options={{ + minimap: { enabled: false }, + fontSize: 14, + lineNumbers: 'on', + scrollBeyondLastLine: false, + wordWrap: 'on', + automaticLayout: true, + }} + /> +
+

请仔细检查 SQL 语句,执行后不可撤销。

+
); }; diff --git a/frontend/src/components/TriggerViewer.tsx b/frontend/src/components/TriggerViewer.tsx new file mode 100644 index 0000000..87df819 --- /dev/null +++ b/frontend/src/components/TriggerViewer.tsx @@ -0,0 +1,240 @@ +import React, { useState, useEffect } from 'react'; +import Editor, { loader } 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 TriggerViewerProps { + tab: TabData; +} + +const TriggerViewer: React.FC = ({ tab }) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [triggerDefinition, setTriggerDefinition] = useState(''); + + const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + const darkMode = theme === 'dark'; + + // 初始化透明 Monaco Editor 主题 + useEffect(() => { + loader.init().then(monaco => { + 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', + } + }); + }); + }, []); + + const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); + const quoteSqlServerIdentifier = (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 buildShowTriggerQuery = (dialect: string, triggerName: string, dbName: string): string => { + const safeTriggerName = escapeSQLLiteral(triggerName); + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + return `SHOW CREATE TRIGGER \`${triggerName.replace(/`/g, '``')}\``; + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + return `SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition +FROM pg_trigger t +JOIN pg_class c ON t.tgrelid = c.oid +WHERE t.tgname = '${safeTriggerName}' + AND NOT t.tgisinternal +LIMIT 1`; + case 'sqlserver': { + return `SELECT OBJECT_DEFINITION(OBJECT_ID('${safeTriggerName.replace(/'/g, "''")}')) AS trigger_definition`; + } + case 'oracle': + case 'dm': + if (!safeDbName) { + return `SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`; + } + return `SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`; + case 'sqlite': + return `SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`; + case 'tdengine': + return `-- TDengine 不支持触发器`; + case 'mongodb': + return `-- MongoDB 不支持触发器`; + default: + return `-- 暂不支持该数据库类型的触发器定义查看`; + } + }; + + const extractTriggerDefinition = (dialect: string, data: any[]): string => { + if (!data || data.length === 0) { + return '-- 未找到触发器定义'; + } + + const row = data[0]; + + switch (dialect) { + case 'mysql': { + // MySQL SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ... + const keys = Object.keys(row); + const sqlKey = keys.find(k => k.toLowerCase().includes('statement') || k.toLowerCase() === 'sql original statement'); + if (sqlKey) return row[sqlKey]; + // Fallback: try to find any key containing CREATE TRIGGER + for (const key of keys) { + const val = String(row[key] || ''); + if (val.toUpperCase().includes('CREATE TRIGGER')) { + return val; + } + } + return JSON.stringify(row, null, 2); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': { + return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || ''; + } + case 'sqlserver': { + return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || ''; + } + case 'oracle': + case 'dm': { + return row.trigger_body || row.TRIGGER_BODY || Object.values(row)[0] || ''; + } + case 'sqlite': { + return row.sql || row.SQL || Object.values(row)[0] || ''; + } + default: + return JSON.stringify(row, null, 2); + } + }; + + useEffect(() => { + const loadTriggerDefinition = async () => { + setLoading(true); + setError(null); + + const conn = connections.find(c => c.id === tab.connectionId); + if (!conn) { + setError('未找到数据库连接'); + setLoading(false); + return; + } + + const triggerName = tab.triggerName || ''; + const dbName = tab.dbName || ''; + + if (!triggerName) { + setError('触发器名称为空'); + setLoading(false); + return; + } + + const dialect = getMetadataDialect(conn); + const query = buildShowTriggerQuery(dialect, triggerName, dbName); + + if (query.startsWith('--')) { + setTriggerDefinition(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 definition = extractTriggerDefinition(dialect, result.data); + setTriggerDefinition(definition); + } else { + setError(result.message || '查询触发器定义失败'); + } + } catch (e: any) { + setError('查询触发器定义失败: ' + (e?.message || String(e))); + } finally { + setLoading(false); + } + }; + + loadTriggerDefinition(); + }, [tab.connectionId, tab.dbName, tab.triggerName, connections]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+
+ 触发器: {tab.triggerName} + {tab.dbName && 数据库: {tab.dbName}} +
+
+ +
+
+ ); +}; + +export default TriggerViewer; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a4a8b66..aa114a1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -62,7 +62,7 @@ export interface TriggerDefinition { export interface TabData { id: string; title: string; - type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command'; + type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger'; connectionId: string; dbName?: string; tableName?: string; @@ -70,6 +70,7 @@ export interface TabData { initialTab?: string; readOnly?: boolean; redisDB?: number; // Redis database index for redis tabs + triggerName?: string; // Trigger name for trigger tabs } export interface DatabaseNode {