feat(db-sidebar): 新增数据库对象分组展示及触发器管理功能

- 侧栏数据库节点按对象类型分组展示(表/视图/触发器)
 - 新增视图节点支持双击打开数据浏览
 - 新增触发器节点支持双击查看触发器定义(TriggerViewer组件)
 - 表级触发器管理:支持查看语句、新增、修改、删除操作
 - 对象分组内按名称字母排序
 - DDL查看及触发器编辑器适配透明模式背景
 - 多数据库类型的视图/触发器元数据查询SQL适配
 - refs #89
This commit is contained in:
Syngnat
2026-02-09 14:50:13 +08:00
parent 087578693e
commit fdb7781a9b
5 changed files with 889 additions and 25 deletions

View File

@@ -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('.');

View File

@@ -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'] = [

View File

@@ -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>
);
};

View 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;

View File

@@ -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 {