From 494484eb92c46ef2c68df9251c67631e51d31030 Mon Sep 17 00:00:00 2001 From: Syngnat <92659908+Syngnat@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:35:17 +0800 Subject: [PATCH 1/9] Release/0.5.1 (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 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 --- .github/workflows/release.yml | 58 +- cmd/optional-driver-agent/main.go | 100 +- cmd/optional-driver-agent/main_test.go | 172 ++++ docs/driver-manifest.json | 4 +- frontend/src/App.css | 68 ++ frontend/src/App.tsx | 531 +++++++--- frontend/src/components/ConnectionModal.tsx | 253 ++++- frontend/src/components/DataGrid.tsx | 948 ++++++++++++++---- frontend/src/components/DataViewer.tsx | 210 +++- .../src/components/DriverManagerModal.tsx | 295 ++++-- frontend/src/components/LogPanel.tsx | 13 +- frontend/src/components/QueryEditor.tsx | 33 +- frontend/src/components/RedisViewer.tsx | 37 +- frontend/src/components/TabManager.tsx | 175 +++- frontend/src/components/TableDesigner.tsx | 3 +- frontend/src/store.ts | 55 +- frontend/src/types.ts | 2 +- frontend/src/utils/dataSourceCapabilities.ts | 86 ++ frontend/wailsjs/go/app/App.d.ts | 4 + frontend/wailsjs/go/app/App.js | 8 + internal/app/app.go | 90 +- internal/app/app_cache_key_test.go | 63 ++ internal/app/methods_driver.go | 214 +++- internal/app/methods_file.go | 237 ++++- internal/app/methods_file_export_test.go | 205 ++++ internal/app/methods_redis.go | 19 +- internal/app/methods_update.go | 43 + internal/connection/types.go | 2 +- internal/db/clickhouse_impl.go | 11 +- internal/db/dsn_test.go | 26 +- internal/db/json_decode.go | 53 + internal/db/json_decode_test.go | 58 ++ internal/db/mysql_agent_impl.go | 2 +- internal/db/optional_driver_agent_impl.go | 47 +- .../db/optional_driver_agent_impl_test.go | 32 + internal/db/oracle_impl.go | 9 +- internal/db/query_value.go | 146 ++- internal/db/query_value_test.go | 125 ++- internal/redis/redis.go | 2 +- internal/redis/redis_impl.go | 521 ++++++++-- 40 files changed, 4306 insertions(+), 654 deletions(-) create mode 100644 cmd/optional-driver-agent/main_test.go create mode 100644 frontend/src/utils/dataSourceCapabilities.ts create mode 100644 internal/app/app_cache_key_test.go create mode 100644 internal/app/methods_file_export_test.go create mode 100644 internal/db/json_decode.go create mode 100644 internal/db/json_decode_test.go create mode 100644 internal/db/optional_driver_agent_impl_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb40164..1b522e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | diff --git a/cmd/optional-driver-agent/main.go b/cmd/optional-driver-agent/main.go index 20c7316..4c0c5b9 100644 --- a/cmd/optional-driver-agent/main.go +++ b/cmd/optional-driver-agent/main.go @@ -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) +} diff --git a/cmd/optional-driver-agent/main_test.go b/cmd/optional-driver-agent/main_test.go new file mode 100644 index 0000000..016e520 --- /dev/null +++ b/cmd/optional-driver-agent/main_test.go @@ -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) + } +} diff --git a/docs/driver-manifest.json b/docs/driver-manifest.json index 1f0302a..d04fba3 100644 --- a/docs/driver-manifest.json +++ b/docs/driver-manifest.json @@ -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" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index 713d6b9..e91f7e7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c189de5..ba93b57 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); + const updateInstallTriggeredVersionRef = React.useRef(null); const updateDownloadMetaRef = React.useRef(null); - const updateDeferredVersionRef = React.useRef(null); const updateNotifiedVersionRef = React.useRef(null); const updateMutedVersionRef = React.useRef(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(''); @@ -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: ( -
-
{`版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`}
- {downloadPathHint ?
{downloadPathHint}
: null} - {installLogHint ?
{installLogHint}
: null} -
- ), - 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 ( -
+
{/* Logo can be added here if available */} GoNavi
@@ -860,35 +1049,35 @@ function App() {
-
+
- {/* Sidebar Footer for Log Toggle */} -
- @@ -979,13 +1193,17 @@ function App() { setIsDriverModalOpen(false)} + onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)} /> setIsAboutOpen(false)} footer={[ - lastUpdateInfo?.hasUpdate ? ( + canShowProgressEntry ? ( + + ) : null, + lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded ? ( ) : null, lastUpdateInfo?.hasUpdate ? ( @@ -1007,7 +1225,7 @@ function App() {
{aboutInfo?.repoUrl ? ( - { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}> + { e.preventDefault(); if (aboutInfo?.repoUrl) BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}> {aboutInfo.repoUrl} ) : '未知'} @@ -1015,7 +1233,7 @@ function App() {
{aboutInfo?.issueUrl ? ( - { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}> + { e.preventDefault(); if (aboutInfo?.issueUrl) BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}> {aboutInfo.issueUrl} ) : '未知'} @@ -1023,7 +1241,7 @@ function App() {
{aboutInfo?.releaseUrl ? ( - { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}> + { e.preventDefault(); if (aboutInfo?.releaseUrl) BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}> {aboutInfo.releaseUrl} ) : '未知'} @@ -1040,6 +1258,37 @@ function App() { width={460} >
+
+
界面缩放 (UI Scale)
+
+ setUiScale(Number(v))} + style={{ flex: 1 }} + /> + {Math.round(effectiveUiScale * 100)}% +
+
+ * 建议小屏设备设置为 85%-95% +
+
+
+
基础字体大小 (Font Size)
+
+ setFontSize(Number(v))} + style={{ flex: 1 }} + /> + {effectiveFontSize}px +
+
背景不透明度 (Opacity)
@@ -1088,6 +1337,17 @@ function App() { * 修改后下次启动生效
+
+ +
@@ -1169,38 +1429,25 @@ function App() { { - 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' ? [ - ] : null} + ] : (updateDownloadProgress.status === 'done' ? [ + , + + ] : (updateDownloadProgress.status === 'error' ? [ + + ] : null))} >
{ } }; +const singleHostUriSchemesByType: Record = { + 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>({}); const [driverStatusLoaded, setDriverStatusLoaded] = useState(false); + const [selectingDbFile, setSelectingDbFile] = useState(false); const [selectingSSHKey, setSelectingSSHKey] = useState(false); const testInFlightRef = useRef(false); const testTimerRef = useRef(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 | 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 | 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} /> + {isFileDb && ( + + + + )} {!isFileDb && ( )} + {dbType === 'oracle' && ( + + + + )} + {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && ( <> @@ -1567,11 +1753,36 @@ const ConnectionModal: React.FC<{ {/* Redis specific: password only, no username */} {isRedis && ( <> + + + + )} - - {redisDbList.map(db => db{db})} diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 797a1ab..05ccbba 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -12,6 +12,7 @@ import { v4 as uuidv4 } from 'uuid'; import 'react-resizable/css/styles.css'; import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { isMacLikePlatform, normalizeOpacityForPlatform } from '../utils/appearance'; +import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -64,6 +65,10 @@ export const GONAVI_ROW_KEY = '__gonavi_row_key__'; // Cell key helpers for batch selection/fill. // Use a control character separator to avoid collisions with rowKey/columnName contents (e.g. `new-123`). const CELL_KEY_SEP = '\u0001'; +const DATE_TIME_CACHE_LIMIT = 2000; +const TABLE_CELL_PREVIEW_MAX_CHARS = 240; +const normalizedDateTimeCache = new Map(); +const objectCellPreviewCache = new WeakMap(); const makeCellKey = (rowKey: string, colName: string) => `${rowKey}${CELL_KEY_SEP}${colName}`; const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | null => { const sepIndex = cellKey.indexOf(CELL_KEY_SEP); @@ -74,10 +79,42 @@ const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | nu }; }; +const trimSimpleCache = (cache: Map, limit: number) => { + if (cache.size < limit) return; + const firstKey = cache.keys().next().value; + if (typeof firstKey === 'string') { + cache.delete(firstKey); + } +}; + +const looksLikeDateTimeText = (val: string): boolean => { + if (!val) return false; + const len = val.length; + if (len < 19 || len > 48) return false; + const charCode0 = val.charCodeAt(0); + if (charCode0 < 48 || charCode0 > 57) return false; + return ( + val[4] === '-' && + val[7] === '-' && + (val[10] === ' ' || val[10] === 'T') && + val[13] === ':' && + val[16] === ':' + ); +}; + // Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing. // Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`. // Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged. const normalizeDateTimeString = (val: string) => { + if (!looksLikeDateTimeText(val)) { + return val; + } + + const cached = normalizedDateTimeCache.get(val); + if (cached !== undefined) { + return cached; + } + // 检查是否为无效日期时间(0000-00-00 或类似格式) if (/^0{4}-0{2}-0{2}/.test(val)) { return val; // 保持原样显示,不尝试转换 @@ -86,8 +123,10 @@ const normalizeDateTimeString = (val: string) => { const match = val.match( /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ ); - if (!match) return val; - return `${match[1]} ${match[2]}`; + const normalized = match ? `${match[1]} ${match[2]}` : val; + trimSimpleCache(normalizedDateTimeCache, DATE_TIME_CACHE_LIMIT); + normalizedDateTimeCache.set(val, normalized); + return normalized; }; const isTemporalColumnType = (columnType?: string): boolean => { @@ -103,14 +142,22 @@ const formatCellValue = (val: any) => { try { if (val === null) return NULL; if (typeof val === 'object') { + const cached = objectCellPreviewCache.get(val); + if (cached !== undefined) { + return cached; + } try { - return JSON.stringify(val); + const nextText = JSON.stringify(val); + const previewText = nextText.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${nextText.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : nextText; + objectCellPreviewCache.set(val, previewText); + return previewText; } catch { return '[Object]'; } } if (typeof val === 'string') { - return normalizeDateTimeString(val); + const normalized = normalizeDateTimeString(val); + return normalized.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${normalized.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : normalized; } return String(val); } catch (e) { @@ -137,6 +184,7 @@ const toFormText = (val: any): string => { // 用于变更比较:NULL 与 undefined 视为同类空值;与空字符串严格区分。 const isCellValueEqualForDiff = (left: any, right: any): boolean => { + if (left === right) return true; const leftNullish = left === null || left === undefined; const rightNullish = right === null || right === undefined; if (leftNullish || rightNullish) return leftNullish && rightNullish; @@ -302,6 +350,7 @@ const DataContext = React.createContext<{ copyToClipboard: (t: string) => void; tableName?: string; enableRowContextMenu: boolean; + supportsCopyInsert: boolean; } | null>(null); interface Item { @@ -316,6 +365,7 @@ interface EditableCellProps { record: Item; handleSave: (record: Item) => void; focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void; + as?: any; [key: string]: any; } @@ -327,6 +377,7 @@ const EditableCell: React.FC = React.memo(({ record, handleSave, focusCell, + as: Component = 'td', ...restProps }) => { const [editing, setEditing] = useState(false); @@ -428,14 +479,14 @@ const EditableCell: React.FC = React.memo(({ }; return ( - {childNode} - + ); }); @@ -444,7 +495,7 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { if (!record || !context) return {children}; - const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu } = context; + const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu, supportsCopyInsert } = context; if (!enableRowContextMenu) { return {children}; @@ -460,12 +511,12 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { }; const menuItems: MenuProps['items'] = [ - { - key: 'insert', - label: `复制为 INSERT`, - icon: , - onClick: () => handleCopyInsert(record) - }, + ...(supportsCopyInsert ? [{ + key: 'insert', + label: '复制为 INSERT', + icon: , + onClick: () => handleCopyInsert(record), + }] : []), { key: 'json', label: '复制为 JSON', icon: , onClick: () => handleCopyJson(record) }, { key: 'csv', label: '复制为 CSV', icon: , onClick: () => handleCopyCsv(record) }, { key: 'copy', label: '复制为 Markdown', icon: , onClick: () => { @@ -502,6 +553,8 @@ interface DataGridProps { columnNames: string[]; loading: boolean; tableName?: string; + exportScope?: 'table' | 'queryResult'; + resultSql?: string; dbName?: string; connectionId?: string; pkColumns?: string[]; @@ -543,7 +596,7 @@ type ColumnMeta = { }; const DataGrid: React.FC = ({ - data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false, + data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false, onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter }) => { const connections = useStore(state => state.connections); @@ -559,8 +612,14 @@ const DataGrid: React.FC = ({ const showColumnComment = queryOptions?.showColumnComment !== false; const showColumnType = queryOptions?.showColumnType !== false; const selectionColumnWidth = 46; - const connTypeLower = String(connections.find(c => c.id === connectionId)?.config?.type || '').trim().toLowerCase(); - const isDuckDBConnection = connTypeLower === 'duckdb'; + const currentConnConfig = connections.find(c => c.id === connectionId)?.config; + const dataSourceCaps = getDataSourceCapabilities(currentConnConfig); + const isDuckDBConnection = dataSourceCaps.type === 'duckdb'; + const supportsCopyInsert = dataSourceCaps.supportsCopyInsert; + const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport; + const isQueryResultExport = exportScope === 'queryResult'; + const canImport = exportScope === 'table' && !!tableName; + const canExport = !!connectionId && (isQueryResultExport || !!tableName); // Background Helper const getBg = (darkHex: string) => { @@ -581,6 +640,38 @@ const DataGrid: React.FC = ({ const rowModBg = darkMode ? getRowBg(22, 34, 56) : getRowBg(230, 247, 255); const rowAddedHover = darkMode ? getRowBg(31, 61, 31) : getRowBg(217, 247, 190); const rowModHover = darkMode ? getRowBg(29, 53, 94) : getRowBg(186, 231, 255); + const selectionAccentHex = darkMode ? '#f6c453' : '#1890ff'; + const selectionAccentRgb = darkMode ? '246, 196, 83' : '24, 144, 255'; + const darkHighlightTextColor = 'rgba(255, 236, 179, 0.98)'; + const lightMetaHintColor = '#595959'; + const lightMetaTooltipColor = '#262626'; + const panelRadius = 10; + const panelOuterGap = 6; + const panelPaddingY = 10; + const panelPaddingX = 12; + const toolbarBottomPadding = 6; + const filterTopPadding = 2; + const panelBorderColor = darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)'; + const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)'; + const floatingScrollbarGap = 6; + const floatingScrollbarInset = 10; + const floatingScrollbarHeight = 10; + const floatingScrollbarTrackBg = 'transparent'; + const floatingScrollbarBorderColor = 'transparent'; + const floatingScrollbarShadow = 'none'; + const floatingScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.34)' : 'rgba(0,0,0,0.22)'; + const floatingScrollbarThumbBorderColor = darkMode ? 'rgba(255,255,255,0.10)' : 'rgba(255,255,255,0.32)'; + const floatingScrollbarThumbShadow = darkMode ? '0 4px 12px rgba(0,0,0,0.28)' : '0 4px 10px rgba(0,0,0,0.12)'; + const horizontalScrollbarTrackBg = 'transparent'; + const horizontalScrollbarTrackBorderColor = 'transparent'; + const horizontalScrollbarTrackShadow = 'none'; + const horizontalScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.14)'; + const horizontalScrollbarThumbBorderColor = 'transparent'; + const horizontalScrollbarThumbShadow = 'none'; + const externalScrollbarMinWidth = 1; + const toolbarDividerColor = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)'; + const columnMetaHintColor = darkMode ? darkHighlightTextColor : lightMetaHintColor; + const columnMetaTooltipColor = darkMode ? darkHighlightTextColor : lightMetaTooltipColor; const [form] = Form.useForm(); const [modal, contextHolder] = Modal.useModal(); @@ -618,6 +709,12 @@ const DataGrid: React.FC = ({ title: '', }); const containerRef = useRef(null); + const tableContainerRef = useRef(null); + const tableScrollTargetsRef = useRef([]); + const externalHScrollRef = useRef(null); + const horizontalSyncSourceRef = useRef<'table' | 'external' | ''>(''); + const lastTableScrollLeftRef = useRef(0); + const lastExternalScrollLeftRef = useRef(0); const pendingScrollToBottomRef = useRef(false); // 批量编辑模式状态 @@ -687,11 +784,20 @@ const DataGrid: React.FC = ({ // Helper to export specific data const exportData = async (rows: any[], format: string) => { const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0); - const cleanRows = rows.map(({ [GONAVI_ROW_KEY]: _rowKey, ...rest }) => rest); - // Pass tableName (or 'export') as default filename - const res = await ExportData(cleanRows, columnNames, tableName || 'export', format); - hide(); - if (res.success) { message.success("导出成功"); } else if (res.message !== "Cancelled") { message.error("导出失败: " + res.message); } + try { + const cleanRows = rows.map(({ [GONAVI_ROW_KEY]: _rowKey, ...rest }) => rest); + // Pass tableName (or 'export') as default filename + const res = await ExportData(cleanRows, columnNames, tableName || 'export', format); + if (res.success) { + message.success("导出成功"); + } else if (res.message !== "Cancelled") { + message.error("导出失败: " + res.message); + } + } catch (e: any) { + message.error("导出失败: " + (e?.message || String(e))); + } finally { + hide(); + } }; const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null); @@ -828,7 +934,7 @@ const DataGrid: React.FC = ({ style={{ marginTop: 2, fontSize: 11, - color: '#8c8c8c', + color: columnMetaHintColor, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -843,7 +949,7 @@ const DataGrid: React.FC = ({ style={{ marginTop: 2, fontSize: 11, - color: '#8c8c8c', + color: columnMetaHintColor, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -859,13 +965,14 @@ const DataGrid: React.FC = ({ if (hoverLines.length === 0) return titleNode; return ( {hoverLines.join('\n')}} + title={
{hoverLines.join('\n')}
} styles={{ root: { maxWidth: 640 } }} + {...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})} > {titleNode}
); - }, [columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]); + }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]); const closeCellEditor = useCallback(() => { setCellEditorOpen(false); @@ -912,23 +1019,19 @@ const DataGrid: React.FC = ({ Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42; const bodyEl = target.querySelector('.ant-table-body') as HTMLElement | null; - const stickyScrollEl = target.querySelector('.ant-table-sticky-scroll') as HTMLElement | null; - const hasHorizontalOverflow = !!bodyEl && (bodyEl.scrollWidth - bodyEl.clientWidth > 1); - const nativeHorizontalScrollbarHeight = bodyEl ? Math.max(0, Math.ceil(bodyEl.offsetHeight - bodyEl.clientHeight)) : 0; - const stickyScrollHeight = stickyScrollEl ? Math.ceil(stickyScrollEl.getBoundingClientRect().height) : 0; - // 动态为横向滚动条(含 sticky 条)预留空间,避免最后一行被遮住。 - const horizontalReserve = hasHorizontalOverflow - ? Math.max(nativeHorizontalScrollbarHeight, stickyScrollHeight, 14) - : Math.max(nativeHorizontalScrollbarHeight, 0); - // sticky 横向滚动条会覆盖在表格底部,额外给 body 增加内边距,确保最后一行完整可见。 + const virtualHolderEl = target.querySelector('.rc-virtual-list-holder') as HTMLElement | null; + const scrollableEl = virtualHolderEl || bodyEl; + const hasHorizontalOverflow = !!scrollableEl && (scrollableEl.scrollWidth - scrollableEl.clientWidth > 1); + // 外部横向滚动条采用悬浮覆盖,不再通过压缩表格高度制造独立底部空白层; + // 只给 body 增加底部内边距,确保最后一行可以完整滚到胶囊条上方。 const nextBodyBottomPadding = hasHorizontalOverflow - ? Math.max(stickyScrollHeight, nativeHorizontalScrollbarHeight, 14) + 6 + ? floatingScrollbarHeight + floatingScrollbarGap + 4 : 0; setTableBodyBottomPadding(nextBodyBottomPadding); - const extraBottom = 10 + horizontalReserve; + const extraBottom = 2; const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom)); setTableHeight(nextHeight); - }, []); + }, [floatingScrollbarGap, floatingScrollbarHeight]); useEffect(() => { const el = containerRef.current; @@ -1430,8 +1533,16 @@ const DataGrid: React.FC = ({ }, [addedRows, rowKeyStr]); const modifiedRowKeySet = useMemo(() => new Set(Object.keys(modifiedRows)), [modifiedRows]); + const rowClassName = useCallback((record: Item) => { + const k = record?.[GONAVI_ROW_KEY]; + if (k === undefined || k === null) return ''; + const keyStr = rowKeyStr(k); + if (addedRowKeySet.has(keyStr)) return 'row-added'; + if (modifiedRowKeySet.has(keyStr) || deletedRowKeys.has(keyStr)) return 'row-modified'; + return ''; + }, [addedRowKeySet, modifiedRowKeySet, deletedRowKeys, rowKeyStr]); - const handleTableChange = (pag: any, filtersArg: any, sorter: any) => { + const handleTableChange = useCallback((pag: any, filtersArg: any, sorter: any) => { if (isResizingRef.current) return; // Block sort if resizing if (sorter.field) { const field = String(sorter.field); @@ -1448,7 +1559,7 @@ const DataGrid: React.FC = ({ setSortInfo(null); if (onSort) onSort('', ''); } - }; + }, [onSort]); // Native Drag State const draggingRef = useRef<{ @@ -1605,6 +1716,11 @@ const DataGrid: React.FC = ({ } }, [cellEditorIsJson, cellEditorValue]); + const handleVirtualCellActivate = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => { + if (!canModifyData) return; + openCellEditor(record, dataIndex, title); + }, [canModifyData, openCellEditor]); + // Merge Data for Display // 'displayData' already merges addedRows. // We need to merge modifiedRows into it for rendering. @@ -1626,24 +1742,27 @@ const DataGrid: React.FC = ({ }, [mergedDisplayData.length]); const jsonViewText = useMemo(() => { + if (viewMode !== 'json') return ''; const cleanRows = mergedDisplayData.map((row) => { const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = row || {}; return normalizeValueForJsonView(rest); }); return JSON.stringify(cleanRows, null, 2); - }, [mergedDisplayData]); + }, [viewMode, mergedDisplayData]); const textViewRows = useMemo(() => { + if (viewMode !== 'text') return []; return mergedDisplayData.map((row) => { const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = row || {}; return rest; }); - }, [mergedDisplayData]); + }, [viewMode, mergedDisplayData]); const currentTextRow = useMemo(() => { + if (viewMode !== 'text') return null; if (textViewRows.length === 0) return null; return textViewRows[textRecordIndex] || null; - }, [textViewRows, textRecordIndex]); + }, [viewMode, textViewRows, textRecordIndex]); const formatTextViewValue = useCallback((val: any): string => { if (val === null) return 'NULL'; @@ -1889,6 +2008,12 @@ const DataGrid: React.FC = ({ closeRowEditor(); }, [rowEditorRowKey, rowEditorForm, addedRows, columnNames, rowKeyStr, closeRowEditor]); + const estimatedVisibleCellCount = mergedDisplayData.length * Math.max(columnNames.length, 1); + const enableLargeResultOptimizedEditing = + viewMode === 'table' && (mergedDisplayData.length >= 60 || estimatedVisibleCellCount >= 4000); + const enableVirtual = enableLargeResultOptimizedEditing; + const enableInlineEditableCell = canModifyData; + const columns = useMemo(() => { return columnNames.map(key => ({ title: renderColumnTitle(key), @@ -1938,18 +2063,49 @@ const DataGrid: React.FC = ({ const mergedColumns = useMemo(() => columns.map(col => { if (!col.editable) return col; + const dataIndex = String(col.dataIndex); return { ...col, - onCell: (record: Item) => ({ - record, - editable: col.editable, - dataIndex: col.dataIndex, - title: String(col.dataIndex), - handleSave: handleCellSave, - focusCell: openCellEditor, - }), + onCell: (record: Item) => { + if (!enableInlineEditableCell) { + const rowKey = record?.[GONAVI_ROW_KEY]; + return { + 'data-row-key': rowKey === undefined || rowKey === null ? undefined : String(rowKey), + 'data-col-name': dataIndex, + onDoubleClick: () => handleVirtualCellActivate(record, dataIndex, dataIndex), + }; + } + return { + record, + editable: col.editable, + dataIndex: col.dataIndex, + title: dataIndex, + handleSave: handleCellSave, + focusCell: openCellEditor, + }; + }, + render: (text: any, record: Item, index: number) => { + const originalRenderContent = col.render ? (col.render as any)(text, record, index) : text; + if (enableVirtual && enableInlineEditableCell) { + return ( + + {originalRenderContent} + + ); + } + return originalRenderContent; + } }; - }), [columns, handleCellSave, openCellEditor]); + }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate]); const handleAddRow = () => { const newKey = `new-${Date.now()}`; @@ -2101,6 +2257,10 @@ const DataGrid: React.FC = ({ }, []); const handleCopyInsert = useCallback((record: any) => { + if (!supportsCopyInsert) { + message.warning("当前数据源不支持复制为 INSERT,请使用 JSON/CSV/Markdown 复制。"); + return; + } const records = getTargets(record); const sqls = records.map((r: any) => { const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; @@ -2110,7 +2270,7 @@ const DataGrid: React.FC = ({ return `INSERT INTO \`${targetTable}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`; }); copyToClipboard(sqls.join('\n')); - }, [tableName, getTargets, copyToClipboard]); + }, [supportsCopyInsert, tableName, getTargets, copyToClipboard]); const handleCopyJson = useCallback((record: any) => { const records = getTargets(record); @@ -2149,12 +2309,17 @@ const DataGrid: React.FC = ({ const config = buildConnConfig(); if (!config) return; const hide = message.loading(`正在导出...`, 0); - const res = await ExportQuery(config as any, dbName || '', sql, defaultName || 'export', format); - hide(); - if (res.success) { - message.success("导出成功"); - } else if (res.message !== "Cancelled") { - message.error("导出失败: " + res.message); + try { + const res = await ExportQuery(config as any, dbName || '', sql, defaultName || 'export', format); + if (res.success) { + message.success("导出成功"); + } else if (res.message !== "Cancelled") { + message.error("导出失败: " + res.message); + } + } catch (e: any) { + message.error("导出失败: " + (e?.message || String(e))); + } finally { + hide(); } }, [buildConnConfig, dbName]); @@ -2198,6 +2363,10 @@ const DataGrid: React.FC = ({ // Context Menu Export const handleExportSelected = useCallback(async (format: string, record: any) => { const records = getTargets(record); + if (isQueryResultExport) { + await exportData(records, format); + return; + } if (!connectionId || !tableName) { await exportData(records, format); return; @@ -2225,11 +2394,11 @@ const DataGrid: React.FC = ({ const sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} WHERE ${pkWhere}`; await exportByQuery(sql, format, tableName || 'export'); - }, [getTargets, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery]); + }, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery]); // Export const handleExport = async (format: string) => { - if (!connectionId || !tableName) return; + if (!connectionId) return; // 1. Export Selected if (selectedRowKeys.length > 0) { @@ -2238,17 +2407,38 @@ const DataGrid: React.FC = ({ return; } + // 查询结果页导出统一按当前结果集(已加载数据)导出,避免再次执行原 SQL 造成大数据导出或长时间阻塞。 + if (isQueryResultExport) { + const sql = String(resultSql || '').trim(); + if (!hasChanges && supportsSqlQueryExport && sql) { + await exportByQuery(sql, format, tableName || 'query_result'); + } else { + await exportData(mergedDisplayData, format); + } + return; + } + // 2. Prompt for Current vs All // Using a custom modal content with buttons to handle 3 states let instance: any; const handleAll = async () => { instance.destroy(); + if (!tableName) return; const config = buildConnConfig(); if (!config) return; const hide = message.loading(`正在导出全部数据...`, 0); - const res = await ExportTable(config as any, dbName || '', tableName, format); - hide(); - if (res.success) { message.success("导出成功"); } else if (res.message !== "Cancelled") { message.error("导出失败: " + res.message); } + try { + const res = await ExportTable(config as any, dbName || '', tableName, format); + if (res.success) { + message.success("导出成功"); + } else if (res.message !== "Cancelled") { + message.error("导出失败: " + res.message); + } + } catch (e: any) { + message.error("导出失败: " + (e?.message || String(e))); + } finally { + hide(); + } }; const handlePage = async () => { instance.destroy(); @@ -2396,11 +2586,6 @@ const DataGrid: React.FC = ({
); - const tableComponents = useMemo(() => ({ - body: { cell: EditableCell, row: ContextMenuRow }, - header: { cell: ResizableTitle } - }), []); - const dataContextValue = useMemo(() => ({ selectedRowKeysRef, displayDataRef, @@ -2411,7 +2596,8 @@ const DataGrid: React.FC = ({ copyToClipboard, tableName, enableRowContextMenu: !canModifyData, - }), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData]); + supportsCopyInsert, + }), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData, supportsCopyInsert]); const cellContextMenuValue = useMemo(() => ({ showMenu: showCellContextMenu, @@ -2427,17 +2613,121 @@ const DataGrid: React.FC = ({ const rowPropsFactory = useCallback((record: any) => ({ record } as any), []); const totalWidth = columns.reduce((sum, col) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth; - const enableVirtual = mergedDisplayData.length >= 200; + const useContextMenuRow = !canModifyData; const tableScrollX = useMemo(() => { const baseWidth = Math.max(totalWidth, 1000); if (!isMacLike || tableViewportWidth <= 0) return baseWidth; // macOS 在“自动隐藏滚动条”模式下容易误判为无横向滚动,预留 2px 触发稳定滚动轨道。 return Math.max(baseWidth, tableViewportWidth + 2); }, [totalWidth, isMacLike, tableViewportWidth]); - const tableStickyConfig = useMemo(() => ({ - getContainer: () => containerRef.current || document.body, - offsetScroll: 0, - }), []); + const horizontalScrollVisible = viewMode === 'table' && !enableVirtual && tableScrollX > tableViewportWidth + 1; + const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX); + const tableScrollConfig = useMemo(() => ({ x: tableScrollX, y: tableHeight }), [tableScrollX, tableHeight]); + const tableComponents = useMemo(() => { + const body: Record = {}; + if (enableInlineEditableCell) { + body.cell = EditableCell; + } + if (useContextMenuRow) { + body.row = ContextMenuRow; + } + return Object.keys(body).length > 0 + ? { body, header: { cell: ResizableTitle } } + : { header: { cell: ResizableTitle } }; + }, [enableInlineEditableCell, useContextMenuRow]); + const tableOnRow = useMemo(() => (useContextMenuRow ? rowPropsFactory : undefined), [useContextMenuRow, rowPropsFactory]); + + const pickHorizontalScrollTargets = useCallback((tableContainer: HTMLElement): HTMLElement[] => { + const body = tableContainer.querySelector('.ant-table-body'); + const content = tableContainer.querySelector('.ant-table-content'); + const virtualHolder = tableContainer.querySelector('.rc-virtual-list-holder'); + const candidates = [virtualHolder, body, content].filter((node): node is HTMLElement => node instanceof HTMLElement); + if (candidates.length === 0) { + return []; + } + const active = candidates.find((target) => target.scrollWidth > target.clientWidth + 1) || candidates[0]; + return active ? [active] : []; + }, []); + + const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => { + const externalScroll = externalHScrollRef.current; + if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') { + return; + } + const nextTargets = targets && targets.length > 0 ? targets : tableScrollTargetsRef.current; + if (!nextTargets || nextTargets.length === 0) { + return; + } + const activeTarget = source || nextTargets.find((target) => target.scrollWidth > target.clientWidth + 1) || nextTargets[0]; + if (!(activeTarget instanceof HTMLElement)) { + return; + } + const nextScrollLeft = activeTarget.scrollLeft; + if (Math.abs(lastTableScrollLeftRef.current - nextScrollLeft) < 1 && Math.abs(externalScroll.scrollLeft - nextScrollLeft) < 1) { + return; + } + lastTableScrollLeftRef.current = nextScrollLeft; + if (Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) { + externalScroll.scrollLeft = nextScrollLeft; + lastExternalScrollLeftRef.current = nextScrollLeft; + } + }, []); + + const applyExternalScrollToTableTargets = useCallback(() => { + const externalScroll = externalHScrollRef.current; + if (!(externalScroll instanceof HTMLDivElement)) { + return; + } + if (horizontalSyncSourceRef.current === 'table') { + return; + } + + const liveTargets = tableScrollTargetsRef.current; + if (liveTargets.length === 0) { + return; + } + + if (Math.abs(lastExternalScrollLeftRef.current - externalScroll.scrollLeft) < 1) { + return; + } + lastExternalScrollLeftRef.current = externalScroll.scrollLeft; + + horizontalSyncSourceRef.current = 'external'; + liveTargets.forEach((target) => { + if (target.scrollWidth <= target.clientWidth + 1) { + return; + } + if (Math.abs(target.scrollLeft - externalScroll.scrollLeft) > 1) { + target.scrollLeft = externalScroll.scrollLeft; + } + }); + lastTableScrollLeftRef.current = externalScroll.scrollLeft; + horizontalSyncSourceRef.current = ''; + }, []); + + const handleExternalHorizontalWheel = useCallback((event: React.WheelEvent) => { + const externalScroll = externalHScrollRef.current; + if (!(externalScroll instanceof HTMLDivElement)) { + return; + } + const dominantDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (!Number.isFinite(dominantDelta) || Math.abs(dominantDelta) < 0.5) { + return; + } + + const maxScrollLeft = Math.max(0, externalScroll.scrollWidth - externalScroll.clientWidth); + if (maxScrollLeft <= 0) { + return; + } + + const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, externalScroll.scrollLeft + dominantDelta)); + if (Math.abs(nextScrollLeft - externalScroll.scrollLeft) < 0.5) { + return; + } + + event.preventDefault(); + externalScroll.scrollLeft = nextScrollLeft; + }, []); useEffect(() => { if (viewMode !== 'table') return; @@ -2445,10 +2735,141 @@ const DataGrid: React.FC = ({ return () => cancelAnimationFrame(rafId); }, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]); + // 虚拟模式下,为 rc-virtual-list 的内置水平滚动条添加鼠标滚轮支持 + // rc-virtual-list 的 ScrollBar 组件原生只支持拖拽,不支持 wheel 事件 + // 方案:使用 MutationObserver 发现滚动条元素后直接绑定 wheel 事件 + useEffect(() => { + if (viewMode !== 'table' || !enableVirtual) return; + const container = tableContainerRef.current; + if (!container) return; + + let currentScrollbarEl: HTMLElement | null = null; + + const handleScrollbarWheel = (e: WheelEvent) => { + const innerEl = container.querySelector('.rc-virtual-list-holder-inner') as HTMLElement | null; + const holderEl = container.querySelector('.rc-virtual-list-holder') as HTMLElement | null; + if (!innerEl || !holderEl) return; + + const dominantDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + if (Math.abs(dominantDelta) < 0.5) return; + + e.preventDefault(); + e.stopPropagation(); + + // 读取当前 marginLeft(负值表示向右偏移) + const currentMarginLeft = parseFloat(innerEl.style.marginLeft) || 0; + const contentWidth = tableScrollX; + const viewportWidth = holderEl.clientWidth; + const maxScroll = Math.max(0, contentWidth - viewportWidth); + + const currentOffset = Math.abs(currentMarginLeft); + const newOffset = Math.min(maxScroll, Math.max(0, currentOffset + dominantDelta)); + + // 直接更新内容位置 + innerEl.style.marginLeft = `${-newOffset}px`; + + // 同步 scrollbar thumb 位置 + if (currentScrollbarEl && maxScroll > 0) { + const thumbEl = currentScrollbarEl.querySelector('[class*="scrollbar-thumb"]') as HTMLElement | null; + if (thumbEl) { + const ratio = newOffset / maxScroll; + const thumbWidth = parseFloat(thumbEl.style.width) || thumbEl.offsetWidth; + const trackWidth = currentScrollbarEl.clientWidth; + const thumbMaxOffset = trackWidth - thumbWidth; + thumbEl.style.left = `${ratio * thumbMaxOffset}px`; + } + } + + // 同步表头水平位置 + const headerEl = container.querySelector('.ant-table-header') as HTMLElement | null; + if (headerEl) { + headerEl.scrollLeft = newOffset; + } + }; + + const bindScrollbar = () => { + const el = container.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null; + if (el && el !== currentScrollbarEl) { + if (currentScrollbarEl) { + currentScrollbarEl.removeEventListener('wheel', handleScrollbarWheel); + } + currentScrollbarEl = el; + el.addEventListener('wheel', handleScrollbarWheel, { passive: false }); + } + }; + + // 初次尝试绑定 + bindScrollbar(); + + // 使用 MutationObserver 监听 DOM 变化,确保即使元素延迟渲染也能绑定 + const observer = new MutationObserver(() => { + bindScrollbar(); + }); + observer.observe(container, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + if (currentScrollbarEl) { + currentScrollbarEl.removeEventListener('wheel', handleScrollbarWheel); + } + }; + }, [viewMode, enableVirtual, tableScrollX, mergedDisplayData.length]); + + useEffect(() => { + if (viewMode !== 'table') return; + const tableContainer = tableContainerRef.current; + const externalScroll = externalHScrollRef.current; + if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) return; + + let rafId: number | null = null; + let boundTargets: HTMLElement[] = []; + + const handleTargetScroll = (event: Event) => { + const source = event.target as HTMLElement | null; + if (horizontalSyncSourceRef.current === 'external') return; + horizontalSyncSourceRef.current = 'table'; + syncExternalScrollFromTargets(undefined, source); + horizontalSyncSourceRef.current = ''; + }; + + const bindCurrentTableTargets = () => { + // Unbind previous targets + boundTargets.forEach(t => t.removeEventListener('scroll', handleTargetScroll)); + const nextTargets = pickHorizontalScrollTargets(tableContainer); + tableScrollTargetsRef.current = nextTargets; + boundTargets = nextTargets; + // Bind scroll listener on new targets + nextTargets.forEach(t => t.addEventListener('scroll', handleTargetScroll, { passive: true })); + syncExternalScrollFromTargets(nextTargets); + }; + + const scheduleBind = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + bindCurrentTableTargets(); + }); + }; + + window.addEventListener('resize', scheduleBind); + scheduleBind(); + + return () => { + window.removeEventListener('resize', scheduleBind); + boundTargets.forEach(t => t.removeEventListener('scroll', handleTargetScroll)); + tableScrollTargetsRef.current = []; + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [viewMode, tableScrollX, mergedDisplayData.length, syncExternalScrollFromTargets, pickHorizontalScrollTargets]); + return ( -
- {/* Toolbar */} -
+
+ {/* Toolbar + Filter Panel */} +
+
{onReload && } - {tableName && } - {tableName && } + {canImport && } + {canExport && } {canModifyData && ( <> -
+
{selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}} -
+
)} -
+
{hasChanges && (
-
+
- {/* Filter Panel */} {showFilter && (
{filterConditions.map(cond => (
@@ -2701,8 +3120,9 @@ const DataGrid: React.FC = ({
)} +
-
+
{contextHolder} = ({ title={cellEditorMeta ? `编辑单元格:${cellEditorMeta.title}` : '编辑单元格'} open={cellEditorOpen} onCancel={closeCellEditor} + destroyOnHidden width={960} maskClosable={false} footer={[ @@ -2767,21 +3188,23 @@ const DataGrid: React.FC = ({
{cellEditorMeta ? `${tableName || ''}${tableName ? '.' : ''}${cellEditorMeta.dataIndex}` : ''}
- setCellEditorValue(val || '')} - options={{ - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: "on", - fontSize: 14, - tabSize: 2, - automaticLayout: true, - }} - /> + {cellEditorOpen && ( + setCellEditorValue(val || '')} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: "on", + fontSize: 14, + tabSize: 2, + automaticLayout: true, + }} + /> + )}
{/* 批量编辑弹窗 */} @@ -2814,6 +3237,7 @@ const DataGrid: React.FC = ({ title="编辑 JSON 结果集" open={jsonEditorOpen} onCancel={() => setJsonEditorOpen(false)} + destroyOnHidden width={980} maskClosable={false} footer={[ @@ -2825,59 +3249,76 @@ const DataGrid: React.FC = ({
说明:此处按当前结果集顺序编辑,不支持在 JSON 模式增删记录(可在表格模式操作)。
- setJsonEditorValue(val || '')} - options={{ - readOnly: false, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: "off", - fontSize: 12, - tabSize: 2, - automaticLayout: true, - }} - /> + {jsonEditorOpen && ( + setJsonEditorValue(val || '')} + options={{ + readOnly: false, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: "off", + fontSize: 12, + tabSize: 2, + automaticLayout: true, + }} + /> + )} {viewMode === 'table' ? ( -
- - - - { - const k = record?.[GONAVI_ROW_KEY]; - if (k === undefined || k === null) return ''; - const keyStr = rowKeyStr(k); - if (addedRowKeySet.has(keyStr)) return 'row-added'; - if (modifiedRowKeySet.has(keyStr) || deletedRowKeys.has(keyStr)) return 'row-modified'; // deleted won't show - return ''; - }} - onRow={rowPropsFactory} - /> - - - - +
+
+ + + +
+ + + + +
+
+
+
) : viewMode === 'json' ? (
@@ -2996,21 +3437,23 @@ const DataGrid: React.FC = ({ 填充到选中行 ({selectedRowKeys.length})
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 复制为 INSERT -
+ {supportsCopyInsert && ( +
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} + onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} + onClick={() => { + if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record); + setCellContextMenu(prev => ({ ...prev, visible: false })); + }} + > + 复制为 INSERT +
+ )}
= ({ .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-track { background: transparent; } - .${gridId} .ant-table { background: transparent !important; } - .${gridId} .ant-table-container { background: transparent !important; border: none !important; } - .${gridId} .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } + .${gridId} .ant-table, + .${gridId} .ant-table-wrapper, + .${gridId} .ant-table-container { + background: transparent !important; + border-radius: ${panelRadius}px !important; + } + .${gridId} .ant-table-wrapper, + .${gridId} .ant-table-container { + border: none !important; + overflow: hidden !important; + } + .${gridId} .ant-table-tbody > tr > td, + .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } .${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } + .${gridId} .ant-table-thead > tr:first-child > th:first-child, + .${gridId} .ant-table-header table > thead > tr:first-child > th:first-child { + border-top-left-radius: ${panelRadius}px !important; + } + .${gridId} .ant-table-thead > tr:first-child > th:last-child, + .${gridId} .ant-table-header table > thead > tr:first-child > th:last-child { + border-top-right-radius: ${panelRadius}px !important; + } + .${gridId} .ant-table-body { + border-bottom-left-radius: ${panelRadius}px !important; + border-bottom-right-radius: ${panelRadius}px !important; + } .${gridId} .ant-table-thead > tr > th::before { display: none !important; } .${gridId} .ant-table-thead > tr > th .ant-table-column-sorters { cursor: default !important; } .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter, .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; } - .${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; } - .${gridId} .ant-table-tbody > tr.ant-table-row-selected > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)'} !important; } - .${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.25)' : 'rgba(24, 144, 255, 0.12)'} !important; } - .${gridId} .row-added td { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; } - .${gridId} .row-modified td { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; } - .${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${rowAddedHover} !important; } - .${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${rowModHover} !important; } - .${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; } - .${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-cell-selected="true"] { - box-shadow: inset 0 0 0 2px #1890ff; - background-image: linear-gradient(${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'}, ${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'}); + .${gridId} .ant-table-tbody > tr:hover > td, + .${gridId} .ant-table-tbody .ant-table-row:hover > .ant-table-cell { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; } + .${gridId} .ant-table-tbody > tr.ant-table-row-selected > td, + .${gridId} .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.18)` : `rgba(${selectionAccentRgb}, 0.08)`} !important; } + .${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td, + .${gridId} .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.28)` : `rgba(${selectionAccentRgb}, 0.12)`} !important; } + .${gridId} .row-added td, + .${gridId} .row-added > .ant-table-cell { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; } + .${gridId} .row-modified td, + .${gridId} .row-modified > .ant-table-cell { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; } + .${gridId} .ant-table-tbody > tr.row-added:hover > td, + .${gridId} .ant-table-tbody .ant-table-row.row-added:hover > .ant-table-cell { background-color: ${rowAddedHover} !important; } + .${gridId} .ant-table-tbody > tr.row-modified:hover > td, + .${gridId} .ant-table-tbody .ant-table-row.row-modified:hover > .ant-table-cell { background-color: ${rowModHover} !important; } + .${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-col-name], + .${gridId}.cell-edit-mode .ant-table-tbody .ant-table-row > .ant-table-cell[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; } + .${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-cell-selected="true"], + .${gridId}.cell-edit-mode .ant-table-tbody .ant-table-row > .ant-table-cell[data-cell-selected="true"] { + box-shadow: inset 0 0 0 2px ${selectionAccentHex}; + background-image: linear-gradient(${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}, ${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}); } .${gridId} .ant-table-content, .${gridId} .ant-table-body { @@ -3188,13 +3662,103 @@ const DataGrid: React.FC = ({ box-sizing: border-box; scroll-padding-bottom: ${tableBodyBottomPadding}px; } - .${gridId} .ant-table-sticky-scroll { - height: 10px !important; - background: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}; - z-index: 20 !important; + .${gridId} .data-grid-table-wrap { + width: 100%; + max-width: 100%; + overflow: hidden; } - .${gridId} .ant-table-sticky-scroll-bar { - background: ${darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.28)'} !important; + .${gridId} .ant-table-sticky-scroll { + display: none !important; + } + .${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal { + height: ${floatingScrollbarHeight + 4}px !important; + bottom: ${floatingScrollbarGap}px !important; + left: ${floatingScrollbarInset}px !important; + right: ${floatingScrollbarInset}px !important; + background: transparent !important; + visibility: visible !important; + pointer-events: auto !important; + z-index: 24; + } + .${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal .ant-table-tbody-virtual-scrollbar-thumb { + background: ${horizontalScrollbarThumbBg} !important; + border: 1px solid ${horizontalScrollbarThumbBorderColor} !important; + border-radius: 999px !important; + box-shadow: ${horizontalScrollbarThumbShadow} !important; + height: ${floatingScrollbarHeight}px !important; + margin-top: 2px; + } + .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-content { + overflow-x: hidden !important; + } + .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-body { + overflow-x: hidden !important; + overflow-y: auto !important; + } + .${gridId} .ant-table-body { + scrollbar-width: thin; + scrollbar-color: ${floatingScrollbarThumbBg} transparent; + } + .${gridId} .ant-table-body::-webkit-scrollbar { + width: ${floatingScrollbarHeight}px; + height: 0; + } + .${gridId} .ant-table-body::-webkit-scrollbar-track { + background: transparent; + margin: 8px 0; + } + .${gridId} .ant-table-body::-webkit-scrollbar-thumb { + background: ${floatingScrollbarThumbBg}; + border: 1px solid ${floatingScrollbarThumbBorderColor}; + border-radius: 999px; + box-shadow: ${floatingScrollbarThumbShadow}; + } + .${gridId} .rc-virtual-list-holder { + scrollbar-width: thin; + scrollbar-color: ${floatingScrollbarThumbBg} transparent; + } + .${gridId} .rc-virtual-list-holder::-webkit-scrollbar { + width: ${floatingScrollbarHeight}px; + height: 0; + } + .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-track { + background: transparent; + margin: 8px 0; + } + .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb { + background: ${floatingScrollbarThumbBg}; + border: 1px solid ${floatingScrollbarThumbBorderColor}; + border-radius: 999px; + box-shadow: ${floatingScrollbarThumbShadow}; + } + .${gridId} .data-grid-external-hscroll { + position: absolute; + left: ${floatingScrollbarInset}px; + right: ${floatingScrollbarInset}px; + bottom: ${floatingScrollbarGap}px; + height: ${floatingScrollbarHeight + 4}px; + overflow-x: auto; + overflow-y: hidden; + background: transparent; + z-index: 24; + } + .${gridId} .data-grid-external-hscroll::-webkit-scrollbar { + height: ${floatingScrollbarHeight}px; + } + .${gridId} .data-grid-external-hscroll::-webkit-scrollbar-track { + background: ${horizontalScrollbarTrackBg}; + border: 1px solid ${horizontalScrollbarTrackBorderColor}; + border-radius: 999px; + box-shadow: ${horizontalScrollbarTrackShadow}; + } + .${gridId} .data-grid-external-hscroll::-webkit-scrollbar-thumb { + background: ${horizontalScrollbarThumbBg}; + border: 1px solid ${horizontalScrollbarThumbBorderColor}; + border-radius: 999px; + box-shadow: ${horizontalScrollbarThumbShadow}; + } + .${gridId} .data-grid-external-hscroll-inner { + height: 1px; } `} @@ -3207,7 +3771,7 @@ const DataGrid: React.FC = ({ bottom: 0, // Fits container height left: 0, width: '2px', - background: '#1890ff', + background: selectionAccentHex, zIndex: 9999, display: 'none', pointerEvents: 'none', diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index a9af795..8950629 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -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([]); const [columnNames, setColumnNames] = useState([]); @@ -144,12 +187,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const [showFilter, setShowFilter] = useState(false); const [filterConditions, setFilterConditions] = useState([]); - 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>({}); + 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} diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index b198d5b..bba7618 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -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; 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 = ; + 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(null); + const [searchKeyword, setSearchKeyword] = useState(''); const [rows, setRows] = useState([]); const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' }); const [progressMap, setProgressMap] = useState>({}); @@ -164,6 +190,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ const [versionLoadingMap, setVersionLoadingMap] = useState>({}); const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({}); 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, 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 }> = ({ 除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。 {networkStatus ? ( - - - 驱动下载依赖 GitHub 与 Go 模块代理网络。若检测失败,建议先启用 HTTP/HTTPS/SOCKS5 代理后重试。 - + networkUnreachable ? ( + + {showDownloadChainAlert ? ( + <> + + 当前可能能访问 GitHub 页面,但驱动包下载会跳转到资产域名。 + 请优先在 GoNavi 顶部“代理”中启用全局代理(填写代理应用本地地址和端口)。 + + {onOpenGlobalProxySettings ? ( + + ) : null} + + 若仍失败,请在代理规则放行:{downloadRequiredHostText};仍无法调整规则时,再考虑开启 TUN 模式。 + + + ) : ( + {networkStatus.summary} + )} + {proxyEnvEntries.length > 0 ? ( + + 检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')} + + ) : null} + + )} + /> + ) : ( + void }> = ({ label: '查看网络检测明细', children: ( - {networkStatus.checks.map((item) => ( - - {item.name}:{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `,HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `,${item.latencyMs}ms` : ''}{item.error ? `,${item.error}` : ''} - - ))} + + 代理链路到 GitHub 连通性延迟:{githubConnectivityProbe ? (githubConnectivityProbe.reachable ? '可达' : '不可达') : '暂无结果'} + {githubConnectivityLatencyMs !== undefined ? `,${githubConnectivityLatencyMs}ms` : ''} + {githubConnectivityProbe?.error ? `,${githubConnectivityProbe.error}` : ''} + {proxyEnvEntries.length > 0 ? ( 检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')} @@ -1163,34 +1314,58 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }, ]} /> - - )} - /> + )} + /> + ) ) : ( - + )} - 自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。 - 行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。 - - 驱动根目录:{downloadDir || '-'} - - {networkStatus?.logPath ? ( - - 运行日志文件:{networkStatus.logPath} - - ) : null} - + + 自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。 + 行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。 + + 驱动根目录:{downloadDir || '-'} + + {networkStatus?.logPath ? ( + + 运行日志文件:{networkStatus.logPath} + + ) : null} + + ), + }, + ]} + /> )} /> - +
+ setSearchKeyword(event.target.value)} + style={{ minWidth: 300, flex: '1 1 360px' }} + /> 覆盖已安装 void }> = ({ onChange={(checked) => setForceOverwriteInstalled(checked)} disabled={batchDirectoryImporting} /> + - - +
+ {filterSummaryText}
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()}”的驱动` + : '暂无驱动数据', + }} />
diff --git a/frontend/src/components/LogPanel.tsx b/frontend/src/components/LogPanel.tsx index c67ccf8..6c57d61 100644 --- a/frontend/src/components/LogPanel.tsx +++ b/frontend/src/components/LogPanel.tsx @@ -27,8 +27,9 @@ const LogPanel: React.FC = ({ 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 = ({ height, onClose, onResizeStart }) = title: 'Time', dataIndex: 'timestamp', width: 80, - render: (ts: number) => {new Date(ts).toLocaleTimeString()} + render: (ts: number) => {new Date(ts).toLocaleTimeString()} }, { title: 'Status', @@ -62,7 +63,7 @@ const LogPanel: React.FC = ({ height, onClose, onResizeStart }) =
{text}
{record.message &&
{record.message}
} - {record.affectedRows !== undefined &&
Affected: {record.affectedRows}
} + {record.affectedRows !== undefined &&
Affected: {record.affectedRows}
}
) } @@ -71,7 +72,7 @@ const LogPanel: React.FC = ({ height, onClose, onResizeStart }) = return (
= ({ height, onClose, onResizeStart }) = {/* Toolbar */}
= ({ 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([]); // 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 /> + + + + + {connections.map(conn => ( + + {conn.name} {conn.config.host ? `(${conn.config.host})` : ''} + + ))} + + + + + + { return result; }; +const sanitizeConnectionTags = (value: unknown): ConnectionTag[] => { + if (!Array.isArray(value)) return []; + const result: ConnectionTag[] = []; + const idSet = new Set(); + + value.forEach((entry, index) => { + if (!entry || typeof entry !== 'object') return; + const raw = entry as Record; + const id = toTrimmedString(raw.id, `tag-${index + 1}`) || `tag-${index + 1}`; + if (idSet.has(id)) return; + idSet.add(id); + + const name = toTrimmedString(raw.name, `标签-${index + 1}`) || `标签-${index + 1}`; + const connectionIds = sanitizeStringArray(raw.connectionIds, 256); + + result.push({ id, name, connectionIds }); + }); + + return result; +}; + const isLegacyDefaultAppearance = (appearance: Partial<{ opacity: number; blur: number }> | undefined): boolean => { if (!appearance) { return true; @@ -325,6 +346,7 @@ export interface GlobalProxyConfig extends ProxyConfig { interface AppState { connections: SavedConnection[]; + connectionTags: ConnectionTag[]; tabs: TabData[]; activeTabId: string | null; activeContext: { connectionId: string; dbName: string } | null; @@ -345,6 +367,12 @@ interface AppState { updateConnection: (conn: SavedConnection) => void; removeConnection: (id: string) => void; + addConnectionTag: (tag: ConnectionTag) => void; + updateConnectionTag: (tag: ConnectionTag) => void; + removeConnectionTag: (id: string) => void; + moveConnectionToTag: (connectionId: string, targetTagId: string | null) => void; + reorderTags: (tagIds: string[]) => void; + addTab: (tab: TabData) => void; closeTab: (id: string) => void; closeOtherTabs: (id: string) => void; @@ -496,6 +524,7 @@ export const useStore = create()( persist( (set) => ({ connections: [], + connectionTags: [], tabs: [], activeTabId: null, activeContext: null, @@ -516,7 +545,46 @@ export const useStore = create()( updateConnection: (conn) => set((state) => ({ connections: state.connections.map(c => c.id === conn.id ? conn : c) })), - removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })), + removeConnection: (id) => set((state) => ({ + connections: state.connections.filter(c => c.id !== id), + connectionTags: state.connectionTags.map(tag => ({ + ...tag, + connectionIds: tag.connectionIds.filter(cid => cid !== id) + })) + })), + + addConnectionTag: (tag) => set((state) => ({ connectionTags: [...state.connectionTags, tag] })), + updateConnectionTag: (tag) => set((state) => ({ + connectionTags: state.connectionTags.map(t => t.id === tag.id ? tag : t) + })), + removeConnectionTag: (id) => set((state) => ({ + connectionTags: state.connectionTags.filter(t => t.id !== id) + })), + moveConnectionToTag: (connectionId, targetTagId) => set((state) => { + const newTags = state.connectionTags.map(tag => { + //先从所有tag中移除该connection + const filteredIds = tag.connectionIds.filter(id => id !== connectionId); + if (tag.id === targetTagId) { + return { ...tag, connectionIds: [...filteredIds, connectionId] }; + } + return { ...tag, connectionIds: filteredIds }; + }); + return { connectionTags: newTags }; + }), + reorderTags: (tagIds) => set((state) => { + const tagMap = new Map(state.connectionTags.map(t => [t.id, t])); + const newTags: ConnectionTag[] = []; + tagIds.forEach(id => { + const tag = tagMap.get(id); + if (tag) { + newTags.push(tag); + tagMap.delete(id); + } + }); + // 追加未指定的tag(如果有的话) + newTags.push(...Array.from(tagMap.values())); + return { connectionTags: newTags }; + }), addTab: (tab) => set((state) => { const index = state.tabs.findIndex(t => t.id === tab.id); @@ -672,6 +740,11 @@ export const useStore = create()( const state = unwrapPersistedAppState(persistedState) as Partial; const nextState: Partial = { ...state }; nextState.connections = sanitizeConnections(state.connections); + if (version < 5) { + nextState.connectionTags = sanitizeConnectionTags(state.connectionTags); + } else { + nextState.connectionTags = sanitizeConnectionTags(state.connectionTags); + } nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); nextState.theme = sanitizeTheme(state.theme); nextState.appearance = sanitizeAppearance(state.appearance, version); @@ -691,6 +764,7 @@ export const useStore = create()( ...currentState, ...state, connections: sanitizeConnections(state.connections), + connectionTags: sanitizeConnectionTags(state.connectionTags), savedQueries: sanitizeSavedQueries(state.savedQueries), theme: sanitizeTheme(state.theme), appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION), @@ -706,6 +780,7 @@ export const useStore = create()( }, partialize: (state) => ({ connections: state.connections, + connectionTags: state.connectionTags, savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2bc8dac..d0cb31b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -61,6 +61,12 @@ export interface SavedConnection { includeRedisDatabases?: number[]; // Redis databases to show (0-15) } +export interface ConnectionTag { + id: string; + name: string; + connectionIds: string[]; +} + export interface ColumnDefinition { name: string; type: string; diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index bd98ace..ae2bdfe 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -51,12 +51,13 @@ type UpdateInfo struct { } type AppInfo struct { - Version string `json:"version"` - Author string `json:"author"` - RepoURL string `json:"repoUrl,omitempty"` - IssueURL string `json:"issueUrl,omitempty"` - ReleaseURL string `json:"releaseUrl,omitempty"` - BuildTime string `json:"buildTime,omitempty"` + Version string `json:"version"` + Author string `json:"author"` + RepoURL string `json:"repoUrl,omitempty"` + IssueURL string `json:"issueUrl,omitempty"` + ReleaseURL string `json:"releaseUrl,omitempty"` + CommunityURL string `json:"communityUrl,omitempty"` + BuildTime string `json:"buildTime,omitempty"` } type updateDownloadResult struct { @@ -137,12 +138,13 @@ func (a *App) CheckForUpdates() connection.QueryResult { func (a *App) GetAppInfo() connection.QueryResult { info := AppInfo{ - Version: getCurrentVersion(), - Author: getCurrentAuthor(), - RepoURL: "https://github.com/" + updateRepo, - IssueURL: "https://github.com/" + updateRepo + "/issues", - ReleaseURL: "https://github.com/" + updateRepo + "/releases", - BuildTime: strings.TrimSpace(AppBuildTime), + Version: getCurrentVersion(), + Author: getCurrentAuthor(), + RepoURL: "https://github.com/" + updateRepo, + IssueURL: "https://github.com/" + updateRepo + "/issues", + ReleaseURL: "https://github.com/" + updateRepo + "/releases", + CommunityURL: "https://aibook.ren", + BuildTime: strings.TrimSpace(AppBuildTime), } return connection.QueryResult{Success: true, Message: "OK", Data: info} } From 457051667803af56478a3661b9e6cb41ecad03b2 Mon Sep 17 00:00:00 2001 From: Toskysun <3ssa@163.com> Date: Wed, 4 Mar 2026 13:54:51 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=E8=A1=A8=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=B8=80=E9=94=AE=E5=AF=BC=E5=87=BA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 chore(gitignore): 忽略 AI 上下文文档避免版本控制污染 添加 CLAUDE.md 及其子目录变体到 .gitignore,防止 AI 辅助开发过程中生成的临时上下文文件被意外提交到仓库。 - 忽略根目录 CLAUDE.md - 忽略所有子目录下的 CLAUDE.md 文件 * feat: 表筛选结果一键导出功能 - 新增表浏览模式下筛选结果的导出功能 - DataViewer 生成包含筛选条件的完整 SQL - DataGrid 动态显示分组导出菜单(筛选结果 + 全表) - 支持 CSV、Excel、JSON、Markdown 四种格式 - 添加未提交修改的警告提示 - 复用现有 ExportQuery 后端方法,无需后端修改 实现细节: - 使用 buildWhereSQL 和 buildOrderBySQL 构建 SQL - 支持 MySQL/MariaDB 的 sort buffer 优化 - 分组菜单设计避免用户误操作 - 导出文件名包含 _filtered 后缀 关闭 #issue --- .gitignore | 3 ++ frontend/src/components/DataGrid.tsx | 40 ++++++++++++++++++++++++-- frontend/src/components/DataViewer.tsx | 21 +++++++++++++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 3bea14c..12d734c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ GoNavi-Wails.exe .ace-tool/ .claude/ tmpclaude-* + +CLAUDE.md +**/CLAUDE.md diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 3e4fb90..24f265c 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -577,6 +577,7 @@ interface DataGridProps { // Filtering showFilter?: boolean; onToggleFilter?: () => void; + exportSqlWithFilter?: string; onApplyFilter?: (conditions: GridFilterCondition[]) => void; } @@ -595,9 +596,9 @@ type ColumnMeta = { comment: string; }; -const DataGrid: React.FC = ({ +const DataGrid: React.FC = ({ data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false, - onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter + onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter }) => { const connections = useStore(state => state.connections); const addSqlLog = useStore(state => state.addSqlLog); @@ -620,6 +621,8 @@ const DataGrid: React.FC = ({ const isQueryResultExport = exportScope === 'queryResult'; const canImport = exportScope === 'table' && !!tableName; const canExport = !!connectionId && (isQueryResultExport || !!tableName); + const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]); + const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0; // Background Helper const getBg = (darkHex: string) => { @@ -2481,6 +2484,23 @@ const DataGrid: React.FC = ({ }); }; + const handleExportFilteredAll = async (format: string) => { + if (!connectionId || !tableName) return; + if (!filteredExportSql) { + message.warning('当前未应用筛选条件'); + return; + } + if (!supportsSqlQueryExport) { + message.error('当前数据源不支持按筛选结果导出'); + return; + } + if (hasChanges) { + message.warning("当前存在未提交修改,筛选结果导出基于数据库已提交数据。"); + } + + await exportByQuery(filteredExportSql, format, `${tableName || 'export'}_filtered`); + }; + const handleImport = async () => { if (!connectionId || !tableName) return; const config = buildConnConfig(); @@ -2562,7 +2582,21 @@ const DataGrid: React.FC = ({ if (onApplyFilter) onApplyFilter(filterConditions); }; - const exportMenu: MenuProps['items'] = [ + const exportMenu: MenuProps['items'] = hasFilteredExportSql ? [ + { type: 'group', label: '筛选结果', children: [ + { key: 'filtered-csv', label: 'CSV', onClick: () => handleExportFilteredAll('csv') }, + { key: 'filtered-xlsx', label: 'Excel (XLSX)', onClick: () => handleExportFilteredAll('xlsx') }, + { key: 'filtered-json', label: 'JSON', onClick: () => handleExportFilteredAll('json') }, + { key: 'filtered-md', label: 'Markdown', onClick: () => handleExportFilteredAll('md') }, + ]}, + { type: 'divider' }, + { type: 'group', label: '全表', children: [ + { key: 'table-csv', label: 'CSV', onClick: () => handleExport('csv') }, + { key: 'table-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') }, + { key: 'table-json', label: 'JSON', onClick: () => handleExport('json') }, + { key: 'table-md', label: 'Markdown', onClick: () => handleExport('md') }, + ]}, + ] : [ { key: 'csv', label: 'CSV', onClick: () => handleExport('csv') }, { key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') }, { key: 'json', label: 'JSON', onClick: () => handleExport('json') }, diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 8950629..9b81b5e 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { message } from 'antd'; import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; @@ -676,6 +676,24 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []); const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []); + const exportSqlWithFilter = useMemo(() => { + const tableName = String(tab.tableName || '').trim(); + const dbType = String(currentConnConfig?.type || '').trim(); + if (!tableName || !dbType) return ''; + + const whereSQL = buildWhereSQL(dbType, filterConditions); + if (!whereSQL) return ''; + + let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; + sql += buildOrderBySQL(dbType, sortInfo, pkColumns); + const normalizedType = dbType.toLowerCase(); + const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend'); + if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { + sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); + } + return sql; + }, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]); + useEffect(() => { fetchData(1, pagination.pageSize); }, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter @@ -702,6 +720,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { onApplyFilter={handleApplyFilter} readOnly={forceReadOnly} sortInfoExternal={sortInfo} + exportSqlWithFilter={exportSqlWithFilter || undefined} />
); From e6da9869272cc8a05499bb0fc138d3da3862113d Mon Sep 17 00:00:00 2001 From: Toskysun <3kddyys@gmail.com> Date: Wed, 4 Mar 2026 17:46:18 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20HTML=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=20(#164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:在 writeRowsToFile 中新增 html 分支 - 后端:实现 writeRowsToHTML 函数,生成包含内嵌 CSS 的独立 HTML 文件 - 后端:实现 formatExportHTMLCell 函数,进行 HTML 转义和换行处理 - 后端:新增测试用例验证 XSS 转义、样式存在、换行处理、空值显示 - 前端:在 DataGrid 所有导出菜单中新增 HTML 选项(右键菜单、工具栏、单元格菜单) - 前端:在 Sidebar 表节点右键菜单中新增 HTML 选项 - 样式:响应式表格设计,支持斑马纹、悬停效果、表头吸顶 - 安全:所有用户数据经过 HTML 转义,防止 XSS 攻击 --- frontend/src/components/DataGrid.tsx | 19 +++ frontend/src/components/Sidebar.tsx | 1 + internal/app/methods_file.go | 188 +++++++++++++++++++++++ internal/app/methods_file_export_test.go | 70 +++++++++ 4 files changed, 278 insertions(+) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 24f265c..89dd364 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -537,6 +537,7 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { { key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected('xlsx', record) }, { key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected('json', record) }, { key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected('md', record) }, + { key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected('html', record) }, ] } ]; @@ -2588,6 +2589,7 @@ const DataGrid: React.FC = ({ { key: 'filtered-xlsx', label: 'Excel (XLSX)', onClick: () => handleExportFilteredAll('xlsx') }, { key: 'filtered-json', label: 'JSON', onClick: () => handleExportFilteredAll('json') }, { key: 'filtered-md', label: 'Markdown', onClick: () => handleExportFilteredAll('md') }, + { key: 'filtered-html', label: 'HTML', onClick: () => handleExportFilteredAll('html') }, ]}, { type: 'divider' }, { type: 'group', label: '全表', children: [ @@ -2595,12 +2597,14 @@ const DataGrid: React.FC = ({ { key: 'table-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') }, { key: 'table-json', label: 'JSON', onClick: () => handleExport('json') }, { key: 'table-md', label: 'Markdown', onClick: () => handleExport('md') }, + { key: 'table-html', label: 'HTML', onClick: () => handleExport('html') }, ]}, ] : [ { key: 'csv', label: 'CSV', onClick: () => handleExport('csv') }, { key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') }, { key: 'json', label: 'JSON', onClick: () => handleExport('json') }, { key: 'md', label: 'Markdown', onClick: () => handleExport('md') }, + { key: 'html', label: 'HTML', onClick: () => handleExport('html') }, ]; const columnInfoSettingContent = ( @@ -3572,6 +3576,21 @@ const DataGrid: React.FC = ({ > 导出为 JSON
+
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} + onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} + onClick={() => { + if (cellContextMenu.record) handleExportSelected('html', cellContextMenu.record); + setCellContextMenu(prev => ({ ...prev, visible: false })); + }} + > + 导出为 HTML +
, document.body )} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 981dc28..b76fa13 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2806,6 +2806,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') }, { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') }, { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') }, + { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(node, 'html') }, ] } ]; diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 2bdbce5..a68620e 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -6,6 +6,7 @@ import ( "encoding/csv" "encoding/json" "fmt" + "html" "math" "os" "path/filepath" @@ -1595,6 +1596,11 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string return writeRowsToXlsx(f.Name(), data, columns) } + // html 使用内嵌 CSS 输出可直接浏览器预览的独立页面 + if format == "html" { + return writeRowsToHTML(f, data, columns) + } + var csvWriter *csv.Writer var jsonEncoder *json.Encoder isJsonFirstRow := true @@ -1688,6 +1694,188 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string return nil } +func formatExportHTMLCell(val interface{}) string { + text := formatExportCellText(val) + escaped := html.EscapeString(text) + escaped = strings.ReplaceAll(escaped, "\r\n", "\n") + escaped = strings.ReplaceAll(escaped, "\r", "\n") + return strings.ReplaceAll(escaped, "\n", "
") +} + +func writeRowsToHTML(f *os.File, data []map[string]interface{}, columns []string) error { + w := bufio.NewWriterSize(f, 1024*256) + + if _, err := w.WriteString(` + + + + + GoNavi Export + + + +
+
+

GoNavi Data Export

+
`); err != nil { + return err + } + + if _, err := fmt.Fprintf(w, "Rows: %d · Columns: %d · Generated: %s", len(data), len(columns), time.Now().Format("2006-01-02 15:04:05")); err != nil { + return err + } + + if _, err := w.WriteString(`
+
+
+
+ `); err != nil { + return err + } + + for _, col := range columns { + if _, err := fmt.Fprintf(w, "", html.EscapeString(col)); err != nil { + return err + } + } + + if _, err := w.WriteString(``); err != nil { + return err + } + + if len(data) == 0 { + colspan := len(columns) + if colspan <= 0 { + colspan = 1 + } + if _, err := fmt.Fprintf(w, ``, colspan); err != nil { + return err + } + } else { + for _, rowMap := range data { + if _, err := w.WriteString(""); err != nil { + return err + } + for _, col := range columns { + if _, err := fmt.Fprintf(w, "", formatExportHTMLCell(rowMap[col])); err != nil { + return err + } + } + if _, err := w.WriteString(""); err != nil { + return err + } + } + } + + if _, err := w.WriteString(`
%s
(0 rows)
%s
+
+
+ +`); err != nil { + return err + } + + return w.Flush() +} + func formatExportCellText(val interface{}) string { if val == nil { return "NULL" diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go index 6a0b1b4..5ddaf9c 100644 --- a/internal/app/methods_file_export_test.go +++ b/internal/app/methods_file_export_test.go @@ -203,3 +203,73 @@ func TestGetExportQueryTimeout_CustomClickHouseUsesLongerMinimum(t *testing.T) { t.Fatalf("custom clickhouse 导出超时下限异常,want=%s got=%s", minClickHouseExportQueryTimeout, timeout) } } + +func TestWriteRowsToFile_HTML_EscapeAndStyle(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-export-*.html") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + data := []map[string]interface{}{ + { + "name": "", + "note": "line1\nline2", + "nullable": nil, + }, + } + columns := []string{"name", "note", "nullable"} + + if err := writeRowsToFile(f, data, columns, "html"); err != nil { + t.Fatalf("写入 html 失败: %v", err) + } + + contentBytes, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatalf("读取 html 失败: %v", err) + } + content := string(contentBytes) + + if !strings.Contains(content, "") { + t.Fatalf("html 导出缺少 doctype: %s", content) + } + if !strings.Contains(content, "position: sticky") { + t.Fatalf("html 导出缺少表头吸顶样式: %s", content) + } + if !strings.Contains(content, "tbody tr:nth-child(even)") { + t.Fatalf("html 导出缺少斑马纹样式: %s", content) + } + if !strings.Contains(content, "<script>alert(1)</script>") { + t.Fatalf("html 导出未进行 XSS 转义: %s", content) + } + if strings.Contains(content, "") { + t.Fatalf("html 导出包含未转义脚本: %s", content) + } + if !strings.Contains(content, "line1
line2") { + t.Fatalf("html 导出换行未转为
: %s", content) + } + if !strings.Contains(content, "NULL") { + t.Fatalf("html 导出空值显示异常: %s", content) + } +} + +func TestWriteRowsToFile_HTML_EscapeHeader(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-export-*.html") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + columnName := "name" + data := []map[string]interface{}{{columnName: "ok"}} + if err := writeRowsToFile(f, data, []string{columnName}, "html"); err != nil { + t.Fatalf("写入 html 失败: %v", err) + } + contentBytes, _ := os.ReadFile(f.Name()) + content := string(contentBytes) + if !strings.Contains(content, "<b>name</b>") || strings.Contains(content, "name") { + t.Fatalf("html 表头未正确转义: %s", content) + } +} From f372b20a68255539ade0e1de8f17ef2ca2651f6b Mon Sep 17 00:00:00 2001 From: ljyf5593 Date: Thu, 5 Mar 2026 12:01:58 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=E7=94=9F=E6=88=90=E7=A9=BA?= =?UTF-8?q?JSON=E6=95=B0=E7=BB=84=E7=9A=84=E9=97=AE=E9=A2=98=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: liujie <469282686@qq.com> --- frontend/src/App.tsx | 2 +- internal/app/methods_file.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18092e2..f2deb44 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -666,7 +666,7 @@ function App() { void message.warning("没有连接可导出"); return; } - const res = await (window as any).go.app.App.ExportData(connections, [], "connections", "json"); + const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases'], "connections", "json"); if (res.success) { void message.success("导出成功"); } else if (res.message !== "Cancelled") { diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index a68620e..9e5fc1b 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -1601,6 +1601,21 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string return writeRowsToHTML(f, data, columns) } + // 如果列名为空但数据不为空,从所有数据行提取所有键 + if len(columns) == 0 && len(data) > 0 { + keySet := make(map[string]bool) + for _, row := range data { + for key := range row { + keySet[key] = true + } + } + // 排序以确保输出一致 + for key := range keySet { + columns = append(columns, key) + } + sort.Strings(columns) + } + var csvWriter *csv.Writer var jsonEncoder *json.Encoder isJsonFirstRow := true From 69942bb77eadeae399438c049f4e0d04336df330 Mon Sep 17 00:00:00 2001 From: ljyf5593 Date: Thu, 5 Mar 2026 15:28:34 +0800 Subject: [PATCH 8/9] =?UTF-8?q?*=20feat:=20SQL=E6=89=A7=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=8F=96=E6=B6=88=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=8A=9F=E8=83=BD=20(#172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: liujie <469282686@qq.com> --- frontend/src/components/QueryEditor.tsx | 104 ++++++++++++++++- frontend/wailsjs/go/app/App.d.ts | 9 ++ frontend/wailsjs/go/app/App.js | 16 +++ frontend/wailsjs/go/models.ts | 2 + go.mod | 2 +- internal/app/app.go | 61 +++++++++- internal/app/methods_db.go | 34 +++++- internal/app/methods_db_cancel_test.go | 149 ++++++++++++++++++++++++ internal/app/methods_driver.go | 14 +-- internal/connection/types.go | 1 + 10 files changed, 367 insertions(+), 25 deletions(-) create mode 100644 internal/app/methods_db_cancel_test.go diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 2d6a36e..16a15a9 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1,11 +1,12 @@ 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'; +import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined } from '@ant-design/icons'; import { format } from 'sql-formatter'; +import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; -import { DBQuery, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App'; +import { DBQuery, DBQueryWithCancel, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; @@ -30,7 +31,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [activeResultKey, setActiveResultKey] = useState(''); const [loading, setLoading] = useState(false); + const [currentQueryId, setCurrentQueryId] = useState(''); const runSeqRef = useRef(0); + const currentQueryIdRef = useRef(''); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); const [saveForm] = Form.useForm(); @@ -186,6 +189,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { fetchMetadata(); }, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载 + // Query ID management helpers + const setQueryId = (id: string) => { + currentQueryIdRef.current = id; + setCurrentQueryId(id); + }; + + const clearQueryId = () => { + currentQueryIdRef.current = ''; + setCurrentQueryId(''); + }; + // Handle Resizing const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); @@ -984,6 +998,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { message.error("请先选择数据库"); return; } + // 如果已有查询在运行,先取消它 + if (currentQueryIdRef.current) { + try { + await CancelQuery(currentQueryIdRef.current); + } catch (error) { + // 忽略取消错误,可能查询已完成 + } + // 清除旧查询ID + clearQueryId(); + } const runSeq = ++runSeqRef.current; setLoading(true); const runStartTime = Date.now(); @@ -1037,7 +1061,18 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const limited = limitApplied ? applyAutoLimit(rawStatement, dbType, probeLimit) : { sql: rawStatement, applied: false, maxRows: probeLimit }; const executedSql = limited.sql; const startTime = Date.now(); - const res = await DBQuery(config as any, currentDb, executedSql); + + // Generate query ID for cancellation using backend UUID with fallback + let queryId: string; + try { + queryId = await GenerateQueryID(); + } catch (error) { + console.warn('GenerateQueryID failed, using local UUID fallback:', error); + queryId = 'query-' + uuidv4(); + } + setQueryId(queryId); + + const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId); const duration = Date.now() - startTime; addSqlLog({ @@ -1052,6 +1087,32 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }); if (!res.success) { + // 检查是否为查询取消错误 + const errorMsg = res.message.toLowerCase(); + const isCancelledError = errorMsg.includes('context canceled') || + errorMsg.includes('查询已取消') || + errorMsg.includes('canceled') || + errorMsg.includes('cancelled') || + errorMsg.includes('statement canceled') || + errorMsg.includes('sql: statement canceled'); + + // 确保不是超时错误 + const isTimeoutError = errorMsg.includes('context deadline exceeded') || + errorMsg.includes('timeout') || + errorMsg.includes('超时') || + errorMsg.includes('deadline exceeded'); + + if (isCancelledError && !isTimeoutError) { + // 查询已被用户取消,不显示错误消息,清理状态 + setResultSets([]); + setActiveResultKey(''); + // 清除查询ID,与handleCancel保持一致 + if (currentQueryIdRef.current) { + clearQueryId(); + } + return; + } + const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; message.error(prefix + res.message); setResultSets([]); @@ -1157,6 +1218,30 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { setActiveResultKey(''); } finally { if (runSeqRef.current === runSeq) setLoading(false); + // Clear query ID after execution completes + clearQueryId(); + } + }; + + const handleCancel = async () => { + if (!currentQueryIdRef.current) { + message.warning('没有正在运行的查询可取消'); + return; + } + const queryIdToCancel = currentQueryIdRef.current; + try { + const res = await CancelQuery(queryIdToCancel); + if (res.success) { + message.success('查询已取消'); + // Clear query ID after successful cancellation + if (currentQueryIdRef.current === queryIdToCancel) { + clearQueryId() + } + } else { + message.warning(res.message); + } + } catch (error: any) { + message.error('取消查询失败: ' + error.message); } }; @@ -1271,9 +1356,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { ]} /> - + + + {loading && ( + + )} +
+ { + setIsShortcutModalOpen(false); + setCapturingShortcutAction(null); + }} + width={720} + footer={[ + , + , + ]} + > +
+
+ 点击“录制”后按下快捷键。按 Esc 可取消录制。建议至少包含一个修饰键(Ctrl/Alt/Shift/Meta)。 +
+ {SHORTCUT_ACTION_ORDER.map((action) => { + const meta = SHORTCUT_ACTION_META[action]; + const binding = shortcutOptions[action] ?? { combo: '', enabled: false }; + const isCapturing = capturingShortcutAction === action; + return ( +
+
+
{meta.label}
+
{meta.description}
+
+
+ + + updateShortcut(action, { enabled: checked })} + /> +
+
+ ); + })} +
+
= { vastbase: ['vastbase'], }; +const sslSupportedTypes = new Set([ + 'mysql', + 'mariadb', + 'diros', + 'sphinx', + 'dameng', + 'clickhouse', + 'postgres', + 'sqlserver', + 'oracle', + 'kingbase', + 'highgo', + 'vastbase', + 'mongodb', + 'redis', + 'tdengine', +]); + +const supportsSSLForType = (type: string) => sslSupportedTypes.has(String(type || '').trim().toLowerCase()); + const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb'; type DriverStatusSnapshot = { @@ -78,6 +98,7 @@ const ConnectionModal: React.FC<{ }> = ({ open, onClose, initialValues, onOpenDriverManager }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); + const [useSSL, setUseSSL] = useState(false); const [useSSH, setUseSSH] = useState(false); const [useProxy, setUseProxy] = useState(false); const [dbType, setDbType] = useState('mysql'); @@ -107,6 +128,17 @@ const ConnectionModal: React.FC<{ const mongoTopology = Form.useWatch('mongoTopology', form) || 'single'; const mongoSrv = Form.useWatch('mongoSrv', form) || false; const redisTopology = Form.useWatch('redisTopology', form) || 'single'; + const isMySQLLike = dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx'; + const isSSLType = supportsSSLForType(dbType); + const sslHintText = isMySQLLike + ? '当 MySQL/MariaDB/Doris/Sphinx 开启安全传输策略时,请启用 SSL;本地自签证书场景可先用 Preferred 或 Skip Verify。' + : dbType === 'dameng' + ? '达梦驱动启用 SSL 需要客户端证书与私钥路径(sslCertPath / sslKeyPath)。' + : dbType === 'sqlserver' + ? 'SQL Server 推荐在生产环境使用 Required,并关闭 TrustServerCertificate。' + : dbType === 'mongodb' + ? 'MongoDB 可通过 TLS 保护连接,证书校验异常时可先用 Skip Verify 验证连通性。' + : '建议优先使用 Required;仅在测试环境或自签证书场景使用 Skip Verify。'; const getSectionBg = (darkHex: string) => { if (!darkMode) { @@ -364,7 +396,7 @@ const ConnectionModal: React.FC<{ uriText: string, expectedSchemes: string[], defaultPort: number, - ): { host: string; port: number; username: string; password: string; database: string } | null => { + ): { host: string; port: number; username: string; password: string; database: string; params: URLSearchParams } | null => { let parsed: ReturnType | null = null; for (const scheme of expectedSchemes) { parsed = parseMultiHostUri(uriText, scheme); @@ -392,6 +424,7 @@ const ConnectionModal: React.FC<{ username: parsed.username, password: parsed.password, database: parsed.database || '', + params: parsed.params, }; }; @@ -425,12 +458,22 @@ const ConnectionModal: React.FC<{ const primary = parseHostPort(hostList[0] || `localhost:${mysqlDefaultPort}`, mysqlDefaultPort); const timeoutValue = Number(parsed.params.get('timeout')); const topology = String(parsed.params.get('topology') || '').toLowerCase(); + const tlsValue = String(parsed.params.get('tls') || '').trim().toLowerCase(); + const sslMode = tlsValue === 'true' + ? 'required' + : tlsValue === 'skip-verify' + ? 'skip-verify' + : tlsValue === 'preferred' + ? 'preferred' + : 'disable'; return { host: primary?.host || 'localhost', port: primary?.port || mysqlDefaultPort, user: parsed.username, password: parsed.password, database: parsed.database || '', + useSSL: sslMode !== 'disable', + sslMode, mysqlTopology: hostList.length > 1 || topology === 'replica' ? 'replica' : 'single', mysqlReplicaHosts: hostList.slice(1), timeout: Number.isFinite(timeoutValue) && timeoutValue > 0 @@ -451,7 +494,7 @@ const ConnectionModal: React.FC<{ } if (type === 'redis') { - const parsed = parseMultiHostUri(trimmedUri, 'redis'); + const parsed = parseMultiHostUri(trimmedUri, 'redis') || parseMultiHostUri(trimmedUri, 'rediss'); if (!parsed) { return null; } @@ -469,10 +512,15 @@ const ConnectionModal: React.FC<{ const topologyParam = String(parsed.params.get('topology') || '').toLowerCase(); const dbText = String(parsed.database || '').trim().replace(/^\//, ''); const dbIndex = Number(dbText); + const isRediss = trimmedUri.toLowerCase().startsWith('rediss://'); + const skipVerifyText = String(parsed.params.get('skip_verify') || '').trim().toLowerCase(); + const skipVerify = skipVerifyText === '1' || skipVerifyText === 'true' || skipVerifyText === 'yes' || skipVerifyText === 'on'; return { host: primary?.host || 'localhost', port: primary?.port || 6379, password: parsed.password || '', + useSSL: isRediss, + sslMode: isRediss ? (skipVerify ? 'skip-verify' : 'required') : 'disable', redisTopology: hostList.length > 1 || topologyParam === 'cluster' ? 'cluster' : 'single', redisHosts: hostList.slice(1), redisDB: Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 ? Math.trunc(dbIndex) : 0, @@ -501,12 +549,18 @@ const ConnectionModal: React.FC<{ ? { host: hostList[0] || 'localhost', port: 27017 } : parseHostPort(hostList[0] || 'localhost:27017', 27017); const timeoutMs = Number(parsed.params.get('connectTimeoutMS') || parsed.params.get('serverSelectionTimeoutMS')); + const tlsText = String(parsed.params.get('tls') || parsed.params.get('ssl') || '').trim().toLowerCase(); + const tlsInsecureText = String(parsed.params.get('tlsInsecure') || parsed.params.get('sslInsecure') || '').trim().toLowerCase(); + const tlsEnabled = tlsText === '1' || tlsText === 'true' || tlsText === 'yes' || tlsText === 'on'; + const tlsInsecure = tlsInsecureText === '1' || tlsInsecureText === 'true' || tlsInsecureText === 'yes' || tlsInsecureText === 'on'; return { host: primary?.host || 'localhost', port: primary?.port || 27017, user: parsed.username, password: parsed.password, database: parsed.database || '', + useSSL: tlsEnabled, + sslMode: tlsEnabled ? (tlsInsecure ? 'skip-verify' : 'required') : 'disable', mongoTopology: hostList.length > 1 || !!parsed.params.get('replicaSet') ? 'replica' : 'single', mongoHosts: hostList.slice(1), mongoSrv: isSrv, @@ -531,13 +585,94 @@ const ConnectionModal: React.FC<{ // Oracle 需要显式 service name,避免 URI 解析后放过必填校验。 return null; } - return { + const parsedValues: Record = { host: parsed.host, port: parsed.port, user: parsed.username, password: parsed.password, database: parsed.database, }; + + if (supportsSSLForType(type)) { + const normalizeBool = (raw: unknown) => { + const text = String(raw ?? '').trim().toLowerCase(); + return text === '1' || text === 'true' || text === 'yes' || text === 'on'; + }; + if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') { + const sslMode = String(parsed.params.get('sslmode') || '').trim().toLowerCase(); + if (sslMode) { + parsedValues.useSSL = sslMode !== 'disable' && sslMode !== 'false'; + parsedValues.sslMode = sslMode === 'disable' || sslMode === 'false' + ? 'disable' + : 'required'; + } + } else if (type === 'sqlserver') { + const encrypt = String(parsed.params.get('encrypt') || '').trim().toLowerCase(); + const trust = String(parsed.params.get('TrustServerCertificate') || parsed.params.get('trustservercertificate') || '').trim().toLowerCase(); + const encrypted = encrypt === 'true' || encrypt === 'mandatory' || encrypt === 'yes' || encrypt === '1' || encrypt === 'strict'; + if (encrypted) { + parsedValues.useSSL = true; + parsedValues.sslMode = trust === 'true' || trust === '1' || trust === 'yes' ? 'skip-verify' : 'required'; + } else if (encrypt) { + parsedValues.useSSL = false; + parsedValues.sslMode = 'disable'; + } + } else if (type === 'clickhouse') { + const secure = String(parsed.params.get('secure') || parsed.params.get('tls') || '').trim().toLowerCase(); + const skipVerify = normalizeBool(parsed.params.get('skip_verify')); + if (secure) { + parsedValues.useSSL = normalizeBool(secure); + parsedValues.sslMode = skipVerify ? 'skip-verify' : (parsedValues.useSSL ? 'required' : 'disable'); + } + } else if (type === 'dameng') { + const certPath = String( + parsed.params.get('SSL_CERT_PATH') + || parsed.params.get('ssl_cert_path') + || parsed.params.get('sslCertPath') + || '' + ).trim(); + const keyPath = String( + parsed.params.get('SSL_KEY_PATH') + || parsed.params.get('ssl_key_path') + || parsed.params.get('sslKeyPath') + || '' + ).trim(); + parsedValues.sslCertPath = certPath; + parsedValues.sslKeyPath = keyPath; + if (certPath || keyPath) { + parsedValues.useSSL = true; + parsedValues.sslMode = 'required'; + } + } else if (type === 'oracle') { + const ssl = String(parsed.params.get('SSL') || parsed.params.get('ssl') || '').trim().toLowerCase(); + const sslVerify = String( + parsed.params.get('SSL VERIFY') + || parsed.params.get('ssl verify') + || parsed.params.get('SSL_VERIFY') + || parsed.params.get('ssl_verify') + || '' + ).trim().toLowerCase(); + if (ssl) { + parsedValues.useSSL = normalizeBool(ssl); + if (!parsedValues.useSSL) { + parsedValues.sslMode = 'disable'; + } else { + parsedValues.sslMode = normalizeBool(sslVerify || 'true') ? 'required' : 'skip-verify'; + } + } + } else if (type === 'tdengine') { + const protocol = String(parsed.params.get('protocol') || '').trim().toLowerCase(); + const skipVerify = normalizeBool(parsed.params.get('skip_verify')); + if (protocol === 'wss') { + parsedValues.useSSL = true; + parsedValues.sslMode = skipVerify ? 'skip-verify' : 'required'; + } else if (protocol === 'ws') { + parsedValues.useSSL = false; + parsedValues.sslMode = 'disable'; + } + } + }; + return parsedValues; } return null; @@ -609,6 +744,16 @@ const ConnectionModal: React.FC<{ if (hosts.length > 1 || values.mysqlTopology === 'replica') { params.set('topology', 'replica'); } + if (values.useSSL) { + const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); + if (mode === 'required') { + params.set('tls', 'true'); + } else if (mode === 'skip-verify') { + params.set('tls', 'skip-verify'); + } else { + params.set('tls', 'preferred'); + } + } if (Number.isFinite(timeout) && timeout > 0) { params.set('timeout', String(timeout)); } @@ -634,8 +779,15 @@ const ConnectionModal: React.FC<{ ? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB)))) : 0; const dbPath = `/${redisDB}`; + if (values.useSSL) { + const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); + if (mode === 'skip-verify' || mode === 'preferred') { + params.set('skip_verify', 'true'); + } + } const query = params.toString(); - return `redis://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; + const scheme = values.useSSL ? 'rediss' : 'redis'; + return `${scheme}://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; } if (isFileDatabaseType(type)) { @@ -675,6 +827,15 @@ const ConnectionModal: React.FC<{ if (authMechanism) { params.set('authMechanism', authMechanism); } + if (values.useSSL) { + const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); + params.set('tls', 'true'); + if (mode === 'skip-verify' || mode === 'preferred') { + params.set('tlsInsecure', 'true'); + } else { + params.delete('tlsInsecure'); + } + } if (Number.isFinite(timeout) && timeout > 0) { params.set('connectTimeoutMS', String(timeout * 1000)); params.set('serverSelectionTimeoutMS', String(timeout * 1000)); @@ -686,7 +847,45 @@ const ConnectionModal: React.FC<{ const scheme = type === 'postgres' ? 'postgresql' : type; const dbPath = database ? `/${encodeURIComponent(database)}` : ''; - return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}`; + const params = new URLSearchParams(); + if (supportsSSLForType(type) && values.useSSL) { + const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); + if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') { + params.set('sslmode', 'require'); + } else if (type === 'sqlserver') { + params.set('encrypt', 'true'); + params.set('TrustServerCertificate', mode === 'skip-verify' || mode === 'preferred' ? 'true' : 'false'); + } else if (type === 'clickhouse') { + params.set('secure', 'true'); + if (mode === 'skip-verify' || mode === 'preferred') { + params.set('skip_verify', 'true'); + } + } else if (type === 'dameng') { + const certPath = String(values.sslCertPath || '').trim(); + const keyPath = String(values.sslKeyPath || '').trim(); + if (certPath) params.set('SSL_CERT_PATH', certPath); + if (keyPath) params.set('SSL_KEY_PATH', keyPath); + } else if (type === 'oracle') { + params.set('SSL', 'TRUE'); + params.set('SSL VERIFY', mode === 'required' ? 'TRUE' : 'FALSE'); + } else if (type === 'tdengine') { + params.set('protocol', 'wss'); + if (mode === 'skip-verify' || mode === 'preferred') { + params.set('skip_verify', 'true'); + } + } + } else if (supportsSSLForType(type)) { + if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') { + params.set('sslmode', 'disable'); + } else if (type === 'sqlserver') { + params.set('encrypt', 'disable'); + params.set('TrustServerCertificate', 'true'); + } else if (type === 'tdengine') { + params.set('protocol', 'ws'); + } + } + const query = params.toString(); + return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ''}`; }; const handleGenerateURI = () => { @@ -838,6 +1037,10 @@ const ConnectionModal: React.FC<{ uri: config.uri || '', includeDatabases: initialValues.includeDatabases, includeRedisDatabases: initialValues.includeRedisDatabases, + useSSL: !!config.useSSL, + sslMode: config.sslMode || 'preferred', + sslCertPath: config.sslCertPath || '', + sslKeyPath: config.sslKeyPath || '', useSSH: config.useSSH, sshHost: config.ssh?.host, sshPort: config.ssh?.port, @@ -871,6 +1074,7 @@ const ConnectionModal: React.FC<{ mongoReplicaUser: config.mongoReplicaUser || '', mongoReplicaPassword: config.mongoReplicaPassword || '' }); + setUseSSL(!!config.useSSL); setUseSSH(config.useSSH || false); setUseProxy(config.useProxy || false); setDbType(configType); @@ -882,6 +1086,7 @@ const ConnectionModal: React.FC<{ // Create mode: Start at step 1 setStep(1); form.resetFields(); + setUseSSL(false); setUseSSH(false); setUseProxy(false); setDbType('mysql'); @@ -932,6 +1137,7 @@ const ConnectionModal: React.FC<{ setLoading(false); form.resetFields(); + setUseSSL(false); setUseSSH(false); setUseProxy(false); setDbType('mysql'); @@ -1073,6 +1279,21 @@ const ConnectionModal: React.FC<{ const type = String(mergedValues.type || '').toLowerCase(); const defaultPort = getDefaultPortByType(type); const isFileDbType = isFileDatabaseType(type); + const sslCapableType = supportsSSLForType(type); + const sslModeRaw = String(mergedValues.sslMode || 'preferred').trim().toLowerCase(); + const sslMode: 'preferred' | 'required' | 'skip-verify' | 'disable' = sslModeRaw === 'required' + ? 'required' + : sslModeRaw === 'skip-verify' + ? 'skip-verify' + : sslModeRaw === 'disable' + ? 'disable' + : 'preferred'; + const effectiveUseSSL = sslCapableType && !!mergedValues.useSSL; + const sslCertPath = sslCapableType ? String(mergedValues.sslCertPath || '').trim() : ''; + const sslKeyPath = sslCapableType ? String(mergedValues.sslKeyPath || '').trim() : ''; + if (type === 'dameng' && effectiveUseSSL && (!sslCertPath || !sslKeyPath)) { + throw new Error('达梦启用 SSL 时必须填写证书路径与私钥路径'); + } let primaryHost = 'localhost'; let primaryPort = defaultPort; @@ -1194,6 +1415,10 @@ const ConnectionModal: React.FC<{ password: keepPassword ? (mergedValues.password || "") : "", savePassword: savePassword, database: mergedValues.database || "", + useSSL: effectiveUseSSL, + sslMode: effectiveUseSSL ? sslMode : 'disable', + sslCertPath: sslCertPath, + sslKeyPath: sslKeyPath, useSSH: !!mergedValues.useSSH, ssh: sshConfig, useProxy: effectiveUseProxy, @@ -1233,6 +1458,7 @@ const ConnectionModal: React.FC<{ const defaultPort = getDefaultPortByType(type); if (isFileDatabaseType(type)) { + setUseSSL(false); setUseSSH(false); setUseProxy(false); form.setFieldsValue({ @@ -1241,6 +1467,10 @@ const ConnectionModal: React.FC<{ user: '', password: '', database: '', + useSSL: false, + sslMode: 'preferred', + sslCertPath: '', + sslKeyPath: '', useSSH: false, sshHost: '', sshPort: 22, @@ -1273,10 +1503,16 @@ const ConnectionModal: React.FC<{ }); } else if (type !== 'custom') { const defaultUser = type === 'clickhouse' ? 'default' : 'root'; + const sslCapableType = supportsSSLForType(type); + setUseSSL(false); form.setFieldsValue({ user: defaultUser, database: '', port: defaultPort, + useSSL: sslCapableType ? false : undefined, + sslMode: sslCapableType ? 'preferred' : undefined, + sslCertPath: sslCapableType ? '' : undefined, + sslKeyPath: sslCapableType ? '' : undefined, mysqlTopology: 'single', redisTopology: 'single', mongoTopology: 'single', @@ -1420,6 +1656,10 @@ const ConnectionModal: React.FC<{ port: 3306, database: '', user: 'root', + useSSL: false, + sslMode: 'preferred', + sslCertPath: '', + sslKeyPath: '', useSSH: false, sshPort: 22, useProxy: false, @@ -1451,6 +1691,7 @@ const ConnectionModal: React.FC<{ if (changed.uri !== undefined || changed.type !== undefined) { setUriFeedback(null); } + if (changed.useSSL !== undefined) setUseSSL(changed.useSSL); if (changed.useSSH !== undefined) setUseSSH(changed.useSSH); if (changed.useProxy !== undefined) setUseProxy(changed.useProxy); if (changed.proxyType !== undefined) { @@ -1835,6 +2076,56 @@ const ConnectionModal: React.FC<{ {!isFileDb && ( <> + {isSSLType && ( + <> + + + 使用 SSL/TLS + + {useSSL && ( +
+ + + + + + + + )} + + {sslHintText} + +
+ )} + + )} + 使用 SSH 隧道 (SSH Tunnel) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 89dd364..5adf70f 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -580,6 +580,7 @@ interface DataGridProps { onToggleFilter?: () => void; exportSqlWithFilter?: string; onApplyFilter?: (conditions: GridFilterCondition[]) => void; + appliedFilterConditions?: FilterCondition[]; } type GridFilterCondition = FilterCondition & { @@ -599,7 +600,7 @@ type ColumnMeta = { const DataGrid: React.FC = ({ data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false, - onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter + onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions }) => { const connections = useStore(state => state.connections); const addSqlLog = useStore(state => state.addSqlLog); @@ -1064,10 +1065,40 @@ const DataGrid: React.FC = ({ const [modifiedRows, setModifiedRows] = useState>({}); const [deletedRowKeys, setDeletedRowKeys] = useState>(new Set()); + const normalizeFilterLogic = useCallback((logic: unknown): 'AND' | 'OR' => { + return String(logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND'; + }, []); + + const normalizeGridFilterConditions = useCallback((conditions?: FilterCondition[]): GridFilterCondition[] => { + if (!Array.isArray(conditions)) return []; + return conditions.map((cond, index) => { + const fallbackId = index + 1; + const nextId = Number.isFinite(Number(cond?.id)) ? Number(cond?.id) : fallbackId; + const op = String(cond?.op || '='); + const rawColumn = String(cond?.column || ''); + return { + id: nextId, + enabled: cond?.enabled !== false, + logic: normalizeFilterLogic(cond?.logic), + column: rawColumn || (op === 'CUSTOM' ? '' : String(columnNames[0] || '')), + op, + value: String(cond?.value ?? ''), + value2: String(cond?.value2 ?? ''), + }; + }); + }, [columnNames, normalizeFilterLogic]); + // Filter State const [filterConditions, setFilterConditions] = useState([]); const [nextFilterId, setNextFilterId] = useState(1); + useEffect(() => { + const nextConditions = normalizeGridFilterConditions(appliedFilterConditions); + setFilterConditions(nextConditions); + const maxId = nextConditions.reduce((max, cond) => (cond.id > max ? cond.id : max), 0); + setNextFilterId(Math.max(1, maxId + 1)); + }, [appliedFilterConditions, normalizeGridFilterConditions]); + const selectedRowKeysRef = useRef(selectedRowKeys); const displayDataRef = useRef([]); @@ -2547,6 +2578,10 @@ const DataGrid: React.FC = ({ { value: 'NOT_IN', label: '不在列表' }, { value: 'CUSTOM', label: '[自定义]' }, ]), []); + const filterLogicOptions = useMemo(() => ([ + { value: 'AND', label: '且 (AND)' }, + { value: 'OR', label: '或 (OR)' }, + ]), []); const isNoValueOp = useCallback((op: string) => ( op === 'IS_NULL' || op === 'IS_NOT_NULL' || op === 'IS_EMPTY' || op === 'IS_NOT_EMPTY' @@ -2555,7 +2590,18 @@ const DataGrid: React.FC = ({ const isListOp = useCallback((op: string) => op === 'IN' || op === 'NOT_IN', []); const addFilter = () => { - setFilterConditions([...filterConditions, { id: nextFilterId, enabled: true, column: columnNames[0] || '', op: '=', value: '', value2: '' }]); + setFilterConditions([ + ...filterConditions, + { + id: nextFilterId, + enabled: true, + logic: 'AND', + column: columnNames[0] || '', + op: '=', + value: '', + value2: '', + } + ]); setNextFilterId(nextFilterId + 1); }; const updateFilter = (id: number, field: keyof GridFilterCondition, val: string | boolean) => { @@ -3066,7 +3112,7 @@ const DataGrid: React.FC = ({ background: 'transparent', boxSizing: 'border-box', }}> - {filterConditions.map(cond => ( + {filterConditions.map((cond, condIndex) => (
= ({ > 启用 - updateFilter(cond.id, 'logic', v)} + options={filterLogicOptions as any} + /> + )} + { return ` ORDER BY ${parts.join(', ')}`; }; +type ViewerFilterSnapshot = { + showFilter: boolean; + conditions: FilterCondition[]; +}; + +const viewerFilterSnapshotsByTab = new Map(); + +const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefined): FilterCondition[] => { + if (!Array.isArray(conditions)) return []; + return conditions.map((cond) => ({ + id: Number.isFinite(Number(cond?.id)) ? Number(cond?.id) : undefined, + enabled: cond?.enabled !== false, + logic: String(cond?.logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND', + column: String(cond?.column || ''), + op: String(cond?.op || '='), + value: String(cond?.value ?? ''), + value2: String(cond?.value2 ?? ''), + })); +}; + +const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => { + const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim()); + if (!cached) { + return { showFilter: false, conditions: [] }; + } + return { + showFilter: cached.showFilter === true, + conditions: normalizeViewerFilterConditions(cached.conditions), + }; +}; + const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const [data, setData] = useState([]); const [columnNames, setColumnNames] = useState([]); @@ -185,14 +217,27 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null); - const [showFilter, setShowFilter] = useState(false); - const [filterConditions, setFilterConditions] = useState([]); + const [showFilter, setShowFilter] = useState(() => getViewerFilterSnapshot(tab.id).showFilter); + const [filterConditions, setFilterConditions] = useState(() => getViewerFilterSnapshot(tab.id).conditions); const duckdbSafeSelectCacheRef = useRef>({}); const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; const currentConnCaps = getDataSourceCapabilities(currentConnConfig); const currentConnType = currentConnCaps.type; const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult; + useEffect(() => { + const snapshot = getViewerFilterSnapshot(tab.id); + setShowFilter(snapshot.showFilter); + setFilterConditions(snapshot.conditions); + }, [tab.id]); + + useEffect(() => { + viewerFilterSnapshotsByTab.set(tab.id, { + showFilter, + conditions: normalizeViewerFilterConditions(filterConditions), + }); + }, [tab.id, showFilter, filterConditions]); + useEffect(() => { setPkColumns([]); pkKeyRef.current = ''; @@ -315,42 +360,67 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const dbName = tab.dbName || ''; const tableName = tab.tableName || ''; + const isMongoDB = dbTypeLower === 'mongodb'; + let mongoFilter: Record | undefined; + if (isMongoDB) { + try { + mongoFilter = buildMongoFilter(filterConditions); + } catch (e: any) { + message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`); + if (fetchSeqRef.current === seq) setLoading(false); + return; + } + } - const whereSQL = buildWhereSQL(dbType, filterConditions); - - const countSql = `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; - - const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; - const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns); - let sql = `${baseSql}${orderBySQL}`; + const whereSQL = isMongoDB + ? JSON.stringify(mongoFilter || {}) + : buildWhereSQL(dbType, filterConditions); + const countSql = isMongoDB + ? buildMongoCountCommand(tableName, mongoFilter || {}) + : `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; + const orderBySQL = isMongoDB ? '' : buildOrderBySQL(dbType, sortInfo, pkColumns); 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 isClickHouse = !isMongoDB && 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; + let sql = ''; + if (isMongoDB) { + const mongoSort = buildMongoSort(sortInfo, pkColumns); + sql = buildMongoFindCommand({ + collection: tableName, + filter: mongoFilter || {}, + sort: mongoSort, + limit: size + 1, + skip: offset, + }); + } else { + const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; + sql = `${baseSql}${orderBySQL}`; + // 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}`; + if (!useClickHouseReversePagination) { + // 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。 + sql += ` LIMIT ${size + 1} OFFSET ${offset}`; + } } const requestStartTime = Date.now(); @@ -718,6 +788,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { showFilter={showFilter} onToggleFilter={handleToggleFilter} onApplyFilter={handleApplyFilter} + appliedFilterConditions={filterConditions} readOnly={forceReadOnly} sortInfoExternal={sortInfo} exportSqlWithFilter={exportSqlWithFilter || undefined} diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 16a15a9..2e66344 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -9,6 +9,8 @@ import { useStore } from '../store'; import { DBQuery, DBQueryWithCancel, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; +import { convertMongoShellToJsonCommand } from '../utils/mongodb'; +import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts'; const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); @@ -68,6 +70,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); + const shortcutOptions = useStore(state => state.shortcutOptions); + const activeTabId = useStore(state => state.activeTabId); useEffect(() => { currentConnectionIdRef.current = currentConnectionId; @@ -268,6 +272,19 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return parts[parts.length - 1] || raw; }; + const splitSchemaAndTable = (qualified: string): { schema: string; table: string } => { + const raw = normalizeQualifiedName(qualified); + if (!raw) return { schema: '', table: '' }; + const parts = raw.split('.').filter(Boolean); + if (parts.length >= 2) { + return { + schema: parts[parts.length - 2] || '', + table: parts[parts.length - 1] || '', + }; + } + return { schema: '', table: parts[0] || '' }; + }; + const buildConnConfig = () => { const connId = currentConnectionIdRef.current; const conn = connectionsRef.current.find(c => c.id === connId); @@ -340,13 +357,14 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { if (qualifierMatch) { const qualifier = stripQuotes(qualifierMatch[1]); const prefix = (qualifierMatch[2] || '').toLowerCase(); + const qualifierLower = qualifier.toLowerCase(); // 首先检查 qualifier 是否是数据库名(跨库表提示) const visibleDbs = visibleDbsRef.current; - if (visibleDbs.some(db => db.toLowerCase() === qualifier.toLowerCase())) { + if (visibleDbs.some(db => db.toLowerCase() === qualifierLower)) { // qualifier 是数据库名,提示该库的表 const tables = tablesRef.current.filter(t => - (t.dbName || '').toLowerCase() === qualifier.toLowerCase() + (t.dbName || '').toLowerCase() === qualifierLower ); const filtered = prefix ? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix)) @@ -363,6 +381,34 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return { suggestions }; } + // qualifier 是 schema(如 dbo/public)时,仅补全表名,避免输入 dbo. 后再补成 dbo.dbo.table + const schemaTables = tablesRef.current + .map(t => { + const parsed = splitSchemaAndTable(t.tableName || ''); + return { + dbName: t.dbName || '', + schema: parsed.schema, + table: parsed.table, + }; + }) + .filter(t => t.schema.toLowerCase() === qualifierLower && !!t.table); + + if (schemaTables.length > 0) { + const filtered = prefix + ? schemaTables.filter(t => t.table.toLowerCase().startsWith(prefix)) + : schemaTables; + + const suggestions = filtered.map(t => ({ + label: t.table, + kind: monaco.languages.CompletionItemKind.Class, + insertText: t.table, + detail: `Table (${t.dbName}${t.schema ? '.' + t.schema : ''})`, + range, + sortText: '0' + t.table + })); + return { suggestions }; + } + // 否则检查是否是表别名或表名,提示列 const reserved = new Set([ 'where', 'on', 'group', 'order', 'limit', 'having', @@ -531,6 +577,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { icon: sqlFormatOptions.keywordCase === 'lower' ? '✓' : undefined, onClick: () => setSqlFormatOptions({ keywordCase: 'lower' }) }, + { type: 'divider' }, + { + key: 'shortcut-settings', + label: '快捷键管理...', + onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-shortcut-settings')), + }, ]; const splitSQLStatements = (sql: string): string[] => { @@ -1035,7 +1087,15 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { try { const rawSQL = getSelectedSQL() || query; - const statements = splitSQLStatements(rawSQL); + const dbType = String((config as any).type || 'mysql'); + const normalizedDbType = dbType.trim().toLowerCase(); + const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';'); + const splitInput = normalizedDbType === 'mongodb' + ? normalizedRawSQL + .replace(/^\s*\/\/.*$/gm, '') + .replace(/^\s*#.*$/gm, '') + : normalizedRawSQL; + const statements = splitSQLStatements(splitInput); if (statements.length === 0) { message.info('没有可执行的 SQL。'); setResultSets([]); @@ -1045,7 +1105,6 @@ 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 forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0; const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0; @@ -1059,9 +1118,24 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const limitApplied = shouldAutoLimit && wantsLimitProbe; const limited = limitApplied ? applyAutoLimit(rawStatement, dbType, probeLimit) : { sql: rawStatement, applied: false, maxRows: probeLimit }; - const executedSql = limited.sql; + let executedSql = limited.sql; + if (String(dbType || '').trim().toLowerCase() === 'mongodb') { + const shellConvert = convertMongoShellToJsonCommand(executedSql); + if (shellConvert.recognized) { + if (shellConvert.error) { + const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; + message.error(prefix + shellConvert.error); + setResultSets([]); + setActiveResultKey(''); + return; + } + if (shellConvert.command) { + executedSql = shellConvert.command; + } + } + } const startTime = Date.now(); - + // Generate query ID for cancellation using backend UUID with fallback let queryId: string; try { @@ -1071,7 +1145,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { queryId = 'query-' + uuidv4(); } setQueryId(queryId); - + const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId); const duration = Date.now() - startTime; @@ -1089,19 +1163,19 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { if (!res.success) { // 检查是否为查询取消错误 const errorMsg = res.message.toLowerCase(); - const isCancelledError = errorMsg.includes('context canceled') || + const isCancelledError = errorMsg.includes('context canceled') || errorMsg.includes('查询已取消') || errorMsg.includes('canceled') || errorMsg.includes('cancelled') || errorMsg.includes('statement canceled') || errorMsg.includes('sql: statement canceled'); - + // 确保不是超时错误 const isTimeoutError = errorMsg.includes('context deadline exceeded') || errorMsg.includes('timeout') || errorMsg.includes('超时') || errorMsg.includes('deadline exceeded'); - + if (isCancelledError && !isTimeoutError) { // 查询已被用户取消,不显示错误消息,清理状态 setResultSets([]); @@ -1112,7 +1186,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } return; } - + const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; message.error(prefix + res.message); setResultSets([]); @@ -1245,6 +1319,48 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } }; + useEffect(() => { + const binding = shortcutOptions.runQuery; + if (!binding?.enabled || !binding.combo) { + return; + } + + const handleRunShortcut = (event: KeyboardEvent) => { + if (activeTabId !== tab.id) { + return; + } + if (!isShortcutMatch(event, binding.combo)) { + return; + } + const editorHasFocus = !!editorRef.current?.hasTextFocus?.(); + if (!editorHasFocus && !isEditableElement(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + void handleRun(); + }; + + window.addEventListener('keydown', handleRunShortcut); + return () => { + window.removeEventListener('keydown', handleRunShortcut); + }; + }, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]); + + useEffect(() => { + const handleRunActiveQuery = () => { + if (activeTabId !== tab.id) { + return; + } + void handleRun(); + }; + + window.addEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener); + return () => { + window.removeEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener); + }; + }, [activeTabId, tab.id, handleRun]); + const handleSave = async () => { try { const values = await saveForm.validateFields(); @@ -1357,9 +1473,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { /> - + + + {loading && ( + + +
{/* Toolbar */} diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index f2d6ef6..9a3c9f9 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -261,9 +261,18 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const darkMode = theme === 'dark'; const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff'; const readOnly = !!tab.readOnly; + const panelRadius = 10; + const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.18)' : 'rgba(0, 0, 0, 0.12)'; + const panelToolbarBorder = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)'; + const panelToolbarBg = darkMode ? 'rgba(20, 20, 20, 0.35)' : 'rgba(255, 255, 255, 0.72)'; + const panelBodyBg = darkMode ? 'rgba(0, 0, 0, 0.24)' : 'rgba(255, 255, 255, 0.82)'; + const focusRowBg = darkMode ? 'rgba(246, 196, 83, 0.22)' : 'rgba(24, 144, 255, 0.12)'; const [tableHeight, setTableHeight] = useState(500); const containerRef = useRef(null); + const pendingFocusColumnKeyRef = useRef(null); + const focusHighlightTimerRef = useRef(null); + const [focusColumnKey, setFocusColumnKey] = useState(''); const openCommentEditor = useCallback((record: EditableColumn) => { if (!record?._key) return; @@ -346,6 +355,61 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { setSelectedColumnRowKeys(prev => prev.filter(key => columns.some(c => c._key === key))); }, [columns]); + useEffect(() => { + return () => { + if (focusHighlightTimerRef.current !== null) { + window.clearTimeout(focusHighlightTimerRef.current); + } + }; + }, []); + + const focusColumnRow = useCallback((targetKey: string): boolean => { + if (activeKey !== 'columns') return false; + const tableBody = containerRef.current?.querySelector('.ant-table-body') as HTMLElement | null; + if (!tableBody) return false; + const row = tableBody.querySelector(`tr[data-row-key="${targetKey}"]`) as HTMLTableRowElement | null; + if (!row) return false; + + row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + setFocusColumnKey(targetKey); + if (focusHighlightTimerRef.current !== null) { + window.clearTimeout(focusHighlightTimerRef.current); + } + focusHighlightTimerRef.current = window.setTimeout(() => { + setFocusColumnKey(prev => (prev === targetKey ? '' : prev)); + }, 1600); + + if (!readOnly) { + const firstInput = row.querySelector('input') as HTMLInputElement | null; + if (firstInput) { + firstInput.focus(); + firstInput.select(); + } + } + return true; + }, [activeKey, readOnly]); + + useEffect(() => { + const pendingKey = pendingFocusColumnKeyRef.current; + if (!pendingKey || activeKey !== 'columns') return; + + let cancelled = false; + const tryFocus = () => { + if (cancelled) return; + if (focusColumnRow(pendingKey)) { + pendingFocusColumnKeyRef.current = null; + } + }; + + const timerA = window.setTimeout(tryFocus, 0); + const timerB = window.setTimeout(tryFocus, 96); + return () => { + cancelled = true; + window.clearTimeout(timerA); + window.clearTimeout(timerB); + }; + }, [activeKey, columns, focusColumnRow]); + // Initial Columns Definition useEffect(() => { const initialCols = [ @@ -886,21 +950,46 @@ ${selectedTrigger.statement}`; })); }; - const handleAddColumn = () => { - const newCol: EditableColumn = { - name: isNewTable ? 'new_column' : `new_col_${columns.length + 1}`, - type: 'varchar(255)', - nullable: 'YES', - key: '', - extra: '', - comment: '', - default: '', - _key: `new-${Date.now()}`, - isNew: true, - isAutoIncrement: false - }; - setColumns([...columns, newCol]); - }; + const createNewColumn = useCallback((indexHint: number): EditableColumn => ({ + name: isNewTable ? 'new_column' : `new_col_${indexHint}`, + type: 'varchar(255)', + nullable: 'YES', + key: '', + extra: '', + comment: '', + default: '', + _key: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + isNew: true, + isAutoIncrement: false + }), [isNewTable]); + + const handleAddColumn = useCallback((insertAfterKey?: string) => { + const newCol = createNewColumn(columns.length + 1); + setColumns(prev => { + const next = [...prev]; + if (insertAfterKey) { + const insertIndex = next.findIndex(col => col._key === insertAfterKey); + if (insertIndex >= 0) { + next.splice(insertIndex + 1, 0, newCol); + return next; + } + } + next.push(newCol); + return next; + }); + setSelectedColumnRowKeys([newCol._key]); + pendingFocusColumnKeyRef.current = newCol._key; + }, [columns.length, createNewColumn]); + + const handleAddColumnAfterSelected = useCallback(() => { + const selectedSet = new Set(selectedColumnRowKeys); + const anchor = columns.find(col => selectedSet.has(col._key)); + if (!anchor) { + message.warning('请先选择一个字段,再执行插入。'); + return; + } + handleAddColumn(anchor._key); + }, [columns, handleAddColumn, selectedColumnRowKeys]); const handleDeleteColumn = (key: string) => { setColumns(prev => prev.filter(c => c._key !== key)); @@ -1920,22 +2009,35 @@ END;`; })); const columnsTabContent = ( -
+
{readOnly ? ( record._key === focusColumnKey ? 'table-designer-focus-row' : ''} size="small" pagination={false} loading={loading} scroll={{ y: tableHeight }} - bordered + bordered={false} components={{ header: { cell: ResizableTitle, @@ -1953,11 +2055,12 @@ END;`; onChange: (nextSelectedRowKeys) => setSelectedColumnRowKeys(nextSelectedRowKeys as string[]), }} rowKey="_key" + rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''} size="small" pagination={false} loading={loading} scroll={{ y: tableHeight }} - bordered + bordered={false} components={{ body: { row: SortableRow }, header: { cell: ResizableTitle } @@ -1985,8 +2088,63 @@ END;`; ); return ( -
-
+
+ +
{isNewTable && ( <> )} - {!readOnly && } - {!isNewTable && } + {!readOnly && } + {!isNewTable && } {!isNewTable && !readOnly && supportsTableCommentOps() && ( - + )} - {!readOnly && } + {!readOnly && } {!readOnly && ( + )} + {!readOnly && ( +