diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 0f8f4fe..a7661c0 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file +d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ccff62a..2c38879 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -911,18 +911,24 @@ function App() { }, []); const handleNewQuery = () => { - let connId = activeContext?.connectionId || ''; - let db = activeContext?.dbName || ''; + let connId = ''; + let db = ''; - // Priority: Active Tab Context > Sidebar Selection + // Priority: Active Tab Context (if connection still valid) > Sidebar Selection (activeContext) if (activeTabId) { const currentTab = tabs.find(t => t.id === activeTabId); - if (currentTab && currentTab.connectionId) { + if (currentTab && currentTab.connectionId && connections.some(c => c.id === currentTab.connectionId)) { connId = currentTab.connectionId; db = currentTab.dbName || ''; } } + // Fallback: Sidebar selection context (only if connection still valid) + if (!connId && activeContext?.connectionId && connections.some(c => c.id === activeContext.connectionId)) { + connId = activeContext.connectionId; + db = activeContext.dbName || ''; + } + addTab({ id: `query-${Date.now()}`, title: '新建查询', diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 00eaad6..b4b10bb 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -690,7 +690,7 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { ]; return ( - + document.body} autoAdjustOverflow> {children} ); @@ -1099,10 +1099,25 @@ const DataGrid: React.FC = ({ e.preventDefault(); e.stopPropagation(); const titleText = typeof (title as any) === 'string' ? (title as string) : (typeof (title as any) === 'number' ? String(title) : String(dataIndex)); + // 预估菜单尺寸(菜单项数 × 行高 + 分隔线 + padding) + const estimatedMenuHeight = 320; + const estimatedMenuWidth = 200; + const viewportH = window.innerHeight; + const viewportW = window.innerWidth; + let menuY = e.clientY; + let menuX = e.clientX; + // 底部空间不足时向上偏移 + if (menuY + estimatedMenuHeight > viewportH) { + menuY = Math.max(4, viewportH - estimatedMenuHeight); + } + // 右侧空间不足时向左偏移 + if (menuX + estimatedMenuWidth > viewportW) { + menuX = Math.max(4, viewportW - estimatedMenuWidth); + } setCellContextMenu({ visible: true, - x: e.clientX, - y: e.clientY, + x: menuX, + y: menuY, record, dataIndex, title: titleText, @@ -1358,6 +1373,25 @@ const DataGrid: React.FC = ({ .${gridId} .ant-table-tbody > tr > td, .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } .${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } + /* 选择列对齐:header TH 无 class(Ant Design 虚拟模式),需用 :first-child 匹配 */ + .${gridId} .ant-table-selection-col, + .${gridId} .ant-table-bordered .ant-table-selection-col, + .${gridId} .ant-table-selection-col.ant-table-selection-col-with-dropdown { + width: ${selectionColumnWidth}px !important; + } + .${gridId} .ant-table-header th:first-child, + .${gridId} .ant-table-thead > tr > th:first-child { + text-align: center !important; + padding-inline-start: 0 !important; + padding-inline-end: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; + } + .${gridId} .ant-table-selection-column { + text-align: center !important; + padding-inline-start: 0 !important; + padding-inline-end: 0 !important; + } .${gridId} .ant-table-thead > tr:first-child > th:first-child, .${gridId} .ant-table-header table > thead > tr:first-child > th:first-child { border-top-left-radius: ${panelRadius}px !important; @@ -4204,8 +4238,16 @@ const DataGrid: React.FC = ({ setSelectedRowKeys([]); onReload(); }}>刷新} - {canImport && } - {canExport && } + + {onToggleFilter && ( + <> +
+ + + )} {canModifyData && ( <> @@ -4295,13 +4337,11 @@ const DataGrid: React.FC = ({ )} - {onToggleFilter && ( + {(canImport || canExport) && ( <>
- + {canImport && } + {canExport && } )} @@ -4771,6 +4811,8 @@ const DataGrid: React.FC = ({ borderRadius: 4, boxShadow: '0 2px 8px rgba(0,0,0,0.15)', minWidth: 160, + maxHeight: `calc(100vh - ${cellContextMenu.y}px - 8px)`, + overflowY: 'auto', color: darkMode ? '#fff' : 'rgba(0, 0, 0, 0.88)' }} onClick={(e) => e.stopPropagation()} diff --git a/frontend/src/components/FindInDatabaseModal.tsx b/frontend/src/components/FindInDatabaseModal.tsx new file mode 100644 index 0000000..cbe3da2 --- /dev/null +++ b/frontend/src/components/FindInDatabaseModal.tsx @@ -0,0 +1,462 @@ +import React, { useState, useRef, useCallback, useMemo } from 'react'; +import { Modal, Input, Button, Table, Progress, Space, Tag, message, Tooltip, Select, Empty } from 'antd'; +import { SearchOutlined, StopOutlined, EyeOutlined, DatabaseOutlined } from '@ant-design/icons'; +import { DBQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/app/App'; +import { quoteIdentPart, escapeLiteral } from '../utils/sql'; +import { useStore } from '../store'; +import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; + +interface FindInDatabaseModalProps { + open: boolean; + onClose: () => void; + connectionId: string; + dbName: string; +} + +interface SearchResultItem { + tableName: string; + matchedColumns: string[]; + matchCount: number; + rows: Record[]; + columns: string[]; +} + +/** 判断数据库列类型是否为文本类型(只搜索文本字段) */ +const isTextColumnType = (colType: string): boolean => { + const t = (colType || '').toLowerCase().trim(); + // 显式排除非文本类型 + if (/^(int|bigint|smallint|tinyint|mediumint|float|double|decimal|numeric|real|money|smallmoney|bit|boolean|bool)/.test(t)) return false; + if (/^(date|time|datetime|timestamp|year|interval)/.test(t)) return false; + if (/^(blob|binary|varbinary|image|bytea|raw|long raw)/.test(t)) return false; + if (/^(geometry|geography|point|line|polygon|spatial)/.test(t)) return false; + if (/^(json|jsonb|xml|uuid|uniqueidentifier)/.test(t)) return false; + if (/^(serial|bigserial|smallserial|autoincrement|identity)/.test(t)) return false; + // 文本类型正匹配 + if (/^(varchar|char|nvarchar|nchar|text|ntext|tinytext|mediumtext|longtext|string|clob|nclob|character)/.test(t)) return true; + if (t === 'sysname' || t === 'sql_variant') return true; + // 未知类型默认尝试搜索 + return true; +}; + +/** 根据 dbType 构建限制返回行数的 SELECT SQL */ +const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number): string => { + const normalizedType = (dbType || '').toLowerCase(); + switch (normalizedType) { + case 'sqlserver': + case 'mssql': + return baseSql.replace(/^SELECT\b/i, `SELECT TOP ${limit}`); + case 'oracle': + case 'dameng': + return `${baseSql} FETCH FIRST ${limit} ROWS ONLY`; + default: + return `${baseSql} LIMIT ${limit}`; + } +}; + +const MAX_MATCH_ROWS_PER_TABLE = 100; + +const FindInDatabaseModal: React.FC = ({ open, onClose, connectionId, dbName }) => { + const [keyword, setKeyword] = useState(''); + const [matchMode, setMatchMode] = useState<'contains' | 'exact'>('contains'); + const [searching, setSearching] = useState(false); + const [results, setResults] = useState([]); + const [progress, setProgress] = useState({ current: 0, total: 0, tableName: '' }); + const [expandedTable, setExpandedTable] = useState(null); + const cancelledRef = useRef(false); + + const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + + const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]); + const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]); + + const wt = useMemo(() => { + const isDark = theme === 'dark'; + return buildOverlayWorkbenchTheme(isDark); + }, [theme]); + + const buildConfig = useCallback(() => { + if (!conn) return null; + return { + ...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: "" } + }; + }, [conn]); + + const handleSearch = useCallback(async () => { + const searchKeyword = keyword.trim(); + if (!searchKeyword) { + message.warning('请输入搜索关键字'); + return; + } + const config = buildConfig(); + if (!config) { + message.error('未找到连接配置'); + return; + } + + setSearching(true); + setResults([]); + setExpandedTable(null); + cancelledRef.current = false; + + try { + // 1. 获取所有表 + const tablesRes = await DBGetTables(config as any, dbName); + if (!tablesRes.success) { + message.error('获取表列表失败: ' + tablesRes.message); + setSearching(false); + return; + } + const tableRows: any[] = Array.isArray(tablesRes.data) ? tablesRes.data : []; + const tableNames = tableRows.map((row: any) => Object.values(row)[0] as string).filter(Boolean); + + if (tableNames.length === 0) { + message.info('当前数据库没有表'); + setSearching(false); + return; + } + + setProgress({ current: 0, total: tableNames.length, tableName: '' }); + + // 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段) + const allColsRes = await DBGetAllColumns(config as any, dbName); + const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : []; + + // 按表名分组 + const columnsByTable: Record> = {}; + allColumns.forEach((col: any) => { + const tbl = col.tableName || ''; + if (!columnsByTable[tbl]) columnsByTable[tbl] = []; + columnsByTable[tbl].push({ name: col.name, type: col.type || '' }); + }); + + const searchResults: SearchResultItem[] = []; + const escapedKeyword = escapeLiteral(searchKeyword); + + // 3. 逐表搜索 + for (let i = 0; i < tableNames.length; i++) { + if (cancelledRef.current) break; + + const tableName = tableNames[i]; + setProgress({ current: i + 1, total: tableNames.length, tableName }); + + // 获取该表的文本列 + const tableCols = columnsByTable[tableName] || []; + const textCols = tableCols.filter(c => isTextColumnType(c.type)); + + if (textCols.length === 0) continue; + + // 构建 WHERE 子句 + const castType = (dbType === 'sqlserver' || dbType === 'mssql') ? 'NVARCHAR(MAX)' : 'CHAR'; + const whereConditions = textCols.map(c => { + const quotedCol = quoteIdentPart(dbType, c.name); + if (matchMode === 'exact') { + return `CAST(${quotedCol} AS ${castType}) = '${escapedKeyword}'`; + } + return `CAST(${quotedCol} AS ${castType}) LIKE '%${escapedKeyword}%'`; + }); + + const quotedTable = quoteIdentPart(dbType, tableName); + const baseSql = `SELECT * FROM ${quotedTable} WHERE ${whereConditions.join(' OR ')}`; + const sql = buildLimitedSelectSQL(dbType, baseSql, MAX_MATCH_ROWS_PER_TABLE); + + try { + const res = await DBQuery(config as any, dbName, sql); + if (res.success && Array.isArray(res.data) && res.data.length > 0) { + // 检查哪些列实际匹配了 + const matchedCols = new Set(); + const lowerKeyword = searchKeyword.toLowerCase(); + res.data.forEach((row: any) => { + textCols.forEach(c => { + const val = row[c.name]; + if (val != null) { + const strVal = String(val).toLowerCase(); + if (matchMode === 'exact' ? strVal === lowerKeyword : strVal.includes(lowerKeyword)) { + matchedCols.add(c.name); + } + } + }); + }); + + if (matchedCols.size > 0) { + const columns = Object.keys(res.data[0]); + searchResults.push({ + tableName, + matchedColumns: Array.from(matchedCols), + matchCount: res.data.length, + rows: res.data, + columns, + }); + setResults([...searchResults]); + } + } + } catch { + // 单表查询失败不中断整体搜索 + } + } + + if (!cancelledRef.current) { + setResults([...searchResults]); + if (searchResults.length === 0) { + message.info('未找到匹配的数据'); + } + } + } catch (e: any) { + message.error('搜索出错: ' + (e?.message || String(e))); + } finally { + setSearching(false); + } + }, [keyword, matchMode, dbName, dbType, buildConfig]); + + const handleCancel = useCallback(() => { + cancelledRef.current = true; + }, []); + + const handleClose = useCallback(() => { + cancelledRef.current = true; + setResults([]); + setExpandedTable(null); + setProgress({ current: 0, total: 0, tableName: '' }); + onClose(); + }, [onClose]); + + // 汇总表的列定义 + const summaryColumns = useMemo(() => [ + { + title: '表名', + dataIndex: 'tableName', + key: 'tableName', + width: 220, + render: (text: string) => ( + + + {text} + + ), + }, + { + title: '匹配列', + dataIndex: 'matchedColumns', + key: 'matchedColumns', + render: (cols: string[]) => ( + + {cols.map(col => ( + {col} + ))} + + ), + }, + { + title: '命中行数', + dataIndex: 'matchCount', + key: 'matchCount', + width: 100, + align: 'center' as const, + render: (count: number) => ( + = MAX_MATCH_ROWS_PER_TABLE ? 'orange' : 'green'}> + {count >= MAX_MATCH_ROWS_PER_TABLE ? `≥${count}` : count} + + ), + }, + { + title: '操作', + key: 'action', + width: 80, + align: 'center' as const, + render: (_: any, record: SearchResultItem) => ( + +