Compare commits

...

253 Commits

Author SHA1 Message Date
Syngnat
e8ef4b824a fix(query-editor): prevent AI inline completion from trapping cursor 2026-07-07 15:10:45 +08:00
Syngnat
d87f8bf36a ♻️ perf(query-editor): 消除大库元数据下补全路径的全量扫描
延续上一轮 AI 补全卡顿修复:AI 元数据预热把整库列灌入共享缓存后,仍有三处热点会
阻塞主线程——buildQueryEditorAiContext 每次请求全量重建合并 Map、普通补全
findPreloadedColumns/relevantColumns 对全量列逐条正则扫描、ghost 可见期间
scroll/layout 每帧全量重渲染 overlay。改为:AI 上下文按依赖引用相等缓存(并保持
数组身份稳定使下游索引跨请求复用)、共享列按表名/标识符建 WeakMap 索引、
重定位用 rAF 合并。
2026-07-06 22:25:39 +08:00
Syngnat
50b4169148 🐛 fix(query-editor): 修复大库下SQL AI补全阻塞主线程导致全局卡顿
根因:全库列元数据加载后,每次内联补全在主线程对全部列做 O(列数×表数) 正则匹配,
且补全上下文被重复构建两次,8万列规模下单次请求耗时约900ms;同时 table_name 意图
每次补全都真实查库。改为按表名末段建索引(WeakMap按请求缓存)、复用已收敛上下文、
warmup 成功后会话内缓存,单次请求耗时降至约12ms。
2026-07-06 20:29:23 +08:00
Syngnat
4a63a175e4 🐛 fix(update): Windows在线更新改为覆盖当前exe并自动重启 2026-07-06 19:36:43 +08:00
Syngnat
2508b7935a 🐛 fix(update): 修复Windows在线更新复用旧安装包 2026-07-06 17:40:45 +08:00
Syngnat
22697baa37 🐛 fix(query-editor): 修复SQL AI补全误触普通补全 2026-07-06 17:26:41 +08:00
Syngnat
2fc587fd31 🐛 fix(frontend): 修复开发启动前端构建报错
- 修正 SQL 草稿快照定时器句柄类型兼容浏览器与 Node 环境
- 删除连接类型目录里重复的 trino 分支避免构建告警
- 配合可用 GOPROXY 验证 wails dev 已能完成前端编译与打包
2026-07-06 17:12:46 +08:00
Syngnat
2a3bd5bd97 🐛 fix(db): 修复Oracle同义词字段注释回填
- 元数据查不到列定义时补查 ALL_SYNONYMS 解析真实表
- 对解析出的 owner 和 table 继续回查列注释与主键信息
- 新增 SBDEV 指向 DEV.PERSON_INFO 的字段注释回归测试
2026-07-06 16:59:46 +08:00
Syngnat
ac68729734 🐛 fix(query-editor): 修复Oracle匿名块事务托管缺失
- 区分 BEGIN 事务控制语句与 Oracle 匿名块起始
- 让包含 DML 的 Oracle like 匿名块进入 SQL 编辑器托管事务
- 补充匿名块进入事务与回滚收口的后端回归测试
2026-07-06 16:59:17 +08:00
Syngnat
19c10f2369 🐛 fix(db): 修复 OceanBase Oracle 空结果并收敛提示
- OceanBase Oracle 只读查询优先走普通 Query 路径
- 补齐普通执行与托管事务两条回归测试
- 调整多语句回退提示避免误导为协议不支持
- 同步前端注释与多语言文案
2026-07-06 15:38:42 +08:00
Syngnat
8af8dd1ae0 🐛 fix(update): 修正 Windows 在线更新落盘与重启逻辑
- Windows 更新包直接下载到当前 EXE 目录
- staging 脚本目录移到临时目录避免暴露隐藏目录
- 安装时直接拉起新下载 EXE 并保留 zip 兼容分支
- 补充 Windows 更新路径回归测试
2026-07-06 15:37:56 +08:00
Syngnat
258fccd56f 🐛 fix(query-editor): 修复 OceanBase 执行误用登录Schema
- 保持 OceanBase Oracle 默认登录 schema 解析不变
- 未限定表名改写时增加登录 schema 到所选 schema 的回退候选
- 补充 SBDEVREAD/SBDEV 与 ORCLPDB1 场景回归测试

Fixes #615
2026-07-06 10:30:00 +08:00
Syngnat
93dc696e90 🐛 fix(db): 修复达梦DDL缺失字段注释 Fixes #615
- 补齐达梦列元数据查询中的字段注释读取
- 保留原始 CREATE TABLE 结果并追加 COMMENT ON COLUMN 语句
- 修正达梦列定义映射时 COMMENT 丢失的问题
- 新增达梦 DDL 注释补全回归测试
2026-07-06 08:56:41 +08:00
Syngnat
00d70d2934 🐛 fix(update): 修复 Windows 更新安装卡住并将安装包落到应用目录
- Windows 安装前预检当前安装目录写权限,避免脚本启动后主进程先退出
- 已下载更新补充打开安装目录入口,便于手动执行安装包
- 更新工作区优先改为当前应用运行目录,并补充回归测试与多语言文案
2026-07-05 20:06:51 +08:00
Syngnat
2f39767211 🐛 fix(update): 修复下载进度标题显示内部键并消除通道切换竞态
- 下载进度状态拆分 key 与 version 字段,弹窗标题恢复显示纯版本号
- SetUpdateChannel 将检查、持久化与缓存清理并入同一临界区,消除下载窗口期竞态
- 通道配置文件改为临时文件加重命名的原子写入
- 移除 useAppUpdateManager 未使用的 isMacRuntime 参数
2026-07-05 17:13:28 +08:00
Syngnat
5beb4c3ce4 feat(data-grid): 无主键表支持全列匹配编辑并移除只读限制
- 新增 all-columns 全列匹配行定位策略,无主键/唯一索引时不再强制只读
- 元数据加载失败、索引查询失败等误报场景全部降级为全列匹配编辑
- 修复 resolveQueryLocatorPlan 同步解析异常导致 SQL 结果页签不出现的问题
- 行安全由后端影响行数校验兜底,非单行命中即回滚整个事务
- 同步更新六语言文案与相关测试用例
2026-07-05 16:00:25 +08:00
Syngnat
8280f342a8 🐛 fix(mcp-server): 修复 arm64 镜像误装 amd64 二进制
- 移除 TARGETOS/TARGETARCH 的默认值,BuildKit 仅在无值声明时注入实际目标平台
- 原默认值 amd64 使 arm64 镜像内二进制架构错误,运行时报 exec format error
2026-07-05 12:13:56 +08:00
Syngnat
e5baf9ead6 feat(update): 支持 latest 与 dev 更新通道切换
- 新增更新通道持久化与按通道检查 GitHub Release
- 关于页支持切换通道并隔离本地更新缓存状态
- 补充多语言文案、Wails 绑定与前后端测试覆盖
2026-07-05 12:08:45 +08:00
Syngnat
06c4222aff ️ perf(ci): Docker 镜像改为原生 runner 分平台构建
- 构建矩阵拆分为 2 镜像 × 2 平台,arm64 使用 ubuntu-24.04-arm 原生 runner,移除 QEMU 模拟
- 各平台独立冒烟测试后以 digest 推送,merge job 合成多平台 manifest 并打标签
- 构建缓存 scope 按镜像-平台划分,避免跨平台缓存混用
2026-07-05 12:03:57 +08:00
Syngnat
f57f9913d5 🐛 fix(ci): 修复 build-env 镜像 npm 符号链接被 COPY 解引用
- 移除对 npm/npx/corepack 的直接 COPY,改为拷贝 node_modules 后重建符号链接
- COPY 会解引用符号链接,导致脚本内相对路径 require 解析失败
2026-07-05 11:40:14 +08:00
Syngnat
579891a53a 🐛 fix(ci): 修复 build-env 镜像 login shell 下 Go 工具链缺失
- 新增 /etc/profile.d 脚本,在 login shell 重置 PATH 后恢复 Go 工具链路径
- 修正 ENV PATH 中无效的 /root/go/bin 为 GOPATH 实际路径 /go/bin
2026-07-05 11:19:56 +08:00
Syngnat
061f78b897 🐛 fix(ci): 修复 mcp-server 本地 replace 模块分层构建
- 在 go mod download 前补拷贝 third_party/highgo-pq 与 third_party/go-irisnative 的 go.mod 依赖信息\n- 保留 Docker layer 缓存,同时兼容本地 replace 模块的依赖解析\n
2026-07-05 11:00:23 +08:00
Syngnat
6671915cf7 🐛 fix(ci): 修复 Docker 镜像构建基础环境
- 将 mcp-server 构建镜像升级到 Go 1.25,匹配当前 go.mod 版本要求\n- 调整 build-env 的 Node 资产复制方式,避免覆盖 Go 工具链\n- 显式补齐 /usr/local/go/bin 到 PATH,修复 smoke test 中 go 不可用\n
2026-07-05 10:57:07 +08:00
Syngnat
0cfcfa77d3 feat(mcp-server): 新增容器化部署支持
- 新增 MCP Server 的 Dockerfile、Compose 环境示例与 GHCR 镜像流水线\n- 补充 Podman Quadlet、Kubernetes Kustomize 与 Helm Chart 部署样例\n- 完善 MCP Server 独立 README,补充本地与远端 Agent 接入说明\n\nFixes #618
2026-07-04 21:57:21 +08:00
Syngnat
9e6a56a77b 📝 docs(readme): 补充 AI MCP 与数据源能力说明
- 补全中英文主 README 的 AI 与 MCP 工作流说明\n- 新增安全边界说明,覆盖 schema-only 与 allowMutating 约束\n- 补齐向量库、消息队列、国产数据库及时序库等数据源清单\n\nRefs #618
2026-07-04 21:56:10 +08:00
Syngnat
714c6fc553 feat(web-server): 新增浏览器访问认证与初始化流程
- 新增 web-server 运行模式与浏览器端运行时桥接
- 支持管理员密码、会话策略、Google Authenticator 与恢复码
- 设置中心补充浏览器访问认证状态与改密入口
- 优化初始化向导,未启用 2FA 时跳过验证器步骤

Refs #618
2026-07-04 21:50:52 +08:00
Syngnat
d7e15f5eb0 feat(release): 补齐 Linux arm64 构建与驱动发布链路
- 增加 dev/release 工作流 Linux arm64 构建矩阵与驱动分流
- 补齐在线更新资产识别及 driver release manifest/asset 校验
- 兼容 Windows 本地 bash/python 解析并补充对应测试
2026-07-04 10:15:13 +08:00
Syngnat
b388b08226 feat(sql-file-execution): 外部SQL执行切换工作台并优化大文件链路
- 前端将运行外部SQL改为工作台Tab并复用导出式进度视图

- 后端新增仅选择文件元数据接口并优化流式读取与批量拼接

- 补充执行Runner、国际化文案和定向测试
2026-07-03 22:11:49 +08:00
Syngnat
3bf42a169c 🐛 fix(sidebar): 修复侧边栏拖拽顺序重启后丢失
- 保留启动恢复阶段未回填连接时的 sidebarRootOrder
- 避免 hydration 过早裁剪连接根节点排序 token
- 补充重启后连接回填场景回归测试
2026-07-03 21:39:40 +08:00
Syngnat
29ee21e9d7 feat(query-editor): 增强 SQL 编辑器 AI 内联补全与独立模型配置
- 新增独立内联补全模型配置与服务端透传
- 优化 SQL 编辑器 Alt+\\ 触发、ghost 延续与对象位补全
- 引入基于已存查询和执行日志的 SQL 记忆补全
- 补充前后端本地化、快捷键与补全回归测试
2026-07-03 20:44:33 +08:00
Syngnat
5eafd2aa3c feat(query-editor): 支持 AI 行内补全与文本生成 SQL
- 新增 SQL 编辑器 AI 辅助服务,接入已配置供应商并生成行内补全

- 增加 Text-to-SQL 弹窗与插入、替换选区、替换全文应用模式

- 补齐多语言文案和定向测试覆盖
2026-07-02 21:36:44 +08:00
Syngnat
b0f077ffbb 🐛 fix(table-designer): 优化结构设计器元数据加载性能
- 字段元数据优先加载并拆分各页签 loading 状态

- 收窄 Oracle 与达梦约束字典查询范围

- 补充结构设计器与字典查询回归测试
2026-07-02 21:15:40 +08:00
Syngnat
0fe3060cf1 🎨 style(query-editor): 优化补全候选元数据显示
- 候选行未选中时也显示元数据,避免只有当前选中项可见
- 将字段名与元数据改为紧凑排列,减少候选列表空间浪费
- 增加表备注、字段类型备注、当前库隔离和候选样式回归断言
2026-07-02 17:50:56 +08:00
Syngnat
c2eaf27c20 🐛 fix(query-editor): 修复补全元数据来源与库范围
- 限定普通表名补全只使用当前选中库,避免其他库表污染候选列表
- 表名补全补充表备注,并在懒加载时回填共享表元数据
- 字段补全详情统一展示表名、类型和备注,文档区补充库表上下文
2026-07-02 17:50:48 +08:00
Syngnat
275d490ca7 🐛 fix(sidebar): 修复 Oracle 表元数据显示缺失
- Oracle/Dameng 表元数据查询移除 ALL_SEGMENTS 依赖
- 使用 ALL_TABLES.BLOCKS 轻量估算表大小,避免权限或大库聚合失败
- 保留表注释、行数、创建时间和最后 DDL 时间展示
- 补充 Oracle-like 元数据 SQL 回归断言
2026-07-02 15:21:45 +08:00
Syngnat
0707d60f0b 🐛 fix(sidebar): 放宽左侧树拖拽宽度上限
- 统一侧栏宽度常量,最大宽度放宽到 960px
- 拖拽时按窗口宽度预留主工作区空间
- 同步 v2 与 legacy 侧栏 CSS 最大宽度
- 持久化侧栏宽度时允许保存更宽配置并补充测试
2026-07-02 15:20:59 +08:00
Syngnat
9b5e785d67 feat(sidebar): 支持表元数据拖拽排序
- 设置中心元数据项支持拖拽调整展示顺序
- 新增 sidebarTableMetadataFieldOrder 持久化并按顺序渲染
- 保持表注释旧配置迁移与元数据开关兼容
- 补充排序、持久化和侧栏渲染回归测试
2026-07-02 15:19:44 +08:00
Syngnat
69d9f41085 feat(connection): 支持生产保护默认折叠
- 为连接配置分区卡片增加可折叠渲染能力

- 生产连接保护默认收起,仅保留标题说明和当前策略标签

- 补充中英文折叠态与展开态回归测试
2026-07-02 14:12:47 +08:00
Syngnat
ffb4d16950 🐛 fix(definition-viewer): 修复对象修改保留完整定义
- 统一对象查看页定义展示,补齐可复制的 CREATE OR REPLACE DDL

- 打开对象修改前清理同标签旧草稿,避免旧草稿覆盖完整注释

- 补充 Oracle 函数、过程和包定义的回归测试
2026-07-02 14:12:22 +08:00
Syngnat
68d4081fcf feat(sidebar): 支持表元数据显示配置
- 新增侧栏表元数据字段配置并兼容旧版表备注开关
- 设置中心新增侧栏表元数据入口并移除树上重复显示开关
- 批量补齐表大小、创建时间、修改时间等元数据加载与渲染
- 统一表元数据格式化逻辑并修复 V2 左树横向滚动裁切
- 补充设置中心、store、侧栏元数据相关回归测试与多语言文案
2026-07-02 12:54:43 +08:00
Syngnat
92eb4266da 🐛 fix(query-editor): 统一对象查看与修改字号
- object-edit 查询态补齐与查看页一致的 Monaco 字号和行高配置
- 统一对象查看与修改的行号宽度,避免视觉尺寸不一致
- 补充 QueryEditor 与 DefinitionViewer Monaco 选项回归测试
2026-07-02 10:47:08 +08:00
Syngnat
b220dd051f 🐛 fix(definition-viewer): 复用对象修改原标签页
- 查看页对象修改直接复用当前标签切换到 object-edit 编辑态
- 侧边栏视图和函数编辑入口统一补齐 queryMode 标记
- 补充 DefinitionViewer 与 store 回归测试锁定标签复用行为
2026-07-02 10:45:59 +08:00
Syngnat
e466e87fcc 🐛 fix(sidebar): 修正树节点加载与展开时序 2026-07-02 09:40:09 +08:00
Syngnat
0428a30071 🐛 fix(data-viewer): 延迟对象设计页的数据预览加载 2026-07-02 09:39:52 +08:00
Syngnat
faaf7169da 🐛 fix(query-editor): 修复 SQL 片段补全与插入交互
- 插入 SQL 片段后主动关闭片段弹窗,保持右键插入行为一致
- 提升 Monaco 补全详情区最小高度,放大片段说明展示区域
- 列补全解析改为基于当前语句引用上下文,修复当前语句前半段补全丢列问题
- 为 SQL 草稿持久化补齐 window 定时器兜底,避免测试环境 clearTimeout 缺失
- 更新 SQL 片段弹窗关闭和补全说明高度相关回归测试
2026-07-01 18:11:41 +08:00
Syngnat
04a61a50dc 🐛 fix(workbench): 统一 v2 工具中心与工作区主题
- 显式传递 uiVersion 构建 overlay theme,避免设置中心和工具中心混用旧版黄蓝色
- 统一 v2 模式下 Ant Design 主色和选中态为绿色语义
- 移除工具中心详情头部重复的返回上一步按钮
- 修复侧边树对象筛选反复切换时的 rc-tree 复用漂移
- 调整 v2 左侧树横向滚动条与首页空态布局,释放工作区展示空间
- 补充工具中心、主题 token 和侧边树 remount 的回归测试
2026-07-01 18:11:08 +08:00
Syngnat
749e543e37 🐛 fix(query-editor): 修复 SQL 片段插入与弹窗交互
- 在 QueryEditor 右键菜单中稳定注册插入 SQL 片段动作,并随语言切换刷新文案,避免入口偶发消失
- SQL 片段选择器改为统一的可拖拽可缩放弹窗,优先直连 Monaco snippetController 插入并保留回退链路
- 选中片段后保持弹窗打开以便连续插入,同时补充入口注册、延迟 onMount 与多语言文案回归测试
2026-07-01 11:03:48 +08:00
Syngnat
d94335dc62 feat(query-editor): 支持自定义新建查询默认 SQL
- 在 appearance 持久化设置中新增 newQuerySqlTemplate,支持默认回退与空白模板
- 新建空白查询标签页按用户模板初始化,不影响外部 SQL、保存查询和只读标签
- 在工具中心补充模板编辑与恢复默认入口,并完善 store、编辑器和 i18n 回归测试
2026-07-01 09:44:22 +08:00
Syngnat
e135956eba 🐛 fix(definition-viewer): 修复对象定义页滚动行号重叠
- 在 DefinitionViewer 只读 Monaco 实例上显式关闭 stickyScroll,避免滚动后出现悬浮代码行与行号叠层
- 同步关闭 TriggerViewer 的 stickyScroll,统一对象定义类只读查看器滚动表现
- 新增对象定义查看器 Monaco 选项回归测试,锁定只读定义页必须禁用 stickyScroll
2026-07-01 09:17:54 +08:00
Syngnat
66b14766a3 🐛 fix(query-editor): 修复补全范围与光标执行
- 仅按当前语句与光标前范围构建别名和表引用,避免跨语句泄露其他库表字段补全
- 在新语句起始关键字场景优先提升 SQL 关键字候选,避免关键字被字段建议压到后面
- Ctrl/Cmd+Enter 无选中内容时回退执行光标所在语句,不再误报没有可选择的 SQL 语句
- 补充补全排序、列范围和光标末尾执行的回归测试
2026-07-01 09:17:33 +08:00
Syngnat
923e789211 🐛 fix(elasticsearch): 修复 ES SQL 末尾分号导致空结果 Fixes #605
- 清理 parseESSQL 解析出的 WHERE 和 ORDER BY 子句尾部分号
- 避免 range 条件把数值误当成字符串传给 Elasticsearch
- 补充带分号的 ES SQL 端到端与解析回归测试
2026-06-30 22:28:17 +08:00
Syngnat
52eacdd9df 🐛 fix(sidebar): 修复表分组折叠后二次展开消失 Fixes #606
- 限制侧边栏折叠时的子树释放范围,只清理可重新懒加载的 connection/database 节点

- 保留 tables object-group 的已加载子节点,避免二次点击展开后表列表被清空

- 补充针对大表分组折叠场景的回归测试
2026-06-30 21:46:13 +08:00
Syngnat
a9bae6bb3f 🐛 fix(explorer): 补齐 Redis 部分导入与库节点加载态
- 新增 Redis 导入预览与按 key 勾选导入能力

- 后端支持导入文件预读、选中 key 过滤与本地化错误提示

- 打开数据库节点时同步展示加载态并补齐前后端回归测试

Fixes #607

Fixes #609
2026-06-30 21:17:00 +08:00
Syngnat
b0d8ccbf23 🐛 fix(definition-viewer): 修复对象定义页行号重影 2026-06-30 20:31:44 +08:00
Syngnat
4b18075314 🐛 fix(db): 修复达梦读查询结果空页
- 统一按解析后的驱动类型判断只读查询结果策略

- 达梦及 dm/dm8 自定义驱动只读查询优先走普通 Query

- 补充事务执行路径与达梦结果集回归测试
2026-06-30 20:06:57 +08:00
Syngnat
784d7d60e2 🐛 fix(query-editor): 修复Monaco搜索框与悬浮提示遮挡
- 移除 QueryEditor 中临时的 find widget DOM 监听与 offset zone 逻辑
- 为 V2 查询编辑器的 Monaco stage 增加搜索框可见态顶部安全区并放开必要溢出
- 调整分栏高度计算与回归测试,确保搜索框间距只作用于 V2 query-editor
2026-06-30 17:25:48 +08:00
Syngnat
ece1119c31 feat(query-editor): 增强行级快捷键并优化搜索框
- 接管 Cmd/Ctrl+E,选择当前行并复制,避免落到宿主搜索
- 新增 Cmd/Ctrl+D 复制当前行到下一行,并同步快捷键冲突校验
- 增加 macOS SQL 菜单桥接,确保原生 Cmd+E 回落到编辑器
- 保持 Monaco 内置搜索并下移,避免遮挡 SQL 第一行
- 补齐多语言文案与 QueryEditor/shortcut/main 回归测试
2026-06-30 15:25:01 +08:00
Syngnat
b9ffcd2959 🐛 fix(sidebar): 支持调节V2左侧导航按钮大小 2026-06-29 21:25:13 +08:00
Syngnat
d900bda756 test(sqlserver): 补强索引预览与DDL回归覆盖
Fixes #379
2026-06-29 20:47:01 +08:00
Syngnat
8ac435dc6d 🐛 fix(sidebar): 修复达梦表分组计数丢失
Fixes #412
2026-06-29 20:43:15 +08:00
Syngnat
99942e37d6 feat(database-ui): 补充表概览元数据并调整数据库菜单
Fixes #395
2026-06-29 20:35:59 +08:00
Syngnat
d2a535e6ff 🐛 fix(mysql): 补充 PolarDB-X 数据库列表回退查询
Fixes #455
2026-06-29 20:25:30 +08:00
Syngnat
61c2e524b4 feat(redis-viewer): 新增加载全部 Key 入口
Fixes #603
2026-06-29 20:17:01 +08:00
Syngnat
b434247838 🐛 fix(ddl): 补齐 kingbase 表DDL索引输出
- 在 SHOW CREATE TABLE 不可用时,fallback 额外拉取索引元数据并生成 CREATE INDEX 语句\n- 跳过与主键重复的索引,避免在 fallback DDL 中重复输出主键索引\n- 补充 kingbase DDL fallback 的索引回归测试\n\nFixes #601
2026-06-29 20:09:01 +08:00
Syngnat
b3f69ac401 🐛 fix(sidebar): 调整暗黑模式横向滚动条样式
- 为 v2 侧栏树横向虚拟滚动条补充暗色轨道与边界样式\n- 保持滚动条尺寸与 thumb 交互不变,仅修正暗黑模式视觉协调性\n- 补充侧栏主题样式断言覆盖\n\nFixes #602
2026-06-29 20:04:17 +08:00
Syngnat
93006d8423 feat(query-editor): 新增编辑器内搜索入口
- 在查询编辑器工具栏新增搜索入口并直连 Monaco 查找\n- 接管 Ctrl/Cmd+F,优先打开编辑器内搜索而不是浏览器默认查找\n- 补充多语言文案与静态测试覆盖\n\nFixes #539
2026-06-29 20:00:27 +08:00
Syngnat
a7fdab5bb2 feat(theme): 新增跟随系统主题模式
- 增加主题偏好持久化并兼容旧版主题状态恢复
- 监听系统深浅色变化并同步窗口与应用主题
- 补齐主题设置文案与持久化相关测试

Fixes #407
2026-06-29 19:52:22 +08:00
Syngnat
bedd1eceb5 🐛 fix(query-editor): 修复异常退出后SQL草稿丢失
- 为查询页签增加 crash-recovery 草稿快照并按 160ms 落盘
- 启动恢复时补齐缺失的 query tabs 与连接上下文
- 保存或关闭查询后清理临时草稿并补充恢复回归测试
2026-06-29 19:21:37 +08:00
Syngnat
dac9e1f571 🐛 fix(sidebar): 恢复批量库表危险操作
- 恢复批量表和批量库按钮到原批量操作弹窗\n- 补回批量删除表和删除库的危险确认与执行逻辑\n- 删除表仅处理 table 对象,避免视图误走 DropTable\n- 补齐多语言文案与入口/删除回归测试
2026-06-29 17:09:01 +08:00
Syngnat
aed87665f4 🐛 fix(query-results): 区分执行消息内容区域
- 为执行消息正文滚动区增加独立边框和浅背景
- 保留标题与复制按钮在顶部区域,正文内容单独成框
- 补充消息块样式断言,覆盖边框、背景和内边距
2026-06-29 15:18:35 +08:00
Syngnat
04bbab3d7e 🐛 fix(query-results): 修复金仓带模式表主键识别
- PG 类数据库保留 schema.table 作为查询结果表名和元数据表名
- 元数据连接继续使用当前数据库,避免把 schema 误当 database
- 补充金仓列、索引和前端可编辑定位回归测试
2026-06-29 15:18:13 +08:00
Syngnat
4798d3e8ec 🐛 fix(db): 修复金仓读查询结果空页
- 调整 PG 类数据库只读查询路径,优先使用普通 Query 返回真实结果集
- 避免金仓原生多结果集返回空行列时吞掉可展示数据
- 补充金仓读查询与原生空结果回退测试覆盖
2026-06-29 15:17:47 +08:00
Syngnat
c940e930bc feat(connection): 支持导入Navicat NCX连接 2026-06-29 14:16:17 +08:00
Syngnat
8280306f82 🔧 chore(wails): 同步数据库对象前端绑定 2026-06-29 13:45:34 +08:00
Syngnat
59e12b0867 🐛 fix(ui): 修复危险主按钮文字对比度 2026-06-29 13:38:07 +08:00
Syngnat
ded531a3c2 🐛 fix(sql-editor): 修复新建查询关闭应用未提示保存 2026-06-29 13:02:23 +08:00
Syngnat
3a471bc1aa 🐛 fix(mcp): 修复执行SQL结果不可见
- execute_sql 文本 Content 输出可读的 SQL 执行摘要和 Markdown 结果表\n- 保留 structuredContent 结构化结果,兼容机器解析客户端\n- 新增 in-memory MCP 调用回归测试,覆盖 select 1 可见结果
2026-06-29 12:32:07 +08:00
Syngnat
ea4f88a20d feat(sql-editor): 关闭应用时提示保存未保存SQL
- 关闭应用前拦截 Wails 退出事件,前端弹出确认退出、保存退出和取消三按钮\n- 保存退出支持外部 SQL 文件和已保存查询,未命名临时查询保留草稿恢复语义\n- 补充退出保护后端测试、前端保存目标测试和多语言文案
2026-06-29 11:16:55 +08:00
Syngnat
8e857b9aee 🐛 fix(query-results): 修复多结果集回退空结果页 2026-06-29 11:09:40 +08:00
Syngnat
f55a332ead feat(mcp): 扩展数据库对象结构读取工具
- 新增 get_views/get_objects 工具并让 get_tables 附带视图列表
- 后端统一返回表、视图、触发器、函数、过程、序列、包、事件和队列类对象
- 补充 MCP 服务测试、对象解析测试、远程 schema-only 文档和多语言文案
2026-06-29 10:25:37 +08:00
Syngnat
893a0f70fb 🐛 fix(query-results): 修复原生空结果集导致结果页缺失
- 后端对只读查询的原生多结果集空列空行结果增加逐条执行回退
- 前端执行成功后显式合并结果集并激活最新结果 tab
- 新增空原生结果集对象的回归测试,避免 SELECT 成功但结果页缺失
2026-06-29 10:23:58 +08:00
Syngnat
fdb05fb8d3 🐛 fix(i18n): 修复系统语言跟随不实时更新
Refs #335
2026-06-28 20:15:56 +08:00
Syngnat
730d4bb1ab 🐛 fix(tabs): 修复对象编辑页关闭后未回到来源标签
- 为 SQL 超链接打开的对象标签记录来源标签

- 关闭当前对象标签时优先回到来源标签

- 补充表对象与对象编辑标签关闭回退测试
2026-06-28 20:03:15 +08:00
Syngnat
fee703e879 🐛 fix(sqlserver): 修复消息循环缺失时吞掉查询结果
- 在 SQL Server 消息循环未产出结果集时兜底扫描 rows

- 保留 SELECT 返回的行数据和空结果列元数据

- 补充 SQL Server fallback 扫描回归测试
2026-06-28 16:41:12 +08:00
Syngnat
4f2f7003c8 ️ perf(ai-chat): 降低流式思考输出渲染开销
- 合并 AI 流式 token 刷新,减少高频状态写入

- 避免纯流式更新重排会话列表,并收窄当前会话订阅

- 补充 thinking 合并刷新和会话列表稳定性回归测试
2026-06-28 16:03:22 +08:00
Syngnat
97540206b3 feat(query-editor): 新增 SQL 美化快捷键 2026-06-28 12:08:40 +08:00
Syngnat
793e078676 🐛 fix(ci): 修复 Doris driver-agent revision 校验失败
- 统一 release/dev 构建流程中的 Doris/diros 驱动归一
- 保持 Doris 资产名为 doris,构建 tag 使用 gonavi_diros_driver
- 优化 driver-agent revision 校验失败日志,避免输出 JSON 解析 traceback
2026-06-28 08:35:05 +08:00
Syngnat
7453600ee5 feat(data-grid): 支持数据视图列头筛选 #490
- 列头筛选复用工具栏筛选状态,应用后同步条件但不自动打开筛选面板

- 修复列头筛选应用、清除和操作符下拉交互

- 补充筛选状态同步、主键查询和列头交互回归测试
2026-06-27 22:58:10 +08:00
Syngnat
6c53ef4eff feat(query-editor): 记住查询编辑区结果区比例
- 持久化查询编辑器与结果面板的分割比例
- 新开查询 Tab 时按已保存比例恢复编辑区高度
- 补充分割比例计算与 QueryEditor 回归测试

Fixes #538
2026-06-27 18:21:24 +08:00
Syngnat
60eb696859 🐛 fix(sidebar): 修复事件定义编辑入口 #540
- 事件右键编辑定义改为打开 object-edit 查询

- 拉取 CREATE EVENT 作为可编辑 SQL,避免只显示 SHOW CREATE EVENT

- 补充事件定义编辑与菜单入口回归测试

Fixes #540
2026-06-27 17:58:54 +08:00
Syngnat
e456925c23 feat(sidebar): 优化表备注悬浮信息展示
- 读取不同数据源表备注并写入左侧表节点
- 支持表分组菜单切换备注显示
- 表节点悬浮复用 Tab 信息卡并移除原生双提示

Fixes #569
2026-06-27 17:43:11 +08:00
Syngnat
038ecc8b70 🐛 fix(export): 兼容换行 SELECT 查询导出
- SELECT/WITH 只读判断支持关键字后换行
- 覆盖多表 JOIN 查询结果导出场景

Fixes #583
2026-06-27 16:41:35 +08:00
Syngnat
3bfcd15dc0 🐛 fix(data-grid): 允许未知总数结果跳页
- 未知总数分页保留跳页控件
- 跳页输入不再被当前估算页数截断

Fixes #584
2026-06-27 16:41:12 +08:00
Syngnat
6abdb8684f 🐛 fix(sidebar): 折叠海量对象树时释放子节点
- 折叠海量数据库/对象分组时清理子树缓存和后代展开态
- 避免虚拟树在大批量函数、过程等对象折叠后残留渲染

Fixes #587
2026-06-27 11:37:55 +08:00
Syngnat
080ae0986a 🐛 fix(window): 适配分辨率变化后的窗口边界
- 运行期检测普通窗口是否超出当前可用屏幕并自动收回
- 启动恢复时同步裁剪超过当前屏幕的窗口尺寸

Fixes #594
2026-06-27 11:17:42 +08:00
Syngnat
bfb61c8449 feat(redis): 优化数据库别名与计数展示
- Redis DB 标题不再重复拼接 key 数量
- 将别名单独渲染为浅色文本并支持设置后即时刷新

Fixes #593
2026-06-27 10:58:24 +08:00
Syngnat
23d7511f55 🐛 fix(sidebar): 单击打开数据库对象节点
- 支持视图、函数、触发器等对象单击直接打开定义 tab
- 覆盖查询日志打开后 CK/SQLite 查询结果仍切回数据结果

Fixes #595
2026-06-27 10:51:50 +08:00
Syngnat
2d73bcc6de 🐛 fix(query-editor): 跳过TDengine托管事务
Fixes #592
2026-06-27 10:41:28 +08:00
Syngnat
a24f4a2bc1 🐛 fix(query-editor): 限制快捷键仅执行选中SQL
Fixes #596
2026-06-27 10:36:46 +08:00
Syngnat
bf51003c66 🐛 fix(query-results): 修复执行消息面板布局异常 2026-06-27 10:21:39 +08:00
Syngnat
2df9dce78b 🐛 fix(query-results): 优化SQL执行消息展示
- 执行消息拆分标题栏和滚动正文区域,避免 SQL 文本被遮挡

- 消息正文关闭自动换行并保留原始空白,支持横向滚动查看完整 SQL

- 补充 SQLServer 消息展示和结果面板结构回归断言
2026-06-26 17:29:03 +08:00
Syngnat
3ed45fab41 🐛 fix(sidebar): 修复表默认打开行为与左树滚动
- 工具设置新增双击表名行为开关,默认打开表数据,可切换到对象设计

- 左侧树、表概览卡片视图和列表视图统一按开关打开数据页内对象设计

- 调整 V2 左侧树横向滚动条布局,使滚动条保持在底部可见
2026-06-26 17:28:49 +08:00
Syngnat
7c9cf95698 🐛 fix(query-editor): 修复对象跳转与存储过程补全
- 对象超链接支持表跳转到数据页内对象设计,并兼容 Ctrl/Cmd 修饰键状态丢失场景

- MySQL 补全增加 CALL 关键字和存储过程/函数名称提示,支持 CALL routine 跳转编辑

- DataGrid 支持按 tab 请求初始切换到对象设计视图,补充相关回归测试
2026-06-26 17:28:09 +08:00
Syngnat
af2c358822 🐛 fix(sidebar): 修复左侧树长名称滚动仍截断
- 调整侧栏树节点内容宽度,避免标题层继续被 ellipsis 截断

- 同步 V2 树标题和标签样式,横向滚动后可查看完整名称

- 补充样式回归测试,覆盖节点内容、标题和标签完整宽度
2026-06-26 15:43:43 +08:00
Syngnat
1226e544ec 🐛 fix(data-grid): 修复文本模式字段注释显示
- 文本记录视图接入列元数据与字段显示开关
- 字段名下方同步显示类型和注释,保持与表格列头设置一致
- DataGridShell 将查询结果列元数据传入文本视图
- 补充文本模式显示和隐藏字段元数据回归测试
2026-06-26 10:08:04 +08:00
Syngnat
faa7ed1ae2 🐛 fix(query-editor): 修复选择器与事务提示浮层
- host 和数据库下拉选项关闭原生 title,避免和自定义 Tooltip 重复显示
- 事务模式说明默认向上弹出,并保留边界自动避让
- 移除下拉打开时强制隐藏事务说明的状态控制
- 补充工具栏和事务设置回归断言
2026-06-26 10:06:56 +08:00
Syngnat
22cdeb677b 🐛 fix(query-editor): 修复对象超链接误定位左树 2026-06-25 22:29:09 +08:00
Syngnat
b210d078d0 test(data-grid): 补充 DDL 视图回归覆盖
覆盖切表常驻、侧栏布局继承和加载态保持

覆盖活动 DDL 入口隐藏重开与侧栏拖拽预览

覆盖 DDL 文本选择时保持横向视角的交互行为
2026-06-25 21:36:27 +08:00
Syngnat
ce30de20b6 🐛 fix(data-grid): 修复 DDL 视图常驻与侧栏交互
记忆 DDL 底部/侧栏布局并支持切表常驻刷新

修复侧栏关闭、拖拽预览和首次加载布局抖动

优化 DDL 行号 gutter,并保留文本选择时的横向视角
2026-06-25 21:36:14 +08:00
Syngnat
8dde4c3c6d 🐛 fix(tool-center): 移除内嵌工具重复标题头
- 工具中心详情页统一承载入口标题和说明
- 数据同步、表结构比对和数据比对嵌入时隐藏内部 hero
- 数据目录、快捷键、连接包和安全更新嵌入时隐藏内部 Modal 标题
- 补充工具中心全 pane 标题归属回归断言
2026-06-25 18:42:53 +08:00
Syngnat
bdb60a656a 🐛 fix(sidebar): 修复 V2 当前连接新建查询入口
- 在当前连接头部增加更明显的新建查询按钮
- 使用文档图标区分连接创建入口,避免与加号按钮混淆
- 通过连接树节点上下文触发新建查询,修复点击无响应
- 补充 V2 侧边栏入口位置与图标回归断言
2026-06-25 18:42:31 +08:00
Syngnat
8a56367d02 🐛 fix(query-editor): 优化工具栏长名称提示与片段弹窗布局
- SQL 编辑器连接和数据库选择器支持悬浮延迟显示完整名称
- SQL 片段管理嵌入工具中心时隐藏重复标题
- 缩小片段操作按钮并拆分滚动区域,避免底部内容被遮挡
- 补充工具栏和片段弹窗源码级回归断言
2026-06-25 18:42:11 +08:00
Syngnat
aebe9bab54 🐛 fix(sql-log): 统一 V2 SQL 日志入口
- V2 左侧入口改为打开当前工作区内嵌 SQL 日志,legacy 继续使用底部全局面板
- 表数据页新增 sqlLog 视图并复用嵌入式 LogPanel,避免无 SQL 编辑器时无法查看日志
- 移除 V2 侧栏底部重复 SQL 日志按钮,保留慢查询入口并补充回归测试
2026-06-25 17:39:57 +08:00
Syngnat
9ab31a7614 🐛 fix(oracle): 修复存储过程斜杠分隔执行截断
- 支持 SQL*Plus 斜杠分隔符后的可选分号,避免 Oracle 过程执行出空语句
- 光标落在过程异常尾部或斜杠分隔行时,仍选择完整 PL/SQL 定义执行
- 补充前端语句选择、QueryEditor 执行和后端 split/DBQueryMulti 回归测试
2026-06-25 17:37:32 +08:00
Syngnat
f6556f25d5 🐛 fix(oracle): 修复过程CASE分割导致执行截断 2026-06-25 13:52:20 +08:00
Syngnat
16a8a763f4 🐛 fix(oracle): 接入对象跳转并修复过程修改执行
- SQL 编辑器补齐 Oracle 序列和存储包元数据、hover 提示与 Ctrl/Cmd 点击跳转
- 对象编辑 SQL 保留 SQLPlus 斜杠分隔符,避免生成 /; 导致 ORA-00900
- 补充导航、对象编辑执行和多语言目录回归测试
2026-06-25 11:36:24 +08:00
Syngnat
4b1cd1b727 🐛 fix(oracle): 修复触发器脚本显示为空
- 触发器列表查询补齐 OWNER、TABLE_NAME、TRIGGER_BODY 等 Oracle 元数据字段
- 优先使用 DBMS_METADATA.GET_DDL 返回完整 CREATE TRIGGER 脚本
- 在 DDL 不可用时基于 USER_TRIGGERS/ALL_TRIGGERS 重建可编辑触发器语句
- 补充 Oracle 触发器 DDL 获取与回退重建回归测试
2026-06-25 10:50:07 +08:00
Syngnat
37ccaf7743 🐛 fix(oracle): 修复存储包定义截断与斜杠误执行
- 对象树支持序列和存储包双击及右键查看定义
- Oracle package 同时读取 PACKAGE 与 PACKAGE BODY 并生成可编辑 SQL
- SQL 拆分器跳过 SQL*Plus 独立斜杠和注释分隔符
- 补充前后端 PL/SQL 拆分与定义查看回归测试
2026-06-25 10:12:28 +08:00
Syngnat
06a984f39d 🐛 fix(elasticsearch): 修复 ES SQL 分号结尾查询无结果
- 兼容 SELECT FROM 索引名后直接接分号的查询语句
- 避免解析失败后退回全索引 query_string 导致空结果
- 补充 ES SQL 转 _search 的回归测试

Fixes #590
2026-06-24 23:36:37 +08:00
Syngnat
6d1c034052 🐛 fix(sidebar): 修复长表名横向滚动截断
- 取消 V2 对象树等宽标签自截断,支持横向滚动展示完整名称

- 提高对象树横向滚动宽度上限并补充回归测试
2026-06-24 22:42:04 +08:00
Syngnat
672d05d124 🐛 fix(sidebar): 恢复连接加载中的转圈状态
- 扩展连接状态为 loading/success/error

- 在连接根节点加载开始时显示 pending,成功后才置绿

- 同步 V2 active host 与 legacy Badge 状态显示并补测试
2026-06-24 22:31:13 +08:00
Syngnat
322d0a7cb8 feat(sidebar): 支持 Oracle 序列和存储包对象树
- 新增 Oracle/Dameng 序列与存储包元数据加载

- 同步对象树节点、V2 筛选、搜索、复制和拖拽支持

- 补充多语言文案和侧边栏回归测试
2026-06-24 22:26:39 +08:00
Syngnat
5725e78931 🐛 fix(mcp): 修复外部 MCP 读取连接不完整
- 启动时自动同步旧本地连接到后端安全仓库

- 启动 HTTP MCP 或安装外部 MCP 前执行连接同步

- 增加安全配置同步回归测试

Fixes #591
2026-06-24 21:37:52 +08:00
Syngnat
69b6072e37 🐛 fix(query-editor): 修复 OceanBase Oracle 查询默认 schema 误判
- 优先使用当前选中的 Oracle-like schema 解析未限定表名

- 为 OceanBase Oracle 只读账号查询补齐业务 schema 限定

- 增加回归用例覆盖登录用户与选中 schema 不一致场景
2026-06-24 21:25:22 +08:00
Syngnat
1a9d417c0a 🐛 fix(query-editor): 修复过程脚本斜杠分隔符误执行
- 前端语句选择跳过独立 SQL*Plus 斜杠分隔符

- 后端 SQL 拆分和流式文件执行保持过程体完整

- 增加 Oracle 过程脚本执行回归测试
2026-06-24 21:10:59 +08:00
Syngnat
8a552c4cb3 🐛 fix(query-editor): 优化事务下拉显示与浮层交互
- 自动提交延迟选项改为多语言完整文案,避免短标签语义不清
- 收窄并校准 V2 工具栏事务下拉宽度,兼顾不截断与紧凑布局
- 事务模式 Select 展开时自动隐藏 DBeaver 参考 Tooltip,避免浮层互相遮挡
- 补充事务设置行为测试和布局守护测试
2026-06-24 17:50:57 +08:00
Syngnat
9da9a36cf3 🐛 fix(snippet): 修复SQL片段入口与弹窗布局
- 将 SQL 编辑器代码片段管理入口改为打开工具中心 SQL 片段面板
- 关闭旧独立片段弹窗入口,避免同一功能出现两个入口形态
- 限制片段管理弹窗内容区高度并固定底部操作行,避免按钮被语法参考内容挤出
2026-06-24 17:49:48 +08:00
Syngnat
d08ab62f92 🐛 fix(data-grid): 修复刷新后未提交修改被清空 2026-06-24 15:56:07 +08:00
辣条
1cb112bcdf 1. 收口多语言能力 2. 修复查询编辑器、侧边栏、DDL 展示等业务问题 (#589)
## 主要改动

### 1. 多语言能力收口
- 收口 6 种语言下的业务文案与交互文案
- 覆盖连接驱动、查询编辑器、导出流程、部分 AI 工具面板等场景
- 补齐多语言回归测试,减少语言切换后文案残留中文或不同步的问题

### 2. 查询编辑器相关修复
- 修复新建查询标签页在语言切换后标题不同步的问题
- 修复查询结果集不展示、总数长期停留在“正在统计中”的问题
- 修复有限分页场景下仍可继续翻到不存在页码并展示异常数据的问题
- 修复 SQL 编辑器挂起事务内执行后续查询时,无法读到未提交修改的问题
- 修复 SQL Server 单条查询尾随 `affectedRows` 结果被误渲染为额外结果页签的问题
- 补齐 SQL Server 多结果集归属与过滤逻辑,避免冗余结果干扰展示

### 3. 侧边栏与视图相关修复
- 修复数据库连接下视图节点展开/滚动后渲染错乱、内容铺满侧栏的问题
- 修复收起视图栏后页面仍残留视图内容的问题
- 修复视图元数据重复导致的侧边栏树结构异常问题
- 补齐相关测试桩与回归测试

### 4. Oracle / DDL 相关修复
- 修复 Oracle 建表 DDL 追加注释时缺少语句结束分号与空行分隔的问题
- 避免 `CREATE TABLE` 与 `COMMENT ON TABLE/COLUMN` 粘连,提升复制后可直接执行的稳定性

### 5. 其他界面修复
- 修复语言设置面板选项显示截断问题
- 收口部分 dev 合并后的多语言遗漏与提示文案不一致问题

## 验证情况

已完成的本地验证包括:

- 前端定向测试
- `npm test -- QueryEditor.external-sql-save.test.tsx
sqlEditorTransaction.test.ts`
- `npm test -- QueryEditor.results-and-drop.test.tsx
QueryEditor.external-sql-save.test.tsx sqlEditorTransaction.test.ts
useSqlEditorTransactionController.test.tsx`
- `npm test -- QueryEditor.results-and-drop.test.tsx ddlFormat.test.ts`

- 后端定向测试
- `go test ./internal/app -run
'TestDBQueryMultiTransactionalKeepsDMLTransactionOpenUntilCommit|TestDBQueryMultiInTransactionReusesPendingManagedSessionForReadQueries'`
- `go test ./internal/db -run 'Test.*GetCreateStatement|Test.*Oracle.*'`

## 已知说明

- `internal/app` 下部分 SQL Server 相关定向测试在当前机器上受“SQL Server 纯 Go
驱动未启用”环境限制,失败原因为本地驱动依赖,不是本次改动引入的问题
- 本 PR 改动面较大,建议合并前重点关注:
  - 多语言切换后的查询编辑器与侧边栏行为
  - SQL Server 结果集展示
  - Oracle DDL 预览与复制执行
  - 挂起事务中的查询结果一致性

## 风险点

- 查询编辑器结果集合并、分页、事务复用逻辑改动较集中
- 多语言文案收口涉及范围较广,建议合并后做一次核心路径冒烟验证
2026-06-24 14:15:31 +08:00
Syngnat
2fb4851d1f 🐛 fix(query-editor): 补齐SQL Server冗余结果集过滤
- 为 SQL Server 原生多结果集缺失 statementIndex 的场景补齐结果归属
- 修复单条 SELECT 查询尾随 affectedRows 结果被误渲染为额外结果页签的问题
- 补充前后端回归测试并保持存储过程多结果集展示不变
2026-06-24 13:58:12 +08:00
Syngnat
32c3ba017e 🐛 fix(oracle): 修复DDL注释拼接缺少分号与换行
- 为 Oracle 建表 DDL 追加注释前补齐语句结束符并保留空行分隔
- 修复 COMMENT ON TABLE/COLUMN 与 CREATE TABLE 粘连导致复制后无法直接执行的问题
- 补充后端 Oracle 建表语句与前端 DDL 格式化回归测试
2026-06-24 13:57:49 +08:00
Syngnat
d09b968cc0 🐛 fix(query-editor): 补齐SQL Server冗余结果集过滤
- 为 SQL Server 原生多结果集缺失 statementIndex 的场景补齐结果归属
- 修复单条 SELECT 查询尾随 affectedRows 结果被误渲染为额外结果页签的问题
- 补充前后端回归测试并保持存储过程多结果集展示不变
2026-06-24 11:57:19 +08:00
Syngnat
90d84da849 🐛 fix(oracle): 修复DDL注释拼接缺少分号与换行
- 为 Oracle 建表 DDL 追加注释前补齐语句结束符并保留空行分隔
- 修复 COMMENT ON TABLE/COLUMN 与 CREATE TABLE 粘连导致复制后无法直接执行的问题
- 补充后端 Oracle 建表语句与前端 DDL 格式化回归测试
2026-06-24 11:40:18 +08:00
tianqijiuyun-latiao
d2c4160c6d 🐛 fix(query-editor): 修复挂起事务内查询结果不一致 2026-06-24 11:37:34 +08:00
tianqijiuyun-latiao
2b4beae9df test(i18n): 校准多语言守卫测试源码引用位置 2026-06-24 11:17:43 +08:00
tianqijiuyun-latiao
7dab6f2e33 🐛 fix(query-editor): 修复结果集阻塞与未知总数分页误导
- 为查询结果定位元数据探测增加软超时降级,避免租户元数据卡死阻塞主查询结果渲染
- 将未知总数分页切换为顺序翻页模式,并修正文案仅在真实统计时显示正在统计中
- 补充查询结果与分页回归测试,覆盖元数据超时和 legacy 未知总数分页场景
2026-06-24 10:47:37 +08:00
tianqijiuyun-latiao
1aab48783d 🐛 fix(query-tab): 修复新建查询标签页语言切换不同步
- 提取共享的新建查询默认标题识别逻辑
- 按当前语言重算默认查询和数据库作用域查询标题
- 补充跨语言标题回归测试
2026-06-24 10:26:05 +08:00
tianqijiuyun-latiao
5089e6b294 test(sidebar): 补齐视图元数据加载测试桩返回字段 2026-06-24 10:17:05 +08:00
tianqijiuyun-latiao
3be08cb6cd 🐛 fix(sidebar): 修复视图元数据重复导致侧边栏树渲染错乱
- MySQL 视图元数据缺失 schema 时回填当前数据库
- 避免多条回退查询生成重复视图节点
- 补充侧边栏视图去重回归测试
2026-06-24 10:14:22 +08:00
tianqijiuyun-latiao
6c4833dcd1 🐛 fix(settings): 修复语言设置面板选项显示截断 2026-06-24 10:07:52 +08:00
tianqijiuyun-latiao
ce17629a8d Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n 2026-06-24 08:08:53 +08:00
Syngnat
5e15c4dd2d 🐛 fix(query-editor): 隐藏 SQL Server 冗余影响行数结果集 2026-06-24 00:22:31 +08:00
tianqijiuyun-latiao
c04d82ee30 Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n
# Conflicts:
#	frontend/src/components/QueryEditor.results-and-drop.test.tsx
2026-06-23 23:58:25 +08:00
tianqijiuyun-latiao
7962aea008 🐛 test(query-editor): 修复 SQL Server 消息结果断言 2026-06-23 23:44:06 +08:00
Syngnat
5493b62bb9 🐛 fix(query-editor): 修复 SQL Server 结果消息缩进并校正回归测试 2026-06-23 23:42:30 +08:00
tianqijiuyun-latiao
86c39ac36f Merge branch 'dev' into feature/20260602_connection_driver_i18n 2026-06-23 23:34:16 +08:00
Syngnat
6bf05c9ed7 🐛 fix(update): 修复桌面端更新安装与重启流程
- macOS 下载完成后改为直接走安装更新链路,不再只打开安装目录
- Windows 更新脚本在重启时显式带上目标工作目录,提升安装后拉起稳定性
- 删除失效的 winget 发布工作流,并补充前端与脚本回归测试

Fixes #585
2026-06-23 22:18:07 +08:00
Syngnat
a2c1b4a7d8 🐛 fix(query-editor): 修复外部SQL快捷保存失效
- 放宽活跃 QueryEditor 在文档级快捷键目标下的保存触发条件
- 修复桌面端 Ctrl/Cmd+S 事件落到 document 时未真正写盘的问题
- 保持普通查询保存行为不变,并补充外部 SQL 文件快捷保存回归测试
2026-06-23 20:18:06 +08:00
Syngnat
07b3b908f9 🐛 fix(mongodb): 修复编辑态字符串类型丢失
- 为 MongoDB 编辑态补充字段名驱动的保守类型推断
- 统一 DataGrid 基准数据为类型化值,覆盖 JSON、行编辑和单元格编辑
- 保持 pMid 等普通字符串字段不被误判为 ObjectId
- 补充 Mongo helper、DataViewer 主键定位与 DataGrid 提交回归测试
2026-06-23 19:48:43 +08:00
tianqijiuyun-latiao
71989af586 🐛 fix(i18n): 收口 dev 合并后的业务提示多语言遗漏 2026-06-23 18:26:20 +08:00
Syngnat
adacf0b5c5 feat(connection): 支持生产连接多项保护策略
- 新增数据编辑、结构编辑、脚本执行和数据导入四类连接级保护配置
- 升级生产连接保护弹窗为多选卡片,并修复选项对齐与勾选态显示
- 按保护类型收口 QueryEditor、DataGrid、表设计、导入与同步目标入口
- 后端统一拦截 SQL 或 Mongo 写操作、结果编辑、结构变更和导入写入
- AI 本地工具与 RPC 执行链路透传连接保护配置并复用后端守卫
- 补充多语言文案、定向测试与需求追踪记录
2026-06-23 17:42:54 +08:00
tianqijiuyun-latiao
09ae3d74c4 🐛 fix(i18n): 合并 dev 后补齐只读保护多语言 2026-06-23 17:05:35 +08:00
tianqijiuyun-latiao
839fdd7d66 Merge branch 'dev' into feature/20260602_connection_driver_i18n
# Conflicts:
#	shared/i18n/de-DE.json
#	shared/i18n/en-US.json
#	shared/i18n/ja-JP.json
#	shared/i18n/ru-RU.json
#	shared/i18n/zh-CN.json
#	shared/i18n/zh-TW.json
2026-06-23 16:23:43 +08:00
Syngnat
b0a9a995fb feat(connection): 新增生产连接只读保护 2026-06-23 15:33:11 +08:00
Syngnat
3205d131c9 🐛 fix(query-editor): 修复消息结果前缀与复制全选交互
- 统一清洗 SQL Server 消息前缀并覆盖结果刷新与分页回填链路
- 将消息结果区改为只读文本区,补充一键复制入口
- 放行编辑器外可编辑区域的 cmd/ctrl+a,避免消息内容全选被抢占
- 补充结果面板交互与国际化回归,确保构建通过
2026-06-23 14:43:13 +08:00
tianqijiuyun-latiao
52d193b2e8 fix(i18n): 收口 dev 合并后的驱动与 QueryEditor 本地化 2026-06-23 14:04:25 +08:00
tianqijiuyun-latiao
d9e52c734a Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n 2026-06-23 13:14:43 +08:00
tianqijiuyun-latiao
3ce85617da feat(i18n): 收口导出前端多语言 2026-06-23 13:14:22 +08:00
tianqijiuyun-latiao
0ba984b277 Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/AISettingsModal.tsx
#	frontend/src/components/ConnectionModal.edit-password.test.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataSyncModal.i18n.test.ts
#	frontend/src/components/DataSyncModal.tsx
#	frontend/src/components/QueryEditor.external-sql-save.test.tsx
#	frontend/src/components/QueryEditor.tsx
#	frontend/src/components/Sidebar.locate-toolbar.test.tsx
#	frontend/src/components/Sidebar.tsx
#	frontend/src/components/SnippetSettingsModal.tsx
#	frontend/src/components/TableOverview.tsx
#	frontend/src/components/ai/AIChatHeader.test.tsx
#	frontend/src/components/ai/AISettingsProvidersSection.tsx
#	frontend/src/components/ai/aiChatPayloadDispatch.ts
#	frontend/src/components/ai/aiChatReadiness.ts
#	frontend/src/components/ai/aiSettingsModalConfig.tsx
#	frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx
#	frontend/src/components/sidebarV2Utils.ts
#	frontend/src/i18n/catalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.ts
#	frontend/src/utils/tabDisplay.ts
#	internal/ai/provider/custom.go
#	internal/ai/service/service.go
#	internal/app/methods_driver.go
#	internal/app/methods_file.go
#	internal/db/custom_impl.go
#	internal/db/iris_impl.go
#	internal/db/mariadb_impl.go
#	internal/db/sqlserver_impl.go
#	shared/i18n/de-DE.json
#	shared/i18n/en-US.json
#	shared/i18n/ja-JP.json
#	shared/i18n/ru-RU.json
#	shared/i18n/zh-CN.json
#	shared/i18n/zh-TW.json
2026-06-23 12:41:27 +08:00
Syngnat
8da8cc7f91 🐛 fix(mongodb): 修复 DataGrid 编辑后 BSON 类型丢失
- 为 MongoDB 结果展示、单元格编辑和行编辑接入类型感知格式化与解析
- 支持 ObjectId、日期、Int32、Int64、Double、Decimal128、UUID 等常见类型保真
- 统一 v1/v2 驱动查询结果的 Extended JSON 输出与 ApplyChanges BSON 恢复
- 补充前端提交链路与后端类型转换回归测试
2026-06-23 12:14:27 +08:00
Syngnat
bc63311003 🐛 fix(oracle): 修复普通查询重复列自动别名缺失
- 在 QueryEditor 查询计划阶段识别显式列与 alias.* 的重复列冲突
- Oracle 执行前自动为冲突显式列补充 _1 风格唯一别名
- 让 locator 与后续追加表达式复用改写后的可执行 SQL
- 补充普通查询重复列自动别名的 Oracle 回归测试
2026-06-23 10:46:35 +08:00
Syngnat
3a00ae1f44 🔧 chore(wails): 同步数据同步 targetSchema TS 绑定
- 为 sync 请求模型补充 targetSchema 字段映射
- 同步自动生成的 frontend wailsjs models.ts 绑定
- 更新 package.json.md5 生成校验文件
2026-06-23 09:55:41 +08:00
Syngnat
e8cad189be 🐛 fix(sqlserver): 修复普通查询结果被原生多结果集吃空
- 对只读 SQL 的原生多结果集空返回增加顺序回退兜底
- 避免 optional driver-agent 成功返回空结果时前端只剩日志无结果集
- 补充 SQLServer 读查询空结果回退回归测试
2026-06-23 09:46:44 +08:00
Syngnat
495a985ae1 🐛 fix(sqlserver): 修复可选驱动查询消息透传缺失
- 为 optional-driver-agent 的 query 和 queryMulti 响应补充 messages 字段
- 在可选驱动 DB 客户端透传 SQL Server 查询提示信息与多结果集
- 补充 agent 与数据库层回归测试并更新 driver agent revision
2026-06-23 08:48:42 +08:00
Syngnat
8f1e6cf379 ️ perf(frontend): 优化长时运行下的搜索与缓存占用
- 为 V2 cmd+k 搜索预建索引并限制初始/宽泛结果数量
- 清理冷数据库树和 DataViewer 长生命周期快照缓存
- 收紧运行时 SQL 日志预算并在 hydration 时压缩旧缓存
2026-06-22 22:36:39 +08:00
Matt Van Horn
05e8dab710 Merge 0ba2bfe645 into 4999fd544d 2026-06-22 00:33:23 -07:00
Matt Van Horn
0ba2bfe645 fix(datasource): ClickHouse 22.8 HTTP 握手兼容 displayName 缺失
clickhouse-go 在 HTTP 握手阶段执行 SELECT displayName(), version(),
revision(), timezone(),而 ClickHouse 22.8 没有 displayName() 函数,
返回 Code 46 UNKNOWN_FUNCTION,导致即便已移除 client_protocol_version
的兼容重试路径仍然连接失败。

扩展现有 HTTP 兼容脚手架:新增 displayName Code 46 检测,复用同一条
兼容重试分支;兼容模式下的 RoundTripper 改写握手探测请求体,将
displayName() 替换为各版本通用的 hostName(),其余请求体原样放行。

Refs #479
2026-06-22 00:19:49 -07:00
tianqijiuyun-latiao
f282da3bcb feat(i18n): 收口多语言功能业务代码 2026-06-22 15:12:42 +08:00
tianqijiuyun-latiao
eba689754c test(ai): 对齐多语言 fallback 测试基线 2026-06-22 13:46:42 +08:00
tianqijiuyun-latiao
d13c153f5e feat(i18n): 收口数据库驱动多语言代码
- 提交 internal/db 多驱动用户可见错误与状态文案多语言化

- 补齐数据库驱动多语言测试与六语言 catalog

- 修复 frontend i18n catalog 的 4 个失效 guard
2026-06-22 10:09:45 +08:00
Syngnat
4999fd544d 🐛 fix(data-sync): 完善多种目标库的 schema 同步链路
- 扩展数据同步目标端 schema 选择与元数据加载,覆盖 SQL Server、IRIS、DuckDB 等独立 schema 场景
- 修正同步链路中的目标表 schema 归一化与 query/apply 表名解析,避免落到错误模式
- 补充前后端回归测试与多语言文案,覆盖 schema 选择、别名识别和结果预览路径

Fixes #571
2026-06-21 22:46:57 +08:00
Syngnat
36233ba9aa Merge pull request #580 from feat/573-redis-db-alias
feat(redis): per-database aliases in the sidebar
2026-06-21 16:42:36 +08:00
Matt Van Horn
4408bce159 feat(redis): support per-database alias in the Redis viewer tree 2026-06-21 00:44:22 -07:00
Syngnat
e7b8e78f9c 🐛 fix(query-editor): 修复当前语句快捷选择在 CRLF 文本下错位
- 统一当前语句选择与执行路径的归一化 offset/position 换算
- 避免 Windows CRLF 文本下 SQL 语句选区错位
- 补充 QueryEditor 当前语句选择回归测试

Fixes #575
2026-06-21 15:08:34 +08:00
Syngnat
29e7e365f1 📝 docs(readme): 补充 Trino 联邦查询支持说明
- 在中英文 README 的支持数据源列表中补充 Trino\n- 明确 Trino 作为跨多数据源联邦查询入口\n\nFixes #577
2026-06-21 14:11:36 +08:00
Syngnat
8ea7ecc477 feat(trino): 新增 Trino 可选驱动接入并补齐查询支持
- 后端新增 Trino 数据库实现与 optional driver-agent provider
- 前端补齐 catalog.schema 连接配置、URI 解析与能力开关
- SQL 编辑器对 Trino 禁用托管事务并补充前后端测试
2026-06-21 13:54:42 +08:00
Syngnat
99b75378c3 feat(data-grid): 完善 ER 图多层关系展开与字段浏览
- 支持按层扩展关联关系并重置为一层视图
- 支持节点字段展开收起与全部字段切换
- 补充 ER 图模型、Hook 与界面回归测试
2026-06-21 11:37:45 +08:00
Syngnat
5f56859898 🐛 fix(query-editor): 兜底 SQL 编辑器中文输入首次不上屏
- 补充 Monaco IME 组合输入提交兜底
- 统一拦截候选键避免快捷键链路抢占
- 增加 QueryEditor 中文输入回归测试

Fixes #578
2026-06-21 11:30:17 +08:00
Syngnat
7f2445a6f5 🐛 fix(monaco-editor): 禁用 EditContext 修复中文输入异常
- 在 MonacoEditor 中统一关闭 editContext
- 修复单引号场景下中文输入首次上屏异常
- 补充编辑器选项回归断言

Close #578
2026-06-20 17:05:38 +08:00
Syngnat
b3a54b3ff8 feat(data-grid): 新增完整 ER 图视图并收口导出回归
- 新增完整 ER 图建模、布局与交互 hook
- 在元数据视图接入 ER 图并补齐样式与多语言文案
- 修正查询结果导出测试交互与 Wails 导出 mock
2026-06-20 15:45:58 +08:00
Syngnat
c8c8497a2f feat(query-editor): 收敛 SQL 分析工作台与结果区日志体验
- 新增 SQL 分析工作台,统一承载慢 SQL 和 SQL 诊断视图
- 将 SQL 执行日志收进结果区首个日志标签并在失败时展示错误摘要
- 调整侧边栏入口、标签展示、多语言文案与定向前端测试覆盖
2026-06-20 14:09:58 +08:00
Syngnat
04019135a0 🐛 fix(sql-diagnose): 兼容新版 MySQL JSON EXPLAIN 解析
- 兼容 query_plan 包装和新版 JSON V2 执行计划结构
- 补齐 covering index、table scan 等节点类型与统计归一化
- 增加新版 MySQL EXPLAIN 解析测试并修正 TotalCost 汇总逻辑
2026-06-20 14:08:02 +08:00
Syngnat
e7935db84b feat(db-connection): 新增连接后台定时探活保活能力
- 为连接配置新增 keepAlive 开关与探活间隔
- 在后端缓存连接上增加后台定时 Ping 调度与失效剔除
- 补充前端表单、Wails 模型映射与定向测试覆盖

Close #579
2026-06-20 11:24:44 +08:00
Syngnat
0f67305100 ♻️ refactor(codebase): 拆分大文件并收敛模块职责
- 拆分 ConnectionModal、DataGrid、QueryEditor、App 等前端大文件

- 抽出 driver assets、v2 workbench 样式和 source-as-test 读取辅助

- 更新相关测试以覆盖拆分后的源码位置
2026-06-19 23:10:00 +08:00
Syngnat
6179c3fbd9 ♻️ refactor(sidebar): 拆分动作与搜索逻辑 2026-06-19 18:46:08 +08:00
Syngnat
13705f9098 ♻️ refactor(sidebar): 抽出 V2 右键菜单逻辑 2026-06-19 18:08:54 +08:00
Syngnat
1dea343aa2 ♻️ refactor(sidebar): 抽出 V2 树标题渲染 2026-06-19 18:03:28 +08:00
Syngnat
293a8ff3e9 ♻️ refactor(sidebar): 抽出实体操作弹窗 2026-06-19 17:56:12 +08:00
Syngnat
6e422aea33 ♻️ refactor(sidebar): 抽出树节点加载器 2026-06-19 17:32:45 +08:00
Syngnat
39e52469f2 ♻️ refactor(sidebar): 抽出批量操作弹窗 2026-06-19 17:23:53 +08:00
Syngnat
3ff5141184 ♻️ refactor(sidebar): 抽出外部 SQL 文件流程 2026-06-19 17:13:24 +08:00
Syngnat
db31513c0b ♻️ refactor(sidebar): 抽出批量导出状态逻辑 2026-06-19 16:55:43 +08:00
Syngnat
5da2c7ff1a ♻️ refactor(sidebar): 抽出元数据加载工具 2026-06-19 16:45:15 +08:00
Syngnat
d109f2891f ♻️ refactor(sidebar): 复用 V2 侧栏工具函数 2026-06-19 16:33:16 +08:00
Syngnat
f946951580 ♻️ refactor(sidebar): 迁出 legacy 节点菜单构建逻辑 2026-06-19 16:21:36 +08:00
Syngnat
f6ebfc2e44 ♻️ refactor(sidebar): 清理 V2 rail 残留死代码
- 删除未被调用的 rail connection button/group 渲染闭包

- 移除对应的折叠、拖拽状态和 badge/host 辅助函数

- 保留当前可达的 active connection 状态展示和树拖拽排序逻辑
2026-06-19 15:16:31 +08:00
Syngnat
540dbc2a28 ♻️ refactor(sidebar): 抽出 Command Search 面板组件
- 新建 SidebarSearchPanel,承接 V2 命令搜索弹层的行、分组和空状态渲染

- Sidebar.tsx 保留搜索状态、过滤和执行逻辑,仅通过 typed props 传入子组件

- 保持 @ 对象搜索、? AI 提问、同步过滤与键盘导航行为不变
2026-06-19 14:55:21 +08:00
Syngnat
2c8128724e ♻️ refactor(sidebar): 抽出 ConnectionRail 为独立子组件
- 新建 sidebar/SidebarConnectionRail.tsx:V2 连接栏(新建分组/批量导出/SQL 文件/定位/AI/工具/设置 7 个按钮)
- Props 用聚合对象(labels + handlers + canLocateActiveTab)避免 18+ 个独立 props drilling
- Sidebar.tsx 删除 renderV2ConnectionRail(-93 行),加 v2ConnectionRailProps 构造 + <SidebarConnectionRail /> 调用
- Sidebar.tsx 从 10187 减至 10122 行
2026-06-19 14:29:57 +08:00
Syngnat
528f3c51a0 ♻️ refactor(sidebar): 迁出命令搜索相关类型与 shouldLoadSidebarNodeOnExpand
- 迁出 V2CommandSearchMode/V2CommandSearchQuery 类型与 parseV2CommandSearchQuery(支持 @/?前缀切换 object/ai 模式)
- 迁出 shouldLoadSidebarNodeOnExpand(节点懒加载判定)
- SidebarNodeLike 加可选 key 字段以适配测试用法
- Sidebar.tsx 从 10235 减至 10187 行,sidebarHelpers.ts 增至 215 行
2026-06-19 14:19:06 +08:00
Syngnat
a4d94624cd ♻️ refactor(sidebar): 继续抽离 resolveV2ObjectGroupTitle 等 2 个工具函数
- 新增 SidebarNodeLike 结构化类型,替代 Pick<TreeNode, ...> 解除对 Sidebar 内部类型的依赖
- 迁出 resolveV2ObjectGroupTitle(对象分组标题本地化)与 resolveSidebarTableNameForCopy(节点表名提取)
- Sidebar.tsx 从 10243 减至 10235 行
2026-06-19 14:15:35 +08:00
Syngnat
87bd16c4ba ♻️ refactor(sidebar): 抽离独立工具函数到 sidebarHelpers 模块
- 新建 sidebar/sidebarHelpers.ts:迁出 6 个无内部类型依赖的纯函数(formatSidebarRowCount/hasSidebarLazyChildren/getV2RailConnectionGroupBadgeText 等)+ V2ExplorerFilter 类型 + V2_RAIL_UNGROUPED 常量
- Sidebar.tsx 通过 import + re-export 双向引用,外部测试文件的 `from './Sidebar'` 保持兼容
- 文件规模:Sidebar.tsx 从 10275 减至 10243 行,建立 sidebar/ 子目录作为后续拆分的目标归宿
2026-06-19 14:11:54 +08:00
Syngnat
dc54d24d2b feat(sidebar): 新增 Sidebar 慢 SQL 历史浮动入口
- 新建 SlowQueryRailButton:独立小组件,从 store 直接读 tabs/connections 解析激活连接
- 挂载方式:浮动在 Sidebar 右下角,不修改 Sidebar.tsx 内部代码(避免改 10275 行的大文件)
- 体验优化:无激活连接时按钮自动禁用并 tooltip 提示;与 Ctrl+Shift+L 快捷键路径并存
- bundle 保持:SlowQueryPanel 继续走 lazy 加载独立 chunk,不进入主 bundle
2026-06-19 13:56:15 +08:00
Syngnat
8457f6c4b7 feat(shortcuts): 把 SQL 诊断与慢查询快捷键注册到管理系统并加菜单入口
- 注册到系统:diagnoseQuery 与 showSlowQueries 加入 ShortcutAction,可在快捷键管理面板自定义
- 修复冲突:原硬编码 Ctrl+Shift+D 与 toggleTheme 冲突、Ctrl+Shift+H 与 toggleLogPanel 冲突;改为 Ctrl+Shift+P / Ctrl+Shift+L
- 菜单入口:QueryEditor 的"更多"下拉加 SQL 诊断、慢 SQL 历史两个菜单项,附带快捷键提示
- i18n 同步:6 种语言补齐 label/description
2026-06-19 13:43:12 +08:00
Syngnat
a2d83744b5 feat(explain): 扩展索引建议规则引擎至 15 条
- 新增规则:LIKE 前缀通配、函数包裹列、笛卡尔积风险、OR 条件无索引、大 OFFSET 分页、SELECT * + JOIN 模式
- 阈值常量:large_offset(10000)、cartesian_product(100000)、wide_table(20 列)
- 测试覆盖:新增 6 个用例验证规则触发与抑制(含边界场景)
2026-06-19 13:43:01 +08:00
Syngnat
946450874f 🔧 chore(wails): 同步 GetSlowQueries 与 ClearSlowQueries 的 TS 绑定
- Wails 工具自动重新生成,把手动插入的绑定重排到字母序正确位置
- 同步 package.json.md5 构建指纹
2026-06-19 13:25:32 +08:00
Syngnat
577a417292 feat(explain-ui): 新增慢 SQL 历史面板与 Ctrl+Shift+H 快捷键
- 面板组件:SlowQueryPanel 含 TopN 列表 + 耗时/扫描行数/时间三种排序 + 清空操作
- 入口接入:QueryEditor 加 Ctrl+Shift+H 快捷键,点击条目回填 SQL 到编辑器
- Wails 绑定:手动同步 GetSlowQueries 与 ClearSlowQueries 到 App.js/App.d.ts
- 颜色提示:耗时 >5s 红色、>1s 橙色,便于一眼识别重查询
2026-06-19 13:23:02 +08:00
Syngnat
a74065bdbb feat(explain): 新增慢 SQL 历史存储与 DBQueryMulti 执行埋点
- 存储层:JSONL 单连接单文件,5MB 自动滚动,TopN 排序 + SQL 指纹去重
- 执行埋点:DBQueryMulti 用 named return + defer 异步记录,成功后自动写入历史
- 阈值过滤:默认 500ms 以下查询跳过记录,避免历史爆炸
- 查询入口:GetSlowQueries 按 duration/rowsRead/recent 排序,ClearSlowQueries 支持清空
- SQL 指纹:字面量/大小写归一化后 sha256,同模板不同参数视为同一查询
- 测试覆盖:新增 13 个单元测试覆盖存储/滚动/排序/去重/指纹
2026-06-19 13:17:39 +08:00
Syngnat
0c320234fd feat(explain-ui): 将诊断工作台接入 QueryEditor 并支持快捷键触发
- 快捷键绑定:QueryEditor 监听 Ctrl+Shift+D(Mac 为 Cmd+Shift+D)打开诊断面板
- 配置解析:从 currentConnectionId 复用 SavedConnection 模式解析 ConnectionConfig
- lazy 加载:ExplainWorkbench 通过 React.lazy + Suspense 隔离,避免 reactflow 进入主 bundle
- 端到端联通:用户在编辑器写 SQL 后按快捷键即可触发 DiagnoseQuery 并可视化结果
2026-06-19 13:11:20 +08:00
Syngnat
f5ae2e51f9 feat(explain-ui): 新增执行计划图渲染组件与索引建议侧栏
- 依赖引入:新增 reactflow + dagre 用于执行计划 DAG 自动布局
- 构建配置:vite manualChunks 拆分 reactflow/dagre/recharts 独立 chunk,便于按需加载
- 类型镜像:utils/explainTypes.ts 镜像后端 ExplainResult/Node/Stats/IndexSuggestion,含颜色与格式化 helper
- 图渲染:ExplainGraph 自定义节点按 opType 着色 + 警告 flag 边框高亮 + dagre TB 布局
- 侧栏组件:ExplainSidebar 含统计条、节点详情、索引建议按 severity 排序
- 主容器:ExplainWorkbench 含 Modal + 执行计划/原文双 tab,调用 DiagnoseQuery 端到端联通
2026-06-19 13:04:49 +08:00
Syngnat
8e24e40fdd feat(explain): 补齐 Oracle/SQLServer/ClickHouse 解析器与索引建议规则引擎
- 方言解析:新增 Oracle DBMS_XPLAN 表格、SQLServer SHOWPLAN_XML、ClickHouse EXPLAIN JSON 解析器
- 规则引擎:新增 10 条跨方言规则(全表扫描、缺索引 JOIN、filesort、估算偏差、缓冲命中、Nested Loop 高扇出等)
- 入口接入:DiagnoseQuery 返回的 Suggestions 自动填充规则匹配结果
- 容错增强:SQLServer strip 默认命名空间与 XML 声明;Oracle 表格列与独立 Predicate 段双源融合
- 测试覆盖:新增 27 个用例覆盖三方言解析与规则触发场景
2026-06-19 12:45:15 +08:00
Syngnat
85648b1e5a 🔧 chore(wails): 同步 DiagnoseQuery 自动生成的 TS 绑定
- 重新生成 App.d.ts / App.js:暴露 DiagnoseQuery(connectionConfig, dbName, sql) 入口
- 更新 models.ts:新增 connection.ExplainResult / ExplainNode / ExplainStats 等类型
- 同步 Service.d.ts / Service.js / package.json.md5(构建缓存指纹)
2026-06-19 12:31:42 +08:00
Syngnat
b997788437 feat(explain): 新增 SQL 诊断工作台后端 EXPLAIN 基建
- 数据结构:新增 ExplainResult/Node/Stats/IndexSuggestion/DiagnoseReport 等归一化模型,跨方言通用
- 接口扩展:Database 接口新增 ExplainExecer 可选能力,支持驱动自带 EXPLAIN 实现
- 核心入口:DiagnoseQuery 支持 SELECT/WITH 白名单校验、方言调度、原生与 fallback 两条执行路径
- 方言适配:buildExplainQuery 覆盖 MySQL/PostgreSQL/SQLite/Oracle/SQLServer/ClickHouse 7 大主流
- 解析器:MySQL FORMAT=JSON 含表格 fallback、PostgreSQL ANALYZE BUFFERS JSON、SQLite EQP 层级解析
- 测试覆盖:新增 27 个单元测试覆盖 SQL 构造与三方言解析器
2026-06-19 12:30:56 +08:00
Syngnat
542bafe6c4 🔧 chore(gitignore): 忽略 optional-driver-agent 构建产物 2026-06-19 12:07:33 +08:00
Syngnat
98965a56e1 🐛 fix(memory): 修复大数据量导出导致进程内存飙升至 16G 的问题
- GC 策略:主进程与 driver-agent 启动时收紧 SetGCPercent 至 50
- 周期回收:scan_rows 与 callStreamQuery 每 5 万行触发 runtime.GC
- 自适应限流:driver-agent 引入 GOMEMLIMIT 自适应策略,2GB 起步按 1GB 步长抬升至 8GB 上限
- 批次调优:流式批次由 256 行缩减至 64 行,降低 JSON 编解码瞬时峰值
2026-06-19 12:05:02 +08:00
Syngnat
21c427bc39 🐛 fix(connection): 优化多数据源连接数占用
- 测试连接改为隔离连接,成功后立即关闭并避免写入全局缓存
- 新增通用 SQL 连接池配置,限制网络型数据源空闲连接长期占用
- Redis 测试连接改为临时客户端并立即释放
- MySQL 连接数超限时释放同实例缓存连接并重试
- 补充连接释放、缓存重试和连接池参数回归测试
2026-06-18 20:29:19 +08:00
Syngnat
6b67bb24b4 feat(tool-center): 优化工具中心分组交互与通用弹窗
- 重构工具中心为侧边分组导航,固定弹窗高度并支持内部滚动
- 新增通用可拖拽可缩放 Modal,统一主要弹窗打开体验
- 为工具中心内嵌入口补充返回上一步交互与底部操作区
- 补充多语言文案和工具中心/Modal/i18n 回归测试
2026-06-18 20:28:47 +08:00
Syngnat
06dd9507ee feat(ai): 补齐 Cursor 与 CodeBuddy 会话态聊天链路
- 新增 SessionChatProvider 接口,补齐非流式对话的会话态复用能力
- 为 Cursor Agent 和 CodeBuddy CLI 同步实现流式与非流式会话续接及状态持久化
- CustomProvider 补充会话态透传,统一 custom provider 的会话复用行为
- Service 新增 AIChatSendInSession,聊天主链路非流式回退改走带 session 的发送接口
- 保留原 AIChatSend 无状态语义,避免标题生成和记忆压缩污染主会话上下文
- 补充前后端定向测试,覆盖会话恢复、续接发送和前端回退分流
2026-06-18 13:35:08 +08:00
Syngnat
b588235b62 feat(ai): 接入 Cursor Cloud Agents API
- 新增 cursor-agent provider,支持创建 agent、轮询 run 状态和 SSE 流式响应
- 接入 AITestProvider 与 AIListModels,支持 Cursor 官方 /v1/models 连通性测试和模型发现
- 在 AI 设置中新增 Cursor 供应商预设,固定 cursor-agent 协议并补齐默认端点配置
- 调整 provider readiness 与 insights 规则,允许 Cursor 未显式选模型时走官方默认模型
- 补充后端 provider/service 测试和前端 preset、表单、readiness 相关用例

Close #576
2026-06-18 12:35:58 +08:00
Syngnat
f457f6aaca feat(ai): 接入 CodeBuddy CLI 并兼容官方登录态
- 新增 CodeBuddy CLI provider,支持 codebuddy/cbc 命令调用与输出解析
- 将 Base URL、API Key/Auth Token、自定义请求头映射到 CodeBuddy CLI 环境变量
- 扩展 custom provider 路由与测试链路,兼容空 Base URL 和 CLI 默认模型选择
- AI 设置新增 CodeBuddy 预设,并补齐 preset 回显识别与匹配逻辑
- 修正就绪态、模型列表与表单校验,允许留空凭证直接复用本机已登录账号
- 补充前后端定向测试并覆盖 CodeBuddy 配置展示文案

Close #574
2026-06-18 12:06:58 +08:00
Syngnat
c8fe90cbee ️ perf(import-export): 降低 OceanBase 导出链路内存占用
- 为 optional driver-agent 补齐 streamQuery 分片协议,避免大结果集整批缓冲到内存
- 在 OceanBase 整表导出和查询结果导出前强校验 driver-agent revision,旧版代理直接拦截并提示重装
- 为 driver-agent 增加大查询和流式导出完成后的 GC/FreeOSMemory 回收逻辑
- 补充导出前校验、流式分片消费和 agent 内存回收的定向测试
- 更新 driver-agent revisions 以匹配新的流式导出协议
2026-06-18 11:32:08 +08:00
Syngnat
6bd87fa568 🐛 fix(export-workbench): 补齐整表导出百分比进度
- 为整表导出链路补充 COUNT(*) 预统计,并把总行数写回首个导出进度事件
- 兼容解析多种总数返回类型,避免后端已知总量时前端仍降级为不定进度条
- 补充后端导出总数解析与前端 runner 状态切换回归测试
2026-06-18 10:58:01 +08:00
Syngnat
293fc6e0fe 🐛 fix(data-grid): 修复字段元数据偶发缺失
- 为字段元数据提取补充可用性判断并在空类型/空备注时自动重试
- 刷新结果集时同步清理字段、外键和唯一键缓存并强制补拉元数据
- 补充 DataGrid 头部元数据回归测试,覆盖首次空结果重试与刷新重载场景
2026-06-18 10:57:51 +08:00
Syngnat
2a8ae05363 🐛 fix(export-workbench): 修正未知总数进度展示并优化 XLSX 收尾阶段
- 修正总行数为 0 时仍被当作已知总数的问题,避免导出进度百分比失真
- 调整导出进度条判定逻辑,未知总数时改为展示实时写入进度
- 统一 Sidebar、TableOverview 和导出工作台的预计行数口径,仅在总数大于 0 时视为已知
- 优化 XLSX 收尾阶段的 ZIP 压缩策略和拷贝缓冲,降低 Windows 大文件导出封装耗时
- 细化 finalizing 阶段文案,明确显示 XLSX 正在封装压缩
- 补充导出进度状态与零总数场景的回归测试
2026-06-18 10:15:00 +08:00
Syngnat
9613f6b624 🐛 fix(frontend): 预构建本地化依赖避免开发启动代理失败 2026-06-18 09:58:06 +08:00
Syngnat
ee78b9b57c ️ perf(import-export): 降低大文件导入导出内存占用
- xlsx 导出改为临时 sheet 加 zip 直写,避免整包缓冲到内存
- xlsx 导入改为 zip xml 流式解析,并将 shared strings 落到临时文件
- 大任务完成后按行数和文件大小阈值触发内存回收
- 补充导入导出流式链路的测试与基准覆盖
2026-06-18 09:21:01 +08:00
Syngnat
5ce4dddd7a ️ perf(ci): 减少 driver-agent 检测与平台 diff 的重复计算
- driver_agents job 一次性产出各平台 driver 集合,build 矩阵直接消费结果\n- detect 阶段补充 setup-go 与缓存,降低 revision 检测的冷启动成本\n- diff-driver-agent-revisions 支持候选 drivers 过滤,并行生成 base/head revisions\n- 补充脚本测试,覆盖候选 driver 过滤与无关 driver 忽略场景
2026-06-17 17:44:40 +08:00
Syngnat
e67285fde1 ️ perf(import): 重构导入链路并支持流式批量写入
- 后端新增流式导入流水线,避免预览和导入阶段整文件驻留内存\n- 导入执行优先复用 BatchApplier 按批提交,并在批量失败时回退单行定位错误\n- 导入进度事件兼容未预扫总行数场景,沿用预览总数稳定展示进度\n- 补充导入预览、批量回退和前端进度展示的最小回归测试
2026-06-17 17:26:57 +08:00
Syngnat
4e31d47936 feat(export-workbench): 支持批量导出工作台并优化 SQL 导出性能
- 侧边栏批量表/批量库入口改为直接打开导出工作台,统一导出配置与进度视图
- 导出工作台新增 batch-tables / batch-databases 模式,支持连接、数据库、对象选择与独立历史记录键
- 连接、数据库、对象下拉项补齐完整名展示与悬浮提示,避免长名称被截断后不可识别
- 后端新增批量对象/批量库导出 WithOptions 链路,统一返回输出文件/目录与进度信息
- SQL dump 数据导出改为按方言批量写入,MySQL/PG 等使用多值 VALUES,Oracle/达梦使用 INSERT ALL
- 补充导出工作台与 SQL dump 的回归测试和 benchmark,覆盖批量模式与批量写入语义
2026-06-17 16:50:05 +08:00
Syngnat
954d126a8f test(sidebar): 适配多语言后的 locate-toolbar 测试 2026-06-17 15:36:58 +08:00
Syngnat
5b31ab7435 feat(export-workbench): 新增导出工作台与进度历史
- 新增表级导出工作台标签页,统一承载导出范围、格式和 XLSX sheet 行数配置
- 结果集、表概览、侧栏和右键菜单统一接入导出工作台与带进度的导出入口
- 导出进度改为事件驱动,未知总数时展示不定进度和实时已写入行数
- 持久化每张表的导出历史并复用同一导出标签,重启后仍可查看最近任务
- 调整导出页签标题、状态胶囊和历史列表,补充工作台与状态流测试覆盖
2026-06-17 14:40:49 +08:00
Syngnat
b3c321be67 ️ perf(export): 重构大结果集导出链路并支持流式写入
- 新增 ExportFileOptions 统一承载导出格式、进度任务和 XLSX sheet 行数上限
- 查询导出改为流式写入文件,避免一次性缓存整批结果导致高内存占用
- 增加值数组快速路径并复用扫描与写入缓冲,减少逐行 map 分配开销
- 为 ClickHouse、自定义驱动、达梦、SQLServer 和 TDengine 补齐 StreamQuery 支持
- 导出时间字符串仅在形似时间时再解析,避免普通文本被误判改写
- 补充 XLSX 分 sheet、流式导出和基准测试覆盖
2026-06-17 14:24:35 +08:00
辣条
d7ad83f0d5 完善多模块多语言 (#572)
- 补齐前后端多模块多语言文案、共享词典与 i18n guard,减少硬编码展示文案。
- 覆盖 QueryEditor、Sidebar 外部 SQL、安全更新、DataGrid、Redis、AI/工具入口等重点场景。
2026-06-17 13:56:01 +08:00
tianqijiuyun-latiao
3006429a9a 🔧 chore(merge): 合并最新 dev 并解决 DataGrid 冲突 2026-06-17 13:52:50 +08:00
tianqijiuyun-latiao
9364c48ef0 feat(i18n): 完善多模块多语言适配与发版验证
扩展前后端多语言文案与共享词典。增加多模块 i18n 回归测试与 guard。收口外部 SQL 菜单和弹窗多语言文案。
2026-06-17 13:17:33 +08:00
Syngnat
3e140c1bc6 🐛 fix(ai-safety): 修正完全模式执行口径与本地工具失败判定
- 修正完全模式下 DML 与过程调用的安全提示和限制说明
- 区分连接探针失败与可恢复 SQL 执行错误,避免数据探针被误终止
- 修复本地 execute_sql 写语句结果返回 affectedRows
- 补充 AI 安全、本地工具执行与 SQL 限制回归测试
2026-06-17 09:49:59 +08:00
Syngnat
7ff3e00759 🐛 fix(query-editor): 修复外部 SQL 标签状态与 OceanBase 查询改写
- 修复外部 SQL 文件删除后标签残留及关闭异常
- 修复 OceanBase Oracle 查询注入隐藏 ROWID 时的表名改写
- 修复小写表名执行时的精确引用并保留日志中的原始 SQL
- 补充查询编辑器相关回归测试
2026-06-17 09:49:39 +08:00
Syngnat
0632c5242c 🐛 fix(oceanbase/data-grid): 修复 Oracle 时间字段显示编辑与结果视图异常
- 修复 OceanBase Oracle DATE 与 TIMESTAMP 的解码、展示和编辑精度丢失问题
- 修复查询结果与数据视图的行号显示、分页页数和日期列展示口径
- 打通 Oracle 与 OceanBase 会话执行链路的扫描方言透传
- 补齐 DBQuery、DataGrid temporal 和 OceanBase 结果链路回归测试
2026-06-17 09:49:15 +08:00
tianqijiuyun-latiao
76b0163bd3 Merge branch 'dev' into feature/20260602_connection_driver_i18n
# Conflicts:
#	frontend/package.json.md5
#	frontend/src/App.tsx
#	frontend/src/components/AIChatPanel.message-boundary.test.tsx
#	frontend/src/components/AIChatPanel.tsx
#	frontend/src/components/AISettingsModal.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataGrid.ddl.test.tsx
#	frontend/src/components/DataGrid.layout.test.tsx
#	frontend/src/components/DataGrid.tsx
#	frontend/src/components/DataGridColumnTitle.test.tsx
#	frontend/src/components/DataGridLegacyCellContextMenu.tsx
#	frontend/src/components/DataGridSecondaryActions.tsx
#	frontend/src/components/DataGridToolbarFrame.tsx
#	frontend/src/components/DataSyncModal.tsx
#	frontend/src/components/DataViewer.tsx
#	frontend/src/components/DefinitionViewer.tsx
#	frontend/src/components/DriverManagerModal.tsx
#	frontend/src/components/QueryEditor.external-sql-save.test.tsx
#	frontend/src/components/QueryEditor.tsx
#	frontend/src/components/RedisViewer.tsx
#	frontend/src/components/Sidebar.locate-toolbar.test.tsx
#	frontend/src/components/Sidebar.tsx
#	frontend/src/components/TabManager.hover.test.tsx
#	frontend/src/components/TabManager.tsx
#	frontend/src/components/TableDesigner.tsx
#	frontend/src/components/V2TableContextMenu.tsx
#	frontend/src/components/ai/AIChatHeader.tsx
#	frontend/src/components/ai/AIHistoryDrawer.tsx
#	frontend/src/main.tsx
#	frontend/src/store.ts
#	frontend/src/utils/aiComposerNotice.test.ts
#	frontend/src/utils/aiComposerNotice.ts
#	frontend/src/utils/connectionModalPresentation.ts
#	frontend/src/utils/driverImportGuidance.ts
#	frontend/src/utils/externalSqlTree.test.ts
#	frontend/src/utils/externalSqlTree.ts
#	frontend/src/utils/sqlDialect.ts
#	internal/ai/service/service.go
2026-06-16 18:35:11 +08:00
Syngnat
6421662f5d 🐛 fix(oracle): 修复裸表查询结果字段元数据缺失
- Oracle-like 裸表查询默认使用连接用户名作为 schema 加载字段元数据
- 修复未写 schema 时结果表头不显示字段类型和注释的问题
- 保持显式 schema.table 查询优先级不变
- 补充查询结果表引用解析和 QueryEditor 回归测试
2026-06-16 14:05:28 +08:00
Syngnat
54195e0591 🐛 fix(sqlserver): 修复对象 SQL 定义获取失败
- SQL Server 对象定义改为通过 sys.all_sql_modules 按库、schema、对象名精确查询
- 增加 sp_helptext 兼容候选,支持拼接多行 Text 返回完整定义
- 统一修复视图、函数/存储过程、触发器定义查看与对象修改入口
- 补充 SQL Server 对象定义查询和组件回归测试
2026-06-16 12:54:39 +08:00
tianqijiuyun-latiao
5fc29a6fd3 feat(i18n): 推进多语言剩余切片闭环
- 补齐 DataGrid、DataViewer、DefinitionViewer、JVM 等模块多语言文案与回归测试
- 收口 JVM 前后端展示、诊断、监控和资源呈现相关多语言路径
- 更新六语言共享词典并保留 raw 边界
2026-06-16 12:40:33 +08:00
Syngnat
f41a15c7b8 feat(data-sync/oceanbase): 拆分比对入口并修复 OceanBase Oracle 连接
- 数据同步:新增表结构比对、数据比对两个独立工具入口
- 比对模式:为 DataSyncModal 增加只读入口展示与模式化文案
- OceanBase:Oracle 租户改用 OB Oracle 专用 MySQL-wire 连接路径
- 连接表单:允许 OceanBase Oracle Service Name 留空,仅 TNS 场景需要填写
- 驱动提示:revision 不匹配提示收敛到驱动管理,不再在普通数据源入口弹出
- 测试覆盖:补充数据比对入口、OceanBase Oracle、driver-agent 提示边界测试
2026-06-16 12:15:16 +08:00
Syngnat
938bc53966 🐛 fix(mysql): 修复 DATE 字段显示为 datetime
- 查询扫描链路透传数据库方言,区分 MySQL 与 Oracle DATE 语义
- MySQL/MariaDB/自定义 mysql 驱动的 DATE/NEWDATE 只展示 YYYY-MM-DD
- 保留 DATETIME/TIMESTAMP 和 Oracle DATE 的时间信息
- 补充值规整与扫描链路回归测试
Close #565
2026-06-16 09:25:16 +08:00
Syngnat
093b3cae1f 🐛 fix(postgres): 修复删除数据库误判当前连接占用
- PostgreSQL 类数据库 DROP DATABASE 自动切换到维护库执行
- 避免前端传入目标库名时被误判为当前连接正在使用
- 同步修复 ALTER DATABASE RENAME 的同类误判
- 补充 PostgreSQL 删除和重命名数据库回归测试
Close #567
2026-06-16 09:07:19 +08:00
Syngnat
0816702084 🐛 fix(external-sql): 修复外部 SQL 文件丢失后标签无法关闭
- 后端读取 SQL 文件失败时返回 file_not_found 结构化错误码
- 前端识别文件被删除或移动的场景,允许用户确认关闭标签
- 保留权限、网络盘异常等非缺失错误的关闭拦截,避免误丢草稿
- 补充前后端测试覆盖缺失文件识别与标签关闭提示
Close #566
2026-06-16 08:48:43 +08:00
Syngnat
c70eb7157f test(oceanbase): 脱敏 Oracle 租户连接测试数据
- 将测试中的内网地址、账号和服务名替换为虚构 fixture
- 统一 OceanBase Oracle 与 SSH 跳板机测试常量
- 保留连接预探测与 SSH 诊断断言语义
2026-06-16 08:37:18 +08:00
Syngnat
23f95d7dc8 feat(query-editor): 支持还原 SQL 美化前内容
- 美化 SQL 前保存最近一次原始内容快照
- 在格式设置菜单提供还原上次美化入口
- 持久化查询标签页的美化恢复快照
- 补充编辑器与状态恢复回归测试
2026-06-16 07:10:04 +08:00
Syngnat
682017ba96 🐛 fix(oceanbase): 修复 Oracle 租户 SSH 预探测超时
- 拆分 OceanBase Oracle 预探测的拨号超时与握手读取超时
- SSH 跳板机场景下使用完整连接超时,避免内网目标被误判不可达
- 保留 MySQL handshake 短读取超时,避免 TNS 端口测试连接变慢
- 补充 SSH 预探测超时与短读取行为回归测试
2026-06-15 17:56:34 +08:00
Syngnat
891c8c1200 🐛 fix(sql-snippet): 修复片段管理编辑与按钮布局
- 支持自定义 SQL 片段按 id 更新,避免修改时重复新增
- 将片段语法说明改为可编辑并随片段持久化
- 将保存、删除、重置、关闭按钮统一收敛到底部操作栏
- 调整操作按钮为大号尺寸并增加最小宽度和底部间距
- 补充片段编辑、布局结构和持久化回归测试
2026-06-15 17:28:48 +08:00
Syngnat
a611c1c04b 🐛 fix(oceanbase): 修复 Oracle 租户跳板机连接预探测失败
- 修复 OceanBase Oracle 预探测未走 SSH 隧道导致内网 IP 被本机直连误判不可达的问题
- 预探测阶段复用完整连接配置,支持通过 SSH 跳板机访问目标地址
- 区分本机 TCP 不可达与 SSH 跳板机访问失败,优化错误提示
- 保留 OBClient 与 TNS 双路径路由逻辑,避免协议判断回退
- 补充 OceanBase Oracle SSH 预探测与网络失败回归测试
2026-06-15 16:13:15 +08:00
tianqijiuyun-latiao
558966a129 feat(i18n): 推进六语言多语言体系与扫描门禁
- 新增共享六语言词典、前端 i18n 运行时与语言设置入口

- 推进连接、驱动、数据网格、查询、AI、Redis、表设计等模块文案本地化

- 补充 raw 边界、SQL/驱动/更新场景测试与 i18n 扫描工具
2026-06-15 14:35:08 +08:00
Syngnat
2f354d2267 feat(saved-query): 新增已存查询独立查看入口
- 侧边栏新增“全部已存查询”根节点,不依赖连接实例或加载数据库
- 按连接、数据库和未匹配状态分组展示后端已加载查询
- 使用独立树节点 key,避免与数据库节点下的同一查询冲突
- 重命名和删除按真实 query id 同步更新所有展示副本
- 补充独立入口分组结构测试,覆盖已匹配和未匹配查询
2026-06-15 14:12:39 +08:00
Syngnat
eca9601ab0 feat(saved-query): 支持已存查询后端持久化与连接重绑
- 后端新增 saved_queries.json 仓库,保存、导入、删除和重绑统一走 Wails 方法
- 启动时导入旧 lite-db-storage 中的 savedQueries 和连接快照,成功后清理旧字段
- 新增连接指纹匹配,唯一强匹配自动重绑,歧义场景保留为未匹配
- 侧边栏新增未匹配已存查询分组,并支持手动绑定到目标连接
- 前端保存、重命名、删除查询改为后端持久化,并补充浏览器 mock
- 补充后端与前端迁移回归测试
2026-06-15 12:20:57 +08:00
1061 changed files with 237721 additions and 39377 deletions

18
.containerignore Normal file
View File

@@ -0,0 +1,18 @@
.git
.github
.idea
.ace-tool
.playwright-mcp
.superpowers
.worktrees
bin
build
dist
frontend/dist
node_modules
frontend/node_modules
tools/__pycache__
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent
optional-driver-agent.exe

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
.git
.github
.idea
.ace-tool
.playwright-mcp
.superpowers
.worktrees
bin
build
dist
frontend/dist
node_modules
frontend/node_modules
tools/__pycache__
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent
optional-driver-agent.exe

View File

@@ -68,6 +68,12 @@ jobs:
runs-on: ubuntu-latest
outputs:
drivers: ${{ steps.detect.outputs.drivers }}
drivers_darwin_amd64: ${{ steps.detect.outputs.drivers_darwin_amd64 }}
drivers_darwin_arm64: ${{ steps.detect.outputs.drivers_darwin_arm64 }}
drivers_windows_amd64: ${{ steps.detect.outputs.drivers_windows_amd64 }}
drivers_windows_arm64: ${{ steps.detect.outputs.drivers_windows_arm64 }}
drivers_linux_amd64: ${{ steps.detect.outputs.drivers_linux_amd64 }}
drivers_linux_arm64: ${{ steps.detect.outputs.drivers_linux_arm64 }}
has_changes: ${{ steps.detect.outputs.has_changes }}
release_source: ${{ steps.detect.outputs.release_source }}
compare_base: ${{ steps.detect.outputs.compare_base }}
@@ -80,6 +86,12 @@ jobs:
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: true
- name: Resolve published driver release source
id: published_source
env:
@@ -149,6 +161,21 @@ jobs:
printf '%s\n' "$merged"
}
platform_output_key() {
case "$1" in
darwin/amd64) echo "darwin_amd64" ;;
darwin/arm64) echo "darwin_arm64" ;;
windows/amd64) echo "windows_amd64" ;;
windows/arm64) echo "windows_arm64" ;;
linux/amd64) echo "linux_amd64" ;;
linux/arm64) echo "linux_arm64" ;;
*)
echo "unknown platform: $1" >&2
return 1
;;
esac
}
BASE_REF="${{ steps.published_source.outputs.source_commit }}"
HAS_MANIFEST="${{ steps.published_source.outputs.has_manifest }}"
MANIFEST_VALID="${{ steps.published_source.outputs.manifest_valid }}"
@@ -173,27 +200,53 @@ jobs:
BASE_REF="all"
fi
echo "🧭 Final driver detection base: $BASE_REF"
FORCE_GLOBAL_DRIVER_BUILDS="$(bash ./tools/should-force-global-driver-builds.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
if [[ "$BASE_REF" != "all" ]]; then
PLATFORM_OUTPUTS=()
PLATFORM_DIFF_FAILED=false
if [[ "$BASE_REF" != "all" && "$FORCE_GLOBAL_DRIVER_BUILDS" != "true" ]]; then
REVISION_DRIVERS=""
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || {
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
DIFF_ARGS=(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")
if [[ -n "$DRIVERS" ]]; then
DIFF_ARGS+=(--drivers "$DRIVERS")
fi
PLATFORM_DRIVERS="$("${DIFF_ARGS[@]}")" || {
echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建"
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
REVISION_DRIVERS=""
PLATFORM_DIFF_FAILED=true
PLATFORM_OUTPUTS=()
break
}
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=${PLATFORM_DRIVERS}")
REVISION_DRIVERS="$(merge_csv "$REVISION_DRIVERS" "$PLATFORM_DRIVERS")"
done
if [[ -n "$REVISION_DRIVERS" ]]; then
if [[ "$PLATFORM_DIFF_FAILED" == "false" && -n "$REVISION_DRIVERS" ]]; then
echo "🧭 Revision diff union drivers: $REVISION_DRIVERS"
DRIVERS="$(merge_csv "$DRIVERS" "$REVISION_DRIVERS")"
fi
fi
FORCE_GLOBAL_DRIVER_BUILDS="$(bash ./tools/should-force-global-driver-builds.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
if [[ "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" || "$PLATFORM_DIFF_FAILED" == "true" ]]; then
PLATFORM_OUTPUTS=()
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=${DRIVERS}")
done
elif [[ ${#PLATFORM_OUTPUTS[@]} -eq 0 ]]; then
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=")
done
fi
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
echo "compare_base=${BASE_REF}" >> "$GITHUB_OUTPUT"
echo "force_global_driver_builds=${FORCE_GLOBAL_DRIVER_BUILDS}" >> "$GITHUB_OUTPUT"
for OUTPUT_LINE in "${PLATFORM_OUTPUTS[@]}"; do
echo "$OUTPUT_LINE" >> "$GITHUB_OUTPUT"
done
if [ -n "$DRIVERS" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "🧭 Changed driver agents: $DRIVERS"
@@ -261,6 +314,15 @@ jobs:
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-22.04-arm
platform: linux/arm64
os_name: Linux
arch_name: Arm64
build_name: gonavi-build-linux-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
@@ -346,6 +408,12 @@ jobs:
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
echo "⚠️ Linux arm64 暂不生成 AppImage仅产出 tar.gz"
touch /tmp/skip-appimage
exit 0
fi
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
@@ -481,47 +549,50 @@ jobs:
wails build -s -skipbindings -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
fi
- name: Resolve Platform Driver Revision Diff
id: revision_diff
- name: Select Platform Driver Set
id: platform_drivers
if: ${{ matrix.build_optional_agents && needs.driver_agents.outputs.has_changes == 'true' }}
shell: bash
run: |
set -euo pipefail
BASE_REF="${{ needs.driver_agents.outputs.compare_base }}"
FALLBACK_DRIVERS="${{ needs.driver_agents.outputs.drivers }}"
FORCE_GLOBAL_DRIVER_BUILDS="${{ needs.driver_agents.outputs.force_global_driver_builds }}"
if [[ -z "$BASE_REF" || "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" ]]; then
if [[ "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" && -n "$BASE_REF" && "$BASE_REF" != "all" ]]; then
echo "⚠️ 当前提交涉及 driver 构建/发布链路,保留全局驱动重建结果:${FALLBACK_DRIVERS}"
else
echo "⚠️ 当前 driver 检测基线不可做平台 diff回退使用全局检测结果${FALLBACK_DRIVERS}"
fi
echo "drivers=${FALLBACK_DRIVERS}" >> "$GITHUB_OUTPUT"
else
echo "🧭 对比当前平台 revisionbase=${BASE_REF} head=${GITHUB_SHA} platform=${{ matrix.platform }}"
if DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "${{ matrix.platform }}")"; then
echo "🧭 当前平台实际需要重建的 driver agents: ${DRIVERS:-<empty>}"
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
else
echo "⚠️ revision 差异对比失败,保守回退为全量重建"
ALL_DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
echo "drivers=${ALL_DRIVERS}" >> "$GITHUB_OUTPUT"
fi
fi
DRIVERS="$(sed -n 's/^drivers=//p' "$GITHUB_OUTPUT" | tail -n 1)"
case "${{ matrix.platform }}" in
darwin/amd64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_darwin_amd64 }}'
;;
darwin/arm64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_darwin_arm64 }}'
;;
windows/amd64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_windows_amd64 }}'
;;
windows/arm64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_windows_arm64 }}'
;;
linux/amd64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_amd64 }}'
;;
linux/arm64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_arm64 }}'
;;
*)
echo "unsupported platform: ${{ matrix.platform }}" >&2
exit 1
;;
esac
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
if [[ -n "$DRIVERS" ]]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "🧭 当前平台最终需要重建的 driver agents: ${DRIVERS}"
else
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "🧭 当前平台无需重建 driver agents"
fi
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents && steps.revision_diff.outputs.has_changes == 'true' }}
if: ${{ matrix.build_optional_agents && steps.platform_drivers.outputs.has_changes == 'true' }}
shell: bash
env:
CHANGED_DRIVER_AGENTS: ${{ steps.revision_diff.outputs.drivers }}
CHANGED_DRIVER_AGENTS: ${{ steps.platform_drivers.outputs.drivers }}
run: |
set -euo pipefail
TARGET_PLATFORM="${{ matrix.platform }}"
@@ -537,7 +608,45 @@ jobs:
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
mkdir -p "$GOCACHE"
echo "🧭 可选驱动使用隔离 GOCACHE$GOCACHE"
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
normalize_driver_token() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
"") return 1 ;;
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elastic|elasticsearch) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse)
echo "$value"
;;
*)
echo "❌ 不支持的 driver-agent${1:-}" >&2
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
declare -a RAW_DRIVERS=()
declare -a DRIVERS=()
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
DRIVERS+=("$DRIVER")
done
if [ ${#DRIVERS[@]} -eq 0 ]; then
echo "🧭 没有需要构建的 driver-agent"
exit 0
fi
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
@@ -593,10 +702,7 @@ jobs:
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
@@ -691,7 +797,7 @@ jobs:
bash ./tools/verify-driver-agent-revisions.sh \
--assets-dir drivers \
--platform "$TARGET_PLATFORM" \
--drivers "$CHANGED_DRIVER_AGENTS"
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
# macOS Packaging
- name: Package macOS DMG

204
.github/workflows/docker-images.yml vendored Normal file
View File

@@ -0,0 +1,204 @@
name: Docker Images
on:
push:
branches:
- dev
tags:
- 'v*'
concurrency:
group: docker-images-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
build:
name: Build ${{ matrix.image_name }} (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
image_name:
- gonavi-mcp-server
- gonavi-build-env
platform:
- linux/amd64
- linux/arm64
include:
- image_name: gonavi-mcp-server
dockerfile: Dockerfile.mcp-server
description: GoNavi MCP Server container image
- image_name: gonavi-build-env
dockerfile: Dockerfile.build-env
description: GoNavi Linux build environment image
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Prepare build variables
id: prep
shell: bash
run: |
set -euo pipefail
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
echo "platform_pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
labels: |
org.opencontainers.image.title=${{ matrix.image_name }}
org.opencontainers.image.description=${{ matrix.description }}
- name: Build smoke image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
load: true
tags: codex-smoke/${{ matrix.image_name }}:local
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
- name: Smoke test MCP Server image
if: matrix.image_name == 'gonavi-mcp-server'
shell: bash
run: |
set -euo pipefail
cid="$(docker run -d -p 8765:8765 -e GONAVI_MCP_HTTP_TOKEN=smoke-token codex-smoke/${{ matrix.image_name }}:local http)"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
for _ in 1 2 3 4 5 6 7 8 9 10; do
if curl --fail --silent http://127.0.0.1:8765/healthz >/dev/null; then
exit 0
fi
sleep 1
done
docker logs "$cid"
echo "MCP Server image smoke test failed" >&2
exit 1
- name: Smoke test build environment image
if: matrix.image_name == 'gonavi-build-env'
shell: bash
run: |
set -euo pipefail
docker run --rm codex-smoke/${{ matrix.image_name }}:local \
bash -lc 'go version && node -v && npm -v && wails version'
- name: Push by digest
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
cache-to: type=gha,mode=max,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
- name: Export digest
shell: bash
run: |
set -euo pipefail
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.push.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Publish ${{ matrix.image_name }}
runs-on: ubuntu-latest
needs: build
strategy:
fail-fast: false
matrix:
include:
- image_name: gonavi-mcp-server
description: GoNavi MCP Server container image
- image_name: gonavi-build-env
description: GoNavi Linux build environment image
steps:
- name: Normalize image namespace
id: vars
shell: bash
run: |
set -euo pipefail
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-${{ matrix.image_name }}-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}
flavor: |
latest=false
tags: |
type=raw,value=dev-latest,enable=${{ github.ref == 'refs/heads/dev' }}
type=sha,format=short,prefix=dev-,enable=${{ github.ref == 'refs/heads/dev' }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
labels: |
org.opencontainers.image.title=${{ matrix.image_name }}
org.opencontainers.image.description=${{ matrix.description }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
shell: bash
run: |
set -euo pipefail
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}@sha256:%s ' *)
- name: Inspect image
shell: bash
run: |
set -euo pipefail
docker buildx imagetools inspect "ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}:${{ steps.meta.outputs.version }}"

View File

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

View File

@@ -177,7 +177,7 @@ jobs:
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
if [[ "$BASE_REF" != "all" ]]; then
REVISION_DRIVERS=""
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || {
echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建"
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
@@ -263,6 +263,15 @@ jobs:
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-22.04-arm
platform: linux/arm64
os_name: Linux
arch_name: Arm64
build_name: gonavi-build-linux-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
# Debian 13 (trixie) 默认仓库已切到 WebKitGTK 4.1:单独提供 4.1 变体产物
- os: ubuntu-24.04
platform: linux/amd64
@@ -354,6 +363,12 @@ jobs:
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
echo "⚠️ Linux arm64 暂不生成 AppImage仅产出 tar.gz"
touch /tmp/skip-appimage
exit 0
fi
# Download linuxdeploy tools for AppImage packaging
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
@@ -535,7 +550,45 @@ jobs:
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
mkdir -p "$GOCACHE"
echo "🧭 可选驱动使用隔离 GOCACHE$GOCACHE"
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
normalize_driver_token() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
"") return 1 ;;
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elastic|elasticsearch) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse)
echo "$value"
;;
*)
echo "❌ 不支持的 driver-agent${1:-}" >&2
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
declare -a RAW_DRIVERS=()
declare -a DRIVERS=()
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
DRIVERS+=("$DRIVER")
done
if [ ${#DRIVERS[@]} -eq 0 ]; then
echo "🧭 没有需要构建的 driver-agent"
exit 0
fi
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
@@ -591,10 +644,7 @@ jobs:
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
@@ -689,7 +739,7 @@ jobs:
bash ./tools/verify-driver-agent-revisions.sh \
--assets-dir drivers \
--platform "$TARGET_PLATFORM" \
--drivers "$CHANGED_DRIVER_AGENTS"
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
# macOS Packaging
- name: Package macOS DMG

2
.gitignore vendored
View File

@@ -17,6 +17,8 @@ dist/
.gemini-clipboard
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent
optional-driver-agent.exe
.ace-tool/
.superpowers/
.claude/

55
Dockerfile.build-env Normal file
View File

@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1.7
ARG GO_IMAGE=golang:1.25-bookworm
ARG NODE_IMAGE=node:20-bookworm
FROM ${NODE_IMAGE} AS node
FROM ${GO_IMAGE}
ARG WEBKIT_API=4.0
ARG WAILS_VERSION=v2.11.0
ENV DEBIAN_FRONTEND=noninteractive
ENV PATH=/go/bin:/usr/local/go/bin:${PATH}
# Debian's /etc/profile resets PATH in login shells (bash -l), dropping the Go
# toolchain; restore it via profile.d which is sourced after that reset.
RUN printf 'export PATH=/go/bin:/usr/local/go/bin:$PATH\n' > /etc/profile.d/10-gonavi-build-env.sh
# Copy only Node.js runtime assets so the Go toolchain from the base image
# stays intact for smoke tests and interactive build shells.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
# npm/npx/corepack are symlinks in the node image; COPY dereferences them into
# standalone scripts whose relative requires break, so recreate the symlinks.
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
build-essential \
ca-certificates \
curl \
file \
git \
make \
pkg-config \
python3 \
unzip \
zip \
libgtk-3-dev \
&& if [ "${WEBKIT_API}" = "4.1" ]; then \
apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libsoup-3.0-dev; \
else \
apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev; \
fi \
&& go install github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION} \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
CMD ["bash"]

46
Dockerfile.mcp-server Normal file
View File

@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder
# Declared without defaults so BuildKit injects the real target platform;
# a default like TARGETARCH=amd64 would win and ship amd64 binaries to arm64.
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
COPY third_party/highgo-pq/go.mod ./third_party/highgo-pq/go.mod
COPY third_party/go-irisnative/go.mod third_party/go-irisnative/go.sum ./third_party/go-irisnative/
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/gonavi-mcp-server ./cmd/gonavi-mcp-server
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --home-dir /var/lib/gonavi --uid 10001 gonavi \
&& mkdir -p /data /var/lib/gonavi/logs \
&& chown -R gonavi:gonavi /data /var/lib/gonavi
COPY --from=builder /out/gonavi-mcp-server /usr/local/bin/gonavi-mcp-server
ENV HOME=/var/lib/gonavi \
GONAVI_DATA_ROOT=/data \
GONAVI_LOG_DIR=/var/lib/gonavi/logs \
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765 \
GONAVI_MCP_HTTP_PATH=/mcp \
GONAVI_MCP_SCHEMA_ONLY=true
VOLUME ["/data"]
EXPOSE 8765
USER gonavi
ENTRYPOINT ["/usr/local/bin/gonavi-mcp-server"]
CMD ["http"]

View File

@@ -34,9 +34,16 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
| Category | Data Source | Driver Mode | Typical Capabilities |
|---|---|---|---|
| Relational | MySQL | Built-in | Schema browsing, SQL query, data editing, export/backup |
| Domestic DB | GoldenDB | Built-in | MySQL-compatible query workflow and distributed transaction scenarios |
| Relational | PostgreSQL | Built-in | Schema browsing, SQL query, data editing, object management |
| Relational | Oracle | Built-in | Query execution, object browsing, data editing |
| Cache | Redis | Built-in | Key browsing, command execution, encoding/view switch |
| Vector Database | Chroma | Built-in | Collection browsing, vector retrieval, metadata filtering |
| Vector Database | Qdrant | Built-in | Collection browsing, vector search, payload filtering |
| Message Queue | RocketMQ | Built-in | Topic browsing, consumer-group inspection, message-oriented workflow |
| Message Queue | MQTT | Built-in | Broker and topic-filter workflow with QoS-aware connection settings |
| Message Queue | Kafka | Built-in | Topic browsing, broker metadata, consumer-group workflow |
| Message Queue | RabbitMQ | Built-in | Queue/exchange browsing, virtual host inspection, management API workflow |
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
| Columnar Analytics | StarRocks | Optional driver agent | Querying, object browsing, SQL execution |
@@ -44,13 +51,19 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
| Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management |
| File-based | SQLite | Optional driver agent | Local DB browsing, editing, export |
| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow |
| Domestic DB | OceanBase | Optional driver agent | MySQL / Oracle tenant access, object browsing, query workflow |
| Domestic DB | Dameng | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | Kingbase | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | HighGo | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | Vastbase | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | OpenGauss | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
| Domestic DB | GaussDB | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
| Multi-model | InterSystems IRIS | Optional driver agent | Namespace browsing, SQL query, object management |
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
| Time-series | Apache IoTDB | Optional driver agent | Storage group / device / timeseries browsing and querying |
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
| Federated Query | Trino | Optional driver agent | Cross-source SQL via multiple catalogs, `catalog.schema` browsing, SQL execution |
| Search | Elasticsearch | Optional driver agent | Index browsing, mapping inspection, JSON DSL / query_string search |
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
@@ -74,6 +87,9 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
- **Built-in MCP Workflow**: Manage MCP servers in AI Settings, install GoNavi MCP to Claude Code / Codex, or expose Streamable HTTP for remote Agents.
- **Remote-Agent Boundary**: Keep saved connections and database passwords on the host running GoNavi while cloud Agents consume schema tools over MCP.
- **Safety Guardrails**: Remote `schema-only` mode omits `execute_sql`; when SQL execution is enabled, non-read-only statements still require explicit `allowMutating=true`.
### Performance
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
@@ -168,6 +184,82 @@ wails build -clean
Artifacts are generated in `build/bin`.
### Docker / Podman (MCP Server Only)
The desktop GUI is not packaged as a container service. Current container support targets `gonavi-mcp-server` only.
### Browser Access Mode (Experimental)
The main GoNavi application now also exposes a `web-server` mode that reuses the same Go backend and React frontend for browser access:
```powershell
go build .
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
```
The first browser visit is redirected to `/setup` to create the web admin password. Google Authenticator is optional but supported out of the box, together with `web_auth.json`, session cookies, recovery codes, and login rate limiting.
Current scope already includes:
- Browser-side Wails bridge (`window.go.*` / `window.runtime.*` -> HTTP / SSE)
- First-run setup page, login page, and logout endpoint
- Session idle timeout / absolute timeout / remember-login window
- Google Authenticator TOTP plus recovery codes
Still in progress:
- Browser upload/download workbenches for external SQL and connection-package flows
- More web capability gating for desktop-only features
- Reverse-proxy / HTTPS / zero-trust deployment guidance
```bash
cp docker.mcp-server.env.example docker.mcp-server.env
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
```
Mount the GoNavi active data root into the container. The mounted directory should contain `connections.json`, `daily_secrets.json`, and optional `drivers/` assets when optional driver agents are required.
The default Compose file pulls the published GHCR image. For local source builds, add the override file:
```bash
docker compose --env-file docker.mcp-server.env \
-f docker-compose.mcp-server.yml \
-f docker-compose.mcp-server.local.yml \
up -d --build
```
For Podman, use the same published OCI image with `podman run`, or the native Quadlet example under [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server). That path is intended for Linux servers / NAS hosts where rootless systemd services are preferred.
Deployment matrix:
- Docker Desktop / Linux server / NAS: `docker-compose.mcp-server.yml`
- Podman / Quadlet: [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server)
- Kubernetes: [deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server) (`kustomization.yaml` + overlays)
- Helm chart: [deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server)
- Build-only Linux environment: `Dockerfile.build-env`
See [cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md) for deployment details and security boundaries.
### Docker / Podman (Build Environment Only)
If you only need a reproducible Linux build environment for Wails, use `Dockerfile.build-env`:
```bash
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
```
The same Dockerfile also works with Podman, for example `podman build -f Dockerfile.build-env -t localhost/gonavi-build-env:local .` and `podman run --rm -it -v "$PWD:/workspace" -w /workspace localhost/gonavi-build-env:local bash`.
The default image installs the WebKitGTK 4.0 build toolchain for broader Linux/NAS compatibility. The image bases are multi-arch, so `amd64` and `arm64` follow the target container platform.
Published images are pushed to GHCR:
- `ghcr.io/syngnat/gonavi-mcp-server:latest`
- `ghcr.io/syngnat/gonavi-build-env:latest`
This image is for building Linux artifacts only. It does not turn the Wails desktop GUI into a browser-accessible web application.
### Cross-Platform Release (GitHub Actions)
The repository includes a release workflow.

View File

@@ -33,9 +33,16 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
| 类别 | 数据源 | 驱动模式 | 典型能力 |
|---|---|---|---|
| 关系型 | MySQL | 内置 | 库表浏览、SQL 查询、数据编辑、导出/备份 |
| 国产数据库 | GoldenDB | 内置 | MySQL 兼容查询工作流、分布式事务场景 |
| 关系型 | PostgreSQL | 内置 | 库表浏览、SQL 查询、数据编辑、对象管理 |
| 关系型 | Oracle | 内置 | 连接查询、对象浏览、数据编辑 |
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
| 向量数据库 | Chroma | 内置 | Collection 浏览、向量检索、元数据过滤 |
| 向量数据库 | Qdrant | 内置 | Collection 浏览、向量搜索、Payload 过滤 |
| 消息队列 | RocketMQ | 内置 | Topic 浏览、消费组检查、消息型工作流 |
| 消息队列 | MQTT | 内置 | Broker / Topic Filter 工作流与 QoS 连接配置 |
| 消息队列 | Kafka | 内置 | Topic 浏览、Broker 元数据、消费组工作流 |
| 消息队列 | RabbitMQ | 内置 | Queue / Exchange 浏览、Virtual Host 检查、Management API 工作流 |
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
| 列式分析 | StarRocks | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
@@ -43,13 +50,19 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
| 国产数据库 | OceanBase | 可选驱动代理 | MySQL / Oracle 租户接入、对象浏览、查询工作流 |
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | OpenGauss | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
| 国产数据库 | GaussDB | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
| 多模型数据库 | InterSystems IRIS | 可选驱动代理 | Namespace 浏览、SQL 查询、对象管理 |
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
| 时序 | Apache IoTDB | 可选驱动代理 | Storage Group / Device / Timeseries 浏览与查询 |
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
| 联邦查询 | Trino | 可选驱动代理 | 跨多数据源联邦 SQL、`catalog.schema` 浏览、SQL 执行 |
| 搜索 | Elasticsearch | 可选驱动代理 | 索引浏览、Mapping 检查、JSON DSL / query_string 查询 |
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
@@ -73,6 +86,9 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude同时支持任意自定义兼容 OpenAI 格式的 API。
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI让 SQL 生成、分析变得更精准。
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等
- **内置 MCP 工作流**:可在 AI 设置里管理 MCP 服务,一键安装 GoNavi MCP 到 Claude Code / Codex或开启 Streamable HTTP 供远端 Agent 使用。
- **远端 Agent 边界清晰**:数据库连接与密码继续保留在运行 GoNavi 的主机上,云端 Agent 通过 MCP 读取结构与上下文。
- **安全控制可追溯**:远端 `schema-only` 模式默认不暴露 `execute_sql`;如开启 SQL 执行,非只读语句仍需显式传入 `allowMutating=true`
### 性能与交互
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
@@ -162,6 +178,82 @@ wails build -clean
构建产物位于 `build/bin`
### Docker / Podman仅 MCP Server
当前容器化支持仅覆盖 `gonavi-mcp-server`,不包含桌面 GUI 主界面。
### 浏览器访问版(实验中)
仓库主程序现在额外提供了一个 `web-server` 运行模式,用同一套 Go 后端 + React 前端提供浏览器访问入口:
```powershell
go build .
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
```
首次访问会进入 `/setup` 完成 Web 管理员密码初始化;可选启用 Google Authenticator服务端会落地 `web_auth.json`、Session Cookie、恢复码与登录失败限流。
当前阶段已具备:
- 浏览器端 Wails bridge`window.go.*` / `window.runtime.*` -> HTTP / SSE
- 首次初始化页、登录页、登出接口
- Session 空闲超时 / 绝对超时 / 记住登录时长
- Google Authenticator TOTP 与恢复码
当前仍在继续收口:
- 外部 SQL / 连接包导入导出等文件型能力的浏览器上传下载工作台
- 更多桌面专属能力的 Web 门禁与替代交互
- 反向代理 / HTTPS / 零信任部署说明
```bash
cp docker.mcp-server.env.example docker.mcp-server.env
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
```
请把 GoNavi 的活动数据目录挂载进容器。该目录内至少应包含 `connections.json``daily_secrets.json`,如需可选驱动代理还应包含 `drivers/`
默认 Compose 会直接拉取 GHCR 预构建镜像。如果你要用当前仓库源码本地构建,再叠加一个 override
```bash
docker compose --env-file docker.mcp-server.env \
-f docker-compose.mcp-server.yml \
-f docker-compose.mcp-server.local.yml \
up -d --build
```
如果你使用 Podman可直接复用同一套 OCI 镜像,走 `podman run` 或 Quadlet。仓库内已经提供 [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server) 作为 Podman 原生部署入口,适合 Linux 服务器 / NAS 的 rootless systemd 常驻服务。
部署矩阵:
- Docker Desktop / Linux 服务器 / NAS`docker-compose.mcp-server.yml`
- Podman / Quadlet[deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server)
- Kubernetes[deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server)`kustomization.yaml` + overlays
- Helm Chart[deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server)
- 仅构建环境:`Dockerfile.build-env`
完整部署方式与安全边界见 [cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md)。
### Docker / Podman仅构建环境
如果只是想要一套稳定的 Linux 构建环境来编译 Wails可直接使用 `Dockerfile.build-env`
```bash
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
```
同一个 Dockerfile 也可以直接给 Podman 使用,例如 `podman build -f Dockerfile.build-env -t localhost/gonavi-build-env:local .`,然后执行 `podman run --rm -it -v "$PWD:/workspace" -w /workspace localhost/gonavi-build-env:local bash`
这个镜像默认安装 WebKitGTK 4.0 构建依赖,兼容面更适合常见 Linux / NAS 场景。镜像底座本身支持多架构,`amd64` / `arm64` 会跟随容器平台。
仓库会把预构建镜像推送到 GHCR
- `ghcr.io/syngnat/gonavi-mcp-server:latest`
- `ghcr.io/syngnat/gonavi-build-env:latest`
这个镜像只负责构建 Linux 产物,不会把 Wails 桌面 GUI 变成可浏览器访问的 Web 服务。
### 跨平台发布GitHub Actions
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。

View File

@@ -10,6 +10,13 @@
- 入参:`connectionId`
- `get_tables`
- 入参:`connectionId`、可选 `dbName`
- 返回表列表,并在 `views` 字段附带视图列表,兼容旧客户端只调用 `get_tables` 的场景
- `get_views`
- 入参:`connectionId`、可选 `dbName`
- 返回视图列表
- `get_objects`
- 入参:`connectionId`、可选 `dbName`、可选 `objectTypes`
- 返回表、视图、触发器、函数、过程、序列、包、事件,以及消息队列类 `topic/queue/exchange` 等对象清单
- `get_columns`
- 入参:`connectionId`、可选 `dbName``tableName`
- `get_table_ddl`
@@ -20,7 +27,7 @@
- 如果 SQL 包含 DDL/DML必须显式传 `allowMutating=true`
- `maxRowsPerResult` 用来限制单个结果集返回的行数,默认 `200`
远程 Agent 只需要库表结构时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、库表、字段、索引、外键、触发器和 DDL 工具。
远程 Agent 只需要结构元数据时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、对象清单、表/视图、字段、索引、外键、触发器和 DDL 工具。
## 运行方式
@@ -42,6 +49,187 @@ go run ./cmd/gonavi-mcp-server stdio
go build -o .\bin\gonavi-mcp-server.exe .\cmd\gonavi-mcp-server
```
## Docker / Podman / Compose
当前容器化支持仅覆盖 `gonavi-mcp-server`,不包含 Wails 桌面 GUI。
当前支持矩阵:
- Docker Desktop / Linux 服务器 / NAS直接使用 Compose 或 `docker run`
- Podman / Quadlet使用 `deploy/podman/gonavi-mcp-server`
- Kubernetes使用 `deploy/k8s/gonavi-mcp-server`
- Helm使用 `deploy/helm/gonavi-mcp-server`
- 仅构建环境:使用仓库根目录 `Dockerfile.build-env`
- 桌面 GUI 浏览器访问版:当前不提供,此仓库主应用仍是 Wails 桌面程序,不是现成的 Web 服务
仓库根目录已提供以下文件:
- `Dockerfile.mcp-server`
- `docker-compose.mcp-server.yml`
- `docker.mcp-server.env.example`
- `deploy/podman/gonavi-mcp-server/*`
推荐流程:
```bash
cp docker.mcp-server.env.example docker.mcp-server.env
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
```
默认 Compose 会拉取 GHCR 预构建镜像。如果你要基于当前工作区源码本地构建,再叠加:
```bash
docker compose --env-file docker.mcp-server.env \
-f docker-compose.mcp-server.yml \
-f docker-compose.mcp-server.local.yml \
up -d --build
```
其中 `GONAVI_HOST_DATA_ROOT` 必须指向 GoNavi 当前活动数据目录。该目录内至少应包含:
- `connections.json`
- `daily_secrets.json`
- `drivers/`(如果目标连接依赖可选 driver agent
容器内默认会设置:
- `GONAVI_DATA_ROOT=/data`
- `GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765`
- `GONAVI_MCP_HTTP_PATH=/mcp`
`GONAVI_DATA_ROOT` 会覆盖默认活动数据目录解析逻辑,避免宿主机路径与容器内路径不一致时依赖 `storage_root.json` 的绝对路径。
如果你只想手动构建镜像:
```bash
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
docker run --rm -p 8765:8765 \
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
-e GONAVI_MCP_SCHEMA_ONLY=true \
-e GONAVI_DATA_ROOT=/data \
-v /absolute/path/to/gonavi-data:/data \
gonavi-mcp-server:local http
```
如果你直接使用已发布镜像:
```bash
docker run --rm -p 8765:8765 \
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
-e GONAVI_MCP_SCHEMA_ONLY=true \
-e GONAVI_DATA_ROOT=/data \
-v /absolute/path/to/gonavi-data:/data \
ghcr.io/syngnat/gonavi-mcp-server:latest http
```
### Podman
仓库内还提供了 Podman 原生部署样例:
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example`
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container`
- `deploy/podman/gonavi-mcp-server/README.md`
直接运行已发布镜像:
```bash
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
ghcr.io/syngnat/gonavi-mcp-server:latest http
```
如果你要基于当前源码本地构建:
```bash
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
localhost/gonavi-mcp-server:local http
```
其中:
- `gonavi-mcp-server.env``deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example` 初始化
- `:Z` 适用于开启 SELinux 的宿主机;未启用 SELinux 可去掉
- 更适合长期运行的方式见 [deploy/podman/gonavi-mcp-server/README.md](../../deploy/podman/gonavi-mcp-server/README.md) 中的 Quadlet 示例
`podman compose` 本身依赖外部 compose provider所以仓库对 Podman 的主支持路径是 `podman run` 与 Quadlet而不是假设所有环境都能直接复用 Compose。
## Kubernetes
仓库内置了最小 K8s 示例:
- `deploy/k8s/gonavi-mcp-server/kustomization.yaml`
- `deploy/k8s/gonavi-mcp-server/base/deployment.yaml`
- `deploy/k8s/gonavi-mcp-server/base/service.yaml`
- `deploy/k8s/gonavi-mcp-server/README.md`
- `deploy/k8s/gonavi-mcp-server/overlays/*`
推荐先从现有 GoNavi 数据目录生成 Secret
```bash
kubectl create namespace gonavi
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
kubectl apply -k deploy/k8s/gonavi-mcp-server
```
如果需要 NAS hostPath、可选 driver agent PVC、Ingress或两者组合可直接使用 `overlays/nas-hostpath``overlays/drivers-pvc``overlays/ingress``overlays/ingress-with-drivers-pvc`
更完整的说明见 [deploy/k8s/gonavi-mcp-server/README.md](../../deploy/k8s/gonavi-mcp-server/README.md)。
## Helm
如果你希望把镜像、Secret、Ingress、hostPath / PVC 挂载做成参数化部署,而不是维护多份 Kustomize overlay可直接使用
- `deploy/helm/gonavi-mcp-server`
快速安装:
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
```
Chart 详细说明见 [deploy/helm/gonavi-mcp-server/README.md](../../deploy/helm/gonavi-mcp-server/README.md)。
## Docker / Podman Build Environment
如果你的目标不是运行 MCP而是给 Linux 服务器 / NAS / CI 准备一套可重复的 Wails 构建环境,可直接使用仓库根目录的 `Dockerfile.build-env`
```bash
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
```
如果你使用 Podman也可以直接执行
```bash
podman build -f Dockerfile.build-env -t localhost/gonavi-build-env:local .
podman run --rm -it -v "$PWD:/workspace" -w /workspace localhost/gonavi-build-env:local bash
```
镜像内已预装 Go、Node、Wails CLI、GTK3 与 WebKitGTK 开发依赖,适合执行:
```bash
wails build
```
这个镜像默认安装 WebKitGTK 4.0 构建依赖,适合作为通用 Linux / NAS 构建环境。镜像基座支持多架构,`amd64` / `arm64` 会跟随容器平台。
预构建镜像会发布到 GHCR
- `ghcr.io/syngnat/gonavi-mcp-server:latest`
- `ghcr.io/syngnat/gonavi-build-env:latest`
它只负责构建 Linux 产物,不会把 Wails 主程序变成浏览器版服务。
远程 Agent 使用 Streamable HTTP 时必须设置 bearer token
```powershell
@@ -121,7 +309,7 @@ OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent不能直接
2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only`
3. 通过 SSH 隧道、反向代理或内网网关把 `http://127.0.0.1:8765/mcp` 暴露为云端 Agent 可访问的 HTTPS 地址。
4. 在 OpenClaw / Hermans 中添加远程 MCP Servertransport 选择 Streamable HTTPURL 指向 `/mcp` 地址,并设置请求头 `Authorization: Bearer <随机token>`
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases``get_tables``get_columns``get_table_ddl` 等工具读取结构。
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases``get_objects``get_tables``get_views``get_columns``get_table_ddl` 等工具读取结构。
如果目标 Agent 支持 `mcpServers` JSON可按下面的通用片段配置

View File

@@ -7,8 +7,11 @@ import (
"fmt"
"os"
"reflect"
"runtime"
"runtime/debug"
"strconv"
"strings"
"sync/atomic"
"time"
"GoNavi-Wails/internal/connection"
@@ -33,6 +36,8 @@ type agentResponse struct {
Error string `json:"error,omitempty"`
Data interface{} `json:"data,omitempty"`
Fields []string `json:"fields,omitempty"`
Messages []string `json:"messages,omitempty"`
ChunkType string `json:"chunkType,omitempty"`
RowsAffected int64 `json:"rowsAffected,omitempty"`
}
@@ -44,6 +49,8 @@ const (
agentMethodOpenSession = "openSession"
agentMethodCloseSession = "closeSession"
agentMethodQuery = "query"
agentMethodQueryMulti = "queryMulti"
agentMethodStreamQuery = "streamQuery"
agentMethodExec = "exec"
agentMethodGetDatabases = "getDatabases"
agentMethodGetTables = "getTables"
@@ -58,9 +65,31 @@ const (
const legacyClickHouseDefaultTimeout = 2 * time.Hour
const (
agentChunkColumns = "columns"
agentChunkRows = "rows"
agentChunkDone = "done"
// agentStreamBatchSize 控制 driver-agent 向主进程发送 row chunk 的批次大小。
// 调小到 64单批 JSON 编码 + 主进程解码的瞬时内存峰值降为原来的 1/4
// 代价是 IPC 次数变为 4 倍,但每批仅一次 stdin/stdout 行读写,整体影响可忽略。
// 重要:减小批次不能根除内存峰值,仍需配合 SetGCPercent + 周期 GC见 main
agentStreamBatchSize = 64
agentMemoryTrimRowsThreshold = 100000
agentMemoryTrimMinInterval = 3 * time.Second
)
var (
agentDriverType string
agentDatabaseFactory func() db.Database
agentDriverType string
agentDatabaseFactory func() db.Database
agentMemoryTrimRunning atomic.Bool
agentMemoryTrimLastAt atomic.Int64
runAgentMemoryTrimAsync = func(fn func()) {
go fn()
}
agentMemoryTrimFn = func() {
runtime.GC()
debug.FreeOSMemory()
}
)
type agentRuntime struct {
@@ -75,6 +104,20 @@ func main() {
os.Exit(2)
}
// driver-agent 是独立进程,主进程无法控制其 GC 行为。
// 大结果集88W+ 行)通过 JSON-lines 跨进程传输时,每行有 5-8 倍内存副本;
// Go 默认 GOGC=100 + Windows MADV_FREE 不归还 RSS会导致 driver-agent 进程
// 内存峰值达到数据总量的 10+ 倍(用户实测 88W 普通业务表撑到 8G+)。
//
// GC 策略组合:
// - SetGCPercent(50):堆增长 50% 即触发 GC比默认 100 更早收敛
// - InitMemorySoftLimit起始 2GB运行时由 MaybeGrowMemoryLimit 自适应抬升到最多 8GB
// (起步保守 + 按需扩张,避免静态 2GB 限制在大表场景触发 GC 硬模式降速 15-25%
//
// 代价CPU 开销增加约 5-10%。导出场景是 I/O 密集型,可忽略。
debug.SetGCPercent(50)
db.InitMemorySoftLimit(db.MemorySoftLimitInitialBytes)
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
writer := bufio.NewWriter(os.Stdout)
@@ -99,11 +142,22 @@ func main() {
continue
}
if strings.TrimSpace(req.Method) == agentMethodStreamQuery {
if err := handleStreamRequest(runtimeState, req, writer); err != nil {
fmt.Fprintf(os.Stderr, "写入流式响应失败:%v\n", err)
break
}
continue
}
resp := handleRequest(runtimeState, req)
if err := writeResponse(writer, resp); err != nil {
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
break
}
if strings.TrimSpace(req.Method) == agentMethodQuery {
maybeReleaseAgentMemory("query-response", countAgentResponseRows(resp.Data))
}
}
runtimeState.close()
@@ -184,12 +238,23 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
} else if ok {
switch method {
case agentMethodQuery:
data, fields, err := queryStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs)
data, fields, messages, err := queryStatementWithMessagesOptionalTimeout(session, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
resp.Fields = fields
resp.Messages = messages
case agentMethodQueryMulti:
data, messages, supported, err := queryMultiStatementWithMessagesOptionalTimeout(session, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
if !supported {
return fail(resp, "当前事务会话不支持多结果集查询")
}
resp.Data = data
resp.Messages = messages
case agentMethodExec:
affected, err := execStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs)
if err != nil {
@@ -208,12 +273,23 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
return fail(resp, err.Error())
}
case agentMethodQuery:
data, fields, err := queryWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
data, fields, messages, err := queryWithMessagesOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
resp.Data = data
resp.Fields = fields
resp.Messages = messages
case agentMethodQueryMulti:
data, messages, supported, err := queryMultiWithMessagesOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
if err != nil {
return fail(resp, err.Error())
}
if !supported {
return fail(resp, "当前驱动不支持原生多结果集查询")
}
resp.Data = data
resp.Messages = messages
case agentMethodExec:
affected, err := execWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
if err != nil {
@@ -288,6 +364,108 @@ func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
return resp
}
type agentStreamResponseWriter struct {
writer *bufio.Writer
requestID int64
columns []string
rows [][]interface{}
rowCount int64
}
func newAgentStreamResponseWriter(writer *bufio.Writer, requestID int64) *agentStreamResponseWriter {
return &agentStreamResponseWriter{
writer: writer,
requestID: requestID,
}
}
func (w *agentStreamResponseWriter) SetColumns(columns []string) error {
w.columns = append([]string(nil), columns...)
return writeResponse(w.writer, agentResponse{
ID: w.requestID,
Success: true,
ChunkType: agentChunkColumns,
Fields: w.columns,
})
}
func (w *agentStreamResponseWriter) ConsumeRow(row map[string]interface{}) error {
if len(w.columns) == 0 {
return fmt.Errorf("流式查询缺少列定义")
}
values := make([]interface{}, len(w.columns))
for idx, column := range w.columns {
values[idx] = row[column]
}
return w.ConsumeRowValues(values)
}
func (w *agentStreamResponseWriter) ConsumeRowValues(values []interface{}) error {
row := append([]interface{}(nil), values...)
w.rows = append(w.rows, row)
w.rowCount++
if len(w.rows) < agentStreamBatchSize {
return nil
}
return w.flushRows()
}
func (w *agentStreamResponseWriter) flushRows() error {
if len(w.rows) == 0 {
return nil
}
rows := w.rows
w.rows = nil
return writeResponse(w.writer, agentResponse{
ID: w.requestID,
Success: true,
ChunkType: agentChunkRows,
Data: rows,
})
}
func (w *agentStreamResponseWriter) finish() error {
return w.flushRows()
}
func handleStreamRequest(runtimeState *agentRuntime, req agentRequest, writer *bufio.Writer) error {
resp := agentResponse{ID: req.ID, Success: true}
if runtimeState.inst == nil {
return writeResponse(writer, fail(resp, "connection not open"))
}
streamWriter := newAgentStreamResponseWriter(writer, req.ID)
if session, ok, err := runtimeState.session(req.SessionID); err != nil {
return writeResponse(writer, fail(resp, err.Error()))
} else if ok {
if err := streamStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs, streamWriter); err != nil {
_ = streamWriter.finish()
return writeResponse(writer, fail(resp, err.Error()))
}
if err := streamWriter.finish(); err != nil {
return err
}
if err := writeResponse(writer, agentResponse{ID: req.ID, Success: true, ChunkType: agentChunkDone}); err != nil {
return err
}
maybeReleaseAgentMemory("stream-query-session", streamWriter.rowCount)
return nil
}
if err := streamDatabaseWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs, streamWriter); err != nil {
_ = streamWriter.finish()
return writeResponse(writer, fail(resp, err.Error()))
}
if err := streamWriter.finish(); err != nil {
return err
}
if err := writeResponse(writer, agentResponse{ID: req.ID, Success: true, ChunkType: agentChunkDone}); err != nil {
return err
}
maybeReleaseAgentMemory("stream-query-db", streamWriter.rowCount)
return nil
}
func (r *agentRuntime) nextID() string {
r.ensureSessionMap()
r.nextSessionID++
@@ -427,6 +605,30 @@ type agentQueryContextRunner interface {
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
}
type agentQueryMessageRunner interface {
QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error)
}
type agentQueryMessageContextRunner interface {
QueryContextWithMessages(context.Context, string) ([]map[string]interface{}, []string, []string, error)
}
type agentMultiResultMessageRunner interface {
QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error)
}
type agentMultiResultMessageContextRunner interface {
QueryMultiContextWithMessages(context.Context, string) ([]connection.ResultSetData, []string, error)
}
type agentMultiResultRunner interface {
QueryMulti(query string) ([]connection.ResultSetData, error)
}
type agentMultiResultContextRunner interface {
QueryMultiContext(context.Context, string) ([]connection.ResultSetData, error)
}
type agentExecRunner interface {
Exec(string) (int64, error)
}
@@ -435,20 +637,39 @@ type agentExecContextRunner interface {
ExecContext(context.Context, string) (int64, error)
}
func queryWithOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
func queryWithMessagesOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, []string, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs <= 0 {
return inst.Query(query)
if q, ok := inst.(agentQueryMessageRunner); ok {
return q.QueryWithMessages(query)
}
data, fields, err := inst.Query(query)
return data, fields, nil, err
}
if q, ok := inst.(agentQueryMessageContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
return q.QueryContextWithMessages(ctx, query)
}
if q, ok := inst.(agentQueryContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
return q.QueryContext(ctx, query)
data, fields, err := q.QueryContext(ctx, query)
return data, fields, nil, err
}
return inst.Query(query)
if q, ok := inst.(agentQueryMessageRunner); ok {
return q.QueryWithMessages(query)
}
data, fields, err := inst.Query(query)
return data, fields, nil, err
}
func queryWithOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
data, fields, _, err := queryWithMessagesOptionalTimeout(inst, query, timeoutMs)
return data, fields, err
}
func queryStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
@@ -459,6 +680,150 @@ func queryStatementWithOptionalTimeout(inst db.StatementExecer, query string, ti
return queryWithOptionalTimeout(queryRunner, query, timeoutMs)
}
func queryStatementWithMessagesOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, []string, error) {
queryRunner, ok := inst.(agentQueryRunner)
if !ok {
return nil, nil, nil, fmt.Errorf("当前事务会话不支持查询语句")
}
return queryWithMessagesOptionalTimeout(queryRunner, query, timeoutMs)
}
func queryMultiWithMessagesOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]connection.ResultSetData, []string, bool, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs > 0 {
if q, ok := inst.(agentMultiResultMessageContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
data, messages, err := q.QueryMultiContextWithMessages(ctx, query)
return data, messages, true, err
}
if q, ok := inst.(agentMultiResultContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
data, err := q.QueryMultiContext(ctx, query)
return data, nil, true, err
}
}
if q, ok := inst.(agentMultiResultMessageRunner); ok {
data, messages, err := q.QueryMultiWithMessages(query)
return data, messages, true, err
}
if q, ok := inst.(agentMultiResultRunner); ok {
data, err := q.QueryMulti(query)
return data, nil, true, err
}
return nil, nil, false, nil
}
func queryMultiStatementWithMessagesOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]connection.ResultSetData, []string, bool, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs > 0 {
if q, ok := inst.(agentMultiResultMessageContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
data, messages, err := q.QueryMultiContextWithMessages(ctx, query)
return data, messages, true, err
}
if q, ok := inst.(agentMultiResultContextRunner); ok {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
data, err := q.QueryMultiContext(ctx, query)
return data, nil, true, err
}
}
if q, ok := inst.(agentMultiResultMessageRunner); ok {
data, messages, err := q.QueryMultiWithMessages(query)
return data, messages, true, err
}
if q, ok := inst.(agentMultiResultRunner); ok {
data, err := q.QueryMulti(query)
return data, nil, true, err
}
return nil, nil, false, nil
}
func streamWithOptionalTimeout(inst db.StreamQueryExecer, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs <= 0 {
return inst.StreamQuery(query, consumer)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
return inst.StreamQueryContext(ctx, query, consumer)
}
func streamBufferedQueryResult(fields []string, data []map[string]interface{}, consumer db.QueryStreamConsumer) error {
if err := consumer.SetColumns(fields); err != nil {
return err
}
if valueConsumer, ok := consumer.(db.QueryStreamValueConsumer); ok {
for _, row := range data {
values := make([]interface{}, len(fields))
for idx, field := range fields {
values[idx] = row[field]
}
if err := valueConsumer.ConsumeRowValues(values); err != nil {
return err
}
}
return nil
}
for _, row := range data {
if err := consumer.ConsumeRow(row); err != nil {
return err
}
}
return nil
}
func streamStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
if streamer, ok := inst.(db.StreamQueryExecer); ok {
return streamWithOptionalTimeout(streamer, query, timeoutMs, consumer)
}
data, fields, err := queryStatementWithOptionalTimeout(inst, query, timeoutMs)
if err != nil {
return err
}
return streamBufferedQueryResult(fields, data, consumer)
}
func streamDatabaseWithOptionalTimeout(inst db.Database, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
if streamer, ok := inst.(db.StreamQueryExecer); ok {
return streamWithOptionalTimeout(streamer, query, timeoutMs, consumer)
}
if provider, ok := inst.(db.SessionExecerProvider); ok {
openCtx := context.Background()
var cancel context.CancelFunc
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
}
if effectiveTimeoutMs > 0 {
openCtx, cancel = context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
defer cancel()
}
session, err := provider.OpenSessionExecer(openCtx)
if err == nil {
defer session.Close()
return streamStatementWithOptionalTimeout(session, query, timeoutMs, consumer)
}
}
data, fields, err := queryWithOptionalTimeout(inst, query, timeoutMs)
if err != nil {
return err
}
return streamBufferedQueryResult(fields, data, consumer)
}
func execWithOptionalTimeout(inst agentExecRunner, query string, timeoutMs int64) (int64, error) {
effectiveTimeoutMs := timeoutMs
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
@@ -478,3 +843,41 @@ func execWithOptionalTimeout(inst agentExecRunner, query string, timeoutMs int64
func execStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) (int64, error) {
return execWithOptionalTimeout(inst, query, timeoutMs)
}
func countAgentResponseRows(data interface{}) int64 {
rows, ok := data.([]map[string]interface{})
if !ok {
return 0
}
return int64(len(rows))
}
func maybeReleaseAgentMemory(reason string, rows int64) {
if rows < agentMemoryTrimRowsThreshold {
return
}
if !agentMemoryTrimRunning.CompareAndSwap(false, true) {
return
}
runAgentMemoryTrimAsync(func() {
defer agentMemoryTrimRunning.Store(false)
if delay := nextAgentMemoryTrimDelay(); delay > 0 {
time.Sleep(delay)
}
agentMemoryTrimFn()
agentMemoryTrimLastAt.Store(time.Now().UnixNano())
})
}
func nextAgentMemoryTrimDelay() time.Duration {
lastUnixNano := agentMemoryTrimLastAt.Load()
if lastUnixNano <= 0 {
return 0
}
elapsed := time.Since(time.Unix(0, lastUnixNano))
if elapsed >= agentMemoryTrimMinInterval {
return 0
}
return agentMemoryTrimMinInterval - elapsed
}

View File

@@ -101,6 +101,9 @@ type fakeAgentTimeoutDB struct {
execCalled bool
execContextCalled bool
deadlineSet bool
queryMessages []string
multiResults []connection.ResultSetData
multiMessages []string
}
func (f *fakeAgentTimeoutDB) Connect(config connection.ConnectionConfig) error { return nil }
@@ -117,6 +120,14 @@ func (f *fakeAgentTimeoutDB) QueryContext(ctx context.Context, query string) ([]
}
return []map[string]interface{}{{"ok": 1}}, []string{"ok"}, nil
}
func (f *fakeAgentTimeoutDB) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) {
data, fields, err := f.QueryContext(context.Background(), query)
return data, fields, append([]string(nil), f.queryMessages...), err
}
func (f *fakeAgentTimeoutDB) QueryContextWithMessages(ctx context.Context, query string) ([]map[string]interface{}, []string, []string, error) {
data, fields, err := f.QueryContext(ctx, query)
return data, fields, append([]string(nil), f.queryMessages...), err
}
func (f *fakeAgentTimeoutDB) Exec(query string) (int64, error) {
f.execCalled = true
return 0, errors.New("exec should not be called")
@@ -150,6 +161,15 @@ func (f *fakeAgentTimeoutDB) GetForeignKeys(dbName, tableName string) ([]connect
func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func (f *fakeAgentTimeoutDB) QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error) {
return append([]connection.ResultSetData(nil), f.multiResults...), append([]string(nil), f.multiMessages...), nil
}
func (f *fakeAgentTimeoutDB) QueryMultiContextWithMessages(ctx context.Context, query string) ([]connection.ResultSetData, []string, error) {
if _, ok := ctx.Deadline(); ok {
f.deadlineSet = true
}
return f.QueryMultiWithMessages(query)
}
type fakeAgentSessionDB struct {
fakeAgentTimeoutDB
@@ -165,6 +185,7 @@ type fakeAgentStatementSession struct {
queryCalls int
execCalls int
closed bool
messages []string
}
func (f *fakeAgentStatementSession) Query(query string) ([]map[string]interface{}, []string, error) {
@@ -175,6 +196,14 @@ func (f *fakeAgentStatementSession) QueryContext(ctx context.Context, query stri
f.queryCalls++
return []map[string]interface{}{{"session_ok": 1}}, []string{"session_ok"}, nil
}
func (f *fakeAgentStatementSession) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) {
data, fields, err := f.QueryContext(context.Background(), query)
return data, fields, append([]string(nil), f.messages...), err
}
func (f *fakeAgentStatementSession) QueryContextWithMessages(ctx context.Context, query string) ([]map[string]interface{}, []string, []string, error) {
data, fields, err := f.QueryContext(ctx, query)
return data, fields, append([]string(nil), f.messages...), err
}
func (f *fakeAgentStatementSession) Exec(query string) (int64, error) {
return f.ExecContext(context.Background(), query)
@@ -190,6 +219,64 @@ func (f *fakeAgentStatementSession) Close() error {
return nil
}
type fakeAgentStreamSession struct {
closed bool
streamCalls int
deadlineSet bool
}
func (f *fakeAgentStreamSession) Exec(query string) (int64, error) {
return 0, nil
}
func (f *fakeAgentStreamSession) ExecContext(ctx context.Context, query string) (int64, error) {
return 0, nil
}
func (f *fakeAgentStreamSession) Close() error {
f.closed = true
return nil
}
func (f *fakeAgentStreamSession) StreamQuery(query string, consumer db.QueryStreamConsumer) error {
return f.StreamQueryContext(context.Background(), query, consumer)
}
func (f *fakeAgentStreamSession) StreamQueryContext(ctx context.Context, query string, consumer db.QueryStreamConsumer) error {
f.streamCalls++
if _, ok := ctx.Deadline(); ok {
f.deadlineSet = true
}
if err := consumer.SetColumns([]string{"id", "name"}); err != nil {
return err
}
if valueConsumer, ok := consumer.(db.QueryStreamValueConsumer); ok {
if err := valueConsumer.ConsumeRowValues([]interface{}{1, "alice"}); err != nil {
return err
}
if err := valueConsumer.ConsumeRowValues([]interface{}{2, "bob"}); err != nil {
return err
}
return nil
}
if err := consumer.ConsumeRow(map[string]interface{}{"id": 1, "name": "alice"}); err != nil {
return err
}
return consumer.ConsumeRow(map[string]interface{}{"id": 2, "name": "bob"})
}
type fakeAgentSessionStreamDB struct {
fakeAgentTimeoutDB
session *fakeAgentStreamSession
openCalls int
}
func (f *fakeAgentSessionStreamDB) OpenSessionExecer(ctx context.Context) (db.StatementExecer, error) {
f.openCalls++
f.session = &fakeAgentStreamSession{}
return f.session, nil
}
func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) {
fake := &fakeAgentTimeoutDB{}
data, fields, err := queryWithOptionalTimeout(fake, "SELECT 1", int64((2 * time.Second).Milliseconds()))
@@ -239,6 +326,77 @@ func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testin
}
}
func TestHandleRequest_QueryIncludesServerMessages(t *testing.T) {
old := agentDriverType
defer func() { agentDriverType = old }()
agentDriverType = "sqlserver"
fake := &fakeAgentTimeoutDB{
queryMessages: []string{"PRINT sql line 1", "PRINT sql line 2"},
}
runtimeState := &agentRuntime{inst: fake, sessions: make(map[string]db.StatementExecer)}
resp := handleRequest(runtimeState, agentRequest{
ID: 11,
Method: agentMethodQuery,
Query: "exec dbo.p_get_select",
TimeoutMs: int64((2 * time.Second).Milliseconds()),
})
if !resp.Success {
t.Fatalf("query request failed: %s", resp.Error)
}
if len(resp.Messages) != 2 || resp.Messages[0] != "PRINT sql line 1" {
t.Fatalf("expected query messages to be preserved, got %#v", resp.Messages)
}
}
func TestHandleRequest_QueryMultiIncludesResultSetsAndMessages(t *testing.T) {
old := agentDriverType
defer func() { agentDriverType = old }()
agentDriverType = "sqlserver"
fake := &fakeAgentTimeoutDB{
multiResults: []connection.ResultSetData{
{
StatementIndex: 1,
Rows: []map[string]interface{}{{"name": "master"}},
Columns: []string{"name"},
},
{
StatementIndex: 1,
Rows: []map[string]interface{}{},
Columns: []string{},
Messages: []string{"PRINT generated sql"},
},
},
multiMessages: []string{"batch top-level message"},
}
runtimeState := &agentRuntime{inst: fake, sessions: make(map[string]db.StatementExecer)}
resp := handleRequest(runtimeState, agentRequest{
ID: 12,
Method: agentMethodQueryMulti,
Query: "exec dbo.p_get_select",
TimeoutMs: int64((2 * time.Second).Milliseconds()),
})
if !resp.Success {
t.Fatalf("queryMulti request failed: %s", resp.Error)
}
if len(resp.Messages) != 1 || resp.Messages[0] != "batch top-level message" {
t.Fatalf("expected top-level messages to be preserved, got %#v", resp.Messages)
}
resultSets, ok := resp.Data.([]connection.ResultSetData)
if !ok {
t.Fatalf("expected []connection.ResultSetData, got %T", resp.Data)
}
if len(resultSets) != 2 {
t.Fatalf("expected 2 result sets, got %#v", resultSets)
}
if len(resultSets[1].Messages) != 1 || resultSets[1].Messages[0] != "PRINT generated sql" {
t.Fatalf("expected message-only result set to be preserved, got %#v", resultSets[1])
}
}
func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.T) {
old := agentDriverType
defer func() { agentDriverType = old }()
@@ -271,6 +429,9 @@ func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.
if !queryResp.Success {
t.Fatalf("session query failed: %s", queryResp.Error)
}
if len(queryResp.Messages) != 0 {
t.Fatalf("expected empty default session messages, got %#v", queryResp.Messages)
}
if fake.queryCalled || fake.queryContextCalled {
t.Fatalf("expected session query to bypass database-level query path, got Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled)
}
@@ -306,3 +467,135 @@ func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.
t.Fatal("expected pinned session to close")
}
}
func TestHandleStreamRequest_UsesSessionStreamerAndWritesChunks(t *testing.T) {
old := agentDriverType
originalAsync := runAgentMemoryTrimAsync
originalTrim := agentMemoryTrimFn
originalLastAt := agentMemoryTrimLastAt.Load()
defer func() { agentDriverType = old }()
defer func() {
runAgentMemoryTrimAsync = originalAsync
agentMemoryTrimFn = originalTrim
agentMemoryTrimRunning.Store(false)
agentMemoryTrimLastAt.Store(originalLastAt)
}()
agentDriverType = "oceanbase"
agentMemoryTrimRunning.Store(false)
agentMemoryTrimLastAt.Store(0)
fake := &fakeAgentSessionStreamDB{}
runtimeState := &agentRuntime{
inst: fake,
sessions: make(map[string]db.StatementExecer),
}
trimmed := 0
runAgentMemoryTrimAsync = func(fn func()) {
fn()
}
agentMemoryTrimFn = func() {
trimmed++
}
var out bytes.Buffer
writer := bufio.NewWriter(&out)
if err := handleStreamRequest(runtimeState, agentRequest{
ID: 9,
Method: agentMethodStreamQuery,
Query: "SELECT * FROM person_info",
TimeoutMs: int64((2 * time.Second).Milliseconds()),
}, writer); err != nil {
t.Fatalf("handleStreamRequest 返回错误: %v", err)
}
if fake.openCalls != 1 {
t.Fatalf("expected OpenSessionExecer called once, got %d", fake.openCalls)
}
if fake.session == nil || fake.session.streamCalls != 1 {
t.Fatalf("expected session streamer used once, session=%#v", fake.session)
}
if !fake.session.deadlineSet {
t.Fatal("expected stream query context deadline to be set")
}
if !fake.session.closed {
t.Fatal("expected session to close after streaming")
}
if fake.queryCalled || fake.queryContextCalled {
t.Fatalf("unexpected fallback query path, Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 3 {
t.Fatalf("expected 3 stream responses, got %d: %q", len(lines), out.String())
}
var columnsResp struct {
Success bool `json:"success"`
ChunkType string `json:"chunkType"`
Fields []string `json:"fields"`
}
if err := json.Unmarshal([]byte(lines[0]), &columnsResp); err != nil {
t.Fatalf("decode columns response failed: %v", err)
}
if !columnsResp.Success || columnsResp.ChunkType != agentChunkColumns || len(columnsResp.Fields) != 2 {
t.Fatalf("unexpected columns response: %#v", columnsResp)
}
var rowsResp struct {
Success bool `json:"success"`
ChunkType string `json:"chunkType"`
Data [][]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(lines[1]), &rowsResp); err != nil {
t.Fatalf("decode rows response failed: %v", err)
}
if !rowsResp.Success || rowsResp.ChunkType != agentChunkRows || len(rowsResp.Data) != 2 {
t.Fatalf("unexpected rows response: %#v", rowsResp)
}
if got := rowsResp.Data[1][1]; got != "bob" {
t.Fatalf("unexpected streamed row payload: %v", rowsResp.Data)
}
var doneResp struct {
Success bool `json:"success"`
ChunkType string `json:"chunkType"`
}
if err := json.Unmarshal([]byte(lines[2]), &doneResp); err != nil {
t.Fatalf("decode done response failed: %v", err)
}
if !doneResp.Success || doneResp.ChunkType != agentChunkDone {
t.Fatalf("unexpected done response: %#v", doneResp)
}
if trimmed != 0 {
t.Fatalf("小流式任务不应触发内存回收got=%d", trimmed)
}
}
func TestMaybeReleaseAgentMemory_TriggersTrimForLargeJobs(t *testing.T) {
originalAsync := runAgentMemoryTrimAsync
originalTrim := agentMemoryTrimFn
originalLastAt := agentMemoryTrimLastAt.Load()
t.Cleanup(func() {
runAgentMemoryTrimAsync = originalAsync
agentMemoryTrimFn = originalTrim
agentMemoryTrimRunning.Store(false)
agentMemoryTrimLastAt.Store(originalLastAt)
})
agentMemoryTrimRunning.Store(false)
agentMemoryTrimLastAt.Store(0)
triggered := 0
runAgentMemoryTrimAsync = func(fn func()) {
fn()
}
agentMemoryTrimFn = func() {
triggered++
}
maybeReleaseAgentMemory("test-large-query", agentMemoryTrimRowsThreshold)
if triggered != 1 {
t.Fatalf("大查询完成后应触发一次内存回收got=%d", triggered)
}
}

View File

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

View File

@@ -0,0 +1,7 @@
apiVersion: v2
name: gonavi-mcp-server
description: Helm chart for deploying GoNavi MCP Server
type: application
version: 0.1.0
appVersion: "latest"
kubeVersion: ">=1.24.0-0"

View File

@@ -0,0 +1,94 @@
# GoNavi MCP Server Helm Chart
这个 Chart 用来部署 `gonavi-mcp-server`,覆盖当前仓库 K8s 示例里的几类常见场景:
- 基于现有 Secret 直接部署
- 通过 Helm values 内联创建测试用 Secret
- 启用 Ingress
- 追加 `drivers/` PVC
- NAS / k3s 单机节点直接挂宿主机数据目录
Chart 目录:
- `Chart.yaml`
- `values.yaml`
- `values-examples/*.yaml`
- `templates/*.yaml`
## 1. 默认安装
默认值假设你已经有一个现成 Secret
- 名称:`gonavi-mcp-server-data`
- 至少包含:
- `connections.json`
- `daily_secrets.json`
- `GONAVI_MCP_HTTP_TOKEN`
安装命令:
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
```
## 2. 直接创建测试用 Secret
如果只是本地验证模板或快速试跑,可以使用示例 values
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/inline-secret.yaml
```
这个示例只适合最小联调,不适合真实生产连接数据。
## 3. 常用示例
启用 Ingress
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/ingress.yaml
```
挂载 drivers PVC
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/drivers-pvc.yaml
```
同时启用 Ingress 与 drivers PVC
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/ingress-with-drivers-pvc.yaml
```
NAS / k3s hostPath
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/nas-hostpath.yaml
```
## 4. 关键参数
- `image.repository` / `image.tag`
- `secret.create`
- `secret.name`
- `data.mode=secret|hostPath|pvc`
- `drivers.mode=none|pvc|hostPath`
- `ingress.enabled`
## 5. 约束
- `data.mode=secret`Secret 必须能提供 `connections.json``daily_secrets.json`
- `data.mode=hostPath` 时,宿主机目录需要包含 GoNavi 活动数据目录内容
- `drivers.mode!=none` 时,会在 `/data/drivers` 追加独立挂载
- Chart 默认只部署 MCP Server不部署桌面 GUI

View File

@@ -0,0 +1,49 @@
{{- define "gonavi-mcp-server.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "gonavi-mcp-server.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := include "gonavi-mcp-server.name" . -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "gonavi-mcp-server.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" -}}
{{- end -}}
{{- define "gonavi-mcp-server.labels" -}}
helm.sh/chart: {{ include "gonavi-mcp-server.chart" . }}
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "gonavi-mcp-server.selectorLabels" -}}
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "gonavi-mcp-server.namespace" -}}
{{- default .Release.Namespace .Values.namespaceOverride -}}
{{- end -}}
{{- define "gonavi-mcp-server.secretName" -}}
{{- if .Values.secret.name -}}
{{- .Values.secret.name -}}
{{- else -}}
{{- include "gonavi-mcp-server.fullname" . -}}
{{- end -}}
{{- end -}}
{{- define "gonavi-mcp-server.serviceName" -}}
{{- include "gonavi-mcp-server.fullname" . -}}
{{- end -}}

View File

@@ -0,0 +1,130 @@
{{- $dataMode := .Values.data.mode -}}
{{- $driversMode := .Values.drivers.mode -}}
{{- if not (or (eq $dataMode "secret") (eq $dataMode "hostPath") (eq $dataMode "pvc")) -}}
{{- fail "data.mode must be one of: secret, hostPath, pvc" -}}
{{- end -}}
{{- if not (or (eq $driversMode "none") (eq $driversMode "pvc") (eq $driversMode "hostPath")) -}}
{{- fail "drivers.mode must be one of: none, pvc, hostPath" -}}
{{- end -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gonavi-mcp-server.fullname" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 8 }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: gonavi-mcp-server
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args:
- http
{{- range .Values.mcp.extraArgs }}
- {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
env:
- name: GONAVI_DATA_ROOT
value: {{ .Values.mcp.dataRoot | quote }}
- name: GONAVI_LOG_DIR
value: {{ .Values.mcp.logDir | quote }}
- name: GONAVI_MCP_HTTP_ADDR
value: {{ .Values.mcp.httpAddr | quote }}
- name: GONAVI_MCP_HTTP_PATH
value: {{ .Values.mcp.httpPath | quote }}
- name: GONAVI_MCP_SCHEMA_ONLY
value: {{ ternary "true" "false" .Values.mcp.schemaOnly | quote }}
- name: GONAVI_MCP_HTTP_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "gonavi-mcp-server.secretName" . }}
key: {{ .Values.secret.tokenKey }}
{{- with .Values.mcp.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: gonavi-data
mountPath: {{ .Values.data.mountPath | quote }}
readOnly: true
{{- if ne $driversMode "none" }}
- name: gonavi-drivers
mountPath: {{ .Values.drivers.mountPath | quote }}
readOnly: true
{{- end }}
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 20
resources:
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
volumes:
- name: gonavi-data
{{- if eq $dataMode "secret" }}
projected:
sources:
- secret:
name: {{ include "gonavi-mcp-server.secretName" . }}
items:
- key: {{ .Values.secret.connectionsJsonKey }}
path: connections.json
- key: {{ .Values.secret.dailySecretsJsonKey }}
path: daily_secrets.json
{{- else if eq $dataMode "hostPath" }}
hostPath:
path: {{ required "data.hostPath.path is required when data.mode=hostPath" .Values.data.hostPath.path | quote }}
type: {{ default "Directory" .Values.data.hostPath.type | quote }}
{{- else if eq $dataMode "pvc" }}
persistentVolumeClaim:
claimName: {{ required "data.pvc.claimName is required when data.mode=pvc" .Values.data.pvc.claimName | quote }}
{{- end }}
{{- if eq $driversMode "pvc" }}
- name: gonavi-drivers
persistentVolumeClaim:
claimName: {{ required "drivers.pvc.claimName is required when drivers.mode=pvc" .Values.drivers.pvc.claimName | quote }}
{{- else if eq $driversMode "hostPath" }}
- name: gonavi-drivers
hostPath:
path: {{ required "drivers.hostPath.path is required when drivers.mode=hostPath" .Values.drivers.hostPath.path | quote }}
type: {{ default "Directory" .Values.drivers.hostPath.type | quote }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,34 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "gonavi-mcp-server.fullname" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className | quote }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path | quote }}
pathType: {{ .pathType | quote }}
backend:
service:
name: {{ include "gonavi-mcp-server.serviceName" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,27 @@
{{- if .Values.secret.create }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "gonavi-mcp-server.secretName" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
type: Opaque
stringData:
{{ .Values.secret.tokenKey }}: {{ required "secret.stringData.token is required when secret.create=true" .Values.secret.stringData.token | quote }}
{{- if eq .Values.data.mode "secret" }}
{{ .Values.secret.connectionsJsonKey }}: |
{{ required "secret.stringData.connectionsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.connectionsJson | nindent 4 }}
{{ .Values.secret.dailySecretsJsonKey }}: |
{{ required "secret.stringData.dailySecretsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.dailySecretsJson | nindent 4 }}
{{- else }}
{{- if .Values.secret.stringData.connectionsJson }}
{{ .Values.secret.connectionsJsonKey }}: |
{{ .Values.secret.stringData.connectionsJson | nindent 4 }}
{{- end }}
{{- if .Values.secret.stringData.dailySecretsJson }}
{{ .Values.secret.dailySecretsJsonKey }}: |
{{ .Values.secret.stringData.dailySecretsJson | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "gonavi-mcp-server.serviceName" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP

View File

@@ -0,0 +1,4 @@
drivers:
mode: pvc
pvc:
claimName: gonavi-drivers

View File

@@ -0,0 +1,19 @@
ingress:
enabled: true
className: nginx
hosts:
- host: gonavi-mcp.example.com
paths:
- path: /mcp
pathType: Prefix
- path: /healthz
pathType: Prefix
tls:
- secretName: gonavi-mcp-server-tls
hosts:
- gonavi-mcp.example.com
drivers:
mode: pvc
pvc:
claimName: gonavi-drivers

View File

@@ -0,0 +1,14 @@
ingress:
enabled: true
className: nginx
hosts:
- host: gonavi-mcp.example.com
paths:
- path: /mcp
pathType: Prefix
- path: /healthz
pathType: Prefix
tls:
- secretName: gonavi-mcp-server-tls
hosts:
- gonavi-mcp.example.com

View File

@@ -0,0 +1,13 @@
secret:
create: true
stringData:
token: replace-with-a-random-token
connectionsJson: |
{
"connections": []
}
dailySecretsJson: |
{
"schemaVersion": 1,
"connections": {}
}

View File

@@ -0,0 +1,10 @@
data:
mode: hostPath
hostPath:
path: /volume1/docker/gonavi/data
type: Directory
secret:
create: true
stringData:
token: replace-with-a-random-token

View File

@@ -0,0 +1,99 @@
nameOverride: ""
fullnameOverride: ""
namespaceOverride: ""
replicaCount: 1
image:
repository: ghcr.io/syngnat/gonavi-mcp-server
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8765
annotations: {}
mcp:
dataRoot: /data
logDir: /var/lib/gonavi/logs
httpAddr: 0.0.0.0:8765
httpPath: /mcp
schemaOnly: true
extraArgs: []
extraEnv: []
secret:
create: false
name: gonavi-mcp-server-data
tokenKey: GONAVI_MCP_HTTP_TOKEN
connectionsJsonKey: connections.json
dailySecretsJsonKey: daily_secrets.json
stringData:
token: ""
connectionsJson: ""
dailySecretsJson: ""
data:
mode: secret
mountPath: /data
hostPath:
path: /volume1/docker/gonavi/data
type: Directory
pvc:
claimName: ""
drivers:
mode: none
mountPath: /data/drivers
pvc:
claimName: gonavi-drivers
hostPath:
path: /volume1/docker/gonavi/drivers
type: Directory
ingress:
enabled: false
className: nginx
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
hosts:
- host: gonavi-mcp.example.com
paths:
- path: /mcp
pathType: Prefix
- path: /healthz
pathType: Prefix
tls:
- secretName: gonavi-mcp-server-tls
hosts:
- gonavi-mcp.example.com
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containerSecurityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -0,0 +1,151 @@
# GoNavi MCP Server Kubernetes 示例
这个目录提供 `gonavi-mcp-server` 的最小 K8s 部署清单,适合:
- Linux 服务器上的单集群部署
- NAS 自带 K8s / k3s / k8s-lite 环境
- 需要把 GoNavi MCP 通过 Ingress / Gateway 暴露给远端 Agent 的场景
如果你希望通过 values 参数统一控制镜像、Secret、Ingress、PVC/hostPath而不是维护多份 overlay请直接使用 [deploy/helm/gonavi-mcp-server](../../helm/gonavi-mcp-server)。
目录结构:
- `kustomization.yaml`:基础部署入口
- `base/kustomization.yaml`:基础资源集合
- `overlays/nas-hostpath`:直接挂 NAS / 单机节点上的 GoNavi 数据目录
- `base/deployment.yaml` / `base/service.yaml`:基础资源
- `overlays/drivers-pvc`:为 `/data/drivers` 增加 PVC 挂载
- `overlays/ingress`:增加 Ingress 暴露 `/mcp`
- `overlays/ingress-with-drivers-pvc`:同时启用 Ingress 和 drivers PVC
## 前提
容器仍然依赖 GoNavi 的活动数据目录。至少要准备:
- `connections.json`
- `daily_secrets.json`
如果目标连接依赖可选 driver agent还需要额外挂载 `/data/drivers`
`base/deployment.yaml` 默认镜像指向:
```text
ghcr.io/syngnat/gonavi-mcp-server:latest
```
如果你要跟随 `dev` 分支,可以改成:
```text
ghcr.io/syngnat/gonavi-mcp-server:dev-latest
```
如果你要使用自建镜像,再按下面方式覆盖 `image`
- 在当前节点可访问的 Docker / Podman / containerd 环境中构建并导入这个镜像
- 把镜像推送到你的私有仓库,并同步修改 `base/deployment.yaml` 里的 `image`
例如:
```bash
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
# or
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
```
## 1. 创建 Secret
推荐直接从现有 GoNavi 数据文件生成:
```bash
kubectl create secret generic gonavi-mcp-server-data \
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
```
如需单独 namespace请先执行
```bash
kubectl create namespace gonavi
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
```
## 2. 应用清单
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server
```
如果使用独立 namespace请先把 YAML 里的 `namespace` 改成你的目标值,或自行用 `kustomize` / Helm 做二次封装。
清单内已经带了基础安全上下文和默认资源配额;如果你的连接数量、对象规模或并发访问更高,建议按实际负载调整 `resources`
### 常用 Overlay
仅基础部署:
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server
```
需要可选 driver agent
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/drivers-pvc
```
NAS / k3s 单机节点直接挂宿主机目录:
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/nas-hostpath
```
需要对外暴露 `/mcp`
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress
```
同时需要 Ingress 与 drivers PVC
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress-with-drivers-pvc
```
应用 overlay 前,记得先改里面的占位值,例如:
- `overlays/ingress/ingress.yaml` 里的域名、TLS Secret、`ingressClassName`
- `overlays/drivers-pvc/patch-deployment.yaml` 里的 PVC 名称 `gonavi-drivers`
- `overlays/nas-hostpath/patch-deployment.yaml` 里的宿主机目录 `/volume1/docker/gonavi/data`
`nas-hostpath` overlay 会直接把整个 GoNavi 数据目录挂到 `/data`,因此 `connections.json``daily_secrets.json``drivers/` 都从宿主机目录读取;此时仍建议保留 `GONAVI_MCP_HTTP_TOKEN` 的 Secret 注入,不要把 token 直接硬编码进 Deployment。
## 3. 校验
```bash
kubectl get pods -n gonavi
kubectl get svc -n gonavi
kubectl port-forward -n gonavi svc/gonavi-mcp-server 8765:8765
curl -H "Authorization: Bearer replace-with-a-random-token" http://127.0.0.1:8765/healthz
```
`/healthz` 返回 `ok` 说明 Pod 已对外提供 HTTP 服务。
## 4. 暴露给远端 Agent
- 集群内使用:直接访问 `http://gonavi-mcp-server.gonavi.svc.cluster.local:8765/mcp`
- 集群外使用:通过 Ingress / Gateway / 反向代理暴露 `/mcp`
- 远端 Agent 请求头:`Authorization: Bearer <你的 token>`
## 5. drivers 挂载
当前示例默认只挂载 `connections.json``daily_secrets.json`。如果保存连接里包含 Dameng、ClickHouse、DuckDB 等依赖 driver agent 的数据源,请在 Deployment 中额外补一个卷,把宿主机或 PVC 中的 `drivers/` 目录挂载到:
```text
/data/drivers
```
否则对应连接会因为缺少 driver agent 无法使用。

View File

@@ -0,0 +1,89 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
labels:
app.kubernetes.io/name: gonavi-mcp-server
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: gonavi-mcp-server
template:
metadata:
labels:
app.kubernetes.io/name: gonavi-mcp-server
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: gonavi-mcp-server
# 如需跟随 dev 分支,可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
image: ghcr.io/syngnat/gonavi-mcp-server:latest
imagePullPolicy: IfNotPresent
args:
- http
ports:
- name: http
containerPort: 8765
protocol: TCP
env:
- name: GONAVI_DATA_ROOT
value: /data
- name: GONAVI_LOG_DIR
value: /var/lib/gonavi/logs
- name: GONAVI_MCP_HTTP_ADDR
value: 0.0.0.0:8765
- name: GONAVI_MCP_HTTP_PATH
value: /mcp
- name: GONAVI_MCP_SCHEMA_ONLY
value: "true"
- name: GONAVI_MCP_HTTP_TOKEN
valueFrom:
secretKeyRef:
name: gonavi-mcp-server-data
key: GONAVI_MCP_HTTP_TOKEN
volumeMounts:
- name: gonavi-data
mountPath: /data
readOnly: true
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumes:
- name: gonavi-data
projected:
sources:
- secret:
name: gonavi-mcp-server-data
items:
- key: connections.json
path: connections.json
- key: daily_secrets.json
path: daily_secrets.json

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: gonavi-mcp-server
namespace: gonavi
labels:
app.kubernetes.io/name: gonavi-mcp-server
spec:
selector:
app.kubernetes.io/name: gonavi-mcp-server
ports:
- name: http
port: 8765
targetPort: http
protocol: TCP

View File

@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- base

View File

@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-deployment.yaml

View File

@@ -0,0 +1,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
spec:
template:
spec:
containers:
- name: gonavi-mcp-server
volumeMounts:
- name: gonavi-drivers
mountPath: /data/drivers
readOnly: true
volumes:
- name: gonavi-drivers
persistentVolumeClaim:
claimName: gonavi-drivers

View File

@@ -0,0 +1,33 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gonavi-mcp-server
namespace: gonavi
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts:
- gonavi-mcp.example.com
secretName: gonavi-mcp-server-tls
rules:
- host: gonavi-mcp.example.com
http:
paths:
- path: /mcp
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765
- path: /healthz
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765

View File

@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
- ingress.yaml
patches:
- path: patch-deployment.yaml

View File

@@ -0,0 +1,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
spec:
template:
spec:
containers:
- name: gonavi-mcp-server
volumeMounts:
- name: gonavi-drivers
mountPath: /data/drivers
readOnly: true
volumes:
- name: gonavi-drivers
persistentVolumeClaim:
claimName: gonavi-drivers

View File

@@ -0,0 +1,33 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gonavi-mcp-server
namespace: gonavi
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts:
- gonavi-mcp.example.com
secretName: gonavi-mcp-server-tls
rules:
- host: gonavi-mcp.example.com
http:
paths:
- path: /mcp
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765
- path: /healthz
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
- ingress.yaml

View File

@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-deployment.yaml

View File

@@ -0,0 +1,14 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
spec:
template:
spec:
volumes:
- $patch: replace
name: gonavi-data
hostPath:
path: /volume1/docker/gonavi/data
type: Directory

View File

@@ -0,0 +1,101 @@
# GoNavi MCP Server Podman 示例
这个目录提供 `gonavi-mcp-server` 的 Podman 原生部署入口,覆盖两类常见场景:
- 直接用 `podman run` 在 Linux 服务器 / NAS 上启动
- 用 rootless Quadlet + systemd 做常驻服务
目录内容:
- `gonavi-mcp-server.env.example`:给 `podman run` / Quadlet 共用的容器环境变量示例
- `gonavi-mcp-server.container`Quadlet 示例
前提仍然一样:宿主机 GoNavi 活动数据目录内至少应包含:
- `connections.json`
- `daily_secrets.json`
如果目标连接依赖可选 driver agent还要保证同一数据目录下已有 `drivers/`
## 1. 直接运行已发布镜像
```bash
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
```
`GONAVI_MCP_HTTP_TOKEN` 改成随机值后运行:
```bash
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
ghcr.io/syngnat/gonavi-mcp-server:latest http
```
查看状态:
```bash
podman ps
podman logs -f gonavi-mcp-server
```
如果你的宿主机没有启用 SELinux可把挂载参数末尾的 `:Z` 去掉;如果启用了 SELinux建议保留它。
## 2. 本地源码构建镜像
仓库根目录已经提供 `.containerignore`Podman 会优先读取它来裁剪构建上下文。
```bash
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
localhost/gonavi-mcp-server:local http
```
## 3. Rootless Quadlet 常驻服务
Podman Quadlet 适合 Linux 服务器 / NAS 上做 rootless 常驻服务。
把文件放到用户级目录:
```bash
mkdir -p ~/.config/containers/systemd
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container ~/.config/containers/systemd/
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ~/.config/containers/systemd/gonavi-mcp-server.env
```
然后修改两处:
- `~/.config/containers/systemd/gonavi-mcp-server.container` 里的 `Volume=/absolute/path/to/gonavi-data:/data:Z`
- `~/.config/containers/systemd/gonavi-mcp-server.env` 里的 `GONAVI_MCP_HTTP_TOKEN`
启动服务:
```bash
systemctl --user daemon-reload
systemctl --user enable --now gonavi-mcp-server.service
systemctl --user status gonavi-mcp-server.service
journalctl --user -u gonavi-mcp-server.service -f
```
如果你希望用户退出登录后服务仍然保持运行,再执行:
```bash
loginctl enable-linger "$USER"
```
Rootless Quadlet 默认搜索路径是 `~/.config/containers/systemd/`;如果你要做系统级 rootful 部署,可改放到 `/etc/containers/systemd/`
## 4. 关于 Compose
Podman 的 `podman compose` 依赖外部 compose provider。也就是说是否能直接复用仓库根目录的 `docker-compose.mcp-server.yml`,取决于你的 Podman 环境是否已经安装并配置了对应 provider。
因此,这个仓库对 Podman 的主支持路径是:
- `podman run`
- Quadlet推荐长期运行
如果你的环境已经有可用的 compose provider再去复用根目录 Compose 文件即可。

View File

@@ -0,0 +1,22 @@
[Unit]
Description=GoNavi MCP Server (rootless Podman)
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=gonavi-mcp-server
Image=ghcr.io/syngnat/gonavi-mcp-server:latest
Pull=newer
EnvironmentFile=./gonavi-mcp-server.env
# 修改成你的 GoNavi 活动数据目录SELinux 主机建议保留 :Z
Volume=/absolute/path/to/gonavi-data:/data:Z
PublishPort=8765:8765
Exec=http
LogDriver=journald
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,15 @@
# 容器内 GoNavi 活动数据根目录。通常不需要修改Volume= 已把宿主机目录挂到 /data。
GONAVI_DATA_ROOT=/data
# 日志目录。默认与镜像内目录保持一致。
GONAVI_LOG_DIR=/var/lib/gonavi/logs
# MCP HTTP 监听地址与路径。
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765
GONAVI_MCP_HTTP_PATH=/mcp
# 远程 MCP 客户端访问时必须携带的 Bearer Token
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
# true = 只暴露结构查询工具,不注册 execute_sql
GONAVI_MCP_SCHEMA_ONLY=true

View File

@@ -0,0 +1,7 @@
services:
gonavi-mcp-server:
build:
context: .
dockerfile: Dockerfile.mcp-server
image: ${GONAVI_MCP_IMAGE_LOCAL:-gonavi-mcp-server:local}
pull_policy: never

View File

@@ -0,0 +1,20 @@
services:
gonavi-mcp-server:
image: ${GONAVI_MCP_IMAGE:-ghcr.io/syngnat/gonavi-mcp-server:latest}
pull_policy: ${GONAVI_MCP_PULL_POLICY:-missing}
container_name: gonavi-mcp-server
restart: unless-stopped
ports:
- "${GONAVI_MCP_HTTP_PORT:-8765}:8765"
environment:
GONAVI_DATA_ROOT: /data
GONAVI_LOG_DIR: /var/lib/gonavi/logs
GONAVI_MCP_HTTP_ADDR: 0.0.0.0:8765
GONAVI_MCP_HTTP_PATH: ${GONAVI_MCP_HTTP_PATH:-/mcp}
GONAVI_MCP_HTTP_TOKEN: ${GONAVI_MCP_HTTP_TOKEN}
GONAVI_MCP_SCHEMA_ONLY: ${GONAVI_MCP_SCHEMA_ONLY:-true}
volumes:
- type: bind
source: ${GONAVI_HOST_DATA_ROOT}
target: /data
command: ["http"]

View File

@@ -0,0 +1,20 @@
# 宿主机 GoNavi 活动数据目录,目录内至少应包含 connections.json 与 daily_secrets.json
GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data
# 预构建镜像地址。稳定版通常使用 latest跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
GONAVI_MCP_IMAGE=ghcr.io/syngnat/gonavi-mcp-server:latest
# 预构建镜像拉取策略always / missing / never
GONAVI_MCP_PULL_POLICY=missing
# 远程 MCP 客户端访问时必须携带的 Bearer Token
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
# 宿主机暴露端口
GONAVI_MCP_HTTP_PORT=8765
# MCP HTTP 路径
GONAVI_MCP_HTTP_PATH=/mcp
# true = 只暴露结构查询工具,不注册 execute_sql
GONAVI_MCP_SCHEMA_ONLY=true

View File

@@ -0,0 +1,45 @@
# 需求进度追踪 - Oracle对象跳转与存储过程修改执行
## 1. 需求摘要
- 需求名称Oracle对象跳转与存储过程修改执行
- 提出日期2026-06-25
- 负责人Codex
- 目标SQL编辑器支持 Oracle 序列、存储包 Ctrl/Cmd 点击跳转;对象编辑执行 Oracle 存储过程时不再生成非法第二条语句。
- 非目标:不调整 Oracle 连接驱动、不改数据库 schema、不重构侧栏对象树。
## 2. 范围与验收
- 范围QueryEditor 对象元数据、hover/跳转逻辑、DefinitionViewer 对象编辑 SQL 生成、相关 i18n 和回归测试。
- 验收标准:序列/存储包在 SQL 编辑器可出现链接提示并打开定义页;带 SQLPlus `/` 的 Oracle PL/SQL 编辑 SQL 不生成 `/;`,执行时保留完整定义交给后端拆分。
- 依赖与约束:复用现有侧栏 sequence/package tab 类型;不混入 `frontend/wailsjs/go/models.ts` 既有未提交改动。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认序列/存储包侧栏已可打开,但 SQL 编辑器跳转缺失;存储过程修改仍报 ORA-00900。
- [x] 阶段 2影响分析影响前端 QueryEditor、DefinitionViewer、i18n、测试后端拆分已有基础保护。
- [x] 阶段 3方案设计补齐元数据与跳转类型对象编辑 SQL 保留 SQLPlus `/` 语义。
- [x] 阶段 4实施计划先补导航闭环再修 PL/SQL 生成/执行,最后跑定向测试。
- [x] 阶段 5实现与自检已完成定向测试、QueryEditor 全文件测试、前端 build、后端 Oracle 拆分测试。
- [ ] 阶段 6评审与交付待提交推送。
- [ ] 阶段 7发布与观察发布后验证 Oracle 对象编辑与 SQL 编辑器跳转。
## 4. 变更清单
- 已完成:补齐 QueryEditor sequence/package 元数据、hover、点击打开定义页修复对象编辑 SQLPlus `/;`;执行路径兼容旧编辑页 `/;`
- 进行中:提交并推送。
- 待处理:发布后验证 Oracle 实库。
## 5. 风险与阻塞
- 风险:三段名称 `schema.package.proc``db.schema.table` 存在歧义。
- 阻塞:无。
- 缓解措施:仅当第一段不是可见数据库时,将三段名称按 Oracle schema-qualified sequence/package 解析。
## 6. 决策记录
- 决策 1序列和存储包复用侧栏现有 `sequence-def` / `package-def` tab 结构。
- 决策 2Oracle PL/SQL 定义执行时保留 SQLPlus `/` 分隔符,由后端拆分器去掉 delimiter避免前端用分号重组造成 ORA-00900。
## 7. 验证记录
- 验证项QueryEditor 导航回归测试、DefinitionViewer 对象编辑测试、SQL 语句拆分测试、i18n catalog 测试、前端 build、后端 Oracle 拆分测试。
- 结果:通过。
- 证据(日志/截图/链接):`npm test -- src/components/QueryEditor.external-sql-save.test.tsx``npm test -- src/components/DefinitionViewer.object-edit.test.tsx src/i18n/catalog.test.ts src/utils/sqlStatementSelection.test.ts``go test ./internal/app -run 'TestDBQueryMultiKeepsOracle|TestDBQueryMultiSkipsOracle'``npm run build`
## 8. 下一步
- 下一步行动:提交并推送到 dev。
- 负责人Codex

View File

@@ -0,0 +1,74 @@
# 需求进度追踪 - 生产连接只读保护
## 1. 需求摘要
- 需求名称:生产连接只读保护
- 提出日期2026-06-23
- 负责人Codex
- 目标:为 SQL 类数据库与 MongoDB 连接增加连接级只读保护启用后仅允许查询阻止写入、DDL、导入和同步目标操作
- 非目标:不为所有侧栏写操作都新增前端隐藏逻辑;不引入新的环境分级体系;不改造 JVM 只读能力
## 2. 范围与验收
- 范围:
- 连接配置模型、保存/回填与 RPC 序列化链路
- 连接弹窗只读开关、查询编辑器本地拦截、DataGrid 导入入口收口
- 后端 SQL/Mongo 查询判定与写操作统一守卫
- 验收标准:
- 支持的数据源出现“生产连接/只允许查询”开关
- 启用后普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止
- 导入、结构变更、清表、同步目标等关键写入口被后端拒绝
- 依赖与约束:
- 保持现有数据源能力判定与 QueryEditor 执行链路
- MongoDB 前端判定以保守拦截为主,最终正确性由后端守卫兜底
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认采用连接级 `readOnly` 布尔字段,不新建环境系统
- [x] 阶段 2影响分析梳理前端能力面板、查询执行、导入与后端写入口
- [x] 阶段 3方案设计确定“前端预拦截 + 后端最终守卫”双层保护
- [x] 阶段 4实施计划接入配置链路、能力判定、查询判定与写入口守卫
- [x] 阶段 5实现与自检补文案、测试与定向验证
- [x] 阶段 6评审与交付确认范围、风险、回滚点和验证命令
- [ ] 阶段 7发布与观察待体验验证
## 4. 变更清单
- 已完成:
- 新增连接级 `readOnly` 配置字段及前后端序列化支持
- 连接弹窗为 SQL 类数据库与 MongoDB 增加生产连接保护开关
- QueryEditor 增加本地非查询拦截DataGrid 导入入口在只读连接下禁用
- 后端为查询、DDL、导入、清表、同步等写入口增加统一只读守卫
- MongoDB 查询判定改为命令级白名单,不再把所有 JSON 命令都视为只读
- 补充前端/后端定向测试与需求追踪文档
- 进行中:
- 等待体验包验证连接弹窗、查询拦截和写操作拒绝文案
- 待处理:
- 如需进一步优化体验,再补侧栏对象级写菜单的前端隐藏/禁用
## 5. 风险与阻塞
- 风险:
- 前端 SQL/Mongo 只读判定是保守策略,边界命令可能仍需后端兜底
- 现有部分侧栏写菜单仍可能显示,但执行时会被后端拒绝
- 阻塞:
- 暂无
- 缓解措施:
- 关键写入口统一走后端守卫;前端只负责提前反馈与减少误操作
## 6. 决策记录
- 决策 1只对 SQL 类数据库和 MongoDB 支持连接级生产保护,其他数据源忽略 `readOnly`
- 决策 2采用顶层 `readOnly` 布尔字段,避免新增环境枚举和迁移成本
- 决策 3MongoDB 只读判定按命令白名单处理,防止把写命令误放行
## 7. 验证记录
- 验证项:
- 前端数据源能力判定、RPC 配置、连接配置测试
- 后端只读连接守卫与 SQL/Mongo 查询判定测试
- 结果:
- 通过
- 证据(日志/截图/链接):
- `go test ./internal/app -run 'Test(EnsureReadOnlyConnectionAllows|SupportsConnectionReadOnlyMode|IsReadOnlySQLQuery)'`
- `npm --prefix frontend test -- src/utils/dataSourceCapabilities.test.ts src/utils/connectionRpcConfig.test.ts src/components/connectionModal/connectionModalConfig.keepalive.test.ts`
## 8. 下一步
- 下一步行动:
- 用体验包回归验证生产连接下的查询、导入、建库删库、同步目标和 Mongo 命令拦截
- 如需更完整 UX再补侧栏写菜单的只读态收口
- 负责人:
- Codex

View File

@@ -14,9 +14,11 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@types/dagre": "^0.7.54",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
"dagre": "^0.8.5",
"fflate": "^0.8.3",
"mermaid": "^11.13.0",
"react": "^18.2.0",
@@ -24,6 +26,7 @@
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"reactflow": "^11.11.4",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
@@ -1214,6 +1217,108 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmmirror.com/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmmirror.com/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"license": "MIT",
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -1928,6 +2033,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.54",
"resolved": "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz",
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -2509,6 +2620,12 @@
"chevrotain": "^11.0.0"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -3081,6 +3198,16 @@
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
@@ -3387,6 +3514,15 @@
"node": ">=6.9.0"
}
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/hachure-fill": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
@@ -3675,6 +3811,12 @@
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
@@ -5713,6 +5855,24 @@
"react": "^18.2.0"
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmmirror.com/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"license": "MIT",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",

View File

@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run"
"test": "vitest run",
"i18n:scan": "go run ../tools/i18n-scan --root .."
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
@@ -16,9 +17,11 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@types/dagre": "^0.7.54",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
"dagre": "^0.8.5",
"fflate": "^0.8.3",
"mermaid": "^11.13.0",
"react": "^18.2.0",
@@ -26,6 +29,7 @@
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"reactflow": "^11.11.4",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",

View File

@@ -1 +1 @@
416aaa5c6e66a62430103d6905ad9465
1d8f9adbde8018f90d013cc740e0405b

View File

@@ -81,8 +81,8 @@ samp {
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-node-content-wrapper {
width: auto !important;
min-width: 0;
width: max-content !important;
min-width: 100%;
display: flex !important;
align-items: center;
gap: 8px;
@@ -106,7 +106,7 @@ samp {
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
flex: 0 0 auto;
min-width: 0;
min-width: max-content;
overflow: visible;
text-overflow: clip;
}
@@ -239,6 +239,35 @@ body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
background-clip: content-box;
}
.gn-query-monaco-stage .monaco-editor .suggest-details-container {
min-height: 260px;
}
.gn-query-monaco-stage .monaco-editor .suggest-details {
min-height: 260px;
}
.gn-query-monaco-stage .monaco-editor .suggest-widget .monaco-list .monaco-list-row > .contents > .main {
justify-content: flex-start;
gap: 6px;
}
.gn-query-monaco-stage .monaco-editor .suggest-widget .monaco-list .monaco-list-row > .contents > .main > .left {
flex: 0 1 auto;
min-width: 0;
}
.gn-query-monaco-stage .monaco-editor .suggest-widget .monaco-list .monaco-list-row > .contents > .main > .right {
flex: 1 1 auto;
max-width: none;
min-width: 0;
}
.gn-query-monaco-stage .monaco-editor .suggest-widget .monaco-list .monaco-list-row.string-label > .contents > .main > .right > .details-label {
display: inline !important;
margin-left: 0;
}
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
body {
transition: color 0.3s;
@@ -686,8 +715,31 @@ body[data-theme='dark'] .gonavi-query-editor-db-token {
color: #c4b5fd;
}
.gonavi-query-editor-ai-inline-ghost {
color: rgba(100, 116, 139, 0.7);
font-style: italic;
pointer-events: none;
}
.gonavi-query-editor-ai-inline-ghost-overlay {
color: rgba(100, 116, 139, 0.7);
font-style: italic;
pointer-events: none;
position: absolute;
white-space: pre;
z-index: 6;
}
body[data-theme='dark'] .gonavi-query-editor-ai-inline-ghost {
color: rgba(203, 213, 225, 0.56);
}
body[data-theme='dark'] .gonavi-query-editor-ai-inline-ghost-overlay {
color: rgba(203, 213, 225, 0.56);
}
/* Legacy sidebar resize bounds — mirror v2 .gn-v2-app-sider so Ant Design inline width locks do not collapse drag range. */
body[data-ui-version="legacy"] .ant-layout-sider {
min-width: 232px !important;
max-width: 420px !important;
max-width: min(960px, calc(100vw - 360px)) !important;
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
describe('settings center layout', () => {
it('uses the same split navigation shell as the tool center', () => {
expect(appSource).toContain("type SettingsCenterGroupKey = 'preferences' | 'services' | 'about';");
expect(appSource).toContain("type SettingsCenterPaneKey = 'language' | 'sidebar-metadata' | 'proxy' | 'web-auth';");
expect(appSource).toContain("const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState<SettingsCenterGroupKey>('preferences');");
expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);");
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
expect(appSource).toContain('style={toolCenterDetailPanelStyle}');
expect(appSource).toContain('style={toolCenterDetailBodyStyle}');
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
expect(appSource).toContain("title: t('app.settings.group.preferences.title')");
expect(appSource).toContain("title: t('app.settings.group.services.title')");
expect(appSource).toContain("title: t('app.settings.group.about.title')");
});
it('moves sidebar table metadata configuration into the settings center', () => {
expect(appSource).toContain("key: 'sidebar-metadata'");
expect(appSource).toContain("title: t('app.settings.sidebar_metadata.title')");
expect(appSource).toContain("description: t('app.settings.sidebar_metadata.description')");
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'sidebar-metadata')");
expect(appSource).toContain("setSidebarTableMetadataFieldSelected(");
expect(appSource).toContain('DndContext');
expect(appSource).toContain('SortableContext');
expect(appSource).toContain('handleSidebarMetadataDragEnd');
expect(appSource).toContain('sidebarTableMetadataFieldOrder');
expect(appSource).toContain('data-sidebar-metadata-field={field}');
expect(appSource).toContain("sidebarTableMetadataFields: DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS");
expect(appSource).toContain("t('sidebar.v2_table_group_menu.display_table_rows')");
expect(appSource).not.toContain("setIsLanguageModalOpen(true)");
});
it('adds browser auth management into the services settings group', () => {
expect(appSource).toContain("key: 'web-auth' as const");
expect(appSource).toContain("title: t('app.settings.entry.web_auth.title')");
expect(appSource).toContain("description: t('app.settings.entry.web_auth.description')");
expect(appSource).toContain("handleOpenSettingsCenterPane('services', 'web-auth')");
expect(appSource).toContain("<WebAuthSettingsPanel");
});
});

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
const tabDisplaySource = readFileSync(
fileURLToPath(new globalThis.URL('./utils/tabDisplay.ts', import.meta.url)),
'utf8',
);
describe('App tab display i18n guards', () => {
it('localizes the tab display settings copy and preview labels', () => {
[
'app.theme.tab_display.title',
'app.theme.tab_display.description',
'app.theme.tab_display.layout.single',
'app.theme.tab_display.layout.double',
'app.theme.tab_display.badge.current',
'app.theme.tab_display.row.primary',
'app.theme.tab_display.row.secondary',
'app.theme.tab_display.action.move_up',
'app.theme.tab_display.action.move_down',
'app.theme.tab_display.preview.prefix',
'app.theme.tab_display.preview.default_label',
'app.theme.tab_display.preview.secondary',
'app.theme.tab_display.preview.focused',
].forEach((key) => {
expect(appSource).toContain(`t('${key}'`);
});
[
'Tab 标签展示',
'自定义连接名、对象类型、对象名、数据库、Schema 和 Host/IP 的展示顺序',
"'单行'",
"'双行'",
'当前预览:',
'默认标签',
',副行',
';当前选中',
'上移',
'下移',
].forEach((legacyText) => {
expect(appSource).not.toContain(legacyText);
});
});
it('keeps tab display element metadata as i18n keys', () => {
[
'connection',
'kind',
'object',
'database',
'schema',
'host',
].forEach((elementKey) => {
expect(tabDisplaySource).toContain(`labelKey: 'app.theme.tab_display.element.${elementKey}.label'`);
expect(tabDisplaySource).toContain(`descriptionKey: 'app.theme.tab_display.element.${elementKey}.description'`);
});
[
'连接名',
'连接简称或环境名',
'对象类型',
'对象名',
'当前 DB / catalog 名称',
'连接目标地址摘要',
].forEach((legacyText) => {
expect(tabDisplaySource).not.toContain(legacyText);
});
});
});

View File

@@ -10,10 +10,26 @@ const appCss = readFileSync(
fileURLToPath(new globalThis.URL('./App.css', import.meta.url)),
'utf8',
);
const v2ThemeCss = readFileSync(
fileURLToPath(new globalThis.URL('./v2-theme.css', import.meta.url)),
'utf8',
);
const linuxCJKFontBannerSource = readFileSync(
fileURLToPath(new globalThis.URL('./components/LinuxCJKFontBanner.tsx', import.meta.url)),
'utf8',
);
const appUtilityStylesSource = readFileSync(
fileURLToPath(new globalThis.URL('./hooks/useAppUtilityStyles.tsx', import.meta.url)),
'utf8',
);
const appSidebarResizeSource = readFileSync(
fileURLToPath(new globalThis.URL('./hooks/useAppSidebarResize.ts', import.meta.url)),
'utf8',
);
const sidebarLayoutSource = readFileSync(
fileURLToPath(new globalThis.URL('./utils/sidebarLayout.ts', import.meta.url)),
'utf8',
);
const getGlobalShortcutCaseBlock = (action: string) => {
const caseToken = `case '${action}':`;
@@ -32,8 +48,12 @@ const getGlobalShortcutCaseBlock = (action: string) => {
describe('tool center menu entries', () => {
it('exposes snippet management next to shortcut management', () => {
expect(appSource).toContain("key: 'snippet-settings'");
expect(appSource).toContain("title: '代码片段管理'");
expect(appSource).toContain('setIsSnippetModalOpen(true)');
expect(appSource).toContain("title: t('app.tools.entry.snippets.title')");
expect(appSource).toContain("description: t('app.tools.entry.snippets.description')");
expect(appSource).toContain("handleOpenToolCenterPane('workspace', 'snippet-settings')");
expect(appSource).toContain('gonavi:open-snippet-settings');
expect(appSource).toContain("setIsSnippetModalOpen(false);");
expect(appSource).not.toContain('setIsSnippetModalOpen(true)');
const snippetIndex = appSource.indexOf("key: 'snippet-settings'");
const shortcutIndex = appSource.indexOf("key: 'shortcut-settings'", snippetIndex);
@@ -41,6 +61,122 @@ describe('tool center menu entries', () => {
expect(shortcutIndex).toBeGreaterThan(snippetIndex);
});
it('uses scalable side navigation for the tool center instead of horizontal segmented switching', () => {
expect(appSource).toContain("type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace';");
expect(appSource).toContain("const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState<ToolCenterGroupKey>('config');");
expect(appSource).toContain("const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState<ToolCenterGroupKey | null>(null);");
expect(appSource).toContain("title: t('app.tools.group.config.title')");
expect(appSource).toContain("title: t('app.tools.group.workflow.title')");
expect(appSource).toContain("title: t('app.tools.group.workspace.title')");
expect(appSource).toContain("toolCenterGroups.find((group) => group.key === activeToolCenterGroupKey)");
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("gridTemplateColumns: '232px minmax(0, 1fr)'");
expect(appUtilityStylesSource).toContain("const toolCenterNavPanelStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterNavScrollStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterContentPanelStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterDetailPanelStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterDetailBodyStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain('role="tablist" aria-orientation="vertical"');
expect(appSource).toContain('role="tab"');
expect(appSource).toContain('aria-selected={active}');
expect(appSource).toContain('title={`${group.title} - ${group.description}`}');
expect(appUtilityStylesSource).toContain("borderRight: `1px solid ${overlayTheme.divider}`");
expect(appSource).toContain('setActiveToolCenterPane(null);');
expect(appSource).toContain('group.items.length');
expect(appSource).toContain("const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => {");
expect(appSource).toContain("const [activeToolCenterPane, setActiveToolCenterPane] = useState<ToolCenterPaneState | null>(null);");
expect(appSource).toContain("const handleReturnToToolCenter = useCallback((closeChild?: () => void) => {");
expect(appSource).toContain("t('common.back_to_previous')");
expect(appSource).toContain("width={1080}");
expect(appSource).toContain('centered');
});
it('keeps the tool center modal height fixed across group switches and scrolls the list area internally', () => {
expect(appUtilityStylesSource).toContain('const toolCenterModalContentStyle = useMemo<React.CSSProperties>(() => ({');
expect(appUtilityStylesSource).toContain("height: 'min(820px, calc(100vh - 64px))'");
expect(appUtilityStylesSource).toContain("const toolCenterModalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterScrollableListStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain("body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 }");
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
expect(appSource).toContain('style={toolCenterDetailPanelStyle}');
expect(appSource).toContain('style={toolCenterDetailBodyStyle}');
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
expect(appUtilityStylesSource).toContain("overflowY: 'auto'");
expect(appSource).toContain("borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none'");
expect(appSource).toContain("borderBottom: `1px solid ${overlayTheme.divider}`");
});
it('lets the tool center detail header own embedded tool titles', () => {
const renderPaneStart = appSource.indexOf('const renderToolCenterPane = () => {');
const renderPaneSource = appSource.slice(
renderPaneStart,
appSource.indexOf('};\n\n return (', renderPaneStart),
);
const connectionPackageSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'connection-package')"),
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'data-root')"),
);
const dataRootSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'data-root')"),
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'security-update')"),
);
const securityUpdateSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'security-update')"),
renderPaneSource.indexOf("activeToolCenterPane.key === 'schema-compare'"),
);
const dataSyncSource = renderPaneSource.slice(
renderPaneSource.indexOf("activeToolCenterPane.key === 'schema-compare'"),
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'drivers')"),
);
const driverSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'drivers')"),
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'snippet-settings')"),
);
const snippetSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'snippet-settings')"),
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'shortcut-settings')"),
);
const shortcutSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'shortcut-settings')"),
renderPaneSource.indexOf('return null;', renderPaneSource.indexOf("if (activeToolCenterPane.key === 'shortcut-settings')")),
);
expect(appSource).toContain('activeToolCenterPaneItem?.title ?? activeToolCenterGroup.title');
expect(connectionPackageSource).toContain('<ConnectionPackagePasswordModal');
expect(connectionPackageSource).not.toContain('renderUtilityModalTitle');
expect(dataRootSource).toContain('title={null}');
expect(dataRootSource).toContain('closable={false}');
expect(dataRootSource).not.toContain('renderUtilityModalTitle');
expect(securityUpdateSource).toContain('<SecurityUpdateSettingsModal');
expect(securityUpdateSource).not.toContain('renderUtilityModalTitle');
expect(dataSyncSource).toContain('<DataSyncModal');
expect(dataSyncSource).not.toContain('renderUtilityModalTitle');
expect(driverSource).toContain('<DriverManagerModal');
expect(driverSource).not.toContain('renderUtilityModalTitle');
expect(snippetSource).toContain('<SnippetSettingsModal');
expect(snippetSource).not.toContain('renderUtilityModalTitle');
expect(shortcutSource).toContain('title={null}');
expect(shortcutSource).toContain('closable={false}');
expect(shortcutSource).not.toContain('renderUtilityModalTitle');
});
it('does not render an extra top back button in the tool center detail header', () => {
const detailHeaderSource = appSource.slice(
appSource.indexOf("{activeToolCenterPane ? ("),
appSource.indexOf('<div style={toolCenterDetailBodyStyle}>', appSource.indexOf("{activeToolCenterPane ? (")),
);
expect(detailHeaderSource).toContain('activeToolCenterPaneItem?.title ?? activeToolCenterGroup.title');
expect(detailHeaderSource).toContain('activeToolCenterPaneItem?.description ?? activeToolCenterGroup.description');
expect(detailHeaderSource).not.toContain('<Button onClick={closeToolCenterPane}>');
expect(detailHeaderSource).not.toContain("{t('common.back_to_previous')}");
});
it('keeps the v2 AI entry in the sidebar and the legacy AI entry on the content edge', () => {
expect(appSource).toContain('onToggleAI={toggleAIPanel}');
expect(appSource).toContain('renderLegacyAIEdgeHandle');
@@ -55,26 +191,44 @@ describe('tool center menu entries', () => {
expect(appSource).toContain('const handleOpenToolsModal = useCallback(');
expect(appSource).toContain('const handleOpenSettingsModal = useCallback(');
expect(appSource).toContain('const handleToggleLogPanel = useCallback(');
expect(appSource).toContain('new CustomEvent');
expect(appSource).toContain("'gonavi:show-sql-execution-log'");
expect(appSource).toContain("detail: { mode: 'open' }");
expect(appSource).toContain('toggleAppLogPanel();');
expect(appSource).toContain('const handleFocusSidebarSearch = useCallback(');
expect(appSource).toContain('const antdTheme = useMemo(() => ({');
expect(appSource).toContain('theme={antdTheme}');
expect(appSource).toContain('const sqlLogCount = useStore(state => state.sqlLogs.length);');
expect(appSource).toContain('onOpenTools={handleOpenToolsModal}');
expect(appSource).toContain('onOpenSettings={handleOpenSettingsModal}');
expect(appSource).toContain('onToggleLogPanel={handleToggleLogPanel}');
expect(appSource).toContain('onFocusCommandSearch={handleFocusSidebarSearch}');
expect(appSource).toContain('sqlLogCount={sqlLogCount}');
expect(appSource).not.toContain('onOpenTools={() => setIsToolsModalOpen(true)}');
expect(appSource).not.toContain('onOpenSettings={() => setIsSettingsModalOpen(true)}');
expect(appSource).not.toContain('onToggleLogPanel={() => setIsLogPanelOpen((prev) => !prev)}');
expect(appSource).not.toContain('sqlLogCount={sqlLogCount}');
expect(appSource).not.toContain('theme={{');
expect(appSource).not.toContain('const sqlLogs = useStore(state => state.sqlLogs);');
});
it('renders the shared SQL log panel only for legacy layouts', () => {
const logPanelIndex = appSource.indexOf('<LogPanel', appSource.indexOf('<Content'));
const logPanelGuardIndex = appSource.lastIndexOf('{isLogPanelOpen && (', logPanelIndex);
const legacyOnlyGuardIndex = appSource.lastIndexOf('{!isV2Ui && isLogPanelOpen && (', logPanelIndex);
expect(logPanelIndex).toBeGreaterThan(-1);
expect(logPanelGuardIndex).toBe(-1);
expect(legacyOnlyGuardIndex).toBeGreaterThan(-1);
expect(appSource).toContain('onClose={handleCloseLogPanel}');
expect(appSource).toContain('onResizeStart={handleLogResizeStart}');
});
it('lets the v2 Sidebar own the entire left layout instead of stacking legacy controls above it', () => {
const siderIndex = appSource.indexOf("className={isV2Ui ? 'gn-v2-app-sider' : undefined}");
const legacyGuardIndex = appSource.indexOf('{!isV2Ui && (', siderIndex);
const legacyCreateIndex = appSource.indexOf('新建连接', legacyGuardIndex);
const legacyCreateIndex = appSource.indexOf('<Button icon={<PlusOutlined />} onClick={handleCreateConnection}', legacyGuardIndex);
const legacyCreateTitleIndex = appSource.indexOf("title={t('connection.new')}", legacyCreateIndex);
const legacyQueryIndex = appSource.indexOf('<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery}', legacyGuardIndex);
const legacyQueryTitleIndex = appSource.indexOf("title={t('query.new')}", legacyQueryIndex);
const sidebarIndex = appSource.indexOf('<Sidebar', legacyGuardIndex);
const floatingLogIndex = appSource.indexOf('Floating SQL Log Toggle', sidebarIndex);
const floatingLogGuardIndex = appSource.indexOf('{!isV2Ui && (', floatingLogIndex);
@@ -83,6 +237,10 @@ describe('tool center menu entries', () => {
expect(legacyGuardIndex).toBeGreaterThan(siderIndex);
expect(legacyCreateIndex).toBeGreaterThan(legacyGuardIndex);
expect(legacyCreateIndex).toBeLessThan(sidebarIndex);
expect(legacyCreateTitleIndex).toBeGreaterThan(legacyCreateIndex);
expect(legacyQueryIndex).toBeGreaterThan(legacyCreateIndex);
expect(legacyQueryIndex).toBeLessThan(sidebarIndex);
expect(legacyQueryTitleIndex).toBeGreaterThan(legacyQueryIndex);
expect(appSource).toContain('paddingBottom: isV2Ui ? 0 : 58');
expect(floatingLogIndex).toBeGreaterThan(sidebarIndex);
expect(floatingLogGuardIndex).toBeGreaterThan(floatingLogIndex);
@@ -94,15 +252,26 @@ describe('tool center menu entries', () => {
expect(appSource).toContain("darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)'");
});
it('keeps tool center and settings v2 accents on the green palette instead of legacy yellow or blue tokens', () => {
expect(appSource).toContain("const v2AntPrimaryColor = darkMode ? '#22c55e' : '#16a34a';");
expect(appSource).toContain("colorPrimary: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#f6c453' : '#1677ff')");
expect(appSource).toContain("background: active\n ? overlayTheme.selectedBg");
expect(appSource).toContain("background: active\n ? overlayTheme.selectedText");
expect(appSource).toContain("background: active\n ? overlayTheme.iconBg");
expect(appSource).toContain("color: active\n ? overlayTheme.iconColor");
expect(appSource).toContain("background: isV2Ui ? v2AntPrimaryBgColor : (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.10)')");
expect(appSource).toContain("color: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff')");
});
it('does not start sidebar resize from right-clicking the resize handle', () => {
expect(appSource).toContain('if (e.button !== 0)');
expect(appSidebarResizeSource).toContain('if (e.button !== 0)');
expect(appSource).toContain('onContextMenu={(event) => {');
expect(appSource).toContain('event.preventDefault();');
expect(appSource).toContain('event.stopPropagation();');
const guardIndex = appSource.indexOf('if (e.button !== 0)');
const ghostDisplayIndex = appSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
const dragStartIndex = appSource.indexOf('sidebarDragRef.current = {', guardIndex);
const guardIndex = appSidebarResizeSource.indexOf('if (e.button !== 0)');
const ghostDisplayIndex = appSidebarResizeSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
const dragStartIndex = appSidebarResizeSource.indexOf('sidebarDragRef.current = {', guardIndex);
expect(guardIndex).toBeGreaterThan(-1);
expect(ghostDisplayIndex).toBeGreaterThan(guardIndex);
@@ -110,18 +279,22 @@ describe('tool center menu entries', () => {
});
it('positions sidebar resize guide from the rendered sider edge', () => {
expect(appSource).toContain('const siderRef = React.useRef<HTMLDivElement | null>(null);');
expect(appSidebarResizeSource).toContain('const siderRef = useRef<HTMLDivElement | null>(null);');
expect(appSource).toContain('ref={siderRef}');
expect(appSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
expect(appSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
expect(appSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
expect(appSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
expect(appSidebarResizeSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
expect(appSidebarResizeSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
expect(appSidebarResizeSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
expect(appSidebarResizeSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
});
it('keeps legacy sidebar resize bounds aligned with the v2 sider CSS limits', () => {
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*420px\s*!important;/s);
it('keeps sidebar resize bounds aligned across drag logic and sider CSS limits', () => {
expect(sidebarLayoutSource).toContain('export const SIDEBAR_RESIZE_MAX_WIDTH = 960;');
expect(sidebarLayoutSource).toContain('export const SIDEBAR_MIN_WORKBENCH_WIDTH = 360;');
expect(appSidebarResizeSource).toContain('resolveSidebarResizeMaxWidth(window.innerWidth, minWidth)');
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s);
expect(v2ThemeCss).toMatch(/body\[data-ui-version="v2"\]\s+\.gn-v2-app-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s);
});
it('keeps connection modal warm-mounted while leaving the other heavyweight modals conditional', () => {
@@ -139,6 +312,10 @@ describe('tool center menu entries', () => {
it('loads editable connection details before opening the edit modal so stored secrets can be shown', () => {
expect(appSource).toContain("typeof backendApp?.GetEditableSavedConnection === 'function'");
expect(appSource).toContain('const editableConnection = await backendApp.GetEditableSavedConnection(conn.id);');
expect(appSource).toContain('const errorMessage = error?.message;');
expect(appSource).toContain("typeof errorMessage === 'string'");
expect(appSource).toContain("t('app.connection.message.editable_load_failed_with_detail', { detail })");
expect(appSource).toContain("t('app.connection.message.editable_load_failed')");
expect(appSource).toContain('setEditingConnection(nextConnection);');
expect(appSource).toContain('setIsModalOpen(true);');
});
@@ -179,7 +356,7 @@ describe('tool center menu entries', () => {
['newConnection', 'handleCreateConnection();'],
['toggleAIPanel', 'toggleAIPanel();'],
['toggleLogPanel', 'handleToggleLogPanel();'],
['toggleTheme', 'setTheme('],
['toggleTheme', 'setThemePreference('],
['openShortcutManager', 'setIsShortcutModalOpen(true);'],
['toggleMacFullscreen', 'handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });'],
['resetWindowZoom', 'handleManualResetWindowZoom();'],
@@ -208,9 +385,13 @@ describe('tool center menu entries', () => {
it('captures window state on startup and lifecycle events instead of waiting only for the polling interval', () => {
expect(appSource).toContain('const scheduleWindowStateSave = (delayMs = 120) => {');
expect(appSource).toContain('const scheduleWindowBoundsRepair = (delayMs = 80) => {');
expect(appSource).toContain('if (hydrated) {');
expect(appSource).toContain('scheduleWindowBoundsRepair(360);');
expect(appSource).toContain('scheduleWindowStateSave(320);');
expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {');
expect(appSource).toContain('scheduleWindowBoundsRepair();');
expect(appSource).toContain('scheduleWindowStateSave(260);');
expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);");
@@ -218,6 +399,15 @@ describe('tool center menu entries', () => {
expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });");
});
it('clamps normal runtime window bounds back into the visible screen after display changes', () => {
expect(appSource).toContain('const readCurrentVisibleViewport = () => ({');
expect(appSource).toContain('const repairRuntimeWindowBounds = async () => {');
expect(appSource).toContain('const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());');
expect(appSource).toContain("void emitWindowDiagnostic('adjust:runtime-window-bounds'");
expect(appSource).toContain('WindowSetSize(nextBounds.width, nextBounds.height);');
expect(appSource).toContain('WindowSetPosition(nextBounds.x, nextBounds.y);');
});
it('keeps titlebar double-click on maximise while shortcuts may enter macOS fullscreen', () => {
expect(appSource).toContain('const handleTitleBarWindowToggle = async (options?: { allowMacNativeFullscreen?: boolean }) => {');
expect(appSource).toContain('const allowMacNativeFullscreen = options?.allowMacNativeFullscreen === true;');
@@ -255,20 +445,54 @@ describe('global appearance tokens', () => {
expect(appSource).toContain("setProperty('--gn-font-size-mono'");
expect(appSource).toContain("setProperty('--gn-data-table-font-size'");
expect(appSource).toContain("setProperty('--gn-sidebar-tree-font-size'");
expect(appSource).toContain("setProperty('--gn-sidebar-rail-scale'");
expect(appSource).toContain("setProperty('--gn-control-height'");
expect(appSource).toContain("setProperty('--gn-control-height-sm'");
expect(appSource).toContain('fontFamily: resolvedUiFontFamily');
expect(appSource).toContain('fontFamilyCode: resolvedMonoFontFamily');
expect(appSource).toContain('数据表字体大小');
expect(appSource).toContain('左侧库表字体大小');
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies)');
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies)');
expect(appSource).toContain('const effectiveSidebarRailScale = sanitizeV2SidebarRailScale(appearance.v2SidebarRailScale);');
expect(appSource).toContain("t('app.theme.appearance.sidebar_rail_scale_title')");
expect(appSource).toContain("t('app.theme.appearance.sidebar_rail_scale_hint')");
expect(appSource).toContain('v2SidebarRailScale: sanitizeV2SidebarRailScale(value)');
expect(appSource).toContain("t('app.theme.data_table.font_size')");
expect(appSource).toContain("t('app.theme.data_table.sidebar_tree_font_size')");
expect(v2ThemeCss).toContain('--gn-sidebar-rail-scale');
expect(v2ThemeCss).toContain('font-size: calc(var(--gn-font-size-sm, 12px) * var(--gn-sidebar-rail-scale, 1));');
expect(v2ThemeCss).toContain('width: calc(38px * var(--gn-v2-rail-scale));');
expect(appSource).toContain("const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action')");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_data')");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_design')");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action_hint')");
expect(appSource).toContain("setAppearance({ tableDoubleClickAction: value as 'open-data' | 'open-design' })");
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies, t)');
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies, t)');
expect(appSource).toContain('ListInstalledFontFamilies()');
expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState<InstalledFontFamily[]>(EMPTY_INSTALLED_FONT_FAMILIES);');
expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';");
expect(appSource).toContain('<LinuxCJKFontBanner');
expect(linuxCJKFontBannerSource).toContain('data-gonavi-linux-cjk-font-banner="true"');
expect(linuxCJKFontBannerSource).toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失');
expect(linuxCJKFontBannerSource).toContain('useI18n');
expect(linuxCJKFontBannerSource).toContain("t('app.linux_cjk_font_banner.title')");
expect(linuxCJKFontBannerSource).toContain("t('app.linux_cjk_font_banner.description')");
expect(linuxCJKFontBannerSource).toContain("t('app.linux_cjk_font_banner.action.open_font_settings')");
expect(linuxCJKFontBannerSource).toContain("t('common.close')");
expect(linuxCJKFontBannerSource).not.toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失');
expect(linuxCJKFontBannerSource).not.toContain('Chinese text may render as');
expect(linuxCJKFontBannerSource).not.toContain('Font Settings');
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_prefix')");
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_suffix')");
expect(appSource).toContain("t('app.theme.query_template.title')");
expect(appSource).toContain("t('app.theme.query_template.description')");
expect(appSource).toContain("t('app.theme.query_template.hint')");
expect(appSource).toContain("t('app.theme.query_template.reset_default')");
expect(appSource).toContain("const newQuerySqlTemplate = appearance.newQuerySqlTemplate ?? DEFAULT_QUERY_TEMPLATE;");
expect(appSource).toContain("onChange={(event) => setAppearance({ newQuerySqlTemplate: event.target.value })}");
expect(appSource).toContain("onClick={() => setAppearance({ newQuerySqlTemplate: null })}");
expect(appSource).not.toContain('Ubuntu/Linux 未检测到中文 CJK 字体');
expect(appSource).not.toContain(',然后重启 GoNavi。');
expect(appSource).not.toContain('新建查询默认 SQL');
expect(appSource).not.toContain('清空后新建查询将保持空白');
expect(appSource).toContain('setIsLinuxCJKFontBannerDismissed(true)');
expect(appSource).toContain('matchFontFamilyOption');
expect(appSource).toContain('showSearch');

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
describe('App tools entry i18n guards', () => {
it('localizes compare tool entry titles and descriptions', () => {
expect(appSource).toContain("t('app.tools.entry.schema_compare.title')");
expect(appSource).toContain("t('app.tools.entry.schema_compare.description')");
expect(appSource).toContain("t('app.tools.entry.data_compare.title')");
expect(appSource).toContain("t('app.tools.entry.data_compare.description')");
expect(appSource).not.toContain("title: '表结构比对'");
expect(appSource).not.toContain("description: '对比源表与目标表结构差异,只预览不执行。'");
expect(appSource).not.toContain("title: '数据比对'");
expect(appSource).not.toContain("description: '按主键分析新增、更新、删除和相同行。'");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -15,37 +15,48 @@ describe('UI version switch placement', () => {
it('keeps the UI version switch in theme mode and outside macOS-only settings', () => {
const themeBranchIndex = appSource.indexOf("{themeModalSection === 'theme' ? (");
const uiVersionIndex = appSource.indexOf('界面版本', themeBranchIndex);
const lightThemeIndex = appSource.indexOf('亮色主题', themeBranchIndex);
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')", themeBranchIndex);
const lightThemeIndex = appSource.indexOf("t('app.theme.mode.light.label')", themeBranchIndex);
const appearanceBranchIndex = appSource.indexOf(') : (', themeBranchIndex);
const macWindowIndex = appSource.indexOf('macOS 窗口控制');
const macWindowIndex = appSource.indexOf("t('app.theme.mac_window.title')");
expect(themeBranchIndex).toBeGreaterThan(-1);
expect(uiVersionIndex).toBeGreaterThan(themeBranchIndex);
expect(uiVersionIndex).toBeLessThan(lightThemeIndex);
expect(uiVersionIndex).toBeLessThan(appearanceBranchIndex);
expect(macWindowIndex).toBeGreaterThan(uiVersionIndex);
expect(appSource).toContain("badge: '默认'");
expect(appSource).toContain("badge: 'Beta'");
expect(appSource).toContain("badge: t('app.theme.ui_version.legacy.badge')");
expect(appSource).toContain("badge: t('app.theme.ui_version.v2.badge')");
expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}");
expect(appSource).toContain('新版 UI 仍在 Beta');
expect(appSource).toContain('Windows、macOS 与 Linux 均可切换');
expect(appSource).toContain('新版左侧搜索模式');
expect(appSource).toContain("t('app.theme.ui_version.beta_warning')");
expect(appSource).toContain("t('app.theme.ui_version.platform_hint')");
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')");
expect(appSource).toContain("value={appearance.v2SidebarSearchMode ?? 'command'}");
expect(appSource).toContain("setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })");
});
it('uses the card-style v2 switch from the redesign instead of the segmented pill', () => {
const uiVersionIndex = appSource.indexOf('界面版本');
const themeModeIndex = appSource.indexOf('主题模式', uiVersionIndex);
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')");
const themeModeIndex = appSource.indexOf("t('app.theme.mode_title')", uiVersionIndex);
const uiVersionBlock = appSource.slice(uiVersionIndex, themeModeIndex);
expect(uiVersionBlock).toContain('NEW');
expect(uiVersionBlock).toContain("t('app.theme.ui_version.badge.new')");
expect(uiVersionBlock).toContain("gridTemplateColumns: 'repeat(2, minmax(0, 1fr))'");
expect(uiVersionBlock).toContain("label: '旧版 UI'");
expect(uiVersionBlock).toContain("label: '新版 UI'");
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.legacy.label')");
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.v2.label')");
expect(uiVersionBlock).toContain('CheckOutlined');
expect(uiVersionBlock).toContain('新版左侧搜索模式');
expect(uiVersionBlock).toContain("t('app.theme.ui_version.sidebar_search.title')");
expect(uiVersionBlock).toContain('<Segmented');
});
it('localizes the v2 sidebar search mode copy', () => {
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')");
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.command')");
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.filter')");
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.hint')");
expect(appSource).not.toContain('新版左侧搜索模式');
expect(appSource).not.toContain('新版命令搜索');
expect(appSource).not.toContain('旧版侧栏筛选');
expect(appSource).not.toContain('新版命令搜索适合跳转连接、表和动作');
});
});

View File

@@ -1,112 +1,100 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { t as catalogTranslate } from '../i18n/catalog';
const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8');
const testSource = readFileSync(new URL('./AIChatPanel.message-boundary.test.tsx', import.meta.url), 'utf8');
const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8');
const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8');
const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8');
const autoContextSource = readFileSync(new URL('./ai/useAIChatAutoContext.ts', import.meta.url), 'utf8');
const payloadDispatchSource = readFileSync(new URL('./ai/aiChatPayloadDispatch.ts', import.meta.url), 'utf8');
const planContextSource = readFileSync(new URL('./ai/useAIChatPlanContexts.ts', import.meta.url), 'utf8');
const resizeSource = readFileSync(new URL('./ai/useAIChatPanelResize.ts', import.meta.url), 'utf8');
const runtimeResourcesSource = readFileSync(new URL('./ai/useAIChatRuntimeResources.ts', import.meta.url), 'utf8');
const sessionStateSource = readFileSync(new URL('./ai/useAIChatSessionState.ts', import.meta.url), 'utf8');
const titleGeneratorSource = readFileSync(new URL('./ai/useAIChatSessionTitleGenerator.ts', import.meta.url), 'utf8');
const localToolsSource = readFileSync(new URL('./ai/useAIChatLocalTools.ts', import.meta.url), 'utf8');
const streamSubscriptionSource = readFileSync(new URL('./ai/useAIChatStreamSubscription.ts', import.meta.url), 'utf8');
const inspectionGuidanceSource = readFileSync(new URL('./ai/aiSystemInspectionGuidance.ts', import.meta.url), 'utf8');
const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8');
const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8');
describe('AIChatPanel message render isolation', () => {
it('keeps per-message render failures scoped to the broken bubble', () => {
const REQUIRED_RENDER_BOUNDARY_KEYS = [
'ai_chat.message.render_error.title',
'ai_chat.message.render_error.body',
'ai_chat.message.render_error.unknown',
'ai_chat.message.render_error.retry',
'ai_chat.message.render_error.delete',
] as const;
describe('AIChatPanel merge resolution', () => {
it('clears conflict markers from the merged files', () => {
expect(source).not.toMatch(/^<{7}|^={7}|^>{7}/m);
expect(testSource).not.toMatch(/^<{7}|^={7}|^>{7}/m);
});
it('keeps dev split architecture while retaining render-boundary isolation', () => {
expect(source).toContain("import AIChatPanelConversationView from './ai/AIChatPanelConversationView';");
expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component');
expect(source).toContain('[AI Message Render Error]');
expect(conversationViewSource).toContain("import AIMessageRenderBoundary from './AIMessageRenderBoundary';");
expect(boundarySource).toContain('这条 AI 消息渲染失败,已自动隔离');
expect(source).toContain('__gonaviLastAIMessageRenderError');
expect(conversationViewSource).toContain('<AIMessageRenderBoundary');
expect(conversationViewSource).toContain('onDeleteMessage={onDeleteMessage}');
});
it('loads user prompt settings and appends them as system messages', () => {
expect(source).toContain("import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources';");
expect(source).toContain('useAIChatRuntimeResources({ onOpenSettings })');
expect(runtimeResourcesSource).toContain('AIGetUserPromptSettings');
expect(runtimeResourcesSource).toContain("window.addEventListener('gonavi:ai:config-changed'");
expect(systemContextSource).toContain('以下是当前用户的自定义补充提示词');
expect(systemContextSource).toContain("appendCustomPromptGroup(systemMessages, ['database']");
});
it('loads MCP tools and skills into the runtime tool chain', () => {
expect(runtimeResourcesSource).toContain('AIListMCPTools');
expect(runtimeResourcesSource).toContain('AIGetSkills');
expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';");
expect(localToolsSource).toContain('executeLocalAIToolCall');
expect(systemContextSource).toContain('以下是当前启用的 Skill');
expect(source).toContain('buildAvailableAIChatTools');
});
it('teaches the runtime to use deeper schema tools when analyzing structure details', () => {
expect(systemContextSource).toContain('get_indexes、get_foreign_keys、get_triggers、get_table_ddl');
expect(systemContextSource).toContain('inspect_active_tab 读取当前活动页签上下文');
expect(systemContextSource).toContain('inspect_workspace_tabs 盘点当前工作区');
expect(inspectionGuidanceSource).toContain('inspect_current_connection');
expect(inspectionGuidanceSource).toContain('inspect_external_sql_directories');
expect(inspectionGuidanceSource).toContain('inspect_external_sql_file');
expect(localToolsSource).toContain('tabs: currentState.tabs');
expect(localToolsSource).toContain('activeTabId: currentState.activeTabId');
expect(localToolsSource).toContain('externalSQLDirectories: currentState.externalSQLDirectories');
expect(localToolsSource).toContain('toolContextMap: toolContextMapRef.current');
expect(localToolsSource).toContain('buildToolResultMessage');
});
it('extracts chat runtime helpers so context compression and error cleanup stay out of the panel file', () => {
expect(source).toContain("import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch';");
expect(source).toContain("import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';");
expect(source).toContain('compressContextIfNeeded, getDynamicMaxContextChars');
expect(source).toContain('useAIChatStreamSubscription({');
expect(source).toContain('useAIChatLocalTools({');
expect(runtimeSource).toContain('export const getDynamicMaxContextChars');
expect(runtimeSource).toContain('export const compressContextIfNeeded');
expect(runtimeSource).toContain('export const sanitizeErrorMsg');
expect(payloadDispatchSource).toContain('export const dispatchAIChatPayload');
expect(payloadDispatchSource).toContain('sanitizeErrorMsg');
expect(localToolsSource).toContain('compressContextIfNeeded');
expect(localToolsSource).toContain('dispatchAIChatPayload');
expect(streamSubscriptionSource).toContain('EventsOn(eventName, handler);');
expect(streamSubscriptionSource).toContain('请直接使用 function call 调用工具执行操作');
expect(streamSubscriptionSource).toContain('executeLocalTools(existing.tool_calls!, doneAssistantId)');
expect(runtimeSource).toContain('⚙️ 对话已超载,正在启动记忆压缩');
});
it('keeps the v2 history mode sorted by the latest updated session first', () => {
expect(source).toContain("import { useAIChatSessionState } from './ai/useAIChatSessionState';");
expect(source).toContain('const panelHistorySessions = useMemo(');
expect(sessionStateSource).toContain('right.updatedAt - left.updatedAt');
expect(sessionStateSource).toContain("const sid = aiActiveSessionId || 'session-fallback';");
expect(source).toContain('buildAIChatInlineHistorySessions(orderedAISessions)');
expect(derivedStateSource).toContain('export const buildAIChatInlineHistorySessions');
expect(derivedStateSource).toContain('sessions.slice(0, limit)');
expect(source).toContain('sessions={panelHistorySessions}');
});
it('extracts plan-context, auto-context, title, and resize hooks so the panel file stays focused on orchestration', () => {
expect(source).toContain("import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts';");
expect(source).toContain("import { useAIChatAutoContext } from './ai/useAIChatAutoContext';");
expect(source).toContain("import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator';");
expect(source).toContain("import { useAIChatPanelResize } from './ai/useAIChatPanelResize';");
expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';");
expect(planContextSource).toContain('export const useAIChatPlanContexts');
expect(planContextSource).toContain('pendingJVMPlanContextRef');
expect(autoContextSource).toContain('export const useAIChatAutoContext');
expect(autoContextSource).toContain('DBShowCreateTable');
expect(titleGeneratorSource).toContain('export const useAIChatSessionTitleGenerator');
expect(titleGeneratorSource).toContain('Failed to auto-generate title');
expect(resizeSource).toContain('export const useAIChatPanelResize');
expect(resizeSource).toContain('document.body.style.pointerEvents = \'none\'');
expect(localToolsSource).toContain('export const useAIChatLocalTools');
expect(localToolsSource).toContain('MAX_TOOL_CALL_ROUNDS');
expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component');
expect(conversationViewSource).toContain("import AIMessageRenderBoundary from './AIMessageRenderBoundary';");
expect(conversationViewSource).toContain('<AIMessageRenderBoundary');
expect(source).toContain('onMessageRenderError={handleMessageRenderError}');
expect(source).toContain('__gonaviLastAIMessageRenderError');
expect(source).toContain('[AI Message Render Error]');
});
it('keeps render-boundary recovery chrome translated through catalog keys', () => {
expect(boundarySource).toContain('useOptionalI18n()');
expect(boundarySource).toContain("catalogTranslate('en-US'");
for (const key of REQUIRED_RENDER_BOUNDARY_KEYS) {
expect(catalogTranslate('en-US', key)).not.toBe(key);
expect(catalogTranslate('zh-CN', key)).not.toBe(key);
expect(boundarySource).toContain(key);
}
for (const oldCopy of [
'这条 AI 消息渲染失败,已自动隔离',
'其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。',
'未知渲染错误',
'重试渲染',
'删除这条消息',
]) {
expect(boundarySource).not.toContain(oldCopy);
}
});
it('restores panel-level i18n orchestration for composer notices and send lifecycle text', () => {
expect(source).toContain("import { useI18n } from '../i18n/provider';");
expect(source).toContain("import type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice';");
expect(source).toContain("import { buildAIComposerNotice } from '../utils/aiComposerNotice';");
expect(source).toContain("const { t } = useI18n();");
expect(source).toContain("const [composerNoticeState, setComposerNoticeState] = useState<AIComposerNoticeDescriptor | null>(null);");
expect(source).toContain("buildAIComposerNotice(t, composerNoticeState) ?? runtimeComposerNotice");
expect(source).toContain("setComposerNoticeState({ kind: 'missing_provider' });");
expect(source).toContain("setComposerNoticeState({ kind: 'provider_incomplete', issues: readiness.issues });");
expect(source).toContain("setComposerNoticeState({ kind: 'missing_model' });");
expect(source).toContain('const chatMessages = [...messages, userMsg].map((message) => toAIRequestMessage(message, t));');
expect(source).toContain('toAIRequestMessage(userMsg, t)');
for (const key of [
'ai_chat.panel.status.model_connecting',
'ai_chat.panel.status.waking_engine',
'ai_chat.panel.status.waiting_response',
'ai_chat.panel.status.memory_summary',
'ai_chat.panel.message.service_not_ready',
]) {
expect(source).toContain(`t('${key}'`);
}
expect(source).not.toContain('buildMissingProviderNotice');
expect(source).not.toContain('buildIncompleteProviderNotice');
expect(source).not.toContain('buildMissingModelNotice');
});
it('keeps translated session and insight chrome in the panel layer instead of falling back to hardcoded copy', () => {
expect(source).toContain("() => orderedAISessions.find((session) => session.id === sid)?.title || t('ai_chat.panel.session.default_title')");
expect(source).toContain("title: session.title || t('ai_chat.panel.session.default_title')");
expect(source).toContain('buildAIChatInsights({');
expect(source).toContain('translate: t,');
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.context.linked_title', { count: contextCount })");
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.context.linked_body', { tables: tablePreview })");
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() })");
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length })");
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.write.detected_title', { count: writeCount })");
expect(source).not.toContain("|| '新对话'");
});
});

View File

@@ -16,11 +16,8 @@ import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
import AIChatPanelConversationView from './ai/AIChatPanelConversationView';
import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
buildIncompleteProviderNotice,
buildMissingModelNotice,
buildMissingProviderNotice,
} from '../utils/aiComposerNotice';
import type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice';
import { buildAIComposerNotice } from '../utils/aiComposerNotice';
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
import { toAIRequestMessage } from '../utils/aiMessagePayload';
import { compressContextIfNeeded, getDynamicMaxContextChars } from '../utils/aiChatRuntime';
@@ -28,8 +25,8 @@ import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts'
import { isMacLikePlatform } from '../utils/appearance';
import { buildAvailableAIChatTools } from '../utils/aiToolRegistry';
import {
buildAIChatInlineHistorySessions,
buildAIChatInsights,
buildAIChatInlineHistorySessions,
calculateAIContextUsageChars,
collectAIChatContextTableNames,
inferAIChatConnectionContext,
@@ -45,6 +42,7 @@ import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts';
import { useAIChatSessionState } from './ai/useAIChatSessionState';
import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator';
import { useAIChatLocalTools } from './ai/useAIChatLocalTools';
import { useI18n } from '../i18n/provider';
interface AIChatPanelProps {
width?: number;
@@ -58,18 +56,20 @@ interface AIChatPanelProps {
const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
export const AIChatPanel: React.FC<AIChatPanelProps> = ({
width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme
export const AIChatPanel: React.FC<AIChatPanelProps> = ({
width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme
}) => {
const { t } = useI18n();
const [input, setInput] = useState('');
const [draftAttachments, setDraftAttachments] = useState<AIChatAttachment[]>([]);
const [sending, setSending] = useState(false);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat');
const [composerNoticeState, setComposerNoticeState] = useState<AIComposerNoticeDescriptor | null>(null);
const {
activeProvider,
composerNotice,
composerNotice: runtimeComposerNotice,
dynamicModels,
fetchDynamicModels,
handleComposerAction,
@@ -77,14 +77,13 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
handleOpenSettingsFromPanel,
loadingModels,
mcpTools,
setComposerNotice,
skills,
userPromptSettings,
} = useAIChatRuntimeResources({ onOpenSettings });
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
const nudgeCountRef = useRef(0);
const {
getCurrentJVMPlanContext,
getCurrentJVMDiagnosticPlanContext,
@@ -92,7 +91,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
pendingJVMDiagnosticPlanContextRef,
} = useAIChatPlanContexts();
const aiChatHistory = useStore(state => state.aiChatHistory);
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
const appearance = useStore(state => state.appearance);
const createNewAISession = useStore(state => state.createNewAISession);
@@ -101,7 +99,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const deleteAIChatMessage = useStore(state => state.deleteAIChatMessage);
const truncateAIChatMessages = useStore(state => state.truncateAIChatMessages);
const updateAISessionTitle = useStore(state => state.updateAISessionTitle);
const activeContext = useStore(state => state.activeContext);
const aiContexts = useStore(state => state.aiContexts);
const connections = useStore(state => state.connections);
@@ -125,8 +123,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
onWidthChange,
});
const availableTools = useMemo(
() => buildAvailableAIChatTools(mcpTools),
[mcpTools],
() => buildAvailableAIChatTools(mcpTools, t),
[mcpTools, t],
);
const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding(
state.shortcutOptions,
@@ -145,23 +143,32 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
tabs,
});
useEffect(() => {
if (runtimeComposerNotice) {
setComposerNoticeState(null);
}
}, [runtimeComposerNotice]);
const getConnectionName = useCallback(() => {
let connectionId = activeContext?.connectionId;
if (!connectionId) {
const activeTab = tabs.find(t => t.id === activeTabId);
const activeTab = tabs.find(tab => tab.id === activeTabId);
connectionId = activeTab?.connectionId;
}
if (!connectionId) return '';
const conn = connections.find(c => c.id === connectionId);
return conn ? conn.name : '';
const connection = connections.find(item => item.id === connectionId);
return connection ? connection.name : '';
}, [activeContext, activeTabId, connections, tabs]);
const activeConnName = getConnectionName();
const composerNotice = useMemo(
() => buildAIComposerNotice(t, composerNoticeState) ?? runtimeComposerNotice,
[composerNoticeState, runtimeComposerNotice, t],
);
const textColor = overlayTheme.titleText;
const mutedColor = overlayTheme.mutedText;
const borderColor = overlayTheme.divider;
const assistantBubbleBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)';
const quickActionBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
const quickActionBorder = overlayTheme.sectionBorder;
@@ -178,15 +185,12 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
}, []);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail?.prompt) {
setInput(detail.prompt);
setTimeout(() => {
const el = textareaRef.current as any;
if (el) {
el.focus();
}
textareaRef.current?.focus();
}, 50);
}
};
@@ -196,8 +200,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const generateTitleForSession = useAIChatSessionTitleGenerator({ updateAISessionTitle });
const handleScrollMessages = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
const handleScrollMessages = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
setShowScrollBottom(!isNearBottom);
}, []);
@@ -230,8 +234,9 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
userPromptSettings,
overrideJVMPlanContext,
overrideJVMDiagnosticPlanContext,
translate: t,
});
}, [availableTools, skills, userPromptSettings]);
}, [availableTools, skills, t, userPromptSettings]);
const {
executeLocalTools,
@@ -249,15 +254,16 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
pendingJVMDiagnosticPlanContextRef,
setSending,
skills,
translate: t,
updateAIChatMessage,
userPromptSettings,
});
const handleRetryMessage = useCallback(async (msg: AIChatMessage) => {
const historyLocal = useStore.getState().aiChatHistory[sid] || [];
const aiIndex = historyLocal.findIndex(m => m.id === msg.id);
const aiIndex = historyLocal.findIndex(message => message.id === msg.id);
if (aiIndex <= 0) return;
let lastUserMsgIndex = -1;
for (let i = aiIndex - 1; i >= 0; i--) {
if (historyLocal[i].role === 'user') {
@@ -265,10 +271,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
break;
}
}
if (lastUserMsgIndex >= 0) {
const userMsg = historyLocal[lastUserMsgIndex];
truncateAIChatMessages(sid, userMsg.id);
truncateAIChatMessages(sid, userMsg.id);
resetToolCallState();
nudgeCountRef.current = 0;
@@ -280,18 +286,21 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
setSending(true);
// 插入 connecting 过渡消息(波纹动画),与 handleSend 保持一致
const connectingMsg: AIChatMessage = {
id: genId(), role: 'assistant', phase: 'connecting', content: '',
timestamp: Date.now(), loading: true,
id: genId(),
role: 'assistant',
phase: 'connecting',
content: '',
timestamp: Date.now(),
loading: true,
jvmPlanContext: retryJVMPlanContext,
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
};
addAIChatMessage(sid, connectingMsg);
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
const messagesPayload = truncatedHistory.map(toAIRequestMessage);
const messagesPayload = truncatedHistory.map((message) => toAIRequestMessage(message, t));
try {
const sysMessages = await buildSystemContextMessages(
retryJVMPlanContext,
@@ -309,6 +318,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
pendingAssistantMessageId: connectingMsg.id,
jvmPlanContext: retryJVMPlanContext,
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
translate: t,
});
} catch {
setSending(false);
@@ -323,6 +333,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
getCurrentJVMPlanContext,
getCurrentJVMDiagnosticPlanContext,
resetToolCallState,
t,
updateAIChatMessage,
]);
@@ -340,6 +351,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
nudgeCountRef,
pendingJVMPlanContextRef,
pendingJVMDiagnosticPlanContextRef,
translate: t,
});
const handleSend = useCallback(async () => {
@@ -355,23 +367,22 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
activeContextItems: aiContexts[connectionKey] || [],
});
// 前置校验:必须配置供应商、补全基础参数并选择模型后才能发送
if (readiness.status === 'missing_provider') {
setComposerNotice(buildMissingProviderNotice());
setComposerNoticeState({ kind: 'missing_provider' });
return;
}
if (readiness.status === 'provider_incomplete') {
setComposerNotice(buildIncompleteProviderNotice(readiness.issues));
setComposerNoticeState({ kind: 'provider_incomplete', issues: readiness.issues });
return;
}
if (readiness.status === 'missing_model' || readiness.status === 'loading_models') {
setComposerNotice(buildMissingModelNotice());
setComposerNoticeState({ kind: 'missing_model' });
return;
}
setComposerNotice(null);
setComposerNoticeState(null);
resetToolCallState();
nudgeCountRef.current = 0; // 重置催促计数
nudgeCountRef.current = 0;
const currentJVMPlanContext = getCurrentJVMPlanContext();
const currentJVMDiagnosticPlanContext = getCurrentJVMDiagnosticPlanContext();
pendingJVMPlanContextRef.current = currentJVMPlanContext;
@@ -386,20 +397,25 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
setDraftAttachments([]);
setSending(true);
if (textareaRef.current) {
textareaRef.current.focus();
}
textareaRef.current?.focus();
const userMsg: AIChatMessage = {
id: genId(), role: 'user', content: text, timestamp: Date.now(),
id: genId(),
role: 'user',
content: text,
timestamp: Date.now(),
images: currentImages.length > 0 ? currentImages : undefined,
attachments: currentFileAttachments.length > 0 ? currentFileAttachments : undefined,
};
addAIChatMessage(sid, userMsg);
const connectingMsg: AIChatMessage = {
id: genId(), role: 'assistant', phase: 'connecting', content: '',
timestamp: Date.now(), loading: true,
id: genId(),
role: 'assistant',
phase: 'connecting',
content: '',
timestamp: Date.now(),
loading: true,
jvmPlanContext: currentJVMPlanContext,
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
};
@@ -410,33 +426,31 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
currentJVMDiagnosticPlanContext,
);
// 【过渡状态 2】上下文已组装完成即将接入模型
updateAIChatMessage(sid, connectingMsg.id, { content: '模型接入中' });
updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.model_connecting') });
const chatMessages = [...messages, userMsg].map(toAIRequestMessage);
const chatMessages = [...messages, userMsg].map((message) => toAIRequestMessage(message, t));
let finalMessagesPayload = chatMessages;
const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model);
const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit);
const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit, t);
if (summary) {
// 清理原有历史,保留系统生成的总结记录和当前的 userMsg 以及 connectingMsg
const compressedMsg: AIChatMessage = {
id: genId(), role: 'assistant', content: `【自动记忆重塑】已将超长历史压缩为摘要:\n\n${summary}`, timestamp: Date.now() - 1000
id: genId(),
role: 'assistant',
content: t('ai_chat.panel.status.memory_summary', { summary }),
timestamp: Date.now() - 1000,
};
useStore.getState().replaceAIChatHistory(sid, [compressedMsg, userMsg, connectingMsg]);
finalMessagesPayload = [
{ role: 'assistant', content: compressedMsg.content },
toAIRequestMessage(userMsg),
toAIRequestMessage(userMsg, t),
];
}
const allMessages = [...systemMessages, ...finalMessagesPayload];
// 【过渡状态 3】大脑唤醒
updateAIChatMessage(sid, connectingMsg.id, { content: '唤醒推理引擎中' });
// 【过渡状态 4】最后一步等待第一字节返回
updateAIChatMessage(sid, connectingMsg.id, { content: '等待模型响应' });
updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waking_engine') });
updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waiting_response') });
await dispatchAIChatPayload({
sid,
@@ -449,7 +463,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
pendingAssistantMessageId: connectingMsg.id,
jvmPlanContext: currentJVMPlanContext,
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
unavailableContent: '❌ AI Service 未就绪',
unavailableContent: t('ai_chat.panel.message.service_not_ready'),
translate: t,
onNonStreamSuccess: messages.length === 0
? () => generateTitleForSession(sid)
: undefined,
@@ -471,11 +486,13 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
getCurrentJVMPlanContext,
getCurrentJVMDiagnosticPlanContext,
loadingModels,
resetToolCallState,
t,
updateAIChatMessage,
]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend);
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, event, handleSend);
}, [aiChatSendShortcutBinding, handleSend]);
const handleStop = useCallback(async () => {
@@ -484,8 +501,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (Service?.AIChatCancel) {
await Service.AIChatCancel(sid);
}
} catch (e) {
console.warn('Failed to stop chat stream', e);
} catch (error) {
console.warn('Failed to stop chat stream', error);
}
setSending(false);
}, [sid]);
@@ -500,7 +517,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
[activeContext?.connectionId, activeContext?.dbName, messages],
);
// useMemo 缓存:避免内联闭包击穿子组件 memo
const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]);
const handleMessageRenderError = useCallback((error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => {
console.error('[AI Message Render Error]', msg.id, error, errorInfo);
@@ -519,12 +535,12 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
(globalThis as any).__gonaviLastAIMessageRenderError = renderErrorPayload;
}, []);
const currentSessionTitle = useMemo(
() => orderedAISessions.find((session) => session.id === sid)?.title || '新对话',
[orderedAISessions, sid],
() => orderedAISessions.find((session) => session.id === sid)?.title || t('ai_chat.panel.session.default_title'),
[orderedAISessions, sid, t],
);
const activeConnectionConfig = useMemo(() => {
if (!inferredConnectionId) return undefined;
const connection = connections.find(c => c.id === inferredConnectionId);
const connection = connections.find(item => item.id === inferredConnectionId);
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
}, [inferredConnectionId, connections]);
const contextUsageChars = useMemo(
@@ -539,28 +555,43 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
}),
[activeContext?.connectionId, activeContext?.dbName, aiContexts],
);
const aiInsights = useMemo(
() => buildAIChatInsights({
const aiInsights = useMemo(() => {
return buildAIChatInsights({
contextTableNames,
sqlLogs,
}),
[contextTableNames, sqlLogs],
);
translate: t,
});
}, [contextTableNames, sqlLogs, t]);
const panelHistorySessions = useMemo(
() => buildAIChatInlineHistorySessions(orderedAISessions),
[orderedAISessions],
() => buildAIChatInlineHistorySessions(
orderedAISessions.map((session) => ({
...session,
title: session.title || t('ai_chat.panel.session.default_title'),
})),
),
[orderedAISessions, t],
);
const effectivePanelMode = useMemo(
() => resolveAIChatPanelMode(isV2Ui, activePanelMode),
[activePanelMode, isV2Ui],
);
const handleComposerActionWithNoticeReset = useCallback((actionKey: 'open-settings' | 'reload-models') => {
setComposerNoticeState(null);
handleComposerAction(actionKey);
}, [handleComposerAction]);
const handleModelChangeWithNoticeReset = useCallback((model: string) => {
setComposerNoticeState(null);
void handleModelChange(model);
}, [handleModelChange]);
return (
<div ref={panelRef} className={`ai-chat-panel${isV2Ui ? ' gn-v2-ai-panel' : ''}`} style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
{isResizing && panelRect.current && createPortal(
<div
<div
ref={ghostRef}
style={{
position: 'fixed',
@@ -662,8 +693,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
sendShortcutBinding={aiChatSendShortcutBinding}
shortcutPlatform={activeShortcutPlatform}
composerNotice={composerNotice}
onComposerAction={handleComposerAction}
onModelChange={handleModelChange}
onComposerAction={handleComposerActionWithNoticeReset}
onModelChange={handleModelChangeWithNoticeReset}
onFetchModels={fetchDynamicModels}
textareaRef={textareaRef}
darkMode={darkMode}

View File

@@ -19,6 +19,79 @@ describe('AISettingsModal edit password behavior', () => {
expect(source).toContain('<AISettingsPromptsSection');
});
it('localizes user prompt settings toast fallbacks', () => {
expect(source).toContain("messageApi.success(t('ai_settings.prompts.message.saved'))");
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.prompts.message.save_failed'))");
expect(source).not.toContain("'自定义提示词已保存'");
expect(source).not.toContain("'保存自定义提示词失败'");
});
it('localizes MCP server toast fallbacks', () => {
expect(source).toContain("messageApi.success(t('ai_settings.mcp_server.message.saved'))");
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.save_failed'))");
expect(source).toContain("messageApi.success(t('ai_settings.mcp_server.message.deleted'))");
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.delete_failed'))");
expect(source).toContain("messageApi.success(res?.message || t('ai_settings.mcp_server.message.test_success'))");
expect(source).toContain("messageApi.error(res?.message || t('ai_settings.mcp_server.message.test_failed'))");
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.test_request_failed'))");
expect(source).not.toContain("'MCP 服务已保存'");
expect(source).not.toContain("'保存 MCP 服务失败'");
expect(source).not.toContain("'MCP 服务已删除'");
expect(source).not.toContain("'删除 MCP 服务失败'");
expect(source).not.toContain("'MCP 服务连接成功'");
expect(source).not.toContain("'MCP 服务测试失败'");
expect(source).not.toContain("'测试 MCP 服务失败'");
});
it('localizes Skill toast fallbacks', () => {
expect(source).toContain("messageApi.success(t('ai_settings.skill.message.saved'))");
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.skill.message.save_failed'))");
expect(source).toContain("messageApi.success(t('ai_settings.skill.message.deleted'))");
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.skill.message.delete_failed'))");
expect(source).not.toContain("'Skill 已保存'");
expect(source).not.toContain("'保存 Skill 失败'");
expect(source).not.toContain("'Skill 已删除'");
expect(source).not.toContain("'删除 Skill 失败'");
});
it('localizes MCP HTTP control and copy fallbacks', () => {
expect(source).toContain("throw new Error(t('ai_settings.clipboard.error.unsupported'))");
expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.control_unsupported_runtime'))");
expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.start_unsupported_version'))");
expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version'))");
expect(source).toContain("messageApi.success(checked ? t('ai_settings.mcp_http.message.started') : t('ai_settings.mcp_http.message.stopped'))");
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_http.message.toggle_failed'))");
expect(source).toContain("messageApi.error(t('ai_settings.mcp_http.message.url_unavailable'))");
expect(source).toContain("copyTextToClipboard(url, t('ai_settings.mcp_http.message.url_copied'))");
expect(source).toContain("messageApi.error(t('ai_settings.mcp_http.message.authorization_header_required'))");
expect(source).toContain("copyTextToClipboard(`Authorization: ${authorizationHeader}`, t('ai_settings.mcp_http.message.authorization_header_copied'))");
expect(source).not.toContain("'当前环境不支持复制到剪贴板'");
expect(source).not.toContain("'当前运行时暂不支持 MCP HTTP 服务控制'");
expect(source).not.toContain("'当前版本暂不支持启动 MCP HTTP 服务'");
expect(source).not.toContain("'当前版本暂不支持停止 MCP HTTP 服务'");
expect(source).not.toContain("'GoNavi MCP HTTP 服务已启动'");
expect(source).not.toContain("'GoNavi MCP HTTP 服务已停止'");
expect(source).not.toContain("'切换 GoNavi MCP HTTP 服务失败'");
expect(source).not.toContain("'当前没有可复制的 MCP HTTP URL'");
expect(source).not.toContain("'MCP HTTP URL 已复制'");
expect(source).not.toContain("'请先启动 MCP HTTP 服务生成 Authorization Header'");
expect(source).not.toContain("'Authorization Header 已复制'");
});
it('localizes MCP HTTP default status fallback', () => {
expect(source).toContain("const defaultMCPHTTPServerStatus = useMemo<AIMCPHTTPServerStatus>(() => ({");
expect(source).toContain("message: t('ai_settings.mcp_http.status.not_running')");
expect(source).toContain("useState<AIMCPHTTPServerStatus>(() => defaultMCPHTTPServerStatus)");
expect(source).not.toContain("'GoNavi MCP HTTP 服务未启动'");
});
it('localizes Skill required built-in tool option labels', () => {
expect(source).toContain("label: `${tool.name} · ${t('ai_settings.tools.builtin_tool_label')}`");
expect(source).toContain("]), [mcpTools, t]);");
expect(source).not.toContain("label: `${tool.name} · 内置工具`");
expect(source).not.toContain("· 内置工具");
});
it('loads MCP servers and skills through the AI service', () => {
expect(source).toContain('Service.AIGetMCPClientInstallStatuses?.()');
expect(source).toContain('Service.AIGetMCPServers?.()');
@@ -78,7 +151,8 @@ describe('AISettingsModal edit password behavior', () => {
it('renders in-modal test errors through the local message host', () => {
expect(source).toContain('antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body })');
expect(source).toContain("void messageApi.error(`测试失败: ${res?.message || '未知错误'}`);");
expect(source).toContain("void messageApi.error(res?.message || t('ai_settings.message.test_failed'))");
expect(source).not.toContain("`测试失败: ${res?.message || '未知错误'}`");
});
it('keeps long ai settings toast errors wrapped within the modal body', () => {

View File

@@ -1,5 +1,6 @@
import Modal from './common/ResizableDraggableModal';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Modal, Form, message as antdMessage } from 'antd';
import { Form, message as antdMessage } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AISkillConfig } from '../types';
import {
@@ -10,6 +11,7 @@ import {
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { useI18n } from '../i18n/provider';
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
import { EMPTY_MCP_CLIENT_STATUSES } from '../utils/mcpClientInstallStatus';
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
@@ -28,8 +30,9 @@ import {
EMPTY_SKILL,
PROVIDER_PRESETS,
findPreset,
localizeProviderPreset,
localizeProviderPresets,
matchProviderPreset,
type ProviderPreset,
waitForAIService,
} from './ai/aiSettingsModalConfig';
interface AISettingsModalProps {
@@ -38,6 +41,7 @@ interface AISettingsModalProps {
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
onBeforeExternalMCPUse?: () => Promise<void>;
}
const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
@@ -46,7 +50,7 @@ const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
message: 'GoNavi MCP HTTP 服务未启动',
message: '',
};
const DEFAULT_MCP_HTTP_SERVER_DRAFT: AIMCPHTTPServerDraft = {
@@ -76,14 +80,19 @@ const normalizeMCPHTTPAuthorizationToken = (value: string): string => {
return withoutHeaderName.replace(/^Bearer\s+/i, '').trim();
};
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId, onBeforeExternalMCPUse }) => {
const { t } = useI18n();
const defaultMCPHTTPServerStatus = useMemo<AIMCPHTTPServerStatus>(() => ({
...DEFAULT_MCP_HTTP_SERVER_STATUS,
message: t('ai_settings.mcp_http.status.not_running'),
}), [t]);
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(DEFAULT_MCP_HTTP_SERVER_STATUS);
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(() => defaultMCPHTTPServerStatus);
const [mcpHTTPServerDraft, setMCPHTTPServerDraft] = useState<AIMCPHTTPServerDraft>(DEFAULT_MCP_HTTP_SERVER_DRAFT);
const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false);
const [skills, setSkills] = useState<AISkillConfig[]>([]);
@@ -111,16 +120,29 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
const localizedProviderPresets = useMemo(
() => localizeProviderPresets(PROVIDER_PRESETS, t),
[t],
);
const findLocalizedPreset = useCallback(
(key: string) => localizeProviderPreset(findPreset(key), t),
[t],
);
const matchLocalizedProviderPreset = useCallback(
(provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>) =>
localizeProviderPreset(matchProviderPreset(provider), t),
[t],
);
const skillRequiredToolOptions = useMemo(() => ([
...BUILTIN_AI_TOOL_INFO.map((tool) => ({
label: `${tool.name} · 内置工具`,
label: `${tool.name} · ${t('ai_settings.tools.builtin_tool_label')}`,
value: tool.name,
})),
...mcpTools.map((tool) => ({
label: `${tool.alias} · ${tool.serverName}`,
value: tool.alias,
})),
]), [mcpTools]);
]), [mcpTools, t]);
const resolveAIService = useCallback(async () => {
const service = await waitForAIService();
@@ -137,11 +159,11 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const copyTextToClipboard = useCallback(async (text: string, successMessage: string) => {
if (typeof navigator?.clipboard?.writeText !== 'function') {
throw new Error('当前环境不支持复制到剪贴板');
throw new Error(t('ai_settings.clipboard.error.unsupported'));
}
await navigator.clipboard.writeText(text);
void messageApi.success(successMessage);
}, [messageApi]);
}, [messageApi, t]);
const {
handleCopySelectedMCPConfigPath,
@@ -160,9 +182,13 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
resolveAIService,
messageApi,
copyTextToClipboard,
onBeforeInstall: () => setLoading(true),
onBeforeInstall: async () => {
setLoading(true);
await onBeforeExternalMCPUse?.();
},
onAfterInstall: () => setLoading(false),
onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')),
translate: t,
});
const loadConfig = useCallback(async () => {
@@ -190,7 +216,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS),
callOrFallback(() => Service.AIGetMCPServers?.(), []),
callOrFallback(() => Service.AIListMCPTools?.(), []),
callOrFallback<AIMCPHTTPServerStatus>(() => Service.AIGetMCPHTTPServerStatus?.(), DEFAULT_MCP_HTTP_SERVER_STATUS),
callOrFallback<AIMCPHTTPServerStatus>(() => Service.AIGetMCPHTTPServerStatus?.(), defaultMCPHTTPServerStatus),
callOrFallback(() => Service.AIGetSkills?.(), []),
callOrFallback<AIMCPClientInstallStatus[]>(() => Service.AIGetMCPClientInstallStatuses?.(), EMPTY_MCP_CLIENT_STATUSES),
]);
@@ -212,7 +238,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes);
if (mcpHTTPServerStatusRes) {
const nextStatus = {
...DEFAULT_MCP_HTTP_SERVER_STATUS,
...defaultMCPHTTPServerStatus,
...mcpHTTPServerStatusRes,
};
setMCPHTTPServerStatus(nextStatus);
@@ -223,7 +249,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
syncMCPClientStatuses(mcpClientStatusesRes);
}
} catch (e) { console.warn('Failed to load AI config', e); }
}, [resolveAIService, syncMCPClientStatuses]);
}, [defaultMCPHTTPServerStatus, resolveAIService, syncMCPClientStatuses]);
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
@@ -305,7 +331,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
},
}));
} catch (e: any) {
void messageApi.error(e?.message || '读取供应商配置失败');
void messageApi.error(e?.message || t('ai_settings.message.load_provider_failed'));
}
};
@@ -319,16 +345,16 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
if (wasActive) {
const newProviders: any[] = await Service?.AIGetProviders?.() || [];
if (newProviders.length > 0) {
const newActiveName = newProviders[0]?.name || '下一个供应商';
void messageApi.success(`已删除,自动切换到「${newActiveName}`);
const newActiveName = newProviders[0]?.name || t('ai_settings.provider.next_provider');
void messageApi.success(t('ai_settings.message.deleted_and_switched', { name: newActiveName }));
} else {
void messageApi.success('已删除');
void messageApi.success(t('ai_settings.message.deleted'));
}
} else {
void messageApi.success('已删除');
void messageApi.success(t('ai_settings.message.deleted'));
}
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) { void messageApi.error(e?.message || '删除失败'); }
} catch (e: any) { void messageApi.error(e?.message || t('ai_settings.message.delete_failed')); }
};
const handleSaveProvider = async () => {
@@ -339,7 +365,8 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
// 构建 payload处理 model/models 逻辑
const preset = findPreset(values.presetKey);
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
const localizedPreset = localizeProviderPreset(preset, t);
const isCustomLike = ['custom', 'ollama', 'codebuddy', 'cursor'].includes(values.presetKey);
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
presetKey: values.presetKey,
presetDefaultModel: preset.defaultModel,
@@ -347,8 +374,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
valuesModel: values.model,
customModels: values.models,
});
const inlineCompletionModel = String(values.inlineCompletionModel || '').trim();
// 内置供应商自动使用 preset label 作为名称
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
const finalName = isCustomLike ? (values.name || localizedPreset.label) : localizedPreset.label;
const finalBaseUrl = resolvePresetBaseURL({
presetKey: values.presetKey,
@@ -371,17 +399,18 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
apiKey: secretDraft.apiKey,
hasSecret: secretDraft.hasSecret,
model: finalModel,
inlineCompletionModel,
models: resolvedModels,
baseUrl: finalBaseUrl,
apiFormat: resolvedTransport.apiFormat,
};
// 后端 AISaveProvider 统一处理新增和更新,返回 void失败抛异常
await Service?.AISaveProvider?.(payload);
void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig();
void messageApi.success(t('ai_settings.message.saved')); resetProviderEditorSession(); void loadConfig();
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) {
if (e?.errorFields) { /* antd form validation error, ignore */ }
else void messageApi.error(e?.message || '保存失败');
else void messageApi.error(e?.message || t('ai_settings.message.save_failed'));
} finally { setLoading(false); }
};
@@ -389,9 +418,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetActiveProvider?.(id);
setActiveProviderId(id); void messageApi.success('已切换');
setActiveProviderId(id); void messageApi.success(t('ai_settings.message.switched'));
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) { void messageApi.error(e?.message || '切换失败'); }
} catch (e: any) { void messageApi.error(e?.message || t('ai_settings.message.switch_failed')); }
};
const handleSafetyChange = async (level: AISafetyLevel) => {
@@ -422,10 +451,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
};
await Service?.AISaveUserPromptSettings?.(payload);
setUserPromptSettings(payload);
void messageApi.success('自定义提示词已保存');
void messageApi.success(t('ai_settings.prompts.message.saved'));
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
} catch (e: any) {
void messageApi.error(e?.message || '保存自定义提示词失败');
void messageApi.error(e?.message || t('ai_settings.prompts.message.save_failed'));
} finally {
setLoading(false);
}
@@ -445,10 +474,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISaveMCPServer?.(server);
await loadConfig();
void messageApi.success('MCP 服务已保存');
void messageApi.success(t('ai_settings.mcp_server.message.saved'));
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
} catch (e: any) {
void messageApi.error(e?.message || '保存 MCP 服务失败');
void messageApi.error(e?.message || t('ai_settings.mcp_server.message.save_failed'));
} finally {
setLoading(false);
}
@@ -465,9 +494,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
} else {
setMCPServers((prev) => prev.filter((item) => item.id !== id));
}
void messageApi.success('MCP 服务已删除');
void messageApi.success(t('ai_settings.mcp_server.message.deleted'));
} catch (e: any) {
void messageApi.error(e?.message || '删除 MCP 服务失败');
void messageApi.error(e?.message || t('ai_settings.mcp_server.message.delete_failed'));
} finally {
setLoading(false);
}
@@ -479,7 +508,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const Service = (window as any).go?.aiservice?.Service;
const res = await Service?.AITestMCPServer?.(server);
if (res?.success) {
void messageApi.success(res?.message || 'MCP 服务连接成功');
void messageApi.success(res?.message || t('ai_settings.mcp_server.message.test_success'));
if (typeof Service?.AIListMCPTools === 'function') {
const nextTools = await Service.AIListMCPTools();
if (Array.isArray(nextTools)) setMCPTools(nextTools);
@@ -487,10 +516,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
setMCPTools(res.tools);
}
} else {
void messageApi.error(res?.message || 'MCP 服务测试失败');
void messageApi.error(res?.message || t('ai_settings.mcp_server.message.test_failed'));
}
} catch (e: any) {
void messageApi.error(e?.message || '测试 MCP 服务失败');
void messageApi.error(e?.message || t('ai_settings.mcp_server.message.test_request_failed'));
} finally {
setLoading(false);
}
@@ -501,13 +530,16 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
setMCPHTTPServerLoading(true);
const Service = await resolveAIService();
if (!Service) {
throw new Error('当前运行时暂不支持 MCP HTTP 服务控制');
throw new Error(t('ai_settings.mcp_http.error.control_unsupported_runtime'));
}
if (checked && typeof Service.AIStartMCPHTTPServer !== 'function') {
throw new Error('当前版本暂不支持启动 MCP HTTP 服务');
throw new Error(t('ai_settings.mcp_http.error.start_unsupported_version'));
}
if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') {
throw new Error('当前版本暂不支持停止 MCP HTTP 服务');
throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version'));
}
if (checked) {
await onBeforeExternalMCPUse?.();
}
const nextStatus = checked
? await Service.AIStartMCPHTTPServer({
@@ -519,15 +551,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
: await Service.AIStopMCPHTTPServer();
if (nextStatus) {
const normalizedStatus = {
...DEFAULT_MCP_HTTP_SERVER_STATUS,
...defaultMCPHTTPServerStatus,
...nextStatus,
};
setMCPHTTPServerStatus(normalizedStatus);
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(normalizedStatus, prev));
}
void messageApi.success(checked ? 'GoNavi MCP HTTP 服务已启动' : 'GoNavi MCP HTTP 服务已停止');
void messageApi.success(checked ? t('ai_settings.mcp_http.message.started') : t('ai_settings.mcp_http.message.stopped'));
} catch (e: any) {
void messageApi.error(e?.message || '切换 GoNavi MCP HTTP 服务失败');
void messageApi.error(e?.message || t('ai_settings.mcp_http.message.toggle_failed'));
} finally {
setMCPHTTPServerLoading(false);
}
@@ -543,19 +575,19 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const handleCopyMCPHTTPServerURL = async () => {
const url = String(mcpHTTPServerStatus.url || '').trim();
if (!url) {
void messageApi.error('当前没有可复制的 MCP HTTP URL');
void messageApi.error(t('ai_settings.mcp_http.message.url_unavailable'));
return;
}
await copyTextToClipboard(url, 'MCP HTTP URL 已复制');
await copyTextToClipboard(url, t('ai_settings.mcp_http.message.url_copied'));
};
const handleCopyMCPHTTPServerAuthorization = async () => {
const authorizationHeader = String(mcpHTTPServerStatus.authorizationHeader || '').trim();
if (!authorizationHeader) {
void messageApi.error('请先启动 MCP HTTP 服务生成 Authorization Header');
void messageApi.error(t('ai_settings.mcp_http.message.authorization_header_required'));
return;
}
await copyTextToClipboard(`Authorization: ${authorizationHeader}`, 'Authorization Header 已复制');
await copyTextToClipboard(`Authorization: ${authorizationHeader}`, t('ai_settings.mcp_http.message.authorization_header_copied'));
};
const updateSkillDraft = (id: string, patch: Partial<AISkillConfig>) => {
@@ -572,10 +604,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISaveSkill?.(skill);
await loadConfig();
void messageApi.success('Skill 已保存');
void messageApi.success(t('ai_settings.skill.message.saved'));
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
} catch (e: any) {
void messageApi.error(e?.message || '保存 Skill 失败');
void messageApi.error(e?.message || t('ai_settings.skill.message.save_failed'));
} finally {
setLoading(false);
}
@@ -592,9 +624,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
} else {
setSkills((prev) => prev.filter((item) => item.id !== id));
}
void messageApi.success('Skill 已删除');
void messageApi.success(t('ai_settings.skill.message.deleted'));
} catch (e: any) {
void messageApi.error(e?.message || '删除 Skill 失败');
void messageApi.error(e?.message || t('ai_settings.skill.message.delete_failed'));
} finally {
setLoading(false);
}
@@ -624,11 +656,12 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
});
const allowEmptySecret = values.presetKey === 'codebuddy';
const secretDraft = resolveProviderSecretDraft({
apiKeyInput: values.apiKey,
});
if (secretDraft.mode === 'clear') {
throw new Error('测试连接前请填写 API Key');
if (secretDraft.mode === 'clear' && !allowEmptySecret) {
throw new Error(t('ai_settings.message.test_requires_new_api_key'));
}
const res = await Service?.AITestProvider?.({
...editingProvider,
@@ -638,14 +671,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
hasSecret: secretDraft.hasSecret,
baseUrl: finalBaseUrl,
model: finalModel,
inlineCompletionModel: String(values.inlineCompletionModel || '').trim(),
models: resolvedModels,
maxTokens: Number(values.maxTokens) || 4096,
temperature: Number(values.temperature) ?? 0.7,
apiFormat: resolvedTransport.apiFormat,
});
if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); }
else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); }
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); }
if (res?.success) { setTestStatus('success'); void messageApi.success(t('ai_settings.message.test_success')); }
else { setTestStatus('error'); void messageApi.error(res?.message || t('ai_settings.message.test_failed')); }
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || t('ai_settings.message.test_failed')); }
finally { setLoading(false); }
};
@@ -656,12 +690,20 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: form.getFieldValue('apiFormat'),
});
const { model: presetModel, models: presetModels } = resolvePresetModelSelection({
presetKey,
presetDefaultModel: preset.defaultModel,
presetModels: preset.models,
customModels: preset.models,
});
form.setFieldsValue({
presetKey,
type: resolvedTransport.type,
apiFormat: resolvedTransport.apiFormat || 'openai',
baseUrl: preset.defaultBaseUrl,
model: preset.defaultModel,
model: presetModel,
models: presetModels,
inlineCompletionModel: '',
});
};
@@ -681,9 +723,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
<RobotOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>AI </div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>{t('ai_settings.title')}</div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
AI
{t('ai_settings.subtitle')}
</div>
</div>
</div>
@@ -714,7 +756,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
editingProvider={editingProvider}
isEditing={isEditing}
form={form}
providerPresets={PROVIDER_PRESETS}
providerPresets={localizedProviderPresets}
watchedPresetKey={watchedPresetKey}
watchedApiFormat={watchedApiFormat}
loading={loading}
@@ -726,8 +768,8 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
cardBorder={cardBorder}
inputBg={inputBg}
onPrimaryPasswordVisibleChange={setPrimaryPasswordVisible}
resolveProviderPreset={matchProviderPreset}
resolvePresetByKey={findPreset}
resolveProviderPreset={matchLocalizedProviderPreset}
resolvePresetByKey={findLocalizedPreset}
onAddProvider={handleAddProvider}
onEditProvider={handleEditProvider}
onDeleteProvider={handleDeleteProvider}

View File

@@ -2,11 +2,15 @@ import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
const connectionModalSource = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8');
const connectionModalConfigSource = readFileSync(new URL('./connectionModal/connectionModalConfig.ts', import.meta.url), 'utf8');
const connectionModalStep2Source = readFileSync(new URL('./connectionModal/ConnectionModalStep2.tsx', import.meta.url), 'utf8');
const connectionModalNetworkSecuritySource = readFileSync(new URL('./connectionModal/ConnectionModalNetworkSecuritySection.tsx', import.meta.url), 'utf8');
const connectionModalUriSource = readFileSync(new URL('./connectionModal/connectionModalUri.ts', import.meta.url), 'utf8');
const redisSectionsSource = readFileSync(new URL('./ConnectionModalRedisSections.tsx', import.meta.url), 'utf8');
const mongoSectionsSource = readFileSync(new URL('./ConnectionModalMongoSections.tsx', import.meta.url), 'utf8');
const connectionTypeCatalogSource = readFileSync(new URL('../utils/connectionTypeCatalog.ts', import.meta.url), 'utf8');
const connectionTypeCapabilitiesSource = readFileSync(new URL('../utils/connectionTypeCapabilities.ts', import.meta.url), 'utf8');
const source = `${connectionModalSource}\n${redisSectionsSource}\n${mongoSectionsSource}\n${connectionTypeCatalogSource}\n${connectionTypeCapabilitiesSource}`;
const source = `${connectionModalSource}\n${connectionModalConfigSource}\n${connectionModalStep2Source}\n${connectionModalNetworkSecuritySource}\n${connectionModalUriSource}\n${redisSectionsSource}\n${mongoSectionsSource}\n${connectionTypeCatalogSource}\n${connectionTypeCapabilitiesSource}`;
describe('ConnectionModal edit password behavior', () => {
it('keeps the prefilled primary password masked by default', () => {
@@ -20,6 +24,26 @@ describe('ConnectionModal edit password behavior', () => {
expect(source).not.toContain('description:\n "当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。"');
expect(source).toContain('String(config.password || "") === ""');
});
it('reuses the shared backend-cancel helper for file and certificate pickers', () => {
expect(source).not.toContain('res?.message !== "已取消"');
expect(source.match(/isBackendCancelledResult\(res\)/g) ?? []).toHaveLength(3);
});
it('uses localized SSL mode labels instead of hardcoded English strings', () => {
expect(source).not.toContain('label: "Preferred"');
expect(source).not.toContain('label: "Required"');
expect(source).not.toContain('label: "Skip Verify"');
expect(source).toMatch(
/label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.preferred",\s*\)/,
);
expect(source).toMatch(
/label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.required",\s*\)/,
);
expect(source).toMatch(
/label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.skip_verify",\s*\)/,
);
});
});
describe('ConnectionModal data source registry', () => {
@@ -31,21 +55,24 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain("name: 'Elasticsearch'");
expect(source).toContain('icon: getDbIcon(item.key, undefined, 36)');
expect(source).toContain('type === "elasticsearch"');
expect(source).toContain("return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';");
expect(source).toContain("'connection_modal.step1.hint.elasticsearch'");
expect(source).toContain(
"'Index browsing, Mapping inspection, JSON DSL, and query_string queries'",
);
expect(source).toContain('const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([');
expect(source).toContain('"mqtt",');
expect(source).toContain(
'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";',
);
expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)');
expect(source).toContain('label="显示数据库 (留空显示全部)"');
expect(source).toContain('connection.modal.field.displayDatabases.label');
});
it('keeps MQTT username optional during test-connection validation', () => {
expect(source).toContain('"mqtt",');
expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)');
expect(source).toContain(': [createUriAwareRequiredRule("请输入用户名")]');
expect(source).toContain('? "未开启认证可留空"');
expect(source).toContain('connection.modal.field.username.required');
expect(source).toContain('connection.modal.field.username.optional_placeholder');
});
it('exposes Chroma in the create-connection picker with vector defaults', () => {
@@ -55,7 +82,10 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain("key: 'chroma'");
expect(source).toContain("name: 'Chroma'");
expect(source).toContain('type === "chroma"');
expect(source).toContain("return 'Collection 浏览、向量检索和元数据过滤';");
expect(source).toContain("'connection_modal.step1.hint.chroma'");
expect(source).toContain(
"'Collection browsing, vector retrieval, and metadata filtering'",
);
expect(source).toContain('return "http://127.0.0.1:8000/default_database?tenant=default_tenant";');
expect(source).toContain('return "tenant=default_tenant&apiKey=...";');
});
@@ -67,7 +97,10 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain("key: 'qdrant'");
expect(source).toContain("name: 'Qdrant'");
expect(source).toContain('type === "qdrant"');
expect(source).toContain("return 'Collection 浏览、向量搜索和 Payload 过滤';");
expect(source).toContain("'connection_modal.step1.hint.qdrant'");
expect(source).toContain(
"'Collection browsing, vector search, and Payload filtering'",
);
expect(source).toContain('return "http://127.0.0.1:6333";');
expect(source).toContain('return "apiKey=...";');
});
@@ -94,11 +127,11 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain("return 'NameServer / Topic / Consumer Group';");
expect(source).toContain('return "rocketmq://accessKey:secretKey@127.0.0.1:9876,127.0.0.2:9876/orders.events?topology=cluster&groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";');
expect(source).toContain('return "groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";');
expect(source).toContain('label="默认 Topic可选"');
expect(source).toContain('label={dbType === "rocketmq" ? "Access Key" : "用户名"}');
expect(source).toContain('label={dbType === "rocketmq" ? "Secret Key" : "密码"}');
expect(source).toContain('emptyPlaceholder: dbType === "rocketmq" ? "未开启认证可留空" : "密码"');
expect(source).toContain('retainedLabel: dbType === "rocketmq" ? "已保存 Secret Key" : "已保存密码"');
expect(source).toContain('t("connection.modal.messageQueue.rocketmq.defaultTopic.label")');
expect(source).toContain('connection.modal.field.username.label');
expect(source).toContain('connection.modal.field.password.label');
expect(source).toContain('connection.modal.field.username.optional_placeholder');
expect(source).toContain('connection.modal.field.password.retained');
});
it('exposes MQTT in the create-connection picker with broker and topic-filter defaults', () => {
@@ -111,7 +144,7 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain("return 'Broker / Topic Filter / QoS';");
expect(source).toContain('return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1";');
expect(source).toContain('return "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&clientId=gonavi-desktop&qos=1&cleanSession=true&fetchWaitMs=4000";');
expect(source).toContain('label="默认 Topic / Filter(可选)"');
expect(source).toContain('t("connection.modal.messageQueue.mqtt.defaultTopicFilter.label")');
});
it('exposes Kafka in the create-connection picker with broker and topic defaults', () => {
@@ -123,7 +156,7 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain("return 'Broker / Topic / Consumer Group';");
expect(source).toContain('return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256";');
expect(source).toContain('return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest";');
expect(source).toContain('label="默认 Topic可选"');
expect(source).toContain('t("connection.modal.messageQueue.kafka.defaultTopic.label")');
});
it('exposes RabbitMQ in the create-connection picker with management-api and vhost defaults', () => {
@@ -136,7 +169,7 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain("return 'Management API / Virtual Host / Queue';");
expect(source).toContain('return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30";');
expect(source).toContain('return "defaultQueue=orders.queue&exchange=events.topic&managementPathPrefix=/rabbitmq";');
expect(source).toContain('label="默认 Virtual Host(可选)"');
expect(source).toContain('t("connection.modal.messageQueue.rabbitmq.defaultVirtualHost.label")');
});
it('exposes GaussDB in the create-connection picker with PostgreSQL-family defaults', () => {
@@ -158,18 +191,62 @@ describe('ConnectionModal data source registry', () => {
expect(source).toContain("key: 'goldendb'");
expect(source).toContain("name: 'GoldenDB'");
expect(source).toContain('type === "goldendb"');
expect(source).toContain("return 'MySQL 兼容 / 分布式事务';");
expect(source).toContain("'connection_modal.step1.hint.goldendb'");
expect(source).toContain("'MySQL compatible / distributed transactions'");
expect(source).toContain('dbType === "goldendb" ? "goldendb" : "mysql"');
expect(source).toContain('type === "goldendb" ? "goldendb" : "mysql"');
expect(source).toContain('? "goldendb"');
});
it('keeps OceanBase Oracle service name optional for OBClient/MySQL-wire connections', () => {
expect(source).toContain('connection.modal.field.oceanBaseServiceName.label');
expect(source).toMatch(
/isOceanBaseOracle\s*\?\s*\[\]\s*:\s*\[\s*createUriAwareRequiredRule\(\s*t\("connection\.modal\.field\.serviceName\.required"/,
);
expect(source).toContain('connection.modal.field.oceanBaseServiceName.help');
expect(source).toContain('connection.modal.field.serviceName.help');
expect(source).toContain('connection.modal.field.serviceName.required');
expect(source).not.toContain('请输入 OceanBase Oracle 服务名');
expect(source).not.toContain('Oracle 租户必须填写监听器注册的 SERVICE_NAME');
});
it('uses localized message queue service, topology, and extra host copy', () => {
[
'label="默认 Topic可选"',
'label="默认 Topic / Filter可选"',
'label="默认 Virtual Host可选"',
'label: "单 Broker"',
'label: "单 NameServer"',
'label="额外 Broker 地址"',
'label="额外 NameServer 地址"',
'help="可输入多个 broker 地址格式host:port回车确认"',
'help="可输入多个 NameServer 地址格式host:port回车确认"',
].forEach((snippet) => {
expect(source).not.toContain(snippet);
});
[
'connection.modal.messageQueue.kafka.defaultTopic.help',
'connection.modal.messageQueue.rocketmq.defaultTopic.help',
'connection.modal.messageQueue.mqtt.defaultTopicFilter.help',
'connection.modal.messageQueue.rabbitmq.defaultVirtualHost.help',
'connection.modal.messageQueue.kafka.topology.single.label',
'connection.modal.messageQueue.rocketmq.topology.single.label',
'connection.modal.messageQueue.mqtt.topology.cluster.description',
'connection.modal.messageQueue.kafka.extraBrokers.placeholder',
'connection.modal.messageQueue.rocketmq.extraNameServers.placeholder',
'connection.modal.messageQueue.mqtt.extraBrokers.placeholder',
].forEach((key) => {
expect(source).toContain(key);
});
});
});
describe('ConnectionModal Redis Sentinel configuration', () => {
it('exposes Sentinel topology fields and safe defaults', () => {
expect(source).toContain('label: "哨兵模式"');
expect(source).toContain('connection.modal.redis.topology.sentinel.label');
expect(source).toContain('name="redisSentinelMaster"');
expect(source).toContain('Sentinel master 名称');
expect(source).toContain('connection.modal.redis.sentinel.master.label');
expect(source).toContain('name="redisSentinelPassword"');
expect(source).toContain('hasRedisSentinelPassword');
expect(source).toContain('clearKey: "redisSentinelPassword"');
@@ -177,6 +254,73 @@ describe('ConnectionModal Redis Sentinel configuration', () => {
expect(source).toContain('form.setFieldValue("port", 6379)');
});
it('uses localized Redis topology, sentinel, credential, and database-scope copy', () => {
[
'label: "单机模式"',
'description: "只连接一个 Redis 节点。"',
'label: "集群模式"',
'description: "Redis Cluster配置多个种子节点。"',
'label: "哨兵模式"',
'description: "通过 Sentinel 发现主节点,适合主从高可用。"',
'? "Sentinel 附加节点地址"',
': "集群附加节点地址"',
'? "上方主机地址作为第一个 Sentinel这里填写其他 Sentinel 节点格式host:port"',
': "主节点使用上方主机地址这里填写其他种子节点格式host:port"',
'label="Sentinel master 名称"',
'help="填写 Sentinel 配置中的 monitor 名称,例如 mymaster。"',
'label="密码 (可选)"',
'emptyPlaceholder: "Redis 密码(如果设置了 requirepass"',
'retainedLabel: "已保存 Redis 密码"',
'label="Sentinel 用户名(可选)"',
'placeholder="留空表示 Sentinel 不使用 ACL 用户名"',
'label="Sentinel 密码(可选)"',
'emptyPlaceholder: "Sentinel 自身认证密码,留空则不发送"',
'retainedLabel: "已保存 Sentinel 密码"',
'clearLabel: "清除已保存 Sentinel 密码"',
'label="显示数据库 (留空显示全部)"',
'help="连接测试成功后可选择"',
'placeholder="选择显示的数据库"',
].forEach((snippet) => {
expect(redisSectionsSource).not.toContain(snippet);
});
[
'connection.modal.redis.topology.single.label',
'connection.modal.redis.topology.cluster.description',
'connection.modal.redis.topology.sentinel.label',
'connection.modal.redis.hosts.sentinel.label',
'connection.modal.redis.hosts.cluster.help',
'connection.modal.redis.sentinel.master.required',
'connection.modal.redis.credentials.primary.placeholder.empty',
'connection.modal.redis.credentials.sentinelPassword.clear',
'connection.modal.redis.databaseScope.placeholder',
].forEach((key) => {
expect(redisSectionsSource).toContain(key);
});
});
it('uses localized Redis test feedback and optional-auth placeholders', () => {
[
'测试连接前请填写新的 Sentinel 密码,或取消清除已保存 Sentinel 密码',
'连接成功但拉取 Redis 数据库列表超时',
'连接成功,但获取 Redis 数据库列表失败',
'未知错误',
'? "未开启认证可留空"',
].forEach((snippet) => {
expect(connectionModalSource).not.toContain(snippet);
});
[
'connection.modal.secret.blocking.redis_sentinel',
'connection.modal.test.redis_database_list_timeout',
'connection.modal.test.redis_database_list_failure',
'connection.modal.error.unknown',
'connection.modal.field.username.optional_placeholder',
].forEach((key) => {
expect(source).toContain(key);
});
});
it('keeps the saved host as the primary Redis node when editing multi-node configs', () => {
expect(source).toContain('const savedPrimaryAddress = isFileDbConfigType');
expect(source).toContain('savedPrimaryAddress,');
@@ -190,10 +334,64 @@ describe('ConnectionModal MongoDB configuration', () => {
it('keeps replica, SRV, and read preference fields in the split Mongo sections', () => {
expect(source).toContain('ConnectionModalMongoSections');
expect(source).toContain('name="mongoSrv"');
expect(source).toContain('SRV 与 SSH 隧道同时启用');
expect(source).toContain('connection.modal.mongodb.discovery.srv_ssh_warning');
expect(source).toContain('name="mongoReplicaPassword"');
expect(source).toContain('clearKey: "mongoReplicaPassword"');
expect(source).toContain('自动发现成员');
expect(source).toContain('connection.modal.action.discover_members');
expect(source).toContain('fieldName: "mongoReadPreference"');
});
it('uses localized MongoDB topology, discovery, replica, and policy copy', () => {
[
'label: "单机模式"',
'description: "只连接一个 MongoDB 节点。"',
'label: "副本集 / 多节点"',
'description: "配置副本集名称和多个候选节点。"',
'label: "标准地址"',
'description: "使用 host:port 直连或副本集节点列表。"',
'label: "SRV 地址"',
'description: "使用 mongodb+srv由 DNS 发现目标节点。"',
'<Tag color="blue">当前</Tag>',
'message="SRV 与 SSH 隧道同时启用时,可能依赖本地 DNS 解析能力"',
'label={mongoSrv ? "附加 SRV 主机(可选)" : "附加节点地址"}',
'? "可输入多个候选主机名格式host若留空则仅使用上方主机。"',
': "可输入多个节点地址格式host:port回车确认"',
'label="副本集名称(可选)"',
'label="副本集用户名(可选)"',
'placeholder="留空沿用主用户名"',
'label="副本集密码(可选)"',
'emptyPlaceholder: "留空沿用主密码"',
'retainedLabel: "已保存副本集密码"',
'clearLabel: "清除已保存副本集密码"',
'当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。',
'自动发现成员',
'title: "角色"',
'title: "健康"',
'? "正常" : "异常"',
'label="认证库 (authSource)"',
'placeholder="默认使用 database 或 admin"',
'<Text strong>读偏好 (readPreference)</Text>',
'description: "只读主节点。"',
'description: "主节点优先。"',
'description: "只读从节点。"',
'description: "从节点优先。"',
'description: "选择最近节点。"',
].forEach((snippet) => {
expect(mongoSectionsSource).not.toContain(snippet);
});
[
'connection.modal.mongodb.topology.single.label',
'connection.modal.mongodb.discovery.standard.label',
'connection.modal.mongodb.discovery.srv_ssh_warning',
'connection.modal.mongodb.replica.hosts.srv.label',
'connection.modal.mongodb.replica.password.description',
'connection.modal.action.discover_members',
'connection.modal.mongodb.members.role',
'connection.modal.mongodb.policy.auth_source.label',
'connection.modal.mongodb.read_preference.primary',
].forEach((key) => {
expect(mongoSectionsSource).toContain(key);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ import {
getStoredSecretPlaceholder,
type ConnectionConfigSectionKey,
} from "../utils/connectionModalPresentation";
import { useI18n } from "../i18n/provider";
import { noAutoCapInputProps } from "../utils/inputAutoCap";
const { Text } = Typography;
@@ -87,278 +88,310 @@ const ConnectionModalMongoSections: React.FC<ConnectionModalMongoSectionsProps>
renderStoredSecretControls,
setChoiceFieldValue,
handleDiscoverMongoMembers,
}) => (
<>
{renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: renderChoiceCards({
fieldName: "mongoTopology",
value: String(mongoTopology),
options: [
{
value: "single",
label: "单机模式",
description: "只连接一个 MongoDB 节点。",
},
{
value: "replica",
label: "副本集 / 多节点",
description: "配置副本集名称和多个候选节点。",
},
],
}),
})}
}) => {
const { t } = useI18n();
{renderConfigSectionCard({
sectionKey: "mongoDiscovery",
icon: <ApiOutlined />,
children: (
<>
<Form.Item name="mongoSrv" hidden valuePropName="checked">
<Checkbox />
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 10,
}}
>
{[
{
value: false,
label: "标准地址",
description: "使用 host:port 直连或副本集节点列表。",
},
{
value: true,
label: "SRV 地址",
description: "使用 mongodb+srv由 DNS 发现目标节点。",
},
].map((option) => {
const active = mongoSrv === option.value;
return (
<button
key={String(option.value)}
type="button"
aria-pressed={active}
onClick={() => setChoiceFieldValue("mongoSrv", option.value)}
style={{
textAlign: "left",
padding: "12px 14px",
borderRadius: 14,
border: active
? darkMode
? "1px solid rgba(255,214,102,0.42)"
: "1px solid rgba(22,119,255,0.36)"
: darkMode
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(16,24,40,0.08)",
background: active
? darkMode
? "rgba(255,214,102,0.10)"
: "rgba(22,119,255,0.07)"
: darkMode
? "rgba(255,255,255,0.03)"
: "rgba(16,24,40,0.03)",
color: darkMode ? "#f5f7ff" : "#162033",
cursor: "pointer",
}}
>
<Space size={8} wrap>
<Text strong>{option.label}</Text>
{active ? <Tag color="blue"></Tag> : null}
</Space>
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
{option.description}
</div>
</button>
);
})}
</div>
{mongoSrv && useSSH && (
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message="SRV 与 SSH 隧道同时启用时,可能依赖本地 DNS 解析能力"
/>
)}
</>
),
})}
{mongoTopology === "replica" &&
renderConfigSectionCard({
sectionKey: "replica",
return (
<>
{renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: renderChoiceCards({
fieldName: "mongoTopology",
value: String(mongoTopology),
options: [
{
value: "single",
label: t("connection.modal.mongodb.topology.single.label"),
description: t("connection.modal.topology.mongodb_single_description"),
},
{
value: "replica",
label: t("connection.modal.mongodb.topology.replica.label"),
description: t("connection.modal.topology.mongodb_replica_description"),
},
],
}),
})}
{renderConfigSectionCard({
sectionKey: "mongoDiscovery",
icon: <ApiOutlined />,
children: (
<>
<Form.Item
name="mongoHosts"
label={mongoSrv ? "附加 SRV 主机(可选)" : "附加节点地址"}
help={
mongoSrv
? "可输入多个候选主机名格式host若留空则仅使用上方主机。"
: "可输入多个节点地址格式host:port回车确认"
}
>
<Select
mode="tags"
placeholder={
mongoSrv
? "例如cluster-a.example.com、cluster-b.example.com"
: "例如10.10.0.12:27017、10.10.0.13:27017"
}
tokenSeparators={[",", ";", " "]}
/>
<Form.Item name="mongoSrv" hidden valuePropName="checked">
<Checkbox />
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 10,
}}
>
<Form.Item
name="mongoReplicaSet"
label="副本集名称(可选)"
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} placeholder="例如rs0" />
</Form.Item>
<Form.Item
name="mongoReplicaUser"
label="副本集用户名(可选)"
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} placeholder="留空沿用主用户名" />
</Form.Item>
{[
{
value: false,
label: t("connection.modal.mongodb.discovery.standard.label"),
description: t("connection.modal.mongodb.discovery.standard.description"),
},
{
value: true,
label: t("connection.modal.mongodb.discovery.srv.label"),
description: t("connection.modal.mongodb.discovery.srv.description"),
},
].map((option) => {
const active = mongoSrv === option.value;
return (
<button
key={String(option.value)}
type="button"
aria-pressed={active}
onClick={() => setChoiceFieldValue("mongoSrv", option.value)}
style={{
textAlign: "left",
padding: "12px 14px",
borderRadius: 14,
border: active
? darkMode
? "1px solid rgba(255,214,102,0.42)"
: "1px solid rgba(22,119,255,0.36)"
: darkMode
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(16,24,40,0.08)",
background: active
? darkMode
? "rgba(255,214,102,0.10)"
: "rgba(22,119,255,0.07)"
: darkMode
? "rgba(255,255,255,0.03)"
: "rgba(16,24,40,0.03)",
color: darkMode ? "#f5f7ff" : "#162033",
cursor: "pointer",
}}
>
<Space size={8} wrap>
<Text strong>{option.label}</Text>
{active ? (
<Tag color="blue">
{t("connection.modal.mongodb.discovery.current")}
</Tag>
) : null}
</Space>
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
{option.description}
</div>
</button>
);
})}
</div>
<Form.Item
name="mongoReplicaPassword"
label="副本集密码(可选)"
style={{ marginTop: 16, marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
emptyPlaceholder: "留空沿用主密码",
retainedLabel: "已保存副本集密码",
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: "mongoReplicaPassword",
clearKey: "mongoReplicaPassword",
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
clearLabel: "清除已保存副本集密码",
description:
"当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。",
})}
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
<Button
onClick={handleDiscoverMongoMembers}
loading={discoveringMembers}
>
</Button>
</Space>
{mongoMembers.length > 0 && (
<Table
size="small"
rowKey={(record) => record.host}
pagination={false}
dataSource={mongoMembers}
style={{ marginBottom: 12 }}
columns={[
{ title: "Host", dataIndex: "host", width: "48%" },
{
title: "角色",
dataIndex: "role",
width: "32%",
render: (value: string, record: MongoMemberInfo) => (
<Tag color={record.isSelf ? "blue" : "default"}>
{value || "UNKNOWN"}
</Tag>
),
},
{
title: "健康",
dataIndex: "healthy",
width: "20%",
render: (value: boolean) => (
<Tag color={value ? "success" : "error"}>
{value ? "正常" : "异常"}
</Tag>
),
},
]}
{mongoSrv && useSSH && (
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message={t("connection.modal.mongodb.discovery.srv_ssh_warning")}
/>
)}
</>
),
})}
{renderConfigSectionCard({
sectionKey: "mongoPolicy",
icon: <ThunderboltOutlined />,
children: (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="mongoAuthSource"
label="认证库 (authSource)"
style={{ marginBottom: 0 }}
{mongoTopology === "replica" &&
renderConfigSectionCard({
sectionKey: "replica",
icon: <ClusterOutlined />,
children: (
<>
<Form.Item
name="mongoHosts"
label={t(
mongoSrv
? "connection.modal.mongodb.replica.hosts.srv.label"
: "connection.modal.mongodb.replica.hosts.standard.label",
)}
help={t(
mongoSrv
? "connection.modal.mongodb.replica.hosts.srv.help"
: "connection.modal.mongodb.replica.hosts.standard.help",
)}
>
<Select
mode="tags"
placeholder={t(
mongoSrv
? "connection.modal.mongodb.replica.hosts.srv.placeholder"
: "connection.modal.mongodb.replica.hosts.standard.placeholder",
)}
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="mongoReplicaSet"
label={t("connection.modal.mongodb.replica.set.label")}
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder={t("connection.modal.mongodb.replica.set.placeholder")}
/>
</Form.Item>
<Form.Item
name="mongoReplicaUser"
label={t("connection.modal.mongodb.replica.user.label")}
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder={t("connection.modal.mongodb.replica.user.placeholder")}
/>
</Form.Item>
</div>
<Form.Item
name="mongoReplicaPassword"
label={t("connection.modal.mongodb.replica.password.label")}
style={{ marginTop: 16, marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
emptyPlaceholder: t(
"connection.modal.mongodb.replica.password.placeholder.empty",
),
retainedLabel: t(
"connection.modal.mongodb.replica.password.placeholder.retained",
),
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: "mongoReplicaPassword",
clearKey: "mongoReplicaPassword",
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
clearLabel: t("connection.modal.mongodb.replica.password.clear"),
description: t("connection.modal.mongodb.replica.password.description"),
})}
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
<Button
onClick={handleDiscoverMongoMembers}
loading={discoveringMembers}
>
{t("connection.modal.action.discover_members")}
</Button>
</Space>
{mongoMembers.length > 0 && (
<Table
size="small"
rowKey={(record) => record.host}
pagination={false}
dataSource={mongoMembers}
style={{ marginBottom: 12 }}
columns={[
{ title: "Host", dataIndex: "host", width: "48%" },
{
title: t("connection.modal.mongodb.members.role"),
dataIndex: "role",
width: "32%",
render: (value: string, record: MongoMemberInfo) => (
<Tag color={record.isSelf ? "blue" : "default"}>
{value || "UNKNOWN"}
</Tag>
),
},
{
title: t("connection.modal.mongodb.members.health"),
dataIndex: "healthy",
width: "20%",
render: (value: boolean) => (
<Tag color={value ? "success" : "error"}>
{t(
value
? "connection.modal.mongodb.members.health.ok"
: "connection.modal.mongodb.members.health.error",
)}
</Tag>
),
},
]}
/>
)}
</>
),
})}
{renderConfigSectionCard({
sectionKey: "mongoPolicy",
icon: <ThunderboltOutlined />,
children: (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Input {...noAutoCapInputProps} placeholder="默认使用 database 或 admin" />
</Form.Item>
<div style={{ display: "grid", gap: 8 }}>
<Text strong> (readPreference)</Text>
{renderChoiceCards({
fieldName: "mongoReadPreference",
value: String(mongoReadPreference),
minWidth: 130,
options: [
{
value: "primary",
label: "primary",
description: "只读主节点。",
},
{
value: "primaryPreferred",
label: "primaryPreferred",
description: "主节点优先。",
},
{
value: "secondary",
label: "secondary",
description: "只读从节点。",
},
{
value: "secondaryPreferred",
label: "secondaryPreferred",
description: "从节点优先。",
},
{
value: "nearest",
label: "nearest",
description: "选择最近节点。",
},
],
})}
<Form.Item
name="mongoAuthSource"
label={t("connection.modal.mongodb.policy.auth_source.label")}
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder={t("connection.modal.mongodb.policy.auth_source.placeholder")}
/>
</Form.Item>
<div style={{ display: "grid", gap: 8 }}>
<Text strong>{t("connection.modal.mongodb.read_preference")}</Text>
{renderChoiceCards({
fieldName: "mongoReadPreference",
value: String(mongoReadPreference),
minWidth: 130,
options: [
{
value: "primary",
label: "primary",
description: t("connection.modal.mongodb.read_preference.primary"),
},
{
value: "primaryPreferred",
label: "primaryPreferred",
description: t(
"connection.modal.mongodb.read_preference.primary_preferred",
),
},
{
value: "secondary",
label: "secondary",
description: t("connection.modal.mongodb.read_preference.secondary"),
},
{
value: "secondaryPreferred",
label: "secondaryPreferred",
description: t(
"connection.modal.mongodb.read_preference.secondary_preferred",
),
},
{
value: "nearest",
label: "nearest",
description: t("connection.modal.mongodb.read_preference.nearest"),
},
],
})}
</div>
</div>
</div>
),
})}
</>
);
),
})}
</>
);
};
export default ConnectionModalMongoSections;

View File

@@ -11,6 +11,7 @@ import {
getStoredSecretPlaceholder,
type ConnectionConfigSectionKey,
} from "../utils/connectionModalPresentation";
import { useI18n } from "../i18n/provider";
import { noAutoCapInputProps } from "../utils/inputAutoCap";
type ChoiceCardOption = {
@@ -67,176 +68,185 @@ const ConnectionModalRedisSections: React.FC<ConnectionModalRedisSectionsProps>
renderConfigSectionCard,
renderStoredSecretControls,
createUriAwareRequiredRule,
}) => (
<>
{renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: (
<>
{renderChoiceCards({
fieldName: "redisTopology",
value: String(redisTopology),
options: [
{
value: "single",
label: "单机模式",
description: "只连接一个 Redis 节点。",
},
{
value: "cluster",
label: "集群模式",
description: "Redis Cluster配置多个种子节点。",
},
{
value: "sentinel",
label: "哨兵模式",
description: "通过 Sentinel 发现主节点,适合主从高可用。",
},
],
})}
{(redisTopology === "cluster" || redisTopology === "sentinel") && (
<>
<Form.Item
name="redisHosts"
label={
redisTopology === "sentinel"
? "Sentinel 附加节点地址"
: "集群附加节点地址"
}
help={
redisTopology === "sentinel"
? "上方主机地址作为第一个 Sentinel这里填写其他 Sentinel 节点格式host:port"
: "主节点使用上方主机地址这里填写其他种子节点格式host:port"
}
style={{ marginTop: 16, marginBottom: 0 }}
>
<Select
mode="tags"
placeholder={
redisTopology === "sentinel"
? "例如10.10.0.12:26379、10.10.0.13:26379"
: "例如10.10.0.12:6379、10.10.0.13:6379"
}
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
{redisTopology === "sentinel" && (
}) => {
const { t } = useI18n();
return (
<>
{renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: (
<>
{renderChoiceCards({
fieldName: "redisTopology",
value: String(redisTopology),
options: [
{
value: "single",
label: t("connection.modal.redis.topology.single.label"),
description: t("connection.modal.redis.topology.single.description"),
},
{
value: "cluster",
label: t("connection.modal.redis.topology.cluster.label"),
description: t("connection.modal.redis.topology.cluster.description"),
},
{
value: "sentinel",
label: t("connection.modal.redis.topology.sentinel.label"),
description: t("connection.modal.redis.topology.sentinel.description"),
},
],
})}
{(redisTopology === "cluster" || redisTopology === "sentinel") && (
<>
<Form.Item
name="redisSentinelMaster"
label="Sentinel master 名称"
help="填写 Sentinel 配置中的 monitor 名称,例如 mymaster。"
rules={[
createUriAwareRequiredRule(
"请输入 Sentinel master 名称",
),
]}
name="redisHosts"
label={t(
redisTopology === "sentinel"
? "connection.modal.redis.hosts.sentinel.label"
: "connection.modal.redis.hosts.cluster.label",
)}
help={t(
redisTopology === "sentinel"
? "connection.modal.redis.hosts.sentinel.help"
: "connection.modal.redis.hosts.cluster.help",
)}
style={{ marginTop: 16, marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如mymaster"
<Select
mode="tags"
placeholder={t(
redisTopology === "sentinel"
? "connection.modal.redis.hosts.sentinel.placeholder"
: "connection.modal.redis.hosts.cluster.placeholder",
)}
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
)}
</>
)}
</>
),
})}
{redisTopology === "sentinel" && (
<Form.Item
name="redisSentinelMaster"
label={t("connection.modal.redis.sentinel.master.label")}
help={t("connection.modal.redis.sentinel.master.help")}
rules={[
createUriAwareRequiredRule(
t("connection.modal.redis.sentinel.master.required"),
),
]}
style={{ marginTop: 16, marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder={t("connection.modal.redis.sentinel.master.placeholder")}
/>
</Form.Item>
)}
</>
)}
</>
),
})}
{renderConfigSectionCard({
sectionKey: "credentials",
icon: <SafetyCertificateOutlined />,
children: (
<>
<Form.Item name="password" label="密码 (可选)">
<Input.Password
{...noAutoCapInputProps}
visibilityToggle={{
visible: primaryPasswordVisible,
onVisibleChange: setPrimaryPasswordVisible,
}}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder: "Redis 密码(如果设置了 requirepass",
retainedLabel: "已保存 Redis 密码",
})}
/>
</Form.Item>
{redisTopology === "sentinel" && (
<>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
{renderConfigSectionCard({
sectionKey: "credentials",
icon: <SafetyCertificateOutlined />,
children: (
<>
<Form.Item name="password" label={t("connection.modal.redis.credentials.primary.label")}>
<Input.Password
{...noAutoCapInputProps}
visibilityToggle={{
visible: primaryPasswordVisible,
onVisibleChange: setPrimaryPasswordVisible,
}}
>
<Form.Item
name="redisSentinelUser"
label="Sentinel 用户名(可选)"
style={{ marginBottom: 0 }}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder: t("connection.modal.redis.credentials.primary.placeholder.empty"),
retainedLabel: t("connection.modal.redis.credentials.primary.placeholder.retained"),
})}
/>
</Form.Item>
{redisTopology === "sentinel" && (
<>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Input
{...noAutoCapInputProps}
placeholder="留空表示 Sentinel 不使用 ACL 用户名"
/>
</Form.Item>
<Form.Item
name="redisSentinelPassword"
label="Sentinel 密码(可选)"
style={{ marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
emptyPlaceholder: "Sentinel 自身认证密码,留空则不发送",
retainedLabel: "已保存 Sentinel 密码",
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: "redisSentinelPassword",
clearKey: "redisSentinelPassword",
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
clearLabel: "清除已保存 Sentinel 密码",
description:
"当前已保存 Sentinel 密码。留空表示继续沿用,输入新值表示替换。",
})}
</>
)}
</>
),
})}
<Form.Item
name="redisSentinelUser"
label={t("connection.modal.redis.credentials.sentinelUser.label")}
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder={t("connection.modal.redis.credentials.sentinelUser.placeholder")}
/>
</Form.Item>
<Form.Item
name="redisSentinelPassword"
label={t("connection.modal.redis.credentials.sentinelPassword.label")}
style={{ marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
emptyPlaceholder: t(
"connection.modal.redis.credentials.sentinelPassword.placeholder.empty",
),
retainedLabel: t(
"connection.modal.redis.credentials.sentinelPassword.placeholder.retained",
),
})}
/>
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: "redisSentinelPassword",
clearKey: "redisSentinelPassword",
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
clearLabel: t("connection.modal.redis.credentials.sentinelPassword.clear"),
description: t(
"connection.modal.redis.credentials.sentinelPassword.description",
),
})}
</>
)}
</>
),
})}
{renderConfigSectionCard({
sectionKey: "databaseScope",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="includeRedisDatabases"
label="显示数据库 (留空显示全部)"
help="连接测试成功后可选择"
style={{ marginBottom: 0 }}
>
<Select
mode="multiple"
placeholder="选择显示的数据库"
allowClear
{renderConfigSectionCard({
sectionKey: "databaseScope",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="includeRedisDatabases"
label={t("connection.modal.redis.databaseScope.label")}
help={t("connection.modal.redis.databaseScope.help")}
style={{ marginBottom: 0 }}
>
{redisDbList.map((db) => (
<Select.Option key={db} value={db}>
db{db}
</Select.Option>
))}
</Select>
</Form.Item>
),
})}
</>
);
<Select
mode="multiple"
placeholder={t("connection.modal.redis.databaseScope.placeholder")}
allowClear
>
{redisDbList.map((db) => (
<Select.Option key={db} value={db}>
db{db}
</Select.Option>
))}
</Select>
</Form.Item>
),
})}
</>
);
};
export default ConnectionModalRedisSections;

View File

@@ -1,5 +1,7 @@
import Modal from './common/ResizableDraggableModal';
import React from 'react';
import { Checkbox, Input, Modal, Typography } from 'antd';
import { Button, Checkbox, Input, Typography } from 'antd';
import { useI18n } from '../i18n/provider';
const { Text } = Typography;
@@ -16,6 +18,8 @@ export interface ConnectionPackagePasswordModalProps {
confirmLoading?: boolean;
confirmText?: string;
cancelText?: string;
onBack?: () => void;
embedded?: boolean;
onIncludeSecretsChange?: (value: boolean) => void;
onUseFilePasswordChange?: (value: boolean) => void;
onPasswordChange: (value: string) => void;
@@ -32,34 +36,54 @@ export default function ConnectionPackagePasswordModal({
password,
error,
confirmLoading,
confirmText = '确认',
cancelText = '取消',
confirmText,
cancelText,
onBack,
embedded = false,
onIncludeSecretsChange,
onUseFilePasswordChange,
onPasswordChange,
onConfirm,
onCancel,
}: ConnectionPackagePasswordModalProps) {
const { t } = useI18n();
const isExportMode = mode === 'export';
const showFilePasswordInput = isExportMode ? useFilePassword : true;
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
const resolvedConfirmText = confirmText ?? t('common.confirm');
const resolvedCancelText = cancelText ?? t('common.cancel');
const placeholder = isExportMode
? t('app.connection_package.dialog.file_password_placeholder')
: t('app.connection_package.dialog.restore_password_placeholder');
const helperText = !includeSecrets
? '将仅导出连接配置,不包含密码。'
? t('app.connection_package.dialog.help.exclude_passwords')
: (useFilePassword
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
? t('app.connection_package.dialog.help.share_file_password_separately')
: t('app.connection_package.dialog.help.encrypted_passwords_recommend_file_password'));
return (
<Modal
open={open}
title={title}
okText={confirmText}
cancelText={cancelText}
confirmLoading={confirmLoading}
onOk={onConfirm}
embedded={embedded}
title={embedded ? null : (
<span style={{ minWidth: 0 }}>{title}</span>
)}
closable={embedded ? false : undefined}
onCancel={onCancel}
destroyOnHidden={false}
maskClosable={false}
footer={[
<Button key="cancel" onClick={onCancel}>
{resolvedCancelText}
</Button>,
<Button key="confirm" type="primary" loading={confirmLoading} onClick={onConfirm}>
{resolvedConfirmText}
</Button>,
onBack ? (
<Button key="back" onClick={onBack}>
{t('common.back_to_previous')}
</Button>
) : null,
]}
>
{isExportMode ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -67,14 +91,14 @@ export default function ConnectionPackagePasswordModal({
checked={includeSecrets}
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
>
{t('app.connection_package.dialog.option.include_passwords')}
</Checkbox>
<Checkbox
checked={useFilePassword}
disabled={!includeSecrets}
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
>
{t('app.connection_package.dialog.option.use_file_password')}
</Checkbox>
</div>
) : null}

View File

@@ -0,0 +1,215 @@
import Modal from './common/ResizableDraggableModal';
import React, { useEffect, useMemo, useState } from 'react';
import { Form, InputNumber, Select, message } from 'antd';
import { ExportOutlined } from '@ant-design/icons';
import { t } from '../i18n';
export type DataExportFormat = 'csv' | 'xlsx' | 'json' | 'md' | 'html';
export type DataExportScope = 'selected' | 'page' | 'all' | 'filteredAll';
export type DataExportFileOptions = {
format: DataExportFormat;
xlsxMaxRowsPerSheet?: number;
};
export type DataExportDialogValues = DataExportFileOptions & {
scope: DataExportScope | string;
};
export type DataExportScopeOption = {
value: DataExportScope | string;
label: string;
description?: string;
disabled?: boolean;
};
export type ShowDataExportDialogOptions = {
title: string;
scopeOptions: DataExportScopeOption[];
initialValues?: Partial<DataExportDialogValues>;
okText?: string;
};
export const MAX_XLSX_ROWS_PER_SHEET = 1048575;
export const DEFAULT_XLSX_ROWS_PER_SHEET = MAX_XLSX_ROWS_PER_SHEET;
export const DEFAULT_DATA_EXPORT_FORMAT: DataExportFormat = 'xlsx';
export const DATA_EXPORT_FORMAT_OPTIONS: Array<{ value: DataExportFormat; label: string }> = [
{ value: 'xlsx', label: 'Excel (XLSX)' },
{ value: 'csv', label: 'CSV' },
{ value: 'json', label: 'JSON' },
{ value: 'md', label: 'Markdown' },
{ value: 'html', label: 'HTML' },
];
const resolveDefaultScope = (scopeOptions: DataExportScopeOption[], initialScope?: string): string => {
const matchedInitial = scopeOptions.find((item) => item.value === initialScope && !item.disabled);
if (matchedInitial) return String(matchedInitial.value);
const firstEnabled = scopeOptions.find((item) => !item.disabled);
return String(firstEnabled?.value || scopeOptions[0]?.value || 'all');
};
const normalizeDialogValues = (
scopeOptions: DataExportScopeOption[],
initialValues?: Partial<DataExportDialogValues>,
): DataExportDialogValues => {
const format = (initialValues?.format || DEFAULT_DATA_EXPORT_FORMAT) as DataExportFormat;
const scope = resolveDefaultScope(scopeOptions, initialValues?.scope ? String(initialValues.scope) : undefined);
const xlsxMaxRowsPerSheet = Number(initialValues?.xlsxMaxRowsPerSheet) > 0
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(initialValues?.xlsxMaxRowsPerSheet)))
: DEFAULT_XLSX_ROWS_PER_SHEET;
return {
format,
scope,
xlsxMaxRowsPerSheet,
};
};
const validateDialogValues = (
values: DataExportDialogValues,
scopeOptions: DataExportScopeOption[],
): string | null => {
if (!DATA_EXPORT_FORMAT_OPTIONS.some((item) => item.value === values.format)) {
return t('data_export.dialog.validation.format_required');
}
if (scopeOptions.length > 0) {
const matchedScope = scopeOptions.find((item) => String(item.value) === String(values.scope));
if (!matchedScope || matchedScope.disabled) {
return t('data_export.dialog.validation.scope_required');
}
}
if (values.format === 'xlsx') {
const rows = Math.trunc(Number(values.xlsxMaxRowsPerSheet) || 0);
if (!Number.isFinite(rows) || rows <= 0) {
return t('data_export.dialog.validation.xlsx_max_rows_required');
}
if (rows > MAX_XLSX_ROWS_PER_SHEET) {
return t('data_export.dialog.validation.xlsx_max_rows_limit', {
maxRows: MAX_XLSX_ROWS_PER_SHEET.toLocaleString(),
});
}
}
return null;
};
const DataExportDialogContent: React.FC<{
scopeOptions: DataExportScopeOption[];
initialValues?: Partial<DataExportDialogValues>;
onChange: (values: DataExportDialogValues) => void;
}> = ({ scopeOptions, initialValues, onChange }) => {
const [values, setValues] = useState<DataExportDialogValues>(() => normalizeDialogValues(scopeOptions, initialValues));
useEffect(() => {
onChange(values);
}, [onChange, values]);
const selectedScope = useMemo(
() => scopeOptions.find((item) => String(item.value) === String(values.scope)),
[scopeOptions, values.scope],
);
return (
<div data-export-config-modal="true">
<Form layout="vertical" colon={false}>
<Form.Item label={t('data_export.dialog.field.format')} style={{ marginBottom: 16 }}>
<Select
value={values.format}
options={DATA_EXPORT_FORMAT_OPTIONS}
onChange={(format) => setValues((prev) => ({ ...prev, format: format as DataExportFormat }))}
/>
</Form.Item>
<Form.Item label={t('data_export.dialog.field.scope')} style={{ marginBottom: 8 }}>
<Select
value={values.scope}
disabled={scopeOptions.length <= 1}
options={scopeOptions.map((item) => ({
value: item.value,
label: item.label,
disabled: item.disabled,
}))}
onChange={(scope) => setValues((prev) => ({ ...prev, scope }))}
/>
</Form.Item>
{selectedScope?.description && (
<div style={{ marginBottom: 16, color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
{selectedScope.description}
</div>
)}
{values.format === 'xlsx' && (
<Form.Item
label={t('data_export.dialog.field.xlsx_max_rows')}
extra={t('data_export.dialog.field.xlsx_max_rows_help', {
maxRows: MAX_XLSX_ROWS_PER_SHEET.toLocaleString(),
})}
style={{ marginBottom: 0 }}
>
<InputNumber
min={1}
max={MAX_XLSX_ROWS_PER_SHEET}
step={100000}
style={{ width: '100%' }}
value={values.xlsxMaxRowsPerSheet}
onChange={(nextValue) => setValues((prev) => ({
...prev,
xlsxMaxRowsPerSheet: Number(nextValue) > 0
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(nextValue)))
: 0,
}))}
/>
</Form.Item>
)}
</Form>
</div>
);
};
export async function showDataExportDialog(
modal: ReturnType<typeof Modal.useModal>[0],
options: ShowDataExportDialogOptions,
): Promise<DataExportDialogValues | null> {
const initialValues = normalizeDialogValues(options.scopeOptions, options.initialValues);
return new Promise((resolve) => {
let resolved = false;
let latestValues = initialValues;
const finish = (nextValue: DataExportDialogValues | null) => {
if (resolved) return;
resolved = true;
resolve(nextValue);
};
modal.confirm({
title: options.title,
icon: <ExportOutlined />,
width: 520,
centered: true,
maskClosable: true,
okText: options.okText || t('data_export.dialog.action.start'),
cancelText: t('common.cancel'),
content: (
<DataExportDialogContent
scopeOptions={options.scopeOptions}
initialValues={initialValues}
onChange={(values) => {
latestValues = values;
}}
/>
),
onOk: async () => {
const errorMessage = validateDialogValues(latestValues, options.scopeOptions);
if (errorMessage) {
void message.error(errorMessage);
throw new Error(errorMessage);
}
finish(latestValues);
},
onCancel: () => {
finish(null);
},
});
});
}

View File

@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const componentFiles = [
'./DataExportDialog.tsx',
'./ExportProgressModal.tsx',
'./TableExportWorkbench.tsx',
'./useExportProgressRunner.ts',
'../utils/tableExportTab.ts',
] as const;
const localeFiles = [
'zh-CN',
'zh-TW',
'en-US',
'ja-JP',
'de-DE',
'ru-RU',
] as const;
const sources = componentFiles.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8'));
const combinedSource = sources.join('\n');
const catalogs = Object.fromEntries(localeFiles.map((locale) => [
locale,
JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>,
])) as Record<typeof localeFiles[number], Record<string, string>>;
const extractKeys = (source: string): string[] => (
Array.from(new Set(source.match(/data_export(?:\.[a-z0-9_]+)+/g) || [])).sort()
);
const placeholdersOf = (value: string): string[] => (
Array.from(value.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g), (match) => match[1]).sort()
);
describe('data export i18n', () => {
it('routes dialog, progress modal, and workbench copy through translation keys instead of inline Han literals', () => {
expect(sources[0]).toContain("t('data_export.dialog.field.format')");
expect(sources[1]).toContain("t('data_export.progress.title.error')");
expect(sources[2]).toContain("t('data_export.workbench.title')");
expect(sources[3]).toContain("t('data_export.progress.title.done')");
expect(sources[3]).toContain("t('data_export.progress.title.error')");
expect(sources[4]).toContain("t('data_export.workbench.scope.all.label')");
expect(sources[4]).toContain("t('data_export.workbench.scope.all.description')");
expect(sources[4]).toContain("t('data_export.progress.value.target_fallback')");
expect(sources[4]).toContain("t('data_export.workbench.task.export_target'");
expect(combinedSource).not.toMatch(/\p{Script=Han}/u);
});
it('keeps all extracted data_export keys present in every supported locale with matching placeholders', () => {
const keys = extractKeys(combinedSource);
const baseline = catalogs['zh-CN'];
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => {
expect(baseline, `zh-CN:${key}`).toHaveProperty(key);
const expectedPlaceholders = placeholdersOf(baseline[key]);
localeFiles.forEach((locale) => {
expect(catalogs[locale], `${locale}:${key}`).toHaveProperty(key);
expect(placeholdersOf(catalogs[locale][key]), `${locale}:${key}`).toEqual(expectedPlaceholders);
});
});
});
});

View File

@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
describe('DataGrid auto commit delay i18n guards', () => {
it('localizes auto commit delay option labels', () => {
expect(dataGridSource).toContain("translateDataGrid('data_grid.toolbar.commit_delay.seconds', { seconds: item.seconds })");
[
"label: '3 秒'",
"label: '5 秒'",
"label: '10 秒'",
"label: '30 秒'",
].forEach((legacyText) => {
expect(dataGridSource).not.toContain(legacyText);
});
});
});

View File

@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
describe('DataGrid auto commit i18n guards', () => {
it('localizes auto commit toast wrappers while preserving raw details', () => {
expect(dataGridSource).toContain("translateDataGrid('data_grid.message.auto_commit_success')");
expect(dataGridSource).toContain("translateDataGrid('data_grid.message.auto_commit_failed', { detail: res.message })");
expect(dataGridSource).not.toContain("'自动提交成功'");
expect(dataGridSource).not.toContain('`自动提交失败: ${res.message}`');
});
});

View File

@@ -0,0 +1,17 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const legacyMenuSource = readFileSync(new URL('./DataGridLegacyCellContextMenu.tsx', import.meta.url), 'utf8');
const v2MenuSource = readFileSync(new URL('./V2TableContextMenu.tsx', import.meta.url), 'utf8');
describe('DataGrid cell undo menu i18n guards', () => {
it('localizes cell undo action labels in legacy and v2 menus', () => {
[
legacyMenuSource,
v2MenuSource,
].forEach((source) => {
expect(source).toContain("data_grid.context_menu.undo_cell_change");
expect(source).not.toContain('撤销此单元格修改');
});
});
});

View File

@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
describe('DataGrid cell undo i18n guards', () => {
it('localizes cell undo toast wrappers', () => {
[
"translateDataGrid('data_grid.message.undo_added_row_hint')",
"translateDataGrid('data_grid.message.undo_cell_original_missing')",
"translateDataGrid('data_grid.message.undo_cell_success')",
].forEach((expected) => {
expect(dataGridSource).toContain(expected);
});
[
'新增行请使用删除选中或整表回滚撤销',
'未找到该单元格的原始数据,无法撤销',
'已撤销单元格修改',
].forEach((legacyText) => {
expect(dataGridSource).not.toContain(legacyText);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
describe('DataGrid embedded designer title i18n guards', () => {
it('localizes the embedded table designer tab title while preserving the raw table name parameter', () => {
expect(dataGridSource).toContain("translateDataGrid('data_grid.embedded_designer.title'");
expect(dataGridSource).toContain('tableName: tableName ||');
expect(dataGridSource).not.toContain('title: `设计表 (${tableName || \'\'}');
});
it('keeps the embedded designer title key in every locale catalog with the tableName placeholder', () => {
(['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const).forEach((locale) => {
const catalog = JSON.parse(
readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'),
) as Record<string, string>;
expect(catalog['data_grid.embedded_designer.title']).toEqual(expect.any(String));
expect(catalog['data_grid.embedded_designer.title']).toContain('{{tableName}}');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
const rowNumberAriaKey = 'data_grid.aria.row_number';
describe('DataGrid row number i18n', () => {
it('localizes the row number column aria label', () => {
expect(source).toContain(`aria-label={translateDataGrid('${rowNumberAriaKey}')}`);
expect(source).not.toContain('aria-label="行号"');
});
it('keeps the row number aria label available in every locale', () => {
locales.forEach((locale) => {
const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>;
expect(catalog[rowNumberAriaKey], `${locale}:${rowNumberAriaKey}`).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,12 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8');
describe('DataGrid secondary actions i18n guards', () => {
it('localizes the object design action label', () => {
expect(secondaryActionsSource).toContain("translate('data_grid.secondary.object_design')");
expect(secondaryActionsSource).not.toContain("'对象设计'");
expect(secondaryActionsSource).not.toContain('>对象设计<');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,8 @@
import React from 'react';
import { Button, Checkbox, Input } from 'antd';
import { t as defaultTranslate, type I18nParams } from '../i18n';
export type DataGridColumnInfoTranslate = (key: string, params?: I18nParams) => string;
export interface DataGridColumnInfoPopoverContentProps {
darkMode: boolean;
@@ -12,6 +15,7 @@ export interface DataGridColumnInfoPopoverContentProps {
enableHiddenColumnMemory: boolean;
canResetOrder: boolean;
canResetHidden: boolean;
translate?: DataGridColumnInfoTranslate;
onShowColumnCommentChange: (checked: boolean) => void;
onShowColumnTypeChange: (checked: boolean) => void;
onToggleAllColumnsVisibility: (visible: boolean) => void;
@@ -34,6 +38,7 @@ const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverConten
enableHiddenColumnMemory,
canResetOrder,
canResetHidden,
translate = defaultTranslate,
onShowColumnCommentChange,
onShowColumnTypeChange,
onToggleAllColumnsVisibility,
@@ -45,24 +50,30 @@ const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverConten
onResetHidden,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 200, maxWidth: 300 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}></div>
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}>
{translate('data_grid.column_settings.display_settings')}
</div>
<Checkbox checked={showColumnComment} onChange={(e) => onShowColumnCommentChange(e.target.checked)}>
{translate('data_grid.column_settings.show_comments')}
</Checkbox>
<Checkbox checked={showColumnType} onChange={(e) => onShowColumnTypeChange(e.target.checked)}>
{translate('data_grid.column_settings.show_types')}
</Checkbox>
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span></span>
<span>{translate('data_grid.column_settings.column_visibility')}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(true)}></a>
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(false)}></a>
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(true)}>
{translate('data_grid.column_settings.show_all')}
</a>
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(false)}>
{translate('data_grid.column_settings.hide_all')}
</a>
</div>
</div>
<Input
placeholder="搜索列名..."
placeholder={translate('data_grid.column_settings.search_columns_placeholder')}
size="small"
value={columnSearchText}
onChange={(e) => onColumnSearchTextChange(e.target.value)}
@@ -85,17 +96,17 @@ const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverConten
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
<Checkbox checked={enableColumnOrderMemory} onChange={(e) => onEnableColumnOrderMemoryChange(e.target.checked)}>
{translate('data_grid.column_settings.remember_column_order')}
</Checkbox>
<Checkbox checked={enableHiddenColumnMemory} onChange={(e) => onEnableHiddenColumnMemoryChange(e.target.checked)}>
{translate('data_grid.column_settings.remember_hidden_columns')}
</Checkbox>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetOrder} onClick={onResetOrder}>
{translate('data_grid.column_settings.reset_order')}
</Button>
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetHidden} onClick={onResetHidden}>
{translate('data_grid.column_settings.reset_hidden')}
</Button>
</div>
</div>

View File

@@ -1,6 +1,9 @@
import React from 'react';
import { AutoComplete, Input, Tooltip } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
export type DataGridColumnQuickFindTranslate = (key: string, params?: I18nParams) => string;
export interface DataGridColumnQuickFindProps {
isV2Ui: boolean;
@@ -9,6 +12,7 @@ export interface DataGridColumnQuickFindProps {
value: string;
options: Array<{ value: string; label?: React.ReactNode }>;
hasTarget: boolean;
translate?: DataGridColumnQuickFindTranslate;
onChange: (value: string) => void;
onSubmit: (value?: string) => void;
}
@@ -18,13 +22,14 @@ const DataGridColumnQuickFind: React.FC<DataGridColumnQuickFindProps> = ({
inputProps,
value,
options,
translate = defaultTranslate,
onChange,
onSubmit,
}) => {
const legacyDropdownOpen = !isV2Ui && String(value || '').trim().length > 0 && options.length > 0;
return (
<Tooltip title="输入字段名,回车或点定位按钮即可跳到对应列">
<Tooltip title={translate('data_grid.column_quick_find.tooltip')}>
<div
data-grid-column-quick-find="true"
className={isV2Ui ? 'gn-v2-data-grid-column-quick-find' : undefined}
@@ -54,7 +59,7 @@ const DataGridColumnQuickFind: React.FC<DataGridColumnQuickFindProps> = ({
size="small"
variant="borderless"
prefix={<SearchOutlined />}
placeholder="跳到字段列..."
placeholder={translate('data_grid.column_quick_find.placeholder')}
value={value}
onChange={(event) => onChange(event.target.value)}
onPressEnter={() => onSubmit(value)}

View File

@@ -1,18 +1,54 @@
import React from 'react';
import { act, create } from 'react-test-renderer';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import DataGridColumnTitle from './DataGridColumnTitle';
vi.mock('antd', () => ({
Button: ({ children, type: buttonType, htmlType, ...props }: { children?: React.ReactNode; type?: string; htmlType?: 'button' | 'submit' | 'reset' }) => (
<button type={htmlType || 'button'} data-button-type={buttonType} {...props}>
{children}
</button>
),
Form: ({ children, component: Component = 'form', onFinish: _onFinish, ...props }: { children?: React.ReactNode; component?: React.ElementType; onFinish?: () => void }) => (
<Component {...props}>{children}</Component>
),
Input: Object.assign(
({ onPressEnter: _onPressEnter, ...props }: { onPressEnter?: () => void }) => <input {...props} />,
{
TextArea: ({ autoSize: _autoSize, ...props }: { autoSize?: unknown }) => <textarea {...props} />,
},
),
Popover: ({ children, content, open }: { children: React.ReactNode; content?: React.ReactNode; open?: boolean }) => (
<span data-popover-open={open ? 'true' : 'false'}>
{content}
{children}
</span>
),
Select: ({ options = [], value, onChange }: { options?: Array<{ value: string; label: string }>; value?: string; onChange?: (value: string) => void }) => (
<select value={value} onChange={(event) => onChange?.(event.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
Tooltip: ({ children, title, rootClassName }: { children: React.ReactNode; title?: React.ReactNode; rootClassName?: string }) => (
<>
<div data-testid="tooltip-title">{title}</div>
<div data-tooltip-root-class={rootClassName}>{title}</div>
{children}
</>
),
}));
vi.mock('@ant-design/icons', () => ({
FilterOutlined: () => <span data-icon="filter" />,
LinkOutlined: () => <span data-icon="link" />,
}));
describe('DataGridColumnTitle', () => {
it('marks v2 table headers as single-line when column type and comment rows are hidden', () => {
const markup = renderToStaticMarkup(
@@ -109,4 +145,207 @@ describe('DataGridColumnTitle', () => {
expect(markup).toContain('data-grid-fk-jump="true"');
expect(markup).toContain('data-ref-table-name="customers"');
});
it('renders a compact column filter trigger with active state', () => {
const markup = renderToStaticMarkup(
<DataGridColumnTitle
columnName="status"
showColumnType={false}
showColumnComment={false}
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
columnFilter={{
active: true,
operatorOptions: [
{ value: '=', label: '=' },
{ value: 'CONTAINS', label: 'Contains' },
],
defaultOperator: 'CONTAINS',
initialOperator: '=',
initialValue: 'active',
filterLabel: 'Filter',
applyLabel: 'Apply',
clearLabel: 'Clear',
valuePlaceholder: 'Value',
secondValuePlaceholder: 'End value',
listValuePlaceholder: 'List values',
noValuePlaceholder: 'No value needed',
isNoValueOp: () => false,
isBetweenOp: () => false,
isListOp: () => false,
onApply: () => true,
onClear: () => true,
}}
/>,
);
expect(markup).toContain('class="gn-v2-column-title-shell"');
expect(markup).toContain('data-grid-column-filter-trigger="true"');
expect(markup).toContain('data-grid-column-filter-active="true"');
expect(markup).toContain('data-grid-column-filter-popover="true"');
expect(markup).toContain('Filter status');
expect(markup).toContain('value="active"');
});
it('applies the column filter from the popover action button', () => {
const onApply = vi.fn(() => true);
const renderer = create(
<DataGridColumnTitle
columnName="code"
showColumnType={false}
showColumnComment={false}
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
columnFilter={{
active: false,
operatorOptions: [
{ value: 'CONTAINS', label: 'Contains' },
],
defaultOperator: 'CONTAINS',
initialOperator: 'CONTAINS',
initialValue: '3551',
filterLabel: 'Filter',
applyLabel: 'Apply',
clearLabel: 'Clear',
valuePlaceholder: 'Value',
secondValuePlaceholder: 'End value',
listValuePlaceholder: 'List values',
noValuePlaceholder: 'No value needed',
isNoValueOp: () => false,
isBetweenOp: () => false,
isListOp: () => false,
onApply,
onClear: () => true,
}}
/>,
);
const applyButton = renderer.root
.findAllByType('button')
.find((button) => button.children.includes('Apply'));
expect(applyButton).toBeTruthy();
act(() => {
applyButton!.props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(onApply).toHaveBeenCalledWith({
op: 'CONTAINS',
value: '3551',
value2: '',
});
});
it('keeps column filter operator switching and clearing interactive', () => {
const onApply = vi.fn(() => true);
const onClear = vi.fn(() => true);
const renderer = create(
<DataGridColumnTitle
columnName="title"
showColumnType={false}
showColumnComment={false}
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
columnFilter={{
active: true,
operatorOptions: [
{ value: '=', label: '=' },
{ value: 'CONTAINS', label: 'Contains' },
],
defaultOperator: 'CONTAINS',
initialOperator: 'CONTAINS',
initialValue: '3551',
filterLabel: 'Filter',
applyLabel: 'Apply',
clearLabel: 'Clear',
valuePlaceholder: 'Value',
secondValuePlaceholder: 'End value',
listValuePlaceholder: 'List values',
noValuePlaceholder: 'No value needed',
isNoValueOp: () => false,
isBetweenOp: () => false,
isListOp: () => false,
onApply,
onClear,
}}
/>,
);
const operatorSelect = renderer.root.findByType('select');
act(() => {
operatorSelect.props.onChange({ target: { value: '=' } });
});
const buttons = renderer.root.findAllByType('button');
const clearButton = buttons.find((button) => button.children.includes('Clear'));
const applyButton = buttons.find((button) => button.children.includes('Apply'));
expect(clearButton).toBeTruthy();
expect(applyButton).toBeTruthy();
act(() => {
applyButton!.props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(onApply).toHaveBeenLastCalledWith({
op: '=',
value: '3551',
value2: '',
});
act(() => {
clearButton!.props.onClick();
});
expect(onClear).toHaveBeenCalledTimes(1);
});
it('uses translated tooltip wrappers while preserving raw metadata values', () => {
const translate = vi.fn((key: string, params?: Record<string, unknown>) => {
if (key === 'data_grid.column.type_tooltip') return `TYPE ${String(params?.type)}`;
if (key === 'data_grid.column.comment_tooltip') return `COMMENT ${String(params?.comment)}`;
if (key === 'data_grid.column.foreign_key_tooltip') return `FK ${String(params?.target)}`;
if (key === 'data_grid.column.foreign_key_jump_title') return `JUMP ${String(params?.tableName)}`;
return key;
});
const markup = renderToStaticMarkup(
<DataGridColumnTitle
columnName="account_id"
columnMeta={{ type: 'uuid', comment: '账户编号' }}
foreignKeyTarget={{ refTableName: 'public.users', refColumnName: 'id' }}
showColumnType
showColumnComment
metaFontSize={11}
columnMetaHintColor="#999"
columnMetaTooltipColor="#fff"
darkMode={false}
translate={translate}
/>,
);
expect(markup).toContain('TYPE uuid');
expect(markup).toContain('COMMENT 账户编号');
expect(markup).toContain('FK public.users.id');
expect(markup).toContain('title="JUMP public.users"');
expect(markup).not.toContain('类型uuid');
expect(markup).not.toContain('备注:账户编号');
expect(markup).not.toContain('外键public.users.id');
expect(markup).not.toContain('跳转到外键表public.users');
expect(translate).toHaveBeenCalledWith('data_grid.column.type_tooltip', { type: 'uuid' });
expect(translate).toHaveBeenCalledWith('data_grid.column.comment_tooltip', { comment: '账户编号' });
expect(translate).toHaveBeenCalledWith('data_grid.column.foreign_key_tooltip', { target: 'public.users.id' });
expect(translate).toHaveBeenCalledWith('data_grid.column.foreign_key_jump_title', { tableName: 'public.users' });
});
});

View File

@@ -1,6 +1,36 @@
import React from 'react';
import { Tooltip } from 'antd';
import { LinkOutlined } from '@ant-design/icons';
import { Button, Input, Popover, Select, Tooltip } from 'antd';
import { FilterOutlined, LinkOutlined } from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
export type DataGridColumnTitleTranslate = (key: string, params?: I18nParams) => string;
export type DataGridColumnFilterDraft = {
op: string;
value: string;
value2?: string;
};
export interface DataGridColumnFilterConfig {
active: boolean;
operatorOptions: Array<{ value: string; label: string }>;
defaultOperator: string;
initialOperator?: string;
initialValue?: string;
initialValue2?: string;
filterLabel: string;
applyLabel: string;
clearLabel: string;
valuePlaceholder: string;
secondValuePlaceholder: string;
listValuePlaceholder: string;
noValuePlaceholder: string;
isNoValueOp: (op: string) => boolean;
isBetweenOp: (op: string) => boolean;
isListOp: (op: string) => boolean;
onApply: (draft: DataGridColumnFilterDraft) => boolean | void;
onClear: () => boolean | void;
}
export interface DataGridColumnTitleProps {
columnName: string;
@@ -19,9 +49,15 @@ export interface DataGridColumnTitleProps {
columnMetaTooltipColor: string;
darkMode: boolean;
highlighted?: boolean;
translate?: DataGridColumnTitleTranslate;
onOpenForeignKey?: () => void;
columnFilter?: DataGridColumnFilterConfig | null;
}
const stopColumnHeaderInteraction = (event: React.SyntheticEvent<HTMLElement>) => {
event.stopPropagation();
};
const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
columnName,
columnMeta,
@@ -33,7 +69,9 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
columnMetaTooltipColor,
darkMode,
highlighted = false,
translate = defaultTranslate,
onOpenForeignKey,
columnFilter,
}) => {
const normalizedName = String(columnName || '');
const columnType = String(columnMeta?.type || '').trim();
@@ -43,13 +81,31 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
const shouldShowColumnType = showColumnType && columnType.length > 0;
const shouldShowColumnComment = showColumnComment && columnComment.length > 0;
const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment;
const [filterPopoverOpen, setFilterPopoverOpen] = React.useState(false);
const initialFilterOperator = columnFilter?.initialOperator || columnFilter?.defaultOperator || '=';
const [draftFilterOperator, setDraftFilterOperator] = React.useState(initialFilterOperator);
const [draftFilterValue, setDraftFilterValue] = React.useState(columnFilter?.initialValue || '');
const [draftFilterValue2, setDraftFilterValue2] = React.useState(columnFilter?.initialValue2 || '');
React.useEffect(() => {
if (!filterPopoverOpen || !columnFilter) return;
setDraftFilterOperator(columnFilter.initialOperator || columnFilter.defaultOperator || '=');
setDraftFilterValue(columnFilter.initialValue || '');
setDraftFilterValue2(columnFilter.initialValue2 || '');
}, [
columnFilter?.defaultOperator,
columnFilter?.initialOperator,
columnFilter?.initialValue,
columnFilter?.initialValue2,
filterPopoverOpen,
]);
const hoverLines: string[] = [];
if (columnType) hoverLines.push(`类型:${columnType}`);
if (columnComment) hoverLines.push(`备注:${columnComment}`);
if (columnType) hoverLines.push(translate('data_grid.column.type_tooltip', { type: columnType }));
if (columnComment) hoverLines.push(translate('data_grid.column.comment_tooltip', { comment: columnComment }));
if (refTableName) {
const refColumnText = refColumnName ? `.${refColumnName}` : '';
hoverLines.push(`外键:${refTableName}${refColumnText}`);
hoverLines.push(translate('data_grid.column.foreign_key_tooltip', { target: `${refTableName}${refColumnText}` }));
}
const fieldLabel = refTableName ? (
@@ -58,7 +114,7 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
data-grid-fk-jump="true"
data-column-name={normalizedName}
data-ref-table-name={refTableName}
title={`跳转到外键表:${refTableName}`}
title={translate('data_grid.column.foreign_key_jump_title', { tableName: refTableName })}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -146,35 +202,227 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
</div>
);
if (hoverLines.length === 0) {
return titleNode;
const titleWithOptionalTooltip = (() => {
if (hoverLines.length === 0) {
return titleNode;
}
const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)';
return (
<Tooltip
title={(
<pre
className="gn-data-grid-column-meta-tooltip-content"
style={{
maxHeight: 260,
overflow: 'auto',
margin: 0,
fontSize: 12,
whiteSpace: 'pre-wrap',
color: tooltipTextColor,
}}
>
{hoverLines.join('\n')}
</pre>
)}
rootClassName="gn-data-grid-column-meta-tooltip"
styles={{ root: { maxWidth: 640 } }}
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
>
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
</Tooltip>
);
})();
if (!columnFilter) {
return titleWithOptionalTooltip;
}
const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)';
return (
<Tooltip
title={(
<pre
className="gn-data-grid-column-meta-tooltip-content"
style={{
maxHeight: 260,
overflow: 'auto',
margin: 0,
fontSize: 12,
whiteSpace: 'pre-wrap',
color: tooltipTextColor,
const noValueOperator = columnFilter.isNoValueOp(draftFilterOperator);
const betweenOperator = columnFilter.isBetweenOp(draftFilterOperator);
const listOperator = columnFilter.isListOp(draftFilterOperator);
const activeColor = darkMode ? '#74d99f' : '#16a34a';
const mutedColor = columnFilter.active
? activeColor
: (darkMode ? 'rgba(255,255,255,0.52)' : 'rgba(15, 23, 42, 0.46)');
const filterButtonTitle = `${columnFilter.filterLabel} ${normalizedName}`;
const submitColumnFilter = (event?: React.SyntheticEvent<HTMLElement>) => {
event?.preventDefault();
event?.stopPropagation();
const applied = columnFilter.onApply({
op: draftFilterOperator,
value: draftFilterValue,
value2: draftFilterValue2,
});
if (applied !== false) setFilterPopoverOpen(false);
};
const filterPopoverContent = (
<div
data-grid-column-filter-popover="true"
onClick={stopColumnHeaderInteraction}
style={{
width: 260,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div
style={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 600,
color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(15,23,42,0.88)',
}}
title={normalizedName}
>
{filterButtonTitle}
</div>
<Select
size="small"
value={draftFilterOperator}
options={columnFilter.operatorOptions}
popupMatchSelectWidth={false}
getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body}
onChange={(value) => {
const nextOperator = String(value || columnFilter.defaultOperator || '=');
setDraftFilterOperator(nextOperator);
if (columnFilter.isNoValueOp(nextOperator)) {
setDraftFilterValue('');
setDraftFilterValue2('');
} else if (!columnFilter.isBetweenOp(nextOperator)) {
setDraftFilterValue2('');
}
}}
/>
{noValueOperator ? (
<Input
size="small"
disabled
value={columnFilter.noValuePlaceholder}
/>
) : listOperator ? (
<Input.TextArea
value={draftFilterValue}
placeholder={columnFilter.listValuePlaceholder}
autoSize={{ minRows: 2, maxRows: 4 }}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onChange={(event) => setDraftFilterValue(event.target.value)}
/>
) : betweenOperator ? (
<div style={{ display: 'flex', gap: 8 }}>
<Input
size="small"
value={draftFilterValue}
placeholder={columnFilter.valuePlaceholder}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onPressEnter={submitColumnFilter}
onChange={(event) => setDraftFilterValue(event.target.value)}
/>
<Input
size="small"
value={draftFilterValue2}
placeholder={columnFilter.secondValuePlaceholder}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onPressEnter={submitColumnFilter}
onChange={(event) => setDraftFilterValue2(event.target.value)}
/>
</div>
) : (
<Input
size="small"
value={draftFilterValue}
placeholder={columnFilter.valuePlaceholder}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onPressEnter={submitColumnFilter}
onChange={(event) => setDraftFilterValue(event.target.value)}
/>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button
size="small"
onClick={() => {
const cleared = columnFilter.onClear();
if (cleared !== false) setFilterPopoverOpen(false);
}}
>
{hoverLines.join('\n')}
</pre>
)}
rootClassName="gn-data-grid-column-meta-tooltip"
styles={{ root: { maxWidth: 640 } }}
{...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})}
{columnFilter.clearLabel}
</Button>
<Button
type="primary"
size="small"
onClick={submitColumnFilter}
>
{columnFilter.applyLabel}
</Button>
</div>
</div>
);
return (
<span
className="gn-v2-column-title-shell"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
maxWidth: '100%',
minWidth: 0,
}}
>
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
</Tooltip>
<span style={{ display: 'inline-flex', minWidth: 0, maxWidth: 'calc(100% - 24px)' }}>
{titleWithOptionalTooltip}
</span>
<Popover
trigger="click"
placement="bottomLeft"
open={filterPopoverOpen}
onOpenChange={setFilterPopoverOpen}
content={filterPopoverContent}
>
<button
type="button"
data-grid-column-filter-trigger="true"
data-grid-column-filter-active={columnFilter.active ? 'true' : undefined}
aria-label={filterButtonTitle}
title={filterButtonTitle}
onClick={(event) => {
event.stopPropagation();
}}
onMouseDown={stopColumnHeaderInteraction}
onPointerDown={stopColumnHeaderInteraction}
style={{
width: 22,
height: 22,
flex: '0 0 22px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
border: columnFilter.active ? `1px solid ${activeColor}` : '1px solid transparent',
borderRadius: 6,
background: columnFilter.active
? (darkMode ? 'rgba(34, 197, 94, 0.14)' : 'rgba(34, 197, 94, 0.12)')
: 'transparent',
color: mutedColor,
cursor: 'pointer',
}}
>
<FilterOutlined style={{ fontSize: 12 }} />
</button>
</Popover>
</span>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,283 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DataGridErDiagram from './DataGridErDiagram';
const hookState = vi.hoisted(() => ({
graph: {
nodes: [
{
id: 'er:messages',
tableName: 'messages',
role: 'current',
isCurrent: true,
incomingCount: 1,
outgoingCount: 0,
relationCount: 1,
columns: Array.from({ length: 12 }, (_, index) => ({
name: `field_${index + 1}`,
type: 'varchar(32)',
comment: '',
nullable: index !== 0,
isPrimary: index === 0,
isForeign: false,
isRelationField: index <= 1,
})),
previewColumnCount: 10,
hiddenColumnCount: 2,
},
{
id: 'er:message_tags',
tableName: 'message_tags',
role: 'incoming',
isCurrent: false,
incomingCount: 0,
outgoingCount: 1,
relationCount: 1,
columns: [
{
name: 'id',
type: 'bigint',
comment: '',
nullable: false,
isPrimary: true,
isForeign: false,
isRelationField: true,
},
{
name: 'message_id',
type: 'bigint',
comment: '',
nullable: false,
isPrimary: false,
isForeign: true,
isRelationField: true,
},
],
previewColumnCount: 2,
hiddenColumnCount: 0,
},
],
edges: [],
relationCount: 1,
relatedTableCount: 1,
incomingTableCount: 1,
outgoingTableCount: 0,
isEmpty: false,
},
loading: false,
reloading: false,
error: '',
partial: false,
reload: vi.fn(),
canExpandRelations: true,
}));
vi.mock('./useDataGridErDiagram', () => ({
useDataGridErDiagram: () => hookState,
}));
vi.mock('antd', () => ({
Alert: ({ message }: { message?: React.ReactNode }) => <div>{message}</div>,
Button: ({
children,
icon,
onClick,
disabled,
...props
}: {
children?: React.ReactNode;
icon?: React.ReactNode;
onClick?: (...args: any[]) => void;
disabled?: boolean;
[key: string]: any;
}) => (
<button type="button" onClick={onClick} disabled={disabled} {...props}>
{icon}
{children}
</button>
),
Spin: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@ant-design/icons', () => {
const Icon = ({ label }: { label: string }) => <span>{label}</span>;
return {
ApartmentOutlined: () => <Icon label="ApartmentOutlined" />,
ArrowRightOutlined: () => <Icon label="ArrowRightOutlined" />,
CompressOutlined: () => <Icon label="CompressOutlined" />,
DatabaseOutlined: () => <Icon label="DatabaseOutlined" />,
ExpandOutlined: () => <Icon label="ExpandOutlined" />,
LinkOutlined: () => <Icon label="LinkOutlined" />,
PlusOutlined: () => <Icon label="PlusOutlined" />,
ReloadOutlined: () => <Icon label="ReloadOutlined" />,
UndoOutlined: () => <Icon label="UndoOutlined" />,
};
});
vi.mock('reactflow', async () => {
const ReactModule = await import('react');
const ReactFlow = ({
nodes,
nodeTypes,
children,
}: {
nodes: Array<{ id: string; type: string; data: any }>;
nodeTypes: Record<string, React.ComponentType<any>>;
children?: React.ReactNode;
}) => (
<div data-react-flow="true">
{nodes.map((node) => {
const NodeComponent = nodeTypes[node.type];
return (
<div key={node.id} data-node-id={node.id}>
<NodeComponent data={node.data} />
</div>
);
})}
{children}
</div>
);
return {
__esModule: true,
default: ReactFlow,
Background: () => null,
Controls: () => null,
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
useReactFlow: () => ({ fitView: vi.fn() }),
useNodesState: (initialNodes: any[]) => {
const [nodes, setNodes] = ReactModule.useState(initialNodes);
return [nodes, setNodes, vi.fn()] as const;
},
useEdgesState: (initialEdges: any[]) => {
const [edges, setEdges] = ReactModule.useState(initialEdges);
return [edges, setEdges, vi.fn()] as const;
},
BackgroundVariant: { Dots: 'dots' },
MarkerType: { ArrowClosed: 'arrowclosed' },
Position: { Left: 'left', Right: 'right' },
};
});
const messages: Record<string, string> = {
'data_grid.metadata_view.er_table_badge': '表',
'data_grid.metadata_view.er_current_badge': '当前表',
'data_grid.metadata_view.er_referenced_by_badge': '被引用',
'data_grid.metadata_view.er_reference_badge': '引用',
'data_grid.metadata_view.er_open_table': '打开表',
'data_grid.metadata_view.er_related_table_count': '{{count}} 张关联表',
'data_grid.metadata_view.er_relation_count': '{{count}} 条关系',
'data_grid.metadata_view.er_relation_depth': '{{count}} 层关系',
'data_grid.metadata_view.er_expand_relations': '展开下一层关系',
'data_grid.metadata_view.er_reset_relations': '重置为一层',
'data_grid.metadata_view.er_expand_fields': '展开全部字段',
'data_grid.metadata_view.er_collapse_fields': '收起字段摘要',
'data_grid.metadata_view.er_expand_hidden_columns': '展开剩余 {{count}} 个字段',
'data_grid.metadata_view.er_empty': '当前表未发现外键关系',
'data_grid.metadata_view.er_partial_warning': '部分关系未能完整加载,图中结果可能不完整',
'data_grid.table_fallback.query_result': '查询结果',
'common.refresh': '刷新',
};
const translate = (key: string, params?: Record<string, unknown>) => {
let template = messages[key] || key;
Object.entries(params || {}).forEach(([paramKey, paramValue]) => {
template = template.replace(`{{${paramKey}}}`, String(paramValue));
});
return template;
};
const textContent = (node: any): string => {
if (node === null || node === undefined || typeof node === 'boolean') {
return '';
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map((child) => textContent(child)).join('');
}
if ('children' in node) {
return textContent(node.children);
}
return '';
};
const findButton = (renderer: ReactTestRenderer, matcher: (node: any) => boolean) => (
renderer.root.find((node) => node.type === 'button' && matcher(node))
);
describe('DataGridErDiagram', () => {
beforeEach(() => {
hookState.reload.mockReset();
hookState.canExpandRelations = true;
});
it('shows hidden fields on demand and lets the toolbar collapse them again', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGridErDiagram
connections={[]}
connectionId="conn-1"
dbName="main"
tableName="messages"
translate={translate}
/>,
);
});
expect(textContent(renderer.toJSON())).not.toContain('field_11');
expect(
findButton(renderer, (node) => node.props['data-er-node-toggle'] === 'er:messages').props.className,
).toContain('nodrag');
await act(async () => {
findButton(renderer, (node) => node.props['data-er-node-toggle'] === 'er:messages').props.onClick({
preventDefault() {},
stopPropagation() {},
});
});
expect(textContent(renderer.toJSON())).toContain('field_11');
expect(textContent(renderer.toJSON())).toContain('收起字段摘要');
await act(async () => {
findButton(renderer, (node) => node.props['data-er-action'] === 'collapse-fields').props.onClick();
});
expect(textContent(renderer.toJSON())).not.toContain('field_11');
});
it('tracks relation depth from the toolbar controls', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGridErDiagram
connections={[]}
connectionId="conn-1"
dbName="main"
tableName="messages"
translate={translate}
/>,
);
});
expect(textContent(renderer.toJSON())).toContain('1 层关系');
await act(async () => {
findButton(renderer, (node) => node.props['data-er-action'] === 'expand-relations').props.onClick();
});
expect(textContent(renderer.toJSON())).toContain('2 层关系');
await act(async () => {
findButton(renderer, (node) => node.props['data-er-action'] === 'reset-relations').props.onClick();
});
expect(textContent(renderer.toJSON())).toContain('1 层关系');
});
});

View File

@@ -0,0 +1,579 @@
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Spin, Tooltip } from 'antd';
import {
ApartmentOutlined,
ArrowRightOutlined,
CompressOutlined,
DatabaseOutlined,
ExpandOutlined,
LinkOutlined,
PlusOutlined,
ReloadOutlined,
UndoOutlined,
} from '@ant-design/icons';
import dagre from 'dagre';
import ReactFlow, {
Background,
BackgroundVariant,
Controls,
MarkerType,
Position,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
type Edge,
type Node,
type NodeMouseHandler,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { t as defaultTranslate, type I18nParams } from '../i18n';
import type { BuildErDiagramGraphResult, ErDiagramEdge, ErDiagramNode } from './dataGridErDiagramModel';
import { useDataGridErDiagram } from './useDataGridErDiagram';
type DataGridMetadataTranslate = (key: string, params?: I18nParams) => string;
export interface DataGridErDiagramProps {
connections: any[];
connectionId?: string;
dbName?: string;
tableName?: string;
translate?: DataGridMetadataTranslate;
onOpenTable?: (tableName: string) => void;
}
type ErDiagramNodeData = {
node: ErDiagramNode;
selected: boolean;
expanded: boolean;
translate: DataGridMetadataTranslate;
onToggleExpanded?: (nodeId: string) => void;
onOpenTable?: (tableName: string) => void;
};
const NODE_WIDTH = 320;
const NODE_BASE_HEIGHT = 108;
const NODE_ROW_HEIGHT = 28;
const NODE_FOOTER_HEIGHT = 36;
const NODE_EXPANDED_MAX_VISIBLE_ROWS = 14;
const getRoleBadgeKey = (role: ErDiagramNode['role']): string => {
switch (role) {
case 'current':
return 'data_grid.metadata_view.er_current_badge';
case 'incoming':
return 'data_grid.metadata_view.er_referenced_by_badge';
case 'outgoing':
return 'data_grid.metadata_view.er_reference_badge';
default:
return 'data_grid.metadata_view.er_table_badge';
}
};
const getVisibleNodeColumns = (node: ErDiagramNode, expanded: boolean) => (
expanded ? node.columns : node.columns.slice(0, node.previewColumnCount)
);
const getVisibleNodeRowCount = (node: ErDiagramNode, expanded: boolean): number => Math.min(
getVisibleNodeColumns(node, expanded).length,
expanded ? NODE_EXPANDED_MAX_VISIBLE_ROWS : node.previewColumnCount,
);
const getNodeColumnViewportHeight = (node: ErDiagramNode, expanded: boolean): number => (
getVisibleNodeRowCount(node, expanded) * NODE_ROW_HEIGHT
);
const estimateNodeHeight = (node: ErDiagramNode, expanded: boolean): number => (
NODE_BASE_HEIGHT +
getNodeColumnViewportHeight(node, expanded) +
(node.hiddenColumnCount > 0 ? NODE_FOOTER_HEIGHT : 0)
);
const edgeColorByDirection: Record<ErDiagramEdge['direction'], string> = {
incoming: '#2f9e44',
outgoing: '#1971c2',
self: '#7c3aed',
};
const layoutGraph = (
graph: BuildErDiagramGraphResult,
translate: DataGridMetadataTranslate,
expandedNodeIds: Set<string>,
onToggleExpanded?: (nodeId: string) => void,
onOpenTable?: (tableName: string) => void,
): { nodes: Node<ErDiagramNodeData>[]; edges: Edge[] } => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setGraph({
rankdir: 'LR',
nodesep: 56,
ranksep: 96,
marginx: 24,
marginy: 24,
});
dagreGraph.setDefaultEdgeLabel(() => ({}));
graph.nodes.forEach((node) => {
const expanded = expandedNodeIds.has(node.id);
dagreGraph.setNode(node.id, {
width: NODE_WIDTH,
height: estimateNodeHeight(node, expanded),
});
});
graph.edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
return {
nodes: graph.nodes.map((node) => {
const expanded = expandedNodeIds.has(node.id);
const height = estimateNodeHeight(node, expanded);
const position = dagreGraph.node(node.id);
return {
id: node.id,
type: 'erTable',
position: {
x: (position?.x ?? NODE_WIDTH / 2) - (NODE_WIDTH / 2),
y: (position?.y ?? height / 2) - (height / 2),
},
data: {
node,
selected: false,
expanded,
translate,
onToggleExpanded,
onOpenTable,
},
draggable: true,
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
}),
edges: graph.edges.map((edge) => {
const color = edgeColorByDirection[edge.direction];
return {
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
type: 'smoothstep',
animated: edge.direction === 'self',
markerEnd: {
type: MarkerType.ArrowClosed,
width: 18,
height: 18,
color,
},
style: {
stroke: color,
strokeWidth: edge.direction === 'self' ? 2 : 1.7,
},
labelStyle: {
fill: 'var(--gn-fg-4)',
fontSize: 11,
fontWeight: 600,
},
labelBgPadding: [6, 3],
labelBgBorderRadius: 4,
labelBgStyle: {
fill: 'var(--gn-bg-panel-2)',
stroke: 'var(--gn-br-1)',
},
};
}),
};
};
const ErTableNode = memo(function ErTableNode({ data }: { data: ErDiagramNodeData }) {
const { node, selected, expanded, translate, onToggleExpanded, onOpenTable } = data;
const roleBadgeKey = getRoleBadgeKey(node.role);
const canOpen = !node.isCurrent && typeof onOpenTable === 'function';
const visibleColumns = getVisibleNodeColumns(node, expanded);
const footerLabel = expanded
? translate('data_grid.metadata_view.er_collapse_fields')
: translate('data_grid.metadata_view.er_expand_hidden_columns', { count: node.hiddenColumnCount });
const columnsScrollable = expanded && visibleColumns.length > NODE_EXPANDED_MAX_VISIBLE_ROWS;
return (
<div
className={`gn-er-node-card${selected ? ' is-selected' : ''}${node.isCurrent ? ' is-current' : ''}`}
data-role={node.role}
data-er-node-table={node.tableName}
>
<div className="gn-er-node-header">
<div className="gn-er-node-title">
<span className="gn-er-node-badge">{translate(roleBadgeKey)}</span>
<strong title={node.tableName}>{node.tableName}</strong>
</div>
{canOpen && (
<Tooltip title={translate('data_grid.metadata_view.er_open_table')}>
<Button
size="small"
type="text"
className="gn-er-node-open nodrag nopan"
icon={<ArrowRightOutlined />}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenTable?.(node.tableName);
}}
/>
</Tooltip>
)}
</div>
<div className="gn-er-node-stats">
<Tooltip title={translate('data_grid.metadata_view.er_referenced_by_badge')}>
<span>
<ApartmentOutlined />
{node.incomingCount}
</span>
</Tooltip>
<Tooltip title={translate('data_grid.metadata_view.er_reference_badge')}>
<span>
<LinkOutlined />
{node.outgoingCount}
</span>
</Tooltip>
</div>
<div
className={`gn-er-node-columns nodrag nopan${columnsScrollable ? ' is-scrollable' : ''}`}
style={{ '--er-node-columns-max-height': `${getNodeColumnViewportHeight(node, expanded)}px` } as React.CSSProperties}
>
{visibleColumns.map((column) => (
<div
key={column.name}
className={`gn-er-node-column${column.isRelationField ? ' is-relation' : ''}`}
data-er-node-column={column.name}
>
<div className="gn-er-node-column-name">
{column.isPrimary && <em className="is-pk">PK</em>}
{!column.isPrimary && column.isForeign && <em className="is-fk">FK</em>}
<code title={column.comment || column.name}>{column.name}</code>
</div>
<span className="gn-er-node-column-type">{column.type || '-'}</span>
</div>
))}
</div>
{node.hiddenColumnCount > 0 && (
<div className="gn-er-node-footer">
<button
type="button"
className="gn-er-node-footer-toggle nodrag nopan"
data-er-node-toggle={node.id}
title={footerLabel}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onToggleExpanded?.(node.id);
}}
>
{footerLabel}
</button>
</div>
)}
</div>
);
});
const DataGridErDiagramCanvasInner: React.FC<{
graph: BuildErDiagramGraphResult;
translate: DataGridMetadataTranslate;
expandedNodeIds: Set<string>;
onToggleNodeExpanded: (nodeId: string) => void;
onOpenTable?: (tableName: string) => void;
}> = ({
graph,
translate,
expandedNodeIds,
onToggleNodeExpanded,
onOpenTable,
}) => {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(graph.nodes[0]?.id || null);
const { fitView } = useReactFlow();
useEffect(() => {
setSelectedNodeId(graph.nodes[0]?.id || null);
}, [graph.nodes]);
const layout = useMemo(
() => layoutGraph(graph, translate, expandedNodeIds, onToggleNodeExpanded, onOpenTable),
[expandedNodeIds, graph, onOpenTable, onToggleNodeExpanded, translate],
);
const [nodes, setNodes, onNodesChange] = useNodesState(layout.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layout.edges);
useEffect(() => {
setNodes(
layout.nodes.map((node) => ({
...node,
data: {
...node.data,
selected: node.id === selectedNodeId,
},
})),
);
}, [layout.nodes, setNodes]);
useEffect(() => {
setEdges(layout.edges);
}, [layout.edges, setEdges]);
useEffect(() => {
setNodes((currentNodes) => currentNodes.map((node) => ({
...node,
data: {
...node.data,
selected: node.id === selectedNodeId,
},
})));
}, [selectedNodeId, setNodes]);
useEffect(() => {
const timer = globalThis.setTimeout(() => {
void fitView({ padding: 0.18, duration: 220 });
}, 0);
return () => {
globalThis.clearTimeout(timer);
};
}, [fitView, layout.edges, layout.nodes]);
const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => {
setSelectedNodeId(node.id);
}, []);
const handleNodeDoubleClick: NodeMouseHandler = useCallback((_event, node) => {
const nodeData = node.data as ErDiagramNodeData | undefined;
if (nodeData?.node?.isCurrent) {
return;
}
nodeData?.onOpenTable?.(nodeData.node.tableName);
}, []);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={{ erTable: ErTableNode }}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onPaneClick={() => setSelectedNodeId(null)}
minZoom={0.35}
maxZoom={1.8}
fitView
fitViewOptions={{ padding: 0.18 }}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1.2} />
<Controls showInteractive={false} />
</ReactFlow>
);
};
const DataGridErDiagramCanvas: React.FC<{
graph: BuildErDiagramGraphResult;
translate: DataGridMetadataTranslate;
expandedNodeIds: Set<string>;
onToggleNodeExpanded: (nodeId: string) => void;
onOpenTable?: (tableName: string) => void;
}> = (props) => (
<ReactFlowProvider>
<DataGridErDiagramCanvasInner {...props} />
</ReactFlowProvider>
);
const DataGridErDiagram: React.FC<DataGridErDiagramProps> = ({
connections,
connectionId,
dbName,
tableName,
translate = defaultTranslate,
onOpenTable,
}) => {
const [relationDepth, setRelationDepth] = useState(1);
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
const {
graph,
loading,
reloading,
error,
partial,
reload,
canExpandRelations,
} = useDataGridErDiagram({
connections,
connectionId,
dbName,
tableName,
relationDepth,
});
useEffect(() => {
setRelationDepth(1);
setExpandedNodeIds(new Set());
}, [connectionId, dbName, tableName]);
const expandableNodeIds = useMemo(
() => graph?.nodes.filter((node) => node.hiddenColumnCount > 0).map((node) => node.id) || [],
[graph],
);
useEffect(() => {
const nextExpandableIds = new Set(expandableNodeIds);
setExpandedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => nextExpandableIds.has(nodeId))));
}, [expandableNodeIds]);
const expandedFieldCount = useMemo(
() => expandableNodeIds.filter((nodeId) => expandedNodeIds.has(nodeId)).length,
[expandableNodeIds, expandedNodeIds],
);
const allFieldsExpanded = expandableNodeIds.length > 0 && expandedFieldCount === expandableNodeIds.length;
const handleToggleNodeExpanded = useCallback((nodeId: string) => {
setExpandedNodeIds((current) => {
const next = new Set(current);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
}, []);
const handleExpandAllFields = useCallback(() => {
setExpandedNodeIds(new Set(expandableNodeIds));
}, [expandableNodeIds]);
const handleCollapseAllFields = useCallback(() => {
setExpandedNodeIds(new Set());
}, []);
const handleExpandRelations = useCallback(() => {
setRelationDepth((currentDepth) => currentDepth + 1);
}, []);
const handleResetRelationDepth = useCallback(() => {
setRelationDepth(1);
}, []);
return (
<div className="gn-v2-data-grid-er-diagram">
<div className="gn-v2-data-grid-er-toolbar">
<div>
<span>{translate('data_grid.metadata_view.er_table_badge')}</span>
<strong>{tableName || translate('data_grid.table_fallback.query_result')}</strong>
</div>
<div>
<div className="gn-v2-data-grid-er-summary">
<span className="gn-v2-data-grid-er-chip">
<DatabaseOutlined />
{translate('data_grid.metadata_view.er_related_table_count', { count: graph?.relatedTableCount ?? 0 })}
</span>
<span className="gn-v2-data-grid-er-chip">
<ApartmentOutlined />
{translate('data_grid.metadata_view.er_relation_count', { count: graph?.relationCount ?? 0 })}
</span>
<span className="gn-v2-data-grid-er-chip">
<LinkOutlined />
{translate('data_grid.metadata_view.er_relation_depth', { count: relationDepth })}
</span>
</div>
<div className="gn-v2-data-grid-er-actions">
<Button
size="small"
icon={<PlusOutlined />}
data-er-action="expand-relations"
onClick={handleExpandRelations}
disabled={!graph || loading || reloading || !canExpandRelations}
>
{translate('data_grid.metadata_view.er_expand_relations')}
</Button>
<Button
size="small"
icon={<UndoOutlined />}
data-er-action="reset-relations"
onClick={handleResetRelationDepth}
disabled={relationDepth <= 1}
>
{translate('data_grid.metadata_view.er_reset_relations')}
</Button>
<Button
size="small"
icon={<ExpandOutlined />}
data-er-action="expand-fields"
onClick={handleExpandAllFields}
disabled={expandableNodeIds.length === 0 || allFieldsExpanded}
>
{translate('data_grid.metadata_view.er_expand_fields')}
</Button>
<Button
size="small"
icon={<CompressOutlined />}
data-er-action="collapse-fields"
onClick={handleCollapseAllFields}
disabled={expandedFieldCount === 0}
>
{translate('data_grid.metadata_view.er_collapse_fields')}
</Button>
<Button
size="small"
icon={<ReloadOutlined />}
data-er-action="refresh"
onClick={reload}
loading={reloading}
>
{translate('common.refresh')}
</Button>
</div>
</div>
</div>
{partial && !error && (
<div className="gn-v2-data-grid-er-alert">
<Alert
type="warning"
showIcon
message={translate('data_grid.metadata_view.er_partial_warning')}
/>
</div>
)}
{error ? (
<div className="gn-v2-data-grid-er-alert">
<Alert type="error" showIcon message={error} />
</div>
) : null}
<div className="gn-v2-data-grid-er-canvas">
<Spin spinning={loading || reloading}>
{graph ? (
<>
{graph.isEmpty && (
<div className="gn-v2-data-grid-er-empty">
{translate('data_grid.metadata_view.er_empty')}
</div>
)}
<DataGridErDiagramCanvas
graph={graph}
translate={translate}
expandedNodeIds={expandedNodeIds}
onToggleNodeExpanded={handleToggleNodeExpanded}
onOpenTable={onOpenTable}
/>
</>
) : (
<div className="gn-v2-data-grid-er-placeholder" />
)}
</Spin>
</div>
</div>
);
};
export default DataGridErDiagram;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { CopyOutlined, EditOutlined, UndoOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
import { t } from '../i18n';
interface CellContextMenuState {
visible: boolean;
@@ -21,6 +22,7 @@ interface DataGridLegacyCellContextMenuProps {
copiedCellPatchAvailable: boolean;
canUndoCellChange: boolean;
supportsCopyInsert: boolean;
translate?: (key: string, params?: Record<string, unknown>) => string;
onClose: () => void;
onCopyFieldName: () => void;
onCopyRowData: () => void;
@@ -55,6 +57,10 @@ const separatorStyle = (darkMode: boolean): React.CSSProperties => ({
margin: '4px 0',
});
const fallbackTranslate = (key: string, params?: Record<string, unknown>) => (
t(key, params as Parameters<typeof t>[1])
);
const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps> = ({
visible,
darkMode,
@@ -66,6 +72,7 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
copiedCellPatchAvailable,
canUndoCellChange,
supportsCopyInsert,
translate = fallbackTranslate,
onClose,
onCopyFieldName,
onCopyRowData,
@@ -130,7 +137,7 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onCopyFieldName}>
<CopyOutlined style={{ marginRight: 8 }} />
{translate('data_grid.context_menu.copy_field_name')}
</div>
<div style={separatorStyle(darkMode)} />
{canModifyData && (
@@ -149,18 +156,18 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
}}
>
<UndoOutlined style={{ marginRight: 8 }} />
{translate('data_grid.context_menu.undo_cell_change')}
</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onSetNull}>
NULL
{translate('data_grid.batch_fill.set_null')}
</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onEditRow}>
<EditOutlined style={{ marginRight: 8 }} />
{translate('data_grid.context_menu.edit_row')}
</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowForPaste)}>
<CopyOutlined style={{ marginRight: 8 }} />
{translate('data_grid.context_menu.copy_row_as_new')}
</div>
<div
style={{
@@ -177,7 +184,9 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
}}
>
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
{canPasteRows ? `粘贴为新增行 (${copiedRowsForPasteLength})` : '粘贴为新增行'}
{canPasteRows
? translate('data_grid.context_menu.paste_row_as_new_count', { count: copiedRowsForPasteLength })
: translate('data_grid.context_menu.paste_row_as_new')}
</div>
<div
style={{
@@ -191,7 +200,7 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
}}
>
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
({selectedRowKeysLength})
{translate('data_grid.context_menu.fill_to_selected_rows', { count: selectedRowKeysLength })}
</div>
<div
style={{
@@ -205,30 +214,30 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
}}
>
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
{translate('data_grid.context_menu.paste_copied_columns')}
</div>
<div style={separatorStyle(darkMode)} />
</>
)}
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowData)}>
<CopyOutlined style={{ marginRight: 8 }} />
{translate('data_grid.context_menu.copy_row_data')}
</div>
{supportsCopyInsert && (
<>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyInsert)}> INSERT</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyUpdate)}> UPDATE</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyDelete)}> DELETE</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyInsert)}>{translate('data_grid.context_menu.copy_as_insert')}</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyUpdate)}>{translate('data_grid.context_menu.copy_as_update')}</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyDelete)}>{translate('data_grid.context_menu.copy_as_delete')}</div>
</>
)}
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyJson)}> JSON</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyCsv)}> CSV</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyMarkdown)}> Markdown</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyJson)}>{translate('data_grid.context_menu.copy_as_json')}</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyCsv)}>{translate('data_grid.context_menu.copy_as_csv')}</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyMarkdown)}>{translate('data_grid.context_menu.copy_as_markdown')}</div>
<div style={separatorStyle(darkMode)} />
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportCsv)}> CSV</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportXlsx)}> Excel</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportJson)}> JSON</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportHtml)}> HTML</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportCsv)}>{translate('data_grid.context_menu.export_as_csv')}</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportXlsx)}>{translate('data_grid.context_menu.export_as_excel')}</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportJson)}>{translate('data_grid.context_menu.export_as_json')}</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onExportHtml)}>{translate('data_grid.context_menu.export_as_html')}</div>
</div>,
document.body,
);

View File

@@ -1,5 +1,6 @@
import Modal from './common/ResizableDraggableModal';
import React from 'react';
import { Button, Checkbox, DatePicker, Form, Input, Modal, TimePicker } from 'antd';
import { Button, Checkbox, DatePicker, Form, Input, TimePicker } from 'antd';
import dayjs from 'dayjs';
import { CopyOutlined } from '@ant-design/icons';
import Editor from './MonacoEditor';
@@ -8,6 +9,9 @@ import {
getTemporalPickerType,
type TemporalPickerType,
} from './dataGridTemporal';
import { t as defaultTranslate, type I18nParams } from '../i18n';
export type DataGridModalsTranslate = (key: string, params?: I18nParams) => string;
type ColumnMeta = {
type: string;
@@ -28,6 +32,7 @@ export interface DataGridRowEditorField {
export interface DataGridModalsProps {
tableName?: string;
darkMode: boolean;
translate?: DataGridModalsTranslate;
displayColumnNames: string[];
rowEditorOpen: boolean;
rowEditorRowKey: string;
@@ -68,6 +73,7 @@ export interface DataGridModalsProps {
const DataGridModals: React.FC<DataGridModalsProps> = ({
tableName,
darkMode,
translate = defaultTranslate,
rowEditorOpen,
rowEditorRowKey,
rowEditorForm,
@@ -105,15 +111,15 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
}) => (
<>
<Modal
title="编辑行"
title={translate('data_grid.row_editor.title')}
open={rowEditorOpen}
onCancel={onCloseRowEditor}
width={980}
destroyOnHidden
maskClosable={false}
footer={[
<Button key="cancel" onClick={onCloseRowEditor}></Button>,
<Button key="ok" type="primary" onClick={onApplyRowEditor}></Button>,
<Button key="cancel" onClick={onCloseRowEditor}>{translate('common.cancel')}</Button>,
<Button key="ok" type="primary" onClick={onApplyRowEditor}>{translate('data_grid.action.apply')}</Button>,
]}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12, display: 'flex', justifyContent: 'space-between', gap: 8 }}>
@@ -168,7 +174,7 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
<Button
size="small"
onClick={() => onOpenRowEditorFieldEditor(field.columnName)}
title="弹窗编辑"
title={translate('data_grid.row_editor.popup_edit')}
disabled={!field.isWritable}
>
...
@@ -181,16 +187,20 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
</Modal>
<Modal
title={cellEditorMeta ? `编辑单元格:${cellEditorMeta.title}` : '编辑单元格'}
title={
cellEditorMeta
? translate('data_grid.cell_editor.title_with_column', { column: cellEditorMeta.title })
: translate('data_grid.cell_editor.title')
}
open={cellEditorOpen}
onCancel={onCloseCellEditor}
destroyOnHidden
width={960}
maskClosable={false}
footer={[
<Button key="format" onClick={onFormatJsonInEditor} disabled={!cellEditorIsJson}> JSON</Button>,
<Button key="cancel" onClick={onCloseCellEditor}></Button>,
<Button key="ok" type="primary" onClick={onSaveCellEditor}></Button>,
<Button key="format" onClick={onFormatJsonInEditor} disabled={!cellEditorIsJson}>{translate('data_grid.json_editor.format')}</Button>,
<Button key="cancel" onClick={onCloseCellEditor}>{translate('common.cancel')}</Button>,
<Button key="ok" type="primary" onClick={onSaveCellEditor}>{translate('common.save')}</Button>,
]}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
@@ -216,22 +226,25 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
</Modal>
<Modal
title={`批量填充 (${selectedCellsSize} 个单元格)`}
title={translate('data_grid.batch_fill.title', { count: selectedCellsSize })}
open={batchEditModalOpen}
onCancel={onCloseBatchEditModal}
onOk={onApplyBatchFill}
width={500}
footer={[
<Button key="cancel" onClick={onCloseBatchEditModal}>{translate('common.cancel')}</Button>,
<Button key="ok" type="primary" onClick={onApplyBatchFill}>{translate('data_grid.action.apply')}</Button>,
]}
>
<div style={{ marginBottom: 16 }}>
<Checkbox checked={batchEditSetNull} onChange={(event) => onBatchEditSetNullChange(event.target.checked)}>
NULL
{translate('data_grid.batch_fill.set_null')}
</Checkbox>
</div>
{!batchEditSetNull && (
<Input.TextArea
value={batchEditValue}
onChange={(event) => onBatchEditValueChange(event.target.value)}
placeholder="输入要填充的值"
placeholder={translate('data_grid.batch_fill.value_placeholder')}
autoSize={{ minRows: 3, maxRows: 10 }}
autoFocus
/>
@@ -239,20 +252,20 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
</Modal>
<Modal
title="编辑 JSON 结果集"
title={translate('data_grid.json_editor.title')}
open={jsonEditorOpen}
onCancel={onCloseJsonEditor}
destroyOnHidden
width={980}
maskClosable={false}
footer={[
<Button key="format" onClick={onFormatJsonEditor}> JSON</Button>,
<Button key="cancel" onClick={onCloseJsonEditor}></Button>,
<Button key="ok" type="primary" onClick={onApplyJsonEditor}></Button>,
<Button key="format" onClick={onFormatJsonEditor}>{translate('data_grid.json_editor.format')}</Button>,
<Button key="cancel" onClick={onCloseJsonEditor}>{translate('common.cancel')}</Button>,
<Button key="ok" type="primary" onClick={onApplyJsonEditor}>{translate('data_grid.json_editor.apply_changes')}</Button>,
]}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
JSON
{translate('data_grid.json_editor.description')}
</div>
{jsonEditorOpen && (
<Editor
@@ -282,10 +295,10 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
width={960}
footer={[
<Button key="copy" icon={<CopyOutlined />} onClick={onCopyDdl} disabled={!ddlText.trim()}>
DDL
{translate('data_grid.ddl.copy')}
</Button>,
<Button key="close" type="primary" onClick={onCloseDdlModal}>
{translate('common.close')}
</Button>,
]}
>
@@ -294,7 +307,7 @@ const DataGridModals: React.FC<DataGridModalsProps> = ({
height="56vh"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={ddlLoading ? '正在加载 DDL...' : ddlText}
value={ddlLoading ? translate('data_grid.ddl.loading') : ddlText}
options={{
readOnly: true,
minimap: { enabled: false },

View File

@@ -1,6 +1,9 @@
import React from 'react';
import { Button, Input, Tooltip } from 'antd';
import { LeftOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
export type DataGridPageFindTranslate = (key: string, params?: I18nParams) => string;
export interface DataGridPageFindProps {
isV2Ui: boolean;
@@ -17,6 +20,7 @@ export interface DataGridPageFindProps {
onCancel: () => void;
onNavigatePrevious: () => void;
onNavigateNext: () => void;
translate?: DataGridPageFindTranslate;
}
const DataGridPageFind: React.FC<DataGridPageFindProps> = ({
@@ -34,67 +38,75 @@ const DataGridPageFind: React.FC<DataGridPageFindProps> = ({
onCancel,
onNavigatePrevious,
onNavigateNext,
}) => (
<Tooltip title="仅查找当前页已加载数据,不改变 WHERE 条件">
<div
data-grid-page-find="true"
className={isV2Ui ? 'gn-v2-data-grid-page-find' : undefined}
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'nowrap', height: 32 }}
>
<Input
className={isV2Ui ? 'gn-v2-data-grid-page-find-input' : undefined}
{...inputProps}
allowClear
size="small"
variant="borderless"
prefix={<SearchOutlined />}
placeholder="当前页查找..."
value={pageFindText}
onChange={(event) => onPageFindTextChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onCancel();
}
}}
style={isV2Ui ? undefined : { width: 168, height: 32 }}
/>
<Button
data-grid-page-find-prev="true"
className={isV2Ui ? 'gn-v2-data-grid-page-find-prev' : undefined}
size="small"
icon={<LeftOutlined />}
disabled={!hasMatches}
onClick={onNavigatePrevious}
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
/>
<Button
data-grid-page-find-next="true"
className={isV2Ui ? 'gn-v2-data-grid-page-find-next' : undefined}
size="small"
icon={<RightOutlined />}
disabled={!hasMatches}
onClick={onNavigateNext}
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
/>
{normalizedPageFindText && (
<span
aria-live="polite"
style={isV2Ui ? undefined : {
fontSize: 12,
color: darkMode ? '#999' : '#666',
lineHeight: 1.4,
whiteSpace: 'nowrap',
textAlign: 'left',
flex: '0 1 auto',
translate = defaultTranslate,
}) => {
const summaryText = translate('data_grid.page_find.summary', {
occurrences: occurrenceCount,
cells: matchedCellCount,
});
return (
<Tooltip title={translate('data_grid.page_find.tooltip')}>
<div
data-grid-page-find="true"
className={isV2Ui ? 'gn-v2-data-grid-page-find' : undefined}
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'nowrap', height: 32 }}
>
<Input
className={isV2Ui ? 'gn-v2-data-grid-page-find-input' : undefined}
{...inputProps}
allowClear
size="small"
variant="borderless"
prefix={<SearchOutlined />}
placeholder={translate('data_grid.page_find.placeholder')}
value={pageFindText}
onChange={(event) => onPageFindTextChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onCancel();
}
}}
>
{hasMatches ? `${activePageFindPosition} / ${matchCount} · ` : ''} {occurrenceCount} / {matchedCellCount}
</span>
)}
</div>
</Tooltip>
);
style={isV2Ui ? undefined : { width: 168, height: 32 }}
/>
<Button
data-grid-page-find-prev="true"
className={isV2Ui ? 'gn-v2-data-grid-page-find-prev' : undefined}
size="small"
icon={<LeftOutlined />}
disabled={!hasMatches}
onClick={onNavigatePrevious}
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
/>
<Button
data-grid-page-find-next="true"
className={isV2Ui ? 'gn-v2-data-grid-page-find-next' : undefined}
size="small"
icon={<RightOutlined />}
disabled={!hasMatches}
onClick={onNavigateNext}
style={isV2Ui ? undefined : { height: 32, minWidth: 32, paddingInline: 8 }}
/>
{normalizedPageFindText && (
<span
aria-live="polite"
style={isV2Ui ? undefined : {
fontSize: 12,
color: darkMode ? '#999' : '#666',
lineHeight: 1.4,
whiteSpace: 'nowrap',
textAlign: 'left',
flex: '0 1 auto',
}}
>
{hasMatches ? `${activePageFindPosition} / ${matchCount} · ` : ''}{summaryText}
</span>
)}
</div>
</Tooltip>
);
};
export default DataGridPageFind;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Button, InputNumber, Pagination, Select } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
interface DataGridPaginationState {
current: number;
@@ -13,6 +14,8 @@ interface DataGridPaginationState {
totalCountCancelled?: boolean;
}
export type DataGridPaginationTranslate = (key: string, params?: I18nParams) => string;
export interface DataGridPaginationBarProps {
isV2Ui: boolean;
pagination?: DataGridPaginationState;
@@ -20,10 +23,13 @@ export interface DataGridPaginationBarProps {
paginationSummaryText: string;
paginationControlTotal: number;
paginationTotalPages: number;
paginationPageText: string;
paginationPageSizeOptions: string[];
showKnownPageCount: boolean;
onPageChange?: (page: number, size: number) => void;
onPageSizeChange: (value: string) => void;
onV2PageStep: (direction: 'previous' | 'next') => void;
translate?: DataGridPaginationTranslate;
}
const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
@@ -33,12 +39,16 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
paginationSummaryText,
paginationControlTotal,
paginationTotalPages,
paginationPageText,
paginationPageSizeOptions,
showKnownPageCount,
onPageChange,
onPageSizeChange,
onV2PageStep,
translate = defaultTranslate,
}) => {
const [jumpPage, setJumpPage] = React.useState<number | null>(pagination?.current ?? null);
const showSequentialPagination = !showKnownPageCount;
React.useEffect(() => {
setJumpPage(pagination?.current ?? null);
@@ -48,9 +58,11 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
return null;
}
const maxJumpPage = Math.max(1, paginationTotalPages);
const maxJumpPage = showKnownPageCount ? Math.max(1, paginationTotalPages) : null;
const normalizedJumpPage = Number.isFinite(Number(jumpPage)) && Number(jumpPage) > 0
? Math.min(maxJumpPage, Math.max(1, Math.trunc(Number(jumpPage))))
? (maxJumpPage !== null
? Math.min(maxJumpPage, Math.max(1, Math.trunc(Number(jumpPage))))
: Math.max(1, Math.trunc(Number(jumpPage))))
: null;
const jumpDisabled = !onPageChange || normalizedJumpPage === null || normalizedJumpPage === pagination.current;
const submitJumpPage = () => {
@@ -60,18 +72,18 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
};
const jumpPageControl = (
<div className="data-grid-pagination-jump" data-grid-pagination-jump="true">
<span className="data-grid-pagination-jump-label"></span>
<span className="data-grid-pagination-jump-label">{translate('data_grid.pagination.jump_label')}</span>
<InputNumber
size="small"
min={1}
max={maxJumpPage}
max={maxJumpPage ?? undefined}
precision={0}
controls={false}
value={jumpPage}
onChange={(value) => setJumpPage(typeof value === 'number' && Number.isFinite(value) ? value : null)}
onPressEnter={submitJumpPage}
className="data-grid-pagination-jump-input"
aria-label="跳转页码"
aria-label={translate('data_grid.pagination.jump_aria')}
disabled={!onPageChange}
/>
<Button
@@ -80,10 +92,35 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
disabled={jumpDisabled}
onClick={submitJumpPage}
>
{translate('data_grid.pagination.jump_action')}
</Button>
</div>
);
const sequentialPaginationControl = (
<div
className="data-grid-pagination-sequential"
data-grid-pagination-sequential="true"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<Button
data-grid-pagination-prev="true"
size="small"
icon={<LeftOutlined />}
disabled={!onPageChange || pagination.current <= 1}
onClick={() => onV2PageStep('previous')}
/>
<div className="data-grid-pagination-page-chip" data-grid-page-chip="true">
<span>{paginationPageText}</span>
</div>
<Button
data-grid-pagination-next="true"
size="small"
icon={<RightOutlined />}
disabled={!onPageChange || pagination.current >= paginationTotalPages}
onClick={() => onV2PageStep('next')}
/>
</div>
);
return (
<div
@@ -103,9 +140,15 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
onClick={() => onV2PageStep('previous')}
/>
<div className="data-grid-pagination-page-chip" data-grid-v2-page-chip="true">
<strong>{pagination.current}</strong>
<span>/</span>
<span>{paginationTotalPages}</span>
{showKnownPageCount ? (
<>
<strong>{pagination.current}</strong>
<span>/</span>
<span>{paginationTotalPages}</span>
</>
) : (
<span>{paginationPageText}</span>
)}
</div>
<Button
data-grid-v2-pagination-next="true"
@@ -120,44 +163,46 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
popupMatchSelectWidth={false}
value={String(pagination.pageSize)}
onChange={onPageSizeChange}
options={paginationPageSizeOptions.map((value) => ({ value, label: `${value}/页` }))}
options={paginationPageSizeOptions.map((value) => ({ value, label: translate('data_grid.pagination.page_size_option', { count: value }) }))}
className="data-grid-pagination-size-select"
aria-label="每页条数"
aria-label={translate('data_grid.pagination.page_size_aria')}
/>
</div>
) : (
<div className="data-grid-pagination-shell">
<div className="data-grid-pagination-summary" aria-live="polite">
<span className="data-grid-pagination-kicker"></span>
<span className="data-grid-pagination-kicker">{translate('data_grid.pagination.result_set')}</span>
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
</div>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={paginationControlTotal}
showSizeChanger={false}
onChange={onPageChange}
showTitle={false}
size="small"
itemRender={(_page, type, originalElement) => {
if (type === 'prev') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><LeftOutlined /></span>;
}
if (type === 'next') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><RightOutlined /></span>;
}
return originalElement;
}}
/>
{showSequentialPagination ? sequentialPaginationControl : (
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={paginationControlTotal}
showSizeChanger={false}
onChange={onPageChange}
showTitle={false}
size="small"
itemRender={(_page, type, originalElement) => {
if (type === 'prev') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><LeftOutlined /></span>;
}
if (type === 'next') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><RightOutlined /></span>;
}
return originalElement;
}}
/>
)}
{jumpPageControl}
<Select
size="small"
popupMatchSelectWidth={false}
value={String(pagination.pageSize)}
onChange={onPageSizeChange}
options={paginationPageSizeOptions.map((value) => ({ value, label: `${value} 条 / 页` }))}
options={paginationPageSizeOptions.map((value) => ({ value, label: translate('data_grid.pagination.page_size_option', { count: value }) }))}
className="data-grid-pagination-size-select"
aria-label="每页条数"
aria-label={translate('data_grid.pagination.page_size_aria')}
/>
</div>
)}

View File

@@ -1,11 +1,14 @@
import React from 'react';
import { Button } from 'antd';
import Editor from './MonacoEditor';
import { t as defaultTranslate, type I18nParams } from '../i18n';
type ColumnMeta = {
type?: string;
};
export type DataGridPreviewPanelTranslate = (key: string, params?: I18nParams) => string;
interface DataGridPreviewPanelProps {
visible: boolean;
isTableSurfaceActive: boolean;
@@ -16,6 +19,7 @@ interface DataGridPreviewPanelProps {
dataPanelValue: string;
columnMetaMap: Record<string, ColumnMeta>;
columnMetaMapByLowerName: Record<string, ColumnMeta>;
translate?: DataGridPreviewPanelTranslate;
onFormatJson: () => void;
onSave: () => void;
onValueChange: (value: string) => void;
@@ -33,6 +37,7 @@ const DataGridPreviewPanel: React.FC<DataGridPreviewPanelProps> = ({
dataPanelValue,
columnMetaMap,
columnMetaMapByLowerName,
translate = defaultTranslate,
onFormatJson,
onSave,
onValueChange,
@@ -71,15 +76,15 @@ const DataGridPreviewPanel: React.FC<DataGridPreviewPanelProps> = ({
}}
>
<span style={{ color: darkMode ? '#aaa' : '#666', fontWeight: 500 }}>
{focusedCellInfo ? focusedCellInfo.dataIndex : '点击单元格查看数据'}
{focusedCellInfo ? focusedCellInfo.dataIndex : translate('data_grid.preview_panel.no_cell_title')}
</span>
{meta?.type ? <span style={{ color: '#888', fontSize: 11 }}>({meta.type})</span> : null}
<div style={{ flex: 1 }} />
{dataPanelIsJson && (
<Button size="small" onClick={onFormatJson}> JSON</Button>
<Button size="small" onClick={onFormatJson}>{translate('data_grid.json_editor.format')}</Button>
)}
{focusedCellWritable && (
<Button size="small" type="primary" onClick={onSave}></Button>
<Button size="small" type="primary" onClick={onSave}>{translate('common.save')}</Button>
)}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
@@ -120,7 +125,7 @@ const DataGridPreviewPanel: React.FC<DataGridPreviewPanelProps> = ({
fontSize: 13,
}}
>
{translate('data_grid.preview_panel.no_cell_description')}
</div>
)}
</div>

View File

@@ -1,12 +1,16 @@
import React from 'react';
import { Button } from 'antd';
import Editor from './MonacoEditor';
import { t as defaultTranslate, type I18nParams } from '../i18n';
export type DataGridRecordViewTranslate = (key: string, params?: I18nParams) => string;
interface DataGridJsonViewProps {
darkMode: boolean;
rowCount: number;
canModifyData: boolean;
jsonViewText: string;
translate?: DataGridRecordViewTranslate;
onOpenJsonEditor: () => void;
}
@@ -15,16 +19,19 @@ export const DataGridJsonView: React.FC<DataGridJsonViewProps> = ({
rowCount,
canModifyData,
jsonViewText,
translate = defaultTranslate,
onOpenJsonEditor,
}) => (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 10px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
{rowCount === 0 ? '当前结果集无数据' : `当前结果集 ${rowCount} 条记录`}
{rowCount === 0
? translate('data_grid.record_view.empty')
: translate('data_grid.record_view.json_record_count', { count: rowCount })}
</span>
{canModifyData && (
<Button size="small" type="primary" onClick={onOpenJsonEditor} disabled={rowCount === 0}>
JSON
{translate('data_grid.record_view.edit_json')}
</Button>
)}
</div>
@@ -56,6 +63,11 @@ interface DataGridTextViewProps {
canModifyData: boolean;
currentTextRow: Record<string, any> | null;
displayOutputColumnNames: string[];
columnMetaMap?: Record<string, { type?: string; comment?: string }>;
columnMetaMapByLowerName?: Record<string, { type?: string; comment?: string }>;
showColumnType?: boolean;
showColumnComment?: boolean;
translate?: DataGridRecordViewTranslate;
onPrev: () => void;
onNext: () => void;
onEditCurrent: () => void;
@@ -69,43 +81,72 @@ export const DataGridTextView: React.FC<DataGridTextViewProps> = ({
canModifyData,
currentTextRow,
displayOutputColumnNames,
columnMetaMap = {},
columnMetaMapByLowerName = {},
showColumnType = true,
showColumnComment = true,
translate = defaultTranslate,
onPrev,
onNext,
onEditCurrent,
formatTextViewValue,
}) => (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<Button size="small" onClick={onPrev} disabled={rowCount === 0 || textRecordIndex <= 0}>
</Button>
<Button size="small" onClick={onNext} disabled={rowCount === 0 || textRecordIndex >= rowCount - 1}>
</Button>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
{rowCount === 0 ? '当前结果集无数据' : `记录 ${textRecordIndex + 1} / ${rowCount}`}
</span>
{canModifyData && (
<Button size="small" type="primary" onClick={onEditCurrent} disabled={rowCount === 0}>
}) => {
const metaTextColor = darkMode ? 'rgba(255,255,255,0.52)' : 'rgba(0,0,0,0.48)';
return (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<Button size="small" onClick={onPrev} disabled={rowCount === 0 || textRecordIndex <= 0}>
{translate('data_grid.record_view.previous')}
</Button>
)}
</div>
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
{currentTextRow ? displayOutputColumnNames.map((col) => (
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
{col} :
<Button size="small" onClick={onNext} disabled={rowCount === 0 || textRecordIndex >= rowCount - 1}>
{translate('data_grid.record_view.next')}
</Button>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
{rowCount === 0
? translate('data_grid.record_view.empty')
: translate('data_grid.record_view.record_position', { current: textRecordIndex + 1, total: rowCount })}
</span>
{canModifyData && (
<Button size="small" type="primary" onClick={onEditCurrent} disabled={rowCount === 0}>
{translate('data_grid.record_view.edit_current')}
</Button>
)}
</div>
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
{currentTextRow ? displayOutputColumnNames.map((col) => {
const columnMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()];
const columnType = String(columnMeta?.type || '').trim();
const columnComment = String(columnMeta?.comment || '').trim();
return (
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
<div style={{ wordBreak: 'break-all' }}>
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)' }}>
{col} :
</div>
{showColumnType && columnType && (
<div style={{ marginTop: 3, fontSize: 11, lineHeight: 1.35, color: metaTextColor }}>
{translate('data_grid.column.type_tooltip', { type: columnType })}
</div>
)}
{showColumnComment && columnComment && (
<div style={{ marginTop: 2, fontSize: 11, lineHeight: 1.35, color: metaTextColor }}>
{translate('data_grid.column.comment_tooltip', { comment: columnComment })}
</div>
)}
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)' }}>
{formatTextViewValue(currentTextRow[col], col)}
</div>
</div>
);
}) : (
<div style={{ fontSize: 12, color: darkMode ? '#999' : '#666', paddingTop: 4 }}>
{translate('data_grid.record_view.empty')}
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)' }}>
{formatTextViewValue(currentTextRow[col], col)}
</div>
</div>
)) : (
<div style={{ fontSize: 12, color: darkMode ? '#999' : '#666', paddingTop: 4 }}>
</div>
)}
)}
</div>
</div>
</div>
);
);
};

View File

@@ -1,13 +1,17 @@
import React from 'react';
import { Segmented } from 'antd';
import { t as defaultTranslate, type I18nParams } from '../i18n';
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er' | 'sqlLog';
export type DataGridResultViewTranslate = (key: string, params?: I18nParams) => string;
export interface DataGridResultViewSwitcherProps {
isV2Ui: boolean;
darkMode: boolean;
viewMode: GridViewMode;
onViewModeChange: (nextMode: GridViewMode) => void;
translate?: DataGridResultViewTranslate;
}
const DataGridResultViewSwitcher: React.FC<DataGridResultViewSwitcherProps> = ({
@@ -15,20 +19,21 @@ const DataGridResultViewSwitcher: React.FC<DataGridResultViewSwitcherProps> = ({
darkMode,
viewMode,
onViewModeChange,
translate = defaultTranslate,
}) => (
<div
data-grid-view-switcher="true"
className={isV2Ui ? 'gn-v2-data-grid-result-switcher' : undefined}
style={isV2Ui ? undefined : { display: 'flex', alignItems: 'center', gap: 8 }}
>
<span style={isV2Ui ? undefined : { fontSize: 12, color: darkMode ? '#999' : '#666' }}></span>
<span style={isV2Ui ? undefined : { fontSize: 12, color: darkMode ? '#999' : '#666' }}>{translate('data_grid.view.result_view')}</span>
<Segmented
size="small"
value={viewMode === 'json' || viewMode === 'text' ? viewMode : 'table'}
options={[
{ label: '表格', value: 'table' },
{ label: translate('data_grid.view.table'), value: 'table' },
{ label: 'JSON', value: 'json' },
{ label: '文本', value: 'text' },
{ label: translate('data_grid.view.text'), value: 'text' },
]}
onChange={(value) => onViewModeChange(String(value) as GridViewMode)}
/>

View File

@@ -2,14 +2,18 @@ import React from 'react';
import { Button, Popover } from 'antd';
import {
AimOutlined,
BugOutlined,
ConsoleSqlOutlined,
EditOutlined,
FileTextOutlined,
LinkOutlined,
TableOutlined,
} from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er' | 'sqlLog';
export type DataGridSecondaryActionsTranslate = (key: string, params?: I18nParams) => string;
export interface DataGridSecondaryActionsProps {
isV2Ui: boolean;
@@ -31,6 +35,7 @@ export interface DataGridSecondaryActionsProps {
isTableSurfaceActive: boolean;
onToggleDataPanel: () => void;
onOpenTableDdl: () => void;
translate?: DataGridSecondaryActionsTranslate;
}
const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
@@ -53,15 +58,19 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
isTableSurfaceActive,
onToggleDataPanel,
onOpenTableDdl,
translate = defaultTranslate,
}) => {
if (isV2Ui) {
const fieldsActionLabel = canOpenObjectDesigner ? '对象设计' : '字段信息';
const fieldsActionLabel = canOpenObjectDesigner
? translate('data_grid.secondary.object_design')
: translate('data_grid.column_settings.field_info');
const fieldsActionIcon = canOpenObjectDesigner ? <EditOutlined /> : <FileTextOutlined />;
const viewTabItems: Array<{ key: GridViewMode; label: string; icon: React.ReactNode; disabled?: boolean }> = [
{ key: 'table', label: '数据预览', icon: <TableOutlined /> },
{ key: 'table', label: translate('data_grid.secondary.data_preview'), icon: <TableOutlined /> },
{ key: 'fields', label: fieldsActionLabel, icon: fieldsActionIcon },
{ key: 'ddl', label: '查看 DDL', icon: <ConsoleSqlOutlined />, disabled: !canViewDdl },
{ key: 'er', label: 'ER 图', icon: <LinkOutlined /> },
{ key: 'ddl', label: translate('data_grid.secondary.view_ddl'), icon: <ConsoleSqlOutlined />, disabled: !canViewDdl },
{ key: 'er', label: translate('data_grid.secondary.er_diagram'), icon: <LinkOutlined /> },
{ key: 'sqlLog', label: translate('log_panel.short_title'), icon: <BugOutlined /> },
];
return (
@@ -98,7 +107,7 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
type={showColumnComment || showColumnType ? 'primary' : 'text'}
icon={<FileTextOutlined />}
>
{translate('data_grid.secondary.column_display')}
</Button>
</Popover>
<Popover trigger="click" placement="topRight" content={<div style={{ padding: 4 }}>{columnQuickFindContent}</div>}>
@@ -108,14 +117,14 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
type="text"
icon={<AimOutlined />}
>
{translate('data_grid.secondary.jump_column')}
</Button>
</Popover>
{pageFindContent}
<div className="gn-v2-data-grid-status-center">
<span className="gn-v2-data-grid-live">live</span>
<span>{mergedDisplayCount} </span>
<span> {pendingChangeCount}</span>
<span className="gn-v2-data-grid-live">{translate('data_grid.secondary.live')}</span>
<span>{translate('data_grid.secondary.row_count', { count: mergedDisplayCount })}</span>
<span>{translate('data_grid.secondary.pending_changes', { count: pendingChangeCount })}</span>
</div>
</div>
<div className="gn-v2-data-grid-status-right">
@@ -154,10 +163,10 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
disabled={!isTableSurfaceActive}
onClick={onToggleDataPanel}
>
{translate('data_grid.secondary.data_preview')}
</Button>
<Popover trigger="click" placement="bottomRight" content={columnInfoSettingContent}>
<Button data-grid-column-display-action="true" icon={<FileTextOutlined />}></Button>
<Button data-grid-column-display-action="true" icon={<FileTextOutlined />}>{translate('data_grid.column_settings.field_info')}</Button>
</Popover>
{canViewDdl && (
<Button
@@ -166,7 +175,7 @@ const DataGridSecondaryActions: React.FC<DataGridSecondaryActionsProps> = ({
loading={ddlLoading}
onClick={onOpenTableDdl}
>
DDL
{translate('data_grid.secondary.view_ddl')}
</Button>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8');
describe('DataGridToolbarFrame i18n guards', () => {
it('localizes data edit commit mode controls', () => {
[
'data_grid.toolbar.commit_mode.tooltip',
'data_grid.toolbar.commit_mode.manual',
'data_grid.toolbar.commit_mode.auto',
'data_grid.toolbar.commit_mode.auto_countdown',
].forEach((key) => {
expect(toolbarSource).toContain(`translate('${key}'`);
});
[
'控制表数据编辑后的提交方式',
"label: '手动提交'",
"label: '自动提交'",
's 后提交',
].forEach((legacyText) => {
expect(toolbarSource).not.toContain(legacyText);
});
});
});

View File

@@ -41,6 +41,7 @@ export interface DataGridToolbarFrameProps {
isV2Ui: boolean;
tableName?: string;
dbName?: string;
translate?: (key: string, params?: Record<string, string | number>) => string;
loading: boolean;
darkMode: boolean;
bgFilter: string;
@@ -92,7 +93,7 @@ export interface DataGridToolbarFrameProps {
noAutoCapInputProps: Record<string, unknown>;
filterFieldSelectStyle: React.CSSProperties;
filterFieldPopupWidth: number;
exportMenu: MenuProps['items'];
onOpenExportModal: () => void;
queryResultCopyMenu: MenuProps['items'];
dbType: string;
onResetPendingChanges: () => void;
@@ -141,6 +142,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
isV2Ui,
tableName,
dbName,
translate: translateProp,
loading,
darkMode,
bgFilter,
@@ -192,7 +194,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
noAutoCapInputProps,
filterFieldSelectStyle,
filterFieldPopupWidth,
exportMenu,
onOpenExportModal,
queryResultCopyMenu,
dbType,
onResetPendingChanges,
@@ -236,6 +238,10 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
onDisableAllFilters,
onClearFiltersAndSorts,
}) => {
const translate = React.useCallback(
(key: string, params?: Record<string, string | number>) => translateProp?.(key, params) ?? key,
[translateProp],
);
const renderToolbarDivider = () => (
<div
className={isV2Ui ? 'gn-v2-toolbar-divider' : undefined}
@@ -245,8 +251,9 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
);
const quickWherePlaceholder = dbType === 'mongodb'
? '输入 MongoDB JSON 查询对象,例如 {"status":"A"}'
: '输入 WHERE 后面的条件,例如 status = 1 AND name LIKE \'A%\'';
? translate('data_grid.filter.mongodb_query_placeholder')
: translate('data_grid.filter.quick_where_placeholder');
const toolbarTitle = tableName || translate('data_grid.table_fallback.query_result');
return (
<div
@@ -284,7 +291,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
<>
<div className="gn-v2-data-grid-toolbar-title">
<TableOutlined className="gn-v2-data-grid-icon" />
<strong title={tableName || '查询结果'}>{tableName || '查询结果'}</strong>
<strong title={toolbarTitle}>{toolbarTitle}</strong>
{dbName && <small title={dbName}>· {dbName}</small>}
</div>
{renderToolbarDivider()}
@@ -292,7 +299,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
)}
{onReload && (
<Button icon={<ReloadOutlined />} disabled={loading} onClick={onRefresh}>
{translate('data_grid.toolbar.refresh')}
</Button>
)}
@@ -300,7 +307,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
<>
{renderToolbarDivider()}
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={onToggleFilterClick}>
{translate('data_grid.toolbar.filter')}
</Button>
</>
)}
@@ -308,13 +315,13 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
{canModifyData && (
<>
{renderToolbarDivider()}
<Button icon={<PlusOutlined />} onClick={onAddRow}></Button>
<Button icon={<PlusOutlined />} onClick={onAddRow}>{translate('data_grid.toolbar.add_row')}</Button>
{allSelectedAreDeleted ? (
<Button icon={<UndoOutlined />} disabled={selectedRowKeysLength === 0} onClick={onUndoDeleteSelected}></Button>
<Button icon={<UndoOutlined />} disabled={selectedRowKeysLength === 0} onClick={onUndoDeleteSelected}>{translate('data_grid.toolbar.undo_delete')}</Button>
) : (
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeysLength === 0} onClick={onDeleteSelected}></Button>
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeysLength === 0} onClick={onDeleteSelected}>{translate('data_grid.toolbar.delete_selected')}</Button>
)}
{selectedRowKeysLength > 0 && <span style={{ fontSize: '12px', color: '#888' }}> {selectedRowKeysLength}</span>}
{selectedRowKeysLength > 0 && <span style={{ fontSize: '12px', color: '#888' }}>{translate('data_grid.toolbar.selected_count', { count: selectedRowKeysLength })}</span>}
{renderToolbarDivider()}
<Button
data-grid-cell-editor-action="true"
@@ -322,18 +329,18 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
type={cellEditMode ? 'primary' : 'default'}
onClick={onToggleCellEditMode}
>
{translate('data_grid.toolbar.cell_editor')}
</Button>
{cellEditMode && selectedCellsSize > 0 && (
<>
<Button icon={<CopyOutlined />} onClick={onCopySelectedCellsToClipboard}>
({selectedCellsSize})
{translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize })}
</Button>
<Button icon={<CopyOutlined />} onClick={onCopySelectedColumnsFromRow}>
({selectedCellsSize})
{translate('data_grid.toolbar.copy_selection_columns', { count: selectedCellsSize })}
</Button>
<Button type="primary" onClick={onOpenBatchEditModal}>
({selectedCellsSize})
{translate('data_grid.toolbar.batch_fill', { count: selectedCellsSize })}
</Button>
</>
)}
@@ -344,10 +351,10 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
disabled={selectedRowKeysLength === 0}
onClick={onPasteCopiedColumnsToSelectedRows}
>
({selectedRowKeysLength})
{translate('data_grid.toolbar.paste_to_selected_rows', { count: selectedRowKeysLength })}
</Button>
<span style={{ fontSize: '12px', color: '#888' }}>
{copiedCellPatchColumnCount}
{translate('data_grid.toolbar.copied_columns_count', { count: copiedCellPatchColumnCount })}
</span>
</>
)}
@@ -361,26 +368,26 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
>
{isV2Ui ? (
<>
<span></span>
<span>{translate('data_grid.toolbar.commit_label')}</span>
<span className="gn-v2-toolbar-kbd">{pendingChangeCount}</span>
</>
) : `提交事务 (${pendingChangeCount})`}
) : translate('data_grid.toolbar.commit', { count: pendingChangeCount })}
</Button>
{hasChanges && (
<Dropdown menu={{ items: [{ key: 'preview-sql', label: '生成预览 SQL', icon: <ConsoleSqlOutlined />, onClick: onPreviewChanges }] }}>
<Button icon={<ConsoleSqlOutlined />}>SQL <DownOutlined /></Button>
<Dropdown menu={{ items: [{ key: 'preview-sql', label: translate('data_grid.toolbar.preview_sql_generate'), icon: <ConsoleSqlOutlined />, onClick: onPreviewChanges }] }}>
<Button icon={<ConsoleSqlOutlined />}>{translate('data_grid.toolbar.preview_sql')} <DownOutlined /></Button>
</Dropdown>
)}
{hasChanges && <Button icon={<UndoOutlined />} onClick={onResetPendingChanges}></Button>}
<Tooltip title="控制表数据编辑后的提交方式。手动提交更安全;自动提交会在最后一次修改后按所选时间提交。">
{hasChanges && <Button icon={<UndoOutlined />} onClick={onResetPendingChanges}>{translate('data_grid.toolbar.rollback')}</Button>}
<Tooltip title={translate('data_grid.toolbar.commit_mode.tooltip')}>
<Select
size="small"
value={dataEditCommitMode}
onChange={onDataEditCommitModeChange}
style={{ width: 118, flex: '0 0 auto' }}
options={[
{ value: 'manual', label: '手动提交' },
{ value: 'auto', label: '自动提交' },
{ value: 'manual', label: translate('data_grid.toolbar.commit_mode.manual') },
{ value: 'auto', label: translate('data_grid.toolbar.commit_mode.auto') },
]}
/>
</Tooltip>
@@ -395,7 +402,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
)}
{dataEditCommitMode === 'auto' && hasChanges && autoCommitRemainingSeconds !== null && (
<span style={{ fontSize: 12, color: '#888', whiteSpace: 'nowrap' }}>
{autoCommitRemainingSeconds}s
{translate('data_grid.toolbar.commit_mode.auto_countdown', { seconds: autoCommitRemainingSeconds })}
</span>
)}
</>
@@ -404,8 +411,8 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
{(canImport || canExport) && (
<>
{renderToolbarDivider()}
{canImport && <Button icon={<ImportOutlined />} onClick={onImport}></Button>}
{canExport && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}> <DownOutlined /></Button></Dropdown>}
{canImport && <Button icon={<ImportOutlined />} onClick={onImport}>{translate('data_grid.toolbar.import')}</Button>}
{canExport && <Button icon={<ExportOutlined />} onClick={onOpenExportModal}>{translate('data_grid.toolbar.export')}</Button>}
</>
)}
@@ -419,7 +426,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
disabled={!canCopyQueryResult}
onClick={onCopyQueryResultCsv}
>
<DownOutlined />
{translate('data_grid.toolbar.copy')} <DownOutlined />
</Button>
</Dropdown>
</>
@@ -427,7 +434,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
<>
{renderToolbarDivider()}
<Tooltip title="一键借助 AI 智能分析当前查询页数据">
<Tooltip title={translate('data_grid.toolbar.ai_insight_tooltip')}>
<Button
className={isV2Ui ? 'gn-v2-ai-insight-button' : undefined}
icon={<RobotOutlined />}
@@ -444,7 +451,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
}}
onClick={onRequestAiInsight}
>
<span>{isV2Ui ? 'AI 洞察' : 'AI 数据洞察'}</span>
<span>{isV2Ui ? translate('data_grid.toolbar.ai_insight_short') : translate('data_grid.toolbar.ai_insight')}</span>
{isV2Ui && aiShortcutLabel !== '-' && <span className="gn-v2-toolbar-kbd">{aiShortcutLabel}</span>}
</Button>
</Tooltip>
@@ -460,12 +467,12 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
{prefersManualTotalCount && (
<>
{renderToolbarDivider()}
<Tooltip title={paginationTotalCountLoading ? '取消本次精确总数统计(不会影响当前浏览)' : '按当前筛选统计精确总数'}>
<Tooltip title={paginationTotalCountLoading ? translate('data_grid.toolbar.cancel_count_tooltip') : translate('data_grid.toolbar.count_total_tooltip')}>
<Button
icon={paginationTotalCountLoading ? <CloseOutlined /> : <VerticalAlignBottomOutlined />}
onClick={onToggleTotalCount}
>
{paginationTotalCountLoading ? '取消统计' : '统计总数'}
{paginationTotalCountLoading ? translate('data_grid.toolbar.cancel_count') : translate('data_grid.toolbar.count_total')}
</Button>
</Tooltip>
</>
@@ -541,10 +548,10 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
/>
</AutoComplete>
<Button size="small" type="primary" onClick={onApplyQuickWhere}>
WHERE
{translate('data_grid.filter.apply_where')}
</Button>
<Button size="small" onClick={onClearQuickWhere} disabled={!quickWhereDraft && !quickWhereCondition}>
{translate('data_grid.filter.clear')}
</Button>
</div>
@@ -556,13 +563,13 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
onChange={(event) => updateFilter(cond.id, 'enabled', event.target.checked)}
style={{ marginTop: 6, flex: '0 0 auto', whiteSpace: 'nowrap' }}
>
{translate('data_grid.filter.enabled')}
</Checkbox>
<Select
style={{ width: 96, minWidth: 96, maxWidth: 96, flex: '0 0 96px' }}
value={condIndex === 0 ? '__FIRST__' : (cond.logic === 'OR' ? 'OR' : 'AND')}
onChange={(value) => updateFilter(cond.id, 'logic', value)}
options={condIndex === 0 ? [{ value: '__FIRST__', label: '首条' }] : filterLogicOptions}
options={condIndex === 0 ? [{ value: '__FIRST__', label: translate('data_grid.filter.first_condition') }] : filterLogicOptions}
disabled={condIndex === 0}
/>
<Select
@@ -579,7 +586,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
.toLowerCase()
.includes(String(input || '').trim().toLowerCase())
}
placeholder="搜索字段名"
placeholder={translate('data_grid.filter.search_field_placeholder')}
disabled={cond.op === 'CUSTOM'}
/>
<Select
@@ -596,7 +603,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
autoSize={{ minRows: 1, maxRows: 4 }}
value={cond.value}
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
placeholder="输入自定义 WHERE 表达式(不需要再写 WHERE例如status IN ('A','B')"
placeholder={translate('data_grid.filter.custom_where_placeholder')}
/>
) : isListOp(cond.op) ? (
<Input.TextArea
@@ -605,7 +612,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
autoSize={{ minRows: 1, maxRows: 4 }}
value={cond.value}
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
placeholder="多个值用逗号或换行分隔"
placeholder={translate('data_grid.filter.list_values_placeholder')}
/>
) : isBetweenOp(cond.op) ? (
<>
@@ -614,18 +621,18 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
style={{ width: 220 }}
value={cond.value}
onChange={(event) => updateFilter(cond.id, 'value', event.target.value)}
placeholder="开始值"
placeholder={translate('data_grid.filter.start_value_placeholder')}
/>
<Input
{...noAutoCapInputProps}
style={{ width: 220 }}
value={cond.value2 || ''}
onChange={(event) => updateFilter(cond.id, 'value2', event.target.value)}
placeholder="结束值"
placeholder={translate('data_grid.filter.end_value_placeholder')}
/>
</>
) : isNoValueOp(cond.op) ? (
<Input {...noAutoCapInputProps} style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
<Input {...noAutoCapInputProps} style={{ width: 220 }} value="" disabled placeholder={translate('data_grid.filter.no_value_placeholder')} />
) : (
<Input
{...noAutoCapInputProps}
@@ -652,7 +659,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
style={{ flex: '0 0 auto' }}
/>
<span style={{ fontSize: 12, color: 'inherit', opacity: 0.7, whiteSpace: 'nowrap', minWidth: 32 }}>
{index === 0 ? '排序' : '然后'}
{index === 0 ? translate('data_grid.filter.sort_label') : translate('data_grid.filter.then_label')}
</span>
<Select
style={filterFieldSelectStyle}
@@ -678,7 +685,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
.toLowerCase()
.includes(String(input || '').trim().toLowerCase())
}
placeholder="选择排序字段"
placeholder={translate('data_grid.filter.select_sort_field_placeholder')}
allowClear
onClear={() => {
const next = sortInfo.filter((_, itemIndex) => itemIndex !== index);
@@ -694,8 +701,8 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
onApplySortInfo(next);
}}
options={[
{ value: 'ascend', label: '升序 ↑' },
{ value: 'descend', label: '降序 ↓' },
{ value: 'ascend', label: `${translate('data_grid.filter.sort_asc')}` },
{ value: 'descend', label: `${translate('data_grid.filter.sort_desc')}` },
]}
disabled={!item.columnKey}
/>
@@ -724,7 +731,7 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
borderTop: ((enableSortControls && sortInfo.length > 0) || filterConditions.length > 0) ? `1px dashed ${panelFrameColor}` : 'none',
}}
>
<Button type="primary" ghost onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
<Button type="primary" ghost onClick={addFilter} size="small" icon={<PlusOutlined />}>{translate('data_grid.filter.add_condition')}</Button>
{enableSortControls && (
<Button
type="dashed"
@@ -736,15 +743,15 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
}}
disabled={sortInfo.length >= displayColumnNames.length}
>
{translate('data_grid.filter.add_sort')}
</Button>
)}
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
<Button size="small" onClick={onEnableAllFilters}></Button>
<Button size="small" onClick={onDisableAllFilters}></Button>
<Button size="small" onClick={onEnableAllFilters}>{translate('data_grid.filter.enable_all')}</Button>
<Button size="small" onClick={onDisableAllFilters}>{translate('data_grid.filter.disable_all')}</Button>
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
<Button type="primary" onClick={onApplyFilters} size="small"></Button>
<Button size="small" icon={<ClearOutlined />} onClick={onClearFiltersAndSorts}></Button>
<Button type="primary" onClick={onApplyFilters} size="small">{translate('data_grid.filter.apply')}</Button>
<Button size="small" icon={<ClearOutlined />} onClick={onClearFiltersAndSorts}>{translate('data_grid.filter.clear')}</Button>
</div>
</div>
)}

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