mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4e20a34a | ||
|
|
8efa7e2de6 | ||
|
|
ecee206304 | ||
|
|
299dceb01c | ||
|
|
5cad761bdd | ||
|
|
b8728170ec | ||
|
|
4ce4cdaad8 | ||
|
|
cc7ef12029 | ||
|
|
5b6403f266 | ||
|
|
caceb2868d | ||
|
|
e7b9ff4a10 |
@@ -1 +1 @@
|
||||
5b8157374dae5f9340e31b2d0bd2c00e
|
||||
d0f9366af59a6367ad3c7e2d4185ead4
|
||||
@@ -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: '新建查询',
|
||||
|
||||
@@ -690,7 +690,7 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} getPopupContainer={() => document.body} autoAdjustOverflow>
|
||||
<tr {...props}>{children}</tr>
|
||||
</Dropdown>
|
||||
);
|
||||
@@ -1099,10 +1099,25 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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<DataGridProps> = ({
|
||||
.${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<DataGridProps> = ({
|
||||
setSelectedRowKeys([]);
|
||||
onReload();
|
||||
}}>刷新</Button>}
|
||||
{canImport && <Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>}
|
||||
{canExport && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>导出 <DownOutlined /></Button></Dropdown>}
|
||||
|
||||
{onToggleFilter && (
|
||||
<>
|
||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => {
|
||||
onToggleFilter();
|
||||
if (filterConditions.length === 0 && !showFilter) addFilter();
|
||||
}}>筛选</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canModifyData && (
|
||||
<>
|
||||
@@ -4295,13 +4337,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{onToggleFilter && (
|
||||
{(canImport || canExport) && (
|
||||
<>
|
||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => {
|
||||
onToggleFilter();
|
||||
if (filterConditions.length === 0 && !showFilter) addFilter();
|
||||
}}>筛选</Button>
|
||||
{canImport && <Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>}
|
||||
{canExport && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>导出 <DownOutlined /></Button></Dropdown>}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4771,6 +4811,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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()}
|
||||
|
||||
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;
|
||||
@@ -20,6 +20,156 @@ const SQL_KEYWORDS = [
|
||||
'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN',
|
||||
];
|
||||
|
||||
// SQL 常用内置函数(通用,适用于 MySQL/PostgreSQL/Oracle/SQL Server 等主流数据源)
|
||||
const SQL_FUNCTIONS: { name: string; detail: string }[] = [
|
||||
// 聚合函数
|
||||
{ name: 'COUNT', detail: '聚合 - 计数' },
|
||||
{ name: 'SUM', detail: '聚合 - 求和' },
|
||||
{ name: 'AVG', detail: '聚合 - 平均值' },
|
||||
{ name: 'MAX', detail: '聚合 - 最大值' },
|
||||
{ name: 'MIN', detail: '聚合 - 最小值' },
|
||||
{ name: 'GROUP_CONCAT', detail: '聚合 - 拼接分组值' },
|
||||
// 字符串函数
|
||||
{ name: 'CONCAT', detail: '字符串 - 拼接' },
|
||||
{ name: 'CONCAT_WS', detail: '字符串 - 带分隔符拼接' },
|
||||
{ name: 'SUBSTRING', detail: '字符串 - 截取子串' },
|
||||
{ name: 'SUBSTR', detail: '字符串 - 截取子串' },
|
||||
{ name: 'LEFT', detail: '字符串 - 从左截取' },
|
||||
{ name: 'RIGHT', detail: '字符串 - 从右截取' },
|
||||
{ name: 'LENGTH', detail: '字符串 - 字节长度' },
|
||||
{ name: 'CHAR_LENGTH', detail: '字符串 - 字符长度' },
|
||||
{ name: 'UPPER', detail: '字符串 - 转大写' },
|
||||
{ name: 'LOWER', detail: '字符串 - 转小写' },
|
||||
{ name: 'TRIM', detail: '字符串 - 去空格' },
|
||||
{ name: 'LTRIM', detail: '字符串 - 去左空格' },
|
||||
{ name: 'RTRIM', detail: '字符串 - 去右空格' },
|
||||
{ name: 'REPLACE', detail: '字符串 - 替换' },
|
||||
{ name: 'REVERSE', detail: '字符串 - 反转' },
|
||||
{ name: 'REPEAT', detail: '字符串 - 重复' },
|
||||
{ name: 'LPAD', detail: '字符串 - 左填充' },
|
||||
{ name: 'RPAD', detail: '字符串 - 右填充' },
|
||||
{ name: 'INSTR', detail: '字符串 - 查找位置' },
|
||||
{ name: 'LOCATE', detail: '字符串 - 查找位置' },
|
||||
{ name: 'FIND_IN_SET', detail: '字符串 - 在集合中查找' },
|
||||
{ name: 'FORMAT', detail: '字符串 - 数字格式化' },
|
||||
{ name: 'SPACE', detail: '字符串 - 生成空格' },
|
||||
{ name: 'INSERT', detail: '字符串 - 插入替换' },
|
||||
{ name: 'FIELD', detail: '字符串 - 返回位置索引' },
|
||||
{ name: 'ELT', detail: '字符串 - 按索引返回' },
|
||||
{ name: 'HEX', detail: '字符串 - 十六进制编码' },
|
||||
{ name: 'UNHEX', detail: '字符串 - 十六进制解码' },
|
||||
// 数学函数
|
||||
{ name: 'ABS', detail: '数学 - 绝对值' },
|
||||
{ name: 'CEIL', detail: '数学 - 向上取整' },
|
||||
{ name: 'CEILING', detail: '数学 - 向上取整' },
|
||||
{ name: 'FLOOR', detail: '数学 - 向下取整' },
|
||||
{ name: 'ROUND', detail: '数学 - 四舍五入' },
|
||||
{ name: 'TRUNCATE', detail: '数学 - 截断小数' },
|
||||
{ name: 'MOD', detail: '数学 - 取模' },
|
||||
{ name: 'RAND', detail: '数学 - 随机数' },
|
||||
{ name: 'SIGN', detail: '数学 - 符号' },
|
||||
{ name: 'POWER', detail: '数学 - 幂运算' },
|
||||
{ name: 'POW', detail: '数学 - 幂运算' },
|
||||
{ name: 'SQRT', detail: '数学 - 平方根' },
|
||||
{ name: 'LOG', detail: '数学 - 对数' },
|
||||
{ name: 'LOG2', detail: '数学 - 以2为底对数' },
|
||||
{ name: 'LOG10', detail: '数学 - 以10为底对数' },
|
||||
{ name: 'LN', detail: '数学 - 自然对数' },
|
||||
{ name: 'EXP', detail: '数学 - e的次方' },
|
||||
{ name: 'PI', detail: '数学 - 圆周率' },
|
||||
{ name: 'GREATEST', detail: '数学 - 返回最大值' },
|
||||
{ name: 'LEAST', detail: '数学 - 返回最小值' },
|
||||
// 日期时间函数
|
||||
{ name: 'NOW', detail: '日期 - 当前日期时间' },
|
||||
{ name: 'CURDATE', detail: '日期 - 当前日期' },
|
||||
{ name: 'CURRENT_DATE', detail: '日期 - 当前日期' },
|
||||
{ name: 'CURTIME', detail: '日期 - 当前时间' },
|
||||
{ name: 'CURRENT_TIME', detail: '日期 - 当前时间' },
|
||||
{ name: 'CURRENT_TIMESTAMP', detail: '日期 - 当前时间戳' },
|
||||
{ name: 'SYSDATE', detail: '日期 - 系统当前时间' },
|
||||
{ name: 'DATE', detail: '日期 - 提取日期部分' },
|
||||
{ name: 'TIME', detail: '日期 - 提取时间部分' },
|
||||
{ name: 'YEAR', detail: '日期 - 提取年份' },
|
||||
{ name: 'MONTH', detail: '日期 - 提取月份' },
|
||||
{ name: 'DAY', detail: '日期 - 提取天' },
|
||||
{ name: 'DAYOFWEEK', detail: '日期 - 星期几(1=周日)' },
|
||||
{ name: 'DAYOFYEAR', detail: '日期 - 年中第几天' },
|
||||
{ name: 'HOUR', detail: '日期 - 提取小时' },
|
||||
{ name: 'MINUTE', detail: '日期 - 提取分钟' },
|
||||
{ name: 'SECOND', detail: '日期 - 提取秒' },
|
||||
{ name: 'DATE_FORMAT', detail: '日期 - 格式化' },
|
||||
{ name: 'DATE_ADD', detail: '日期 - 加日期' },
|
||||
{ name: 'DATE_SUB', detail: '日期 - 减日期' },
|
||||
{ name: 'DATEDIFF', detail: '日期 - 日期差(天)' },
|
||||
{ name: 'TIMEDIFF', detail: '日期 - 时间差' },
|
||||
{ name: 'TIMESTAMPDIFF', detail: '日期 - 时间戳差' },
|
||||
{ name: 'TIMESTAMPADD', detail: '日期 - 时间戳加' },
|
||||
{ name: 'STR_TO_DATE', detail: '日期 - 字符串转日期' },
|
||||
{ name: 'UNIX_TIMESTAMP', detail: '日期 - Unix时间戳' },
|
||||
{ name: 'FROM_UNIXTIME', detail: '日期 - 从Unix时间戳转换' },
|
||||
{ name: 'LAST_DAY', detail: '日期 - 月末日期' },
|
||||
{ name: 'WEEK', detail: '日期 - 第几周' },
|
||||
{ name: 'QUARTER', detail: '日期 - 第几季度' },
|
||||
{ name: 'ADDDATE', detail: '日期 - 加日期' },
|
||||
{ name: 'SUBDATE', detail: '日期 - 减日期' },
|
||||
// 条件/流程控制函数
|
||||
{ name: 'IF', detail: '条件 - 如果' },
|
||||
{ name: 'IFNULL', detail: '条件 - NULL替换' },
|
||||
{ name: 'NULLIF', detail: '条件 - 相等返回NULL' },
|
||||
{ name: 'COALESCE', detail: '条件 - 返回第一个非NULL' },
|
||||
{ name: 'CASE', detail: '条件 - 分支表达式' },
|
||||
// 类型转换
|
||||
{ name: 'CAST', detail: '转换 - 类型转换' },
|
||||
{ name: 'CONVERT', detail: '转换 - 类型/字符集转换' },
|
||||
// JSON 函数
|
||||
{ name: 'JSON_EXTRACT', detail: 'JSON - 提取值' },
|
||||
{ name: 'JSON_UNQUOTE', detail: 'JSON - 去引号' },
|
||||
{ name: 'JSON_SET', detail: 'JSON - 设置值' },
|
||||
{ name: 'JSON_INSERT', detail: 'JSON - 插入值' },
|
||||
{ name: 'JSON_REPLACE', detail: 'JSON - 替换值' },
|
||||
{ name: 'JSON_REMOVE', detail: 'JSON - 删除值' },
|
||||
{ name: 'JSON_CONTAINS', detail: 'JSON - 包含判断' },
|
||||
{ name: 'JSON_OBJECT', detail: 'JSON - 构建对象' },
|
||||
{ name: 'JSON_ARRAY', detail: 'JSON - 构建数组' },
|
||||
{ name: 'JSON_LENGTH', detail: 'JSON - 元素个数' },
|
||||
{ name: 'JSON_TYPE', detail: 'JSON - 值类型' },
|
||||
{ name: 'JSON_VALID', detail: 'JSON - 验证' },
|
||||
{ name: 'JSON_KEYS', detail: 'JSON - 获取键列表' },
|
||||
// 加密/哈希函数
|
||||
{ name: 'MD5', detail: '加密 - MD5哈希' },
|
||||
{ name: 'SHA1', detail: '加密 - SHA1哈希' },
|
||||
{ name: 'SHA2', detail: '加密 - SHA2哈希' },
|
||||
{ name: 'UUID', detail: '工具 - 生成UUID' },
|
||||
// 信息函数
|
||||
{ name: 'DATABASE', detail: '信息 - 当前数据库' },
|
||||
{ name: 'USER', detail: '信息 - 当前用户' },
|
||||
{ name: 'VERSION', detail: '信息 - MySQL版本' },
|
||||
{ name: 'CONNECTION_ID', detail: '信息 - 连接ID' },
|
||||
{ name: 'LAST_INSERT_ID', detail: '信息 - 最后插入ID' },
|
||||
{ name: 'ROW_COUNT', detail: '信息 - 影响行数' },
|
||||
{ name: 'FOUND_ROWS', detail: '信息 - 匹配总行数' },
|
||||
{ name: 'CHARSET', detail: '信息 - 字符集' },
|
||||
{ name: 'COLLATION', detail: '信息 - 排序规则' },
|
||||
// 窗口函数
|
||||
{ name: 'ROW_NUMBER', detail: '窗口 - 行号' },
|
||||
{ name: 'RANK', detail: '窗口 - 排名(有间隔)' },
|
||||
{ name: 'DENSE_RANK', detail: '窗口 - 排名(无间隔)' },
|
||||
{ name: 'NTILE', detail: '窗口 - 分桶' },
|
||||
{ name: 'LAG', detail: '窗口 - 前一行' },
|
||||
{ name: 'LEAD', detail: '窗口 - 后一行' },
|
||||
{ name: 'FIRST_VALUE', detail: '窗口 - 第一个值' },
|
||||
{ name: 'LAST_VALUE', detail: '窗口 - 最后一个值' },
|
||||
{ name: 'NTH_VALUE', detail: '窗口 - 第N个值' },
|
||||
// 其他
|
||||
{ name: 'DISTINCT', detail: '修饰 - 去重' },
|
||||
{ name: 'EXISTS', detail: '修饰 - 存在判断' },
|
||||
{ name: 'BETWEEN', detail: '修饰 - 范围判断' },
|
||||
{ name: 'LIKE', detail: '修饰 - 模式匹配' },
|
||||
{ name: 'REGEXP', detail: '修饰 - 正则匹配' },
|
||||
{ name: 'BENCHMARK', detail: '工具 - 性能测试' },
|
||||
{ name: 'SLEEP', detail: '工具 - 延时' },
|
||||
];
|
||||
|
||||
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
|
||||
|
||||
@@ -540,10 +690,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
&& wordPrefix.length > 0
|
||||
&& SQL_KEYWORDS.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
|
||||
const sortGroups = shouldBoostKeywords
|
||||
? { keyword: '00', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' }
|
||||
? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' }
|
||||
: expectsTableName
|
||||
? { keyword: '20', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' }
|
||||
: { keyword: '30', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' };
|
||||
? { keyword: '20', func: '25', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' }
|
||||
: { keyword: '30', func: '25', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' };
|
||||
|
||||
// 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等)
|
||||
// 权重最高,输入 WHERE 条件时优先显示
|
||||
@@ -610,10 +760,24 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
sortText: sortGroups.keyword + k,
|
||||
}));
|
||||
|
||||
// 内置函数提示
|
||||
const funcSuggestions = SQL_FUNCTIONS
|
||||
.filter((f) => startsWithPrefix(f.name))
|
||||
.map(f => ({
|
||||
label: f.name,
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: f.name + '($0)',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
detail: f.detail,
|
||||
range,
|
||||
sortText: sortGroups.func + f.name,
|
||||
}));
|
||||
|
||||
const suggestions = [
|
||||
...relevantColumns, // FROM 表的列最优先
|
||||
...tableSuggestions, // 表次之
|
||||
...dbSuggestions, // 数据库
|
||||
...funcSuggestions, // 内置函数
|
||||
...keywordSuggestions // 关键字最后
|
||||
];
|
||||
return { suggestions };
|
||||
@@ -776,9 +940,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
return statements;
|
||||
};
|
||||
|
||||
// DEBT: 改用 DBQueryMulti 后前端不再逐条处理语句,此函数暂时未使用。
|
||||
// 当恢复前端自动行数限制功能时需要启用。
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const getLeadingKeyword = (sql: string): string => {
|
||||
const text = (sql || '').replace(/\r\n/g, '\n');
|
||||
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||
@@ -1071,24 +1232,53 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
return -1;
|
||||
};
|
||||
|
||||
// DEBT: 改用 DBQueryMulti 后前端不再逐条处理语句,此函数暂时未使用。
|
||||
// 当恢复前端自动行数限制功能时需要启用。
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => {
|
||||
const normalizedType = (dbType || 'mysql').toLowerCase();
|
||||
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === 'clickhouse' || normalizedType === '';
|
||||
if (!supportsLimit) return { sql, applied: false, maxRows };
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };
|
||||
const normalizedType = (dbType || 'mysql').toLowerCase();
|
||||
|
||||
// 只对 SELECT 语句自动加限制
|
||||
const keyword = getLeadingKeyword(sql);
|
||||
if (keyword !== 'SELECT') return { sql, applied: false, maxRows };
|
||||
|
||||
const { main, tail } = splitSqlTail(sql);
|
||||
if (!main.trim()) return { sql, applied: false, maxRows };
|
||||
|
||||
const fromPos = findTopLevelKeyword(main, 'from');
|
||||
const limitPos = findTopLevelKeyword(main, 'limit');
|
||||
// 已有 LIMIT → 不注入
|
||||
if (limitPos >= 0 && (fromPos < 0 || limitPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
const fetchPos = findTopLevelKeyword(main, 'fetch');
|
||||
// 已有 FETCH → 不注入
|
||||
if (fetchPos >= 0 && (fromPos < 0 || fetchPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
|
||||
// SQL Server / mssql: 检查是否已有 TOP,未有则注入 SELECT TOP N
|
||||
if (normalizedType === 'sqlserver' || normalizedType === 'mssql') {
|
||||
const topPos = findTopLevelKeyword(main, 'top');
|
||||
if (topPos >= 0) return { sql, applied: false, maxRows }; // 已有 TOP
|
||||
// 在 SELECT 关键字之后插入 TOP N
|
||||
const selectPos = findTopLevelKeyword(main, 'select');
|
||||
if (selectPos < 0) return { sql, applied: false, maxRows };
|
||||
const afterSelect = selectPos + 'SELECT'.length;
|
||||
// 处理 SELECT DISTINCT 的情况
|
||||
const restAfterSelect = main.slice(afterSelect);
|
||||
const distinctMatch = restAfterSelect.match(/^(\s+DISTINCT\b)/i);
|
||||
const insertOffset = distinctMatch ? afterSelect + distinctMatch[1].length : afterSelect;
|
||||
const nextMain = main.slice(0, insertOffset) + ` TOP ${maxRows}` + main.slice(insertOffset);
|
||||
return { sql: nextMain + tail, applied: true, maxRows };
|
||||
}
|
||||
|
||||
// Oracle / Dameng: 使用 FETCH FIRST N ROWS ONLY(Oracle 12c+ 标准语法)
|
||||
if (normalizedType === 'oracle' || normalizedType === 'dameng') {
|
||||
// 检查是否已有 ROWNUM 限制
|
||||
const rownumPos = findTopLevelKeyword(main, 'rownum');
|
||||
if (rownumPos >= 0) return { sql, applied: false, maxRows };
|
||||
const offsetPos = findTopLevelKeyword(main, 'offset');
|
||||
if (offsetPos >= 0 && (fromPos < 0 || offsetPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
const nextMain = main.trimEnd() + ` FETCH FIRST ${maxRows} ROWS ONLY`;
|
||||
return { sql: nextMain + tail, applied: true, maxRows };
|
||||
}
|
||||
|
||||
// 通用 LIMIT 语法(MySQL, PostgreSQL, SQLite, ClickHouse, DuckDB 等)
|
||||
const offsetPos = findTopLevelKeyword(main, 'offset');
|
||||
const forPos = findTopLevelKeyword(main, 'for');
|
||||
const lockPos = findTopLevelKeyword(main, 'lock');
|
||||
@@ -1283,7 +1473,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
} else {
|
||||
// 非 MongoDB:使用 DBQueryMulti 一次性执行多条 SQL,后端返回多结果集
|
||||
const fullSQL = normalizedRawSQL;
|
||||
let fullSQL = normalizedRawSQL;
|
||||
if (!fullSQL.trim()) {
|
||||
message.info('没有可执行的 SQL。');
|
||||
setResultSets([]);
|
||||
@@ -1291,6 +1481,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动给 SELECT 语句注入行数限制(防止大结果集卡死)
|
||||
const maxRowsForLimit = Number(queryOptions?.maxRows) || 0;
|
||||
if (Number.isFinite(maxRowsForLimit) && maxRowsForLimit > 0) {
|
||||
const stmts = splitSQLStatements(fullSQL);
|
||||
const limitedStmts = stmts.map(s => {
|
||||
const result = applyAutoLimit(s, normalizedDbType, maxRowsForLimit);
|
||||
return result.sql;
|
||||
});
|
||||
fullSQL = limitedStmts.join(';\n');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let queryId: string;
|
||||
try {
|
||||
|
||||
@@ -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 => {
|
||||
@@ -2189,7 +2193,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: "", // No db selected
|
||||
database: (conn.config.type === 'oracle' || conn.config.type === 'dameng') ? (conn.config.database || "") : "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1397,14 +1397,23 @@ ${selectedTrigger.statement}`;
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
try {
|
||||
const res = await DBQuery(config as any, tab.dbName || '', sql);
|
||||
if (res.success) {
|
||||
message.success(successMessage);
|
||||
await fetchData();
|
||||
return true;
|
||||
// 多条 DDL 语句(如 DROP INDEX + CREATE INDEX)需要逐条执行,
|
||||
// 因为 Go MySQL 驱动默认不支持多语句 Exec。
|
||||
const statements = sql.split(/;\s*\n/).map(s => s.trim()).filter(Boolean);
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
let stmt = statements[i];
|
||||
if (!stmt.endsWith(';')) stmt += ';';
|
||||
const res = await DBQuery(config as any, tab.dbName || '', stmt);
|
||||
if (!res.success) {
|
||||
const prefix = statements.length > 1 ? `第 ${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
|
||||
message.error(prefix + res.message);
|
||||
if (i > 0) await fetchData();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
message.error('执行失败: ' + res.message);
|
||||
return false;
|
||||
message.success(successMessage);
|
||||
await fetchData();
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
message.error('执行失败: ' + (e?.message || String(e)));
|
||||
return false;
|
||||
@@ -2676,7 +2685,7 @@ END;`;
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
<pre style={{ background: '#f5f5f5', padding: '10px', borderRadius: '4px', border: '1px solid #eee', whiteSpace: 'pre-wrap' }}>
|
||||
<pre style={{ background: darkMode ? '#1e1e1e' : '#f5f5f5', color: darkMode ? '#d4d4d4' : 'inherit', padding: '10px', borderRadius: '4px', border: darkMode ? '1px solid #333' : '1px solid #eee', whiteSpace: 'pre-wrap' }}>
|
||||
{previewSql}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -109,6 +109,8 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
|
||||
// MariaDB uses same syntax as MySQL
|
||||
} else if dbType == "sphinx" {
|
||||
return connection.QueryResult{Success: false, Message: "Sphinx 暂不支持创建数据库"}
|
||||
} else if dbType == "oracle" || dbType == "dameng" {
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)的「数据库」实际为用户/Schema,暂不支持通过此入口创建,请使用 SQL 编辑器执行 CREATE USER 语句", dbType)}
|
||||
}
|
||||
|
||||
_, err = dbInst.Exec(query)
|
||||
|
||||
@@ -957,8 +957,25 @@ if %ERRORLEVEL%==0 (
|
||||
)
|
||||
call :log host process exited
|
||||
|
||||
rem -- Win10 needs extra time for kernel to release exe file handles --
|
||||
timeout /t 3 /nobreak >nul
|
||||
call :log cooldown finished, starting file replace
|
||||
|
||||
set /a RETRY=0
|
||||
:move_retry
|
||||
call :log attempt !RETRY!: trying rename-then-copy strategy
|
||||
ren "%TARGET%" "%TARGET_NAME%.old" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 (
|
||||
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if !ERRORLEVEL!==0 (
|
||||
del /F /Q "%TARGET%.old" >> "%LOG_FILE%" 2>&1
|
||||
goto move_done
|
||||
)
|
||||
call :log copy after rename failed, restoring old file
|
||||
ren "%TARGET_NAME%.old" "%TARGET_NAME%" >> "%LOG_FILE%" 2>&1
|
||||
)
|
||||
|
||||
call :log rename strategy failed, trying direct move
|
||||
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
@@ -966,8 +983,13 @@ copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
set /a RETRY+=1
|
||||
if !RETRY! LSS 20 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
if !RETRY! LSS 15 (
|
||||
set /a WAIT=1
|
||||
if !RETRY! GEQ 3 set /a WAIT=2
|
||||
if !RETRY! GEQ 6 set /a WAIT=3
|
||||
if !RETRY! GEQ 9 set /a WAIT=5
|
||||
call :log waiting !WAIT! seconds before retry
|
||||
timeout /t !WAIT! /nobreak >nul
|
||||
goto move_retry
|
||||
)
|
||||
|
||||
@@ -975,6 +997,7 @@ call :log replace failed after retries (portable mode, no elevation): check dire
|
||||
exit /b 1
|
||||
|
||||
:move_done
|
||||
del /F /Q "%TARGET%.old" >> "%LOG_FILE%" 2>&1
|
||||
start "" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log cmd start failed, trying powershell Start-Process
|
||||
|
||||
@@ -38,3 +38,37 @@ func TestBuildWindowsScriptKeepsBatchForSyntax(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWindowsScriptWin10Fixes(t *testing.T) {
|
||||
script := buildWindowsScript(
|
||||
`C:\tmp\GoNavi-v0.5.0-windows-amd64.exe`,
|
||||
`C:\Program Files\GoNavi\GoNavi.exe`,
|
||||
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.5.0`,
|
||||
`C:\Program Files\GoNavi\logs\update-install.log`,
|
||||
99999,
|
||||
)
|
||||
|
||||
// 验证 Win10 关键修复点
|
||||
win10Fixes := []struct {
|
||||
desc string
|
||||
token string
|
||||
}{
|
||||
{"cooldown after process exit", `timeout /t 3 /nobreak >nul`},
|
||||
{"cooldown log", `call :log cooldown finished, starting file replace`},
|
||||
{"rename-before-replace strategy", `ren "%TARGET%" "%TARGET_NAME%.old"`},
|
||||
{"copy after rename", `copy /Y "%SOURCE_EXE%" "%TARGET%"`},
|
||||
{"restore on copy failure", `ren "%TARGET_NAME%.old" "%TARGET_NAME%"`},
|
||||
{"direct move fallback", `call :log rename strategy failed, trying direct move`},
|
||||
{"exponential backoff tier 1", `if !RETRY! GEQ 3 set /a WAIT=2`},
|
||||
{"exponential backoff tier 2", `if !RETRY! GEQ 6 set /a WAIT=3`},
|
||||
{"exponential backoff tier 3", `if !RETRY! GEQ 9 set /a WAIT=5`},
|
||||
{"retry limit 15", `if !RETRY! LSS 15`},
|
||||
{"cleanup old file", `del /F /Q "%TARGET%.old"`},
|
||||
}
|
||||
for _, fix := range win10Fixes {
|
||||
if !strings.Contains(script, fix.token) {
|
||||
t.Errorf("Win10 fix missing [%s]: expected token: %s", fix.desc, fix.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user