Compare commits

...

544 Commits

Author SHA1 Message Date
Syngnat
c0ae40c638 🐛 fix(mysql): 修复旧版 Windows 无法解析 Asia/Shanghai 时区
- 嵌入 Go IANA 时区数据,兼容 Windows Server 2012 等缺少 zoneinfo 的环境
- 保持 MySQL serverTimezone=GMT+8 到 loc=Asia/Shanghai 的时间语义
- 增加 MySQL DSN 时区解析回归测试
Refs #449
2026-05-10 17:29:11 +08:00
Syngnat
947bdbbe0c Merge pull request #451 from TonyJiangWJ/feature/sql-snippets
# Conflicts:
#	frontend/package.json.md5
2026-05-10 12:46:45 +08:00
Syngnat
c99287dc10 Merge pull request #450 from jsfaint/fix/pg_schema 2026-05-10 12:44:19 +08:00
Syngnat
49c20bef89 🐛 fix(data-grid): 修复快捷 WHERE 自动补全回车行为
- 调整快捷 WHERE 输入框 Enter 处理,避免抢占 AutoComplete 选中事件
- 方向键高亮建议项后回车优先选择字段
- 增加快捷 WHERE 回车行为回归测试
2026-05-10 12:41:08 +08:00
Syngnat
d26d7d2ff0 🐛 fix(data-grid): 修复数据输出列序与时间精度问题
- 统一复制、导出、JSON/Text 视图按表格展示列序输出
- 表级导出改用显式列查询,避免 SELECT * 丢失界面列序
- 保留 datetime(3) 等时间字段的小数秒展示与复制输出
Refs #434
2026-05-10 12:32:41 +08:00
TonyJiangWJ
30f3ac86aa feat(query-editor): 支持 SQL 片段配置 2026-05-10 08:59:25 +08:00
Jia Sui
741fba4c27 🐛 fix(postgres): 修复 LIKE 'pg_%' 误匹配 pgsrpschema 等非系统 schema
LIKE 模式中 '_' 是单字符通配符,'pg_%' 不仅匹配 pg_catalog/pg_toast,
还会匹配 pgsrpschema 等以 'pgs' 开头的 schema,导致这些 schema
下的表被 GetTables 漏掉,侧边栏不显示 schema 分组。
改用 LIKE 'pg|_%' ESCAPE '|','_' 仅匹配字面量下划线。
2026-05-09 19:11:18 +08:00
Syngnat
baed7a2721 🐛 fix(sidebar): 修复树节点左侧图标对齐
- 调整树节点内容区布局,固定展开符和图标宽度
- 保持树节点标题、展开符和图标左侧对齐稳定
- 补充侧边栏树横向滚动 CSS 回归测试
2026-05-09 16:08:49 +08:00
Syngnat
4ad074a90c 🐛 fix(window): 修复 Windows 最大化还原后文字变大
- 将缩放修正改为去抖检查,避免 focus/resize/visibilitychange 连续触发
- 最大化/还原改为显式切换窗口状态,减少重复 toggle 带来的抖动
- 补充 Windows 缩放修正相关工具测试
2026-05-09 16:08:31 +08:00
Syngnat
6a0f3f3a73 feat(sidebar): 支持当前表定位到左侧菜单
- 新增左侧工具栏定位按钮,支持按当前激活标签定位表/视图
- 抽离 sidebarLocate 工具函数,统一定位请求解析、路径匹配和 schema 分组
- 侧边栏接收定位事件后自动展开、选中并滚动到目标节点
- 移除 DataGrid 内部定位入口,补充定位与工具栏回归测试
2026-05-09 16:08:03 +08:00
Syngnat
ecdbe09c6c 🐛 fix(sidebar): 优化侧边栏拖拽热区并减少误触
- 将右侧边缘分隔条改为独立拖拽带
- 给树内容右侧预留缓冲区,避免拖宽时误点连接、库或表
- 拖拽期间锁定光标并禁用选中,提升拖动稳定性
- 保持原有宽度边界和拖拽反馈不变
2026-05-09 11:31:15 +08:00
Syngnat
8d8366c190 🐛 fix(query-editor): 修复 Oracle 星号查询定位列别名非法
- Oracle `SELECT *` 改写时使用合法源表别名 `gonavi_query_source`
- 让自动注入的 `ROWID` 绑定到源表别名,避免 `ORA-00911`
- 保留显式字段查询的 `ROWID` 追加逻辑
- 新增回归测试覆盖 `SELECT * FROM EDC_LOG` 的执行 SQL
- 校验生成 SQL 不再包含非法自动别名
2026-05-09 11:11:40 +08:00
Syngnat
faef619413 🐛 fix(mac-window): 修复查询替换框在 macOS 无法关闭
- 放行编辑器和输入控件内的 Escape 按键事件

- 保留 macOS 原生全屏下普通区域的 Escape 抑制逻辑

- 补充 Mac 窗口快捷键回归测试

Refs #433
2026-05-08 23:00:23 +08:00
Syngnat
0c2b112234 🐛 fix(duckdb): 修复 Windows 扩展下载平台不匹配问题
- 改用官方 duckdb.dll 动态库构建 Windows DuckDB driver-agent

- 安装、总包和本地导入流程同步携带运行时依赖

- 更新 DuckDB driver-agent revision 并补充安装链路测试

Refs #430
2026-05-08 22:50:03 +08:00
Syngnat
ff0661d285 🐛 fix(sqlserver): 修复新建数据库语法兼容问题
Refs #438

- SQL Server 创建数据库改用方言标识符

- 补齐 mssql/sql_server 别名归一

- 增加回归测试
2026-05-08 21:41:01 +08:00
Syngnat
5052c7fa6f 🐛 fix(doris): 修复数据库重命名与字段变更预览
Refs #439
- Doris 重命名数据库改走原生 ALTER DATABASE RENAME
- Doris 字段/注释预览改为兼容语法,移除 AFTER/FIRST 和无效 NONE
- 补充相关回归测试
2026-05-08 21:24:47 +08:00
Syngnat
ab420e3d24 🐛 fix(driver-manager): 统一驱动管理页明暗主题底色
Refs #440
2026-05-08 20:28:41 +08:00
Syngnat
1616ba8ae4 🐛 fix(DataGrid): 修复聚合查询结果无法复制的问题
- 为查询结果页新增独立复制入口
- 支持 CSV、JSON、Markdown 复制当前结果集
- 补充聚合列复制与按钮可点击回归测试
2026-05-06 21:47:16 +08:00
Syngnat
da9a76715a 🐛 fix(driver): 修复驱动代理校验与 DuckDB 表预览超时
- 校验可选 driver-agent revision,避免重装后复用旧代理
- DuckDB 表预览默认不再追加兜底 ORDER BY
- 优化 DuckDB 超时中断提示并补充回归测试
2026-05-06 19:32:55 +08:00
Syngnat
3c68325132 🐛 fix(oceanbase): 修复 Oracle 协议保存与连接链路
- 测试连接统一走 RPC 配置构造,确保 OceanBase Oracle 协议生效

- 保存连接时同步写入 oceanBaseProtocol 与 protocol 参数

- 编辑回显支持从显式字段、连接参数和 URI 恢复协议

- 双击连接时清理旧树缓存,避免复用 MySQL 协议子节点

- 补充 OceanBase 协议解析与缓存 key 隔离测试
2026-04-30 17:27:17 +08:00
Syngnat
5f9adcac37 🐛 fix(ai): 兼容 DeepSeek reasoning 内容响应
- 增加 reasoning_content 字段解析与前后端类型定义

- 兼容 DeepSeek 流式和非流式响应中的推理内容

- 统一 AI 消息 payload 映射,避免历史消息丢失推理内容

- 补充 OpenAI 兼容 Provider 与前端消息映射测试
2026-04-30 17:26:36 +08:00
Syngnat
d2dad75167 ♻️ refactor(oceanbase): 完善双协议连接链路
- 抽象 OceanBase 协议解析与运行态参数注入
- 复用 OracleDB 实现 OceanBase Oracle 租户连接能力
- 调整 DDL、schema、SQL 方言和数据源能力判断
- 补充协议优先级、缓存隔离和 RPC 参数测试
- 支持按指定 driver 自动生成 agent revision
2026-04-30 15:05:05 +08:00
Syngnat
98c62fd6bd 🎨 style(driver): 重做驱动管理页面布局与交互
- 页面结构:将驱动表格改为卡片列表,移除横向滚动依赖
- 信息展示:新增顶部状态统计,清晰区分全部、已启用、需重装、未启用
- 重装提示:将长原因文案收敛为摘要展示,并支持展开查看完整原因
- 操作优化:集中展示版本、进度、安装、重装、移除、本地导入和日志入口
- 响应式适配:窄屏下驱动卡片自动堆叠,避免内容挤压
2026-04-30 13:35:07 +08:00
Syngnat
7fd6d78c83 feat(driver): 新增 OceanBase 与 OpenGauss Agent 数据源
- 数据源支持:新增 OceanBase 与 OpenGauss optional driver-agent 实现
- 连接适配:复用 MySQL/PostgreSQL 兼容链路并补齐查询、DDL、同步能力
- 前端入口:补充连接表单、侧边栏、图标、SQL 方言和危险操作识别
- 驱动管理:更新 driver manifest、安装提示和 revision 自动生成链路
- 构建发布:支持多平台 driver-agent 打包并优化 release 构建失败提示
2026-04-30 13:13:01 +08:00
Syngnat
c92959f3e8 feat(connection): 支持多数据源额外连接参数配置
- 前端连接表单新增额外连接参数入口,支持 URI query 格式录入与解析回填
- MySQL 兼容驱动支持 JDBC 常见参数映射,修复 UTF-8 字符集与 serverTimezone 兼容问题
- 扩展 Oracle、PostgreSQL 兼容、SQL Server、ClickHouse、MongoDB、达梦、TDengine 参数合并
- 按不同驱动通道处理 DSN、URI、Options 与 Settings,避免统一透传导致连接异常
- 修复编辑已保存连接时解析无认证 URI 会清空已有账号密码的问题
- 补充连接参数透传、缓存隔离、DSN 合并与 URI 回填回归测试
2026-04-30 10:57:52 +08:00
Syngnat
c65e429072 🐛 fix(oracle): 兼容旧版本自动限行语法
- Oracle/Dameng 自动限行改为 ROWNUM 外层包裹
- 避免旧版本 Oracle 不支持 FETCH FIRST 导致 ORA-00933
- 保留尾部分号与注释,避免执行语句结构丢失
- 跳过 FOR UPDATE 语句自动包裹,避免改变锁语义
- 补充 Oracle/Dameng 自动限行回归测试
Refs #429
2026-04-30 08:33:24 +08:00
Syngnat
c1ebce4ef5 feat(query-editor): 放宽单表查询结果列级编辑边界
- 查询编辑:支持简单表列与表达式列混合展示
- 编辑安全:仅允许真实表列编辑,表达式列保持只读
- 提交流程:支持结果列别名映射回真实表字段
- 测试覆盖:补充聚合查询静默只读与列级提交用例
2026-04-29 20:07:22 +08:00
Syngnat
c927e33c8c feat(driver): 提醒重装旧版驱动代理
- optional-driver-agent 新增 metadata 方法返回 driverType、agentRevision 与协议版本
- 安装和本地导入驱动后记录 agentRevision,并在驱动状态中比对是否需要更新
- 驱动管理、连接表单和已有连接加载入口提示重装旧版 agent
- 补充旧 revision 检测和 custom 连接使用统计回归测试
2026-04-29 17:26:16 +08:00
Syngnat
824aafbdea 🔧 chore(driver): 自动生成驱动代理 revision
- 新增脚本按 optional driver-agent 源码依赖生成 revision 指纹
- 构建脚本与 dev/release workflow 在打包前自动刷新 revision
- 生成驱动 revision 映射并补充 optional driver 覆盖校验
2026-04-29 17:26:09 +08:00
Syngnat
0c1586d7a4 🐛 fix(clickhouse): 修复协议选择与连接错误提示
- 支持 ClickHouse 手动 HTTP/Native 协议优先级,避免 URI scheme 覆盖用户选择
- Auto 模式识别 Native/HTTP 协议误配错误并自动尝试备用协议
- 净化连接失败中的二进制乱码,补充测试连接参数校验和排查日志
- 前端表单增加 ClickHouse 协议选择并同步类型、缓存 key 与持久化兼容
Refs #425
2026-04-29 17:25:54 +08:00
Syngnat
b1ef52f62e feat(data-grid): 支持无主键表安全编辑
- 定位策略:新增主键、唯一索引和 Oracle ROWID 三类安全行定位能力
- 查询编辑器:简单单表 SELECT 自动补充隐藏定位列,复杂结果保持只读
- 表预览:无主键表可通过唯一索引或 Oracle ROWID 安全编辑
- 提交流程:移除无主键整行 WHERE fallback,隐藏定位列不参与展示和写入
- 后端保护:Oracle、MySQL、PostgreSQL 更新删除必须恰好影响 1 行
- 测试覆盖:补充 QueryEditor、DataViewer、DataGrid 和 ApplyChanges 相关用例
Refs #419
2026-04-29 12:33:35 +08:00
Syngnat
05a913ccb2 🐛 fix(query-editor): 修复多数据源大查询限流失效
- SQL限流:抽取查询自动限流工具,修复 SELECT 判断大小写不一致导致限制未生效
- 方言适配:按 Oracle/Dameng、SQL Server、MySQL/PostgreSQL 等方言分别注入行数限制
- 自定义驱动:支持 custom 连接根据 driver 解析 Oracle、PostgreSQL、SQL Server 等方言
- MongoDB修复:修正 db.collection.find() 解析边界,并对 find/只读 aggregate 下推 limit
- Oracle优化:DSN 增加 PREFETCH_ROWS 和 LOB FETCH 参数,减少大结果集拉取开销
- 测试覆盖:补充 SQL 方言矩阵、MongoDB 限流和 Oracle DSN 参数测试
Refs #424
2026-04-29 10:29:34 +08:00
Syngnat
f51dbcfb2c 🐛 fix(oracle): 修复查询结果编辑提交后数据还原
Oracle GetColumns 未返回主键列标记,前端 pkColumns 为空后退化为
全列 WHERE 条件,Oracle 空字符串即 NULL 语义导致 UPDATE 匹配 0 行。

LEFT JOIN all_constraints + all_cons_columns 检测主键列并赋值 Key="PRI",
与达梦驱动实现方式一致。
2026-04-29 09:41:25 +08:00
Syngnat
5f7578c5ea feat(ai): 支持录制聊天发送快捷键
- 工具中心新增 AI 聊天发送快捷键,默认 Enter 并支持 Ctrl/Cmd/Alt+Enter
- AI 输入框按录制绑定发送,保留 Shift+Enter 换行和输入法 composing 保护
- 修复 shortcutOptions 启动刷新覆盖录制值的问题,并校验脏持久化快捷键
- 补充快捷键、输入框提示和持久化回归测试
- 撤回 macOS Caps Lock 浮层无效前端规避,恢复输入控件 no-auto-cap 属性
- 新增需求进度追踪文档记录验证结果
2026-04-28 18:12:42 +08:00
Syngnat
56eaca9081 🐛 fix(data-grid): 修复 schema 数据源 DDL 查看异常
- 表页入口:查看 DDL 不再依赖 dbName,支持金仓/PG 等 schema 数据源
- 标识符解析:新增 quote-safe qualified name 拆分,避免引号内点号被误拆
- DDL 兼容:PG、HighGo、VastBase 使用安全拆分处理 schema.table
- 自定义驱动:补齐 custom HighGo DDL 查询时的数据库上下文
- 测试覆盖:新增 schema 表、视图 fallback、dotted 标识符等回归用例
2026-04-28 14:57:52 +08:00
Syngnat
51675f9d05 🐛 fix(ai): 修复多方言执行与 DDL 降级
- SQL 执行:移除 AI 工具和代码块预览中硬编码的 LIMIT 50
- 方言适配:按连接类型和自定义驱动别名生成只读 SQL 预览限流语句
- Oracle 兼容:Oracle、自定义 Oracle 和达梦改用 ROWNUM 语法限制行数
- 权限降级:获取表 DDL 失败时自动降级为字段元数据摘要
- 上下文优化:手动添加表结构上下文时复用同一套 DDL 降级逻辑
- 测试覆盖:新增 AI SQL 限流和表结构降级单元测试
Refs #418
2026-04-28 14:03:48 +08:00
Syngnat
f5f87189df 🐛 fix(oracle): 修复查询结果编辑提交日期格式报错
- 参数处理:提交事务前加载 Oracle 表字段类型,用于识别 DATE 和 TIMESTAMP 字段
- 更新修复:UPDATE 的 SET 值和 WHERE 条件统一转换日期时间参数
- 场景覆盖:修复新建查询结果编辑后提交事务触发 ORA-01861 的问题
- 类型绑定:将 Oracle 日期时间字符串解析为 time.Time,避免依赖数据库会话日期格式
- 兼容处理:支持 RFC3339、带时区和常见本地日期时间格式
- 测试覆盖:新增 Oracle ApplyChanges recording driver 回归测试
Refs #419
2026-04-28 13:39:32 +08:00
Syngnat
ef634075ab 🐛 fix(external-sql): 修复外部 SQL 文件保存不写回源文件
- 保存逻辑:外部 SQL 文件标签页携带 filePath,保存时写回原始磁盘文件
- 后端接口:新增 WriteSQLFile 能力,支持覆盖已有 SQL 文件并保留原文件权限
- 状态隔离:外部文件保存失败时不创建 savedQuery,避免写入 localStorage 副本
- 兼容行为:非文件标签页继续沿用原有 savedQuery 快速保存逻辑
- 文案优化:将数据库下入口改为“外部 SQL 目录”,减少与单文件打开入口的歧义
- 测试覆盖:补充前端保存分支、后端写文件边界和外部 SQL 目录文案测试
Refs #422
2026-04-28 13:26:55 +08:00
Syngnat
a07eea7815 feat(data-grid): 新增表数据页 DDL 查看与当前页查找
- 表数据页新增查看 DDL 入口,支持直接打开只读 SQL 预览弹窗
- 当前页查找支持大小写不敏感高亮,仅作用于已加载数据和显示列
- 查找结果新增上一个、下一个导航,并自动聚焦选中匹配单元格
- DDL 请求增加上下文过期保护,避免切表后展示旧表结构
- 补充 DataGrid 布局、DDL 交互和查找工具函数单元测试
Refs #417
2026-04-28 12:39:51 +08:00
Syngnat
5886b1ded8 🔧 chore(frontend): 同步 package 校验文件 2026-04-28 10:36:07 +08:00
Syngnat
299a80dd5a 🐛 fix(frontend): 修复 macOS Caps Lock 输入浮层 2026-04-28 10:21:19 +08:00
Syngnat
225e9e61ed 🐛 fix(kingbase): 修复表操作标识符引用 2026-04-28 10:21:19 +08:00
Syngnat
fa4f2a938a 🐛 fix(jvm): 绑定前端变更执行到预览上下文
将 JVM 资源变更执行绑定到最近一次成功预览和连接配置指纹,并遮蔽敏感快照、payload 示例和 AI 上下文中的敏感值。
2026-04-28 09:42:48 +08:00
Syngnat
ec2eefc9d2 🐛 fix(jvm): 加固诊断命令策略与输出脱敏
在服务端阻断只读连接中的高风险和多行诊断命令,并对诊断事件与错误消息统一脱敏,避免凭证、Authorization 和 PEM 片段泄漏。
2026-04-28 09:42:41 +08:00
Syngnat
58ee269855 🐛 fix(jvm): 收紧 JMX domain allowlist 校验
在 helper runtime 中对直接 ObjectName、资源浏览、变更预览和监控路径统一执行 domain allowlist,阻断默认域别名和空白后缀绕过。
2026-04-28 09:42:29 +08:00
Syngnat
ffc4f2c2d9 🐛 fix(jvm): 强化变更确认令牌校验
将 JVM 变更确认从可重算校验值升级为服务端发放的一次性令牌,避免未预览、重放或上下文变更后继续执行高风险变更。
2026-04-28 09:42:21 +08:00
Syngnat
1b31c54917 🐛 fix(redis): 修复精确搜索无法命中命名空间
- 精确搜索识别无通配符的 Redis literal pattern
- 同时查询完整 Key 与同名命名空间前缀
- 修复输入 Agent 无法显示 Agent 文件夹的问题
- 避免误匹配 AgentCapacity、AgentState 等相似前缀
- 补充 glob literal 与命名空间搜索回归测试
- 更新 Redis 精确搜索输入提示文案
2026-04-27 11:31:20 +08:00
Syngnat
3665639300 🐛 fix(data-sync): 修复已保存连接同步时未恢复密文
- Data Sync 分析/预览/同步入口统一恢复源库和目标库连接密文
- 避免已保存 PostgreSQL 连接因空密码触发 28P01
- 保留前端选择的源/目标数据库覆盖值
- 增加保存连接密文恢复回归测试
Refs #413
2026-04-26 20:55:20 +08:00
Syngnat
3b9116e259 perf(table-overview): 优化大量表搜索渲染性能
- 预计算表概览搜索索引与排序键
- 使用 deferred value 降低搜索输入阻塞
- 限制大结果集首批渲染数量并支持继续加载
- 增加表概览过滤与渲染上限回归测试
2026-04-26 20:42:14 +08:00
Syngnat
a06f45da28 feat(redis): 新增 Key 精确搜索模式
- 增加 Redis Key 模糊/精确搜索切换
- 精确模式不再追加通配符并保留大小写敏感匹配
- 转义 Redis glob 特殊字符避免误匹配
- 补充搜索模式回归测试
2026-04-26 20:34:07 +08:00
Syngnat
21222cf9f4 🐛 fix(redis): 修复自动模式 JSON 大整数精度丢失
- 保留 Redis JSON 值中的大整数原始字面量
- 避免自动格式化时通过 JSON.stringify 改写超出安全范围的数字
- 补充自动模式大整数与字符串转义展示回归测试
Refs #400
2026-04-26 20:15:13 +08:00
Syngnat
30301cd637 feat(data-grid): 新增快速 WHERE 筛选输入与补全能力
- 新增表格筛选面板快速 WHERE 条件输入
- 支持字段、操作符和关键字自动补全
- 查询、分页统计与筛选导出合并快速 WHERE 条件
- 修复补全过程中的字段引号丢失和重复追加问题
Refs #354
2026-04-26 20:06:15 +08:00
Syngnat
55829bce86 🐛 fix(connection): 修复连接颜色重启丢失并同步标签页展示
- 恢复连接清洗流程中的图标类型与颜色字段
- 标签页增加连接色标识,便于区分多连接会话
- 抽取连接视觉解析并补充回归测试
Refs #334
2026-04-26 19:33:12 +08:00
Syngnat
2b340f3136 feat(data-grid): 增加复制行和粘贴行操作
- 表数据工具栏新增复制行、粘贴行按钮
- 支持将选中行复制为新增行草稿,提交前可继续检查编辑
- 抽离行复制粘贴数据构造逻辑并补充回归测试
Refs #332
2026-04-26 19:09:25 +08:00
Syngnat
9eb06f6f96 feat(data-grid): 增加复制行和粘贴行操作
- 表数据工具栏新增复制行、粘贴行按钮
- 支持将选中行复制为新增行草稿,提交前可继续检查编辑
- 抽离行复制粘贴数据构造逻辑并补充回归测试
Refs #332
2026-04-26 19:09:10 +08:00
Syngnat
01dd62f4e2 🐛 fix(table-designer): 去除 SQL 变更重复标记
- 移除 Monaco glyph margin 变更标记通道

- 仅保留 line decorations 左侧单一变更标记

- 补充防重复标记回归测试

Refs #324
2026-04-26 18:04:01 +08:00
Syngnat
09ecc841ab 🐛 fix(table-designer): 突出显示 SQL 变更行
- 识别新增、删除、重命名、属性修改等变更 SQL 行

- 使用 Monaco decorations 仅标记变更行,保留基础 SQL 语法高亮

- 补充变更行识别与装饰渲染回归测试

Refs #324
2026-04-26 17:53:22 +08:00
Syngnat
3a0c5201a0 feat(table-designer): 高亮显示 SQL 变更预览
- SQL 变更弹窗接入只读 SQL 高亮预览组件

- 注册明暗主题下的 SQL token 颜色

- 补充 SQL 预览高亮回归测试
2026-04-26 17:38:42 +08:00
Syngnat
5f6acc25da 🔧 chore(gitignore): 忽略 Playwright MCP 临时目录
- 将 .playwright-mcp/ 加入仓库忽略规则

- 避免本地浏览器工具临时文件进入提交状态
2026-04-26 17:24:45 +08:00
Syngnat
5bbeb7f373 feat(jvm/connection): 优化诊断工作台与连接配置体验
- JVM 诊断工作台改为会话优先布局,未建会话前隐藏命令输入

- 优化命令模板、实时输出、审计历史和能力检查卡片展示

- 连接配置表单引入按数据源分组的卡片化布局

- 补充连接配置布局和 JVM 诊断工作台回归测试
2026-04-26 17:18:10 +08:00
Syngnat
df4fcab90b 🐛 fix(sql): 适配多数据源 SQL 方言生成
- 表设计 DDL 按 Oracle/Dameng、SQL Server、PG-family、SQLite/DuckDB、ClickHouse/TDengine 分支生成

- 新增统一 SQL 方言工具,驱动字段类型候选和 SQL 自动补全

- 修复 Oracle/Dameng DATE/TIMESTAMP 删除条件字面量

- 补充多方言 DDL、补全和 Oracle 删除回归测试

Refs #402

Refs #409
2026-04-26 17:14:07 +08:00
Syngnat
f16e2f15c2 🐛 fix(jvm): 加固诊断与变更安全边界
- 诊断 SSE 支持空心跳事件,避免无输出时解码失败

- Arthas Tunnel 增加会话过期清理、配置漂移校验和取消兜底

- Provider 合约清理 Base URL 查询参数和片段,避免探测泄露敏感信息

- JVM 变更请求强制校验原因并规范化写入审计字段
2026-04-26 14:34:43 +08:00
Syngnat
38e71119a4 feat(jvm-diagnostic): 优化诊断控制台命令体验
- 诊断命令输入使用编辑器外观并支持 Arthas 命令补全

- 新增命令执行 pending 输出、前端终态兜底和历史刷新

- 会话、输出、历史记录统一展示中文语义状态

- 补充诊断控制台和补全展示测试
2026-04-26 14:34:23 +08:00
Syngnat
ff2b86819d feat(jvm-ui): 完善 JVM 工作台与监控入口
- 新增 JVM 持续监控仪表盘、图表、状态卡和详情面板

- 统一概览、资源浏览、审计页面的 JVM 工作台布局

- Sidebar 和 TabManager 支持监控入口、诊断入口兜底和上下文切换

- 补充前端状态模型、展示文案和组件回归测试
2026-04-26 14:34:02 +08:00
Syngnat
9d08b185d0 feat(jvm): 新增持续监控与采样链路
- 后端新增监控会话管理,支持启动、停止和历史查询

- JMX、Endpoint、Agent Provider 补齐监控快照采集能力

- JMX helper 增加内存、GC、线程、类加载采样并更新内嵌运行时

- 生成 Wails 监控接口绑定并补充后端回归测试
2026-04-26 14:33:41 +08:00
Syngnat
a43c84f968 🔧 chore(dev): 合并 JVM 缓存可视化编辑分支
- 合并 JVM 连接、资源治理、诊断控制台与 Arthas Tunnel 能力
- 合并测试版号统一与 macOS 无交互 ZIP 打包调整
- 基于最新 origin/dev 完成合并并通过前后端最小验证
2026-04-24 16:52:03 +08:00
Syngnat
14c6510835 🔧 fix(release/version): 对齐测试版号并移除Mac交互打包
- build-release 优先读取 GONAVI_VERSION 与 version/dev-version.txt
- 新增共享测试版号文件,统一开发态与发布脚本版本来源
- internal/app 版本解析增加 dev-version 回退与回归测试
- macOS 发布改为 ZIP 归档,不再触发 create-dmg 与 Finder 排版
- 补充发布脚本调整的需求追踪文档
2026-04-24 16:48:09 +08:00
Syngnat
6f14e827ab feat(jvm): 完成资源治理与诊断增强
- 新增 JMX/Endpoint/Agent 三种 JVM 连接模式与配置归一化链路
- 支持资源浏览、变更预览、写入应用、审计记录与只读约束
- 接入 AI 结构化写入计划与诊断计划回填能力
- 新增 Agent Bridge、Arthas Tunnel、JMX Helper 诊断传输实现
- 增加诊断控制台、命令模板、输出历史与自动补全交互
- 补齐前后端契约、运行夹具与 JVM 相关回归测试
2026-04-24 16:45:34 +08:00
Syngnat
d9b4c6a21b 🐛 fix(jvm): 固定 AI 重试链路的 JVM 上下文
- 为 JVM AI 回复的重新生成流程继承原始页签上下文并透传到新消息
- 让重试、催促重发和工具回合续跑都按原 JVM 上下文构建 system prompt
- 避免切换页签后重试 JVM 计划时出现上下文错位或定向能力丢失
- 重新通过前端全量测试、前端构建与 wails 生产构建验证
2026-04-23 13:40:29 +08:00
Syngnat
d2c3e3e779 🐛 fix(jvm): 修正 AI 计划映射与页签定向应用
- 为 JVM AI 计划补充显式草稿映射,避免 payload 包装层直接透传到现有变更契约
- 将 updateValue 映射为当前 JVM 写入链路的 put,并限制为 JSON 对象 payload
- 为 AI 聊天消息绑定 JVM 来源上下文,按 tab/connection/provider/resource 定向应用计划
- 补充 JVM AI 计划解析、契约映射和目标页签解析单测
- 更新需求追踪并回填 go test、前端测试、构建与 wails build 验证结果
2026-04-23 13:02:04 +08:00
Syngnat
3cb2d494cc feat(jvm): 接入 AI 结构化变更计划
- 新增 JVM AI 计划解析器与 fenced json 契约测试
- 为 JVM 资源页注入 AI 计划生成 prompt 并支持回填草稿
- 在 AI 对话上下文中补充 JVM 资源约束与应用入口
2026-04-23 12:42:02 +08:00
Syngnat
9a61622568 feat(jvm): 增加 JVM 写入预览与审计
- 打通 JVM 变更预览、执行确认与审计记录链路
- 增加 Guard 校验、模式约束与审计写入失败回传
- 补齐审计页签、预览弹窗和 Task 5 回归覆盖
2026-04-23 12:14:36 +08:00
Syngnat
21f2b29d1d feat(jvm): 打通 JVM 只读资源浏览链路
- 后端新增 JVMListResources 与 JVMGetValue 接口并补齐回归测试
- Sidebar 基于能力探测展示 JVM 模式节点并懒加载资源节点
- TabManager 接入 JVMOverview、JVMResourceBrowser 与模式徽标展示
- 补齐 JVM Tab 元数据与连接持久化 sanitize 逻辑
- 更新需求追踪文档并记录 Task 4 验证结果与残余风险
2026-04-23 11:21:36 +08:00
Syngnat
7ddb49a81d 🐛 fix(jvm): 修正连接表单模式回填与超时同步
- 保留编辑态 JVM 连接的原始 preferredMode,避免旧配置被静默降级
- 将 JVM 可见超时统一同步到 Endpoint 探测配置
- 抽取 JVM 可编辑模式判定与回填逻辑,统一 ConnectionModal 行为
- 补充 JVM 模式与超时纯函数测试,覆盖 unsupported preferredMode 分支
- 更新需求追踪文档,记录 Task 3 实现、复审与验证结果
2026-04-23 10:20:47 +08:00
Syngnat
9bb7ece2dd 🐛 fix(frontend):收敛JVM模式选项与标题文案 2026-04-23 09:42:37 +08:00
Syngnat
177dafacc9 feat(frontend):接入JVM连接表单与展示元数据 2026-04-23 09:23:28 +08:00
Syngnat
03a1506686 feat(jvm): 增加连接测试与能力探测 API
- 新增 JVM provider 工厂与 JMX、Endpoint 骨架实现
- 暴露 TestJVMConnection 和 JVMProbeCapabilities 并统一 QueryResult 返回
- 刷新 Wails 绑定与 JVM 连接模型,补齐前后端方法签名
- 补充 App 编排测试与 provider 契约测试,避免假成功和静默成功
- 更新需求追踪,记录 Task 2 审查结论与验证证据
2026-04-22 17:52:28 +08:00
Syngnat
15b1ad24d1 feat(jvm): 落地 JVM 连接契约与配置归一化
- 新增 JVM 连接配置与共享 DTO,补齐 JMX 和 Endpoint 契约
- 实现后端归一化规则,支持默认只读、模式回退和 JMX 端口兜底
- 新增前端 JVM 默认值与配置构建工具,统一模式环境和端口收敛
- 补充 Go 与 Vitest 用例并更新需求追踪,记录 Task 1 验证证据
2026-04-22 17:20:00 +08:00
Syngnat
f584270209 📝 docs(jvm): 沉淀 JVM Connector MVP 实施计划
- 按 Task 拆分连接契约、Provider、前端工作台与 AI 集成实现路径
- 明确前后端文件边界、TDD 顺序、Wails 绑定刷新与回归命令
- 补齐共享 DTO、provider factory 和审计落盘等关键实现细节
- 同步需求追踪进入实施计划阶段
2026-04-22 16:50:40 +08:00
Syngnat
fe9d02734f 📝 docs(jvm): 沉淀 JVM 缓存可视化编辑设计
- 新增 JVM Connector 的统一入口、多 Provider 与能力协商方案
- 明确 JMX 与 Management Endpoint 为 MVP,Agent 仅保留扩展位
- 定义资源模型、AI 协同、Guard Layer、审计与分期边界
- 同步需求追踪中的范围、风险、决策与验证记录
2026-04-22 16:50:40 +08:00
Syngnat
65a9f4352e feat(sql-files): 支持外部 SQL 目录树与双击打开
- 新增 SQL 目录选择、枚举与按路径读取接口,复用大文件执行能力
- Sidebar 增加外部 SQL 文件目录树、目录管理入口与双击打开查询标签
- 补充 external SQL 持久化与前后端回归测试

Fixes #319
2026-04-17 21:02:48 +08:00
Syngnat
f3b78f9763 🐛 fix(driver): 明确JDBC Jar导入限制并补充Kingbase指引
- 后端在驱动包选择与本地导入前拦截 JDBC Jar,并返回替代说明
- 驱动管理统一改为“导入驱动包”,补充不支持 JDBC Jar 的提示
- 自定义连接补充 kingbase8 等驱动别名与 Go 驱动说明
- 新增后端与前端回归测试

Refs #317
2026-04-17 20:41:58 +08:00
Syngnat
0bccdeed8c feat(ui): 优化侧边栏设置中心与数据表交互
- 收敛左上角入口为工具和设置中心,并调整新建连接操作优先级
- 优化表设计器 SQL 预览高亮和刷新前未保存字段变更确认
- 下移数据页次级操作并将编辑行收口到单元格右键菜单
- 补充侧边栏布局、表设计器草稿检测和数据页布局回归测试

Refs #324
2026-04-17 20:09:46 +08:00
Syngnat
39f6fbbe1f 🐛 fix(export): 修正带注释的 JOIN 查询结果导出校验
- 导出前缀判断增加前置 SQL 注释清理,避免合法 SELECT 被误判
- ExportQuery 统一复用 looksLikeSelectOrWith 逻辑,消除重复校验分支
- 补充带前置注释的 INNER JOIN 导出回归测试

Fixes #391
2026-04-17 19:01:39 +08:00
Syngnat
8a1a9a8fb8 🐛 fix(mongodb): 支持 Mongo shell 快捷查询命令
- 为 show dbs 和 show databases 转换 listDatabases JSON 命令
- 为 show collections 和 show tables 转换 listCollections JSON 命令
- 补充 Mongo shell 快捷命令回归测试并验证前端构建

Fixes #390
2026-04-17 18:56:01 +08:00
Syngnat
dca5f629b2 🐛 fix(dameng): 修正表格更新无法识别主键列
- 达梦列元数据查询补充主键关联并返回 column_key
- GetColumns 正确映射主键标记,避免表格更新退化为整行 WHERE
- 补充达梦列元数据回归测试,并验证带驱动 tag 的实现编译通过

Fixes #389
2026-04-17 18:42:47 +08:00
Syngnat
8eae39c2c2 🐛 fix(redis-viewer): 修正 Redis 值自动与 UTF-8 展示不一致
- 新增 redisValueDisplay 工具,统一自动、UTF-8 与十六进制模式的展示判断
- 修正已解码 Unicode 文本被重复按字节解码导致的乱码问题
- 补充 Redis 值展示回归测试,并让各数据类型复用同一套展示逻辑

Fixes #386
2026-04-17 18:31:04 +08:00
Syngnat
9613b2a8eb 🐛 fix(window): 修正启动窗口恢复到不可见区域
- 启动恢复普通窗口时先校验持久化 bounds 是否仍与可视区域相交
- 完全掉出可视区域时自动回正并回写新的窗口位置到 store
- 补充窗口恢复 helper 回归测试并验证前端构建通过

Fixes #384
2026-04-17 18:19:42 +08:00
Syngnat
4fd679ce42 🐛 fix(sqlserver): 修正 uniqueidentifier 展示为十六进制字节
- 查询值规整新增 uniqueidentifier 识别并复用 go-mssqldb GUID 格式化
- 避免 SQL Server 查询结果把 GUID 展示为原始 0x 字节串
- 补充 uniqueidentifier 原始字节回归测试并覆盖驱动返回值路径

Fixes #381
2026-04-17 18:10:51 +08:00
Syngnat
e56a72eb9f 🐛 fix(redis): 修正 hash 详情读取依赖 HGETALL
- 为 hash 读取增加 HGETALL 权限受限时的 HSCAN 降级路径
- RedisGetValue 与 GetHash 统一复用 fallback 并保留长度元数据
- 补充普通用户权限受限与非权限错误回归测试

Fixes #380
2026-04-17 18:07:50 +08:00
Syngnat
0fda09a19f 🔧 chore(dev): 合并 open issue backlog 修复分支
- 合并已按 issue 拆分提交的 backlog 修复与 SQL 结果集同步能力
- 解决 DataGrid、Sidebar 以及 legacy WebKit 存储迁移测试的合并冲突
- 保留 dev 分支当前结构并移除已废弃的 issue backlog 跟踪文档
2026-04-17 17:52:14 +08:00
Syngnat
33b78fb583 🐛 fix(sync): 同步 SQL 结果集同步前端模型绑定
- 为数据同步请求模型补齐 sourceQuery 字段生成声明
- 使前端生成绑定与 SQL 结果集同步后端参数保持一致
- 补齐 issue #321 功能收尾所需的 Wails 模型产物

Fixes #321
2026-04-17 17:45:10 +08:00
Syngnat
40416fb4df 🐛 fix(redis): 同步 hash 字段删除接口前端绑定
- 同步 Wails 前端声明中的 RedisDeleteHashField 参数类型
- 使生成绑定与后端兼容字符串和数组入参的实现保持一致
- 补齐 issue #343 修复后的前端接口声明

Fixes #343
2026-04-17 17:45:05 +08:00
Syngnat
651eec1617 feat(sync): 新增 SQL 结果集数据同步能力
- 同步引擎新增查询结果集同步分支,支持单目标表差异分析、预览与执行
- 数据同步工作台增加 SQL 结果集模式,并补充目标表与查询校验
- 补充后端同步链路与前端请求构造回归测试,并更新 backlog 记录

Fixes #321
2026-04-17 16:31:55 +08:00
Syngnat
9dc58acb39 🐛 fix(table-designer): 修正 MySQL 列改名预览 SQL
- 将 MySQL 列改名从 MODIFY COLUMN 切换为 CHANGE COLUMN 语法
- 保留类型变更与列位置子句的既有生成逻辑
- 补充回归测试并更新 issue backlog 记录

Fixes #373
2026-04-17 15:06:09 +08:00
Syngnat
f3193f0933 🐛 fix(ai): 修正 SQL 代码块 Markdown 换行渲染
- 为 AI markdown 渲染补充 fenced code block 预处理
- 修正 opening/closing fence 缺少换行时的代码块解析失败
- 补充回归测试并更新 issue backlog 记录

Fixes #369
2026-04-17 14:37:36 +08:00
Syngnat
7cb46f9f69 🐛 fix(window): 修正最大化窗口恢复焦点后重复动画
- 收敛 Windows 最大化窗口的激活修复逻辑,避免返回前台时重复 toggle
- 标题栏按钮按窗口状态切换 maximize/restore 图标并立即同步 store
- 补充窗口状态规则测试并更新 issue backlog 记录

Fixes #368
2026-04-17 14:18:38 +08:00
Syngnat
04c4613e4d 🐛 fix(datagrid): 修正日期字段设置值被误存为 NULL
- 抽取时间字段保存 helper 并统一 picker 类型与格式化逻辑
- 单元格保存优先使用 picker 实时值,避免 Form 同步滞后把日期误判为空
- 补充前端回归测试并更新 issue backlog 记录

Fixes #363
2026-04-17 13:46:38 +08:00
Syngnat
8a10519f9b 🐛 fix(query): 修正新建查询未引用 PostgreSQL 大写表名
- 抽取表查询模板 helper 并统一复用方言标识符引用逻辑
- 修正 Sidebar 与 TableOverview 的表节点新建查询入口
- 补充前端回归测试并更新 issue backlog 记录

Fixes #349
2026-04-17 13:30:07 +08:00
Syngnat
d57081ecfb 🐛 fix(query): 修正查询结果同名列被覆盖问题
- 为查询结果扫描增加稳定列名归一化,重复列自动追加序号后缀
- 统一返回字段列表与行数据键名,避免同名列值被后写覆盖
- 补充 scanRows 回归测试并更新 issue backlog 记录

Fixes #348
2026-04-17 13:24:50 +08:00
Syngnat
035f536e8d 🐛 fix(tdengine): 补齐超级表元数据查询
- 表列表合并 SHOW TABLES 与 SHOW STABLES 结果
- 返回前统一去重并排序,确保超级表可见
- 增加 TDEngine 表列表回归测试

Fixes #346
2026-04-17 13:14:08 +08:00
Syngnat
22e4299d3e 🐛 fix(redis): 修正 hash 字段删除参数序列化错误
- 前端统一按数组传递 hash 字段删除参数
- 后端兼容单字符串与数组两种删除入参
- 补充 Redis hash 字段删除回归测试

Fixes #343
2026-04-17 12:45:21 +08:00
Syngnat
384aea132c 🐛 fix(sync): 修正仅同步结构未生效
- 让已存在目标表场景复用通用补字段逻辑生成结构变更 SQL
- 为分析与预览结果补充结构差异计数与结构 SQL 明细
- 补充结构同步回归测试并更新 backlog 记录

Fixes #342
2026-04-17 12:35:23 +08:00
Syngnat
890478eb7b 🐛 fix(clickhouse): 修正 8132 端口连接失败
- 将 8132 纳入 ClickHouse HTTP 端口识别范围
- 同步修正协议切换日志与错误提示中的端口说明
- 补充连接协议识别回归测试并更新 backlog 记录

Fixes #338
2026-04-17 12:27:20 +08:00
Syngnat
8c79f2af0c 🐛 fix(update): 修正 Linux 变体自动更新失效
- 更新资产选择逻辑按当前 Linux 可执行文件变体匹配 release 包
- Linux 更新脚本优先查找与当前二进制同名的新文件
- 补充自动更新回归测试并更新 backlog 记录

Fixes #337
2026-04-17 12:17:11 +08:00
Syngnat
a2cad9f7ce 🐛 fix(ai): 修正 Anthropic 兼容供应商问答失败
- 为 AnthropicProvider.Chat 与 ChatStream 补充工具调用降级回退
- 首次携带 tools 请求在 400/422/404 时自动去掉 tools 重试一次
- 补充兼容供应商问答回归测试并更新 backlog 记录

Fixes #333
2026-04-17 12:02:23 +08:00
Syngnat
af90936fcc 🐛 fix(frontend): 修复 Redis 搜索匹配与输入交互体验
- Redis Key 搜索默认补全包含匹配并支持 ASCII 大小写不敏感
- Redis 标签页增加连接名与 host 摘要,区分同名 db 标签
- 抽取 inputAutoCap、redisSearchPattern、tabDisplay 共享工具并补充回归测试
- 覆盖连接配置、Redis 搜索、表设计、表概览和数据表筛选输入的自动纠正问题
- 在 macOS 文本输入面板关闭局部毛玻璃,修复输入法切换出现透明框
2026-04-16 18:07:38 +08:00
Syngnat
d3a1c017da 🐛 fix(driver): 修复可选驱动在线安装回归问题
Refs #388

- 修复 builtin 默认安装版本判定错误
- 恢复驱动总包 bundle 兜底路径
- 优化 Kingbase 安装策略,避免发行版优先本地构建
- 增强驱动安装日志与回归测试
2026-04-16 15:05:16 +08:00
Syngnat
a90423c04c Merge pull request #385 from Jonclex/dev 2026-04-15 15:17:46 +08:00
Jonclex
6e23053ac6 Merge branch 'Syngnat:dev' into dev 2026-04-15 14:47:18 +08:00
jonclex
9b50e9c9c8 fix(custom+mysql):CustomDB(driver=mysql)路径没有同步修改,custom链接打开时schema报错,双击表查询报错 refs bug#385 2026-04-15 14:42:45 +08:00
Syngnat
4c76202d2c Merge pull request #382 from anyanfei/feature/add_import_xml_dev 2026-04-15 13:50:49 +08:00
anyanfei
9c5b1a033a fix(import connect):统一测试用例文案;仅判断mysql-workbench-xml 2026-04-15 13:40:17 +08:00
jonclex
c631feef91 fix(ui): 表概览排除视图 refs bug#375 2026-04-15 10:27:22 +08:00
jonclex
737896627a fix(mysql): 表列表排除视图 refs bug#375 2026-04-15 10:06:44 +08:00
anyanfei
47235e1390 fix(import connect):修改导入时提示,而不是在连接时提示 2026-04-15 09:53:29 +08:00
anyanfei
b6121fe1f8 - 背景与问题 :以前没有支持官方工具mysqlworkbench的xml导入,现在支持了
- 变更点:新增mysqlworkbench的xml文件导入,并当没有密码时,提示用户,而不是直接使用空密码进行直接连接,更友好
  - 影响范围:仅导入受到影响
  - 验证方式:点击导入,用mysqlworkbench的xml进行导入即可
2026-04-14 18:50:40 +08:00
Syngnat
f78b132c7c 修改mysql编辑视图的时候保存失败。 (#377) 2026-04-13 17:13:08 +08:00
Jonclex
1adef17366 Merge branch 'Syngnat:dev' into dev 2026-04-13 15:49:53 +08:00
jonclex
ada9bbf03e fix(mysql): 修复视图编辑时的DDL头部兼容 2026-04-13 15:39:08 +08:00
Syngnat
266f217bfd 合并拉取请求 #371
fix: Oracle/DM数据库侧边栏视图不显示和默认模式显示问题
2026-04-13 12:51:42 +08:00
Jonclex
54d46453df Merge branch 'Syngnat:dev' into dev 2026-04-13 12:41:53 +08:00
Syngnat
c7cf9526de 🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串
- 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知
- WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据
- DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验
- 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞
- 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
2026-04-13 12:40:25 +08:00
jonclex
d849cd49af fix: Oracle/DM数据库侧边栏不显示'默认模式'节点 2026-04-13 12:39:14 +08:00
Syngnat
604aaad69d 合并拉取请求 #366
fix(sidebar): normalize mysql view names (#365)
2026-04-13 10:35:00 +08:00
jonclex
605e266eab fix(sidebar): normalize mysql view names (#365) 2026-04-13 10:25:03 +08:00
辣条
2569a3779a feat(connection-package): 支持连接恢复包双模式加密导入导出 (#361)
## 变更说明

  - 为连接恢复包新增 v2 双模式加密导入导出
  - 默认使用应用内置密钥加密 secrets 字段,无需用户输入文件密码
  - 可选增加文件保护密码,形成双层加密
  - 保留 v1 导入兼容,并兼容 legacy JSON 明文导入
  - 前端新增 v2 格式识别与导出弹窗选项适配
  - 合并过程中已处理与最新 dev 的冲突,确保现有安全更新链路不被破坏

  ## 回归验证

  - `go test -count=1 ./...`
  - `npm test`
  - `npm run build`
- `wails build -platform windows/amd64 -clean -o
GoNavi-windows-amd64-test -ldflags "-s -w -X
GoNavi-Wails/internal/app.AppVersion=dev-d150780-merge-test"`

  ## 人工验证

  - 明文配置导入通过
  - v2 无文件密码导出后可直接导入
  - v2 带文件密码导出后需密码导入
  - 导入旧版恢复包与 legacy JSON 均正常

  ## 备注

  - 本 PR 基于最新 `dev` 进行冲突整合
2026-04-12 12:48:47 +08:00
Syngnat
bb6271246b 🐛 fix(mac): 禁用正式包默认窗口诊断以规避启动无窗体问题
- 将 macOS 原生窗口诊断改为默认关闭
- 仅在显式设置 GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS 时启用后端诊断
- 仅在前端开发环境启用窗口诊断采集
- 避免正式构建在启动阶段附加额外窗口状态探测与日志观察
- 为诊断开关补充前后端最小回归测试

Refs: #360
2026-04-12 12:46:15 +08:00
Syngnat
8e0d1b0a80 📝 docs(contributing): 修正 dev 分支贡献流程说明
- 修正文档中的默认分支与集成分支描述
- 调整贡献分支创建基线为 dev
- 调整外部 Pull Request 目标分支为 dev
- 同步 README 中英文贡献说明
- 更新 release 后 main 回流 dev 的维护说明

Refs: #352
2026-04-12 12:34:50 +08:00
tianqijiuyun-latiao
d150780879 Merge branch 'feature/20260408_security-update' into merge/feature-20260408-security-update-onto-dev
# Conflicts:
#	frontend/src/App.tsx
#	frontend/wailsjs/go/app/App.d.ts
#	frontend/wailsjs/go/app/App.js
2026-04-12 09:40:28 +08:00
tianqijiuyun-latiao
52d2ee7592 feat(connection-package): 支持连接恢复包双模式加密导入导出
- 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路
- 扩展前后端导入导出流程并兼容 v1 与 legacy 格式
- 修复无文件密码恢复包导入误弹密码框导致的流程阻塞
2026-04-11 23:51:43 +08:00
Syngnat
2410aad849 feat(table): 支持截断表与清空表操作
Fixes #351
2026-04-11 22:53:04 +08:00
Syngnat
33b21cc5ee 🐛 fix(driver): 兼容跨平台 Go 路径回退测试 2026-04-11 22:36:21 +08:00
Syngnat
1a0ba9a499 🐛 fix(sidebar): 避免默认显示横向滚动条
Fixes #329
2026-04-11 22:27:26 +08:00
Syngnat
7a2563b83b feat(data-grid): 支持拖选单元格直接复制到剪贴板
Fixes #322
2026-04-11 22:10:48 +08:00
Syngnat
632e57ea60 feat(data-grid): 支持双击列边界自适应宽度
Fixes #330
2026-04-11 22:05:53 +08:00
Syngnat
ca76440981 🐛 fix(connection): 收紧稳定期数据库连接自动重试
Fixes #331
2026-04-11 21:58:16 +08:00
Syngnat
af5e84213f 🐛 fix(driver): 扩展 TDengine 历史版本选择范围
Fixes #325
2026-04-11 21:53:53 +08:00
Syngnat
fcade0f860 feat(sidebar): 支持窄侧栏横向滚动查看
Fixes #329
2026-04-11 21:53:52 +08:00
Syngnat
1c2377bc62 🐛 fix(driver): 修复达梦驱动安装误走无效直链
Fixes #320
2026-04-11 21:53:52 +08:00
Syngnat
426ef3bcf6 🐛 fix(update): 修复 Windows 更新脚本安装失败
Fixes #328
2026-04-11 21:53:52 +08:00
Syngnat
fb500ee33b 🐛 fix(mysql): 回退当前数据库列表查询
Fixes #327
2026-04-11 21:53:52 +08:00
Syngnat
89d79ff10c 🐛 fix(mysql): 修复 bit 列写入归一化
Fixes #318
2026-04-11 21:53:52 +08:00
Syngnat
aa1bb5b886 🐛 fix(kingbase): 回退当前数据库元数据查询
Fixes #316
2026-04-11 21:53:52 +08:00
Syngnat
5038ae5c9b 🐛 fix(window): 修复 Windows 恢复焦点后界面缩放异常
Fixes #315
2026-04-11 21:53:52 +08:00
Syngnat
83fe3d4ed9 🐛 fix(driver): 提升批量 INSERT 执行效率
Fixes #311
2026-04-11 21:53:51 +08:00
Syngnat
808c773134 feat(table-overview): 优化库内表概览为逐行展示
Fixes #310
2026-04-11 21:53:51 +08:00
Syngnat
5d86ee7c76 🐛 fix(clickhouse): 获取数据库列表失败时回退当前库
Fixes #308
2026-04-11 21:53:51 +08:00
Syngnat
8297829be6 feat(driver): 增加驱动目录直达入口与手动导入提示
Fixes #306
2026-04-11 21:53:51 +08:00
Syngnat
f696f52470 🐛 fix(table-designer): 修复金仓新增字段保存失败
Fixes #305
2026-04-11 21:53:51 +08:00
Syngnat
60b63d7a22 feat(icon): 补充 SQL Server 数据库图标
Fixes #287
2026-04-11 21:53:50 +08:00
Syngnat
1f617f9d53 feat(storage): 支持自定义数据目录与显式迁移
Fixes #242
2026-04-11 21:53:50 +08:00
tianqijiuyun-latiao
1751e14d20 🐛 fix(security): 修复安全更新重检卡死与 Redis 密文兼容 2026-04-11 20:12:23 +08:00
tianqijiuyun-latiao
82e06bd94d 🐛 fix(security): 完善密文升级导入覆盖与安全更新链路
- 完善连接恢复包与 legacy 导入覆盖语义及密文兼容处理

- 修复安全更新详情高亮反馈与相关前后端链路

- 补强 keyring 误判边界与安全更新回归测试
2026-04-11 16:53:03 +08:00
DurianPankek
c810d999bd Merge 803c33b306 into 0009c98c7e 2026-04-11 13:23:51 +08:00
folltoshe
0009c98c7e feat(window): 在全屏状态下时隐藏圆角 2026-04-11 04:40:35 +08:00
tianqijiuyun-latiao
070ff72ad8 feat(security): 完成密文升级与连接恢复包导入导出 2026-04-10 21:29:45 +08:00
DurianPankek
803c33b306 🐛 fix(window): 修复 mac 原生全屏下输入时窗口丢失 2026-04-10 19:43:15 +08:00
Syngnat
1d882d089f 🐛 fix(driver): 修复可选驱动构建时 Go PATH 检测误判 (#353)
## 背景
在 `dev-ac6ef06` 构建中,安装 SQL Server 等可选驱动时,GoNavi 在部分 macOS 环境会误报“当前环境未安装
Go”。
实际问题并非未安装 Go,而是应用从图形界面启动时没有继承终端中的 PATH,导致 `brew` 安装的 Go(如
`/opt/homebrew/bin/go`)无法被 `exec.LookPath("go")` 发现,进而阻塞可选驱动代理的本地构建流程。

材料参考:

<img width="2142" height="1460" alt="连接失败"
src="https://github.com/user-attachments/assets/0844cf97-5720-4677-a806-65e056fa9766"
/>
<img width="289" height="74" alt="image"
src="https://github.com/user-attachments/assets/3e98e482-f74d-4b68-8605-b712fbdb98c1"
/>


## 关键修改
- 为可选驱动源码构建新增 `go` 可执行文件解析逻辑,避免仅依赖当前进程 PATH
- 增加常见 Go 安装路径兜底:
  - `/opt/homebrew/bin/go`
  - `/usr/local/go/bin/go`
  - `/usr/local/bin/go`
- 在常见路径未命中时,再回退到登录 shell 中执行 `command -v go`
- 解析 shell 输出时逐行筛选真实存在的路径,避免 shell 启动脚本输出额外提示导致误判
- 为 Go 探测逻辑补充单元测试,覆盖:
  - shell 列表去重与顺序
  - 常见路径回退
  - shell 回退
  - 噪音输出过滤

## 影响范围
- 仅影响可选驱动代理的源码构建阶段
- 不影响已内置驱动
- 不影响普通数据库连接、前端界面和其他业务逻辑
- 主要改善 macOS 图形界面启动应用时的 Go 环境探测兼容性

## 验证方式
已执行:
```bash
go test ./internal/app
```
## 修复效果
<img width="1114" height="784" alt="已连接1"
src="https://github.com/user-attachments/assets/72f4bb89-6c0b-4632-9098-3ce5b865e288"
/>
<img width="1032" height="791" alt="已连接2"
src="https://github.com/user-attachments/assets/6330cff2-c13b-4a9b-852d-8fc234819f81"
/>

## 验证点:
- 终端内已安装 Go 且可执行时,保持现有行为
- GUI 进程未继承 PATH 时,可通过常见目录或 shell 回退找到 Go
- shell 启动脚本存在额外输出时,仍可解析到真实 Go 路径

## 风险与回滚
### 风险:
- 仅新增本地命令探测与路径兜底逻辑,影响面较小
- 若用户使用非常规 Go 安装方式,仍可能需要后续补充手动指定 Go 路径的正式方案

### 回滚:
- 可直接回退本 PR 中 internal/app/methods_driver.go 与对应测试变更

## 备注
当前使用中还观察到“驱动下载链路域名不可达”在已有网络代理时可能出现误报,但该问题既不影响当前 PATH
修复的有效性,也并不阻塞下载,所以未纳入本次修改范围。
2026-04-09 17:17:15 +08:00
DurianPankek
19da7fc66c 🐛 fix(driver): improve Go PATH detection for optional driver builds 2026-04-09 16:16:22 +08:00
Syngnat
c1877ea013 🐛 fix(ci): 修复 dev-latest 历史 release 残留导致的重复发布问题
- 将 dev release 清理逻辑改为枚举所有历史 release
- 删除全部 tag_name=dev-latest 的 draft 与 prerelease 残留
- 避免只删除单个 release 时遗留旧的 Dev Build 页面项
- 保留后续 tag ref 清理与重新发布流程
- 确保 dev 发布前仓库中只保留一个有效的 dev-latest release
2026-04-09 13:34:41 +08:00
Syngnat
60dbb8a559 🐛 fix(ci): 修复 dev 预发布构建时间格式并收敛并发运行
- 为 dev-build workflow 增加同分支并发互斥配置
- 避免多个 dev 运行同时操作 dev-latest release
- 新增构建时间格式化步骤,将时间统一输出为 yyyy-MM-dd HH:mm:ss
- 将 release 文案中的构建时间改为引用格式化结果
- 保持现有 dev 版本号与 release/tag 清理逻辑不变
2026-04-09 13:31:01 +08:00
Syngnat
67fe3e3017 🐛 fix(ci): 修复 dev 预发布因重复 tag 导致的发布失败
- 将 dev-latest 预发布前的清理步骤改为 actions/github-script
- 显式按 GitHub API 删除已存在的 release 与 tags/dev-latest 引用
- 移除对未维护 delete-tag-and-release 动作的依赖
- 保持 dev-latest 固定预发布标签语义不变
- 避免 softprops/action-gh-release 在 finalizing release 阶段因 tag_name already_exists 失败
2026-04-09 12:30:13 +08:00
tianqijiuyun-latiao
1a042321d2 🐛 fix(connection): 修复失败连接高频重试并暂停后台自动元数据拉取 #331
- 后端为失败数据库连接增加冷却窗口,避免短时间内重复真实建连
- 补充失败冷却回归测试,覆盖重复失败、冷却后重试和成功后清理场景
- 前端在后台态暂停查询页、侧边栏和表概览的自动元数据拉取
- 保持手动刷新、手动展开等显式操作行为不变
2026-04-08 22:31:50 +08:00
Syngnat
35944d58f8 合并拉取请求 #344
🐛 fix(connection-modal): 修复连接配置输入框自动首字母大写问题
2026-04-08 19:39:07 +08:00
DurianPankek
5c2509c37f 🐛 fix(connection-modal): 修复连接配置输入框自动首字母大写问题 2026-04-08 19:11:18 +08:00
Syngnat
8e1b01b550 Revert "🐛 fix(connection-modal): 修复连接配置输入框自动首字母大写问题 (#344)"
This reverts commit 29fa5eb6df.
2026-04-08 10:41:34 +08:00
Syngnat
29fa5eb6df 🐛 fix(connection-modal): 修复连接配置输入框自动首字母大写问题 (#344)
合并外部贡献者 DurianPancake 的 PR #344 修改(解决冲突后)。

主要改动:
- 新增 noAutoCapInputProps 常量,统一关闭 autoCapitalize/autoCorrect/spellCheck
- 在所有文本输入组件(Input/Input.Password/Input.TextArea)应用该属性
- 增加弹窗级 MutationObserver 兜底,对动态 DOM 元素补充禁用属性

影响范围: 仅前端连接配置弹窗内的文本输入行为,后端逻辑不变
2026-04-08 10:40:16 +08:00
Syngnat
7c6391af3d ️ perf(ai-chat): 优化会话持久化序列化方式
- 序列化方式:AISaveSession 从 MarshalIndent 改为 Marshal
- 存储优化:移除不必要的缩进格式化,减少磁盘占用
- 性能提升:紧凑 JSON 序列化速度更快,减少内存分配
2026-04-08 10:30:51 +08:00
Syngnat
5746796bc2 🐛 fix(export): 修复导出时间时区误偏移 (#345)
## 背景
导出查询结果时,时间字段在部分场景出现错误时区偏移。典型表现为数据库中正确的本地时间在导出后被额外偏移(例如 +8 小时),影响
JSON/文本类导出的可用性与可信度。

## 变更内容
- 修复导出时间解析逻辑,区分“带时区时间字符串”和“无时区时间字符串”的处理方式:
  - 带时区值按其时区语义解析;
  - 无时区值按本地语义解析,避免误按 UTC 导致二次偏移。
- 统一导出时间格式化行为,避免在导出阶段再次进行不必要的时区换算,确保 `timestamp without time zone`
等场景保持原始钟表时间。
- 补充回归测试,覆盖以下关键路径:
  - 无时区时间字符串导出不偏移;
  - RFC3339 字符串解析后格式化行为稳定;
  - `time.Time` 导出保持预期钟表时间;
  - JSON 导出时间字段行为一致。

## 影响范围
- 主要影响导出链路中的时间字段格式化(CSV/JSON/MD/HTML/XLSX 对应后端写出逻辑)。
- 不涉及连接协议、SQL 执行流程和驱动安装机制。

## 验证方式
- 已通过:
  - `go test ./internal/app`
  - `go test -race ./internal/app`
  - `go test ./...`

## 风险与说明
- 已确认并修复本次问题对应的导出时区偏移路径。
- 当前系统仍存在“基于值推断时间语义”的历史设计约束;这里的“元数据驱动”是指基于数据库列定义类型(如 `timestamp
with/without time zone`、`datetimeoffset` 等)来决定是否允许时区换算。
- 上述历史约束并非本次修改引入。后续建议按数据库类型矩阵(DB matrix)逐库适配元数据策略,以降低跨数据库兼容风险与误判风险。

## 相关截图
- 问题对比:问题1、问题2
<img width="419" height="170" alt="问题1"
src="https://github.com/user-attachments/assets/a4d9f949-1f5c-4dcc-b3fa-13082347fec3"
/>
<img width="736" height="130" alt="问题2"
src="https://github.com/user-attachments/assets/b1d5b9e4-7f79-4929-875c-a422d1fbe51b"
/>

---
- 修复后:修复1、修复2
<img width="548" height="130" alt="修复1"
src="https://github.com/user-attachments/assets/1ee0a91d-2dec-4060-9c8e-9817f437dae7"
/>
<img width="486" height="128" alt="修复2"
src="https://github.com/user-attachments/assets/baa8cb25-b08a-4f31-94d8-a4a50753fb97"
/>
2026-04-08 10:23:27 +08:00
DurianPankek
3ec7c9be9d 🐛 fix(export): 修复导出时间时区误偏移 2026-04-07 19:38:16 +08:00
辣条
ac6ef06413 feat(app): 合并配置密文存储、数据表增强与驱动相关修复 (#339)
## 背景
  本次合并汇总了 5 类功能,并对冲突处理后的代码进行了回归审查,目标是将配置密文存储能力、数据表体验增强及驱动相关修复一并并入。

  ## 本次变更
  1. 修复 Data Viewer 多列排序状态残留,避免排序条件切换后失效。
  2. 收紧 MongoDB 可选驱动支持区间,仅支持 1.17.x 与 2.x,并补齐对应版本识别与导入校验。
  3. 完成配置密文存储前后端闭环,包括:
     - 新增密钥存储基础设施与状态枚举
     - 拆分 AI Provider 元数据与密钥存储
     - 暴露连接配置、代理配置相关密钥存储 API
     - 前端状态迁移为不保存明文密钥
     - 通过连接配置 ID 路由 RPC 配置
     - 修复密文编辑与状态残留问题
  4. 增强 DataGrid 显示能力,补充展示策略并支持行级 SQL 复制。
  5. 修复本地驱动导入版本识别与数据库连接校验遗漏,补齐 ClickHouse 等相关校验路径。

  ## 附带修复
  - 修复 Claude CLI 在 Windows 下的测试稳定性问题。

  ## 验证情况
  - `go test ./...`
  - `go build ./...`
- `npm test -- src/store.test.ts src/utils/dataGridDisplay.test.ts
src/components/dataGridCopyInsert.test.ts
src/utils/connectionRpcConfig.test.ts
src/utils/connectionSecretDraft.test.ts src/utils/
providerSecretDraft.test.ts src/utils/customConnectionDsn.test.ts
src/utils/aiProviderEditorState.test.ts
src/utils/browserMockConnections.test.ts
src/utils/dataViewerAutoFetch.test.ts`
  - `npm run build`

  ## 说明
  - 其他功能主要依据提交差异、代码检查与自动化测试完成回归确认。
  - 当前未发现因冲突处理导致的明确编译问题、功能失效或目标偏离
2026-04-05 12:42:59 +08:00
tianqijiuyun-latiao
ac0b6c05e8 🐛 fix(database): 修复本地驱动导入版本识别与连接校验遗漏
- MongoDB 本地导入按所选版本解析目录与压缩包

- ClickHouse 连接测试补充 query path 校验

- 补充驱动版本与查询路径回归测试
2026-04-05 12:09:06 +08:00
tianqijiuyun-latiao
37b3c78049 feat(datagrid): 增强数据表显示与行级SQL复制
- 新增 DataGrid 竖向分隔线与列宽模式配置并持久化\n- 支持复制 INSERT/UPDATE/DELETE 并按主键或唯一键生成条件\n- 补充外观配置与 SQL 复制相关测试
2026-04-05 12:06:40 +08:00
tianqijiuyun-latiao
255cc14bf6 🐛 fix(config-secret-storage): 修复密文编辑与状态残留问题
- 修复自定义连接编辑时已保存 DSN 无法留空沿用的问题
- 重置 AI 供应商编辑态与清空密钥开关,避免关闭后状态残留
- 对齐浏览器 mock 复制连接的 config.id 语义并补充回归测试
2026-04-05 11:59:38 +08:00
tianqijiuyun-latiao
4718755208 feat(security): 完成配置密文存储前后端闭环
- 补齐连接与代理密文字段的保留替换清空语义

- 接通保存复制删除导入接口并返回 secretless 视图

- 刷新 Wails 绑定并补充实现留痕文档
2026-04-05 11:52:59 +08:00
tianqijiuyun-latiao
91b5b85904 ♻️ refactor(security): 通过连接配置 ID 路由 RPC 配置 2026-04-05 11:42:28 +08:00
tianqijiuyun-latiao
c842201bf4 feat(security): 前端状态迁移至无明文密钥存储 2026-04-05 11:40:20 +08:00
tianqijiuyun-latiao
263db6bf30 feat(security): 暴露连接配置与代理的密钥存储 API 2026-04-05 11:39:54 +08:00
tianqijiuyun-latiao
b5e8f5c022 feat(security): 新增连接配置与代理的密钥仓库 2026-04-05 11:39:34 +08:00
tianqijiuyun-latiao
b62d22395b feat(security): 拆分 AI 供应商元数据与密钥存储 2026-04-05 11:39:15 +08:00
tianqijiuyun-latiao
f74270d585 🐛 fix(security): 新增密钥存储状态枚举 2026-04-05 11:38:56 +08:00
tianqijiuyun-latiao
ef64a24e01 feat(security): 新增密钥存储基础架构 2026-04-05 11:38:41 +08:00
tianqijiuyun-latiao
c1266c225a 🐛 fix(ai/provider): 修复 Claude CLI 在 Windows 上的测试稳定性 2026-04-05 11:33:39 +08:00
tianqijiuyun-latiao
acee1a06e8 fix(driver): 收紧 MongoDB 驱动支持区间 2026-04-05 11:32:41 +08:00
tianqijiuyun-latiao
eddb9f38c9 🐛 fix(data-viewer): 修复多列排序状态残留导致排序失效
- 将表格排序状态改为按当前 sorter 结果重建\n- 避免取消或切换多列排序后保留失效字段\n- 抽取排序状态归一化工具供数据表复用
2026-04-05 11:32:37 +08:00
Syngnat
fbda6917f7 🐛 fix(ai-chat): 修复 DeepSeek 回显引导提示词并优化收敛策略
- 删除 ≥5 轮注入 system 提示的逻辑(部分模型会将其当作对话内容输出)
- 改为 ≥10 轮时移除 tools 参数,从物理层面终止工具调用循环
2026-04-02 11:04:57 +08:00
Syngnat
b022cd63e5 🐛 fix(ai-chat): 修复重新生成时缺少状态过渡动画的问题
- handleRetryMessage 补齐 connecting 过渡消息(波纹动画),与 handleSend 流程一致
- 重试时同步重置工具调用计数器,防止继承旧计数导致过早熔断
2026-04-02 10:56:27 +08:00
Syngnat
9eb42565f1 🐛 fix(ai-chat): 修复工具调用无限循环与写操作误报执行失败问题
- 循环熔断:新增全局工具调用总轮次上限(15轮),防止 DeepSeek 等模型无限循环
- 软引导:工具调用 ≥5 轮时注入 system 提示引导模型尽快收敛输出
- LIMIT 修复:execute_sql 不再对 UPDATE/DELETE/INSERT 等写操作追加 LIMIT 50
- 语法防御:去除 SQL 末尾分号防止拼接出 "; LIMIT 50" 的无效语法
2026-04-02 10:49:11 +08:00
Syngnat
6d533167da feat(sidebar/table-overview): 优化右键菜单交互,增加危险操作二级分类防误触
- 菜单增强:为数据库、表、视图、函数等底层对象节点新增「危险操作」二级子菜单
- 误触防护:将明确破坏性的「删除表」、「删除数据库」等入口移至更深层级进行视觉隔离
- UI 交互:引入 WarningOutlined 图标单独高亮标识风险区域
- 统一作用域:同步变更至侧边栏连接树 (Sidebar) 和表数据概览 (TableOverview) 的上下文菜单
2026-04-02 10:11:33 +08:00
Syngnat
f992ad72e6 feat(mongodb): 支持无认证模式连接低版本 MongoDB 实例
- 连接表单:验证方式新增"无认证 (None)"选项,MongoDB 用户名改为非必填
- URI 构建:当 MongoAuthMechanism 为 NONE 时跳过 user/password/authSource/authMechanism
- 兼容优化:无用户名时不再默认设置 authSource=admin,避免驱动对无密码实例发起认证
- 双版本同步:mongodb_impl.go 与 mongodb_impl_v1.go 同步修改
- refs #303
2026-04-01 16:46:27 +08:00
Syngnat
5c0f6f8ff4 🐛 fix(data-grid): 修复数据预览面板日期格式化、JSON切换失效及幽灵变更计数问题
- 日期时间字段预览时通过 normalizeDateTimeString 格式化带时区的 ISO 格式
- 切换单元格时始终更新预览值,用 dataPanelOriginalRef 替代 suppress 机制判断 dirty
- handleCellSave 增加根源级变更检测,与原始 data 逐字段比较后才写入 modifiedRows
- 英文消息 "No changes to commit" 改为中文 "没有可提交的变更"
- refs #301
2026-04-01 16:21:57 +08:00
Syngnat
1eb517f083 ♻️ refactor(table-designer): 按索引类别精确分类索引方法类型选项
- MySQL InnoDB:所有索引类别均为固定方法(BTREE/FULLTEXT/RTREE),移除无意义的"默认"选项
- PostgreSQL:普通索引保留全部方法选项,主键和唯一索引固定为 BTREE
- 新增 getFixedIndexType 辅助函数,切换索引类别时自动设置对应的固定方法类型
- getIndexTypeOptions 接受 kind 参数,按类别动态返回精确的选项列表
- 切换类别时若当前方法不在新选项中,自动重置为合法值
- refs #299
2026-04-01 16:04:22 +08:00
Syngnat
02fa0aef46 🔥 remove(table-designer): 移除 MySQL 索引类型中不支持的 HASH 和 RTREE 选项
- MySQL InnoDB 引擎不支持手动创建 HASH/RTREE 索引,执行后会静默降级为 BTREE
- 从 MYSQL_INDEX_TYPE_OPTIONS 中移除 HASH 和 RTREE,避免用户误选导致修改"不生效"
- MySQL 下仅保留 DEFAULT/BTREE/FULLTEXT/SPATIAL 四种索引类型
- refs #298
2026-04-01 15:54:29 +08:00
Syngnat
f7107a1625 🐛 fix(data-grid): 修复单元格编辑值丢失及日期选择器滚动偏移问题
- 移除 Form.Item 的 preserve={false},修复嵌套字段名下编辑后值变为 undefined 的问题
- 将表单值初始化移至 useEffect([editing]),确保每次编辑时从 record 重新读取并覆盖旧值
- 新增 cellRef 绑定单元格 DOM,用于定位滚动容器
- DatePicker/TimePicker 面板打开时在 ant-table-wrapper 上拦截 wheel 事件,阻止表格滚动导致选择器漂移
- 面板关闭时自动移除 wheel 事件监听,恢复正常滚动
- refs #297
2026-04-01 15:45:50 +08:00
Syngnat
08ab06c038 feat(sidebar/table-overview): 优化侧边栏交互并新增表概览列表视图
- 修复连接刷新后数据库节点无法再次展开的问题,刷新时清除子节点 expandedKeys/loadedKeys/loadingRef
- 表概览由双击改为单击"表(N)"分组节点打开,双击仅触发展开/折叠
- 使用 clickTimerRef 延时防抖区分单击与双击事件,避免双击同时打开表概览
- 表概览新增列表视图模式,展示表名、注释、行数、数据大小、索引大小、引擎等列
- 工具栏新增卡片/列表视图切换按钮,两种视图共享搜索、排序和右键菜单
- refs #296
- refs #324
2026-04-01 15:29:42 +08:00
Syngnat
3402b56fdb 🎨 style(data-grid): 重构筛选面板为 flex 分区布局
- 外层改为 flex column,拆分为可滚动内容区(maxHeight: 200px)和固定操作栏
- "添加排序"从内容区提升到操作栏,条件渲染依赖 onSort 存在性
- "添加条件"使用 primary ghost 按钮增强辨识度
- refs #295
2026-04-01 15:03:02 +08:00
Syngnat
2c2baca69f 🐛 fix(data-grid): 修复日期时间字段二次编辑时日历残留上次选择日期标记
- Form.Item 默认 preserve={true},DatePicker 卸载后表单仍保留旧 dayjs 值
- 再次进入编辑时 DatePicker 读取到残留值,导致日历面板显示上次选择的日期圆圈
- 设置 preserve={false} 确保每次编辑态卸载后清除字段值,消除残留标记
- refs #290
2026-04-01 14:53:44 +08:00
Syngnat
e464c2cce1 🐛 fix(data-grid): 修复日期时间类型字段编辑交互问题并中文化日期选择器
- 修复"此刻"按钮点击后自动提交的问题,改为自定义按钮仅填值、需点击"确定"才保存
- 修复 datetime 编辑态点击外部后不退出的问题,通过 onBlur + pickerOpenRef 兜底
- 全局配置 dayjs 中文 locale,日期选择器月份/星期等文本显示为中文
- 为 time/date/year 类型 picker 添加 onBlur 兜底,确保焦点离开后退出编辑
- save 函数增加 editing 守卫和 catch 兜底,防止重复保存或异常时卡死编辑态
- refs #289
2026-04-01 14:49:28 +08:00
Syngnat
15f72c013d 📝 docs(readme): 新增项目 Star 增长趋势图与动态状态徽章
- 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量
- 增长趋势:底部区域新增 Star History 的动态增长曲线图表
- 兼容性修复:将 HTML `<picture>` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题
- 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新
2026-04-01 14:04:26 +08:00
Syngnat
c2c8870841 📝 docs(readme): 新增项目 Star 增长趋势图与动态状态徽章
- 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量
- 增长趋势:底部区域新增 Star History 的动态增长曲线图表
- 兼容性修复:将 HTML `<picture>` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题
- 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新
2026-04-01 14:03:14 +08:00
Syngnat
4f7ac7149a 📝 docs(readme): 新增项目 Star 增长趋势图与动态状态徽章
- 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量
- 增长趋势:底部区域新增 Star History 的动态增长曲线图表
- 兼容性修复:将 HTML `<picture>` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题
- 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新
2026-04-01 13:52:57 +08:00
tianqijiuyun-latiao
8d8af530a7 Merge remote-tracking branch 'upstream/dev' into feature/20260327_opt
# Conflicts:
#	frontend/src/components/DataGrid.tsx
2026-03-31 12:36:20 +08:00
tianqijiuyun-latiao
29b96719d5 🐛 fix(sql): 修复时间字段复制与导出SQL格式 2026-03-31 12:29:03 +08:00
Syngnat
9c96246320 feat(postgres): PostgreSQL 支持不带 schema 前缀的表名补全与执行
- 后端优化:连接成功后自动查询所有用户 schema 并将 search_path 写入 DSN 重建连接池
- 连接池修复:SET search_path 仅对单个连接生效,改为 DSN 级别配置使所有连接生效
- 表名补全:前端智能匹配 schema.table 中的纯表名部分,输入表名即可触发补全
- 同名表处理:跨 schema 存在同名表时补全自动显示 schema.table 格式以区分
- 列补全增强:FROM/JOIN 引用纯表名时关联列提示和别名列提示均可正确匹配
2026-03-31 12:09:33 +08:00
Syngnat
31644dee6b 🐛 fix(dameng): 修复达梦数据源侧边栏无法展开数据库节点的问题
- 权限适配:取消对 SYSDBA schema 的默认过滤,并增加 `SELECT USER FROM DUAL` 兜底查询
- 树节点容错:Sidebar 当数据库为空时不再阻断加载状态,允许用户重试刷新并增加明确提示
- 类型修正:修复 RedisMonitor 组件 `NodeJS.Timeout` 在 Vite 下的编译报错
- 测试覆盖:补充达梦 SYSDBA 过滤及兜底查询逻辑的单元测试
2026-03-30 19:46:05 +08:00
Syngnat
aa9d8d243a feat(redis/monitor/oracle/data-viewer): 新增 Redis 实例监控并优化 Oracle 大表预览体验
- 新增 RedisMonitor 面板,展示 QPS、内存、CPU、连接数和键数量趋势图
- 引入 recharts 依赖并补齐监控图表所需前端包与锁文件
- Sidebar 新增 Redis 实例监控入口,TabManager 与 TabData 接入 redis-monitor 页签类型
- RedisCommandEditor 支持多行脚本块解析、选区执行、耗时记录与终端化结果展示
- Oracle 表预览移除自动精确 COUNT(*),避免打开大表时额外阻塞
- 无筛选整表预览接入 ALL_TABLES.NUM_ROWS 近似总数展示,并拆分近似总数与近似总页数语义
2026-03-30 16:48:19 +08:00
Syngnat
6e55d63877 📝 docs(readme): 更新AI助手功能描述与界面截图,并添加友情链接
- 核心特性:补充 AI 智能助手的多模型支持、表结构上下文和快捷指令介绍
- 界面更新:移除旧版截图,替换为全新的 AI 对话、模型配置与上下文选择界面截图
- 友情链接:在文档底部补充 linux.do 及 AI全书 链接
- 多语言:同步更新中英文双语版 README 细节内容
2026-03-30 10:43:46 +08:00
tianqijiuyun-latiao
c126c4b731 Merge remote-tracking branch 'upstream/dev' into feature/20260327_opt 2026-03-29 22:34:39 +08:00
tianqijiuyun-latiao
c85de27aac perf(query): 批量写语句走一次性 Exec 减少网络往返,修复大量 INSERT 执行慢问题
- 新增 BatchWriteExecer 可选接口(ExecBatchContext)
- MySQL/MariaDB/Doris/PostgreSQL/SQLite/DuckDB 实现该接口
- DBQueryMulti 检测到纯写操作时走批量路径,500 条 INSERT 从 500 次网络往返降至 1 次
- 混合语句(SELECT + INSERT)及不支持该接口的驱动继续走原有逐条执行路径
2026-03-29 12:17:37 +08:00
Syngnat
eeef0f06ed 🐛 fix(app): 修复供应商预设识别并兼容Wails开发模式资源加载
- 抽离供应商预设匹配逻辑,避免自定义 OpenAI 端点误识别为千问 Coding Plan
- 调整 AI 设置弹窗的预设回填逻辑,并补充预设识别回归测试
- 通过 dev/prod build tag 拆分前端资源装配,避免开发模式依赖 frontend/dist
2026-03-28 17:40:27 +08:00
Syngnat
fcd4d4026c 🔧 chore(gitignore): 移除本地追踪文档并补充忽略规则
- 从版本控制中移除 docs/superpowers 下的计划与设计文档
- 从版本控制中移除 docs/需求追踪 下的本地进度追踪文档
- 补充忽略规则,避免本地需求追踪与 superpowers 文档再次误提交
2026-03-28 17:35:21 +08:00
Syngnat
a7bee7f3b6 feat(ai-entry): 优化AI助手贴边入口交互体验
- 将 AI 助手入口从侧栏工具区迁移为主内容区右侧贴边标签
- 调整打开态贴边标签锚点到面板左外沿,避免遮挡头部操作区
- 重排侧栏顶部工具布局,恢复四项按钮的稳定网格结构
- 新增 aiEntryLayout 布局辅助与回归测试,覆盖打开态附着位置
2026-03-28 16:48:06 +08:00
tianqijiuyun-latiao
ed4a7b96d4 🐛 fix(query): 修复千万级表查询超时、表头备注类型不显示及datetime INSERT格式问题 refs #307
- QueryEditor: SQL编辑器查询 timeout 下限设为 120s,防止大表全量查询被 30s 超时取消
- QueryEditor: 放宽表名提取正则,支持 SELECT col1,col2 FROM table 形式,修复表头备注/类型不显示
- DataGrid: handleCopyInsert 对 datetime 值调用 normalizeDateTimeString,消除 RFC3339 格式中的 T 和时区后缀
2026-03-27 18:39:09 +08:00
Syngnat
09d013f27d 🐛 fix(app): 为稳定期首次连接增加瞬时网络重试保护 (#309)
## 问题背景
在 app 启动后等待 20s 以上,再手动触发数据库连接时,遇到瞬时网络错误(如 `no route to
host`)会立即失败,用户体感为“没有重试”。

相关讨论与上下文参考:
- https://github.com/Syngnat/GoNavi/pull/294

## 问题描述
此前重试保护逻辑主要以“应用启动窗口(20s)”为边界:
- 启动窗口内:瞬时网络失败会自动重试
- 启动窗口外:即使是瞬时网络失败也不重试
这导致“用户首次手动连接发生在稳定期”时,行为与预期不一致。

## 本地复现关键日志(节选)
```log
2026/03/27 15:21:04.792462 [INFO] 应用启动完成(首次连接保护窗口=20s,最多重试=4 次)
2026/03/27 15:22:29.196794 [INFO] 获取数据库连接:... 启动阶段=稳定期(age=1m24.405s)
2026/03/27 15:22:29.208920 [ERROR] 建立数据库连接失败:... connect: no route to host
2026/03/27 15:22:29.212453 [ERROR] DBGetDatabases 获取连接失败:... connect: no route to host


2026/03/27 16:07:45.463959 [INFO] 获取数据库连接:... 启动阶段=稳定期(age=21s)
2026/03/27 16:07:45.470744 [ERROR] 建立数据库连接失败:... connect: no route to host
2026/03/27 16:07:45.473604 [WARN] 检测到瞬时网络失败,准备重试连接:... 尝试=1/4 延迟=800ms
2026/03/27 16:07:46.277658 [INFO] 获取数据库连接:... 启动阶段=稳定期(age=21.814s)
2026/03/27 16:07:46.281761 [INFO] 创建数据库驱动实例:... 尝试=2/4
2026/03/27 16:07:46.284741 [ERROR] 建立数据库连接失败:... connect: no route to host
2026/03/27 16:20:59.298636 [INFO] 应用启动完成(首次连接保护窗口=20s,最多重试=4 次)
2026/03/27 16:23:26.180978 [INFO] 获取数据库连接:... 启动阶段=稳定期(age=2m26.883s)
2026/03/27 16:23:26.201478 [INFO] 数据库连接成功并写入缓存:...
```

## 变更内容
- 调整连接重试判定逻辑:
  - 启动窗口内:保持原有重试预算(最多 4 次)
  - 启动窗口外:若为瞬时网络错误,补充一次保护重试(总计 2 次尝试)
  - 非瞬时错误(如认证失败)在稳定期不重试
- 日志文案泛化,避免“仅启动期”误导:
  - 数据库连接在重试后成功
  - 检测到瞬时网络失败,准备重试连接
## 测试与验证
### 新增/更新单元测试覆盖以下场景:
- 启动期瞬时错误重试并成功
- 稳定期瞬时错误重试一次并成功
- 稳定期瞬时错误持续失败时,仅重试一次后停止
- 稳定期非瞬时错误不重试
- 稳定期重试路径输出重试提示日志
- 启动期瞬时错误失败时使用完整重试预算
### 本地执行:
- go test ./internal/app -run StartupRetry -count=1
- go test ./internal/app -count=1
### 影响范围
- 连接建立重试策略(internal/app/app.go)
- 启动重试相关测试(internal/app/app_startup_connect_retry_test.go)
## 风险与回滚
- 风险:稳定期瞬时网络错误会增加一次重试等待(约 800ms)
- 回滚:可回退本 PR 即恢复“仅启动窗口重试”的旧策略
2026-03-27 17:30:14 +08:00
Syngnat
09aa526570 🐛 fix(ai/provider/chat-ui): 修复千问 Coding Plan 预设与 Claude CLI 报错
- 统一千问 Coding Plan 到 claude-cli 链路
- 修正旧配置识别与模型列表逻辑
- 透传 Claude CLI 鉴权失败和错误事件
- 移除误杀正常回复的启动定时器
2026-03-27 17:02:51 +08:00
DurianPankek
5844cd7c01 🐛 fix(app): 为稳定期首次连接增加瞬时网络重试保护 2026-03-27 16:27:46 +08:00
Syngnat
4f74c44147 🐛 fix(ai/provider/chat-ui): 修复AI供应商兼容性并优化聊天提示交互
- 修复通义千问百炼 Anthropic 兼容鉴权头与健康检查请求
- 拆分通义千问百炼通用与 Coding Plan 双入口,调整预设回填与模型策略
- 修复火山 Coding Plan 模型列表过滤逻辑,避免混入无关模型
- 统一 OpenAI 兼容供应商路径与模型列表处理,补充相关服务层测试
- 优化 AI 设置供应商卡片布局,统一高度并收紧文本展示
- 将聊天区模型校验提示改为输入框上方的内联提示卡,补充前端回归测试
2026-03-27 14:29:03 +08:00
Syngnat
a5fdfefa2d 🐛 fix(ai/volcengine): 修复火山引擎兼容路径并拆分双预设
- OpenAI 兼容 URL 归一化改为保留已有 v3 和 v4 版本段,避免火山与智谱地址被错误补 /v1
- 对误填 /chat/completions 和 /models 的地址先回退到 base URL,再拼接目标端点
- 模型列表与连通性检测复用统一端点解析逻辑,修复火山 Coding Plan 等兼容服务请求
- AI 设置页拆分火山方舟与火山 Coding Plan 两个预设,并按完整路径精确匹配回显
- 修正模型下拉默认值行为,未选模型时保持占位态,避免误用动态列表首项
- 补充 provider 与 service 回归测试,并新增需求追踪文档
2026-03-27 12:04:55 +08:00
Syngnat
37ac13b94e 🐛 fix(ai/wails-binding): 修复生命周期绑定生成类型错误
- 收敛 App 与 AI Service 的内部生命周期方法,避免被 Wails 误导出到前端
- 将启动初始化改为包级生命周期接线,保持主程序启动流程不变
- 隐藏内部清理方法,移除生成绑定中的无效 context/time 类型声明
- 同步更新 frontend/wailsjs 绑定文件,清理 Service 与 App 的错误导出
- 调整相关测试调用,确保内部方法重命名后行为一致
2026-03-27 11:42:57 +08:00
Syngnat
d4d685b076 feat(ci/ai): 新增 dev 分支自动构建工作流并增强 Claude CLI Windows 兼容性
- CI 新增:添加 dev-build.yml,push dev 分支自动触发全平台构建并发布 Pre-release
- CI 清理:删除已废弃的 test-build-all-platforms.yml 和 test-macos-build.yml
- Claude CLI:新增 Windows 环境自动探测 Git Bash 路径(ProgramFiles/LocalAppData 多候选)
- Claude CLI:setEnv 改为可返回 error,环境变量操作提纯为 buildClaudeCLIEnv 纯函数
- Claude CLI:新增 upsertEnv/fileExists/joinWindowsPath 等工具函数
2026-03-27 10:49:39 +08:00
Syngnat
9f6d524e3d 🐛 fix(ai/provider): 修复 Kimi 与 MiniMax 供应商兼容路由
- 调整 Kimi 预设为 Anthropic 兼容入口并修正 Moonshot 域名回显
- 修复 Anthropic 请求地址归一化,确保聊天请求正确落到 /v1/messages
- 修正 Kimi 模型列表与测试连接路由,固定使用 Moonshot /v1/models
- 修正 MiniMax 默认模型与兼容模型集合,避免请求不存在的 /anthropic/v1/models
- 为 MiniMax 健康检查改用最小化 messages 请求,并兼容旧模型名配置
- 补充 Kimi 与 MiniMax 供应商回归测试,更新需求追踪文档
2026-03-26 22:26:25 +08:00
Syngnat
a89289f1cc Merge branch 'dev' into feature/ai-integration-ygf-20260323 2026-03-26 20:29:20 +08:00
Syngnat
b958ff6481 🐛 fix(ai/query-editor/mac-window): 修复模型兼容性并优化即时执行与窗口交互
- AI 兼容性:为 Anthropic Provider 补齐 tools/tool_use/tool_result 协议转换,支持工具调用与流式工具结果解析
- 降级策略:OpenAI 兼容接口在 tools 请求返回 400/422/404 时自动回退为纯文本模式
- 配置修复:调整 MiniMax 预设为 Anthropic 兼容端点并更新默认模型列表
- 状态隔离:AI 聊天面板停止将动态模型列表写回供应商配置,避免污染静态 models 数据
- 编辑器修复:QueryEditor 在 runImmediately 场景下避免重复追加 SQL,改为直接选中并执行
- 交互优化:修复 macOS 原生窗口控制切换与标题栏点击行为,避免窗口按钮状态异常
2026-03-26 17:57:29 +08:00
Syngnat
98e9e5686d feat(ai): 发布全新 AI Copilot 助手面板与工作区智能打通
- 核心架构:新增独立 AI 会话中枢,集成主流大模型生态(含私有部署中继版)的无感衔接发问
- 智能诊断:打破信息孤岛,大模型可通过关联工作区实时数据表 DDL 和错误栈,充当专属 DBA 排错及代码编写
- 视觉与多模态:支持极简发图读图交互体验,智能补全模型所需的缺省预警 Prompt,并兼容不规范中转端点图文并茂
- UI 与性能:重构聊天浮层挂靠逻辑与渲染阻断,应对长时间巨量问答引发的卡段内存泄漏,会话自动保存归档
2026-03-26 16:02:08 +08:00
Syngnat
93446e060e 🐛 fix(table-designer): 修复索引编辑丢失与勾选异常 (#302)
## 问题描述


问题1:设计表中修改索引时,当前实现采用“先删除旧索引,再创建新索引”的流程。当新索引创建过程中出现异常时,旧索引已经被删除,最终会导致原有索引丢失。见https://github.com/Syngnat/GoNavi/issues/300
问题2:自测时发现

索引列表还存在选择交互异常:

- 只有一条索引被选中时,checkbox 偶发无法取消
- “修改”按钮会因选中状态异常而偶发不可用
- checkbox 与整行点击在某些情况下表现不一致


## 问题原因
### 1. 索引编辑失败后丢失原索引
索引修改流程是拆成两条 DDL 顺序执行:
1. 删除旧索引
2. 创建新索引
执行层没有事务保护,也没有失败补偿逻辑。
因此当第 1 步成功、第 2 步失败时,原索引会被直接删掉。

### 2. 索引勾选状态异常
索引表存在两套同时修改选中状态的交互:
- checkbox 自己维护一套 toggle
- 整行点击也维护一套 toggle
两套逻辑共同修改 `selectedIndexKeys`,会导致事件命中时出现互相抵消,从而出现:
- checkbox 偶发点不动
- 单选状态不稳定
- “修改”按钮偶发不可用

## 修复方案
### 1. 增加索引编辑失败恢复机制
- 抽出统一的 DDL 顺序执行逻辑,明确拿到失败语句位置
- 修改索引时,若旧索引删除成功但新索引创建失败,则自动尝试按旧定义恢复原索引
- 若无法恢复,则给出明确错误提示
- 同时增加“无实际变更”判断,避免无意义执行破坏性 DDL
### 2. 统一索引选择交互入口
- 将索引选中状态收敛到单一的 `toggleIndexSelection` 入口
- checkbox 区域改为只走同一套状态切换逻辑
- 阻断 checkbox 区域事件冒泡,避免和整行点击双重触发
- 消除重复选中与单选取消不稳定问题
### 3. 补充单元测试
新增针对索引相关 helper 的单元测试,覆盖:
- 索引行到编辑表单的归一化
- 无变更编辑识别
- 选择切换不重复
- 单选场景下反复点击可稳定选中/取消
- 仅在“删除旧索引成功、创建新索引失败”时触发恢复判断

## 验证效果
### 已验证
- 修改索引时,若新索引创建失败,会尝试恢复原索引
- 单条索引选中后,可稳定通过 checkbox 取消选中
- 多选/取消后,单选状态仍然稳定
- “修改”按钮随单选状态稳定启用/禁用

### 单元测试
执行命令:

```bash
npm test -- src/components/tableDesignerIndexUtils.test.ts
```

## 回归执行结果:

### 问题1
- 索引bug#300_上报问题现象
<img width="1119" height="433" alt="索引bug#300_上报问题现象"
src="https://github.com/user-attachments/assets/61831c2f-5840-4d0d-ab71-d6c82d0db63e"
/>

- 索引bug#300_修复效果截图
<img width="1500" height="460" alt="索引bug#300_修复效果截图"
src="https://github.com/user-attachments/assets/277fd339-9bc4-4cfb-9e0f-d2365e334cdd"
/>

### 问题2
- 索引修改前端事件问题现象截图,有时看着是正常的,实则是两套前端事件冲突

<img width="324" height="283" alt="索引修改前端事件问题"
src="https://github.com/user-attachments/assets/849c362c-4ce3-46b6-9a33-f7348be9c581"
/>

<img width="491" height="348" alt="索引修改前端事件问题2_有时看着是正常的"
src="https://github.com/user-attachments/assets/855a1ed7-1365-44cc-a2f9-6993c3d761e0"
/>

<img width="707" height="406" alt="索引修改前端事件问题3_checkbox事件冲突"
src="https://github.com/user-attachments/assets/3c5fc75f-9eb2-470e-8b0c-976b8eaf5a94"
/>

- 索引修改前端事件问题修复效果
<img width="2308" height="792" alt="索引修改前端事件问题修复效果"
src="https://github.com/user-attachments/assets/f22e8145-58fd-4ba1-9d29-e81a879af64d"
/>

### 影响范围说明
本次修改影响设计表中的“索引”页签交互与索引编辑执行流程,主要包括:
- 索引修改
- 索引单选/多选
- “修改”按钮启用状态
- 索引失败后的恢复处理
不影响:
- 普通表结构保存流程
- 外键维护逻辑
- 触发器维护逻辑
- 非索引相关页面交互

### 风险说明
- 索引恢复依赖旧索引定义能正确还原为创建 SQL
- 当前修复已覆盖前端交互和失败补偿逻辑,但不同数据库方言下仍建议结合实际库型回归验证一次索引修改流程
2026-03-26 12:16:45 +08:00
DurianPankek
ecc8ff1197 🐛 fix(table-designer): 修复索引编辑丢失与勾选异常 2026-03-23 17:32:11 +08:00
Syngnat
82369b4070 Merge branch 'dev' into pr-294 2026-03-22 20:58:21 +08:00
Syngnat
1bda751ada feat(ai-chat): 全面升级AI聊天面板并优化交互体验
- 消息管理:新增聊天气泡的重试、编辑与单条删除功能及相对应的持久化状态函数
- 快捷操作:支持长文一键滑动到底端,并在代码块内增加SQL一键送入编辑器的快捷执行机制
- 视觉优化:深化AI回复背景沉浸感,重绘AI洞察按钮并移除设置面板所有的冗余紫色调
- 设置调优:放宽模型初始必填限制,新增内置系统提示词(Builtin Prompt)全览面板
2026-03-22 20:54:29 +08:00
DurianPankek
7bc358d612 🐛 fix(connect): 修复首次启动数据库连接偶发失败 2026-03-21 16:17:29 +08:00
Syngnat
36a57f9601 feat(shortcut): 将 macOS 全屏切换快捷键注册到快捷键管理面板
- 新增 toggleMacFullscreen action 到 shortcuts.ts
- 新增 platformOnly 字段支持按平台过滤快捷键显示
- 默认绑定 Ctrl+Meta+F,仅 macOS 下显示
- 移除 App.tsx 中的硬编码全屏快捷键判断,统一走 shortcuts 系统
2026-03-20 21:44:12 +08:00
Syngnat
e85c561f1e feat(mac-window): 支持切换 macOS 原生窗口控制与原生全屏行为 (#288)
## 背景
当前 GoNavi 使用自定义无边框标题栏与右上角窗口按钮,在 macOS 下与系统原生窗口交互习惯存在明显差异:
- 缺少左上角原生红黄绿窗口控制按钮
- 绿色按钮不具备 macOS 原生全屏 / Space 语义
- 标题栏交互和系统应用不够一致

这个 PR 为 macOS 增加了可切换的原生窗口控制模式,在尽量不影响现有跨平台行为的前提下,补齐 macOS 用户更熟悉的窗口体验。

## 变更内容
- 在 `主题 -> 外观参数` 中新增 `使用 macOS 原生窗口控制` 开关
- 启用后:
  - 显示 macOS 左上角原生红黄绿按钮
  - 隐藏现有右上角自定义窗口按钮
  - 为标题栏内容预留原生按钮安全区域
  - 优先使用 macOS 原生全屏行为
  - 支持 `Control + Command + F` 切换全屏
- 修复原生全屏下按 `Esc` 会意外退出全屏的问题
- 补充窗口行为、边界条件和相关工具函数单元测试

## 影响范围
- 仅影响 macOS 下启用该开关时的窗口样式与交互
- Windows/Linux 默认行为不变
- Windows 构建已验证通过

## 验证结果
已完成以下验证:
- [x] `npm run test`
- [x] `npm run build`
- [x] `go test ./...`
- [x] macOS 手工验证通过
- [x] Windows 构建验证通过

### macOS 手工验证项
- [x] 设置页可见 `使用 macOS 原生窗口控制`
- [x] 开关关闭时,保留当前自定义标题栏与右上角按钮
- [x] 开关开启时,右上角自定义按钮隐藏
- [x] 开启后显示左上角原生红黄绿按钮
- [x] 绿色按钮进入原生全屏
- [x] 原生全屏进入独立 Space
- [x] `Control + Command + F` 可切换全屏
- [x] 原生全屏下按 `Esc` 不再意外退出全屏
- [x] 浅色 / 深色主题下显示正常
- [x] 模糊与透明效果在普通窗口和全屏下均可正常工作
- [x] 最小化行为正常

## 截图 / 演示
### 历史窗口样式
- `MAC_历史版本窗口.png`
<img width="1920" height="1080" alt="MAC_历史版本窗口"
src="https://github.com/user-attachments/assets/4bd9176f-9d7e-43d1-9e1a-c7a6bfc0e28c"
/>

### 设置项与菜单
- `MAC_菜单控制.png`
<img width="1278" height="909" alt="MAC_菜单控制"
src="https://github.com/user-attachments/assets/520da1b5-af59-4f1a-ba5d-36abdc03ef60"
/>

- `MAC_菜单控制_Dark.png`
<img width="1119" height="861" alt="MAC_菜单控制_Dark"
src="https://github.com/user-attachments/assets/b21af50e-b583-4895-b316-cc21b7498a51"
/>

- `MAC_恢复默认.png`
<img width="1526" height="922" alt="MAC_恢复默认"
src="https://github.com/user-attachments/assets/0129f69d-b2ca-45eb-847a-6b6cb37ca576"
/>

### 原生窗口控制效果
- `MAC_窗口组件原生控制.png`
<img width="1236" height="849" alt="MAC_窗口组件原生控制"
src="https://github.com/user-attachments/assets/003dba09-d0a8-4999-8241-f2d1db078d1b"
/>

- `MAC_窗口组件原生控制2.png`
<img width="1920" height="834" alt="MAC_窗口组件原生控制2"
src="https://github.com/user-attachments/assets/241c94a6-955f-41f8-9b1d-b9a40d0a5251"
/>

- `MAC_切换后变化.png`
<img width="1920" height="1080" alt="MAC_切换后变化"
src="https://github.com/user-attachments/assets/52d8977b-9c64-4413-85d9-f94bdcdc0e53"
/>

### 全屏、快捷键与 Space 行为
- `MAC_快捷键.png`
<img width="1227" height="846" alt="MAC_快捷键"
src="https://github.com/user-attachments/assets/2972cee3-3621-42f1-bd05-1e24eaded5ef"
/>

- `MAC_支持SPACE切换.png`
<img width="353" height="251" alt="MAC_支持SPACE切换"
src="https://github.com/user-attachments/assets/044974a6-64c4-4d0c-8ba9-3445af77f8e4"
/>

- `MAC_最大化.png`
<img width="1920" height="1079" alt="MAC_最大化"
src="https://github.com/user-attachments/assets/dbdf4cd4-0abd-4142-9c81-08c8c23af73b"
/>

### 模糊与透明效果
- `MAC_模糊与透明.png`
<img width="1267" height="954" alt="MAC_模糊与透明"
src="https://github.com/user-attachments/assets/f5a3a377-805e-4d5f-a3f0-fa588d77d9f7"
/>

- `MAC_模糊与透明_全屏.png`
<img width="1920" height="1080" alt="MAC_模糊与透明_全屏"
src="https://github.com/user-attachments/assets/e20642ba-b828-47d0-a154-c23a7b15643d"
/>

### 其他窗口行为
- `MAC_窗口最小化.png`
<img width="276" height="129" alt="MAC_窗口最小化"
src="https://github.com/user-attachments/assets/d7f758a0-072e-4c47-95e6-9539075f1d71"
/>

- `MAC_设置启动全屏-重新打开.png`
<img width="1920" height="1080" alt="MAC_设置启动全屏-重新打开"
src="https://github.com/user-attachments/assets/b033d102-5062-46cb-9c41-c6fe330df007"
/>

### Windows 回归验证
- `WINDOWS_菜单.png`
<img width="1920" height="1040" alt="WINDOWS_菜单"
src="https://github.com/user-attachments/assets/3a295470-c1c6-42f5-a265-2cd38e9224cf"
/>


- `WINDOWS_全屏.png`
<img width="1920" height="1040" alt="WINDOWS_全屏"
src="https://github.com/user-attachments/assets/b254dc81-0c42-4024-9f91-3e029bfe29a0"
/>

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

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

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

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

- 表头自定义组件保留 width,virtual 模式下选择列正常显示
- 新增后端 ExportQuery,导出当前页/选中行避免长字段 IPC 截断
- 筛选支持更多操作符并统一 WHERE 生成逻辑
Close #57
Close #56

 feat(table-edit): 增加整行编辑面板,提升多字段/长文本编辑效率

- 支持选中行后一键打开编辑面板
- 全字段可编辑,长文本/JSON 友好输入与弹窗编辑
- 应用后写入本地变更,提交事务后落库

️ perf(table): 表数据打开加速,主键/统计等耗时操作异步化

- DataViewer 主键列元数据异步拉取,首屏数据优先渲染
- 查询页增加结果集最大行数限制,减少大表全量返回
- DBQuery 引入 Context 超时,降低长查询对 UI 的阻塞风险
- 查询行数设置持久化保存
Closes #48 

 feat(db-ui): 修复金仓打开表报错并增强结果页编辑体验

- postgres/kingbase 查询前自动清洗 ""ident"" 形式的非法标识符
- 结果表支持单元格弹窗编辑,提升 JSON/长文本可编辑性
- 修复查询结果表头与数据列宽度不对齐问题
Closes #49
2026-02-04 12:30:42 +08:00
Syngnat
213a33e4f3 Merge pull request #58 from Syngnat/feature/table-and-database-export-20260203-ygf
Feature/table and database export 20260203 ygf
2026-02-04 12:29:33 +08:00
Syngnat
a00f87582d 🐛 fix(table): 修复虚拟表全选丢失并完善导出/筛选能力
- 表头自定义组件保留 width,virtual 模式下选择列正常显示
  - 新增后端 ExportQuery,导出当前页/选中行避免长字段 IPC 截断
  - 筛选支持更多操作符并统一 WHERE 生成逻辑
  Close #57
  Close #56
2026-02-04 12:23:41 +08:00
Syngnat
f129623000 feat(table-edit): 增加整行编辑面板,提升多字段/长文本编辑效率
- 支持选中行后一键打开编辑面板
  - 全字段可编辑,长文本/JSON 友好输入与弹窗编辑
  - 应用后写入本地变更,提交事务后落库
2026-02-04 11:43:47 +08:00
Syngnat
8dbc97e466 ️ perf(table): 表数据打开加速,主键/统计等耗时操作异步化
- DataViewer 主键列元数据异步拉取,首屏数据优先渲染
  - 查询页增加结果集最大行数限制,减少大表全量返回
  - DBQuery 引入 Context 超时,降低长查询对 UI 的阻塞风险
  - 查询行数设置持久化保存
  Closes #48
  Closes #49
2026-02-04 11:01:28 +08:00
Syngnat
4a0db185c0 feat(db-ui): 修复金仓打开表报错并增强结果页编辑体验
- postgres/kingbase 查询前自动清洗 ""ident"" 形式的非法标识符
  - 结果表支持单元格弹窗编辑,提升 JSON/长文本可编辑性
  - 修复查询结果表头与数据列宽度不对齐问题
2026-02-04 10:13:02 +08:00
Syngnat
5793f63ac8 ️ optimize(core): 查询多语句多结果与大表交互/元数据体验优化
- 支持分号多语句拆分(含引号/注释/PG dollar-quote),多结果集 Tab 展示;
- 支持选中运行;结果 Tab 支持关闭
- 修复结果区高度自动收缩/最后一行裁剪;切换结果更顺滑(关闭 ink-bar 动画、修复隐藏面板叠加显示)
- 补齐 PostgreSQL/SQLite 设计表元数据接口;
- 修复 Kingbase schema/标识符引用导致打开表失败
- 标签页右键支持关闭其他/关闭左侧/关闭右侧/关闭所有
2026-02-03 22:48:24 +08:00
Syngnat
8aabc67634 Merge pull request #46 from Syngnat/feature/table-and-database-export-20260203-ygf
- 支持分号多语句拆分(含引号/注释/PG dollar-quote),多结果集 Tab 展示;
- 支持选中运行;结果 Tab 支持关闭
- 修复结果区高度自动收缩/最后一行裁剪;切换结果更顺滑(关闭 ink-bar 动画、修复隐藏面板叠加显示)
- 补齐 PostgreSQL/SQLite 设计表元数据接口;
- 修复 Kingbase schema/标识符引用导致打开表失败
- 标签页右键支持关闭其他/关闭左侧/关闭右侧/关闭所有
2026-02-03 22:46:25 +08:00
杨国锋
34c494ce51 ️ optimize(core): 查询多语句多结果与大表交互/元数据体验优化
- 支持分号多语句拆分(含引号/注释/PG dollar-quote),多结果集 Tab 展示;
  - 支持选中运行;结果 Tab 支持关闭
  - 修复结果区高度自动收缩/最后一行裁剪;切换结果更顺滑(关闭 ink-bar 动画、修复隐藏面板叠加显示)
  - 补齐 PostgreSQL/SQLite 设计表元数据接口;
  - 修复 Kingbase schema/标识符引用导致打开表失败
  - 标签页右键支持关闭其他/关闭左侧/关闭右侧/关闭所有
2026-02-03 22:44:48 +08:00
Syngnat
178de02783 Merge pull request #45 from bengbengbalabalabeng/chore-add-issues-templates
- 新增 issues template 以统一 issue 类型
2026-02-03 22:39:39 +08:00
baicaixiaozhan
94e5b8d2c6 chore: add Github issues templates 2026-02-03 21:49:43 +08:00
杨国锋
89e2247c05 feat(database): 增强库/表级导出与备份能力,优化侧边栏交互
- 数据库节点新增导出全部表结构/结构+数据 SQL(ExportDatabaseSQL)
  - 表节点支持多选/单选右键导出与备份(ExportTablesSQL)
  - ExportTable 支持导出 SQL(结构+数据)
  - 双击表仅打开表数据,不再触发展开/折叠
2026-02-03 19:49:04 +08:00
Syngnat
b2ede61b79 Merge pull request #43 from Syngnat/feature/0.2.3
️ perf(frontend): 大数据表格拖拽与打开加载性能、增加数据同步差异对比、行级选择
2026-02-03 19:23:49 +08:00
Syngnat
db381ae9d1 Merge pull request #42 from Syngnat/dev
️ perf(frontend): 大数据表格拖拽与打开加载性能、增加数据同步差异对比、行级选择
2026-02-03 19:23:15 +08:00
Syngnat
f946cfd647 Merge pull request #41 from Syngnat/feature/data-sync-optimization-20260203-ygf
️ perf(frontend): 大数据表格拖拽与打开加载性能、增加数据同步差异对比、行级选择
2026-02-03 19:21:29 +08:00
杨国锋
46c48c5ea8 ️ perf(frontend): 大数据表格拖拽与打开加载性能
- 列宽拖拽改为 rAF + transform 更新幽灵线,降低 mousemove 负载
- 大结果集自动启用 antd Table virtual 渲染,减少 DOM 压力
- 打开表改为先查数据,COUNT(*) 后台统计并回填分页总数,避免长时间 loading
- 统一内部 rowKey 字段 __gonavi_row_key__,避免与业务字段 key 冲突
2026-02-03 19:16:10 +08:00
杨国锋
e3bf160072 feat(sync): 数据同步支持差异对比、行级选择与实时进度日志
- 新增差异分析/预览接口与前端预览抽屉(插入/更新/删除)
  - 支持按表勾选插入/更新/删除(删除默认不勾选)
  - 支持按主键选择行级同步;无主键/复合主键表跳过并提示
  - 同步过程实时输出中文日志与进度条,便于定位失败步骤
2026-02-03 17:37:41 +08:00
Syngnat
791425a5a8 🐛 fix(db): 适配 schema/owner 限定名,修复 PG/金仓表不存在,修复表格数据显示异常
- 覆盖 mysql/postgres/kingbase/oracle/dameng/sqlite/custom 的 Query 返回值转换
- 修正可编辑表格保存范围,避免状态残留影响显示
- 表列表返回 schema.table/owner.table,避免 search_path 不一致导致 relation does not exist
- 元数据/导入导出/提交变更统一解析限定名并正确引用
- 前端查询与数据浏览支持限定名 quote
- 单元格编辑态时间字段统一显示为 YYYY-MM-DD HH:mm:ss
close #36
2026-02-03 14:39:05 +08:00
Syngnat
d7acfd1af9 🐛 fix(db): 适配 schema/owner 限定名,修复 PG/金仓表不存在,修复表格数据显示异常
- 覆盖 mysql/postgres/kingbase/oracle/dameng/sqlite/custom 的 Query 返回值转换
- 修正可编辑表格保存范围,避免状态残留影响显示
- 表列表返回 schema.table/owner.table,避免 search_path 不一致导致 relation does not exist
- 元数据/导入导出/提交变更统一解析限定名并正确引用
- 前端查询与数据浏览支持限定名 quote
- 单元格编辑态时间字段统一显示为 YYYY-MM-DD HH:mm:ss
close #36
2026-02-03 14:38:05 +08:00
Syngnat
80fbfd6365 Merge pull request #37 from Syngnat/feature/extend-datasource-and-sync-20250202-ygf
🐛 fix(db): 适配 schema/owner 限定名,修复 PG/金仓表不存在,修复表格数据显示异常
2026-02-03 14:35:13 +08:00
Syngnat
88952e87c1 Merge pull request #35 from Syngnat/release/0.2.1
- 前端改用通用 DB API,避免强制走 MySQL 接口导致 PostgreSQL 等连接异常
- 后端统一各数据源 timeout(Ping 超时 + 连接参数注入)
- DSN 生成兼容特殊字符密码(Postgres/Oracle/达梦/金仓)
- 增加文件日志与错误链输出,连接失败提示日志路径便于排障
2026-02-03 12:27:39 +08:00
Syngnat
99f947e577 🐛 fix(connection): 修复多数据源连接测试成功但实际失败,closes #23
- 前端改用通用 DB API,避免强制走 MySQL 接口导致 PostgreSQL 等连接异常
- 后端统一各数据源 timeout(Ping 超时 + 连接参数注入)
- DSN 生成兼容特殊字符密码(Postgres/Oracle/达梦/金仓)
- 增加文件日志与错误链输出,连接失败提示日志路径便于排障
2026-02-03 12:24:55 +08:00
Syngnat
c981a65834 Merge pull request #32 from Syngnat/release/0.2.0
Release/0.2.0
2026-02-03 08:53:42 +08:00
Syngnat
b9d9ab5464 Merge pull request #31 from Syngnat/dev
docs: improve image layout
2026-02-03 08:53:14 +08:00
Syngnat
6b503480cf fix: badges display error in readme
- 修复README中徽章显示问题
2026-02-02 21:14:33 +08:00
676 changed files with 181860 additions and 2915 deletions

View File

@@ -0,0 +1,58 @@
name: 问题反馈
description: 软件问题反馈
title: "[Bug] "
labels: ["bug"]
body:
- type: checkboxes
id: searched
attributes:
label: 已经搜索过 Issues未发现重复问题*
options:
- label: 我已经搜索过 Issues没有发现重复问题
validations:
required: true
- type: input
id: system
attributes:
label: 操作系统及版本
placeholder: Windows 10 22H2 / macOS Mojave / Linux
validations:
required: true
- type: input
id: version
attributes:
label: 软件安装版本
placeholder: v0.2.3
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题简述及复现流程
description: 请详细描述你遇到的问题,并提供复现步骤
placeholder: |
1. 打开软件
2. 点击 xxx
3. 预期结果是 ...
4. 实际结果是 ...
5. 截图 ...
validations:
required: true
- type: textarea
id: extra
attributes:
label: 其他补充
description: 如果你有额外信息,请在此填写
placeholder: 可选
- type: checkboxes
id: pr
attributes:
label: 是否愿意提交 PR 修复当前 Issue
options:
- label: 我愿意尝试提交 PR

View File

@@ -0,0 +1,37 @@
name: 功能建议
description: 添加全新功能或改进现有功能
title: "[Enhancement] "
labels: ["enhancement"]
body:
- type: checkboxes
id: searched
attributes:
label: 已经搜索过 Issues未发现重复问题*
options:
- label: 我已经搜索过 Issues没有发现重复问题
validations:
required: true
- type: textarea
id: feature
attributes:
label: 功能描述
description: 请详细描述你希望添加或改进的功能
placeholder: 请描述你想要的功能
validations:
required: true
- type: textarea
id: extra
attributes:
label: 其他补充
description: 如果你有额外信息,请在此填写
placeholder: 可选
- type: checkboxes
id: pr
attributes:
label: 是否愿意提交 PR 实现当前 Issue
options:
- label: 我愿意尝试提交 PR

30
.github/ISSUE_TEMPLATE/03-generic.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: 其他反馈
description: 其他类型反馈、建议或讨论
title: "[Question] "
labels: ["question"]
body:
- type: checkboxes
id: searched
attributes:
label: 已经搜索过 Issues未发现重复问题*
options:
- label: 我已经搜索过 Issues没有发现重复问题
validations:
required: true
- type: textarea
id: content
attributes:
label: 内容
description: 请填写你的反馈、建议或讨论内容
placeholder: 请描述你的问题或想法
validations:
required: true
- type: textarea
id: extra
attributes:
label: 其他补充
description: 如果你有额外信息,请在此填写
placeholder: 可选

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

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:
- '*'

708
.github/workflows/dev-build.yml vendored Normal file
View File

@@ -0,0 +1,708 @@
name: Dev Build
on:
push:
branches:
- dev
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
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-build-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-build-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-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"
- 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
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"
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
# ---- 生成 dev 版本号 ----
- name: Generate Dev Version
id: version
shell: bash
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
DEV_VERSION="dev-${SHORT_SHA}"
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
echo "📌 Dev 版本号: ${DEV_VERSION}"
- name: Build
shell: bash
run: |
set -euo pipefail
DEV_VERSION="${{ steps.version.outputs.version }}"
./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}"
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=${DEV_VERSION}"
else
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_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 oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
prepare_duckdb_windows_library() {
local lib_dir="$RUNNER_TEMP/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
local zip_path="$RUNNER_TEMP/libduckdb-windows-amd64.zip"
if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/duckdb.lib" ]; then
echo "$lib_dir"
return 0
fi
mkdir -p "$lib_dir"
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
unzip -qo "$zip_path" -d "$lib_dir"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
echo "$lib_dir"
}
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"
BUILD_TAGS="$TAG"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
DUCKDB_LIB_DIR=""
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then
DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)"
BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib"
fi
echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})"
if [ "$DRIVER" = "duckdb" ]; then
if [ -n "$DUCKDB_LIB_DIR" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="-L${DUCKDB_LIB_DIR} -lduckdb" PATH="${DUCKDB_LIB_DIR}:$PATH" go build \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll"
else
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
fi
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${BUILD_TAGS}" \
-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="${{ steps.version.outputs.version }}"
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 压缩,保留原始主程序。"
echo "🔏 正在进行 Ad-hoc 签名..."
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$APP_NAME" || true
fi
codesign --force --deep --sign - "$APP_NAME"
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 \
--volname "GoNavi Dev Build" \
--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"
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dev-verify.XXXXXX")
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$PACKAGED_APP" ]; then
echo "❌ DMG 内未找到 .app 应用包!"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
exit 1
fi
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
mv "$DMG_NAME" "../../$FINAL_NAME"
# Windows Packaging
- name: Package Windows EXE
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
Set-Location build/bin
$version = "${{ steps.version.outputs.version }}"
$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
- name: Package Linux
if: contains(matrix.platform, 'linux')
run: |
VERSION="${{ steps.version.outputs.version }}"
cd build/bin
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
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
echo "📦 正在打包 $TAR_NAME..."
tar -czvf "$TAR_NAME" "$TARGET"
mv "$TAR_NAME" ../../
if [ -f /tmp/skip-appimage ]; then
echo "⚠️ 跳过 AppImage 打包"
exit 0
fi
echo "📦 正在生成 AppImage..."
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
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
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
export DEPLOY_GTK_VERSION=3
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || {
echo "⚠️ AppImage 生成失败,但 tar.gz 已成功生成"
exit 0
}
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || {
echo "⚠️ AppImage 重命名失败"
exit 0
}
if [ -f "$APPIMAGE_NAME" ]; then
mv "$APPIMAGE_NAME" ../../
echo "✅ AppImage 生成成功"
fi
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: dev-build-artifacts-${{ strategy.job-index }}
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**
retention-days: 7
# 汇总所有产物并发布为 Pre-release
release:
name: Publish Dev Pre-release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download All Artifacts
uses: actions/download-artifact@v4
with:
path: release-assets
pattern: dev-build-artifacts-*
merge-multiple: true
- name: List Assets
run: ls -R release-assets
- 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
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: Generate Dev Version
id: version
run: |
SHORT_SHA="${GITHUB_SHA:0:7}"
DEV_VERSION="dev-${SHORT_SHA}"
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
- name: Format Build Time
id: build_time
shell: bash
run: |
python3 - <<'PY' >> "$GITHUB_OUTPUT"
from datetime import datetime, timezone, timedelta
raw = "${{ github.event.head_commit.timestamp }}"
dt = datetime.fromisoformat(raw)
china_tz = timezone(timedelta(hours=8))
formatted = dt.astimezone(china_tz).strftime("%Y-%m-%d %H:%M:%S")
print(f"display={formatted}")
PY
# 删除旧的 dev pre-release保持只有最新一个
- name: Reset Previous Dev Release
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const tag = 'dev-latest';
const ref = `tags/${tag}`;
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const matchedReleases = releases.filter((release) => release.tag_name === tag);
if (matchedReleases.length === 0) {
core.info(`No existing releases found for tag ${tag}`);
} else {
for (const release of matchedReleases) {
core.info(`Deleting release ${release.id} (${release.name || 'unnamed'}) for tag ${tag}`);
await github.rest.repos.deleteRelease({
owner,
repo,
release_id: release.id,
});
}
}
try {
await github.rest.git.deleteRef({
owner,
repo,
ref,
});
core.info(`Deleted ref ${ref}`);
} catch (error) {
if (error.status === 404) {
core.info(`No existing ref found for ${ref}`);
} else {
throw error;
}
}
- name: Create Dev Pre-release
uses: softprops/action-gh-release@v2
with:
tag_name: dev-latest
name: "🧪 Dev Build (${{ steps.version.outputs.version }})"
target_commitish: ${{ github.sha }}
files: release-assets/*
prerelease: true
draft: false
body: |
## 🧪 测试版本 (Dev Build)
**版本**: `${{ steps.version.outputs.version }}`
**分支**: `dev`
**提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
**构建时间**: ${{ steps.build_time.outputs.display }}
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
> 每次 push 到 `dev` 分支会自动覆盖此 release。
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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,244 @@ 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
./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}"
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 oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
prepare_duckdb_windows_library() {
local lib_dir="$RUNNER_TEMP/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
local zip_path="$RUNNER_TEMP/libduckdb-windows-amd64.zip"
if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/duckdb.lib" ]; then
echo "$lib_dir"
return 0
fi
mkdir -p "$lib_dir"
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
unzip -qo "$zip_path" -d "$lib_dir"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
echo "$lib_dir"
}
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"
BUILD_TAGS="$TAG"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
DUCKDB_LIB_DIR=""
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then
DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)"
BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib"
fi
echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})"
if [ "$DRIVER" = "duckdb" ]; then
if [ -n "$DUCKDB_LIB_DIR" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="-L${DUCKDB_LIB_DIR} -lduckdb" PATH="${DUCKDB_LIB_DIR}:$PATH" go build \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll"
else
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
fi
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${BUILD_TAGS}" \
-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 +337,24 @@ 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。
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$APP_NAME" || true
fi
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 \
@@ -83,36 +367,180 @@ jobs:
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-release-verify.XXXXXX")
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$PACKAGED_APP" ]; then
echo "❌ DMG 内未找到 .app 应用包!"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
exit 1
fi
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
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 +559,177 @@ 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/Windows/duckdb.dll"
"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 +737,6 @@ jobs:
files: release-assets/*
draft: true
make_latest: true
body_path: ${{ steps.changelog.outputs.changelog_file }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

17
.gitignore vendored
View File

@@ -1,12 +1,12 @@
# IDE
.idea/
*.iml
.gitignore
# build / release artifacts
frontend/release/
**/release/
**/dist/
**/build/
build/bin/
# wails / node artifacts (按需)
node_modules/
@@ -17,3 +17,16 @@ dist/
GoNavi-Wails
GoNavi-Wails.exe
.ace-tool/
.superpowers/
.claude/
.gemini/
.playwright-mcp/
**/tmpclaude-*
docs/superpowers/
docs/需求追踪/
CLAUDE.md
**/CLAUDE.md
.worktrees
docs
.tmp_superpowers_edit

143
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,143 @@
# Contributing Guide
Thank you for contributing to this project.
This repository uses `dev` as the default integration branch, while stable releases are published from `main` through `release/*` branches.
---
## Branch Model
- `dev`: default branch and day-to-day integration branch
- `main`: stable release branch
- `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 `dev`**.
Reasons:
- `dev` is the active integration branch, so changes can be reviewed in the same lane as ongoing work
- contributors align with the branch that triggers day-to-day validation and dev builds
- maintainers can cut `release/*` branches from `dev` without re-syncing external changes first
Recommended flow:
1. Fork this repository
2. Sync your fork with `dev` and create a branch from `dev` (`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 `dev` 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 `dev` should generally use **Squash and merge**.
Reasons:
- keeps `dev` history readable and easier to audit during active iteration
- maps each PR to a single integration commit on `dev`
- reduces cherry-pick and conflict cost before creating `release/*`
---
## Maintainer Sync Rules
Because external pull requests are merged directly into `dev`, maintainers should treat `dev` as the source branch for daily collaboration and release preparation.
### 1. 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
```
### 2. 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
```
### 3. Sync `main` back to `dev` after release
After the release, sync `main` back into `dev` so the next iteration starts from the released code line:
```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.

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

@@ -0,0 +1,143 @@
# 贡献指南
感谢你对本项目的贡献。
本项目当前采用“`dev` 作为默认集成分支,`main` 作为稳定发布分支,`release/*` 负责发版准备”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
---
## 分支模型
- `dev`:默认分支,也是日常开发集成分支
- `main`:稳定发布分支
- `release/*`:发布准备分支,主要供维护者使用
- 外部贡献者建议使用以下分支命名:
- `fix/*`:问题修复
- `feature/*`:功能新增或增强
维护者发布流转如下:
```text
feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
```
---
## 外部贡献者如何提 Pull Request
无论是 `fix/*` 还是 `feature/*`**外部贡献者统一直接向 `dev` 发起 Pull Request**。
这样做的原因:
- `dev` 是当前日常集成分支,评审与合入路径和维护者开发流程一致
- 外部贡献会直接进入触发日常校验和 dev 构建的分支
- 维护者可以直接从 `dev``release/*`,减少额外同步步骤
建议流程:
1. Fork 本仓库
2. 先同步你 fork 中的 `dev`,再从 `dev` 创建分支(建议命名为 `fix/*``feature/*`
3. 完成代码修改,并进行必要自检
4. 推送到你的远程分支
5. 向本仓库的 `dev` 分支发起 Pull Request
---
## Pull Request 要求
请尽量保证 PR 单一、清晰、可审核。
建议遵循以下要求:
- 一个 PR 只解决一类问题,避免混入无关改动
- 标题清晰说明改动目的
- 描述中说明:
- 背景与问题
- 变更点
- 影响范围
- 验证方式
- 如涉及 UI 调整,建议附截图或录屏
- 如涉及兼容性、数据变更或构建链路调整,请明确说明风险和回滚方式
---
## PR 合并策略(维护者)
`dev` 分支上的 PR 建议使用 **Squash and merge**
原因:
- 保持 `dev` 集成历史清晰、便于审查
- 每个 PR 在 `dev` 上对应一个明确的集成提交
- 降低发版前整理与冲突处理成本
---
## 维护者同步规则
由于外部 PR 会直接合入 `dev`,维护者应将 `dev` 作为日常协作与发版准备的主线分支。
### 1. 发版前从 dev 切 release/*
发布前由维护者基于 `dev` 创建发布分支,例如:
```bash
git checkout dev
git pull
git checkout -b release/v0.6.0
git push -u origin release/v0.6.0
```
### 2. 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
```
### 3. main 回流到 dev发版后必做
发布完成后,需要将 `main` 回流到 `dev`,确保下一轮开发从已发布代码线继续推进:
```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先对齐方案再实施
- 如提交内容与项目当前架构方向冲突,维护者可能要求收敛范围后再合并
感谢你的贡献。

247
README.md
View File

@@ -1,134 +1,233 @@
# 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)
[![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)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
**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" />
<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" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<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" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
</div>
---
## ✨ 核心特性
## Key Features
### 🚀 极致性能
- **零卡顿交互**:采用独创的 "幽灵拖拽" (Ghost Resizing) 技术,在包含数万行数据的表格中调整列宽,依然保持 60fps+ 的丝滑体验。
- **虚拟滚动**:轻松处理海量数据展示,拒绝卡顿。
### AI Assistant (New)
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
### 🔌 多数据库支持
- **MySQL**:完整的支持,包括表结构设计、索引管理、外键管理等。
- **PostgreSQL**:基础支持(持续完善中)。
- **SQLite**:本地文件数据库支持。
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
### Performance
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
- **Virtualized rendering**: keeps large result sets responsive.
### 📊 强大的数据管理 (DataGrid)
- **所见即所得编辑**:直接在表格中双击单元格修改数据。
- **事务操作**:支持批量新增、修改、删除,一键提交或回滚事务。
- **智能上下文**:自动识别单表查询,解锁编辑功能;复杂查询自动切换为只读模式。
- **数据导出**:支持导出为 CSV, Excel (XLSX), JSON, Markdown 等格式。
### 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.
### 📝 智能 SQL 编辑器
- **Monaco Editor 内核**:集成 VS Code 同款编辑器,体验极佳。
- **智能补全**:自动感知当前连接上下文,提供数据库、表名、字段名的实时补全。
- **多标签页**:支持多窗口并行操作,像浏览器一样管理你的查询会话。
### SQL Editor
- Monaco Editor core.
- Context-aware completion for databases/tables/columns.
- Multi-tab query workflow.
### 🎨 现代化 UI
- **Ant Design 5**:企业级 UI 设计语言。
- **暗黑模式**:内置深色/浅色主题切换,适应不同光照环境。
- **响应式布局**:灵活的侧边栏与布局调整。
### Batch Export / Backup
- Database-level and table-level batch export/backup.
- Scope-aware operation flow to reduce mistakes.
### 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 branch from `dev` and open pull requests against `dev`.
## Star History
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## Links
- [linux.do](https://linux.do/)
- [AIBook](https://aibook.ren/)
## License
Licensed under [Apache-2.0](LICENSE).

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

@@ -0,0 +1,217 @@
# 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)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
**语言**: [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/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
</div>
---
## 核心特性
### AI 智能助手 (New)
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude同时支持任意自定义兼容 OpenAI 格式的 API。
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI让 SQL 生成、分析变得更精准。
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等
### 性能与交互
- 大数据场景下保持流畅交互(含 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)
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
## Star History (Star 增长趋势)
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## 友情链接
- [linux.do](https://linux.do/)
- [AI全书](https://aibook.ren/)
## 开源协议
本项目采用 [Apache-2.0 协议](LICENSE)。

9
assets_dev.go Normal file
View File

@@ -0,0 +1,9 @@
//go:build dev
package main
import "os"
// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS
// 避免编译时依赖 frontend/dist 被并发重建。
var assets = os.DirFS(".")

13
assets_prod.go Normal file
View File

@@ -0,0 +1,13 @@
//go:build !dev
package main
import (
"embed"
"io/fs"
)
//go:embed all:frontend/dist
var embeddedAssets embed.FS
var assets fs.FS = embeddedAssets

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

@@ -0,0 +1,403 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64)
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
DUCKDB_WINDOWS_SUPPORT_DLL="duckdb.dll"
usage() {
cat <<'EOF'
用法:
./build-driver-agents.sh [选项]
选项:
--drivers <列表> 指定驱动列表逗号分隔例如kingbase,mongodb
--platform <目标> 目标平台current、all、GOOS/GOARCH或逗号分隔列表
默认 current当前 Go 环境)
--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
./build-driver-agents.sh --platform all
./build-driver-agents.sh --platform darwin/arm64,windows/amd64,linux/amd64
EOF
}
normalize_driver() {
local name
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$name" in
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
mariadb|oceanbase|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|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
}
current_platform() {
echo "$(go env GOOS)/$(go env GOARCH)"
}
append_platform() {
local candidate
candidate="$1"
if [[ "$platform_seen" == *"|$candidate|"* ]]; then
return 0
fi
platforms+=("$candidate")
platform_seen="${platform_seen}${candidate}|"
}
normalize_platform() {
local value goos goarch platform_dir
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
current|"")
current_platform
;;
*/*)
goos="${value%%/*}"
goarch="${value##*/}"
platform_dir="$(platform_dir_name "$goos")"
if [[ -z "$goos" || -z "$goarch" || "$platform_dir" == "Unknown" ]]; then
return 1
fi
echo "$goos/$goarch"
;;
*)
return 1
;;
esac
}
zip_bundle() {
local bundle_zip_path="$1"
local bundle_stage_dir="$2"
local -a bundle_dirs=()
local dir
for dir in "$bundle_stage_dir"/*; do
[[ -d "$dir" ]] || continue
bundle_dirs+=("$(basename "$dir")")
done
if [[ ${#bundle_dirs[@]} -eq 0 ]]; then
echo "❌ 驱动总包 staging 目录为空。"
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" "${bundle_dirs[@]}"
)
elif command -v python3 >/dev/null 2>&1; then
BUNDLE_STAGE_DIR="$bundle_stage_dir" BUNDLE_ZIP_PATH="$bundle_zip_path" python3 - <<'PY'
import os
import zipfile
from pathlib import Path
stage = Path(os.environ["BUNDLE_STAGE_DIR"])
target = Path(os.environ["BUNDLE_ZIP_PATH"])
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in stage.rglob("*"):
if path.is_file():
zf.write(path, path.relative_to(stage).as_posix())
PY
else
echo "❌ 未找到 zip 或 python3无法生成驱动总包 zip。"
exit 1
fi
}
prepare_duckdb_windows_library() {
local cache_root="$1"
local lib_dir="$cache_root/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
local zip_path="$cache_root/libduckdb-windows-amd64.zip"
if [[ -f "$lib_dir/duckdb.dll" && -f "$lib_dir/duckdb.lib" ]]; then
printf '%s\n' "$lib_dir"
return 0
fi
mkdir -p "$lib_dir"
echo "⬇️ 下载 DuckDB Windows 官方动态库:$DUCKDB_WINDOWS_LIBRARY_URL" >&2
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
elif command -v wget >/dev/null 2>&1; then
wget -q "$DUCKDB_WINDOWS_LIBRARY_URL" -O "$zip_path"
else
echo "❌ 未找到 curl 或 wget无法下载 DuckDB Windows 动态库。" >&2
return 1
fi
if command -v unzip >/dev/null 2>&1; then
unzip -qo "$zip_path" -d "$lib_dir"
elif command -v python3 >/dev/null 2>&1; then
DUCKDB_LIB_ZIP="$zip_path" DUCKDB_LIB_DIR="$lib_dir" python3 - <<'PY'
import os
import zipfile
zip_path = os.environ["DUCKDB_LIB_ZIP"]
target = os.environ["DUCKDB_LIB_DIR"]
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(target)
PY
else
echo "❌ 未找到 unzip 或 python3无法解压 DuckDB Windows 动态库。" >&2
return 1
fi
if [[ ! -f "$lib_dir/duckdb.dll" || ! -f "$lib_dir/duckdb.lib" ]]; then
echo "❌ DuckDB Windows 动态库包缺少 duckdb.dll 或 duckdb.lib。" >&2
return 1
fi
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
printf '%s\n' "$lib_dir"
}
join_by_comma() {
local IFS=,
echo "$*"
}
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
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
revision_driver_csv="$(join_by_comma "${drivers[@]}")"
declare -a platforms=()
platform_seen="|"
if [[ -z "$target_platform" ]]; then
target_platform="current"
fi
IFS=',' read -r -a raw_platforms <<<"$target_platform"
for item in "${raw_platforms[@]}"; do
normalized_platform="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
if [[ "$normalized_platform" == "all" ]]; then
for default_platform in "${DEFAULT_PLATFORMS[@]}"; do
append_platform "$default_platform"
done
continue
fi
normalized_platform="$(normalize_platform "$item")" || {
echo "❌ --platform 参数格式错误,应为 current、all、GOOS/GOARCH 或逗号分隔列表,例如 darwin/arm64,windows/amd64"
exit 1
}
append_platform "$normalized_platform"
done
if [[ ${#platforms[@]} -eq 0 ]]; then
echo "❌ 未指定有效目标平台。"
exit 1
fi
mkdir -p "$out_root"
out_root_abs="$(cd "$out_root" && pwd)"
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
cleanup() {
rm -rf "$bundle_stage_dir"
}
trap cleanup EXIT
if [[ ${#platforms[@]} -eq 1 ]]; then
single_platform="${platforms[0]}"
single_platform_key="${single_platform/\//-}"
single_output_dir="${out_root%/}/$single_platform_key"
mkdir -p "$single_output_dir"
bundle_zip_path="$(cd "$single_output_dir" && pwd)/$bundle_name"
else
bundle_zip_path="$out_root_abs/$bundle_name"
fi
declare -a built_assets=()
declare -a failed_drivers=()
declare -a skipped_drivers=()
echo "🚀 开始构建 optional-driver-agent"
echo " 平台:${platforms[*]}"
echo " 输出根目录:$out_root_abs"
echo " 驱动列表:${drivers[*]}"
for platform in "${platforms[@]}"; do
goos="${platform%%/*}"
goarch="${platform##*/}"
platform_key="${goos}-${goarch}"
platform_dir="$(platform_dir_name "$goos")"
output_dir="${out_root%/}/${platform_key}"
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
mkdir -p "$output_dir" "$bundle_platform_dir"
output_dir_abs="$(cd "$output_dir" && pwd)"
echo ""
echo "🧭 生成 driver-agent revision 指纹:$platform"
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$platform" --drivers "$revision_driver_csv"
for driver in "${drivers[@]}"; do
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
echo "⚠️ 跳过 duckdb$platform 仅支持 windows/amd64"
skipped_drivers+=("duckdb($platform)")
continue
fi
build_driver="$(build_driver_name "$driver")"
tag="gonavi_${build_driver}_driver"
build_tags="$tag"
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
duckdb_lib_dir=""
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" == "amd64" ]]; then
duckdb_lib_dir="$(prepare_duckdb_windows_library "$bundle_stage_dir")"
build_tags="$build_tags duckdb_use_lib"
fi
echo "🔧 构建 $driver -> $asset_name (platform=$platform, tags=$build_tags, CGO_ENABLED=$cgo_enabled)"
set +e
if [[ -n "$duckdb_lib_dir" ]]; then
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
CGO_LDFLAGS="-L${duckdb_lib_dir} -lduckdb" PATH="${duckdb_lib_dir}:$PATH" \
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
else
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
fi
build_exit=$?
set -e
if [[ $build_exit -ne 0 ]]; then
echo "❌ 构建失败:$driver ($platform)"
failed_drivers+=("$driver($platform)")
if [[ "$strict_mode" == "true" ]]; then
exit $build_exit
fi
continue
fi
cp "$output_path" "$bundle_platform_dir/$asset_name"
if [[ -n "$duckdb_lib_dir" ]]; then
cp "$duckdb_lib_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" "$output_dir_abs/$DUCKDB_WINDOWS_SUPPORT_DLL"
cp "$duckdb_lib_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" "$bundle_platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL"
built_assets+=("$platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL")
fi
built_assets+=("$platform_dir/$asset_name")
done
done
if [[ ${#built_assets[@]} -eq 0 ]]; then
echo "❌ 未成功构建任何驱动代理。"
exit 1
fi
zip_bundle "$bundle_zip_path" "$bundle_stage_dir"
echo ""
echo "✅ 构建完成"
echo " 单文件输出根目录:$out_root_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

@@ -1,17 +1,44 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 配置
APP_NAME="GoNavi"
DIST_DIR="dist"
BUILD_BIN_DIR="build/bin"
DEFAULT_BINARY_NAME="GoNavi" # 对应 wails.json 中的 outputfilename
DEV_VERSION_FILE="version/dev-version.txt"
DEFAULT_DEV_VERSION="0.0.1-test"
# 提取版本号
VERSION=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')
if [ -z "$VERSION" ]; then
VERSION="0.0.0"
fi
resolve_build_version() {
if [ -n "${GONAVI_VERSION:-}" ]; then
printf '%s\n' "${GONAVI_VERSION}"
return
fi
if [ -f "$DEV_VERSION_FILE" ]; then
local dev_version
dev_version=$(head -n 1 "$DEV_VERSION_FILE" | tr -d '\r' | tr -d '[:space:]')
if [ -n "$dev_version" ]; then
printf '%s\n' "$dev_version"
return
fi
fi
local package_version
package_version=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[:space:]')
if [ -n "$package_version" ]; then
printf '%s\n' "$package_version"
return
fi
printf '%s\n' "$DEFAULT_DEV_VERSION"
}
VERSION="$(resolve_build_version)"
echo " 检测到版本号: $VERSION"
LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION"
# 颜色配置
GREEN='\033[0;32m'
@@ -19,131 +46,346 @@ RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
BUILD_FAILURES=()
record_build_failure() {
local target="$1"
BUILD_FAILURES+=("$target")
}
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
}
clear_macos_bundle_xattrs() {
local bundle_path="$1"
if [ -z "$bundle_path" ] || [ ! -e "$bundle_path" ]; then
return
fi
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$bundle_path" >/dev/null 2>&1 || true
fi
}
package_macos_bundle_zip() {
local app_path="$1"
local archive_path="$2"
local archive_abs
if [ ! -d "$app_path" ]; then
echo -e "${RED} ❌ 未找到 macOS 应用包:$app_path${NC}"
exit 1
fi
archive_abs="$(cd "$(dirname "$archive_path")" && pwd)/$(basename "$archive_path")"
rm -f "$archive_path"
if command -v ditto >/dev/null 2>&1; then
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$archive_abs"
elif command -v zip >/dev/null 2>&1; then
(
cd "$(dirname "$app_path")" && \
zip -qry "$archive_abs" "$(basename "$app_path")"
)
else
echo -e "${RED} ❌ 未找到 ditto/zip无法打包 macOS 应用。${NC}"
exit 1
fi
if [ ! -f "$archive_abs" ]; then
echo -e "${RED} ❌ macOS 应用归档失败:$archive_abs${NC}"
exit 1
fi
}
package_macos_release() {
local platform="$1"
local archive_suffix="$2"
echo -e "${GREEN}🍎 正在构建 macOS (${platform})...${NC}"
generate_driver_agent_revisions "darwin/${platform}"
wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS"
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}"
record_build_failure "macOS ${platform}"
return
fi
local app_src="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
local app_dest_name="${APP_NAME}-${VERSION}-${archive_suffix}.app"
local zip_name="${APP_NAME}-${VERSION}-${archive_suffix}.zip"
mv "$app_src" "$DIST_DIR/$app_dest_name"
local app_bin_path
app_bin_path=$(find "$DIST_DIR/$app_dest_name/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -z "$app_bin_path" ] || [ ! -f "$app_bin_path" ]; then
echo -e "${RED} ❌ 未找到 macOS ${platform} 主程序文件。${NC}"
exit 1
fi
echo -e "${YELLOW} ⚠️ macOS ${platform} 改为无交互 ZIP 打包,不再生成 DMG。${NC}"
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (${platform})..."
clear_macos_bundle_xattrs "$DIST_DIR/$app_dest_name"
codesign --force --deep --sign - "$DIST_DIR/$app_dest_name"
echo " 📦 正在打包 macOS 应用归档 (${platform})..."
package_macos_bundle_zip "$DIST_DIR/$app_dest_name" "$DIST_DIR/$zip_name"
rm -rf "$DIST_DIR/$app_dest_name"
echo " ✅ 已生成 $zip_name"
}
generate_driver_agent_revisions() {
local platform="$1"
echo " 🧭 正在生成 driver-agent revision 指纹 (${platform})..."
./tools/generate-driver-agent-revisions.sh --platform "$platform"
}
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
# 清理并创建输出目录
rm -rf $DIST_DIR
mkdir -p $DIST_DIR
rm -rf "$DIST_DIR"
mkdir -p "$DIST_DIR"
# --- macOS ARM64 构建 ---
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
wails build -platform darwin/arm64 -clean
if [ $? -eq 0 ]; then
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
DMG_NAME="${APP_NAME}-${VERSION}-mac-arm64.dmg"
# 移动 .app 到 dist
mv "$APP_SRC" "$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 \
--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
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}"
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
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
else
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具。${NC}"
fi
else
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
fi
package_macos_release "arm64" "mac-arm64"
package_macos_release "amd64" "mac-amd64"
# --- 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
generate_driver_agent_revisions "windows/amd64"
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}"
record_build_failure "Windows amd64"
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
generate_driver_agent_revisions "windows/arm64"
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}"
record_build_failure "Windows arm64"
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直接构建
generate_driver_agent_revisions "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}"
record_build_failure "Linux amd64"
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
generate_driver_agent_revisions "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 可执行文件"
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}"
record_build_failure "Linux amd64"
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直接构建
generate_driver_agent_revisions "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}"
record_build_failure "Linux arm64"
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
generate_driver_agent_revisions "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}"
record_build_failure "Linux arm64"
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}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
ls -1 "$DIST_DIR"
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 ""
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}"
echo -e "${YELLOW}📦 已成功生成的产物在 'dist/' 目录下:${NC}"
else
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
fi
ls -lh "$DIST_DIR"
echo ""
echo -e "${GREEN}📋 支持的平台:${NC}"
echo " • macOS (Intel/Apple Silicon): .zip"
echo " • Windows (x64/ARM64): .exe"
echo " • Linux (x64/ARM64): .tar.gz"
echo ""
echo -e "${YELLOW}💡 提示Linux AppImage 包请使用 GitHub Actions CI/CD 构建。${NC}"
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
exit 1
fi

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>

339
cmd/manualtestseed/main.go Normal file
View File

@@ -0,0 +1,339 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"GoNavi-Wails/internal/ai"
aiservice "GoNavi-Wails/internal/ai/service"
"GoNavi-Wails/internal/app"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
const (
modeSeedSecureStorage = "seed-secure-storage"
modeSeedAIUpdate = "seed-ai-update"
)
const (
testConnectionID = "manualtest-postgres"
testSecureProviderID = "manualtest-secure-provider"
testPendingProviderID = "manualtest-pending-provider"
testBackupDirName = "manual-test-backups"
connectionsFileName = "connections.json"
globalProxyFileName = "global_proxy.json"
aiConfigFileName = "ai_config.json"
securityUpdateFileName = "config-security-update.json"
)
type backupManifest struct {
CreatedAt string `json:"createdAt"`
ConfigDir string `json:"configDir"`
Files []backupManifestFile `json:"files"`
}
type backupManifestFile struct {
RelativePath string `json:"relativePath"`
Existed bool `json:"existed"`
}
type storedAIConfig struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Providers []ai.ProviderConfig `json:"providers"`
ActiveProvider string `json:"activeProvider"`
SafetyLevel string `json:"safetyLevel"`
ContextLevel string `json:"contextLevel"`
}
func main() {
mode := flag.String("mode", modeSeedSecureStorage, "seed mode: seed-secure-storage | seed-ai-update")
flag.Parse()
configDir, err := resolveConfigDir()
if err != nil {
fatalf("resolve config dir failed: %v", err)
}
store := secretstore.NewKeyringStore()
if err := store.HealthCheck(); err != nil {
fatalf("secret store unavailable: %v", err)
}
backupDir, err := backupConfigFiles(configDir)
if err != nil {
fatalf("backup config files failed: %v", err)
}
switch strings.TrimSpace(*mode) {
case modeSeedSecureStorage:
if err := seedSecureStorage(configDir, store); err != nil {
fatalf("seed secure storage failed: %v", err)
}
fmt.Printf("mode=%s\nbackup=%s\nconnectionId=%s\nproviderId=%s\n", modeSeedSecureStorage, backupDir, testConnectionID, testSecureProviderID)
case modeSeedAIUpdate:
if err := seedAIUpdate(configDir, store); err != nil {
fatalf("seed ai update failed: %v", err)
}
fmt.Printf("mode=%s\nbackup=%s\npendingProviderId=%s\n", modeSeedAIUpdate, backupDir, testPendingProviderID)
default:
fatalf("unsupported mode: %s", *mode)
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
func resolveConfigDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, ".gonavi"), nil
}
func backupConfigFiles(configDir string) (string, error) {
backupDir := filepath.Join(configDir, testBackupDirName, time.Now().Format("20060102-150405"))
files := []string{
connectionsFileName,
globalProxyFileName,
aiConfigFileName,
filepath.Join("migrations", securityUpdateFileName),
}
manifest := backupManifest{
CreatedAt: time.Now().Format(time.RFC3339),
ConfigDir: configDir,
Files: make([]backupManifestFile, 0, len(files)),
}
for _, relativePath := range files {
srcPath := filepath.Join(configDir, relativePath)
info, err := os.Stat(srcPath)
if err != nil {
if os.IsNotExist(err) {
manifest.Files = append(manifest.Files, backupManifestFile{
RelativePath: relativePath,
Existed: false,
})
continue
}
return "", err
}
if info.IsDir() {
continue
}
dstPath := filepath.Join(backupDir, relativePath)
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return "", err
}
data, err := os.ReadFile(srcPath)
if err != nil {
return "", err
}
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
return "", err
}
manifest.Files = append(manifest.Files, backupManifestFile{
RelativePath: relativePath,
Existed: true,
})
}
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", err
}
manifestData, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestData, 0o644); err != nil {
return "", err
}
return backupDir, nil
}
func seedSecureStorage(configDir string, store secretstore.SecretStore) error {
if err := cleanupKnownTestSecrets(store); err != nil {
return err
}
appService := app.NewAppWithSecretStore(store)
_ = appService.DeleteConnection(testConnectionID)
if _, err := appService.SaveConnection(connection.SavedConnectionInput{
ID: testConnectionID,
Name: "手工测试 PostgreSQL",
Config: connection.ConnectionConfig{
ID: testConnectionID,
Type: "postgres",
Host: "127.0.0.1",
Port: 5432,
User: "postgres",
Password: "manualtest-pg-secret",
Database: "postgres",
},
}); err != nil {
return err
}
if _, err := appService.SaveGlobalProxy(connection.SaveGlobalProxyInput{
Enabled: true,
Type: "http",
Host: "127.0.0.1",
Port: 7890,
User: "manual-test",
Password: "manualtest-proxy-secret",
}); err != nil {
return err
}
storeConfig := aiservice.NewProviderConfigStore(configDir, store)
snapshot, err := storeConfig.LoadRuntime()
if err != nil {
return err
}
snapshot.Providers = filterProviders(snapshot.Providers, testSecureProviderID, testPendingProviderID)
snapshot.Providers = append(snapshot.Providers, ai.ProviderConfig{
ID: testSecureProviderID,
Type: "custom",
Name: "手工测试 Secure Provider",
APIKey: "manualtest-ai-secret",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o-mini",
APIFormat: "openai",
Headers: map[string]string{
"Authorization": "Bearer manualtest-header-secret",
"X-Trace-Id": "manualtest-visible",
},
MaxTokens: 2048,
Temperature: 0.2,
})
if snapshot.SafetyLevel == "" {
snapshot.SafetyLevel = ai.PermissionReadOnly
}
if snapshot.ContextLevel == "" {
snapshot.ContextLevel = ai.ContextSchemaOnly
}
return storeConfig.Save(snapshot)
}
func seedAIUpdate(configDir string, store secretstore.SecretStore) error {
if err := cleanupKnownTestSecrets(store); err != nil {
return err
}
configPath := filepath.Join(configDir, aiConfigFileName)
cfg, err := readStoredAIConfig(configPath)
if err != nil {
return err
}
cfg.Providers = filterProviders(cfg.Providers, testSecureProviderID, testPendingProviderID)
cfg.Providers = append(cfg.Providers, ai.ProviderConfig{
ID: testPendingProviderID,
Type: "custom",
Name: "手工测试 待迁移 AI",
APIKey: "manualtest-ai-update-secret",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o-mini",
APIFormat: "openai",
MaxTokens: 1024,
})
if cfg.SchemaVersion == 0 {
cfg.SchemaVersion = 2
}
if cfg.Providers == nil {
cfg.Providers = []ai.ProviderConfig{}
}
if err := os.MkdirAll(configDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0o644)
}
func readStoredAIConfig(configPath string) (storedAIConfig, error) {
cfg := storedAIConfig{
Providers: []ai.ProviderConfig{},
SafetyLevel: string(ai.PermissionReadOnly),
ContextLevel: string(ai.ContextSchemaOnly),
SchemaVersion: 2,
ActiveProvider: "",
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return storedAIConfig{}, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return storedAIConfig{}, err
}
if cfg.Providers == nil {
cfg.Providers = []ai.ProviderConfig{}
}
return cfg, nil
}
func filterProviders(providers []ai.ProviderConfig, excludedIDs ...string) []ai.ProviderConfig {
excluded := make(map[string]struct{}, len(excludedIDs))
for _, id := range excludedIDs {
excluded[strings.TrimSpace(id)] = struct{}{}
}
filtered := make([]ai.ProviderConfig, 0, len(providers))
for _, provider := range providers {
if _, skip := excluded[strings.TrimSpace(provider.ID)]; skip {
continue
}
filtered = append(filtered, provider)
}
return filtered
}
func cleanupKnownTestSecrets(store secretstore.SecretStore) error {
type secretRef struct {
kind string
id string
}
refs := []secretRef{
{kind: "connection", id: testConnectionID},
{kind: "global-proxy", id: "default"},
{kind: "ai-provider", id: testSecureProviderID},
{kind: "ai-provider", id: testPendingProviderID},
}
for _, item := range refs {
ref, err := secretstore.BuildRef(item.kind, item.id)
if err != nil {
return err
}
if err := store.Delete(ref); err != nil && !isIgnorableDeleteError(err) {
return err
}
}
return nil
}
func isIgnorableDeleteError(err error) bool {
if err == nil || os.IsNotExist(err) {
return true
}
message := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(message, "could not be found") ||
strings.Contains(message, "not be found in the keyring") ||
strings.Contains(message, "element not found")
}

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,338 @@
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"
agentMethodMetadata = "metadata"
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
case agentMethodMetadata:
resp.Data = map[string]string{
"driverType": strings.TrimSpace(agentDriverType),
"agentRevision": db.OptionalDriverAgentRevision(agentDriverType),
"protocolSchema": "json-lines-v1",
}
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,200 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
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)
}
}
func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) {
previousDriverType := agentDriverType
previousFactory := agentDatabaseFactory
t.Cleanup(func() {
agentDriverType = previousDriverType
agentDatabaseFactory = previousFactory
})
agentDriverType = "clickhouse"
agentDatabaseFactory = func() db.Database { return nil }
var inst db.Database
resp := handleRequest(&inst, agentRequest{ID: 7, Method: agentMethodMetadata})
if !resp.Success {
t.Fatalf("metadata request failed: %s", resp.Error)
}
data, ok := resp.Data.(map[string]string)
if !ok {
t.Fatalf("metadata response data type = %T", resp.Data)
}
if data["driverType"] != "clickhouse" {
t.Fatalf("unexpected driver type: %q", data["driverType"])
}
if data["agentRevision"] != db.OptionalDriverAgentRevision("clickhouse") {
t.Fatalf("unexpected agent revision: %q", data["agentRevision"])
}
}
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_oceanbase_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "oceanbase"
agentDatabaseFactory = func() db.Database {
return &db.OceanBaseDB{}
}
}

View File

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

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

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

@@ -0,0 +1,101 @@
{
"engine": "go",
"drivers": {
"mariadb": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mariadb"
},
"oceanbase": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/oceanbase"
},
"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"
},
"opengauss": {
"engine": "go",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/opengauss"
},
"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

@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>AI UI Brainstorming Prototypes</title>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Ant Design -->
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
<script src="https://unpkg.com/antd/dist/antd.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/antd/dist/reset.css" />
<!-- Icons -->
<script src="https://unpkg.com/@ant-design/icons/dist/index.umd.js"></script>
<style>
body { padding: 40px; background: #f0f2f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
.prototype-container { display: flex; gap: 40px; }
.prototype-column { flex: 1; max-width: 600px; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); overflow: hidden; }
.prototype-header { padding: 16px 24px; border-bottom: 1px solid #f0f0f0; background: #fafafa; font-weight: bold; }
.prototype-body { padding: 24px; }
/* Default App Theme Colors (Light Mode) */
:root {
--gn-border: rgba(16,24,40,0.08);
--gn-bg: rgba(255,255,255,0.84);
--gn-text: #162033;
--gn-muted: rgba(16,24,40,0.55);
--gn-primary: #1677ff;
--gn-primary-bg: rgba(24,144,255,0.1);
}
/* V1 Styles: Professional List */
.v1-list-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; margin-bottom: 8px; border-radius: 8px;
border: 1px solid transparent; cursor: pointer; transition: all 0.2s;
}
.v1-list-item:hover { background: #f5f5f5; }
.v1-list-item.selected {
background: var(--gn-primary-bg); border-color: var(--gn-primary);
}
/* V2 Styles: Refined Cards (ConnectionModal Style) */
.v2-card-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
}
.v2-card {
padding: 16px; border-radius: 12px; border: 1px solid var(--gn-border);
cursor: pointer; transition: all 0.2s; background: white;
box-shadow: inset 0 0 0 1px rgba(16,24,40,0.01);
}
.v2-card:hover { border-color: #d9d9d9; background: #fafafa; }
.v2-card.selected {
border-color: var(--gn-primary); box-shadow: 0 0 0 1px var(--gn-primary) inset;
}
.section-title { font-size: 13px; font-weight: 600; color: var(--gn-muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState } = React;
const { Input, Slider, Select, Button, Form, ConfigProvider } = antd;
const { ThunderboltOutlined, CloudOutlined, ExperimentOutlined, AppstoreOutlined, SettingOutlined, LinkOutlined, KeyOutlined } = icons;
const PROVIDERS = [
{ key: 'openai', label: 'OpenAI', icon: <ThunderboltOutlined />, desc: 'GPT-4o / o1' },
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'V3 / R1' },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Sonnet 3.5' },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '通用 API' },
];
const V1ListDesign = () => {
const [selected, setSelected] = useState('openai');
return (
<div className="prototype-column">
<div className="prototype-header">方案一IDE 专业列表风格 (更克制无彩色渐变)</div>
<div className="prototype-body">
<div className="section-title">提供商选择</div>
<div style={{ marginBottom: 24, padding: 8, background: '#fafafa', borderRadius: 10, border: '1px solid #f0f0f0' }}>
{PROVIDERS.map(p => (
<div key={p.key} className={`v1-list-item ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 32, height: 32, borderRadius: 6, display: 'grid', placeItems: 'center',
background: selected === p.key ? '#1677ff' : '#e6f4ff',
color: selected === p.key ? '#fff' : '#1677ff', fontSize: 16
}}>
{p.icon}
</div>
<div>
<div style={{ fontWeight: 500, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
<div style={{ fontSize: 12, color: 'var(--gn-muted)' }}>{p.desc}</div>
</div>
</div>
<div style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selected === p.key ? 'var(--gn-primary)' : '#d9d9d9'}`, padding: 2 }}>
{selected === p.key && <div style={{ width: '100%', height: '100%', background: 'var(--gn-primary)', borderRadius: '50%' }} />}
</div>
</div>
))}
</div>
<div className="section-title">连接配置 (紧凑表单)</div>
<Form layout="vertical" size="middle">
<Form.Item label="API Endpoint">
<Input placeholder="https://api.openai.com/v1" prefix={<LinkOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
<Form.Item label="API Key">
<Input.Password placeholder="sk-..." prefix={<KeyOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
<Form.Item label="Model Name">
<Input placeholder="gpt-4o" prefix={<AppstoreOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
</Form>
</div>
</div>
);
};
const V2CardDesign = () => {
const [selected, setSelected] = useState('openai');
return (
<div className="prototype-column">
<div className="prototype-header">方案二GoNavi 统一卡片风格 (类似 ConnectionModal)</div>
<div className="prototype-body">
<div className="section-title">选择服务提供商</div>
<div className="v2-card-grid" style={{ marginBottom: 24 }}>
{PROVIDERS.map(p => (
<div key={p.key} className={`v2-card ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ color: selected === p.key ? 'var(--gn-primary)' : 'var(--gn-muted)', fontSize: 20, marginTop: 2 }}>
{p.icon}
</div>
<div>
<div style={{ fontWeight: 600, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
<div style={{ fontSize: 12, color: 'var(--gn-muted)', marginTop: 4 }}>{p.desc}</div>
</div>
</div>
</div>
))}
</div>
<div style={{ padding: 20, borderRadius: 12, border: '1px solid var(--gn-border)', background: '#fafafa' }}>
<div className="section-title" style={{ marginTop: 0 }}>认证与设置</div>
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} size="middle">
<Form.Item label="Endpoint" style={{ marginBottom: 16 }}>
<Input placeholder="https://api..." />
</Form.Item>
<Form.Item label="API Key" style={{ marginBottom: 16 }}>
<Input.Password placeholder="sk-..." />
</Form.Item>
<Form.Item label="模型名称" style={{ marginBottom: 0 }}>
<Input placeholder="例如 gpt-4o" />
</Form.Item>
</Form>
</div>
</div>
</div>
);
};
const App = () => (
<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}>
<div style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 24, margin: 0 }}>AI 设置 UI 重构探讨</h1>
<p style={{ color: 'var(--gn-muted)' }}>当前设计带有太多渐变和鲜艳色彩"AI 味"以下是遵循 GoNavi 本身设计规范克制专业的两个方案</p>
</div>
<div className="prototype-container">
<V1ListDesign />
<V2CardDesign />
</div>
</ConfigProvider>
);
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>

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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
{
"name": "gonavi-client",
"private": true,
"version": "0.0.1",
"version": "0.6.5",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
@@ -15,11 +16,17 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
"mermaid": "^11.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
"uuid": "^9.0.1",
"zustand": "^4.4.7"
@@ -28,9 +35,12 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-resizable": "^3.0.8",
"@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vitest": "^3.2.4"
}
}
}

View File

@@ -1 +1 @@
5b8157374dae5f9340e31b2d0bd2c00e
d0464f9da25e9356e61652e638c99ffe

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

View File

@@ -0,0 +1,6 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>SQL Server</title>
<path fill="#A91D22" d="M4.2 7.25c1.05-1.56 4.53-2.69 8.24-2.69 3.34 0 6.13.91 7.25 2.15.57.64.63 1.29.16 1.87-1 1.27-3.81 2.09-7.18 2.09-3.85 0-7.1-1.03-8.29-2.52-.32-.4-.38-.61-.18-.9Z"/>
<path fill="#D63539" d="M5.07 11.11c1.27-1.2 4.24-2.04 7.42-2.04 3.59 0 6.58 1.04 7.34 2.54.27.54.16 1.07-.34 1.55-1.18 1.12-3.89 1.81-7.12 1.81-3.56 0-6.56-.91-7.6-2.25-.4-.52-.31-1.02.3-1.61Z"/>
<path fill="#F15F5C" d="M7.2 16.12c1.12-.75 3.11-1.18 5.38-1.18 2.43 0 4.59.52 5.71 1.39.84.65 1 1.42.42 2.05-.92 1-3.09 1.63-5.74 1.63-2.87 0-5.34-.75-6.22-1.88-.53-.68-.36-1.37.45-2.01Z"/>
</svg>

After

Width:  |  Height:  |  Size: 691 B

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: var(--gonavi-border-radius); /* Slightly rounded app window corners */
}
/* 侧边栏 Tree 样式优化 */
@@ -30,4 +35,568 @@ html, body, #root {
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 8px;
}
}
.sidebar-tree-scroll-shell {
overflow-x: auto;
overflow-y: hidden;
}
.sidebar-tree-scroll-content {
min-width: 100%;
}
.sidebar-tree-scroll-shell .ant-tree {
min-width: 100%;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder,
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder-inner {
min-width: 100%;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-treenode {
width: auto;
min-width: 100%;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-node-content-wrapper {
width: auto !important;
min-width: 0;
display: flex !important;
align-items: center;
gap: 8px;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-switcher {
flex: 0 0 24px;
width: 24px;
min-width: 24px;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-iconEle {
flex: 0 0 16px;
width: 16px;
min-width: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-inline-end: 0;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
flex: 0 0 auto;
min-width: 0;
overflow: visible;
text-overflow: clip;
}
.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;
}
.driver-manager-modal .ant-modal-body {
background: var(--ant-color-bg-layout, #f5f5f5);
}
.driver-manager-shell {
display: flex;
flex-direction: column;
gap: 14px;
}
.driver-manager-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: stretch;
padding: 14px 16px;
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
}
.driver-manager-heading {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.driver-manager-stats {
display: grid;
grid-template-columns: repeat(4, minmax(64px, 1fr));
gap: 8px;
min-width: 360px;
}
.driver-manager-stat {
display: flex;
flex-direction: column;
gap: 2px;
justify-content: center;
min-height: 58px;
padding: 8px 10px;
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: rgba(5, 5, 5, 0.02);
}
.driver-manager-stat span:first-child {
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
.driver-manager-stat-warning span:first-child {
color: #d48806;
}
.driver-manager-directory-panel {
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
}
.driver-manager-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.driver-manager-search {
min-width: 280px;
flex: 1 1 360px;
}
.driver-manager-toolbar-actions {
justify-content: flex-end;
}
.driver-manager-list-head {
display: flex;
justify-content: space-between;
gap: 12px;
min-height: 24px;
}
.driver-manager-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.driver-manager-card {
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
overflow: hidden;
}
.driver-manager-card-warning {
border-color: rgba(250, 173, 20, 0.35);
}
.driver-manager-card-ready {
border-color: rgba(82, 196, 26, 0.22);
}
.driver-manager-card-main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 38%);
gap: 16px;
padding: 16px;
}
.driver-manager-card-info {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.driver-manager-title-row,
.driver-manager-meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.driver-manager-driver-name {
font-size: 16px;
}
.driver-manager-meta-row {
row-gap: 4px;
}
.driver-manager-update-note {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(250, 173, 20, 0.1);
}
.driver-manager-note-text,
.driver-manager-muted-message {
margin-bottom: 0 !important;
}
.driver-manager-muted-message {
color: var(--ant-color-text-secondary);
}
.driver-manager-card-controls {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.driver-manager-control-block {
display: grid;
gap: 4px;
}
.driver-manager-control-label,
.driver-manager-small-text {
font-size: 12px;
}
.driver-manager-version-control {
display: grid;
gap: 4px;
}
.driver-manager-version-lock {
line-height: 24px;
}
.driver-manager-card-actions {
justify-content: flex-end;
}
.driver-manager-card-actions .ant-btn {
min-width: 88px;
}
.driver-manager-footer-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
@media (max-width: 900px) {
.driver-manager-header,
.driver-manager-card-main {
grid-template-columns: 1fr;
}
.driver-manager-stats {
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.driver-manager-card-actions {
justify-content: flex-start;
}
}
.security-update-action-btn.ant-btn,
.security-update-action-btn.ant-btn-default,
.security-update-action-btn.ant-btn-primary,
.security-update-action-btn.ant-btn-text {
box-shadow: none !important;
}
.security-update-action-btn.ant-btn:focus,
.security-update-action-btn.ant-btn:focus-visible,
.security-update-action-btn.ant-btn-default:focus,
.security-update-action-btn.ant-btn-default:focus-visible,
.security-update-action-btn.ant-btn-primary:focus,
.security-update-action-btn.ant-btn-primary:focus-visible,
.security-update-action-btn.ant-btn-text:focus,
.security-update-action-btn.ant-btn-text:focus-visible {
outline: none !important;
box-shadow: none !important;
}
.security-update-banner {
position: relative;
isolation: isolate;
}
.security-update-result-card {
transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
}
.security-update-result-card-active {
animation: security-update-result-pulse 1.8s ease;
}
@keyframes security-update-result-pulse {
0% {
transform: translateY(0);
}
30% {
transform: translateY(-2px);
}
100% {
transform: translateY(0);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,497 @@
.ai-chat-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
border-left: 1px solid rgba(128, 128, 128, 0.12);
position: relative;
}
/* Resize Handle */
.ai-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 10;
transition: background 0.15s ease;
}
.ai-resize-handle:hover,
.ai-resize-handle.active {
background: rgba(22, 119, 255, 0.5);
}
/* Header */
.ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
flex-shrink: 0;
}
.ai-chat-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.ai-chat-header-left .ai-logo {
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.ai-chat-header-left .ai-title {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.01em;
}
.ai-chat-header-right {
display: flex;
align-items: center;
gap: 4px;
}
/* Messages Area */
.ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-chat-messages::-webkit-scrollbar {
width: 5px;
}
.ai-chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.ai-chat-messages::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 3px;
}
/* Welcome */
.ai-chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 40px 20px;
text-align: center;
flex: 1;
}
.ai-chat-welcome .welcome-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: grid;
place-items: center;
font-size: 28px;
}
.ai-chat-welcome .welcome-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 4px;
}
.ai-chat-welcome .quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 8px;
}
.ai-chat-welcome .quick-action-btn {
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid;
}
.ai-chat-welcome .quick-action-btn:hover {
background: rgba(99, 102, 241, 0.12) !important;
border-color: rgba(99, 102, 241, 0.3) !important;
color: #818cf8 !important;
}
/* IDE Style Messages */
.ai-ide-message {
padding: 12px 16px;
animation: ai-msg-in 0.2s ease-out;
}
@keyframes ai-msg-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-ide-message-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.ai-ide-message-content {
font-size: 13px;
line-height: 1.6;
word-break: break-word;
/* Remove pre-wrap here, as it conflicts with ReactMarkdown's block rendering */
}
/* Markdown Styles Override */
.ai-markdown-content {
white-space: normal;
}
.ai-markdown-content p {
margin: 0 0 10px;
}
.ai-markdown-content p:last-child {
margin-bottom: 0;
}
.ai-markdown-content h1,
.ai-markdown-content h2,
.ai-markdown-content h3,
.ai-markdown-content h4,
.ai-markdown-content h5,
.ai-markdown-content h6 {
margin: 16px 0 8px;
line-height: 1.4;
font-weight: 600;
}
.ai-markdown-content h1:first-child,
.ai-markdown-content h2:first-child,
.ai-markdown-content h3:first-child,
.ai-markdown-content h4:first-child,
.ai-markdown-content h5:first-child,
.ai-markdown-content h6:first-child {
margin-top: 0;
}
.ai-markdown-content pre {
margin: 10px 0;
border-radius: 4px;
padding: 10px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
overflow-x: auto;
border: 1px solid rgba(128, 128, 128, 0.15);
background: rgba(0, 0, 0, 0.2);
}
.ai-markdown-content code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: rgba(128, 128, 128, 0.15);
padding: 2px 4px;
border-radius: 3px;
font-size: 0.95em;
}
.ai-markdown-content ul, .ai-markdown-content ol {
margin: 0 0 10px;
padding-left: 20px;
}
.ai-markdown-content li {
margin-bottom: 4px;
}
/* Advanced Typing/Blinker indicator */
.ai-blinking-cursor {
display: inline-block;
width: 6px;
height: 14px;
background-color: currentColor;
border-radius: 1px;
vertical-align: middle;
margin-left: 4px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes ai-dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* History Drawer Styles */
.ai-history-list::-webkit-scrollbar {
width: 4px;
}
.ai-history-list::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.2);
border-radius: 4px;
}
.ai-history-list:hover::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.4);
}
.ai-history-item:hover {
background: rgba(128, 128, 128, 0.08) !important;
}
.ai-history-item .ai-history-delete-btn {
opacity: 0;
transition: opacity 0.2s, background 0.2s;
}
.ai-history-item:hover .ai-history-delete-btn,
.ai-history-item.active .ai-history-delete-btn {
opacity: 1;
}
/* Input Area */
.ai-chat-input-area {
padding: 12px 16px 16px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
flex-shrink: 0;
}
/* Textarea scrollbar */
.ai-chat-input-wrapper textarea {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar {
width: 4px;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar-track {
background: transparent;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.ai-chat-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
border-radius: 6px;
border: 1px solid transparent;
border-bottom-color: rgba(128, 128, 128, 0.4);
padding: 6px 10px;
transition: all 0.2s ease;
background: transparent !important;
box-shadow: none !important;
}
.ai-chat-input-wrapper:focus-within {
border-color: var(--ant-primary-color, #1677ff) !important;
background: rgba(128, 128, 128, 0.05) !important;
}
.ai-chat-input-wrapper textarea {
width: 100%;
border: none;
outline: none;
background: transparent;
resize: none;
font-size: 13px;
line-height: 1.5;
min-height: 28px;
max-height: 200px;
padding: 0;
font-family: inherit;
overflow-y: auto;
}
.ai-chat-input-wrapper textarea::placeholder {
opacity: 0.4;
}
.ai-chat-send-btn {
width: 26px;
height: 26px;
border-radius: 4px;
display: grid;
place-items: center;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.ai-chat-send-btn:hover {
transform: scale(1.06);
}
.ai-chat-send-btn:active {
transform: scale(0.96);
}
.ai-chat-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.ai-ide-message:hover .ai-message-actions {
opacity: 1 !important;
}
/* Markdown 额外样式增强: Table & Blockquote */
.ai-markdown-content table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
/* 让消息内容区域成为表格的滚动约束容器 */
.ai-ide-message-content {
max-width: 100%;
overflow-x: hidden;
}
/* 表格滚动容器 - 不限定直接子元素 */
.ai-markdown-content table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
max-width: 100%;
}
.ai-markdown-content table::-webkit-scrollbar {
height: 4px;
}
.ai-markdown-content table::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.ai-markdown-content th,
.ai-markdown-content td {
border: 1px solid rgba(125, 125, 125, 0.2);
padding: 6px 12px;
text-align: left;
white-space: nowrap;
}
.ai-markdown-content th {
background: rgba(125, 125, 125, 0.1);
font-weight: 600;
}
.ai-markdown-content blockquote {
margin: 12px 0;
padding: 8px 14px;
border-left: 4px solid rgba(125, 125, 125, 0.4);
background: rgba(125, 125, 125, 0.05);
color: inherit;
opacity: 0.85;
border-radius: 0 6px 6px 0;
font-style: italic;
}
/* 覆盖 code 块容器样式避免和 syntax highlighter 冲突 */
.ai-markdown-content > pre {
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
}
/* ===== 新版 AI 状态流转动画 ===== */
/* 1. 连接脉冲动画 (connecting) */
.ai-wave-pulse {
display: flex;
align-items: center;
gap: 4px;
}
.ai-wave-pulse span {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: currentColor;
animation: wave-pulse-anim 1.2s ease-in-out infinite;
}
.ai-wave-pulse span:nth-child(1) { animation-delay: 0s; }
.ai-wave-pulse span:nth-child(2) { animation-delay: 0.15s; }
.ai-wave-pulse span:nth-child(3) { animation-delay: 0.3s; }
@keyframes wave-pulse-anim {
0%, 100% { transform: translateY(0) scale(0.8); opacity: 0.4; }
50% { transform: translateY(-4px) scale(1.1); opacity: 1; }
}
/* 2. 平滑高度与透明度过渡 (针对 ThinkingBlock 和 面板折叠) */
.ai-expand-transition {
display: grid;
transition: grid-template-rows 0.3s ease-out, opacity 0.3s ease-out;
}
.ai-expand-transition.expanded {
grid-template-rows: 1fr;
opacity: 1;
}
.ai-expand-transition.collapsed {
grid-template-rows: 0fr;
opacity: 0;
}
.ai-expand-transition > div {
overflow: hidden;
}
/* 3. Agent风格旋转Loading环 */
.ai-spinning-ring {
width: 14px;
height: 14px;
border: 2px solid rgba(22, 119, 255, 0.2);
border-top-color: #1677ff;
border-radius: 50%;
animation: ai-spin-anim 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes ai-spin-anim {
to { transform: rotate(360deg); }
}
/* 面板/弹窗内部 toast 定位覆盖:从 fixed视口顶部改为 absolute容器内部顶部 */
.ai-chat-panel .ant-message,
.ai-settings-body .ant-message {
position: absolute !important;
top: 16px !important;
left: 50% !important;
transform: translateX(-50%) !important;
right: auto !important;
width: max-content;
z-index: 100;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,840 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Button, Input, Select, Form, Checkbox, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
import {
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_MODELS,
resolveProviderPresetKey,
resolvePresetBaseURL,
resolvePresetModelSelection,
resolvePresetTransport,
} from '../utils/aiProviderPresets';
import {
PROVIDER_PRESET_CARD_BASE_STYLE,
PROVIDER_PRESET_CARD_CONTENT_STYLE,
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
PROVIDER_PRESET_GRID_STYLE,
PROVIDER_PRESET_CARD_TITLE_STYLE,
} from '../utils/aiSettingsPresetLayout';
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
interface ProviderPreset {
key: string;
label: string;
icon: React.ReactNode;
desc: string;
color: string;
backendType: AIProviderType;
fixedApiFormat?: string;
defaultBaseUrl: string;
defaultModel: string;
models: string[];
}
const PROVIDER_PRESETS: ProviderPreset[] = [
{ key: 'openai', label: 'OpenAI', icon: <ApiOutlined />, desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] },
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
{ key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: <CloudOutlined />, desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
{ key: 'qwen-coding-plan', label: '通义千问Coding Plan', icon: <CloudOutlined />, desc: 'Claude Code CLI 代理链路 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS },
{ key: 'zhipu', label: '智谱 GLM', icon: <ExperimentOutlined />, desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] },
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] },
{ key: 'gemini', label: 'Gemini', icon: <CloudOutlined />, desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] },
{ key: 'volcengine-ark', label: '火山方舟', icon: <CloudOutlined />, desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
{ key: 'volcengine-coding', label: '火山 Coding Plan', icon: <CloudOutlined />, desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] },
{ key: 'minimax', label: 'MiniMax', icon: <ExperimentOutlined />, desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] },
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
];
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): ProviderPreset => {
const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
return findPreset(presetKey);
};
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
{ label: '只读模式', value: 'readonly', desc: 'AI 仅可执行 SELECT 等查询操作,最安全', color: '#22c55e', icon: '🔒' },
{ label: '读写模式', value: 'readwrite', desc: 'AI 可执行 INSERT/UPDATE/DELETE危险操作需二次确认', color: '#f59e0b', icon: '⚠️' },
{ label: '完全模式', value: 'full', desc: 'AI 可执行所有操作(含 DDL高危操作自动告警', color: '#ef4444', icon: '🔓' },
];
const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; icon: string }[] = [
{ label: '仅 Schema', value: 'schema_only', desc: '只传递表/列结构信息给 AI', icon: '📋' },
{ label: '含采样数据', value: 'with_samples', desc: '包含少量采样数据帮助 AI 理解数据特征', icon: '📊' },
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
];
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
const [clearProviderSecret, setClearProviderSecret] = useState(false);
const [form] = Form.useForm();
const modalBodyRef = useRef<HTMLDivElement>(null);
// Modal 内部 toast 通知
const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body });
// 主题色
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
// Hook 必须在组件顶层调用,不能在条件分支内
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
const watchedApiKeyInput = Form.useWatch('apiKey', form);
const loadConfig = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service) { console.warn('[AI] Service not found on window.go'); return; }
const [provRes, safeRes, ctxRes, promptsRes] = await Promise.all([
Service.AIGetProviders?.() || [],
Service.AIGetSafetyLevel?.() || 'readonly',
Service.AIGetContextLevel?.() || 'schema_only',
Service.AIGetBuiltinPrompts?.() || {},
]);
console.log('[AI] AIGetProviders result:', JSON.stringify(provRes), 'isArray:', Array.isArray(provRes));
if (Array.isArray(provRes)) {
setProviders(provRes);
const activeRes = await Service.AIGetActiveProvider?.();
console.log('[AI] AIGetActiveProvider result:', activeRes);
if (activeRes) setActiveProviderId(activeRes);
}
if (safeRes) setSafetyLevel(safeRes);
if (ctxRes) setContextLevel(ctxRes);
if (promptsRes) setBuiltinPrompts(promptsRes);
} catch (e) { console.warn('Failed to load AI config', e); }
}, []);
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
useEffect(() => {
if (!open || !focusProviderId) {
return;
}
if (!providers.some((provider) => provider.id === focusProviderId)) {
return;
}
setActiveSection('providers');
setActiveProviderId(focusProviderId);
}, [focusProviderId, open, providers]);
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
setEditingProvider(session.editingProvider as AIProviderConfig | null);
setIsEditing(session.isEditing);
setTestStatus(session.testStatus);
setClearProviderSecret(session.clearProviderSecret);
form.resetFields();
if (session.formValues) {
form.setFieldsValue(session.formValues);
}
}, [form]);
const resetProviderEditorSession = useCallback(() => {
applyProviderEditorSession(buildClosedProviderEditorSession());
}, [applyProviderEditorSession]);
const handleModalClose = useCallback(() => {
resetProviderEditorSession();
onClose();
}, [onClose, resetProviderEditorSession]);
useEffect(() => {
if (!open) {
resetProviderEditorSession();
}
}, [open, resetProviderEditorSession]);
const handleAddProvider = () => {
const preset = findPreset('openai');
applyProviderEditorSession(buildAddProviderEditorSession({
presetKey: 'openai',
presetBackendType: preset.backendType,
presetBaseUrl: preset.defaultBaseUrl,
presetModel: preset.defaultModel,
presetModels: preset.models,
apiFormat: 'openai',
}));
};
const handleEditProvider = (p: AIProviderConfig) => {
// 尝试根据 baseUrl 和 type 推断 preset
const matchedPreset = matchProviderPreset(p);
const resolvedTransport = resolvePresetTransport({
presetBackendType: matchedPreset.backendType,
presetFixedApiFormat: matchedPreset.fixedApiFormat,
valuesApiFormat: p.apiFormat,
});
applyProviderEditorSession(buildEditProviderEditorSession({
provider: { ...p, presetKey: matchedPreset.key } as any,
formValues: {
...p,
type: resolvedTransport.type,
models: p.models || [],
presetKey: matchedPreset.key,
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
},
}));
};
const handleDeleteProvider = async (id: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
const wasActive = id === activeProviderId;
await Service?.AIDeleteProvider?.(id);
await loadConfig();
// 合并提示:删除的是当前激活的供应商时,附带自动切换信息
if (wasActive) {
const newProviders: any[] = await Service?.AIGetProviders?.() || [];
if (newProviders.length > 0) {
const newActiveName = newProviders[0]?.name || '下一个供应商';
void messageApi.success(`已删除,自动切换到「${newActiveName}`);
} else {
void messageApi.success('已删除');
}
} else {
void messageApi.success('已删除');
}
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) { void messageApi.error(e?.message || '删除失败'); }
};
const handleSaveProvider = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
// 构建 payload处理 model/models 逻辑
const preset = findPreset(values.presetKey);
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
presetKey: values.presetKey,
presetDefaultModel: preset.defaultModel,
presetModels: preset.models,
valuesModel: values.model,
customModels: values.models,
});
// 内置供应商自动使用 preset label 作为名称
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
const finalBaseUrl = resolvePresetBaseURL({
presetKey: values.presetKey,
presetDefaultBaseUrl: preset.defaultBaseUrl,
valuesBaseUrl: values.baseUrl,
});
const resolvedTransport = resolvePresetTransport({
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
});
const secretDraft = resolveProviderSecretDraft({
hasSecret: editingProvider?.hasSecret,
apiKeyInput: values.apiKey,
clearSecret: clearProviderSecret,
});
const payload = {
...editingProvider,
...values,
...resolvedTransport,
name: finalName,
apiKey: secretDraft.apiKey,
hasSecret: secretDraft.hasSecret,
model: finalModel,
models: resolvedModels,
baseUrl: finalBaseUrl,
apiFormat: resolvedTransport.apiFormat,
};
// 后端 AISaveProvider 统一处理新增和更新,返回 void失败抛异常
await Service?.AISaveProvider?.(payload);
void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig();
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) {
if (e?.errorFields) { /* antd form validation error, ignore */ }
else void messageApi.error(e?.message || '保存失败');
} finally { setLoading(false); }
};
const handleSetActive = async (id: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetActiveProvider?.(id);
setActiveProviderId(id); void messageApi.success('已切换');
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) { void messageApi.error(e?.message || '切换失败'); }
};
const handleSafetyChange = async (level: AISafetyLevel) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetSafetyLevel?.(level);
setSafetyLevel(level);
} catch (e) { /* ignore */ }
};
const handleContextChange = async (level: AIContextLevel) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetContextLevel?.(level);
setContextLevel(level);
} catch (e) { /* ignore */ }
};
const handleTestProvider = async () => {
try {
const values = await form.validateFields();
setLoading(true);
setTestStatus('idle');
const Service = (window as any).go?.aiservice?.Service;
const preset = findPreset(values.presetKey || 'openai');
const finalBaseUrl = resolvePresetBaseURL({
presetKey: values.presetKey || 'openai',
presetDefaultBaseUrl: preset.defaultBaseUrl,
valuesBaseUrl: values.baseUrl,
});
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
presetKey: values.presetKey || 'openai',
presetDefaultModel: preset.defaultModel,
presetModels: preset.models,
valuesModel: values.model,
customModels: values.models,
});
const resolvedTransport = resolvePresetTransport({
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
});
const secretDraft = resolveProviderSecretDraft({
hasSecret: editingProvider?.hasSecret,
apiKeyInput: values.apiKey,
clearSecret: clearProviderSecret,
});
if (secretDraft.mode === 'clear') {
throw new Error('测试连接前请填写新的 API Key或取消清除已保存密钥');
}
const res = await Service?.AITestProvider?.({
...editingProvider,
...values,
...resolvedTransport,
apiKey: secretDraft.apiKey,
hasSecret: secretDraft.hasSecret,
baseUrl: finalBaseUrl,
model: finalModel,
models: resolvedModels,
maxTokens: Number(values.maxTokens) || 4096,
temperature: Number(values.temperature) ?? 0.7,
apiFormat: resolvedTransport.apiFormat,
});
if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); }
else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); }
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); }
finally { setLoading(false); }
};
const handlePresetChange = (presetKey: string) => {
const preset = findPreset(presetKey);
const resolvedTransport = resolvePresetTransport({
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: form.getFieldValue('apiFormat'),
});
form.setFieldsValue({
presetKey,
type: resolvedTransport.type,
apiFormat: resolvedTransport.apiFormat || 'openai',
baseUrl: preset.defaultBaseUrl,
model: preset.defaultModel,
});
};
// ---- 字段装饰器样式 ----
const fieldGroupStyle: React.CSSProperties = {
padding: '14px 16px', borderRadius: 12, border: `1px solid ${cardBorder}`,
background: cardBg, marginBottom: 12,
};
const fieldLabelStyle: React.CSSProperties = {
fontSize: 13, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.08em',
color: sectionLabelColor, marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6,
};
// ===== Provider 列表 =====
const renderProviderList = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{providers.length === 0 && (
<div style={{
textAlign: 'center', padding: '36px 20px', color: overlayTheme.mutedText, fontSize: 14,
border: `1px dashed ${cardBorder}`, borderRadius: 14, background: cardBg,
}}>
<RobotOutlined style={{ fontSize: 32, marginBottom: 12, opacity: 0.3, display: 'block' }} />
<br />
<span style={{ fontSize: 13, opacity: 0.6 }}>使 AI </span>
</div>
)}
{providers.map(p => {
const matchedPreset = matchProviderPreset(p);
const isActive = p.id === activeProviderId;
return (
<div key={p.id} onClick={() => handleSetActive(p.id)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${isActive ? overlayTheme.selectedText : cardBorder}`,
background: isActive ? overlayTheme.selectedBg : cardBg,
display: 'flex', alignItems: 'center', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center',
background: isActive ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
color: isActive ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, flexShrink: 0, transition: 'all 0.2s ease',
}}>
{matchedPreset.icon || <ApiOutlined />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{p.name || p.type}
{isActive && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 13 }} />}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{matchedPreset.label}</span>
<span style={{ opacity: 0.4 }}>·</span>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model || '未选择模型'}</span>
</div>
</div>
<Space size={2}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />}
onClick={e => { e.stopPropagation(); handleEditProvider(p); }}
style={{ color: overlayTheme.mutedText }} />
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => handleDeleteProvider(p.id)}
okButtonProps={{ danger: true }} okText="删除" cancelText="取消">
<Button type="text" size="small" icon={<DeleteOutlined />} danger
onClick={e => e.stopPropagation()} />
</Popconfirm>
</Space>
</div>
);
})}
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddProvider}
style={{ borderRadius: 12, height: 42, borderColor: darkMode ? 'rgba(255,255,255,0.12)' : undefined }}>
</Button>
</div>
);
// ===== Provider 编辑表单 =====
const renderProviderForm = () => {
const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai';
return (
<div>
{/* 顶部返回 */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
<Button size="small" onClick={resetProviderEditorSession}
style={{ borderRadius: 8 }}> </Button>
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
</span>
</div>
<Form form={form} layout="vertical" size="small">
{/* Provider 类型选择 - 卡片式 */}
<div style={fieldGroupStyle}>
<div style={fieldLabelStyle}>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item name="presetKey" noStyle>
<div style={PROVIDER_PRESET_GRID_STYLE}>
{PROVIDER_PRESETS.map(pt => (
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
style={{
...PROVIDER_PRESET_CARD_BASE_STYLE,
border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`,
background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'),
}}>
<div style={{
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease', flexShrink: 0,
}}>
{pt.icon}
</div>
<div style={PROVIDER_PRESET_CARD_CONTENT_STYLE}>
<div style={{ ...PROVIDER_PRESET_CARD_TITLE_STYLE, fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
<div style={{ ...PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.4 }}>{pt.desc}</div>
</div>
</div>
))}
</div>
</Form.Item>
<Form.Item name="type" hidden><Input /></Form.Item>
</div>
{/* 基本信息 - 仅自定义/Ollama 显示 */}
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
<div style={fieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}></span>} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}>
<Input placeholder="例如:我的自建 OpenAI / 专属大模型"
size="middle"
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{presetKeyFromForm === 'custom' && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API </span>} name="apiFormat" style={{ marginBottom: 16 }}>
<div style={{
display: 'inline-flex', padding: 4, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, gap: 4
}}>
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map(fmt => (
<div
key={fmt.value}
onClick={() => form.setFieldsValue({ apiFormat: fmt.value })}
style={{
padding: '6px 16px', borderRadius: 6, fontSize: 13, fontWeight: watchedApiFormat === fmt.value ? 600 : 500, cursor: 'pointer',
background: watchedApiFormat === fmt.value ? (darkMode ? '#374151' : '#ffffff') : 'transparent',
color: watchedApiFormat === fmt.value ? overlayTheme.titleText : overlayTheme.mutedText,
boxShadow: watchedApiFormat === fmt.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
transition: 'all 0.2s ease',
}}
>
{fmt.label}
</div>
))}
</div>
</Form.Item>
)}
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}></span>} name="models" style={{ marginBottom: 0 }}>
<Select mode="tags" size="middle" placeholder="配置指定的模型ID留空则默认去服务端拉取" style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="model" hidden><Input /></Form.Item>
<Form.Item name="name" hidden><Input /></Form.Item>
{/* 认证信息 */}
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
<div style={fieldLabelStyle}>
<KeyOutlined style={{ fontSize: 14 }} /> &
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || clearProviderSecret || editingProvider?.hasSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: editingProvider?.hasSecret ? 8 : 16 }}>
<Input.Password placeholder={editingProvider?.hasSecret ? '留空表示继续沿用已保存密钥' : 'sk-... / 你的 API Key'}
size="middle"
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{editingProvider?.hasSecret && (
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: cardBg }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
API Key沿
</div>
<Checkbox
checked={clearProviderSecret}
disabled={String(watchedApiKeyInput || '').trim() !== ''}
onChange={(event) => setClearProviderSecret(event.target.checked)}
>
API Key
</Checkbox>
</div>
)}
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
<Input placeholder={findPreset(presetKeyFromForm).defaultBaseUrl || 'https://...'}
size="middle"
suffix={<LinkOutlined style={{ color: overlayTheme.mutedText }} />}
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
)}
</div>
{/* 操作按钮 */}
<div style={{
display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12, paddingTop: 16,
borderTop: `1px solid ${cardBorder}`, paddingBottom: 24,
}}>
<Button onClick={handleTestProvider} loading={loading} style={{ borderRadius: 10 }}
icon={testStatus === 'success' ? <CheckOutlined style={{ color: '#22c55e' }} /> : undefined}>
{testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'}
</Button>
<Button type="primary" onClick={handleSaveProvider} loading={loading}
style={{ borderRadius: 10, fontWeight: 600 }}>
</Button>
</div>
</Form>
</div>
);
};
// ===== 安全控制 =====
const renderSafetySettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
AI SQL
</div>
{SAFETY_OPTIONS.map(opt => {
const active = safetyLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleSafetyChange(opt.value)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`,
background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg,
display: 'flex', alignItems: 'flex-start', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
background: active ? (opt.color === '#ef4444' ? `${opt.color}25` : overlayTheme.iconBg) : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
color: active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor) : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}>
{opt.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor, fontSize: 14 }} />}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
// ===== 上下文级别 =====
const renderContextSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
AI
</div>
{CONTEXT_OPTIONS.map(opt => {
const active = contextLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleContextChange(opt.value)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : cardBg,
display: 'flex', alignItems: 'flex-start', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
background: active ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}>
{opt.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 14 }} />}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
const renderBuiltinPrompts = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
GoNavi AI
</div>
{Object.entries(builtinPrompts).map(([title, promptText]) => (
<div key={title} style={{
padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: cardBg,
}}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
</div>
<div style={{
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
padding: '10px 12px', borderRadius: 8, fontSize: 13, color: overlayTheme.mutedText,
whiteSpace: 'pre-wrap', fontFamily: 'monospace', lineHeight: 1.5,
userSelect: 'text', border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)'
}}>
{promptText}
</div>
</div>
))}
</div>
);
const BUILTIN_TOOLS_INFO = [
{ name: 'get_connections', icon: '🔗', desc: '获取所有可用的数据库连接', detail: '返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。', params: '无参数' },
{ name: 'get_databases', icon: '🗄️', desc: '获取指定连接下的所有数据库', detail: '传入 connectionId返回该连接下的数据库/Schema 名称列表。', params: 'connectionId: 连接 ID' },
{ name: 'get_tables', icon: '📋', desc: '获取指定数据库下的所有表名', detail: '传入 connectionId 和 dbName返回表名列表。AI 用它来定位用户提到的目标表。', params: 'connectionId, dbName' },
{ name: 'get_columns', icon: '🔍', desc: '获取指定表的字段结构', detail: '传入 connectionId、dbName 和 tableName返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。', params: 'connectionId, dbName, tableName' },
{ name: 'get_table_ddl', icon: '📝', desc: '获取表的建表语句 (DDL)', detail: '传入 connectionId、dbName 和 tableName返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。', params: 'connectionId, dbName, tableName' },
{ name: 'execute_sql', icon: '▶️', desc: '执行 SQL 查询并返回结果', detail: '传入 connectionId、dbName 和 sql在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。', params: 'connectionId, dbName, sql' },
];
const renderBuiltinTools = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
AI
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, padding: '8px 12px', borderRadius: 8, background: cardBg, border: `1px solid ${cardBorder}` }}>
💡 get_connections get_databases get_tables get_columns SQL
</div>
{BUILTIN_TOOLS_INFO.map(tool => (
<div key={tool.name} style={{
padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg,
transition: 'all 0.2s ease',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 20 }}>{tool.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, fontFamily: 'monospace' }}>
{tool.name}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 2 }}>{tool.desc}</div>
</div>
</div>
<div style={{
fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, padding: '8px 12px',
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.02)', borderRadius: 8,
}}>
{tool.detail}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, display: 'flex', alignItems: 'center', gap: 6 }}>
<ToolOutlined style={{ fontSize: 12 }} />
<span></span>
<code style={{ fontFamily: 'monospace', fontSize: 12, padding: '1px 6px', borderRadius: 4, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
{tool.params}
</code>
</div>
</div>
))}
</div>
);
const modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
};
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{
width: 38, height: 38, borderRadius: 12, display: 'grid', placeItems: 'center',
background: overlayTheme.iconBg, color: overlayTheme.iconColor, fontSize: 18, flexShrink: 0,
}}>
<RobotOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>AI </div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
AI
</div>
</div>
</div>
}
open={open}
onCancel={handleModalClose}
footer={null}
width={820}
styles={{
content: modalShellStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, height: 620, overflow: 'hidden' },
}}
>
<div ref={modalBodyRef} className="ai-settings-body" style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch', position: 'relative' }}>
{messageContextHolder}
<div style={{ padding: '0 12px', height: 'fit-content' }}>
<div style={{ marginBottom: 12, fontWeight: 600, color: overlayTheme.titleText }}></div>
<div style={{ display: 'grid', gap: 10 }}>
{[
{ key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: <ApiOutlined /> },
{ key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: <SafetyCertificateOutlined /> },
{ key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: <RobotOutlined /> },
{ key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: <ToolOutlined /> },
{ key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: <ExperimentOutlined /> },
].map((item) => {
const active = activeSection === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setActiveSection(item.key as typeof activeSection)}
style={{
textAlign: 'left',
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${active
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
background: active
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 16 }}>{item.icon}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.title}</span>
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : 'rgba(128,128,128,0.7)' }}>
{item.description}
</div>
</button>
);
})}
</div>
</div>
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
{activeSection === 'safety' && renderSafetySettings()}
{activeSection === 'context' && renderContextSettings()}
{activeSection === 'tools' && renderBuiltinTools()}
{activeSection === 'prompts' && renderBuiltinPrompts()}
</div>
</div>
</Modal>
);
};
export default AISettingsModal;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { Checkbox, Input, Modal, Typography } from 'antd';
const { Text } = Typography;
type ConnectionPackagePasswordModalMode = 'import' | 'export';
export interface ConnectionPackagePasswordModalProps {
open: boolean;
title: string;
mode?: ConnectionPackagePasswordModalMode;
includeSecrets?: boolean;
useFilePassword?: boolean;
password: string;
error?: string;
confirmLoading?: boolean;
confirmText?: string;
cancelText?: string;
onIncludeSecretsChange?: (value: boolean) => void;
onUseFilePasswordChange?: (value: boolean) => void;
onPasswordChange: (value: string) => void;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConnectionPackagePasswordModal({
open,
title,
mode = 'import',
includeSecrets = true,
useFilePassword = false,
password,
error,
confirmLoading,
confirmText = '确认',
cancelText = '取消',
onIncludeSecretsChange,
onUseFilePasswordChange,
onPasswordChange,
onConfirm,
onCancel,
}: ConnectionPackagePasswordModalProps) {
const isExportMode = mode === 'export';
const showFilePasswordInput = isExportMode ? useFilePassword : true;
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
const helperText = !includeSecrets
? '将仅导出连接配置,不包含密码。'
: (useFilePassword
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
return (
<Modal
open={open}
title={title}
okText={confirmText}
cancelText={cancelText}
confirmLoading={confirmLoading}
onOk={onConfirm}
onCancel={onCancel}
destroyOnClose={false}
maskClosable={false}
>
{isExportMode ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Checkbox
checked={includeSecrets}
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
>
</Checkbox>
<Checkbox
checked={useFilePassword}
disabled={!includeSecrets}
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
>
</Checkbox>
</div>
) : null}
{showFilePasswordInput ? (
<Input.Password
autoFocus
value={password}
placeholder={placeholder}
disabled={isExportMode && !useFilePassword}
onChange={(event) => onPasswordChange(event.target.value)}
/>
) : null}
{isExportMode ? (
<Text type={useFilePassword ? 'warning' : 'secondary'} style={{ display: 'block', marginTop: 8 }}>
{helperText}
</Text>
) : null}
{error ? (
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
{error}
</Text>
) : null}
</Modal>
);
}

View File

@@ -0,0 +1,464 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DataGrid, { buildDataGridCommitChangeSet, GONAVI_ROW_KEY } from './DataGrid';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
},
queryOptions: {
showColumnComment: false,
showColumnType: false,
},
setQueryOptions: vi.fn(),
tableColumnOrders: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
clearTableColumnOrder: vi.fn(),
tableHiddenColumns: {},
enableHiddenColumnMemory: false,
setTableHiddenColumns: vi.fn(),
setEnableHiddenColumnMemory: vi.fn(),
clearTableHiddenColumns: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
ImportData: vi.fn(),
ExportTable: vi.fn(),
ExportData: vi.fn(),
ExportQuery: vi.fn(),
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
loading: vi.fn(() => vi.fn()),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
}));
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('@monaco-editor/react', () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
}));
vi.mock('./ImportPreviewModal', () => ({
default: () => null,
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
ReloadOutlined: Icon,
ImportOutlined: Icon,
ExportOutlined: Icon,
DownOutlined: Icon,
PlusOutlined: Icon,
DeleteOutlined: Icon,
SaveOutlined: Icon,
UndoOutlined: Icon,
FilterOutlined: Icon,
CloseOutlined: Icon,
ConsoleSqlOutlined: Icon,
FileTextOutlined: Icon,
CopyOutlined: Icon,
ClearOutlined: Icon,
EditOutlined: Icon,
VerticalAlignBottomOutlined: Icon,
LeftOutlined: Icon,
RightOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
};
});
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: any) => <>{children}</>,
PointerSensor: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
closestCenter: vi.fn(),
}));
vi.mock('@dnd-kit/sortable', () => ({
SortableContext: ({ children }: any) => <>{children}</>,
useSortable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
})),
horizontalListSortingStrategy: vi.fn(),
arrayMove: (items: any[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
},
}));
vi.mock('@dnd-kit/utilities', () => ({
CSS: {
Transform: {
toString: () => '',
},
},
}));
vi.mock('antd', () => {
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button type="button" disabled={disabled || loading} data-button-type={type} onClick={onClick} {...rest}>
{children}
</button>
);
const Input: any = ({ value, onChange, placeholder, ...rest }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} {...rest} />
);
Input.TextArea = ({ value, onChange, placeholder }: any) => (
<textarea value={value} onChange={onChange} placeholder={placeholder} />
);
const createForm = () => ({
resetFields: vi.fn(),
setFieldsValue: vi.fn(),
getFieldsValue: vi.fn(() => ({})),
getFieldValue: vi.fn(),
validateFields: vi.fn(() => Promise.resolve({})),
});
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [createForm()];
const Modal: any = ({ children, footer, open, title }: any) => (
open ? (
<section data-modal-title={title}>
<h2>{title}</h2>
{children}
<div>{footer}</div>
</section>
) : null
);
Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null];
const passthrough = ({ children }: any) => <>{children}</>;
return {
Table: () => <table />,
message: messageApi,
Input,
Button,
Dropdown: passthrough,
Form,
Pagination: () => null,
Select: () => null,
Modal,
Checkbox: ({ checked, onChange }: any) => <input type="checkbox" checked={checked} onChange={onChange} />,
Segmented: () => null,
Tooltip: passthrough,
Popover: passthrough,
DatePicker: () => null,
TimePicker: () => null,
AutoComplete: ({ children }: any) => <>{children}</>,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
const normalizeValue = (_columnName: string, value: any) => value;
const rowKeyToString = (key: any) => String(key);
const commitColumnGuard = (columnName: string) => (
columnName !== GONAVI_ROW_KEY && columnName !== ORACLE_ROWID_LOCATOR_COLUMN
);
describe('DataGrid commit change set', () => {
it('uses unique locator values instead of falling back to the whole row', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': { [GONAVI_ROW_KEY]: 'row-1', EMAIL: 'a@example.com', NAME: 'new-name', AGE: 42 },
},
deletedRowKeys: new Set(),
data: [{ [GONAVI_ROW_KEY]: 'row-1', EMAIL: 'a@example.com', NAME: 'old-name', AGE: 42 }],
editLocator: {
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
},
visibleColumnNames: ['EMAIL', 'NAME', 'AGE'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({
ok: true,
changes: {
inserts: [],
updates: [{ keys: { EMAIL: 'a@example.com' }, values: { NAME: 'new-name' } }],
deletes: [],
},
});
});
it('uses hidden Oracle ROWID only as locator and excludes it from update values', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'BBBB' },
},
deletedRowKeys: new Set(),
data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }],
editLocator: {
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
},
visibleColumnNames: ['NAME'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({
ok: true,
changes: {
inserts: [],
updates: [{ keys: { ROWID: 'AAAA' }, values: { NAME: 'new-name' } }],
deletes: [],
},
});
});
it('commits only writable result columns and maps aliases back to table columns', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': {
[GONAVI_ROW_KEY]: 'row-1',
DISPLAY_NAME: 'new-name',
NAME_UPPER: 'NEW-NAME',
},
},
deletedRowKeys: new Set(),
data: [{
[GONAVI_ROW_KEY]: 'row-1',
ID: 7,
DISPLAY_NAME: 'old-name',
NAME_UPPER: 'OLD-NAME',
}],
editLocator: {
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['ID'],
writableColumns: {
DISPLAY_NAME: 'NAME',
},
readOnly: false,
},
visibleColumnNames: ['DISPLAY_NAME', 'NAME_UPPER'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({
ok: true,
changes: {
inserts: [],
updates: [{ keys: { ID: 7 }, values: { NAME: 'new-name' } }],
deletes: [],
},
});
});
it('fails closed when no safe locator is available', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name' },
},
deletedRowKeys: new Set(),
data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name' }],
editLocator: undefined,
visibleColumnNames: ['NAME'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({ ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' });
});
it('rejects delete rows when unique locator value is null', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {},
deletedRowKeys: new Set(['row-1']),
data: [{ [GONAVI_ROW_KEY]: 'row-1', EMAIL: null, NAME: 'old-name' }],
editLocator: {
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
},
visibleColumnNames: ['EMAIL', 'NAME'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({ ok: false, error: '定位列 EMAIL 的值为空,无法安全提交修改。' });
});
});
describe('DataGrid DDL interactions', () => {
beforeEach(() => {
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' });
vi.stubGlobal('document', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
activeElement: null,
elementFromPoint: vi.fn(() => null),
createElement: vi.fn(() => ({
style: {},
getContext: vi.fn(() => ({ measureText: vi.fn(() => ({ width: 0 })) })),
})),
body: { style: {} },
});
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
innerHeight: 768,
innerWidth: 1024,
getComputedStyle: vi.fn(() => ({ font: '12px sans-serif' })),
});
vi.stubGlobal('navigator', {
platform: 'MacIntel',
userAgent: '',
clipboard: { writeText: vi.fn(() => Promise.resolve()) },
});
vi.stubGlobal('HTMLElement', class {});
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
backendApp.ImportData.mockReset();
backendApp.ExportTable.mockReset();
backendApp.ExportData.mockReset();
backendApp.ExportQuery.mockReset();
backendApp.ApplyChanges.mockReset();
backendApp.DBGetColumns.mockReset();
backendApp.DBGetIndexes.mockReset();
backendApp.DBShowCreateTable.mockReset();
vi.unstubAllGlobals();
});
it('ignores stale DDL responses after the table context changes', async () => {
let resolveFirstRequest: (value: any) => void = () => {};
backendApp.DBShowCreateTable.mockReturnValueOnce(new Promise((resolve) => {
resolveFirstRequest = resolve;
}));
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await act(async () => {
renderer!.update(
<DataGrid
data={[{ __gonavi_row_key__: 'row-2', id: 2 }]}
columnNames={['id']}
loading={false}
tableName="orders"
dbName="main"
connectionId="conn-1"
/>,
);
resolveFirstRequest({ success: true, data: 'CREATE TABLE users' });
});
await waitForEffects();
expect(textContent(renderer!.root)).not.toContain('CREATE TABLE users');
expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,220 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import DataGrid, { formatCellDisplayText } from './DataGrid';
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
connections: [],
addSqlLog: vi.fn(),
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
},
queryOptions: {
showColumnComment: false,
showColumnType: false,
},
setQueryOptions: vi.fn(),
tableColumnOrders: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
clearTableColumnOrder: vi.fn(),
tableHiddenColumns: {},
enableHiddenColumnMemory: false,
setTableHiddenColumns: vi.fn(),
setEnableHiddenColumnMemory: vi.fn(),
clearTableHiddenColumns: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}),
}));
vi.mock('../../wailsjs/go/app/App', () => ({
ImportData: vi.fn(),
ExportTable: vi.fn(),
ExportData: vi.fn(),
ExportQuery: vi.fn(),
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
vi.mock('@monaco-editor/react', () => ({
default: () => null,
}));
describe('DataGrid layout', () => {
it('renders a secondary action strip for view switching and auxiliary actions', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
readOnly
pagination={{
current: 1,
pageSize: 100,
total: 1,
}}
onPageChange={() => {}}
/>,
);
expect(markup).toContain('data-grid-secondary-actions="true"');
expect(markup).toContain('data-grid-view-switcher="true"');
expect(markup).toContain('data-grid-page-find="true"');
expect(markup).toContain('data-grid-page-find-prev="true"');
expect(markup).toContain('data-grid-page-find-next="true"');
expect(markup).toContain('当前页查找...');
});
it('preserves fractional seconds when rendering datetime values', () => {
expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456');
});
it('renders a DDL action for table data pages only', () => {
const tableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
expect(tableMarkup).toContain('查看 DDL');
expect(tableMarkup).not.toContain('data-grid-locate-sidebar-action="true"');
const schemaTableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="public.users"
dbName=""
connectionId="conn-1"
/>,
);
expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"');
expect(schemaTableMarkup).toContain('查看 DDL');
expect(schemaTableMarkup).toContain('data-grid-page-find="true"');
const queryMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
exportScope="queryResult"
/>,
);
expect(queryMarkup).not.toContain('data-grid-ddl-action="true"');
});
it('renders row copy and paste actions in editable table toolbar', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
pkColumns={['id']}
/>,
);
expect(markup).toContain('data-grid-copy-row-action="true"');
expect(markup).toContain('data-grid-paste-row-action="true"');
expect(markup).toContain('复制行');
expect(markup).toContain('粘贴行');
});
it('renders a clickable copy action for aggregate query results', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
'COUNT(*)': 12,
},
]}
columnNames={['COUNT(*)']}
loading={false}
exportScope="queryResult"
/>,
);
expect(markup).toContain('data-grid-query-copy-action="true"');
expect(markup).not.toMatch(/data-grid-query-copy-action="true"[^>]*disabled/);
expect(markup).toContain('复制');
});
it('renders a quick WHERE condition editor when table filters are visible', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
showFilter
quickWhereCondition="name like 'a%'"
onApplyQuickWhereCondition={() => {}}
/>,
);
expect(markup).toContain('data-grid-quick-where="true"');
expect(markup).toContain('WHERE');
expect(markup).toContain('输入 WHERE 后面的条件');
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,239 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import DataViewer from './DataViewer';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'oracle',
config: {
type: 'oracle',
host: '127.0.0.1',
port: 1521,
user: 'scott',
password: '',
database: 'ORCLPDB1',
},
},
],
addSqlLog: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBQuery: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
warning: vi.fn(),
}));
const dataGridState = vi.hoisted(() => ({
latestProps: null as any,
}));
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('antd', () => ({
message: messageApi,
}));
vi.mock('./DataGrid', () => ({
default: (props: any) => {
dataGridState.latestProps = props;
return <div data-grid="true" />;
},
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'EDC_LOG',
type: 'table',
connectionId: 'conn-1',
dbName: 'MYCIMLED',
tableName: 'EDC_LOG',
...overrides,
});
const flushPromises = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe('DataViewer safe editing locator', () => {
const renderAndReload = async (tab: TabData = createTab()) => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DataViewer tab={tab} />);
});
await act(async () => {
await dataGridState.latestProps.onReload();
});
await flushPromises();
return renderer!;
};
beforeEach(() => {
vi.clearAllMocks();
dataGridState.latestProps = null;
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQuery.mockResolvedValue({
success: true,
fields: ['ID', 'NAME'],
data: [{ ID: 7, NAME: 'old-name' }],
});
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
});
it('enables table preview editing after primary keys are loaded', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
const renderer = await renderAndReload();
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['ID'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
renderer.unmount();
});
it('uses a unique index when the table has no primary key', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'EMAIL', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValue({
success: true,
data: [{ name: 'UK_EMAIL', columnName: 'EMAIL', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' }],
});
const renderer = await renderAndReload();
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
renderer.unmount();
});
it('uses hidden Oracle ROWID when no primary or unique key is available', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBQuery.mockResolvedValue({
success: true,
fields: ['ID', 'NAME', ORACLE_ROWID_LOCATOR_COLUMN],
data: [{ ID: 7, NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }],
});
const renderer = await renderAndReload();
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
expect(backendApp.DBQuery.mock.calls.some((call: any[]) => String(call[2]).includes(`ROWID AS "${ORACLE_ROWID_LOCATOR_COLUMN}"`))).toBe(true);
renderer.unmount();
});
it('does not add fallback ORDER BY for DuckDB table preview when a primary key is available', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-order', dbName: 'main', tableName: 'events', title: 'events' }));
const tableQueries = backendApp.DBQuery.mock.calls
.map((call: any[]) => String(call[2] || ''))
.filter((sql: string) => sql.includes('FROM "events"'));
expect(tableQueries.length).toBeGreaterThan(0);
expect(tableQueries.every((sql: string) => !/\border\s+by\b/i.test(sql))).toBe(true);
expect(tableQueries[tableQueries.length - 1]).toContain('LIMIT 101 OFFSET 0');
renderer.unmount();
});
it('shows an actionable message for DuckDB timeout interruption errors', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBQuery.mockResolvedValue({
success: false,
message: 'context deadline exceeded INTERRUPT Error: Interrupted!',
fields: [],
data: [],
});
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-timeout', dbName: 'main', tableName: 'events', title: 'events' }));
expect(messageApi.error).toHaveBeenCalledWith('DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。');
expect(storeState.addSqlLog.mock.calls.some((call: any[]) => String(call[0]?.message || '').includes('context deadline exceeded'))).toBe(true);
renderer.unmount();
});
it('keeps non-Oracle table preview read-only when no safe locator exists', async () => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
});
const renderer = await renderAndReload(createTab({ dbName: 'main', tableName: 'users', title: 'users' }));
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith('表 main.users 保持只读:未检测到主键或可用唯一索引,无法安全提交修改。');
renderer.unmount();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,233 @@
import React from 'react';
// ─── 公共接口 ───────────────────────────────────────────────
export interface DbIconProps {
size?: number;
color?: string;
}
// ─── 默认色表 ───────────────────────────────────────────────
const DB_DEFAULT_COLORS: Record<string, string> = {
mysql: '#00758F',
mariadb: '#003545',
oceanbase: '#0052CC',
postgres: '#336791',
redis: '#DC382D',
mongodb: '#47A248',
jvm: '#1677FF',
kingbase: '#1890FF',
dameng: '#E6002D',
oracle: '#F80000',
sqlserver: '#CC2927',
clickhouse: '#FFBF00',
sqlite: '#003B57',
duckdb: '#FFC107',
vastbase: '#0066CC',
opengauss: '#2446A8',
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', 'sqlserver',
]);
/** 品牌 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 OceanBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oceanbase} label="OB" />
);
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 }) => (
<BrandSvgIcon type="sqlserver" size={size} color={color} />
);
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 OpenGaussIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.opengauss} label="OG" />
);
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" />
);
const JVMIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.jvm} label="JVM" />
);
/** 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,
oceanbase: OceanBaseIcon,
diros: DorisIcon,
sphinx: SphinxIcon,
postgres: PostgresIcon,
redis: RedisIcon,
mongodb: MongoDBIcon,
jvm: JVMIcon,
kingbase: KingBaseIcon,
dameng: DamengIcon,
oracle: OracleIcon,
sqlserver: SQLServerIcon,
clickhouse: ClickHouseIcon,
sqlite: SQLiteIcon,
duckdb: DuckDBIcon,
vastbase: VastBaseIcon,
opengauss: OpenGaussIcon,
highgo: HighGoIcon,
tdengine: TDengineIcon,
custom: CustomIcon,
};
/** 可选图标类型列表(用于图标选择器 UI */
export const DB_ICON_TYPES: string[] = [
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
'kingbase', 'dameng', 'vastbase', 'opengauss', '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', oceanbase: 'OceanBase', postgres: 'PostgreSQL',
redis: 'Redis', mongodb: 'MongoDB', jvm: 'JVM',
oracle: 'Oracle',
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', opengauss: 'OpenGauss', 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,494 @@
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';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
interface DefinitionViewerProps {
tab: TabData;
}
const normalizeMySQLViewDDL = (rawDefinition: unknown): string => {
const text = String(rawDefinition || '').trim();
if (!text) return '';
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
if (createViewPrefixPattern.test(normalized)) {
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
}
if (/^\s*(select|with)\b/i.test(normalized)) {
return normalized;
}
return `${normalized};`;
};
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';
if (driver === 'oceanbase') return 'mysql';
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
return driver;
}
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || 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':
case 'opengauss': {
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':
case 'opengauss': {
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(buildRpcConnectionConfig(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(buildRpcConnectionConfig(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 normalizeMySQLViewDDL(textDefinition);
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
if (sqlKey) return normalizeMySQLViewDDL(row[sqlKey]);
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
if (tableSqlKey) return normalizeMySQLViewDDL(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 normalizeMySQLViewDDL(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,465 @@
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';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isMacLikePlatform } from '../utils/appearance';
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 disableLocalBackdropFilter = isMacLikePlatform();
const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
const wt = useMemo(() => {
const isDark = theme === 'dark';
return buildOverlayWorkbenchTheme(isDark, { disableBackdropFilter: disableLocalBackdropFilter });
}, [disableLocalBackdropFilter, theme]);
const buildConfig = useCallback(() => {
if (!conn) return null;
return {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
}, [conn]);
const handleSearch = useCallback(async () => {
const searchKeyword = keyword.trim();
if (!searchKeyword) {
message.warning('请输入搜索关键字');
return;
}
const config = buildConfig();
if (!config) {
message.error('未找到连接配置');
return;
}
setSearching(true);
setResults([]);
setExpandedTable(null);
cancelledRef.current = false;
try {
// 1. 获取所有表
const tablesRes = await DBGetTables(buildRpcConnectionConfig(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(buildRpcConnectionConfig(config) as any, dbName);
const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : [];
// 按表名分组
const columnsByTable: Record<string, Array<{ name: string; type: string }>> = {};
allColumns.forEach((col: any) => {
const tbl = col.tableName || '';
if (!columnsByTable[tbl]) columnsByTable[tbl] = [];
columnsByTable[tbl].push({ name: col.name, type: col.type || '' });
});
const searchResults: SearchResultItem[] = [];
const escapedKeyword = escapeLiteral(searchKeyword);
// 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(buildRpcConnectionConfig(config) as any, dbName, sql);
if (res.success && Array.isArray(res.data) && res.data.length > 0) {
// 检查哪些列实际匹配了
const matchedCols = new Set<string>();
const lowerKeyword = searchKeyword.toLowerCase();
res.data.forEach((row: any) => {
textCols.forEach(c => {
const val = row[c.name];
if (val != null) {
const strVal = String(val).toLowerCase();
if (matchMode === 'exact' ? strVal === lowerKeyword : strVal.includes(lowerKeyword)) {
matchedCols.add(c.name);
}
}
});
});
if (matchedCols.size > 0) {
const columns = Object.keys(res.data[0]);
searchResults.push({
tableName,
matchedColumns: Array.from(matchedCols),
matchCount: res.data.length,
rows: res.data,
columns,
});
setResults([...searchResults]);
}
}
} catch {
// 单表查询失败不中断整体搜索
}
}
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,251 @@
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';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
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(buildRpcConnectionConfig(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

@@ -0,0 +1,48 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMAuditViewer from "./JVMAuditViewer";
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
connections: [
{
id: "conn-jvm-1",
name: "orders-jvm",
config: {
host: "localhost",
port: 10990,
jvm: {
preferredMode: "endpoint",
readOnly: false,
},
},
},
],
theme: "light",
}),
}));
describe("JVMAuditViewer", () => {
it("renders a unified JVM workspace audit shell", () => {
const markup = renderToStaticMarkup(
<JVMAuditViewer
tab={{
id: "tab-jvm-audit",
type: "jvm-audit",
title: "[orders-jvm] JVM 审计",
connectionId: "conn-jvm-1",
providerMode: "endpoint",
} as any}
/>,
);
expect(markup).toContain('data-jvm-workspace-shell="true"');
expect(markup).toContain('data-jvm-workspace-hero="true"');
expect(markup).toContain("JVM 变更审计");
expect(markup).toContain("审计记录");
expect(markup).toContain("最近 50 条");
});
});

View File

@@ -0,0 +1,271 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Card,
Empty,
Select,
Space,
Table,
Tag,
Typography,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import { ReloadOutlined } from "@ant-design/icons";
import { useStore } from "../store";
import type { JVMAuditRecord, TabData } from "../types";
import {
formatJVMAuditResultLabel,
formatJVMActionDisplayText,
resolveJVMAuditResultColor,
} from "../utils/jvmResourcePresentation";
import JVMModeBadge from "./jvm/JVMModeBadge";
import {
getJVMWorkspaceCardStyle,
JVMWorkspaceHero,
JVMWorkspaceShell,
} from "./jvm/JVMWorkspaceLayout";
const { Text } = Typography;
type JVMAuditViewerProps = {
tab: TabData;
};
const LIMIT_OPTIONS = [20, 50, 100, 200];
const normalizeAuditRecords = (value: any): JVMAuditRecord[] => {
if (Array.isArray(value)) {
return value as JVMAuditRecord[];
}
if (Array.isArray(value?.data)) {
return value.data as JVMAuditRecord[];
}
return [];
};
const filterAuditRecordsByMode = (
records: JVMAuditRecord[],
providerMode?: string,
): JVMAuditRecord[] => {
const normalizedMode = String(providerMode || "")
.trim()
.toLowerCase();
if (!normalizedMode) {
return records;
}
return records.filter(
(record) =>
String(record.providerMode || "")
.trim()
.toLowerCase() === normalizedMode,
);
};
const formatTimestamp = (timestamp: number): string => {
if (!timestamp) {
return "-";
}
const normalized = timestamp > 1e12 ? timestamp : timestamp * 1000;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return String(timestamp);
}
return date.toLocaleString("zh-CN", { hour12: false });
};
const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const theme = useStore((state) => state.theme);
const darkMode = theme === "dark";
const [limit, setLimit] = useState(50);
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<JVMAuditRecord[]>([]);
const [error, setError] = useState("");
const columns = useMemo<ColumnsType<JVMAuditRecord>>(
() => [
{
title: "时间",
dataIndex: "timestamp",
key: "timestamp",
width: 180,
render: (value: number) => formatTimestamp(value),
},
{
title: "模式",
dataIndex: "providerMode",
key: "providerMode",
width: 120,
render: (value: string) => (
<JVMModeBadge mode={value || tab.providerMode || "jmx"} />
),
},
{
title: "动作",
dataIndex: "action",
key: "action",
width: 160,
render: (value: string) => formatJVMActionDisplayText(value) || "-",
},
{
title: "资源",
dataIndex: "resourceId",
key: "resourceId",
ellipsis: true,
render: (value: string) => value || "-",
},
{
title: "原因",
dataIndex: "reason",
key: "reason",
ellipsis: true,
render: (value: string) => value || "-",
},
{
title: "来源",
dataIndex: "source",
key: "source",
width: 120,
render: (value?: string) => {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "ai-plan") {
return <Tag color="purple">AI </Tag>;
}
return <Tag></Tag>;
},
},
{
title: "结果",
dataIndex: "result",
key: "result",
width: 140,
render: (value: string) => (
<Tag color={resolveJVMAuditResultColor(value)}>
{formatJVMAuditResultLabel(value)}
</Tag>
),
},
],
[tab.providerMode],
);
const loadRecords = async () => {
if (!connection) {
setLoading(false);
setRecords([]);
setError("连接不存在或已被删除");
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListAuditRecords !== "function") {
setLoading(false);
setRecords([]);
setError("JVMListAuditRecords 后端方法不可用");
return;
}
setLoading(true);
setError("");
try {
const result = await backendApp.JVMListAuditRecords(connection.id, limit);
if (result?.success === false) {
setRecords([]);
setError(String(result?.message || "读取 JVM 审计记录失败"));
return;
}
setRecords(
filterAuditRecordsByMode(
normalizeAuditRecords(result),
tab.providerMode,
),
);
} catch (err: any) {
setRecords([]);
setError(err?.message || "读取 JVM 审计记录失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRecords();
}, [connection, limit, tab.connectionId]);
if (!connection) {
return (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
const activeMode =
tab.providerMode || connection.config.jvm?.preferredMode || "jmx";
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
return (
<JVMWorkspaceShell darkMode={darkMode}>
<JVMWorkspaceHero
darkMode={darkMode}
eyebrow="JVM Audit"
title="JVM 变更审计"
description={
<>
<Text strong>{connection.name}</Text>
<Text type="secondary"> · {connection.id}</Text>
<Text type="secondary"> · {limit} </Text>
</>
}
badges={<JVMModeBadge mode={activeMode} />}
actions={
<>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => void loadRecords()}
>
</Button>
<Select
size="small"
value={limit}
onChange={setLimit}
options={LIMIT_OPTIONS.map((item) => ({
value: item,
label: `最近 ${item}`,
}))}
style={{ width: 132 }}
/>
</>
}
/>
<Card title="审计记录" variant="borderless" style={cardStyle}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
<Table<JVMAuditRecord>
rowKey={(record) =>
`${record.timestamp}-${record.resourceId}-${record.action}`
}
loading={loading}
columns={columns}
dataSource={records}
pagination={false}
locale={{
emptyText: error ? "当前无法加载审计记录" : "暂无审计记录",
}}
scroll={{ x: 960 }}
size="small"
/>
</Space>
</Card>
</JVMWorkspaceShell>
);
};
export default JVMAuditViewer;

View File

@@ -0,0 +1,946 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create } from "react-test-renderer";
import { message } from "antd";
import { beforeEach, describe, expect, it, vi } from "vitest";
import JVMDiagnosticConsole, {
createJVMDiagnosticLocalPendingChunk,
createJVMDiagnosticRunningRecord,
isJVMDiagnosticTerminalPhase,
} from "./JVMDiagnosticConsole";
const baseState = {
connections: [
{
id: "conn-1",
name: "orders-jvm",
config: {
host: "orders.internal",
jvm: {
diagnostic: {
enabled: true,
transport: "agent-bridge",
},
},
},
},
],
jvmDiagnosticDrafts: {},
jvmDiagnosticOutputs: {},
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
let mockState: any = baseState;
let registeredCompletionProvider: any = null;
let registeredDiagnosticChunkHandler: any = null;
const mockBackendApp = {
JVMListDiagnosticAuditRecords: vi.fn(),
JVMExecuteDiagnosticCommand: vi.fn(),
};
const mockMonaco = {
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(
startLineNumber: number,
startColumn: number,
endLineNumber: number,
endColumn: number,
) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
KeyMod: { CtrlCmd: 2048 },
KeyCode: { Enter: 3 },
editor: {
setTheme: vi.fn(),
},
languages: {
CompletionItemKind: {
Keyword: 1,
Snippet: 2,
Value: 3,
},
CompletionItemInsertTextRule: {
InsertAsSnippet: 4,
},
register: vi.fn(),
registerCompletionItemProvider: vi.fn((language: string, provider: any) => {
if (language === "jvm-diagnostic") {
registeredCompletionProvider = provider;
}
return { dispose: vi.fn() };
}),
},
};
const mockEditor = {
addCommand: vi.fn(),
};
vi.mock("@monaco-editor/react", () => ({
default: ({
beforeMount,
language,
onMount,
value,
}: {
beforeMount?: (monaco: any) => void;
language?: string;
onMount?: (editor: any, monaco: any) => void;
value?: string;
}) => {
beforeMount?.(mockMonaco);
onMount?.(mockEditor, mockMonaco);
return (
<div
data-before-mount={beforeMount ? "true" : "false"}
data-monaco-editor-mock="true"
data-language={language}
>
{value}
</div>
);
},
}));
vi.mock("../../wailsjs/runtime", () => ({
EventsOn: vi.fn((_eventName: string, handler: any) => {
registeredDiagnosticChunkHandler = handler;
return vi.fn();
}),
}));
vi.mock("@ant-design/icons", () => {
const Icon = () => <span />;
return {
ClearOutlined: Icon,
HistoryOutlined: Icon,
PauseCircleOutlined: Icon,
PlayCircleOutlined: Icon,
ReloadOutlined: Icon,
RocketOutlined: Icon,
ToolOutlined: Icon,
};
});
vi.mock("antd", () => {
const passthrough = ({ children, style }: any) => <div style={style}>{children}</div>;
const Text = ({ children, style }: any) => <span style={style}>{children}</span>;
const Paragraph = ({ children, style }: any) => <p style={style}>{children}</p>;
const Title = ({ children, style }: any) => <h3 style={style}>{children}</h3>;
const Empty = ({ description }: any) => <div>{description}</div>;
Empty.PRESENTED_IMAGE_SIMPLE = "simple";
const List = ({ dataSource = [], renderItem }: any) => (
<div>{dataSource.map((item: any, index: number) => renderItem(item, index))}</div>
);
List.Item = ({ children, style }: any) => <div style={style}>{children}</div>;
const Typography = { Text, Paragraph, Title };
return {
Alert: ({ message: alertMessage, description, style }: any) => (
<div style={style}>{alertMessage}{description}</div>
),
Button: ({ children, onClick, disabled, style }: any) => <button onClick={onClick} disabled={disabled} style={style}>{children}</button>,
Card: ({ children, title, style }: any) => <section style={style}>{title}{children}</section>,
Empty,
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
List,
Space: passthrough,
Tag: ({ children, style }: any) => <span style={style}>{children}</span>,
Typography,
message: {
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
};
});
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) => selector(mockState),
}));
describe("JVMDiagnosticConsole", () => {
beforeEach(() => {
registeredCompletionProvider = null;
registeredDiagnosticChunkHandler = null;
mockState = {
...baseState,
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
mockBackendApp.JVMListDiagnosticAuditRecords.mockResolvedValue({
success: true,
data: [],
});
mockBackendApp.JVMExecuteDiagnosticCommand.mockReset();
vi.mocked(message.success).mockClear();
vi.mocked(message.warning).mockClear();
vi.mocked(message.info).mockClear();
(globalThis as any).window = {
...(globalThis as any).window,
go: { app: { App: mockBackendApp } },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
mockMonaco.editor.setTheme.mockClear();
mockMonaco.languages.register.mockClear();
mockMonaco.languages.registerCompletionItemProvider.mockClear();
mockEditor.addCommand.mockClear();
});
it("builds local pending output and history while a command is waiting for backend events", () => {
const chunk = createJVMDiagnosticLocalPendingChunk({
sessionId: "session-1",
commandId: "cmd-1",
command: "thread -n 5",
});
const record = createJVMDiagnosticRunningRecord({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
source: "manual",
reason: "排查线程",
});
expect(chunk).toMatchObject({
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
});
expect(chunk.content).toContain("thread -n 5");
expect(record).toMatchObject({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
status: "running",
reason: "排查线程",
});
expect(isJVMDiagnosticTerminalPhase("completed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("failed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("running")).toBe(false);
});
it("keeps a stable workbench shell and hides command inputs before session creation", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("开始一次诊断");
expect(markup).toContain("命令输入将在会话建立后显示");
expect(markup).toContain("先建立会话,再显示命令编辑器和模板");
expect(markup).toContain("会话与能力");
expect(markup).toContain("审计历史");
expect(markup).not.toContain("命令模板");
expect(markup).not.toContain("实时输出");
expect(markup).not.toContain('data-monaco-editor-mock="true"');
});
it("shows command input, reason field, and presets after a session exists", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("overflow:auto");
expect(markup).toContain("JVM 诊断工作台");
expect(markup).toContain("会话与能力");
expect(markup).toContain("实时输出");
expect(markup).toContain("审计历史");
expect(markup.indexOf("命令输入")).toBeGreaterThanOrEqual(0);
expect(markup).toContain("诊断命令");
expect(markup).toContain("诊断原因(可选)");
expect(markup).toContain("用于审计记录");
expect(markup.indexOf("命令输入")).toBeLessThan(markup.indexOf("实时输出"));
expect(markup).toContain("观察类命令");
expect(markup).toContain("thread");
expect(markup).toContain("执行命令");
expect(markup).toContain('data-monaco-editor-mock="true"');
expect(markup).toContain('data-language="jvm-diagnostic"');
});
it("redacts sensitive diagnostic output in the rendered console", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "watch com.foo.SecretService read '{returnObj}'",
},
},
jvmDiagnosticOutputs: {
"tab-1": [
{
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "password=secret-token\napiKey: api-key-secret",
},
],
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("password=********");
expect(markup).toContain("apiKey: ********");
expect(markup).not.toContain("secret-token");
expect(markup).not.toContain("api-key-secret");
});
it("uses the same styled editor shell and registers command completion before mount", () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thr",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain(
'data-jvm-diagnostic-command-editor-shell="true"',
);
expect(markup).toContain('data-before-mount="true"');
expect(markup).toContain("border-radius:14px");
expect(registeredCompletionProvider).toBeTruthy();
const result = registeredCompletionProvider.provideCompletionItems(
{
getValueInRange: () => "thr",
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 4 }),
},
{ lineNumber: 1, column: 4 },
);
expect(result.suggestions).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: "thread",
insertText: "thread ",
}),
]),
);
});
it("redacts failed diagnostic event content before storing and alerting", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts successful diagnostic warning messages", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
mockBackendApp.JVMExecuteDiagnosticCommand.mockResolvedValue({
success: true,
message: "api_key=query-secret",
});
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
expect(message.warning).toHaveBeenCalledWith("api_key=********");
expect(message.warning).not.toHaveBeenCalledWith(
expect.stringContaining("query-secret"),
);
});
it("redacts successful diagnostic warning messages with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({
success: true,
message: "def456\n-----END PRIVATE KEY-----",
});
});
expect(JSON.stringify((message.warning as any).mock.calls)).not.toContain(
"def456",
);
});
it("keeps diagnostic redaction state after clearing visible output", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIV",
},
});
});
const clearButton = renderer.root
.findAllByType("button")
.find((button: any) => button.children.includes("清空输出"));
await act(async () => {
clearButton.props.onClick();
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "ATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(mockState.clearJVMDiagnosticOutput).toHaveBeenCalledWith("tab-1");
expect(JSON.stringify(appendedChunks)).not.toContain("ATE KEY");
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
});
it("redacts frontend fallback errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("keeps diagnostic redaction state after local completion fallback", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({ success: true });
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("redacts terminal-seen execute errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "still waiting for execute call",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts execute errors after a real failed terminal event closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts delayed failed terminal events after frontend fallback closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMMonitoringDashboard from "./JVMMonitoringDashboard";
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
theme: "light",
connections: [
{
id: "conn-1",
name: "orders-jvm",
config: {
host: "orders.internal",
port: 9010,
jvm: {
preferredMode: "jmx",
allowedModes: ["jmx"],
},
},
},
],
}),
}));
describe("JVMMonitoringDashboard", () => {
it("shows start action and empty-state guidance before monitoring starts", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-1",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain("开始监控");
expect(markup).toContain("当前尚未开始持续监控");
expect(markup).toContain("堆内存");
expect(markup).toContain("暂无堆内存采样数据");
expect(markup).not.toContain("暂无 Heap 采样数据");
expect(markup).not.toContain("当前 provider 未提供 Heap 指标");
});
it("renders a dedicated vertical scroll shell for tall monitoring content", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-scroll",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain('data-jvm-monitoring-dashboard-scroll-shell="true"');
expect(markup).toContain("height:100%");
expect(markup).toContain("overflow-y:auto");
});
it("stacks monitoring charts before detail panels so charts keep full content width", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-layout",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain('data-jvm-monitoring-content-stack="true"');
expect(markup).toContain("gap:24px");
expect(markup).not.toContain("minmax(min(100%, 320px), 1fr)");
});
});

View File

@@ -0,0 +1,392 @@
import React, { useEffect, useMemo, useState } from "react";
import { Alert, Button, Card, Empty, Space, Spin, Tag, Typography } from "antd";
import { DashboardOutlined, PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons";
import { useStore } from "../store";
import type { JVMMonitoringSessionState, TabData } from "../types";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import {
buildMonitoringAvailabilityText,
normalizeMonitoringProviderMode,
type JVMMonitoringProviderMode,
} from "../utils/jvmMonitoringPresentation";
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
import JVMMonitoringCharts from "./jvm/JVMMonitoringCharts";
import JVMMonitoringDetailPanel from "./jvm/JVMMonitoringDetailPanel";
import JVMMonitoringStatusCards from "./jvm/JVMMonitoringStatusCards";
const { Paragraph, Text, Title } = Typography;
const POLL_INTERVAL_MS = 2000;
type JVMMonitoringDashboardProps = {
tab: TabData;
};
const isMonitoringSessionMissing = (message: string): boolean =>
/monitoring session not found/i.test(String(message || ""));
const createEmptySession = (
connectionId: string,
providerMode: JVMMonitoringProviderMode,
): JVMMonitoringSessionState => ({
connectionId,
providerMode,
running: false,
points: [],
recentGcEvents: [],
availableMetrics: [],
missingMetrics: [],
providerWarnings: [],
});
const normalizeMonitoringSession = (
payload: any,
connectionId: string,
providerMode: JVMMonitoringProviderMode,
): JVMMonitoringSessionState => ({
connectionId: String(payload?.connectionId || connectionId),
providerMode: normalizeMonitoringProviderMode(payload?.providerMode, providerMode),
running: payload?.running === true,
points: Array.isArray(payload?.points) ? payload.points : [],
recentGcEvents: Array.isArray(payload?.recentGcEvents) ? payload.recentGcEvents : [],
availableMetrics: Array.isArray(payload?.availableMetrics)
? payload.availableMetrics
: [],
missingMetrics: Array.isArray(payload?.missingMetrics) ? payload.missingMetrics : [],
providerWarnings: Array.isArray(payload?.providerWarnings)
? payload.providerWarnings
: [],
});
const resolveBackendApp = () =>
typeof window === "undefined" ? undefined : (window as any).go?.app?.App;
const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab }) => {
const theme = useStore((state) => state.theme);
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const darkMode = theme === "dark";
const providerMode = normalizeMonitoringProviderMode(
tab.providerMode,
normalizeMonitoringProviderMode(connection?.config.jvm?.preferredMode, "jmx"),
);
const [session, setSession] = useState<JVMMonitoringSessionState>(() =>
createEmptySession(tab.connectionId, providerMode),
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const [pollSeed, setPollSeed] = useState(0);
const rpcConnectionConfig = useMemo(() => {
if (!connection) {
return null;
}
return buildRpcConnectionConfig(connection.config, {
database: "",
jvm: {
...(connection.config.jvm || {}),
preferredMode: providerMode,
allowedModes: [providerMode],
},
});
}, [connection, providerMode]);
const latestPoint = useMemo(() => {
const points = session.points || [];
return points.length > 0 ? points[points.length - 1] : undefined;
}, [session.points]);
useEffect(() => {
setSession(createEmptySession(tab.connectionId, providerMode));
}, [tab.connectionId, providerMode]);
useEffect(() => {
if (!connection || !rpcConnectionConfig) {
setLoading(false);
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const backendApp = resolveBackendApp();
const poll = async () => {
if (cancelled) {
return;
}
setLoading(true);
if (typeof backendApp?.JVMGetMonitoringHistory !== "function") {
setError("JVMGetMonitoringHistory 后端方法不可用");
setLoading(false);
return;
}
try {
const result = await backendApp.JVMGetMonitoringHistory(
rpcConnectionConfig,
providerMode,
);
if (cancelled) {
return;
}
if (result?.success === false) {
const message = String(result?.message || "读取监控历史失败");
if (isMonitoringSessionMissing(message)) {
setSession(createEmptySession(tab.connectionId, providerMode));
setError("");
setLoading(false);
return;
}
throw new Error(message);
}
const nextSession = normalizeMonitoringSession(
result?.data,
tab.connectionId,
providerMode,
);
setSession(nextSession);
setError("");
setLoading(false);
if (nextSession.running) {
timer = setTimeout(poll, POLL_INTERVAL_MS);
}
} catch (fetchError: any) {
if (!cancelled) {
setError(fetchError?.message || "读取监控历史失败");
setLoading(false);
}
}
};
void poll();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed]);
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 80 }} />;
}
const backendApp = resolveBackendApp();
const availabilityText = buildMonitoringAvailabilityText(session);
const modeMeta = resolveJVMModeMeta(providerMode);
const emptyState = !session.running && (session.points || []).length === 0;
const handleStart = async () => {
if (!rpcConnectionConfig || typeof backendApp?.JVMStartMonitoring !== "function") {
setError("JVMStartMonitoring 后端方法不可用");
return;
}
setActionLoading(true);
setError("");
try {
const result = await backendApp.JVMStartMonitoring(rpcConnectionConfig);
if (result?.success === false) {
throw new Error(String(result?.message || "开始监控失败"));
}
setSession(
normalizeMonitoringSession(result?.data, tab.connectionId, providerMode),
);
setPollSeed((current) => current + 1);
} catch (startError: any) {
setError(startError?.message || "开始监控失败");
} finally {
setActionLoading(false);
}
};
const handleStop = async () => {
if (!rpcConnectionConfig || typeof backendApp?.JVMStopMonitoring !== "function") {
setError("JVMStopMonitoring 后端方法不可用");
return;
}
setActionLoading(true);
setError("");
try {
const result = await backendApp.JVMStopMonitoring(
rpcConnectionConfig,
providerMode,
);
if (result?.success === false) {
throw new Error(String(result?.message || "停止监控失败"));
}
setSession((current) => ({ ...current, running: false }));
setPollSeed((current) => current + 1);
} catch (stopError: any) {
setError(stopError?.message || "停止监控失败");
} finally {
setActionLoading(false);
}
};
return (
<div
className="jvm-monitoring-dashboard-scroll-shell"
data-jvm-monitoring-dashboard-scroll-shell="true"
style={{
height: "100%",
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: 20,
display: "grid",
gap: 16,
alignContent: "start",
background: darkMode ? "#141414" : "#f5f7fb",
}}
>
<Card variant="borderless" style={{ borderRadius: 12 }}>
<Space
direction="vertical"
size={12}
style={{ width: "100%", alignItems: "stretch" }}
>
<Space size={12} wrap style={{ justifyContent: "space-between" }}>
<div>
<Title level={3} style={{ margin: 0 }}>
<DashboardOutlined style={{ color: "#1677ff", marginRight: 8 }} />
JVM
</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
<Text type="secondary">
{" "}
· {connection.config.host}:{connection.config.port}
</Text>
</Paragraph>
</div>
<Space wrap>
<Tag color={modeMeta.color} style={{ marginInlineEnd: 0 }}>
{modeMeta.label}
</Tag>
{session.running ? (
<Tag color="green"></Tag>
) : (
<Tag></Tag>
)}
<Button
icon={<ReloadOutlined />}
onClick={() => setPollSeed((current) => current + 1)}
>
</Button>
{session.running ? (
<Button
danger
type="primary"
icon={<PauseCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStop()}
>
</Button>
) : (
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStart()}
>
</Button>
)}
</Space>
</Space>
{(session.missingMetrics?.length || session.providerWarnings?.length) ? (
<Alert
type="warning"
showIcon
message="监控能力存在降级"
description={availabilityText}
/>
) : null}
{error ? <Alert type="error" showIcon message={error} /> : null}
</Space>
</Card>
{loading && emptyState ? (
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
<Spin />
</div>
) : null}
{emptyState ? (
<div
data-jvm-monitoring-content-stack="true"
style={{
display: "grid",
gap: 24,
alignItems: "start",
}}
>
<Card variant="borderless" style={{ borderRadius: 12 }}>
<Empty
description="当前尚未开始持续监控"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Paragraph type="secondary" style={{ maxWidth: 520, margin: "0 auto 16px" }}>
GoNavi
</Paragraph>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStart()}
>
</Button>
</Empty>
</Card>
<JVMMonitoringCharts
points={session.points || []}
session={session}
darkMode={darkMode}
/>
</div>
) : (
<div
data-jvm-monitoring-content-stack="true"
style={{
display: "grid",
gap: 24,
alignItems: "start",
}}
>
<JVMMonitoringStatusCards
latestPoint={latestPoint}
session={session}
darkMode={darkMode}
/>
<JVMMonitoringCharts
points={session.points || []}
session={session}
darkMode={darkMode}
/>
<JVMMonitoringDetailPanel
session={session}
latestPoint={latestPoint}
darkMode={darkMode}
/>
</div>
)}
</div>
);
};
export default JVMMonitoringDashboard;

View File

@@ -0,0 +1,65 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMOverview from "./JVMOverview";
vi.mock("../../wailsjs/go/app/App", () => ({
JVMProbeCapabilities: vi.fn(),
}));
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
connections: [
{
id: "conn-jvm-1",
name: "orders-jvm",
config: {
host: "localhost",
port: 10990,
jvm: {
preferredMode: "jmx",
allowedModes: ["jmx", "endpoint", "agent"],
readOnly: true,
environment: "dev",
endpoint: {
enabled: true,
baseUrl: "http://localhost:8080/actuator",
},
agent: {
enabled: true,
baseUrl: "http://localhost:8563",
},
},
},
},
],
theme: "light",
}),
}));
describe("JVMOverview", () => {
it("renders a unified JVM workspace overview shell", () => {
const markup = renderToStaticMarkup(
<JVMOverview
tab={{
id: "tab-jvm-overview",
type: "jvm-overview",
title: "[orders-jvm] JVM 概览",
connectionId: "conn-jvm-1",
providerMode: "jmx",
} as any}
/>,
);
expect(markup).toContain('data-jvm-workspace-shell="true"');
expect(markup).toContain('data-jvm-workspace-hero="true"');
expect(markup).toContain("JVM 运行时概览");
expect(markup).toContain("连接摘要");
expect(markup).toContain("模式能力");
expect(markup).toContain("JMX 地址");
expect(markup).toContain("Endpoint");
expect(markup).toContain("Agent");
});
});

View File

@@ -0,0 +1,239 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Card,
Descriptions,
Empty,
Skeleton,
Space,
Tag,
Typography,
} from "antd";
import { useStore } from "../store";
import { JVMProbeCapabilities } from "../../wailsjs/go/app/App";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
import type { JVMCapability, TabData } from "../types";
import JVMModeBadge from "./jvm/JVMModeBadge";
import {
getJVMWorkspaceCardStyle,
JVMWorkspaceHero,
JVMWorkspaceShell,
} from "./jvm/JVMWorkspaceLayout";
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
type JVMOverviewProps = {
tab: TabData;
};
const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
);
const theme = useStore((state) => state.theme);
const darkMode = theme === "dark";
const providerMode =
tab.providerMode || connection?.config.jvm?.preferredMode || "jmx";
const readOnly = connection?.config.jvm?.readOnly !== false;
const allowedModes = connection?.config.jvm?.allowedModes || [];
const [capabilities, setCapabilities] = useState<JVMCapability[]>([]);
const [capabilityLoading, setCapabilityLoading] = useState(true);
const [capabilityError, setCapabilityError] = useState("");
const endpointSummary = useMemo(() => {
if (!connection?.config.jvm?.endpoint) {
return "";
}
const endpoint = connection.config.jvm.endpoint;
if (!endpoint.enabled && !endpoint.baseUrl) {
return "";
}
return endpoint.baseUrl || "已启用";
}, [connection]);
const agentSummary = useMemo(() => {
if (!connection?.config.jvm?.agent) {
return "";
}
const agent = connection.config.jvm.agent;
if (!agent.enabled && !agent.baseUrl) {
return "";
}
return agent.baseUrl || "已启用";
}, [connection]);
const allowedModeSummary = useMemo(() => {
const items = allowedModes.length > 0 ? allowedModes : ["jmx"];
return items.map((item) => resolveJVMModeMeta(item).label).join("、");
}, [allowedModes]);
useEffect(() => {
if (!connection) {
setCapabilities([]);
setCapabilityError("连接不存在或已被删除");
setCapabilityLoading(false);
return;
}
let cancelled = false;
const loadCapabilities = async () => {
setCapabilityLoading(true);
setCapabilityError("");
try {
const result = await JVMProbeCapabilities(
buildRpcConnectionConfig(connection.config, { database: "" }) as any,
);
if (cancelled) {
return;
}
if (result?.success === false) {
setCapabilities([]);
setCapabilityError(
String(result?.message || "读取 JVM 模式能力失败"),
);
return;
}
setCapabilities(
Array.isArray(result?.data) ? (result.data as JVMCapability[]) : [],
);
} catch (error: any) {
if (!cancelled) {
setCapabilities([]);
setCapabilityError(error?.message || "读取 JVM 模式能力失败");
}
} finally {
if (!cancelled) {
setCapabilityLoading(false);
}
}
};
void loadCapabilities();
return () => {
cancelled = true;
};
}, [connection]);
if (!connection) {
return (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
const jmxHost = connection.config.jvm?.jmx?.host || connection.config.host;
const jmxPort = connection.config.jvm?.jmx?.port || connection.config.port;
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
return (
<JVMWorkspaceShell darkMode={darkMode}>
<JVMWorkspaceHero
darkMode={darkMode}
eyebrow="JVM Runtime"
title="JVM 运行时概览"
description={
<>
<Text strong>{connection.name}</Text>
<Text type="secondary">
{" "}
· {connection.config.host}:{connection.config.port}
</Text>
</>
}
badges={
<>
<JVMModeBadge mode={providerMode} />
<Tag color={readOnly ? "blue" : "red"}>
{readOnly ? "只读连接" : "可写连接"}
</Tag>
<Tag>{connection.config.jvm?.environment || "dev"}</Tag>
</>
}
/>
<Card title="连接摘要" variant="borderless" style={cardStyle}>
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
<Descriptions.Item label="当前模式">
{resolveJVMModeMeta(providerMode).label}
</Descriptions.Item>
<Descriptions.Item label="允许模式">
{allowedModeSummary}
</Descriptions.Item>
<Descriptions.Item label="JMX 地址">{`${jmxHost}:${jmxPort}`}</Descriptions.Item>
<Descriptions.Item label="Endpoint">
{endpointSummary || "未配置"}
</Descriptions.Item>
<Descriptions.Item label="Agent">
{agentSummary || "未配置"}
</Descriptions.Item>
<Descriptions.Item label="资源浏览">
{"通过侧边栏展开模式节点后懒加载"}
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="模式能力" variant="borderless" style={cardStyle}>
{capabilityLoading ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : capabilityError ? (
<Alert
type="error"
showIcon
message="读取 JVM 模式能力失败"
description={
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{capabilityError}
</span>
}
/>
) : capabilities.length === 0 ? (
<Empty description="暂无模式能力数据" />
) : (
<Space direction="vertical" size={12} style={{ width: "100%" }}>
{capabilities.map((capability) => (
<div
key={capability.mode}
style={{
border: "1px solid rgba(5, 5, 5, 0.08)",
borderRadius: 8,
padding: 12,
}}
>
<Space size={8} wrap>
<JVMModeBadge mode={capability.mode} />
<Tag color={capability.canBrowse ? "green" : "default"}>
{capability.canBrowse ? "可浏览" : "不可浏览"}
</Tag>
<Tag color={capability.canWrite ? "red" : "blue"}>
{capability.canWrite ? "可写" : "只读"}
</Tag>
<Tag color={capability.canPreview ? "gold" : "default"}>
{capability.canPreview ? "支持预览" : "不支持预览"}
</Tag>
</Space>
{capability.reason ? (
<Text
type="secondary"
style={{
display: "block",
marginTop: 8,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{capability.reason}
</Text>
) : null}
</div>
))}
</Space>
)}
</Card>
</JVMWorkspaceShell>
);
};
export default JVMOverview;

View File

@@ -0,0 +1,563 @@
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import JVMResourceBrowser from "./JVMResourceBrowser";
import type { JVMValueSnapshot } from "../types";
const storeState = vi.hoisted(() => ({
connections: [
{
id: "conn-jvm-writable",
name: "orders-jvm",
config: {
host: "127.0.0.1",
user: "jmx-user",
port: 9010,
type: "jvm",
jvm: {
preferredMode: "jmx",
readOnly: false,
jmx: {
password: "initial-jmx-secret",
},
},
},
},
],
addTab: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
theme: "light",
}));
const backendApp = vi.hoisted(() => ({
JVMGetValue: vi.fn(),
JVMPreviewChange: vi.fn(),
JVMApplyChange: vi.fn(),
}));
vi.mock("@monaco-editor/react", () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
}));
vi.mock("@ant-design/icons", () => ({
FileSearchOutlined: () => <span />,
ReloadOutlined: () => <span />,
RobotOutlined: () => <span />,
}));
vi.mock("antd", () => {
const Text = ({ children }: any) => <span>{children}</span>;
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button
type="button"
data-button-type={type}
disabled={disabled || loading}
onClick={onClick}
{...rest}
>
{children}
</button>
);
const Card = ({ children, title }: any) => (
<section>
<h2>{title}</h2>
{children}
</section>
);
const Descriptions: any = ({ children }: any) => <dl>{children}</dl>;
Descriptions.Item = ({ children, label }: any) => (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
const Input: any = ({ value, onChange, placeholder }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} />
);
Input.TextArea = ({ value, onChange }: any) => (
<textarea value={value} onChange={onChange} />
);
return {
Alert: ({ message }: any) => <div role="alert">{message}</div>,
Button,
Card,
Descriptions,
Empty: ({ description }: any) => <div>{description}</div>,
Input,
Skeleton: () => <div>loading</div>,
Space: ({ children }: any) => <div>{children}</div>,
Tag: ({ children }: any) => <span>{children}</span>,
Typography: { Text },
};
});
vi.mock("../store", () => {
const useStore = (selector: (state: typeof storeState) => any) => selector(storeState);
useStore.getState = () => storeState;
return { useStore };
});
vi.mock("./jvm/JVMModeBadge", () => ({
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
}));
vi.mock("./jvm/JVMWorkspaceLayout", () => ({
getJVMWorkspaceCardStyle: () => ({}),
JVMWorkspaceHero: ({ actions, badges, description, title }: any) => (
<header>
<h1>{title}</h1>
{description}
{badges}
{actions}
</header>
),
JVMWorkspaceShell: ({ children }: any) => <main>{children}</main>,
}));
vi.mock("./jvm/JVMChangePreviewModal", () => ({
default: ({ open, onConfirm }: any) =>
open ? <button type="button" onClick={onConfirm}></button> : null,
}));
const writableTab = {
id: "tab-jvm-resource",
type: "jvm-resource",
title: "[orders-jvm] JVM 资源",
connectionId: "conn-jvm-writable",
providerMode: "jmx",
resourcePath: "jmx:/attribute/app/Mode",
resourceKind: "attribute",
} as any;
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === "string" ? item : textContent(item)))
.join("");
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === "button" && textContent(node).includes(text))[0];
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe("JVMResourceBrowser interactions", () => {
beforeEach(() => {
storeState.connections = [
{
id: "conn-jvm-writable",
name: "orders-jvm",
config: {
host: "127.0.0.1",
user: "jmx-user",
port: 9010,
type: "jvm",
jvm: {
preferredMode: "jmx",
readOnly: false,
jmx: {
password: "initial-jmx-secret",
},
},
},
},
];
const snapshot: JVMValueSnapshot = {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
version: "v1",
value: "cold",
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "warm" },
},
],
};
backendApp.JVMGetValue.mockResolvedValue({ success: true, data: snapshot });
backendApp.JVMPreviewChange.mockResolvedValue({
allowed: true,
requiresConfirmation: true,
confirmationToken: "token-from-preview",
summary: "设置 Mode",
riskLevel: "high",
before: snapshot,
after: { ...snapshot, value: "warm", version: "v2" },
});
backendApp.JVMApplyChange.mockResolvedValue({
success: true,
data: {
status: "applied",
updatedValue: { ...snapshot, value: "warm", version: "v2" },
},
});
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
go: {
app: {
App: backendApp,
},
},
});
});
afterEach(() => {
backendApp.JVMGetValue.mockReset();
backendApp.JVMPreviewChange.mockReset();
backendApp.JVMApplyChange.mockReset();
vi.unstubAllGlobals();
});
it("applies the latest successful preview request even when the draft is edited afterward", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
});
const payloadEditor = () => renderer!.root.findByType("textarea");
await act(async () => {
payloadEditor().props.onChange({ target: { value: '{"value":"previewed"}' } });
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
await act(async () => {
payloadEditor().props.onChange({ target: { value: '{"value":"edited-after-preview"}' } });
});
await act(async () => {
findButton(renderer!, "确认执行").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMApplyChange).toHaveBeenCalledTimes(1);
expect(backendApp.JVMApplyChange.mock.calls[0][0]).toBe(
backendApp.JVMPreviewChange.mock.calls[0][0],
);
expect(backendApp.JVMApplyChange.mock.calls[0][1]).toMatchObject({
action: "set",
confirmationToken: "token-from-preview",
payload: { value: "previewed" },
});
});
it("does not let a stale snapshot resource id override the current resource preview", async () => {
backendApp.JVMGetValue.mockResolvedValueOnce({
success: true,
data: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
version: "v1",
value: "cold",
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "warm" },
},
],
} as JVMValueSnapshot,
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
});
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMPreviewChange.mock.calls[backendApp.JVMPreviewChange.mock.calls.length - 1]?.[1]).toMatchObject({
resourceId: "jmx:/attribute/app/OtherMode",
});
});
it("ignores stale preview responses after the resource context changes", async () => {
let resolvePreview: (value: any) => void = () => {};
backendApp.JVMPreviewChange.mockReturnValueOnce(
new Promise((resolve) => {
resolvePreview = resolve;
}),
);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
resolvePreview({
allowed: true,
requiresConfirmation: true,
confirmationToken: "stale-token",
summary: "旧预览",
riskLevel: "high",
before: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "cold",
},
after: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "warm",
},
});
});
await waitForEffects();
expect(findButton(renderer!, "确认执行")).toBeUndefined();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after the resource context changes", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
findButton(renderer!, "确认执行").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after the connection config changes", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
storeState.connections = [
{
...storeState.connections[0],
config: {
...storeState.connections[0].config,
jvm: {
...storeState.connections[0].config.jvm,
readOnly: true,
},
},
},
];
await act(async () => {
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
});
const confirmButton = findButton(renderer!, "确认执行");
if (confirmButton) {
await act(async () => {
confirmButton.props.onClick();
});
}
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after JVM credentials change", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
storeState.connections = [
{
...storeState.connections[0],
config: {
...storeState.connections[0].config,
jvm: {
...storeState.connections[0].config.jvm,
jmx: {
...storeState.connections[0].config.jvm.jmx,
password: "rotated-jmx-secret",
},
},
},
},
];
await act(async () => {
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
});
const confirmButton = findButton(renderer!, "确认执行");
if (confirmButton) {
await act(async () => {
confirmButton.props.onClick();
});
}
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("does not seed sensitive payload examples into the draft editor", async () => {
backendApp.JVMGetValue.mockResolvedValueOnce({
success: true,
data: {
resourceId: "jmx:/attribute/app/Password",
kind: "attribute",
format: "string",
version: "v1",
value: "secret-token",
sensitive: true,
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "secret-token" },
},
],
} as JVMValueSnapshot,
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/Password",
}}
/>,
);
});
await waitForEffects();
expect(renderer!.root.findByType("textarea").props.value).not.toContain("secret-token");
});
});

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import JVMResourceBrowser from './JVMResourceBrowser';
vi.mock('@monaco-editor/react', () => ({
default: ({ language, value }: { language?: string; value?: string }) => (
<div data-monaco-editor-mock="true" data-language={language}>
{value}
</div>
),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
connections: [
{
id: 'conn-jvm-1',
name: 'localhost',
config: {
host: 'localhost',
jvm: {
preferredMode: 'jmx',
readOnly: true,
},
},
},
{
id: 'conn-jvm-2',
name: 'writable-jvm',
config: {
host: 'localhost',
jvm: {
preferredMode: 'jmx',
readOnly: false,
},
},
},
],
addTab: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}),
}));
vi.mock('./jvm/JVMModeBadge', () => ({
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
}));
vi.mock('./jvm/JVMChangePreviewModal', () => ({
default: () => null,
}));
describe('JVMResourceBrowser layout', () => {
it('renders a dedicated vertical scroll shell for tall snapshot content', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-1',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-1',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).toContain('data-jvm-resource-browser-scroll-shell="true"');
expect(markup).toContain('data-jvm-workspace-shell="true"');
expect(markup).toContain('data-jvm-workspace-hero="true"');
expect(markup).toContain('data-jvm-resource-workbench="true"');
expect(markup).toContain('height:100%');
expect(markup).toContain('overflow-y:auto');
expect(markup).toContain('grid-template-columns:minmax(0, 1fr) minmax(360px, 440px)');
});
it('shows the draft action field with a Chinese label', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-2',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-2',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).toContain('动作');
expect(markup).not.toContain('>Action<');
});
it('hides the change draft form entirely for read-only JVM connections', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-3',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-1',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).not.toContain('变更草稿');
expect(markup).not.toContain('预览变更');
expect(markup).not.toContain('Payload(JSON)');
});
});

File diff suppressed because it is too large Load Diff

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;

View File

@@ -0,0 +1,590 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedQuery, TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import QueryEditor from './QueryEditor';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
addTab: vi.fn(),
savedQueries: [] as SavedQuery[],
saveQuery: vi.fn(),
theme: 'light',
sqlFormatOptions: { keywordCase: 'upper' as const },
setSqlFormatOptions: vi.fn(),
queryOptions: { maxRows: 5000 },
setQueryOptions: vi.fn(),
shortcutOptions: {
runQuery: { enabled: false, combo: '' },
},
activeTabId: 'tab-1',
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBQueryWithCancel: vi.fn(),
DBQueryMulti: vi.fn(),
DBGetTables: vi.fn(),
DBGetAllColumns: vi.fn(),
DBGetDatabases: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
CancelQuery: vi.fn(),
GenerateQueryID: vi.fn(),
WriteSQLFile: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}));
const dataGridState = vi.hoisted(() => ({
latestProps: null as any,
}));
const editorState = vi.hoisted(() => {
const state = {
value: '',
editor: null as any,
};
state.editor = {
getValue: vi.fn(() => state.value),
setValue: vi.fn((value: string) => {
state.value = value;
}),
getModel: vi.fn(() => ({
getValue: () => state.value,
setValue: (value: string) => {
state.value = value;
},
getValueInRange: () => '',
getLineContent: () => '',
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }),
})),
getSelection: vi.fn(() => null),
addAction: vi.fn(),
onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
hasTextFocus: vi.fn(() => true),
};
return state;
});
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('../utils/autoFetchVisibility', () => ({
useAutoFetchVisibility: () => false,
}));
vi.mock('@monaco-editor/react', () => ({
default: ({ defaultValue, onMount }: any) => {
React.useEffect(() => {
editorState.value = String(defaultValue || '');
onMount?.(editorState.editor, {
editor: { setTheme: vi.fn() },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
registerCompletionItemProvider: vi.fn(),
},
});
}, []);
return <textarea data-editor value={editorState.value} readOnly />;
},
}));
vi.mock('./DataGrid', () => ({
default: (props: any) => {
dataGridState.latestProps = props;
return <div data-grid="true" />;
},
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
SettingOutlined: Icon,
CloseOutlined: Icon,
StopOutlined: Icon,
RobotOutlined: Icon,
};
});
vi.mock('antd', () => {
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>
{children}
</button>
);
Button.Group = ({ children }: any) => <div>{children}</div>;
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
return {
Button,
message: messageApi,
Modal: ({ children, open }: any) => (open ? <section>{children}</section> : null),
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
Form,
Dropdown: ({ children }: any) => <>{children}</>,
Tooltip: ({ children }: any) => <>{children}</>,
Select: () => null,
Tabs: ({ items }: any) => <div>{items?.[0]?.children}</div>,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'query.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1;',
...overrides,
});
describe('QueryEditor external SQL save', () => {
beforeEach(() => {
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
storeState.addTab.mockReset();
storeState.saveQuery.mockReset();
storeState.savedQueries = [];
storeState.activeTabId = 'tab-1';
messageApi.success.mockReset();
messageApi.error.mockReset();
messageApi.warning.mockReset();
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.GenerateQueryID.mockResolvedValue('query-1');
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
dataGridState.latestProps = null;
editorState.value = '';
editorState.editor.getValue.mockClear();
editorState.editor.setValue.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
let renderer!: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 2;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 2;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
filePath,
query: 'select 2;',
savedQueryId: undefined,
}));
expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
});
it('does not create saved queries when external SQL file writes fail', async () => {
let renderer!: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 4;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 4;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).not.toHaveBeenCalled();
expect(messageApi.error).toHaveBeenCalledWith('保存 SQL 文件失败: 磁盘只读');
});
it('keeps saved query quick-save behavior for non-file tabs', async () => {
storeState.savedQueries = [
{
id: 'saved-1',
name: '常用查询',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
},
];
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
editorState.value = 'select 3;';
await act(async () => {
findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).not.toHaveBeenCalled();
expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'saved-1',
name: '常用查询',
sql: 'select 3;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
}));
});
it('automatically appends hidden primary key locator columns for editable query results', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_ID'], rows: [{ NAME: 'old-name', __gonavi_locator_1_ID: 7 }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('MYCIMLED.EDC_LOG');
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['__gonavi_locator_1_ID'],
hiddenColumns: ['__gonavi_locator_1_ID'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(dataGridState.latestProps?.resultSql).toBe('SELECT NAME FROM MYCIMLED.EDC_LOG');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"ID" AS "__gonavi_locator_1_ID"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses a unique index locator for query results without primary keys', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_EMAIL'], rows: [{ NAME: 'old-name', __gonavi_locator_1_EMAIL: 'a@example.com' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'EMAIL', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValueOnce({
success: true,
data: [{ name: 'UK_EMAIL', columnName: 'EMAIL', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['__gonavi_locator_1_EMAIL'],
hiddenColumns: ['__gonavi_locator_1_EMAIL'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"EMAIL" AS "__gonavi_locator_1_EMAIL"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses hidden Oracle ROWID for query results without primary or unique keys', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain(`ROWID AS "${ORACLE_ROWID_LOCATOR_COLUMN}"`);
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('rewrites Oracle SELECT * queries before injecting hidden ROWID locator columns', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['WAFER_ID', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ WAFER_ID: 'R015Z10F08', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'WAFER_ID', key: '' }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT * FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG');
expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG gonavi_query_source');
expect(executedSql).not.toContain('__gonavi_query_source__');
expect(executedSql).not.toContain('SELECT *, ROWID AS');
expect(executedSql).toMatch(/SELECT\s+gonavi_query_source\.\*\s*,\s+gonavi_query_source\.ROWID\s+AS\s+"__gonavi_oracle_rowid__"/i);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
renderer?.unmount();
});
it('keeps non-Oracle query results read-only when no safe locator exists', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith('查询结果保持只读main.users 未检测到主键或可用唯一索引,无法安全提交修改。');
});
it('allows editable table columns while leaving expression columns out of commits', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['DISPLAY_NAME', 'NAME_UPPER', '__gonavi_locator_1_ID'],
rows: [{ DISPLAY_NAME: 'old-name', NAME_UPPER: 'OLD-NAME', __gonavi_locator_1_ID: 7 }],
}],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'SELECT NAME AS DISPLAY_NAME, UPPER(NAME) AS NAME_UPPER FROM users',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['__gonavi_locator_1_ID'],
hiddenColumns: ['__gonavi_locator_1_ID'],
writableColumns: {
DISPLAY_NAME: 'NAME',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('`ID` AS `__gonavi_locator_1_ID`');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it.each([
'mysql',
'mariadb',
'oceanbase',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'opengauss',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
])(
'keeps aggregate query results silently read-only for %s',
async (dbType) => {
storeState.connections[0].config.type = dbType;
storeState.connections[0].config.database = dbType === 'oracle' || dbType === 'dameng' ? 'APP' : 'main';
const forceReadOnlyQueryResult = dbType === 'tdengine' || dbType === 'clickhouse';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['COUNT'], rows: [{ COUNT: 1 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: storeState.connections[0].config.database,
query: 'SELECT count(1) FROM users',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe(forceReadOnlyQueryResult ? undefined : 'users');
expect(dataGridState.latestProps?.editLocator).toBeUndefined();
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
expect(messageApi.warning).not.toHaveBeenCalled();
},
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,429 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Button, Space, message } from 'antd';
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import Editor, { OnMount } from '@monaco-editor/react';
interface RedisCommandEditorProps {
connectionId: string;
redisDB: number;
}
interface CommandResult {
command: string;
result: any;
error?: string;
timestamp: number;
durationMs: number;
}
// 智能解析 Redis 脚本块,保护多行引号内的换行符
function parseRedisScriptBlocks(script: string): string[] {
const blocks: string[] = [];
let currentBlock = "";
let inQuote: string | null = null;
let isEscaping = false;
const lines = script.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) {
continue;
}
for (let j = 0; j < line.length; j++) {
const char = line[j];
if (isEscaping) {
isEscaping = false;
currentBlock += char;
continue;
}
if (char === '\\') {
isEscaping = true;
currentBlock += char;
continue;
}
if (char === '"' || char === "'") {
if (inQuote === char) {
inQuote = null;
} else if (!inQuote) {
inQuote = char;
}
}
currentBlock += char;
}
if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) {
if (!inQuote) {
blocks.push(currentBlock.trim());
currentBlock = "";
} else {
currentBlock += '\n';
}
}
}
if (currentBlock.trim() !== '') {
blocks.push(currentBlock.trim());
}
return blocks.filter(b => b.trim() !== '');
}
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);
// UI Layout state
const [editorHeight, setEditorHeight] = useState(250);
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const resultsEndRef = useRef<HTMLDivElement>(null);
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, monaco) => {
editorRef.current = editor;
editor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
() => handleExecute()
);
if (!(window as any).__redisCompletionRegistered) {
(window as any).__redisCompletionRegistered = true;
const redisCommands = [
"APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP",
"BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX",
"CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY",
"DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE",
"EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH",
"GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE",
"GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO",
"HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN",
"HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR",
"INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT",
"LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM",
"LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET",
"MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME",
"PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH",
"PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME",
"RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX",
"SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT",
"SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD",
"SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER",
"SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN",
"SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL",
"TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD",
"XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE",
"XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF",
"ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT",
"ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX",
"ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK",
"ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK",
"ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE"
];
monaco.languages.registerCompletionItemProvider('redis', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
};
return {
suggestions: redisCommands.map(cmd => ({
label: cmd,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: cmd,
range: range,
detail: "Redis Command"
}))
};
}
});
}
};
const handleExecute = async () => {
const config = getConfig();
if (!config) return;
let cmdToExecute = '';
// 1. 获取用户是否有高亮选中的文本
const selection = editorRef.current?.getSelection();
if (selection && !selection.isEmpty()) {
cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || '';
} else {
// 没有选中则取全部文本
cmdToExecute = editorRef.current?.getValue() || '';
}
cmdToExecute = cmdToExecute.trim();
if (!cmdToExecute) {
message.warning('请输入要执行的命令');
return;
}
// 2. 智能解析多行命令
const commands = parseRedisScriptBlocks(cmdToExecute);
if (commands.length === 0) return;
setLoading(true);
const newResults: CommandResult[] = [];
for (const cmd of commands) {
const start = Date.now();
try {
const res = await (window as any).go.app.App.RedisExecuteCommand(buildRpcConnectionConfig(config), cmd);
newResults.push({
command: cmd,
result: res.success ? res.data : null,
error: res.success ? undefined : res.message,
timestamp: Date.now(),
durationMs: Date.now() - start
});
} catch (e: any) {
newResults.push({
command: cmd,
result: null,
error: e?.message || String(e),
timestamp: Date.now(),
durationMs: Date.now() - start
});
}
}
setResults(prev => [...prev, ...newResults]);
setLoading(false);
};
// Auto scroll to bottom when new results arrive
useEffect(() => {
if (resultsEndRef.current) {
resultsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [results]);
const handleClear = () => {
setResults([]);
};
const formatResult = (result: any): React.ReactNode => {
if (result === null || result === undefined) {
return <span style={{ color: '#569cd6' }}>(nil)</span>;
}
if (typeof result === 'string') {
// 尝试美化 JSON 字符串
try {
const parsed = JSON.parse(result);
if (typeof parsed === 'object' && parsed !== null) {
return (
<div style={{ marginTop: 4, padding: 8, background: 'rgba(0,0,0,0.2)', borderRadius: 4 }}>
{JSON.stringify(parsed, null, 2)}
</div>
);
}
} catch (e) {
// not a valid json, just return string
}
return <span style={{ color: '#ce9178' }}>"{result}"</span>;
}
if (typeof result === 'number') {
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
}
if (Array.isArray(result)) {
if (result.length === 0) {
return '(empty array)';
}
return (
<div style={{ marginLeft: 8 }}>
{result.map((item, index) => (
<div key={index} style={{ display: 'flex' }}>
<span style={{ color: '#608b4e', marginRight: 8, userSelect: 'none' }}>{index + 1})</span>
<div>{formatResult(item)}</div>
</div>
))}
</div>
);
}
if (typeof result === 'object') {
return JSON.stringify(result, null, 2);
}
return String(result);
};
// Resizing logic
const handleDragStart = (e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'row-resize';
};
const handleDragMove = useCallback((e: MouseEvent) => {
if (!dragRef.current) return;
const delta = e.clientY - dragRef.current.startY;
let newHeight = dragRef.current.startHeight + delta;
// 限制高度
const minHeight = 100;
const maxHeight = containerRef.current ? containerRef.current.clientHeight - 100 : 800;
if (newHeight < minHeight) newHeight = minHeight;
if (newHeight > maxHeight) newHeight = maxHeight;
setEditorHeight(newHeight);
// 更新编辑器布局
if (editorRef.current) {
editorRef.current.layout();
}
}, []);
const handleDragEnd = useCallback(() => {
dragRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'default';
if (editorRef.current) {
editorRef.current.layout();
}
}, [handleDragMove]);
if (!connection) {
return <div style={{ padding: 20 }}></div>;
}
return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', background: '#fff' }}>
{/* Editor Top Pane */}
<div style={{ height: editorHeight, minHeight: 100, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fdfdfd' }}>
<Space>
<span style={{ fontWeight: 600 }}>Redis Console</span>
<span style={{ color: '#888', fontSize: 13, background: '#f0f0f0', padding: '2px 8px', borderRadius: 12 }}>db{redisDB}</span>
</Space>
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleExecute}
loading={loading}
>
(Cmd+Enter)
</Button>
</Space>
</div>
<div style={{ flex: 1, position: 'relative' }}>
<Editor
defaultLanguage="redis"
language="redis"
value={command}
onChange={(value) => setCommand(value || '')}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
fontSize: 14,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
padding: { top: 10, bottom: 10 }
}}
/>
</div>
</div>
{/* Resizer Handle */}
<div
className="horizontal-resizer"
onMouseDown={handleDragStart}
style={{
height: 8,
cursor: 'row-resize',
background: '#f0f0f0',
borderTop: '1px solid #e0e0e0',
borderBottom: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10
}}
>
<div style={{ width: 40, height: 4, background: '#ccc', borderRadius: 2 }} />
</div>
{/* Results Terminal Bottom Pane */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '4px 12px', background: '#252526', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #333' }}>
<span style={{ color: '#ccc', fontSize: 12 }}>Execution Output</span>
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: '#aaa' }}></Button>
</div>
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: '"Consolas", "Courier New", monospace', fontSize: 13, padding: 12 }}>
{results.length === 0 ? (
<div style={{ color: '#666', textAlign: 'center', marginTop: 40 }}>
<div></div>
<div style={{ fontSize: 12, marginTop: 12 }}>
Tips: <code></code> <code style={{ color: '#999' }}>Ctrl + Enter</code>
</div>
</div>
) : (
results.map((item, index) => (
<div key={item.timestamp + index} style={{ marginBottom: 16 }}>
<div style={{ color: '#569cd6', marginBottom: 6, fontWeight: 'bold' }}>
<span style={{ color: '#4CAF50', marginRight: 8 }}></span>
{item.command}
<span style={{ color: '#666', fontSize: 11, marginLeft: 12, fontWeight: 'normal' }}>[{item.durationMs}ms]</span>
</div>
<div style={{ paddingLeft: 20 }}>
{item.error ? (
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
(error) {item.error}
</div>
) : (
<div style={{ whiteSpace: 'pre-wrap' }}>
{formatResult(item.result)}
</div>
)}
</div>
</div>
))
)}
<div ref={resultsEndRef} />
</div>
</div>
</div>
);
};
export default RedisCommandEditor;

View File

@@ -0,0 +1,379 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Card, Row, Col, Statistic, Select, Button, message, Tag, Typography, Tooltip, Spin } from 'antd';
import { AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid, Legend, LineChart, Line } from 'recharts';
import {
DesktopOutlined,
DashboardOutlined,
ApiOutlined,
HddOutlined,
ReloadOutlined,
PlayCircleOutlined,
PauseCircleOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { RedisGetServerInfo } from '../../wailsjs/go/app/App';
const { Title, Text } = Typography;
interface RedisMonitorProps {
connectionId: string;
redisDB: number;
}
// Data point for charts
interface MetricPoint {
time: string;
qps: number;
memory: number; // in MB
memory_rss: number; // in MB
clients: number;
cpuSys: number;
cpuUser: number;
hitRate: number;
keys: number;
}
const MAX_HISTORY_POINTS = 60; // Keep up to 60 data points
const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
const [isRunning, setIsRunning] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<MetricPoint[]>([]);
const [currentInfo, setCurrentInfo] = useState<Record<string, string>>({});
// Ref to track if component is mounted to prevent state updates after unmount
const mountedRef = useRef(true);
// Interval ref
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Previous ops counter to calculate QPS if instantaneous_ops_per_sec is not enough
const prevMetricsRef = useRef({ prevOps: 0, prevTime: 0 });
const connection = connections.find((c: SavedConnection) => c.id === connectionId);
const fetchMetrics = async () => {
if (!connection) return;
try {
const config = buildRpcConnectionConfig(connection.config, { redisDB });
const res = await RedisGetServerInfo(config);
if (!mountedRef.current) return;
if (!res.success) {
setError(res.message || 'Failed to fetch Redis info');
return;
}
setError(null);
const infoMap = res.data as Record<string, string>;
setCurrentInfo(infoMap);
const now = new Date();
const timeStr = now.toLocaleTimeString([], { hour12: false, second: '2-digit' });
// Parse values
const qps = parseInt(infoMap['instantaneous_ops_per_sec'] || '0', 10);
const memBytes = parseInt(infoMap['used_memory'] || '0', 10);
const memRssBytes = parseInt(infoMap['used_memory_rss'] || '0', 10);
const clients = parseInt(infoMap['connected_clients'] || '0', 10);
const cpuSys = parseFloat(infoMap['used_cpu_sys'] || '0');
const cpuUser = parseFloat(infoMap['used_cpu_user'] || '0');
const hits = parseInt(infoMap['keyspace_hits'] || '0', 10);
const misses = parseInt(infoMap['keyspace_misses'] || '0', 10);
const hitRate = (hits + misses) > 0 ? (hits / (hits + misses)) * 100 : 0;
let keys = 0;
Object.keys(infoMap).forEach(k => {
if (k.startsWith('db')) {
const m = infoMap[k].match(/keys=(\d+)/);
if (m) keys += parseInt(m[1], 10);
}
});
const point: MetricPoint = {
time: timeStr,
qps,
memory: parseFloat((memBytes / 1024 / 1024).toFixed(2)),
memory_rss: parseFloat((memRssBytes / 1024 / 1024).toFixed(2)),
clients,
cpuSys: parseFloat(cpuSys.toFixed(2)),
cpuUser: parseFloat(cpuUser.toFixed(2)),
hitRate: parseFloat(hitRate.toFixed(2)),
keys
};
setHistory(prev => {
const next = [...prev, point];
if (next.length > MAX_HISTORY_POINTS) {
return next.slice(next.length - MAX_HISTORY_POINTS);
}
return next;
});
if (loading) setLoading(false);
} catch (err: any) {
if (mountedRef.current) {
setError(err.message || 'Unknown error');
if (loading) setLoading(false);
}
}
};
useEffect(() => {
mountedRef.current = true;
fetchMetrics(); // initial fetch
return () => {
mountedRef.current = false;
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
if (isRunning) {
intervalRef.current = setInterval(fetchMetrics, 2000); // 2 second interval
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isRunning, connectionId, redisDB, connection]);
if (!connection) {
return <div style={{ padding: 20 }}>Connection not found.</div>;
}
// Determine styles for charts based on theme
const chartTextColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)';
const chartGridColor = darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
const cardBgColor = darkMode ? '#1f1f1f' : '#ffffff';
const getFormatMemoryString = (bytes: string) => {
const val = parseInt(bytes || '0', 10);
if (val > 1024*1024*1024) return (val/1024/1024/1024).toFixed(2) + ' GB';
if (val > 1024*1024) return (val/1024/1024).toFixed(2) + ' MB';
if (val > 1024) return (val/1024).toFixed(2) + ' KB';
return val + ' B';
};
const getUptimeString = (seconds: string) => {
const d = parseInt(seconds || '0', 10);
if (d < 60) return `${d}s`;
if (d < 3600) return `${Math.floor(d/60)}m ${d%60}s`;
if (d < 86400) return `${Math.floor(d/3600)}h ${Math.floor((d%3600)/60)}m`;
return `${Math.floor(d/86400)}d ${Math.floor((d%86400)/3600)}h`;
};
return (
<div style={{ height: '100%', overflow: 'auto', padding: '16px 24px', backgroundColor: darkMode ? '#141414' : '#f0f2f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<div>
<Title level={3} style={{ margin: 0, fontWeight: 600 }}>
<DashboardOutlined style={{ marginRight: 8, color: '#1677ff' }} />
Redis
</Title>
<Text type="secondary">
{connection.name}
{currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`}
{currentInfo.os && `${currentInfo.os}`}
</Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
{error && <Tag color="error" style={{ height: 32, lineHeight: '30px', fontSize: 13 }}>{error}</Tag>}
{loading && !error && <Spin style={{ alignSelf: 'center', marginRight: 16 }} />}
<Button
type={isRunning ? "default" : "primary"}
icon={isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={() => setIsRunning(!isRunning)}
>
{isRunning ? '暂停刷新' : '恢复刷新'}
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchMetrics}>
</Button>
</div>
</div>
<Row gutter={[16, 16]}>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}><DesktopOutlined /> (Used)</span>}
value={getFormatMemoryString(currentInfo.used_memory || '0')}
valueStyle={{ color: '#eb2f96', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Peak: {getFormatMemoryString(currentInfo.used_memory_peak || '0')}</Text>}
/>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}><ApiOutlined /> (Clients)</span>}
value={currentInfo.connected_clients || '0'}
valueStyle={{ color: '#1677ff', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Blocked: {currentInfo.blocked_clients || '0'}</Text>}
/>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}><HddOutlined /> (OPS)</span>}
value={currentInfo.instantaneous_ops_per_sec || '0'}
valueStyle={{ color: '#52c41a', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>cmds/s</Text>}
/>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}> (Uptime)</span>}
value={getUptimeString(currentInfo.uptime_in_seconds || '0')}
valueStyle={{ color: '#fa8c16', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Days: {currentInfo.uptime_in_days || '0'}</Text>}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={12}>
<Card
bordered={false}
title="请求吞吐量 (QPS)"
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorQps" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#52c41a" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
/>
<Area type="monotone" dataKey="qps" name="QPS" stroke="#52c41a" strokeWidth={2} fillOpacity={1} fill="url(#colorQps)" isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={12}>
<Card
bordered={false}
title="内存开销 (Memory)"
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} domain={['auto', 'auto']} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
formatter={(value: any) => [`${value} MB`]}
/>
<Legend verticalAlign="top" height={36}/>
<Line type="monotone" dataKey="memory" name="Used Memory" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="memory_rss" name="RSS Memory" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={12}>
<Card
bordered={false}
title="CPU 使用率 (CPU Usage)"
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
formatter={(value: any) => [`${value} s`]}
/>
<Legend verticalAlign="top" height={36}/>
<Line type="monotone" dataKey="cpuSys" name="System" stroke="#cf1322" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="cpuUser" name="User" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={12}>
<Card
bordered={false}
title="连接信息 (Clients & Keys)"
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis yAxisId="left" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis yAxisId="right" orientation="right" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
/>
<Legend verticalAlign="top" height={36}/>
<Line yAxisId="left" type="stepAfter" dataKey="clients" name="Clients" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line yAxisId="right" type="stepAfter" dataKey="keys" name="Total Keys" stroke="#fa8c16" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
<div style={{ marginTop: 24 }}>
<Card bordered={false} title="详细服务器参数" style={{ background: cardBgColor, borderRadius: 8 }}>
<div style={{ columnCount: 3, columnGap: 40 }}>
{['redis_version', 'os', 'arch_bits', 'multiplexing_api', 'gcc_version', 'run_id', 'tcp_port', 'uptime_in_days', 'hz', 'lru_clock', 'role', 'maxmemory_human', 'maxmemory_policy', 'mem_fragmentation_ratio', 'keyspace_hits', 'keyspace_misses', 'total_connections_received'].map(key => (
currentInfo[key] ? (
<div key={key} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8, borderBottom: `1px dashed ${chartGridColor}` }}>
<Text type="secondary">{key}</Text>
<Text strong>{currentInfo[key]}</Text>
</div>
) : null
))}
</div>
</Card>
</div>
</div>
);
};
export default RedisMonitor;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
import { Button } from 'antd';
import { CloseOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import type { SecurityUpdateStatus } from '../types';
import { getSecurityUpdateStatusMeta } from '../utils/securityUpdatePresentation';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_BANNER_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateBannerSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateBannerProps {
status: SecurityUpdateStatus;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
onStart: () => void;
onRetry: () => void;
onRestart: () => void;
onOpenDetails: () => void;
onDismiss: () => void;
}
const resolvePrimaryAction = (
status: SecurityUpdateStatus,
actions: Pick<SecurityUpdateBannerProps, 'onStart' | 'onRetry' | 'onRestart' | 'onOpenDetails'>,
) => {
switch (status.overallStatus) {
case 'postponed':
return {
label: '立即更新',
onClick: actions.onStart,
};
case 'needs_attention':
return {
label: '查看详情',
onClick: actions.onOpenDetails,
};
case 'rolled_back':
return {
label: '重新开始更新',
onClick: actions.onRestart,
};
default:
return {
label: '查看详情',
onClick: actions.onOpenDetails,
};
}
};
const resolveSecondaryAction = (
status: SecurityUpdateStatus,
actions: Pick<SecurityUpdateBannerProps, 'onRetry' | 'onOpenDetails'>,
) => {
switch (status.overallStatus) {
case 'needs_attention':
return {
label: '重新检查',
onClick: actions.onRetry,
};
case 'rolled_back':
return {
label: '查看详情',
onClick: actions.onOpenDetails,
};
default:
return null;
}
};
const SecurityUpdateBanner = ({
status,
darkMode,
overlayTheme,
surfaceOpacity = 1,
onStart,
onRetry,
onRestart,
onOpenDetails,
onDismiss,
}: SecurityUpdateBannerProps) => {
const statusMeta = getSecurityUpdateStatusMeta(status);
const primaryAction = resolvePrimaryAction(status, { onStart, onRetry, onRestart, onOpenDetails });
const secondaryAction = resolveSecondaryAction(status, { onRetry, onOpenDetails });
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
return (
<div
className={SECURITY_UPDATE_BANNER_CLASS}
style={{
margin: '12px 12px 0',
padding: '14px 16px',
borderRadius: 16,
...getSecurityUpdateBannerSurfaceStyle(overlayTheme, surfaceOpacity),
display: 'flex',
alignItems: 'center',
gap: 16,
overflow: 'hidden',
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 14,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
flexShrink: 0,
fontSize: 18,
}}
>
<SafetyCertificateOutlined />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
</div>
<div style={{ marginTop: 4, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{statusMeta.description}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{secondaryAction ? (
<Button className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={secondaryAction.onClick}>
{secondaryAction.label}
</Button>
) : null}
<Button
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={actionButtonStyle}
type="primary"
onClick={primaryAction.onClick}
>
{primaryAction.label}
</Button>
<Button
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={{ ...actionButtonStyle, width: 36, minWidth: 36, paddingInline: 0 }}
type="text"
icon={<CloseOutlined />}
onClick={onDismiss}
/>
</div>
</div>
);
};
export type { SecurityUpdateBannerProps };
export default SecurityUpdateBanner;

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