Compare commits

...

101 Commits

Author SHA1 Message Date
Syngnat
7beb08c960 合并拉取请求 #358
Release/0.6.6
2026-04-11 22:56:29 +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
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
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
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
9bbdcea3fd Release/0.6.5 2026-04-01 16:47:42 +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
Syngnat
d9cbbc6c31 Release/0.6.4 2026-03-28 11:24:23 +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
198 changed files with 29476 additions and 2121 deletions

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

@@ -0,0 +1,660 @@
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 }}"
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 doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
if [ "$DRIVER" = "duckdb" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
fi
done
# macOS Packaging
- name: Package macOS DMG
if: contains(matrix.platform, 'darwin')
run: |
brew install create-dmg
VERSION="${{ 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 签名..."
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"
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 }}

View File

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

View File

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

4
.gitignore vendored
View File

@@ -21,6 +21,10 @@ GoNavi-Wails.exe
.claude/
.gemini/
**/tmpclaude-*
docs/superpowers/
docs/需求追踪/
CLAUDE.md
**/CLAUDE.md
.worktrees
docs

View File

@@ -5,6 +5,8 @@
[![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)
**Language**: English | [简体中文](README.zh-CN.md)
@@ -53,19 +55,24 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
<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
### 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.
### Performance
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
- **Virtualized rendering**: keeps large result sets responsive.
@@ -207,6 +214,20 @@ For the full workflow, branch model, and maintainer sync rules, see:
External contributors should open pull requests directly against `main`.
## 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).

View File

@@ -5,6 +5,8 @@
[![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) | 简体中文
@@ -52,19 +54,24 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
<h2 align="center">📸 项目截图</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
<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>
---
## 核心特性
### AI 智能助手 (New)
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude同时支持任意自定义兼容 OpenAI 格式的 API。
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI让 SQL 生成、分析变得更精准。
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等
### 性能与交互
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
- 虚拟滚动渲染,降低大结果集卡顿风险。
@@ -190,6 +197,21 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
外部贡献者统一直接向 `main` 发起 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

View File

@@ -0,0 +1,111 @@
# 2026-04-11 Issue Backlog Tracking
## Scope
- 分支:`codex/issue-242-data-root`
- 策略:按 GitHub issue 创建时间从早到晚逐条处理
- 提交要求:每条 issue 单独本地提交,提交信息使用 `Fixes #<issue>`
## Progress
| Issue | Title | Status | Commit |
| --- | --- | --- | --- |
| #242 | 希望有自定义数据存储位置功能 | Fixed | `1f617f9` |
| #287 | 建议补充 Sql Server 数据库图标 | Fixed | `60b63d7` |
| #305 | 金仓数据库设计表新增字段保存失败 | Fixed | `f696f52` |
| #306 | 驱动下载 | Fixed | `8297829` |
| #308 | clickhouse 获取数据库列表失败 | Fixed | `5d86ee7` |
| #310 | 选择库后,右侧行显示各个表 | Fixed | `808c773` |
| #311 | WIN 系统的执行 500 多条 insert 语句要几分钟 | Fixed | `83fe3d4` |
| #315 | 窗体内缩放异常 | Fixed | `5038ae5` |
| #316 | 人大金仓数据库驱动版本过低 | Fixed | `aa1bb5b` |
| #317 | 驱动管理增加导入 jar 功能 | Blocked | - |
| #318 | mysql,bit 列,修改成 1 失败 | Fixed | `89d79ff` |
| #319 | 关于运行外部 sql 文件的一些建议 | Deferred | - |
| #320 | 无法连接达梦数据库 | Fixed | `1c2377b` |
| #322 | 【拖选复制】希望添加 查询结果表格可以拖选复制效果就如操作excel表格的选择复制一样 | Fixed | Pending |
| #325 | 有没有考虑对数据库的驱动版本进行选择或者自定义? | Fixed | `af5e842` |
| #327 | SHOW DATABASES 报错 | Fixed | `fb500ee` |
| #328 | [Bug] 安装更新失败 | Fixed | `426ef3b` |
| #329 | 如果调整了左侧导航栏的宽度后,建议左侧导航栏内增加横向滚动查看 | Fixed | `fcade0f` |
| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | `632e57e` |
| #331 | 重复连接 DB一分钟重试了 60 多次 | Fixed | `ca76440` |
| #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending |
## Notes
### #317
- 当前驱动管理只支持内置 Go 驱动和可选 Go 驱动代理包。
- 仓库内不存在 JDBC/JAR 装载、Java 运行时探测、classpath 管理或桥接执行链路。
- 在现有架构下直接增加 “导入 jar” 入口会形成假功能,因此暂记为架构阻塞,不做伪实现。
### #318
- 根因MySQL 写入归一化只覆盖时间列,`bit` 列提交时会把前端传来的 `"1"`/`"0"` 原样透传给驱动。
- 处理:为 MySQL `bit` 列补充写入值归一化,将常见文本/布尔/数值输入转换为驱动可接受的 `[]byte`
- 验证:补充 `internal/db/mysql_value_test.go` 回归测试,覆盖 `bit(1)` 的 insert/update 写入路径。
### #319
- 现有应用已支持“运行外部 SQL 文件”,但 issue 诉求包含目录树、目录加载、双击文件打开等整组工作区能力。
- 该项已超出单点缺陷修复范围,暂按功能增强项顺延,避免在逐条修 bug 流程中引入大范围 UI/状态管理重构。
### #320
- 达梦当前走可选 Go 驱动代理安装链路,不支持 JAR 导入属于既有架构边界。
- 根因:驱动 release 资产缓存把 `GoNavi-DriverAgents.zip` 里的 bundle 条目也混进了“顶层已发布 asset”集合导致安装链路误以为存在单独的 `dameng-driver-agent-*.exe` 下载地址。
- 处理:缓存层区分真实 release 顶层 asset 与 bundle index 条目,安装 URL 解析仅在真实顶层 asset 存在时才走直链bundle-only 驱动改为直接进入总包提取回退,不再先卡在 20% 试无效 URL。
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖 bundle-only 达梦驱动跳过伪直链,并回归 Mongo 历史版本与本地导入链路。
### #327
- 根因:低权限 MySQL 账号执行 `SHOW DATABASES` 会直接报错,当前实现没有回退路径。
- 处理:为数据库列表查询增加 `SELECT DATABASE()` 回退,仅保留当前连接库时也能正常展示。
- 验证:补充 `internal/db/mysql_metadata_test.go` 回归测试,覆盖有权限、多库和低权限回退场景。
### #328
- 根因Windows 更新脚本在批处理执行、错误码读取和重启命令上不够稳,`cmd /C start`、LF 行尾和块内 `%ERRORLEVEL%` 在实际环境下容易引发安装失败。
- 处理:更新脚本统一输出为 CRLF块内错误码改为延迟展开旧文件回退路径统一为 `TARGET_OLD`,并将脚本启动方式收敛为 `cmd.exe /D /C call <script>`
- 验证:补充 `internal/app/methods_update_windows_script_test.go`覆盖批处理语法、Win10 回退路径、CRLF 行尾、延迟展开和启动命令构造。
### #325
- 根因TDengine 的版本列表虽然支持下拉选择,但后端在抓取与缓存 Go 模块版本时只保留最近 5 个版本,导致 `3.5.x / 3.3.x / 3.0.x` 这类旧版根本不会进入选择列表。
- 处理:放宽 TDengine 的历史版本窗口,并补充离线 fallback 版本矩阵;同时扩大模块版本缓存上限,确保旧版不会在抓取阶段就被截断。
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖缓存命中与 fallback 两条路径,并回归 Mongo 版本约束逻辑。
### #329
- 根因:侧边栏连接树被全局 Tree 样式固定为 `width: 100%`,标题同时启用了省略截断,导致缩窄侧栏后长节点无法形成横向溢出。
- 处理:为 Sidebar 树增加专用横向滚动容器,并在 Sidebar 作用域内覆写 Tree 宽度与标题截断规则,让节点宽度随内容扩展且保留最小占满。
- 验证:执行 `frontend``npm run build`,确认 TS/CSS 改动编译通过且仅作用于 Sidebar 树。
### #331
- 根因:连接失败时存在双层重试叠加。`DBGetDatabases / DBGetTables / DBQuery` 在缓存失效后本来就会主动重建连接一次,而 `connectDatabaseWithStartupRetry` 在稳定期仍会额外放行一次瞬时错误自动重试,导致一次后台探测会被放大成多次真实建连。
- 处理:将连接自动重试范围收敛到应用启动保护窗口内;稳定期下所有连接探测与重建都只执行一次,避免后台挂起场景持续放大失败流量。
- 验证:补充并更新 `internal/app/app_startup_connect_retry_test.go`,覆盖稳定期瞬时失败不重试、不再输出重试提示,以及启动期仍保留完整重试预算。
### #330
- 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。
- 处理:为 `DataGrid` 的列宽拖拽手柄增加双击入口,按当前表头与已加载结果集内容估算目标宽度,并直接复用现有 `columnWidths` 状态更新布局。
- 验证:新增 `frontend/src/components/dataGridAutoWidth.test.ts` 覆盖列宽估算规则,并执行 `frontend``npm run build` 确认 TS 与打包通过。
### #322
- 根因:`DataGrid` 已经具备拖选单元格和选区状态维护能力,但当前复制能力只支持把同一行选中的列值暂存为内部 patch用于“粘贴到选中行”没有把矩形选区真正导出到系统剪贴板。
- 处理:新增选区复制 helper将矩形选区按当前可见行列顺序导出为制表符文本同时补上工具栏“复制选区”按钮和 `Ctrl/Cmd+C` 快捷键,让拖选后的复制行为更接近 Excel。
- 验证:新增 `frontend/src/components/dataGridSelectionCopy.test.ts` 覆盖选区排序与剪贴板文本规整规则,并执行 `frontend``npm run build` 确认功能接线通过。
### #351
- 根因:后端已有批量清空表能力,但前端单表危险操作菜单只暴露了“删除表”,没有把“截断表 / 清空表”作为显式入口提供给用户;同时批量“清空”动作底层语义也混用了 `TRUNCATE/DELETE`
- 处理:后端将“截断表”和“清空表”拆分为显式能力,统一通过 helper 生成多数据库 SQL前端为 Sidebar 和 TableOverview 的表菜单补上两个危险操作入口,并仅在明确支持 `TRUNCATE TABLE` 的数据库类型上显示“截断表”。
- 验证:新增 `internal/app/methods_file_clear_test.go``frontend/src/components/tableDataDangerActions.test.ts`,并执行 `go test ./...``frontend``npm run build` 确认全量通过。
## Next
- 继续处理下一个最早且可直接落地的开放 issue。

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>

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"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"
@@ -31,6 +38,7 @@
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vitest": "^3.2.4"
}
}
}

View File

@@ -1 +1 @@
5b8157374dae5f9340e31b2d0bd2c00e
f697e821b4acd5cf614d63d46453e8a4

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

View File

@@ -7,7 +7,7 @@ html, body, #root {
}
body, #root {
border-radius: 14px; /* Slightly rounded app window corners */
border-radius: var(--gonavi-border-radius); /* Slightly rounded app window corners */
}
/* 侧边栏 Tree 样式优化 */
@@ -37,6 +37,41 @@ body, #root {
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;
}
.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;
}

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,829 @@
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;
}
// 预设配置:每个预设映射到后端 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 }) => {
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]);
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;

View File

@@ -5,6 +5,8 @@ import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICO
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
@@ -17,6 +19,43 @@ const CONNECTION_MODAL_WIDTH = 960;
const CONNECTION_MODAL_BODY_HEIGHT = 620;
const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)';
const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)';
const noAutoCapInputProps = {
autoCapitalize: 'none' as const,
autoCorrect: 'off' as const,
spellCheck: false,
};
const applyNoAutoCapAttributes = (element: Element) => {
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
return;
}
element.setAttribute('autocapitalize', 'none');
element.setAttribute('autocorrect', 'off');
element.setAttribute('spellcheck', 'false');
};
type ConnectionSecretKey =
| 'primaryPassword'
| 'sshPassword'
| 'proxyPassword'
| 'httpTunnelPassword'
| 'mysqlReplicaPassword'
| 'mongoReplicaPassword'
| 'opaqueURI'
| 'opaqueDSN';
type ConnectionSecretClearState = Record<ConnectionSecretKey, boolean>;
const createEmptyConnectionSecretClearState = (): ConnectionSecretClearState => ({
primaryPassword: false,
sshPassword: false,
proxyPassword: false,
httpTunnelPassword: false,
mysqlReplicaPassword: false,
mongoReplicaPassword: false,
opaqueURI: false,
opaqueDSN: false,
});
const getDefaultPortByType = (type: string) => {
switch (type) {
@@ -122,6 +161,7 @@ const ConnectionModal: React.FC<{
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
const [selectingDbFile, setSelectingDbFile] = useState(false);
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
const [clearSecrets, setClearSecrets] = useState<ConnectionSecretClearState>(createEmptyConnectionSecretClearState);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(null);
const addConnection = useStore((state) => state.addConnection);
@@ -171,6 +211,23 @@ const ConnectionModal: React.FC<{
border: darkMode ? '1px solid rgba(255, 255, 255, 0.16)' : '1px solid rgba(0, 0, 0, 0.06)',
};
useEffect(() => {
if (!open) return;
const applyForConnectionModal = () => {
document
.querySelectorAll('.connection-modal-wrap input, .connection-modal-wrap textarea')
.forEach(applyNoAutoCapAttributes);
};
applyForConnectionModal();
const observer = new MutationObserver(() => {
applyForConnectionModal();
});
observer.observe(document.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
};
}, [open]);
const modalShellStyle = useMemo(() => ({
background: overlayTheme.shellBg,
@@ -192,6 +249,51 @@ const ConnectionModal: React.FC<{
lineHeight: 1.6,
}), [overlayTheme]);
const renderStoredSecretControls = ({
fieldName,
clearKey,
hasStoredSecret,
clearLabel,
description,
}: {
fieldName: string;
clearKey: ConnectionSecretKey;
hasStoredSecret?: boolean;
clearLabel: string;
description: string;
}) => {
if (!initialValues || !hasStoredSecret) {
return null;
}
return (
<Form.Item noStyle shouldUpdate={(prev, next) => prev[fieldName] !== next[fieldName]}>
{({ getFieldValue }) => {
const draftValue = getFieldValue(fieldName);
const hasDraftValue = String(draftValue ?? '') !== '';
const cardBorder = darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(16,24,40,0.08)';
const cardBg = darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)';
const effectiveChecked = clearSecrets[clearKey] && !hasDraftValue;
return (
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: cardBorder, background: cardBg }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
{hasDraftValue ? '已输入新值,保存时会替换当前已保存内容。' : description}
</div>
<Checkbox
checked={effectiveChecked}
disabled={hasDraftValue}
onChange={(event) => {
const checked = event.target.checked;
setClearSecrets((prev) => ({ ...prev, [clearKey]: checked }));
}}
>
{clearLabel}
</Checkbox>
</div>
);
}}
</Form.Item>
);
};
const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
@@ -749,6 +851,19 @@ const ConnectionModal: React.FC<{
}
});
const createCustomDsnRule = () => ({
validator(_: unknown, value: unknown) {
const validationMessage = getCustomConnectionDsnValidationMessage({
dsnInput: value,
hasStoredSecret: initialValues?.hasOpaqueDSN,
clearStoredSecret: clearSecrets.opaqueDSN,
});
return validationMessage
? Promise.reject(new Error(validationMessage))
: Promise.resolve();
}
});
const getUriPlaceholder = () => {
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') {
const defaultPort = getDefaultPortByType(dbType);
@@ -1066,6 +1181,7 @@ const ConnectionModal: React.FC<{
setUriFeedback(null);
setCustomIconType(undefined);
setCustomIconColor(undefined);
setClearSecrets(createEmptyConnectionSecretClearState());
setTypeSelectWarning(null);
setDriverStatusLoaded(false);
void refreshDriverStatus();
@@ -1198,6 +1314,107 @@ const ConnectionModal: React.FC<{
};
}, []);
const buildSavedConnectionInput = (config: ConnectionConfig, values: any) => {
const connectionId = initialValues?.id || config.id || Date.now().toString();
const primaryDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasPrimaryPassword,
valueInput: config.password,
clearSecret: clearSecrets.primaryPassword,
forceClear: values.type === 'mongodb' && values.savePassword === false,
});
const sshDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasSSHPassword,
valueInput: config.ssh?.password,
clearSecret: clearSecrets.sshPassword,
forceClear: !config.useSSH,
});
const proxyDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasProxyPassword,
valueInput: config.proxy?.password,
clearSecret: clearSecrets.proxyPassword,
forceClear: !config.useProxy,
});
const httpTunnelDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasHttpTunnelPassword,
valueInput: config.httpTunnel?.password,
clearSecret: clearSecrets.httpTunnelPassword,
forceClear: !config.useHttpTunnel,
});
const mysqlReplicaEnabled = (config.type === 'mysql' || config.type === 'mariadb' || config.type === 'diros' || config.type === 'sphinx')
&& config.topology === 'replica';
const mysqlReplicaDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasMySQLReplicaPassword,
valueInput: config.mysqlReplicaPassword,
clearSecret: clearSecrets.mysqlReplicaPassword,
forceClear: !mysqlReplicaEnabled,
});
const mongoReplicaEnabled = config.type === 'mongodb'
&& config.topology === 'replica'
&& values.savePassword !== false;
const mongoReplicaDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasMongoReplicaPassword,
valueInput: config.mongoReplicaPassword,
clearSecret: clearSecrets.mongoReplicaPassword,
forceClear: !mongoReplicaEnabled,
});
const opaqueUriDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasOpaqueURI,
valueInput: config.uri,
clearSecret: clearSecrets.opaqueURI,
forceClear: values.type === 'custom',
trimInput: true,
});
const opaqueDsnDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasOpaqueDSN,
valueInput: config.dsn,
clearSecret: clearSecrets.opaqueDSN,
forceClear: values.type !== 'custom',
trimInput: true,
});
const isRedisType = values.type === 'redis';
const displayHost = String((config as any).host || values.host || '').trim();
const nextName = values.name || (isFileDatabaseType(values.type)
? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB')
: (values.type === 'redis' ? `Redis ${displayHost}` : displayHost));
return {
id: connectionId,
name: nextName,
config: {
...config,
id: connectionId,
password: primaryDraft.value,
ssh: {
...(config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }),
password: sshDraft.value,
},
proxy: {
...(config.proxy || { type: 'socks5', host: '', port: 1080, user: '', password: '' }),
password: proxyDraft.value,
},
httpTunnel: {
...(config.httpTunnel || { host: '', port: 8080, user: '', password: '' }),
password: httpTunnelDraft.value,
},
uri: opaqueUriDraft.value,
dsn: opaqueDsnDraft.value,
mysqlReplicaPassword: mysqlReplicaDraft.value,
mongoReplicaPassword: mongoReplicaDraft.value,
},
includeDatabases: values.includeDatabases,
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined,
iconType: customIconType || '',
iconColor: customIconColor || '',
clearPrimaryPassword: primaryDraft.clearStoredSecret,
clearSSHPassword: sshDraft.clearStoredSecret,
clearProxyPassword: proxyDraft.clearStoredSecret,
clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret,
clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret,
clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret,
clearOpaqueURI: opaqueUriDraft.clearStoredSecret,
clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret,
};
};
const handleOk = async () => {
try {
await form.validateFields();
@@ -1211,28 +1428,21 @@ const ConnectionModal: React.FC<{
setLoading(true);
const config = await buildConfig(values, true);
const displayHost = String((config as any).host || values.host || '').trim();
const isRedisType = values.type === 'redis';
const newConn = {
id: initialValues ? initialValues.id : Date.now().toString(),
name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
config: config,
includeDatabases: values.includeDatabases,
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined,
iconType: customIconType,
iconColor: customIconColor,
};
const payload = buildSavedConnectionInput(config, values);
const backendApp = (window as any).go?.app?.App;
const savedConnection = await backendApp?.SaveConnection?.(payload);
if (!savedConnection) {
throw new Error('保存连接失败:后端接口不可用');
}
if (initialValues) {
updateConnection(newConn);
updateConnection(savedConnection);
message.success('配置已更新(未连接)');
} else {
addConnection(newConn);
addConnection(savedConnection);
message.success('配置已保存(未连接)');
}
setLoading(false);
form.resetFields();
setUseSSL(false);
setUseSSH(false);
@@ -1240,8 +1450,11 @@ const ConnectionModal: React.FC<{
setUseHttpTunnel(false);
setDbType('mysql');
setStep(1);
setClearSecrets(createEmptyConnectionSecretClearState());
onClose();
} catch (e) {
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setLoading(false);
}
};
@@ -1271,6 +1484,30 @@ const ConnectionModal: React.FC<{
}
};
const getBlockingSecretClearMessage = (values: any): string | null => {
if (clearSecrets.primaryPassword && values.type !== 'custom' && !isFileDatabaseType(values.type) && String(values.password ?? '') === '') {
return '测试连接前请填写新的密码,或取消清除已保存密码';
}
if (clearSecrets.sshPassword && values.useSSH && String(values.sshPassword ?? '') === '') {
return '测试连接前请填写新的 SSH 密码,或取消清除已保存 SSH 密码';
}
if (clearSecrets.proxyPassword && values.useProxy && !values.useHttpTunnel && String(values.proxyPassword ?? '') === '') {
return '测试连接前请填写新的代理密码,或取消清除已保存代理密码';
}
if (clearSecrets.httpTunnelPassword && values.useHttpTunnel && String(values.httpTunnelPassword ?? '') === '') {
return '测试连接前请填写新的隧道密码,或取消清除已保存隧道密码';
}
if (clearSecrets.mysqlReplicaPassword && (values.type === 'mysql' || values.type === 'mariadb' || values.type === 'diros' || values.type === 'sphinx') && values.mysqlTopology === 'replica' && String(values.mysqlReplicaPassword ?? '') === '') {
return '测试连接前请填写新的从库密码,或取消清除已保存从库密码';
}
if (clearSecrets.mongoReplicaPassword && values.type === 'mongodb' && values.mongoTopology === 'replica' && String(values.mongoReplicaPassword ?? '') === '') {
return '测试连接前请填写新的副本集密码,或取消清除已保存副本集密码';
}
if (values.type === 'mongodb' && values.savePassword === false && initialValues?.hasPrimaryPassword && String(values.password ?? '') === '') {
return '测试连接前请填写新的 MongoDB 密码,或重新勾选保存密码';
}
return null;
};
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
const text = String(reason ?? '').trim();
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
@@ -1290,9 +1527,17 @@ const ConnectionModal: React.FC<{
promptInstallDriver(values.type, unavailableReason);
return;
}
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
if (blockingSecretClearMessage) {
setTestResult({ type: 'error', message: blockingSecretClearMessage });
return;
}
setLoading(true);
setTestResult(null);
const config = await buildConfig(values, false);
if (initialValues?.id) {
config.id = initialValues.id;
}
const timeoutSecondsRaw = Number(values.timeout);
const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) && timeoutSecondsRaw > 0
? Math.min(timeoutSecondsRaw, MAX_TIMEOUT_SECONDS)
@@ -1368,7 +1613,15 @@ const ConnectionModal: React.FC<{
await form.validateFields();
const values = form.getFieldsValue(true);
setDiscoveringMembers(true);
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
if (blockingSecretClearMessage) {
message.error(blockingSecretClearMessage);
return;
}
const config = await buildConfig(values, false);
if (initialValues?.id) {
config.id = initialValues.id;
}
const result = await MongoDiscoverMembers(config as any);
if (!result.success) {
message.error(result.message || '成员发现失败');
@@ -1850,7 +2103,7 @@ const ConnectionModal: React.FC<{
<div style={{ ...modalMutedTextStyle, marginBottom: 16 }}></div>
<Form.Item name="name" label="连接名称">
<Input placeholder="例如:本地测试库" />
<Input {...noAutoCapInputProps} placeholder="例如:本地测试库" />
</Form.Item>
{!isCustom && (
@@ -1860,7 +2113,7 @@ const ConnectionModal: React.FC<{
label="连接 URI可复制粘贴"
help="支持从参数生成、复制到剪贴板,或粘贴后一键解析回填参数"
>
<Input.TextArea rows={3} placeholder={getUriPlaceholder()} />
<Input.TextArea {...noAutoCapInputProps} rows={3} placeholder={getUriPlaceholder()} />
</Form.Item>
<Space size={8} style={{ marginBottom: uriFeedback ? 12 : 16 }} wrap>
<Button onClick={handleGenerateURI}> URI</Button>
@@ -1877,17 +2130,31 @@ const ConnectionModal: React.FC<{
style={{ marginBottom: 16 }}
/>
)}
{renderStoredSecretControls({
fieldName: 'uri',
clearKey: 'opaqueURI',
hasStoredSecret: initialValues?.hasOpaqueURI,
clearLabel: '清除已保存 URI',
description: '当前已保存连接 URI。留空表示继续沿用输入新值表示替换。',
})}
</>
)}
{isCustom ? (
<>
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help="已支持: mysql, postgres, sqlite, oracle, dm, kingbase">
<Input placeholder="例如: mysql, postgres" />
<Input {...noAutoCapInputProps} placeholder="例如: mysql, postgres" />
</Form.Item>
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[{ required: true, message: '请输入连接字符串' }]}>
<Input.TextArea rows={4} placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8" />
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[createCustomDsnRule()]}>
<Input.TextArea {...noAutoCapInputProps} rows={4} placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8" />
</Form.Item>
{renderStoredSecretControls({
fieldName: 'dsn',
clearKey: 'opaqueDSN',
hasStoredSecret: initialValues?.hasOpaqueDSN,
clearLabel: '清除已保存 DSN',
description: '当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。',
})}
</>
) : (
<>
@@ -1899,6 +2166,7 @@ const ConnectionModal: React.FC<{
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder={isFileDb ? (dbType === 'duckdb' ? '/path/to/db.duckdb' : '/path/to/db.sqlite') : 'localhost'}
/>
</Form.Item>
@@ -1926,7 +2194,7 @@ const ConnectionModal: React.FC<{
label="默认连接数据库(可选)"
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
>
<Input placeholder="例如appdb" />
<Input {...noAutoCapInputProps} placeholder="例如appdb" />
</Form.Item>
)}
@@ -1937,7 +2205,7 @@ const ConnectionModal: React.FC<{
rules={[createUriAwareRequiredRule('请输入 Oracle 服务名(例如 ORCLPDB1')]}
help="请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
>
<Input placeholder="例如ORCLPDB1" />
<Input {...noAutoCapInputProps} placeholder="例如ORCLPDB1" />
</Form.Item>
)}
@@ -1962,12 +2230,19 @@ const ConnectionModal: React.FC<{
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="mysqlReplicaUser" label="从库用户名(可选)" style={{ marginBottom: 0 }}>
<Input placeholder="留空沿用主库用户名" />
<Input {...noAutoCapInputProps} placeholder="留空沿用主库用户名" />
</Form.Item>
<Form.Item name="mysqlReplicaPassword" label="从库密码(可选)" style={{ marginBottom: 0 }}>
<Input.Password placeholder="留空沿用主库密码" />
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主库密码" />
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: 'mysqlReplicaPassword',
clearKey: 'mysqlReplicaPassword',
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
clearLabel: '清除已保存从库密码',
description: '当前已保存从库密码。留空表示继续沿用,输入新值表示替换。',
})}
</>
)}
</>
@@ -2001,15 +2276,22 @@ const ConnectionModal: React.FC<{
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="mongoReplicaSet" label="副本集名称(可选)" style={{ marginBottom: 0 }}>
<Input placeholder="例如rs0" />
<Input {...noAutoCapInputProps} placeholder="例如rs0" />
</Form.Item>
<Form.Item name="mongoReplicaUser" label="副本集用户名(可选)" style={{ marginBottom: 0 }}>
<Input placeholder="留空沿用主用户名" />
<Input {...noAutoCapInputProps} placeholder="留空沿用主用户名" />
</Form.Item>
</div>
<Form.Item name="mongoReplicaPassword" label="副本集密码(可选)" style={{ marginBottom: 0 }}>
<Input.Password placeholder="留空沿用主密码" />
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主密码" />
</Form.Item>
{renderStoredSecretControls({
fieldName: 'mongoReplicaPassword',
clearKey: 'mongoReplicaPassword',
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
clearLabel: '清除已保存副本集密码',
description: '当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。',
})}
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
<Button onClick={handleDiscoverMongoMembers} loading={discoveringMembers}></Button>
</Space>
@@ -2045,7 +2327,7 @@ const ConnectionModal: React.FC<{
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="mongoAuthSource" label="认证库 (authSource)" style={{ marginBottom: 0 }}>
<Input placeholder="默认使用 database 或 admin" />
<Input {...noAutoCapInputProps} placeholder="默认使用 database 或 admin" />
</Form.Item>
<Form.Item name="mongoReadPreference" label="读偏好 (readPreference)" style={{ marginBottom: 0 }}>
<Select
@@ -2082,8 +2364,15 @@ const ConnectionModal: React.FC<{
</Form.Item>
)}
<Form.Item name="password" label="密码 (可选)">
<Input.Password placeholder="Redis 密码(如果设置了 requirepass" />
<Input.Password {...noAutoCapInputProps} placeholder="Redis 密码(如果设置了 requirepass" />
</Form.Item>
{renderStoredSecretControls({
fieldName: 'password',
clearKey: 'primaryPassword',
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: '清除已保存密码',
description: '当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。',
})}
<Form.Item
name="includeRedisDatabases"
label="显示数据库 (留空显示全部)"
@@ -2097,17 +2386,18 @@ const ConnectionModal: React.FC<{
)}
{!isFileDb && !isRedis && (
<>
<div style={{ display: 'grid', gridTemplateColumns: dbType === 'mongodb' ? 'minmax(0, 1fr) minmax(0, 1fr) 180px' : 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item
name="user"
label="用户名"
rules={[createUriAwareRequiredRule('请输入用户名')]}
rules={dbType === 'mongodb' ? [] : [createUriAwareRequiredRule('请输入用户名')]}
style={{ marginBottom: 0 }}
>
<Input />
<Input {...noAutoCapInputProps} />
</Form.Item>
<Form.Item name="password" label="密码" style={{ marginBottom: 0 }}>
<Input.Password />
<Input.Password {...noAutoCapInputProps} />
</Form.Item>
{dbType === 'mongodb' && (
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ marginBottom: 0 }}>
@@ -2115,6 +2405,7 @@ const ConnectionModal: React.FC<{
allowClear
placeholder="自动协商"
options={[
{ value: 'NONE', label: '无认证 (None)' },
{ value: 'SCRAM-SHA-1', label: 'SCRAM-SHA-1' },
{ value: 'SCRAM-SHA-256', label: 'SCRAM-SHA-256' },
{ value: 'MONGODB-AWS', label: 'MONGODB-AWS' },
@@ -2123,6 +2414,14 @@ const ConnectionModal: React.FC<{
</Form.Item>
)}
</div>
{renderStoredSecretControls({
fieldName: 'password',
clearKey: 'primaryPassword',
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: '清除已保存密码',
description: '当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。',
})}
</>
)}
{dbType === 'mongodb' && (
@@ -2182,10 +2481,10 @@ const ConnectionModal: React.FC<{
{dbType === 'dameng' && (
<>
<Form.Item name="sslCertPath" label="客户端证书路径 (SSL_CERT_PATH)" rules={[{ required: true, message: '达梦 SSL 需要证书路径' }]} style={{ marginBottom: 8 }}>
<Input placeholder="例如: C:\certs\client-cert.pem" />
<Input {...noAutoCapInputProps} placeholder="例如: C:\certs\client-cert.pem" />
</Form.Item>
<Form.Item name="sslKeyPath" label="客户端私钥路径 (SSL_KEY_PATH)" rules={[{ required: true, message: '达梦 SSL 需要私钥路径' }]} style={{ marginBottom: 8 }}>
<Input placeholder="例如: C:\certs\client-key.pem" />
<Input {...noAutoCapInputProps} placeholder="例如: C:\certs\client-key.pem" />
</Form.Item>
</>
)}
@@ -2208,7 +2507,7 @@ const ConnectionModal: React.FC<{
<div style={tunnelSectionStyle}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 120px', gap: 16 }}>
<Form.Item name="sshHost" label="SSH 主机 (域名或IP)" rules={[{ required: useSSH, message: '请输入SSH主机' }]} style={{ flex: 1 }}>
<Input placeholder="例如: ssh.example.com 或 192.168.1.100" />
<Input {...noAutoCapInputProps} placeholder="例如: ssh.example.com 或 192.168.1.100" />
</Form.Item>
<Form.Item name="sshPort" label="端口" rules={[{ required: useSSH, message: '请输入SSH端口' }]} style={{ width: 100 }}>
<InputNumber style={{ width: '100%' }} />
@@ -2216,22 +2515,29 @@ const ConnectionModal: React.FC<{
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="sshUser" label="SSH 用户" rules={[{ required: useSSH, message: '请输入SSH用户' }]} style={{ flex: 1 }}>
<Input placeholder="root" />
<Input {...noAutoCapInputProps} placeholder="root" />
</Form.Item>
<Form.Item name="sshPassword" label="SSH 密码" style={{ flex: 1 }}>
<Input.Password placeholder="密码" />
<Input.Password {...noAutoCapInputProps} placeholder="密码" />
</Form.Item>
</div>
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="sshKeyPath" noStyle>
<Input placeholder="绝对路径" />
<Input {...noAutoCapInputProps} placeholder="绝对路径" />
</Form.Item>
<Button onClick={handleSelectSSHKeyFile} loading={selectingSSHKey}>
...
</Button>
</Space.Compact>
</Form.Item>
{renderStoredSecretControls({
fieldName: 'sshPassword',
clearKey: 'sshPassword',
hasStoredSecret: initialValues?.hasSSHPassword,
clearLabel: '清除已保存 SSH 密码',
description: '当前已保存 SSH 密码。留空表示继续沿用,输入新值表示替换。',
})}
</div>
)}
</div>
@@ -2249,7 +2555,7 @@ const ConnectionModal: React.FC<{
) : (
<div style={tunnelSectionStyle}>
<Form.Item name="proxyHost" label="代理主机" rules={[{ required: useProxy, message: '请输入代理主机' }]}>
<Input placeholder="例如: 127.0.0.1 或 proxy.company.com" />
<Input {...noAutoCapInputProps} placeholder="例如: 127.0.0.1 或 proxy.company.com" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '180px 120px', gap: 16 }}>
<Form.Item name="proxyType" label="代理类型" rules={[{ required: useProxy, message: '请选择代理类型' }]} style={{ marginBottom: 0 }}>
@@ -2264,12 +2570,19 @@ const ConnectionModal: React.FC<{
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="proxyUser" label="代理用户名(可选)" style={{ flex: 1 }}>
<Input placeholder="留空表示无认证" />
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
<Input.Password placeholder="留空表示无认证" />
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: 'proxyPassword',
clearKey: 'proxyPassword',
hasStoredSecret: initialValues?.hasProxyPassword,
clearLabel: '清除已保存代理密码',
description: '当前已保存代理密码。留空表示继续沿用,输入新值表示替换。',
})}
</div>
)}
</div>
@@ -2287,7 +2600,7 @@ const ConnectionModal: React.FC<{
<div style={tunnelSectionStyle}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 120px', gap: 16 }}>
<Form.Item name="httpTunnelHost" label="隧道主机" rules={[{ required: useHttpTunnel, message: '请输入隧道主机' }]} style={{ flex: 1 }}>
<Input placeholder="例如: tunnel.company.com 或 127.0.0.1" />
<Input {...noAutoCapInputProps} placeholder="例如: tunnel.company.com 或 127.0.0.1" />
</Form.Item>
<Form.Item name="httpTunnelPort" label="端口" rules={[{ required: useHttpTunnel, message: '请输入隧道端口' }]} style={{ width: 120 }}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
@@ -2295,12 +2608,19 @@ const ConnectionModal: React.FC<{
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="httpTunnelUser" label="隧道用户名(可选)" style={{ flex: 1 }}>
<Input placeholder="留空表示无认证" />
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="httpTunnelPassword" label="隧道密码(可选)" style={{ flex: 1 }}>
<Input.Password placeholder="留空表示无认证" />
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: 'httpTunnelPassword',
clearKey: 'httpTunnelPassword',
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
clearLabel: '清除已保存隧道密码',
description: '当前已保存隧道密码。留空表示继续沿用,输入新值表示替换。',
})}
<Text type="secondary" style={{ fontSize: 12 }}>使 HTTP CONNECT </Text>
</div>
)}
@@ -2503,7 +2823,7 @@ const ConnectionModal: React.FC<{
}
}}
>
<Form.Item name="type" hidden><Input /></Form.Item>
<Form.Item name="type" hidden><Input {...noAutoCapInputProps} /></Form.Item>
{currentDriverUnavailableReason && (
<Alert
showIcon
@@ -2831,3 +3151,7 @@ const ConnectionModal: React.FC<{
};
export default ConnectionModal;

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview
import { SavedConnection } from '../types';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
const { Title, Text } = Typography;
const { Step } = Steps;
@@ -74,7 +76,10 @@ const toSqlLiteral = (value: any, dbType: string): string => {
return value ? 'TRUE' : 'FALSE';
}
if (value instanceof Date) {
return `'${value.toISOString().replace(/'/g, "''")}'`;
return `'${formatLocalDateTimeLiteral(value).replace(/'/g, "''")}'`;
}
if (typeof value === 'string') {
return `'${value.replace(/'/g, "''")}'`;
}
if (typeof value === 'object') {
try {
@@ -86,6 +91,20 @@ const toSqlLiteral = (value: any, dbType: string): string => {
return `'${String(value).replace(/'/g, "''")}'`;
};
const toTypedSqlLiteral = (value: any, dbType: string, columnType?: string): string => {
if (typeof value === 'string') {
const normalized = normalizeTemporalLiteralText(value, columnType, false);
return toSqlLiteral(normalized, dbType);
}
if (value instanceof Date) {
const normalized = String(columnType || '').trim()
? formatLocalDateTimeLiteral(value)
: value.toISOString();
return toSqlLiteral(normalized, dbType);
}
return toSqlLiteral(value, dbType);
};
const resolveRedisDbIndex = (raw?: string): number => {
const value = Number(String(raw || '').trim());
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
@@ -100,6 +119,9 @@ const buildSqlPreview = (
if (!previewData || !tableName) return { sqlText: '', statementCount: 0 };
const tableExpr = quoteSqlTable(dbType, tableName);
const pkCol = String(previewData.pkColumn || 'id');
const columnTypesByLowerName = previewData?.columnTypes && typeof previewData.columnTypes === 'object'
? previewData.columnTypes as Record<string, string>
: {};
const statements: string[] = [];
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
@@ -118,7 +140,7 @@ const buildSqlPreview = (
const columns = Object.keys(row);
if (columns.length === 0) return;
const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', ');
const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', ');
const valExpr = columns.map((c) => toTypedSqlLiteral(row[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])).join(', ');
statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`);
});
}
@@ -134,10 +156,10 @@ const buildSqlPreview = (
const setCols = changedColumns.filter((c: string) => String(c) !== pkCol);
if (setCols.length === 0) return;
const setExpr = setCols
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`)
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toTypedSqlLiteral(source[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])}`)
.join(', ');
statements.push(
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
);
});
}
@@ -147,7 +169,7 @@ const buildSqlPreview = (
const pk = String(rowWrap?.pk ?? '');
if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return;
statements.push(
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
);
});
}
@@ -215,14 +237,11 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const logBoxRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const normalizeConnConfig = (conn: SavedConnection, database?: string) => ({
...conn.config,
port: Number((conn.config as any).port),
password: conn.config.password || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
database: typeof database === 'string' ? database : (conn.config.database || ""),
});
const normalizeConnConfig = (conn: SavedConnection, database?: string) => (
buildRpcConnectionConfig(conn.config, {
database: typeof database === 'string' ? database : (conn.config.database || ''),
})
);
useEffect(() => {
if (!open) return;
@@ -521,22 +540,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
});
const config = {
sourceConfig: {
...sConn.config,
port: Number((sConn.config as any).port),
password: sConn.config.password || "",
useSSH: sConn.config.useSSH || false,
ssh: sConn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
database: sourceDb,
},
targetConfig: {
...tConn.config,
port: Number((tConn.config as any).port),
password: tConn.config.password || "",
useSSH: tConn.config.useSSH || false,
ssh: tConn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
database: targetDb,
},
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: syncContent,
mode: syncMode,

View File

@@ -6,7 +6,10 @@ import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
type ViewerPaginationState = {
current: number;
@@ -14,6 +17,7 @@ type ViewerPaginationState = {
total: number;
totalKnown: boolean;
totalApprox: boolean;
approximateTotal?: number;
totalCountLoading: boolean;
totalCountCancelled: boolean;
};
@@ -70,30 +74,6 @@ const parseTotalFromCountRow = (row: any): number | null => {
return null;
};
const parseDuckDBApproxTotalRow = (row: any): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
const preferredKeys = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'count', 'total'];
for (const preferred of preferredKeys) {
for (const [key, raw] of entries) {
if (String(key || '').trim().toLowerCase() !== preferred) continue;
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
for (const [key, raw] of entries) {
const normalized = String(key || '').trim().toLowerCase();
if (normalized.includes('estimate') || normalized.includes('row') || normalized.includes('count') || normalized.includes('total')) {
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
return null;
};
const normalizeDuckDBIdentifier = (raw: string): string => {
const text = String(raw || '').trim();
if (text.length >= 2) {
@@ -201,7 +181,7 @@ const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
};
};
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
@@ -214,6 +194,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const countKeyRef = useRef<string>('');
const duckdbApproxSeqRef = useRef(0);
const duckdbApproxKeyRef = useRef<string>('');
const oracleApproxSeqRef = useRef(0);
const oracleApproxKeyRef = useRef<string>('');
const manualCountSeqRef = useRef(0);
const manualCountKeyRef = useRef<string>('');
const pkSeqRef = useRef(0);
@@ -228,6 +210,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
left: initialViewerSnapshot.scrollLeft,
});
const initialLoadRef = useRef(false);
const skipNextAutoFetchRef = useRef(false);
const [pagination, setPagination] = useState<ViewerPaginationState>({
current: initialViewerSnapshot.currentPage,
@@ -246,8 +229,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
const currentConnType = currentConnCaps.type;
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
const preferManualTotalCount = currentConnCaps.preferManualTotalCount;
const supportsApproximateTableCount = currentConnCaps.supportsApproximateTableCount;
const supportsApproximateTotalPages = currentConnCaps.supportsApproximateTotalPages;
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
const normalizedTabId = String(tabId || '').trim();
if (!normalizedTabId) return;
@@ -288,6 +273,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
pkKeyRef.current = '';
countKeyRef.current = '';
duckdbApproxKeyRef.current = '';
oracleApproxKeyRef.current = '';
manualCountKeyRef.current = '';
duckdbSafeSelectCacheRef.current = {};
latestConfigRef.current = null;
@@ -297,6 +283,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
latestCountKeyRef.current = '';
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
skipNextAutoFetchRef.current = true;
setPagination(prev => ({
...prev,
current: snapshot.currentPage,
@@ -304,6 +291,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total: 0,
totalKnown: false,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
}));
@@ -317,10 +305,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}, [tab.id, persistViewerSnapshot]);
const handleDuckDBManualCount = useCallback(async () => {
if (latestDbTypeRef.current !== 'duckdb') {
return;
}
const handleManualTotalCount = useCallback(async () => {
const config = latestConfigRef.current;
const dbName = latestDbNameRef.current;
const countSql = latestCountSqlRef.current;
@@ -335,13 +320,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const countSeq = ++manualCountSeqRef.current;
const countStart = Date.now();
setPagination(prev => ({ ...prev, totalCountLoading: true, totalCountCancelled: false }));
const countConfig: any = { ...(config as any), timeout: 120 };
const countConfig = buildRpcConnectionConfig(config, { timeout: 120 });
try {
const resCount = await DBQuery(countConfig as any, dbName, countSql);
const countDuration = Date.now() - countStart;
addSqlLog({
id: `log-${Date.now()}-duckdb-manual-count`,
id: `log-${Date.now()}-manual-count`,
timestamp: Date.now(),
sql: countSql,
status: resCount?.success ? 'success' : 'error',
@@ -375,6 +360,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total,
totalKnown: true,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
}));
@@ -386,7 +372,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
}, [addSqlLog]);
const handleDuckDBCancelManualCount = useCallback(() => {
const handleCancelManualTotalCount = useCallback(() => {
manualCountSeqRef.current++;
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
}, []);
@@ -438,7 +424,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const totalRows = Number(pagination.total);
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
const totalKnown = pagination.totalKnown && hasFiniteTotal;
const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0;
const approximateTotalRows = Number(pagination.approximateTotal);
const hasApproximateTotalPages =
!totalKnown &&
supportsApproximateTotalPages &&
pagination.totalApprox &&
Number.isFinite(approximateTotalRows) &&
approximateTotalRows > 0;
const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : totalRows;
const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0;
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
const offset = (currentPage - 1) * size;
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
@@ -485,7 +479,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
const startTime = Date.now();
try {
const result = await DBQuery(config as any, dbName, querySql);
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, querySql);
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
@@ -521,7 +515,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
let safeSelect = duckdbSafeSelectCacheRef.current[cacheKey] || '';
if (!safeSelect) {
try {
const resCols = await DBGetColumns(config as any, dbName, tableName);
const resCols = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName);
if (resCols?.success && Array.isArray(resCols.data)) {
const columnDefs = resCols.data as ColumnDefinition[];
const selectParts = columnDefs.map((col) => {
@@ -574,7 +568,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
if (pkKeyRef.current !== pkKey) {
pkKeyRef.current = pkKey;
const pkSeq = ++pkSeqRef.current;
DBGetColumns(config as any, dbName, tableName)
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName)
.then((resCols: any) => {
if (pkSeqRef.current !== pkSeq) return;
if (pkKeyRef.current !== pkKey) return;
@@ -632,6 +626,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total: derivedTotal,
totalKnown: true,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
};
@@ -647,13 +642,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
}
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
const hasApproximateTotalForCurrentKey =
prev.totalApprox &&
(duckdbApproxKeyRef.current === countKey || oracleApproxKeyRef.current === countKey) &&
Number.isFinite(prev.approximateTotal) &&
Number(prev.approximateTotal) >= minExpectedTotal;
if (hasApproximateTotalForCurrentKey) {
return {
...prev,
current: currentPage,
pageSize: size,
total: derivedTotal,
totalKnown: false,
totalApprox: true,
approximateTotal: prev.approximateTotal,
totalCountLoading: keepManualCounting,
totalCountCancelled: false,
};
@@ -665,12 +667,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total: derivedTotal,
totalKnown: false,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: keepManualCounting,
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
};
});
const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB;
const shouldRunAsyncCount = !derivedTotalKnown && !preferManualTotalCount;
if (shouldRunAsyncCount) {
if (countKeyRef.current !== countKey) {
countKeyRef.current = countKey;
@@ -678,7 +681,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const countStart = Date.now();
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。
const countConfig: any = { ...(config as any), timeout: 5 };
const countConfig = buildRpcConnectionConfig(config, { timeout: 5 });
DBQuery(countConfig, dbName, countSql)
.then((resCount: any) => {
@@ -695,7 +698,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
if (countSeqRef.current !== countSeq) return;
if (countKeyRef.current !== countKey) return;
if (latestCountKeyRef.current !== countKey) return;
if (!resCount.success) return;
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
@@ -708,6 +711,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total,
totalKnown: true,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
}));
@@ -720,48 +724,88 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
}
if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) {
duckdbApproxKeyRef.current = countKey;
const approxSeq = ++duckdbApproxSeqRef.current;
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
const escapedSchema = escapeSQLLiteral(schemaName);
const escapedTable = escapeSQLLiteral(pureTableName);
const approxConfig: any = { ...(config as any), timeout: 3 };
const approxSqlCandidates = [
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
];
if (!derivedTotalKnown) {
const approximateCountStrategy = supportsApproximateTableCount
? resolveApproximateTableCountStrategy({ dbType: dbTypeLower, whereSQL })
: 'none';
(async () => {
for (const approxSql of approxSqlCandidates) {
try {
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
if (approximateCountStrategy === 'duckdb-estimated-size' && duckdbApproxKeyRef.current !== countKey) {
duckdbApproxKeyRef.current = countKey;
const approxSeq = ++duckdbApproxSeqRef.current;
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
const escapedSchema = escapeSQLLiteral(schemaName);
const escapedTable = escapeSQLLiteral(pureTableName);
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
const approxSqlCandidates = [
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
];
const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]);
if (approxTotal === null) continue;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
(async () => {
for (const approxSql of approxSqlCandidates) {
try {
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
const approxTotal = parseApproximateTableCountRow(approxRes.data[0]);
if (approxTotal === null) continue;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
setPagination(prev => {
if (latestCountKeyRef.current !== countKey) return prev;
if (prev.totalKnown) return prev;
return {
...prev,
totalKnown: false,
totalApprox: true,
approximateTotal: approxTotal,
totalCountCancelled: false,
};
});
return;
} catch {
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
}
}
})();
}
if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) {
oracleApproxKeyRef.current = countKey;
const approxSeq = ++oracleApproxSeqRef.current;
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
const approxSql = buildOracleApproximateTotalSql({ dbName, tableName });
DBQuery(approxConfig as any, dbName, approxSql)
.then((approxRes: any) => {
if (oracleApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) return;
const approxTotal = parseApproximateTableCountRow(approxRes.data[0], ['approx_total', 'num_rows', 'estimated_rows', 'row_count', 'count', 'total']);
if (approxTotal === null) return;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) return;
setPagination(prev => {
if (countKeyRef.current !== countKey) return prev;
if (latestCountKeyRef.current !== countKey) return prev;
if (prev.totalKnown) return prev;
return {
...prev,
total: approxTotal,
totalKnown: false,
totalApprox: true,
approximateTotal: approxTotal,
totalCountCancelled: false,
};
});
return;
} catch {
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
}
}
})();
})
.catch(() => {
if (oracleApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
});
}
}
} else {
message.error(String(resData.message || '查询失败'));
@@ -780,7 +824,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
// 依赖 pkColumns在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
@@ -828,7 +872,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
useEffect(() => {
if (!initialLoadRef.current) {
const action = resolveDataViewerAutoFetchAction({
skipNextAutoFetch: skipNextAutoFetchRef.current,
hasInitialLoad: initialLoadRef.current,
});
if (action === 'skip') {
skipNextAutoFetchRef.current = false;
return;
}
if (action === 'load-current-page') {
initialLoadRef.current = true;
fetchData(pagination.current, pagination.pageSize);
return;
@@ -851,8 +903,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
onSort={handleSort}
onPageChange={handlePageChange}
pagination={pagination}
onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined}
onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined}
onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined}
onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined}
showFilter={showFilter}
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}

View File

@@ -37,7 +37,7 @@ export const getDbDefaultColor = (type: string): string =>
const BRAND_SVG_TYPES = new Set([
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite',
'diros', 'sphinx', 'duckdb',
'diros', 'sphinx', 'duckdb', 'sqlserver',
]);
/** 品牌 SVG 图标:用 <img> 加载 /db-icons/*.svg */
@@ -110,7 +110,7 @@ const OracleIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oracle} label="Or" />
);
const SQLServerIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sqlserver} label="SS" />
<BrandSvgIcon type="sqlserver" size={size} color={color} />
);
const DorisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="diros" size={size} color={color} />

View File

@@ -4,6 +4,7 @@ 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;
@@ -201,7 +202,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const sql = String(query || '').trim();
if (!sql) continue;
try {
const result = await DBQuery(config as any, dbName, sql);
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
if (!result.success || !Array.isArray(result.data)) {
lastMessage = result.message || lastMessage;
continue;
@@ -227,7 +228,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
];
for (const query of candidates) {
try {
const result = await DBQuery(config as any, dbName, query);
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
continue;
}

View File

@@ -11,6 +11,7 @@ import {
GetDriverVersionPackageSize,
GetDriverStatusList,
InstallLocalDriverPackage,
OpenDriverDownloadDirectory,
RemoveDriverPackage,
SelectDriverPackageDirectory,
SelectDriverPackageFile,
@@ -757,6 +758,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
};
}, [appendOperationLog, open]);
const resolveLocalImportVersion = useCallback((row: DriverStatusRow) => {
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
return selectedOption?.version || row.pinnedVersion || '';
}, [selectedVersionMap, versionMap]);
const installDriver = useCallback(async (row: DriverStatusRow) => {
setActionState({ driverType: row.type, kind: 'install' });
setProgressMap((prev) => ({
@@ -820,9 +831,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
percent: 0,
},
}));
appendOperationLog(row.type, `[START] 开始本地导入(${sourceLabel}${pathText}`);
const selectedVersion = resolveLocalImportVersion(row);
const versionTip = selectedVersion ? `${selectedVersion}` : '';
appendOperationLog(row.type, `[START] 开始本地导入${versionTip}${sourceLabel}${pathText}`);
try {
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir);
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir, selectedVersion);
if (!result?.success) {
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
@@ -831,9 +844,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
return false;
}
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
appendOperationLog(row.type, `[DONE] 本地导入安装完成 ${versionTip}`.trim());
if (!options?.silentToast) {
message.success(`${row.name} 本地驱动包已安装启用`);
message.success(`${row.name}${versionTip} 本地驱动包已安装启用`);
}
if (!options?.skipRefresh) {
await refreshStatus(false);
@@ -842,7 +855,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
} finally {
setActionState({ driverType: '', kind: '' });
}
}, [appendOperationLog, downloadDir, refreshStatus]);
}, [appendOperationLog, downloadDir, refreshStatus, resolveLocalImportVersion]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
@@ -936,6 +949,18 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
message.error(`目录导入失败${forceTip}:失败 ${failCount}${skipTip}`);
}, [appendOperationLog, downloadDir, forceOverwriteInstalled, installDriverFromLocalPath, refreshStatus, rows]);
const openDriverDirectory = useCallback(async () => {
try {
const res = await OpenDriverDownloadDirectory(downloadDir);
if (!res?.success) {
throw new Error(res?.message || '打开驱动目录失败');
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
message.error(`打开驱动目录失败: ${errMsg}`);
}
}, [downloadDir]);
const openDriverLog = useCallback((driverType: string) => {
const normalized = String(driverType || '').trim().toLowerCase();
if (!normalized) {
@@ -1067,29 +1092,35 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
const mongoHint = row.type === 'mongodb'
? '当前仅支持 MongoDB 1.17.x 和 2.x更老 1.x 暂不提供安装。'
: '';
return (
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
<div style={{ display: 'grid', gap: 4 }}>
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
{mongoHint ? <Text type="secondary" style={{ fontSize: 12 }}>{mongoHint}</Text> : null}
</div>
);
},
},
@@ -1342,10 +1373,14 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
children: (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary">使</Text>
<Text type="secondary">/ `mariadb-driver-agent``mariadb-driver-agent.exe``GoNavi-DriverAgents.zip`使</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
<Button icon={<FolderOpenOutlined />} onClick={() => void openDriverDirectory()}>
</Button>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
@@ -1374,6 +1409,12 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
onChange={(checked) => setForceOverwriteInstalled(checked)}
disabled={batchDirectoryImporting}
/>
<Button
icon={<FolderOpenOutlined />}
onClick={() => void openDriverDirectory()}
>
</Button>
<Button
icon={<FolderOpenOutlined />}
loading={batchDirectoryImporting}

View File

@@ -5,6 +5,7 @@ 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';
interface FindInDatabaseModalProps {
open: boolean;
@@ -106,7 +107,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
try {
// 1. 获取所有表
const tablesRes = await DBGetTables(config as any, dbName);
const tablesRes = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
if (!tablesRes.success) {
message.error('获取表列表失败: ' + tablesRes.message);
setSearching(false);
@@ -124,7 +125,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
setProgress({ current: 0, total: tableNames.length, tableName: '' });
// 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段)
const allColsRes = await DBGetAllColumns(config as any, dbName);
const allColsRes = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : [];
// 按表名分组
@@ -166,7 +167,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
const sql = buildLimitedSelectSQL(dbType, baseSql, MAX_MATCH_ROWS_PER_TABLE);
try {
const res = await DBQuery(config as any, dbName, sql);
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>();

View File

@@ -4,6 +4,7 @@ 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;
@@ -107,7 +108,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
};
const res = await ImportDataWithProgress(config as any, dbName, tableName, filePath);
const res = await ImportDataWithProgress(buildRpcConnectionConfig(config) as any, dbName, tableName, filePath);
if (res.success && res.data) {
setImportResult(res.data);

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined } from '@ant-design/icons';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition } from '../types';
@@ -11,6 +11,7 @@ import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
@@ -183,7 +184,7 @@ let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type
let sharedVisibleDbs: string[] = [];
let sharedColumnsCacheData: Record<string, any[]> = {};
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
type ResultSet = {
@@ -202,8 +203,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// Result Sets
const [resultSets, setResultSets] = useState<ResultSet[]>([]);
const [activeResultKey, setActiveResultKey] = useState<string>('');
const [loading, setLoading] = useState(false);
const [executionError, setExecutionError] = useState<string>('');
const [, setCurrentQueryId] = useState<string>('');
const runSeqRef = useRef(0);
const currentQueryIdRef = useRef('');
@@ -336,7 +337,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const res = await DBGetDatabases(config as any);
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
if (res.success && Array.isArray(res.data)) {
let dbs = res.data.map((row: any) => row.Database || row.database);
@@ -392,7 +393,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
for (const dbName of visibleDbs) {
// 获取表
const resTables = await DBGetTables(config as any, dbName);
const resTables = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
if (resTables.success && Array.isArray(resTables.data)) {
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
tableNames.forEach((tableName: string) => {
@@ -401,7 +402,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
// 获取列 (所有数据库类型都支持 DBGetAllColumns)
const resCols = await DBGetAllColumns(config as any, dbName);
const resCols = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
if (resCols.success && Array.isArray(resCols.data)) {
resCols.data.forEach((col: any) => {
allColumns.push({
@@ -465,6 +466,38 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// 应用透明主题(主题已在 main.tsx 全局注册)
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
// 注册 AI 右键菜单操作
const aiActions = [
{ id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' },
{ id: 'ai.explainSQL', label: '🤖 AI 解释 SQL', useSelection: true, prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n{SQL}\n```' },
{ id: 'ai.optimizeSQL', label: '🤖 AI 优化 SQL', useSelection: true, prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n{SQL}\n```' },
];
aiActions.forEach(action => {
editor.addAction({
id: action.id,
label: action.label,
contextMenuGroupId: '9_ai',
contextMenuOrder: 1,
run: (ed: any) => {
const selection = ed.getModel()?.getValueInRange(ed.getSelection());
const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current);
const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDbRef.current || '默认'}"】\n` : '';
let prompt = ctxText + action.prompt;
if (action.useSelection && selection) {
prompt = prompt.replace('{SQL}', selection);
}
// 打开 AI 面板并填入 prompt
const store = useStore.getState();
if (!store.aiPanelVisible) {
store.setAIPanelVisible(true);
}
// 通过自定义事件将 prompt 发送到 AI 面板
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
},
});
});
// 全局只注册一次 SQL completion provider避免多 tab 重复注册导致补全项重复
if (!sqlCompletionRegistered) {
sqlCompletionRegistered = true;
@@ -545,7 +578,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const config = buildConnConfig();
if (!config) return [] as ColumnDefinition[];
const res = await DBGetColumns(config as any, dbName, tableIdent);
const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableIdent);
if (res?.success && Array.isArray(res.data)) {
const cols = res.data as ColumnDefinition[];
sharedColumnsCacheData[key] = cols;
@@ -684,11 +717,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// Prefer preloaded MySQL all-columns cache
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[];
if (sharedAllColumnsData.length > 0) {
const tiTableLower = (tableInfo.tableName || '').toLowerCase();
cols = sharedAllColumnsData
.filter(c =>
(c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() &&
(c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase()
)
.filter(c => {
if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false;
const cTableLower = (c.tableName || '').toLowerCase();
if (cTableLower === tiTableLower) return true;
// schema.table 格式匹配纯表名
const parsed = splitSchemaAndTable(c.tableName || '');
return (parsed.table || '').toLowerCase() === tiTableLower;
})
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName }));
} else {
const dbCols = await getColumnsByDB(tableInfo.tableName);
@@ -741,7 +779,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
.filter(c => {
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
const shortIdent = (c.tableName || '').toLowerCase();
return (foundTables.has(fullIdent) || foundTables.has(shortIdent)) && startsWithPrefix(c.name || '');
// 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users
const parsed = splitSchemaAndTable(c.tableName || '');
const pureIdent = (parsed.table || '').toLowerCase();
return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && startsWithPrefix(c.name || '');
})
.map(c => {
// 当前库的表字段优先级更高
@@ -756,24 +797,61 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
};
});
// 表提示:当前库显示表名,其他库显示 db.table 格式
// 表提示:当前库智能处理 schema.table 格式
// 1. 构建纯表名到 schema 列表的映射,检测同名表
const currentDbTables = sharedTablesData.filter(t =>
(t.dbName || '').toLowerCase() === currentDatabase.toLowerCase()
);
const tableNameToSchemas = new Map<string, string[]>();
for (const t of currentDbTables) {
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = (parsed.table || t.tableName || '').toLowerCase();
const schemas = tableNameToSchemas.get(pureTable) || [];
schemas.push(parsed.schema || '');
tableNameToSchemas.set(pureTable, schemas);
}
const tableSuggestions = sharedTablesData
.filter(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
return startsWithPrefix(label || '');
if (!isCurrentDb) {
// 跨库:用 db.table 格式匹配
return startsWithPrefix(`${t.dbName}.${t.tableName}`);
}
// 当前库:同时用完整名和纯表名匹配
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
return startsWithPrefix(t.tableName || '') || startsWithPrefix(pureTable);
})
.map(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
if (!isCurrentDb) {
const label = `${t.dbName}.${t.tableName}`;
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText: label,
detail: `Table (${t.dbName})`,
range,
sortText: sortGroups.tableOther + t.tableName,
};
}
// 当前库:检查是否有跨 schema 同名表
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || [];
const hasDuplicate = schemas.length > 1;
// 同名表存在于多个 schema → 显示 schema.table否则只显示纯表名
const label = hasDuplicate ? t.tableName : pureTable;
const insertText = hasDuplicate ? t.tableName : pureTable;
const schemaInfo = parsed.schema ? ` (${parsed.schema})` : '';
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText,
detail: `Table (${t.dbName})`,
detail: `Table${schemaInfo}`,
range,
sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName,
sortText: sortGroups.tableCurrent + pureTable,
};
});
@@ -823,7 +901,92 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
return { suggestions };
}
});
// 注册 / 斜杠命令 AI 快捷补全
const slashCmdDefs = [
{ cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' },
{ cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL' },
{ cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n{SQL}\n```', useSelection: true },
{ cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n{SQL}\n```', useSelection: true },
{ cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' },
{ cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' },
{ cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' },
{ cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' },
];
// 全局变量存储命令定义,供 onDidChangeModelContent 使用
(window as any).__gonaviSlashCmdDefs = slashCmdDefs;
monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['/'],
provideCompletionItems: (model: any, position: any) => {
const lineContent = model.getLineContent(position.lineNumber);
const textBefore = lineContent.substring(0, position.column - 1).trimStart();
if (!textBefore.startsWith('/')) {
return { suggestions: [] };
}
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: position.column - textBefore.length,
endColumn: position.column,
};
return {
suggestions: slashCmdDefs.map((c, i) => ({
label: `${c.cmd} ${c.label}`,
kind: monaco.languages.CompletionItemKind.Event,
detail: c.desc,
insertText: `__AI_${c.cmd.slice(1).toUpperCase()}__`,
range,
sortText: String(i).padStart(2, '0'),
})),
};
},
});
} // end sqlCompletionRegistered guard
// 每个编辑器实例都注册内容变化监听(检测斜杠命令标记)
let _handlingSlash = false;
editor.onDidChangeModelContent(() => {
if (_handlingSlash) return;
const model = editor.getModel();
if (!model) return;
const content = model.getValue();
const markerMatch = content.match(/__AI_(\w+)__/);
if (!markerMatch) return;
const cmdKey = markerMatch[1].toLowerCase();
const defs = (window as any).__gonaviSlashCmdDefs || [];
const cmdDef = defs.find((c: any) => c.cmd === `/${cmdKey}`);
if (!cmdDef) return;
// 清除标记文本(带递归保护)
_handlingSlash = true;
const fullText = model.getValue();
const newText = fullText.replace(markerMatch[0], '').replace(/^\s*\n/, '');
model.setValue(newText);
_handlingSlash = false;
// 组装 prompt
const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current);
const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDbRef.current || '默认'}"】\n` : '';
let finalPrompt = ctxText + cmdDef.prompt;
if (cmdDef.useSelection) {
const sel = editor.getSelection();
const selText = sel ? model.getValueInRange(sel) : '';
finalPrompt = finalPrompt.replace('{SQL}', selText || getCurrentQuery());
}
// 打开 AI 面板并注入 prompt
const store = useStore.getState();
if (!store.aiPanelVisible) {
store.setAIPanelVisible(true);
}
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: finalPrompt } }));
}, store.aiPanelVisible ? 0 : 350);
});
};
const handleFormat = () => {
@@ -835,6 +998,28 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
};
const handleAIAction = (action: 'generate' | 'explain' | 'optimize' | 'schema') => {
const editor = editorRef.current;
const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || '';
const fullSQL = getCurrentQuery();
const conn = connections.find(c => c.id === currentConnectionId);
const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDb || '默认'}"】\n` : '';
const prompts: Record<string, string> = {
generate: `${ctxText}请根据当前数据库表结构生成查询语句:`,
explain: `${ctxText}请解释以下 SQL 语句的执行逻辑:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``,
optimize: `${ctxText}请分析以下 SQL 语句的性能并给出优化建议:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``,
schema: `${ctxText}请针对当前数据库的表结构进行系统分析,并给出性能和设计上的优化建议。`,
};
const store = useStore.getState();
if (!store.aiPanelVisible) {
store.setAIPanelVisible(true);
}
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: prompts[action] } }));
};
const formatSettingsMenu: MenuProps['items'] = [
{
key: 'upper',
@@ -1371,7 +1556,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
} catch {
queryId = 'reload-' + Date.now();
}
const res = await DBQueryMulti(config as any, currentDb, sql, queryId);
const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, sql, queryId);
if (!res?.success) {
message.error('刷新失败: ' + (res?.message || '未知错误'));
return;
@@ -1430,9 +1615,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// 清除旧查询ID
clearQueryId();
}
const runSeq = ++runSeqRef.current;
setLoading(true);
const runStartTime = Date.now();
const runSeq = ++runSeqRef.current;
setLoading(true);
setExecutionError('');
const runStartTime = Date.now();
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) {
message.error("Connection not found");
@@ -1446,18 +1632,19 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
return;
}
const config = {
...conn.config,
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: "" }
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
timeout: Math.max(Number(conn.config.timeout) || 30, 120),
};
try {
const rawSQL = getSelectedSQL() || currentQuery;
const dbType = String((config as any).type || 'mysql');
const dbType = String((buildRpcConnectionConfig(config) as any).type || 'mysql');
const normalizedDbType = dbType.trim().toLowerCase();
const normalizedRawSQL = String(rawSQL || '').replace(//g, ';');
@@ -1489,7 +1676,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
if (shellConvert.recognized) {
if (shellConvert.error) {
const prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
message.error(prefix + shellConvert.error);
setExecutionError(prefix + shellConvert.error);
setResultSets([]);
setActiveResultKey('');
return;
@@ -1508,7 +1695,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
setQueryId(queryId);
const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId);
const res = await DBQueryWithCancel(buildRpcConnectionConfig(config) as any, currentDb, executedSql, queryId);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query-${idx + 1}`,
@@ -1522,7 +1709,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
});
if (!res.success) {
const prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
message.error(prefix + res.message);
setExecutionError(prefix + res.message);
setResultSets([]);
setActiveResultKey('');
return;
@@ -1609,7 +1796,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
setQueryId(queryId);
const res = await DBQueryMulti(config as any, currentDb, fullSQL, queryId);
const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, fullSQL, queryId);
const duration = Date.now() - startTime;
addSqlLog({
@@ -1644,7 +1831,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
return;
}
message.error(res.message);
setExecutionError(res.message);
setResultSets([]);
setActiveResultKey('');
return;
@@ -1702,8 +1889,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
let simpleTableName: string | undefined = undefined;
if (rawStatement) {
// 支持多行 SQLSELECT * FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等
const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+(?:[\w`"]+\.)?[`"]?(\w+)[`"]?\s*(?:$|[\s;])/im);
// 支持多行 SQLSELECT [cols] FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等
// JOIN 查询表名歧义,不提取
const hasJoin = /\bJOIN\b/i.test(rawStatement);
const tableMatch = !hasJoin
? rawStatement.match(/^\s*SELECT\s+.+?\s+FROM\s+(?:[\w`"\[\].]+\.)?[`"\[]?(\w+)[`"\]]?\s*(?:$|[\s;])/im)
: null;
if (tableMatch) {
simpleTableName = tableMatch[1];
if (!forceReadOnlyResult) {
@@ -1731,7 +1922,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
setActiveResultKey(nextResultSets[0]?.key || '');
pendingPk.forEach(({ resultKey, tableName }) => {
DBGetColumns(config as any, currentDb, tableName)
DBGetColumns(buildRpcConnectionConfig(config) as any, currentDb, tableName)
.then((resCols: any) => {
if (runSeqRef.current !== runSeq) return;
if (!resCols?.success) {
@@ -1882,6 +2073,89 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
};
}, [activeTabId, tab.id, handleRun]);
// 监听由 TabManager 分发的专用注入事件
useEffect(() => {
const handleInsertSql = (e: any) => {
if (e.detail?.tabId !== tab.id || !e.detail?.sql) return;
const { sql: sqlText, connectionId, dbName } = e.detail;
// 同步更新 ref防止异步 fetchDbs 竞态覆盖正确的 dbName
if (connectionId && connectionId !== currentConnectionId) {
if (dbName) {
currentDbRef.current = dbName;
setCurrentDb(dbName);
}
setCurrentConnectionId(connectionId);
} else if (dbName && dbName !== currentDb) {
currentDbRef.current = dbName;
setCurrentDb(dbName);
}
const editor = editorRef.current;
const monaco = monacoRef.current;
if (editor && monaco) {
const model = editor.getModel();
const existingContent = editor.getValue?.() || '';
// runImmediately 模式下,如果编辑器内容已是待注入的 SQLTabManager 创建时已传入),
// 跳过追加,直接选中全部内容并执行
if (e.detail.runImmediately && existingContent.trim() === sqlText.trim()) {
if (model) {
const lineCount = model.getLineCount();
const maxCol = model.getLineMaxColumn(lineCount);
editor.setSelection(new monaco.Range(1, 1, lineCount, maxCol));
editor.focus();
setTimeout(() => handleRun(), 500);
}
} else {
let position = editor.getPosition();
if (!position && model) {
const lineCount = model.getLineCount();
const maxCol = model.getLineMaxColumn(lineCount);
position = new monaco.Position(lineCount, maxCol);
}
if (position) {
const mText = (sqlText.endsWith('\n') ? sqlText : sqlText + '\n');
const startRange = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column);
editor.executeEdits('ai-insert', [{
range: startRange,
text: (position.column > 1 ? '\n' : '') + mText,
forceMoveMarkers: true
}]);
// 定位并滚动到可见区域
const targetLine = position.lineNumber + (position.column > 1 ? 1 : 0);
editor.revealLineInCenterIfOutsideViewport(targetLine);
editor.setPosition({ lineNumber: targetLine + mText.split('\n').length - 1, column: 1 });
editor.focus();
if (!e.detail.runImmediately) {
message.success('代码已在当前光标处成功插入');
}
if (e.detail.runImmediately) {
const endPosition = editor.getPosition();
editor.setSelection(new monaco.Range(
targetLine, 1,
endPosition.lineNumber, endPosition.column
));
// 🔧 延迟 500ms 等待连接/数据库切换的 setState 生效后再执行
setTimeout(() => handleRun(), 500);
}
}
}
} else {
setQuery((prev: string) => prev ? prev + '\n' + sqlText : sqlText);
message.success('代码已追加');
}
};
window.addEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener);
return () => window.removeEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener);
}, [tab.id, handleRun]);
const resolveDefaultQueryName = () => {
const rawTitle = String(tab.title || '').trim();
if (!rawTitle || rawTitle.startsWith('新建查询')) {
@@ -2067,6 +2341,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
<Button icon={<SettingOutlined />} />
</Dropdown>
</Button.Group>
<Dropdown menu={{ items: [
{ key: 'ai-generate', label: '生成 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('generate') },
{ key: 'ai-explain', label: '解释 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('explain') },
{ key: 'ai-optimize', label: '优化 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('optimize') },
{ type: 'divider' as const },
{ key: 'ai-schema', label: 'Schema 分析', icon: <RobotOutlined />, onClick: () => handleAIAction('schema') },
] }} placement="bottomRight">
<Button icon={<RobotOutlined />} style={{ color: '#818cf8' }}>AI</Button>
</Dropdown>
</div>
<div style={{ height: editorHeight, minHeight: '100px' }}>
@@ -2168,6 +2452,35 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
})()
}))}
/>
) : executionError ? (
<div style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
<div style={{ color: '#ff4d4f', fontWeight: 'bold', fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<CloseOutlined />
<span></span>
</div>
<div className="custom-scrollbar" style={{ padding: 16, background: darkMode ? '#2d1a1a' : '#fff2f0', border: `1px solid ${darkMode ? '#5c2020' : '#ffccc7'}`, borderRadius: 6, color: darkMode ? '#ffa39e' : '#cf1322', fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: '40vh', overflow: 'auto' }}>
{executionError}
</div>
<div style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<RobotOutlined />}
style={{ background: '#818cf8', borderColor: '#818cf8', boxShadow: '0 2px 0 rgba(129, 140, 248, 0.2)' }}
onClick={() => {
const errSql = getCurrentQuery();
const prompt = `我在执行以下 SQL 时遇到了错误:\n\`\`\`sql\n${errSql}\n\`\`\`\n\n数据库报错信息如下\n\`\`\`text\n${executionError}\n\`\`\`\n\n请帮我分析错误原因并给出修改建议。`;
const store = useStore.getState();
const wasClosed = !store.aiPanelVisible;
if (wasClosed) store.setAIPanelVisible(true);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
}, wasClosed ? 350 : 0);
}}
>
AI
</Button>
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0 }} />
)}

View File

@@ -1,7 +1,8 @@
import React, { useState, useCallback, useRef } from 'react';
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 {
@@ -14,6 +15,67 @@ interface CommandResult {
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 }) => {
@@ -23,6 +85,13 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
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(() => {
@@ -37,77 +106,173 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
};
}, [connection, redisDB]);
const handleEditorMount: OnMount = (editor) => {
const handleEditorMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
// Add keyboard shortcut for execute
editor.addCommand(
// Ctrl/Cmd + Enter
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
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;
const cmdToExecute = command.trim();
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('请输入命令');
message.warning('请输入要执行的命令');
return;
}
// Support multiple commands separated by newlines
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
// 2. 智能解析多行命令
const commands = parseRedisScriptBlocks(cmdToExecute);
if (commands.length === 0) return;
setLoading(true);
const newResults: CommandResult[] = [];
for (const cmd of commands) {
const trimmedCmd = cmd.trim();
if (!trimmedCmd) continue;
const start = Date.now();
try {
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
const res = await (window as any).go.app.App.RedisExecuteCommand(buildRpcConnectionConfig(config), cmd);
newResults.push({
command: trimmedCmd,
command: cmd,
result: res.success ? res.data : null,
error: res.success ? undefined : res.message,
timestamp: Date.now()
timestamp: Date.now(),
durationMs: Date.now() - start
});
} catch (e: any) {
newResults.push({
command: trimmedCmd,
command: cmd,
result: null,
error: e?.message || String(e),
timestamp: Date.now()
timestamp: Date.now(),
durationMs: Date.now() - start
});
}
}
setResults(prev => [...newResults, ...prev]);
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): string => {
const formatResult = (result: any): React.ReactNode => {
if (result === null || result === undefined) {
return '(nil)';
return <span style={{ color: '#569cd6' }}>(nil)</span>;
}
if (typeof result === 'string') {
return `"${result}"`;
// 尝试美化 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 `(integer) ${result}`;
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
}
if (Array.isArray(result)) {
if (result.length === 0) {
return '(empty array)';
}
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
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);
@@ -115,18 +280,56 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
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 style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Command Input */}
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<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: 500 }}>Redis </span>
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
<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
@@ -135,68 +338,89 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
onClick={handleExecute}
loading={loading}
>
(Ctrl+Enter)
(Cmd+Enter)
</Button>
<Button icon={<ClearOutlined />} onClick={handleClear}></Button>
</Space>
</div>
<Editor
height="150px"
defaultLanguage="plaintext"
value={command}
onChange={(value) => setCommand(value || '')}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
fontSize: 14,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2
}}
/>
<div 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>
{/* Results */}
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
{results.length === 0 ? (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
Redis Ctrl+Enter
<br />
<span style={{ fontSize: 12 }}></span>
</div>
) : (
results.map((item, index) => (
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
<div style={{ color: '#569cd6', marginBottom: 4 }}>
&gt; {item.command}
{/* 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>
{item.error ? (
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
(error) {item.error}
</div>
) : (
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
{formatResult(item.result)}
</div>
)}
</div>
))
)}
</div>
{/* Common Commands Help */}
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
:
<span style={{ marginLeft: 8 }}>
<code>KEYS *</code> |
<code style={{ marginLeft: 8 }}>GET key</code> |
<code style={{ marginLeft: 8 }}>SET key value</code> |
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
<code style={{ marginLeft: 8 }}>INFO</code> |
<code style={{ marginLeft: 8 }}>DBSIZE</code>
</span>
) : (
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>
);

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;

View File

@@ -7,6 +7,7 @@ import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
import Editor from '@monaco-editor/react';
import type { DataNode } from 'antd/es/tree';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
applyRenamedRedisKeyState,
applyTreeNodeCheck,
@@ -429,7 +430,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
setLoading(true);
try {
const res = await (window as any).go.app.App.RedisScanKeys(config, normalizedPattern, fromCursor, effectiveTargetCount);
const res = await (window as any).go.app.App.RedisScanKeys(buildRpcConnectionConfig(config), normalizedPattern, fromCursor, effectiveTargetCount);
if (requestId !== latestLoadRequestIdRef.current) {
return;
}
@@ -508,7 +509,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
setValueLoading(true);
try {
const res = await (window as any).go.app.App.RedisGetValue(config, key);
const res = await (window as any).go.app.App.RedisGetValue(buildRpcConnectionConfig(config), key);
if (res.success) {
setKeyValue(res.data);
setSelectedKey(key);
@@ -539,7 +540,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisDeleteKeys(config, keysToDelete);
const res = await (window as any).go.app.App.RedisDeleteKeys(buildRpcConnectionConfig(config), keysToDelete);
if (res.success) {
message.success(`已删除 ${res.data.deleted} 个 Key`);
setKeys(prev => prev.filter(k => !keysToDelete.includes(k.key)));
@@ -567,7 +568,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
try {
const values = await ttlForm.validateFields();
const res = await (window as any).go.app.App.RedisSetTTL(config, selectedKey, values.ttl);
const res = await (window as any).go.app.App.RedisSetTTL(buildRpcConnectionConfig(config), selectedKey, values.ttl);
if (res.success) {
message.success('TTL 设置成功');
setTtlModalOpen(false);
@@ -586,7 +587,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
if (!config || !selectedKey) return;
try {
const res = await (window as any).go.app.App.RedisSetString(config, selectedKey, editValue, keyValue?.ttl || -1);
const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), selectedKey, editValue, keyValue?.ttl || -1);
if (res.success) {
message.success('保存成功');
setEditModalOpen(false);
@@ -605,7 +606,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
try {
const values = await newKeyForm.validateFields();
const res = await (window as any).go.app.App.RedisSetString(config, values.key, values.value, values.ttl || -1);
const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), values.key, values.value, values.ttl || -1);
if (res.success) {
message.success('创建成功');
setNewKeyModalOpen(false);
@@ -642,7 +643,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return;
}
const existsRes = await (window as any).go.app.App.RedisKeyExists(config, nextKey);
const existsRes = await (window as any).go.app.App.RedisKeyExists(buildRpcConnectionConfig(config), nextKey);
if (!existsRes?.success) {
message.error('校验目标 Key 失败: ' + (existsRes?.message || '未知错误'));
return;
@@ -652,7 +653,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return;
}
const res = await (window as any).go.app.App.RedisRenameKey(config, renameTargetKey, nextKey);
const res = await (window as any).go.app.App.RedisRenameKey(buildRpcConnectionConfig(config), renameTargetKey, nextKey);
if (res.success) {
const nextState = applyRenamedRedisKeyState(
{
@@ -1177,7 +1178,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisSetHashField(config, selectedKey, field, newValue);
const res = await (window as any).go.app.App.RedisSetHashField(buildRpcConnectionConfig(config), selectedKey, field, newValue);
if (res.success) {
message.success('修改成功');
loadKeyValue(selectedKey);
@@ -1193,7 +1194,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisDeleteHashField(config, selectedKey, field);
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, field);
if (res.success) {
message.success('删除成功');
loadKeyValue(selectedKey);
@@ -1338,7 +1339,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisListSet(config, selectedKey, index, newValue);
const res = await (window as any).go.app.App.RedisListSet(buildRpcConnectionConfig(config), selectedKey, index, newValue);
if (res.success) {
message.success('修改成功');
loadKeyValue(selectedKey);
@@ -1354,7 +1355,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisListPush(config, selectedKey, { values: [value], position });
const res = await (window as any).go.app.App.RedisListPush(buildRpcConnectionConfig(config), selectedKey, { values: [value], position });
if (res.success) {
message.success('添加成功');
loadKeyValue(selectedKey);
@@ -1508,7 +1509,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisSetAdd(config, selectedKey, [member]);
const res = await (window as any).go.app.App.RedisSetAdd(buildRpcConnectionConfig(config), selectedKey, [member]);
if (res.success) {
message.success('添加成功');
loadKeyValue(selectedKey);
@@ -1524,7 +1525,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisSetRemove(config, selectedKey, [member]);
const res = await (window as any).go.app.App.RedisSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
if (res.success) {
message.success('删除成功');
loadKeyValue(selectedKey);
@@ -1645,7 +1646,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisZSetAdd(config, selectedKey, [{ member, score }]);
const res = await (window as any).go.app.App.RedisZSetAdd(buildRpcConnectionConfig(config), selectedKey, [{ member, score }]);
if (res.success) {
message.success('添加成功');
loadKeyValue(selectedKey);
@@ -1661,7 +1662,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisZSetRemove(config, selectedKey, [member]);
const res = await (window as any).go.app.App.RedisZSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
if (res.success) {
message.success('删除成功');
loadKeyValue(selectedKey);
@@ -1841,7 +1842,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}
try {
const res = await (window as any).go.app.App.RedisStreamAdd(config, selectedKey, fieldMap, id || '*');
const res = await (window as any).go.app.App.RedisStreamAdd(buildRpcConnectionConfig(config), selectedKey, fieldMap, id || '*');
if (res.success) {
const newID = res.data?.id ? ` (${res.data.id})` : '';
message.success(`添加成功${newID}`);
@@ -1859,7 +1860,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisStreamDelete(config, selectedKey, [id]);
const res = await (window as any).go.app.App.RedisStreamDelete(buildRpcConnectionConfig(config), selectedKey, [id]);
if (res.success) {
const deleted = Number(res.data?.deleted ?? 0);
if (deleted > 0) {

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import RedisMonitor from './RedisMonitor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
@@ -89,6 +90,7 @@ const TabManager: React.FC = () => {
const theme = useStore(state => state.theme);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const addTab = useStore(state => state.addTab);
const closeTab = useStore(state => state.closeTab);
const closeOtherTabs = useStore(state => state.closeOtherTabs);
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
@@ -134,6 +136,59 @@ const TabManager: React.FC = () => {
setDraggingTabId(null);
};
React.useEffect(() => {
const handleGlobalInsertSql = (e: any) => {
const { sql, runImmediately, connectionId: eventConnId, dbName: eventDbName } = e.detail;
if (!sql) return;
const activeTab = tabs.find(t => t.id === activeTabId);
// 🔧 runImmediately点击"执行")始终新建独立 tab避免追加到已有 tab 导致 SQL 重复
if (runImmediately) {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: '新建查询',
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: newTabId, sql, runImmediately: true, connectionId: resolvedConnId, dbName: resolvedDbName }
}));
}, 300);
return;
}
// 插入模式:追加到已有 tab 或新建 tab
if (activeTab && activeTab.type === 'query') {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: activeTab.id, sql, runImmediately: false, connectionId: eventConnId, dbName: eventDbName }
}));
} else {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: '新建查询',
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
}
};
window.addEventListener('gonavi:insert-sql', handleGlobalInsertSql);
return () => window.removeEventListener('gonavi:insert-sql', handleGlobalInsertSql);
}, [tabs, activeTabId, addTab, setActiveTab, connections]);
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
@@ -145,17 +200,20 @@ const TabManager: React.FC = () => {
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const tabIsActive = tab.id === activeTabId;
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
} else if (tab.type === 'table') {
content = <DataViewer tab={tab} />;
content = <DataViewer tab={tab} isActive={tabIsActive} />;
} else if (tab.type === 'design') {
content = <TableDesigner tab={tab} />;
} else if (tab.type === 'redis-keys') {
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-command') {
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-monitor') {
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'trigger') {
content = <TriggerViewer tab={tab} />;
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
@@ -202,7 +260,7 @@ const TabManager: React.FC = () => {
key: tab.id,
children: content,
};
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>

View File

@@ -8,6 +8,9 @@ import Editor, { loader } from '@monaco-editor/react';
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
interface EditableColumn extends ColumnDefinition {
_key: string;
@@ -48,6 +51,13 @@ interface ForeignKeyFormState {
refColumnNames: string[];
}
interface SchemaExecutionResult {
ok: boolean;
message?: string;
failedStatementIndex?: number;
statementCount: number;
}
// 通用兜底类型列表
const COMMON_TYPES = [
{ value: 'int' },
@@ -209,14 +219,6 @@ const COMMON_DEFAULTS = [
{ value: "''" },
];
const MYSQL_INDEX_TYPE_OPTIONS = [
{ label: '默认', value: 'DEFAULT' },
{ label: 'BTREE', value: 'BTREE' },
{ label: 'HASH', value: 'HASH' },
{ label: 'FULLTEXT', value: 'FULLTEXT' },
{ label: 'SPATIAL', value: 'SPATIAL' },
{ label: 'RTREE', value: 'RTREE' },
];
const PGLIKE_INDEX_TYPE_OPTIONS = [
{ label: '默认', value: 'DEFAULT' },
@@ -751,14 +753,14 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
};
const promises: Promise<any>[] = [
DBGetColumns(config as any, tab.dbName || '', tab.tableName || ''),
DBGetIndexes(config as any, tab.dbName || '', tab.tableName || ''),
DBGetForeignKeys(config as any, tab.dbName || '', tab.tableName || ''),
DBGetTriggers(config as any, tab.dbName || '', tab.tableName || '')
DBGetColumns(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''),
DBGetIndexes(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''),
DBGetForeignKeys(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''),
DBGetTriggers(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || '')
];
if (!isNewTable) {
promises.push(DBShowCreateTable(config as any, tab.dbName || '', tab.tableName || ''));
promises.push(DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''));
}
const results = await Promise.all(promises);
@@ -848,7 +850,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
if (!type) return '';
if (type === 'custom') {
return inferDialectFromCustomDriver(String((conn?.config as any)?.driver || ''));
return inferDialectFromCustomDriver(String(conn?.config?.driver || ''));
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
@@ -993,7 +995,7 @@ ${selectedTrigger.statement}`;
const dropSql = buildDropTriggerSql(selectedTrigger.name);
try {
const res = await DBQuery(config as any, tab.dbName || '', dropSql);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
if (res.success) {
message.success('触发器删除成功');
setSelectedTrigger(null);
@@ -1030,7 +1032,7 @@ ${selectedTrigger.statement}`;
// 如果是编辑模式,先删除旧触发器
if (triggerEditMode === 'edit' && selectedTrigger) {
const dropSql = buildDropTriggerSql(selectedTrigger.name);
const dropRes = await DBQuery(config as any, tab.dbName || '', dropSql);
const dropRes = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
if (!dropRes.success) {
message.error('删除旧触发器失败: ' + dropRes.message);
setTriggerExecuting(false);
@@ -1039,7 +1041,7 @@ ${selectedTrigger.statement}`;
}
// 执行创建语句
const res = await DBQuery(config as any, tab.dbName || '', triggerEditSql);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', triggerEditSql);
if (res.success) {
message.success(triggerEditMode === 'create' ? '触发器创建成功' : '触发器修改成功');
setIsTriggerEditModalOpen(false);
@@ -1433,14 +1435,37 @@ ${selectedTrigger.statement}`;
];
};
const getIndexTypeOptions = () => {
const getIndexTypeOptions = (kind?: IndexKind) => {
const dbType = getDbType();
if (isMysqlLikeDialect(dbType)) return MYSQL_INDEX_TYPE_OPTIONS;
if (isPgLikeDialect(dbType)) return PGLIKE_INDEX_TYPE_OPTIONS;
const k = kind || 'NORMAL';
if (isMysqlLikeDialect(dbType)) {
// MySQL InnoDB: 所有索引均为固定方法类型
if (k === 'FULLTEXT') return [{ label: 'FULLTEXT', value: 'FULLTEXT' }];
if (k === 'SPATIAL') return [{ label: 'RTREE', value: 'RTREE' }];
return [{ label: 'BTREE', value: 'BTREE' }];
}
if (isPgLikeDialect(dbType)) {
if (k === 'PRIMARY' || k === 'UNIQUE') return [{ label: 'BTREE', value: 'BTREE' }];
return PGLIKE_INDEX_TYPE_OPTIONS;
}
if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS;
return [{ label: '默认', value: 'DEFAULT' }];
};
/** 根据索引类别返回固定的索引方法类型,可选类别返回 undefined */
const getFixedIndexType = (kind: IndexKind): string | undefined => {
const dbType = getDbType();
if (isMysqlLikeDialect(dbType)) {
if (kind === 'PRIMARY') return 'BTREE';
if (kind === 'FULLTEXT') return 'FULLTEXT';
if (kind === 'SPATIAL') return 'RTREE';
}
if (isPgLikeDialect(dbType)) {
if (kind === 'PRIMARY') return 'BTREE';
}
return undefined;
};
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``;
const colDefs = targetColumns.map(curr => {
@@ -1499,7 +1524,7 @@ ${selectedTrigger.statement}`;
const sql = buildCreateTableSql(copyTableName.trim(), selectedColumns, copyCharset, copyCollation);
setCopyExecuting(true);
try {
const res = await DBQuery(config as any, tab.dbName || '', sql);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
if (res.success) {
message.success(`已将 ${selectedColumns.length} 个字段复制到新表 ${copyTableName.trim()}`);
setIsCopyColumnsModalOpen(false);
@@ -1511,11 +1536,10 @@ ${selectedTrigger.statement}`;
}
};
const executeSchemaSql = async (sql: string, successMessage: string): Promise<boolean> => {
const executeSchemaStatements = async (sqlText: string): Promise<SchemaExecutionResult> => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error('未找到连接');
return false;
return { ok: false, message: '未找到连接', statementCount: 0 };
}
const config = {
...conn.config,
@@ -1525,20 +1549,68 @@ ${selectedTrigger.statement}`;
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const statements = sqlText.split(/;\s*\n/).map(s => s.trim()).filter(Boolean);
for (let i = 0; i < statements.length; i++) {
let stmt = statements[i];
if (!stmt.endsWith(';')) stmt += ';';
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', stmt);
if (!res.success) {
const prefix = statements.length > 1 ? `${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
return {
ok: false,
message: prefix + res.message,
failedStatementIndex: i,
statementCount: statements.length,
};
}
}
return { ok: true, statementCount: statements.length };
};
const buildIndexFormFromRow = (row: IndexDisplayRow): IndexFormState => {
return normalizeIndexFormFromRow(
row as IndexDisplaySnapshot,
getIndexKindOptions().map(item => item.value as IndexKind),
);
};
const executeIndexEditSql = async (dropSql: string, addSql: string, previousIndex: IndexDisplayRow): Promise<boolean> => {
const result = await executeSchemaStatements(`${dropSql}\n${addSql}`);
if (result.ok) {
message.success('索引修改成功');
await fetchData();
return true;
}
const oldCreateSql = buildIndexCreateSql(buildIndexFormFromRow(previousIndex));
if (!oldCreateSql) {
message.error((result.message || '执行失败') + ';且无法自动恢复原索引,请尽快检查');
await fetchData();
return false;
}
if (!shouldRestoreOriginalIndex(result)) {
message.error(result.message || '执行失败');
return false;
}
const restoreResult = await executeSchemaStatements(oldCreateSql);
if (restoreResult.ok) {
message.error((result.message || '执行失败') + ';已自动恢复原索引');
} else {
message.error((result.message || '执行失败') + `;恢复原索引失败: ${restoreResult.message || '未知错误'}`);
}
await fetchData();
return false;
};
const executeSchemaSql = async (sql: string, successMessage: string): Promise<boolean> => {
try {
// 多条 DDL 语句(如 DROP INDEX + CREATE INDEX需要逐条执行
// 因为 Go MySQL 驱动默认不支持多语句 Exec。
const statements = sql.split(/;\s*\n/).map(s => s.trim()).filter(Boolean);
for (let i = 0; i < statements.length; i++) {
let stmt = statements[i];
if (!stmt.endsWith(';')) stmt += ';';
const res = await DBQuery(config as any, tab.dbName || '', stmt);
if (!res.success) {
const prefix = statements.length > 1 ? `${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
message.error(prefix + res.message);
if (i > 0) await fetchData();
return false;
}
const result = await executeSchemaStatements(sql);
if (!result.ok) {
message.error(result.message || '执行失败');
if ((result.failedStatementIndex ?? 0) > 0) await fetchData();
return false;
}
message.success(successMessage);
await fetchData();
@@ -1633,32 +1705,7 @@ END;`;
return;
}
setIndexModalMode('edit');
const selectedName = String(selectedIndex.name || '').trim();
const selectedNameUpper = selectedName.toUpperCase();
const selectedTypeUpper = String(selectedIndex.indexType || '').trim().toUpperCase();
let kind: IndexKind = 'NORMAL';
if (selectedNameUpper === 'PRIMARY') {
kind = 'PRIMARY';
} else if (selectedTypeUpper === 'FULLTEXT') {
kind = 'FULLTEXT';
} else if (selectedTypeUpper === 'SPATIAL') {
kind = 'SPATIAL';
} else if (selectedIndex.nonUnique === 0) {
kind = 'UNIQUE';
}
const supportedKinds = new Set(getIndexKindOptions().map(item => item.value));
if (!supportedKinds.has(kind)) {
kind = selectedIndex.nonUnique === 0 ? 'UNIQUE' : 'NORMAL';
}
setIndexForm({
name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName,
columnNames: [...selectedIndex.columnNames],
kind,
indexType: kind === 'NORMAL' || kind === 'UNIQUE'
? (selectedTypeUpper || 'DEFAULT')
: 'DEFAULT',
});
setIndexForm(buildIndexFormFromRow(selectedIndex));
setIsIndexModalOpen(true);
};
@@ -1817,13 +1864,32 @@ END;`;
let sql = addSql;
if (indexModalMode === 'edit' && selectedIndex) {
const previousForm = buildIndexFormFromRow(selectedIndex);
const nextForm: IndexFormState = {
name: indexForm.kind === 'PRIMARY' ? 'PRIMARY' : nextName,
columnNames: [...indexForm.columnNames],
kind: indexForm.kind,
indexType: indexForm.kind === 'NORMAL' || indexForm.kind === 'UNIQUE'
? (String(indexForm.indexType || '').trim().toUpperCase() || 'DEFAULT')
: 'DEFAULT',
};
if (!hasIndexFormChanged(previousForm, nextForm)) {
setIndexSaving(false);
message.info('没有检测到索引变更');
return;
}
const dropSql = buildIndexDropSql(selectedIndex.name);
if (!dropSql) {
setIndexSaving(false);
message.warning('当前数据库暂不支持删除该索引');
return;
}
sql = `${dropSql}\n${addSql}`;
const ok = await executeIndexEditSql(dropSql, addSql, selectedIndex);
setIndexSaving(false);
if (ok) {
setIsIndexModalOpen(false);
}
return;
}
const ok = await executeSchemaSql(sql, indexModalMode === 'create' ? '索引新增成功' : '索引修改成功');
@@ -2053,105 +2119,44 @@ END;`;
return;
}
const tableName = `\`${isNewTable ? newTableName : tab.tableName}\``;
if (isNewTable) {
// CREATE TABLE
const sql = buildCreateTableSql(isNewTable ? newTableName : tab.tableName || '', columns, charset, collation);
setPreviewSql(sql);
setIsPreviewOpen(true);
} else {
// ALTER TABLE (Existing logic)
const alters: string[] = [];
originalColumns.forEach(orig => {
if (!columns.find(c => c._key === orig._key)) {
alters.push(`DROP COLUMN \`${orig.name}\``);
}
const tableInfo = resolveTableInfo();
const sql = buildAlterTablePreviewSql({
dbType: tableInfo.dbType,
tableName: tableInfo.qualifiedName,
originalColumns,
columns,
});
columns.forEach((curr, index) => {
const orig = originalColumns.find(c => c._key === curr._key);
const prevCol = index > 0 ? columns[index - 1] : null;
const positionSql = prevCol ? `AFTER \`${prevCol.name}\`` : 'FIRST';
let extra = curr.extra || "";
if (curr.isAutoIncrement) {
if (!extra.toLowerCase().includes('auto_increment')) extra += " AUTO_INCREMENT";
} else {
extra = extra.replace(/auto_increment/gi, "").trim();
}
const colDef = `\`${curr.name}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${curr.default}'` : ''} ${extra} COMMENT '${curr.comment}'`;
if (!orig) {
alters.push(`ADD COLUMN ${colDef} ${positionSql}`);
} else {
const origIndex = originalColumns.findIndex(c => c._key === curr._key);
const origPrevCol = origIndex > 0 ? originalColumns[origIndex - 1] : null;
let positionChanged = false;
if (index === 0 && origIndex !== 0) positionChanged = true;
if (index > 0 && (!origPrevCol || origPrevCol._key !== prevCol?._key)) positionChanged = true;
const isNameChanged = orig.name !== curr.name;
const isTypeChanged = orig.type !== curr.type;
const isNullableChanged = orig.nullable !== curr.nullable;
const isDefaultChanged = orig.default !== curr.default;
const isCommentChanged = orig.comment !== curr.comment;
const isAIChanged = orig.isAutoIncrement !== curr.isAutoIncrement;
if (isNameChanged || isTypeChanged || isNullableChanged || isDefaultChanged || isCommentChanged || positionChanged || isAIChanged) {
if (isNameChanged) {
alters.push(`CHANGE COLUMN \`${orig.name}\` ${colDef} ${positionSql}`);
} else {
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`);
}
}
}
});
const origPKKeys = originalColumns.filter(c => c.key === 'PRI').map(c => c._key);
const newPKKeys = columns.filter(c => c.key === 'PRI').map(c => c._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every(k => newPKKeys.includes(k));
if (keysChanged) {
if (origPKKeys.length > 0) alters.push(`DROP PRIMARY KEY`);
if (newPKKeys.length > 0) {
const pkNames = columns.filter(c => c.key === 'PRI').map(c => `\`${c.name}\``).join(', ');
alters.push(`ADD PRIMARY KEY (${pkNames})`);
}
}
if (alters.length === 0) {
if (!sql.trim()) {
message.info("没有检测到变更");
return;
}
const sql = `ALTER TABLE ${tableName}\n` + alters.join(",\n");
setPreviewSql(sql);
setIsPreviewOpen(true);
}
};
const handleExecuteSave = async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) 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 DBQuery(config as any, tab.dbName || '', previewSql);
if (res.success) {
message.success(isNewTable ? "表创建成功!" : "表结构修改成功!");
setIsPreviewOpen(false);
if (!isNewTable) {
const result = await executeSchemaStatements(previewSql);
if (!result.ok) {
message.error(result.message || "执行失败");
return;
}
message.success(isNewTable ? "表创建成功!" : "表结构修改成功!");
setIsPreviewOpen(false);
if (!isNewTable) {
fetchData();
} else {
// TODO: Close tab or reload sidebar?
// Ideally, refresh sidebar node.
}
} else {
message.error("执行失败: " + res.message);
}
};
};
// Merge columns with resize handler
const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({
@@ -2270,12 +2275,16 @@ END;`;
const allIndexKeys = groupedIndexes.map(idx => idx.key);
const isAllSelected = allIndexKeys.length > 0 && selectedIndexKeys.length === allIndexKeys.length;
const isIndeterminate = selectedIndexKeys.length > 0 && selectedIndexKeys.length < allIndexKeys.length;
const toggleIndexSelection = (key: string, checked?: boolean) => {
setSelectedIndexKeys(prev => getNextIndexSelection(prev, key, checked));
};
const selectColumn = {
title: () => (
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
setSelectedIndexKeys(e.target.checked ? allIndexKeys : []);
}}
@@ -2286,18 +2295,19 @@ END;`;
key: '_select',
width: 48,
render: (_: any, record: any) => (
<Checkbox
checked={selectedIndexKeys.includes(record.key)}
onChange={(e) => {
<span
onClick={(e) => {
e.stopPropagation();
setSelectedIndexKeys(prev =>
e.target.checked
? [...prev, record.key]
: prev.filter(k => k !== record.key)
);
toggleIndexSelection(record.key);
}}
style={{ margin: 0 }}
/>
style={{ display: 'inline-flex' }}
>
<Checkbox
checked={selectedIndexKeys.includes(record.key)}
onChange={() => undefined}
style={{ margin: 0, pointerEvents: 'none' }}
/>
</span>
),
};
@@ -2593,11 +2603,7 @@ END;`;
}}
onRow={(record) => ({
onClick: () => {
setSelectedIndexKeys(prev =>
prev.includes(record.key)
? prev.filter(k => k !== record.key)
: [...prev, record.key]
);
toggleIndexSelection(record.key);
},
style: { cursor: 'pointer' }
})}
@@ -2878,26 +2884,40 @@ END;`;
<Select
value={indexForm.kind}
options={getIndexKindOptions()}
onChange={(val: IndexKind) =>
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: val === 'NORMAL' || val === 'UNIQUE' ? (prev.indexType || 'DEFAULT') : 'DEFAULT',
}))
}
onChange={(val: IndexKind) => {
const fixedType = getFixedIndexType(val);
if (fixedType) {
// 固定类型PRIMARY/FULLTEXT/SPATIAL直接设置对应的索引方法
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: fixedType,
}));
} else {
const nextTypeOptions = getIndexTypeOptions(val);
const currentType = indexForm.indexType || 'DEFAULT';
const isCurrentTypeValid = nextTypeOptions.some(opt => opt.value === currentType);
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: isCurrentTypeValid ? currentType : 'DEFAULT',
}));
}
}}
style={{ width: 220 }}
/>
<Select
value={indexForm.indexType}
onChange={(val) => setIndexForm(prev => ({ ...prev, indexType: val }))}
options={getIndexTypeOptions()}
options={getIndexTypeOptions(indexForm.kind)}
style={{ width: 160 }}
disabled={indexForm.kind === 'PRIMARY' || indexForm.kind === 'FULLTEXT' || indexForm.kind === 'SPATIAL'}
/>
</Space>
<div style={{ color: '#888', fontSize: 12 }}>
</div>
</Space>
</Modal>

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
interface TableOverviewProps {
tab: TabData;
@@ -22,6 +24,7 @@ interface TableStatRow {
type SortField = 'name' | 'rows' | 'dataSize';
type SortOrder = 'asc' | 'desc';
type ViewMode = 'card' | 'list';
const formatSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '—';
@@ -138,6 +141,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const darkMode = theme === 'dark';
const [tables, setTables] = useState<TableStatRow[]>([]);
@@ -145,6 +149,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const [searchText, setSearchText] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
@@ -160,9 +165,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
const dialect = getMetadataDialect(connection.config.type, (connection.config as any)?.driver);
const dialect = getMetadataDialect(connection.config.type, connection.config.driver);
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
const res = await DBQuery(config as any, tab.dbName || '', sql);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(dialect, res.data));
} else {
@@ -195,6 +200,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const openTable = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `${connection.id}-${tab.dbName}-${tableName}`,
title: tableName,
@@ -203,10 +209,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
dbName: tab.dbName,
tableName,
});
}, [connection, tab.dbName, addTab]);
}, [connection, tab.dbName, addTab, setActiveContext]);
const openDesign = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: `设计表 (${tableName})`,
@@ -217,7 +224,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
initialTab: 'columns',
readOnly: false,
});
}, [connection, tab.dbName, addTab]);
}, [connection, tab.dbName, addTab, setActiveContext]);
const buildConfig = useCallback(() => {
if (!connection) return null;
@@ -234,7 +241,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const handleCopyStructure = useCallback(async (tableName: string) => {
const config = buildConfig();
if (!config) return;
const res = await DBShowCreateTable(config as any, tab.dbName || '', tableName);
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
if (res.success) {
navigator.clipboard.writeText(res.data as string);
message.success('表结构已复制到剪贴板');
@@ -247,7 +254,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const config = buildConfig();
if (!config) return;
const hide = message.loading(`正在导出 ${tableName}${format.toUpperCase()}...`, 0);
const res = await ExportTable(config as any, tab.dbName || '', tableName, format);
const res = await ExportTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, format);
hide();
if (res.success) {
message.success('导出成功');
@@ -264,7 +271,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const res = await DropTable(config as any, tab.dbName || '', tableName);
const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
if (res.success) {
message.success('表删除成功');
loadData();
@@ -275,6 +282,40 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
});
}, [buildConfig, tab.dbName, loadData]);
const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => {
const config = buildConfig();
if (!config) return;
const { label, progressLabel } = getTableDataDangerActionMeta(action);
Modal.confirm({
title: `确认${label}`,
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
okText: '继续',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
const app = (window as any).go.app.App;
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
try {
const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]);
hide();
if (res.success) {
message.success(`${progressLabel}成功`);
loadData();
} else {
message.error(`${progressLabel}失败: ${res.message}`);
return Promise.reject();
}
} catch (e: any) {
hide();
message.error(`${progressLabel}失败: ${e?.message || String(e)}`);
return Promise.reject();
}
},
});
}, [buildConfig, tab.dbName, loadData]);
const handleRenameTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
@@ -294,7 +335,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const trimmed = newName.trim();
if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); }
if (trimmed === tableName) { message.warning('新旧表名相同'); return; }
const res = await RenameTable(config as any, tab.dbName || '', tableName, trimmed);
const res = await RenameTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, trimmed);
if (res.success) {
message.success('表重命名成功');
loadData();
@@ -332,6 +373,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const totalRows = tables.reduce((s, t) => s + t.rows, 0);
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
const maxCombinedSize = sortedFiltered.reduce((max, table) => {
return Math.max(max, table.dataSize + table.indexSize);
}, 0);
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
if (loading) {
return (
@@ -363,14 +408,43 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</Dropdown>
<div style={{ display: 'flex', gap: 2, padding: 2, borderRadius: 6, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
<Tooltip title="卡片视图">
<div
onClick={() => setViewMode('card')}
style={{
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
background: viewMode === 'card' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
boxShadow: viewMode === 'card' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: viewMode === 'card' ? accentColor : textMuted,
}}
>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
<Tooltip title="列表视图">
<div
onClick={() => setViewMode('list')}
style={{
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
background: viewMode === 'list' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
boxShadow: viewMode === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: viewMode === 'list' ? accentColor : textMuted,
}}
>
<UnorderedListOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
</div>
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</div>
{/* Cards Grid */}
{/* Content Area */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : (
) : viewMode === 'card' ? (
/* ========== 卡片视图 ========== */
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
@@ -383,6 +457,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
@@ -397,7 +472,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
]},
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
@@ -447,6 +526,147 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
</Dropdown>
))}
</div>
) : (
/* ========== 行视图 ========== */
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{sortedFiltered.map(t => {
const combinedSize = t.dataSize + t.indexSize;
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)';
const rowSecondary = t.comment || (t.engine ? `${t.engine}` : '双击打开数据,右键查看更多操作');
return (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
});
}},
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
]},
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
]},
],
}}
>
<div
onDoubleClick={() => openTable(t.name)}
style={{
position: 'relative',
overflow: 'hidden',
borderRadius: 10,
border: `1px solid ${cardBorder}`,
background: cardBg,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: fillWidth,
background: fillColor,
pointerEvents: 'none',
transition: 'width 0.2s ease',
}}
/>
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '14px 16px',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0, flex: '1 1 320px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ color: textPrimary, fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.name}
</span>
</Tooltip>
{t.engine && (
<span
style={{
flexShrink: 0,
padding: '1px 6px',
borderRadius: 999,
fontSize: 11,
color: textMuted,
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
}}
>
{t.engine}
</span>
)}
</div>
<Tooltip title={rowSecondary} mouseEnterDelay={0.4}>
<div style={{ marginTop: 6, color: textSecondary, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{rowSecondary}
</div>
</Tooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap', fontSize: 12 }}>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</div>
</div>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}></div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
</div>
</div>
</div>
</div>
</div>
</Dropdown>
);
})}
</div>
)}
</div>
</div>

View File

@@ -4,6 +4,7 @@ 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 TriggerViewerProps {
tab: TabData;
@@ -100,7 +101,7 @@ LIMIT 1`];
const sql = String(query || '').trim();
if (!sql) continue;
try {
const result = await DBQuery(config as any, dbName, sql);
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
if (!result.success || !Array.isArray(result.data)) {
lastMessage = result.message || lastMessage;
continue;
@@ -126,7 +127,7 @@ LIMIT 1`];
];
for (const query of candidates) {
try {
const result = await DBQuery(config as any, dbName, query);
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
continue;
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIChatMessage } from '../../types';
interface AIChatHeaderProps {
darkMode: boolean;
mutedColor: string;
textColor: string;
overlayTheme: OverlayWorkbenchTheme;
onHistoryClick: () => void;
onClear: () => void;
onSettingsClick: () => void;
onClose: () => void;
messages?: AIChatMessage[];
sessionTitle?: string;
}
const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
const lines: string[] = [`# ${title}`, '', `> 导出时间:${new Date().toLocaleString()}`, ''];
messages.forEach(msg => {
const role = msg.role === 'user' ? '👤 You' : '🤖 GoNavi AI';
lines.push(`## ${role}`);
lines.push('');
lines.push(msg.content);
lines.push('');
lines.push('---');
lines.push('');
});
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '-')}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
darkMode, mutedColor, textColor, overlayTheme,
onHistoryClick, onClear, onSettingsClick, onClose,
messages = [], sessionTitle = '新对话'
}) => {
return (
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
<div className="ai-chat-header-left" style={{ gap: 8 }}>
<Tooltip title="历史会话">
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
</Tooltip>
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
<RobotOutlined />
</div>
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
</div>
<div className="ai-chat-header-right">
{messages.length > 0 && (
<Tooltip title="导出为 Markdown">
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
</Tooltip>
)}
<Tooltip title="新对话 (清空当前)">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="AI 设置">
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { AIChatInput } from './AIChatInput';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
vi.mock('../../store', () => ({
useStore: (selector: (state: any) => any) => selector({
aiContexts: {},
addAIContext: vi.fn(),
removeAIContext: vi.fn(),
}),
}));
vi.mock('../../../wailsjs/go/app/App', () => ({
DBGetTables: vi.fn(),
DBShowCreateTable: vi.fn(),
DBGetDatabases: vi.fn(),
}));
describe('AIChatInput notice layout', () => {
it('renders the composer notice above the input editor', () => {
const markup = renderToStaticMarkup(
<AIChatInput
input=""
setInput={() => {}}
draftImages={[]}
setDraftImages={() => {}}
sending={false}
onSend={() => {}}
onStop={() => {}}
handleKeyDown={() => {}}
activeConnName=""
activeContext={null}
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
composerNotice={{
tone: 'error',
title: '模型列表加载失败',
description: '请检查供应商入口和 API Key。',
}}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
/>
);
const noticeIndex = markup.indexOf('data-ai-chat-composer-notice="true"');
const inputIndex = markup.indexOf('data-ai-chat-composer-input="true"');
expect(noticeIndex).toBeGreaterThanOrEqual(0);
expect(inputIndex).toBeGreaterThanOrEqual(0);
expect(noticeIndex).toBeLessThan(inputIndex);
});
});

View File

@@ -0,0 +1,633 @@
import React from 'react';
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { useStore } from '../../store';
import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
interface AIChatInputProps {
input: string;
setInput: (val: string) => void;
draftImages: string[];
setDraftImages: React.Dispatch<React.SetStateAction<string[]>>;
sending: boolean;
onSend: () => void;
onStop: () => void;
handleKeyDown: (e: React.KeyboardEvent) => void;
activeConnName: string;
activeContext: any;
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
darkMode: boolean;
textColor: string;
mutedColor: string;
overlayTheme: OverlayWorkbenchTheme;
contextUsageChars?: number;
maxContextChars?: number;
}
export const AIChatInput: React.FC<AIChatInputProps> = ({
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
composerNotice,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars
}) => {
const [contextOpen, setContextOpen] = React.useState(false);
const [contextLoading, setContextLoading] = React.useState(false);
const [contextTables, setContextTables] = React.useState<{name: string}[]>([]);
const [selectedTableKeys, setSelectedTableKeys] = React.useState<string[]>([]);
const [searchText, setSearchText] = React.useState('');
const [appendingContext, setAppendingContext] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
files.forEach(file => {
if (file.type.indexOf('image') !== -1) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(file);
}
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const [dbList, setDbList] = React.useState<string[]>([]);
const [selectedDbName, setSelectedDbName] = React.useState<string>('');
const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase()));
const [contextExpanded, setContextExpanded] = React.useState(false);
const composerNoticePalette = React.useMemo(() => {
if (composerNotice?.tone === 'error') {
return darkMode
? {
background: 'rgba(255,120,117,0.12)',
borderColor: 'rgba(255,120,117,0.24)',
iconColor: '#ff7875',
}
: {
background: 'rgba(255,77,79,0.08)',
borderColor: 'rgba(255,77,79,0.16)',
iconColor: '#ff4d4f',
};
}
return darkMode
? {
background: 'rgba(250,173,20,0.12)',
borderColor: 'rgba(250,173,20,0.22)',
iconColor: '#ffd666',
}
: {
background: 'rgba(250,173,20,0.08)',
borderColor: 'rgba(250,173,20,0.18)',
iconColor: '#d48806',
};
}, [composerNotice, darkMode]);
// Slash commands
const [showSlashMenu, setShowSlashMenu] = React.useState(false);
const [slashFilter, setSlashFilter] = React.useState('');
const slashCommands = React.useMemo(() => [
{ cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' },
{ cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL' },
{ cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```' },
{ cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```' },
{ cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' },
{ cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' },
{ cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' },
{ cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' },
], []);
const filteredSlashCmds = slashCommands.filter(c => c.cmd.startsWith(slashFilter.toLowerCase()));
const aiContexts = useStore(state => state.aiContexts);
const addAIContext = useStore(state => state.addAIContext);
const removeAIContext = useStore(state => state.removeAIContext);
const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
const activeContextItems = aiContexts[connectionKey] || [];
const fetchTablesForDb = async (dbName: string, connConfig: any) => {
setContextLoading(true);
setSelectedDbName(dbName);
try {
const res = await DBGetTables(buildRpcConnectionConfig(connConfig), dbName);
if (res.success && Array.isArray(res.data)) {
setContextTables(res.data.map(r => ({ name: Object.values(r)[0] as string })));
} else {
message.error('获取表格失败: ' + res.message);
setContextTables([]);
}
} catch (e: any) {
message.error(e.message);
setContextTables([]);
} finally {
setContextLoading(false);
}
};
const handleOpenContext = async () => {
if (!activeContext?.connectionId) {
message.warning('请先在左侧选择一个数据库作为所聊上下文');
return;
}
const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId);
if (!conn) return;
setContextOpen(true);
setContextLoading(true);
setSearchText('');
// Store dbName::tableName composite keys
setSelectedTableKeys(activeContextItems.map(c => `${c.dbName}::${c.tableName}`));
try {
// Fetch databases
const dbRes = await DBGetDatabases(buildRpcConnectionConfig(conn.config) as any);
if (dbRes.success && Array.isArray(dbRes.data)) {
const databases = dbRes.data.map((r: any) => Object.values(r)[0] as string);
setDbList(databases);
}
// Fetch tables for the active contextual database
const initDbName = activeContext.dbName || '';
setSelectedDbName(initDbName);
const tablesRes = await DBGetTables(buildRpcConnectionConfig(conn.config) as any, initDbName);
if (tablesRes.success && Array.isArray(tablesRes.data)) {
setContextTables(tablesRes.data.map((r: any) => ({ name: Object.values(r)[0] as string })));
} else {
setContextTables([]);
}
} catch (e: any) {
message.error(e.message);
} finally {
setContextLoading(false);
}
};
const handleAppendContext = async () => {
const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId);
if (!conn) return;
setAppendingContext(true);
try {
let addedCount = 0;
let removedCount = 0;
for (const cx of activeContextItems) {
const key = `${cx.dbName}::${cx.tableName}`;
if (!selectedTableKeys.includes(key)) {
removeAIContext(connectionKey, cx.dbName, cx.tableName);
removedCount++;
}
}
for (const key of selectedTableKeys) {
const [dbName, tableName] = key.split('::');
if (!dbName || !tableName) continue;
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
continue;
}
const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName);
let createSql = '';
if (res.success && res.data) {
if (typeof res.data === 'string') {
createSql = res.data;
} else if (Array.isArray(res.data) && res.data.length > 0) {
const row = res.data[0];
createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string;
}
} else {
message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误'));
}
if (createSql) {
addAIContext(connectionKey, {
dbName: dbName,
tableName: tableName,
ddl: createSql
});
addedCount++;
}
}
if (addedCount > 0 || removedCount > 0) {
if (addedCount > 0 && removedCount === 0) {
message.success(`已添加 ${addedCount} 张表的结构到上下文`);
} else if (removedCount > 0 && addedCount === 0) {
message.success(`已从上下文移除 ${removedCount} 张表的结构`);
} else {
message.success(`上下文已同步更新:新增 ${addedCount},移除 ${removedCount}`);
}
if (addedCount > 0) setContextExpanded(true);
} else {
message.info('选中的表未发生变化');
}
setContextOpen(false);
} catch (e: any) {
message.error(e.message);
} finally {
setAppendingContext(false);
}
};
return (
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
<div className="ai-chat-input-wrapper" style={{
borderColor: 'transparent',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 8,
padding: '8px 4px 8px'
}}>
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{activeContextItems.length > 0 && (
<Tag
onClick={() => setContextExpanded(!contextExpanded)}
style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }}
>
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
<DatabaseOutlined /> ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
</span>
</Tag>
)}
{contextExpanded && activeContextItems.map((ctx, idx) => (
<Tag
key={`ctx-${idx}`}
closable
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }}
>
<span style={{ fontSize: 13 }}>🗄 {ctx.tableName}</span>
</Tag>
))}
{draftImages.map((b64, i) => (
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
<div
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }}
>
</div>
</div>
))}
</div>
{composerNotice && (
<div
data-ai-chat-composer-notice="true"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: '8px 10px',
borderRadius: 12,
background: composerNoticePalette.background,
border: `1px solid ${composerNoticePalette.borderColor}`,
}}
>
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
{composerNotice.title}
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
{composerNotice.description}
</div>
</div>
</div>
)}
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
{showSlashMenu && filteredSlashCmds.length > 0 && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
background: darkMode ? '#2a2a2a' : '#fff',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
maxHeight: 220, overflowY: 'auto', padding: 4
}}>
{filteredSlashCmds.map(cmd => (
<div
key={cmd.cmd}
style={{
padding: '8px 12px', borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 10,
transition: 'background 0.15s'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={() => {
setInput(cmd.prompt);
setShowSlashMenu(false);
setSlashFilter('');
textareaRef.current?.focus();
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{cmd.cmd}</span>
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{cmd.label}</span>
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{cmd.desc}</span>
</div>
))}
</div>
)}
<Input.TextArea
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
if (blob) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
}
}
}
}}
ref={textareaRef as any}
value={input}
onChange={(e) => {
const val = e.target.value;
setInput(val);
// Slash command detection
if (val.startsWith('/')) {
setSlashFilter(val.split(/\s/)[0]);
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
setSlashFilter('');
}
}}
onKeyDown={handleKeyDown as any}
placeholder="输入消息... (Enter 发送Shift+Enter 换行,/ 快捷命令)"
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{activeConnName && (
<Tooltip title="当前数据查询上下文">
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 11, padding: '2px 8px', borderRadius: 12,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
color: overlayTheme.mutedText, cursor: 'default'
}}>
<DatabaseOutlined style={{ fontSize: 10 }} />
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
</span>
</div>
</Tooltip>
)}
{activeProvider && (
<Select
size="small"
variant="filled"
value={activeProvider.model || undefined}
onChange={onModelChange}
onDropdownVisibleChange={(open) => {
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
onFetchModels();
}
}}
loading={loadingModels}
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
style={{ width: 130, fontSize: 11, background: 'transparent' }}
dropdownStyle={{ minWidth: 200 }}
showSearch
placeholder="选择模型"
/>
)}
{contextUsageChars !== undefined && maxContextChars !== undefined && (
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k时将触发自动压缩。`}>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 10, padding: '2px 6px', borderRadius: 12, border: '1px solid transparent',
background: contextUsageChars > maxContextChars * 0.8 ? (darkMode ? 'rgba(250, 173, 20, 0.1)' : 'rgba(250, 173, 20, 0.08)') : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'),
borderColor: contextUsageChars > maxContextChars * 0.8 ? 'rgba(250, 173, 20, 0.3)' : 'transparent',
color: contextUsageChars > maxContextChars * 0.8 ? '#faad14' : overlayTheme.mutedText, cursor: 'default',
transition: 'all 0.3s'
}}>
<span>🧠 {(contextUsageChars / 1000).toFixed(1)}k / {(maxContextChars / 1000).toFixed(0)}k</span>
</div>
</Tooltip>
)}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
<input
type="file"
accept="image/*"
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImageUpload}
/>
<Tooltip title="上传图片/截图">
<Button
type="text"
icon={<PictureOutlined style={{ fontSize: 16 }} />}
onClick={() => fileInputRef.current?.click()}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
/>
</Tooltip>
<Tooltip title="关联附带数据库表上下文">
<Button
type="text"
icon={<TableOutlined style={{ fontSize: 16 }} />}
onClick={handleOpenContext}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
/>
</Tooltip>
{sending ? (
<button
className="ai-chat-send-btn ai-chat-stop-btn"
onClick={onStop}
title="停止生成"
style={{
background: 'rgba(255,77,79,0.1)',
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
width: 26, height: 26, borderRadius: 6, padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
}}
>
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
</button>
) : (
<button
className="ai-chat-send-btn"
onClick={() => onSend()}
disabled={!input.trim() && draftImages.length === 0}
title="发送"
style={{
background: (input.trim() || draftImages.length > 0) ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
color: (input.trim() || draftImages.length > 0) ? overlayTheme.iconColor : mutedColor,
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: (input.trim() || draftImages.length > 0) ? 'pointer' : 'not-allowed', flexShrink: 0
}}
>
<SendOutlined />
</button>
)}
</div>
</div>
</div>
<Modal
title={<span style={{ color: textColor }}></span>}
open={contextOpen}
onCancel={() => setContextOpen(false)}
onOk={handleAppendContext}
confirmLoading={appendingContext}
okText="同步所选表至上下文"
cancelText="取消"
centered
styles={{
content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder },
header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder },
body: { padding: '20px 24px' }
}}
>
<Spin spinning={contextLoading}>
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
{dbList.length > 0 && (
<Select
value={selectedDbName}
onChange={val => {
const c = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
if (c) fetchTablesForDb(val, c.config);
}}
options={dbList.map(d => ({ label: d, value: d }))}
style={{ width: 160, flexShrink: 0 }}
placeholder="切换数据库"
showSearch
/>
)}
<Input
placeholder="在当前库搜索表名..."
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }}
/>
</div>
{filteredTables.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`, paddingBottom: 12, marginBottom: 8 }}>
<Checkbox
indeterminate={
filteredTables.length > 0 &&
filteredTables.some(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) &&
!filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))
}
checked={filteredTables.length > 0 && filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))}
onChange={(e) => {
if (e.target.checked) {
const newSelected = new Set([...selectedTableKeys, ...filteredTables.map(t => `${selectedDbName}::${t.name}`)]);
setSelectedTableKeys(Array.from(newSelected));
} else {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
setSelectedTableKeys(selectedTableKeys.filter(key => !filteredKeys.includes(key)));
}
}}
style={{ color: textColor, fontWeight: 'bold' }}
>
({filteredTables.length})
</Checkbox>
<Button
type="link"
size="small"
style={{ padding: 0, height: 'auto', fontSize: 13 }}
onClick={() => {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
const remainingSelected = selectedTableKeys.filter(key => !filteredKeys.includes(key));
const toAdd = filteredKeys.filter(key => !selectedTableKeys.includes(key));
setSelectedTableKeys([...remainingSelected, ...toAdd]);
}}
>
</Button>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredTables.map(t => {
const key = `${selectedDbName}::${t.name}`;
const isSelected = selectedTableKeys.includes(key);
return (
<div
key={key}
style={{
padding: '6px 10px',
borderRadius: 6,
transition: 'background 0.2s',
cursor: 'pointer'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={(e) => {
// If click originated from the checkbox input itself, let its onChange handle it to avoid duplicate toggle
if ((e.target as HTMLElement).tagName.toLowerCase() === 'input') return;
if (isSelected) {
setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
} else {
setSelectedTableKeys([...selectedTableKeys, key]);
}
}}
>
<Checkbox
checked={isSelected}
onChange={(e) => {
if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]);
else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
}}
style={{ color: textColor, width: '100%' }}
>
<span style={{ fontSize: 13, userSelect: 'none' }}>{t.name}</span>
</Checkbox>
</div>
);
})}
</div>
</div>
</div>
) : (
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
'{searchText}'
</div>
)}
</Spin>
</Modal>
</div>
);
};

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { RobotOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
interface AIChatWelcomeProps {
overlayTheme: OverlayWorkbenchTheme;
quickActionBg: string;
quickActionBorder: string;
textColor: string;
mutedColor: string;
onQuickAction: (prompt: string, autoSend?: boolean) => void;
contextTableNames?: string[];
}
export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
overlayTheme, quickActionBg, quickActionBorder, textColor, mutedColor, onQuickAction, contextTableNames = []
}) => {
const hasContext = contextTableNames.length > 0;
const tableList = contextTableNames.join('、');
const quickActions = hasContext
? [
{ label: '📝 生成 SQL', prompt: `请根据以下表结构生成一条常用查询语句:${tableList}` },
{ label: '🔍 解释表结构', prompt: `请详细解释以下表的设计意图和字段含义:${tableList}` },
{ label: '⚡ 优化建议', prompt: `请分析以下表的结构设计,给出索引优化和查询性能优化建议:${tableList}` },
{ label: '🏗️ Schema 分析', prompt: `请对以下表进行全面的 Schema 分析,包括数据类型选择、范式评估和改进建议:${tableList}` },
]
: [
{ label: '📝 生成 SQL', prompt: '请根据当前数据库表结构生成一条查询语句:' },
{ label: '🔍 解释 SQL', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' },
{ label: '⚡ 优化建议', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' },
{ label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' },
];
return (
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
GoNavi AI
</div>
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
{hasContext
? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。`
: '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'}
</div>
<div className="quick-actions">
{quickActions.map(action => (
<div
key={action.label}
className="quick-action-btn"
style={{
background: quickActionBg,
borderColor: quickActionBorder,
color: textColor,
}}
onClick={() => onQuickAction(action.prompt)}
>
{action.label}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { Drawer, Button, Tooltip, Input } from 'antd';
import { MenuFoldOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
import { useStore } from '../../store';
interface AIHistoryDrawerProps {
open: boolean;
onClose: () => void;
bgColor?: string;
darkMode: boolean;
textColor: string;
mutedColor: string;
borderColor: string;
onCreateNew: () => void;
sessionId: string;
}
export const AIHistoryDrawer: React.FC<AIHistoryDrawerProps> = ({
open, onClose, bgColor, darkMode, textColor, mutedColor, borderColor, onCreateNew, sessionId
}) => {
const aiChatSessions = useStore(state => state.aiChatSessions);
const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId);
const deleteAISession = useStore(state => state.deleteAISession);
// 阶段4: 历史记录搜索
const [searchText, setSearchText] = useState('');
const filteredSessions = aiChatSessions.filter(s =>
!searchText || (s.title && s.title.toLowerCase().includes(searchText.toLowerCase()))
);
return (
<Drawer
placement="left"
closable={false}
onClose={onClose}
open={open}
getContainer={false}
style={{ position: 'absolute', background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa') }}
width={260}
bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}
>
{/* 侧拉面板头部 */}
<div style={{ padding: '16px 16px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor }}></span>
<Tooltip title="收起">
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
</div>
{/* 新建对话按钮 */}
<div style={{ padding: '0 12px 12px' }}>
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={() => { onCreateNew(); onClose(); }}
style={{ borderColor: borderColor, color: textColor, background: 'transparent' }}
>
</Button>
</div>
{/* 列表搜索 */}
<div style={{ padding: '0 12px 12px' }}>
<Input
placeholder="搜索历史记录..."
prefix={<SearchOutlined style={{ color: mutedColor }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
variant="filled"
size="small"
style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'transparent', color: textColor }}
/>
</div>
{/* 列表容器 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 10px 16px' }} className="ai-history-list">
{filteredSessions.length === 0 ? (
<div style={{ padding: '30px 0', textAlign: 'center', color: mutedColor, fontSize: 12 }}></div>
) : (
filteredSessions.map(session => (
<div
key={session.id}
className={`ai-history-item ${sessionId === session.id ? 'active' : ''}`}
onClick={() => { setAIActiveSessionId(session.id); onClose(); }}
style={{
padding: '10px 12px',
borderRadius: 6,
marginBottom: 4,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: sessionId === session.id ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : 'transparent',
transition: 'background 0.2s',
}}
>
<div style={{ overflow: 'hidden', flex: 1, paddingRight: 8 }}>
<div style={{ fontSize: 13, color: textColor, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: sessionId === session.id ? 600 : 'normal' }}>
{session.title || '新对话'}
</div>
<div style={{ fontSize: 11, color: mutedColor, marginTop: 4 }}>
{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<Tooltip title="删除">
<Button
className="ai-history-delete-btn"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
deleteAISession(session.id);
}}
style={{ display: sessionId === session.id ? 'inline-flex' : undefined }}
/>
</Tooltip>
</div>
))
)}
</div>
</Drawer>
);
};

View File

@@ -0,0 +1,735 @@
import React, { useState, useEffect, useRef } from 'react';
import { Tooltip, message } from 'antd';
import { UserOutlined, RobotOutlined, EditOutlined, ReloadOutlined, DeleteOutlined, CheckOutlined, CopyOutlined, PlayCircleOutlined, ApiOutlined, LoadingOutlined, CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import mermaid from 'mermaid';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { AIChatMessage, AIToolCall } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
const MemoizedMarkdown = React.memo(({
content,
darkMode,
overlayTheme,
activeConnectionConfig,
activeConnectionId,
activeDbName
}: {
content: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
activeConnectionConfig?: any;
activeConnectionId?: string;
activeDbName?: string;
}) => {
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
const components = React.useMemo(() => ({
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
if (!inline && match && match[1] === 'mermaid') {
return <MermaidRenderer chart={String(children).replace(/\n$/, '')} darkMode={darkMode} />;
}
return !inline && match ? (
<AIBlockHashRender match={match} darkMode={darkMode} overlayTheme={overlayTheme} children={children} activeConnectionConfig={activeConnectionConfig} activeConnectionId={activeConnectionId} activeDbName={activeDbName} />
) : (
<code className={className} {...props}>
{children}
</code>
);
}
}), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]);
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{content}
</ReactMarkdown>
);
});
interface AIMessageBubbleProps {
msg: AIChatMessage;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
textColor: string;
onEdit: (msg: AIChatMessage) => void;
onRetry: (msg: AIChatMessage) => void;
onDelete: (id: string) => void;
activeConnectionId?: string;
activeConnectionConfig?: any;
activeDbName?: string;
allMessages?: AIChatMessage[];
}
const AIToolResultItem: React.FC<{ resultMsg: AIChatMessage, darkMode: boolean, overlayTheme: OverlayWorkbenchTheme }> = ({ resultMsg, darkMode, overlayTheme }) => {
const [toolExpanded, setToolExpanded] = useState(false);
const charCount = resultMsg.content ? resultMsg.content.length : 0;
return (
<div style={{
background: darkMode ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.02)',
borderRadius: 6,
padding: '6px 10px',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
marginTop: 8,
width: '100%'
}}>
<div
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', gap: 6, fontSize: 12, color: overlayTheme.mutedText }}
onClick={() => setToolExpanded(!toolExpanded)}
>
{toolExpanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
<ApiOutlined style={{ color: '#1677ff' }} />
<span> (<span style={{ fontFamily: 'monospace', color: overlayTheme.iconColor }}>{resultMsg.tool_name || 'unknown'}</span>)</span>
<span style={{ fontSize: 11, marginLeft: 8, opacity: 0.6 }}>{charCount > 0 ? `${charCount} 个字符` : '无数据'}</span>
</div>
{toolExpanded && (
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 300, overflowY: 'auto', background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.03)', padding: 8, borderRadius: 6 }}>
{resultMsg.content}
</div>
)}
</div>
);
};
const MermaidRenderer = ({ chart, darkMode }: { chart: string, darkMode: boolean }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (containerRef.current) {
try {
mermaid.initialize({ startOnLoad: false, theme: darkMode ? 'dark' : 'default' });
const id = `mermaid-${Math.random().toString(36).substring(2)}`;
(async () => {
const result: any = await mermaid.render(id, chart);
if (containerRef.current) {
containerRef.current.innerHTML = result.svg || result;
}
})().catch((e: any) => {
if (containerRef.current) {
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 解析失败: ${e.message}</div>`;
}
});
} catch (e: any) {
if (containerRef.current) {
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 渲染异常: ${e.message}</div>`;
}
}
}
}, [chart, darkMode]);
return <div ref={containerRef} className="ai-mermaid-container" style={{ margin: '16px 0', display: 'flex', justifyContent: 'flex-start', overflowX: 'auto' }} />;
};
const CodeCopyBtn = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false);
return (
<span
className="ai-code-copy-btn"
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
opacity: copied ? 1 : 0.6,
transition: 'opacity 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = copied ? '1' : '0.6'; }}
>
{copied ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
<span style={{ marginLeft: 4 }}>{copied ? '已复制' : '复制代码'}</span>
</span>
);
};
const CodeRunBtn = ({ text, connectionId, dbName }: { text: string; connectionId?: string; dbName?: string }) => {
// 解析 SQL 顶部的 @context 注释,格式:-- @context connectionId=xxx dbName=yyy
const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m);
const resolvedConnId = contextMatch?.[1] || connectionId;
const resolvedDbName = contextMatch?.[2] || dbName;
// 发送给查询编辑器时去掉 @context 注释行
const cleanSql = text.replace(/^--\s*@context\s+.*\n?/gm, '').trim();
const sqlDetail = (runImmediately: boolean) => ({ sql: cleanSql, runImmediately, connectionId: resolvedConnId, dbName: resolvedDbName });
const handleExecute = async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AICheckSQL) {
const result = await Service.AICheckSQL(text);
if (!result.allowed) {
message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`);
return;
}
if (result.requiresConfirm) {
const { Modal } = await import('antd');
Modal.confirm({
title: '⚠️ 安全确认',
content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`,
okText: '确认执行',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
},
});
return;
}
}
// Safety check passed or not available, execute directly
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
} catch (e) {
// If safety check fails, still allow manual execution
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
}
};
return (
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<Tooltip title="将该段 SQL 注入查询工作区(可快捷修改或执行)">
<span
className="ai-code-run-btn"
onClick={() => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(false) }));
}}
style={{
cursor: 'pointer', display: 'flex', alignItems: 'center',
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#10b981'
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
>
<PlayCircleOutlined />
<span style={{ marginLeft: 4 }}></span>
</span>
</Tooltip>
<Tooltip title="立即执行(受 AI 安全策略管控)">
<span
className="ai-code-run-btn"
onClick={handleExecute}
style={{
cursor: 'pointer', display: 'flex', alignItems: 'center',
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#1677ff'
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
>
<PlayCircleOutlined />
<span style={{ marginLeft: 4 }}></span>
</span>
</Tooltip>
</div>
);
};
// 阶段2: 代码块体验升级 (折叠展开、行号显示、内联SQL预览)
const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConnectionConfig, activeConnectionId, activeDbName }: any) => {
const codeText = String(children).replace(/\n$/, '');
// 将 @context 注释行从显示文本中剔除,用户无需看到内部元数据
const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim();
const [expanded, setExpanded] = useState(false);
const [previewData, setPreviewData] = useState<any[] | null>(null);
const [previewCols, setPreviewCols] = useState<string[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState('');
const [previewExpanded, setPreviewExpanded] = useState(false);
const MAX_HEIGHT = 300;
const isLongCode = displayText.split('\n').length > 15;
const isSql = match[1] === 'sql';
const isSelectQuery = isSql && /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i.test(displayText.trim());
const handleInlineExecute = async () => {
if (!activeConnectionConfig || previewLoading) return;
setPreviewLoading(true);
setPreviewError('');
setPreviewData(null);
try {
const { DBQuery } = await import('../../../wailsjs/go/app/App');
const res = await DBQuery(activeConnectionConfig, activeDbName || '', displayText + ' LIMIT 50');
if (res.success && Array.isArray(res.data)) {
const rows = res.data as any[];
const cols = rows.length > 0 ? Object.keys(rows[0]) : [];
setPreviewCols(cols);
setPreviewData(rows.slice(0, 20));
setPreviewExpanded(true);
} else {
setPreviewError(res.message || '查询无结果');
}
} catch (err: any) {
setPreviewError(err?.message || '执行失败');
} finally {
setPreviewLoading(false);
}
};
return (
<div className="ai-code-block-container" style={{ margin: '12px 0', border: overlayTheme.sectionBorder, borderRadius: 6, overflow: 'hidden' }}>
<div className="ai-code-header" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '6px 12px', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
fontSize: 12, color: overlayTheme.mutedText
}}>
<span style={{ fontFamily: 'monospace' }}>{match[1]}</span>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
{isSql && <CodeRunBtn text={codeText} connectionId={activeConnectionId} dbName={activeDbName} />}
{isSelectQuery && activeConnectionConfig && (
<Tooltip title="在聊天内预览查询结果最多20行">
<span
onClick={handleInlineExecute}
style={{
cursor: previewLoading ? 'wait' : 'pointer', display: 'flex', alignItems: 'center',
opacity: previewLoading ? 1 : 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#faad14'
}}
onMouseEnter={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '0.6'; }}
>
{previewLoading ? '⏳' : '👁'}
<span style={{ marginLeft: 4 }}>{previewLoading ? '执行中...' : '预览'}</span>
</span>
</Tooltip>
)}
<CodeCopyBtn text={displayText} />
</div>
</div>
<div style={{ position: 'relative' }}>
<SyntaxHighlighter
style={darkMode ? vscDarkPlus as any : vs as any}
language={match[1]}
PreTag="div"
showLineNumbers={true}
customStyle={{
margin: 0,
borderRadius: 0,
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.02)',
maxHeight: expanded ? 'none' : (isLongCode ? MAX_HEIGHT : 'none'),
overflowY: expanded ? 'auto' : 'hidden',
fontSize: '14px',
lineHeight: 1.6
}}
codeTagProps={{
style: {
fontSize: '14px',
fontFamily: 'Menlo, Monaco, Consolas, "Courier New", monospace'
}
}}
>
{displayText}
</SyntaxHighlighter>
{!expanded && isLongCode && (
<div
style={{
position: 'absolute',
bottom: 0, left: 0, right: 0,
height: 60,
background: `linear-gradient(to bottom, transparent, ${darkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)'})`,
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
paddingBottom: 8, cursor: 'pointer'
}}
onClick={() => setExpanded(true)}
>
<span style={{ fontSize: 12, color: overlayTheme.iconColor, background: darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', padding: '2px 8px', borderRadius: 12 }}>
</span>
</div>
)}
{expanded && isLongCode && (
<div
style={{
display: 'flex', justifyContent: 'center', padding: '6px 0',
background: darkMode ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.02)', cursor: 'pointer',
borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`
}}
onClick={() => setExpanded(false)}
>
<span style={{ fontSize: 12, color: overlayTheme.iconColor }}></span>
</div>
)}
</div>
{/* Inline SQL Preview Results */}
{previewError && (
<div style={{ padding: '8px 12px', fontSize: 12, color: '#ef4444', background: darkMode ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.05)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}` }}>
{previewError}
</div>
)}
{previewExpanded && previewData && previewData.length > 0 && (
<div style={{ borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px', background: darkMode ? 'rgba(250,173,20,0.08)' : 'rgba(250,173,20,0.05)' }}>
<span style={{ fontSize: 11, color: overlayTheme.mutedText }}>📊 {previewData.length} × {previewCols.length} </span>
<span style={{ fontSize: 11, color: overlayTheme.mutedText, cursor: 'pointer' }} onClick={() => setPreviewExpanded(false)}> </span>
</div>
<div style={{ overflowX: 'auto', maxHeight: 200, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'monospace' }}>
<thead>
<tr>
{previewCols.map(col => (
<th key={col} style={{ padding: '4px 8px', textAlign: 'left', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', color: overlayTheme.titleText, fontWeight: 600, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}` }}>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewData.map((row, ri) => (
<tr key={ri}>
{previewCols.map(col => (
<td key={col} style={{ padding: '3px 8px', color: overlayTheme.mutedText, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'}`, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{row[col] === null ? <span style={{ color: '#999', fontStyle: 'italic' }}>NULL</span> : String(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!previewExpanded && previewData && previewData.length > 0 && (
<div
style={{ padding: '4px 12px', cursor: 'pointer', fontSize: 11, color: overlayTheme.mutedText, background: darkMode ? 'rgba(250,173,20,0.05)' : 'rgba(250,173,20,0.03)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}` }}
onClick={() => setPreviewExpanded(true)}
>
📊 {previewData.length}
</div>
)}
</div>
);
};
// 可折叠思考过程组件
const ThinkingBlock: React.FC<{ displayThinking: string; totalLen: number; isTyping: boolean; isGlobalLoading: boolean; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ displayThinking, totalLen, isTyping, isGlobalLoading, darkMode, overlayTheme, hasContent }) => {
// 如果整体在loading且尚未吐出content我们认为真正的思考还在进行如果吐出content了思考框就算告一段落
const isActivelyThinking = isGlobalLoading && !hasContent;
const [expanded, setExpanded] = useState(isActivelyThinking);
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => { if (isActivelyThinking) setExpanded(true); }, [isActivelyThinking]);
// 断开连接或思考结束时,若已有内容且不再产生新内容则默认收起
React.useEffect(() => {
if (!isGlobalLoading) setExpanded(false);
}, [isGlobalLoading]);
// 自动滚动到思考内容底部
React.useEffect(() => {
if (expanded && isTyping && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [displayThinking, expanded, isTyping]);
return (
<div style={{
marginBottom: hasContent ? 8 : 0,
borderRadius: 6,
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
overflow: 'hidden',
}}>
<div
onClick={() => setExpanded(e => !e)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 10px', cursor: 'pointer',
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
fontSize: 12, color: overlayTheme.mutedText, userSelect: 'none',
}}
>
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10 }}></span>
<span>💭 </span>
{isActivelyThinking && <span style={{ fontSize: 10, color: '#8b5cf6', animation: 'pulse 1.5s ease-in-out infinite' }}>...</span>}
{!isActivelyThinking && <span style={{ fontSize: 10, opacity: 0.5 }}>({displayThinking.length} )</span>}
</div>
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
<div ref={contentRef} style={{
padding: expanded ? '8px 12px' : '0 12px',
borderLeft: '3px solid #8b5cf6',
margin: '0 8px 8px',
fontSize: 12, lineHeight: 1.7,
color: overlayTheme.mutedText,
fontStyle: 'italic',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
maxHeight: 400, overflowY: 'auto',
}}>
{displayThinking}
{isTyping && <span className="ai-blinking-cursor" style={{ background: '#8b5cf6', marginLeft: 4, width: 6, height: 12, display: 'inline-block', verticalAlign: 'middle', opacity: 0.8 }} />}
</div>
</div>
</div>
);
};
// 工具调用进度面板聚合展示组件
const AIToolCallingBlock: React.FC<{ tool_calls: AIToolCall[]; loading: boolean; allMessages: AIChatMessage[]; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ tool_calls, loading, allMessages, darkMode, overlayTheme, hasContent }) => {
const totalCalls = tool_calls.length;
const allDone = tool_calls.every(tc => allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id));
const [expanded, setExpanded] = useState(!allDone && loading);
// 断开连接或执行完毕时,若已完成则默认收起
React.useEffect(() => {
if (allDone || !loading) setExpanded(false);
}, [allDone, loading]);
// 显示友好的人类可读动作名
const getHumanActionName = (fname: string) => {
if (fname === 'get_connections') return '获取可用连接信息';
if (fname === 'get_databases') return '扫描数据库列表';
if (fname === 'get_tables') return '分析表结构信息';
return fname;
};
return (
<div style={{
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.025)',
borderRadius: 8, fontSize: 12, overflow: 'hidden',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
marginTop: hasContent ? 12 : 0,
display: 'flex', flexDirection: 'column',
}}>
<div
onClick={() => setExpanded(!expanded)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', cursor: 'pointer', userSelect: 'none',
background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.titleText, fontWeight: 500 }}>
{!allDone && loading ? (
<div className="ai-spinning-ring" />
) : (
<CheckOutlined style={{ color: '#10b981' }} />
)}
<span>{!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${totalCalls} 项)`}</span>
</div>
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10, color: overlayTheme.mutedText }}></span>
</div>
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
<div style={{ padding: expanded ? '4px 12px 12px' : '0 12px' }}>
{tool_calls.map((tc, idx) => {
const resultMsg = allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id);
const isDone = !!resultMsg;
const actionName = getHumanActionName(tc.function.name);
return (
<div key={tc.id} style={{
display: 'flex', flexDirection: 'column', gap: 4,
marginTop: 6, paddingLeft: 8,
borderLeft: `2px solid ${isDone ? '#10b981' : (loading ? '#1677ff' : overlayTheme.shellBorder)}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isDone
? <CheckOutlined style={{ color: '#10b981', fontSize: 11 }} />
: (loading ? <div className="ai-spinning-ring" style={{ width: 10, height: 10, borderWidth: 1.5 }} /> : <ApiOutlined style={{ color: overlayTheme.mutedText, fontSize: 11 }} />)
}
<span style={{ color: isDone ? overlayTheme.mutedText : overlayTheme.titleText }}>{actionName}</span>
</div>
{resultMsg && <AIToolResultItem resultMsg={resultMsg} darkMode={darkMode} overlayTheme={overlayTheme} />}
</div>
);
})}
</div>
</div>
</div>
);
};
export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg, darkMode, overlayTheme, textColor, onEdit, onRetry, onDelete, activeConnectionId, activeConnectionConfig, activeDbName, allMessages }) => {
const [isCopied, setIsCopied] = useState(false);
const isUser = msg.role === 'user';
// 从 content 中提取 <think>...</think> 标签内容(部分模型如 MiniMax、DeepSeek 会以文本形式返回思考过程)
const { displayContent, parsedThinking } = React.useMemo(() => {
const content = msg.content || '';
// 优先使用后端已结构化的 thinking 字段(如 Claude API 原生 thinking
if (msg.thinking) {
return { displayContent: content, parsedThinking: msg.thinking };
}
// 尝试从 content 中提取 <think>...</think> 标签
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
let thinkParts: string[] = [];
let cleanContent = content;
let match;
while ((match = thinkRegex.exec(content)) !== null) {
thinkParts.push(match[1].trim());
}
if (thinkParts.length > 0) {
// 移除所有 <think>...</think> 标签(含未闭合的)
cleanContent = content.replace(/<think>[\s\S]*?(?:<\/think>|$)/g, '').trim();
return { displayContent: cleanContent, parsedThinking: thinkParts.join('\n\n') };
}
return { displayContent: content, parsedThinking: '' };
}, [msg.content, msg.thinking]);
const isTypingThinking = !!(msg.loading && msg.phase === 'thinking');
if (msg.role === 'tool') return null;
// 如果是纯空壳的加载状态connecting或还在思考/工具阶段但还没吐出一个字的 content
const isWaitState = msg.phase === 'connecting' ||
(msg.loading && !msg.content && (msg.phase === 'thinking' || msg.phase === 'tool_calling'));
if (isWaitState) {
return (
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
<div style={{
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
borderRadius: 12, padding: '14px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.mutedText }}>
<div className="ai-wave-pulse">
<span /> <span /> <span />
</div>
<span style={{ fontSize: 13, opacity: 0.8 }}>{msg.content || '正在建立连接'}...</span>
</div>
{/* 即使在波纹过渡态,如果有 thinking / tool_calls 也要显示出来,只是把它们压在波纹下面 */}
<div style={{ marginTop: parsedThinking || (msg.tool_calls && msg.tool_calls.length > 0) ? 12 : 0 }}>
{!isUser && parsedThinking && (
<ThinkingBlock
displayThinking={parsedThinking}
totalLen={parsedThinking.length}
isTyping={isTypingThinking}
isGlobalLoading={!!msg.loading}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={false}
/>
)}
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
<AIToolCallingBlock
tool_calls={msg.tool_calls}
loading={!!msg.loading}
allMessages={allMessages || []}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={false}
/>
)}
</div>
</div>
</div>
);
}
return (
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
<div style={{
background: isUser ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
borderRadius: 12,
padding: '14px 16px',
}}>
<div className="ai-ide-message-header" style={{
color: isUser ? overlayTheme.mutedText : overlayTheme.titleText,
marginBottom: isUser ? 6 : 10,
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
}}>
<div>
{isUser
? <><UserOutlined /> <span>You</span></>
: <><RobotOutlined style={{ color: overlayTheme.iconColor }} /> <span>GoNavi AI</span></>}
</div>
{/* 气泡操作栏 */}
<div className="ai-message-actions" style={{ display: 'flex', gap: 8, opacity: 0, transition: 'opacity 0.2s', padding: '0 4px' }}>
<Tooltip title={isCopied ? "已复制" : "复制全文"}>
{isCopied ? (
<CheckOutlined className="ai-action-icon" style={{ color: '#10b981' }} />
) : (
<CopyOutlined className="ai-action-icon" onClick={() => {
navigator.clipboard.writeText(msg.content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
}} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
)}
</Tooltip>
{isUser ? (
<Tooltip title="编辑此条消息(移除其后所有记录并重新发送)">
<EditOutlined className="ai-action-icon" onClick={() => onEdit(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
) : (
<Tooltip title="重新生成(移除此条并触发上次用户输入重发)">
<ReloadOutlined className="ai-action-icon" onClick={() => onRetry(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
)}
<Tooltip title="删除单条消息">
<DeleteOutlined className="ai-action-icon" onClick={() => onDelete(msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
</div>
</div>
<div className="ai-ide-message-content ai-markdown-content" style={{ color: textColor }}>
{msg.images && msg.images.length > 0 && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
{msg.images.map((img, i) => (
<img key={i} src={img} alt={`Attached ${i}`} style={{ maxWidth: 200, maxHeight: 200, borderRadius: 8, objectFit: 'contain', border: overlayTheme.shellBorder }} />
))}
</div>
)}
{/* 可折叠思考过程 */}
{!isUser && parsedThinking && (
<ThinkingBlock
displayThinking={parsedThinking}
totalLen={parsedThinking.length}
isTyping={isTypingThinking}
isGlobalLoading={!!msg.loading}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={!!msg.content}
/>
)}
{isUser ? (
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 13 }}>{msg.content}</div>
) : (
<MemoizedMarkdown
content={displayContent}
darkMode={darkMode}
overlayTheme={overlayTheme}
activeConnectionConfig={activeConnectionConfig}
activeConnectionId={activeConnectionId}
activeDbName={activeDbName}
/>
)}
{/* 错误原文复制按钮 */}
{!isUser && msg.rawError && (
<div style={{ marginTop: 8 }}>
<button
onClick={() => {
navigator.clipboard.writeText(msg.rawError || '');
const btn = document.getElementById(`raw-err-btn-${msg.id}`);
if (btn) { btn.textContent = '✅ 已复制'; setTimeout(() => { btn.textContent = '📋 复制报错原文'; }, 1500); }
}}
id={`raw-err-btn-${msg.id}`}
style={{
fontSize: 12, padding: '3px 10px', borderRadius: 6, cursor: 'pointer',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'}`,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
color: overlayTheme.mutedText, transition: 'all 0.15s ease',
}}
>
📋
</button>
</div>
)}
{/* 工具调用进度展示 */}
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
<AIToolCallingBlock
tool_calls={msg.tool_calls}
loading={!!msg.loading}
allMessages={allMessages || []}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={!!msg.content}
/>
)}
{msg.loading && msg.phase !== 'tool_calling' && msg.content && (
<span className="ai-blinking-cursor" style={{ background: overlayTheme.iconColor }} />
)}
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
calculateAutoFitColumnWidth,
normalizeAutoFitCellText,
} from './dataGridAutoWidth';
const measure = (text: string) => text.length * 8;
describe('dataGridAutoWidth helpers', () => {
it('prefers the widest header or sampled value and adds padding', () => {
const width = calculateAutoFitColumnWidth({
headerTexts: ['user_name'],
valueTexts: ['alice', 'very_long_username_value'],
measureHeaderText: measure,
measureCellText: measure,
padding: 32,
minWidth: 80,
maxWidth: 720,
defaultWidth: 140,
});
expect(width).toBe('very_long_username_value'.length * 8 + 32);
});
it('measures multiline content by the longest visible line and clamps to max width', () => {
const width = calculateAutoFitColumnWidth({
headerTexts: ['notes'],
valueTexts: ['short\nmuch much longer line here'],
measureHeaderText: measure,
measureCellText: measure,
padding: 24,
minWidth: 80,
maxWidth: 160,
defaultWidth: 140,
});
expect(width).toBe(160);
});
it('normalizes null and oversized object values into stable preview text', () => {
expect(normalizeAutoFitCellText(null)).toBe('NULL');
expect(normalizeAutoFitCellText({ a: 1, b: 2 })).toBe('{"a":1,"b":2}');
expect(normalizeAutoFitCellText(Array.from({ length: 81 }, (_, index) => index))).toBe('[Array(81)]');
});
});

View File

@@ -0,0 +1,108 @@
const AUTO_FIT_DEFAULT_MIN_WIDTH = 80;
const AUTO_FIT_DEFAULT_MAX_WIDTH = 720;
const AUTO_FIT_DEFAULT_PADDING = 40;
const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200;
const AUTO_FIT_MAX_PREVIEW_CHARS = 120;
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return Object.prototype.toString.call(value) === '[object Object]';
};
const clampWidth = (value: number, minWidth: number, maxWidth: number) => {
const safeMin = Math.max(1, Math.floor(minWidth));
const safeMax = Math.max(safeMin, Math.floor(maxWidth));
return Math.min(safeMax, Math.max(safeMin, Math.ceil(value)));
};
const normalizePreviewLine = (value: string): string => {
const normalized = String(value ?? '').replace(/\r\n/g, '\n');
if (normalized.length <= AUTO_FIT_MAX_PREVIEW_CHARS) {
return normalized;
}
return `${normalized.slice(0, AUTO_FIT_MAX_PREVIEW_CHARS)}`;
};
const splitPreviewLines = (value: string): string[] => {
return normalizePreviewLine(value)
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.length > 0);
};
export const normalizeAutoFitCellText = (value: unknown): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (typeof value === 'string') {
return normalizePreviewLine(value);
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
if (Array.isArray(value)) {
if (value.length > 80) {
return `[Array(${value.length})]`;
}
try {
return normalizePreviewLine(JSON.stringify(value));
} catch {
return '[Array]';
}
}
if (isPlainObject(value)) {
const topLevelSize = Object.keys(value).length;
if (topLevelSize > 80) {
return `{Object(${topLevelSize})}`;
}
try {
return normalizePreviewLine(JSON.stringify(value));
} catch {
return '[Object]';
}
}
return normalizePreviewLine(String(value));
};
export const calculateAutoFitColumnWidth = ({
headerTexts,
valueTexts,
measureHeaderText,
measureCellText,
minWidth = AUTO_FIT_DEFAULT_MIN_WIDTH,
maxWidth = AUTO_FIT_DEFAULT_MAX_WIDTH,
padding = AUTO_FIT_DEFAULT_PADDING,
sampleLimit = AUTO_FIT_DEFAULT_SAMPLE_LIMIT,
defaultWidth,
}: {
headerTexts: Array<string | null | undefined>;
valueTexts: unknown[];
measureHeaderText: (text: string) => number;
measureCellText: (text: string) => number;
minWidth?: number;
maxWidth?: number;
padding?: number;
sampleLimit?: number;
defaultWidth: number;
}): number => {
const safePadding = Math.max(0, Math.ceil(padding));
let widestTextWidth = Math.max(0, Number(defaultWidth) - safePadding);
headerTexts.forEach((text) => {
splitPreviewLines(normalizeAutoFitCellText(text ?? '')).forEach((line) => {
widestTextWidth = Math.max(widestTextWidth, measureHeaderText(line));
});
});
valueTexts.slice(0, Math.max(1, sampleLimit)).forEach((value) => {
splitPreviewLines(normalizeAutoFitCellText(value)).forEach((line) => {
widestTextWidth = Math.max(widestTextWidth, measureCellText(line));
});
});
return clampWidth(widestTextWidth + safePadding, minWidth, maxWidth);
};

View File

@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest';
import {
buildCopyDeleteSQL,
buildCopyInsertSQL,
buildCopyUpdateSQL,
resolveUniqueKeyGroupsFromIndexes,
} from './dataGridCopyInsert';
describe('buildCopyInsertSQL', () => {
it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.OrderLog',
orderedCols: ['CreatedAt', 'note'],
record: {
CreatedAt: '2026-01-21T18:32:26+08:00',
note: "O'Brien",
},
columnTypesByLowerName: {
createdat: 'timestamp without time zone',
note: 'text',
},
});
expect(sql).toBe(
`INSERT INTO public."OrderLog" ("CreatedAt", note) VALUES ('2026-01-21 18:32:26', 'O''Brien');`,
);
});
it('keeps timezone offsets for timezone-aware PostgreSQL columns while still removing the T separator', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['created_at'],
record: {
created_at: '2026-01-21T18:32:26+08:00',
},
columnTypesByLowerName: {
created_at: 'timestamp with time zone',
},
});
expect(sql).toBe(
`INSERT INTO public.audit_log (created_at) VALUES ('2026-01-21 18:32:26+08:00');`,
);
});
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['payload'],
record: {
payload: '2026-01-21T18:32:26+08:00',
},
columnTypesByLowerName: {
payload: 'text',
},
});
expect(sql).toBe(
`INSERT INTO public.audit_log (payload) VALUES ('2026-01-21T18:32:26+08:00');`,
);
});
it('groups composite unique indexes by name and sequence order', () => {
expect(resolveUniqueKeyGroupsFromIndexes([
{ name: 'PRIMARY', columnName: 'id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
{ name: 'uk_order_code', columnName: 'code', nonUnique: 0, seqInIndex: 2, indexType: 'BTREE' },
{ name: 'uk_order_code', columnName: 'tenant_id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
{ name: 'idx_note', columnName: 'note', nonUnique: 1, seqInIndex: 1, indexType: 'BTREE' },
])).toEqual([
['id'],
['tenant_id', 'code'],
]);
});
it('builds UPDATE SQL with a primary-key WHERE clause and keeps literal formatting aligned with INSERT', () => {
const result = buildCopyUpdateSQL({
dbType: 'mysql',
tableName: 'orders',
orderedCols: ['id', 'note', 'deleted_at'],
record: {
id: 7,
note: "O'Brien",
deleted_at: null,
},
pkColumns: ['id'],
columnTypesByLowerName: {
deleted_at: 'datetime',
},
allTableColumns: ['id', 'note', 'deleted_at'],
});
expect(result).toEqual({
ok: true,
whereStrategy: 'primary-key',
sql: `UPDATE \`orders\` SET \`id\` = '7', \`note\` = 'O''Brien', \`deleted_at\` = NULL WHERE (\`id\` = '7');`,
});
});
it('builds DELETE SQL with a composite unique-key WHERE clause when no primary key is available', () => {
const result = buildCopyDeleteSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['tenant_id', 'code', 'payload'],
record: {
tenant_id: 'acme',
code: 'evt-7',
payload: '{"ok":true}',
},
uniqueKeyGroups: [['tenant_id', 'code']],
allTableColumns: ['tenant_id', 'code', 'payload'],
});
expect(result).toEqual({
ok: true,
whereStrategy: 'unique-key',
sql: `DELETE FROM public.audit_log WHERE (tenant_id = 'acme' AND code = 'evt-7');`,
});
});
it('falls back to all-column matching and uses IS NULL for null values', () => {
const result = buildCopyDeleteSQL({
dbType: 'sqlserver',
tableName: 'dbo.OrderLog',
orderedCols: ['id', 'deleted_at', 'flag'],
allTableColumns: ['id', 'deleted_at', 'flag'],
record: {
id: 5,
deleted_at: null,
flag: true,
},
});
expect(result).toEqual({
ok: true,
whereStrategy: 'all-columns',
sql: `DELETE FROM [dbo].[OrderLog] WHERE ([id] = '5' AND [deleted_at] IS NULL AND [flag] = 'true');`,
});
});
it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => {
const result = buildCopyDeleteSQL({
dbType: 'mysql',
tableName: 'orders',
orderedCols: ['note'],
allTableColumns: ['id', 'note', 'created_at'],
record: {
note: 'partial row',
},
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('expected buildCopyDeleteSQL to fail');
}
expect(result.error).toContain('主键');
expect(result.error).toContain('全部字段');
});
});

View File

@@ -0,0 +1,417 @@
import type { IndexDefinition } from '../types';
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
type BuildCopyInsertSQLParams = {
dbType: string;
tableName?: string;
orderedCols: string[];
record: Record<string, any>;
columnTypesByLowerName?: Record<string, string>;
};
type BuildCopyMutationSQLParams = BuildCopyInsertSQLParams & {
pkColumns?: string[];
uniqueKeyGroups?: string[][];
allTableColumns?: string[];
};
type CopySqlWhereStrategy = 'primary-key' | 'unique-key' | 'all-columns';
export type CopyMutationSQLResult =
| { ok: true; sql: string; whereStrategy: CopySqlWhereStrategy }
| { ok: false; error: string };
type CopyMutationWhereClauseResult =
| { ok: true; clause: string; whereStrategy: CopySqlWhereStrategy }
| { ok: false; error: string };
const looksLikeDateTimeText = (val: string): boolean => {
if (!val) return false;
const len = val.length;
if (len < 19 || len > 64) return false;
const charCode0 = val.charCodeAt(0);
if (charCode0 < 48 || charCode0 > 57) return false;
return (
val[4] === '-' &&
val[7] === '-' &&
(val[10] === ' ' || val[10] === 'T') &&
val[13] === ':' &&
val[16] === ':'
);
};
const normalizeDateTimeString = (val: string): string => {
if (!looksLikeDateTimeText(val)) {
return val;
}
if (/^0{4}-0{2}-0{2}/.test(val)) {
return val;
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
return match ? `${match[1]} ${match[2]}` : val;
};
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
if (!looksLikeDateTimeText(val)) {
return val;
}
if (/^0{4}-0{2}-0{2}/.test(val)) {
return val;
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
if (!match) {
return val;
}
const suffix = match[3] || '';
return `${match[1]} ${match[2]}${suffix}`;
};
const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp') || raw.includes('timestamptz')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'timetz' || base === 'year';
};
const isTimezoneAwareColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
return (
raw.includes('with time zone') ||
raw.includes('with timezone') ||
raw.includes('datetimeoffset') ||
raw.includes('timestamptz') ||
raw.includes('timetz')
);
};
export const normalizeTemporalLiteralText = (
value: string,
columnType?: string,
normalizeWhenTypeMissing = false,
): string => {
const rawType = String(columnType || '').trim();
if (!rawType) {
return normalizeWhenTypeMissing ? normalizeDateTimeString(value) : value;
}
if (!isTemporalColumnType(rawType)) {
return value;
}
return isTimezoneAwareColumnType(rawType)
? normalizeTimezoneAwareDateTimeString(value)
: normalizeDateTimeString(value);
};
export const formatLocalDateTimeLiteral = (value: Date): string => {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const hour = String(value.getHours()).padStart(2, '0');
const minute = String(value.getMinutes()).padStart(2, '0');
const second = String(value.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
};
const getColumnType = (columnTypesByLowerName: Record<string, string>, columnName: string): string | undefined => (
columnTypesByLowerName[String(columnName || '').toLowerCase()]
);
const getRecordValue = (
record: Record<string, any>,
columnName: string,
): { exists: boolean; value: any } => {
if (Object.prototype.hasOwnProperty.call(record || {}, columnName)) {
return { exists: true, value: record?.[columnName] };
}
const loweredColumnName = String(columnName || '').toLowerCase();
const matchedKey = Object.keys(record || {}).find((key) => key.toLowerCase() === loweredColumnName);
if (!matchedKey) {
return { exists: false, value: undefined };
}
return { exists: true, value: record?.[matchedKey] };
};
const normalizeColumnList = (columns: string[] | undefined): string[] => {
const seen = new Set<string>();
const result: string[] = [];
(columns || []).forEach((column) => {
const normalized = String(column || '').trim();
if (!normalized) return;
const lowered = normalized.toLowerCase();
if (seen.has(lowered)) return;
seen.add(lowered);
result.push(normalized);
});
return result;
};
const toNormalizedLiteralText = (value: any, columnType?: string): string => {
if (typeof value === 'string') {
return normalizeTemporalLiteralText(value, columnType, true);
}
if (value instanceof Date) {
return formatLocalDateTimeLiteral(value);
}
return String(value);
};
const formatCopySqlLiteral = (value: any, columnType?: string): string => {
if (value === null || value === undefined) {
return 'NULL';
}
return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`;
};
const doesResultCoverAllTableColumns = (orderedCols: string[], allTableColumns: string[]): boolean => {
const normalizedOrderedCols = normalizeColumnList(orderedCols);
const normalizedAllTableColumns = normalizeColumnList(allTableColumns);
if (normalizedOrderedCols.length === 0 || normalizedOrderedCols.length !== normalizedAllTableColumns.length) {
return false;
}
const orderedSet = new Set(normalizedOrderedCols.map((column) => column.toLowerCase()));
return normalizedAllTableColumns.every((column) => orderedSet.has(column.toLowerCase()));
};
const buildWhereClauseForColumns = ({
dbType,
columns,
record,
columnTypesByLowerName,
requireNonNullValues,
}: {
dbType: string;
columns: string[];
record: Record<string, any>;
columnTypesByLowerName: Record<string, string>;
requireNonNullValues: boolean;
}): string | null => {
const predicates: string[] = [];
for (const columnName of columns) {
const { exists, value } = getRecordValue(record, columnName);
if (!exists) {
return null;
}
const quotedColumn = quoteIdentPart(dbType, columnName);
if (value === null || value === undefined) {
if (requireNonNullValues) {
return null;
}
predicates.push(`${quotedColumn} IS NULL`);
continue;
}
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`);
}
if (predicates.length === 0) {
return null;
}
return `(${predicates.join(' AND ')})`;
};
const resolveMutationWhereClause = ({
dbType,
orderedCols,
record,
pkColumns = [],
uniqueKeyGroups = [],
allTableColumns = [],
columnTypesByLowerName = {},
}: BuildCopyMutationSQLParams): CopyMutationWhereClauseResult => {
const normalizedPkColumns = normalizeColumnList(pkColumns);
const pkWhereClause = buildWhereClauseForColumns({
dbType,
columns: normalizedPkColumns,
record,
columnTypesByLowerName,
requireNonNullValues: true,
});
if (pkWhereClause) {
return { ok: true, clause: pkWhereClause, whereStrategy: 'primary-key' };
}
const normalizedUniqueKeyGroups = (uniqueKeyGroups || [])
.map((group) => normalizeColumnList(group))
.filter((group) => group.length > 0);
for (const group of normalizedUniqueKeyGroups) {
const uniqueWhereClause = buildWhereClauseForColumns({
dbType,
columns: group,
record,
columnTypesByLowerName,
requireNonNullValues: true,
});
if (uniqueWhereClause) {
return { ok: true, clause: uniqueWhereClause, whereStrategy: 'unique-key' };
}
}
if (doesResultCoverAllTableColumns(orderedCols, allTableColumns)) {
const fullRowWhereClause = buildWhereClauseForColumns({
dbType,
columns: orderedCols,
record,
columnTypesByLowerName,
requireNonNullValues: false,
});
if (fullRowWhereClause) {
return { ok: true, clause: fullRowWhereClause, whereStrategy: 'all-columns' };
}
}
return {
ok: false,
error: '当前结果集缺少可安全定位行数据的主键/唯一键,且未覆盖表的全部字段,无法生成 WHERE 条件。',
};
};
export const buildCopyInsertSQL = ({
dbType,
tableName,
orderedCols,
record,
columnTypesByLowerName = {},
}: BuildCopyInsertSQLParams): string => {
const targetTable = quoteQualifiedIdent(dbType, tableName || 'table');
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
const values = orderedCols.map((col) => {
const { value } = getRecordValue(record, col);
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col));
});
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
};
const buildCopyMutationSQL = (
mode: 'update' | 'delete',
{
dbType,
tableName,
orderedCols,
record,
pkColumns = [],
uniqueKeyGroups = [],
allTableColumns = [],
columnTypesByLowerName = {},
}: BuildCopyMutationSQLParams,
): CopyMutationSQLResult => {
const normalizedTableName = String(tableName || '').trim();
const normalizedOrderedCols = normalizeColumnList(orderedCols);
if (!normalizedTableName) {
return {
ok: false,
error: `当前结果集未关联明确表名,无法生成 ${mode.toUpperCase()} SQL。`,
};
}
if (normalizedOrderedCols.length === 0) {
return {
ok: false,
error: '当前结果集没有可复制的字段,无法生成 SQL。',
};
}
const whereClause = resolveMutationWhereClause({
dbType,
orderedCols: normalizedOrderedCols,
record,
pkColumns,
uniqueKeyGroups,
allTableColumns,
columnTypesByLowerName,
});
if (whereClause.ok === false) {
return { ok: false, error: whereClause.error };
}
const targetTable = quoteQualifiedIdent(dbType, normalizedTableName);
if (mode === 'delete') {
return {
ok: true,
sql: `DELETE FROM ${targetTable} WHERE ${whereClause.clause};`,
whereStrategy: whereClause.whereStrategy,
};
}
const assignments = normalizedOrderedCols.map((columnName) => {
const { value } = getRecordValue(record, columnName);
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`;
});
return {
ok: true,
sql: `UPDATE ${targetTable} SET ${assignments.join(', ')} WHERE ${whereClause.clause};`,
whereStrategy: whereClause.whereStrategy,
};
};
export const buildCopyUpdateSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
buildCopyMutationSQL('update', params)
);
export const buildCopyDeleteSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
buildCopyMutationSQL('delete', params)
);
export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | undefined): string[][] => {
type IndexBucket = {
order: number;
columns: Array<{ columnName: string; seqInIndex: number; order: number }>;
};
const buckets = new Map<string, IndexBucket>();
(indexes || []).forEach((index, order) => {
if (index?.nonUnique !== 0) {
return;
}
const name = String(index?.name || '').trim();
const columnName = String(index?.columnName || '').trim();
if (!name || !columnName) {
return;
}
if (!buckets.has(name)) {
buckets.set(name, { order, columns: [] });
}
const bucket = buckets.get(name);
if (!bucket) {
return;
}
bucket.columns.push({
columnName,
seqInIndex: Number.isFinite(Number(index?.seqInIndex)) ? Number(index.seqInIndex) : 0,
order,
});
});
return Array.from(buckets.values())
.sort((left, right) => left.order - right.order)
.map((bucket) => {
const seen = new Set<string>();
return bucket.columns
.slice()
.sort((left, right) => {
const leftSeq = left.seqInIndex > 0 ? left.seqInIndex : Number.MAX_SAFE_INTEGER;
const rightSeq = right.seqInIndex > 0 ? right.seqInIndex : Number.MAX_SAFE_INTEGER;
if (leftSeq !== rightSeq) {
return leftSeq - rightSeq;
}
return left.order - right.order;
})
.map((item) => item.columnName)
.filter((columnName) => {
const lowered = columnName.toLowerCase();
if (seen.has(lowered)) {
return false;
}
seen.add(lowered);
return true;
});
})
.filter((group) => group.length > 0);
};

View File

@@ -1,69 +1,32 @@
import { describe, expect, it } from 'vitest';
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
if (actual !== expected) {
throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
}
};
describe('dataGridLayout helpers', () => {
it('returns zero bottom padding without horizontal overflow', () => {
expect(calculateTableBodyBottomPadding({
hasHorizontalOverflow: false,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
})).toBe(0);
});
assertEqual(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: false,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
}),
0,
'无横向滚动条时不应增加底部间距'
);
it('adds safe area when horizontal overflow exists', () => {
expect(calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
})).toBe(28);
expect(calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 14,
floatingScrollbarGap: 4,
})).toBe(30);
});
assertEqual(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
}),
28,
'默认悬浮滚动条应预留滚动条高度、间距和额外安全区'
);
assertEqual(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 14,
floatingScrollbarGap: 4,
}),
30,
'较粗滚动条场景下应同步放大底部安全区'
);
assertEqual(
calculateVirtualTableScrollX({
totalWidth: 646,
tableViewportWidth: 1200,
isMacLike: false,
}),
1200,
'列总宽小于视口时应按视口宽度返回 scroll.x避免 header/body 走两套宽度'
);
assertEqual(
calculateVirtualTableScrollX({
totalWidth: 646,
tableViewportWidth: 0,
isMacLike: false,
}),
646,
'未拿到视口宽度时应退回列宽总和'
);
assertEqual(
calculateVirtualTableScrollX({
totalWidth: 1200,
tableViewportWidth: 800,
isMacLike: true,
}),
1202,
'macOS 横向溢出时仍需额外预留 2px 以稳定滚动轨道'
);
console.log('dataGridLayout tests passed');
it('keeps scroll width aligned with viewport or content width', () => {
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 1200, isMacLike: false })).toBe(1200);
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 0, isMacLike: false })).toBe(646);
expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
describe('dataGridSelectionCopy helpers', () => {
it('builds clipboard text in visible row and column order', () => {
const text = buildSelectedCellClipboardText({
selectedCells: [
{ rowKey: 'row-2', colName: 'name' },
{ rowKey: 'row-1', colName: 'id' },
{ rowKey: 'row-1', colName: 'name' },
{ rowKey: 'row-2', colName: 'id' },
],
rows: [
{ __rowKey: 'row-1', id: 1, name: 'Alice' },
{ __rowKey: 'row-2', id: 2, name: 'Bob' },
],
columnOrder: ['id', 'name', 'email'],
rowKeyField: '__rowKey',
});
expect(text).toBe('1\tAlice\n2\tBob');
});
it('normalizes null, objects and multiline text for clipboard safety', () => {
const text = buildSelectedCellClipboardText({
selectedCells: [
{ rowKey: 'row-1', colName: 'notes' },
{ rowKey: 'row-1', colName: 'meta' },
{ rowKey: 'row-2', colName: 'notes' },
{ rowKey: 'row-2', colName: 'meta' },
],
rows: [
{ __rowKey: 'row-1', notes: null, meta: { a: 1 } },
{ __rowKey: 'row-2', notes: 'line1\nline2\tvalue', meta: [1, 2] },
],
columnOrder: ['notes', 'meta'],
rowKeyField: '__rowKey',
});
expect(text).toBe('NULL\t{"a":1}\nline1 line2 value\t[1,2]');
});
});

View File

@@ -0,0 +1,65 @@
export interface SelectedGridCell {
rowKey: string;
colName: string;
}
const normalizeClipboardCellValue = (value: unknown): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (typeof value === 'string') {
return value.replace(/\r\n/g, '\n').replace(/[\t\n\r]+/g, ' ').trim();
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
try {
return JSON.stringify(value).replace(/[\t\n\r]+/g, ' ').trim();
} catch {
return String(value).replace(/[\t\n\r]+/g, ' ').trim();
}
};
export const buildSelectedCellClipboardText = ({
selectedCells,
rows,
columnOrder,
rowKeyField,
}: {
selectedCells: SelectedGridCell[];
rows: Array<Record<string, any>>;
columnOrder: string[];
rowKeyField: string;
}): string => {
if (!selectedCells.length || !rows.length || !columnOrder.length || !rowKeyField) {
return '';
}
const selectedRowKeys = new Set(selectedCells.map((cell) => cell.rowKey));
const selectedColumnKeys = new Set(selectedCells.map((cell) => cell.colName));
const orderedRows = rows.filter((row) => selectedRowKeys.has(String(row?.[rowKeyField] ?? '')));
const orderedColumns = columnOrder.filter((columnName) => selectedColumnKeys.has(columnName));
if (!orderedRows.length || !orderedColumns.length) {
return '';
}
const selectedCellKeySet = new Set(selectedCells.map((cell) => `${cell.rowKey}::${cell.colName}`));
return orderedRows
.map((row) => {
const rowKey = String(row?.[rowKeyField] ?? '');
return orderedColumns
.map((columnName) => {
if (!selectedCellKeySet.has(`${rowKey}::${columnName}`)) {
return '';
}
return normalizeClipboardCellValue(row?.[columnName]);
})
.join('\t');
})
.join('\n');
};

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { supportsTableTruncateAction } from './tableDataDangerActions';
describe('tableDataDangerActions', () => {
it('supports native truncate for known relational dialects', () => {
expect(supportsTableTruncateAction('mysql')).toBe(true);
expect(supportsTableTruncateAction('postgres')).toBe(true);
expect(supportsTableTruncateAction('custom', 'postgresql')).toBe(true);
expect(supportsTableTruncateAction('custom', 'kingbase8')).toBe(true);
});
it('rejects truncate for unsupported or document-style backends', () => {
expect(supportsTableTruncateAction('sqlite')).toBe(false);
expect(supportsTableTruncateAction('mongodb')).toBe(false);
expect(supportsTableTruncateAction('custom', 'sqlite3')).toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
export type TableDataDangerActionKind = 'truncate' | 'clear';
const resolveCustomDriverDialect = (driver: string): string => {
const normalized = String(driver || '').trim().toLowerCase();
switch (normalized) {
case 'postgresql':
case 'postgres':
case 'pg':
case 'pq':
case 'pgx':
return 'postgres';
case 'dm':
case 'dameng':
case 'dm8':
return 'dameng';
case 'sqlite3':
case 'sqlite':
return 'sqlite';
case 'sphinxql':
return 'sphinx';
case 'diros':
case 'doris':
return 'diros';
case 'kingbase':
case 'kingbase8':
case 'kingbasees':
case 'kingbasev8':
return 'kingbase';
case 'highgo':
return 'highgo';
case 'vastbase':
return 'vastbase';
default:
break;
}
if (normalized.includes('postgres')) return 'postgres';
if (normalized.includes('kingbase')) return 'kingbase';
if (normalized.includes('highgo')) return 'highgo';
if (normalized.includes('vastbase')) return 'vastbase';
if (normalized.includes('sqlite')) return 'sqlite';
if (normalized.includes('sphinx')) return 'sphinx';
if (normalized.includes('diros') || normalized.includes('doris')) return 'diros';
return normalized;
};
export const resolveTableDataActionDBType = (type: string, driver?: string): string => {
const normalizedType = String(type || '').trim().toLowerCase();
if (normalizedType !== 'custom') {
return normalizedType;
}
return resolveCustomDriverDialect(driver || '');
};
export const supportsTableTruncateAction = (type: string, driver?: string): boolean => {
switch (resolveTableDataActionDBType(type, driver)) {
case 'mysql':
case 'mariadb':
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'sqlserver':
case 'oracle':
case 'dameng':
case 'clickhouse':
case 'duckdb':
return true;
default:
return false;
}
};
export const getTableDataDangerActionMeta = (action: TableDataDangerActionKind): {
label: string;
progressLabel: string;
} => {
if (action === 'truncate') {
return { label: '截断表', progressLabel: '截断' };
}
return { label: '清空表', progressLabel: '清空' };
};

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import {
hasIndexFormChanged,
normalizeIndexFormFromRow,
shouldRestoreOriginalIndex,
toggleIndexSelection,
type IndexDisplaySnapshot,
type IndexFormSnapshot,
} from './tableDesignerIndexUtils';
describe('tableDesignerIndexUtils', () => {
it('normalizes index rows for edit form reuse', () => {
const row: IndexDisplaySnapshot = {
key: 'idx_user_name',
name: 'idx_user_name',
indexType: 'btree',
nonUnique: 0,
columnNames: ['name'],
};
expect(normalizeIndexFormFromRow(row, ['NORMAL', 'UNIQUE', 'PRIMARY', 'FULLTEXT', 'SPATIAL'])).toEqual({
name: 'idx_user_name',
columnNames: ['name'],
kind: 'UNIQUE',
indexType: 'BTREE',
});
});
it('detects no-op index edits as unchanged', () => {
const previousForm: IndexFormSnapshot = {
name: 'idx_user_name',
columnNames: ['name'],
kind: 'UNIQUE',
indexType: 'BTREE',
};
const nextForm: IndexFormSnapshot = {
name: 'idx_user_name',
columnNames: ['name'],
kind: 'UNIQUE',
indexType: 'BTREE',
};
expect(hasIndexFormChanged(previousForm, nextForm)).toBe(false);
});
it('marks edits as changed when index columns differ', () => {
const previousForm: IndexFormSnapshot = {
name: 'idx_user_name',
columnNames: ['name'],
kind: 'NORMAL',
indexType: 'DEFAULT',
};
const nextForm: IndexFormSnapshot = {
name: 'idx_user_name',
columnNames: ['name', 'email'],
kind: 'NORMAL',
indexType: 'DEFAULT',
};
expect(hasIndexFormChanged(previousForm, nextForm)).toBe(true);
});
it('toggles selected index keys without duplicates', () => {
expect(toggleIndexSelection([], 'idx_user_name', true)).toEqual(['idx_user_name']);
expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name', true)).toEqual(['idx_user_name']);
expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name')).toEqual([]);
});
it('keeps single-selection toggles stable across repeated clicks', () => {
let selected = toggleIndexSelection([], 'idx_user_name');
expect(selected).toEqual(['idx_user_name']);
selected = toggleIndexSelection(selected, 'idx_user_name');
expect(selected).toEqual([]);
selected = toggleIndexSelection(selected, 'idx_user_name');
expect(selected).toEqual(['idx_user_name']);
selected = toggleIndexSelection(selected, 'idx_user_email');
expect(selected).toEqual(['idx_user_name', 'idx_user_email']);
selected = toggleIndexSelection(selected, 'idx_user_email');
expect(selected).toEqual(['idx_user_name']);
selected = toggleIndexSelection(selected, 'idx_user_name');
expect(selected).toEqual([]);
});
it('only restores original index when create step fails after drop step', () => {
expect(shouldRestoreOriginalIndex({ failedStatementIndex: 1 })).toBe(true);
expect(shouldRestoreOriginalIndex({ failedStatementIndex: 0 })).toBe(false);
expect(shouldRestoreOriginalIndex({})).toBe(false);
});
});

View File

@@ -0,0 +1,78 @@
export type IndexKind = 'NORMAL' | 'UNIQUE' | 'PRIMARY' | 'FULLTEXT' | 'SPATIAL';
export interface IndexDisplaySnapshot {
key: string;
name: string;
indexType: string;
nonUnique: number;
columnNames: string[];
}
export interface IndexFormSnapshot {
name: string;
columnNames: string[];
kind: IndexKind;
indexType: string;
}
export interface SchemaExecutionSnapshot {
failedStatementIndex?: number;
}
export const normalizeIndexFormFromRow = (
row: IndexDisplaySnapshot,
supportedKinds: IndexKind[],
): IndexFormSnapshot => {
const selectedName = String(row.name || '').trim();
const selectedNameUpper = selectedName.toUpperCase();
const selectedTypeUpper = String(row.indexType || '').trim().toUpperCase();
let kind: IndexKind = 'NORMAL';
if (selectedNameUpper === 'PRIMARY') {
kind = 'PRIMARY';
} else if (selectedTypeUpper === 'FULLTEXT') {
kind = 'FULLTEXT';
} else if (selectedTypeUpper === 'SPATIAL') {
kind = 'SPATIAL';
} else if (row.nonUnique === 0) {
kind = 'UNIQUE';
}
if (!supportedKinds.includes(kind)) {
kind = row.nonUnique === 0 ? 'UNIQUE' : 'NORMAL';
}
return {
name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName,
columnNames: [...row.columnNames],
kind,
indexType: kind === 'NORMAL' || kind === 'UNIQUE'
? (selectedTypeUpper || 'DEFAULT')
: 'DEFAULT',
};
};
export const hasIndexFormChanged = (
previousForm: IndexFormSnapshot,
nextForm: IndexFormSnapshot,
): boolean => {
if (previousForm.name !== nextForm.name) return true;
if (previousForm.kind !== nextForm.kind) return true;
if (previousForm.indexType !== nextForm.indexType) return true;
if (previousForm.columnNames.length !== nextForm.columnNames.length) return true;
return previousForm.columnNames.some((col, idx) => col !== nextForm.columnNames[idx]);
};
export const toggleIndexSelection = (
selectedKeys: string[],
key: string,
checked?: boolean,
): string[] => {
const exists = selectedKeys.includes(key);
const nextChecked = checked ?? !exists;
if (nextChecked) {
return exists ? selectedKeys : [...selectedKeys, key];
}
return selectedKeys.filter((item) => item !== key);
};
export const shouldRestoreOriginalIndex = (result: SchemaExecutionSnapshot): boolean => (
(result.failedStatementIndex ?? -1) > 0
);

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import {
buildAlterTablePreviewSql,
type BuildAlterTablePreviewInput,
type EditableColumnSnapshot,
} from './tableDesignerSchemaSql';
const baseColumn = (overrides: Partial<EditableColumnSnapshot>): EditableColumnSnapshot => ({
_key: overrides._key || 'col',
name: overrides.name || 'id',
type: overrides.type || 'int',
nullable: overrides.nullable || 'NO',
default: overrides.default || '',
extra: overrides.extra || '',
comment: overrides.comment || '',
key: overrides.key || '',
isAutoIncrement: overrides.isAutoIncrement || false,
});
const buildInput = (overrides: Partial<BuildAlterTablePreviewInput>): BuildAlterTablePreviewInput => ({
dbType: overrides.dbType || 'mysql',
tableName: overrides.tableName || 'users',
originalColumns: overrides.originalColumns || [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })],
columns: overrides.columns || [
baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' }),
baseColumn({ _key: 'age', name: 'age', nullable: 'YES', comment: '年龄' }),
],
});
describe('tableDesignerSchemaSql', () => {
it('keeps mysql alter preview syntax with column position clauses', () => {
const sql = buildAlterTablePreviewSql(buildInput({ dbType: 'mysql' }));
expect(sql).toContain('ALTER TABLE `users`');
expect(sql).toContain('ADD COLUMN `age` int NULL');
expect(sql).toContain("COMMENT '年龄'");
expect(sql).toContain('AFTER `id`');
});
it('builds kingbase alter preview without mysql-only syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'kingbase',
tableName: 'public.users',
}));
expect(sql).toContain('ALTER TABLE public.users');
expect(sql).toContain('ADD COLUMN age int');
expect(sql).toContain("COMMENT ON COLUMN public.users.age IS '年龄';");
expect(sql).not.toContain('`');
expect(sql).not.toContain('AFTER');
expect(sql).not.toContain(' FIRST');
});
});

View File

@@ -0,0 +1,255 @@
export interface EditableColumnSnapshot {
_key: string;
name: string;
type: string;
nullable: string;
default?: string | null;
extra?: string;
comment?: string;
key?: string;
isAutoIncrement?: boolean;
}
export interface BuildAlterTablePreviewInput {
dbType: string;
tableName: string;
originalColumns: EditableColumnSnapshot[];
columns: EditableColumnSnapshot[];
}
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
const escapeBacktickIdentifier = (value: string) => String(value || '').replace(/`/g, '``');
const escapeDoubleQuoteIdentifier = (value: string) => String(value || '').replace(/"/g, '""');
const stripIdentifierQuotes = (part: string): string => {
const text = String(part || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1).trim();
}
if (text.startsWith('[') && text.endsWith(']')) {
return text.slice(1, -1).replace(/]]/g, ']').trim();
}
return text;
};
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
if (!raw) return { schemaName: '', objectName: '' };
const idx = raw.lastIndexOf('.');
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
return {
schemaName: stripIdentifierQuotes(raw.substring(0, idx)),
objectName: stripIdentifierQuotes(raw.substring(idx + 1)),
};
};
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
const isPgLikeDialect = (dbType: string): boolean =>
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
const needsPgLikeQuote = (ident: string): boolean => !/^[a-z_][a-z0-9_]*$/.test(ident);
const quoteIdentifierPart = (part: string, dbType: string): string => {
const ident = stripIdentifierQuotes(part);
if (!ident) return '';
if (isMysqlLikeDialect(dbType)) {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isPgLikeDialect(dbType)) {
if (!needsPgLikeQuote(ident)) {
return ident;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
}
return ident;
};
const quoteIdentifierPath = (path: string, dbType: string): string =>
String(path || '')
.trim()
.split('.')
.map((part) => stripIdentifierQuotes(part))
.filter(Boolean)
.map((part) => quoteIdentifierPart(part, dbType))
.join('.');
const formatPgLikeDefault = (value: string): string => {
const trimmed = String(value || '').trim();
if (!trimmed) return '';
if (/^'.*'$/.test(trimmed)) return trimmed;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase() === 'NULL' ? 'NULL' : trimmed.toUpperCase();
if (/^(current_timestamp|current_date|current_time)$/i.test(trimmed)) return trimmed.toUpperCase();
if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return trimmed;
return `'${escapeSqlString(trimmed)}'`;
};
const buildMySqlColumnDefinition = (column: EditableColumnSnapshot): string => {
let extra = String(column.extra || '');
if (column.isAutoIncrement) {
if (!extra.toLowerCase().includes('auto_increment')) {
extra += ' AUTO_INCREMENT';
}
} else {
extra = extra.replace(/auto_increment/gi, '').trim();
}
const defaultSql = column.default ? `DEFAULT '${escapeSqlString(String(column.default))}'` : '';
return `${quoteIdentifierPart(column.name, 'mysql')} ${column.type} ${column.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${defaultSql} ${extra} COMMENT '${escapeSqlString(column.comment || '')}'`.replace(/\s+/g, ' ').trim();
};
const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot): string => {
const parts = [quoteIdentifierPart(column.name, 'postgres'), String(column.type || '').trim()];
const defaultValue = String(column.default || '').trim();
if (defaultValue) {
parts.push(`DEFAULT ${formatPgLikeDefault(defaultValue)}`);
}
if (column.nullable === 'NO') {
parts.push('NOT NULL');
}
return parts.join(' ').trim();
};
const buildPgLikeCommentSql = (tableRef: string, columnName: string, comment: string): string => {
const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, 'postgres')}`;
const trimmed = String(comment || '').trim();
if (!trimmed) {
return `COMMENT ON COLUMN ${columnRef} IS NULL;`;
}
return `COMMENT ON COLUMN ${columnRef} IS '${escapeSqlString(trimmed)}';`;
};
const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const tableName = quoteIdentifierPath(input.tableName, 'mysql');
const alters: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, 'mysql')}`);
}
});
input.columns.forEach((curr, index) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
const prevCol = index > 0 ? input.columns[index - 1] : null;
const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, 'mysql')}` : 'FIRST';
const colDef = buildMySqlColumnDefinition(curr);
if (!orig) {
alters.push(`ADD COLUMN ${colDef} ${positionSql}`.trim());
return;
}
if (
curr.name !== orig.name ||
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
curr.default !== orig.default ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
) {
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
}
});
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
if (origPKKeys.length > 0) {
alters.push('DROP PRIMARY KEY');
}
if (newPKKeys.length > 0) {
const pkNames = input.columns
.filter((col) => col.key === 'PRI')
.map((col) => quoteIdentifierPart(col.name, 'mysql'))
.join(', ');
alters.push(`ADD PRIMARY KEY (${pkNames})`);
}
}
if (alters.length === 0) {
return '';
}
return `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
};
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const tableParts = splitQualifiedName(input.tableName);
const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName);
const tableRef = quoteIdentifierPath(input.tableName, 'postgres');
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, 'postgres')};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr)};`);
if (String(curr.comment || '').trim()) {
statements.push(buildPgLikeCommentSql(tableRef, curr.name, curr.comment || ''));
}
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, 'postgres')} TO ${quoteIdentifierPart(curr.name, 'postgres')};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} TYPE ${curr.type};`);
}
const currDefault = String(curr.default || '').trim();
const origDefault = String(orig.default || '').trim();
if (currDefault !== origDefault) {
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} SET DEFAULT ${formatPgLikeDefault(currDefault)};`);
} else {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} DROP DEFAULT;`);
}
}
if (curr.nullable !== orig.nullable) {
statements.push(
`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`,
);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildPgLikeCommentSql(tableRef, currentName, curr.comment || ''));
}
});
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
if (origPKKeys.length > 0) {
statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, 'postgres')};`);
}
if (newPKKeys.length > 0) {
const pkNames = input.columns
.filter((col) => col.key === 'PRI')
.map((col) => quoteIdentifierPart(col.name, 'postgres'))
.join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`);
}
}
return statements.join('\n');
};
export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = String(input.dbType || '').trim().toLowerCase();
if (isPgLikeDialect(dbType)) {
return buildPgLikeAlterPreviewSql({ ...input, dbType });
}
return buildMySqlAlterPreviewSql({ ...input, dbType });
};

View File

@@ -3,23 +3,117 @@ import ReactDOM from 'react-dom/client'
import App from './App'
// import './index.css' // Optional global styles
// 全局配置 dayjs 使用中文 locale使 Ant Design 的 DatePicker/TimePicker 等组件
// 的月份、星期等文本显示为中文。必须在 Ant Design 组件渲染前完成配置。
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。
import 'monaco-editor/esm/nls.messages.zh-cn'
import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'
import { cloneBrowserMockValue, duplicateBrowserMockConnection, resolveBrowserMockSecretFlag } from './utils/browserMockConnections'
loader.config({ monaco })
if (typeof window !== 'undefined' && !(window as any).go) {
const mockConnections: any[] = [];
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
let mockDataRootInfo: any = {
path: 'C:/mock/.gonavi',
defaultPath: 'C:/mock/.gonavi',
driverPath: 'C:/mock/.gonavi/drivers',
isDefaultPath: true,
bootstrapPath: 'C:/mock/.gonavi/storage_root.json',
};
const upsertMockConnection = (view: any) => {
const index = mockConnections.findIndex((item) => item.id === view.id);
if (index >= 0) {
mockConnections[index] = view;
return;
}
mockConnections.push(view);
};
const saveMockConnection = (input: any) => {
const existing = mockConnections.find((item) => item.id === input?.id);
const config = (input?.config && typeof input.config === 'object') ? input.config : {};
const ssh = (config.ssh && typeof config.ssh === 'object') ? config.ssh : {};
const proxy = (config.proxy && typeof config.proxy === 'object') ? config.proxy : {};
const httpTunnel = (config.httpTunnel && typeof config.httpTunnel === 'object') ? config.httpTunnel : {};
const nextId = String(input?.id || existing?.id || `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const view = {
id: nextId,
name: String(input?.name || existing?.name || '未命名连接'),
config: {
...config,
id: nextId,
password: '',
ssh: { ...ssh, password: '' },
proxy: { ...proxy, password: '' },
httpTunnel: { ...httpTunnel, password: '' },
uri: '',
dsn: '',
mysqlReplicaPassword: '',
mongoReplicaPassword: '',
},
includeDatabases: Array.isArray(input?.includeDatabases) ? [...input.includeDatabases] : existing?.includeDatabases,
includeRedisDatabases: Array.isArray(input?.includeRedisDatabases) ? [...input.includeRedisDatabases] : existing?.includeRedisDatabases,
iconType: typeof input?.iconType === 'string' ? input.iconType : (existing?.iconType || ''),
iconColor: typeof input?.iconColor === 'string' ? input.iconColor : (existing?.iconColor || ''),
hasPrimaryPassword: resolveBrowserMockSecretFlag(config.password, !!input?.clearPrimaryPassword, existing?.hasPrimaryPassword),
hasSSHPassword: resolveBrowserMockSecretFlag(ssh.password, !!input?.clearSSHPassword, existing?.hasSSHPassword),
hasProxyPassword: resolveBrowserMockSecretFlag(proxy.password, !!input?.clearProxyPassword, existing?.hasProxyPassword),
hasHttpTunnelPassword: resolveBrowserMockSecretFlag(httpTunnel.password, !!input?.clearHttpTunnelPassword, existing?.hasHttpTunnelPassword),
hasMySQLReplicaPassword: resolveBrowserMockSecretFlag(config.mysqlReplicaPassword, !!input?.clearMySQLReplicaPassword, existing?.hasMySQLReplicaPassword),
hasMongoReplicaPassword: resolveBrowserMockSecretFlag(config.mongoReplicaPassword, !!input?.clearMongoReplicaPassword, existing?.hasMongoReplicaPassword),
hasOpaqueURI: resolveBrowserMockSecretFlag(config.uri, !!input?.clearOpaqueURI, existing?.hasOpaqueURI),
hasOpaqueDSN: resolveBrowserMockSecretFlag(config.dsn, !!input?.clearOpaqueDSN, existing?.hasOpaqueDSN),
};
upsertMockConnection(view);
return cloneBrowserMockValue(view);
};
const saveMockGlobalProxy = (input: any) => {
const nextPassword = String(input?.password ?? '');
mockGlobalProxy = {
...mockGlobalProxy,
...input,
password: '',
hasPassword: nextPassword !== '' ? true : !!mockGlobalProxy.hasPassword,
};
return cloneBrowserMockValue(mockGlobalProxy);
};
(window as any).go = {
app: {
App: {
CheckUpdate: async () => ({ success: false }),
DownloadUpdate: async () => ({ success: false }),
GetSavedConnections: async () => [],
SaveConnection: async () => null,
DeleteConnection: async () => null,
GetSavedConnections: async () => cloneBrowserMockValue(mockConnections),
SaveConnection: async (input: any) => saveMockConnection(input),
DeleteConnection: async (id: string) => {
const index = mockConnections.findIndex((item) => item.id === id);
if (index >= 0) {
mockConnections.splice(index, 1);
}
return null;
},
DuplicateConnection: async (id: string) => {
const existing = mockConnections.find((item) => item.id === id);
if (!existing) return null;
const duplicated = duplicateBrowserMockConnection({
existing,
items: mockConnections,
nextId: `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
});
mockConnections.push(duplicated);
return cloneBrowserMockValue(duplicated);
},
ImportLegacyConnections: async (items: any[]) => items.map((item) => saveMockConnection(item)),
OpenConnection: async () => null,
CloseConnection: async () => null,
GetDatabases: async () => [],
@@ -31,16 +125,32 @@ if (typeof window !== 'undefined' && !(window as any).go) {
SaveQuery: async () => null,
DeleteQuery: async () => null,
GetAppInfo: async () => ({}),
GetDataRootDirectoryInfo: async () => ({ success: true, data: cloneBrowserMockValue(mockDataRootInfo) }),
CheckForUpdates: async () => ({ success: false }),
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
OpenDriverDownloadDirectory: async (path: string) => ({ success: true, data: { path } }),
OpenDataRootDirectory: async () => ({ success: true }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false }),
ExportData: async () => ({ success: false }),
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
ImportLegacyGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
SelectDataRootDirectory: async (currentPath: string) => ({ success: true, data: { ...mockDataRootInfo, path: currentPath || mockDataRootInfo.path } }),
ApplyDataRootDirectory: async (path: string) => {
const nextPath = String(path || mockDataRootInfo.defaultPath);
mockDataRootInfo = {
...mockDataRootInfo,
path: nextPath,
driverPath: `${nextPath}/drivers`,
isDefaultPath: nextPath === mockDataRootInfo.defaultPath,
};
return { success: true, message: '数据目录已更新', data: cloneBrowserMockValue(mockDataRootInfo) };
},
}
}
};
}
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark', inherit: true, rules: [],
@@ -56,3 +166,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<App />
</React.StrictMode>,
)

17
frontend/src/node-test-shims.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
declare module 'node:fs' {
export function readFileSync(path: string | URL, encoding: string): string;
}
declare module 'node:path' {
interface PathModule {
dirname(path: string): string;
resolve(...paths: string[]): string;
}
const path: PathModule;
export default path;
}
declare module 'node:url' {
export function fileURLToPath(url: string | URL): string;
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const appCss = readFileSync(path.resolve(__dirname, './App.css'), 'utf8');
describe('sidebar tree horizontal scroll css', () => {
it('keeps the virtual tree width anchored to the sidebar by default', () => {
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-list-holder,\s*\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-list-holder-inner\s*\{[^}]*min-width:\s*100%;/s);
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-list-holder,\s*\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-list-holder-inner\s*\{[^}]*max-content/s);
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-treenode\s*\{[^}]*width:\s*auto;[^}]*min-width:\s*100%;/s);
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-treenode\s*\{[^}]*width:\s*max-content/s);
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-node-content-wrapper\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*0;/s);
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-node-content-wrapper\s*\{[^}]*max-content/s);
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-title\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*visible;/s);
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-title\s*\{[^}]*max-content/s);
});
});

View File

@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
class MemoryStorage implements Storage {
private data = new Map<string, string>();
get length(): number {
return this.data.size;
}
clear(): void {
this.data.clear();
}
getItem(key: string): string | null {
return this.data.has(key) ? this.data.get(key)! : null;
}
key(index: number): string | null {
return Array.from(this.data.keys())[index] ?? null;
}
removeItem(key: string): void {
this.data.delete(key);
}
setItem(key: string, value: string): void {
this.data.set(key, String(value));
}
}
const importStore = async () => {
const store = await import('./store');
await store.useStore.persist.rehydrate();
return store;
};
describe('store appearance persistence', () => {
let storage: MemoryStorage;
beforeEach(() => {
storage = new MemoryStorage();
vi.stubGlobal('localStorage', storage);
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
it('fills missing DataGrid appearance settings with defaults during hydration', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
appearance: {
enabled: false,
opacity: 0.75,
blur: 6,
useNativeMacWindowControls: true,
},
},
version: 7,
}));
const { useStore } = await importStore();
const appearance = useStore.getState().appearance;
expect(appearance.enabled).toBe(false);
expect(appearance.opacity).toBe(0.75);
expect(appearance.blur).toBe(6);
expect(appearance.useNativeMacWindowControls).toBe(true);
expect(appearance.showDataTableVerticalBorders).toBe(false);
expect(appearance.dataTableColumnWidthMode).toBe('standard');
});
it('persists DataGrid appearance settings and restores them after reload', async () => {
const { useStore } = await importStore();
useStore.getState().setAppearance({
showDataTableVerticalBorders: true,
dataTableColumnWidthMode: 'compact',
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.appearance.showDataTableVerticalBorders).toBe(true);
expect(persisted.state.appearance.dataTableColumnWidthMode).toBe('compact');
vi.resetModules();
const reloaded = await importStore();
const appearance = reloaded.useStore.getState().appearance;
expect(appearance.showDataTableVerticalBorders).toBe(true);
expect(appearance.dataTableColumnWidthMode).toBe('compact');
});
});

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag } from './types';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem, GlobalProxyConfig } from './types';
import {
ShortcutAction,
ShortcutBinding,
@@ -9,8 +9,27 @@ import {
cloneShortcutOptions,
sanitizeShortcutOptions,
} from './utils/shortcuts';
import { toPersistedGlobalProxy } from './utils/globalProxyDraft';
import {
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
sanitizeDataGridDisplaySettings,
type DataGridDisplaySettings,
} from './utils/dataGridDisplay';
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0 };
export interface AppearanceSettings extends DataGridDisplaySettings {
enabled: boolean;
opacity: number;
blur: number;
useNativeMacWindowControls: boolean;
}
export const DEFAULT_APPEARANCE: AppearanceSettings = {
enabled: true,
opacity: 1.0,
blur: 0,
useNativeMacWindowControls: false,
...DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
};
const DEFAULT_UI_SCALE = 1.0;
const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
@@ -25,7 +44,7 @@ const MAX_HOST_ENTRY_LENGTH = 512;
const MAX_HOST_ENTRIES = 64;
const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 3600;
const PERSIST_VERSION = 6;
const PERSIST_VERSION = 8;
const DEFAULT_CONNECTION_TYPE = 'mysql';
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
enabled: false,
@@ -34,6 +53,7 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
port: 1080,
user: '',
password: '',
hasPassword: false,
};
const SUPPORTED_CONNECTION_TYPES = new Set([
'mysql',
@@ -246,6 +266,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
const safeConfig: ConnectionConfig & Record<string, unknown> = {
...raw,
id: toTrimmedString(raw.id ?? raw.ID),
type,
host: toTrimmedString(raw.host, 'localhost') || 'localhost',
port: normalizePort(raw.port, defaultPort),
@@ -321,7 +342,16 @@ const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection
return {
id,
name,
config,
config: { ...config, id: config.id || id },
secretRef: toTrimmedString(raw.secretRef) || undefined,
hasPrimaryPassword: raw.hasPrimaryPassword === true,
hasSSHPassword: raw.hasSSHPassword === true,
hasProxyPassword: raw.hasProxyPassword === true,
hasHttpTunnelPassword: raw.hasHttpTunnelPassword === true,
hasMySQLReplicaPassword: raw.hasMySQLReplicaPassword === true,
hasMongoReplicaPassword: raw.hasMongoReplicaPassword === true,
hasOpaqueURI: raw.hasOpaqueURI === true,
hasOpaqueDSN: raw.hasOpaqueDSN === true,
includeDatabases: includeDatabases.length > 0 ? includeDatabases : undefined,
includeRedisDatabases: includeRedisDatabases.length > 0 ? includeRedisDatabases : undefined,
};
@@ -393,10 +423,6 @@ export interface QueryOptions {
showColumnType: boolean;
}
export interface GlobalProxyConfig extends ProxyConfig {
enabled: boolean;
}
interface AppState {
connections: SavedConnection[];
connectionTags: ConnectionTag[];
@@ -405,7 +431,7 @@ interface AppState {
activeContext: { connectionId: string; dbName: string } | null;
savedQueries: SavedQuery[];
theme: 'light' | 'dark';
appearance: { enabled: boolean; opacity: number; blur: number };
appearance: AppearanceSettings;
uiScale: number;
fontSize: number;
startupFullscreen: boolean;
@@ -424,9 +450,23 @@ interface AppState {
windowState: 'normal' | 'fullscreen' | 'maximized';
sidebarWidth: number;
// AI 运行时与持久化状态
aiPanelVisible: boolean;
aiChatHistory: Record<string, AIChatMessage[]>; // sessionId -> messages
replaceAIChatHistory: (sessionId: string, messages: AIChatMessage[]) => void;
aiChatSessions: { id: string; title: string; updatedAt: number }[]; // 历史会话列表
aiActiveSessionId: string | null;
updateAISessionTitle: (sessionId: string, title: string) => void;
aiContexts: Record<string, AIContextItem[]>;
addAIContext: (connectionKey: string, context: AIContextItem) => void;
removeAIContext: (connectionKey: string, dbName: string, tableName: string) => void;
clearAIContexts: (connectionKey: string) => void;
addConnection: (conn: SavedConnection) => void;
updateConnection: (conn: SavedConnection) => void;
removeConnection: (id: string) => void;
replaceConnections: (connections: SavedConnection[]) => void;
addConnectionTag: (tag: ConnectionTag) => void;
updateConnectionTag: (tag: ConnectionTag) => void;
@@ -450,11 +490,12 @@ interface AppState {
deleteQuery: (id: string) => void;
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number }>) => void;
setAppearance: (appearance: Partial<AppearanceSettings>) => void;
setUiScale: (scale: number) => void;
setFontSize: (size: number) => void;
setStartupFullscreen: (enabled: boolean) => void;
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
replaceGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
setQueryOptions: (options: Partial<QueryOptions>) => void;
updateShortcut: (action: ShortcutAction, binding: Partial<ShortcutBinding>) => void;
@@ -475,6 +516,18 @@ interface AppState {
setWindowBounds: (bounds: { width: number; height: number; x: number; y: number }) => void;
setWindowState: (state: 'normal' | 'fullscreen' | 'maximized') => void;
setSidebarWidth: (width: number) => void;
// AI actions
toggleAIPanel: () => void;
setAIPanelVisible: (visible: boolean) => void;
addAIChatMessage: (sessionId: string, message: AIChatMessage) => void;
updateAIChatMessage: (sessionId: string, messageId: string, updates: Partial<AIChatMessage>) => void;
deleteAIChatMessage: (sessionId: string, messageId: string) => void;
truncateAIChatMessages: (sessionId: string, upToMessageId: string) => void;
clearAIChatHistory: (sessionId: string) => void;
deleteAISession: (sessionId: string) => void;
createNewAISession: () => void;
setAIActiveSessionId: (sessionId: string | null) => void;
}
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
@@ -561,16 +614,22 @@ const sanitizeTableHiddenColumns = (value: unknown): Record<string, string[]> =>
};
const sanitizeAppearance = (
appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined,
appearance: Partial<AppearanceSettings> | undefined,
version: number
): { enabled: boolean; opacity: number; blur: number } => {
): AppearanceSettings => {
if (!appearance || typeof appearance !== 'object') {
return { ...DEFAULT_APPEARANCE };
}
const dataGridDisplaySettings = sanitizeDataGridDisplaySettings(appearance);
const nextAppearance = {
enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled,
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean'
? appearance.useNativeMacWindowControls
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
showDataTableVerticalBorders: dataGridDisplaySettings.showDataTableVerticalBorders,
dataTableColumnWidthMode: dataGridDisplaySettings.dataTableColumnWidthMode,
};
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
return { ...DEFAULT_APPEARANCE };
@@ -590,18 +649,24 @@ const sanitizeFontSize = (value: unknown): number => {
return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE);
};
const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
const sanitizeGlobalProxy = (
value: unknown,
options: { allowPassword?: boolean } = {}
): GlobalProxyConfig => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase();
const type: 'socks5' | 'http' = typeRaw === 'http' ? 'http' : 'socks5';
const fallbackPort = type === 'http' ? 8080 : 1080;
const password = toTrimmedString(raw.password);
return {
enabled: raw.enabled === true,
type,
host: toTrimmedString(raw.host),
port: normalizePort(raw.port, fallbackPort),
user: toTrimmedString(raw.user),
password: toTrimmedString(raw.password),
password: options.allowPassword === false ? '' : password,
hasPassword: raw.hasPassword === true || password !== '',
secretRef: toTrimmedString(raw.secretRef) || undefined,
};
};
@@ -639,6 +704,74 @@ const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknow
return raw;
};
// --- AI 会话文件持久化辅助函数 ---
/** 每个 session 独立防抖定时器2秒 */
const _persistTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function _debouncedPersistSession(sessionId: string) {
if (_persistTimers[sessionId]) clearTimeout(_persistTimers[sessionId]);
_persistTimers[sessionId] = setTimeout(() => {
delete _persistTimers[sessionId];
const state = useStore.getState();
const messages = state.aiChatHistory[sessionId];
const sessionMeta = state.aiChatSessions.find(s => s.id === sessionId);
if (!messages && !sessionMeta) return; // session 已被删除,跳过
const title = sessionMeta?.title || '新的对话';
const updatedAt = sessionMeta?.updatedAt || Date.now();
const messagesJSON = JSON.stringify(messages || []);
const Service = (window as any).go?.aiservice?.Service;
Service?.AISaveSession?.(sessionId, title, updatedAt, messagesJSON).catch((e: any) => {
console.error('[AI Session Persist] 持久化失败:', sessionId, e);
});
}, 2000);
}
/** 从后端加载会话列表(仅元数据,不含消息体) */
export async function loadAISessionsFromBackend(): Promise<{ id: string; title: string; updatedAt: number }[]> {
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AIGetSessions) return [];
try {
const sessions = await Service.AIGetSessions();
if (Array.isArray(sessions)) {
useStore.setState({ aiChatSessions: sessions });
return sessions;
}
} catch (e) {
console.error('[AI Session] 加载会话列表失败:', e);
}
return [];
}
/** 从后端加载指定会话的消息数据到内存 */
export async function loadAISessionFromBackend(sessionId: string): Promise<boolean> {
const state = useStore.getState();
// 如果内存中已有消息,跳过重复加载
if (state.aiChatHistory[sessionId]?.length > 0) return true;
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AILoadSession) return false;
try {
const result = await Service.AILoadSession(sessionId);
if (result?.success) {
let messages = result.messages;
// messages 可能是 JSON string 或已解析的数组
if (typeof messages === 'string') {
try { messages = JSON.parse(messages); } catch { messages = []; }
}
if (Array.isArray(messages)) {
useStore.setState((prev) => ({
aiChatHistory: { ...prev.aiChatHistory, [sessionId]: messages },
}));
return true;
}
}
} catch (e) {
console.error('[AI Session] 加载会话消息失败:', sessionId, e);
}
return false;
}
export const useStore = create<AppState>()(
persist(
(set) => ({
@@ -668,6 +801,13 @@ export const useStore = create<AppState>()(
windowState: 'normal' as const,
sidebarWidth: 330,
// AI 运行状态
aiPanelVisible: false,
aiChatHistory: {},
aiChatSessions: [],
aiActiveSessionId: null,
aiContexts: {},
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
updateConnection: (conn) => set((state) => ({
connections: state.connections.map(c => c.id === conn.id ? conn : c)
@@ -679,6 +819,7 @@ export const useStore = create<AppState>()(
connectionIds: tag.connectionIds.filter(cid => cid !== id)
}))
})),
replaceConnections: (connections) => set({ connections: sanitizeConnections(connections) }),
addConnectionTag: (tag) => set((state) => ({ connectionTags: [...state.connectionTags, tag] })),
updateConnectionTag: (tag) => set((state) => ({
@@ -860,6 +1001,7 @@ export const useStore = create<AppState>()(
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })),
replaceGlobalProxy: (proxy) => set({ globalProxy: sanitizeGlobalProxy({ ...DEFAULT_GLOBAL_PROXY, ...proxy }) }),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
updateShortcut: (action, binding) => set((state) => ({
@@ -947,6 +1089,152 @@ export const useStore = create<AppState>()(
setWindowState: (state) => set({ windowState: state }),
setSidebarWidth: (width) => set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }),
// AI actions
toggleAIPanel: () => set((state) => ({ aiPanelVisible: !state.aiPanelVisible })),
setAIPanelVisible: (visible) => set({ aiPanelVisible: visible }),
addAIChatMessage: (sessionId, message) => {
set((state) => {
const history = { ...state.aiChatHistory };
const messages = history[sessionId] || [];
history[sessionId] = [...messages, message];
let newSessions = [...state.aiChatSessions];
const existingSession = newSessions.find(s => s.id === sessionId);
if (!existingSession) {
let title = message.role === 'user' ? message.content : '新的对话';
if (title.length > 20) {
title = title.substring(0, 20) + '...';
}
newSessions.unshift({ id: sessionId, title, updatedAt: Date.now() });
} else {
newSessions = newSessions.filter(s => s.id !== sessionId);
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
}
return { aiChatHistory: history, aiChatSessions: newSessions };
});
// 异步持久化到文件fire-and-forget防抖由外层控制
_debouncedPersistSession(sessionId);
},
updateAIChatMessage: (sessionId, messageId, updates) => {
set((state) => {
const messages = state.aiChatHistory[sessionId];
if (!messages) return state;
const idx = messages.findIndex(m => m.id === messageId);
if (idx < 0) return state;
const newMessages = [...messages];
newMessages[idx] = { ...newMessages[idx], ...updates };
const history = { ...state.aiChatHistory, [sessionId]: newMessages };
const isContentOnlyUpdate = Object.keys(updates).length === 1 && 'content' in updates;
if (!isContentOnlyUpdate) {
let newSessions = [...state.aiChatSessions];
const existingSession = newSessions.find(s => s.id === sessionId);
if (existingSession) {
newSessions = newSessions.filter(s => s.id !== sessionId);
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
}
return { aiChatHistory: history, aiChatSessions: newSessions };
}
return { aiChatHistory: history };
});
// 流式打字高频调用,防抖 2 秒后才写磁盘
_debouncedPersistSession(sessionId);
},
deleteAIChatMessage: (sessionId, messageId) => {
set((state) => {
const history = { ...state.aiChatHistory };
if (history[sessionId]) {
history[sessionId] = history[sessionId].filter(m => m.id !== messageId);
}
return { aiChatHistory: history };
});
_debouncedPersistSession(sessionId);
},
truncateAIChatMessages: (sessionId, upToMessageId) => {
set((state) => {
const history = { ...state.aiChatHistory };
const messages = history[sessionId];
if (messages) {
const idx = messages.findIndex(m => m.id === upToMessageId);
if (idx >= 0) {
history[sessionId] = messages.slice(0, idx + 1);
}
}
return { aiChatHistory: history };
});
_debouncedPersistSession(sessionId);
},
clearAIChatHistory: (sessionId) => {
set((state) => {
const history = { ...state.aiChatHistory };
delete history[sessionId];
return { aiChatHistory: history };
});
_debouncedPersistSession(sessionId);
},
replaceAIChatHistory: (sessionId, messages) => {
set((state) => {
const history = { ...state.aiChatHistory };
history[sessionId] = messages;
return { aiChatHistory: history };
});
_debouncedPersistSession(sessionId);
},
deleteAISession: (sessionId) => {
set((state) => {
const history = { ...state.aiChatHistory };
delete history[sessionId];
const newSessions = state.aiChatSessions.filter(s => s.id !== sessionId);
const newActive = state.aiActiveSessionId === sessionId ? null : state.aiActiveSessionId;
return { aiChatHistory: history, aiChatSessions: newSessions, aiActiveSessionId: newActive };
});
// 删除文件
const Service = (window as any).go?.aiservice?.Service;
Service?.AIDeleteSession?.(sessionId).catch(() => {});
},
createNewAISession: () => set(() => {
const newId = `session-${Date.now()}`;
return { aiActiveSessionId: newId };
}),
setAIActiveSessionId: (sessionId) => set({ aiActiveSessionId: sessionId }),
updateAISessionTitle: (sessionId, title) => {
set((state) => {
const newSessions = [...state.aiChatSessions];
const session = newSessions.find(s => s.id === sessionId);
if (session) {
session.title = title;
}
return { aiChatSessions: newSessions };
});
_debouncedPersistSession(sessionId);
},
addAIContext: (connectionKey, context) => set((state) => {
const contexts = state.aiContexts[connectionKey] || [];
if (contexts.find(c => c.dbName === context.dbName && c.tableName === context.tableName)) {
return state;
}
return {
aiContexts: {
...state.aiContexts,
[connectionKey]: [...contexts, context]
}
};
}),
removeAIContext: (connectionKey, dbName, tableName) => set((state) => {
const contexts = state.aiContexts[connectionKey] || [];
return {
aiContexts: {
...state.aiContexts,
[connectionKey]: contexts.filter(c => !(c.dbName === dbName && c.tableName === tableName))
}
};
}),
clearAIContexts: (connectionKey) => set((state) => {
const { [connectionKey]: _, ...rest } = state.aiContexts;
return { aiContexts: rest };
}),
}),
{
name: 'lite-db-storage', // name of the item in the storage (must be unique)
@@ -954,7 +1242,7 @@ export const useStore = create<AppState>()(
migrate: (persistedState: unknown, version: number) => {
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
const nextState: Partial<AppState> = { ...state };
nextState.connections = sanitizeConnections(state.connections);
nextState.connections = [];
if (version < 5) {
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
} else {
@@ -966,7 +1254,7 @@ export const useStore = create<AppState>()(
nextState.uiScale = sanitizeUiScale(state.uiScale);
nextState.fontSize = sanitizeFontSize(state.fontSize);
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy, { allowPassword: false });
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions);
@@ -982,6 +1270,10 @@ export const useStore = create<AppState>()(
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
nextState.windowState = sanitizeWindowState(state.windowState);
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
// 保留原有的 AI 持久化记录,或者为空(版本兼容)
nextState.aiChatHistory = (state.aiChatHistory && typeof state.aiChatHistory === 'object') ? state.aiChatHistory : {};
nextState.aiChatSessions = Array.isArray(state.aiChatSessions) ? state.aiChatSessions : [];
return nextState as AppState;
},
merge: (persistedState, currentState) => {
@@ -989,7 +1281,7 @@ export const useStore = create<AppState>()(
return {
...currentState,
...state,
connections: sanitizeConnections(state.connections),
connections: currentState.connections,
connectionTags: sanitizeConnectionTags(state.connectionTags),
savedQueries: sanitizeSavedQueries(state.savedQueries),
theme: sanitizeTheme(state.theme),
@@ -997,7 +1289,7 @@ export const useStore = create<AppState>()(
uiScale: sanitizeUiScale(state.uiScale),
fontSize: sanitizeFontSize(state.fontSize),
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
globalProxy: sanitizeGlobalProxy(state.globalProxy),
globalProxy: sanitizeGlobalProxy(state.globalProxy, { allowPassword: false }),
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
@@ -1011,10 +1303,13 @@ export const useStore = create<AppState>()(
queryOptions: sanitizeQueryOptions(state.queryOptions),
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
// AI 会话数据不再从 localStorage 恢复,改为从后端文件加载
aiChatHistory: {},
aiChatSessions: [],
};
},
partialize: (state) => ({
connections: state.connections,
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
theme: state.theme,
@@ -1022,7 +1317,7 @@ export const useStore = create<AppState>()(
uiScale: state.uiScale,
fontSize: state.fontSize,
startupFullscreen: state.startupFullscreen,
globalProxy: state.globalProxy,
globalProxy: toPersistedGlobalProxy(state.globalProxy),
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: state.shortcutOptions,
@@ -1035,6 +1330,8 @@ export const useStore = create<AppState>()(
windowBounds: state.windowBounds,
windowState: state.windowState,
sidebarWidth: state.sidebarWidth,
// AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage
}), // Don't persist logs
}
)

View File

@@ -22,6 +22,7 @@ export interface HTTPTunnelConfig {
}
export interface ConnectionConfig {
id?: string;
type: string;
host: string;
port: number;
@@ -70,12 +71,27 @@ export interface SavedConnection {
id: string;
name: string;
config: ConnectionConfig;
secretRef?: string;
hasPrimaryPassword?: boolean;
hasSSHPassword?: boolean;
hasProxyPassword?: boolean;
hasHttpTunnelPassword?: boolean;
hasMySQLReplicaPassword?: boolean;
hasMongoReplicaPassword?: boolean;
hasOpaqueURI?: boolean;
hasOpaqueDSN?: boolean;
includeDatabases?: string[];
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
}
export interface GlobalProxyConfig extends ProxyConfig {
enabled: boolean;
hasPassword?: boolean;
secretRef?: string;
}
export interface ConnectionTag {
id: string;
name: string;
@@ -118,7 +134,7 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
connectionId: string;
dbName?: string;
tableName?: string;
@@ -183,3 +199,67 @@ export interface StreamEntry {
id: string;
fields: Record<string, string>;
}
// --- AI Types ---
export type AIProviderType = 'openai' | 'anthropic' | 'gemini' | 'custom';
export type AISafetyLevel = 'readonly' | 'readwrite' | 'full';
export type AIContextLevel = 'schema_only' | 'with_samples' | 'with_results';
export interface AIContextItem {
dbName: string;
tableName: string;
ddl: string;
}
export interface AIProviderConfig {
id: string;
type: AIProviderType;
name: string;
apiKey: string;
secretRef?: string;
hasSecret?: boolean;
baseUrl: string;
model: string;
models?: string[];
apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli
headers?: Record<string, string>;
maxTokens: number;
temperature: number;
}
export interface AIToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string;
};
}
export type ChatPhase = 'idle' | 'connecting' | 'thinking' | 'generating' | 'tool_calling';
export interface AIChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
phase?: ChatPhase;
content: string;
thinking?: string;
timestamp: number;
loading?: boolean;
images?: string[]; // base64 encoded images with data URI prefix
tool_calls?: AIToolCall[];
tool_call_id?: string;
tool_name?: string; // used for UI display
rawError?: string; // 存储未清洗的原始错误信息,用于用户复制排查
success?: boolean; // 标记探针执行是否成功
}
export interface AISafetyResult {
allowed: boolean;
operationType: 'query' | 'dml' | 'ddl' | 'other';
requiresConfirm: boolean;
warningMessage?: string;
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import {
buildModelFetchFailedNotice,
buildMissingModelNotice,
buildMissingProviderNotice,
} from './aiComposerNotice';
describe('ai composer notice helpers', () => {
it('builds a compact notice for missing provider', () => {
expect(buildMissingProviderNotice()).toEqual({
tone: 'warning',
title: '还没有可用供应商',
description: '先在 AI 设置里添加并启用一个模型供应商。',
});
});
it('builds a compact notice for missing model selection', () => {
expect(buildMissingModelNotice()).toEqual({
tone: 'warning',
title: '先选择一个模型',
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
});
});
it('builds a readable inline notice for model fetch failures', () => {
expect(buildModelFetchFailedNotice('当前接口未返回可用模型')).toEqual({
tone: 'error',
title: '模型列表加载失败',
description: '当前接口未返回可用模型',
});
});
});

View File

@@ -0,0 +1,27 @@
export type AIComposerNoticeTone = 'warning' | 'error';
export interface AIComposerNotice {
tone: AIComposerNoticeTone;
title: string;
description: string;
}
const defaultModelFetchFailedDescription = '请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。';
export const buildMissingProviderNotice = (): AIComposerNotice => ({
tone: 'warning',
title: '还没有可用供应商',
description: '先在 AI 设置里添加并启用一个模型供应商。',
});
export const buildMissingModelNotice = (): AIComposerNotice => ({
tone: 'warning',
title: '先选择一个模型',
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
});
export const buildModelFetchFailedNotice = (error?: string): AIComposerNotice => ({
tone: 'error',
title: '模型列表加载失败',
description: String(error || '').trim() || defaultModelFetchFailedDescription,
});

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import {
SIDEBAR_UTILITY_ITEM_KEYS,
resolveAIEntryPlacement,
resolveAIEdgeHandleAttachment,
resolveAIEdgeHandleDockStyle,
resolveAIEdgeHandleStyle,
} from './aiEntryLayout';
describe('ai entry layout', () => {
it('keeps the sidebar utility group free of the AI entry', () => {
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']);
});
it('anchors the AI entry to the content edge', () => {
expect(resolveAIEntryPlacement()).toBe('content-edge');
});
it('attaches the closed handle to the content shell', () => {
expect(resolveAIEdgeHandleAttachment(false)).toBe('content-shell');
});
it('attaches the open handle to the panel shell', () => {
expect(resolveAIEdgeHandleAttachment(true)).toBe('panel-shell');
});
it('keeps the closed handle docked on the content edge', () => {
expect(resolveAIEdgeHandleDockStyle('content-shell')).toMatchObject({
position: 'absolute',
top: 16,
right: 0,
zIndex: 12,
});
});
it('keeps the open handle outside the panel shell to avoid header overlap', () => {
expect(resolveAIEdgeHandleDockStyle('panel-shell')).toMatchObject({
position: 'absolute',
top: 16,
right: '100%',
zIndex: 12,
});
});
it('uses the attached active appearance when the AI panel is open', () => {
const style = resolveAIEdgeHandleStyle({
darkMode: true,
aiPanelVisible: true,
effectiveUiScale: 1,
});
expect(style.color).toBe('#ffd666');
expect(style.background).toBe('rgba(255,214,102,0.12)');
expect(style.borderRadius).toBe('10px 0 0 10px');
expect(style.borderRight).toBe('none');
expect(style.height).toBe(24);
});
it('uses the subdued attached appearance when the AI panel is closed', () => {
const style = resolveAIEdgeHandleStyle({
darkMode: false,
aiPanelVisible: false,
effectiveUiScale: 1,
});
expect(style.color).toBe('rgba(22,32,51,0.82)');
expect(style.background).toBe('rgba(15,23,42,0.04)');
expect(style.paddingInline).toBe(8);
expect(style.borderRadius).toBe('10px 0 0 10px');
});
});

View File

@@ -0,0 +1,59 @@
import type { CSSProperties } from 'react';
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
export type AIEntryPlacement = 'content-edge';
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
export interface ResolveAIEdgeHandleStyleInput {
darkMode: boolean;
aiPanelVisible: boolean;
effectiveUiScale: number;
}
export const resolveAIEntryPlacement = (): AIEntryPlacement => 'content-edge';
export const resolveAIEdgeHandleAttachment = (
aiPanelVisible: boolean,
): AIEdgeHandleAttachment => (aiPanelVisible ? 'panel-shell' : 'content-shell');
export const resolveAIEdgeHandleDockStyle = (
attachment: AIEdgeHandleAttachment,
): CSSProperties => ({
position: 'absolute',
top: 16,
right: attachment === 'panel-shell' ? '100%' : 0,
zIndex: 12,
});
export const resolveAIEdgeHandleStyle = ({
darkMode,
aiPanelVisible,
effectiveUiScale,
}: ResolveAIEdgeHandleStyleInput): CSSProperties => {
const inactiveColor = darkMode ? 'rgba(255,255,255,0.86)' : 'rgba(22,32,51,0.82)';
const inactiveBackground = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)';
const inactiveBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)';
return {
height: Math.max(24, Math.round(24 * effectiveUiScale)),
paddingInline: Math.max(8, Math.round(8 * effectiveUiScale)),
borderRadius: '10px 0 0 10px',
border: `1px solid ${aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.18)') : inactiveBorder}`,
borderRight: 'none',
background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)') : inactiveBackground,
color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : inactiveColor,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: Math.max(4, Math.round(4 * effectiveUiScale)),
fontSize: Math.max(12, Math.round(12 * effectiveUiScale)),
fontWeight: 600,
lineHeight: 1,
boxShadow: 'none',
backdropFilter: 'none',
WebkitBackdropFilter: 'none',
whiteSpace: 'nowrap',
flexShrink: 0,
};
};

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import {
buildAddProviderEditorSession,
buildClosedProviderEditorSession,
buildEditProviderEditorSession,
} from './aiProviderEditorState';
describe('aiProviderEditorState', () => {
it('resets clearProviderSecret when starting add flow', () => {
const session = buildAddProviderEditorSession({
previousClearProviderSecret: true,
presetBackendType: 'openai',
presetBaseUrl: 'https://api.openai.com/v1',
presetModel: 'gpt-4.1',
});
expect(session.clearProviderSecret).toBe(false);
expect(session.isEditing).toBe(true);
expect(session.testStatus).toBe('idle');
});
it('resets clearProviderSecret when starting edit flow', () => {
const session = buildEditProviderEditorSession({
previousClearProviderSecret: true,
provider: {
id: 'provider-1',
type: 'openai',
name: 'OpenAI',
apiKey: '',
hasSecret: true,
},
});
expect(session.clearProviderSecret).toBe(false);
expect(session.isEditing).toBe(true);
expect(session.editingProvider?.id).toBe('provider-1');
});
it('resets clearProviderSecret when the modal closes', () => {
const session = buildClosedProviderEditorSession({
previousClearProviderSecret: true,
});
expect(session.clearProviderSecret).toBe(false);
expect(session.isEditing).toBe(false);
expect(session.editingProvider).toBeNull();
});
});

View File

@@ -0,0 +1,92 @@
import type { AIProviderConfig, AIProviderType } from '../types';
type ProviderEditorStatus = 'idle' | 'success' | 'error';
type ProviderEditorConfig = Partial<AIProviderConfig> & Pick<AIProviderConfig, 'id' | 'type' | 'name' | 'apiKey'> & { presetKey?: string };
export interface ProviderEditorSession {
editingProvider: ProviderEditorConfig | null;
formValues: Record<string, unknown> | null;
isEditing: boolean;
clearProviderSecret: boolean;
testStatus: ProviderEditorStatus;
}
interface BuildAddProviderEditorSessionInput {
previousClearProviderSecret?: boolean;
presetKey?: string;
presetBackendType: AIProviderType;
presetBaseUrl: string;
presetModel: string;
presetModels?: string[];
apiFormat?: string;
}
interface BuildEditProviderEditorSessionInput {
previousClearProviderSecret?: boolean;
provider: ProviderEditorConfig;
formValues?: Record<string, unknown>;
}
interface BuildClosedProviderEditorSessionInput {
previousClearProviderSecret?: boolean;
}
export const buildAddProviderEditorSession = ({
presetKey = 'openai',
presetBackendType,
presetBaseUrl,
presetModel,
presetModels = [],
apiFormat = 'openai',
}: BuildAddProviderEditorSessionInput): ProviderEditorSession => {
const editingProvider: ProviderEditorConfig = {
id: '',
type: presetBackendType,
name: '',
apiKey: '',
baseUrl: presetBaseUrl,
model: presetModel,
models: [...presetModels],
maxTokens: 4096,
temperature: 0.7,
presetKey,
};
return {
editingProvider,
formValues: {
...editingProvider,
presetKey,
apiFormat,
},
isEditing: true,
clearProviderSecret: false,
testStatus: 'idle',
};
};
export const buildEditProviderEditorSession = ({
provider,
formValues,
}: BuildEditProviderEditorSessionInput): ProviderEditorSession => ({
editingProvider: provider,
formValues: formValues || {
...provider,
models: provider.models || [],
presetKey: provider.presetKey,
apiFormat: provider.apiFormat || 'openai',
},
isEditing: true,
clearProviderSecret: false,
testStatus: 'idle',
});
export const buildClosedProviderEditorSession = (_input?: BuildClosedProviderEditorSessionInput): ProviderEditorSession => ({
editingProvider: null,
formValues: null,
isEditing: false,
clearProviderSecret: false,
testStatus: 'idle',
});

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest';
import type { AIProviderType } from '../types';
import {
LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_BAILIAN_MODELS_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_MODELS,
matchQwenPresetKey,
resolvePresetBaseURL,
resolvePresetModelSelection,
resolvePresetTransport,
resolveProviderPresetKey,
} from './aiProviderPresets';
type PresetMatcher = {
key: string;
backendType: AIProviderType;
defaultBaseUrl: string;
fixedApiFormat?: string;
};
const PRESETS: PresetMatcher[] = [
{ key: 'openai', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1' },
{ key: 'qwen-bailian', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL },
{
key: 'qwen-coding-plan',
backendType: 'custom',
defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
fixedApiFormat: 'claude-cli',
},
{ key: 'custom', backendType: 'custom', defaultBaseUrl: '' },
];
describe('ai provider preset helpers', () => {
it('maps legacy Bailian compatible-mode URL back to the Bailian preset', () => {
expect(matchQwenPresetKey({
type: 'openai',
baseUrl: QWEN_BAILIAN_MODELS_BASE_URL,
})).toBe('qwen-bailian');
});
it('maps Coding Plan Claude CLI config back to the dedicated Coding Plan preset', () => {
expect(matchQwenPresetKey({
type: 'custom',
apiFormat: 'claude-cli',
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
})).toBe('qwen-coding-plan');
});
it('maps legacy Coding Plan OpenAI config back to the dedicated Coding Plan preset', () => {
expect(matchQwenPresetKey({
type: 'openai',
baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
})).toBe('qwen-coding-plan');
});
it('does not treat a custom OpenAI endpoint as the built-in Coding Plan preset', () => {
expect(matchQwenPresetKey({
type: 'custom',
apiFormat: 'openai',
baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
})).toBeNull();
});
it('does not keep a baked-in model list for the Coding Plan preset', () => {
expect(QWEN_CODING_PLAN_MODELS).toEqual([
'qwen3.5-plus',
'kimi-k2.5',
'glm-5',
'MiniMax-M2.5',
'qwen3-max-2026-01-23',
'qwen3-coder-next',
'qwen3-coder-plus',
'glm-4.7',
]);
});
it('keeps built-in preset model empty when the preset intentionally requires an explicit selection', () => {
expect(resolvePresetModelSelection({
presetKey: 'qwen-coding-plan',
presetDefaultModel: '',
presetModels: QWEN_CODING_PLAN_MODELS,
valuesModel: '',
customModels: [],
})).toEqual({
model: '',
models: QWEN_CODING_PLAN_MODELS,
});
});
it('still falls back to the first configured model for custom-like presets', () => {
expect(resolvePresetModelSelection({
presetKey: 'custom',
presetDefaultModel: '',
presetModels: [],
valuesModel: '',
customModels: ['foo-model', 'bar-model'],
})).toEqual({
model: 'foo-model',
models: ['foo-model', 'bar-model'],
});
});
it('forces built-in presets back to their standard base URL when saving or testing', () => {
expect(resolvePresetBaseURL({
presetKey: 'qwen-bailian',
presetDefaultBaseUrl: 'https://dashscope.aliyuncs.com/apps/anthropic',
valuesBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
})).toBe('https://dashscope.aliyuncs.com/apps/anthropic');
});
it('keeps the user-entered base URL for custom-like presets', () => {
expect(resolvePresetBaseURL({
presetKey: 'custom',
presetDefaultBaseUrl: '',
valuesBaseUrl: 'https://example-proxy.internal/v1',
})).toBe('https://example-proxy.internal/v1');
});
it('forces qwen coding plan to save as custom plus claude-cli', () => {
expect(resolvePresetTransport({
presetBackendType: 'custom',
presetFixedApiFormat: 'claude-cli',
valuesApiFormat: 'anthropic',
})).toEqual({
type: 'custom',
apiFormat: 'claude-cli',
});
});
it('keeps custom preset transport editable', () => {
expect(resolvePresetTransport({
presetBackendType: 'custom',
valuesApiFormat: 'gemini',
})).toEqual({
type: 'custom',
apiFormat: 'gemini',
});
});
});
describe('resolveProviderPresetKey', () => {
it('不会把自定义 OpenAI 端点误识别成千问 Coding Plan', () => {
const key = resolveProviderPresetKey(
{
type: 'custom',
apiFormat: 'openai',
baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
},
PRESETS,
'custom',
);
expect(key).toBe('custom');
});
it('仍然能识别当前内置的千问 Coding Plan 预设', () => {
const key = resolveProviderPresetKey(
{
type: 'custom',
apiFormat: 'claude-cli',
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
},
PRESETS,
'custom',
);
expect(key).toBe('qwen-coding-plan');
});
it('仍然能识别当前内置的千问百炼预设', () => {
const key = resolveProviderPresetKey(
{
type: 'anthropic',
apiFormat: undefined,
baseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL,
},
PRESETS,
'custom',
);
expect(key).toBe('qwen-bailian');
});
});

View File

@@ -0,0 +1,216 @@
import type { AIProviderConfig, AIProviderType } from '../types';
export const LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
export const LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL = 'https://coding.dashscope.aliyuncs.com/v1';
export const QWEN_BAILIAN_ANTHROPIC_BASE_URL = 'https://dashscope.aliyuncs.com/apps/anthropic';
export const QWEN_CODING_PLAN_ANTHROPIC_BASE_URL = 'https://coding.dashscope.aliyuncs.com/apps/anthropic';
export const QWEN_BAILIAN_MODELS_BASE_URL = LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL;
export const QWEN_CODING_PLAN_MODELS = [
'qwen3.5-plus',
'kimi-k2.5',
'glm-5',
'MiniMax-M2.5',
'qwen3-max-2026-01-23',
'qwen3-coder-next',
'qwen3-coder-plus',
'glm-4.7',
];
const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']);
export interface ResolvePresetModelSelectionInput {
presetKey: string;
presetDefaultModel: string;
presetModels: string[];
valuesModel?: string;
customModels?: string[];
}
export interface ResolvePresetModelSelectionResult {
model: string;
models: string[];
}
export interface ResolvePresetBaseURLInput {
presetKey: string;
presetDefaultBaseUrl: string;
valuesBaseUrl?: string;
}
export interface ResolvePresetTransportInput {
presetBackendType: AIProviderType;
presetFixedApiFormat?: string;
valuesApiFormat?: string;
}
export interface ResolvePresetTransportResult {
type: AIProviderType;
apiFormat?: string;
}
export interface ProviderPresetMatcher {
key: string;
backendType: AIProviderType;
defaultBaseUrl: string;
fixedApiFormat?: string;
}
export const getProviderHostname = (raw?: string): string => {
if (!raw) return '';
try {
return new URL(raw).hostname.toLowerCase();
} catch {
return '';
}
};
export const getProviderFingerprint = (raw?: string): string => {
if (!raw) return '';
try {
const url = new URL(raw);
const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase();
return `${url.hostname.toLowerCase()}${normalizedPath}`;
} catch {
return '';
}
};
export const matchQwenPresetKey = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): string | null => {
const fingerprint = getProviderFingerprint(provider.baseUrl);
if (
fingerprint !== ''
&& fingerprint === getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL)
&& provider.type === 'anthropic'
) {
return 'qwen-bailian';
}
if (
fingerprint !== ''
&& fingerprint === getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL)
&& provider.type === 'openai'
) {
return 'qwen-bailian';
}
if (
fingerprint !== ''
&& fingerprint === getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL)
&& provider.type === 'custom'
&& provider.apiFormat === 'claude-cli'
) {
return 'qwen-coding-plan';
}
if (
fingerprint !== ''
&& fingerprint === getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL)
&& provider.type === 'openai'
) {
return 'qwen-coding-plan';
}
return null;
};
export const resolveProviderPresetKey = (
provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>,
presets: ProviderPresetMatcher[],
fallbackKey = 'custom',
): string => {
const qwenPresetKey = matchQwenPresetKey(provider);
if (qwenPresetKey) {
return qwenPresetKey;
}
const fingerprint = getProviderFingerprint(provider.baseUrl);
const exactPreset = presets.find((preset) =>
preset.backendType === provider.type
&& fingerprint !== ''
&& fingerprint === getProviderFingerprint(preset.defaultBaseUrl)
&& (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat),
);
if (exactPreset) {
return exactPreset.key;
}
// custom 供应商必须保守处理,避免仅凭 host 错误吞掉用户显式保存的自定义配置。
if (provider.type === 'custom') {
return fallbackKey;
}
const host = getProviderHostname(provider.baseUrl);
if (provider.type === 'anthropic' && host.endsWith('moonshot.cn')) {
const moonshotPreset = presets.find((preset) => preset.key === 'moonshot');
if (moonshotPreset) {
return moonshotPreset.key;
}
}
const hostPreset = presets.find((preset) =>
preset.backendType === provider.type
&& host !== ''
&& host === getProviderHostname(preset.defaultBaseUrl)
&& (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat),
);
if (hostPreset) {
return hostPreset.key;
}
const typePreset = presets.find((preset) => preset.backendType === provider.type && !preset.fixedApiFormat);
return typePreset?.key || fallbackKey;
};
export const resolvePresetModelSelection = ({
presetKey,
presetDefaultModel,
presetModels,
valuesModel,
customModels,
}: ResolvePresetModelSelectionInput): ResolvePresetModelSelectionResult => {
const isCustomLike = CUSTOM_LIKE_PRESET_KEYS.has(presetKey);
const resolvedModels = isCustomLike ? (customModels || []) : presetModels;
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
return {
models: resolvedModels,
model: isCustomLike ? (valuesModel || fallbackModel) : (valuesModel || presetDefaultModel),
};
};
export const resolvePresetBaseURL = ({
presetKey,
presetDefaultBaseUrl,
valuesBaseUrl,
}: ResolvePresetBaseURLInput): string => {
if (CUSTOM_LIKE_PRESET_KEYS.has(presetKey)) {
return valuesBaseUrl || presetDefaultBaseUrl;
}
return presetDefaultBaseUrl;
};
export const resolvePresetTransport = ({
presetBackendType,
presetFixedApiFormat,
valuesApiFormat,
}: ResolvePresetTransportInput): ResolvePresetTransportResult => {
if (presetFixedApiFormat) {
return {
type: presetBackendType,
apiFormat: presetFixedApiFormat,
};
}
if (presetBackendType === 'custom') {
return {
type: presetBackendType,
apiFormat: valuesApiFormat || 'openai',
};
}
return {
type: presetBackendType,
apiFormat: undefined,
};
};

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
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 './aiSettingsPresetLayout';
describe('ai settings preset layout', () => {
it('uses a fixed grid auto row height so provider bubbles stay visually consistent across rows', () => {
expect(PROVIDER_PRESET_GRID_STYLE).toMatchObject({
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: 6,
gridAutoRows: '96px',
alignItems: 'stretch',
});
});
it('stretches each provider card to fill the row height', () => {
expect(PROVIDER_PRESET_CARD_BASE_STYLE).toMatchObject({
display: 'flex',
alignItems: 'flex-start',
gap: 10,
height: '100%',
minHeight: '96px',
overflow: 'hidden',
});
});
it('keeps the text column compact instead of pinning the description to the bottom', () => {
expect(PROVIDER_PRESET_CARD_CONTENT_STYLE).toMatchObject({
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
});
expect(PROVIDER_PRESET_CARD_DESCRIPTION_STYLE).toMatchObject({
marginTop: 4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
});
expect(PROVIDER_PRESET_CARD_TITLE_STYLE).toMatchObject({
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
});
});
});

View File

@@ -0,0 +1,47 @@
import type { CSSProperties } from 'react';
export const PROVIDER_PRESET_CARD_HEIGHT = 96;
export const PROVIDER_PRESET_GRID_STYLE: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: 6,
gridAutoRows: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
alignItems: 'stretch',
};
export const PROVIDER_PRESET_CARD_BASE_STYLE: CSSProperties = {
padding: '12px 14px',
borderRadius: 12,
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'flex-start',
gap: 10,
height: '100%',
minHeight: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
boxSizing: 'border-box',
overflow: 'hidden',
};
export const PROVIDER_PRESET_CARD_CONTENT_STYLE: CSSProperties = {
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
};
export const PROVIDER_PRESET_CARD_DESCRIPTION_STYLE: CSSProperties = {
marginTop: 4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
};
export const PROVIDER_PRESET_CARD_TITLE_STYLE: CSSProperties = {
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
};

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
describe('appearance helpers', () => {
it('falls back to opaque non-blurred appearance when disabled', () => {
expect(resolveAppearanceValues({ enabled: false, opacity: 0.3, blur: 12 })).toEqual({ opacity: 1, blur: 0 });
});
it('preserves configured values when appearance is enabled', () => {
expect(resolveAppearanceValues({ enabled: true, opacity: 0.72, blur: 9 })).toEqual({ opacity: 0.72, blur: 9 });
});
it('caps opacity at full opacity upper bound', () => {
expect(normalizeOpacityForPlatform(2)).toBe(1);
});
it('never returns negative blur and formats blur filter correctly', () => {
expect(normalizeBlurForPlatform(-4)).toBe(0);
expect(blurToFilter(0)).toBeUndefined();
expect(blurToFilter(8)).toBe('blur(8px)');
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import {
buildOracleApproximateTotalSql,
parseApproximateTableCountRow,
resolveApproximateTableCountStrategy,
} from './approximateTableCount';
describe('approximateTableCount', () => {
it('uses oracle metadata approximate total only for unfiltered full-table preview', () => {
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: '' })).toBe('oracle-num-rows');
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: 'WHERE id = 1' })).toBe('none');
});
it('keeps duckdb approximate count on unfiltered previews', () => {
expect(resolveApproximateTableCountStrategy({ dbType: 'duckdb', whereSQL: '' })).toBe('duckdb-estimated-size');
});
it('builds Oracle approx count SQL from owner and table name', () => {
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("owner = 'HR'");
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("table_name = 'EMPLOYEES'");
});
it('parses approximate total rows using preferred keys', () => {
expect(parseApproximateTableCountRow({ NUM_ROWS: '1234' }, ['num_rows'])).toBe(1234);
expect(parseApproximateTableCountRow({ approx_total: 5678 }, ['approx_total'])).toBe(5678);
});
});

View File

@@ -0,0 +1,106 @@
export type ApproximateTableCountStrategy = 'none' | 'duckdb-estimated-size' | 'oracle-num-rows';
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
if (typeof value === 'number') {
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
}
if (typeof value === 'bigint') {
return value >= 0n && value <= MAX_SAFE_BIGINT ? Number(value) : null;
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) return null;
if (/^[+-]?\d+$/.test(text)) {
try {
const parsed = BigInt(text);
return parsed >= 0n && parsed <= MAX_SAFE_BIGINT ? Number(parsed) : null;
} catch {
return null;
}
}
const parsed = Number(text);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
}
return null;
};
const stripOuterQuotes = (value: string): string => {
const trimmed = String(value || '').trim();
if (trimmed.length < 2) return trimmed;
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`') || (first === '[' && last === ']')) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
};
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
const resolveOracleOwnerAndTable = (params: { dbName: string; tableName: string }) => {
const rawTable = String(params.tableName || '').trim();
const parts = rawTable.split('.').map(stripOuterQuotes).filter(Boolean);
const tableName = String(parts[parts.length - 1] || rawTable || '').trim();
const ownerCandidate = parts.length >= 2 ? parts[parts.length - 2] : String(params.dbName || '').trim();
return {
owner: ownerCandidate.toUpperCase(),
tableName: tableName.toUpperCase(),
};
};
export const resolveApproximateTableCountStrategy = (params: {
dbType: string;
whereSQL: string;
}): ApproximateTableCountStrategy => {
const dbType = String(params.dbType || '').trim().toLowerCase();
const whereSQL = String(params.whereSQL || '').trim();
if (whereSQL) return 'none';
if (dbType === 'duckdb') return 'duckdb-estimated-size';
if (dbType === 'oracle') return 'oracle-num-rows';
return 'none';
};
export const buildOracleApproximateTotalSql = (params: { dbName: string; tableName: string }): string => {
const { owner, tableName } = resolveOracleOwnerAndTable(params);
const escapedTable = escapeSQLLiteral(tableName);
if (!owner) {
return `SELECT num_rows AS approx_total FROM user_tables WHERE table_name = '${escapedTable}' AND ROWNUM = 1`;
}
return `SELECT num_rows AS approx_total FROM all_tables WHERE owner = '${escapeSQLLiteral(owner)}' AND table_name = '${escapedTable}' AND ROWNUM = 1`;
};
export const parseApproximateTableCountRow = (
row: unknown,
preferredKeys: string[] = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'num_rows', 'count', 'total'],
): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
for (const preferredKey of preferredKeys) {
const normalizedPreferred = String(preferredKey || '').trim().toLowerCase();
for (const [key, value] of entries) {
if (String(key || '').trim().toLowerCase() !== normalizedPreferred) continue;
const parsed = toNonNegativeFiniteNumber(value);
if (parsed !== null) return parsed;
}
}
for (const [key, value] of entries) {
const normalizedKey = String(key || '').trim().toLowerCase();
if (!normalizedKey.includes('estimate') && !normalizedKey.includes('row') && !normalizedKey.includes('count') && !normalizedKey.includes('total')) {
continue;
}
const parsed = toNonNegativeFiniteNumber(value);
if (parsed !== null) return parsed;
}
for (const [, value] of entries) {
const parsed = toNonNegativeFiniteNumber(value);
if (parsed !== null) return parsed;
}
return null;
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { duplicateBrowserMockConnection } from './browserMockConnections';
describe('duplicateBrowserMockConnection', () => {
it('rewrites config.id to match the duplicated top-level id', () => {
const duplicated = duplicateBrowserMockConnection({
existing: {
id: 'conn-1',
name: 'Primary',
config: {
id: 'conn-1',
type: 'postgres',
},
includeDatabases: ['appdb'],
},
items: [],
nextId: 'conn-2',
});
expect(duplicated.id).toBe('conn-2');
expect(duplicated.config.id).toBe('conn-2');
expect(duplicated.name).toBe('Primary - 副本');
expect(duplicated.includeDatabases).toEqual(['appdb']);
});
});

View File

@@ -0,0 +1,47 @@
export const cloneBrowserMockValue = <T,>(value: T): T => {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
};
export const resolveBrowserMockSecretFlag = (nextValue: unknown, clearFlag: boolean, existingFlag?: boolean) => {
if (String(nextValue ?? '') !== '') return true;
if (clearFlag) return false;
return !!existingFlag;
};
export const buildBrowserMockDuplicateName = (rawName: string, items: any[]): string => {
const baseName = String(rawName || '').trim() || '连接';
const suffix = ' - 副本';
const usedNames = new Set(items.map((item) => String(item?.name || '').trim()));
let candidate = `${baseName}${suffix}`;
let counter = 2;
while (usedNames.has(candidate)) {
candidate = `${baseName}${suffix} ${counter}`;
counter += 1;
}
return candidate;
};
interface DuplicateBrowserMockConnectionInput {
existing: any;
items: any[];
nextId: string;
}
export const duplicateBrowserMockConnection = ({ existing, items, nextId }: DuplicateBrowserMockConnectionInput) => {
const duplicated = cloneBrowserMockValue({
...existing,
id: nextId,
name: buildBrowserMockDuplicateName(existing?.name, items),
config: {
...cloneBrowserMockValue(existing?.config),
id: nextId,
},
includeDatabases: Array.isArray(existing?.includeDatabases) ? [...existing.includeDatabases] : undefined,
includeRedisDatabases: Array.isArray(existing?.includeRedisDatabases) ? [...existing.includeRedisDatabases] : undefined,
});
return duplicated;
};

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { connection } from '../../wailsjs/go/models';
import { buildRpcConnectionConfig } from './connectionRpcConfig';
describe('buildRpcConnectionConfig', () => {
it('preserves the saved connection id while normalizing numeric fields', () => {
const result = buildRpcConnectionConfig({
id: 'conn-1',
type: 'postgres',
host: 'db.local',
port: '5432' as unknown as number,
user: 'postgres',
useSSH: true,
ssh: {
host: 'bastion.local',
port: '2222' as unknown as number,
user: 'ops',
},
useProxy: true,
proxy: {
type: 'http',
host: '127.0.0.1',
port: '8080' as unknown as number,
},
} as any, {
id: 'conn-2',
timeout: '120' as unknown as number,
redisDB: '6' as unknown as number,
database: 'app',
});
expect(result.id).toBe('conn-1');
expect(result.port).toBe(5432);
expect(result.ssh?.port).toBe(2222);
expect(result.proxy?.port).toBe(8080);
expect(result.timeout).toBe(120);
expect(result.redisDB).toBe(6);
expect(result.database).toBe('app');
});
it('fills default nested config blocks needed by RPC calls', () => {
const result = buildRpcConnectionConfig({
id: 'conn-redis',
type: 'redis',
host: '127.0.0.1',
port: 6379,
user: '',
} as any, {
useSSH: true,
useHttpTunnel: true,
redisDB: '4' as unknown as number,
});
expect(result.id).toBe('conn-redis');
expect(result.redisDB).toBe(4);
expect(result.ssh).toEqual({
host: '',
port: 22,
user: '',
password: '',
keyPath: '',
});
expect(result.httpTunnel).toEqual({
host: '',
port: 8080,
user: '',
password: '',
});
});
it('returns a Wails connection model instance for RPC compatibility', () => {
const result = buildRpcConnectionConfig({
id: 'conn-model',
type: 'mysql',
host: '127.0.0.1',
port: '3306' as unknown as number,
user: 'root',
useSSH: true,
ssh: {
host: 'jump.local',
port: '2222' as unknown as number,
user: 'ops',
},
useProxy: true,
proxy: {
type: 'http',
host: '127.0.0.1',
port: '8080' as unknown as number,
},
useHttpTunnel: true,
httpTunnel: {
host: '127.0.0.1',
port: '9000' as unknown as number,
},
} as any);
expect(result).toBeInstanceOf(connection.ConnectionConfig);
expect(result.ssh).toBeInstanceOf(connection.SSHConfig);
expect(result.proxy).toBeInstanceOf(connection.ProxyConfig);
expect(result.httpTunnel).toBeInstanceOf(connection.HTTPTunnelConfig);
expect(typeof (result as any).convertValues).toBe('function');
});
});

View File

@@ -0,0 +1,122 @@
import { connection } from '../../wailsjs/go/models';
export type RpcConnectionConfig = connection.ConnectionConfig & { id?: string };
type ConnectionConfigInput = {
id?: string;
ssh?: Record<string, any>;
proxy?: Record<string, any>;
httpTunnel?: Record<string, any>;
[key: string]: any;
};
type SSHConfigInput = Record<string, any>;
type ProxyConfigInput = Record<string, any>;
type HttpTunnelConfigInput = Record<string, any>;
const toStringValue = (value: unknown, fallback = ''): string => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return fallback;
};
const toOptionalInteger = (value: unknown, fallback?: number): number | undefined => {
if (value === undefined || value === null || value === '') {
return fallback;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.trunc(parsed);
};
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
return toStringValue(value).toLowerCase() === 'http' ? 'http' : 'socks5';
};
const normalizeSSHConfig = (value: unknown): connection.SSHConfig => {
const raw = (value ?? {}) as SSHConfigInput;
return new connection.SSHConfig({
host: toStringValue(raw.host),
port: toOptionalInteger(raw.port, 22) ?? 22,
user: toStringValue(raw.user),
password: toStringValue(raw.password),
keyPath: toStringValue(raw.keyPath),
});
};
const normalizeProxyConfig = (value: unknown): connection.ProxyConfig => {
const raw = (value ?? {}) as ProxyConfigInput;
const type = normalizeProxyType(raw.type);
return new connection.ProxyConfig({
type,
host: toStringValue(raw.host),
port: toOptionalInteger(raw.port, type === 'http' ? 8080 : 1080) ?? (type === 'http' ? 8080 : 1080),
user: toStringValue(raw.user),
password: toStringValue(raw.password),
});
};
const normalizeHttpTunnelConfig = (value: unknown): connection.HTTPTunnelConfig => {
const raw = (value ?? {}) as HttpTunnelConfigInput;
return new connection.HTTPTunnelConfig({
host: toStringValue(raw.host),
port: toOptionalInteger(raw.port, 8080) ?? 8080,
user: toStringValue(raw.user),
password: toStringValue(raw.password),
});
};
export function buildRpcConnectionConfig(
config: ConnectionConfigInput,
overrides: ConnectionConfigInput = {},
): RpcConnectionConfig {
const mergedSSH = {
...(config.ssh ?? {}),
...(overrides.ssh ?? {}),
};
const mergedProxy = {
...(config.proxy ?? {}),
...(overrides.proxy ?? {}),
};
const mergedHttpTunnel = {
...(config.httpTunnel ?? {}),
...(overrides.httpTunnel ?? {}),
};
const merged: ConnectionConfigInput = {
...config,
...overrides,
ssh: mergedSSH,
proxy: mergedProxy,
httpTunnel: mergedHttpTunnel,
};
const baseId = toStringValue(config.id).trim() || toStringValue(overrides.id).trim() || undefined;
const timeout = toOptionalInteger(merged.timeout, toOptionalInteger(config.timeout));
const redisDB = toOptionalInteger(merged.redisDB, toOptionalInteger(config.redisDB));
const rpcConfig = new connection.ConnectionConfig({
...merged,
type: toStringValue(merged.type),
host: toStringValue(merged.host),
port: toOptionalInteger(merged.port, toOptionalInteger(config.port, 0)) ?? 0,
user: toStringValue(merged.user),
password: toStringValue(merged.password),
database: toStringValue(merged.database),
useSSH: merged.useSSH === true,
ssh: normalizeSSHConfig(merged.ssh),
useProxy: merged.useProxy === true,
proxy: normalizeProxyConfig(merged.proxy),
useHttpTunnel: merged.useHttpTunnel === true,
httpTunnel: normalizeHttpTunnelConfig(merged.httpTunnel),
timeout,
redisDB,
}) as RpcConnectionConfig;
rpcConfig.id = baseId;
return rpcConfig;
}

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { resolveConnectionSecretDraft } from './connectionSecretDraft';
describe('resolveConnectionSecretDraft', () => {
it('keeps an existing stored secret when edit form leaves the field blank', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: '',
clearSecret: false,
});
expect(result.value).toBe('');
expect(result.clearStoredSecret).toBe(false);
expect(result.keepsStoredSecret).toBe(true);
expect(result.hasSecretAfterSave).toBe(true);
});
it('replaces the stored secret when a new value is entered', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: ' mongodb://demo ',
clearSecret: false,
trimInput: true,
});
expect(result.value).toBe('mongodb://demo');
expect(result.clearStoredSecret).toBe(false);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(true);
});
it('clears the stored secret when explicitly requested', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: '',
clearSecret: true,
});
expect(result.value).toBe('');
expect(result.clearStoredSecret).toBe(true);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(false);
});
it('prefers a newly entered value over a stale clear toggle', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: 'new-password',
clearSecret: true,
});
expect(result.value).toBe('new-password');
expect(result.clearStoredSecret).toBe(false);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(true);
});
it('does not emit a clear flag for a brand new blank field', () => {
const result = resolveConnectionSecretDraft({
hasSecret: false,
valueInput: '',
clearSecret: false,
});
expect(result.value).toBe('');
expect(result.clearStoredSecret).toBe(false);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(false);
});
it('supports force clearing stored secrets', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: 'temporary',
clearSecret: false,
forceClear: true,
});
expect(result.value).toBe('');
expect(result.clearStoredSecret).toBe(true);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(false);
});
});

View File

@@ -0,0 +1,63 @@
export interface ConnectionSecretDraftInput {
valueInput?: string;
hasSecret?: boolean;
clearSecret?: boolean;
forceClear?: boolean;
trimInput?: boolean;
}
export interface ConnectionSecretDraftResult {
value: string;
clearStoredSecret: boolean;
keepsStoredSecret: boolean;
hasSecretAfterSave: boolean;
}
export function resolveConnectionSecretDraft(input: ConnectionSecretDraftInput): ConnectionSecretDraftResult {
const rawValue = input.valueInput ?? '';
const value = input.trimInput ? String(rawValue).trim() : String(rawValue);
if (input.forceClear) {
return {
value: '',
clearStoredSecret: true,
keepsStoredSecret: false,
hasSecretAfterSave: false,
};
}
if (value !== '') {
return {
value,
clearStoredSecret: false,
keepsStoredSecret: false,
hasSecretAfterSave: true,
};
}
if (input.clearSecret) {
return {
value: '',
clearStoredSecret: true,
keepsStoredSecret: false,
hasSecretAfterSave: false,
};
}
if (input.hasSecret) {
return {
value: '',
clearStoredSecret: false,
keepsStoredSecret: true,
hasSecretAfterSave: true,
};
}
return {
value: '',
clearStoredSecret: false,
keepsStoredSecret: false,
hasSecretAfterSave: false,
};
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { shouldAllowBlankCustomDsn } from './customConnectionDsn';
describe('shouldAllowBlankCustomDsn', () => {
it('allows a blank DSN when editing a connection that already has a stored opaque DSN', () => {
expect(shouldAllowBlankCustomDsn({
dsnInput: '',
hasStoredSecret: true,
clearStoredSecret: false,
})).toBe(true);
});
it('requires a new DSN when the user chooses to clear the stored opaque DSN', () => {
expect(shouldAllowBlankCustomDsn({
dsnInput: '',
hasStoredSecret: true,
clearStoredSecret: true,
})).toBe(false);
});
it('requires a DSN for brand new custom connections', () => {
expect(shouldAllowBlankCustomDsn({
dsnInput: '',
hasStoredSecret: false,
clearStoredSecret: false,
})).toBe(false);
});
it('accepts a newly entered DSN even when a stored secret already exists', () => {
expect(shouldAllowBlankCustomDsn({
dsnInput: 'driver://demo',
hasStoredSecret: true,
clearStoredSecret: true,
})).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
export interface CustomConnectionDsnState {
dsnInput: unknown;
hasStoredSecret?: boolean;
clearStoredSecret?: boolean;
}
export const getCustomConnectionDsnValidationMessage = ({
dsnInput,
hasStoredSecret,
clearStoredSecret,
}: CustomConnectionDsnState): string | null => {
const dsnText = String(dsnInput ?? '').trim();
if (dsnText !== '') {
return null;
}
if (hasStoredSecret && !clearStoredSecret) {
return null;
}
if (hasStoredSecret && clearStoredSecret) {
return '请输入新的连接字符串,或取消清除已保存 DSN';
}
return '请输入连接字符串';
};
export const shouldAllowBlankCustomDsn = (state: CustomConnectionDsnState): boolean => (
getCustomConnectionDsnValidationMessage(state) === null
);

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
resolveDataTableColumnWidth,
resolveDataTableDefaultColumnWidth,
resolveDataTableVerticalBorderColor,
sanitizeDataGridDisplaySettings,
} from './dataGridDisplay';
describe('dataGridDisplay helpers', () => {
it('sanitizes missing display settings to safe defaults', () => {
expect(sanitizeDataGridDisplaySettings(undefined)).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
expect(sanitizeDataGridDisplaySettings({ dataTableColumnWidthMode: 'invalid' as never })).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
});
it('resolves standard and compact default column widths', () => {
expect(resolveDataTableDefaultColumnWidth('standard')).toBe(200);
expect(resolveDataTableDefaultColumnWidth('compact')).toBe(140);
});
it('keeps manual column widths ahead of mode defaults', () => {
expect(resolveDataTableColumnWidth({ manualWidth: 320, widthMode: 'compact' })).toBe(320);
expect(resolveDataTableColumnWidth({ manualWidth: undefined, widthMode: 'compact' })).toBe(140);
});
it('uses subtle themed vertical border colors and transparent when disabled', () => {
expect(resolveDataTableVerticalBorderColor({ darkMode: true, visible: true })).toBe('rgba(255, 255, 255, 0.08)');
expect(resolveDataTableVerticalBorderColor({ darkMode: false, visible: true })).toBe('rgba(15, 23, 42, 0.08)');
expect(resolveDataTableVerticalBorderColor({ darkMode: false, visible: false })).toBe('transparent');
});
});

View File

@@ -0,0 +1,72 @@
export type DataTableColumnWidthMode = 'standard' | 'compact';
export interface DataGridDisplaySettings {
showDataTableVerticalBorders: boolean;
dataTableColumnWidthMode: DataTableColumnWidthMode;
}
export const DEFAULT_DATA_GRID_DISPLAY_SETTINGS: DataGridDisplaySettings = {
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
};
export const DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS = [
{ label: '标准 200px', value: 'standard' as const },
{ label: '紧凑 140px', value: 'compact' as const },
];
const STANDARD_DATA_TABLE_COLUMN_WIDTH = 200;
const COMPACT_DATA_TABLE_COLUMN_WIDTH = 140;
export const sanitizeDataTableColumnWidthMode = (value: unknown): DataTableColumnWidthMode => {
return value === 'compact' ? 'compact' : 'standard';
};
export const sanitizeDataGridDisplaySettings = (
value: Partial<DataGridDisplaySettings> | undefined
): DataGridDisplaySettings => {
if (!value || typeof value !== 'object') {
return { ...DEFAULT_DATA_GRID_DISPLAY_SETTINGS };
}
return {
showDataTableVerticalBorders: value.showDataTableVerticalBorders === true,
dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value.dataTableColumnWidthMode),
};
};
export const resolveDataTableDefaultColumnWidth = (
widthMode: DataTableColumnWidthMode | null | undefined
): number => {
return sanitizeDataTableColumnWidthMode(widthMode) === 'compact'
? COMPACT_DATA_TABLE_COLUMN_WIDTH
: STANDARD_DATA_TABLE_COLUMN_WIDTH;
};
export const resolveDataTableColumnWidth = ({
manualWidth,
widthMode,
}: {
manualWidth: number | null | undefined;
widthMode: DataTableColumnWidthMode | null | undefined;
}): number => {
if (typeof manualWidth === 'number' && Number.isFinite(manualWidth) && manualWidth > 0) {
return manualWidth;
}
return resolveDataTableDefaultColumnWidth(widthMode);
};
export const resolveDataTableVerticalBorderColor = ({
darkMode,
visible,
}: {
darkMode: boolean;
visible: boolean;
}): string => {
if (!visible) {
return 'transparent';
}
return darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.08)';
};

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
resolvePaginationPageText,
resolvePaginationSummaryText,
resolvePaginationTotalForControl,
} from './dataGridPagination';
describe('dataGridPagination', () => {
it('shows Oracle approximate total in summary but not in total-page chip', () => {
const pagination = {
current: 3,
pageSize: 100,
total: 301,
totalKnown: false,
totalApprox: true,
approximateTotal: 1832451,
};
expect(resolvePaginationSummaryText({
pagination,
prefersManualTotalCount: true,
supportsApproximateTableCount: true,
})).toContain('约 1832451 条');
expect(resolvePaginationPageText({
pagination,
supportsApproximateTotalPages: false,
})).toBe('第 3 页');
expect(resolvePaginationTotalForControl({
pagination,
supportsApproximateTotalPages: false,
})).toBe(301);
});
it('still allows DuckDB to use approximate totals for page counts', () => {
const pagination = {
current: 2,
pageSize: 100,
total: 201,
totalKnown: false,
totalApprox: true,
approximateTotal: 1000,
};
expect(resolvePaginationPageText({
pagination,
supportsApproximateTotalPages: true,
})).toBe('第 2 / 10 页');
expect(resolvePaginationTotalForControl({
pagination,
supportsApproximateTotalPages: true,
})).toBe(1000);
});
});

View File

@@ -0,0 +1,92 @@
export type PaginationStateLike = {
current: number;
pageSize: number;
total: number;
totalKnown?: boolean;
totalApprox?: boolean;
approximateTotal?: number;
totalCountLoading?: boolean;
totalCountCancelled?: boolean;
};
const toFiniteNonNegativeNumber = (value: unknown): number | null => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
};
const resolveApproximateTotal = (pagination: PaginationStateLike): number | null => {
if (!pagination.totalApprox) return null;
const approximateTotal = toFiniteNonNegativeNumber(pagination.approximateTotal);
return approximateTotal !== null && approximateTotal > 0 ? approximateTotal : null;
};
const resolveCurrentCount = (pagination: PaginationStateLike): number => {
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0));
const hasValidRange = total > 0 && rangeStart > 0;
if (!hasValidRange) return 0;
const rangeEnd = Math.min(total, rangeStart + pagination.pageSize - 1);
return Math.max(0, rangeEnd - rangeStart + 1);
};
export const resolvePaginationSummaryText = (params: {
pagination: PaginationStateLike;
prefersManualTotalCount: boolean;
supportsApproximateTableCount: boolean;
}): string => {
const { pagination, prefersManualTotalCount, supportsApproximateTableCount } = params;
const currentCount = resolveCurrentCount(pagination);
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const approximateTotal = resolveApproximateTotal(pagination);
if (pagination.totalKnown === false) {
if (prefersManualTotalCount) {
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`;
if (supportsApproximateTableCount && approximateTotal !== null) return `当前 ${currentCount} 条 / 约 ${approximateTotal}`;
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
return `当前 ${currentCount} 条 / 总数未统计`;
}
return `当前 ${currentCount} 条 / 正在统计总数…`;
}
if (!Number.isFinite(total) || total <= 0) {
return '当前 0 条 / 共 0 条';
}
return `当前 ${currentCount} 条 / 共 ${total}`;
};
export const resolvePaginationPageText = (params: {
pagination: PaginationStateLike;
supportsApproximateTotalPages: boolean;
}): string => {
const { pagination, supportsApproximateTotalPages } = params;
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const approximateTotal = resolveApproximateTotal(pagination);
const effectiveTotal =
pagination.totalKnown !== false
? exactTotal
: supportsApproximateTotalPages && approximateTotal !== null
? approximateTotal
: 0;
if (effectiveTotal <= 0) return `${pagination.current}`;
const totalPages = Math.max(1, Math.ceil(effectiveTotal / Math.max(1, pagination.pageSize)));
if (pagination.totalKnown === false && !(supportsApproximateTotalPages && approximateTotal !== null)) {
return `${pagination.current}`;
}
return `${pagination.current} / ${totalPages}`;
};
export const resolvePaginationTotalForControl = (params: {
pagination: PaginationStateLike;
supportsApproximateTotalPages: boolean;
}): number => {
const { pagination, supportsApproximateTotalPages } = params;
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const approximateTotal = resolveApproximateTotal(pagination);
if (pagination.totalKnown !== false) return exactTotal;
if (supportsApproximateTotalPages && approximateTotal !== null) return approximateTotal;
return exactTotal;
};

View File

@@ -0,0 +1,43 @@
export type GridSortInfoItem = {
columnKey: string;
order: string;
enabled?: boolean;
};
type TableSorterLike = {
field?: unknown;
columnKey?: unknown;
order?: unknown;
};
export const resolveGridSortInfoFromTableSorter = ({
sorter,
}: {
sorter: TableSorterLike | TableSorterLike[] | null | undefined;
}): GridSortInfoItem[] => {
const sorters = Array.isArray(sorter)
? sorter
: ((sorter?.field || sorter?.columnKey) ? [sorter] : []);
if (sorters.length === 0) {
return [];
}
const next: GridSortInfoItem[] = [];
const seen = new Set<string>();
for (const item of sorters) {
const field = String(item?.field || item?.columnKey || '').trim();
if (!field) continue;
const order = item?.order as string;
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
if (!normalizedOrder) continue;
const dedupeKey = field.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
next.push({ columnKey: field, order: normalizedOrder, enabled: true });
}
return next;
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { getDataSourceCapabilities } from './dataSourceCapabilities';
describe('dataSourceCapabilities', () => {
it('treats Oracle table preview totals as manual exact count plus approximate metadata count', () => {
expect(getDataSourceCapabilities({ type: 'oracle' })).toMatchObject({
type: 'oracle',
preferManualTotalCount: true,
supportsApproximateTableCount: true,
supportsApproximateTotalPages: false,
});
});
it('keeps DuckDB manual count and approximate total support', () => {
expect(getDataSourceCapabilities({ type: 'duckdb' })).toMatchObject({
type: 'duckdb',
preferManualTotalCount: true,
supportsApproximateTableCount: true,
supportsApproximateTotalPages: true,
});
});
it('keeps MySQL on automatic total count mode', () => {
expect(getDataSourceCapabilities({ type: 'mysql' })).toMatchObject({
type: 'mysql',
preferManualTotalCount: false,
supportsApproximateTableCount: false,
supportsApproximateTotalPages: false,
});
});
});

View File

@@ -64,6 +64,9 @@ const COPY_INSERT_TYPES = new Set([
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle']);
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']);
export type DataSourceCapabilities = {
type: string;
@@ -71,6 +74,9 @@ export type DataSourceCapabilities = {
supportsSqlQueryExport: boolean;
supportsCopyInsert: boolean;
forceReadOnlyQueryResult: boolean;
preferManualTotalCount: boolean;
supportsApproximateTableCount: boolean;
supportsApproximateTotalPages: boolean;
};
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
@@ -81,6 +87,8 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type),
supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type),
supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type),
};
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { resolveDataViewerAutoFetchAction } from './dataViewerAutoFetch';
describe('resolveDataViewerAutoFetchAction', () => {
it('skips one fetch while tab state is hydrating', () => {
expect(resolveDataViewerAutoFetchAction({
skipNextAutoFetch: true,
hasInitialLoad: false,
})).toBe('skip');
});
it('loads current page on the first real fetch', () => {
expect(resolveDataViewerAutoFetchAction({
skipNextAutoFetch: false,
hasInitialLoad: false,
})).toBe('load-current-page');
});
it('reloads from first page after sort or filter changes', () => {
expect(resolveDataViewerAutoFetchAction({
skipNextAutoFetch: false,
hasInitialLoad: true,
})).toBe('reload-first-page');
});
});

View File

@@ -0,0 +1,16 @@
export type DataViewerAutoFetchAction = 'skip' | 'load-current-page' | 'reload-first-page';
export const resolveDataViewerAutoFetchAction = (params: {
skipNextAutoFetch: boolean;
hasInitialLoad: boolean;
}): DataViewerAutoFetchAction => {
if (params.skipNextAutoFetch) {
return 'skip';
}
if (!params.hasInitialLoad) {
return 'load-current-page';
}
return 'reload-first-page';
};

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { createGlobalProxyDraft, toPersistedGlobalProxy } from './globalProxyDraft';
describe('global proxy draft', () => {
it('hydrates a secretless draft from backend metadata while keeping password input blank', () => {
const draft = createGlobalProxyDraft({
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
hasPassword: true,
password: 'should-be-ignored',
});
expect(draft.password).toBe('');
expect(draft.hasPassword).toBe(true);
});
it('drops password from persisted metadata but preserves hasPassword', () => {
const persisted = toPersistedGlobalProxy({
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
hasPassword: true,
});
expect('password' in persisted).toBe(false);
expect(persisted.hasPassword).toBe(true);
});
});

View File

@@ -0,0 +1,62 @@
import { GlobalProxyConfig } from '../types';
const toTrimmedString = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return '';
};
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5';
};
const normalizePort = (value: unknown, fallbackPort: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallbackPort;
}
const port = Math.trunc(parsed);
if (port <= 0 || port > 65535) {
return fallbackPort;
}
return port;
};
export function createGlobalProxyDraft(value: Partial<GlobalProxyConfig> = {}): GlobalProxyConfig {
const type = normalizeProxyType(value.type);
return {
enabled: value.enabled === true,
type,
host: toTrimmedString(value.host),
port: normalizePort(value.port, type === 'http' ? 8080 : 1080),
user: toTrimmedString(value.user),
password: '',
hasPassword: value.hasPassword === true,
secretRef: toTrimmedString(value.secretRef) || undefined,
};
}
export function toPersistedGlobalProxy(value: Partial<GlobalProxyConfig> = {}): Omit<GlobalProxyConfig, 'password'> {
const draft = createGlobalProxyDraft(value);
return {
enabled: draft.enabled,
type: draft.type,
host: draft.host,
port: draft.port,
user: draft.user,
hasPassword: draft.hasPassword,
secretRef: draft.secretRef,
};
}
export function toSaveGlobalProxyInput(value: Partial<GlobalProxyConfig> = {}): GlobalProxyConfig {
const draft = createGlobalProxyDraft(value);
return {
...draft,
password: typeof value.password === 'string' ? value.password : '',
};
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './legacyConnectionStorage';
describe('legacy connection storage', () => {
it('extracts legacy saved connections and global proxy password from lite-db-storage', () => {
const payload = JSON.stringify({
state: {
connections: [
{
id: 'conn-1',
name: 'Primary',
config: {
id: 'conn-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
});
const result = readLegacyPersistedSecrets(payload);
expect(result.connections).toHaveLength(1);
expect(result.connections[0]?.config.password).toBe('secret');
expect(result.globalProxy?.password).toBe('proxy-secret');
});
it('strips persisted connection secrets but keeps secretless proxy metadata', () => {
const payload = JSON.stringify({
state: {
connections: [
{
id: 'conn-1',
name: 'Primary',
config: {
id: 'conn-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
});
const sanitized = stripLegacyPersistedSecrets(payload);
const parsed = JSON.parse(sanitized);
expect(parsed.state.connections).toEqual([]);
expect(parsed.state.globalProxy.password).toBeUndefined();
expect(parsed.state.globalProxy.hasPassword).toBe(true);
});
});

View File

@@ -0,0 +1,110 @@
import { GlobalProxyConfig, SavedConnection } from '../types';
export const LEGACY_PERSIST_KEY = 'lite-db-storage';
const toTrimmedString = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return '';
};
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5';
};
const normalizePort = (value: unknown, fallbackPort: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallbackPort;
}
const port = Math.trunc(parsed);
if (port <= 0 || port > 65535) {
return fallbackPort;
}
return port;
};
const parsePersistedEnvelope = (payload: string | null | undefined): Record<string, unknown> => {
if (!payload || typeof payload !== 'string') {
return {};
}
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
if (parsed.state && typeof parsed.state === 'object') {
return parsed.state as Record<string, unknown>;
}
return parsed;
} catch {
return {};
}
};
export function readLegacyPersistedSecrets(payload: string | null | undefined): {
connections: SavedConnection[];
globalProxy: GlobalProxyConfig | null;
} {
const state = parsePersistedEnvelope(payload);
const connections = Array.isArray(state.connections)
? state.connections.filter((item): item is SavedConnection => !!item && typeof item === 'object')
: [];
const proxyRaw = state.globalProxy && typeof state.globalProxy === 'object'
? state.globalProxy as Record<string, unknown>
: null;
if (!proxyRaw) {
return { connections, globalProxy: null };
}
const type = normalizeProxyType(proxyRaw.type);
const password = toTrimmedString(proxyRaw.password);
const globalProxy: GlobalProxyConfig = {
enabled: proxyRaw.enabled === true,
type,
host: toTrimmedString(proxyRaw.host),
port: normalizePort(proxyRaw.port, type === 'http' ? 8080 : 1080),
user: toTrimmedString(proxyRaw.user),
password,
hasPassword: proxyRaw.hasPassword === true || password !== '',
secretRef: toTrimmedString(proxyRaw.secretRef) || undefined,
};
const hasMeaningfulProxyState = globalProxy.enabled || globalProxy.host !== '' || globalProxy.user !== '' || globalProxy.password !== '' || globalProxy.hasPassword === true;
return {
connections,
globalProxy: hasMeaningfulProxyState ? globalProxy : null,
};
}
export function stripLegacyPersistedSecrets(payload: string | null | undefined): string {
if (!payload || typeof payload !== 'string') {
return '';
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(payload) as Record<string, unknown>;
} catch {
return payload;
}
const state = parsed.state && typeof parsed.state === 'object'
? parsed.state as Record<string, unknown>
: parsed;
state.connections = [];
if (state.globalProxy && typeof state.globalProxy === 'object') {
const proxy = { ...(state.globalProxy as Record<string, unknown>) };
const password = toTrimmedString(proxy.password);
delete proxy.password;
if (password !== '') {
proxy.hasPassword = true;
}
state.globalProxy = proxy;
}
return JSON.stringify(parsed);
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
getMacNativeTitlebarPaddingLeft,
getMacNativeTitlebarPaddingRight,
shouldHandleMacNativeFullscreenShortcut,
shouldSuppressMacNativeEscapeExit,
} from './macWindow';
describe('macWindow helpers', () => {
it('uses compact padding when native controls are disabled', () => {
expect(getMacNativeTitlebarPaddingLeft(1, false)).toBe(16);
expect(getMacNativeTitlebarPaddingRight(1, false)).toBe(0);
});
it('reserves traffic-light safe area when native controls are enabled', () => {
expect(getMacNativeTitlebarPaddingLeft(1, true)).toBe(96);
expect(getMacNativeTitlebarPaddingRight(1, true)).toBe(16);
});
it('keeps minimum safe area under small ui scales', () => {
expect(getMacNativeTitlebarPaddingLeft(0.5, true)).toBe(88);
expect(getMacNativeTitlebarPaddingRight(0.5, true)).toBe(12);
});
it('matches Control+Command+F only for mac native mode', () => {
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(true);
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'F' })).toBe(true);
});
it('rejects conflicting modifiers and non-target keys', () => {
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: true, key: 'f' })).toBe(false);
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: false, altKey: false, key: 'f' })).toBe(false);
expect(shouldHandleMacNativeFullscreenShortcut(false, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
expect(shouldHandleMacNativeFullscreenShortcut(true, false, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'g' })).toBe(false);
});
it('suppresses Escape only in mac native fullscreen mode', () => {
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: false })).toBe(true);
expect(shouldSuppressMacNativeEscapeExit(true, true, false, { key: 'Escape', defaultPrevented: false })).toBe(false);
expect(shouldSuppressMacNativeEscapeExit(true, false, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
expect(shouldSuppressMacNativeEscapeExit(false, true, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Enter', defaultPrevented: false })).toBe(false);
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: true })).toBe(false);
});
});

View File

@@ -0,0 +1,42 @@
export const getMacNativeTitlebarPaddingLeft = (uiScale: number, enabled: boolean): number => {
if (!enabled) {
return Math.max(12, Math.round(16 * uiScale));
}
return Math.max(88, Math.round(96 * uiScale));
};
export const getMacNativeTitlebarPaddingRight = (uiScale: number, enabled: boolean): number => {
if (!enabled) {
return 0;
}
return Math.max(12, Math.round(16 * uiScale));
};
export const shouldHandleMacNativeFullscreenShortcut = (
isMacRuntime: boolean,
useNativeMacWindowControls: boolean,
event: Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'key'>,
): boolean => {
if (!isMacRuntime || !useNativeMacWindowControls) {
return false;
}
if (!event.ctrlKey || !event.metaKey || event.altKey) {
return false;
}
return String(event.key || '').toLowerCase() === 'f';
};
export const shouldSuppressMacNativeEscapeExit = (
isMacRuntime: boolean,
useNativeMacWindowControls: boolean,
isFullscreen: boolean,
event: Pick<KeyboardEvent, 'key' | 'defaultPrevented'>,
): boolean => {
if (!isMacRuntime || !useNativeMacWindowControls || !isFullscreen) {
return false;
}
if (event.defaultPrevented) {
return false;
}
return String(event.key || '') === 'Escape';
};

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