Compare commits

..

44 Commits

Author SHA1 Message Date
Syngnat
c99f857d0a feat(TableOverview): 新增表平铺视图概览功能
- 新建 TableOverview 组件:卡片网格展示表名、注释、行数、数据大小、引擎
- 数据获取:通过 DBQuery 发 SHOW TABLE STATUS 等 SQL 适配多数据库方言
- 交互功能:搜索过滤、按名称/行数/大小排序、双击打开DataGrid、Tooltip悬浮全名
- 右键菜单:与 Sidebar 完全一致(新建查询/设计表/复制结构/备份/重命名/删除/导出)
- 入口集成:双击侧边栏"表(N)"分组节点打开概览Tab,注册table-overview类型
- UI细节:统计指标固定列宽对齐,卡片hover高亮边框
2026-03-19 11:58:12 +08:00
Syngnat
2c3f4a1032 ♻️ refactor(TableDesigner): 重构选择交互为手动Checkbox并优化渲染性能
- 交互重构:移除rowSelection依赖,改用手动Checkbox列避免Ant Design内部对齐差异
- 列隔离:Checkbox和Sort列脱离resizableColumns,不经过ResizableTitle处理
- 对齐修复:.ant-input padding-left归零,消除borderless Input导致的th/td文字偏移
- 性能优化:resizableColumns/sortColumn等用useMemo稳定引用,Tab切换startTransition降级
- 动画加速:ink-bar添加will-change:transform独立合成层,过渡缩短至0.15s
2026-03-19 11:33:30 +08:00
Syngnat
72de16995a feat(TableDesigner): 索引表支持列宽拖拽和Checkbox多选全选
- 拖拽调整:索引表header支持拖拽调整列宽,带三角形角标与DataGrid一致
- 多选重构:索引选择从Radio单选改为Checkbox多选,支持全选/取消全选/半选指示
- 选择列固定:Checkbox列固定48px宽度,不参与拖拽resize,header和body对齐一致
- 按钮逻辑:编辑按钮要求恰好选中1个索引,删除按钮要求选中≥1个索引
- 样式优化:索引表header禁用文字选中和光标效果,保持干净交互体验
2026-03-19 10:25:06 +08:00
Syngnat
0adc8411fa 🐛 fix(data-grid/table-designer/about): 修复空表横向滚动、索引编辑回显及关于弹窗按钮间距
- 空表滚动:虚拟模式下空数据表缺少 virtual-holder 元素时,回退到直接滚动表头实现横向滚动
- 索引回显:修复修改索引后再次编辑时被删除的字段仍然显示的问题,selectedIndex 随 groupedIndexes 同步更新
- 按钮间距:关于弹窗 footer 增加 flex-wrap 和 gap,解决关闭按钮与上方操作按钮行重叠
- refs #258
2026-03-19 08:59:49 +08:00
杨国锋
8efa7e2de6 🔧 fix(App/handleNewQuery): 修复新建查询默认数据库选择错误
- 验证当前 tab 的 connectionId 仍存在于 connections 列表中才复用上下文
- activeContext 作为次优先回退,同样验证 connectionId 有效性
- 避免关闭连接 A 后新建查询仍默认选中数据库 A 的问题
- refs #241
2026-03-18 21:24:45 +08:00
杨国锋
ecee206304 feat(Sidebar/FindInDatabaseModal): 新增全局数据库搜索功能
- 数据库右键菜单新增「在数据库中搜索」入口
- 逐表搜索文本列,支持包含/精确匹配两种模式
- 智能过滤非文本列(int/blob/date 等自动跳过)
- 兼容 MySQL LIMIT / SQL Server TOP / Oracle FETCH FIRST
- 结果以汇总表格展示,支持展开查看匹配行详情
- refs #240
2026-03-18 21:16:23 +08:00
杨国锋
299dceb01c 🔧 fix(QueryEditor): 修复最大返回行数对 SQL Server 等数据源不生效的问题
- 启用 applyAutoLimit 在 SQL 层面自动注入行数限制
- SQL Server 使用 TOP N,Oracle/Dameng 使用 FETCH FIRST N ROWS ONLY
- 已有 LIMIT/TOP/FETCH/ROWNUM 时自动跳过,不重复注入
- 移除相关 DEBT 标记
- refs #236
2026-03-18 21:02:54 +08:00
杨国锋
5cad761bdd feat(QueryEditor): 增加 SQL 内置函数自动补全提示
- 新增约 120 个常用函数(聚合/字符串/日期/JSON/窗口等分类)
- 以 Function 图标区分,选中自动插入括号
- 适用于所有支持的数据源类型
- refs #248
2026-03-18 20:53:50 +08:00
杨国锋
b8728170ec 🐛 fix(CreateDatabase): 修复 Oracle 新建数据库时因缺少 Service Name 报错
- 前端 Oracle/达梦连接保留原始 database 字段而非清空
- 后端添加 Oracle/达梦不支持此入口创建的友好提示
- refs #223
2026-03-18 20:45:07 +08:00
杨国锋
4ce4cdaad8 🐛 fix(TableDesigner): 修复 MySQL 索引编辑保存时多语句执行失败
- executeSchemaSql 将拼接的 DDL 按分号换行拆分后逐条执行
2026-03-18 20:32:00 +08:00
杨国锋
cc7ef12029 🐛 fix(TableDesigner): 修复深色主题下 SQL 变更确认弹窗文字不可见
- 将 <pre> 的硬编码浅色背景/边框替换为 darkMode 适配的颜色值
- refs #251
2026-03-18 20:23:38 +08:00
杨国锋
5b6403f266 🐛 fix(update): 修复 Win10 自动更新时文件被占用导致替换失败
- 冷却期:进程退出后增加 3 秒等待,确保 Win10 内核释放 exe 文件句柄
- 替换策略:新增 rename-before-replace 机制,先重命名旧文件再复制新文件
- 退避重试:替换固定 1 秒间隔为指数退避(1s→2s→3s→5s),总等待约 36 秒
- 残留清理:替换成功后删除 .old 残留文件
- 测试覆盖:新增 TestBuildWindowsScriptWin10Fixes 验证全部修复点
2026-03-18 20:16:09 +08:00
Syngnat
caceb2868d 🐛 fix(data-grid): 修复右键菜单被窗口裁剪和全选checkbox未对齐
- 单元格右键菜单增加视口边界检测,底部/右侧空间不足时自动偏移
- 菜单容器添加 maxHeight + overflowY auto,确保所有选项可滚动访问
- 修复表头选择列 TH 无 class(虚拟模式),用 :first-child 统一 padding 和对齐
- 行右键菜单 Dropdown 挂载到 document.body 并启用 autoAdjustOverflow
2026-03-18 18:01:29 +08:00
Syngnat
e7b9ff4a10 ♻️ refactor(data-grid): 优化右键菜单定位算法与工具栏按钮优先级
- 单元格菜单 position:fixed 增加 viewport 边界碰撞检测与动态 maxHeight
- 行菜单 Dropdown 通过 getPopupContainer 脱离容器 overflow 限制
- 工具栏按钮按使用频率重排:刷新 → 筛选 → [编辑区] → 导入/导出
2026-03-18 17:43:10 +08:00
Syngnat
76f65cb96c 🐛 fix(ci): 修复 Chocolatey UPX 包不可用导致 Windows 构建失败
- Chocolatey NuGet 仓库无法解析 upx 包,触发 NuGetResolverInputException
- 改为 Invoke-WebRequest 从 GitHub Releases 下载 upx-4.2.4-win64.zip
- 使用 GITHUB_PATH 环境文件注入 UPX 路径,后续步骤可直接调用
- 消除对 Chocolatey 包注册表的外部依赖,提高 CI 稳定性
2026-03-18 17:29:24 +08:00
Syngnat
8bdc6e8086 ️ perf(data-grid): memoize CSS模板与样式变量,优化render热路径
- P0:gridCssText useMemo化,依赖[themeStyles, gridId, tableBodyBottomPadding]
- P1:80+行主题变量收敛到themeStyles useMemo,依赖[darkMode, opacity, blur]
- P2:CELL_ELLIPSIS_STYLE/VIRTUAL_CELL_WRAPPER_STYLE提升为模块级常量
- P5:合并getBoundingClientRect为单次调用,减少强制重排
- P6:useRef替代displayColumnNames依赖,切断normalizeGridFilterConditions级联
2026-03-18 17:20:53 +08:00
Syngnat
1eb2f6dffe 🐛 fix(data-grid): 修复虚拟表格横向滚动时标头与数据列错位
- 废弃 marginLeft hack,改用合成 WheelEvent 驱动 rc-virtual-list 内部滚动状态
- 虚拟模式 wheel 事件交由 rc-virtual-list 原生处理,rc-table 自动同步 header
- 外部滚动条同步改为 WheelEvent + rAF 异步链路
- refs #249
2026-03-18 16:13:40 +08:00
Syngnat
5c5e1fc68f 🐛 fix(redis,mongodb): 修复Redis集群数据显示不全和MongoDB指定数据库连接失败
- Redis集群ScanKeys改用ForEachMaster逐master节点扫描合并去重
- MongoDB authSource未显式设置时始终默认admin而非Database值
- 同步修复mongodb_impl.go和mongodb_impl_v1.go
- refs #246
2026-03-18 15:51:19 +08:00
Syngnat
fb70f1420c feat(sql-file): 支持大 SQL 文件后端流式执行,解决 WebView2 崩溃
- 新增流式 SQL 拆分器 sql_split_stream.go(逐行状态机)
- OpenSQLFile 超过 50MB 返回文件路径而非内容
- 新增 ExecuteSQLFile 后端流式读取+拆分+逐条执行+事件推送进度
- 新增 CancelSQLFileExecution 支持中途取消
- 前端增加 SQL 文件执行进度 Modal(进度条/计数/取消/结果展示)
- refs #238
2026-03-18 15:33:37 +08:00
Syngnat
d75596921c feat(sidebar): 增加已保存查询删除功能并扩展运行外部SQL文件入口
- 已存查询节点增加右键菜单:打开查询 / 删除查询(含确认弹窗)
- 连接节点右键菜单增加"运行外部SQL文件"入口
- 侧栏工具栏增加"运行外部SQL文件"按钮,使用当前活跃连接上下文
- 统一所有 SQL 文件相关文案为"运行外部SQL文件"
- 表头字段备注过长溢出修复(overflow: hidden)
- 列宽调整与拖拽排序隔离(onPointerDown stopPropagation + isResizingRef guard)
- refs #247
2026-03-18 15:01:56 +08:00
Syngnat
d251594fd9 🐛 fix(data-grid): 修复字段备注过长时溢出重叠到相邻列的问题
- 为 .gonavi-sortable-header-cell 添加 overflow: hidden 限制 th 溢出
- 为 .sortable-header-cell-drag-handle 添加 overflow: hidden 限制内容溢出
- 配合已有 text-overflow: ellipsis 实现长文本截断显示
- 完整备注仍可通过 Tooltip 悬浮查看
- refs #239
2026-03-18 14:47:40 +08:00
Syngnat
7598bf372b 🐛 fix(data-grid): 修复拖拽调整列宽时意外触发列排序拖拽的问题
- ResizableTitle 的 resize handle 增加 onPointerDown stopPropagation
- 阻止 pointerdown 事件冒泡到 @dnd-kit 的 PointerSensor
- handleDragEnd 增加 isResizingRef 防御性检查,双重保险
- 确保列宽调整与列排序两个操作完全隔离
2026-03-18 14:43:26 +08:00
Syngnat
64021ffd2a 🐛 fix(batch-truncate/query): 修复批量清空表安全隐患并优化多语句执行错误反馈
- 安全加固:TruncateTables 增加审计日志(Warnf 级别)和参数校验(上限 200 张)
- 容错增强:批量清空部分失败时返回已执行 SQL 列表并提示已清空表不可恢复
- 错误优化:DBQueryMulti 逐条执行失败时附带语句序号和已成功条数
- 性能优化:splitSQLStatements 从 string 拼接改为 strings.Builder,消除 O(n²) 分配
- 转义修复:splitSQLStatements 支持 SQL 标准转义单引号 '' 防止误拆分
- 前端修复:handleBatchClear 统一取消判断字符串为 '已取消' 并移除冗余变量声明
- refs #244
2026-03-18 14:32:11 +08:00
Syngnat
fbd785400f 批量表操作支持清空数据 (#253)
批量表操作支持清空数据. 
已支持数据库 mysql / mongodb
2026-03-18 14:16:24 +08:00
wjm
b573fd95cc 接口漏提了 2026-03-18 11:18:51 +08:00
wjm
a097d96380 批量表操作支持清空数据. mysql / mongodb 2026-03-18 11:05:44 +08:00
杨国锋
6ee0fea110 feat(multi-query): 适配 MariaDB/SQLServer/DiROS 多结果集并增加回退提示
- MariaDB: DSN 添加 multiStatements=true,实现 QueryMulti/QueryMultiContext
- SQL Server: 实现 QueryMulti/QueryMultiContext(go-mssqldb 原生支持批处理)
- DiROS: DSN 添加 multiStatements=true(继承 MySQLDB 的方法)
- Sphinx: 自动继承 MySQLDB 多结果集支持,无需额外改动
- 不支持原生多语句的数据源执行多条 SQL 时,前端展示 info 提示
- refs #235
2026-03-17 22:53:24 +08:00
杨国锋
e6b822c967 ♻️ refactor(QueryEditor): 清理未使用变量及IDE警告
- 移除未使用的 DBQuery 导入和 currentQueryId 解构
- 简化正则表达式 [\w] 为 \w(4处)
- 移除变量初始值冗余和 nextKey 中间变量
- 为异步调用添加 void 前缀消除 Promise 忽略警告
- 为暂未使用的 getLeadingKeyword/applyAutoLimit 添加 DEBT 标记
2026-03-17 22:39:55 +08:00
杨国锋
0ab10d2e80 feat(query): 支持多条SQL语句执行返回多结果集
- 新增 ResultSetData 结构体承载单个结果集数据
- 新增 MultiResultQuerier/MultiResultQuerierContext 可选接口
- 新增 scanMultiRows 函数利用 NextResultSet() 遍历所有结果集
- MySQL 驱动 DSN 开启 multiStatements=true 并实现多结果集接口
- 新增 DBQueryMulti Wails 方法,支持驱动原生多结果集及自动回退逐条执行
- 新增 Go 版 SQL 拆分函数 splitSQLStatements 及 10 个单元测试
- 前端 QueryEditor handleRun 改为一次性调用 DBQueryMulti
- MongoDB 保持独立的逐条执行路径不受影响
- refs #235
2026-03-17 22:21:49 +08:00
杨国锋
064cdc34be ♻️ refactor(全局): 统一错误消息中文化,补充 godoc 与测试,修复横向滚动和 Vite 代理
错误消息中文化:
- 19 个驱动实现文件中 connection not open / table name required 等英文消息替换为中文
- methods_file.go / methods_db.go / methods_driver.go 英文消息中文化
- 前端 App/Sidebar/DataGrid/ConnectionModal/DriverManagerModal 同步替换 "Cancelled" → "已取消"

文档与测试:
- database.go Database/BatchApplier 接口、types.go 12 个类型、driver_support.go 导出函数补充中文 godoc
- 新增 logger_test.go(ErrorChain 5 个用例)和 methods_db_conn_test.go(连接管理 7 个用例)

Bug 修复:
- DataGrid: 将 liveTargets 空检查移至非虚拟路径,修复外部横向滚动条拖动时内容不跟随
- vite.config.ts: server.host 指定 127.0.0.1,修复 IPv6 回环被拦截导致 Wails 代理 502

基础设施:
- .gitignore 新增 .gemini/ 规则,tmpclaude-* 改为 **/tmpclaude-* 覆盖子目录
2026-03-17 21:44:50 +08:00
Syngnat
c62f4b7d3c ♻️ refactor(query-editor/sidebar): 优化查询新建保存链路与 SQL 补全排序体验
- 表右键“新建查询”默认填充 SQL 模板为 SELECT * FROM <table>;
- Query Tab 增加 savedQueryId,首次保存后将“新建查询”转为已保存查询形态
- 保存按钮改为快速保存:已保存查询直接覆盖保存,不再弹命名对话框
- 首次保存(无保存身份)时保留命名弹窗,保存后同步更新当前 tab 标题与 query
- 已保存查询从侧栏打开时携带 savedQueryId,保持保存链路一致
- SQL 自动补全增加前缀过滤与上下文分组排序,关键字在非表名上下文下优先展示
- refs #232
2026-03-13 17:20:33 +08:00
Syngnat
304a4926d2 🔧 fix(query-editor/sidebar): 修复全选错位并完善侧栏拖拽自适应
- 修复新建查询页 Ctrl/Cmd+A 命中空白区域的问题,改为强制触发 Monaco 全选
- 限制全选拦截仅作用于当前活动查询页,避免影响其他编辑区
- 修复 sidebarWidth 声明时序导致的运行时 ReferenceError
- 侧栏拖窄时工具按钮按宽度分档自适应(4列→2列→图标模式)
- 新建查询/新建连接在窄宽度下改为单列,避免图文重叠
- 补充按钮防溢出样式,保证窄宽度可读与可点击
2026-03-13 16:22:33 +08:00
Syngnat
cabf84a041 🔧 fix(frontend/ci): 移除前端测试对 node:assert 的类型依赖
- 修复 darwin/arm64 构建中 tsc 无法解析 node:assert 的 TS2307 报错
- 将 dataGridLayout.test.ts 中的 node:assert 替换为本地 assertEqual
- 将 redisViewerWorkbenchTheme.test.ts 中的 node:assert 替换为本地断言函数
- 将 overlayWorkbenchTheme.test.ts 中的 node:assert 替换为本地断言函数
- 保持原有断言语义不变,避免引入新的运行时依赖
- 本地验证 npm --prefix frontend run build 通过
2026-03-13 15:36:09 +08:00
Syngnat
9b02720169 🔧 fix(data-grid): 修复虚拟表格滚动条遮挡并统一横向同步链路
- 修复数据视图横向滚动条遮挡最后一行内容的问题
- 为虚拟表格接入外部横向滚动条,移除内部重复横向滚动轨道
- 统一拖拽滚动条与鼠标滑轮的横向同步逻辑,修复内容移动但滚动条不跟随
- 调整横向滚动条底部停靠间距,避免继续压住表格内容
- 提升纵向滚动条 thumb 对比度并增加弱轨道底色,改善深色主题下可见性
- 新增 DataGrid 布局计算辅助函数与最小测试用例
- refs #220
2026-03-13 15:27:18 +08:00
Syngnat
eb36dcc5a2 🔧 fix(redis/ui): 统一 Redis 工作台交互样式并修复 Tree 节点异常高亮
- Redis 页面重构为工作台样式,统一左右面板、工具条和详情区层级
- 接入 light/dark/透明模式主题参数,修复 Redis 页面与全局主题不一致问题
- 新增文件夹递归勾选、全选全部、分组全选/取消全选能力
- 支持 Redis Key 右键菜单重命名并同步更新树节点、选中态和详情面板
- 修复 type=none 时读取失败问题,过期或已删除 Key 自动提示并移出列表
- 接管 Redis Tree 展开箭头渲染,修复 switcher 命中区错位和悬浮白线问题
- 统一工具、代理、主题、关于、筛选、新建组和新建连接等弹层主题
- refs #231
2026-03-13 14:51:20 +08:00
Syngnat
1a3f137438 🔧 fix(db/kingbase): 统一 search_path 构建并修复双引号重复转义
- 新增 buildKingbaseSearchPathCommon,统一 search_path 规范化与拼装逻辑
- schema 名称先做 normalize + 去重,避免已带引号值被二次转义为 ""schema""
- getSearchPathStr 改为收集原始 schema 后走公共构建流程
- optional-driver-agent 复用同一构建函数,消除两套实现偏差
- 对 public 做大小写归一,确保 search_path 输出稳定
- 新增 TestBuildKingbaseSearchPathCommon 覆盖 quoted/escaped/dedupe 场景
2026-03-13 11:22:35 +08:00
杨国锋
5f94cd3911 🔧 fix(tab-manager): 修复切换Tab导致表数据编辑与筛选状态丢失
- 移除非活动业务Tab内容置空逻辑,避免DataViewer/DataGrid卸载重建
- 设置 destroyInactiveTabPane=false,确保切换Tab不销毁页面
- 在 DataViewer 统一快照持久化并增加卸载兜底写回
- 保持切换Tab不自动刷新,仅手动刷新或显式状态变化触发加载
- refs #218
2026-03-12 23:25:32 +08:00
杨国锋
bb257c35bc feat(data-grid): 新增同表多列跨行复制粘贴能力
- 在单元格编辑模式新增复制缓冲区,保存源行与多列值
- 新增“复制选区列值”操作,仅允许同一行多列选区复制
- 新增“粘贴到选中行”操作,按同名列批量写入并自动排除源行
- 复用 addedRows/modifiedRows 变更路径,保持提交事务与回滚逻辑一致
- 单元格右键菜单增加“粘贴已复制列(同名列)”入口
- 切换连接/库/表时自动清空复制缓冲区,避免跨上下文误粘贴
- refs #217
2026-03-12 23:14:52 +08:00
杨国锋
1dabac1a65 🔧 fix(window): 修复Windows启动全屏锁死并补齐标题栏退出全屏逻辑 2026-03-12 19:38:54 +08:00
杨国锋
e013288967 🔧 fix(ci/release-winget): 修复 Node20 弃用告警并强制启用 Node24 运行时
- 在 release-winget workflow 增加 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true
- 与现有 release/test workflow 的 Node24 配置保持一致
- 避免 actions/checkout、setup-go、setup-node 触发 Node20 弃用告警
2026-03-12 19:23:46 +08:00
杨国锋
d467322ebe 🔧 fix(release/macos): 移除 macOS 打包链路的 UPX 压缩逻辑
- 删除 release 与手动测试工作流中的 macOS UPX 安装与压缩步骤
- build-release.sh 不再对 macOS arm64/amd64 主程序执行 UPX
- 保留 Windows 与 Linux 的 UPX 压缩策略
2026-03-12 19:00:21 +08:00
Syngnat
e26a456eae 🔧 fix(release/ci): 修复跨平台UPX兼容并处理Windows ARM64打包失败
- CI 工作流统一启用 Node24 JavaScript 运行时,消除 Node20 退役告警干扰
- macOS 打包阶段为 UPX 增加 --force-macos,修复 Mach-O 压缩失败
- Windows 打包按架构分流:arm64 跳过 UPX 并保留原始 EXE,amd64 继续强制压缩
- Windows 压缩流程新增 $LASTEXITCODE 显式校验,避免命令失败被误判为成功
- 本地 build-release.sh 同步 macOS/Windows 的 UPX 兼容策略与错误处理逻辑
2026-03-12 17:54:09 +08:00
Syngnat
501ad9e9a3 Merge branch 'fix/ssh-issue-20260310-ygf' into dev
# Conflicts:
#	internal/db/kingbase_impl.go
2026-03-12 17:30:48 +08:00
凌封
eef973b7fc fix: KingBase 连接后自动设置 search_path,修复自定义 schema 下表查询报 relation does not exist 的问题 (#215) 2026-03-12 10:04:49 +08:00
71 changed files with 6652 additions and 1747 deletions

View File

@@ -10,6 +10,9 @@ on:
description: 'Tag of release you want to publish'
type: string
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
publish:
runs-on: windows-latest

View File

@@ -8,6 +8,9 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# Phase 1: Build in parallel and output artifacts
build:
@@ -88,23 +91,25 @@ jobs:
with:
node-version: '20'
- name: Install UPX (macOS)
if: contains(matrix.platform, 'darwin')
run: |
brew install upx
upx --version
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
choco install upx --no-progress -y
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
$UPX_VERSION = "4.2.4"
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
$zipPath = "$env:RUNNER_TEMP\upx.zip"
$extractPath = "$env:RUNNER_TEMP\upx"
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
if (!(Test-Path $upxCmd)) {
Write-Error "❌ 未检测到 upx无法保证 Windows 产物经过压缩"
exit 1
}
& upx --version
& $upxCmd --version
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
- name: Install Linux Dependencies
@@ -301,20 +306,10 @@ jobs:
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
echo "❌ 未找到 macOS 应用主程序,无法进行 UPX 压缩!"
echo "❌ 未找到 macOS 应用主程序!"
exit 1
fi
BEFORE_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
echo "🗜️ 正在使用 UPX 压缩 macOS 可执行文件: $APP_BIN ..."
upx --best --lzma --force "$APP_BIN"
upx -t "$APP_BIN"
AFTER_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ macOS UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " macOS UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
echo " macOS 产物不执行 UPX 压缩,保留原始主程序。"
echo "🔏 正在进行 Ad-hoc 签名..."
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
@@ -361,21 +356,35 @@ jobs:
exit 1
}
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
& upx -t $finalExe | Out-Host
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
if ($isArm64Target) {
Write-Warning "⚠️ UPX 当前不支持 win64/arm64跳过压缩并保留原始 EXE。"
$LASTEXITCODE = 0
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 压缩失败($LASTEXITCODE"
exit 1
}
& upx -t $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 校验失败($LASTEXITCODE"
exit 1
}
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
}
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."

View File

@@ -1,4 +1,4 @@
name: Test Build All Platforms (Manual)
name: Test Build All Platforms (Manual)
on:
workflow_dispatch:
@@ -11,6 +11,9 @@ on:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
concurrency:
group: test-build-${{ github.ref }}
cancel-in-progress: false
@@ -93,23 +96,25 @@ jobs:
with:
node-version: '20'
- name: Install UPX (macOS)
if: contains(matrix.platform, 'darwin')
run: |
brew install upx
upx --version
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
choco install upx --no-progress -y
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
$UPX_VERSION = "4.2.4"
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
$zipPath = "$env:RUNNER_TEMP\upx.zip"
$extractPath = "$env:RUNNER_TEMP\upx"
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
if (!(Test-Path $upxCmd)) {
Write-Error "❌ 未检测到 upx无法保证 Windows 测试产物经过压缩"
exit 1
}
& upx --version
& $upxCmd --version
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
@@ -265,20 +270,10 @@ jobs:
APP_NAME=$(basename "$APP_PATH")
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
echo "未找到 macOS 应用主程序,无法进行 UPX 压缩"
echo "未找到 macOS 应用主程序"
exit 1
fi
BEFORE_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
echo "🗜️ 使用 UPX 压缩 macOS 可执行文件: $APP_BIN ..."
upx --best --lzma --force "$APP_BIN"
upx -t "$APP_BIN"
AFTER_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ macOS UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " macOS UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
echo " macOS 产物不执行 UPX 压缩,保留原始主程序。"
codesign --force --deep --sign - "$APP_NAME"
ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip"
DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg"
@@ -316,21 +311,35 @@ jobs:
Write-Error "未找到构建产物 '$target'"
exit 1
}
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 测试产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
& upx -t $finalExe | Out-Host
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
if ($isArm64Target) {
Write-Warning "⚠️ UPX 当前不支持 win64/arm64跳过压缩并保留原始 EXE。"
$LASTEXITCODE = 0
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 测试产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 压缩失败($LASTEXITCODE"
exit 1
}
& upx -t $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 校验失败($LASTEXITCODE"
exit 1
}
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
}
}
New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null
Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force

View File

@@ -16,6 +16,9 @@ on:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build-macos:
name: Build macOS ${{ matrix.arch }}

4
.gitignore vendored
View File

@@ -17,8 +17,10 @@ dist/
GoNavi-Wails
GoNavi-Wails.exe
.ace-tool/
.superpowers/
.claude/
tmpclaude-*
.gemini/
**/tmpclaude-*
CLAUDE.md
**/CLAUDE.md

View File

@@ -108,9 +108,9 @@ if [ $? -eq 0 ]; then
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
try_compress_binary_with_upx "$APP_BIN_PATH" "macOS arm64 应用主程序"
echo -e "${YELLOW} ⚠️ macOS arm64 不再执行 UPX 压缩,保留原始主程序。${NC}"
else
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件,无法执行 UPX 压缩${NC}"
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
exit 1
fi
@@ -215,9 +215,9 @@ if [ $? -eq 0 ]; then
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
try_compress_binary_with_upx "$APP_BIN_PATH" "macOS amd64 应用主程序"
echo -e "${YELLOW} ⚠️ macOS amd64 不再执行 UPX 压缩,保留原始主程序。${NC}"
else
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件,无法执行 UPX 压缩${NC}"
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
exit 1
fi
@@ -327,7 +327,7 @@ if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
if [ $? -eq 0 ]; then
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
try_compress_binary_with_upx "$TARGET_EXE" "Windows arm64 可执行文件"
echo -e "${YELLOW} ⚠️ 当前 UPX 不支持 win64/arm64跳过 Windows arm64 压缩。${NC}"
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
else
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"

View File

@@ -37,6 +37,91 @@ body, #root {
padding-right: 8px;
}
.redis-viewer-workbench .ant-tree {
background: transparent;
}
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner,
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner .ant-tree-treenode {
width: 100% !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper {
min-height: 36px;
border-radius: 14px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
flex: 1 1 auto;
min-width: 0;
width: auto !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:hover,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:active,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus-visible,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
outline: none !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-treenode {
padding: 2px 0;
width: 100%;
border-radius: 14px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
border: none;
align-items: center;
position: relative;
z-index: 0;
display: flex !important;
box-sizing: border-box;
}
.redis-viewer-workbench .ant-tree .ant-tree-switcher {
width: 0 !important;
min-width: 0 !important;
margin-inline-end: 0 !important;
padding: 0 !important;
overflow: hidden !important;
background: transparent !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-switcher:hover,
.redis-viewer-workbench .ant-tree .ant-tree-switcher:active,
.redis-viewer-workbench .ant-tree .ant-tree-switcher:focus {
background: transparent !important;
}
.redis-viewer-workbench .redis-tree-expander-button:hover,
.redis-viewer-workbench .redis-tree-expander-button:focus-visible {
background: transparent !important;
outline: none;
}
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper {
border-radius: 10px;
margin-inline-end: 6px;
}
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper:last-child {
margin-inline-end: 0;
}
.redis-viewer-workbench .ant-table {
background: transparent;
}
.redis-viewer-workbench .ant-table-wrapper .ant-table-thead > tr > th {
font-weight: 700;
}
/* Scrollbar styling for dark mode */
body[data-theme='dark'] ::-webkit-scrollbar {
width: 10px;
@@ -97,6 +182,16 @@ body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-s
color: rgba(255, 236, 179, 0.98) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
background: rgba(255, 255, 255, 0.05) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
background: linear-gradient(90deg, rgba(246, 196, 83, 0.22), rgba(246, 196, 83, 0.08)) !important;
border: 1px solid rgba(246, 196, 83, 0.24) !important;
}
body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner {
background-color: #f6c453 !important;
border-color: #f6c453 !important;
@@ -135,6 +230,41 @@ body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:h
background: rgba(246, 196, 83, 0.26) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
color: rgba(230, 234, 242, 0.9);
}
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
background: rgba(246, 196, 83, 0.16);
border-color: rgba(246, 196, 83, 0.3);
color: #f6c453;
}
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
background: rgba(15, 23, 42, 0.04) !important;
}
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
color: rgba(15, 23, 42, 0.92) !important;
background: linear-gradient(90deg, rgba(22, 119, 255, 0.12), rgba(22, 119, 255, 0.04)) !important;
border: 1px solid rgba(22, 119, 255, 0.18) !important;
}
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(15, 23, 42, 0.08);
color: rgba(51, 65, 85, 0.88);
}
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
background: rgba(22, 119, 255, 0.1);
border-color: rgba(22, 119, 255, 0.22);
color: #1677ff;
}
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
.connection-modal-wrap {
overflow: hidden !important;

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise } from '../wailsjs/runtime';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
@@ -12,6 +12,7 @@ import LogPanel from './components/LogPanel';
import { useStore } from './store';
import { SavedConnection } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
@@ -88,6 +89,7 @@ function App() {
const [runtimePlatform, setRuntimePlatform] = useState('');
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
const [sidebarWidth, setSidebarWidth] = useState(330);
const globalProxyInvalidHintShownRef = React.useRef(false);
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
@@ -218,6 +220,7 @@ function App() {
const maxApplyAttempts = 6;
const applyRetryDelayMs = 400;
const settleDelayMs = 160;
const useMaximiseForStartup = isWindowsPlatform();
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
try {
@@ -253,15 +256,21 @@ function App() {
if (await checkStartupPreferenceApplied()) {
return;
}
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底
// Windows 使用最大化,避免进入真正全屏后无法通过标题栏交互退出
// 其他平台保持全屏优先、最大化兜底。
try {
await WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
if (useMaximiseForStartup) {
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
} else {
await WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
}
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
} catch (e) {
console.warn("Wails Window APIs unavailable", e);
}
@@ -434,7 +443,6 @@ function App() {
const floatingLogButtonShadow = darkMode
? '0 8px 22px rgba(0,0,0,0.38)'
: '0 8px 20px rgba(0,0,0,0.16)';
const isOpaqueUtilityMode = resolvedAppearance.opacity >= 0.999 && resolvedAppearance.blur <= 0;
const utilityButtonBgAlpha = darkMode
? Math.max(0.28, Math.min(0.76, effectiveOpacity * 0.72))
@@ -454,10 +462,13 @@ function App() {
: (darkMode
? `0 8px 18px rgba(0,0,0,${Math.max(0.10, Math.min(0.22, effectiveOpacity * 0.24))})`
: `0 8px 18px rgba(15,23,42,${Math.max(0.04, Math.min(0.12, effectiveOpacity * 0.12))})`);
const isSidebarNarrow = sidebarWidth < 360;
const isSidebarCompact = sidebarWidth < 320;
const isSidebarUltraCompact = sidebarWidth < 260;
const utilityButtonStyle = useMemo(() => ({
height: Math.max(30, Math.round(32 * effectiveUiScale)),
width: '100%',
paddingInline: Math.max(10, Math.round(12 * effectiveUiScale)),
paddingInline: isSidebarCompact ? Math.max(8, Math.round(9 * effectiveUiScale)) : Math.max(10, Math.round(12 * effectiveUiScale)),
borderRadius: 10,
border: `1px solid ${utilityButtonBorderColor}`,
background: utilityButtonBgColor,
@@ -468,17 +479,14 @@ function App() {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
const utilityDropdownShellStyle = useMemo(() => ({
borderRadius: 14,
padding: 6,
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.32)' : '0 16px 36px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(16px)' : 'none',
gap: isSidebarCompact ? 4 : 6,
minWidth: 0,
overflow: 'hidden',
}), [darkMode]);
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: isSidebarCompact ? 13 : 14,
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, isSidebarCompact, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const sidebarQuickActionBaseStyle = useMemo(() => ({
height: Math.max(34, Math.round(36 * effectiveUiScale)),
@@ -493,6 +501,8 @@ function App() {
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}), [blurFilter, darkMode, effectiveUiScale]);
const sidebarQueryActionStyle = useMemo(() => ({
@@ -510,65 +520,58 @@ function App() {
color: '#2a1f00',
}), [sidebarQuickActionBaseStyle]);
const utilityMenuTheme = useMemo(() => ({
components: {
Menu: {
popupBg: 'transparent',
darkPopupBg: 'transparent',
itemBg: 'transparent',
darkItemBg: 'transparent',
subMenuItemBg: 'transparent',
itemColor: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
itemHoverColor: darkMode ? '#fff7d6' : '#0f172a',
itemHoverBg: darkMode ? 'rgba(255,214,102,0.10)' : 'rgba(24,144,255,0.08)',
itemSelectedColor: darkMode ? '#ffd666' : '#1677ff',
itemSelectedBg: darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.12)',
itemBorderRadius: 10,
itemMarginBlock: 4,
itemMarginInline: 0,
itemPaddingInline: 12,
itemHeight: 40,
groupTitleColor: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(16,24,40,0.48)',
},
},
}), [darkMode]);
const renderUtilityDropdown = (menu: React.ReactNode) => (
<ConfigProvider theme={utilityMenuTheme}>
<div style={{ ...utilityDropdownShellStyle, minWidth: 220 }}>
{menu}
</div>
</ConfigProvider>
);
const utilityModalShellStyle = useMemo(() => ({
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.32)' : '0 18px 42px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const utilityPanelStyle = useMemo(() => ({
padding: 16,
borderRadius: 14,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
}), [darkMode]);
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const utilityMutedTextStyle = useMemo(() => ({
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [darkMode]);
}), [overlayTheme]);
const renderUtilityModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
const utilityActionCardStyle = useMemo(() => ({
width: '100%',
minHeight: 68,
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
color: overlayTheme.titleText,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 14,
paddingInline: 16,
boxShadow: 'none',
fontSize: 15,
fontWeight: 600,
}), [overlayTheme]);
const utilityActionHintStyle = useMemo(() => ({
fontSize: 12,
color: overlayTheme.mutedText,
fontWeight: 400,
marginTop: 2,
}), [overlayTheme]);
const sidebarHorizontalPadding = 10;
const sidebarHorizontalPadding = isSidebarCompact ? 8 : 10;
const addTab = useStore(state => state.addTab);
const activeContext = useStore(state => state.activeContext);
@@ -640,6 +643,8 @@ function App() {
const isMacRuntime = runtimePlatform === 'darwin'
|| (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform()));
const isWindowsRuntime = runtimePlatform === 'windows'
|| (runtimePlatform === '' && isWindowsPlatform());
const formatBytes = (bytes?: number) => {
if (!bytes || bytes <= 0) return '0 B';
@@ -906,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: '新建查询',
@@ -948,7 +959,7 @@ function App() {
} catch (e) {
void message.error("解析 JSON 失败");
}
} else if (res.message !== "Cancelled") {
} else if (res.message !== "已取消") {
void message.error("导入失败: " + res.message);
}
};
@@ -961,45 +972,12 @@ function App() {
const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases'], "connections", "json");
if (res.success) {
void message.success("导出成功");
} else if (res.message !== "Cancelled") {
} else if (res.message !== "已取消") {
void message.error("导出失败: " + res.message);
}
};
const toolsMenu: MenuProps['items'] = [
{
key: 'import',
label: '导入连接配置',
icon: <UploadOutlined />,
onClick: handleImportConnections
},
{
key: 'export',
label: '导出连接配置',
icon: <DownloadOutlined />,
onClick: handleExportConnections
},
{
key: 'sync',
label: '数据同步',
icon: <UploadOutlined rotate={90} />,
onClick: () => setIsSyncModalOpen(true)
},
{
key: 'drivers',
label: '驱动管理',
icon: <SettingOutlined />,
onClick: () => setIsDriverModalOpen(true)
},
{ type: 'divider' },
{
key: 'shortcut-settings',
label: '快捷键管理',
icon: <LinkOutlined />,
onClick: () => setIsShortcutModalOpen(true)
}
];
const [isToolsModalOpen, setIsToolsModalOpen] = useState(false);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
@@ -1075,16 +1053,27 @@ function App() {
setIsDriverModalOpen(true);
};
const handleTitleBarWindowToggle = async () => {
try {
if (await WindowIsFullscreen()) {
await WindowUnfullscreen();
return;
}
await WindowToggleMaximise();
} catch (_) {
// ignore
}
};
const handleTitleBarDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
return;
}
try { WindowToggleMaximise(); } catch(e) {}
void handleTitleBarWindowToggle();
};
// Sidebar Resizing
const [sidebarWidth, setSidebarWidth] = useState(330);
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
const rafRef = React.useRef<number | null>(null);
const ghostRef = React.useRef<HTMLDivElement>(null);
@@ -1447,7 +1436,7 @@ function App() {
type="text"
icon={<BorderOutlined />}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={WindowToggleMaximise}
onClick={() => { void handleTitleBarWindowToggle(); }}
/>
<Button
type="text"
@@ -1471,17 +1460,15 @@ function App() {
>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft" dropdownRender={renderUtilityDropdown}>
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle}></Button>
</Dropdown>
<Button type="text" icon={<GlobalOutlined />} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}></Button>
<Button type="text" icon={<SkinOutlined />} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}></Button>
<Button type="text" icon={<InfoCircleOutlined />} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}></Button>
<div style={{ display: 'grid', gridTemplateColumns: isSidebarNarrow ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)}>{isSidebarUltraCompact ? null : '工具'}</Button>
<Button type="text" icon={<GlobalOutlined />} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}>{isSidebarUltraCompact ? null : '代理'}</Button>
<Button type="text" icon={<SkinOutlined />} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}>{isSidebarUltraCompact ? null : '主题'}</Button>
<Button type="text" icon={<InfoCircleOutlined />} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}>{isSidebarUltraCompact ? null : '关于'}</Button>
</div>
</div>
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 8, width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: isSidebarCompact ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1fr)', gap: 8, width: '100%' }}>
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
</Button>
@@ -1568,6 +1555,79 @@ function App() {
initialValues={editingConnection}
onOpenDriverManager={handleOpenDriverManagerFromConnection}
/>
<Modal
title={renderUtilityModalTitle(<ToolOutlined />, '工具中心', '集中处理连接配置、同步、驱动和快捷键相关操作。')}
open={isToolsModalOpen}
onCancel={() => setIsToolsModalOpen(false)}
footer={null}
width={560}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
>
<div style={{ display: 'grid', gap: 12, padding: '12px 0' }}>
{[
{
key: 'import',
icon: <UploadOutlined />,
title: '导入连接配置',
description: '从本地文件恢复连接列表。',
onClick: () => {
setIsToolsModalOpen(false);
void handleImportConnections();
},
},
{
key: 'export',
icon: <DownloadOutlined />,
title: '导出连接配置',
description: '导出当前连接与可见配置字段。',
onClick: () => {
setIsToolsModalOpen(false);
void handleExportConnections();
},
},
{
key: 'sync',
icon: <UploadOutlined rotate={90} />,
title: '数据同步',
description: '进入跨源同步工作流。',
onClick: () => {
setIsToolsModalOpen(false);
setIsSyncModalOpen(true);
},
},
{
key: 'drivers',
icon: <SettingOutlined />,
title: '驱动管理',
description: '安装、更新或移除数据库驱动。',
onClick: () => {
setIsToolsModalOpen(false);
setIsDriverModalOpen(true);
},
},
{
key: 'shortcut-settings',
icon: <LinkOutlined />,
title: '快捷键管理',
description: '查看并调整全局快捷键绑定。',
onClick: () => {
setIsToolsModalOpen(false);
setIsShortcutModalOpen(true);
},
},
].map((item) => (
<Button key={item.key} type="text" style={utilityActionCardStyle} onClick={item.onClick}>
<span style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{item.icon}
</span>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', minWidth: 0 }}>
<span>{item.title}</span>
<span style={utilityActionHintStyle}>{item.description}</span>
</span>
</Button>
))}
</div>
</Modal>
<DataSyncModal
open={isSyncModalOpen}
onClose={() => setIsSyncModalOpen(false)}
@@ -1581,7 +1641,7 @@ function App() {
title={renderUtilityModalTitle(<InfoCircleOutlined />, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')}
open={isAboutOpen}
onCancel={() => setIsAboutOpen(false)}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10, justifyContent: 'flex-end' } }}
footer={[
canShowProgressEntry ? (
<Button key="progress" icon={<DownloadOutlined />} onClick={showUpdateDownloadProgress}></Button>
@@ -1836,11 +1896,11 @@ function App() {
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span></span>
<span>{isWindowsRuntime ? '启动时全屏Windows 按最大化处理)' : '启动时全屏'}</span>
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
*
{isWindowsRuntime ? '* Windows 下该选项按“启动时最大化”处理,修改后下次启动生效' : '* 修改后下次启动生效'}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, paddingTop: 8, paddingBottom: 12 }}>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
@@ -157,6 +158,7 @@ const ConnectionModal: React.FC<{
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const tunnelSectionStyle: React.CSSProperties = {
padding: '12px',
@@ -168,35 +170,33 @@ const ConnectionModal: React.FC<{
const modalShellStyle = useMemo(() => ({
background: darkMode
? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const modalInnerSectionStyle = useMemo(() => ({
padding: 14,
borderRadius: 14,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
}), [darkMode]);
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalMutedTextStyle = useMemo(() => ({
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [darkMode]);
}), [overlayTheme]);
const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
@@ -1013,7 +1013,7 @@ const ConnectionModal: React.FC<{
if (selectedPath) {
form.setFieldValue('sshKeyPath', selectedPath);
}
} else if (res?.message !== 'Cancelled') {
} else if (res?.message !== '已取消') {
message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`);
}
} catch (e: any) {
@@ -1037,7 +1037,7 @@ const ConnectionModal: React.FC<{
if (selectedPath) {
form.setFieldValue('host', normalizeFileDbPath(selectedPath));
}
} else if (res?.message !== 'Cancelled') {
} else if (res?.message !== '已取消') {
message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`);
}
} catch (e: any) {

File diff suppressed because it is too large Load Diff

View File

@@ -247,6 +247,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
const currentConnType = currentConnCaps.type;
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
const normalizedTabId = String(tabId || '').trim();
if (!normalizedTabId) return;
viewerFilterSnapshotsByTab.set(normalizedTabId, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
scrollTop: scrollSnapshotRef.current.top,
scrollLeft: scrollSnapshotRef.current.left,
...overrides,
});
}, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
@@ -258,16 +272,14 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab.id]);
useEffect(() => {
viewerFilterSnapshotsByTab.set(tab.id, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
scrollTop: scrollSnapshotRef.current.top,
scrollLeft: scrollSnapshotRef.current.left,
});
}, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
persistViewerSnapshot(tab.id);
}, [tab.id, persistViewerSnapshot]);
useEffect(() => {
return () => {
persistViewerSnapshot(tab.id);
};
}, [tab.id, persistViewerSnapshot]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
@@ -298,13 +310,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
scrollSnapshotRef.current = snapshot;
const cached = getViewerFilterSnapshot(tab.id);
viewerFilterSnapshotsByTab.set(tab.id, {
...cached,
persistViewerSnapshot(tab.id, {
scrollTop: snapshot.top,
scrollLeft: snapshot.left,
});
}, [tab.id]);
}, [tab.id, persistViewerSnapshot]);
const handleDuckDBManualCount = useCallback(async () => {
if (latestDbTypeRef.current !== 'duckdb') {

View File

@@ -847,7 +847,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== 'Cancelled') {
if (String(fileRes?.message || '') !== '已取消') {
message.error(fileRes?.message || '选择本地驱动包文件失败');
}
return;
@@ -863,7 +863,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const installDriversFromDirectory = useCallback(async () => {
const directoryRes = await SelectDriverPackageDirectory(downloadDir);
if (!directoryRes?.success) {
if (String(directoryRes?.message || '') !== 'Cancelled') {
if (String(directoryRes?.message || '') !== '已取消') {
message.error(directoryRes?.message || '选择本地驱动包目录失败');
}
return;

View 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;

View File

@@ -6,12 +6,170 @@ import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBQueryWithCancel, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS',
'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'ADD', 'MODIFY', 'CHANGE',
'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT',
'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 ');
@@ -33,7 +191,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const [activeResultKey, setActiveResultKey] = useState<string>('');
const [loading, setLoading] = useState(false);
const [currentQueryId, setCurrentQueryId] = useState<string>('');
const [, setCurrentQueryId] = useState<string>('');
const runSeqRef = useRef(0);
const currentQueryIdRef = useRef('');
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
@@ -50,6 +208,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const monacoRef = useRef<any>(null);
const lastExternalQueryRef = useRef<string>(tab.query || '');
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
const editorPaneRef = useRef<HTMLDivElement | null>(null);
const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db)
const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db)
const visibleDbsRef = useRef<string[]>([]); // Store visible databases for cross-db intellisense
@@ -60,6 +220,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
[connections]
);
const addSqlLog = useStore(state => state.addSqlLog);
const addTab = useStore(state => state.addTab);
const savedQueries = useStore(state => state.savedQueries);
const currentConnectionIdRef = useRef(currentConnectionId);
const currentDbRef = useRef(currentDb);
const connectionsRef = useRef(connections);
@@ -74,6 +236,18 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const shortcutOptions = useStore(state => state.shortcutOptions);
const activeTabId = useStore(state => state.activeTabId);
const currentSavedQuery = useMemo(() => {
const savedId = String(tab.savedQueryId || '').trim();
if (savedId) {
return savedQueries.find((item) => item.id === savedId) || null;
}
const tabId = String(tab.id || '').trim();
if (!tabId) {
return null;
}
return savedQueries.find((item) => item.id === tabId) || null;
}, [savedQueries, tab.id, tab.savedQueryId]);
useEffect(() => {
currentConnectionIdRef.current = currentConnectionId;
}, [currentConnectionId]);
@@ -159,7 +333,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
setDbList([]);
}
};
fetchDbs();
void fetchDbs();
}, [currentConnectionId, connections]);
// Fetch Metadata for Autocomplete (Cross-database)
@@ -211,7 +385,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
tablesRef.current = allTables;
allColumnsRef.current = allColumns;
};
fetchMetadata();
void fetchMetadata();
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
// Query ID management helpers
@@ -346,7 +520,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
// 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列
const threePartMatch = linePrefix.match(/([`"]?[\w]+[`"]?)\.([`"]?[\w]+[`"]?)\.(\w*)$/);
const threePartMatch = linePrefix.match(/([`"]?\w+[`"]?)\.([`"]?\w+[`"]?)\.(\w*)$/);
if (threePartMatch) {
const dbPart = stripQuotes(threePartMatch[1]);
const tablePart = stripQuotes(threePartMatch[2]);
@@ -374,7 +548,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
// 1) 两段式 qualifier.xxx 格式
const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_][\w]*[`"]?)\.(\w*)$/);
const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_]\w*[`"]?)\.(\w*)$/);
if (qualifierMatch) {
const qualifier = stripQuotes(qualifierMatch[1]);
const prefix = (qualifierMatch[2] || '').toLowerCase();
@@ -439,7 +613,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const aliasMap: Record<string, {dbName: string, tableName: string}> = {};
// Capture table and optional alias, support db.table format
const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?[\w]+[`"]?))?/gi;
const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?\w+[`"]?(?:\s*\.\s*[`"]?\w+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?\w+[`"]?))?/gi;
let m;
while ((m = aliasRegex.exec(fullText)) !== null) {
const tableIdent = normalizeQualifiedName(m[1] || '');
@@ -468,7 +642,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const tableInfo = aliasMap[qualifier.toLowerCase()];
if (tableInfo) {
// Prefer preloaded MySQL all-columns cache
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[] = [];
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[];
if (allColumnsRef.current.length > 0) {
cols = allColumnsRef.current
.filter(c =>
@@ -498,7 +672,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
// 2) global/table/column completion
const tableRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)/gi;
const tableRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?\w+[`"]?(?:\s*\.\s*[`"]?\w+[`"]?)?)/gi;
const foundTables = new Set<string>();
let match;
while ((match = tableRegex.exec(fullText)) !== null) {
@@ -509,6 +683,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
const currentDatabase = currentDbRef.current || '';
const wordPrefix = (word.word || '').toLowerCase();
const startsWithPrefix = (candidate: string) => !wordPrefix || candidate.toLowerCase().startsWith(wordPrefix);
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim());
const shouldBoostKeywords = !expectsTableName
&& wordPrefix.length > 0
&& SQL_KEYWORDS.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
const sortGroups = shouldBoostKeywords
? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' }
: expectsTableName
? { 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 条件时优先显示
@@ -516,7 +701,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
.filter(c => {
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
const shortIdent = (c.tableName || '').toLowerCase();
return foundTables.has(fullIdent) || foundTables.has(shortIdent);
return (foundTables.has(fullIdent) || foundTables.has(shortIdent)) && startsWithPrefix(c.name || '');
})
.map(c => {
// 当前库的表字段优先级更高
@@ -527,12 +712,18 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
insertText: c.name,
detail: `${c.type} (${c.dbName}.${c.tableName})`,
range,
sortText: isCurrentDb ? '00' + c.name : '01' + c.name // FROM 表字段最优先
sortText: isCurrentDb ? sortGroups.columnCurrent + c.name : sortGroups.columnOther + c.name,
};
});
// 表提示:当前库显示表名,其他库显示 db.table 格式
const tableSuggestions = tablesRef.current.map(t => {
const tableSuggestions = tablesRef.current
.filter(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
return startsWithPrefix(label || '');
})
.map(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
@@ -542,33 +733,51 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
insertText,
detail: `Table (${t.dbName})`,
range,
sortText: isCurrentDb ? '10' + t.tableName : '11' + t.tableName // 表次优先
sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName,
};
});
// 数据库提示
const dbSuggestions = visibleDbsRef.current.map(db => ({
label: db,
kind: monaco.languages.CompletionItemKind.Module,
insertText: db,
detail: 'Database',
range,
sortText: '20' + db // 数据库最后
}));
const dbSuggestions = visibleDbsRef.current
.filter((db) => startsWithPrefix(db))
.map(db => ({
label: db,
kind: monaco.languages.CompletionItemKind.Module,
insertText: db,
detail: 'Database',
range,
sortText: sortGroups.db + db,
}));
// 关键字提示
const keywordSuggestions = ['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
const keywordSuggestions = SQL_KEYWORDS
.filter((k) => startsWithPrefix(k))
.map(k => ({
label: k,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: k,
range,
sortText: '30' + k // 关键字权重最低
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 };
@@ -581,7 +790,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase });
syncQueryToEditor(formatted);
} catch (e) {
message.error("格式化失败: SQL 语法可能有误");
void message.error("格式化失败: SQL 语法可能有误");
}
};
@@ -1024,20 +1233,52 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
};
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 ONLYOracle 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');
@@ -1112,36 +1353,31 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const dbType = String((config as any).type || 'mysql');
const normalizedDbType = dbType.trim().toLowerCase();
const normalizedRawSQL = String(rawSQL || '').replace(//g, ';');
const splitInput = normalizedDbType === 'mongodb'
? normalizedRawSQL
// MongoDB 仍走逐条执行的旧路径
const isMongoDB = normalizedDbType === 'mongodb';
if (isMongoDB) {
// MongoDB: 保持逐条执行
const splitInput = normalizedRawSQL
.replace(/^\s*\/\/.*$/gm, '')
.replace(/^\s*#.*$/gm, '')
: normalizedRawSQL;
const statements = splitSQLStatements(splitInput);
if (statements.length === 0) {
message.info('没有可执行的 SQL。');
setResultSets([]);
setActiveResultKey('');
return;
}
.replace(/^\s*#.*$/gm, '');
const statements = splitSQLStatements(splitInput);
if (statements.length === 0) {
message.info('没有可执行的 SQL。');
setResultSets([]);
setActiveResultKey('');
return;
}
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0;
let anyTruncated = false;
const pendingPk: Array<{ resultKey: string; tableName: string }> = [];
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
let anyTruncated = false;
for (let idx = 0; idx < statements.length; idx++) {
const rawStatement = statements[idx];
const leadingKeyword = getLeadingKeyword(rawStatement);
const shouldAutoLimit = leadingKeyword === 'select' || leadingKeyword === 'with';
const limitApplied = shouldAutoLimit && wantsLimitProbe;
const limited = limitApplied ? applyAutoLimit(rawStatement, dbType, probeLimit) : { sql: rawStatement, applied: false, maxRows: probeLimit };
let executedSql = limited.sql;
if (String(dbType || '').trim().toLowerCase() === 'mongodb') {
for (let idx = 0; idx < statements.length; idx++) {
const rawStatement = statements[idx];
let executedSql = rawStatement;
const shellConvert = convertMongoShellToJsonCommand(executedSql);
if (shellConvert.recognized) {
if (shellConvert.error) {
@@ -1155,10 +1391,108 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
executedSql = shellConvert.command;
}
}
}
const startTime = Date.now();
const startTime = Date.now();
let queryId: string;
try {
queryId = await GenerateQueryID();
} catch (error) {
console.warn('GenerateQueryID failed, using local UUID fallback:', error);
queryId = 'query-' + uuidv4();
}
setQueryId(queryId);
// Generate query ID for cancellation using backend UUID with fallback
const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query-${idx + 1}`,
timestamp: Date.now(),
sql: executedSql,
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined),
dbName: currentDb
});
if (!res.success) {
const prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
message.error(prefix + res.message);
setResultSets([]);
setActiveResultKey('');
return;
}
if (Array.isArray(res.data)) {
let rows = (res.data as any[]) || [];
let truncated = false;
if (wantsLimitProbe && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) {
truncated = true;
anyTruncated = true;
rows = rows.slice(0, maxRows);
}
const cols = (res.fields && res.fields.length > 0)
? (res.fields as string[])
: (rows.length > 0 ? Object.keys(rows[0]) : []);
rows.forEach((row: any, i: number) => {
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i;
});
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
rows,
columns: cols,
pkColumns: [],
readOnly: true,
truncated
});
} else {
const affected = Number((res.data as any)?.affectedRows);
if (Number.isFinite(affected)) {
const row = { affectedRows: affected };
(row as any)[GONAVI_ROW_KEY] = 0;
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
rows: [row],
columns: ['affectedRows'],
pkColumns: [],
readOnly: true
});
}
}
}
setResultSets(nextResultSets);
setActiveResultKey(nextResultSets[0]?.key || '');
if (statements.length > 1) {
message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`);
} else if (nextResultSets.length === 0) {
message.success('执行成功。');
}
if (anyTruncated && maxRows > 0) {
message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`);
}
} else {
// 非 MongoDB使用 DBQueryMulti 一次性执行多条 SQL后端返回多结果集
let fullSQL = normalizedRawSQL;
if (!fullSQL.trim()) {
message.info('没有可执行的 SQL。');
setResultSets([]);
setActiveResultKey('');
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 {
queryId = await GenerateQueryID();
@@ -1168,22 +1502,20 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
setQueryId(queryId);
const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId);
const res = await DBQueryMulti(config as any, currentDb, fullSQL, queryId);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query-${idx + 1}`,
id: `log-${Date.now()}-query-multi`,
timestamp: Date.now(),
sql: executedSql,
sql: fullSQL,
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined),
dbName: currentDb
});
if (!res.success) {
// 检查是否为查询取消错误
const errorMsg = res.message.toLowerCase();
const isCancelledError = errorMsg.includes('context canceled') ||
errorMsg.includes('查询已取消') ||
@@ -1191,72 +1523,49 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
errorMsg.includes('cancelled') ||
errorMsg.includes('statement canceled') ||
errorMsg.includes('sql: statement canceled');
// 确保不是超时错误
const isTimeoutError = errorMsg.includes('context deadline exceeded') ||
errorMsg.includes('timeout') ||
errorMsg.includes('超时') ||
errorMsg.includes('deadline exceeded');
if (isCancelledError && !isTimeoutError) {
// 查询已被用户取消,不显示错误消息,清理状态
setResultSets([]);
setActiveResultKey('');
// 清除查询ID与handleCancel保持一致
if (currentQueryIdRef.current) {
clearQueryId();
}
return;
}
const prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
message.error(prefix + res.message);
message.error(res.message);
setResultSets([]);
setActiveResultKey('');
return;
}
if (Array.isArray(res.data)) {
let rows = (res.data as any[]) || [];
let truncated = false;
if (limited.applied && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) {
truncated = true;
anyTruncated = true;
rows = rows.slice(0, maxRows);
}
const cols = (res.fields && res.fields.length > 0)
? (res.fields as string[])
: (rows.length > 0 ? Object.keys(rows[0]) : []);
// res.data 是 ResultSetData[] 数组
const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : [];
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
let anyTruncated = false;
const pendingPk: Array<{ resultKey: string; tableName: string }> = [];
rows.forEach((row: any, i: number) => {
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i;
});
// 前端也拆分语句用于匹配原始 SQL展示和表名检测
const statements = splitSQLStatements(fullSQL);
let simpleTableName: string | undefined = undefined;
const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
if (!forceReadOnlyResult) {
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
}
}
for (let idx = 0; idx < resultSetDataArray.length; idx++) {
const rsData = resultSetDataArray[idx];
const rawStatement = (idx < statements.length) ? statements[idx] : '';
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: limited.applied ? applyAutoLimit(rawStatement, dbType, Math.max(1, Number(maxRows) || 1)).sql : rawStatement,
rows,
columns: cols,
tableName: simpleTableName,
pkColumns: [],
readOnly: true,
pkLoading: !!simpleTableName,
truncated
});
} else {
const affected = Number((res.data as any)?.affectedRows);
if (Number.isFinite(affected)) {
const row = { affectedRows: affected };
// 检查是否为 affectedRows 类结果集
const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1
&& rsData.columns && rsData.columns.length === 1
&& rsData.columns[0] === 'affectedRows';
if (isAffectedResult) {
const affected = Number(rsData.rows[0]?.affectedRows);
const row = { affectedRows: Number.isFinite(affected) ? affected : 0 };
(row as any)[GONAVI_ROW_KEY] = 0;
nextResultSets.push({
key: `result-${idx + 1}`,
@@ -1267,37 +1576,80 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
pkColumns: [],
readOnly: true
});
} else {
let rows = Array.isArray(rsData.rows) ? rsData.rows : [];
let truncated = false;
if (Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) {
truncated = true;
anyTruncated = true;
rows = rows.slice(0, maxRows);
}
const cols = (rsData.columns && rsData.columns.length > 0)
? rsData.columns
: (rows.length > 0 ? Object.keys(rows[0]) : []);
rows.forEach((row: any, i: number) => {
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i;
});
let simpleTableName: string | undefined = undefined;
if (rawStatement) {
const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
if (!forceReadOnlyResult) {
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
}
}
}
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
rows,
columns: cols,
tableName: simpleTableName,
pkColumns: [],
readOnly: true,
pkLoading: !!simpleTableName,
truncated
});
}
}
}
setResultSets(nextResultSets);
setActiveResultKey(nextResultSets[0]?.key || '');
setResultSets(nextResultSets);
setActiveResultKey(nextResultSets[0]?.key || '');
pendingPk.forEach(({ resultKey, tableName }) => {
DBGetColumns(config as any, currentDb, tableName)
.then((resCols: any) => {
if (runSeqRef.current !== runSeq) return;
if (!resCols?.success) {
pendingPk.forEach(({ resultKey, tableName }) => {
DBGetColumns(config as any, currentDb, tableName)
.then((resCols: any) => {
if (runSeqRef.current !== runSeq) return;
if (!resCols?.success) {
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs));
return;
}
const primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkColumns: primaryKeys, pkLoading: false, readOnly: false } : rs));
})
.catch(() => {
if (runSeqRef.current !== runSeq) return;
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs));
return;
}
const primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkColumns: primaryKeys, pkLoading: false, readOnly: false } : rs));
})
.catch(() => {
if (runSeqRef.current !== runSeq) return;
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs));
});
});
});
});
if (statements.length > 1) {
message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`);
} else if (nextResultSets.length === 0) {
message.success('执行成功。');
}
if (anyTruncated && maxRows > 0) {
message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`);
// 后端附带的提示信息(如数据源不支持原生多语句执行的回退提示)
if (res.message) {
message.info(res.message);
}
if (resultSetDataArray.length > 1) {
message.success(`已执行完成,生成 ${nextResultSets.length} 个结果集。`);
} else if (nextResultSets.length === 0) {
message.success('执行成功。');
}
if (anyTruncated && maxRows > 0) {
message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`);
}
}
} catch (e: any) {
message.error("Error executing query: " + e.message);
@@ -1341,6 +1693,46 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
};
useEffect(() => {
const handleSelectAllInEditor = (event: KeyboardEvent) => {
if (activeTabId !== tab.id) {
return;
}
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') {
return;
}
const editor = editorRef.current;
if (!editor) {
return;
}
const targetNode = event.target instanceof Node ? event.target : null;
const editorHasFocus = !!editor.hasTextFocus?.();
const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode));
const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode));
if (!editorHasFocus && !inEditorPane) {
return;
}
if (!editorHasFocus && isEditableElement(event.target) && !inEditorPane) {
return;
}
if (!editorHasFocus && !inQueryEditor) {
return;
}
event.preventDefault();
event.stopPropagation();
editor.focus?.();
editor.trigger('keyboard', 'editor.action.selectAll', null);
};
window.addEventListener('keydown', handleSelectAllInEditor, true);
return () => {
window.removeEventListener('keydown', handleSelectAllInEditor, true);
};
}, [activeTabId, tab.id]);
useEffect(() => {
const binding = shortcutOptions.runQuery;
if (!binding?.enabled || !binding.combo) {
@@ -1383,16 +1775,60 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
};
}, [activeTabId, tab.id, handleRun]);
const resolveDefaultQueryName = () => {
const rawTitle = String(tab.title || '').trim();
if (!rawTitle || rawTitle.startsWith('新建查询')) {
return '未命名查询';
}
return rawTitle;
};
const persistQuery = (payload: { id: string; name: string; createdAt?: number }) => {
const sql = getCurrentQuery();
const saved = {
id: payload.id,
name: payload.name,
sql,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
createdAt: payload.createdAt ?? Date.now(),
};
saveQuery(saved);
addTab({
...tab,
title: payload.name,
query: sql,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
savedQueryId: payload.id,
});
return saved;
};
const handleQuickSave = () => {
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const saveId = existed?.id || fallbackSavedId || '';
if (!saveId) {
saveForm.setFieldsValue({ name: resolveDefaultQueryName() });
setIsSaveModalOpen(true);
return;
}
const saveName = existed?.name || resolveDefaultQueryName();
persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt });
message.success('查询已保存!');
};
const handleSave = async () => {
try {
const values = await saveForm.validateFields();
saveQuery({
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
name: values.name,
sql: getCurrentQuery(),
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
createdAt: Date.now()
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const nextSavedId = existed?.id || fallbackSavedId || `saved-${Date.now()}`;
persistQuery({
id: nextSavedId,
name: String(values.name || '').trim() || '未命名查询',
createdAt: existed?.createdAt,
});
message.success('查询已保存!');
setIsSaveModalOpen(false);
@@ -1408,8 +1844,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
setActiveResultKey(prevActive => {
if (prevActive && prevActive !== key) return prevActive;
const nextKey = next[idx]?.key || next[idx - 1]?.key || next[0]?.key || '';
return nextKey;
return next[idx]?.key || next[idx - 1]?.key || next[0]?.key || '';
});
return next;
@@ -1417,7 +1852,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
};
return (
<div style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div ref={queryEditorRootRef} style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<style>{`
.query-result-tabs {
flex: 1 1 auto;
@@ -1460,6 +1895,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
transition: none !important;
}
`}</style>
<div ref={editorPaneRef}>
<div style={{ padding: '8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<Select
style={{ width: 150 }}
@@ -1512,10 +1948,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
</Button>
)}
</Button.Group>
<Button icon={<SaveOutlined />} onClick={() => {
saveForm.setFieldsValue({ name: tab.title.replace('Query (', '').replace(')', '') });
setIsSaveModalOpen(true);
}}>
<Button icon={<SaveOutlined />} onClick={handleQuickSave}>
</Button>
@@ -1557,6 +1990,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}}
title="拖动调整高度"
/>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
{resultSets.length > 0 ? (

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip } from 'antd';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress } from 'antd';
import {
DatabaseOutlined,
TableOutlined,
@@ -32,10 +32,13 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
CheckOutlined,
FilterOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection } from '../types';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
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;
@@ -88,6 +91,7 @@ const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
const connections = useStore(state => state.connections);
const savedQueries = useStore(state => state.savedQueries);
const deleteQuery = useStore(state => state.deleteQuery);
const addConnection = useStore(state => state.addConnection);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
@@ -106,6 +110,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const tableSortPreference = useStore(state => state.tableSortPreference);
const recordTableAccess = useStore(state => state.recordTableAccess);
const setTableSortPreference = useStore(state => state.setTableSortPreference);
const addSqlLog = useStore(state => state.addSqlLog);
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
@@ -121,41 +126,40 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#141414');
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const modalPanelStyle = useMemo(() => ({
background: darkMode
? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const modalSectionStyle = useMemo(() => ({
padding: 14,
borderRadius: 14,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
}), [darkMode]);
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalScrollSectionStyle = useMemo(() => ({
maxHeight: 400,
overflow: 'auto' as const,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
border: overlayTheme.sectionBorder,
borderRadius: 14,
padding: 12,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.8)',
}), [darkMode]);
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalHintTextStyle = useMemo(() => ({
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [darkMode]);
}), [overlayTheme]);
const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
@@ -279,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 => {
@@ -1447,6 +1454,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onDoubleClick = (e: any, node: any) => {
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
const { id, dbName, schemaName } = node.dataRef;
addTab({
id: `table-overview-${id}-${dbName}${schemaName ? `-${schemaName}` : ''}`,
title: `表概览 - ${dbName}${schemaName ? ` (${schemaName})` : ''}`,
type: 'table-overview' as any,
connectionId: id,
dbName,
});
return;
}
if (node.type === 'table') {
const { tableName, dbName, id } = node.dataRef;
// 记录表访问
@@ -1479,7 +1497,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
type: 'query',
connectionId: q.connectionId,
dbName: q.dbName,
query: q.sql
query: q.sql,
savedQueryId: q.id,
});
return;
} else if (node.type === 'redis-db') {
@@ -1560,7 +1579,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
};
@@ -1583,7 +1602,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
@@ -1610,7 +1629,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
@@ -1799,7 +1818,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
} else {
message.success('导出成功');
}
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
@@ -1808,6 +1827,94 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
};
const handleBatchClear = async () => {
const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key));
if (selectedObjects.length === 0) {
message.warning('请至少选择一个对象');
return;
}
const { conn, dbName } = batchDbContext;
const objectNames = selectedObjects.map(t => t.objectName);
const ok = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: '确认清空选中表',
content: `清空选中表会永久删除表中所有数据,操作不可逆,是否继续?\r\n\r\n连接: ${conn.name}\n数据库: ${dbName}`,
okText: '继续',
cancelText: '取消',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!ok) return;
setIsBatchModalOpen(false);
const hide = message.loading(`正在清空选中表 (${objectNames.length})...`, 0);
const startTime = Date.now();
try {
const app = (window as any).go.app.App;
const res = await app.TruncateTables(normalizeConnConfig(conn.config), dbName, objectNames);
hide();
const duration = Date.now() - startTime;
if (res.success) {
message.success('清空成功');
// 构造 SQL 日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) */\n`;
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
logSql += res.data.executedSQLs.join(';\n') + ';';
} else {
logSql += objectNames.map(name => name).join('; ');
}
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'success',
duration,
message: res.message,
dbName,
affectedRows: res.data?.count || 0
});
} else if (res.message !== '已取消') {
message.error('清空失败: ' + res.message);
// 记录失败的日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) - FAILED */\n`;
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
logSql += res.data.executedSQLs.join(';\n') + ';';
} else {
logSql += objectNames.map(name => name).join('; ');
}
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'error',
duration,
message: res.message,
dbName
});
}
} catch (e: any) {
const duration = Date.now() - startTime;
hide();
const errMsg = e?.message || String(e);
message.error('清空失败: ' + errMsg);
// 记录异常的日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) - ERROR */\n`;
logSql += objectNames.map(name => name).join('; ');
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'error',
duration,
message: errMsg,
dbName
});
}
};
const handleCheckAll = (checked: boolean) => {
if (batchSelectionScope === 'all') {
setCheckedTableKeys(checked ? allBatchObjectKeys : []);
@@ -1939,7 +2046,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hide();
if (res.success) {
message.success(`${db.dbName} 导出成功`);
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error(`${db.dbName} 导出失败: ` + res.message);
break;
} else {
@@ -1968,23 +2075,127 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const handleRunSQLFile = async (node: any) => {
const res = await (window as any).go.app.App.OpenSQLFile();
const res = await OpenSQLFile();
if (res.success) {
const sqlContent = res.data;
const data = res.data;
// 大文件:后端返回文件路径,走流式执行
if (data && typeof data === 'object' && data.isLargeFile) {
const connId = node.type === 'connection' ? node.key : node.dataRef?.id;
const dbName = node.dataRef?.dbName || '';
const conn = connections.find(c => c.id === connId);
if (!conn) {
message.error('未找到对应的连接配置');
return;
}
startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB);
return;
}
// 小文件:加载到编辑器
const sqlContent = data;
const { dbName, id } = node.dataRef;
addTab({
id: `query-${Date.now()}`,
title: `Import SQL`,
title: `运行外部SQL文件`,
type: 'query',
connectionId: node.type === 'connection' ? node.key : node.dataRef.id,
dbName: dbName,
query: sqlContent
});
} else if (res.message !== "Cancelled") {
message.error("读取文件失败: " + res.message);
} else if (res.message !== '已取消') {
message.error('读取文件失败: ' + res.message);
}
};
const handleOpenSQLFileFromToolbar = async () => {
const ctx = useStore.getState().activeContext;
if (!ctx?.connectionId) {
message.warning('请先选择一个连接或数据库');
return;
}
const res = await OpenSQLFile();
if (res.success) {
const data = res.data;
// 大文件:后端流式执行
if (data && typeof data === 'object' && data.isLargeFile) {
const conn = connections.find(c => c.id === ctx.connectionId);
if (!conn) {
message.error('未找到对应的连接配置');
return;
}
startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB);
return;
}
// 小文件
addTab({
id: `query-${Date.now()}`,
title: `运行外部SQL文件`,
type: 'query',
connectionId: ctx.connectionId,
dbName: ctx.dbName || undefined,
query: data
});
} else if (res.message !== '已取消') {
message.error('读取文件失败: ' + res.message);
}
};
// SQL 文件流式执行状态
const [sqlFileExecState, setSqlFileExecState] = useState<{
open: boolean;
jobId: string;
fileSizeMB: string;
status: 'running' | 'done' | 'cancelled' | 'error';
executed: number;
failed: number;
total: number;
percent: number;
currentSQL: string;
resultMessage: string;
}>({
open: false, jobId: '', fileSizeMB: '', status: 'running',
executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: ''
});
const startSQLFileExecution = (config: any, dbName: string, filePath: string, fileSizeMB: string) => {
const jobId = `sqlfile-${Date.now()}`;
setSqlFileExecState({
open: true, jobId, fileSizeMB, status: 'running',
executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: ''
});
// 监听进度事件
const offProgress = EventsOn('sqlfile:progress', (event: any) => {
if (!event || event.jobId !== jobId) return;
setSqlFileExecState(prev => ({
...prev,
status: event.status || prev.status,
executed: typeof event.executed === 'number' ? event.executed : prev.executed,
failed: typeof event.failed === 'number' ? event.failed : prev.failed,
total: typeof event.total === 'number' ? event.total : prev.total,
percent: typeof event.percent === 'number' ? Math.min(100, event.percent) : prev.percent,
currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL,
}));
});
// 异步执行
ExecuteSQLFile(config, dbName, filePath, jobId).then(res => {
offProgress();
setSqlFileExecState(prev => ({
...prev,
status: res.success ? 'done' : (prev.status === 'cancelled' ? 'cancelled' : 'error'),
percent: 100,
resultMessage: res.message || '',
}));
}).catch(err => {
offProgress();
setSqlFileExecState(prev => ({
...prev,
status: 'error',
resultMessage: String(err?.message || err),
}));
});
};
const handleCreateDatabase = async () => {
try {
const values = await createDbForm.validateFields();
@@ -1993,7 +2204,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: "" }
};
@@ -2535,12 +2746,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const searchScopePopoverContent = useMemo(() => {
const smartSelected = searchScopes.includes('smart');
const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart');
const borderColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)';
const mutedTextColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)';
const titleColor = darkMode ? 'rgba(255,255,255,0.92)' : '#162033';
const panelBg = darkMode
? 'linear-gradient(180deg, rgba(17,24,39,0.96) 0%, rgba(10,15,26,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)';
const borderColor = overlayTheme.sectionBorder.replace('1px solid ', '');
const mutedTextColor = overlayTheme.mutedText;
const titleColor = overlayTheme.titleText;
const panelBg = overlayTheme.shellBg;
const smartBg = smartSelected
? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)')
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)');
@@ -2591,7 +2800,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</div>
</label>
<div style={{ height: 1, background: borderColor, opacity: 0.9 }} />
<div style={{ height: 1, background: overlayTheme.divider, opacity: 0.9 }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.3, color: mutedTextColor, textTransform: 'uppercase' }}></div>
@@ -2628,7 +2837,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</div>
</div>
);
}, [darkMode, searchScopes]);
}, [darkMode, overlayTheme, searchScopes]);
const parseHostOnlyToken = (value: unknown): string[] => {
const raw = String(value || '').trim();
@@ -2983,6 +3192,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
}
},
{
key: 'open-sql-file',
label: '运行外部SQL文件',
icon: <FileAddOutlined />,
onClick: () => handleRunSQLFile(node)
},
{ type: 'divider' },
{
key: 'edit',
@@ -3169,7 +3384,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
},
{
key: 'run-sql',
label: '运行 SQL 文件...',
label: '运行外部SQL文件',
icon: <FileAddOutlined />,
onClick: () => handleRunSQLFile(node)
}
@@ -3261,13 +3476,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
const tableName = String(node.dataRef?.tableName || '').trim();
const queryTemplate = tableName ? `SELECT * FROM ${tableName};` : 'SELECT * FROM ';
addTab({
id: `query-${Date.now()}`,
title: `新建查询`,
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: ''
query: queryTemplate
});
}
},
@@ -3324,6 +3541,56 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
];
}
// 已存查询节点的右键菜单
if (node.type === 'saved-query') {
const q = node.dataRef;
return [
{
key: 'open-query',
label: '打开查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: q.id,
title: q.name,
type: 'query',
connectionId: q.connectionId,
dbName: q.dbName,
query: q.sql,
savedQueryId: q.id,
});
}
},
{ type: 'divider' },
{
key: 'delete-query',
label: '删除查询',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除已保存的查询 "${q.name}" 吗?此操作不可恢复。`,
okButtonProps: { danger: true },
onOk: () => {
deleteQuery(q.id);
// 从树中移除节点
setTreeData(origin => {
const removeNode = (list: TreeNode[]): TreeNode[] =>
list
.filter(n => n.key !== node.key)
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
return removeNode(origin);
});
message.success('查询已删除');
}
});
}
}
];
}
return [];
};
@@ -3536,6 +3803,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
>
</Button>
<Button
size="small"
icon={<FileAddOutlined />}
onClick={handleOpenSQLFileFromToolbar}
style={{ flex: '1 1 auto' }}
>
SQL文件
</Button>
</div>
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
@@ -3716,6 +3991,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</Button>
<Space size={8} wrap style={{ marginLeft: 'auto' }}>
<Button
key="clear"
danger
icon={<DeleteOutlined />}
onClick={() => handleBatchClear()}
disabled={checkedTableKeys.length === 0}
>
</Button>
<Button
key="export-schema"
icon={<ExportOutlined />}
@@ -3989,6 +4273,66 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</>
)}
</Modal>
{/* SQL 文件流式执行进度 Modal */}
<Modal
title="运行外部SQL文件"
open={sqlFileExecState.open}
centered
closable={sqlFileExecState.status !== 'running'}
maskClosable={false}
footer={sqlFileExecState.status === 'running' ? [
<Button key="cancel" danger onClick={() => {
CancelSQLFileExecution(sqlFileExecState.jobId);
setSqlFileExecState(prev => ({ ...prev, status: 'cancelled' }));
}}>
</Button>
] : [
<Button key="close" type="primary" onClick={() => setSqlFileExecState(prev => ({ ...prev, open: false }))}>
</Button>
]}
onCancel={() => {
if (sqlFileExecState.status !== 'running') {
setSqlFileExecState(prev => ({ ...prev, open: false }));
}
}}
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none' }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
>
<div style={{ marginBottom: 16 }}>
<Progress
percent={Math.round(sqlFileExecState.percent)}
status={sqlFileExecState.status === 'error' ? 'exception' : sqlFileExecState.status === 'done' ? 'success' : 'active'}
strokeColor={sqlFileExecState.status === 'cancelled' ? '#faad14' : undefined}
/>
</div>
<div style={{ fontSize: 13, lineHeight: '22px', marginBottom: 8 }}>
<div><strong>{sqlFileExecState.fileSizeMB} MB</strong></div>
<div><strong>{
sqlFileExecState.status === 'running' ? '执行中...' :
sqlFileExecState.status === 'done' ? '✅ 完成' :
sqlFileExecState.status === 'cancelled' ? '⚠️ 已取消' : '❌ 出错'
}</strong></div>
<div><strong style={{ color: '#52c41a' }}>{sqlFileExecState.executed}</strong> | <strong style={{ color: sqlFileExecState.failed > 0 ? '#ff4d4f' : undefined }}>{sqlFileExecState.failed}</strong> </div>
</div>
{sqlFileExecState.currentSQL && sqlFileExecState.status === 'running' && (
<div style={{ fontSize: 12, color: 'rgba(128,128,128,0.8)', background: 'rgba(128,128,128,0.06)', borderRadius: 6, padding: '6px 10px', marginTop: 8, fontFamily: 'monospace', wordBreak: 'break-all', maxHeight: 60, overflow: 'hidden' }}>
{sqlFileExecState.currentSQL}
</div>
)}
{sqlFileExecState.resultMessage && sqlFileExecState.status !== 'running' && (
<div style={{ fontSize: 12, marginTop: 12, maxHeight: 200, overflow: 'auto', whiteSpace: 'pre-wrap', background: 'rgba(128,128,128,0.06)', borderRadius: 6, padding: '8px 12px' }}>
{sqlFileExecState.resultMessage}
</div>
)}
</Modal>
<FindInDatabaseModal
open={findInDbContext.open}
onClose={() => setFindInDbContext({ open: false, connectionId: '', dbName: '' })}
connectionId={findInDbContext.connectionId}
dbName={findInDbContext.dbName}
/>
</div>
);
};

View File

@@ -14,6 +14,7 @@ import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import type { TabData } from '../types';
const detectConnectionEnvLabel = (connectionName: string): string | null => {
@@ -28,7 +29,7 @@ const detectConnectionEnvLabel = (connectionName: string): string | null => {
};
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
if (tab.type !== 'table' && tab.type !== 'design') return tab.title;
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
if (!connectionName) return tab.title;
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
@@ -144,12 +145,8 @@ const TabManager: React.FC = () => {
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command';
const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive;
let content;
if (!shouldRenderContent) {
content = null;
} else if (tab.type === 'query') {
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
} else if (tab.type === 'table') {
content = <DataViewer tab={tab} />;
@@ -163,6 +160,8 @@ const TabManager: React.FC = () => {
content = <TriggerViewer tab={tab} />;
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
content = <DefinitionViewer tab={tab} />;
} else if (tab.type === 'table-overview') {
content = <TableOverview tab={tab} />;
}
const menuItems: MenuProps['items'] = [
@@ -203,7 +202,7 @@ const TabManager: React.FC = () => {
key: tab.id,
children: content,
};
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>
@@ -297,6 +296,7 @@ const TabManager: React.FC = () => {
<Tabs
className="main-tabs"
type="editable-card"
destroyInactiveTabPane={false}
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react';
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag } from 'antd';
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd';
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
@@ -121,7 +121,7 @@ const ResizableTitle = (props: any) => {
nextStyle.width = width;
}
if (!width) {
if (!onResizeStart) {
return <th {...restProps} style={nextStyle} />;
}
@@ -225,7 +225,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const [tableCommentDraft, setTableCommentDraft] = useState('');
const [isTableCommentModalOpen, setIsTableCommentModalOpen] = useState(false);
const [tableCommentSaving, setTableCommentSaving] = useState(false);
const [selectedIndex, setSelectedIndex] = useState<IndexDisplayRow | null>(null);
const [selectedIndexKeys, setSelectedIndexKeys] = useState<string[]>([]);
const [isIndexModalOpen, setIsIndexModalOpen] = useState(false);
const [indexModalMode, setIndexModalMode] = useState<'create' | 'edit'>('create');
const [indexSaving, setIndexSaving] = useState(false);
@@ -270,6 +270,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const [tableHeight, setTableHeight] = useState(500);
const containerRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const pendingFocusColumnKeyRef = useRef<string | null>(null);
const focusHighlightTimerRef = useRef<number | null>(null);
const [focusColumnKey, setFocusColumnKey] = useState('');
@@ -329,7 +330,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
// --- Resizable Columns State ---
const [tableColumns, setTableColumns] = useState<any[]>([]);
const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number } | null>(null);
const [indexColumns, setIndexColumns] = useState<any[]>([]);
const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number; setter: React.Dispatch<React.SetStateAction<any[]>> } | null>(null);
const resizeRafRef = useRef<number | null>(null);
const latestResizeXRef = useRef<number | null>(null);
const ghostRef = useRef<HTMLDivElement>(null);
@@ -413,11 +415,6 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
// Initial Columns Definition
useEffect(() => {
const initialCols = [
...(readOnly ? [] : [{
key: 'sort',
width: 40,
render: () => <MenuOutlined style={{ cursor: 'grab', color: '#999' }} />,
}]),
{
title: '名',
dataIndex: 'name',
@@ -548,17 +545,17 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
document.body.style.userSelect = '';
}, []);
const handleResizeStart = useCallback((index: number) => (e: React.MouseEvent) => {
const createResizeStartHandler = useCallback((columns: any[], setter: React.Dispatch<React.SetStateAction<any[]>>) => (index: number) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const currentWidth = Number(tableColumns[index]?.width || 200);
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0;
resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft };
const currentWidth = Number(columns[index]?.width || 200);
const containerLeft = shellRef.current?.getBoundingClientRect().left ?? 0;
resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft, setter };
latestResizeXRef.current = startX;
if (ghostRef.current && containerRef.current) {
if (ghostRef.current && shellRef.current) {
const relativeLeft = startX - containerLeft;
ghostRef.current.style.transform = `translateX(${relativeLeft}px)`;
ghostRef.current.style.display = 'block';
@@ -575,10 +572,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const onUp = (event: MouseEvent) => {
if (resizeDragRef.current) {
const { startX: dragStartX, startWidth, index: dragIndex } = resizeDragRef.current;
const { startX: dragStartX, startWidth, index: dragIndex, setter: dragSetter } = resizeDragRef.current;
const deltaX = event.clientX - dragStartX;
const newWidth = Math.max(50, startWidth + deltaX);
setTableColumns((prevColumns) => {
dragSetter((prevColumns) => {
if (!prevColumns[dragIndex]) return prevColumns;
const nextColumns = [...prevColumns];
nextColumns[dragIndex] = {
@@ -598,7 +595,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
document.addEventListener('mouseup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [cleanupResizeState, detachResizeListeners, flushResizeGhost, tableColumns]);
}, [cleanupResizeState, detachResizeListeners, flushResizeGhost]);
const handleResizeStart = useMemo(() => createResizeStartHandler(tableColumns, setTableColumns), [createResizeStartHandler, tableColumns]);
const handleIndexResizeStart = useMemo(() => createResizeStartHandler(indexColumns, setIndexColumns), [createResizeStartHandler, indexColumns]);
useEffect(() => {
return () => {
@@ -1083,6 +1083,11 @@ ${selectedTrigger.statement}`;
});
}, [indexes]);
const selectedIndex = useMemo(() => {
if (selectedIndexKeys.length === 0) return null;
return groupedIndexes.find(idx => selectedIndexKeys.includes(idx.key)) || null;
}, [selectedIndexKeys, groupedIndexes]);
const groupedIndexFieldCount = useMemo(
() => groupedIndexes.reduce((total, row) => total + row.columnNames.length, 0),
[groupedIndexes]
@@ -1161,11 +1166,12 @@ ${selectedTrigger.statement}`;
);
useEffect(() => {
if (!selectedIndex) return;
if (!groupedIndexes.some(idx => idx.key === selectedIndex.key)) {
setSelectedIndex(null);
if (selectedIndexKeys.length === 0) return;
const validKeys = selectedIndexKeys.filter(key => groupedIndexes.some(idx => idx.key === key));
if (validKeys.length !== selectedIndexKeys.length) {
setSelectedIndexKeys(validKeys);
}
}, [groupedIndexes, selectedIndex]);
}, [groupedIndexes, selectedIndexKeys]);
useEffect(() => {
if (!selectedForeignKey) return;
@@ -1397,14 +1403,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;
@@ -2000,13 +2015,163 @@ END;`;
};
// Merge columns with resize handler
const resizableColumns = tableColumns.map((col, index) => ({
const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({
...col,
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleResizeStart(index),
}),
}));
})), [tableColumns]);
// 字段表 Checkbox 选择列(不参与 resize支持全选
const allColumnKeys = useMemo(() => columns.map(c => c._key), [columns]);
const isAllColumnsSelected = allColumnKeys.length > 0 && selectedColumnRowKeys.length === allColumnKeys.length;
const isColumnsIndeterminate = selectedColumnRowKeys.length > 0 && selectedColumnRowKeys.length < allColumnKeys.length;
const columnSelectCol = useMemo(() => ({
title: () => (
<Checkbox
checked={isAllColumnsSelected}
indeterminate={isColumnsIndeterminate}
onChange={(e: any) => setSelectedColumnRowKeys(e.target.checked ? allColumnKeys : [])}
style={{ margin: 0 }}
/>
),
dataIndex: '_select',
key: '_select',
width: 48,
render: (_: any, record: any) => (
<Checkbox
checked={selectedColumnRowKeys.includes(record._key)}
onChange={(e: any) => {
e.stopPropagation();
setSelectedColumnRowKeys((prev: string[]) =>
e.target.checked
? [...prev, record._key]
: prev.filter((k: string) => k !== record._key)
);
}}
style={{ margin: 0 }}
/>
),
}), [selectedColumnRowKeys, allColumnKeys, isAllColumnsSelected, isColumnsIndeterminate]);
// sort 拖拽列(不参与 resize
const sortColumn = useMemo(() => ({
key: 'sort',
width: 40,
render: () => <MenuOutlined style={{ cursor: 'grab', color: '#999' }} />,
}), []);
const columnsWithSelect = useMemo(() =>
readOnly
? resizableColumns
: [columnSelectCol, sortColumn, ...resizableColumns],
[readOnly, columnSelectCol, sortColumn, resizableColumns]
);
// --- Index Columns Init ---
useEffect(() => {
setIndexColumns([
{
title: '索引名',
dataIndex: 'name',
key: 'name',
width: 240,
render: (text: string) => (
<Tooltip title={text}>
<span style={{ display: 'inline-block', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{text}
</span>
</Tooltip>
),
},
{
title: '字段',
dataIndex: 'columnNames',
key: 'columnNames',
width: 320,
render: (columnNames: string[]) => {
if (!columnNames || columnNames.length === 0) {
return '-';
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{columnNames.map((columnName: string, idx: number) => (
<Tag key={`${columnName}-${idx}`}>
{columnName}
</Tag>
))}
</div>
);
}
},
{
title: '索引类型',
dataIndex: 'indexType',
key: 'indexType',
width: 140,
render: (text: string) => text || '-',
},
{
title: '唯一性',
dataIndex: 'nonUnique',
key: 'nonUnique',
width: 110,
render: (v: number) => (
<Tag color={v === 0 ? 'gold' : 'default'}>
{v === 0 ? '唯一' : '普通'}
</Tag>
),
},
]);
}, []);
// Checkbox 选择列(不参与 resize支持全选
const allIndexKeys = groupedIndexes.map(idx => idx.key);
const isAllSelected = allIndexKeys.length > 0 && selectedIndexKeys.length === allIndexKeys.length;
const isIndeterminate = selectedIndexKeys.length > 0 && selectedIndexKeys.length < allIndexKeys.length;
const selectColumn = {
title: () => (
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onChange={(e) => {
setSelectedIndexKeys(e.target.checked ? allIndexKeys : []);
}}
style={{ margin: 0 }}
/>
),
dataIndex: '_select',
key: '_select',
width: 48,
render: (_: any, record: any) => (
<Checkbox
checked={selectedIndexKeys.includes(record.key)}
onChange={(e) => {
e.stopPropagation();
setSelectedIndexKeys(prev =>
e.target.checked
? [...prev, record.key]
: prev.filter(k => k !== record.key)
);
}}
style={{ margin: 0 }}
/>
),
};
const resizableIndexColumns = [
selectColumn,
...indexColumns.map((col, index) => ({
...col,
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleIndexResizeStart(index),
}),
})),
];
const columnsTabContent = (
<div
@@ -2030,7 +2195,7 @@ END;`;
{readOnly ? (
<Table
dataSource={columns}
columns={resizableColumns}
columns={columnsWithSelect}
rowKey="_key"
rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''}
size="small"
@@ -2049,11 +2214,7 @@ END;`;
<SortableContext items={columns.map(c => c._key)} strategy={verticalListSortingStrategy}>
<Table
dataSource={columns}
columns={resizableColumns}
rowSelection={{
selectedRowKeys: selectedColumnRowKeys,
onChange: (nextSelectedRowKeys) => setSelectedColumnRowKeys(nextSelectedRowKeys as string[]),
}}
columns={columnsWithSelect}
rowKey="_key"
rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''}
size="small"
@@ -2069,6 +2230,86 @@ END;`;
</SortableContext>
</DndContext>
)}
</div>
);
return (
<div ref={shellRef} className="table-designer-shell" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '6px 0', position: 'relative' }}>
<style>{`
.table-designer-shell .ant-table,
.table-designer-shell .ant-table-wrapper,
.table-designer-shell .ant-table-container {
background: transparent !important;
}
.table-designer-shell .ant-table-wrapper {
border: none !important;
overflow: hidden !important;
}
.table-designer-shell .ant-table-container {
border: none !important;
}
.table-designer-shell .ant-table-thead > tr > th {
background: transparent !important;
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'} !important;
border-inline-end: 1px solid transparent !important;
}
.table-designer-shell .ant-table-tbody > tr > td,
.table-designer-shell .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;
}
.table-designer-shell .ant-table-tbody td .ant-input {
padding-left: 0 !important;
padding-right: 0 !important;
}
.table-designer-shell .ant-table-tbody td .ant-select .ant-select-selector {
padding-left: 0 !important;
}
.table-designer-shell .ant-table-thead > tr > th::before {
display: none !important;
}
.table-designer-shell .ant-table-thead > tr > th {
cursor: default !important;
user-select: none !important;
-webkit-user-select: none !important;
}
.table-designer-shell .ant-table-tbody > tr:hover > td,
.table-designer-shell .ant-table-tbody .ant-table-row:hover > .ant-table-cell {
background: ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.02)'} !important;
}
.table-designer-shell .ant-tabs-nav {
margin-bottom: 8px !important;
}
.table-designer-shell .ant-tabs-nav::before {
border-bottom-color: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'} !important;
}
.table-designer-shell .ant-tabs-ink-bar {
will-change: transform;
transition: width 0.15s ease, left 0.15s ease, transform 0.15s ease !important;
}
.table-designer-shell .ant-tabs-tab {
transition: color 0.15s ease !important;
}
.table-designer-shell .ant-tabs-content-holder,
.table-designer-shell .ant-tabs-content,
.table-designer-shell .ant-tabs-tabpane {
height: 100%;
}
.table-designer-shell .react-resizable-handle {
position: absolute !important;
right: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 10px !important;
height: auto !important;
background-position: top right !important;
cursor: col-resize !important;
z-index: 10;
touch-action: none;
}
`}</style>
<div
ref={ghostRef}
style={{
@@ -2084,52 +2325,6 @@ END;`;
willChange: 'transform',
}}
/>
</div>
);
return (
<div className="table-designer-shell" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '6px 0' }}>
<style>{`
.table-designer-shell .ant-table,
.table-designer-shell .ant-table-wrapper,
.table-designer-shell .ant-table-container {
background: transparent !important;
}
.table-designer-shell .ant-table-wrapper,
.table-designer-shell .ant-table-container {
border: none !important;
overflow: hidden !important;
}
.table-designer-shell .ant-table-thead > tr > th {
background: transparent !important;
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'} !important;
border-inline-end: 1px solid transparent !important;
}
.table-designer-shell .ant-table-tbody > tr > td,
.table-designer-shell .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;
}
.table-designer-shell .ant-table-thead > tr > th::before {
display: none !important;
}
.table-designer-shell .ant-table-tbody > tr:hover > td,
.table-designer-shell .ant-table-tbody .ant-table-row:hover > .ant-table-cell {
background: ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.02)'} !important;
}
.table-designer-shell .ant-tabs-nav {
margin-bottom: 8px !important;
}
.table-designer-shell .ant-tabs-nav::before {
border-bottom-color: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'} !important;
}
.table-designer-shell .ant-tabs-content-holder,
.table-designer-shell .ant-tabs-content,
.table-designer-shell .ant-tabs-tabpane {
height: 100%;
}
`}</style>
<div
style={{
padding: '10px 12px 8px 12px',
@@ -2202,7 +2397,7 @@ END;`;
</div>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
onChange={(key) => React.startTransition(() => setActiveKey(key))}
style={{
flex: 1,
minHeight: 0,
@@ -2225,20 +2420,20 @@ END;`;
key: 'indexes',
label: '索引',
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className="index-table-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} disabled={!supportsIndexSchemaOps()} onClick={openCreateIndexModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={openEditIndexModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={handleDeleteIndex}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length !== 1} onClick={openEditIndexModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length === 0} onClick={handleDeleteIndex}></Button>
{!supportsIndexSchemaOps() && (
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
</span>
)}
{supportsIndexSchemaOps() && selectedIndex && (
{supportsIndexSchemaOps() && selectedIndexKeys.length > 0 && (
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedIndex.name}
{selectedIndexKeys.length}
</span>
)}
</div>
@@ -2248,75 +2443,22 @@ END;`;
</div>
<Table
dataSource={groupedIndexes}
columns={[
{
title: '索引名',
dataIndex: 'name',
key: 'name',
width: 240,
render: (text: string) => (
<Tooltip title={text}>
<span style={{ display: 'inline-block', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{text}
</span>
</Tooltip>
),
},
{
title: '字段',
dataIndex: 'columnNames',
key: 'columnNames',
render: (columnNames: string[]) => {
if (!columnNames || columnNames.length === 0) {
return '-';
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{columnNames.map((columnName, idx) => (
<Tag key={`${columnName}-${idx}`}>
{columnName}
</Tag>
))}
</div>
);
}
},
{
title: '索引类型',
dataIndex: 'indexType',
key: 'indexType',
width: 140,
render: (text: string) => text || '-',
},
{
title: '唯一性',
dataIndex: 'nonUnique',
key: 'nonUnique',
width: 110,
render: (v: number) => (
<Tag color={v === 0 ? 'gold' : 'default'}>
{v === 0 ? '唯一' : '普通'}
</Tag>
),
},
]}
columns={resizableIndexColumns}
rowKey="key"
size="small"
pagination={false}
loading={loading}
scroll={{ x: 960, y: tableHeight }}
rowSelection={{
type: 'radio',
selectedRowKeys: selectedIndex ? [selectedIndex.key] : [],
onChange: (_, selectedRows) => setSelectedIndex((selectedRows[0] as IndexDisplayRow) || null),
components={{
header: { cell: ResizableTitle },
}}
onRow={(record) => ({
onClick: () => {
if (selectedIndex?.key === record.key) {
setSelectedIndex(null);
} else {
setSelectedIndex(record);
}
setSelectedIndexKeys(prev =>
prev.includes(record.key)
? prev.filter(k => k !== record.key)
: [...prev, record.key]
);
},
style: { cursor: 'pointer' }
})}
@@ -2676,7 +2818,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>

View File

@@ -0,0 +1,454 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
interface TableOverviewProps {
tab: TabData;
}
interface TableStatRow {
name: string;
comment: string;
rows: number;
dataSize: number;
indexSize: number;
engine: string;
createTime: string;
updateTime: string;
}
type SortField = 'name' | 'rows' | 'dataSize';
type SortOrder = 'asc' | 'desc';
const formatSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const formatRows = (count: number): string => {
if (count === undefined || count === null || count < 0) return '—';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
};
const getMetadataDialect = (connType: string, driver?: string): string => {
const type = (connType || '').trim().toLowerCase();
if (type === 'custom') {
const d = (driver || '').trim().toLowerCase();
if (d === 'diros' || d === 'doris') return 'mysql';
return d;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
switch (dialect) {
case 'mysql':
return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
case 'postgres':
case 'kingbase':
case 'vastbase': {
const schema = schemaName || 'public';
return `
SELECT
c.relname AS table_name,
obj_description(c.oid, 'pg_class') AS table_comment,
c.reltuples::bigint AS table_rows,
pg_total_relation_size(c.oid) AS data_length,
pg_indexes_size(c.oid) AS index_length
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname = '${escapeLiteral(schema)}'
ORDER BY c.relname`;
}
case 'sqlserver': {
const safeDB = `[${dbName.replace(/]/g, ']]')}]`;
return `
SELECT
t.name AS table_name,
ep.value AS table_comment,
SUM(p.rows) AS table_rows,
SUM(a.total_pages) * 8 * 1024 AS data_length,
SUM(a.used_pages) * 8 * 1024 AS index_length
FROM ${safeDB}.sys.tables t
LEFT JOIN ${safeDB}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description'
LEFT JOIN ${safeDB}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)
LEFT JOIN ${safeDB}.sys.allocation_units a ON p.partition_id = a.container_id
WHERE t.type = 'U'
GROUP BY t.name, ep.value
ORDER BY t.name`;
}
case 'clickhouse':
return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`;
case 'dm':
case 'oracle': {
const owner = (schemaName || dbName).toUpperCase();
return `SELECT table_name, comments AS table_comment, num_rows AS table_rows, 0 AS data_length, 0 AS index_length FROM all_tab_comments JOIN all_tables USING (table_name, owner) WHERE owner = '${escapeLiteral(owner)}' ORDER BY table_name`;
}
default:
return `SELECT table_name, '' AS table_comment, 0 AS table_rows, 0 AS data_length, 0 AS index_length FROM information_schema.tables WHERE table_schema = '${escapeLiteral(dbName)}' AND table_type = 'BASE TABLE' ORDER BY table_name`;
}
};
const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableStatRow[] => {
return rows.map((row) => {
const get = (keys: string[]): any => {
for (const k of keys) {
for (const rk of Object.keys(row)) {
if (rk.toLowerCase() === k.toLowerCase() && row[rk] !== null && row[rk] !== undefined) return row[rk];
}
}
return undefined;
};
const strVal = (keys: string[]) => String(get(keys) ?? '').trim();
const numVal = (keys: string[]) => {
const v = get(keys);
if (v === null || v === undefined || v === '') return 0;
const n = Number(v);
return isNaN(n) ? 0 : Math.max(0, Math.round(n));
};
return {
name: strVal(['Name', 'table_name', 'tablename', 'TABLE_NAME']),
comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']),
rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']),
dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']),
indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH']),
engine: strVal(['Engine', 'engine']),
createTime: strVal(['Create_time', 'create_time']),
updateTime: strVal(['Update_time', 'update_time']),
};
}).filter(t => t.name);
};
const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const addTab = useStore(state => state.addTab);
const darkMode = theme === 'dark';
const [tables, setTables] = useState<TableStatRow[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const loadData = useCallback(async () => {
if (!connection) return;
setLoading(true);
try {
const config = {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
const dialect = getMetadataDialect(connection.config.type, (connection.config as any)?.driver);
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
const res = await DBQuery(config as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(dialect, res.data));
} else {
message.error('获取表信息失败: ' + (res.message || '未知错误'));
}
} catch (e: any) {
message.error('获取表信息失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
}, [connection, tab.dbName]);
useEffect(() => { loadData(); }, [loadData]);
const sortedFiltered = useMemo(() => {
let list = [...tables];
if (searchText.trim()) {
const kw = searchText.trim().toLowerCase();
list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw));
}
list.sort((a, b) => {
let cmp = 0;
if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
else if (sortField === 'rows') cmp = a.rows - b.rows;
else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize;
return sortOrder === 'asc' ? cmp : -cmp;
});
return list;
}, [tables, searchText, sortField, sortOrder]);
const openTable = useCallback((tableName: string) => {
if (!connection) return;
addTab({
id: `${connection.id}-${tab.dbName}-${tableName}`,
title: tableName,
type: 'table',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
});
}, [connection, tab.dbName, addTab]);
const openDesign = useCallback((tableName: string) => {
if (!connection) return;
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: `设计表 (${tableName})`,
type: 'design',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
initialTab: 'columns',
readOnly: false,
});
}, [connection, tab.dbName, addTab]);
const buildConfig = useCallback(() => {
if (!connection) return null;
return {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
}, [connection]);
const handleCopyStructure = useCallback(async (tableName: string) => {
const config = buildConfig();
if (!config) return;
const res = await DBShowCreateTable(config as any, tab.dbName || '', tableName);
if (res.success) {
navigator.clipboard.writeText(res.data as string);
message.success('表结构已复制到剪贴板');
} else {
message.error(res.message);
}
}, [buildConfig, tab.dbName]);
const handleExport = useCallback(async (tableName: string, format: string) => {
const config = buildConfig();
if (!config) return;
const hide = message.loading(`正在导出 ${tableName}${format.toUpperCase()}...`, 0);
const res = await ExportTable(config as any, tab.dbName || '', tableName, format);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
}, [buildConfig, tab.dbName]);
const handleDeleteTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
Modal.confirm({
title: '确认删除表',
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const res = await DropTable(config as any, tab.dbName || '', tableName);
if (res.success) {
message.success('表删除成功');
loadData();
} else {
message.error('删除失败: ' + res.message);
}
},
});
}, [buildConfig, tab.dbName, loadData]);
const handleRenameTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
let newName = tableName;
Modal.confirm({
title: '重命名表',
content: (
<Input
defaultValue={tableName}
onChange={e => { newName = e.target.value; }}
placeholder="输入新表名"
autoFocus
style={{ marginTop: 8 }}
/>
),
onOk: async () => {
const trimmed = newName.trim();
if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); }
if (trimmed === tableName) { message.warning('新旧表名相同'); return; }
const res = await RenameTable(config as any, tab.dbName || '', tableName, trimmed);
if (res.success) {
message.success('表重命名成功');
loadData();
} else {
message.error('重命名失败: ' + res.message);
}
},
});
}, [buildConfig, tab.dbName, loadData]);
// --- Theme ---
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const textPrimary = darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)';
const textSecondary = darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)';
const textMuted = darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)';
const accentColor = '#1677ff';
const containerBg = darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.01)';
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder(field === 'name' ? 'asc' : 'desc');
}
};
const sortMenuItems = [
{ key: 'name', label: `按名称${sortField === 'name' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('name') },
{ key: 'rows', label: `按行数${sortField === 'rows' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('rows') },
{ key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') },
];
const totalRows = tables.reduce((s, t) => s + t.rows, 0);
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
<Spin size="large" tip="加载表信息..." />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
{/* Toolbar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
<DatabaseOutlined style={{ fontSize: 16, color: accentColor }} />
<span style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
<span style={{ fontSize: 12, color: textMuted }}>
{tables.length} · {formatRows(totalRows)} · {formatSize(totalSize)}
</span>
<div style={{ flex: 1 }} />
<Input
placeholder="搜索表名或注释..."
prefix={<SearchOutlined style={{ color: textMuted }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
style={{ width: 240 }}
size="small"
/>
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</Dropdown>
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</div>
{/* Cards Grid */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{sortedFiltered.map(t => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
});
}},
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
]},
],
}}
>
<div
onDoubleClick={() => openTable(t.name)}
style={{
background: cardBg,
border: `1px solid ${cardBorder}`,
borderRadius: 10,
padding: '14px 16px',
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
{t.name}
</span>
</Tooltip>
</div>
{t.comment && (
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.comment}
</div>
</Tooltip>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
</div>
</div>
</Dropdown>
))}
</div>
)}
</div>
</div>
);
};
export default TableOverview;

View File

@@ -0,0 +1,39 @@
import { calculateTableBodyBottomPadding } from './dataGridLayout';
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
if (actual !== expected) {
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
}
};
assertEqual(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: false,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
}),
0,
'无横向滚动条时不应增加底部间距'
);
assertEqual(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
}),
28,
'默认悬浮滚动条应预留滚动条高度、间距和额外安全区'
);
assertEqual(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 14,
floatingScrollbarGap: 4,
}),
30,
'较粗滚动条场景下应同步放大底部安全区'
);
console.log('dataGridLayout tests passed');

View File

@@ -0,0 +1,23 @@
export interface TableBodyBottomPaddingOptions {
hasHorizontalOverflow: boolean;
floatingScrollbarHeight: number;
floatingScrollbarGap: number;
}
const MIN_SCROLLBAR_CLEARANCE = 8;
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
export const calculateTableBodyBottomPadding = ({
hasHorizontalOverflow,
floatingScrollbarHeight,
floatingScrollbarGap,
}: TableBodyBottomPaddingOptions): number => {
if (!hasHorizontalOverflow) {
return 0;
}
const safeScrollbarHeight = Math.max(0, Math.ceil(floatingScrollbarHeight));
const safeScrollbarGap = Math.max(0, Math.ceil(floatingScrollbarGap));
return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE;
};

View File

@@ -0,0 +1,105 @@
import type { RedisKeyInfo } from '../types';
import {
applyRenamedRedisKeyState,
applyTreeNodeCheck,
buildCheckedTreeNodeState,
buildRedisKeyTree,
isGroupFullyChecked,
} from './redisViewerTree';
const assert = (condition: unknown, message: string) => {
if (!condition) {
throw new Error(message);
}
};
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
const actualText = JSON.stringify(actual);
const expectedText = JSON.stringify(expected);
if (actualText !== expectedText) {
throw new Error(`${message}\nactual: ${actualText}\nexpected: ${expectedText}`);
}
};
const sampleKeys: RedisKeyInfo[] = [
{ key: 'app:user:1', type: 'string', ttl: -1 },
{ key: 'app:user:2', type: 'string', ttl: -1 },
{ key: 'app:order:1', type: 'hash', ttl: 120 },
{ key: 'misc', type: 'set', ttl: -1 },
];
const tree = buildRedisKeyTree(sampleKeys, true);
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
assert(appGroup, '应生成 group:app 节点');
assert(userGroup, '应生成 group:app:user 节点');
assertEqual(
appGroup?.descendantRawKeys,
['app:order:1', 'app:user:1', 'app:user:2'],
'app 分组应收集全部后代 key'
);
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
assertEqual(
selectedAfterGroupCheck,
['app:order:1', 'app:user:1', 'app:user:2'],
'勾选分组应递归选中全部后代 key'
);
const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
assertEqual(
checkedState.checked,
['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app'],
'全部后代已选中时,父分组和叶子都应进入 checked'
);
assertEqual(checkedState.halfChecked, [], '全部后代已选中时不应有 halfChecked');
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck), true, '全部后代已选中时,分组应视为 fully checked');
const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
assertEqual(selectedAfterGroupUncheck, [], '取消勾选分组应移除全部后代 key');
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck), false, '取消后分组不应再是 fully checked');
const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
assertEqual(
partialState.halfChecked,
['group:app:user', 'group:app'],
'仅部分后代选中时,相关分组应进入 halfChecked'
);
assertEqual(isGroupFullyChecked(appGroup!, ['app:user:1']), false, '部分选中时分组不应是 fully checked');
const renamedState = applyRenamedRedisKeyState(
{
keys: sampleKeys,
selectedKey: 'app:user:2',
selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
},
'app:user:2',
'app:user:200'
);
assertEqual(
renamedState.keys.map((item) => item.key),
['app:user:1', 'app:user:200', 'app:order:1', 'misc'],
'重命名后 keys 列表应替换旧 key'
);
assertEqual(renamedState.selectedKey, 'app:user:200', '当前详情选中的 key 应切换为新 key');
assertEqual(
renamedState.selectedKeys,
['app:user:1', 'app:user:200', 'misc'],
'批量选中集合中的旧 key 应映射为新 key'
);
const unrelatedRenameState = applyRenamedRedisKeyState(
{
keys: sampleKeys,
selectedKey: 'misc',
selectedKeys: ['app:user:1'],
},
'app:order:1',
'app:order:9'
);
assertEqual(unrelatedRenameState.selectedKey, 'misc', '非当前详情 key 的重命名不应影响 selectedKey');
assertEqual(unrelatedRenameState.selectedKeys, ['app:user:1'], '非已勾选 key 的重命名不应污染选中集合');
console.log('redisViewerTree tests passed');

View File

@@ -0,0 +1,260 @@
import type { DataNode } from 'antd/es/tree';
import type { RedisKeyInfo } from '../types';
const KEY_GROUP_DELIMITER = ':';
const EMPTY_SEGMENT_LABEL = '(empty)';
type RedisKeyTreeLeaf = {
keyInfo: RedisKeyInfo;
label: string;
};
type RedisKeyTreeGroup = {
name: string;
path: string;
children: Map<string, RedisKeyTreeGroup>;
leaves: RedisKeyTreeLeaf[];
leafCount: number;
};
export type RedisTreeDataNode = DataNode & {
nodeType: 'group' | 'leaf';
groupName?: string;
groupLeafCount?: number;
leafLabel?: string;
rawKey?: string;
keyType?: string;
ttl?: number;
descendantRawKeys?: string[];
};
export type RedisKeyTreeResult = {
treeData: RedisTreeDataNode[];
groupKeys: string[];
};
export type RedisTreeCheckedState = {
checked: string[];
halfChecked: string[];
};
export type RenamedRedisKeyStateInput = {
keys: RedisKeyInfo[];
selectedKey: string | null;
selectedKeys: string[];
};
export type RenamedRedisKeyStateResult = {
keys: RedisKeyInfo[];
selectedKey: string | null;
selectedKeys: string[];
};
const normalizeKeySegment = (segment: string): string => {
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
};
const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => {
return { name, path, children: new Map(), leaves: [], leafCount: 0 };
};
const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => {
let count = group.leaves.length;
group.children.forEach((child) => {
count += calculateGroupLeafCount(child);
});
group.leafCount = count;
return count;
};
export const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`;
export const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => {
const keyText = String(nodeKey);
if (!keyText.startsWith('key:')) {
return null;
}
return keyText.slice(4);
};
export const buildRedisKeyTree = (
keys: RedisKeyInfo[],
sortLeafNodes: boolean
): RedisKeyTreeResult => {
const root = createTreeGroup('__root__', '__root__');
keys.forEach((keyInfo) => {
const segments = keyInfo.key.split(KEY_GROUP_DELIMITER);
if (segments.length <= 1) {
root.leaves.push({ keyInfo, label: keyInfo.key });
return;
}
const groupSegments = segments.slice(0, -1);
const leafLabel = normalizeKeySegment(segments[segments.length - 1]);
let current = root;
const pathParts: string[] = [];
groupSegments.forEach((segment) => {
const normalized = normalizeKeySegment(segment);
pathParts.push(normalized);
const groupPath = pathParts.join(KEY_GROUP_DELIMITER);
let child = current.children.get(normalized);
if (!child) {
child = createTreeGroup(normalized, groupPath);
current.children.set(normalized, child);
}
current = child;
});
current.leaves.push({ keyInfo, label: leafLabel });
});
calculateGroupLeafCount(root);
const groupKeys: string[] = [];
const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => {
const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name));
const childLeaves = sortLeafNodes
? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key))
: group.leaves;
const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => {
const children = toTreeNodes(child);
const descendantRawKeys = children.flatMap((node) => {
if (node.nodeType === 'leaf') {
return node.rawKey ? [node.rawKey] : [];
}
return node.descendantRawKeys || [];
});
const groupNodeKey = `group:${child.path}`;
groupKeys.push(groupNodeKey);
return {
key: groupNodeKey,
title: child.name,
nodeType: 'group',
groupName: child.name,
groupLeafCount: child.leafCount,
selectable: false,
descendantRawKeys,
children,
};
});
const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => {
return {
key: buildLeafNodeKey(leaf.keyInfo.key),
isLeaf: true,
title: leaf.label,
nodeType: 'leaf',
leafLabel: leaf.label,
rawKey: leaf.keyInfo.key,
keyType: leaf.keyInfo.type,
ttl: leaf.keyInfo.ttl,
};
});
return [...groupNodes, ...leafNodes];
};
return {
treeData: toTreeNodes(root),
groupKeys,
};
};
export const applyTreeNodeCheck = (
selectedKeys: string[],
node: RedisTreeDataNode,
checked: boolean
): string[] => {
if (node.nodeType === 'leaf') {
if (!node.rawKey) {
return selectedKeys;
}
if (checked) {
return Array.from(new Set([...selectedKeys, node.rawKey]));
}
return selectedKeys.filter((item) => item !== node.rawKey);
}
const descendantRawKeys = node.descendantRawKeys || [];
if (descendantRawKeys.length === 0) {
return selectedKeys;
}
if (checked) {
return Array.from(new Set([...selectedKeys, ...descendantRawKeys]));
}
const removeSet = new Set(descendantRawKeys);
return selectedKeys.filter((item) => !removeSet.has(item));
};
const walkGroupStates = (
nodes: RedisTreeDataNode[],
selectedKeySet: Set<string>,
checked: string[],
halfChecked: string[]
) => {
nodes.forEach((node) => {
if (node.nodeType === 'leaf') {
if (node.rawKey && selectedKeySet.has(node.rawKey)) {
checked.push(String(node.key));
}
return;
}
walkGroupStates((node.children || []) as RedisTreeDataNode[], selectedKeySet, checked, halfChecked);
const descendantRawKeys = node.descendantRawKeys || [];
if (descendantRawKeys.length === 0) {
return;
}
const selectedCount = descendantRawKeys.filter((rawKey) => selectedKeySet.has(rawKey)).length;
if (selectedCount === descendantRawKeys.length) {
checked.push(String(node.key));
return;
}
if (selectedCount > 0) {
halfChecked.push(String(node.key));
}
});
};
export const buildCheckedTreeNodeState = (
selectedKeys: string[],
keyTree: RedisKeyTreeResult
): RedisTreeCheckedState => {
const selectedKeySet = new Set(selectedKeys);
const checked: string[] = [];
const halfChecked: string[] = [];
walkGroupStates(keyTree.treeData, selectedKeySet, checked, halfChecked);
return { checked, halfChecked };
};
export const isGroupFullyChecked = (
node: RedisTreeDataNode,
selectedKeys: string[]
): boolean => {
if (node.nodeType !== 'group') {
return false;
}
const descendantRawKeys = node.descendantRawKeys || [];
if (descendantRawKeys.length === 0) {
return false;
}
const selectedKeySet = new Set(selectedKeys);
return descendantRawKeys.every((rawKey) => selectedKeySet.has(rawKey));
};
export const applyRenamedRedisKeyState = (
state: RenamedRedisKeyStateInput,
oldKey: string,
newKey: string
): RenamedRedisKeyStateResult => {
return {
keys: state.keys.map((item) => (item.key === oldKey ? { ...item, key: newKey } : item)),
selectedKey: state.selectedKey === oldKey ? newKey : state.selectedKey,
selectedKeys: state.selectedKeys.map((item) => (item === oldKey ? newKey : item)),
};
};

View File

@@ -0,0 +1,50 @@
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
if (actual !== expected) {
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
}
};
const assertNotEqual = (actual: unknown, expected: unknown, message: string) => {
if (actual === expected) {
throw new Error(`${message}\nactual: ${String(actual)}\nnotExpected: ${String(expected)}`);
}
};
const assertMatch = (value: string, pattern: RegExp, message: string) => {
if (!pattern.test(value)) {
throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
}
};
const darkTheme = buildRedisWorkbenchTheme({
darkMode: true,
opacity: 0.72,
blur: 14,
});
assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
assertMatch(darkTheme.panelBg, /^rgba\(/, 'dark 主题面板背景应为 rgba');
assertMatch(darkTheme.toolbarPrimaryBg, /^linear-gradient\(/, '工具栏主按钮应使用渐变背景');
assertNotEqual(darkTheme.actionDangerBg, darkTheme.actionSecondaryBg, '危险态按钮背景不应与普通按钮相同');
assertNotEqual(darkTheme.treeSelectedBg, darkTheme.treeHoverBg, '树节点选中态与悬浮态不应相同');
assertMatch(darkTheme.appBg, /rgba\(15, 15, 17,/, 'dark 背景应保持中性黑基底');
assertMatch(darkTheme.panelBg, /rgba\(24, 24, 28,/, 'dark 面板背景应保持中性黑灰');
assertMatch(darkTheme.panelBgStrong, /rgba\(31, 31, 36,/, 'dark 强面板背景应保持中性黑灰');
assertEqual(darkTheme.backdropFilter, 'blur(14px)', 'blur 参数应映射为 backdropFilter');
const lightTheme = buildRedisWorkbenchTheme({
darkMode: false,
opacity: 1,
blur: 0,
});
assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
assertMatch(lightTheme.panelBg, /^rgba\(/, 'light 主题面板背景应为 rgba');
assertMatch(lightTheme.contentEmptyBg, /^linear-gradient\(/, 'light 空状态背景应为渐变');
assertNotEqual(lightTheme.textPrimary, lightTheme.textSecondary, '主次文本颜色应区分');
assertNotEqual(lightTheme.statusTagBg, lightTheme.statusTagMutedBg, '状态 tag 应区分普通与弱化样式');
assertEqual(lightTheme.backdropFilter, 'none', 'blur=0 时 backdropFilter 应为 none');
console.log('redisViewerWorkbenchTheme tests passed');

View File

@@ -0,0 +1,129 @@
type RedisWorkbenchThemeInput = {
darkMode: boolean;
opacity: number;
blur: number;
};
type RedisWorkbenchTheme = {
isDark: boolean;
appBg: string;
panelBg: string;
panelBgStrong: string;
panelBgSubtle: string;
panelBorder: string;
panelInset: string;
toolbarPrimaryBg: string;
contentEmptyBg: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
accent: string;
accentSoft: string;
accentBorder: string;
actionSecondaryBg: string;
actionSecondaryBorder: string;
actionDangerBg: string;
actionDangerBorder: string;
actionDangerText: string;
statusTagBg: string;
statusTagBorder: string;
statusTagMutedBg: string;
statusTagMutedBorder: string;
treeHoverBg: string;
treeSelectedBg: string;
treeSelectedBorder: string;
divider: string;
shadow: string;
backdropFilter: string;
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export const buildRedisWorkbenchTheme = ({
darkMode,
opacity,
blur,
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
const normalizedOpacity = clamp(opacity, 0.1, 1);
const normalizedBlur = Math.max(0, Math.round(blur));
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
if (darkMode) {
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
const appBottomAlpha = isTranslucent ? Math.max(0.12, Math.min(0.28, normalizedOpacity * 0.22)) : 0.96;
const panelAlpha = isTranslucent ? Math.max(0.06, Math.min(0.16, normalizedOpacity * 0.1)) : 0.34;
const strongAlpha = isTranslucent ? Math.max(0.1, Math.min(0.22, normalizedOpacity * 0.16)) : 0.42;
const subtleAlpha = isTranslucent ? Math.max(0.03, Math.min(0.08, normalizedOpacity * 0.05)) : 0.08;
return {
isDark: true,
appBg: `linear-gradient(180deg, rgba(15, 15, 17, ${appTopAlpha}) 0%, rgba(11, 11, 13, ${appBottomAlpha}) 100%)`,
panelBg: `rgba(24, 24, 28, ${panelAlpha})`,
panelBgStrong: `rgba(31, 31, 36, ${strongAlpha})`,
panelBgSubtle: `rgba(255, 255, 255, ${subtleAlpha})`,
panelBorder: `1px solid rgba(255, 255, 255, ${isTranslucent ? Math.max(0.12, Math.min(0.24, normalizedOpacity * 0.2)) : 0.08})`,
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? Math.max(0.05, Math.min(0.12, normalizedOpacity * 0.1)) : 0.04})`,
toolbarPrimaryBg: `linear-gradient(135deg, rgba(246,196,83,0.22) 0%, rgba(246,196,83,0.12) 100%)`,
contentEmptyBg: `linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.015) 100%)`,
textPrimary: 'rgba(245, 247, 251, 0.96)',
textSecondary: 'rgba(218, 224, 235, 0.82)',
textMuted: 'rgba(168, 177, 194, 0.72)',
accent: '#f6c453',
accentSoft: 'rgba(246, 196, 83, 0.18)',
accentBorder: 'rgba(246, 196, 83, 0.3)',
actionSecondaryBg: 'rgba(255, 255, 255, 0.04)',
actionSecondaryBorder: 'rgba(255, 255, 255, 0.09)',
actionDangerBg: 'rgba(255, 95, 95, 0.12)',
actionDangerBorder: 'rgba(255, 95, 95, 0.28)',
actionDangerText: '#ff8f8f',
statusTagBg: 'rgba(25, 106, 255, 0.16)',
statusTagBorder: 'rgba(25, 106, 255, 0.28)',
statusTagMutedBg: 'rgba(255, 255, 255, 0.04)',
statusTagMutedBorder: 'rgba(255, 255, 255, 0.08)',
treeHoverBg: 'rgba(255, 255, 255, 0.045)',
treeSelectedBg: 'linear-gradient(90deg, rgba(246,196,83,0.2) 0%, rgba(246,196,83,0.08) 100%)',
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
divider: 'rgba(255, 255, 255, 0.07)',
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
};
}
const appTopAlpha = isTranslucent ? Math.max(0.16, Math.min(0.36, normalizedOpacity * 0.24)) : 0.98;
const appBottomAlpha = isTranslucent ? Math.max(0.22, Math.min(0.44, normalizedOpacity * 0.32)) : 0.96;
const panelAlpha = isTranslucent ? Math.max(0.18, Math.min(0.4, normalizedOpacity * 0.26)) : 0.94;
const strongAlpha = isTranslucent ? Math.max(0.26, Math.min(0.52, normalizedOpacity * 0.34)) : 0.98;
return {
isDark: false,
appBg: `linear-gradient(180deg, rgba(248, 250, 252, ${appTopAlpha}) 0%, rgba(242, 245, 248, ${appBottomAlpha}) 100%)`,
panelBg: `rgba(255, 255, 255, ${panelAlpha})`,
panelBgStrong: `rgba(255, 255, 255, ${strongAlpha})`,
panelBgSubtle: 'rgba(15, 23, 42, 0.03)',
panelBorder: `1px solid rgba(15, 23, 42, ${isTranslucent ? Math.max(0.1, Math.min(0.18, normalizedOpacity * 0.12)) : 0.08})`,
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? 0.38 : 0.72})`,
toolbarPrimaryBg: 'linear-gradient(135deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.06) 100%)',
contentEmptyBg: 'linear-gradient(180deg, rgba(15,23,42,0.02) 0%, rgba(15,23,42,0.01) 100%)',
textPrimary: 'rgba(15, 23, 42, 0.92)',
textSecondary: 'rgba(51, 65, 85, 0.82)',
textMuted: 'rgba(100, 116, 139, 0.76)',
accent: '#1677ff',
accentSoft: 'rgba(22, 119, 255, 0.12)',
accentBorder: 'rgba(22, 119, 255, 0.22)',
actionSecondaryBg: 'rgba(255, 255, 255, 0.72)',
actionSecondaryBorder: 'rgba(15, 23, 42, 0.08)',
actionDangerBg: 'rgba(255, 77, 79, 0.08)',
actionDangerBorder: 'rgba(255, 77, 79, 0.24)',
actionDangerText: '#cf1322',
statusTagBg: 'rgba(22, 119, 255, 0.1)',
statusTagBorder: 'rgba(22, 119, 255, 0.16)',
statusTagMutedBg: 'rgba(15, 23, 42, 0.04)',
statusTagMutedBorder: 'rgba(15, 23, 42, 0.08)',
treeHoverBg: 'rgba(15, 23, 42, 0.035)',
treeSelectedBg: 'linear-gradient(90deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.05) 100%)',
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
divider: 'rgba(15, 23, 42, 0.08)',
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
};
};
export type { RedisWorkbenchTheme, RedisWorkbenchThemeInput };

View File

@@ -116,7 +116,7 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
connectionId: string;
dbName?: string;
tableName?: string;
@@ -128,6 +128,7 @@ export interface TabData {
viewName?: string; // View name for view definition tabs
routineName?: string; // Routine name for function/procedure definition tabs
routineType?: string; // 'FUNCTION' or 'PROCEDURE'
savedQueryId?: string; // Saved query identity for quick-save behavior
}
export interface DatabaseNode {

View File

@@ -0,0 +1,27 @@
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
if (actual !== expected) {
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
}
};
const assertMatch = (value: string, pattern: RegExp, message: string) => {
if (!pattern.test(value)) {
throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
}
};
const darkTheme = buildOverlayWorkbenchTheme(true);
assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
assertMatch(darkTheme.shellBg, /rgba\(15, 15, 17,/, 'dark 弹层背景应保持中性黑');
assertMatch(darkTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/, 'dark section 背景透明度应匹配');
assertEqual(darkTheme.iconColor, '#ffd666', 'dark 图标色应为金色强调');
const lightTheme = buildOverlayWorkbenchTheme(false);
assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
assertMatch(lightTheme.shellBg, /rgba\(255,255,255,0\.98\)/, 'light 弹层背景透明度应匹配');
assertMatch(lightTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/, 'light section 背景透明度应匹配');
assertEqual(lightTheme.iconColor, '#1677ff', 'light 图标色应为蓝色强调');
console.log('overlayWorkbenchTheme tests passed');

View File

@@ -0,0 +1,59 @@
type OverlayWorkbenchTheme = {
isDark: boolean;
shellBg: string;
shellBorder: string;
shellShadow: string;
shellBackdropFilter: string;
sectionBg: string;
sectionBorder: string;
mutedText: string;
titleText: string;
iconBg: string;
iconColor: string;
hoverBg: string;
selectedBg: string;
selectedText: string;
divider: string;
};
export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => {
if (darkMode) {
return {
isDark: true,
shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)',
shellBorder: '1px solid rgba(255,255,255,0.08)',
shellShadow: '0 24px 56px rgba(0,0,0,0.34)',
shellBackdropFilter: 'blur(18px)',
sectionBg: 'rgba(255,255,255,0.03)',
sectionBorder: '1px solid rgba(255,255,255,0.08)',
mutedText: 'rgba(255,255,255,0.5)',
titleText: '#f5f7ff',
iconBg: 'rgba(255,214,102,0.12)',
iconColor: '#ffd666',
hoverBg: 'rgba(255,214,102,0.10)',
selectedBg: 'rgba(255,214,102,0.14)',
selectedText: '#ffd666',
divider: 'rgba(255,255,255,0.08)',
};
}
return {
isDark: false,
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
shellBorder: '1px solid rgba(16,24,40,0.08)',
shellShadow: '0 18px 42px rgba(15,23,42,0.12)',
shellBackdropFilter: 'none',
sectionBg: 'rgba(255,255,255,0.84)',
sectionBorder: '1px solid rgba(16,24,40,0.08)',
mutedText: 'rgba(16,24,40,0.55)',
titleText: '#162033',
iconBg: 'rgba(24,144,255,0.1)',
iconColor: '#1677ff',
hoverBg: 'rgba(24,144,255,0.08)',
selectedBg: 'rgba(24,144,255,0.12)',
selectedText: '#1677ff',
divider: 'rgba(16,24,40,0.08)',
};
};
export type { OverlayWorkbenchTheme };

2
frontend/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
},
build: {
outDir: 'dist', // Standard Wails output directory
emptyOutDir: true,
}
});

View File

@@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
},

View File

@@ -9,6 +9,8 @@ export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:s
export function CancelQuery(arg1:string):Promise<connection.QueryResult>;
export function CancelSQLFileExecution(arg1:string):Promise<connection.QueryResult>;
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
export function CheckForUpdates():Promise<connection.QueryResult>;
@@ -41,6 +43,8 @@ export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string
export function DBQueryIsolated(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DBQueryMulti(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
@@ -63,6 +67,8 @@ export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:stri
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
@@ -131,6 +137,8 @@ export function RedisGetServerInfo(arg1:connection.ConnectionConfig):Promise<con
export function RedisGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisKeyExists(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;
@@ -188,3 +196,5 @@ export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;

View File

@@ -10,6 +10,10 @@ export function CancelQuery(arg1) {
return window['go']['app']['App']['CancelQuery'](arg1);
}
export function CancelSQLFileExecution(arg1) {
return window['go']['app']['App']['CancelSQLFileExecution'](arg1);
}
export function CheckDriverNetworkStatus() {
return window['go']['app']['App']['CheckDriverNetworkStatus']();
}
@@ -74,6 +78,10 @@ export function DBQueryIsolated(arg1, arg2, arg3) {
return window['go']['app']['App']['DBQueryIsolated'](arg1, arg2, arg3);
}
export function DBQueryMulti(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DBQueryMulti'](arg1, arg2, arg3, arg4);
}
export function DBQueryWithCancel(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DBQueryWithCancel'](arg1, arg2, arg3, arg4);
}
@@ -118,6 +126,10 @@ export function DropView(arg1, arg2, arg3) {
return window['go']['app']['App']['DropView'](arg1, arg2, arg3);
}
export function ExecuteSQLFile(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExecuteSQLFile'](arg1, arg2, arg3, arg4);
}
export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}
@@ -254,6 +266,10 @@ export function RedisGetValue(arg1, arg2) {
return window['go']['app']['App']['RedisGetValue'](arg1, arg2);
}
export function RedisKeyExists(arg1, arg2) {
return window['go']['app']['App']['RedisKeyExists'](arg1, arg2);
}
export function RedisListPush(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3);
}
@@ -369,3 +385,7 @@ export function SetWindowTranslucency(arg1, arg2) {
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}
export function TruncateTables(arg1, arg2, arg3) {
return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3);
}

View File

@@ -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)
@@ -116,7 +118,7 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Database created successfully"}
return connection.QueryResult{Success: true, Message: "数据库创建成功"}
}
func resolveDDLDBType(config connection.ConnectionConfig) string {
@@ -487,6 +489,151 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin
}
}
// DBQueryMulti 执行可能包含多条 SQL 语句的查询,返回多个结果集。
// 如果底层驱动支持 MultiResultQuerier一次性执行所有语句
// 否则按分号拆分后逐条执行,模拟多结果集。
func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult {
runConfig := normalizeRunConfig(config, dbName)
if queryID == "" {
queryID = generateQueryID()
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
logger.Error(err, "DBQueryMulti 获取连接失败:%s", formatConnSummary(runConfig))
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
}
query = sanitizeSQLForPgLike(runConfig.Type, query)
timeoutSeconds := runConfig.Timeout
if timeoutSeconds <= 0 {
timeoutSeconds = 30
}
ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second)
defer cancel()
a.queryMu.Lock()
a.runningQueries[queryID] = queryContext{
cancel: cancel,
started: time.Now(),
}
a.queryMu.Unlock()
defer func() {
a.queryMu.Lock()
delete(a.runningQueries, queryID)
a.queryMu.Unlock()
}()
// 尝试使用驱动原生多结果集支持
runMultiQuery := func(inst db.Database) ([]connection.ResultSetData, error) {
if q, ok := inst.(db.MultiResultQuerierContext); ok {
return q.QueryMultiContext(ctx, query)
}
if q, ok := inst.(db.MultiResultQuerier); ok {
return q.QueryMulti(query)
}
return nil, nil // 返回 nil 表示不支持
}
results, err := runMultiQuery(dbInst)
if err != nil && shouldRefreshCachedConnection(err) {
if a.invalidateCachedDatabase(runConfig, err) {
retryInst, retryErr := a.getDatabaseForcePing(runConfig)
if retryErr != nil {
logger.Error(retryErr, "DBQueryMulti 重建连接失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
return connection.QueryResult{Success: false, Message: retryErr.Error(), QueryID: queryID}
}
results, err = runMultiQuery(retryInst)
}
}
if err != nil {
logger.Error(err, "DBQueryMulti 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
}
// 驱动支持多结果集,直接返回
if results != nil {
return connection.QueryResult{Success: true, Data: results, QueryID: queryID}
}
// 驱动不支持多结果集,回退到逐条执行
statements := splitSQLStatements(query)
if len(statements) == 0 {
return connection.QueryResult{
Success: true,
Data: []connection.ResultSetData{},
QueryID: queryID,
}
}
var resultSets []connection.ResultSetData
for idx, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if isReadOnlySQLQuery(runConfig.Type, stmt) {
var data []map[string]interface{}
var columns []string
if q, ok := dbInst.(interface {
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
}); ok {
data, columns, err = q.QueryContext(ctx, stmt)
} else {
data, columns, err = dbInst.Query(stmt)
}
if err != nil {
logger.Error(err, "DBQueryMulti 逐条查询失败(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt))
errMsg := fmt.Sprintf("第 %d 条语句执行失败: %v", idx+1, err)
if len(resultSets) > 0 {
errMsg += fmt.Sprintf("(前 %d 条已执行成功)", len(resultSets))
}
return connection.QueryResult{Success: false, Message: errMsg, QueryID: queryID}
}
if data == nil {
data = make([]map[string]interface{}, 0)
}
if columns == nil {
columns = []string{}
}
resultSets = append(resultSets, connection.ResultSetData{Rows: data, Columns: columns})
} else {
var affected int64
if e, ok := dbInst.(interface {
ExecContext(context.Context, string) (int64, error)
}); ok {
affected, err = e.ExecContext(ctx, stmt)
} else {
affected, err = dbInst.Exec(stmt)
}
if err != nil {
logger.Error(err, "DBQueryMulti 逐条执行失败(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt))
errMsg := fmt.Sprintf("第 %d 条语句执行失败: %v", idx+1, err)
if len(resultSets) > 0 {
errMsg += fmt.Sprintf("(前 %d 条已执行成功)", len(resultSets))
}
return connection.QueryResult{Success: false, Message: errMsg, QueryID: queryID}
}
resultSets = append(resultSets, connection.ResultSetData{
Rows: []map[string]interface{}{{"affectedRows": affected}},
Columns: []string{"affectedRows"},
})
}
}
if resultSets == nil {
resultSets = []connection.ResultSetData{}
}
// 回退到逐条执行且有多条语句时,附加提示信息
var fallbackMsg string
if len(statements) > 1 {
fallbackMsg = fmt.Sprintf("当前数据源(%s不支持原生多语句执行已自动拆分为 %d 条语句逐条执行。", runConfig.Type, len(statements))
}
return connection.QueryResult{Success: true, Data: resultSets, QueryID: queryID, Message: fallbackMsg}
}
func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
runConfig := normalizeRunConfig(config, dbName)

View File

@@ -0,0 +1,112 @@
package app
import (
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestNormalizeTestConnectionConfig_CapsTimeout(t *testing.T) {
cfg := connection.ConnectionConfig{Timeout: 60}
got := normalizeTestConnectionConfig(cfg)
if got.Timeout != testConnectionTimeoutUpperBoundSeconds {
t.Fatalf("timeout 应被限制为 %d, got=%d", testConnectionTimeoutUpperBoundSeconds, got.Timeout)
}
}
func TestNormalizeTestConnectionConfig_KeepSmallTimeout(t *testing.T) {
cfg := connection.ConnectionConfig{Timeout: 5}
got := normalizeTestConnectionConfig(cfg)
if got.Timeout != 5 {
t.Fatalf("timeout 不应被修改, got=%d", got.Timeout)
}
}
func TestNormalizeTestConnectionConfig_ZeroTimeout(t *testing.T) {
cfg := connection.ConnectionConfig{Timeout: 0}
got := normalizeTestConnectionConfig(cfg)
if got.Timeout != testConnectionTimeoutUpperBoundSeconds {
t.Fatalf("零值 timeout 应被修正, got=%d", got.Timeout)
}
}
func TestFormatConnSummary_BasicMySQL(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "mysql",
Host: "127.0.0.1",
Port: 3306,
User: "root",
Database: "test_db",
Timeout: 30,
}
got := formatConnSummary(cfg)
for _, want := range []string{"类型=mysql", "127.0.0.1:3306", "test_db", "root"} {
if !strings.Contains(got, want) {
t.Fatalf("formatConnSummary 应包含 %q, got=%q", want, got)
}
}
}
func TestFormatConnSummary_SQLitePath(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "sqlite",
Host: "/data/test.db",
}
got := formatConnSummary(cfg)
if !strings.Contains(got, "类型=sqlite") {
t.Fatalf("formatConnSummary 缺少类型, got=%q", got)
}
if !strings.Contains(got, "/data/test.db") {
t.Fatalf("formatConnSummary 缺少路径, got=%q", got)
}
}
func TestFormatConnSummary_SSH(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "mysql",
Host: "db.internal",
Port: 3306,
User: "app",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "jump.server",
Port: 22,
User: "admin",
},
}
got := formatConnSummary(cfg)
if !strings.Contains(got, "SSH=jump.server:22") {
t.Fatalf("formatConnSummary 应包含 SSH 信息, got=%q", got)
}
}
func TestFormatConnSummary_Proxy(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "mysql",
Host: "db.internal",
Port: 3306,
UseProxy: true,
Proxy: connection.ProxyConfig{
Type: "socks5",
Host: "proxy.local",
Port: 1080,
},
}
got := formatConnSummary(cfg)
if !strings.Contains(got, "代理=socks5://proxy.local:1080") {
t.Fatalf("formatConnSummary 应包含代理信息, got=%q", got)
}
}
func TestFormatConnSummary_DefaultTimeout(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "mysql",
Host: "localhost",
Port: 3306,
}
got := formatConnSummary(cfg)
if !strings.Contains(got, "超时=30s") {
t.Fatalf("formatConnSummary 默认超时应为30s, got=%q", got)
}
}

View File

@@ -353,7 +353,7 @@ func (a *App) SelectDriverDownloadDirectory(currentDir string) connection.QueryR
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
resolved, err := resolveDriverDownloadDirectory(selection)
@@ -392,7 +392,7 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
if abs, err := filepath.Abs(selection); err == nil {
@@ -423,7 +423,7 @@ func (a *App) SelectDriverPackageDirectory(currentPath string) connection.QueryR
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
if abs, err := filepath.Abs(selection); err == nil {
selection = abs

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"html"
"io"
"math"
"os"
"path/filepath"
@@ -48,7 +49,28 @@ func (a *App) OpenSQLFile() connection.QueryResult {
}
if selection == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
// 检查文件大小
const maxSQLFileSize int64 = 50 * 1024 * 1024 // 50MB
fi, err := os.Stat(selection)
if err != nil {
return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)}
}
// 大文件:只返回文件路径和大小,不读取内容
if fi.Size() > maxSQLFileSize {
sizeMB := float64(fi.Size()) / (1024 * 1024)
return connection.QueryResult{
Success: true,
Data: map[string]interface{}{
"isLargeFile": true,
"filePath": selection,
"fileSize": fi.Size(),
"fileSizeMB": fmt.Sprintf("%.1f", sizeMB),
},
}
}
content, err := os.ReadFile(selection)
@@ -59,6 +81,184 @@ func (a *App) OpenSQLFile() connection.QueryResult {
return connection.QueryResult{Success: true, Data: string(content)}
}
// ExecuteSQLFile 在后端流式读取并执行大 SQL 文件,通过事件推送进度。
// 前端通过 EventsOn("sqlfile:progress", ...) 监听进度。
func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string, filePath string, jobID string) connection.QueryResult {
if strings.TrimSpace(filePath) == "" {
return connection.QueryResult{Success: false, Message: "文件路径为空"}
}
if strings.TrimSpace(jobID) == "" {
jobID = fmt.Sprintf("sqlfile-%d", time.Now().UnixMilli())
}
logger.Warnf("ExecuteSQLFile 开始file=%s db=%s jobID=%s", filePath, dbName, jobID)
// 获取数据库连接
runConfig := normalizeRunConfig(config, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
logger.Error(err, "ExecuteSQLFile 获取连接失败:%s", formatConnSummary(runConfig))
return connection.QueryResult{Success: false, Message: err.Error()}
}
// 打开文件
f, err := os.Open(filePath)
if err != nil {
return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法打开文件: %v", err)}
}
defer f.Close()
// 获取文件大小用于计算进度
fi, _ := f.Stat()
totalSize := fi.Size()
// 设置取消上下文
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
a.queryMu.Lock()
a.runningQueries[jobID] = queryContext{
cancel: cancel,
started: time.Now(),
}
a.queryMu.Unlock()
defer func() {
a.queryMu.Lock()
delete(a.runningQueries, jobID)
a.queryMu.Unlock()
}()
// 发送进度事件的辅助函数
emitProgress := func(status string, executed, failed, total int, bytesRead int64, currentSQL string, errMsg string) {
percent := 0.0
if totalSize > 0 {
percent = float64(bytesRead) / float64(totalSize) * 100
if percent > 100 {
percent = 100
}
}
runtime.EventsEmit(a.ctx, "sqlfile:progress", map[string]interface{}{
"jobId": jobID,
"status": status,
"executed": executed,
"failed": failed,
"total": total,
"percent": percent,
"bytesRead": bytesRead,
"totalBytes": totalSize,
"currentSQL": currentSQL,
"error": errMsg,
})
}
emitProgress("running", 0, 0, 0, 0, "", "")
// 使用 countingReader 追踪已读取字节数
cr := &countingReader{r: f}
var executedCount int
var failedCount int
var errorLogs []string
startTime := time.Now()
_, streamErr := streamSQLFile(cr, func(index int, stmt string) error {
// 检查是否已取消
select {
case <-ctx.Done():
return fmt.Errorf("已取消")
default:
}
// 执行语句
_, execErr := dbInst.Exec(stmt)
if execErr != nil {
failedCount++
snippet := stmt
if len(snippet) > 200 {
snippet = snippet[:200] + "..."
}
errLog := fmt.Sprintf("第 %d 条语句执行失败: %v\n SQL: %s", index+1, execErr, snippet)
errorLogs = append(errorLogs, errLog)
logger.Warnf("ExecuteSQLFile %s", errLog)
} else {
executedCount++
}
// 每条语句执行后推送进度(但限频:每 100 条或每秒推一次)
total := executedCount + failedCount
if total%100 == 0 || total <= 10 {
snippet := stmt
if len(snippet) > 100 {
snippet = snippet[:100] + "..."
}
emitProgress("running", executedCount, failedCount, total, cr.n, snippet, "")
}
return nil
})
duration := time.Since(startTime)
if streamErr != nil && streamErr.Error() == "已取消" {
emitProgress("cancelled", executedCount, failedCount, executedCount+failedCount, cr.n, "", "用户取消执行")
logger.Warnf("ExecuteSQLFile 已取消executed=%d failed=%d duration=%v", executedCount, failedCount, duration)
return connection.QueryResult{
Success: false,
Message: fmt.Sprintf("执行已取消。已执行 %d 条,失败 %d 条,耗时 %v。", executedCount, failedCount, duration.Round(time.Millisecond)),
}
}
if streamErr != nil {
emitProgress("error", executedCount, failedCount, executedCount+failedCount, cr.n, "", streamErr.Error())
return connection.QueryResult{
Success: false,
Message: fmt.Sprintf("文件读取错误: %v。已执行 %d 条。", streamErr, executedCount),
}
}
emitProgress("done", executedCount, failedCount, executedCount+failedCount, totalSize, "", "")
summary := fmt.Sprintf("执行完成。成功 %d 条,失败 %d 条,耗时 %v。", executedCount, failedCount, duration.Round(time.Millisecond))
if len(errorLogs) > 0 {
maxShow := 20
if len(errorLogs) < maxShow {
maxShow = len(errorLogs)
}
summary += "\n\n错误详情前 " + fmt.Sprintf("%d", maxShow) + " 条):\n" + strings.Join(errorLogs[:maxShow], "\n")
if len(errorLogs) > maxShow {
summary += fmt.Sprintf("\n...还有 %d 条错误未显示", len(errorLogs)-maxShow)
}
}
logger.Warnf("ExecuteSQLFile 完成executed=%d failed=%d duration=%v", executedCount, failedCount, duration)
return connection.QueryResult{Success: failedCount == 0, Message: summary}
}
// CancelSQLFileExecution 取消正在执行的 SQL 文件任务。
func (a *App) CancelSQLFileExecution(jobID string) connection.QueryResult {
a.queryMu.Lock()
defer a.queryMu.Unlock()
if ctx, exists := a.runningQueries[jobID]; exists {
ctx.cancel()
delete(a.runningQueries, jobID)
return connection.QueryResult{Success: true, Message: "已发送取消请求"}
}
return connection.QueryResult{Success: false, Message: "未找到该任务"}
}
// countingReader 包装 io.Reader追踪已读取的字节数。
type countingReader struct {
r io.Reader
n int64
}
func (cr *countingReader) Read(p []byte) (int, error) {
n, err := cr.r.Read(p)
cr.n += int64(n)
return n, err
}
func (a *App) ImportConfigFile() connection.QueryResult {
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select Config File",
@@ -75,7 +275,7 @@ func (a *App) ImportConfigFile() connection.QueryResult {
}
if selection == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
content, err := os.ReadFile(selection)
@@ -120,7 +320,7 @@ func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
if abs, err := filepath.Abs(selection); err == nil {
selection = abs
@@ -192,7 +392,7 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
if abs, err := filepath.Abs(selection); err == nil {
selection = abs
@@ -203,7 +403,7 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti
// PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据
func (a *App) PreviewImportFile(filePath string) connection.QueryResult {
if filePath == "" {
return connection.QueryResult{Success: false, Message: "File path required"}
return connection.QueryResult{Success: false, Message: "文件路径不能为空"}
}
rows, columns, err := parseImportFile(filePath)
@@ -243,7 +443,7 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s
}
if selection == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
// 返回文件路径供前端预览
@@ -492,7 +692,7 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
}
if len(rows) == 0 {
return connection.QueryResult{Success: true, Message: "No data to import"}
return connection.QueryResult{Success: true, Message: "无可导入数据"}
}
runConfig := normalizeRunConfig(config, dbName)
@@ -584,7 +784,7 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
})
if err != nil || filename == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
runConfig := normalizeRunConfig(config, dbName)
@@ -616,7 +816,7 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Export successful"}
return connection.QueryResult{Success: true, Message: "导出完成"}
}
query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(runConfig.Type, tableName))
@@ -632,10 +832,10 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
}
defer f.Close()
if err := writeRowsToFile(f, data, columns, format); err != nil {
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
}
return connection.QueryResult{Success: true, Message: "Export successful"}
return connection.QueryResult{Success: true, Message: "导出完成"}
}
func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeData bool) connection.QueryResult {
@@ -648,7 +848,7 @@ func (a *App) ExportTablesDataSQL(config connection.ConnectionConfig, dbName str
func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeSchema bool, includeData bool) connection.QueryResult {
if !includeSchema && !includeData {
return connection.QueryResult{Success: false, Message: "invalid export mode"}
return connection.QueryResult{Success: false, Message: "无效的导出模式"}
}
safeDbName := strings.TrimSpace(dbName)
@@ -671,7 +871,7 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
DefaultFilename: defaultFilename,
})
if err != nil || filename == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
runConfig := normalizeRunConfig(config, dbName)
@@ -717,13 +917,13 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Export successful"}
return connection.QueryResult{Success: true, Message: "导出完成"}
}
func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName string, includeData bool) connection.QueryResult {
safeDbName := strings.TrimSpace(dbName)
if safeDbName == "" {
return connection.QueryResult{Success: false, Message: "dbName required"}
return connection.QueryResult{Success: false, Message: "数据库名称不能为空"}
}
suffix := "schema"
if includeData {
@@ -735,7 +935,7 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
DefaultFilename: fmt.Sprintf("%s_%s.sql", safeDbName, suffix),
})
if err != nil || filename == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
runConfig := normalizeRunConfig(config, dbName)
@@ -772,7 +972,92 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "Export successful"}
return connection.QueryResult{Success: true, Message: "导出完成"}
}
// TruncateTables 清空指定表的数据(针对 MySQL 使用 TRUNCATEMongoDB 使用 delete否则使用 DELETE
// 注意MySQL 的 TRUNCATE TABLE 是 DDL 操作,无法事务回滚;批量清空为逐表执行,
// 如果中途失败,已清空的表无法恢复。错误结果会附带已执行的 SQL 列表供排查。
func (a *App) TruncateTables(config connection.ConnectionConfig, dbName string, tableNames []string) connection.QueryResult {
runConfig := normalizeRunConfig(config, dbName)
// 参数校验
if len(tableNames) == 0 {
return connection.QueryResult{Success: false, Message: "未指定要清空的表"}
}
objects := make([]string, 0, len(tableNames))
seen := make(map[string]struct{}, len(tableNames))
for _, t := range tableNames {
tt := strings.TrimSpace(t)
if tt == "" {
continue
}
if _, ok := seen[tt]; ok {
continue
}
seen[tt] = struct{}{}
objects = append(objects, tt)
}
if len(objects) == 0 {
return connection.QueryResult{Success: false, Message: "未指定要清空的表"}
}
const maxBatchSize = 200
if len(objects) > maxBatchSize {
return connection.QueryResult{Success: false, Message: fmt.Sprintf("单次最多清空 %d 张表,当前选中 %d 张", maxBatchSize, len(objects))}
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
// 审计日志:记录清空操作的发起
logger.Warnf("TruncateTables 开始:%s db=%s tables=%v共 %d 张)", formatConnSummary(runConfig), dbName, objects, len(objects))
dbType := strings.ToLower(strings.TrimSpace(runConfig.Type))
var executedSQLs []string
for i, objectName := range objects {
var sql string
if dbType == "mysql" || dbType == "mariadb" {
sql = fmt.Sprintf("TRUNCATE TABLE %s", quoteQualifiedIdentByType(runConfig.Type, objectName))
} else if dbType == "mongodb" {
// MongoDB 使用 delete 命令清空集合中的所有文档
// deletes 的 limit 为 0 表示删除所有匹配的文档
sql = fmt.Sprintf(`{"delete":"%s","deletes":[{"q":{},"limit":0}]}`, objectName)
} else {
sql = fmt.Sprintf("DELETE FROM %s", quoteQualifiedIdentByType(runConfig.Type, objectName))
}
if _, err := dbInst.Exec(sql); err != nil {
logger.Warnf("TruncateTables 第 %d/%d 张表失败:%s table=%s err=%v已成功清空 %d 张)", i+1, len(objects), formatConnSummary(runConfig), objectName, err, len(executedSQLs))
errMsg := fmt.Sprintf("清空 %s 失败: %v", objectName, err)
if len(executedSQLs) > 0 {
errMsg += fmt.Sprintf("(注意:前 %d 张表已清空且无法恢复)", len(executedSQLs))
}
return connection.QueryResult{
Success: false,
Message: errMsg,
Data: map[string]interface{}{
"executedSQLs": executedSQLs,
"count": len(executedSQLs),
},
}
}
executedSQLs = append(executedSQLs, sql)
}
logger.Warnf("TruncateTables 完成:%s db=%s 共清空 %d 张表", formatConnSummary(runConfig), dbName, len(executedSQLs))
return connection.QueryResult{
Success: true,
Message: "清空成功",
Data: map[string]interface{}{
"executedSQLs": executedSQLs,
"count": len(executedSQLs),
},
}
}
func quoteIdentByType(dbType string, ident string) string {
@@ -1471,7 +1756,7 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
if err != nil || filename == "" {
logger.Infof("ExportData 已取消或未选择文件err=%v", err)
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
logger.Infof("ExportData 选定文件:%s", filename)
@@ -1482,11 +1767,11 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
defer f.Close()
if err := writeRowsToFile(f, data, columns, format); err != nil {
logger.Warnf("ExportData 写入失败file=%s err=%v", filename, err)
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
}
logger.Infof("ExportData 完成file=%s rows=%d", filename, len(data))
return connection.QueryResult{Success: true, Message: "Export successful"}
return connection.QueryResult{Success: true, Message: "导出完成"}
}
// ExportQuery exports by executing the provided SELECT query on backend side.
@@ -1494,7 +1779,7 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, query string, defaultName string, format string) connection.QueryResult {
query = strings.TrimSpace(query)
if query == "" {
return connection.QueryResult{Success: false, Message: "query required"}
return connection.QueryResult{Success: false, Message: "查询语句不能为空"}
}
if defaultName == "" {
@@ -1507,7 +1792,7 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
})
if err != nil || filename == "" {
logger.Infof("ExportQuery 已取消或未选择文件err=%v", err)
return connection.QueryResult{Success: false, Message: "Cancelled"}
return connection.QueryResult{Success: false, Message: "已取消"}
}
logger.Infof("ExportQuery 开始type=%s db=%s format=%s file=%s sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), strings.ToLower(strings.TrimSpace(format)), filename, sqlSnippet(query))
@@ -1520,7 +1805,7 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
query = sanitizeSQLForPgLike(runConfig.Type, query)
lowerQuery := strings.ToLower(strings.TrimSpace(query))
if !(strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "with")) {
return connection.QueryResult{Success: false, Message: "Only SELECT/WITH queries are supported"}
return connection.QueryResult{Success: false, Message: "仅支持 SELECT/WITH 查询导出"}
}
data, columns, err := queryDataForExport(dbInst, runConfig, query)
@@ -1537,11 +1822,11 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
if err := writeRowsToFile(f, data, columns, format); err != nil {
logger.Warnf("ExportQuery 写入失败file=%s err=%v", filename, err)
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
}
logger.Infof("ExportQuery 完成file=%s rows=%d cols=%d", filename, len(data), len(columns))
return connection.QueryResult{Success: true, Message: "Export successful"}
return connection.QueryResult{Success: true, Message: "导出完成"}
}
func queryDataForExport(dbInst db.Database, config connection.ConnectionConfig, query string) ([]map[string]interface{}, []string, error) {

View File

@@ -453,6 +453,23 @@ func (a *App) RedisRenameKey(config connection.ConnectionConfig, oldKey, newKey
return connection.QueryResult{Success: true, Message: "重命名成功"}
}
// RedisKeyExists checks whether a key already exists
func (a *App) RedisKeyExists(config connection.ConnectionConfig, key string) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
exists, err := client.KeyExists(key)
if err != nil {
logger.Error(err, "RedisKeyExists 检查失败key=%s", key)
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: map[string]bool{"exists": exists}}
}
// RedisDeleteHashField deletes fields from a hash
func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key string, fields []string) connection.QueryResult {
config.Type = "redis"

View File

@@ -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

View File

@@ -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)
}
}
}

175
internal/app/sql_split.go Normal file
View File

@@ -0,0 +1,175 @@
package app
import "strings"
// splitSQLStatements 按分号拆分 SQL 文本为独立语句。
// 正确处理单引号/双引号/反引号字符串、行注释(-- / #)、块注释(/* */)和
// PostgreSQL/Kingbase 的 $$...$$ dollar-quoting避免在这些上下文中错误拆分。
// 同时支持 SQL 标准的转义单引号(两个连续单引号 '' 表示字面量引号)。
func splitSQLStatements(sql string) []string {
text := strings.ReplaceAll(sql, "\r\n", "\n")
var statements []string
var cur strings.Builder
inSingle := false
inDouble := false
inBacktick := false
escaped := false
inLineComment := false
inBlockComment := false
var dollarTag string // postgres/kingbase: $$...$$ or $tag$...$tag$
push := func() {
s := strings.TrimSpace(cur.String())
if s != "" {
statements = append(statements, s)
}
cur.Reset()
}
for i := 0; i < len(text); i++ {
ch := text[i]
next := byte(0)
if i+1 < len(text) {
next = text[i+1]
}
// 行注释
if inLineComment {
if ch == '\n' {
inLineComment = false
}
cur.WriteByte(ch)
continue
}
// 块注释
if inBlockComment {
cur.WriteByte(ch)
if ch == '*' && next == '/' {
cur.WriteByte('/')
i++
inBlockComment = false
}
continue
}
// Dollar-quoting
if dollarTag != "" {
if strings.HasPrefix(text[i:], dollarTag) {
cur.WriteString(dollarTag)
i += len(dollarTag) - 1
dollarTag = ""
} else {
cur.WriteByte(ch)
}
continue
}
// 转义字符反斜杠转义MySQL 风格)
if escaped {
escaped = false
cur.WriteByte(ch)
continue
}
if (inSingle || inDouble) && ch == '\\' {
escaped = true
cur.WriteByte(ch)
continue
}
// 字符串开闭
if !inDouble && !inBacktick && ch == '\'' {
if inSingle && next == '\'' {
// SQL 标准转义:两个连续单引号 '' 表示字面量引号,保持在引号内
cur.WriteByte(ch)
cur.WriteByte(next)
i++
continue
}
inSingle = !inSingle
cur.WriteByte(ch)
continue
}
if !inSingle && !inBacktick && ch == '"' {
inDouble = !inDouble
cur.WriteByte(ch)
continue
}
if !inSingle && !inDouble && ch == '`' {
inBacktick = !inBacktick
cur.WriteByte(ch)
continue
}
// 在引号/反引号内部不做任何判断
if inSingle || inDouble || inBacktick {
cur.WriteByte(ch)
continue
}
// 行注释开始
if ch == '-' && next == '-' {
inLineComment = true
cur.WriteByte(ch)
continue
}
if ch == '#' {
inLineComment = true
cur.WriteByte(ch)
continue
}
// 块注释开始
if ch == '/' && next == '*' {
inBlockComment = true
cur.WriteString("/*")
i++
continue
}
// Dollar-quoting 开始
if ch == '$' {
if tag := parseSQLDollarTag(text[i:]); tag != "" {
dollarTag = tag
cur.WriteString(tag)
i += len(tag) - 1
continue
}
}
// 分号分隔(支持全角分号""
if ch == ';' {
push()
continue
}
// 全角分号 UTF-8 序列: 0xEF 0xBC 0x9B
if ch == 0xEF && i+2 < len(text) && text[i+1] == 0xBC && text[i+2] == 0x9B {
push()
i += 2
continue
}
cur.WriteByte(ch)
}
push()
return statements
}
// parseSQLDollarTag 解析 PostgreSQL/Kingbase 的 dollar-quoting 标签。
func parseSQLDollarTag(s string) string {
if len(s) < 2 || s[0] != '$' {
return ""
}
for i := 1; i < len(s); i++ {
c := s[i]
if c == '$' {
return s[:i+1]
}
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
return ""
}
}
return ""
}

View File

@@ -0,0 +1,209 @@
package app
import (
"bufio"
"io"
"strings"
)
// sqlStreamSplitter 是一个流式 SQL 语句拆分器,适用于处理大文件。
// 调用方通过 Feed(chunk) 逐块喂入数据,通过 Flush() 获取最后一条残余语句。
// 内部维护与 splitSQLStatements 完全一致的状态机逻辑。
type sqlStreamSplitter struct {
cur strings.Builder
inSingle bool
inDouble bool
inBacktick bool
escaped bool
inLineComment bool
inBlockComment bool
dollarTag string
}
// Feed 将一个 chunk 喂入拆分器,返回在此 chunk 中完成的 SQL 语句列表。
func (s *sqlStreamSplitter) Feed(chunk []byte) []string {
var statements []string
text := string(chunk)
for i := 0; i < len(text); i++ {
ch := text[i]
next := byte(0)
if i+1 < len(text) {
next = text[i+1]
}
// 行注释
if s.inLineComment {
if ch == '\n' {
s.inLineComment = false
}
s.cur.WriteByte(ch)
continue
}
// 块注释
if s.inBlockComment {
s.cur.WriteByte(ch)
if ch == '*' && next == '/' {
s.cur.WriteByte('/')
i++
s.inBlockComment = false
}
continue
}
// Dollar-quoting
if s.dollarTag != "" {
if strings.HasPrefix(text[i:], s.dollarTag) {
s.cur.WriteString(s.dollarTag)
i += len(s.dollarTag) - 1
s.dollarTag = ""
} else {
s.cur.WriteByte(ch)
}
continue
}
// 转义字符
if s.escaped {
s.escaped = false
s.cur.WriteByte(ch)
continue
}
if (s.inSingle || s.inDouble) && ch == '\\' {
s.escaped = true
s.cur.WriteByte(ch)
continue
}
// 字符串开闭
if !s.inDouble && !s.inBacktick && ch == '\'' {
if s.inSingle && next == '\'' {
// SQL 标准转义:两个连续单引号
s.cur.WriteByte(ch)
s.cur.WriteByte(next)
i++
continue
}
s.inSingle = !s.inSingle
s.cur.WriteByte(ch)
continue
}
if !s.inSingle && !s.inBacktick && ch == '"' {
s.inDouble = !s.inDouble
s.cur.WriteByte(ch)
continue
}
if !s.inSingle && !s.inDouble && ch == '`' {
s.inBacktick = !s.inBacktick
s.cur.WriteByte(ch)
continue
}
// 在引号/反引号内部不做任何判断
if s.inSingle || s.inDouble || s.inBacktick {
s.cur.WriteByte(ch)
continue
}
// 行注释开始
if ch == '-' && next == '-' {
s.inLineComment = true
s.cur.WriteByte(ch)
continue
}
if ch == '#' {
s.inLineComment = true
s.cur.WriteByte(ch)
continue
}
// 块注释开始
if ch == '/' && next == '*' {
s.inBlockComment = true
s.cur.WriteString("/*")
i++
continue
}
// Dollar-quoting 开始
if ch == '$' {
if tag := parseSQLDollarTag(text[i:]); tag != "" {
s.dollarTag = tag
s.cur.WriteString(tag)
i += len(tag) - 1
continue
}
}
// 分号分隔
if ch == ';' {
stmt := strings.TrimSpace(s.cur.String())
if stmt != "" {
statements = append(statements, stmt)
}
s.cur.Reset()
continue
}
// 全角分号
if ch == 0xEF && i+2 < len(text) && text[i+1] == 0xBC && text[i+2] == 0x9B {
stmt := strings.TrimSpace(s.cur.String())
if stmt != "" {
statements = append(statements, stmt)
}
s.cur.Reset()
i += 2
continue
}
s.cur.WriteByte(ch)
}
return statements
}
// Flush 返回缓冲区中剩余的不完整语句(文件结束时调用)。
func (s *sqlStreamSplitter) Flush() string {
stmt := strings.TrimSpace(s.cur.String())
s.cur.Reset()
return stmt
}
// streamSQLFile 从 reader 中流式读取 SQL 并逐条回调。
// onStatement 返回 error 时停止读取并返回该 error。
// 返回总处理语句数和可能的错误。
func streamSQLFile(reader io.Reader, onStatement func(index int, stmt string) error) (int, error) {
splitter := &sqlStreamSplitter{}
scanner := bufio.NewScanner(reader)
// 设置最大 token 为 4MB处理超长单行
const maxLineSize = 4 * 1024 * 1024
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
count := 0
for scanner.Scan() {
line := scanner.Bytes()
// 保持换行符,因为行注释依赖 \n 来结束
lineWithNewline := append(line, '\n')
stmts := splitter.Feed(lineWithNewline)
for _, stmt := range stmts {
if err := onStatement(count, stmt); err != nil {
return count, err
}
count++
}
}
if err := scanner.Err(); err != nil {
return count, err
}
// 处理文件末尾不以分号结尾的最后一条语句
if last := splitter.Flush(); last != "" {
if err := onStatement(count, last); err != nil {
return count, err
}
count++
}
return count, nil
}

View File

@@ -0,0 +1,113 @@
package app
import (
"reflect"
"testing"
)
func TestSplitSQLStatements_BasicSplit(t *testing.T) {
input := "SELECT 1; SELECT 2; SELECT 3"
got := splitSQLStatements(input)
want := []string{"SELECT 1", "SELECT 2", "SELECT 3"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_QuotedSemicolon(t *testing.T) {
input := `SELECT 'hello;world'; SELECT 2`
got := splitSQLStatements(input)
want := []string{`SELECT 'hello;world'`, "SELECT 2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_LineComment(t *testing.T) {
input := "SELECT 1; -- this is a comment;\nSELECT 2"
got := splitSQLStatements(input)
want := []string{"SELECT 1", "-- this is a comment;\nSELECT 2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_BlockComment(t *testing.T) {
input := "SELECT /* ; */ 1; SELECT 2"
got := splitSQLStatements(input)
want := []string{"SELECT /* ; */ 1", "SELECT 2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_EmptyInput(t *testing.T) {
got := splitSQLStatements("")
if len(got) != 0 {
t.Errorf("splitSQLStatements(\"\") = %v, want empty slice", got)
}
}
func TestSplitSQLStatements_SingleStatement(t *testing.T) {
input := "SELECT * FROM users WHERE id = 1"
got := splitSQLStatements(input)
want := []string{"SELECT * FROM users WHERE id = 1"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_DollarQuoting(t *testing.T) {
input := "SELECT $tag$hello;world$tag$; SELECT 2"
got := splitSQLStatements(input)
want := []string{"SELECT $tag$hello;world$tag$", "SELECT 2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_FullWidthSemicolon(t *testing.T) {
input := "SELECT 1SELECT 2"
got := splitSQLStatements(input)
want := []string{"SELECT 1", "SELECT 2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_Backtick(t *testing.T) {
input := "SELECT `col;name` FROM t; SELECT 2"
got := splitSQLStatements(input)
want := []string{"SELECT `col;name` FROM t", "SELECT 2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_TrailingSemicolon(t *testing.T) {
input := "SELECT 1; SELECT 2;"
got := splitSQLStatements(input)
want := []string{"SELECT 1", "SELECT 2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_SQLEscapedQuote(t *testing.T) {
input := "SELECT 'it''s a test'; SELECT 2"
got := splitSQLStatements(input)
want := []string{"SELECT 'it''s a test'", "SELECT 2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}
func TestSplitSQLStatements_SQLEscapedQuoteMultiple(t *testing.T) {
input := "INSERT INTO t VALUES ('O''Brien', 'it''s OK'); SELECT 1"
got := splitSQLStatements(input)
want := []string{"INSERT INTO t VALUES ('O''Brien', 'it''s OK')", "SELECT 1"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSQLStatements(%q) = %v, want %v", input, got, want)
}
}

View File

@@ -1,6 +1,6 @@
package connection
// SSHConfig holds SSH connection details
// SSHConfig 存储 SSH 隧道连接配置。
type SSHConfig struct {
Host string `json:"host"`
Port int `json:"port"`
@@ -9,7 +9,7 @@ type SSHConfig struct {
KeyPath string `json:"keyPath"`
}
// ProxyConfig holds proxy connection details
// ProxyConfig 存储代理连接配置。
type ProxyConfig struct {
Type string `json:"type"` // socks5 | http
Host string `json:"host"`
@@ -18,7 +18,7 @@ type ProxyConfig struct {
Password string `json:"password,omitempty"`
}
// HTTPTunnelConfig holds independent HTTP CONNECT tunnel details
// HTTPTunnelConfig 存储 HTTP CONNECT 隧道配置。
type HTTPTunnelConfig struct {
Host string `json:"host"`
Port int `json:"port"`
@@ -26,7 +26,7 @@ type HTTPTunnelConfig struct {
Password string `json:"password,omitempty"`
}
// ConnectionConfig holds database connection details including SSH
// ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。
type ConnectionConfig struct {
Type string `json:"type"`
Host string `json:"host"`
@@ -63,7 +63,13 @@ type ConnectionConfig struct {
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
}
// QueryResult is the standard response format for Wails methods
// ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。
type ResultSetData struct {
Rows []map[string]interface{} `json:"rows"`
Columns []string `json:"columns"`
}
// QueryResult 是 Wails 绑定方法的统一响应格式,前端通过此结构体接收后端结果。
type QueryResult struct {
Success bool `json:"success"`
Message string `json:"message"`
@@ -72,7 +78,7 @@ type QueryResult struct {
QueryID string `json:"queryId,omitempty"` // Unique ID for query cancellation
}
// ColumnDefinition represents a table column
// ColumnDefinition 描述表的一个列定义。
type ColumnDefinition struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -83,7 +89,7 @@ type ColumnDefinition struct {
Comment string `json:"comment"`
}
// IndexDefinition represents a table index
// IndexDefinition 描述表的一个索引定义。
type IndexDefinition struct {
Name string `json:"name"`
ColumnName string `json:"columnName"`
@@ -93,7 +99,7 @@ type IndexDefinition struct {
SubPart int `json:"subPart,omitempty"`
}
// ForeignKeyDefinition represents a foreign key
// ForeignKeyDefinition 描述表的一个外键定义。
type ForeignKeyDefinition struct {
Name string `json:"name"`
ColumnName string `json:"columnName"`
@@ -102,7 +108,7 @@ type ForeignKeyDefinition struct {
ConstraintName string `json:"constraintName"`
}
// TriggerDefinition represents a trigger
// TriggerDefinition 描述表的一个触发器定义。
type TriggerDefinition struct {
Name string `json:"name"`
Timing string `json:"timing"` // BEFORE/AFTER
@@ -110,26 +116,27 @@ type TriggerDefinition struct {
Statement string `json:"statement"`
}
// ColumnDefinitionWithTable represents a column with its table name (for search/autocomplete)
// ColumnDefinitionWithTable 带有表名标识的列定义,用于跨表搜索和 SQL 自动补全。
type ColumnDefinitionWithTable struct {
TableName string `json:"tableName"`
Name string `json:"name"`
Type string `json:"type"`
}
// UpdateRow represents a row update with keys (WHERE) and values (SET)
// UpdateRow 表示一行更新操作Keys WHERE 条件Values SET 值。
type UpdateRow struct {
Keys map[string]interface{} `json:"keys"`
Values map[string]interface{} `json:"values"`
}
// ChangeSet represents a batch of changes
// ChangeSet 表示一组批量变更,包含新增、修改和删除操作。
type ChangeSet struct {
Inserts []map[string]interface{} `json:"inserts"`
Updates []UpdateRow `json:"updates"`
Deletes []map[string]interface{} `json:"deletes"`
}
// MongoMemberInfo 描述 MongoDB 副本集成员的信息。
type MongoMemberInfo struct {
Host string `json:"host"`
Role string `json:"role"`

View File

@@ -271,7 +271,7 @@ func (c *ClickHouseDB) Close() error {
func (c *ClickHouseDB) Ping() error {
if c.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := c.pingTimeout
if timeout <= 0 {
@@ -284,7 +284,7 @@ func (c *ClickHouseDB) Ping() error {
func (c *ClickHouseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if c.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := c.conn.QueryContext(ctx, query)
if err != nil {
@@ -296,7 +296,7 @@ func (c *ClickHouseDB) QueryContext(ctx context.Context, query string) ([]map[st
func (c *ClickHouseDB) Query(query string) ([]map[string]interface{}, []string, error) {
if c.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := c.conn.Query(query)
if err != nil {
@@ -308,7 +308,7 @@ func (c *ClickHouseDB) Query(query string) ([]map[string]interface{}, []string,
func (c *ClickHouseDB) ExecContext(ctx context.Context, query string) (int64, error) {
if c.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := c.conn.ExecContext(ctx, query)
if err != nil {
@@ -319,7 +319,7 @@ func (c *ClickHouseDB) ExecContext(ctx context.Context, query string) (int64, er
func (c *ClickHouseDB) Exec(query string) (int64, error) {
if c.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := c.conn.Exec(query)
if err != nil {
@@ -404,7 +404,7 @@ func (c *ClickHouseDB) GetCreateStatement(dbName, tableName string) (string, err
return "", err
}
if len(data) == 0 {
return "", fmt.Errorf("create statement not found")
return "", fmt.Errorf("未找到建表语句")
}
row := data[0]
if val, ok := getClickHouseValueFromRow(row, "statement", "create_statement", "sql", "query"); ok {
@@ -427,7 +427,7 @@ func (c *ClickHouseDB) GetCreateStatement(dbName, tableName string) (string, err
if longest != "" {
return longest, nil
}
return "", fmt.Errorf("create statement not found")
return "", fmt.Errorf("未找到建表语句")
}
func (c *ClickHouseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -582,7 +582,7 @@ func (c *ClickHouseDB) GetTriggers(dbName, tableName string) ([]connection.Trigg
func (c *ClickHouseDB) resolveDatabaseAndTable(dbName, tableName string) (string, string, error) {
rawTable := strings.TrimSpace(tableName)
if rawTable == "" {
return "", "", fmt.Errorf("table name required")
return "", "", fmt.Errorf("表名不能为空")
}
resolvedDB := strings.TrimSpace(dbName)
@@ -603,7 +603,7 @@ func (c *ClickHouseDB) resolveDatabaseAndTable(dbName, tableName string) (string
resolvedDB = defaultClickHouseDatabase
}
if resolvedTable == "" {
return "", "", fmt.Errorf("table name required")
return "", "", fmt.Errorf("表名不能为空")
}
return resolvedDB, resolvedTable, nil
}
@@ -682,7 +682,7 @@ func isClickHouseTruthy(value interface{}) bool {
func (c *ClickHouseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if c.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
database, table, err := c.resolveDatabaseAndTable(c.database, tableName)
@@ -723,7 +723,7 @@ func (c *ClickHouseDB) ApplyChanges(tableName string, changes connection.ChangeS
continue
}
if _, err := c.conn.Exec(query); err != nil {
return fmt.Errorf("insert error: %v; sql=%s", err, query)
return fmt.Errorf("插入失败:%v; sql=%s", err, query)
}
}
return nil

View File

@@ -47,7 +47,7 @@ func (c *CustomDB) Close() error {
func (c *CustomDB) Ping() error {
if c.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := c.pingTimeout
if timeout <= 0 {
@@ -60,7 +60,7 @@ func (c *CustomDB) Ping() error {
func (c *CustomDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if c.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := c.conn.QueryContext(ctx, query)
@@ -74,7 +74,7 @@ func (c *CustomDB) QueryContext(ctx context.Context, query string) ([]map[string
func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, error) {
if c.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := c.conn.Query(query)
@@ -87,7 +87,7 @@ func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, erro
func (c *CustomDB) ExecContext(ctx context.Context, query string) (int64, error) {
if c.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := c.conn.ExecContext(ctx, query)
if err != nil {
@@ -98,7 +98,7 @@ func (c *CustomDB) ExecContext(ctx context.Context, query string) (int64, error)
func (c *CustomDB) Exec(query string) (int64, error) {
if c.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := c.conn.Exec(query)
if err != nil {
@@ -249,7 +249,7 @@ func (c *CustomDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if c.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := c.conn.Begin()
@@ -321,7 +321,7 @@ func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -349,12 +349,12 @@ func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -378,7 +378,7 @@ func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet)
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}

View File

@@ -143,7 +143,7 @@ func (d *DamengDB) Close() error {
func (d *DamengDB) Ping() error {
if d.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := d.pingTimeout
if timeout <= 0 {
@@ -156,7 +156,7 @@ func (d *DamengDB) Ping() error {
func (d *DamengDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if d.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := d.conn.QueryContext(ctx, query)
@@ -170,7 +170,7 @@ func (d *DamengDB) QueryContext(ctx context.Context, query string) ([]map[string
func (d *DamengDB) Query(query string) ([]map[string]interface{}, []string, error) {
if d.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := d.conn.Query(query)
@@ -183,7 +183,7 @@ func (d *DamengDB) Query(query string) ([]map[string]interface{}, []string, erro
func (d *DamengDB) ExecContext(ctx context.Context, query string) (int64, error) {
if d.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := d.conn.ExecContext(ctx, query)
if err != nil {
@@ -194,7 +194,7 @@ func (d *DamengDB) ExecContext(ctx context.Context, query string) (int64, error)
func (d *DamengDB) Exec(query string) (int64, error) {
if d.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := d.conn.Exec(query)
if err != nil {
@@ -260,7 +260,7 @@ func (d *DamengDB) GetCreateStatement(dbName, tableName string) (string, error)
return fmt.Sprintf("%v", val), nil
}
}
return "", fmt.Errorf("create statement not found")
return "", fmt.Errorf("未找到建表语句")
}
func (d *DamengDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -390,7 +390,7 @@ func (d *DamengDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if d.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := d.conn.Begin()
@@ -438,7 +438,7 @@ func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -466,12 +466,12 @@ func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -495,7 +495,7 @@ func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet)
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}

View File

@@ -2,27 +2,58 @@ package db
import (
"GoNavi-Wails/internal/connection"
"context"
"fmt"
"strings"
)
// Database 定义了统一的数据源访问接口。
// 所有数据库驱动MySQL、PostgreSQL、Oracle 等)均需实现此接口。
// 方法调用方可通过 NewDatabase 工厂函数获取对应驱动的实例。
type Database interface {
// Connect 根据连接配置建立数据库连接。
Connect(config connection.ConnectionConfig) error
// Close 关闭数据库连接并释放底层资源。
Close() error
// Ping 测试连接是否仍然可用。
Ping() error
// Query 执行查询语句,返回结果行(列名→值映射)和列名列表。
Query(query string) ([]map[string]interface{}, []string, error)
// Exec 执行非查询语句INSERT/UPDATE/DELETE 等),返回受影响行数。
Exec(query string) (int64, error)
// GetDatabases 返回当前连接可访问的数据库列表。
GetDatabases() ([]string, error)
// GetTables 返回指定数据库下的表列表。
GetTables(dbName string) ([]string, error)
// GetCreateStatement 返回指定表的建表 DDL 语句。
GetCreateStatement(dbName, tableName string) (string, error)
// GetColumns 返回指定表的列定义列表。
GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error)
// GetAllColumns 返回指定数据库下所有表的列定义(含表名标识)。
GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error)
// GetIndexes 返回指定表的索引定义列表。
GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error)
// GetForeignKeys 返回指定表的外键定义列表。
GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error)
// GetTriggers 返回指定表的触发器定义列表。
GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error)
}
// MultiResultQuerier 是可选接口,支持多结果集的驱动实现此接口。
// 执行可能包含多条 SQL 语句的查询,返回所有结果集。
type MultiResultQuerier interface {
QueryMulti(query string) ([]connection.ResultSetData, error)
}
// MultiResultQuerierContext 是带 context 的多结果集查询接口。
type MultiResultQuerierContext interface {
QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error)
}
// BatchApplier 定义了批量变更提交接口。
// 支持批量编辑的驱动实现此接口,用于一次性提交前端 DataGrid 中的增删改操作。
type BatchApplier interface {
// ApplyChanges 将一组变更(新增、修改、删除)批量提交到指定表。
ApplyChanges(tableName string, changes connection.ChangeSet) error
}
@@ -72,7 +103,9 @@ func normalizeDatabaseType(dbType string) string {
}
}
// Factory
// NewDatabase 根据数据库类型创建对应的 Database 实例。
// dbType 为数据库类型标识(如 "mysql"、"postgres"、"oracle" 等),大小写不敏感。
// 如果指定类型未注册,返回错误。
func NewDatabase(dbType string) (Database, error) {
normalized := normalizeDatabaseType(dbType)
if normalized == "" {
@@ -80,7 +113,7 @@ func NewDatabase(dbType string) (Database, error) {
}
factory, ok := databaseFactories[normalized]
if !ok {
return nil, fmt.Errorf("unsupported database type: %s", dbType)
return nil, fmt.Errorf("不支持的数据库类型:%s", dbType)
}
return factory(), nil
}

View File

@@ -151,7 +151,7 @@ func (d *DirosDB) getDSN(config connection.ConnectionConfig) (string, error) {
tlsMode := resolveMySQLTLSMode(config)
return fmt.Sprintf(
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s&multiStatements=true",
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode),
), nil
}

View File

@@ -8,6 +8,7 @@ import (
"sync"
)
// coreBuiltinDrivers 是始终内置可用的核心驱动,无需额外安装即可使用。
var coreBuiltinDrivers = map[string]struct{}{
"mysql": {},
"redis": {},
@@ -91,6 +92,8 @@ func driverDisplayName(driverType string) string {
}
}
// IsOptionalGoDriver 返回指定驱动类型是否为可选的纯 Go 驱动。
// 可选驱动需要用户在驱动管理界面点击“安装启用”后才能使用。
func IsOptionalGoDriver(driverType string) bool {
_, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)]
return ok
@@ -100,6 +103,7 @@ func IsOptionalGoDriverBuildIncluded(driverType string) bool {
return optionalGoDriverBuildIncluded(normalizeRuntimeDriverType(driverType))
}
// IsBuiltinDriver 返回指定驱动类型是否为核心内置驱动(始终可用,无需安装)。
func IsBuiltinDriver(driverType string) bool {
_, ok := coreBuiltinDrivers[normalizeRuntimeDriverType(driverType)]
return ok
@@ -146,6 +150,8 @@ func currentExternalDriverDownloadDirectory() string {
return defaultExternalDriverDownloadDirectory()
}
// SetExternalDriverDownloadDirectory 设置可选驱动的下载存储目录。
// 如果路径解析失败,会回退到默认目录(~/.gonavi/drivers
func SetExternalDriverDownloadDirectory(downloadDir string) {
root, err := resolveExternalDriverRoot(downloadDir)
if err != nil {

View File

@@ -55,7 +55,7 @@ func (d *DuckDB) Close() error {
func (d *DuckDB) Ping() error {
if d.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := d.pingTimeout
if timeout <= 0 {
@@ -68,7 +68,7 @@ func (d *DuckDB) Ping() error {
func (d *DuckDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if d.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := d.conn.QueryContext(ctx, query)
if err != nil {
@@ -80,7 +80,7 @@ func (d *DuckDB) QueryContext(ctx context.Context, query string) ([]map[string]i
func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error) {
if d.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := d.conn.Query(query)
if err != nil {
@@ -92,7 +92,7 @@ func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error)
func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) {
if d.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := d.conn.ExecContext(ctx, query)
if err != nil {
@@ -103,7 +103,7 @@ func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) {
func (d *DuckDB) Exec(query string) (int64, error) {
if d.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := d.conn.Exec(query)
if err != nil {
@@ -174,7 +174,7 @@ ORDER BY table_schema, table_name`
func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) {
schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName)
if pureTable == "" {
return "", fmt.Errorf("table name required")
return "", fmt.Errorf("表名不能为空")
}
escapedTable := escapeDuckDBLiteral(pureTable)
@@ -204,13 +204,13 @@ func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) {
}
}
return "", fmt.Errorf("create statement not found")
return "", fmt.Errorf("未找到建表语句")
}
func (d *DuckDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName)
if pureTable == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
query := fmt.Sprintf(`
@@ -303,7 +303,7 @@ func (d *DuckDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefi
func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if d.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := d.conn.Begin()
@@ -346,7 +346,7 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -367,12 +367,12 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
args = append(args, v)
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -392,7 +392,7 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}

View File

@@ -125,7 +125,7 @@ func (h *HighGoDB) Close() error {
func (h *HighGoDB) Ping() error {
if h.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := h.pingTimeout
if timeout <= 0 {
@@ -138,7 +138,7 @@ func (h *HighGoDB) Ping() error {
func (h *HighGoDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if h.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := h.conn.QueryContext(ctx, query)
@@ -152,7 +152,7 @@ func (h *HighGoDB) QueryContext(ctx context.Context, query string) ([]map[string
func (h *HighGoDB) Query(query string) ([]map[string]interface{}, []string, error) {
if h.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := h.conn.Query(query)
@@ -165,7 +165,7 @@ func (h *HighGoDB) Query(query string) ([]map[string]interface{}, []string, erro
func (h *HighGoDB) ExecContext(ctx context.Context, query string) (int64, error) {
if h.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := h.conn.ExecContext(ctx, query)
if err != nil {
@@ -176,7 +176,7 @@ func (h *HighGoDB) ExecContext(ctx context.Context, query string) (int64, error)
func (h *HighGoDB) Exec(query string) (int64, error) {
if h.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := h.conn.Exec(query)
if err != nil {
@@ -232,7 +232,7 @@ func (h *HighGoDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -302,7 +302,7 @@ func (h *HighGoDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefin
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -407,7 +407,7 @@ func (h *HighGoDB) GetForeignKeys(dbName, tableName string) ([]connection.Foreig
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -467,7 +467,7 @@ func (h *HighGoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -531,7 +531,7 @@ ORDER BY table_schema, table_name, ordinal_position`
func (h *HighGoDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if h.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := h.conn.Begin()
@@ -579,7 +579,7 @@ func (h *HighGoDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -607,12 +607,12 @@ func (h *HighGoDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -636,7 +636,7 @@ func (h *HighGoDB) ApplyChanges(tableName string, changes connection.ChangeSet)
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}

View File

@@ -162,3 +162,45 @@ func findKingbaseQualifiedSeparator(raw string) int {
return -1
}
// buildKingbaseSearchPathCommon 统一构建 Kingbase search_path。
// 返回 search_path SQL 片段和规范化后的 schema 列表(用于调试/扩展)。
func buildKingbaseSearchPathCommon(rawSchemas []string) (string, []string) {
if len(rawSchemas) == 0 {
return "", nil
}
seen := make(map[string]struct{}, len(rawSchemas)+1)
quotedParts := make([]string, 0, len(rawSchemas)+1)
normalizedSchemas := make([]string, 0, len(rawSchemas)+1)
appendSchema := func(raw string) {
cleaned := normalizeKingbaseIdentCommon(raw)
if cleaned == "" {
return
}
if strings.EqualFold(cleaned, "public") {
cleaned = "public"
}
key := strings.ToLower(cleaned)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
normalizedSchemas = append(normalizedSchemas, cleaned)
escaped := strings.ReplaceAll(cleaned, `"`, `""`)
quotedParts = append(quotedParts, `"`+escaped+`"`)
}
for _, raw := range rawSchemas {
appendSchema(raw)
}
if _, ok := seen["public"]; !ok {
appendSchema("public")
}
if len(quotedParts) == 0 {
return "", normalizedSchemas
}
return strings.Join(quotedParts, ", "), normalizedSchemas
}

View File

@@ -50,3 +50,43 @@ func TestSplitKingbaseQualifiedNameCommon(t *testing.T) {
})
}
}
func TestBuildKingbaseSearchPathCommon(t *testing.T) {
tests := []struct {
name string
in []string
want string
wantLen int
}{
{
name: "normal schemas",
in: []string{"ldf_server", "public"},
want: `"ldf_server", "public"`,
wantLen: 2,
},
{
name: "quoted and escaped schemas should not be double quoted",
in: []string{`"ldf_server"`, `""bcs_barcode""`, `\"public\"`},
want: `"ldf_server", "bcs_barcode", "public"`,
wantLen: 3,
},
{
name: "dedupe ignoring case and keep public fallback",
in: []string{"LDF_SERVER", "ldf_server", "PUBLIC"},
want: `"LDF_SERVER", "public"`,
wantLen: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, parts := buildKingbaseSearchPathCommon(tt.in)
if got != tt.want {
t.Fatalf("buildKingbaseSearchPathCommon(%v)=%q,want=%q", tt.in, got, tt.want)
}
if len(parts) != tt.wantLen {
t.Fatalf("buildKingbaseSearchPathCommon(%v) parts=%v, len=%d, wantLen=%d", tt.in, parts, len(parts), tt.wantLen)
}
})
}
}

View File

@@ -198,7 +198,7 @@ func (k *KingbaseDB) getSearchPathStr() string {
}
defer rows.Close()
var schemas []string
var rawSchemas []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
@@ -206,17 +206,12 @@ func (k *KingbaseDB) getSearchPathStr() string {
}
name = strings.TrimSpace(name)
if name != "" {
// 使用 SQL 标准的双引号包裹标识符
escaped := strings.ReplaceAll(name, `"`, `""`)
schemas = append(schemas, `"`+escaped+`"`)
rawSchemas = append(rawSchemas, name)
}
}
if len(schemas) == 0 {
return ""
}
return strings.Join(schemas, ", ")
searchPath, _ := buildKingbaseSearchPathCommon(rawSchemas)
return searchPath
}
func (k *KingbaseDB) Close() error {
@@ -237,7 +232,7 @@ func (k *KingbaseDB) Close() error {
func (k *KingbaseDB) Ping() error {
if k.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := k.pingTimeout
if timeout <= 0 {
@@ -250,7 +245,7 @@ func (k *KingbaseDB) Ping() error {
func (k *KingbaseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if k.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := k.conn.QueryContext(ctx, query)
@@ -264,7 +259,7 @@ func (k *KingbaseDB) QueryContext(ctx context.Context, query string) ([]map[stri
func (k *KingbaseDB) Query(query string) ([]map[string]interface{}, []string, error) {
if k.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := k.conn.Query(query)
@@ -277,7 +272,7 @@ func (k *KingbaseDB) Query(query string) ([]map[string]interface{}, []string, er
func (k *KingbaseDB) ExecContext(ctx context.Context, query string) (int64, error) {
if k.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := k.conn.ExecContext(ctx, query)
if err != nil {
@@ -288,7 +283,7 @@ func (k *KingbaseDB) ExecContext(ctx context.Context, query string) (int64, erro
func (k *KingbaseDB) Exec(query string) (int64, error) {
if k.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := k.conn.Exec(query)
if err != nil {
@@ -372,7 +367,7 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
}
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
// 转义函数:处理单引号,移除双引号
@@ -445,7 +440,7 @@ ORDER BY a.attnum`, esc(schema), esc(table))
func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection.ColumnDefinition, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
// 转义函数
@@ -529,7 +524,7 @@ func (k *KingbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDef
}
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
// 转义函数:处理单引号,移除双引号
@@ -627,7 +622,7 @@ func (k *KingbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
}
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
// 转义函数:处理单引号,移除双引号
@@ -709,7 +704,7 @@ func (k *KingbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
}
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
// 转义函数:处理单引号,移除双引号
@@ -752,7 +747,7 @@ func (k *KingbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if k.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := k.conn.Begin()
@@ -763,7 +758,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
schema, table := splitKingbaseQualifiedTable(tableName)
if table == "" {
return fmt.Errorf("table name required")
return fmt.Errorf("表名不能为空")
}
qualifiedTable := ""
@@ -816,7 +811,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
@@ -845,7 +840,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v; sql=%s", err, query)
return fmt.Errorf("插入失败:%v; sql=%s", err, query)
}
}

View File

@@ -41,7 +41,7 @@ func (m *MariaDB) getDSN(config connection.ConnectionConfig) (string, error) {
tlsMode := resolveMySQLTLSMode(config)
return fmt.Sprintf(
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s&multiStatements=true",
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode),
), nil
}
@@ -73,7 +73,7 @@ func (m *MariaDB) Close() error {
func (m *MariaDB) Ping() error {
if m.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := m.pingTimeout
if timeout <= 0 {
@@ -84,9 +84,33 @@ func (m *MariaDB) Ping() error {
return m.conn.PingContext(ctx)
}
func (m *MariaDB) QueryMulti(query string) ([]connection.ResultSetData, error) {
if m.conn == nil {
return nil, fmt.Errorf("连接未打开")
}
rows, err := m.conn.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMultiRows(rows)
}
func (m *MariaDB) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
if m.conn == nil {
return nil, fmt.Errorf("连接未打开")
}
rows, err := m.conn.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMultiRows(rows)
}
func (m *MariaDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if m.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := m.conn.QueryContext(ctx, query)
@@ -100,7 +124,7 @@ func (m *MariaDB) QueryContext(ctx context.Context, query string) ([]map[string]
func (m *MariaDB) Query(query string) ([]map[string]interface{}, []string, error) {
if m.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := m.conn.Query(query)
@@ -113,7 +137,7 @@ func (m *MariaDB) Query(query string) ([]map[string]interface{}, []string, error
func (m *MariaDB) ExecContext(ctx context.Context, query string) (int64, error) {
if m.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := m.conn.ExecContext(ctx, query)
if err != nil {
@@ -124,7 +148,7 @@ func (m *MariaDB) ExecContext(ctx context.Context, query string) (int64, error)
func (m *MariaDB) Exec(query string) (int64, error) {
if m.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := m.conn.Exec(query)
if err != nil {
@@ -186,7 +210,7 @@ func (m *MariaDB) GetCreateStatement(dbName, tableName string) (string, error) {
return fmt.Sprintf("%v", val), nil
}
}
return "", fmt.Errorf("create statement not found")
return "", fmt.Errorf("未找到建表语句")
}
func (m *MariaDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -320,7 +344,7 @@ func (m *MariaDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDef
func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if m.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := m.conn.Begin()
@@ -342,7 +366,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
}
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -367,12 +391,12 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE `%s` SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -394,7 +418,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tableName, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}
@@ -404,7 +428,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
func (m *MariaDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
if dbName == "" {
return nil, fmt.Errorf("database name required for GetAllColumns")
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
}
data, _, err := m.Query(query)

View File

@@ -237,9 +237,6 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" && strings.TrimSpace(config.Database) != "" {
authSource = strings.TrimSpace(config.Database)
}
if authSource == "" {
authSource = "admin"
}
@@ -480,7 +477,7 @@ func (m *MongoDB) Close() error {
func (m *MongoDB) Ping() error {
if m.client == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := m.pingTimeout
if timeout <= 0 {
@@ -684,7 +681,7 @@ func buildMembersFromHello(raw bson.M) []connection.MongoMemberInfo {
func (m *MongoDB) DiscoverMembers() (string, []connection.MongoMemberInfo, error) {
if m.client == nil {
return "", nil, fmt.Errorf("connection not open")
return "", nil, fmt.Errorf("连接未打开")
}
timeout := m.pingTimeout
@@ -835,7 +832,7 @@ func extractCollectionFromSQL(sql string) string {
func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if m.client == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
query = strings.TrimSpace(query)
@@ -1079,7 +1076,7 @@ func (m *MongoDB) ExecContext(ctx context.Context, query string) (int64, error)
func (m *MongoDB) GetDatabases() ([]string, error) {
if m.client == nil {
return nil, fmt.Errorf("connection not open")
return nil, fmt.Errorf("连接未打开")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -1094,7 +1091,7 @@ func (m *MongoDB) GetDatabases() ([]string, error) {
func (m *MongoDB) GetTables(dbName string) ([]string, error) {
if m.client == nil {
return nil, fmt.Errorf("connection not open")
return nil, fmt.Errorf("连接未打开")
}
targetDB := dbName
@@ -1130,7 +1127,7 @@ func (m *MongoDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWit
// GetIndexes returns indexes for a MongoDB collection
func (m *MongoDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
if m.client == nil {
return nil, fmt.Errorf("connection not open")
return nil, fmt.Errorf("连接未打开")
}
targetDB := dbName
@@ -1197,7 +1194,7 @@ func (m *MongoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDef
// ApplyChanges implements batch changes for MongoDB
func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if m.client == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -1213,7 +1210,7 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
}
if len(filter) > 0 {
if _, err := collection.DeleteOne(ctx, filter); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
}
@@ -1225,7 +1222,7 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
filter[k] = v
}
if len(filter) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
updateDoc := bson.M{"$set": bson.M{}}
@@ -1234,7 +1231,7 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
}
if _, err := collection.UpdateOne(ctx, filter, updateDoc); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -1246,7 +1243,7 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
}
if len(doc) > 0 {
if _, err := collection.InsertOne(ctx, doc); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}
}

View File

@@ -238,9 +238,6 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" && strings.TrimSpace(config.Database) != "" {
authSource = strings.TrimSpace(config.Database)
}
if authSource == "" {
authSource = "admin"
}
@@ -483,7 +480,7 @@ func (m *MongoDBV1) Close() error {
func (m *MongoDBV1) Ping() error {
if m.client == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := m.pingTimeout
if timeout <= 0 {
@@ -687,7 +684,7 @@ func buildMembersFromHello(raw bson.M) []connection.MongoMemberInfo {
func (m *MongoDBV1) DiscoverMembers() (string, []connection.MongoMemberInfo, error) {
if m.client == nil {
return "", nil, fmt.Errorf("connection not open")
return "", nil, fmt.Errorf("连接未打开")
}
timeout := m.pingTimeout
@@ -838,7 +835,7 @@ func extractCollectionFromSQL(sql string) string {
func (m *MongoDBV1) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if m.client == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
query = strings.TrimSpace(query)
@@ -1082,7 +1079,7 @@ func (m *MongoDBV1) ExecContext(ctx context.Context, query string) (int64, error
func (m *MongoDBV1) GetDatabases() ([]string, error) {
if m.client == nil {
return nil, fmt.Errorf("connection not open")
return nil, fmt.Errorf("连接未打开")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -1097,7 +1094,7 @@ func (m *MongoDBV1) GetDatabases() ([]string, error) {
func (m *MongoDBV1) GetTables(dbName string) ([]string, error) {
if m.client == nil {
return nil, fmt.Errorf("connection not open")
return nil, fmt.Errorf("连接未打开")
}
targetDB := dbName
@@ -1133,7 +1130,7 @@ func (m *MongoDBV1) GetAllColumns(dbName string) ([]connection.ColumnDefinitionW
// GetIndexes returns indexes for a MongoDB collection
func (m *MongoDBV1) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
if m.client == nil {
return nil, fmt.Errorf("connection not open")
return nil, fmt.Errorf("连接未打开")
}
targetDB := dbName
@@ -1200,7 +1197,7 @@ func (m *MongoDBV1) GetTriggers(dbName, tableName string) ([]connection.TriggerD
// ApplyChanges implements batch changes for MongoDB
func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if m.client == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -1216,7 +1213,7 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
}
if len(filter) > 0 {
if _, err := collection.DeleteOne(ctx, filter); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
}
@@ -1228,7 +1225,7 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
filter[k] = v
}
if len(filter) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
updateDoc := bson.M{"$set": bson.M{}}
@@ -1237,7 +1234,7 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
}
if _, err := collection.UpdateOne(ctx, filter, updateDoc); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -1249,7 +1246,7 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
}
if len(doc) > 0 {
if _, err := collection.InsertOne(ctx, doc); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}
}

View File

@@ -429,7 +429,7 @@ func (m *MySQLAgentDB) ApplyChanges(tableName string, changes connection.ChangeS
func (m *MySQLAgentDB) requireClient() (*mysqlAgentClient, error) {
if m.client == nil {
return nil, fmt.Errorf("connection not open")
return nil, fmt.Errorf("连接未打开")
}
return m.client, nil
}

View File

@@ -186,7 +186,7 @@ func (m *MySQLDB) getDSN(config connection.ConnectionConfig) (string, error) {
tlsMode := resolveMySQLTLSMode(config)
return fmt.Sprintf(
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s",
"%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds&tls=%s&multiStatements=true",
config.User, config.Password, protocol, address, database, timeout, url.QueryEscape(tlsMode),
), nil
}
@@ -267,7 +267,7 @@ func (m *MySQLDB) Close() error {
func (m *MySQLDB) Ping() error {
if m.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := m.pingTimeout
if timeout <= 0 {
@@ -278,9 +278,33 @@ func (m *MySQLDB) Ping() error {
return m.conn.PingContext(ctx)
}
func (m *MySQLDB) QueryMulti(query string) ([]connection.ResultSetData, error) {
if m.conn == nil {
return nil, fmt.Errorf("连接未打开")
}
rows, err := m.conn.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMultiRows(rows)
}
func (m *MySQLDB) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
if m.conn == nil {
return nil, fmt.Errorf("连接未打开")
}
rows, err := m.conn.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMultiRows(rows)
}
func (m *MySQLDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if m.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := m.conn.QueryContext(ctx, query)
@@ -294,7 +318,7 @@ func (m *MySQLDB) QueryContext(ctx context.Context, query string) ([]map[string]
func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error) {
if m.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := m.conn.Query(query)
@@ -307,7 +331,7 @@ func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error
func (m *MySQLDB) ExecContext(ctx context.Context, query string) (int64, error) {
if m.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := m.conn.ExecContext(ctx, query)
if err != nil {
@@ -318,7 +342,7 @@ func (m *MySQLDB) ExecContext(ctx context.Context, query string) (int64, error)
func (m *MySQLDB) Exec(query string) (int64, error) {
if m.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := m.conn.Exec(query)
if err != nil {
@@ -380,7 +404,7 @@ func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) {
return fmt.Sprintf("%v", val), nil
}
}
return "", fmt.Errorf("create statement not found")
return "", fmt.Errorf("未找到建表语句")
}
func (m *MySQLDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -514,7 +538,7 @@ func (m *MySQLDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDef
func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if m.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
columnTypeMap := m.loadColumnTypeMap(tableName)
@@ -539,7 +563,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
res, err := tx.Exec(query, args...)
if err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return fmt.Errorf("删除未生效:未匹配到任何行")
@@ -567,13 +591,13 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE `%s` SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
res, err := tx.Exec(query, args...)
if err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return fmt.Errorf("更新未生效:未匹配到任何行")
@@ -600,7 +624,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
query := fmt.Sprintf("INSERT INTO `%s` () VALUES ()", tableName)
res, err := tx.Exec(query)
if err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return fmt.Errorf("插入未生效:未影响任何行")
@@ -611,7 +635,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tableName, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
res, err := tx.Exec(query, args...)
if err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return fmt.Errorf("插入未生效:未影响任何行")
@@ -774,7 +798,7 @@ func formatMySQLDateTime(t time.Time) string {
func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
if dbName == "" {
return nil, fmt.Errorf("database name required for GetAllColumns")
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
}
data, _, err := m.Query(query)

View File

@@ -510,7 +510,7 @@ func (d *OptionalDriverAgentDB) ApplyChanges(tableName string, changes connectio
func (d *OptionalDriverAgentDB) requireClient() (*optionalDriverAgentClient, error) {
if d.client == nil {
return nil, fmt.Errorf("connection not open")
return nil, fmt.Errorf("连接未打开")
}
return d.client, nil
}
@@ -582,27 +582,8 @@ func (d *OptionalDriverAgentDB) listKingbaseSchemas(ctx context.Context) ([]stri
}
func buildKingbaseSearchPathFromSchemas(schemas []string) string {
if len(schemas) == 0 {
return ""
}
seen := make(map[string]struct{}, len(schemas)+1)
parts := make([]string, 0, len(schemas)+1)
for _, name := range schemas {
trimmed := normalizeKingbaseAgentIdent(name)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
parts = append(parts, quoteKingbaseAgentIdent(trimmed))
}
if _, ok := seen["public"]; !ok {
parts = append(parts, "public")
}
return strings.Join(parts, ", ")
searchPath, _ := buildKingbaseSearchPathCommon(schemas)
return searchPath
}
func quoteKingbaseAgentIdent(name string) string {

View File

@@ -135,7 +135,7 @@ func (o *OracleDB) Close() error {
func (o *OracleDB) Ping() error {
if o.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := o.pingTimeout
if timeout <= 0 {
@@ -148,7 +148,7 @@ func (o *OracleDB) Ping() error {
func (o *OracleDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if o.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := o.conn.QueryContext(ctx, query)
@@ -162,7 +162,7 @@ func (o *OracleDB) QueryContext(ctx context.Context, query string) ([]map[string
func (o *OracleDB) Query(query string) ([]map[string]interface{}, []string, error) {
if o.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := o.conn.Query(query)
@@ -175,7 +175,7 @@ func (o *OracleDB) Query(query string) ([]map[string]interface{}, []string, erro
func (o *OracleDB) ExecContext(ctx context.Context, query string) (int64, error) {
if o.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := o.conn.ExecContext(ctx, query)
if err != nil {
@@ -186,7 +186,7 @@ func (o *OracleDB) ExecContext(ctx context.Context, query string) (int64, error)
func (o *OracleDB) Exec(query string) (int64, error) {
if o.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := o.conn.Exec(query)
if err != nil {
@@ -259,7 +259,7 @@ func (o *OracleDB) GetCreateStatement(dbName, tableName string) (string, error)
return fmt.Sprintf("%v", val), nil
}
}
return "", fmt.Errorf("create statement not found")
return "", fmt.Errorf("未找到建表语句")
}
func (o *OracleDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -391,7 +391,7 @@ func (o *OracleDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if o.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := o.conn.Begin()
@@ -439,7 +439,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -467,12 +467,12 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -496,7 +496,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}

View File

@@ -195,7 +195,7 @@ func (p *PostgresDB) Close() error {
func (p *PostgresDB) Ping() error {
if p.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := p.pingTimeout
if timeout <= 0 {
@@ -208,7 +208,7 @@ func (p *PostgresDB) Ping() error {
func (p *PostgresDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if p.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := p.conn.QueryContext(ctx, query)
@@ -222,7 +222,7 @@ func (p *PostgresDB) QueryContext(ctx context.Context, query string) ([]map[stri
func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, error) {
if p.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := p.conn.Query(query)
@@ -235,7 +235,7 @@ func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, er
func (p *PostgresDB) ExecContext(ctx context.Context, query string) (int64, error) {
if p.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := p.conn.ExecContext(ctx, query)
if err != nil {
@@ -246,7 +246,7 @@ func (p *PostgresDB) ExecContext(ctx context.Context, query string) (int64, erro
func (p *PostgresDB) Exec(query string) (int64, error) {
if p.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := p.conn.Exec(query)
if err != nil {
@@ -302,7 +302,7 @@ func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -372,7 +372,7 @@ func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDef
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -478,7 +478,7 @@ func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -538,7 +538,7 @@ func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -602,7 +602,7 @@ ORDER BY table_schema, table_name, ordinal_position`
func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if p.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := p.conn.Begin()
@@ -650,7 +650,7 @@ func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -678,12 +678,12 @@ func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -707,7 +707,7 @@ func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}

View File

@@ -2,6 +2,8 @@ package db
import (
"database/sql"
"GoNavi-Wails/internal/connection"
)
func scanRows(rows *sql.Rows) ([]map[string]interface{}, []string, error) {
@@ -44,3 +46,38 @@ func scanRows(rows *sql.Rows) ([]map[string]interface{}, []string, error) {
}
return resultData, columns, nil
}
// scanMultiRows 遍历 sql.Rows 中的所有结果集,将每个结果集作为 ResultSetData 返回。
// 利用 rows.NextResultSet() 支持一次 query 返回多个结果集的场景。
func scanMultiRows(rows *sql.Rows) ([]connection.ResultSetData, error) {
var results []connection.ResultSetData
for {
data, cols, err := scanRows(rows)
if err != nil {
return results, err
}
if data == nil {
data = make([]map[string]interface{}, 0)
}
if cols == nil {
cols = []string{}
}
results = append(results, connection.ResultSetData{
Rows: data,
Columns: cols,
})
if !rows.NextResultSet() {
break
}
}
if len(results) == 0 {
results = []connection.ResultSetData{{
Rows: make([]map[string]interface{}, 0),
Columns: []string{},
}}
}
if err := rows.Err(); err != nil {
return results, err
}
return results, nil
}

View File

@@ -184,7 +184,7 @@ func (s *SQLiteDB) Close() error {
func (s *SQLiteDB) Ping() error {
if s.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := s.pingTimeout
if timeout <= 0 {
@@ -197,7 +197,7 @@ func (s *SQLiteDB) Ping() error {
func (s *SQLiteDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if s.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := s.conn.QueryContext(ctx, query)
@@ -211,7 +211,7 @@ func (s *SQLiteDB) QueryContext(ctx context.Context, query string) ([]map[string
func (s *SQLiteDB) Query(query string) ([]map[string]interface{}, []string, error) {
if s.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := s.conn.Query(query)
@@ -224,7 +224,7 @@ func (s *SQLiteDB) Query(query string) ([]map[string]interface{}, []string, erro
func (s *SQLiteDB) ExecContext(ctx context.Context, query string) (int64, error) {
if s.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := s.conn.ExecContext(ctx, query)
if err != nil {
@@ -235,7 +235,7 @@ func (s *SQLiteDB) ExecContext(ctx context.Context, query string) (int64, error)
func (s *SQLiteDB) Exec(query string) (int64, error) {
if s.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := s.conn.Exec(query)
if err != nil {
@@ -275,13 +275,13 @@ func (s *SQLiteDB) GetCreateStatement(dbName, tableName string) (string, error)
return fmt.Sprintf("%v", val), nil
}
}
return "", fmt.Errorf("create statement not found")
return "", fmt.Errorf("未找到建表语句")
}
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
@@ -372,7 +372,7 @@ func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
@@ -463,7 +463,7 @@ func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefin
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
@@ -537,7 +537,7 @@ func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.Foreig
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
@@ -588,7 +588,7 @@ func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if s.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := s.conn.Begin()
@@ -634,7 +634,7 @@ func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -659,12 +659,12 @@ func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -686,7 +686,7 @@ func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet)
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}

View File

@@ -117,7 +117,7 @@ func (s *SqlServerDB) Close() error {
func (s *SqlServerDB) Ping() error {
if s.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := s.pingTimeout
if timeout <= 0 {
@@ -128,9 +128,33 @@ func (s *SqlServerDB) Ping() error {
return s.conn.PingContext(ctx)
}
func (s *SqlServerDB) QueryMulti(query string) ([]connection.ResultSetData, error) {
if s.conn == nil {
return nil, fmt.Errorf("连接未打开")
}
rows, err := s.conn.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMultiRows(rows)
}
func (s *SqlServerDB) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
if s.conn == nil {
return nil, fmt.Errorf("连接未打开")
}
rows, err := s.conn.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMultiRows(rows)
}
func (s *SqlServerDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if s.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := s.conn.QueryContext(ctx, query)
@@ -144,7 +168,7 @@ func (s *SqlServerDB) QueryContext(ctx context.Context, query string) ([]map[str
func (s *SqlServerDB) Query(query string) ([]map[string]interface{}, []string, error) {
if s.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := s.conn.Query(query)
@@ -157,7 +181,7 @@ func (s *SqlServerDB) Query(query string) ([]map[string]interface{}, []string, e
func (s *SqlServerDB) ExecContext(ctx context.Context, query string) (int64, error) {
if s.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := s.conn.ExecContext(ctx, query)
if err != nil {
@@ -168,7 +192,7 @@ func (s *SqlServerDB) ExecContext(ctx context.Context, query string) (int64, err
func (s *SqlServerDB) Exec(query string) (int64, error) {
if s.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := s.conn.Exec(query)
if err != nil {
@@ -236,7 +260,7 @@ func (s *SqlServerDB) GetColumns(dbName, tableName string) ([]connection.ColumnD
}
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -344,7 +368,7 @@ func (s *SqlServerDB) GetIndexes(dbName, tableName string) ([]connection.IndexDe
}
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -425,7 +449,7 @@ func (s *SqlServerDB) GetForeignKeys(dbName, tableName string) ([]connection.For
}
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -483,7 +507,7 @@ func (s *SqlServerDB) GetTriggers(dbName, tableName string) ([]connection.Trigge
}
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -530,7 +554,7 @@ ORDER BY tr.name`,
func (s *SqlServerDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if s.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := s.conn.Begin()
@@ -573,7 +597,7 @@ func (s *SqlServerDB) ApplyChanges(tableName string, changes connection.ChangeSe
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -601,12 +625,12 @@ func (s *SqlServerDB) ApplyChanges(tableName string, changes connection.ChangeSe
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -630,7 +654,7 @@ func (s *SqlServerDB) ApplyChanges(tableName string, changes connection.ChangeSe
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}

View File

@@ -120,7 +120,7 @@ func (t *TDengineDB) Close() error {
func (t *TDengineDB) Ping() error {
if t.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := t.pingTimeout
if timeout <= 0 {
@@ -133,7 +133,7 @@ func (t *TDengineDB) Ping() error {
func (t *TDengineDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if t.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := t.conn.QueryContext(ctx, query)
@@ -147,7 +147,7 @@ func (t *TDengineDB) QueryContext(ctx context.Context, query string) ([]map[stri
func (t *TDengineDB) Query(query string) ([]map[string]interface{}, []string, error) {
if t.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := t.conn.Query(query)
@@ -161,7 +161,7 @@ func (t *TDengineDB) Query(query string) ([]map[string]interface{}, []string, er
func (t *TDengineDB) ExecContext(ctx context.Context, query string) (int64, error) {
if t.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := t.conn.ExecContext(ctx, query)
if err != nil {
@@ -172,7 +172,7 @@ func (t *TDengineDB) ExecContext(ctx context.Context, query string) (int64, erro
func (t *TDengineDB) Exec(query string) (int64, error) {
if t.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := t.conn.Exec(query)
if err != nil {
@@ -274,7 +274,7 @@ func (t *TDengineDB) GetCreateStatement(dbName, tableName string) (string, error
if lastErr != nil {
return "", lastErr
}
return "", fmt.Errorf("create statement not found")
return "", fmt.Errorf("未找到建表语句")
}
func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -325,7 +325,7 @@ func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
if strings.TrimSpace(dbName) == "" {
return nil, fmt.Errorf("database name required for GetAllColumns")
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
}
tables, err := t.GetTables(dbName)
@@ -365,10 +365,10 @@ func (t *TDengineDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
func (t *TDengineDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if t.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
if strings.TrimSpace(tableName) == "" {
return fmt.Errorf("table name required")
return fmt.Errorf("表名不能为空")
}
if len(changes.Updates) > 0 || len(changes.Deletes) > 0 {
return fmt.Errorf("TDengine 目标端当前仅支持 INSERT 写入,暂不支持 UPDATE/DELETE 差异同步,请改用仅插入或全量覆盖模式")
@@ -384,7 +384,7 @@ func (t *TDengineDB) ApplyChanges(tableName string, changes connection.ChangeSet
continue
}
if _, err := t.conn.Exec(query); err != nil {
return fmt.Errorf("insert error: %v; sql=%s", err, query)
return fmt.Errorf("插入失败:%v; sql=%s", err, query)
}
}
return nil
@@ -392,7 +392,7 @@ func (t *TDengineDB) ApplyChanges(tableName string, changes connection.ChangeSet
func buildTDengineInsertSQL(qualifiedTable string, row map[string]interface{}) (string, error) {
if strings.TrimSpace(qualifiedTable) == "" {
return "", fmt.Errorf("qualified table required")
return "", fmt.Errorf("需要指定完整的表名")
}
if len(row) == 0 {
return "", nil

View File

@@ -124,7 +124,7 @@ func (v *VastbaseDB) Close() error {
func (v *VastbaseDB) Ping() error {
if v.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
timeout := v.pingTimeout
if timeout <= 0 {
@@ -137,7 +137,7 @@ func (v *VastbaseDB) Ping() error {
func (v *VastbaseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if v.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := v.conn.QueryContext(ctx, query)
@@ -151,7 +151,7 @@ func (v *VastbaseDB) QueryContext(ctx context.Context, query string) ([]map[stri
func (v *VastbaseDB) Query(query string) ([]map[string]interface{}, []string, error) {
if v.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
return nil, nil, fmt.Errorf("连接未打开")
}
rows, err := v.conn.Query(query)
@@ -164,7 +164,7 @@ func (v *VastbaseDB) Query(query string) ([]map[string]interface{}, []string, er
func (v *VastbaseDB) ExecContext(ctx context.Context, query string) (int64, error) {
if v.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := v.conn.ExecContext(ctx, query)
if err != nil {
@@ -175,7 +175,7 @@ func (v *VastbaseDB) ExecContext(ctx context.Context, query string) (int64, erro
func (v *VastbaseDB) Exec(query string) (int64, error) {
if v.conn == nil {
return 0, fmt.Errorf("connection not open")
return 0, fmt.Errorf("连接未打开")
}
res, err := v.conn.Exec(query)
if err != nil {
@@ -231,7 +231,7 @@ func (v *VastbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -301,7 +301,7 @@ func (v *VastbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDef
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -406,7 +406,7 @@ func (v *VastbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -466,7 +466,7 @@ func (v *VastbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
return nil, fmt.Errorf("表名不能为空")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -530,7 +530,7 @@ ORDER BY table_schema, table_name, ordinal_position`
func (v *VastbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if v.conn == nil {
return fmt.Errorf("connection not open")
return fmt.Errorf("连接未打开")
}
tx, err := v.conn.Begin()
@@ -578,7 +578,7 @@ func (v *VastbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("delete error: %v", err)
return fmt.Errorf("删除失败:%v", err)
}
}
@@ -606,12 +606,12 @@ func (v *VastbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
}
if len(wheres) == 0 {
return fmt.Errorf("update requires keys")
return fmt.Errorf("更新操作需要主键条件")
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("update error: %v", err)
return fmt.Errorf("更新失败:%v", err)
}
}
@@ -635,7 +635,7 @@ func (v *VastbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("insert error: %v", err)
return fmt.Errorf("插入失败:%v", err)
}
}

View File

@@ -0,0 +1,65 @@
package logger
import (
"errors"
"fmt"
"testing"
)
func TestErrorChain_NilError(t *testing.T) {
if got := ErrorChain(nil); got != "" {
t.Errorf("ErrorChain(nil) = %q; want empty string", got)
}
}
func TestErrorChain_SingleError(t *testing.T) {
err := errors.New("single error")
got := ErrorChain(err)
if got != "single error" {
t.Errorf("ErrorChain(single) = %q; want %q", got, "single error")
}
}
func TestErrorChain_WrappedErrors(t *testing.T) {
inner := errors.New("root cause")
middle := fmt.Errorf("middle: %w", inner)
outer := fmt.Errorf("outer: %w", middle)
got := ErrorChain(outer)
// Should contain all three distinct messages
if got == "" {
t.Fatal("ErrorChain returned empty string for wrapped errors")
}
// The chain should start with the outermost error
if len(got) < len("outer:") {
t.Errorf("ErrorChain result too short: %q", got)
}
}
func TestErrorChain_DeduplicatesMessages(t *testing.T) {
// Create a chain where wrapping doesn't add new text
inner := errors.New("same message")
outer := fmt.Errorf("%w", inner)
got := ErrorChain(outer)
// Should not repeat "same message"
if got != "same message" {
t.Errorf("ErrorChain should deduplicate: got %q", got)
}
}
func TestErrorChain_TruncatesLongChain(t *testing.T) {
// Build a chain of 25 errors (exceeds the 20-level limit)
var err error = errors.New("base")
for i := 0; i < 25; i++ {
err = fmt.Errorf("level-%d: %w", i, err)
}
got := ErrorChain(err)
if got == "" {
t.Fatal("ErrorChain returned empty for long chain")
}
// Should contain truncation notice
if len(got) == 0 {
t.Error("expected non-empty result for long chain")
}
}

View File

@@ -3,6 +3,7 @@ package redis
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/url"
@@ -18,6 +19,8 @@ import (
"github.com/redis/go-redis/v9"
)
var ErrRedisKeyGone = errors.New("Redis Key 不存在或已过期")
// RedisClientImpl implements RedisClient using go-redis
type RedisClientImpl struct {
client redis.UniversalClient
@@ -385,6 +388,65 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
ctx, cancel := context.WithTimeout(context.Background(), maxDuration+5*time.Second)
defer cancel()
// 集群模式:逐 master 节点 SCAN 后合并去重
if r.isCluster && r.clusterClient != nil {
keys := make([]string, 0, int(targetCount))
seen := make(map[string]struct{}, int(targetCount))
var mu sync.Mutex
err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error {
var nodeCursor uint64
round := 0
scanStartedAt := time.Now()
for {
if time.Since(scanStartedAt) >= maxDuration {
break
}
mu.Lock()
enough := len(keys) >= int(targetCount)
mu.Unlock()
if enough {
break
}
batch, nextCursor, err := node.Scan(nodeCtx, nodeCursor, physicalPattern, scanStepCount).Result()
if err != nil {
return err
}
mu.Lock()
for _, key := range batch {
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
if len(keys) >= int(targetCount) {
break
}
}
mu.Unlock()
nodeCursor = nextCursor
round++
if nodeCursor == 0 || round >= maxRounds {
break
}
}
return nil
})
if err != nil {
return nil, err
}
// 集群模式 cursor 无意义,始终返回 "0" 表示扫描完成
return &RedisScanResult{
Keys: r.loadRedisKeyInfos(ctx, keys),
Cursor: "0",
}, nil
}
// 非集群模式:原逻辑
currentCursor := cursor
round := 0
scanStartedAt := time.Now()
@@ -472,20 +534,29 @@ func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string)
if ttlErr != nil && ttlErr != redis.Nil {
ttlValue = -2
}
ttlSeconds := toRedisTTLSeconds(ttlValue)
if isRedisKeyGone(keyType, ttlSeconds) {
continue
}
result = append(result, RedisKeyInfo{
Key: r.toDisplayKey(key),
Type: keyType,
TTL: toRedisTTLSeconds(ttlValue),
TTL: ttlSeconds,
})
}
return result
}
for i, key := range keys {
keyType := typeResults[i].Val()
ttlSeconds := toRedisTTLSeconds(ttlResults[i].Val())
if isRedisKeyGone(keyType, ttlSeconds) {
continue
}
result = append(result, RedisKeyInfo{
Key: r.toDisplayKey(key),
Type: typeResults[i].Val(),
TTL: toRedisTTLSeconds(ttlResults[i].Val()),
Type: keyType,
TTL: ttlSeconds,
})
}
return result
@@ -501,6 +572,17 @@ func toRedisTTLSeconds(ttl time.Duration) int64 {
return int64(ttl.Seconds())
}
func isRedisKeyGone(keyType string, ttl int64) bool {
return keyType == "none" || ttl == -2
}
func normalizeRedisGetValueError(keyType string, ttl int64) error {
if isRedisKeyGone(keyType, ttl) {
return ErrRedisKeyGone
}
return nil
}
// GetKeyType returns the type of a key
func (r *RedisClientImpl) GetKeyType(key string) (string, error) {
if r.client == nil {
@@ -594,6 +676,9 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
}
ttl, _ := r.GetTTL(key)
if err := normalizeRedisGetValueError(keyType, ttl); err != nil {
return nil, err
}
physicalKey := r.toPhysicalKey(key)
result := &RedisValue{

View File

@@ -1,6 +1,9 @@
package redis
import "testing"
import (
"errors"
"testing"
)
func TestSanitizeRedisPassword(t *testing.T) {
tests := []struct {
@@ -79,3 +82,40 @@ func TestSanitizeRedisPassword(t *testing.T) {
})
}
}
func TestIsRedisKeyGone(t *testing.T) {
tests := []struct {
name string
keyType string
ttl int64
want bool
}{
{name: "type none", keyType: "none", ttl: -2, want: true},
{name: "type none without ttl", keyType: "none", ttl: -1, want: true},
{name: "missing by ttl", keyType: "string", ttl: -2, want: true},
{name: "normal string", keyType: "string", ttl: 30, want: false},
{name: "permanent hash", keyType: "hash", ttl: -1, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRedisKeyGone(tt.keyType, tt.ttl); got != tt.want {
t.Fatalf("isRedisKeyGone(%q, %d)=%v, want %v", tt.keyType, tt.ttl, got, tt.want)
}
})
}
}
func TestNormalizeRedisGetValueError(t *testing.T) {
err := normalizeRedisGetValueError("none", -2)
if !errors.Is(err, ErrRedisKeyGone) {
t.Fatalf("expected ErrRedisKeyGone, got %v", err)
}
if err == nil || err.Error() != "Redis Key 不存在或已过期" {
t.Fatalf("unexpected error text: %v", err)
}
if normalizeRedisGetValueError("hash", -1) != nil {
t.Fatal("expected nil for supported existing key")
}
}