mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-14 17:03:25 +08:00
- 重构工具中心为侧边分组导航,固定弹窗高度并支持内部滚动 - 新增通用可拖拽可缩放 Modal,统一主要弹窗打开体验 - 为工具中心内嵌入口补充返回上一步交互与底部操作区 - 补充多语言文案和工具中心/Modal/i18n 回归测试
459 lines
20 KiB
TypeScript
459 lines
20 KiB
TypeScript
import Modal from './common/ResizableDraggableModal';
|
|
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
|
import { 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';
|
|
import { useI18n } from '../i18n/provider';
|
|
|
|
interface FindInDatabaseModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
connectionId: string;
|
|
dbName: string;
|
|
}
|
|
|
|
interface SearchResultItem {
|
|
tableName: string;
|
|
matchedColumns: string[];
|
|
matchCount: number;
|
|
rows: Record<string, any>[];
|
|
columns: string[];
|
|
}
|
|
|
|
/** Returns whether a database column type is searchable as text. */
|
|
const isTextColumnType = (colType: string): boolean => {
|
|
const t = (colType || '').toLowerCase().trim();
|
|
// Explicitly skip non-text types before falling back to searchable.
|
|
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;
|
|
// Positive text type matches.
|
|
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;
|
|
// Unknown types are attempted by default.
|
|
return true;
|
|
};
|
|
|
|
/** Builds a SELECT statement with a dialect-specific row limit. */
|
|
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 { t } = useI18n();
|
|
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 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(t('find_in_database.message.keyword_required'));
|
|
return;
|
|
}
|
|
const config = buildConfig();
|
|
if (!config) {
|
|
message.error(t('find_in_database.message.connection_config_not_found'));
|
|
return;
|
|
}
|
|
|
|
setSearching(true);
|
|
setResults([]);
|
|
setExpandedTable(null);
|
|
cancelledRef.current = false;
|
|
|
|
try {
|
|
const tablesRes = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
|
|
if (!tablesRes.success) {
|
|
message.error(t('find_in_database.message.get_tables_failed', { detail: 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(t('find_in_database.message.no_tables'));
|
|
setSearching(false);
|
|
return;
|
|
}
|
|
|
|
setProgress({ current: 0, total: tableNames.length, tableName: '' });
|
|
|
|
const allColsRes = await DBGetAllColumns(buildRpcConnectionConfig(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);
|
|
|
|
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;
|
|
|
|
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<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 {
|
|
// Per-table query failures should not stop the whole search.
|
|
}
|
|
}
|
|
|
|
if (!cancelledRef.current) {
|
|
setResults([...searchResults]);
|
|
if (searchResults.length === 0) {
|
|
message.info(t('find_in_database.message.no_matches'));
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
message.error(t('find_in_database.message.search_failed', { detail: e?.message || String(e) }));
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}, [keyword, matchMode, dbName, dbType, buildConfig, t]);
|
|
|
|
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: t('find_in_database.column.table_name'),
|
|
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: t('find_in_database.column.matched_columns'),
|
|
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: t('find_in_database.column.match_count'),
|
|
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: t('find_in_database.column.action'),
|
|
key: 'action',
|
|
width: 80,
|
|
align: 'center' as const,
|
|
render: (_: any, record: SearchResultItem) => (
|
|
<Tooltip title={expandedTable === record.tableName ? t('find_in_database.tooltip.collapse_details') : t('find_in_database.tooltip.view_details')}>
|
|
<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, t]);
|
|
|
|
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 }} />
|
|
{t('find_in_database.title', { 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 },
|
|
}}
|
|
destroyOnHidden
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<Input
|
|
placeholder={t('find_in_database.placeholder.keyword')}
|
|
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: t('find_in_database.match.contains'), value: 'contains' },
|
|
{ label: t('find_in_database.match.exact'), value: 'exact' },
|
|
]}
|
|
/>
|
|
{searching ? (
|
|
<Button icon={<StopOutlined />} danger onClick={handleCancel}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
) : (
|
|
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch} disabled={!keyword.trim()}>
|
|
{t('common.search')}
|
|
</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 }}>
|
|
{t('find_in_database.progress.searching_table', {
|
|
table: progress.tableName,
|
|
current: progress.current,
|
|
total: progress.total,
|
|
})}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{results.length > 0 && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ fontSize: 13, color: wt.mutedText, fontWeight: 500 }}>
|
|
{t('find_in_database.summary.found_tables', { count: results.length })}
|
|
{searching && t('find_in_database.summary.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 }} />
|
|
{t('find_in_database.detail.title', { table: expandedResult.tableName })}
|
|
</span>
|
|
<Tag color="blue">{t('find_in_database.detail.row_count', { count: 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={t('find_in_database.message.no_matches')} style={{ margin: '24px 0' }} />
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default FindInDatabaseModal;
|