mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
- 数据库右键菜单新增「在数据库中搜索」入口 - 逐表搜索文本列,支持包含/精确匹配两种模式 - 智能过滤非文本列(int/blob/date 等自动跳过) - 兼容 MySQL LIMIT / SQL Server TOP / Oracle FETCH FIRST - 结果以汇总表格展示,支持展开查看匹配行详情 - refs #240
463 lines
19 KiB
TypeScript
463 lines
19 KiB
TypeScript
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;
|