mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 22:29:40 +08:00
✨ feat(db-sidebar): 新增数据库对象分组展示及触发器管理功能
- 侧栏数据库节点按对象类型分组展示(表/视图/触发器) - 新增视图节点支持双击打开数据浏览 - 新增触发器节点支持双击查看触发器定义(TriggerViewer组件) - 表级触发器管理:支持查看语句、新增、修改、删除操作 - 对象分组内按名称字母排序 - DDL查看及触发器编辑器适配透明模式背景 - 多数据库类型的视图/触发器元数据查询SQL适配 - refs #89
This commit is contained in:
@@ -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<string, any>, candidateKeys: string[]): string => {
|
||||
const keyMap = new Map<string, any>();
|
||||
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, any>): 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<Record<string, any>[]> => {
|
||||
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<string, any>[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadViews = async (conn: any, dbName: string): Promise<string[]> => {
|
||||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||
const query = buildViewsMetadataQuery(dialect, dbName);
|
||||
const rows = await queryMetadataRows(conn, dbName, query);
|
||||
const seen = new Set<string>();
|
||||
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<Array<{ displayName: string; triggerName: string; tableName: string }>> => {
|
||||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||
const query = buildTriggersMetadataQuery(dialect, dbName);
|
||||
const rows = await queryMetadataRows(conn, dbName, query);
|
||||
const seen = new Set<string>();
|
||||
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: <EyeOutlined />,
|
||||
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: <FunctionOutlined />,
|
||||
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', '表', <TableOutlined />, tables),
|
||||
buildObjectGroup('views', '视图', <EyeOutlined />, viewNodes),
|
||||
buildObjectGroup('triggers', '触发器', <FunctionOutlined />, 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: <EyeOutlined />,
|
||||
onClick: () => onDoubleClick(null, node)
|
||||
},
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
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('.');
|
||||
|
||||
@@ -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 = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-command') {
|
||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'trigger') {
|
||||
content = <TriggerViewer tab={tab} />;
|
||||
}
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
|
||||
@@ -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<string>('');
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState(tab.initialTab || "columns");
|
||||
const [selectedTrigger, setSelectedTrigger] = useState<TriggerDefinition | null>(null);
|
||||
const [isTriggerModalOpen, setIsTriggerModalOpen] = useState(false);
|
||||
const [isTriggerEditModalOpen, setIsTriggerEditModalOpen] = useState(false);
|
||||
const [triggerEditMode, setTriggerEditMode] = useState<'create' | 'edit'>('create');
|
||||
const [triggerEditSql, setTriggerEditSql] = useState<string>('');
|
||||
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<HTMLDivElement>(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: <ExclamationCircleOutlined />,
|
||||
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: (
|
||||
<Table
|
||||
dataSource={triggers}
|
||||
columns={[
|
||||
{ title: '名', dataIndex: 'name', key: 'name' },
|
||||
{ title: '时间', dataIndex: 'timing', key: 'timing' },
|
||||
{ title: '事件', dataIndex: 'event', key: 'event' },
|
||||
{ title: '语句', dataIndex: 'statement', key: 'statement', ellipsis: true },
|
||||
]}
|
||||
rowKey="name"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
disabled={!selectedTrigger}
|
||||
onClick={() => setIsTriggerModalOpen(true)}
|
||||
>
|
||||
查看语句
|
||||
</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={handleCreateTrigger}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!selectedTrigger} onClick={handleEditTrigger}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedTrigger} onClick={handleDeleteTrigger}>删除</Button>
|
||||
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
|
||||
{selectedTrigger ? `已选择: ${selectedTrigger.name}` : '请点击选择触发器'}
|
||||
</span>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={triggers}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '时机', dataIndex: 'timing', key: 'timing', width: 100 },
|
||||
{ title: '事件', dataIndex: 'event', key: 'event', width: 100 },
|
||||
]}
|
||||
rowKey="name"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="该表暂无触发器" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
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' }
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
] : []),
|
||||
@@ -701,8 +987,22 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
label: 'DDL',
|
||||
icon: <FileTextOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(100vh - 200px)', overflow: 'auto', padding: 10, background: '#f5f5f5', border: '1px solid #eee' }}>
|
||||
<pre>{ddl}</pre>
|
||||
<div style={{ height: 'calc(100vh - 200px)', border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={ddl}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}] : [])
|
||||
@@ -725,6 +1025,75 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
</div>
|
||||
<p style={{ marginTop: 10, color: '#faad14' }}>请仔细检查 SQL,执行后不可撤销。</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={selectedTrigger ? `触发器: ${selectedTrigger.name}` : '触发器详情'}
|
||||
open={isTriggerModalOpen}
|
||||
onCancel={() => setIsTriggerModalOpen(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
{selectedTrigger && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, display: 'flex', gap: 24 }}>
|
||||
<span><strong>时机:</strong> {selectedTrigger.timing}</span>
|
||||
<span><strong>事件:</strong> {selectedTrigger.event}</span>
|
||||
</div>
|
||||
<div style={{ border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
|
||||
<Editor
|
||||
height="350px"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={selectedTrigger.statement}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={triggerEditMode === 'create' ? '新增触发器' : '修改触发器'}
|
||||
open={isTriggerEditModalOpen}
|
||||
onCancel={() => setIsTriggerEditModalOpen(false)}
|
||||
width={800}
|
||||
okText={triggerEditMode === 'create' ? '创建' : '保存'}
|
||||
cancelText="取消"
|
||||
confirmLoading={triggerExecuting}
|
||||
onOk={handleExecuteTriggerSql}
|
||||
>
|
||||
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
|
||||
{triggerEditMode === 'edit' && selectedTrigger && (
|
||||
<span>修改触发器时会先删除原触发器,再创建新触发器。</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ border: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
|
||||
<Editor
|
||||
height="350px"
|
||||
language="sql"
|
||||
theme={darkMode ? 'vs-dark' : 'light'}
|
||||
value={triggerEditSql}
|
||||
onChange={(val) => setTriggerEditSql(val || '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: 10, color: '#faad14' }}>请仔细检查 SQL 语句,执行后不可撤销。</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
240
frontend/src/components/TriggerViewer.tsx
Normal file
240
frontend/src/components/TriggerViewer.tsx
Normal file
@@ -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<TriggerViewerProps> = ({ tab }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [triggerDefinition, setTriggerDefinition] = useState<string>('');
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin tip="加载触发器定义..." />
|
||||
</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>触发器: </strong>{tab.triggerName}
|
||||
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>数据库: {tab.dbName}</span>}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={triggerDefinition}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TriggerViewer;
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user