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([]); // 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([]); const [autoExpandParent, setAutoExpandParent] = useState(true); const [loadedKeys, setLoadedKeys] = useState([]); const [selectedKeys, setSelectedKeys] = useState([]); const selectedNodesRef = useRef([]); const loadingNodesRef = useRef>(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(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>({}); // Create Database Modal const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false); const [createDbForm] = Form.useForm(); const [targetConnection, setTargetConnection] = useState(null); // Batch Operations Modal const [isBatchModalOpen, setIsBatchModalOpen] = useState(false); const [batchTables, setBatchTables] = useState([]); const [checkedTableKeys, setCheckedTableKeys] = useState([]); const [batchDbContext, setBatchDbContext] = useState(null); const [selectedConnection, setSelectedConnection] = useState(''); const [selectedDatabase, setSelectedDatabase] = useState(''); const [availableDatabases, setAvailableDatabases] = useState([]); // Batch Database Operations Modal const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false); const [batchDatabases, setBatchDatabases] = useState([]); const [checkedDbKeys, setCheckedDbKeys] = useState([]); const [batchConnContext, setBatchConnContext] = useState(null); const [selectedDbConnection, setSelectedDbConnection] = useState(''); 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' ? : , 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: , 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: , 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: , type: 'queries-folder', isLeaf: dbQueries.length === 0, children: dbQueries.map(q => ({ title: q.name, key: q.id, icon: , 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: , 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: , type: 'folder-columns', isLeaf: true, dataRef: conn }, { title: '索引', key: `${key}-indexes`, icon: , type: 'folder-indexes', isLeaf: true, dataRef: conn }, { title: '外键', key: `${key}-fks`, icon: , type: 'folder-fks', isLeaf: true, dataRef: conn }, { title: '触发器', key: `${key}-triggers`, icon: , 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) => { 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: , onClick: () => loadDatabases(node) }, { type: 'divider' }, { key: 'new-command', label: '新建命令窗口', icon: , 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: , onClick: () => { if (onEditConnection) onEditConnection(node.dataRef); } }, { key: 'disconnect', label: '断开连接', icon: , 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: , danger: true, onClick: () => { Modal.confirm({ title: '确认删除', content: `确定要删除连接 "${node.title}" 吗?`, onOk: () => removeConnection(node.key) }); } } ]; } // Regular database connection menu return [ { key: 'new-db', label: '新建数据库', icon: , onClick: () => { setTargetConnection(node); setIsCreateDbModalOpen(true); } }, { key: 'refresh', label: '刷新', icon: , onClick: () => loadDatabases(node) }, { type: 'divider' }, { key: 'new-query', label: '新建查询', icon: , onClick: () => { addTab({ id: `query-${Date.now()}`, title: `新建查询`, type: 'query', connectionId: node.key, dbName: undefined, query: '' }); } }, { type: 'divider' }, { key: 'edit', label: '编辑连接', icon: , onClick: () => { if (onEditConnection) onEditConnection(node.dataRef); } }, { key: 'disconnect', label: '断开连接', icon: , 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: , 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: , onClick: () => { addTab({ id: `redis-keys-${id}-db${redisDB}`, title: `db${redisDB}`, type: 'redis-keys', connectionId: id, redisDB: redisDB }); } }, { key: 'new-command', label: '新建命令窗口', icon: , 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: , onClick: () => openNewTableDesign(node) }, { key: 'refresh', label: '刷新', icon: , onClick: () => loadTables(node) }, { key: 'export-db-schema', label: '导出全部表结构 (SQL)', icon: , onClick: () => handleExportDatabaseSQL(node, false) }, { key: 'backup-db-sql', label: '备份全部表 (结构+数据 SQL)', icon: , onClick: () => handleExportDatabaseSQL(node, true) }, { type: 'divider' }, { key: 'disconnect-db', label: '关闭数据库', icon: , 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: , 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: , onClick: () => handleRunSQLFile(node) } ]; } else if (node.type === 'table') { return [ { key: 'new-query', label: '新建查询', icon: , 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: , onClick: () => openDesign(node, 'columns', false) }, { key: 'copy-structure', label: '复制表结构', icon: , onClick: () => handleCopyStructure(node) }, { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(node, 'sql') }, { type: 'divider' }, { key: 'export', label: '导出表数据', icon: , 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' ? ( ) : null; return {statusBadge}{node.title}; }; const onRightClick = ({ event, node }: any) => { const items = getNodeMenuItems(node); if (items && items.length > 0) { setContextMenu({ x: event.clientX, y: event.clientY, items }); } }; return (
{/* Toolbar for batch operations - always visible */}
{contextMenu && ( { if (!open) setContextMenu(null); }} trigger={['contextMenu']} >
)} setIsCreateDbModalOpen(false)} >
{/* Charset option could be added here */}
setIsBatchModalOpen(false)} width={600} footer={[ , , ]} >
{batchTables.length > 0 && ( <>
已选择 {checkedTableKeys.length} / {batchTables.length} 张表
setCheckedTableKeys(values as string[])} style={{ width: '100%' }} > {batchTables.map(table => ( {table.title} ))}
)}
setIsBatchDbModalOpen(false)} width={600} footer={[ , , ]} >
{batchDatabases.length > 0 && ( <>
已选择 {checkedDbKeys.length} / {batchDatabases.length} 个库
setCheckedDbKeys(values as string[])} style={{ width: '100%' }} > {batchDatabases.map(db => ( {db.title} ))}
)}
); }; export default Sidebar;