Files
MyGoNavi/frontend/src/components/Sidebar.tsx
Syngnat 027115ab87 🔧 fix(appearance): 修复透明通透失效并统一 Win/Mac 视觉强度
- 新增 macOS 原生窗口通透补强与启动重试,修复偶发不生效
- 引入跨平台透明/模糊映射,统一 Win/Mac 同滑块值观感
- 调整主窗口圆角与裁剪,优化整体视觉一致性
2026-02-06 11:35:16 +08:00

1502 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select } from 'antd';
import {
DatabaseOutlined,
TableOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOpenOutlined,
FileTextOutlined,
CopyOutlined,
ExportOutlined,
SaveOutlined,
EditOutlined,
DownOutlined,
SearchOutlined,
KeyOutlined,
ThunderboltOutlined,
UnorderedListOutlined,
FunctionOutlined,
LinkOutlined,
FileAddOutlined,
PlusOutlined,
ReloadOutlined,
DeleteOutlined,
DisconnectOutlined,
CloudOutlined,
CheckSquareOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
import { DBGetDatabases, DBGetTables, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/app/App';
import { normalizeOpacityForPlatform } from '../utils/appearance';
const { Search } = Input;
interface TreeNode {
title: string;
key: string;
isLeaf?: boolean;
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';
}
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
const connections = useStore(state => state.connections);
const savedQueries = useStore(state => state.savedQueries);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const removeConnection = useStore(state => state.removeConnection);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const [treeData, setTreeData] = useState<TreeNode[]>([]);
// Background Helper (Duplicate logic for now, ideally shared)
const getBg = (darkHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${opacity})`;
const hex = darkHex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#141414');
const [searchValue, setSearchValue] = useState('');
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [autoExpandParent, setAutoExpandParent] = useState(true);
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
// Virtual Scroll State
const [treeHeight, setTreeHeight] = useState(500);
const treeContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!treeContainerRef.current) return;
const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
setTreeHeight(entry.contentRect.height);
}
});
resizeObserver.observe(treeContainerRef.current);
return () => resizeObserver.disconnect();
}, []);
// Connection Status State: key -> 'success' | 'error'
const [connectionStates, setConnectionStates] = useState<Record<string, 'success' | 'error'>>({});
// Create Database Modal
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
const [createDbForm] = Form.useForm();
const [targetConnection, setTargetConnection] = useState<any>(null);
// Batch Operations Modal
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
const [batchTables, setBatchTables] = useState<any[]>([]);
const [checkedTableKeys, setCheckedTableKeys] = useState<string[]>([]);
const [batchDbContext, setBatchDbContext] = useState<any>(null);
const [selectedConnection, setSelectedConnection] = useState<string>('');
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
// Batch Database Operations Modal
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
const [batchDatabases, setBatchDatabases] = useState<any[]>([]);
const [checkedDbKeys, setCheckedDbKeys] = useState<string[]>([]);
const [batchConnContext, setBatchConnContext] = useState<any>(null);
const [selectedDbConnection, setSelectedDbConnection] = useState<string>('');
useEffect(() => {
// Refresh queries for expanded databases
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
for (const node of nodes) {
if (node.key === k) return node;
if (node.children) {
const res = findNode(node.children, k);
if (res) return res;
}
}
return null;
};
expandedKeys.forEach(key => {
const node = findNode(treeData, key);
if (node && node.type === 'database') {
loadTables(node);
}
});
}, [savedQueries]);
useEffect(() => {
setTreeData(connections.map(conn => ({
title: conn.name,
key: conn.id,
icon: conn.config.type === 'redis' ? <CloudOutlined style={{ color: '#DC382D' }} /> : <HddOutlined />,
type: 'connection',
dataRef: conn,
isLeaf: false,
})));
}, [connections]);
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
return list.map(node => {
if (node.key === key) {
return { ...node, children };
}
if (node.children) {
return { ...node, children: updateTreeData(node.children, key, children) };
}
return node;
});
};
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
const loadKey = `dbs-${conn.id}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
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: "" }
};
// Handle Redis connections differently
if (conn.config.type === 'redis') {
try {
const res = await (window as any).go.app.App.RedisGetDatabases(config);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
let dbs = (res.data as any[]).map((db: any) => ({
title: `db${db.index}${db.keys > 0 ? ` (${db.keys})` : ''}`,
key: `${conn.id}-db${db.index}`,
icon: <DatabaseOutlined style={{ color: '#DC382D' }} />,
type: 'redis-db' as const,
dataRef: { ...conn, redisDB: db.index },
isLeaf: true,
dbIndex: db.index,
}));
// Filter Redis databases if configured
if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex));
}
setTreeData(origin => updateTreeData(origin, node.key, dbs));
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-dbs` });
} finally {
loadingNodesRef.current.delete(loadKey);
}
return;
}
try {
const res = await DBGetDatabases(config as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
let dbs = (res.data as any[]).map((row: any) => ({
title: row.Database || row.database,
key: `${conn.id}-${row.Database || row.database}`,
icon: <DatabaseOutlined />,
type: 'database' as const,
dataRef: { ...conn, dbName: row.Database || row.database },
isLeaf: false,
}));
// Filter databases if configured
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
}
setTreeData(origin => updateTreeData(origin, node.key, dbs));
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
}
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
const loadTables = async (node: any) => {
const conn = node.dataRef; // has dbName
const dbName = conn.dbName;
const key = node.key;
const loadKey = `tables-${conn.id}-${dbName}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
const queriesNode: TreeNode = {
title: '已存查询',
key: `${key}-queries`,
icon: <FolderOpenOutlined />,
type: 'queries-folder',
isLeaf: dbQueries.length === 0,
children: dbQueries.map(q => ({
title: q.name,
key: q.id,
icon: <FileTextOutlined />,
type: 'saved-query',
dataRef: q,
isLeaf: true
}))
};
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: "" }
};
try {
const res = await DBGetTables(config as any, conn.dbName);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
const tables = (res.data as any[]).map((row: any) => {
const tableName = Object.values(row)[0] as string;
return {
title: tableName,
key: `${conn.id}-${conn.dbName}-${tableName}`,
icon: <TableOutlined />,
type: 'table' as const,
dataRef: { ...conn, tableName },
isLeaf: false,
};
});
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...tables]));
} else {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
message.error({ content: res.message, key: `db-${key}-tables` });
}
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
const onLoadData = async ({ key, children, dataRef, type }: any) => {
if (children) return;
if (type === 'connection') {
await loadDatabases({ key, dataRef });
} else if (type === 'database') {
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[] = [
{
title: '列',
key: `${key}-columns`,
icon: <UnorderedListOutlined />,
type: 'folder-columns',
isLeaf: true,
dataRef: conn
},
{
title: '索引',
key: `${key}-indexes`,
icon: <KeyOutlined style={{ transform: 'rotate(45deg)' }} />,
type: 'folder-indexes',
isLeaf: true,
dataRef: conn
},
{
title: '外键',
key: `${key}-fks`,
icon: <LinkOutlined />,
type: 'folder-fks',
isLeaf: true,
dataRef: conn
},
{
title: '触发器',
key: `${key}-triggers`,
icon: <ThunderboltOutlined />,
type: 'folder-triggers',
isLeaf: true,
dataRef: conn
}
];
setTreeData(origin => updateTreeData(origin, key, folders));
}
};
const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => {
const { tableName, dbName, id } = node.dataRef;
addTab({
id: `design-${id}-${dbName}-${tableName}`,
title: `${readOnly ? '表结构' : '设计表'} (${tableName})`,
type: 'design',
connectionId: id,
dbName: dbName,
tableName: tableName,
initialTab: initialTab,
readOnly: readOnly
});
};
const openNewTableDesign = (node: any) => {
const { dbName, id } = node.dataRef;
addTab({
id: `new-table-${id}-${dbName}-${Date.now()}`,
title: `新建表 - ${dbName}`,
type: 'design',
connectionId: id,
dbName: dbName,
tableName: '', // Empty tableName signals creation mode
initialTab: 'columns',
readOnly: false
});
};
const onSelect = (keys: React.Key[], info: any) => {
setSelectedKeys(keys);
selectedNodesRef.current = info.selectedNodes || [];
if (keys.length === 0) {
setActiveContext(null);
return;
}
if (!info.selected) return;
const { type, dataRef, key, title } = info.node;
// Update active context
if (type === 'connection') {
setActiveContext({ connectionId: key, dbName: '' });
} else if (type === 'database') {
setActiveContext({ connectionId: dataRef.id, dbName: title });
} else if (type === 'table') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
} else if (type === 'redis-db') {
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
}
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true);
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true);
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', true);
};
const onExpand = (newExpandedKeys: React.Key[]) => {
setExpandedKeys(newExpandedKeys);
setAutoExpandParent(false);
};
const onDoubleClick = (e: any, node: any) => {
if (node.type === 'table') {
const { tableName, dbName, id } = node.dataRef;
addTab({
id: node.key,
title: tableName,
type: 'table',
connectionId: id,
dbName,
tableName,
});
return;
} else if (node.type === 'saved-query') {
const q = node.dataRef;
addTab({
id: q.id,
title: q.name,
type: 'query',
connectionId: q.connectionId,
dbName: q.dbName,
query: q.sql
});
return;
} else if (node.type === 'redis-db') {
const { id, redisDB } = node.dataRef;
addTab({
id: `redis-keys-${id}-db${redisDB}`,
title: `db${redisDB}`,
type: 'redis-keys',
connectionId: id,
redisDB: redisDB
});
return;
}
const key = node.key;
const isExpanded = expandedKeys.includes(key);
const newExpandedKeys = isExpanded
? expandedKeys.filter(k => k !== key)
: [...expandedKeys, key];
setExpandedKeys(newExpandedKeys);
if (!isExpanded) setAutoExpandParent(false);
};
const handleCopyStructure = async (node: any) => {
const { config, dbName, tableName } = node.dataRef;
const res = await DBShowCreateTable({
...config,
port: Number(config.port),
password: config.password || "",
database: config.database || "",
useSSH: config.useSSH || false,
ssh: config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
} as any, dbName, tableName);
if (res.success) {
navigator.clipboard.writeText(res.data as string);
message.success('表结构已复制到剪贴板');
} else {
message.error(res.message);
}
};
const handleExport = async (node: any, format: string) => {
const { config, dbName, tableName } = node.dataRef;
const hide = message.loading(`正在导出 ${tableName}${format.toUpperCase()}...`, 0);
const res = await ExportTable({
...config,
port: Number(config.port),
password: config.password || "",
database: config.database || "",
useSSH: config.useSSH || false,
ssh: config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
} as any, dbName, tableName, format);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
message.error('导出失败: ' + res.message);
}
};
const normalizeConnConfig = (raw: any) => ({
...raw,
port: Number(raw.port),
password: raw.password || "",
database: raw.database || "",
useSSH: raw.useSSH || false,
ssh: raw.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
});
const handleExportDatabaseSQL = async (node: any, includeData: boolean) => {
const conn = node.dataRef;
const dbName = conn.dbName || node.title;
const hide = message.loading(includeData ? `正在备份数据库 ${dbName} (结构+数据)...` : `正在导出数据库 ${dbName} 表结构...`, 0);
try {
const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(conn.config), dbName, includeData);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
hide();
message.error('导出失败: ' + (e?.message || String(e)));
}
};
const handleExportTablesSQL = async (nodes: any[], includeData: boolean) => {
if (!nodes || nodes.length === 0) return;
const first = nodes[0].dataRef;
const dbName = first.dbName;
const connId = first.id;
const allSame = nodes.every(n => n?.dataRef?.id === connId && n?.dataRef?.dbName === dbName);
if (!allSame) {
message.error('请在同一连接、同一数据库下选择多张表进行导出');
return;
}
const tableNames = nodes.map(n => n.dataRef.tableName).filter(Boolean);
const hide = message.loading(includeData ? `正在备份选中表 (${tableNames.length})...` : `正在导出选中表结构 (${tableNames.length})...`, 0);
try {
const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(first.config), dbName, tableNames, includeData);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
hide();
message.error('导出失败: ' + (e?.message || String(e)));
}
};
const openBatchOperationModal = async () => {
// Check if current selected node is database or table
let connId = '';
let dbName = '';
if (selectedNodesRef.current.length > 0) {
const node = selectedNodesRef.current[0];
if (node.type === 'database') {
connId = node.dataRef.id;
dbName = node.title;
} else if (node.type === 'table') {
connId = node.dataRef.id;
dbName = node.dataRef.dbName;
}
}
setSelectedConnection(connId);
setSelectedDatabase(dbName);
setBatchTables([]);
setCheckedTableKeys([]);
setAvailableDatabases([]);
if (connId) {
const conn = connections.find(c => c.id === connId);
if (conn) {
await loadDatabasesForBatch(conn);
if (dbName) {
await loadTablesForBatch(conn, dbName);
}
}
}
setIsBatchModalOpen(true);
};
const loadDatabasesForBatch = async (conn: SavedConnection) => {
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 res = await DBGetDatabases(config as any);
if (res.success) {
let dbs = (res.data as any[]).map((row: any) => {
const dbName = row.Database || row.database;
return {
title: dbName,
key: `${conn.id}-${dbName}`,
dbName: dbName
};
});
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName));
}
setAvailableDatabases(dbs);
} else {
message.error('获取数据库列表失败: ' + res.message);
}
};
const loadTablesForBatch = async (conn: SavedConnection, dbName: string) => {
setBatchDbContext({ conn, dbName });
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 res = await DBGetTables(config as any, dbName);
if (res.success) {
const tables = (res.data as any[]).map((row: any) => {
const tableName = Object.values(row)[0] as string;
return {
title: tableName,
key: `${conn.id}-${dbName}-${tableName}`,
tableName: tableName,
dataRef: { ...conn, tableName, dbName }
};
});
setBatchTables(tables);
setCheckedTableKeys([]);
} else {
message.error('获取表列表失败: ' + res.message);
}
};
const handleConnectionChange = async (connId: string) => {
setSelectedConnection(connId);
setSelectedDatabase('');
setBatchTables([]);
setCheckedTableKeys([]);
const conn = connections.find(c => c.id === connId);
if (conn) {
await loadDatabasesForBatch(conn);
}
};
const handleDatabaseChange = async (dbName: string) => {
setSelectedDatabase(dbName);
const conn = connections.find(c => c.id === selectedConnection);
if (conn && dbName) {
await loadTablesForBatch(conn, dbName);
}
};
const handleBatchExport = async (includeData: boolean) => {
const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key));
if (selectedTables.length === 0) {
message.warning('请至少选择一张表');
return;
}
setIsBatchModalOpen(false);
const { conn, dbName } = batchDbContext;
const tableNames = selectedTables.map(t => t.tableName);
const hide = message.loading(includeData ? `正在备份选中表 (${tableNames.length})...` : `正在导出选中表结构 (${tableNames.length})...`, 0);
try {
const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, includeData);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
hide();
message.error('导出失败: ' + (e?.message || String(e)));
}
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckedTableKeys(batchTables.map(t => t.key));
} else {
setCheckedTableKeys([]);
}
};
const handleInvertSelection = () => {
const allKeys = batchTables.map(t => t.key);
const newChecked = allKeys.filter(k => !checkedTableKeys.includes(k));
setCheckedTableKeys(newChecked);
};
const openBatchDatabaseModal = async () => {
// Check if current selected node is connection or database
let connId = '';
if (selectedNodesRef.current.length > 0) {
const node = selectedNodesRef.current[0];
if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') {
connId = node.key as string;
} else if (node.type === 'database') {
connId = node.dataRef.id;
} else if (node.type === 'table') {
connId = node.dataRef.id;
}
}
setSelectedDbConnection(connId);
setBatchDatabases([]);
setCheckedDbKeys([]);
if (connId) {
const conn = connections.find(c => c.id === connId);
if (conn) {
await loadDatabasesForDbBatch(conn);
}
}
setIsBatchDbModalOpen(true);
};
const loadDatabasesForDbBatch = async (conn: SavedConnection) => {
setBatchConnContext(conn);
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 res = await DBGetDatabases(config as any);
if (res.success) {
let dbs = (res.data as any[]).map((row: any) => {
const dbName = row.Database || row.database;
return {
title: dbName,
key: `${conn.id}-${dbName}`,
dbName: dbName,
dataRef: { ...conn, dbName }
};
});
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName));
}
setBatchDatabases(dbs);
setCheckedDbKeys([]);
} else {
message.error('获取数据库列表失败: ' + res.message);
}
};
const handleDbConnectionChange = async (connId: string) => {
setSelectedDbConnection(connId);
const conn = connections.find(c => c.id === connId);
if (conn) {
await loadDatabasesForDbBatch(conn);
}
};
const handleBatchDbExport = async (includeData: boolean) => {
const selectedDbs = batchDatabases.filter(db => checkedDbKeys.includes(db.key));
if (selectedDbs.length === 0) {
message.warning('请至少选择一个数据库');
return;
}
setIsBatchDbModalOpen(false);
for (const db of selectedDbs) {
const hide = message.loading(includeData ? `正在备份数据库 ${db.dbName} (结构+数据)...` : `正在导出数据库 ${db.dbName} 表结构...`, 0);
try {
const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(batchConnContext.config), db.dbName, includeData);
hide();
if (res.success) {
message.success(`${db.dbName} 导出成功`);
} else if (res.message !== 'Cancelled') {
message.error(`${db.dbName} 导出失败: ` + res.message);
break;
} else {
break; // User cancelled
}
} catch (e: any) {
hide();
message.error(`${db.dbName} 导出失败: ` + (e?.message || String(e)));
break;
}
}
};
const handleCheckAllDb = (checked: boolean) => {
if (checked) {
setCheckedDbKeys(batchDatabases.map(db => db.key));
} else {
setCheckedDbKeys([]);
}
};
const handleInvertSelectionDb = () => {
const allKeys = batchDatabases.map(db => db.key);
const newChecked = allKeys.filter(k => !checkedDbKeys.includes(k));
setCheckedDbKeys(newChecked);
};
const handleRunSQLFile = async (node: any) => {
const res = await (window as any).go.app.App.OpenSQLFile();
if (res.success) {
const sqlContent = res.data;
const { dbName, id } = node.dataRef;
addTab({
id: `query-${Date.now()}`,
title: `Import SQL`,
type: 'query',
connectionId: node.type === 'connection' ? node.key : node.dataRef.id,
dbName: dbName,
query: sqlContent
});
} else if (res.message !== "Cancelled") {
message.error("读取文件失败: " + res.message);
}
};
const handleCreateDatabase = async () => {
try {
const values = await createDbForm.validateFields();
const conn = targetConnection.dataRef;
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: "", // No db selected
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const res = await CreateDatabase(config as any, values.name);
if (res.success) {
message.success("数据库创建成功");
setIsCreateDbModalOpen(false);
createDbForm.resetFields();
// Refresh node
loadDatabases(targetConnection);
} else {
message.error("创建失败: " + res.message);
}
} catch (e) {
// Validate failed
}
};
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
setSearchValue(value);
};
const loop = (data: TreeNode[]): TreeNode[] => {
const result: TreeNode[] = [];
data.forEach(item => {
const match = item.title.toLowerCase().indexOf(searchValue.toLowerCase()) > -1;
if (item.children) {
const filteredChildren = loop(item.children);
if (filteredChildren.length > 0 || match) {
result.push({ ...item, children: filteredChildren });
}
} else {
if (match) {
result.push(item);
}
}
});
return result;
};
const displayTreeData = useMemo(() => {
if (!searchValue) return treeData;
return loop(treeData);
}, [searchValue, treeData]);
const getNodeMenuItems = (node: any): MenuProps['items'] => {
const conn = node.dataRef as SavedConnection;
const isRedis = conn?.config?.type === 'redis';
if (node.type === 'connection') {
// Redis connection menu
if (isRedis) {
return [
{
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
},
{ type: 'divider' },
{
key: 'new-command',
label: '新建命令窗口',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `redis-cmd-${node.key}-${Date.now()}`,
title: `命令 - ${node.title}`,
type: 'redis-command',
connectionId: node.key,
redisDB: 0
});
}
},
{ type: 'divider' },
{
key: 'edit',
label: '编辑连接',
icon: <EditOutlined />,
onClick: () => {
if (onEditConnection) onEditConnection(node.dataRef);
}
},
{
key: 'disconnect',
label: '断开连接',
icon: <DisconnectOutlined />,
onClick: () => {
setConnectionStates(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => {
if (k === node.key || k.startsWith(`${node.key}-`)) {
delete next[k];
}
});
return next;
});
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setTreeData(origin => updateTreeData(origin, node.key, undefined));
message.success("已断开连接");
}
},
{
key: 'delete',
label: '删除连接',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => removeConnection(node.key)
});
}
}
];
}
// Regular database connection menu
return [
{
key: 'new-db',
label: '新建数据库',
icon: <DatabaseOutlined />,
onClick: () => {
setTargetConnection(node);
setIsCreateDbModalOpen(true);
}
},
{
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
},
{ type: 'divider' },
{
key: 'new-query',
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `query-${Date.now()}`,
title: `新建查询`,
type: 'query',
connectionId: node.key,
dbName: undefined,
query: ''
});
}
},
{ type: 'divider' },
{
key: 'edit',
label: '编辑连接',
icon: <EditOutlined />,
onClick: () => {
if (onEditConnection) onEditConnection(node.dataRef);
}
},
{
key: 'disconnect',
label: '断开连接',
icon: <DisconnectOutlined />,
onClick: () => {
// Reset status recursively
setConnectionStates(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => {
if (k === node.key || k.startsWith(`${node.key}-`)) {
delete next[k];
}
});
return next;
});
// Collapse node and children
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
// Reset loaded state recursively
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
// Clear children (undefined to trigger reload)
setTreeData(origin => updateTreeData(origin, node.key, undefined));
message.success("已断开连接");
}
},
{
key: 'delete',
label: '删除连接',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => removeConnection(node.key)
});
}
}
];
} else if (node.type === 'redis-db') {
// Redis database menu
const { id, redisDB } = node.dataRef;
return [
{
key: 'open-keys',
label: '浏览 Key',
icon: <KeyOutlined />,
onClick: () => {
addTab({
id: `redis-keys-${id}-db${redisDB}`,
title: `db${redisDB}`,
type: 'redis-keys',
connectionId: id,
redisDB: redisDB
});
}
},
{
key: 'new-command',
label: '新建命令窗口',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `redis-cmd-${id}-db${redisDB}-${Date.now()}`,
title: `命令 - db${redisDB}`,
type: 'redis-command',
connectionId: id,
redisDB: redisDB
});
}
}
];
} else if (node.type === 'database') {
return [
{
key: 'new-table',
label: '新建表',
icon: <TableOutlined />,
onClick: () => openNewTableDesign(node)
},
{
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadTables(node)
},
{
key: 'export-db-schema',
label: '导出全部表结构 (SQL)',
icon: <ExportOutlined />,
onClick: () => handleExportDatabaseSQL(node, false)
},
{
key: 'backup-db-sql',
label: '备份全部表 (结构+数据 SQL)',
icon: <SaveOutlined />,
onClick: () => handleExportDatabaseSQL(node, true)
},
{ type: 'divider' },
{
key: 'disconnect-db',
label: '关闭数据库',
icon: <DisconnectOutlined />,
onClick: () => {
setConnectionStates(prev => {
const next = { ...prev };
delete next[node.key];
return next;
});
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setTreeData(origin => updateTreeData(origin, node.key, undefined));
}
},
{
key: 'new-query',
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `query-${Date.now()}`,
title: `新建查询 (${node.title})`,
type: 'query',
connectionId: node.dataRef.id,
dbName: node.title,
query: ''
});
}
},
{
key: 'run-sql',
label: '运行 SQL 文件...',
icon: <FileAddOutlined />,
onClick: () => handleRunSQLFile(node)
}
];
} else if (node.type === 'table') {
return [
{
key: 'new-query',
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `query-${Date.now()}`,
title: `新建查询`,
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: ''
});
}
},
{ type: 'divider' },
{
key: 'design-table',
label: '设计表',
icon: <EditOutlined />,
onClick: () => openDesign(node, 'columns', false)
},
{
key: 'copy-structure',
label: '复制表结构',
icon: <CopyOutlined />,
onClick: () => handleCopyStructure(node)
},
{
key: 'backup-table',
label: '备份表 (SQL)',
icon: <SaveOutlined />,
onClick: () => handleExport(node, 'sql')
},
{
type: 'divider'
},
{
key: 'export',
label: '导出表数据',
icon: <ExportOutlined />,
children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(node, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') },
]
}
];
}
return [];
};
const titleRender = (node: any) => {
let status: 'success' | 'error' | 'default' = 'default';
if (node.type === 'connection' || node.type === 'database') {
if (connectionStates[node.key] === 'success') status = 'success';
else if (connectionStates[node.key] === 'error') status = 'error';
}
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
<Badge status={status} style={{ marginRight: 8 }} />
) : null;
return <span title={node.title}>{statusBadge}{node.title}</span>;
};
const onRightClick = ({ event, node }: any) => {
const items = getNodeMenuItems(node);
if (items && items.length > 0) {
setContextMenu({
x: event.clientX,
y: event.clientY,
items
});
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '4px 8px' }}>
<Search placeholder="搜索..." onChange={onSearch} size="small" />
</div>
{/* Toolbar for batch operations - always visible */}
<div style={{ padding: '4px 8px', borderBottom: 'none', display: 'flex', gap: 4 }}>
<Button
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchOperationModal()}
style={{ flex: 1 }}
>
</Button>
<Button
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchDatabaseModal()}
style={{ flex: 1 }}
>
</Button>
</div>
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<Tree
showIcon
loadData={onLoadData}
treeData={displayTreeData}
onDoubleClick={onDoubleClick}
onSelect={onSelect}
titleRender={titleRender}
expandedKeys={expandedKeys}
onExpand={onExpand}
loadedKeys={loadedKeys}
onLoad={setLoadedKeys}
autoExpandParent={autoExpandParent}
selectedKeys={selectedKeys}
blockNode
height={treeHeight}
onRightClick={onRightClick}
/>
</div>
{contextMenu && (
<Dropdown
menu={{ items: contextMenu.items }}
open={true}
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
trigger={['contextMenu']}
>
<div style={{ position: 'fixed', left: contextMenu.x, top: contextMenu.y, width: 1, height: 1 }} />
</Dropdown>
)}
<Modal
title="新建数据库"
open={isCreateDbModalOpen}
onOk={handleCreateDatabase}
onCancel={() => setIsCreateDbModalOpen(false)}
>
<Form form={createDbForm} layout="vertical">
<Form.Item name="name" label="数据库名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input />
</Form.Item>
{/* Charset option could be added here */}
</Form>
</Modal>
<Modal
title="批量操作表"
open={isBatchModalOpen}
onCancel={() => setIsBatchModalOpen(false)}
width={600}
footer={[
<Button key="cancel" onClick={() => setIsBatchModalOpen(false)}>
</Button>,
<Button
key="export-schema"
icon={<ExportOutlined />}
onClick={() => handleBatchExport(false)}
disabled={checkedTableKeys.length === 0}
>
({checkedTableKeys.length})
</Button>,
<Button
key="backup"
type="primary"
icon={<SaveOutlined />}
onClick={() => handleBatchExport(true)}
disabled={checkedTableKeys.length === 0}
>
({checkedTableKeys.length})
</Button>
]}
>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}></label>
<Select
value={selectedConnection}
onChange={handleConnectionChange}
style={{ width: '100%' }}
placeholder="请选择连接"
>
{connections.filter(c => c.config.type !== 'redis').map(conn => (
<Select.Option key={conn.id} value={conn.id}>
{conn.name}
</Select.Option>
))}
</Select>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}></label>
<Select
value={selectedDatabase}
onChange={handleDatabaseChange}
style={{ width: '100%' }}
placeholder="请先选择连接"
disabled={!selectedConnection}
>
{availableDatabases.map(db => (
<Select.Option key={db.key} value={db.dbName}>
{db.title}
</Select.Option>
))}
</Select>
</div>
</div>
{batchTables.length > 0 && (
<>
<div style={{ marginBottom: 16 }}>
<Space>
<Button
size="small"
onClick={() => handleCheckAll(true)}
>
</Button>
<Button
size="small"
onClick={() => handleCheckAll(false)}
>
</Button>
<Button
size="small"
onClick={handleInvertSelection}
>
</Button>
<span style={{ color: '#999' }}>
{checkedTableKeys.length} / {batchTables.length}
</span>
</Space>
</div>
<div style={{ maxHeight: 400, overflow: 'auto', border: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
<Checkbox.Group
value={checkedTableKeys}
onChange={(values) => setCheckedTableKeys(values as string[])}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{batchTables.map(table => (
<Checkbox key={table.key} value={table.key}>
<TableOutlined style={{ marginRight: 8 }} />
{table.title}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</div>
</>
)}
</Modal>
<Modal
title="批量操作库"
open={isBatchDbModalOpen}
onCancel={() => setIsBatchDbModalOpen(false)}
width={600}
footer={[
<Button key="cancel" onClick={() => setIsBatchDbModalOpen(false)}>
</Button>,
<Button
key="export-schema"
icon={<ExportOutlined />}
onClick={() => handleBatchDbExport(false)}
disabled={checkedDbKeys.length === 0}
>
({checkedDbKeys.length})
</Button>,
<Button
key="backup"
type="primary"
icon={<SaveOutlined />}
onClick={() => handleBatchDbExport(true)}
disabled={checkedDbKeys.length === 0}
>
({checkedDbKeys.length})
</Button>
]}
>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}></label>
<Select
value={selectedDbConnection}
onChange={handleDbConnectionChange}
style={{ width: '100%' }}
placeholder="请选择连接"
>
{connections.filter(c => c.config.type !== 'redis').map(conn => (
<Select.Option key={conn.id} value={conn.id}>
{conn.name}
</Select.Option>
))}
</Select>
</div>
{batchDatabases.length > 0 && (
<>
<div style={{ marginBottom: 16 }}>
<Space>
<Button
size="small"
onClick={() => handleCheckAllDb(true)}
>
</Button>
<Button
size="small"
onClick={() => handleCheckAllDb(false)}
>
</Button>
<Button
size="small"
onClick={handleInvertSelectionDb}
>
</Button>
<span style={{ color: '#999' }}>
{checkedDbKeys.length} / {batchDatabases.length}
</span>
</Space>
</div>
<div style={{ maxHeight: 400, overflow: 'auto', border: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
<Checkbox.Group
value={checkedDbKeys}
onChange={(values) => setCheckedDbKeys(values as string[])}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{batchDatabases.map(db => (
<Checkbox key={db.key} value={db.key}>
<DatabaseOutlined style={{ marginRight: 8 }} />
{db.title}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</div>
</>
)}
</Modal>
</div>
);
};
export default Sidebar;