mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 22:09:40 +08:00
Release/0.5.1 (#149)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容 - DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败 - DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试 - 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致 - 增强查询异常日志与重试路径,降低大表场景卡顿与误报 * ✨ feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示 - 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动 - 显示“匹配 x / y”统计与无结果提示 - 优化头部区域排版,提升透明/暗色场景下的视觉对齐 * 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验 - 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle - 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为 - Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑 - 连接弹窗补充 Oracle 服务名输入项与 URI 示例 * 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径 - 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈 - DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级 - QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致 - 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性 * 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失 - 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度 - 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串 - 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页 - refs #142 * 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导 - 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达” - 网络不可达场景仅保留红色强提醒,移除重复二级告警 - 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理 - 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致 - refs #141 * ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现 - 重构Tab拖拽排序实现,统一为可配置拖拽引擎 - 规范拖拽与点击事件边界,提升交互一致性 - 统一多组件暗色透明样式策略,减少硬编码色值 - 提升Redis/表格/连接面板在透明模式下的观感一致性 - refs #144 * ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示 - 重构更新检查与下载状态同步流程,减少前后端状态分叉 - 进度展示严格绑定 latestVersion,避免跨版本状态串用 - 优化 about 打开场景的静默检查状态回填逻辑 - 统一下载弹窗关闭/后台隐藏行为 - 保持现有安装流程并补齐目录打开能力 * 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式 - 移除侧栏底部整条日志入口容器 - 新增悬浮按钮阴影/边框/透明背景并适配明暗主题 - 为树区域预留底部空间避免入口遮挡内容 * ✨ feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换 - 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示 - 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离 - 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则 - 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空 - refs #145 * ✨ feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复 - 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题 - 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM - 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条 - 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动) - 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题 - 新增白色主题全局滚动条样式适配透明模式(App.css) - App.tsx主题token与组件样式优化 - refs #147 * 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现 - 清除未使用代码和冗余状态 - 替换弃用 API 以消除 IDE 提示 - 显式处理浮动 Promise 避免告警 - 保持现有更新检查和代理设置行为不变 --------- Co-authored-by: Syngnat <yangguofeng919@gmail.com>
This commit is contained in:
58
.github/workflows/release.yml
vendored
58
.github/workflows/release.yml
vendored
@@ -131,6 +131,24 @@ jobs:
|
||||
- name: Install Wails
|
||||
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
|
||||
- name: Prepare MinGW For DuckDB (Windows)
|
||||
if: ${{ matrix.build_optional_agents && contains(matrix.platform, 'windows') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$mingwBin = "C:\msys64\mingw64\bin"
|
||||
if (!(Test-Path $mingwBin)) {
|
||||
choco install mingw --yes --no-progress
|
||||
$mingwBin = "C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin"
|
||||
}
|
||||
if (!(Test-Path $mingwBin)) {
|
||||
Write-Error "❌ 未找到 MinGW GCC 路径:$mingwBin"
|
||||
exit 1
|
||||
}
|
||||
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
"CC=$mingwBin\gcc.exe" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CXX=$mingwBin\g++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Host "✅ 已配置 DuckDB cgo 编译器: $mingwBin"
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -166,20 +184,12 @@ jobs:
|
||||
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_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
DUCKDB_RC=$?
|
||||
set -e
|
||||
if [ "${DUCKDB_RC}" -ne 0 ]; then
|
||||
echo "⚠️ DuckDB 代理构建失败(平台 ${GOOS}/${GOARCH}),跳过该资产,不阻断发布"
|
||||
rm -f "${OUTPUT_PATH}"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
@@ -369,6 +379,38 @@ jobs:
|
||||
- name: List Assets
|
||||
run: ls -R release-assets
|
||||
|
||||
- name: Verify Optional Driver Assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-assets
|
||||
|
||||
REQUIRED_FILES=(
|
||||
"drivers/Windows/duckdb-driver-agent-windows-amd64.exe"
|
||||
"drivers/MacOS/duckdb-driver-agent-darwin-amd64"
|
||||
"drivers/MacOS/duckdb-driver-agent-darwin-arm64"
|
||||
"drivers/Linux/duckdb-driver-agent-linux-amd64"
|
||||
"drivers/Windows/clickhouse-driver-agent-windows-amd64.exe"
|
||||
"drivers/MacOS/clickhouse-driver-agent-darwin-amd64"
|
||||
"drivers/MacOS/clickhouse-driver-agent-darwin-arm64"
|
||||
"drivers/Linux/clickhouse-driver-agent-linux-amd64"
|
||||
)
|
||||
|
||||
missing=0
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "❌ 缺少驱动资产:$file"
|
||||
missing=1
|
||||
else
|
||||
echo "✅ 已找到驱动资产:$file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
echo "❌ 可选驱动资产不完整,终止发布"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package Driver Agents Bundle
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -2,10 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
@@ -16,6 +19,7 @@ type agentRequest struct {
|
||||
Method string `json:"method"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TimeoutMs int64 `json:"timeoutMs,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName,omitempty"`
|
||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||
@@ -47,6 +51,8 @@ const (
|
||||
agentMethodApplyChanges = "applyChanges"
|
||||
)
|
||||
|
||||
const legacyClickHouseDefaultTimeout = 2 * time.Hour
|
||||
|
||||
var (
|
||||
agentDriverType string
|
||||
agentDatabaseFactory func() db.Database
|
||||
@@ -137,14 +143,14 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
case agentMethodQuery:
|
||||
data, fields, err := (*inst).Query(req.Query)
|
||||
data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
case agentMethodExec:
|
||||
affected, err := (*inst).Exec(req.Query)
|
||||
affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
@@ -218,7 +224,11 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
}
|
||||
|
||||
func writeResponse(writer *bufio.Writer, resp agentResponse) error {
|
||||
payload, err := json.Marshal(resp)
|
||||
// 对响应数据做统一 JSON 安全归一化:
|
||||
// 将 map[any]any(如 duckdb.Map)递归转换为 map[string]any,避免序列化失败导致代理进程退出。
|
||||
safeResp := resp
|
||||
safeResp.Data = normalizeAgentResponseData(resp.Data)
|
||||
payload, err := json.Marshal(safeResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -234,3 +244,87 @@ func fail(resp agentResponse, errText string) agentResponse {
|
||||
resp.Error = strings.TrimSpace(errText)
|
||||
return resp
|
||||
}
|
||||
|
||||
func normalizeAgentResponseData(v interface{}) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Pointer, reflect.Interface:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
return normalizeAgentResponseData(rv.Elem().Interface())
|
||||
case reflect.Map:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, rv.Len())
|
||||
iter := rv.MapRange()
|
||||
for iter.Next() {
|
||||
out[fmt.Sprint(iter.Key().Interface())] = normalizeAgentResponseData(iter.Value().Interface())
|
||||
}
|
||||
return out
|
||||
case reflect.Slice:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
// 保持 []byte 原样,避免改变现有二进制列的 JSON 编码行为(base64)。
|
||||
if rv.Type().Elem().Kind() == reflect.Uint8 {
|
||||
return v
|
||||
}
|
||||
size := rv.Len()
|
||||
items := make([]interface{}, size)
|
||||
for i := 0; i < size; i++ {
|
||||
items[i] = normalizeAgentResponseData(rv.Index(i).Interface())
|
||||
}
|
||||
return items
|
||||
case reflect.Array:
|
||||
size := rv.Len()
|
||||
items := make([]interface{}, size)
|
||||
for i := 0; i < size; i++ {
|
||||
items[i] = normalizeAgentResponseData(rv.Index(i).Interface())
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs <= 0 {
|
||||
return inst.Query(query)
|
||||
}
|
||||
if q, ok := inst.(interface {
|
||||
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||
}); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return q.QueryContext(ctx, query)
|
||||
}
|
||||
return inst.Query(query)
|
||||
}
|
||||
|
||||
func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (int64, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs <= 0 {
|
||||
return inst.Exec(query)
|
||||
}
|
||||
if e, ok := inst.(interface {
|
||||
ExecContext(context.Context, string) (int64, error)
|
||||
}); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return e.ExecContext(ctx, query)
|
||||
}
|
||||
return inst.Exec(query)
|
||||
}
|
||||
|
||||
172
cmd/optional-driver-agent/main_test.go
Normal file
172
cmd/optional-driver-agent/main_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
type duckMapLike map[any]any
|
||||
|
||||
func TestWriteResponse_NormalizesMapAnyAny(t *testing.T) {
|
||||
resp := agentResponse{
|
||||
ID: 1,
|
||||
Success: true,
|
||||
Data: []map[string]interface{}{
|
||||
{
|
||||
"id": int64(7),
|
||||
"meta": duckMapLike{"k": "v", 2: "two"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
writer := bufio.NewWriter(&out)
|
||||
if err := writeResponse(writer, resp); err != nil {
|
||||
t.Fatalf("writeResponse 返回错误: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &decoded); err != nil {
|
||||
t.Fatalf("解码响应失败: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Data) != 1 {
|
||||
t.Fatalf("期望 1 行数据,实际 %d", len(decoded.Data))
|
||||
}
|
||||
meta, ok := decoded.Data[0]["meta"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("meta 字段类型异常: %T", decoded.Data[0]["meta"])
|
||||
}
|
||||
if meta["k"] != "v" {
|
||||
t.Fatalf("字符串 key 转换异常: %v", meta["k"])
|
||||
}
|
||||
if meta["2"] != "two" {
|
||||
t.Fatalf("数字 key 未字符串化: %v", meta["2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) {
|
||||
raw := []byte{0x61, 0x62, 0x63}
|
||||
normalized := normalizeAgentResponseData(raw)
|
||||
out, ok := normalized.([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("期望 []byte,实际 %T", normalized)
|
||||
}
|
||||
if !bytes.Equal(out, raw) {
|
||||
t.Fatalf("[]byte 内容被意外改写: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAgentTimeoutDB struct {
|
||||
queryCalled bool
|
||||
queryContextCalled bool
|
||||
execCalled bool
|
||||
execContextCalled bool
|
||||
deadlineSet bool
|
||||
}
|
||||
|
||||
func (f *fakeAgentTimeoutDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||
func (f *fakeAgentTimeoutDB) Close() error { return nil }
|
||||
func (f *fakeAgentTimeoutDB) Ping() error { return nil }
|
||||
func (f *fakeAgentTimeoutDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
f.queryCalled = true
|
||||
return nil, nil, errors.New("query should not be called")
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
f.queryContextCalled = true
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
f.deadlineSet = true
|
||||
}
|
||||
return []map[string]interface{}{{"ok": 1}}, []string{"ok"}, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) Exec(query string) (int64, error) {
|
||||
f.execCalled = true
|
||||
return 0, errors.New("exec should not be called")
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
f.execContextCalled = true
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
f.deadlineSet = true
|
||||
}
|
||||
return 3, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetDatabases() ([]string, error) { return nil, nil }
|
||||
func (f *fakeAgentTimeoutDB) GetTables(dbName string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) {
|
||||
fake := &fakeAgentTimeoutDB{}
|
||||
data, fields, err := queryWithOptionalTimeout(fake, "SELECT 1", int64((2 * time.Second).Milliseconds()))
|
||||
if err != nil {
|
||||
t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err)
|
||||
}
|
||||
if !fake.queryContextCalled || fake.queryCalled {
|
||||
t.Fatalf("query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
|
||||
}
|
||||
if !fake.deadlineSet {
|
||||
t.Fatal("queryWithOptionalTimeout 未设置 deadline")
|
||||
}
|
||||
if len(data) != 1 || len(fields) != 1 || fields[0] != "ok" {
|
||||
t.Fatalf("queryWithOptionalTimeout 返回数据异常: data=%v fields=%v", data, fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecWithOptionalTimeout_UsesExecContext(t *testing.T) {
|
||||
fake := &fakeAgentTimeoutDB{}
|
||||
affected, err := execWithOptionalTimeout(fake, "DELETE FROM t", int64((2 * time.Second).Milliseconds()))
|
||||
if err != nil {
|
||||
t.Fatalf("execWithOptionalTimeout 返回错误: %v", err)
|
||||
}
|
||||
if !fake.execContextCalled || fake.execCalled {
|
||||
t.Fatalf("exec 调用路径异常,ExecContext=%v Exec=%v", fake.execContextCalled, fake.execCalled)
|
||||
}
|
||||
if !fake.deadlineSet {
|
||||
t.Fatal("execWithOptionalTimeout 未设置 deadline")
|
||||
}
|
||||
if affected != 3 {
|
||||
t.Fatalf("受影响行数异常,want=3 got=%d", affected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testing.T) {
|
||||
old := agentDriverType
|
||||
agentDriverType = "clickhouse"
|
||||
defer func() { agentDriverType = old }()
|
||||
|
||||
fake := &fakeAgentTimeoutDB{}
|
||||
_, _, err := queryWithOptionalTimeout(fake, "SELECT 1", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("queryWithOptionalTimeout 返回错误: %v", err)
|
||||
}
|
||||
if !fake.queryContextCalled || fake.queryCalled {
|
||||
t.Fatalf("clickhouse legacy query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"duckdb": {
|
||||
"engine": "go",
|
||||
"version": "2.5.5",
|
||||
"version": "2.5.6",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/duckdb"
|
||||
},
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"clickhouse": {
|
||||
"engine": "go",
|
||||
"version": "2.43.0",
|
||||
"version": "2.43.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/clickhouse"
|
||||
},
|
||||
|
||||
@@ -57,6 +57,29 @@ body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for light mode (transparent-friendly) */
|
||||
body[data-theme='light'] ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.30);
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
|
||||
body {
|
||||
transition: color 0.3s;
|
||||
@@ -67,6 +90,51 @@ body[data-theme='dark'] {
|
||||
在透明窗口环境下会显著加剧 GPU 负载 */
|
||||
}
|
||||
|
||||
/* 暗色 + 透明:提升选中/焦点可读性,避免默认蓝色在半透明背景下发灰 */
|
||||
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
|
||||
body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
|
||||
background: rgba(246, 196, 83, 0.24) !important;
|
||||
color: rgba(255, 236, 179, 0.98) !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #f6c453 !important;
|
||||
border-color: #f6c453 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-checkbox-indeterminate .ant-checkbox-inner::after {
|
||||
background-color: #f6c453 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-checkbox:hover .ant-checkbox-inner,
|
||||
body[data-theme='dark'] .ant-checkbox-wrapper:hover .ant-checkbox-inner {
|
||||
border-color: #f6c453 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-radio-checked .ant-radio-inner {
|
||||
border-color: #f6c453 !important;
|
||||
background-color: #f6c453 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-radio-wrapper:hover .ant-radio-inner,
|
||||
body[data-theme='dark'] .ant-radio:hover .ant-radio-inner {
|
||||
border-color: #f6c453 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-switch.ant-switch-checked {
|
||||
background: #d8a93b !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td,
|
||||
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell {
|
||||
background: rgba(246, 196, 83, 0.18) !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td,
|
||||
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell {
|
||||
background: rgba(246, 196, 83, 0.26) !important;
|
||||
}
|
||||
|
||||
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
|
||||
.connection-modal-wrap {
|
||||
overflow: hidden !important;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowToggleMaximise } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -16,6 +16,25 @@ import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/A
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
const MIN_FONT_SIZE = 12;
|
||||
const MAX_FONT_SIZE = 20;
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
|
||||
const detectNavigatorPlatform = (): string => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const uaDataPlatform = (navigator as Navigator & {
|
||||
userAgentData?: { platform?: string };
|
||||
}).userAgentData?.platform;
|
||||
if (uaDataPlatform) {
|
||||
return uaDataPlatform;
|
||||
}
|
||||
return navigator.userAgent || '';
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -26,15 +45,33 @@ function App() {
|
||||
const setTheme = useStore(state => state.setTheme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const setAppearance = useStore(state => state.setAppearance);
|
||||
const uiScale = useStore(state => state.uiScale);
|
||||
const setUiScale = useStore(state => state.setUiScale);
|
||||
const fontSize = useStore(state => state.fontSize);
|
||||
const setFontSize = useStore(state => state.setFontSize);
|
||||
const startupFullscreen = useStore(state => state.startupFullscreen);
|
||||
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
|
||||
const globalProxy = useStore(state => state.globalProxy);
|
||||
const setGlobalProxy = useStore(state => state.setGlobalProxy);
|
||||
const darkMode = themeMode === 'dark';
|
||||
const effectiveUiScale = Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Number(uiScale) || DEFAULT_UI_SCALE));
|
||||
const effectiveFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(Number(fontSize) || DEFAULT_FONT_SIZE)));
|
||||
const tokenFontSize = Math.round(effectiveFontSize * effectiveUiScale);
|
||||
const tokenFontSizeSM = Math.max(10, Math.round(tokenFontSize * 0.86));
|
||||
const tokenFontSizeLG = Math.max(tokenFontSize + 1, Math.round(tokenFontSize * 1.14));
|
||||
const tokenControlHeight = Math.max(24, Math.round(32 * effectiveUiScale));
|
||||
const tokenControlHeightSM = Math.max(20, Math.round(24 * effectiveUiScale));
|
||||
const tokenControlHeightLG = Math.max(30, Math.round(40 * effectiveUiScale));
|
||||
const appComponentSize: 'small' | 'middle' | 'large' = effectiveUiScale <= 0.92 ? 'small' : (effectiveUiScale >= 1.12 ? 'large' : 'middle');
|
||||
const titleBarHeight = Math.max(28, Math.round(32 * effectiveUiScale));
|
||||
const toolbarHeight = Math.max(32, Math.round(36 * effectiveUiScale));
|
||||
const titleBarButtonWidth = Math.max(40, Math.round(46 * effectiveUiScale));
|
||||
const floatingLogButtonHeight = Math.max(30, Math.round(34 * effectiveUiScale));
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
|
||||
const blurFilter = blurToFilter(effectiveBlur);
|
||||
const windowCornerRadius = 14;
|
||||
const [runtimePlatform, setRuntimePlatform] = useState('');
|
||||
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
|
||||
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
|
||||
const globalProxyInvalidHintShownRef = React.useRef(false);
|
||||
@@ -42,7 +79,7 @@ function App() {
|
||||
// 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView,
|
||||
// 避免 GPU 持续计算窗口背后的模糊合成
|
||||
useEffect(() => {
|
||||
SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => {});
|
||||
void SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => undefined);
|
||||
}, [appearance.opacity, appearance.blur]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,12 +87,18 @@ function App() {
|
||||
Environment()
|
||||
.then((env) => {
|
||||
if (cancelled) return;
|
||||
setIsLinuxRuntime((env?.platform || '').toLowerCase() === 'linux');
|
||||
const platform = String(env?.platform || '').toLowerCase();
|
||||
setRuntimePlatform(platform);
|
||||
setIsLinuxRuntime(platform === 'linux');
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
const platform = typeof navigator !== 'undefined' ? navigator.platform : '';
|
||||
setIsLinuxRuntime(/linux/i.test(platform));
|
||||
const platform = detectNavigatorPlatform();
|
||||
const normalized = /linux/i.test(platform)
|
||||
? 'linux'
|
||||
: (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : ''));
|
||||
setRuntimePlatform(normalized);
|
||||
setIsLinuxRuntime(normalized === 'linux');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -86,7 +129,7 @@ function App() {
|
||||
|
||||
if (invalidWhenEnabled) {
|
||||
if (!globalProxyInvalidHintShownRef.current) {
|
||||
message.warning({
|
||||
void message.warning({
|
||||
content: '全局代理已开启,但地址或端口无效,当前按未启用处理',
|
||||
key: 'global-proxy-invalid',
|
||||
});
|
||||
@@ -94,7 +137,7 @@ function App() {
|
||||
}
|
||||
} else {
|
||||
globalProxyInvalidHintShownRef.current = false;
|
||||
message.destroy('global-proxy-invalid');
|
||||
void message.destroy('global-proxy-invalid');
|
||||
}
|
||||
|
||||
const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled;
|
||||
@@ -110,7 +153,7 @@ function App() {
|
||||
if (cancelled || res?.success) {
|
||||
return;
|
||||
}
|
||||
message.error({
|
||||
void message.error({
|
||||
content: '全局代理配置失败: ' + (res?.message || '未知错误'),
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
@@ -120,7 +163,7 @@ function App() {
|
||||
return;
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
|
||||
message.error({
|
||||
void message.error({
|
||||
content: '全局代理配置失败: ' + errMsg,
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
@@ -175,18 +218,18 @@ function App() {
|
||||
if (!useStore.getState().startupFullscreen) {
|
||||
return;
|
||||
}
|
||||
Promise.resolve()
|
||||
void Promise.resolve()
|
||||
.then(async () => {
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
return;
|
||||
}
|
||||
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
|
||||
WindowFullscreen();
|
||||
await WindowFullscreen();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
return;
|
||||
}
|
||||
WindowMaximise();
|
||||
await WindowMaximise();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
return;
|
||||
@@ -195,7 +238,7 @@ function App() {
|
||||
applyStartupWindowPreference(attempt + 1);
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
}, applyRetryDelayMs);
|
||||
};
|
||||
|
||||
if (useStore.persist.hasHydrated()) {
|
||||
@@ -218,7 +261,7 @@ function App() {
|
||||
}, []);
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string, lightHex: string) => {
|
||||
const getBg = (darkHex: string) => {
|
||||
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
|
||||
|
||||
// Parse hex to rgb
|
||||
@@ -229,8 +272,16 @@ function App() {
|
||||
return `rgba(${r}, ${g}, ${b}, ${effectiveOpacity})`;
|
||||
};
|
||||
// Specific colors
|
||||
const bgMain = getBg('#141414', '#ffffff');
|
||||
const bgContent = getBg('#1d1d1d', '#ffffff');
|
||||
const bgMain = getBg('#141414');
|
||||
const bgContent = getBg('#1d1d1d');
|
||||
const floatingLogButtonBorderColor = darkMode ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.16)';
|
||||
const floatingLogButtonTextColor = darkMode ? 'rgba(255,255,255,0.92)' : 'rgba(0,0,0,0.82)';
|
||||
const floatingLogButtonBgColor = darkMode
|
||||
? `rgba(34, 34, 34, ${Math.max(effectiveOpacity, 0.82)})`
|
||||
: `rgba(255, 255, 255, ${Math.max(effectiveOpacity, 0.9)})`;
|
||||
const floatingLogButtonShadow = darkMode
|
||||
? '0 8px 22px rgba(0,0,0,0.38)'
|
||||
: '0 8px 20px rgba(0,0,0,0.16)';
|
||||
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const activeContext = useStore(state => state.activeContext);
|
||||
@@ -241,11 +292,12 @@ function App() {
|
||||
const updateCheckInFlightRef = React.useRef(false);
|
||||
const updateDownloadInFlightRef = React.useRef(false);
|
||||
const updateDownloadedVersionRef = React.useRef<string | null>(null);
|
||||
const updateInstallTriggeredVersionRef = React.useRef<string | null>(null);
|
||||
const updateDownloadMetaRef = React.useRef<UpdateDownloadResultData | null>(null);
|
||||
const updateDeferredVersionRef = React.useRef<string | null>(null);
|
||||
const updateNotifiedVersionRef = React.useRef<string | null>(null);
|
||||
const updateMutedVersionRef = React.useRef<string | null>(null);
|
||||
const [isAboutOpen, setIsAboutOpen] = useState(false);
|
||||
const isAboutOpenRef = React.useRef(false);
|
||||
const [aboutLoading, setAboutLoading] = useState(false);
|
||||
const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string } | null>(null);
|
||||
const [aboutUpdateStatus, setAboutUpdateStatus] = useState<string>('');
|
||||
@@ -299,6 +351,9 @@ function App() {
|
||||
autoRelaunch?: boolean;
|
||||
};
|
||||
|
||||
const isMacRuntime = runtimePlatform === 'darwin'
|
||||
|| (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform()));
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
@@ -311,52 +366,18 @@ function App() {
|
||||
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
};
|
||||
|
||||
const promptRestartForUpdate = (info: UpdateInfo, resultData?: UpdateDownloadResultData) => {
|
||||
const downloadPathHint = resultData?.downloadPath
|
||||
? `更新包路径:${resultData.downloadPath}`
|
||||
: '';
|
||||
const installLogHint = resultData?.installLogPath
|
||||
? `安装日志:${resultData.installLogPath}`
|
||||
: '';
|
||||
Modal.confirm({
|
||||
title: '更新已下载',
|
||||
content: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, userSelect: 'text' }}>
|
||||
<div>{`版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`}</div>
|
||||
{downloadPathHint ? <div style={{ fontSize: 12, color: '#8c8c8c' }}>{downloadPathHint}</div> : null}
|
||||
{installLogHint ? <div style={{ fontSize: 12, color: '#8c8c8c' }}>{installLogHint}</div> : null}
|
||||
</div>
|
||||
),
|
||||
okText: '立即重启',
|
||||
cancelText: '稍后',
|
||||
onOk: async () => {
|
||||
updateDeferredVersionRef.current = null;
|
||||
const res = await (window as any).go.app.App.InstallUpdateAndRestart();
|
||||
if (!res?.success) {
|
||||
message.error('更新安装失败: ' + (res?.message || '未知错误'));
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
updateDeferredVersionRef.current = info.latestVersion;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const downloadUpdate = React.useCallback(async (info: UpdateInfo, silent: boolean) => {
|
||||
if (updateDownloadInFlightRef.current) return;
|
||||
if (updateDownloadedVersionRef.current === info.latestVersion) {
|
||||
if (!silent) {
|
||||
const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath;
|
||||
message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion})`);
|
||||
}
|
||||
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
|
||||
promptRestartForUpdate(info, updateDownloadMetaRef.current || undefined);
|
||||
void message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion})`);
|
||||
showUpdateDownloadProgress();
|
||||
}
|
||||
return;
|
||||
}
|
||||
updateDownloadInFlightRef.current = true;
|
||||
updateDownloadMetaRef.current = null;
|
||||
const key = 'update-download';
|
||||
setUpdateDownloadProgress({
|
||||
open: true,
|
||||
version: info.latestVersion,
|
||||
@@ -366,33 +387,94 @@ function App() {
|
||||
total: info.assetSize || 0,
|
||||
message: ''
|
||||
});
|
||||
message.loading({ content: `正在下载更新 ${info.latestVersion}...`, key, duration: 0 });
|
||||
const res = await (window as any).go.app.App.DownloadUpdate();
|
||||
updateDownloadInFlightRef.current = false;
|
||||
if (res?.success) {
|
||||
const resultData = (res?.data || {}) as UpdateDownloadResultData;
|
||||
updateDownloadMetaRef.current = resultData;
|
||||
updateDownloadedVersionRef.current = info.latestVersion;
|
||||
setUpdateDownloadProgress(prev => ({ ...prev, status: 'done', percent: 100, open: false }));
|
||||
setUpdateDownloadProgress(prev => {
|
||||
const total = prev.total > 0 ? prev.total : (info.assetSize || 0);
|
||||
return { ...prev, status: 'done', percent: 100, downloaded: total, total, message: '', open: false };
|
||||
});
|
||||
setLastUpdateInfo((prev) => {
|
||||
if (!prev || prev.latestVersion !== info.latestVersion) {
|
||||
return {
|
||||
...info,
|
||||
downloaded: true,
|
||||
downloadPath: resultData?.downloadPath || info.downloadPath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
downloaded: true,
|
||||
downloadPath: resultData?.downloadPath || prev.downloadPath || info.downloadPath,
|
||||
};
|
||||
});
|
||||
if (resultData?.downloadPath) {
|
||||
message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, key, duration: 5 });
|
||||
void message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, duration: 5 });
|
||||
} else {
|
||||
message.success({ content: '更新下载完成', key, duration: 2 });
|
||||
}
|
||||
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,待重启安装)`);
|
||||
if (!silent || updateDeferredVersionRef.current !== info.latestVersion) {
|
||||
promptRestartForUpdate(info, resultData);
|
||||
void message.success({ content: '更新下载完成', duration: 2 });
|
||||
}
|
||||
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,请点击“下载进度”后安装)`);
|
||||
} else {
|
||||
setUpdateDownloadProgress(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
message: res?.message || '未知错误'
|
||||
}));
|
||||
message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), key, duration: 4 });
|
||||
void message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), duration: 4 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showUpdateDownloadProgress = React.useCallback(() => {
|
||||
setUpdateDownloadProgress((prev) => {
|
||||
if (prev.status === 'idle') return prev;
|
||||
return { ...prev, open: true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hideUpdateDownloadProgress = React.useCallback(() => {
|
||||
setUpdateDownloadProgress((prev) => ({ ...prev, open: false }));
|
||||
}, []);
|
||||
|
||||
const isLatestUpdateDownloaded = Boolean(lastUpdateInfo?.hasUpdate) && (
|
||||
Boolean(lastUpdateInfo?.downloaded)
|
||||
|| (Boolean(lastUpdateInfo?.latestVersion) && updateDownloadedVersionRef.current === lastUpdateInfo?.latestVersion)
|
||||
);
|
||||
const isBackgroundProgressForLatestUpdate = Boolean(lastUpdateInfo?.hasUpdate)
|
||||
&& Boolean(lastUpdateInfo?.latestVersion)
|
||||
&& updateDownloadProgress.version === lastUpdateInfo?.latestVersion
|
||||
&& (updateDownloadProgress.status === 'start'
|
||||
|| updateDownloadProgress.status === 'downloading'
|
||||
|| updateDownloadProgress.status === 'error');
|
||||
const canShowProgressEntry = (isLatestUpdateDownloaded || isBackgroundProgressForLatestUpdate)
|
||||
&& updateInstallTriggeredVersionRef.current !== (lastUpdateInfo?.latestVersion || null);
|
||||
|
||||
const handleInstallFromProgress = React.useCallback(async () => {
|
||||
if (updateDownloadProgress.status !== 'done') {
|
||||
return;
|
||||
}
|
||||
if (isMacRuntime) {
|
||||
const res = await (window as any).go.app.App.OpenDownloadedUpdateDirectory();
|
||||
if (!res?.success) {
|
||||
void message.error('打开安装目录失败: ' + (res?.message || '未知错误'));
|
||||
return;
|
||||
}
|
||||
updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null;
|
||||
hideUpdateDownloadProgress();
|
||||
void message.success(res?.message || '已打开安装目录,请手动完成替换');
|
||||
return;
|
||||
}
|
||||
const res = await (window as any).go.app.App.InstallUpdateAndRestart();
|
||||
if (!res?.success) {
|
||||
void message.error('更新安装失败: ' + (res?.message || '未知错误'));
|
||||
return;
|
||||
}
|
||||
updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null;
|
||||
hideUpdateDownloadProgress();
|
||||
}, [hideUpdateDownloadProgress, isMacRuntime, lastUpdateInfo?.latestVersion, updateDownloadProgress.status, updateDownloadProgress.version]);
|
||||
|
||||
const checkForUpdates = React.useCallback(async (silent: boolean) => {
|
||||
if (updateCheckInFlightRef.current) return;
|
||||
updateCheckInFlightRef.current = true;
|
||||
@@ -403,14 +485,14 @@ function App() {
|
||||
updateCheckInFlightRef.current = false;
|
||||
if (!res?.success) {
|
||||
if (!silent) {
|
||||
message.error('检查更新失败: ' + (res?.message || '未知错误'));
|
||||
void message.error('检查更新失败: ' + (res?.message || '未知错误'));
|
||||
setAboutUpdateStatus('检查更新失败: ' + (res?.message || '未知错误'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const info: UpdateInfo = res.data;
|
||||
if (!info) return;
|
||||
setLastUpdateInfo(info);
|
||||
const aboutOpen = isAboutOpenRef.current;
|
||||
if (info.hasUpdate) {
|
||||
const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion;
|
||||
const hasDownloaded = Boolean(info.downloaded) || localDownloaded;
|
||||
@@ -422,34 +504,103 @@ function App() {
|
||||
info,
|
||||
downloadPath: downloadPath || undefined,
|
||||
};
|
||||
setUpdateDownloadProgress((prev) => {
|
||||
if (prev.status === 'start' || prev.status === 'downloading') {
|
||||
return prev;
|
||||
}
|
||||
const total = info.assetSize || prev.total || 0;
|
||||
return {
|
||||
...prev,
|
||||
open: prev.open && prev.version === info.latestVersion,
|
||||
version: info.latestVersion,
|
||||
status: 'done',
|
||||
percent: 100,
|
||||
downloaded: total,
|
||||
total,
|
||||
message: '',
|
||||
};
|
||||
});
|
||||
setLastUpdateInfo({
|
||||
...info,
|
||||
downloaded: true,
|
||||
downloadPath: downloadPath || undefined,
|
||||
});
|
||||
} else {
|
||||
if (updateDownloadedVersionRef.current !== info.latestVersion) {
|
||||
updateDownloadMetaRef.current = null;
|
||||
}
|
||||
setUpdateDownloadProgress((prev) => {
|
||||
if (prev.status === 'start' || prev.status === 'downloading') {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
open: false,
|
||||
version: info.latestVersion,
|
||||
status: 'idle',
|
||||
percent: 0,
|
||||
downloaded: 0,
|
||||
total: info.assetSize || 0,
|
||||
message: '',
|
||||
};
|
||||
});
|
||||
setLastUpdateInfo(info);
|
||||
}
|
||||
const statusText = hasDownloaded
|
||||
? `发现新版本 ${info.latestVersion}(已下载,待重启安装)`
|
||||
? `发现新版本 ${info.latestVersion}(已下载,请点击“下载进度”后安装)`
|
||||
: `发现新版本 ${info.latestVersion}(未下载)`;
|
||||
if (!silent) {
|
||||
message.info(`发现新版本 ${info.latestVersion}`);
|
||||
void message.info(`发现新版本 ${info.latestVersion}`);
|
||||
setAboutUpdateStatus(statusText);
|
||||
}
|
||||
if (silent && isAboutOpen) {
|
||||
if (silent && aboutOpen) {
|
||||
setAboutUpdateStatus(statusText);
|
||||
}
|
||||
if (silent && !isAboutOpen && updateMutedVersionRef.current !== info.latestVersion && updateNotifiedVersionRef.current !== info.latestVersion) {
|
||||
if (silent && !aboutOpen && updateMutedVersionRef.current !== info.latestVersion && updateNotifiedVersionRef.current !== info.latestVersion) {
|
||||
updateNotifiedVersionRef.current = info.latestVersion;
|
||||
setIsAboutOpen(true);
|
||||
}
|
||||
} else if (!silent) {
|
||||
setUpdateDownloadProgress((prev) => {
|
||||
if (prev.status === 'start' || prev.status === 'downloading') {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
open: false,
|
||||
version: '',
|
||||
status: 'idle',
|
||||
percent: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
message: '',
|
||||
};
|
||||
});
|
||||
setLastUpdateInfo(info);
|
||||
const text = `当前已是最新版本(${info.currentVersion || '未知'})`;
|
||||
message.success(text);
|
||||
void message.success(text);
|
||||
setAboutUpdateStatus(text);
|
||||
} else if (silent && isAboutOpen) {
|
||||
} else if (silent && aboutOpen) {
|
||||
setUpdateDownloadProgress((prev) => {
|
||||
if (prev.status === 'start' || prev.status === 'downloading') {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
open: false,
|
||||
version: '',
|
||||
status: 'idle',
|
||||
percent: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
message: '',
|
||||
};
|
||||
});
|
||||
setLastUpdateInfo(info);
|
||||
const text = `当前已是最新版本(${info.currentVersion || '未知'})`;
|
||||
setAboutUpdateStatus(text);
|
||||
} else {
|
||||
setLastUpdateInfo(info);
|
||||
}
|
||||
}, [downloadUpdate]);
|
||||
}, []);
|
||||
|
||||
const loadAboutInfo = React.useCallback(async () => {
|
||||
setAboutLoading(true);
|
||||
@@ -457,7 +608,7 @@ function App() {
|
||||
if (res?.success) {
|
||||
setAboutInfo(res.data);
|
||||
} else {
|
||||
message.error('获取应用信息失败: ' + (res?.message || '未知错误'));
|
||||
void message.error('获取应用信息失败: ' + (res?.message || '未知错误'));
|
||||
}
|
||||
setAboutLoading(false);
|
||||
}, []);
|
||||
@@ -498,28 +649,28 @@ function App() {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
message.success(`成功导入 ${count} 个连接`);
|
||||
void message.success(`成功导入 ${count} 个连接`);
|
||||
} else {
|
||||
message.error("文件格式错误:需要 JSON 数组");
|
||||
void message.error("文件格式错误:需要 JSON 数组");
|
||||
}
|
||||
} catch (e) {
|
||||
message.error("解析 JSON 失败");
|
||||
void message.error("解析 JSON 失败");
|
||||
}
|
||||
} else if (res.message !== "Cancelled") {
|
||||
message.error("导入失败: " + res.message);
|
||||
void message.error("导入失败: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportConnections = async () => {
|
||||
if (connections.length === 0) {
|
||||
message.warning("没有连接可导出");
|
||||
void message.warning("没有连接可导出");
|
||||
return;
|
||||
}
|
||||
const res = await (window as any).go.app.App.ExportData(connections, [], "connections", "json");
|
||||
if (res.success) {
|
||||
message.success("导出成功");
|
||||
void message.success("导出成功");
|
||||
} else if (res.message !== "Cancelled") {
|
||||
message.error("导出失败: " + res.message);
|
||||
void message.error("导出失败: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -648,7 +799,7 @@ function App() {
|
||||
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
|
||||
return;
|
||||
}
|
||||
(window as any).runtime.WindowToggleMaximise();
|
||||
WindowToggleMaximise();
|
||||
};
|
||||
|
||||
// Sidebar Resizing
|
||||
@@ -715,27 +866,39 @@ function App() {
|
||||
document.body.style.backgroundColor = 'transparent';
|
||||
document.body.style.color = darkMode ? '#ffffff' : '#000000';
|
||||
document.body.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
document.body.style.fontSize = `${effectiveFontSize}px`;
|
||||
document.documentElement.style.setProperty('--gonavi-font-size', `${effectiveFontSize}px`);
|
||||
}, [darkMode, effectiveFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
isAboutOpenRef.current = isAboutOpen;
|
||||
}, [isAboutOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAboutOpen) {
|
||||
if (lastUpdateInfo?.hasUpdate) {
|
||||
setAboutUpdateStatus(`发现新版本 ${lastUpdateInfo.latestVersion}(未下载)`);
|
||||
const localDownloaded = updateDownloadedVersionRef.current === lastUpdateInfo.latestVersion;
|
||||
const hasDownloaded = Boolean(lastUpdateInfo.downloaded) || localDownloaded;
|
||||
setAboutUpdateStatus(
|
||||
hasDownloaded
|
||||
? `发现新版本 ${lastUpdateInfo.latestVersion}(已下载,请点击“下载进度”后安装)`
|
||||
: `发现新版本 ${lastUpdateInfo.latestVersion}(未下载)`
|
||||
);
|
||||
} else if (lastUpdateInfo) {
|
||||
setAboutUpdateStatus(`当前已是最新版本(${lastUpdateInfo.currentVersion || '未知'})`);
|
||||
} else {
|
||||
setAboutUpdateStatus('未检查');
|
||||
}
|
||||
loadAboutInfo();
|
||||
void loadAboutInfo();
|
||||
}
|
||||
}, [isAboutOpen, lastUpdateInfo, loadAboutInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const startupTimer = window.setTimeout(() => {
|
||||
checkForUpdates(true);
|
||||
void checkForUpdates(true);
|
||||
}, 2000);
|
||||
const interval = window.setInterval(() => {
|
||||
checkForUpdates(true);
|
||||
void checkForUpdates(true);
|
||||
}, 30 * 60 * 1000);
|
||||
return () => {
|
||||
window.clearTimeout(startupTimer);
|
||||
@@ -758,7 +921,7 @@ function App() {
|
||||
: (total > 0 ? (downloaded / total) * 100 : 0);
|
||||
const percent = Math.max(0, Math.min(100, percentRaw));
|
||||
setUpdateDownloadProgress(prev => ({
|
||||
open: nextStatus === 'start' || nextStatus === 'downloading' || nextStatus === 'error',
|
||||
open: prev.open,
|
||||
version: prev.version,
|
||||
status: nextStatus,
|
||||
percent,
|
||||
@@ -782,13 +945,21 @@ function App() {
|
||||
} as any;
|
||||
|
||||
const showLinuxResizeHandles = isLinuxRuntime;
|
||||
const resizeGuideColor = darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)';
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
componentSize={appComponentSize}
|
||||
theme={{
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
fontSize: tokenFontSize,
|
||||
fontSizeSM: tokenFontSizeSM,
|
||||
fontSizeLG: tokenFontSizeLG,
|
||||
controlHeight: tokenControlHeight,
|
||||
controlHeightSM: tokenControlHeightSM,
|
||||
controlHeightLG: tokenControlHeightLG,
|
||||
colorBgLayout: 'transparent',
|
||||
colorBgContainer: darkMode
|
||||
? `rgba(29, 29, 29, ${effectiveOpacity})`
|
||||
@@ -799,6 +970,20 @@ function App() {
|
||||
colorFillAlter: darkMode
|
||||
? `rgba(38, 38, 38, ${effectiveOpacity})`
|
||||
: `rgba(250, 250, 250, ${effectiveOpacity})`,
|
||||
colorPrimary: darkMode ? '#f6c453' : '#1677ff',
|
||||
colorPrimaryHover: darkMode ? '#ffd666' : '#4096ff',
|
||||
colorPrimaryActive: darkMode ? '#d8a93b' : '#0958d9',
|
||||
colorInfo: darkMode ? '#f6c453' : '#1677ff',
|
||||
colorLink: darkMode ? '#ffd666' : '#1677ff',
|
||||
colorLinkHover: darkMode ? '#ffe58f' : '#4096ff',
|
||||
colorLinkActive: darkMode ? '#d8a93b' : '#0958d9',
|
||||
colorPrimaryBg: darkMode ? 'rgba(246, 196, 83, 0.22)' : '#e6f4ff',
|
||||
colorPrimaryBgHover: darkMode ? 'rgba(246, 196, 83, 0.30)' : '#bae0ff',
|
||||
colorPrimaryBorder: darkMode ? 'rgba(246, 196, 83, 0.45)' : '#91caff',
|
||||
colorPrimaryBorderHover: darkMode ? 'rgba(246, 196, 83, 0.60)' : '#69b1ff',
|
||||
controlItemBgActive: darkMode ? 'rgba(246, 196, 83, 0.20)' : 'rgba(22, 119, 255, 0.12)',
|
||||
controlItemBgActiveHover: darkMode ? 'rgba(246, 196, 83, 0.28)' : 'rgba(22, 119, 255, 0.18)',
|
||||
controlOutline: darkMode ? 'rgba(246, 196, 83, 0.50)' : 'rgba(5, 145, 255, 0.24)',
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
@@ -815,7 +1000,10 @@ function App() {
|
||||
},
|
||||
Tabs: {
|
||||
cardBg: 'transparent',
|
||||
itemActiveColor: darkMode ? '#177ddc' : '#1890ff',
|
||||
itemActiveColor: darkMode ? '#ffd666' : '#1890ff',
|
||||
itemHoverColor: darkMode ? '#ffe58f' : '#40a9ff',
|
||||
itemSelectedColor: darkMode ? '#ffd666' : '#1677ff',
|
||||
inkBarColor: darkMode ? '#ffd666' : '#1677ff',
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -835,7 +1023,7 @@ function App() {
|
||||
<div
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
style={{
|
||||
height: 32,
|
||||
height: titleBarHeight,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -845,10 +1033,11 @@ function App() {
|
||||
userSelect: 'none',
|
||||
WebkitAppRegion: 'drag', // Wails drag region
|
||||
'--wails-draggable': 'drag',
|
||||
paddingLeft: 16
|
||||
paddingLeft: Math.max(12, Math.round(16 * effectiveUiScale)),
|
||||
fontSize: tokenFontSize
|
||||
} as any}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: Math.max(6, Math.round(8 * effectiveUiScale)), fontWeight: 600 }}>
|
||||
{/* Logo can be added here if available */}
|
||||
GoNavi
|
||||
</div>
|
||||
@@ -860,35 +1049,35 @@ function App() {
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MinusOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: 46 }}
|
||||
onClick={() => (window as any).runtime.WindowMinimise()}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={WindowMinimise}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BorderOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: 46 }}
|
||||
onClick={() => (window as any).runtime.WindowToggleMaximise()}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={WindowToggleMaximise}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
className="titlebar-close-btn"
|
||||
style={{ height: '100%', borderRadius: 0, width: 46 }}
|
||||
onClick={() => (window as any).runtime.Quit()}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={Quit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: 36,
|
||||
height: toolbarHeight,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 4,
|
||||
padding: '0 8px',
|
||||
gap: Math.max(2, Math.round(4 * effectiveUiScale)),
|
||||
padding: `0 ${Math.max(6, Math.round(8 * effectiveUiScale))}px`,
|
||||
borderBottom: 'none',
|
||||
background: bgMain,
|
||||
}}
|
||||
@@ -920,17 +1109,42 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58 }}>
|
||||
<Sidebar onEditConnection={handleEditConnection} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer for Log Toggle */}
|
||||
<div style={{ padding: '8px', borderTop: 'none', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Button
|
||||
type={isLogPanelOpen ? "primary" : "text"}
|
||||
icon={<BugOutlined />}
|
||||
{/* Floating SQL Log Toggle */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 10,
|
||||
right: 14,
|
||||
bottom: 10,
|
||||
zIndex: 20,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type={isLogPanelOpen ? "primary" : "text"}
|
||||
icon={<BugOutlined />}
|
||||
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
|
||||
block
|
||||
style={isLogPanelOpen ? {
|
||||
width: '100%',
|
||||
height: floatingLogButtonHeight,
|
||||
borderRadius: 999,
|
||||
boxShadow: floatingLogButtonShadow,
|
||||
pointerEvents: 'auto'
|
||||
} : {
|
||||
width: '100%',
|
||||
height: floatingLogButtonHeight,
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${floatingLogButtonBorderColor}`,
|
||||
color: floatingLogButtonTextColor,
|
||||
background: floatingLogButtonBgColor,
|
||||
boxShadow: floatingLogButtonShadow,
|
||||
backdropFilter: blurFilter,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
SQL 执行日志
|
||||
</Button>
|
||||
@@ -979,13 +1193,17 @@ function App() {
|
||||
<DriverManagerModal
|
||||
open={isDriverModalOpen}
|
||||
onClose={() => setIsDriverModalOpen(false)}
|
||||
onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)}
|
||||
/>
|
||||
<Modal
|
||||
title="关于 GoNavi"
|
||||
open={isAboutOpen}
|
||||
onCancel={() => setIsAboutOpen(false)}
|
||||
footer={[
|
||||
lastUpdateInfo?.hasUpdate ? (
|
||||
canShowProgressEntry ? (
|
||||
<Button key="progress" icon={<DownloadOutlined />} onClick={showUpdateDownloadProgress}>下载进度</Button>
|
||||
) : null,
|
||||
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded ? (
|
||||
<Button key="download" icon={<DownloadOutlined />} onClick={() => downloadUpdate(lastUpdateInfo, false)}>下载更新</Button>
|
||||
) : null,
|
||||
lastUpdateInfo?.hasUpdate ? (
|
||||
@@ -1007,7 +1225,7 @@ function App() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<GithubOutlined />
|
||||
{aboutInfo?.repoUrl ? (
|
||||
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.repoUrl) BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>
|
||||
{aboutInfo.repoUrl}
|
||||
</a>
|
||||
) : '未知'}
|
||||
@@ -1015,7 +1233,7 @@ function App() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<BugOutlined />
|
||||
{aboutInfo?.issueUrl ? (
|
||||
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.issueUrl) BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>
|
||||
{aboutInfo.issueUrl}
|
||||
</a>
|
||||
) : '未知'}
|
||||
@@ -1023,7 +1241,7 @@ function App() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<CloudDownloadOutlined />
|
||||
{aboutInfo?.releaseUrl ? (
|
||||
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.releaseUrl) BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>
|
||||
{aboutInfo.releaseUrl}
|
||||
</a>
|
||||
) : '未知'}
|
||||
@@ -1040,6 +1258,37 @@ function App() {
|
||||
width={460}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>界面缩放 (UI Scale)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={MIN_UI_SCALE}
|
||||
max={MAX_UI_SCALE}
|
||||
step={0.05}
|
||||
value={effectiveUiScale}
|
||||
onChange={(v) => setUiScale(Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 56 }}>{Math.round(effectiveUiScale * 100)}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
* 建议小屏设备设置为 85%-95%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>基础字体大小 (Font Size)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step={1}
|
||||
value={effectiveFontSize}
|
||||
onChange={(v) => setFontSize(Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 56 }}>{effectiveFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>背景不透明度 (Opacity)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
@@ -1088,6 +1337,17 @@ function App() {
|
||||
* 修改后下次启动生效
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ opacity: 1.0, blur: 0 });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1169,38 +1429,25 @@ function App() {
|
||||
<Modal
|
||||
title={updateDownloadProgress.version ? `下载更新 ${updateDownloadProgress.version}` : '下载更新'}
|
||||
open={updateDownloadProgress.open}
|
||||
closable={updateDownloadProgress.status === 'error'}
|
||||
maskClosable={false}
|
||||
keyboard={updateDownloadProgress.status === 'error'}
|
||||
onCancel={() => {
|
||||
if (updateDownloadProgress.status === 'error') {
|
||||
setUpdateDownloadProgress({
|
||||
open: false,
|
||||
version: '',
|
||||
status: 'idle',
|
||||
percent: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
message: ''
|
||||
});
|
||||
}
|
||||
}}
|
||||
footer={updateDownloadProgress.status === 'error' ? [
|
||||
closable
|
||||
maskClosable
|
||||
keyboard
|
||||
onCancel={hideUpdateDownloadProgress}
|
||||
footer={updateDownloadProgress.status === 'start' || updateDownloadProgress.status === 'downloading' ? [
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => setUpdateDownloadProgress({
|
||||
open: false,
|
||||
version: '',
|
||||
status: 'idle',
|
||||
percent: 0,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
message: ''
|
||||
})}
|
||||
key="background"
|
||||
onClick={hideUpdateDownloadProgress}
|
||||
>
|
||||
关闭
|
||||
隐藏到后台
|
||||
</Button>
|
||||
] : null}
|
||||
] : (updateDownloadProgress.status === 'done' ? [
|
||||
<Button key="close" onClick={hideUpdateDownloadProgress}>关闭</Button>,
|
||||
<Button key="install" type="primary" onClick={handleInstallFromProgress}>
|
||||
{isMacRuntime ? '打开安装目录' : '安装更新'}
|
||||
</Button>
|
||||
] : (updateDownloadProgress.status === 'error' ? [
|
||||
<Button key="close" onClick={hideUpdateDownloadProgress}>关闭</Button>
|
||||
] : null))}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Progress
|
||||
@@ -1240,7 +1487,7 @@ function App() {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '4px',
|
||||
background: 'rgba(24, 144, 255, 0.5)',
|
||||
background: resizeGuideColor,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
display: 'none'
|
||||
@@ -1255,7 +1502,7 @@ function App() {
|
||||
left: sidebarWidth, // Start from sidebar edge
|
||||
right: 0,
|
||||
height: '4px',
|
||||
background: 'rgba(24, 144, 255, 0.5)',
|
||||
background: resizeGuideColor,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
display: 'none',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Se
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
const { Meta } = Card;
|
||||
@@ -41,6 +41,19 @@ const getDefaultPortByType = (type: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const singleHostUriSchemesByType: Record<string, string[]> = {
|
||||
postgres: ['postgresql', 'postgres'],
|
||||
clickhouse: ['clickhouse'],
|
||||
oracle: ['oracle'],
|
||||
sqlserver: ['sqlserver'],
|
||||
redis: ['redis'],
|
||||
tdengine: ['tdengine'],
|
||||
dameng: ['dameng', 'dm'],
|
||||
kingbase: ['kingbase'],
|
||||
highgo: ['highgo'],
|
||||
vastbase: ['vastbase'],
|
||||
};
|
||||
|
||||
const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb';
|
||||
|
||||
type DriverStatusSnapshot = {
|
||||
@@ -80,6 +93,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 [selectingDbFile, setSelectingDbFile] = useState(false);
|
||||
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
@@ -92,6 +106,7 @@ const ConnectionModal: React.FC<{
|
||||
const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single';
|
||||
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
|
||||
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
|
||||
const redisTopology = Form.useWatch('redisTopology', form) || 'single';
|
||||
|
||||
const getSectionBg = (darkHex: string) => {
|
||||
if (!darkMode) {
|
||||
@@ -105,6 +120,8 @@ const ConnectionModal: React.FC<{
|
||||
};
|
||||
|
||||
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
|
||||
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
|
||||
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
|
||||
|
||||
const tunnelSectionStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
@@ -343,6 +360,41 @@ const ConnectionModal: React.FC<{
|
||||
};
|
||||
};
|
||||
|
||||
const parseSingleHostUri = (
|
||||
uriText: string,
|
||||
expectedSchemes: string[],
|
||||
defaultPort: number,
|
||||
): { host: string; port: number; username: string; password: string; database: string } | null => {
|
||||
let parsed: ReturnType<typeof parseMultiHostUri> | null = null;
|
||||
for (const scheme of expectedSchemes) {
|
||||
parsed = parseMultiHostUri(uriText, scheme);
|
||||
if (parsed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
|
||||
return null;
|
||||
}
|
||||
const hostList = normalizeAddressList(parsed.hosts, defaultPort);
|
||||
if (!hostList.length) {
|
||||
return null;
|
||||
}
|
||||
const primary = parseHostPort(hostList[0] || `localhost:${defaultPort}`, defaultPort);
|
||||
return {
|
||||
host: primary?.host || 'localhost',
|
||||
port: primary?.port || defaultPort,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database || '',
|
||||
};
|
||||
};
|
||||
|
||||
const parseUriToValues = (uriText: string, type: string): Record<string, any> | null => {
|
||||
const trimmedUri = String(uriText || '').trim();
|
||||
if (!trimmedUri) {
|
||||
@@ -398,6 +450,35 @@ const ConnectionModal: React.FC<{
|
||||
return { host: normalizeFileDbPath(safeDecode(rawPath)) };
|
||||
}
|
||||
|
||||
if (type === 'redis') {
|
||||
const parsed = parseMultiHostUri(trimmedUri, 'redis');
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
|
||||
return null;
|
||||
}
|
||||
const hostList = normalizeAddressList(parsed.hosts, 6379);
|
||||
if (!hostList.length) {
|
||||
return null;
|
||||
}
|
||||
const primary = parseHostPort(hostList[0] || 'localhost:6379', 6379);
|
||||
const topologyParam = String(parsed.params.get('topology') || '').toLowerCase();
|
||||
const dbText = String(parsed.database || '').trim().replace(/^\//, '');
|
||||
const dbIndex = Number(dbText);
|
||||
return {
|
||||
host: primary?.host || 'localhost',
|
||||
port: primary?.port || 6379,
|
||||
password: parsed.password || '',
|
||||
redisTopology: hostList.length > 1 || topologyParam === 'cluster' ? 'cluster' : 'single',
|
||||
redisHosts: hostList.slice(1),
|
||||
redisDB: Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 ? Math.trunc(dbIndex) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'mongodb') {
|
||||
const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv');
|
||||
if (!parsed) {
|
||||
@@ -440,28 +521,22 @@ const ConnectionModal: React.FC<{
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'clickhouse') {
|
||||
const parsed = parseMultiHostUri(trimmedUri, 'clickhouse');
|
||||
const singleHostSchemes = singleHostUriSchemesByType[type];
|
||||
if (singleHostSchemes && singleHostSchemes.length > 0) {
|
||||
const parsed = parseSingleHostUri(trimmedUri, singleHostSchemes, getDefaultPortByType(type));
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
|
||||
if (type === 'oracle' && !String(parsed.database || '').trim()) {
|
||||
// Oracle 需要显式 service name,避免 URI 解析后放过必填校验。
|
||||
return null;
|
||||
}
|
||||
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
|
||||
return null;
|
||||
}
|
||||
const hostList = normalizeAddressList(parsed.hosts, 9000);
|
||||
if (!hostList.length) {
|
||||
return null;
|
||||
}
|
||||
const primary = parseHostPort(hostList[0] || 'localhost:9000', 9000);
|
||||
return {
|
||||
host: primary?.host || 'localhost',
|
||||
port: primary?.port || 9000,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
user: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database || '',
|
||||
database: parsed.database,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,6 +577,12 @@ const ConnectionModal: React.FC<{
|
||||
if (dbType === 'clickhouse') {
|
||||
return 'clickhouse://default:pass@127.0.0.1:9000/default';
|
||||
}
|
||||
if (dbType === 'redis') {
|
||||
return 'redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster';
|
||||
}
|
||||
if (dbType === 'oracle') {
|
||||
return 'oracle://user:pass@127.0.0.1:1521/ORCLPDB1';
|
||||
}
|
||||
return '例如: postgres://user:pass@127.0.0.1:5432/db_name';
|
||||
};
|
||||
|
||||
@@ -537,6 +618,26 @@ const ConnectionModal: React.FC<{
|
||||
return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
if (type === 'redis') {
|
||||
const primary = toAddress(host, port, 6379);
|
||||
const clusterHosts = values.redisTopology === 'cluster'
|
||||
? normalizeAddressList(values.redisHosts, 6379)
|
||||
: [];
|
||||
const hosts = normalizeAddressList([primary, ...clusterHosts], 6379);
|
||||
const params = new URLSearchParams();
|
||||
if (hosts.length > 1 || values.redisTopology === 'cluster') {
|
||||
params.set('topology', 'cluster');
|
||||
}
|
||||
const redisPassword = String(values.password || '');
|
||||
const redisAuth = redisPassword ? `:${encodeURIComponent(redisPassword)}@` : '';
|
||||
const redisDB = Number.isFinite(Number(values.redisDB))
|
||||
? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB))))
|
||||
: 0;
|
||||
const dbPath = `/${redisDB}`;
|
||||
const query = params.toString();
|
||||
return `redis://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
if (isFileDatabaseType(type)) {
|
||||
const pathText = normalizeFileDbPath(String(values.host || '').trim());
|
||||
if (!pathText) {
|
||||
@@ -665,6 +766,30 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDatabaseFile = async () => {
|
||||
if (selectingDbFile) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSelectingDbFile(true);
|
||||
const currentPath = String(form.getFieldValue('host') || '').trim();
|
||||
const res = await SelectDatabaseFile(currentPath, dbType);
|
||||
if (res?.success) {
|
||||
const data = res.data || {};
|
||||
const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim();
|
||||
if (selectedPath) {
|
||||
form.setFieldValue('host', normalizeFileDbPath(selectedPath));
|
||||
}
|
||||
} else if (res?.message !== 'Cancelled') {
|
||||
message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(`选择数据库文件失败: ${e?.message || String(e)}`);
|
||||
} finally {
|
||||
setSelectingDbFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTestResult(null); // Reset test result
|
||||
@@ -698,8 +823,10 @@ const ConnectionModal: React.FC<{
|
||||
: (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 redisHosts = configType === 'redis' ? normalizedHosts.slice(1) : [];
|
||||
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
|
||||
const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet;
|
||||
const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0;
|
||||
form.setFieldsValue({
|
||||
type: configType,
|
||||
name: initialValues.name,
|
||||
@@ -732,12 +859,15 @@ const ConnectionModal: React.FC<{
|
||||
mysqlReplicaPassword: config.mysqlReplicaPassword || '',
|
||||
mongoTopology: mongoIsReplica ? 'replica' : 'single',
|
||||
mongoHosts: mongoHosts,
|
||||
redisTopology: redisIsCluster ? 'cluster' : 'single',
|
||||
redisHosts: redisHosts,
|
||||
mongoSrv: !!config.mongoSrv,
|
||||
mongoReplicaSet: config.replicaSet || '',
|
||||
mongoAuthSource: config.authSource || '',
|
||||
mongoReadPreference: config.readPreference || 'primary',
|
||||
mongoAuthMechanism: config.mongoAuthMechanism || '',
|
||||
savePassword: config.savePassword !== false,
|
||||
redisDB: Number.isFinite(Number(config.redisDB)) ? Number(config.redisDB) : 0,
|
||||
mongoReplicaUser: config.mongoReplicaUser || '',
|
||||
mongoReplicaPassword: config.mongoReplicaPassword || ''
|
||||
});
|
||||
@@ -852,7 +982,6 @@ const ConnectionModal: React.FC<{
|
||||
if (res.success) {
|
||||
setTestResult({ type: 'success', message: res.message });
|
||||
if (isRedisType) {
|
||||
// Redis: generate database list 0-15
|
||||
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
|
||||
} else {
|
||||
// Other databases: fetch database list
|
||||
@@ -961,7 +1090,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
|
||||
let hosts: string[] = [];
|
||||
let topology: 'single' | 'replica' | undefined;
|
||||
let topology: 'single' | 'replica' | 'cluster' | undefined;
|
||||
let replicaSet = '';
|
||||
let authSource = '';
|
||||
let readPreference = '';
|
||||
@@ -1015,6 +1144,22 @@ const ConnectionModal: React.FC<{
|
||||
mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
if (type === 'redis') {
|
||||
const clusterNodes = mergedValues.redisTopology === 'cluster'
|
||||
? normalizeAddressList(mergedValues.redisHosts, defaultPort)
|
||||
: [];
|
||||
const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...clusterNodes], defaultPort);
|
||||
if (mergedValues.redisTopology === 'cluster' || allHosts.length > 1) {
|
||||
hosts = allHosts;
|
||||
topology = 'cluster';
|
||||
} else {
|
||||
topology = 'single';
|
||||
}
|
||||
mergedValues.redisDB = Number.isFinite(Number(mergedValues.redisDB))
|
||||
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
|
||||
: 0;
|
||||
}
|
||||
|
||||
const sshConfig = mergedValues.useSSH ? {
|
||||
host: mergedValues.sshHost,
|
||||
port: Number(mergedValues.sshPort),
|
||||
@@ -1056,6 +1201,9 @@ const ConnectionModal: React.FC<{
|
||||
driver: mergedValues.driver,
|
||||
dsn: mergedValues.dsn,
|
||||
timeout: Number(mergedValues.timeout || 30),
|
||||
redisDB: Number.isFinite(Number(mergedValues.redisDB))
|
||||
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
|
||||
: 0,
|
||||
uri: String(mergedValues.uri || '').trim(),
|
||||
hosts: hosts,
|
||||
topology: topology,
|
||||
@@ -1106,6 +1254,7 @@ const ConnectionModal: React.FC<{
|
||||
proxyUser: '',
|
||||
proxyPassword: '',
|
||||
mysqlTopology: 'single',
|
||||
redisTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
mongoSrv: false,
|
||||
mongoReadPreference: 'primary',
|
||||
@@ -1114,11 +1263,13 @@ const ConnectionModal: React.FC<{
|
||||
mongoAuthMechanism: '',
|
||||
savePassword: true,
|
||||
mysqlReplicaHosts: [],
|
||||
redisHosts: [],
|
||||
mongoHosts: [],
|
||||
mysqlReplicaUser: '',
|
||||
mysqlReplicaPassword: '',
|
||||
mongoReplicaUser: '',
|
||||
mongoReplicaPassword: '',
|
||||
redisDB: 0,
|
||||
});
|
||||
} else if (type !== 'custom') {
|
||||
const defaultUser = type === 'clickhouse' ? 'default' : 'root';
|
||||
@@ -1127,6 +1278,7 @@ const ConnectionModal: React.FC<{
|
||||
database: '',
|
||||
port: defaultPort,
|
||||
mysqlTopology: 'single',
|
||||
redisTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
mongoSrv: false,
|
||||
mongoReadPreference: 'primary',
|
||||
@@ -1135,11 +1287,13 @@ const ConnectionModal: React.FC<{
|
||||
mongoAuthMechanism: '',
|
||||
savePassword: true,
|
||||
mysqlReplicaHosts: [],
|
||||
redisHosts: [],
|
||||
mongoHosts: [],
|
||||
mysqlReplicaUser: '',
|
||||
mysqlReplicaPassword: '',
|
||||
mongoReplicaUser: '',
|
||||
mongoReplicaPassword: '',
|
||||
redisDB: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1223,8 +1377,8 @@ const ConnectionModal: React.FC<{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
marginBottom: 4,
|
||||
background: activeGroup === idx ? '#e6f4ff' : 'transparent',
|
||||
color: activeGroup === idx ? '#1677ff' : undefined,
|
||||
background: activeGroup === idx ? step1SidebarActiveBg : 'transparent',
|
||||
color: activeGroup === idx ? step1SidebarActiveColor : undefined,
|
||||
fontWeight: activeGroup === idx ? 500 : 400,
|
||||
transition: 'all 0.2s',
|
||||
fontSize: 13,
|
||||
@@ -1274,17 +1428,20 @@ const ConnectionModal: React.FC<{
|
||||
timeout: 30,
|
||||
uri: '',
|
||||
mysqlTopology: 'single',
|
||||
redisTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
mongoSrv: false,
|
||||
mongoReadPreference: 'primary',
|
||||
mongoAuthMechanism: '',
|
||||
savePassword: true,
|
||||
mysqlReplicaHosts: [],
|
||||
redisHosts: [],
|
||||
mongoHosts: [],
|
||||
mysqlReplicaUser: '',
|
||||
mysqlReplicaPassword: '',
|
||||
mongoReplicaUser: '',
|
||||
mongoReplicaPassword: '',
|
||||
redisDB: 0,
|
||||
}}
|
||||
onValuesChange={(changed) => {
|
||||
if (testResult) {
|
||||
@@ -1312,6 +1469,17 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
// Type change handled by step 1, but keep sync if select changes (hidden now)
|
||||
if (changed.type !== undefined) setDbType(changed.type);
|
||||
if (changed.redisTopology !== undefined) {
|
||||
const supportedDbs = Array.from({ length: 16 }, (_, i) => i);
|
||||
setRedisDbList(supportedDbs);
|
||||
const selectedDbsRaw = form.getFieldValue('includeRedisDatabases');
|
||||
const selectedDbs = Array.isArray(selectedDbsRaw) ? selectedDbsRaw.map((entry: any) => Number(entry)) : [];
|
||||
const validDbs = selectedDbs
|
||||
.filter((entry: number) => Number.isFinite(entry))
|
||||
.map((entry: number) => Math.trunc(entry))
|
||||
.filter((entry: number) => supportedDbs.includes(entry));
|
||||
form.setFieldValue('includeRedisDatabases', validDbs.length > 0 ? validDbs : undefined);
|
||||
}
|
||||
if (
|
||||
changed.type !== undefined
|
||||
|| changed.host !== undefined
|
||||
@@ -1392,6 +1560,13 @@ const ConnectionModal: React.FC<{
|
||||
onDoubleClick={requestTest}
|
||||
/>
|
||||
</Form.Item>
|
||||
{isFileDb && (
|
||||
<Form.Item label=" " style={{ width: 120 }}>
|
||||
<Button style={{ width: '100%' }} onClick={handleSelectDatabaseFile} loading={selectingDbFile}>
|
||||
浏览...
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isFileDb && (
|
||||
<Form.Item
|
||||
name="port"
|
||||
@@ -1414,6 +1589,17 @@ const ConnectionModal: React.FC<{
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{dbType === 'oracle' && (
|
||||
<Form.Item
|
||||
name="database"
|
||||
label="服务名 (Service Name)"
|
||||
rules={[createUriAwareRequiredRule('请输入 Oracle 服务名(例如 ORCLPDB1)')]}
|
||||
help="请填写监听器注册的 SERVICE_NAME(不是用户名)。例如:ORCLPDB1"
|
||||
>
|
||||
<Input placeholder="例如:ORCLPDB1" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
|
||||
<>
|
||||
<Form.Item name="mysqlTopology" label="连接模式">
|
||||
@@ -1567,11 +1753,36 @@ const ConnectionModal: React.FC<{
|
||||
{/* Redis specific: password only, no username */}
|
||||
{isRedis && (
|
||||
<>
|
||||
<Form.Item name="redisTopology" label="连接模式">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'single', label: '单机模式' },
|
||||
{ value: 'cluster', label: '集群模式(Redis Cluster)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{redisTopology === 'cluster' && (
|
||||
<Form.Item
|
||||
name="redisHosts"
|
||||
label="集群附加节点地址"
|
||||
help="主节点使用上方主机地址;这里填写其他种子节点,格式:host:port"
|
||||
>
|
||||
<Select mode="tags" placeholder="例如:10.10.0.12:6379、10.10.0.13:6379" tokenSeparators={[',', ';', ' ']} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="password" label="密码 (可选)">
|
||||
<Input.Password placeholder="Redis 密码(如果设置了 requirepass)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="includeRedisDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
|
||||
<Select mode="multiple" placeholder="选择显示的数据库 (0-15)" allowClear>
|
||||
<Form.Item
|
||||
name="includeRedisDatabases"
|
||||
label="显示数据库 (留空显示全部)"
|
||||
help="连接测试成功后可选择"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择显示的数据库 (0-15)"
|
||||
allowClear
|
||||
>
|
||||
{redisDbList.map(db => <Select.Option key={db} value={db}>db{db}</Select.Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,10 @@ import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBGetColumns, DBQueryIsolated } from '../../wailsjs/go/app/App';
|
||||
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildOrderBySQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
|
||||
type ViewerPaginationState = {
|
||||
current: number;
|
||||
@@ -16,18 +17,33 @@ type ViewerPaginationState = {
|
||||
totalCountCancelled: boolean;
|
||||
};
|
||||
|
||||
const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const isIntegerText = (text: string): boolean => /^[+-]?\d+$/.test(text);
|
||||
|
||||
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value >= 0n ? Number(value) : null;
|
||||
return value >= 0n && value <= JS_MAX_SAFE_INTEGER_BIGINT ? Number(value) : null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim();
|
||||
if (!text) return null;
|
||||
if (isIntegerText(text)) {
|
||||
try {
|
||||
const parsedBigInt = BigInt(text);
|
||||
if (parsedBigInt < 0n || parsedBigInt > JS_MAX_SAFE_INTEGER_BIGINT) {
|
||||
return null;
|
||||
}
|
||||
return Number(parsedBigInt);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -108,6 +124,33 @@ const resolveDuckDBSchemaAndTable = (dbName: string, tableName: string) => {
|
||||
|
||||
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
|
||||
|
||||
const isDuckDBUnsupportedTypeError = (msg: string): boolean => /unsupported\s*type:\s*duckdb\./i.test(String(msg || ''));
|
||||
|
||||
const isDuckDBComplexColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
|
||||
};
|
||||
|
||||
const reverseOrderBySQL = (orderBySQL: string): string => {
|
||||
const raw = String(orderBySQL || '').trim();
|
||||
if (!raw) return '';
|
||||
const body = raw.replace(/^order\s+by\s+/i, '').trim();
|
||||
if (!body) return '';
|
||||
|
||||
const parts = body
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
if (/\s+asc$/i.test(part)) return part.replace(/\s+asc$/i, ' DESC');
|
||||
if (/\s+desc$/i.test(part)) return part.replace(/\s+desc$/i, ' ASC');
|
||||
return `${part} DESC`;
|
||||
});
|
||||
if (parts.length === 0) return '';
|
||||
return ` ORDER BY ${parts.join(', ')}`;
|
||||
};
|
||||
|
||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
@@ -144,12 +187,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
|
||||
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
|
||||
const forceReadOnly = currentConnType === 'tdengine' || currentConnType === 'clickhouse';
|
||||
|
||||
const runIsolatedQuery = useCallback(async (queryConfig: any, dbName: string, sql: string) => {
|
||||
return DBQueryIsolated(queryConfig as any, dbName, sql);
|
||||
}, []);
|
||||
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
|
||||
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
||||
const currentConnType = currentConnCaps.type;
|
||||
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
|
||||
|
||||
useEffect(() => {
|
||||
setPkColumns([]);
|
||||
@@ -157,6 +199,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
countKeyRef.current = '';
|
||||
duckdbApproxKeyRef.current = '';
|
||||
manualCountKeyRef.current = '';
|
||||
duckdbSafeSelectCacheRef.current = {};
|
||||
latestConfigRef.current = null;
|
||||
latestDbTypeRef.current = '';
|
||||
latestDbNameRef.current = '';
|
||||
@@ -194,7 +237,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const countConfig: any = { ...(config as any), timeout: 120 };
|
||||
|
||||
try {
|
||||
const resCount = await runIsolatedQuery(countConfig, dbName, countSql);
|
||||
const resCount = await DBQuery(countConfig as any, dbName, countSql);
|
||||
const countDuration = Date.now() - countStart;
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-duckdb-manual-count`,
|
||||
@@ -240,7 +283,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||
message.error(`统计总数失败: ${String(e?.message || e)}`);
|
||||
}
|
||||
}, [addSqlLog, runIsolatedQuery]);
|
||||
}, [addSqlLog]);
|
||||
|
||||
const handleDuckDBCancelManualCount = useCallback(() => {
|
||||
manualCountSeqRef.current++;
|
||||
@@ -277,35 +320,112 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const countSql = `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
|
||||
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const offset = (page - 1) * size;
|
||||
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
||||
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
|
||||
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
let sql = `${baseSql}${orderBySQL}`;
|
||||
const totalRows = Number(pagination.total);
|
||||
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
|
||||
const totalKnown = pagination.totalKnown && hasFiniteTotal;
|
||||
const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0;
|
||||
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
|
||||
const offset = (currentPage - 1) * size;
|
||||
const isClickHouse = dbTypeLower === 'clickhouse';
|
||||
const reverseOrderSQL = isClickHouse ? reverseOrderBySQL(orderBySQL) : '';
|
||||
let useClickHouseReversePagination = false;
|
||||
let clickHouseReverseLimit = 0;
|
||||
let clickHouseReverseHasMore = false;
|
||||
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
|
||||
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET,并在前端翻转结果。
|
||||
if (isClickHouse && totalKnown && offset > 0 && reverseOrderSQL) {
|
||||
const pageRowCount = Math.max(0, Math.min(size, totalRows - offset));
|
||||
if (pageRowCount > 0) {
|
||||
const tailOffset = Math.max(0, totalRows - (offset + pageRowCount));
|
||||
if (tailOffset < offset) {
|
||||
sql = `${baseSql}${reverseOrderSQL} LIMIT ${pageRowCount} OFFSET ${tailOffset}`;
|
||||
useClickHouseReversePagination = true;
|
||||
clickHouseReverseLimit = pageRowCount;
|
||||
clickHouseReverseHasMore = currentPage < totalPages;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!useClickHouseReversePagination) {
|
||||
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
||||
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
|
||||
}
|
||||
|
||||
const requestStartTime = Date.now();
|
||||
let executedSql = sql;
|
||||
try {
|
||||
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
|
||||
const startTime = Date.now();
|
||||
const result = await DBQuery(config as any, dbName, querySql);
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
sql: querySql,
|
||||
status: result.success ? 'success' : 'error',
|
||||
duration: Date.now() - startTime,
|
||||
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
|
||||
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
|
||||
dbName
|
||||
});
|
||||
return result;
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, querySql);
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
sql: querySql,
|
||||
status: result.success ? 'success' : 'error',
|
||||
duration: Date.now() - startTime,
|
||||
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
|
||||
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
|
||||
dbName
|
||||
});
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
const errMessage = String(e?.message || e || 'query failed');
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
sql: querySql,
|
||||
status: 'error',
|
||||
duration: Date.now() - startTime,
|
||||
message: `${attemptLabel}: ${errMessage}`,
|
||||
dbName
|
||||
});
|
||||
return { success: false, message: errMessage, data: [], fields: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
|
||||
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
|
||||
let resData = await executeDataQuery(sql, '主查询');
|
||||
|
||||
if (!resData.success && dbTypeLower === 'duckdb' && isDuckDBUnsupportedTypeError(String(resData.message || ''))) {
|
||||
const cacheKey = `${tab.connectionId}|${dbName}|${tableName}`;
|
||||
let safeSelect = duckdbSafeSelectCacheRef.current[cacheKey] || '';
|
||||
if (!safeSelect) {
|
||||
try {
|
||||
const resCols = await DBGetColumns(config as any, dbName, tableName);
|
||||
if (resCols?.success && Array.isArray(resCols.data)) {
|
||||
const columnDefs = resCols.data as ColumnDefinition[];
|
||||
const selectParts = columnDefs.map((col) => {
|
||||
const colName = String(col?.name || '').trim();
|
||||
if (!colName) return '';
|
||||
const quotedCol = quoteIdentPart(dbType, colName);
|
||||
if (isDuckDBComplexColumnType(col?.type)) {
|
||||
return `CAST(${quotedCol} AS VARCHAR) AS ${quotedCol}`;
|
||||
}
|
||||
return quotedCol;
|
||||
}).filter(Boolean);
|
||||
if (selectParts.length > 0) {
|
||||
safeSelect = selectParts.join(', ');
|
||||
duckdbSafeSelectCacheRef.current[cacheKey] = safeSelect;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and keep original error path
|
||||
}
|
||||
}
|
||||
|
||||
if (safeSelect) {
|
||||
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
fallbackSql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
fallbackSql += ` LIMIT ${size + 1} OFFSET ${offset}`;
|
||||
executedSql = fallbackSql;
|
||||
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
|
||||
}
|
||||
}
|
||||
|
||||
if (!resData.success && isMySQLFamily && hasSort && isSortMemoryErr(resData.message)) {
|
||||
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
|
||||
if (retrySql32MB !== sql) {
|
||||
@@ -348,7 +468,12 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
let resultData = resData.data as any[];
|
||||
if (!Array.isArray(resultData)) resultData = [];
|
||||
|
||||
const hasMore = resultData.length > size;
|
||||
if (useClickHouseReversePagination) {
|
||||
// 反向查询后恢复为原排序方向,保证用户看到的仍是“最后一页正序数据”。
|
||||
resultData = resultData.slice(0, clickHouseReverseLimit).reverse();
|
||||
}
|
||||
|
||||
const hasMore = useClickHouseReversePagination ? clickHouseReverseHasMore : resultData.length > size;
|
||||
if (hasMore) resultData = resultData.slice(0, size);
|
||||
|
||||
let fieldNames = resData.fields || [];
|
||||
@@ -363,7 +488,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setData(resultData);
|
||||
const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`;
|
||||
const derivedTotalKnown = !hasMore;
|
||||
const derivedTotal = derivedTotalKnown ? offset + resultData.length : page * size + 1;
|
||||
const derivedTotal = derivedTotalKnown ? offset + resultData.length : currentPage * size + 1;
|
||||
const isDuckDB = dbTypeLower === 'duckdb';
|
||||
const minExpectedTotal = hasMore ? offset + resultData.length + 1 : offset + resultData.length;
|
||||
if (derivedTotalKnown) countKeyRef.current = countKey;
|
||||
@@ -377,7 +502,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
if (derivedTotalKnown) {
|
||||
return {
|
||||
...prev,
|
||||
current: page,
|
||||
current: currentPage,
|
||||
pageSize: size,
|
||||
total: derivedTotal,
|
||||
totalKnown: true,
|
||||
@@ -388,19 +513,19 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
if (prev.totalKnown && countKeyRef.current === countKey) {
|
||||
if (!isDuckDB) {
|
||||
return { ...prev, current: page, pageSize: size };
|
||||
return { ...prev, current: currentPage, pageSize: size };
|
||||
}
|
||||
// 当当前页存在“下一页”信号时,已知总数至少应大于当前页末尾。
|
||||
// 若旧总数不满足该条件(例如历史统计值为 0),降级为未知总数并回退到 derivedTotal。
|
||||
if (Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
|
||||
return { ...prev, current: page, pageSize: size };
|
||||
return { ...prev, current: currentPage, pageSize: size };
|
||||
}
|
||||
}
|
||||
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
|
||||
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
|
||||
return {
|
||||
...prev,
|
||||
current: page,
|
||||
current: currentPage,
|
||||
pageSize: size,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
@@ -410,7 +535,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
current: page,
|
||||
current: currentPage,
|
||||
pageSize: size,
|
||||
total: derivedTotal,
|
||||
totalKnown: false,
|
||||
@@ -450,11 +575,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
if (!resCount.success) return;
|
||||
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
|
||||
|
||||
let total: number | null = null;
|
||||
const parsed = Number(resCount.data[0]?.['total']);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
total = parsed;
|
||||
}
|
||||
const total = parseTotalFromCountRow(resCount.data[0]);
|
||||
if (total === null) return;
|
||||
|
||||
setPagination(prev => ({
|
||||
@@ -489,7 +610,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
(async () => {
|
||||
for (const approxSql of approxSqlCandidates) {
|
||||
try {
|
||||
const approxRes = await runIsolatedQuery(approxConfig, dbName, approxSql);
|
||||
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (countKeyRef.current !== countKey) return;
|
||||
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
|
||||
@@ -534,7 +655,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
}
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
}, [connections, tab, sortInfo, filterConditions, pkColumns, runIsolatedQuery]);
|
||||
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
|
||||
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
|
||||
// 主键信息只会在首次加载后更新一次,避免循环查询。
|
||||
|
||||
@@ -566,6 +687,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
columnNames={columnNames}
|
||||
loading={loading}
|
||||
tableName={tab.tableName}
|
||||
exportScope="table"
|
||||
dbName={tab.dbName}
|
||||
connectionId={tab.connectionId}
|
||||
pkColumns={pkColumns}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Collapse, Input, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, InfoCircleFilled, ReloadOutlined } from '@ant-design/icons';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { useStore } from '../store';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
@@ -63,6 +63,9 @@ type DriverNetworkProbe = {
|
||||
reachable: boolean;
|
||||
httpStatus?: number;
|
||||
latencyMs?: number;
|
||||
tcpLatencyMs?: number;
|
||||
httpLatencyMs?: number;
|
||||
method?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -71,12 +74,22 @@ type DriverNetworkStatus = {
|
||||
summary: string;
|
||||
recommendedProxy: boolean;
|
||||
proxyConfigured: boolean;
|
||||
downloadChainReachable?: boolean;
|
||||
downloadRequiredHosts?: string[];
|
||||
proxyEnv?: Record<string, string>;
|
||||
checks: DriverNetworkProbe[];
|
||||
checkedAt?: string;
|
||||
logPath?: string;
|
||||
};
|
||||
|
||||
const parseOptionalLatency = (value: unknown): number | undefined => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const sharedInfoAlertIcon = <InfoCircleFilled style={{ fontSize: 24 }} />;
|
||||
|
||||
type DriverVersionOption = {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
@@ -90,6 +103,14 @@ type DriverVersionOption = {
|
||||
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
|
||||
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
|
||||
const DRIVER_TABLE_SCROLL_X = 1450;
|
||||
const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000;
|
||||
const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase();
|
||||
|
||||
let driverStatusSnapshotCache: { rows: DriverStatusRow[]; downloadDir: string; cachedAt: number } | null = null;
|
||||
let driverNetworkSnapshotCache: { status: DriverNetworkStatus; cachedAt: number } | null = null;
|
||||
|
||||
const isFreshCache = (cachedAt: number, ttlMs: number): boolean => Date.now() - cachedAt <= ttlMs;
|
||||
|
||||
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
|
||||
type SelectOption = { value: string; label: string };
|
||||
@@ -137,7 +158,11 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenGlobalProxySettings?: () => void }> = ({
|
||||
open,
|
||||
onClose,
|
||||
onOpenGlobalProxySettings,
|
||||
}) => {
|
||||
const theme = useStore((state) => state.theme);
|
||||
const appearance = useStore((state) => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
@@ -151,6 +176,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
const [downloadDir, setDownloadDir] = useState('');
|
||||
const [networkChecking, setNetworkChecking] = useState(false);
|
||||
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [rows, setRows] = useState<DriverStatusRow[]>([]);
|
||||
const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' });
|
||||
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
|
||||
@@ -164,6 +190,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
|
||||
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
|
||||
const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X);
|
||||
const downloadDirRef = useRef(downloadDir);
|
||||
|
||||
useEffect(() => {
|
||||
downloadDirRef.current = downloadDir;
|
||||
}, [downloadDir]);
|
||||
|
||||
const appendOperationLog = useCallback((
|
||||
driverType: string,
|
||||
@@ -281,10 +312,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
horizontalSyncSourceRef.current = '';
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async (toastOnError = true) => {
|
||||
setLoading(true);
|
||||
const refreshStatus = useCallback(async (
|
||||
toastOnError = true,
|
||||
options?: { showLoading?: boolean },
|
||||
) => {
|
||||
const showLoading = options?.showLoading ?? true;
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const res = await GetDriverStatusList(downloadDir, '');
|
||||
const res = await GetDriverStatusList(downloadDirRef.current, '');
|
||||
if (!res?.success) {
|
||||
if (toastOnError) {
|
||||
message.error(res?.message || '拉取驱动状态失败');
|
||||
@@ -296,6 +333,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
const resolvedDir = String(data.downloadDir || '').trim();
|
||||
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
|
||||
|
||||
const effectiveDownloadDir = resolvedDir || downloadDirRef.current;
|
||||
if (resolvedDir) {
|
||||
setDownloadDir(resolvedDir);
|
||||
}
|
||||
@@ -318,17 +356,30 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
message: String(item.message || '').trim() || undefined,
|
||||
}));
|
||||
setRows(nextRows);
|
||||
driverStatusSnapshotCache = {
|
||||
rows: nextRows,
|
||||
downloadDir: effectiveDownloadDir,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (toastOnError) {
|
||||
message.error(`拉取驱动状态失败:${err?.message || String(err)}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [downloadDir]);
|
||||
}, []);
|
||||
|
||||
const checkNetworkStatus = useCallback(async (toastOnError = false) => {
|
||||
setNetworkChecking(true);
|
||||
const checkNetworkStatus = useCallback(async (
|
||||
toastOnError = false,
|
||||
options?: { showLoading?: boolean },
|
||||
) => {
|
||||
const showLoading = options?.showLoading ?? true;
|
||||
if (showLoading) {
|
||||
setNetworkChecking(true);
|
||||
}
|
||||
try {
|
||||
const res = await CheckDriverNetworkStatus();
|
||||
if (!res?.success) {
|
||||
@@ -343,26 +394,40 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
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,
|
||||
httpStatus: parseOptionalLatency(item.httpStatus),
|
||||
latencyMs: parseOptionalLatency(item.latencyMs),
|
||||
tcpLatencyMs: parseOptionalLatency(item.tcpLatencyMs),
|
||||
httpLatencyMs: parseOptionalLatency(item.httpLatencyMs),
|
||||
method: String(item.method || '').trim().toUpperCase() || undefined,
|
||||
error: String(item.error || '').trim() || undefined,
|
||||
}));
|
||||
setNetworkStatus({
|
||||
const nextStatus: DriverNetworkStatus = {
|
||||
reachable: !!data.reachable,
|
||||
summary: String(data.summary || '').trim() || '驱动网络检测已完成',
|
||||
recommendedProxy: !!data.recommendedProxy,
|
||||
proxyConfigured: !!data.proxyConfigured,
|
||||
downloadChainReachable: typeof data.downloadChainReachable === 'boolean' ? data.downloadChainReachable : undefined,
|
||||
downloadRequiredHosts: Array.isArray(data.downloadRequiredHosts)
|
||||
? data.downloadRequiredHosts.map((item: unknown) => String(item || '').trim()).filter(Boolean)
|
||||
: undefined,
|
||||
proxyEnv: (data.proxyEnv || {}) as Record<string, string>,
|
||||
checkedAt: String(data.checkedAt || '').trim() || undefined,
|
||||
checks: normalizedChecks,
|
||||
logPath: String(data.logPath || '').trim() || undefined,
|
||||
});
|
||||
};
|
||||
setNetworkStatus(nextStatus);
|
||||
driverNetworkSnapshotCache = {
|
||||
status: nextStatus,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (toastOnError) {
|
||||
message.error(`驱动网络检测失败:${err?.message || String(err)}`);
|
||||
}
|
||||
} finally {
|
||||
setNetworkChecking(false);
|
||||
if (showLoading) {
|
||||
setNetworkChecking(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -521,8 +586,29 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
tableScrollTargetsRef.current = [];
|
||||
return;
|
||||
}
|
||||
refreshStatus(false);
|
||||
checkNetworkStatus(false);
|
||||
|
||||
const cachedStatus = driverStatusSnapshotCache;
|
||||
const hasCachedStatus = !!cachedStatus;
|
||||
if (cachedStatus) {
|
||||
setRows(cachedStatus.rows);
|
||||
if (cachedStatus.downloadDir) {
|
||||
setDownloadDir(cachedStatus.downloadDir);
|
||||
}
|
||||
}
|
||||
const shouldRefreshStatus = !cachedStatus || !isFreshCache(cachedStatus.cachedAt, DRIVER_STATUS_CACHE_TTL_MS);
|
||||
if (shouldRefreshStatus) {
|
||||
void refreshStatus(false, { showLoading: !hasCachedStatus });
|
||||
}
|
||||
|
||||
const cachedNetwork = driverNetworkSnapshotCache;
|
||||
const hasCachedNetwork = !!cachedNetwork;
|
||||
if (cachedNetwork) {
|
||||
setNetworkStatus(cachedNetwork.status);
|
||||
}
|
||||
const shouldRefreshNetwork = !cachedNetwork || !isFreshCache(cachedNetwork.cachedAt, DRIVER_NETWORK_CACHE_TTL_MS);
|
||||
if (shouldRefreshNetwork) {
|
||||
void checkNetworkStatus(false, { showLoading: !hasCachedNetwork });
|
||||
}
|
||||
}, [checkNetworkStatus, open, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1075,10 +1161,47 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
}
|
||||
return rows.find((item) => item.type === logDriverType);
|
||||
}, [logDriverType, rows]);
|
||||
const normalizedSearchKeyword = useMemo(() => normalizeDriverSearchText(searchKeyword), [searchKeyword]);
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!normalizedSearchKeyword) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) => {
|
||||
const searchableParts = [
|
||||
row.name,
|
||||
row.type,
|
||||
row.pinnedVersion,
|
||||
row.installedVersion,
|
||||
row.message,
|
||||
row.builtIn ? '内置' : '外置',
|
||||
row.connectable ? '已启用' : row.packageInstalled ? '已安装' : '未启用',
|
||||
];
|
||||
const searchableText = normalizeDriverSearchText(searchableParts.filter(Boolean).join(' '));
|
||||
return searchableText.includes(normalizedSearchKeyword);
|
||||
});
|
||||
}, [normalizedSearchKeyword, rows]);
|
||||
const filterSummaryText = useMemo(() => {
|
||||
if (normalizedSearchKeyword) {
|
||||
return `匹配 ${filteredRows.length} / ${rows.length}`;
|
||||
}
|
||||
return `共 ${rows.length} 个驱动`;
|
||||
}, [filteredRows.length, normalizedSearchKeyword, rows.length]);
|
||||
|
||||
const activeDriverLogs = operationLogMap[logDriverType] || [];
|
||||
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
|
||||
const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {});
|
||||
const downloadRequiredHosts = (networkStatus?.downloadRequiredHosts || []).filter(Boolean);
|
||||
const showDownloadChainAlert = networkStatus?.downloadChainReachable === false;
|
||||
const networkUnreachable = networkStatus?.reachable === false;
|
||||
const downloadRequiredHostText = (downloadRequiredHosts.length > 0
|
||||
? downloadRequiredHosts
|
||||
: ['github.com', 'api.github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'raw.githubusercontent.com']).join('、');
|
||||
const githubConnectivityProbe = networkStatus?.checks.find((item) => item.name === 'GitHub API')
|
||||
|| networkStatus?.checks.find((item) => item.name === 'GitHub 驱动发布')
|
||||
|| null;
|
||||
const githubConnectivityLatencyMs = githubConnectivityProbe
|
||||
? (githubConnectivityProbe.httpLatencyMs ?? githubConnectivityProbe.latencyMs ?? githubConnectivityProbe.tcpLatencyMs)
|
||||
: undefined;
|
||||
const logBlockBackground = darkMode
|
||||
? `rgba(28, 28, 28, ${Math.max(opacity, 0.82)})`
|
||||
: `rgba(255, 255, 255, ${Math.max(opacity, 0.92)})`;
|
||||
@@ -1129,15 +1252,43 @@ 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>
|
||||
networkUnreachable ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={showDownloadChainAlert ? '重要提醒:驱动下载链路域名不可达' : '重要提醒:驱动下载网络不可达'}
|
||||
description={(
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{showDownloadChainAlert ? (
|
||||
<>
|
||||
<Text>
|
||||
当前可能能访问 GitHub 页面,但驱动包下载会跳转到资产域名。
|
||||
请优先在 GoNavi 顶部“代理”中启用全局代理(填写代理应用本地地址和端口)。
|
||||
</Text>
|
||||
{onOpenGlobalProxySettings ? (
|
||||
<Button size="small" onClick={onOpenGlobalProxySettings}>打开全局代理设置</Button>
|
||||
) : null}
|
||||
<Text>
|
||||
若仍失败,请在代理规则放行:{downloadRequiredHostText};仍无法调整规则时,再考虑开启 TUN 模式。
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text>{networkStatus.summary}</Text>
|
||||
)}
|
||||
{proxyEnvEntries.length > 0 ? (
|
||||
<Text type="secondary">
|
||||
检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message={networkStatus.summary}
|
||||
description={(
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
@@ -1146,11 +1297,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
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>
|
||||
))}
|
||||
<Text type="secondary">
|
||||
代理链路到 GitHub 连通性延迟:{githubConnectivityProbe ? (githubConnectivityProbe.reachable ? '可达' : '不可达') : '暂无结果'}
|
||||
{githubConnectivityLatencyMs !== undefined ? `,${githubConnectivityLatencyMs}ms` : ''}
|
||||
{githubConnectivityProbe?.error ? `,${githubConnectivityProbe.error}` : ''}
|
||||
</Text>
|
||||
{proxyEnvEntries.length > 0 ? (
|
||||
<Text type="secondary">
|
||||
检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')}
|
||||
@@ -1163,34 +1314,58 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Alert type="info" showIcon message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'} />
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={sharedInfoAlertIcon}
|
||||
message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={sharedInfoAlertIcon}
|
||||
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>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'driver-directory',
|
||||
label: '查看驱动目录与复用说明',
|
||||
children: (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: '100%', display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索驱动名称/类型(如 DuckDB、clickhouse)"
|
||||
value={searchKeyword}
|
||||
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||
style={{ minWidth: 300, flex: '1 1 360px' }}
|
||||
/>
|
||||
<Space size={8}>
|
||||
<Text type="secondary">覆盖已安装</Text>
|
||||
<Switch
|
||||
@@ -1198,15 +1373,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
onChange={(checked) => setForceOverwriteInstalled(checked)}
|
||||
disabled={batchDirectoryImporting}
|
||||
/>
|
||||
<Button
|
||||
icon={<FolderOpenOutlined />}
|
||||
loading={batchDirectoryImporting}
|
||||
onClick={() => void installDriversFromDirectory()}
|
||||
>
|
||||
导入驱动目录
|
||||
</Button>
|
||||
</Space>
|
||||
<Button
|
||||
icon={<FolderOpenOutlined />}
|
||||
loading={batchDirectoryImporting}
|
||||
onClick={() => void installDriversFromDirectory()}
|
||||
>
|
||||
导入驱动目录
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Text type="secondary">{filterSummaryText}</Text>
|
||||
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
@@ -1217,11 +1393,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
rowKey="type"
|
||||
loading={loading}
|
||||
columns={columns as any}
|
||||
dataSource={rows}
|
||||
dataSource={filteredRows}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
sticky={false}
|
||||
scroll={{ x: DRIVER_TABLE_SCROLL_X }}
|
||||
locale={{
|
||||
emptyText: normalizedSearchKeyword
|
||||
? `未找到匹配“${String(searchKeyword || '').trim()}”的驱动`
|
||||
: '暂无驱动数据',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
@@ -27,8 +27,9 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
};
|
||||
const bgMain = getBg('#1f1f1f');
|
||||
const bgToolbar = getBg('#2a2a2a');
|
||||
const bgMain = getBg('#1d1d1d');
|
||||
const panelDividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
const panelMutedTextColor = darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)';
|
||||
const logScrollbarThumb = darkMode ? 'rgba(255, 255, 255, 0.34)' : 'rgba(0, 0, 0, 0.26)';
|
||||
const logScrollbarThumbHover = darkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.36)';
|
||||
|
||||
@@ -37,7 +38,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
title: 'Time',
|
||||
dataIndex: 'timestamp',
|
||||
width: 80,
|
||||
render: (ts: number) => <span style={{ color: '#888', fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
|
||||
render: (ts: number) => <span style={{ color: panelMutedTextColor, fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
@@ -62,7 +63,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.2' }}>
|
||||
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
|
||||
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
|
||||
{record.affectedRows !== undefined && <div style={{ color: '#888', marginTop: 1 }}>Affected: {record.affectedRows}</div>}
|
||||
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>Affected: {record.affectedRows}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -71,7 +72,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
return (
|
||||
<div style={{
|
||||
height,
|
||||
borderTop: 'none',
|
||||
borderTop: `1px solid ${panelDividerColor}`,
|
||||
background: bgMain,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -95,7 +96,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
borderBottom: 'none',
|
||||
borderBottom: `1px solid ${panelDividerColor}`,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
@@ -7,6 +7,7 @@ import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
|
||||
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
|
||||
@@ -14,6 +15,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
type ResultSet = {
|
||||
key: string;
|
||||
sql: string;
|
||||
exportSql?: string;
|
||||
rows: any[];
|
||||
columns: string[];
|
||||
tableName?: string;
|
||||
@@ -47,6 +49,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const visibleDbsRef = useRef<string[]>([]); // Store visible databases for cross-db intellisense
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const queryCapableConnections = useMemo(
|
||||
() => connections.filter(c => getDataSourceCapabilities(c.config).supportsQueryEditor),
|
||||
[connections]
|
||||
);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const currentConnectionIdRef = useRef(currentConnectionId);
|
||||
const currentDbRef = useRef(currentDb);
|
||||
@@ -64,6 +70,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
currentConnectionIdRef.current = currentConnectionId;
|
||||
}, [currentConnectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryCapableConnections.some(c => c.id === currentConnectionId)) {
|
||||
const fallback = queryCapableConnections[0]?.id || '';
|
||||
if (fallback && fallback !== currentConnectionId) {
|
||||
setCurrentConnectionId(fallback);
|
||||
setCurrentDb('');
|
||||
}
|
||||
}
|
||||
}, [queryCapableConnections, currentConnectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
currentDbRef.current = currentDb;
|
||||
}, [currentDb]);
|
||||
@@ -977,6 +993,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
if (runSeqRef.current === runSeq) setLoading(false);
|
||||
return;
|
||||
}
|
||||
const connCaps = getDataSourceCapabilities(conn.config);
|
||||
if (!connCaps.supportsQueryEditor) {
|
||||
message.error("当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。");
|
||||
if (runSeqRef.current === runSeq) setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
@@ -1000,8 +1022,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const nextResultSets: ResultSet[] = [];
|
||||
const maxRows = Number(queryOptions?.maxRows) || 0;
|
||||
const dbType = String((config as any).type || 'mysql');
|
||||
const normalizedDbType = dbType.toLowerCase();
|
||||
const forceReadOnlyResult = normalizedDbType === 'tdengine' || normalizedDbType === 'clickhouse';
|
||||
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
|
||||
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
|
||||
const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0;
|
||||
let anyTruncated = false;
|
||||
@@ -1066,6 +1087,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
nextResultSets.push({
|
||||
key: `result-${idx + 1}`,
|
||||
sql: rawStatement,
|
||||
exportSql: limited.applied ? applyAutoLimit(rawStatement, dbType, Math.max(1, Number(maxRows) || 1)).sql : rawStatement,
|
||||
rows,
|
||||
columns: cols,
|
||||
tableName: simpleTableName,
|
||||
@@ -1082,6 +1104,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
nextResultSets.push({
|
||||
key: `result-${idx + 1}`,
|
||||
sql: rawStatement,
|
||||
exportSql: rawStatement,
|
||||
rows: [row],
|
||||
columns: ['affectedRows'],
|
||||
pkColumns: [],
|
||||
@@ -1223,7 +1246,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setCurrentConnectionId(val);
|
||||
setCurrentDb('');
|
||||
}}
|
||||
options={connections.map(c => ({ label: c.name, value: c.id }))}
|
||||
options={queryCapableConnections.map(c => ({ label: c.name, value: c.id }))}
|
||||
showSearch
|
||||
/>
|
||||
<Select
|
||||
@@ -1333,6 +1356,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
columnNames={rs.columns}
|
||||
loading={loading}
|
||||
tableName={rs.tableName}
|
||||
exportScope="queryResult"
|
||||
resultSql={rs.exportSql || rs.sql}
|
||||
dbName={currentDb}
|
||||
connectionId={currentConnectionId}
|
||||
pkColumns={rs.pkColumns}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useStore } from '../store';
|
||||
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -394,8 +395,21 @@ const buildRedisKeyTree = (
|
||||
};
|
||||
|
||||
const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const { connections } = useStore();
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const connection = connections.find(c => c.id === connectionId);
|
||||
const keyAccentColor = darkMode ? '#ffd666' : '#1677ff';
|
||||
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
|
||||
const valueToolbarBg = darkMode
|
||||
? `rgba(38, 38, 38, ${opacity})`
|
||||
: `rgba(245, 245, 245, ${opacity})`;
|
||||
const valueToolbarBorder = darkMode
|
||||
? `1px solid rgba(255, 255, 255, ${Math.max(0.12, Math.min(0.24, opacity * 0.22))})`
|
||||
: `1px solid rgba(0, 0, 0, ${Math.max(0.08, Math.min(0.2, opacity * 0.12))})`;
|
||||
const valueToolbarText = darkMode ? 'rgba(255, 255, 255, 0.78)' : '#666';
|
||||
|
||||
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -805,7 +819,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<KeyOutlined style={{ color: '#1677ff', flexShrink: 0 }} />
|
||||
<KeyOutlined style={{ color: keyAccentColor, flexShrink: 0 }} />
|
||||
<Tooltip title={rawKey}>
|
||||
<span
|
||||
style={{
|
||||
@@ -901,13 +915,13 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
background: '#f5f5f5',
|
||||
borderBottom: '1px solid #d9d9d9',
|
||||
background: valueToolbarBg,
|
||||
borderBottom: valueToolbarBorder,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: '#666' }}>
|
||||
<span style={{ fontSize: 12, color: valueToolbarText }}>
|
||||
{encoding && `编码: ${encoding}`}
|
||||
</span>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
@@ -920,6 +934,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<Editor
|
||||
height="calc(100% - 72px)"
|
||||
language={isJson ? 'json' : 'plaintext'}
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={displayValue}
|
||||
options={{
|
||||
readOnly: true,
|
||||
@@ -1069,7 +1084,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
@@ -1248,7 +1263,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
@@ -1403,7 +1418,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
@@ -1557,7 +1572,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
@@ -1771,7 +1786,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 720 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
@@ -1964,6 +1979,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<Editor
|
||||
height="450px"
|
||||
language={tryFormatJson(editValue).isJson ? 'json' : 'plaintext'}
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={editValue}
|
||||
onChange={(value) => setEditValue(value || '')}
|
||||
options={{
|
||||
@@ -2028,6 +2044,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<Editor
|
||||
height="450px"
|
||||
language={jsonEditConfig?.isJson ? 'json' : 'plaintext'}
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
defaultValue={jsonEditConfig?.value || ''}
|
||||
onChange={(value) => { jsonEditValueRef.current = value || ''; }}
|
||||
onMount={(editor) => { jsonEditValueRef.current = jsonEditConfig?.value || ''; }}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { Tabs, Dropdown } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { MenuProps, TabsProps } from 'antd';
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
|
||||
import { useStore } from '../store';
|
||||
import DataViewer from './DataViewer';
|
||||
import QueryEditor from './QueryEditor';
|
||||
@@ -29,9 +34,58 @@ const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined):
|
||||
return `[${prefix}] ${tab.title}`;
|
||||
};
|
||||
|
||||
type SortableTabLabelProps = {
|
||||
displayTitle: string;
|
||||
menuItems: MenuProps['items'];
|
||||
};
|
||||
|
||||
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
|
||||
displayTitle,
|
||||
menuItems,
|
||||
}) => {
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<span
|
||||
className="tab-dnd-label"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
title="拖拽调整标签顺序"
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
type DraggableTabNodeProps = {
|
||||
node: React.ReactElement;
|
||||
};
|
||||
|
||||
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
|
||||
const tabId = String(node.key || '').trim();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
|
||||
const style: React.CSSProperties = {
|
||||
...(node.props.style || {}),
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
opacity: isDragging ? 0.88 : 1,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
zIndex: isDragging ? 2 : node.props.style?.zIndex,
|
||||
};
|
||||
|
||||
return React.cloneElement(node, {
|
||||
ref: setNodeRef,
|
||||
style,
|
||||
...attributes,
|
||||
...listeners,
|
||||
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
|
||||
});
|
||||
};
|
||||
|
||||
const TabManager: React.FC = () => {
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const setActiveTab = useStore(state => state.setActiveTab);
|
||||
const closeTab = useStore(state => state.closeTab);
|
||||
@@ -39,6 +93,15 @@ const TabManager: React.FC = () => {
|
||||
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
|
||||
const closeTabsToRight = useStore(state => state.closeTabsToRight);
|
||||
const closeAllTabs = useStore(state => state.closeAllTabs);
|
||||
const moveTab = useStore(state => state.moveTab);
|
||||
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const suppressClickUntilRef = useRef<number>(0);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
const onChange = (newActiveKey: string) => {
|
||||
setActiveTab(newActiveKey);
|
||||
@@ -50,11 +113,43 @@ const TabManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const sourceId = String(event.active.id || '').trim();
|
||||
setDraggingTabId(sourceId || null);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const sourceId = String(event.active.id || '').trim();
|
||||
const targetId = String(event.over?.id || '').trim();
|
||||
setDraggingTabId(null);
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
return;
|
||||
}
|
||||
suppressClickUntilRef.current = Date.now() + 120;
|
||||
moveTab(sourceId, targetId);
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setDraggingTabId(null);
|
||||
};
|
||||
|
||||
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
|
||||
|
||||
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
|
||||
<DefaultTabBar {...tabBarProps}>
|
||||
{(node) => <DraggableTabNode key={node.key} node={node} />}
|
||||
</DefaultTabBar>
|
||||
);
|
||||
|
||||
const items = useMemo(() => tabs.map((tab, index) => {
|
||||
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
||||
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
||||
const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command';
|
||||
const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive;
|
||||
let content;
|
||||
if (tab.type === 'query') {
|
||||
if (!shouldRenderContent) {
|
||||
content = null;
|
||||
} else if (tab.type === 'query') {
|
||||
content = <QueryEditor tab={tab} />;
|
||||
} else if (tab.type === 'table') {
|
||||
content = <DataViewer tab={tab} />;
|
||||
@@ -100,14 +195,15 @@ const TabManager: React.FC = () => {
|
||||
|
||||
return {
|
||||
label: (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<span onContextMenu={(e) => e.preventDefault()}>{displayTitle}</span>
|
||||
</Dropdown>
|
||||
<SortableTabLabel
|
||||
displayTitle={displayTitle}
|
||||
menuItems={menuItems}
|
||||
/>
|
||||
),
|
||||
key: tab.id,
|
||||
children: content,
|
||||
};
|
||||
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -156,18 +252,63 @@ const TabManager: React.FC = () => {
|
||||
display: none !important;
|
||||
}
|
||||
.main-tabs .ant-tabs-nav::before {
|
||||
border-bottom: none !important;
|
||||
border-bottom: 1px solid ${tabsNavBorderColor} !important;
|
||||
}
|
||||
.main-tabs .ant-tabs-tab {
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease;
|
||||
}
|
||||
.main-tabs .tab-dnd-label {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
.main-tabs .tab-dnd-node.is-dragging,
|
||||
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible {
|
||||
outline: none !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.72);
|
||||
background: rgba(255, 214, 102, 0.16);
|
||||
}
|
||||
body[data-theme='light'] .main-tabs .ant-tabs-tab-btn:focus-visible {
|
||||
outline: none !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
|
||||
background: rgba(9, 109, 217, 0.08);
|
||||
}
|
||||
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
background: rgba(255, 214, 102, 0.12) !important;
|
||||
border-color: rgba(255, 214, 102, 0.4) !important;
|
||||
}
|
||||
`}</style>
|
||||
<Tabs
|
||||
className="main-tabs"
|
||||
type="editable-card"
|
||||
onChange={onChange}
|
||||
activeKey={activeTabId || undefined}
|
||||
onEdit={onEdit}
|
||||
items={items}
|
||||
hideAdd
|
||||
/>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
<Tabs
|
||||
className="main-tabs"
|
||||
type="editable-card"
|
||||
onChange={(newActiveKey) => {
|
||||
if (Date.now() < suppressClickUntilRef.current) return;
|
||||
onChange(newActiveKey);
|
||||
}}
|
||||
activeKey={activeTabId || undefined}
|
||||
onEdit={onEdit}
|
||||
items={items}
|
||||
hideAdd
|
||||
renderTabBar={renderTabBar}
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -259,6 +259,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff';
|
||||
const readOnly = !!tab.readOnly;
|
||||
|
||||
const [tableHeight, setTableHeight] = useState(500);
|
||||
@@ -1973,7 +1974,7 @@ END;`;
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '2px',
|
||||
background: '#1890ff',
|
||||
background: resizeGuideColor,
|
||||
zIndex: 9999,
|
||||
display: 'none',
|
||||
pointerEvents: 'none',
|
||||
|
||||
@@ -3,6 +3,12 @@ import { persist } from 'zustand/middleware';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery } from './types';
|
||||
|
||||
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
const MIN_FONT_SIZE = 12;
|
||||
const MAX_FONT_SIZE = 20;
|
||||
const DEFAULT_STARTUP_FULLSCREEN = false;
|
||||
const LEGACY_DEFAULT_OPACITY = 0.95;
|
||||
const OPACITY_EPSILON = 1e-6;
|
||||
@@ -107,6 +113,13 @@ const normalizeIntegerInRange = (value: unknown, fallbackValue: number, min: num
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeFloatInRange = (value: unknown, fallbackValue: number, min: number, max: number): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallbackValue;
|
||||
if (parsed < min || parsed > max) return fallbackValue;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const isValidHostEntry = (entry: string): boolean => {
|
||||
if (!entry) return false;
|
||||
if (entry.length > MAX_HOST_ENTRY_LENGTH) return false;
|
||||
@@ -199,7 +212,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
proxy,
|
||||
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
|
||||
hosts: sanitizeAddressList(raw.hosts),
|
||||
topology: raw.topology === 'replica' ? 'replica' : 'single',
|
||||
topology: raw.topology === 'replica' ? 'replica' : (raw.topology === 'cluster' ? 'cluster' : 'single'),
|
||||
mysqlReplicaUser: toTrimmedString(raw.mysqlReplicaUser),
|
||||
mysqlReplicaPassword: savePassword ? toTrimmedString(raw.mysqlReplicaPassword) : '',
|
||||
replicaSet: toTrimmedString(raw.replicaSet),
|
||||
@@ -318,6 +331,8 @@ interface AppState {
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { opacity: number; blur: number };
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
startupFullscreen: boolean;
|
||||
globalProxy: GlobalProxyConfig;
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
@@ -337,6 +352,7 @@ interface AppState {
|
||||
closeTabsToRight: (id: string) => void;
|
||||
closeTabsByConnection: (connectionId: string) => void;
|
||||
closeTabsByDatabase: (connectionId: string, dbName: string) => void;
|
||||
moveTab: (sourceId: string, targetId: string) => void;
|
||||
closeAllTabs: () => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
|
||||
@@ -346,6 +362,8 @@ interface AppState {
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||
setUiScale: (scale: number) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
@@ -440,6 +458,14 @@ const sanitizeStartupFullscreen = (value: unknown): boolean => {
|
||||
return value === true;
|
||||
};
|
||||
|
||||
const sanitizeUiScale = (value: unknown): number => {
|
||||
return normalizeFloatInRange(value, DEFAULT_UI_SCALE, MIN_UI_SCALE, MAX_UI_SCALE);
|
||||
};
|
||||
|
||||
const sanitizeFontSize = (value: unknown): number => {
|
||||
return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
};
|
||||
|
||||
const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase();
|
||||
@@ -476,6 +502,8 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: [],
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
uiScale: DEFAULT_UI_SCALE,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
|
||||
globalProxy: { ...DEFAULT_GLOBAL_PROXY },
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
@@ -571,6 +599,23 @@ export const useStore = create<AppState>()(
|
||||
};
|
||||
}),
|
||||
|
||||
moveTab: (sourceId, targetId) => set((state) => {
|
||||
const fromId = String(sourceId || '').trim();
|
||||
const toId = String(targetId || '').trim();
|
||||
if (!fromId || !toId || fromId === toId) {
|
||||
return state;
|
||||
}
|
||||
const fromIndex = state.tabs.findIndex((tab) => tab.id === fromId);
|
||||
const toIndex = state.tabs.findIndex((tab) => tab.id === toId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
return state;
|
||||
}
|
||||
const nextTabs = [...state.tabs];
|
||||
const [movingTab] = nextTabs.splice(fromIndex, 1);
|
||||
nextTabs.splice(toIndex, 0, movingTab);
|
||||
return { tabs: nextTabs };
|
||||
}),
|
||||
|
||||
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
|
||||
|
||||
setActiveTab: (id) => set({ activeTabId: id }),
|
||||
@@ -589,6 +634,8 @@ export const useStore = create<AppState>()(
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
|
||||
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
|
||||
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
|
||||
setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })),
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
@@ -628,6 +675,8 @@ export const useStore = create<AppState>()(
|
||||
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
||||
nextState.theme = sanitizeTheme(state.theme);
|
||||
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
||||
nextState.uiScale = sanitizeUiScale(state.uiScale);
|
||||
nextState.fontSize = sanitizeFontSize(state.fontSize);
|
||||
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
|
||||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
||||
@@ -645,6 +694,8 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
|
||||
uiScale: sanitizeUiScale(state.uiScale),
|
||||
fontSize: sanitizeFontSize(state.fontSize),
|
||||
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy),
|
||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||
@@ -658,6 +709,8 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
uiScale: state.uiScale,
|
||||
fontSize: state.fontSize,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
globalProxy: state.globalProxy,
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface ConnectionConfig {
|
||||
redisDB?: number; // Redis database index (0-15)
|
||||
uri?: string; // Connection URI for copy/paste
|
||||
hosts?: string[]; // Multi-host addresses: host:port
|
||||
topology?: 'single' | 'replica';
|
||||
topology?: 'single' | 'replica' | 'cluster';
|
||||
mysqlReplicaUser?: string;
|
||||
mysqlReplicaPassword?: string;
|
||||
replicaSet?: string;
|
||||
|
||||
86
frontend/src/utils/dataSourceCapabilities.ts
Normal file
86
frontend/src/utils/dataSourceCapabilities.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ConnectionConfig } from '../types';
|
||||
|
||||
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver'> | null | undefined;
|
||||
|
||||
const normalizeDataSourceToken = (raw: string): string => {
|
||||
const normalized = String(raw || '').trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'doris':
|
||||
return 'diros';
|
||||
case 'postgresql':
|
||||
return 'postgres';
|
||||
case 'dm':
|
||||
return 'dameng';
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveDataSourceType = (config: ConnectionLike): string => {
|
||||
if (!config) return '';
|
||||
const type = normalizeDataSourceToken(String(config.type || ''));
|
||||
if (type === 'custom') {
|
||||
const driver = normalizeDataSourceToken(String(config.driver || ''));
|
||||
return driver || 'custom';
|
||||
}
|
||||
return type;
|
||||
};
|
||||
|
||||
const SQL_QUERY_EXPORT_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'sqlserver',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
'oracle',
|
||||
'dameng',
|
||||
'tdengine',
|
||||
'clickhouse',
|
||||
]);
|
||||
|
||||
const COPY_INSERT_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'sqlserver',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
'oracle',
|
||||
'dameng',
|
||||
'tdengine',
|
||||
'clickhouse',
|
||||
]);
|
||||
|
||||
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
|
||||
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
|
||||
|
||||
export type DataSourceCapabilities = {
|
||||
type: string;
|
||||
supportsQueryEditor: boolean;
|
||||
supportsSqlQueryExport: boolean;
|
||||
supportsCopyInsert: boolean;
|
||||
forceReadOnlyQueryResult: boolean;
|
||||
};
|
||||
|
||||
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
|
||||
const type = resolveDataSourceType(config);
|
||||
return {
|
||||
type,
|
||||
supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type),
|
||||
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
|
||||
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
|
||||
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
|
||||
};
|
||||
};
|
||||
|
||||
4
frontend/wailsjs/go/app/App.d.ts
vendored
4
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -100,6 +100,8 @@ export function MySQLQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:str
|
||||
|
||||
export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDownloadedUpdateDirectory():Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -164,6 +166,8 @@ export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise
|
||||
|
||||
export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDatabaseFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverPackageDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -194,6 +194,10 @@ export function MySQLShowCreateTable(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['MySQLShowCreateTable'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function OpenDownloadedUpdateDirectory() {
|
||||
return window['go']['app']['App']['OpenDownloadedUpdateDirectory']();
|
||||
}
|
||||
|
||||
export function OpenSQLFile() {
|
||||
return window['go']['app']['App']['OpenSQLFile']();
|
||||
}
|
||||
@@ -322,6 +326,10 @@ export function ResolveDriverRepositoryURL(arg1) {
|
||||
return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDatabaseFile(arg1, arg2) {
|
||||
return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1);
|
||||
}
|
||||
|
||||
@@ -74,16 +74,67 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
logger.Close()
|
||||
}
|
||||
|
||||
// Helper: Generate a unique key for the connection config
|
||||
func getCacheKey(config connection.ConnectionConfig) string {
|
||||
if !config.UseSSH {
|
||||
config.SSH = connection.SSHConfig{}
|
||||
func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
normalized := config
|
||||
normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type))
|
||||
// timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。
|
||||
normalized.Timeout = 0
|
||||
normalized.SavePassword = false
|
||||
|
||||
if !normalized.UseSSH {
|
||||
normalized.SSH = connection.SSHConfig{}
|
||||
}
|
||||
if !config.UseProxy {
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
if !normalized.UseProxy {
|
||||
normalized.Proxy = connection.ProxyConfig{}
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(config)
|
||||
if isFileDatabaseType(normalized.Type) {
|
||||
dsn := strings.TrimSpace(normalized.Host)
|
||||
if dsn == "" {
|
||||
dsn = strings.TrimSpace(normalized.Database)
|
||||
}
|
||||
if dsn == "" {
|
||||
dsn = ":memory:"
|
||||
}
|
||||
|
||||
// DuckDB/SQLite 仅基于文件来源识别连接,其他网络字段不参与键计算。
|
||||
normalized.Host = dsn
|
||||
normalized.Database = ""
|
||||
normalized.Port = 0
|
||||
normalized.User = ""
|
||||
normalized.Password = ""
|
||||
normalized.URI = ""
|
||||
normalized.Hosts = nil
|
||||
normalized.Topology = ""
|
||||
normalized.MySQLReplicaUser = ""
|
||||
normalized.MySQLReplicaPassword = ""
|
||||
normalized.ReplicaSet = ""
|
||||
normalized.AuthSource = ""
|
||||
normalized.ReadPreference = ""
|
||||
normalized.MongoSRV = false
|
||||
normalized.MongoAuthMechanism = ""
|
||||
normalized.MongoReplicaUser = ""
|
||||
normalized.MongoReplicaPassword = ""
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func resolveFileDatabaseDSN(config connection.ConnectionConfig) string {
|
||||
dsn := strings.TrimSpace(config.Host)
|
||||
if dsn == "" {
|
||||
dsn = strings.TrimSpace(config.Database)
|
||||
}
|
||||
if dsn == "" {
|
||||
dsn = ":memory:"
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// Helper: Generate a unique key for the connection config
|
||||
func getCacheKey(config connection.ConnectionConfig) string {
|
||||
normalized := normalizeCacheKeyConfig(config)
|
||||
b, _ := json.Marshal(normalized)
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -235,12 +286,19 @@ func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Datab
|
||||
|
||||
func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
|
||||
effectiveConfig := applyGlobalProxyToConnection(config)
|
||||
isFileDB := isFileDatabaseType(effectiveConfig.Type)
|
||||
|
||||
key := getCacheKey(effectiveConfig)
|
||||
shortKey := key
|
||||
if len(shortKey) > 12 {
|
||||
shortKey = shortKey[:12]
|
||||
}
|
||||
if isFileDB {
|
||||
rawDSN := resolveFileDatabaseDSN(effectiveConfig)
|
||||
normalizedDSN := resolveFileDatabaseDSN(normalizeCacheKeyConfig(effectiveConfig))
|
||||
logger.Infof("文件库连接缓存探测:类型=%s 原始DSN=%s 归一化DSN=%s timeout=%ds forcePing=%t 缓存Key=%s",
|
||||
strings.TrimSpace(effectiveConfig.Type), rawDSN, normalizedDSN, effectiveConfig.Timeout, forcePing, shortKey)
|
||||
}
|
||||
|
||||
if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
@@ -260,6 +318,9 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
entry, ok := a.dbCache[key]
|
||||
a.mu.RUnlock()
|
||||
if ok {
|
||||
if isFileDB {
|
||||
logger.Infof("命中文件库连接缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||||
}
|
||||
needPing := forcePing
|
||||
if !needPing {
|
||||
lastPing := entry.lastPing
|
||||
@@ -269,6 +330,9 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
}
|
||||
|
||||
if !needPing {
|
||||
if isFileDB {
|
||||
logger.Infof("复用文件库连接缓存(免 Ping):类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||||
}
|
||||
return entry.inst, nil
|
||||
}
|
||||
|
||||
@@ -280,6 +344,9 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
a.dbCache[key] = cur
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if isFileDB {
|
||||
logger.Infof("复用文件库连接缓存(Ping 成功):类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||||
}
|
||||
return entry.inst, nil
|
||||
} else {
|
||||
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
@@ -294,6 +361,12 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
delete(a.dbCache, key)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if isFileDB {
|
||||
logger.Infof("文件库缓存连接已剔除,准备新建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||||
}
|
||||
}
|
||||
if isFileDB {
|
||||
logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||||
}
|
||||
|
||||
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
@@ -324,6 +397,9 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
a.mu.Unlock()
|
||||
// Prefer existing cached connection to avoid cache racing duplicates.
|
||||
_ = dbInst.Close()
|
||||
if isFileDB {
|
||||
logger.Infof("并发创建命中已存在文件库连接,关闭新建连接并复用缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||||
}
|
||||
return existing.inst, nil
|
||||
}
|
||||
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
|
||||
|
||||
63
internal/app/app_cache_key_test.go
Normal file
63
internal/app/app_cache_key_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestGetCacheKey_IgnoreTimeout(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "duckdb",
|
||||
Host: `C:\data\songs.duckdb`,
|
||||
Timeout: 30,
|
||||
UseProxy: false,
|
||||
UseSSH: false,
|
||||
}
|
||||
modified := base
|
||||
modified.Timeout = 120
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left != right {
|
||||
t.Fatalf("expected same cache key when only timeout differs, got %s vs %s", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_DuckDBHostAndDatabaseEquivalent(t *testing.T) {
|
||||
withHost := connection.ConnectionConfig{
|
||||
Type: "duckdb",
|
||||
Host: `D:\music\songs.duckdb`,
|
||||
}
|
||||
withDatabase := connection.ConnectionConfig{
|
||||
Type: "duckdb",
|
||||
Database: `D:\music\songs.duckdb`,
|
||||
}
|
||||
|
||||
left := getCacheKey(withHost)
|
||||
right := getCacheKey(withDatabase)
|
||||
if left != right {
|
||||
t.Fatalf("expected same cache key for duckdb host/database path, got %s vs %s", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_KeepDatabaseIsolation(t *testing.T) {
|
||||
a := connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Host: "127.0.0.1",
|
||||
Port: 3306,
|
||||
User: "root",
|
||||
Password: "root",
|
||||
Database: "db_a",
|
||||
Timeout: 30,
|
||||
}
|
||||
b := a
|
||||
b.Database = "db_b"
|
||||
b.Timeout = 5
|
||||
|
||||
left := getCacheKey(a)
|
||||
right := getCacheKey(b)
|
||||
if left == right {
|
||||
t.Fatalf("expected different cache key for different database targets")
|
||||
}
|
||||
}
|
||||
@@ -83,12 +83,15 @@ type driverDownloadProgressPayload struct {
|
||||
}
|
||||
|
||||
type driverNetworkProbeItem struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Reachable bool `json:"reachable"`
|
||||
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Reachable bool `json:"reachable"`
|
||||
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
TCPLatency int64 `json:"tcpLatencyMs,omitempty"`
|
||||
HTTPLatency int64 `json:"httpLatencyMs,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type pinnedDriverPackage struct {
|
||||
@@ -201,6 +204,7 @@ const (
|
||||
driverBundleIndexMaxSize = 1 << 20
|
||||
driverManifestMaxSize = 2 << 20
|
||||
driverNetworkProbeTimeout = 4 * time.Second
|
||||
driverNetworkProbeTCPTimeout = 3 * time.Second
|
||||
localDriverDirectoryScanMaxEntries = 20000
|
||||
driverChecksumPolicyStrict = "strict"
|
||||
driverChecksumPolicyWarn = "warn"
|
||||
@@ -218,14 +222,14 @@ const builtinDriverManifestJSON = `{
|
||||
"sphinx": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sphinx" },
|
||||
"sqlserver": { "engine": "go", "version": "1.9.6", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlserver" },
|
||||
"sqlite": { "engine": "go", "version": "1.44.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlite" },
|
||||
"duckdb": { "engine": "go", "version": "2.5.5", "checksumPolicy": "off", "downloadUrl": "builtin://activate/duckdb" },
|
||||
"duckdb": { "engine": "go", "version": "2.5.6", "checksumPolicy": "off", "downloadUrl": "builtin://activate/duckdb" },
|
||||
"dameng": { "engine": "go", "version": "1.8.22", "checksumPolicy": "off", "downloadUrl": "builtin://activate/dameng" },
|
||||
"kingbase": { "engine": "go", "version": "0.0.0-20201021123113-29bd62a876c3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/kingbase" },
|
||||
"highgo": { "engine": "go", "version": "0.0.0-local", "checksumPolicy": "off", "downloadUrl": "builtin://activate/highgo" },
|
||||
"vastbase": { "engine": "go", "version": "1.11.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/vastbase" },
|
||||
"mongodb": { "engine": "go", "version": "2.5.0", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mongodb" },
|
||||
"tdengine": { "engine": "go", "version": "3.7.8", "checksumPolicy": "off", "downloadUrl": "builtin://activate/tdengine" },
|
||||
"clickhouse": { "engine": "go", "version": "2.43.0", "checksumPolicy": "off", "downloadUrl": "builtin://activate/clickhouse" }
|
||||
"clickhouse": { "engine": "go", "version": "2.43.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/clickhouse" }
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -271,14 +275,14 @@ var latestDriverVersionMap = map[string]string{
|
||||
"sphinx": "1.9.3",
|
||||
"sqlserver": "1.9.6",
|
||||
"sqlite": "1.46.1",
|
||||
"duckdb": "2.5.5",
|
||||
"duckdb": "2.5.6",
|
||||
"dameng": "1.8.22",
|
||||
"kingbase": "0.0.0-20201021123113-29bd62a876c3",
|
||||
"highgo": "0.0.0-local",
|
||||
"vastbase": "1.11.2",
|
||||
"mongodb": "2.5.0",
|
||||
"tdengine": "3.7.8",
|
||||
"clickhouse": "2.43.0",
|
||||
"clickhouse": "2.43.1",
|
||||
"oracle": "2.9.0",
|
||||
"postgres": "1.11.2",
|
||||
"redis": "9.17.3",
|
||||
@@ -647,24 +651,43 @@ func (a *App) CheckDriverNetworkStatus() connection.QueryResult {
|
||||
Name: "GitHub 驱动发布",
|
||||
URL: fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", updateRepo, optionalDriverBundleAssetName),
|
||||
},
|
||||
{
|
||||
Name: "GitHub Release 资产域名",
|
||||
URL: "https://release-assets.githubusercontent.com/",
|
||||
},
|
||||
{
|
||||
Name: "Go 模块代理",
|
||||
URL: "https://proxy.golang.org/github.com/go-sql-driver/mysql/@v/list",
|
||||
},
|
||||
}
|
||||
|
||||
client := newHTTPClientWithGlobalProxy(driverNetworkProbeTimeout)
|
||||
allReachable := true
|
||||
for index := range checks {
|
||||
checks[index] = probeDriverNetworkEndpoint(checks[index])
|
||||
checks[index] = probeDriverNetworkEndpoint(client, checks[index])
|
||||
if !checks[index].Reachable {
|
||||
allReachable = false
|
||||
}
|
||||
}
|
||||
findProbe := func(name string) (driverNetworkProbeItem, bool) {
|
||||
for _, item := range checks {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(name)) {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return driverNetworkProbeItem{}, false
|
||||
}
|
||||
githubAPICheck, _ := findProbe("GitHub API")
|
||||
githubReleaseCheck, _ := findProbe("GitHub 驱动发布")
|
||||
releaseAssetsCheck, _ := findProbe("GitHub Release 资产域名")
|
||||
downloadChainReachable := githubReleaseCheck.Reachable && releaseAssetsCheck.Reachable
|
||||
|
||||
proxyEnv := collectDriverProxyEnv()
|
||||
proxyConfigured := len(proxyEnv) > 0
|
||||
summary := "驱动下载网络检测通过,可直接安装驱动。"
|
||||
if !allReachable {
|
||||
if githubAPICheck.Reachable && !downloadChainReachable {
|
||||
summary = "重要提醒:GitHub API 可达,但驱动下载链路不可达。请优先在 GoNavi 启用全局代理(填写代理应用本地地址和端口),并在代理规则中放行 github.com、api.github.com、release-assets.githubusercontent.com、objects.githubusercontent.com、raw.githubusercontent.com;若仍失败,再考虑开启 TUN 模式。"
|
||||
} else if !allReachable {
|
||||
if proxyConfigured {
|
||||
summary = "检测到部分驱动下载地址不可达,请确认系统代理配置有效后重试。"
|
||||
} else {
|
||||
@@ -678,6 +701,14 @@ func (a *App) CheckDriverNetworkStatus() connection.QueryResult {
|
||||
"recommendedProxy": !allReachable,
|
||||
"proxyConfigured": proxyConfigured,
|
||||
"proxyEnv": proxyEnv,
|
||||
"downloadChainReachable": downloadChainReachable,
|
||||
"downloadRequiredHosts": []string{
|
||||
"github.com",
|
||||
"api.github.com",
|
||||
"release-assets.githubusercontent.com",
|
||||
"objects.githubusercontent.com",
|
||||
"raw.githubusercontent.com",
|
||||
},
|
||||
"checkedAt": time.Now().Format(time.RFC3339),
|
||||
"checks": checks,
|
||||
}
|
||||
@@ -890,12 +921,15 @@ func (a *App) emitDriverDownloadProgress(driverType string, status string, downl
|
||||
runtime.EventsEmit(a.ctx, driverDownloadProgressEvent, payload)
|
||||
}
|
||||
|
||||
func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeItem {
|
||||
func probeDriverNetworkEndpoint(client *http.Client, item driverNetworkProbeItem) driverNetworkProbeItem {
|
||||
probed := item
|
||||
probed.Reachable = false
|
||||
probed.HTTPStatus = 0
|
||||
probed.Error = ""
|
||||
probed.LatencyMs = 0
|
||||
probed.TCPLatency = 0
|
||||
probed.HTTPLatency = 0
|
||||
probed.Method = ""
|
||||
|
||||
urlText := strings.TrimSpace(item.URL)
|
||||
if urlText == "" {
|
||||
@@ -903,33 +937,34 @@ func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeI
|
||||
return probed
|
||||
}
|
||||
|
||||
client := newHTTPClientWithGlobalProxy(driverNetworkProbeTimeout)
|
||||
start := time.Now()
|
||||
req, err := http.NewRequest(http.MethodHead, urlText, nil)
|
||||
if err != nil {
|
||||
probed.Error = normalizeErrorMessage(err)
|
||||
return probed
|
||||
if tcpLatency, tcpErr := probeDriverTCPLatency(urlText); tcpErr == nil {
|
||||
probed.TCPLatency = tcpLatency
|
||||
probed.LatencyMs = tcpLatency
|
||||
}
|
||||
req.Header.Set("User-Agent", "GoNavi-DriverManager")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 某些网关不支持 HEAD,请回退为 GET(不读取正文)。
|
||||
reqGet, reqErr := http.NewRequest(http.MethodGet, urlText, nil)
|
||||
if reqErr != nil {
|
||||
probed.Error = normalizeErrorMessage(reqErr)
|
||||
probed.LatencyMs = time.Since(start).Milliseconds()
|
||||
return probed
|
||||
}
|
||||
reqGet.Header.Set("User-Agent", "GoNavi-DriverManager")
|
||||
resp, err = client.Do(reqGet)
|
||||
if client == nil {
|
||||
client = newHTTPClientWithGlobalProxy(driverNetworkProbeTimeout)
|
||||
}
|
||||
start := time.Now()
|
||||
resp, method, err := doDriverProbeRequest(client, urlText, http.MethodGet)
|
||||
if err != nil || shouldFallbackHeadProbe(resp) {
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
// 回退到 HEAD 时重置计时,避免把失败重试耗时累计到最终延迟指标里。
|
||||
start = time.Now()
|
||||
resp, method, err = doDriverProbeRequest(client, urlText, http.MethodHead)
|
||||
}
|
||||
probed.HTTPLatency = time.Since(start).Milliseconds()
|
||||
if probed.LatencyMs <= 0 {
|
||||
probed.LatencyMs = probed.HTTPLatency
|
||||
}
|
||||
probed.LatencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
probed.Error = normalizeDriverNetworkError(err)
|
||||
return probed
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
probed.Method = method
|
||||
|
||||
probed.HTTPStatus = resp.StatusCode
|
||||
if resp.StatusCode >= 500 {
|
||||
@@ -940,6 +975,121 @@ func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeI
|
||||
return probed
|
||||
}
|
||||
|
||||
func probeDriverTCPLatency(rawURL string) (int64, error) {
|
||||
dialAddr, err := resolveDriverProbeDialAddress(rawURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", dialAddr, driverNetworkProbeTCPTimeout)
|
||||
elapsed := time.Since(start)
|
||||
latency := elapsed.Milliseconds()
|
||||
if elapsed > 0 && latency <= 0 {
|
||||
latency = 1
|
||||
}
|
||||
if err != nil {
|
||||
return latency, err
|
||||
}
|
||||
_ = conn.Close()
|
||||
return latency, nil
|
||||
}
|
||||
|
||||
func resolveDriverProbeDialAddress(rawURL string) (string, error) {
|
||||
urlText := strings.TrimSpace(rawURL)
|
||||
if urlText == "" {
|
||||
return "", fmt.Errorf("检测地址为空")
|
||||
}
|
||||
parsed, err := url.Parse(urlText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
targetHost := strings.TrimSpace(parsed.Hostname())
|
||||
if targetHost == "" {
|
||||
return "", fmt.Errorf("检测地址缺少主机")
|
||||
}
|
||||
targetPort := strings.TrimSpace(parsed.Port())
|
||||
if targetPort == "" {
|
||||
if strings.EqualFold(parsed.Scheme, "http") {
|
||||
targetPort = "80"
|
||||
} else {
|
||||
targetPort = "443"
|
||||
}
|
||||
}
|
||||
|
||||
if proxyURL := resolveDriverProbeProxyURL(parsed); proxyURL != nil {
|
||||
proxyHost := strings.TrimSpace(proxyURL.Hostname())
|
||||
if proxyHost == "" {
|
||||
return net.JoinHostPort(targetHost, targetPort), nil
|
||||
}
|
||||
proxyPort := strings.TrimSpace(proxyURL.Port())
|
||||
if proxyPort == "" {
|
||||
proxyPort = defaultPortForScheme(proxyURL.Scheme)
|
||||
}
|
||||
return net.JoinHostPort(proxyHost, proxyPort), nil
|
||||
}
|
||||
|
||||
return net.JoinHostPort(targetHost, targetPort), nil
|
||||
}
|
||||
|
||||
func resolveDriverProbeProxyURL(target *url.URL) *url.URL {
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
snapshot := currentGlobalProxyConfig()
|
||||
if snapshot.Enabled {
|
||||
proxyURL, err := buildProxyURLFromConfig(snapshot.Proxy)
|
||||
if err == nil {
|
||||
return proxyURL
|
||||
}
|
||||
}
|
||||
|
||||
req := &http.Request{URL: target}
|
||||
proxyURL, err := http.ProxyFromEnvironment(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return proxyURL
|
||||
}
|
||||
|
||||
func defaultPortForScheme(scheme string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(scheme)) {
|
||||
case "https":
|
||||
return "443"
|
||||
case "socks5", "socks5h":
|
||||
return "1080"
|
||||
case "http":
|
||||
fallthrough
|
||||
default:
|
||||
return "80"
|
||||
}
|
||||
}
|
||||
|
||||
func doDriverProbeRequest(client *http.Client, urlText string, method string) (*http.Response, string, error) {
|
||||
req, err := http.NewRequest(method, urlText, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "GoNavi-DriverManager")
|
||||
// 用 GET+Range 探测可更接近真实下载链路,同时避免下载正文。
|
||||
if strings.EqualFold(method, http.MethodGet) {
|
||||
req.Header.Set("Range", "bytes=0-0")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, method, err
|
||||
}
|
||||
return resp, method, nil
|
||||
}
|
||||
|
||||
func shouldFallbackHeadProbe(resp *http.Response) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
return resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented
|
||||
}
|
||||
|
||||
func normalizeDriverNetworkError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
|
||||
@@ -2,12 +2,14 @@ package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,11 +17,16 @@ import (
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/utils"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const minExportQueryTimeout = 5 * time.Minute
|
||||
const minClickHouseExportQueryTimeout = 2 * time.Hour
|
||||
|
||||
func (a *App) OpenSQLFile() connection.QueryResult {
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select SQL File",
|
||||
@@ -120,6 +127,78 @@ func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult {
|
||||
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}}
|
||||
}
|
||||
|
||||
func (a *App) SelectDatabaseFile(currentPath string, driverType string) connection.QueryResult {
|
||||
defaultDir := strings.TrimSpace(currentPath)
|
||||
if defaultDir == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
defaultDir = home
|
||||
}
|
||||
}
|
||||
if filepath.Ext(defaultDir) != "" {
|
||||
defaultDir = filepath.Dir(defaultDir)
|
||||
}
|
||||
if defaultDir != "" && !filepath.IsAbs(defaultDir) {
|
||||
if abs, err := filepath.Abs(defaultDir); err == nil {
|
||||
defaultDir = abs
|
||||
}
|
||||
}
|
||||
|
||||
normalizedType := strings.ToLower(strings.TrimSpace(driverType))
|
||||
filters := []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "数据库文件",
|
||||
Pattern: "*.db;*.sqlite;*.sqlite3;*.db3;*.duckdb;*.ddb",
|
||||
},
|
||||
{
|
||||
DisplayName: "所有文件",
|
||||
Pattern: "*",
|
||||
},
|
||||
}
|
||||
title := "选择数据库文件"
|
||||
switch normalizedType {
|
||||
case "sqlite":
|
||||
title = "选择 SQLite 数据文件"
|
||||
filters = []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "SQLite 文件",
|
||||
Pattern: "*.db;*.sqlite;*.sqlite3;*.db3",
|
||||
},
|
||||
{
|
||||
DisplayName: "所有文件",
|
||||
Pattern: "*",
|
||||
},
|
||||
}
|
||||
case "duckdb":
|
||||
title = "选择 DuckDB 数据文件"
|
||||
filters = []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "DuckDB 文件",
|
||||
Pattern: "*.duckdb;*.ddb;*.db",
|
||||
},
|
||||
{
|
||||
DisplayName: "所有文件",
|
||||
Pattern: "*",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: title,
|
||||
DefaultDirectory: defaultDir,
|
||||
Filters: filters,
|
||||
})
|
||||
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 == "" {
|
||||
@@ -541,7 +620,7 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
|
||||
|
||||
query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(runConfig.Type, tableName))
|
||||
|
||||
data, columns, err := dbInst.Query(query)
|
||||
data, columns, err := queryDataForExport(dbInst, runConfig, query)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
@@ -872,7 +951,7 @@ func listViewNameLookup(dbInst db.Database, config connection.ConnectionConfig,
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
rows, _, err := dbInst.Query(query)
|
||||
rows, _, err := queryDataForExport(dbInst, config, query)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -983,7 +1062,7 @@ func tryGetViewCreateStatement(
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
rows, _, err := dbInst.Query(query)
|
||||
rows, _, err := queryDataForExport(dbInst, config, query)
|
||||
if err != nil || len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -1348,7 +1427,7 @@ func dumpTableSQL(
|
||||
|
||||
qualified := qualifyTable(schemaName, pureTableName)
|
||||
selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified))
|
||||
data, columns, err := dbInst.Query(selectSQL)
|
||||
data, columns, err := queryDataForExport(dbInst, config, selectSQL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1383,14 +1462,17 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
|
||||
if defaultName == "" {
|
||||
defaultName = "export"
|
||||
}
|
||||
logger.Infof("ExportData 开始:rows=%d cols=%d format=%s defaultName=%s", len(data), len(columns), strings.ToLower(strings.TrimSpace(format)), strings.TrimSpace(defaultName))
|
||||
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "Export Data",
|
||||
DefaultFilename: fmt.Sprintf("%s.%s", defaultName, strings.ToLower(format)),
|
||||
})
|
||||
|
||||
if err != nil || filename == "" {
|
||||
logger.Infof("ExportData 已取消或未选择文件:err=%v", err)
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
}
|
||||
logger.Infof("ExportData 选定文件:%s", filename)
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
@@ -1398,9 +1480,11 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
|
||||
}
|
||||
defer f.Close()
|
||||
if err := writeRowsToFile(f, data, columns, format); err != nil {
|
||||
logger.Warnf("ExportData 写入失败:file=%s err=%v", filename, err)
|
||||
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||
}
|
||||
|
||||
logger.Infof("ExportData 完成:file=%s rows=%d", filename, len(data))
|
||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||
}
|
||||
|
||||
@@ -1421,8 +1505,10 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
|
||||
DefaultFilename: fmt.Sprintf("%s.%s", defaultName, strings.ToLower(format)),
|
||||
})
|
||||
if err != nil || filename == "" {
|
||||
logger.Infof("ExportQuery 已取消或未选择文件:err=%v", err)
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
}
|
||||
logger.Infof("ExportQuery 开始:type=%s db=%s format=%s file=%s sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), strings.ToLower(strings.TrimSpace(format)), filename, sqlSnippet(query))
|
||||
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
@@ -1436,8 +1522,9 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
|
||||
return connection.QueryResult{Success: false, Message: "Only SELECT/WITH queries are supported"}
|
||||
}
|
||||
|
||||
data, columns, err := dbInst.Query(query)
|
||||
data, columns, err := queryDataForExport(dbInst, runConfig, query)
|
||||
if err != nil {
|
||||
logger.Warnf("ExportQuery 查询失败:type=%s db=%s err=%v sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), err, sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
@@ -1448,12 +1535,55 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
|
||||
defer f.Close()
|
||||
|
||||
if err := writeRowsToFile(f, data, columns, format); err != nil {
|
||||
logger.Warnf("ExportQuery 写入失败:file=%s err=%v", filename, err)
|
||||
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||
}
|
||||
|
||||
logger.Infof("ExportQuery 完成:file=%s rows=%d cols=%d", filename, len(data), len(columns))
|
||||
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||
}
|
||||
|
||||
func queryDataForExport(dbInst db.Database, config connection.ConnectionConfig, query string) ([]map[string]interface{}, []string, error) {
|
||||
timeout := getExportQueryTimeout(config)
|
||||
dbType := resolveDDLDBType(config)
|
||||
if dbType == "clickhouse" {
|
||||
logger.Infof("ClickHouse 导出查询开始:timeout=%s SQL片段=%q", timeout, sqlSnippet(query))
|
||||
}
|
||||
if q, ok := dbInst.(interface {
|
||||
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||
}); ok {
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
data, columns, err := q.QueryContext(ctx, query)
|
||||
if err != nil && dbType == "clickhouse" {
|
||||
logger.Warnf("ClickHouse 导出查询失败:timeout=%s SQL片段=%q err=%v", timeout, sqlSnippet(query), err)
|
||||
}
|
||||
return data, columns, err
|
||||
}
|
||||
data, columns, err := dbInst.Query(query)
|
||||
if err != nil && dbType == "clickhouse" {
|
||||
logger.Warnf("ClickHouse 导出查询失败(无 QueryContext):timeout=%s SQL片段=%q err=%v", timeout, sqlSnippet(query), err)
|
||||
}
|
||||
return data, columns, err
|
||||
}
|
||||
|
||||
func getExportQueryTimeout(config connection.ConnectionConfig) time.Duration {
|
||||
timeout := time.Duration(config.Timeout) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = minExportQueryTimeout
|
||||
}
|
||||
if resolveDDLDBType(config) == "clickhouse" {
|
||||
if timeout < minClickHouseExportQueryTimeout {
|
||||
timeout = minClickHouseExportQueryTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
if timeout < minExportQueryTimeout {
|
||||
timeout = minExportQueryTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string, format string) error {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if f == nil {
|
||||
@@ -1527,7 +1657,11 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := jsonEncoder.Encode(rowMap); err != nil {
|
||||
exportedRow := make(map[string]interface{}, len(columns))
|
||||
for _, col := range columns {
|
||||
exportedRow[col] = normalizeExportJSONValue(rowMap[col])
|
||||
}
|
||||
if err := jsonEncoder.Encode(exportedRow); err != nil {
|
||||
return err
|
||||
}
|
||||
isJsonFirstRow = false
|
||||
@@ -1567,11 +1701,102 @@ func formatExportCellText(val interface{}) string {
|
||||
return "NULL"
|
||||
}
|
||||
return v.Format("2006-01-02 15:04:05")
|
||||
case float32:
|
||||
f := float64(v)
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return "NULL"
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 32)
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return "NULL"
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case json.Number:
|
||||
text := strings.TrimSpace(v.String())
|
||||
if text == "" {
|
||||
return "NULL"
|
||||
}
|
||||
return text
|
||||
default:
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExportJSONValue(val interface{}) interface{} {
|
||||
if val == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := val.(type) {
|
||||
case float32:
|
||||
f := float64(v)
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return nil
|
||||
}
|
||||
return json.Number(strconv.FormatFloat(f, 'f', -1, 32))
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil
|
||||
}
|
||||
return json.Number(strconv.FormatFloat(v, 'f', -1, 64))
|
||||
case json.Number:
|
||||
text := strings.TrimSpace(v.String())
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return json.Number(text)
|
||||
case map[string]interface{}:
|
||||
out := make(map[string]interface{}, len(v))
|
||||
for key, item := range v {
|
||||
out[key] = normalizeExportJSONValue(item)
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
items := make([]interface{}, len(v))
|
||||
for i, item := range v {
|
||||
items[i] = normalizeExportJSONValue(item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(val)
|
||||
switch rv.Kind() {
|
||||
case reflect.Pointer, reflect.Interface:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
return normalizeExportJSONValue(rv.Elem().Interface())
|
||||
case reflect.Map:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, rv.Len())
|
||||
iter := rv.MapRange()
|
||||
for iter.Next() {
|
||||
out[fmt.Sprint(iter.Key().Interface())] = normalizeExportJSONValue(iter.Value().Interface())
|
||||
}
|
||||
return out
|
||||
case reflect.Slice:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
if rv.Type().Elem().Kind() == reflect.Uint8 {
|
||||
return val
|
||||
}
|
||||
fallthrough
|
||||
case reflect.Array:
|
||||
size := rv.Len()
|
||||
items := make([]interface{}, size)
|
||||
for i := 0; i < size; i++ {
|
||||
items[i] = normalizeExportJSONValue(rv.Index(i).Interface())
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// writeRowsToXlsx 使用 excelize 写入真正的 xlsx 格式文件
|
||||
func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []string) error {
|
||||
xlsx := excelize.NewFile()
|
||||
|
||||
205
internal/app/methods_file_export_test.go
Normal file
205
internal/app/methods_file_export_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
type fakeExportQueryDB struct {
|
||||
data []map[string]interface{}
|
||||
cols []string
|
||||
err error
|
||||
|
||||
lastQuery string
|
||||
lastContextTimeout time.Duration
|
||||
hasContextDeadline bool
|
||||
}
|
||||
|
||||
func (f *fakeExportQueryDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||
func (f *fakeExportQueryDB) Close() error { return nil }
|
||||
func (f *fakeExportQueryDB) Ping() error { return nil }
|
||||
func (f *fakeExportQueryDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
f.lastQuery = query
|
||||
return f.data, f.cols, f.err
|
||||
}
|
||||
func (f *fakeExportQueryDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
f.lastQuery = query
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
f.hasContextDeadline = true
|
||||
f.lastContextTimeout = time.Until(deadline)
|
||||
}
|
||||
return f.data, f.cols, f.err
|
||||
}
|
||||
func (f *fakeExportQueryDB) Exec(query string) (int64, error) { return 0, nil }
|
||||
func (f *fakeExportQueryDB) GetDatabases() ([]string, error) { return nil, nil }
|
||||
func (f *fakeExportQueryDB) GetTables(dbName string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeExportQueryDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeExportQueryDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeExportQueryDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeExportQueryDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeExportQueryDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeExportQueryDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestFormatExportCellText_FloatNoScientificNotation(t *testing.T) {
|
||||
got := formatExportCellText(1.445663e+06)
|
||||
if strings.Contains(strings.ToLower(got), "e+") || strings.Contains(strings.ToLower(got), "e-") {
|
||||
t.Fatalf("不应输出科学计数法,got=%q", got)
|
||||
}
|
||||
if got != "1445663" {
|
||||
t.Fatalf("浮点整值导出异常,want=%q got=%q", "1445663", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRowsToFile_Markdown_NumberKeepPlainText(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "gonavi-export-*.md")
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时文件失败: %v", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
data := []map[string]interface{}{
|
||||
{"id": 1.445663e+06},
|
||||
}
|
||||
columns := []string{"id"}
|
||||
|
||||
if err := writeRowsToFile(f, data, columns, "md"); err != nil {
|
||||
t.Fatalf("写入 md 失败: %v", err)
|
||||
}
|
||||
|
||||
contentBytes, err := os.ReadFile(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("读取 md 失败: %v", err)
|
||||
}
|
||||
content := string(contentBytes)
|
||||
if strings.Contains(strings.ToLower(content), "e+") || strings.Contains(strings.ToLower(content), "e-") {
|
||||
t.Fatalf("md 导出包含科学计数法: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "| 1445663 |") {
|
||||
t.Fatalf("md 导出未保留整数字面量,content=%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRowsToFile_JSON_NumberKeepPlainText(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "gonavi-export-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时文件失败: %v", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
data := []map[string]interface{}{
|
||||
{"id": 1.445663e+06},
|
||||
}
|
||||
columns := []string{"id"}
|
||||
|
||||
if err := writeRowsToFile(f, data, columns, "json"); err != nil {
|
||||
t.Fatalf("写入 json 失败: %v", err)
|
||||
}
|
||||
|
||||
contentBytes, err := os.ReadFile(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("读取 json 失败: %v", err)
|
||||
}
|
||||
content := string(contentBytes)
|
||||
if strings.Contains(strings.ToLower(content), "e+") || strings.Contains(strings.ToLower(content), "e-") {
|
||||
t.Fatalf("json 导出包含科学计数法: %s", content)
|
||||
}
|
||||
|
||||
var decoded []map[string]json.Number
|
||||
decoder := json.NewDecoder(bytes.NewReader(contentBytes))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&decoded); err != nil {
|
||||
t.Fatalf("解析导出 json 失败: %v", err)
|
||||
}
|
||||
if len(decoded) != 1 {
|
||||
t.Fatalf("导出行数异常,got=%d", len(decoded))
|
||||
}
|
||||
if decoded[0]["id"].String() != "1445663" {
|
||||
t.Fatalf("json 数值格式异常,want=1445663 got=%s", decoded[0]["id"].String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDataForExport_UsesMinimumTimeout(t *testing.T) {
|
||||
fake := &fakeExportQueryDB{
|
||||
data: []map[string]interface{}{{"v": 1}},
|
||||
cols: []string{"v"},
|
||||
}
|
||||
_, _, err := queryDataForExport(fake, connection.ConnectionConfig{Timeout: 10}, "SELECT 1")
|
||||
if err != nil {
|
||||
t.Fatalf("queryDataForExport 返回错误: %v", err)
|
||||
}
|
||||
if !fake.hasContextDeadline {
|
||||
t.Fatal("queryDataForExport 应设置 context deadline")
|
||||
}
|
||||
if fake.lastQuery != "SELECT 1" {
|
||||
t.Fatalf("queryDataForExport 查询语句异常,want=%q got=%q", "SELECT 1", fake.lastQuery)
|
||||
}
|
||||
lowerBound := minExportQueryTimeout - 5*time.Second
|
||||
upperBound := minExportQueryTimeout + 5*time.Second
|
||||
if fake.lastContextTimeout < lowerBound || fake.lastContextTimeout > upperBound {
|
||||
t.Fatalf("导出最小超时异常,want≈%s got=%s", minExportQueryTimeout, fake.lastContextTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDataForExport_UsesLargerConfiguredTimeout(t *testing.T) {
|
||||
fake := &fakeExportQueryDB{
|
||||
data: []map[string]interface{}{{"v": 1}},
|
||||
cols: []string{"v"},
|
||||
}
|
||||
_, _, err := queryDataForExport(fake, connection.ConnectionConfig{Timeout: 900}, "SELECT 1")
|
||||
if err != nil {
|
||||
t.Fatalf("queryDataForExport 返回错误: %v", err)
|
||||
}
|
||||
if !fake.hasContextDeadline {
|
||||
t.Fatal("queryDataForExport 应设置 context deadline")
|
||||
}
|
||||
expected := 900 * time.Second
|
||||
lowerBound := expected - 5*time.Second
|
||||
upperBound := expected + 5*time.Second
|
||||
if fake.lastContextTimeout < lowerBound || fake.lastContextTimeout > upperBound {
|
||||
t.Fatalf("导出配置超时异常,want≈%s got=%s", expected, fake.lastContextTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExportQueryTimeout_ClickHouseUsesLongerMinimum(t *testing.T) {
|
||||
timeout := getExportQueryTimeout(connection.ConnectionConfig{
|
||||
Type: "clickhouse",
|
||||
Timeout: 30,
|
||||
})
|
||||
if timeout != minClickHouseExportQueryTimeout {
|
||||
t.Fatalf("clickhouse 导出超时下限异常,want=%s got=%s", minClickHouseExportQueryTimeout, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExportQueryTimeout_CustomClickHouseUsesLongerMinimum(t *testing.T) {
|
||||
timeout := getExportQueryTimeout(connection.ConnectionConfig{
|
||||
Type: "custom",
|
||||
Driver: "clickhouse",
|
||||
Timeout: 30,
|
||||
})
|
||||
if timeout != minClickHouseExportQueryTimeout {
|
||||
t.Fatalf("custom clickhouse 导出超时下限异常,want=%s got=%s", minClickHouseExportQueryTimeout, timeout)
|
||||
}
|
||||
}
|
||||
@@ -67,24 +67,27 @@ func getRedisClientCacheKey(config connection.ConnectionConfig) string {
|
||||
}
|
||||
|
||||
func formatRedisConnSummary(config connection.ConnectionConfig) string {
|
||||
timeoutSeconds := config.Timeout
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = 30
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("类型=redis 地址=")
|
||||
b.WriteString(config.Host)
|
||||
b.WriteString(":")
|
||||
b.WriteString(string(rune(config.Port + '0')))
|
||||
b.WriteString(strconv.Itoa(config.Port))
|
||||
if topology := strings.TrimSpace(config.Topology); topology != "" {
|
||||
b.WriteString(" 模式=")
|
||||
b.WriteString(topology)
|
||||
}
|
||||
if len(config.Hosts) > 0 {
|
||||
b.WriteString(" 节点数=")
|
||||
b.WriteString(strconv.Itoa(len(config.Hosts)))
|
||||
}
|
||||
b.WriteString(" DB=")
|
||||
b.WriteString(string(rune(config.RedisDB + '0')))
|
||||
b.WriteString(strconv.Itoa(config.RedisDB))
|
||||
|
||||
if config.UseSSH {
|
||||
b.WriteString(" SSH=")
|
||||
b.WriteString(config.SSH.Host)
|
||||
b.WriteString(":")
|
||||
b.WriteString(string(rune(config.SSH.Port + '0')))
|
||||
b.WriteString(strconv.Itoa(config.SSH.Port))
|
||||
b.WriteString(" 用户=")
|
||||
b.WriteString(config.SSH.User)
|
||||
}
|
||||
|
||||
@@ -233,6 +233,49 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) OpenDownloadedUpdateDirectory() connection.QueryResult {
|
||||
a.updateMu.Lock()
|
||||
staged := a.updateState.staged
|
||||
a.updateMu.Unlock()
|
||||
if staged == nil {
|
||||
return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"}
|
||||
}
|
||||
assetPath := strings.TrimSpace(staged.FilePath)
|
||||
if assetPath == "" {
|
||||
return connection.QueryResult{Success: false, Message: "更新包路径为空"}
|
||||
}
|
||||
dirPath := strings.TrimSpace(filepath.Dir(assetPath))
|
||||
if dirPath == "" || dirPath == "." {
|
||||
return connection.QueryResult{Success: false, Message: "无法解析更新目录"}
|
||||
}
|
||||
if stat, err := os.Stat(dirPath); err != nil || !stat.IsDir() {
|
||||
return connection.QueryResult{Success: false, Message: "更新目录不存在或不可访问"}
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch stdRuntime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", dirPath)
|
||||
case "windows":
|
||||
cmd = exec.Command("explorer", dirPath)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", dirPath)
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前平台暂不支持打开目录:%s", stdRuntime.GOOS)}
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
logger.Error(err, "打开更新目录失败")
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("打开更新目录失败:%v", err)}
|
||||
}
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("已打开安装目录:%s", dirPath),
|
||||
Data: map[string]any{
|
||||
"path": dirPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
|
||||
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir(info.LatestVersion))
|
||||
if workspaceDir == "" {
|
||||
|
||||
@@ -37,7 +37,7 @@ type ConnectionConfig struct {
|
||||
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
|
||||
Topology string `json:"topology,omitempty"` // single | replica | cluster
|
||||
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
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
defaultClickHousePort = 9000
|
||||
defaultClickHouseUser = "default"
|
||||
defaultClickHouseDatabase = "default"
|
||||
minClickHouseReadTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
type ClickHouseDB struct {
|
||||
@@ -101,7 +102,11 @@ func applyClickHouseURI(config connection.ConnectionConfig) connection.Connectio
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig) *clickhouse.Options {
|
||||
timeout := getConnectTimeout(config)
|
||||
connectTimeout := getConnectTimeout(config)
|
||||
readTimeout := connectTimeout
|
||||
if readTimeout < minClickHouseReadTimeout {
|
||||
readTimeout = minClickHouseReadTimeout
|
||||
}
|
||||
return &clickhouse.Options{
|
||||
Addr: []string{
|
||||
net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
||||
@@ -111,8 +116,8 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig
|
||||
Username: strings.TrimSpace(config.User),
|
||||
Password: config.Password,
|
||||
},
|
||||
DialTimeout: timeout,
|
||||
ReadTimeout: timeout,
|
||||
DialTimeout: connectTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestClickHouseOptions_UsesStructuredTimeoutAndAuth(t *testing.T) {
|
||||
if opts.DialTimeout != 15*time.Second {
|
||||
t.Fatalf("dial timeout 不符合预期:%s", opts.DialTimeout)
|
||||
}
|
||||
if opts.ReadTimeout != 15*time.Second {
|
||||
if opts.ReadTimeout != minClickHouseReadTimeout {
|
||||
t.Fatalf("read timeout 不符合预期:%s", opts.ReadTimeout)
|
||||
}
|
||||
if _, ok := opts.Settings["write_timeout"]; ok {
|
||||
@@ -160,3 +160,27 @@ func TestClickHouseOptions_UsesStructuredTimeoutAndAuth(t *testing.T) {
|
||||
t.Fatalf("options 不应通过 settings 传递 dial_timeout:%v", opts.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClickHouseOptions_ReadTimeoutUsesLargerConfiguredTimeout(t *testing.T) {
|
||||
c := &ClickHouseDB{}
|
||||
cfg := normalizeClickHouseConfig(connection.ConnectionConfig{
|
||||
Type: "clickhouse",
|
||||
Host: "127.0.0.1",
|
||||
Port: 9000,
|
||||
User: "default",
|
||||
Password: "secret",
|
||||
Database: "analytics",
|
||||
Timeout: 900,
|
||||
})
|
||||
|
||||
opts := c.buildClickHouseOptions(cfg)
|
||||
if opts == nil {
|
||||
t.Fatal("options 为空")
|
||||
}
|
||||
if opts.DialTimeout != 900*time.Second {
|
||||
t.Fatalf("dial timeout 不符合预期:%s", opts.DialTimeout)
|
||||
}
|
||||
if opts.ReadTimeout != 900*time.Second {
|
||||
t.Fatalf("read timeout 不符合预期:%s", opts.ReadTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
53
internal/db/json_decode.go
Normal file
53
internal/db/json_decode.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func decodeJSONWithUseNumber(data []byte, out interface{}) error {
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeDecodedJSONNumbers(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDecodedJSONNumbers(out interface{}) {
|
||||
switch typed := out.(type) {
|
||||
case *[]map[string]interface{}:
|
||||
if typed == nil {
|
||||
return
|
||||
}
|
||||
for i := range *typed {
|
||||
row := (*typed)[i]
|
||||
for key, value := range row {
|
||||
row[key] = normalizeQueryValue(value)
|
||||
}
|
||||
}
|
||||
case *map[string]interface{}:
|
||||
if typed == nil || *typed == nil {
|
||||
return
|
||||
}
|
||||
for key, value := range *typed {
|
||||
(*typed)[key] = normalizeQueryValue(value)
|
||||
}
|
||||
case *[]interface{}:
|
||||
if typed == nil {
|
||||
return
|
||||
}
|
||||
for i, item := range *typed {
|
||||
(*typed)[i] = normalizeQueryValue(item)
|
||||
}
|
||||
case *interface{}:
|
||||
if typed == nil {
|
||||
return
|
||||
}
|
||||
*typed = normalizeQueryValue(*typed)
|
||||
}
|
||||
}
|
||||
58
internal/db/json_decode_test.go
Normal file
58
internal/db/json_decode_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecodeJSONWithUseNumber_QueryRowsPreserveUnsafeInteger(t *testing.T) {
|
||||
raw := []byte(`[{"id":9007199254740993,"safe":123,"nested":{"n":9007199254740992},"arr":[9007199254740992,1],"decimal":1.25}]`)
|
||||
var out []map[string]interface{}
|
||||
|
||||
if err := decodeJSONWithUseNumber(raw, &out); err != nil {
|
||||
t.Fatalf("解码失败: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("期望 1 行,实际 %d", len(out))
|
||||
}
|
||||
|
||||
row := out[0]
|
||||
if got, ok := row["id"].(string); !ok || got != "9007199254740993" {
|
||||
t.Fatalf("id 应为 string 且保持精度,实际=%v(%T)", row["id"], row["id"])
|
||||
}
|
||||
if got, ok := row["safe"].(int64); !ok || got != 123 {
|
||||
t.Fatalf("safe 应为 int64(123),实际=%v(%T)", row["safe"], row["safe"])
|
||||
}
|
||||
nested, ok := row["nested"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("nested 类型异常:%T", row["nested"])
|
||||
}
|
||||
if got, ok := nested["n"].(string); !ok || got != "9007199254740992" {
|
||||
t.Fatalf("nested.n 应为 string 且保持精度,实际=%v(%T)", nested["n"], nested["n"])
|
||||
}
|
||||
arr, ok := row["arr"].([]interface{})
|
||||
if !ok || len(arr) != 2 {
|
||||
t.Fatalf("arr 类型异常:%v(%T)", row["arr"], row["arr"])
|
||||
}
|
||||
if got, ok := arr[0].(string); !ok || got != "9007199254740992" {
|
||||
t.Fatalf("arr[0] 应为 string 且保持精度,实际=%v(%T)", arr[0], arr[0])
|
||||
}
|
||||
if got, ok := arr[1].(int64); !ok || got != 1 {
|
||||
t.Fatalf("arr[1] 应为 int64(1),实际=%v(%T)", arr[1], arr[1])
|
||||
}
|
||||
if got, ok := row["decimal"].(float64); !ok || got != 1.25 {
|
||||
t.Fatalf("decimal 应为 float64(1.25),实际=%v(%T)", row["decimal"], row["decimal"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONWithUseNumber_TypedStruct(t *testing.T) {
|
||||
type item struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var out []item
|
||||
if err := decodeJSONWithUseNumber([]byte(`[{"id":7,"name":"ok"}]`), &out); err != nil {
|
||||
t.Fatalf("解码失败: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].ID != 7 || out[0].Name != "ok" {
|
||||
t.Fatalf("结构体解码结果异常:%+v", out)
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func (c *mysqlAgentClient) call(req mysqlAgentRequest, out interface{}, fields *
|
||||
*rowsAffected = resp.RowsAffected
|
||||
}
|
||||
if out != nil && len(resp.Data) > 0 {
|
||||
if err := json.Unmarshal(resp.Data, out); err != nil {
|
||||
if err := decodeJSONWithUseNumber(resp.Data, out); err != nil {
|
||||
return fmt.Errorf("解析 MySQL 驱动代理数据失败:%w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -38,6 +40,7 @@ type optionalAgentRequest struct {
|
||||
Method string `json:"method"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TimeoutMs int64 `json:"timeoutMs,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName,omitempty"`
|
||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||
@@ -176,7 +179,7 @@ func (c *optionalDriverAgentClient) call(req optionalAgentRequest, out interface
|
||||
*rowsAffected = resp.RowsAffected
|
||||
}
|
||||
if out != nil && len(resp.Data) > 0 {
|
||||
if err := json.Unmarshal(resp.Data, out); err != nil {
|
||||
if err := decodeJSONWithUseNumber(resp.Data, out); err != nil {
|
||||
return fmt.Errorf("解析 %s 驱动代理数据失败:%w", driverDisplayName(c.driver), err)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +226,7 @@ func (d *OptionalDriverAgentDB) Connect(config connection.ConnectionConfig) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Infof("%s 驱动代理路径:%s", driverDisplayName(d.driverType), executablePath)
|
||||
client, err := newOptionalDriverAgentClient(d.driverType, executablePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -260,7 +264,20 @@ func (d *OptionalDriverAgentDB) QueryContext(ctx context.Context, query string)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return d.Query(query)
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var data []map[string]interface{}
|
||||
var fields []string
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodQuery,
|
||||
Query: query,
|
||||
TimeoutMs: timeoutMsFromContext(ctx),
|
||||
}, &data, &fields, nil); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, fields, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
@@ -283,7 +300,19 @@ func (d *OptionalDriverAgentDB) ExecContext(ctx context.Context, query string) (
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return d.Exec(query)
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var affected int64
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodExec,
|
||||
Query: query,
|
||||
TimeoutMs: timeoutMsFromContext(ctx),
|
||||
}, nil, nil, &affected); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) Exec(query string) (int64, error) {
|
||||
@@ -443,3 +472,15 @@ func (d *OptionalDriverAgentDB) requireClient() (*optionalDriverAgentClient, err
|
||||
}
|
||||
return d.client, nil
|
||||
}
|
||||
|
||||
func timeoutMsFromContext(ctx context.Context) int64 {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
remaining := time.Until(deadline).Milliseconds()
|
||||
if remaining <= 0 {
|
||||
return 1
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
32
internal/db/optional_driver_agent_impl_test.go
Normal file
32
internal/db/optional_driver_agent_impl_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimeoutMsFromContext_NoDeadline(t *testing.T) {
|
||||
if got := timeoutMsFromContext(context.Background()); got != 0 {
|
||||
t.Fatalf("无 deadline 时应返回 0,got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutMsFromContext_WithDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
got := timeoutMsFromContext(ctx)
|
||||
if got <= 0 {
|
||||
t.Fatalf("有 deadline 时应返回正值,got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutMsFromContext_ExpiredDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
|
||||
if got := timeoutMsFromContext(ctx); got != 1 {
|
||||
t.Fatalf("过期 deadline 应返回 1,got=%d", got)
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,7 @@ type OracleDB struct {
|
||||
|
||||
func (o *OracleDB) getDSN(config connection.ConnectionConfig) string {
|
||||
// oracle://user:pass@host:port/service_name
|
||||
database := config.Database
|
||||
if database == "" {
|
||||
database = config.User // Default to user service/schema if empty?
|
||||
}
|
||||
database := strings.TrimSpace(config.Database)
|
||||
|
||||
u := &url.URL{
|
||||
Scheme: "oracle",
|
||||
@@ -44,6 +41,10 @@ func (o *OracleDB) getDSN(config connection.ConnectionConfig) string {
|
||||
func (o *OracleDB) Connect(config connection.ConnectionConfig) error {
|
||||
var dsn string
|
||||
var err error
|
||||
serviceName := strings.TrimSpace(config.Database)
|
||||
if serviceName == "" {
|
||||
return fmt.Errorf("Oracle 连接缺少服务名(Service Name),请在连接配置中填写,例如 ORCLPDB1")
|
||||
}
|
||||
|
||||
if config.UseSSH {
|
||||
// Create SSH tunnel with local port forwarding
|
||||
|
||||
@@ -2,12 +2,27 @@ package db
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
jsMaxSafeInteger int64 = 9007199254740991
|
||||
jsMinSafeInteger int64 = -9007199254740991
|
||||
jsMaxSafeUint uint64 = 9007199254740991
|
||||
)
|
||||
|
||||
var (
|
||||
jsMaxSafeBigInt = big.NewInt(jsMaxSafeInteger)
|
||||
jsMinSafeBigInt = big.NewInt(jsMinSafeInteger)
|
||||
)
|
||||
|
||||
// normalizeQueryValue normalizes driver-returned values for UI/JSON transport.
|
||||
// 当前主要处理 []byte:如果是可读文本则转为 string,否则转为十六进制字符串,避免前端出现“空白值”。
|
||||
func normalizeQueryValue(v interface{}) interface{} {
|
||||
@@ -18,7 +33,114 @@ func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) inter
|
||||
if b, ok := v.([]byte); ok {
|
||||
return bytesToDisplayValue(b, databaseTypeName)
|
||||
}
|
||||
return v
|
||||
return normalizeCompositeQueryValue(v)
|
||||
}
|
||||
|
||||
func normalizeCompositeQueryValue(v interface{}) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch typed := v.(type) {
|
||||
case []interface{}:
|
||||
items := make([]interface{}, len(typed))
|
||||
for i, item := range typed {
|
||||
items[i] = normalizeQueryValue(item)
|
||||
}
|
||||
return items
|
||||
case map[string]interface{}:
|
||||
out := make(map[string]interface{}, len(typed))
|
||||
for key, value := range typed {
|
||||
out[key] = normalizeQueryValue(value)
|
||||
}
|
||||
return out
|
||||
case json.Number:
|
||||
return normalizeJSONNumberForJS(typed)
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Pointer:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
return normalizeQueryValue(rv.Elem().Interface())
|
||||
case reflect.Map:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, rv.Len())
|
||||
iter := rv.MapRange()
|
||||
for iter.Next() {
|
||||
out[mapKeyToString(iter.Key().Interface())] = normalizeQueryValue(iter.Value().Interface())
|
||||
}
|
||||
return out
|
||||
case reflect.Slice, reflect.Array:
|
||||
// []byte 在上层已单独处理,这里保留对其它切片/数组的递归规整。
|
||||
if rv.Kind() == reflect.Slice && rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
size := rv.Len()
|
||||
items := make([]interface{}, size)
|
||||
for i := 0; i < size; i++ {
|
||||
items[i] = normalizeQueryValue(rv.Index(i).Interface())
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return normalizeUnsafeIntegerForJS(rv, v)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeJSONNumberForJS(n json.Number) interface{} {
|
||||
text := strings.TrimSpace(n.String())
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if integer, ok := parseJSONInteger(text); ok {
|
||||
if integer.Cmp(jsMaxSafeBigInt) > 0 || integer.Cmp(jsMinSafeBigInt) < 0 {
|
||||
return text
|
||||
}
|
||||
return integer.Int64()
|
||||
}
|
||||
|
||||
if f, err := n.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func parseJSONInteger(text string) (*big.Int, bool) {
|
||||
if text == "" {
|
||||
return nil, false
|
||||
}
|
||||
start := 0
|
||||
if text[0] == '+' || text[0] == '-' {
|
||||
if len(text) == 1 {
|
||||
return nil, false
|
||||
}
|
||||
start = 1
|
||||
}
|
||||
for i := start; i < len(text); i++ {
|
||||
if text[i] < '0' || text[i] > '9' {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
value, ok := new(big.Int).SetString(text, 10)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func mapKeyToString(key interface{}) string {
|
||||
if key == nil {
|
||||
return "null"
|
||||
}
|
||||
if s, ok := key.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", key)
|
||||
}
|
||||
|
||||
func bytesToDisplayValue(b []byte, databaseTypeName string) interface{} {
|
||||
@@ -33,8 +155,7 @@ func bytesToDisplayValue(b []byte, databaseTypeName string) interface{} {
|
||||
if isBitLikeDBType(dbType) {
|
||||
if u, ok := bytesToUint64(b); ok {
|
||||
// JS number precision is limited; keep large bitmasks as string.
|
||||
const maxSafeInteger = 9007199254740991 // 2^53 - 1
|
||||
if u <= maxSafeInteger {
|
||||
if u <= jsMaxSafeUint {
|
||||
return int64(u)
|
||||
}
|
||||
return fmt.Sprintf("%d", u)
|
||||
@@ -89,6 +210,25 @@ func bytesToUint64(b []byte) (uint64, bool) {
|
||||
return u, true
|
||||
}
|
||||
|
||||
func normalizeUnsafeIntegerForJS(rv reflect.Value, original interface{}) interface{} {
|
||||
switch rv.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
n := rv.Int()
|
||||
if n > jsMaxSafeInteger || n < jsMinSafeInteger {
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
return original
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
u := rv.Uint()
|
||||
if u > jsMaxSafeUint {
|
||||
return strconv.FormatUint(u, 10)
|
||||
}
|
||||
return original
|
||||
default:
|
||||
return original
|
||||
}
|
||||
}
|
||||
|
||||
func isMostlyPrintable(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type duckMapLike map[any]any
|
||||
|
||||
func TestNormalizeQueryValueWithDBType_BitBytes(t *testing.T) {
|
||||
v := normalizeQueryValueWithDBType([]byte{0x00}, "BIT")
|
||||
@@ -42,3 +47,121 @@ func TestNormalizeQueryValueWithDBType_ByteFallbacks(t *testing.T) {
|
||||
t.Fatalf("未知类型 0xff 期望返回 0xff,实际=%v(%T)", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeQueryValueWithDBType_MapAnyAnyForJSON(t *testing.T) {
|
||||
input := duckMapLike{
|
||||
"id": int64(1),
|
||||
1: "one",
|
||||
true: []interface{}{duckMapLike{2: "two"}},
|
||||
"bytes": []byte("ok"),
|
||||
}
|
||||
|
||||
v := normalizeQueryValueWithDBType(input, "")
|
||||
root, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("期望转换为 map[string]interface{},实际=%T", v)
|
||||
}
|
||||
|
||||
if root["id"] != int64(1) {
|
||||
t.Fatalf("id 字段异常,实际=%v(%T)", root["id"], root["id"])
|
||||
}
|
||||
if root["1"] != "one" {
|
||||
t.Fatalf("数字 key 未被字符串化,实际=%v(%T)", root["1"], root["1"])
|
||||
}
|
||||
if root["bytes"] != "ok" {
|
||||
t.Fatalf("嵌套 []byte 未被转换,实际=%v(%T)", root["bytes"], root["bytes"])
|
||||
}
|
||||
|
||||
arr, ok := root["true"].([]interface{})
|
||||
if !ok || len(arr) != 1 {
|
||||
t.Fatalf("bool key 下的数组结构异常,实际=%v(%T)", root["true"], root["true"])
|
||||
}
|
||||
nested, ok := arr[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("嵌套 map 未被转换,实际=%v(%T)", arr[0], arr[0])
|
||||
}
|
||||
if nested["2"] != "two" {
|
||||
t.Fatalf("嵌套 map 数字 key 未转换,实际=%v(%T)", nested["2"], nested["2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeQueryValueWithDBType_UnsafeIntegersAsString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "int64 overflow", input: int64(9007199254740992), want: "9007199254740992"},
|
||||
{name: "int64 underflow", input: int64(-9007199254740992), want: "-9007199254740992"},
|
||||
{name: "uint64 overflow", input: uint64(9007199254740992), want: "9007199254740992"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := normalizeQueryValueWithDBType(tc.input, "")
|
||||
if got != tc.want {
|
||||
t.Fatalf("期望=%q,实际=%v(%T)", tc.want, got, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeQueryValueWithDBType_SafeIntegersKeepType(t *testing.T) {
|
||||
got := normalizeQueryValueWithDBType(int64(9007199254740991), "")
|
||||
if _, ok := got.(int64); !ok {
|
||||
t.Fatalf("安全范围 int64 应保持数字类型,实际=%v(%T)", got, got)
|
||||
}
|
||||
|
||||
got = normalizeQueryValueWithDBType(uint64(9007199254740991), "")
|
||||
if _, ok := got.(uint64); !ok {
|
||||
t.Fatalf("安全范围 uint64 应保持数字类型,实际=%v(%T)", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeQueryValueWithDBType_JSONNumber(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input json.Number
|
||||
wantType string
|
||||
wantValue string
|
||||
}{
|
||||
{name: "safe integer", input: json.Number("9007199254740991"), wantType: "int64", wantValue: "9007199254740991"},
|
||||
{name: "unsafe integer", input: json.Number("9007199254740992"), wantType: "string", wantValue: "9007199254740992"},
|
||||
{name: "unsafe negative integer", input: json.Number("-9007199254740992"), wantType: "string", wantValue: "-9007199254740992"},
|
||||
{name: "decimal", input: json.Number("12.5"), wantType: "float64", wantValue: "12.5"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := normalizeQueryValueWithDBType(tc.input, "")
|
||||
switch tc.wantType {
|
||||
case "int64":
|
||||
v, ok := got.(int64)
|
||||
if !ok {
|
||||
t.Fatalf("期望 int64,实际=%T", got)
|
||||
}
|
||||
if v != 9007199254740991 {
|
||||
t.Fatalf("期望值=%s,实际=%d", tc.wantValue, v)
|
||||
}
|
||||
case "string":
|
||||
v, ok := got.(string)
|
||||
if !ok {
|
||||
t.Fatalf("期望 string,实际=%T", got)
|
||||
}
|
||||
if v != tc.wantValue {
|
||||
t.Fatalf("期望值=%s,实际=%s", tc.wantValue, v)
|
||||
}
|
||||
case "float64":
|
||||
v, ok := got.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("期望 float64,实际=%T", got)
|
||||
}
|
||||
if v != 12.5 {
|
||||
t.Fatalf("期望值=%s,实际=%v", tc.wantValue, v)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("未知断言类型:%s", tc.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type RedisValue struct {
|
||||
|
||||
// RedisDBInfo represents information about a Redis database
|
||||
type RedisDBInfo struct {
|
||||
Index int `json:"index"` // Database index (0-15)
|
||||
Index int `json:"index"` // Database index (single: 0-15, cluster: logical 0-15)
|
||||
Keys int64 `json:"keys"` // Number of keys in this database
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ package redis
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -16,10 +18,14 @@ import (
|
||||
|
||||
// RedisClientImpl implements RedisClient using go-redis
|
||||
type RedisClientImpl struct {
|
||||
client *redis.Client
|
||||
config connection.ConnectionConfig
|
||||
currentDB int
|
||||
forwarder *ssh.LocalForwarder
|
||||
client redis.UniversalClient
|
||||
singleClient *redis.Client
|
||||
clusterClient *redis.ClusterClient
|
||||
config connection.ConnectionConfig
|
||||
currentDB int
|
||||
isCluster bool
|
||||
seedAddrs []string
|
||||
forwarder *ssh.LocalForwarder
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -40,14 +46,183 @@ func NewRedisClient() RedisClient {
|
||||
return &RedisClientImpl{}
|
||||
}
|
||||
|
||||
func normalizeRedisTimeout(timeoutSeconds int) time.Duration {
|
||||
if timeoutSeconds <= 0 {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return time.Duration(timeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
func normalizeRedisSeedAddress(raw string, defaultPort int) (string, error) {
|
||||
addr := strings.TrimSpace(raw)
|
||||
if addr == "" {
|
||||
return "", fmt.Errorf("Redis 节点地址不能为空")
|
||||
}
|
||||
|
||||
if _, _, err := net.SplitHostPort(addr); err == nil {
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
if !strings.Contains(addr, ":") {
|
||||
return net.JoinHostPort(addr, strconv.Itoa(defaultPort)), nil
|
||||
}
|
||||
|
||||
// 尝试兼容 host:port 但端口格式异常的场景。
|
||||
host, port, ok := strings.Cut(addr, ":")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("无效 Redis 节点地址: %s", addr)
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
port = strings.TrimSpace(port)
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("无效 Redis 节点地址: %s", addr)
|
||||
}
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
return "", fmt.Errorf("无效 Redis 端口: %s", addr)
|
||||
}
|
||||
return net.JoinHostPort(host, port), nil
|
||||
}
|
||||
|
||||
func buildRedisSeedAddrs(config connection.ConnectionConfig) ([]string, error) {
|
||||
defaultPort := config.Port
|
||||
if defaultPort <= 0 {
|
||||
defaultPort = 6379
|
||||
}
|
||||
|
||||
candidates := make([]string, 0, 1+len(config.Hosts))
|
||||
if strings.TrimSpace(config.Host) != "" {
|
||||
candidates = append(candidates, fmt.Sprintf("%s:%d", strings.TrimSpace(config.Host), defaultPort))
|
||||
}
|
||||
candidates = append(candidates, config.Hosts...)
|
||||
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
addrs := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
normalized, err := normalizeRedisSeedAddress(candidate, defaultPort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := seen[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
addrs = append(addrs, normalized)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("Redis 连接地址不能为空")
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) redisNamespacePrefixForDB(index int) string {
|
||||
if !r.isCluster || index <= 0 {
|
||||
return ""
|
||||
}
|
||||
// Redis Cluster 仅支持物理 db0;这里用固定前缀模拟逻辑库隔离。
|
||||
return fmt.Sprintf("__gonavi_db_%d__:", index)
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) redisNamespacePrefix() string {
|
||||
return r.redisNamespacePrefixForDB(r.currentDB)
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) toPhysicalKey(key string) string {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
prefix := r.redisNamespacePrefix()
|
||||
if prefix == "" || strings.HasPrefix(trimmed, prefix) {
|
||||
return trimmed
|
||||
}
|
||||
return prefix + trimmed
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) toPhysicalPattern(pattern string) string {
|
||||
normalized := strings.TrimSpace(pattern)
|
||||
if normalized == "" {
|
||||
normalized = "*"
|
||||
}
|
||||
prefix := r.redisNamespacePrefix()
|
||||
if prefix == "" {
|
||||
return normalized
|
||||
}
|
||||
return prefix + normalized
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) toPhysicalKeys(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
physical := r.toPhysicalKey(key)
|
||||
if physical == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, physical)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) toDisplayKey(key string) string {
|
||||
prefix := r.redisNamespacePrefix()
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
return strings.TrimPrefix(key, prefix)
|
||||
}
|
||||
|
||||
// Connect establishes a connection to Redis
|
||||
func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error {
|
||||
r.config = config
|
||||
r.currentDB = config.RedisDB
|
||||
if r.config.RedisDB < 0 || r.config.RedisDB > 15 {
|
||||
r.config.RedisDB = 0
|
||||
}
|
||||
r.currentDB = r.config.RedisDB
|
||||
r.forwarder = nil
|
||||
r.client = nil
|
||||
r.singleClient = nil
|
||||
r.clusterClient = nil
|
||||
r.isCluster = false
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
seedAddrs, err := buildRedisSeedAddrs(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.seedAddrs = append([]string(nil), seedAddrs...)
|
||||
|
||||
// Handle SSH tunnel if enabled
|
||||
topology := strings.ToLower(strings.TrimSpace(config.Topology))
|
||||
r.isCluster = topology == "cluster" || len(seedAddrs) > 1
|
||||
|
||||
if r.isCluster && config.UseSSH {
|
||||
return fmt.Errorf("Redis 集群模式暂不支持 SSH 隧道,请关闭 SSH 后重试")
|
||||
}
|
||||
|
||||
timeout := normalizeRedisTimeout(config.Timeout)
|
||||
if r.isCluster {
|
||||
opts := &redis.ClusterOptions{
|
||||
Addrs: seedAddrs,
|
||||
Username: strings.TrimSpace(config.User),
|
||||
Password: config.Password,
|
||||
DialTimeout: timeout,
|
||||
ReadTimeout: timeout,
|
||||
WriteTimeout: timeout,
|
||||
}
|
||||
clusterClient := redis.NewClusterClient(opts)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := clusterClient.Ping(ctx).Err(); err != nil {
|
||||
clusterClient.Close()
|
||||
return fmt.Errorf("Redis 集群连接失败: %w", err)
|
||||
}
|
||||
r.client = clusterClient
|
||||
r.clusterClient = clusterClient
|
||||
logger.Infof("Redis 集群连接成功: seeds=%s 逻辑库=db%d", strings.Join(seedAddrs, ","), r.currentDB)
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := seedAddrs[0]
|
||||
if config.UseSSH {
|
||||
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
|
||||
if err != nil {
|
||||
@@ -60,32 +235,26 @@ func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error {
|
||||
|
||||
opts := &redis.Options{
|
||||
Addr: addr,
|
||||
Username: strings.TrimSpace(config.User),
|
||||
Password: config.Password,
|
||||
DB: config.RedisDB,
|
||||
DialTimeout: time.Duration(config.Timeout) * time.Second,
|
||||
ReadTimeout: time.Duration(config.Timeout) * time.Second,
|
||||
WriteTimeout: time.Duration(config.Timeout) * time.Second,
|
||||
DB: r.currentDB,
|
||||
DialTimeout: timeout,
|
||||
ReadTimeout: timeout,
|
||||
WriteTimeout: timeout,
|
||||
}
|
||||
|
||||
if opts.DialTimeout == 0 {
|
||||
opts.DialTimeout = 30 * time.Second
|
||||
opts.ReadTimeout = 30 * time.Second
|
||||
opts.WriteTimeout = 30 * time.Second
|
||||
}
|
||||
|
||||
r.client = redis.NewClient(opts)
|
||||
|
||||
// Test connection
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opts.DialTimeout)
|
||||
singleClient := redis.NewClient(opts)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
if err := r.client.Ping(ctx).Err(); err != nil {
|
||||
r.client.Close()
|
||||
r.client = nil
|
||||
if err := singleClient.Ping(ctx).Err(); err != nil {
|
||||
singleClient.Close()
|
||||
return fmt.Errorf("Redis 连接失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("Redis 连接成功: %s DB=%d", addr, config.RedisDB)
|
||||
r.client = singleClient
|
||||
r.singleClient = singleClient
|
||||
logger.Infof("Redis 连接成功: %s DB=%d", addr, r.currentDB)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,6 +263,11 @@ func (r *RedisClientImpl) Close() error {
|
||||
if r.client != nil {
|
||||
err := r.client.Close()
|
||||
r.client = nil
|
||||
r.singleClient = nil
|
||||
r.clusterClient = nil
|
||||
r.isCluster = false
|
||||
r.seedAddrs = nil
|
||||
r.forwarder = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -118,6 +292,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
physicalPattern := r.toPhysicalPattern(pattern)
|
||||
|
||||
isSearchPattern := pattern != "*"
|
||||
targetCount := normalizeRedisScanTargetCount(count)
|
||||
@@ -150,7 +325,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
|
||||
break
|
||||
}
|
||||
|
||||
batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result()
|
||||
batch, nextCursor, err := r.client.Scan(ctx, currentCursor, physicalPattern, scanStepCount).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -226,7 +401,7 @@ func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string)
|
||||
ttlValue = -2
|
||||
}
|
||||
result = append(result, RedisKeyInfo{
|
||||
Key: key,
|
||||
Key: r.toDisplayKey(key),
|
||||
Type: keyType,
|
||||
TTL: toRedisTTLSeconds(ttlValue),
|
||||
})
|
||||
@@ -236,7 +411,7 @@ func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string)
|
||||
|
||||
for i, key := range keys {
|
||||
result = append(result, RedisKeyInfo{
|
||||
Key: key,
|
||||
Key: r.toDisplayKey(key),
|
||||
Type: typeResults[i].Val(),
|
||||
TTL: toRedisTTLSeconds(ttlResults[i].Val()),
|
||||
})
|
||||
@@ -261,7 +436,7 @@ func (r *RedisClientImpl) GetKeyType(key string) (string, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return r.client.Type(ctx, key).Result()
|
||||
return r.client.Type(ctx, r.toPhysicalKey(key)).Result()
|
||||
}
|
||||
|
||||
// GetTTL returns the TTL of a key in seconds
|
||||
@@ -272,7 +447,7 @@ func (r *RedisClientImpl) GetTTL(key string) (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ttl, err := r.client.TTL(ctx, key).Result()
|
||||
ttl, err := r.client.TTL(ctx, r.toPhysicalKey(key)).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -295,9 +470,9 @@ func (r *RedisClientImpl) SetTTL(key string, ttl int64) error {
|
||||
|
||||
if ttl < 0 {
|
||||
// Remove expiry
|
||||
return r.client.Persist(ctx, key).Err()
|
||||
return r.client.Persist(ctx, r.toPhysicalKey(key)).Err()
|
||||
}
|
||||
return r.client.Expire(ctx, key, time.Duration(ttl)*time.Second).Err()
|
||||
return r.client.Expire(ctx, r.toPhysicalKey(key), time.Duration(ttl)*time.Second).Err()
|
||||
}
|
||||
|
||||
// DeleteKeys deletes one or more keys
|
||||
@@ -307,7 +482,11 @@ func (r *RedisClientImpl) DeleteKeys(keys []string) (int64, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return r.client.Del(ctx, keys...).Result()
|
||||
physicalKeys := r.toPhysicalKeys(keys)
|
||||
if len(physicalKeys) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return r.client.Del(ctx, physicalKeys...).Result()
|
||||
}
|
||||
|
||||
// RenameKey renames a key
|
||||
@@ -317,7 +496,7 @@ func (r *RedisClientImpl) RenameKey(oldKey, newKey string) error {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return r.client.Rename(ctx, oldKey, newKey).Err()
|
||||
return r.client.Rename(ctx, r.toPhysicalKey(oldKey), r.toPhysicalKey(newKey)).Err()
|
||||
}
|
||||
|
||||
// KeyExists checks if a key exists
|
||||
@@ -327,7 +506,7 @@ func (r *RedisClientImpl) KeyExists(key string) (bool, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
n, err := r.client.Exists(ctx, key).Result()
|
||||
n, err := r.client.Exists(ctx, r.toPhysicalKey(key)).Result()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
@@ -343,6 +522,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
}
|
||||
|
||||
ttl, _ := r.GetTTL(key)
|
||||
physicalKey := r.toPhysicalKey(key)
|
||||
|
||||
result := &RedisValue{
|
||||
Type: keyType,
|
||||
@@ -354,7 +534,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
|
||||
switch keyType {
|
||||
case "string":
|
||||
val, err := r.client.Get(ctx, key).Result()
|
||||
val, err := r.client.Get(ctx, physicalKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -362,7 +542,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
result.Length = int64(len(val))
|
||||
|
||||
case "hash":
|
||||
val, err := r.client.HGetAll(ctx, key).Result()
|
||||
val, err := r.client.HGetAll(ctx, physicalKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -370,7 +550,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
result.Length = int64(len(val))
|
||||
|
||||
case "list":
|
||||
length, err := r.client.LLen(ctx, key).Result()
|
||||
length, err := r.client.LLen(ctx, physicalKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -379,7 +559,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
if length < limit {
|
||||
limit = length
|
||||
}
|
||||
val, err := r.client.LRange(ctx, key, 0, limit-1).Result()
|
||||
val, err := r.client.LRange(ctx, physicalKey, 0, limit-1).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -387,12 +567,12 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
result.Length = length
|
||||
|
||||
case "set":
|
||||
length, err := r.client.SCard(ctx, key).Result()
|
||||
length, err := r.client.SCard(ctx, physicalKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Get members using SMembers (limited by Redis server)
|
||||
members, err := r.client.SMembers(ctx, key).Result()
|
||||
members, err := r.client.SMembers(ctx, physicalKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -400,7 +580,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
result.Length = length
|
||||
|
||||
case "zset":
|
||||
length, err := r.client.ZCard(ctx, key).Result()
|
||||
length, err := r.client.ZCard(ctx, physicalKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -409,7 +589,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
if length < limit {
|
||||
limit = length
|
||||
}
|
||||
val, err := r.client.ZRangeWithScores(ctx, key, 0, limit-1).Result()
|
||||
val, err := r.client.ZRangeWithScores(ctx, physicalKey, 0, limit-1).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -424,7 +604,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
result.Length = length
|
||||
|
||||
case "stream":
|
||||
length, err := r.client.XLen(ctx, key).Result()
|
||||
length, err := r.client.XLen(ctx, physicalKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -437,7 +617,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
|
||||
if length < limit {
|
||||
limit = length
|
||||
}
|
||||
val, err := r.client.XRangeN(ctx, key, "-", "+", limit).Result()
|
||||
val, err := r.client.XRangeN(ctx, physicalKey, "-", "+", limit).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -457,7 +637,7 @@ func (r *RedisClientImpl) GetString(key string) (string, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return r.client.Get(ctx, key).Result()
|
||||
return r.client.Get(ctx, r.toPhysicalKey(key)).Result()
|
||||
}
|
||||
|
||||
// SetString sets a string value with optional TTL
|
||||
@@ -472,7 +652,7 @@ func (r *RedisClientImpl) SetString(key, value string, ttl int64) error {
|
||||
if ttl > 0 {
|
||||
expiration = time.Duration(ttl) * time.Second
|
||||
}
|
||||
return r.client.Set(ctx, key, value, expiration).Err()
|
||||
return r.client.Set(ctx, r.toPhysicalKey(key), value, expiration).Err()
|
||||
}
|
||||
|
||||
// GetHash gets all fields of a hash
|
||||
@@ -482,7 +662,7 @@ func (r *RedisClientImpl) GetHash(key string) (map[string]string, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return r.client.HGetAll(ctx, key).Result()
|
||||
return r.client.HGetAll(ctx, r.toPhysicalKey(key)).Result()
|
||||
}
|
||||
|
||||
// SetHashField sets a field in a hash
|
||||
@@ -492,7 +672,7 @@ func (r *RedisClientImpl) SetHashField(key, field, value string) error {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return r.client.HSet(ctx, key, field, value).Err()
|
||||
return r.client.HSet(ctx, r.toPhysicalKey(key), field, value).Err()
|
||||
}
|
||||
|
||||
// DeleteHashField deletes fields from a hash
|
||||
@@ -502,7 +682,7 @@ func (r *RedisClientImpl) DeleteHashField(key string, fields ...string) error {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return r.client.HDel(ctx, key, fields...).Err()
|
||||
return r.client.HDel(ctx, r.toPhysicalKey(key), fields...).Err()
|
||||
}
|
||||
|
||||
// GetList gets a range of elements from a list
|
||||
@@ -512,7 +692,7 @@ func (r *RedisClientImpl) GetList(key string, start, stop int64) ([]string, erro
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return r.client.LRange(ctx, key, start, stop).Result()
|
||||
return r.client.LRange(ctx, r.toPhysicalKey(key), start, stop).Result()
|
||||
}
|
||||
|
||||
// ListPush pushes values to the end of a list
|
||||
@@ -526,7 +706,7 @@ func (r *RedisClientImpl) ListPush(key string, values ...string) error {
|
||||
for i, v := range values {
|
||||
args[i] = v
|
||||
}
|
||||
return r.client.RPush(ctx, key, args...).Err()
|
||||
return r.client.RPush(ctx, r.toPhysicalKey(key), args...).Err()
|
||||
}
|
||||
|
||||
// ListSet sets the value at an index in a list
|
||||
@@ -536,7 +716,7 @@ func (r *RedisClientImpl) ListSet(key string, index int64, value string) error {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return r.client.LSet(ctx, key, index, value).Err()
|
||||
return r.client.LSet(ctx, r.toPhysicalKey(key), index, value).Err()
|
||||
}
|
||||
|
||||
// GetSet gets all members of a set
|
||||
@@ -546,7 +726,7 @@ func (r *RedisClientImpl) GetSet(key string) ([]string, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return r.client.SMembers(ctx, key).Result()
|
||||
return r.client.SMembers(ctx, r.toPhysicalKey(key)).Result()
|
||||
}
|
||||
|
||||
// SetAdd adds members to a set
|
||||
@@ -560,7 +740,7 @@ func (r *RedisClientImpl) SetAdd(key string, members ...string) error {
|
||||
for i, m := range members {
|
||||
args[i] = m
|
||||
}
|
||||
return r.client.SAdd(ctx, key, args...).Err()
|
||||
return r.client.SAdd(ctx, r.toPhysicalKey(key), args...).Err()
|
||||
}
|
||||
|
||||
// SetRemove removes members from a set
|
||||
@@ -574,7 +754,7 @@ func (r *RedisClientImpl) SetRemove(key string, members ...string) error {
|
||||
for i, m := range members {
|
||||
args[i] = m
|
||||
}
|
||||
return r.client.SRem(ctx, key, args...).Err()
|
||||
return r.client.SRem(ctx, r.toPhysicalKey(key), args...).Err()
|
||||
}
|
||||
|
||||
// GetZSet gets members with scores from a sorted set
|
||||
@@ -585,7 +765,7 @@ func (r *RedisClientImpl) GetZSet(key string, start, stop int64) ([]ZSetMember,
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
val, err := r.client.ZRangeWithScores(ctx, key, start, stop).Result()
|
||||
val, err := r.client.ZRangeWithScores(ctx, r.toPhysicalKey(key), start, stop).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -615,7 +795,7 @@ func (r *RedisClientImpl) ZSetAdd(key string, members ...ZSetMember) error {
|
||||
Member: m.Member,
|
||||
}
|
||||
}
|
||||
return r.client.ZAdd(ctx, key, zMembers...).Err()
|
||||
return r.client.ZAdd(ctx, r.toPhysicalKey(key), zMembers...).Err()
|
||||
}
|
||||
|
||||
// ZSetRemove removes members from a sorted set
|
||||
@@ -629,7 +809,7 @@ func (r *RedisClientImpl) ZSetRemove(key string, members ...string) error {
|
||||
for i, m := range members {
|
||||
args[i] = m
|
||||
}
|
||||
return r.client.ZRem(ctx, key, args...).Err()
|
||||
return r.client.ZRem(ctx, r.toPhysicalKey(key), args...).Err()
|
||||
}
|
||||
|
||||
// GetStream gets stream entries in a range
|
||||
@@ -650,7 +830,7 @@ func (r *RedisClientImpl) GetStream(key, start, stop string, count int64) ([]Str
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
val, err := r.client.XRangeN(ctx, key, start, stop, count).Result()
|
||||
val, err := r.client.XRangeN(ctx, r.toPhysicalKey(key), start, stop, count).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -678,7 +858,7 @@ func (r *RedisClientImpl) StreamAdd(key string, fields map[string]string, id str
|
||||
defer cancel()
|
||||
|
||||
newID, err := r.client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: key,
|
||||
Stream: r.toPhysicalKey(key),
|
||||
ID: id,
|
||||
Values: values,
|
||||
}).Result()
|
||||
@@ -699,7 +879,7 @@ func (r *RedisClientImpl) StreamDelete(key string, ids ...string) (int64, error)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return r.client.XDel(ctx, key, ids...).Result()
|
||||
return r.client.XDel(ctx, r.toPhysicalKey(key), ids...).Result()
|
||||
}
|
||||
|
||||
func toStreamEntries(messages []redis.XMessage) []StreamEntry {
|
||||
@@ -717,6 +897,72 @@ func toStreamEntries(messages []redis.XMessage) []StreamEntry {
|
||||
return entries
|
||||
}
|
||||
|
||||
func parseRedisCommandGetKeysResult(result interface{}) []string {
|
||||
items, ok := result.([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
switch v := item.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
keys = append(keys, v)
|
||||
}
|
||||
case []byte:
|
||||
text := string(v)
|
||||
if text != "" {
|
||||
keys = append(keys, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) rewriteCommandArgsForNamespace(ctx context.Context, args []string) []string {
|
||||
if !r.isCluster || r.currentDB <= 0 || len(args) == 0 {
|
||||
return args
|
||||
}
|
||||
|
||||
command := strings.ToUpper(strings.TrimSpace(args[0]))
|
||||
if command == "COMMAND" || command == "SELECT" || command == "FLUSHDB" {
|
||||
return args
|
||||
}
|
||||
|
||||
probeArgs := make([]interface{}, 0, len(args)+2)
|
||||
probeArgs = append(probeArgs, "COMMAND", "GETKEYS")
|
||||
for _, arg := range args {
|
||||
probeArgs = append(probeArgs, arg)
|
||||
}
|
||||
|
||||
result, err := r.client.Do(ctx, probeArgs...).Result()
|
||||
if err != nil {
|
||||
return args
|
||||
}
|
||||
|
||||
keyCandidates := parseRedisCommandGetKeysResult(result)
|
||||
if len(keyCandidates) == 0 {
|
||||
return args
|
||||
}
|
||||
|
||||
rewritten := append([]string(nil), args...)
|
||||
used := make([]bool, len(rewritten))
|
||||
for _, key := range keyCandidates {
|
||||
for i := 1; i < len(rewritten); i++ {
|
||||
if used[i] {
|
||||
continue
|
||||
}
|
||||
if rewritten[i] != key {
|
||||
continue
|
||||
}
|
||||
rewritten[i] = r.toPhysicalKey(rewritten[i])
|
||||
used[i] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return rewritten
|
||||
}
|
||||
|
||||
// ExecuteCommand executes a raw Redis command
|
||||
func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) {
|
||||
if r.client == nil {
|
||||
@@ -729,6 +975,33 @@ func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if r.isCluster {
|
||||
command := strings.ToUpper(strings.TrimSpace(args[0]))
|
||||
switch command {
|
||||
case "SELECT":
|
||||
if len(args) < 2 {
|
||||
return nil, fmt.Errorf("SELECT 命令缺少数据库索引")
|
||||
}
|
||||
index, err := strconv.Atoi(strings.TrimSpace(args[1]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效数据库索引: %s", args[1])
|
||||
}
|
||||
if index < 0 || index > 15 {
|
||||
return nil, fmt.Errorf("数据库索引必须在 0-15 之间")
|
||||
}
|
||||
r.currentDB = index
|
||||
r.config.RedisDB = index
|
||||
return "OK", nil
|
||||
case "FLUSHDB":
|
||||
if err := r.FlushDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return "OK", nil
|
||||
}
|
||||
}
|
||||
|
||||
args = r.rewriteCommandArgsForNamespace(ctx, args)
|
||||
|
||||
// Convert to []interface{}
|
||||
cmdArgs := make([]interface{}, len(args))
|
||||
for i, arg := range args {
|
||||
@@ -795,6 +1068,31 @@ func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if r.isCluster && r.clusterClient != nil {
|
||||
var totalKeys int64
|
||||
var mu sync.Mutex
|
||||
err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error {
|
||||
keys, err := node.DBSize(nodeCtx).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mu.Lock()
|
||||
totalKeys += keys
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Redis 集群获取 key 数量失败,回退为 0: %v", err)
|
||||
totalKeys = 0
|
||||
}
|
||||
result := make([]RedisDBInfo, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
result[i] = RedisDBInfo{Index: i, Keys: 0}
|
||||
}
|
||||
result[0].Keys = totalKeys
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Get keyspace info
|
||||
info, err := r.client.Info(ctx, "keyspace").Result()
|
||||
if err != nil {
|
||||
@@ -845,34 +1143,47 @@ func (r *RedisClientImpl) SelectDB(index int) error {
|
||||
if r.client == nil {
|
||||
return fmt.Errorf("Redis 客户端未连接")
|
||||
}
|
||||
|
||||
if r.isCluster {
|
||||
if index < 0 || index > 15 {
|
||||
return fmt.Errorf("数据库索引必须在 0-15 之间")
|
||||
}
|
||||
r.currentDB = index
|
||||
r.config.RedisDB = index
|
||||
return nil
|
||||
}
|
||||
|
||||
if index < 0 || index > 15 {
|
||||
return fmt.Errorf("数据库索引必须在 0-15 之间")
|
||||
}
|
||||
|
||||
// Create new client with different DB
|
||||
addr := fmt.Sprintf("%s:%d", r.config.Host, r.config.Port)
|
||||
addr := ""
|
||||
if len(r.seedAddrs) > 0 {
|
||||
addr = r.seedAddrs[0]
|
||||
}
|
||||
if r.forwarder != nil {
|
||||
addr = r.forwarder.LocalAddr
|
||||
}
|
||||
if addr == "" {
|
||||
addr = fmt.Sprintf("%s:%d", r.config.Host, r.config.Port)
|
||||
}
|
||||
|
||||
timeout := normalizeRedisTimeout(r.config.Timeout)
|
||||
|
||||
opts := &redis.Options{
|
||||
Addr: addr,
|
||||
Username: strings.TrimSpace(r.config.User),
|
||||
Password: r.config.Password,
|
||||
DB: index,
|
||||
DialTimeout: time.Duration(r.config.Timeout) * time.Second,
|
||||
ReadTimeout: time.Duration(r.config.Timeout) * time.Second,
|
||||
WriteTimeout: time.Duration(r.config.Timeout) * time.Second,
|
||||
}
|
||||
|
||||
if opts.DialTimeout == 0 {
|
||||
opts.DialTimeout = 30 * time.Second
|
||||
opts.ReadTimeout = 30 * time.Second
|
||||
opts.WriteTimeout = 30 * time.Second
|
||||
DialTimeout: timeout,
|
||||
ReadTimeout: timeout,
|
||||
WriteTimeout: timeout,
|
||||
}
|
||||
|
||||
newClient := redis.NewClient(opts)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opts.DialTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
if err := newClient.Ping(ctx).Err(); err != nil {
|
||||
@@ -881,9 +1192,14 @@ func (r *RedisClientImpl) SelectDB(index int) error {
|
||||
}
|
||||
|
||||
// Close old client and replace
|
||||
r.client.Close()
|
||||
if r.client != nil {
|
||||
_ = r.client.Close()
|
||||
}
|
||||
r.client = newClient
|
||||
r.singleClient = newClient
|
||||
r.clusterClient = nil
|
||||
r.currentDB = index
|
||||
r.config.RedisDB = index
|
||||
|
||||
logger.Infof("Redis 切换到数据库: db%d", index)
|
||||
return nil
|
||||
@@ -899,6 +1215,63 @@ func (r *RedisClientImpl) FlushDB() error {
|
||||
if r.client == nil {
|
||||
return fmt.Errorf("Redis 客户端未连接")
|
||||
}
|
||||
|
||||
if r.isCluster && r.clusterClient != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
namespacePrefix := r.redisNamespacePrefix()
|
||||
var deletedTotal int64
|
||||
var deletedMu sync.Mutex
|
||||
|
||||
err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error {
|
||||
var cursor uint64
|
||||
for {
|
||||
pattern := "*"
|
||||
if namespacePrefix != "" {
|
||||
pattern = namespacePrefix + "*"
|
||||
}
|
||||
keys, nextCursor, err := node.Scan(nodeCtx, cursor, pattern, 2000).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if namespacePrefix == "" {
|
||||
filtered := keys[:0]
|
||||
for _, key := range keys {
|
||||
// db0 保留兼容:不删除逻辑库前缀 key,避免误清理 db1~db15。
|
||||
if strings.HasPrefix(key, "__gonavi_db_") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, key)
|
||||
}
|
||||
keys = filtered
|
||||
}
|
||||
|
||||
if len(keys) > 0 {
|
||||
deleted, err := node.Del(nodeCtx, keys...).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deletedMu.Lock()
|
||||
deletedTotal += deleted
|
||||
deletedMu.Unlock()
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Infof("Redis 集群逻辑库清空完成: db%d deleted=%d", r.currentDB, deletedTotal)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return r.client.FlushDB(ctx).Err()
|
||||
|
||||
Reference in New Issue
Block a user