mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-10 17:43:15 +08:00
✨ feat(Sidebar/FindInDatabaseModal): 新增全局数据库搜索功能
- 数据库右键菜单新增「在数据库中搜索」入口 - 逐表搜索文本列,支持包含/精确匹配两种模式 - 智能过滤非文本列(int/blob/date 等自动跳过) - 兼容 MySQL LIMIT / SQL Server TOP / Oracle FETCH FIRST - 结果以汇总表格展示,支持展开查看匹配行详情 - refs #240
This commit is contained in:
462
frontend/src/components/FindInDatabaseModal.tsx
Normal file
462
frontend/src/components/FindInDatabaseModal.tsx
Normal file
@@ -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<string, any>[];
|
||||
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<FindInDatabaseModalProps> = ({ open, onClose, connectionId, dbName }) => {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [matchMode, setMatchMode] = useState<'contains' | 'exact'>('contains');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, tableName: '' });
|
||||
const [expandedTable, setExpandedTable] = useState<string | null>(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<string, Array<{ name: string; type: string }>> = {};
|
||||
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<string>();
|
||||
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) => (
|
||||
<span style={{ fontWeight: 500, color: wt.titleText }}>
|
||||
<DatabaseOutlined style={{ marginRight: 6, color: wt.iconColor }} />
|
||||
{text}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '匹配列',
|
||||
dataIndex: 'matchedColumns',
|
||||
key: 'matchedColumns',
|
||||
render: (cols: string[]) => (
|
||||
<Space size={4} wrap>
|
||||
{cols.map(col => (
|
||||
<Tag key={col} color="blue" style={{ margin: 0, fontSize: 12 }}>{col}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '命中行数',
|
||||
dataIndex: 'matchCount',
|
||||
key: 'matchCount',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (count: number) => (
|
||||
<Tag color={count >= MAX_MATCH_ROWS_PER_TABLE ? 'orange' : 'green'}>
|
||||
{count >= MAX_MATCH_ROWS_PER_TABLE ? `≥${count}` : count}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: any, record: SearchResultItem) => (
|
||||
<Tooltip title={expandedTable === record.tableName ? '收起详情' : '查看详情'}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); setExpandedTable(prev => prev === record.tableName ? null : record.tableName); }}
|
||||
style={{ color: wt.iconColor }}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
], [wt, expandedTable]);
|
||||
|
||||
// 展开的详情行 - 动态列
|
||||
const expandedResult = useMemo(() => {
|
||||
if (!expandedTable) return null;
|
||||
return results.find(r => r.tableName === expandedTable);
|
||||
}, [expandedTable, results]);
|
||||
|
||||
const detailColumns = useMemo(() => {
|
||||
if (!expandedResult) return [];
|
||||
const lowerKeyword = keyword.trim().toLowerCase();
|
||||
return expandedResult.columns.map(col => ({
|
||||
title: col,
|
||||
dataIndex: col,
|
||||
key: col,
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (value: any) => {
|
||||
const strVal = value != null ? String(value) : '';
|
||||
const isMatch = expandedResult.matchedColumns.includes(col) &&
|
||||
strVal.toLowerCase().includes(lowerKeyword);
|
||||
return (
|
||||
<Tooltip title={strVal} placement="topLeft">
|
||||
<span style={isMatch ? { background: 'rgba(255, 193, 7, 0.3)', padding: '1px 3px', borderRadius: 3 } : undefined}>
|
||||
{strVal || <span style={{ color: wt.mutedText }}>NULL</span>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
}));
|
||||
}, [expandedResult, keyword, wt]);
|
||||
|
||||
const percent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<span style={{ color: wt.titleText, fontWeight: 600 }}>
|
||||
<SearchOutlined style={{ marginRight: 8, color: wt.iconColor }} />
|
||||
在数据库中搜索 — {dbName}
|
||||
</span>
|
||||
}
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={960}
|
||||
styles={{
|
||||
content: {
|
||||
background: wt.shellBg,
|
||||
borderRadius: 16,
|
||||
border: wt.shellBorder,
|
||||
boxShadow: wt.shellShadow,
|
||||
backdropFilter: wt.shellBackdropFilter,
|
||||
WebkitBackdropFilter: wt.shellBackdropFilter,
|
||||
},
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8 },
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="输入要搜索的字符串..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onPressEnter={!searching ? handleSearch : undefined}
|
||||
style={{ flex: 1 }}
|
||||
disabled={searching}
|
||||
autoFocus
|
||||
/>
|
||||
<Select
|
||||
value={matchMode}
|
||||
onChange={v => setMatchMode(v)}
|
||||
disabled={searching}
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ label: '包含', value: 'contains' },
|
||||
{ label: '精确匹配', value: 'exact' },
|
||||
]}
|
||||
/>
|
||||
{searching ? (
|
||||
<Button icon={<StopOutlined />} danger onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch} disabled={!keyword.trim()}>
|
||||
搜索
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{searching && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Progress
|
||||
percent={percent}
|
||||
size="small"
|
||||
status="active"
|
||||
strokeColor={wt.iconColor}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: wt.mutedText }}>
|
||||
正在搜索 {progress.tableName}... ({progress.current}/{progress.total})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果汇总表 */}
|
||||
{results.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 13, color: wt.mutedText, fontWeight: 500 }}>
|
||||
找到 {results.length} 个表包含匹配数据
|
||||
{searching && '(搜索进行中...)'}
|
||||
</div>
|
||||
<Table
|
||||
dataSource={results}
|
||||
columns={summaryColumns}
|
||||
rowKey="tableName"
|
||||
size="small"
|
||||
pagination={false}
|
||||
style={{ borderRadius: 8, overflow: 'hidden' }}
|
||||
scroll={{ y: expandedTable ? 200 : 400 }}
|
||||
onRow={(record) => ({
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
background: expandedTable === record.tableName ? wt.hoverBg : undefined,
|
||||
},
|
||||
onClick: () => setExpandedTable(prev => prev === record.tableName ? null : record.tableName),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情展开 */}
|
||||
{expandedResult && (
|
||||
<div style={{
|
||||
border: wt.sectionBorder,
|
||||
borderRadius: 8,
|
||||
background: wt.sectionBg,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: wt.sectionBorder,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: wt.titleText,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span>
|
||||
<DatabaseOutlined style={{ marginRight: 6 }} />
|
||||
{expandedResult.tableName} — 匹配行详情
|
||||
</span>
|
||||
<Tag color="blue">{expandedResult.rows.length} 行</Tag>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={expandedResult.rows.map((row, i) => ({ ...row, __rowIdx: i }))}
|
||||
columns={detailColumns}
|
||||
rowKey="__rowIdx"
|
||||
size="small"
|
||||
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false }}
|
||||
scroll={{ x: Math.max(800, expandedResult.columns.length * 180) }}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无结果且搜索完成 */}
|
||||
{!searching && results.length === 0 && progress.total > 0 && (
|
||||
<Empty description="未找到匹配的数据" style={{ margin: '24px 0' }} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FindInDatabaseModal;
|
||||
@@ -38,6 +38,7 @@ import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -282,6 +283,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [batchConnContext, setBatchConnContext] = useState<any>(null);
|
||||
const [selectedDbConnection, setSelectedDbConnection] = useState<string>('');
|
||||
|
||||
// Find in Database Modal
|
||||
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh queries for expanded databases
|
||||
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
|
||||
@@ -4312,6 +4316,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<FindInDatabaseModal
|
||||
open={findInDbContext.open}
|
||||
onClose={() => setFindInDbContext({ open: false, connectionId: '', dbName: '' })}
|
||||
connectionId={findInDbContext.connectionId}
|
||||
dbName={findInDbContext.dbName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user