From 26b79adc5f308c2b595b24d983ab5e08ec639212 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 2 Mar 2026 10:49:23 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=F0=9F=90=9B=20fix(data-viewer):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DClickHouse=E5=B0=BE=E9=83=A8=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E5=BC=82=E5=B8=B8=E5=B9=B6=E5=A2=9E=E5=BC=BADuckDB?= =?UTF-8?q?=E5=A4=8D=E6=9D=82=E7=B1=BB=E5=9E=8B=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败 - DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试 - 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致 - 增强查询异常日志与重试路径,降低大表场景卡顿与误报 --- .github/workflows/release.yml | 54 +++++- cmd/optional-driver-agent/main.go | 55 +++++- cmd/optional-driver-agent/main_test.go | 62 +++++++ docs/driver-manifest.json | 2 +- frontend/src/components/ConnectionModal.tsx | 34 +++- frontend/src/components/DataViewer.tsx | 175 ++++++++++++++++---- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/app.go | 90 +++++++++- internal/app/app_cache_key_test.go | 63 +++++++ internal/app/methods_driver.go | 4 +- internal/app/methods_file.go | 170 ++++++++++++++++++- internal/app/methods_file_export_test.go | 89 ++++++++++ internal/db/query_value.go | 66 +++++++- internal/db/query_value_test.go | 39 +++++ 15 files changed, 853 insertions(+), 56 deletions(-) create mode 100644 cmd/optional-driver-agent/main_test.go create mode 100644 internal/app/app_cache_key_test.go create mode 100644 internal/app/methods_file_export_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec38a17..0e0cb32 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,34 @@ jobs: - name: List Assets run: ls -R release-assets + - name: Verify DuckDB 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" + ) + + missing=0 + for file in "${REQUIRED_FILES[@]}"; do + if [ ! -f "$file" ]; then + echo "❌ 缺少 DuckDB 驱动资产:$file" + missing=1 + else + echo "✅ 已找到 DuckDB 驱动资产:$file" + fi + done + + if [ "$missing" -ne 0 ]; then + echo "❌ DuckDB 驱动资产不完整,终止发布" + 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..63f6945 100644 --- a/cmd/optional-driver-agent/main.go +++ b/cmd/optional-driver-agent/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "reflect" "strings" "GoNavi-Wails/internal/connection" @@ -218,7 +219,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 +239,51 @@ 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 + } +} diff --git a/cmd/optional-driver-agent/main_test.go b/cmd/optional-driver-agent/main_test.go new file mode 100644 index 0000000..e74c805 --- /dev/null +++ b/cmd/optional-driver-agent/main_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "testing" +) + +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) + } +} diff --git a/docs/driver-manifest.json b/docs/driver-manifest.json index 1f0302a..2352ea1 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" }, diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 3f362a8..5c8ad1b 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -3,7 +3,7 @@ import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Se import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons'; import { useStore } from '../store'; import { normalizeOpacityForPlatform } from '../utils/appearance'; -import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; +import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; const { Meta } = Card; @@ -80,6 +80,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); @@ -665,6 +666,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 @@ -1392,6 +1417,13 @@ const ConnectionModal: React.FC<{ onDoubleClick={requestTest} /> + {isFileDb && ( + + + + )} {!isFileDb && ( { 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,19 +171,17 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const [showFilter, setShowFilter] = useState(false); const [filterConditions, setFilterConditions] = useState([]); + const duckdbSafeSelectCacheRef = useRef>({}); 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); - }, []); - useEffect(() => { setPkColumns([]); pkKeyRef.current = ''; countKeyRef.current = ''; duckdbApproxKeyRef.current = ''; manualCountKeyRef.current = ''; + duckdbSafeSelectCacheRef.current = {}; latestConfigRef.current = null; latestDbTypeRef.current = ''; latestDbNameRef.current = ''; @@ -194,7 +219,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 +265,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 +302,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 +450,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 +470,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 +484,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { if (derivedTotalKnown) { return { ...prev, - current: page, + current: currentPage, pageSize: size, total: derivedTotal, totalKnown: true, @@ -388,19 +495,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 +517,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { } return { ...prev, - current: page, + current: currentPage, pageSize: size, total: derivedTotal, totalKnown: false, @@ -489,7 +596,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 +641,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:在无手动排序时可回退到主键稳定排序。 // 主键信息只会在首次加载后更新一次,避免循环查询。 diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 72ad6a1..98e4dd2 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -164,6 +164,8 @@ export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise export function ResolveDriverRepositoryURL(arg1:string):Promise; +export function SelectDatabaseFile(arg1:string,arg2:string):Promise; + export function SelectDriverDownloadDirectory(arg1:string):Promise; export function SelectDriverPackageDirectory(arg1:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 86f801f..e6def2b 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -322,6 +322,10 @@ export function ResolveDriverRepositoryURL(arg1) { return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1); } +export function SelectDatabaseFile(arg1, arg2) { + return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2); +} + export function SelectDriverDownloadDirectory(arg1) { return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1); } diff --git a/internal/app/app.go b/internal/app/app.go index b8dd6a7..5616523 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -74,16 +74,67 @@ func (a *App) Shutdown(ctx context.Context) { logger.Close() } -// Helper: Generate a unique key for the connection config -func getCacheKey(config connection.ConnectionConfig) string { - if !config.UseSSH { - config.SSH = connection.SSHConfig{} +func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.ConnectionConfig { + normalized := config + normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type)) + // timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。 + normalized.Timeout = 0 + normalized.SavePassword = false + + if !normalized.UseSSH { + normalized.SSH = connection.SSHConfig{} } - if !config.UseProxy { - config.Proxy = connection.ProxyConfig{} + if !normalized.UseProxy { + normalized.Proxy = connection.ProxyConfig{} } - b, _ := json.Marshal(config) + if isFileDatabaseType(normalized.Type) { + dsn := strings.TrimSpace(normalized.Host) + if dsn == "" { + dsn = strings.TrimSpace(normalized.Database) + } + if dsn == "" { + dsn = ":memory:" + } + + // DuckDB/SQLite 仅基于文件来源识别连接,其他网络字段不参与键计算。 + normalized.Host = dsn + normalized.Database = "" + normalized.Port = 0 + normalized.User = "" + normalized.Password = "" + normalized.URI = "" + normalized.Hosts = nil + normalized.Topology = "" + normalized.MySQLReplicaUser = "" + normalized.MySQLReplicaPassword = "" + normalized.ReplicaSet = "" + normalized.AuthSource = "" + normalized.ReadPreference = "" + normalized.MongoSRV = false + normalized.MongoAuthMechanism = "" + normalized.MongoReplicaUser = "" + normalized.MongoReplicaPassword = "" + } + + return normalized +} + +func resolveFileDatabaseDSN(config connection.ConnectionConfig) string { + dsn := strings.TrimSpace(config.Host) + if dsn == "" { + dsn = strings.TrimSpace(config.Database) + } + if dsn == "" { + dsn = ":memory:" + } + return dsn +} + +// Helper: Generate a unique key for the connection config +func getCacheKey(config connection.ConnectionConfig) string { + normalized := normalizeCacheKeyConfig(config) + b, _ := json.Marshal(normalized) sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } @@ -235,12 +286,19 @@ func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Datab func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) { effectiveConfig := applyGlobalProxyToConnection(config) + isFileDB := isFileDatabaseType(effectiveConfig.Type) key := getCacheKey(effectiveConfig) shortKey := key if len(shortKey) > 12 { shortKey = shortKey[:12] } + if isFileDB { + rawDSN := resolveFileDatabaseDSN(effectiveConfig) + normalizedDSN := resolveFileDatabaseDSN(normalizeCacheKeyConfig(effectiveConfig)) + logger.Infof("文件库连接缓存探测:类型=%s 原始DSN=%s 归一化DSN=%s timeout=%ds forcePing=%t 缓存Key=%s", + strings.TrimSpace(effectiveConfig.Type), rawDSN, normalizedDSN, effectiveConfig.Timeout, forcePing, shortKey) + } if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported { if strings.TrimSpace(reason) == "" { @@ -260,6 +318,9 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing entry, ok := a.dbCache[key] a.mu.RUnlock() if ok { + if isFileDB { + logger.Infof("命中文件库连接缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) + } needPing := forcePing if !needPing { lastPing := entry.lastPing @@ -269,6 +330,9 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing } if !needPing { + if isFileDB { + logger.Infof("复用文件库连接缓存(免 Ping):类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) + } return entry.inst, nil } @@ -280,6 +344,9 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing a.dbCache[key] = cur } a.mu.Unlock() + if isFileDB { + logger.Infof("复用文件库连接缓存(Ping 成功):类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) + } return entry.inst, nil } else { logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) @@ -294,6 +361,12 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing delete(a.dbCache, key) } a.mu.Unlock() + if isFileDB { + logger.Infof("文件库缓存连接已剔除,准备新建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) + } + } + if isFileDB { + logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) @@ -324,6 +397,9 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing a.mu.Unlock() // Prefer existing cached connection to avoid cache racing duplicates. _ = dbInst.Close() + if isFileDB { + logger.Infof("并发创建命中已存在文件库连接,关闭新建连接并复用缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) + } return existing.inst, nil } a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now} diff --git a/internal/app/app_cache_key_test.go b/internal/app/app_cache_key_test.go new file mode 100644 index 0000000..ef7714f --- /dev/null +++ b/internal/app/app_cache_key_test.go @@ -0,0 +1,63 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestGetCacheKey_IgnoreTimeout(t *testing.T) { + base := connection.ConnectionConfig{ + Type: "duckdb", + Host: `C:\data\songs.duckdb`, + Timeout: 30, + UseProxy: false, + UseSSH: false, + } + modified := base + modified.Timeout = 120 + + left := getCacheKey(base) + right := getCacheKey(modified) + if left != right { + t.Fatalf("expected same cache key when only timeout differs, got %s vs %s", left, right) + } +} + +func TestGetCacheKey_DuckDBHostAndDatabaseEquivalent(t *testing.T) { + withHost := connection.ConnectionConfig{ + Type: "duckdb", + Host: `D:\music\songs.duckdb`, + } + withDatabase := connection.ConnectionConfig{ + Type: "duckdb", + Database: `D:\music\songs.duckdb`, + } + + left := getCacheKey(withHost) + right := getCacheKey(withDatabase) + if left != right { + t.Fatalf("expected same cache key for duckdb host/database path, got %s vs %s", left, right) + } +} + +func TestGetCacheKey_KeepDatabaseIsolation(t *testing.T) { + a := connection.ConnectionConfig{ + Type: "mysql", + Host: "127.0.0.1", + Port: 3306, + User: "root", + Password: "root", + Database: "db_a", + Timeout: 30, + } + b := a + b.Database = "db_b" + b.Timeout = 5 + + left := getCacheKey(a) + right := getCacheKey(b) + if left == right { + t.Fatalf("expected different cache key for different database targets") + } +} diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index cef721a..49ea66e 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -218,7 +218,7 @@ const builtinDriverManifestJSON = `{ "sphinx": { "engine": "go", "version": "1.9.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sphinx" }, "sqlserver": { "engine": "go", "version": "1.9.6", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlserver" }, "sqlite": { "engine": "go", "version": "1.44.3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlite" }, - "duckdb": { "engine": "go", "version": "2.5.5", "checksumPolicy": "off", "downloadUrl": "builtin://activate/duckdb" }, + "duckdb": { "engine": "go", "version": "2.5.6", "checksumPolicy": "off", "downloadUrl": "builtin://activate/duckdb" }, "dameng": { "engine": "go", "version": "1.8.22", "checksumPolicy": "off", "downloadUrl": "builtin://activate/dameng" }, "kingbase": { "engine": "go", "version": "0.0.0-20201021123113-29bd62a876c3", "checksumPolicy": "off", "downloadUrl": "builtin://activate/kingbase" }, "highgo": { "engine": "go", "version": "0.0.0-local", "checksumPolicy": "off", "downloadUrl": "builtin://activate/highgo" }, @@ -271,7 +271,7 @@ var latestDriverVersionMap = map[string]string{ "sphinx": "1.9.3", "sqlserver": "1.9.6", "sqlite": "1.46.1", - "duckdb": "2.5.5", + "duckdb": "2.5.6", "dameng": "1.8.22", "kingbase": "0.0.0-20201021123113-29bd62a876c3", "highgo": "0.0.0-local", diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 561ef9b..6efef4c 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -8,6 +8,7 @@ import ( "math" "os" "path/filepath" + "reflect" "sort" "strconv" "strings" @@ -120,6 +121,78 @@ func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult { return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}} } +func (a *App) SelectDatabaseFile(currentPath string, driverType string) connection.QueryResult { + defaultDir := strings.TrimSpace(currentPath) + if defaultDir == "" { + if home, err := os.UserHomeDir(); err == nil { + defaultDir = home + } + } + if filepath.Ext(defaultDir) != "" { + defaultDir = filepath.Dir(defaultDir) + } + if defaultDir != "" && !filepath.IsAbs(defaultDir) { + if abs, err := filepath.Abs(defaultDir); err == nil { + defaultDir = abs + } + } + + normalizedType := strings.ToLower(strings.TrimSpace(driverType)) + filters := []runtime.FileFilter{ + { + DisplayName: "数据库文件", + Pattern: "*.db;*.sqlite;*.sqlite3;*.db3;*.duckdb;*.ddb", + }, + { + DisplayName: "所有文件", + Pattern: "*", + }, + } + title := "选择数据库文件" + switch normalizedType { + case "sqlite": + title = "选择 SQLite 数据文件" + filters = []runtime.FileFilter{ + { + DisplayName: "SQLite 文件", + Pattern: "*.db;*.sqlite;*.sqlite3;*.db3", + }, + { + DisplayName: "所有文件", + Pattern: "*", + }, + } + case "duckdb": + title = "选择 DuckDB 数据文件" + filters = []runtime.FileFilter{ + { + DisplayName: "DuckDB 文件", + Pattern: "*.duckdb;*.ddb;*.db", + }, + { + DisplayName: "所有文件", + Pattern: "*", + }, + } + } + + selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ + Title: title, + DefaultDirectory: defaultDir, + Filters: filters, + }) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if strings.TrimSpace(selection) == "" { + return connection.QueryResult{Success: false, Message: "Cancelled"} + } + if abs, err := filepath.Abs(selection); err == nil { + selection = abs + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}} +} + // PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据 func (a *App) PreviewImportFile(filePath string) connection.QueryResult { if filePath == "" { @@ -1527,7 +1600,11 @@ func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string return err } } - if err := jsonEncoder.Encode(rowMap); err != nil { + exportedRow := make(map[string]interface{}, len(columns)) + for _, col := range columns { + exportedRow[col] = normalizeExportJSONValue(rowMap[col]) + } + if err := jsonEncoder.Encode(exportedRow); err != nil { return err } isJsonFirstRow = false @@ -1567,11 +1644,102 @@ func formatExportCellText(val interface{}) string { return "NULL" } return v.Format("2006-01-02 15:04:05") + case float32: + f := float64(v) + if math.IsNaN(f) || math.IsInf(f, 0) { + return "NULL" + } + return strconv.FormatFloat(f, 'f', -1, 32) + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) { + return "NULL" + } + return strconv.FormatFloat(v, 'f', -1, 64) + case json.Number: + text := strings.TrimSpace(v.String()) + if text == "" { + return "NULL" + } + return text default: return fmt.Sprintf("%v", val) } } +func normalizeExportJSONValue(val interface{}) interface{} { + if val == nil { + return nil + } + + switch v := val.(type) { + case float32: + f := float64(v) + if math.IsNaN(f) || math.IsInf(f, 0) { + return nil + } + return json.Number(strconv.FormatFloat(f, 'f', -1, 32)) + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) { + return nil + } + return json.Number(strconv.FormatFloat(v, 'f', -1, 64)) + case json.Number: + text := strings.TrimSpace(v.String()) + if text == "" { + return nil + } + return json.Number(text) + case map[string]interface{}: + out := make(map[string]interface{}, len(v)) + for key, item := range v { + out[key] = normalizeExportJSONValue(item) + } + return out + case []interface{}: + items := make([]interface{}, len(v)) + for i, item := range v { + items[i] = normalizeExportJSONValue(item) + } + return items + } + + rv := reflect.ValueOf(val) + switch rv.Kind() { + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return nil + } + return normalizeExportJSONValue(rv.Elem().Interface()) + case reflect.Map: + if rv.IsNil() { + return nil + } + out := make(map[string]interface{}, rv.Len()) + iter := rv.MapRange() + for iter.Next() { + out[fmt.Sprint(iter.Key().Interface())] = normalizeExportJSONValue(iter.Value().Interface()) + } + return out + case reflect.Slice: + if rv.IsNil() { + return nil + } + if rv.Type().Elem().Kind() == reflect.Uint8 { + return val + } + fallthrough + case reflect.Array: + size := rv.Len() + items := make([]interface{}, size) + for i := 0; i < size; i++ { + items[i] = normalizeExportJSONValue(rv.Index(i).Interface()) + } + return items + default: + return val + } +} + // writeRowsToXlsx 使用 excelize 写入真正的 xlsx 格式文件 func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []string) error { xlsx := excelize.NewFile() diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go new file mode 100644 index 0000000..7fef8a9 --- /dev/null +++ b/internal/app/methods_file_export_test.go @@ -0,0 +1,89 @@ +package app + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" +) + +func TestFormatExportCellText_FloatNoScientificNotation(t *testing.T) { + got := formatExportCellText(1.445663e+06) + if strings.Contains(strings.ToLower(got), "e+") || strings.Contains(strings.ToLower(got), "e-") { + t.Fatalf("不应输出科学计数法,got=%q", got) + } + if got != "1445663" { + t.Fatalf("浮点整值导出异常,want=%q got=%q", "1445663", got) + } +} + +func TestWriteRowsToFile_Markdown_NumberKeepPlainText(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-export-*.md") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + data := []map[string]interface{}{ + {"id": 1.445663e+06}, + } + columns := []string{"id"} + + if err := writeRowsToFile(f, data, columns, "md"); err != nil { + t.Fatalf("写入 md 失败: %v", err) + } + + contentBytes, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatalf("读取 md 失败: %v", err) + } + content := string(contentBytes) + if strings.Contains(strings.ToLower(content), "e+") || strings.Contains(strings.ToLower(content), "e-") { + t.Fatalf("md 导出包含科学计数法: %s", content) + } + if !strings.Contains(content, "| 1445663 |") { + t.Fatalf("md 导出未保留整数字面量,content=%s", content) + } +} + +func TestWriteRowsToFile_JSON_NumberKeepPlainText(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-export-*.json") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + data := []map[string]interface{}{ + {"id": 1.445663e+06}, + } + columns := []string{"id"} + + if err := writeRowsToFile(f, data, columns, "json"); err != nil { + t.Fatalf("写入 json 失败: %v", err) + } + + contentBytes, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatalf("读取 json 失败: %v", err) + } + content := string(contentBytes) + if strings.Contains(strings.ToLower(content), "e+") || strings.Contains(strings.ToLower(content), "e-") { + t.Fatalf("json 导出包含科学计数法: %s", content) + } + + var decoded []map[string]json.Number + decoder := json.NewDecoder(bytes.NewReader(contentBytes)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + t.Fatalf("解析导出 json 失败: %v", err) + } + if len(decoded) != 1 { + t.Fatalf("导出行数异常,got=%d", len(decoded)) + } + if decoded[0]["id"].String() != "1445663" { + t.Fatalf("json 数值格式异常,want=1445663 got=%s", decoded[0]["id"].String()) + } +} diff --git a/internal/db/query_value.go b/internal/db/query_value.go index d4dde25..36e9744 100644 --- a/internal/db/query_value.go +++ b/internal/db/query_value.go @@ -3,6 +3,7 @@ package db import ( "encoding/hex" "fmt" + "reflect" "strings" "unicode" "unicode/utf8" @@ -18,7 +19,70 @@ func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) inter if b, ok := v.([]byte); ok { return bytesToDisplayValue(b, databaseTypeName) } - return v + return normalizeCompositeQueryValue(v) +} + +func normalizeCompositeQueryValue(v interface{}) interface{} { + if v == nil { + return nil + } + + switch typed := v.(type) { + case []interface{}: + items := make([]interface{}, len(typed)) + for i, item := range typed { + items[i] = normalizeQueryValue(item) + } + return items + case map[string]interface{}: + out := make(map[string]interface{}, len(typed)) + for key, value := range typed { + out[key] = normalizeQueryValue(value) + } + return out + } + + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Pointer: + if rv.IsNil() { + return nil + } + return normalizeQueryValue(rv.Elem().Interface()) + case reflect.Map: + if rv.IsNil() { + return nil + } + out := make(map[string]interface{}, rv.Len()) + iter := rv.MapRange() + for iter.Next() { + out[mapKeyToString(iter.Key().Interface())] = normalizeQueryValue(iter.Value().Interface()) + } + return out + case reflect.Slice, reflect.Array: + // []byte 在上层已单独处理,这里保留对其它切片/数组的递归规整。 + if rv.Kind() == reflect.Slice && rv.IsNil() { + return nil + } + size := rv.Len() + items := make([]interface{}, size) + for i := 0; i < size; i++ { + items[i] = normalizeQueryValue(rv.Index(i).Interface()) + } + return items + default: + return v + } +} + +func mapKeyToString(key interface{}) string { + if key == nil { + return "null" + } + if s, ok := key.(string); ok { + return s + } + return fmt.Sprintf("%v", key) } func bytesToDisplayValue(b []byte, databaseTypeName string) interface{} { diff --git a/internal/db/query_value_test.go b/internal/db/query_value_test.go index 1b2c140..a19fa26 100644 --- a/internal/db/query_value_test.go +++ b/internal/db/query_value_test.go @@ -2,6 +2,8 @@ package db import "testing" +type duckMapLike map[any]any + func TestNormalizeQueryValueWithDBType_BitBytes(t *testing.T) { v := normalizeQueryValueWithDBType([]byte{0x00}, "BIT") if v != int64(0) { @@ -42,3 +44,40 @@ func TestNormalizeQueryValueWithDBType_ByteFallbacks(t *testing.T) { t.Fatalf("未知类型 0xff 期望返回 0xff,实际=%v(%T)", v, v) } } + +func TestNormalizeQueryValueWithDBType_MapAnyAnyForJSON(t *testing.T) { + input := duckMapLike{ + "id": int64(1), + 1: "one", + true: []interface{}{duckMapLike{2: "two"}}, + "bytes": []byte("ok"), + } + + v := normalizeQueryValueWithDBType(input, "") + root, ok := v.(map[string]interface{}) + if !ok { + t.Fatalf("期望转换为 map[string]interface{},实际=%T", v) + } + + if root["id"] != int64(1) { + t.Fatalf("id 字段异常,实际=%v(%T)", root["id"], root["id"]) + } + if root["1"] != "one" { + t.Fatalf("数字 key 未被字符串化,实际=%v(%T)", root["1"], root["1"]) + } + if root["bytes"] != "ok" { + t.Fatalf("嵌套 []byte 未被转换,实际=%v(%T)", root["bytes"], root["bytes"]) + } + + arr, ok := root["true"].([]interface{}) + if !ok || len(arr) != 1 { + t.Fatalf("bool key 下的数组结构异常,实际=%v(%T)", root["true"], root["true"]) + } + nested, ok := arr[0].(map[string]interface{}) + if !ok { + t.Fatalf("嵌套 map 未被转换,实际=%v(%T)", arr[0], arr[0]) + } + if nested["2"] != "two" { + t.Fatalf("嵌套 map 数字 key 未转换,实际=%v(%T)", nested["2"], nested["2"]) + } +} From 4d0940636d0b671e3b9525cbde1fb2130288f1f9 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 2 Mar 2026 11:10:48 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E2=9C=A8=20feat(frontend-driver):=20?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E7=AE=A1=E7=90=86=E6=94=AF=E6=8C=81=E5=BF=AB?= =?UTF-8?q?=E9=80=9F=E6=90=9C=E7=B4=A2=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动 - 显示“匹配 x / y”统计与无结果提示 - 优化头部区域排版,提升透明/暗色场景下的视觉对齐 --- .../src/components/DriverManagerModal.tsx | 62 +++++++++++++++---- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index b198d5b..ce86735 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -1,5 +1,5 @@ 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 { Alert, Button, Collapse, Input, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd'; import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, ReloadOutlined } from '@ant-design/icons'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { useStore } from '../store'; @@ -90,6 +90,7 @@ 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 normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase(); const buildVersionSelectOptions = (options: DriverVersionOption[]) => { type SelectOption = { value: string; label: string }; @@ -151,6 +152,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>({}); @@ -1075,6 +1077,31 @@ 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}`); @@ -1190,7 +1217,14 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ )} /> - +
+ 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()}”的驱动` + : '暂无驱动数据', + }} />
From 84688e995abcf72ee973c48acdad6d4e70afc013 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 2 Mar 2026 11:46:59 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=F0=9F=94=A7=20fix(connection-modal):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=E6=95=B0=E6=8D=AE=E6=BA=90URI?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E8=A7=A3=E6=9E=90=E5=B9=B6=E6=A0=A1=E6=AD=A3?= =?UTF-8?q?Oracle=E6=9C=8D=E5=8A=A1=E5=90=8D=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle - 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为 - Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑 - 连接弹窗补充 Oracle 服务名输入项与 URI 示例 --- frontend/src/components/ConnectionModal.tsx | 84 +++++++++++++++++---- internal/db/oracle_impl.go | 9 ++- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 5c8ad1b..2b209c8 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -41,6 +41,19 @@ const getDefaultPortByType = (type: string) => { } }; +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 = { @@ -344,6 +357,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) { @@ -441,28 +489,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, }; } @@ -503,6 +545,9 @@ const ConnectionModal: React.FC<{ if (dbType === 'clickhouse') { return 'clickhouse://default:pass@127.0.0.1:9000/default'; } + if (dbType === 'oracle') { + return 'oracle://user:pass@127.0.0.1:1521/ORCLPDB1'; + } return '例如: postgres://user:pass@127.0.0.1:5432/db_name'; }; @@ -1446,6 +1491,17 @@ const ConnectionModal: React.FC<{
)} + {dbType === 'oracle' && ( + + + + )} + {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && ( <> diff --git a/internal/db/oracle_impl.go b/internal/db/oracle_impl.go index 43ec441..727e82c 100644 --- a/internal/db/oracle_impl.go +++ b/internal/db/oracle_impl.go @@ -26,10 +26,7 @@ type OracleDB struct { func (o *OracleDB) getDSN(config connection.ConnectionConfig) string { // oracle://user:pass@host:port/service_name - database := config.Database - if database == "" { - database = config.User // Default to user service/schema if empty? - } + database := strings.TrimSpace(config.Database) u := &url.URL{ Scheme: "oracle", @@ -44,6 +41,10 @@ func (o *OracleDB) getDSN(config connection.ConnectionConfig) string { func (o *OracleDB) Connect(config connection.ConnectionConfig) error { var dsn string var err error + serviceName := strings.TrimSpace(config.Database) + if serviceName == "" { + return fmt.Errorf("Oracle 连接缺少服务名(Service Name),请在连接配置中填写,例如 ORCLPDB1") + } if config.UseSSH { // Create SSH tunnel with local port forwarding From 3ca898a95032a2b78ac2ab9bdacb2b31673a168b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 2 Mar 2026 14:18:44 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=90=9B=20fix(query-export):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9F=A5=E8=AF=A2=E7=BB=93=E6=9E=9C=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=8D=A1=E4=BD=8F=E5=B9=B6=E7=BB=9F=E4=B8=80=E6=8C=89?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E8=83=BD=E5=8A=9B=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈 - DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级 - QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致 - 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性 --- .github/workflows/release.yml | 12 +- cmd/optional-driver-agent/main.go | 45 +++++- cmd/optional-driver-agent/main_test.go | 110 +++++++++++++ docs/driver-manifest.json | 2 +- frontend/src/components/DataGrid.tsx | 146 ++++++++++++------ frontend/src/components/DataViewer.tsx | 8 +- frontend/src/components/QueryEditor.tsx | 33 +++- frontend/src/utils/dataSourceCapabilities.ts | 86 +++++++++++ internal/app/methods_driver.go | 4 +- internal/app/methods_file.go | 67 +++++++- internal/app/methods_file_export_test.go | 116 ++++++++++++++ internal/db/clickhouse_impl.go | 11 +- internal/db/dsn_test.go | 26 +++- internal/db/optional_driver_agent_impl.go | 45 +++++- .../db/optional_driver_agent_impl_test.go | 32 ++++ 15 files changed, 672 insertions(+), 71 deletions(-) create mode 100644 frontend/src/utils/dataSourceCapabilities.ts create mode 100644 internal/db/optional_driver_agent_impl_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e0cb32..b373353 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -379,7 +379,7 @@ jobs: - name: List Assets run: ls -R release-assets - - name: Verify DuckDB Driver Assets + - name: Verify Optional Driver Assets shell: bash run: | set -euo pipefail @@ -390,20 +390,24 @@ jobs: "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 "❌ 缺少 DuckDB 驱动资产:$file" + echo "❌ 缺少驱动资产:$file" missing=1 else - echo "✅ 已找到 DuckDB 驱动资产:$file" + echo "✅ 已找到驱动资产:$file" fi done if [ "$missing" -ne 0 ]; then - echo "❌ DuckDB 驱动资产不完整,终止发布" + echo "❌ 可选驱动资产不完整,终止发布" exit 1 fi diff --git a/cmd/optional-driver-agent/main.go b/cmd/optional-driver-agent/main.go index 63f6945..4c0c5b9 100644 --- a/cmd/optional-driver-agent/main.go +++ b/cmd/optional-driver-agent/main.go @@ -2,11 +2,13 @@ package main import ( "bufio" + "context" "encoding/json" "fmt" "os" "reflect" "strings" + "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" @@ -17,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"` @@ -48,6 +51,8 @@ const ( agentMethodApplyChanges = "applyChanges" ) +const legacyClickHouseDefaultTimeout = 2 * time.Hour + var ( agentDriverType string agentDatabaseFactory func() db.Database @@ -138,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()) } @@ -287,3 +292,39 @@ func normalizeAgentResponseData(v interface{}) interface{} { 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 index e74c805..016e520 100644 --- a/cmd/optional-driver-agent/main_test.go +++ b/cmd/optional-driver-agent/main_test.go @@ -3,8 +3,13 @@ package main import ( "bufio" "bytes" + "context" "encoding/json" + "errors" "testing" + "time" + + "GoNavi-Wails/internal/connection" ) type duckMapLike map[any]any @@ -60,3 +65,108 @@ func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) { 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 2352ea1..d04fba3 100644 --- a/docs/driver-manifest.json +++ b/docs/driver-manifest.json @@ -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/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 797a1ab..51f6d2b 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 { @@ -302,6 +303,7 @@ const DataContext = React.createContext<{ copyToClipboard: (t: string) => void; tableName?: string; enableRowContextMenu: boolean; + supportsCopyInsert: boolean; } | null>(null); interface Item { @@ -444,7 +446,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 +462,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 +504,8 @@ interface DataGridProps { columnNames: string[]; loading: boolean; tableName?: string; + exportScope?: 'table' | 'queryResult'; + resultSql?: string; dbName?: string; connectionId?: string; pkColumns?: string[]; @@ -543,7 +547,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 +563,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) => { @@ -687,11 +697,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); @@ -2101,6 +2120,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 +2133,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 +2172,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 +2226,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 +2257,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 +2270,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(); @@ -2411,7 +2464,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, @@ -2456,8 +2510,8 @@ const DataGrid: React.FC = ({ setSelectedRowKeys([]); onReload(); }}>刷新} - {tableName && } - {tableName && } + {canImport && } + {canExport && } {canModifyData && ( <> @@ -2996,21 +3050,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 +
+ )}
= ({ tab }) => { const [showFilter, setShowFilter] = useState(false); const [filterConditions, setFilterConditions] = useState([]); const duckdbSafeSelectCacheRef = useRef>({}); - const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase(); - const forceReadOnly = currentConnType === 'tdengine' || currentConnType === 'clickhouse'; + const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; + const currentConnCaps = getDataSourceCapabilities(currentConnConfig); + const currentConnType = currentConnCaps.type; + const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult; useEffect(() => { setPkColumns([]); @@ -673,6 +676,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/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 347f43e..2d6a36e 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import Editor, { OnMount } from '@monaco-editor/react'; import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd'; import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined } from '@ant-design/icons'; @@ -7,6 +7,7 @@ import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; import { DBQuery, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; +import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); @@ -14,6 +15,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { type ResultSet = { key: string; sql: string; + exportSql?: string; rows: any[]; columns: string[]; tableName?: string; @@ -47,6 +49,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const visibleDbsRef = useRef([]); // 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 /> + + {redisTopology === 'cluster' && ( + + + + diff --git a/frontend/src/store.ts b/frontend/src/store.ts index beaea1b..4a4b320 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -199,7 +199,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { proxy, uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH), hosts: sanitizeAddressList(raw.hosts), - topology: raw.topology === 'replica' ? 'replica' : 'single', + topology: raw.topology === 'replica' ? 'replica' : (raw.topology === 'cluster' ? 'cluster' : 'single'), mysqlReplicaUser: toTrimmedString(raw.mysqlReplicaUser), mysqlReplicaPassword: savePassword ? toTrimmedString(raw.mysqlReplicaPassword) : '', replicaSet: toTrimmedString(raw.replicaSet), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e8a6cb4..2bc8dac 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -32,7 +32,7 @@ export interface ConnectionConfig { redisDB?: number; // Redis database index (0-15) uri?: string; // Connection URI for copy/paste hosts?: string[]; // Multi-host addresses: host:port - topology?: 'single' | 'replica'; + topology?: 'single' | 'replica' | 'cluster'; mysqlReplicaUser?: string; mysqlReplicaPassword?: string; replicaSet?: string; diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index e88d79d..1b626b0 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -67,24 +67,27 @@ func getRedisClientCacheKey(config connection.ConnectionConfig) string { } func formatRedisConnSummary(config connection.ConnectionConfig) string { - timeoutSeconds := config.Timeout - if timeoutSeconds <= 0 { - timeoutSeconds = 30 - } - var b strings.Builder b.WriteString("类型=redis 地址=") b.WriteString(config.Host) b.WriteString(":") - b.WriteString(string(rune(config.Port + '0'))) + b.WriteString(strconv.Itoa(config.Port)) + if topology := strings.TrimSpace(config.Topology); topology != "" { + b.WriteString(" 模式=") + b.WriteString(topology) + } + if len(config.Hosts) > 0 { + b.WriteString(" 节点数=") + b.WriteString(strconv.Itoa(len(config.Hosts))) + } b.WriteString(" DB=") - b.WriteString(string(rune(config.RedisDB + '0'))) + b.WriteString(strconv.Itoa(config.RedisDB)) if config.UseSSH { b.WriteString(" SSH=") b.WriteString(config.SSH.Host) b.WriteString(":") - b.WriteString(string(rune(config.SSH.Port + '0'))) + b.WriteString(strconv.Itoa(config.SSH.Port)) b.WriteString(" 用户=") b.WriteString(config.SSH.User) } diff --git a/internal/connection/types.go b/internal/connection/types.go index cfc0253..20b4cbb 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -37,7 +37,7 @@ type ConnectionConfig struct { RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) URI string `json:"uri,omitempty"` // Connection URI for copy/paste Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port - Topology string `json:"topology,omitempty"` // single | replica + Topology string `json:"topology,omitempty"` // single | replica | cluster MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name diff --git a/internal/redis/redis.go b/internal/redis/redis.go index 80e58f6..d9e776b 100644 --- a/internal/redis/redis.go +++ b/internal/redis/redis.go @@ -12,7 +12,7 @@ type RedisValue struct { // RedisDBInfo represents information about a Redis database type RedisDBInfo struct { - Index int `json:"index"` // Database index (0-15) + Index int `json:"index"` // Database index (single: 0-15, cluster: logical 0-15) Keys int64 `json:"keys"` // Number of keys in this database } diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index 044f16d..f08b4f5 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -3,8 +3,10 @@ package redis import ( "context" "fmt" + "net" "strconv" "strings" + "sync" "time" "GoNavi-Wails/internal/connection" @@ -16,10 +18,14 @@ import ( // RedisClientImpl implements RedisClient using go-redis type RedisClientImpl struct { - client *redis.Client - config connection.ConnectionConfig - currentDB int - forwarder *ssh.LocalForwarder + client redis.UniversalClient + singleClient *redis.Client + clusterClient *redis.ClusterClient + config connection.ConnectionConfig + currentDB int + isCluster bool + seedAddrs []string + forwarder *ssh.LocalForwarder } const ( @@ -40,14 +46,183 @@ func NewRedisClient() RedisClient { return &RedisClientImpl{} } +func normalizeRedisTimeout(timeoutSeconds int) time.Duration { + if timeoutSeconds <= 0 { + return 30 * time.Second + } + return time.Duration(timeoutSeconds) * time.Second +} + +func normalizeRedisSeedAddress(raw string, defaultPort int) (string, error) { + addr := strings.TrimSpace(raw) + if addr == "" { + return "", fmt.Errorf("Redis 节点地址不能为空") + } + + if _, _, err := net.SplitHostPort(addr); err == nil { + return addr, nil + } + + if !strings.Contains(addr, ":") { + return net.JoinHostPort(addr, strconv.Itoa(defaultPort)), nil + } + + // 尝试兼容 host:port 但端口格式异常的场景。 + host, port, ok := strings.Cut(addr, ":") + if !ok { + return "", fmt.Errorf("无效 Redis 节点地址: %s", addr) + } + host = strings.TrimSpace(host) + port = strings.TrimSpace(port) + if host == "" { + return "", fmt.Errorf("无效 Redis 节点地址: %s", addr) + } + if _, err := strconv.Atoi(port); err != nil { + return "", fmt.Errorf("无效 Redis 端口: %s", addr) + } + return net.JoinHostPort(host, port), nil +} + +func buildRedisSeedAddrs(config connection.ConnectionConfig) ([]string, error) { + defaultPort := config.Port + if defaultPort <= 0 { + defaultPort = 6379 + } + + candidates := make([]string, 0, 1+len(config.Hosts)) + if strings.TrimSpace(config.Host) != "" { + candidates = append(candidates, fmt.Sprintf("%s:%d", strings.TrimSpace(config.Host), defaultPort)) + } + candidates = append(candidates, config.Hosts...) + + seen := make(map[string]struct{}, len(candidates)) + addrs := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + normalized, err := normalizeRedisSeedAddress(candidate, defaultPort) + if err != nil { + return nil, err + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + addrs = append(addrs, normalized) + } + if len(addrs) == 0 { + return nil, fmt.Errorf("Redis 连接地址不能为空") + } + return addrs, nil +} + +func (r *RedisClientImpl) redisNamespacePrefixForDB(index int) string { + if !r.isCluster || index <= 0 { + return "" + } + // Redis Cluster 仅支持物理 db0;这里用固定前缀模拟逻辑库隔离。 + return fmt.Sprintf("__gonavi_db_%d__:", index) +} + +func (r *RedisClientImpl) redisNamespacePrefix() string { + return r.redisNamespacePrefixForDB(r.currentDB) +} + +func (r *RedisClientImpl) toPhysicalKey(key string) string { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return "" + } + prefix := r.redisNamespacePrefix() + if prefix == "" || strings.HasPrefix(trimmed, prefix) { + return trimmed + } + return prefix + trimmed +} + +func (r *RedisClientImpl) toPhysicalPattern(pattern string) string { + normalized := strings.TrimSpace(pattern) + if normalized == "" { + normalized = "*" + } + prefix := r.redisNamespacePrefix() + if prefix == "" { + return normalized + } + return prefix + normalized +} + +func (r *RedisClientImpl) toPhysicalKeys(keys []string) []string { + if len(keys) == 0 { + return nil + } + result := make([]string, 0, len(keys)) + for _, key := range keys { + physical := r.toPhysicalKey(key) + if physical == "" { + continue + } + result = append(result, physical) + } + return result +} + +func (r *RedisClientImpl) toDisplayKey(key string) string { + prefix := r.redisNamespacePrefix() + if prefix == "" { + return key + } + return strings.TrimPrefix(key, prefix) +} + // Connect establishes a connection to Redis func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error { r.config = config - r.currentDB = config.RedisDB + if r.config.RedisDB < 0 || r.config.RedisDB > 15 { + r.config.RedisDB = 0 + } + r.currentDB = r.config.RedisDB + r.forwarder = nil + r.client = nil + r.singleClient = nil + r.clusterClient = nil + r.isCluster = false - addr := fmt.Sprintf("%s:%d", config.Host, config.Port) + seedAddrs, err := buildRedisSeedAddrs(config) + if err != nil { + return err + } + r.seedAddrs = append([]string(nil), seedAddrs...) - // Handle SSH tunnel if enabled + topology := strings.ToLower(strings.TrimSpace(config.Topology)) + r.isCluster = topology == "cluster" || len(seedAddrs) > 1 + + if r.isCluster && config.UseSSH { + return fmt.Errorf("Redis 集群模式暂不支持 SSH 隧道,请关闭 SSH 后重试") + } + + timeout := normalizeRedisTimeout(config.Timeout) + if r.isCluster { + opts := &redis.ClusterOptions{ + Addrs: seedAddrs, + Username: strings.TrimSpace(config.User), + Password: config.Password, + DialTimeout: timeout, + ReadTimeout: timeout, + WriteTimeout: timeout, + } + clusterClient := redis.NewClusterClient(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := clusterClient.Ping(ctx).Err(); err != nil { + clusterClient.Close() + return fmt.Errorf("Redis 集群连接失败: %w", err) + } + r.client = clusterClient + r.clusterClient = clusterClient + logger.Infof("Redis 集群连接成功: seeds=%s 逻辑库=db%d", strings.Join(seedAddrs, ","), r.currentDB) + return nil + } + + addr := seedAddrs[0] if config.UseSSH { forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port) if err != nil { @@ -60,32 +235,26 @@ func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error { opts := &redis.Options{ Addr: addr, + Username: strings.TrimSpace(config.User), Password: config.Password, - DB: config.RedisDB, - DialTimeout: time.Duration(config.Timeout) * time.Second, - ReadTimeout: time.Duration(config.Timeout) * time.Second, - WriteTimeout: time.Duration(config.Timeout) * time.Second, + DB: r.currentDB, + DialTimeout: timeout, + ReadTimeout: timeout, + WriteTimeout: timeout, } - if opts.DialTimeout == 0 { - opts.DialTimeout = 30 * time.Second - opts.ReadTimeout = 30 * time.Second - opts.WriteTimeout = 30 * time.Second - } - - r.client = redis.NewClient(opts) - - // Test connection - ctx, cancel := context.WithTimeout(context.Background(), opts.DialTimeout) + singleClient := redis.NewClient(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - if err := r.client.Ping(ctx).Err(); err != nil { - r.client.Close() - r.client = nil + if err := singleClient.Ping(ctx).Err(); err != nil { + singleClient.Close() return fmt.Errorf("Redis 连接失败: %w", err) } - logger.Infof("Redis 连接成功: %s DB=%d", addr, config.RedisDB) + r.client = singleClient + r.singleClient = singleClient + logger.Infof("Redis 连接成功: %s DB=%d", addr, r.currentDB) return nil } @@ -94,6 +263,11 @@ func (r *RedisClientImpl) Close() error { if r.client != nil { err := r.client.Close() r.client = nil + r.singleClient = nil + r.clusterClient = nil + r.isCluster = false + r.seedAddrs = nil + r.forwarder = nil return err } return nil @@ -118,6 +292,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( if pattern == "" { pattern = "*" } + physicalPattern := r.toPhysicalPattern(pattern) isSearchPattern := pattern != "*" targetCount := normalizeRedisScanTargetCount(count) @@ -150,7 +325,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( break } - batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result() + batch, nextCursor, err := r.client.Scan(ctx, currentCursor, physicalPattern, scanStepCount).Result() if err != nil { return nil, err } @@ -226,7 +401,7 @@ func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) ttlValue = -2 } result = append(result, RedisKeyInfo{ - Key: key, + Key: r.toDisplayKey(key), Type: keyType, TTL: toRedisTTLSeconds(ttlValue), }) @@ -236,7 +411,7 @@ func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) for i, key := range keys { result = append(result, RedisKeyInfo{ - Key: key, + Key: r.toDisplayKey(key), Type: typeResults[i].Val(), TTL: toRedisTTLSeconds(ttlResults[i].Val()), }) @@ -261,7 +436,7 @@ func (r *RedisClientImpl) GetKeyType(key string) (string, error) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return r.client.Type(ctx, key).Result() + return r.client.Type(ctx, r.toPhysicalKey(key)).Result() } // GetTTL returns the TTL of a key in seconds @@ -272,7 +447,7 @@ func (r *RedisClientImpl) GetTTL(key string) (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - ttl, err := r.client.TTL(ctx, key).Result() + ttl, err := r.client.TTL(ctx, r.toPhysicalKey(key)).Result() if err != nil { return 0, err } @@ -295,9 +470,9 @@ func (r *RedisClientImpl) SetTTL(key string, ttl int64) error { if ttl < 0 { // Remove expiry - return r.client.Persist(ctx, key).Err() + return r.client.Persist(ctx, r.toPhysicalKey(key)).Err() } - return r.client.Expire(ctx, key, time.Duration(ttl)*time.Second).Err() + return r.client.Expire(ctx, r.toPhysicalKey(key), time.Duration(ttl)*time.Second).Err() } // DeleteKeys deletes one or more keys @@ -307,7 +482,11 @@ func (r *RedisClientImpl) DeleteKeys(keys []string) (int64, error) { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - return r.client.Del(ctx, keys...).Result() + physicalKeys := r.toPhysicalKeys(keys) + if len(physicalKeys) == 0 { + return 0, nil + } + return r.client.Del(ctx, physicalKeys...).Result() } // RenameKey renames a key @@ -317,7 +496,7 @@ func (r *RedisClientImpl) RenameKey(oldKey, newKey string) error { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return r.client.Rename(ctx, oldKey, newKey).Err() + return r.client.Rename(ctx, r.toPhysicalKey(oldKey), r.toPhysicalKey(newKey)).Err() } // KeyExists checks if a key exists @@ -327,7 +506,7 @@ func (r *RedisClientImpl) KeyExists(key string) (bool, error) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - n, err := r.client.Exists(ctx, key).Result() + n, err := r.client.Exists(ctx, r.toPhysicalKey(key)).Result() return n > 0, err } @@ -343,6 +522,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { } ttl, _ := r.GetTTL(key) + physicalKey := r.toPhysicalKey(key) result := &RedisValue{ Type: keyType, @@ -354,7 +534,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { switch keyType { case "string": - val, err := r.client.Get(ctx, key).Result() + val, err := r.client.Get(ctx, physicalKey).Result() if err != nil { return nil, err } @@ -362,7 +542,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { result.Length = int64(len(val)) case "hash": - val, err := r.client.HGetAll(ctx, key).Result() + val, err := r.client.HGetAll(ctx, physicalKey).Result() if err != nil { return nil, err } @@ -370,7 +550,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { result.Length = int64(len(val)) case "list": - length, err := r.client.LLen(ctx, key).Result() + length, err := r.client.LLen(ctx, physicalKey).Result() if err != nil { return nil, err } @@ -379,7 +559,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { if length < limit { limit = length } - val, err := r.client.LRange(ctx, key, 0, limit-1).Result() + val, err := r.client.LRange(ctx, physicalKey, 0, limit-1).Result() if err != nil { return nil, err } @@ -387,12 +567,12 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { result.Length = length case "set": - length, err := r.client.SCard(ctx, key).Result() + length, err := r.client.SCard(ctx, physicalKey).Result() if err != nil { return nil, err } // Get members using SMembers (limited by Redis server) - members, err := r.client.SMembers(ctx, key).Result() + members, err := r.client.SMembers(ctx, physicalKey).Result() if err != nil { return nil, err } @@ -400,7 +580,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { result.Length = length case "zset": - length, err := r.client.ZCard(ctx, key).Result() + length, err := r.client.ZCard(ctx, physicalKey).Result() if err != nil { return nil, err } @@ -409,7 +589,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { if length < limit { limit = length } - val, err := r.client.ZRangeWithScores(ctx, key, 0, limit-1).Result() + val, err := r.client.ZRangeWithScores(ctx, physicalKey, 0, limit-1).Result() if err != nil { return nil, err } @@ -424,7 +604,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { result.Length = length case "stream": - length, err := r.client.XLen(ctx, key).Result() + length, err := r.client.XLen(ctx, physicalKey).Result() if err != nil { return nil, err } @@ -437,7 +617,7 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { if length < limit { limit = length } - val, err := r.client.XRangeN(ctx, key, "-", "+", limit).Result() + val, err := r.client.XRangeN(ctx, physicalKey, "-", "+", limit).Result() if err != nil { return nil, err } @@ -457,7 +637,7 @@ func (r *RedisClientImpl) GetString(key string) (string, error) { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return r.client.Get(ctx, key).Result() + return r.client.Get(ctx, r.toPhysicalKey(key)).Result() } // SetString sets a string value with optional TTL @@ -472,7 +652,7 @@ func (r *RedisClientImpl) SetString(key, value string, ttl int64) error { if ttl > 0 { expiration = time.Duration(ttl) * time.Second } - return r.client.Set(ctx, key, value, expiration).Err() + return r.client.Set(ctx, r.toPhysicalKey(key), value, expiration).Err() } // GetHash gets all fields of a hash @@ -482,7 +662,7 @@ func (r *RedisClientImpl) GetHash(key string) (map[string]string, error) { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - return r.client.HGetAll(ctx, key).Result() + return r.client.HGetAll(ctx, r.toPhysicalKey(key)).Result() } // SetHashField sets a field in a hash @@ -492,7 +672,7 @@ func (r *RedisClientImpl) SetHashField(key, field, value string) error { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return r.client.HSet(ctx, key, field, value).Err() + return r.client.HSet(ctx, r.toPhysicalKey(key), field, value).Err() } // DeleteHashField deletes fields from a hash @@ -502,7 +682,7 @@ func (r *RedisClientImpl) DeleteHashField(key string, fields ...string) error { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return r.client.HDel(ctx, key, fields...).Err() + return r.client.HDel(ctx, r.toPhysicalKey(key), fields...).Err() } // GetList gets a range of elements from a list @@ -512,7 +692,7 @@ func (r *RedisClientImpl) GetList(key string, start, stop int64) ([]string, erro } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - return r.client.LRange(ctx, key, start, stop).Result() + return r.client.LRange(ctx, r.toPhysicalKey(key), start, stop).Result() } // ListPush pushes values to the end of a list @@ -526,7 +706,7 @@ func (r *RedisClientImpl) ListPush(key string, values ...string) error { for i, v := range values { args[i] = v } - return r.client.RPush(ctx, key, args...).Err() + return r.client.RPush(ctx, r.toPhysicalKey(key), args...).Err() } // ListSet sets the value at an index in a list @@ -536,7 +716,7 @@ func (r *RedisClientImpl) ListSet(key string, index int64, value string) error { } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return r.client.LSet(ctx, key, index, value).Err() + return r.client.LSet(ctx, r.toPhysicalKey(key), index, value).Err() } // GetSet gets all members of a set @@ -546,7 +726,7 @@ func (r *RedisClientImpl) GetSet(key string) ([]string, error) { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - return r.client.SMembers(ctx, key).Result() + return r.client.SMembers(ctx, r.toPhysicalKey(key)).Result() } // SetAdd adds members to a set @@ -560,7 +740,7 @@ func (r *RedisClientImpl) SetAdd(key string, members ...string) error { for i, m := range members { args[i] = m } - return r.client.SAdd(ctx, key, args...).Err() + return r.client.SAdd(ctx, r.toPhysicalKey(key), args...).Err() } // SetRemove removes members from a set @@ -574,7 +754,7 @@ func (r *RedisClientImpl) SetRemove(key string, members ...string) error { for i, m := range members { args[i] = m } - return r.client.SRem(ctx, key, args...).Err() + return r.client.SRem(ctx, r.toPhysicalKey(key), args...).Err() } // GetZSet gets members with scores from a sorted set @@ -585,7 +765,7 @@ func (r *RedisClientImpl) GetZSet(key string, start, stop int64) ([]ZSetMember, ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - val, err := r.client.ZRangeWithScores(ctx, key, start, stop).Result() + val, err := r.client.ZRangeWithScores(ctx, r.toPhysicalKey(key), start, stop).Result() if err != nil { return nil, err } @@ -615,7 +795,7 @@ func (r *RedisClientImpl) ZSetAdd(key string, members ...ZSetMember) error { Member: m.Member, } } - return r.client.ZAdd(ctx, key, zMembers...).Err() + return r.client.ZAdd(ctx, r.toPhysicalKey(key), zMembers...).Err() } // ZSetRemove removes members from a sorted set @@ -629,7 +809,7 @@ func (r *RedisClientImpl) ZSetRemove(key string, members ...string) error { for i, m := range members { args[i] = m } - return r.client.ZRem(ctx, key, args...).Err() + return r.client.ZRem(ctx, r.toPhysicalKey(key), args...).Err() } // GetStream gets stream entries in a range @@ -650,7 +830,7 @@ func (r *RedisClientImpl) GetStream(key, start, stop string, count int64) ([]Str ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - val, err := r.client.XRangeN(ctx, key, start, stop, count).Result() + val, err := r.client.XRangeN(ctx, r.toPhysicalKey(key), start, stop, count).Result() if err != nil { return nil, err } @@ -678,7 +858,7 @@ func (r *RedisClientImpl) StreamAdd(key string, fields map[string]string, id str defer cancel() newID, err := r.client.XAdd(ctx, &redis.XAddArgs{ - Stream: key, + Stream: r.toPhysicalKey(key), ID: id, Values: values, }).Result() @@ -699,7 +879,7 @@ func (r *RedisClientImpl) StreamDelete(key string, ids ...string) (int64, error) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return r.client.XDel(ctx, key, ids...).Result() + return r.client.XDel(ctx, r.toPhysicalKey(key), ids...).Result() } func toStreamEntries(messages []redis.XMessage) []StreamEntry { @@ -717,6 +897,72 @@ func toStreamEntries(messages []redis.XMessage) []StreamEntry { return entries } +func parseRedisCommandGetKeysResult(result interface{}) []string { + items, ok := result.([]interface{}) + if !ok || len(items) == 0 { + return nil + } + keys := make([]string, 0, len(items)) + for _, item := range items { + switch v := item.(type) { + case string: + if v != "" { + keys = append(keys, v) + } + case []byte: + text := string(v) + if text != "" { + keys = append(keys, text) + } + } + } + return keys +} + +func (r *RedisClientImpl) rewriteCommandArgsForNamespace(ctx context.Context, args []string) []string { + if !r.isCluster || r.currentDB <= 0 || len(args) == 0 { + return args + } + + command := strings.ToUpper(strings.TrimSpace(args[0])) + if command == "COMMAND" || command == "SELECT" || command == "FLUSHDB" { + return args + } + + probeArgs := make([]interface{}, 0, len(args)+2) + probeArgs = append(probeArgs, "COMMAND", "GETKEYS") + for _, arg := range args { + probeArgs = append(probeArgs, arg) + } + + result, err := r.client.Do(ctx, probeArgs...).Result() + if err != nil { + return args + } + + keyCandidates := parseRedisCommandGetKeysResult(result) + if len(keyCandidates) == 0 { + return args + } + + rewritten := append([]string(nil), args...) + used := make([]bool, len(rewritten)) + for _, key := range keyCandidates { + for i := 1; i < len(rewritten); i++ { + if used[i] { + continue + } + if rewritten[i] != key { + continue + } + rewritten[i] = r.toPhysicalKey(rewritten[i]) + used[i] = true + break + } + } + return rewritten +} + // ExecuteCommand executes a raw Redis command func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) { if r.client == nil { @@ -729,6 +975,33 @@ func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + if r.isCluster { + command := strings.ToUpper(strings.TrimSpace(args[0])) + switch command { + case "SELECT": + if len(args) < 2 { + return nil, fmt.Errorf("SELECT 命令缺少数据库索引") + } + index, err := strconv.Atoi(strings.TrimSpace(args[1])) + if err != nil { + return nil, fmt.Errorf("无效数据库索引: %s", args[1]) + } + if index < 0 || index > 15 { + return nil, fmt.Errorf("数据库索引必须在 0-15 之间") + } + r.currentDB = index + r.config.RedisDB = index + return "OK", nil + case "FLUSHDB": + if err := r.FlushDB(); err != nil { + return nil, err + } + return "OK", nil + } + } + + args = r.rewriteCommandArgsForNamespace(ctx, args) + // Convert to []interface{} cmdArgs := make([]interface{}, len(args)) for i, arg := range args { @@ -795,6 +1068,31 @@ func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + if r.isCluster && r.clusterClient != nil { + var totalKeys int64 + var mu sync.Mutex + err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error { + keys, err := node.DBSize(nodeCtx).Result() + if err != nil { + return err + } + mu.Lock() + totalKeys += keys + mu.Unlock() + return nil + }) + if err != nil { + logger.Warnf("Redis 集群获取 key 数量失败,回退为 0: %v", err) + totalKeys = 0 + } + result := make([]RedisDBInfo, 16) + for i := 0; i < 16; i++ { + result[i] = RedisDBInfo{Index: i, Keys: 0} + } + result[0].Keys = totalKeys + return result, nil + } + // Get keyspace info info, err := r.client.Info(ctx, "keyspace").Result() if err != nil { @@ -845,34 +1143,47 @@ func (r *RedisClientImpl) SelectDB(index int) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } + + if r.isCluster { + if index < 0 || index > 15 { + return fmt.Errorf("数据库索引必须在 0-15 之间") + } + r.currentDB = index + r.config.RedisDB = index + return nil + } + if index < 0 || index > 15 { return fmt.Errorf("数据库索引必须在 0-15 之间") } // Create new client with different DB - addr := fmt.Sprintf("%s:%d", r.config.Host, r.config.Port) + addr := "" + if len(r.seedAddrs) > 0 { + addr = r.seedAddrs[0] + } if r.forwarder != nil { addr = r.forwarder.LocalAddr } + if addr == "" { + addr = fmt.Sprintf("%s:%d", r.config.Host, r.config.Port) + } + + timeout := normalizeRedisTimeout(r.config.Timeout) opts := &redis.Options{ Addr: addr, + Username: strings.TrimSpace(r.config.User), Password: r.config.Password, DB: index, - DialTimeout: time.Duration(r.config.Timeout) * time.Second, - ReadTimeout: time.Duration(r.config.Timeout) * time.Second, - WriteTimeout: time.Duration(r.config.Timeout) * time.Second, - } - - if opts.DialTimeout == 0 { - opts.DialTimeout = 30 * time.Second - opts.ReadTimeout = 30 * time.Second - opts.WriteTimeout = 30 * time.Second + DialTimeout: timeout, + ReadTimeout: timeout, + WriteTimeout: timeout, } newClient := redis.NewClient(opts) - ctx, cancel := context.WithTimeout(context.Background(), opts.DialTimeout) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if err := newClient.Ping(ctx).Err(); err != nil { @@ -881,9 +1192,14 @@ func (r *RedisClientImpl) SelectDB(index int) error { } // Close old client and replace - r.client.Close() + if r.client != nil { + _ = r.client.Close() + } r.client = newClient + r.singleClient = newClient + r.clusterClient = nil r.currentDB = index + r.config.RedisDB = index logger.Infof("Redis 切换到数据库: db%d", index) return nil @@ -899,6 +1215,63 @@ func (r *RedisClientImpl) FlushDB() error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } + + if r.isCluster && r.clusterClient != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + namespacePrefix := r.redisNamespacePrefix() + var deletedTotal int64 + var deletedMu sync.Mutex + + err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error { + var cursor uint64 + for { + pattern := "*" + if namespacePrefix != "" { + pattern = namespacePrefix + "*" + } + keys, nextCursor, err := node.Scan(nodeCtx, cursor, pattern, 2000).Result() + if err != nil { + return err + } + + if namespacePrefix == "" { + filtered := keys[:0] + for _, key := range keys { + // db0 保留兼容:不删除逻辑库前缀 key,避免误清理 db1~db15。 + if strings.HasPrefix(key, "__gonavi_db_") { + continue + } + filtered = append(filtered, key) + } + keys = filtered + } + + if len(keys) > 0 { + deleted, err := node.Del(nodeCtx, keys...).Result() + if err != nil { + return err + } + deletedMu.Lock() + deletedTotal += deleted + deletedMu.Unlock() + } + + cursor = nextCursor + if cursor == 0 { + break + } + } + return nil + }) + if err != nil { + return err + } + logger.Infof("Redis 集群逻辑库清空完成: db%d deleted=%d", r.currentDB, deletedTotal) + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return r.client.FlushDB(ctx).Err() From e76e174bfe50185f6f68fcf909d63a516f9748e7 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 3 Mar 2026 13:49:31 +0800 Subject: [PATCH 11/12] =?UTF-8?q?=E2=9C=A8=20feat(DataGrid):=20=E5=A4=A7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=A1=A8=E8=99=9A=E6=8B=9F=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96=E5=8F=8AUI=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题 - 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM - 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条 - 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动) - 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题 - 新增白色主题全局滚动条样式适配透明模式(App.css) - App.tsx主题token与组件样式优化 - refs #147 --- frontend/src/App.css | 29 +- frontend/src/App.tsx | 99 +++- frontend/src/components/DataGrid.tsx | 783 ++++++++++++++++++++----- frontend/src/components/TabManager.tsx | 94 +-- frontend/src/store.ts | 35 ++ 5 files changed, 844 insertions(+), 196 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 72a84c1..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; @@ -102,11 +125,13 @@ 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 > 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 > 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; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e97f1c7..ac328a7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,12 @@ 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; function App() { const [isModalOpen, setIsModalOpen] = useState(false); @@ -26,11 +32,28 @@ 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); @@ -834,7 +857,9 @@ 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; @@ -916,9 +941,16 @@ function App() { return ( -
+
{/* Logo can be added here if available */} GoNavi
@@ -1007,13 +1040,13 @@ function App() {
@@ -1029,13 +1062,13 @@ function App() {
setIsLogPanelOpen(!isLogPanelOpen)} style={isLogPanelOpen ? { width: '100%', - height: 34, + height: floatingLogButtonHeight, borderRadius: 999, boxShadow: floatingLogButtonShadow, pointerEvents: 'auto' } : { width: '100%', - height: 34, + height: floatingLogButtonHeight, borderRadius: 999, border: `1px solid ${floatingLogButtonBorderColor}`, color: floatingLogButtonTextColor, @@ -1216,6 +1249,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)
@@ -1264,6 +1328,17 @@ function App() { * 修改后下次启动生效
+
+ +
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 012c2bb..05ccbba 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -65,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); @@ -75,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; // 保持原样显示,不尝试转换 @@ -87,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 => { @@ -104,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) { @@ -138,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; @@ -318,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; } @@ -329,6 +377,7 @@ const EditableCell: React.FC = React.memo(({ record, handleSave, focusCell, + as: Component = 'td', ...restProps }) => { const [editing, setEditing] = useState(false); @@ -430,14 +479,14 @@ const EditableCell: React.FC = React.memo(({ }; return ( - {childNode} - + ); }); @@ -596,6 +645,31 @@ const DataGrid: React.FC = ({ 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; @@ -635,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); // 批量编辑模式状态 @@ -885,8 +965,9 @@ 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}
@@ -938,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; @@ -1456,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); @@ -1474,7 +1559,7 @@ const DataGrid: React.FC = ({ setSortInfo(null); if (onSort) onSort('', ''); } - }; + }, [onSort]); // Native Drag State const draggingRef = useRef<{ @@ -1631,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. @@ -1652,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'; @@ -1915,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), @@ -1964,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()}`; @@ -2456,11 +2586,6 @@ const DataGrid: React.FC = ({
); - const tableComponents = useMemo(() => ({ - body: { cell: EditableCell, row: ContextMenuRow }, - header: { cell: ResizableTitle } - }), []); - const dataContextValue = useMemo(() => ({ selectedRowKeysRef, displayDataRef, @@ -2488,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; @@ -2506,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 && {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}} -
+
)} -
+
{hasChanges && (
-
+
- {/* Filter Panel */} {showFilter && (
{filterConditions.map(cond => (
@@ -2762,8 +3120,9 @@ const DataGrid: React.FC = ({
)} +
-
+
{contextHolder} = ({ title={cellEditorMeta ? `编辑单元格:${cellEditorMeta.title}` : '编辑单元格'} open={cellEditorOpen} onCancel={closeCellEditor} + destroyOnHidden width={960} maskClosable={false} footer={[ @@ -2828,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, + }} + /> + )}
{/* 批量编辑弹窗 */} @@ -2875,6 +3237,7 @@ const DataGrid: React.FC = ({ title="编辑 JSON 结果集" open={jsonEditorOpen} onCancel={() => setJsonEditorOpen(false)} + destroyOnHidden width={980} maskClosable={false} footer={[ @@ -2886,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' ? (
@@ -3222,23 +3602,54 @@ const DataGrid: React.FC = ({ .${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(${selectionAccentRgb}, 0.18)` : `rgba(${selectionAccentRgb}, 0.08)`} !important; } - .${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.28)` : `rgba(${selectionAccentRgb}, 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"] { + .${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)`}); } @@ -3251,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; } `} diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 7f61c83..555bf15 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useRef, useState } from 'react'; import { Tabs, Dropdown } from 'antd'; -import type { MenuProps } from 'antd'; +import type { MenuProps, TabsProps } from 'antd'; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core'; import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable'; @@ -35,44 +35,18 @@ const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): }; type SortableTabLabelProps = { - tabId: string; displayTitle: string; menuItems: MenuProps['items']; - draggingTabId: string | null; - onSelect: (tabId: string) => void; }; const SortableTabLabel: React.FC = ({ - tabId, displayTitle, menuItems, - draggingTabId, - onSelect, }) => { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId }); - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)', - opacity: isDragging ? 0.88 : 1, - cursor: isDragging ? 'grabbing' : 'grab', - display: 'inline-flex', - alignItems: 'center', - maxWidth: '100%', - touchAction: 'none', - }; - const isDragBlocked = !!draggingTabId && draggingTabId !== tabId; - return ( { - if (!isDragBlocked) onSelect(tabId); - }} + className="tab-dnd-label" onContextMenu={(e) => e.preventDefault()} title="拖拽调整标签顺序" > @@ -82,9 +56,36 @@ const SortableTabLabel: React.FC = ({ ); }; +type DraggableTabNodeProps = { + node: React.ReactElement; +}; + +const DraggableTabNode: React.FC = ({ node }) => { + const tabId = String(node.key || '').trim(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId }); + const style: React.CSSProperties = { + ...(node.props.style || {}), + transform: CSS.Transform.toString(transform), + transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)', + opacity: isDragging ? 0.88 : 1, + cursor: isDragging ? 'grabbing' : 'grab', + touchAction: 'none', + zIndex: isDragging ? 2 : node.props.style?.zIndex, + }; + + return React.cloneElement(node, { + ref: setNodeRef, + style, + ...attributes, + ...listeners, + className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`, + }); +}; + const TabManager: React.FC = () => { const tabs = useStore(state => state.tabs); const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); const activeTabId = useStore(state => state.activeTabId); const setActiveTab = useStore(state => state.setActiveTab); const closeTab = useStore(state => state.closeTab); @@ -93,6 +94,7 @@ const TabManager: React.FC = () => { const closeTabsToRight = useStore(state => state.closeTabsToRight); const closeAllTabs = useStore(state => state.closeAllTabs); const moveTab = useStore(state => state.moveTab); + const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)'; const [draggingTabId, setDraggingTabId] = useState(null); const suppressClickUntilRef = useRef(0); const sensors = useSensors( @@ -111,11 +113,6 @@ const TabManager: React.FC = () => { } }; - const handleTabSelect = (tabId: string) => { - if (Date.now() < suppressClickUntilRef.current) return; - setActiveTab(tabId); - }; - const handleDragStart = (event: DragStartEvent) => { const sourceId = String(event.active.id || '').trim(); setDraggingTabId(sourceId || null); @@ -138,11 +135,21 @@ const TabManager: React.FC = () => { const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]); + const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => ( + + {(node) => } + + ); + const items = useMemo(() => tabs.map((tab, index) => { const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name; const displayTitle = buildTabDisplayTitle(tab, connectionName); + const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command'; + const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive; let content; - if (tab.type === 'query') { + if (!shouldRenderContent) { + content = null; + } else if (tab.type === 'query') { content = ; } else if (tab.type === 'table') { content = ; @@ -189,17 +196,14 @@ const TabManager: React.FC = () => { return { label: ( ), key: tab.id, children: content, }; - }), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, draggingTabId]); + }), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]); return ( <> @@ -248,7 +252,7 @@ const TabManager: React.FC = () => { display: none !important; } .main-tabs .ant-tabs-nav::before { - border-bottom: none !important; + border-bottom: 1px solid ${tabsNavBorderColor} !important; } .main-tabs .ant-tabs-tab { transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease; @@ -256,8 +260,12 @@ const TabManager: React.FC = () => { .main-tabs .tab-dnd-label { user-select: none; -webkit-user-select: none; + display: inline-flex; + align-items: center; + max-width: 100%; } - .main-tabs .tab-dnd-label.is-dragging { + .main-tabs .tab-dnd-node.is-dragging, + .main-tabs .tab-dnd-node.is-dragging .tab-dnd-label { cursor: grabbing !important; } body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible { @@ -289,11 +297,15 @@ const TabManager: React.FC = () => { { + if (Date.now() < suppressClickUntilRef.current) return; + onChange(newActiveKey); + }} activeKey={activeTabId || undefined} onEdit={onEdit} items={items} hideAdd + renderTabBar={renderTabBar} /> diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 4a4b320..e6d14fc 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -3,6 +3,12 @@ import { persist } from 'zustand/middleware'; import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery } from './types'; const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 }; +const DEFAULT_UI_SCALE = 1.0; +const MIN_UI_SCALE = 0.8; +const MAX_UI_SCALE = 1.25; +const DEFAULT_FONT_SIZE = 14; +const MIN_FONT_SIZE = 12; +const MAX_FONT_SIZE = 20; const DEFAULT_STARTUP_FULLSCREEN = false; const LEGACY_DEFAULT_OPACITY = 0.95; const OPACITY_EPSILON = 1e-6; @@ -107,6 +113,13 @@ const normalizeIntegerInRange = (value: unknown, fallbackValue: number, min: num return normalized; }; +const normalizeFloatInRange = (value: unknown, fallbackValue: number, min: number, max: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallbackValue; + if (parsed < min || parsed > max) return fallbackValue; + return parsed; +}; + const isValidHostEntry = (entry: string): boolean => { if (!entry) return false; if (entry.length > MAX_HOST_ENTRY_LENGTH) return false; @@ -318,6 +331,8 @@ interface AppState { savedQueries: SavedQuery[]; theme: 'light' | 'dark'; appearance: { opacity: number; blur: number }; + uiScale: number; + fontSize: number; startupFullscreen: boolean; globalProxy: GlobalProxyConfig; sqlFormatOptions: { keywordCase: 'upper' | 'lower' }; @@ -347,6 +362,8 @@ interface AppState { setTheme: (theme: 'light' | 'dark') => void; setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void; + setUiScale: (scale: number) => void; + setFontSize: (size: number) => void; setStartupFullscreen: (enabled: boolean) => void; setGlobalProxy: (proxy: Partial) => void; setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void; @@ -441,6 +458,14 @@ const sanitizeStartupFullscreen = (value: unknown): boolean => { return value === true; }; +const sanitizeUiScale = (value: unknown): number => { + return normalizeFloatInRange(value, DEFAULT_UI_SCALE, MIN_UI_SCALE, MAX_UI_SCALE); +}; + +const sanitizeFontSize = (value: unknown): number => { + return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE); +}; + const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => { const raw = (value && typeof value === 'object') ? value as Record : {}; const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase(); @@ -477,6 +502,8 @@ export const useStore = create()( savedQueries: [], theme: 'light', appearance: { ...DEFAULT_APPEARANCE }, + uiScale: DEFAULT_UI_SCALE, + fontSize: DEFAULT_FONT_SIZE, startupFullscreen: DEFAULT_STARTUP_FULLSCREEN, globalProxy: { ...DEFAULT_GLOBAL_PROXY }, sqlFormatOptions: { keywordCase: 'upper' }, @@ -607,6 +634,8 @@ export const useStore = create()( setTheme: (theme) => set({ theme }), setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })), + setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }), + setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }), setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }), setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })), setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }), @@ -646,6 +675,8 @@ export const useStore = create()( nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); nextState.theme = sanitizeTheme(state.theme); nextState.appearance = sanitizeAppearance(state.appearance, version); + nextState.uiScale = sanitizeUiScale(state.uiScale); + nextState.fontSize = sanitizeFontSize(state.fontSize); nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen); nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy); nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions); @@ -663,6 +694,8 @@ export const useStore = create()( savedQueries: sanitizeSavedQueries(state.savedQueries), theme: sanitizeTheme(state.theme), appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION), + uiScale: sanitizeUiScale(state.uiScale), + fontSize: sanitizeFontSize(state.fontSize), startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen), globalProxy: sanitizeGlobalProxy(state.globalProxy), sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), @@ -676,6 +709,8 @@ export const useStore = create()( savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, + uiScale: state.uiScale, + fontSize: state.fontSize, startupFullscreen: state.startupFullscreen, globalProxy: state.globalProxy, sqlFormatOptions: state.sqlFormatOptions, From f477feab2f5ac805adc76a649aa2807a8a4a1b99 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 3 Mar 2026 14:11:35 +0800 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=94=A7=20chore(app):=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=20App.tsx=20=E7=B1=BB=E5=9E=8B=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=E5=B9=B6=E6=94=B6=E6=95=9B=E5=89=8D=E7=AB=AF=E5=A3=B3=E5=B1=82?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 清除未使用代码和冗余状态 - 替换弃用 API 以消除 IDE 提示 - 显式处理浮动 Promise 避免告警 - 保持现有更新检查和代理设置行为不变 --- frontend/src/App.tsx | 105 +++++++++++++++++++++++-------------------- 1 file changed, 57 insertions(+), 48 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac328a7..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'; @@ -23,6 +23,19 @@ 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); const [isSyncModalOpen, setIsSyncModalOpen] = useState(false); @@ -66,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(() => { @@ -80,7 +93,7 @@ function App() { }) .catch(() => { if (cancelled) return; - const platform = typeof navigator !== 'undefined' ? navigator.platform : ''; + const platform = detectNavigatorPlatform(); const normalized = /linux/i.test(platform) ? 'linux' : (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : '')); @@ -116,7 +129,7 @@ function App() { if (invalidWhenEnabled) { if (!globalProxyInvalidHintShownRef.current) { - message.warning({ + void message.warning({ content: '全局代理已开启,但地址或端口无效,当前按未启用处理', key: 'global-proxy-invalid', }); @@ -124,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; @@ -140,7 +153,7 @@ function App() { if (cancelled || res?.success) { return; } - message.error({ + void message.error({ content: '全局代理配置失败: ' + (res?.message || '未知错误'), key: 'global-proxy-sync-error', }); @@ -150,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', }); @@ -205,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; @@ -225,7 +238,7 @@ function App() { applyStartupWindowPreference(attempt + 1); } }); - }, 300); + }, applyRetryDelayMs); }; if (useStore.persist.hasHydrated()) { @@ -248,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 @@ -259,8 +272,8 @@ 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 @@ -339,7 +352,7 @@ function App() { }; const isMacRuntime = runtimePlatform === 'darwin' - || (runtimePlatform === '' && typeof navigator !== 'undefined' && /mac/i.test(navigator.platform)); + || (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform())); const formatBytes = (bytes?: number) => { if (!bytes || bytes <= 0) return '0 B'; @@ -358,7 +371,7 @@ function App() { if (updateDownloadedVersionRef.current === info.latestVersion) { if (!silent) { const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath; - message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion})`); + void message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion})`); showUpdateDownloadProgress(); } return; @@ -399,9 +412,9 @@ function App() { }; }); if (resultData?.downloadPath) { - message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, duration: 5 }); + void message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, duration: 5 }); } else { - message.success({ content: '更新下载完成', duration: 2 }); + void message.success({ content: '更新下载完成', duration: 2 }); } setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,请点击“下载进度”后安装)`); } else { @@ -410,7 +423,7 @@ function App() { status: 'error', message: res?.message || '未知错误' })); - message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), duration: 4 }); + void message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), duration: 4 }); } }, []); @@ -425,10 +438,6 @@ function App() { setUpdateDownloadProgress((prev) => ({ ...prev, open: false })); }, []); - const hasUpdateDownloadProgress = updateDownloadProgress.status === 'start' - || updateDownloadProgress.status === 'downloading' - || updateDownloadProgress.status === 'done' - || updateDownloadProgress.status === 'error'; const isLatestUpdateDownloaded = Boolean(lastUpdateInfo?.hasUpdate) && ( Boolean(lastUpdateInfo?.downloaded) || (Boolean(lastUpdateInfo?.latestVersion) && updateDownloadedVersionRef.current === lastUpdateInfo?.latestVersion) @@ -449,17 +458,17 @@ function App() { if (isMacRuntime) { const res = await (window as any).go.app.App.OpenDownloadedUpdateDirectory(); if (!res?.success) { - message.error('打开安装目录失败: ' + (res?.message || '未知错误')); + void message.error('打开安装目录失败: ' + (res?.message || '未知错误')); return; } updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null; hideUpdateDownloadProgress(); - message.success(res?.message || '已打开安装目录,请手动完成替换'); + void message.success(res?.message || '已打开安装目录,请手动完成替换'); return; } const res = await (window as any).go.app.App.InstallUpdateAndRestart(); if (!res?.success) { - message.error('更新安装失败: ' + (res?.message || '未知错误')); + void message.error('更新安装失败: ' + (res?.message || '未知错误')); return; } updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null; @@ -476,7 +485,7 @@ function App() { updateCheckInFlightRef.current = false; if (!res?.success) { if (!silent) { - message.error('检查更新失败: ' + (res?.message || '未知错误')); + void message.error('检查更新失败: ' + (res?.message || '未知错误')); setAboutUpdateStatus('检查更新失败: ' + (res?.message || '未知错误')); } return; @@ -541,7 +550,7 @@ function App() { ? `发现新版本 ${info.latestVersion}(已下载,请点击“下载进度”后安装)` : `发现新版本 ${info.latestVersion}(未下载)`; if (!silent) { - message.info(`发现新版本 ${info.latestVersion}`); + void message.info(`发现新版本 ${info.latestVersion}`); setAboutUpdateStatus(statusText); } if (silent && aboutOpen) { @@ -568,7 +577,7 @@ function App() { }); setLastUpdateInfo(info); const text = `当前已是最新版本(${info.currentVersion || '未知'})`; - message.success(text); + void message.success(text); setAboutUpdateStatus(text); } else if (silent && aboutOpen) { setUpdateDownloadProgress((prev) => { @@ -599,7 +608,7 @@ function App() { if (res?.success) { setAboutInfo(res.data); } else { - message.error('获取应用信息失败: ' + (res?.message || '未知错误')); + void message.error('获取应用信息失败: ' + (res?.message || '未知错误')); } setAboutLoading(false); }, []); @@ -640,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); } }; @@ -790,7 +799,7 @@ function App() { if (target?.closest('[data-no-titlebar-toggle="true"]')) { return; } - (window as any).runtime.WindowToggleMaximise(); + WindowToggleMaximise(); }; // Sidebar Resizing @@ -880,16 +889,16 @@ function App() { } 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); @@ -1041,13 +1050,13 @@ function App() { type="text" icon={} style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }} - onClick={() => (window as any).runtime.WindowMinimise()} + onClick={WindowMinimise} />
@@ -1216,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} ) : '未知'} @@ -1224,7 +1233,7 @@ function App() {