From e7b9ff4a10bd19a5a683b9bbe4fc83c3d958b12f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 18 Mar 2026 17:43:10 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(data-grid):?= =?UTF-8?q?=20=E4=BC=98=E5=8C=96=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E7=AE=97=E6=B3=95=E4=B8=8E=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A0=8F=E6=8C=89=E9=92=AE=E4=BC=98=E5=85=88=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 单元格菜单 position:fixed 增加 viewport 边界碰撞检测与动态 maxHeight - 行菜单 Dropdown 通过 getPopupContainer 脱离容器 overflow 限制 - 工具栏按钮按使用频率重排:刷新 → 筛选 → [编辑区] → 导入/导出 --- frontend/src/components/DataGrid.tsx | 43 +++++++++++++++++++++------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 00eaad6..dd98cef 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -690,7 +690,7 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { ]; return ( - + document.body} autoAdjustOverflow> {children} ); @@ -1099,10 +1099,25 @@ const DataGrid: React.FC = ({ 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, @@ -4204,8 +4219,16 @@ const DataGrid: React.FC = ({ setSelectedRowKeys([]); onReload(); }}>刷新} - {canImport && } - {canExport && } + + {onToggleFilter && ( + <> +
+ + + )} {canModifyData && ( <> @@ -4295,13 +4318,11 @@ const DataGrid: React.FC = ({ )} - {onToggleFilter && ( + {(canImport || canExport) && ( <>
- + {canImport && } + {canExport && } )} @@ -4771,6 +4792,8 @@ const DataGrid: React.FC = ({ 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()} From caceb2868d278cc67c8cb338c1d889d7a8a524e9 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 18 Mar 2026 18:01:29 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E8=A2=AB?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E8=A3=81=E5=89=AA=E5=92=8C=E5=85=A8=E9=80=89?= =?UTF-8?q?checkbox=E6=9C=AA=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 单元格右键菜单增加视口边界检测,底部/右侧空间不足时自动偏移 - 菜单容器添加 maxHeight + overflowY auto,确保所有选项可滚动访问 - 修复表头选择列 TH 无 class(虚拟模式),用 :first-child 统一 padding 和对齐 - 行右键菜单 Dropdown 挂载到 document.body 并启用 autoAdjustOverflow --- frontend/src/components/DataGrid.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index dd98cef..b4b10bb 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1373,6 +1373,25 @@ const DataGrid: React.FC = ({ .${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; From 5b6403f266752bd7453cb632281a870a2929e64e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Wed, 18 Mar 2026 20:16:09 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=90=9B=20fix(update):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Win10=20=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=97=B6=E6=96=87=E4=BB=B6=E8=A2=AB=E5=8D=A0=E7=94=A8=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E6=9B=BF=E6=8D=A2=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 冷却期:进程退出后增加 3 秒等待,确保 Win10 内核释放 exe 文件句柄 - 替换策略:新增 rename-before-replace 机制,先重命名旧文件再复制新文件 - 退避重试:替换固定 1 秒间隔为指数退避(1s→2s→3s→5s),总等待约 36 秒 - 残留清理:替换成功后删除 .old 残留文件 - 测试覆盖:新增 TestBuildWindowsScriptWin10Fixes 验证全部修复点 --- internal/app/methods_update.go | 27 +++++++++++++-- .../app/methods_update_windows_script_test.go | 34 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index ae2bdfe..240f446 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -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 diff --git a/internal/app/methods_update_windows_script_test.go b/internal/app/methods_update_windows_script_test.go index 9313497..2dcbb51 100644 --- a/internal/app/methods_update_windows_script_test.go +++ b/internal/app/methods_update_windows_script_test.go @@ -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) + } + } +} + From cc7ef1202986bb5e04f6f67d2ab17e490f83c254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Wed, 18 Mar 2026 20:23:38 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=90=9B=20fix(TableDesigner):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98=E4=B8=8B?= =?UTF-8?q?=20SQL=20=E5=8F=98=E6=9B=B4=E7=A1=AE=E8=AE=A4=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E6=96=87=E5=AD=97=E4=B8=8D=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将
 的硬编码浅色背景/边框替换为 darkMode 适配的颜色值
- refs #251
---
 frontend/package.json.md5                 | 2 +-
 frontend/src/components/TableDesigner.tsx | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index 0f8f4fe..a7661c0 100755
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-5b8157374dae5f9340e31b2d0bd2c00e
\ No newline at end of file
+d0f9366af59a6367ad3c7e2d4185ead4
\ No newline at end of file
diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx
index 4a36b31..742d1a8 100644
--- a/frontend/src/components/TableDesigner.tsx
+++ b/frontend/src/components/TableDesigner.tsx
@@ -2676,7 +2676,7 @@ END;`;
             cancelText="取消"
         >
             
-
+                
                     {previewSql}
                 
From 4ce4cdaad8d39de546b2b04a7f09cd72b73f623c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Wed, 18 Mar 2026 20:32:00 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=90=9B=20fix(TableDesigner):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20MySQL=20=E7=B4=A2=E5=BC=95=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=BF=9D=E5=AD=98=E6=97=B6=E5=A4=9A=E8=AF=AD=E5=8F=A5?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executeSchemaSql 将拼接的 DDL 按分号换行拆分后逐条执行 --- frontend/src/components/TableDesigner.tsx | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 742d1a8..04b98fc 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -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; From b8728170ec77df09bdc86a26afae698f8cc234d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Wed, 18 Mar 2026 20:45:07 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=9B=20fix(CreateDatabase):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Oracle=20=E6=96=B0=E5=BB=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E6=97=B6=E5=9B=A0=E7=BC=BA=E5=B0=91=20Servic?= =?UTF-8?q?e=20Name=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端 Oracle/达梦连接保留原始 database 字段而非清空 - 后端添加 Oracle/达梦不支持此入口创建的友好提示 - refs #223 --- frontend/src/components/Sidebar.tsx | 2 +- internal/app/methods_db.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b7e7dec..26caf42 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2189,7 +2189,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: "" } }; diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 601a6a5..f95e55f 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -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) From 5cad761bddb046d6cedb6278c372479db54c210b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Wed, 18 Mar 2026 20:53:50 +0800 Subject: [PATCH 07/10] =?UTF-8?q?=E2=9C=A8=20feat(QueryEditor):=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20SQL=20=E5=86=85=E7=BD=AE=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=A1=A5=E5=85=A8=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增约 120 个常用函数(聚合/字符串/日期/JSON/窗口等分类) - 以 Function 图标区分,选中自动插入括号 - 适用于所有支持的数据源类型 - refs #248 --- frontend/src/components/QueryEditor.tsx | 170 +++++++++++++++++++++++- 1 file changed, 167 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 01507e7..2b290b6 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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 }; From 299dceb01c3ebc8b1e49e5f3359599a204f6b290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Wed, 18 Mar 2026 21:02:54 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=94=A7=20fix(QueryEditor):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=80=E5=A4=A7=E8=BF=94=E5=9B=9E=E8=A1=8C?= =?UTF-8?q?=E6=95=B0=E5=AF=B9=20SQL=20Server=20=E7=AD=89=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90=E4=B8=8D=E7=94=9F=E6=95=88=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启用 applyAutoLimit 在 SQL 层面自动注入行数限制 - SQL Server 使用 TOP N,Oracle/Dameng 使用 FETCH FIRST N ROWS ONLY - 已有 LIMIT/TOP/FETCH/ROWNUM 时自动跳过,不重复注入 - 移除相关 DEBT 标记 - refs #236 --- frontend/src/components/QueryEditor.tsx | 57 ++++++++++++++++++++----- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 2b290b6..f54534b 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -940,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'; @@ -1235,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'); @@ -1447,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([]); @@ -1455,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 { From ecee2063041680b297f1a66c8f94b5fb47beb7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=9B=BD=E9=94=8B?= Date: Wed, 18 Mar 2026 21:16:23 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E2=9C=A8=20feat(Sidebar/FindInDatabaseMo?= =?UTF-8?q?dal):=20=E6=96=B0=E5=A2=9E=E5=85=A8=E5=B1=80=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库右键菜单新增「在数据库中搜索」入口 - 逐表搜索文本列,支持包含/精确匹配两种模式 - 智能过滤非文本列(int/blob/date 等自动跳过) - 兼容 MySQL LIMIT / SQL Server TOP / Oracle FETCH FIRST - 结果以汇总表格展示,支持展开查看匹配行详情 - refs #240 --- .../src/components/FindInDatabaseModal.tsx | 462 ++++++++++++++++++ frontend/src/components/Sidebar.tsx | 10 + 2 files changed, 472 insertions(+) create mode 100644 frontend/src/components/FindInDatabaseModal.tsx diff --git a/frontend/src/components/FindInDatabaseModal.tsx b/frontend/src/components/FindInDatabaseModal.tsx new file mode 100644 index 0000000..cbe3da2 --- /dev/null +++ b/frontend/src/components/FindInDatabaseModal.tsx @@ -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[]; + columns: string[]; +} + +/** 判断数据库列类型是否为文本类型(只搜索文本字段) */ +const isTextColumnType = (colType: string): boolean => { + const t = (colType || '').toLowerCase().trim(); + // 显式排除非文本类型 + if (/^(int|bigint|smallint|tinyint|mediumint|float|double|decimal|numeric|real|money|smallmoney|bit|boolean|bool)/.test(t)) return false; + if (/^(date|time|datetime|timestamp|year|interval)/.test(t)) return false; + if (/^(blob|binary|varbinary|image|bytea|raw|long raw)/.test(t)) return false; + if (/^(geometry|geography|point|line|polygon|spatial)/.test(t)) return false; + if (/^(json|jsonb|xml|uuid|uniqueidentifier)/.test(t)) return false; + if (/^(serial|bigserial|smallserial|autoincrement|identity)/.test(t)) return false; + // 文本类型正匹配 + if (/^(varchar|char|nvarchar|nchar|text|ntext|tinytext|mediumtext|longtext|string|clob|nclob|character)/.test(t)) return true; + if (t === 'sysname' || t === 'sql_variant') return true; + // 未知类型默认尝试搜索 + return true; +}; + +/** 根据 dbType 构建限制返回行数的 SELECT SQL */ +const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number): string => { + const normalizedType = (dbType || '').toLowerCase(); + switch (normalizedType) { + case 'sqlserver': + case 'mssql': + return baseSql.replace(/^SELECT\b/i, `SELECT TOP ${limit}`); + case 'oracle': + case 'dameng': + return `${baseSql} FETCH FIRST ${limit} ROWS ONLY`; + default: + return `${baseSql} LIMIT ${limit}`; + } +}; + +const MAX_MATCH_ROWS_PER_TABLE = 100; + +const FindInDatabaseModal: React.FC = ({ open, onClose, connectionId, dbName }) => { + const [keyword, setKeyword] = useState(''); + const [matchMode, setMatchMode] = useState<'contains' | 'exact'>('contains'); + const [searching, setSearching] = useState(false); + const [results, setResults] = useState([]); + const [progress, setProgress] = useState({ current: 0, total: 0, tableName: '' }); + const [expandedTable, setExpandedTable] = useState(null); + const cancelledRef = useRef(false); + + const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + + const 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> = {}; + 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(); + const lowerKeyword = searchKeyword.toLowerCase(); + res.data.forEach((row: any) => { + textCols.forEach(c => { + const val = row[c.name]; + if (val != null) { + const strVal = String(val).toLowerCase(); + if (matchMode === 'exact' ? strVal === lowerKeyword : strVal.includes(lowerKeyword)) { + matchedCols.add(c.name); + } + } + }); + }); + + if (matchedCols.size > 0) { + const columns = Object.keys(res.data[0]); + searchResults.push({ + tableName, + matchedColumns: Array.from(matchedCols), + matchCount: res.data.length, + rows: res.data, + columns, + }); + setResults([...searchResults]); + } + } + } catch { + // 单表查询失败不中断整体搜索 + } + } + + if (!cancelledRef.current) { + setResults([...searchResults]); + if (searchResults.length === 0) { + message.info('未找到匹配的数据'); + } + } + } catch (e: any) { + message.error('搜索出错: ' + (e?.message || String(e))); + } finally { + setSearching(false); + } + }, [keyword, matchMode, dbName, dbType, buildConfig]); + + const handleCancel = useCallback(() => { + cancelledRef.current = true; + }, []); + + const handleClose = useCallback(() => { + cancelledRef.current = true; + setResults([]); + setExpandedTable(null); + setProgress({ current: 0, total: 0, tableName: '' }); + onClose(); + }, [onClose]); + + // 汇总表的列定义 + const summaryColumns = useMemo(() => [ + { + title: '表名', + dataIndex: 'tableName', + key: 'tableName', + width: 220, + render: (text: string) => ( + + + {text} + + ), + }, + { + title: '匹配列', + dataIndex: 'matchedColumns', + key: 'matchedColumns', + render: (cols: string[]) => ( + + {cols.map(col => ( + {col} + ))} + + ), + }, + { + title: '命中行数', + dataIndex: 'matchCount', + key: 'matchCount', + width: 100, + align: 'center' as const, + render: (count: number) => ( + = MAX_MATCH_ROWS_PER_TABLE ? 'orange' : 'green'}> + {count >= MAX_MATCH_ROWS_PER_TABLE ? `≥${count}` : count} + + ), + }, + { + title: '操作', + key: 'action', + width: 80, + align: 'center' as const, + render: (_: any, record: SearchResultItem) => ( + +