Compare commits

...

30 Commits

Author SHA1 Message Date
Syngnat
9685102229 feat(sidebar-batch-table): 批量操作表新增对象筛选与作用范围控制
- 批量操作表弹窗新增关键字筛选(忽略大小写包含匹配)
- 新增类型筛选(全部对象/仅表/仅视图)
- 新增勾选作用范围切换(当前筛选结果/全部对象)
- 全选、取消全选、反选逻辑按作用范围执行
- 筛选区域展示命中计数与无匹配空态提示
- refs #130
2026-02-27 14:21:14 +08:00
Syngnat
3505b4428a Merge remote-tracking branch 'origin/fix/windows-issue-20260226-ygf' into fix/windows-issue-20260226-ygf 2026-02-27 13:57:04 +08:00
Syngnat
9ebdf7f053 feat(appearance): 新增启动时全屏开关并支持启动窗口状态自动应用
- 在外观设置中提供用户可控的启动全屏偏好项
- 持久化保存用户偏好,重启后自动恢复
- 启动阶段按偏好自动执行全屏,失败时回退最大化
- 保持现有标题栏窗口操作行为不变
- refs #129
2026-02-27 13:56:36 +08:00
Syngnat
9ad852c10b 🐛 fix(redis-viewer): 修复大数据量场景 Key 加载不完整问题
- 后端 ScanKeys 改为按目标数量多轮聚合扫描,不再只依赖单轮返回结果
- 新增扫描目标数/步长/轮次上限,避免扫描过少或无限循环
- 前端首屏加载、搜索、刷新统一按较大批次请求,避免回退到几百条
- 加载更多改为按固定批次继续拉取并保持去重合并
- refs #129
2026-02-27 13:56:36 +08:00
Syngnat
2a8fff4d93 feat(driver-manager): 增强驱动管理网络诊断与本地导入能力
- 新增 CheckDriverNetworkStatus,探测 GitHub API/Release/Go Proxy 可达性并返回代理环境信息。
- 驱动管理弹窗新增网络检测结果、驱动目录复用说明、本地导入入口与日志查看。
- 操作日志支持同签名进度覆盖更新,下载百分比动态刷新,不再重复新增日志行。
- 修正弹窗滚动行为与表格滚动体验。
- refs #128
2026-02-27 13:56:36 +08:00
Syngnat
eca560b4e5 🐛 fix(data-grid): 修复单元格编辑器拖拽越界不自动滚动
- 在 DataGrid 拖拽选区流程新增边缘自动滚动能力(横向+纵向)
- 拖拽过程中增加鼠标位置跟踪并通过 RAF 循环驱动滚动
- 通过 elementFromPoint 兜底命中单元格,保证越界拖拽时选区持续更新
- 在 mouseup、模式切换和退出编辑器时统一清理 RAF 与拖拽状态
- refs #127
2026-02-27 13:56:36 +08:00
Syngnat
2f475dddc0 🐛 fix(windows-upgrade): 修复Windows升级后连接列表丢失问题
- 启动参数新增固定 WebviewUserDataPath 到 %APPDATA%/GoNavi/WebView2
- 首次启动自动迁移历史 WebView 数据目录
- 保留现有存储键,避免破坏已落盘配置
- 前端持久化读取增加历史结构兼容
- refs #125
2026-02-27 13:56:35 +08:00
Syngnat
ad9d8a12be feat(appearance): 新增启动时全屏开关并支持启动窗口状态自动应用
- 在外观设置中提供用户可控的启动全屏偏好项
- 持久化保存用户偏好,重启后自动恢复
- 启动阶段按偏好自动执行全屏,失败时回退最大化
- 保持现有标题栏窗口操作行为不变
- refs #129
2026-02-27 13:55:37 +08:00
Syngnat
095b22951e 🐛 fix(redis-viewer): 修复大数据量场景 Key 加载不完整问题
- 后端 ScanKeys 改为按目标数量多轮聚合扫描,不再只依赖单轮返回结果
- 新增扫描目标数/步长/轮次上限,避免扫描过少或无限循环
- 前端首屏加载、搜索、刷新统一按较大批次请求,避免回退到几百条
- 加载更多改为按固定批次继续拉取并保持去重合并
- refs #129
2026-02-27 13:26:28 +08:00
Syngnat
7350a011e3 feat(driver-manager): 增强驱动管理网络诊断与本地导入能力
- 新增 CheckDriverNetworkStatus,探测 GitHub API/Release/Go Proxy 可达性并返回代理环境信息。
- 驱动管理弹窗新增网络检测结果、驱动目录复用说明、本地导入入口与日志查看。
- 操作日志支持同签名进度覆盖更新,下载百分比动态刷新,不再重复新增日志行。
- 修正弹窗滚动行为与表格滚动体验。
- refs #128
2026-02-27 12:29:54 +08:00
Syngnat
53b5802add 🐛 fix(data-grid): 修复单元格编辑器拖拽越界不自动滚动
- 在 DataGrid 拖拽选区流程新增边缘自动滚动能力(横向+纵向)
- 拖拽过程中增加鼠标位置跟踪并通过 RAF 循环驱动滚动
- 通过 elementFromPoint 兜底命中单元格,保证越界拖拽时选区持续更新
- 在 mouseup、模式切换和退出编辑器时统一清理 RAF 与拖拽状态
- refs #127
2026-02-27 10:57:05 +08:00
Syngnat
54e7077317 🐛 fix(windows-upgrade): 修复Windows升级后连接列表丢失问题
- 启动参数新增固定 WebviewUserDataPath 到 %APPDATA%/GoNavi/WebView2
- 首次启动自动迁移历史 WebView 数据目录
- 保留现有存储键,避免破坏已落盘配置
- 前端持久化读取增加历史结构兼容
2026-02-27 10:45:57 +08:00
Syngnat
96de46cf1e 🐛 fix(postgres-connection): 修复无postgres库时连接失败并支持默认连接库配置
- PostgreSQL 空 database 时按 postgres、template1、用户名同名库回退连接
- 移除后端对 database=postgres 的硬编码写死逻辑
- 连接弹窗新增 PostgreSQL 默认连接数据库(可选)配置项
- refs #120
2026-02-27 09:49:47 +08:00
Syngnat
7d5592d8d9 feat(db): 数据库连接新增 SOCKS5/HTTP 代理能力并兼容 SRV/SSH 场景
- 后端 ConnectionConfig 增加代理配置并完成规范化处理
- 普通 TCP 数据源通过本地转发接入代理
- MongoDB 使用 Dialer 支持代理连接(含 SRV)
- 前端连接配置新增代理 UI、字段清洗与数据回填
- refs #122
2026-02-27 09:31:24 +08:00
Syngnat
d0ba8822f3 feat(driver-manager): 完善驱动多版本安装与版本级包大小动态展示
- 新增驱动版本列表能力,支持按版本选择安装
- 新增按版本查询安装包大小接口,前端切换版本后动态刷新
- 增加版本大小查询回退策略(tag 未命中时回退 latest)
- 优化版本下拉加载链路并增加后台预热,降低首次展开等待
2026-02-27 08:37:35 +08:00
Syngnat
140db73ef4 🐛 fix(startup-release): 修复 Win/mac 发布包白屏与无响应问题
- 移除 v0.4.7 引入的高风险 chunk 拆分配置
- 恢复 main.tsx 的 Monaco 稳定初始化方式
- 调整 release workflow 的 macOS codesign 参数避免双击无反应
2026-02-26 15:21:36 +08:00
Syngnat
bec5013a44 🐛 fix(update-windows): 修复自动更新脚本变量转义导致TARGET语法错误
- 将 buildWindowsScript 改为模板占位符替换,避免 fmt.Sprintf 吞掉批处理百分号
- 修正 for %%I/%%F 语法,消除“此时不应有 TARGET~nxI”报错
- 保留原有更新重试与日志流程,不改变下载与安装主链路
- refs #112
2026-02-26 14:23:36 +08:00
Syngnat
66a3113fa8 🐛 fix(datagrid-mysql): 修复MySQL行编辑时datetime空值提交失败
- 前端按列类型归一化 temporal 字段,INSERT 空值跳过字段、UPDATE 空值转 NULL
- 后端 ApplyChanges 增加 temporal 字段兜底,避免空字符串写入 datetime/timestamp
- 新增全默认值插入路径,兼容 CURRENT_TIMESTAMP 等默认值场景
- refs #113
2026-02-26 14:13:27 +08:00
Syngnat
a435d62d3b feat(connection-modal): 新增SSH私钥文件浏览选择能力
- 新增私钥文件选择入口,减少手动输入路径错误
- 复用系统文件对话框并自动回填私钥路径
- 保留手动输入作为兜底方式
- refs #119
2026-02-26 13:57:50 +08:00
Syngnat
50d92d3184 🐛 fix(backup-export): 修复批量备份未区分视图与表导致导出失败
- 批量操作弹窗按“表/视图”分组展示并支持混合勾选
- 批量导出改为对象集合传参,统一结构/数据导出入口
- SQL 导出链路新增视图识别与排序,避免将视图当表处理
- 增加多方言视图 DDL 查询与回退逻辑,规避 create statement not found
- 视图数据导出阶段自动跳过并追加说明注释
- refs #117
2026-02-26 13:45:17 +08:00
Syngnat
91658848c9 🔧 fix(frontend): 修复表设计能力门禁并优化构建分包策略
- 修复触发器分组进入设计页时误设只读,恢复索引/外键页增删改按钮显示
  - 重构 TableDesigner 数据源方言识别,移除 MySQL 与固定方言白名单硬限制
  - 按能力控制索引/外键/表备注编辑入口,并补充多方言 DDL 生成与通用兜底
  - 收敛已知不支持场景:sqlite/duckdb/tdengine 禁用外键编辑,sqlite 禁用表备注编辑
  - Monaco 改为按需 worker(editor/json)并补齐 vite 类型声明,避免构建类型报错
  - 细化 Vite manualChunks(antd/monaco 子模块拆分),消除 >500k chunk 告警
  - refs #115
2026-02-26 12:08:07 +08:00
Syngnat
fda30539b6 🐛 fix(highgo): 修复海量数据源复制表结构仅返回注释
- 识别 HighGo 占位建表语句
- 通过 GetColumns 生成包含字段与主键的建表SQL
- 避免右键复制表结构出现空字段
- refs #99
2026-02-14 15:45:02 +08:00
Syngnat
1ba68fcbfe 🐛 fix(release): 修复 Debian 13 Linux 产物 WebKitGTK 依赖不兼容
- Linux Release 增加 WebKitGTK 4.1 变体(-WebKit41),保留 4.0 产物
- CI 按 WebKit 版本安装依赖,并为 Wails 注入 webkit2_41 构建标签
- 完善驱动代理可执行文件路径校验错误提示(区分不存在/目录)
- README 补充 Linux 依赖排障与产物选择说明
- refs #98
2026-02-14 15:17:03 +08:00
Syngnat
f0e1c7e72c 🔧 fix(driver-agent): 修复 Windows 启动驱动代理弹出终端窗口
- 为 Windows 新增 agent 进程启动参数(HideWindow + CREATE_NO_WINDOW)
- optional driver agent 启动路径统一应用进程隐藏配置
- MySQL agent 启动路径同步应用进程隐藏配置
2026-02-14 15:01:29 +08:00
Syngnat
663717d738 ♻️ refactor(driver-delivery): 重构可选驱动分发为总包+索引模式
- 工作流统一收敛驱动产物并打包单一压缩包
- 新增驱动总包索引读取与缓存合并逻辑
- 保留原单文件直链兼容并增加总包提取回退
2026-02-14 11:45:51 +08:00
Syngnat
5329f212f7 feat(schema-editor): 表设计器新增索引/外键管理能力并支持表备注修改
- 支持新增/修改/删除索引与外键(MySQL)
- 表备注弹窗编辑并同步刷新 DDL/元数据
- 索引类型补齐 UNIQUE/PRIMARY/FULLTEXT/SPATIAL 等
- refs #108
2026-02-14 11:25:13 +08:00
Syngnat
d6e967a0d0 feat(table-designer): 支持字段注释弹框编辑并恢复DDL常显
- 注释列新增双击与按钮触发的弹框编辑能力
- 增加长文本注释编辑弹窗并支持直接回写字段定义
- 非新建表场景统一拉取并展示 DDL 标签页
- 优化注释只读态展示,补充悬浮完整内容
- refs #105
2026-02-14 10:36:54 +08:00
Syngnat
7ca2d20c17 feat(datagrid): 增强列头字段信息展示并优化排序与右键菜单交互
- 新增列头类型/备注常驻显示与悬浮详情展示
- 新增字段信息开关并持久化 showColumnComment/showColumnType 配置
- 排序改为仅箭头区域可触发,排序提示仅显示在排序图标上
- 修复可编辑表中右键菜单重复弹出与透明重影问题
- refs #106
2026-02-14 10:30:01 +08:00
Syngnat
9307ca5e16 feat(table-designer): 支持勾选字段并一键复制到新表
- 设计表字段列表增加多选能力,支持按行勾选字段
- 工具栏新增“复制选中到新表”按钮与交互
- 新增目标表配置弹窗,支持表名、字符集、排序规则设置
- 复用建表 SQL 生成逻辑并直接执行创建新表
- refs #107
2026-02-14 09:57:47 +08:00
Syngnat
60a42e3c34 🔧 fix(connection-modal): 修复 SQLite 连接配置回填导致路径变形问题
- ConnectionModal 中 sqlite 使用独立路径规则,不再参与 host:port 解析
- 修复编辑连接时的回填逻辑,阻断 F:\... 被追加 :3306
- 统一 URI 解析与生成行为,确保保存后再次编辑不变形
- 保留并强化驱动安装态判断与现有交互
2026-02-14 09:51:17 +08:00
42 changed files with 7029 additions and 506 deletions

View File

@@ -22,26 +22,56 @@ jobs:
os_name: MacOS
arch_name: Amd64
build_name: gonavi-build-darwin-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: macos-latest
platform: darwin/arm64
os_name: MacOS
arch_name: Arm64
build_name: gonavi-build-darwin-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/amd64
os_name: Windows
arch_name: Amd64
build_name: gonavi-build-windows-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/arm64
os_name: Windows
arch_name: Arm64
build_name: gonavi-build-windows-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: ubuntu-22.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-build-linux-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
# Debian 13 (trixie) 默认仓库已切到 WebKitGTK 4.1:单独提供 4.1 变体产物
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-build-linux-amd64-webkit41
wails_tags: "webkit2_41"
artifact_suffix: "-WebKit41"
build_optional_agents: false
linux_webkit: "4.1"
steps:
- name: Checkout code
@@ -63,7 +93,17 @@ jobs:
if: contains(matrix.platform, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libfuse2
sudo apt-get install -y libgtk-3-dev
# WebKitGTK 4.1 需要 libsoup34.0 使用 libsoup2通常由 webkit2gtk dev 包拉起)
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
else
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
# Download linuxdeploy tools for AppImage packaging
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
@@ -94,9 +134,15 @@ jobs:
- name: Build
shell: bash
run: |
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
set -euo pipefail
TAG_ARGS=()
if [ -n "${{ matrix.wails_tags }}" ]; then
TAG_ARGS+=(-tags "${{ matrix.wails_tags }}")
fi
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} "${TAG_ARGS[@]}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents }}
shell: bash
run: |
set -euo pipefail
@@ -104,6 +150,8 @@ jobs:
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
for DRIVER in "${DRIVERS[@]}"; do
TAG="gonavi_${DRIVER}_driver"
@@ -111,20 +159,21 @@ jobs:
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
echo "🔧 构建 ${OUTPUT} (tag=${TAG})"
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
if [ "$DRIVER" = "duckdb" ]; then
set +e
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT}" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
DUCKDB_RC=$?
set -e
if [ "${DUCKDB_RC}" -ne 0 ]; then
echo "⚠️ DuckDB 代理构建失败(平台 ${GOOS}/${GOARCH}),跳过该资产,不阻断发布"
rm -f "${OUTPUT}"
rm -f "${OUTPUT_PATH}"
continue
fi
else
@@ -132,7 +181,7 @@ jobs:
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT}" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
fi
done
@@ -154,10 +203,12 @@ jobs:
APP_NAME=$(basename "$APP_PATH")
echo "🔏 正在进行 Ad-hoc 签名..."
codesign --force --options runtime --deep --sign - "$APP_NAME"
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.build_name }}.dmg"
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}.dmg"
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
echo "📦 正在生成 DMG: $DMG_NAME..."
create-dmg \
@@ -184,8 +235,8 @@ jobs:
$version = $version.Substring(1)
}
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}.exe"
$finalZipName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}.zip"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
$finalZipName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.zip"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
@@ -211,8 +262,8 @@ jobs:
VERSION="${VERSION#v}"
cd build/bin
TARGET="${{ matrix.build_name }}"
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}.tar.gz"
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}.AppImage"
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz"
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage"
if [ ! -f "$TARGET" ]; then
echo "❌ 未找到构建产物 '$TARGET'!"
@@ -295,7 +346,7 @@ jobs:
GoNavi-*.zip
GoNavi-*.tar.gz
GoNavi-*.AppImage
*-driver-agent-*
drivers/**
retention-days: 1
# Phase 2: Collect all artifacts and Publish Release (Single Job)
@@ -314,6 +365,59 @@ jobs:
- name: List Assets
run: ls -R release-assets
- name: Package Driver Agents Bundle
shell: bash
run: |
set -euo pipefail
cd release-assets
if [ ! -d drivers ]; then
echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包"
exit 0
fi
if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then
echo "⚠️ drivers 目录为空,跳过驱动总包打包"
rm -rf drivers
exit 0
fi
echo "📦 打包驱动总包GoNavi-DriverAgents.zip"
python3 - <<'PY'
import json
import os
import zipfile
from pathlib import Path
out_name = "GoNavi-DriverAgents.zip"
index_name = "GoNavi-DriverAgents-Index.json"
base = Path("drivers")
out_path = Path(out_name)
index_path = Path(index_name)
if out_path.exists():
out_path.unlink()
if index_path.exists():
index_path.unlink()
size_index = {}
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in base.rglob("*"):
if not p.is_file():
continue
arcname = p.relative_to(base).as_posix()
zf.write(p, arcname)
size_index[p.name] = p.stat().st_size
index_path.write_text(
json.dumps({"assets": size_index}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"created {out_name} size={out_path.stat().st_size} bytes")
print(f"created {index_name} entries={len(size_index)}")
PY
# Release 只发布一个驱动总包,避免大量平铺资产污染 Release 页面
rm -rf drivers
- name: Generate SHA256SUMS
shell: bash
run: |

View File

@@ -129,6 +129,7 @@ wails build -clean
支持构建:
* macOS (AMD64 / ARM64)
* Windows (AMD64)
* Linux (AMD64提供 WebKitGTK 4.0 与 4.1 变体产物)
---
@@ -146,6 +147,27 @@ wails build -clean
```
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
### Linux 启动报错缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
GoNavi 的 Linux 二进制依赖系统 WebKitGTK 运行库。不同发行版默认版本不同:
- Debian 13 / Ubuntu 24.04 及更新版本:通常为 WebKitGTK 4.1
- Ubuntu 22.04 / Debian 12 等:通常为 WebKitGTK 4.0
如果启动时报错(如 `libwebkit2gtk-4.0.so.37: cannot open shared object file`),请按系统安装对应依赖后重试:
```bash
# Debian 13 / Ubuntu 24.04+
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0
# Ubuntu 22.04 / Debian 12
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
```
如果你使用的是 Release 中带 `-WebKit41` 后缀的 Linux 产物,请优先在 Debian 13 / Ubuntu 24.04+ 上使用;普通 Linux 产物更适合 WebKitGTK 4.0 运行环境。
---
## 🤝 贡献指南

View File

@@ -3,79 +3,79 @@
"drivers": {
"mariadb": {
"engine": "go",
"version": "go-embedded",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mariadb"
},
"diros": {
"engine": "go",
"version": "go-embedded",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/diros"
},
"sphinx": {
"engine": "go",
"version": "go-embedded",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sphinx"
},
"sqlserver": {
"engine": "go",
"version": "go-embedded",
"version": "1.9.6",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sqlserver"
},
"sqlite": {
"engine": "go",
"version": "go-embedded",
"version": "1.44.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sqlite"
},
"duckdb": {
"engine": "go",
"version": "go-embedded",
"version": "2.5.5",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/duckdb"
},
"dameng": {
"engine": "go",
"version": "go-embedded",
"version": "1.8.22",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/dameng"
},
"kingbase": {
"engine": "go",
"version": "go-embedded",
"version": "0.0.0-20201021123113-29bd62a876c3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/kingbase"
},
"highgo": {
"engine": "go",
"version": "go-embedded",
"version": "0.0.0-local",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/highgo"
},
"vastbase": {
"engine": "go",
"version": "go-embedded",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/vastbase"
},
"mongodb": {
"engine": "go",
"version": "go-embedded",
"version": "2.5.0",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mongodb"
},
"tdengine": {
"engine": "go",
"version": "go-embedded",
"version": "3.7.8",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/tdengine"
},
"postgres": {
"engine": "go",
"version": "go-embedded",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/postgres"
}

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress } from 'antd';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
import { Environment, EventsOn } from '../wailsjs/runtime/runtime';
import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
@@ -26,6 +26,8 @@ function App() {
const setTheme = useStore(state => state.setTheme);
const appearance = useStore(state => state.appearance);
const setAppearance = useStore(state => state.setAppearance);
const startupFullscreen = useStore(state => state.startupFullscreen);
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
const darkMode = themeMode === 'dark';
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
@@ -56,6 +58,84 @@ function App() {
};
}, []);
useEffect(() => {
let cancelled = false;
let startupWindowTimer: number | null = null;
const maxApplyAttempts = 6;
const applyRetryDelayMs = 400;
const settleDelayMs = 160;
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
try {
if (await WindowIsFullscreen()) {
return true;
}
} catch (_) {
// ignore
}
try {
if (await WindowIsMaximised()) {
return true;
}
} catch (_) {
// ignore
}
return false;
};
const applyStartupWindowPreference = (attempt: number) => {
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
startupWindowTimer = window.setTimeout(() => {
if (cancelled) {
return;
}
if (!useStore.getState().startupFullscreen) {
return;
}
Promise.resolve()
.then(async () => {
if (await checkStartupPreferenceApplied()) {
return;
}
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
if (attempt < maxApplyAttempts) {
applyStartupWindowPreference(attempt + 1);
}
});
}, 300);
};
if (useStore.persist.hasHydrated()) {
applyStartupWindowPreference(1);
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
if (cancelled) {
return;
}
applyStartupWindowPreference(1);
});
return () => {
cancelled = true;
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
unsubscribeHydration();
};
}, []);
// Background Helper
const getBg = (darkHex: string, lightHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
@@ -915,6 +995,16 @@ function App() {
</>
)}
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span></span>
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
*
</div>
</div>
</div>
</Modal>

View File

@@ -2,8 +2,8 @@ import React, { useState, useEffect, useRef } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import { useStore } from '../store';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect } from '../../wailsjs/go/app/App';
import { MongoMemberInfo, SavedConnection } from '../types';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
const { Meta } = Card;
const { Text } = Typography;
@@ -27,6 +27,7 @@ const getDefaultPortByType = (type: string) => {
case 'highgo': return 5866;
case 'mariadb': return 3306;
case 'vastbase': return 5432;
case 'sqlite': return 0;
case 'duckdb': return 0;
default: return 3306;
}
@@ -57,6 +58,7 @@ const ConnectionModal: React.FC<{
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [useSSH, setUseSSH] = useState(false);
const [useProxy, setUseProxy] = useState(false);
const [dbType, setDbType] = useState('mysql');
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
@@ -70,6 +72,7 @@ const ConnectionModal: React.FC<{
const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null);
const [driverStatusMap, setDriverStatusMap] = useState<Record<string, DriverStatusSnapshot>>({});
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(null);
const addConnection = useStore((state) => state.addConnection);
@@ -236,6 +239,23 @@ const ConnectionModal: React.FC<{
}
};
const normalizeFileDbPath = (rawPath: string): string => {
let pathText = String(rawPath || '').trim();
if (!pathText) {
return '';
}
// 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。
if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) {
pathText = pathText.slice(1);
}
// 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。
const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/);
if (legacyMatch?.[1]) {
return legacyMatch[1];
}
return pathText;
};
const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
const prefix = `${expectedScheme}://`;
if (!uriText.toLowerCase().startsWith(prefix)) {
@@ -335,30 +355,6 @@ const ConnectionModal: React.FC<{
}
if (isFileDatabaseType(type)) {
const tryExtractPath = (uri: string, scheme: string): string | null => {
const parsed = parseMultiHostUri(uri, scheme);
if (!parsed) {
return null;
}
const host = String(parsed.hosts?.[0] || '').trim();
const dbPath = String(parsed.database || '').trim();
if (host && dbPath) {
return `/${host}/${dbPath}`.replace(/\/+/g, '/');
}
if (host) {
return `/${host}`.replace(/\/+/g, '/');
}
if (dbPath) {
return dbPath.startsWith('/') ? dbPath : `/${dbPath}`;
}
return null;
};
const pathFromScheme = tryExtractPath(trimmedUri, type);
if (pathFromScheme) {
return { host: decodeURIComponent(pathFromScheme) };
}
const rawPath = trimmedUri
.replace(/^sqlite:\/\//i, '')
.replace(/^duckdb:\/\//i, '')
@@ -366,7 +362,7 @@ const ConnectionModal: React.FC<{
if (!rawPath) {
return null;
}
return { host: decodeURIComponent(rawPath) };
return { host: normalizeFileDbPath(safeDecode(rawPath)) };
}
if (type === 'mongodb') {
@@ -481,12 +477,11 @@ const ConnectionModal: React.FC<{
}
if (isFileDatabaseType(type)) {
const pathText = String(values.host || '').trim();
const pathText = normalizeFileDbPath(String(values.host || '').trim());
if (!pathText) {
return `${type}://`;
}
const normalizedPath = pathText.startsWith('/') ? pathText : `/${pathText}`;
return `${type}://${encodeURI(normalizedPath)}`;
return `${type}://${encodeURI(pathText)}`;
}
if (type === 'mongodb') {
@@ -585,6 +580,30 @@ const ConnectionModal: React.FC<{
}
};
const handleSelectSSHKeyFile = async () => {
if (selectingSSHKey) {
return;
}
try {
setSelectingSSHKey(true);
const currentPath = String(form.getFieldValue('sshKeyPath') || '').trim();
const res = await SelectSSHKeyFile(currentPath);
if (res?.success) {
const data = res.data || {};
const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim();
if (selectedPath) {
form.setFieldValue('sshKeyPath', selectedPath);
}
} else if (res?.message !== 'Cancelled') {
message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`);
}
} catch (e: any) {
message.error(`选择私钥文件失败: ${e?.message || String(e)}`);
} finally {
setSelectingSSHKey(false);
}
};
useEffect(() => {
if (open) {
setTestResult(null); // Reset test result
@@ -602,13 +621,20 @@ const ConnectionModal: React.FC<{
const config: any = initialValues.config || {};
const configType = String(config.type || 'mysql');
const defaultPort = getDefaultPortByType(configType);
const normalizedHosts = normalizeAddressList(config.hosts, defaultPort);
const primaryAddress = parseHostPort(
normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort),
defaultPort
);
const primaryHost = primaryAddress?.host || String(config.host || 'localhost');
const primaryPort = primaryAddress?.port || Number(config.port || defaultPort);
const isFileDbConfigType = isFileDatabaseType(configType);
const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort);
const primaryAddress = isFileDbConfigType
? null
: parseHostPort(
normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort),
defaultPort
);
const primaryHost = isFileDbConfigType
? normalizeFileDbPath(String(config.host || ''))
: (primaryAddress?.host || String(config.host || 'localhost'));
const primaryPort = isFileDbConfigType
? 0
: (primaryAddress?.port || Number(config.port || defaultPort));
const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : [];
const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : [];
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
@@ -630,6 +656,12 @@ const ConnectionModal: React.FC<{
sshUser: config.ssh?.user,
sshPassword: config.ssh?.password,
sshKeyPath: config.ssh?.keyPath,
useProxy: config.useProxy,
proxyType: config.proxy?.type || 'socks5',
proxyHost: config.proxy?.host,
proxyPort: config.proxy?.port,
proxyUser: config.proxy?.user,
proxyPassword: config.proxy?.password,
driver: config.driver,
dsn: config.dsn,
timeout: config.timeout || 30,
@@ -649,6 +681,7 @@ const ConnectionModal: React.FC<{
mongoReplicaPassword: config.mongoReplicaPassword || ''
});
setUseSSH(config.useSSH || false);
setUseProxy(config.useProxy || false);
setDbType(configType);
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
if (configType === 'redis') {
@@ -659,6 +692,7 @@ const ConnectionModal: React.FC<{
setStep(1);
form.resetFields();
setUseSSH(false);
setUseProxy(false);
setDbType('mysql');
setActiveGroup(0);
}
@@ -708,6 +742,7 @@ const ConnectionModal: React.FC<{
setLoading(false);
form.resetFields();
setUseSSH(false);
setUseProxy(false);
setDbType('mysql');
setStep(1);
onClose();
@@ -827,7 +862,7 @@ const ConnectionModal: React.FC<{
}
};
const buildConfig = async (values: any, forPersist: boolean) => {
const buildConfig = async (values: any, forPersist: boolean): Promise<ConnectionConfig> => {
const mergedValues = { ...values };
const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type);
const isEmptyField = (value: unknown) => (
@@ -847,12 +882,22 @@ const ConnectionModal: React.FC<{
const type = String(mergedValues.type || '').toLowerCase();
const defaultPort = getDefaultPortByType(type);
const parsedPrimary = parseHostPort(
toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort),
defaultPort
);
const primaryHost = parsedPrimary?.host || 'localhost';
const primaryPort = parsedPrimary?.port || defaultPort;
const isFileDbType = isFileDatabaseType(type);
let primaryHost = 'localhost';
let primaryPort = defaultPort;
if (isFileDbType) {
// 文件型数据库sqlite/duckdb这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。
primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim());
primaryPort = 0;
} else {
const parsedPrimary = parseHostPort(
toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort),
defaultPort
);
primaryHost = parsedPrimary?.host || 'localhost';
primaryPort = parsedPrimary?.port || defaultPort;
}
let hosts: string[] = [];
let topology: 'single' | 'replica' | undefined;
@@ -916,6 +961,22 @@ const ConnectionModal: React.FC<{
password: mergedValues.sshPassword || "",
keyPath: mergedValues.sshKeyPath || ""
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy;
const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase();
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
const proxyConfig: NonNullable<ConnectionConfig['proxy']> = effectiveUseProxy ? {
type: proxyType,
host: String(mergedValues.proxyHost || '').trim(),
port: Number(mergedValues.proxyPort || (proxyTypeRaw === 'http' ? 8080 : 1080)),
user: String(mergedValues.proxyUser || '').trim(),
password: mergedValues.proxyPassword || "",
} : {
type: 'socks5',
host: '',
port: 1080,
user: '',
password: '',
};
const keepPassword = !forPersist || savePassword;
@@ -929,6 +990,8 @@ const ConnectionModal: React.FC<{
database: mergedValues.database || "",
useSSH: !!mergedValues.useSSH,
ssh: sshConfig,
useProxy: effectiveUseProxy,
proxy: proxyConfig,
driver: mergedValues.driver,
dsn: mergedValues.dsn,
timeout: Number(mergedValues.timeout || 30),
@@ -960,8 +1023,45 @@ const ConnectionModal: React.FC<{
form.setFieldsValue({ type: type });
const defaultPort = getDefaultPortByType(type);
if (!isFileDatabaseType(type) && type !== 'custom') {
if (isFileDatabaseType(type)) {
setUseSSH(false);
setUseProxy(false);
form.setFieldsValue({
host: '',
port: 0,
user: '',
password: '',
database: '',
useSSH: false,
sshHost: '',
sshPort: 22,
sshUser: '',
sshPassword: '',
sshKeyPath: '',
useProxy: false,
proxyType: 'socks5',
proxyHost: '',
proxyPort: 1080,
proxyUser: '',
proxyPassword: '',
mysqlTopology: 'single',
mongoTopology: 'single',
mongoSrv: false,
mongoReadPreference: 'primary',
mongoReplicaSet: '',
mongoAuthSource: '',
mongoAuthMechanism: '',
savePassword: true,
mysqlReplicaHosts: [],
mongoHosts: [],
mysqlReplicaUser: '',
mysqlReplicaPassword: '',
mongoReplicaUser: '',
mongoReplicaPassword: '',
});
} else if (type !== 'custom') {
form.setFieldsValue({
database: '',
port: defaultPort,
mysqlTopology: 'single',
mongoTopology: 'single',
@@ -1100,9 +1200,13 @@ const ConnectionModal: React.FC<{
type: 'mysql',
host: 'localhost',
port: 3306,
database: '',
user: 'root',
useSSH: false,
sshPort: 22,
useProxy: false,
proxyType: 'socks5',
proxyPort: 1080,
timeout: 30,
uri: '',
mysqlTopology: 'single',
@@ -1127,6 +1231,21 @@ const ConnectionModal: React.FC<{
setUriFeedback(null);
}
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
if (changed.useProxy !== undefined) setUseProxy(changed.useProxy);
if (changed.proxyType !== undefined) {
const nextType = String(changed.proxyType || 'socks5').toLowerCase();
if (nextType === 'http') {
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
if (!currentPort || currentPort === 1080) {
form.setFieldValue('proxyPort', 8080);
}
} else {
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
if (!currentPort || currentPort === 8080) {
form.setFieldValue('proxyPort', 1080);
}
}
}
// Type change handled by step 1, but keep sync if select changes (hidden now)
if (changed.type !== undefined) setDbType(changed.type);
if (
@@ -1221,6 +1340,16 @@ const ConnectionModal: React.FC<{
)}
</div>
{(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && (
<Form.Item
name="database"
label="默认连接数据库(可选)"
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
>
<Input placeholder="例如appdb" />
</Form.Item>
)}
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
<>
<Form.Item name="mysqlTopology" label="连接模式">
@@ -1454,12 +1583,51 @@ const ConnectionModal: React.FC<{
<Input.Password placeholder="密码" />
</Form.Item>
</div>
<Form.Item name="sshKeyPath" label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
<Input placeholder="绝对路径" />
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="sshKeyPath" noStyle>
<Input placeholder="绝对路径" />
</Form.Item>
<Button onClick={handleSelectSSHKeyFile} loading={selectingSSHKey}>
...
</Button>
</Space.Compact>
</Form.Item>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<Form.Item name="useProxy" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox>使 (SOCKS5 / HTTP CONNECT)</Checkbox>
</Form.Item>
{useProxy && (
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="proxyType" label="代理类型" rules={[{ required: useProxy, message: '请选择代理类型' }]} style={{ width: 180 }}>
<Select options={[
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP CONNECT' },
]} />
</Form.Item>
<Form.Item name="proxyHost" label="代理主机" rules={[{ required: useProxy, message: '请输入代理主机' }]} style={{ flex: 1 }}>
<Input placeholder="例如: 127.0.0.1 或 proxy.company.com" />
</Form.Item>
<Form.Item name="proxyPort" label="端口" rules={[{ required: useProxy, message: '请输入代理端口' }]} style={{ width: 120 }}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="proxyUser" label="代理用户名(可选)" style={{ flex: 1 }}>
<Input placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
<Input.Password placeholder="留空表示无认证" />
</Form.Item>
</div>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<Collapse

View File

@@ -1,12 +1,13 @@
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented } from 'antd';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover } from 'antd';
import type { SortOrder } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
import type { ColumnDefinition } from '../types';
import { v4 as uuidv4 } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
@@ -89,6 +90,14 @@ const normalizeDateTimeString = (val: string) => {
return `${match[1]} ${match[2]}`;
};
const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'year';
};
// --- Helper: Format Value ---
const formatCellValue = (val: any) => {
try {
@@ -292,6 +301,7 @@ const DataContext = React.createContext<{
handleExportSelected: (format: string, r: any) => void;
copyToClipboard: (t: string) => void;
tableName?: string;
enableRowContextMenu: boolean;
} | null>(null);
interface Item {
@@ -434,7 +444,11 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
if (!record || !context) return <tr {...props}>{children}</tr>;
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard } = context;
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu } = context;
if (!enableRowContextMenu) {
return <tr {...props}>{children}</tr>;
}
const getTargets = () => {
const keys = selectedRowKeysRef.current;
@@ -513,6 +527,11 @@ type GridFilterCondition = FilterCondition & {
type GridViewMode = 'table' | 'json' | 'text';
type ColumnMeta = {
type: string;
comment: string;
};
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter
@@ -521,10 +540,14 @@ const DataGrid: React.FC<DataGridProps> = ({
const addSqlLog = useStore(state => state.addSqlLog);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const queryOptions = useStore(state => state.queryOptions);
const setQueryOptions = useStore(state => state.setQueryOptions);
const isMacLike = useMemo(() => isMacLikePlatform(), []);
const darkMode = theme === 'dark';
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const canModifyData = !readOnly && !!tableName;
const showColumnComment = queryOptions?.showColumnComment !== false;
const showColumnType = queryOptions?.showColumnType !== false;
const selectionColumnWidth = 46;
// Background Helper
@@ -538,7 +561,7 @@ const DataGrid: React.FC<DataGridProps> = ({
};
const bgContent = getBg('#1d1d1d');
const bgFilter = getBg('#262626');
const bgContextMenu = getBg('#1f1f1f');
const bgContextMenu = darkMode ? '#1f1f1f' : '#ffffff';
// Row Colors with Opacity
const getRowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`;
@@ -596,6 +619,8 @@ const DataGrid: React.FC<DataGridProps> = ({
// 使用 ref 来优化拖拽性能,完全避免状态更新
const cellSelectionRafRef = useRef<number | null>(null);
const cellSelectionScrollRafRef = useRef<number | null>(null);
const cellSelectionAutoScrollRafRef = useRef<number | null>(null);
const cellSelectionPointerRef = useRef<{ x: number; y: number } | null>(null);
const isDraggingRef = useRef(false);
// 导入预览 Modal 状态
@@ -661,6 +686,9 @@ const DataGrid: React.FC<DataGridProps> = ({
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [columnMetaMap, setColumnMetaMap] = useState<Record<string, ColumnMeta>>({});
const columnMetaCacheRef = useRef<Record<string, Record<string, ColumnMeta>>>({});
const columnMetaSeqRef = useRef(0);
useEffect(() => {
const nextOrder = sortInfoExternal?.order === 'ascend' || sortInfoExternal?.order === 'descend'
@@ -677,6 +705,158 @@ const DataGrid: React.FC<DataGridProps> = ({
}
}, [sortInfoExternal, sortInfo]);
useEffect(() => {
const normalizedTableName = String(tableName || '').trim();
const normalizedDbName = String(dbName || '').trim();
if (!connectionId || !normalizedTableName) {
setColumnMetaMap({});
return;
}
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {});
}, [connectionId, dbName, tableName]);
useEffect(() => {
const normalizedTableName = String(tableName || '').trim();
const normalizedDbName = String(dbName || '').trim();
if (!connectionId || !normalizedTableName) return;
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
if (columnMetaCacheRef.current[cacheKey]) return;
const conn = connections.find(c => c.id === connectionId);
if (!conn) {
setColumnMetaMap({});
return;
}
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const seq = ++columnMetaSeqRef.current;
DBGetColumns(config as any, normalizedDbName, normalizedTableName)
.then((res) => {
if (seq !== columnMetaSeqRef.current) return;
if (!res.success || !Array.isArray(res.data)) {
setColumnMetaMap({});
return;
}
const nextMap: Record<string, ColumnMeta> = {};
(res.data as ColumnDefinition[]).forEach((column: any) => {
const name = String(column?.name ?? column?.Name ?? '').trim();
if (!name) return;
const type = String(column?.type ?? column?.Type ?? '').trim();
const comment = String(column?.comment ?? column?.Comment ?? '').trim();
nextMap[name] = { type, comment };
});
columnMetaCacheRef.current[cacheKey] = nextMap;
setColumnMetaMap(nextMap);
})
.catch(() => {
if (seq !== columnMetaSeqRef.current) return;
setColumnMetaMap({});
});
}, [connections, connectionId, dbName, tableName]);
const columnMetaMapByLowerName = useMemo(() => {
const next: Record<string, ColumnMeta> = {};
Object.entries(columnMetaMap).forEach(([name, meta]) => {
const lowerName = String(name || '').toLowerCase();
if (!lowerName || next[lowerName]) return;
next[lowerName] = meta;
});
return next;
}, [columnMetaMap]);
const normalizeCommitCellValue = useCallback(
(columnName: string, value: any, mode: 'insert' | 'update') => {
if (value === undefined) return undefined;
const normalizedName = String(columnName || '').trim();
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
const temporal = isTemporalColumnType(meta?.type);
if (!temporal) {
return value;
}
if (value === null) {
return null;
}
if (typeof value === 'string') {
const raw = value.trim();
if (raw === '') {
// INSERT 空时间值直接忽略字段让数据库默认值生效UPDATE 空时间值转 NULL。
return mode === 'insert' ? undefined : null;
}
return normalizeDateTimeString(value);
}
return value;
},
[columnMetaMap, columnMetaMapByLowerName]
);
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
const normalizedName = String(name || '');
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
const hoverLines: string[] = [];
if (meta?.type) hoverLines.push(`类型:${meta.type}`);
if (meta?.comment) hoverLines.push(`备注:${meta.comment}`);
const titleNode = (
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, lineHeight: 1.2 }}>
<span style={{ whiteSpace: 'nowrap' }}>{normalizedName}</span>
{showColumnType && meta?.type && (
<span
style={{
marginTop: 2,
fontSize: 11,
color: '#8c8c8c',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{meta.type}
</span>
)}
{showColumnComment && meta?.comment && (
<span
style={{
marginTop: 2,
fontSize: 11,
color: '#8c8c8c',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{meta.comment}
</span>
)}
</div>
);
if (hoverLines.length === 0) return titleNode;
return (
<Tooltip
title={<pre style={{ maxHeight: 260, overflow: 'auto', margin: 0, fontSize: 12, whiteSpace: 'pre-wrap' }}>{hoverLines.join('\n')}</pre>}
styles={{ root: { maxWidth: 640 } }}
>
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
</Tooltip>
);
}, [columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]);
const closeCellEditor = useCallback(() => {
setCellEditorOpen(false);
setCellEditorMeta(null);
@@ -924,6 +1104,11 @@ const DataGrid: React.FC<DataGridProps> = ({
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
updateCellSelection(new Set());
}, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]);
@@ -933,8 +1118,12 @@ const DataGrid: React.FC<DataGridProps> = ({
const container = containerRef.current;
if (!container) return;
const EDGE_THRESHOLD_PX = 28;
const MIN_SCROLL_STEP = 8;
const MAX_SCROLL_STEP = 24;
const getCellInfo = (target: HTMLElement): { rowKey: string; colName: string } | null => {
const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => {
if (!target) return null;
const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement;
if (!td) return null;
const rowKey = td.getAttribute('data-row-key');
@@ -943,35 +1132,12 @@ const DataGrid: React.FC<DataGridProps> = ({
return { rowKey, colName };
};
const onMouseDown = (e: MouseEvent) => {
const cellInfo = getCellInfo(e.target as HTMLElement);
if (!cellInfo) return;
e.preventDefault();
isDraggingRef.current = true;
const currentData = displayDataRef.current;
const nextRowIndexMap = new Map<string, number>();
currentData.forEach((r, idx) => {
const k = r?.[GONAVI_ROW_KEY];
if (k === undefined) return;
nextRowIndexMap.set(String(k), idx);
});
rowIndexMapRef.current = nextRowIndexMap;
const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1;
const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex };
currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]);
updateCellSelection(currentSelectionRef.current);
const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => {
const target = document.elementFromPoint(x, y) as HTMLElement | null;
return getCellInfo(target);
};
const onMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current || !selectionStartRef.current) return;
const cellInfo = getCellInfo(e.target as HTMLElement);
if (!cellInfo) return;
// 使用 RAF 节流
const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => {
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
}
@@ -1010,9 +1176,124 @@ const DataGrid: React.FC<DataGridProps> = ({
});
};
const stopAutoScroll = () => {
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
};
const getScrollStep = (distanceToEdge: number): number => {
const ratio = Math.min(1, Math.max(0, distanceToEdge / EDGE_THRESHOLD_PX));
return Math.round(MIN_SCROLL_STEP + (MAX_SCROLL_STEP - MIN_SCROLL_STEP) * ratio);
};
const autoScrollTick = () => {
if (!isDraggingRef.current || !selectionStartRef.current) {
stopAutoScroll();
return;
}
const pointer = cellSelectionPointerRef.current;
const tableBody = container.querySelector('.ant-table-body') as HTMLElement | null;
if (!pointer || !tableBody) {
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
return;
}
const rect = tableBody.getBoundingClientRect();
const maxScrollTop = Math.max(0, tableBody.scrollHeight - tableBody.clientHeight);
const maxScrollLeft = Math.max(0, tableBody.scrollWidth - tableBody.clientWidth);
let deltaY = 0;
let deltaX = 0;
if (pointer.y < rect.top + EDGE_THRESHOLD_PX && tableBody.scrollTop > 0) {
const distance = rect.top + EDGE_THRESHOLD_PX - pointer.y;
deltaY = -getScrollStep(distance);
} else if (pointer.y > rect.bottom - EDGE_THRESHOLD_PX && tableBody.scrollTop < maxScrollTop) {
const distance = pointer.y - (rect.bottom - EDGE_THRESHOLD_PX);
deltaY = getScrollStep(distance);
}
if (pointer.x < rect.left + EDGE_THRESHOLD_PX && tableBody.scrollLeft > 0) {
const distance = rect.left + EDGE_THRESHOLD_PX - pointer.x;
deltaX = -getScrollStep(distance);
} else if (pointer.x > rect.right - EDGE_THRESHOLD_PX && tableBody.scrollLeft < maxScrollLeft) {
const distance = pointer.x - (rect.right - EDGE_THRESHOLD_PX);
deltaX = getScrollStep(distance);
}
let didScroll = false;
if (deltaY !== 0) {
const nextTop = Math.max(0, Math.min(maxScrollTop, tableBody.scrollTop + deltaY));
if (nextTop !== tableBody.scrollTop) {
tableBody.scrollTop = nextTop;
didScroll = true;
}
}
if (deltaX !== 0) {
const nextLeft = Math.max(0, Math.min(maxScrollLeft, tableBody.scrollLeft + deltaX));
if (nextLeft !== tableBody.scrollLeft) {
tableBody.scrollLeft = nextLeft;
didScroll = true;
}
}
if (didScroll) {
const cellInfo = getCellInfoFromPoint(pointer.x, pointer.y);
if (cellInfo) scheduleSelectionUpdate(cellInfo);
}
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
};
const ensureAutoScroll = () => {
if (cellSelectionAutoScrollRafRef.current !== null) return;
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
};
const onMouseDown = (e: MouseEvent) => {
const target = e.target instanceof HTMLElement ? e.target : null;
const cellInfo = getCellInfo(target);
if (!cellInfo) return;
e.preventDefault();
isDraggingRef.current = true;
cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY };
const currentData = displayDataRef.current;
const nextRowIndexMap = new Map<string, number>();
currentData.forEach((r, idx) => {
const k = r?.[GONAVI_ROW_KEY];
if (k === undefined) return;
nextRowIndexMap.set(String(k), idx);
});
rowIndexMapRef.current = nextRowIndexMap;
const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1;
const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex };
currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]);
updateCellSelection(currentSelectionRef.current);
ensureAutoScroll();
};
const onMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current || !selectionStartRef.current) return;
cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY };
ensureAutoScroll();
const target = e.target instanceof HTMLElement ? e.target : null;
const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY);
if (!cellInfo) return;
scheduleSelectionUpdate(cellInfo);
};
const onMouseUp = () => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
stopAutoScroll();
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
@@ -1053,6 +1334,8 @@ const DataGrid: React.FC<DataGridProps> = ({
cancelAnimationFrame(cellSelectionScrollRafRef.current);
cellSelectionScrollRafRef.current = null;
}
stopAutoScroll();
cellSelectionPointerRef.current = null;
isDraggingRef.current = false;
};
}, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]);
@@ -1592,7 +1875,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const columns = useMemo(() => {
return columnNames.map(key => ({
title: key,
title: renderColumnTitle(key),
dataIndex: key,
key: key,
// 不使用 ellipsis避免 Ant Design 的 Tooltip 展开行为
@@ -1608,9 +1891,29 @@ const DataGrid: React.FC<DataGridProps> = ({
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleResizeStart(key), // Only need start
onClickCapture: (event: React.MouseEvent<HTMLElement>) => {
if (!onSort) return;
const headerCell = event.currentTarget as HTMLElement;
const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null;
const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null;
const isInArrow = [upArrow, downArrow].some((el) => {
if (!el) return false;
const rect = el.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
});
if (isInArrow) return;
// 仅允许点击上下箭头触发排序,点击字段名或表头其它区域不触发排序。
event.preventDefault();
event.stopPropagation();
},
}),
}));
}, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort]);
}, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]);
const mergedColumns = useMemo(() => columns.map(col => {
if (!col.editable) return col;
@@ -1620,7 +1923,7 @@ const DataGrid: React.FC<DataGridProps> = ({
record,
editable: col.editable,
dataIndex: col.dataIndex,
title: col.title,
title: String(col.dataIndex),
handleSave: handleCellSave,
focusCell: openCellEditor,
}),
@@ -1653,7 +1956,17 @@ const DataGrid: React.FC<DataGridProps> = ({
const updates: any[] = [];
const deletes: any[] = [];
addedRows.forEach(row => { const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row; inserts.push(vals); });
addedRows.forEach(row => {
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row;
const normalizedValues: Record<string, any> = {};
Object.entries(vals).forEach(([col, val]) => {
const normalizedVal = normalizeCommitCellValue(col, val, 'insert');
if (normalizedVal !== undefined) {
normalizedValues[col] = normalizedVal;
}
});
inserts.push(normalizedValues);
});
deletedRowKeys.forEach(keyStr => {
// Find original data
const originalRow = data.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr) || addedRows.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr);
@@ -1686,8 +1999,16 @@ const DataGrid: React.FC<DataGridProps> = ({
});
}
if (Object.keys(values).length === 0) return;
updates.push({ keys: pkData, values });
const normalizedValues: Record<string, any> = {};
Object.entries(values).forEach(([col, val]) => {
const normalizedVal = normalizeCommitCellValue(col, val, 'update');
if (normalizedVal !== undefined) {
normalizedValues[col] = normalizedVal;
}
});
if (Object.keys(normalizedValues).length === 0) return;
updates.push({ keys: pkData, values: normalizedValues });
});
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
@@ -2037,6 +2358,23 @@ const DataGrid: React.FC<DataGridProps> = ({
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
];
const columnInfoSettingContent = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 168 }}>
<Checkbox
checked={showColumnComment}
onChange={(e) => setQueryOptions({ showColumnComment: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={showColumnType}
onChange={(e) => setQueryOptions({ showColumnType: e.target.checked })}
>
</Checkbox>
</div>
);
const tableComponents = useMemo(() => ({
body: { cell: EditableCell, row: ContextMenuRow },
header: { cell: ResizableTitle }
@@ -2099,6 +2437,7 @@ const DataGrid: React.FC<DataGridProps> = ({
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
cellSelectionRafRef.current = null;
@@ -2107,6 +2446,10 @@ const DataGrid: React.FC<DataGridProps> = ({
cancelAnimationFrame(cellSelectionScrollRafRef.current);
cellSelectionScrollRafRef.current = null;
}
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
updateCellSelection(new Set());
if (!next) setBatchEditModalOpen(false);
message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式');
@@ -2149,6 +2492,15 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
<div style={{ marginLeft: 'auto' }} />
<div style={{ flexShrink: 0 }}>
<Popover
trigger="click"
placement="bottomRight"
content={columnInfoSettingContent}
>
<Button icon={<FileTextOutlined />}></Button>
</Popover>
</div>
<div style={{ flexShrink: 0 }}>
<Segmented
size="small"
@@ -2161,12 +2513,26 @@ const DataGrid: React.FC<DataGridProps> = ({
onChange={(val) => {
const nextMode = String(val) as GridViewMode;
if (nextMode === 'json' && cellEditMode) {
setCellEditMode(false);
setSelectedCells(new Set());
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
updateCellSelection(new Set());
}
setCellEditMode(false);
setSelectedCells(new Set());
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
cellSelectionRafRef.current = null;
}
if (cellSelectionScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionScrollRafRef.current);
cellSelectionScrollRafRef.current = null;
}
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
updateCellSelection(new Set());
}
if (nextMode === 'text') {
const selectedKey = selectedRowKeys[0];
if (selectedKey !== undefined) {
@@ -2413,13 +2779,14 @@ const DataGrid: React.FC<DataGridProps> = ({
{viewMode === 'table' ? (
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName, enableRowContextMenu: !canModifyData }}>
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
<EditableContext.Provider value={form}>
<Table
components={tableComponents}
dataSource={mergedDisplayData}
columns={mergedColumns}
showSorterTooltip={{ target: 'sorter-icon' }}
size="small"
tableLayout="fixed"
scroll={{ x: tableScrollX, y: tableHeight }}
@@ -2721,6 +3088,9 @@ const DataGrid: React.FC<DataGridProps> = ({
.${gridId} .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
.${gridId} .ant-table-thead > tr > th::before { display: none !important; }
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorters { cursor: default !important; }
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorter,
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; }
.${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; }
.${gridId} .ant-table-tbody > tr.ant-table-row-selected > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)'} !important; }
.${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.25)' : 'rgba(24, 144, 255, 0.12)'} !important; }

View File

@@ -1,24 +1,35 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Modal, Progress, Space, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import {
CheckDriverNetworkStatus,
DownloadDriverPackage,
GetDriverVersionList,
GetDriverVersionPackageSize,
GetDriverStatusList,
InstallLocalDriverPackage,
RemoveDriverPackage,
SelectDriverPackageFile,
} from '../../wailsjs/go/app/App';
const { Text } = Typography;
const { Paragraph, Text } = Typography;
type DriverStatusRow = {
type: string;
name: string;
builtIn: boolean;
pinnedVersion?: string;
installedVersion?: string;
packageSizeText?: string;
runtimeAvailable: boolean;
packageInstalled: boolean;
connectable: boolean;
defaultDownloadUrl?: string;
installDir?: string;
packagePath?: string;
executablePath?: string;
downloadedAt?: string;
message?: string;
};
@@ -35,12 +46,152 @@ type ProgressState = {
percent: number;
};
type DriverLogEntry = {
time: string;
text: string;
signature: string;
};
type DriverNetworkProbe = {
name: string;
url: string;
reachable: boolean;
httpStatus?: number;
latencyMs?: number;
error?: string;
};
type DriverNetworkStatus = {
reachable: boolean;
summary: string;
recommendedProxy: boolean;
proxyConfigured: boolean;
proxyEnv?: Record<string, string>;
checks: DriverNetworkProbe[];
checkedAt?: string;
logPath?: string;
};
type DriverVersionOption = {
version: string;
downloadUrl: string;
packageSizeText?: string;
recommended?: boolean;
source?: string;
year?: string;
displayLabel?: string;
};
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
type SelectOption = { value: string; label: string };
type SelectGroup = { label: string; options: SelectOption[] };
if (options.length === 0) {
return [] as Array<SelectOption | SelectGroup>;
}
const yearGroups = new Map<string, SelectOption[]>();
const others: SelectOption[] = [];
options.forEach((option) => {
const selectOption: SelectOption = {
value: buildVersionOptionKey(option),
label: option.displayLabel || option.version || '默认版本',
};
const year = String(option.year || '').trim();
if (!year) {
others.push(selectOption);
return;
}
const group = yearGroups.get(year) || [];
group.push(selectOption);
yearGroups.set(year, group);
});
const sortedYears = Array.from(yearGroups.keys()).sort((a, b) => {
const left = Number.parseInt(a, 10);
const right = Number.parseInt(b, 10);
const leftValid = Number.isFinite(left);
const rightValid = Number.isFinite(right);
if (leftValid && rightValid) {
return right - left;
}
return b.localeCompare(a);
});
const grouped: SelectGroup[] = sortedYears.map((year) => ({
label: `${year}`,
options: yearGroups.get(year) || [],
}));
if (others.length > 0) {
grouped.push({ label: '其他', options: others });
}
return grouped;
};
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const [loading, setLoading] = useState(false);
const [downloadDir, setDownloadDir] = useState('');
const [networkChecking, setNetworkChecking] = useState(false);
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
const [rows, setRows] = useState<DriverStatusRow[]>([]);
const [actionDriver, setActionDriver] = useState('');
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
const [logDriverType, setLogDriverType] = useState('');
const [logModalOpen, setLogModalOpen] = useState(false);
const [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const appendOperationLog = useCallback((
driverType: string,
text: string,
signature?: string,
mode: 'append' | 'update-last' = 'append',
) => {
const normalized = String(driverType || '').trim().toLowerCase();
const content = String(text || '').trim();
if (!normalized || !content) {
return;
}
const sign = String(signature || content).trim() || content;
const now = new Date().toLocaleTimeString();
setOperationLogMap((prev) => {
const history = prev[normalized] || [];
if (history.length > 0) {
const last = history[history.length - 1];
if (last.signature === sign) {
if (mode === 'update-last') {
if (last.text === content) {
return prev;
}
const nextHistory = [...history];
nextHistory[nextHistory.length - 1] = {
...last,
text: content,
time: now,
};
return { ...prev, [normalized]: nextHistory };
}
return prev;
}
}
const nextHistory = [
...history,
{
time: now,
text: content,
signature: sign,
},
];
const sliced = nextHistory.length > 200 ? nextHistory.slice(nextHistory.length - 200) : nextHistory;
return { ...prev, [normalized]: sliced };
});
}, []);
const refreshStatus = useCallback(async (toastOnError = true) => {
setLoading(true);
@@ -65,11 +216,17 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
type: String(item.type || '').trim(),
name: String(item.name || item.type || '').trim(),
builtIn: !!item.builtIn,
pinnedVersion: String(item.pinnedVersion || '').trim() || undefined,
installedVersion: String(item.installedVersion || '').trim() || undefined,
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
runtimeAvailable: !!item.runtimeAvailable,
packageInstalled: !!item.packageInstalled,
connectable: !!item.connectable,
defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined,
installDir: String(item.installDir || '').trim() || undefined,
packagePath: String(item.packagePath || '').trim() || undefined,
executablePath: String(item.executablePath || '').trim() || undefined,
downloadedAt: String(item.downloadedAt || '').trim() || undefined,
message: String(item.message || '').trim() || undefined,
}));
setRows(nextRows);
@@ -82,12 +239,201 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
}
}, [downloadDir]);
const checkNetworkStatus = useCallback(async (toastOnError = false) => {
setNetworkChecking(true);
try {
const res = await CheckDriverNetworkStatus();
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || '驱动网络检测失败');
}
return;
}
const data = (res?.data || {}) as any;
const checks = Array.isArray(data.checks) ? data.checks : [];
const normalizedChecks: DriverNetworkProbe[] = checks.map((item: any) => ({
name: String(item.name || '').trim(),
url: String(item.url || '').trim(),
reachable: !!item.reachable,
httpStatus: Number(item.httpStatus || 0) || undefined,
latencyMs: Number(item.latencyMs || 0) || undefined,
error: String(item.error || '').trim() || undefined,
}));
setNetworkStatus({
reachable: !!data.reachable,
summary: String(data.summary || '').trim() || '驱动网络检测已完成',
recommendedProxy: !!data.recommendedProxy,
proxyConfigured: !!data.proxyConfigured,
proxyEnv: (data.proxyEnv || {}) as Record<string, string>,
checkedAt: String(data.checkedAt || '').trim() || undefined,
checks: normalizedChecks,
logPath: String(data.logPath || '').trim() || undefined,
});
} catch (err: any) {
if (toastOnError) {
message.error(`驱动网络检测失败:${err?.message || String(err)}`);
}
} finally {
setNetworkChecking(false);
}
}, []);
const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => {
if (row.builtIn) {
return [] as DriverVersionOption[];
}
const driverType = String(row.type || '').trim();
if (!driverType) {
return [] as DriverVersionOption[];
}
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: true }));
try {
const res = await GetDriverVersionList(driverType, '');
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || `${row.name} 版本列表加载失败`);
}
return [] as DriverVersionOption[];
}
const data = (res?.data || {}) as any;
const rawVersions = Array.isArray(data.versions) ? data.versions : [];
const options: DriverVersionOption[] = rawVersions
.map((item: any) => {
const version = String(item.version || '').trim();
const downloadUrl = String(item.downloadUrl || '').trim();
if (!version && !downloadUrl) {
return null;
}
return {
version,
downloadUrl,
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
recommended: !!item.recommended,
source: String(item.source || '').trim() || undefined,
year: String(item.year || '').trim() || undefined,
displayLabel: String(item.displayLabel || '').trim() || undefined,
} as DriverVersionOption;
})
.filter((item: DriverVersionOption | null): item is DriverVersionOption => !!item);
if (options.length === 0) {
const fallbackVersion = String(row.pinnedVersion || '').trim();
const fallbackURL = String(row.defaultDownloadUrl || '').trim();
if (fallbackVersion || fallbackURL) {
options.push({
version: fallbackVersion,
downloadUrl: fallbackURL,
recommended: true,
source: 'fallback',
displayLabel: fallbackVersion || '默认版本',
});
}
}
setVersionMap((prev) => ({ ...prev, [driverType]: options }));
setSelectedVersionMap((prev) => {
const currentKey = prev[driverType];
if (currentKey && options.some((option) => buildVersionOptionKey(option) === currentKey)) {
return prev;
}
const preferred =
options.find((option) => option.version === row.installedVersion) ||
options.find((option) => option.version === row.pinnedVersion) ||
options.find((option) => option.recommended) ||
options[0];
if (!preferred) {
return prev;
}
return { ...prev, [driverType]: buildVersionOptionKey(preferred) };
});
return options;
} catch (err: any) {
if (toastOnError) {
message.error(`加载 ${row.name} 版本列表失败:${err?.message || String(err)}`);
}
return [] as DriverVersionOption[];
} finally {
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: false }));
}
}, []);
const loadVersionPackageSize = useCallback(async (row: DriverStatusRow, optionKey: string) => {
if (row.builtIn) {
return;
}
const driverType = String(row.type || '').trim();
if (!driverType || !optionKey) {
return;
}
const options = versionMap[driverType] || [];
const selectedOption = options.find((item) => buildVersionOptionKey(item) === optionKey);
if (!selectedOption) {
return;
}
if (String(selectedOption.packageSizeText || '').trim()) {
return;
}
const versionText = String(selectedOption.version || '').trim();
if (!versionText) {
return;
}
const loadingKey = buildVersionSizeLoadingKey(driverType, optionKey);
if (versionSizeLoadingMap[loadingKey]) {
return;
}
setVersionSizeLoadingMap((prev) => ({ ...prev, [loadingKey]: true }));
try {
const res = await GetDriverVersionPackageSize(driverType, versionText);
if (!res?.success) {
return;
}
const data = (res?.data || {}) as any;
const sizeText = String(data.packageSizeText || '').trim();
if (!sizeText) {
return;
}
setVersionMap((prev) => {
const current = prev[driverType] || [];
let changed = false;
const next = current.map((item) => {
if (buildVersionOptionKey(item) !== optionKey) {
return item;
}
if (String(item.packageSizeText || '').trim() === sizeText) {
return item;
}
changed = true;
return { ...item, packageSizeText: sizeText };
});
if (!changed) {
return prev;
}
return { ...prev, [driverType]: next };
});
} finally {
setVersionSizeLoadingMap((prev) => {
if (!prev[loadingKey]) {
return prev;
}
const next = { ...prev };
delete next[loadingKey];
return next;
});
}
}, [versionMap, versionSizeLoadingMap]);
useEffect(() => {
if (!open) {
return;
}
refreshStatus(false);
}, [open, refreshStatus]);
checkNetworkStatus(false);
}, [checkNetworkStatus, open, refreshStatus]);
useEffect(() => {
if (!open) {
@@ -112,11 +458,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
percent,
},
}));
const progressText = `${Math.round(percent)}%`;
const statusText = String(status || '').toUpperCase();
const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`;
const lineSignature = `${statusText}|${messageText || '-'}`;
appendOperationLog(driverType, lineText, lineSignature, 'update-last');
});
return () => {
off();
};
}, [open]);
}, [appendOperationLog, open]);
const installDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
@@ -128,27 +479,97 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
percent: 0,
},
}));
appendOperationLog(row.type, '[START] 开始自动安装');
try {
const result = await DownloadDriverPackage(row.type, '', downloadDir);
let options = versionMap[row.type] || [];
if (options.length === 0) {
options = await loadVersionOptions(row, true);
}
const selectedKey = selectedVersionMap[row.type];
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const selectedVersion = selectedOption?.version || row.pinnedVersion || '';
const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || '';
const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir);
if (!result?.success) {
message.error(result?.message || `安装 ${row.name} 失败`);
const errText = result?.message || `安装 ${row.name} 失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
message.success(`${row.name} 已安装启用`);
const versionTip = selectedVersion ? `${selectedVersion}` : '';
appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`);
message.success(`${row.name}${versionTip} 已安装启用`);
refreshStatus(false);
} finally {
setActionDriver('');
}
}, [downloadDir, refreshStatus]);
}, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== 'Cancelled') {
message.error(fileRes?.message || '选择本地驱动包失败');
}
return;
}
const filePath = String((fileRes?.data as any)?.path || '').trim();
if (!filePath) {
message.error('未选择有效的驱动包文件');
return;
}
setActionDriver(row.type);
setProgressMap((prev) => ({
...prev,
[row.type]: {
status: 'start',
message: '开始导入本地驱动包',
percent: 0,
},
}));
appendOperationLog(row.type, `[START] 开始本地导入:${filePath}`);
try {
const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir);
if (!result?.success) {
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
message.success(`${row.name} 本地驱动包已安装启用`);
refreshStatus(false);
} finally {
setActionDriver('');
}
}, [appendOperationLog, downloadDir, refreshStatus]);
const openDriverLog = useCallback((driverType: string) => {
const normalized = String(driverType || '').trim().toLowerCase();
if (!normalized) {
return;
}
setLogDriverType(normalized);
setLogModalOpen(true);
}, []);
const removeDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
appendOperationLog(row.type, '[START] 开始移除驱动');
try {
const result = await RemoveDriverPackage(row.type, downloadDir);
if (!result?.success) {
message.error(result?.message || `移除 ${row.name} 失败`);
const errText = result?.message || `移除 ${row.name} 失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
appendOperationLog(row.type, '[DONE] 驱动移除完成');
message.success(`${row.name} 已移除`);
setProgressMap((prev) => {
const next = { ...prev };
@@ -159,7 +580,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
} finally {
setActionDriver('');
}
}, [downloadDir, refreshStatus]);
}, [appendOperationLog, downloadDir, refreshStatus]);
const columns = useMemo(() => {
return [
@@ -169,12 +590,47 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
key: 'name',
width: 150,
},
{
title: '安装位置',
key: 'installPath',
width: 260,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary"></Text>;
}
const installPath = row.executablePath || row.installDir || '-';
if (installPath === '-') {
return <Text type="secondary">-</Text>;
}
return (
<Text copyable={{ text: installPath }} style={{ fontSize: 12 }}>
{installPath}
</Text>
);
},
},
{
title: '安装包大小',
dataIndex: 'packageSizeText',
key: 'packageSizeText',
width: 120,
render: (_: string | undefined, row: DriverStatusRow) => row.packageSizeText || '-',
render: (_: string | undefined, row: DriverStatusRow) => {
if (row.builtIn) {
return row.packageSizeText || '-';
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
return '计算中...';
}
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
},
},
{
title: '状态',
@@ -224,10 +680,47 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
return <Progress percent={percent} status={status} size="small" />;
},
},
{
title: '驱动版本',
key: 'driverVersion',
width: 230,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
return (
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionDriver === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
);
},
},
{
title: '操作',
key: 'actions',
width: 190,
width: 320,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
@@ -237,19 +730,20 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
if (isSlimBuildUnavailable && !row.packageInstalled) {
return <Text type="secondary"> Full </Text>;
}
if (row.connectable) {
return (
<Button
danger
icon={<DeleteOutlined />}
loading={loadingAction}
onClick={() => removeDriver(row)}
>
</Button>
);
}
return (
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const mainAction = row.connectable ? (
<Button
danger
icon={<DeleteOutlined />}
loading={loadingAction}
onClick={() => removeDriver(row)}
>
</Button>
) : (
<Button
type="primary"
icon={<DownloadOutlined />}
@@ -259,10 +753,41 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
</Button>
);
return (
<Space size={8} wrap>
{mainAction}
<Button
icon={<FileSearchOutlined />}
loading={loadingAction}
onClick={() => installDriverFromLocalFile(row)}
>
</Button>
<Button
type={hasLogs ? 'default' : 'text'}
disabled={!hasLogs}
onClick={() => openDriverLog(row.type)}
>
</Button>
</Space>
);
},
},
];
}, [actionDriver, installDriver, progressMap, removeDriver]);
}, [actionDriver, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
const activeLogRow = useMemo(() => {
if (!logDriverType) {
return undefined;
}
return rows.find((item) => item.type === logDriverType);
}, [logDriverType, rows]);
const activeDriverLogs = operationLogMap[logDriverType] || [];
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {});
return (
<Modal
@@ -270,11 +795,23 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
open={open}
onCancel={onClose}
width={980}
style={{ top: 24 }}
styles={{
body: {
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 18,
},
}}
destroyOnClose
footer={[
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>,
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>,
<Button key="close" type="primary" onClick={onClose}>
</Button>,
@@ -282,6 +819,67 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text type="secondary"> MySQL / Redis / Oracle / PostgreSQL </Text>
{networkStatus ? (
<Alert
type={networkStatus.reachable ? 'success' : 'warning'}
showIcon
message={networkStatus.summary}
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary">
GitHub Go HTTP/HTTPS/SOCKS5
</Text>
<Collapse
size="small"
items={[
{
key: 'checks',
label: '查看网络检测明细',
children: (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{networkStatus.checks.map((item) => (
<Text key={`${item.name}-${item.url}`} type={item.reachable ? 'secondary' : 'danger'}>
{item.name}{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `${item.latencyMs}ms` : ''}{item.error ? `${item.error}` : ''}
</Text>
))}
{proxyEnvEntries.length > 0 ? (
<Text type="secondary">
{proxyEnvEntries.map(([key]) => key).join('、')}
</Text>
) : (
<Text type="secondary"></Text>
)}
</Space>
),
},
]}
/>
</Space>
)}
/>
) : (
<Alert type="info" showIcon message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'} />
)}
<Alert
type="info"
showIcon
message="驱动目录与复用说明"
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary"> `mariadb-driver-agent` / `mariadb-driver-agent.exe` `GoNavi-DriverAgents.zip`</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
) : null}
</Space>
)}
/>
<Table
rowKey="type"
@@ -290,8 +888,40 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
dataSource={rows}
pagination={false}
size="middle"
scroll={{ x: 1450 }}
/>
</Space>
<Modal
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
open={logModalOpen}
onCancel={() => setLogModalOpen(false)}
footer={[
<Button key="close-log" type="primary" onClick={() => setLogModalOpen(false)}>
</Button>,
]}
width={780}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{activeLogRow?.installDir ? (
<Paragraph copyable={{ text: activeLogRow.installDir }} style={{ marginBottom: 0 }}>
{activeLogRow.installDir}
</Paragraph>
) : null}
{activeLogRow?.executablePath ? (
<Paragraph copyable={{ text: activeLogRow.executablePath }} style={{ marginBottom: 0 }}>
{activeLogRow.executablePath}
</Paragraph>
) : null}
{activeDriverLogLines.length > 0 ? (
<pre style={{ margin: 0, maxHeight: 360, overflow: 'auto', padding: 12, background: '#fafafa', borderRadius: 8, border: '1px solid #f0f0f0', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{activeDriverLogLines.join('\n')}
</pre>
) : (
<Text type="secondary"></Text>
)}
</Space>
</Modal>
</Modal>
);
};

View File

@@ -14,6 +14,8 @@ const REDIS_TREE_KEY_TYPE_WIDTH = 92;
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
const REDIS_TREE_KEY_TTL_WIDTH = 92;
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
const REDIS_KEY_INITIAL_LOAD_COUNT = 2000;
const REDIS_KEY_LOAD_MORE_COUNT = 2000;
interface RedisViewerProps {
connectionId: string;
@@ -462,27 +464,34 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
}, [connection, redisDB]);
const loadKeys = useCallback(async (pattern: string = '*', fromCursor: number = 0, append: boolean = false) => {
const loadKeys = useCallback(async (
pattern: string = '*',
fromCursor: number = 0,
append: boolean = false,
targetCount: number = REDIS_KEY_INITIAL_LOAD_COUNT
) => {
const config = getConfig();
if (!config) return;
setLoading(true);
try {
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, 100);
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, targetCount);
if (res.success) {
const result = res.data;
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
const nextCursor = Number(result?.cursor || 0);
if (append) {
setKeys(prev => {
const keyMap = new Map<string, RedisKeyInfo>();
prev.forEach(item => keyMap.set(item.key, item));
result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
return Array.from(keyMap.values());
});
} else {
setKeys(result.keys);
setKeys(scannedKeys);
}
setCursor(result.cursor);
setHasMore(result.cursor !== 0);
setCursor(nextCursor);
setHasMore(nextCursor !== 0);
} else {
message.error('加载 Key 失败: ' + res.message);
}
@@ -494,23 +503,26 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}, [getConfig]);
useEffect(() => {
loadKeys(searchPattern, 0, false);
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
}, [redisDB]);
const handleSearch = (value: string) => {
const pattern = value.trim() || '*';
setSearchPattern(pattern);
setCursor(0);
loadKeys(pattern, 0, false);
loadKeys(pattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
};
const handleLoadMore = () => {
loadKeys(searchPattern, cursor, true);
if (!hasMore || loading) {
return;
}
loadKeys(searchPattern, cursor, true, REDIS_KEY_LOAD_MORE_COUNT);
};
const handleRefresh = () => {
setCursor(0);
loadKeys(searchPattern, 0, false);
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
};
const loadKeyValue = async (key: string) => {
@@ -1777,7 +1789,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</Spin>
{hasMore && (
<div style={{ padding: 8, textAlign: 'center' }}>
<Button onClick={handleLoadMore} loading={loading}></Button>
<Button onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}></Button>
</div>
)}
</div>

View File

@@ -46,6 +46,17 @@ interface TreeNode {
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
type BatchObjectType = 'table' | 'view';
type BatchObjectFilterType = 'all' | BatchObjectType;
type BatchSelectionScope = 'filtered' | 'all';
interface BatchObjectItem {
title: string;
key: string;
objectName: string;
objectType: BatchObjectType;
dataRef: any;
}
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
const connections = useStore(state => state.connections);
@@ -118,12 +129,53 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
// Batch Operations Modal
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
const [batchTables, setBatchTables] = useState<any[]>([]);
const [batchTables, setBatchTables] = useState<BatchObjectItem[]>([]);
const [checkedTableKeys, setCheckedTableKeys] = useState<string[]>([]);
const [batchDbContext, setBatchDbContext] = useState<any>(null);
const [selectedConnection, setSelectedConnection] = useState<string>('');
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
const [batchFilterKeyword, setBatchFilterKeyword] = useState<string>('');
const [batchFilterType, setBatchFilterType] = useState<BatchObjectFilterType>('all');
const [batchSelectionScope, setBatchSelectionScope] = useState<BatchSelectionScope>('filtered');
const filteredBatchObjects = useMemo(() => {
const keyword = batchFilterKeyword.trim().toLowerCase();
return batchTables.filter((item) => {
if (batchFilterType !== 'all' && item.objectType !== batchFilterType) {
return false;
}
if (!keyword) {
return true;
}
return item.title.toLowerCase().includes(keyword) || item.objectName.toLowerCase().includes(keyword);
});
}, [batchFilterKeyword, batchFilterType, batchTables]);
const groupedBatchObjects = useMemo(() => {
const tables = filteredBatchObjects.filter(item => item.objectType === 'table');
const views = filteredBatchObjects.filter(item => item.objectType === 'view');
return { tables, views };
}, [filteredBatchObjects]);
const allBatchObjectKeys = useMemo(() => batchTables.map(item => item.key), [batchTables]);
const allBatchObjectKeysByType = useMemo(() => {
if (batchFilterType === 'all') {
return allBatchObjectKeys;
}
return batchTables
.filter((item) => item.objectType === batchFilterType)
.map((item) => item.key);
}, [allBatchObjectKeys, batchFilterType, batchTables]);
const filteredBatchObjectKeys = useMemo(() => filteredBatchObjects.map(item => item.key), [filteredBatchObjects]);
const selectionScopeTargetKeys = useMemo(
() => (batchSelectionScope === 'filtered' ? filteredBatchObjectKeys : allBatchObjectKeysByType),
[allBatchObjectKeysByType, batchSelectionScope, filteredBatchObjectKeys]
);
useEffect(() => {
if (batchFilterType === 'all') {
return;
}
const allowed = new Set(allBatchObjectKeysByType);
setCheckedTableKeys((prev) => prev.filter((key) => allowed.has(key)));
}, [allBatchObjectKeysByType, batchFilterType]);
// Batch Database Operations Modal
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
@@ -573,16 +625,33 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
results.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
const triggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row);
if (!triggerName) return;
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']);
const tableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']);
const fullTableName = buildQualifiedName(schemaName, tableName);
const uniqueKey = `${triggerName}@@${fullTableName}`;
const rawTriggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row);
if (!rawTriggerName) return;
const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']);
const rawTableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']);
const triggerParts = splitQualifiedName(rawTriggerName);
const tableParts = splitQualifiedName(rawTableName);
const resolvedSchema = (
rawSchemaName
|| tableParts.schemaName
|| triggerParts.schemaName
|| dbName
).trim();
const resolvedTriggerName = (triggerParts.objectName || rawTriggerName).trim();
const resolvedTableName = (tableParts.objectName || rawTableName).trim();
const fullTableName = buildQualifiedName(resolvedSchema, resolvedTableName);
// MySQL 下 trigger 名在同 schema 内唯一,直接按 schema+trigger 去重可彻底规避多元数据查询导致的重复
const uniqueKey = dialect === 'mysql'
? `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}`
: `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`;
if (seen.has(uniqueKey)) return;
seen.add(uniqueKey);
const displayName = fullTableName ? `${triggerName} (${fullTableName})` : triggerName;
triggers.push({ displayName, triggerName, tableName: fullTableName });
const displayName = fullTableName ? `${resolvedTriggerName} (${fullTableName})` : resolvedTriggerName;
triggers.push({ displayName, triggerName: resolvedTriggerName, tableName: fullTableName || resolvedTableName });
});
});
return { triggers, supported: hasSuccessfulQuery };
@@ -755,19 +824,35 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
});
const triggerEntries = triggersResult.triggers.map((trigger) => {
const triggerParsed = splitQualifiedName(trigger.triggerName);
const tableParsed = splitQualifiedName(trigger.tableName);
const schemaName = tableParsed.schemaName || triggerParsed.schemaName;
const triggerObjectName = triggerParsed.objectName || trigger.triggerName;
const tableObjectName = tableParsed.objectName || trigger.tableName;
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
return {
...trigger,
schemaName,
displayName,
};
});
const triggerEntries = (() => {
const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = [];
const triggerSeen = new Set<string>();
const metadataDialect = getMetadataDialect(conn as SavedConnection);
triggersResult.triggers.forEach((trigger) => {
const triggerParsed = splitQualifiedName(trigger.triggerName);
const tableParsed = splitQualifiedName(trigger.tableName);
const schemaName = tableParsed.schemaName || triggerParsed.schemaName || String(conn.dbName || '').trim();
const triggerObjectName = (triggerParsed.objectName || trigger.triggerName).trim();
const tableObjectName = (tableParsed.objectName || trigger.tableName).trim();
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
const dedupeKey = metadataDialect === 'mysql'
? `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}`
: `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}@@${tableObjectName.toLowerCase()}`;
if (triggerSeen.has(dedupeKey)) return;
triggerSeen.add(dedupeKey);
deduped.push({
...trigger,
schemaName,
triggerName: triggerObjectName,
tableName: buildQualifiedName(schemaName, tableObjectName) || tableObjectName,
displayName,
});
});
return deduped;
})();
const routineEntries = routinesResult.routines.map((routine) => {
const parsed = splitQualifiedName(routine.routineName);
@@ -1061,10 +1146,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
}
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true);
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true);
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', true);
if (type === 'folder-columns') openDesign(info.node, 'columns', false);
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);
};
const onExpand = (newExpandedKeys: React.Key[]) => {
@@ -1255,7 +1340,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (node.type === 'database') {
connId = node.dataRef.id;
dbName = node.title;
} else if (node.type === 'table') {
} else if (node.type === 'table' || node.type === 'view') {
connId = node.dataRef.id;
dbName = node.dataRef.dbName;
}
@@ -1266,6 +1351,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setBatchTables([]);
setCheckedTableKeys([]);
setAvailableDatabases([]);
setBatchFilterKeyword('');
setBatchFilterType('all');
setBatchSelectionScope('filtered');
if (connId) {
const conn = connections.find(c => c.id === connId);
@@ -1323,23 +1411,42 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const res = await DBGetTables(config as any, dbName);
if (res.success) {
const tables = (res.data as any[]).map((row: any) => {
const tableName = Object.values(row)[0] as string;
return {
title: tableName,
key: `${conn.id}-${dbName}-${tableName}`,
tableName: tableName,
dataRef: { ...conn, tableName, dbName }
};
});
const [res, viewResult] = await Promise.all([
DBGetTables(config as any, dbName),
loadViews(conn, dbName).catch(() => ({ views: [], supported: false })),
]);
setBatchTables(tables);
setCheckedTableKeys([]);
} else {
if (!res.success) {
message.error('获取表列表失败: ' + res.message);
return;
}
const viewSet = new Set(viewResult.views.map(view => view.toLowerCase()));
const tableObjects: BatchObjectItem[] = (res.data as any[])
.map((row: any) => Object.values(row)[0] as string)
.filter((tableName: string) => !viewSet.has(tableName.toLowerCase()))
.map((tableName: string) => ({
title: getSidebarTableDisplayName(conn, tableName),
key: `${conn.id}-${dbName}-table-${tableName}`,
objectName: tableName,
objectType: 'table' as const,
dataRef: { ...conn, tableName, dbName, objectType: 'table' },
}));
const viewObjects: BatchObjectItem[] = viewResult.views.map((viewName: string) => ({
title: getSidebarTableDisplayName(conn, viewName),
key: `${conn.id}-${dbName}-view-${viewName}`,
objectName: viewName,
objectType: 'view' as const,
dataRef: { ...conn, tableName: viewName, dbName, objectType: 'view' },
}));
tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
setBatchTables([...tableObjects, ...viewObjects]);
setCheckedTableKeys([]);
};
const handleConnectionChange = async (connId: string) => {
@@ -1347,6 +1454,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setSelectedDatabase('');
setBatchTables([]);
setCheckedTableKeys([]);
setBatchFilterKeyword('');
setBatchFilterType('all');
setBatchSelectionScope('filtered');
const conn = connections.find(c => c.id === connId);
if (conn) {
@@ -1356,6 +1466,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const handleDatabaseChange = async (dbName: string) => {
setSelectedDatabase(dbName);
setBatchFilterKeyword('');
setBatchFilterType('all');
setBatchSelectionScope('filtered');
const conn = connections.find(c => c.id === selectedConnection);
if (conn && dbName) {
@@ -1364,31 +1477,36 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const handleBatchExport = async (mode: BatchTableExportMode) => {
const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key));
if (selectedTables.length === 0) {
message.warning('请至少选择一张表');
const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key));
if (selectedObjects.length === 0) {
message.warning('请至少选择一个对象');
return;
}
setIsBatchModalOpen(false);
const { conn, dbName } = batchDbContext;
const tableNames = selectedTables.map(t => t.tableName);
const objectNames = selectedObjects.map(t => t.objectName);
const selectedViewCount = selectedObjects.filter(item => item.objectType === 'view').length;
const loadingText = mode === 'backup'
? `正在备份选中 (${tableNames.length})...`
? `正在备份选中对象 (${objectNames.length})...`
: mode === 'dataOnly'
? `正在导出选中数据 (INSERT) (${tableNames.length})...`
: `正在导出选中结构 (${tableNames.length})...`;
? `正在导出选中对象数据 (INSERT) (${objectNames.length})...`
: `正在导出选中对象结构 (${objectNames.length})...`;
const hide = message.loading(loadingText, 0);
try {
const app = (window as any).go.app.App;
const res = mode === 'dataOnly'
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, tableNames)
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, mode === 'backup');
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, objectNames)
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, objectNames, mode === 'backup');
hide();
if (res.success) {
message.success('导出成功');
if (mode !== 'schema' && selectedViewCount > 0) {
message.success(`导出成功(已自动跳过 ${selectedViewCount} 个视图的数据导出)`);
} else {
message.success('导出成功');
}
} else if (res.message !== 'Cancelled') {
message.error('导出失败: ' + res.message);
}
@@ -1399,17 +1517,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckedTableKeys(batchTables.map(t => t.key));
} else {
setCheckedTableKeys([]);
if (batchSelectionScope === 'all') {
setCheckedTableKeys(checked ? allBatchObjectKeys : []);
return;
}
if (filteredBatchObjectKeys.length === 0) {
return;
}
if (checked) {
setCheckedTableKeys(prev => {
const nextSet = new Set(prev);
filteredBatchObjectKeys.forEach((key) => nextSet.add(key));
return allBatchObjectKeys.filter((key) => nextSet.has(key));
});
return;
}
const filteredKeySet = new Set(filteredBatchObjectKeys);
setCheckedTableKeys(prev => prev.filter((key) => !filteredKeySet.has(key)));
};
const handleInvertSelection = () => {
const allKeys = batchTables.map(t => t.key);
const newChecked = allKeys.filter(k => !checkedTableKeys.includes(k));
setCheckedTableKeys(newChecked);
if (batchSelectionScope === 'all') {
setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key)));
return;
}
if (filteredBatchObjectKeys.length === 0) {
return;
}
setCheckedTableKeys(prev => {
const nextSet = new Set(prev);
filteredBatchObjectKeys.forEach((key) => {
if (nextSet.has(key)) {
nextSet.delete(key);
} else {
nextSet.add(key);
}
});
return allBatchObjectKeys.filter((key) => nextSet.has(key));
});
};
const openBatchDatabaseModal = async () => {
@@ -2803,6 +2948,43 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</div>
</div>
{batchTables.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Space wrap size={8} style={{ width: '100%' }}>
<Input
allowClear
value={batchFilterKeyword}
onChange={(e) => setBatchFilterKeyword(e.target.value)}
placeholder="筛选表/视图名称"
prefix={<SearchOutlined />}
style={{ width: 260 }}
/>
<Select
value={batchFilterType}
onChange={(value) => setBatchFilterType(value as BatchObjectFilterType)}
style={{ width: 140 }}
options={[
{ label: '全部对象', value: 'all' },
{ label: '仅表', value: 'table' },
{ label: '仅视图', value: 'view' },
]}
/>
<Select
value={batchSelectionScope}
onChange={(value) => setBatchSelectionScope(value as BatchSelectionScope)}
style={{ width: 220 }}
options={[
{ label: '勾选作用于:当前筛选结果', value: 'filtered' },
{ label: '勾选作用于:全部对象', value: 'all' },
]}
/>
</Space>
<div style={{ marginTop: 6, color: '#999', fontSize: 12 }}>
{filteredBatchObjects.length} / {batchTables.length}
</div>
</div>
)}
{batchTables.length > 0 && (
<>
<div style={{ marginBottom: 16 }}>
@@ -2810,23 +2992,26 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
<Button
size="small"
onClick={() => handleCheckAll(true)}
disabled={selectionScopeTargetKeys.length === 0}
>
</Button>
<Button
size="small"
onClick={() => handleCheckAll(false)}
disabled={selectionScopeTargetKeys.length === 0}
>
</Button>
<Button
size="small"
onClick={handleInvertSelection}
disabled={selectionScopeTargetKeys.length === 0}
>
</Button>
<span style={{ color: '#999' }}>
{checkedTableKeys.length} / {batchTables.length}
{checkedTableKeys.length} / {batchTables.length}
</span>
</Space>
</div>
@@ -2836,14 +3021,43 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onChange={(values) => setCheckedTableKeys(values as string[])}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{batchTables.map(table => (
<Checkbox key={table.key} value={table.key}>
<TableOutlined style={{ marginRight: 8 }} />
{table.title}
</Checkbox>
))}
</Space>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{groupedBatchObjects.tables.length > 0 && (
<div>
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
({groupedBatchObjects.tables.length})
</div>
<Space direction="vertical" style={{ width: '100%' }}>
{groupedBatchObjects.tables.map(table => (
<Checkbox key={table.key} value={table.key}>
<TableOutlined style={{ marginRight: 8 }} />
{table.title}
</Checkbox>
))}
</Space>
</div>
)}
{groupedBatchObjects.views.length > 0 && (
<div>
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
({groupedBatchObjects.views.length})
</div>
<Space direction="vertical" style={{ width: '100%' }}>
{groupedBatchObjects.views.map(view => (
<Checkbox key={view.key} value={view.key}>
<EyeOutlined style={{ marginRight: 8 }} />
{view.title}
</Checkbox>
))}
</Space>
</div>
)}
{groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && (
<div style={{ color: '#999', padding: '8px 0' }}>
</div>
)}
</div>
</Checkbox.Group>
</div>
</>

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types';
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
const DEFAULT_STARTUP_FULLSCREEN = false;
const LEGACY_DEFAULT_OPACITY = 0.95;
const OPACITY_EPSILON = 1e-6;
const MAX_URI_LENGTH = 4096;
@@ -155,6 +156,16 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
password: toTrimmedString(sshRaw.password),
keyPath: toTrimmedString(sshRaw.keyPath),
};
const proxyRaw = (raw.proxy && typeof raw.proxy === 'object') ? raw.proxy as Record<string, unknown> : {};
const proxyTypeRaw = toTrimmedString(proxyRaw.type, 'socks5').toLowerCase();
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
const proxy = {
type: proxyType,
host: toTrimmedString(proxyRaw.host),
port: normalizePort(proxyRaw.port, proxyTypeRaw === 'http' ? 8080 : 1080),
user: toTrimmedString(proxyRaw.user),
password: toTrimmedString(proxyRaw.password),
};
const safeConfig: ConnectionConfig & Record<string, unknown> = {
...raw,
@@ -167,6 +178,8 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
database: toTrimmedString(raw.database),
useSSH: !!raw.useSSH,
ssh,
useProxy: !!raw.useProxy,
proxy,
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
hosts: sanitizeAddressList(raw.hosts),
topology: raw.topology === 'replica' ? 'replica' : 'single',
@@ -194,10 +207,27 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
return safeConfig;
};
const resolveConnectionConfigPayload = (raw: Record<string, unknown>): unknown => {
if (raw.config && typeof raw.config === 'object') {
return raw.config;
}
// 兼容历史/导入场景:连接对象可能是扁平结构(无 config 包装)。
const hasLegacyFlatConfig =
raw.type !== undefined ||
raw.host !== undefined ||
raw.port !== undefined ||
raw.user !== undefined ||
raw.database !== undefined;
if (hasLegacyFlatConfig) {
return raw;
}
return undefined;
};
const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection | null => {
if (!value || typeof value !== 'object') return null;
const raw = value as Record<string, unknown>;
const config = sanitizeConnectionConfig(raw.config);
const config = sanitizeConnectionConfig(resolveConnectionConfigPayload(raw));
const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`;
const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`;
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
@@ -252,6 +282,12 @@ export interface SqlLog {
affectedRows?: number;
}
export interface QueryOptions {
maxRows: number;
showColumnComment: boolean;
showColumnType: boolean;
}
interface AppState {
connections: SavedConnection[];
tabs: TabData[];
@@ -260,8 +296,9 @@ interface AppState {
savedQueries: SavedQuery[];
theme: 'light' | 'dark';
appearance: { opacity: number; blur: number };
startupFullscreen: boolean;
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
queryOptions: { maxRows: number };
queryOptions: QueryOptions;
sqlLogs: SqlLog[];
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, 'name' | 'frequency'>;
@@ -286,8 +323,9 @@ interface AppState {
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
setStartupFullscreen: (enabled: boolean) => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
setQueryOptions: (options: Partial<QueryOptions>) => void;
addSqlLog: (log: SqlLog) => void;
clearSqlLogs: () => void;
@@ -326,13 +364,15 @@ const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'low
return { keywordCase: raw.keywordCase === 'lower' ? 'lower' : 'upper' };
};
const sanitizeQueryOptions = (value: unknown): { maxRows: number } => {
const sanitizeQueryOptions = (value: unknown): QueryOptions => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const maxRows = Number(raw.maxRows);
const showColumnComment = typeof raw.showColumnComment === 'boolean' ? raw.showColumnComment : true;
const showColumnType = typeof raw.showColumnType === 'boolean' ? raw.showColumnType : true;
if (!Number.isFinite(maxRows) || maxRows <= 0) {
return { maxRows: 5000 };
return { maxRows: 5000, showColumnComment, showColumnType };
}
return { maxRows: Math.min(50000, Math.trunc(maxRows)) };
return { maxRows: Math.min(50000, Math.trunc(maxRows)), showColumnComment, showColumnType };
};
const sanitizeTableAccessCount = (value: unknown): Record<string, number> => {
@@ -372,6 +412,21 @@ const sanitizeAppearance = (
return nextAppearance;
};
const sanitizeStartupFullscreen = (value: unknown): boolean => {
return value === true;
};
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
if (!persistedState || typeof persistedState !== 'object') {
return {};
}
const raw = persistedState as Record<string, unknown>;
if (raw.state && typeof raw.state === 'object') {
return raw.state as Record<string, unknown>;
}
return raw;
};
export const useStore = create<AppState>()(
persist(
(set) => ({
@@ -382,8 +437,9 @@ export const useStore = create<AppState>()(
savedQueries: [],
theme: 'light',
appearance: { ...DEFAULT_APPEARANCE },
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
sqlFormatOptions: { keywordCase: 'upper' },
queryOptions: { maxRows: 5000 },
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
sqlLogs: [],
tableAccessCount: {},
tableSortPreference: {},
@@ -493,6 +549,7 @@ export const useStore = create<AppState>()(
setTheme: (theme) => set({ theme }),
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
@@ -524,15 +581,13 @@ export const useStore = create<AppState>()(
name: 'lite-db-storage', // name of the item in the storage (must be unique)
version: 3,
migrate: (persistedState: unknown, version: number) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as AppState;
}
const state = persistedState as Partial<AppState>;
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
const nextState: Partial<AppState> = { ...state };
nextState.connections = sanitizeConnections(state.connections);
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
nextState.theme = sanitizeTheme(state.theme);
nextState.appearance = sanitizeAppearance(state.appearance, version);
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
@@ -540,9 +595,7 @@ export const useStore = create<AppState>()(
return nextState as AppState;
},
merge: (persistedState, currentState) => {
const state = (persistedState && typeof persistedState === 'object')
? persistedState as Partial<AppState>
: {};
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
return {
...currentState,
...state,
@@ -550,6 +603,7 @@ export const useStore = create<AppState>()(
savedQueries: sanitizeSavedQueries(state.savedQueries),
theme: sanitizeTheme(state.theme),
appearance: sanitizeAppearance(state.appearance, 3),
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
@@ -561,6 +615,7 @@ export const useStore = create<AppState>()(
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
startupFullscreen: state.startupFullscreen,
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
tableAccessCount: state.tableAccessCount,

View File

@@ -6,6 +6,14 @@ export interface SSHConfig {
keyPath?: string;
}
export interface ProxyConfig {
type: 'socks5' | 'http';
host: string;
port: number;
user?: string;
password?: string;
}
export interface ConnectionConfig {
type: string;
host: string;
@@ -16,6 +24,8 @@ export interface ConnectionConfig {
database?: string;
useSSH?: boolean;
ssh?: SSHConfig;
useProxy?: boolean;
proxy?: ProxyConfig;
driver?: string;
dsn?: string;
timeout?: number;

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

View File

@@ -12,4 +12,4 @@ export default defineConfig({
outDir: 'dist', // Standard Wails output directory
emptyOutDir: true,
}
})
})

View File

@@ -6,6 +6,8 @@ import {redis} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
export function CheckForUpdates():Promise<connection.QueryResult>;
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
@@ -38,7 +40,7 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryRe
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DownloadUpdate():Promise<connection.QueryResult>;
@@ -66,6 +68,10 @@ export function GetAppInfo():Promise<connection.QueryResult>;
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function GetDriverVersionList(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function GetDriverVersionPackageSize(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
@@ -156,6 +162,8 @@ export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.Qu
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;

View File

@@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
}
export function CheckDriverNetworkStatus() {
return window['go']['app']['App']['CheckDriverNetworkStatus']();
}
export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates']();
}
@@ -70,8 +74,8 @@ export function DataSyncPreview(arg1, arg2, arg3) {
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
}
export function DownloadDriverPackage(arg1, arg2, arg3) {
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3);
export function DownloadDriverPackage(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4);
}
export function DownloadUpdate() {
@@ -126,6 +130,14 @@ export function GetDriverStatusList(arg1, arg2) {
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
}
export function GetDriverVersionList(arg1, arg2) {
return window['go']['app']['App']['GetDriverVersionList'](arg1, arg2);
}
export function GetDriverVersionPackageSize(arg1, arg2) {
return window['go']['app']['App']['GetDriverVersionPackageSize'](arg1, arg2);
}
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}
@@ -306,6 +318,10 @@ export function SelectDriverPackageFile(arg1) {
return window['go']['app']['App']['SelectDriverPackageFile'](arg1);
}
export function SelectSSHKeyFile(arg1) {
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
}
export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}

View File

@@ -48,6 +48,26 @@ export namespace connection {
return a;
}
}
export class ProxyConfig {
type: string;
host: string;
port: number;
user?: string;
password?: string;
static createFrom(source: any = {}) {
return new ProxyConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
}
}
export class SSHConfig {
host: string;
port: number;
@@ -78,6 +98,8 @@ export namespace connection {
database: string;
useSSH: boolean;
ssh: SSHConfig;
useProxy?: boolean;
proxy?: ProxyConfig;
driver?: string;
dsn?: string;
timeout?: number;
@@ -110,6 +132,8 @@ export namespace connection {
this.database = source["database"];
this.useSSH = source["useSSH"];
this.ssh = this.convertValues(source["ssh"], SSHConfig);
this.useProxy = source["useProxy"];
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
this.driver = source["driver"];
this.dsn = source["dsn"];
this.timeout = source["timeout"];
@@ -146,6 +170,7 @@ export namespace connection {
return a;
}
}
export class QueryResult {
success: boolean;
message: string;

4
go.mod
View File

@@ -17,6 +17,8 @@ require (
github.com/xuri/excelize/v2 v2.10.0
go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/crypto v0.47.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/text v0.33.0
modernc.org/sqlite v1.44.3
)
@@ -83,8 +85,6 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect

View File

@@ -15,6 +15,7 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
proxytunnel "GoNavi-Wails/internal/proxy"
)
const dbCachePingInterval = 30 * time.Second
@@ -66,6 +67,7 @@ func (a *App) Shutdown(ctx context.Context) {
logger.Error(err, "关闭数据库连接失败")
}
}
proxytunnel.CloseAllForwarders()
// Close all Redis connections
CloseAllRedisClients()
logger.Infof("资源释放完成,应用已关闭")
@@ -77,9 +79,8 @@ func getCacheKey(config connection.ConnectionConfig) string {
if !config.UseSSH {
config.SSH = connection.SSHConfig{}
}
// 保持与驱动默认一致,避免同一连接被重复缓存
if config.Type == "postgres" && config.Database == "" {
config.Database = "postgres"
if !config.UseProxy {
config.Proxy = connection.ProxyConfig{}
}
b, _ := json.Marshal(config)
@@ -133,8 +134,17 @@ func formatConnSummary(config connection.ConnectionConfig) string {
}
var b strings.Builder
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
if normalizedType == "sqlite" || normalizedType == "duckdb" {
path := strings.TrimSpace(config.Host)
if path == "" {
path = "(未配置)"
}
b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds))
} else {
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
}
if len(config.Hosts) > 0 {
b.WriteString(fmt.Sprintf(" 节点数=%d", len(config.Hosts)))
@@ -166,6 +176,12 @@ func formatConnSummary(config connection.ConnectionConfig) string {
if config.UseSSH {
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
}
if config.UseProxy {
b.WriteString(fmt.Sprintf(" 代理=%s://%s:%d", strings.ToLower(strings.TrimSpace(config.Proxy.Type)), config.Proxy.Host, config.Proxy.Port))
if strings.TrimSpace(config.Proxy.User) != "" {
b.WriteString(" 代理认证=已配置")
}
}
if config.Type == "custom" {
driver := strings.TrimSpace(config.Driver)
@@ -260,7 +276,14 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
return nil, err
}
if err := dbInst.Connect(config); err != nil {
connectConfig, proxyErr := resolveDialConfigWithProxy(config)
if proxyErr != nil {
wrapped := wrapConnectError(config, proxyErr)
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
return nil, wrapped
}
if err := dbInst.Connect(connectConfig); err != nil {
wrapped := wrapConnectError(config, err)
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
return nil, wrapped

202
internal/app/db_proxy.go Normal file
View File

@@ -0,0 +1,202 @@
package app
import (
"fmt"
"net"
"strconv"
"strings"
"GoNavi-Wails/internal/connection"
proxytunnel "GoNavi-Wails/internal/proxy"
)
func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
config := raw
if !config.UseProxy {
config.Proxy = connection.ProxyConfig{}
return config, nil
}
normalizedProxy, err := proxytunnel.NormalizeConfig(config.Proxy)
if err != nil {
return connection.ConnectionConfig{}, err
}
config.Proxy = normalizedProxy
if config.UseSSH {
sshPort := config.SSH.Port
if sshPort <= 0 {
sshPort = 22
}
forwardedSSH, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.SSH.Host), sshPort)
if err != nil {
return connection.ConnectionConfig{}, fmt.Errorf("代理连接 SSH 网关失败:%w", err)
}
config.SSH.Host = forwardedSSH.host
config.SSH.Port = forwardedSSH.port
config.UseProxy = false
config.Proxy = connection.ProxyConfig{}
return config, nil
}
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
if normalizedType == "sqlite" || normalizedType == "duckdb" || normalizedType == "custom" {
// 文件型/自定义 DSN 类型不走标准 host:port不在此层改写。
return config, nil
}
if normalizedType == "mongodb" && config.MongoSRV {
// Mongo SRV 由驱动侧 Dialer 处理代理,避免破坏 DNS SRV 拓扑发现。
return config, nil
}
targetPort := config.Port
if targetPort <= 0 {
targetPort = defaultPortByType(normalizedType)
}
forwardedPrimary, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.Host), targetPort)
if err != nil {
return connection.ConnectionConfig{}, err
}
config.Host = forwardedPrimary.host
config.Port = forwardedPrimary.port
if len(config.Hosts) > 0 {
rewritten := make([]string, 0, len(config.Hosts))
seen := make(map[string]struct{}, len(config.Hosts))
for _, rawEntry := range config.Hosts {
targetHost, targetPort, ok := parseAddressWithDefaultPort(rawEntry, defaultPortByType(normalizedType))
if !ok {
continue
}
forwarded, forwardErr := buildProxyForwardAddress(normalizedProxy, targetHost, targetPort)
if forwardErr != nil {
return connection.ConnectionConfig{}, forwardErr
}
rewrittenAddress := formatHostPort(forwarded.host, forwarded.port)
if _, exists := seen[rewrittenAddress]; exists {
continue
}
seen[rewrittenAddress] = struct{}{}
rewritten = append(rewritten, rewrittenAddress)
}
config.Hosts = rewritten
}
config.UseProxy = false
config.Proxy = connection.ProxyConfig{}
return config, nil
}
type hostPort struct {
host string
port int
}
func buildProxyForwardAddress(proxyConfig connection.ProxyConfig, targetHost string, targetPort int) (hostPort, error) {
host := strings.TrimSpace(targetHost)
if host == "" {
host = "localhost"
}
port := targetPort
if port <= 0 {
return hostPort{}, fmt.Errorf("目标端口无效:%d", targetPort)
}
forwarder, err := proxytunnel.GetOrCreateLocalForwarder(proxyConfig, host, port)
if err != nil {
return hostPort{}, err
}
localHost, localPort, splitOK := parseAddressWithDefaultPort(forwarder.LocalAddr, 0)
if !splitOK || localPort <= 0 {
return hostPort{}, fmt.Errorf("解析代理本地转发地址失败:%s", forwarder.LocalAddr)
}
return hostPort{host: localHost, port: localPort}, nil
}
func parseAddressWithDefaultPort(raw string, defaultPort int) (string, int, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return "", 0, false
}
if strings.HasPrefix(text, "[") {
if host, portText, err := net.SplitHostPort(text); err == nil {
if port, convErr := strconv.Atoi(portText); convErr == nil && port > 0 && port <= 65535 {
return strings.TrimSpace(host), port, true
}
return "", 0, false
}
trimmed := strings.Trim(strings.TrimPrefix(text, "["), "]")
if trimmed != "" && defaultPort > 0 {
return trimmed, defaultPort, true
}
return "", 0, false
}
if strings.Count(text, ":") == 0 {
if defaultPort <= 0 {
return "", 0, false
}
return text, defaultPort, true
}
if strings.Count(text, ":") == 1 {
host, portText, err := net.SplitHostPort(text)
if err == nil {
port, convErr := strconv.Atoi(portText)
if convErr == nil && port > 0 && port <= 65535 {
return strings.TrimSpace(host), port, true
}
return "", 0, false
}
if defaultPort > 0 {
return strings.TrimSpace(text), defaultPort, true
}
return "", 0, false
}
// IPv6 地址未带端口,使用默认端口。
if defaultPort > 0 {
return text, defaultPort, true
}
return "", 0, false
}
func formatHostPort(host string, port int) string {
h := strings.TrimSpace(host)
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
return fmt.Sprintf("[%s]:%d", h, port)
}
return fmt.Sprintf("%s:%d", h, port)
}
func defaultPortByType(driverType string) int {
switch strings.ToLower(strings.TrimSpace(driverType)) {
case "mysql", "mariadb":
return 3306
case "diros":
return 9030
case "sphinx":
return 9306
case "postgres", "vastbase":
return 5432
case "redis":
return 6379
case "tdengine":
return 6041
case "oracle":
return 1521
case "dameng":
return 5236
case "kingbase":
return 54321
case "sqlserver":
return 1433
case "mongodb":
return 27017
case "highgo":
return 5866
default:
return 0
}
}

View File

@@ -190,9 +190,6 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string,
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
}
runConfig := config
if strings.TrimSpace(runConfig.Database) == "" {
runConfig.Database = "postgres"
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
@@ -228,9 +225,6 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再删除"}
}
runConfig = config
if strings.TrimSpace(runConfig.Database) == "" {
runConfig.Database = "postgres"
}
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除数据库", dbType)}
@@ -465,6 +459,7 @@ func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) con
func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
runConfig := normalizeRunConfig(config, dbName)
dbType := resolveDDLDBType(config)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
@@ -478,10 +473,120 @@ func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName strin
logger.Error(err, "DBShowCreateTable 获取建表语句失败:%s 表=%s", formatConnSummary(runConfig), tableName)
return connection.QueryResult{Success: false, Message: err.Error()}
}
if shouldFallbackCreateStatement(dbType, sqlStr) {
columns, colErr := dbInst.GetColumns(schemaName, pureTableName)
if colErr != nil {
logger.Error(colErr, "DBShowCreateTable 兜底加载字段失败:%s 表=%s", formatConnSummary(runConfig), tableName)
return connection.QueryResult{Success: false, Message: colErr.Error()}
}
fallbackDDL, buildErr := buildFallbackCreateStatement(dbType, schemaName, pureTableName, columns)
if buildErr != nil {
logger.Error(buildErr, "DBShowCreateTable 兜底生成 DDL 失败:%s 表=%s", formatConnSummary(runConfig), tableName)
return connection.QueryResult{Success: false, Message: buildErr.Error()}
}
sqlStr = fallbackDDL
}
return connection.QueryResult{Success: true, Data: sqlStr}
}
func shouldFallbackCreateStatement(dbType string, ddl string) bool {
switch dbType {
case "postgres", "kingbase", "highgo", "vastbase":
default:
return false
}
trimmed := strings.TrimSpace(ddl)
if trimmed == "" {
return true
}
if hasCreateTableHead(trimmed) {
return false
}
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "not fully supported") ||
strings.Contains(lower, "not directly supported") ||
strings.Contains(lower, "not supported") {
return true
}
return true
}
func hasCreateTableHead(sqlText string) bool {
lines := strings.Split(sqlText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "--") || strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") {
continue
}
return strings.HasPrefix(strings.ToLower(line), "create table")
}
return false
}
func buildFallbackCreateStatement(dbType string, schemaName string, tableName string, columns []connection.ColumnDefinition) (string, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return "", fmt.Errorf("表名不能为空")
}
if len(columns) == 0 {
return "", fmt.Errorf("未获取到字段定义,无法生成建表语句")
}
qualifiedTable := quoteTableIdentByType(dbType, schemaName, table)
columnLines := make([]string, 0, len(columns)+1)
primaryKeys := make([]string, 0, 2)
for _, col := range columns {
colNameRaw := strings.TrimSpace(col.Name)
if colNameRaw == "" {
continue
}
colType := strings.TrimSpace(col.Type)
if colType == "" {
colType = "text"
}
colName := quoteIdentByType(dbType, colNameRaw)
defParts := []string{fmt.Sprintf("%s %s", colName, colType)}
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
defParts = append(defParts, "NOT NULL")
}
if col.Default != nil {
defVal := strings.TrimSpace(*col.Default)
if defVal != "" {
defParts = append(defParts, "DEFAULT "+defVal)
}
}
columnLines = append(columnLines, " "+strings.Join(defParts, " "))
if strings.EqualFold(strings.TrimSpace(col.Key), "PRI") {
primaryKeys = append(primaryKeys, colName)
}
}
if len(columnLines) == 0 {
return "", fmt.Errorf("字段定义为空,无法生成建表语句")
}
if len(primaryKeys) > 0 {
columnLines = append(columnLines, " PRIMARY KEY ("+strings.Join(primaryKeys, ", ")+")")
}
ddl := strings.Builder{}
ddl.WriteString("CREATE TABLE ")
ddl.WriteString(qualifiedTable)
ddl.WriteString(" (\n")
ddl.WriteString(strings.Join(columnLines, ",\n"))
ddl.WriteString("\n);")
return ddl.String(), nil
}
func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
runConfig := normalizeRunConfig(config, dbName)

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"math"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -77,6 +78,48 @@ func (a *App) ImportConfigFile() connection.QueryResult {
return connection.QueryResult{Success: true, Data: string(content)}
}
func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult {
defaultDir := strings.TrimSpace(currentPath)
if defaultDir == "" {
if home, err := os.UserHomeDir(); err == nil {
defaultDir = filepath.Join(home, ".ssh")
}
}
if filepath.Ext(defaultDir) != "" {
defaultDir = filepath.Dir(defaultDir)
}
if defaultDir != "" && !filepath.IsAbs(defaultDir) {
if abs, err := filepath.Abs(defaultDir); err == nil {
defaultDir = abs
}
}
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "选择 SSH 私钥文件",
DefaultDirectory: defaultDir,
Filters: []runtime.FileFilter{
{
DisplayName: "私钥文件",
Pattern: "*.pem;*.key;*.ppk;*id_rsa*",
},
{
DisplayName: "所有文件",
Pattern: "*",
},
},
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if strings.TrimSpace(selection) == "" {
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
if abs, err := filepath.Abs(selection); err == nil {
selection = abs
}
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}}
}
// PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据
func (a *App) PreviewImportFile(filePath string) connection.QueryResult {
if filePath == "" {
@@ -485,7 +528,8 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true); err != nil {
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true, viewLookup); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := writeSQLFooter(w, runConfig); err != nil {
@@ -556,7 +600,7 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
return connection.QueryResult{Success: false, Message: err.Error()}
}
tables := make([]string, 0, len(tableNames))
objects := make([]string, 0, len(tableNames))
seen := make(map[string]struct{}, len(tableNames))
for _, t := range tableNames {
t = strings.TrimSpace(t)
@@ -567,9 +611,10 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
continue
}
seen[t] = struct{}{}
tables = append(tables, t)
objects = append(objects, t)
}
sort.Strings(tables)
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
objects = buildExportObjectOrder(runConfig, dbName, objects, viewLookup, false)
f, err := os.Create(filename)
if err != nil {
@@ -583,8 +628,8 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
for _, t := range tables {
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeSchema, includeData); err != nil {
for _, objectName := range objects {
if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, includeSchema, includeData, viewLookup); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
}
@@ -623,7 +668,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
sort.Strings(tables)
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
objects := buildExportObjectOrder(runConfig, dbName, tables, viewLookup, true)
f, err := os.Create(filename)
if err != nil {
@@ -637,8 +683,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
for _, t := range tables {
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, true, includeData); err != nil {
for _, objectName := range objects {
if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, true, includeData, viewLookup); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
}
@@ -743,6 +789,404 @@ func ensureSQLTerminator(sql string) string {
return sql + ";"
}
func buildExportObjectOrder(
config connection.ConnectionConfig,
dbName string,
rawObjects []string,
viewLookup map[string]string,
includeAllViews bool,
) []string {
tableSet := make(map[string]string, len(rawObjects))
viewSet := make(map[string]string, len(rawObjects))
for _, rawName := range rawObjects {
objectName := strings.TrimSpace(rawName)
if objectName == "" {
continue
}
key := normalizeExportObjectKey(config, dbName, objectName)
if key == "" {
continue
}
if canonicalViewName, ok := viewLookup[key]; ok {
if strings.TrimSpace(canonicalViewName) == "" {
canonicalViewName = objectName
}
viewSet[key] = canonicalViewName
delete(tableSet, key)
continue
}
if _, isView := viewSet[key]; isView {
continue
}
if _, exists := tableSet[key]; !exists {
tableSet[key] = objectName
}
}
if includeAllViews {
for key, viewName := range viewLookup {
canonicalViewName := strings.TrimSpace(viewName)
if canonicalViewName == "" {
continue
}
viewSet[key] = canonicalViewName
delete(tableSet, key)
}
}
tables := mapValuesSorted(tableSet)
views := mapValuesSorted(viewSet)
return append(tables, views...)
}
func mapValuesSorted(values map[string]string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
result = append(result, value)
}
sort.Strings(result)
return result
}
func normalizeExportObjectKey(config connection.ConnectionConfig, dbName string, objectName string) string {
schemaName, pureName := normalizeSchemaAndTable(config, dbName, objectName)
return normalizeExportObjectKeyByParts(schemaName, pureName)
}
func normalizeExportObjectKeyByParts(schemaName, objectName string) string {
return strings.ToLower(strings.TrimSpace(qualifyTable(schemaName, objectName)))
}
func listViewNameLookup(dbInst db.Database, config connection.ConnectionConfig, dbName string) map[string]string {
viewLookup := make(map[string]string)
queries := buildListViewQueries(config, dbName)
for _, query := range queries {
if strings.TrimSpace(query) == "" {
continue
}
rows, _, err := dbInst.Query(query)
if err != nil {
continue
}
for _, row := range rows {
tableType := strings.ToUpper(exportRowValueCI(row, "table_type", "type"))
if tableType != "" && tableType != "VIEW" {
continue
}
schemaName := exportRowValueCI(row, "schema_name", "table_schema", "owner", "schema", "db")
viewName := exportRowValueCI(row, "object_name", "view_name", "table_name", "name")
if viewName == "" {
viewName = exportInferObjectName(row)
}
if strings.TrimSpace(viewName) == "" {
continue
}
fullName := strings.TrimSpace(qualifyTable(schemaName, viewName))
if fullName == "" {
fullName = strings.TrimSpace(viewName)
}
key := normalizeExportObjectKey(config, dbName, fullName)
if key == "" {
continue
}
if _, exists := viewLookup[key]; !exists {
viewLookup[key] = fullName
}
}
}
return viewLookup
}
func buildListViewQueries(config connection.ConnectionConfig, dbName string) []string {
dbType := resolveDDLDBType(config)
escapedDbName := escapeSQLLiteral(dbName)
switch dbType {
case "mysql", "mariadb", "diros", "sphinx":
queries := []string{
fmt.Sprintf(`SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name, TABLE_TYPE AS table_type FROM information_schema.tables WHERE TABLE_TYPE='VIEW' AND TABLE_SCHEMA='%s' ORDER BY TABLE_NAME`, escapedDbName),
}
if strings.TrimSpace(dbName) != "" {
queries = append(queries, fmt.Sprintf("SHOW FULL TABLES FROM %s WHERE Table_type = 'VIEW'", quoteIdentByType("mysql", dbName)))
}
return queries
case "postgres", "kingbase", "highgo", "vastbase":
return []string{
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name`,
}
case "sqlserver":
safeDBName := strings.TrimSpace(config.Database)
if safeDBName == "" {
safeDBName = strings.TrimSpace(dbName)
}
if safeDBName == "" {
return nil
}
safeDB := quoteIdentByType("sqlserver", safeDBName)
return []string{
fmt.Sprintf(`SELECT s.name AS schema_name, v.name AS object_name FROM %s.sys.views v JOIN %s.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name`, safeDB, safeDB),
}
case "oracle", "dameng":
if strings.TrimSpace(dbName) == "" {
return []string{
`SELECT VIEW_NAME AS object_name FROM user_views ORDER BY VIEW_NAME`,
}
}
return []string{
fmt.Sprintf("SELECT OWNER AS schema_name, VIEW_NAME AS object_name FROM all_views WHERE OWNER = '%s' ORDER BY VIEW_NAME", strings.ToUpper(escapedDbName)),
}
case "sqlite":
return []string{
"SELECT name AS object_name FROM sqlite_master WHERE type='view' ORDER BY name",
}
case "duckdb":
return []string{
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`,
}
default:
if strings.TrimSpace(dbName) == "" {
return []string{
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views`,
}
}
return []string{
fmt.Sprintf(`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema='%s'`, escapedDbName),
}
}
}
func tryGetViewCreateStatement(
dbInst db.Database,
config connection.ConnectionConfig,
dbName string,
schemaName string,
viewName string,
) (string, bool) {
queries := buildViewCreateQueries(config, dbName, schemaName, viewName)
for _, query := range queries {
if strings.TrimSpace(query) == "" {
continue
}
rows, _, err := dbInst.Query(query)
if err != nil || len(rows) == 0 {
continue
}
createSQL := strings.TrimSpace(extractViewCreateSQL(rows[0]))
if createSQL == "" {
continue
}
if looksLikeSelectOrWith(createSQL) {
qualifiedView := qualifyTable(schemaName, viewName)
createSQL = fmt.Sprintf("CREATE VIEW %s AS %s", quoteQualifiedIdentByType(config.Type, qualifiedView), strings.TrimSuffix(strings.TrimSpace(createSQL), ";"))
}
return ensureSQLTerminator(createSQL), true
}
return "", false
}
func buildViewCreateQueries(config connection.ConnectionConfig, dbName, schemaName, viewName string) []string {
dbType := resolveDDLDBType(config)
safeSchema := strings.TrimSpace(schemaName)
safeView := strings.TrimSpace(viewName)
if safeView == "" {
return nil
}
escapedSchema := escapeSQLLiteral(safeSchema)
escapedView := escapeSQLLiteral(safeView)
escapedDB := escapeSQLLiteral(dbName)
switch dbType {
case "mysql", "mariadb", "diros", "sphinx":
if safeSchema == "" {
safeSchema = strings.TrimSpace(dbName)
}
if safeSchema != "" {
return []string{
fmt.Sprintf("SHOW CREATE VIEW %s.%s", quoteIdentByType("mysql", safeSchema), quoteIdentByType("mysql", safeView)),
}
}
return []string{
fmt.Sprintf("SHOW CREATE VIEW %s", quoteIdentByType("mysql", safeView)),
}
case "postgres", "kingbase", "highgo", "vastbase":
if safeSchema == "" {
safeSchema = "public"
}
regClassName := fmt.Sprintf(`"%s"."%s"`, strings.ReplaceAll(safeSchema, `"`, `""`), strings.ReplaceAll(safeView, `"`, `""`))
regClassName = strings.ReplaceAll(regClassName, "'", "''")
return []string{
fmt.Sprintf("SELECT pg_get_viewdef('%s'::regclass, true) AS ddl", regClassName),
}
case "sqlserver":
schema := safeSchema
if schema == "" {
schema = "dbo"
}
safeDBName := strings.TrimSpace(config.Database)
if safeDBName == "" {
safeDBName = strings.TrimSpace(dbName)
}
if safeDBName == "" {
return nil
}
safeDB := quoteIdentByType("sqlserver", safeDBName)
return []string{
fmt.Sprintf(`SELECT m.definition AS ddl
FROM %s.sys.views v
JOIN %s.sys.schemas s ON v.schema_id = s.schema_id
JOIN %s.sys.sql_modules m ON v.object_id = m.object_id
WHERE s.name = '%s' AND v.name = '%s'`,
safeDB, safeDB, safeDB, escapeSQLLiteral(schema), escapedView),
}
case "oracle", "dameng":
if safeSchema == "" {
safeSchema = strings.TrimSpace(dbName)
}
if safeSchema != "" {
return []string{
fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView), strings.ToUpper(escapeSQLLiteral(safeSchema))),
}
}
return []string{
fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView)),
}
case "sqlite":
return []string{
fmt.Sprintf("SELECT sql AS ddl FROM sqlite_master WHERE type='view' AND name='%s'", escapedView),
}
case "duckdb":
if safeSchema == "" {
safeSchema = "main"
escapedSchema = "main"
}
return []string{
fmt.Sprintf("SELECT sql AS ddl FROM duckdb_views() WHERE view_name = '%s' AND schema_name = '%s' LIMIT 1", escapedView, escapedSchema),
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
}
default:
if safeSchema != "" {
return []string{
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
}
}
if strings.TrimSpace(dbName) != "" {
return []string{
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedDB),
}
}
return []string{
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' LIMIT 1", escapedView),
}
}
}
func extractViewCreateSQL(row map[string]interface{}) string {
if row == nil {
return ""
}
ddl := exportRowValueCI(row, "create view", "create_statement", "create_sql", "ddl", "sql", "view_definition", "definition")
if ddl != "" {
return ddl
}
for _, value := range row {
if value == nil {
continue
}
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text == "" || text == "<nil>" {
continue
}
lower := strings.ToLower(text)
if strings.HasPrefix(lower, "create ") || strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") {
return text
}
}
return ""
}
func exportRowValueCI(row map[string]interface{}, candidates ...string) string {
if len(row) == 0 || len(candidates) == 0 {
return ""
}
for _, candidate := range candidates {
candidate = strings.ToLower(strings.TrimSpace(candidate))
if candidate == "" {
continue
}
for key, value := range row {
normalizedKey := strings.ToLower(strings.TrimSpace(key))
if normalizedKey != candidate {
continue
}
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text == "<nil>" {
return ""
}
return text
}
}
return ""
}
func exportInferObjectName(row map[string]interface{}) string {
if len(row) == 0 {
return ""
}
for key, value := range row {
normalizedKey := strings.ToLower(strings.TrimSpace(key))
if normalizedKey == "" {
continue
}
if strings.Contains(normalizedKey, "type") {
continue
}
if strings.Contains(normalizedKey, "table") || strings.Contains(normalizedKey, "view") || strings.Contains(normalizedKey, "name") || strings.Contains(normalizedKey, "ddl") || strings.Contains(normalizedKey, "sql") {
if value == nil {
continue
}
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text == "" || text == "<nil>" {
continue
}
return text
}
}
for _, value := range row {
if value == nil {
continue
}
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text == "" || text == "<nil>" {
continue
}
return text
}
return ""
}
func looksLikeSelectOrWith(sql string) bool {
trimmed := strings.TrimSpace(strings.TrimSuffix(sql, ";"))
if trimmed == "" {
return false
}
lower := strings.ToLower(trimmed)
return strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") || lower == "select" || lower == "with"
}
func escapeSQLLiteral(value string) string {
return strings.ReplaceAll(strings.TrimSpace(value), "'", "''")
}
func isMySQLHexLiteral(s string) bool {
if len(s) < 3 || !(strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) {
return false
@@ -798,13 +1242,63 @@ func formatSQLValue(dbType string, v interface{}) string {
}
}
func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeSchema bool, includeData bool) error {
func dumpTableSQL(
w *bufio.Writer,
dbInst db.Database,
config connection.ConnectionConfig,
dbName,
tableName string,
includeSchema bool,
includeData bool,
viewLookup map[string]string,
) error {
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
objectKey := normalizeExportObjectKeyByParts(schemaName, pureTableName)
_, isView := viewLookup[objectKey]
var createSQL string
if includeSchema {
if isView {
viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName)
if ok {
createSQL = viewDDL
} else {
ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName)
if err != nil {
return err
}
createSQL = ddl
}
} else {
ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName)
if err != nil {
if viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
createSQL = viewDDL
isView = true
} else {
return err
}
} else {
createSQL = ddl
}
}
}
if includeData && !includeSchema && !isView {
if _, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
isView = true
}
}
objectLabel := "Table"
if isView {
objectLabel = "View"
}
if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil {
return err
}
if _, err := w.WriteString(fmt.Sprintf("-- Table: %s\n", qualifyTable(schemaName, pureTableName))); err != nil {
if _, err := w.WriteString(fmt.Sprintf("-- %s: %s\n", objectLabel, qualifyTable(schemaName, pureTableName))); err != nil {
return err
}
if _, err := w.WriteString("-- ----------------------------\n\n"); err != nil {
@@ -812,10 +1306,6 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect
}
if includeSchema {
createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName)
if err != nil {
return err
}
if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil {
return err
}
@@ -828,6 +1318,13 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect
return nil
}
if isView {
if _, err := w.WriteString("-- View data export skipped (INSERT for views is not emitted).\n"); err != nil {
return err
}
return nil
}
qualified := qualifyTable(schemaName, pureTableName)
selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified))
data, columns, err := dbInst.Query(selectSQL)

View File

@@ -13,6 +13,7 @@ import (
"os/exec"
"path/filepath"
stdRuntime "runtime"
"strconv"
"strings"
"time"
@@ -857,55 +858,55 @@ func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
}
func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
return fmt.Sprintf(`@echo off
script := `@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "SOURCE=%s"
set "TARGET=%s"
set "STAGED=%s"
set "LOG_FILE=%s"
set PID=%d
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
set "TARGET=__GONAVI_UPDATE_TARGET__"
set "STAGED=__GONAVI_UPDATE_STAGED__"
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
set PID=__GONAVI_UPDATE_PID__
call :log updater started
if not exist "%%SOURCE%%" (
call :log source file not found: %%SOURCE%%
if not exist "%SOURCE%" (
call :log source file not found: %SOURCE%
exit /b 1
)
for %%I in ("%%TARGET%%") do set "TARGET_NAME=%%~nxI"
for %%I in ("%%SOURCE%%") do set "SOURCE_EXT=%%~xI"
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
set "SOURCE_EXE="
if /I "%%SOURCE_EXT%%"==".zip" (
set "EXTRACT_DIR=%%STAGED%%\_extract"
if exist "%%EXTRACT_DIR%%" (
rmdir /S /Q "%%EXTRACT_DIR%%" >> "%%LOG_FILE%%" 2>&1
if /I "%SOURCE_EXT%"==".zip" (
set "EXTRACT_DIR=%STAGED%\_extract"
if exist "%EXTRACT_DIR%" (
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
)
mkdir "%%EXTRACT_DIR%%" >> "%%LOG_FILE%%" 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%%LOG_FILE%%" 2>&1
if %%ERRORLEVEL%% NEQ 0 (
call :log expand zip failed: %%SOURCE%%
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL% NEQ 0 (
call :log expand zip failed: %SOURCE%
exit /b 1
)
if exist "%%EXTRACT_DIR%%\%%TARGET_NAME%%" (
set "SOURCE_EXE=%%EXTRACT_DIR%%\%%TARGET_NAME%%"
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
) else (
for /R "%%EXTRACT_DIR%%" %%F in (*.exe) do (
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
if not defined SOURCE_EXE (
set "SOURCE_EXE=%%~fF"
)
)
)
if not defined SOURCE_EXE (
call :log no executable found in portable zip: %%SOURCE%%
call :log no executable found in portable zip: %SOURCE%
exit /b 1
)
) else (
set "SOURCE_EXE=%%SOURCE%%"
set "SOURCE_EXE=%SOURCE%"
)
:waitloop
tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul
if %%ERRORLEVEL%%==0 (
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
if %ERRORLEVEL%==0 (
timeout /t 1 /nobreak >nul
goto waitloop
)
@@ -913,11 +914,11 @@ call :log host process exited
set /a RETRY=0
:move_retry
move /Y "%%SOURCE_EXE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
if %%ERRORLEVEL%%==0 goto move_done
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
copy /Y "%%SOURCE_EXE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
if %%ERRORLEVEL%%==0 goto move_done
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
set /a RETRY+=1
if !RETRY! LSS 20 (
@@ -929,23 +930,30 @@ call :log replace failed after retries (portable mode, no elevation): check dire
exit /b 1
:move_done
start "" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
if %%ERRORLEVEL%% NEQ 0 (
start "" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL% NEQ 0 (
call :log cmd start failed, trying powershell Start-Process
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%%TARGET%%'" >> "%%LOG_FILE%%" 2>&1
if %%ERRORLEVEL%% NEQ 0 (
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%'" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL% NEQ 0 (
call :log relaunch failed
exit /b 1
)
)
rmdir /S /Q "%%STAGED%%" >> "%%LOG_FILE%%" 2>&1
rmdir /S /Q "%STAGED%" >> "%LOG_FILE%" 2>&1
call :log update finished
exit /b 0
:log
echo [%%date%% %%time%%] %%*>>"%%LOG_FILE%%"
echo [%date% %time%] %*>>"%LOG_FILE%"
exit /b 0
`, source, target, stagedDir, logPath, pid)
`
return strings.NewReplacer(
"__GONAVI_UPDATE_SOURCE__", source,
"__GONAVI_UPDATE_TARGET__", target,
"__GONAVI_UPDATE_STAGED__", stagedDir,
"__GONAVI_UPDATE_LOG__", logPath,
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
).Replace(script)
}
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {

View File

@@ -0,0 +1,40 @@
package app
import (
"strings"
"testing"
)
func TestBuildWindowsScriptKeepsBatchForSyntax(t *testing.T) {
script := buildWindowsScript(
`C:\tmp\GoNavi-v0.4.0-windows-amd64.zip`,
`C:\Program Files\GoNavi\GoNavi.exe`,
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.4.0`,
`C:\Program Files\GoNavi\logs\update-install.log`,
13579,
)
mustContain := []string{
`for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"`,
`for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"`,
`for /R "%EXTRACT_DIR%" %%F in (*.exe) do (`,
`set "SOURCE_EXE=%%~fF"`,
}
for _, want := range mustContain {
if !strings.Contains(script, want) {
t.Fatalf("windows update script missing required token: %s\nscript:\n%s", want, script)
}
}
mustNotContain := []string{
`for %I in ("%TARGET%") do set "TARGET_NAME=%~nxI"`,
`for %I in ("%SOURCE%") do set "SOURCE_EXT=%~xI"`,
`for /R "%EXTRACT_DIR%" %F in (*.exe) do (`,
`set "SOURCE_EXE=%~fF"`,
}
for _, bad := range mustNotContain {
if strings.Contains(script, bad) {
t.Fatalf("windows update script contains invalid batch syntax: %s\nscript:\n%s", bad, script)
}
}
}

View File

@@ -9,33 +9,44 @@ type SSHConfig struct {
KeyPath string `json:"keyPath"`
}
// ProxyConfig holds proxy connection details
type ProxyConfig struct {
Type string `json:"type"` // socks5 | http
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
}
// ConnectionConfig holds database connection details including SSH
type ConnectionConfig struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
Database string `json:"database"`
UseSSH bool `json:"useSSH"`
SSH SSHConfig `json:"ssh"`
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
Topology string `json:"topology,omitempty"` // single | replica
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
Database string `json:"database"`
UseSSH bool `json:"useSSH"`
SSH SSHConfig `json:"ssh"`
UseProxy bool `json:"useProxy,omitempty"`
Proxy ProxyConfig `json:"proxy,omitempty"`
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
Topology string `json:"topology,omitempty"` // single | replica
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
}
// QueryResult is the standard response format for Wails methods

View File

@@ -0,0 +1,9 @@
//go:build !windows
package db
import "os/exec"
func configureAgentProcess(cmd *exec.Cmd) {
_ = cmd
}

View File

@@ -0,0 +1,20 @@
//go:build windows
package db
import (
"os/exec"
"syscall"
)
const windowsCreateNoWindow = 0x08000000
func configureAgentProcess(cmd *exec.Cmd) {
if cmd == nil {
return
}
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windowsCreateNoWindow,
}
}

View File

@@ -14,6 +14,7 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
proxytunnel "GoNavi-Wails/internal/proxy"
"GoNavi-Wails/internal/ssh"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -29,6 +30,14 @@ type MongoDB struct {
forwarder *ssh.LocalForwarder
}
type mongoProxyDialer struct {
proxyConfig connection.ProxyConfig
}
func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
return proxytunnel.DialContext(ctx, d.proxyConfig, network, address)
}
const defaultMongoPort = 27017
func normalizeMongoAddress(host string, port int) string {
@@ -328,6 +337,9 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
uri := m.getURI(attemptConfig)
clientOpts := options.Client().ApplyURI(uri)
if attemptConfig.UseProxy {
clientOpts.SetDialer(&mongoProxyDialer{proxyConfig: attemptConfig.Proxy})
}
client, err := mongo.Connect(clientOpts)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s连接失败: %v", authLabel, err))

View File

@@ -67,11 +67,16 @@ func newMySQLAgentClient(executablePath string) (*mysqlAgentClient, error) {
if pathText == "" {
return nil, fmt.Errorf("MySQL 驱动代理路径为空")
}
if info, err := os.Stat(pathText); err != nil || info.IsDir() {
info, err := os.Stat(pathText)
if err != nil {
return nil, fmt.Errorf("MySQL 驱动代理不存在:%s", pathText)
}
if info.IsDir() {
return nil, fmt.Errorf("MySQL 驱动代理路径是目录:%s", pathText)
}
cmd := exec.Command(pathText)
configureAgentProcess(cmd)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("创建 MySQL 驱动代理 stdin 失败:%w", err)

View File

@@ -501,6 +501,8 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
return fmt.Errorf("connection not open")
}
columnTypeMap := m.loadColumnTypeMap(tableName)
tx, err := m.conn.Begin()
if err != nil {
return err
@@ -513,7 +515,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
var args []interface{}
for k, v := range pk {
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
args = append(args, normalizeMySQLDateTimeValue(v))
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
}
if len(wheres) == 0 {
continue
@@ -535,7 +537,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
for k, v := range update.Values {
sets = append(sets, fmt.Sprintf("`%s` = ?", k))
args = append(args, normalizeMySQLDateTimeValue(v))
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
}
if len(sets) == 0 {
@@ -545,7 +547,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
var wheres []string
for k, v := range update.Keys {
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
args = append(args, normalizeMySQLDateTimeValue(v))
args = append(args, normalizeMySQLValueForWrite(k, v, columnTypeMap))
}
if len(wheres) == 0 {
@@ -569,12 +571,24 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
var args []interface{}
for k, v := range row {
normalizedValue, omit := normalizeMySQLValueForInsert(k, v, columnTypeMap)
if omit {
continue
}
cols = append(cols, fmt.Sprintf("`%s`", k))
placeholders = append(placeholders, "?")
args = append(args, normalizeMySQLDateTimeValue(v))
args = append(args, normalizedValue)
}
if len(cols) == 0 {
query := fmt.Sprintf("INSERT INTO `%s` () VALUES ()", tableName)
res, err := tx.Exec(query)
if err != nil {
return fmt.Errorf("insert error: %v", err)
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return fmt.Errorf("插入未生效:未影响任何行")
}
continue
}
@@ -629,6 +643,69 @@ func normalizeMySQLDateTimeValue(value interface{}) interface{} {
return value
}
func (m *MySQLDB) loadColumnTypeMap(tableName string) map[string]string {
result := map[string]string{}
table := strings.TrimSpace(tableName)
if table == "" {
return result
}
columns, err := m.GetColumns("", table)
if err != nil {
logger.Warnf("加载列元数据失败(不影响提交):表=%s err=%v", table, err)
return result
}
for _, col := range columns {
name := strings.ToLower(strings.TrimSpace(col.Name))
if name == "" {
continue
}
result[name] = strings.TrimSpace(col.Type)
}
return result
}
func normalizeMySQLValueForInsert(columnName string, value interface{}, columnTypeMap map[string]string) (interface{}, bool) {
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
if !isMySQLTemporalColumnType(columnType) {
return value, false
}
text, ok := value.(string)
if ok && strings.TrimSpace(text) == "" {
// INSERT 空时间字段不写入,交给 DB 默认值处理(如 CURRENT_TIMESTAMP
return nil, true
}
return normalizeMySQLDateTimeValue(value), false
}
func normalizeMySQLValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} {
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
if !isMySQLTemporalColumnType(columnType) {
return value
}
text, ok := value.(string)
if ok && strings.TrimSpace(text) == "" {
return nil
}
return normalizeMySQLDateTimeValue(value)
}
func isMySQLTemporalColumnType(columnType string) bool {
raw := strings.ToLower(strings.TrimSpace(columnType))
if raw == "" {
return false
}
if strings.Contains(raw, "datetime") || strings.Contains(raw, "timestamp") {
return true
}
base := raw
if idx := strings.IndexAny(base, "( "); idx >= 0 {
base = base[:idx]
}
return base == "date" || base == "time" || base == "year"
}
func hasTimezoneOffset(text string) bool {
pos := strings.LastIndexAny(text, "+-")
if pos < 0 || pos < 10 || pos+1 >= len(text) {

View File

@@ -68,11 +68,16 @@ func newOptionalDriverAgentClient(driverType string, executablePath string) (*op
if pathText == "" {
return nil, fmt.Errorf("%s 驱动代理路径为空", driverDisplayName(driverType))
}
if info, err := os.Stat(pathText); err != nil || info.IsDir() {
info, err := os.Stat(pathText)
if err != nil {
return nil, fmt.Errorf("%s 驱动代理不存在:%s", driverDisplayName(driverType), pathText)
}
if info.IsDir() {
return nil, fmt.Errorf("%s 驱动代理路径是目录:%s", driverDisplayName(driverType), pathText)
}
cmd := exec.Command(pathText)
configureAgentProcess(cmd)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("创建 %s 驱动代理 stdin 失败:%w", driverDisplayName(driverType), err)

View File

@@ -0,0 +1,48 @@
package db
import (
"reflect"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestResolvePostgresConnectDatabases_ExplicitDatabase(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "postgres",
Database: "analytics",
User: "app_user",
}
got := resolvePostgresConnectDatabases(cfg)
want := []string{"analytics"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
}
}
func TestResolvePostgresConnectDatabases_FallbackOrder(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "postgres",
User: "app_user",
}
got := resolvePostgresConnectDatabases(cfg)
want := []string{"postgres", "template1", "app_user"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
}
}
func TestResolvePostgresConnectDatabases_DeduplicateUserDefault(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "postgres",
User: "postgres",
}
got := resolvePostgresConnectDatabases(cfg)
want := []string{"postgres", "template1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
}
}

View File

@@ -24,6 +24,30 @@ type PostgresDB struct {
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
}
func resolvePostgresConnectDatabases(config connection.ConnectionConfig) []string {
explicit := strings.TrimSpace(config.Database)
if explicit != "" {
return []string{explicit}
}
candidates := []string{"postgres", "template1", strings.TrimSpace(config.User)}
seen := make(map[string]struct{}, len(candidates))
result := make([]string, 0, len(candidates))
for _, name := range candidates {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
normalized := strings.ToLower(trimmed)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, trimmed)
}
return result
}
func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
// postgres://user:password@host:port/dbname?sslmode=disable
dbname := config.Database
@@ -53,8 +77,23 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
return fmt.Errorf("%s", reason)
}
var dsn string
var err error
runConfig := config
p.pingTimeout = getConnectTimeout(config)
cleanupOnFailure := true
defer func() {
if !cleanupOnFailure {
return
}
if p.conn != nil {
_ = p.conn.Close()
p.conn = nil
}
if p.forwarder != nil {
_ = p.forwarder.Close()
p.forwarder = nil
}
}()
if config.UseSSH {
// Create SSH tunnel with local port forwarding
@@ -83,24 +122,44 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
localConfig.Port = port
localConfig.UseSSH = false // Disable SSH flag for DSN generation
dsn = p.getDSN(localConfig)
runConfig = localConfig
logger.Infof("PostgreSQL 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
} else {
dsn = p.getDSN(config)
}
db, err := sql.Open("postgres", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
}
p.conn = db
p.pingTimeout = getConnectTimeout(config)
attemptDBs := resolvePostgresConnectDatabases(runConfig)
var failures []string
for _, dbName := range attemptDBs {
attemptConfig := runConfig
attemptConfig.Database = dbName
dsn := p.getDSN(attemptConfig)
// Force verification
if err := p.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
dbConn, err := sql.Open("postgres", dsn)
if err != nil {
failures = append(failures, fmt.Sprintf("数据库=%s 打开连接失败: %v", dbName, err))
continue
}
p.conn = dbConn
// Force verification
if err := p.Ping(); err != nil {
failures = append(failures, fmt.Sprintf("数据库=%s 验证失败: %v", dbName, err))
_ = dbConn.Close()
p.conn = nil
continue
}
if strings.TrimSpace(config.Database) == "" && !strings.EqualFold(dbName, "postgres") {
logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName)
}
cleanupOnFailure = false
return nil
}
return nil
if len(failures) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的连接数据库")
}
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(failures, ""))
}
func (p *PostgresDB) Close() error {

View File

@@ -6,6 +6,9 @@ import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -21,7 +24,14 @@ type SQLiteDB struct {
}
func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
dsn := config.Host
dsn, err := resolveSQLiteDSN(config)
if err != nil {
return err
}
if err := ensureSQLiteParentDir(dsn); err != nil {
return err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
@@ -31,11 +41,140 @@ func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
// Force verification
if err := s.Ping(); err != nil {
_ = db.Close()
s.conn = nil
return fmt.Errorf("连接建立后验证失败:%w", err)
}
return nil
}
func resolveSQLiteDSN(config connection.ConnectionConfig) (string, error) {
dsn := strings.TrimSpace(config.Host)
if dsn == "" {
dsn = strings.TrimSpace(config.Database)
}
dsn = normalizeSQLitePath(dsn)
if dsn == "" {
return "", fmt.Errorf("SQLite 需要本地数据库文件路径(例如 /path/to/demo.sqlite")
}
if strings.EqualFold(dsn, ":memory:") {
return dsn, nil
}
if looksLikeHostPort(dsn) {
return "", fmt.Errorf("SQLite 需要本地数据库文件路径,当前输入看起来是主机地址:%s", dsn)
}
return dsn, nil
}
func normalizeSQLitePath(raw string) string {
text := strings.TrimSpace(raw)
if strings.HasPrefix(text, "/") && len(text) > 3 && isWindowsDrivePath(text[1:]) {
text = text[1:]
}
if isWindowsDrivePath(text) {
text = trimLegacyPortSuffix(text)
}
return text
}
func isWindowsDrivePath(path string) bool {
if len(path) < 3 {
return false
}
drive := path[0]
if !((drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z')) {
return false
}
if path[1] != ':' {
return false
}
sep := path[2]
return sep == '\\' || sep == '/'
}
func trimLegacyPortSuffix(path string) string {
normalized := path
for {
idx := strings.LastIndex(normalized, ":")
if idx <= 1 || idx+1 >= len(normalized) {
return normalized
}
suffix := normalized[idx+1:]
validDigits := true
for _, ch := range suffix {
if ch < '0' || ch > '9' {
validDigits = false
break
}
}
if !validDigits {
return normalized
}
normalized = normalized[:idx]
}
}
func looksLikeHostPort(raw string) bool {
text := strings.TrimSpace(raw)
if text == "" {
return false
}
if strings.ContainsAny(text, `/\`) {
return false
}
if strings.HasPrefix(strings.ToLower(text), "file:") {
return false
}
if strings.HasPrefix(text, "[") {
closing := strings.LastIndex(text, "]")
if closing <= 0 || closing+1 >= len(text) {
return false
}
portText := strings.TrimSpace(strings.TrimPrefix(text[closing+1:], ":"))
return isValidPortText(portText)
}
if strings.Count(text, ":") != 1 {
return false
}
split := strings.LastIndex(text, ":")
if split <= 0 || split+1 >= len(text) {
return false
}
return isValidPortText(strings.TrimSpace(text[split+1:]))
}
func isValidPortText(text string) bool {
port, err := strconv.Atoi(text)
return err == nil && port > 0 && port <= 65535
}
func ensureSQLiteParentDir(dsn string) error {
text := strings.TrimSpace(dsn)
if text == "" || strings.EqualFold(text, ":memory:") {
return nil
}
// file: URI 由驱动处理,避免在这里误判路径格式。
if strings.HasPrefix(strings.ToLower(text), "file:") {
return nil
}
path := text
if idx := strings.Index(path, "?"); idx >= 0 {
path = path[:idx]
}
path = strings.TrimSpace(path)
if path == "" {
return nil
}
dir := filepath.Dir(path)
if dir == "." || dir == "" {
return nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建 SQLite 数据文件目录失败:%w", err)
}
return nil
}
func (s *SQLiteDB) Close() error {
if s.conn != nil {
return s.conn.Close()

View File

@@ -0,0 +1,79 @@
//go:build gonavi_full_drivers || gonavi_sqlite_driver
package db
import (
"os"
"path/filepath"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestResolveSQLiteDSNRejectsHostPort(t *testing.T) {
_, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: "localhost:3306"})
if err == nil {
t.Fatalf("期望拦截 host:port 输入")
}
if !strings.Contains(err.Error(), "本地数据库文件路径") {
t.Fatalf("错误提示不符合预期: %v", err)
}
}
func TestResolveSQLiteDSNFallbackDatabase(t *testing.T) {
dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Database: "/tmp/demo.sqlite"})
if err != nil {
t.Fatalf("解析 DSN 失败: %v", err)
}
if dsn != "/tmp/demo.sqlite" {
t.Fatalf("期望使用 database 作为 DSN实际=%s", dsn)
}
}
func TestResolveSQLiteDSNNormalizesWindowsLegacyPath(t *testing.T) {
dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `F:\py\py\history.db:3306:3306`})
if err != nil {
t.Fatalf("解析 DSN 失败: %v", err)
}
if dsn != `F:\py\py\history.db` {
t.Fatalf("期望清理历史端口污染,实际=%s", dsn)
}
}
func TestResolveSQLiteDSNNormalizesWindowsPathWithLeadingSlash(t *testing.T) {
dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `/F:\py\py\history.db:3306`})
if err != nil {
t.Fatalf("解析 DSN 失败: %v", err)
}
if dsn != `F:\py\py\history.db` {
t.Fatalf("期望清理前导斜杠与端口污染,实际=%s", dsn)
}
}
func TestEnsureSQLiteParentDirCreatesNestedDir(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "nested", "child", "demo.sqlite")
if err := ensureSQLiteParentDir(target); err != nil {
t.Fatalf("创建目录失败: %v", err)
}
info, err := os.Stat(filepath.Dir(target))
if err != nil {
t.Fatalf("目录不存在: %v", err)
}
if !info.IsDir() {
t.Fatalf("目标不是目录: %s", filepath.Dir(target))
}
}
func TestLooksLikeHostPort(t *testing.T) {
if !looksLikeHostPort("localhost:3306") {
t.Fatalf("localhost:3306 应识别为 host:port")
}
if looksLikeHostPort("/tmp/demo.sqlite") {
t.Fatalf("/tmp/demo.sqlite 不应识别为 host:port")
}
if looksLikeHostPort(`C:\sqlite\demo.db`) {
t.Fatalf("Windows 路径不应识别为 host:port")
}
}

344
internal/proxy/proxy.go Normal file
View File

@@ -0,0 +1,344 @@
package proxy
import (
"bufio"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
xproxy "golang.org/x/net/proxy"
)
const (
defaultDialTimeout = 8 * time.Second
)
type LocalForwarder struct {
LocalAddr string
RemoteAddr string
ProxyAddr string
ProxyType string
cfg connection.ProxyConfig
listener net.Listener
closeChan chan struct{}
closeOnce sync.Once
closed bool
closedMu sync.RWMutex
}
var (
forwarderMu sync.RWMutex
localForwarders = make(map[string]*LocalForwarder)
)
func NormalizeConfig(config connection.ProxyConfig) (connection.ProxyConfig, error) {
result := connection.ProxyConfig{
Type: strings.ToLower(strings.TrimSpace(config.Type)),
Host: strings.TrimSpace(config.Host),
Port: config.Port,
User: strings.TrimSpace(config.User),
Password: config.Password,
}
switch result.Type {
case "socks5", "socks5h", "http":
default:
return result, fmt.Errorf("不支持的代理类型:%s", config.Type)
}
if result.Type == "socks5h" {
result.Type = "socks5"
}
if result.Host == "" {
return result, fmt.Errorf("代理主机为空")
}
if result.Port <= 0 || result.Port > 65535 {
return result, fmt.Errorf("代理端口无效:%d", result.Port)
}
return result, nil
}
func GetOrCreateLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
cfg, err := NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
if strings.TrimSpace(remoteHost) == "" || remotePort <= 0 {
return nil, fmt.Errorf("无效的远端地址:%s:%d", remoteHost, remotePort)
}
key := forwarderCacheKey(cfg, remoteHost, remotePort)
forwarderMu.RLock()
forwarder, exists := localForwarders[key]
forwarderMu.RUnlock()
if exists && forwarder != nil && !forwarder.IsClosed() {
return forwarder, nil
}
if exists {
forwarderMu.Lock()
delete(localForwarders, key)
forwarderMu.Unlock()
}
next, err := NewLocalForwarder(cfg, remoteHost, remotePort)
if err != nil {
return nil, err
}
forwarderMu.Lock()
localForwarders[key] = next
forwarderMu.Unlock()
return next, nil
}
func forwarderCacheKey(cfg connection.ProxyConfig, remoteHost string, remotePort int) string {
trimmedHost := strings.TrimSpace(remoteHost)
credential := cfg.User + "\x00" + cfg.Password
credentialHash := sha256.Sum256([]byte(credential))
// 仅保留短指纹用于区分不同认证信息,避免在 key 日志中泄露明文口令。
fingerprint := hex.EncodeToString(credentialHash[:8])
return fmt.Sprintf("%s://%s:%d@%s:%d#%s", cfg.Type, cfg.Host, cfg.Port, trimmedHost, remotePort, fingerprint)
}
func NewLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
cfg, err := NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("创建本地代理监听失败:%w", err)
}
localAddr := listener.Addr().String()
remoteAddr := net.JoinHostPort(strings.TrimSpace(remoteHost), fmt.Sprintf("%d", remotePort))
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
forwarder := &LocalForwarder{
LocalAddr: localAddr,
RemoteAddr: remoteAddr,
ProxyAddr: proxyAddr,
ProxyType: cfg.Type,
cfg: cfg,
listener: listener,
closeChan: make(chan struct{}),
}
go forwarder.forward()
logger.Infof("已创建代理端口转发:本地 %s -> 远端 %s代理 %s://%s", localAddr, remoteAddr, cfg.Type, proxyAddr)
return forwarder, nil
}
func (f *LocalForwarder) forward() {
for {
localConn, err := f.listener.Accept()
if err != nil {
select {
case <-f.closeChan:
return
default:
logger.Warnf("接受本地代理连接失败:%v", err)
return
}
}
go f.handleConnection(localConn)
}
}
func (f *LocalForwarder) handleConnection(localConn net.Conn) {
defer localConn.Close()
ctx, cancel := context.WithTimeout(context.Background(), defaultDialTimeout)
remoteConn, err := dialThroughProxy(ctx, f.cfg, "tcp", f.RemoteAddr)
cancel()
if err != nil {
logger.Warnf("通过代理连接远端失败:远端=%s 代理=%s://%s 错误=%v", f.RemoteAddr, f.ProxyType, f.ProxyAddr, err)
return
}
defer remoteConn.Close()
errc := make(chan error, 2)
var closeOnce sync.Once
closeBoth := func() {
_ = localConn.Close()
_ = remoteConn.Close()
}
go func() {
_, copyErr := io.Copy(remoteConn, localConn)
closeOnce.Do(closeBoth)
errc <- copyErr
}()
go func() {
_, copyErr := io.Copy(localConn, remoteConn)
closeOnce.Do(closeBoth)
errc <- copyErr
}()
<-errc
<-errc
}
func (f *LocalForwarder) Close() error {
var err error
f.closeOnce.Do(func() {
f.closedMu.Lock()
f.closed = true
f.closedMu.Unlock()
close(f.closeChan)
err = f.listener.Close()
if err != nil {
logger.Warnf("关闭代理端口转发失败:%v", err)
}
})
return err
}
func (f *LocalForwarder) IsClosed() bool {
f.closedMu.RLock()
defer f.closedMu.RUnlock()
return f.closed
}
func CloseAllForwarders() {
forwarderMu.Lock()
defer forwarderMu.Unlock()
for key, forwarder := range localForwarders {
if forwarder == nil {
continue
}
_ = forwarder.Close()
logger.Infof("已关闭代理端口转发:%s", key)
}
localForwarders = make(map[string]*LocalForwarder)
}
func DialContext(ctx context.Context, proxyConfig connection.ProxyConfig, network, address string) (net.Conn, error) {
cfg, err := NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
return dialThroughProxy(ctx, cfg, network, address)
}
func dialThroughProxy(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
switch cfg.Type {
case "socks5":
return dialSOCKS5(ctx, cfg, network, address)
case "http":
return dialHTTPConnect(ctx, cfg, address)
default:
return nil, fmt.Errorf("不支持的代理类型:%s", cfg.Type)
}
}
func dialSOCKS5(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
var auth *xproxy.Auth
if cfg.User != "" || cfg.Password != "" {
auth = &xproxy.Auth{
User: cfg.User,
Password: cfg.Password,
}
}
dialer, err := xproxy.SOCKS5("tcp", proxyAddr, auth, &net.Dialer{Timeout: defaultDialTimeout})
if err != nil {
return nil, fmt.Errorf("创建 SOCKS5 代理拨号器失败:%w", err)
}
type result struct {
conn net.Conn
err error
}
ch := make(chan result, 1)
go func() {
conn, dialErr := dialer.Dial(network, address)
ch <- result{conn: conn, err: dialErr}
}()
select {
case <-ctx.Done():
go func() {
r := <-ch
if r.conn != nil {
_ = r.conn.Close()
}
}()
return nil, ctx.Err()
case r := <-ch:
if r.err != nil {
return nil, fmt.Errorf("SOCKS5 代理连接失败:%w", r.err)
}
return r.conn, nil
}
}
func dialHTTPConnect(ctx context.Context, cfg connection.ProxyConfig, address string) (net.Conn, error) {
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
dialer := &net.Dialer{Timeout: defaultDialTimeout}
conn, err := dialer.DialContext(ctx, "tcp", proxyAddr)
if err != nil {
return nil, fmt.Errorf("连接 HTTP 代理失败:%w", err)
}
connectReq := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: address},
Host: address,
Header: make(http.Header),
}
if cfg.User != "" || cfg.Password != "" {
raw := cfg.User + ":" + cfg.Password
connectReq.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw)))
}
if err := connectReq.Write(conn); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("发送 HTTP CONNECT 请求失败:%w", err)
}
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, connectReq)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("读取 HTTP CONNECT 响应失败:%w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_ = conn.Close()
return nil, fmt.Errorf("HTTP 代理 CONNECT 失败:%s", strings.TrimSpace(resp.Status))
}
if reader.Buffered() == 0 {
return conn, nil
}
return &bufferedConn{Conn: conn, reader: reader}, nil
}
type bufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (c *bufferedConn) Read(p []byte) (int, error) {
if c.reader == nil {
return c.Conn.Read(p)
}
if c.reader.Buffered() == 0 {
c.reader = nil
return c.Conn.Read(p)
}
return c.reader.Read(p)
}

View File

@@ -0,0 +1,44 @@
package proxy
import (
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestNormalizeConfigSupportsSocks5hAlias(t *testing.T) {
cfg, err := NormalizeConfig(connection.ProxyConfig{
Type: "SOCKS5H",
Host: "127.0.0.1",
Port: 1080,
})
if err != nil {
t.Fatalf("NormalizeConfig returned error: %v", err)
}
if cfg.Type != "socks5" {
t.Fatalf("expected normalized proxy type socks5, got %s", cfg.Type)
}
}
func TestForwarderCacheKeyIncludesCredentialFingerprint(t *testing.T) {
base := connection.ProxyConfig{
Type: "socks5",
Host: "127.0.0.1",
Port: 1080,
User: "tester",
Password: "first-password",
}
other := base
other.Password = "second-password"
keyA := forwarderCacheKey(base, "db.internal", 3306)
keyB := forwarderCacheKey(other, "db.internal", 3306)
if keyA == keyB {
t.Fatalf("expected different cache key for different credentials")
}
if strings.Contains(keyA, base.Password) || strings.Contains(keyB, other.Password) {
t.Fatalf("cache key should not contain raw password")
}
}

View File

@@ -22,6 +22,14 @@ type RedisClientImpl struct {
forwarder *ssh.LocalForwarder
}
const (
redisScanDefaultTargetCount int64 = 2000
redisScanMaxTargetCount int64 = 10000
redisScanMinStepCount int64 = 200
redisScanMaxStepCount int64 = 2000
redisScanMaxRounds = 64
)
// NewRedisClient creates a new Redis client instance
func NewRedisClient() RedisClient {
return &RedisClientImpl{}
@@ -108,21 +116,70 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
if pattern == "" {
pattern = "*"
}
targetCount := normalizeRedisScanTargetCount(count)
scanStepCount := normalizeRedisScanStepCount(targetCount)
currentCursor := cursor
round := 0
keys := make([]string, 0, int(targetCount))
seen := make(map[string]struct{}, int(targetCount))
for len(keys) < int(targetCount) {
batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result()
if err != nil {
return nil, err
}
for _, key := range batch {
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
if len(keys) >= int(targetCount) {
break
}
}
currentCursor = nextCursor
round++
if currentCursor == 0 || round >= redisScanMaxRounds {
break
}
}
return &RedisScanResult{
Keys: r.loadRedisKeyInfos(ctx, keys),
Cursor: currentCursor,
}, nil
}
func normalizeRedisScanTargetCount(count int64) int64 {
if count <= 0 {
count = 100
return redisScanDefaultTargetCount
}
if count > redisScanMaxTargetCount {
return redisScanMaxTargetCount
}
return count
}
func normalizeRedisScanStepCount(targetCount int64) int64 {
if targetCount < redisScanMinStepCount {
return redisScanMinStepCount
}
if targetCount > redisScanMaxStepCount {
return redisScanMaxStepCount
}
return targetCount
}
func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) []RedisKeyInfo {
result := make([]RedisKeyInfo, 0, len(keys))
if len(keys) == 0 {
return result
}
keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, count).Result()
if err != nil {
return nil, err
}
result := &RedisScanResult{
Keys: make([]RedisKeyInfo, 0, len(keys)),
Cursor: nextCursor,
}
// Get type and TTL for each key
pipe := r.client.Pipeline()
typeResults := make([]*redis.StatusCmd, len(keys))
ttlResults := make([]*redis.DurationCmd, len(keys))
@@ -132,37 +189,44 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
ttlResults[i] = pipe.TTL(ctx, key)
}
_, err = pipe.Exec(ctx)
_, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
// Fallback: get info one by one
for _, key := range keys {
keyType, _ := r.GetKeyType(key)
ttl, _ := r.GetTTL(key)
result.Keys = append(result.Keys, RedisKeyInfo{
keyType, typeErr := r.client.Type(ctx, key).Result()
if typeErr != nil && typeErr != redis.Nil {
keyType = ""
}
ttlValue, ttlErr := r.client.TTL(ctx, key).Result()
if ttlErr != nil && ttlErr != redis.Nil {
ttlValue = -2
}
result = append(result, RedisKeyInfo{
Key: key,
Type: keyType,
TTL: ttl,
TTL: toRedisTTLSeconds(ttlValue),
})
}
return result, nil
return result
}
for i, key := range keys {
keyType := typeResults[i].Val()
ttl := int64(ttlResults[i].Val().Seconds())
if ttlResults[i].Val() == -1 {
ttl = -1
} else if ttlResults[i].Val() == -2 {
ttl = -2
}
result.Keys = append(result.Keys, RedisKeyInfo{
result = append(result, RedisKeyInfo{
Key: key,
Type: keyType,
TTL: ttl,
Type: typeResults[i].Val(),
TTL: toRedisTTLSeconds(ttlResults[i].Val()),
})
}
return result
}
return result, nil
func toRedisTTLSeconds(ttl time.Duration) int64 {
if ttl == -1 {
return -1
}
if ttl == -2 {
return -2
}
return int64(ttl.Seconds())
}
// GetKeyType returns the type of a key

View File

@@ -41,6 +41,7 @@ func main() {
BackdropType: windows.Acrylic,
DisableWindowIcon: false,
DisableFramelessWindowDecorations: false,
WebviewUserDataPath: resolveWindowsWebviewUserDataPath(),
},
Mac: &mac.Options{
WebviewIsTransparent: true,

View File

@@ -0,0 +1,123 @@
//go:build windows
package main
import (
"io"
"os"
"path/filepath"
"strings"
)
func resolveWindowsWebviewUserDataPath() string {
appDataDir := strings.TrimSpace(os.Getenv("APPDATA"))
if appDataDir == "" {
return ""
}
targetDir := filepath.Join(appDataDir, "GoNavi", "WebView2")
_ = migrateLegacyWindowsWebviewUserData(appDataDir, targetDir)
return targetDir
}
func migrateLegacyWindowsWebviewUserData(appDataDir, targetDir string) error {
if dirHasContent(targetDir) {
return nil
}
exeName := "GoNavi.exe"
if exePath, err := os.Executable(); err == nil {
base := strings.TrimSpace(filepath.Base(exePath))
if base != "" {
exeName = base
}
}
exeBase := strings.TrimSuffix(exeName, filepath.Ext(exeName))
candidates := []string{
filepath.Join(appDataDir, exeName),
filepath.Join(appDataDir, exeBase),
filepath.Join(appDataDir, "GoNavi.exe"),
filepath.Join(appDataDir, "GoNavi"),
}
seen := make(map[string]struct{}, len(candidates))
for _, candidate := range candidates {
src := filepath.Clean(strings.TrimSpace(candidate))
if src == "" || strings.EqualFold(src, filepath.Clean(targetDir)) {
continue
}
key := strings.ToLower(src)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if !dirHasContent(src) {
continue
}
return copyDirTree(src, targetDir)
}
return nil
}
func dirHasContent(path string) bool {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return false
}
entries, err := os.ReadDir(path)
return err == nil && len(entries) > 0
}
func copyDirTree(srcDir, dstDir string) error {
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return err
}
return filepath.WalkDir(srcDir, func(srcPath string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
relPath, err := filepath.Rel(srcDir, srcPath)
if err != nil {
return err
}
if relPath == "." {
return nil
}
dstPath := filepath.Join(dstDir, relPath)
if d.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
return copyFileWithMode(srcPath, dstPath, info.Mode())
})
}
func copyFileWithMode(srcPath, dstPath string, mode os.FileMode) error {
srcFile, err := os.Open(srcPath)
if err != nil {
return err
}
defer srcFile.Close()
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,7 @@
//go:build !windows
package main
func resolveWindowsWebviewUserDataPath() string {
return ""
}