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'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isMacLikePlatform } from '../utils/appearance'; 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 disableLocalBackdropFilter = isMacLikePlatform(); 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, { disableBackdropFilter: disableLocalBackdropFilter }); }, [disableLocalBackdropFilter, 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(buildRpcConnectionConfig(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(buildRpcConnectionConfig(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(buildRpcConnectionConfig(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) => (