From 54e70773170fb967d065463fb222d54652499cd5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 10:45:57 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=90=9B=20fix(windows-upgrade):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DWindows=E5=8D=87=E7=BA=A7=E5=90=8E=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=88=97=E8=A1=A8=E4=B8=A2=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启动参数新增固定 WebviewUserDataPath 到 %APPDATA%/GoNavi/WebView2 - 首次启动自动迁移历史 WebView 数据目录 - 保留现有存储键,避免破坏已落盘配置 - 前端持久化读取增加历史结构兼容 --- frontend/src/store.ts | 39 ++++++-- main.go | 1 + main_windows_webview_userdata.go | 123 ++++++++++++++++++++++++++ main_windows_webview_userdata_stub.go | 7 ++ 4 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 main_windows_webview_userdata.go create mode 100644 main_windows_webview_userdata_stub.go diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 51281d4..5dd95a1 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -206,10 +206,27 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { return safeConfig; }; +const resolveConnectionConfigPayload = (raw: Record): unknown => { + if (raw.config && typeof raw.config === 'object') { + return raw.config; + } + // 兼容历史/导入场景:连接对象可能是扁平结构(无 config 包装)。 + const hasLegacyFlatConfig = + raw.type !== undefined || + raw.host !== undefined || + raw.port !== undefined || + raw.user !== undefined || + raw.database !== undefined; + if (hasLegacyFlatConfig) { + return raw; + } + return undefined; +}; + const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection | null => { if (!value || typeof value !== 'object') return null; const raw = value as Record; - const config = sanitizeConnectionConfig(raw.config); + const config = sanitizeConnectionConfig(resolveConnectionConfigPayload(raw)); const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`; const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`; const name = toTrimmedString(raw.name, fallbackName) || fallbackName; @@ -392,6 +409,17 @@ const sanitizeAppearance = ( return nextAppearance; }; +const unwrapPersistedAppState = (persistedState: unknown): Record => { + if (!persistedState || typeof persistedState !== 'object') { + return {}; + } + const raw = persistedState as Record; + if (raw.state && typeof raw.state === 'object') { + return raw.state as Record; + } + return raw; +}; + export const useStore = create()( persist( (set) => ({ @@ -544,10 +572,7 @@ export const useStore = create()( name: 'lite-db-storage', // name of the item in the storage (must be unique) version: 3, migrate: (persistedState: unknown, version: number) => { - if (!persistedState || typeof persistedState !== 'object') { - return persistedState as AppState; - } - const state = persistedState as Partial; + const state = unwrapPersistedAppState(persistedState) as Partial; const nextState: Partial = { ...state }; nextState.connections = sanitizeConnections(state.connections); nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); @@ -560,9 +585,7 @@ export const useStore = create()( return nextState as AppState; }, merge: (persistedState, currentState) => { - const state = (persistedState && typeof persistedState === 'object') - ? persistedState as Partial - : {}; + const state = unwrapPersistedAppState(persistedState) as Partial; return { ...currentState, ...state, diff --git a/main.go b/main.go index 44f3cd1..02cedcb 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,7 @@ func main() { BackdropType: windows.Acrylic, DisableWindowIcon: false, DisableFramelessWindowDecorations: false, + WebviewUserDataPath: resolveWindowsWebviewUserDataPath(), }, Mac: &mac.Options{ WebviewIsTransparent: true, diff --git a/main_windows_webview_userdata.go b/main_windows_webview_userdata.go new file mode 100644 index 0000000..dcf2748 --- /dev/null +++ b/main_windows_webview_userdata.go @@ -0,0 +1,123 @@ +//go:build windows + +package main + +import ( + "io" + "os" + "path/filepath" + "strings" +) + +func resolveWindowsWebviewUserDataPath() string { + appDataDir := strings.TrimSpace(os.Getenv("APPDATA")) + if appDataDir == "" { + return "" + } + + targetDir := filepath.Join(appDataDir, "GoNavi", "WebView2") + _ = migrateLegacyWindowsWebviewUserData(appDataDir, targetDir) + return targetDir +} + +func migrateLegacyWindowsWebviewUserData(appDataDir, targetDir string) error { + if dirHasContent(targetDir) { + return nil + } + + exeName := "GoNavi.exe" + if exePath, err := os.Executable(); err == nil { + base := strings.TrimSpace(filepath.Base(exePath)) + if base != "" { + exeName = base + } + } + exeBase := strings.TrimSuffix(exeName, filepath.Ext(exeName)) + + candidates := []string{ + filepath.Join(appDataDir, exeName), + filepath.Join(appDataDir, exeBase), + filepath.Join(appDataDir, "GoNavi.exe"), + filepath.Join(appDataDir, "GoNavi"), + } + + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + src := filepath.Clean(strings.TrimSpace(candidate)) + if src == "" || strings.EqualFold(src, filepath.Clean(targetDir)) { + continue + } + key := strings.ToLower(src) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + + if !dirHasContent(src) { + continue + } + return copyDirTree(src, targetDir) + } + return nil +} + +func dirHasContent(path string) bool { + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return false + } + entries, err := os.ReadDir(path) + return err == nil && len(entries) > 0 +} + +func copyDirTree(srcDir, dstDir string) error { + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return err + } + + return filepath.WalkDir(srcDir, func(srcPath string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relPath, err := filepath.Rel(srcDir, srcPath) + if err != nil { + return err + } + if relPath == "." { + return nil + } + dstPath := filepath.Join(dstDir, relPath) + + if d.IsDir() { + return os.MkdirAll(dstPath, 0o755) + } + + info, err := d.Info() + if err != nil { + return err + } + return copyFileWithMode(srcPath, dstPath, info.Mode()) + }) +} + +func copyFileWithMode(srcPath, dstPath string, mode os.FileMode) error { + srcFile, err := os.Open(srcPath) + if err != nil { + return err + } + defer srcFile.Close() + + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm()) + if err != nil { + return err + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + return nil +} diff --git a/main_windows_webview_userdata_stub.go b/main_windows_webview_userdata_stub.go new file mode 100644 index 0000000..7dfe331 --- /dev/null +++ b/main_windows_webview_userdata_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +func resolveWindowsWebviewUserDataPath() string { + return "" +} From 53b5802add226472b3f9be3e60d6e12497a88270 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 10:57:05 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=95=E5=85=83=E6=A0=BC=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E6=8B=96=E6=8B=BD=E8=B6=8A=E7=95=8C=E4=B8=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 DataGrid 拖拽选区流程新增边缘自动滚动能力(横向+纵向) - 拖拽过程中增加鼠标位置跟踪并通过 RAF 循环驱动滚动 - 通过 elementFromPoint 兜底命中单元格,保证越界拖拽时选区持续更新 - 在 mouseup、模式切换和退出编辑器时统一清理 RAF 与拖拽状态 - refs #127 --- frontend/src/components/DataGrid.tsx | 192 ++++++++++++++++++++++----- 1 file changed, 158 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 7dee7a7..6cfec4f 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -619,6 +619,8 @@ const DataGrid: React.FC = ({ // 使用 ref 来优化拖拽性能,完全避免状态更新 const cellSelectionRafRef = useRef(null); const cellSelectionScrollRafRef = useRef(null); + const cellSelectionAutoScrollRafRef = useRef(null); + const cellSelectionPointerRef = useRef<{ x: number; y: number } | null>(null); const isDraggingRef = useRef(false); // 导入预览 Modal 状态 @@ -1102,6 +1104,11 @@ const DataGrid: React.FC = ({ currentSelectionRef.current = new Set(); selectionStartRef.current = null; isDraggingRef.current = false; + cellSelectionPointerRef.current = null; + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } updateCellSelection(new Set()); }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]); @@ -1111,8 +1118,12 @@ const DataGrid: React.FC = ({ const container = containerRef.current; if (!container) return; + const EDGE_THRESHOLD_PX = 28; + const MIN_SCROLL_STEP = 8; + const MAX_SCROLL_STEP = 24; - const getCellInfo = (target: HTMLElement): { rowKey: string; colName: string } | null => { + const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => { + if (!target) return null; const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement; if (!td) return null; const rowKey = td.getAttribute('data-row-key'); @@ -1121,35 +1132,12 @@ const DataGrid: React.FC = ({ return { rowKey, colName }; }; - const onMouseDown = (e: MouseEvent) => { - const cellInfo = getCellInfo(e.target as HTMLElement); - if (!cellInfo) return; - - e.preventDefault(); - isDraggingRef.current = true; - const currentData = displayDataRef.current; - const nextRowIndexMap = new Map(); - currentData.forEach((r, idx) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - nextRowIndexMap.set(String(k), idx); - }); - rowIndexMapRef.current = nextRowIndexMap; - - const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1; - const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; - selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex }; - currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]); - updateCellSelection(currentSelectionRef.current); + const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => { + const target = document.elementFromPoint(x, y) as HTMLElement | null; + return getCellInfo(target); }; - const onMouseMove = (e: MouseEvent) => { - if (!isDraggingRef.current || !selectionStartRef.current) return; - - const cellInfo = getCellInfo(e.target as HTMLElement); - if (!cellInfo) return; - - // 使用 RAF 节流 + const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => { if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); } @@ -1188,9 +1176,124 @@ const DataGrid: React.FC = ({ }); }; + const stopAutoScroll = () => { + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } + }; + + const getScrollStep = (distanceToEdge: number): number => { + const ratio = Math.min(1, Math.max(0, distanceToEdge / EDGE_THRESHOLD_PX)); + return Math.round(MIN_SCROLL_STEP + (MAX_SCROLL_STEP - MIN_SCROLL_STEP) * ratio); + }; + + const autoScrollTick = () => { + if (!isDraggingRef.current || !selectionStartRef.current) { + stopAutoScroll(); + return; + } + + const pointer = cellSelectionPointerRef.current; + const tableBody = container.querySelector('.ant-table-body') as HTMLElement | null; + if (!pointer || !tableBody) { + cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); + return; + } + + const rect = tableBody.getBoundingClientRect(); + const maxScrollTop = Math.max(0, tableBody.scrollHeight - tableBody.clientHeight); + const maxScrollLeft = Math.max(0, tableBody.scrollWidth - tableBody.clientWidth); + let deltaY = 0; + let deltaX = 0; + + if (pointer.y < rect.top + EDGE_THRESHOLD_PX && tableBody.scrollTop > 0) { + const distance = rect.top + EDGE_THRESHOLD_PX - pointer.y; + deltaY = -getScrollStep(distance); + } else if (pointer.y > rect.bottom - EDGE_THRESHOLD_PX && tableBody.scrollTop < maxScrollTop) { + const distance = pointer.y - (rect.bottom - EDGE_THRESHOLD_PX); + deltaY = getScrollStep(distance); + } + + if (pointer.x < rect.left + EDGE_THRESHOLD_PX && tableBody.scrollLeft > 0) { + const distance = rect.left + EDGE_THRESHOLD_PX - pointer.x; + deltaX = -getScrollStep(distance); + } else if (pointer.x > rect.right - EDGE_THRESHOLD_PX && tableBody.scrollLeft < maxScrollLeft) { + const distance = pointer.x - (rect.right - EDGE_THRESHOLD_PX); + deltaX = getScrollStep(distance); + } + + let didScroll = false; + if (deltaY !== 0) { + const nextTop = Math.max(0, Math.min(maxScrollTop, tableBody.scrollTop + deltaY)); + if (nextTop !== tableBody.scrollTop) { + tableBody.scrollTop = nextTop; + didScroll = true; + } + } + + if (deltaX !== 0) { + const nextLeft = Math.max(0, Math.min(maxScrollLeft, tableBody.scrollLeft + deltaX)); + if (nextLeft !== tableBody.scrollLeft) { + tableBody.scrollLeft = nextLeft; + didScroll = true; + } + } + + if (didScroll) { + const cellInfo = getCellInfoFromPoint(pointer.x, pointer.y); + if (cellInfo) scheduleSelectionUpdate(cellInfo); + } + + cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); + }; + + const ensureAutoScroll = () => { + if (cellSelectionAutoScrollRafRef.current !== null) return; + cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); + }; + + const onMouseDown = (e: MouseEvent) => { + const target = e.target instanceof HTMLElement ? e.target : null; + const cellInfo = getCellInfo(target); + if (!cellInfo) return; + + e.preventDefault(); + isDraggingRef.current = true; + cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; + const currentData = displayDataRef.current; + const nextRowIndexMap = new Map(); + currentData.forEach((r, idx) => { + const k = r?.[GONAVI_ROW_KEY]; + if (k === undefined) return; + nextRowIndexMap.set(String(k), idx); + }); + rowIndexMapRef.current = nextRowIndexMap; + + const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1; + const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; + selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex }; + currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]); + updateCellSelection(currentSelectionRef.current); + ensureAutoScroll(); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isDraggingRef.current || !selectionStartRef.current) return; + cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; + ensureAutoScroll(); + + const target = e.target instanceof HTMLElement ? e.target : null; + const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY); + if (!cellInfo) return; + scheduleSelectionUpdate(cellInfo); + }; + const onMouseUp = () => { if (!isDraggingRef.current) return; isDraggingRef.current = false; + cellSelectionPointerRef.current = null; + stopAutoScroll(); if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); @@ -1231,6 +1334,8 @@ const DataGrid: React.FC = ({ cancelAnimationFrame(cellSelectionScrollRafRef.current); cellSelectionScrollRafRef.current = null; } + stopAutoScroll(); + cellSelectionPointerRef.current = null; isDraggingRef.current = false; }; }, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]); @@ -2332,6 +2437,7 @@ const DataGrid: React.FC = ({ currentSelectionRef.current = new Set(); selectionStartRef.current = null; isDraggingRef.current = false; + cellSelectionPointerRef.current = null; if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); cellSelectionRafRef.current = null; @@ -2340,6 +2446,10 @@ const DataGrid: React.FC = ({ cancelAnimationFrame(cellSelectionScrollRafRef.current); cellSelectionScrollRafRef.current = null; } + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } updateCellSelection(new Set()); if (!next) setBatchEditModalOpen(false); message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式'); @@ -2403,12 +2513,26 @@ const DataGrid: React.FC = ({ onChange={(val) => { const nextMode = String(val) as GridViewMode; if (nextMode === 'json' && cellEditMode) { - setCellEditMode(false); - setSelectedCells(new Set()); - currentSelectionRef.current = new Set(); - selectionStartRef.current = null; - updateCellSelection(new Set()); - } + setCellEditMode(false); + setSelectedCells(new Set()); + currentSelectionRef.current = new Set(); + selectionStartRef.current = null; + isDraggingRef.current = false; + cellSelectionPointerRef.current = null; + if (cellSelectionRafRef.current !== null) { + cancelAnimationFrame(cellSelectionRafRef.current); + cellSelectionRafRef.current = null; + } + if (cellSelectionScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionScrollRafRef.current); + cellSelectionScrollRafRef.current = null; + } + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } + updateCellSelection(new Set()); + } if (nextMode === 'text') { const selectedKey = selectedRowKeys[0]; if (selectedKey !== undefined) { From 7350a011e3e611b64ab8792c68c08160b3216ea8 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 12:29:54 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E2=9C=A8=20feat(driver-manager):=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E9=A9=B1=E5=8A=A8=E7=AE=A1=E7=90=86=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E8=AF=8A=E6=96=AD=E4=B8=8E=E6=9C=AC=E5=9C=B0=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CheckDriverNetworkStatus,探测 GitHub API/Release/Go Proxy 可达性并返回代理环境信息。 - 驱动管理弹窗新增网络检测结果、驱动目录复用说明、本地导入入口与日志查看。 - 操作日志支持同签名进度覆盖更新,下载百分比动态刷新,不再重复新增日志行。 - 修正弹窗滚动行为与表格滚动体验。 - refs #128 --- .../src/components/DriverManagerModal.tsx | 394 ++++++++++++++++-- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/methods_driver.go | 360 ++++++++++++++-- 4 files changed, 706 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index 6bbadd3..d4e7190 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -1,16 +1,19 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd'; -import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd'; +import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { + CheckDriverNetworkStatus, DownloadDriverPackage, GetDriverVersionList, GetDriverVersionPackageSize, GetDriverStatusList, + InstallLocalDriverPackage, RemoveDriverPackage, + SelectDriverPackageFile, } from '../../wailsjs/go/app/App'; -const { Text } = Typography; +const { Paragraph, Text } = Typography; type DriverStatusRow = { type: string; @@ -23,6 +26,10 @@ type DriverStatusRow = { packageInstalled: boolean; connectable: boolean; defaultDownloadUrl?: string; + installDir?: string; + packagePath?: string; + executablePath?: string; + downloadedAt?: string; message?: string; }; @@ -39,6 +46,32 @@ type ProgressState = { percent: number; }; +type DriverLogEntry = { + time: string; + text: string; + signature: string; +}; + +type DriverNetworkProbe = { + name: string; + url: string; + reachable: boolean; + httpStatus?: number; + latencyMs?: number; + error?: string; +}; + +type DriverNetworkStatus = { + reachable: boolean; + summary: string; + recommendedProxy: boolean; + proxyConfigured: boolean; + proxyEnv?: Record; + checks: DriverNetworkProbe[]; + checkedAt?: string; + logPath?: string; +}; + type DriverVersionOption = { version: string; downloadUrl: string; @@ -101,14 +134,65 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => { const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { const [loading, setLoading] = useState(false); const [downloadDir, setDownloadDir] = useState(''); + const [networkChecking, setNetworkChecking] = useState(false); + const [networkStatus, setNetworkStatus] = useState(null); const [rows, setRows] = useState([]); const [actionDriver, setActionDriver] = useState(''); const [progressMap, setProgressMap] = useState>({}); + const [operationLogMap, setOperationLogMap] = useState>({}); + const [logDriverType, setLogDriverType] = useState(''); + const [logModalOpen, setLogModalOpen] = useState(false); const [versionMap, setVersionMap] = useState>({}); const [selectedVersionMap, setSelectedVersionMap] = useState>({}); const [versionLoadingMap, setVersionLoadingMap] = useState>({}); const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({}); + const appendOperationLog = useCallback(( + driverType: string, + text: string, + signature?: string, + mode: 'append' | 'update-last' = 'append', + ) => { + const normalized = String(driverType || '').trim().toLowerCase(); + const content = String(text || '').trim(); + if (!normalized || !content) { + return; + } + const sign = String(signature || content).trim() || content; + const now = new Date().toLocaleTimeString(); + setOperationLogMap((prev) => { + const history = prev[normalized] || []; + if (history.length > 0) { + const last = history[history.length - 1]; + if (last.signature === sign) { + if (mode === 'update-last') { + if (last.text === content) { + return prev; + } + const nextHistory = [...history]; + nextHistory[nextHistory.length - 1] = { + ...last, + text: content, + time: now, + }; + return { ...prev, [normalized]: nextHistory }; + } + return prev; + } + } + const nextHistory = [ + ...history, + { + time: now, + text: content, + signature: sign, + }, + ]; + const sliced = nextHistory.length > 200 ? nextHistory.slice(nextHistory.length - 200) : nextHistory; + return { ...prev, [normalized]: sliced }; + }); + }, []); + const refreshStatus = useCallback(async (toastOnError = true) => { setLoading(true); try { @@ -139,6 +223,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ packageInstalled: !!item.packageInstalled, connectable: !!item.connectable, defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined, + installDir: String(item.installDir || '').trim() || undefined, + packagePath: String(item.packagePath || '').trim() || undefined, + executablePath: String(item.executablePath || '').trim() || undefined, + downloadedAt: String(item.downloadedAt || '').trim() || undefined, message: String(item.message || '').trim() || undefined, })); setRows(nextRows); @@ -151,6 +239,45 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ } }, [downloadDir]); + const checkNetworkStatus = useCallback(async (toastOnError = false) => { + setNetworkChecking(true); + try { + const res = await CheckDriverNetworkStatus(); + if (!res?.success) { + if (toastOnError) { + message.error(res?.message || '驱动网络检测失败'); + } + return; + } + const data = (res?.data || {}) as any; + const checks = Array.isArray(data.checks) ? data.checks : []; + const normalizedChecks: DriverNetworkProbe[] = checks.map((item: any) => ({ + name: String(item.name || '').trim(), + url: String(item.url || '').trim(), + reachable: !!item.reachable, + httpStatus: Number(item.httpStatus || 0) || undefined, + latencyMs: Number(item.latencyMs || 0) || undefined, + error: String(item.error || '').trim() || undefined, + })); + setNetworkStatus({ + reachable: !!data.reachable, + summary: String(data.summary || '').trim() || '驱动网络检测已完成', + recommendedProxy: !!data.recommendedProxy, + proxyConfigured: !!data.proxyConfigured, + proxyEnv: (data.proxyEnv || {}) as Record, + checkedAt: String(data.checkedAt || '').trim() || undefined, + checks: normalizedChecks, + logPath: String(data.logPath || '').trim() || undefined, + }); + } catch (err: any) { + if (toastOnError) { + message.error(`驱动网络检测失败:${err?.message || String(err)}`); + } + } finally { + setNetworkChecking(false); + } + }, []); + const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => { if (row.builtIn) { return [] as DriverVersionOption[]; @@ -305,7 +432,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ return; } refreshStatus(false); - }, [open, refreshStatus]); + checkNetworkStatus(false); + }, [checkNetworkStatus, open, refreshStatus]); useEffect(() => { if (!open) { @@ -330,11 +458,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ percent, }, })); + const progressText = `${Math.round(percent)}%`; + const statusText = String(status || '').toUpperCase(); + const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`; + const lineSignature = `${statusText}|${messageText || '-'}`; + appendOperationLog(driverType, lineText, lineSignature, 'update-last'); }); return () => { off(); }; - }, [open]); + }, [appendOperationLog, open]); const installDriver = useCallback(async (row: DriverStatusRow) => { setActionDriver(row.type); @@ -346,6 +479,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ percent: 0, }, })); + appendOperationLog(row.type, '[START] 开始自动安装'); try { let options = versionMap[row.type] || []; if (options.length === 0) { @@ -361,25 +495,81 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir); if (!result?.success) { - message.error(result?.message || `安装 ${row.name} 失败`); + const errText = result?.message || `安装 ${row.name} 失败`; + appendOperationLog(row.type, `[ERROR] ${errText}`); + message.error(errText); return; } const versionTip = selectedVersion ? `(${selectedVersion})` : ''; + appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`); message.success(`${row.name}${versionTip} 已安装启用`); refreshStatus(false); } finally { setActionDriver(''); } - }, [downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]); + }, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]); + + const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => { + const fileRes = await SelectDriverPackageFile(downloadDir); + if (!fileRes?.success) { + if (String(fileRes?.message || '') !== 'Cancelled') { + message.error(fileRes?.message || '选择本地驱动包失败'); + } + return; + } + const filePath = String((fileRes?.data as any)?.path || '').trim(); + if (!filePath) { + message.error('未选择有效的驱动包文件'); + return; + } + + setActionDriver(row.type); + setProgressMap((prev) => ({ + ...prev, + [row.type]: { + status: 'start', + message: '开始导入本地驱动包', + percent: 0, + }, + })); + appendOperationLog(row.type, `[START] 开始本地导入:${filePath}`); + try { + const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir); + if (!result?.success) { + const errText = result?.message || `导入 ${row.name} 本地驱动包失败`; + appendOperationLog(row.type, `[ERROR] ${errText}`); + message.error(errText); + return; + } + appendOperationLog(row.type, '[DONE] 本地导入安装完成'); + message.success(`${row.name} 本地驱动包已安装启用`); + refreshStatus(false); + } finally { + setActionDriver(''); + } + }, [appendOperationLog, downloadDir, refreshStatus]); + + const openDriverLog = useCallback((driverType: string) => { + const normalized = String(driverType || '').trim().toLowerCase(); + if (!normalized) { + return; + } + setLogDriverType(normalized); + setLogModalOpen(true); + }, []); const removeDriver = useCallback(async (row: DriverStatusRow) => { setActionDriver(row.type); + appendOperationLog(row.type, '[START] 开始移除驱动'); try { const result = await RemoveDriverPackage(row.type, downloadDir); if (!result?.success) { - message.error(result?.message || `移除 ${row.name} 失败`); + const errText = result?.message || `移除 ${row.name} 失败`; + appendOperationLog(row.type, `[ERROR] ${errText}`); + message.error(errText); return; } + appendOperationLog(row.type, '[DONE] 驱动移除完成'); message.success(`${row.name} 已移除`); setProgressMap((prev) => { const next = { ...prev }; @@ -390,7 +580,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ } finally { setActionDriver(''); } - }, [downloadDir, refreshStatus]); + }, [appendOperationLog, downloadDir, refreshStatus]); const columns = useMemo(() => { return [ @@ -400,6 +590,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ key: 'name', width: 150, }, + { + title: '安装位置', + key: 'installPath', + width: 260, + render: (_: string, row: DriverStatusRow) => { + if (row.builtIn) { + return 内置; + } + const installPath = row.executablePath || row.installDir || '-'; + if (installPath === '-') { + return -; + } + return ( + + {installPath} + + ); + }, + }, { title: '安装包大小', dataIndex: 'packageSizeText', @@ -511,7 +720,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ { title: '操作', key: 'actions', - width: 190, + width: 320, render: (_: string, row: DriverStatusRow) => { if (row.builtIn) { return -; @@ -521,19 +730,20 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ if (isSlimBuildUnavailable && !row.packageInstalled) { return 需 Full 版; } - if (row.connectable) { - return ( - - ); - } - return ( + + const logs = operationLogMap[row.type] || []; + const hasLogs = logs.length > 0; + + const mainAction = row.connectable ? ( + + ) : ( ); + + return ( + + {mainAction} + + + + ); }, }, ]; - }, [actionDriver, installDriver, loadVersionOptions, loadVersionPackageSize, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]); + }, [actionDriver, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]); + + const activeLogRow = useMemo(() => { + if (!logDriverType) { + return undefined; + } + return rows.find((item) => item.type === logDriverType); + }, [logDriverType, rows]); + + const activeDriverLogs = operationLogMap[logDriverType] || []; + const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`); + const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {}); return ( void }> = ({ open={open} onCancel={onClose} width={980} + style={{ top: 24 }} + styles={{ + body: { + maxHeight: 'calc(100vh - 220px)', + overflowY: 'auto', + overflowX: 'hidden', + paddingRight: 18, + }, + }} destroyOnClose footer={[ , + , , @@ -566,6 +819,67 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ > 除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。 + {networkStatus ? ( + + + 驱动下载依赖 GitHub 与 Go 模块代理网络。若检测失败,建议先启用 HTTP/HTTPS/SOCKS5 代理后重试。 + + + {networkStatus.checks.map((item) => ( + + {item.name}:{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `,HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `,${item.latencyMs}ms` : ''}{item.error ? `,${item.error}` : ''} + + ))} + {proxyEnvEntries.length > 0 ? ( + + 检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')} + + ) : ( + 未检测到系统代理环境变量。 + )} + + ), + }, + ]} + /> + + )} + /> + ) : ( + + )} + + + 自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。 + 手动导入支持单个驱动代理文件(如 `mariadb-driver-agent` / `mariadb-driver-agent.exe`)或驱动总包 `GoNavi-DriverAgents.zip`。 + + 驱动根目录:{downloadDir || '-'} + + {networkStatus?.logPath ? ( + + 运行日志文件:{networkStatus.logPath} + + ) : null} + + )} + /> void }> = ({ dataSource={rows} pagination={false} size="middle" + scroll={{ x: 1450 }} /> + setLogModalOpen(false)} + footer={[ + , + ]} + width={780} + > + + {activeLogRow?.installDir ? ( + + 安装目录:{activeLogRow.installDir} + + ) : null} + {activeLogRow?.executablePath ? ( + + 驱动可执行文件:{activeLogRow.executablePath} + + ) : null} + {activeDriverLogLines.length > 0 ? ( +
+              {activeDriverLogLines.join('\n')}
+            
+ ) : ( + 当前驱动暂无操作日志。 + )} +
+
); }; diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index e177d59..8c38771 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -6,6 +6,8 @@ import {redis} from '../models'; export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise; +export function CheckDriverNetworkStatus():Promise; + export function CheckForUpdates():Promise; export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 120f0bf..3b1a185 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4); } +export function CheckDriverNetworkStatus() { + return window['go']['app']['App']['CheckDriverNetworkStatus'](); +} + export function CheckForUpdates() { return window['go']['app']['App']['CheckForUpdates'](); } diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 46268e0..9fe2e56 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -21,6 +22,7 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/logger" "github.com/wailsapp/wails/v2/pkg/runtime" "golang.org/x/mod/semver" @@ -79,6 +81,15 @@ type driverDownloadProgressPayload struct { Message string `json:"message,omitempty"` } +type driverNetworkProbeItem struct { + Name string `json:"name"` + URL string `json:"url"` + Reachable bool `json:"reachable"` + HTTPStatus int `json:"httpStatus,omitempty"` + LatencyMs int64 `json:"latencyMs,omitempty"` + Error string `json:"error,omitempty"` +} + type pinnedDriverPackage struct { Version string DownloadURL string @@ -188,6 +199,7 @@ const ( driverVersionWarmupMinInterval = 30 * time.Second driverBundleIndexMaxSize = 1 << 20 driverManifestMaxSize = 2 << 20 + driverNetworkProbeTimeout = 4 * time.Second driverChecksumPolicyStrict = "strict" driverChecksumPolicyWarn = "warn" driverChecksumPolicyOff = "off" @@ -592,6 +604,59 @@ func (a *App) GetDriverStatusList(downloadDir string, manifestURL string) connec } } +func (a *App) CheckDriverNetworkStatus() connection.QueryResult { + checks := []driverNetworkProbeItem{ + { + Name: "GitHub API", + URL: "https://api.github.com/rate_limit", + }, + { + Name: "GitHub 驱动发布", + URL: fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", updateRepo, optionalDriverBundleAssetName), + }, + { + Name: "Go 模块代理", + URL: "https://proxy.golang.org/github.com/go-sql-driver/mysql/@v/list", + }, + } + + allReachable := true + for index := range checks { + checks[index] = probeDriverNetworkEndpoint(checks[index]) + if !checks[index].Reachable { + allReachable = false + } + } + + proxyEnv := collectDriverProxyEnv() + proxyConfigured := len(proxyEnv) > 0 + summary := "驱动下载网络检测通过,可直接安装驱动。" + if !allReachable { + if proxyConfigured { + summary = "检测到部分驱动下载地址不可达,请确认系统代理配置有效后重试。" + } else { + summary = "检测到部分驱动下载地址不可达,建议先配置 HTTP/HTTPS/SOCKS5 代理后再安装驱动。" + } + } + + data := map[string]interface{}{ + "reachable": allReachable, + "summary": summary, + "recommendedProxy": !allReachable, + "proxyConfigured": proxyConfigured, + "proxyEnv": proxyEnv, + "checkedAt": time.Now().Format(time.RFC3339), + "checks": checks, + } + if logPath := strings.TrimSpace(logger.Path()); logPath != "" { + data["logPath"] = logPath + } + return connection.QueryResult{ + Success: true, + Data: data, + } +} + func (a *App) InstallLocalDriverPackage(driverType string, filePath string, downloadDir string) connection.QueryResult { definition, ok := resolveDriverDefinition(driverType) if !ok { @@ -614,28 +679,27 @@ func (a *App) InstallLocalDriverPackage(driverType string, filePath string, down } db.SetExternalDriverDownloadDirectory(resolvedDir) - hash := "" - if pathText := strings.TrimSpace(filePath); pathText != "" { - if fileHash, hashErr := hashFileSHA256(pathText); hashErr == nil { - hash = fileHash + a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, "开始安装本地驱动包") + selectedVersion := resolveDriverInstallVersion(definition.PinnedVersion, "local://manual", definition) + meta, installErr := installOptionalDriverAgentFromLocalFile(definition, filePath, resolvedDir, selectedVersion) + if installErr != nil { + errText := normalizeErrorMessage(installErr) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(installErr, "导入本地驱动包失败,driver=%s file=%s", definition.Type, strings.TrimSpace(filePath)), } } - - a.emitDriverDownloadProgress(definition.Type, "start", 0, 0, "开始安装") - meta := installedDriverPackage{ - DriverType: definition.Type, - Version: resolveDriverInstallVersion(definition.PinnedVersion, "local://activate", definition), - FilePath: "", - FileName: "embedded-go-driver", - DownloadURL: "local://activate", - SHA256: hash, - DownloadedAt: time.Now().Format(time.RFC3339), - } + a.emitDriverDownloadProgress(definition.Type, "downloading", 90, 100, "写入驱动元数据") if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil { - a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error()) - return connection.QueryResult{Success: false, Message: err.Error()} + errText := normalizeErrorMessage(err) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(err, "写入本地驱动元数据失败,driver=%s", definition.Type), + } } - a.emitDriverDownloadProgress(definition.Type, "done", 1, 1, "安装完成(纯 Go 驱动已启用)") + a.emitDriverDownloadProgress(definition.Type, "done", 100, 100, "本地驱动包导入完成") return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{ "driverType": definition.Type, @@ -683,13 +747,21 @@ func (a *App) DownloadDriverPackage(driverType string, version string, downloadU a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, fmt.Sprintf("开始安装 %s 驱动代理", displayName)) meta, installErr := installOptionalDriverAgentPackage(a, definition, selectedVersion, resolvedDir, urlText) if installErr != nil { - a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, installErr.Error()) - return connection.QueryResult{Success: false, Message: installErr.Error()} + errText := normalizeErrorMessage(installErr) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(installErr, "驱动下载安装失败,driver=%s version=%s url=%s", definition.Type, selectedVersion, urlText), + } } a.emitDriverDownloadProgress(definition.Type, "downloading", 95, 100, "写入驱动元数据") if writeErr := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); writeErr != nil { - a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, writeErr.Error()) - return connection.QueryResult{Success: false, Message: writeErr.Error()} + errText := normalizeErrorMessage(writeErr) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(writeErr, "写入驱动元数据失败,driver=%s version=%s", definition.Type, selectedVersion), + } } a.emitDriverDownloadProgress(definition.Type, "done", 100, 100, fmt.Sprintf("%s 驱动代理安装完成", displayName)) return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{ @@ -710,8 +782,12 @@ func (a *App) DownloadDriverPackage(driverType string, version string, downloadU DownloadedAt: time.Now().Format(time.RFC3339), } if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil { - a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error()) - return connection.QueryResult{Success: false, Message: err.Error()} + errText := normalizeErrorMessage(err) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(err, "写入驱动元数据失败,driver=%s version=%s", definition.Type, selectedVersion), + } } a.emitDriverDownloadProgress(definition.Type, "done", 1, 1, "安装完成(纯 Go 驱动已启用)") @@ -781,6 +857,100 @@ func (a *App) emitDriverDownloadProgress(driverType string, status string, downl runtime.EventsEmit(a.ctx, driverDownloadProgressEvent, payload) } +func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeItem { + probed := item + probed.Reachable = false + probed.HTTPStatus = 0 + probed.Error = "" + probed.LatencyMs = 0 + + urlText := strings.TrimSpace(item.URL) + if urlText == "" { + probed.Error = "检测地址为空" + return probed + } + + client := &http.Client{Timeout: driverNetworkProbeTimeout} + start := time.Now() + req, err := http.NewRequest(http.MethodHead, urlText, nil) + if err != nil { + probed.Error = normalizeErrorMessage(err) + return probed + } + req.Header.Set("User-Agent", "GoNavi-DriverManager") + + resp, err := client.Do(req) + if err != nil { + // 某些网关不支持 HEAD,请回退为 GET(不读取正文)。 + reqGet, reqErr := http.NewRequest(http.MethodGet, urlText, nil) + if reqErr != nil { + probed.Error = normalizeErrorMessage(reqErr) + probed.LatencyMs = time.Since(start).Milliseconds() + return probed + } + reqGet.Header.Set("User-Agent", "GoNavi-DriverManager") + resp, err = client.Do(reqGet) + } + probed.LatencyMs = time.Since(start).Milliseconds() + if err != nil { + probed.Error = normalizeDriverNetworkError(err) + return probed + } + defer resp.Body.Close() + + probed.HTTPStatus = resp.StatusCode + if resp.StatusCode >= 500 { + probed.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) + return probed + } + probed.Reachable = true + return probed +} + +func normalizeDriverNetworkError(err error) string { + if err == nil { + return "" + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "网络连接超时" + } + return normalizeErrorMessage(err) +} + +func collectDriverProxyEnv() map[string]string { + keys := []string{ + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy", + } + result := make(map[string]string) + for _, key := range keys { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + continue + } + result[key] = value + } + return result +} + +func driverLogHint() string { + path := strings.TrimSpace(logger.Path()) + if path == "" { + return "" + } + return fmt.Sprintf("(详细日志:%s)", path) +} + +func logDriverOperationError(err error, format string, args ...interface{}) string { + message := normalizeErrorMessage(err) + if strings.TrimSpace(message) == "" { + message = "未知错误" + } + logger.Error(err, format, args...) + return strings.TrimSpace(message) + driverLogHint() +} + func defaultDriverDownloadDirectory() string { root, err := db.ResolveExternalDriverRoot("") if err == nil && strings.TrimSpace(root) != "" { @@ -1378,12 +1548,7 @@ func fetchGoModuleVersionMetas(modulePath string) ([]goModuleVersionMeta, error) } endpoint := fmt.Sprintf("https://proxy.golang.org/%s/@v/list", escapeGoModulePathForProxy(trimmed)) - client := &http.Client{ - Timeout: driverModuleLatestProbeTimeout, - Transport: &http.Transport{ - Proxy: nil, - }, - } + client := &http.Client{Timeout: driverModuleLatestProbeTimeout} req, err := http.NewRequest(http.MethodGet, endpoint, nil) if err != nil { return nil, err @@ -2025,6 +2190,141 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele }, nil } +func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) { + driverType := normalizeDriverType(definition.Type) + displayName := resolveDriverDisplayName(definition) + pathText := strings.TrimSpace(filePath) + if pathText == "" { + return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为空") + } + if absPath, absErr := filepath.Abs(pathText); absErr == nil { + pathText = absPath + } + info, statErr := os.Stat(pathText) + if statErr != nil { + return installedDriverPackage{}, fmt.Errorf("读取本地驱动包失败:%w", statErr) + } + if info.IsDir() { + return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为目录:%s", pathText) + } + + executablePath, err := db.ResolveOptionalDriverAgentExecutablePath(resolvedDir, driverType) + if err != nil { + return installedDriverPackage{}, err + } + if mkErr := os.MkdirAll(filepath.Dir(executablePath), 0o755); mkErr != nil { + return installedDriverPackage{}, fmt.Errorf("创建 %s 驱动目录失败:%w", displayName, mkErr) + } + + downloadSource := fmt.Sprintf("local://manual/%s", filepath.Base(pathText)) + if strings.EqualFold(filepath.Ext(pathText), ".zip") { + entryName, extractErr := installOptionalDriverAgentFromLocalZip(pathText, definition, executablePath) + if extractErr != nil { + return installedDriverPackage{}, extractErr + } + if strings.TrimSpace(entryName) != "" { + downloadSource = downloadSource + "#" + entryName + } + } else { + if copyErr := copyAgentBinary(pathText, executablePath); copyErr != nil { + return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr) + } + } + + hash, hashErr := hashFileSHA256(executablePath) + if hashErr != nil { + return installedDriverPackage{}, fmt.Errorf("计算 %s 驱动代理摘要失败:%w", displayName, hashErr) + } + return installedDriverPackage{ + DriverType: driverType, + Version: strings.TrimSpace(selectedVersion), + FilePath: pathText, + FileName: filepath.Base(pathText), + ExecutablePath: executablePath, + DownloadURL: downloadSource, + SHA256: hash, + DownloadedAt: time.Now().Format(time.RFC3339), + }, nil +} + +func installOptionalDriverAgentFromLocalZip(zipPath string, definition driverDefinition, executablePath string) (string, error) { + driverType := normalizeDriverType(definition.Type) + displayName := resolveDriverDisplayName(definition) + reader, err := zip.OpenReader(zipPath) + if err != nil { + return "", fmt.Errorf("打开本地驱动包失败:%w", err) + } + defer reader.Close() + + entryPath := optionalDriverBundleEntryPath(driverType) + expectedBaseName := optionalDriverReleaseAssetName(driverType) + findEntry := func() *zip.File { + for _, file := range reader.File { + name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./")) + if name == entryPath { + return file + } + } + for _, file := range reader.File { + name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./")) + if strings.EqualFold(name, entryPath) { + return file + } + } + for _, file := range reader.File { + name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./")) + if strings.EqualFold(filepath.Base(name), expectedBaseName) { + return file + } + } + return nil + } + + entry := findEntry() + if entry == nil { + return "", fmt.Errorf("本地驱动包内未找到 %s 代理文件(期望路径 %s)", displayName, entryPath) + } + + src, err := entry.Open() + if err != nil { + return "", fmt.Errorf("读取本地驱动包条目失败:%w", err) + } + defer src.Close() + + tempPath := executablePath + ".tmp" + _ = os.Remove(tempPath) + dst, err := os.Create(tempPath) + if err != nil { + return "", fmt.Errorf("创建驱动代理临时文件失败:%w", err) + } + if _, err := io.Copy(dst, src); err != nil { + dst.Close() + _ = os.Remove(tempPath) + return "", fmt.Errorf("写入驱动代理失败:%w", err) + } + if err := dst.Sync(); err != nil { + dst.Close() + _ = os.Remove(tempPath) + return "", fmt.Errorf("落盘驱动代理失败:%w", err) + } + if err := dst.Close(); err != nil { + _ = os.Remove(tempPath) + return "", fmt.Errorf("关闭驱动代理文件失败:%w", err) + } + if chmodErr := os.Chmod(tempPath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { + _ = os.Remove(tempPath) + return "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr) + } + if err := os.Rename(tempPath, executablePath); err != nil { + _ = os.Remove(tempPath) + return "", fmt.Errorf("替换驱动代理失败:%w", err) + } + if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { + return "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr) + } + return filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(entry.Name), "./")), nil +} + func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, executablePath string, downloadURL string) (string, string, error) { driverType := normalizeDriverType(definition.Type) displayName := resolveDriverDisplayName(definition) From 095b22951e71c4fffdcd01a9edd11d3846ce8455 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 13:26:28 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=90=9B=20fix(redis-viewer):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=A7=E6=95=B0=E6=8D=AE=E9=87=8F=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=20Key=20=E5=8A=A0=E8=BD=BD=E4=B8=8D=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 ScanKeys 改为按目标数量多轮聚合扫描,不再只依赖单轮返回结果 - 新增扫描目标数/步长/轮次上限,避免扫描过少或无限循环 - 前端首屏加载、搜索、刷新统一按较大批次请求,避免回退到几百条 - 加载更多改为按固定批次继续拉取并保持去重合并 - refs #129 --- frontend/src/components/RedisViewer.tsx | 34 ++++--- internal/redis/redis_impl.go | 124 ++++++++++++++++++------ 2 files changed, 117 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index cb102ed..d998950 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -14,6 +14,8 @@ const REDIS_TREE_KEY_TYPE_WIDTH = 92; const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84; const REDIS_TREE_KEY_TTL_WIDTH = 92; const REDIS_TREE_HIDE_TTL_THRESHOLD = 460; +const REDIS_KEY_INITIAL_LOAD_COUNT = 2000; +const REDIS_KEY_LOAD_MORE_COUNT = 2000; interface RedisViewerProps { connectionId: string; @@ -462,27 +464,34 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; }, [connection, redisDB]); - const loadKeys = useCallback(async (pattern: string = '*', fromCursor: number = 0, append: boolean = false) => { + const loadKeys = useCallback(async ( + pattern: string = '*', + fromCursor: number = 0, + append: boolean = false, + targetCount: number = REDIS_KEY_INITIAL_LOAD_COUNT + ) => { const config = getConfig(); if (!config) return; setLoading(true); try { - const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, 100); + const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, targetCount); if (res.success) { const result = res.data; + const scannedKeys = Array.isArray(result?.keys) ? result.keys : []; + const nextCursor = Number(result?.cursor || 0); if (append) { setKeys(prev => { const keyMap = new Map(); prev.forEach(item => keyMap.set(item.key, item)); - result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item)); + scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item)); return Array.from(keyMap.values()); }); } else { - setKeys(result.keys); + setKeys(scannedKeys); } - setCursor(result.cursor); - setHasMore(result.cursor !== 0); + setCursor(nextCursor); + setHasMore(nextCursor !== 0); } else { message.error('加载 Key 失败: ' + res.message); } @@ -494,23 +503,26 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }, [getConfig]); useEffect(() => { - loadKeys(searchPattern, 0, false); + loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT); }, [redisDB]); const handleSearch = (value: string) => { const pattern = value.trim() || '*'; setSearchPattern(pattern); setCursor(0); - loadKeys(pattern, 0, false); + loadKeys(pattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT); }; const handleLoadMore = () => { - loadKeys(searchPattern, cursor, true); + if (!hasMore || loading) { + return; + } + loadKeys(searchPattern, cursor, true, REDIS_KEY_LOAD_MORE_COUNT); }; const handleRefresh = () => { setCursor(0); - loadKeys(searchPattern, 0, false); + loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT); }; const loadKeyValue = async (key: string) => { @@ -1777,7 +1789,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { {hasMore && (
- +
)} diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index bd32aea..03ee844 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -22,6 +22,14 @@ type RedisClientImpl struct { forwarder *ssh.LocalForwarder } +const ( + redisScanDefaultTargetCount int64 = 2000 + redisScanMaxTargetCount int64 = 10000 + redisScanMinStepCount int64 = 200 + redisScanMaxStepCount int64 = 2000 + redisScanMaxRounds = 64 +) + // NewRedisClient creates a new Redis client instance func NewRedisClient() RedisClient { return &RedisClientImpl{} @@ -108,21 +116,70 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( if pattern == "" { pattern = "*" } + targetCount := normalizeRedisScanTargetCount(count) + scanStepCount := normalizeRedisScanStepCount(targetCount) + currentCursor := cursor + round := 0 + + keys := make([]string, 0, int(targetCount)) + seen := make(map[string]struct{}, int(targetCount)) + + for len(keys) < int(targetCount) { + batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result() + if err != nil { + return nil, err + } + + for _, key := range batch { + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + keys = append(keys, key) + if len(keys) >= int(targetCount) { + break + } + } + + currentCursor = nextCursor + round++ + if currentCursor == 0 || round >= redisScanMaxRounds { + break + } + } + + return &RedisScanResult{ + Keys: r.loadRedisKeyInfos(ctx, keys), + Cursor: currentCursor, + }, nil +} + +func normalizeRedisScanTargetCount(count int64) int64 { if count <= 0 { - count = 100 + return redisScanDefaultTargetCount + } + if count > redisScanMaxTargetCount { + return redisScanMaxTargetCount + } + return count +} + +func normalizeRedisScanStepCount(targetCount int64) int64 { + if targetCount < redisScanMinStepCount { + return redisScanMinStepCount + } + if targetCount > redisScanMaxStepCount { + return redisScanMaxStepCount + } + return targetCount +} + +func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) []RedisKeyInfo { + result := make([]RedisKeyInfo, 0, len(keys)) + if len(keys) == 0 { + return result } - keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, count).Result() - if err != nil { - return nil, err - } - - result := &RedisScanResult{ - Keys: make([]RedisKeyInfo, 0, len(keys)), - Cursor: nextCursor, - } - - // Get type and TTL for each key pipe := r.client.Pipeline() typeResults := make([]*redis.StatusCmd, len(keys)) ttlResults := make([]*redis.DurationCmd, len(keys)) @@ -132,37 +189,44 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( ttlResults[i] = pipe.TTL(ctx, key) } - _, err = pipe.Exec(ctx) + _, err := pipe.Exec(ctx) if err != nil && err != redis.Nil { - // Fallback: get info one by one for _, key := range keys { - keyType, _ := r.GetKeyType(key) - ttl, _ := r.GetTTL(key) - result.Keys = append(result.Keys, RedisKeyInfo{ + keyType, typeErr := r.client.Type(ctx, key).Result() + if typeErr != nil && typeErr != redis.Nil { + keyType = "" + } + ttlValue, ttlErr := r.client.TTL(ctx, key).Result() + if ttlErr != nil && ttlErr != redis.Nil { + ttlValue = -2 + } + result = append(result, RedisKeyInfo{ Key: key, Type: keyType, - TTL: ttl, + TTL: toRedisTTLSeconds(ttlValue), }) } - return result, nil + return result } for i, key := range keys { - keyType := typeResults[i].Val() - ttl := int64(ttlResults[i].Val().Seconds()) - if ttlResults[i].Val() == -1 { - ttl = -1 - } else if ttlResults[i].Val() == -2 { - ttl = -2 - } - result.Keys = append(result.Keys, RedisKeyInfo{ + result = append(result, RedisKeyInfo{ Key: key, - Type: keyType, - TTL: ttl, + Type: typeResults[i].Val(), + TTL: toRedisTTLSeconds(ttlResults[i].Val()), }) } + return result +} - return result, nil +func toRedisTTLSeconds(ttl time.Duration) int64 { + if ttl == -1 { + return -1 + } + if ttl == -2 { + return -2 + } + return int64(ttl.Seconds()) } // GetKeyType returns the type of a key From ad9d8a12bed3ae7187b3b3775c0d691706f2f414 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 13:55:37 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E2=9C=A8=20feat(appearance):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=90=AF=E5=8A=A8=E6=97=B6=E5=85=A8=E5=B1=8F=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E5=B9=B6=E6=94=AF=E6=8C=81=E5=90=AF=E5=8A=A8=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E7=8A=B6=E6=80=81=E8=87=AA=E5=8A=A8=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在外观设置中提供用户可控的启动全屏偏好项 - 持久化保存用户偏好,重启后自动恢复 - 启动阶段按偏好自动执行全屏,失败时回退最大化 - 保持现有标题栏窗口操作行为不变 - refs #129 --- frontend/src/App.tsx | 94 ++++++++++++++++++++++++++++++++++++++++++- frontend/src/store.ts | 12 ++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2125400..0d8db66 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 } from 'antd'; +import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons'; -import { Environment, EventsOn } from '../wailsjs/runtime/runtime'; +import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; @@ -26,6 +26,8 @@ function App() { const setTheme = useStore(state => state.setTheme); const appearance = useStore(state => state.appearance); const setAppearance = useStore(state => state.setAppearance); + const startupFullscreen = useStore(state => state.startupFullscreen); + const setStartupFullscreen = useStore(state => state.setStartupFullscreen); const darkMode = themeMode === 'dark'; const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity); const effectiveBlur = normalizeBlurForPlatform(appearance.blur); @@ -56,6 +58,84 @@ function App() { }; }, []); + useEffect(() => { + let cancelled = false; + let startupWindowTimer: number | null = null; + const maxApplyAttempts = 6; + const applyRetryDelayMs = 400; + const settleDelayMs = 160; + + const checkStartupPreferenceApplied = async (): Promise => { + try { + if (await WindowIsFullscreen()) { + return true; + } + } catch (_) { + // ignore + } + try { + if (await WindowIsMaximised()) { + return true; + } + } catch (_) { + // ignore + } + return false; + }; + + const applyStartupWindowPreference = (attempt: number) => { + if (startupWindowTimer !== null) { + window.clearTimeout(startupWindowTimer); + } + startupWindowTimer = window.setTimeout(() => { + if (cancelled) { + return; + } + if (!useStore.getState().startupFullscreen) { + return; + } + Promise.resolve() + .then(async () => { + if (await checkStartupPreferenceApplied()) { + return; + } + // 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。 + WindowFullscreen(); + await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + if (await checkStartupPreferenceApplied()) { + return; + } + WindowMaximise(); + await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + if (await checkStartupPreferenceApplied()) { + return; + } + if (attempt < maxApplyAttempts) { + applyStartupWindowPreference(attempt + 1); + } + }); + }, 300); + }; + + if (useStore.persist.hasHydrated()) { + applyStartupWindowPreference(1); + } + const unsubscribeHydration = useStore.persist.onFinishHydration(() => { + if (cancelled) { + return; + } + applyStartupWindowPreference(1); + }); + + return () => { + cancelled = true; + if (startupWindowTimer !== null) { + window.clearTimeout(startupWindowTimer); + } + unsubscribeHydration(); + }; + }, []); + // Background Helper const getBg = (darkHex: string, lightHex: string) => { if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white @@ -915,6 +995,16 @@ function App() { )} +
+
启动窗口
+
+ 启动时全屏 + setStartupFullscreen(checked)} /> +
+
+ * 修改后下次启动生效 +
+
diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 5dd95a1..b1dddbd 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'; import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types'; const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 }; +const DEFAULT_STARTUP_FULLSCREEN = false; const LEGACY_DEFAULT_OPACITY = 0.95; const OPACITY_EPSILON = 1e-6; const MAX_URI_LENGTH = 4096; @@ -295,6 +296,7 @@ interface AppState { savedQueries: SavedQuery[]; theme: 'light' | 'dark'; appearance: { opacity: number; blur: number }; + startupFullscreen: boolean; sqlFormatOptions: { keywordCase: 'upper' | 'lower' }; queryOptions: QueryOptions; sqlLogs: SqlLog[]; @@ -321,6 +323,7 @@ interface AppState { setTheme: (theme: 'light' | 'dark') => void; setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void; + setStartupFullscreen: (enabled: boolean) => void; setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void; setQueryOptions: (options: Partial) => void; @@ -409,6 +412,10 @@ const sanitizeAppearance = ( return nextAppearance; }; +const sanitizeStartupFullscreen = (value: unknown): boolean => { + return value === true; +}; + const unwrapPersistedAppState = (persistedState: unknown): Record => { if (!persistedState || typeof persistedState !== 'object') { return {}; @@ -430,6 +437,7 @@ export const useStore = create()( savedQueries: [], theme: 'light', appearance: { ...DEFAULT_APPEARANCE }, + startupFullscreen: DEFAULT_STARTUP_FULLSCREEN, sqlFormatOptions: { keywordCase: 'upper' }, queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true }, sqlLogs: [], @@ -541,6 +549,7 @@ export const useStore = create()( setTheme: (theme) => set({ theme }), setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })), + setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }), setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }), setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })), @@ -578,6 +587,7 @@ export const useStore = create()( nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); nextState.theme = sanitizeTheme(state.theme); nextState.appearance = sanitizeAppearance(state.appearance, version); + nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen); nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions); nextState.queryOptions = sanitizeQueryOptions(state.queryOptions); nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount); @@ -593,6 +603,7 @@ export const useStore = create()( savedQueries: sanitizeSavedQueries(state.savedQueries), theme: sanitizeTheme(state.theme), appearance: sanitizeAppearance(state.appearance, 3), + startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen), sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), queryOptions: sanitizeQueryOptions(state.queryOptions), tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), @@ -604,6 +615,7 @@ export const useStore = create()( savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, + startupFullscreen: state.startupFullscreen, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions, tableAccessCount: state.tableAccessCount, From 2f475dddc0bd8ee991fe09e369f9e8e356c591ac Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 10:45:57 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=90=9B=20fix(windows-upgrade):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DWindows=E5=8D=87=E7=BA=A7=E5=90=8E=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=88=97=E8=A1=A8=E4=B8=A2=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启动参数新增固定 WebviewUserDataPath 到 %APPDATA%/GoNavi/WebView2 - 首次启动自动迁移历史 WebView 数据目录 - 保留现有存储键,避免破坏已落盘配置 - 前端持久化读取增加历史结构兼容 - refs #125 --- frontend/src/store.ts | 39 ++++++-- main.go | 1 + main_windows_webview_userdata.go | 123 ++++++++++++++++++++++++++ main_windows_webview_userdata_stub.go | 7 ++ 4 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 main_windows_webview_userdata.go create mode 100644 main_windows_webview_userdata_stub.go diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 51281d4..5dd95a1 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -206,10 +206,27 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { return safeConfig; }; +const resolveConnectionConfigPayload = (raw: Record): unknown => { + if (raw.config && typeof raw.config === 'object') { + return raw.config; + } + // 兼容历史/导入场景:连接对象可能是扁平结构(无 config 包装)。 + const hasLegacyFlatConfig = + raw.type !== undefined || + raw.host !== undefined || + raw.port !== undefined || + raw.user !== undefined || + raw.database !== undefined; + if (hasLegacyFlatConfig) { + return raw; + } + return undefined; +}; + const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection | null => { if (!value || typeof value !== 'object') return null; const raw = value as Record; - const config = sanitizeConnectionConfig(raw.config); + const config = sanitizeConnectionConfig(resolveConnectionConfigPayload(raw)); const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`; const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`; const name = toTrimmedString(raw.name, fallbackName) || fallbackName; @@ -392,6 +409,17 @@ const sanitizeAppearance = ( return nextAppearance; }; +const unwrapPersistedAppState = (persistedState: unknown): Record => { + if (!persistedState || typeof persistedState !== 'object') { + return {}; + } + const raw = persistedState as Record; + if (raw.state && typeof raw.state === 'object') { + return raw.state as Record; + } + return raw; +}; + export const useStore = create()( persist( (set) => ({ @@ -544,10 +572,7 @@ export const useStore = create()( name: 'lite-db-storage', // name of the item in the storage (must be unique) version: 3, migrate: (persistedState: unknown, version: number) => { - if (!persistedState || typeof persistedState !== 'object') { - return persistedState as AppState; - } - const state = persistedState as Partial; + const state = unwrapPersistedAppState(persistedState) as Partial; const nextState: Partial = { ...state }; nextState.connections = sanitizeConnections(state.connections); nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); @@ -560,9 +585,7 @@ export const useStore = create()( return nextState as AppState; }, merge: (persistedState, currentState) => { - const state = (persistedState && typeof persistedState === 'object') - ? persistedState as Partial - : {}; + const state = unwrapPersistedAppState(persistedState) as Partial; return { ...currentState, ...state, diff --git a/main.go b/main.go index 44f3cd1..02cedcb 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,7 @@ func main() { BackdropType: windows.Acrylic, DisableWindowIcon: false, DisableFramelessWindowDecorations: false, + WebviewUserDataPath: resolveWindowsWebviewUserDataPath(), }, Mac: &mac.Options{ WebviewIsTransparent: true, diff --git a/main_windows_webview_userdata.go b/main_windows_webview_userdata.go new file mode 100644 index 0000000..dcf2748 --- /dev/null +++ b/main_windows_webview_userdata.go @@ -0,0 +1,123 @@ +//go:build windows + +package main + +import ( + "io" + "os" + "path/filepath" + "strings" +) + +func resolveWindowsWebviewUserDataPath() string { + appDataDir := strings.TrimSpace(os.Getenv("APPDATA")) + if appDataDir == "" { + return "" + } + + targetDir := filepath.Join(appDataDir, "GoNavi", "WebView2") + _ = migrateLegacyWindowsWebviewUserData(appDataDir, targetDir) + return targetDir +} + +func migrateLegacyWindowsWebviewUserData(appDataDir, targetDir string) error { + if dirHasContent(targetDir) { + return nil + } + + exeName := "GoNavi.exe" + if exePath, err := os.Executable(); err == nil { + base := strings.TrimSpace(filepath.Base(exePath)) + if base != "" { + exeName = base + } + } + exeBase := strings.TrimSuffix(exeName, filepath.Ext(exeName)) + + candidates := []string{ + filepath.Join(appDataDir, exeName), + filepath.Join(appDataDir, exeBase), + filepath.Join(appDataDir, "GoNavi.exe"), + filepath.Join(appDataDir, "GoNavi"), + } + + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + src := filepath.Clean(strings.TrimSpace(candidate)) + if src == "" || strings.EqualFold(src, filepath.Clean(targetDir)) { + continue + } + key := strings.ToLower(src) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + + if !dirHasContent(src) { + continue + } + return copyDirTree(src, targetDir) + } + return nil +} + +func dirHasContent(path string) bool { + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return false + } + entries, err := os.ReadDir(path) + return err == nil && len(entries) > 0 +} + +func copyDirTree(srcDir, dstDir string) error { + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return err + } + + return filepath.WalkDir(srcDir, func(srcPath string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relPath, err := filepath.Rel(srcDir, srcPath) + if err != nil { + return err + } + if relPath == "." { + return nil + } + dstPath := filepath.Join(dstDir, relPath) + + if d.IsDir() { + return os.MkdirAll(dstPath, 0o755) + } + + info, err := d.Info() + if err != nil { + return err + } + return copyFileWithMode(srcPath, dstPath, info.Mode()) + }) +} + +func copyFileWithMode(srcPath, dstPath string, mode os.FileMode) error { + srcFile, err := os.Open(srcPath) + if err != nil { + return err + } + defer srcFile.Close() + + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm()) + if err != nil { + return err + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + return nil +} diff --git a/main_windows_webview_userdata_stub.go b/main_windows_webview_userdata_stub.go new file mode 100644 index 0000000..7dfe331 --- /dev/null +++ b/main_windows_webview_userdata_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +func resolveWindowsWebviewUserDataPath() string { + return "" +} From eca560b4e5f0635d79a8061618da384e2b841e31 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 10:57:05 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=95=E5=85=83=E6=A0=BC=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E6=8B=96=E6=8B=BD=E8=B6=8A=E7=95=8C=E4=B8=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 DataGrid 拖拽选区流程新增边缘自动滚动能力(横向+纵向) - 拖拽过程中增加鼠标位置跟踪并通过 RAF 循环驱动滚动 - 通过 elementFromPoint 兜底命中单元格,保证越界拖拽时选区持续更新 - 在 mouseup、模式切换和退出编辑器时统一清理 RAF 与拖拽状态 - refs #127 --- frontend/src/components/DataGrid.tsx | 192 ++++++++++++++++++++++----- 1 file changed, 158 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 7dee7a7..6cfec4f 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -619,6 +619,8 @@ const DataGrid: React.FC = ({ // 使用 ref 来优化拖拽性能,完全避免状态更新 const cellSelectionRafRef = useRef(null); const cellSelectionScrollRafRef = useRef(null); + const cellSelectionAutoScrollRafRef = useRef(null); + const cellSelectionPointerRef = useRef<{ x: number; y: number } | null>(null); const isDraggingRef = useRef(false); // 导入预览 Modal 状态 @@ -1102,6 +1104,11 @@ const DataGrid: React.FC = ({ currentSelectionRef.current = new Set(); selectionStartRef.current = null; isDraggingRef.current = false; + cellSelectionPointerRef.current = null; + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } updateCellSelection(new Set()); }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]); @@ -1111,8 +1118,12 @@ const DataGrid: React.FC = ({ const container = containerRef.current; if (!container) return; + const EDGE_THRESHOLD_PX = 28; + const MIN_SCROLL_STEP = 8; + const MAX_SCROLL_STEP = 24; - const getCellInfo = (target: HTMLElement): { rowKey: string; colName: string } | null => { + const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => { + if (!target) return null; const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement; if (!td) return null; const rowKey = td.getAttribute('data-row-key'); @@ -1121,35 +1132,12 @@ const DataGrid: React.FC = ({ return { rowKey, colName }; }; - const onMouseDown = (e: MouseEvent) => { - const cellInfo = getCellInfo(e.target as HTMLElement); - if (!cellInfo) return; - - e.preventDefault(); - isDraggingRef.current = true; - const currentData = displayDataRef.current; - const nextRowIndexMap = new Map(); - currentData.forEach((r, idx) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - nextRowIndexMap.set(String(k), idx); - }); - rowIndexMapRef.current = nextRowIndexMap; - - const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1; - const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; - selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex }; - currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]); - updateCellSelection(currentSelectionRef.current); + const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => { + const target = document.elementFromPoint(x, y) as HTMLElement | null; + return getCellInfo(target); }; - const onMouseMove = (e: MouseEvent) => { - if (!isDraggingRef.current || !selectionStartRef.current) return; - - const cellInfo = getCellInfo(e.target as HTMLElement); - if (!cellInfo) return; - - // 使用 RAF 节流 + const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => { if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); } @@ -1188,9 +1176,124 @@ const DataGrid: React.FC = ({ }); }; + const stopAutoScroll = () => { + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } + }; + + const getScrollStep = (distanceToEdge: number): number => { + const ratio = Math.min(1, Math.max(0, distanceToEdge / EDGE_THRESHOLD_PX)); + return Math.round(MIN_SCROLL_STEP + (MAX_SCROLL_STEP - MIN_SCROLL_STEP) * ratio); + }; + + const autoScrollTick = () => { + if (!isDraggingRef.current || !selectionStartRef.current) { + stopAutoScroll(); + return; + } + + const pointer = cellSelectionPointerRef.current; + const tableBody = container.querySelector('.ant-table-body') as HTMLElement | null; + if (!pointer || !tableBody) { + cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); + return; + } + + const rect = tableBody.getBoundingClientRect(); + const maxScrollTop = Math.max(0, tableBody.scrollHeight - tableBody.clientHeight); + const maxScrollLeft = Math.max(0, tableBody.scrollWidth - tableBody.clientWidth); + let deltaY = 0; + let deltaX = 0; + + if (pointer.y < rect.top + EDGE_THRESHOLD_PX && tableBody.scrollTop > 0) { + const distance = rect.top + EDGE_THRESHOLD_PX - pointer.y; + deltaY = -getScrollStep(distance); + } else if (pointer.y > rect.bottom - EDGE_THRESHOLD_PX && tableBody.scrollTop < maxScrollTop) { + const distance = pointer.y - (rect.bottom - EDGE_THRESHOLD_PX); + deltaY = getScrollStep(distance); + } + + if (pointer.x < rect.left + EDGE_THRESHOLD_PX && tableBody.scrollLeft > 0) { + const distance = rect.left + EDGE_THRESHOLD_PX - pointer.x; + deltaX = -getScrollStep(distance); + } else if (pointer.x > rect.right - EDGE_THRESHOLD_PX && tableBody.scrollLeft < maxScrollLeft) { + const distance = pointer.x - (rect.right - EDGE_THRESHOLD_PX); + deltaX = getScrollStep(distance); + } + + let didScroll = false; + if (deltaY !== 0) { + const nextTop = Math.max(0, Math.min(maxScrollTop, tableBody.scrollTop + deltaY)); + if (nextTop !== tableBody.scrollTop) { + tableBody.scrollTop = nextTop; + didScroll = true; + } + } + + if (deltaX !== 0) { + const nextLeft = Math.max(0, Math.min(maxScrollLeft, tableBody.scrollLeft + deltaX)); + if (nextLeft !== tableBody.scrollLeft) { + tableBody.scrollLeft = nextLeft; + didScroll = true; + } + } + + if (didScroll) { + const cellInfo = getCellInfoFromPoint(pointer.x, pointer.y); + if (cellInfo) scheduleSelectionUpdate(cellInfo); + } + + cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); + }; + + const ensureAutoScroll = () => { + if (cellSelectionAutoScrollRafRef.current !== null) return; + cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); + }; + + const onMouseDown = (e: MouseEvent) => { + const target = e.target instanceof HTMLElement ? e.target : null; + const cellInfo = getCellInfo(target); + if (!cellInfo) return; + + e.preventDefault(); + isDraggingRef.current = true; + cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; + const currentData = displayDataRef.current; + const nextRowIndexMap = new Map(); + currentData.forEach((r, idx) => { + const k = r?.[GONAVI_ROW_KEY]; + if (k === undefined) return; + nextRowIndexMap.set(String(k), idx); + }); + rowIndexMapRef.current = nextRowIndexMap; + + const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1; + const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; + selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex }; + currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]); + updateCellSelection(currentSelectionRef.current); + ensureAutoScroll(); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isDraggingRef.current || !selectionStartRef.current) return; + cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; + ensureAutoScroll(); + + const target = e.target instanceof HTMLElement ? e.target : null; + const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY); + if (!cellInfo) return; + scheduleSelectionUpdate(cellInfo); + }; + const onMouseUp = () => { if (!isDraggingRef.current) return; isDraggingRef.current = false; + cellSelectionPointerRef.current = null; + stopAutoScroll(); if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); @@ -1231,6 +1334,8 @@ const DataGrid: React.FC = ({ cancelAnimationFrame(cellSelectionScrollRafRef.current); cellSelectionScrollRafRef.current = null; } + stopAutoScroll(); + cellSelectionPointerRef.current = null; isDraggingRef.current = false; }; }, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]); @@ -2332,6 +2437,7 @@ const DataGrid: React.FC = ({ currentSelectionRef.current = new Set(); selectionStartRef.current = null; isDraggingRef.current = false; + cellSelectionPointerRef.current = null; if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); cellSelectionRafRef.current = null; @@ -2340,6 +2446,10 @@ const DataGrid: React.FC = ({ cancelAnimationFrame(cellSelectionScrollRafRef.current); cellSelectionScrollRafRef.current = null; } + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } updateCellSelection(new Set()); if (!next) setBatchEditModalOpen(false); message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式'); @@ -2403,12 +2513,26 @@ const DataGrid: React.FC = ({ onChange={(val) => { const nextMode = String(val) as GridViewMode; if (nextMode === 'json' && cellEditMode) { - setCellEditMode(false); - setSelectedCells(new Set()); - currentSelectionRef.current = new Set(); - selectionStartRef.current = null; - updateCellSelection(new Set()); - } + setCellEditMode(false); + setSelectedCells(new Set()); + currentSelectionRef.current = new Set(); + selectionStartRef.current = null; + isDraggingRef.current = false; + cellSelectionPointerRef.current = null; + if (cellSelectionRafRef.current !== null) { + cancelAnimationFrame(cellSelectionRafRef.current); + cellSelectionRafRef.current = null; + } + if (cellSelectionScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionScrollRafRef.current); + cellSelectionScrollRafRef.current = null; + } + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } + updateCellSelection(new Set()); + } if (nextMode === 'text') { const selectedKey = selectedRowKeys[0]; if (selectedKey !== undefined) { From 2a8fff4d93e30ebb1255539ba8846fd4270d1c31 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 12:29:54 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=E2=9C=A8=20feat(driver-manager):=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E9=A9=B1=E5=8A=A8=E7=AE=A1=E7=90=86=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E8=AF=8A=E6=96=AD=E4=B8=8E=E6=9C=AC=E5=9C=B0=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CheckDriverNetworkStatus,探测 GitHub API/Release/Go Proxy 可达性并返回代理环境信息。 - 驱动管理弹窗新增网络检测结果、驱动目录复用说明、本地导入入口与日志查看。 - 操作日志支持同签名进度覆盖更新,下载百分比动态刷新,不再重复新增日志行。 - 修正弹窗滚动行为与表格滚动体验。 - refs #128 --- .../src/components/DriverManagerModal.tsx | 394 ++++++++++++++++-- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/methods_driver.go | 360 ++++++++++++++-- 4 files changed, 706 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index 6bbadd3..d4e7190 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -1,16 +1,19 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd'; -import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd'; +import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { + CheckDriverNetworkStatus, DownloadDriverPackage, GetDriverVersionList, GetDriverVersionPackageSize, GetDriverStatusList, + InstallLocalDriverPackage, RemoveDriverPackage, + SelectDriverPackageFile, } from '../../wailsjs/go/app/App'; -const { Text } = Typography; +const { Paragraph, Text } = Typography; type DriverStatusRow = { type: string; @@ -23,6 +26,10 @@ type DriverStatusRow = { packageInstalled: boolean; connectable: boolean; defaultDownloadUrl?: string; + installDir?: string; + packagePath?: string; + executablePath?: string; + downloadedAt?: string; message?: string; }; @@ -39,6 +46,32 @@ type ProgressState = { percent: number; }; +type DriverLogEntry = { + time: string; + text: string; + signature: string; +}; + +type DriverNetworkProbe = { + name: string; + url: string; + reachable: boolean; + httpStatus?: number; + latencyMs?: number; + error?: string; +}; + +type DriverNetworkStatus = { + reachable: boolean; + summary: string; + recommendedProxy: boolean; + proxyConfigured: boolean; + proxyEnv?: Record; + checks: DriverNetworkProbe[]; + checkedAt?: string; + logPath?: string; +}; + type DriverVersionOption = { version: string; downloadUrl: string; @@ -101,14 +134,65 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => { const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { const [loading, setLoading] = useState(false); const [downloadDir, setDownloadDir] = useState(''); + const [networkChecking, setNetworkChecking] = useState(false); + const [networkStatus, setNetworkStatus] = useState(null); const [rows, setRows] = useState([]); const [actionDriver, setActionDriver] = useState(''); const [progressMap, setProgressMap] = useState>({}); + const [operationLogMap, setOperationLogMap] = useState>({}); + const [logDriverType, setLogDriverType] = useState(''); + const [logModalOpen, setLogModalOpen] = useState(false); const [versionMap, setVersionMap] = useState>({}); const [selectedVersionMap, setSelectedVersionMap] = useState>({}); const [versionLoadingMap, setVersionLoadingMap] = useState>({}); const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({}); + const appendOperationLog = useCallback(( + driverType: string, + text: string, + signature?: string, + mode: 'append' | 'update-last' = 'append', + ) => { + const normalized = String(driverType || '').trim().toLowerCase(); + const content = String(text || '').trim(); + if (!normalized || !content) { + return; + } + const sign = String(signature || content).trim() || content; + const now = new Date().toLocaleTimeString(); + setOperationLogMap((prev) => { + const history = prev[normalized] || []; + if (history.length > 0) { + const last = history[history.length - 1]; + if (last.signature === sign) { + if (mode === 'update-last') { + if (last.text === content) { + return prev; + } + const nextHistory = [...history]; + nextHistory[nextHistory.length - 1] = { + ...last, + text: content, + time: now, + }; + return { ...prev, [normalized]: nextHistory }; + } + return prev; + } + } + const nextHistory = [ + ...history, + { + time: now, + text: content, + signature: sign, + }, + ]; + const sliced = nextHistory.length > 200 ? nextHistory.slice(nextHistory.length - 200) : nextHistory; + return { ...prev, [normalized]: sliced }; + }); + }, []); + const refreshStatus = useCallback(async (toastOnError = true) => { setLoading(true); try { @@ -139,6 +223,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ packageInstalled: !!item.packageInstalled, connectable: !!item.connectable, defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined, + installDir: String(item.installDir || '').trim() || undefined, + packagePath: String(item.packagePath || '').trim() || undefined, + executablePath: String(item.executablePath || '').trim() || undefined, + downloadedAt: String(item.downloadedAt || '').trim() || undefined, message: String(item.message || '').trim() || undefined, })); setRows(nextRows); @@ -151,6 +239,45 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ } }, [downloadDir]); + const checkNetworkStatus = useCallback(async (toastOnError = false) => { + setNetworkChecking(true); + try { + const res = await CheckDriverNetworkStatus(); + if (!res?.success) { + if (toastOnError) { + message.error(res?.message || '驱动网络检测失败'); + } + return; + } + const data = (res?.data || {}) as any; + const checks = Array.isArray(data.checks) ? data.checks : []; + const normalizedChecks: DriverNetworkProbe[] = checks.map((item: any) => ({ + name: String(item.name || '').trim(), + url: String(item.url || '').trim(), + reachable: !!item.reachable, + httpStatus: Number(item.httpStatus || 0) || undefined, + latencyMs: Number(item.latencyMs || 0) || undefined, + error: String(item.error || '').trim() || undefined, + })); + setNetworkStatus({ + reachable: !!data.reachable, + summary: String(data.summary || '').trim() || '驱动网络检测已完成', + recommendedProxy: !!data.recommendedProxy, + proxyConfigured: !!data.proxyConfigured, + proxyEnv: (data.proxyEnv || {}) as Record, + checkedAt: String(data.checkedAt || '').trim() || undefined, + checks: normalizedChecks, + logPath: String(data.logPath || '').trim() || undefined, + }); + } catch (err: any) { + if (toastOnError) { + message.error(`驱动网络检测失败:${err?.message || String(err)}`); + } + } finally { + setNetworkChecking(false); + } + }, []); + const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => { if (row.builtIn) { return [] as DriverVersionOption[]; @@ -305,7 +432,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ return; } refreshStatus(false); - }, [open, refreshStatus]); + checkNetworkStatus(false); + }, [checkNetworkStatus, open, refreshStatus]); useEffect(() => { if (!open) { @@ -330,11 +458,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ percent, }, })); + const progressText = `${Math.round(percent)}%`; + const statusText = String(status || '').toUpperCase(); + const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`; + const lineSignature = `${statusText}|${messageText || '-'}`; + appendOperationLog(driverType, lineText, lineSignature, 'update-last'); }); return () => { off(); }; - }, [open]); + }, [appendOperationLog, open]); const installDriver = useCallback(async (row: DriverStatusRow) => { setActionDriver(row.type); @@ -346,6 +479,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ percent: 0, }, })); + appendOperationLog(row.type, '[START] 开始自动安装'); try { let options = versionMap[row.type] || []; if (options.length === 0) { @@ -361,25 +495,81 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir); if (!result?.success) { - message.error(result?.message || `安装 ${row.name} 失败`); + const errText = result?.message || `安装 ${row.name} 失败`; + appendOperationLog(row.type, `[ERROR] ${errText}`); + message.error(errText); return; } const versionTip = selectedVersion ? `(${selectedVersion})` : ''; + appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`); message.success(`${row.name}${versionTip} 已安装启用`); refreshStatus(false); } finally { setActionDriver(''); } - }, [downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]); + }, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]); + + const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => { + const fileRes = await SelectDriverPackageFile(downloadDir); + if (!fileRes?.success) { + if (String(fileRes?.message || '') !== 'Cancelled') { + message.error(fileRes?.message || '选择本地驱动包失败'); + } + return; + } + const filePath = String((fileRes?.data as any)?.path || '').trim(); + if (!filePath) { + message.error('未选择有效的驱动包文件'); + return; + } + + setActionDriver(row.type); + setProgressMap((prev) => ({ + ...prev, + [row.type]: { + status: 'start', + message: '开始导入本地驱动包', + percent: 0, + }, + })); + appendOperationLog(row.type, `[START] 开始本地导入:${filePath}`); + try { + const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir); + if (!result?.success) { + const errText = result?.message || `导入 ${row.name} 本地驱动包失败`; + appendOperationLog(row.type, `[ERROR] ${errText}`); + message.error(errText); + return; + } + appendOperationLog(row.type, '[DONE] 本地导入安装完成'); + message.success(`${row.name} 本地驱动包已安装启用`); + refreshStatus(false); + } finally { + setActionDriver(''); + } + }, [appendOperationLog, downloadDir, refreshStatus]); + + const openDriverLog = useCallback((driverType: string) => { + const normalized = String(driverType || '').trim().toLowerCase(); + if (!normalized) { + return; + } + setLogDriverType(normalized); + setLogModalOpen(true); + }, []); const removeDriver = useCallback(async (row: DriverStatusRow) => { setActionDriver(row.type); + appendOperationLog(row.type, '[START] 开始移除驱动'); try { const result = await RemoveDriverPackage(row.type, downloadDir); if (!result?.success) { - message.error(result?.message || `移除 ${row.name} 失败`); + const errText = result?.message || `移除 ${row.name} 失败`; + appendOperationLog(row.type, `[ERROR] ${errText}`); + message.error(errText); return; } + appendOperationLog(row.type, '[DONE] 驱动移除完成'); message.success(`${row.name} 已移除`); setProgressMap((prev) => { const next = { ...prev }; @@ -390,7 +580,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ } finally { setActionDriver(''); } - }, [downloadDir, refreshStatus]); + }, [appendOperationLog, downloadDir, refreshStatus]); const columns = useMemo(() => { return [ @@ -400,6 +590,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ key: 'name', width: 150, }, + { + title: '安装位置', + key: 'installPath', + width: 260, + render: (_: string, row: DriverStatusRow) => { + if (row.builtIn) { + return 内置; + } + const installPath = row.executablePath || row.installDir || '-'; + if (installPath === '-') { + return -; + } + return ( + + {installPath} + + ); + }, + }, { title: '安装包大小', dataIndex: 'packageSizeText', @@ -511,7 +720,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ { title: '操作', key: 'actions', - width: 190, + width: 320, render: (_: string, row: DriverStatusRow) => { if (row.builtIn) { return -; @@ -521,19 +730,20 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ if (isSlimBuildUnavailable && !row.packageInstalled) { return 需 Full 版; } - if (row.connectable) { - return ( - - ); - } - return ( + + const logs = operationLogMap[row.type] || []; + const hasLogs = logs.length > 0; + + const mainAction = row.connectable ? ( + + ) : ( ); + + return ( + + {mainAction} + + + + ); }, }, ]; - }, [actionDriver, installDriver, loadVersionOptions, loadVersionPackageSize, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]); + }, [actionDriver, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]); + + const activeLogRow = useMemo(() => { + if (!logDriverType) { + return undefined; + } + return rows.find((item) => item.type === logDriverType); + }, [logDriverType, rows]); + + const activeDriverLogs = operationLogMap[logDriverType] || []; + const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`); + const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {}); return ( void }> = ({ open={open} onCancel={onClose} width={980} + style={{ top: 24 }} + styles={{ + body: { + maxHeight: 'calc(100vh - 220px)', + overflowY: 'auto', + overflowX: 'hidden', + paddingRight: 18, + }, + }} destroyOnClose footer={[ , + , , @@ -566,6 +819,67 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ > 除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。 + {networkStatus ? ( + + + 驱动下载依赖 GitHub 与 Go 模块代理网络。若检测失败,建议先启用 HTTP/HTTPS/SOCKS5 代理后重试。 + + + {networkStatus.checks.map((item) => ( + + {item.name}:{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `,HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `,${item.latencyMs}ms` : ''}{item.error ? `,${item.error}` : ''} + + ))} + {proxyEnvEntries.length > 0 ? ( + + 检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')} + + ) : ( + 未检测到系统代理环境变量。 + )} + + ), + }, + ]} + /> + + )} + /> + ) : ( + + )} + + + 自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。 + 手动导入支持单个驱动代理文件(如 `mariadb-driver-agent` / `mariadb-driver-agent.exe`)或驱动总包 `GoNavi-DriverAgents.zip`。 + + 驱动根目录:{downloadDir || '-'} + + {networkStatus?.logPath ? ( + + 运行日志文件:{networkStatus.logPath} + + ) : null} + + )} + />
void }> = ({ dataSource={rows} pagination={false} size="middle" + scroll={{ x: 1450 }} /> + setLogModalOpen(false)} + footer={[ + , + ]} + width={780} + > + + {activeLogRow?.installDir ? ( + + 安装目录:{activeLogRow.installDir} + + ) : null} + {activeLogRow?.executablePath ? ( + + 驱动可执行文件:{activeLogRow.executablePath} + + ) : null} + {activeDriverLogLines.length > 0 ? ( +
+              {activeDriverLogLines.join('\n')}
+            
+ ) : ( + 当前驱动暂无操作日志。 + )} +
+
); }; diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index e177d59..8c38771 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -6,6 +6,8 @@ import {redis} from '../models'; export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise; +export function CheckDriverNetworkStatus():Promise; + export function CheckForUpdates():Promise; export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 120f0bf..3b1a185 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4); } +export function CheckDriverNetworkStatus() { + return window['go']['app']['App']['CheckDriverNetworkStatus'](); +} + export function CheckForUpdates() { return window['go']['app']['App']['CheckForUpdates'](); } diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 46268e0..9fe2e56 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -21,6 +22,7 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/logger" "github.com/wailsapp/wails/v2/pkg/runtime" "golang.org/x/mod/semver" @@ -79,6 +81,15 @@ type driverDownloadProgressPayload struct { Message string `json:"message,omitempty"` } +type driverNetworkProbeItem struct { + Name string `json:"name"` + URL string `json:"url"` + Reachable bool `json:"reachable"` + HTTPStatus int `json:"httpStatus,omitempty"` + LatencyMs int64 `json:"latencyMs,omitempty"` + Error string `json:"error,omitempty"` +} + type pinnedDriverPackage struct { Version string DownloadURL string @@ -188,6 +199,7 @@ const ( driverVersionWarmupMinInterval = 30 * time.Second driverBundleIndexMaxSize = 1 << 20 driverManifestMaxSize = 2 << 20 + driverNetworkProbeTimeout = 4 * time.Second driverChecksumPolicyStrict = "strict" driverChecksumPolicyWarn = "warn" driverChecksumPolicyOff = "off" @@ -592,6 +604,59 @@ func (a *App) GetDriverStatusList(downloadDir string, manifestURL string) connec } } +func (a *App) CheckDriverNetworkStatus() connection.QueryResult { + checks := []driverNetworkProbeItem{ + { + Name: "GitHub API", + URL: "https://api.github.com/rate_limit", + }, + { + Name: "GitHub 驱动发布", + URL: fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", updateRepo, optionalDriverBundleAssetName), + }, + { + Name: "Go 模块代理", + URL: "https://proxy.golang.org/github.com/go-sql-driver/mysql/@v/list", + }, + } + + allReachable := true + for index := range checks { + checks[index] = probeDriverNetworkEndpoint(checks[index]) + if !checks[index].Reachable { + allReachable = false + } + } + + proxyEnv := collectDriverProxyEnv() + proxyConfigured := len(proxyEnv) > 0 + summary := "驱动下载网络检测通过,可直接安装驱动。" + if !allReachable { + if proxyConfigured { + summary = "检测到部分驱动下载地址不可达,请确认系统代理配置有效后重试。" + } else { + summary = "检测到部分驱动下载地址不可达,建议先配置 HTTP/HTTPS/SOCKS5 代理后再安装驱动。" + } + } + + data := map[string]interface{}{ + "reachable": allReachable, + "summary": summary, + "recommendedProxy": !allReachable, + "proxyConfigured": proxyConfigured, + "proxyEnv": proxyEnv, + "checkedAt": time.Now().Format(time.RFC3339), + "checks": checks, + } + if logPath := strings.TrimSpace(logger.Path()); logPath != "" { + data["logPath"] = logPath + } + return connection.QueryResult{ + Success: true, + Data: data, + } +} + func (a *App) InstallLocalDriverPackage(driverType string, filePath string, downloadDir string) connection.QueryResult { definition, ok := resolveDriverDefinition(driverType) if !ok { @@ -614,28 +679,27 @@ func (a *App) InstallLocalDriverPackage(driverType string, filePath string, down } db.SetExternalDriverDownloadDirectory(resolvedDir) - hash := "" - if pathText := strings.TrimSpace(filePath); pathText != "" { - if fileHash, hashErr := hashFileSHA256(pathText); hashErr == nil { - hash = fileHash + a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, "开始安装本地驱动包") + selectedVersion := resolveDriverInstallVersion(definition.PinnedVersion, "local://manual", definition) + meta, installErr := installOptionalDriverAgentFromLocalFile(definition, filePath, resolvedDir, selectedVersion) + if installErr != nil { + errText := normalizeErrorMessage(installErr) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(installErr, "导入本地驱动包失败,driver=%s file=%s", definition.Type, strings.TrimSpace(filePath)), } } - - a.emitDriverDownloadProgress(definition.Type, "start", 0, 0, "开始安装") - meta := installedDriverPackage{ - DriverType: definition.Type, - Version: resolveDriverInstallVersion(definition.PinnedVersion, "local://activate", definition), - FilePath: "", - FileName: "embedded-go-driver", - DownloadURL: "local://activate", - SHA256: hash, - DownloadedAt: time.Now().Format(time.RFC3339), - } + a.emitDriverDownloadProgress(definition.Type, "downloading", 90, 100, "写入驱动元数据") if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil { - a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error()) - return connection.QueryResult{Success: false, Message: err.Error()} + errText := normalizeErrorMessage(err) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(err, "写入本地驱动元数据失败,driver=%s", definition.Type), + } } - a.emitDriverDownloadProgress(definition.Type, "done", 1, 1, "安装完成(纯 Go 驱动已启用)") + a.emitDriverDownloadProgress(definition.Type, "done", 100, 100, "本地驱动包导入完成") return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{ "driverType": definition.Type, @@ -683,13 +747,21 @@ func (a *App) DownloadDriverPackage(driverType string, version string, downloadU a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, fmt.Sprintf("开始安装 %s 驱动代理", displayName)) meta, installErr := installOptionalDriverAgentPackage(a, definition, selectedVersion, resolvedDir, urlText) if installErr != nil { - a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, installErr.Error()) - return connection.QueryResult{Success: false, Message: installErr.Error()} + errText := normalizeErrorMessage(installErr) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(installErr, "驱动下载安装失败,driver=%s version=%s url=%s", definition.Type, selectedVersion, urlText), + } } a.emitDriverDownloadProgress(definition.Type, "downloading", 95, 100, "写入驱动元数据") if writeErr := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); writeErr != nil { - a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, writeErr.Error()) - return connection.QueryResult{Success: false, Message: writeErr.Error()} + errText := normalizeErrorMessage(writeErr) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(writeErr, "写入驱动元数据失败,driver=%s version=%s", definition.Type, selectedVersion), + } } a.emitDriverDownloadProgress(definition.Type, "done", 100, 100, fmt.Sprintf("%s 驱动代理安装完成", displayName)) return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{ @@ -710,8 +782,12 @@ func (a *App) DownloadDriverPackage(driverType string, version string, downloadU DownloadedAt: time.Now().Format(time.RFC3339), } if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil { - a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error()) - return connection.QueryResult{Success: false, Message: err.Error()} + errText := normalizeErrorMessage(err) + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) + return connection.QueryResult{ + Success: false, + Message: logDriverOperationError(err, "写入驱动元数据失败,driver=%s version=%s", definition.Type, selectedVersion), + } } a.emitDriverDownloadProgress(definition.Type, "done", 1, 1, "安装完成(纯 Go 驱动已启用)") @@ -781,6 +857,100 @@ func (a *App) emitDriverDownloadProgress(driverType string, status string, downl runtime.EventsEmit(a.ctx, driverDownloadProgressEvent, payload) } +func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeItem { + probed := item + probed.Reachable = false + probed.HTTPStatus = 0 + probed.Error = "" + probed.LatencyMs = 0 + + urlText := strings.TrimSpace(item.URL) + if urlText == "" { + probed.Error = "检测地址为空" + return probed + } + + client := &http.Client{Timeout: driverNetworkProbeTimeout} + start := time.Now() + req, err := http.NewRequest(http.MethodHead, urlText, nil) + if err != nil { + probed.Error = normalizeErrorMessage(err) + return probed + } + req.Header.Set("User-Agent", "GoNavi-DriverManager") + + resp, err := client.Do(req) + if err != nil { + // 某些网关不支持 HEAD,请回退为 GET(不读取正文)。 + reqGet, reqErr := http.NewRequest(http.MethodGet, urlText, nil) + if reqErr != nil { + probed.Error = normalizeErrorMessage(reqErr) + probed.LatencyMs = time.Since(start).Milliseconds() + return probed + } + reqGet.Header.Set("User-Agent", "GoNavi-DriverManager") + resp, err = client.Do(reqGet) + } + probed.LatencyMs = time.Since(start).Milliseconds() + if err != nil { + probed.Error = normalizeDriverNetworkError(err) + return probed + } + defer resp.Body.Close() + + probed.HTTPStatus = resp.StatusCode + if resp.StatusCode >= 500 { + probed.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) + return probed + } + probed.Reachable = true + return probed +} + +func normalizeDriverNetworkError(err error) string { + if err == nil { + return "" + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "网络连接超时" + } + return normalizeErrorMessage(err) +} + +func collectDriverProxyEnv() map[string]string { + keys := []string{ + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy", + } + result := make(map[string]string) + for _, key := range keys { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + continue + } + result[key] = value + } + return result +} + +func driverLogHint() string { + path := strings.TrimSpace(logger.Path()) + if path == "" { + return "" + } + return fmt.Sprintf("(详细日志:%s)", path) +} + +func logDriverOperationError(err error, format string, args ...interface{}) string { + message := normalizeErrorMessage(err) + if strings.TrimSpace(message) == "" { + message = "未知错误" + } + logger.Error(err, format, args...) + return strings.TrimSpace(message) + driverLogHint() +} + func defaultDriverDownloadDirectory() string { root, err := db.ResolveExternalDriverRoot("") if err == nil && strings.TrimSpace(root) != "" { @@ -1378,12 +1548,7 @@ func fetchGoModuleVersionMetas(modulePath string) ([]goModuleVersionMeta, error) } endpoint := fmt.Sprintf("https://proxy.golang.org/%s/@v/list", escapeGoModulePathForProxy(trimmed)) - client := &http.Client{ - Timeout: driverModuleLatestProbeTimeout, - Transport: &http.Transport{ - Proxy: nil, - }, - } + client := &http.Client{Timeout: driverModuleLatestProbeTimeout} req, err := http.NewRequest(http.MethodGet, endpoint, nil) if err != nil { return nil, err @@ -2025,6 +2190,141 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele }, nil } +func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) { + driverType := normalizeDriverType(definition.Type) + displayName := resolveDriverDisplayName(definition) + pathText := strings.TrimSpace(filePath) + if pathText == "" { + return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为空") + } + if absPath, absErr := filepath.Abs(pathText); absErr == nil { + pathText = absPath + } + info, statErr := os.Stat(pathText) + if statErr != nil { + return installedDriverPackage{}, fmt.Errorf("读取本地驱动包失败:%w", statErr) + } + if info.IsDir() { + return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为目录:%s", pathText) + } + + executablePath, err := db.ResolveOptionalDriverAgentExecutablePath(resolvedDir, driverType) + if err != nil { + return installedDriverPackage{}, err + } + if mkErr := os.MkdirAll(filepath.Dir(executablePath), 0o755); mkErr != nil { + return installedDriverPackage{}, fmt.Errorf("创建 %s 驱动目录失败:%w", displayName, mkErr) + } + + downloadSource := fmt.Sprintf("local://manual/%s", filepath.Base(pathText)) + if strings.EqualFold(filepath.Ext(pathText), ".zip") { + entryName, extractErr := installOptionalDriverAgentFromLocalZip(pathText, definition, executablePath) + if extractErr != nil { + return installedDriverPackage{}, extractErr + } + if strings.TrimSpace(entryName) != "" { + downloadSource = downloadSource + "#" + entryName + } + } else { + if copyErr := copyAgentBinary(pathText, executablePath); copyErr != nil { + return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr) + } + } + + hash, hashErr := hashFileSHA256(executablePath) + if hashErr != nil { + return installedDriverPackage{}, fmt.Errorf("计算 %s 驱动代理摘要失败:%w", displayName, hashErr) + } + return installedDriverPackage{ + DriverType: driverType, + Version: strings.TrimSpace(selectedVersion), + FilePath: pathText, + FileName: filepath.Base(pathText), + ExecutablePath: executablePath, + DownloadURL: downloadSource, + SHA256: hash, + DownloadedAt: time.Now().Format(time.RFC3339), + }, nil +} + +func installOptionalDriverAgentFromLocalZip(zipPath string, definition driverDefinition, executablePath string) (string, error) { + driverType := normalizeDriverType(definition.Type) + displayName := resolveDriverDisplayName(definition) + reader, err := zip.OpenReader(zipPath) + if err != nil { + return "", fmt.Errorf("打开本地驱动包失败:%w", err) + } + defer reader.Close() + + entryPath := optionalDriverBundleEntryPath(driverType) + expectedBaseName := optionalDriverReleaseAssetName(driverType) + findEntry := func() *zip.File { + for _, file := range reader.File { + name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./")) + if name == entryPath { + return file + } + } + for _, file := range reader.File { + name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./")) + if strings.EqualFold(name, entryPath) { + return file + } + } + for _, file := range reader.File { + name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./")) + if strings.EqualFold(filepath.Base(name), expectedBaseName) { + return file + } + } + return nil + } + + entry := findEntry() + if entry == nil { + return "", fmt.Errorf("本地驱动包内未找到 %s 代理文件(期望路径 %s)", displayName, entryPath) + } + + src, err := entry.Open() + if err != nil { + return "", fmt.Errorf("读取本地驱动包条目失败:%w", err) + } + defer src.Close() + + tempPath := executablePath + ".tmp" + _ = os.Remove(tempPath) + dst, err := os.Create(tempPath) + if err != nil { + return "", fmt.Errorf("创建驱动代理临时文件失败:%w", err) + } + if _, err := io.Copy(dst, src); err != nil { + dst.Close() + _ = os.Remove(tempPath) + return "", fmt.Errorf("写入驱动代理失败:%w", err) + } + if err := dst.Sync(); err != nil { + dst.Close() + _ = os.Remove(tempPath) + return "", fmt.Errorf("落盘驱动代理失败:%w", err) + } + if err := dst.Close(); err != nil { + _ = os.Remove(tempPath) + return "", fmt.Errorf("关闭驱动代理文件失败:%w", err) + } + if chmodErr := os.Chmod(tempPath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { + _ = os.Remove(tempPath) + return "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr) + } + if err := os.Rename(tempPath, executablePath); err != nil { + _ = os.Remove(tempPath) + return "", fmt.Errorf("替换驱动代理失败:%w", err) + } + if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { + return "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr) + } + return filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(entry.Name), "./")), nil +} + func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, executablePath string, downloadURL string) (string, string, error) { driverType := normalizeDriverType(definition.Type) displayName := resolveDriverDisplayName(definition) From 9ad852c10bb5a745f9a04827fef23b99280cc538 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 13:26:28 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=90=9B=20fix(redis-viewer):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=A7=E6=95=B0=E6=8D=AE=E9=87=8F=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=20Key=20=E5=8A=A0=E8=BD=BD=E4=B8=8D=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 ScanKeys 改为按目标数量多轮聚合扫描,不再只依赖单轮返回结果 - 新增扫描目标数/步长/轮次上限,避免扫描过少或无限循环 - 前端首屏加载、搜索、刷新统一按较大批次请求,避免回退到几百条 - 加载更多改为按固定批次继续拉取并保持去重合并 - refs #129 --- frontend/src/components/RedisViewer.tsx | 34 ++++--- internal/redis/redis_impl.go | 124 ++++++++++++++++++------ 2 files changed, 117 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index cb102ed..d998950 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -14,6 +14,8 @@ const REDIS_TREE_KEY_TYPE_WIDTH = 92; const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84; const REDIS_TREE_KEY_TTL_WIDTH = 92; const REDIS_TREE_HIDE_TTL_THRESHOLD = 460; +const REDIS_KEY_INITIAL_LOAD_COUNT = 2000; +const REDIS_KEY_LOAD_MORE_COUNT = 2000; interface RedisViewerProps { connectionId: string; @@ -462,27 +464,34 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; }, [connection, redisDB]); - const loadKeys = useCallback(async (pattern: string = '*', fromCursor: number = 0, append: boolean = false) => { + const loadKeys = useCallback(async ( + pattern: string = '*', + fromCursor: number = 0, + append: boolean = false, + targetCount: number = REDIS_KEY_INITIAL_LOAD_COUNT + ) => { const config = getConfig(); if (!config) return; setLoading(true); try { - const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, 100); + const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, targetCount); if (res.success) { const result = res.data; + const scannedKeys = Array.isArray(result?.keys) ? result.keys : []; + const nextCursor = Number(result?.cursor || 0); if (append) { setKeys(prev => { const keyMap = new Map(); prev.forEach(item => keyMap.set(item.key, item)); - result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item)); + scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item)); return Array.from(keyMap.values()); }); } else { - setKeys(result.keys); + setKeys(scannedKeys); } - setCursor(result.cursor); - setHasMore(result.cursor !== 0); + setCursor(nextCursor); + setHasMore(nextCursor !== 0); } else { message.error('加载 Key 失败: ' + res.message); } @@ -494,23 +503,26 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }, [getConfig]); useEffect(() => { - loadKeys(searchPattern, 0, false); + loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT); }, [redisDB]); const handleSearch = (value: string) => { const pattern = value.trim() || '*'; setSearchPattern(pattern); setCursor(0); - loadKeys(pattern, 0, false); + loadKeys(pattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT); }; const handleLoadMore = () => { - loadKeys(searchPattern, cursor, true); + if (!hasMore || loading) { + return; + } + loadKeys(searchPattern, cursor, true, REDIS_KEY_LOAD_MORE_COUNT); }; const handleRefresh = () => { setCursor(0); - loadKeys(searchPattern, 0, false); + loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT); }; const loadKeyValue = async (key: string) => { @@ -1777,7 +1789,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { {hasMore && (
- +
)} diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index bd32aea..03ee844 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -22,6 +22,14 @@ type RedisClientImpl struct { forwarder *ssh.LocalForwarder } +const ( + redisScanDefaultTargetCount int64 = 2000 + redisScanMaxTargetCount int64 = 10000 + redisScanMinStepCount int64 = 200 + redisScanMaxStepCount int64 = 2000 + redisScanMaxRounds = 64 +) + // NewRedisClient creates a new Redis client instance func NewRedisClient() RedisClient { return &RedisClientImpl{} @@ -108,21 +116,70 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( if pattern == "" { pattern = "*" } + targetCount := normalizeRedisScanTargetCount(count) + scanStepCount := normalizeRedisScanStepCount(targetCount) + currentCursor := cursor + round := 0 + + keys := make([]string, 0, int(targetCount)) + seen := make(map[string]struct{}, int(targetCount)) + + for len(keys) < int(targetCount) { + batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result() + if err != nil { + return nil, err + } + + for _, key := range batch { + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + keys = append(keys, key) + if len(keys) >= int(targetCount) { + break + } + } + + currentCursor = nextCursor + round++ + if currentCursor == 0 || round >= redisScanMaxRounds { + break + } + } + + return &RedisScanResult{ + Keys: r.loadRedisKeyInfos(ctx, keys), + Cursor: currentCursor, + }, nil +} + +func normalizeRedisScanTargetCount(count int64) int64 { if count <= 0 { - count = 100 + return redisScanDefaultTargetCount + } + if count > redisScanMaxTargetCount { + return redisScanMaxTargetCount + } + return count +} + +func normalizeRedisScanStepCount(targetCount int64) int64 { + if targetCount < redisScanMinStepCount { + return redisScanMinStepCount + } + if targetCount > redisScanMaxStepCount { + return redisScanMaxStepCount + } + return targetCount +} + +func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) []RedisKeyInfo { + result := make([]RedisKeyInfo, 0, len(keys)) + if len(keys) == 0 { + return result } - keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, count).Result() - if err != nil { - return nil, err - } - - result := &RedisScanResult{ - Keys: make([]RedisKeyInfo, 0, len(keys)), - Cursor: nextCursor, - } - - // Get type and TTL for each key pipe := r.client.Pipeline() typeResults := make([]*redis.StatusCmd, len(keys)) ttlResults := make([]*redis.DurationCmd, len(keys)) @@ -132,37 +189,44 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( ttlResults[i] = pipe.TTL(ctx, key) } - _, err = pipe.Exec(ctx) + _, err := pipe.Exec(ctx) if err != nil && err != redis.Nil { - // Fallback: get info one by one for _, key := range keys { - keyType, _ := r.GetKeyType(key) - ttl, _ := r.GetTTL(key) - result.Keys = append(result.Keys, RedisKeyInfo{ + keyType, typeErr := r.client.Type(ctx, key).Result() + if typeErr != nil && typeErr != redis.Nil { + keyType = "" + } + ttlValue, ttlErr := r.client.TTL(ctx, key).Result() + if ttlErr != nil && ttlErr != redis.Nil { + ttlValue = -2 + } + result = append(result, RedisKeyInfo{ Key: key, Type: keyType, - TTL: ttl, + TTL: toRedisTTLSeconds(ttlValue), }) } - return result, nil + return result } for i, key := range keys { - keyType := typeResults[i].Val() - ttl := int64(ttlResults[i].Val().Seconds()) - if ttlResults[i].Val() == -1 { - ttl = -1 - } else if ttlResults[i].Val() == -2 { - ttl = -2 - } - result.Keys = append(result.Keys, RedisKeyInfo{ + result = append(result, RedisKeyInfo{ Key: key, - Type: keyType, - TTL: ttl, + Type: typeResults[i].Val(), + TTL: toRedisTTLSeconds(ttlResults[i].Val()), }) } + return result +} - return result, nil +func toRedisTTLSeconds(ttl time.Duration) int64 { + if ttl == -1 { + return -1 + } + if ttl == -2 { + return -2 + } + return int64(ttl.Seconds()) } // GetKeyType returns the type of a key From 9ebdf7f05395eef33288579071089bb7d507ddd5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 13:55:37 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E2=9C=A8=20feat(appearance):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=90=AF=E5=8A=A8=E6=97=B6=E5=85=A8=E5=B1=8F=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E5=B9=B6=E6=94=AF=E6=8C=81=E5=90=AF=E5=8A=A8=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E7=8A=B6=E6=80=81=E8=87=AA=E5=8A=A8=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在外观设置中提供用户可控的启动全屏偏好项 - 持久化保存用户偏好,重启后自动恢复 - 启动阶段按偏好自动执行全屏,失败时回退最大化 - 保持现有标题栏窗口操作行为不变 - refs #129 --- frontend/src/App.tsx | 94 ++++++++++++++++++++++++++++++++++++++++++- frontend/src/store.ts | 12 ++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2125400..0d8db66 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 } from 'antd'; +import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons'; -import { Environment, EventsOn } from '../wailsjs/runtime/runtime'; +import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; @@ -26,6 +26,8 @@ function App() { const setTheme = useStore(state => state.setTheme); const appearance = useStore(state => state.appearance); const setAppearance = useStore(state => state.setAppearance); + const startupFullscreen = useStore(state => state.startupFullscreen); + const setStartupFullscreen = useStore(state => state.setStartupFullscreen); const darkMode = themeMode === 'dark'; const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity); const effectiveBlur = normalizeBlurForPlatform(appearance.blur); @@ -56,6 +58,84 @@ function App() { }; }, []); + useEffect(() => { + let cancelled = false; + let startupWindowTimer: number | null = null; + const maxApplyAttempts = 6; + const applyRetryDelayMs = 400; + const settleDelayMs = 160; + + const checkStartupPreferenceApplied = async (): Promise => { + try { + if (await WindowIsFullscreen()) { + return true; + } + } catch (_) { + // ignore + } + try { + if (await WindowIsMaximised()) { + return true; + } + } catch (_) { + // ignore + } + return false; + }; + + const applyStartupWindowPreference = (attempt: number) => { + if (startupWindowTimer !== null) { + window.clearTimeout(startupWindowTimer); + } + startupWindowTimer = window.setTimeout(() => { + if (cancelled) { + return; + } + if (!useStore.getState().startupFullscreen) { + return; + } + Promise.resolve() + .then(async () => { + if (await checkStartupPreferenceApplied()) { + return; + } + // 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。 + WindowFullscreen(); + await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + if (await checkStartupPreferenceApplied()) { + return; + } + WindowMaximise(); + await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs)); + if (await checkStartupPreferenceApplied()) { + return; + } + if (attempt < maxApplyAttempts) { + applyStartupWindowPreference(attempt + 1); + } + }); + }, 300); + }; + + if (useStore.persist.hasHydrated()) { + applyStartupWindowPreference(1); + } + const unsubscribeHydration = useStore.persist.onFinishHydration(() => { + if (cancelled) { + return; + } + applyStartupWindowPreference(1); + }); + + return () => { + cancelled = true; + if (startupWindowTimer !== null) { + window.clearTimeout(startupWindowTimer); + } + unsubscribeHydration(); + }; + }, []); + // Background Helper const getBg = (darkHex: string, lightHex: string) => { if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white @@ -915,6 +995,16 @@ function App() { )} +
+
启动窗口
+
+ 启动时全屏 + setStartupFullscreen(checked)} /> +
+
+ * 修改后下次启动生效 +
+
diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 5dd95a1..b1dddbd 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'; import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types'; const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 }; +const DEFAULT_STARTUP_FULLSCREEN = false; const LEGACY_DEFAULT_OPACITY = 0.95; const OPACITY_EPSILON = 1e-6; const MAX_URI_LENGTH = 4096; @@ -295,6 +296,7 @@ interface AppState { savedQueries: SavedQuery[]; theme: 'light' | 'dark'; appearance: { opacity: number; blur: number }; + startupFullscreen: boolean; sqlFormatOptions: { keywordCase: 'upper' | 'lower' }; queryOptions: QueryOptions; sqlLogs: SqlLog[]; @@ -321,6 +323,7 @@ interface AppState { setTheme: (theme: 'light' | 'dark') => void; setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void; + setStartupFullscreen: (enabled: boolean) => void; setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void; setQueryOptions: (options: Partial) => void; @@ -409,6 +412,10 @@ const sanitizeAppearance = ( return nextAppearance; }; +const sanitizeStartupFullscreen = (value: unknown): boolean => { + return value === true; +}; + const unwrapPersistedAppState = (persistedState: unknown): Record => { if (!persistedState || typeof persistedState !== 'object') { return {}; @@ -430,6 +437,7 @@ export const useStore = create()( savedQueries: [], theme: 'light', appearance: { ...DEFAULT_APPEARANCE }, + startupFullscreen: DEFAULT_STARTUP_FULLSCREEN, sqlFormatOptions: { keywordCase: 'upper' }, queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true }, sqlLogs: [], @@ -541,6 +549,7 @@ export const useStore = create()( setTheme: (theme) => set({ theme }), setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })), + setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }), setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }), setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })), @@ -578,6 +587,7 @@ export const useStore = create()( nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); nextState.theme = sanitizeTheme(state.theme); nextState.appearance = sanitizeAppearance(state.appearance, version); + nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen); nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions); nextState.queryOptions = sanitizeQueryOptions(state.queryOptions); nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount); @@ -593,6 +603,7 @@ export const useStore = create()( savedQueries: sanitizeSavedQueries(state.savedQueries), theme: sanitizeTheme(state.theme), appearance: sanitizeAppearance(state.appearance, 3), + startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen), sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), queryOptions: sanitizeQueryOptions(state.queryOptions), tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), @@ -604,6 +615,7 @@ export const useStore = create()( savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, + startupFullscreen: state.startupFullscreen, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions, tableAccessCount: state.tableAccessCount, From 96851022293204a869f1387e1b706a7a521d0379 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 14:21:14 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=A8=20feat(sidebar-batch-table):=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C=E8=A1=A8=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E7=AD=9B=E9=80=89=E4=B8=8E=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 批量操作表弹窗新增关键字筛选(忽略大小写包含匹配) - 新增类型筛选(全部对象/仅表/仅视图) - 新增勾选作用范围切换(当前筛选结果/全部对象) - 全选、取消全选、反选逻辑按作用范围执行 - 筛选区域展示命中计数与无匹配空态提示 - refs #130 --- frontend/src/components/Sidebar.tsx | 139 ++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3dc871b..9b5cf28 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -47,6 +47,8 @@ interface TreeNode { type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; type BatchObjectType = 'table' | 'view'; +type BatchObjectFilterType = 'all' | BatchObjectType; +type BatchSelectionScope = 'filtered' | 'all'; interface BatchObjectItem { title: string; @@ -133,11 +135,47 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const [selectedConnection, setSelectedConnection] = useState(''); const [selectedDatabase, setSelectedDatabase] = useState(''); const [availableDatabases, setAvailableDatabases] = useState([]); + const [batchFilterKeyword, setBatchFilterKeyword] = useState(''); + const [batchFilterType, setBatchFilterType] = useState('all'); + const [batchSelectionScope, setBatchSelectionScope] = useState('filtered'); + const filteredBatchObjects = useMemo(() => { + const keyword = batchFilterKeyword.trim().toLowerCase(); + return batchTables.filter((item) => { + if (batchFilterType !== 'all' && item.objectType !== batchFilterType) { + return false; + } + if (!keyword) { + return true; + } + return item.title.toLowerCase().includes(keyword) || item.objectName.toLowerCase().includes(keyword); + }); + }, [batchFilterKeyword, batchFilterType, batchTables]); const groupedBatchObjects = useMemo(() => { - const tables = batchTables.filter(item => item.objectType === 'table'); - const views = batchTables.filter(item => item.objectType === 'view'); + const tables = filteredBatchObjects.filter(item => item.objectType === 'table'); + const views = filteredBatchObjects.filter(item => item.objectType === 'view'); return { tables, views }; - }, [batchTables]); + }, [filteredBatchObjects]); + const allBatchObjectKeys = useMemo(() => batchTables.map(item => item.key), [batchTables]); + const allBatchObjectKeysByType = useMemo(() => { + if (batchFilterType === 'all') { + return allBatchObjectKeys; + } + return batchTables + .filter((item) => item.objectType === batchFilterType) + .map((item) => item.key); + }, [allBatchObjectKeys, batchFilterType, batchTables]); + const filteredBatchObjectKeys = useMemo(() => filteredBatchObjects.map(item => item.key), [filteredBatchObjects]); + const selectionScopeTargetKeys = useMemo( + () => (batchSelectionScope === 'filtered' ? filteredBatchObjectKeys : allBatchObjectKeysByType), + [allBatchObjectKeysByType, batchSelectionScope, filteredBatchObjectKeys] + ); + useEffect(() => { + if (batchFilterType === 'all') { + return; + } + const allowed = new Set(allBatchObjectKeysByType); + setCheckedTableKeys((prev) => prev.filter((key) => allowed.has(key))); + }, [allBatchObjectKeysByType, batchFilterType]); // Batch Database Operations Modal const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false); @@ -1313,6 +1351,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> setBatchTables([]); setCheckedTableKeys([]); setAvailableDatabases([]); + setBatchFilterKeyword(''); + setBatchFilterType('all'); + setBatchSelectionScope('filtered'); if (connId) { const conn = connections.find(c => c.id === connId); @@ -1413,6 +1454,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> setSelectedDatabase(''); setBatchTables([]); setCheckedTableKeys([]); + setBatchFilterKeyword(''); + setBatchFilterType('all'); + setBatchSelectionScope('filtered'); const conn = connections.find(c => c.id === connId); if (conn) { @@ -1422,6 +1466,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const handleDatabaseChange = async (dbName: string) => { setSelectedDatabase(dbName); + setBatchFilterKeyword(''); + setBatchFilterType('all'); + setBatchSelectionScope('filtered'); const conn = connections.find(c => c.id === selectedConnection); if (conn && dbName) { @@ -1470,17 +1517,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; const handleCheckAll = (checked: boolean) => { - if (checked) { - setCheckedTableKeys(batchTables.map(t => t.key)); - } else { - setCheckedTableKeys([]); + if (batchSelectionScope === 'all') { + setCheckedTableKeys(checked ? allBatchObjectKeys : []); + return; } + if (filteredBatchObjectKeys.length === 0) { + return; + } + if (checked) { + setCheckedTableKeys(prev => { + const nextSet = new Set(prev); + filteredBatchObjectKeys.forEach((key) => nextSet.add(key)); + return allBatchObjectKeys.filter((key) => nextSet.has(key)); + }); + return; + } + const filteredKeySet = new Set(filteredBatchObjectKeys); + setCheckedTableKeys(prev => prev.filter((key) => !filteredKeySet.has(key))); }; const handleInvertSelection = () => { - const allKeys = batchTables.map(t => t.key); - const newChecked = allKeys.filter(k => !checkedTableKeys.includes(k)); - setCheckedTableKeys(newChecked); + if (batchSelectionScope === 'all') { + setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key))); + return; + } + if (filteredBatchObjectKeys.length === 0) { + return; + } + setCheckedTableKeys(prev => { + const nextSet = new Set(prev); + filteredBatchObjectKeys.forEach((key) => { + if (nextSet.has(key)) { + nextSet.delete(key); + } else { + nextSet.add(key); + } + }); + return allBatchObjectKeys.filter((key) => nextSet.has(key)); + }); }; const openBatchDatabaseModal = async () => { @@ -2874,6 +2948,43 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> + {batchTables.length > 0 && ( +
+ + setBatchFilterKeyword(e.target.value)} + placeholder="筛选表/视图名称" + prefix={} + style={{ width: 260 }} + /> + setBatchSelectionScope(value as BatchSelectionScope)} + style={{ width: 220 }} + options={[ + { label: '勾选作用于:当前筛选结果', value: 'filtered' }, + { label: '勾选作用于:全部对象', value: 'all' }, + ]} + /> + +
+ 当前筛选命中 {filteredBatchObjects.length} / {batchTables.length} 个对象 +
+
+ )} + {batchTables.length > 0 && ( <>
@@ -2881,18 +2992,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> @@ -2938,6 +3052,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
)} + {groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && ( +
+ 无匹配对象 +
+ )}