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 />