Compare commits

...

307 Commits

Author SHA1 Message Date
Syngnat
6ec1072d2e Release/0.6.3 2026-03-20 16:53:11 +08:00
Syngnat
da28207168 🐛 fix(ci): 修复 Release 构建资产丢失,checkout 隔离到独立目录
- checkout path 改为 repo-for-changelog 避免 git 操作干扰 release-assets
- Generate Changelog 步骤进入 checkout 子目录执行 git log
2026-03-20 16:51:42 +08:00
Syngnat
9f2f8b33e8 Release/0.6.3 2026-03-20 16:24:26 +08:00
Syngnat
87cfbee6d3 🐛 fix(ci): 修复 Release 构建资产丢失问题
- Checkout 步骤添加 clean: false 防止 git clean 删除已下载的 release-assets
2026-03-20 16:23:13 +08:00
Syngnat
d984a15508 release/0.6.3 2026-03-20 16:08:38 +08:00
Syngnat
0100b771b0 🔧 ci(release): 优化 Release Notes 自动生成,按 commit 前缀分类展示详细变更
- 替换 generate_release_notes 为 git log 提取 commit message
- 按 emoji 前缀分 6 组:新功能、🐛修复、性能、♻️重构、🌐国际化、🔧其他
- 底部附加 compare 链接,空分类自动跳过
2026-03-20 16:07:25 +08:00
Syngnat
1758d6f918 feat(table-designer): 补全设计表字段类型列表,按数据库方言分组
- 新增 DB_TYPE_OPTIONS 按 MySQL/PostgreSQL/SQL Server/SQLite/Oracle 分组
- MySQL:补充数值(float/double/smallint/mediumint)、字符串(tinytext/mediumtext/longtext)、
  二进制(blob/tinyblob/mediumblob/longblob)、其他(enum/set/bit/year)
- PostgreSQL:补充 serial/boolean/timestamptz/jsonb/uuid/inet 等
- SQL Server:补充 float/real/money/nvarchar/datetime2/uniqueidentifier 等
- AutoComplete options 从固定 COMMON_TYPES 改为 DB_TYPE_OPTIONS[getDbType()] 动态获取
- refs #281
2026-03-20 16:00:35 +08:00
Syngnat
b86cfcacaa 🌐 fix(editor): 加载 monaco-editor 中文 NLS 语言包修复右键菜单英文显示
- 在 main.tsx 中 import 'monaco-editor/esm/nls.messages.zh-cn'
- NLS 必须在 monaco-editor 主包之前导入才能生效
- 覆盖所有 Monaco Editor 实例的内置菜单(Cut→剪切、Copy→复制等)
- refs #269
2026-03-20 15:52:38 +08:00
Syngnat
7d543e06c6 🐛 fix(export): 导出数据日期时间格式化为本地时区 yyyy-MM-dd HH:mm:ss
- formatExportCellText default 分支增加字符串日期时间解析与格式化
- normalizeExportJSONValue 新增 time.Time 和字符串日期时间处理
- 覆盖 CSV/JSON/XLSX/HTML/Markdown 全部导出格式
- refs #270
2026-03-20 15:44:53 +08:00
Syngnat
17e4e3ad1c feat(data-grid): 新增底部数据预览面板支持长数据字段完整查看与编辑
- 工具栏新增「数据预览」切换按钮,点击展开/收起底部面板
- 单击单元格自动更新面板内容,完整展示长文本和 JSON 数据
- 面板使用 Monaco Editor,JSON 数据自动语法高亮
- 编辑模式下支持直接修改并保存,只读模式下 Editor 设为 readOnly
- 支持 JSON 一键格式化功能
- 通过 ref 追踪面板状态避免 mergedColumns 过度重渲染
- refs #271
2026-03-20 15:37:17 +08:00
Syngnat
84579b83c9 feat(TableDesigner): 优化设计表滚动体验并支持索引批量删除
- 滚动优化:修复 Tab 切换时 ResizeObserver 高度归零导致表格异常滚动
- 零高度守卫:移除 activeKey 依赖,跳过 display:none 时的零高度观测
- 触发器统一:触发器 Tab 补充 scroll={{ y: tableHeight }} 与索引/外键保持一致
- 批量删除:handleDeleteIndex 支持多选索引批量生成 DROP SQL 合并执行
- 交互优化:删除确认弹窗展示选中索引数量和名称列表
- 状态清理:批量删除成功后自动清空 selectedIndexKeys
- refs #273
2026-03-20 15:21:47 +08:00
Syngnat
7ddef7096b 🐛 fix(editor): 修复DDL视图stickyScroll首行冻结透明背景导致字符重叠
- 根因定位:TableDesigner和TriggerViewer中局部主题定义覆盖了全局配置
- 全局主题新增editorStickyScroll.background不透明背景色
- 移除TableDesigner.tsx中重复的透明主题定义(26行)
- 移除TriggerViewer.tsx中重复的透明主题定义(26行)
- 清理未使用的loader import
- refs #274
2026-03-20 15:00:00 +08:00
Syngnat
557178f182 🐛 fix(query-editor): 修复SQL查询结果行数限制和显示问题
- 移除每次执行SQL重复弹出"结果集已自动限制"的warning提示
- 用户手写LIMIT时尊重原始结果,不再被前端maxRows截断
- 结果集tab标签显示精确行数,去掉"1000+"的+号后缀
- refs #275
2026-03-20 14:50:18 +08:00
Syngnat
a1b546ddd9 feat(data-grid): 日期时间类型字段集成DatePicker选择器
- 类型识别:根据列元数据自动识别datetime/date/time/year类型
- inline编辑:日期时间列双击弹出DatePicker替代纯文本Input
- 行编辑器:日期时间字段使用DatePicker组件
- 交互优化:datetime类型需点"确定"按钮才保存,date/time/year即选即保存
- 取消支持:datetime选择器点击外部自动取消编辑,不保存
- 值转换:编辑时字符串↔dayjs自动转换,无效日期回退为文本输入
- refs #276
2026-03-20 14:35:45 +08:00
Syngnat
da5e879409 🐛 fix(data-grid): 修复复制为INSERT/CSV/Markdown字段乱序及特殊字符未转义
- INSERT:使用 columnNames 保持 DDL 字段顺序,值中单引号转义为 ''
- CSV:使用 columnNames 保持字段顺序,值中双引号转义为 "",增加表头行
- Markdown:使用 columnNames 保持字段顺序,转义管道符和换行,增加表头行和分隔行
- refs #277
2026-03-20 14:10:23 +08:00
Syngnat
8935ad2905 🐛 fix(query-editor): 修复多Tab场景下SQL智能提示读取错误数据库上下文
- 根因:completion provider 只注册一次,闭包捕获首个 Tab 的组件 ref,切换 Tab 后仍读取旧上下文
- 修复:新增模块级共享变量,所有 QueryEditor 实例在成为活跃 Tab 时同步状态
- 共享变量:currentDb、connectionId、tables、allColumns、visibleDbs、columnsCache
- provider 闭包改为读取共享变量,确保始终使用当前活跃 Tab 的数据库上下文
- metadata 加载和数据库列表获取后同步更新共享变量
- refs #278
2026-03-20 13:59:38 +08:00
Syngnat
cd5a0e85e8 feat(data-grid): 筛选面板新增多字段排序功能并支持启用禁用
- 排序扩展:SortInfo 类型从单字段扩展为数组,SQL 和 MongoDB 均支持多字段 ORDER BY
- 筛选面板:新增排序配置区域,支持动态添加/删除多个排序字段及启用/禁用
- 表头联动:启用 Ant Design 多列排序模式,表头排序图标与筛选面板双向同步
- 增量更新:表头点击排序时在现有排序数组中增量更新,不覆盖其他字段
- 循环优化:表头排序从"升序→降序→取消"改为"升序↔降序"切换
- 布局优化:操作按钮栏增加分隔符分组,排序区域与按钮间增加视觉分隔
- refs #279
2026-03-20 13:22:10 +08:00
Syngnat
ccb9f09452 🐛 fix(store): 修复保存查询后再次打开产生重复Tab
- 新增语义去重:addTab对query类型按savedQueryId匹配已有Tab
- 匹配条件覆盖savedQueryId相同或Tab id等于savedQueryId两种场景
- 命中已有Tab时复用并激活,避免重复创建
- refs #280
2026-03-20 12:59:24 +08:00
Syngnat
5afd80c559 feat(about/update): 优化macOS下载更新交互与关于弹窗按钮布局
- 自动打开目录:macOS下载完成后根据用户是否点了"隐藏到后台"决定是否自动打开下载目录
- 文件校验兜底:打开安装目录失败时清除已下载状态,允许重新下载
- 缓存同步修复:checkForUpdates以后端downloaded字段为准,清除过期的本地ref缓存
- 关于弹窗重构:已下载状态直接显示"打开安装目录"主操作按钮,无需经下载进度中转
- 按钮互斥优化:下载中隐藏"下载更新"和"本次不再提示",显示"下载进度"
- 按钮排版调整:主操作按钮置右侧高亮,各状态下按钮层次分明
2026-03-20 12:55:16 +08:00
Syngnat
1b36f60821 🐛 fix(query-editor/data-grid): 修复只读查询结果右键菜单失效及提交事务后数据丢失
- 右键菜单修复:移除 handleContextMenu 的 editable 守卫,只读模式也能弹出右键菜单
- 非编辑单元格绑定:EditableCell 非编辑模式增加 onContextMenu 包装,确保右键事件触发
- mergedColumns 统一:所有列通过 onCell 绑定 onContextMenu,不再跳过非 editable 列
- 表名正则增强:支持多行 SQL 和 schema.table 写法,复杂 SELECT 也能提取表名获得编辑能力
- 精准重查询:新增 handleReloadResult 函数,提交事务后只用当前结果集 SQL 重查,避免整个编辑器 SQL 二次处理导致数据丢失
- refs #267
2026-03-20 12:11:09 +08:00
Syngnat
eaa76d8f04 feat(connection): 新增数据库连接图标功能并修复达梦数据库列表为空
- 图标组件:新增 DatabaseIcons.tsx,10 种品牌 SVG logo + 7 种彩色文字标签覆盖全部数据源
- 品牌资源:下载 MySQL/PG/Redis/MongoDB/ClickHouse/SQLite/MariaDB/Doris/Sphinx/DuckDB 的 SVG(CC0 许可)
- 类型扩展:SavedConnection 新增 iconType/iconColor 支持自定义图标和颜色
- 外观配置:ConnectionModal 新增"外观"配置区(图标选择器 + 14 色选择器 + 预览面板)
- 数据源选择:Step1 数据源类型卡片全部统一为品牌图标
- 达梦修复:dameng_metadata.go 增加原生 SQL 查询和诊断日志,改善数据库列表获取
- refs #114
- refs #266
2026-03-20 11:19:08 +08:00
Syngnat
0f717706b0 🐛 fix(TableOverview/DataGrid): 修复表概览重复打开Tab及隐藏列修改失效
- Tab去重:表概览 buildTableStatusSQL 对 postgres/kingbase/vastbase/highgo/sqlserver 返回 schema.table 格式表名,与侧边栏一致
- Tab ID统一:移除 openTable 中多余的 table- 前缀,使 Tab ID 格式匹配
- 语义去重:addTab 新增 connectionId+dbName+tableName 语义匹配作为安全网
- 数据修复:handleCommit 和 applyRowEditor 将 displayColumnNames 改为 columnNames,确保隐藏列修改被正确提交
- refs #264
- refs #265
2026-03-20 10:33:51 +08:00
Syngnat
bfa918cb9d Release/v0.6.2 (#263) 2026-03-19 21:18:01 +08:00
Syngnat
8950081a6c 🐛 fix(QueryEditor): 修复多 Tab 导致 SQL 自动补全项重复的问题
- registerCompletionItemProvider 为 monaco.languages 全局 API,多 Tab 实例重复注册导致补全项成倍重复
- 添加模块级标志 sqlCompletionRegistered 确保全局只注册一次
- Provider 内部通过 ref 读取当前上下文,单次注册不影响多 Tab 的上下文感知
- refs #261
2026-03-19 21:14:11 +08:00
Syngnat
3bf8758418 ️ perf(App/handleNewQuery): 缓存 handleNewQuery 并消除 Tab ID 碰撞风险
- 将 handleNewQuery 改为 useCallback,减少 useEffect 中事件监听器的无效重绑定
- Tab ID 生成方式改为 Date.now() + 随机后缀,与项目既有模式一致
2026-03-19 20:46:46 +08:00
Syngnat
561d3810da 🐛 fix(data-grid): 修复窄表场景表头与数据列错位 2026-03-19 18:16:51 +08:00
Syngnat
18cb66b893 🐛 fix(query-editor/data-grid): 修复UPDATE影响行数为0及虚拟表Shift+滚轮横向滚动失效
- 后端修复:DBQueryMulti 包含写操作时跳过原生 QueryMulti,走逐条 Exec 路径获取 RowsAffected
- 结果展示:UPDATE/INSERT/DELETE 结果改为简洁的执行成功提示,不再展示 DataGrid 全套操作按钮
- Tab标签:写操作结果集标签改为「结果 N ✓」替代原来的行数计数
- 横向滚动:修复虚拟表守卫检查选择器不匹配(.rc-virtual-list-holder → .ant-table-tbody-virtual-holder)
- 事件处理:使用 event.isTrusted 区分合成事件,通过 applyVirtualHorizontalOffset 驱动 rc-virtual-list
- 目标检查:isTableDataAreaTarget 改为黑名单模式,兼容 rc-virtual-list 包裹元素
2026-03-19 17:13:38 +08:00
Syngnat
ab61e703b1 🐛 fix(data-grid): 修复空数据表Shift+滚轮横向滚动失效
- 目标匹配:isTableDataAreaTarget 新增 .ant-table-placeholder 选择器覆盖空表占位元素
- 虚拟回退:虚拟模式下 rc-virtual-list-holder 不存在时,回退到手动滚动表头并同步外部滚动条
- 精准匹配:仅添加 .ant-table-placeholder,避免 .ant-table-header 导致有数据表头体滚动不同步
2026-03-19 14:32:12 +08:00
Syngnat
7933b4c315 feat(window): 实现窗口尺寸位置与侧边栏宽度持久化记忆
- 窗口状态:新增 windowState 记录全屏/最大化/普通状态,关闭后重开自动恢复
- 窗口尺寸:普通窗口模式下每2秒自动保存宽高和坐标位置
- 侧边栏宽度:sidebarWidth 从 useState 迁移至 zustand store 持久化
- 状态恢复:启动时根据保存的状态决定全屏/最大化/恢复具体尺寸位置
- 数据校验:新增 sanitizeWindowBounds/sanitizeWindowState/sanitizeSidebarWidth 校验函数
- 兼容处理:startupFullscreen 设置优先级高于自动记忆的窗口状态
- refs #259
2026-03-19 12:26:44 +08:00
Syngnat
4e73f6d8b5 release/0.6.1 2026-03-19 11:59:25 +08:00
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
Syngnat
2f4e20a34a release/0.6.0 2026-03-18 21:27:31 +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
dfabd77615 🐛 fix(ci): 修复 Chocolatey UPX 包不可用导致 Windows 构建失败 2026-03-18 17:30:33 +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
4a2dda8aa2 合并拉取请求 #254
release/0.5.9
2026-03-18 17:21:55 +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
d1d3fa26f1 🔧 fix(frontend/ci): 移除前端测试对 node:assert 的类型依赖 (#234) 2026-03-13 15:37:16 +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
fc8e62b997 release/0.5.8 (#233) 2026-03-13 15:29:53 +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
Syngnat
b0eb93bfa3 Release/0.5.7 (#230)
🔧 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 弃用告警

🔧 fix(window): 修复Windows启动全屏锁死并补齐标题栏退出全屏逻辑
2026-03-12 19:46:40 +08:00
杨国锋
11b8e0f12a Merge branch 'dev' into release/0.5.7 2026-03-12 19:39:42 +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
Syngnat
8c5fee1c7a * 🔧 fix(release/macos): 移除 macOS 打包链路的 UPX 压缩逻辑 2026-03-12 19:08:05 +08:00
杨国锋
ec05f518a9 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	.github/workflows/release.yml
#	.github/workflows/test-build-all-platforms.yml
#	build-release.sh
2026-03-12 19:06:48 +08:00
杨国锋
2c9aa640fd Merge branch 'dev' into release/0.5.7 2026-03-12 19:04:20 +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
9f7cc58fad Release/0.5.7 (#227)
* 🎨 style(DataGrid): 清理冗余代码与静态分析告警

- 类型重构:通过修正 React Context 的函数签名解决了 void 类型的链式调用错误
- 代码精简:利用 Nullish Coalescing (??) 优化组件配置项降级逻辑,剥离无意义的隐式 undefined 赋值
- 工具链适配:适配 IDE 拼写检查与 Promise strict rules,确保全文件零警

* 🔧 fix(db/kingbase_impl): 修复标识符无条件加双引号导致SQL语法报错

- quoteKingbaseIdent 改为条件引用,仅对大写字母、保留字、特殊字符的标识符添加双引号
- 新增 kingbaseIdentNeedsQuote 判断标识符是否需要引用
- 新增 isKingbaseReservedWord 检测常见SQL保留字
- 补充 TestQuoteKingbaseIdent、TestKingbaseIdentNeedsQuote 单测覆盖各场景
- refs #176

* 🔧 fix(release,db/kingbase_impl): 修复金仓默认 schema 并静默生成 DMG

- Kingbase:在 current_schema() 为 public 时探测候选 schema,并通过 DSN search_path 重连,兼容未限定 schema 的查询
- 候选优先级:数据库名/用户名同名 schema(存在性校验),否则仅在“唯一用户 schema 有表”场景兜底
- 避免连接污染:每次 Connect 重置探测结果,重连成功后替换连接并关闭旧连接
- 打包脚本:create-dmg 增加 --sandbox-safe,避免构建时自动弹出/打开挂载窗口
- 产物格式:强制 --format UDZO,并将 rw.*.dmg/UDRW 中间产物转换为可分发 DMG
- 校验门禁:增加 hdiutil verify,失败时保留 .app 便于排查,同时修正卷图标探测并补 ad-hoc 签名

* 🐛 fix(connection/redis): 修复 Redis URI 用户名处理导致认证失败

- Redis URI 解析回填 user 字段,兼容 redis://user:pass@... 与 redis://:pass@...
- 生成 URI 时按需输出 user/password,避免丢失用户名信息
- Redis 类型默认用户名置空,并在构建配置时清理历史默认 root
- 避免 go-redis 触发 ACL AUTH(user, pass) 导致 WRONGPASS
- refs #212

* 🔧 fix(release,ssh): 修复 SSH 误判连接成功并纠正 DMG 打包结构

- SSH 缓存 key 纳入认证指纹(password/keyPath),避免改错凭证仍复用旧连接/端口转发
- MySQL/MariaDB/Doris:SSH 隧道建立失败直接返回错误,不再回退直连导致测试误判成功
- 新增最小单测覆盖 SSH cache key 与 UseSSH 异常路径
- build-release.sh:create-dmg 使用 staging 目录作为 source,避免 DMG 根目录变成 Contents
- refs #213

* fix: KingBase 连接后自动设置 search_path,修复自定义 schema 下表查询报 relation does not exist 的问题 (#215)

* 🔧 fix(driver/kingbase,mongodb): 修复外置驱动事务引用与连接测试链路问题

- 金仓外置驱动链路增加表名与变更字段归一化,修复 ApplyChanges 场景下双引号转义异常导致的 SQL 语法错误
- 新增金仓公共标识符工具并复用到 kingbase_impl 与 optional_driver_agent_impl,统一处理多重转义、schema.table 拆分与引用规范
- 金仓代理连接后自动探测并设置 search_path,降低查询时必须手写 schema 前缀的概率
- MongoDB 连接参数改为显式 host/hosts 优先,避免被 URI 中 localhost 覆盖;代理链路保留目标地址不再改写为本地地址
- 连接测试增加前后端超时收敛与日志增强,避免长时间转圈;连接错误文案在未启用 TLS 时移除误导性的“SSL”前缀
- 统一日志级别为 INFO/WARN/ERROR,默认日志目录收敛到 ~/.GoNavi/Logs,并补充驱动构建脚本 build-driver-agents.sh

* 🔧 fix(release/sidebar): 统一跨平台UPX压缩并修复PG函数列表查询兼容性

- 构建脚本新增通用 UPX 压缩函数,覆盖 macOS、Linux、Windows 产物
- 本地打包改为强制压缩策略:未安装 upx、压缩失败或校验失败直接终止
- macOS 打包在签名前压缩 .app 主程序并执行 upx -t 校验
- Linux 打包在生成 tar.gz 前压缩可执行文件并执行 upx -t 校验
- GitHub Release 与测试构建流程补齐 macOS/Linux/Windows 的 upx 安装与压缩步骤
- PostgreSQL/PG-like 函数元数据查询增加多路兼容 SQL,修复函数列表不显示问题
- refs #221
- refs #222

* 🔧 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 兼容策略与错误处理逻辑

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: 凌封 <49424247+fengin@users.noreply.github.com>
2026-03-12 17:58:05 +08:00
Syngnat
97bf891df3 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	.github/workflows/release.yml
#	.github/workflows/test-build-all-platforms.yml
#	build-release.sh
2026-03-12 17:55:17 +08:00
Syngnat
72a9692200 Merge branch 'dev' into release/0.5.7 2026-03-12 17:54:26 +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
eaa45f17fd Release/0.5.7 (#226)
* 🎨 style(DataGrid): 清理冗余代码与静态分析告警

- 类型重构:通过修正 React Context 的函数签名解决了 void 类型的链式调用错误
- 代码精简:利用 Nullish Coalescing (??) 优化组件配置项降级逻辑,剥离无意义的隐式 undefined 赋值
- 工具链适配:适配 IDE 拼写检查与 Promise strict rules,确保全文件零警

* 🔧 fix(db/kingbase_impl): 修复标识符无条件加双引号导致SQL语法报错

- quoteKingbaseIdent 改为条件引用,仅对大写字母、保留字、特殊字符的标识符添加双引号
- 新增 kingbaseIdentNeedsQuote 判断标识符是否需要引用
- 新增 isKingbaseReservedWord 检测常见SQL保留字
- 补充 TestQuoteKingbaseIdent、TestKingbaseIdentNeedsQuote 单测覆盖各场景
- refs #176

* 🔧 fix(release,db/kingbase_impl): 修复金仓默认 schema 并静默生成 DMG

- Kingbase:在 current_schema() 为 public 时探测候选 schema,并通过 DSN search_path 重连,兼容未限定 schema 的查询
- 候选优先级:数据库名/用户名同名 schema(存在性校验),否则仅在“唯一用户 schema 有表”场景兜底
- 避免连接污染:每次 Connect 重置探测结果,重连成功后替换连接并关闭旧连接
- 打包脚本:create-dmg 增加 --sandbox-safe,避免构建时自动弹出/打开挂载窗口
- 产物格式:强制 --format UDZO,并将 rw.*.dmg/UDRW 中间产物转换为可分发 DMG
- 校验门禁:增加 hdiutil verify,失败时保留 .app 便于排查,同时修正卷图标探测并补 ad-hoc 签名

* 🐛 fix(connection/redis): 修复 Redis URI 用户名处理导致认证失败

- Redis URI 解析回填 user 字段,兼容 redis://user:pass@... 与 redis://:pass@...
- 生成 URI 时按需输出 user/password,避免丢失用户名信息
- Redis 类型默认用户名置空,并在构建配置时清理历史默认 root
- 避免 go-redis 触发 ACL AUTH(user, pass) 导致 WRONGPASS
- refs #212

* 🔧 fix(release,ssh): 修复 SSH 误判连接成功并纠正 DMG 打包结构

- SSH 缓存 key 纳入认证指纹(password/keyPath),避免改错凭证仍复用旧连接/端口转发
- MySQL/MariaDB/Doris:SSH 隧道建立失败直接返回错误,不再回退直连导致测试误判成功
- 新增最小单测覆盖 SSH cache key 与 UseSSH 异常路径
- build-release.sh:create-dmg 使用 staging 目录作为 source,避免 DMG 根目录变成 Contents
- refs #213

* fix: KingBase 连接后自动设置 search_path,修复自定义 schema 下表查询报 relation does not exist 的问题 (#215)

* 🔧 fix(driver/kingbase,mongodb): 修复外置驱动事务引用与连接测试链路问题

- 金仓外置驱动链路增加表名与变更字段归一化,修复 ApplyChanges 场景下双引号转义异常导致的 SQL 语法错误
- 新增金仓公共标识符工具并复用到 kingbase_impl 与 optional_driver_agent_impl,统一处理多重转义、schema.table 拆分与引用规范
- 金仓代理连接后自动探测并设置 search_path,降低查询时必须手写 schema 前缀的概率
- MongoDB 连接参数改为显式 host/hosts 优先,避免被 URI 中 localhost 覆盖;代理链路保留目标地址不再改写为本地地址
- 连接测试增加前后端超时收敛与日志增强,避免长时间转圈;连接错误文案在未启用 TLS 时移除误导性的“SSL”前缀
- 统一日志级别为 INFO/WARN/ERROR,默认日志目录收敛到 ~/.GoNavi/Logs,并补充驱动构建脚本 build-driver-agents.sh

* 🔧 fix(release/sidebar): 统一跨平台UPX压缩并修复PG函数列表查询兼容性

- 构建脚本新增通用 UPX 压缩函数,覆盖 macOS、Linux、Windows 产物
- 本地打包改为强制压缩策略:未安装 upx、压缩失败或校验失败直接终止
- macOS 打包在签名前压缩 .app 主程序并执行 upx -t 校验
- Linux 打包在生成 tar.gz 前压缩可执行文件并执行 upx -t 校验
- GitHub Release 与测试构建流程补齐 macOS/Linux/Windows 的 upx 安装与压缩步骤
- PostgreSQL/PG-like 函数元数据查询增加多路兼容 SQL,修复函数列表不显示问题
- refs #221
- refs #222

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: 凌封 <49424247+fengin@users.noreply.github.com>
2026-03-12 17:40:35 +08:00
Syngnat
f101a59d32 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataGrid.tsx
2026-03-12 17:34:07 +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
Syngnat
482a7fce2e 🔧 fix(release/sidebar): 统一跨平台UPX压缩并修复PG函数列表查询兼容性
- 构建脚本新增通用 UPX 压缩函数,覆盖 macOS、Linux、Windows 产物
- 本地打包改为强制压缩策略:未安装 upx、压缩失败或校验失败直接终止
- macOS 打包在签名前压缩 .app 主程序并执行 upx -t 校验
- Linux 打包在生成 tar.gz 前压缩可执行文件并执行 upx -t 校验
- GitHub Release 与测试构建流程补齐 macOS/Linux/Windows 的 upx 安装与压缩步骤
- PostgreSQL/PG-like 函数元数据查询增加多路兼容 SQL,修复函数列表不显示问题
- refs #221
- refs #222
2026-03-12 17:30:16 +08:00
Syngnat
e6af5f966b 🔧 fix(driver/kingbase,mongodb): 修复外置驱动事务引用与连接测试链路问题
- 金仓外置驱动链路增加表名与变更字段归一化,修复 ApplyChanges 场景下双引号转义异常导致的 SQL 语法错误
- 新增金仓公共标识符工具并复用到 kingbase_impl 与 optional_driver_agent_impl,统一处理多重转义、schema.table 拆分与引用规范
- 金仓代理连接后自动探测并设置 search_path,降低查询时必须手写 schema 前缀的概率
- MongoDB 连接参数改为显式 host/hosts 优先,避免被 URI 中 localhost 覆盖;代理链路保留目标地址不再改写为本地地址
- 连接测试增加前后端超时收敛与日志增强,避免长时间转圈;连接错误文案在未启用 TLS 时移除误导性的“SSL”前缀
- 统一日志级别为 INFO/WARN/ERROR,默认日志目录收敛到 ~/.GoNavi/Logs,并补充驱动构建脚本 build-driver-agents.sh
2026-03-12 16:45:46 +08:00
凌封
eef973b7fc fix: KingBase 连接后自动设置 search_path,修复自定义 schema 下表查询报 relation does not exist 的问题 (#215) 2026-03-12 10:04:49 +08:00
Syngnat
d8b6b4ef8d 🔧 fix(release,ssh): 修复 SSH 误判连接成功并纠正 DMG 打包结构
- SSH 缓存 key 纳入认证指纹(password/keyPath),避免改错凭证仍复用旧连接/端口转发
- MySQL/MariaDB/Doris:SSH 隧道建立失败直接返回错误,不再回退直连导致测试误判成功
- 新增最小单测覆盖 SSH cache key 与 UseSSH 异常路径
- build-release.sh:create-dmg 使用 staging 目录作为 source,避免 DMG 根目录变成 Contents
- refs #213
2026-03-11 14:36:36 +08:00
Syngnat
4d58cc6e26 🐛 fix(connection/redis): 修复 Redis URI 用户名处理导致认证失败
- Redis URI 解析回填 user 字段,兼容 redis://user:pass@... 与 redis://:pass@...
- 生成 URI 时按需输出 user/password,避免丢失用户名信息
- Redis 类型默认用户名置空,并在构建配置时清理历史默认 root
- 避免 go-redis 触发 ACL AUTH(user, pass) 导致 WRONGPASS
- refs #212
2026-03-11 14:04:37 +08:00
Syngnat
b0bdddad9b 🔧 fix(release,db/kingbase_impl): 修复金仓默认 schema 并静默生成 DMG
- Kingbase:在 current_schema() 为 public 时探测候选 schema,并通过 DSN search_path 重连,兼容未限定 schema 的查询
- 候选优先级:数据库名/用户名同名 schema(存在性校验),否则仅在“唯一用户 schema 有表”场景兜底
- 避免连接污染:每次 Connect 重置探测结果,重连成功后替换连接并关闭旧连接
- 打包脚本:create-dmg 增加 --sandbox-safe,避免构建时自动弹出/打开挂载窗口
- 产物格式:强制 --format UDZO,并将 rw.*.dmg/UDRW 中间产物转换为可分发 DMG
- 校验门禁:增加 hdiutil verify,失败时保留 .app 便于排查,同时修正卷图标探测并补 ad-hoc 签名
2026-03-11 13:39:41 +08:00
Syngnat
a73ca36a32 🔧 fix(db/kingbase_impl): 修复标识符无条件加双引号导致SQL语法报错
- quoteKingbaseIdent 改为条件引用,仅对大写字母、保留字、特殊字符的标识符添加双引号
- 新增 kingbaseIdentNeedsQuote 判断标识符是否需要引用
- 新增 isKingbaseReservedWord 检测常见SQL保留字
- 补充 TestQuoteKingbaseIdent、TestKingbaseIdentNeedsQuote 单测覆盖各场景
- refs #176
2026-03-11 10:23:41 +08:00
Syngnat
92e9381fcc 🎨 style(DataGrid): 清理冗余代码与静态分析告警
- 类型重构:通过修正 React Context 的函数签名解决了 void 类型的链式调用错误
- 代码精简:利用 Nullish Coalescing (??) 优化组件配置项降级逻辑,剥离无意义的隐式 undefined 赋值
- 工具链适配:适配 IDE 拼写检查与 Promise strict rules,确保全文件零警
2026-03-11 09:19:49 +08:00
Syngnat
c4c7e379d1 feat(DataGrid): 增加表格列的动态显示与隐藏控制
- 字段面板新增列可见性筛选,支持列表内快速搜索、按需勾选与一键重置
- 新增持久化状态,自动记忆每张数据表的个性化隐藏列配置
- 优化数据提交链路,确保列的隐藏仅影响视图交互,不干扰增删改及复制功能
2026-03-10 16:45:35 +08:00
Syngnat
695713c779 feat(DataGrid): 实现数据视图列标题拖拽排序及顺序记忆
- 功能集成:接入 @dnd-kit 实现表头水平拖拽排序,支持多列位置灵活调整
- 持久化:Store 新增 tableColumnOrders 状态,支持按“连接-库-表”多维度记忆自定义列序
- 交互优化:重构表头 DOM 结构并消除内边距,实现“悬停手型、按住抓取”的精准指针反馈
- 性能提升:通过 React.memo 减少重渲染,并启用 will-change 硬件加速确保 60FPS 流畅度
- 稳定性:增强 Wails 环境接口调用的异常捕获,并补全前端独立开发环境下的 API Stub
2026-03-10 15:49:22 +08:00
Syngnat
6ad690cffc release/0.5.6 (#210)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

* feat: 统一筛选条件逻辑按钮宽度 (#201)

* 🐛 fix(oracle-query): 修复 Oracle 表数据分页 SQL 兼容问题 refs #196 (#202)

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166

* 🐛 fix(dameng): 修复达梦连接成功后数据库列表为空问题

- 调整达梦数据库列表获取策略,优先回退查询当前 schema 与当前用户
- 保留可见用户与 owner 聚合逻辑,兼容低权限账号场景
- 补充前端空列表提示与后端单元测试,降低排查成本
- close #203

*  feat(data-sync): 扩展跨库迁移链路并优化数据同步交互

- 统一同库同步与跨库迁移入口,补充模式区分与风险提示
- 扩展 ClickHouse 与 PG-like 双向迁移,并新增 PG-like、ClickHouse、TDengine 到 MongoDB 的迁移路由
- 完善 TDengine 目标端建表规划、回归测试与需求追踪文档
- refs #51

* 🐛 fix(connection): 修复新建连接时标签切换导致表单数据丢失

- 在 SSH 标签页测试连接时,基础信息的 host 回退为默认值 localhost
- 在基础信息标签页保存时,SSH 配置丢失
- 保存结果仅包含当前选中标签页的字段
- refs #208

* 🐛 fix(mongodb): 修复单机模式连接副本集实例时地址被替换为内网地址

- getURI 在 topology=single 时未设置 directConnection=true
- 驱动连接目标地址后自动跟随副本集成员发现,切换到 localhost:27017
- 在 mongodb_impl.go 和 mongodb_impl_v1.go 中添加 directConnection=true
- 仅在 topology 非 replica、无 replicaSet、非 SRV 时生效
- refs #205

* 🐛 fix(DataGrid): 修复虚拟滚动模式下右键菜单失效

- 行级和单元格级右键菜单的启用条件互斥,虚拟滚动模式下两者同时失效
- enableLargeResultOptimizedEditing 关闭了内联编辑但未回退启用行级菜单
- 修改 useContextMenuRow 和 enableRowContextMenu 条件,虚拟模式下启用行级菜单
- 更新 dataContextValue 的 useMemo 依赖数组
- refs #209

* 🐛 fix(sqlserver): 修复 SQL Server 查看表数据时分页语法和标识符引用错误

- quoteIdentPart 缺少 sqlserver 分支,标识符使用双引号而非 [bracket]
- buildPaginatedSelectSQL 增加 mssql 别名兜底,避免 dbType 变体导致走 default 分支
- 修复后标识符使用 [bracket],分页使用 OFFSET FETCH NEXT 语法
- refs #204

*  feat(DataGrid): 统一表格右键菜单交互体验

- 彻底移除功能较少的行级右键菜单 ContextMenuRow,统一使用功能更丰富的单元格右键菜单
- 优化虚拟滚动模式和只读模式下的渲染,支持触发单元格右键菜单
- 菜单展示自适应:在只读或不可修改数据的场景下自动隐藏「设置为 NULL」与「填充到选中行」等编辑项
- refs #209

* 🔧 fix(DataGrid): 默认开启虚拟滚动并修复多选单元格高亮失效问题

- 移除根据数据量和列数动态判断是否开启虚拟滚动的阈值限制,改为在表格视图下默认全量开启,彻底解决卡顿问题
- 修复 `updateCellSelection` 在查找坐标节点时硬编码 `td` 选择器的问题,改为精确匹配 `.ant-table-cell`,兼容虚拟滚动时的 `div` 渲染模式
- 修复因透明窗口特性导致的 `transparent !important` 把高亮样式强行覆盖的问题,拔高了多选状态下背景与边框 CSS 的优先级
- 解决单元格内外多重属性嵌套导致的高亮右侧留白现象,使得高亮框完全贴合表格单元格边缘
- 适配主题色响应(暗黑模式使用黄色深色高亮,白昼模式使用默认蓝色高亮)

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: TSS <266256496+Zencok@users.noreply.github.com>
2026-03-10 11:26:02 +08:00
Syngnat
ca49b37dc7 🔧 fix(DataGrid): 默认开启虚拟滚动并修复多选单元格高亮失效问题
- 移除根据数据量和列数动态判断是否开启虚拟滚动的阈值限制,改为在表格视图下默认全量开启,彻底解决卡顿问题
- 修复 `updateCellSelection` 在查找坐标节点时硬编码 `td` 选择器的问题,改为精确匹配 `.ant-table-cell`,兼容虚拟滚动时的 `div` 渲染模式
- 修复因透明窗口特性导致的 `transparent !important` 把高亮样式强行覆盖的问题,拔高了多选状态下背景与边框 CSS 的优先级
- 解决单元格内外多重属性嵌套导致的高亮右侧留白现象,使得高亮框完全贴合表格单元格边缘
- 适配主题色响应(暗黑模式使用黄色深色高亮,白昼模式使用默认蓝色高亮)
2026-03-10 11:17:03 +08:00
Syngnat
c8c0c5f20a feat(DataGrid): 统一表格右键菜单交互体验
- 彻底移除功能较少的行级右键菜单 ContextMenuRow,统一使用功能更丰富的单元格右键菜单
- 优化虚拟滚动模式和只读模式下的渲染,支持触发单元格右键菜单
- 菜单展示自适应:在只读或不可修改数据的场景下自动隐藏「设置为 NULL」与「填充到选中行」等编辑项
- refs #209
2026-03-10 10:58:27 +08:00
Syngnat
d61d7ec39b 🐛 fix(sqlserver): 修复 SQL Server 查看表数据时分页语法和标识符引用错误
- quoteIdentPart 缺少 sqlserver 分支,标识符使用双引号而非 [bracket]
- buildPaginatedSelectSQL 增加 mssql 别名兜底,避免 dbType 变体导致走 default 分支
- 修复后标识符使用 [bracket],分页使用 OFFSET FETCH NEXT 语法
- refs #204
2026-03-10 10:50:16 +08:00
Syngnat
e964c8ecf8 🐛 fix(DataGrid): 修复虚拟滚动模式下右键菜单失效
- 行级和单元格级右键菜单的启用条件互斥,虚拟滚动模式下两者同时失效
- enableLargeResultOptimizedEditing 关闭了内联编辑但未回退启用行级菜单
- 修改 useContextMenuRow 和 enableRowContextMenu 条件,虚拟模式下启用行级菜单
- 更新 dataContextValue 的 useMemo 依赖数组
- refs #209
2026-03-10 10:42:34 +08:00
Syngnat
7644462180 🐛 fix(mongodb): 修复单机模式连接副本集实例时地址被替换为内网地址
- getURI 在 topology=single 时未设置 directConnection=true
- 驱动连接目标地址后自动跟随副本集成员发现,切换到 localhost:27017
- 在 mongodb_impl.go 和 mongodb_impl_v1.go 中添加 directConnection=true
- 仅在 topology 非 replica、无 replicaSet、非 SRV 时生效
- refs #205
2026-03-10 10:32:31 +08:00
Syngnat
3bd02e2e09 🐛 fix(connection): 修复新建连接时标签切换导致表单数据丢失
- 在 SSH 标签页测试连接时,基础信息的 host 回退为默认值 localhost
- 在基础信息标签页保存时,SSH 配置丢失
- 保存结果仅包含当前选中标签页的字段
- refs #208
2026-03-10 10:27:13 +08:00
Syngnat
22bd1c4c28 Release/0.5.5 (#207)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

* feat: 统一筛选条件逻辑按钮宽度 (#201)

* 🐛 fix(oracle-query): 修复 Oracle 表数据分页 SQL 兼容问题 refs #196 (#202)

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166

* 🐛 fix(dameng): 修复达梦连接成功后数据库列表为空问题

- 调整达梦数据库列表获取策略,优先回退查询当前 schema 与当前用户
- 保留可见用户与 owner 聚合逻辑,兼容低权限账号场景
- 补充前端空列表提示与后端单元测试,降低排查成本
- close #203

*  feat(data-sync): 扩展跨库迁移链路并优化数据同步交互

- 统一同库同步与跨库迁移入口,补充模式区分与风险提示
- 扩展 ClickHouse 与 PG-like 双向迁移,并新增 PG-like、ClickHouse、TDengine 到 MongoDB 的迁移路由
- 完善 TDengine 目标端建表规划、回归测试与需求追踪文档
- refs #51

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: TSS <266256496+Zencok@users.noreply.github.com>
2026-03-09 17:36:52 +08:00
Syngnat
0daf702d25 feat(data-sync): 扩展跨库迁移链路并优化数据同步交互
- 统一同库同步与跨库迁移入口,补充模式区分与风险提示
- 扩展 ClickHouse 与 PG-like 双向迁移,并新增 PG-like、ClickHouse、TDengine 到 MongoDB 的迁移路由
- 完善 TDengine 目标端建表规划、回归测试与需求追踪文档
- refs #51
2026-03-09 17:22:26 +08:00
Syngnat
058c74e49a 🐛 fix(dameng): 修复达梦连接成功后数据库列表为空问题
- 调整达梦数据库列表获取策略,优先回退查询当前 schema 与当前用户
- 保留可见用户与 owner 聚合逻辑,兼容低权限账号场景
- 补充前端空列表提示与后端单元测试,降低排查成本
- close #203
2026-03-09 11:02:00 +08:00
杨国锋
b85c7529ec feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路
- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166
2026-03-08 18:42:27 +08:00
杨国锋
e521d2125f feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路
- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
2026-03-08 18:41:05 +08:00
辣条
450fdfa59e 🐛 fix(oracle-query): 修复 Oracle 表数据分页 SQL 兼容问题 refs #196 (#202) 2026-03-08 00:42:48 +08:00
TSS
c87b15b22a feat: 统一筛选条件逻辑按钮宽度 (#201) 2026-03-07 21:45:26 +08:00
Syngnat
89c81823bc Release/0.5.4 (#199)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
2026-03-07 17:15:30 +08:00
Syngnat
797ba27d20 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	.github/workflows/test-build-all-platforms.yml
#	frontend/src/components/ConnectionModal.tsx
#	internal/db/query_value.go
#	internal/db/query_value_test.go
2026-03-07 17:10:17 +08:00
Syngnat
ed1f40e04a ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化
- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明
2026-03-07 17:01:49 +08:00
辣条
2b190e564f feat(multi-db,query,ci): 增强多数据源兼容性、查询体验与全平台测试构建流程 (#197)
* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

* fix(ci): 修复全平台测试包 artifact 命名冲突

* fix(data-viewer): 保持切换标签后的表格滚动位置

* fix(datetime-display): 修复零日期显示被错误转换 refs #189

* fix(window-scale): 修复任务栏切换后字体异常放大 refs #193

* fix(data-grid-scroll): 修复数据区触摸板横向滚动失效 refs #175

* fix(db-query-value): 清理 query_value 合并冲突并保持零日期处理

* chore(ci): 删除旧的 macOS 单平台测试工作流
2026-03-07 13:40:50 +08:00
github-actions[bot]
1c050aefd0 🔁 chore(sync): 回灌 main 到 dev (#195)
* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>
2026-03-06 17:36:28 +08:00
辣条
75a5a322e0 - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)
* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流
2026-03-06 17:32:14 +08:00
Syngnat
61d6197fe3 Merge branch 'fix/editor-sql-error-20260306-ygf' into dev 2026-03-06 14:57:06 +08:00
Syngnat
6157161293 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并
- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰
2026-03-06 14:56:43 +08:00
github-actions[bot]
0f843a7dcf 🔁 chore(sync): 回灌 main 到 dev (#192)
* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>
2026-03-06 14:31:15 +08:00
Syngnat
fb65b553e9 Release/0.5.3 (#191) 2026-03-06 14:30:07 +08:00
Syngnat
1a5bf79dd3 Merge branch 'fix/editor-sql-error-20260306-ygf' into dev 2026-03-06 14:27:39 +08:00
Syngnat
dea096d4c2 feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名 2026-03-06 14:26:08 +08:00
github-actions[bot]
04f8b266d3 - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)
* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
2026-03-06 13:57:11 +08:00
辣条
b53227cb15 - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)
* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157
2026-03-06 13:55:13 +08:00
Syngnat
0246d7fae5 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	CONTRIBUTING.md
#	CONTRIBUTING.zh-CN.md
2026-03-06 11:17:18 +08:00
Syngnat
4aa177ed37 🔧 chore(branch-sync): 补充 main 回灌 dev 权限前置条件并增加失败告警 2026-03-06 11:05:27 +08:00
Syngnat
4f5a7bd94b feat(branch-sync): 新增 main 回灌 dev 自动同步工作流并同步中英文贡献指南 2026-03-06 09:40:49 +08:00
Syngnat
00c6f9871f Release/0.5.2 (#183) 2026-03-05 17:17:03 +08:00
Syngnat
6a4b397ecc Merge branch 'feature/suport-clickhouse-20260227-ygf' into dev 2026-03-05 17:15:16 +08:00
Syngnat
3973038aea Merge branch 'main' into dev
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataGrid.tsx
#	frontend/src/components/DataViewer.tsx
#	frontend/src/components/QueryEditor.tsx
#	internal/app/methods_driver.go
#	internal/app/methods_file_export_test.go
#	internal/db/clickhouse_impl.go
#	internal/db/oracle_impl.go
#	internal/redis/redis_impl.go
2026-03-05 17:11:41 +08:00
辣条
71b41459e7 feat(mongodb,connection-tree,query-editor,sidebar,sqlserver,table-designer,ssl): 完成 MongoDB v1/v2 驱动切换与复制连接,增强快捷键/搜索/筛选与设计表体验,并修复 SQLServer、SSL 及连接稳定性问题 (#180)
* feat(mongodb-driver,connection-tree): 支持 MongoDB v1/v2 切换并新增复制连接

* fix(mongodb-query): 修复 MongoDB 筛选不生效并兼容 shell 语法执行

refs #153

* fix(query-editor): 修复 SQLServer 自动补全回车重复 dbo 前缀

refs #159

* fix(sqlserver-table-designer): 修复设计表读取列时错误使用 schema 作为数据库名

refs #156

* feat(shortcuts): 增加快捷键设置并支持 SQL 执行/侧边栏搜索

refs #158

* fix(sidebar-search): 优化范围搜索匹配与交互

refs #158

* fix(filter,connection-recovery): 保持筛选状态并修复连接失效卡死

refs #165

同步修复连接失效后侧栏持续转圈、断开后无法恢复的问题

* feat(table-designer): 统一设计表界面风格并优化字段新增交互

- 统一设计表页面与数据面板的视觉风格,覆盖工具栏、Tabs、表格与编辑区域

- 移除默认硬边框,改为透明背景与细分隔线,提升整体观感一致性

- 添加字段后自动滚动到新行并高亮,且自动聚焦输入框

- 新增" 在选中字段后添加\,支持按选中字段位置插入字段

* feat(data-grid-filter): 筛选字段支持快捷搜索

- 在筛选条件字段下拉启用可搜索(showSearch)

- 支持字段名大小写不敏感模糊匹配

- 表字段较多时可快速定位目标字段,减少下拉查找耗时

refs #171

* fix(db-ssl): 支持多数据源 SSL/TLS 连接并补齐达梦证书配置

refs #167

* fix(sidebar): 修复数据库加载时 null.map 导致表加载失败

* fix(query-editor): 合并运行按钮并保留 SQL 停止执行入口
2026-03-05 16:52:06 +08:00
ljyf5593
69942bb77e * feat: SQL执行中时,增加取消执行功能 (#172)
Co-authored-by: liujie <469282686@qq.com>
2026-03-05 15:28:34 +08:00
ljyf5593
f372b20a68 fix: 修复连接导出功能生成空JSON数组的问题 (#169)
Co-authored-by: liujie <469282686@qq.com>
2026-03-05 12:01:58 +08:00
Toskysun
e6da986927 feat: 新增 HTML 导出功能 (#164)
- 后端:在 writeRowsToFile 中新增 html 分支
- 后端:实现 writeRowsToHTML 函数,生成包含内嵌 CSS 的独立 HTML 文件
- 后端:实现 formatExportHTMLCell 函数,进行 HTML 转义和换行处理
- 后端:新增测试用例验证 XSS 转义、样式存在、换行处理、空值显示
- 前端:在 DataGrid 所有导出菜单中新增 HTML 选项(右键菜单、工具栏、单元格菜单)
- 前端:在 Sidebar 表节点右键菜单中新增 HTML 选项
- 样式:响应式表格设计,支持斑马纹、悬停效果、表头吸顶
- 安全:所有用户数据经过 HTML 转义,防止 XSS 攻击
2026-03-04 17:46:18 +08:00
Toskysun
4570516678 feat: 表筛选结果一键导出功能 (#161)
* 🔧 chore(gitignore): 忽略 AI 上下文文档避免版本控制污染

添加 CLAUDE.md 及其子目录变体到 .gitignore,防止 AI 辅助开发过程中生成的临时上下文文件被意外提交到仓库。

- 忽略根目录 CLAUDE.md
- 忽略所有子目录下的 CLAUDE.md 文件

* feat: 表筛选结果一键导出功能

- 新增表浏览模式下筛选结果的导出功能
- DataViewer 生成包含筛选条件的完整 SQL
- DataGrid 动态显示分组导出菜单(筛选结果 + 全表)
- 支持 CSV、Excel、JSON、Markdown 四种格式
- 添加未提交修改的警告提示
- 复用现有 ExportQuery 后端方法,无需后端修改

实现细节:
- 使用 buildWhereSQL 和 buildOrderBySQL 构建 SQL
- 支持 MySQL/MariaDB 的 sort buffer 优化
- 分组菜单设计避免用户误操作
- 导出文件名包含 _filtered 后缀

关闭 #issue
2026-03-04 13:54:51 +08:00
凌封
8c91d8929b Feature/add aibook (#160)
* feat: 增加技术圈连接

* feat: 增强水平滚动条在大量数据下支持鼠标滚轮 (refs #146)

* feat: 新增连接标签分组功能,支持创建/编辑/删除标签、拖拽归组、右键移至标签 refs #148
2026-03-04 11:50:34 +08:00
Syngnat
786835c9bc 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口
- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档
2026-03-03 15:49:58 +08:00
Syngnat
f2fc7cbd05 Release/0.5.1 (#152)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
2026-03-03 15:25:25 +08:00
Syngnat
462ca57907 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链
- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤
2026-03-03 15:22:02 +08:00
Syngnat
4bfdb2cb6c Release/0.5.1 (#150)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致
2026-03-03 15:06:16 +08:00
Syngnat
6918b56ed9 Merge remote-tracking branch 'origin/feature/suport-clickhouse-20260227-ygf' into feature/suport-clickhouse-20260227-ygf 2026-03-03 14:58:48 +08:00
Syngnat
1afb8850ad 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路
- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致
2026-03-03 14:58:37 +08:00
Syngnat
3284eeba17 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路
- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致
2026-03-03 14:58:19 +08:00
Syngnat
494484eb92 Release/0.5.1 (#149)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
2026-03-03 14:35:17 +08:00
Syngnat
6156884455 Merge branch 'feature/suport-clickhouse-20260227-ygf' into dev1 2026-03-03 14:23:04 +08:00
Syngnat
a54b8906a3 Revert "feat: 增加关于内容技术圈"
This reverts commit 9a684cd82c.
2026-03-03 14:18:53 +08:00
Syngnat
f477feab2f 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现
- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变
2026-03-03 14:11:35 +08:00
Syngnat
e76e174bfe feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复
- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147
2026-03-03 13:49:31 +08:00
Syngnat
b904c0b107 feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换
- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145
2026-03-03 09:42:49 +08:00
Syngnat
c02e7c12e8 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式
- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容
2026-03-02 17:45:09 +08:00
Syngnat
a87c801e66 ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示
- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力
2026-03-02 17:26:40 +08:00
Syngnat
7f00139847 ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现
- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144
2026-03-02 16:34:09 +08:00
Syngnat
78c5351399 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导
- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141
2026-03-02 15:58:58 +08:00
Syngnat
e2acfa51eb Merge pull request #143 from fengin/feature/addAibook
feat: 增加关于内容技术圈
2026-03-02 14:46:03 +08:00
fengin
9a684cd82c feat: 增加关于内容技术圈 2026-03-02 14:42:42 +08:00
Syngnat
e3b142053f 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失
- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142
2026-03-02 14:40:59 +08:00
Syngnat
3ca898a950 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径
- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性
2026-03-02 14:18:44 +08:00
Syngnat
84688e995a 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验
- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例
2026-03-02 11:46:59 +08:00
Syngnat
4d0940636d feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示
- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐
2026-03-02 11:10:48 +08:00
Syngnat
26b79adc5f 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容
- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报
2026-03-02 10:49:23 +08:00
Syngnat
90aa3561be Merge pull request #140 from Syngnat/release/0.5.0
Release/0.5.0
2026-02-28 15:57:40 +08:00
Syngnat
ec59023736 📝 docs(i18n-readme): 建立README中英双文档结构并统一能力描述
- 英文主 README 覆盖项目定位、性能特性与安装说明
- 中文 README 覆盖同等信息密度,避免内容断层
- 中英文文档统一支持数据源表格与驱动模式说明
- 完成语言切换链接配置,便于读者快速切换
2026-02-28 15:54:46 +08:00
Syngnat
4a96cb93d2 🎨 style(connection-modal): 优化新建连接弹窗尺寸与分类栏视觉一致性
- 统一调整弹窗 step1/step2 尺寸参数,改善布局观感
- 增加 step1 内容区最小高度,减少拥挤感
- 分类栏分割线改为主题感知颜色,消除深色模式下突兀白线
2026-02-28 15:36:26 +08:00
Syngnat
4c322db9d0 💥 breaking(driver-manager): 统一 Doris 驱动命名并移除 diros 历史包兼容
- 前后端统一 Doris 展示与连接命名,修复 diros 拼写问题
- GitHub Actions 驱动产物改为 doris-driver-agent-* 命名
- 构建流程保持内部 gonavi_diros_driver 映射,避免构建链路中断
- 驱动安装/下载/解压链路仅识别 doris 资产名,不再兼容 diros 历史包
- 内置与文档 manifest 下载地址统一为 builtin://activate/doris
- close #132
2026-02-28 15:27:58 +08:00
Syngnat
ed18c8285f 🐛 fix(db-compat): 修复PG系建表语句兼容并优化DuckDB大表总数统计
- 统一 DBShowCreateTable 与导出链路的 DDL 兜底逻辑,修复 Kingbase/Postgres 占位语句问题
- 增强 custom driver 到 postgres/kingbase/highgo/vastbase 的映射并补充回归测试
- DuckDB 关闭自动后台 COUNT(*),避免大文件场景翻页与查询卡顿
- 新增近似总数展示、手动精确统计与取消统计交互
- 新增 DBQueryIsolated 独立连接查询能力并同步前端 wailsjs 接口
- refs #136
2026-02-28 15:00:13 +08:00
Syngnat
5f8cedabd8 🐛 fix(update-proxy): 修复本地代理下检查更新 TLS 证书未知颁发者失败
- 在全局代理 HTTP 传输层增加本地回环代理兼容回退能力
- 回退触发条件限制为 unknown authority 且仅 GET/HEAD 请求
- 保留默认 TLS 校验策略并输出告警日志便于审计定位
- refs #139
2026-02-28 13:55:42 +08:00
Syngnat
20923989b9 🐛 fix(connection-modal): 修复透明暗色模式下 SSH/代理配置区块白底问题
- 为连接弹窗接入主题与透明度状态,按模式动态计算区块背景
- 将 SSH 与代理配置容器统一替换为自适应样式并补齐边框层次
- 保持连接测试与保存逻辑不变,仅修复显示层
2026-02-28 13:37:19 +08:00
Syngnat
210106cde7 🐛 fix(driver-modal): 修复驱动日志弹窗在透明暗色主题下对比度异常
- 将日志内容容器改为 dark/light 双模式自适应样式
- 使用全局外观透明度参数参与日志背景渲染
- 保持驱动安装与日志采集逻辑不变,仅修复显示层
2026-02-28 13:27:49 +08:00
Syngnat
87aac277ec 🎨 style(redis-viewer): 对齐 Redis 拖拽分割条与侧边栏宽度调整样式
- 分割条宽度调整为与 host 侧边栏一致
- 分割条背景统一为 transparent,去除 hover 强对比效果
- 保持拖拽命中区与提示文案,提升整体样式一致性
2026-02-28 12:53:06 +08:00
Syngnat
4de3f408c5 🐛 fix(redis-scan): 修复大数据量下命名空间加载不完整问题
- 前后端 Redis SCAN 游标统一为字符串传递,避免 Number 精度丢失
- RedisScanKeys 增加 string/number 游标兼容解析,异常游标降级并告警
- 新增游标解析单测
- refs #135
2026-02-28 12:32:22 +08:00
Syngnat
439625a49c 🔧 fix(duckdb-pagination): 修复 DuckDB 总数异常导致分页不可用
- 修正 DataViewer 在 hasMore 与 totalKnown 冲突时的分页状态处理
- 增强 DuckDB COUNT(*) 结果解析,兼容字段名与数值类型差异
- 将分页兜底逻辑收敛为 DuckDB 专用,避免影响其他数据库
- 修复 total=0 时分页文案显示异常
- refs #136
2026-02-28 12:14:34 +08:00
Syngnat
884d72f3d3 ♻️ refactor(clickhouse): 使用结构化 Options 替代 DSN 连接构造
- 用 buildClickHouseOptions 收敛连接参数生成逻辑
- 将连接入口改为 clickhouse.OpenDB(Options)
- 清理 DSN 中的 write_timeout/read_timeout/dial_timeout 透传路径
- 同步重写 ClickHouse 相关测试断言
- refs #138
2026-02-28 11:56:59 +08:00
Syngnat
98c1600e13 feat(driver-manager): 增强驱动管理本地导入并统一滚动交互体验
- 新增驱动目录批量导入入口,支持覆盖已安装开关与去重处理
- 行内本地导入聚焦单文件场景,目录导入与单文件导入流程统一
- 已安装驱动版本选择锁定,避免安装后误改版本
- 补充驱动下载网络检测与日志可见性,提升问题定位效率
- 重构驱动管理横向滚动条实现,修复双滚动条/消失/位置异常问题
2026-02-28 11:33:21 +08:00
Syngnat
eb594b7741 Merge pull request #134 from Syngnat/release/0.4.9
release/0.4.9
2026-02-27 17:39:17 +08:00
Syngnat
587ed3444b ️ perf(ci-assets): 完整化驱动打包资产覆盖范围
- 将 clickhouse 纳入可选驱动构建数组
- 提升发布资产完整性与可用性
- 减少驱动安装阶段因资产缺失导致的失败
2026-02-27 17:37:40 +08:00
Syngnat
e366a61910 Merge pull request #133 from Syngnat/release/0.4.9
Release/0.4.9
2026-02-27 17:24:09 +08:00
Syngnat
5986b71c4d ️ perf(redis-datagrid): 优化大数据场景下搜索与右键菜单响应性能
- RedisViewer 引入树节点轻量化、虚拟滚动与大 keyspace 性能模式,降低 Key 列表卡顿
- Redis 搜索按模式分级加载并增加请求乱序保护,避免搜索结果回写抖动
- Redis 后端 ScanKeys 为搜索模式增加时间预算与轮次上限,优先返回可继续分页结果
- DataGrid 稳定 Context/rowSelection/onRow 引用并增加 shouldCellUpdate,减少右键触发全表重渲染
2026-02-27 17:22:38 +08:00
Syngnat
cb18bc3067 feat(driver-proxy): 新增ClickHouse数据源并提供全局代理独立入口
- 新增 ClickHouse 可选驱动实现与 optional-driver-agent provider,补齐驱动注册与清单配置
- 补齐 ClickHouse 连接与 SQL 适配:连接默认端口/用户、LIMIT、标识符引用、只读编辑限制
- 新增全局代理后端能力与前端持久化配置,更新检查和驱动网络请求统一走代理客户端
2026-02-27 16:39:13 +08:00
Syngnat
d676ac9084 Merge remote-tracking branch 'origin/main' 2026-02-27 14:25:02 +08:00
Syngnat
7fcbcb2471 Merge branch 'release/0.4.8' 2026-02-27 14:24:44 +08:00
Syngnat
c680e50e74 Merge pull request #131 from Syngnat/release/0.4.8
Release/0.4.8
2026-02-27 14:24:16 +08:00
Syngnat
9685102229 feat(sidebar-batch-table): 批量操作表新增对象筛选与作用范围控制
- 批量操作表弹窗新增关键字筛选(忽略大小写包含匹配)
- 新增类型筛选(全部对象/仅表/仅视图)
- 新增勾选作用范围切换(当前筛选结果/全部对象)
- 全选、取消全选、反选逻辑按作用范围执行
- 筛选区域展示命中计数与无匹配空态提示
- refs #130
2026-02-27 14:21:14 +08:00
Syngnat
3505b4428a Merge remote-tracking branch 'origin/fix/windows-issue-20260226-ygf' into fix/windows-issue-20260226-ygf 2026-02-27 13:57:04 +08:00
Syngnat
9ebdf7f053 feat(appearance): 新增启动时全屏开关并支持启动窗口状态自动应用
- 在外观设置中提供用户可控的启动全屏偏好项
- 持久化保存用户偏好,重启后自动恢复
- 启动阶段按偏好自动执行全屏,失败时回退最大化
- 保持现有标题栏窗口操作行为不变
- refs #129
2026-02-27 13:56:36 +08:00
Syngnat
9ad852c10b 🐛 fix(redis-viewer): 修复大数据量场景 Key 加载不完整问题
- 后端 ScanKeys 改为按目标数量多轮聚合扫描,不再只依赖单轮返回结果
- 新增扫描目标数/步长/轮次上限,避免扫描过少或无限循环
- 前端首屏加载、搜索、刷新统一按较大批次请求,避免回退到几百条
- 加载更多改为按固定批次继续拉取并保持去重合并
- refs #129
2026-02-27 13:56:36 +08:00
Syngnat
2a8fff4d93 feat(driver-manager): 增强驱动管理网络诊断与本地导入能力
- 新增 CheckDriverNetworkStatus,探测 GitHub API/Release/Go Proxy 可达性并返回代理环境信息。
- 驱动管理弹窗新增网络检测结果、驱动目录复用说明、本地导入入口与日志查看。
- 操作日志支持同签名进度覆盖更新,下载百分比动态刷新,不再重复新增日志行。
- 修正弹窗滚动行为与表格滚动体验。
- refs #128
2026-02-27 13:56:36 +08:00
Syngnat
eca560b4e5 🐛 fix(data-grid): 修复单元格编辑器拖拽越界不自动滚动
- 在 DataGrid 拖拽选区流程新增边缘自动滚动能力(横向+纵向)
- 拖拽过程中增加鼠标位置跟踪并通过 RAF 循环驱动滚动
- 通过 elementFromPoint 兜底命中单元格,保证越界拖拽时选区持续更新
- 在 mouseup、模式切换和退出编辑器时统一清理 RAF 与拖拽状态
- refs #127
2026-02-27 13:56:36 +08:00
Syngnat
2f475dddc0 🐛 fix(windows-upgrade): 修复Windows升级后连接列表丢失问题
- 启动参数新增固定 WebviewUserDataPath 到 %APPDATA%/GoNavi/WebView2
- 首次启动自动迁移历史 WebView 数据目录
- 保留现有存储键,避免破坏已落盘配置
- 前端持久化读取增加历史结构兼容
- refs #125
2026-02-27 13:56:35 +08:00
Syngnat
ad9d8a12be feat(appearance): 新增启动时全屏开关并支持启动窗口状态自动应用
- 在外观设置中提供用户可控的启动全屏偏好项
- 持久化保存用户偏好,重启后自动恢复
- 启动阶段按偏好自动执行全屏,失败时回退最大化
- 保持现有标题栏窗口操作行为不变
- refs #129
2026-02-27 13:55:37 +08:00
Syngnat
095b22951e 🐛 fix(redis-viewer): 修复大数据量场景 Key 加载不完整问题
- 后端 ScanKeys 改为按目标数量多轮聚合扫描,不再只依赖单轮返回结果
- 新增扫描目标数/步长/轮次上限,避免扫描过少或无限循环
- 前端首屏加载、搜索、刷新统一按较大批次请求,避免回退到几百条
- 加载更多改为按固定批次继续拉取并保持去重合并
- refs #129
2026-02-27 13:26:28 +08:00
Syngnat
7350a011e3 feat(driver-manager): 增强驱动管理网络诊断与本地导入能力
- 新增 CheckDriverNetworkStatus,探测 GitHub API/Release/Go Proxy 可达性并返回代理环境信息。
- 驱动管理弹窗新增网络检测结果、驱动目录复用说明、本地导入入口与日志查看。
- 操作日志支持同签名进度覆盖更新,下载百分比动态刷新,不再重复新增日志行。
- 修正弹窗滚动行为与表格滚动体验。
- refs #128
2026-02-27 12:29:54 +08:00
Syngnat
53b5802add 🐛 fix(data-grid): 修复单元格编辑器拖拽越界不自动滚动
- 在 DataGrid 拖拽选区流程新增边缘自动滚动能力(横向+纵向)
- 拖拽过程中增加鼠标位置跟踪并通过 RAF 循环驱动滚动
- 通过 elementFromPoint 兜底命中单元格,保证越界拖拽时选区持续更新
- 在 mouseup、模式切换和退出编辑器时统一清理 RAF 与拖拽状态
- refs #127
2026-02-27 10:57:05 +08:00
Syngnat
54e7077317 🐛 fix(windows-upgrade): 修复Windows升级后连接列表丢失问题
- 启动参数新增固定 WebviewUserDataPath 到 %APPDATA%/GoNavi/WebView2
- 首次启动自动迁移历史 WebView 数据目录
- 保留现有存储键,避免破坏已落盘配置
- 前端持久化读取增加历史结构兼容
2026-02-27 10:45:57 +08:00
Syngnat
4cb5071b0b Merge pull request #124 from Syngnat/release/0.4.7
Release/0.4.7
2026-02-27 09:51:49 +08:00
Syngnat
96de46cf1e 🐛 fix(postgres-connection): 修复无postgres库时连接失败并支持默认连接库配置
- PostgreSQL 空 database 时按 postgres、template1、用户名同名库回退连接
- 移除后端对 database=postgres 的硬编码写死逻辑
- 连接弹窗新增 PostgreSQL 默认连接数据库(可选)配置项
- refs #120
2026-02-27 09:49:47 +08:00
Syngnat
7d5592d8d9 feat(db): 数据库连接新增 SOCKS5/HTTP 代理能力并兼容 SRV/SSH 场景
- 后端 ConnectionConfig 增加代理配置并完成规范化处理
- 普通 TCP 数据源通过本地转发接入代理
- MongoDB 使用 Dialer 支持代理连接(含 SRV)
- 前端连接配置新增代理 UI、字段清洗与数据回填
- refs #122
2026-02-27 09:31:24 +08:00
Syngnat
d0ba8822f3 feat(driver-manager): 完善驱动多版本安装与版本级包大小动态展示
- 新增驱动版本列表能力,支持按版本选择安装
- 新增按版本查询安装包大小接口,前端切换版本后动态刷新
- 增加版本大小查询回退策略(tag 未命中时回退 latest)
- 优化版本下拉加载链路并增加后台预热,降低首次展开等待
2026-02-27 08:37:35 +08:00
Syngnat
140db73ef4 🐛 fix(startup-release): 修复 Win/mac 发布包白屏与无响应问题
- 移除 v0.4.7 引入的高风险 chunk 拆分配置
- 恢复 main.tsx 的 Monaco 稳定初始化方式
- 调整 release workflow 的 macOS codesign 参数避免双击无反应
2026-02-26 15:21:36 +08:00
Syngnat
7ae5341c1c Merge pull request #121 from Syngnat/release/0.4.7
Release/0.4.7
2026-02-26 14:28:10 +08:00
Syngnat
bec5013a44 🐛 fix(update-windows): 修复自动更新脚本变量转义导致TARGET语法错误
- 将 buildWindowsScript 改为模板占位符替换,避免 fmt.Sprintf 吞掉批处理百分号
- 修正 for %%I/%%F 语法,消除“此时不应有 TARGET~nxI”报错
- 保留原有更新重试与日志流程,不改变下载与安装主链路
- refs #112
2026-02-26 14:23:36 +08:00
Syngnat
66a3113fa8 🐛 fix(datagrid-mysql): 修复MySQL行编辑时datetime空值提交失败
- 前端按列类型归一化 temporal 字段,INSERT 空值跳过字段、UPDATE 空值转 NULL
- 后端 ApplyChanges 增加 temporal 字段兜底,避免空字符串写入 datetime/timestamp
- 新增全默认值插入路径,兼容 CURRENT_TIMESTAMP 等默认值场景
- refs #113
2026-02-26 14:13:27 +08:00
Syngnat
a435d62d3b feat(connection-modal): 新增SSH私钥文件浏览选择能力
- 新增私钥文件选择入口,减少手动输入路径错误
- 复用系统文件对话框并自动回填私钥路径
- 保留手动输入作为兜底方式
- refs #119
2026-02-26 13:57:50 +08:00
Syngnat
50d92d3184 🐛 fix(backup-export): 修复批量备份未区分视图与表导致导出失败
- 批量操作弹窗按“表/视图”分组展示并支持混合勾选
- 批量导出改为对象集合传参,统一结构/数据导出入口
- SQL 导出链路新增视图识别与排序,避免将视图当表处理
- 增加多方言视图 DDL 查询与回退逻辑,规避 create statement not found
- 视图数据导出阶段自动跳过并追加说明注释
- refs #117
2026-02-26 13:45:17 +08:00
Syngnat
91658848c9 🔧 fix(frontend): 修复表设计能力门禁并优化构建分包策略
- 修复触发器分组进入设计页时误设只读,恢复索引/外键页增删改按钮显示
  - 重构 TableDesigner 数据源方言识别,移除 MySQL 与固定方言白名单硬限制
  - 按能力控制索引/外键/表备注编辑入口,并补充多方言 DDL 生成与通用兜底
  - 收敛已知不支持场景:sqlite/duckdb/tdengine 禁用外键编辑,sqlite 禁用表备注编辑
  - Monaco 改为按需 worker(editor/json)并补齐 vite 类型声明,避免构建类型报错
  - 细化 Vite manualChunks(antd/monaco 子模块拆分),消除 >500k chunk 告警
  - refs #115
2026-02-26 12:08:07 +08:00
Syngnat
01940e74b7 🐛 fix(release.yml): 修复构建脚本空标签数组未绑定导致失败
- Build 步骤改为有标签/无标签分支执行
- 避免 set -u 下 TAG_ARGS[@] 报 unbound variable
- 保持 webkit2_41 标签构建路径不变
2026-02-14 15:51:07 +08:00
Syngnat
30210bc40e Merge pull request #111 from Syngnat/release/0.4.6
Release/0.4.6
2026-02-14 15:47:38 +08:00
Syngnat
fda30539b6 🐛 fix(highgo): 修复海量数据源复制表结构仅返回注释
- 识别 HighGo 占位建表语句
- 通过 GetColumns 生成包含字段与主键的建表SQL
- 避免右键复制表结构出现空字段
- refs #99
2026-02-14 15:45:02 +08:00
Syngnat
1ba68fcbfe 🐛 fix(release): 修复 Debian 13 Linux 产物 WebKitGTK 依赖不兼容
- Linux Release 增加 WebKitGTK 4.1 变体(-WebKit41),保留 4.0 产物
- CI 按 WebKit 版本安装依赖,并为 Wails 注入 webkit2_41 构建标签
- 完善驱动代理可执行文件路径校验错误提示(区分不存在/目录)
- README 补充 Linux 依赖排障与产物选择说明
- refs #98
2026-02-14 15:17:03 +08:00
Syngnat
f0e1c7e72c 🔧 fix(driver-agent): 修复 Windows 启动驱动代理弹出终端窗口
- 为 Windows 新增 agent 进程启动参数(HideWindow + CREATE_NO_WINDOW)
- optional driver agent 启动路径统一应用进程隐藏配置
- MySQL agent 启动路径同步应用进程隐藏配置
2026-02-14 15:01:29 +08:00
Syngnat
e90a3e2db6 Merge pull request #110 from Syngnat/release/0.4.5
Release/0.4.5
2026-02-14 11:47:59 +08:00
Syngnat
663717d738 ♻️ refactor(driver-delivery): 重构可选驱动分发为总包+索引模式
- 工作流统一收敛驱动产物并打包单一压缩包
- 新增驱动总包索引读取与缓存合并逻辑
- 保留原单文件直链兼容并增加总包提取回退
2026-02-14 11:45:51 +08:00
Syngnat
5329f212f7 feat(schema-editor): 表设计器新增索引/外键管理能力并支持表备注修改
- 支持新增/修改/删除索引与外键(MySQL)
- 表备注弹窗编辑并同步刷新 DDL/元数据
- 索引类型补齐 UNIQUE/PRIMARY/FULLTEXT/SPATIAL 等
- refs #108
2026-02-14 11:25:13 +08:00
Syngnat
d6e967a0d0 feat(table-designer): 支持字段注释弹框编辑并恢复DDL常显
- 注释列新增双击与按钮触发的弹框编辑能力
- 增加长文本注释编辑弹窗并支持直接回写字段定义
- 非新建表场景统一拉取并展示 DDL 标签页
- 优化注释只读态展示,补充悬浮完整内容
- refs #105
2026-02-14 10:36:54 +08:00
Syngnat
7ca2d20c17 feat(datagrid): 增强列头字段信息展示并优化排序与右键菜单交互
- 新增列头类型/备注常驻显示与悬浮详情展示
- 新增字段信息开关并持久化 showColumnComment/showColumnType 配置
- 排序改为仅箭头区域可触发,排序提示仅显示在排序图标上
- 修复可编辑表中右键菜单重复弹出与透明重影问题
- refs #106
2026-02-14 10:30:01 +08:00
Syngnat
9307ca5e16 feat(table-designer): 支持勾选字段并一键复制到新表
- 设计表字段列表增加多选能力,支持按行勾选字段
- 工具栏新增“复制选中到新表”按钮与交互
- 新增目标表配置弹窗,支持表名、字符集、排序规则设置
- 复用建表 SQL 生成逻辑并直接执行创建新表
- refs #107
2026-02-14 09:57:47 +08:00
Syngnat
60a42e3c34 🔧 fix(connection-modal): 修复 SQLite 连接配置回填导致路径变形问题
- ConnectionModal 中 sqlite 使用独立路径规则,不再参与 host:port 解析
- 修复编辑连接时的回填逻辑,阻断 F:\... 被追加 :3306
- 统一 URI 解析与生成行为,确保保存后再次编辑不变形
- 保留并强化驱动安装态判断与现有交互
2026-02-14 09:51:17 +08:00
Syngnat
5df95730d8 Merge pull request #109 from Syngnat/release/0.4.4
feat(drivers): 支持按需启动数据源并通过外置驱动代理减少发行包体积
2026-02-13 17:26:13 +08:00
Syngnat
26a7aacfec feat(drivers): 支持按需启动数据源并通过外置驱动代理减少发行包体积
- MySQL/Redis/Oracle/PostgreSQL 内置可用,其余数据源改为“安装启用”后可用
- 新建连接对未安装驱动做弹窗内拦截提示,并支持一键跳转驱动管理安装
- 驱动管理展示安装包真实大小(从 Release 资产元数据读取)并优化加载性能
- Release 工作流发布各平台驱动代理资产,主程序构建启用 -s -w 精简
2026-02-13 17:23:38 +08:00
Syngnat
67a9c454d0 Merge remote-tracking branch 'origin/main' 2026-02-12 10:39:46 +08:00
Syngnat
c17493952b Merge branch 'release/0.4.3' 2026-02-12 10:39:30 +08:00
Syngnat
dd258bd46c Merge pull request #102 from Syngnat/release/0.4.3
release/0.4.3
2026-02-12 10:38:57 +08:00
Syngnat
8df9ea717c 🔧 fix(ci-release-duckdb): 修复 DuckDB 导致的多平台打包失败并统一发布命名与更新匹配
- DuckDB 驱动迁移至官方 duckdb-go/v2 并按平台条件编译
- 修复 Windows/arm64 与 macOS/arm64 的构建失败链路
- 修复 macOS 10.13 下窗口材质可用性告警导致的打包问题
- 统一发布包命名规则(去掉版本前缀 v,架构统一 Amd64/Arm64)
- Windows 同时产出 exe/zip,在线更新优先匹配 exe 并保留 zip 兼容
2026-02-12 10:37:00 +08:00
Syngnat
505c89066b Merge pull request #101 from Syngnat/release/0.4.3
Release/0.4.3
2026-02-12 09:28:33 +08:00
Syngnat
31f2a47d26 🐛 fix(updater-macos): 修复更新状态误判并调整Mac下载目录
- CheckForUpdates 增加本地已下载包探测并回填 downloaded/downloadPath
- DownloadUpdate 复用同版本已下载包,避免重复下载
- macOS 更新包默认落盘到 ~/Desktop/GoNavi-<version>/
- 关于页更新状态改为按已下载/未下载准确展示
2026-02-11 17:41:42 +08:00
Syngnat
e01ecfc387 feat(datasource): 新增 DuckDB 与 Diros 数据源并补齐 DuckDB 函数管理
- 新增 DuckDB 与 Diros 后端驱动实现并接入数据库工厂
- 前端连接配置补充 DuckDB/Diros 入口及方言映射
- 侧边栏支持 DuckDB Macro 函数列表加载与对象分组展示
- 定义查看器支持 DuckDB 函数定义查询与 DDL 还原
- 后端补充 DuckDB 函数删除分支并限制存储过程操作
2026-02-11 17:25:38 +08:00
Syngnat
69d9a0b11e Merge pull request #100 from xuanyanwow/main
Support Sphinx DESCRIBE in GetColumns
2026-02-11 15:41:00 +08:00
宣言就是Siam
33f4208f39 Support Sphinx DESCRIBE in GetColumns
Update SphinxDB.GetColumns to use Sphinx's DESCRIBE output to build column definitions instead of delegating unconditionally to MySQL. The code issues `DESCRIBE <table>` and parses Field/Type/Properties (with case-insensitive lookup), sets sensible defaults (Nullable="YES", no primary key, Extra from Properties) and marks indexed fields as MUL. If DESCRIBE fails or returns no rows the implementation falls back to s.MySQLDB.GetColumns. Also add a logger import and a warning when DESCRIBE returns no columns.
2026-02-11 15:23:46 +08:00
Syngnat
0eeda1d137 Merge pull request #97 from Syngnat/release/0.4.2
Release/0.4.2
2026-02-11 11:18:45 +08:00
Syngnat
17d174bc5b ♻️ refactor(sphinx-compat): 优化Sphinx表列表查询兼容实现
- 保留MySQL复用路径并增加Sphinx语法不兼容回退分支
- 统一回退查询结果的字段提取逻辑
- 提升Sphinx索引列表加载健壮性与容错能力
2026-02-11 11:14:39 +08:00
Syngnat
9320f524a2 🐛 fix(connection-modal): 修复URI解析提示显示在弹窗外的问题
- 将生成/解析/复制URI反馈改为弹窗内联Alert展示
- 统一URI操作提示状态管理,避免全局message层级错位
- 在弹窗打开及URI/type变更时清理旧提示
2026-02-11 10:54:32 +08:00
Syngnat
e31dc4e7f1 feat(redis-stream): 支持 Redis Stream 类型查看与消息增删
- 后端扩展 RedisClient 接口,新增 StreamEntry 与 Stream 操作定义
- Redis 实现新增 XADD/XDEL/XRANGE 封装并接入 RedisGetValue 的 stream 分支
- App 层新增 RedisStreamAdd 与 RedisStreamDelete 方法并返回操作结果
- 前端新增 stream 类型视图,支持消息新增、删除与字段复制
- refs #92
2026-02-11 10:41:22 +08:00
Syngnat
ab92e94bf8 ♻️ refactor(tab-lifecycle): 统一连接与数据库关闭时的标签回收逻辑
- 下沉批量关页逻辑到 store,减少组件重复过滤代码
- Sidebar 仅负责触发动作,状态回收由 store 原子处理
- 优化标签生命周期一致性与可维护性
2026-02-11 10:23:54 +08:00
Syngnat
da5708b5bc 🔧 fix(frontend-data-grid): 修复小屏布局截断并根治MySQL排序内存溢出 2026-02-11 10:12:03 +08:00
Syngnat
189a2a1871 Merge pull request #96 from Syngnat/release/0.4.1
Release/0.4.1
2026-02-10 21:55:42 +08:00
杨国锋
ecf47da81b ♻️ refactor(connection-modal): 重构连接测试反馈交互并优化弹窗布局
- 将测试反馈统一收敛到底部状态区展示
- 失败原因改为独立弹窗查看,避免超长文案挤压主界面
- 调整 modal content/body/footer 弹性结构以适配高度变化
2026-02-10 21:51:50 +08:00
杨国锋
21c8b9a102 🔧 fix(table-designer): 对齐设计表字段拖拽与数据表格的交互与样式
- 字段列宽拖拽改为“虚线预览 + 鼠标释放后提交宽度”
- 新增列宽拖拽 Ghost Line,统一与数据表格的视觉反馈
- 拖拽期间统一全局 col-resize 光标与禁选文本,结束后完整清理监听与状态
2026-02-10 21:02:31 +08:00
杨国锋
a07b418b8f ♻️ refactor(log-panel): 优化SQL日志面板高度边界与滚动区域样式
- 重构最小高度约束逻辑,最小态聚焦单条日志
- 增加日志区域局部滚动条样式,避免影响全局滚动条
- 调整日志表格背景透明度以统一界面表现
2026-02-10 20:54:40 +08:00
杨国锋
4bf10e5612 🔧 fix(connection-uri): 修复URI解析成功后异常配置落盘导致应用崩溃
- 收紧 ConnectionModal 的 URI 解析校验(长度、主机数量、主机格式、端口范围、超时上限)
- 为 URI 回填增加异常兜底,避免解析阶段触发前端崩溃
- 在 store persist 的 migrate/merge 增加连接配置净化,启动时自动隔离坏数据
- 补充 ConnectionConfig 的 driver/dsn/timeout 类型并同步需求追踪文档
2026-02-10 20:40:22 +08:00
杨国锋
e6fe6eb026 feat(sphinx): 新增Sphinx数据源并补齐对象能力兼容链路
- 新增 SphinxDB 驱动注册并复用 MySQL 协议连接
- 前端新增 sphinx 连接类型与默认端口 9306
- 函数/视图/触发器改为多语句回退查询与版本探测提示
- 后端对不支持能力返回稳定降级结果
2026-02-10 20:12:25 +08:00
杨国锋
b4f80f39df 🔧 fix(app-window): 修复 Linux Mint 窗口仅左上角可缩放问题
- 增加 Linux 运行时识别并启用专用缩放命中层
- 补齐四边四角 app-region: drag 热区
- Linux 下禁用外层 clipPath 裁切以避免边缘命中异常
2026-02-10 19:32:03 +08:00
杨国锋
4d32dd2cb5 🔧 fix(data-viewer): 修复筛选后提交事务导致记录顺序漂移
- 抽取统一 ORDER BY 生成逻辑,避免无序重载
- 无显式排序时回退按主键升序,保证结果稳定
- 同步更新 DataGrid 当前页查询导出排序规则
2026-02-10 18:41:25 +08:00
Syngnat
de8fb60a30 feat(highgo-sm3): 增加瀚高SM3专用驱动并解耦PostgreSQL连接链路
- 引入 third_party/highgo-pq 作为 HighGo 专用驱动实现
- 调整驱动注册与连接入口,避免覆盖 postgres 驱动
- 保持 PG 数据源行为不变并补充接入文档
2026-02-10 17:42:28 +08:00
Syngnat
b3b77f490d Merge pull request #95 from Syngnat/release/0.4.0
🔧 fix(data-grid/sidebar/import): 修复时间格式异常并完善schema分层分组
2026-02-10 17:00:48 +08:00
Syngnat
52abed83e6 🔧 fix(data-grid/sidebar/import): 修复时间格式异常并完善schema分层分组
- 导入按列类型标准化 datetime/date/time,避免 +0800 CST 导致 1292 错误
- 导出文件统一时间格式为 yyyy-MM-dd HH:mm:ss
- JSON 视图时间字符串统一规范化显示
- 侧边栏改为 schema -> 对象类型 -> 对象 的分层分组展示
- refs #89
2026-02-10 16:58:13 +08:00
Syngnat
80dc863455 feat(data-grid-import): 新增结果多视图与导入预览进度能力
- DataGrid 新增表格/JSON/文本视图切换,支持 JSON 与文本模式编辑回写
- 修复展开 SQL 日志后横向滚动条异常及末行被遮挡问题
- 新增导入预览与进度导入接口,支持 CSV/JSON/Excel 文件
- 补充 Wails 绑定与 excelize 依赖更新
2026-02-10 16:08:10 +08:00
Syngnat
1a3b55ce19 Merge pull request #94 from Syngnat/release/0.3.9
🔧fix(mongodb): 修复MongoDB查询仅返回一条数据的问题
2026-02-10 12:27:53 +08:00
Syngnat
fa318a9f0e 🔧fix(mongodb): 修复MongoDB查询仅返回一条数据的问题
- queryWithContext 中 find/count 命令改用原生 Collection.Find()和 CountDocuments() API,替代RunCommand 的 firstBatch 模式
- 新增 convertBsonValue 将 ObjectID/bson.M/bson.D/bson.A 转为JSON 友好类型,_id 列自动置首
- DBQuery 增加 MongoDB JSON 命令识别,避免 find 命令误走 Exec 分支

️perf(macos): 动态控制 NSVisualEffectView 降低 MacOS GPU 持续消耗,Windows不受影响

- NSVisualEffectView 启动默认 alpha 由 0.72 改为 0,窗口默认 opaque
- 新增 gonaviSetEffectViewAlpha ObjC 函数支持运行时动态切换
- 新增 SetWindowTranslucency Wails 绑定方法供前端调用
- 启动重试次数由 24 次缩减至 8 次
- opacity=1.0 且 blur=0 时窗口标记 opaque,GPU 不再持续计算模糊合成
- App.tsx 仅保留最外层 Layout 的 backdropFilter,移除 TitleBar/MenuBar/Content/DataGrid/LogPanel 冗余嵌套
- App.css 移除暗色模式全局 text-shadow 减少 compositing 开销
2026-02-10 12:25:34 +08:00
Syngnat
8dafad7ce3 Merge pull request #93 from Syngnat/release/0.3.8
Release/0.3.8
2026-02-09 21:56:38 +08:00
杨国锋
78e35a5be8 ️ perf(data-grid): 重构批量编辑链路并优化表格渲染性能
- 重构批量改单元格的状态流,减少高频交互时的无效重渲染
- 优化大数据量场景下的表格交互流畅度与响应延迟
- 调整单元格编辑细节,增强与 Navicat 编辑习惯的一致性

🔧 fix(sidebar-connection): 修复多数据源切换后旧连接节点无响应问题

- 修复新建并连接新数据源后,旧数据源点击无响应的问题

 feat(tab-manager): 表与设计标签支持环境前缀显示

- 基于连接名识别 DEV/UAT/PROD/SIT/STG/TEST 环境标记
- 仅对 table/design 标签添加环境前缀,查询等标签保持原样
- 无法识别标准环境时回退显示连接名,提升多环境可辨识性

 feat(connection-config): 新增连接URI复制解析并支持MySQL/Mongo主从配置

- 连接弹窗新增 URI 生成、解析、复制能力,支持参数回填
- MySQL 支持多地址主从拓扑、从库地址列表与从库独立凭据
- Mongo 支持多节点配置、replicaSet、authSource、readPreference
- 扩展前后端连接配置模型并同步 Wails 生成类型文件
- 后端接入主从凭据回退策略,保持旧配置兼容

 feat(mongodb-replica): 对齐Navicat主从配置并补齐成员发现能力

- 新增 mongoSrv、mongoAuthMechanism、savePassword 配置项
- 支持 mongodb+srv URI 构建与解析,并透传 authMechanism
- 新增 MongoDiscoverMembers 接口,返回成员与状态信息
- 驱动侧实现 replSetGetStatus -> hello/isMaster 回退发现链路
- 前端弹窗新增 SRV 开关、验证方式、成员发现按钮与状态表
- 增加 SRV+SSH 冲突提示与后端保护,避免无效连接路径

🔧 fix(app-error-text): 修复连接测试错误信息乱码并完善日志提示

- 新增错误文本编码纠正能力,处理混合编码导致的中文乱码
- 连接错误提示统一走 normalizeErrorMessage 输出
- 增加 GB18030 纠正相关单元测试覆盖 PostgreSQL 认证失败场景
- go.mod 显式引入 golang.org/x/text 依赖

 feat(filter-panel): 筛选条件支持启用停用与批量开关

- 筛选条件新增 enabled 状态,支持按条件勾选启用/停用
- 筛选面板新增“全启用”“全停用”快捷操作
- SQL 组装时自动跳过已停用条件,保留条件内容便于复用
- 同步 DataViewer 与 SQL 工具层类型,确保筛选链路一致性

🔧 fix(connection-modal-scroll): 修复连接弹窗滚动行为并去除外层滚动条

- 连接配置步骤设置弹窗 body 最大高度与内部滚动
- 为连接弹窗增加专用 wrapClassName 并禁用外层滚动
- 修复出现双滚动条的问题,确保仅保留弹窗内部滚动条
2026-02-09 21:54:11 +08:00
Syngnat
35ed555857 ️ perf(data-grid): 重构批量编辑实现并优化渲染性能
- 架构优化:移除 CellEditModeContext,避免 Context 变化触发全表重渲染
  - 事件委托:在容器级别处理鼠标事件,减少事件监听器数量从 O(n*m) 到 O(1)
  - DOM查询优化:使用 data-row-key/data-col-name 属性直接定位单元格
  - RAF节流:拖拽选择使用 requestAnimationFrame 节流,保证 60fps 流畅度
  - CSS类控制:批量编辑模式样式通过 CSS 类切换,而非内联 style
2026-02-09 17:37:59 +08:00
Syngnat
954a5d77d3 Merge pull request #90 from Syngnat/release/0.3.7
Release/0.3.7
2026-02-09 16:01:12 +08:00
Syngnat
f3130ff517 🔧fix(data-grid): 修复无效日期时间值导致应用崩溃问题
- normalizeDateTimeString 函数添加无效日期时间检测(0000-00-00)
- 无效日期时间保持原样显示,不尝试转换
- 根本原因:MySQL 等数据库的 0000-00-00 00:00:00 值导致渲染崩溃
2026-02-09 15:58:04 +08:00
Syngnat
012c99be9e feat(sidebar): 新增侧栏表自定义排序功能
- 支持按名称排序(字母顺序,默认)
- 支持按使用频率排序(打开次数降序)
- 右键表分组节点选择排序方式
- 排序偏好和访问统计持久化保存
- 每个数据库可独立设置排序方式
2026-02-09 15:53:35 +08:00
Syngnat
c8575c315b 🔧fix(data-grid): 修复查询含SQL语句字段时应用崩溃问题
- formatCellValue 函数添加 try-catch 保护
- JSON.stringify 异常时降级显示 [Object]
- 新增 DataGridErrorBoundary 错误边界组件
- 渲染错误时显示友好提示并提供重试按钮
2026-02-09 15:43:25 +08:00
Syngnat
601d69faeb feat(data-grid): 新增表格批量编辑功能
- 批量填充相同值:右键菜单新增"填充到选中行"选项,可将当前单元格值批量填充到所有选中行
- 拖拽填充柄:单元格悬停时右下角显示蓝色填充柄,支持向下拖拽自动填充
- 智能自增算法:数字类型+1,字符串末尾数字+1并保持前导零位数(如 item_001 → item_002)
- 性能优化:使用 ref 缓存 DOM 查询结果,避免拖拽过程中触发 React 重渲染
- 选区指示器使用 fixed 定位渲染到 Portal,确保位置准确
2026-02-09 15:31:18 +08:00
Syngnat
fdb7781a9b feat(db-sidebar): 新增数据库对象分组展示及触发器管理功能
- 侧栏数据库节点按对象类型分组展示(表/视图/触发器)
 - 新增视图节点支持双击打开数据浏览
 - 新增触发器节点支持双击查看触发器定义(TriggerViewer组件)
 - 表级触发器管理:支持查看语句、新增、修改、删除操作
 - 对象分组内按名称字母排序
 - DDL查看及触发器编辑器适配透明模式背景
 - 多数据库类型的视图/触发器元数据查询SQL适配
 - refs #89
2026-02-09 14:50:13 +08:00
Syngnat
087578693e feat(db-sidebar): 新增TDengine支持并优化跨数据源表名展示体验
- 引入 TDengine 数据源能力并补齐运行时配置与标识符处理
- 侧栏对 schema.table 数据源统一展示短表名
- 表节点悬停显示完整 schema.table,降低重名识别成本
- 更新文档与验证用例,保证改动可追踪可回归
2026-02-09 12:12:35 +08:00
Syngnat
aceabb63f5 🔧 fix(redis-viewer): 修复 Redis Key 列表窄窗口遮挡并支持 TTL 响应式隐藏
- 将 Key 行改为弹性布局,避免 type 标签覆盖名称
- 基于左侧面板宽度阈值自动隐藏 TTL,优先保证名称可读性
- refs #88
2026-02-09 11:20:03 +08:00
杨国锋
8587f72f81 🔧 fix(update): 修复更新下载时文件被占用导致失败的问题 2026-02-08 14:19:13 +08:00
Syngnat
1b5a71d478 Merge pull request #87 from Syngnat/release/0.3.6
 feat(datasource): 新增 MariaDB、Vastbase、HighGo、MongoDB、SQL Server 五种数据源支持

- MariaDB:MySQL驱动占位,默认端口3306,归类关系型数据库
- Vastbase(海量):PG驱动占位,默认端口5432,归类国产数据库
- HighGo(瀚高):PG驱动,支持SM3认证扩展,归类国产数据库
- MongoDB:官方驱动实现,归类NoSQL
- SQL Server:微软官方驱动实现,归类关系型数据库
- ConnectionModal 新增数据源选项卡与默认端口配置
- database.go 新增5种类型的实例化分支
- 同步更新 db_context、methods_db、sql_sanitize、methods_file、sql_helpers 类型判断

🔧 fix(appearance): 优化 Windows 透明效果体验并调整透明度滑块灵敏度

- Windows 平台限制:隐藏模糊滑块,显示系统 Acrylic 效果说明
- 透明度因子调整:Windows 从 0.20 调整为 0.70,变化更平滑
- 透明度因子调整:macOS 从 0.20 调整为 0.60,变化更平滑
- 用户体验:修复滑块从 100% 拉到 95% 时透明度变化过于剧烈的问题
2026-02-08 14:02:55 +08:00
杨国锋
83ad3b09d9 🔧 fix(appearance): 优化 Windows 透明效果体验并调整透明度滑块灵敏度
- Windows 平台限制:隐藏模糊滑块,显示系统 Acrylic 效果说明
- 透明度因子调整:Windows 从 0.20 调整为 0.70,变化更平滑
- 透明度因子调整:macOS 从 0.20 调整为 0.60,变化更平滑
- 用户体验:修复滑块从 100% 拉到 95% 时透明度变化过于剧烈的问题
2026-02-08 14:00:28 +08:00
杨国锋
72811092b4 feat(datasource): 新增 MariaDB、Vastbase、HighGo、MongoDB、SQL Server 五种数据源支持
- MariaDB:MySQL驱动占位,默认端口3306,归类关系型数据库
- Vastbase(海量):PG驱动占位,默认端口5432,归类国产数据库
- HighGo(瀚高):PG驱动,支持SM3认证扩展,归类国产数据库
- MongoDB:官方驱动实现,归类NoSQL
- SQL Server:微软官方驱动实现,归类关系型数据库
- ConnectionModal 新增数据源选项卡与默认端口配置
- database.go 新增5种类型的实例化分支
- 同步更新 db_context、methods_db、sql_sanitize、methods_file、sql_helpers 类型判断
- 新增 HighGo SM3 驱动集成指南
2026-02-08 13:39:39 +08:00
Syngnat
b67135e2c1 Merge pull request #85 from ushaio/fix/sidebar-border
🎨 style(layout): 为侧边栏添加右侧分割线
2026-02-07 19:56:02 +08:00
Syngnat
f5e16b0b70 Merge pull request #84 from ushaio/fix/postgres-uppercase-table-quoting
🔧 fix(postgres): 修复含大写字母的表名查询报错 relation does not exist
2026-02-07 19:55:47 +08:00
ushaio
f8535dd272 🎨 style(layout): 为侧边栏添加右侧分割线
左侧栏与右侧内容区之间缺少视觉分隔,添加 1px 半透明灰色边框,
明暗主题下均适用。
2026-02-06 22:12:46 +08:00
ushaio
5cd8681b80 🔧 fix(postgres): 修复含大写字母的表名查询报错 relation does not exist
PostgreSQL 会将未加双引号的标识符自动折叠为小写,导致如 Blog 表在查询时
变为 public.blog,触发 relation "public.blog" does not exist 错误。

在 needsQuote 中增加大写字母检测,确保含大写的标识符被双引号包裹。
同时修复 KingBase 的相同问题(共用同一逻辑分支)。
2026-02-06 21:59:23 +08:00
Syngnat
4b381c82b5 feat(sidebar-redis-db): 新增库表重命名删除、批量仅数据导出与Redis多选删键能力
 feat(sidebar-redis-db): 新增库表重命名删除、批量仅数据导出与Redis多选删键能力

- 后端新增数据库/表重命名与删除能力,覆盖多数据源差异处理
- 批量操作表新增“仅导出数据(INSERT)”模式并完善导出链路
- Redis Key 列表支持分组展示、勾选批量删除与当前Key删除入口
- 同步 Wails 前后端绑定接口并优化批量操作弹窗按钮布局
- refs #80
2026-02-06 17:00:29 +08:00
Syngnat
820b064e7f feat(sidebar-redis-db): 新增库表重命名删除、批量仅数据导出与Redis多选删键能力
- 后端新增数据库/表重命名与删除能力,覆盖多数据源差异处理
- 批量操作表新增“仅导出数据(INSERT)”模式并完善导出链路
- Redis Key 列表支持分组展示、勾选批量删除与当前Key删除入口
- 同步 Wails 前后端绑定接口并优化批量操作弹窗按钮布局
2026-02-06 16:57:05 +08:00
Syngnat
70cb6148c6 🔧 fix(app): 修复更新流程可用性并完善窗口交互一致性
- 补齐更新下载进度、下载路径和安装日志路径提示
- 修复更新重启后拉起不稳定问题并增加平台兜底
- 恢复标题栏双击切换窗口状态能力
- 调整透明度初始行为为 100% 并保留用户配置
2026-02-06 15:53:31 +08:00
Syngnat
0cb9cb8bc9 🔧 fix(appearance): 修复100%%不透明仍透明并隔离Dev图标缓存 2026-02-06 14:33:15 +08:00
Syngnat
c2c88d743b 🔧 fix(updater): 修复Mac更新重启无效并增强Windows便携替换可靠性
- 修复 macOS 点击“立即重启”后无反应,增加 Quit 后兜底退出
- 增强 macOS 更新脚本:日志、AppTranslocation 目标回退、管理员权限回退与自动 xattr 清除 quarantine
- 增强 Windows 便携更新:move/copy 重试、失败可观测日志、保留非提权替换策略
2026-02-06 12:12:45 +08:00
Syngnat
e8ef6b0b38 🔧 fix(appearance): 修复透明通透失效并统一 Win/Mac 视觉强度
- 新增 macOS 原生窗口通透补强与启动重试,修复偶发不生效
- 引入跨平台透明/模糊映射,统一 Win/Mac 同滑块值观感
- 调整主窗口圆角与裁剪,优化整体视觉一致性
2026-02-06 11:37:18 +08:00
Syngnat
257459f96a Merge branch 'feature/sql-cross-db-intellisense-20260205-ygf' into dev 2026-02-06 11:35:54 +08:00
Syngnat
027115ab87 🔧 fix(appearance): 修复透明通透失效并统一 Win/Mac 视觉强度
- 新增 macOS 原生窗口通透补强与启动重试,修复偶发不生效
- 引入跨平台透明/模糊映射,统一 Win/Mac 同滑块值观感
- 调整主窗口圆角与裁剪,优化整体视觉一致性
2026-02-06 11:35:16 +08:00
Syngnat
96cb8134c4 Merge pull request #79 from Syngnat/release/0.3.2
 feat(editor/appearance): 跨库SQL智能提示与全局透明度模糊效果
2026-02-05 21:30:15 +08:00
Syngnat
b108cd1c90 Merge pull request #78 from Syngnat/feature/sql-cross-db-intellisense-20260205-ygf
 feat(editor/appearance): 跨库SQL智能提示与全局透明度模糊效果
2026-02-05 21:28:36 +08:00
杨国锋
d1ce9cefb8 feat(editor/appearance): 跨库SQL智能提示与全局透明度模糊效果
跨库SQL智能提示:
  - 扩展 tablesRef/allColumnsRef 支持跨库元数据存储
  - 根据 includeDatabases 配置过滤可见数据库
  - 支持三段式(db.table.column)和两段式(db.table)补全格式
  - 优化补全权重:FROM表字段优先于其他表和关键字
  - 移除数据库类型限制,PostgreSQL等均支持列信息获取

  全局透明度与高斯模糊:
  - 新增 appearance 状态管理(opacity/blur)并持久化
  - App/Sidebar/LogPanel/DataGrid/TabManager 适配透明背景
  - 使用 backdropFilter 实现高斯模糊效果
  - 右键菜单使用 Portal 渲染避免 fixed 定位失效

  单元格右键菜单增强:
  - 合并复制(INSERT/JSON/CSV/Markdown)和导出功能
  - 添加 stopPropagation 防止菜单事件冒泡
2026-02-05 21:26:03 +08:00
杨国锋
f75e04f091 ♻️ refactor(theme): 重构主题系统并统一全局暗色视觉 2026-02-05 20:07:25 +08:00
Syngnat
1fc182817e feat(about): 优化关于弹窗的更新提示与下载交互
- 记录最新更新信息并展示“更新状态”
  - 自动检查发现新版本弹出关于,但不自动下载
  - 新增“下载更新/本次不再提示”按钮
2026-02-05 17:21:43 +08:00
Syngnat
3c28b0adeb feat(updater): 接入 GitHub Release 在线更新与关于信息展示
- 后端新增更新检查/下载/安装流程与应用信息接口
  - 关于弹窗展示版本/作者/仓库/Issue/Release,并内置检查更新
  - 构建/发布注入版本号并生成 SHA256SUMS
  - 顶部工具栏入口调整与新建查询补全默认空 SQL
2026-02-05 16:56:25 +08:00
Syngnat
ec4b3d9018 feat(updater): 接入 GitHub Release 在线更新与关于信息展示
- 后端新增更新检查/下载/安装流程与应用信息接口
  - 关于弹窗展示版本/作者/仓库/Issue/Release,并内置检查更新
  - 构建/发布注入版本号并生成 SHA256SUMS
  - 顶部工具栏入口调整与新建查询补全默认空 SQL
2026-02-05 16:50:44 +08:00
Syngnat
8654485cfe 📝 docs(readme): 更新数据源与功能特性说明 2026-02-05 14:40:05 +08:00
Syngnat
9beb73ea40 Merge pull request #75 from Syngnat/release/0.3.1
 feat(frontend/backend): 批量操作与表格编辑增强并完善事务支持

  - 批量导出/备份:表与数据库支持全选/反选/智能上下文
  - 右键菜单:单元格菜单支持设置 NULL
  - 编辑优化:大字段弹窗、仅值变化标记、提交只发送差异字段
  - 事务支持:PostgreSQL/SQLite/Oracle/DaMeng/KingBase ApplyChanges
  - MySQL 修复:提交前归一化 datetime,避免写入失败
  - 性能优化:移除 activeCell 重渲染、useRef 存储选中节点、防重加载
  - Redis 优化:二进制智能解码与视图模式切换
  - 资源更新:替换前端 favicon/logo
2026-02-05 14:35:12 +08:00
Syngnat
3b19a33d4b Merge pull request #74 from Syngnat/feature/support-redis-20260204-ygf
 feat(frontend/backend): 批量操作与表格编辑增强并完善事务支持
2026-02-05 14:32:06 +08:00
Syngnat
13ba78103c feat(frontend/backend): 批量操作与表格编辑增强并完善事务支持
- 批量导出/备份:表与数据库支持全选/反选/智能上下文
  - 右键菜单:单元格菜单支持设置 NULL
  - 编辑优化:大字段弹窗、仅值变化标记、提交只发送差异字段
  - 事务支持:PostgreSQL/SQLite/Oracle/DaMeng/KingBase ApplyChanges
  - MySQL 修复:提交前归一化 datetime,避免写入失败
  - 性能优化:移除 activeCell 重渲染、useRef 存储选中节点、防重加载
  - Redis 优化:二进制智能解码与视图模式切换
  - 资源更新:替换前端 favicon/logo
2026-02-05 14:30:05 +08:00
Syngnat
538e4a1506 Merge pull request #70 from bengbengbalabalabeng/feat-issues-55
ci: add publish-to-winget action
2026-02-05 08:41:48 +08:00
Syngnat
934581c796 chore(ci): 调整 WinGet 发布配置
## 修改内容
- 修正 WinGet workflow 中 installers-regex,使其匹配实际 Release 产物名称

## 修改原因
- 原匹配规则无法匹配 GoNavi-windows-amd64.exe / GoNavi-windows-arm64.exe
- 避免 WinGet 发布流程找不到安装包导致失败

## 影响范围
- CI / WinGet 发布流程
2026-02-05 08:41:18 +08:00
baicaixiaozhan
1486b98d27 ci: add publish-to-winget action 2026-02-04 20:02:43 +08:00
Syngnat
6cda430f03 🔧 chore(ci/build): 移除Linux ARM64构建支持以简化发布流程
- 从构建矩阵中移除linux/arm64平台
  - 移除ARM64交叉编译工具链安装逻辑
  - 简化Linux依赖安装流程,移除条件判断
  - 保留macOS和Windows的ARM64支持(原生构建)
  - 当前支持平台:macOS(AMD64/ARM64)、Windows(AMD64/ARM64)、Linux(AMD64)
  - 技术原因:Wails CGO交叉编译在x86_64 runner上存在头文件冲突问题
2026-02-04 17:50:13 +08:00
Syngnat
f56c3d5f6e 🐛 fix(workflows): 移除了 dpkg --add-architecture arm64,这会导致 apt 尝试从不存在的 ARM64 仓库获取包 2026-02-04 17:43:31 +08:00
Syngnat
74c9143c95 🐛 fix(workflows): 添加 wget 重试机制(3次重试,超时控制) 2026-02-04 17:36:59 +08:00
Syngnat
0e4a833ffa 🐛 fix(workflows): 修复artifact_name 冲突 2026-02-04 17:30:26 +08:00
Syngnat
37ad9885b7 Merge pull request #69 from Syngnat/release/0.3.0
🐛 fix(workflows): 修复actions语法错误
2026-02-04 17:19:46 +08:00
Syngnat
5cef9a4032 Merge pull request #68 from Syngnat/dev
🐛 fix(workflows): 修复actions语法错误
2026-02-04 17:18:54 +08:00
Syngnat
f49767c38b 🐛 fix(workflows): 修复actions语法错误 2026-02-04 17:17:02 +08:00
Syngnat
7e8699ba02 Merge pull request #67 from Syngnat/release/0.3.0
 feat(redis): 新增Redis数据源完整支持
2026-02-04 17:05:11 +08:00
Syngnat
5f0ce5ed7a Merge pull request #66 from Syngnat/feature/support-redis-20260204-ygf
 feat(redis): 新增Redis数据源完整支持
2026-02-04 17:03:40 +08:00
Syngnat
49c7620bdd 🐛 fix(redis/kingbase): Redis数据库选择优化与金仓标识符引号修复
- Redis配置优化:移除固定数据库输入框,改为测试连接后多选数据库
  - 数据库筛选:支持选择显示的Redis数据库(0-15),留空显示全部
  - 类型扩展:SavedConnection新增includeRedisDatabases字段存储用户选择
  - 侧边栏过滤:根据配置过滤显示的Redis数据库列表
  - 金仓修复:KingBase/PostgreSQL标识符仅在必要时加双引号
  - 保留字检测:新增needsQuote函数识别特殊字符和SQL保留字
2026-02-04 17:00:51 +08:00
Syngnat
80fa7a1acd feat(redis): 新增Redis数据源完整支持
- 后端实现:新增Redis客户端接口与go-redis实现,支持SSH隧道连接
  - API方法:新增21个Redis操作API(连接/Key/Value/命令执行等)
  - 连接配置:ConnectionModal支持Redis类型,自动识别端口与认证方式
  - 数据浏览:RedisViewer组件支持Key列表展示、类型识别与分页加载
  - 值编辑器:支持String/Hash/List/Set/ZSet五种数据类型的查看与编辑
  - 二进制处理:自动检测二进制数据并以十六进制格式展示
  - 命令终端:RedisCommandEditor支持多行命令执行与结果展示
  - 交互优化:JSON语法高亮编辑、一键复制值、面板宽度可调整
2026-02-04 16:45:51 +08:00
Syngnat
68770a42e2 Merge pull request #65 from Syngnat/feature/support-linux-windosw-arm-amd-20260204-ygf
 feat(ci/build): 新增Linux和Windows ARM64多平台构建支持
2026-02-04 15:15:18 +08:00
Syngnat
06aebf716e feat(ci/build): 新增Linux和Windows ARM64多平台构建支持
- CI矩阵扩展:新增Linux amd64/arm64和Windows arm64构建任务
  - AppImage支持:Linux平台生成通用AppImage包,兼容所有主流发行版
  - 依赖安装:自动安装GTK3/WebKit2GTK及ARM64交叉编译工具链
  - 本地构建:build-release.sh支持Linux/Windows多架构本地构建
  - 交叉编译:macOS/Linux可交叉编译其他平台,自动检测工具链
  - 打包优化:Linux输出tar.gz和AppImage两种格式
2026-02-04 15:02:42 +08:00
Syngnat
f551b19f40 Merge pull request #64 from Syngnat/release/0.2.6
♻️ refactor(database/ssh): SSH隧道架构重构与多数据源适配
2026-02-04 14:41:43 +08:00
Syngnat
6674ad69e1 Merge pull request #63 from Syngnat/dev
♻️ refactor(database/ssh): SSH隧道架构重构与多数据源适配
2026-02-04 14:40:34 +08:00
Syngnat
37d35684f1 Merge pull request #62 from Syngnat/feature/table-and-database-export-20260203-ygf
♻️ refactor(database/ssh): SSH隧道架构重构与多数据源适配
2026-02-04 14:37:11 +08:00
Syngnat
71e5de0cdc ♻️ refactor(database/ssh): SSH隧道架构重构与多数据源适配
- 架构升级:从driver专属拨号器改为通用本地端口转发模式
  - 并发安全:sync.Once保护Close操作,RWMutex保护状态访问,双向errc等待
  - 连接池化:GetOrCreateLocalForwarder/GetOrCreateSSHClient实现缓存复用
  - SQL安全:kingbase_impl.go引入esc函数,防止双引号注入(""ldf_server""问题)
  - Schema动态化:三级fallback(schema.table解析→dbName参数→current_schema())
  - 代码复用:scanRows统一行扫描逻辑,normalizeQueryValueWithDBType增强类型处理
  Close #40
2026-02-04 14:35:31 +08:00
289 changed files with 84483 additions and 2612 deletions

26
.github/release.yaml vendored Normal file
View File

@@ -0,0 +1,26 @@
changelog:
categories:
- title: 新功能
labels:
- feature
- enhancement
- feat
- title: 问题修复
labels:
- bug
- fix
- title: 文档与流程
labels:
- docs
- documentation
- ci
- workflow
- chore
- title: 重构与优化
labels:
- refactor
- perf
- optimization
- title: 其他更新
labels:
- '*'

25
.github/workflows/release-winget.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Publish to WinGet
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
release_tag:
required: true
description: 'Tag of release you want to publish'
type: string
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
publish:
runs-on: windows-latest
steps:
- uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: Syngnat.GoNavi
installers-regex: 'GoNavi-windows-(amd64|arm64)\.exe$'
release-tag: ${{ inputs.release_tag || github.ref_name }}
token: ${{ secrets.WINGET_TOKEN }}

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:
@@ -19,16 +22,59 @@ jobs:
include:
- os: macos-latest
platform: darwin/amd64
artifact_name: GoNavi-mac-amd64
asset_ext: .dmg
os_name: MacOS
arch_name: Amd64
build_name: gonavi-build-darwin-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: macos-latest
platform: darwin/arm64
artifact_name: GoNavi-mac-arm64
asset_ext: .dmg
os_name: MacOS
arch_name: Arm64
build_name: gonavi-build-darwin-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/amd64
artifact_name: GoNavi-windows-amd64
asset_ext: .exe
os_name: Windows
arch_name: Amd64
build_name: gonavi-build-windows-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/arm64
os_name: Windows
arch_name: Arm64
build_name: gonavi-build-windows-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: ubuntu-22.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-build-linux-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
# Debian 13 (trixie) 默认仓库已切到 WebKitGTK 4.1:单独提供 4.1 变体产物
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-build-linux-amd64-webkit41
wails_tags: "webkit2_41"
artifact_suffix: "-WebKit41"
build_optional_agents: false
linux_webkit: "4.1"
steps:
- name: Checkout code
@@ -45,19 +91,210 @@ jobs:
with:
node-version: '20'
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$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
}
& $upxCmd --version
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev
# WebKitGTK 4.1 需要 libsoup34.0 使用 libsoup2通常由 webkit2gtk dev 包拉起)
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
else
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
upx --version
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
# Download linuxdeploy tools for AppImage packaging
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
echo "📥 下载 linuxdeploy..."
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
-O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
echo "⚠️ linuxdeploy 下载失败AppImage 打包将跳过"
touch /tmp/skip-appimage
}
echo "📥 下载 linuxdeploy-plugin-gtk..."
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
-O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
echo "⚠️ linuxdeploy-plugin-gtk 下载失败AppImage 打包将跳过"
touch /tmp/skip-appimage
}
if [ ! -f /tmp/skip-appimage ]; then
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
echo "✅ AppImage 工具准备完成"
fi
- name: Install Wails
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
id: msys2_duckdb
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
continue-on-error: true
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
shell: pwsh
run: |
function Find-MingwBin([string[]]$candidates) {
foreach ($bin in $candidates) {
if ([string]::IsNullOrWhiteSpace($bin)) {
continue
}
$gcc = Join-Path $bin 'gcc.exe'
$gxx = Join-Path $bin 'g++.exe'
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
return $bin
}
}
return $null
}
$msys2Outcome = "${{ steps.msys2_duckdb.outcome }}"
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
$candidateBins = @()
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
}
$candidateBins += @(
'C:\msys64\ucrt64\bin',
'D:\a\_temp\msys64\ucrt64\bin'
)
$candidateBins = @($candidateBins | Select-Object -Unique)
$mingwBin = Find-MingwBin $candidateBins
if (-not $mingwBin) {
if ($msys2Outcome -ne 'success') {
Write-Warning "⚠️ MSYS2 安装步骤结果为 $msys2Outcome回退到 UCRT64 本机路径探测"
} else {
Write-Warning "⚠️ MSYS2 已执行,但未找到 UCRT64 gcc/g++,回退到本机路径探测"
}
$mingwBin = Find-MingwBin $candidateBins
}
if (-not $mingwBin) {
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。已检查:$($candidateBins -join ', ')"
exit 1
}
$gcc = (Join-Path $mingwBin 'gcc.exe')
$gxx = (Join-Path $mingwBin 'g++.exe')
if (!(Test-Path $gcc) -or !(Test-Path $gxx)) {
Write-Error "❌ DuckDB 编译器缺失gcc=$gcc g++=$gxx"
exit 1
}
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx"
- name: Verify DuckDB CGO Toolchain (Windows AMD64)
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
shell: pwsh
run: |
& "$env:CC" --version
& "$env:CXX" --version
- name: Build
shell: bash
run: |
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.artifact_name }}
set -euo pipefail
if [ -n "${{ matrix.wails_tags }}" ]; then
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
else
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
fi
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents }}
shell: bash
run: |
set -euo pipefail
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
if [ "$DRIVER" = "duckdb" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
fi
done
# macOS Packaging
- name: Package macOS DMG
if: contains(matrix.platform, 'darwin')
run: |
brew install create-dmg
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
cd build/bin
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
@@ -66,11 +303,21 @@ jobs:
exit 1
fi
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 应用主程序!"
exit 1
fi
echo " macOS 产物不执行 UPX 压缩,保留原始主程序。"
echo "🔏 正在进行 Ad-hoc 签名..."
codesign --force --options runtime --deep --sign - "$APP_NAME"
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.artifact_name }}.dmg"
DMG_NAME="${{ matrix.build_name }}.dmg"
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
echo "📦 正在生成 DMG: $DMG_NAME..."
create-dmg \
@@ -84,35 +331,168 @@ jobs:
"$DMG_NAME" \
"$APP_NAME"
mv "$DMG_NAME" ../../
mv "$DMG_NAME" "../../$FINAL_NAME"
# Windows Packaging
- name: Prepare Windows Exe
- name: Package Windows EXE
if: contains(matrix.platform, 'windows')
shell: bash
shell: pwsh
run: |
Set-Location build/bin
$version = "${{ github.ref_name }}"
if ($version.StartsWith("v")) {
$version = $version.Substring(1)
}
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
} elseif (Test-Path "$target") {
Rename-Item -Path "$target" -NewName "$target.exe"
$finalExe = "$target.exe"
} else {
Write-Error "❌ 未找到构建产物 '$target'!"
exit 1
}
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
if ($isArm64Target) {
Write-Warning "⚠️ UPX 当前不支持 win64/arm64跳过压缩并保留原始 EXE。"
$LASTEXITCODE = 0
} else {
$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..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
# Linux Packaging (tar.gz and AppImage)
- name: Package Linux
if: contains(matrix.platform, 'linux')
run: |
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
cd build/bin
TARGET="${{ matrix.artifact_name }}"
if [ -f "$TARGET.exe" ]; then
FINAL_EXE="$TARGET.exe"
elif [ -f "$TARGET" ]; then
mv "$TARGET" "$TARGET.exe"
FINAL_EXE="$TARGET.exe"
else
TARGET="${{ matrix.build_name }}"
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz"
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage"
if [ ! -f "$TARGET" ]; then
echo "❌ 未找到构建产物 '$TARGET'!"
exit 1
fi
echo "📦 正在移动 $FINAL_EXE 到根目录..."
mv "$FINAL_EXE" "../../$FINAL_EXE"
chmod +x "$TARGET"
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
upx --best --lzma --force "$TARGET"
upx -t "$TARGET"
AFTER_BYTES=$(wc -c <"$TARGET" | 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 "✅ Linux 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 " Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
# 1. Create tar.gz
echo "📦 正在打包 $TAR_NAME..."
tar -czvf "$TAR_NAME" "$TARGET"
mv "$TAR_NAME" ../../
# 2. Create AppImage (skip for ARM64 or if tools unavailable)
if [ -f /tmp/skip-appimage ]; then
echo "⚠️ 跳过 AppImage 打包"
exit 0
fi
echo "📦 正在生成 AppImage..."
# Create AppDir structure
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
# Create desktop file
printf '%s\n' \
'[Desktop Entry]' \
'Name=GoNavi' \
'Exec=gonavi' \
'Icon=gonavi' \
'Type=Application' \
'Categories=Development;Database;' \
'Comment=Database Management Tool' \
> AppDir/usr/share/applications/gonavi.desktop
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
# Create a simple icon (or use existing if available)
if [ -f "../../build/appicon.png" ]; then
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
cp "../../build/appicon.png" AppDir/gonavi.png
else
# Create a placeholder icon
convert -size 256x256 xc:#336791 -fill white -gravity center -pointsize 48 -annotate 0 "GoNavi" AppDir/gonavi.png || \
wget -q "https://via.placeholder.com/256/336791/FFFFFF?text=GoNavi" -O AppDir/gonavi.png || \
touch AppDir/gonavi.png
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
fi
# Build AppImage
export DEPLOY_GTK_VERSION=3
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || {
echo "⚠️ AppImage 生成失败,但 tar.gz 已成功生成"
exit 0
}
# Rename output
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || {
echo "⚠️ AppImage 重命名失败"
exit 0
}
if [ -f "$APPIMAGE_NAME" ]; then
mv "$APPIMAGE_NAME" ../../
echo "✅ AppImage 生成成功"
fi
# Upload to Actions Artifacts (Temporary Storage)
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: build-artifacts-${{ strategy.job-index }} # Unique name per job
path: GoNavi-*${{ matrix.asset_ext }}
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**
retention-days: 1
# Phase 2: Collect all artifacts and Publish Release (Single Job)
@@ -131,6 +511,176 @@ jobs:
- name: List Assets
run: ls -R release-assets
- name: Verify Optional Driver Assets
shell: bash
run: |
set -euo pipefail
cd release-assets
REQUIRED_FILES=(
"drivers/Windows/duckdb-driver-agent-windows-amd64.exe"
"drivers/MacOS/duckdb-driver-agent-darwin-amd64"
"drivers/MacOS/duckdb-driver-agent-darwin-arm64"
"drivers/Linux/duckdb-driver-agent-linux-amd64"
"drivers/Windows/clickhouse-driver-agent-windows-amd64.exe"
"drivers/MacOS/clickhouse-driver-agent-darwin-amd64"
"drivers/MacOS/clickhouse-driver-agent-darwin-arm64"
"drivers/Linux/clickhouse-driver-agent-linux-amd64"
)
missing=0
for file in "${REQUIRED_FILES[@]}"; do
if [ ! -f "$file" ]; then
echo "❌ 缺少驱动资产:$file"
missing=1
else
echo "✅ 已找到驱动资产:$file"
fi
done
if [ "$missing" -ne 0 ]; then
echo "❌ 可选驱动资产不完整,终止发布"
exit 1
fi
- name: Package Driver Agents Bundle
shell: bash
run: |
set -euo pipefail
cd release-assets
if [ ! -d drivers ]; then
echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包"
exit 0
fi
if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then
echo "⚠️ drivers 目录为空,跳过驱动总包打包"
rm -rf drivers
exit 0
fi
echo "📦 打包驱动总包GoNavi-DriverAgents.zip"
python3 - <<'PY'
import json
import os
import zipfile
from pathlib import Path
out_name = "GoNavi-DriverAgents.zip"
index_name = "GoNavi-DriverAgents-Index.json"
base = Path("drivers")
out_path = Path(out_name)
index_path = Path(index_name)
if out_path.exists():
out_path.unlink()
if index_path.exists():
index_path.unlink()
size_index = {}
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in base.rglob("*"):
if not p.is_file():
continue
arcname = p.relative_to(base).as_posix()
zf.write(p, arcname)
size_index[p.name] = p.stat().st_size
index_path.write_text(
json.dumps({"assets": size_index}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"created {out_name} size={out_path.stat().st_size} bytes")
print(f"created {index_name} entries={len(size_index)}")
PY
# Release 只发布一个驱动总包,避免大量平铺资产污染 Release 页面
rm -rf drivers
- name: Generate SHA256SUMS
shell: bash
run: |
cd release-assets
FILES=()
while IFS= read -r file; do
if [ -n "$file" ]; then
FILES+=("$file")
fi
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
if [ ${#FILES[@]} -eq 0 ]; then
echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS"
: > SHA256SUMS
else
sha256sum "${FILES[@]}" > SHA256SUMS
fi
- name: Checkout code for changelog
uses: actions/checkout@v4
with:
fetch-depth: 0
path: repo-for-changelog
- name: Generate Changelog
id: changelog
shell: bash
run: |
set -euo pipefail
cd repo-for-changelog
TAG="${{ github.ref_name }}"
# 获取上一个 tag
PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true)
if [ -z "$PREV_TAG" ]; then
echo "⚠️ 未找到上一个 tag使用全部 commit"
RANGE="$TAG"
else
RANGE="${PREV_TAG}..${TAG}"
fi
echo "📋 生成更新日志:$RANGE"
# 提取 commit 消息(排除 merge commit
COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true)
if [ -z "$COMMITS" ]; then
BODY="暂无提交记录。"
else
CAT_FEAT=""
CAT_FIX=""
CAT_PERF=""
CAT_REFACTOR=""
CAT_I18N=""
CAT_OTHER=""
while IFS= read -r line; do
[ -z "$line" ] && continue
case "$line" in
✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;;
🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;;
⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;;
♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;;
🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;;
🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
esac
done <<< "$COMMITS"
BODY=""
[ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n"
[ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n"
[ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n"
[ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n"
[ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n"
[ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n"
# 附加 compare 链接
if [ -n "$PREV_TAG" ]; then
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n"
fi
fi
# 写入到文件避免多行环境变量问题
printf '%b' "$BODY" > /tmp/changelog.md
echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
- name: Create Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
@@ -138,5 +688,6 @@ jobs:
files: release-assets/*
draft: true
make_latest: true
body_path: ${{ steps.changelog.outputs.changelog_file }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,412 @@
name: Test Build All Platforms (Manual)
on:
workflow_dispatch:
inputs:
build_label:
description: "测试包标识(仅用于文件名)"
required: false
default: "test"
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
concurrency:
group: test-build-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: darwin/amd64
os_name: MacOS
arch_name: Amd64
build_name: gonavi-test-darwin-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: macos-latest
platform: darwin/arm64
os_name: MacOS
arch_name: Arm64
build_name: gonavi-test-darwin-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/amd64
os_name: Windows
arch_name: Amd64
build_name: gonavi-test-windows-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/arm64
os_name: Windows
arch_name: Arm64
build_name: gonavi-test-windows-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: ubuntu-22.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-test-linux-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-test-linux-amd64-webkit41
wails_tags: "webkit2_41"
artifact_suffix: "-WebKit41"
build_optional_agents: false
linux_webkit: "4.1"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$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
}
& $upxCmd --version
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
else
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
upx --version
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
echo "skip-appimage=true" >> "$GITHUB_ENV"
}
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
echo "skip-appimage=true" >> "$GITHUB_ENV"
}
if [ "${skip-appimage:-false}" != "true" ]; then
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
fi
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
id: msys2_duckdb
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
continue-on-error: true
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
shell: pwsh
run: |
function Find-MingwBin([string[]]$candidates) {
foreach ($bin in $candidates) {
if ([string]::IsNullOrWhiteSpace($bin)) {
continue
}
$gcc = Join-Path $bin 'gcc.exe'
$gxx = Join-Path $bin 'g++.exe'
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
return $bin
}
}
return $null
}
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
$candidateBins = @()
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
}
$candidateBins += @(
'C:\msys64\ucrt64\bin',
'D:\a\_temp\msys64\ucrt64\bin'
)
$candidateBins = @($candidateBins | Select-Object -Unique)
$mingwBin = Find-MingwBin $candidateBins
if (-not $mingwBin) {
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。"
exit 1
}
$gcc = Join-Path $mingwBin 'gcc.exe'
$gxx = Join-Path $mingwBin 'g++.exe'
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build App
shell: bash
run: |
set -euo pipefail
BUILD_LABEL="${{ inputs.build_label }}"
if [ -z "$BUILD_LABEL" ]; then
BUILD_LABEL="test"
fi
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
if [ -n "${{ matrix.wails_tags }}" ]; then
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
else
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
fi
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents }}
shell: bash
run: |
set -euo pipefail
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "跳过 DuckDB driver: ${GOOS}/${GOARCH}"
continue
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
if [ "$DRIVER" = "duckdb" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
fi
done
- name: Package macOS
if: contains(matrix.platform, 'darwin')
shell: bash
run: |
set -euo pipefail
brew install create-dmg
LABEL="${{ inputs.build_label }}"
if [ -z "$LABEL" ]; then
LABEL="test"
fi
cd build/bin
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$APP_PATH" ]; then
echo "未找到 .app 应用包"
exit 1
fi
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 应用主程序"
exit 1
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"
mkdir -p ../../artifacts
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "../../artifacts/$ZIP_NAME"
create-dmg \
--volname "GoNavi Test Installer" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_NAME" 200 190 \
--hide-extension "$APP_NAME" \
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
mv "$DMG_NAME" "../../artifacts/$DMG_NAME"
shasum -a 256 "../../artifacts/$ZIP_NAME" > "../../artifacts/$ZIP_NAME.sha256"
shasum -a 256 "../../artifacts/$DMG_NAME" > "../../artifacts/$DMG_NAME.sha256"
- name: Package Windows
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$label = "${{ inputs.build_label }}"
if ([string]::IsNullOrWhiteSpace($label)) { $label = 'test' }
Set-Location build/bin
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
} elseif (Test-Path "$target") {
Rename-Item -Path "$target" -NewName "$target.exe"
$finalExe = "$target.exe"
} else {
Write-Error "未找到构建产物 '$target'"
exit 1
}
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
if ($isArm64Target) {
Write-Warning "⚠️ UPX 当前不支持 win64/arm64跳过压缩并保留原始 EXE。"
$LASTEXITCODE = 0
} else {
$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
Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii
- name: Package Linux
if: contains(matrix.platform, 'linux')
shell: bash
run: |
set -euo pipefail
LABEL="${{ inputs.build_label }}"
if [ -z "$LABEL" ]; then
LABEL="test"
fi
cd build/bin
TARGET="${{ matrix.build_name }}"
TAR_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.tar.gz"
APPIMAGE_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.AppImage"
mkdir -p ../../artifacts
if [ ! -f "$TARGET" ]; then
echo "未找到构建产物 '$TARGET'"
exit 1
fi
chmod +x "$TARGET"
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
echo "🗜️ 使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
upx --best --lzma --force "$TARGET"
upx -t "$TARGET"
AFTER_BYTES=$(wc -c <"$TARGET" | 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 "✅ Linux 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 " Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
tar -czvf "../../artifacts/$TAR_NAME" "$TARGET"
sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256"
if [ "${skip-appimage:-false}" = "true" ]; then
echo "跳过 AppImage 打包"
exit 0
fi
mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
printf '%s\n' '[Desktop Entry]' 'Name=GoNavi' 'Exec=gonavi' 'Icon=gonavi' 'Type=Application' 'Categories=Development;Database;' 'Comment=Database Management Tool' > AppDir/usr/share/applications/gonavi.desktop
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
if [ -f "../../build/appicon.png" ]; then
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
cp "../../build/appicon.png" AppDir/gonavi.png
else
touch AppDir/gonavi.png
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
fi
export DEPLOY_GTK_VERSION=3
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || exit 0
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || exit 0
mv "$APPIMAGE_NAME" "../../artifacts/$APPIMAGE_NAME"
sha256sum "../../artifacts/$APPIMAGE_NAME" > "../../artifacts/$APPIMAGE_NAME.sha256"
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: test-build-${{ matrix.build_name }}-run${{ github.run_number }}
path: |
artifacts/*
drivers/**
if-no-files-found: error
retention-days: 7

94
.github/workflows/test-macos-build.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
name: Test Build macOS (Manual)
on:
workflow_dispatch:
inputs:
build_label:
description: "测试包标识(仅用于文件名)"
required: false
default: "test"
push:
branches:
- feature/kingbase_opt
paths:
- ".github/workflows/test-macos-build.yml"
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build-macos:
name: Build macOS ${{ matrix.arch }}
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- platform: darwin/amd64
arch: amd64
- platform: darwin/arm64
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.3"
check-latest: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
- name: Build App
run: |
set -euo pipefail
OUTPUT_NAME="gonavi-test-${{ matrix.arch }}"
BUILD_LABEL="${{ inputs.build_label }}"
if [ -z "$BUILD_LABEL" ]; then
BUILD_LABEL="test"
fi
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
wails build \
-platform "${{ matrix.platform }}" \
-clean \
-o "$OUTPUT_NAME" \
-ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
- name: Package Zip
run: |
set -euo pipefail
APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app"
if [ ! -d "$APP_PATH" ]; then
APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true)
fi
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
echo "未找到 .app 产物"
ls -la build/bin || true
exit 1
fi
LABEL="${{ inputs.build_label }}"
if [ -z "$LABEL" ]; then
LABEL="test"
fi
ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip"
mkdir -p artifacts
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME"
shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256"
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }}
path: artifacts/*
if-no-files-found: error

9
.gitignore vendored
View File

@@ -6,7 +6,7 @@
frontend/release/
**/release/
**/dist/
**/build/
build/bin/
# wails / node artifacts (按需)
node_modules/
@@ -17,3 +17,10 @@ dist/
GoNavi-Wails
GoNavi-Wails.exe
.ace-tool/
.superpowers/
.claude/
.gemini/
**/tmpclaude-*
CLAUDE.md
**/CLAUDE.md

155
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,155 @@
# Contributing Guide
Thank you for contributing to this project.
This repository follows a release-first workflow: `main` is the default public branch, while releases are prepared through `release/*` branches.
---
## Branch Model
- `main`: stable release branch and default branch
- `dev`: day-to-day integration branch for maintainers
- `release/*`: release preparation branches for maintainers
- Recommended branch names for external contributors:
- `fix/*`: bug fixes
- `feature/*`: new features or enhancements
Maintainer release flow:
```text
feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
```
---
## How External Contributors Should Open Pull Requests
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `main`**.
Reasons:
- `main` is the default branch, so the PR entry point is clearer
- merged contributions are immediately visible on the default branch
- maintainers can handle downstream sync and release preparation in one place
Recommended flow:
1. Fork this repository
2. Create a branch in your fork (`fix/*` or `feature/*` is recommended)
3. Make your changes and perform basic self-checks
4. Push the branch to your fork
5. Open a pull request against the `main` branch of this repository
---
## Pull Request Requirements
Please keep each pull request focused, reviewable, and easy to validate.
Recommended expectations:
- one pull request should address one logical change
- use a clear title that explains the purpose
- include the following in the description:
- background and problem statement
- key changes
- impact scope
- validation method
- include screenshots or recordings for UI changes when helpful
- explicitly mention risk and rollback notes for compatibility, data, or build-chain changes
---
## Merge Strategy for Maintainers
Pull requests merged into `main` should generally use **Squash and merge**.
Reasons:
- keeps `main` history clean and linear
- maps each PR to a single commit on `main`
- reduces release, audit, and rollback complexity
---
## Maintainer Sync Rules
Because external pull requests are merged directly into `main`, maintainers must sync `main` back to development and release branches to avoid branch drift.
### 1. Sync `main` -> `dev` (required)
The automatic GitHub Actions sync workflow has been removed.
Maintainers should sync `main` back to `dev` manually when needed:
```bash
git checkout dev
git pull
git merge main
git push
```
### 2. Create `release/*` from `dev`
Before a release, create a release branch from `dev`, for example:
```bash
git checkout dev
git pull
git checkout -b release/v0.6.0
git push -u origin release/v0.6.0
```
### 3. Release from `release/*` back to `main`
When release preparation is complete, merge the release branch back into `main` and create a tag:
```bash
git checkout main
git pull
git merge release/v0.6.0
git push
git tag v0.6.0
git push origin v0.6.0
```
### 4. Sync `main` back to `dev` after release
After the release, the same automation still applies. If needed, you can run the workflow manually (`workflow_dispatch`) or execute the fallback commands:
```bash
git checkout dev
git pull
git merge main
git push
```
---
## Commit Message Recommendation
Keep commit messages clear and easy to audit.
Recommended format:
```text
emoji type(scope): concise description
```
Examples:
```text
🔧 fix(ci): fix DuckDB driver toolchain on Windows AMD64
✨ feat(redis): add Stream data browsing support
♻️ refactor(datagrid): optimize large-table horizontal scrolling and rendering
```
---
## Additional Notes
- Please include validation results for documentation, build-chain, or driver compatibility changes
- For larger changes, opening an issue or draft PR first is recommended
- Maintainers may ask contributors to narrow the scope if the change conflicts with the current project direction
Thank you for contributing.

155
CONTRIBUTING.zh-CN.md Normal file
View File

@@ -0,0 +1,155 @@
# 贡献指南
感谢你对本项目的贡献。
本项目采用“发布优先(`main` 为默认分支)+ `release/*` 分支发版”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
---
## 分支模型
- `main`:稳定发布分支,也是仓库默认分支
- `dev`:日常开发集成分支,主要供维护者使用
- `release/*`:发布准备分支,主要供维护者使用
- 外部贡献者建议使用以下分支命名:
- `fix/*`:问题修复
- `feature/*`:功能新增或增强
维护者发布流转如下:
```text
feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
```
---
## 外部贡献者如何提 Pull Request
无论是 `fix/*` 还是 `feature/*`**外部贡献者统一直接向 `main` 发起 Pull Request**。
这样做的原因:
- `main` 是默认分支PR 入口更直观
- 合并后贡献会直接体现在默认分支
- 便于维护者统一做后续同步与发版整理
建议流程:
1. Fork 本仓库
2. 从你自己的仓库创建分支(建议命名为 `fix/*``feature/*`
3. 完成代码修改,并进行必要自检
4. 推送到你的远程分支
5. 向本仓库的 `main` 分支发起 Pull Request
---
## Pull Request 要求
请尽量保证 PR 单一、清晰、可审核。
建议遵循以下要求:
- 一个 PR 只解决一类问题,避免混入无关改动
- 标题清晰说明改动目的
- 描述中说明:
- 背景与问题
- 变更点
- 影响范围
- 验证方式
- 如涉及 UI 调整,建议附截图或录屏
- 如涉及兼容性、数据变更或构建链路调整,请明确说明风险和回滚方式
---
## PR 合并策略(维护者)
`main` 分支上的 PR 建议使用 **Squash and merge**
原因:
- 保持 `main` 历史干净、线性
- 每个 PR 在 `main` 上对应一个清晰提交
- 降低发布排查与回滚成本
---
## 维护者同步规则
由于外部 PR 会直接合入 `main`,维护者必须及时将 `main` 的变更同步到开发与发布分支,避免分支漂移。
### 1. main → dev 同步(必做)
仓库已移除 GitHub Actions 自动回灌 workflow。
当前统一采用手动方式将 `main` 同步回 `dev`
```bash
git checkout dev
git pull
git merge main
git push
```
### 2. 发版前从 dev 切 release/*
发布前由维护者基于 `dev` 创建发布分支,例如:
```bash
git checkout dev
git pull
git checkout -b release/v0.6.0
git push -u origin release/v0.6.0
```
### 3. release/* → main 发版
发布准备完成后,将 `release/*` 合并回 `main`,并打标签发布:
```bash
git checkout main
git pull
git merge release/v0.6.0
git push
git tag v0.6.0
git push origin v0.6.0
```
### 4. main 回流到 dev发版后必做
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致:
```bash
git checkout dev
git pull
git merge main
git push
```
---
## 提交建议
建议保持提交信息简洁、明确,便于维护者审查与后续追踪。
推荐格式:
```text
emoji type(scope): 中文描述
```
示例:
```text
🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链
✨ feat(redis): 新增 Stream 类型数据浏览支持
♻️ refactor(datagrid): 优化大表横向滚动与渲染结构
```
---
## 其他说明
- 文档、构建链路、驱动兼容性相关改动,请尽量附带验证结果
- 若改动较大,建议先提 Issue 或 Draft PR先对齐方案再实施
- 如提交内容与项目当前架构方向冲突,维护者可能要求收敛范围后再合并
感谢你的贡献。

214
README.md
View File

@@ -1,4 +1,4 @@
# GoNavi - 现代化的轻量级数据库管理工具
# GoNavi - A Modern Lightweight Database Client
[![Go Version](https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi)](https://go.dev/)
[![Wails Version](https://img.shields.io/badge/Wails-v2-red)](https://wails.io)
@@ -6,11 +6,51 @@
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
**GoNavi** 是一款基于 **Wails (Go)****React** 构建的现代化、高性能、跨平台数据库管理客户端。它旨在提供如原生应用般流畅的用户体验,同时保持极低的资源占用。
**Language**: English | [简体中文](README.zh-CN.md)
相比于 Electron 应用GoNavi 的体积更小(~10MB启动速度更快内存占用更低。
GoNavi is a modern, high-performance, cross-platform database client built with **Wails (Go)** and **React**.
It delivers native-like responsiveness with low resource usage.
<h2 align="center">📸 项目截图</h2>
Compared with many Electron-based clients, GoNavi is typically smaller in binary size (around 10MB class), starts faster, and uses less memory.
---
## Project Overview
GoNavi is designed for developers and DBAs who need a unified desktop experience across multiple databases.
- **Native-performance architecture**: Wails (Go + WebView) with lightweight runtime overhead.
- **Large dataset usability**: virtualized rendering and optimized DataGrid workflows for high-volume tables.
- **Unified connectivity**: URI build/parse, SSH tunnel, proxy support, and on-demand driver activation.
- **Production-oriented workflow**: SQL editor, object management, batch export/backup, sync tools, execution logs, and update checks.
## Supported Data Sources
> `Built-in`: available out of the box.
> `Optional driver agent`: install/enable via Driver Manager first.
| Category | Data Source | Driver Mode | Typical Capabilities |
|---|---|---|---|
| Relational | MySQL | Built-in | Schema browsing, SQL query, data editing, export/backup |
| Relational | PostgreSQL | Built-in | Schema browsing, SQL query, data editing, object management |
| Relational | Oracle | Built-in | Query execution, object browsing, data editing |
| Cache | Redis | Built-in | Key browsing, command execution, encoding/view switch |
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
| Search | Sphinx | Optional driver agent | SphinxQL querying and object browsing |
| Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management |
| File-based | SQLite | Optional driver agent | Local DB browsing, editing, export |
| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow |
| Domestic DB | Dameng | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | Kingbase | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | HighGo | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | Vastbase | Optional driver agent | Querying, object browsing, data editing |
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
<h2 align="center">📸 Screenshots</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
@@ -24,111 +64,149 @@
---
## ✨ 核心特性
## Key Features
### 🚀 极致性能
- **零卡顿交互**:采用独创的 "幽灵拖拽" (Ghost Resizing) 技术,在包含数万行数据的表格中调整列宽,依然保持 60fps+ 的丝滑体验。
- **虚拟滚动**:轻松处理海量数据展示,拒绝卡顿。
### Performance
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
- **Virtualized rendering**: keeps large result sets responsive.
### 🔌 多数据库支持
- **MySQL**:完整的支持,包括表结构设计、索引管理、外键管理等。
- **PostgreSQL**:基础支持(持续完善中)。
- **SQLite**:本地文件数据库支持。
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
### Data Management (DataGrid)
- In-place cell editing.
- Batch insert/update/delete with transaction-oriented submit/rollback.
- Large-field popup editor.
- Context actions (set NULL, copy/export, etc.).
- Smart read/write mode switching based on query context.
- Export formats: CSV, Excel (XLSX), JSON, Markdown.
### 📊 强大的数据管理 (DataGrid)
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
- **事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
- **数据导出**:支持导出为 CSV, Excel (XLSX), JSON, Markdown 等格式。
### SQL Editor
- Monaco Editor core.
- Context-aware completion for databases/tables/columns.
- Multi-tab query workflow.
### 📝 智能 SQL 编辑器
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
- **智能补全**:自动感知当前连接上下文,提供数据库、表名、字段名的实时补全。
- **多标签页**:支持多窗口并行操作,像浏览器一样管理你的查询会话。
### Batch Export / Backup
- Database-level and table-level batch export/backup.
- Scope-aware operation flow to reduce mistakes.
### 🎨 现代化 UI
- **Ant Design 5**:企业级 UI 设计语言。
- **暗黑模式**:内置深色/浅色主题切换,适应不同光照环境。
- **响应式布局**:灵活的侧边栏与布局调整。
### Connectivity
- URI generation/parsing.
- SSH tunnel support.
- Proxy support.
- Config import/export (JSON).
- Optional driver management and activation.
### Redis Tools
- Multi-view value rendering (auto/raw text/UTF-8/hex).
- Built-in command execution panel.
### Observability and Update
- SQL execution logs with timing information.
- Startup/scheduled/manual update checks.
### UI/UX
- Ant Design 5 based interface.
- Light/Dark themes.
- Flexible sidebar and layout behavior.
---
## 🛠️ 技术栈
## Tech Stack
* **后端 (Backend)**: Go 1.24 + Wails v2
* **前端 (Frontend)**: React 18 + TypeScript + Vite
* **UI 框架**: Ant Design 5
* **状态管理**: Zustand
* **编辑器**: Monaco Editor
- **Backend**: Go 1.24 + Wails v2
- **Frontend**: React 18 + TypeScript + Vite
- **UI**: Ant Design 5
- **State Management**: Zustand
- **Editor**: Monaco Editor
---
## 📦 安装与运行
## Installation and Run
### 前置要求
* [Go](https://go.dev/dl/) 1.21+
* [Node.js](https://nodejs.org/) 18+
* [Wails CLI](https://wails.io/docs/gettingstarted/installation): `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
### Prerequisites
- [Go](https://go.dev/dl/) 1.21+
- [Node.js](https://nodejs.org/) 18+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
### 开发模式
### Development Mode
```bash
# 克隆项目
# Clone
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
# 启动开发服务器 (支持热重载)
# Start development with hot reload
wails dev
```
### 编译构建
### Build
```bash
# 构建当前平台的可执行文件
# Build for current platform
wails build
# 清理并构建 (推荐发布前使用)
# Clean build (recommended before release)
wails build -clean
```
构建产物将位于 `build/bin` 目录下。
Artifacts are generated in `build/bin`.
### 跨平台编译 (GitHub Actions)
### Cross-Platform Release (GitHub Actions)
本项目内置了 GitHub Actions 流水线Push `v*` 格式的 Tag 即可自动触发构建并发布 Release。
支持构建:
* macOS (AMD64 / ARM64)
* Windows (AMD64)
The repository includes a release workflow.
Push a `v*` tag to trigger automated build and release.
Release notes are generated automatically from merged pull requests and categorized by `.github/release.yaml`.
Target artifacts include:
- macOS (AMD64 / ARM64)
- Windows (AMD64)
- Linux (AMD64, WebKitGTK 4.0 and 4.1 variants)
---
## ❓ 常见问题 (Troubleshooting)
## Troubleshooting
### macOS 提示 "应用已损坏,无法打开"
### macOS: "App is damaged and cant be opened"
由于本项目尚未购买 Apple 开发者证书进行签名NotarizationmacOS 的 Gatekeeper 安全机制可能会拦截应用的运行。请按照以下步骤解决:
Without Apple notarization, Gatekeeper may block startup.
1. 将下载的 `GoNavi.app` 拖入 **应用程序** 文件夹。
2. 打开 **终端 (Terminal)**
3. 复制并执行以下命令(输入密码时不会显示):
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
1. Move `GoNavi.app` to **Applications**.
2. Open **Terminal**.
3. Run:
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
Or right-click the app in Finder and choose **Open** with Control key flow.
### Linux: missing `libwebkit2gtk` / `libjavascriptcoregtk`
GoNavi depends on WebKitGTK runtime libraries.
```bash
# Debian 13 / Ubuntu 24.04+
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0
# Ubuntu 22.04 / Debian 12
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
```
If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+.
---
## 🤝 贡献指南
## Contributing
欢迎提交 Issue 和 Pull Request
Issues and pull requests are welcome.
1. Fork 本仓库
2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交你的改动 (`git commit -m 'feat: Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启一个 Pull Request
For the full workflow, branch model, and maintainer sync rules, see:
## 📄 开源协议
- [CONTRIBUTING.md](CONTRIBUTING.md)
本项目采用 [Apache-2.0 协议](LICENSE) 开源。
External contributors should open pull requests directly against `main`.
## License
Licensed under [Apache-2.0](LICENSE).

195
README.zh-CN.md Normal file
View File

@@ -0,0 +1,195 @@
# GoNavi - 现代化轻量级数据库客户端
[![Go Version](https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi)](https://go.dev/)
[![Wails Version](https://img.shields.io/badge/Wails-v2-red)](https://wails.io)
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
**语言**: [English](README.md) | 简体中文
GoNavi 是基于 **Wails (Go)****React** 构建的跨平台数据库管理工具,强调原生性能、低资源占用与多数据源统一工作流。
相比常见 Electron 客户端GoNavi 在体积、启动速度和内存占用上更轻量。
---
## 项目简介
GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做到“快、稳、统一”。
- **原生性能架构**WailsGo + WebView降低运行时开销。
- **大数据可用性**:虚拟滚动 + DataGrid 交互优化,提升大结果集可操作性。
- **统一连接能力**:支持 URI 生成/解析、SSH 隧道、代理、驱动按需安装。
- **工程化能力完整**:覆盖 SQL 编辑、对象管理、批量导出/备份、数据同步、执行日志、在线更新。
## 支持的数据源
> `内置`:主程序开箱即用。
> `可选驱动代理`:需在驱动管理中安装启用后可用。
| 类别 | 数据源 | 驱动模式 | 典型能力 |
|---|---|---|---|
| 关系型 | MySQL | 内置 | 库表浏览、SQL 查询、数据编辑、导出/备份 |
| 关系型 | PostgreSQL | 内置 | 库表浏览、SQL 查询、数据编辑、对象管理 |
| 关系型 | Oracle | 内置 | 连接查询、对象浏览、数据编辑 |
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
| 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 |
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
<h2 align="center">📸 项目截图</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
</div>
---
## 核心特性
### 性能与交互
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
- 虚拟滚动渲染,降低大结果集卡顿风险。
### 数据管理DataGrid
- 单元格所见即所得编辑。
- 批量新增/修改/删除,支持事务提交与回滚。
- 大字段弹窗编辑。
- 右键上下文操作NULL、复制、导出等
- 根据查询上下文智能切换读写模式。
- 支持 CSV / XLSX / JSON / Markdown 导出。
### SQL 编辑器
- 基于 Monaco Editor。
- 上下文补全(数据库/表/字段)。
- 多标签查询工作流。
### 连接与驱动
- URI 生成与解析。
- SSH 隧道、代理支持。
- 连接配置 JSON 导入/导出。
- 可选驱动安装与启用管理。
### Redis 工具
- 自动/原始文本/UTF-8/十六进制等视图模式。
- 内置命令执行面板。
### 可观测性与更新
- SQL 执行日志(含耗时)。
- 启动/定时/手动更新检查。
### UI 体验
- Ant Design 5 体系。
- 深色/浅色主题切换。
- 灵活布局与侧边栏行为。
---
## 技术栈
- **后端**: Go 1.24 + Wails v2
- **前端**: React 18 + TypeScript + Vite
- **UI 框架**: Ant Design 5
- **状态管理**: Zustand
- **编辑器**: Monaco Editor
---
## 安装与运行
### 前置要求
- [Go](https://go.dev/dl/) 1.21+
- [Node.js](https://nodejs.org/) 18+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
### 开发模式
```bash
# 克隆项目
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
# 启动开发(热重载)
wails dev
```
### 编译构建
```bash
# 构建当前平台
wails build
# 清理后构建(发布前推荐)
wails build -clean
```
构建产物位于 `build/bin`
### 跨平台发布GitHub Actions
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
Release 更新说明会基于已合并 Pull Request 自动生成,并按 `.github/release.yaml` 分类。
支持目标:
- macOS (AMD64 / ARM64)
- Windows (AMD64)
- Linux (AMD64含 WebKitGTK 4.0 / 4.1 变体)
---
## 常见问题
### macOS 提示“应用已损坏,无法打开”
在未进行 Apple Notarization 时Gatekeeper 可能拦截应用。
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
### Linux 缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
```bash
# Debian 13 / Ubuntu 24.04+
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0
# Ubuntu 22.04 / Debian 12
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
```
---
## 贡献指南
欢迎提交 Issue 与 Pull Request。
完整流程、分支模型与维护者同步规则请查看:
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
外部贡献者统一直接向 `main` 发起 Pull Request。
## 开源协议
本项目采用 [Apache-2.0 协议](LICENSE)。

228
build-driver-agents.sh Executable file
View File

@@ -0,0 +1,228 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
usage() {
cat <<'EOF'
用法:
./build-driver-agents.sh [选项]
选项:
--drivers <列表> 指定驱动列表逗号分隔例如kingbase,mongodb
--platform <GOOS/GOARCH>
目标平台,默认使用当前 Go 环境go env GOOS/GOARCH
--out-dir <目录> 输出目录根路径默认dist/driver-agents
--bundle-name <文件名> 驱动总包 zip 名称默认GoNavi-DriverAgents.zip
--strict 任一驱动构建失败即中断(默认失败后继续,最后汇总)
-h, --help 显示帮助
示例:
./build-driver-agents.sh
./build-driver-agents.sh --drivers kingbase
./build-driver-agents.sh --platform windows/amd64 --drivers kingbase,mongodb
EOF
}
normalize_driver() {
local name
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$name" in
doris|diros) echo "doris" ;;
mariadb|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
echo "$name"
;;
*)
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
platform_dir_name() {
case "$1" in
windows) echo "Windows" ;;
darwin) echo "MacOS" ;;
linux) echo "Linux" ;;
*) echo "Unknown" ;;
esac
}
driver_csv=""
target_platform=""
out_root="dist/driver-agents"
bundle_name="GoNavi-DriverAgents.zip"
strict_mode="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--drivers)
driver_csv="${2:-}"
shift 2
;;
--platform)
target_platform="${2:-}"
shift 2
;;
--out-dir)
out_root="${2:-}"
shift 2
;;
--bundle-name)
bundle_name="${2:-}"
shift 2
;;
--strict)
strict_mode="true"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "❌ 未知参数:$1"
usage
exit 1
;;
esac
done
if ! command -v go >/dev/null 2>&1; then
echo "❌ 未找到 Go请先安装 Go 并确保 go 在 PATH 中。"
exit 1
fi
if [[ -z "$target_platform" ]]; then
target_platform="$(go env GOOS)/$(go env GOARCH)"
fi
if [[ "$target_platform" != */* ]]; then
echo "❌ --platform 参数格式错误,应为 GOOS/GOARCH例如 darwin/arm64"
exit 1
fi
goos="${target_platform%%/*}"
goarch="${target_platform##*/}"
platform_key="${goos}-${goarch}"
platform_dir="$(platform_dir_name "$goos")"
declare -a drivers=()
if [[ -n "$driver_csv" ]]; then
IFS=',' read -r -a raw_drivers <<<"$driver_csv"
for item in "${raw_drivers[@]}"; do
normalized="$(normalize_driver "$item")" || {
echo "❌ 不支持的驱动:$item"
exit 1
}
drivers+=("$normalized")
done
else
drivers=("${DEFAULT_DRIVERS[@]}")
fi
output_dir="${out_root%/}/${platform_key}"
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
cleanup() {
rm -rf "$bundle_stage_dir"
}
trap cleanup EXIT
mkdir -p "$output_dir" "$bundle_platform_dir"
output_dir_abs="$(cd "$output_dir" && pwd)"
bundle_zip_path="$output_dir_abs/$bundle_name"
declare -a built_assets=()
declare -a failed_drivers=()
declare -a skipped_drivers=()
echo "🚀 开始构建 optional-driver-agent"
echo " 平台:$goos/$goarch"
echo " 输出目录:$output_dir_abs"
echo " 驱动列表:${drivers[*]}"
for driver in "${drivers[@]}"; do
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
echo "⚠️ 跳过 duckdb仅支持 windows/amd64"
skipped_drivers+=("$driver")
continue
fi
build_driver="$(build_driver_name "$driver")"
tag="gonavi_${build_driver}_driver"
asset_name="${driver}-driver-agent-${goos}-${goarch}"
if [[ "$goos" == "windows" ]]; then
asset_name="${asset_name}.exe"
fi
output_path="$output_dir_abs/$asset_name"
cgo_enabled=0
if [[ "$driver" == "duckdb" ]]; then
cgo_enabled=1
fi
echo "🔧 构建 $driver -> $asset_name (tag=$tag, CGO_ENABLED=$cgo_enabled)"
set +e
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
go build -tags "$tag" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
build_exit=$?
set -e
if [[ $build_exit -ne 0 ]]; then
echo "❌ 构建失败:$driver"
failed_drivers+=("$driver")
if [[ "$strict_mode" == "true" ]]; then
exit $build_exit
fi
continue
fi
cp "$output_path" "$bundle_platform_dir/$asset_name"
built_assets+=("$asset_name")
done
if [[ ${#built_assets[@]} -eq 0 ]]; then
echo "❌ 未成功构建任何驱动代理。"
exit 1
fi
rm -f "$bundle_zip_path"
if command -v zip >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
zip -qry "$bundle_zip_path" "$platform_dir"
)
elif command -v ditto >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
ditto -c -k --sequesterRsrc --keepParent "$platform_dir" "$bundle_zip_path"
)
else
echo "❌ 未找到 zip/ditto无法生成驱动总包 zip。"
exit 1
fi
echo ""
echo "✅ 构建完成"
echo " 单文件输出目录:$output_dir_abs"
echo " 驱动总包:$bundle_zip_path"
echo " 已构建:${built_assets[*]}"
if [[ ${#skipped_drivers[@]} -gt 0 ]]; then
echo " 已跳过:${skipped_drivers[*]}"
fi
if [[ ${#failed_drivers[@]} -gt 0 ]]; then
echo "⚠️ 构建失败驱动:${failed_drivers[*]}"
exit 2
fi

View File

@@ -12,6 +12,7 @@ if [ -z "$VERSION" ]; then
VERSION="0.0.0"
fi
echo " 检测到版本号: $VERSION"
LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION"
# 颜色配置
GREEN='\033[0;32m'
@@ -19,6 +20,75 @@ RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
get_file_size_bytes() {
local target="$1"
if [ ! -f "$target" ]; then
echo 0
return
fi
if stat -f%z "$target" >/dev/null 2>&1; then
stat -f%z "$target"
return
fi
if stat -c%s "$target" >/dev/null 2>&1; then
stat -c%s "$target"
return
fi
wc -c <"$target" | tr -d '[:space:]'
}
format_size_mb() {
local bytes="${1:-0}"
awk -v b="$bytes" 'BEGIN { printf "%.2fMB", b / 1024 / 1024 }'
}
try_compress_binary_with_upx() {
local exe_path="$1"
local label="$2"
if [ ! -f "$exe_path" ]; then
echo -e "${RED} ❌ 未找到 ${label} 文件:$exe_path${NC}"
exit 1
fi
if ! command -v upx >/dev/null 2>&1; then
echo -e "${RED} ❌ 未找到 upx${label} 必须进行压缩后才能继续打包。${NC}"
case "$(uname -s)" in
Darwin)
echo " 安装命令: brew install upx"
;;
Linux)
echo " 安装命令: sudo apt-get install -y upx-ucl (或对应发行版包管理器)"
;;
esac
exit 1
fi
local before_bytes after_bytes
before_bytes=$(get_file_size_bytes "$exe_path")
echo " 🗜️ 正在使用 UPX 压缩 ${label}..."
if upx --best --lzma --force "$exe_path" >/dev/null 2>&1; then
if ! upx -t "$exe_path" >/dev/null 2>&1; then
echo -e "${RED} ❌ UPX 校验失败:${label}${NC}"
exit 1
fi
after_bytes=$(get_file_size_bytes "$exe_path")
if [ "$after_bytes" -lt "$before_bytes" ]; then
local saved_bytes=$((before_bytes - after_bytes))
echo " ✅ UPX 压缩完成: $(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes"),减少 $(format_size_mb "$saved_bytes")"
else
echo " UPX 压缩完成: $(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes")"
fi
else
echo -e "${RED} ❌ UPX 压缩失败:${label}${NC}"
exit 1
fi
}
MAC_VOLICON_PATH="build/darwin/icon.icns"
if [ ! -f "$MAC_VOLICON_PATH" ]; then
MAC_VOLICON_PATH=""
fi
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
# 清理并创建输出目录
@@ -27,7 +97,7 @@ mkdir -p $DIST_DIR
# --- macOS ARM64 构建 ---
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
wails build -platform darwin/arm64 -clean
wails build -platform darwin/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
@@ -35,115 +105,361 @@ if [ $? -eq 0 ]; then
# 移动 .app 到 dist
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
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
echo -e "${YELLOW} ⚠️ macOS arm64 不再执行 UPX 压缩,保留原始主程序。${NC}"
else
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
exit 1
fi
# 创建 DMG
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (arm64)..."
# 移除已存在的 DMG (以防万一)
rm -f "$DIST_DIR/$DMG_NAME"
create-dmg \
--volname "${APP_NAME} ${VERSION}" \
--volicon "build/appicon.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$DIST_DIR/$APP_DEST_NAME"
# 检查是否生成了 rw.* 的临时文件并重命名 (create-dmg 有时会有此行为)
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到临时文件名,正在重命名...${NC}"
mv "$RW_FILE" "$DIST_DIR/$DMG_NAME"
fi
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
# 创建 DMG
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (arm64)..."
# 移除已存在的 DMG (以防万一)
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
else
if command -v ditto &> /dev/null; then
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
else
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
else
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
fi
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
if [ -f "$DIST_DIR/$DMG_NAME" ]; then
echo " ✅ 已生成 $DMG_NAME"
else
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}"
create-dmg "${CREATE_DMG_ARGS[@]}" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$STAGE_DIR"
CREATE_DMG_EXIT_CODE=$?
rm -rf "$STAGE_DIR"
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
else
# create-dmg 可能会在失败时遗留 rw.*.dmg 中间产物;不要直接当作最终产物使用
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
rm -f "$RW_FILE"
fi
fi
# 防御性:即使生成了目标文件,也要确保不是 UDRWUDRW 在 Finder 下可能表现为“已损坏/无法打开”)
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
if [ "$DMG_FORMAT" = "UDRW" ]; then
echo -e "${YELLOW} ⚠️ 检测到 UDRW可写原始映像正在转换为 UDZO...${NC}"
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
rm -f "$TMP_UDZO"
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
echo " 安装命令: brew install create-dmg"
fi
else
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}"
fi
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
echo " 安装命令: brew install create-dmg"
fi
else
echo -e "${RED} ❌ macOS arm64 构建失败。${NC}"
fi
# --- macOS AMD64 构建 ---
echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}"
wails build -platform darwin/amd64 -clean
wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app"
DMG_NAME="${APP_NAME}-${VERSION}-mac-amd64.dmg"
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (amd64)..."
rm -f "$DIST_DIR/$DMG_NAME"
create-dmg \
--volname "${APP_NAME} ${VERSION}" \
--volicon "build/appicon.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$DIST_DIR/$APP_DEST_NAME"
# 检查是否生成了 rw.* 的临时文件并重命名
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到临时文件名,正在重命名...${NC}"
mv "$RW_FILE" "$DIST_DIR/$DMG_NAME"
fi
fi
rm -rf "$DIST_DIR/$APP_DEST_NAME"
if [ -f "$DIST_DIR/$DMG_NAME" ]; then
echo " ✅ 已生成 $DMG_NAME"
else
echo -e "${RED} ❌ DMG 生成失败。${NC}"
fi
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
echo -e "${YELLOW} ⚠️ macOS amd64 不再执行 UPX 压缩,保留原始主程序。${NC}"
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具${NC}"
echo -e "${RED} 未找到 macOS amd64 主程序文件${NC}"
exit 1
fi
else
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
# Ad-hoc 代码签名
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (amd64)..."
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-amd64.XXXXXX")
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
else
if command -v ditto &> /dev/null; then
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
else
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
else
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
fi
create-dmg "${CREATE_DMG_ARGS[@]}" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_DEST_NAME" 200 190 \
--hide-extension "$APP_DEST_NAME" \
--app-drop-link 600 185 \
"$DIST_DIR/$DMG_NAME" \
"$STAGE_DIR"
CREATE_DMG_EXIT_CODE=$?
rm -rf "$STAGE_DIR"
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
else
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
if [ -n "$RW_FILE" ]; then
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
rm -f "$RW_FILE"
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
if [ "$DMG_FORMAT" = "UDRW" ]; then
echo -e "${YELLOW} ⚠️ 检测到 UDRW可写原始映像正在转换为 UDZO...${NC}"
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
rm -f "$TMP_UDZO"
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
fi
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
echo -e "${RED} ❌ DMG 生成失败。${NC}"
fi
fi
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具。${NC}"
fi
else
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
fi
# --- Windows AMD64 构建 ---
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
wails build -platform windows/amd64 -clean
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
try_compress_binary_with_upx "$TARGET_EXE" "Windows amd64 可执行文件"
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
else
echo -e "${RED} ❌ Windows 构建失败。${NC}"
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows 构建。${NC}"
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows amd64 构建。${NC}"
fi
# --- Windows ARM64 构建 ---
echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}"
if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
echo -e "${YELLOW} ⚠️ 当前 UPX 不支持 win64/arm64跳过 Windows arm64 压缩。${NC}"
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
else
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 MinGW ARM64 工具 (aarch64-w64-mingw32-gcc),跳过 Windows arm64 构建。${NC}"
echo " 安装命令: brew install mingw-w64 (需要支持 ARM64 的版本)"
fi
# --- Linux AMD64 构建 ---
echo -e "${GREEN}🐧 正在构建 Linux (amd64)...${NC}"
# 检测当前系统
CURRENT_OS=$(uname -s)
CURRENT_ARCH=$(uname -m)
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
# 本机 Linux amd64直接构建
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux amd64 可执行文件"
# 打包为 tar.gz
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
rm "${APP_NAME}-${VERSION}-linux-amd64"
cd ..
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
else
echo -e "${RED} ❌ Linux amd64 构建失败。${NC}"
fi
elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
# macOS 或其他系统,尝试交叉编译
export CC=x86_64-linux-gnu-gcc
export CXX=x86_64-linux-gnu-g++
export CGO_ENABLED=1
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux amd64 可执行文件"
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-amd64.tar.gz" "${APP_NAME}-${VERSION}-linux-amd64"
rm "${APP_NAME}-${VERSION}-linux-amd64"
cd ..
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
else
echo -e "${RED} ❌ Linux amd64 交叉编译失败。${NC}"
fi
unset CC CXX CGO_ENABLED
else
echo -e "${YELLOW} ⚠️ 非 Linux 系统且未找到交叉编译工具,跳过 Linux amd64 构建。${NC}"
echo " 在 Linux 上运行此脚本可直接构建,或安装交叉编译工具链。"
fi
# --- Linux ARM64 构建 ---
echo -e "${GREEN}🐧 正在构建 Linux (arm64)...${NC}"
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
# 本机 Linux arm64直接构建
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux arm64 可执行文件"
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
rm "${APP_NAME}-${VERSION}-linux-arm64"
cd ..
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
else
echo -e "${RED} ❌ Linux arm64 构建失败。${NC}"
fi
elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
# 交叉编译
export CC=aarch64-linux-gnu-gcc
export CXX=aarch64-linux-gnu-g++
export CGO_ENABLED=1
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
if [ $? -eq 0 ]; then
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$TARGET_LINUX_BIN"
chmod +x "$TARGET_LINUX_BIN"
try_compress_binary_with_upx "$TARGET_LINUX_BIN" "Linux arm64 可执行文件"
cd "$DIST_DIR"
tar -czvf "${APP_NAME}-${VERSION}-linux-arm64.tar.gz" "${APP_NAME}-${VERSION}-linux-arm64"
rm "${APP_NAME}-${VERSION}-linux-arm64"
cd ..
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
else
echo -e "${RED} ❌ Linux arm64 交叉编译失败。${NC}"
fi
unset CC CXX CGO_ENABLED
else
echo -e "${YELLOW} ⚠️ 非 Linux ARM64 系统且未找到交叉编译工具,跳过 Linux arm64 构建。${NC}"
echo " 安装命令 (Ubuntu): sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu"
echo " 安装命令 (macOS): brew install aarch64-linux-gnu-gcc (需要第三方 tap)"
fi
# 清理中间构建目录
rm -rf "build/bin"
echo -e "${GREEN}🔐 生成 SHA256SUMS...${NC}"
if command -v sha256sum &> /dev/null; then
cd "$DIST_DIR"
: > SHA256SUMS
for f in *; do
[ -f "$f" ] || continue
sha256sum "$f" >> SHA256SUMS
done
cd ..
elif command -v shasum &> /dev/null; then
cd "$DIST_DIR"
: > SHA256SUMS
for f in *; do
[ -f "$f" ] || continue
shasum -a 256 "$f" >> SHA256SUMS
done
cd ..
else
echo -e "${YELLOW} ⚠️ 未找到 sha256sum/shasum跳过校验文件生成。${NC}"
fi
echo ""
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
ls -1 "$DIST_DIR"
ls -lh "$DIST_DIR"
echo ""
echo -e "${GREEN}📋 支持的平台:${NC}"
echo " • macOS (Intel/Apple Silicon): .dmg"
echo " • Windows (x64/ARM64): .exe"
echo " • Linux (x64/ARM64): .tar.gz"
echo ""
echo -e "${YELLOW}💡 提示Linux AppImage 包请使用 GitHub Actions CI/CD 构建。${NC}"

BIN
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}.dev</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

63
build/darwin/Info.plist Normal file
View File

@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>

BIN
build/darwin/icon.icns Normal file

Binary file not shown.

BIN
build/windows/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

15
build/windows/info.json Normal file
View File

@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

View File

@@ -0,0 +1,227 @@
//go:build gonavi_mysql_driver
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
type mysqlAgentRequest struct {
ID int64 `json:"id"`
Method string `json:"method"`
Config *connection.ConnectionConfig `json:"config,omitempty"`
Query string `json:"query,omitempty"`
DBName string `json:"dbName,omitempty"`
TableName string `json:"tableName,omitempty"`
Changes *connection.ChangeSet `json:"changes,omitempty"`
}
type mysqlAgentResponse struct {
ID int64 `json:"id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Data interface{} `json:"data,omitempty"`
Fields []string `json:"fields,omitempty"`
RowsAffected int64 `json:"rowsAffected,omitempty"`
}
const (
mysqlAgentMethodConnect = "connect"
mysqlAgentMethodClose = "close"
mysqlAgentMethodPing = "ping"
mysqlAgentMethodQuery = "query"
mysqlAgentMethodExec = "exec"
mysqlAgentMethodGetDatabases = "getDatabases"
mysqlAgentMethodGetTables = "getTables"
mysqlAgentMethodGetCreateStmt = "getCreateStatement"
mysqlAgentMethodGetColumns = "getColumns"
mysqlAgentMethodGetAllColumns = "getAllColumns"
mysqlAgentMethodGetIndexes = "getIndexes"
mysqlAgentMethodGetForeignKey = "getForeignKeys"
mysqlAgentMethodGetTriggers = "getTriggers"
mysqlAgentMethodApplyChanges = "applyChanges"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
writer := bufio.NewWriter(os.Stdout)
defer writer.Flush()
var inst *db.MySQLDB
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var req mysqlAgentRequest
if err := json.Unmarshal([]byte(line), &req); err != nil {
_ = writeResponse(writer, mysqlAgentResponse{
ID: req.ID,
Success: false,
Error: fmt.Sprintf("解析请求失败:%v", err),
})
continue
}
resp := handleRequest(&inst, req)
if err := writeResponse(writer, resp); err != nil {
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
break
}
}
if inst != nil {
_ = inst.Close()
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err)
}
}
func handleRequest(inst **db.MySQLDB, req mysqlAgentRequest) mysqlAgentResponse {
resp := mysqlAgentResponse{
ID: req.ID,
Success: true,
}
switch strings.TrimSpace(req.Method) {
case mysqlAgentMethodConnect:
if req.Config == nil {
return fail(resp, "连接配置为空")
}
if *inst != nil {
_ = (*inst).Close()
}
next := &db.MySQLDB{}
if err := next.Connect(*req.Config); err != nil {
return fail(resp, err.Error())
}
*inst = next
return resp
case mysqlAgentMethodClose:
if *inst != nil {
if err := (*inst).Close(); err != nil {
return fail(resp, err.Error())
}
*inst = nil
}
return resp
}
if *inst == nil {
return fail(resp, "connection not open")
}
switch strings.TrimSpace(req.Method) {
case mysqlAgentMethodPing:
if err := (*inst).Ping(); err != nil {
return fail(resp, err.Error())
}
case mysqlAgentMethodQuery:
data, fields, err := (*inst).Query(req.Query)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
resp.Fields = fields
case mysqlAgentMethodExec:
affected, err := (*inst).Exec(req.Query)
if err != nil {
return fail(resp, err.Error())
}
resp.RowsAffected = affected
case mysqlAgentMethodGetDatabases:
data, err := (*inst).GetDatabases()
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case mysqlAgentMethodGetTables:
data, err := (*inst).GetTables(req.DBName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case mysqlAgentMethodGetCreateStmt:
data, err := (*inst).GetCreateStatement(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case mysqlAgentMethodGetColumns:
data, err := (*inst).GetColumns(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case mysqlAgentMethodGetAllColumns:
data, err := (*inst).GetAllColumns(req.DBName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case mysqlAgentMethodGetIndexes:
data, err := (*inst).GetIndexes(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case mysqlAgentMethodGetForeignKey:
data, err := (*inst).GetForeignKeys(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case mysqlAgentMethodGetTriggers:
data, err := (*inst).GetTriggers(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case mysqlAgentMethodApplyChanges:
if req.Changes == nil {
return fail(resp, "变更集为空")
}
applier, ok := interface{}(*inst).(interface {
ApplyChanges(tableName string, changes connection.ChangeSet) error
})
if !ok {
return fail(resp, "当前驱动不支持 ApplyChanges")
}
if err := applier.ApplyChanges(req.TableName, *req.Changes); err != nil {
return fail(resp, err.Error())
}
default:
return fail(resp, "不支持的方法")
}
return resp
}
func writeResponse(writer *bufio.Writer, resp mysqlAgentResponse) error {
payload, err := json.Marshal(resp)
if err != nil {
return err
}
payload = append(payload, '\n')
if _, err := writer.Write(payload); err != nil {
return err
}
return writer.Flush()
}
func fail(resp mysqlAgentResponse, errText string) mysqlAgentResponse {
resp.Success = false
resp.Error = strings.TrimSpace(errText)
return resp
}

View File

@@ -0,0 +1,330 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
type agentRequest struct {
ID int64 `json:"id"`
Method string `json:"method"`
Config *connection.ConnectionConfig `json:"config,omitempty"`
Query string `json:"query,omitempty"`
TimeoutMs int64 `json:"timeoutMs,omitempty"`
DBName string `json:"dbName,omitempty"`
TableName string `json:"tableName,omitempty"`
Changes *connection.ChangeSet `json:"changes,omitempty"`
}
type agentResponse struct {
ID int64 `json:"id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Data interface{} `json:"data,omitempty"`
Fields []string `json:"fields,omitempty"`
RowsAffected int64 `json:"rowsAffected,omitempty"`
}
const (
agentMethodConnect = "connect"
agentMethodClose = "close"
agentMethodPing = "ping"
agentMethodQuery = "query"
agentMethodExec = "exec"
agentMethodGetDatabases = "getDatabases"
agentMethodGetTables = "getTables"
agentMethodGetCreateStmt = "getCreateStatement"
agentMethodGetColumns = "getColumns"
agentMethodGetAllColumns = "getAllColumns"
agentMethodGetIndexes = "getIndexes"
agentMethodGetForeignKey = "getForeignKeys"
agentMethodGetTriggers = "getTriggers"
agentMethodApplyChanges = "applyChanges"
)
const legacyClickHouseDefaultTimeout = 2 * time.Hour
var (
agentDriverType string
agentDatabaseFactory func() db.Database
)
func main() {
if agentDatabaseFactory == nil || strings.TrimSpace(agentDriverType) == "" {
fmt.Fprintf(os.Stderr, "未配置驱动代理 provider请使用 gonavi_<driver>_driver 标签构建\n")
os.Exit(2)
}
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
writer := bufio.NewWriter(os.Stdout)
defer writer.Flush()
var inst db.Database
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var req agentRequest
if err := json.Unmarshal([]byte(line), &req); err != nil {
_ = writeResponse(writer, agentResponse{
ID: req.ID,
Success: false,
Error: fmt.Sprintf("解析请求失败:%v", err),
})
continue
}
resp := handleRequest(&inst, req)
if err := writeResponse(writer, resp); err != nil {
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
break
}
}
if inst != nil {
_ = inst.Close()
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err)
}
}
func handleRequest(inst *db.Database, req agentRequest) agentResponse {
resp := agentResponse{ID: req.ID, Success: true}
method := strings.TrimSpace(req.Method)
switch method {
case agentMethodConnect:
if req.Config == nil {
return fail(resp, "连接配置为空")
}
if *inst != nil {
_ = (*inst).Close()
}
next := agentDatabaseFactory()
if next == nil {
return fail(resp, "驱动代理初始化失败")
}
if err := next.Connect(*req.Config); err != nil {
return fail(resp, err.Error())
}
*inst = next
return resp
case agentMethodClose:
if *inst != nil {
if err := (*inst).Close(); err != nil {
return fail(resp, err.Error())
}
*inst = nil
}
return resp
}
if *inst == nil {
return fail(resp, "connection not open")
}
switch method {
case agentMethodPing:
if err := (*inst).Ping(); err != nil {
return fail(resp, err.Error())
}
case agentMethodQuery:
data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
resp.Fields = fields
case agentMethodExec:
affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
resp.RowsAffected = affected
case agentMethodGetDatabases:
data, err := (*inst).GetDatabases()
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetTables:
data, err := (*inst).GetTables(req.DBName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetCreateStmt:
data, err := (*inst).GetCreateStatement(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetColumns:
data, err := (*inst).GetColumns(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetAllColumns:
data, err := (*inst).GetAllColumns(req.DBName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetIndexes:
data, err := (*inst).GetIndexes(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetForeignKey:
data, err := (*inst).GetForeignKeys(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodGetTriggers:
data, err := (*inst).GetTriggers(req.DBName, req.TableName)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
case agentMethodApplyChanges:
if req.Changes == nil {
return fail(resp, "变更集为空")
}
applier, ok := (*inst).(interface {
ApplyChanges(tableName string, changes connection.ChangeSet) error
})
if !ok {
return fail(resp, "当前驱动不支持 ApplyChanges")
}
if err := applier.ApplyChanges(req.TableName, *req.Changes); err != nil {
return fail(resp, err.Error())
}
default:
return fail(resp, "不支持的方法")
}
return resp
}
func writeResponse(writer *bufio.Writer, resp agentResponse) error {
// 对响应数据做统一 JSON 安全归一化:
// 将 map[any]any如 duckdb.Map递归转换为 map[string]any避免序列化失败导致代理进程退出。
safeResp := resp
safeResp.Data = normalizeAgentResponseData(resp.Data)
payload, err := json.Marshal(safeResp)
if err != nil {
return err
}
payload = append(payload, '\n')
if _, err := writer.Write(payload); err != nil {
return err
}
return writer.Flush()
}
func fail(resp agentResponse, errText string) agentResponse {
resp.Success = false
resp.Error = strings.TrimSpace(errText)
return resp
}
func normalizeAgentResponseData(v interface{}) interface{} {
if v == nil {
return nil
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Pointer, reflect.Interface:
if rv.IsNil() {
return nil
}
return normalizeAgentResponseData(rv.Elem().Interface())
case reflect.Map:
if rv.IsNil() {
return nil
}
out := make(map[string]interface{}, rv.Len())
iter := rv.MapRange()
for iter.Next() {
out[fmt.Sprint(iter.Key().Interface())] = normalizeAgentResponseData(iter.Value().Interface())
}
return out
case reflect.Slice:
if rv.IsNil() {
return nil
}
// 保持 []byte 原样,避免改变现有二进制列的 JSON 编码行为base64
if rv.Type().Elem().Kind() == reflect.Uint8 {
return v
}
size := rv.Len()
items := make([]interface{}, size)
for i := 0; i < size; i++ {
items[i] = normalizeAgentResponseData(rv.Index(i).Interface())
}
return items
case reflect.Array:
size := rv.Len()
items := make([]interface{}, size)
for i := 0; i < size; i++ {
items[i] = normalizeAgentResponseData(rv.Index(i).Interface())
}
return items
default:
return v
}
}
func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs <= 0 {
return inst.Query(query)
}
if q, ok := inst.(interface {
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
}); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
return q.QueryContext(ctx, query)
}
return inst.Query(query)
}
func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (int64, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs <= 0 {
return inst.Exec(query)
}
if e, ok := inst.(interface {
ExecContext(context.Context, string) (int64, error)
}); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
return e.ExecContext(ctx, query)
}
return inst.Exec(query)
}

View File

@@ -0,0 +1,172 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
type duckMapLike map[any]any
func TestWriteResponse_NormalizesMapAnyAny(t *testing.T) {
resp := agentResponse{
ID: 1,
Success: true,
Data: []map[string]interface{}{
{
"id": int64(7),
"meta": duckMapLike{"k": "v", 2: "two"},
},
},
}
var out bytes.Buffer
writer := bufio.NewWriter(&out)
if err := writeResponse(writer, resp); err != nil {
t.Fatalf("writeResponse 返回错误: %v", err)
}
var decoded struct {
Data []map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &decoded); err != nil {
t.Fatalf("解码响应失败: %v", err)
}
if len(decoded.Data) != 1 {
t.Fatalf("期望 1 行数据,实际 %d", len(decoded.Data))
}
meta, ok := decoded.Data[0]["meta"].(map[string]interface{})
if !ok {
t.Fatalf("meta 字段类型异常: %T", decoded.Data[0]["meta"])
}
if meta["k"] != "v" {
t.Fatalf("字符串 key 转换异常: %v", meta["k"])
}
if meta["2"] != "two" {
t.Fatalf("数字 key 未字符串化: %v", meta["2"])
}
}
func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) {
raw := []byte{0x61, 0x62, 0x63}
normalized := normalizeAgentResponseData(raw)
out, ok := normalized.([]byte)
if !ok {
t.Fatalf("期望 []byte实际 %T", normalized)
}
if !bytes.Equal(out, raw) {
t.Fatalf("[]byte 内容被意外改写: %v", out)
}
}
type fakeAgentTimeoutDB struct {
queryCalled bool
queryContextCalled bool
execCalled bool
execContextCalled bool
deadlineSet bool
}
func (f *fakeAgentTimeoutDB) Connect(config connection.ConnectionConfig) error { return nil }
func (f *fakeAgentTimeoutDB) Close() error { return nil }
func (f *fakeAgentTimeoutDB) Ping() error { return nil }
func (f *fakeAgentTimeoutDB) Query(query string) ([]map[string]interface{}, []string, error) {
f.queryCalled = true
return nil, nil, errors.New("query should not be called")
}
func (f *fakeAgentTimeoutDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
f.queryContextCalled = true
if _, ok := ctx.Deadline(); ok {
f.deadlineSet = true
}
return []map[string]interface{}{{"ok": 1}}, []string{"ok"}, nil
}
func (f *fakeAgentTimeoutDB) Exec(query string) (int64, error) {
f.execCalled = true
return 0, errors.New("exec should not be called")
}
func (f *fakeAgentTimeoutDB) ExecContext(ctx context.Context, query string) (int64, error) {
f.execContextCalled = true
if _, ok := ctx.Deadline(); ok {
f.deadlineSet = true
}
return 3, nil
}
func (f *fakeAgentTimeoutDB) GetDatabases() ([]string, error) { return nil, nil }
func (f *fakeAgentTimeoutDB) GetTables(dbName string) ([]string, error) {
return nil, nil
}
func (f *fakeAgentTimeoutDB) GetCreateStatement(dbName, tableName string) (string, error) {
return "", nil
}
func (f *fakeAgentTimeoutDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return nil, nil
}
func (f *fakeAgentTimeoutDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *fakeAgentTimeoutDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *fakeAgentTimeoutDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) {
fake := &fakeAgentTimeoutDB{}
data, fields, err := queryWithOptionalTimeout(fake, "SELECT 1", int64((2 * time.Second).Milliseconds()))
if err != nil {
t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err)
}
if !fake.queryContextCalled || fake.queryCalled {
t.Fatalf("query 调用路径异常QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
}
if !fake.deadlineSet {
t.Fatal("queryWithOptionalTimeout 未设置 deadline")
}
if len(data) != 1 || len(fields) != 1 || fields[0] != "ok" {
t.Fatalf("queryWithOptionalTimeout 返回数据异常: data=%v fields=%v", data, fields)
}
}
func TestExecWithOptionalTimeout_UsesExecContext(t *testing.T) {
fake := &fakeAgentTimeoutDB{}
affected, err := execWithOptionalTimeout(fake, "DELETE FROM t", int64((2 * time.Second).Milliseconds()))
if err != nil {
t.Fatalf("execWithOptionalTimeout 返回错误: %v", err)
}
if !fake.execContextCalled || fake.execCalled {
t.Fatalf("exec 调用路径异常ExecContext=%v Exec=%v", fake.execContextCalled, fake.execCalled)
}
if !fake.deadlineSet {
t.Fatal("execWithOptionalTimeout 未设置 deadline")
}
if affected != 3 {
t.Fatalf("受影响行数异常want=3 got=%d", affected)
}
}
func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testing.T) {
old := agentDriverType
agentDriverType = "clickhouse"
defer func() { agentDriverType = old }()
fake := &fakeAgentTimeoutDB{}
_, _, err := queryWithOptionalTimeout(fake, "SELECT 1", 0)
if err != nil {
t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err)
}
if !fake.queryContextCalled || fake.queryCalled {
t.Fatalf("clickhouse legacy query 调用路径异常QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_clickhouse_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "clickhouse"
agentDatabaseFactory = func() db.Database {
return &db.ClickHouseDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_dameng_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "dameng"
agentDatabaseFactory = func() db.Database {
return &db.DamengDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_diros_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "diros"
agentDatabaseFactory = func() db.Database {
return &db.DirosDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_duckdb_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "duckdb"
agentDatabaseFactory = func() db.Database {
return &db.DuckDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_highgo_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "highgo"
agentDatabaseFactory = func() db.Database {
return &db.HighGoDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_kingbase_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "kingbase"
agentDatabaseFactory = func() db.Database {
return &db.KingbaseDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_mariadb_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "mariadb"
agentDatabaseFactory = func() db.Database {
return &db.MariaDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_mongodb_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "mongodb"
agentDatabaseFactory = func() db.Database {
return &db.MongoDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_mongodb_driver_v1
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "mongodb"
agentDatabaseFactory = func() db.Database {
return &db.MongoDBV1{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_mysql_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "mysql"
agentDatabaseFactory = func() db.Database {
return &db.MySQLDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_sphinx_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "sphinx"
agentDatabaseFactory = func() db.Database {
return &db.SphinxDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_sqlite_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "sqlite"
agentDatabaseFactory = func() db.Database {
return &db.SQLiteDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_sqlserver_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "sqlserver"
agentDatabaseFactory = func() db.Database {
return &db.SqlServerDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_tdengine_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "tdengine"
agentDatabaseFactory = func() db.Database {
return &db.TDengineDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_vastbase_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "vastbase"
agentDatabaseFactory = func() db.Database {
return &db.VastbaseDB{}
}
}

89
docs/driver-manifest.json Normal file
View File

@@ -0,0 +1,89 @@
{
"engine": "go",
"drivers": {
"mariadb": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mariadb"
},
"doris": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/doris"
},
"sphinx": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sphinx"
},
"sqlserver": {
"engine": "go",
"version": "1.9.6",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sqlserver"
},
"sqlite": {
"engine": "go",
"version": "1.44.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sqlite"
},
"duckdb": {
"engine": "go",
"version": "2.5.6",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/duckdb"
},
"dameng": {
"engine": "go",
"version": "1.8.22",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/dameng"
},
"kingbase": {
"engine": "go",
"version": "0.0.0-20201021123113-29bd62a876c3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/kingbase"
},
"highgo": {
"engine": "go",
"version": "0.0.0-local",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/highgo"
},
"vastbase": {
"engine": "go",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/vastbase"
},
"mongodb": {
"engine": "go",
"version": "2.5.0",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mongodb"
},
"tdengine": {
"engine": "go",
"version": "3.7.8",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/tdengine"
},
"clickhouse": {
"engine": "go",
"version": "2.43.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/clickhouse"
},
"postgres": {
"engine": "go",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/postgres"
}
}
}

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.ace-tool/

View File

@@ -2,12 +2,29 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GoNavi</title>
<script>
if (typeof window !== 'undefined' && !window.go) {
window.go = {
app: {
App: new Proxy({}, { get: () => async () => ({ success: false }) })
}
};
}
if (typeof window !== 'undefined' && !window.runtime) {
window.runtime = new Proxy({}, {
get: (target, prop) => {
if (prop === 'Environment') return async () => ({ platform: 'darwin' });
return typeof prop === 'string' && prop.startsWith('WindowIs') ? () => false : () => {};
}
});
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>ClickHouse</title><path d="M21.333 10H24v4h-2.667ZM16 1.335h2.667v21.33H16Zm-5.333 0h2.666v21.33h-2.666ZM0 22.665V1.335h2.667v21.33zm5.333-21.33H8v21.33H5.333Z"/></svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apache Doris</title><path d="M8.666.0001c-.5355-.004-1.068.1072-1.5241.3384-.207.1048-.5749.3802-.8177.6118-1.0278.9803-1.2876 2.5138-.6553 3.8679.205.439.5068.7694 2.8476 3.1166 2.4527 2.4594 2.6352 2.6255 2.8852 2.6258.2446.0003.3647-.099 1.4408-1.19.9367-.9496 1.2306-1.2992 1.4536-1.7286.5966-1.149.6487-2.0513.174-3.014-.2264-.459-.4816-.7514-1.9012-2.176-.9018-.9052-1.7907-1.7496-1.9751-1.8765C10.0488.2005 9.3548.0052 8.666 0ZM3.5518 5.5737c-.2176.0031-.6097.085-.6097.3285v12.0904l.1642.175c.1123.1194.2498.1748.4342.1748.2545 0 .4436-.1738 3.349-3.0786 2.6868-2.6862 3.079-2.909 3.0791-3.305.0002-.3961-.3924-.6194-3.0784-3.306-2.8612-2.8619-3.0968-3.079-3.3384-3.079Zm13.0967.861c-.0481.0184-.112.1636-.1418.3225-.0756.403-.3719 1.109-.6572 1.5663-.1407.2253-2.2392 2.3955-5.049 5.2212-2.7513 2.7667-4.9104 4.9985-5.0468 5.2165-.4552.7275-.5967 1.3905-.4684 2.1964.222 1.3947 1.3263 2.6812 2.5486 2.9693.4667.11 1.618.0927 2.0329-.0305.2084-.062.526-.2112.7055-.3318.5023-.3373 9.341-9.0562 9.6463-9.5154.449-.6753.8356-1.0716.8395-1.9762-.0056-.5935-.1305-1.1138-1.0715-2.306-.5094-.6523-3.2341-3.3723-3.338-3.3324Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>DuckDB</title><path d="M12 0C5.363 0 0 5.363 0 12s5.363 12 12 12 12-5.363 12-12S18.637 0 12 0zM9.502 7.03a4.974 4.974 0 0 1 4.97 4.97 4.974 4.974 0 0 1-4.97 4.97A4.974 4.974 0 0 1 4.532 12a4.974 4.974 0 0 1 4.97-4.97zm6.563 3.183h2.351c.98 0 1.787.782 1.787 1.762s-.807 1.789-1.787 1.789h-2.351v-3.551z"/></svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MariaDB</title><path d="M23.157 4.412c-.676.284-.79.31-1.673.372-.65.045-.757.057-1.212.209-.75.246-1.395.75-2.02 1.59-.296.398-1.249 1.913-1.249 1.988 0 .057-.65.998-.915 1.32-.574.713-1.08 1.079-2.14 1.59-.77.36-1.224.524-4.102 1.477-1.073.353-2.133.738-2.367.864-.852.449-1.515 1.036-2.203 1.938-1.003 1.32-.972 1.313-3.042.947a12.264 12.264 0 00-.675-.063c-.644-.05-1.023.044-1.332.334L0 17.193l.177.088c.094.05.353.234.561.398.215.17.461.347.55.391.088.044.17.088.183.101.012.013-.089.17-.228.353-.435.581-.593.871-.574 1.048.019.164.032.17.43.17.517-.006.826-.056 1.261-.208.65-.233 2.058-.94 2.784-1.4.776-.5 1.717-.998 1.956-1.042.082-.02.354-.07.594-.114.58-.107 1.464-.095 2.587.05.108.013.373.045.6.064.227.025.43.057.454.076.026.012.474.037.998.056.934.026 1.104.007 1.3-.189.126-.133.385-.631.498-.985.209-.643.417-.921.366-.492-.113.966-.322 1.692-.713 2.411-.259.499-.663 1.092-.934 1.395-.322.347-.315.36.088.315.619-.063 1.471-.397 2.096-.82.827-.562 1.647-1.691 2.19-3.03.107-.27.22-.22.183.083-.013.094-.038.315-.057.498l-.031.328.353-.202c.833-.48 1.414-1.262 2.127-2.884.227-.518.877-2.922 1.073-3.976a9.64 9.64 0 01.271-1.042c.127-.429.196-.555.48-.858.183-.19.625-.555.978-.808.72-.505.953-.75 1.187-1.205.208-.417.284-1.13.132-1.357-.132-.202-.284-.196-.763.006Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MongoDB</title><path d="M17.193 9.555c-1.264-5.58-4.252-7.414-4.573-8.115-.28-.394-.53-.954-.735-1.44-.036.495-.055.685-.523 1.184-.723.566-4.438 3.682-4.74 10.02-.282 5.912 4.27 9.435 4.888 9.884l.07.05A73.49 73.49 0 0111.91 24h.481c.114-1.032.284-2.056.51-3.07.417-.296.604-.463.85-.693a11.342 11.342 0 003.639-8.464c.01-.814-.103-1.662-.197-2.218zm-5.336 8.195s0-8.291.275-8.29c.213 0 .49 10.695.49 10.695-.381-.045-.765-1.76-.765-2.405z"/></svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MySQL</title><path d="M16.405 5.501c-.115 0-.193.014-.274.033v.013h.014c.054.104.146.18.214.273.054.107.1.214.154.32l.014-.015c.094-.066.14-.172.14-.333-.04-.047-.046-.094-.08-.14-.04-.067-.126-.1-.18-.153zM5.77 18.695h-.927a50.854 50.854 0 00-.27-4.41h-.008l-1.41 4.41H2.45l-1.4-4.41h-.01a72.892 72.892 0 00-.195 4.41H0c.055-1.966.192-3.81.41-5.53h1.15l1.335 4.064h.008l1.347-4.064h1.095c.242 2.015.384 3.86.428 5.53zm4.017-4.08c-.378 2.045-.876 3.533-1.492 4.46-.482.716-1.01 1.073-1.583 1.073-.153 0-.34-.046-.566-.138v-.494c.11.017.24.026.386.026.268 0 .483-.075.647-.222.197-.18.295-.382.295-.605 0-.155-.077-.47-.23-.944L6.23 14.615h.91l.727 2.36c.164.536.233.91.205 1.123.4-1.064.678-2.227.835-3.483zm12.325 4.08h-2.63v-5.53h.885v4.85h1.745zm-3.32.135l-1.016-.5c.09-.076.177-.158.255-.25.433-.506.648-1.258.648-2.253 0-1.83-.718-2.746-2.155-2.746-.704 0-1.254.232-1.65.697-.43.508-.646 1.256-.646 2.245 0 .972.19 1.686.574 2.14.35.41.877.615 1.583.615.264 0 .506-.033.725-.098l1.325.772.36-.622zM15.5 17.588c-.225-.36-.337-.94-.337-1.736 0-1.393.424-2.09 1.27-2.09.443 0 .77.167.977.5.224.362.336.936.336 1.723 0 1.404-.424 2.108-1.27 2.108-.445 0-.77-.167-.978-.5zm-1.658-.425c0 .47-.172.856-.516 1.156-.344.3-.803.45-1.384.45-.543 0-1.064-.172-1.573-.515l.237-.476c.438.22.833.328 1.19.328.332 0 .593-.073.783-.22a.754.754 0 00.3-.615c0-.33-.23-.61-.648-.845-.388-.213-1.163-.657-1.163-.657-.422-.307-.632-.636-.632-1.177 0-.45.157-.81.47-1.085.315-.278.72-.415 1.22-.415.512 0 .98.136 1.4.41l-.213.476a2.726 2.726 0 00-1.064-.23c-.283 0-.502.068-.654.206a.685.685 0 00-.248.524c0 .328.234.61.666.85.393.215 1.187.67 1.187.67.433.305.648.63.648 1.168zm9.382-5.852c-.535-.014-.95.04-1.297.188-.1.04-.26.04-.274.167.055.053.063.14.11.214.08.134.218.313.346.407.14.11.28.216.427.31.26.16.555.255.81.416.145.094.293.213.44.313.073.05.12.14.214.172v-.02c-.046-.06-.06-.147-.105-.214-.067-.067-.134-.127-.2-.193a3.223 3.223 0 00-.695-.675c-.214-.146-.682-.35-.77-.595l-.013-.014c.146-.013.32-.066.46-.106.227-.06.435-.047.67-.106.106-.027.213-.06.32-.094v-.06c-.12-.12-.21-.283-.334-.395a8.867 8.867 0 00-1.104-.823c-.21-.134-.476-.22-.697-.334-.08-.04-.214-.06-.26-.127-.12-.146-.19-.34-.275-.514a17.69 17.69 0 01-.547-1.163c-.12-.262-.193-.523-.34-.763-.69-1.137-1.437-1.826-2.586-2.5-.247-.14-.543-.2-.856-.274-.167-.008-.334-.02-.5-.027-.11-.047-.216-.174-.31-.235-.38-.24-1.364-.76-1.644-.072-.18.434.267.862.422 1.082.115.153.26.328.34.5.047.116.06.235.107.356.106.294.207.622.347.897.073.14.153.287.247.413.054.073.146.107.167.227-.094.136-.1.334-.154.5-.24.757-.146 1.693.194 2.25.107.166.362.534.703.393.3-.12.234-.5.32-.835.02-.08.007-.133.048-.187v.015c.094.188.188.367.274.555.206.328.566.668.867.895.16.12.287.328.487.402v-.02h-.015c-.043-.058-.1-.086-.154-.133a3.445 3.445 0 01-.35-.4 8.76 8.76 0 01-.747-1.218c-.11-.21-.202-.436-.29-.643-.04-.08-.04-.2-.107-.24-.1.146-.247.273-.32.453-.127.288-.14.642-.188 1.01-.027.007-.014 0-.027.014-.214-.052-.287-.274-.367-.46-.2-.475-.233-1.238-.06-1.785.047-.14.247-.582.167-.716-.042-.127-.174-.2-.247-.303a2.478 2.478 0 01-.24-.427c-.16-.374-.24-.788-.414-1.162-.08-.173-.22-.354-.334-.513-.127-.18-.267-.307-.368-.52-.033-.073-.08-.194-.027-.274.014-.054.042-.075.094-.09.088-.072.335.022.422.062.247.1.455.194.662.334.094.066.195.193.315.226h.14c.214.047.455.014.655.073.355.114.675.28.962.46a5.953 5.953 0 012.085 2.286c.08.154.115.295.188.455.14.33.313.663.455.982.14.315.275.636.476.897.1.14.502.213.682.286.133.06.34.115.46.188.23.14.454.3.67.454.11.076.443.243.463.378z"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Redis</title><path d="M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"/></svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Sphinx</title><path d="M16.284 19.861c0-.654.177-1.834.393-2.623.499-1.822.774-4.079.497-4.079-.116 0-.959.762-1.873 1.694-3.472 3.54-7.197 5.543-10.312 5.543-1.778 0-2.987-.45-4.154-1.545C.128 18.186 0 17.858 0 16.703c0-1.188.117-1.468.909-2.175.718-.642 1.171-.813 2.157-.813.76.171 1.21.16 1.457.461.251.296.338 1.265.035 1.832-.162.303-.585.491-1.105.491-.49 0-.77-.116-.669-.278.315-.511-.135-.857-.713-.548-.699.374-.711 1.698-.021 2.322.969.878 3.65 1.208 5.262.648 1.743-.605 4.022-2.061 5.841-3.732l1.6-1.469-2.088-.013c-2.186-.012-3.608-.273-8.211-1.506-1.531-.41-3.003-.765-3.271-.789-.304-.026-.503-.274-.487-.656.027-.646.378-1.127.793-1.308.249-.109 1.977-.274 3.809-.761 7.136-1.898 7.569-1.629 12.323-.426 1.553.393 3.351.821 4.147.835 1.227.022 1.493.124 1.74.666.16.351.291.686.291.745 0 .058-.695.424-1.545.813-3.12 1.428-4.104 2.185-3.088 3.635.421.602.412.666-.14 1.052-.323.227-.59.687-.593 1.022-.009.908-.583 2.856-1.417 3.624l-.732.675v-1.189Zm1.594-8.328c1.242-.346 1.994-.738 3.539-1.562-1.272-.372-4.462-.895-4.462-.895-2.354-.472-2.108-.448-2.214.071a3.475 3.475 0 0 1-.45 1.105c-.541.848-2.521 1.026-3.656.483-.356-.171-.714-.821-.709-1.283.007-.65-.362-.801-.598-.714-.191.07-.813.079-2.179.448-4.514 1.217-5.132 1.078-2.189 1.495.353.05 2.223.572 3.136.815 2.239.597 2.658.641 5.556.581 2.015-.042 2.858-.163 4.226-.544ZM.732 6.258c.056-.577.088-.702 1.692-1.025.919-.185 3.185-.785 5.036-1.333 4.254-1.26 5.462-1.263 9.873-.026 1.904.535 4.037.973 4.74.975 1.097.002 1.668.487 1.668.487.505 1.16.412 1.24-1.558 1.24-1.374 0-2.558-.232-4.385-.857-1.389-.476-3.369-.923-4.451-1.004-1.974-.149-1.971-.15-8.072 1.529-1.072.295-2.553.624-3.29.732l-1.342.196.089-.914Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>SQLite</title><path d="M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

52
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<!-- Background: Soft Light Grey -->
<linearGradient id="bgSoft" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#f5f7fa;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c3cfe2;stop-opacity:1" />
</linearGradient>
<!-- Hexagon: Solid Tech Pink -->
<linearGradient id="solidPink" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FF5F6D;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFC371;stop-opacity:1" />
</linearGradient>
<!-- N: Solid Tech Blue/Cyan -->
<linearGradient id="solidCyan" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00c6ff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0072ff;stop-opacity:1" />
</linearGradient>
<filter id="hardShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
<feOffset dx="4" dy="4" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.2"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect x="32" y="32" width="448" height="448" rx="100" fill="url(#bgSoft)" />
<!-- Main Content Centered -->
<g transform="translate(106, 106) scale(0.6)" filter="url(#hardShadow)">
<!-- Hex G -->
<path d="M 250 0 L 466 125 L 466 375 L 250 500 L 34 375 L 34 125 Z"
fill="none" stroke="url(#solidPink)" stroke-width="45" stroke-linejoin="round"/>
<!-- G Crossbar -->
<path d="M 466 300 L 330 300" stroke="url(#solidPink)" stroke-width="45" stroke-linecap="round"/>
<!-- Inner N -->
<path d="M 160 350 L 160 150 L 340 350 L 340 150"
fill="none" stroke="url(#solidCyan)" stroke-width="50" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -3,6 +3,11 @@ html, body, #root {
margin: 0;
padding: 0;
overflow: hidden; /* Disable global scrollbar */
background-color: transparent !important; /* CRITICAL: Allow Wails window transparency */
}
body, #root {
border-radius: 14px; /* Slightly rounded app window corners */
}
/* 侧边栏 Tree 样式优化 */
@@ -30,4 +35,308 @@ html, body, #root {
text-overflow: ellipsis;
white-space: nowrap;
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;
height: 10px;
}
body[data-theme='dark'] ::-webkit-scrollbar-track {
background: #1f1f1f;
}
body[data-theme='dark'] ::-webkit-scrollbar-corner {
background: #1f1f1f;
}
body[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 4px;
border: 2px solid #1f1f1f;
}
body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* Scrollbar styling for light mode (transparent-friendly) */
body[data-theme='light'] ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
body[data-theme='light'] ::-webkit-scrollbar-track {
background: transparent;
}
body[data-theme='light'] ::-webkit-scrollbar-corner {
background: transparent;
}
body[data-theme='light'] ::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.30);
border: 2px solid transparent;
background-clip: content-box;
}
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
body {
transition: color 0.3s;
}
body[data-theme='dark'] {
/* 移除全局 text-shadow对每个文本元素增加 GPU compositing 成本,
在透明窗口环境下会显著加剧 GPU 负载 */
}
/* 暗色 + 透明:提升选中/焦点可读性,避免默认蓝色在半透明背景下发灰 */
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
background: rgba(246, 196, 83, 0.24) !important;
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;
}
body[data-theme='dark'] .ant-checkbox-indeterminate .ant-checkbox-inner::after {
background-color: #f6c453 !important;
}
body[data-theme='dark'] .ant-checkbox:hover .ant-checkbox-inner,
body[data-theme='dark'] .ant-checkbox-wrapper:hover .ant-checkbox-inner {
border-color: #f6c453 !important;
}
body[data-theme='dark'] .ant-radio-checked .ant-radio-inner {
border-color: #f6c453 !important;
background-color: #f6c453 !important;
}
body[data-theme='dark'] .ant-radio-wrapper:hover .ant-radio-inner,
body[data-theme='dark'] .ant-radio:hover .ant-radio-inner {
border-color: #f6c453 !important;
}
body[data-theme='dark'] .ant-switch.ant-switch-checked {
background: #d8a93b !important;
}
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td,
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell {
background: rgba(246, 196, 83, 0.18) !important;
}
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td,
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell {
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;
}
.connection-modal-wrap .ant-modal-content {
max-height: calc(100vh - 72px);
display: flex;
flex-direction: column;
}
.connection-modal-wrap .ant-modal-body {
flex: 1 1 auto;
min-height: 0;
}
.connection-modal-wrap .ant-modal-footer {
flex-shrink: 0;
}
/* Custom Title Bar Close Button Hover */
.titlebar-close-btn:hover {
background-color: #ff4d4f !important;
color: #fff !important;
}
/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */
.driver-manager-table .ant-table-sticky-scroll {
display: none !important;
}
/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body {
overflow-x: auto !important;
-ms-overflow-style: none;
scrollbar-width: none;
}
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal {
height: 0 !important;
}
.driver-manager-table-wrap {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.driver-manager-footer {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.driver-manager-footer-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
.driver-manager-hscroll {
width: 100%;
height: 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-gutter: stable;
background: transparent;
}
.driver-manager-hscroll-inner {
height: 1px;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
import { Modal, Form, Select, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs } from 'antd';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { Modal, Form, Select, Input, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs, theme as antdTheme } from 'antd';
import { DatabaseOutlined, RocketOutlined, SwapOutlined, TableOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
import { SavedConnection } from '../types';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
const { Title, Text } = Typography;
const { Step } = Steps;
@@ -21,6 +23,12 @@ type TableDiffSummary = {
deletes?: number;
same?: number;
message?: string;
targetTableExists?: boolean;
plannedAction?: string;
warnings?: string[];
unsupportedObjects?: string[];
indexesToCreate?: number;
indexesSkipped?: number;
};
type TableOps = {
insert: boolean;
@@ -31,10 +39,135 @@ type TableOps = {
selectedDeletePks?: string[];
};
type WorkflowType = 'sync' | 'migration';
const quoteSqlIdent = (dbType: string, ident: string): string => {
const raw = String(ident || '').trim();
if (!raw) return raw;
const t = String(dbType || '').toLowerCase();
if (t === 'mysql' || t === 'mariadb' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
return `\`${raw.replace(/`/g, '``')}\``;
}
if (t === 'sqlserver') {
return `[${raw.replace(/]/g, ']]')}]`;
}
return `"${raw.replace(/"/g, '""')}"`;
};
const quoteSqlTable = (dbType: string, tableName: string): string => {
const raw = String(tableName || '').trim();
if (!raw) return raw;
if (!raw.includes('.')) return quoteSqlIdent(dbType, raw);
return raw
.split('.')
.map((part) => quoteSqlIdent(dbType, part))
.join('.');
};
const toSqlLiteral = (value: any, dbType: string): string => {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'boolean') {
const t = String(dbType || '').toLowerCase();
if (t === 'sqlserver') return value ? '1' : '0';
return value ? 'TRUE' : 'FALSE';
}
if (value instanceof Date) {
return `'${value.toISOString().replace(/'/g, "''")}'`;
}
if (typeof value === 'object') {
try {
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
} catch {
return `'${String(value).replace(/'/g, "''")}'`;
}
}
return `'${String(value).replace(/'/g, "''")}'`;
};
const resolveRedisDbIndex = (raw?: string): number => {
const value = Number(String(raw || '').trim());
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
};
const buildSqlPreview = (
previewData: any,
tableName: string,
dbType: string,
ops?: TableOps,
): { sqlText: string; statementCount: number } => {
if (!previewData || !tableName) return { sqlText: '', statementCount: 0 };
const tableExpr = quoteSqlTable(dbType, tableName);
const pkCol = String(previewData.pkColumn || 'id');
const statements: string[] = [];
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
const deleteRows = Array.isArray(previewData.deletes) ? previewData.deletes : [];
const selectedInsert = new Set((ops?.selectedInsertPks || []).map((v) => String(v)));
const selectedUpdate = new Set((ops?.selectedUpdatePks || []).map((v) => String(v)));
const selectedDelete = new Set((ops?.selectedDeletePks || []).map((v) => String(v)));
if (ops?.insert !== false) {
insertRows.forEach((rowWrap: any) => {
const pk = String(rowWrap?.pk ?? '');
if (selectedInsert.size > 0 && !selectedInsert.has(pk)) return;
const row = rowWrap?.row || {};
const columns = Object.keys(row);
if (columns.length === 0) return;
const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', ');
const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', ');
statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`);
});
}
if (ops?.update !== false) {
updateRows.forEach((rowWrap: any) => {
const pk = String(rowWrap?.pk ?? '');
if (selectedUpdate.size > 0 && !selectedUpdate.has(pk)) return;
const source = rowWrap?.source || {};
const changedColumns = Array.isArray(rowWrap?.changedColumns)
? rowWrap.changedColumns
: Object.keys(source).filter((k) => k !== pkCol);
const setCols = changedColumns.filter((c: string) => String(c) !== pkCol);
if (setCols.length === 0) return;
const setExpr = setCols
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`)
.join(', ');
statements.push(
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
);
});
}
if (ops?.delete) {
deleteRows.forEach((rowWrap: any) => {
const pk = String(rowWrap?.pk ?? '');
if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return;
statements.push(
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
);
});
}
return {
sqlText: statements.join('\n'),
statementCount: statements.length,
};
};
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const connections = useStore((state) => state.connections);
const themeMode = useStore((state) => state.theme);
const appearance = useStore((state) => state.appearance);
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
const { token } = antdTheme.useToken();
const darkMode = themeMode === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
// Step 1: Config
const [sourceConnId, setSourceConnId] = useState<string>('');
@@ -50,9 +183,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const [selectedTables, setSelectedTables] = useState<string[]>([]);
// Options
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>('data');
const [syncMode, setSyncMode] = useState<string>('insert_update');
const [autoAddColumns, setAutoAddColumns] = useState<boolean>(true);
const [targetTableStrategy, setTargetTableStrategy] = useState<'existing_only' | 'auto_create_if_missing' | 'smart'>('existing_only');
const [createIndexes, setCreateIndexes] = useState<boolean>(false);
const [mongoCollectionName, setMongoCollectionName] = useState<string>('');
const [showSameTables, setShowSameTables] = useState<boolean>(false);
const [analyzing, setAnalyzing] = useState<boolean>(false);
const [diffTables, setDiffTables] = useState<TableDiffSummary[]>([]);
@@ -128,9 +265,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setSourceDb('');
setTargetDb('');
setSelectedTables([]);
setWorkflowType('sync');
setSyncContent('data');
setSyncMode('insert_update');
setAutoAddColumns(true);
setTargetTableStrategy('existing_only');
setCreateIndexes(false);
setShowSameTables(false);
setAnalyzing(false);
setDiffTables([]);
@@ -148,36 +288,66 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
}
}, [open]);
useEffect(() => {
if (workflowType === 'migration') {
if (syncMode === 'insert_update') {
setSyncMode('insert_only');
}
if (syncContent === 'schema') {
setSyncContent('both');
}
if (targetTableStrategy === 'existing_only') {
setTargetTableStrategy('smart');
}
if (!createIndexes) {
setCreateIndexes(true);
}
} else {
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
}
}, [workflowType]);
const handleSourceConnChange = async (connId: string) => {
setSourceConnId(connId);
setSourceDb('');
const conn = connections.find(c => c.id === connId);
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
setSourceDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username));
}
} catch(e) { message.error("Failed to fetch source databases"); }
setLoading(false);
}
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
const dbRows = Array.isArray(res.data) ? res.data : [];
setSourceDbs(dbRows
.map((r: any) => r?.Database || r?.database || r?.username)
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
}
} catch(e) { message.error("Failed to fetch source databases"); }
setLoading(false);
}
};
const handleTargetConnChange = async (connId: string) => {
setTargetConnId(connId);
setTargetDb('');
const conn = connections.find(c => c.id === connId);
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
setTargetDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username));
}
} catch(e) { message.error("Failed to fetch target databases"); }
setLoading(false);
}
if (conn) {
setLoading(true);
try {
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
if (res.success) {
const dbRows = Array.isArray(res.data) ? res.data : [];
setTargetDbs(dbRows
.map((r: any) => r?.Database || r?.database || r?.username)
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
}
} catch(e) { message.error("Failed to fetch target databases"); }
setLoading(false);
}
};
const nextToTables = async () => {
@@ -189,14 +359,17 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
try {
const conn = connections.find(c => c.id === sourceConnId);
if (conn) {
const config = normalizeConnConfig(conn, sourceDb);
const res = await DBGetTables(config as any, sourceDb);
if (res.success) {
// DBGetTables returns [{Table: "name"}, ...]
const tables = (res.data as any[]).map((row: any) => row.Table || row.table || row.TABLE_NAME || Object.values(row)[0]);
setAllTables(tables as string[]);
setCurrentStep(1);
} else {
const config = normalizeConnConfig(conn, sourceDb);
const res = await DBGetTables(config as any, sourceDb);
if (res.success) {
// DBGetTables returns [{Table: "name"}, ...]
const tableRows = Array.isArray(res.data) ? res.data : [];
const tables = tableRows
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
setAllTables(tables as string[]);
setCurrentStep(1);
} else {
message.error(res.message);
}
}
@@ -236,6 +409,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
content: syncContent,
mode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
jobId,
};
@@ -286,6 +462,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
content: "data",
mode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
};
try {
@@ -362,6 +541,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
content: syncContent,
mode: syncMode,
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
tableOptions,
jobId,
};
@@ -402,10 +584,139 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
);
};
const previewSql = useMemo(() => {
if (!previewData || !previewTable) return { sqlText: '', statementCount: 0 };
const targetType = String(connections.find(c => c.id === targetConnId)?.config?.type || '');
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
return buildSqlPreview(previewData, previewTable, targetType, ops);
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
const analysisWarnings = useMemo(() => {
const items: string[] = [];
diffTables.forEach((table) => {
(table.warnings || []).forEach((warning) => items.push(`${table.table}: ${warning}`));
(table.unsupportedObjects || []).forEach((warning) => items.push(`${table.table}: ${warning}`));
});
return Array.from(new Set(items));
}, [diffTables]);
const isMigrationWorkflow = workflowType === 'migration';
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
const sourceType = String(sourceConn?.config?.type || '').toLowerCase();
const targetType = String(targetConn?.config?.type || '').toLowerCase();
const isRedisMongoKeyspaceMigration = isMigrationWorkflow && (
(sourceType === 'redis' && targetType === 'mongodb') ||
(sourceType === 'mongodb' && targetType === 'redis')
);
const defaultMongoCollectionName = useMemo(() => {
if (sourceType === 'redis' && targetType === 'mongodb') {
return `redis_db_${resolveRedisDbIndex(sourceDb || sourceConn?.config?.database)}_keys`;
}
if (sourceType === 'mongodb' && targetType === 'redis') {
return selectedTables[0] || `redis_db_${resolveRedisDbIndex(targetDb || targetConn?.config?.database)}_keys`;
}
return '';
}, [sourceType, targetType, sourceDb, targetDb, sourceConn, targetConn, selectedTables]);
const modalPanelStyle = useMemo(() => ({
background: darkMode
? 'linear-gradient(180deg, rgba(16,22,34,0.96) 0%, rgba(10,14,24,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.36)' : '0 18px 44px rgba(15,23,42,0.14)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
borderRadius: 18,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : `rgba(255,255,255,${Math.max(effectiveOpacity, 0.88)})`,
boxShadow: darkMode ? '0 12px 32px rgba(0,0,0,0.22)' : '0 10px 24px rgba(15,23,42,0.08)',
overflow: 'hidden',
}), [darkMode, effectiveOpacity]);
const heroPanelStyle = useMemo<React.CSSProperties>(() => ({
padding: 18,
borderRadius: 18,
border: darkMode ? '1px solid rgba(255,214,102,0.12)' : '1px solid rgba(24,144,255,0.12)',
background: darkMode
? 'linear-gradient(135deg, rgba(255,214,102,0.10) 0%, rgba(255,255,255,0.03) 100%)'
: 'linear-gradient(135deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.95) 100%)',
marginBottom: 18,
}), [darkMode]);
const badgeStyle = useMemo<React.CSSProperties>(() => ({
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 999,
border: darkMode ? '1px solid rgba(255,255,255,0.10)' : '1px solid rgba(15,23,42,0.08)',
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.86)',
color: darkMode ? 'rgba(255,255,255,0.88)' : '#334155',
fontSize: 12,
fontWeight: 600,
}), [darkMode]);
const quietPanelStyle = useMemo<React.CSSProperties>(() => ({
padding: 14,
borderRadius: 16,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)',
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(248,250,252,0.92)',
}), [darkMode]);
const modalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
}), []);
const modalScrollableContentStyle = useMemo<React.CSSProperties>(() => ({
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
overscrollBehavior: 'contain',
}), []);
const modalFooterBarStyle = useMemo<React.CSSProperties>(() => ({
marginTop: 18,
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
paddingTop: 12,
borderTop: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(15,23,42,0.06)',
flex: '0 0 auto',
}), [darkMode]);
const renderModalTitle = (title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{
width: 38,
height: 38,
borderRadius: 14,
display: 'grid',
placeItems: 'center',
background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)',
color: darkMode ? '#ffd666' : token.colorPrimary,
flexShrink: 0,
}}>
{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{title}</div>
<div style={{ marginTop: 4, fontSize: 12, lineHeight: 1.6, color: darkMode ? 'rgba(255,255,255,0.56)' : 'rgba(15,23,42,0.58)' }}>{description}</div>
</div>
</div>
);
return (
<>
<Modal
title="数据同步"
title={renderModalTitle(isMigrationWorkflow ? '跨库迁移工作台' : '数据同步工作台', isMigrationWorkflow ? '按源库 → 目标库完成建表、导入与风险预检。' : '按已有目标表完成差异对比、同步执行与结果确认。')}
open={open}
onCancel={() => {
if (syncing) {
@@ -414,23 +725,61 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
}
onClose();
}}
width={800}
width={920}
footer={null}
destroyOnHidden
closable={!syncing}
maskClosable={!syncing}
styles={{
content: modalPanelStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 },
body: {
paddingTop: 8,
height: 760,
maxHeight: 'calc(100vh - 120px)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
},
footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 },
}}
>
<div style={modalWorkspaceStyle}>
<div style={{ flex: '0 0 auto' }}>
<div style={heroPanelStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{isMigrationWorkflow ? '跨数据源迁移' : '数据同步'}</div>
<div style={{ marginTop: 6, fontSize: 13, lineHeight: 1.7, color: darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(15,23,42,0.62)' }}>
{isMigrationWorkflow
? '适合把源表迁移到另一套数据库,可按策略自动建表、导入数据并补建可兼容索引。'
: '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。'}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<span style={badgeStyle}>{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow ? '迁移模式' : '同步模式'}</span>
<span style={badgeStyle}><DatabaseOutlined /> {sourceConnId ? '已选源连接' : '待选源连接'}</span>
<span style={badgeStyle}><TableOutlined /> {selectedTables.length || 0} </span>
</div>
</div>
</div>
<Steps current={currentStep} style={{ marginBottom: 24 }}>
<Step title="配置源与目标" />
<Step title="选择表" />
<Step title="执行结果" />
</Steps>
</div>
<div style={modalScrollableContentStyle}>
{/* STEP 1: CONFIG */}
{currentStep === 0 && (
<div>
<div style={{ display: 'flex', gap: 24, justifyContent: 'center' }}>
<Card title="源数据库" style={{ width: 350 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 44px minmax(0, 1fr)', gap: 18, alignItems: 'stretch' }}>
<Card
title="源数据库"
style={shellCardStyle}
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
>
<Form layout="vertical">
<Form.Item label="连接">
<Select value={sourceConnId} onChange={handleSourceConnChange}>
@@ -444,8 +793,16 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</Form.Item>
</Form>
</Card>
<div style={{ display: 'flex', alignItems: 'center' }}></div>
<Card title="目标数据库" style={{ width: 350 }}>
<div style={{ display: 'grid', placeItems: 'center' }}>
<div style={{ ...badgeStyle, width: 44, height: 44, borderRadius: 14, justifyContent: 'center', padding: 0 }}>
<SwapOutlined />
</div>
</div>
<Card
title="目标数据库"
style={shellCardStyle}
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
>
<Form layout="vertical">
<Form.Item label="连接">
<Select value={targetConnId} onChange={handleTargetConnChange}>
@@ -461,27 +818,94 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</Card>
</div>
<Card title="同步选项" style={{ marginTop: 16 }}>
<Card
title={isMigrationWorkflow ? '迁移选项' : '同步选项'}
style={{ ...shellCardStyle, marginTop: 18 }}
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
>
<div style={{ ...quietPanelStyle, marginBottom: 14 }}>
<Text style={{ color: darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(15,23,42,0.68)', lineHeight: 1.7 }}>
</Text>
</div>
<Form layout="vertical">
<Form.Item label="同步内容">
<Form.Item label="功能类型">
<Select value={workflowType} onChange={setWorkflowType}>
<Option value="sync"></Option>
<Option value="migration"></Option>
</Select>
</Form.Item>
<Alert
type={isMigrationWorkflow ? 'info' : 'success'}
showIcon
style={{ marginBottom: 12 }}
message={isMigrationWorkflow
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
/>
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
<Select value={syncContent} onChange={setSyncContent}>
<Option value="data"></Option>
<Option value="schema"></Option>
<Option value="both"> + </Option>
</Select>
</Form.Item>
<Form.Item label="同步模式">
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
<Option value="insert_update">//</Option>
<Option value="insert_only"></Option>
<Option value="full_overwrite"></Option>
</Select>
</Form.Item>
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow}>
<Option value="existing_only">使</Option>
<Option value="auto_create_if_missing"></Option>
<Option value="smart"></Option>
</Select>
</Form.Item>
{isRedisMongoKeyspaceMigration && (
<Form.Item
label="Mongo 集合名(可选)"
extra={sourceType === 'redis'
? '为空时沿用默认集合名;填写后本次 Redis 键空间会统一写入该 Mongo 集合。'
: 'MongoDB → Redis 场景下通常直接选择源集合;这里留空即可,未显式选集合时才会回退使用该名称。'}
>
<Input
value={mongoCollectionName}
onChange={(e) => setMongoCollectionName(e.target.value)}
placeholder={defaultMongoCollectionName || '请输入 Mongo 集合名'}
allowClear
maxLength={128}
/>
</Form.Item>
)}
<Form.Item>
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
MySQL
MySQL MySQL Kingbase
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
/
</Checkbox>
</Form.Item>
{isMigrationWorkflow && targetTableStrategy !== 'existing_only' && (
<Alert
type="info"
showIcon
message="自动建表模式首期仅支持 MySQL → Kingbase将迁移字段、主键、普通/唯一/联合索引,并显式跳过全文、空间、前缀、函数类索引。"
style={{ marginBottom: 12 }}
/>
)}
{!isMigrationWorkflow && (
<Alert
type="info"
showIcon
message="数据同步模式默认基于已有目标表执行;如需跨数据源建表导入,请切换到“跨库迁移”。"
style={{ marginBottom: 12 }}
/>
)}
{syncContent !== 'schema' && syncMode === 'full_overwrite' && (
<Alert
type="warning"
@@ -496,26 +920,42 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{/* STEP 2: TABLES */}
{currentStep === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary">:</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={quietPanelStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text type="secondary"></Text>
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
</Checkbox>
</div>
<Transfer
</div>
<Transfer
dataSource={allTables.map(t => ({ key: t, title: t }))}
titles={['源表', '已选表']}
targetKeys={selectedTables}
onChange={(keys) => setSelectedTables(keys as string[])}
render={item => item.title}
listStyle={{ width: 350, height: 280, marginTop: 0 }}
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表', notFoundContent: '暂无数据' }}
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表', notFoundContent: '暂无数据' }}
/>
</div>
{diffTables.length > 0 && (
<div>
<Divider orientation="left"></Divider>
<div style={quietPanelStyle}>
<Divider orientation="left" style={{ marginTop: 0 }}></Divider>
{analysisWarnings.length > 0 && (
<Alert
type="warning"
showIcon
message="预检发现风险或降级项,请在执行前确认"
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
{analysisWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
{analysisWarnings.length > 8 && <li> {analysisWarnings.length - 8} </li>}
</ul>
}
style={{ marginBottom: 12 }}
/>
)}
<Table
size="small"
pagination={false}
@@ -527,13 +967,29 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const same = Number(t.same || 0);
const msg = String(t.message || '').trim();
const can = !!t.canSync;
const warns = Array.isArray(t.warnings) ? t.warnings.length : 0;
const unsupported = Array.isArray(t.unsupportedObjects) ? t.unsupportedObjects.length : 0;
if (showSameTables) return true;
if (!can) return true;
if (msg) return true;
if (msg || warns > 0 || unsupported > 0) return true;
return ins > 0 || upd > 0 || del > 0 || same === 0;
})}
columns={[
{ title: '表名', dataIndex: 'table', key: 'table', ellipsis: true },
{
title: '目标表',
key: 'targetTableExists',
width: 90,
render: (_: any, r: any) => r.targetTableExists ? '已存在' : '不存在'
},
{
title: '计划',
dataIndex: 'plannedAction',
key: 'plannedAction',
width: 220,
ellipsis: true,
render: (v: any) => String(v || '')
},
{
title: '插入',
key: 'inserts',
@@ -542,11 +998,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
const disabled = !r.canSync || analyzing || Number(r.inserts || 0) === 0;
return (
<Checkbox
checked={!!ops.insert}
disabled={disabled}
onChange={(e) => updateTableOption(r.table, 'insert', e.target.checked)}
>
<Checkbox checked={!!ops.insert} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'insert', e.target.checked)}>
{Number(r.inserts || 0)}
</Checkbox>
);
@@ -560,11 +1012,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
const disabled = !r.canSync || analyzing || Number(r.updates || 0) === 0;
return (
<Checkbox
checked={!!ops.update}
disabled={disabled}
onChange={(e) => updateTableOption(r.table, 'update', e.target.checked)}
>
<Checkbox checked={!!ops.update} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'update', e.target.checked)}>
{Number(r.updates || 0)}
</Checkbox>
);
@@ -578,18 +1026,28 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
const disabled = !r.canSync || analyzing || Number(r.deletes || 0) === 0;
return (
<Checkbox
checked={!!ops.delete}
disabled={disabled}
onChange={(e) => updateTableOption(r.table, 'delete', e.target.checked)}
>
<Checkbox checked={!!ops.delete} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'delete', e.target.checked)}>
{Number(r.deletes || 0)}
</Checkbox>
);
}
},
{ title: '相同', dataIndex: 'same', key: 'same', width: 70, render: (v: any) => Number(v || 0) },
{ title: '消息', dataIndex: 'message', key: 'message', ellipsis: true, render: (v: any) => (v ? String(v) : '') },
{
title: '风险',
key: 'warnings',
width: 220,
render: (_: any, r: any) => {
const warns = [...(Array.isArray(r.warnings) ? r.warnings : []), ...(Array.isArray(r.unsupportedObjects) ? r.unsupportedObjects : [])];
if (warns.length === 0) return '-';
return (
<div style={{ color: '#d48806', fontSize: 12, lineHeight: 1.5 }}>
{warns.slice(0, 2).map((item: string) => <div key={item}>{item}</div>)}
{warns.length > 2 && <div> {warns.length - 2} </div>}
</div>
);
}
},
{
title: '预览',
key: 'preview',
@@ -613,7 +1071,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{/* STEP 3: RESULT */}
{currentStep === 2 && (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={quietPanelStyle}>
<Alert
message={syncing ? "正在同步" : (syncResult?.success ? "同步完成" : "同步失败")}
description={
@@ -625,7 +1084,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
showIcon
/>
<div style={{ marginTop: 12 }}>
<div style={{ marginTop: 14 }}>
<Progress
percent={syncProgress.percent}
status={syncing ? "active" : (syncResult?.success ? "success" : "exception")}
@@ -633,7 +1092,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
/>
</div>
<Divider orientation="left"></Divider>
</div>
<div style={quietPanelStyle}>
<Divider orientation="left" style={{ marginTop: 0 }}></Divider>
<div
ref={logBoxRef}
onScroll={() => {
@@ -642,14 +1103,25 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = nearBottom;
}}
style={{ background: '#f5f5f5', padding: 12, height: 300, overflowY: 'auto', fontFamily: 'monospace' }}
style={{
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)',
borderRadius: 14,
padding: 12,
height: 300,
overflowY: 'auto',
fontFamily: 'SFMono-Regular, ui-monospace, Menlo, Consolas, monospace'
}}
>
{syncLogs.map((item, i: number) => <div key={i}>{renderSyncLogItem(item)}</div>)}
</div>
</div>
</div>
)}
<div style={{ marginTop: 24, textAlign: 'right' }}>
</div>
<div style={modalFooterBarStyle}>
{currentStep === 0 && (
<Button type="primary" onClick={nextToTables} loading={loading}></Button>
)}
@@ -676,14 +1148,16 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</>
)}
</div>
</div>
</Modal>
<Drawer
title={`差异预览:${previewTable}`}
styles={{ body: { background: darkMode ? 'rgba(9,13,20,0.98)' : '#f8fafc' } }}
open={previewOpen}
onClose={() => { setPreviewOpen(false); setPreviewTable(''); setPreviewData(null); }}
width={900}
>
{previewLoading && <Alert type="info" showIcon message="正在加载差异预览..." />}
{previewLoading && <Alert type="info" showIcon message="正在加载差异预览" />}
{!previewLoading && previewData && (
<div>
<Alert
@@ -794,6 +1268,51 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
/>
</div>
)
},
{
key: 'sql',
label: `SQL(${previewSql.statementCount})`,
children: (
<div>
<Alert
type="info"
showIcon
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
/>
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary"> {previewSql.statementCount} 200 /</Text>
<Button
size="small"
disabled={!previewSql.sqlText}
onClick={async () => {
try {
await navigator.clipboard.writeText(previewSql.sqlText || '');
message.success('SQL 已复制');
} catch {
message.error('复制失败,请手动复制');
}
}}
>
SQL
</Button>
</div>
<pre
style={{
margin: 0,
padding: 10,
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
</pre>
</div>
)
}
]}
/>

View File

@@ -1,41 +1,395 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { message } from 'antd';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildWhereSQL, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
type ViewerPaginationState = {
current: number;
pageSize: number;
total: number;
totalKnown: boolean;
totalApprox: boolean;
totalCountLoading: boolean;
totalCountCancelled: boolean;
};
const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
const isIntegerText = (text: string): boolean => /^[+-]?\d+$/.test(text);
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
if (typeof value === 'number') {
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
}
if (typeof value === 'bigint') {
return value >= 0n && value <= JS_MAX_SAFE_INTEGER_BIGINT ? Number(value) : null;
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) return null;
if (isIntegerText(text)) {
try {
const parsedBigInt = BigInt(text);
if (parsedBigInt < 0n || parsedBigInt > JS_MAX_SAFE_INTEGER_BIGINT) {
return null;
}
return Number(parsedBigInt);
} catch {
return null;
}
}
const parsed = Number(text);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
}
return null;
};
const parseTotalFromCountRow = (row: any): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
for (const [key, raw] of entries) {
const normalized = String(key || '').trim().toLowerCase();
if (normalized === 'total' || normalized === 'count' || normalized.includes('count')) {
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
for (const [, raw] of entries) {
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
return null;
};
const parseDuckDBApproxTotalRow = (row: any): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
const preferredKeys = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'count', 'total'];
for (const preferred of preferredKeys) {
for (const [key, raw] of entries) {
if (String(key || '').trim().toLowerCase() !== preferred) continue;
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
for (const [key, raw] of entries) {
const normalized = String(key || '').trim().toLowerCase();
if (normalized.includes('estimate') || normalized.includes('row') || normalized.includes('count') || normalized.includes('total')) {
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
return null;
};
const normalizeDuckDBIdentifier = (raw: string): string => {
const text = String(raw || '').trim();
if (text.length >= 2) {
const first = text[0];
const last = text[text.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
return text.slice(1, -1).trim();
}
}
return text;
};
const resolveDuckDBSchemaAndTable = (dbName: string, tableName: string) => {
const rawTable = String(tableName || '').trim();
if (!rawTable) return { schemaName: 'main', pureTableName: '' };
const parts = rawTable.split('.');
if (parts.length >= 2) {
const pureTableName = normalizeDuckDBIdentifier(parts[parts.length - 1]);
const schemaName = normalizeDuckDBIdentifier(parts[parts.length - 2]);
if (schemaName && pureTableName) {
return { schemaName, pureTableName };
}
}
const fallbackSchema = normalizeDuckDBIdentifier(String(dbName || '').trim()) || 'main';
return { schemaName: fallbackSchema, pureTableName: normalizeDuckDBIdentifier(rawTable) };
};
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
const isDuckDBUnsupportedTypeError = (msg: string): boolean => /unsupported\s*type:\s*duckdb\./i.test(String(msg || ''));
const isDuckDBComplexColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
};
const reverseOrderBySQL = (orderBySQL: string): string => {
const raw = String(orderBySQL || '').trim();
if (!raw) return '';
const body = raw.replace(/^order\s+by\s+/i, '').trim();
if (!body) return '';
const parts = body
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
if (/\s+asc$/i.test(part)) return part.replace(/\s+asc$/i, ' DESC');
if (/\s+desc$/i.test(part)) return part.replace(/\s+desc$/i, ' ASC');
return `${part} DESC`;
});
if (parts.length === 0) return '';
return ` ORDER BY ${parts.join(', ')}`;
};
type ViewerFilterSnapshot = {
showFilter: boolean;
conditions: FilterCondition[];
currentPage: number;
pageSize: number;
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
scrollTop: number;
scrollLeft: number;
};
type ViewerScrollSnapshot = {
top: number;
left: number;
};
const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>();
const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefined): FilterCondition[] => {
if (!Array.isArray(conditions)) return [];
return conditions.map((cond) => ({
id: Number.isFinite(Number(cond?.id)) ? Number(cond?.id) : undefined,
enabled: cond?.enabled !== false,
logic: String(cond?.logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND',
column: String(cond?.column || ''),
op: String(cond?.op || '='),
value: String(cond?.value ?? ''),
value2: String(cond?.value2 ?? ''),
}));
};
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
if (!cached) {
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
}
return {
showFilter: cached.showFilter === true,
conditions: normalizeViewerFilterConditions(cached.conditions),
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
sortInfo: Array.isArray(cached.sortInfo)
? cached.sortInfo.filter(s => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend'))
.map(s => ({ columnKey: String(s.columnKey), order: s.order }))
: (cached.sortInfo && (cached.sortInfo as any).columnKey ? [{ columnKey: String((cached.sortInfo as any).columnKey), order: (cached.sortInfo as any).order }] : []),
scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0,
scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0,
};
};
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
const [pkColumns, setPkColumns] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const { connections, addSqlLog } = useStore();
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
const fetchSeqRef = useRef(0);
const countSeqRef = useRef(0);
const countKeyRef = useRef<string>('');
const duckdbApproxSeqRef = useRef(0);
const duckdbApproxKeyRef = useRef<string>('');
const manualCountSeqRef = useRef(0);
const manualCountKeyRef = useRef<string>('');
const pkSeqRef = useRef(0);
const pkKeyRef = useRef<string>('');
const latestConfigRef = useRef<any>(null);
const latestDbTypeRef = useRef<string>('');
const latestDbNameRef = useRef<string>('');
const latestCountSqlRef = useRef<string>('');
const latestCountKeyRef = useRef<string>('');
const scrollSnapshotRef = useRef<ViewerScrollSnapshot>({
top: initialViewerSnapshot.scrollTop,
left: initialViewerSnapshot.scrollLeft,
});
const initialLoadRef = useRef(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 100,
const [pagination, setPagination] = useState<ViewerPaginationState>({
current: initialViewerSnapshot.currentPage,
pageSize: initialViewerSnapshot.pageSize,
total: 0,
totalKnown: false
totalKnown: false,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
});
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>(initialViewerSnapshot.sortInfo);
const [showFilter, setShowFilter] = useState(false);
const [filterConditions, setFilterConditions] = useState<any[]>([]);
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
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);
setShowFilter(snapshot.showFilter);
setFilterConditions(snapshot.conditions);
setSortInfo(snapshot.sortInfo);
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
}, [tab.id]);
useEffect(() => {
persistViewerSnapshot(tab.id);
}, [tab.id, persistViewerSnapshot]);
useEffect(() => {
return () => {
persistViewerSnapshot(tab.id);
};
}, [tab.id, persistViewerSnapshot]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
setPkColumns([]);
pkKeyRef.current = '';
countKeyRef.current = '';
setPagination(prev => ({ ...prev, current: 1, total: 0, totalKnown: false }));
}, [tab.connectionId, tab.dbName, tab.tableName]);
duckdbApproxKeyRef.current = '';
manualCountKeyRef.current = '';
duckdbSafeSelectCacheRef.current = {};
latestConfigRef.current = null;
latestDbTypeRef.current = '';
latestDbNameRef.current = '';
latestCountSqlRef.current = '';
latestCountKeyRef.current = '';
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
setPagination(prev => ({
...prev,
current: snapshot.currentPage,
pageSize: snapshot.pageSize,
total: 0,
totalKnown: false,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
}));
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName]);
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
scrollSnapshotRef.current = snapshot;
persistViewerSnapshot(tab.id, {
scrollTop: snapshot.top,
scrollLeft: snapshot.left,
});
}, [tab.id, persistViewerSnapshot]);
const handleDuckDBManualCount = useCallback(async () => {
if (latestDbTypeRef.current !== 'duckdb') {
return;
}
const config = latestConfigRef.current;
const dbName = latestDbNameRef.current;
const countSql = latestCountSqlRef.current;
const countKey = latestCountKeyRef.current;
if (!config || !countSql || !countKey) {
message.warning('当前结果集尚未就绪,请先执行一次加载');
return;
}
manualCountKeyRef.current = countKey;
const countSeq = ++manualCountSeqRef.current;
const countStart = Date.now();
setPagination(prev => ({ ...prev, totalCountLoading: true, totalCountCancelled: false }));
const countConfig: any = { ...(config as any), timeout: 120 };
try {
const resCount = await DBQuery(countConfig as any, dbName, countSql);
const countDuration = Date.now() - countStart;
addSqlLog({
id: `log-${Date.now()}-duckdb-manual-count`,
timestamp: Date.now(),
sql: countSql,
status: resCount?.success ? 'success' : 'error',
duration: countDuration,
message: resCount?.success ? '' : String(resCount?.message || '统计失败'),
dbName
});
if (manualCountSeqRef.current !== countSeq) return;
if (manualCountKeyRef.current !== countKey) return;
if (!resCount?.success) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(String(resCount?.message || '统计总数失败'));
return;
}
if (!Array.isArray(resCount.data) || resCount.data.length === 0) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
return;
}
const total = parseTotalFromCountRow(resCount.data[0]);
if (total === null) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error('统计结果解析失败');
return;
}
setPagination(prev => ({
...prev,
total,
totalKnown: true,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
}));
} catch (e: any) {
if (manualCountSeqRef.current !== countSeq) return;
if (manualCountKeyRef.current !== countKey) return;
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(`统计总数失败: ${String(e?.message || e)}`);
}
}, [addSqlLog]);
const handleDuckDBCancelManualCount = useCallback(() => {
manualCountSeqRef.current++;
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
}, []);
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
const seq = ++fetchSeqRef.current;
@@ -57,40 +411,163 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
};
const dbType = config.type || '';
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
const dbName = tab.dbName || '';
const tableName = tab.tableName || '';
const whereSQL = buildWhereSQL(dbType, filterConditions);
const countSql = `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
if (sortInfo && sortInfo.order) {
sql += ` ORDER BY ${quoteIdentPart(dbType, sortInfo.columnKey)} ${sortInfo.order === 'ascend' ? 'ASC' : 'DESC'}`;
const isMongoDB = dbTypeLower === 'mongodb';
let mongoFilter: Record<string, unknown> | undefined;
if (isMongoDB) {
try {
mongoFilter = buildMongoFilter(filterConditions);
} catch (e: any) {
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
if (fetchSeqRef.current === seq) setLoading(false);
return;
}
}
const offset = (page - 1) * size;
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
const startTime = Date.now();
try {
const pData = DBQuery(config as any, dbName, sql);
const resData = await pData;
const duration = Date.now() - startTime;
// Log Execution
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: sql,
status: resData.success ? 'success' : 'error',
duration: duration,
message: resData.success ? '' : resData.message,
affectedRows: Array.isArray(resData.data) ? resData.data.length : undefined,
dbName
const whereSQL = isMongoDB
? JSON.stringify(mongoFilter || {})
: buildWhereSQL(dbType, filterConditions);
const countSql = isMongoDB
? buildMongoCountCommand(tableName, mongoFilter || {})
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const orderBySQL = isMongoDB ? '' : buildOrderBySQL(dbType, sortInfo, pkColumns);
const totalRows = Number(pagination.total);
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
const totalKnown = pagination.totalKnown && hasFiniteTotal;
const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0;
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
const offset = (currentPage - 1) * size;
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
const reverseOrderSQL = isClickHouse ? reverseOrderBySQL(orderBySQL) : '';
let useClickHouseReversePagination = false;
let clickHouseReverseLimit = 0;
let clickHouseReverseHasMore = false;
let sql = '';
if (isMongoDB) {
const mongoSort = buildMongoSort(sortInfo, pkColumns);
sql = buildMongoFindCommand({
collection: tableName,
filter: mongoFilter || {},
sort: mongoSort,
limit: size + 1,
skip: offset,
});
} else {
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
sql = `${baseSql}${orderBySQL}`;
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET并在前端翻转结果。
if (isClickHouse && totalKnown && offset > 0 && reverseOrderSQL) {
const pageRowCount = Math.max(0, Math.min(size, totalRows - offset));
if (pageRowCount > 0) {
const tailOffset = Math.max(0, totalRows - (offset + pageRowCount));
if (tailOffset < offset) {
sql = buildPaginatedSelectSQL(dbType, baseSql, reverseOrderSQL, pageRowCount, tailOffset);
useClickHouseReversePagination = true;
clickHouseReverseLimit = pageRowCount;
clickHouseReverseHasMore = currentPage < totalPages;
}
}
}
if (!useClickHouseReversePagination) {
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, size + 1, offset);
}
}
const requestStartTime = Date.now();
let executedSql = sql;
try {
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
const startTime = Date.now();
try {
const result = await DBQuery(config as any, dbName, querySql);
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: querySql,
status: result.success ? 'success' : 'error',
duration: Date.now() - startTime,
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
dbName
});
return result;
} catch (e: any) {
const errMessage = String(e?.message || e || 'query failed');
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: querySql,
status: 'error',
duration: Date.now() - startTime,
message: `${attemptLabel}: ${errMessage}`,
dbName
});
return { success: false, message: errMessage, data: [], fields: [] };
}
};
const hasSort = hasExplicitSort(sortInfo);
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
let resData = await executeDataQuery(sql, '主查询');
if (!resData.success && dbTypeLower === 'duckdb' && isDuckDBUnsupportedTypeError(String(resData.message || ''))) {
const cacheKey = `${tab.connectionId}|${dbName}|${tableName}`;
let safeSelect = duckdbSafeSelectCacheRef.current[cacheKey] || '';
if (!safeSelect) {
try {
const resCols = await DBGetColumns(config as any, dbName, tableName);
if (resCols?.success && Array.isArray(resCols.data)) {
const columnDefs = resCols.data as ColumnDefinition[];
const selectParts = columnDefs.map((col) => {
const colName = String(col?.name || '').trim();
if (!colName) return '';
const quotedCol = quoteIdentPart(dbType, colName);
if (isDuckDBComplexColumnType(col?.type)) {
return `CAST(${quotedCol} AS VARCHAR) AS ${quotedCol}`;
}
return quotedCol;
}).filter(Boolean);
if (selectParts.length > 0) {
safeSelect = selectParts.join(', ');
duckdbSafeSelectCacheRef.current[cacheKey] = safeSelect;
}
}
} catch {
// ignore and keep original error path
}
}
if (safeSelect) {
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, pkColumns), size + 1, offset);
executedSql = fallbackSql;
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
}
}
if (!resData.success && isMySQLFamily && hasSort && isSortMemoryErr(resData.message)) {
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
if (retrySql32MB !== sql) {
executedSql = retrySql32MB;
resData = await executeDataQuery(retrySql32MB, '重试(32MB sort_buffer)');
}
if (!resData.success && isSortMemoryErr(resData.message)) {
const retrySql128MB = withSortBufferTuningSQL(dbType, sql, 128 * 1024 * 1024);
if (retrySql128MB !== executedSql) {
executedSql = retrySql128MB;
resData = await executeDataQuery(retrySql128MB, '重试(128MB sort_buffer)');
}
}
if (resData.success) {
message.warning('已自动提升排序缓冲并重试成功。');
}
}
if (pkColumns.length === 0) {
const pkKey = `${tab.connectionId}|${dbName}|${tableName}`;
@@ -116,7 +593,12 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
let resultData = resData.data as any[];
if (!Array.isArray(resultData)) resultData = [];
const hasMore = resultData.length > size;
if (useClickHouseReversePagination) {
// 反向查询后恢复为原排序方向,保证用户看到的仍是“最后一页正序数据”。
resultData = resultData.slice(0, clickHouseReverseLimit).reverse();
}
const hasMore = useClickHouseReversePagination ? clickHouseReverseHasMore : resultData.length > size;
if (hasMore) resultData = resultData.slice(0, size);
let fieldNames = resData.fields || [];
@@ -131,26 +613,74 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
setData(resultData);
const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`;
const derivedTotalKnown = !hasMore;
const derivedTotal = derivedTotalKnown ? offset + resultData.length : page * size + 1;
const derivedTotal = derivedTotalKnown ? offset + resultData.length : currentPage * size + 1;
const isDuckDB = dbTypeLower === 'duckdb';
const minExpectedTotal = hasMore ? offset + resultData.length + 1 : offset + resultData.length;
if (derivedTotalKnown) countKeyRef.current = countKey;
latestConfigRef.current = config;
latestDbTypeRef.current = dbTypeLower;
latestDbNameRef.current = dbName;
latestCountSqlRef.current = countSql;
latestCountKeyRef.current = countKey;
setPagination(prev => {
if (derivedTotalKnown) {
return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: true };
return {
...prev,
current: currentPage,
pageSize: size,
total: derivedTotal,
totalKnown: true,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
};
}
if (prev.totalKnown && countKeyRef.current === countKey) {
return { ...prev, current: page, pageSize: size };
if (!isDuckDB) {
return { ...prev, current: currentPage, pageSize: size };
}
// 当当前页存在“下一页”信号时,已知总数至少应大于当前页末尾。
// 若旧总数不满足该条件(例如历史统计值为 0降级为未知总数并回退到 derivedTotal。
if (Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
return { ...prev, current: currentPage, pageSize: size };
}
}
return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: false };
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
return {
...prev,
current: currentPage,
pageSize: size,
totalKnown: false,
totalApprox: true,
totalCountLoading: keepManualCounting,
totalCountCancelled: false,
};
}
return {
...prev,
current: currentPage,
pageSize: size,
total: derivedTotal,
totalKnown: false,
totalApprox: false,
totalCountLoading: keepManualCounting,
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
};
});
if (!derivedTotalKnown) {
const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB;
if (shouldRunAsyncCount) {
if (countKeyRef.current !== countKey) {
countKeyRef.current = countKey;
const countSeq = ++countSeqRef.current;
const countStart = Date.now();
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。
const countConfig: any = { ...(config as any), timeout: 5 };
DBQuery(config as any, dbName, countSql)
DBQuery(countConfig, dbName, countSql)
.then((resCount: any) => {
const countDuration = Date.now() - countStart;
@@ -170,10 +700,17 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
if (!resCount.success) return;
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
const total = Number(resCount.data[0]?.['total']);
if (!Number.isFinite(total) || total < 0) return;
const total = parseTotalFromCountRow(resCount.data[0]);
if (total === null) return;
setPagination(prev => ({ ...prev, total, totalKnown: true }));
setPagination(prev => ({
...prev,
total,
totalKnown: true,
totalApprox: false,
totalCountLoading: false,
totalCountCancelled: false,
}));
})
.catch(() => {
if (countSeqRef.current !== countSeq) return;
@@ -182,8 +719,52 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}
}
if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) {
duckdbApproxKeyRef.current = countKey;
const approxSeq = ++duckdbApproxSeqRef.current;
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
const escapedSchema = escapeSQLLiteral(schemaName);
const escapedTable = escapeSQLLiteral(pureTableName);
const approxConfig: any = { ...(config as any), timeout: 3 };
const approxSqlCandidates = [
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
];
(async () => {
for (const approxSql of approxSqlCandidates) {
try {
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]);
if (approxTotal === null) continue;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
setPagination(prev => {
if (countKeyRef.current !== countKey) return prev;
if (prev.totalKnown) return prev;
return {
...prev,
total: approxTotal,
totalKnown: false,
totalApprox: true,
totalCountCancelled: false,
};
});
return;
} catch {
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
}
}
})();
}
} else {
message.error(resData.message);
message.error(String(resData.message || '查询失败'));
}
} catch (e: any) {
if (fetchSeqRef.current !== seq) return;
@@ -191,43 +772,78 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: sql,
sql: executedSql,
status: 'error',
duration: Date.now() - startTime,
duration: Date.now() - requestStartTime,
message: e.message,
dbName
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns.length]);
// Depend on pkColumns.length to avoid loop? No, pkColumns is updated inside.
// Actually, 'pkColumns' state shouldn't trigger re-fetch.
// The 'if (pkColumns.length === 0)' check is inside.
// So adding pkColumns to dependency is safer but might trigger double fetch if not careful?
// Only if pkColumns changes. It changes once from [] to [...].
// So it's fine.
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
// Handlers memoized
const handleReload = useCallback(() => {
countKeyRef.current = '';
fetchData(pagination.current, pagination.pageSize);
}, [fetchData, pagination.current, pagination.pageSize]);
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
const handleSort = useCallback((field: string, order: string) => {
// 支持多字段排序field 为 JSON 数组字符串时解析为多字段
try {
const parsed = JSON.parse(field);
if (Array.isArray(parsed)) {
setSortInfo(parsed.filter((s: any) => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend')));
return;
}
} catch { /* 单字段模式 */ }
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
const normalizedField = String(field || '').trim();
if (!normalizedField || !normalizedOrder) {
setSortInfo([]);
return;
}
setSortInfo([{ columnKey: normalizedField, order: normalizedOrder, enabled: true }]);
}, []);
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: any[]) => setFilterConditions(conditions), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
const exportSqlWithFilter = useMemo(() => {
const tableName = String(tab.tableName || '').trim();
const dbType = String(currentConnConfig?.type || '').trim();
if (!tableName || !dbType) return '';
const whereSQL = buildWhereSQL(dbType, filterConditions);
if (!whereSQL) return '';
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
const normalizedType = dbType.toLowerCase();
const hasSortForBuffer = hasExplicitSort(sortInfo);
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
useEffect(() => {
fetchData(1, pagination.pageSize);
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
if (!initialLoadRef.current) {
initialLoadRef.current = true;
fetchData(pagination.current, pagination.pageSize);
return;
}
fetchData(1, pagination.pageSize);
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
return (
<div style={{ flex: '1 1 auto', minHeight: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={data}
columnNames={columnNames}
loading={loading}
tableName={tab.tableName}
exportScope="table"
dbName={tab.dbName}
connectionId={tab.connectionId}
pkColumns={pkColumns}
@@ -235,9 +851,17 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
onSort={handleSort}
onPageChange={handlePageChange}
pagination={pagination}
onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined}
onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined}
showFilter={showFilter}
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
appliedFilterConditions={filterConditions}
readOnly={forceReadOnly}
sortInfoExternal={sortInfo}
exportSqlWithFilter={exportSqlWithFilter || undefined}
scrollSnapshot={scrollSnapshotRef.current}
onScrollSnapshotChange={handleTableScrollSnapshotChange}
/>
</div>
);

View File

@@ -0,0 +1,217 @@
import React from 'react';
// ─── 公共接口 ───────────────────────────────────────────────
export interface DbIconProps {
size?: number;
color?: string;
}
// ─── 默认色表 ───────────────────────────────────────────────
const DB_DEFAULT_COLORS: Record<string, string> = {
mysql: '#00758F',
mariadb: '#003545',
postgres: '#336791',
redis: '#DC382D',
mongodb: '#47A248',
kingbase: '#1890FF',
dameng: '#E6002D',
oracle: '#F80000',
sqlserver: '#CC2927',
clickhouse: '#FFBF00',
sqlite: '#003B57',
duckdb: '#FFC107',
vastbase: '#0066CC',
highgo: '#00A86B',
tdengine: '#2962FF',
diros: '#0050B3',
sphinx: '#2F5D62',
custom: '#888888',
};
export const getDbDefaultColor = (type: string): string =>
DB_DEFAULT_COLORS[type?.toLowerCase()] || DB_DEFAULT_COLORS.custom;
// ─── 有品牌 SVG 文件的数据库类型(文件在 /db-icons/ 下) ────
const BRAND_SVG_TYPES = new Set([
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite',
'diros', 'sphinx', 'duckdb',
]);
/** 品牌 SVG 图标:用 <img> 加载 /db-icons/*.svg */
const BrandSvgIcon: React.FC<{ type: string; size: number; color?: string }> = ({ type, size, color }) => {
const bgColor = color || getDbDefaultColor(type);
return (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, borderRadius: size * 0.22,
background: '#fff', border: `1.5px solid ${bgColor}`,
flexShrink: 0, overflow: 'hidden',
}}>
<img
src={`/db-icons/${type}.svg`}
alt={type}
width={size * 0.7}
height={size * 0.7}
style={{ display: 'block' }}
/>
</span>
);
};
// ─── 彩色标签图标fallback ──────────────────────────────
/** 通用彩色标签:填充背景 + 白色粗体缩写 */
const ColorBadge: React.FC<{ size: number; color: string; label: string }> = ({ size, color, label }) => {
const textSize = label.length <= 2 ? size * 0.48 : size * 0.38;
return (
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="22" rx="5" fill={color}/>
<text
x="12" y="12" dominantBaseline="central" textAnchor="middle"
fontSize={textSize} fontWeight="800" fontFamily="system-ui,-apple-system,sans-serif"
fill="#fff" letterSpacing={label.length > 2 ? -0.5 : 0}
>
{label}
</text>
</svg>
);
};
// ─── 各数据库图标 ───────────────────────────────────────────
// 有品牌 SVG 的数据库
const MySQLIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mysql" size={size} color={color} />
);
const MariaDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mariadb" size={size} color={color} />
);
const PostgresIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="postgres" size={size} color={color} />
);
const RedisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="redis" size={size} color={color} />
);
const MongoDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mongodb" size={size} color={color} />
);
const ClickHouseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="clickhouse" size={size} color={color} />
);
const SQLiteIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="sqlite" size={size} color={color} />
);
// 无品牌 SVG → 彩色文字标签
const OracleIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oracle} label="Or" />
);
const SQLServerIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sqlserver} label="SS" />
);
const DorisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="diros" size={size} color={color} />
);
const SphinxIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="sphinx" size={size} color={color} />
);
const DuckDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="duckdb" size={size} color={color} />
);
const KingBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.kingbase} label="KB" />
);
const DamengIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.dameng} label="DM" />
);
const VastBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.vastbase} label="VB" />
);
const HighGoIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.highgo} label="HG" />
);
const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.tdengine} label="TD" />
);
/** Custom — 齿轮图标 */
const CustomIcon: React.FC<DbIconProps> = ({ size = 16, color }) => {
const c = color || DB_DEFAULT_COLORS.custom;
return (
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="22" rx="5" fill={c}/>
<circle cx="12" cy="12" r="3.5" stroke="#fff" strokeWidth="1.5" fill="none"/>
<path d="M12 4v2.5M12 17.5V20M4 12h2.5M17.5 12H20M6.34 6.34l1.77 1.77M15.89 15.89l1.77 1.77M6.34 17.66l1.77-1.77M15.89 8.11l1.77-1.77" stroke="#fff" strokeWidth="1.3" strokeLinecap="round"/>
</svg>
);
};
// ─── 图标注册表 ─────────────────────────────────────────────
const DorisIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.diros} label="Do" />
);
const SphinxIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sphinx} label="Sp" />
);
const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
mysql: MySQLIcon,
mariadb: MariaDBIcon,
diros: DorisIcon,
sphinx: SphinxIcon,
postgres: PostgresIcon,
redis: RedisIcon,
mongodb: MongoDBIcon,
kingbase: KingBaseIcon,
dameng: DamengIcon,
oracle: OracleIcon,
sqlserver: SQLServerIcon,
clickhouse: ClickHouseIcon,
sqlite: SQLiteIcon,
duckdb: DuckDBIcon,
vastbase: VastBaseIcon,
highgo: HighGoIcon,
tdengine: TDengineIcon,
custom: CustomIcon,
};
/** 可选图标类型列表(用于图标选择器 UI */
export const DB_ICON_TYPES: string[] = [
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom',
];
/** 该类型是否有品牌 SVG 文件 */
export const hasBrandSvg = (type: string): boolean => BRAND_SVG_TYPES.has(type?.toLowerCase());
/** 获取数据库图标 React 节点 */
export const getDbIcon = (type: string, color?: string, size?: number): React.ReactNode => {
const key = (type || 'custom').toLowerCase();
const Component = DB_ICON_MAP[key] || CustomIcon;
return <Component size={size} color={color} />;
};
/** 获取数据库图标显示名称(中文) */
export const getDbIconLabel = (type: string): string => {
const labels: Record<string, string> = {
mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL',
redis: 'Redis', mongodb: 'MongoDB', oracle: 'Oracle',
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine',
custom: '自定义',
};
return labels[type?.toLowerCase()] || type;
};
/** 预设颜色列表 */
export const PRESET_ICON_COLORS: string[] = [
'#336791', '#00758F', '#DC382D', '#47A248', '#F80000',
'#CC2927', '#1890FF', '#E6002D', '#FFBF00', '#2962FF',
'#00A86B', '#0066CC', '#FF6B35', '#7C3AED',
];

View File

@@ -0,0 +1,471 @@
import React, { useState, useEffect } from 'react';
import Editor from '@monaco-editor/react';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
interface DefinitionViewerProps {
tab: TabData;
}
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [definition, setDefinition] = useState<string>('');
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const getMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
return driver;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const isSphinxConnection = (conn: any): boolean => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'sphinx') return true;
if (type !== 'custom') return false;
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
return driver === 'sphinx' || driver === 'sphinxql';
};
const parseSchemaAndName = (fullName: string): { schema: string; name: string } => {
const raw = String(fullName || '').trim();
const idx = raw.lastIndexOf('.');
if (idx > 0 && idx < raw.length - 1) {
return { schema: raw.substring(0, idx), name: raw.substring(idx + 1) };
}
return { schema: '', name: raw };
};
const getCaseInsensitiveRawValue = (row: Record<string, any>, candidateKeys: string[]): any => {
const keyMap = new Map<string, any>();
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
for (const key of candidateKeys) {
const value = keyMap.get(key.toLowerCase());
if (value !== undefined && value !== null) {
return value;
}
}
return undefined;
};
const parseDuckDBParameterNames = (raw: any): string[] => {
if (Array.isArray(raw)) {
return raw
.map((item) => String(item ?? '').trim())
.filter((item) => item !== '' && item.toLowerCase() !== '<nil>');
}
const text = String(raw ?? '').trim();
if (!text) return [];
const normalized = text.startsWith('[') && text.endsWith(']')
? text.slice(1, -1)
: text;
return normalized
.split(',')
.map((part) => part.trim())
.filter((part) => part !== '' && part.toLowerCase() !== '<nil>');
};
const buildDuckDBMacroDDL = (
schemaName: string,
functionName: string,
parametersRaw: any,
macroDefinitionRaw: any
): string => {
const schema = String(schemaName || '').trim();
const name = String(functionName || '').trim();
const macroDefinition = String(macroDefinitionRaw || '').trim();
if (!name || !macroDefinition) return '';
const parameters = parseDuckDBParameterNames(parametersRaw).join(', ');
const qualifiedName = schema ? `${schema}.${name}` : name;
const isTableMacro = !macroDefinition.startsWith('(');
if (isTableMacro) {
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`;
}
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`;
};
const buildShowViewQueries = (dialect: string, viewName: string, dbName: string): string[] => {
const { schema, name } = parseSchemaAndName(viewName);
const safeName = escapeSQLLiteral(name);
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
return [
`SHOW CREATE VIEW \`${name.replace(/`/g, '``')}\``,
safeDbName
? `SELECT VIEW_DEFINITION AS view_definition FROM information_schema.views WHERE table_schema = '${safeDbName}' AND table_name = '${safeName}' LIMIT 1`
: '',
`SHOW CREATE TABLE \`${name.replace(/`/g, '``')}\``,
].filter(Boolean);
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
const schemaRef = schema || 'public';
return [`SELECT pg_get_viewdef('${escapeSQLLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`];
}
case 'sqlserver':
return [`SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(viewName)}')) AS view_definition`];
case 'oracle':
case 'dm':
if (schema) {
return [`SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${escapeSQLLiteral(schema).toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`];
}
if (safeDbName) {
return [`SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`];
}
return [`SELECT TEXT AS view_definition FROM USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`];
case 'sqlite':
return [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${safeName}'`];
case 'duckdb': {
const schemaRef = schema || 'main';
return [`SELECT view_definition FROM information_schema.views WHERE table_schema = '${escapeSQLLiteral(schemaRef)}' AND table_name = '${safeName}' LIMIT 1`];
}
default:
return [`-- 暂不支持该数据库类型的视图定义查看`];
}
};
const buildShowRoutineQueries = (dialect: string, routineName: string, routineType: string, dbName: string): string[] => {
const { schema, name } = parseSchemaAndName(routineName);
const safeName = escapeSQLLiteral(name);
const safeDbName = escapeSQLLiteral(dbName);
const upperType = (routineType || 'FUNCTION').toUpperCase();
switch (dialect) {
case 'mysql':
return [
`SHOW CREATE ${upperType} \`${name.replace(/`/g, '``')}\``,
safeDbName
? `SELECT ROUTINE_DEFINITION AS routine_definition, ROUTINE_TYPE AS routine_type FROM information_schema.routines WHERE routine_schema = '${safeDbName}' AND routine_name = '${safeName}' LIMIT 1`
: '',
upperType === 'PROCEDURE'
? `SHOW PROCEDURE STATUS LIKE '${safeName}'`
: `SHOW FUNCTION STATUS LIKE '${safeName}'`,
].filter(Boolean);
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
const schemaRef = schema || 'public';
return [`SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${safeName}' LIMIT 1`];
}
case 'sqlserver':
return [`SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(routineName)}')) AS routine_definition`];
case 'oracle':
case 'dm': {
const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : '');
if (owner) {
return [`SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`];
}
return [`SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`];
}
case 'duckdb': {
const schemaRef = schema || 'main';
const safeSchema = escapeSQLLiteral(schemaRef);
return [
`SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${safeSchema}' AND function_name = '${safeName}' LIMIT 1`,
`SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND function_name = '${safeName}' ORDER BY CASE WHEN schema_name = '${safeSchema}' THEN 0 ELSE 1 END, schema_name LIMIT 1`,
];
}
case 'sqlite':
return [`-- SQLite 不支持函数/存储过程定义管理`];
default:
return [`-- 暂不支持该数据库类型的函数/存储过程定义查看`];
}
};
const runQueryCandidates = async (
config: Record<string, any>,
dbName: string,
queries: string[]
): Promise<{ success: boolean; data: any[]; message?: string }> => {
let lastMessage = '';
let hasSuccessfulQuery = false;
for (const query of queries) {
const sql = String(query || '').trim();
if (!sql) continue;
try {
const result = await DBQuery(config as any, dbName, sql);
if (!result.success || !Array.isArray(result.data)) {
lastMessage = result.message || lastMessage;
continue;
}
hasSuccessfulQuery = true;
if (result.data.length > 0) {
return { success: true, data: result.data };
}
} catch (error: any) {
lastMessage = error?.message || String(error);
}
}
if (hasSuccessfulQuery) {
return { success: true, data: [] };
}
return { success: false, data: [], message: lastMessage };
};
const getVersionHint = async (config: Record<string, any>, dbName: string): Promise<string> => {
const candidates = [
`SELECT VERSION() AS version`,
`SHOW VARIABLES LIKE 'version'`,
];
for (const query of candidates) {
try {
const result = await DBQuery(config as any, dbName, query);
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
continue;
}
const row = result.data[0] as Record<string, any>;
const version =
row.version
|| row.VERSION
|| row.Value
|| row.value
|| Object.values(row)[1]
|| Object.values(row)[0];
const text = String(version || '').trim();
if (text) return text;
} catch {
// ignore
}
}
return '';
};
const extractViewDefinition = (dialect: string, data: any[]): string => {
if (!data || data.length === 0) return '-- 未找到视图定义';
const row = data[0];
switch (dialect) {
case 'mysql': {
const keys = Object.keys(row);
const textDefinition = row.view_definition || row.VIEW_DEFINITION;
if (textDefinition) return String(textDefinition);
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
if (sqlKey) return row[sqlKey];
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
if (tableSqlKey) return row[tableSqlKey];
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('VIEW') || val.toUpperCase().includes('TABLE'))) {
return val;
}
}
return JSON.stringify(row, null, 2);
}
case 'oracle':
case 'dm':
return row.view_definition || row.VIEW_DEFINITION || row.text || row.TEXT || Object.values(row)[0] || '';
default:
return row.view_definition || row.VIEW_DEFINITION || row.sql || row.SQL || Object.values(row)[0] || '';
}
};
const extractRoutineDefinition = (dialect: string, data: any[]): string => {
if (!data || data.length === 0) return '-- 未找到函数/存储过程定义';
switch (dialect) {
case 'mysql': {
const row = data[0];
const keys = Object.keys(row);
if (row.routine_definition || row.ROUTINE_DEFINITION) {
return String(row.routine_definition || row.ROUTINE_DEFINITION);
}
const sqlKey = keys.find(k => k.toLowerCase().includes('create function') || k.toLowerCase().includes('create procedure'));
if (sqlKey) return row[sqlKey];
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('FUNCTION') || val.toUpperCase().includes('PROCEDURE'))) {
return val;
}
}
const routineName = String(row.Name || row.name || '').trim();
if (routineName) {
const routineType = String(row.Type || row.type || row.ROUTINE_TYPE || row.routine_type || 'FUNCTION').trim().toUpperCase();
return `-- 当前数据源未返回可执行定义文本,已返回元数据\n-- 名称: ${routineName}\n-- 类型: ${routineType}\n${JSON.stringify(row, null, 2)}`;
}
return JSON.stringify(row, null, 2);
}
case 'oracle':
case 'dm': {
// Oracle/DM ALL_SOURCE returns multiple rows, one per line
return data.map(row => row.text || row.TEXT || Object.values(row)[0] || '').join('');
}
case 'duckdb': {
const row = data[0] as Record<string, any>;
const ddl = buildDuckDBMacroDDL(
String(getCaseInsensitiveRawValue(row, ['schema_name']) || '').trim(),
String(getCaseInsensitiveRawValue(row, ['function_name', 'routine_name', 'name']) || '').trim(),
getCaseInsensitiveRawValue(row, ['parameters']),
getCaseInsensitiveRawValue(row, ['macro_definition'])
);
if (ddl) return ddl;
const fallback = getCaseInsensitiveRawValue(row, ['macro_definition', 'routine_definition', 'definition']);
if (fallback !== undefined && fallback !== null && String(fallback).trim() !== '') {
return String(fallback);
}
return JSON.stringify(row, null, 2);
}
default: {
const row = data[0];
return row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row)[0] || '';
}
}
};
useEffect(() => {
const loadDefinition = async () => {
setLoading(true);
setError(null);
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
setError('未找到数据库连接');
setLoading(false);
return;
}
const dbName = tab.dbName || '';
const dialect = getMetadataDialect(conn);
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
let queries: string[];
let extractFn: (dialect: string, data: any[]) => string;
let objectLabel: string;
if (tab.type === 'view-def') {
const viewName = tab.viewName || '';
if (!viewName) {
setError('视图名称为空');
setLoading(false);
return;
}
queries = buildShowViewQueries(dialect, viewName, dbName);
extractFn = extractViewDefinition;
objectLabel = '视图';
} else {
const routineName = tab.routineName || '';
const routineType = tab.routineType || 'FUNCTION';
if (!routineName) {
setError('函数/存储过程名称为空');
setLoading(false);
return;
}
queries = buildShowRoutineQueries(dialect, routineName, routineType, dbName);
extractFn = extractRoutineDefinition;
objectLabel = '函数/存储过程';
}
if (!queries.length || String(queries[0] || '').startsWith('--')) {
setDefinition(String(queries[0] || '-- 暂不支持该对象定义查看'));
setLoading(false);
return;
}
try {
const config = {
...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: '' }
};
const result = await runQueryCandidates(config, dbName, queries);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
const def = extractFn(dialect, result.data);
setDefinition(def);
return;
}
if (result.success) {
if (sphinxLike) {
const version = await getVersionHint(config, dbName);
const versionText = version ? `(版本: ${version}` : '';
setDefinition(`-- 当前 Sphinx 实例${versionText}未返回${objectLabel}定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`);
return;
}
setDefinition(`-- 未找到${objectLabel}定义`);
} else if (sphinxLike) {
const version = await getVersionHint(config, dbName);
const versionText = version ? `(版本: ${version}` : '';
setDefinition(`-- 当前 Sphinx 实例${versionText}不支持${objectLabel}定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`);
} else {
setError(result.message || '查询定义失败');
}
} catch (e: any) {
setError('查询定义失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
};
loadDefinition();
}, [tab.connectionId, tab.dbName, tab.viewName, tab.routineName, tab.routineType, tab.type, connections]);
const objectLabel = tab.type === 'view-def' ? '视图' : '函数/存储过程';
const objectName = tab.type === 'view-def' ? tab.viewName : tab.routineName;
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin tip={`加载${objectLabel}定义...`} />
</div>
);
}
if (error) {
return (
<div style={{ padding: 16 }}>
<Alert type="error" message="加载失败" description={error} showIcon />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 16px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0' }}>
<strong>{objectLabel}: </strong>{objectName}
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>: {tab.dbName}</span>}
{tab.routineType && <span style={{ marginLeft: 16, color: '#888' }}>: {tab.routineType}</span>}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<Editor
height="100%"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={definition}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
</div>
);
};
export default DefinitionViewer;

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,250 @@
import React, { useState, useEffect } from 'react';
import { Modal, Table, Alert, Progress, Button, Space } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App';
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
interface ImportPreviewModalProps {
visible: boolean;
filePath: string;
connectionId: string;
dbName: string;
tableName: string;
onClose: () => void;
onSuccess: () => void;
}
interface PreviewData {
columns: string[];
totalRows: number;
previewRows: any[];
}
interface ImportProgress {
current: number;
total: number;
success: number;
errors: number;
}
const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
visible,
filePath,
connectionId,
dbName,
tableName,
onClose,
onSuccess
}) => {
const connections = useStore(state => state.connections);
const [loading, setLoading] = useState(true);
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [progress, setProgress] = useState<ImportProgress | null>(null);
const [importResult, setImportResult] = useState<any>(null);
useEffect(() => {
if (visible && filePath) {
loadPreview();
}
}, [visible, filePath]);
useEffect(() => {
if (importing) {
const unsubscribe = EventsOn('import:progress', (data: ImportProgress) => {
setProgress(data);
});
return () => {
EventsOff('import:progress');
};
}
}, [importing]);
const loadPreview = async () => {
setLoading(true);
setError(null);
try {
const res = await PreviewImportFile(filePath);
if (res.success && res.data) {
setPreviewData({
columns: res.data.columns || [],
totalRows: res.data.totalRows || 0,
previewRows: res.data.previewRows || []
});
} else {
setError(res.message || '预览失败');
}
} catch (e: any) {
setError('预览失败: ' + e.message);
} finally {
setLoading(false);
}
};
const handleImport = async () => {
if (!previewData) return;
setImporting(true);
setProgress({ current: 0, total: previewData.totalRows, success: 0, errors: 0 });
setImportResult(null);
try {
const conn = connections.find(c => c.id === connectionId);
if (!conn) {
setError('连接配置未找到');
setImporting(false);
return;
}
const config = {
...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: '' }
};
const res = await ImportDataWithProgress(config as any, dbName, tableName, filePath);
if (res.success && res.data) {
setImportResult(res.data);
if (res.data.failed === 0) {
onSuccess();
}
} else {
setError(res.message || '导入失败');
}
} catch (e: any) {
setError('导入失败: ' + e.message);
} finally {
setImporting(false);
}
};
const columns = previewData?.columns.map(col => ({
title: col,
dataIndex: col,
key: col,
ellipsis: true,
width: 150
})) || [];
const progressPercent = progress ? Math.round((progress.current / progress.total) * 100) : 0;
return (
<Modal
title="导入数据预览"
open={visible}
onCancel={onClose}
width={900}
footer={
importResult ? (
<Space>
<Button onClick={onClose}></Button>
</Space>
) : importing ? null : (
<Space>
<Button onClick={onClose}></Button>
<Button
type="primary"
onClick={handleImport}
disabled={!previewData || loading}
>
</Button>
</Space>
)
}
>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} showIcon />}
{loading && <div style={{ textAlign: 'center', padding: 40 }}>...</div>}
{!loading && previewData && !importing && !importResult && (
<>
<Alert
type="info"
message={`${previewData.totalRows} 行数据,${previewData.columns.length} 个字段`}
description='以下是前 5 行预览数据,确认无误后点击“开始导入”'
style={{ marginBottom: 16 }}
showIcon
/>
<div style={{ marginBottom: 8, fontWeight: 600 }}></div>
<div style={{ marginBottom: 16, padding: 8, background: '#f5f5f5', borderRadius: 4 }}>
{previewData.columns.join(', ')}
</div>
<div style={{ marginBottom: 8, fontWeight: 600 }}> 5 </div>
<Table
dataSource={previewData.previewRows}
columns={columns}
pagination={false}
scroll={{ x: 'max-content' }}
size="small"
bordered
/>
</>
)}
{importing && progress && (
<div style={{ padding: '40px 20px' }}>
<div style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, textAlign: 'center' }}>
...
</div>
<Progress percent={progressPercent} status="active" />
<div style={{ marginTop: 16, textAlign: 'center', color: '#666' }}>
{progress.current} / {progress.total}
<span style={{ marginLeft: 16, color: '#52c41a' }}>
<CheckCircleOutlined /> {progress.success}
</span>
{progress.errors > 0 && (
<span style={{ marginLeft: 16, color: '#ff4d4f' }}>
<CloseCircleOutlined /> {progress.errors}
</span>
)}
</div>
</div>
)}
{importResult && (
<div style={{ padding: 20 }}>
<Alert
type={importResult.failed === 0 ? 'success' : 'warning'}
message="导入完成"
description={
<div>
<div> {importResult.success} </div>
{importResult.failed > 0 && <div> {importResult.failed} </div>}
</div>
}
showIcon
style={{ marginBottom: 16 }}
/>
{importResult.errorLogs && importResult.errorLogs.length > 0 && (
<>
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}></div>
<div style={{
maxHeight: 300,
overflow: 'auto',
background: '#fff1f0',
border: '1px solid #ffccc7',
borderRadius: 4,
padding: 12,
fontSize: 12,
fontFamily: 'monospace'
}}>
{importResult.errorLogs.map((log: string, idx: number) => (
<div key={idx} style={{ marginBottom: 4 }}>{log}</div>
))}
</div>
</>
)}
</div>
)}
</Modal>
);
};
export default ImportPreviewModal;

View File

@@ -1,7 +1,8 @@
import React, { useRef, useEffect } from 'react';
import { Table, Tag, Button, Tooltip } from 'antd';
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
import { Table, Tag, Button, Tooltip, Empty } from 'antd';
import { ClearOutlined, CloseOutlined, BugOutlined, ClockCircleOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
interface LogPanelProps {
height: number;
@@ -10,21 +11,57 @@ interface LogPanelProps {
}
const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
const { sqlLogs, clearSqlLogs, darkMode } = useStore();
const sqlLogs = useStore(state => state.sqlLogs);
const clearSqlLogs = useStore(state => state.clearSqlLogs);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
// Background Helper
const getBg = (darkHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${opacity})`;
const hex = darkHex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#1d1d1d');
const shellOpacity = darkMode ? Math.max(0.18, opacity * 0.82) : Math.max(0.28, opacity * 0.92);
const shellOpacityStrong = darkMode ? Math.max(0.22, opacity * 0.9) : Math.max(0.34, opacity * 0.96);
const panelDividerColor = darkMode
? `rgba(255,255,255,${Math.max(0.04, opacity * 0.10)})`
: `rgba(0,0,0,${Math.max(0.04, opacity * 0.08)})`;
const panelMutedTextColor = darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)';
const panelShellBg = darkMode
? `linear-gradient(180deg, rgba(15,20,30,${shellOpacity}) 0%, rgba(9,13,22,${shellOpacityStrong}) 100%)`
: `linear-gradient(180deg, rgba(255,255,255,${shellOpacityStrong}) 0%, rgba(246,248,252,${shellOpacity}) 100%)`;
const panelAccentColor = darkMode ? '#ffd666' : '#1677ff';
const panelShadow = darkMode
? `0 12px 28px rgba(0,0,0,${Math.max(0.05, opacity * 0.18)})`
: `0 12px 24px rgba(15,23,42,${Math.max(0.02, opacity * 0.08)})`;
const logScrollbarThumb = darkMode
? `rgba(255, 255, 255, ${Math.max(0.18, opacity * 0.34)})`
: `rgba(0, 0, 0, ${Math.max(0.12, opacity * 0.26)})`;
const logScrollbarThumbHover = darkMode
? `rgba(255, 255, 255, ${Math.max(0.28, opacity * 0.48)})`
: `rgba(0, 0, 0, ${Math.max(0.18, opacity * 0.36)})`;
const columns = [
{
title: 'Time',
dataIndex: 'timestamp',
width: 80,
render: (ts: number) => <span style={{ color: '#888', fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
render: (ts: number) => <span style={{ color: panelMutedTextColor, fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
},
{
title: 'Status',
dataIndex: 'status',
width: 70,
render: (status: string) => (
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0 }}>
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0, borderRadius: 999, paddingInline: 8, fontSize: 11, fontWeight: 700 }}>
{status === 'success' ? 'OK' : 'ERR'}
</Tag>
)
@@ -39,10 +76,10 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
title: 'SQL / Message',
dataIndex: 'sql',
render: (text: string, record: any) => (
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.2' }}>
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.45' }}>
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
{record.affectedRows !== undefined && <div style={{ color: '#888', marginTop: 1 }}>Affected: {record.affectedRows}</div>}
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>Affected: {record.affectedRows}</div>}
</div>
)
}
@@ -51,12 +88,18 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
return (
<div style={{
height,
borderTop: darkMode ? '1px solid #303030' : '1px solid #d9d9d9',
background: darkMode ? '#1f1f1f' : '#fff',
margin: 0,
border: `1px solid ${panelDividerColor}`,
borderRadius: 14,
background: panelShellBg,
WebkitBackdropFilter: opacity < 0.999 ? 'blur(14px)' : 'none',
boxShadow: panelShadow,
backdropFilter: darkMode && opacity < 0.999 ? 'blur(18px)' : 'none',
display: 'flex',
flexDirection: 'column',
position: 'relative',
zIndex: 100 // Ensure above other content
overflow: 'hidden',
zIndex: 100
}}>
{/* Resize Handle */}
<div
@@ -74,41 +117,95 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
{/* Toolbar */}
<div style={{
padding: '4px 8px',
borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
padding: '10px 14px',
borderBottom: `1px solid ${panelDividerColor}`,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: darkMode ? '#2a2a2a' : '#fafafa',
height: 32
gap: 12,
minHeight: 48
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>
<BugOutlined /> SQL
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? `rgba(255,214,102,${Math.max(0.10, Math.min(0.18, opacity * 0.18))})` : `rgba(24,144,255,${Math.max(0.08, Math.min(0.16, opacity * 0.16))})`, color: panelAccentColor, flexShrink: 0 }}>
<BugOutlined />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: darkMode ? '#f5f7ff' : '#162033' }}>SQL </div>
<div style={{ fontSize: 12, color: panelMutedTextColor }}>便</div>
</div>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Tooltip title="清空日志">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} />
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} style={{ color: panelMutedTextColor }} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} />
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
</Tooltip>
</div>
</div>
{/* List */}
<div style={{ flex: 1, overflow: 'auto' }}>
<Table
dataSource={sqlLogs}
columns={columns}
size="small"
pagination={false}
rowKey="id"
showHeader={false}
// scroll={{ y: height - 32 }} // Let flex handle it
/>
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto', padding: '8px 10px 10px' }}>
{sqlLogs.length === 0 ? (
<div style={{ height: '100%', minHeight: 160, display: 'grid', placeItems: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<span style={{ color: panelMutedTextColor }}> SQL </span>}
/>
</div>
) : (
<Table
className="log-panel-table"
dataSource={sqlLogs}
columns={columns}
size="small"
pagination={false}
rowKey="id"
showHeader={false}
/>
)}
</div>
<style>{`
.log-panel-scroll {
scrollbar-width: thin;
scrollbar-color: ${logScrollbarThumb} transparent;
}
.log-panel-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.log-panel-scroll::-webkit-scrollbar-track,
.log-panel-scroll::-webkit-scrollbar-corner {
background: transparent;
}
.log-panel-scroll::-webkit-scrollbar-thumb {
background: ${logScrollbarThumb};
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
.log-panel-scroll::-webkit-scrollbar-thumb:hover {
background: ${logScrollbarThumbHover};
background-clip: padding-box;
}
.log-panel-table .ant-table,
.log-panel-table .ant-table-container,
.log-panel-table .ant-table-tbody > tr > td {
background: transparent !important;
}
.log-panel-table .ant-table-tbody > tr > td {
padding: 8px 10px !important;
border-bottom: 1px solid ${panelDividerColor} !important;
}
.log-panel-table .ant-table-tbody > tr:last-child > td {
border-bottom: none !important;
}
.log-panel-table .ant-table-row:hover > td {
background: ${darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)'} !important;
}
`}</style>
</div>
);
};
export default LogPanel;
export default LogPanel;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
import React, { useState, useCallback, useRef } from 'react';
import { Button, Space, message } from 'antd';
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import Editor, { OnMount } from '@monaco-editor/react';
interface RedisCommandEditorProps {
connectionId: string;
redisDB: number;
}
interface CommandResult {
command: string;
result: any;
error?: string;
timestamp: number;
}
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
const { connections } = useStore();
const connection = connections.find(c => c.id === connectionId);
const [command, setCommand] = useState('');
const [results, setResults] = useState<CommandResult[]>([]);
const [loading, setLoading] = useState(false);
const editorRef = useRef<any>(null);
const getConfig = useCallback(() => {
if (!connection) return null;
return {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || "",
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
redisDB: redisDB
};
}, [connection, redisDB]);
const handleEditorMount: OnMount = (editor) => {
editorRef.current = editor;
// Add keyboard shortcut for execute
editor.addCommand(
// Ctrl/Cmd + Enter
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
() => handleExecute()
);
};
const handleExecute = async () => {
const config = getConfig();
if (!config) return;
const cmdToExecute = command.trim();
if (!cmdToExecute) {
message.warning('请输入命令');
return;
}
// Support multiple commands separated by newlines
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
setLoading(true);
const newResults: CommandResult[] = [];
for (const cmd of commands) {
const trimmedCmd = cmd.trim();
if (!trimmedCmd) continue;
try {
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
newResults.push({
command: trimmedCmd,
result: res.success ? res.data : null,
error: res.success ? undefined : res.message,
timestamp: Date.now()
});
} catch (e: any) {
newResults.push({
command: trimmedCmd,
result: null,
error: e?.message || String(e),
timestamp: Date.now()
});
}
}
setResults(prev => [...newResults, ...prev]);
setLoading(false);
};
const handleClear = () => {
setResults([]);
};
const formatResult = (result: any): string => {
if (result === null || result === undefined) {
return '(nil)';
}
if (typeof result === 'string') {
return `"${result}"`;
}
if (typeof result === 'number') {
return `(integer) ${result}`;
}
if (Array.isArray(result)) {
if (result.length === 0) {
return '(empty array)';
}
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
}
if (typeof result === 'object') {
return JSON.stringify(result, null, 2);
}
return String(result);
};
if (!connection) {
return <div style={{ padding: 20 }}></div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Command Input */}
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<span style={{ fontWeight: 500 }}>Redis </span>
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
</Space>
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleExecute}
loading={loading}
>
(Ctrl+Enter)
</Button>
<Button icon={<ClearOutlined />} onClick={handleClear}></Button>
</Space>
</div>
<Editor
height="150px"
defaultLanguage="plaintext"
value={command}
onChange={(value) => setCommand(value || '')}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
fontSize: 14,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2
}}
/>
</div>
{/* Results */}
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
{results.length === 0 ? (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
Redis Ctrl+Enter
<br />
<span style={{ fontSize: 12 }}></span>
</div>
) : (
results.map((item, index) => (
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
<div style={{ color: '#569cd6', marginBottom: 4 }}>
&gt; {item.command}
</div>
{item.error ? (
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
(error) {item.error}
</div>
) : (
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
{formatResult(item.result)}
</div>
)}
</div>
))
)}
</div>
{/* Common Commands Help */}
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
:
<span style={{ marginLeft: 8 }}>
<code>KEYS *</code> |
<code style={{ marginLeft: 8 }}>GET key</code> |
<code style={{ marginLeft: 8 }}>SET key value</code> |
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
<code style={{ marginLeft: 8 }}>INFO</code> |
<code style={{ marginLeft: 8 }}>DBSIZE</code>
</span>
</div>
</div>
);
};
export default RedisCommandEditor;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,108 @@
import React, { useMemo } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { Tabs, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import type { MenuProps, TabsProps } from 'antd';
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import { useStore } from '../store';
import DataViewer from './DataViewer';
import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
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 => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
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}`;
};
type SortableTabLabelProps = {
displayTitle: string;
menuItems: MenuProps['items'];
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
displayTitle,
menuItems,
}) => {
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span
className="tab-dnd-label"
onContextMenu={(e) => e.preventDefault()}
title="拖拽调整标签顺序"
>
{displayTitle}
</span>
</Dropdown>
);
};
type DraggableTabNodeProps = {
node: React.ReactElement;
};
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
const tabId = String(node.key || '').trim();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
const style: React.CSSProperties = {
...(node.props.style || {}),
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
opacity: isDragging ? 0.88 : 1,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
zIndex: isDragging ? 2 : node.props.style?.zIndex,
};
return React.cloneElement(node, {
ref: setNodeRef,
style,
...attributes,
...listeners,
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
});
};
const TabManager: React.FC = () => {
const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs } = useStore();
const tabs = useStore(state => state.tabs);
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const closeTab = useStore(state => state.closeTab);
const closeOtherTabs = useStore(state => state.closeOtherTabs);
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
const closeTabsToRight = useStore(state => state.closeTabsToRight);
const closeAllTabs = useStore(state => state.closeAllTabs);
const moveTab = useStore(state => state.moveTab);
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const suppressClickUntilRef = useRef<number>(0);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const onChange = (newActiveKey: string) => {
setActiveTab(newActiveKey);
@@ -19,7 +114,37 @@ const TabManager: React.FC = () => {
}
};
const handleDragStart = (event: DragStartEvent) => {
const sourceId = String(event.active.id || '').trim();
setDraggingTabId(sourceId || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const sourceId = String(event.active.id || '').trim();
const targetId = String(event.over?.id || '').trim();
setDraggingTabId(null);
if (!sourceId || !targetId || sourceId === targetId) {
return;
}
suppressClickUntilRef.current = Date.now() + 120;
moveTab(sourceId, targetId);
};
const handleDragCancel = () => {
setDraggingTabId(null);
};
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
<DefaultTabBar {...tabBarProps}>
{(node) => <DraggableTabNode key={node.key} node={node} />}
</DefaultTabBar>
);
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
@@ -27,6 +152,16 @@ const TabManager: React.FC = () => {
content = <DataViewer tab={tab} />;
} else if (tab.type === 'design') {
content = <TableDesigner tab={tab} />;
} else if (tab.type === 'redis-keys') {
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-command') {
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'trigger') {
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'] = [
@@ -59,14 +194,15 @@ const TabManager: React.FC = () => {
return {
label: (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span onContextMenu={(e) => e.preventDefault()}>{tab.title}</span>
</Dropdown>
<SortableTabLabel
displayTitle={displayTitle}
menuItems={menuItems}
/>
),
key: tab.id,
children: content,
};
}), [tabs, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>
@@ -75,6 +211,7 @@ const TabManager: React.FC = () => {
height: 100%;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -85,6 +222,7 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -92,12 +230,14 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -105,20 +245,70 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
.main-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
.main-tabs .ant-tabs-nav::before {
border-bottom: 1px solid ${tabsNavBorderColor} !important;
}
.main-tabs .ant-tabs-tab {
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease;
}
.main-tabs .tab-dnd-label {
user-select: none;
-webkit-user-select: none;
display: inline-flex;
align-items: center;
max-width: 100%;
}
.main-tabs .tab-dnd-node.is-dragging,
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
cursor: grabbing !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible {
outline: none !important;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.72);
background: rgba(255, 214, 102, 0.16);
}
body[data-theme='light'] .main-tabs .ant-tabs-tab-btn:focus-visible {
outline: none !important;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
background: rgba(9, 109, 217, 0.08);
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(255, 214, 102, 0.12) !important;
border-color: rgba(255, 214, 102, 0.4) !important;
}
`}</style>
<Tabs
className="main-tabs"
type="editable-card"
onChange={onChange}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
hideAdd
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<Tabs
className="main-tabs"
type="editable-card"
destroyInactiveTabPane={false}
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);
}}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
hideAdd
renderTabBar={renderTabBar}
/>
</SortableContext>
</DndContext>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,456 @@
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':
case 'highgo': {
const schema = schemaName || 'public';
return `
SELECT
n.nspname || '.' || 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
s.name + '.' + 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
JOIN ${safeDB}.sys.schemas s ON t.schema_id = s.schema_id
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 s.name, t.name, ep.value
ORDER BY s.name, 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,316 @@
import React, { useState, useEffect } from 'react';
import Editor from '@monaco-editor/react';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
interface TriggerViewerProps {
tab: TabData;
}
const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [triggerDefinition, setTriggerDefinition] = useState<string>('');
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
const getMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
return driver;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const isSphinxConnection = (conn: any): boolean => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'sphinx') return true;
if (type !== 'custom') return false;
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
return driver === 'sphinx' || driver === 'sphinxql';
};
const buildShowTriggerQueries = (dialect: string, triggerName: string, dbName: string): string[] => {
const safeTriggerName = escapeSQLLiteral(triggerName);
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
return [
`SHOW CREATE TRIGGER \`${triggerName.replace(/`/g, '``')}\``,
safeDbName
? `SELECT ACTION_STATEMENT AS trigger_definition FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' AND trigger_name = '${safeTriggerName}' LIMIT 1`
: '',
safeDbName
? `SHOW TRIGGERS FROM \`${dbName.replace(/`/g, '``')}\` LIKE '${safeTriggerName}'`
: `SHOW TRIGGERS LIKE '${safeTriggerName}'`,
].filter(Boolean);
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return [`SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition
FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
WHERE t.tgname = '${safeTriggerName}'
AND NOT t.tgisinternal
LIMIT 1`];
case 'sqlserver': {
return [`SELECT OBJECT_DEFINITION(OBJECT_ID('${safeTriggerName.replace(/'/g, "''")}')) AS trigger_definition`];
}
case 'oracle':
case 'dm':
if (!safeDbName) {
return [`SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`];
}
return [`SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`];
case 'sqlite':
return [`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`];
case 'duckdb':
return [`-- DuckDB 不支持触发器`];
case 'tdengine':
return [`-- TDengine 不支持触发器`];
case 'mongodb':
return [`-- MongoDB 不支持触发器`];
default:
return [`-- 暂不支持该数据库类型的触发器定义查看`];
}
};
const runQueryCandidates = async (
config: Record<string, any>,
dbName: string,
queries: string[]
): Promise<{ success: boolean; data: any[]; message?: string }> => {
let lastMessage = '';
let hasSuccessfulQuery = false;
for (const query of queries) {
const sql = String(query || '').trim();
if (!sql) continue;
try {
const result = await DBQuery(config as any, dbName, sql);
if (!result.success || !Array.isArray(result.data)) {
lastMessage = result.message || lastMessage;
continue;
}
hasSuccessfulQuery = true;
if (result.data.length > 0) {
return { success: true, data: result.data };
}
} catch (error: any) {
lastMessage = error?.message || String(error);
}
}
if (hasSuccessfulQuery) {
return { success: true, data: [] };
}
return { success: false, data: [], message: lastMessage };
};
const getVersionHint = async (config: Record<string, any>, dbName: string): Promise<string> => {
const candidates = [
`SELECT VERSION() AS version`,
`SHOW VARIABLES LIKE 'version'`,
];
for (const query of candidates) {
try {
const result = await DBQuery(config as any, dbName, query);
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
continue;
}
const row = result.data[0] as Record<string, any>;
const version =
row.version
|| row.VERSION
|| row.Value
|| row.value
|| Object.values(row)[1]
|| Object.values(row)[0];
const text = String(version || '').trim();
if (text) return text;
} catch {
// ignore
}
}
return '';
};
const extractTriggerDefinition = (dialect: string, data: any[]): string => {
if (!data || data.length === 0) {
return '-- 未找到触发器定义';
}
const row = data[0];
switch (dialect) {
case 'mysql': {
// MySQL SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ...
const keys = Object.keys(row);
if (row.trigger_definition || row.TRIGGER_DEFINITION) {
return String(row.trigger_definition || row.TRIGGER_DEFINITION);
}
if (row.ACTION_STATEMENT || row.action_statement) {
return String(row.ACTION_STATEMENT || row.action_statement);
}
const sqlKey = keys.find(k => k.toLowerCase().includes('statement') || k.toLowerCase() === 'sql original statement');
if (sqlKey) return row[sqlKey];
// Fallback: try to find any key containing CREATE TRIGGER
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE TRIGGER')) {
return val;
}
}
return JSON.stringify(row, null, 2);
}
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
}
case 'sqlserver': {
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
}
case 'oracle':
case 'dm': {
return row.trigger_body || row.TRIGGER_BODY || Object.values(row)[0] || '';
}
case 'sqlite': {
return row.sql || row.SQL || Object.values(row)[0] || '';
}
default:
return JSON.stringify(row, null, 2);
}
};
useEffect(() => {
const loadTriggerDefinition = async () => {
setLoading(true);
setError(null);
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
setError('未找到数据库连接');
setLoading(false);
return;
}
const triggerName = tab.triggerName || '';
const dbName = tab.dbName || '';
if (!triggerName) {
setError('触发器名称为空');
setLoading(false);
return;
}
const dialect = getMetadataDialect(conn);
const queries = buildShowTriggerQueries(dialect, triggerName, dbName);
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
if (!queries.length || String(queries[0] || '').startsWith('--')) {
setTriggerDefinition(String(queries[0] || '-- 暂不支持该数据库类型的触发器定义查看'));
setLoading(false);
return;
}
try {
const config = {
...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: '' }
};
const result = await runQueryCandidates(config, dbName, queries);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
const definition = extractTriggerDefinition(dialect, result.data);
setTriggerDefinition(definition);
return;
}
if (result.success) {
if (sphinxLike) {
const version = await getVersionHint(config, dbName);
const versionText = version ? `(版本: ${version}` : '';
setTriggerDefinition(`-- 当前 Sphinx 实例${versionText}未返回触发器定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`);
return;
}
setTriggerDefinition('-- 未找到触发器定义');
} else if (sphinxLike) {
const version = await getVersionHint(config, dbName);
const versionText = version ? `(版本: ${version}` : '';
setTriggerDefinition(`-- 当前 Sphinx 实例${versionText}不支持触发器定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`);
} else {
setError(result.message || '查询触发器定义失败');
}
} catch (e: any) {
setError('查询触发器定义失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
};
loadTriggerDefinition();
}, [tab.connectionId, tab.dbName, tab.triggerName, connections]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin tip="加载触发器定义..." />
</div>
);
}
if (error) {
return (
<div style={{ padding: 16 }}>
<Alert type="error" message="加载失败" description={error} showIcon />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 16px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0' }}>
<strong>: </strong>{tab.triggerName}
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>: {tab.dbName}</span>}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<Editor
height="100%"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={triggerDefinition}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
</div>
);
};
export default TriggerViewer;

View File

@@ -0,0 +1,69 @@
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } 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,
'较粗滚动条场景下应同步放大底部安全区'
);
assertEqual(
calculateVirtualTableScrollX({
totalWidth: 646,
tableViewportWidth: 1200,
isMacLike: false,
}),
1200,
'列总宽小于视口时应按视口宽度返回 scroll.x避免 header/body 走两套宽度'
);
assertEqual(
calculateVirtualTableScrollX({
totalWidth: 646,
tableViewportWidth: 0,
isMacLike: false,
}),
646,
'未拿到视口宽度时应退回列宽总和'
);
assertEqual(
calculateVirtualTableScrollX({
totalWidth: 1200,
tableViewportWidth: 800,
isMacLike: true,
}),
1202,
'macOS 横向溢出时仍需额外预留 2px 以稳定滚动轨道'
);
console.log('dataGridLayout tests passed');

View File

@@ -0,0 +1,48 @@
export interface TableBodyBottomPaddingOptions {
hasHorizontalOverflow: boolean;
floatingScrollbarHeight: number;
floatingScrollbarGap: number;
}
export interface VirtualTableScrollXOptions {
totalWidth: number;
tableViewportWidth: number;
isMacLike: boolean;
}
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;
};
export const calculateVirtualTableScrollX = ({
totalWidth,
tableViewportWidth,
isMacLike,
}: VirtualTableScrollXOptions): number => {
const safeTotalWidth = Math.max(0, Math.ceil(totalWidth));
const safeViewportWidth = Math.max(0, Math.floor(tableViewportWidth));
if (safeViewportWidth > 0 && safeTotalWidth < safeViewportWidth) {
return safeViewportWidth;
}
if (isMacLike && safeViewportWidth > 0 && safeTotalWidth > safeViewportWidth) {
return safeTotalWidth + 2;
}
return safeTotalWidth;
};

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

@@ -2,6 +2,13 @@ export {};
declare global {
interface Window {
go: any;
runtime: {
WindowMinimise: () => void;
WindowToggleMaximise: () => void;
Quit: () => void;
BrowserOpenURL: (url: string) => void;
};
ipcRenderer: {
send: (channel: string, ...args: any[]) => void;
on: (channel: string, listener: (event: any, ...args: any[]) => void) => void;

View File

@@ -3,6 +3,54 @@ import ReactDOM from 'react-dom/client'
import App from './App'
// import './index.css' // Optional global styles
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。
import 'monaco-editor/esm/nls.messages.zh-cn'
import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'
loader.config({ monaco })
if (typeof window !== 'undefined' && !(window as any).go) {
(window as any).go = {
app: {
App: {
CheckUpdate: async () => ({ success: false }),
DownloadUpdate: async () => ({ success: false }),
GetSavedConnections: async () => [],
SaveConnection: async () => null,
DeleteConnection: async () => null,
OpenConnection: async () => null,
CloseConnection: async () => null,
GetDatabases: async () => [],
GetTables: async () => [],
GetTableData: async () => ({ columns: [], rows: [], total: 0 }),
GetTableColumns: async () => [],
ExecuteQuery: async () => ({ columns: [], rows: [], time: 0 }),
GetSavedQueries: async () => [],
SaveQuery: async () => null,
DeleteQuery: async () => null,
GetAppInfo: async () => ({}),
CheckForUpdates: async () => ({ success: false }),
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false }),
ExportData: async () => ({ success: false }),
}
}
};
}
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#1e1e1e', 'editorStickyScrollHover.background': '#2a2a2a' }
})
monaco.editor.defineTheme('transparent-light', {
base: 'vs', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#ffffff', 'editorStickyScrollHover.background': '#f5f5f5' }
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

File diff suppressed because it is too large Load Diff

View File

@@ -6,15 +6,64 @@ export interface SSHConfig {
keyPath?: string;
}
export interface ProxyConfig {
type: 'socks5' | 'http';
host: string;
port: number;
user?: string;
password?: string;
}
export interface HTTPTunnelConfig {
host: string;
port: number;
user?: string;
password?: string;
}
export interface ConnectionConfig {
type: string;
host: string;
port: number;
user: string;
password?: string;
savePassword?: boolean;
database?: string;
useSSL?: boolean;
sslMode?: 'preferred' | 'required' | 'skip-verify' | 'disable';
sslCertPath?: string;
sslKeyPath?: string;
useSSH?: boolean;
ssh?: SSHConfig;
useProxy?: boolean;
proxy?: ProxyConfig;
useHttpTunnel?: boolean;
httpTunnel?: HTTPTunnelConfig;
driver?: string;
dsn?: string;
timeout?: number;
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
hosts?: string[]; // Multi-host addresses: host:port
topology?: 'single' | 'replica' | 'cluster';
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;
authSource?: string;
readPreference?: string;
mongoSrv?: boolean;
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
}
export interface MongoMemberInfo {
host: string;
role: string;
state: string;
stateCode?: number;
healthy: boolean;
isSelf?: boolean;
}
export interface SavedConnection {
@@ -22,6 +71,15 @@ export interface SavedConnection {
name: string;
config: ConnectionConfig;
includeDatabases?: string[];
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
}
export interface ConnectionTag {
id: string;
name: string;
connectionIds: string[];
}
export interface ColumnDefinition {
@@ -60,13 +118,19 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
connectionId: string;
dbName?: string;
tableName?: string;
query?: string;
initialTab?: string;
readOnly?: boolean;
redisDB?: number; // Redis database index for redis tabs
triggerName?: string; // Trigger name for trigger tabs
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 {
@@ -85,3 +149,37 @@ export interface SavedQuery {
dbName: string;
createdAt: number;
}
// Redis types
export interface RedisKeyInfo {
key: string;
type: string;
ttl: number;
}
export interface RedisScanResult {
keys: RedisKeyInfo[];
cursor: string;
}
export interface RedisValue {
type: 'string' | 'hash' | 'list' | 'set' | 'zset' | 'stream';
ttl: number;
value: any;
length: number;
}
export interface RedisDBInfo {
index: number;
keys: number;
}
export interface ZSetMember {
member: string;
score: number;
}
export interface StreamEntry {
id: string;
fields: Record<string, string>;
}

View File

@@ -0,0 +1,82 @@
const DEFAULT_OPACITY = 1.0;
const MIN_OPACITY = 0.1;
const MAX_OPACITY = 1.0;
// 平台透明度映射因子值越大滑块变化越平滑1.0 = 线性映射)
const MAC_OPACITY_FACTOR = 0.60;
const MAC_BLUR_FACTOR = 1.00;
const WINDOWS_OPACITY_FACTOR = 0.70;
const WINDOWS_BLUR_FACTOR = 1.00;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export interface AppearanceSettingsLike {
enabled?: boolean;
opacity?: number;
blur?: number;
}
export const resolveAppearanceValues = (appearance: AppearanceSettingsLike | undefined): { opacity: number; blur: number } => {
if (!appearance || appearance.enabled !== false) {
return {
opacity: appearance?.opacity ?? DEFAULT_OPACITY,
blur: appearance?.blur ?? 0,
};
}
return { opacity: DEFAULT_OPACITY, blur: 0 };
};
export const isMacLikePlatform = (): boolean => {
if (typeof navigator === 'undefined') {
return false;
}
const platform = navigator.platform || '';
const ua = navigator.userAgent || '';
return /(Mac|iPhone|iPad|iPod)/i.test(`${platform} ${ua}`);
};
export const isWindowsPlatform = (): boolean => {
if (typeof navigator === 'undefined') {
return false;
}
const platform = navigator.platform || '';
const ua = navigator.userAgent || '';
return /(Win|Windows)/i.test(`${platform} ${ua}`);
};
const getPlatformFactors = () => {
if (isMacLikePlatform()) {
return { opacity: MAC_OPACITY_FACTOR, blur: MAC_BLUR_FACTOR };
}
if (isWindowsPlatform()) {
return { opacity: WINDOWS_OPACITY_FACTOR, blur: WINDOWS_BLUR_FACTOR };
}
return undefined;
};
export const normalizeOpacityForPlatform = (opacity: number | undefined): number => {
const raw = clamp(opacity ?? DEFAULT_OPACITY, MIN_OPACITY, MAX_OPACITY);
// 用户显式拉到 100%% 时,必须保持完全不透明,不能再被平台映射压低。
if (raw >= MAX_OPACITY - 1e-6) {
return MAX_OPACITY;
}
const factors = getPlatformFactors();
if (!factors) {
return raw;
}
return clamp(MIN_OPACITY + (raw - MIN_OPACITY) * factors.opacity, MIN_OPACITY, MAX_OPACITY);
};
export const normalizeBlurForPlatform = (blur: number | undefined): number => {
const raw = Math.max(0, blur ?? 0);
const factors = getPlatformFactors();
if (!factors) {
return raw;
}
return Math.round(raw * factors.blur);
};
export const blurToFilter = (blur: number): string | undefined => {
return blur > 0 ? `blur(${blur}px)` : undefined;
};

View File

@@ -0,0 +1,86 @@
import type { ConnectionConfig } from '../types';
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver'> | null | undefined;
const normalizeDataSourceToken = (raw: string): string => {
const normalized = String(raw || '').trim().toLowerCase();
switch (normalized) {
case 'doris':
return 'diros';
case 'postgresql':
return 'postgres';
case 'dm':
return 'dameng';
default:
return normalized;
}
};
export const resolveDataSourceType = (config: ConnectionLike): string => {
if (!config) return '';
const type = normalizeDataSourceToken(String(config.type || ''));
if (type === 'custom') {
const driver = normalizeDataSourceToken(String(config.driver || ''));
return driver || 'custom';
}
return type;
};
const SQL_QUERY_EXPORT_TYPES = new Set([
'mysql',
'mariadb',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
]);
const COPY_INSERT_TYPES = new Set([
'mysql',
'mariadb',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
]);
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
export type DataSourceCapabilities = {
type: string;
supportsQueryEditor: boolean;
supportsSqlQueryExport: boolean;
supportsCopyInsert: boolean;
forceReadOnlyQueryResult: boolean;
};
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
const type = resolveDataSourceType(config);
return {
type,
supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type),
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
};
};

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,258 @@
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
export type ShortcutAction =
| 'runQuery'
| 'focusSidebarSearch'
| 'newQueryTab'
| 'toggleLogPanel'
| 'toggleTheme'
| 'openShortcutManager';
export interface ShortcutBinding {
combo: string;
enabled: boolean;
}
export type ShortcutOptions = Record<ShortcutAction, ShortcutBinding>;
export interface ShortcutActionMeta {
label: string;
description: string;
allowInEditable?: boolean;
}
const MODIFIER_ORDER = ['Ctrl', 'Meta', 'Alt', 'Shift'] as const;
const MODIFIER_SET = new Set(MODIFIER_ORDER);
const KEY_ALIASES: Record<string, string> = {
control: 'Ctrl',
ctrl: 'Ctrl',
command: 'Meta',
cmd: 'Meta',
meta: 'Meta',
option: 'Alt',
alt: 'Alt',
shift: 'Shift',
escape: 'Esc',
esc: 'Esc',
return: 'Enter',
enter: 'Enter',
tab: 'Tab',
space: 'Space',
' ': 'Space',
backspace: 'Backspace',
delete: 'Delete',
del: 'Delete',
arrowup: 'Up',
up: 'Up',
arrowdown: 'Down',
down: 'Down',
arrowleft: 'Left',
left: 'Left',
arrowright: 'Right',
right: 'Right',
pagedown: 'PageDown',
pageup: 'PageUp',
home: 'Home',
end: 'End',
insert: 'Insert',
',': ',',
'.': '.',
'/': '/',
';': ';',
"'": "'",
'[': '[',
']': ']',
'\\': '\\',
'-': '-',
'=': '=',
'`': '`',
};
export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'runQuery',
'focusSidebarSearch',
'newQueryTab',
'toggleLogPanel',
'toggleTheme',
'openShortcutManager',
];
export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> = {
runQuery: {
label: '执行 SQL',
description: '在当前查询页执行 SQL',
},
focusSidebarSearch: {
label: '聚焦侧边栏搜索',
description: '定位到左侧连接树搜索框',
allowInEditable: true,
},
newQueryTab: {
label: '新建查询页',
description: '创建一个新的 SQL 查询标签页',
},
toggleLogPanel: {
label: '切换日志面板',
description: '打开或关闭 SQL 执行日志面板',
},
toggleTheme: {
label: '切换主题',
description: '在亮色和暗色主题之间切换',
},
openShortcutManager: {
label: '打开快捷键管理',
description: '打开快捷键设置面板',
allowInEditable: true,
},
};
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
runQuery: { combo: 'Ctrl+Shift+R', enabled: true },
focusSidebarSearch: { combo: 'Ctrl+F', enabled: true },
newQueryTab: { combo: 'Ctrl+Shift+N', enabled: true },
toggleLogPanel: { combo: 'Ctrl+Shift+L', enabled: true },
toggleTheme: { combo: 'Ctrl+Shift+D', enabled: true },
openShortcutManager: { combo: 'Ctrl+,', enabled: true },
};
const normalizeKeyToken = (value: string): string => {
const token = String(value || '').trim();
if (!token) return '';
const alias = KEY_ALIASES[token.toLowerCase()];
if (alias) return alias;
if (/^f([1-9]|1[0-2])$/i.test(token)) {
return token.toUpperCase();
}
if (token.length === 1) {
return token === '+' ? '+' : token.toUpperCase();
}
return token.length > 1 ? token[0].toUpperCase() + token.slice(1).toLowerCase() : token;
};
export const normalizeShortcutCombo = (combo: string): string => {
const raw = String(combo || '').trim();
if (!raw) return '';
const pieces = raw
.split('+')
.map(part => part.trim())
.filter(Boolean);
const modifiers: string[] = [];
let key = '';
pieces.forEach((part) => {
const normalized = normalizeKeyToken(part);
if (!normalized) return;
if (MODIFIER_SET.has(normalized as typeof MODIFIER_ORDER[number])) {
if (!modifiers.includes(normalized)) {
modifiers.push(normalized);
}
return;
}
key = normalized;
});
modifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a as typeof MODIFIER_ORDER[number]) - MODIFIER_ORDER.indexOf(b as typeof MODIFIER_ORDER[number]));
if (!key) {
return modifiers.join('+');
}
return [...modifiers, key].join('+');
};
const normalizeKeyboardKey = (key: string): string => {
const token = String(key || '').trim();
if (!token) return '';
const alias = KEY_ALIASES[token.toLowerCase()];
if (alias) return alias;
if (token.length === 1) {
if (token === ' ') return 'Space';
return token.toUpperCase();
}
if (/^f([1-9]|1[0-2])$/i.test(token)) {
return token.toUpperCase();
}
return token.length > 1 ? token[0].toUpperCase() + token.slice(1) : token;
};
export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => {
const key = normalizeKeyboardKey(event.key);
if (!key || MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])) {
return '';
}
const modifiers: string[] = [];
if (event.ctrlKey) modifiers.push('Ctrl');
if (event.metaKey) modifiers.push('Meta');
if (event.altKey) modifiers.push('Alt');
if (event.shiftKey) modifiers.push('Shift');
return normalizeShortcutCombo([...modifiers, key].join('+'));
};
export const isShortcutMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo: string): boolean => {
const expected = normalizeShortcutCombo(combo);
if (!expected) return false;
const actual = eventToShortcut(event);
return actual === expected;
};
export const hasModifierKey = (combo: string): boolean => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized) return false;
return normalized.split('+').some(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]));
};
export const cloneShortcutOptions = (value: ShortcutOptions): ShortcutOptions => {
return SHORTCUT_ACTION_ORDER.reduce((acc, action) => {
acc[action] = {
combo: normalizeShortcutCombo(value[action]?.combo || DEFAULT_SHORTCUT_OPTIONS[action].combo),
enabled: value[action]?.enabled !== false,
};
return acc;
}, {} as ShortcutOptions);
};
export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const defaults = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS);
SHORTCUT_ACTION_ORDER.forEach((action) => {
const actionRaw = raw[action];
if (!actionRaw || typeof actionRaw !== 'object') {
return;
}
const binding = actionRaw as Record<string, unknown>;
const combo = normalizeShortcutCombo(String(binding.combo || defaults[action].combo));
defaults[action] = {
combo: combo || defaults[action].combo,
enabled: binding.enabled === false ? false : true,
};
});
return defaults;
};
export const isEditableElement = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
const tag = target.tagName.toLowerCase();
if (target.isContentEditable) {
return true;
}
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
return true;
}
if (target.closest('.monaco-editor, .monaco-inputbox, .ant-select, .ant-picker, .ant-input')) {
return true;
}
return false;
};
export const getShortcutDisplay = (combo: string): string => {
const normalized = normalizeShortcutCombo(combo);
return normalized || '-';
};

View File

@@ -1,5 +1,7 @@
export type FilterCondition = {
id?: number;
enabled?: boolean;
logic?: 'AND' | 'OR';
column?: string;
op?: string;
value?: string;
@@ -18,10 +20,42 @@ const normalizeIdentPart = (ident: string) => {
return raw;
};
// 检查标识符是否需要引号(包含特殊字符或是保留字)
const needsQuote = (ident: string): boolean => {
if (!ident) return false;
// 如果包含特殊字符(非字母、数字、下划线)则需要引号
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(ident)) return true;
// PostgreSQL 会将未加引号的标识符折叠为小写,含大写字母时必须加引号
if (/[A-Z]/.test(ident)) return true;
// 常见 SQL 保留字列表(简化版)
const reserved = ['select', 'from', 'where', 'table', 'index', 'user', 'order', 'group', 'by', 'limit', 'offset', 'and', 'or', 'not', 'null', 'true', 'false', 'key', 'primary', 'foreign', 'references', 'default', 'constraint', 'create', 'drop', 'alter', 'insert', 'update', 'delete', 'set', 'values', 'into', 'join', 'left', 'right', 'inner', 'outer', 'on', 'as', 'is', 'in', 'like', 'between', 'case', 'when', 'then', 'else', 'end', 'having', 'distinct', 'all', 'any', 'exists', 'union', 'except', 'intersect'];
return reserved.includes(ident.toLowerCase());
};
export const quoteIdentPart = (dbType: string, ident: string) => {
const raw = normalizeIdentPart(ident);
if (!raw) return raw;
if ((dbType || '').toLowerCase() === 'mysql') return `\`${raw.replace(/`/g, '``')}\``;
const dbTypeLower = (dbType || '').toLowerCase();
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
return `\`${raw.replace(/`/g, '``')}\``;
}
// 对于 KingBase/PostgreSQL只在必要时加引号
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres') {
if (needsQuote(raw)) {
return `"${raw.replace(/"/g, '""')}"`;
}
// 不加引号,保持原样(数据库会自动转小写处理)
return raw;
}
// SQL Server 使用 [bracket] 标识符
if (dbTypeLower === 'sqlserver' || dbTypeLower === 'mssql') {
return `[${raw.replace(/]/g, ']]')}]`;
}
// 其他数据库默认加双引号
return `"${raw.replace(/"/g, '""')}"`;
};
@@ -35,6 +69,147 @@ export const quoteQualifiedIdent = (dbType: string, ident: string) => {
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");
type SortInfoItem = {
columnKey?: string;
order?: string;
enabled?: boolean;
};
type SortInfo = SortInfoItem | SortInfoItem[] | null | undefined;
// 为排序查询按库类型注入 sort_buffer 提升参数(仅影响当前语句)。
// MySQL: 使用 Optimizer Hint `SET_VAR`。
// MariaDB: 使用 `SET STATEMENT ... FOR` 包装当前查询。
export const withSortBufferTuningSQL = (
dbType: string,
sql: string,
sortBufferBytes: number,
) => {
const rawSql = String(sql || '');
const trimmed = rawSql.trim();
if (!trimmed) return rawSql;
if (!/^select\b/i.test(trimmed)) return rawSql;
const normalizedType = String(dbType || '').trim().toLowerCase();
const bytes = Math.max(256 * 1024, Math.floor(Number(sortBufferBytes) || 0));
if (normalizedType === 'mysql') {
return rawSql.replace(
/^\s*select\b/i,
(matched) => `${matched} /*+ SET_VAR(sort_buffer_size=${bytes}) */`,
);
}
if (normalizedType === 'mariadb') {
return `SET STATEMENT sort_buffer_size=${bytes} FOR ${rawSql}`;
}
return rawSql;
};
/** 将 SortInfo单字段或多字段标准化为 SortInfoItem 数组 */
const normalizeSortInfoItems = (sortInfo: SortInfo): SortInfoItem[] => {
if (!sortInfo) return [];
if (Array.isArray(sortInfo)) return sortInfo;
return [sortInfo];
};
/** 判断 SortInfo 中是否存在至少一个有效排序 */
export const hasExplicitSort = (sortInfo: SortInfo): boolean => {
const items = normalizeSortInfoItems(sortInfo);
return items.some(item => {
if (item?.enabled === false) return false;
const col = String(item?.columnKey || '').trim();
const order = String(item?.order || '');
return !!col && (order === 'ascend' || order === 'descend');
});
};
export const buildOrderBySQL = (
dbType: string,
sortInfo: SortInfo,
fallbackColumns: string[] = [],
) => {
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const items = normalizeSortInfoItems(sortInfo);
const seen = new Set<string>();
const sortParts: string[] = [];
for (const item of items) {
if (item?.enabled === false) continue;
const sortColumn = normalizeIdentPart(String(item?.columnKey || ''));
const sortOrder = String(item?.order || '');
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
if (sortColumn && direction) {
const key = sortColumn.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
sortParts.push(`${quoteIdentPart(dbType, sortColumn)} ${direction}`);
}
}
}
if (sortParts.length > 0) {
return ` ORDER BY ${sortParts.join(', ')}`;
}
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY即使按主键可能触发 filesort
// 导致 `Error 1038 (HY001): Out of sort memory`。
// 因此仅在用户主动点击排序时下发 ORDER BY默认分页查询不加兜底排序。
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros') {
return '';
}
const stableColumns = (fallbackColumns || [])
.map((col) => normalizeIdentPart(String(col || '')))
.filter((col) => {
if (!col) return false;
const key = col.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (stableColumns.length > 0) {
const parts = stableColumns.map((col) => `${quoteIdentPart(dbType, col)} ASC`);
return ` ORDER BY ${parts.join(', ')}`;
}
return '';
};
export const buildPaginatedSelectSQL = (
dbType: string,
baseSql: string,
orderBySQL: string,
limit: number,
offset: number,
) => {
const normalizedType = String(dbType || '').trim().toLowerCase();
const safeLimit = Math.max(0, Math.floor(Number(limit) || 0));
const safeOffset = Math.max(0, Math.floor(Number(offset) || 0));
const base = String(baseSql || '').trim();
const orderBy = String(orderBySQL || '');
if (!base || safeLimit <= 0) {
return `${base}${orderBy}`;
}
switch (normalizedType) {
case 'oracle': {
const orderedSql = `${base}${orderBy}`;
const upperBound = safeOffset + safeLimit;
if (safeOffset <= 0) {
return `SELECT * FROM (${orderedSql}) WHERE ROWNUM <= ${upperBound}`;
}
return `SELECT * FROM (SELECT "__gonavi_page__".*, ROWNUM "__gonavi_rn__" FROM (${orderedSql}) "__gonavi_page__" WHERE ROWNUM <= ${upperBound}) WHERE "__gonavi_rn__" > ${safeOffset}`;
}
case 'sqlserver':
case 'mssql': {
const effectiveOrderBy = orderBy.trim() ? orderBy : ' ORDER BY (SELECT NULL)';
return `${base}${effectiveOrderBy} OFFSET ${safeOffset} ROWS FETCH NEXT ${safeLimit} ROWS ONLY`;
}
default:
return `${base}${orderBy} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
}
};
export const parseListValues = (val: string) => {
const raw = (val || '').trim();
if (!raw) return [];
@@ -44,18 +219,31 @@ export const parseListValues = (val: string) => {
.filter(Boolean);
};
const normalizeConditionLogic = (logic: unknown): 'AND' | 'OR' => {
return String(logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND';
};
export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) => {
const whereParts: string[] = [];
const whereParts: Array<{ expr: string; logic: 'AND' | 'OR' }> = [];
(conditions || []).forEach((cond) => {
if (cond?.enabled === false) return;
const op = (cond?.op || '').trim();
const column = (cond?.column || '').trim();
const value = (cond?.value ?? '').toString();
const value2 = (cond?.value2 ?? '').toString();
const logic = normalizeConditionLogic(cond?.logic);
const appendWherePart = (expr: string) => {
const normalizedExpr = String(expr || '').trim();
if (!normalizedExpr) return;
whereParts.push({ expr: normalizedExpr, logic });
};
if (op === 'CUSTOM') {
const expr = value.trim();
if (expr) whereParts.push(`(${expr})`);
if (expr) appendWherePart(`(${expr})`);
return;
}
@@ -65,80 +253,80 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
switch (op) {
case 'IS_NULL':
whereParts.push(`${col} IS NULL`);
appendWherePart(`${col} IS NULL`);
return;
case 'IS_NOT_NULL':
whereParts.push(`${col} IS NOT NULL`);
appendWherePart(`${col} IS NOT NULL`);
return;
case 'IS_EMPTY':
// 兼容:空值通常理解为 NULL 或空字符串
whereParts.push(`(${col} IS NULL OR ${col} = '')`);
appendWherePart(`(${col} IS NULL OR ${col} = '')`);
return;
case 'IS_NOT_EMPTY':
whereParts.push(`(${col} IS NOT NULL AND ${col} <> '')`);
appendWherePart(`(${col} IS NOT NULL AND ${col} <> '')`);
return;
case 'BETWEEN': {
const v1 = value.trim();
const v2 = value2.trim();
if (!v1 || !v2) return;
whereParts.push(`${col} BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
appendWherePart(`${col} BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
return;
}
case 'NOT_BETWEEN': {
const v1 = value.trim();
const v2 = value2.trim();
if (!v1 || !v2) return;
whereParts.push(`${col} NOT BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
appendWherePart(`${col} NOT BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
return;
}
case 'IN': {
const items = parseListValues(value);
if (items.length === 0) return;
const list = items.map(v => `'${escapeLiteral(v)}'`).join(', ');
whereParts.push(`${col} IN (${list})`);
appendWherePart(`${col} IN (${list})`);
return;
}
case 'NOT_IN': {
const items = parseListValues(value);
if (items.length === 0) return;
const list = items.map(v => `'${escapeLiteral(v)}'`).join(', ');
whereParts.push(`${col} NOT IN (${list})`);
appendWherePart(`${col} NOT IN (${list})`);
return;
}
case 'CONTAINS': {
const v = value.trim();
if (!v) return;
whereParts.push(`${col} LIKE '%${escapeLiteral(v)}%'`);
appendWherePart(`${col} LIKE '%${escapeLiteral(v)}%'`);
return;
}
case 'NOT_CONTAINS': {
const v = value.trim();
if (!v) return;
whereParts.push(`${col} NOT LIKE '%${escapeLiteral(v)}%'`);
appendWherePart(`${col} NOT LIKE '%${escapeLiteral(v)}%'`);
return;
}
case 'STARTS_WITH': {
const v = value.trim();
if (!v) return;
whereParts.push(`${col} LIKE '${escapeLiteral(v)}%'`);
appendWherePart(`${col} LIKE '${escapeLiteral(v)}%'`);
return;
}
case 'NOT_STARTS_WITH': {
const v = value.trim();
if (!v) return;
whereParts.push(`${col} NOT LIKE '${escapeLiteral(v)}%'`);
appendWherePart(`${col} NOT LIKE '${escapeLiteral(v)}%'`);
return;
}
case 'ENDS_WITH': {
const v = value.trim();
if (!v) return;
whereParts.push(`${col} LIKE '%${escapeLiteral(v)}'`);
appendWherePart(`${col} LIKE '%${escapeLiteral(v)}'`);
return;
}
case 'NOT_ENDS_WITH': {
const v = value.trim();
if (!v) return;
whereParts.push(`${col} NOT LIKE '%${escapeLiteral(v)}'`);
appendWherePart(`${col} NOT LIKE '%${escapeLiteral(v)}'`);
return;
}
case '=':
@@ -149,7 +337,7 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
case '>=': {
const v = value.trim();
if (!v) return;
whereParts.push(`${col} ${op} '${escapeLiteral(v)}'`);
appendWherePart(`${col} ${op} '${escapeLiteral(v)}'`);
return;
}
default: {
@@ -157,17 +345,23 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
if (op.toUpperCase() === 'LIKE') {
const v = value.trim();
if (!v) return;
whereParts.push(`${col} LIKE '%${escapeLiteral(v)}%'`);
appendWherePart(`${col} LIKE '%${escapeLiteral(v)}%'`);
return;
}
const v = value.trim();
if (!v) return;
whereParts.push(`${col} ${op} '${escapeLiteral(v)}'`);
appendWherePart(`${col} ${op} '${escapeLiteral(v)}'`);
}
}
});
return whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
};
if (whereParts.length === 0) return '';
let whereExpr = `(${whereParts[0].expr})`;
for (let i = 1; i < whereParts.length; i++) {
const part = whereParts[i];
whereExpr = `(${whereExpr} ${part.logic} (${part.expr}))`;
}
return `WHERE ${whereExpr}`;
};

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

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,
},
@@ -12,4 +13,4 @@ export default defineConfig({
outDir: 'dist', // Standard Wails output directory
emptyOutDir: true,
}
})
})

View File

@@ -1,10 +1,26 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {connection} from '../models';
import {time} from '../models';
import {sync} from '../models';
import {redis} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
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>;
export function CleanupStaleQueries(arg1:time.Duration):Promise<void>;
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
@@ -25,6 +41,12 @@ export function DBGetTriggers(arg1:connection.ConnectionConfig,arg2:string,arg3:
export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
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>;
export function DataSync(arg1:sync.SyncConfig):Promise<sync.SyncResult>;
@@ -33,6 +55,20 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryRe
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DownloadUpdate():Promise<connection.QueryResult>;
export function DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function DropFunction(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
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>;
@@ -41,12 +77,34 @@ export function ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:st
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>,arg4:boolean):Promise<connection.QueryResult>;
export function GenerateQueryID():Promise<string>;
export function GetAppInfo():Promise<connection.QueryResult>;
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function GetDriverVersionList(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function GetDriverVersionPackageSize(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function GetGlobalProxyConfig():Promise<connection.QueryResult>;
export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function MySQLConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function MySQLGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
@@ -57,6 +115,86 @@ export function MySQLQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:str
export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function OpenDownloadedUpdateDirectory():Promise<connection.QueryResult>;
export function OpenSQLFile():Promise<connection.QueryResult>;
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisDeleteKeys(arg1:connection.ConnectionConfig,arg2:Array<string>):Promise<connection.QueryResult>;
export function RedisExecuteCommand(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisFlushDB(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisGetServerInfo(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
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>;
export function RedisRenameKey(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:any,arg4:number):Promise<connection.QueryResult>;
export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise<connection.QueryResult>;
export function RedisSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisSetHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function RedisSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisSetString(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:number):Promise<connection.QueryResult>;
export function RedisSetTTL(arg1:connection.ConnectionConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
export function RedisStreamAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Record<string, string>,arg4:string):Promise<connection.QueryResult>;
export function RedisStreamDelete(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisTestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<redis.ZSetMember>):Promise<connection.QueryResult>;
export function RedisZSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RemoveDriverPackage(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function RenameDatabase(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function RenameView(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ResolveDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.QueryResult>;
export function SelectDatabaseFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
export function SelectDriverPackageDirectory(arg1:string):Promise<connection.QueryResult>;
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
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

@@ -6,6 +6,34 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
}
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']();
}
export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates']();
}
export function CleanupStaleQueries(arg1) {
return window['go']['app']['App']['CleanupStaleQueries'](arg1);
}
export function ConfigureDriverRuntimeDirectory(arg1) {
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
}
export function ConfigureGlobalProxy(arg1, arg2) {
return window['go']['app']['App']['ConfigureGlobalProxy'](arg1, arg2);
}
export function CreateDatabase(arg1, arg2) {
return window['go']['app']['App']['CreateDatabase'](arg1, arg2);
}
@@ -46,6 +74,18 @@ export function DBQuery(arg1, arg2, arg3) {
return window['go']['app']['App']['DBQuery'](arg1, arg2, arg3);
}
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);
}
export function DBShowCreateTable(arg1, arg2, arg3) {
return window['go']['app']['App']['DBShowCreateTable'](arg1, arg2, arg3);
}
@@ -62,6 +102,34 @@ export function DataSyncPreview(arg1, arg2, arg3) {
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
}
export function DownloadDriverPackage(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4);
}
export function DownloadUpdate() {
return window['go']['app']['App']['DownloadUpdate']();
}
export function DropDatabase(arg1, arg2) {
return window['go']['app']['App']['DropDatabase'](arg1, arg2);
}
export function DropFunction(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DropFunction'](arg1, arg2, arg3, arg4);
}
export function DropTable(arg1, arg2, arg3) {
return window['go']['app']['App']['DropTable'](arg1, arg2, arg3);
}
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);
}
@@ -78,10 +146,38 @@ export function ExportTable(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4);
}
export function ExportTablesDataSQL(arg1, arg2, arg3) {
return window['go']['app']['App']['ExportTablesDataSQL'](arg1, arg2, arg3);
}
export function ExportTablesSQL(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4);
}
export function GenerateQueryID() {
return window['go']['app']['App']['GenerateQueryID']();
}
export function GetAppInfo() {
return window['go']['app']['App']['GetAppInfo']();
}
export function GetDriverStatusList(arg1, arg2) {
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
}
export function GetDriverVersionList(arg1, arg2) {
return window['go']['app']['App']['GetDriverVersionList'](arg1, arg2);
}
export function GetDriverVersionPackageSize(arg1, arg2) {
return window['go']['app']['App']['GetDriverVersionPackageSize'](arg1, arg2);
}
export function GetGlobalProxyConfig() {
return window['go']['app']['App']['GetGlobalProxyConfig']();
}
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}
@@ -90,6 +186,22 @@ export function ImportData(arg1, arg2, arg3) {
return window['go']['app']['App']['ImportData'](arg1, arg2, arg3);
}
export function ImportDataWithProgress(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4);
}
export function InstallLocalDriverPackage(arg1, arg2, arg3) {
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3);
}
export function InstallUpdateAndRestart() {
return window['go']['app']['App']['InstallUpdateAndRestart']();
}
export function MongoDiscoverMembers(arg1) {
return window['go']['app']['App']['MongoDiscoverMembers'](arg1);
}
export function MySQLConnect(arg1) {
return window['go']['app']['App']['MySQLConnect'](arg1);
}
@@ -110,10 +222,170 @@ export function MySQLShowCreateTable(arg1, arg2, arg3) {
return window['go']['app']['App']['MySQLShowCreateTable'](arg1, arg2, arg3);
}
export function OpenDownloadedUpdateDirectory() {
return window['go']['app']['App']['OpenDownloadedUpdateDirectory']();
}
export function OpenSQLFile() {
return window['go']['app']['App']['OpenSQLFile']();
}
export function PreviewImportFile(arg1) {
return window['go']['app']['App']['PreviewImportFile'](arg1);
}
export function RedisConnect(arg1) {
return window['go']['app']['App']['RedisConnect'](arg1);
}
export function RedisDeleteHashField(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisDeleteHashField'](arg1, arg2, arg3);
}
export function RedisDeleteKeys(arg1, arg2) {
return window['go']['app']['App']['RedisDeleteKeys'](arg1, arg2);
}
export function RedisExecuteCommand(arg1, arg2) {
return window['go']['app']['App']['RedisExecuteCommand'](arg1, arg2);
}
export function RedisFlushDB(arg1) {
return window['go']['app']['App']['RedisFlushDB'](arg1);
}
export function RedisGetDatabases(arg1) {
return window['go']['app']['App']['RedisGetDatabases'](arg1);
}
export function RedisGetServerInfo(arg1) {
return window['go']['app']['App']['RedisGetServerInfo'](arg1);
}
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);
}
export function RedisListSet(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisListSet'](arg1, arg2, arg3, arg4);
}
export function RedisRenameKey(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisRenameKey'](arg1, arg2, arg3);
}
export function RedisScanKeys(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisScanKeys'](arg1, arg2, arg3, arg4);
}
export function RedisSelectDB(arg1, arg2) {
return window['go']['app']['App']['RedisSelectDB'](arg1, arg2);
}
export function RedisSetAdd(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisSetAdd'](arg1, arg2, arg3);
}
export function RedisSetHashField(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisSetHashField'](arg1, arg2, arg3, arg4);
}
export function RedisSetRemove(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisSetRemove'](arg1, arg2, arg3);
}
export function RedisSetString(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisSetString'](arg1, arg2, arg3, arg4);
}
export function RedisSetTTL(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisSetTTL'](arg1, arg2, arg3);
}
export function RedisStreamAdd(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisStreamAdd'](arg1, arg2, arg3, arg4);
}
export function RedisStreamDelete(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisStreamDelete'](arg1, arg2, arg3);
}
export function RedisTestConnection(arg1) {
return window['go']['app']['App']['RedisTestConnection'](arg1);
}
export function RedisZSetAdd(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisZSetAdd'](arg1, arg2, arg3);
}
export function RedisZSetRemove(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisZSetRemove'](arg1, arg2, arg3);
}
export function RemoveDriverPackage(arg1, arg2) {
return window['go']['app']['App']['RemoveDriverPackage'](arg1, arg2);
}
export function RenameDatabase(arg1, arg2, arg3) {
return window['go']['app']['App']['RenameDatabase'](arg1, arg2, arg3);
}
export function RenameTable(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RenameTable'](arg1, arg2, arg3, arg4);
}
export function RenameView(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RenameView'](arg1, arg2, arg3, arg4);
}
export function ResolveDriverDownloadDirectory(arg1) {
return window['go']['app']['App']['ResolveDriverDownloadDirectory'](arg1);
}
export function ResolveDriverPackageDownloadURL(arg1, arg2) {
return window['go']['app']['App']['ResolveDriverPackageDownloadURL'](arg1, arg2);
}
export function ResolveDriverRepositoryURL(arg1) {
return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1);
}
export function SelectDatabaseFile(arg1, arg2) {
return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2);
}
export function SelectDriverDownloadDirectory(arg1) {
return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1);
}
export function SelectDriverPackageDirectory(arg1) {
return window['go']['app']['App']['SelectDriverPackageDirectory'](arg1);
}
export function SelectDriverPackageFile(arg1) {
return window['go']['app']['App']['SelectDriverPackageFile'](arg1);
}
export function SelectSSHKeyFile(arg1) {
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
}
export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['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

@@ -48,6 +48,44 @@ export namespace connection {
return a;
}
}
export class HTTPTunnelConfig {
host: string;
port: number;
user?: string;
password?: string;
static createFrom(source: any = {}) {
return new HTTPTunnelConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
}
}
export class ProxyConfig {
type: string;
host: string;
port: number;
user?: string;
password?: string;
static createFrom(source: any = {}) {
return new ProxyConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
}
}
export class SSHConfig {
host: string;
port: number;
@@ -74,12 +112,34 @@ export namespace connection {
port: number;
user: string;
password: string;
savePassword?: boolean;
database: string;
useSSL?: boolean;
sslMode?: string;
sslCertPath?: string;
sslKeyPath?: string;
useSSH: boolean;
ssh: SSHConfig;
useProxy?: boolean;
proxy?: ProxyConfig;
useHttpTunnel?: boolean;
httpTunnel?: HTTPTunnelConfig;
driver?: string;
dsn?: string;
timeout?: number;
redisDB?: number;
uri?: string;
hosts?: string[];
topology?: string;
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;
authSource?: string;
readPreference?: string;
mongoSrv?: boolean;
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
static createFrom(source: any = {}) {
return new ConnectionConfig(source);
@@ -92,12 +152,34 @@ export namespace connection {
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.savePassword = source["savePassword"];
this.database = source["database"];
this.useSSL = source["useSSL"];
this.sslMode = source["sslMode"];
this.sslCertPath = source["sslCertPath"];
this.sslKeyPath = source["sslKeyPath"];
this.useSSH = source["useSSH"];
this.ssh = this.convertValues(source["ssh"], SSHConfig);
this.useProxy = source["useProxy"];
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
this.useHttpTunnel = source["useHttpTunnel"];
this.httpTunnel = this.convertValues(source["httpTunnel"], HTTPTunnelConfig);
this.driver = source["driver"];
this.dsn = source["dsn"];
this.timeout = source["timeout"];
this.redisDB = source["redisDB"];
this.uri = source["uri"];
this.hosts = source["hosts"];
this.topology = source["topology"];
this.mysqlReplicaUser = source["mysqlReplicaUser"];
this.mysqlReplicaPassword = source["mysqlReplicaPassword"];
this.replicaSet = source["replicaSet"];
this.authSource = source["authSource"];
this.readPreference = source["readPreference"];
this.mongoSrv = source["mongoSrv"];
this.mongoAuthMechanism = source["mongoAuthMechanism"];
this.mongoReplicaUser = source["mongoReplicaUser"];
this.mongoReplicaPassword = source["mongoReplicaPassword"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -118,11 +200,14 @@ export namespace connection {
return a;
}
}
export class QueryResult {
success: boolean;
message: string;
data: any;
fields?: string[];
queryId?: string;
static createFrom(source: any = {}) {
return new QueryResult(source);
@@ -134,12 +219,32 @@ export namespace connection {
this.message = source["message"];
this.data = source["data"];
this.fields = source["fields"];
this.queryId = source["queryId"];
}
}
}
export namespace redis {
export class ZSetMember {
member: string;
score: number;
static createFrom(source: any = {}) {
return new ZSetMember(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.member = source["member"];
this.score = source["score"];
}
}
}
export namespace sync {
export class TableOptions {
@@ -172,6 +277,9 @@ export namespace sync {
mode: string;
jobId?: string;
autoAddColumns?: boolean;
targetTableStrategy?: string;
createIndexes?: boolean;
mongoCollectionName?: string;
tableOptions?: Record<string, TableOptions>;
static createFrom(source: any = {}) {
@@ -187,6 +295,9 @@ export namespace sync {
this.mode = source["mode"];
this.jobId = source["jobId"];
this.autoAddColumns = source["autoAddColumns"];
this.targetTableStrategy = source["targetTableStrategy"];
this.createIndexes = source["createIndexes"];
this.mongoCollectionName = source["mongoCollectionName"];
this.tableOptions = this.convertValues(source["tableOptions"], TableOptions, true);
}

68
go.mod
View File

@@ -5,24 +5,58 @@ go 1.24.3
require (
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
gitee.com/chunanyong/dm v1.8.22
github.com/ClickHouse/clickhouse-go/v2 v2.43.0
github.com/duckdb/duckdb-go/v2 v2.5.5
github.com/go-sql-driver/mysql v1.9.3
github.com/google/uuid v1.6.0
github.com/highgo/pq-sm3 v0.0.0
github.com/lib/pq v1.11.1
github.com/microsoft/go-mssqldb v1.9.6
github.com/redis/go-redis/v9 v9.17.3
github.com/sijms/go-ora/v2 v2.9.0
github.com/taosdata/driver-go/v3 v3.7.8
github.com/wailsapp/wails/v2 v2.11.0
github.com/xuri/excelize/v2 v2.10.0
go.mongodb.org/mongo-driver v1.17.9
go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/crypto v0.47.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/text v0.33.0
modernc.org/sqlite v1.44.3
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.5.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/duckdb/duckdb-go-bindings v0.3.3 // indirect
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.3.3 // indirect
github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.3.3 // indirect
github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.3.3 // indirect
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3 // indirect
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
@@ -31,22 +65,46 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/tiendc/go-deepcopy v1.7.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.48.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace github.com/highgo/pq-sm3 => ./third_party/highgo-pq

258
go.sum
View File

@@ -4,31 +4,122 @@ gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3 h1:QjslQNaH5Nuap5i4ni
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3/go.mod h1:7lH5A1jzCXD9Nl16DzaBUOfDAT8NPrDmZwKu1p5wf94=
gitee.com/chunanyong/dm v1.8.22 h1:H7fsrnUIvEA0jlDWew7vwELry1ff+tLMIu2Fk2cIBSg=
gitee.com/chunanyong/dm v1.8.22/go.mod h1:EPRJnuPFgbyOFgJ0TRYCTGzhq+ZT4wdyaj/GW/LLcNg=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI=
github.com/apache/arrow-go/v18 v18.5.1/go.mod h1:OCCJsmdq8AsRm8FkBSSmYTwL/s4zHW9CqxeBxEytkNE=
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/duckdb/duckdb-go-bindings v0.3.3 h1:lXogtCY8hiGLQvTfK55HcgvaA3K2MrwKeZGqhIin35U=
github.com/duckdb/duckdb-go-bindings v0.3.3/go.mod h1:zS7OpBP8zwVlP38OljRZOnqWYlNd4KLcVfMoA1JFzpk=
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.3.3 h1:ue8BtIOSt+2Bt2fEfTAvBcQLxzBFhgfCcyzPtqQWTRA=
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.3.3/go.mod h1:EnAvZh1kNJHp5yF+M1ZHNEvapnmt6anq1xXHVrAGqMo=
github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.3.3 h1:2TrSeTgtwi3WIvub9ba0mny+AClSNo1w0Ghszc2B8lQ=
github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.3.3/go.mod h1:IGLSeEcFhNeZF16aVjQCULD7TsFZKG5G7SyKJAXKp5c=
github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.3.3 h1:GN0cexhfE7uLb7qgDmsYG324wKF15nW+O7v5+NGalS4=
github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.3.3/go.mod h1:KAIynZ0GHCS7X5fRyuFnQMg/SZBPK/bS9OCOVojClxw=
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3 h1:bIJV+ct6yvMXjy+N3bfILFd0fkTK50AUhUTerkY40/8=
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3/go.mod h1:81SGOYoEUs8qaAfSk1wRfM5oobrIJ5KI7AzYhK6/bvQ=
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3 h1:SK2sunA/MPb2T3113iFzHv6DWeu+qrsw0DizTFrvM+Q=
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3/go.mod h1:K25pJL26ARblGDeuAkrdblFvUen92+CwksLtPEHRqqQ=
github.com/duckdb/duckdb-go/v2 v2.5.5 h1:TlK8ipnzoKW2aNrjGqRkFWLCDpJDxR/VwH8ezEcvVhw=
github.com/duckdb/duckdb-go/v2 v2.5.5/go.mod h1:6uIbC3gz36NCEygECzboygOo/Z9TeVwox/puG+ohWV0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -53,25 +144,73 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg=
github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/taosdata/driver-go/v3 v3.7.8 h1:N2H6HLLZH2ve2ipcoFgG9BJS+yW0XksqNYwEdSmHaJk=
github.com/taosdata/driver-go/v3 v3.7.8/go.mod h1:gSxBEPOueMg0rTmMO1Ug6aeD7AwGdDGvUtLrsDTTpYc=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -84,35 +223,126 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 h1:i0p03B68+xC1kD2QUO8JzDTPXCzhN56OLJ+IhHY8U3A=
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

View File

@@ -8,25 +8,47 @@ import (
"errors"
"fmt"
"net"
"net/url"
"os"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
proxytunnel "GoNavi-Wails/internal/proxy"
"github.com/google/uuid"
)
const dbCachePingInterval = 30 * time.Second
type cachedDatabase struct {
inst db.Database
lastPing time.Time
}
type queryContext struct {
cancel context.CancelFunc
started time.Time
}
// App struct
type App struct {
ctx context.Context
dbCache map[string]db.Database // Cache for DB connections
mu sync.Mutex // Mutex for cache access
ctx context.Context
dbCache map[string]cachedDatabase // Cache for DB connections
mu sync.RWMutex // Mutex for cache access
updateMu sync.Mutex
updateState updateState
queryMu sync.RWMutex
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{
dbCache: make(map[string]db.Database),
dbCache: make(map[string]cachedDatabase),
runningQueries: make(map[string]queryContext),
}
}
@@ -35,42 +57,170 @@ func NewApp() *App {
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
logger.Init()
applyMacWindowTranslucencyFix()
logger.Infof("应用启动完成")
}
// SetWindowTranslucency 动态调整 macOS 窗口透明度。
// 前端在加载用户外观设置后、以及用户修改外观时调用此方法。
// opacity=1.0 且 blur=0 时窗口标记为 opaqueGPU 不再持续计算窗口背后的模糊合成。
func (a *App) SetWindowTranslucency(opacity float64, blur float64) {
setMacWindowTranslucency(opacity, blur)
}
// Shutdown is called when the app terminates
func (a *App) Shutdown(ctx context.Context) {
logger.Infof("应用开始关闭,准备释放资源")
a.mu.Lock()
defer a.mu.Unlock()
for _, dbInst := range a.dbCache {
if err := dbInst.Close(); err != nil {
if err := dbInst.inst.Close(); err != nil {
logger.Error(err, "关闭数据库连接失败")
}
}
proxytunnel.CloseAllForwarders()
// Close all Redis connections
CloseAllRedisClients()
logger.Infof("资源释放完成,应用已关闭")
logger.Close()
}
// Helper: Generate a unique key for the connection config
func getCacheKey(config connection.ConnectionConfig) string {
if !config.UseSSH {
config.SSH = connection.SSHConfig{}
func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
normalized := config
normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type))
// timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。
normalized.Timeout = 0
normalized.SavePassword = false
if !normalized.UseSSH {
normalized.SSH = connection.SSHConfig{}
}
// 保持与驱动默认一致,避免同一连接被重复缓存
if config.Type == "postgres" && config.Database == "" {
config.Database = "postgres"
if !normalized.UseProxy {
normalized.Proxy = connection.ProxyConfig{}
}
if !normalized.UseHTTPTunnel {
normalized.HTTPTunnel = connection.HTTPTunnelConfig{}
}
b, _ := json.Marshal(config)
if isFileDatabaseType(normalized.Type) {
dsn := strings.TrimSpace(normalized.Host)
if dsn == "" {
dsn = strings.TrimSpace(normalized.Database)
}
if dsn == "" {
dsn = ":memory:"
}
// DuckDB/SQLite 仅基于文件来源识别连接,其他网络字段不参与键计算。
normalized.Host = dsn
normalized.Database = ""
normalized.Port = 0
normalized.User = ""
normalized.Password = ""
normalized.URI = ""
normalized.Hosts = nil
normalized.Topology = ""
normalized.MySQLReplicaUser = ""
normalized.MySQLReplicaPassword = ""
normalized.ReplicaSet = ""
normalized.AuthSource = ""
normalized.ReadPreference = ""
normalized.MongoSRV = false
normalized.MongoAuthMechanism = ""
normalized.MongoReplicaUser = ""
normalized.MongoReplicaPassword = ""
normalized.UseHTTPTunnel = false
normalized.HTTPTunnel = connection.HTTPTunnelConfig{}
}
return normalized
}
func resolveFileDatabaseDSN(config connection.ConnectionConfig) string {
dsn := strings.TrimSpace(config.Host)
if dsn == "" {
dsn = strings.TrimSpace(config.Database)
}
if dsn == "" {
dsn = ":memory:"
}
return dsn
}
// Helper: Generate a unique key for the connection config
func getCacheKey(config connection.ConnectionConfig) string {
normalized := normalizeCacheKeyConfig(config)
b, _ := json.Marshal(normalized)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func shortCacheKey(cacheKey string) string {
shortKey := cacheKey
if len(shortKey) > 12 {
shortKey = shortKey[:12]
}
return shortKey
}
func shouldRefreshCachedConnection(err error) bool {
if err == nil {
return false
}
normalized := strings.ToLower(normalizeErrorMessage(err))
if normalized == "" {
return false
}
patterns := []string{
"invalid connection",
"bad connection",
"database is closed",
"connection is already closed",
"use of closed network connection",
"broken pipe",
"connection reset by peer",
"server has gone away",
"eof",
}
for _, pattern := range patterns {
if strings.Contains(normalized, pattern) {
return true
}
}
return false
}
func (a *App) invalidateCachedDatabase(config connection.ConnectionConfig, reason error) bool {
effectiveConfig := applyGlobalProxyToConnection(config)
key := getCacheKey(effectiveConfig)
shortKey := shortCacheKey(key)
a.mu.Lock()
defer a.mu.Unlock()
entry, exists := a.dbCache[key]
if !exists || entry.inst == nil {
return false
}
if closeErr := entry.inst.Close(); closeErr != nil {
logger.Error(closeErr, "关闭失效缓存连接失败缓存Key=%s", shortKey)
}
delete(a.dbCache, key)
if reason != nil {
logger.Errorf("检测到连接失效,已清理缓存连接:%s 缓存Key=%s 原因=%s", formatConnSummary(effectiveConfig), shortKey, normalizeErrorMessage(reason))
} else {
logger.Infof("已清理缓存连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
}
return true
}
func wrapConnectError(config connection.ConnectionConfig, err error) error {
if err == nil {
return nil
}
err = sanitizeMongoConnectErrorLabel(config, err)
var netErr net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
@@ -84,16 +234,89 @@ func wrapConnectError(config connection.ConnectionConfig, err error) error {
return withLogHint{err: err, logPath: logger.Path()}
}
type errorMessageOverride struct {
message string
cause error
}
func (e errorMessageOverride) Error() string {
return e.message
}
func (e errorMessageOverride) Unwrap() error {
return e.cause
}
func sanitizeMongoConnectErrorLabel(config connection.ConnectionConfig, err error) error {
if err == nil {
return nil
}
if strings.ToLower(strings.TrimSpace(config.Type)) != "mongodb" {
return err
}
if mongoConnectUsesTLS(config) {
return err
}
original := err.Error()
rewritten := strings.ReplaceAll(original, "SSL 主库凭据", "主库凭据")
rewritten = strings.ReplaceAll(rewritten, "SSL 从库凭据", "从库凭据")
if rewritten == original {
return err
}
return errorMessageOverride{
message: rewritten,
cause: err,
}
}
func mongoConnectUsesTLS(config connection.ConnectionConfig) bool {
if config.UseSSL {
return true
}
uriText := strings.TrimSpace(config.URI)
if uriText == "" {
return false
}
parsed, err := url.Parse(uriText)
if err != nil {
return false
}
for _, key := range []string{"tls", "ssl"} {
if enabled, known := parseMongoBool(parsed.Query().Get(key)); known {
return enabled
}
}
return strings.EqualFold(strings.TrimSpace(parsed.Scheme), "mongodb+srv")
}
func parseMongoBool(raw string) (enabled bool, known bool) {
value := strings.ToLower(strings.TrimSpace(raw))
switch value {
case "1", "true", "t", "yes", "y", "on", "required":
return true, true
case "0", "false", "f", "no", "n", "off", "disable", "disabled":
return false, true
default:
return false, false
}
}
type withLogHint struct {
err error
logPath string
}
func (e withLogHint) Error() string {
if strings.TrimSpace(e.logPath) == "" {
return e.err.Error()
message := normalizeErrorMessage(e.err)
path := strings.TrimSpace(e.logPath)
if path == "" {
return message
}
return fmt.Sprintf("%s详细日志%s", e.err.Error(), e.logPath)
info, statErr := os.Stat(path)
if statErr != nil || info.IsDir() || info.Size() <= 0 {
return message
}
return fmt.Sprintf("%s详细日志%s", message, path)
}
func (e withLogHint) Unwrap() error {
@@ -112,12 +335,60 @@ func formatConnSummary(config connection.ConnectionConfig) string {
}
var b strings.Builder
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
if normalizedType == "sqlite" || normalizedType == "duckdb" {
path := strings.TrimSpace(config.Host)
if path == "" {
path = "(未配置)"
}
b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds))
} else {
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
}
if len(config.Hosts) > 0 {
b.WriteString(fmt.Sprintf(" 节点数=%d", len(config.Hosts)))
}
if strings.TrimSpace(config.Topology) != "" {
b.WriteString(fmt.Sprintf(" 拓扑=%s", strings.TrimSpace(config.Topology)))
}
if strings.TrimSpace(config.URI) != "" {
b.WriteString(fmt.Sprintf(" URI=已配置(长度=%d)", len(config.URI)))
}
if strings.TrimSpace(config.MySQLReplicaUser) != "" {
b.WriteString(" MySQL从库凭据=已配置")
}
if strings.EqualFold(strings.TrimSpace(config.Type), "mongodb") {
if strings.TrimSpace(config.MongoReplicaUser) != "" {
b.WriteString(" Mongo从库凭据=已配置")
}
if strings.TrimSpace(config.ReplicaSet) != "" {
b.WriteString(fmt.Sprintf(" 副本集=%s", strings.TrimSpace(config.ReplicaSet)))
}
if strings.TrimSpace(config.ReadPreference) != "" {
b.WriteString(fmt.Sprintf(" 读偏好=%s", strings.TrimSpace(config.ReadPreference)))
}
if strings.TrimSpace(config.AuthSource) != "" {
b.WriteString(fmt.Sprintf(" 认证库=%s", strings.TrimSpace(config.AuthSource)))
}
}
if config.UseSSH {
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
}
if config.UseProxy {
b.WriteString(fmt.Sprintf(" 代理=%s://%s:%d", strings.ToLower(strings.TrimSpace(config.Proxy.Type)), config.Proxy.Host, config.Proxy.Port))
if strings.TrimSpace(config.Proxy.User) != "" {
b.WriteString(" 代理认证=已配置")
}
}
if config.UseHTTPTunnel {
b.WriteString(fmt.Sprintf(" HTTP隧道=%s:%d", strings.TrimSpace(config.HTTPTunnel.Host), config.HTTPTunnel.Port))
if strings.TrimSpace(config.HTTPTunnel.User) != "" {
b.WriteString(" HTTP隧道认证=已配置")
}
}
if config.Type == "custom" {
driver := strings.TrimSpace(config.Driver)
@@ -134,49 +405,202 @@ func formatConnSummary(config connection.ConnectionConfig) string {
return b.String()
}
func (a *App) getDatabaseForcePing(config connection.ConnectionConfig) (db.Database, error) {
return a.getDatabaseWithPing(config, true)
}
// Helper: Get or create a database connection
func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, error) {
key := getCacheKey(config)
return a.getDatabaseWithPing(config, false)
}
func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Database, error) {
effectiveConfig := applyGlobalProxyToConnection(config)
if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported {
if strings.TrimSpace(reason) == "" {
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type))
}
return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()}
}
dbInst, err := db.NewDatabase(effectiveConfig.Type)
if err != nil {
return nil, err
}
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
if proxyErr != nil {
_ = dbInst.Close()
return nil, wrapConnectError(effectiveConfig, proxyErr)
}
if err := dbInst.Connect(connectConfig); err != nil {
_ = dbInst.Close()
return nil, wrapConnectError(effectiveConfig, err)
}
return dbInst, nil
}
func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
effectiveConfig := applyGlobalProxyToConnection(config)
isFileDB := isFileDatabaseType(effectiveConfig.Type)
key := getCacheKey(effectiveConfig)
shortKey := key
if len(shortKey) > 12 {
shortKey = shortKey[:12]
}
if config.UseSSH && config.Type != "mysql" {
logger.Warnf("当前仅 MySQL 支持内置 SSH 直连,其他类型请使用本地端口转发:%s", formatConnSummary(config))
if isFileDB {
rawDSN := resolveFileDatabaseDSN(effectiveConfig)
normalizedDSN := resolveFileDatabaseDSN(normalizeCacheKeyConfig(effectiveConfig))
logger.Infof("文件库连接缓存探测:类型=%s 原始DSN=%s 归一化DSN=%s timeout=%ds forcePing=%t 缓存Key=%s",
strings.TrimSpace(effectiveConfig.Type), rawDSN, normalizedDSN, effectiveConfig.Timeout, forcePing, shortKey)
}
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(config), shortKey)
a.mu.Lock()
defer a.mu.Unlock()
if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported {
if strings.TrimSpace(reason) == "" {
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type))
}
// Best-effort cleanup: if cached instance exists for this exact config, close it.
a.mu.Lock()
if cur, exists := a.dbCache[key]; exists && cur.inst != nil {
_ = cur.inst.Close()
delete(a.dbCache, key)
}
a.mu.Unlock()
return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()}
}
if dbInst, ok := a.dbCache[key]; ok {
logger.Infof("命中连接缓存开始检测可用性缓存Key=%s", shortKey)
if err := dbInst.Ping(); err == nil {
logger.Infof("缓存连接可用缓存Key=%s", shortKey)
return dbInst, nil
a.mu.RLock()
entry, ok := a.dbCache[key]
a.mu.RUnlock()
if ok {
if isFileDB {
logger.Infof("命中文件库连接缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
}
needPing := forcePing
if !needPing {
lastPing := entry.lastPing
if lastPing.IsZero() || time.Since(lastPing) >= dbCachePingInterval {
needPing = true
}
}
if !needPing {
if isFileDB {
logger.Infof("复用文件库连接缓存(免 Ping类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
}
return entry.inst, nil
}
if err := entry.inst.Ping(); err == nil {
// Update lastPing (best effort)
a.mu.Lock()
if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst {
cur.lastPing = time.Now()
a.dbCache[key] = cur
}
a.mu.Unlock()
if isFileDB {
logger.Infof("复用文件库连接缓存Ping 成功):类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
}
return entry.inst, nil
} else {
logger.Error(err, "缓存连接不可用准备重建缓存Key=%s", shortKey)
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
}
if err := dbInst.Close(); err != nil {
logger.Error(err, "关闭失效缓存连接失败缓存Key=%s", shortKey)
// Ping failed: remove cached instance (best effort)
a.mu.Lock()
if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst {
if err := cur.inst.Close(); err != nil {
logger.Error(err, "关闭失效缓存连接失败缓存Key=%s", shortKey)
}
delete(a.dbCache, key)
}
delete(a.dbCache, key)
a.mu.Unlock()
if isFileDB {
logger.Infof("文件库缓存连接已剔除,准备新建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
}
}
if isFileDB {
logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
}
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", config.Type, shortKey)
dbInst, err := db.NewDatabase(config.Type)
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
dbInst, err := db.NewDatabase(effectiveConfig.Type)
if err != nil {
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", config.Type, shortKey)
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
return nil, err
}
if err := dbInst.Connect(config); err != nil {
wrapped := wrapConnectError(config, err)
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
if proxyErr != nil {
wrapped := wrapConnectError(effectiveConfig, proxyErr)
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
return nil, wrapped
}
a.dbCache[key] = dbInst
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(config), shortKey)
if err := dbInst.Connect(connectConfig); err != nil {
wrapped := wrapConnectError(effectiveConfig, err)
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
return nil, wrapped
}
now := time.Now()
a.mu.Lock()
if existing, exists := a.dbCache[key]; exists && existing.inst != nil {
a.mu.Unlock()
// Prefer existing cached connection to avoid cache racing duplicates.
_ = dbInst.Close()
if isFileDB {
logger.Infof("并发创建命中已存在文件库连接,关闭新建连接并复用缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
}
return existing.inst, nil
}
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
a.mu.Unlock()
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
return dbInst, nil
}
// generateQueryID generates a unique ID for a query using UUID v4
func generateQueryID() string {
return "query-" + uuid.New().String()
}
// CancelQuery cancels a running query by its ID
func (a *App) CancelQuery(queryID string) connection.QueryResult {
a.queryMu.Lock()
defer a.queryMu.Unlock()
if ctx, exists := a.runningQueries[queryID]; exists {
ctx.cancel()
delete(a.runningQueries, queryID)
logger.Infof("查询已取消queryID=%s", queryID)
return connection.QueryResult{Success: true, Message: "查询已取消"}
}
logger.Warnf("取消查询失败queryID=%s 不存在或已完成", queryID)
return connection.QueryResult{Success: false, Message: "查询不存在或已完成"}
}
// CleanupStaleQueries removes queries older than maxAge
func (a *App) CleanupStaleQueries(maxAge time.Duration) {
a.queryMu.Lock()
defer a.queryMu.Unlock()
now := time.Now()
for id, ctx := range a.runningQueries {
if now.Sub(ctx.started) > maxAge {
// Query likely finished or stuck, remove from tracking
delete(a.runningQueries, id)
// Query expired, silently remove
}
}
}
// GenerateQueryID generates a unique query ID for cancellation tracking
func (a *App) GenerateQueryID() string {
return generateQueryID()
}

View File

@@ -0,0 +1,63 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
)
func TestGetCacheKey_IgnoreTimeout(t *testing.T) {
base := connection.ConnectionConfig{
Type: "duckdb",
Host: `C:\data\songs.duckdb`,
Timeout: 30,
UseProxy: false,
UseSSH: false,
}
modified := base
modified.Timeout = 120
left := getCacheKey(base)
right := getCacheKey(modified)
if left != right {
t.Fatalf("expected same cache key when only timeout differs, got %s vs %s", left, right)
}
}
func TestGetCacheKey_DuckDBHostAndDatabaseEquivalent(t *testing.T) {
withHost := connection.ConnectionConfig{
Type: "duckdb",
Host: `D:\music\songs.duckdb`,
}
withDatabase := connection.ConnectionConfig{
Type: "duckdb",
Database: `D:\music\songs.duckdb`,
}
left := getCacheKey(withHost)
right := getCacheKey(withDatabase)
if left != right {
t.Fatalf("expected same cache key for duckdb host/database path, got %s vs %s", left, right)
}
}
func TestGetCacheKey_KeepDatabaseIsolation(t *testing.T) {
a := connection.ConnectionConfig{
Type: "mysql",
Host: "127.0.0.1",
Port: 3306,
User: "root",
Password: "root",
Database: "db_a",
Timeout: 30,
}
b := a
b.Database = "db_b"
b.Timeout = 5
left := getCacheKey(a)
right := getCacheKey(b)
if left == right {
t.Fatalf("expected different cache key for different database targets")
}
}

View File

@@ -0,0 +1,84 @@
package app
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestWrapConnectError_MongoNoSSL_RemovesMisleadingSSLLabel(t *testing.T) {
config := connection.ConnectionConfig{
Type: "mongodb",
UseSSL: false,
}
sourceErr := errors.New("MongoDB 连接失败SSL 主库凭据验证失败: mock error")
wrapped := wrapConnectError(config, sourceErr)
text := wrapped.Error()
if strings.Contains(text, "SSL 主库凭据") {
t.Fatalf("expected ssl label to be removed when TLS disabled, got: %s", text)
}
if !strings.Contains(text, "主库凭据验证失败") {
t.Fatalf("expected auth label to remain, got: %s", text)
}
}
func TestWrapConnectError_MongoURIForcesTLS_KeepsSSLLabel(t *testing.T) {
config := connection.ConnectionConfig{
Type: "mongodb",
UseSSL: false,
URI: "mongodb://user:pass@127.0.0.1:27017/admin?tls=true",
}
sourceErr := errors.New("MongoDB 连接失败SSL 主库凭据验证失败: mock error")
wrapped := wrapConnectError(config, sourceErr)
text := wrapped.Error()
if !strings.Contains(text, "SSL 主库凭据") {
t.Fatalf("expected ssl label to remain when URI enables TLS, got: %s", text)
}
}
func TestWrapConnectError_MongoSRVDefaultTLS_KeepsSSLLabel(t *testing.T) {
config := connection.ConnectionConfig{
Type: "mongodb",
UseSSL: false,
URI: "mongodb+srv://user:pass@cluster0.example.com/admin",
}
sourceErr := errors.New("MongoDB 连接失败SSL 主库凭据验证失败: mock error")
wrapped := wrapConnectError(config, sourceErr)
text := wrapped.Error()
if !strings.Contains(text, "SSL 主库凭据") {
t.Fatalf("expected ssl label to remain for mongodb+srv default TLS, got: %s", text)
}
}
func TestWithLogHintError_OmitEmptyLogPath(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "gonavi.log")
if err := os.WriteFile(logPath, nil, 0o644); err != nil {
t.Fatalf("write empty log failed: %v", err)
}
err := withLogHint{err: errors.New("连接失败"), logPath: logPath}
text := err.Error()
if strings.Contains(text, "详细日志:") {
t.Fatalf("expected no log hint for empty file, got: %s", text)
}
}
func TestWithLogHintError_IncludeNonEmptyLogPath(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "gonavi.log")
if err := os.WriteFile(logPath, []byte("log entry\n"), 0o644); err != nil {
t.Fatalf("write log failed: %v", err)
}
err := withLogHint{err: errors.New("连接失败"), logPath: logPath}
text := err.Error()
if !strings.Contains(text, "详细日志:"+logPath) {
t.Fatalf("expected log hint with path, got: %s", text)
}
}

Some files were not shown because too many files have changed in this diff Show More