Compare commits

..

107 Commits

Author SHA1 Message Date
Syngnat
bf5a9c3306 合并拉取请求 #362
release/0.6.7
2026-04-12 12:51:23 +08:00
辣条
2569a3779a feat(connection-package): 支持连接恢复包双模式加密导入导出 (#361)
## 变更说明

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

  ## 回归验证

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

  ## 人工验证

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

  ## 备注

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

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

Refs: #352
2026-04-12 12:34:50 +08:00
tianqijiuyun-latiao
d150780879 Merge branch 'feature/20260408_security-update' into merge/feature-20260408-security-update-onto-dev
# Conflicts:
#	frontend/src/App.tsx
#	frontend/wailsjs/go/app/App.d.ts
#	frontend/wailsjs/go/app/App.js
2026-04-12 09:40:28 +08:00
tianqijiuyun-latiao
52d2ee7592 feat(connection-package): 支持连接恢复包双模式加密导入导出
- 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路
- 扩展前后端导入导出流程并兼容 v1 与 legacy 格式
- 修复无文件密码恢复包导入误弹密码框导致的流程阻塞
2026-04-11 23:51:43 +08:00
Syngnat
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
tianqijiuyun-latiao
1751e14d20 🐛 fix(security): 修复安全更新重检卡死与 Redis 密文兼容 2026-04-11 20:12:23 +08:00
tianqijiuyun-latiao
82e06bd94d 🐛 fix(security): 完善密文升级导入覆盖与安全更新链路
- 完善连接恢复包与 legacy 导入覆盖语义及密文兼容处理

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

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

材料参考:

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- 刷新 Wails 绑定并补充实现留痕文档
2026-04-05 11:52:59 +08:00
tianqijiuyun-latiao
91b5b85904 ♻️ refactor(security): 通过连接配置 ID 路由 RPC 配置 2026-04-05 11:42:28 +08:00
tianqijiuyun-latiao
c842201bf4 feat(security): 前端状态迁移至无明文密钥存储 2026-04-05 11:40:20 +08:00
tianqijiuyun-latiao
263db6bf30 feat(security): 暴露连接配置与代理的密钥存储 API 2026-04-05 11:39:54 +08:00
tianqijiuyun-latiao
b5e8f5c022 feat(security): 新增连接配置与代理的密钥仓库 2026-04-05 11:39:34 +08:00
tianqijiuyun-latiao
b62d22395b feat(security): 拆分 AI 供应商元数据与密钥存储 2026-04-05 11:39:15 +08:00
tianqijiuyun-latiao
f74270d585 🐛 fix(security): 新增密钥存储状态枚举 2026-04-05 11:38:56 +08:00
tianqijiuyun-latiao
ef64a24e01 feat(security): 新增密钥存储基础架构 2026-04-05 11:38:41 +08:00
tianqijiuyun-latiao
c1266c225a 🐛 fix(ai/provider): 修复 Claude CLI 在 Windows 上的测试稳定性 2026-04-05 11:33:39 +08:00
tianqijiuyun-latiao
acee1a06e8 fix(driver): 收紧 MongoDB 驱动支持区间 2026-04-05 11:32:41 +08:00
tianqijiuyun-latiao
eddb9f38c9 🐛 fix(data-viewer): 修复多列排序状态残留导致排序失效
- 将表格排序状态改为按当前 sorter 结果重建\n- 避免取消或切换多列排序后保留失效字段\n- 抽取排序状态归一化工具供数据表复用
2026-04-05 11:32:37 +08:00
Syngnat
fbda6917f7 🐛 fix(ai-chat): 修复 DeepSeek 回显引导提示词并优化收敛策略
- 删除 ≥5 轮注入 system 提示的逻辑(部分模型会将其当作对话内容输出)
- 改为 ≥10 轮时移除 tools 参数,从物理层面终止工具调用循环
2026-04-02 11:04:57 +08:00
Syngnat
b022cd63e5 🐛 fix(ai-chat): 修复重新生成时缺少状态过渡动画的问题
- handleRetryMessage 补齐 connecting 过渡消息(波纹动画),与 handleSend 流程一致
- 重试时同步重置工具调用计数器,防止继承旧计数导致过早熔断
2026-04-02 10:56:27 +08:00
Syngnat
9eb42565f1 🐛 fix(ai-chat): 修复工具调用无限循环与写操作误报执行失败问题
- 循环熔断:新增全局工具调用总轮次上限(15轮),防止 DeepSeek 等模型无限循环
- 软引导:工具调用 ≥5 轮时注入 system 提示引导模型尽快收敛输出
- LIMIT 修复:execute_sql 不再对 UPDATE/DELETE/INSERT 等写操作追加 LIMIT 50
- 语法防御:去除 SQL 末尾分号防止拼接出 "; LIMIT 50" 的无效语法
2026-04-02 10:49:11 +08:00
Syngnat
6d533167da feat(sidebar/table-overview): 优化右键菜单交互,增加危险操作二级分类防误触
- 菜单增强:为数据库、表、视图、函数等底层对象节点新增「危险操作」二级子菜单
- 误触防护:将明确破坏性的「删除表」、「删除数据库」等入口移至更深层级进行视觉隔离
- UI 交互:引入 WarningOutlined 图标单独高亮标识风险区域
- 统一作用域:同步变更至侧边栏连接树 (Sidebar) 和表数据概览 (TableOverview) 的上下文菜单
2026-04-02 10:11:33 +08:00
Syngnat
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
d9cbbc6c31 Release/0.6.4 2026-03-28 11:24:23 +08:00
Syngnat
6ec1072d2e Release/0.6.3 2026-03-20 16:53:11 +08:00
Syngnat
9f2f8b33e8 Release/0.6.3 2026-03-20 16:24:26 +08:00
Syngnat
d984a15508 release/0.6.3 2026-03-20 16:08:38 +08:00
Syngnat
bfa918cb9d Release/v0.6.2 (#263) 2026-03-19 21:18:01 +08:00
Syngnat
4e73f6d8b5 release/0.6.1 2026-03-19 11:59:25 +08:00
Syngnat
2f4e20a34a release/0.6.0 2026-03-18 21:27:31 +08:00
Syngnat
dfabd77615 🐛 fix(ci): 修复 Chocolatey UPX 包不可用导致 Windows 构建失败 2026-03-18 17:30:33 +08:00
Syngnat
4a2dda8aa2 合并拉取请求 #254
release/0.5.9
2026-03-18 17:21:55 +08:00
Syngnat
d1d3fa26f1 🔧 fix(frontend/ci): 移除前端测试对 node:assert 的类型依赖 (#234) 2026-03-13 15:37:16 +08:00
Syngnat
fc8e62b997 release/0.5.8 (#233) 2026-03-13 15:29:53 +08:00
Syngnat
b0eb93bfa3 Release/0.5.7 (#230)
🔧 fix(ci/release-winget): 修复 Node20 弃用告警并强制启用 Node24 运行时

- 在 release-winget workflow 增加 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true
- 与现有 release/test workflow 的 Node24 配置保持一致
- 避免 actions/checkout、setup-go、setup-node 触发 Node20 弃用告警

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* 🔧 fix(release/ci): 修复跨平台UPX兼容并处理Windows ARM64打包失败

- CI 工作流统一启用 Node24 JavaScript 运行时,消除 Node20 退役告警干扰
- macOS 打包阶段为 UPX 增加 --force-macos,修复 Mach-O 压缩失败
- Windows 打包按架构分流:arm64 跳过 UPX 并保留原始 EXE,amd64 继续强制压缩
- Windows 压缩流程新增 $LASTEXITCODE 显式校验,避免命令失败被误判为成功
- 本地 build-release.sh 同步 macOS/Windows 的 UPX 兼容策略与错误处理逻辑

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: 凌封 <49424247+fengin@users.noreply.github.com>
2026-03-12 17:40:35 +08:00
Syngnat
f101a59d32 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataGrid.tsx
2026-03-12 17:34:07 +08:00
Syngnat
6ad690cffc release/0.5.6 (#210)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

---------

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: TSS <266256496+Zencok@users.noreply.github.com>
2026-03-10 11:26:02 +08:00
Syngnat
22bd1c4c28 Release/0.5.5 (#207)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

---------

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: TSS <266256496+Zencok@users.noreply.github.com>
2026-03-09 17:36:52 +08:00
Syngnat
89c81823bc Release/0.5.4 (#199)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

---------

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
2026-03-07 17:15:30 +08:00
181 changed files with 24366 additions and 1266 deletions

View File

@@ -5,6 +5,10 @@ on:
branches:
- dev
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
@@ -575,14 +579,63 @@ jobs:
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: Delete Previous Dev Release
uses: dev-drprasad/delete-tag-and-release@v1.1
continue-on-error: true
- name: Reset Previous Dev Release
uses: actions/github-script@v7
with:
tag_name: dev-latest
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
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
@@ -599,7 +652,7 @@ jobs:
**版本**: `${{ steps.version.outputs.version }}`
**分支**: `dev`
**提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
**构建时间**: ${{ github.event.head_commit.timestamp }}
**构建时间**: ${{ steps.build_time.outputs.display }}
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
> 每次 push 到 `dev` 分支会自动覆盖此 release。

5
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# IDE
.idea/
*.iml
.gitignore
# build / release artifacts
frontend/release/
**/release/
@@ -26,3 +26,6 @@ docs/需求追踪/
CLAUDE.md
**/CLAUDE.md
.worktrees
docs
.tmp_superpowers_edit

View File

@@ -2,14 +2,14 @@
Thank you for contributing to this project.
This repository follows a release-first workflow: `main` is the default public branch, while releases are prepared through `release/*` branches.
This repository uses `dev` as the default integration branch, while stable releases are published from `main` through `release/*` branches.
---
## Branch Model
- `main`: stable release branch and default branch
- `dev`: day-to-day integration branch for maintainers
- `dev`: default branch and day-to-day integration branch
- `main`: stable release branch
- `release/*`: release preparation branches for maintainers
- Recommended branch names for external contributors:
- `fix/*`: bug fixes
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
## How External Contributors Should Open Pull Requests
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `main`**.
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `dev`**.
Reasons:
- `main` is the default branch, so the PR entry point is clearer
- merged contributions are immediately visible on the default branch
- maintainers can handle downstream sync and release preparation in one place
- `dev` is the active integration branch, so changes can be reviewed in the same lane as ongoing work
- contributors align with the branch that triggers day-to-day validation and dev builds
- maintainers can cut `release/*` branches from `dev` without re-syncing external changes first
Recommended flow:
1. Fork this repository
2. Create a branch in your fork (`fix/*` or `feature/*` is recommended)
2. Sync your fork with `dev` and create a branch from `dev` (`fix/*` or `feature/*` is recommended)
3. Make your changes and perform basic self-checks
4. Push the branch to your fork
5. Open a pull request against the `main` branch of this repository
5. Open a pull request against the `dev` branch of this repository
---
@@ -63,33 +63,21 @@ Recommended expectations:
## Merge Strategy for Maintainers
Pull requests merged into `main` should generally use **Squash and merge**.
Pull requests merged into `dev` should generally use **Squash and merge**.
Reasons:
- keeps `main` history clean and linear
- maps each PR to a single commit on `main`
- reduces release, audit, and rollback complexity
- keeps `dev` history readable and easier to audit during active iteration
- maps each PR to a single integration commit on `dev`
- reduces cherry-pick and conflict cost before creating `release/*`
---
## Maintainer Sync Rules
Because external pull requests are merged directly into `main`, maintainers must sync `main` back to development and release branches to avoid branch drift.
Because external pull requests are merged directly into `dev`, maintainers should treat `dev` as the source branch for daily collaboration and release preparation.
### 1. Sync `main` -> `dev` (required)
The automatic GitHub Actions sync workflow has been removed.
Maintainers should sync `main` back to `dev` manually when needed:
```bash
git checkout dev
git pull
git merge main
git push
```
### 2. Create `release/*` from `dev`
### 1. Create `release/*` from `dev`
Before a release, create a release branch from `dev`, for example:
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
git push -u origin release/v0.6.0
```
### 3. Release from `release/*` back to `main`
### 2. Release from `release/*` back to `main`
When release preparation is complete, merge the release branch back into `main` and create a tag:
@@ -113,9 +101,9 @@ git tag v0.6.0
git push origin v0.6.0
```
### 4. Sync `main` back to `dev` after release
### 3. Sync `main` back to `dev` after release
After the release, the same automation still applies. If needed, you can run the workflow manually (`workflow_dispatch`) or execute the fallback commands:
After the release, sync `main` back into `dev` so the next iteration starts from the released code line:
```bash
git checkout dev

View File

@@ -2,14 +2,14 @@
感谢你对本项目的贡献。
本项目采用“发布优先(`main` 为默认分支)+ `release/*` 分支发版”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
本项目当前采用“`dev` 作为默认集成分支,`main` 作为稳定发布分支,`release/*` 负责发版准备”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
---
## 分支模型
- `main`:稳定发布分支,也是仓库默认分支
- `dev`:日常开发集成分支,主要供维护者使用
- `dev`:默认分支,也是日常开发集成分支
- `main`:稳定发布分支
- `release/*`:发布准备分支,主要供维护者使用
- 外部贡献者建议使用以下分支命名:
- `fix/*`:问题修复
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
## 外部贡献者如何提 Pull Request
无论是 `fix/*` 还是 `feature/*`**外部贡献者统一直接向 `main` 发起 Pull Request**。
无论是 `fix/*` 还是 `feature/*`**外部贡献者统一直接向 `dev` 发起 Pull Request**。
这样做的原因:
- `main` 是默认分支PR 入口更直观
- 合并后贡献会直接体现在默认分支
- 便于维护者统一做后续同步与发版整理
- `dev` 是当前日常集成分支,评审与合入路径和维护者开发流程一致
- 外部贡献会直接进入触发日常校验和 dev 构建的分支
- 维护者可以直接从 `dev``release/*`,减少额外同步步骤
建议流程:
1. Fork 本仓库
2. 从你自己的仓库创建分支(建议命名为 `fix/*``feature/*`
2. 先同步你 fork 中的 `dev`,再从 `dev` 创建分支(建议命名为 `fix/*``feature/*`
3. 完成代码修改,并进行必要自检
4. 推送到你的远程分支
5. 向本仓库的 `main` 分支发起 Pull Request
5. 向本仓库的 `dev` 分支发起 Pull Request
---
@@ -63,33 +63,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
## PR 合并策略(维护者)
`main` 分支上的 PR 建议使用 **Squash and merge**
`dev` 分支上的 PR 建议使用 **Squash and merge**
原因:
- 保持 `main` 历史干净、线性
- 每个 PR 在 `main` 上对应一个清晰提交
- 降低发布排查与回滚成本
- 保持 `dev` 集成历史清晰、便于审查
- 每个 PR 在 `dev` 上对应一个明确的集成提交
- 降低发版前整理与冲突处理成本
---
## 维护者同步规则
由于外部 PR 会直接合入 `main`,维护者必须及时将 `main` 的变更同步到开发与发布分支,避免分支漂移
由于外部 PR 会直接合入 `dev`,维护者应将 `dev` 作为日常协作与发版准备的主线分支
### 1. main → dev 同步(必做)
仓库已移除 GitHub Actions 自动回灌 workflow。
当前统一采用手动方式将 `main` 同步回 `dev`
```bash
git checkout dev
git pull
git merge main
git push
```
### 2. 发版前从 dev 切 release/*
### 1. 发版前从 dev 切 release/*
发布前由维护者基于 `dev` 创建发布分支,例如:
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
git push -u origin release/v0.6.0
```
### 3. release/* → main 发版
### 2. release/* → main 发版
发布准备完成后,将 `release/*` 合并回 `main`,并打标签发布:
@@ -113,9 +101,9 @@ git tag v0.6.0
git push origin v0.6.0
```
### 4. main 回流到 dev发版后必做
### 3. main 回流到 dev发版后必做
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致
发布完成后,需要将 `main` 回流到 `dev`,确保下一轮开发从已发布代码线继续推进
```bash
git checkout dev

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)
@@ -210,7 +212,16 @@ For the full workflow, branch model, and maintainer sync rules, see:
- [CONTRIBUTING.md](CONTRIBUTING.md)
External contributors should open pull requests directly against `main`.
External contributors should branch from `dev` and open pull requests against `dev`.
## Star History
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## Links

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) | 简体中文
@@ -193,7 +195,17 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
外部贡献者统一直接向 `main` 发起 Pull Request。
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
## Star History (Star 增长趋势)
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## 友情链接

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

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

View File

@@ -0,0 +1,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

@@ -1,12 +1,12 @@
{
"name": "gonavi-client",
"version": "0.0.1",
"version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gonavi-client",
"version": "0.0.1",
"version": "0.6.5",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,7 +1,7 @@
{
"name": "gonavi-client",
"private": true,
"version": "0.0.1",
"version": "0.6.5",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1 +1 @@
f697e821b4acd5cf614d63d46453e8a4
8cc5d6401a6ce7dd0f500c66ce8bb4a9

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;
}
@@ -340,3 +375,47 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
.driver-manager-hscroll-inner {
height: 1px;
}
.security-update-action-btn.ant-btn,
.security-update-action-btn.ant-btn-default,
.security-update-action-btn.ant-btn-primary,
.security-update-action-btn.ant-btn-text {
box-shadow: none !important;
}
.security-update-action-btn.ant-btn:focus,
.security-update-action-btn.ant-btn:focus-visible,
.security-update-action-btn.ant-btn-default:focus,
.security-update-action-btn.ant-btn-default:focus-visible,
.security-update-action-btn.ant-btn-primary:focus,
.security-update-action-btn.ant-btn-primary:focus-visible,
.security-update-action-btn.ant-btn-text:focus,
.security-update-action-btn.ant-btn-text:focus-visible {
outline: none !important;
box-shadow: none !important;
}
.security-update-banner {
position: relative;
isolation: isolate;
}
.security-update-result-card {
transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
}
.security-update-result-card-active {
animation: security-update-result-pulse 1.8s ease;
}
@keyframes security-update-result-pulse {
0% {
transform: translateY(0);
}
30% {
transform: translateY(-2px);
}
100% {
transform: translateY(0);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ import { AIMessageBubble } from './ai/AIMessageBubble';
import { AIChatInput } from './ai/AIChatInput';
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
import type { AIComposerNotice } from '../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
buildMissingModelNotice,
buildMissingProviderNotice,
@@ -226,6 +227,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const resizeStartX = useRef(0);
const resizeStartWidth = useRef(0);
const toolCallRoundRef = useRef(0); // 连续失败轮次计数
const totalToolRoundRef = useRef(0); // 全局工具调用总轮次计数(防止无限循环)
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
const panelRef = useRef<HTMLDivElement>(null); // 面板 DOM ref用于拖拽时直接操作宽度
const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染)
@@ -259,7 +261,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const conn = useStore.getState().connections.find(c => c.id === connectionId);
if (conn) {
import('../../wailsjs/go/app/App').then(({ DBShowCreateTable }) => {
DBShowCreateTable(conn.config as any, dbName, tableName).then(res => {
DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName).then(res => {
if (res.success && res.data) {
let createSql = '';
if (typeof res.data === 'string') createSql = res.data;
@@ -351,7 +353,12 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (!activeProvider) return;
try {
const Service = (window as any).go?.aiservice?.Service;
const payload = { ...activeProvider, model: val };
const payload = {
...activeProvider,
model: val,
apiKey: activeProvider.apiKey || '',
hasSecret: activeProvider.hasSecret ?? Boolean(activeProvider.secretRef),
};
await Service?.AISaveProvider?.(payload);
setActiveProvider(payload);
setComposerNotice(null);
@@ -673,7 +680,21 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (lastUserMsgIndex >= 0) {
const userMsg = historyLocal[lastUserMsgIndex];
truncateAIChatMessages(sid, userMsg.id);
// 重置计数器(与 handleSend 保持一致)
toolCallRoundRef.current = 0;
totalToolRoundRef.current = 0;
nudgeCountRef.current = 0;
setSending(true);
// 插入 connecting 过渡消息(波纹动画),与 handleSend 保持一致
const connectingMsg: AIChatMessage = {
id: genId(), role: 'assistant', phase: 'connecting', content: '',
timestamp: Date.now(), loading: true
};
addAIChatMessage(sid, connectingMsg);
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content, images: m.images }));
@@ -783,6 +804,20 @@ SELECT * FROM users WHERE status = 1;
const toolContextMapRef = useRef<Map<string, { connectionId: string; dbName: string; tables: string[] }>>(new Map());
const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => {
// 【全局轮次熔断】防止模型(如 DeepSeek在已生成答案后仍无限循环调用工具
const MAX_TOOL_CALL_ROUNDS = 15;
totalToolRoundRef.current += 1;
if (totalToolRoundRef.current > MAX_TOOL_CALL_ROUNDS) {
updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' });
useStore.getState().addAIChatMessage(sid, {
id: genId(), role: 'assistant',
content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`,
timestamp: Date.now(),
});
setSending(false);
return;
}
const results: AIChatMessage[] = [];
// 【串行逐条执行 + 实时写入 store】
for (const tc of toolCalls) {
@@ -805,7 +840,7 @@ SELECT * FROM users WHERE status = 1;
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
if (conn) {
try {
const dbRes = await DBGetDatabases(conn.config as any);
const dbRes = await DBGetDatabases(buildRpcConnectionConfig(conn.config) as any);
if (dbRes?.success && Array.isArray(dbRes.data)) {
let dNames = dbRes.data.map((r: any) => r.Database || r.database || Object.values(r)[0]);
if (dNames.length > 50) dNames = [...dNames.slice(0, 50), '...(截断)'];
@@ -826,7 +861,7 @@ SELECT * FROM users WHERE status = 1;
try {
const rawDbName = args.dbName || args.database;
const safeDbName = rawDbName ? String(rawDbName).trim() : '';
const tbRes = await DBGetTables(conn.config as any, safeDbName);
const tbRes = await DBGetTables(buildRpcConnectionConfig(conn.config) as any, safeDbName);
if (tbRes?.success && Array.isArray(tbRes.data)) {
let tNames = tbRes.data.map((r: any) => r.Table || r.table || Object.values(r)[0] as string);
if (tNames.length > 150) tNames = [...tNames.slice(0, 150), '...(截断)'];
@@ -852,7 +887,7 @@ SELECT * FROM users WHERE status = 1;
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBGetColumns } = await import('../../wailsjs/go/app/App');
const colRes = await DBGetColumns(conn.config as any, safeDbName, safeTable);
const colRes = await DBGetColumns(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (colRes?.success && Array.isArray(colRes.data)) {
// 只保留关键字段信息,减少 token 占用
const cols = colRes.data.map((c: any) => {
@@ -883,7 +918,7 @@ SELECT * FROM users WHERE status = 1;
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBShowCreateTable } = await import('../../wailsjs/go/app/App');
const ddlRes = await DBShowCreateTable(conn.config as any, safeDbName, safeTable);
const ddlRes = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (ddlRes?.success) {
resStr = typeof ddlRes.data === 'string' ? ddlRes.data : JSON.stringify(ddlRes.data);
success = true;
@@ -910,7 +945,14 @@ SELECT * FROM users WHERE status = 1;
}
}
const { DBQuery } = await import('../../wailsjs/go/app/App');
const qRes = await DBQuery(conn.config as any, safeDbName, safeSql + (safeSql.toLowerCase().includes('limit') ? '' : ' LIMIT 50'));
// 只对只读查询自动追加 LIMIT写操作UPDATE/DELETE/INSERT等不追加
const sqlTrimmed = safeSql.replace(/;\s*$/, ''); // 去掉末尾分号防止拼接出 "; LIMIT 50"
const sqlFirstWord = sqlTrimmed.trimStart().split(/\s/)[0]?.toLowerCase() || '';
const isReadQuery = ['select', 'show', 'describe', 'desc', 'explain', 'with'].includes(sqlFirstWord);
const finalSql = (isReadQuery && !sqlTrimmed.toLowerCase().includes('limit'))
? sqlTrimmed + ' LIMIT 50'
: sqlTrimmed;
const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeSql + (safeSql.toLowerCase().includes('limit') ? '' : ' LIMIT 50'));
if (qRes?.success) {
const rows = Array.isArray(qRes.data) ? qRes.data : [];
const limitedRows = rows.slice(0, 50);
@@ -1020,11 +1062,16 @@ SELECT * FROM users WHERE status = 1;
}
const allMessages = [...sysMessages, ...finalMessagesPayload];
// 【软收敛】超过 10 轮工具调用后,不再传递 tools 参数,从物理层面强制模型只能用文本回答
const SOFT_LIMIT_ROUNDS = 10;
const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : LOCAL_TOOLS;
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) {
await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS);
await Service.AIChatStream(sid, allMessages, chainTools);
} else if (Service?.AIChatSend) {
const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS);
const result = await Service.AIChatSend(allMessages, chainTools);
const errR = result?.error || '未知错误';
const errC = sanitizeErrorMsg(errR);
useStore.getState().addAIChatMessage(sid, {
@@ -1057,6 +1104,7 @@ SELECT * FROM users WHERE status = 1;
setComposerNotice(null);
toolCallRoundRef.current = 0; // 重置工具调用轮次计数
totalToolRoundRef.current = 0; // 重置总轮次计数
nudgeCountRef.current = 0; // 重置催促计数
const currentImages = [...draftImages];
@@ -1264,7 +1312,8 @@ SELECT * FROM users WHERE status = 1;
const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]);
const activeConnectionConfig = useMemo(() => {
if (!inferredConnectionId) return undefined;
return connections.find(c => c.id === inferredConnectionId)?.config;
const connection = connections.find(c => c.id === inferredConnectionId);
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
}, [inferredConnectionId, connections]);
const contextUsageChars = useMemo(() =>
messages.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
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 {
@@ -18,6 +18,8 @@ import {
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';
@@ -26,6 +28,7 @@ interface AISettingsModalProps {
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
@@ -77,7 +80,7 @@ const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; ico
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
];
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
@@ -88,6 +91,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
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);
@@ -105,6 +109,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
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 {
@@ -131,18 +136,52 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
useEffect(() => {
if (!open || !focusProviderId) {
return;
}
if (!providers.some((provider) => provider.id === focusProviderId)) {
return;
}
setActiveSection('providers');
setActiveProviderId(focusProviderId);
}, [focusProviderId, open, providers]);
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
setEditingProvider(session.editingProvider as AIProviderConfig | null);
setIsEditing(session.isEditing);
setTestStatus(session.testStatus);
setClearProviderSecret(session.clearProviderSecret);
form.resetFields();
if (session.formValues) {
form.setFieldsValue(session.formValues);
}
}, [form]);
const resetProviderEditorSession = useCallback(() => {
applyProviderEditorSession(buildClosedProviderEditorSession());
}, [applyProviderEditorSession]);
const handleModalClose = useCallback(() => {
resetProviderEditorSession();
onClose();
}, [onClose, resetProviderEditorSession]);
useEffect(() => {
if (!open) {
resetProviderEditorSession();
}
}, [open, resetProviderEditorSession]);
const handleAddProvider = () => {
const preset = findPreset('openai');
const newProvider: AIProviderConfig = {
id: '', type: preset.backendType, name: '', apiKey: '',
baseUrl: preset.defaultBaseUrl, model: preset.defaultModel,
models: [], maxTokens: 4096, temperature: 0.7,
};
setEditingProvider({ ...newProvider, presetKey: 'openai' } as any);
setIsEditing(true);
setTestStatus('idle');
form.resetFields();
form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' });
applyProviderEditorSession(buildAddProviderEditorSession({
presetKey: 'openai',
presetBackendType: preset.backendType,
presetBaseUrl: preset.defaultBaseUrl,
presetModel: preset.defaultModel,
presetModels: preset.models,
apiFormat: 'openai',
}));
};
const handleEditProvider = (p: AIProviderConfig) => {
@@ -153,17 +192,16 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
presetFixedApiFormat: matchedPreset.fixedApiFormat,
valuesApiFormat: p.apiFormat,
});
setEditingProvider(p);
setIsEditing(true);
setTestStatus('idle');
form.resetFields();
form.setFieldsValue({
...p,
type: resolvedTransport.type,
models: p.models || [],
presetKey: matchedPreset.key,
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
});
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) => {
@@ -217,12 +255,18 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
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,
@@ -230,7 +274,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
};
// 后端 AISaveProvider 统一处理新增和更新,返回 void失败抛异常
await Service?.AISaveProvider?.(payload);
void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); void loadConfig();
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 */ }
@@ -287,10 +331,20 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
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,
@@ -401,7 +455,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
<div>
{/* 顶部返回 */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
<Button size="small" onClick={() => { setIsEditing(false); setEditingProvider(null); }}
<Button size="small" onClick={resetProviderEditorSession}
style={{ borderRadius: 8 }}> </Button>
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
@@ -492,11 +546,25 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
<div style={fieldLabelStyle}>
<KeyOutlined style={{ fontSize: 14 }} /> &
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: 16 }}>
<Input.Password placeholder="sk-... / 你的 API Key"
<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 }}>
@@ -699,7 +767,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
</div>
}
open={open}
onCancel={onClose}
onCancel={handleModalClose}
footer={null}
width={820}
styles={{
@@ -765,3 +833,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
};
export default AISettingsModal;

View File

@@ -5,6 +5,13 @@ 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 {
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
resolveConnectionTestFailureFeedback,
} from '../utils/connectionModalPresentation';
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 +24,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) {
@@ -96,7 +140,8 @@ const ConnectionModal: React.FC<{
onClose: () => void;
initialValues?: SavedConnection | null;
onOpenDriverManager?: () => void;
}> = ({ open, onClose, initialValues, onOpenDriverManager }) => {
onSaved?: (savedConnection: SavedConnection) => void | Promise<void>;
}> = ({ open, onClose, initialValues, onOpenDriverManager, onSaved }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [useSSL, setUseSSL] = useState(false);
@@ -122,6 +167,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 +217,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 +255,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 +857,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 +1187,7 @@ const ConnectionModal: React.FC<{
setUriFeedback(null);
setCustomIconType(undefined);
setCustomIconColor(undefined);
setClearSecrets(createEmptyConnectionSecretClearState());
setTypeSelectWarning(null);
setDriverStatusLoaded(false);
void refreshDriverStatus();
@@ -1198,6 +1320,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 +1434,28 @@ 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);
if (onSaved) {
void Promise.resolve(onSaved(savedConnection)).catch((error: unknown) => {
console.warn('Failed to refresh post-save state', error);
void message.warning('配置已保存,但安全更新状态暂未刷新,请稍后重新检查');
});
}
form.resetFields();
setUseSSL(false);
setUseSSH(false);
@@ -1240,8 +1463,11 @@ const ConnectionModal: React.FC<{
setUseHttpTunnel(false);
setDbType('mysql');
setStep(1);
setClearSecrets(createEmptyConnectionSecretClearState());
onClose();
} catch (e) {
} catch (e: any) {
message.error(normalizeConnectionSecretErrorMessage(e?.message || e, '保存失败'));
} finally {
setLoading(false);
}
};
@@ -1271,10 +1497,38 @@ const ConnectionModal: React.FC<{
}
};
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
const text = String(reason ?? '').trim();
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
return `测试失败: ${normalized}`;
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 applyTestFailureFeedback = (feedback: { message: string; shouldToast: boolean }) => {
setTestResult({ type: 'error', message: feedback.message });
if (feedback.shouldToast) {
void message.error({
content: feedback.message,
key: 'connection-test-failure',
});
}
};
const handleTest = async () => {
@@ -1285,14 +1539,29 @@ const ConnectionModal: React.FC<{
const values = form.getFieldsValue(true);
const unavailableReason = await resolveDriverUnavailableReason(values.type);
if (unavailableReason) {
const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用');
setTestResult({ type: 'error', message: failMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'driver_unavailable',
reason: unavailableReason,
fallback: '驱动未安装启用',
}));
promptInstallDriver(values.type, unavailableReason);
return;
}
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
if (blockingSecretClearMessage) {
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'secret_blocked',
reason: blockingSecretClearMessage,
fallback: '连接参数不完整',
}));
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)
@@ -1310,6 +1579,7 @@ const ConnectionModal: React.FC<{
);
if (res.success) {
void message.destroy('connection-test-failure');
setTestResult({ type: 'success', message: res.message });
if (isRedisType) {
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
@@ -1333,27 +1603,33 @@ const ConnectionModal: React.FC<{
}
} else {
setDbList([]);
message.warning(`连接成功,但获取数据库列表失败:${dbRes.message || '未知错误'}`);
message.warning(`连接成功,但获取数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, '未知错误')}`);
}
}
} else {
const failMessage = buildTestFailureMessage(
res?.message,
'连接被拒绝或参数无效,请检查后重试'
);
setTestResult({ type: 'error', message: failMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'runtime',
reason: res?.message,
fallback: '连接被拒绝或参数无效,请检查后重试',
}));
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'errorFields' in e) {
const failMessage = '测试失败: 请先完善必填项后再测试连接';
setTestResult({ type: 'error', message: failMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'validation',
reason: '',
fallback: '请先完善必填项后再测试连接',
}));
return;
}
const reason = e instanceof Error
? e.message
: (typeof e === 'string' ? e : '未知异常');
const failMessage = buildTestFailureMessage(reason, '未知异常');
setTestResult({ type: 'error', message: failMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'runtime',
reason,
fallback: '未知异常',
}));
} finally {
testInFlightRef.current = false;
setLoading(false);
@@ -1368,10 +1644,18 @@ 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 || '成员发现失败');
message.error(normalizeConnectionSecretErrorMessage(result.message, '成员发现失败'));
return;
}
const data = (result.data as Record<string, any>) || {};
@@ -1392,7 +1676,7 @@ const ConnectionModal: React.FC<{
}
message.success(result.message || `发现 ${members.length} 个成员`);
} catch (error: any) {
message.error(error?.message || '成员发现失败');
message.error(normalizeConnectionSecretErrorMessage(error?.message || error, '成员发现失败'));
} finally {
setDiscoveringMembers(false);
}
@@ -1850,7 +2134,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 +2144,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 +2161,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 +2197,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 +2225,7 @@ const ConnectionModal: React.FC<{
label="默认连接数据库(可选)"
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
>
<Input placeholder="例如appdb" />
<Input {...noAutoCapInputProps} placeholder="例如appdb" />
</Form.Item>
)}
@@ -1937,7 +2236,7 @@ const ConnectionModal: React.FC<{
rules={[createUriAwareRequiredRule('请输入 Oracle 服务名(例如 ORCLPDB1')]}
help="请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
>
<Input placeholder="例如ORCLPDB1" />
<Input {...noAutoCapInputProps} placeholder="例如ORCLPDB1" />
</Form.Item>
)}
@@ -1962,12 +2261,26 @@ 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={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
emptyPlaceholder: '留空沿用主库密码',
retainedLabel: '已保存从库密码',
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: 'mysqlReplicaPassword',
clearKey: 'mysqlReplicaPassword',
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
clearLabel: '清除已保存从库密码',
description: '当前已保存从库密码。留空表示继续沿用,输入新值表示替换。',
})}
</>
)}
</>
@@ -2001,15 +2314,29 @@ 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={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
emptyPlaceholder: '留空沿用主密码',
retainedLabel: '已保存副本集密码',
})}
/>
</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 +2372,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 +2409,22 @@ const ConnectionModal: React.FC<{
</Form.Item>
)}
<Form.Item name="password" label="密码 (可选)">
<Input.Password placeholder="Redis 密码(如果设置了 requirepass" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder: 'Redis 密码(如果设置了 requirepass',
retainedLabel: '已保存 Redis 密码',
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: 'password',
clearKey: 'primaryPassword',
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: '清除已保存密码',
description: '当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。',
})}
<Form.Item
name="includeRedisDatabases"
label="显示数据库 (留空显示全部)"
@@ -2097,17 +2438,25 @@ 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}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder: '密码',
retainedLabel: '已保存密码',
})}
/>
</Form.Item>
{dbType === 'mongodb' && (
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ marginBottom: 0 }}>
@@ -2115,6 +2464,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 +2473,14 @@ const ConnectionModal: React.FC<{
</Form.Item>
)}
</div>
{renderStoredSecretControls({
fieldName: 'password',
clearKey: 'primaryPassword',
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: '清除已保存密码',
description: '当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。',
})}
</>
)}
{dbType === 'mongodb' && (
@@ -2182,10 +2540,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 +2566,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 +2574,36 @@ 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={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasSSHPassword,
emptyPlaceholder: '密码',
retainedLabel: '已保存 SSH 密码',
})}
/>
</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 +2621,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 +2636,26 @@ 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={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasProxyPassword,
emptyPlaceholder: '留空表示无认证',
retainedLabel: '已保存代理密码',
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: 'proxyPassword',
clearKey: 'proxyPassword',
hasStoredSecret: initialValues?.hasProxyPassword,
clearLabel: '清除已保存代理密码',
description: '当前已保存代理密码。留空表示继续沿用,输入新值表示替换。',
})}
</div>
)}
</div>
@@ -2287,7 +2673,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 +2681,26 @@ 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={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
emptyPlaceholder: '留空表示无认证',
retainedLabel: '已保存隧道密码',
})}
/>
</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 +2903,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 +3231,7 @@ const ConnectionModal: React.FC<{
};
export default ConnectionModal;

View File

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

View File

@@ -23,17 +23,33 @@ import {
arrayMove
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
import type { ColumnDefinition } from '../types';
import type { ColumnDefinition, IndexDefinition } from '../types';
import { v4 as generateUuid } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
resolveDataTableColumnWidth,
resolveDataTableDefaultColumnWidth,
resolveDataTableVerticalBorderColor,
} from '../utils/dataGridDisplay';
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
import { resolveGridSortInfoFromTableSorter } from '../utils/dataGridSort';
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
import {
buildCopyDeleteSQL,
buildCopyInsertSQL,
buildCopyUpdateSQL,
normalizeTemporalLiteralText,
resolveUniqueKeyGroupsFromIndexes,
} from './dataGridCopyInsert';
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -378,7 +394,7 @@ const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): a
// --- Resizable Header (Native Implementation) ---
const ResizableTitle = React.forwardRef<HTMLTableCellElement, any>((props, ref) => {
const { onResizeStart, width, ...restProps } = props;
const { onResizeStart, onResizeAutoFit, width, ...restProps } = props;
const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties;
if (width) {
@@ -401,12 +417,20 @@ const ResizableTitle = React.forwardRef<HTMLTableCellElement, any>((props, ref)
// Pass the header element reference implicitly via event target
onResizeStart(e);
}}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (typeof onResizeAutoFit === 'function') {
onResizeAutoFit(e);
}
}}
onPointerDown={(e) => {
// 阻止 pointerdown 冒泡到 @dnd-kit 的 PointerSensor
// 避免调整列宽时意外触发列拖拽排序
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
title="拖动调整列宽,双击按内容自适应"
style={{
position: 'absolute',
right: 0, // Align to right edge
@@ -530,6 +554,8 @@ const DataContext = React.createContext<{
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
displayDataRef: React.MutableRefObject<any[]>;
handleCopyInsert: (r: any) => void;
handleCopyUpdate: (r: any) => void;
handleCopyDelete: (r: any) => void;
handleCopyJson: (r: any) => void;
handleCopyCsv: (r: any) => void;
handleExportSelected: (format: string, r: any) => Promise<void>;
@@ -570,32 +596,52 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
}) => {
const [editing, setEditing] = useState(false);
const inputRef = useRef<any>(null);
const cellRef = useRef<HTMLElement>(null);
const pickerOpenRef = useRef(false);
const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null);
const form = useContext(EditableContext);
const cellContextMenuContext = useContext(CellContextMenuContext);
/** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */
const lockTableScroll = useCallback((lock: boolean) => {
if (lock) {
// 查找虚拟滚动容器或常规滚动容器
const tableWrapper = cellRef.current?.closest?.('.ant-table-wrapper') as HTMLElement | null;
if (tableWrapper) {
const handler = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); };
tableWrapper.addEventListener('wheel', handler, { capture: true, passive: false });
scrollLockRef.current = { el: tableWrapper, handler };
}
} else if (scrollLockRef.current) {
const { el, handler } = scrollLockRef.current;
el.removeEventListener('wheel', handler, { capture: true } as any);
scrollLockRef.current = null;
}
}, []);
useEffect(() => {
if (editing) {
// 每次进入编辑时强制设置表单值(覆盖 form store 中可能残留的旧值)
const raw = record[dataIndex];
const fieldName = getCellFieldName(record, dataIndex);
if (isDateTimeField) {
const dayjsVal = parseToDayjs(raw, pickerType);
setCellFieldValue(form, fieldName, dayjsVal);
} else {
const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw;
setCellFieldValue(form, fieldName, initialValue);
}
inputRef.current?.focus();
}
}, [editing]);
const toggleEdit = () => {
setEditing(!editing);
const raw = record[dataIndex];
const fieldName = getCellFieldName(record, dataIndex);
if (isDateTimeField) {
// 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用
const dayjsVal = parseToDayjs(raw, pickerType);
setCellFieldValue(form, fieldName, dayjsVal);
} else {
const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw;
setCellFieldValue(form, fieldName, initialValue);
}
};
const save = async () => {
try {
if (!form) return;
if (!form || !editing) return;
const fieldName = getCellFieldName(record, dataIndex);
await form.validateFields([fieldName]);
let nextValue = form.getFieldValue(fieldName);
@@ -616,6 +662,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
}
} catch (errInfo) {
console.log('Save failed:', errInfo);
// 日期时间类型保存失败时兜底退出编辑,避免 DatePicker 卡在编辑态
if (isDateTimeField && editing) setEditing(false);
}
};
@@ -641,6 +689,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
style={{ width: '100%' }}
format={TEMPORAL_FORMATS[pickerType]}
onChange={() => setTimeout(save, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
needConfirm={false}
/>
) : pickerType === 'datetime' ? (
@@ -648,12 +698,31 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
ref={inputRef}
style={{ width: '100%' }}
showTime
showNow={false}
format={TEMPORAL_FORMATS[pickerType]}
renderExtraFooter={() => (
<a
style={{ padding: '0 2px' }}
onClick={() => {
// 自定义"此刻":仅将当前时间填入表单字段,面板保持打开。
// 用户需点击"确定"才真正保存,替代内置 showNow 的自动提交行为。
const fieldName = getCellFieldName(record, dataIndex);
setCellFieldValue(form, fieldName, dayjs());
}}
></a>
)}
onOk={() => setTimeout(save, 0)}
onOpenChange={(open) => {
// 面板关闭(点击外部)且非通过"确定"按钮触发时退出编辑,不保存
pickerOpenRef.current = open;
lockTableScroll(open);
// 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮onOk触发保存
if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0);
}}
onBlur={() => {
// 兜底:面板未打开或已关闭时,点击外部通过 blur 退出编辑。
// 延迟检查面板状态,避免点击自定义"此刻"按钮时误退出(此时面板仍打开)。
setTimeout(() => { if (editing && !pickerOpenRef.current) setEditing(false); }, 150);
}}
needConfirm
/>
) : (
@@ -663,6 +732,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
format={TEMPORAL_FORMATS[pickerType]}
picker={pickerType as any}
onChange={() => setTimeout(save, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
needConfirm={false}
/>
)
@@ -721,6 +792,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
return (
<Component
ref={cellRef}
{...restProps}
data-row-key={record ? String(record?.[GONAVI_ROW_KEY]) : undefined}
data-col-name={dataIndex || undefined}
@@ -736,7 +808,19 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
if (!record || !context) return <tr {...props}>{children}</tr>;
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu, supportsCopyInsert } = context;
const {
selectedRowKeysRef,
displayDataRef,
handleCopyInsert,
handleCopyUpdate,
handleCopyDelete,
handleCopyJson,
handleCopyCsv,
handleExportSelected,
copyToClipboard,
enableRowContextMenu,
supportsCopyInsert,
} = context;
if (!enableRowContextMenu) {
return <tr {...props}>{children}</tr>;
@@ -757,6 +841,16 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
label: '复制为 INSERT',
icon: <ConsoleSqlOutlined />,
onClick: () => handleCopyInsert(record),
}, {
key: 'update',
label: '复制为 UPDATE',
icon: <ConsoleSqlOutlined />,
onClick: () => handleCopyUpdate(record),
}, {
key: 'delete',
label: '复制为 DELETE',
icon: <ConsoleSqlOutlined />,
onClick: () => handleCopyDelete(record),
}] : []),
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
@@ -882,6 +976,13 @@ const DataGrid: React.FC<DataGridProps> = ({
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const showDataTableVerticalBorders = appearance.showDataTableVerticalBorders === true;
const dataTableColumnWidthMode = appearance.dataTableColumnWidthMode;
const defaultColumnWidth = resolveDataTableDefaultColumnWidth(dataTableColumnWidthMode);
const dataTableVerticalBorderColor = resolveDataTableVerticalBorderColor({
darkMode,
visible: showDataTableVerticalBorders,
});
const canModifyData = !readOnly && !!tableName;
const showColumnComment = queryOptions?.showColumnComment ?? true;
const showColumnType = queryOptions?.showColumnType ?? true;
@@ -1000,6 +1101,8 @@ const DataGrid: React.FC<DataGridProps> = ({
const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount;
const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount;
const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages;
const dbType = dataSourceCaps.type;
const isDuckDBConnection = dataSourceCaps.type === 'duckdb';
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
const isQueryResultExport = exportScope === 'queryResult';
@@ -1124,6 +1227,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const [dataPanelValue, setDataPanelValue] = useState('');
const [dataPanelIsJson, setDataPanelIsJson] = useState(false);
const dataPanelDirtyRef = useRef(false);
const dataPanelOriginalRef = useRef('');
const [rowEditorOpen, setRowEditorOpen] = useState(false);
const [rowEditorRowKey, setRowEditorRowKey] = useState<string>('');
const rowEditorBaseRawRef = useRef<Record<string, any>>({});
@@ -1260,8 +1364,11 @@ const DataGrid: React.FC<DataGridProps> = ({
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>([]);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [columnMetaMap, setColumnMetaMap] = useState<Record<string, ColumnMeta>>({});
const [uniqueKeyGroups, setUniqueKeyGroups] = useState<string[][]>([]);
const columnMetaCacheRef = useRef<Record<string, Record<string, ColumnMeta>>>({});
const columnMetaSeqRef = useRef(0);
const uniqueKeyGroupsCacheRef = useRef<Record<string, string[][]>>({});
const uniqueKeyGroupsSeqRef = useRef(0);
useEffect(() => {
const ext = sortInfoExternal || [];
@@ -1276,10 +1383,12 @@ const DataGrid: React.FC<DataGridProps> = ({
const normalizedDbName = String(dbName || '').trim();
if (!connectionId || !normalizedTableName) {
setColumnMetaMap({});
setUniqueKeyGroups([]);
return;
}
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {});
setUniqueKeyGroups(uniqueKeyGroupsCacheRef.current[cacheKey] || []);
}, [connectionId, dbName, tableName]);
useEffect(() => {
@@ -1306,7 +1415,7 @@ const DataGrid: React.FC<DataGridProps> = ({
};
const seq = ++columnMetaSeqRef.current;
DBGetColumns(config as any, normalizedDbName, normalizedTableName)
DBGetColumns(buildRpcConnectionConfig(config) as any, normalizedDbName, normalizedTableName)
.then((res) => {
if (seq !== columnMetaSeqRef.current) return;
if (!res.success || !Array.isArray(res.data)) {
@@ -1330,6 +1439,47 @@ const DataGrid: React.FC<DataGridProps> = ({
});
}, [connections, connectionId, dbName, tableName]);
useEffect(() => {
const normalizedTableName = String(tableName || '').trim();
const normalizedDbName = String(dbName || '').trim();
if (!connectionId || !normalizedTableName) return;
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
if (uniqueKeyGroupsCacheRef.current[cacheKey]) return;
const conn = connections.find(c => c.id === connectionId);
if (!conn) {
setUniqueKeyGroups([]);
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 seq = ++uniqueKeyGroupsSeqRef.current;
DBGetIndexes(config as any, normalizedDbName, normalizedTableName)
.then((res) => {
if (seq !== uniqueKeyGroupsSeqRef.current) return;
if (!res.success || !Array.isArray(res.data)) {
setUniqueKeyGroups([]);
return;
}
const nextGroups = resolveUniqueKeyGroupsFromIndexes(res.data as IndexDefinition[]);
uniqueKeyGroupsCacheRef.current[cacheKey] = nextGroups;
setUniqueKeyGroups(nextGroups);
})
.catch(() => {
if (seq !== uniqueKeyGroupsSeqRef.current) return;
setUniqueKeyGroups([]);
});
}, [connections, connectionId, dbName, tableName]);
const columnMetaMapByLowerName = useMemo(() => {
const next: Record<string, ColumnMeta> = {};
Object.entries(columnMetaMap).forEach(([name, meta]) => {
@@ -1340,6 +1490,27 @@ const DataGrid: React.FC<DataGridProps> = ({
return next;
}, [columnMetaMap]);
const columnTypeMapByLowerName = useMemo(() => {
const next: Record<string, string> = {};
Object.entries(columnMetaMapByLowerName).forEach(([name, meta]) => {
const type = String(meta?.type || '').trim();
if (!name || !type) return;
next[name] = type;
});
return next;
}, [columnMetaMapByLowerName]);
const allTableColumnNames = useMemo(() => {
const metaColumns = Object.keys(columnMetaMap);
if (metaColumns.length > 0) {
return metaColumns;
}
if (exportScope === 'table') {
return columnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY);
}
return [];
}, [columnMetaMap, exportScope, columnNames]);
const normalizeCommitCellValue = useCallback(
(columnName: string, value: any, mode: 'insert' | 'update') => {
if (value === undefined) return undefined;
@@ -1361,7 +1532,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// INSERT 空时间值直接忽略字段让数据库默认值生效UPDATE 空时间值转 NULL。
return mode === 'insert' ? undefined : null;
}
return normalizeDateTimeString(value);
return normalizeTemporalLiteralText(value, meta?.type, true);
}
return value;
@@ -1436,14 +1607,18 @@ const DataGrid: React.FC<DataGridProps> = ({
const updateFocusedCell = useCallback((record: Item, dataIndex: string) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
const text = toEditableText(raw);
let text = toEditableText(raw);
// 日期时间字段格式化(处理带时区的 ISO 格式如 2026-03-22T00:00:00+08:00
if (typeof raw === 'string') {
text = normalizeDateTimeString(raw);
}
const isJson = looksLikeJsonText(text);
setFocusedCellInfo({ record, dataIndex, title: dataIndex });
// 仅在面板未被用户手动编辑时自动同步值
if (!dataPanelDirtyRef.current) {
setDataPanelValue(text);
setDataPanelIsJson(isJson);
}
// 切换到新单元格时总是更新预览值并重置 dirty 标记
dataPanelOriginalRef.current = text;
setDataPanelValue(text);
setDataPanelIsJson(isJson);
dataPanelDirtyRef.current = false;
}, []);
const handleDataPanelFormatJson = useCallback(() => {
@@ -1506,8 +1681,15 @@ const DataGrid: React.FC<DataGridProps> = ({
overflow: hidden !important;
}
.${gridId} .ant-table-tbody > tr > td,
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell,
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid ${dataTableVerticalBorderColor} !important; }
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid ${dataTableVerticalBorderColor} !important; }
.${gridId} .ant-table-tbody > tr > td:last-child,
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell:last-child,
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell:last-child,
.${gridId} .ant-table-thead > tr > th:last-child {
border-inline-end-color: transparent !important;
}
/* 选择列对齐header TH 无 classAnt Design 虚拟模式),需用 :first-child 匹配 */
.${gridId} .ant-table-header th:first-child,
.${gridId} .ant-table-thead > tr > th:first-child {
@@ -1944,7 +2126,7 @@ const DataGrid: React.FC<DataGridProps> = ({
justify-content: center;
line-height: 1;
}
`, [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity]);
`, [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity, dataTableVerticalBorderColor]);
const recalculateTableMetrics = useCallback((targetElement?: HTMLElement | null) => {
const target = targetElement || containerRef.current;
@@ -2698,39 +2880,10 @@ const DataGrid: React.FC<DataGridProps> = ({
const handleTableChange = useCallback((_pag: any, _filtersArg: any, sorter: any) => {
if (isResizingRef.current) return; // Block sort if resizing
// Ant Design 多列排序模式下 sorter 可能是数组
const sorters = Array.isArray(sorter) ? sorter : (sorter?.field ? [sorter] : []);
if (sorters.length === 0) {
setSortInfo([]);
if (onSort) onSort(JSON.stringify([]), '');
return;
}
// 在现有排序数组基础上增量更新
const next = [...sortInfo];
for (const s of sorters) {
const field = String(s.field || '');
if (!field) continue;
const order = s.order as string;
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
const existIdx = next.findIndex(item => item.columnKey === field);
if (!normalizedOrder) {
// Ant Design 第三次点击想取消排序:
// 如果该字段已在排序数组中,回转为升序而非移除
if (existIdx >= 0) {
next[existIdx] = { ...next[existIdx], order: 'ascend', enabled: true };
}
// 不在数组中则忽略
} else if (existIdx >= 0) {
// 已存在:更新排序方向
next[existIdx] = { ...next[existIdx], order: normalizedOrder, enabled: true };
} else {
// 不存在:追加到末尾
next.push({ columnKey: field, order: normalizedOrder, enabled: true });
}
}
const next = resolveGridSortInfoFromTableSorter({ sorter });
setSortInfo(next);
if (onSort) onSort(JSON.stringify(next), '');
}, [onSort, sortInfo]);
}, [onSort]);
// Native Drag State
const draggingRef = useRef<{
@@ -2743,6 +2896,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const resizeRafRef = useRef<number | null>(null);
const latestClientXRef = useRef<number | null>(null);
const isResizingRef = useRef(false); // Lock for sorting
const autoFitCanvasRef = useRef<HTMLCanvasElement | null>(null);
const flushGhostPosition = useCallback(() => {
resizeRafRef.current = null;
@@ -2768,7 +2922,10 @@ const DataGrid: React.FC<DataGridProps> = ({
const startX = e.clientX;
const currentWidth = columnWidths[key] || 200;
const currentWidth = resolveDataTableColumnWidth({
manualWidth: columnWidths[key],
widthMode: dataTableColumnWidthMode,
});
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0;
@@ -2799,7 +2956,75 @@ const DataGrid: React.FC<DataGridProps> = ({
document.body.style.userSelect = 'none';
}, [columnWidths]);
}, [columnWidths, dataTableColumnWidthMode]);
const measureTextWidth = useCallback((text: string, font: string) => {
if (typeof document === 'undefined') {
return text.length * 8;
}
if (!autoFitCanvasRef.current) {
autoFitCanvasRef.current = document.createElement('canvas');
}
const context = autoFitCanvasRef.current.getContext('2d');
if (!context) {
return text.length * 8;
}
context.font = font;
return context.measureText(text).width;
}, []);
const buildAutoFitMeasurer = useCallback((element: HTMLElement | null, fallbackFont: string) => {
let font = fallbackFont;
if (typeof window !== 'undefined' && element) {
const computed = window.getComputedStyle(element);
const weight = computed.fontWeight || '400';
const size = computed.fontSize || '13px';
const family = computed.fontFamily || 'sans-serif';
font = `${weight} ${size} ${family}`;
}
return (text: string) => measureTextWidth(text, font);
}, [measureTextWidth]);
const handleResizeAutoFit = useCallback((key: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const handleEl = e.currentTarget as HTMLElement | null;
const headerEl = handleEl?.closest('th') as HTMLElement | null;
const sampleCell = Array.from(
containerRef.current?.querySelectorAll('.ant-table-cell[data-col-name]') || []
).find((node) => (node as HTMLElement).getAttribute('data-col-name') === key) as HTMLElement | undefined;
const meta = columnMetaMap[key] || columnMetaMapByLowerName[key.toLowerCase()];
const headerTexts = [key];
if (showColumnType && meta?.type) headerTexts.push(meta.type);
if (showColumnComment && meta?.comment) headerTexts.push(meta.comment);
const defaultWidth = resolveDataTableColumnWidth({
manualWidth: columnWidths[key],
widthMode: dataTableColumnWidthMode,
});
const containerWidth = containerRef.current?.clientWidth ?? 0;
const nextWidth = calculateAutoFitColumnWidth({
headerTexts,
valueTexts: displayDataRef.current.map((row) => row?.[key]),
measureHeaderText: buildAutoFitMeasurer(headerEl, '600 13px sans-serif'),
measureCellText: buildAutoFitMeasurer(sampleCell ?? null, '400 13px sans-serif'),
defaultWidth,
minWidth: 80,
maxWidth: Math.max(720, Math.floor(containerWidth * 0.85)),
});
setColumnWidths((prev) => ({ ...prev, [key]: nextWidth }));
}, [
buildAutoFitMeasurer,
columnMetaMap,
columnMetaMapByLowerName,
columnWidths,
dataTableColumnWidthMode,
showColumnComment,
showColumnType,
]);
// 2. Drag Move (Global)
const handleResizeMove = useCallback((e: MouseEvent) => {
@@ -2840,28 +3065,49 @@ const DataGrid: React.FC<DataGridProps> = ({
}, []);
const handleCellSave = useCallback((row: any) => {
// Optimistic update for display
// In parent-controlled data, we might need parent to update 'data',
// but here we manage 'modifiedRows' locally and overlay it.
// Since 'displayData' is derived from 'data' + 'modifiedRows', we need to update the source if it's in 'data'.
// But 'data' prop is immutable.
// So we update 'modifiedRows'.
// Check if it's an added row
const rowKey = row?.[GONAVI_ROW_KEY];
if (rowKey === undefined) return;
const isAdded = addedRows.some(r => r?.[GONAVI_ROW_KEY] === rowKey);
if (isAdded) {
setAddedRows(prev => prev.map(r => r?.[GONAVI_ROW_KEY] === rowKey ? { ...r, ...row } : r));
} else {
// 查找原始行数据,对比是否真正有值变更
const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey);
if (originalRow) {
const changedFields: Record<string, any> = {};
for (const col of Object.keys(row)) {
if (col === GONAVI_ROW_KEY) continue;
if (!isCellValueEqualForDiff(originalRow[col], row[col])) {
changedFields[col] = row[col];
}
}
if (Object.keys(changedFields).length === 0) {
// 没有实际变更,从 modifiedRows 中移除该行(如有)
setModifiedRows(prev => {
const keyStr = rowKeyStr(rowKey);
if (!(keyStr in prev)) return prev;
const next = { ...prev };
delete next[keyStr];
return next;
});
return;
}
}
setModifiedRows(prev => ({ ...prev, [rowKeyStr(rowKey)]: row }));
}
}, [addedRows]);
}, [addedRows, data]);
const handleDataPanelSave = useCallback(() => {
if (!focusedCellInfo) return;
// 与 updateFocusedCell 设置的原始值比较,避免幽灵变更
if (dataPanelValue === dataPanelOriginalRef.current) {
dataPanelDirtyRef.current = false;
void message.info('数据未变更');
return;
}
const nextRow: any = { ...focusedCellInfo.record, [focusedCellInfo.dataIndex]: dataPanelValue };
handleCellSave(nextRow);
dataPanelOriginalRef.current = dataPanelValue;
dataPanelDirtyRef.current = false;
void message.success('已保存');
}, [focusedCellInfo, dataPanelValue, handleCellSave]);
@@ -3222,7 +3468,10 @@ const DataGrid: React.FC<DataGridProps> = ({
dataIndex: key,
key: key,
// 不使用 ellipsis避免 Ant Design 的 Tooltip 展开行为
width: columnWidths[key] || 200,
width: resolveDataTableColumnWidth({
manualWidth: columnWidths[key],
widthMode: dataTableColumnWidthMode,
}),
sorter: onSort ? { multiple: displayColumnNames.indexOf(key) + 1 } : false,
sortOrder: (sortInfo.find(s => s.columnKey === key && s.enabled !== false)?.order || null) as SortOrder | undefined,
editable: canModifyData, // Only editable if table name known and not readonly
@@ -3241,6 +3490,7 @@ const DataGrid: React.FC<DataGridProps> = ({
width: column.width,
className: 'gonavi-sortable-header-cell',
onResizeStart: handleResizeStart(key), // Only need start
onResizeAutoFit: handleResizeAutoFit(key),
onClickCapture: (event: React.MouseEvent<HTMLElement>) => {
if (!onSort) return;
const headerCell = event.currentTarget as HTMLElement;
@@ -3263,7 +3513,7 @@ const DataGrid: React.FC<DataGridProps> = ({
},
}),
}));
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]);
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode]);
const mergedColumns = useMemo(() => columns.map((col): ColumnType<any> => {
const dataIndex = String(col.dataIndex);
@@ -3429,7 +3679,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
void message.info("No changes to commit");
void message.info("没有可提交的变更");
return;
}
@@ -3443,7 +3693,7 @@ const DataGrid: React.FC<DataGridProps> = ({
};
const startTime = Date.now();
const res = await ApplyChanges(config as any, dbName || '', tableName, { inserts, updates, deletes } as any);
const res = await ApplyChanges(buildRpcConnectionConfig(config) as any, dbName || '', tableName, { inserts, updates, deletes } as any);
const duration = Date.now() - startTime;
// Construct a pseudo-SQL representation for the log
@@ -3485,6 +3735,59 @@ const DataGrid: React.FC<DataGridProps> = ({
navigator.clipboard.writeText(text).catch(console.error);
void message.success("Copied to clipboard");
}, []);
const handleCopySelectedCellsToClipboard = useCallback(() => {
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) {
void message.info('请先拖选要复制的单元格');
return;
}
const parsed = Array.from(activeSelection)
.map((cellKey) => splitCellKey(cellKey))
.filter((item): item is { rowKey: string; colName: string } => !!item);
if (parsed.length === 0) {
void message.info('未识别到可复制的单元格');
return;
}
const text = buildSelectedCellClipboardText({
selectedCells: parsed,
rows: mergedDisplayData as Array<Record<string, any>>,
columnOrder: displayColumnNames,
rowKeyField: GONAVI_ROW_KEY,
});
if (!text) {
void message.info('当前选区没有可复制内容');
return;
}
copyToClipboard(text);
}, [selectedCells, mergedDisplayData, displayColumnNames, copyToClipboard]);
useEffect(() => {
if (!cellEditMode) return;
const onKeyDown = (event: KeyboardEvent) => {
const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c';
if (!isCopy) return;
const activeElement = document.activeElement as HTMLElement | null;
const tagName = String(activeElement?.tagName || '').toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable) {
return;
}
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) return;
event.preventDefault();
handleCopySelectedCellsToClipboard();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard]);
const getTargets = useCallback((clickedRecord: any) => {
const selKeys = selectedRowKeysRef.current;
@@ -3496,26 +3799,87 @@ const DataGrid: React.FC<DataGridProps> = ({
return [clickedRecord];
}, []);
const handleCopyInsert = useCallback((record: any) => {
const buildCopySqlBatchText = useCallback((mode: 'insert' | 'update' | 'delete', record: any): string | null => {
if (!supportsCopyInsert) {
void message.warning("当前数据源不支持复制为 INSERT,请使用 JSON/CSV/Markdown 复制。");
return;
void message.warning("当前数据源不支持复制 SQL,请使用 JSON/CSV/Markdown 复制。");
return null;
}
const records = getTargets(record);
// 使用 columnNames 保持表定义的字段顺序,而非 Object.keys() 的不确定顺序
const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY);
const sqlList = records.map((r: any) => {
const values = orderedCols.map(c => {
const v = r[c];
if (v === null || v === undefined) return 'NULL';
const str = typeof v === 'string' ? normalizeDateTimeString(v) : String(v);
const escaped = str.replace(/'/g, "''");
return `'${escaped}'`;
});
const targetTable = tableName || 'table';
return `INSERT INTO \`${targetTable}\` (${orderedCols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
if (mode === 'insert') {
return records.map((row: any) => buildCopyInsertSQL({
dbType,
tableName,
orderedCols,
record: row,
columnTypesByLowerName: columnTypeMapByLowerName,
})).join('\n\n');
}
const sqlResults = records.map((row: any) => (
mode === 'update'
? buildCopyUpdateSQL({
dbType,
tableName,
orderedCols,
record: row,
pkColumns,
uniqueKeyGroups,
allTableColumns: allTableColumnNames,
columnTypesByLowerName: columnTypeMapByLowerName,
})
: buildCopyDeleteSQL({
dbType,
tableName,
orderedCols,
record: row,
pkColumns,
uniqueKeyGroups,
allTableColumns: allTableColumnNames,
columnTypesByLowerName: columnTypeMapByLowerName,
})
));
const failedResult = sqlResults.find((result) => result.ok === false);
if (failedResult && failedResult.ok === false) {
void message.warning(failedResult.error);
return null;
}
const sqlTexts: string[] = [];
sqlResults.forEach((result) => {
if (result.ok) {
sqlTexts.push(result.sql);
}
});
copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, tableName, columnNames, getTargets, copyToClipboard]);
return sqlTexts.join('\n\n');
}, [
supportsCopyInsert,
getTargets,
columnNames,
dbType,
tableName,
columnTypeMapByLowerName,
pkColumns,
uniqueKeyGroups,
allTableColumnNames,
]);
const handleCopyInsert = useCallback((record: any) => {
const batchText = buildCopySqlBatchText('insert', record);
if (!batchText) return;
copyToClipboard(batchText);
}, [buildCopySqlBatchText, copyToClipboard]);
const handleCopyUpdate = useCallback((record: any) => {
const batchText = buildCopySqlBatchText('update', record);
if (!batchText) return;
copyToClipboard(batchText);
}, [buildCopySqlBatchText, copyToClipboard]);
const handleCopyDelete = useCallback((record: any) => {
const batchText = buildCopySqlBatchText('delete', record);
if (!batchText) return;
copyToClipboard(batchText);
}, [buildCopySqlBatchText, copyToClipboard]);
const handleCopyJson = useCallback((record: any) => {
const records = getTargets(record);
@@ -3563,7 +3927,7 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!config) return;
const hide = message.loading(`正在导出...`, 0);
try {
const res = await ExportQuery(config as any, dbName || '', sql, defaultName || 'export', format);
const res = await ExportQuery(buildRpcConnectionConfig(config) as any, dbName || '', sql, defaultName || 'export', format);
if (res.success) {
void message.success("导出成功");
} else if (res.message !== "已取消") {
@@ -3681,7 +4045,7 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!config) return;
const hide = message.loading(`正在导出全部数据...`, 0);
try {
const res = await ExportTable(config as any, dbName || '', tableName, format);
const res = await ExportTable(buildRpcConnectionConfig(config) as any, dbName || '', tableName, format);
if (res.success) {
void message.success("导出成功");
} else if (res.message !== "已取消") {
@@ -3756,7 +4120,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const config = buildConnConfig();
if (!config) return;
const res = await ImportData(config as any, dbName || '', tableName);
const res = await ImportData(buildRpcConnectionConfig(config) as any, dbName || '', tableName);
if (res.success && res.data && res.data.filePath) {
setImportFilePath(res.data.filePath);
setImportPreviewVisible(true);
@@ -3966,6 +4330,8 @@ const DataGrid: React.FC<DataGridProps> = ({
selectedRowKeysRef,
displayDataRef,
handleCopyInsert,
handleCopyUpdate,
handleCopyDelete,
handleCopyJson,
handleCopyCsv,
handleExportSelected,
@@ -3973,7 +4339,7 @@ const DataGrid: React.FC<DataGridProps> = ({
tableName,
enableRowContextMenu: false,
supportsCopyInsert,
}), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData, supportsCopyInsert]);
}), [handleCopyCsv, handleCopyDelete, handleCopyInsert, handleCopyJson, handleCopyUpdate, handleExportSelected, copyToClipboard, tableName, supportsCopyInsert]);
const cellContextMenuValue = useMemo(() => ({
showMenu: showCellContextMenu,
@@ -3988,7 +4354,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const rowPropsFactory = useCallback((record: any) => ({ record } as any), []);
const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth;
const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || defaultColumnWidth), 0) + selectionColumnWidth;
const useContextMenuRow = false;
const tableScrollX = useMemo(() => {
// rc-table 在 scroll.x 小于容器宽度时会把实际列宽按视口补齐。
@@ -4580,6 +4946,12 @@ const DataGrid: React.FC<DataGridProps> = ({
</Button>
{cellEditMode && selectedCells.size > 0 && (
<>
<Button
icon={<CopyOutlined />}
onClick={handleCopySelectedCellsToClipboard}
>
({selectedCells.size})
</Button>
<Button
icon={<CopyOutlined />}
onClick={handleCopySelectedColumnsFromRow}
@@ -4767,7 +5139,11 @@ const DataGrid: React.FC<DataGridProps> = ({
padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`,
background: 'transparent',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
}}>
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
<div style={{ maxHeight: 200, overflowY: 'auto', overflowX: 'hidden', flex: '0 1 auto' }}>
{filterConditions.map((cond, condIndex) => (
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start', opacity: cond.enabled === false ? 0.58 : 1 }}>
<Checkbox
@@ -4910,14 +5286,17 @@ const DataGrid: React.FC<DataGridProps> = ({
}} />
</div>
))}
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => {
const next = [...sortInfo, { columnKey: displayColumnNames.find(c => !sortInfo.some(s => s.columnKey === c)) || displayColumnNames[0] || '', order: 'ascend', enabled: true }];
onSort(JSON.stringify(next), '');
}} disabled={sortInfo.length >= displayColumnNames.length} style={{ marginBottom: 4 }}></Button>
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginTop: (onSort && sortInfo.length > 0) ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) ? `1px dashed ${panelFrameColor}` : 'none' }}>
<Button type="dashed" onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', flex: '0 0 auto', marginTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? `1px dashed ${panelFrameColor}` : 'none' }}>
<Button type="primary" ghost onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
{onSort && (
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => {
const next = [...sortInfo, { columnKey: displayColumnNames.find(c => !sortInfo.some(s => s.columnKey === c)) || displayColumnNames[0] || '', order: 'ascend', enabled: true }];
onSort(JSON.stringify(next), '');
}} disabled={sortInfo.length >= displayColumnNames.length}></Button>
)}
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: true })))}></Button>
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: false })))}></Button>
@@ -5277,8 +5656,10 @@ const DataGrid: React.FC<DataGridProps> = ({
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={dataPanelValue}
onChange={(val) => {
setDataPanelValue(val || '');
dataPanelDirtyRef.current = true;
const newVal = val || '';
setDataPanelValue(newVal);
// 只有值真正与原始值不同时才标记 dirty
dataPanelDirtyRef.current = newVal !== dataPanelOriginalRef.current;
}}
options={{
minimap: { enabled: false },
@@ -5381,21 +5762,53 @@ const DataGrid: React.FC<DataGridProps> = ({
</>
)}
{supportsCopyInsert && (
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
INSERT
</div>
<>
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
INSERT
</div>
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleCopyUpdate(cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
UPDATE
</div>
<div
style={{
padding: '8px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (cellContextMenu.record) handleCopyDelete(cellContextMenu.record);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}}
>
DELETE
</div>
</>
)}
<div
style={{

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

@@ -9,6 +9,7 @@ import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildM
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;
@@ -319,7 +320,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
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);
@@ -478,7 +479,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
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(),
@@ -514,7 +515,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
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) => {
@@ -567,7 +568,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
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;
@@ -680,7 +681,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
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) => {
@@ -734,7 +735,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
const escapedSchema = escapeSQLLiteral(schemaName);
const escapedTable = escapeSQLLiteral(pureTableName);
const approxConfig: any = { ...(config as any), timeout: 3 };
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`,
@@ -775,7 +776,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) {
oracleApproxKeyRef.current = countKey;
const approxSeq = ++oracleApproxSeqRef.current;
const approxConfig: any = { ...(config as any), timeout: 3 };
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
const approxSql = buildOracleApproximateTotalSql({ dbName, tableName });
DBQuery(approxConfig as any, dbName, approxSql)

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

@@ -11,6 +11,8 @@ 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 { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
@@ -248,6 +250,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const setQueryOptions = useStore(state => state.setQueryOptions);
const shortcutOptions = useStore(state => state.shortcutOptions);
const activeTabId = useStore(state => state.activeTabId);
const autoFetchVisible = useAutoFetchVisibility();
const currentSavedQuery = useMemo(() => {
const savedId = String(tab.savedQueryId || '').trim();
@@ -323,6 +326,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// Fetch Database List
useEffect(() => {
if (!autoFetchVisible) {
return;
}
const fetchDbs = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) return;
@@ -336,7 +343,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
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);
@@ -366,10 +373,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
};
void fetchDbs();
}, [currentConnectionId, connections]);
}, [autoFetchVisible, currentConnectionId, connections]);
// Fetch Metadata for Autocomplete (Cross-database)
useEffect(() => {
if (!autoFetchVisible) {
return;
}
const fetchMetadata = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) return;
@@ -392,7 +403,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
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 +412,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
// 获取列 (所有数据库类型都支持 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({
@@ -423,7 +434,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
};
void fetchMetadata();
}, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
}, [autoFetchVisible, currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
// Query ID management helpers
const setQueryId = (id: string) => {
@@ -577,7 +588,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
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;
@@ -716,11 +727,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// 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);
@@ -773,7 +789,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
.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 => {
// 当前库的表字段优先级更高
@@ -788,24 +807,61 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
});
// 表提示:当前库显示表名,其他库显示 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,
};
});
@@ -1510,7 +1566,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
} 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;
@@ -1598,7 +1654,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
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, ';');
@@ -1649,7 +1705,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
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}`,
@@ -1750,7 +1806,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
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({
@@ -1876,7 +1932,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
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) {

View File

@@ -2,6 +2,7 @@ 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 {
@@ -201,7 +202,7 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
for (const cmd of commands) {
const start = Date.now();
try {
const res = await (window as any).go.app.App.RedisExecuteCommand(config, cmd);
const res = await (window as any).go.app.App.RedisExecuteCommand(buildRpcConnectionConfig(config), cmd);
newResults.push({
command: cmd,
result: res.success ? res.data : null,

View File

@@ -12,6 +12,7 @@ import {
} 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;
@@ -51,7 +52,7 @@ const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) =>
// Ref to track if component is mounted to prevent state updates after unmount
const mountedRef = useRef(true);
// Interval ref
const intervalRef = useRef<NodeJS.Timeout | null>(null);
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 });
@@ -61,7 +62,7 @@ const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) =>
if (!connection) return;
try {
const config = { ...connection.config, redisDB } as any;
const config = buildRpcConnectionConfig(connection.config, { redisDB });
const res = await RedisGetServerInfo(config);
if (!mountedRef.current) return;

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

View File

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

View File

@@ -0,0 +1,133 @@
import { Button, Modal } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { CSSProperties } from 'react';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_MODAL_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateShellSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateIntroModalProps {
open: boolean;
loading?: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
onStart: () => void;
onPostpone: () => void;
onViewDetails: () => void;
}
const actionButtonStyle: CSSProperties = {
...getSecurityUpdateActionButtonStyle(),
height: 38,
paddingInline: 18,
};
const SecurityUpdateIntroModal = ({
open,
loading = false,
darkMode,
overlayTheme,
surfaceOpacity = 1,
onStart,
onPostpone,
onViewDetails,
}: SecurityUpdateIntroModalProps) => {
return (
<Modal
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
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,
}}
>
<SafetyCertificateOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
</div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
使
</div>
</div>
</div>
)}
open={open}
closable={!loading}
maskClosable={!loading}
keyboard={!loading}
onCancel={onPostpone}
width={560}
styles={{
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
footer={[
<Button
key="details"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
type="primary"
ghost
style={actionButtonStyle}
onClick={onViewDetails}
disabled={loading}
>
</Button>,
<Button
key="later"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
type="primary"
ghost
style={actionButtonStyle}
onClick={onPostpone}
disabled={loading}
>
</Button>,
<Button
key="start"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
type="primary"
style={actionButtonStyle}
loading={loading}
onClick={onStart}
>
</Button>,
]}
>
<div
style={{
padding: '12px 0 6px',
color: darkMode ? 'rgba(255,255,255,0.82)' : '#2f3b52',
lineHeight: 1.8,
fontSize: 14,
}}
>
使
</div>
</Modal>
);
};
export type { SecurityUpdateIntroModalProps };
export default SecurityUpdateIntroModal;

View File

@@ -0,0 +1,69 @@
import { Modal, Spin } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
SECURITY_UPDATE_MODAL_CLASS,
getSecurityUpdateShellSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateProgressModalProps {
open: boolean;
stageText: string;
detailText?: string;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
}
const SecurityUpdateProgressModal = ({
open,
stageText,
detailText,
overlayTheme,
surfaceOpacity = 1,
}: SecurityUpdateProgressModalProps) => {
return (
<Modal
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
open={open}
closable={false}
maskClosable={false}
keyboard={false}
footer={null}
width={420}
centered
styles={{
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
header: { display: 'none' },
body: { padding: 28 },
}}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 16 }}>
<div
style={{
width: 52,
height: 52,
borderRadius: 18,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
fontSize: 22,
}}
>
<SafetyCertificateOutlined />
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
{stageText}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{detailText ?? '更新过程中会保留当前可用配置,请稍候。'}
</div>
<Spin size="large" />
</div>
</Modal>
);
};
export type { SecurityUpdateProgressModalProps };
export default SecurityUpdateProgressModal;

View File

@@ -0,0 +1,337 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Empty, Modal, Tag } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
import {
getSecurityUpdateIssueActionMeta,
getSecurityUpdateIssueSeverityMeta,
getSecurityUpdateItemStatusMeta,
getSecurityUpdateStatusMeta,
sortSecurityUpdateIssues,
} from '../utils/securityUpdatePresentation';
import {
hasSecurityUpdateRecentResult,
resolveSecurityUpdateFocusState,
type SecurityUpdateFocusState,
type SecurityUpdateSettingsFocusTarget,
} from '../utils/securityUpdateRepairFlow';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_MODAL_CLASS,
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
SECURITY_UPDATE_RESULT_CARD_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateSectionSurfaceStyle,
getSecurityUpdateShellSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateSettingsModalProps {
open: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
status: SecurityUpdateStatus;
focusTarget?: SecurityUpdateSettingsFocusTarget | null;
focusRequest?: number;
onClose: () => void;
onStart: () => void;
onRetry: () => void;
onRestart: () => void;
onIssueAction: (issue: SecurityUpdateIssue) => void;
}
const sectionStyle = (
overlayTheme: OverlayWorkbenchTheme,
surfaceOpacity: number,
options?: { emphasized?: boolean },
) => ({
borderRadius: 14,
padding: 16,
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, {
...options,
surfaceOpacity,
}),
});
const EMPTY_FOCUS_STATE: SecurityUpdateFocusState = {
target: null,
pulseKey: null,
};
const SecurityUpdateSettingsModal = ({
open,
darkMode,
overlayTheme,
surfaceOpacity = 1,
status,
focusTarget = null,
focusRequest = 0,
onClose,
onStart,
onRetry,
onRestart,
onIssueAction,
}: SecurityUpdateSettingsModalProps) => {
const statusMeta = getSecurityUpdateStatusMeta(status);
const sortedIssues = sortSecurityUpdateIssues(status.issues);
const showRecentResult = hasSecurityUpdateRecentResult(status);
const showStart = status.overallStatus === 'pending' || status.overallStatus === 'postponed';
const showRetry = status.overallStatus === 'needs_attention';
const showRestart = status.overallStatus === 'needs_attention' || status.overallStatus === 'rolled_back';
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
const [activeFocus, setActiveFocus] = useState<SecurityUpdateFocusState>(EMPTY_FOCUS_STATE);
const statusSectionRef = useRef<HTMLDivElement | null>(null);
const recentResultRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const nextFocus = resolveSecurityUpdateFocusState(open, focusTarget, focusRequest);
if (!nextFocus.target || !nextFocus.pulseKey) {
setActiveFocus(EMPTY_FOCUS_STATE);
return undefined;
}
const targetNode = nextFocus.target === 'recent_result'
? recentResultRef.current
: statusSectionRef.current;
if (!targetNode) {
return undefined;
}
setActiveFocus(EMPTY_FOCUS_STATE);
const animationFrame = window.requestAnimationFrame(() => {
targetNode.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
targetNode.focus({ preventScroll: true });
setActiveFocus(nextFocus);
});
const highlightTimer = window.setTimeout(() => {
setActiveFocus((current) => (
current.pulseKey === nextFocus.pulseKey ? EMPTY_FOCUS_STATE : current
));
}, 1800);
return () => {
window.cancelAnimationFrame(animationFrame);
window.clearTimeout(highlightTimer);
};
}, [focusRequest, focusTarget, open]);
return (
<Modal
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
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,
}}
>
<SafetyCertificateOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
</div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
</div>
</div>
</div>
)}
open={open}
onCancel={onClose}
footer={[
showRetry ? (
<Button key="retry" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRetry}>
</Button>
) : null,
showRestart ? (
<Button key="restart" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRestart}>
</Button>
) : null,
showStart ? (
<Button
key="start"
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={actionButtonStyle}
type="primary"
onClick={onStart}
>
</Button>
) : null,
<Button key="close" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onClose}>
</Button>,
]}
width={760}
styles={{
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
>
<div style={{ display: 'grid', gap: 14, padding: '12px 0' }}>
<div
ref={statusSectionRef}
tabIndex={-1}
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'status' })}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
{statusMeta.label}
</div>
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{statusMeta.description}
</div>
</div>
<Tag color={
statusMeta.tone === 'success'
? 'success'
: statusMeta.tone === 'error'
? 'error'
: statusMeta.tone === 'processing'
? 'processing'
: statusMeta.tone === 'warning'
? 'warning'
: 'default'
}>
{statusMeta.label}
</Tag>
</div>
</div>
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
{[
{ label: '总计', value: status.summary.total },
{ label: '已更新', value: status.summary.updated },
{ label: '待处理', value: status.summary.pending },
{ label: '已跳过', value: status.summary.skipped },
{ label: '失败', value: status.summary.failed },
].map((item) => (
<div
key={item.label}
style={{
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
borderRadius: 12,
padding: '12px 10px',
}}
>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>{item.label}</div>
<div style={{ marginTop: 6, fontSize: 20, fontWeight: 700, color: overlayTheme.titleText }}>{item.value}</div>
</div>
))}
</div>
</div>
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
</div>
{sortedIssues.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="当前没有待处理项"
/>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{sortedIssues.map((issue) => {
const actionMeta = getSecurityUpdateIssueActionMeta(issue);
const itemStatusMeta = getSecurityUpdateItemStatusMeta(issue.status);
const issueSeverityMeta = getSecurityUpdateIssueSeverityMeta(issue.severity);
return (
<div
key={issue.id}
style={{
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
borderRadius: 12,
padding: 14,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText }}>
{issue.title || issue.message || issue.id}
</div>
<Tag color={itemStatusMeta.color}>
{itemStatusMeta.label}
</Tag>
<Tag color={issueSeverityMeta.color}>
{issueSeverityMeta.label}
</Tag>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{issue.message || '当前项需要进一步处理后才能完成安全更新。'}
</div>
</div>
<Button
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
style={actionButtonStyle}
type={actionMeta.emphasis === 'primary' ? 'primary' : 'default'}
onClick={() => onIssueAction(issue)}
>
{actionMeta.label}
</Button>
</div>
);
})}
</div>
)}
</div>
{showRecentResult ? (
<div
ref={recentResultRef}
tabIndex={-1}
className={[
SECURITY_UPDATE_RESULT_CARD_CLASS,
activeFocus.target === 'recent_result' ? SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS : '',
].filter(Boolean).join(' ')}
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'recent_result' })}
>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>
</div>
{status.backupPath ? (
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
<span style={{ color: overlayTheme.titleText }}>{status.backupPath}</span>
</div>
) : null}
{status.lastError ? (
<div style={{ marginTop: 8, fontSize: 13, color: '#ff7875', lineHeight: 1.7 }}>
{status.lastError}
</div>
) : null}
</div>
) : null}
</div>
</Modal>
);
};
export type { SecurityUpdateSettingsModalProps };
export default SecurityUpdateSettingsModal;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress } from 'antd';
import {
DatabaseOutlined,
@@ -31,16 +31,20 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
TagOutlined,
CheckOutlined,
FilterOutlined,
DashboardOutlined
DashboardOutlined,
WarningOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import FindInDatabaseModal from './FindInDatabaseModal';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
const { Search } = Input;
@@ -116,6 +120,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const autoFetchVisible = useAutoFetchVisibility();
const [treeData, setTreeData] = useState<TreeNode[]>([]);
// Background Helper (Duplicate logic for now, ideally shared)
@@ -175,6 +180,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
// Virtual Scroll State
@@ -289,6 +295,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
useEffect(() => {
if (!autoFetchVisible) {
return;
}
// Refresh queries for expanded databases
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
for (const node of nodes) {
@@ -307,7 +317,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
loadTables(node);
}
});
}, [savedQueries]);
}, [autoFetchVisible, savedQueries]);
useEffect(() => {
setTreeData((prev) => {
@@ -364,129 +374,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
}, [connections, connectionTags]);
const buildDuplicateConnectionName = (rawName: string): string => {
const baseName = String(rawName || '').trim() || '连接';
const suffix = ' - 副本';
const usedNames = new Set(connections.map(conn => String(conn.name || '').trim()));
let candidate = `${baseName}${suffix}`;
let counter = 2;
while (usedNames.has(candidate)) {
candidate = `${baseName}${suffix} ${counter}`;
counter += 1;
}
return candidate;
};
const handleDuplicateConnection = async (conn: SavedConnection) => {
if (!conn?.id) return;
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DuplicateConnection !== 'function') {
message.error('复制连接失败:后端接口不可用');
return;
}
const cloneConnectionConfig = (config: SavedConnection['config']): SavedConnection['config'] => {
const raw: any = config || {};
let cloned: any = {};
try {
cloned = typeof structuredClone === 'function'
? structuredClone(raw)
: JSON.parse(JSON.stringify(raw));
} catch {
cloned = { ...raw };
const duplicatedConnection = await backendApp.DuplicateConnection(conn.id);
if (!duplicatedConnection) {
throw new Error('复制连接失败:后端未返回结果');
}
addConnection(duplicatedConnection);
message.success(`已复制连接: ${duplicatedConnection.name}`);
} catch (error: any) {
message.error(error?.message || '复制连接失败');
}
const readString = (...values: unknown[]): string => {
for (const value of values) {
if (typeof value === 'string') {
return value;
}
}
return '';
};
const readBool = (fallback: boolean, ...values: unknown[]): boolean => {
for (const value of values) {
if (typeof value === 'boolean') {
return value;
}
}
return fallback;
};
const readNumber = (fallback: number, ...values: unknown[]): number => {
for (const value of values) {
const num = Number(value);
if (Number.isFinite(num)) {
return num;
}
}
return fallback;
};
const rawSSH = (cloned.ssh ?? cloned.SSH ?? {}) as Record<string, unknown>;
const normalizedSSH = {
host: readString(rawSSH.host, rawSSH.Host, cloned.sshHost, cloned.SSHHost),
port: readNumber(22, rawSSH.port, rawSSH.Port, cloned.sshPort, cloned.SSHPort),
user: readString(rawSSH.user, rawSSH.User, cloned.sshUser, cloned.SSHUser),
password: readString(rawSSH.password, rawSSH.Password, cloned.sshPassword, cloned.SSHPassword),
keyPath: readString(rawSSH.keyPath, rawSSH.KeyPath, cloned.sshKeyPath, cloned.SSHKeyPath),
};
const hasSSHDetail = Boolean(
normalizedSSH.host
|| normalizedSSH.user
|| normalizedSSH.password
|| normalizedSSH.keyPath
);
const rawProxy = (cloned.proxy ?? cloned.Proxy ?? {}) as Record<string, unknown>;
const proxyTypeRaw = readString(rawProxy.type, rawProxy.Type, cloned.proxyType, cloned.ProxyType).toLowerCase();
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
const normalizedProxy = {
type: proxyType,
host: readString(rawProxy.host, rawProxy.Host, cloned.proxyHost, cloned.ProxyHost),
port: readNumber(proxyType === 'http' ? 8080 : 1080, rawProxy.port, rawProxy.Port, cloned.proxyPort, cloned.ProxyPort),
user: readString(rawProxy.user, rawProxy.User, cloned.proxyUser, cloned.ProxyUser),
password: readString(rawProxy.password, rawProxy.Password, cloned.proxyPassword, cloned.ProxyPassword),
};
const hasProxyDetail = Boolean(normalizedProxy.host || normalizedProxy.user || normalizedProxy.password);
const rawHttpTunnel = (cloned.httpTunnel ?? cloned.HTTPTunnel ?? {}) as Record<string, unknown>;
const normalizedHttpTunnel = {
host: readString(rawHttpTunnel.host, rawHttpTunnel.Host, cloned.httpTunnelHost, cloned.HttpTunnelHost),
port: readNumber(8080, rawHttpTunnel.port, rawHttpTunnel.Port, cloned.httpTunnelPort, cloned.HttpTunnelPort),
user: readString(rawHttpTunnel.user, rawHttpTunnel.User, cloned.httpTunnelUser, cloned.HttpTunnelUser),
password: readString(rawHttpTunnel.password, rawHttpTunnel.Password, cloned.httpTunnelPassword, cloned.HttpTunnelPassword),
};
const hasHttpTunnelDetail = Boolean(normalizedHttpTunnel.host || normalizedHttpTunnel.user || normalizedHttpTunnel.password);
const normalizedUseHttpTunnel = readBool(hasHttpTunnelDetail, cloned.useHttpTunnel, cloned.UseHTTPTunnel);
const normalizedUseProxy = !normalizedUseHttpTunnel && readBool(hasProxyDetail, cloned.useProxy, cloned.UseProxy);
const rawHosts = Array.isArray(cloned.hosts)
? cloned.hosts
: (Array.isArray(cloned.Hosts) ? cloned.Hosts : []);
const normalizedHosts = rawHosts
.map((entry: unknown) => String(entry || '').trim())
.filter((entry: string) => !!entry);
return {
...(cloned as SavedConnection['config']),
useSSH: readBool(hasSSHDetail, cloned.useSSH, cloned.UseSSH),
ssh: normalizedSSH,
useProxy: normalizedUseProxy,
proxy: normalizedProxy,
useHttpTunnel: normalizedUseHttpTunnel,
httpTunnel: normalizedHttpTunnel,
hosts: normalizedHosts,
timeout: readNumber(30, cloned.timeout, cloned.Timeout),
};
};
const handleDuplicateConnection = (conn: SavedConnection) => {
if (!conn) return;
const duplicatedConnection: SavedConnection = {
...conn,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: buildDuplicateConnectionName(conn.name),
config: cloneConnectionConfig(conn.config),
includeDatabases: conn.includeDatabases ? [...conn.includeDatabases] : undefined,
includeRedisDatabases: conn.includeRedisDatabases ? [...conn.includeRedisDatabases] : undefined,
};
addConnection(duplicatedConnection);
message.success(`已复制连接: ${duplicatedConnection.name}`);
};
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
return list.map(node => {
@@ -525,7 +431,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true;
if (dbType !== 'custom') return false;
const customDriver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
const customDriver = String(conn?.config?.driver || '').trim().toLowerCase();
return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver);
};
@@ -541,7 +447,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const getMetadataDialect = (conn: SavedConnection | undefined): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
const driver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
return driver;
}
@@ -567,7 +473,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'sphinx') return true;
if (type !== 'custom') return false;
const driver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
return driver === 'sphinx' || driver === 'sphinxql';
};
@@ -855,7 +761,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
for (const spec of normalizedSpecs) {
try {
const result = await DBQuery(config as any, dbName, spec.sql);
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, spec.sql);
if (!result.success || !Array.isArray(result.data)) {
continue;
}
@@ -986,7 +892,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
// Handle Redis connections differently
if (conn.config.type === 'redis') {
try {
const res = await (window as any).go.app.App.RedisGetDatabases(config);
const res = await (window as any).go.app.App.RedisGetDatabases(buildRpcConnectionConfig(config));
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const redisRows: any[] = Array.isArray(res.data) ? res.data : [];
@@ -1018,7 +924,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
try {
const res = await DBGetDatabases(config as any);
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
@@ -1036,13 +942,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
}
setTreeData(origin => updateTreeData(origin, node.key, dbs));
if (dbs.length > 0) {
setTreeData(origin => updateTreeData(origin, node.key, dbs));
} else {
// 空列表:清理 loadedKeys 以允许重新加载,不设置 children = []
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.warning({ content: '未获取到可见数据库/schema请检查账号权限或右键刷新', key: `conn-${conn.id}-dbs` });
}
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-dbs` });
} finally {
loadingNodesRef.current.delete(loadKey);
@@ -1084,7 +998,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
try {
const res = await DBGetTables(config as any, conn.dbName);
const res = await DBGetTables(buildRpcConnectionConfig(config) as any, conn.dbName);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
@@ -1448,6 +1362,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false);
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false);
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false);
else if (type === 'object-group' && dataRef?.groupKey === 'tables') {
// 单击延迟打开表概览,双击时会取消此定时器
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
const { id, dbName: gDbName, schemaName } = dataRef;
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
addTab({
id: `table-overview-${id}-${gDbName}${schemaName ? `-${schemaName}` : ''}`,
title: `表概览 - ${gDbName}${schemaName ? ` (${schemaName})` : ''}`,
type: 'table-overview' as any,
connectionId: id,
dbName: gDbName,
schemaName,
} as any);
}, 250);
}
};
const onExpand = (newExpandedKeys: React.Key[]) => {
@@ -1456,7 +1386,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onDoubleClick = (e: any, node: any) => {
// 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态
// 双击时取消单击延迟动作(如表概览打开),让双击只触发展开/折叠
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
const { type, dataRef, key: nodeKey } = node;
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
@@ -1464,18 +1398,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
const { id, dbName, schemaName } = node.dataRef;
addTab({
id: `table-overview-${id}-${dbName}${schemaName ? `-${schemaName}` : ''}`,
title: `表概览 - ${dbName}${schemaName ? ` (${schemaName})` : ''}`,
type: 'table-overview' as any,
connectionId: id,
dbName,
schemaName,
} as any);
return;
}
if (node.type === 'table') {
const { tableName, dbName, id } = node.dataRef;
// 记录表访问
@@ -1560,14 +1482,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const handleCopyStructure = async (node: any) => {
const { config, dbName, tableName } = node.dataRef;
const res = await DBShowCreateTable({
...config,
port: Number(config.port),
password: config.password || "",
database: config.database || "",
useSSH: config.useSSH || false,
ssh: config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
} as any, dbName, tableName);
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, dbName, tableName);
if (res.success) {
navigator.clipboard.writeText(res.data as string);
message.success('表结构已复制到剪贴板');
@@ -1579,14 +1494,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const handleExport = async (node: any, format: string) => {
const { config, dbName, tableName } = node.dataRef;
const hide = message.loading(`正在导出 ${tableName}${format.toUpperCase()}...`, 0);
const res = await ExportTable({
...config,
port: Number(config.port),
password: config.password || "",
database: config.database || "",
useSSH: config.useSSH || false,
ssh: config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
} as any, dbName, tableName, format);
const res = await ExportTable(buildRpcConnectionConfig(config) as any, dbName, tableName, format);
hide();
if (res.success) {
message.success('导出成功');
@@ -1595,14 +1503,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
};
const normalizeConnConfig = (raw: any) => ({
...raw,
port: Number(raw.port),
password: raw.password || "",
database: raw.database || "",
useSSH: raw.useSSH || false,
ssh: raw.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
});
const normalizeConnConfig = (raw: any) => (
buildRpcConnectionConfig(raw)
);
const handleExportDatabaseSQL = async (node: any, includeData: boolean) => {
const conn = node.dataRef;
@@ -1697,7 +1600,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
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) {
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
let dbs = dbRows.map((row: any) => {
@@ -1732,7 +1635,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const [res, viewResult] = await Promise.all([
DBGetTables(config as any, dbName),
DBGetTables(buildRpcConnectionConfig(config) as any, dbName),
loadViews(conn, dbName).catch(() => ({ views: [], supported: false })),
]);
@@ -1865,13 +1768,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const startTime = Date.now();
try {
const app = (window as any).go.app.App;
const res = await app.TruncateTables(normalizeConnConfig(conn.config), dbName, objectNames);
const res = await app.ClearTables(normalizeConnConfig(conn.config), dbName, objectNames);
hide();
const duration = Date.now() - startTime;
if (res.success) {
message.success('清空成功');
// 构造 SQL 日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) */\n`;
let logSql = `/* Clear Tables (${objectNames.length} tables) */\n`;
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
logSql += res.data.executedSQLs.join(';\n') + ';';
} else {
@@ -1890,7 +1793,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
} else if (res.message !== '已取消') {
message.error('清空失败: ' + res.message);
// 记录失败的日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) - FAILED */\n`;
let logSql = `/* Clear Tables (${objectNames.length} tables) - FAILED */\n`;
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
logSql += res.data.executedSQLs.join(';\n') + ';';
} else {
@@ -1912,7 +1815,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const errMsg = e?.message || String(e);
message.error('清空失败: ' + errMsg);
// 记录异常的日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) - ERROR */\n`;
let logSql = `/* Clear Tables (${objectNames.length} tables) - ERROR */\n`;
logSql += objectNames.map(name => name).join('; ');
addSqlLog({
id: Date.now().toString(),
@@ -2008,7 +1911,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
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) {
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
let dbs = dbRows.map((row: any) => {
@@ -2220,7 +2123,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const res = await CreateDatabase(config as any, values.name);
const res = await CreateDatabase(buildRpcConnectionConfig(config) as any, values.name);
if (res.success) {
message.success("数据库创建成功");
setIsCreateDbModalOpen(false);
@@ -2236,14 +2139,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {
return {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: clearDatabase ? "" : ((overrideDatabase ?? conn.config.database) || ""),
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
return buildRpcConnectionConfig(conn.config, {
database: clearDatabase ? '' : ((overrideDatabase ?? conn.config.database) || ''),
});
};
const getConnectionNodeRef = (connRef: any) => {
@@ -2285,7 +2183,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await RenameDatabase(config as any, oldDbName, newDbName);
const res = await RenameDatabase(buildRpcConnectionConfig(config) as any, oldDbName, newDbName);
if (res.success) {
message.success("数据库重命名成功");
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
@@ -2312,7 +2210,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropDatabase(config as any, dbName);
const res = await DropDatabase(buildRpcConnectionConfig(config) as any, dbName);
if (res.success) {
message.success("数据库删除成功");
closeTabsByDatabase(conn.id, dbName);
@@ -2342,7 +2240,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return;
}
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await RenameTable(config as any, conn.dbName, oldTableName, newTableName);
const res = await RenameTable(buildRpcConnectionConfig(config) as any, conn.dbName, oldTableName, newTableName);
if (res.success) {
message.success("表重命名成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
@@ -2367,7 +2265,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropTable(config as any, conn.dbName, tableName);
const res = await DropTable(buildRpcConnectionConfig(config) as any, conn.dbName, tableName);
if (res.success) {
message.success("表删除成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
@@ -2378,6 +2276,84 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
const handleTableDataDangerAction = async (node: any, action: TableDataDangerActionKind) => {
const conn = node.dataRef;
const tableName = String(conn.tableName || '').trim();
if (!tableName) return;
const { label, progressLabel } = getTableDataDangerActionMeta(action);
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: `确认${label}`,
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
okText: '继续',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!confirmed) return;
const config = buildRuntimeConfig(conn, conn.dbName);
const app = (window as any).go.app.App;
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
const startTime = Date.now();
try {
const res = await app[methodName](buildRpcConnectionConfig(config) as any, conn.dbName, [tableName]);
hide();
const duration = Date.now() - startTime;
const executedSQLs = Array.isArray(res.data?.executedSQLs) ? res.data.executedSQLs : [];
const logSql = executedSQLs.length > 0
? executedSQLs.join(';\n') + ';'
: `/* ${label} ${tableName} */`;
if (res.success) {
message.success(`${progressLabel}成功`);
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'success',
duration,
message: res.message,
dbName: conn.dbName,
affectedRows: res.data?.count || 0,
});
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
return;
}
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'error',
duration,
message: res.message,
dbName: conn.dbName,
});
if (res.message !== '已取消') {
message.error(`${progressLabel}失败: ${res.message}`);
}
} catch (e: any) {
const duration = Date.now() - startTime;
const errMsg = e?.message || String(e);
hide();
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: `/* ${label} ${tableName} - ERROR */`,
status: 'error',
duration,
message: errMsg,
dbName: conn.dbName,
});
message.error(`${progressLabel}失败: ${errMsg}`);
}
};
// --- 视图操作 ---
const openViewDefinition = (node: any) => {
const { viewName, dbName, id } = node.dataRef;
@@ -2427,7 +2403,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
}
if (query) {
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) {
const row = result.data[0] as Record<string, any>;
const def = row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
@@ -2493,7 +2469,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropView(config as any, conn.dbName, viewName);
const res = await DropView(buildRpcConnectionConfig(config) as any, conn.dbName, viewName);
if (res.success) {
message.success("视图删除成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
@@ -2520,7 +2496,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return;
}
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await RenameView(config as any, conn.dbName, oldViewName, newViewName);
const res = await RenameView(buildRpcConnectionConfig(config) as any, conn.dbName, oldViewName, newViewName);
if (res.success) {
message.success("视图重命名成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
@@ -2592,7 +2568,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
}
if (query) {
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) {
if (dialect === 'oracle' || dialect === 'dm') {
const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join('');
@@ -2686,7 +2662,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropFunction(config as any, conn.dbName, routineName, routineType);
const res = await DropFunction(buildRpcConnectionConfig(config) as any, conn.dbName, routineName, routineType);
if (res.success) {
message.success(`${typeLabel}删除成功`);
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
@@ -3082,7 +3058,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
onClick: () => {
const connKey = String(node.key);
// 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
// 清除 loadingNodesRef 中残留的子节点加载标记
Array.from(loadingNodesRef.current).forEach(lk => {
if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk);
});
loadDatabases(node);
}
},
{ type: 'divider' },
{
@@ -3158,9 +3144,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => {
closeTabsByConnection(String(node.key));
removeConnection(node.key);
onOk: async () => {
const connId = String(node.key);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DeleteConnection !== 'function') {
message.error('删除连接失败:后端接口不可用');
throw new Error('DeleteConnection unavailable');
}
try {
await backendApp.DeleteConnection(connId);
closeTabsByConnection(connId);
removeConnection(connId);
message.success('已删除连接');
} catch (error: any) {
message.error(error?.message || '删除连接失败');
throw error;
}
}
});
}
@@ -3199,7 +3198,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
onClick: () => {
const connKey = String(node.key);
// 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
// 清除 loadingNodesRef 中残留的子节点加载标记
Array.from(loadingNodesRef.current).forEach(lk => {
if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk);
});
loadDatabases(node);
}
},
{ type: 'divider' },
{
@@ -3285,9 +3294,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => {
closeTabsByConnection(String(node.key));
removeConnection(node.key);
onOk: async () => {
const connId = String(node.key);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DeleteConnection !== 'function') {
message.error('删除连接失败:后端接口不可用');
throw new Error('DeleteConnection unavailable');
}
try {
await backendApp.DeleteConnection(connId);
closeTabsByConnection(connId);
removeConnection(connId);
message.success('已删除连接');
} catch (error: any) {
message.error(error?.message || '删除连接失败');
throw error;
}
}
});
}
@@ -3359,11 +3381,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
},
{
key: 'drop-db',
label: '删除数据库',
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDeleteDatabase(node)
key: 'danger-zone',
label: '危险操作',
icon: <WarningOutlined />,
children: [
{
key: 'drop-db',
label: '删除数据库',
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDeleteDatabase(node)
}
]
},
{
key: 'refresh',
@@ -3476,11 +3505,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
},
{
key: 'drop-view',
label: '删除视图',
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDropView(node)
key: 'danger-zone',
label: '危险操作',
icon: <WarningOutlined />,
children: [
{
key: 'drop-view',
label: '删除视图',
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDropView(node)
}
]
},
];
} else if (node.type === 'routine') {
@@ -3501,11 +3537,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
},
{ type: 'divider' },
{
key: 'drop-routine',
label: `删除${typeLabel}`,
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDropRoutine(node)
key: 'danger-zone',
label: '危险操作',
icon: <WarningOutlined />,
children: [
{
key: 'drop-routine',
label: `删除${typeLabel}`,
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDropRoutine(node)
}
]
},
];
} else if (node.type === 'table') {
@@ -3557,11 +3600,30 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
},
{
key: 'drop-table',
label: '删除表',
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDeleteTable(node)
key: 'danger-zone',
label: '危险操作',
icon: <WarningOutlined />,
children: [
...(supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver) ? [{
key: 'truncate-table',
label: '截断表',
danger: true,
onClick: () => handleTableDataDangerAction(node, 'truncate')
}] : []),
{
key: 'clear-table',
label: '清空表',
danger: true,
onClick: () => handleTableDataDangerAction(node, 'clear')
},
{
key: 'drop-table',
label: '删除表',
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDeleteTable(node)
}
]
},
{
type: 'divider'
@@ -3822,29 +3884,31 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</Tooltip>
</div>
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<Tree
showIcon
draggable={{
icon: false,
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
}}
onDrop={handleDrop}
loadData={onLoadData}
treeData={displayTreeData}
onDoubleClick={onDoubleClick}
onSelect={onSelect}
titleRender={titleRender}
expandedKeys={expandedKeys}
onExpand={onExpand}
loadedKeys={loadedKeys}
onLoad={setLoadedKeys}
autoExpandParent={autoExpandParent}
selectedKeys={selectedKeys}
blockNode
height={treeHeight}
onRightClick={onRightClick}
/>
<div ref={treeContainerRef} className="sidebar-tree-scroll-shell" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<div className="sidebar-tree-scroll-content">
<Tree
showIcon
draggable={{
icon: false,
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
}}
onDrop={handleDrop}
loadData={onLoadData}
treeData={displayTreeData}
onDoubleClick={onDoubleClick}
onSelect={onSelect}
titleRender={titleRender}
expandedKeys={expandedKeys}
onExpand={onExpand}
loadedKeys={loadedKeys}
onLoad={setLoadedKeys}
autoExpandParent={autoExpandParent}
selectedKeys={selectedKeys}
blockNode
height={treeHeight}
onRightClick={onRightClick}
/>
</div>
</div>
{contextMenu && (

View File

@@ -9,6 +9,8 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg
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;
@@ -217,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' },
@@ -759,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);
@@ -856,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';
@@ -1001,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);
@@ -1038,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);
@@ -1047,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);
@@ -1441,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 => {
@@ -1507,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);
@@ -1536,7 +1553,7 @@ ${selectedTrigger.statement}`;
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);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', stmt);
if (!res.success) {
const prefix = statements.length > 1 ? `${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
return {
@@ -2102,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) => ({
@@ -2928,20 +2884,34 @@ 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'}
/>

View File

@@ -1,9 +1,12 @@
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 { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
interface TableOverviewProps {
tab: TabData;
@@ -22,6 +25,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 '—';
@@ -146,8 +150,10 @@ 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]);
const autoFetchVisible = useAutoFetchVisibility();
const loadData = useCallback(async () => {
if (!connection) return;
@@ -161,9 +167,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 {
@@ -176,7 +182,12 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
}
}, [connection, tab.dbName]);
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => {
if (!autoFetchVisible) {
return;
}
void loadData();
}, [autoFetchVisible, loadData]);
const sortedFiltered = useMemo(() => {
let list = [...tables];
@@ -237,7 +248,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('表结构已复制到剪贴板');
@@ -250,7 +261,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('导出成功');
@@ -267,7 +278,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();
@@ -278,6 +289,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;
@@ -297,7 +342,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();
@@ -335,6 +380,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 (
@@ -366,14 +415,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))',
@@ -401,7 +479,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') },
@@ -451,6 +533,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

@@ -5,6 +5,7 @@ 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;
@@ -124,7 +125,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
setContextLoading(true);
setSelectedDbName(dbName);
try {
const res = await DBGetTables(connConfig, dbName);
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 {
@@ -155,7 +156,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
try {
// Fetch databases
const dbRes = await DBGetDatabases(conn.config as any);
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);
@@ -164,7 +165,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
// Fetch tables for the active contextual database
const initDbName = activeContext.dbName || '';
setSelectedDbName(initDbName);
const tablesRes = await DBGetTables(conn.config as any, 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 {
@@ -201,7 +202,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
continue;
}
const res = await DBShowCreateTable(conn.config as any, dbName, tableName);
const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName);
let createSql = '';
if (res.success && res.data) {
if (typeof res.data === 'string') {

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

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

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

@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./App', () => ({
default: () => null,
}));
const createRootMock = vi.fn(() => ({
render: vi.fn(),
}));
vi.mock('react-dom/client', () => ({
default: {
createRoot: createRootMock,
},
createRoot: createRootMock,
}));
const dayjsLocaleMock = vi.fn();
vi.mock('dayjs', () => ({
default: Object.assign(() => null, {
locale: dayjsLocaleMock,
}),
}));
vi.mock('dayjs/locale/zh-cn', () => ({}));
const loaderConfigMock = vi.fn();
vi.mock('@monaco-editor/react', () => ({
loader: {
config: loaderConfigMock,
},
}));
const defineThemeMock = vi.fn();
vi.mock('monaco-editor', () => ({
editor: {
defineTheme: defineThemeMock,
},
}));
vi.mock('monaco-editor/esm/nls.messages.zh-cn', () => ({}));
const importMain = async () => {
await import('./main');
return (globalThis as typeof globalThis & {
window: {
go?: {
app?: {
App?: {
ImportConfigFile: () => Promise<{ success: boolean; message?: string }>;
ImportConnectionsPayload: (raw: string, password?: string) => Promise<unknown>;
ExportConnectionsPackage: (options?: { includeSecrets?: boolean; filePassword?: string }) => Promise<{ success: boolean; message?: string }>;
};
};
};
};
}).window.go?.app?.App;
};
describe('main browser mock', () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
getElementById: vi.fn(() => ({})),
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
vi.resetModules();
});
it('returns explicit browser-mode messages for import picker and package export', async () => {
const app = await importMain();
expect(app).toBeDefined();
await expect(app!.ImportConfigFile()).resolves.toEqual({
success: false,
message: '已取消',
});
await expect(app!.ExportConnectionsPackage({ includeSecrets: true, filePassword: '' })).resolves.toEqual({
success: false,
message: '浏览器 mock 不支持恢复包导出',
});
});
it('rejects non-array payloads instead of treating them as successful imports', async () => {
const app = await importMain();
await expect(app!.ImportConnectionsPayload('{"version":1}')).rejects.toThrow(
'浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组',
);
});
});

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,45 @@ 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 }),
CheckForUpdatesSilently: 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 }),
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
ImportConnectionsPayload: async (raw: string, _password?: string) => {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((item) => saveMockConnection(item));
}
} catch {
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
}
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
},
ExportConnectionsPackage: async (_options?: { includeSecrets?: boolean; filePassword?: string }) => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }),
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 +179,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);
});
});

142
frontend/src/store.test.ts Normal file
View File

@@ -0,0 +1,142 @@
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');
});
it('does not clear persisted legacy connections during hydration migration', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
},
version: 7,
}));
const { useStore } = await importStore();
expect(useStore.getState().connections).toHaveLength(1);
expect(useStore.getState().connections[0]?.config.password).toBe('secret');
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
version: 7,
}));
const { useStore } = await importStore();
expect(useStore.getState().globalProxy.password).toBe('proxy-secret');
expect(useStore.getState().globalProxy.hasPassword).toBe(true);
});
});

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem } 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, useNativeMacWindowControls: false };
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 = 7;
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; useNativeMacWindowControls: boolean };
appearance: AppearanceSettings;
uiScale: number;
fontSize: number;
startupFullscreen: boolean;
@@ -440,6 +466,7 @@ interface AppState {
addConnection: (conn: SavedConnection) => void;
updateConnection: (conn: SavedConnection) => void;
removeConnection: (id: string) => void;
replaceConnections: (connections: SavedConnection[]) => void;
addConnectionTag: (tag: ConnectionTag) => void;
updateConnectionTag: (tag: ConnectionTag) => void;
@@ -463,11 +490,12 @@ interface AppState {
deleteQuery: (id: string) => void;
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }>) => 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;
@@ -525,6 +553,34 @@ const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
return result;
};
const hasLegacyConnectionSecrets = (connections: SavedConnection[]): boolean => {
return connections.some((connection) => {
const config = connection?.config && typeof connection.config === 'object'
? connection.config as unknown as Record<string, unknown>
: {};
const ssh = config.ssh && typeof config.ssh === 'object'
? config.ssh as Record<string, unknown>
: {};
const proxy = config.proxy && typeof config.proxy === 'object'
? config.proxy as Record<string, unknown>
: {};
const httpTunnel = config.httpTunnel && typeof config.httpTunnel === 'object'
? config.httpTunnel as Record<string, unknown>
: {};
return (
toTrimmedString(config.password) !== ''
|| toTrimmedString(ssh.password) !== ''
|| toTrimmedString(proxy.password) !== ''
|| toTrimmedString(httpTunnel.password) !== ''
|| toTrimmedString(config.mysqlReplicaPassword) !== ''
|| toTrimmedString(config.mongoReplicaPassword) !== ''
|| toTrimmedString(config.uri) !== ''
|| toTrimmedString(config.dsn) !== ''
);
});
};
const sanitizeTheme = (value: unknown): 'light' | 'dark' => (value === 'dark' ? 'dark' : 'light');
const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'lower' } => {
@@ -586,12 +642,13 @@ const sanitizeTableHiddenColumns = (value: unknown): Record<string, string[]> =>
};
const sanitizeAppearance = (
appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined,
appearance: Partial<AppearanceSettings> | undefined,
version: number
): { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean } => {
): 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,
@@ -599,6 +656,8 @@ const sanitizeAppearance = (
useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean'
? appearance.useNativeMacWindowControls
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
showDataTableVerticalBorders: dataGridDisplaySettings.showDataTableVerticalBorders,
dataTableColumnWidthMode: dataGridDisplaySettings.dataTableColumnWidthMode,
};
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
return { ...DEFAULT_APPEARANCE };
@@ -618,18 +677,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,
};
};
@@ -782,6 +847,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) => ({
@@ -963,6 +1029,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) => ({
@@ -1270,31 +1337,39 @@ export const useStore = create<AppState>()(
aiChatSessions: [],
};
},
partialize: (state) => ({
connections: state.connections,
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
uiScale: state.uiScale,
fontSize: state.fontSize,
startupFullscreen: state.startupFullscreen,
globalProxy: state.globalProxy,
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: state.shortcutOptions,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,
enableColumnOrderMemory: state.enableColumnOrderMemory,
tableHiddenColumns: state.tableHiddenColumns,
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
windowBounds: state.windowBounds,
windowState: state.windowState,
sidebarWidth: state.sidebarWidth,
partialize: (state) => {
const partialState: Partial<AppState> = {
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
uiScale: state.uiScale,
fontSize: state.fontSize,
startupFullscreen: state.startupFullscreen,
globalProxy: toTrimmedString(state.globalProxy.password) !== ''
? { ...state.globalProxy }
: toPersistedGlobalProxy(state.globalProxy),
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: state.shortcutOptions,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,
enableColumnOrderMemory: state.enableColumnOrderMemory,
tableHiddenColumns: state.tableHiddenColumns,
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
windowBounds: state.windowBounds,
windowState: state.windowState,
sidebarWidth: state.sidebarWidth,
};
if (hasLegacyConnectionSecrets(state.connections)) {
partialState.connections = state.connections;
}
// AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage
}), // Don't persist logs
return partialState as AppState;
}, // 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;
@@ -201,6 +217,8 @@ export interface AIProviderConfig {
type: AIProviderType;
name: string;
apiKey: string;
secretRef?: string;
hasSecret?: boolean;
baseUrl: string;
model: string;
models?: string[];
@@ -243,3 +261,71 @@ export interface AISafetyResult {
requiresConfirm: boolean;
warningMessage?: string;
}
export type SecurityUpdateOverallStatus =
| 'not_detected'
| 'pending'
| 'postponed'
| 'in_progress'
| 'needs_attention'
| 'completed'
| 'rolled_back';
export type SecurityUpdateIssueScope = 'connection' | 'global_proxy' | 'ai_provider' | 'system';
export type SecurityUpdateIssueSeverity = 'high' | 'medium' | 'low';
export type SecurityUpdateItemStatus = 'pending' | 'updated' | 'needs_attention' | 'skipped' | 'failed';
export type SecurityUpdateIssueReasonCode =
| 'migration_required'
| 'secret_missing'
| 'field_invalid'
| 'write_conflict'
| 'validation_failed'
| 'environment_blocked';
export type SecurityUpdateIssueAction =
| 'open_connection'
| 'open_proxy_settings'
| 'open_ai_settings'
| 'retry_update'
| 'view_details';
export interface SecurityUpdateSummary {
total: number;
updated: number;
pending: number;
skipped: number;
failed: number;
}
export interface SecurityUpdateIssue {
id: string;
scope?: SecurityUpdateIssueScope;
refId?: string;
title?: string;
severity?: SecurityUpdateIssueSeverity;
status?: SecurityUpdateItemStatus;
reasonCode?: SecurityUpdateIssueReasonCode;
action?: SecurityUpdateIssueAction;
message?: string;
}
export interface SecurityUpdateStatus {
schemaVersion?: number;
migrationId?: string;
overallStatus: SecurityUpdateOverallStatus;
sourceType?: 'current_app_saved_config';
reminderVisible?: boolean;
canStart?: boolean;
canPostpone?: boolean;
canRetry?: boolean;
backupAvailable?: boolean;
backupPath?: string;
startedAt?: string;
updatedAt?: string;
completedAt?: string;
postponedAt?: string;
summary: SecurityUpdateSummary;
issues: SecurityUpdateIssue[];
lastError?: string;
}

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,22 @@
import { describe, expect, it } from 'vitest';
import { isAutoFetchVisible } from './autoFetchVisibility';
describe('isAutoFetchVisible', () => {
it('allows auto fetch only when the document is visible and not hidden', () => {
expect(isAutoFetchVisible({ hidden: false, visibilityState: 'visible' })).toBe(true);
});
it('blocks auto fetch when the page is hidden even if visibilityState looks visible', () => {
expect(isAutoFetchVisible({ hidden: true, visibilityState: 'visible' })).toBe(false);
});
it('blocks auto fetch when visibilityState is not visible', () => {
expect(isAutoFetchVisible({ hidden: false, visibilityState: 'hidden' })).toBe(false);
});
it('defaults to allowing auto fetch when document visibility APIs are unavailable', () => {
expect(isAutoFetchVisible(undefined)).toBe(true);
expect(isAutoFetchVisible({})).toBe(true);
});
});

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
type AutoFetchVisibilitySource = Partial<Pick<Document, 'hidden' | 'visibilityState'>> | undefined;
export const isAutoFetchVisible = (source?: AutoFetchVisibilitySource): boolean => {
if (!source) {
return true;
}
if (source.hidden === true) {
return false;
}
if (source.visibilityState && source.visibilityState !== 'visible') {
return false;
}
return true;
};
const getDocumentAutoFetchVisibility = (): boolean => {
if (typeof document === 'undefined') {
return true;
}
return isAutoFetchVisible(document);
};
export const useAutoFetchVisibility = (): boolean => {
const [isVisible, setIsVisible] = useState<boolean>(() => getDocumentAutoFetchVisibility());
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const syncVisibility = () => {
setIsVisible(getDocumentAutoFetchVisibility());
};
syncVisibility();
document.addEventListener('visibilitychange', syncVisibility);
window.addEventListener('focus', syncVisibility);
window.addEventListener('pageshow', syncVisibility);
return () => {
document.removeEventListener('visibilitychange', syncVisibility);
window.removeEventListener('focus', syncVisibility);
window.removeEventListener('pageshow', syncVisibility);
};
}, []);
return isVisible;
};

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,186 @@
import { describe, expect, it } from 'vitest';
import {
detectConnectionImportKind,
isConnectionPackagePasswordRequiredError,
isConnectionPackageExportCanceled,
resolveConnectionPackageExportResult,
normalizeConnectionPackagePassword,
} from './connectionExport';
describe('connectionExport', () => {
it('detects v2 app-managed packages', () => {
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 1,
exportedAt: '2026-04-11T21:00:00Z',
connections: [],
}))).toBe('app-managed-package');
});
it('detects v2 encrypted packages', () => {
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 2,
kdf: {
n: 'a2id',
m: 65536,
t: 3,
l: 4,
s: 'c2FsdA==',
},
nc: 'bm9uY2Utbm9uY2U=',
d: 'encrypted-data',
}))).toBe('encrypted-package');
});
it('rejects malformed v2 app-managed packages without connections array', () => {
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 1,
exportedAt: '2026-04-11T21:00:00Z',
}))).toBe('invalid');
});
it('rejects malformed v2 encrypted packages without protected payload fields', () => {
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 2,
kdf: {
n: 'a2id',
m: 65536,
t: 3,
l: 4,
},
}))).toBe('invalid');
});
it('detects v1 encrypted packages by gonavi envelope kind', () => {
expect(detectConnectionImportKind(JSON.stringify({
schemaVersion: 1,
kind: 'gonavi_connection_package',
cipher: 'AES-256-GCM',
kdf: {
name: 'Argon2id',
memoryKiB: 65536,
timeCost: 3,
parallelism: 4,
salt: 'c2FsdA==',
},
nonce: 'bm9uY2Utbm9uY2U=',
payload: 'encrypted-data',
}))).toBe('encrypted-package');
});
it('detects legacy imports from historical json arrays', () => {
expect(detectConnectionImportKind(JSON.stringify([
{
id: 'conn-1',
name: 'Primary',
config: {
type: 'postgres',
},
},
]))).toBe('legacy-json');
});
it('returns invalid for malformed or unsupported content', () => {
expect(detectConnectionImportKind('{not-json}')).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
p: 0,
}))).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
v: 2,
kind: 'gonavi_connection_package',
}))).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
kind: 'gonavi_connection_package',
payload: 'encrypted-data',
}))).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify([
{
foo: 'bar',
},
]))).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({
kind: 'other_package',
payload: 'encrypted-data',
}))).toBe('invalid');
expect(detectConnectionImportKind('null')).toBe('invalid');
});
it('trims package passwords before use', () => {
expect(normalizeConnectionPackagePassword(' secret-pass ')).toBe('secret-pass');
expect(normalizeConnectionPackagePassword('\n\t \t')).toBe('');
});
it('recognizes backend password-required errors for protected packages', () => {
expect(isConnectionPackagePasswordRequiredError(new Error('恢复包密码不能为空'))).toBe(true);
expect(isConnectionPackagePasswordRequiredError({ message: '恢复包密码不能为空' })).toBe(true);
expect(isConnectionPackagePasswordRequiredError('恢复包密码不能为空')).toBe(true);
expect(isConnectionPackagePasswordRequiredError(new Error('文件密码错误或文件已损坏'))).toBe(false);
expect(isConnectionPackagePasswordRequiredError(undefined)).toBe(false);
});
it('treats export cancel as a non-error backend result', () => {
expect(isConnectionPackageExportCanceled({ success: false, message: '已取消' })).toBe(true);
expect(isConnectionPackageExportCanceled({ success: false, message: '导出失败' })).toBe(false);
expect(isConnectionPackageExportCanceled({ success: true, message: '已取消' })).toBe(false);
expect(isConnectionPackageExportCanceled(undefined)).toBe(false);
});
it('maps export results to dialog state transitions', () => {
const staleDialog = {
open: true,
mode: 'export' as const,
includeSecrets: true,
useFilePassword: false,
password: ' secret-pass ',
error: '上一次失败',
confirmLoading: false,
};
const canceledResult = resolveConnectionPackageExportResult(staleDialog, { success: false, message: '已取消' });
expect(canceledResult.kind).toBe('canceled');
if (canceledResult.kind === 'canceled') {
expect(typeof canceledResult.nextDialog).toBe('function');
expect((canceledResult.nextDialog as (current: typeof staleDialog) => typeof staleDialog)({
open: false,
mode: 'export',
includeSecrets: true,
useFilePassword: false,
password: 'secret-pass',
error: '更新后的错误',
confirmLoading: true,
})).toEqual({
open: false,
mode: 'export',
includeSecrets: true,
useFilePassword: false,
password: 'secret-pass',
error: '',
confirmLoading: false,
});
}
expect(resolveConnectionPackageExportResult(staleDialog, { success: true, message: '导出完成' })).toEqual({
kind: 'succeeded',
});
expect(resolveConnectionPackageExportResult(staleDialog, { success: false, message: '磁盘已满' })).toEqual({
kind: 'failed',
error: '磁盘已满',
});
expect(resolveConnectionPackageExportResult(staleDialog, undefined)).toEqual({
kind: 'failed',
error: '导出失败',
});
});
});

View File

@@ -0,0 +1,189 @@
import type { ConnectionConfig, SavedConnection } from '../types';
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'invalid';
export type ConnectionPackageDialogSnapshot = {
open: boolean;
mode: 'export' | 'import';
includeSecrets: boolean;
useFilePassword: boolean;
password: string;
error: string;
confirmLoading: boolean;
};
export type ConnectionPackageDialogUpdater = (
current: ConnectionPackageDialogSnapshot,
) => ConnectionPackageDialogSnapshot;
export type ConnectionPackageExportResult =
| { kind: 'canceled'; nextDialog: ConnectionPackageDialogUpdater }
| { kind: 'succeeded' }
| { kind: 'failed'; error: string };
type JsonObject = Record<string, unknown>;
const CONNECTION_PACKAGE_KIND = 'gonavi_connection_package';
const CONNECTION_PACKAGE_SCHEMA_VERSION_V2 = 2;
const CONNECTION_PACKAGE_PROTECTION_APP_MANAGED = 1;
const CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD = 2;
const CANCELED_MESSAGE = '已取消';
const CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE = '恢复包密码不能为空';
const isJsonObject = (value: unknown): value is JsonObject => (
typeof value === 'object' && value !== null && !Array.isArray(value)
);
const isConnectionPackageKDF = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.name === 'string'
&& typeof value.memoryKiB === 'number'
&& typeof value.timeCost === 'number'
&& typeof value.parallelism === 'number'
&& typeof value.salt === 'string'
);
const isConnectionPackageEnvelope = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.schemaVersion === 'number'
&& value.kind === CONNECTION_PACKAGE_KIND
&& typeof value.cipher === 'string'
&& isConnectionPackageKDF(value.kdf)
&& typeof value.nonce === 'string'
&& typeof value.payload === 'string'
);
const isConnectionPackageV2Envelope = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& value.kind === CONNECTION_PACKAGE_KIND
&& value.v === CONNECTION_PACKAGE_SCHEMA_VERSION_V2
&& typeof value.p === 'number'
);
const isConnectionPackageKDFV2 = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.n === 'string'
&& typeof value.m === 'number'
&& typeof value.t === 'number'
&& typeof value.l === 'number'
&& typeof value.s === 'string'
);
const isConnectionPackageV2AppManagedEnvelope = (value: unknown): value is JsonObject => (
isConnectionPackageV2Envelope(value)
&& value.p === CONNECTION_PACKAGE_PROTECTION_APP_MANAGED
&& Array.isArray(value.connections)
);
const isConnectionPackageV2ProtectedEnvelope = (value: unknown): value is JsonObject => (
isConnectionPackageV2Envelope(value)
&& value.p === CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD
&& isConnectionPackageKDFV2(value.kdf)
&& typeof value.nc === 'string'
&& typeof value.d === 'string'
);
const isLegacyConnectionConfig = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.type === 'string'
);
const isLegacyConnectionItem = (value: unknown): value is JsonObject => (
isJsonObject(value)
&& typeof value.id === 'string'
&& typeof value.name === 'string'
&& isLegacyConnectionConfig(value.config)
);
const parseConnectionImportRaw = (raw: unknown): unknown => {
if (typeof raw !== 'string') {
return raw;
}
try {
return JSON.parse(raw);
} catch {
return undefined;
}
};
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
const parsed = parseConnectionImportRaw(raw);
if (isConnectionPackageV2AppManagedEnvelope(parsed)) {
return 'app-managed-package';
}
if (isConnectionPackageV2ProtectedEnvelope(parsed)) {
return 'encrypted-package';
}
if (isConnectionPackageV2Envelope(parsed)) {
return 'invalid';
}
if (Array.isArray(parsed) && parsed.every((item) => isLegacyConnectionItem(item))) {
return 'legacy-json';
}
if (isConnectionPackageEnvelope(parsed)) {
return 'encrypted-package';
}
return 'invalid';
};
export const normalizeConnectionPackagePassword = (value: string): string => value.trim();
export const isConnectionPackagePasswordRequiredError = (value: unknown): boolean => {
if (typeof value === 'string') {
return value.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
}
if (value instanceof Error) {
return value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
}
return isJsonObject(value)
&& typeof value.message === 'string'
&& value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
};
export const isConnectionPackageExportCanceled = (result: unknown): boolean => (
isJsonObject(result)
&& result.success === false
&& result.message === CANCELED_MESSAGE
);
export const resolveConnectionPackageExportResult = (
_currentDialog: ConnectionPackageDialogSnapshot,
result: unknown,
): ConnectionPackageExportResult => {
if (isConnectionPackageExportCanceled(result)) {
return {
kind: 'canceled',
nextDialog: (current) => ({
...current,
confirmLoading: false,
error: '',
}),
};
}
if (isJsonObject(result) && result.success === true) {
return { kind: 'succeeded' };
}
return {
kind: 'failed',
error: isJsonObject(result) && typeof result.message === 'string' && result.message.trim()
? result.message
: '导出失败',
};
};
const legacyExportRemovedError = (): never => {
throw new Error('Legacy connection JSON export has been removed. Use the recovery package flow instead.');
};
export const sanitizeConnectionConfigForExport = (_config: ConnectionConfig): never => legacyExportRemovedError();
export const buildExportableConnections = (_connections: SavedConnection[]): never => legacyExportRemovedError();

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
resolveConnectionTestFailureFeedback,
} from './connectionModalPresentation';
describe('connectionModalPresentation', () => {
it('shows an explicit stored-secret placeholder instead of an empty-looking password field', () => {
expect(getStoredSecretPlaceholder({
hasStoredSecret: true,
emptyPlaceholder: '密码',
retainedLabel: '已保存密码',
})).toBe('••••••(留空表示继续沿用已保存密码)');
});
it('keeps the original placeholder when no stored secret exists', () => {
expect(getStoredSecretPlaceholder({
hasStoredSecret: false,
emptyPlaceholder: '密码',
retainedLabel: '已保存密码',
})).toBe('密码');
});
it('maps missing saved-connection errors to a secret-specific hint', () => {
expect(normalizeConnectionSecretErrorMessage('saved connection not found: conn-1')).toBe(
'未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
);
});
it('preserves existing user-facing messages', () => {
expect(normalizeConnectionSecretErrorMessage('连接测试超时')).toBe('连接测试超时');
});
it('shows a toast-worthy failure message for saved-secret lookup errors during connection tests', () => {
expect(resolveConnectionTestFailureFeedback({
kind: 'runtime',
reason: 'saved connection not found: conn-1',
fallback: '连接失败',
})).toEqual({
message: '测试失败: 未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
shouldToast: true,
});
});
it('keeps required-field validation failures inline without an extra toast', () => {
expect(resolveConnectionTestFailureFeedback({
kind: 'validation',
reason: '',
fallback: '连接失败',
})).toEqual({
message: '测试失败: 请先完善必填项后再测试连接',
shouldToast: false,
});
});
});

View File

@@ -0,0 +1,78 @@
type StoredSecretPlaceholderOptions = {
hasStoredSecret?: boolean;
emptyPlaceholder: string;
retainedLabel: string;
};
type ConnectionTestFailureKind =
| 'validation'
| 'runtime'
| 'driver_unavailable'
| 'secret_blocked';
type ConnectionTestFailureFeedback = {
message: string;
shouldToast: boolean;
};
const normalizeText = (value: unknown, fallback = ''): string => {
const text = String(value ?? '').trim();
if (!text || text === 'undefined' || text === 'null') {
return fallback;
}
return text;
};
export const getStoredSecretPlaceholder = ({
hasStoredSecret,
emptyPlaceholder,
retainedLabel,
}: StoredSecretPlaceholderOptions): string => (
hasStoredSecret
? `••••••(留空表示继续沿用${retainedLabel}`
: emptyPlaceholder
);
export const normalizeConnectionSecretErrorMessage = (
value: unknown,
fallback = '',
): string => {
const text = normalizeText(value, fallback);
const lower = text.toLowerCase();
if (lower.includes('saved connection not found:')) {
return '未找到当前连接对应的已保存密文,请重新填写密码并保存后再试';
}
if (lower.includes('secret store unavailable')) {
return '系统密文存储当前不可用,请检查系统钥匙串或凭据管理器后再试';
}
return text;
};
export const resolveConnectionTestFailureFeedback = ({
kind,
reason,
fallback,
}: {
kind: ConnectionTestFailureKind;
reason: unknown;
fallback: string;
}): ConnectionTestFailureFeedback => {
if (kind === 'validation') {
return {
message: '测试失败: 请先完善必填项后再测试连接',
shouldToast: false,
};
}
return {
message: `测试失败: ${normalizeConnectionSecretErrorMessage(reason, fallback)}`,
shouldToast: true,
};
};
export type {
ConnectionTestFailureFeedback,
ConnectionTestFailureKind,
};

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,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,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,183 @@
import { describe, expect, it } from 'vitest';
import {
hasLegacyMigratableSensitiveItems,
readLegacyPersistedSecrets,
stripLegacyPersistedConnectionById,
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('clears legacy connection and proxy source data after cleanup', () => {
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).toBeUndefined();
});
it('treats a meaningful legacy global proxy as migratable even when it has no password', () => {
const payload = JSON.stringify({
state: {
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
},
},
});
expect(hasLegacyMigratableSensitiveItems(payload)).toBe(true);
});
it('detects migratable sensitive items before cleanup and clears the signal after cleanup', () => {
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',
},
},
});
expect(hasLegacyMigratableSensitiveItems(payload)).toBe(true);
expect(hasLegacyMigratableSensitiveItems(stripLegacyPersistedSecrets(payload))).toBe(false);
});
it('removes only the repaired legacy connection while preserving other source data', () => {
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',
},
},
{
id: 'conn-2',
name: 'Replica',
config: {
id: 'conn-2',
type: 'mysql',
host: 'replica.local',
port: 3306,
user: 'root',
password: 'replica-secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
});
const sanitized = stripLegacyPersistedConnectionById(payload, 'conn-1');
const parsed = JSON.parse(sanitized);
expect(parsed.state.connections).toEqual([
expect.objectContaining({
id: 'conn-2',
config: expect.objectContaining({
password: 'replica-secret',
}),
}),
]);
expect(parsed.state.globalProxy).toEqual(expect.objectContaining({
password: 'proxy-secret',
}));
});
});

View File

@@ -0,0 +1,142 @@
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 hasLegacyMigratableSensitiveItems(payload: string | null | undefined): boolean {
const legacy = readLegacyPersistedSecrets(payload);
return legacy.connections.length > 0 || legacy.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 !== undefined) {
delete state.globalProxy;
}
return JSON.stringify(parsed);
}
export function stripLegacyPersistedConnectionById(
payload: string | null | undefined,
connectionId: string,
): 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;
const targetId = toTrimmedString(connectionId);
if (!targetId || !Array.isArray(state.connections)) {
return payload;
}
state.connections = state.connections.filter((item) => {
if (!item || typeof item !== 'object') {
return true;
}
return toTrimmedString((item as { id?: unknown }).id) !== targetId;
});
return JSON.stringify(parsed);
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { shouldEnableMacWindowDiagnostics } from './macWindowDiagnostics';
describe('macWindowDiagnostics', () => {
it('stays disabled outside macOS runtime', () => {
expect(shouldEnableMacWindowDiagnostics(false, true)).toBe(false);
});
it('stays disabled for production builds on macOS', () => {
expect(shouldEnableMacWindowDiagnostics(true, false)).toBe(false);
});
it('enables diagnostics only for macOS development builds', () => {
expect(shouldEnableMacWindowDiagnostics(true, true)).toBe(true);
});
});

View File

@@ -0,0 +1,6 @@
export const shouldEnableMacWindowDiagnostics = (
isMacRuntime: boolean,
isDevBuild: boolean,
): boolean => {
return isMacRuntime && isDevBuild;
};

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { resolveProviderSecretDraft } from './providerSecretDraft';
describe('resolveProviderSecretDraft', () => {
it('keeps existing provider secret when edit form leaves apiKey blank', () => {
const result = resolveProviderSecretDraft({
hasSecret: true,
apiKeyInput: '',
clearSecret: false,
});
expect(result.mode).toBe('keep');
expect(result.apiKey).toBe('');
expect(result.hasSecret).toBe(true);
});
it('replaces the provider secret when a new apiKey is entered', () => {
const result = resolveProviderSecretDraft({
hasSecret: true,
apiKeyInput: ' sk-new ',
clearSecret: false,
});
expect(result.mode).toBe('replace');
expect(result.apiKey).toBe('sk-new');
expect(result.hasSecret).toBe(true);
});
it('clears the stored provider secret when requested', () => {
const result = resolveProviderSecretDraft({
hasSecret: true,
apiKeyInput: '',
clearSecret: true,
});
expect(result.mode).toBe('clear');
expect(result.apiKey).toBe('');
expect(result.hasSecret).toBe(false);
});
});

View File

@@ -0,0 +1,47 @@
export type ProviderSecretDraftMode = 'keep' | 'replace' | 'clear';
export interface ProviderSecretDraftInput {
hasSecret?: boolean;
apiKeyInput?: string;
clearSecret?: boolean;
}
export interface ProviderSecretDraftResult {
mode: ProviderSecretDraftMode;
apiKey: string;
hasSecret: boolean;
}
export function resolveProviderSecretDraft(input: ProviderSecretDraftInput): ProviderSecretDraftResult {
const apiKey = String(input.apiKeyInput || '').trim();
if (input.clearSecret) {
return {
mode: 'clear',
apiKey: '',
hasSecret: false,
};
}
if (apiKey) {
return {
mode: 'replace',
apiKey,
hasSecret: true,
};
}
if (input.hasSecret) {
return {
mode: 'keep',
apiKey: '',
hasSecret: true,
};
}
return {
mode: 'clear',
apiKey: '',
hasSecret: false,
};
}

View File

@@ -0,0 +1,698 @@
import { describe, expect, it, vi } from 'vitest';
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
import {
bootstrapSecureConfig,
finalizeSecurityUpdateStatus,
mergeSecurityUpdateStatusWithLegacySource,
startSecurityUpdateFromBootstrap,
} from './secureConfigBootstrap';
import { stripLegacyPersistedConnectionById } from './legacyConnectionStorage';
const legacyPayload = JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-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 createMemoryStorage = () => {
const data = new Map<string, string>();
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, value);
},
removeItem: (key: string) => {
data.delete(key);
},
};
};
const createBaseArgs = (storage = createMemoryStorage()) => {
const replaceConnections = vi.fn();
const replaceGlobalProxy = vi.fn();
storage.setItem(LEGACY_PERSIST_KEY, legacyPayload);
return {
storage,
replaceConnections,
replaceGlobalProxy,
};
};
describe('secureConfigBootstrap', () => {
it('builds legacy pending summary and issue list before the first round starts', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.status.overallStatus).toBe('pending');
expect(result.status.summary).toEqual({
total: 2,
updated: 0,
pending: 2,
skipped: 0,
failed: 0,
});
expect(result.status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({
scope: 'connection',
refId: 'legacy-1',
action: 'open_connection',
}),
expect.objectContaining({
scope: 'global_proxy',
action: 'open_proxy_settings',
}),
]));
});
it('shows intro when legacy sensitive items exist and backend status is pending', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'pending',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.status.overallStatus).toBe('pending');
expect(result.shouldShowIntro).toBe(true);
expect(result.shouldShowBanner).toBe(false);
expect(args.replaceConnections).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'legacy-1' })]),
);
});
it('keeps banner flow without intro when backend status is postponed', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'postponed',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.shouldShowIntro).toBe(false);
expect(result.shouldShowBanner).toBe(true);
});
it('keeps legacy pending summary and issues when a pre-start round is postponed', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'postponed',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.status.overallStatus).toBe('postponed');
expect(result.status.summary.total).toBe(2);
expect(result.status.summary.pending).toBe(2);
expect(result.status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ scope: 'connection', refId: 'legacy-1' }),
expect.objectContaining({ scope: 'global_proxy' }),
]));
});
it('merges backend pending issues with legacy source items before the first round starts', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'pending',
summary: { total: 1, updated: 0, pending: 1, skipped: 0, failed: 0 },
issues: [
{
id: 'ai-provider-openai-main',
scope: 'ai_provider',
refId: 'openai-main',
title: 'OpenAI',
severity: 'medium',
status: 'pending',
reasonCode: 'secret_missing',
action: 'open_ai_settings',
message: 'AI 提供商配置仍需完成安全更新',
},
],
}),
},
});
expect(result.status.overallStatus).toBe('pending');
expect(result.status.summary).toEqual({
total: 3,
updated: 0,
pending: 3,
skipped: 0,
failed: 0,
});
expect(result.status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ scope: 'ai_provider', refId: 'openai-main' }),
expect.objectContaining({ scope: 'connection', refId: 'legacy-1' }),
expect.objectContaining({ scope: 'global_proxy' }),
]));
});
it('keeps banner flow without intro when backend status is rolled_back', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'rolled_back',
summary: { total: 1, updated: 0, pending: 0, skipped: 0, failed: 1 },
issues: [],
}),
},
});
expect(result.shouldShowIntro).toBe(false);
expect(result.shouldShowBanner).toBe(true);
});
it('merges legacy pending items into rolled_back status without overwriting backend system issues', () => {
const status = mergeSecurityUpdateStatusWithLegacySource({
overallStatus: 'rolled_back',
summary: { total: 1, updated: 0, pending: 0, skipped: 0, failed: 1 },
issues: [
{
id: 'system-blocked',
scope: 'system',
title: '系统回滚',
severity: 'high',
status: 'failed',
reasonCode: 'environment_blocked',
action: 'view_details',
message: '后端已回滚本轮更新,需要处理后重试。',
},
],
}, legacyPayload);
expect(status.overallStatus).toBe('rolled_back');
expect(status.summary).toEqual({
total: 3,
updated: 0,
pending: 2,
skipped: 0,
failed: 1,
});
expect(status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'system-blocked', scope: 'system' }),
expect.objectContaining({ id: 'legacy-connection-legacy-1', scope: 'connection', refId: 'legacy-1' }),
expect.objectContaining({ id: 'legacy-global-proxy-default', scope: 'global_proxy' }),
]));
});
it('merges legacy pending items into needs_attention status without overwriting backend system issues', () => {
const status = mergeSecurityUpdateStatusWithLegacySource({
overallStatus: 'needs_attention',
summary: { total: 2, updated: 1, pending: 0, skipped: 0, failed: 1 },
issues: [
{
id: 'system-partial-failure',
scope: 'system',
title: '部分失败',
severity: 'high',
status: 'failed',
reasonCode: 'environment_blocked',
action: 'view_details',
message: '部分项目迁移失败,需要人工处理。',
},
{
id: 'ai-provider-openai-main',
scope: 'ai_provider',
refId: 'openai-main',
title: 'OpenAI',
severity: 'medium',
status: 'updated',
action: 'open_ai_settings',
message: 'AI 提供商配置已完成安全更新。',
},
],
}, legacyPayload);
expect(status.overallStatus).toBe('needs_attention');
expect(status.summary).toEqual({
total: 4,
updated: 1,
pending: 2,
skipped: 0,
failed: 1,
});
expect(status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'system-partial-failure', scope: 'system' }),
expect.objectContaining({ id: 'ai-provider-openai-main', scope: 'ai_provider', refId: 'openai-main' }),
expect.objectContaining({ id: 'legacy-connection-legacy-1', scope: 'connection', refId: 'legacy-1' }),
expect.objectContaining({ id: 'legacy-global-proxy-default', scope: 'global_proxy' }),
]));
});
it('does not merge local legacy pending items back into an active migration round that already reports needs_attention', () => {
const status = mergeSecurityUpdateStatusWithLegacySource({
migrationId: 'migration-active-1',
overallStatus: 'needs_attention',
summary: { total: 3, updated: 2, pending: 1, skipped: 0, failed: 0 },
issues: [
{
id: 'ai-provider-openai-main',
scope: 'ai_provider',
refId: 'openai-main',
title: 'OpenAI',
severity: 'medium',
status: 'needs_attention',
reasonCode: 'secret_missing',
action: 'open_ai_settings',
message: 'AI 提供商配置需要补充后才能完成安全更新。',
},
],
}, legacyPayload);
expect(status.overallStatus).toBe('needs_attention');
expect(status.summary).toEqual({
total: 3,
updated: 2,
pending: 1,
skipped: 0,
failed: 0,
});
expect(status.issues).toEqual([
expect.objectContaining({ id: 'ai-provider-openai-main', scope: 'ai_provider', refId: 'openai-main' }),
]);
});
it('does not merge local legacy pending items back into a rolled_back migration round', () => {
const status = mergeSecurityUpdateStatusWithLegacySource({
migrationId: 'migration-active-2',
overallStatus: 'rolled_back',
summary: { total: 3, updated: 1, pending: 0, skipped: 0, failed: 2 },
issues: [
{
id: 'system-blocked',
scope: 'system',
title: '系统回滚',
severity: 'high',
status: 'failed',
reasonCode: 'environment_blocked',
action: 'view_details',
message: '后端已回滚本轮更新,需要处理后重试。',
},
],
}, legacyPayload);
expect(status.overallStatus).toBe('rolled_back');
expect(status.summary).toEqual({
total: 3,
updated: 1,
pending: 0,
skipped: 0,
failed: 2,
});
expect(status.issues).toEqual([
expect.objectContaining({ id: 'system-blocked', scope: 'system' }),
]);
});
it('loads backend secure config directly when no legacy source exists', async () => {
const storage = createMemoryStorage();
const replaceConnections = vi.fn();
const replaceGlobalProxy = vi.fn();
const result = await bootstrapSecureConfig({
storage,
replaceConnections,
replaceGlobalProxy,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
GetSavedConnections: vi.fn().mockResolvedValue([
{
id: 'secure-1',
name: 'Secure',
config: {
id: 'secure-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
},
]),
},
});
expect(result.status.overallStatus).toBe('not_detected');
expect(replaceConnections).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'secure-1' })]),
);
});
it('shows intro when backend status is pending even without legacy local source', async () => {
const storage = createMemoryStorage();
const replaceConnections = vi.fn();
const replaceGlobalProxy = vi.fn();
const result = await bootstrapSecureConfig({
storage,
replaceConnections,
replaceGlobalProxy,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'pending',
summary: { total: 1, updated: 0, pending: 1, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.status.overallStatus).toBe('pending');
expect(result.shouldShowIntro).toBe(true);
expect(result.shouldShowBanner).toBe(false);
});
it('falls back to legacy visible config when StartSecurityUpdate throws', async () => {
const args = createBaseArgs();
const result = await startSecurityUpdateFromBootstrap({
...args,
backend: {
StartSecurityUpdate: vi.fn().mockRejectedValue(new Error('boom')),
},
});
expect(result.status).toBeNull();
expect(result.error?.message).toContain('boom');
expect(args.replaceConnections).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'legacy-1' })]),
);
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).toContain('"password":"secret"');
});
it('starts security update even when rawPayload is empty but backend supports AI-only update', async () => {
const storage = createMemoryStorage();
const replaceConnections = vi.fn();
const replaceGlobalProxy = vi.fn();
const StartSecurityUpdate = vi.fn().mockResolvedValue({
overallStatus: 'completed',
summary: { total: 1, updated: 1, pending: 0, skipped: 0, failed: 0 },
issues: [],
});
const result = await startSecurityUpdateFromBootstrap({
storage,
replaceConnections,
replaceGlobalProxy,
backend: {
StartSecurityUpdate,
},
});
expect(result.error).toBeNull();
expect(result.status?.overallStatus).toBe('completed');
expect(StartSecurityUpdate).toHaveBeenCalledWith({
sourceType: 'current_app_saved_config',
rawPayload: '',
options: {
allowPartial: true,
writeBackup: true,
},
});
});
it('keeps source-side secrets when update ends in needs_attention', async () => {
const args = createBaseArgs();
const result = await startSecurityUpdateFromBootstrap({
...args,
backend: {
StartSecurityUpdate: vi.fn().mockResolvedValue({
overallStatus: 'needs_attention',
summary: { total: 3, updated: 2, pending: 1, skipped: 0, failed: 0 },
issues: [{ id: 'ai-1' }],
}),
GetSavedConnections: vi.fn().mockResolvedValue([]),
},
});
expect(result.status?.overallStatus).toBe('needs_attention');
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).toContain('"password":"secret"');
});
it('cleans source-side secrets only after completed update and backend refresh', async () => {
const args = createBaseArgs();
const result = await startSecurityUpdateFromBootstrap({
...args,
backend: {
StartSecurityUpdate: vi.fn().mockResolvedValue({
overallStatus: 'completed',
summary: { total: 3, updated: 3, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
GetSavedConnections: vi.fn().mockResolvedValue([
{
id: 'secure-1',
name: 'Secure',
config: {
id: 'secure-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
hasPrimaryPassword: true,
},
]),
GetGlobalProxyConfig: vi.fn().mockResolvedValue({
success: true,
data: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
hasPassword: true,
},
}),
},
});
expect(result.status?.overallStatus).toBe('completed');
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"');
expect(args.replaceConnections).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'secure-1' })]),
);
});
it('refreshes backend config and strips source-side secrets when a later round finishes as completed', async () => {
const args = createBaseArgs();
const status = await finalizeSecurityUpdateStatus({
...args,
backend: {
GetSavedConnections: vi.fn().mockResolvedValue([
{
id: 'secure-1',
name: 'Secure',
config: {
id: 'secure-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
hasPrimaryPassword: true,
},
]),
GetGlobalProxyConfig: vi.fn().mockResolvedValue({
success: true,
data: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
hasPassword: true,
},
}),
},
}, {
overallStatus: 'completed',
summary: { total: 3, updated: 3, pending: 0, skipped: 0, failed: 0 },
issues: [],
});
expect(status.overallStatus).toBe('completed');
expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"');
expect(args.replaceConnections).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'secure-1' })]),
);
});
it('reduces legacy pending issues after a single connection is repaired before the first round starts', () => {
const initialStatus = mergeSecurityUpdateStatusWithLegacySource({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}, legacyPayload);
const nextPayload = stripLegacyPersistedConnectionById(legacyPayload, 'legacy-1');
const status = mergeSecurityUpdateStatusWithLegacySource({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}, nextPayload, {
previousStatus: initialStatus,
});
expect(status.overallStatus).toBe('pending');
expect(status.summary).toEqual({
total: 2,
updated: 1,
pending: 1,
skipped: 0,
failed: 0,
});
expect(status.issues).toEqual([
expect.objectContaining({
scope: 'global_proxy',
action: 'open_proxy_settings',
}),
]);
});
it('accumulates pre-start repaired progress across multiple connection saves in the same round-free session', () => {
const multiConnectionPayload = JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy 1',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db-1.local',
port: 5432,
user: 'postgres',
password: 'secret-1',
},
},
{
id: 'legacy-2',
name: 'Legacy 2',
config: {
id: 'legacy-2',
type: 'postgres',
host: 'db-2.local',
port: 5432,
user: 'postgres',
password: 'secret-2',
},
},
{
id: 'legacy-3',
name: 'Legacy 3',
config: {
id: 'legacy-3',
type: 'postgres',
host: 'db-3.local',
port: 5432,
user: 'postgres',
password: 'secret-3',
},
},
],
},
});
const backendStatus = {
overallStatus: 'not_detected' as const,
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
};
const initialStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, multiConnectionPayload);
const afterFirstRepairPayload = stripLegacyPersistedConnectionById(multiConnectionPayload, 'legacy-1');
const afterFirstRepairStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, afterFirstRepairPayload, {
previousStatus: initialStatus,
});
const afterSecondRepairPayload = stripLegacyPersistedConnectionById(afterFirstRepairPayload, 'legacy-2');
const afterSecondRepairStatus = mergeSecurityUpdateStatusWithLegacySource(backendStatus, afterSecondRepairPayload, {
previousStatus: afterFirstRepairStatus,
});
expect(afterFirstRepairStatus.summary).toEqual({
total: 3,
updated: 1,
pending: 2,
skipped: 0,
failed: 0,
});
expect(afterSecondRepairStatus.summary).toEqual({
total: 3,
updated: 2,
pending: 1,
skipped: 0,
failed: 0,
});
expect(afterSecondRepairStatus.issues).toEqual([
expect.objectContaining({
id: 'legacy-connection-legacy-3',
scope: 'connection',
refId: 'legacy-3',
}),
]);
});
});

View File

@@ -0,0 +1,412 @@
import {
GlobalProxyConfig,
SavedConnection,
SecurityUpdateIssue,
SecurityUpdateStatus,
SecurityUpdateSummary,
} from '../types';
import { createGlobalProxyDraft } from './globalProxyDraft';
import {
LEGACY_PERSIST_KEY,
hasLegacyMigratableSensitiveItems,
readLegacyPersistedSecrets,
stripLegacyPersistedSecrets,
} from './legacyConnectionStorage';
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
type BackendGlobalProxyResult = {
success?: boolean;
data?: Partial<GlobalProxyConfig>;
};
type SecurityUpdateBackend = {
GetSecurityUpdateStatus?: () => Promise<Partial<SecurityUpdateStatus> | undefined>;
StartSecurityUpdate?: (request: {
sourceType: 'current_app_saved_config';
rawPayload: string;
options?: {
allowPartial?: boolean;
writeBackup?: boolean;
};
}) => Promise<Partial<SecurityUpdateStatus> | undefined>;
GetSavedConnections?: () => Promise<SavedConnection[]>;
GetGlobalProxyConfig?: () => Promise<BackendGlobalProxyResult | undefined>;
};
type SecureConfigBootstrapArgs = {
backend?: SecurityUpdateBackend;
storage?: StorageLike;
replaceConnections: (connections: SavedConnection[]) => void;
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void;
};
type SecureConfigBootstrapResult = {
status: SecurityUpdateStatus;
rawPayload: string | null;
hasLegacySensitiveItems: boolean;
shouldShowIntro: boolean;
shouldShowBanner: boolean;
};
type StartSecurityUpdateResult = {
status: SecurityUpdateStatus | null;
error: Error | null;
};
type MergeSecurityUpdateStatusOptions = {
previousStatus?: Partial<SecurityUpdateStatus> | null;
};
const defaultSummary = () => ({
total: 0,
updated: 0,
pending: 0,
skipped: 0,
failed: 0,
});
const hasMeaningfulSummary = (summary: SecurityUpdateSummary): boolean => (
summary.total > 0
|| summary.updated > 0
|| summary.pending > 0
|| summary.skipped > 0
|| summary.failed > 0
);
const buildLegacyPendingDetails = (rawPayload: string | null): {
hasLegacyItems: boolean;
summary: SecurityUpdateSummary;
issues: SecurityUpdateIssue[];
} => {
const legacy = readLegacyPersistedSecrets(rawPayload);
const issues: SecurityUpdateIssue[] = legacy.connections.map((connection) => ({
id: `legacy-connection-${connection.id}`,
scope: 'connection',
refId: connection.id,
title: connection.name || connection.id,
severity: 'medium',
status: 'pending',
reasonCode: 'migration_required',
action: 'open_connection',
message: '该连接仍保存在当前应用的本地配置中,完成安全更新后会迁入新的安全存储。',
}));
if (legacy.globalProxy) {
issues.push({
id: 'legacy-global-proxy-default',
scope: 'global_proxy',
title: '全局代理',
severity: 'medium',
status: 'pending',
reasonCode: 'migration_required',
action: 'open_proxy_settings',
message: '全局代理仍保存在当前应用的本地配置中,完成安全更新后会迁入新的安全存储。',
});
}
return {
hasLegacyItems: issues.length > 0,
summary: {
total: issues.length,
updated: 0,
pending: issues.length,
skipped: 0,
failed: 0,
},
issues,
};
};
const mergeSecurityUpdateIssues = (
baseIssues: SecurityUpdateIssue[],
legacyIssues: SecurityUpdateIssue[],
): {
issues: SecurityUpdateIssue[];
addedCount: number;
} => {
const issueIds = new Set(baseIssues.map((issue) => issue.id));
const additions = legacyIssues.filter((issue) => !issueIds.has(issue.id));
return {
issues: [...baseIssues, ...additions],
addedCount: additions.length,
};
};
const isLocalLegacyIssue = (issue: Partial<SecurityUpdateIssue> | null | undefined): boolean => {
const issueId = String(issue?.id || '').trim();
return issueId.startsWith('legacy-connection-') || issueId === 'legacy-global-proxy-default';
};
const countLocalLegacyIssues = (issues: SecurityUpdateIssue[]): number => (
issues.filter((issue) => isLocalLegacyIssue(issue)).length
);
const deriveLegacySummary = (
base: SecurityUpdateStatus,
currentLegacyCount: number,
previousStatus?: Partial<SecurityUpdateStatus> | null,
): {
summary: SecurityUpdateSummary;
hasContribution: boolean;
} => {
const previousSummary = previousStatus?.summary ?? defaultSummary();
const previousIssues = Array.isArray(previousStatus?.issues) ? previousStatus.issues : [];
const previousLegacyCount = countLocalLegacyIssues(previousIssues);
const previousLegacyTotal = Math.max(
0,
previousSummary.total - base.summary.total,
previousSummary.updated - base.summary.updated + previousLegacyCount,
previousLegacyCount,
);
const previousLegacyUpdated = Math.max(
0,
Math.min(previousLegacyTotal, previousSummary.updated - base.summary.updated),
);
const repairedSincePrevious = Math.max(0, previousLegacyCount - currentLegacyCount);
const nextLegacyUpdated = Math.min(previousLegacyTotal, previousLegacyUpdated + repairedSincePrevious);
const nextLegacyTotal = Math.max(previousLegacyTotal, nextLegacyUpdated + currentLegacyCount);
return {
summary: {
total: base.summary.total + nextLegacyTotal,
updated: base.summary.updated + nextLegacyUpdated,
pending: base.summary.pending + currentLegacyCount,
skipped: base.summary.skipped,
failed: base.summary.failed,
},
hasContribution: nextLegacyTotal > 0,
};
};
export const mergeSecurityUpdateStatusWithLegacySource = (
status: Partial<SecurityUpdateStatus> | undefined,
rawPayload: string | null,
options?: MergeSecurityUpdateStatusOptions,
): SecurityUpdateStatus => {
const base: SecurityUpdateStatus = {
...defaultStatus(),
...status,
summary: {
...defaultSummary(),
...(status?.summary ?? {}),
},
issues: Array.isArray(status?.issues) ? status.issues : [],
};
const hasActiveMigrationRound = String(base.migrationId || '').trim() !== '';
const baseNonLegacyIssues = base.issues.filter((issue) => !isLocalLegacyIssue(issue));
const legacy = buildLegacyPendingDetails(rawPayload);
const legacySummary = deriveLegacySummary(base, legacy.issues.length, options?.previousStatus);
if (!legacySummary.hasContribution) {
return base;
}
const mergedIssues = mergeSecurityUpdateIssues(baseNonLegacyIssues, legacy.issues).issues;
if (base.overallStatus === 'not_detected') {
if (!legacy.hasLegacyItems) {
return base;
}
return {
...base,
overallStatus: 'pending',
reminderVisible: true,
canStart: true,
canPostpone: true,
summary: legacySummary.summary,
issues: mergedIssues,
};
}
if (base.overallStatus === 'pending' || base.overallStatus === 'postponed') {
return {
...base,
summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary,
issues: mergedIssues,
canStart: true,
canPostpone: true,
reminderVisible: base.overallStatus === 'pending' ? true : base.reminderVisible,
};
}
if (base.overallStatus === 'rolled_back' || base.overallStatus === 'needs_attention') {
if (hasActiveMigrationRound) {
return base;
}
return {
...base,
summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary,
issues: mergedIssues,
};
}
return base;
};
const defaultStatus = (): SecurityUpdateStatus => ({
overallStatus: 'not_detected',
summary: defaultSummary(),
issues: [],
});
const resolveStorage = (storage?: StorageLike): StorageLike | undefined => {
if (storage) {
return storage;
}
if (typeof window === 'undefined') {
return undefined;
}
return window.localStorage;
};
const applyLegacyVisibleConfig = (
rawPayload: string | null,
replaceConnections: (connections: SavedConnection[]) => void,
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void,
) => {
const legacy = readLegacyPersistedSecrets(rawPayload);
if (legacy.connections.length > 0) {
replaceConnections(legacy.connections);
}
if (legacy.globalProxy) {
replaceGlobalProxy(createGlobalProxyDraft(legacy.globalProxy));
}
};
const refreshVisibleConfigFromBackend = async (
backend: SecurityUpdateBackend | undefined,
replaceConnections: (connections: SavedConnection[]) => void,
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void,
allowEmptyConnections: boolean,
) => {
if (typeof backend?.GetSavedConnections === 'function') {
try {
const connections = await backend.GetSavedConnections();
if (Array.isArray(connections) && (allowEmptyConnections || connections.length > 0)) {
replaceConnections(connections);
}
} catch {
// Keep current visible state as fallback.
}
}
if (typeof backend?.GetGlobalProxyConfig === 'function') {
try {
const proxyResult = await backend.GetGlobalProxyConfig();
if (proxyResult?.success && proxyResult.data) {
replaceGlobalProxy(createGlobalProxyDraft(proxyResult.data));
}
} catch {
// Keep current visible state as fallback.
}
}
};
const cleanupLegacySourceIfCompleted = (
storage: StorageLike | undefined,
rawPayload: string | null,
status: SecurityUpdateStatus,
) => {
if (!storage || !rawPayload || status.overallStatus !== 'completed') {
return;
}
const sanitizedPayload = stripLegacyPersistedSecrets(rawPayload);
if (sanitizedPayload && sanitizedPayload !== rawPayload) {
storage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
}
};
export async function finalizeSecurityUpdateStatus(
args: SecureConfigBootstrapArgs,
rawStatus: Partial<SecurityUpdateStatus> | undefined,
): Promise<SecurityUpdateStatus> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload);
if (status.overallStatus === 'completed') {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
cleanupLegacySourceIfCompleted(storage, rawPayload, status);
}
return status;
}
export async function bootstrapSecureConfig(args: SecureConfigBootstrapArgs): Promise<SecureConfigBootstrapResult> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const hasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(rawPayload);
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
const backendStatus = typeof args.backend?.GetSecurityUpdateStatus === 'function'
? await args.backend.GetSecurityUpdateStatus()
: undefined;
const status = mergeSecurityUpdateStatusWithLegacySource(backendStatus, rawPayload);
if (!hasLegacySensitiveItems) {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
} else if (status.overallStatus === 'completed') {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
cleanupLegacySourceIfCompleted(storage, rawPayload, status);
}
return {
status,
rawPayload,
hasLegacySensitiveItems,
shouldShowIntro: status.overallStatus === 'pending',
shouldShowBanner: ['postponed', 'rolled_back', 'needs_attention'].includes(status.overallStatus),
};
}
export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstrapArgs): Promise<StartSecurityUpdateResult> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const startPayload = rawPayload ?? '';
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
if (typeof args.backend?.StartSecurityUpdate !== 'function') {
return {
status: null,
error: new Error('安全更新能力不可用'),
};
}
try {
const rawStatus = await args.backend.StartSecurityUpdate({
sourceType: 'current_app_saved_config',
rawPayload: startPayload,
options: {
allowPartial: true,
writeBackup: true,
},
});
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload);
if (status.overallStatus === 'completed') {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
cleanupLegacySourceIfCompleted(storage, rawPayload, status);
}
return { status, error: null };
} catch (error) {
applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy);
return {
status: null,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
export type {
BackendGlobalProxyResult,
MergeSecurityUpdateStatusOptions,
SecurityUpdateBackend,
SecureConfigBootstrapArgs,
SecureConfigBootstrapResult,
StartSecurityUpdateResult,
};

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
import {
getSecurityUpdateIssueSeverityMeta,
getSecurityUpdateItemStatusMeta,
getSecurityUpdateIssueActionMeta,
getSecurityUpdateStatusMeta,
resolveSecurityUpdateEntryVisibility,
sortSecurityUpdateIssues,
} from './securityUpdatePresentation';
const createStatus = (overallStatus: SecurityUpdateStatus['overallStatus']): SecurityUpdateStatus => ({
overallStatus,
summary: {
total: 0,
updated: 0,
pending: 0,
skipped: 0,
failed: 0,
},
issues: [],
});
describe('securityUpdatePresentation', () => {
it('sorts issues by severity from high to low', () => {
const issues: SecurityUpdateIssue[] = [
{ id: 'medium-1', severity: 'medium' },
{ id: 'low-1', severity: 'low' },
{ id: 'high-1', severity: 'high' },
{ id: 'medium-2', severity: 'medium' },
];
expect(sortSecurityUpdateIssues(issues).map((issue) => issue.id)).toEqual([
'high-1',
'medium-1',
'medium-2',
'low-1',
]);
});
it('maps needs_attention, rolled_back and completed to stable display labels', () => {
expect(getSecurityUpdateStatusMeta(createStatus('needs_attention')).label).toBe('待处理');
expect(getSecurityUpdateStatusMeta(createStatus('rolled_back')).label).toBe('已回退');
expect(getSecurityUpdateStatusMeta(createStatus('completed')).label).toBe('已完成');
});
it('resolves intro, banner and detail entry visibility for key overall states', () => {
expect(resolveSecurityUpdateEntryVisibility(createStatus('pending'))).toEqual({
showIntro: true,
showBanner: false,
showDetailEntry: true,
});
expect(resolveSecurityUpdateEntryVisibility(createStatus('postponed'))).toEqual({
showIntro: false,
showBanner: true,
showDetailEntry: true,
});
expect(resolveSecurityUpdateEntryVisibility(createStatus('rolled_back'))).toEqual({
showIntro: false,
showBanner: true,
showDetailEntry: true,
});
});
it('maps issue scope actions to existing repair entry labels', () => {
expect(getSecurityUpdateIssueActionMeta({ id: 'conn', scope: 'connection', action: 'open_connection' }).label).toBe('打开连接');
expect(getSecurityUpdateIssueActionMeta({ id: 'proxy', scope: 'global_proxy', action: 'open_proxy_settings' }).label).toBe('代理设置');
expect(getSecurityUpdateIssueActionMeta({ id: 'ai', scope: 'ai_provider', action: 'open_ai_settings' }).label).toBe('AI 设置');
expect(getSecurityUpdateIssueActionMeta({ id: 'system', scope: 'system', action: 'view_details' }).label).toBe('查看详情');
});
it('maps item status to explicit Chinese labels instead of reusing severity wording', () => {
expect(getSecurityUpdateItemStatusMeta('needs_attention')).toEqual({
label: '待处理',
color: 'warning',
});
expect(getSecurityUpdateItemStatusMeta('updated')).toEqual({
label: '已更新',
color: 'success',
});
});
it('maps issue severity to dedicated risk labels', () => {
expect(getSecurityUpdateIssueSeverityMeta('medium')).toEqual({
label: '中风险',
color: 'warning',
});
expect(getSecurityUpdateIssueSeverityMeta('high')).toEqual({
label: '高风险',
color: 'error',
});
});
});

View File

@@ -0,0 +1,210 @@
import type {
SecurityUpdateIssue,
SecurityUpdateIssueAction,
SecurityUpdateIssueSeverity,
SecurityUpdateItemStatus,
SecurityUpdateStatus,
} from '../types';
type SecurityUpdateTone = 'default' | 'warning' | 'processing' | 'success' | 'error';
type SecurityUpdateStatusMeta = {
label: string;
description: string;
tone: SecurityUpdateTone;
};
type SecurityUpdateEntryVisibility = {
showIntro: boolean;
showBanner: boolean;
showDetailEntry: boolean;
};
type SecurityUpdateIssueActionMeta = {
label: string;
emphasis: 'primary' | 'default';
};
type SecurityUpdateBadgeMeta = {
label: string;
color: SecurityUpdateTone;
};
const severityWeight: Record<SecurityUpdateIssueSeverity, number> = {
high: 0,
medium: 1,
low: 2,
};
const actionMetaMap: Record<SecurityUpdateIssueAction, SecurityUpdateIssueActionMeta> = {
open_connection: {
label: '打开连接',
emphasis: 'primary',
},
open_proxy_settings: {
label: '代理设置',
emphasis: 'primary',
},
open_ai_settings: {
label: 'AI 设置',
emphasis: 'primary',
},
retry_update: {
label: '重新检查',
emphasis: 'primary',
},
view_details: {
label: '查看详情',
emphasis: 'default',
},
};
const itemStatusMetaMap: Record<SecurityUpdateItemStatus, SecurityUpdateBadgeMeta> = {
pending: {
label: '待更新',
color: 'processing',
},
updated: {
label: '已更新',
color: 'success',
},
needs_attention: {
label: '待处理',
color: 'warning',
},
skipped: {
label: '已跳过',
color: 'default',
},
failed: {
label: '失败',
color: 'error',
},
};
const issueSeverityMetaMap: Record<SecurityUpdateIssueSeverity, SecurityUpdateBadgeMeta> = {
high: {
label: '高风险',
color: 'error',
},
medium: {
label: '中风险',
color: 'warning',
},
low: {
label: '低风险',
color: 'default',
},
};
export function sortSecurityUpdateIssues(issues: SecurityUpdateIssue[]): SecurityUpdateIssue[] {
return [...issues].sort((left, right) => {
const leftWeight = severityWeight[left.severity ?? 'low'];
const rightWeight = severityWeight[right.severity ?? 'low'];
if (leftWeight !== rightWeight) {
return leftWeight - rightWeight;
}
return left.id.localeCompare(right.id);
});
}
export function getSecurityUpdateStatusMeta(status: SecurityUpdateStatus): SecurityUpdateStatusMeta {
switch (status.overallStatus) {
case 'pending':
return {
label: '待更新',
description: '检测到可进行的安全更新,你可以现在开始或稍后继续。',
tone: 'warning',
};
case 'postponed':
return {
label: '待更新',
description: '本次安全更新已延后,当前可用配置会继续保留。',
tone: 'warning',
};
case 'in_progress':
return {
label: '更新中',
description: '正在检查并更新已保存配置的安全存储。',
tone: 'processing',
};
case 'needs_attention':
return {
label: '待处理',
description: '更新尚未完成,有少量配置需要你处理。',
tone: 'warning',
};
case 'completed':
return {
label: '已完成',
description: '已保存配置已完成安全更新。',
tone: 'success',
};
case 'rolled_back':
return {
label: '已回退',
description: '本次更新未完成,系统已保留当前可用配置。',
tone: 'error',
};
case 'not_detected':
default:
return {
label: '未检测到',
description: '当前没有需要处理的安全更新。',
tone: 'default',
};
}
}
export function resolveSecurityUpdateEntryVisibility(status: SecurityUpdateStatus): SecurityUpdateEntryVisibility {
switch (status.overallStatus) {
case 'pending':
return {
showIntro: true,
showBanner: false,
showDetailEntry: true,
};
case 'postponed':
case 'needs_attention':
case 'rolled_back':
return {
showIntro: false,
showBanner: true,
showDetailEntry: true,
};
case 'completed':
case 'in_progress':
return {
showIntro: false,
showBanner: false,
showDetailEntry: true,
};
case 'not_detected':
default:
return {
showIntro: false,
showBanner: false,
showDetailEntry: false,
};
}
}
export function getSecurityUpdateIssueActionMeta(issue: Partial<SecurityUpdateIssue>): SecurityUpdateIssueActionMeta {
return actionMetaMap[issue.action ?? 'view_details'] ?? actionMetaMap.view_details;
}
export function getSecurityUpdateItemStatusMeta(status?: SecurityUpdateItemStatus): SecurityUpdateBadgeMeta {
return itemStatusMetaMap[status ?? 'pending'] ?? itemStatusMetaMap.pending;
}
export function getSecurityUpdateIssueSeverityMeta(severity?: SecurityUpdateIssueSeverity): SecurityUpdateBadgeMeta {
return issueSeverityMetaMap[severity ?? 'low'] ?? issueSeverityMetaMap.low;
}
export type {
SecurityUpdateBadgeMeta,
SecurityUpdateEntryVisibility,
SecurityUpdateIssueActionMeta,
SecurityUpdateStatusMeta,
SecurityUpdateTone,
};

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
import {
hasSecurityUpdateRecentResult,
resolveSecurityUpdateFocusState,
resolveSecurityUpdateRepairEntry,
resolveSecurityUpdateSettingsFocusTarget,
shouldRefreshSecurityUpdateDetailsFocus,
shouldReopenSecurityUpdateDetails,
shouldRetrySecurityUpdateAfterRepairSave,
} from './securityUpdateRepairFlow';
const createConnection = (id: string): SavedConnection => ({
id,
name: `连接-${id}`,
config: {
id,
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
},
});
const createStatus = (overrides: Partial<SecurityUpdateStatus> = {}): SecurityUpdateStatus => ({
overallStatus: 'needs_attention',
summary: {
total: 1,
updated: 0,
pending: 1,
skipped: 0,
failed: 0,
},
issues: [],
...overrides,
});
describe('securityUpdateRepairFlow', () => {
it('opens the matching connection and preserves the return source for security update repairs', () => {
const target = createConnection('conn-1');
const issue: SecurityUpdateIssue = {
id: 'issue-1',
action: 'open_connection',
refId: 'conn-1',
};
expect(resolveSecurityUpdateRepairEntry(issue, [target])).toEqual({
type: 'connection',
connection: target,
repairSource: 'connection',
});
});
it('returns a user-facing warning when the target connection no longer exists', () => {
const issue: SecurityUpdateIssue = {
id: 'issue-1',
action: 'open_connection',
refId: 'missing-conn',
};
expect(resolveSecurityUpdateRepairEntry(issue, [createConnection('conn-1')])).toEqual({
type: 'warning',
message: '未找到对应连接,请先重新检查最新状态',
});
});
it('maps proxy, ai and retry actions to the expected repair entry', () => {
expect(resolveSecurityUpdateRepairEntry({ id: 'proxy', action: 'open_proxy_settings' }, [])).toEqual({
type: 'proxy',
repairSource: 'proxy',
});
expect(resolveSecurityUpdateRepairEntry({ id: 'ai', action: 'open_ai_settings', refId: 'provider-1' }, [])).toEqual({
type: 'ai',
providerId: 'provider-1',
repairSource: 'ai',
});
expect(resolveSecurityUpdateRepairEntry({ id: 'retry', action: 'retry_update' }, [])).toEqual({
type: 'retry',
});
});
it('routes view_details actions to the latest result section when a recent result exists', () => {
const status = createStatus({
backupPath: '/tmp/gonavi-backup.json',
lastError: '写入新密钥失败',
});
expect(hasSecurityUpdateRecentResult(status)).toBe(true);
expect(resolveSecurityUpdateSettingsFocusTarget(status)).toBe('recent_result');
expect(resolveSecurityUpdateRepairEntry({ id: 'details', action: 'view_details' }, [], status)).toEqual({
type: 'details',
focusTarget: 'recent_result',
});
});
it('falls back to the status section when no recent result is available yet', () => {
const status = createStatus();
expect(hasSecurityUpdateRecentResult(status)).toBe(false);
expect(resolveSecurityUpdateSettingsFocusTarget(status)).toBe('status');
expect(resolveSecurityUpdateRepairEntry({ id: 'details', action: 'view_details' }, [], status)).toEqual({
type: 'details',
focusTarget: 'status',
});
});
it('builds a fresh focus pulse for repeated details clicks and clears it when the modal closes', () => {
expect(resolveSecurityUpdateFocusState(true, 'status', 1)).toEqual({
target: 'status',
pulseKey: 'status:1',
});
expect(resolveSecurityUpdateFocusState(true, 'status', 2)).toEqual({
target: 'status',
pulseKey: 'status:2',
});
expect(resolveSecurityUpdateFocusState(false, 'status', 2)).toEqual({
target: null,
pulseKey: null,
});
expect(resolveSecurityUpdateFocusState(true, null, 3)).toEqual({
target: null,
pulseKey: null,
});
});
it('reopens security update details after closing a repair entry opened from that page', () => {
expect(shouldReopenSecurityUpdateDetails('connection')).toBe(true);
expect(shouldReopenSecurityUpdateDetails('proxy')).toBe(true);
expect(shouldReopenSecurityUpdateDetails('ai')).toBe(true);
expect(shouldReopenSecurityUpdateDetails(null)).toBe(false);
});
it('retries the current round automatically after saving a connection from the repair flow', () => {
expect(shouldRetrySecurityUpdateAfterRepairSave('connection')).toBe(true);
expect(shouldRetrySecurityUpdateAfterRepairSave('proxy')).toBe(false);
expect(shouldRetrySecurityUpdateAfterRepairSave('ai')).toBe(false);
expect(shouldRetrySecurityUpdateAfterRepairSave(null)).toBe(false);
});
it('does not force a new focus pulse when the details modal is already open and only the round result is refreshing', () => {
expect(shouldRefreshSecurityUpdateDetailsFocus({
requestedOpen: true,
wasOpen: true,
})).toBe(false);
expect(shouldRefreshSecurityUpdateDetailsFocus({
requestedOpen: true,
wasOpen: false,
})).toBe(true);
expect(shouldRefreshSecurityUpdateDetailsFocus({
requestedOpen: false,
wasOpen: true,
})).toBe(false);
});
});

View File

@@ -0,0 +1,126 @@
import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
export type SecurityUpdateRepairSource = 'connection' | 'proxy' | 'ai';
export type SecurityUpdateSettingsFocusTarget = 'recent_result' | 'status';
export type SecurityUpdateFocusState = {
target: SecurityUpdateSettingsFocusTarget | null;
pulseKey: string | null;
};
export type SecurityUpdateRepairEntry =
| {
type: 'connection';
connection: SavedConnection;
repairSource: 'connection';
}
| {
type: 'proxy';
repairSource: 'proxy';
}
| {
type: 'ai';
providerId?: string;
repairSource: 'ai';
}
| {
type: 'retry';
}
| {
type: 'details';
focusTarget: SecurityUpdateSettingsFocusTarget;
}
| {
type: 'warning';
message: string;
};
export const hasSecurityUpdateRecentResult = (
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
): boolean => Boolean(status?.backupPath || status?.lastError);
export const resolveSecurityUpdateSettingsFocusTarget = (
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
): SecurityUpdateSettingsFocusTarget => (
hasSecurityUpdateRecentResult(status) ? 'recent_result' : 'status'
);
export const resolveSecurityUpdateFocusState = (
open: boolean,
focusTarget: SecurityUpdateSettingsFocusTarget | null | undefined,
focusRequest: number,
): SecurityUpdateFocusState => {
if (!open || !focusTarget) {
return {
target: null,
pulseKey: null,
};
}
return {
target: focusTarget,
pulseKey: `${focusTarget}:${focusRequest}`,
};
};
export const resolveSecurityUpdateRepairEntry = (
issue: SecurityUpdateIssue,
connections: SavedConnection[],
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
): SecurityUpdateRepairEntry => {
if (issue.action === 'open_connection') {
const target = connections.find((connection) => connection.id === issue.refId);
if (!target) {
return {
type: 'warning',
message: '未找到对应连接,请先重新检查最新状态',
};
}
return {
type: 'connection',
connection: target,
repairSource: 'connection',
};
}
if (issue.action === 'open_proxy_settings') {
return {
type: 'proxy',
repairSource: 'proxy',
};
}
if (issue.action === 'open_ai_settings') {
return {
type: 'ai',
providerId: issue.refId || undefined,
repairSource: 'ai',
};
}
if (issue.action === 'retry_update') {
return {
type: 'retry',
};
}
return {
type: 'details',
focusTarget: resolveSecurityUpdateSettingsFocusTarget(status),
};
};
export const shouldReopenSecurityUpdateDetails = (
repairSource: SecurityUpdateRepairSource | null | undefined,
): boolean => repairSource === 'connection' || repairSource === 'proxy' || repairSource === 'ai';
export const shouldRefreshSecurityUpdateDetailsFocus = ({
requestedOpen,
wasOpen,
}: {
requestedOpen: boolean;
wasOpen: boolean;
}): boolean => requestedOpen && !wasOpen;
export const shouldRetrySecurityUpdateAfterRepairSave = (
repairSource: SecurityUpdateRepairSource | null | undefined,
): boolean => repairSource === 'connection';

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_BANNER_CLASS,
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
SECURITY_UPDATE_RESULT_CARD_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateBannerSurfaceStyle,
getSecurityUpdateSectionSurfaceStyle,
getSecurityUpdateShellSurfaceStyle,
} from './securityUpdateVisuals';
describe('securityUpdateVisuals', () => {
it('builds action buttons without default ant focus glow shadow', () => {
expect(SECURITY_UPDATE_ACTION_BUTTON_CLASS).toBe('security-update-action-btn');
expect(SECURITY_UPDATE_BANNER_CLASS).toBe('security-update-banner');
expect(SECURITY_UPDATE_RESULT_CARD_CLASS).toBe('security-update-result-card');
expect(SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS).toBe('security-update-result-card-active');
expect(getSecurityUpdateActionButtonStyle()).toMatchObject({
height: 36,
borderRadius: 12,
boxShadow: 'none',
fontWeight: 600,
});
});
it('keeps the shell surface aligned with overlay shell tokens in light and dark mode', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
const darkTheme = buildOverlayWorkbenchTheme(true);
expect(getSecurityUpdateShellSurfaceStyle(lightTheme)).toMatchObject({
border: lightTheme.shellBorder,
background: lightTheme.shellBg,
boxShadow: lightTheme.shellShadow,
backdropFilter: lightTheme.shellBackdropFilter,
});
expect(getSecurityUpdateShellSurfaceStyle(darkTheme)).toMatchObject({
border: darkTheme.shellBorder,
background: darkTheme.shellBg,
boxShadow: darkTheme.shellShadow,
backdropFilter: darkTheme.shellBackdropFilter,
});
});
it('keeps the banner surface aligned with overlay shell tokens instead of translucent section tokens', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
const darkTheme = buildOverlayWorkbenchTheme(true);
expect(getSecurityUpdateBannerSurfaceStyle(lightTheme)).toMatchObject({
border: lightTheme.shellBorder,
background: lightTheme.shellBg,
boxShadow: 'none',
backdropFilter: lightTheme.shellBackdropFilter,
});
expect(getSecurityUpdateBannerSurfaceStyle(darkTheme)).toMatchObject({
border: darkTheme.shellBorder,
background: darkTheme.shellBg,
boxShadow: 'none',
backdropFilter: darkTheme.shellBackdropFilter,
});
});
it('can scale shell surface alpha with the current appearance opacity so reminder layers stay visually consistent', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
const fadedShell = getSecurityUpdateShellSurfaceStyle(lightTheme, 0.5);
const fadedBanner = getSecurityUpdateBannerSurfaceStyle(lightTheme, 0.5);
expect(fadedShell.background).not.toBe(lightTheme.shellBg);
expect(fadedShell.border).not.toBe(lightTheme.shellBorder);
expect(fadedShell.background).toContain('0.49');
expect(fadedBanner.background).toContain('0.49');
});
it('can emphasize a section surface for transient focus and recent-result highlighting', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
const darkTheme = buildOverlayWorkbenchTheme(true);
expect(getSecurityUpdateSectionSurfaceStyle(lightTheme)).toMatchObject({
border: lightTheme.sectionBorder,
background: lightTheme.sectionBg,
boxShadow: 'none',
});
expect(getSecurityUpdateSectionSurfaceStyle(darkTheme)).toMatchObject({
border: darkTheme.sectionBorder,
background: darkTheme.sectionBg,
boxShadow: 'none',
});
const emphasizedLight = getSecurityUpdateSectionSurfaceStyle(lightTheme, { emphasized: true });
const emphasizedDark = getSecurityUpdateSectionSurfaceStyle(darkTheme, { emphasized: true });
expect(emphasizedLight.background).not.toBe(lightTheme.sectionBg);
expect(emphasizedLight.boxShadow).not.toBe('none');
expect(emphasizedDark.background).not.toBe(darkTheme.sectionBg);
expect(emphasizedDark.boxShadow).not.toBe('none');
});
});

View File

@@ -0,0 +1,94 @@
import type { CSSProperties } from 'react';
import type { OverlayWorkbenchTheme } from './overlayWorkbenchTheme';
export const SECURITY_UPDATE_ACTION_BUTTON_CLASS = 'security-update-action-btn';
export const SECURITY_UPDATE_BANNER_CLASS = 'security-update-banner';
export const SECURITY_UPDATE_MODAL_CLASS = 'security-update-modal';
export const SECURITY_UPDATE_RESULT_CARD_CLASS = 'security-update-result-card';
export const SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS = 'security-update-result-card-active';
type SecurityUpdateSectionSurfaceOptions = {
emphasized?: boolean;
surfaceOpacity?: number;
};
const clampOpacity = (value: number): number => Math.min(1, Math.max(0.1, value));
const formatAlpha = (value: number): string => (
Number(value.toFixed(3)).toString()
);
const applySurfaceOpacity = (token: string, surfaceOpacity = 1): string => {
const normalizedOpacity = clampOpacity(surfaceOpacity);
if (normalizedOpacity >= 0.999) {
return token;
}
return token.replace(
/rgba\(\s*([^)]+?)\s*,\s*([0-9]*\.?[0-9]+)\s*\)/g,
(_, channels: string, alpha: string) => `rgba(${channels}, ${formatAlpha(Number(alpha) * normalizedOpacity)})`,
);
};
const getSecurityUpdateHighlightBorder = (overlayTheme: OverlayWorkbenchTheme): string => (
overlayTheme.isDark
? '1px solid rgba(255,214,102,0.26)'
: '1px solid rgba(22,119,255,0.22)'
);
const getSecurityUpdateHighlightBackground = (overlayTheme: OverlayWorkbenchTheme): string => (
overlayTheme.isDark
? 'linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,255,255,0.05) 100%)'
: 'linear-gradient(180deg, rgba(22,119,255,0.12) 0%, rgba(255,255,255,0.96) 100%)'
);
const getSecurityUpdateHighlightShadow = (overlayTheme: OverlayWorkbenchTheme): string => (
overlayTheme.isDark
? '0 0 0 1px rgba(255,214,102,0.12), 0 12px 24px rgba(0,0,0,0.16)'
: '0 0 0 1px rgba(22,119,255,0.08), 0 10px 22px rgba(15,23,42,0.08)'
);
export const getSecurityUpdateActionButtonStyle = (): CSSProperties => ({
height: 36,
borderRadius: 12,
paddingInline: 16,
boxShadow: 'none',
fontWeight: 600,
});
export const getSecurityUpdateShellSurfaceStyle = (
overlayTheme: OverlayWorkbenchTheme,
surfaceOpacity = 1,
): CSSProperties => ({
border: applySurfaceOpacity(overlayTheme.shellBorder, surfaceOpacity),
background: applySurfaceOpacity(overlayTheme.shellBg, surfaceOpacity),
boxShadow: applySurfaceOpacity(overlayTheme.shellShadow, surfaceOpacity),
backdropFilter: overlayTheme.shellBackdropFilter,
});
export const getSecurityUpdateBannerSurfaceStyle = (
overlayTheme: OverlayWorkbenchTheme,
surfaceOpacity = 1,
): CSSProperties => ({
...getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
boxShadow: 'none',
});
export const getSecurityUpdateSectionSurfaceStyle = (
overlayTheme: OverlayWorkbenchTheme,
options: SecurityUpdateSectionSurfaceOptions = {},
): CSSProperties => ({
border: applySurfaceOpacity(
options.emphasized ? getSecurityUpdateHighlightBorder(overlayTheme) : overlayTheme.sectionBorder,
options.surfaceOpacity,
),
background: applySurfaceOpacity(
options.emphasized ? getSecurityUpdateHighlightBackground(overlayTheme) : overlayTheme.sectionBg,
options.surfaceOpacity,
),
boxShadow: options.emphasized
? applySurfaceOpacity(getSecurityUpdateHighlightShadow(overlayTheme), options.surfaceOpacity)
: 'none',
transition: 'background 180ms ease, border-color 180ms ease, box-shadow 180ms ease',
});

View File

@@ -10,10 +10,10 @@ describe('startup readiness helpers', () => {
});
});
it('keeps sidebar blocked until initial global proxy sync finishes', () => {
it('keeps sidebar blocked until secure config bootstrap finishes', () => {
expect(getConnectionWorkbenchState(true, false)).toEqual({
ready: false,
message: '正在同步全局代理配置...',
message: '正在加载安全配置...',
});
});
@@ -24,3 +24,4 @@ describe('startup readiness helpers', () => {
});
});
});

View File

@@ -16,7 +16,7 @@ export function getConnectionWorkbenchState(
if (!hasAppliedInitialGlobalProxy) {
return {
ready: false,
message: '正在同步全局代理配置...',
message: '正在加载安全配置...',
};
}
return {
@@ -24,3 +24,4 @@ export function getConnectionWorkbenchState(
message: '',
};
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import {
computeWindowsViewportScaleRatio,
getWindowsScaleFixNudgedWidth,
hasWindowsViewportScaleDrift,
} from './windowsScaleFix';
describe('windowsScaleFix', () => {
it('treats matching window and viewport metrics as stable', () => {
const ratio = computeWindowsViewportScaleRatio({
windowWidth: 1920,
innerWidth: 1280,
devicePixelRatio: 1.5,
});
expect(ratio).toBeCloseTo(1, 5);
expect(hasWindowsViewportScaleDrift({
windowWidth: 1920,
innerWidth: 1280,
devicePixelRatio: 1.5,
})).toBe(false);
});
it('detects zoom drift from viewport width mismatch', () => {
expect(hasWindowsViewportScaleDrift({
windowWidth: 1920,
innerWidth: 1100,
devicePixelRatio: 1.5,
})).toBe(true);
});
it('detects zoom drift from visual viewport scale', () => {
expect(hasWindowsViewportScaleDrift({
windowWidth: 1600,
innerWidth: 1600,
devicePixelRatio: 1,
visualViewportScale: 1.12,
})).toBe(true);
});
it('returns a one-pixel nudge width for normal windows', () => {
expect(getWindowsScaleFixNudgedWidth(960)).toBe(959);
expect(getWindowsScaleFixNudgedWidth(420)).toBe(421);
});
});

View File

@@ -0,0 +1,46 @@
type WindowsViewportScaleInput = {
windowWidth: number;
innerWidth: number;
devicePixelRatio: number;
visualViewportScale?: number | null;
};
export const computeWindowsViewportScaleRatio = ({
windowWidth,
innerWidth,
devicePixelRatio,
}: WindowsViewportScaleInput): number => {
const normalizedWindowWidth = Number(windowWidth);
const normalizedInnerWidth = Number(innerWidth);
const normalizedDevicePixelRatio = Number(devicePixelRatio);
if (
!Number.isFinite(normalizedWindowWidth) || normalizedWindowWidth <= 0 ||
!Number.isFinite(normalizedInnerWidth) || normalizedInnerWidth <= 0 ||
!Number.isFinite(normalizedDevicePixelRatio) || normalizedDevicePixelRatio <= 0
) {
return 1;
}
return (normalizedWindowWidth / normalizedDevicePixelRatio) / normalizedInnerWidth;
};
export const hasWindowsViewportScaleDrift = (
metrics: WindowsViewportScaleInput,
tolerance = 0.08,
): boolean => {
const normalizedTolerance = Math.max(0.01, Number(tolerance) || 0.08);
const visualViewportScale = Number(metrics.visualViewportScale);
if (Number.isFinite(visualViewportScale) && Math.abs(visualViewportScale - 1) > normalizedTolerance) {
return true;
}
const viewportScaleRatio = computeWindowsViewportScaleRatio(metrics);
return Math.abs(viewportScaleRatio - 1) > normalizedTolerance;
};
export const getWindowsScaleFixNudgedWidth = (width: number): number => {
const normalizedWidth = Math.trunc(Number(width) || 0);
if (normalizedWidth <= 0) {
return 0;
}
return normalizedWidth > 480 ? normalizedWidth - 1 : normalizedWidth + 1;
};

View File

@@ -2,10 +2,13 @@
// This file is automatically generated. DO NOT EDIT
import {connection} from '../models';
import {sync} from '../models';
import {app} from '../models';
import {redis} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
export function ApplyDataRootDirectory(arg1:string,arg2:boolean):Promise<connection.QueryResult>;
export function CancelQuery(arg1:string):Promise<connection.QueryResult>;
export function CancelSQLFileExecution(arg1:string):Promise<connection.QueryResult>;
@@ -14,6 +17,10 @@ export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
export function CheckForUpdates():Promise<connection.QueryResult>;
export function CheckForUpdatesSilently():Promise<connection.QueryResult>;
export function ClearTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
@@ -52,6 +59,10 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryRe
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
export function DeleteConnection(arg1:string):Promise<void>;
export function DismissSecurityUpdateReminder():Promise<app.SecurityUpdateStatus>;
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DownloadUpdate():Promise<connection.QueryResult>;
@@ -64,8 +75,12 @@ export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:stri
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DuplicateConnection(arg1:string):Promise<connection.SavedConnectionView>;
export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportConnectionsPackage(arg1:app.ConnectionExportOptions):Promise<connection.QueryResult>;
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
@@ -82,6 +97,8 @@ export function GenerateQueryID():Promise<string>;
export function GetAppInfo():Promise<connection.QueryResult>;
export function GetDataRootDirectoryInfo():Promise<connection.QueryResult>;
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function GetDriverVersionList(arg1:string,arg2:string):Promise<connection.QueryResult>;
@@ -90,16 +107,28 @@ export function GetDriverVersionPackageSize(arg1:string,arg2:string):Promise<con
export function GetGlobalProxyConfig():Promise<connection.QueryResult>;
export function GetSavedConnections():Promise<Array<connection.SavedConnectionView>>;
export function GetSecurityUpdateStatus():Promise<app.SecurityUpdateStatus>;
export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportConnectionsPayload(arg1:string,arg2:string):Promise<Array<connection.SavedConnectionView>>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ImportLegacyConnections(arg1:Array<connection.SavedConnectionInput>):Promise<Array<connection.SavedConnectionView>>;
export function ImportLegacyGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
export function LogWindowDiagnostic(arg1:string,arg2:string):Promise<void>;
export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function MySQLConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
@@ -112,8 +141,12 @@ export function MySQLQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:str
export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function OpenDataRootDirectory():Promise<connection.QueryResult>;
export function OpenDownloadedUpdateDirectory():Promise<connection.QueryResult>;
export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
export function OpenSQLFile():Promise<connection.QueryResult>;
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
@@ -180,6 +213,16 @@ export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise
export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.QueryResult>;
export function RestartSecurityUpdate(arg1:app.RestartSecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
export function RetrySecurityUpdateCurrentRound(arg1:app.RetrySecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
export function SaveConnection(arg1:connection.SavedConnectionInput):Promise<connection.SavedConnectionView>;
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
export function SelectDataRootDirectory(arg1:string):Promise<connection.QueryResult>;
export function SelectDatabaseFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
@@ -194,6 +237,8 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;

View File

@@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
}
export function ApplyDataRootDirectory(arg1, arg2) {
return window['go']['app']['App']['ApplyDataRootDirectory'](arg1, arg2);
}
export function CancelQuery(arg1) {
return window['go']['app']['App']['CancelQuery'](arg1);
}
@@ -22,6 +26,14 @@ export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates']();
}
export function CheckForUpdatesSilently() {
return window['go']['app']['App']['CheckForUpdatesSilently']();
}
export function ClearTables(arg1, arg2, arg3) {
return window['go']['app']['App']['ClearTables'](arg1, arg2, arg3);
}
export function ConfigureDriverRuntimeDirectory(arg1) {
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
}
@@ -98,6 +110,14 @@ export function DataSyncPreview(arg1, arg2, arg3) {
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
}
export function DeleteConnection(arg1) {
return window['go']['app']['App']['DeleteConnection'](arg1);
}
export function DismissSecurityUpdateReminder() {
return window['go']['app']['App']['DismissSecurityUpdateReminder']();
}
export function DownloadDriverPackage(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4);
}
@@ -122,10 +142,18 @@ export function DropView(arg1, arg2, arg3) {
return window['go']['app']['App']['DropView'](arg1, arg2, arg3);
}
export function DuplicateConnection(arg1) {
return window['go']['app']['App']['DuplicateConnection'](arg1);
}
export function ExecuteSQLFile(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExecuteSQLFile'](arg1, arg2, arg3, arg4);
}
export function ExportConnectionsPackage(arg1) {
return window['go']['app']['App']['ExportConnectionsPackage'](arg1);
}
export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}
@@ -158,6 +186,10 @@ export function GetAppInfo() {
return window['go']['app']['App']['GetAppInfo']();
}
export function GetDataRootDirectoryInfo() {
return window['go']['app']['App']['GetDataRootDirectoryInfo']();
}
export function GetDriverStatusList(arg1, arg2) {
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
}
@@ -174,10 +206,22 @@ export function GetGlobalProxyConfig() {
return window['go']['app']['App']['GetGlobalProxyConfig']();
}
export function GetSavedConnections() {
return window['go']['app']['App']['GetSavedConnections']();
}
export function GetSecurityUpdateStatus() {
return window['go']['app']['App']['GetSecurityUpdateStatus']();
}
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}
export function ImportConnectionsPayload(arg1, arg2) {
return window['go']['app']['App']['ImportConnectionsPayload'](arg1, arg2);
}
export function ImportData(arg1, arg2, arg3) {
return window['go']['app']['App']['ImportData'](arg1, arg2, arg3);
}
@@ -186,14 +230,26 @@ export function ImportDataWithProgress(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4);
}
export function InstallLocalDriverPackage(arg1, arg2, arg3) {
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3);
export function ImportLegacyConnections(arg1) {
return window['go']['app']['App']['ImportLegacyConnections'](arg1);
}
export function ImportLegacyGlobalProxy(arg1) {
return window['go']['app']['App']['ImportLegacyGlobalProxy'](arg1);
}
export function InstallLocalDriverPackage(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3, arg4);
}
export function InstallUpdateAndRestart() {
return window['go']['app']['App']['InstallUpdateAndRestart']();
}
export function LogWindowDiagnostic(arg1, arg2) {
return window['go']['app']['App']['LogWindowDiagnostic'](arg1, arg2);
}
export function MongoDiscoverMembers(arg1) {
return window['go']['app']['App']['MongoDiscoverMembers'](arg1);
}
@@ -218,10 +274,18 @@ export function MySQLShowCreateTable(arg1, arg2, arg3) {
return window['go']['app']['App']['MySQLShowCreateTable'](arg1, arg2, arg3);
}
export function OpenDataRootDirectory() {
return window['go']['app']['App']['OpenDataRootDirectory']();
}
export function OpenDownloadedUpdateDirectory() {
return window['go']['app']['App']['OpenDownloadedUpdateDirectory']();
}
export function OpenDriverDownloadDirectory(arg1) {
return window['go']['app']['App']['OpenDriverDownloadDirectory'](arg1);
}
export function OpenSQLFile() {
return window['go']['app']['App']['OpenSQLFile']();
}
@@ -354,6 +418,26 @@ export function ResolveDriverRepositoryURL(arg1) {
return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1);
}
export function RestartSecurityUpdate(arg1) {
return window['go']['app']['App']['RestartSecurityUpdate'](arg1);
}
export function RetrySecurityUpdateCurrentRound(arg1) {
return window['go']['app']['App']['RetrySecurityUpdateCurrentRound'](arg1);
}
export function SaveConnection(arg1) {
return window['go']['app']['App']['SaveConnection'](arg1);
}
export function SaveGlobalProxy(arg1) {
return window['go']['app']['App']['SaveGlobalProxy'](arg1);
}
export function SelectDataRootDirectory(arg1) {
return window['go']['app']['App']['SelectDataRootDirectory'](arg1);
}
export function SelectDatabaseFile(arg1, arg2) {
return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2);
}
@@ -382,6 +466,10 @@ export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}
export function StartSecurityUpdate(arg1) {
return window['go']['app']['App']['StartSecurityUpdate'](arg1);
}
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}

View File

@@ -78,6 +78,8 @@ export namespace ai {
type: string;
name: string;
apiKey: string;
secretRef?: string;
hasSecret?: boolean;
baseUrl: string;
model: string;
models?: string[];
@@ -96,6 +98,8 @@ export namespace ai {
this.type = source["type"];
this.name = source["name"];
this.apiKey = source["apiKey"];
this.secretRef = source["secretRef"];
this.hasSecret = source["hasSecret"];
this.baseUrl = source["baseUrl"];
this.model = source["model"];
this.models = source["models"];
@@ -175,6 +179,233 @@ export namespace ai {
}
export namespace app {
export class ConnectionExportOptions {
includeSecrets: boolean;
filePassword?: string;
static createFrom(source: any = {}) {
return new ConnectionExportOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.includeSecrets = source["includeSecrets"];
this.filePassword = source["filePassword"];
}
}
export class SecurityUpdateOptions {
allowPartial?: boolean;
writeBackup?: boolean;
static createFrom(source: any = {}) {
return new SecurityUpdateOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.allowPartial = source["allowPartial"];
this.writeBackup = source["writeBackup"];
}
}
export class RestartSecurityUpdateRequest {
migrationId?: string;
sourceType: string;
rawPayload?: string;
options?: SecurityUpdateOptions;
static createFrom(source: any = {}) {
return new RestartSecurityUpdateRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.migrationId = source["migrationId"];
this.sourceType = source["sourceType"];
this.rawPayload = source["rawPayload"];
this.options = this.convertValues(source["options"], SecurityUpdateOptions);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class RetrySecurityUpdateRequest {
migrationId?: string;
static createFrom(source: any = {}) {
return new RetrySecurityUpdateRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.migrationId = source["migrationId"];
}
}
export class SecurityUpdateIssue {
id: string;
scope: string;
refId?: string;
title: string;
severity: string;
status: string;
reasonCode: string;
action: string;
message: string;
static createFrom(source: any = {}) {
return new SecurityUpdateIssue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.scope = source["scope"];
this.refId = source["refId"];
this.title = source["title"];
this.severity = source["severity"];
this.status = source["status"];
this.reasonCode = source["reasonCode"];
this.action = source["action"];
this.message = source["message"];
}
}
export class SecurityUpdateSummary {
total: number;
updated: number;
pending: number;
skipped: number;
failed: number;
static createFrom(source: any = {}) {
return new SecurityUpdateSummary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.total = source["total"];
this.updated = source["updated"];
this.pending = source["pending"];
this.skipped = source["skipped"];
this.failed = source["failed"];
}
}
export class SecurityUpdateStatus {
schemaVersion?: number;
migrationId?: string;
overallStatus: string;
sourceType?: string;
reminderVisible: boolean;
canStart: boolean;
canPostpone: boolean;
canRetry: boolean;
backupAvailable: boolean;
backupPath?: string;
startedAt?: string;
updatedAt?: string;
completedAt?: string;
postponedAt?: string;
summary: SecurityUpdateSummary;
issues: SecurityUpdateIssue[];
lastError?: string;
static createFrom(source: any = {}) {
return new SecurityUpdateStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.schemaVersion = source["schemaVersion"];
this.migrationId = source["migrationId"];
this.overallStatus = source["overallStatus"];
this.sourceType = source["sourceType"];
this.reminderVisible = source["reminderVisible"];
this.canStart = source["canStart"];
this.canPostpone = source["canPostpone"];
this.canRetry = source["canRetry"];
this.backupAvailable = source["backupAvailable"];
this.backupPath = source["backupPath"];
this.startedAt = source["startedAt"];
this.updatedAt = source["updatedAt"];
this.completedAt = source["completedAt"];
this.postponedAt = source["postponedAt"];
this.summary = this.convertValues(source["summary"], SecurityUpdateSummary);
this.issues = this.convertValues(source["issues"], SecurityUpdateIssue);
this.lastError = source["lastError"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class StartSecurityUpdateRequest {
sourceType: string;
rawPayload?: string;
options?: SecurityUpdateOptions;
static createFrom(source: any = {}) {
return new StartSecurityUpdateRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sourceType = source["sourceType"];
this.rawPayload = source["rawPayload"];
this.options = this.convertValues(source["options"], SecurityUpdateOptions);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace connection {
export class UpdateRow {
@@ -284,6 +515,7 @@ export namespace connection {
}
}
export class ConnectionConfig {
id?: string;
type: string;
host: string;
port: number;
@@ -324,6 +556,7 @@ export namespace connection {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
@@ -377,6 +610,32 @@ export namespace connection {
return a;
}
}
export class GlobalProxyView {
enabled: boolean;
type: string;
host: string;
port: number;
user?: string;
password?: string;
hasPassword?: boolean;
secretRef?: string;
static createFrom(source: any = {}) {
return new GlobalProxyView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.hasPassword = source["hasPassword"];
this.secretRef = source["secretRef"];
}
}
export class QueryResult {
@@ -400,6 +659,146 @@ export namespace connection {
}
}
export class SaveGlobalProxyInput {
enabled: boolean;
type: string;
host: string;
port: number;
user?: string;
password?: string;
static createFrom(source: any = {}) {
return new SaveGlobalProxyInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
}
}
export class SavedConnectionInput {
id?: string;
name: string;
config: ConnectionConfig;
includeDatabases?: string[];
includeRedisDatabases?: number[];
iconType?: string;
iconColor?: string;
clearPrimaryPassword?: boolean;
clearSSHPassword?: boolean;
clearProxyPassword?: boolean;
clearHttpTunnelPassword?: boolean;
clearMySQLReplicaPassword?: boolean;
clearMongoReplicaPassword?: boolean;
clearOpaqueURI?: boolean;
clearOpaqueDSN?: boolean;
static createFrom(source: any = {}) {
return new SavedConnectionInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.config = this.convertValues(source["config"], ConnectionConfig);
this.includeDatabases = source["includeDatabases"];
this.includeRedisDatabases = source["includeRedisDatabases"];
this.iconType = source["iconType"];
this.iconColor = source["iconColor"];
this.clearPrimaryPassword = source["clearPrimaryPassword"];
this.clearSSHPassword = source["clearSSHPassword"];
this.clearProxyPassword = source["clearProxyPassword"];
this.clearHttpTunnelPassword = source["clearHttpTunnelPassword"];
this.clearMySQLReplicaPassword = source["clearMySQLReplicaPassword"];
this.clearMongoReplicaPassword = source["clearMongoReplicaPassword"];
this.clearOpaqueURI = source["clearOpaqueURI"];
this.clearOpaqueDSN = source["clearOpaqueDSN"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SavedConnectionView {
id: string;
name: string;
config: ConnectionConfig;
includeDatabases?: string[];
includeRedisDatabases?: number[];
iconType?: string;
iconColor?: string;
secretRef?: string;
hasPrimaryPassword?: boolean;
hasSSHPassword?: boolean;
hasProxyPassword?: boolean;
hasHttpTunnelPassword?: boolean;
hasMySQLReplicaPassword?: boolean;
hasMongoReplicaPassword?: boolean;
hasOpaqueURI?: boolean;
hasOpaqueDSN?: boolean;
static createFrom(source: any = {}) {
return new SavedConnectionView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.config = this.convertValues(source["config"], ConnectionConfig);
this.includeDatabases = source["includeDatabases"];
this.includeRedisDatabases = source["includeRedisDatabases"];
this.iconType = source["iconType"];
this.iconColor = source["iconColor"];
this.secretRef = source["secretRef"];
this.hasPrimaryPassword = source["hasPrimaryPassword"];
this.hasSSHPassword = source["hasSSHPassword"];
this.hasProxyPassword = source["hasProxyPassword"];
this.hasHttpTunnelPassword = source["hasHttpTunnelPassword"];
this.hasMySQLReplicaPassword = source["hasMySQLReplicaPassword"];
this.hasMongoReplicaPassword = source["hasMongoReplicaPassword"];
this.hasOpaqueURI = source["hasOpaqueURI"];
this.hasOpaqueDSN = source["hasOpaqueDSN"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}

14
go.mod
View File

@@ -26,13 +26,22 @@ require (
modernc.org/sqlite v1.44.3
)
require (
github.com/kr/pretty v0.3.1 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/99designs/keyring v1.2.2
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.5.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/duckdb/duckdb-go-bindings v0.3.3 // indirect
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.3.3 // indirect
@@ -41,17 +50,20 @@ require (
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3 // indirect
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -68,6 +80,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
@@ -100,6 +113,7 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
modernc.org/libc v1.67.6 // indirect

30
go.sum
View File

@@ -4,6 +4,10 @@ gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3 h1:QjslQNaH5Nuap5i4ni
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3/go.mod h1:7lH5A1jzCXD9Nl16DzaBUOfDAT8NPrDmZwKu1p5wf94=
gitee.com/chunanyong/dm v1.8.22 h1:H7fsrnUIvEA0jlDWew7vwELry1ff+tLMIu2Fk2cIBSg=
gitee.com/chunanyong/dm v1.8.22/go.mod h1:EPRJnuPFgbyOFgJ0TRYCTGzhq+ZT4wdyaj/GW/LLcNg=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
@@ -34,6 +38,9 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -56,6 +63,8 @@ github.com/duckdb/duckdb-go/v2 v2.5.5 h1:TlK8ipnzoKW2aNrjGqRkFWLCDpJDxR/VwH8ezEc
github.com/duckdb/duckdb-go/v2 v2.5.5/go.mod h1:6uIbC3gz36NCEygECzboygOo/Z9TeVwox/puG+ohWV0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
@@ -68,6 +77,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@@ -95,6 +106,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
@@ -114,6 +127,9 @@ github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -158,9 +174,10 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
@@ -169,6 +186,7 @@ github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -186,6 +204,9 @@ github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTK
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
@@ -201,6 +222,7 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -300,6 +322,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -340,8 +363,9 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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