Compare commits

..

41 Commits

Author SHA1 Message Date
Syngnat
9bbdcea3fd Release/0.6.5 2026-04-01 16:47:42 +08:00
Syngnat
f992ad72e6 feat(mongodb): 支持无认证模式连接低版本 MongoDB 实例
- 连接表单:验证方式新增"无认证 (None)"选项,MongoDB 用户名改为非必填
- URI 构建:当 MongoAuthMechanism 为 NONE 时跳过 user/password/authSource/authMechanism
- 兼容优化:无用户名时不再默认设置 authSource=admin,避免驱动对无密码实例发起认证
- 双版本同步:mongodb_impl.go 与 mongodb_impl_v1.go 同步修改
- refs #303
2026-04-01 16:46:27 +08:00
Syngnat
5c0f6f8ff4 🐛 fix(data-grid): 修复数据预览面板日期格式化、JSON切换失效及幽灵变更计数问题
- 日期时间字段预览时通过 normalizeDateTimeString 格式化带时区的 ISO 格式
- 切换单元格时始终更新预览值,用 dataPanelOriginalRef 替代 suppress 机制判断 dirty
- handleCellSave 增加根源级变更检测,与原始 data 逐字段比较后才写入 modifiedRows
- 英文消息 "No changes to commit" 改为中文 "没有可提交的变更"
- refs #301
2026-04-01 16:21:57 +08:00
Syngnat
1eb517f083 ♻️ refactor(table-designer): 按索引类别精确分类索引方法类型选项
- MySQL InnoDB:所有索引类别均为固定方法(BTREE/FULLTEXT/RTREE),移除无意义的"默认"选项
- PostgreSQL:普通索引保留全部方法选项,主键和唯一索引固定为 BTREE
- 新增 getFixedIndexType 辅助函数,切换索引类别时自动设置对应的固定方法类型
- getIndexTypeOptions 接受 kind 参数,按类别动态返回精确的选项列表
- 切换类别时若当前方法不在新选项中,自动重置为合法值
- refs #299
2026-04-01 16:04:22 +08:00
Syngnat
02fa0aef46 🔥 remove(table-designer): 移除 MySQL 索引类型中不支持的 HASH 和 RTREE 选项
- MySQL InnoDB 引擎不支持手动创建 HASH/RTREE 索引,执行后会静默降级为 BTREE
- 从 MYSQL_INDEX_TYPE_OPTIONS 中移除 HASH 和 RTREE,避免用户误选导致修改"不生效"
- MySQL 下仅保留 DEFAULT/BTREE/FULLTEXT/SPATIAL 四种索引类型
- refs #298
2026-04-01 15:54:29 +08:00
Syngnat
f7107a1625 🐛 fix(data-grid): 修复单元格编辑值丢失及日期选择器滚动偏移问题
- 移除 Form.Item 的 preserve={false},修复嵌套字段名下编辑后值变为 undefined 的问题
- 将表单值初始化移至 useEffect([editing]),确保每次编辑时从 record 重新读取并覆盖旧值
- 新增 cellRef 绑定单元格 DOM,用于定位滚动容器
- DatePicker/TimePicker 面板打开时在 ant-table-wrapper 上拦截 wheel 事件,阻止表格滚动导致选择器漂移
- 面板关闭时自动移除 wheel 事件监听,恢复正常滚动
- refs #297
2026-04-01 15:45:50 +08:00
Syngnat
08ab06c038 feat(sidebar/table-overview): 优化侧边栏交互并新增表概览列表视图
- 修复连接刷新后数据库节点无法再次展开的问题,刷新时清除子节点 expandedKeys/loadedKeys/loadingRef
- 表概览由双击改为单击"表(N)"分组节点打开,双击仅触发展开/折叠
- 使用 clickTimerRef 延时防抖区分单击与双击事件,避免双击同时打开表概览
- 表概览新增列表视图模式,展示表名、注释、行数、数据大小、索引大小、引擎等列
- 工具栏新增卡片/列表视图切换按钮,两种视图共享搜索、排序和右键菜单
- refs #296
- refs #324
2026-04-01 15:29:42 +08:00
Syngnat
3402b56fdb 🎨 style(data-grid): 重构筛选面板为 flex 分区布局
- 外层改为 flex column,拆分为可滚动内容区(maxHeight: 200px)和固定操作栏
- "添加排序"从内容区提升到操作栏,条件渲染依赖 onSort 存在性
- "添加条件"使用 primary ghost 按钮增强辨识度
- refs #295
2026-04-01 15:03:02 +08:00
Syngnat
2c2baca69f 🐛 fix(data-grid): 修复日期时间字段二次编辑时日历残留上次选择日期标记
- Form.Item 默认 preserve={true},DatePicker 卸载后表单仍保留旧 dayjs 值
- 再次进入编辑时 DatePicker 读取到残留值,导致日历面板显示上次选择的日期圆圈
- 设置 preserve={false} 确保每次编辑态卸载后清除字段值,消除残留标记
- refs #290
2026-04-01 14:53:44 +08:00
Syngnat
e464c2cce1 🐛 fix(data-grid): 修复日期时间类型字段编辑交互问题并中文化日期选择器
- 修复"此刻"按钮点击后自动提交的问题,改为自定义按钮仅填值、需点击"确定"才保存
- 修复 datetime 编辑态点击外部后不退出的问题,通过 onBlur + pickerOpenRef 兜底
- 全局配置 dayjs 中文 locale,日期选择器月份/星期等文本显示为中文
- 为 time/date/year 类型 picker 添加 onBlur 兜底,确保焦点离开后退出编辑
- save 函数增加 editing 守卫和 catch 兜底,防止重复保存或异常时卡死编辑态
- refs #289
2026-04-01 14:49:28 +08:00
Syngnat
15f72c013d 📝 docs(readme): 新增项目 Star 增长趋势图与动态状态徽章
- 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量
- 增长趋势:底部区域新增 Star History 的动态增长曲线图表
- 兼容性修复:将 HTML `<picture>` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题
- 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新
2026-04-01 14:04:26 +08:00
Syngnat
c2c8870841 📝 docs(readme): 新增项目 Star 增长趋势图与动态状态徽章
- 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量
- 增长趋势:底部区域新增 Star History 的动态增长曲线图表
- 兼容性修复:将 HTML `<picture>` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题
- 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新
2026-04-01 14:03:14 +08:00
Syngnat
4f7ac7149a 📝 docs(readme): 新增项目 Star 增长趋势图与动态状态徽章
- 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量
- 增长趋势:底部区域新增 Star History 的动态增长曲线图表
- 兼容性修复:将 HTML `<picture>` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题
- 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新
2026-04-01 13:52:57 +08:00
tianqijiuyun-latiao
8d8af530a7 Merge remote-tracking branch 'upstream/dev' into feature/20260327_opt
# Conflicts:
#	frontend/src/components/DataGrid.tsx
2026-03-31 12:36:20 +08:00
tianqijiuyun-latiao
29b96719d5 🐛 fix(sql): 修复时间字段复制与导出SQL格式 2026-03-31 12:29:03 +08:00
Syngnat
9c96246320 feat(postgres): PostgreSQL 支持不带 schema 前缀的表名补全与执行
- 后端优化:连接成功后自动查询所有用户 schema 并将 search_path 写入 DSN 重建连接池
- 连接池修复:SET search_path 仅对单个连接生效,改为 DSN 级别配置使所有连接生效
- 表名补全:前端智能匹配 schema.table 中的纯表名部分,输入表名即可触发补全
- 同名表处理:跨 schema 存在同名表时补全自动显示 schema.table 格式以区分
- 列补全增强:FROM/JOIN 引用纯表名时关联列提示和别名列提示均可正确匹配
2026-03-31 12:09:33 +08:00
Syngnat
31644dee6b 🐛 fix(dameng): 修复达梦数据源侧边栏无法展开数据库节点的问题
- 权限适配:取消对 SYSDBA schema 的默认过滤,并增加 `SELECT USER FROM DUAL` 兜底查询
- 树节点容错:Sidebar 当数据库为空时不再阻断加载状态,允许用户重试刷新并增加明确提示
- 类型修正:修复 RedisMonitor 组件 `NodeJS.Timeout` 在 Vite 下的编译报错
- 测试覆盖:补充达梦 SYSDBA 过滤及兜底查询逻辑的单元测试
2026-03-30 19:46:05 +08:00
Syngnat
d9cbbc6c31 Release/0.6.4 2026-03-28 11:24:23 +08:00
Syngnat
6ec1072d2e Release/0.6.3 2026-03-20 16:53:11 +08:00
Syngnat
9f2f8b33e8 Release/0.6.3 2026-03-20 16:24:26 +08:00
Syngnat
d984a15508 release/0.6.3 2026-03-20 16:08:38 +08:00
Syngnat
bfa918cb9d Release/v0.6.2 (#263) 2026-03-19 21:18:01 +08:00
Syngnat
4e73f6d8b5 release/0.6.1 2026-03-19 11:59:25 +08:00
Syngnat
2f4e20a34a release/0.6.0 2026-03-18 21:27:31 +08:00
Syngnat
dfabd77615 🐛 fix(ci): 修复 Chocolatey UPX 包不可用导致 Windows 构建失败 2026-03-18 17:30:33 +08:00
Syngnat
4a2dda8aa2 合并拉取请求 #254
release/0.5.9
2026-03-18 17:21:55 +08:00
Syngnat
d1d3fa26f1 🔧 fix(frontend/ci): 移除前端测试对 node:assert 的类型依赖 (#234) 2026-03-13 15:37:16 +08:00
Syngnat
fc8e62b997 release/0.5.8 (#233) 2026-03-13 15:29:53 +08:00
Syngnat
b0eb93bfa3 Release/0.5.7 (#230)
🔧 fix(ci/release-winget): 修复 Node20 弃用告警并强制启用 Node24 运行时

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

---------

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

---------

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

---------

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

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

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

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

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

* Release/0.5.3 (#191)

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

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

refs #168

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

refs #178

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

refs #176

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

refs #177

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

---------

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

View File

@@ -5,6 +5,8 @@
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
**Language**: English | [简体中文](README.zh-CN.md)
@@ -212,6 +214,15 @@ For the full workflow, branch model, and maintainer sync rules, see:
External contributors should open pull requests directly against `main`.
## Star History
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## Links
- [linux.do](https://linux.do/)

View File

@@ -5,6 +5,8 @@
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
**语言**: [English](README.md) | 简体中文
@@ -195,6 +197,16 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
外部贡献者统一直接向 `main` 发起 Pull Request。
## Star History (Star 增长趋势)
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## 友情链接
- [linux.do](https://linux.do/)

View File

@@ -2101,7 +2101,7 @@ const ConnectionModal: React.FC<{
<Form.Item
name="user"
label="用户名"
rules={[createUriAwareRequiredRule('请输入用户名')]}
rules={dbType === 'mongodb' ? [] : [createUriAwareRequiredRule('请输入用户名')]}
style={{ marginBottom: 0 }}
>
<Input />
@@ -2115,6 +2115,7 @@ const ConnectionModal: React.FC<{
allowClear
placeholder="自动协商"
options={[
{ value: 'NONE', label: '无认证 (None)' },
{ value: 'SCRAM-SHA-1', label: 'SCRAM-SHA-1' },
{ value: 'SCRAM-SHA-256', label: 'SCRAM-SHA-256' },
{ value: 'MONGODB-AWS', label: 'MONGODB-AWS' },

View File

@@ -34,6 +34,7 @@ import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
import { buildCopyInsertSQL, normalizeTemporalLiteralText } from './dataGridCopyInsert';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -570,32 +571,52 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
}) => {
const [editing, setEditing] = useState(false);
const inputRef = useRef<any>(null);
const cellRef = useRef<HTMLElement>(null);
const pickerOpenRef = useRef(false);
const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null);
const form = useContext(EditableContext);
const cellContextMenuContext = useContext(CellContextMenuContext);
/** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */
const lockTableScroll = useCallback((lock: boolean) => {
if (lock) {
// 查找虚拟滚动容器或常规滚动容器
const tableWrapper = cellRef.current?.closest?.('.ant-table-wrapper') as HTMLElement | null;
if (tableWrapper) {
const handler = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); };
tableWrapper.addEventListener('wheel', handler, { capture: true, passive: false });
scrollLockRef.current = { el: tableWrapper, handler };
}
} else if (scrollLockRef.current) {
const { el, handler } = scrollLockRef.current;
el.removeEventListener('wheel', handler, { capture: true } as any);
scrollLockRef.current = null;
}
}, []);
useEffect(() => {
if (editing) {
// 每次进入编辑时强制设置表单值(覆盖 form store 中可能残留的旧值)
const raw = record[dataIndex];
const fieldName = getCellFieldName(record, dataIndex);
if (isDateTimeField) {
const dayjsVal = parseToDayjs(raw, pickerType);
setCellFieldValue(form, fieldName, dayjsVal);
} else {
const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw;
setCellFieldValue(form, fieldName, initialValue);
}
inputRef.current?.focus();
}
}, [editing]);
const toggleEdit = () => {
setEditing(!editing);
const raw = record[dataIndex];
const fieldName = getCellFieldName(record, dataIndex);
if (isDateTimeField) {
// 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用
const dayjsVal = parseToDayjs(raw, pickerType);
setCellFieldValue(form, fieldName, dayjsVal);
} else {
const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw;
setCellFieldValue(form, fieldName, initialValue);
}
};
const save = async () => {
try {
if (!form) return;
if (!form || !editing) return;
const fieldName = getCellFieldName(record, dataIndex);
await form.validateFields([fieldName]);
let nextValue = form.getFieldValue(fieldName);
@@ -616,6 +637,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
}
} catch (errInfo) {
console.log('Save failed:', errInfo);
// 日期时间类型保存失败时兜底退出编辑,避免 DatePicker 卡在编辑态
if (isDateTimeField && editing) setEditing(false);
}
};
@@ -641,6 +664,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
style={{ width: '100%' }}
format={TEMPORAL_FORMATS[pickerType]}
onChange={() => setTimeout(save, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
needConfirm={false}
/>
) : pickerType === 'datetime' ? (
@@ -648,12 +673,31 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
ref={inputRef}
style={{ width: '100%' }}
showTime
showNow={false}
format={TEMPORAL_FORMATS[pickerType]}
renderExtraFooter={() => (
<a
style={{ padding: '0 2px' }}
onClick={() => {
// 自定义"此刻":仅将当前时间填入表单字段,面板保持打开。
// 用户需点击"确定"才真正保存,替代内置 showNow 的自动提交行为。
const fieldName = getCellFieldName(record, dataIndex);
setCellFieldValue(form, fieldName, dayjs());
}}
></a>
)}
onOk={() => setTimeout(save, 0)}
onOpenChange={(open) => {
// 面板关闭(点击外部)且非通过"确定"按钮触发时退出编辑,不保存
pickerOpenRef.current = open;
lockTableScroll(open);
// 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮onOk触发保存
if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0);
}}
onBlur={() => {
// 兜底:面板未打开或已关闭时,点击外部通过 blur 退出编辑。
// 延迟检查面板状态,避免点击自定义"此刻"按钮时误退出(此时面板仍打开)。
setTimeout(() => { if (editing && !pickerOpenRef.current) setEditing(false); }, 150);
}}
needConfirm
/>
) : (
@@ -663,6 +707,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
format={TEMPORAL_FORMATS[pickerType]}
picker={pickerType as any}
onChange={() => setTimeout(save, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
needConfirm={false}
/>
)
@@ -721,6 +767,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
return (
<Component
ref={cellRef}
{...restProps}
data-row-key={record ? String(record?.[GONAVI_ROW_KEY]) : undefined}
data-col-name={dataIndex || undefined}
@@ -1000,6 +1047,8 @@ const DataGrid: React.FC<DataGridProps> = ({
const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount;
const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount;
const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages;
const dbType = dataSourceCaps.type;
const isDuckDBConnection = dataSourceCaps.type === 'duckdb';
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
const isQueryResultExport = exportScope === 'queryResult';
@@ -1124,6 +1173,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const [dataPanelValue, setDataPanelValue] = useState('');
const [dataPanelIsJson, setDataPanelIsJson] = useState(false);
const dataPanelDirtyRef = useRef(false);
const dataPanelOriginalRef = useRef('');
const [rowEditorOpen, setRowEditorOpen] = useState(false);
const [rowEditorRowKey, setRowEditorRowKey] = useState<string>('');
const rowEditorBaseRawRef = useRef<Record<string, any>>({});
@@ -1340,6 +1390,16 @@ const DataGrid: React.FC<DataGridProps> = ({
return next;
}, [columnMetaMap]);
const columnTypeMapByLowerName = useMemo(() => {
const next: Record<string, string> = {};
Object.entries(columnMetaMapByLowerName).forEach(([name, meta]) => {
const type = String(meta?.type || '').trim();
if (!name || !type) return;
next[name] = type;
});
return next;
}, [columnMetaMapByLowerName]);
const normalizeCommitCellValue = useCallback(
(columnName: string, value: any, mode: 'insert' | 'update') => {
if (value === undefined) return undefined;
@@ -1361,7 +1421,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// INSERT 空时间值直接忽略字段让数据库默认值生效UPDATE 空时间值转 NULL。
return mode === 'insert' ? undefined : null;
}
return normalizeDateTimeString(value);
return normalizeTemporalLiteralText(value, meta?.type, true);
}
return value;
@@ -1436,14 +1496,18 @@ const DataGrid: React.FC<DataGridProps> = ({
const updateFocusedCell = useCallback((record: Item, dataIndex: string) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
const text = toEditableText(raw);
let text = toEditableText(raw);
// 日期时间字段格式化(处理带时区的 ISO 格式如 2026-03-22T00:00:00+08:00
if (typeof raw === 'string') {
text = normalizeDateTimeString(raw);
}
const isJson = looksLikeJsonText(text);
setFocusedCellInfo({ record, dataIndex, title: dataIndex });
// 仅在面板未被用户手动编辑时自动同步值
if (!dataPanelDirtyRef.current) {
setDataPanelValue(text);
setDataPanelIsJson(isJson);
}
// 切换到新单元格时总是更新预览值并重置 dirty 标记
dataPanelOriginalRef.current = text;
setDataPanelValue(text);
setDataPanelIsJson(isJson);
dataPanelDirtyRef.current = false;
}, []);
const handleDataPanelFormatJson = useCallback(() => {
@@ -2840,28 +2904,49 @@ const DataGrid: React.FC<DataGridProps> = ({
}, []);
const handleCellSave = useCallback((row: any) => {
// Optimistic update for display
// In parent-controlled data, we might need parent to update 'data',
// but here we manage 'modifiedRows' locally and overlay it.
// Since 'displayData' is derived from 'data' + 'modifiedRows', we need to update the source if it's in 'data'.
// But 'data' prop is immutable.
// So we update 'modifiedRows'.
// Check if it's an added row
const rowKey = row?.[GONAVI_ROW_KEY];
if (rowKey === undefined) return;
const isAdded = addedRows.some(r => r?.[GONAVI_ROW_KEY] === rowKey);
if (isAdded) {
setAddedRows(prev => prev.map(r => r?.[GONAVI_ROW_KEY] === rowKey ? { ...r, ...row } : r));
} else {
// 查找原始行数据,对比是否真正有值变更
const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey);
if (originalRow) {
const changedFields: Record<string, any> = {};
for (const col of Object.keys(row)) {
if (col === GONAVI_ROW_KEY) continue;
if (!isCellValueEqualForDiff(originalRow[col], row[col])) {
changedFields[col] = row[col];
}
}
if (Object.keys(changedFields).length === 0) {
// 没有实际变更,从 modifiedRows 中移除该行(如有)
setModifiedRows(prev => {
const keyStr = rowKeyStr(rowKey);
if (!(keyStr in prev)) return prev;
const next = { ...prev };
delete next[keyStr];
return next;
});
return;
}
}
setModifiedRows(prev => ({ ...prev, [rowKeyStr(rowKey)]: row }));
}
}, [addedRows]);
}, [addedRows, data]);
const handleDataPanelSave = useCallback(() => {
if (!focusedCellInfo) return;
// 与 updateFocusedCell 设置的原始值比较,避免幽灵变更
if (dataPanelValue === dataPanelOriginalRef.current) {
dataPanelDirtyRef.current = false;
void message.info('数据未变更');
return;
}
const nextRow: any = { ...focusedCellInfo.record, [focusedCellInfo.dataIndex]: dataPanelValue };
handleCellSave(nextRow);
dataPanelOriginalRef.current = dataPanelValue;
dataPanelDirtyRef.current = false;
void message.success('已保存');
}, [focusedCellInfo, dataPanelValue, handleCellSave]);
@@ -3429,7 +3514,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
void message.info("No changes to commit");
void message.info("没有可提交的变更");
return;
}
@@ -3505,17 +3590,15 @@ const DataGrid: React.FC<DataGridProps> = ({
// 使用 columnNames 保持表定义的字段顺序,而非 Object.keys() 的不确定顺序
const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY);
const sqlList = records.map((r: any) => {
const values = orderedCols.map(c => {
const v = r[c];
if (v === null || v === undefined) return 'NULL';
const str = typeof v === 'string' ? normalizeDateTimeString(v) : String(v);
const escaped = str.replace(/'/g, "''");
return `'${escaped}'`;
return buildCopyInsertSQL({
dbType,
tableName,
orderedCols,
record: r,
columnTypesByLowerName: columnTypeMapByLowerName,
});
const targetTable = tableName || 'table';
return `INSERT INTO \`${targetTable}\` (${orderedCols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
});
copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, tableName, columnNames, getTargets, copyToClipboard]);
copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, columnNames, getTargets, copyToClipboard, dbType, tableName, columnTypeMapByLowerName]);
const handleCopyJson = useCallback((record: any) => {
const records = getTargets(record);
@@ -4767,7 +4850,11 @@ const DataGrid: React.FC<DataGridProps> = ({
padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`,
background: 'transparent',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
}}>
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
<div style={{ maxHeight: 200, overflowY: 'auto', overflowX: 'hidden', flex: '0 1 auto' }}>
{filterConditions.map((cond, condIndex) => (
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start', opacity: cond.enabled === false ? 0.58 : 1 }}>
<Checkbox
@@ -4910,14 +4997,17 @@ const DataGrid: React.FC<DataGridProps> = ({
}} />
</div>
))}
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => {
const next = [...sortInfo, { columnKey: displayColumnNames.find(c => !sortInfo.some(s => s.columnKey === c)) || displayColumnNames[0] || '', order: 'ascend', enabled: true }];
onSort(JSON.stringify(next), '');
}} disabled={sortInfo.length >= displayColumnNames.length} style={{ marginBottom: 4 }}></Button>
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginTop: (onSort && sortInfo.length > 0) ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) ? `1px dashed ${panelFrameColor}` : 'none' }}>
<Button type="dashed" onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', flex: '0 0 auto', marginTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? `1px dashed ${panelFrameColor}` : 'none' }}>
<Button type="primary" ghost onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
{onSort && (
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => {
const next = [...sortInfo, { columnKey: displayColumnNames.find(c => !sortInfo.some(s => s.columnKey === c)) || displayColumnNames[0] || '', order: 'ascend', enabled: true }];
onSort(JSON.stringify(next), '');
}} disabled={sortInfo.length >= displayColumnNames.length}></Button>
)}
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: true })))}></Button>
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: false })))}></Button>
@@ -5277,8 +5367,10 @@ const DataGrid: React.FC<DataGridProps> = ({
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={dataPanelValue}
onChange={(val) => {
setDataPanelValue(val || '');
dataPanelDirtyRef.current = true;
const newVal = val || '';
setDataPanelValue(newVal);
// 只有值真正与原始值不同时才标记 dirty
dataPanelDirtyRef.current = newVal !== dataPanelOriginalRef.current;
}}
options={{
minimap: { enabled: false },

View File

@@ -6,6 +6,7 @@ import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview
import { SavedConnection } from '../types';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
const { Title, Text } = Typography;
const { Step } = Steps;
@@ -74,7 +75,10 @@ const toSqlLiteral = (value: any, dbType: string): string => {
return value ? 'TRUE' : 'FALSE';
}
if (value instanceof Date) {
return `'${value.toISOString().replace(/'/g, "''")}'`;
return `'${formatLocalDateTimeLiteral(value).replace(/'/g, "''")}'`;
}
if (typeof value === 'string') {
return `'${value.replace(/'/g, "''")}'`;
}
if (typeof value === 'object') {
try {
@@ -86,6 +90,20 @@ const toSqlLiteral = (value: any, dbType: string): string => {
return `'${String(value).replace(/'/g, "''")}'`;
};
const toTypedSqlLiteral = (value: any, dbType: string, columnType?: string): string => {
if (typeof value === 'string') {
const normalized = normalizeTemporalLiteralText(value, columnType, false);
return toSqlLiteral(normalized, dbType);
}
if (value instanceof Date) {
const normalized = String(columnType || '').trim()
? formatLocalDateTimeLiteral(value)
: value.toISOString();
return toSqlLiteral(normalized, dbType);
}
return toSqlLiteral(value, dbType);
};
const resolveRedisDbIndex = (raw?: string): number => {
const value = Number(String(raw || '').trim());
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
@@ -100,6 +118,9 @@ const buildSqlPreview = (
if (!previewData || !tableName) return { sqlText: '', statementCount: 0 };
const tableExpr = quoteSqlTable(dbType, tableName);
const pkCol = String(previewData.pkColumn || 'id');
const columnTypesByLowerName = previewData?.columnTypes && typeof previewData.columnTypes === 'object'
? previewData.columnTypes as Record<string, string>
: {};
const statements: string[] = [];
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
@@ -118,7 +139,7 @@ const buildSqlPreview = (
const columns = Object.keys(row);
if (columns.length === 0) return;
const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', ');
const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', ');
const valExpr = columns.map((c) => toTypedSqlLiteral(row[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])).join(', ');
statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`);
});
}
@@ -134,10 +155,10 @@ const buildSqlPreview = (
const setCols = changedColumns.filter((c: string) => String(c) !== pkCol);
if (setCols.length === 0) return;
const setExpr = setCols
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`)
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toTypedSqlLiteral(source[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])}`)
.join(', ');
statements.push(
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
);
});
}
@@ -147,7 +168,7 @@ const buildSqlPreview = (
const pk = String(rowWrap?.pk ?? '');
if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return;
statements.push(
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
);
});
}

View File

@@ -716,11 +716,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// Prefer preloaded MySQL all-columns cache
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[];
if (sharedAllColumnsData.length > 0) {
const tiTableLower = (tableInfo.tableName || '').toLowerCase();
cols = sharedAllColumnsData
.filter(c =>
(c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() &&
(c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase()
)
.filter(c => {
if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false;
const cTableLower = (c.tableName || '').toLowerCase();
if (cTableLower === tiTableLower) return true;
// schema.table 格式匹配纯表名
const parsed = splitSchemaAndTable(c.tableName || '');
return (parsed.table || '').toLowerCase() === tiTableLower;
})
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName }));
} else {
const dbCols = await getColumnsByDB(tableInfo.tableName);
@@ -773,7 +778,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
.filter(c => {
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
const shortIdent = (c.tableName || '').toLowerCase();
return (foundTables.has(fullIdent) || foundTables.has(shortIdent)) && startsWithPrefix(c.name || '');
// 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users
const parsed = splitSchemaAndTable(c.tableName || '');
const pureIdent = (parsed.table || '').toLowerCase();
return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && startsWithPrefix(c.name || '');
})
.map(c => {
// 当前库的表字段优先级更高
@@ -788,24 +796,61 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
});
// 表提示:当前库显示表名,其他库显示 db.table 格式
// 表提示:当前库智能处理 schema.table 格式
// 1. 构建纯表名到 schema 列表的映射,检测同名表
const currentDbTables = sharedTablesData.filter(t =>
(t.dbName || '').toLowerCase() === currentDatabase.toLowerCase()
);
const tableNameToSchemas = new Map<string, string[]>();
for (const t of currentDbTables) {
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = (parsed.table || t.tableName || '').toLowerCase();
const schemas = tableNameToSchemas.get(pureTable) || [];
schemas.push(parsed.schema || '');
tableNameToSchemas.set(pureTable, schemas);
}
const tableSuggestions = sharedTablesData
.filter(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
return startsWithPrefix(label || '');
if (!isCurrentDb) {
// 跨库:用 db.table 格式匹配
return startsWithPrefix(`${t.dbName}.${t.tableName}`);
}
// 当前库:同时用完整名和纯表名匹配
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
return startsWithPrefix(t.tableName || '') || startsWithPrefix(pureTable);
})
.map(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
if (!isCurrentDb) {
const label = `${t.dbName}.${t.tableName}`;
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText: label,
detail: `Table (${t.dbName})`,
range,
sortText: sortGroups.tableOther + t.tableName,
};
}
// 当前库:检查是否有跨 schema 同名表
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || [];
const hasDuplicate = schemas.length > 1;
// 同名表存在于多个 schema → 显示 schema.table否则只显示纯表名
const label = hasDuplicate ? t.tableName : pureTable;
const insertText = hasDuplicate ? t.tableName : pureTable;
const schemaInfo = parsed.schema ? ` (${parsed.schema})` : '';
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText,
detail: `Table (${t.dbName})`,
detail: `Table${schemaInfo}`,
range,
sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName,
sortText: sortGroups.tableCurrent + pureTable,
};
});

View File

@@ -51,7 +51,7 @@ const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) =>
// Ref to track if component is mounted to prevent state updates after unmount
const mountedRef = useRef(true);
// Interval ref
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Previous ops counter to calculate QPS if instantaneous_ops_per_sec is not enough
const prevMetricsRef = useRef({ prevOps: 0, prevTime: 0 });

View File

@@ -175,6 +175,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
// Virtual Scroll State
@@ -1036,13 +1037,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
}
setTreeData(origin => updateTreeData(origin, node.key, dbs));
if (dbs.length > 0) {
setTreeData(origin => updateTreeData(origin, node.key, dbs));
} else {
// 空列表:清理 loadedKeys 以允许重新加载,不设置 children = []
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.warning({ content: '未获取到可见数据库/schema请检查账号权限或右键刷新', key: `conn-${conn.id}-dbs` });
}
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-dbs` });
} finally {
loadingNodesRef.current.delete(loadKey);
@@ -1448,6 +1457,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false);
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false);
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false);
else if (type === 'object-group' && dataRef?.groupKey === 'tables') {
// 单击延迟打开表概览,双击时会取消此定时器
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
const { id, dbName: gDbName, schemaName } = dataRef;
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
addTab({
id: `table-overview-${id}-${gDbName}${schemaName ? `-${schemaName}` : ''}`,
title: `表概览 - ${gDbName}${schemaName ? ` (${schemaName})` : ''}`,
type: 'table-overview' as any,
connectionId: id,
dbName: gDbName,
schemaName,
} as any);
}, 250);
}
};
const onExpand = (newExpandedKeys: React.Key[]) => {
@@ -1456,7 +1481,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onDoubleClick = (e: any, node: any) => {
// 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态
// 双击时取消单击延迟动作(如表概览打开),让双击只触发展开/折叠
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
const { type, dataRef, key: nodeKey } = node;
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
@@ -1464,18 +1493,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
const { id, dbName, schemaName } = node.dataRef;
addTab({
id: `table-overview-${id}-${dbName}${schemaName ? `-${schemaName}` : ''}`,
title: `表概览 - ${dbName}${schemaName ? ` (${schemaName})` : ''}`,
type: 'table-overview' as any,
connectionId: id,
dbName,
schemaName,
} as any);
return;
}
if (node.type === 'table') {
const { tableName, dbName, id } = node.dataRef;
// 记录表访问
@@ -3082,7 +3099,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
onClick: () => {
const connKey = String(node.key);
// 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
// 清除 loadingNodesRef 中残留的子节点加载标记
Array.from(loadingNodesRef.current).forEach(lk => {
if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk);
});
loadDatabases(node);
}
},
{ type: 'divider' },
{
@@ -3199,7 +3226,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
onClick: () => {
const connKey = String(node.key);
// 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
// 清除 loadingNodesRef 中残留的子节点加载标记
Array.from(loadingNodesRef.current).forEach(lk => {
if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk);
});
loadDatabases(node);
}
},
{ type: 'divider' },
{

View File

@@ -217,14 +217,6 @@ const COMMON_DEFAULTS = [
{ value: "''" },
];
const MYSQL_INDEX_TYPE_OPTIONS = [
{ label: '默认', value: 'DEFAULT' },
{ label: 'BTREE', value: 'BTREE' },
{ label: 'HASH', value: 'HASH' },
{ label: 'FULLTEXT', value: 'FULLTEXT' },
{ label: 'SPATIAL', value: 'SPATIAL' },
{ label: 'RTREE', value: 'RTREE' },
];
const PGLIKE_INDEX_TYPE_OPTIONS = [
{ label: '默认', value: 'DEFAULT' },
@@ -1441,14 +1433,37 @@ ${selectedTrigger.statement}`;
];
};
const getIndexTypeOptions = () => {
const getIndexTypeOptions = (kind?: IndexKind) => {
const dbType = getDbType();
if (isMysqlLikeDialect(dbType)) return MYSQL_INDEX_TYPE_OPTIONS;
if (isPgLikeDialect(dbType)) return PGLIKE_INDEX_TYPE_OPTIONS;
const k = kind || 'NORMAL';
if (isMysqlLikeDialect(dbType)) {
// MySQL InnoDB: 所有索引均为固定方法类型
if (k === 'FULLTEXT') return [{ label: 'FULLTEXT', value: 'FULLTEXT' }];
if (k === 'SPATIAL') return [{ label: 'RTREE', value: 'RTREE' }];
return [{ label: 'BTREE', value: 'BTREE' }];
}
if (isPgLikeDialect(dbType)) {
if (k === 'PRIMARY' || k === 'UNIQUE') return [{ label: 'BTREE', value: 'BTREE' }];
return PGLIKE_INDEX_TYPE_OPTIONS;
}
if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS;
return [{ label: '默认', value: 'DEFAULT' }];
};
/** 根据索引类别返回固定的索引方法类型,可选类别返回 undefined */
const getFixedIndexType = (kind: IndexKind): string | undefined => {
const dbType = getDbType();
if (isMysqlLikeDialect(dbType)) {
if (kind === 'PRIMARY') return 'BTREE';
if (kind === 'FULLTEXT') return 'FULLTEXT';
if (kind === 'SPATIAL') return 'RTREE';
}
if (isPgLikeDialect(dbType)) {
if (kind === 'PRIMARY') return 'BTREE';
}
return undefined;
};
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``;
const colDefs = targetColumns.map(curr => {
@@ -2928,20 +2943,34 @@ END;`;
<Select
value={indexForm.kind}
options={getIndexKindOptions()}
onChange={(val: IndexKind) =>
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: val === 'NORMAL' || val === 'UNIQUE' ? (prev.indexType || 'DEFAULT') : 'DEFAULT',
}))
}
onChange={(val: IndexKind) => {
const fixedType = getFixedIndexType(val);
if (fixedType) {
// 固定类型PRIMARY/FULLTEXT/SPATIAL直接设置对应的索引方法
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: fixedType,
}));
} else {
const nextTypeOptions = getIndexTypeOptions(val);
const currentType = indexForm.indexType || 'DEFAULT';
const isCurrentTypeValid = nextTypeOptions.some(opt => opt.value === currentType);
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: isCurrentTypeValid ? currentType : 'DEFAULT',
}));
}
}}
style={{ width: 220 }}
/>
<Select
value={indexForm.indexType}
onChange={(val) => setIndexForm(prev => ({ ...prev, indexType: val }))}
options={getIndexTypeOptions()}
options={getIndexTypeOptions(indexForm.kind)}
style={{ width: 160 }}
disabled={indexForm.kind === 'PRIMARY' || indexForm.kind === 'FULLTEXT' || indexForm.kind === 'SPATIAL'}
/>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
@@ -22,6 +22,7 @@ interface TableStatRow {
type SortField = 'name' | 'rows' | 'dataSize';
type SortOrder = 'asc' | 'desc';
type ViewMode = 'card' | 'list';
const formatSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '—';
@@ -146,6 +147,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const [searchText, setSearchText] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('card');
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
@@ -366,14 +368,43 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</Dropdown>
<div style={{ display: 'flex', gap: 2, padding: 2, borderRadius: 6, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
<Tooltip title="卡片视图">
<div
onClick={() => setViewMode('card')}
style={{
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
background: viewMode === 'card' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
boxShadow: viewMode === 'card' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: viewMode === 'card' ? accentColor : textMuted,
}}
>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
<Tooltip title="列表视图">
<div
onClick={() => setViewMode('list')}
style={{
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
background: viewMode === 'list' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
boxShadow: viewMode === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: viewMode === 'list' ? accentColor : textMuted,
}}
>
<UnorderedListOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
</div>
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</div>
{/* Cards Grid */}
{/* Content Area */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : (
) : viewMode === 'card' ? (
/* ========== 卡片视图 ========== */
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
@@ -451,6 +482,115 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
</Dropdown>
))}
</div>
) : (
/* ========== 列表/表格视图 ========== */
<div style={{ borderRadius: 8, border: `1px solid ${cardBorder}`, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)' }}>
{[
{ field: 'name' as SortField, label: '表名', width: undefined },
{ field: null, label: '注释', width: undefined },
{ field: 'rows' as SortField, label: '行数', width: 100 },
{ field: 'dataSize' as SortField, label: '数据大小', width: 110 },
{ field: null, label: '索引大小', width: 110 },
{ field: null, label: '引擎', width: 90 },
].map((col, idx) => (
<th
key={idx}
onClick={col.field ? () => toggleSort(col.field!) : undefined}
style={{
padding: '10px 14px',
textAlign: idx >= 2 ? 'right' : 'left',
fontWeight: 600,
color: textSecondary,
borderBottom: `1px solid ${cardBorder}`,
cursor: col.field ? 'pointer' : 'default',
userSelect: 'none',
whiteSpace: 'nowrap',
width: col.width,
}}
>
{col.label}
{col.field && sortField === col.field && (
<span style={{ marginLeft: 4, fontSize: 11 }}>
{sortOrder === 'asc' ? '↑' : '↓'}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedFiltered.map((t, rowIdx) => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
});
}},
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
]},
],
}}
>
<tr
onDoubleClick={() => openTable(t.name)}
style={{
cursor: 'pointer',
transition: 'background 0.12s',
borderBottom: rowIdx < sortedFiltered.length - 1 ? `1px solid ${cardBorder}` : 'none',
}}
onMouseEnter={e => { (e.currentTarget as HTMLTableRowElement).style.background = cardHoverBg; }}
onMouseLeave={e => { (e.currentTarget as HTMLTableRowElement).style.background = 'transparent'; }}
>
<td style={{ padding: '10px 14px', color: textPrimary, fontWeight: 500 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.name}</span>
</Tooltip>
</div>
</td>
<td style={{ padding: '10px 14px', color: textSecondary, maxWidth: 260, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.comment ? (
<Tooltip title={t.comment} mouseEnterDelay={0.4}><span>{t.comment}</span></Tooltip>
) : (
<span style={{ color: textMuted }}></span>
)}
</td>
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', color: textMuted }}>{t.engine || '—'}</td>
</tr>
</Dropdown>
))}
</tbody>
</table>
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { buildCopyInsertSQL } from './dataGridCopyInsert';
describe('buildCopyInsertSQL', () => {
it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.OrderLog',
orderedCols: ['CreatedAt', 'note'],
record: {
CreatedAt: '2026-01-21T18:32:26+08:00',
note: "O'Brien",
},
columnTypesByLowerName: {
createdat: 'timestamp without time zone',
note: 'text',
},
});
expect(sql).toBe(
`INSERT INTO public."OrderLog" ("CreatedAt", note) VALUES ('2026-01-21 18:32:26', 'O''Brien');`,
);
});
it('keeps timezone offsets for timezone-aware PostgreSQL columns while still removing the T separator', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['created_at'],
record: {
created_at: '2026-01-21T18:32:26+08:00',
},
columnTypesByLowerName: {
created_at: 'timestamp with time zone',
},
});
expect(sql).toBe(
`INSERT INTO public.audit_log (created_at) VALUES ('2026-01-21 18:32:26+08:00');`,
);
});
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['payload'],
record: {
payload: '2026-01-21T18:32:26+08:00',
},
columnTypesByLowerName: {
payload: 'text',
},
});
expect(sql).toBe(
`INSERT INTO public.audit_log (payload) VALUES ('2026-01-21T18:32:26+08:00');`,
);
});
});

View File

@@ -0,0 +1,131 @@
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
type BuildCopyInsertSQLParams = {
dbType: string;
tableName?: string;
orderedCols: string[];
record: Record<string, any>;
columnTypesByLowerName?: Record<string, string>;
};
const looksLikeDateTimeText = (val: string): boolean => {
if (!val) return false;
const len = val.length;
if (len < 19 || len > 64) return false;
const charCode0 = val.charCodeAt(0);
if (charCode0 < 48 || charCode0 > 57) return false;
return (
val[4] === '-' &&
val[7] === '-' &&
(val[10] === ' ' || val[10] === 'T') &&
val[13] === ':' &&
val[16] === ':'
);
};
const normalizeDateTimeString = (val: string): string => {
if (!looksLikeDateTimeText(val)) {
return val;
}
if (/^0{4}-0{2}-0{2}/.test(val)) {
return val;
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
return match ? `${match[1]} ${match[2]}` : val;
};
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
if (!looksLikeDateTimeText(val)) {
return val;
}
if (/^0{4}-0{2}-0{2}/.test(val)) {
return val;
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
if (!match) {
return val;
}
const suffix = match[3] || '';
return `${match[1]} ${match[2]}${suffix}`;
};
const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp') || raw.includes('timestamptz')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'timetz' || base === 'year';
};
const isTimezoneAwareColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
return (
raw.includes('with time zone') ||
raw.includes('with timezone') ||
raw.includes('datetimeoffset') ||
raw.includes('timestamptz') ||
raw.includes('timetz')
);
};
export const normalizeTemporalLiteralText = (
value: string,
columnType?: string,
normalizeWhenTypeMissing = false,
): string => {
const rawType = String(columnType || '').trim();
if (!rawType) {
return normalizeWhenTypeMissing ? normalizeDateTimeString(value) : value;
}
if (!isTemporalColumnType(rawType)) {
return value;
}
return isTimezoneAwareColumnType(rawType)
? normalizeTimezoneAwareDateTimeString(value)
: normalizeDateTimeString(value);
};
export const formatLocalDateTimeLiteral = (value: Date): string => {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const hour = String(value.getHours()).padStart(2, '0');
const minute = String(value.getMinutes()).padStart(2, '0');
const second = String(value.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
};
export const buildCopyInsertSQL = ({
dbType,
tableName,
orderedCols,
record,
columnTypesByLowerName = {},
}: BuildCopyInsertSQLParams): string => {
const targetTable = quoteQualifiedIdent(dbType, tableName || 'table');
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
const values = orderedCols.map((col) => {
const value = record?.[col];
if (value === null || value === undefined) return 'NULL';
const columnType = columnTypesByLowerName[String(col || '').toLowerCase()];
const raw =
typeof value === 'string'
? normalizeTemporalLiteralText(value, columnType, true)
: value instanceof Date
? formatLocalDateTimeLiteral(value)
: String(value);
return `'${escapeLiteral(raw)}'`;
});
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
};

View File

@@ -3,6 +3,12 @@ import ReactDOM from 'react-dom/client'
import App from './App'
// import './index.css' // Optional global styles
// 全局配置 dayjs 使用中文 locale使 Ant Design 的 DatePicker/TimePicker 等组件
// 的月份、星期等文本显示为中文。必须在 Ant Design 组件渲染前完成配置。
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。

View File

@@ -574,7 +574,7 @@ func isDateTimeColumnType(columnType string) bool {
if typ == "" {
return false
}
return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp")
return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") || strings.Contains(typ, "timestamptz")
}
func isTimeOnlyColumnType(columnType string) bool {
@@ -585,7 +585,7 @@ func isTimeOnlyColumnType(columnType string) bool {
if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") {
return false
}
return strings.Contains(typ, "time")
return strings.Contains(typ, "time") || strings.Contains(typ, "timetz")
}
func isDateOnlyColumnType(dbType, columnType string) bool {
@@ -1717,6 +1717,10 @@ func dumpTableSQL(
if err != nil {
return err
}
columnTypeMap := map[string]string{}
if defs, colErr := dbInst.GetColumns(schemaName, pureTableName); colErr == nil {
columnTypeMap = buildImportColumnTypeMap(defs)
}
if len(data) == 0 {
if _, err := w.WriteString("-- (0 rows)\n"); err != nil {
return err
@@ -1733,7 +1737,7 @@ func dumpTableSQL(
for _, row := range data {
values := make([]string, 0, len(columns))
for _, c := range columns {
values = append(values, formatSQLValue(config.Type, row[c]))
values = append(values, formatImportSQLValue(config.Type, columnTypeMap[normalizeColumnName(c)], row[c]))
}
if _, err := w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", "))); err != nil {
return err

View File

@@ -273,3 +273,17 @@ func TestWriteRowsToFile_HTML_EscapeHeader(t *testing.T) {
t.Fatalf("html 表头未正确转义: %s", content)
}
}
func TestFormatImportSQLValue_NormalizesTimestampWithoutTimezone(t *testing.T) {
got := formatImportSQLValue("postgres", "timestamp without time zone", "2026-01-21T18:32:26+08:00")
if got != "'2026-01-21 18:32:26'" {
t.Fatalf("时间字面量归一化异常want=%q got=%q", "'2026-01-21 18:32:26'", got)
}
}
func TestFormatImportSQLValue_LeavesTextLiteralUntouched(t *testing.T) {
got := formatImportSQLValue("postgres", "text", "2026-01-21T18:32:26+08:00")
if got != "'2026-01-21T18:32:26+08:00'" {
t.Fatalf("文本字段不应被归一化want=%q got=%q", "'2026-01-21T18:32:26+08:00'", got)
}
}

View File

@@ -9,9 +9,9 @@ import (
)
var damengDatabaseQueries = []string{
// 优先使用达梦原生系统表
"SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME",
"SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME",
// 优先使用达梦原生系统表SYSDBA 保留:作为默认管理员 schema大多数用户在此创建业务表
"SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME",
"SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME",
// Oracle 兼容层
"SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS DATABASE_NAME FROM DUAL",
"SELECT SYS_CONTEXT('USERENV', 'CURRENT_USER') AS DATABASE_NAME FROM DUAL",
@@ -21,6 +21,8 @@ var damengDatabaseQueries = []string{
"SELECT USERNAME AS DATABASE_NAME FROM SYS.DBA_USERS ORDER BY USERNAME",
"SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_OBJECTS ORDER BY OWNER",
"SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_TABLES ORDER BY OWNER",
// 最终兜底:获取当前连接用户作为 schema 名称
"SELECT USER AS DATABASE_NAME FROM DUAL",
}
type damengQueryFunc func(query string) ([]map[string]interface{}, []string, error)

View File

@@ -71,3 +71,50 @@ func TestCollectDamengDatabaseNames_ReturnsErrorWhenNoNameResolved(t *testing.T)
t.Fatalf("错误不符合预期: %v", err)
}
}
// TestCollectDamengDatabaseNames_IncludesSYSDBA 验证 SYSDBA达梦默认管理员 schema
// 不会被系统 schema 过滤排除。
func TestCollectDamengDatabaseNames_IncludesSYSDBA(t *testing.T) {
t.Parallel()
got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) {
switch query {
case damengDatabaseQueries[0]:
// 查询 0 返回 SYSDBA之前会被排除修复后应该返回
return []map[string]interface{}{{"DATABASE_NAME": "SYSDBA"}}, nil, nil
default:
return nil, nil, errors.New("permission denied")
}
})
if err != nil {
t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err)
}
want := []string{"SYSDBA"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SYSDBA 应该包含在结果中, got=%v want=%v", got, want)
}
}
// TestCollectDamengDatabaseNames_FallbackToCurrentUser 验证当所有查询都失败时
// 兜底查询 SELECT USER FROM DUAL 能返回当前用户作为 schema。
func TestCollectDamengDatabaseNames_FallbackToCurrentUser(t *testing.T) {
t.Parallel()
lastQuery := damengDatabaseQueries[len(damengDatabaseQueries)-1]
got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) {
if query == lastQuery {
return []map[string]interface{}{{"DATABASE_NAME": "SYSDBA"}}, nil, nil
}
// 前面所有查询要么返回空要么报错
return []map[string]interface{}{}, nil, nil
})
if err != nil {
t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err)
}
want := []string{"SYSDBA"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("兜底查询应该返回当前用户, got=%v want=%v", got, want)
}
}

View File

@@ -215,7 +215,9 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
hostText := strings.Join(seeds, ",")
uri := fmt.Sprintf("%s://%s", scheme, hostText)
if config.User != "" {
noAuth := strings.EqualFold(strings.TrimSpace(config.MongoAuthMechanism), "NONE")
if config.User != "" && !noAuth {
var userinfo *url.Userinfo
if config.Password != "" {
userinfo = url.UserPassword(config.User, config.Password)
@@ -236,11 +238,14 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
params.Set("connectTimeoutMS", strconv.Itoa(timeout*1000))
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" {
authSource = "admin"
// 仅在有用户名且非 NONE 认证时设置 authSource
if config.User != "" && !noAuth {
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" {
authSource = "admin"
}
params.Set("authSource", authSource)
}
params.Set("authSource", authSource)
if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" {
params.Set("replicaSet", replicaSet)
@@ -248,7 +253,8 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
if readPreference := strings.TrimSpace(config.ReadPreference); readPreference != "" {
params.Set("readPreference", readPreference)
}
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" {
// NONE 表示无认证,不设置 authMechanism
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" && !noAuth {
params.Set("authMechanism", authMechanism)
}

View File

@@ -216,7 +216,9 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
hostText := strings.Join(seeds, ",")
uri := fmt.Sprintf("%s://%s", scheme, hostText)
if config.User != "" {
noAuth := strings.EqualFold(strings.TrimSpace(config.MongoAuthMechanism), "NONE")
if config.User != "" && !noAuth {
var userinfo *url.Userinfo
if config.Password != "" {
userinfo = url.UserPassword(config.User, config.Password)
@@ -237,11 +239,14 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
params.Set("connectTimeoutMS", strconv.Itoa(timeout*1000))
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" {
authSource = "admin"
// 仅在有用户名且非 NONE 认证时设置 authSource
if config.User != "" && !noAuth {
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" {
authSource = "admin"
}
params.Set("authSource", authSource)
}
params.Set("authSource", authSource)
if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" {
params.Set("replicaSet", replicaSet)
@@ -249,7 +254,8 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
if readPreference := strings.TrimSpace(config.ReadPreference); readPreference != "" {
params.Set("readPreference", readPreference)
}
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" {
// NONE 表示无认证,不设置 authMechanism
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" && !noAuth {
params.Set("authMechanism", authMechanism)
}

View File

@@ -166,6 +166,9 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName)
}
// 设置 search_path使所有用户 schema 下的表可以不带 schema 前缀访问
p.ensureSearchPath(dsn)
cleanupOnFailure = false
return nil
}
@@ -611,6 +614,101 @@ ORDER BY table_schema, table_name, ordinal_position`
return cols, nil
}
// ensureSearchPath 查询当前数据库中所有用户 schema通过重建连接池将 search_path 写入 DSN。
// 仅使用 SET search_path 只对连接池中的单个连接生效,后续查询可能拿到未设置的连接。
// 将 search_path 写入 DSN (lib/pq 支持任意 PostgreSQL runtime parameter)
// 使连接池中每个连接建立时自动携带 search_path与金仓行为一致。
func (p *PostgresDB) ensureSearchPath(baseDSN string) {
if p.conn == nil {
return
}
rawSchemas := p.queryUserSchemas()
if len(rawSchemas) == 0 {
return
}
// 构建 search_path SQL 片段(带双引号转义),用于 SET 兜底
searchPathSQL, normalizedSchemas := buildKingbaseSearchPathCommon(rawSchemas)
if strings.TrimSpace(searchPathSQL) == "" {
return
}
// 策略 1将 search_path 写入 DSN重建连接池
// lib/pq 支持在 URL 查参数中设置任意 PostgreSQL runtime parameter
// 如 ?search_path=ce,public每个新连接建立时会自动 SET search_path。
searchPathDSNVal := strings.Join(normalizedSchemas, ",")
u, parseErr := url.Parse(baseDSN)
if parseErr == nil {
q := u.Query()
q.Set("search_path", searchPathDSNVal)
u.RawQuery = q.Encode()
newDSN := u.String()
newDB, err := sql.Open("postgres", newDSN)
if err == nil {
newDB.SetConnMaxLifetime(5 * time.Minute)
oldConn := p.conn
p.conn = newDB
if err := p.Ping(); err == nil {
_ = oldConn.Close()
logger.Infof("PostgreSQL 已通过 DSN 配置 search_path%s", searchPathDSNVal)
return
}
// DSN 方式失败,回滚
_ = newDB.Close()
p.conn = oldConn
logger.Warnf("PostgreSQL DSN search_path 验证失败,回退至 SET 方式")
}
}
// 策略 2 兜底:通过 SET search_path 设置(仅影响单个连接,但聊胜于无)
timeout := p.pingTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
ctx, cancel := utils.ContextWithTimeout(timeout)
defer cancel()
if _, err := p.conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", searchPathSQL)); err != nil {
logger.Warnf("PostgreSQL 设置 search_path 失败:%v", err)
return
}
logger.Infof("PostgreSQL 已通过 SET 设置 search_path%s", searchPathSQL)
}
// queryUserSchemas 查询当前数据库中所有用户 schema。
func (p *PostgresDB) queryUserSchemas() []string {
if p.conn == nil {
return nil
}
query := `SELECT nspname FROM pg_namespace
WHERE nspname NOT IN ('pg_catalog', 'information_schema')
AND nspname NOT LIKE 'pg_%'
ORDER BY nspname`
rows, err := p.conn.Query(query)
if err != nil {
logger.Warnf("PostgreSQL 查询用户 schema 失败:%v", err)
return nil
}
defer rows.Close()
var schemas []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
continue
}
name = strings.TrimSpace(name)
if name != "" {
schemas = append(schemas, name)
}
}
return schemas
}
func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if p.conn == nil {
return fmt.Errorf("连接未打开")

View File

@@ -21,6 +21,7 @@ type PreviewUpdateRow struct {
type TableDiffPreview struct {
Table string `json:"table"`
PKColumn string `json:"pkColumn"`
ColumnTypes map[string]string `json:"columnTypes,omitempty"`
TotalInserts int `json:"totalInserts"`
TotalUpdates int `json:"totalUpdates"`
TotalDeletes int `json:"totalDeletes"`
@@ -112,6 +113,7 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
out := TableDiffPreview{
Table: tableName,
PKColumn: pkCol,
ColumnTypes: make(map[string]string, len(cols)),
TotalInserts: 0,
TotalUpdates: 0,
TotalDeletes: 0,
@@ -119,6 +121,14 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
Updates: make([]PreviewUpdateRow, 0),
Deletes: make([]PreviewRow, 0),
}
for _, col := range cols {
name := strings.ToLower(strings.TrimSpace(col.Name))
typ := strings.TrimSpace(col.Type)
if name == "" || typ == "" {
continue
}
out.ColumnTypes[name] = typ
}
sourcePKSet := make(map[string]struct{}, len(sourceRows))
for _, sRow := range sourceRows {