Compare commits

..

55 Commits

Author SHA1 Message Date
Syngnat
65a9f4352e feat(sql-files): 支持外部 SQL 目录树与双击打开
- 新增 SQL 目录选择、枚举与按路径读取接口,复用大文件执行能力
- Sidebar 增加外部 SQL 文件目录树、目录管理入口与双击打开查询标签
- 补充 external SQL 持久化与前后端回归测试

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  ## 回归验证

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

  ## 人工验证

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

  ## 备注

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

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

Refs: #352
2026-04-12 12:34:50 +08:00
tianqijiuyun-latiao
d150780879 Merge branch 'feature/20260408_security-update' into merge/feature-20260408-security-update-onto-dev
# Conflicts:
#	frontend/src/App.tsx
#	frontend/wailsjs/go/app/App.d.ts
#	frontend/wailsjs/go/app/App.js
2026-04-12 09:40:28 +08:00
tianqijiuyun-latiao
52d2ee7592 feat(connection-package): 支持连接恢复包双模式加密导入导出
- 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路
- 扩展前后端导入导出流程并兼容 v1 与 legacy 格式
- 修复无文件密码恢复包导入误弹密码框导致的流程阻塞
2026-04-11 23:51:43 +08:00
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
tianqijiuyun-latiao
070ff72ad8 feat(security): 完成密文升级与连接恢复包导入导出 2026-04-10 21:29:45 +08:00
tianqijiuyun-latiao
1a042321d2 🐛 fix(connection): 修复失败连接高频重试并暂停后台自动元数据拉取 #331
- 后端为失败数据库连接增加冷却窗口,避免短时间内重复真实建连
- 补充失败冷却回归测试,覆盖重复失败、冷却后重试和成功后清理场景
- 前端在后台态暂停查询页、侧边栏和表概览的自动元数据拉取
- 保持手动刷新、手动展开等显式操作行为不变
2026-04-08 22:31:50 +08:00
182 changed files with 20027 additions and 1558 deletions

View File

@@ -320,6 +320,9 @@ jobs:
echo " macOS 产物不执行 UPX 压缩,保留原始主程序。"
echo "🔏 正在进行 Ad-hoc 签名..."
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$APP_NAME" || true
fi
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.build_name }}.dmg"
@@ -336,6 +339,17 @@ jobs:
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dev-verify.XXXXXX")
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$PACKAGED_APP" ]; then
echo "❌ DMG 内未找到 .app 应用包!"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
exit 1
fi
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
mv "$DMG_NAME" "../../$FINAL_NAME"

View File

@@ -314,6 +314,9 @@ jobs:
echo "🔏 正在进行 Ad-hoc 签名..."
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$APP_NAME" || true
fi
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.build_name }}.dmg"
@@ -330,6 +333,17 @@ jobs:
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-release-verify.XXXXXX")
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$PACKAGED_APP" ]; then
echo "❌ DMG 内未找到 .app 应用包!"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
exit 1
fi
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
mv "$DMG_NAME" "../../$FINAL_NAME"

5
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# IDE
.idea/
*.iml
.gitignore
# build / release artifacts
frontend/release/
**/release/
@@ -27,4 +27,5 @@ docs/需求追踪/
CLAUDE.md
**/CLAUDE.md
.worktrees
docs
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

@@ -212,7 +212,7 @@ 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">

View File

@@ -195,7 +195,7 @@ 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 增长趋势)

View File

@@ -84,6 +84,63 @@ try_compress_binary_with_upx() {
fi
}
clear_macos_bundle_xattrs() {
local bundle_path="$1"
if [ -z "$bundle_path" ] || [ ! -e "$bundle_path" ]; then
return
fi
if command -v xattr >/dev/null 2>&1; then
xattr -cr "$bundle_path" >/dev/null 2>&1 || true
fi
}
verify_macos_dmg_bundle_signature() {
local dmg_path="$1"
local mount_dir=""
local app_path=""
if [ -z "$dmg_path" ] || [ ! -f "$dmg_path" ]; then
echo -e "${RED} ❌ DMG 文件不存在,无法校验签名:$dmg_path${NC}"
return 1
fi
if ! command -v hdiutil >/dev/null 2>&1 || ! command -v codesign >/dev/null 2>&1; then
echo -e "${YELLOW} ⚠️ 当前环境缺少 hdiutil 或 codesign跳过 DMG 内应用签名校验。${NC}"
return 0
fi
mount_dir=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dmg-verify.XXXXXX")
if [ -z "$mount_dir" ] || [ ! -d "$mount_dir" ]; then
echo -e "${RED} ❌ 创建 DMG 校验挂载目录失败。${NC}"
return 1
fi
if ! hdiutil attach -nobrowse -readonly -mountpoint "$mount_dir" "$dmg_path" >/dev/null 2>&1; then
rmdir "$mount_dir" >/dev/null 2>&1 || true
echo -e "${RED} ❌ 挂载 DMG 失败,无法校验签名。${NC}"
return 1
fi
app_path=$(find "$mount_dir" -maxdepth 1 -name "*.app" -print -quit)
if [ -z "$app_path" ] || [ ! -d "$app_path" ]; then
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
rmdir "$mount_dir" >/dev/null 2>&1 || true
echo -e "${RED} ❌ DMG 内未找到 .app 应用包。${NC}"
return 1
fi
if ! codesign --verify --deep --strict --verbose=4 "$app_path" >/dev/null 2>&1; then
echo -e "${RED} ❌ DMG 内 .app 签名校验失败:$(basename "$app_path")${NC}"
codesign --verify --deep --strict --verbose=4 "$app_path" 2>&1 | sed 's/^/ /'
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
rmdir "$mount_dir" >/dev/null 2>&1 || true
return 1
fi
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
rmdir "$mount_dir" >/dev/null 2>&1 || true
return 0
}
MAC_VOLICON_PATH="build/darwin/icon.icns"
if [ ! -f "$MAC_VOLICON_PATH" ]; then
MAC_VOLICON_PATH=""
@@ -112,19 +169,20 @@ if [ $? -eq 0 ]; then
else
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
exit 1
fi
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
fi
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME"
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
# 创建 DMG
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (arm64)..."
# 移除已存在的 DMG (以防万一)
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
# 创建 DMG
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (arm64)..."
# 移除已存在的 DMG (以防万一)
rm -f "$DIST_DIR/$DMG_NAME"
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
else
@@ -134,8 +192,9 @@ if [ $? -eq 0 ]; then
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
else
@@ -179,15 +238,17 @@ if [ $? -eq 0 ]; then
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then
echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}"
else
# 删除中间的 .app 文件,保持目录整洁
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
fi
@@ -219,11 +280,12 @@ if [ $? -eq 0 ]; then
else
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
exit 1
fi
# Ad-hoc 代码签名
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
fi
# Ad-hoc 代码签名
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME"
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
if command -v create-dmg &> /dev/null; then
echo " 📦 正在打包 DMG (amd64)..."
@@ -239,8 +301,9 @@ if [ $? -eq 0 ]; then
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
fi
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口CI/本地静默打包更友好)。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
if [ -n "$MAC_VOLICON_PATH" ]; then
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
else
@@ -282,14 +345,16 @@ if [ $? -eq 0 ]; then
fi
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
else
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then
echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}"
else
rm -rf "$DIST_DIR/$APP_DEST_NAME"
echo " ✅ 已生成 $DMG_NAME"
fi
fi
fi

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

@@ -1,111 +0,0 @@
# 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
26a843d5fd071d0c7e9d8022e98eb4e3

View File

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

@@ -28,6 +28,7 @@ interface AISettingsModalProps {
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
@@ -79,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');
@@ -135,6 +136,17 @@ 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);

View File

@@ -4,9 +4,16 @@ import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutl
import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import {
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
resolveConnectionTestFailureFeedback,
} from '../utils/connectionModalPresentation';
import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn';
import { CUSTOM_CONNECTION_DRIVER_HELP } from '../utils/driverImportGuidance';
import { applyNoAutoCapAttributes, noAutoCapInputProps } from '../utils/inputAutoCap';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
@@ -19,21 +26,6 @@ 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'
@@ -135,7 +127,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);
@@ -171,6 +164,7 @@ const ConnectionModal: React.FC<{
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const disableLocalBackdropFilter = isMacLikePlatform();
const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single';
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
@@ -201,7 +195,10 @@ const ConnectionModal: React.FC<{
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const overlayTheme = useMemo(
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
[darkMode, disableLocalBackdropFilter],
);
const tunnelSectionStyle: React.CSSProperties = {
padding: '12px',
@@ -1443,6 +1440,13 @@ const ConnectionModal: React.FC<{
message.success('配置已保存(未连接)');
}
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);
@@ -1453,7 +1457,7 @@ const ConnectionModal: React.FC<{
setClearSecrets(createEmptyConnectionSecretClearState());
onClose();
} catch (e: any) {
message.error(e?.message || '保存失败');
message.error(normalizeConnectionSecretErrorMessage(e?.message || e, '保存失败'));
} finally {
setLoading(false);
}
@@ -1508,10 +1512,14 @@ const ConnectionModal: React.FC<{
}
return null;
};
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
const text = String(reason ?? '').trim();
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
return `测试失败: ${normalized}`;
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 () => {
@@ -1522,14 +1530,21 @@ 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) {
setTestResult({ type: 'error', message: blockingSecretClearMessage });
applyTestFailureFeedback(resolveConnectionTestFailureFeedback({
kind: 'secret_blocked',
reason: blockingSecretClearMessage,
fallback: '连接参数不完整',
}));
return;
}
setLoading(true);
@@ -1555,6 +1570,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));
@@ -1578,27 +1594,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);
@@ -1624,7 +1646,7 @@ const ConnectionModal: React.FC<{
}
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>) || {};
@@ -1645,7 +1667,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);
}
@@ -2142,7 +2164,7 @@ const ConnectionModal: React.FC<{
{isCustom ? (
<>
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help="已支持: mysql, postgres, sqlite, oracle, dm, kingbase">
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help={CUSTOM_CONNECTION_DRIVER_HELP}>
<Input {...noAutoCapInputProps} placeholder="例如: mysql, postgres" />
</Form.Item>
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[createCustomDsnRule()]}>
@@ -2233,7 +2255,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} placeholder="留空沿用主库用户名" />
</Form.Item>
<Form.Item name="mysqlReplicaPassword" label="从库密码(可选)" style={{ marginBottom: 0 }}>
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主库密码" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
emptyPlaceholder: '留空沿用主库密码',
retainedLabel: '已保存从库密码',
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
@@ -2283,7 +2312,14 @@ const ConnectionModal: React.FC<{
</Form.Item>
</div>
<Form.Item name="mongoReplicaPassword" label="副本集密码(可选)" style={{ marginBottom: 0 }}>
<Input.Password {...noAutoCapInputProps} placeholder="留空沿用主密码" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
emptyPlaceholder: '留空沿用主密码',
retainedLabel: '已保存副本集密码',
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: 'mongoReplicaPassword',
@@ -2364,7 +2400,14 @@ const ConnectionModal: React.FC<{
</Form.Item>
)}
<Form.Item name="password" label="密码 (可选)">
<Input.Password {...noAutoCapInputProps} placeholder="Redis 密码(如果设置了 requirepass" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder: 'Redis 密码(如果设置了 requirepass',
retainedLabel: '已保存 Redis 密码',
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: 'password',
@@ -2397,7 +2440,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} />
</Form.Item>
<Form.Item name="password" label="密码" style={{ marginBottom: 0 }}>
<Input.Password {...noAutoCapInputProps} />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder: '密码',
retainedLabel: '已保存密码',
})}
/>
</Form.Item>
{dbType === 'mongodb' && (
<Form.Item name="mongoAuthMechanism" label="验证方式" style={{ marginBottom: 0 }}>
@@ -2518,7 +2568,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} placeholder="root" />
</Form.Item>
<Form.Item name="sshPassword" label="SSH 密码" style={{ flex: 1 }}>
<Input.Password {...noAutoCapInputProps} placeholder="密码" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasSSHPassword,
emptyPlaceholder: '密码',
retainedLabel: '已保存 SSH 密码',
})}
/>
</Form.Item>
</div>
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
@@ -2573,7 +2630,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasProxyPassword,
emptyPlaceholder: '留空表示无认证',
retainedLabel: '已保存代理密码',
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
@@ -2611,7 +2675,14 @@ const ConnectionModal: React.FC<{
<Input {...noAutoCapInputProps} placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="httpTunnelPassword" label="隧道密码(可选)" style={{ flex: 1 }}>
<Input.Password {...noAutoCapInputProps} placeholder="留空表示无认证" />
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
emptyPlaceholder: '留空表示无认证',
retainedLabel: '已保存隧道密码',
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
@@ -3153,5 +3224,3 @@ 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

@@ -0,0 +1,80 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import DataGrid from './DataGrid';
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
connections: [],
addSqlLog: vi.fn(),
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
},
queryOptions: {
showColumnComment: false,
showColumnType: false,
},
setQueryOptions: vi.fn(),
tableColumnOrders: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
clearTableColumnOrder: vi.fn(),
tableHiddenColumns: {},
enableHiddenColumnMemory: false,
setTableHiddenColumns: vi.fn(),
setEnableHiddenColumnMemory: vi.fn(),
clearTableHiddenColumns: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}),
}));
vi.mock('../../wailsjs/go/app/App', () => ({
ImportData: vi.fn(),
ExportTable: vi.fn(),
ExportData: vi.fn(),
ExportQuery: vi.fn(),
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
}));
vi.mock('@monaco-editor/react', () => ({
default: () => null,
}));
describe('DataGrid layout', () => {
it('renders a secondary action strip for view switching and auxiliary actions', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
readOnly
pagination={{
current: 1,
pageSize: 100,
total: 1,
}}
onPageChange={() => {}}
/>,
);
expect(markup).toContain('data-grid-secondary-actions="true"');
expect(markup).toContain('data-grid-view-switcher="true"');
});
});

View File

@@ -31,7 +31,7 @@ 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 { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
resolveDataTableColumnWidth,
@@ -50,6 +50,16 @@ import {
} from './dataGridCopyInsert';
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';
import {
TEMPORAL_FORMATS,
formatFromDayjs,
getTemporalPickerType,
isTemporalColumnType,
parseToDayjs,
resolveTemporalEditorSaveValue,
type TemporalPickerType,
} from './dataGridTemporal';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -166,51 +176,6 @@ const normalizeDateTimeString = (val: string) => {
return normalized;
};
const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'year';
};
// 根据列类型返回 DatePicker 的 picker 模式
type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return null;
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
const base = raw.split(/[ (]/)[0];
if (base === 'date') return 'date';
if (base === 'time') return 'time';
if (base === 'year') return 'year';
return null;
};
const TEMPORAL_FORMATS: Record<string, string> = {
datetime: 'YYYY-MM-DD HH:mm:ss',
date: 'YYYY-MM-DD',
time: 'HH:mm:ss',
year: 'YYYY',
};
// 将字符串值转为 dayjs 对象(用于 DatePicker无效值返回 null
const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
if (val === null || val === undefined || val === '') return null;
const str = String(val).trim();
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null; // 无效日期
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
const d = dayjs(str, fmt);
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
};
// 将 dayjs 对象格式化为对应格式字符串
const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
if (!val || !val.isValid()) return '';
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
return val.format(fmt);
};
// --- Helper: Format Value ---
const formatCellValue = (val: any) => {
try {
@@ -639,17 +604,14 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
setEditing(!editing);
};
const save = async () => {
const save = async (pickerValue?: dayjs.Dayjs | null) => {
try {
if (!form || !editing) return;
const fieldName = getCellFieldName(record, dataIndex);
await form.validateFields([fieldName]);
let nextValue = form.getFieldValue(fieldName);
// 日期时间类型: 将 dayjs 对象转回格式化字符串
if (isDateTimeField && nextValue && dayjs.isDayjs(nextValue)) {
nextValue = formatFromDayjs(nextValue as dayjs.Dayjs, pickerType);
} else if (isDateTimeField && !nextValue) {
nextValue = null;
if (isDateTimeField) {
nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType);
}
toggleEdit();
// 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。
@@ -688,9 +650,9 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
ref={inputRef}
style={{ width: '100%' }}
format={TEMPORAL_FORMATS[pickerType]}
onChange={() => setTimeout(save, 0)}
onChange={(value) => setTimeout(() => { void save(value); }, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
onBlur={() => setTimeout(() => { void save(); }, 0)}
needConfirm={false}
/>
) : pickerType === 'datetime' ? (
@@ -711,7 +673,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
}}
></a>
)}
onOk={() => setTimeout(save, 0)}
onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)}
onOpenChange={(open) => {
pickerOpenRef.current = open;
lockTableScroll(open);
@@ -731,17 +693,17 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
style={{ width: '100%' }}
format={TEMPORAL_FORMATS[pickerType]}
picker={pickerType as any}
onChange={() => setTimeout(save, 0)}
onChange={(value) => setTimeout(() => { void save(value); }, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
onBlur={() => setTimeout(() => { void save(); }, 0)}
needConfirm={false}
/>
)
) : (
<Input
ref={inputRef}
onPressEnter={save}
onBlur={save}
onPressEnter={() => { void save(); }}
onBlur={() => { void save(); }}
onFocus={(e) => {
try {
(e.target as HTMLInputElement)?.select?.();
@@ -2234,6 +2196,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// Filter State
const [filterConditions, setFilterConditions] = useState<GridFilterCondition[]>([]);
const [nextFilterId, setNextFilterId] = useState(1);
const filterPanelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const nextConditions = normalizeGridFilterConditions(appliedFilterConditions);
@@ -2242,6 +2205,30 @@ const DataGrid: React.FC<DataGridProps> = ({
setNextFilterId(Math.max(1, maxId + 1));
}, [appliedFilterConditions, normalizeGridFilterConditions]);
useEffect(() => {
if (!showFilter) {
return;
}
const root = filterPanelRef.current;
if (!root) {
return;
}
const apply = () => {
applyNoAutoCapAttributesWithin(root);
};
apply();
if (typeof MutationObserver === 'undefined') {
return;
}
const observer = new MutationObserver(() => {
apply();
});
observer.observe(root, { childList: true, subtree: true });
return () => {
observer.disconnect();
};
}, [showFilter]);
const selectedRowKeysRef = useRef(selectedRowKeys);
const displayDataRef = useRef<any[]>([]);
@@ -3260,20 +3247,6 @@ const DataGrid: React.FC<DataGridProps> = ({
setRowEditorOpen(true);
}, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr, columnMetaMap, columnMetaMapByLowerName]);
const openRowEditor = useCallback(() => {
if (!canModifyData) return;
if (selectedRowKeys.length > 1) {
void message.info('一次只能编辑一行,请仅选择一行');
return;
}
const keyStr = selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined;
if (!keyStr) {
void message.info('请先选择一行(勾选复选框)');
return;
}
openRowEditorByKey(keyStr);
}, [canModifyData, selectedRowKeys, rowKeyStr, openRowEditorByKey]);
const openCurrentViewRowEditor = useCallback(() => {
if (!canModifyData) return;
const currentRow = mergedDisplayData[textRecordIndex];
@@ -3291,6 +3264,50 @@ const DataGrid: React.FC<DataGridProps> = ({
setJsonEditorOpen(true);
}, [canModifyData, jsonViewText]);
const handleViewModeChange = useCallback((nextMode: GridViewMode) => {
if (nextMode === 'json' && cellEditMode) {
setCellEditMode(false);
setSelectedCells(new Set());
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
cellSelectionRafRef.current = null;
}
if (cellSelectionScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionScrollRafRef.current);
cellSelectionScrollRafRef.current = null;
}
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
updateCellSelection(new Set());
}
if (nextMode === 'text') {
const selectedKey = selectedRowKeys[0];
if (selectedKey !== undefined) {
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
if (idx >= 0) {
setTextRecordIndex(idx);
}
}
}
setViewMode(nextMode);
}, [cellEditMode, mergedDisplayData, selectedRowKeys, rowKeyStr, updateCellSelection]);
const handleOpenContextMenuRowEditor = useCallback(() => {
if (!canModifyData) return;
const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY];
if (rowKey === undefined || rowKey === null) return;
openRowEditorByKey(rowKeyStr(rowKey));
setCellContextMenu(prev => ({ ...prev, visible: false }));
}, [canModifyData, cellContextMenu.record, openRowEditorByKey, rowKeyStr]);
const handleFormatJsonEditor = useCallback(() => {
try {
const parsed = JSON.parse(jsonEditorValue);
@@ -4002,7 +4019,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
const dbType = config.type || '';
const dbType = resolveDataSourceType(config);
const pkWhere = buildPkWhereSql(records, dbType);
if (!pkWhere) {
await exportData(records, format);
@@ -4071,7 +4088,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
const sql = buildCurrentPageSql(config.type || '');
const sql = buildCurrentPageSql(resolveDataSourceType(config));
if (!sql) {
await exportData(displayData, format);
return;
@@ -4881,7 +4898,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''} data-grid-root`} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, background: 'transparent' }}>
{/* Toolbar + Filter Panel */}
<div style={{ margin: `${panelOuterGap}px 0 ${panelOuterGap}px 0`, border: `1px solid ${panelFrameColor}`, borderRadius: `${panelRadius}px`, background: bgFilter, overflow: 'hidden', boxSizing: 'border-box' }}>
<div className="data-grid-toolbar-scroll" style={{ padding: showFilter ? `${panelPaddingY}px ${panelPaddingX}px ${toolbarBottomPadding}px ${panelPaddingX}px` : `${panelPaddingY}px ${panelPaddingX}px`, border: 'none', borderRadius: 0, background: 'transparent', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarGutter: 'stable', WebkitOverflowScrolling: 'touch', boxSizing: 'border-box' }}>
<div className="data-grid-toolbar-scroll" data-grid-primary-actions="true" style={{ padding: showFilter ? `${panelPaddingY}px ${panelPaddingX}px ${toolbarBottomPadding}px ${panelPaddingX}px` : `${panelPaddingY}px ${panelPaddingX}px`, border: 'none', borderRadius: 0, background: 'transparent', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarGutter: 'stable', WebkitOverflowScrolling: 'touch', boxSizing: 'border-box' }}>
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
setAddedRows([]);
setModifiedRows({});
@@ -4904,13 +4921,6 @@ const DataGrid: React.FC<DataGridProps> = ({
<>
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
<Button icon={<PlusOutlined />} onClick={handleAddRow}></Button>
<Button
icon={<EditOutlined />}
disabled={selectedRowKeys.length !== 1}
onClick={openRowEditor}
>
</Button>
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}></Button>
{selectedRowKeys.length > 0 && <span style={{ fontSize: '12px', color: '#888' }}> {selectedRowKeys.length}</span>}
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
@@ -5060,82 +5070,10 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
<div style={{ marginLeft: 'auto' }} />
<div style={{ flexShrink: 0 }}>
<Button
icon={<EditOutlined />}
type={dataPanelOpen ? 'primary' : 'default'}
onClick={() => {
const next = !dataPanelOpen;
setDataPanelOpen(next);
if (!next) {
setFocusedCellInfo(null);
setDataPanelValue('');
setDataPanelIsJson(false);
dataPanelDirtyRef.current = false;
}
}}
>
</Button>
</div>
<div style={{ flexShrink: 0 }}>
<Popover
trigger="click"
placement="bottomRight"
content={columnInfoSettingContent}
>
<Button icon={<FileTextOutlined />}></Button>
</Popover>
</div>
<div style={{ flexShrink: 0 }}>
<Segmented
size="small"
value={viewMode}
options={[
{ label: '表格', value: 'table' },
{ label: 'JSON', value: 'json' },
{ label: '文本', value: 'text' }
]}
onChange={(val) => {
const nextMode = String(val) as GridViewMode;
if (nextMode === 'json' && cellEditMode) {
setCellEditMode(false);
setSelectedCells(new Set());
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
cellSelectionRafRef.current = null;
}
if (cellSelectionScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionScrollRafRef.current);
cellSelectionScrollRafRef.current = null;
}
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
updateCellSelection(new Set());
}
if (nextMode === 'text') {
const selectedKey = selectedRowKeys[0];
if (selectedKey !== undefined) {
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
if (idx >= 0) {
setTextRecordIndex(idx);
}
}
}
setViewMode(nextMode);
}}
/>
</div>
</div>
{showFilter && (
<div style={{
<div ref={filterPanelRef} style={{
padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`,
background: 'transparent',
boxSizing: 'border-box',
@@ -5184,6 +5122,7 @@ const DataGrid: React.FC<DataGridProps> = ({
{cond.op === 'CUSTOM' ? (
<Input.TextArea
{...noAutoCapInputProps}
style={{ flex: 1 }}
autoSize={{ minRows: 1, maxRows: 4 }}
value={cond.value}
@@ -5192,6 +5131,7 @@ const DataGrid: React.FC<DataGridProps> = ({
/>
) : isListOp(cond.op) ? (
<Input.TextArea
{...noAutoCapInputProps}
style={{ flex: 1 }}
autoSize={{ minRows: 1, maxRows: 4 }}
value={cond.value}
@@ -5201,12 +5141,14 @@ const DataGrid: React.FC<DataGridProps> = ({
) : isBetweenOp(cond.op) ? (
<>
<Input
{...noAutoCapInputProps}
style={{ width: 220 }}
value={cond.value}
onChange={e => updateFilter(cond.id, 'value', e.target.value)}
placeholder="开始值"
/>
<Input
{...noAutoCapInputProps}
style={{ width: 220 }}
value={cond.value2 || ''}
onChange={e => updateFilter(cond.id, 'value2', e.target.value)}
@@ -5214,9 +5156,10 @@ const DataGrid: React.FC<DataGridProps> = ({
/>
</>
) : isNoValueOp(cond.op) ? (
<Input style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
<Input {...noAutoCapInputProps} style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
) : (
<Input
{...noAutoCapInputProps}
style={{ width: 280 }}
value={cond.value}
onChange={e => updateFilter(cond.id, 'value', e.target.value)}
@@ -5718,6 +5661,19 @@ const DataGrid: React.FC<DataGridProps> = ({
>
NULL
</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={handleOpenContextMenuRowEditor}
>
<EditOutlined style={{ marginRight: 8 }} />
</div>
<div
style={{
padding: '8px 12px',
@@ -5927,6 +5883,58 @@ const DataGrid: React.FC<DataGridProps> = ({
document.body
)}
</div>
<div
data-grid-secondary-actions="true"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
flexWrap: 'wrap',
padding: '4px 0 0',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Button
icon={<EditOutlined />}
type={dataPanelOpen ? 'primary' : 'default'}
disabled={viewMode !== 'table'}
onClick={() => {
const next = !dataPanelOpen;
setDataPanelOpen(next);
if (!next) {
setFocusedCellInfo(null);
setDataPanelValue('');
setDataPanelIsJson(false);
dataPanelDirtyRef.current = false;
}
}}
>
</Button>
<Popover
trigger="click"
placement="bottomRight"
content={columnInfoSettingContent}
>
<Button icon={<FileTextOutlined />}></Button>
</Popover>
</div>
<div data-grid-view-switcher="true" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}></span>
<Segmented
size="small"
value={viewMode}
options={[
{ label: '表格', value: 'table' },
{ label: 'JSON', value: 'json' },
{ label: '文本', value: 'text' }
]}
onChange={(val) => handleViewModeChange(String(val) as GridViewMode)}
/>
</div>
</div>
{pagination && (
<div className="data-grid-pagination-wrap" style={{ padding: '12px 0 0', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>

View File

@@ -5,13 +5,15 @@ import { useStore } from '../store';
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
import { SavedConnection } from '../types';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
const { Title, Text } = Typography;
const { Step } = Steps;
const { Option } = Select;
const { TextArea } = Input;
type SyncLogEvent = { jobId: string; level?: string; message?: string; ts?: number };
type SyncProgressEvent = { jobId: string; percent?: number; current?: number; total?: number; table?: string; stage?: string };
@@ -24,6 +26,7 @@ type TableDiffSummary = {
updates?: number;
deletes?: number;
same?: number;
schemaDiffCount?: number;
message?: string;
targetTableExists?: boolean;
plannedAction?: string;
@@ -123,6 +126,15 @@ const buildSqlPreview = (
? previewData.columnTypes as Record<string, string>
: {};
const statements: string[] = [];
const schemaStatements = Array.isArray(previewData.schemaStatements)
? previewData.schemaStatements
.map((item: any) => String(item || '').trim())
.filter((item: string) => item.length > 0)
: [];
schemaStatements.forEach((statement: string) => {
statements.push(statement.endsWith(';') ? statement : `${statement};`);
});
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
@@ -190,6 +202,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const darkMode = themeMode === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const disableLocalBackdropFilter = isMacLikePlatform();
// Step 1: Config
const [sourceConnId, setSourceConnId] = useState<string>('');
@@ -203,6 +216,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
// Step 2: Tables
const [allTables, setAllTables] = useState<string[]>([]);
const [selectedTables, setSelectedTables] = useState<string[]>([]);
const [sourceDatasetMode, setSourceDatasetMode] = useState<SourceDatasetMode>('table');
const [sourceQuery, setSourceQuery] = useState<string>('');
// Options
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
@@ -283,7 +298,10 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setTargetConnId('');
setSourceDb('');
setTargetDb('');
setAllTables([]);
setSelectedTables([]);
setSourceDatasetMode('table');
setSourceQuery('');
setWorkflowType('sync');
setSyncContent('data');
setSyncMode('insert_update');
@@ -331,6 +349,28 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
}
}, [workflowType]);
useEffect(() => {
if (sourceDatasetMode !== 'query') return;
if (workflowType !== 'sync') {
setWorkflowType('sync');
}
if (syncContent !== 'data') {
setSyncContent('data');
}
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
if (autoAddColumns) {
setAutoAddColumns(false);
}
if (selectedTables.length > 1) {
setSelectedTables(selectedTables.slice(0, 1));
}
}, [sourceDatasetMode, workflowType, syncContent, targetTableStrategy, createIndexes, autoAddColumns, selectedTables]);
const handleSourceConnChange = async (connId: string) => {
setSourceConnId(connId);
setSourceDb('');
@@ -376,10 +416,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setLoading(true);
try {
const conn = connections.find(c => c.id === sourceConnId);
const connId = isSourceQueryMode ? targetConnId : sourceConnId;
const dbName = isSourceQueryMode ? targetDb : sourceDb;
const conn = connections.find(c => c.id === connId);
if (conn) {
const config = normalizeConnConfig(conn, sourceDb);
const res = await DBGetTables(config as any, sourceDb);
const config = normalizeConnConfig(conn, dbName);
const res = await DBGetTables(config as any, dbName);
if (res.success) {
// DBGetTables returns [{Table: "name"}, ...]
const tableRows = Array.isArray(res.data) ? res.data : [];
@@ -387,6 +429,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
setAllTables(tables as string[]);
setSelectedTables(prev => {
const existing = prev.filter((name) => tables.includes(name));
if (isSourceQueryMode) {
return existing.slice(0, 1);
}
return existing;
});
setCurrentStep(1);
} else {
message.error(res.message);
@@ -404,7 +453,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
};
const analyzeDiff = async () => {
if (selectedTables.length === 0) return;
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
if (selectionError) return message.error(selectionError);
if (!sourceConnId || !targetConnId) return message.error("Select connections first");
if (!sourceDb || !targetDb) return message.error("Select databases first");
@@ -421,18 +471,20 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
autoScrollRef.current = true;
setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: '', stage: '差异分析' });
const config = {
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: syncContent,
mode: "insert_update",
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
mongoCollectionName,
jobId,
};
});
try {
const res = await DataSyncAnalyze(config as any);
@@ -474,17 +526,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setPreviewLoading(true);
setPreviewData(null);
const config = {
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: "data",
mode: "insert_update",
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
};
mongoCollectionName,
});
try {
const res = await DataSyncPreview(config as any, table, 200);
@@ -501,6 +555,11 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
};
const runSync = async () => {
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
if (selectionError) {
message.error(selectionError);
return;
}
if (syncContent !== 'schema' && diffTables.length === 0) {
message.error("请先对比差异,再开始同步");
return;
@@ -539,19 +598,21 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
stage: '准备开始',
});
const config = {
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: syncContent,
mode: syncMode,
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode,
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
mongoCollectionName,
tableOptions,
jobId,
};
});
try {
const res = await DataSync(config as any);
@@ -595,6 +656,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
return buildSqlPreview(previewData, previewTable, targetType, ops);
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
const previewHasSchemaStatements = useMemo(
() => Array.isArray(previewData?.schemaStatements) && previewData.schemaStatements.length > 0,
[previewData],
);
const previewSchemaWarnings = useMemo(
() => Array.isArray(previewData?.schemaWarnings) ? previewData.schemaWarnings as string[] : [],
[previewData],
);
const previewHasDataDiff = useMemo(
() => Number(previewData?.totalInserts || 0) + Number(previewData?.totalUpdates || 0) + Number(previewData?.totalDeletes || 0) > 0,
[previewData],
);
const analysisWarnings = useMemo(() => {
const items: string[] = [];
@@ -605,6 +678,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
return Array.from(new Set(items));
}, [diffTables]);
const isSourceQueryMode = sourceDatasetMode === 'query';
const isMigrationWorkflow = workflowType === 'migration';
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
@@ -630,8 +704,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.36)' : '0 18px 44px rgba(15,23,42,0.14)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
backdropFilter: resolveTextInputSafeBackdropFilter(darkMode ? 'blur(18px)' : 'none', disableLocalBackdropFilter),
}), [darkMode, disableLocalBackdropFilter]);
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
borderRadius: 18,
@@ -837,7 +911,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Form.Item label="功能类型">
<Select value={workflowType} onChange={setWorkflowType}>
<Option value="sync"></Option>
<Option value="migration"></Option>
<Option value="migration" disabled={isSourceQueryMode}></Option>
</Select>
</Form.Item>
<Form.Item label="源数据方式">
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
<Option value="table"></Option>
<Option value="query"> SQL </Option>
</Select>
</Form.Item>
<Alert
@@ -848,11 +928,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
/>
{isSourceQueryMode && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="SQL 结果集同步当前只支持:源端自定义 SQL -> 单个已存在目标表;查询结果需包含目标表主键列。"
/>
)}
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
<Select value={syncContent} onChange={setSyncContent}>
<Option value="data"></Option>
<Option value="schema"></Option>
<Option value="both"> + </Option>
<Option value="schema" disabled={isSourceQueryMode}></Option>
<Option value="both" disabled={isSourceQueryMode}> + </Option>
</Select>
</Form.Item>
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
@@ -863,7 +951,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</Select>
</Form.Item>
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
<Option value="existing_only">使</Option>
<Option value="auto_create_if_missing"></Option>
<Option value="smart"></Option>
@@ -886,12 +974,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</Form.Item>
)}
<Form.Item>
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
MySQL MySQL Kingbase
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
MySQL MySQL KingbaseSQL
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
/
</Checkbox>
</Form.Item>
@@ -927,21 +1015,56 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{currentStep === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={quietPanelStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text type="secondary"></Text>
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
</Checkbox>
</div>
<Transfer
dataSource={allTables.map(t => ({ key: t, title: t }))}
titles={['源表', '已选表']}
targetKeys={selectedTables}
onChange={(keys) => setSelectedTables(keys as string[])}
render={item => item.title}
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
/>
{!isSourceQueryMode && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text type="secondary"></Text>
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
</Checkbox>
</div>
<Transfer
dataSource={allTables.map(t => ({ key: t, title: t }))}
titles={['源表', '已选表']}
targetKeys={selectedTables}
onChange={(keys) => setSelectedTables(keys as string[])}
render={item => item.title}
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
/>
</>
)}
{isSourceQueryMode && (
<Form layout="vertical">
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="请输入源查询 SQL并选择一个目标表。差异分析会直接基于该结果集与目标表对比。"
/>
<Form.Item label="源查询 SQL">
<TextArea
value={sourceQuery}
onChange={(e) => setSourceQuery(e.target.value)}
rows={8}
placeholder="例如SELECT id, name, email FROM users WHERE status = 'active'"
spellCheck={false}
/>
</Form.Item>
<Form.Item label="目标表">
<Select
value={selectedTables[0]}
onChange={(value) => setSelectedTables(value ? [value] : [])}
showSearch
allowClear
placeholder="请选择一个目标表"
optionFilterProp="children"
>
{allTables.map((table) => <Option key={table} value={table}>{table}</Option>)}
</Select>
</Form.Item>
</Form>
)}
</div>
{diffTables.length > 0 && (
@@ -1060,8 +1183,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
render: (_: any, r: any) => {
const can = !!r.canSync;
const hasDiff = Number(r.inserts || 0) + Number(r.updates || 0) + Number(r.deletes || 0) > 0;
const hasSchemaDiff = Number(r.schemaDiffCount || 0) > 0;
return (
<Button size="small" disabled={!can || !hasDiff || analyzing} onClick={() => openPreview(r.table)}>
<Button size="small" disabled={!can || !(hasDiff || hasSchemaDiff) || analyzing} onClick={() => openPreview(r.table)}>
</Button>
);
@@ -1133,14 +1257,14 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{currentStep === 1 && (
<>
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}></Button>
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing} style={{ marginRight: 8 }}>
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
</Button>
<Button
type="primary"
onClick={runSync}
loading={loading}
disabled={selectedTables.length === 0 || (syncContent !== 'schema' && diffTables.length === 0)}
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
>
</Button>
@@ -1168,12 +1292,59 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Alert
type="info"
showIcon
message={`插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`}
message={
previewHasDataDiff
? `插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`
: (previewData.schemaSummary || `检测到 ${previewSql.statementCount} 条结构变更语句`)
}
/>
{previewSchemaWarnings.length > 0 && (
<Alert
style={{ marginTop: 12 }}
type="warning"
showIcon
message="结构预览包含风险或降级项"
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
{previewSchemaWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
{previewSchemaWarnings.length > 8 && <li> {previewSchemaWarnings.length - 8} </li>}
</ul>
}
/>
)}
<Divider />
<Tabs
items={[
{
...(previewHasSchemaStatements ? [{
key: 'schema',
label: `结构(${Array.isArray(previewData.schemaStatements) ? previewData.schemaStatements.length : 0})`,
children: (
<div>
<Text type="secondary">
{previewData.schemaSummary || '以下为本次结构同步计划执行的语句。'}
</Text>
<pre
style={{
marginTop: 8,
marginBottom: 0,
padding: 10,
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{Array.isArray(previewData.schemaStatements) && previewData.schemaStatements.length > 0
? previewData.schemaStatements.join('\n')
: '-- 当前表结构无可执行变更'}
</pre>
</div>
)
}] : []),
...(previewHasDataDiff ? [{
key: 'insert',
label: `插入(${previewData.totalInserts || 0})`,
children: (
@@ -1273,7 +1444,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
/>
</div>
)
},
}] : []),
{
key: 'sql',
label: `SQL(${previewSql.statementCount})`,
@@ -1282,10 +1453,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Alert
type="info"
showIcon
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
message={
previewHasDataDiff
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
}
/>
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary"> {previewSql.statementCount} 200 /</Text>
<Text type="secondary">
{previewHasDataDiff
? `${previewSql.statementCount} 条语句(预览数据最多 200 条/类型)`
: `${previewSql.statementCount} 条结构变更语句`}
</Text>
<Button
size="small"
disabled={!previewSql.sqlText}
@@ -1314,7 +1493,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
wordBreak: 'break-word'
}}
>
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
{previewSql.sqlText || (previewHasDataDiff ? '-- 当前勾选范围下无 SQL 可预览' : '-- 当前表结构无可执行变更')}
</pre>
</div>
)

View File

@@ -7,7 +7,7 @@ import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
@@ -396,7 +396,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const dbType = config.type || '';
const dbType = resolveDataSourceType(config);
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
@@ -855,7 +855,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const exportSqlWithFilter = useMemo(() => {
const tableName = String(tab.tableName || '').trim();
const dbType = String(currentConnConfig?.type || '').trim();
const dbType = resolveDataSourceType(currentConnConfig);
if (!tableName || !dbType) return '';
const whereSQL = buildWhereSQL(dbType, filterConditions);
@@ -869,7 +869,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, sortInfo, pkColumns]);
useEffect(() => {
const action = resolveDataViewerAutoFetchAction({

View File

@@ -10,6 +10,23 @@ interface DefinitionViewerProps {
tab: TabData;
}
const normalizeMySQLViewDDL = (rawDefinition: unknown): string => {
const text = String(rawDefinition || '').trim();
if (!text) return '';
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
if (createViewPrefixPattern.test(normalized)) {
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
}
if (/^\s*(select|with)\b/i.test(normalized)) {
return normalized;
}
return `${normalized};`;
};
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -257,15 +274,15 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
case 'mysql': {
const keys = Object.keys(row);
const textDefinition = row.view_definition || row.VIEW_DEFINITION;
if (textDefinition) return String(textDefinition);
if (textDefinition) return normalizeMySQLViewDDL(textDefinition);
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
if (sqlKey) return row[sqlKey];
if (sqlKey) return normalizeMySQLViewDDL(row[sqlKey]);
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
if (tableSqlKey) return row[tableSqlKey];
if (tableSqlKey) return normalizeMySQLViewDDL(row[tableSqlKey]);
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('VIEW') || val.toUpperCase().includes('TABLE'))) {
return val;
return normalizeMySQLViewDDL(val);
}
}
return JSON.stringify(row, null, 2);

View File

@@ -4,6 +4,11 @@ import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutline
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import {
DRIVER_LOCAL_IMPORT_BUTTON_LABEL,
DRIVER_LOCAL_IMPORT_DIRECTORY_HELP,
DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP,
} from '../utils/driverImportGuidance';
import {
CheckDriverNetworkStatus,
DownloadDriverPackage,
@@ -1171,7 +1176,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
loading={loadingLocal}
onClick={() => installDriverFromLocalFile(row)}
>
{DRIVER_LOCAL_IMPORT_BUTTON_LABEL}
</Button>
<Button
type={hasLogs ? 'default' : 'text'}
@@ -1373,8 +1378,8 @@ 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>
<Text type="secondary">{DRIVER_LOCAL_IMPORT_DIRECTORY_HELP}</Text>
<Text type="secondary">{DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP}</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>

View File

@@ -6,6 +6,7 @@ import { quoteIdentPart, escapeLiteral } from '../utils/sql';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isMacLikePlatform } from '../utils/appearance';
interface FindInDatabaseModalProps {
open: boolean;
@@ -67,14 +68,15 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const disableLocalBackdropFilter = isMacLikePlatform();
const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
const wt = useMemo(() => {
const isDark = theme === 'dark';
return buildOverlayWorkbenchTheme(isDark);
}, [theme]);
return buildOverlayWorkbenchTheme(isDark, { disableBackdropFilter: disableLocalBackdropFilter });
}, [disableLocalBackdropFilter, theme]);
const buildConfig = useCallback(() => {
if (!conn) return null;

View File

@@ -11,6 +11,7 @@ import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
const SQL_KEYWORDS = [
@@ -249,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();
@@ -324,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;
@@ -367,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;
@@ -424,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) => {

View File

@@ -6,7 +6,14 @@ import { useStore } from '../store';
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 {
blurToFilter,
isMacLikePlatform,
normalizeBlurForPlatform,
normalizeOpacityForPlatform,
resolveAppearanceValues,
resolveTextInputSafeBackdropFilter,
} from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
applyRenamedRedisKeyState,
@@ -19,6 +26,9 @@ import {
type RedisTreeDataNode,
} from './redisViewerTree';
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from '../utils/redisSearchPattern';
import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay';
const { Search } = Input;
@@ -39,148 +49,6 @@ interface RedisViewerProps {
redisDB: number;
}
// 尝试多种方式解码二进制数据
const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => {
if (!value || value.length === 0) {
return { displayValue: '', encoding: 'UTF-8', needsHex: false };
}
// 统计字节分布
let nullCount = 0;
let printableCount = 0;
let highByteCount = 0;
const sampleSize = Math.min(value.length, 200);
for (let i = 0; i < sampleSize; i++) {
const code = value.charCodeAt(i);
if (code === 0) {
nullCount++;
} else if (code >= 32 && code < 127) {
printableCount++;
} else if (code >= 128) {
highByteCount++;
}
}
// 如果超过30%是null字节很可能是二进制数据显示十六进制
if (nullCount / sampleSize > 0.3) {
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
}
// 如果超过70%是可打印ASCII字符直接显示
if (printableCount / sampleSize > 0.7) {
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
}
// 尝试UTF-8解码
if (highByteCount > 0) {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
// 检查解码质量
let validChars = 0;
let replacementChars = 0;
let controlChars = 0;
for (let i = 0; i < Math.min(decoded.length, 200); i++) {
const code = decoded.charCodeAt(i);
if (code === 0xFFFD) {
replacementChars++;
} else if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
controlChars++;
} else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) {
// ASCII可打印字符、中文字符、中文标点
validChars++;
}
}
const totalChecked = Math.min(decoded.length, 200);
// 如果替换字符超过10%或控制字符超过20%说明不是有效的UTF-8文本
if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) {
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
}
// 如果有效字符超过50%使用UTF-8解码
if (validChars / totalChecked > 0.5) {
return { displayValue: decoded, encoding: 'UTF-8', needsHex: false };
}
} catch (e) {
// UTF-8解码失败
}
}
// 默认显示十六进制
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
};
// 检测是否为二进制数据(包含大量不可打印字符)
const isBinaryData = (value: string): boolean => {
if (!value || value.length === 0) return false;
// 检查前 100 个字符中不可打印字符的比例
const sampleSize = Math.min(value.length, 100);
let nonPrintableCount = 0;
for (let i = 0; i < sampleSize; i++) {
const code = value.charCodeAt(i);
// 不可打印字符控制字符0-31除了 9, 10, 13和 DEL127
if ((code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127 || code > 255) {
nonPrintableCount++;
}
}
// 如果超过 10% 是不可打印字符,认为是二进制数据
return nonPrintableCount / sampleSize > 0.1;
};
// 将字符串转换为十六进制显示
const toHexDisplay = (value: string): string => {
const bytes: string[] = [];
const ascii: string[] = [];
let result = '';
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
bytes.push(code.toString(16).padStart(2, '0').toUpperCase());
// 可打印 ASCII 字符显示原字符,否则显示点
ascii.push(code >= 32 && code < 127 ? value[i] : '.');
if (bytes.length === 16 || i === value.length - 1) {
const offset = (Math.floor(i / 16) * 16).toString(16).padStart(8, '0').toUpperCase();
const hexPart = bytes.join(' ').padEnd(47, ' ');
const asciiPart = ascii.join('');
result += `${offset} ${hexPart} |${asciiPart}|\n`;
bytes.length = 0;
ascii.length = 0;
}
}
return result;
};
// 尝试解析并格式化 JSON
const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => {
try {
const parsed = JSON.parse(value);
return { isJson: true, formatted: JSON.stringify(parsed, null, 2) };
} catch {
return { isJson: false, formatted: value };
}
};
// 格式化字符串值 - 支持 JSON、二进制数据检测和智能解码
const formatStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding?: string } => {
// 先检测是否为二进制数据
if (isBinaryData(value)) {
const { displayValue, encoding, needsHex } = tryDecodeValue(value);
return { displayValue, isBinary: needsHex, isJson: false, encoding };
}
// 尝试 JSON 格式化
const { isJson, formatted } = tryFormatJson(value);
return { displayValue: formatted, isBinary: false, isJson, encoding: 'UTF-8' };
};
// 可拖拽分隔条组件 - 使用直接 DOM 操作避免卡顿
const ResizableDivider: React.FC<{
onResizeEnd: (newWidth: number) => void;
@@ -283,8 +151,16 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const blur = normalizeBlurForPlatform(resolvedAppearance.blur);
const disableLocalBackdropFilter = isMacLikePlatform();
const connection = connections.find(c => c.id === connectionId);
const workbenchTheme = useMemo(() => buildRedisWorkbenchTheme({ darkMode, opacity, blur }), [blur, darkMode, opacity]);
const workbenchTheme = useMemo(
() => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }),
[blur, darkMode, disableLocalBackdropFilter, opacity],
);
const workbenchBackdropFilter = useMemo(
() => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter),
[blur, disableLocalBackdropFilter],
);
const keyAccentColor = workbenchTheme.accent;
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
const valueToolbarBg = workbenchTheme.panelBgStrong;
@@ -293,6 +169,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
const [loading, setLoading] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [searchPattern, setSearchPattern] = useState('*');
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
@@ -467,13 +344,29 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
useEffect(() => {
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
}, [redisDB]);
}, [loadKeys, redisDB]);
const executeSearch = useCallback((value: string) => {
const normalized = normalizeRedisSearchInput(value);
setSearchInput(normalized.keyword);
setSearchPattern(normalized.pattern);
setCursor('0');
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
}, [loadKeys]);
const handleSearch = (value: string) => {
const pattern = value.trim() || '*';
setSearchPattern(pattern);
executeSearch(value);
};
const handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const normalized = normalizeRedisSearchDraftChange(event.target.value);
setSearchInput(normalized.keyword);
if (!normalized.shouldSearchImmediately) {
return;
}
setSearchPattern(normalized.pattern);
setCursor('0');
loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false));
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
};
const handleLoadMore = () => {
@@ -1040,6 +933,22 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderValueEditor = () => {
const processValueForCurrentView = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
}
if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
}
if (viewMode === 'utf8') {
return { displayValue: decodeRedisUtf8Value(value), isBinary: false, isJson: false, encoding: 'UTF-8' };
}
return formatRedisStringValue(value);
};
if (!keyValue || !selectedKey) {
return (
<div
@@ -1061,33 +970,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const renderStringValue = () => {
const strValue = String(keyValue.value);
// 根据查看模式生成显示内容
const getDisplayContent = () => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(strValue), isBinary: true, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: strValue, isBinary: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(strValue.length);
for (let i = 0; i < strValue.length; i++) {
bytes[i] = strValue.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: strValue, isBinary: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
const { displayValue, isBinary, isJson, encoding } = formatStringValue(strValue);
return { displayValue, isBinary, encoding };
}
};
const { displayValue, isBinary, encoding } = getDisplayContent();
const isJson = viewMode === 'auto' && formatStringValue(strValue).isJson;
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(strValue);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -1146,31 +1029,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderHashValue = () => {
// 根据查看模式处理值
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
return formatStringValue(value);
}
};
const data = Object.entries(keyValue.value as Record<string, string>).map(([field, value]) => {
const { displayValue, isBinary, isJson, encoding } = processValue(value);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
return { field, value, displayValue, isBinary, isJson, encoding };
});
@@ -1194,7 +1054,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(buildRpcConnectionConfig(config), selectedKey, field);
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]);
if (res.success) {
message.success('删除成功');
loadKeyValue(selectedKey);
@@ -1214,9 +1074,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
title: '添加字段',
content: (
<Form id="add-hash-field-form" layout="vertical">
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
<Input id="new-hash-field" />
</Form.Item>
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
<Input id="new-hash-field" {...noAutoCapInputProps} />
</Form.Item>
<Form.Item label="值" name="value" rules={[{ required: true }]}>
<Input.TextArea id="new-hash-value" rows={4} />
</Form.Item>
@@ -1307,31 +1167,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderListValue = () => {
// 根据查看模式处理值
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
return formatStringValue(value);
}
};
const data = (keyValue.value as string[]).map((value, index) => {
const { displayValue, isBinary, isJson, encoding } = processValue(value);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
return { index, value, displayValue, isBinary, isJson, encoding };
});
@@ -1477,31 +1314,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderSetValue = () => {
// 根据查看模式处理值
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
return formatStringValue(value);
}
};
const data = (keyValue.value as string[]).map((member, index) => {
const { displayValue, isBinary, isJson, encoding } = processValue(member);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(member);
return { index, member, displayValue, isBinary, isJson, encoding };
});
@@ -1614,31 +1428,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderZSetValue = () => {
// 根据查看模式处理值
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
// auto mode
return formatStringValue(value);
}
};
const data = (keyValue.value as Array<{ member: string; score: number }>).map((item, index) => {
const { displayValue, isBinary, isJson, encoding } = processValue(item.member);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(item.member);
return { ...item, index, displayMember: displayValue, isBinary, isJson, encoding };
});
@@ -1779,30 +1570,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const renderStreamValue = () => {
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
return formatStringValue(value);
}
};
const data = (keyValue.value as StreamEntry[]).map((item, index) => {
const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2);
const { displayValue, isBinary, isJson, encoding } = processValue(rawFieldsText);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(rawFieldsText);
return {
index,
id: item.id,
@@ -1888,7 +1658,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<div>
<div style={{ marginBottom: 8 }}>
<label>ID *</label>
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
<Input id="new-stream-id" {...noAutoCapInputProps} placeholder="例如: * 或 1723110000000-0" />
</div>
<div>
<label> JSON</label>
@@ -2050,7 +1820,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}
return (
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: blurToFilter(blur), WebkitBackdropFilter: blurToFilter(blur) }}>
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
{/* Left: Key List */}
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
<div style={{ ...workbenchCardStyle, padding: 12 }}>
@@ -2063,9 +1833,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</div>
<Space.Compact style={{ width: '100%' }}>
<Search
placeholder="搜索 Key (支持 * 通配符)"
defaultValue="*"
{...noAutoCapInputProps}
placeholder="搜索 Key"
value={searchInput}
onChange={handleSearchInputChange}
onSearch={handleSearch}
allowClear
enterButton={<SearchOutlined />}
/>
</Space.Compact>
@@ -2152,7 +1925,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
>
<Editor
height="450px"
language={tryFormatJson(editValue).isJson ? 'json' : 'plaintext'}
language={formatRedisStringValue(editValue).isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={editValue}
onChange={(value) => setEditValue(value || '')}
@@ -2177,7 +1950,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
>
<Form form={newKeyForm} layout="vertical" initialValues={{ ttl: -1 }}>
<Form.Item name="key" label="Key" rules={[{ required: true, message: '请输入 Key' }]}>
<Input placeholder="key name" />
<Input {...noAutoCapInputProps} placeholder="key name" />
</Form.Item>
<Form.Item name="value" label="值" rules={[{ required: true, message: '请输入值' }]}>
<Input.TextArea rows={4} placeholder="value" />
@@ -2207,7 +1980,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
rules={[{ required: true, message: '请输入新的 Key 名称' }]}
extra={renameTargetKey ? `原始 Key${renameTargetKey}` : undefined}
>
<Input placeholder="new:key:name" />
<Input {...noAutoCapInputProps} placeholder="new:key:name" />
</Form.Item>
</Form>
</Modal>

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

@@ -36,14 +36,20 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
} from '@ant-design/icons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection } from '../types';
import { SavedConnection, ExternalSQLTreeEntry } 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 { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile } 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 { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import FindInDatabaseModal from './FindInDatabaseModal';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
const { Search } = Input;
@@ -54,7 +60,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag';
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag';
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
@@ -93,10 +99,30 @@ const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
tag: <TagOutlined />,
};
const normalizeMySQLViewDDLForEditing = (viewName: string, rawDefinition: unknown): string => {
const text = String(rawDefinition || '').trim();
if (!text) return '';
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
if (createViewPrefixPattern.test(normalized)) {
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
}
if (/^\s*(select|with)\b/i.test(normalized)) {
return `CREATE OR REPLACE VIEW ${viewName} AS\n${normalized};`;
}
return `${normalized};`;
};
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
const connections = useStore(state => state.connections);
const savedQueries = useStore(state => state.savedQueries);
const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
const deleteQuery = useStore(state => state.deleteQuery);
const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory);
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
const addConnection = useStore(state => state.addConnection);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
@@ -119,6 +145,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const disableLocalBackdropFilter = isMacLikePlatform();
const autoFetchVisible = useAutoFetchVisibility();
const [treeData, setTreeData] = useState<TreeNode[]>([]);
// Background Helper (Duplicate logic for now, ideally shared)
@@ -131,7 +159,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#141414');
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const overlayTheme = useMemo(
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
[darkMode, disableLocalBackdropFilter],
);
const modalPanelStyle = useMemo(() => ({
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
@@ -293,25 +324,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
useEffect(() => {
// Refresh queries for expanded databases
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
for (const node of nodes) {
if (node.key === k) return node;
if (node.children) {
const res = findNode(node.children, k);
if (res) return res;
}
}
return null;
};
if (!autoFetchVisible) {
return;
}
expandedKeys.forEach(key => {
const node = findNode(treeData, key);
const node = findTreeNodeByKey(treeData, key);
if (node && node.type === 'database') {
loadTables(node);
}
});
}, [savedQueries]);
}, [autoFetchVisible, externalSQLDirectories, savedQueries]);
useEffect(() => {
setTreeData((prev) => {
@@ -400,6 +423,68 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
const findTreeNodeByKey = (nodes: TreeNode[], targetKey: React.Key): TreeNode | null => {
for (const node of nodes) {
if (node.key === targetKey) {
return node;
}
if (node.children) {
const child = findTreeNodeByKey(node.children, targetKey);
if (child) {
return child;
}
}
}
return null;
};
const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => {
const icon = (() => {
switch (node.type) {
case 'external-sql-root':
return <FolderOpenOutlined />;
case 'external-sql-directory':
return <HddOutlined />;
case 'external-sql-folder':
return <FolderOutlined />;
default:
return <FileTextOutlined />;
}
})();
return {
...node,
icon,
children: node.children?.map((child) => decorateExternalSQLTreeNode(child)),
};
};
const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => {
if (!node) return null;
if (node.type === 'database') {
return {
connectionId: String(node?.dataRef?.id || '').trim(),
dbName: String(node?.dataRef?.dbName || '').trim(),
dbNodeKey: String(node.key || '').trim(),
};
}
if (
node.type === 'external-sql-root'
|| node.type === 'external-sql-directory'
|| node.type === 'external-sql-folder'
|| node.type === 'external-sql-file'
) {
return {
connectionId: String(node?.dataRef?.connectionId || '').trim(),
dbName: String(node?.dataRef?.dbName || '').trim(),
dbNodeKey: String(node?.dataRef?.dbNodeKey || '').trim(),
};
}
return null;
};
const SIDEBAR_SCHEMA_DB_TYPES = new Set([
'postgres',
'kingbase',
@@ -788,7 +873,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name'])
|| getMySQLShowTablesName(row)
|| getFirstRowValue(row);
const fullName = buildQualifiedName(schemaName, viewName);
const fullName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName);
if (!fullName || seen.has(fullName)) return;
seen.add(fullName);
views.push(fullName);
@@ -966,6 +1051,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
loadingNodesRef.current.add(loadKey);
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
const dbExternalSQLDirectories = useStore.getState().externalSQLDirectories.filter(directory => directory.connectionId === conn.id && directory.dbName === dbName);
const queriesNode: TreeNode = {
title: '已存查询',
@@ -1007,11 +1093,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
});
const [viewsResult, triggersResult, routinesResult] = await Promise.all([
loadViews(conn, conn.dbName),
loadDatabaseTriggers(conn, conn.dbName),
loadFunctions(conn, conn.dbName),
]);
const [viewsResult, triggersResult, routinesResult] = await Promise.all([
loadViews(conn, conn.dbName),
loadDatabaseTriggers(conn, conn.dbName),
loadFunctions(conn, conn.dbName),
]);
const externalSQLDirectoryResults = await Promise.all(
dbExternalSQLDirectories.map(async (directory) => {
const directoryRes = await ListSQLDirectory(directory.path);
if (!directoryRes.success) {
message.warning({
key: `external-sql-${directory.id}`,
content: `SQL 目录读取失败: ${directory.name} (${directoryRes.message})`,
});
return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] };
}
return {
id: directory.id,
entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [],
};
}),
);
const externalSQLTrees = externalSQLDirectoryResults.reduce<Record<string, ExternalSQLTreeEntry[]>>((accumulator, item) => {
accumulator[item.id] = item.entries;
return accumulator;
}, {});
const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({
dbNodeKey: String(key),
connectionId: String(conn.id),
dbName: String(conn.dbName),
directories: dbExternalSQLDirectories,
directoryTrees: externalSQLTrees,
}));
const viewRows: string[] = Array.isArray(viewsResult.views) ? viewsResult.views : [];
const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : [];
@@ -1195,7 +1308,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry)));
triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry)));
const dialect = getMetadataDialect(conn as SavedConnection);
const isOracleLike = (dialect === 'oracle' || dialect === 'dm');
const schemaNodes: TreeNode[] = Array.from(schemaMap.values())
.filter((bucket) => !(isOracleLike && !bucket.schemaName))
.sort((a, b) => {
if (!a.schemaName && !b.schemaName) return 0;
if (!a.schemaName) return -1;
@@ -1203,8 +1320,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase());
})
.map((bucket) => {
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
const schemaTitle = bucket.schemaName || '默认模式';
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
const schemaTitle = bucket.schemaName || '默认模式';
const groupedNodes: TreeNode[] = [
buildObjectGroup(schemaNodeKey, 'tables', '表', <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'views', '视图', <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
@@ -1223,7 +1340,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
});
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...schemaNodes]));
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...schemaNodes]));
} else {
const groupedNodes: TreeNode[] = [
buildObjectGroup(key as string, 'tables', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
@@ -1232,7 +1349,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
];
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes]));
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...groupedNodes]));
}
} else {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
@@ -1348,6 +1465,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
} else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
} else if (type === 'redis-db') {
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
}
@@ -1390,6 +1509,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
if (node.type === 'table') {
@@ -1428,6 +1548,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
savedQueryId: q.id,
});
return;
} else if (node.type === 'external-sql-file') {
void openExternalSQLFile(node);
return;
} else if (node.type === 'redis-db') {
const { id, redisDB } = node.dataRef;
addTab({
@@ -2104,6 +2227,119 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
const refreshDatabaseNode = async (dbNodeKey: string) => {
if (!dbNodeKey) {
return;
}
const dbNode = findTreeNodeByKey(treeData, dbNodeKey);
if (dbNode && dbNode.type === 'database') {
await loadTables(dbNode);
}
};
const openExternalSQLFile = async (fileNode: any) => {
const connectionId = String(fileNode?.dataRef?.connectionId || '').trim();
const dbName = String(fileNode?.dataRef?.dbName || '').trim();
const filePath = String(fileNode?.dataRef?.path || '').trim();
const fileName = String(fileNode?.dataRef?.name || fileNode?.title || 'SQL文件').trim() || 'SQL文件';
if (!connectionId || !dbName || !filePath) {
message.error('SQL 文件上下文不完整,无法打开');
return;
}
const res = await ReadSQLFile(filePath);
if (!res.success) {
if (res.message !== '已取消') {
message.error('读取 SQL 文件失败: ' + res.message);
}
return;
}
const data = res.data;
if (data && typeof data === 'object' && data.isLargeFile) {
const conn = connections.find((item) => item.id === connectionId);
if (!conn) {
message.error('未找到对应的连接配置');
return;
}
startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB);
return;
}
addTab({
id: buildExternalSQLTabId(connectionId, dbName, filePath),
title: fileName,
type: 'query',
connectionId,
dbName,
query: String(data || ''),
});
};
const handleAddExternalSQLDirectory = async (node: any) => {
const context = getNodeDatabaseContext(node);
if (!context?.connectionId || !context?.dbName || !context?.dbNodeKey) {
message.warning('请在具体数据库下添加外部 SQL 目录');
return;
}
const currentDirectory = externalSQLDirectories.find((item) =>
item.connectionId === context.connectionId && item.dbName === context.dbName,
)?.path || '';
const selection = await SelectSQLDirectory(currentDirectory);
if (!selection.success) {
if (selection.message !== '已取消') {
message.error('选择 SQL 目录失败: ' + selection.message);
}
return;
}
const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record<string, unknown> : {};
const path = String(payload.path || '').trim();
const name = String(payload.name || '').trim();
if (!path) {
message.error('未获取到有效的 SQL 目录路径');
return;
}
const directoryId = buildExternalSQLDirectoryId(context.connectionId, context.dbName, path);
saveExternalSQLDirectory({
id: directoryId,
name: name || path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录',
path,
connectionId: context.connectionId,
dbName: context.dbName,
createdAt: Date.now(),
});
setExpandedKeys((prev) => Array.from(new Set([...prev, context.dbNodeKey, `${context.dbNodeKey}-external-sql`])));
setAutoExpandParent(false);
await refreshDatabaseNode(context.dbNodeKey);
message.success('外部 SQL 目录已添加');
};
const handleRemoveExternalSQLDirectory = async (node: any) => {
const directoryId = String(node?.dataRef?.id || '').trim();
const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
if (!directoryId) {
message.error('未找到可移除的 SQL 目录');
return;
}
deleteExternalSQLDirectory(directoryId);
await refreshDatabaseNode(dbNodeKey);
message.success('外部 SQL 目录已移除');
};
const handleRefreshExternalSQLDirectory = async (node: any) => {
const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
if (!dbNodeKey) {
message.warning('当前目录缺少数据库上下文,无法刷新');
return;
}
await refreshDatabaseNode(dbNodeKey);
message.success('外部 SQL 目录已刷新');
};
const handleCreateDatabase = async () => {
try {
const values = await createDbForm.validateFields();
@@ -2134,7 +2370,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {
return buildRpcConnectionConfig(conn.config, {
database: clearDatabase ? '' : ((overrideDatabase ?? conn.config.database) || ''),
database: resolveSidebarRuntimeDatabase(
conn?.config?.type,
conn?.config?.driver,
conn?.config?.database,
overrideDatabase,
clearDatabase,
),
});
};
@@ -2402,7 +2644,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
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) || '';
if (def) {
template = `-- 编辑视图 ${viewName}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
if (dialect === 'mysql') {
template = `-- 编辑视图 ${viewName}\n${normalizeMySQLViewDDLForEditing(viewName, def)}`;
} else {
template = `-- 编辑视图 ${viewName}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
}
}
}
}
@@ -2820,51 +3066,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
);
}, [darkMode, overlayTheme, searchScopes]);
const parseHostOnlyToken = (value: unknown): string[] => {
const raw = String(value || '').trim();
if (!raw) {
return [];
}
let text = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
if (text.includes('/')) {
text = text.split('/')[0];
}
if (text.includes('?')) {
text = text.split('?')[0];
}
if (text.includes('@')) {
text = text.split('@').pop() || '';
}
return text
.split(',')
.map((entry) => {
const token = entry.trim();
if (!token) return '';
if (token.startsWith('[')) {
const rightBracketIndex = token.indexOf(']');
if (rightBracketIndex > 0) {
return token.slice(0, rightBracketIndex + 1).toLowerCase();
}
}
const colonIndex = token.lastIndexOf(':');
if (colonIndex > 0) {
return token.slice(0, colonIndex).toLowerCase();
}
return token.toLowerCase();
})
.filter(Boolean);
};
const getConnectionHostSearchText = (node: TreeNode): string => {
if (node.type !== 'connection') return '';
const config = node.dataRef?.config || {};
const hostTokens = [
...parseHostOnlyToken(config.host),
...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry: string) => parseHostOnlyToken(entry)) : []),
...parseHostOnlyToken(config.uri),
];
const uniqueHosts = Array.from(new Set(hostTokens));
return uniqueHosts.join(' ');
return resolveConnectionHostTokens(config).join(' ');
};
const getConnectionNameSearchText = (node: TreeNode): string => {
@@ -3072,7 +3277,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onClick: () => {
addTab({
id: `redis-cmd-${node.key}-${Date.now()}`,
title: `命令 - ${node.title}`,
title: '命令 - db0',
type: 'redis-command',
connectionId: node.key,
redisDB: 0
@@ -3086,7 +3291,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onClick: () => {
addTab({
id: `redis-monitor-${node.key}-${Date.now()}`,
title: `监控: ${node.title}`,
title: '监控 - db0',
type: 'redis-monitor',
connectionId: node.key,
redisDB: 0
@@ -3348,7 +3553,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onClick: () => {
addTab({
id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`,
title: `监控: ${connections.find(c => c.id === id)?.name || id}`,
title: `监控 - db${redisDB}`,
type: 'redis-monitor',
connectionId: id,
redisDB: redisDB
@@ -3553,7 +3758,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
icon: <ConsoleSqlOutlined />,
onClick: () => {
const tableName = String(node.dataRef?.tableName || '').trim();
const queryTemplate = tableName ? `SELECT * FROM ${tableName};` : 'SELECT * FROM ';
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
addTab({
id: `query-${Date.now()}`,
title: `新建查询`,
@@ -3686,6 +3891,55 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
];
}
if (node.type === 'external-sql-root') {
return [
{
key: 'add-external-sql-directory',
label: '添加 SQL 目录',
icon: <PlusOutlined />,
onClick: () => {
void handleAddExternalSQLDirectory(node);
}
}
];
}
if (node.type === 'external-sql-directory') {
return [
{
key: 'refresh-external-sql-directory',
label: '刷新目录',
icon: <ReloadOutlined />,
onClick: () => {
void handleRefreshExternalSQLDirectory(node);
}
},
{ type: 'divider' },
{
key: 'remove-external-sql-directory',
label: '移除目录',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
void handleRemoveExternalSQLDirectory(node);
}
}
];
}
if (node.type === 'external-sql-file') {
return [
{
key: 'open-external-sql-file',
label: '打开 SQL 文件',
icon: <ConsoleSqlOutlined />,
onClick: () => {
void openExternalSQLFile(node);
}
}
];
}
return [];
};
@@ -3711,6 +3965,33 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hoverTitle = rawTableName;
}
}
} else if (node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') {
hoverTitle = String(node?.dataRef?.path || displayTitle);
}
if (node.type === 'external-sql-root') {
return (
<span
title={hoverTitle}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}
>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{statusBadge}
{displayTitle}
</span>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleAddExternalSQLDirectory(node);
}}
style={{ paddingInline: 4, height: 20 }}
/>
</span>
);
}
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
@@ -3797,6 +4078,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
<Input
{...noAutoCapInputProps}
ref={searchInputRef}
placeholder="搜索..."
onChange={onSearch}
@@ -3988,7 +4270,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
>
<Form form={createDbForm} layout="vertical">
<Form.Item name="name" label="数据库名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input />
<Input {...noAutoCapInputProps} />
</Form.Item>
{/* Charset option could be added here */}
</Form>
@@ -4006,7 +4288,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
>
<Form form={renameDbForm} layout="vertical">
<Form.Item name="newName" label="新数据库名称" rules={[{ required: true, message: '请输入新数据库名称' }]}>
<Input />
<Input {...noAutoCapInputProps} />
</Form.Item>
</Form>
</Modal>
@@ -4023,7 +4305,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
>
<Form form={renameTableForm} layout="vertical">
<Form.Item name="newName" label="新表名" rules={[{ required: true, message: '请输入新表名' }]}>
<Input />
<Input {...noAutoCapInputProps} />
</Form.Item>
</Form>
</Modal>
@@ -4040,7 +4322,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
>
<Form form={renameViewForm} layout="vertical">
<Form.Item name="newName" label="新视图名" rules={[{ required: true, message: '请输入新视图名' }]}>
<Input />
<Input {...noAutoCapInputProps} />
</Form.Item>
</Form>
</Modal>

View File

@@ -17,24 +17,7 @@ import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import type { TabData } from '../types';
const detectConnectionEnvLabel = (connectionName: string): string | null => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
if (!connectionName) return tab.title;
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
};
import { buildTabDisplayTitle } from '../utils/tabDisplay';
type SortableTabLabelProps = {
displayTitle: string;
@@ -50,7 +33,7 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
<span
className="tab-dnd-label"
onContextMenu={(e) => e.preventDefault()}
title="拖拽调整标签顺序"
title={displayTitle}
>
{displayTitle}
</span>
@@ -198,8 +181,8 @@ const TabManager: React.FC = () => {
);
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayTitle = buildTabDisplayTitle(tab, connection);
const tabIsActive = tab.id === activeTabId;
let content;
if (tab.type === 'query') {
@@ -337,6 +320,10 @@ const TabManager: React.FC = () => {
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
background: rgba(9, 109, 217, 0.08);
}
body[data-theme='light'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(24, 144, 255, 0.10) !important;
border-color: rgba(24, 144, 255, 0.28) !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(255, 214, 102, 0.12) !important;
border-color: rgba(255, 214, 102, 0.4) !important;

View File

@@ -9,8 +9,9 @@ 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 { buildAlterTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
interface EditableColumn extends ColumnDefinition {
_key: string;
@@ -546,7 +547,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
key: 'name',
width: 180,
render: (text: string, record: EditableColumn) => readOnly ? text : (
<Input value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
<Input {...noAutoCapInputProps} value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
)
},
{
@@ -1395,6 +1396,19 @@ ${selectedTrigger.statement}`;
};
};
const hasUnsavedDraftChanges = useMemo(() => {
if (isNewTable || readOnly) {
return false;
}
const tableInfo = resolveTableInfo();
return hasAlterTableDraftChanges({
dbType: tableInfo.dbType,
tableName: tableInfo.qualifiedName,
originalColumns,
columns,
});
}, [columns, connections, isNewTable, originalColumns, readOnly, tab.connectionId, tab.dbName, tab.tableName]);
const supportsIndexSchemaOps = (): boolean => {
const dbType = getDbType();
if (!dbType) return false;
@@ -2142,6 +2156,24 @@ END;`;
}
};
const handleRefreshDesigner = () => {
if (!hasUnsavedDraftChanges) {
void fetchData();
return;
}
Modal.confirm({
title: '存在未保存的字段变更',
icon: <ExclamationCircleOutlined />,
content: '刷新后会丢失当前尚未保存的字段调整,是否仍要刷新并覆盖当前草稿?',
okText: '仍然刷新',
cancelText: '取消',
onOk: async () => {
await fetchData();
},
});
};
const handleExecuteSave = async () => {
const result = await executeSchemaStatements(previewSql);
if (!result.ok) {
@@ -2492,6 +2524,7 @@ END;`;
{isNewTable && (
<>
<Input
{...noAutoCapInputProps}
placeholder="请输入表名"
value={newTableName}
onChange={e => setNewTableName(e.target.value)}
@@ -2517,7 +2550,7 @@ END;`;
</>
)}
{!readOnly && <Button size="small" icon={<SaveOutlined />} type="primary" onClick={generateDDL}></Button>}
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={fetchData}></Button>}
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={handleRefreshDesigner}></Button>}
{!isNewTable && !readOnly && supportsTableCommentOps() && (
<Button size="small" icon={<EditOutlined />} onClick={openTableCommentModal}></Button>
)}
@@ -2805,6 +2838,7 @@ END;`;
{selectedColumns.length}
</div>
<Input
{...noAutoCapInputProps}
placeholder="请输入目标表名"
value={copyTableName}
onChange={e => setCopyTableName(e.target.value)}
@@ -2865,6 +2899,7 @@ END;`;
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
{...noAutoCapInputProps}
placeholder={indexForm.kind === 'PRIMARY' ? '主键索引固定名称PRIMARY' : '索引名(例如 idx_user_name'}
value={indexForm.name}
onChange={(e) => setIndexForm(prev => ({ ...prev, name: e.target.value }))}
@@ -2934,6 +2969,7 @@ END;`;
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
{...noAutoCapInputProps}
placeholder="外键约束名(例如 fk_order_user"
value={foreignKeyForm.constraintName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))}
@@ -2949,6 +2985,7 @@ END;`;
style={{ width: '100%' }}
/>
<Input
{...noAutoCapInputProps}
placeholder="参考表(支持 db.table"
value={foreignKeyForm.refTableName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}
@@ -2977,10 +3014,24 @@ END;`;
okText="执行"
cancelText="取消"
>
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
<pre style={{ background: darkMode ? '#1e1e1e' : '#f5f5f5', color: darkMode ? '#d4d4d4' : 'inherit', padding: '10px', borderRadius: '4px', border: darkMode ? '1px solid #333' : '1px solid #eee', whiteSpace: 'pre-wrap' }}>
{previewSql}
</pre>
<div style={{ maxHeight: '400px', overflow: 'hidden', borderRadius: 8, border: darkMode ? '1px solid #333' : '1px solid #eee' }}>
<Editor
height="360px"
defaultLanguage="sql"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={previewSql}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
padding: { top: 8, bottom: 8 },
}}
/>
</div>
<p style={{ marginTop: 10, color: '#faad14' }}> SQL</p>
</Modal>

View File

@@ -4,8 +4,11 @@ import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, D
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 { noAutoCapInputProps } from '../utils/inputAutoCap';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
interface TableOverviewProps {
tab: TabData;
@@ -54,10 +57,23 @@ const getMetadataDialect = (connType: string, driver?: string): string => {
};
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
switch (dialect) {
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
switch (dialect) {
case 'mysql':
return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
return `
SELECT
TABLE_NAME AS table_name,
TABLE_COMMENT AS table_comment,
TABLE_ROWS AS table_rows,
DATA_LENGTH AS data_length,
INDEX_LENGTH AS index_length,
ENGINE AS engine,
CREATE_TIME AS create_time,
UPDATE_TIME AS update_time
FROM information_schema.tables
WHERE table_schema = '${escapeLiteral(dbName)}'
AND table_type = 'BASE TABLE'
ORDER BY table_name`;
case 'postgres':
case 'kingbase':
case 'vastbase':
@@ -152,6 +168,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const [viewMode, setViewMode] = useState<ViewMode>('list');
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const metadataDialect = useMemo(
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver),
[connection?.config?.driver, connection?.config?.type]
);
const autoFetchVisible = useAutoFetchVisibility();
const loadData = useCallback(async () => {
if (!connection) return;
@@ -165,11 +186,10 @@ 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.driver);
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', (tab as any).schemaName);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(dialect, res.data));
setTables(parseTableStats(metadataDialect, res.data));
} else {
message.error('获取表信息失败: ' + (res.message || '未知错误'));
}
@@ -178,9 +198,14 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
} finally {
setLoading(false);
}
}, [connection, tab.dbName]);
}, [connection, metadataDialect, tab.dbName]);
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => {
if (!autoFetchVisible) {
return;
}
void loadData();
}, [autoFetchVisible, loadData]);
const sortedFiltered = useMemo(() => {
let list = [...tables];
@@ -324,6 +349,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
title: '重命名表',
content: (
<Input
{...noAutoCapInputProps}
defaultValue={tableName}
onChange={e => { newName = e.target.value; }}
placeholder="输入新表名"
@@ -397,6 +423,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
</span>
<div style={{ flex: 1 }} />
<Input
{...noAutoCapInputProps}
placeholder="搜索表名或注释..."
prefix={<SearchOutlined style={{ color: textMuted }} />}
value={searchText}
@@ -464,7 +491,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },
@@ -550,7 +577,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },

View File

@@ -8,6 +8,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { AIChatMessage, AIToolCall } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
@@ -27,6 +28,7 @@ const MemoizedMarkdown = React.memo(({
activeConnectionId?: string;
activeDbName?: string;
}) => {
const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]);
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
const components = React.useMemo(() => ({
code({ node, inline, className, children, ...props }: any) {
@@ -46,7 +48,7 @@ const MemoizedMarkdown = React.memo(({
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{content}
{normalizedContent}
</ReactMarkdown>
);
});

View File

@@ -0,0 +1,10 @@
import dayjs from 'dayjs';
import { describe, expect, it } from 'vitest';
import { resolveTemporalEditorSaveValue } from './dataGridTemporal';
describe('dataGridTemporal helpers', () => {
it('prefers the picker selected date when form store has not caught up yet', () => {
expect(resolveTemporalEditorSaveValue(undefined, dayjs('2026-04-12'), 'date')).toBe('2026-04-12');
});
});

View File

@@ -0,0 +1,59 @@
import dayjs from 'dayjs';
export type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
export const TEMPORAL_FORMATS: Record<string, string> = {
datetime: 'YYYY-MM-DD HH:mm:ss',
date: 'YYYY-MM-DD',
time: 'HH:mm:ss',
year: 'YYYY',
};
export const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'year';
};
export const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return null;
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
const base = raw.split(/[ (]/)[0];
if (base === 'date') return 'date';
if (base === 'time') return 'time';
if (base === 'year') return 'year';
return null;
};
export const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
if (val === null || val === undefined || val === '') return null;
const str = String(val).trim();
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null;
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
const d = dayjs(str, fmt);
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
};
export const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
if (!val || !val.isValid()) return '';
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
return val.format(fmt);
};
export const resolveTemporalEditorSaveValue = (
formValue: any,
pickerValue: dayjs.Dayjs | null | undefined,
pickerType: TemporalPickerType,
): string | null | any => {
const value = pickerValue !== undefined ? pickerValue : formValue;
if (value && dayjs.isDayjs(value)) {
return formatFromDayjs(value as dayjs.Dayjs, pickerType);
}
if (!value) {
return null;
}
return value;
};

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { buildDataSyncRequest, validateDataSyncSelection } from './dataSyncRequest';
describe('validateDataSyncSelection', () => {
it('requires source query and single target table in query mode', () => {
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: [],
sourceQuery: '',
syncContent: 'data',
})).toBe('请输入源查询 SQL');
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: [],
sourceQuery: 'select 1',
syncContent: 'data',
})).toBe('SQL 结果集同步需要选择一个目标表');
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: ['users', 'orders'],
sourceQuery: 'select 1',
syncContent: 'data',
})).toBe('SQL 结果集同步需要选择一个目标表');
});
it('forces data-only in query mode', () => {
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: ['users'],
sourceQuery: 'select 1',
syncContent: 'both',
})).toBe('SQL 结果集同步仅支持仅同步数据');
});
});
describe('buildDataSyncRequest', () => {
it('normalizes query mode payload for backend', () => {
const payload = buildDataSyncRequest({
sourceConfig: { type: 'mysql' },
targetConfig: { type: 'mysql' },
selectedTables: ['users'],
sourceDatasetMode: 'query',
sourceQuery: ' SELECT id, name FROM active_users ',
syncContent: 'both',
syncMode: 'insert_update',
autoAddColumns: true,
targetTableStrategy: 'smart',
createIndexes: true,
mongoCollectionName: ' ',
jobId: 'job-1',
tableOptions: { users: { insert: true, update: true, delete: false } },
});
expect(payload).toMatchObject({
tables: ['users'],
sourceQuery: 'SELECT id, name FROM active_users',
content: 'data',
mode: 'insert_update',
autoAddColumns: false,
targetTableStrategy: 'existing_only',
createIndexes: false,
jobId: 'job-1',
});
});
});

View File

@@ -0,0 +1,85 @@
export type SourceDatasetMode = 'table' | 'query';
type SyncContent = 'data' | 'schema' | 'both';
type TargetTableStrategy = 'existing_only' | 'auto_create_if_missing' | 'smart';
type BuildDataSyncRequestParams = {
sourceConfig: any;
targetConfig: any;
selectedTables: string[];
sourceDatasetMode: SourceDatasetMode;
sourceQuery: string;
syncContent: SyncContent;
syncMode: string;
autoAddColumns: boolean;
targetTableStrategy: TargetTableStrategy;
createIndexes: boolean;
mongoCollectionName: string;
jobId?: string;
tableOptions?: Record<string, any>;
};
type ValidateDataSyncSelectionParams = {
sourceDatasetMode: SourceDatasetMode;
selectedTables: string[];
sourceQuery: string;
syncContent: SyncContent;
};
export const validateDataSyncSelection = ({
sourceDatasetMode,
selectedTables,
sourceQuery,
syncContent,
}: ValidateDataSyncSelectionParams): string | null => {
if (sourceDatasetMode === 'query') {
if (!String(sourceQuery || '').trim()) {
return '请输入源查询 SQL';
}
if (selectedTables.length !== 1) {
return 'SQL 结果集同步需要选择一个目标表';
}
if (syncContent !== 'data') {
return 'SQL 结果集同步仅支持仅同步数据';
}
return null;
}
if (selectedTables.length === 0) {
return '请选择至少一张表';
}
return null;
};
export const buildDataSyncRequest = ({
sourceConfig,
targetConfig,
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode,
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName,
jobId,
tableOptions,
}: BuildDataSyncRequestParams) => {
const isQueryMode = sourceDatasetMode === 'query';
return {
sourceConfig,
targetConfig,
tables: selectedTables,
sourceQuery: isQueryMode ? String(sourceQuery || '').trim() : undefined,
content: isQueryMode ? 'data' : syncContent,
mode: syncMode,
autoAddColumns: isQueryMode ? false : autoAddColumns,
targetTableStrategy: isQueryMode ? 'existing_only' : targetTableStrategy,
createIndexes: isQueryMode ? false : createIndexes,
mongoCollectionName: String(mongoCollectionName || '').trim(),
...(jobId ? { jobId } : {}),
...(tableOptions ? { tableOptions } : {}),
};
};

View File

@@ -25,4 +25,9 @@ describe('buildRedisWorkbenchTheme', () => {
expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
expect(lightTheme.backdropFilter).toBe('none');
});
it('can disable redis workbench blur for macOS text-entry compatibility', () => {
const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14, disableBackdropFilter: true });
expect(darkTheme.backdropFilter).toBe('none');
});
});

View File

@@ -1,7 +1,10 @@
import { resolveTextInputSafeBackdropFilter } from '../utils/appearance';
type RedisWorkbenchThemeInput = {
darkMode: boolean;
opacity: number;
blur: number;
disableBackdropFilter?: boolean;
};
type RedisWorkbenchTheme = {
@@ -43,10 +46,15 @@ export const buildRedisWorkbenchTheme = ({
darkMode,
opacity,
blur,
disableBackdropFilter,
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
const normalizedOpacity = clamp(opacity, 0.1, 1);
const normalizedBlur = Math.max(0, Math.round(blur));
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
const backdropFilter = resolveTextInputSafeBackdropFilter(
normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
disableBackdropFilter ?? false,
);
if (darkMode) {
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
@@ -84,7 +92,7 @@ export const buildRedisWorkbenchTheme = ({
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
divider: 'rgba(255, 255, 255, 0.07)',
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
backdropFilter,
};
}
@@ -122,7 +130,7 @@ export const buildRedisWorkbenchTheme = ({
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
divider: 'rgba(15, 23, 42, 0.08)',
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
backdropFilter,
};
};

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
buildAlterTablePreviewSql,
hasAlterTableDraftChanges,
type BuildAlterTablePreviewInput,
type EditableColumnSnapshot,
} from './tableDesignerSchemaSql';
@@ -29,6 +30,18 @@ const buildInput = (overrides: Partial<BuildAlterTablePreviewInput>): BuildAlter
});
describe('tableDesignerSchemaSql', () => {
it('detects when alter table drafts contain unsaved column changes', () => {
expect(hasAlterTableDraftChanges(buildInput({ dbType: 'mysql' }))).toBe(true);
expect(
hasAlterTableDraftChanges(
buildInput({
dbType: 'mysql',
columns: [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })],
}),
),
).toBe(false);
});
it('keeps mysql alter preview syntax with column position clauses', () => {
const sql = buildAlterTablePreviewSql(buildInput({ dbType: 'mysql' }));
@@ -51,4 +64,16 @@ describe('tableDesignerSchemaSql', () => {
expect(sql).not.toContain('AFTER');
expect(sql).not.toContain(' FIRST');
});
it('uses mysql change column syntax when renaming a column', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'mysql',
originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'varchar(64)', nullable: 'YES' })],
columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'varchar(64)', nullable: 'YES' })],
}));
expect(sql).toContain('CHANGE COLUMN `name` `display_name` varchar(64) NULL');
expect(sql).toContain('FIRST');
expect(sql).not.toContain('MODIFY COLUMN `display_name`');
});
});

View File

@@ -140,14 +140,21 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string =
return;
}
if (
curr.name !== orig.name ||
const definitionChanged =
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
curr.default !== orig.default ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
) {
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement);
if (curr.name !== orig.name) {
alters.push(
`CHANGE COLUMN ${quoteIdentifierPart(orig.name, 'mysql')} ${colDef} ${positionSql}`.trim(),
);
return;
}
if (definitionChanged) {
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
}
});
@@ -253,3 +260,6 @@ export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): s
}
return buildMySqlAlterPreviewSql({ ...input, dbType });
};
export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean =>
buildAlterTablePreviewSql(input).trim().length > 0;

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

@@ -127,11 +127,27 @@ if (typeof window !== 'undefined' && !(window as any).go) {
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 }),
SelectSQLDirectory: async (currentPath: string) => ({ success: false, message: currentPath ? '已取消' : '已取消' }),
ListSQLDirectory: async () => ({ success: true, data: [] }),
ReadSQLFile: async () => ({ success: false, message: '已取消' }),
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),

View File

@@ -91,4 +91,100 @@ describe('store appearance persistence', () => {
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);
});
it('persists external SQL directories and restores valid items after reload', async () => {
const { useStore } = await importStore();
useStore.getState().saveExternalSQLDirectory({
id: 'ext-1',
name: 'scripts',
path: 'D:/sql/scripts',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 1,
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.externalSQLDirectories).toEqual([
{
id: 'ext-1',
name: 'scripts',
path: 'D:/sql/scripts',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 1,
},
]);
storage.setItem('lite-db-storage', JSON.stringify({
state: {
externalSQLDirectories: [
persisted.state.externalSQLDirectories[0],
{ path: '', name: 'broken' },
],
},
version: 7,
}));
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().externalSQLDirectories).toEqual([
{
id: 'ext-1',
name: 'scripts',
path: 'D:/sql/scripts',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 1,
},
]);
});
});

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem, GlobalProxyConfig } from './types';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem, GlobalProxyConfig, ExternalSQLDirectory } from './types';
import {
ShortcutAction,
ShortcutBinding,
@@ -9,6 +9,7 @@ import {
cloneShortcutOptions,
sanitizeShortcutOptions,
} from './utils/shortcuts';
import { buildExternalSQLDirectoryId } from './utils/externalSqlTree';
import { toPersistedGlobalProxy } from './utils/globalProxyDraft';
import {
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
@@ -430,6 +431,7 @@ interface AppState {
activeTabId: string | null;
activeContext: { connectionId: string; dbName: string } | null;
savedQueries: SavedQuery[];
externalSQLDirectories: ExternalSQLDirectory[];
theme: 'light' | 'dark';
appearance: AppearanceSettings;
uiScale: number;
@@ -488,6 +490,8 @@ interface AppState {
saveQuery: (query: SavedQuery) => void;
deleteQuery: (id: string) => void;
saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void;
deleteExternalSQLDirectory: (id: string) => void;
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<AppearanceSettings>) => void;
@@ -553,6 +557,57 @@ const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
return result;
};
const sanitizeExternalSQLDirectories = (value: unknown): ExternalSQLDirectory[] => {
if (!Array.isArray(value)) return [];
const result: ExternalSQLDirectory[] = [];
value.forEach((entry, index) => {
if (!entry || typeof entry !== 'object') return;
const raw = entry as Record<string, unknown>;
const path = toTrimmedString(raw.path);
const connectionId = toTrimmedString(raw.connectionId);
const dbName = toTrimmedString(raw.dbName);
if (!path || !connectionId || !dbName) return;
const fallbackName = path.split(/[\\/]/).filter(Boolean).pop() || `SQL目录-${index + 1}`;
result.push({
id: toTrimmedString(raw.id, buildExternalSQLDirectoryId(connectionId, dbName, path)) || buildExternalSQLDirectoryId(connectionId, dbName, path),
name: toTrimmedString(raw.name, fallbackName) || fallbackName,
path,
connectionId,
dbName,
createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(),
});
});
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' } => {
@@ -781,6 +836,7 @@ export const useStore = create<AppState>()(
activeTabId: null,
activeContext: null,
savedQueries: [],
externalSQLDirectories: [],
theme: 'light',
appearance: { ...DEFAULT_APPEARANCE },
uiScale: DEFAULT_UI_SCALE,
@@ -995,6 +1051,43 @@ export const useStore = create<AppState>()(
deleteQuery: (id) => set((state) => ({ savedQueries: state.savedQueries.filter(q => q.id !== id) })),
saveExternalSQLDirectory: (directory) => set((state) => {
const path = toTrimmedString(directory.path);
const connectionId = toTrimmedString(directory.connectionId);
const dbName = toTrimmedString(directory.dbName);
if (!path || !connectionId || !dbName) {
return state;
}
const nextDirectory: ExternalSQLDirectory = {
id: toTrimmedString(directory.id, buildExternalSQLDirectoryId(connectionId, dbName, path)) || buildExternalSQLDirectoryId(connectionId, dbName, path),
name: toTrimmedString(directory.name, path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录') || 'SQL目录',
path,
connectionId,
dbName,
createdAt: Number.isFinite(Number(directory.createdAt)) ? Number(directory.createdAt) : Date.now(),
};
const existingIndex = state.externalSQLDirectories.findIndex((item) =>
item.id === nextDirectory.id
|| (
item.connectionId === nextDirectory.connectionId
&& item.dbName === nextDirectory.dbName
&& item.path === nextDirectory.path
),
);
if (existingIndex === -1) {
return { externalSQLDirectories: [...state.externalSQLDirectories, nextDirectory] };
}
return {
externalSQLDirectories: state.externalSQLDirectories.map((item, index) =>
index === existingIndex ? nextDirectory : item,
),
};
}),
deleteExternalSQLDirectory: (id) => set((state) => ({
externalSQLDirectories: state.externalSQLDirectories.filter((item) => item.id !== id),
})),
setTheme: (theme) => set({ theme }),
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
@@ -1242,19 +1335,20 @@ export const useStore = create<AppState>()(
migrate: (persistedState: unknown, version: number) => {
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
const nextState: Partial<AppState> = { ...state };
nextState.connections = [];
nextState.connections = sanitizeConnections(state.connections);
if (version < 5) {
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
} else {
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
}
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
nextState.externalSQLDirectories = sanitizeExternalSQLDirectories(state.externalSQLDirectories);
nextState.theme = sanitizeTheme(state.theme);
nextState.appearance = sanitizeAppearance(state.appearance, version);
nextState.uiScale = sanitizeUiScale(state.uiScale);
nextState.fontSize = sanitizeFontSize(state.fontSize);
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy, { allowPassword: false });
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions);
@@ -1281,15 +1375,16 @@ export const useStore = create<AppState>()(
return {
...currentState,
...state,
connections: currentState.connections,
connections: sanitizeConnections(state.connections),
connectionTags: sanitizeConnectionTags(state.connectionTags),
savedQueries: sanitizeSavedQueries(state.savedQueries),
externalSQLDirectories: sanitizeExternalSQLDirectories(state.externalSQLDirectories),
theme: sanitizeTheme(state.theme),
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
uiScale: sanitizeUiScale(state.uiScale),
fontSize: sanitizeFontSize(state.fontSize),
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
globalProxy: sanitizeGlobalProxy(state.globalProxy, { allowPassword: false }),
globalProxy: sanitizeGlobalProxy(state.globalProxy),
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
@@ -1309,30 +1404,40 @@ export const useStore = create<AppState>()(
aiChatSessions: [],
};
},
partialize: (state) => ({
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
uiScale: state.uiScale,
fontSize: state.fontSize,
startupFullscreen: state.startupFullscreen,
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,
partialize: (state) => {
const partialState: Partial<AppState> = {
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
externalSQLDirectories: state.externalSQLDirectories,
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

@@ -166,6 +166,22 @@ export interface SavedQuery {
createdAt: number;
}
export interface ExternalSQLDirectory {
id: string;
name: string;
path: string;
connectionId: string;
dbName: string;
createdAt: number;
}
export interface ExternalSQLTreeEntry {
name: string;
path: string;
isDir: boolean;
children?: ExternalSQLTreeEntry[];
}
// Redis types
export interface RedisKeyInfo {
key: string;
@@ -262,4 +278,70 @@ export interface AISafetyResult {
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

@@ -8,8 +8,8 @@ import {
} from './aiEntryLayout';
describe('ai entry layout', () => {
it('keeps the sidebar utility group free of the AI entry', () => {
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']);
it('keeps the sidebar utility group compact and free of the AI entry', () => {
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'settings']);
});
it('anchors the AI entry to the content edge', () => {

View File

@@ -1,6 +1,6 @@
import type { CSSProperties } from 'react';
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'settings'] as const;
export type AIEntryPlacement = 'content-edge';
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { normalizeAiMarkdown } from './aiMarkdown';
describe('normalizeAiMarkdown', () => {
it('inserts a missing newline after the fenced code language marker', () => {
expect(normalizeAiMarkdown('```sqlSELECT COUNT(*) AS order_count\nFROM customer_order;\n```')).toBe(
'```sql\nSELECT COUNT(*) AS order_count\nFROM customer_order;\n```',
);
});
});

View File

@@ -0,0 +1,13 @@
export const normalizeAiMarkdown = (content: string): string => {
let text = String(content || '').replace(/\r\n/g, '\n');
const knownFenceLanguages = [
'sql', 'mermaid', 'json', 'javascript', 'typescript', 'ts', 'js', 'tsx', 'jsx',
'bash', 'sh', 'shell', 'python', 'py', 'go', 'java', 'yaml', 'yml', 'html', 'css',
'xml', 'markdown', 'md', 'text', 'plaintext', 'vue', 'php', 'ruby', 'rust', 'toml',
'ini', 'diff',
];
const fencePattern = new RegExp(`(^|\\n)\`\`\`(${knownFenceLanguages.join('|')})([^\\n])`, 'gi');
text = text.replace(fencePattern, '$1```$2\n$3');
text = text.replace(/([^\n])```(?=\n|$)/g, '$1\n```');
return text;
};

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { resolveAboutDisplayVersion } from './appVersionDisplay';
describe('resolveAboutDisplayVersion', () => {
it('shows fixed dev version for development build', () => {
expect(resolveAboutDisplayVersion('development', '0.6.5')).toBe('0.0.1-dev');
});
it('shows fixed dev version for wails dev build type', () => {
expect(resolveAboutDisplayVersion('dev', '0.6.5')).toBe('0.0.1-dev');
});
it('keeps real version for non-development builds', () => {
expect(resolveAboutDisplayVersion('production', '0.6.5')).toBe('0.6.5');
});
it('falls back to unknown when version is empty outside development', () => {
expect(resolveAboutDisplayVersion('production', '')).toBe('未知');
});
});

View File

@@ -0,0 +1,14 @@
const DEV_ABOUT_VERSION = '0.0.1-dev';
export const resolveAboutDisplayVersion = (
buildType: string,
version: string | undefined,
): string => {
const normalizedBuildType = String(buildType || '').trim().toLowerCase();
if (normalizedBuildType === 'development' || normalizedBuildType === 'dev') {
return DEV_ABOUT_VERSION;
}
const normalizedVersion = String(version || '').trim();
return normalizedVersion || '未知';
};

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
import {
blurToFilter,
normalizeBlurForPlatform,
normalizeOpacityForPlatform,
resolveAppearanceValues,
resolveTextInputSafeBackdropFilter,
} from './appearance';
describe('appearance helpers', () => {
it('falls back to opaque non-blurred appearance when disabled', () => {
@@ -20,4 +26,10 @@ describe('appearance helpers', () => {
expect(blurToFilter(0)).toBeUndefined();
expect(blurToFilter(8)).toBe('blur(8px)');
});
it('disables local backdrop blur for text-entry surfaces on macOS', () => {
expect(resolveTextInputSafeBackdropFilter('blur(18px)', true)).toBe('none');
expect(resolveTextInputSafeBackdropFilter('blur(18px)', false)).toBe('blur(18px)');
expect(resolveTextInputSafeBackdropFilter(undefined, true)).toBe('none');
});
});

View File

@@ -80,3 +80,16 @@ export const normalizeBlurForPlatform = (blur: number | undefined): number => {
export const blurToFilter = (blur: number): string | undefined => {
return blur > 0 ? `blur(${blur}px)` : undefined;
};
// macOS WebView 下,文本输入区域祖先节点的 backdrop-filter 会和输入法候选/切换浮层叠加,
// 造成额外的透明框。这里允许交互面板按平台降级为非模糊背景。
export const resolveTextInputSafeBackdropFilter = (
backdropFilter: string | undefined,
disableForMacLike: boolean = isMacLikePlatform(),
): string => {
const normalized = String(backdropFilter || '').trim();
if (!normalized || normalized === 'none') {
return 'none';
}
return disableForMacLike ? 'none' : normalized;
};

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,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,197 @@
import type { ConnectionConfig, SavedConnection } from '../types';
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | '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;
}
};
const isMySQLWorkbenchXML = (raw: string): boolean => (
raw.includes('<data') && raw.includes('grt_format') && raw.includes('db.mgmt.Connection')
);
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
if (typeof raw === 'string' && isMySQLWorkbenchXML(raw)) {
return 'mysql-workbench-xml';
}
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,22 @@
import { describe, expect, it } from 'vitest';
import {
CUSTOM_CONNECTION_DRIVER_HELP,
DRIVER_LOCAL_IMPORT_BUTTON_LABEL,
DRIVER_LOCAL_IMPORT_DIRECTORY_HELP,
DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP,
} from './driverImportGuidance';
describe('driver import guidance', () => {
it('keeps local import copy focused on driver packages instead of JDBC jars', () => {
expect(DRIVER_LOCAL_IMPORT_BUTTON_LABEL).toBe('导入驱动包');
expect(DRIVER_LOCAL_IMPORT_DIRECTORY_HELP).toContain('导入驱动目录');
expect(DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP).toContain('JDBC Jar');
});
it('documents custom driver aliases for kingbase and related fallbacks', () => {
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('kingbase8');
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('pgx');
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('JDBC Jar');
});
});

View File

@@ -0,0 +1,10 @@
export const DRIVER_LOCAL_IMPORT_BUTTON_LABEL = '导入驱动包';
export const DRIVER_LOCAL_IMPORT_DIRECTORY_HELP =
'如果应用内下载链路失败,可先手动下载驱动包到该目录,再使用“导入驱动包”或“导入驱动目录”完成安装。';
export const DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP =
'行内“导入驱动包”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`),不支持直接导入 JDBC Jar批量导入请使用上方“导入驱动目录”。';
export const CUSTOM_CONNECTION_DRIVER_HELP =
'已支持: mysql, postgres, sqlite, oracle, dm, kingbase别名支持 postgresql/pgx、dm8、kingbase8/kingbasees/kingbasev8。当前不支持通过 JDBC Jar 扩展驱动。';

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
import { buildExternalSQLRootNode, buildExternalSQLTabId } from './externalSqlTree';
describe('externalSqlTree helpers', () => {
it('builds external SQL root node with nested directory and file entries', () => {
const directories: ExternalSQLDirectory[] = [
{
id: 'dir-1',
name: 'scripts',
path: 'D:/sql/scripts',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 1,
},
];
const trees: Record<string, ExternalSQLTreeEntry[]> = {
'dir-1': [
{
name: 'ddl',
path: 'D:/sql/scripts/ddl',
isDir: true,
children: [
{
name: 'init.sql',
path: 'D:/sql/scripts/ddl/init.sql',
isDir: false,
},
],
},
],
};
const node = buildExternalSQLRootNode({
dbNodeKey: 'conn-1-demo',
connectionId: 'conn-1',
dbName: 'demo',
directories,
directoryTrees: trees,
});
expect(node.type).toBe('external-sql-root');
expect(node.children).toHaveLength(1);
expect(node.children?.[0]).toMatchObject({
title: 'scripts',
type: 'external-sql-directory',
});
expect(node.children?.[0].children?.[0]).toMatchObject({
title: 'ddl',
type: 'external-sql-folder',
});
expect(node.children?.[0].children?.[0].children?.[0]).toMatchObject({
title: 'init.sql',
type: 'external-sql-file',
});
});
it('builds query tab ids with connection and database isolation', () => {
const first = buildExternalSQLTabId('conn-1', 'demo', 'D:/sql/init.sql');
const second = buildExternalSQLTabId('conn-1', 'demo2', 'D:/sql/init.sql');
expect(first).toContain('conn-1');
expect(first).toContain('demo');
expect(first).not.toBe(second);
});
});

View File

@@ -0,0 +1,131 @@
import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
export type ExternalSQLNodeType =
| 'external-sql-root'
| 'external-sql-directory'
| 'external-sql-folder'
| 'external-sql-file';
export interface ExternalSQLTreeNode {
title: string;
key: string;
isLeaf?: boolean;
children?: ExternalSQLTreeNode[];
type: ExternalSQLNodeType;
dataRef: Record<string, unknown>;
}
type BuildExternalSQLRootNodeParams = {
dbNodeKey: string;
connectionId: string;
dbName: string;
directories: ExternalSQLDirectory[];
directoryTrees: Record<string, ExternalSQLTreeEntry[]>;
};
const normalizeExternalSQLPath = (value: string): string =>
String(value || '').trim().replace(/\\/g, '/');
const resolveDirectoryDisplayName = (directory: ExternalSQLDirectory): string => {
const explicitName = String(directory.name || '').trim();
if (explicitName) return explicitName;
const normalizedPath = normalizeExternalSQLPath(directory.path);
const segments = normalizedPath.split('/').filter(Boolean);
return segments[segments.length - 1] || 'SQL目录';
};
export const buildExternalSQLDirectoryId = (connectionId: string, dbName: string, directoryPath: string): string =>
`external-sql-dir:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(directoryPath)}`;
export const buildExternalSQLTabId = (connectionId: string, dbName: string, filePath: string): string =>
`external-sql-tab:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(filePath)}`;
const buildExternalSQLNodeKey = (type: ExternalSQLNodeType, base: string): string =>
`${type}:${normalizeExternalSQLPath(base)}`;
const mapExternalSQLTreeEntries = (
entries: ExternalSQLTreeEntry[],
context: { connectionId: string; dbName: string; dbNodeKey: string; directoryId: string },
): ExternalSQLTreeNode[] => entries.map((entry) => {
const entryPath = normalizeExternalSQLPath(entry.path);
if (entry.isDir) {
const children = mapExternalSQLTreeEntries(entry.children || [], context);
return {
title: entry.name,
key: buildExternalSQLNodeKey('external-sql-folder', entryPath),
type: 'external-sql-folder',
isLeaf: children.length === 0,
children: children.length > 0 ? children : undefined,
dataRef: {
connectionId: context.connectionId,
dbName: context.dbName,
dbNodeKey: context.dbNodeKey,
directoryId: context.directoryId,
path: entry.path,
name: entry.name,
},
};
}
return {
title: entry.name,
key: buildExternalSQLNodeKey('external-sql-file', entryPath),
type: 'external-sql-file',
isLeaf: true,
dataRef: {
connectionId: context.connectionId,
dbName: context.dbName,
dbNodeKey: context.dbNodeKey,
directoryId: context.directoryId,
path: entry.path,
name: entry.name,
},
};
});
export const buildExternalSQLRootNode = ({
dbNodeKey,
connectionId,
dbName,
directories,
directoryTrees,
}: BuildExternalSQLRootNodeParams): ExternalSQLTreeNode => {
const sortedDirectories = [...directories].sort((left, right) =>
resolveDirectoryDisplayName(left).toLowerCase().localeCompare(resolveDirectoryDisplayName(right).toLowerCase()),
);
const children = sortedDirectories.map((directory) => {
const directoryChildren = mapExternalSQLTreeEntries(directoryTrees[directory.id] || [], {
connectionId,
dbName,
dbNodeKey,
directoryId: directory.id,
});
return {
title: resolveDirectoryDisplayName(directory),
key: buildExternalSQLNodeKey('external-sql-directory', directory.id),
type: 'external-sql-directory' as const,
isLeaf: directoryChildren.length === 0,
children: directoryChildren.length > 0 ? directoryChildren : undefined,
dataRef: {
...directory,
connectionId,
dbName,
dbNodeKey,
},
};
});
return {
title: children.length > 0 ? `外部 SQL 文件 (${children.length})` : '外部 SQL 文件',
key: `${dbNodeKey}-external-sql`,
type: 'external-sql-root',
isLeaf: children.length === 0,
children: children.length > 0 ? children : undefined,
dataRef: {
connectionId,
dbName,
dbNodeKey,
},
};
};

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { applyNoAutoCapAttributes, applyNoAutoCapAttributesWithin, noAutoCapInputProps } from './inputAutoCap';
describe('inputAutoCap', () => {
it('exports input props that disable auto capitalization and correction', () => {
expect(noAutoCapInputProps).toEqual({
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
});
});
it('applies lowercase DOM attributes to inputs and textareas', () => {
const inputAttributes: Record<string, string> = {};
const textareaAttributes: Record<string, string> = {};
const input = {
tagName: 'INPUT',
setAttribute: (key: string, value: string) => {
inputAttributes[key] = value;
},
} as unknown as Element;
const textarea = {
tagName: 'TEXTAREA',
setAttribute: (key: string, value: string) => {
textareaAttributes[key] = value;
},
} as unknown as Element;
applyNoAutoCapAttributes(input);
applyNoAutoCapAttributes(textarea);
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(inputAttributes.spellcheck).toBe('false');
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
expect(textareaAttributes.spellcheck).toBe('false');
});
it('applies no-auto-cap attributes to all nested inputs and textareas within a container', () => {
const inputAttributes: Record<string, string> = {};
const textareaAttributes: Record<string, string> = {};
const input = {
tagName: 'INPUT',
setAttribute: (key: string, value: string) => {
inputAttributes[key] = value;
},
} as unknown as Element;
const textarea = {
tagName: 'TEXTAREA',
setAttribute: (key: string, value: string) => {
textareaAttributes[key] = value;
},
} as unknown as Element;
const root = {
querySelectorAll: (selector: string) => {
expect(selector).toBe('input, textarea');
return [input, textarea];
},
} as unknown as ParentNode;
applyNoAutoCapAttributesWithin(root);
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
});
});

View File

@@ -0,0 +1,26 @@
export const noAutoCapInputProps = {
autoCapitalize: 'none' as const,
autoCorrect: 'off' as const,
spellCheck: false,
};
export const applyNoAutoCapAttributes = (element: Element) => {
const tagName = String((element as Element | null)?.tagName || '').toUpperCase();
if (tagName !== 'INPUT' && tagName !== 'TEXTAREA') {
return;
}
element.setAttribute('autocapitalize', 'none');
element.setAttribute('autocorrect', 'off');
element.setAttribute('spellcheck', 'false');
};
export const applyNoAutoCapAttributesWithin = (root: ParentNode | null | undefined) => {
if (!root || typeof root.querySelectorAll !== 'function') {
return;
}
root.querySelectorAll('input, textarea').forEach((element) => {
applyNoAutoCapAttributes(element);
});
};

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './legacyConnectionStorage';
import {
hasLegacyMigratableSensitiveItems,
readLegacyPersistedSecrets,
stripLegacyPersistedConnectionById,
stripLegacyPersistedSecrets,
} from './legacyConnectionStorage';
describe('legacy connection storage', () => {
it('extracts legacy saved connections and global proxy password from lite-db-storage', () => {
@@ -37,7 +42,7 @@ describe('legacy connection storage', () => {
expect(result.globalProxy?.password).toBe('proxy-secret');
});
it('strips persisted connection secrets but keeps secretless proxy metadata', () => {
it('clears legacy connection and proxy source data after cleanup', () => {
const payload = JSON.stringify({
state: {
connections: [
@@ -69,7 +74,110 @@ describe('legacy connection storage', () => {
const parsed = JSON.parse(sanitized);
expect(parsed.state.connections).toEqual([]);
expect(parsed.state.globalProxy.password).toBeUndefined();
expect(parsed.state.globalProxy.hasPassword).toBe(true);
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

@@ -79,6 +79,11 @@ export function readLegacyPersistedSecrets(payload: string | null | undefined):
};
}
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 '';
@@ -96,15 +101,42 @@ export function stripLegacyPersistedSecrets(payload: string | null | undefined):
: parsed;
state.connections = [];
if (state.globalProxy && typeof state.globalProxy === 'object') {
const proxy = { ...(state.globalProxy as Record<string, unknown>) };
const password = toTrimmedString(proxy.password);
delete proxy.password;
if (password !== '') {
proxy.hasPassword = true;
}
state.globalProxy = proxy;
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,25 @@
import { describe, expect, it } from 'vitest';
import { shouldEnableMacWindowDiagnostics } from './macWindowDiagnostics';
describe('macWindowDiagnostics', () => {
it('stays disabled outside macOS runtime', () => {
expect(shouldEnableMacWindowDiagnostics(false, true, 'true')).toBe(false);
});
it('stays disabled for production builds on macOS', () => {
expect(shouldEnableMacWindowDiagnostics(true, false, 'true')).toBe(false);
});
it('stays disabled by default for macOS development builds', () => {
expect(shouldEnableMacWindowDiagnostics(true, true)).toBe(false);
expect(shouldEnableMacWindowDiagnostics(true, true, '')).toBe(false);
expect(shouldEnableMacWindowDiagnostics(true, true, '0')).toBe(false);
});
it('enables diagnostics only when explicitly opted in on macOS development builds', () => {
expect(shouldEnableMacWindowDiagnostics(true, true, '1')).toBe(true);
expect(shouldEnableMacWindowDiagnostics(true, true, 'true')).toBe(true);
expect(shouldEnableMacWindowDiagnostics(true, true, 'yes')).toBe(true);
});
});

View File

@@ -0,0 +1,22 @@
const isTruthyFlag = (value: string | undefined): boolean => {
switch (String(value || '').trim().toLowerCase()) {
case '1':
case 'true':
case 'yes':
case 'on':
return true;
default:
return false;
}
};
export const shouldEnableMacWindowDiagnostics = (
isMacRuntime: boolean,
isDevBuild: boolean,
envValue?: string,
): boolean => {
if (!isMacRuntime || !isDevBuild) {
return false;
}
return isTruthyFlag(envValue);
};

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { convertMongoShellToJsonCommand } from './mongodb';
describe('convertMongoShellToJsonCommand', () => {
it('converts show dbs shell shortcut to listDatabases command', () => {
expect(convertMongoShellToJsonCommand('show dbs;')).toEqual({
recognized: true,
command: JSON.stringify({ listDatabases: 1, nameOnly: true }),
});
});
it('converts show collections shell shortcut to listCollections command', () => {
expect(convertMongoShellToJsonCommand(' show collections ')).toEqual({
recognized: true,
command: JSON.stringify({ listCollections: 1, filter: {}, nameOnly: true }),
});
});
});

View File

@@ -752,10 +752,42 @@ const buildMongoDeleteCommand = (
return JSON.stringify(command);
};
const convertMongoShellShortcutCommand = (raw: string): ShellConvertResult | null => {
const normalized = String(raw || '')
.replace(/[;]+\s*$/, '')
.trim()
.replace(/\s+/g, ' ')
.toLowerCase();
if (!normalized) {
return null;
}
if (normalized === 'show dbs' || normalized === 'show databases') {
return {
recognized: true,
command: JSON.stringify({ listDatabases: 1, nameOnly: true }),
};
}
if (normalized === 'show collections' || normalized === 'show tables') {
return {
recognized: true,
command: JSON.stringify({ listCollections: 1, filter: {}, nameOnly: true }),
};
}
return null;
};
export const convertMongoShellToJsonCommand = (raw: string): ShellConvertResult => {
let input = String(raw || '').trim();
input = input.replace(/^[\s]*(\/\/[^\n]*\n)+/g, '').trim();
input = input.replace(/[;]+\s*$/, '');
const shortcut = convertMongoShellShortcutCommand(input);
if (shortcut) {
return shortcut;
}
if (!/^db\./i.test(input)) {
return { recognized: false };
}

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { buildTableSelectQuery } from './objectQueryTemplates';
describe('buildTableSelectQuery', () => {
it('quotes uppercase postgres table names in new query templates', () => {
expect(buildTableSelectQuery('postgres', 'public.MyTable')).toBe('SELECT * FROM public."MyTable";');
});
});

View File

@@ -0,0 +1,9 @@
import { quoteQualifiedIdent } from './sql';
export const buildTableSelectQuery = (dbType: string, tableName: string): string => {
const normalizedTableName = String(tableName || '').trim();
if (!normalizedTableName) {
return 'SELECT * FROM ';
}
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)};`;
};

View File

@@ -18,4 +18,9 @@ describe('buildOverlayWorkbenchTheme', () => {
expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
expect(lightTheme.iconColor).toBe('#1677ff');
});
it('can disable shell blur for macOS text-entry compatibility', () => {
const darkTheme = buildOverlayWorkbenchTheme(true, { disableBackdropFilter: true });
expect(darkTheme.shellBackdropFilter).toBe('none');
});
});

View File

@@ -1,3 +1,5 @@
import { resolveTextInputSafeBackdropFilter } from './appearance';
type OverlayWorkbenchTheme = {
isDark: boolean;
shellBg: string;
@@ -16,14 +18,22 @@ type OverlayWorkbenchTheme = {
divider: string;
};
export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => {
export const buildOverlayWorkbenchTheme = (
darkMode: boolean,
options?: { disableBackdropFilter?: boolean },
): OverlayWorkbenchTheme => {
const shellBackdropFilter = resolveTextInputSafeBackdropFilter(
darkMode ? 'blur(18px)' : 'none',
options?.disableBackdropFilter ?? false,
);
if (darkMode) {
return {
isDark: true,
shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)',
shellBorder: '1px solid rgba(255,255,255,0.08)',
shellShadow: '0 24px 56px rgba(0,0,0,0.34)',
shellBackdropFilter: 'blur(18px)',
shellBackdropFilter,
sectionBg: 'rgba(255,255,255,0.03)',
sectionBorder: '1px solid rgba(255,255,255,0.08)',
mutedText: 'rgba(255,255,255,0.5)',
@@ -42,7 +52,7 @@ export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchT
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
shellBorder: '1px solid rgba(16,24,40,0.08)',
shellShadow: '0 18px 42px rgba(15,23,42,0.12)',
shellBackdropFilter: 'none',
shellBackdropFilter,
sectionBg: 'rgba(255,255,255,0.84)',
sectionBorder: '1px solid rgba(16,24,40,0.08)',
mutedText: 'rgba(16,24,40,0.55)',

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from './redisSearchPattern';
describe('normalizeRedisSearchInput', () => {
it('returns wildcard for empty input', () => {
expect(normalizeRedisSearchInput('')).toEqual({
keyword: '',
pattern: '*',
});
});
it('wraps plain keywords with wildcard for contains matching', () => {
expect(normalizeRedisSearchInput('order')).toEqual({
keyword: 'order',
pattern: '*[oO][rR][dD][eE][rR]*',
});
});
it('builds ascii case-insensitive patterns for letter keywords', () => {
expect(normalizeRedisSearchInput('agent')).toEqual({
keyword: 'agent',
pattern: '*[aA][gG][eE][nN][tT]*',
});
});
it('escapes redis glob special characters as literals', () => {
expect(normalizeRedisSearchInput('user:*:[id]?')).toEqual({
keyword: 'user:*:[id]?',
pattern: '*[uU][sS][eE][rR]:\\*:\\[[iI][dD]\\]\\?*',
});
});
it('marks empty draft changes for immediate reset search', () => {
expect(normalizeRedisSearchDraftChange('')).toEqual({
keyword: '',
pattern: '*',
shouldSearchImmediately: true,
});
});
});

View File

@@ -0,0 +1,41 @@
const REDIS_GLOB_SPECIAL_CHARS = /([*?\[\]\\])/g;
const ASCII_LETTER = /^[A-Za-z]$/;
const escapeRedisGlobLiteral = (value: string): string => {
return value.replace(REDIS_GLOB_SPECIAL_CHARS, '\\$1');
};
const toCaseInsensitiveRedisGlobLiteral = (value: string): string => {
return Array.from(value).map((char) => {
if (!ASCII_LETTER.test(char)) {
return escapeRedisGlobLiteral(char);
}
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return `[${lower}${upper}]`;
}).join('');
};
export const normalizeRedisSearchInput = (rawValue: string): { keyword: string; pattern: string } => {
const keyword = String(rawValue || '').trim();
if (!keyword) {
return { keyword: '', pattern: '*' };
}
return {
keyword,
pattern: `*${toCaseInsensitiveRedisGlobLiteral(keyword)}*`,
};
};
export const normalizeRedisSearchDraftChange = (rawValue: string): {
keyword: string;
pattern: string;
shouldSearchImmediately: boolean;
} => {
const normalized = normalizeRedisSearchInput(rawValue);
return {
...normalized,
shouldSearchImmediately: normalized.keyword === '',
};
};

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { decodeRedisUtf8Value, formatRedisStringValue } from './redisValueDisplay';
const toRedisByteString = (text: string): string => (
Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join('')
);
describe('redisValueDisplay', () => {
it('keeps already decoded unicode text in utf8 mode', () => {
expect(decodeRedisUtf8Value('中文内容')).toBe('中文内容');
});
it('decodes utf8 byte strings in auto mode', () => {
expect(formatRedisStringValue(toRedisByteString('中文内容'))).toMatchObject({
displayValue: '中文内容',
isBinary: false,
isJson: false,
encoding: 'UTF-8',
});
});
it('falls back to hex for obvious binary values', () => {
expect(formatRedisStringValue('\u0000\u0001\u0002abc')).toMatchObject({
isBinary: true,
encoding: 'HEX',
});
});
});

View File

@@ -0,0 +1,153 @@
const hasDecodedUnicodeText = (value: string): boolean => {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0xFF) {
return true;
}
}
return false;
};
const toByteArray = (value: string): Uint8Array => {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
return bytes;
};
const decodeUtf8Bytes = (value: string): string => (
new TextDecoder('utf-8', { fatal: false }).decode(toByteArray(value))
);
const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => {
if (!value || value.length === 0) {
return { displayValue: '', encoding: 'UTF-8', needsHex: false };
}
if (hasDecodedUnicodeText(value)) {
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
}
let nullCount = 0;
let printableCount = 0;
let highByteCount = 0;
const sampleSize = Math.min(value.length, 200);
for (let i = 0; i < sampleSize; i++) {
const code = value.charCodeAt(i);
if (code === 0) {
nullCount++;
} else if (code >= 32 && code < 127) {
printableCount++;
} else if (code >= 128) {
highByteCount++;
}
}
if (nullCount / sampleSize > 0.3) {
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
}
if (highByteCount === 0 && printableCount / sampleSize > 0.7) {
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
}
if (highByteCount > 0) {
try {
const decoded = decodeUtf8Bytes(value);
let validChars = 0;
let replacementChars = 0;
let controlChars = 0;
for (let i = 0; i < Math.min(decoded.length, 200); i++) {
const code = decoded.charCodeAt(i);
if (code === 0xFFFD) {
replacementChars++;
} else if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
controlChars++;
} else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) {
validChars++;
}
}
const totalChecked = Math.max(1, Math.min(decoded.length, 200));
if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) {
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
}
if (validChars / totalChecked > 0.5) {
return { displayValue: decoded, encoding: 'UTF-8', needsHex: false };
}
} catch {
// ignore decode failure
}
}
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
};
const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => {
try {
const parsed = JSON.parse(value);
return { isJson: true, formatted: JSON.stringify(parsed, null, 2) };
} catch {
return { isJson: false, formatted: value };
}
};
export const toHexDisplay = (value: string): string => {
const bytes: string[] = [];
const ascii: string[] = [];
let result = '';
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
bytes.push(code.toString(16).padStart(2, '0').toUpperCase());
ascii.push(code >= 32 && code < 127 ? value[i] : '.');
if (bytes.length === 16 || i === value.length - 1) {
const offset = (Math.floor(i / 16) * 16).toString(16).padStart(8, '0').toUpperCase();
const hexPart = bytes.join(' ').padEnd(47, ' ');
const asciiPart = ascii.join('');
result += `${offset} ${hexPart} |${asciiPart}|\n`;
bytes.length = 0;
ascii.length = 0;
}
}
return result;
};
export const decodeRedisUtf8Value = (value: string): string => {
if (!value || value.length === 0) {
return '';
}
if (hasDecodedUnicodeText(value)) {
return value;
}
try {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0x7F) {
return decodeUtf8Bytes(value);
}
}
return value;
} catch {
return value;
}
};
export const formatRedisStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding: string } => {
const { displayValue, encoding, needsHex } = tryDecodeValue(value);
if (needsHex) {
return { displayValue, isBinary: true, isJson: false, encoding };
}
const { isJson, formatted } = tryFormatJson(displayValue);
return { displayValue: formatted, isBinary: false, isJson, encoding };
};

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

@@ -0,0 +1,73 @@
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: raw.substring(0, idx),
objectName: raw.substring(idx + 1),
};
};
const normalizeSidebarConnectionDialect = (type: string, driver: string): string => {
const normalizedType = String(type || '').trim().toLowerCase();
if (normalizedType === 'custom') {
const normalizedDriver = String(driver || '').trim().toLowerCase();
if (normalizedDriver === 'postgresql' || normalizedDriver === 'postgres' || normalizedDriver === 'pg') return 'postgres';
if (normalizedDriver === 'dameng' || normalizedDriver === 'dm' || normalizedDriver === 'dm8') return 'dm';
if (normalizedDriver.includes('oracle')) return 'oracle';
return normalizedDriver;
}
if (normalizedType === 'dameng') return 'dm';
return normalizedType;
};
export const normalizeSidebarViewName = (dialect: string, dbName: string, schemaName: string, viewName: string): string => {
const normalizedDialect = String(dialect || '').trim().toLowerCase();
const normalizedDbName = String(dbName || '').trim();
const normalizedSchemaName = String(schemaName || '').trim();
const normalizedViewName = String(viewName || '').trim();
if (!normalizedViewName) {
return '';
}
if (normalizedDialect === 'mysql') {
const parsed = splitQualifiedName(normalizedViewName);
if (parsed.objectName) {
return parsed.objectName;
}
return normalizedViewName;
}
if (!normalizedSchemaName || normalizedViewName.includes('.')) {
return normalizedViewName;
}
return `${normalizedSchemaName}.${normalizedViewName}`;
};
export const resolveSidebarRuntimeDatabase = (
type: string,
driver: string,
savedDatabase: string,
overrideDatabase?: string,
clearDatabase: boolean = false,
): string => {
if (clearDatabase) return '';
const normalizedSavedDatabase = String(savedDatabase || '').trim();
const normalizedOverrideDatabase = String(overrideDatabase || '').trim();
if (!normalizedOverrideDatabase) {
return normalizedSavedDatabase;
}
const dialect = normalizeSidebarConnectionDialect(type, driver);
if (dialect === 'oracle' || dialect === 'dm') {
return normalizedSavedDatabase || normalizedOverrideDatabase;
}
return normalizedOverrideDatabase;
};

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection, TabData } from '../types';
import { buildTabDisplayTitle, resolveConnectionHostSummary } from './tabDisplay';
const redisConnection: SavedConnection = {
id: 'redis-1',
name: '订单缓存',
config: {
type: 'redis',
host: '10.10.0.12',
port: 6379,
user: '',
database: '',
hosts: ['10.10.0.13:6379', '10.10.0.14:6379'],
},
};
describe('tabDisplay', () => {
it('builds compact host summary for multi-host redis connections', () => {
expect(resolveConnectionHostSummary(redisConnection.config)).toBe('10.10.0.12 +2');
});
it('adds connection and host identity to redis key tabs', () => {
const redisKeysTab: TabData = {
id: 'redis-keys-redis-1-db0',
title: 'db0',
type: 'redis-keys',
connectionId: 'redis-1',
redisDB: 0,
};
expect(buildTabDisplayTitle(redisKeysTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] db0');
});
it('normalizes redis command and monitor tabs to db-scoped labels', () => {
const commandTab: TabData = {
id: 'cmd-1',
title: '命令 - db1',
type: 'redis-command',
connectionId: 'redis-1',
redisDB: 1,
};
const monitorTab: TabData = {
id: 'monitor-1',
title: '监控: 订单缓存',
type: 'redis-monitor',
connectionId: 'redis-1',
redisDB: 1,
};
expect(buildTabDisplayTitle(commandTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 命令 - db1');
expect(buildTabDisplayTitle(monitorTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 监控 - db1');
});
it('keeps table tabs on the existing prefix strategy', () => {
const tableTab: TabData = {
id: 'table-1',
title: 'orders',
type: 'table',
connectionId: 'redis-1',
dbName: 'app',
tableName: 'orders',
};
expect(buildTabDisplayTitle(tableTab, redisConnection)).toBe('[订单缓存] orders');
});
});

View File

@@ -0,0 +1,99 @@
import type { ConnectionConfig, SavedConnection, TabData } from '../types';
export const detectConnectionEnvLabel = (connectionName: string): string | null => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const parseHostOnlyToken = (value: unknown): string[] => {
const raw = String(value || '').trim();
if (!raw) {
return [];
}
let text = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
if (text.includes('/')) {
text = text.split('/')[0];
}
if (text.includes('?')) {
text = text.split('?')[0];
}
if (text.includes('@')) {
text = text.split('@').pop() || '';
}
return text
.split(',')
.map((entry) => {
const token = entry.trim();
if (!token) return '';
if (token.startsWith('[')) {
const rightBracketIndex = token.indexOf(']');
if (rightBracketIndex > 0) {
return token.slice(0, rightBracketIndex + 1).toLowerCase();
}
}
const colonIndex = token.lastIndexOf(':');
if (colonIndex > 0) {
return token.slice(0, colonIndex).toLowerCase();
}
return token.toLowerCase();
})
.filter(Boolean);
};
export const resolveConnectionHostTokens = (config?: ConnectionConfig): string[] => {
if (!config) {
return [];
}
return Array.from(new Set([
...parseHostOnlyToken(config.host),
...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry) => parseHostOnlyToken(entry)) : []),
...parseHostOnlyToken(config.uri),
]));
};
export const resolveConnectionHostSummary = (config?: ConnectionConfig): string => {
const hosts = resolveConnectionHostTokens(config);
if (hosts.length === 0) return '';
if (hosts.length === 1) return hosts[0];
return `${hosts[0]} +${hosts.length - 1}`;
};
const isRedisTab = (tab: TabData): boolean => {
return tab.type === 'redis-keys' || tab.type === 'redis-command' || tab.type === 'redis-monitor';
};
const buildRedisBaseTitle = (tab: TabData): string => {
const dbLabel = `db${tab.redisDB ?? 0}`;
if (tab.type === 'redis-command') return `命令 - ${dbLabel}`;
if (tab.type === 'redis-monitor') return `监控 - ${dbLabel}`;
return dbLabel;
};
export const buildTabDisplayTitle = (tab: TabData, connection?: SavedConnection): string => {
const connectionName = String(connection?.name || '').trim();
if (isRedisTab(tab)) {
const hostSummary = resolveConnectionHostSummary(connection?.config);
const identity = [connectionName, hostSummary].filter(Boolean).join(' | ');
return identity ? `[${identity}] ${buildRedisBaseTitle(tab)}` : buildRedisBaseTitle(tab);
}
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') {
return tab.title;
}
if (!connectionName) {
return tab.title;
}
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { resolveVisibleStartupWindowBounds } from './windowRestoreBounds';
describe('windowRestoreBounds', () => {
it('keeps existing bounds when the window still overlaps the visible area', () => {
expect(resolveVisibleStartupWindowBounds(
{ width: 1280, height: 820, x: -120, y: 40 },
{ availWidth: 1920, availHeight: 1080, availLeft: 0, availTop: 0 },
)).toEqual({ width: 1280, height: 820, x: -120, y: 40 });
});
it('recenters bounds when the saved window is fully outside the visible area', () => {
expect(resolveVisibleStartupWindowBounds(
{ width: 1280, height: 820, x: 3200, y: 1800 },
{ availWidth: 1920, availHeight: 1080, availLeft: 0, availTop: 0 },
)).toEqual({ width: 1280, height: 820, x: 320, y: 130 });
});
it('recenters bounds when the saved window is fully above and left of the visible area', () => {
expect(resolveVisibleStartupWindowBounds(
{ width: 900, height: 640, x: -1600, y: -900 },
{ availWidth: 1600, availHeight: 900, availLeft: 0, availTop: 0 },
)).toEqual({ width: 900, height: 640, x: 350, y: 130 });
});
});

View File

@@ -0,0 +1,47 @@
export type WindowRestoreBounds = {
width: number;
height: number;
x: number;
y: number;
};
type VisibleViewport = {
availWidth: number;
availHeight: number;
availLeft?: number;
availTop?: number;
};
const MIN_VISIBLE_WIDTH = 160;
const MIN_VISIBLE_HEIGHT = 120;
export const resolveVisibleStartupWindowBounds = (
bounds: WindowRestoreBounds,
viewport: VisibleViewport,
): WindowRestoreBounds => {
const visibleWidth = Math.trunc(Number(viewport.availWidth) || 0);
const visibleHeight = Math.trunc(Number(viewport.availHeight) || 0);
if (visibleWidth <= 0 || visibleHeight <= 0) {
return bounds;
}
const visibleLeft = Math.trunc(Number(viewport.availLeft) || 0);
const visibleTop = Math.trunc(Number(viewport.availTop) || 0);
const visibleRight = visibleLeft + visibleWidth;
const visibleBottom = visibleTop + visibleHeight;
const overlapWidth = Math.min(bounds.x + bounds.width, visibleRight) - Math.max(bounds.x, visibleLeft);
const overlapHeight = Math.min(bounds.y + bounds.height, visibleBottom) - Math.max(bounds.y, visibleTop);
if (
overlapWidth >= Math.min(MIN_VISIBLE_WIDTH, bounds.width) &&
overlapHeight >= Math.min(MIN_VISIBLE_HEIGHT, bounds.height)
) {
return bounds;
}
return {
...bounds,
x: visibleLeft + Math.max(0, Math.trunc((visibleWidth - bounds.width) / 2)),
y: visibleTop + Math.max(0, Math.trunc((visibleHeight - bounds.height) / 2)),
};
};

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './windowStateUi';
describe('windowStateUi', () => {
it('does not re-toggle a maximized window on activation when focus returns', () => {
expect(shouldToggleMaximisedWindowForScaleFix('activation', true)).toBe(false);
});
it('switches the titlebar toggle icon to restore when the window is maximized', () => {
expect(resolveTitleBarToggleIconKey('maximized')).toBe('restore');
});
});

View File

@@ -0,0 +1,11 @@
export type WindowVisualState = 'normal' | 'maximized' | 'fullscreen';
export type WindowScaleFixReason = 'activation' | 'ratio-change';
export type TitleBarToggleIconKey = 'maximize' | 'restore';
export const shouldToggleMaximisedWindowForScaleFix = (
reason: WindowScaleFixReason,
hasViewportScaleDrift: boolean,
): boolean => reason === 'ratio-change' && hasViewportScaleDrift;
export const resolveTitleBarToggleIconKey = (windowState: WindowVisualState): TitleBarToggleIconKey =>
windowState === 'maximized' ? 'restore' : 'maximize';

View File

@@ -1,2 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -2,6 +2,7 @@
// 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>;
@@ -16,6 +17,8 @@ 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>;
@@ -58,6 +61,8 @@ export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Pr
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>;
@@ -74,6 +79,8 @@ export function DuplicateConnection(arg1:string):Promise<connection.SavedConnect
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>;
@@ -102,8 +109,12 @@ 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>;
@@ -138,11 +149,15 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.Quer
export function OpenSQLFile():Promise<connection.QueryResult>;
export function ListSQLDirectory(arg1:string):Promise<connection.QueryResult>;
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:any):Promise<connection.QueryResult>;
export function RedisDeleteKeys(arg1:connection.ConnectionConfig,arg2:Array<string>):Promise<connection.QueryResult>;
@@ -202,8 +217,14 @@ 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 SelectSQLDirectory(arg1:string):Promise<connection.QueryResult>;
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
export function SelectDataRootDirectory(arg1:string):Promise<connection.QueryResult>;
@@ -222,6 +243,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>;

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